凌晨3点的报警:一次MySQL死锁引发的P2故障复盘

还记得上个月那个周五,本该是我雷打不动的"代码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几乎同时执行:

  1. T1 执行 DELETE FROM coupon_record WHERE user_id = 1001 AND coupon_id = 55; (注意:此时记录不存在)
  2. 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会把所有扫描过的行全部锁住!

这不是锁一行,这是把整张表给"停业整顿"了。

实操教训:

  1. Explain是必选项:任何涉及Update/Delete的SQL,上线前必须跑Explain。
  2. 区分度陷阱:不要在低区分度字段上加锁更新,哪怕加了索引,MySQL也可能忽略。如果必须按状态更新,尝试加上时间范围或ID范围,强制其走索引。

避坑指南与工具模板

经过这几次折腾,我总结了一套针对死锁的"急救包"。我不建议大家遇到死锁就重启应用,那治标不治本。

1. 常用排查工具模板

配图

当死锁发生时,不要慌,按照这个步骤抓取现场:

第一步:开启死锁日志记录(建议生产环境常态化开启,开销极小)

-- 确保这个参数是开启的,它会将死锁信息打印到 error log
set global innodb_print_all_deadlocks = 1;

第二步:查看最近一次死锁的"验尸报告"

SHOW ENGINE INNODB STATUS\G;

重点看 LATEST DETECTED DEADLOCK 这一节,找到 HOLDS THE LOCKWAITING FOR THIS LOCK 的具体SQL。

第三步:分析锁类型

  • RECORD LOCKS:行锁,最常见。
  • GAP / NEXT-KEY:间隙锁,重点排查范围查询或不存在的记录更新。
  • INSERT INTENTION:插入意向锁,通常伴随GAP锁出现。

2. 三个落地的行动建议

配图

如果你想彻底根治项目里的死锁隐患,建议明天上班就做这三件事:

  1. 代码审查(Code Review)专项: 重点搜索代码里的 UpdateDelete 批量操作。检查入参集合是否进行了排序。这是成本最低、收益最高的改动。

配图

  1. 大事务瘦身: 我见过很多死锁是因为事务太长。把无关的查询、HTTP请求、计算逻辑全部挪出 @Transactional 范围。持有锁的时间越短,发生碰撞的概率呈指数级下降。

  2. 调整隔离级别(进阶): 如果业务允许(大部分互联网业务都允许),考虑将MySQL隔离级别从 REPEATABLE-READ (RR) 调整为 READ-COMMITTED (RC)。RC级别下没有Gap Lock(外键约束和唯一性检查除外),能天然规避掉本文第一部分提到的90%的诡异死锁。

写在最后:

数据库死锁就像交通堵塞,没有绝对的"不堵车",只有更科学的交通规则。作为技术人,我们不仅要会写代码,更要懂得代码背后,数据库为了保证数据一致性所做的那些"妥协"与"努力"。

希望这篇复盘能帮你省下几个通宵排查的时间。如果你也有类似的"血泪史",欢迎在评论区交流。