还记得上个月那个周五,本该是我雷打不动的"代码Reivew时间",结果凌晨3点12分,电话炸醒了我。监控报警群里一片红海:核心交易链路接口超时,数据库CPU飙升,应用层大量抛出 Deadlock found when trying to get lock 异常。
这次事故给我的教训极其深刻——我曾经以为只要SQL写得对,索引加得准,死锁就离我很远。直到这起P2级故障狠狠打脸:在高并发场景下,死锁往往不是代码写错了,而是这时候的数据库"太聪明"了。
今天把这次事故的完整排查过程、踩坑细节和最终方案复盘出来,希望能帮大家避开这个隐蔽的深坑。
诡异的"间隙":当Insert遇上Delete
故障发生的背景是一个类似"抢券"的业务场景。为了防止用户重复领取,我们的逻辑很简单:先查一下有没有记录,没有则插入,有则更新状态。
这时候,你可能会觉得:“这逻辑没毛病啊,加了唯一索引,顶多报个主键冲突,怎么会死锁?”
我们也这么想的。但在排查 SHOW ENGINE INNODB STATUS 日志时,我发现事情并不简单。死锁日志里频繁出现 lock_mode X locks gap before rec insert intention waiting 字样。
这就是第一个坑:间隙锁(Gap Lock)与插入意向锁的冲突。
在这次故障中,有两个并发事务T1和T2几乎同时执行:
- T1 执行
DELETE FROM coupon_record WHERE user_id = 1001 AND coupon_id = 55; (注意:此时记录不存在) - T2 执行
INSERT INTO coupon_record ...(插入同一条数据)
我复盘时发现,因为记录不存在,MySQL的隔离级别(默认RR)为了防止幻读,Delete操作并没有只锁住"不存在的那一行",而是加了一个 Gap Lock(间隙锁)。
当T1持有Gap Lock时,T2想要插入数据,需要获取 Insert Intention Lock(插入意向锁)。关键点来了:间隙锁和插入意向锁是互斥的。
如果此时T2也因为之前的某个查询持有了一个重叠的Gap Lock(这种情况在高并发重试逻辑中极易出现),两个事务就会互相持有对方需要的Gap Lock,同时等待对方释放,死锁瞬间形成。
我们在排查时,盯着代码里的 Insert 看了半天,完全忽略了前置的 Delete/Update 产生的隐形锁。
批量更新的噩梦:顺序真的很重要
解决完上面的Gap Lock问题后,系统平稳了一周。但在一次大促压测中,死锁警报再次拉响。这次不是Insert了,而是纯粹的批量Update。
场景是这样的:我们需要批量扣减库存。 代码逻辑大致如下:
@Transactional
public void batchDecreaseStock(List<Long> skuIds, int quantity) {
for (Long skuId : skuIds) {
stockRepository.decrease(skuId, quantity);
}
}
乍一看,平平无奇。但在并发量达到 2000 QPS 时,数据库大量报错。
踩坑复盘:
前端传过来的 skuIds 列表顺序是随机的!
- 事务A 的更新顺序是:
[Item_A, Item_B] - 事务B 的更新顺序是:
[Item_B, Item_A]
当事务A锁住了Item_A,准备去锁Item_B时;事务B正好锁住了Item_B,准备去锁Item_A。典型的资源循环依赖。
这个坑最隐蔽的地方在于,它只在特定数据分布下爆发。平时测试量小,碰巧两个请求操作相同商品集合的概率低,根本测不出来。一旦上线遇到热点商品,瞬间爆炸。
修正方法极其简单,简单到让我事后想抽自己一巴掌:在进入事务前,强制对资源ID进行排序。
// 修复后的代码片段
public void batchDecreaseStock(List<Long> skuIds, int quantity) {
// 强制排序,打破循环等待条件
Collections.sort(skuIds);
// 开启事务处理...
}
加上这一行代码后,压测TPS直接从波动的500飙升到稳稳的3000,死锁错误归零。
索引失效引发的"锁全表"恐慌
第三个案例,发生在一个看似无害的后台管理功能上。运营反馈说,每当他们批量修改订单状态时,前台用户下单就会变慢甚至超时。
查看慢查询日志,发现Update语句耗时惊人。再看锁等待列表,发现这波操作竟然锁住了大量无关的订单记录。
核心原因: 并没有走我们预想的索引,或者说是走了索引但MySQL认为效率低改走了全表扫描/索引扫描。
InnoDB的行锁是基于索引实现的。如果Update语句的 WHERE 条件没有走索引,或者因为数据区分度不高(例如 status 字段,只有0和1),优化器放弃索引走全表扫描,那么MySQL会把所有扫描过的行全部锁住!
这不是锁一行,这是把整张表给"停业整顿"了。
实操教训:
- Explain是必选项:任何涉及Update/Delete的SQL,上线前必须跑Explain。
- 区分度陷阱:不要在低区分度字段上加锁更新,哪怕加了索引,MySQL也可能忽略。如果必须按状态更新,尝试加上时间范围或ID范围,强制其走索引。
避坑指南与工具模板
经过这几次折腾,我总结了一套针对死锁的"急救包"。我不建议大家遇到死锁就重启应用,那治标不治本。
1. 常用排查工具模板
当死锁发生时,不要慌,按照这个步骤抓取现场:
第一步:开启死锁日志记录(建议生产环境常态化开启,开销极小)
-- 确保这个参数是开启的,它会将死锁信息打印到 error log
set global innodb_print_all_deadlocks = 1;
第二步:查看最近一次死锁的"验尸报告"
SHOW ENGINE INNODB STATUS\G;
重点看 LATEST DETECTED DEADLOCK 这一节,找到 HOLDS THE LOCK 和 WAITING FOR THIS LOCK 的具体SQL。
第三步:分析锁类型
RECORD LOCKS:行锁,最常见。GAP/NEXT-KEY:间隙锁,重点排查范围查询或不存在的记录更新。INSERT INTENTION:插入意向锁,通常伴随GAP锁出现。
2. 三个落地的行动建议
如果你想彻底根治项目里的死锁隐患,建议明天上班就做这三件事:
- 代码审查(Code Review)专项:
重点搜索代码里的
Update和Delete批量操作。检查入参集合是否进行了排序。这是成本最低、收益最高的改动。
-
大事务瘦身: 我见过很多死锁是因为事务太长。把无关的查询、HTTP请求、计算逻辑全部挪出
@Transactional范围。持有锁的时间越短,发生碰撞的概率呈指数级下降。 -
调整隔离级别(进阶): 如果业务允许(大部分互联网业务都允许),考虑将MySQL隔离级别从
REPEATABLE-READ(RR) 调整为READ-COMMITTED(RC)。RC级别下没有Gap Lock(外键约束和唯一性检查除外),能天然规避掉本文第一部分提到的90%的诡异死锁。
写在最后:
数据库死锁就像交通堵塞,没有绝对的"不堵车",只有更科学的交通规则。作为技术人,我们不仅要会写代码,更要懂得代码背后,数据库为了保证数据一致性所做的那些"妥协"与"努力"。
希望这篇复盘能帮你省下几个通宵排查的时间。如果你也有类似的"血泪史",欢迎在评论区交流。