在高并发写入系统中,某些特定记录会被大量事务同时修改,例如秒杀商品的库存行、账户余额主记录等。这种被频繁更新的单行数据被称为热点行。当多个事务都对同一行执行UPDATE并加排他行锁时,后面来的事务必须等待前一个事务提交或回滚才能获取锁,从而形成行锁等待队列。一旦请求量超过数据库处理能力,等待线程堆积,CPU可能并不高但RT飙升,连接池被打满。

一、热点行锁等待的底层原理
以MySQL InnoDB为例,执行UPDATE table SET cnt = cnt - 1 WHERE id = 1时,引擎会先对id=1这一行加排他锁(X锁),属于当前读。事务未提交前,其他试图修改该行的语句会在锁等待队列中阻塞。参数innodb_lock_wait_timeout控制最长等待时间,默认五十秒,超时则报错。
从锁实现看,InnoDB每行记录有隐藏的事务ID和回滚指针,锁信息存放在内存的锁结构中。热点行意味着几乎所有写事务都指向同一个锁对象,并发度被压缩为1。即使机器有几十核,这一行也只能串行处理。因此解决思路要么是降低单行冲突,要么是减少持锁时长,要么是把冲突转移到更高效的组件。
二、拆分行:把单行热点打散为多行
一种常见方案是将一个热点行拆分为多行,比如库存表增加slot字段,预先插入十行代表不同槽位,总库存等于各槽位之和。更新时随机或取模选一个槽位扣减,将锁冲突概率降低为原来的十分之一。
示例表结构与更新逻辑如下:
CREATE TABLE stock_slot ( id INT PRIMARY KEY, goods_id INT, slot_no INT, cnt INT, KEY (goods_id, slot_no) ); -- 随机选一个槽位扣减,降低单行锁概率 UPDATE stock_slot SET cnt = cnt - 1 WHERE goods_id = 1001 AND slot_no = (FLOOR(RAND() * 10) + 1) AND cnt > 0;
该方法的优点是对数据库侵入小,无需额外中间件。缺点是读取总库存需SUM聚合,且要保证扣减不超卖需在应用层或触发器里处理跨槽位逻辑。若某个槽位耗尽但其他槽位有货,需重试其他槽位,代码稍复杂。
三、乐观锁:用版本号避免长持锁
乐观锁不依赖数据库行锁阻塞,而是在UPDATE时带上版本条件,利用原子更新判断是否成功。如果版本不匹配说明已被别人改过,应用捕获影响行数为0后重试。
-- 假设有 version 字段 UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 5;
在Java代码中判断:
int rows = jdbcTemplate.update(
"UPDATE account SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?",
100, 1, oldVersion);
if (rows == 0) {
// 版本冲突,重试或抛异常
}
</p>
<p>乐观锁减少了锁等待,但在极度热点下大量重试也会放大CPU和SQL调用量。它适合冲突频率中等、重试代价小的场景,不适合秒杀那种瞬时超高冲突。</p>
<h2>四、缩短事务与减少持锁时间</h2>
<p>很多锁堆积是因为事务里混入了远程调用或慢逻辑。应将热点行更新放在事务最后一步,提交立刻释放锁。避免<code>SELECT ... FOR UPDATE</code>后做无关计算。</p>
<pre class=brush:sql;toolbar:false>
-- 错误示范:先锁行,再慢处理
START TRANSACTION;
SELECT cnt FROM goods WHERE id = 1 FOR UPDATE;
-- 此处调用第三方接口耗时2秒
UPDATE goods SET cnt = cnt - 1 WHERE id = 1;
COMMIT;
-- 正确示范:先算好,最后才加锁更新
START TRANSACTION;
UPDATE goods SET cnt = cnt - 1 WHERE id = 1 AND cnt > 0;
COMMIT;
把判断和扣减合并为单条UPDATE,不仅减少持锁时间,还借助数据库原子性防超卖。配合更小的innodb_lock_wait_timeout可让失败请求快速失败而非占着连接。
五、借助外部缓存做预扣减
当单机数据库无法扛住时,可把扣减移到Redis等内存组件,利用INCRBY或Lua脚本原子扣减,数据库异步落账。这样热点行的SQL更新被大幅削峰。
-- Redis Lua 原子扣库存
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock >= tonumber(ARGV[1]) then
return redis.call('DECRBY', KEYS[1], ARGV[1])
else
return -1
end
该方案性能极高,但引入一致性问题:Redis扣减后数据库可能因宕机未同步。通常做法是订单创建后发消息队列异步刷库,或定时校对。它适合允许短暂不一致、最终一致的营销场景。
六、数据库原生热点行优化
部分云数据库提供热点行缓存,例如将某行的更新在内存中合并再批量写入,避免每次都走完整事务锁流程。MySQL社区版没有此特性,但可通过自增列做计数器绕开:UPDATE counter SET val = val + 1在InnoDB里有优化,单行自增更新并不会如想象中严重排队,因为使用了轻量互斥而非长事务锁。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 拆分行 | 实现简单,无外部依赖 | 读总库存需聚合 |
| 乐观锁 | 无锁等待 | 高冲突下重试多 |
| 缩短事务 | 立竿见影 | 需改造代码 |
| Redis预扣 | 性能极强 | 一致性复杂 |
实际生产中往往组合使用:拆分行加短事务打底,极端流量再叠加Redis预扣。理解行锁等待堆积的根源,才能针对业务容忍度选出合适架构。