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(不带修饰) |
两条关键认知:
- RR(可重复读)是间隙锁存在的土壤。RC(读已提交)下几乎没有 Gap Lock(只保留行锁 + 外键/唯一键检查),所以“把隔离级别降到 RC”是绕开间隙锁死锁的野路子——有效但有代价(幻读风险回来、binlog 必须用 row 格式);
- 间隙锁之间不互斥,但间隙锁“防插入”。两个事务可以同时持有同一个间隙的 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% |
| 批量扣减接口 P99 | 1.8s | 210ms |
三板斧里,唯一索引是根治(锁范围缩小),排序是防御(顺序统一),短事务是减震(窗口变小)——按这个优先级落地。
五、死锁日志解读技巧(排查工具箱)
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 LOCKS(LATEST DETECTED DEADLOCK 解读)
- MySQL 官方手册:Next-Key Locking 与间隙锁
- MySQL 官方手册:innodb_print_all_deadlocks
- sys schema:innodb_lock_waits 视图
- MySQL 官方博客:InnoDB 死锁检测
- 丁奇《MySQL 实战 45 讲》(间隙锁与死锁案例章节)
标题:MySQL 死锁排查记:两个事务互相等待——InnoDB 行锁与间隙锁的真相
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/12/1788598073074.html
公众号:服务端技术精选
- 引言
- 一、30 秒补课:InnoDB 的锁到底有几种
- 二、事故现场还原
- 2.1 涉事的表与 SQL
- 2.2 死锁日志初见:locks gap 暴露真凶
- 三、根因分析:间隙锁是怎么把两行“锁”到一起的
- 3.1 没走对索引 → 锁从一行膨胀成一片
- 3.2 死锁的形成时序:四步闭环
- 3.3 为什么事务范围拉长是帮凶
- 四、修复方案:三板斧,全部落地
- 4.1 第一板斧:加唯一索引,把锁范围缩回一行
- 4.2 第二板斧:统一加锁顺序
- 4.3 第三板斧:缩小事务范围
- 4.4 修复效果
- 五、死锁日志解读技巧(排查工具箱)
- 5.1 标准排查流程
- 5.2 锁关键字对照表(日志阅读密码本)
- 5.3 辅助工具
- 六、常见问题
- 6.1 死锁报错后业务该怎么办:重试的正确姿势
- 6.2 把隔离级别降到 RC 能一劳永逸吗?
- 6.3 为什么 SELECT 也会卷入死锁?
- 6.4 SHOW ENGINE INNODB STATUS 只显示最后一次死锁,并发高根本抓不到怎么办?
- 6.5 热点行(比如同一个 SKU 被抢)的死锁和这个一样吗?
- 6.6 怎么在上线前预防死锁,而不是上线后排查?
- 七、总结
- 排查与修复速查卡
- 一句话
- 给团队的建议
- 参考资料
评论