我写了个转账接口,MySQL 把我一个事务杀了——SHOW ENGINE INNODB STATUS 全拆解

场景:两个并发转账事务,各更新两条记录,应用突然报 DeadlockFoundWhenCommitting 路径:SHOW ENGINE INNODB STATUS → LATEST DETECTED DEADLOCK → 事务1/2 锁持有与等待 → 修复事务顺序

上篇讲了翻到 1000 页慢了 200 倍——OFFSET 的扫了再扔。这篇从"慢"到"死",看另一种更隐蔽的 MySQL 陷阱:死锁。

-- 事务1:从 id=1 转账到 id=2
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 事务2:从 id=2 转账到 id=1(同时执行)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 2;
UPDATE account SET balance = balance + 100 WHERE id = 1;
COMMIT;

——两个事务各更新了两行数据,MySQL 却把其中一个杀了。

以下分析基于 MySQL 8.0.32。

【现象】交叉更新:两行数据的锁战

表结构与数据

一张账户表,500 万行数据:

CREATE TABLE account (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  balance DECIMAL(12,2) NOT NULL DEFAULT 0.00,
  version INT NOT NULL DEFAULT 0,
  INDEX idx_user_id (user_id)
) ENGINE=InnoDB;

两条记录:

id user_id balance
1 100 1000.00
2 200 2000.00

Naive 直觉

大多数开发者的第一反应:"死锁了?重启应用就行。InnoDB 会自动回滚一个事务,数据不会丢。"

这个想法是危险的。回滚的事务意味着这次转账没有执行——如果业务层没有重试机制,用户会收到"操作失败"而订单可能已经部分更新了。

真正需要回答的问题不是"怎么办",而是"为什么形成循环等待"

复现并发场景

两个线程同时执行转账:T1 更新 id=1 的同时 T2 更新 id=2——各自持有第一把锁后,再试图获取对方持有的锁。

应用日志:

2026-07-19 14:30:45.678 ERROR  [http-nio-8080-exec-4] c.o.transfer.TransferService  - 
Deadlock found when trying to get lock; try restarting transaction
  nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: 
  Deadlock found when trying to get lock; try restarting transaction

2026-07-19 14:30:45.678 INFO  [http-nio-8080-exec-5] c.o.transfer.TransferService  - Transfer succeeded: id=2 → id=1

事务2成功了,事务1被回滚——但业务层面,用户1发起的转账操作失败了。

死锁现场:SHOW ENGINE INNODB STATUS

第一时间执行:

SHOW ENGINE INNODB STATUS\G

输出(只贴 LATEST DETECTED DEADLOCK 段,其他段省略):

SHOW ENGINE INNODB STATUS 输出 光看这段输出,新手会一头雾水。别怕——INNODB STATUS 的结构是有规律的,只需要看四个信息。

再查一下两张表确认战况:

死锁时 PROCESSLIST 和 data_locks

SHOW PROCESSLIST 看到两个线程都在 Updating 状态,performance_schema.data_locks 显示事务1持有 id=1、等待 id=2,事务2持有 id=2、等待 id=1——循环等待的证据。

【解析】拆解 LATEST DETECTED DEADLOCK

段落结构

LATEST DETECTED DEADLOCK 段由三部分组成:

部分 内容 看什么
*** (1) TRANSACTION 第一个事务 持有锁 + 等待锁
*** (2) TRANSACTION 第二个事务 持有锁 + 等待锁
WE ROLL BACK 被回滚的事务 为什么选它

核心逻辑:一个事务 × 两个状态 = 完整的循环等待图。

事务1 vs 事务2 锁对比

事务1(TRANSACTION 123456)

关键信息逐行拆解:

TRANSACTION 123456, ACTIVE 2 sec starting index read

ACTIVE 2 sec — 这个事务已经活了 2 秒,比事务2(1 秒)长。等一下,回滚选择跟这个数字有关

LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)

LOCK WAIT — 它在等待锁。2 lock structs 表示它已经持有和等待的锁结构共 2 个。"1 row lock(s)" 表示它持有 1 行的锁。

HOLDS THE LOCK(S):

Record lock, heap no 2 -- id=1

持有 id=1 的行锁。

WAITING FOR THIS LOCK TO BE GRANTED:

Record lock, heap no 3 -- id=2

等待 id=2 的行锁。

事务2(TRANSACTION 123457)

ACTIVE 1 sec — 只活了 1 秒

HOLDS THE LOCK(S):

Record lock, heap no 3 -- id=2

持有 id=2 的行锁。

WAITING FOR THIS LOCK TO BE GRANTED:

Record lock, heap no 2 -- id=1

等待 id=1 的行锁。

循环等待图

事务1持有 id=1 等 id=2,事务2持有 id=2 等 id=1:

Wait-For 循环等待

这不就是经典的互相等待?

InnoDB 的 Wait-For Graph 检测到了这个循环,选择了一个事务回滚——不是 InnoDB 没阻止死锁,而是死锁本身就是并发程序的固有风险。InnoDB 能做的不是在死锁发生前阻止它,而是在死锁发生后快速检测并解除它。

为什么选事务2回滚?

*** WE ROLL BACK TRANSACTION (2)

MySQL 选择回滚事务2——回滚成本更低的事务

判断依据: - 事务2 ACTIVE 1 sec(事务1 2 sec)— 活得更短 - 事务2 执行的更新更少 - 回滚事务2 意味着只丢失一个较短的操作,资源释放更快

Wait-For Graph 是 InnoDB 的锁等待图检测算法。它记录事务间的锁等待关系,定期检测是否存在环。检测到环 → 选择环中成本最低的事务回滚 → 释放锁 → 其他事务继续。

为什么形成循环等待?

明明是两条独立的转账请求,各操作各的数据,为什么会互相锁住?

因为两个事务加锁的顺序不一致。

事务 第一把锁 第二把锁
事务1 id=1 → 持有 id=2 → 等待
事务2 id=2 → 持有 id=1 → 等待

如果两个事务都按 id=1 → id=2 的顺序更新,情况会完全不同:

时间 →  事务1                         事务2
  ↓     BEGIN                         BEGIN
  ↓     UPDATE id=1 -- 持有           UPDATE id=1 -- 等事务1释放
  ↓     UPDATE id=2 -- 持有            ...阻塞中...
  ↓     COMMIT 释放锁                 ↑ 事务1释放后获得锁
  ↓                                  UPDATE id=2 -- 成功

事务2虽然等了一会儿,但不会死锁。加锁顺序不一致是死锁最常见的病因。

【路径】🔍 死锁排查工具箱

第一反应不是重启

下次应用报 DeadlockFoundWhenCommitting,按这个顺序走:

场景 第一步 看什么
刚发生死锁 SHOW ENGINE INNODB STATUS\G LATEST DETECTED DEADLOCK 段
高频发生 SET GLOBAL innodb_print_all_deadlocks = ON MySQL 错误日志
偶发 SHOW ENGINE INNODB STATUS 连续执行 对比两次输出
无法重现 开启 general log + 业务重试 死锁时的 SQL 时序

死锁排查三步走

读 INNODB STATUS 的三步法

第一步:找到 LATEST DETECTED DEADLOCK
        不是所有段都要看,只看这一个

第二步:提取两个事务的 HOLD 和 WAIT
        T1 HOLDS: X → T1 WAITS: Y
        T2 HOLDS: Y → T2 WAITS: X
        → 环在哪?

第三步:看 WE ROLL BACK TRANSACTION (N)
        业务层需要为事务N 设置重试

在生产中开启死锁日志

-- 所有死锁写入错误日志(线上必开)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 查看死锁相关变量
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
SHOW VARIABLES LIKE 'innodb_deadlock_detect';

innodb_print_all_deadlocks 默认 OFF——这意味着 MySQL 不会记录每一次死锁的详细信息,只保留最近一次。线上开启后,错误日志里的 LATEST DETECTED DEADLOCK 片段可以作为历史快照对比优化前后的效果。

【重构】三个修复方向

方案1:统一更新顺序(治本)

所有事务内的 UPDATE 按固定顺序执行——比如按主键 id 排序。

-- ❌ 每个事务各自决定先更新谁
-- 事务1: id=1 → id=2
-- 事务2: id=2 → id=1

-- ✅ 统一按 id 升序更新
-- 两个事务都按 id=1 → id=2

在 Java 代码中,最简单的实现:

// ❌ 直接更新
account1.setBalance(account1.getBalance() - amount);
accountRepository.update(account1);
account2.setBalance(account2.getBalance() + amount);
accountRepository.update(account2);

// ✅ 按 id 排序后更新
List<Account> accounts = Arrays.asList(account1, account2);
accounts.sort(Comparator.comparing(Account::getId));
for (Account acc : accounts) {
    accountRepository.update(acc);
}

验证:用同样的场景复现——两个并发事务不再死锁,只有一个会短暂等待。

方案2:缩短事务持续时间(低成本)

死锁不是事务同时更新——是同时更新且锁持有时间重叠。死锁窗口 = 事务从获取第一把锁到获取第二把锁的时间差。窗口越长,撞车的概率越大。

常见延长窗口的操作:

// ❌ 事务内做远程调用
@Transactional
public void transfer(Account from, Account to, BigDecimal amount) {
    update(from, -amount);                // 第一把锁
    boolean ok = notifyRemote(from, to);  // HTTP 调用,锁等在这里
    update(to, +amount);                  // 第二把锁
}

// ✅ 远程调用提前到事务外
public void transfer(Account from, Account to, BigDecimal amount) {
    boolean ok = notifyRemote(from, to);  // 事务外的预检查
    doTransfer(from, to, amount);         // 事务内只有 DB 操作
}

原则:事务里只放 DB 操作。远程调用、消息发送、文件读写全部移出事务。

方案3:应用层重试(兜底)

死锁修不完——你永远无法在代码层面消除所有死锁。但你可以让代码在死锁后自动重试

Spring Boot 集成 @Retryable:

Spring @Retryable 集成

<!-- spring-retry 依赖 -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

手动重试(无 Spring 依赖时):

public void transferWithRetry(Long fromId, Long toId, BigDecimal amount) {
    for (int i = 0; i < 3; i++) {
        try {
            transfer(fromId, toId, amount);
            return;
        } catch (DeadlockLoserDataAccessException e) {
            if (i == 2) throw e;
            Thread.sleep(100L * (1 << i)); // 100ms → 200ms → 400ms
        }
    }
}

死锁重试必须是幂等的——同一个请求重试 3 次不能重复扣款。业务层要保证 fromIdtoIdamount 三者唯一确定一笔转账(加流水号去重)。

方案对比

方案 效果 代价 推荐度
统一更新顺序 根治 代码改造量小 ⭐⭐⭐⭐⭐
缩短事务时间 大幅降低概率 只需重构事务边界 ⭐⭐⭐⭐
应用层重试 兜底 需保证幂等 ⭐⭐⭐⭐⭐

推荐:三个一起上。方案1治本,方案2减少窗口,方案3兜底意外。

方案对比

【标记】🗝 搜出项目里的隐患

代码层:搜事务内的更新顺序

# 搜同时出现多个 UPDATE 的方法
grep -rn "UPDATE.*WHERE" src/ --include="*.java" | grep -B2 "UPDATE" | grep -v "^--$"

# 更精确:搜事务内连续调用的 Repository.update
grep -rn "\.update(" src/ --include="*.java" | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

检测命令

模式识别:哪些代码容易死锁

代码模式 风险
事务方法内多次 update ⚠️ 中
不同方法各操作不同 ID 顺序 ⚠️ 高
事务内穿插远程调用(等服务间隙加长了锁持有时间) ⚠️ 高
循环中逐条 UPDATE ⚠️ 极高

优化前后对比

开启 innodb_print_all_deadlocks 后比较: - 优化前:错误日志每天出现 5+ 次 deadlock 记录 - 优化后:同一场景的 deadlock 记录消失

-- 优化前:SHOW ENGINE INNODB STATUS 有 LATEST DETECTED DEADLOCK 段
-- 优化后:同一场景的 SHOW 输出不再有死锁记录

死锁不可怕——可怕的是不修复事务顺序,只重启应用。"死锁了重启就行"这个想法本身,才是下一个死锁的种子。

下篇我们聊行锁升级为表锁的生产事故排查——一条不带 WHERE 的 UPDATE 如何把整张表锁住,间接让所有查询排队超时。