线上突然报 Deadlock found when trying to get lock; try restarting transaction,很多人第一反应是"数据库死锁了"。但真正难的不是知道死锁了,而是从那一大坨 LATEST DETECTED DEADLOCK 里看出:哪两个事务打架、等的是哪个索引、为什么形成循环等待。
一、事故现场
线上订单接口偶发失败,日志里出现:
text
org.springframework.dao.DeadlockLoserDataAccessException:
Deadlock found when trying to get lock; try restarting transaction
业务侧的表现很烦:用户偶尔提交失败,重试一下又好了;数据库 CPU 不高,慢 SQL 不一定明显,错误集中在高峰期。
开发同学一开始只知道"发生死锁了"。但问三个问题,没人能立刻答上来:
- 是哪两条 SQL 死锁?
- 它们等的是行锁、间隙锁,还是插入意向锁?
- 该加索引、改顺序,还是缩短事务?
这时候就不能只看应用异常了,得去看 MySQL 的死锁日志。
二、先把死锁日志拿出来
最常用的是:
sql
SHOW ENGINE INNODB STATUS\G
里面找这一段:
text
LATEST DETECTED DEADLOCK
------------------------
如果你希望每次死锁都落进 MySQL 错误日志,可以打开:
sql
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
SET GLOBAL innodb_print_all_deadlocks = ON;
生产环境我一般建议打开(死锁频繁时日志量会大,但比出事后抓不到现场强得多)。否则 SHOW ENGINE INNODB STATUS 只能看到最近一次死锁,频繁发生时很快就被覆盖了。
三、先看一个简化版死锁日志
下面是一段简化后的死锁日志,先熟悉结构,不用急着全看懂。
假设事务 2 在当前库存更新之前,已经执行过一条订单更新,所以它的 HOLDS 里会出现 order_info 上的锁。
text
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 3 row lock(s)
MySQL thread id 101, OS thread handle 1400, query id 888 localhost app updating
UPDATE order_info SET status = 'CANCELED' WHERE order_no = 'O1001'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index uk_order_no of table `biz`.`order_info`
trx id 12345 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 102, OS thread handle 1500, query id 889 localhost app updating
UPDATE stock SET version = version + 1 WHERE sku = 'SKU1001'
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index uk_order_no of table `biz`.`order_info`
trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 59 page no 5 n bits 72 index PRIMARY of table `biz`.`stock`
trx id 12346 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1)
注意一个细节:事务 (2) 当前卡住的 SQL 是库存更新 UPDATE stock ... WHERE sku='SKU1001',它前面那条订单更新 UPDATE order_info WHERE order_no='O1001' 已经拿到 uk_order_no 上的锁(所以在 HOLDS 里能看到)。这个"当前 SQL"和"已持有锁来自更早的语句"的区别,后面读日志时很关键。
真实日志会更长,但核心就几块:TRANSACTION 1 在干什么、它 WAITING FOR 什么锁、TRANSACTION 2 在干什么、它 HOLDS 什么锁、它又在等什么锁、最后 WE ROLL BACK 了谁。看死锁日志不是从头背到尾,而是按顺序回答几个问题。
四、第一步:看是哪条 SQL 在等锁
先找每个事务里的 SQL,一般在这一行附近:
text
MySQL thread id 101, OS thread handle 1400, query id 888 localhost app updating
UPDATE order_info SET status = 'CANCELED' WHERE order_no = 'O1001'
说明事务 1 当时正在执行 UPDATE order_info ... WHERE order_no='O1001'。事务 2 同理找出来。
第一步先别急着分析锁,先把两条 SQL 抄出来。很多死锁排查卡住,是因为一上来就钻进 space id、page no、heap no 这些细节里。其实先确认业务动作更重要:事务 A 是取消订单,事务 B 是支付订单。业务动作一还原,后面才知道该找哪个接口、哪个方法、哪个事务边界。
五、第二步:看等的是哪个索引
找到这一段:
text
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index uk_order_no of table `biz`.`order_info`
trx id 12345 lock_mode X locks rec but not gap waiting
重点看三个信息:index uk_order_no、table biz.order_info、lock_mode X locks rec but not gap waiting。翻译成人话就是:事务 1 正在等 order_info 表的 uk_order_no 索引上的一把 X 记录锁。
| 日志字段 | 你要看什么 |
|---|---|
table |
哪张表 |
index |
哪个索引 |
lock_mode |
什么锁模式 |
waiting |
当前事务是否正在等 |
为什么索引重要?因为 InnoDB 的行锁锁的是索引记录,死锁日志里出现哪个索引,冲突通常就发生在那条索引路径上。
- 看到
index PRIMARY,大概率是主键记录冲突。 - 看到
index idx_status_create_time,得去看这条二级索引的扫描范围。 - 看到
index idx_product_id配上lock_mode X locks gap before rec insert intention waiting,八成就和 Gap Lock、插入意向锁有关。
六、第三步:把锁关键词翻译成人话
死锁日志最难读的是这一串:lock_mode X locks rec but not gap,或者 lock_mode X locks gap before rec insert intention waiting。先记住几个高频词。
lock_mode X ------ 排他锁,通常出现在 UPDATE、DELETE、SELECT ... FOR UPDATE、INSERT 里,表示这个事务想修改或独占某条记录。
locks rec but not gap ------ 记录锁,大白话就是锁某一条索引记录、不锁前后间隙,常见于主键或唯一索引等值命中。比如 UPDATE order_info SET status='PAID' WHERE id=1001 走主键,通常就是记录锁。
locks gap before rec ------ 锁住某条记录前面的间隙,不一定锁某一行,可能锁的是两条索引记录之间的空档。常见于范围查询、非唯一索引等值查询、查不存在的数据、SELECT ... FOR UPDATE。
insert intention waiting ------ 插入意向锁在等待,意思是有事务想往某个间隙插新记录,但这个间隙被别人锁住了。如果看到 locks gap before rec insert intention waiting,基本可以往 Gap Lock 方向查。
waiting ------ 带 waiting 的是当前事务正在等的锁,没带 waiting 的通常是已经持有的锁。排查时先找 waiting,再找谁持有它。
七、第四步:看对方持有什么锁
一个事务在等锁,肯定是另一个事务持有了冲突锁。所以看完 WAITING FOR THIS LOCK TO BE GRANTED,还要看另一方的 HOLDS THE LOCK(S)。
比如事务 (2) 的 HOLDS:
text
RECORD LOCKS space id 58 page no 4 n bits 72 index uk_order_no of table `biz`.`order_info`
trx id 12346 lock_mode X locks rec but not gap
说明事务 2 已经持有 order_info.uk_order_no 上的 X 记录锁,而事务 1 正在等这把锁。再看事务 2 在等什么:
text
RECORD LOCKS space id 59 page no 5 n bits 72 index PRIMARY of table `biz`.`stock`
trx id 12346 lock_mode X locks rec but not gap waiting
这条锁又被事务 1 持有,循环等待就形成了。死锁的本质就是:事务 A 持有锁 1、等待锁 2;事务 B 持有锁 2、等待锁 1。日志的作用,就是让你把这个环找出来。
八、第五步:还原时间线
看死锁日志,最后一定要还原成时间线。比如还原出来是这样:
- T1:事务 A 锁住 stock.SKU1001
- T2:事务 B 锁住 order_info.O1001
- T3:事务 A 想锁 order_info.O1001,被事务 B 挡住
- T4:事务 B 想锁 stock.SKU1001,被事务 A 挡住
- T5:循环等待,MySQL 回滚事务 A
修复方向就很清楚了:两个事务加锁顺序不一致。事务 A 是先锁库存、再锁订单;事务 B 是先锁订单、再锁库存。只要并发一高,就可能互相等待。
修复不是玄学调参,而是把加锁顺序统一:所有业务都先锁订单再锁库存,或者都先锁库存再锁订单。顺序一致,循环等待的概率会大幅下降。
九、常见死锁类型怎么从日志识别?
1. 主键记录互锁 日志关键词:index PRIMARY + locks rec but not gap。常见原因是两个事务更新同一批主键但顺序相反,比如 A 先更新 id=1 再 id=2,B 先更新 id=2 再 id=1。修复方向:按主键升序更新,保持加锁顺序一致。
2. 非唯一索引 Gap Lock 死锁 日志关键词:locks gap before rec + insert intention waiting。常见原因是非唯一索引等值/范围更新,同时又有 INSERT 插入同一段间隙。修复方向:能唯一就加唯一索引,缩短事务,调整操作顺序,必要时评估 READ COMMITTED。
3. 没走索引导致锁范围扩大 日志里可能看到 大量 row lock(s)、index PRIMARY、而 UPDATE 条件却不是主键,再配合执行计划 type=ALL / key=NULL / rows 很大。修复方向:给 WHERE 条件加合适索引,避免隐式类型转换,避免函数包索引列,批量更新分批。
4. 插入唯一键冲突相关死锁 日志里可能看到 duplicate key、insert、index uk_xxx。常见原因是多个事务并发插入同一个唯一键,或插入失败后还继续持有某些锁。修复方向:业务幂等,捕获 DuplicateKeyException,减少事务内复杂逻辑,失败后尽快结束事务。
十、MySQL 回滚谁,不代表谁写错了
死锁日志最后会有 *** WE ROLL BACK TRANSACTION (1)。很多人看到事务 1 被回滚,就以为事务 1 是"凶手"。不一定。MySQL 只是选一个代价较小的事务回滚,让另一个事务继续。被回滚的事务未必是写错的一方。
真正要看的是:两个事务为什么加锁顺序形成了环、哪个 SQL 扫描范围过大、哪个事务持锁时间太长、哪个索引导致了 Gap Lock。死锁不是单方事故,它是两个或多个事务共同形成的等待环。
十一、线上修复怎么选?
1. 先保证能安全重试。 死锁在高并发系统里无法完全杜绝,所以业务层要允许安全重试。但前提是接口幂等、外部副作用可控、不会重复扣款/发券/发消息。如果只是本地数据库状态更新,可以捕获死锁异常后短暂退避重试;事务里还调了远程接口,就不能无脑重试。
2. 再查索引和执行计划。 很多死锁本质是扫描范围太大,一定要看 EXPLAIN,重点查 UPDATE/DELETE 是否走索引、SELECT FOR UPDATE 是否走合适索引、是否有隐式类型转换、是否对索引列套函数。
3. 统一加锁顺序。 多行、多表更新尽量统一顺序,比如批量更新订单:
java
orderIds.sort(Long::compareTo);
然后按主键升序更新,不要一个接口升序、一个接口降序。
4. 缩短事务。 事务里别塞远程接口、大循环、文件处理、复杂计算、用户等待。锁持有时间越短,死锁窗口越小。
5. 必要时拆批。 批量更新一大堆数据容易造成锁范围大、回滚成本高,更稳的是每批 200/500/1000 条、按主键分页、每批一个小事务、失败可重试。
十二、上线前 checklist
看到死锁,或者上线高并发写接口前,可以过一遍:
| 检查项 | 风险 | 建议 |
|---|---|---|
| 是否开启死锁日志 | 出事后没证据 | 开 innodb_print_all_deadlocks |
| 是否能定位 SQL | 只看到异常,不知道谁打架 | 记录 traceId 和 SQL 参数 |
| UPDATE/DELETE 是否走索引 | 锁范围放大 | 上线前 EXPLAIN |
| 多行更新是否顺序一致 | 循环等待 | 按主键/业务键排序 |
| 是否有 SELECT FOR UPDATE | Gap Lock、锁等待 | 检查索引和事务范围 |
| 是否在事务里调远程接口 | 持锁时间变长 | 移出事务 |
| 是否有批量大事务 | 锁多、回滚慢 | 分批小事务 |
| 死锁后是否安全重试 | 用户偶发失败 | 幂等后重试 |
十三、总结
MySQL 死锁日志不是拿来吓人的,它其实只回答三件事:谁在等锁、谁持有锁、它们为什么等成了一个环。排查时按顺序来:抄出两个事务正在执行的 SQL → 看 WAITING FOR THIS LOCK → 看 table 和 index → 翻译 lock_mode → 找对方 HOLDS THE LOCK(S) → 还原时间线 → 决定是改索引、改顺序、缩事务,还是加重试。
一句话收尾:死锁不是玄学,日志里早把答案写出来了,只是它写得不太像人话。
下一篇准备写:MySQL 死锁后直接重试,为什么可能把订单扣两次?
如果你也在排查 Java 后端线上问题,可以关注我。这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。