一次库存扣减死锁事故复盘:InnoDB加锁顺序、死锁检测与解法
各位,我是老张。今天从一次实战出发,往下拆一层。
周二晚上九点,下单系统的报警群炸了。ERROR 1213刷屏,Deadlock found when trying to get lock。库存服务10分钟攒了一千多条死锁日志,订单创建失败率冲到4%。运维重启实例,症状才消停。我盯着SHOW ENGINE INNODB STATUS的输出,脑子里就一句话:又是库存扣减,又是那两条SQL。
锁这东西,光背四个条件没用。真出事的时候,你得知道锁到底加在哪、按什么顺序加。今天就从这次事故把InnoDB的加锁机制拆开讲。
一、先说结论
死锁的本质是循环等待。InnoDB的锁加在索引记录上,不是加在逻辑的"行"上。RR隔离级别下,等值查询也会给索引区间加next-key lock。两条SQL只要以不同顺序触碰同一组索引记录,死锁就是迟早的事。
| 关键点 | 说明 |
|---|---|
| 锁的落点 | 索引记录,不是表行 |
| 加锁顺序 | InnoDB按索引顺序逐条加 |
| RR等值查询 | 唯一索引命中退化为记录锁,普通索引加next-key lock |
| 死锁条件 | 互斥、持有并等待、不可剥夺、循环等待 |
| 回滚对象 | 检测到死锁后,回滚undo最少的事务 |
这里有个反直觉的点。隔离级别越高,锁越多,死锁越频繁。你越追求"安全",死锁概率越大,后面压测数据能直接看到。
二、事故现场:两条SQL怎么互相卡死的
业务就两个接口。下单扣库存,回补加库存。两个接口都按sku_id定位,加锁顺序却不一样。
下单:update t_inventory set stock = stock - 1
where sku_id in ('sku_1','sku_2','sku_3');
回补:update t_inventory set stock = stock + 1
where sku_id = 'sku_2';
单个看都没毛病。下单一次锁三个sku,回补锁一个。问题出在并发。订单A扣sku_1和sku_2,订单B扣sku_2和sku_1。A先拿到sku_1的锁,B先拿到sku_2的锁。接着A要sku_2,被B占着。B要sku_1,被A占着。谁都不让,循环等待成立。
SHOW ENGINE INNODB STATUS里,LATEST DETECTED DEADLOCK段会把两个事务的锁信息全打出来。一个WAITING FOR这个lock,一个WAITING FOR那个lock。看这段日志,比看报错本身有用得多。
三、InnoDB的锁到底加在哪
这个不讲清楚,后面全看不懂。
InnoDB的行锁,本质是索引记录锁。表没有索引,退化成整表锁。有主键,锁加在主键索引上。有二级索引,先锁二级索引的记录,再回表锁主键的记录。
加锁的最小单位是索引记录,不是逻辑上的行。你感觉锁的是同一行,实际可能是主键索引和二级索引两条记录,锁了两个地方。
RR隔离级别下,InnoDB用next-key lock防幻读。next-key lock是record lock加gap lock。gap lock锁索引记录之间的间隙,加锁范围是一个左开右闭的区间。
举个例子。sku_id上有个普通索引,记录值1、5、10、20。select * from t_inventory where sku_id = 5 for update在RR下会加next-key lock。先锁住1到5之间的间隙,再锁住值5这一行。组合起来就是区间(1, 5]被锁住,其他事务不能插入sku_id=3的记录,也不能修改sku_id=5的行。
这就是RR下等值查询也锁区间的真相。你以为锁了一行,其实锁了一段。唯一索引等值命中时有优化,next-key lock退化成record lock,只锁命中的那条记录。所以主键等值更新,锁的就是那一行。
四、加锁顺序的完整推导
把事务1的加锁轨迹画出来看。
事务1: update t_inventory set stock = stock - 1
where sku_id in ('sku_1','sku_5');
步骤:
1. 走二级索引(sku_id)定位记录sku_id='sku_1'
2. RR下加next-key lock,锁区间(-∞, 'sku_1']
3. 回表,对主键索引记录加record lock
4. 定位下一条记录sku_id='sku_5'
5. 加next-key lock,锁区间('sku_1', 'sku_5']
6. 回表,主键记录加record lock
7. 修改两行stock
8. 事务提交,统一释放
注意一个细节。事务2执行sku_id in ('sku_5','sku_1'),InnoDB不会按SQL里的字面顺序加锁,而是按索引顺序。所以两条SQL即使字面顺序反过来,加锁顺序也一样,单条SQL内死不了。
真正会死的,是不同SQL走了不同路径,或者业务代码分两步操作。先锁A再锁B,另一个事务先锁B再锁A。这次事故就是第二种。
五、死锁检测机制
死锁靠谁发现?innodb_deadlock_detect,默认开。它维护一张wait-for graph,节点是事务,边表示"在等谁的锁"。每次加锁请求触发检测,沿着图找环。找到环,就回滚其中一个事务,放另一个走。
回滚哪个?InnoDB选undo log最少的事务。改得最少、回滚成本最低的那个。如果两个事务的undo量相同,会优先回滚后加锁的那个。无论选哪个,被回滚的事务都会收到ERROR 1213,业务不重试这条请求就黄了。
压测环境:16核64G,NVMe SSD,MySQL 8.0.32。模拟两个事务交叉更新同一组sku,500并发跑10分钟。
| 隔离级别 | 死锁次数 | 平均TPS |
|---|---|---|
| RR | 47 | 38,000 |
| RC | 3 | 41,000 |
RC下gap lock基本不生效,只锁命中的记录,死锁率掉了一个数量级。代价是可能幻读,业务得自己兜底。
死锁检测本身也有开销。每次加锁都扫一遍等待图。
| 配置 | 平均TPS | 检测CPU占用 |
|---|---|---|
| 默认开启 | 38,000 | 约8% |
| 关闭检测 | 41,000 | 约2% |
关闭检测后没有1213了,死锁不会被主动发现。锁等待会硬拖到innodb_lock_wait_timeout(默认50秒)才超时报错。省了检测的CPU,但请求要挂50秒,连接池可能被占满,这条路上线前得想清楚。
六、解法:架构决策框架
从事故里总结,方案按成本从低到高排。
第一,统一加锁顺序。所有改库存的地方,先把sku_id排序再按序加锁。全局顺序一致,循环等待就断了。这是根治,成本最低。这次事故最后就是这么改的。
第二,收窄锁的覆盖范围。能用唯一索引定位就别扫区间,能走主键就走主键,把next-key lock的范围压到最小。
第三,降隔离级别。业务允许就把RR降到RC,gap lock消失,死锁率断崖式下跌,代价是幻读自查。
第四,业务侧重试。死锁是常态不是异常。把1213当正常情况,捕获后重试两三次。配合固定顺序加锁,影响基本抹平。
| 场景 | 推荐做法 |
|---|---|
| 下单/库存核心链路 | 固定顺序加锁 + 重试 |
| 高并发读多写少 | 降RC,锁只加在命中行 |
| 批量更新 | 一次锁全量,别分批 |
| 报表/长事务 | 别开长事务,持锁时长决定冲突窗口 |
七、踩过的坑
最早一次死锁,我盯SHOW ENGINE INNODB STATUS只盯着两个事务的SQL,死活看不出哪锁哪。后来把LOCK WAIT段连同索引名一起看,才发现是二级索引和主键索引两条锁链交叉。纯粹加锁顺序问题。这个排查习惯留到现在。
还有一个坑。业务以为"先查再改"能避免死锁,先select出来排个序再按序update。结果select默认不加锁,两个事务同时查到同一批sku,再各自update,顺序还是乱的。真要控制顺序,要么直接排序update,要么select加for update。这个我搞错过,线上踩完才反应过来。
迁移的时候也容易踩。国产数据库的死锁检测开关、日志格式跟MySQL不完全一样,别拿MySQL的排查思路硬套。我之前切库后按老习惯找LATEST DETECTED DEADLOCK,日志结构对不上,排查浪费了半天。参数名和报错关键字都得先验证一遍再上线。
结语
死锁不神秘,就是加锁顺序不一致造成的循环等待。InnoDB把锁加在索引记录上,RR下还锁区间。理解加锁顺序,比背死锁四条件有用得多。
后来我从Java后端转做架构,就是因为这类问题。应用层的try-catch救不了数据库层的锁,你得走到内核那一层去看。
各位生产环境有没有被死锁坑过?用的什么解法?一起聊聊。
我是底层玩家老张。咱不聊概念,只聊源码和压测跑出来的东西。
浙公网安备 33010602011771号