我写了个转账接口,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 段,其他段省略):
光看这段输出,新手会一头雾水。别怕——INNODB STATUS 的结构是有规律的,只需要看四个信息。
再查一下两张表确认战况:

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(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:

这不就是经典的互相等待?
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-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 次不能重复扣款。业务层要保证 fromId、toId、amount 三者唯一确定一笔转账(加流水号去重)。
方案对比
| 方案 | 效果 | 代价 | 推荐度 |
|---|---|---|---|
| 统一更新顺序 | 根治 | 代码改造量小 | ⭐⭐⭐⭐⭐ |
| 缩短事务时间 | 大幅降低概率 | 只需重构事务边界 | ⭐⭐⭐⭐ |
| 应用层重试 | 兜底 | 需保证幂等 | ⭐⭐⭐⭐⭐ |
推荐:三个一起上。方案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 如何把整张表锁住,间接让所有查询排队超时。