MySQL 死锁 1213 实测:读懂 LATEST DETECTED DEADLOCK,间隙锁 + 插入意向锁为什么也会死锁
在 MySQL 8.0.46 测试实例、自造数据上复现两类最常见的死锁(相反顺序更新、FOR UPDATE 不存在的值后再 INSERT),逐行解读 SHOW ENGINE INNODB STATUS 的死锁日志,并实测关闭死锁检测、固定加锁顺序、唯一索引 + READ COMMITTED 三种做法的结果,附只读巡检 SQL 和可复跑脚本。
InnoDB 发现死锁是即时的:形成环的那一刻(实测约 1.3 秒,就是第二条 UPDATE 发出的时刻)就回滚代价小的一方并报 1213。关掉死锁检测不会更快,两边都等满超时报 1205,一个都没成功。真正的修法是让所有事务按同一顺序加锁;「先查不存在再插入」这种写法,用唯一索引兜底比加 FOR UPDATE 更稳。
适用对象
- 死锁检测开着时,InnoDB 在环形成的瞬间就选一个事务回滚:实测相反顺序转账,受害方 1.31–1.33 秒报 1213(就是它发出第二条 UPDATE 的时刻),另一方 2.31 秒正常提交。
- 「先 SELECT … FOR UPDATE 查一个不存在的值,没有就 INSERT」在 REPEATABLE READ 下两个并发事务必死锁:两边都拿到同一段间隙锁(彼此兼容),再各自申请插入意向锁时互相卡住。日志里关键词是 locks gap before rec 和 insert intention waiting。
- innodb_deadlock_detect=OFF + lock_wait_timeout=5 重放同一场景:两边都在 6.31 秒报 1205,两个事务都失败。高并发热点行可以考虑关检测,但普通业务关掉只会更慢。
- 修法实测:两边都按 id 升序 FOR UPDATE,第二个事务等 1 秒后正常提交(2.01 秒),零死锁;order_no 加唯一索引 + READ COMMITTED,第二个事务等对方提交后得到 1062 重复键错误,而不是死锁。
相反顺序更新两行:最经典的死锁
会话 A:BEGIN; UPDATE accounts … WHERE id=1; 等 1 秒; UPDATE … WHERE id=2; COMMIT。会话 B 晚 0.3 秒开始,顺序反过来:先 id=2,再 id=1。这就是两笔方向相反的转账同时发生。
A 的第二条 UPDATE 在 1.0 秒时开始等 B 手里的 id=2;B 的第二条在 1.3 秒时要 A 手里的 id=1,环形成。InnoDB 没有等任何超时,当场回滚 B 并返回 ERROR 1213 (40001),A 随即拿到锁,2.31 秒提交。两轮结果一样,最终余额 id=1 为 990、id=2 为 1010,只有 A 的转账生效。
本节判断
- 1213 的 SQLSTATE 是 40001,含义是「整个事务已经回滚,请重试整个事务」,不是只重试最后一条语句。应用侧的重试要从 BEGIN 重新开始。
- InnoDB 选受害者看的是事务「代价」(改动的行数、锁的数量),通常回滚改得少的那个;你不能指望固定是后来的那个。
| 会话 | 动作 | 第 1 轮 | 第 2 轮 | 结果 |
|---|---|---|---|---|
| A | id=1 → id=2 | 2.314 | 2.314 | 提交 |
| B | id=2 → id=1(晚 0.3 秒) | 1.314 | 1.330 | ERROR 1213,被选为受害者回滚 |
LATEST DETECTED DEADLOCK 一节该看哪几行
SHOW ENGINE INNODB STATUS\G 只保留最近一次死锁;打开 innodb_print_all_deadlocks 后每一次都会写进错误日志,我在测试实例的 err.log 里也看到了同样的内容。场景一的日志我只保留了关键行:
*** (1) TRANSACTION: … UPDATE accounts SET balance=balance+10 WHERE id=2 —— 事务 1 被卡住时正在执行的语句。注意它只显示「卡住的那条」,事务前面已经执行过的 UPDATE id=1 不会出现,这是很多人读日志时漏掉的:要靠 HOLDS THE LOCK(S) 反推。
*** (1) HOLDS THE LOCK(S): … index PRIMARY of table `deadlock_lab`.`accounts` … lock_mode X locks rec but not gap —— 持有主键上的记录排他锁(不含间隙)。*** (1) WAITING FOR THIS LOCK TO BE GRANTED: … lock_mode X locks rec but not gap waiting —— 在等另一行的同类锁。事务 (2) 正好对称。最后一行 *** WE ROLL BACK TRANSACTION (2) 告诉你谁被回滚。
我读死锁日志只问三个问题:两条语句分别是什么;锁在哪个索引上(PRIMARY、二级索引还是唯一索引);锁模式是 rec but not gap(记录锁)、gap before rec(间隙锁)还是 insert intention(插入意向锁)。三个答案凑齐,基本就能定位到代码。
FOR UPDATE 一个不存在的值,再 INSERT:两个事务必死锁
这是我在业务代码里见得最多的一种:为了「防止重复下单」,先 SELECT … WHERE order_no=? FOR UPDATE,查不到就 INSERT。看起来加了锁很安全,实际上在 REPEATABLE READ 下,order_no=50001 不存在时 InnoDB 锁的是 50000 和 50002 之间的间隙。间隙锁之间互相兼容,所以两个事务都能顺利拿到;接着两边都要往这个间隙里 INSERT,需要插入意向锁,而插入意向锁和对方的间隙锁冲突——环形成。
两轮实测:会话 D 在 1.31 秒报 1213,会话 C 在 2.31 秒提交,表里最终只有一条 order_no=50001。日志里的关键行是:HOLDS … index idx_order_no … lock_mode X locks gap before rec,WAITING … lock_mode X locks gap before rec insert intention waiting。看到这两句,基本就可以确定是「先查后插」模式。
本节判断
- 间隙锁还有一个副作用:我另外让一个事务 FOR UPDATE 不存在的 60001 后不提交,另一个会话 INSERT 60001 就一直等,下载区只读 SQL 第 3、4 段在等待第 11 秒时抓到了它:locked_index=idx_order_no,等待方模式 X,GAP,INSERT_INTENTION,持有方 X,GAP,LOCK_DATA 都是 60002, 30001(即「order_no=60002、主键 30001 那一行之前的间隙」)。
| 会话 | 动作 | 第 1 轮 | 第 2 轮 | 结果 |
|---|---|---|---|---|
| C | FOR UPDATE order_no=50001(不存在)→ INSERT | 2.315 | 2.314 | 提交 |
| D | 同上(晚 0.3 秒) | 1.313 | 1.312 | ERROR 1213 |
三种处理办法,各跑了两轮
关闭死锁检测:SET GLOBAL innodb_deadlock_detect=OFF、innodb_lock_wait_timeout=5,重放场景一。没有人被立刻回滚,两个事务都等到超时,在 6.31 秒同时报 ERROR 1205,两笔转账都没成功。官方文档建议只在大量并发争抢同一热点行、死锁检测本身成为瓶颈时才关;普通业务关掉只会把 1 秒内解决的事变成 5 秒后两边都失败。
固定加锁顺序:两边先执行 SELECT id FROM accounts WHERE id IN (5,6) ORDER BY id FOR UPDATE,再按各自方向改余额。第二个事务在第一句就排队等待,第一个 1.01 秒提交后它接着执行,2.01 秒提交,两轮零死锁、两笔都生效。
唯一索引 + READ COMMITTED:把 order_no 建成 UNIQUE,会话隔离级别设为 READ COMMITTED,再重放场景二。RC 下 FOR UPDATE 不存在的值不再加间隙锁;第二个 INSERT 撞到对方还没提交的同值记录时会等待,对方 2.01 秒提交后,它得到 ERROR 1062 Duplicate entry,而不是死锁。重复下单被唯一索引挡住,应用只要把 1062 当作「已存在」处理即可。
本节判断
- 每跑一遍完整脚本,INNODB_METRICS 里 lock_deadlocks 加 2(场景一、二各一次),lock_timeouts 加 2(场景三两边各一次)。这两个计数器自实例启动累计,定时采样相减就能得到每小时死锁次数,比翻错误日志方便。
- 把隔离级别改成 READ COMMITTED 不是免费的:它会改变一致性读的语义,也要求 binlog_format=ROW。我的建议是只在需要的会话或业务里改,并先在测试环境回归。
| 做法 | 会话 | 第 1 轮 | 第 2 轮 | 结果 |
|---|---|---|---|---|
| 关闭死锁检测(timeout=5) | E | 6.311 | 6.311 | ERROR 1205 |
| F | 6.312 | 6.311 | ERROR 1205 | |
| 按 id 升序 FOR UPDATE | G | 1.009 | 1.011 | 提交 |
| H | 2.012 | 2.012 | 等待后提交 | |
| UNIQUE + READ COMMITTED | I | 2.013 | 2.011 | 提交 |
| J | 2.013 | 2.011 | ERROR 1062(无死锁) |
线上看到 1213 时,我按这个顺序处理
从报错到修代码
- 01
1
先确认应用会重试
1213 意味着整个事务已回滚。没有重试的接口先补上「从 BEGIN 开始、最多 3 次、带随机退避」的重试,这是止血。
- 02
2
量一下频率
跑只读 SQL 第 2 段,记下 lock_deadlocks,一小时后再跑一次相减。偶发几次属于正常,持续每分钟都有就是设计问题。
- 03
3
读日志定位两条 SQL
SHOW ENGINE INNODB STATUS\G 或错误日志(需 innodb_print_all_deadlocks=ON)。按「语句 / 索引 / 锁模式」三问拆开。
- 04
4
按模式改代码
rec but not gap 互等 → 统一加锁顺序;gap + insert intention → 用唯一索引兜底、去掉先查后插的 FOR UPDATE;二级索引范围更新 → 缩小范围或改成按主键更新。
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
innodb_lock_wait_timeout 调大能减少死锁吗?
不能。死锁检测开着时,死锁和锁等待超时是两回事:死锁当场就被处理,跟这个超时无关。调大超时只会让普通的锁等待(比如某个事务忘了提交)拖得更久。
只读 SQL 里为什么没有 SHOW ENGINE INNODB STATUS?
它不是 SELECT,我们的下载脚本统一只放 SELECT 语句,方便你直接交给只读账号执行。死锁详情请单独执行 SHOW ENGINE INNODB STATUS\G(需要 PROCESS 权限)。
这些耗时能代表生产吗?
不能。这是本机测试实例和自造数据,脚本用 SLEEP 人为制造并发窗口,数字只说明顺序和量级。生产上死锁的触发取决于并发度和事务长度,但日志的读法和修法是一样的。