MySQL 死锁排查记:两个事务互相等待——InnoDB 行锁与间隙锁的真相

引言

大促彩排当晚,秒杀压测到 3000 并发时,下单接口开始批量报错,日志里刷屏的是同一句话:

Deadlock found when trying to get lock; try restarting transaction

DBA 的第一反应是“死锁了,MySQL 会自动回滚一个,重启事务就行”——问题是这个死锁每秒都在发生:被回滚的那一半请求里,一部分重试成功,一部分又撞进下一轮死锁,订单创建成功率从 100% 跌到 62%,压测被迫中断。

更蹊跷的是业务视角的死锁描述:两个事务根本“没有”操作同一行——事务 A 更新的是 SKU 1001 的库存,事务 B 更新的是 SKU 2002 的库存,它们凭什么互相等待?带着这个“不相干的行为什么会死锁”的疑问,我们拉开了一次教科书级的 InnoDB 锁排查:SHOW ENGINE INNODB STATUS 的死锁日志 → lock_mode X locks gap 字样 → 真相是间隙锁。这篇文章完整还原排查过程,并附上死锁日志的逐行解读技巧。


一、30 秒补课:InnoDB 的锁到底有几种

看懂死锁日志的前提是分清锁类型。只需要记住三种:

锁类型加锁对象出现场景日志关键字
Record Lock(记录锁)索引上的某一条记录UPDATE ... WHERE id=1(等值命中)lock_mode X locks rec but not gap
Gap Lock(间隙锁)索引记录之间的开区间空隙UPDATE ... WHERE status=1(范围/未命中索引)lock_mode X locks gap
Next-Key Lock记录 + 前面的间隙(左开右闭)RR 隔离级别下的默认加锁单位lock_mode X(不带修饰)

两条关键认知:

  1. RR(可重复读)是间隙锁存在的土壤。RC(读已提交)下几乎没有 Gap Lock(只保留行锁 + 外键/唯一键检查),所以“把隔离级别降到 RC”是绕开间隙锁死锁的野路子——有效但有代价(幻读风险回来、binlog 必须用 row 格式);
  2. 间隙锁之间不互斥,但间隙锁“防插入”。两个事务可以同时持有同一个间隙的 Gap Lock(这和记录锁完全不同),但任何事务向这个间隙 INSERT 都会被两者挡住——这是本文死锁的核心机制,先埋个伏笔。

二、事故现场还原

2.1 涉事的表与 SQL

CREATE TABLE seckill_stock (
    id          BIGINT PRIMARY KEY,
    sku_id      BIGINT NOT NULL,
    region_id   BIGINT NOT NULL DEFAULT 1,
    stock       INT NOT NULL,
    KEY idx_region_stock (region_id, stock)     -- 注意:sku_id 上没有索引!
) ENGINE=InnoDB;

业务逻辑(简化后的事务):

@Transactional
public void deductStock(Long skuId, int qty) {
    // ① 扣减指定 SKU 库存(目的:只锁这一行)
    stockMapper.deduct(skuId, qty);              // UPDATE seckill_stock SET stock = stock - #{qty}
                                                 //   WHERE region_id = 1 AND sku_id = #{skuId}
    // ② 热度记录(另一张表,本案例无关但拉长了事务)
    hotMapper.insert(new HotSaleRecord(skuId));
    // ③ ...后续风控检查等 20ms 的逻辑
}

压测并发下,事务 A 扣 SKU 1001、事务 B 扣 SKU 2002——两个不同的行,却报了死锁。

2.2 死锁日志初见:locks gap 暴露真凶

SHOW ENGINE INNODB STATUS\G   -- 在 LATEST DETECTED DEADLOCK 段落里:

*** (1) TRANSACTION:
TRANSACTION 42101, ACTIVE 0 sec performing UPDATE
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE seckill_stock SET stock = stock - 1
 WHERE region_id = 1 AND sku_id = 1001

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 88 page no 5 n bits 72 index idx_region_stock of table `shop`.`seckill_stock`
lock_mode X locks gap before rec      ← 事务1 持有某个间隙锁

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 88 page no 5 n bits 72 index idx_region_stock ...
lock_mode X waiting                   ← 事务1 在等一把 Next-Key Lock

*** (2) TRANSACTION:
TRANSACTION 42102, ACTIVE 0 sec performing UPDATE
UPDATE seckill_stock SET stock = stock - 1
 WHERE region_id = 1 AND sku_id = 2002

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X locks gap before rec   ← 事务2 也持有间隙锁

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X waiting
*** WE ROLL BACK TRANSACTION (2)

两个事务“持有”的都是间隙锁,“等待”的都是对方间隙上的插入权——这就是为什么看起来不相干的两行会死锁。机制拆解在第三章。


三、根因分析:间隙锁是怎么把两行“锁”到一起的

3.1 没走对索引 → 锁从一行膨胀成一片

先看第一条线索:WHERE region_id = 1 AND sku_id = 1001 里,sku_id 没有索引,只有联合索引 idx_region_stock(region_id, stock) 可用(region_id 等值 + stock 范围式扫描)。执行器只能:

① 在 idx_region_stock 上定位 region_id=1 的整个前缀区间
② 逐条扫描这个区间,在 SERVER 层过滤 sku_id
③ 扫描到哪,Next-Key Lock 就加到哪 —— 整个 region_id=1 的区间全被锁住

锁不是加在“你要改的行”上,而是加在“扫描路径经过的所有索引记录和间隙”上。region_id=1 下有几千个 SKU,事务 A 扣 1001 时锁住了 (prev_sku, 1001] 的一片区间——包括 2002 所在的位置。

3.2 死锁的形成时序:四步闭环

数据:idx_region_stock 索引序 …… 1001, 1003, 1005, 2002, 2004 ……

T1: 事务A(扣1001)在 1001 处加 Next-Key Lock,锁住 (1000, 1001]
     并继续扫描,锁住 (1001,1003] (1003,1005] (1005,2002]  ← 含2002的间隙
T2: 事务B(扣2002)在 2002 处加 Next-Key Lock,锁住 (2001, 2002]
     并继续扫描,锁住 (2002,2004] …… 回头也要锁 (1005,2002] 这个间隙
     —— 间隙锁不冲突!B 成功拿到 (1005,2002] 的 Gap Lock ✅
T3: 事务A 想 INSERT 新热点记录/或扫描路径需要 2002 的记录锁
     → 被 B 的 Next-Key Lock 挡住,等待 ⛔
T4: 事务B 需要 (1005,2002] 区间内某条记录的 X Record Lock(或向该
     间隙 INSERT——A 的间隙锁挡插入)→ 被 A 挡住,等待 ⛔
     → 循环等待形成,InnoDB 检测器介入,回滚代价小的事务 B

注意 T2 的微妙之处:间隙锁是“共享”的(两个事务可以同时持有同一间隙),所以 B 能拿到 A 已持有的间隙锁;但 B 后续需要的记录锁在 A 手里,而 A 等的记录锁在 B 手里——Gap Lock 的共享性 + Record Lock 的排他性叠加,构成了这个经典死锁模型(社区里著名的 “INSERT 与 Next-Key 死锁”和“两个范围 UPDATE 交叉”都是同一机制的变体)。

3.3 为什么事务范围拉长是帮凶

死锁需要“环”,环需要时间窗口。示例事务里扣完库存还干了风控检查(20ms+),事务越长,持有间隙锁的时间越长,两个事务的锁区间交叠概率越大。压测 3000 并发下,这个概率从千分之一被放大到每秒必现。


四、修复方案:三板斧,全部落地

4.1 第一板斧:加唯一索引,把锁范围缩回一行

ALTER TABLE seckill_stock ADD UNIQUE KEY uk_sku_region (sku_id, region_id);

加了 sku_id 索引后,同样的 UPDATE 走唯一索引等值命中,InnoDB 优化为只加一条 Record Lock(locks rec but not gap)——扫描范围从“整个 region 前缀”缩到“一行”,事务 A 和事务 B 的锁区间从“大范围交叠”变成“各自一行不相干”。这一条改动直接消灭了本案例的死锁(下图是修复前后的锁范围对比):

修复前(走 idx_region_stock 范围扫描):
  事务A 锁:(1000,1001] (1001,1003] (1003,1005] (1005,2002] …
  事务B 锁:(2001,2002] (2002,2004] … + (1005,2002] 交叉 → 死锁区

修复后(走 uk_sku_region 等值):
  事务A 锁:sku_id=1001 这一行
  事务B 锁:sku_id=2002 这一行     → 无交叠,永不死锁

通用教训UPDATE/DELETE 的 WHERE 条件必须能走等值/唯一索引——走不到,RR 级别的 InnoDB 只能用范围锁保正确性,死锁面立刻扩大。上线前用 EXPLAIN 检查所有写语句的索引路径,是性价比最高的死锁预防。

4.2 第二板斧:统一加锁顺序

跨多行更新(如批量扣减多个 SKU)的场景,所有事务按同一顺序拿锁

// ❌ 修复前:按业务传入顺序更新 → 事务A 改 [1001,2002],事务B 改 [2002,1001] → 死锁
// ✅ 修复后:先排序再更新,全事务统一从大到小
List<Long> skuIds = req.getSkuIds().stream()
        .distinct()
        .sorted(Comparator.reverseOrder())   // 排序是死锁预防的"银河法则"
        .toList();
for (Long skuId : skuIds) {
    stockMapper.deduct(skuId, qtyOf(skuId));
}

这不是秒杀专属——凡是“一个事务里更新多行”的代码都该检查顺序:批量核销、转账对敲、购物车结算,全适用。

4.3 第三板斧:缩小事务范围

// ❌ 修复前:库存扣减与风控检查(20ms RPC)在同一个事务
// ✅ 修复后:锁内只留 DB 操作,RPC/消息/记录移出事务
@Transactional
public void deductStock(Long skuId, int qty) {
    stockMapper.deduct(skuId, qty);           // 事务内:只剩 SQL
    afterCommit(() -> hotMapper.record(skuId));  // 事务提交后再做杂务
}

事务持有的锁时长从 ~25ms 缩到 ~3ms,即使将来再出现锁交叠,窗口也小到难以撞上。

4.4 修复效果

指标修复前修复后
死锁频率~8 次/秒0(连续 3 轮压测)
下单成功率62%99.97%
批量扣减接口 P991.8s210ms

三板斧里,唯一索引是根治(锁范围缩小),排序是防御(顺序统一),短事务是减震(窗口变小)——按这个优先级落地。


五、死锁日志解读技巧(排查工具箱)

5.1 标准排查流程

STEP 1  SHOW ENGINE INNODB STATUS\G  → 找 LATEST DETECTED DEADLOCK 段
STEP 2  读两个事务的 SQL:确认它们"表面"操作的行是否相同
STEP 3  读 HOLDS/WAITING 的锁类型关键字(见 5.2 对照表)
STEP 4  涉及间隙锁时,EXPLAIN 复现两个 UPDATE 的执行计划
        —— 90% 的间隙锁死锁都伴随"写语句没走等值索引"
STEP 5  若日志被覆盖(业务并发高时死锁日志更新极快):
        打开 innodb_print_all_deadlocks = ON,全量记录到 error log

5.2 锁关键字对照表(日志阅读密码本)

lock_mode X waiting                    → 等待 Next-Key 锁(记录+间隙)
lock_mode X locks rec but not gap      → 持有/等待纯记录锁(等值唯一索引命中)
lock_mode X locks gap before rec       → 持有间隙锁(防插入,不防读)
lock_mode X locks gap before rec insert intention waiting
                                       → 插入意向锁被间隙锁挡住(INSERT 型死锁标志)
lock_mode X locks supremum             → 锁住了索引"正无穷"端(范围扫描到最大值)
index idx_xxx of table ...             → 死锁发生在哪个索引上(定位 SQL 走的路)

两个高频判型:

  • 看到 gap 字样 → 优先怀疑“写语句没走等值索引”,EXPLAIN 一步到位;
  • 看到 insert intention waiting → 经典的“间隙锁 × INSERT”死锁(一个事务范围查询持有间隙锁,另一个事务 INSERT 落进该间隙)——商业代码里最常见的一种,解法同 4.1(缩小锁范围)或 RC。

5.3 辅助工具

工具/命令用途
SELECT * FROM sys.innodb_lock_waits\G实时锁等待(死锁已发生看日志,正在堵看这个)
SELECT * FROM performance_schema.data_locks当前事务持有的全部锁(8.0 替代 information_schema.innodb_locks)
information_schema.INNODB_TRX找长事务:trx_started 超过 10s 的都是嫌疑犯
innodb_print_all_deadlocks=ON死锁全量留痕(注意写 error log 的量)
innodb_deadlock_detect=ON(默认开)死锁检测器;极高并发热点行场景才有“关闭+靠超时”的取舍,别乱关

六、常见问题

6.1 死锁报错后业务该怎么办:重试的正确姿势

死锁被回滚的一方应该重试(报错文本里 MySQL 已提示 try restarting transaction),但要带三条纪律:① 只重试死锁类错误(错误码 1213 / SQLState 40001),其他错误重试无意义;② 指数退避 + 上限(如 50ms → 200ms → 800ms,最多 3 次),避免重试风暴互相再锁死;③ 整个事务重跑而不是断点续传——回滚后上下文已失效。Spring 里可对 DeadlockLoserDataAccessException@Retryable,比手写循环干净。

6.2 把隔离级别降到 RC 能一劳永逸吗?

RC 下 Gap Lock 基本消失,本文这类间隙锁死锁确实根治。但代价要清楚:幻读语义改变(同一事务两次范围查询结果可能不同)、binlog 必须 ROW 格式(STATEMENT 会主从不一致)、存量代码若有依赖 RR 语义的(如可重复读的报表逻辑)需要排查。结论:RC 是合法选项但不是免费选项;多数死锁问题靠“索引 + 顺序 + 短事务”三板斧就能解决,先别动隔离级别。

6.3 为什么 SELECT 也会卷入死锁?

普通 SELECT 是快照读(MVCC),不加锁。但三种 SELECT 会加锁:SELECT ... FOR UPDATE(X 记录锁+间隙锁)、SELECT ... FOR SHARE(S 锁)、序列化级别的一切读。排查时务必搜代码里的 FOR UPDATE——它们是 SELECT 面孔的 UPDATE,加锁规则和 UPDATE 一模一样,也一模一样地需要“等值索引 + 排序”。

6.4 SHOW ENGINE INNODB STATUS 只显示最后一次死锁,并发高根本抓不到怎么办?

两步:① 打开 innodb_print_all_deadlocks,所有死锁进 error log(配合日志轮转);② 用 performance_schema 抓“活现场”——死锁是瞬时快照,但死锁前的一秒里锁等待是持续存在的sys.innodb_lock_waits 能看到谁堵谁,顺着 blocking_pid 找到持锁事务的 SQL(performance_schema.events_statements_history),比死锁日志的信息还全。

6.5 热点行(比如同一个 SKU 被抢)的死锁和这个一样吗?

不一样,热点行是“排队问题”不是“环问题”:几千个事务等同一行的记录锁,是单点排队,不构成循环等待,通常表现为超时而非死锁。解法也不同:单行热点用原子 UPDATE(stock=stock-1)+ 快速失败/排队削峰,或库存分段(拆成 N 行随机扣)、Redis 预扣。先判型再开药:报 Deadlock 是环,报 Lock wait timeout 是堵

6.6 怎么在上线前预防死锁,而不是上线后排查?

三个落地动作:① Code Review 加一条检查项——事务内 UPDATE/DELETE 的 WHERE 必须等值唯一索引命中,批量更新必须先排序;② 测试环境把 innodb_print_all_deadlocks 常开,压测报告里“死锁次数=0”作为准出指标;③ 对核心写路径写并发测试(两个线程按相反顺序更新同一批数据),死锁在 10 并发下就能稳定复现,不用等大促。


七、总结

排查与修复速查卡

┌─────────────────┬────────────────────────────────────────────────┐
│ 环节             │ 关键动作                                        │
├─────────────────┼────────────────────────────────────────────────┤
│ 现场抓取         │ SHOW ENGINE INNODB STATUS → LATEST DETECTED     │
│                 │   DEADLOCK;高频覆盖开 innodb_print_all_deadlocks│
│ 日志判型         │ 带 gap → 写语句没走等值索引                       │
│                 │ insert intention waiting → 间隙锁×INSERT          │
│ 根因确认         │ EXPLAIN 两个涉事 UPDATE 的索引路径                │
│ 修复三板斧       │ ① 唯一索引缩锁范围(根治)                       │
│                 │ ② 多行更新统一排序(防御)                        │
│                 │ ③ 事务内只留 SQL(减震)                          │
│ 业务兜底         │ 1213 错误码指数退避重试,上限 3 次                 │
└─────────────────┴────────────────────────────────────────────────┘

一句话

“两行不相干的数据为什么死锁”——答案藏在 InnoDB 的加锁单位里:RR 级别下,锁不是加在你改的那一行,而是加在执行计划扫描过的每一条索引记录和每一个间隙上;写语句走不上等值索引,锁就从一行膨胀成一片,间隙锁的共享性再叠上记录锁的排他性,环就形成了。排查的死穴是读懂锁日志关键字(gap / insert intention / supremum),预防的铁律是三板斧:唯一索引缩小锁范围、统一排序杜绝逆序、短事务压缩窗口。死锁不可怕,可怕的是只在报错时重启重试,而从不看一眼那段以 LATEST DETECTED DEADLOCK 开头的日志。

给团队的建议

建议
规范Code Review 检查项:写语句 WHERE 等值唯一索引命中;多行更新先排序
配置生产开 innodb_print_all_deadlocks;压测准出指标含“死锁次数=0”
监控死锁频次(error log 关键字计数)+ 长事务(TRX >10s)双告警
习惯事务内禁 RPC/慢逻辑(afterCommit 处理杂务)
排障团队人人过一遍 5.2 锁关键字对照表——20 行的“密码本”值一次事故

互动话题:你遇到过最离谱的死锁是什么?“两行不相干的数据死锁”这种谜案你破过吗?评论区聊聊。


参考资料


标题:MySQL 死锁排查记:两个事务互相等待——InnoDB 行锁与间隙锁的真相
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/12/1788598073074.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消