数据库

MySQL 死锁排查:从 SHOW ENGINE INNODB STATUS 到根因

ERROR 1213 (40001) 突然打断线上事务,却不知道两条 SQL 到底怎么互相等死了。本文给出一条完整链路:用 SHOW ENGINE INNODB STATUS 读懂死锁段落的每一行、把持锁者与等待者对上号、区分记录锁与间隙锁的影响,并给出按主键顺序改写批量更新、以及用 innodb_print_all_deadlock 长期留证的预防策略。

作者:巧匠团队·11 分钟阅读·更新于 2026-09-30

死锁是怎么发生的:等待关系图

死锁的本质是一张有向图里出现了环。事务 A 持有锁 L1 并等待 L2,事务 B 持有 L2 并等待 L1,于是 A 等 B、B 等 A,谁也无法推进。InnoDB 检测到环后会立刻回滚**代价较小**的那个事务(通常是改动行数少的),并向客户端抛出 `ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction`。

值得注意的是,InnoDB 的死锁检测是主动的、实时的,不需要等 `innodb_lock_wait_timeout` 超时。所以死锁带来的表现是「瞬间失败」,而普通的锁等待超时是「卡 50 秒然后失败」。如果你看到的是等待很久才失败,那多半是锁等待超时而不是死锁,排查方向完全不同——先去看有没有长事务持有锁。

还有第三种情况:两个事务虽然有环,但恰好在检测发生的瞬间其中一个已经完成,从而没有被判定为死锁,最终双双成功。这种「侥幸逃过」不会留下任何错误,只表现为偶发的锁等待变长。如果你的应用出现随机变慢却抓不到任何死锁日志,就要想到这一点。

理解这张等待关系图是读懂死锁报告的前提。报告里列出的每一对 `WAITING FOR` / `HOLDS THE LOCK`,就是图里的一条边;把两段拼起来就能还原环。接下来的两节就是教你逐行读出这张图。

SHOW ENGINE INNODB STATUS\G
# 关键段落关键词:LATEST DETECTED DEADLOCK
# 该命令输出的是「最近一次」死锁,历史死锁不在这里

读懂死锁报告:逐行拆解

`SHOW ENGINE INNODB STATUS\G` 里 `LATEST DETECTED DEADLOCK` 段落包含若干个 `*** (1) TRANSACTION:` 块,每块代表环上的一个事务。第一行是事务 ID 与在 MySQL 内部线程中的位置;紧接着的 `TRANSACTION N ACTIVE N statement executing` 说明它此刻正在执行哪条 SQL。

随后是该事务持有的锁,形如 `RECORD LOCKS space id 5 page id 11 nbits 72 index PRIMARY of table `db`.`orders` trx id 12345 lock_mode X locks rec but not gap`。这里的关键信息有三个:锁模式(X 表示排他写锁)、`index PRIMARY` 说明它在聚簇索引上、`trx id N` 就是「持有者」的身份标识。

最后是等待的锁,形如 `waiting for X lock on record number 9`。把它与上一个事务持有的 `trx id` 对照,你就得到了「谁在等谁」。把所有事务块两两配对,等候关系图就完整了——环在哪里一目了然。

排查实操建议:不要指望等你手工去跑 `SHOW ENGINE INNODB STATUS`。死锁是瞬时的,等你敲完命令可能已经覆盖成下一次死锁了。正确做法是开启 `innodb_print_all_deadlock = ON`,让 MySQL 把每次死锁都写进错误日志,应用侧配一个日志采集直接告警。

SET GLOBAL innodb_print_all_deadlock = ON;
SET GLOBAL innodb_deadlock_detect = ON;   -- 默认即为 ON
SHOW VARIABLES LIKE "innodb_print_all_deadlock";

间隙锁:为什么没有索引的更新也会死锁

在默认的 `REPEATABLE READ` 隔离级别下,InnoDB 使用**间隙锁**(gap lock)来防止幻读。当一条 UPDATE 的 WHERE 条件走不了索引时,InnoDB 不是「找到就锁」,而是会把扫描范围内的**所有间隙都锁上**。这会让本来互不相干的两个事务,在同一张表的相邻区间上互相等待。

这就是「明明更新的是不同的行,为什么还死锁」的答案。举个例子:表里没有 `idx_status` 索引,事务 A 执行 `UPDATE t SET v=1 WHERE status=0` 锁住了整个扫描区间;事务 B 执行 `UPDATE t SET v=2 WHERE status=0` 想锁另一行,却因为整个区间已被锁而排队。只要两个事务以不同顺序触碰到这段区间,就形成环。

规避手段有两个层面。第一是**加索引**:让 WHERE 条件走唯一索引或普通索引,锁的范围就收敛到具体行而非整个区间。锁的行数越少,并发度越高,这是最根本的优化。第二是**改隔离级别**:如果业务允许,把隔离级别降到 `READ COMMITTED` 可以让 InnoDB 不用间隙锁(除非是唯一性检查或外键约束场景),死锁概率大幅下降,但需评估幻读对业务的影响。

还有一个实践建议:任何 WHERE 条件都用不上索引的 UPDATE 都是危险信号,应该在代码评审阶段就拦下来,而不是等线上报警。可以用 `EXPLAIN` 检查每个高频 UPDATE 的访问类型,`type` 为 `ALL`(全表扫描)就意味着锁扫描全表。

EXPLAIN UPDATE orders SET v = 1 WHERE status = 0;
-- 关注 EXPLAIN 输出中的 type 列:ALL 表示全表扫描(危险)
ALTER TABLE orders ADD INDEX idx_status (status);
SHOW INDEX FROM orders;

按固定顺序访问资源:最有效的改写

死锁的首要成因是**多资源以不同顺序被访问**。事务 A 先更新订单表再更新库存表,事务 B 先更新库存表再更新订单表——只要这两个事务并发执行,就必然存在死锁窗口。这不是概率问题,而是必然问题。

解决方案是把顺序在架构层面固定下来:约定所有多表事务一律**先操作 id 较小的表(或字典序靠前的表)**,再操作另一张。这个约定一旦写进开发规范并在代码评审中检查,就能消灭这一整类死锁。

批量更新尤其要注意。一条 `UPDATE orders SET status=2 WHERE id IN (1,3,5,7,...)` 会按主键顺序逐行加锁,实际是安全的;但如果 WHERE 条件是通过子查询或 JOIN 得来的,实际加锁顺序取决于执行计划,**不保证**与 IN 列表顺序一致。稳妥做法是在子查询里显式加 `ORDER BY` 强制排序。

另一个有效模式是**缩短事务**:事务持有锁的时间越短,形成环的机会越少。把大事务拆成多个小事务、避免在事务中间做网络调用或等待用户输入,能显著降低死锁率。核心原则是:先做便宜的校验和外部调用,再开启数据库事务。

UPDATE orders o JOIN (
    SELECT id FROM orders WHERE account_id = ? ORDER BY id
) AS t ON o.id = t.id
SET o.status = 2;
-- 子查询里显式 ORDER BY,保证加锁顺序确定

预防策略、重试与长期留证

死锁在设计上是**正常现象**,无法彻底消除,只能预防 + 快速恢复。MySQL 对死锁使用的是 1213 错误码,而 1213 的语义是「事务未提交,可安全重试整个事务」。因此应用层必须有死锁重试逻辑——这是数据库层无法替你做的部分。

重试要有上限与退避。典型实现是:捕获 1213 后整体重放事务,最多 3 次,每次之间做指数退避加随机抖动(jitter)。随机抖动很重要,因为两个事务如果同步重试,会再次同步碰撞。注意重试必须是**整个事务**的重放,不能只重试失败的那一条 SQL——回滚后事务上下文已经变了。

长期留证靠配置。`innodb_print_all_deadlock = ON` 会把每次死锁写入错误日志;同时用 Performance Schema 可以事后统计死锁频率,判断改动是否真的有效。排查长期卡顿则关注长事务:`SHOW ENGINE INNODB STATUS` 的 `TRANSACTIONS` 段会列出运行超过 0 秒的活跃事务,是锁等待超时的常见真凶。

最后建立监控闭环。把死锁计数做成图表按小时观察,配合发布节奏对齐——如果某次发布后死锁数陡增,基本可以锁定是哪个改动引入的。用 `performance_schema.data_locks` 与 `data_lock_waits` 也可以做实时锁等待分析,无需依赖应用侧埋点。

SET GLOBAL innodb_print_all_deadlock = ON;

SELECT count FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_lock_waits LIMIT 10;
SELECT ENGINE_TRANSACTION_ID, trx_started FROM information_schema.innodb_trx ORDER BY trx_started;

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。