在高并发系统中,如果大量请求同时尝试修改同一行数据(比如秒杀库存、热点账户余额、全局计数器),这一行就会成为热点行。热点行本身并不少见,但如果不做处理,行锁会串行化所有更新操作,导致数据库连接堆积、响应延迟急剧上升,甚至拖垮整个服务。
这一节会从成因分析入手,再给出在 MySQL 层和业务层都可以落地的优化方案。这些方案互不冲突,可以根据场景组合使用。
16.4.1 热点行问题的表现与根因
当多个事务并发更新同一行时,InnoDB 会为这一行加上排他锁(X 锁),其他试图修改该行的事务必须等待前一个事务提交并释放锁。即使你的事务只执行一条 UPDATE ... WHERE id = ? 且耗时极短,在极高并发下,锁等待本身就会形成严重的排队效应。
其根本瓶颈在于:
- 行锁串行化:同一时刻只能有一个事务持有该行的写锁。
- 上下文切换开销:大量线程处于等待锁状态,操作系统频繁进行线程调度,CPU 虽然看起来不忙,但有效吞吐量很低。
- 连接与内存资源被耗尽:等待锁的事务会占用数据库连接和临时内存,新的请求无法建立连接,表现为“数据库连不上”或大量超时。
监控这类问题的方法:执行 SHOW ENGINE INNODB STATUS,在输出中查看 LATEST DETECTED DEADLOCK 和 TRANSACTIONS 段,如果发现大量事务在等待同一个行锁(WAITING FOR THIS LOCK TO BE GRANTED),基本就能确认热点行存在。也可以查询 performance_schema.data_locks 和 data_lock_waits 表获取更准确的锁信息。
16.4.2 方案一:逻辑拆分,变一行竞争为多行竞争
如果业务逻辑允许,最直接的办法是把热点行拆分成多行,让并发请求分散到不同行上,降低单行锁竞争。
以库存扣减为例,初始库存 1000 件,不要使用一行:
-- 单行记录
CREATE TABLE stock (product_id INT PRIMARY KEY, total INT);
UPDATE stock SET total = total - 1 WHERE product_id = 1 AND total > 0;
可以改为多个“库存槽”:
CREATE TABLE stock_slots (
slot_id INT PRIMARY KEY,
product_id INT,
quantity INT,
KEY idx_product (product_id)
);
初始化时插入若干条记录(比如总共 1000 件,分 10 个槽,每个槽 100 件)。扣减时随机选择一个还有库存的槽进行更新:
UPDATE stock_slots
SET quantity = quantity - 1
WHERE product_id = 1 AND quantity > 0 AND slot_id = ?
ORDER BY RAND() -- 实际应用中应避免用 ORDER BY RAND(),可在应用层随机选槽
LIMIT 1;
这样同时到达的 100 个扣减请求会被分散到不同的槽,锁竞争强度下降到 1/N。应用层需要增加汇总逻辑:当所有槽都扣减完才真正售罄。
此方案成本低,改动小,但热点分散程度有限,极端并发仍可能集中在某个槽上。
16.4.3 方案二:应用层排队与合并写入
在应用服务器或中间层对同一行的更新操作进行排队,合并写入,从而减少对数据库的直接压力。
典型做法是使用内存队列(比如基于 Guava 的 RateLimiter 或 Disruptor),将针对同一热点行的更新请求先暂存在队列中,定期或积攒到一定数量后再批量写入数据库。
示例思路(伪代码):
ConcurrentHashMap<Long, Queue<UpdateTask>> taskQueues = ...;
// 将扣减任务放入对应行的队列
void submitUpdate(Long rowId, int delta) {
Queue<UpdateTask> queue = taskQueues.computeIfAbsent(rowId, k -> new ConcurrentLinkedQueue<>());
queue.offer(new UpdateTask(delta));
scheduleIfNeeded(rowId); // 触发异步处理
}
// 批量处理队列中的任务
void processQueue(Long rowId) {
Queue<UpdateTask> queue = taskQueues.get(rowId);
int totalDelta = 0;
UpdateTask task;
while ((task = queue.poll()) != null) {
totalDelta += task.delta;
}
if (totalDelta != 0) {
jdbcTemplate.update("UPDATE account SET balance = balance + ? WHERE id = ?", totalDelta, rowId);
}
}
优点是将数据库的并发更新转化为单连接批量处理,大幅降低锁竞争。缺点是增加了应用复杂度,且必须保证队列的处理不丢失(应用重启、节点故障时需要恢复机制),另外实时性会略有降低。
16.4.4 方案三:缓存与校验,最终落地
对于高并发场景下并非每一步都需要事务强一致的更新(例如点击计数、点赞数),可以在缓存(Redis)中完成计数,定期刷回 MySQL。
以文章阅读数递增为例:
- 每次阅读请求直接在 Redis 中对
article:123:views执行INCR。 - 后台定时任务(如每分钟)读取 Redis 的当前值,执行
UPDATE articles SET views = ? WHERE id = 123,然后重置 Redis 计数器。 - 若 Redis 宕机从数据库重建,损失一点点时效性。
这种方法把热点行竞争从 MySQL 转移到了 Redis,而 Redis 的内存操作和单线程模型能轻松支撑极高的并发修改。缺点是需要处理缓存与数据库的数据一致性(即便有少量计数丢失,对大部分业务可接受),并保证定时任务的幂等。
如果更新需要严格的原子比较,可以在 Redis 中使用 Lua 脚本实现条件更新(如库存扣减),扣减成功后再异步落库,或者先落库但用 Redis 抗大部分流量,极端情况由数据库兜底。
16.4.5 方案四:乐观锁配合重试
当热点行的锁竞争激烈时,可以考虑使用乐观锁,即先查询版本号,更新时带上版本号条件,更新失败则重试。乐观锁在冲突率较高的情况下会因为大量重试造成 CPU 浪费和延迟抖动,对某些场景仍然可以使用,但通常不是热点行更新的首选。
如果要使用乐观锁,需要配合随机退避重试,避免全部请求同时重试:
-- 假设表中有一个 version 字段
SELECT quantity, version FROM product WHERE id = 1;
-- 扣减逻辑判断 quantity 是否足够
UPDATE product SET quantity = quantity - 1, version = version + 1
WHERE id = 1 AND version = old_version AND quantity > 0;
如果受影响行数为 0,则重试。热点行下这种方案 SQL 请求数会激增,适合并发量不太极端的场景,或者仅在少数情况下发生冲突的场合。
16.4.6 方案五:利用 MySQL 8.0 的“NOWAIT”和“SKIP LOCKED”
在 MySQL 8.0 中,SELECT ... FOR UPDATE 增加了 NOWAIT 和 SKIP LOCKED 选项,可以绕过锁等待。
NOWAIT:遇到锁立即报错返回,不等待。SKIP LOCKED:跳过已经被锁住的行,只返回未锁定的行。
以库存扣减为例,使用 SKIP LOCKED 可以避免卡在已经锁定的库存槽上:
START TRANSACTION;
SELECT slot_id FROM stock_slots
WHERE product_id = 1 AND quantity > 0
ORDER BY slot_id
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 如果返回了 slot_id,执行对应扣减,否则说明全部售罄
这样可以配合前面库存槽拆分方案,进一步提升并发吞吐。唯一的代价是需要循环尝试,直到获得一个可用的行或判定失败。
16.4.7 方案六:在代码层面缩短锁持有时间
不管用哪种方案,都要尽可能缩短事务持有锁的时间。一个常见的错误是把业务逻辑或 RPC 调用放在数据库事务之内。
正确做法是:
- 所有耗时的非数据库操作(如调用第三方接口、发送消息、生成 HTML)都放在事务外部。
- 事务内只包含必要的数据库读写,打开事务 -> 执行更新 -> 提交事务,整个过程尽量短。
很多时候热点行竞争的加剧,并不是因为更新本身慢,而是因为某些代码在事务内执行了网络 I/O,导致锁长时间不释放,其他请求堆积。这一点在代码评审时应特别提醒。
16.4.8 优化策略选择与组合
没有一种方案能适用所有场景,以下是常见场景的推荐组合:
| 场景 | 推荐方案 |
|------|----------|
| 库存扣减、票务秒杀 | 库存槽拆分 + SKIP LOCKED + 应用层队列 |
| 账户余额频繁变动 | 应用层队列合并写,或 T 级缓冲记账后异步汇总 |
| 计数器、点赞数 | Redis 缓存计数 + 定期回写 |
| 热点配置表少量更新 | 乐观锁 + 小退避重试 |
| 常规热点行偶尔发生 | 缩短事务、开设只读副本分流读负载 |
无论采用哪种策略,都要结合监控。监控指标包括:InnoDB_row_lock_waits、InnoDB_row_lock_time、锁等待超时事务数。如果这些指标在优化后显著下降,说明措施有效。
热点行更新优化本质上就是在绝对一致性和并发性能之间寻找一个平衡点。掌握这些方法后,你就能在业务快速增长时快速判断并实施合适的方案,避免数据库成为瓶颈。