线上报 Deadlock,怎么从日志里找出是哪两条 SQL 打架?

线上突然报 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 idpage noheap 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_notable biz.order_infolock_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 keyinsertindex 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 的真实踩坑复现。

相关推荐
卷无止境1 小时前
Python数据进阶专题:用声明式验证让数据管道告别"惊喜"
后端·python·数据分析
glartchen1 小时前
win11下使用 WSL2 部署kali 虚拟机(不踩坑版)
后端
聂二AI落地内参1 小时前
ComfyUI 平台化:能力契约、节点白名单和积分预扣怎么串起来
后端
用户7827110810632 小时前
Spring MVC RESTful 员工管理实战 ——踩坑全记录
后端
SimonKing2 小时前
Spring I18N 底层源码大揭秘:你的 MessageSource 到底是怎么找到配置的?
java·后端·程序员
IT_陈寒2 小时前
Vue的响应式竟然在这常见操作下失效了
前端·人工智能·后端
她说彩礼65万2 小时前
Asp.net core ContenResult
后端·asp.net
枕星而眠3 小时前
Git仓库基础用法
大数据·git·后端·elasticsearch·全文检索
ayqy贾杰3 小时前
Claude Fable 5 提示词泄漏,抓紧学习下
前端·后端·面试