MySQL / InnoDB 存储引擎系列 · 第六篇

两个事务互相等对方,数据库怎么知道该杀掉哪一个?

上一篇提到间隙锁能挡住往一个区间里插数据。这一篇讲另一种更麻烦的情况:两个事务互相等着对方释放锁,谁都不会主动放手——如果不干预,两边会永远卡住。这篇会用真实制造出来的一次死锁,看 InnoDB 怎么发现这个僵局、怎么决定牺牲谁。

不是描述死锁检测"应该"怎么工作——是真的用两个连接互相锁对方需要的行,触发了一次真实死锁,然后把 SHOW ENGINE INNODB STATUS 里的原始取证报告拿出来看。

ERROR 1213
真实触发的 InnoDB 死锁错误码
"latest to join"
源码里牺牲者的真实选择依据

先看真实发生的一次死锁

经典的"交叉加锁"场景:两个事务各自锁住一行,然后都想要对方手里的那一行。

真实实测 -- 会话 A START TRANSACTION; UPDATE accounts SET balance=balance-10 WHERE id=1; -- A 锁住 id=1 -- 会话 B START TRANSACTION; UPDATE accounts SET balance=balance-10 WHERE id=2; -- B 锁住 id=2 -- 会话 A 想要 id=2(被 B 占着)-- 阻塞等待 UPDATE accounts SET balance=balance+10 WHERE id=2; -- 会话 B 想要 id=1(被 A 占着)-- 这一步补上了那个环 UPDATE accounts SET balance=balance+10 WHERE id=1; ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

会话 B 的第二条语句直接收到了错误,而不是继续等下去。InnoDB 检测到了"A 等 B、B 又等 A"这个环,主动选了一个牺牲者,把它的语句连同整个等待都撤销掉。

真实的取证报告

SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 段,完整记录了这次死锁的两个事务分别持有什么、在等什么。

真实实测 ------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 121950, ACTIVE 2 sec starting index read UPDATE accounts SET balance=balance+10 WHERE id=2 *** (1) HOLDS THE LOCK(S): RECORD LOCKS ... trx id 121950 lock_mode X locks rec but not gap -- 持有 id=1 的锁 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... trx id 121950 lock_mode X locks rec but not gap waiting -- 在等 id=2 *** (2) TRANSACTION: TRANSACTION 121951, ACTIVE 2 sec starting index read UPDATE accounts SET balance=balance+10 WHERE id=1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS ... trx id 121951 lock_mode X locks rec but not gap -- 持有 id=2 的锁 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... trx id 121951 lock_mode X locks rec but not gap waiting -- 在等 id=1 *** WE ROLL BACK TRANSACTION (2)

上面截取自本机真实的 SHOW ENGINE INNODB STATUS 输出,去掉了每条锁记录里的物理页偏移量、十六进制字段等噪音细节,保留了"持有什么、等什么、最终撤销谁"这条主线。

事务 (1) 是会话 A,事务 (2) 是会话 B。两边的"HOLDS"和"WAITING FOR"正好交叉成一个环。最后一行"WE ROLL BACK TRANSACTION (2)"——被撤销的是 (2),也就是 B,和终端里 B 收到 ERROR 1213 完全对应。

为什么偏偏是 B

死锁检测靠的是一张"等待图"(wait-for graph):谁在等谁,画成一张有向图,一旦出现环就是死锁。真正有意思的问题是——环上不止一个事务,选哪个撤销?

storage/innobase/lock/lock0wait.cc · mysql-server @ mysql-9.7.1, L705 /** Given the `infos` about transactions ... which form a deadlock cycle, identifies the transaction with the largest `reservation_no`, that is the one which was the latest to join the cycle. */ static size_t lock_wait_find_latest_pos_on_cycle(...)

每个开始等待的事务都会拿到一个递增的编号(reservation_no)。真实规则(基础情形,不考虑更复杂的调度权重):环上编号最大的——也就是最后一个补上这个环的事务——被选为牺牲者。对照我们的实验:A 先发出"等 B"这条边,B 后发出"等 A"这条边——B 的这一步才让环真正闭合,所以 B 的编号更大,B 被撤销。这不是巧合,是源码里写死的规则。

演示:等待图是怎么闭合成一个环的

用上面真实实验的结构做的最小可验证模型。

等待图(wait-for graph)构建与死锁检测
这套算法不是只对两个事务的环有效——额外验证过一个三方循环等待(X 等 Y、Y 等 Z、Z 等 X)的场景,同样正确识别出环、并选中"最后补上这条边"的 Z 作为牺牲者。演示动画之外用独立的暴力算法(完全不同的实现路径,单纯沿着等待关系反复走,看会不会回到起点)交叉验证过所有场景,结果一致。

锁的全貌:不只有行锁

上面看到的都是行级 X 锁。InnoDB 真实的锁类型,在源码的一个 enum 里能看全:

storage/innobase/include/lock0types.h enum lock_mode { LOCK_IS = 0, /* intention shared 意向共享 */ LOCK_IX, /* intention exclusive 意向排他 */ LOCK_S, /* shared 共享锁 */ LOCK_X, /* exclusive 排他锁 */ LOCK_AUTO_INC, /* 表的自增列专用锁 */ };

LOCK_IS/LOCK_IX 是"意向锁",加在级别,不是为了锁住某一行,而是让别的事务不用去逐行扫描就能快速判断"这张表有没有更细粒度的锁"——比如想给整张表加锁之前,先看一眼有没有人已经持有行级 IX 锁,不用真的去数每一行。LOCK_S/LOCK_X 就是我们这次实验里真正冲突的那一对——两边都要 X 锁,互斥,才有了死锁的条件。

行级锁具体锁住"记录本身"还是"记录之间的间隙",还有一层独立的标记(上一篇提到的 LOCK_GAP),和这里的 IS/IX/S/X 是两个维度的组合。

参考与说明

  • 死锁实验在本机真实运行的 MySQL 9.7.1 上完成:两个通过命名管道(FIFO)保持的长连接会话,交叉更新两行数据触发了真实的死锁检测,终端错误信息和 SHOW ENGINE INNODB STATUS 报告均为真实抓取,只做了格式整理和噪音字段(物理页偏移、十六进制记录内容)的裁剪,没有改动实质内容。
  • 源码引用(lock0wait.cclock_wait_find_latest_pos_on_cyclelock0lock.ccDeadlock_notifierlock0types.hlock_mode 枚举)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致。
  • 真实的调度权重(schedule_weight)机制比本文描述的"最后加入者"规则更复杂——它还会考虑"有多少别的事务在等你",给长期阻塞别人的事务更高的被牺牲优先级。本文只验证并演示了基础情形(权重相等时的 tie-breaking 规则),没有覆盖权重调度的完整逻辑。
  • 没有涉及:意向锁与行锁之间具体的兼容性矩阵、AUTO_INC 锁的特殊调度模式、锁的实际内存结构(lock_t)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电