真实实测-- 会话 ASTART TRANSACTION;
UPDATE accounts SET balance=balance-10 WHERE id=1;-- A 锁住 id=1-- 会话 BSTART 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 完全对应。
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 作为牺牲者。演示动画之外用独立的暴力算法(完全不同的实现路径,单纯沿着等待关系反复走,看会不会回到起点)交叉验证过所有场景,结果一致。
LOCK_IS/LOCK_IX 是"意向锁",加在表级别,不是为了锁住某一行,而是让别的事务不用去逐行扫描就能快速判断"这张表有没有更细粒度的锁"——比如想给整张表加锁之前,先看一眼有没有人已经持有行级 IX 锁,不用真的去数每一行。LOCK_S/LOCK_X 就是我们这次实验里真正冲突的那一对——两边都要 X 锁,互斥,才有了死锁的条件。
死锁实验在本机真实运行的 MySQL 9.7.1 上完成:两个通过命名管道(FIFO)保持的长连接会话,交叉更新两行数据触发了真实的死锁检测,终端错误信息和 SHOW ENGINE INNODB STATUS 报告均为真实抓取,只做了格式整理和噪音字段(物理页偏移、十六进制记录内容)的裁剪,没有改动实质内容。