Skip to content

死锁排查与案例分析(show engine innodb status)

问题

线上 MySQL 出现死锁,如何快速定位死锁原因?SHOW ENGINE INNODB STATUS 输出中哪些信息最关键?

死锁的本质

死锁(Deadlock)是指两个或多个事务在执行过程中,互相等待对方持有的锁资源,导致所有事务都无法继续推进的状态。MySQL InnoDB 的死锁检测器每秒扫描一次锁等待图,发现环路后立即回滚其中一个事务(通常是 undo 行数最少的事务),释放锁资源。

死锁和锁等待超时是两个不同的概念:

对比项死锁锁等待超时
触发机制InnoDB 死锁检测器主动扫描innodb_lock_wait_timeout 秒数耗尽
处理方式回滚代价较小的事务当前等待事务直接报错
恢复速度秒级自动释放默认 50 秒后才报错
可见性业务代码中表现为 ER_LOCK_DEADLOCK表现为 ER_LOCK_WAIT_TIMEOUT

使用 SHOW ENGINE INNODB STATUS 排查死锁

定位死锁信息

执行以下命令查看最近一次死锁的详细信息:

sql
SHOW ENGINE INNODB STATUS\G

输出中重点关注 LATEST DETECTED DEADLOCK 段,它包含以下关键信息:

  1. 事务 1 和事务 2 的 SQL 语句:显示每个事务最后执行的 SQL
  2. 持有锁(HOLDS THE LOCK):当前事务已持有的锁资源
  3. 等待锁(WAITING FOR THIS LOCK TO BE GRANTED):当前事务正在等待的锁资源
  4. 回滚的事务:InnoDB 选择回滚哪个事务及其原因

输出示例

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-20 14:32:15 0x7f1234
*** (1) TRANSACTION:
TRANSACTION 20001, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 8, OS thread handle 14000, query id 100 localhost root updating
UPDATE orders SET status = 2 WHERE id = 100

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `db`.`orders`
trx id 20001 lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `db`.`orders`
trx id 20001 lock_mode X locks rec but not gap

*** (2) TRANSACTION:
TRANSACTION 20002, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 9, OS thread handle 14001, query id 101 localhost root updating
UPDATE orders SET status = 3 WHERE id = 200

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `db`.`orders`
trx id 20002 lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `db`.`orders`
trx id 20002 lock_mode X locks rec but not gap

*** WE ROLL BACK TRANSACTION (2)

从这个例子可以清晰看出典型的 AB-BA 死锁

  • 事务 1 持有 id=100 的锁,等待 id=200 的锁
  • 事务 2 持有 id=200 的锁,等待 id=100 的锁
  • InnoDB 选择回滚事务 2(undo 行数较少的那一个)

常见死锁场景与案例分析

场景一:AB-BA 循环等待

这是最常见的死锁模式,两个事务以相反顺序访问同一组资源。

sql
-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 锁 id=1
-- 此时事务 B 执行了第 2 条
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 等待 id=2 的锁
COMMIT;

-- 事务 B
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 2;  -- 锁 id=2
-- 此时事务 A 持有 id=1,等待 id=2
-- 事务 B 持有 id=2,等待 id=1 → 死锁
UPDATE account SET balance = balance + 100 WHERE id = 1;  -- 等待 id=1 的锁
COMMIT;

解法:所有事务按照固定的顺序访问资源(例如按主键从小到大排序后再操作)。

场景二:间隙锁导致的死锁

RR 隔离级别下,间隙锁(Gap Lock)经常导致隐式死锁:

sql
-- 表 t 中现有数据:id=1, id=5, id=10
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id > 5 FOR UPDATE;  -- 锁住 (5,10] 和 (10, +∞) 的间隙

-- 事务 B
BEGIN;
INSERT INTO t (id, name) VALUES (7, 'hello');  -- 插入意图锁等待间隙锁释放
-- 事务 A 此时执行:
INSERT INTO t (id, name) VALUES (12, 'world'); -- 等待事务 B 的插入意向锁
-- 事务 A 等待事务 B,事务 B 等待事务 A → 死锁

解法:降低隔离级别至 RC(不产生间隙锁),或缩小锁范围。

场景三:不同索引导致的锁顺序不一致

sql
-- 表 t 有索引 idx_a (a) 和 idx_b (b)
-- 事务 A 通过索引 a 访问行
BEGIN;
UPDATE t SET b = 2 WHERE a = 1;  -- 通过 idx_a 锁行

-- 事务 B 通过索引 b 访问同一行
BEGIN;
UPDATE t SET a = 2 WHERE b = 1;  -- 通过 idx_b 锁行
-- 两个事务持有了不同索引上的锁,继续操作时形成死锁

解法:确保所有更新操作通过同一个索引访问行,或者统一使用主键。

如何开启死锁日志

MySQL 5.6+ 提供了 innodb_print_all_deadlocks 参数,开启后所有死锁信息都会记录到 MySQL 错误日志中,而不仅仅是 SHOW ENGINE INNODB STATUS 中保留最近一次死锁。

sql
SET GLOBAL innodb_print_all_deadlocks = ON;

生产环境强烈建议开启此参数,否则你可能只看到最近一次死锁,而线上存在多个死锁模式时,排查会非常困难。

死锁避免的实战建议

1. 固定访问顺序

在业务代码中,对所有涉及资源访问的操作,强制按主键或唯一键排序后再执行:

java
// 错误做法:可能导致 AB-BA 死锁
List<Long> ids = getOrderIds();
for (Long id : ids) {
    updateOrder(id);  // 并发时可能导致死锁
}

// 正确做法:先排序再操作
List<Long> ids = getOrderIds();
Collections.sort(ids);  // 固定访问顺序
for (Long id : ids) {
    updateOrder(id);
}

2. 降低隔离级别

如果业务可以容忍不可重复读,将隔离级别从 RR 降为 RC:

sql
SET GLOBAL transaction_isolation = 'READ-COMMITTED';

RC 下没有间隙锁,死锁概率大幅降低。但要注意:RC 下必须使用 binlog_format=ROW

3. 调整死锁检测参数

在高并发场景下,死锁检测本身可能成为性能瓶颈。InnoDB 的死锁检测器每秒扫描一次锁等待图,在数千个并发事务的场景下,扫描成本很高。

sql
-- 关闭死锁检测(高并发下可选,但需要配合锁超时兜底)
SET GLOBAL innodb_deadlock_detect = OFF;

关闭死锁检测后,真正的死锁只能通过 innodb_lock_wait_timeout(默认 50 秒)超时来释放,所以需要同时调小超时时间:

sql
SET GLOBAL innodb_lock_wait_timeout = 5;  -- 5 秒超时

4. 缩短事务时间

事务时间越长,持有锁的时间越长,死锁概率越高。优化原则:

  • 不要在事务中执行远程 RPC 调用或网络 IO
  • 把大事务拆成小事务,每次处理固定行数(如 1000 行)就提交
  • 事务中只做必要的操作,能放到事务外的逻辑就别放进来

5. 使用重试机制

死锁不可避免,业务代码必须做好重试:

java
@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3, backoff = @Backoff(delay = 100))
public void updateAccount(Long id, BigDecimal amount) {
    accountMapper.updateBalance(id, amount);
}

Spring 的 @Retryable 注解或手动重试循环都可以。注意重试间隔不要太短,避免死锁解除后立即又撞上另一把锁。

总结

排查工具用途
SHOW ENGINE INNODB STATUS查看最近一次死锁详情
innodb_print_all_deadlocks=ON将所有死锁写入错误日志
performance_schema监控锁等待和事务时间
错误日志查看所有死锁的历史记录

死锁不是 MySQL 的 bug,而是并发事务中的正常现象。排查死锁时,不要只看死锁本身,要看业务代码的访问模式——是否所有事务按固定顺序访问资源、事务是否过长、隔离级别是否合理。死锁的终极解法不是"消灭死锁",而是"死锁发生后业务能自动重试恢复"。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。