整个 SQL 流程如下:
我们按照顺序执行分别在 MySQL 8.0.17 和 MySQL 8.0.18 执行,可以看到在 8.0.17 版本事务 t2 因为死锁检测而被视为进行了回滚,而 8.0.18 却不会回滚事务 t2.
基于 MySQL 8.0.17
我们基于问题版本 8.0.17 来分析 Bug 的真正原因.
通过表结构我们可以看到整个表有两个索引, PRIMARY INDEX 和 UNIQUE INDEX uk_account. 因为是死锁问题, 所以我们要逐条分析 SQL 语句加的 record lock 分别是什么:
t1-1 t1-1 是一条 SELECT FROM UPDATE 的语句, 而
account_id
和是一组唯一索引字段, 所以只需要加一个主键索引的 X record lock 和唯一索引 uk_account 的 X record lock.t2 t2 语句与 t1-1 相同, 加锁一致,也是一个主键索引的 X record lock 和 唯一索引 uk_account 的 X record lock.
t1-2 t1-2 注意 t1-2 的查询条件只有where account_id = ‘1’, 这与 t1-1 的查询条件是不同的, 所以在 RR 隔离级别下,为了避免出现可能的幻读, 这需要加一个 Next-key lock, 另外需要对 record (2,’2’,1,100,1) 加一个 GAP lock, 防止在此之前的插入造成幻读.
通过show engine innodb status\G
我们可以看到当前的事务的锁持有信息, 事务 t1 分别执行 t1-1 和 t1-2 语句后持有的锁分别有:
- 一个主键索引的 X record lock.
- 一个 UNIQUE INDEX 的 X record lock.
- 一个 Next-key record lock, InnoDB 为了明确 Next-key lock 和普通的 record lock 的区别,分别用不同的 mode 来区分:
Next-key lock:
- 一个 GAP record lock
既然 t2 被死锁检测回滚, 我们就需要检查当时是什么锁关系导致了死锁.
官方在 8.0.18 版本对死锁检测进行了优化, 将原先的死锁检测机制MySQL 死锁检测源码分析 交给了 background thread 来处理, 具体的 Patch 链接: . 具体的思路是将当前事务系统的 lock 信息打一份快照, 由这份快照判断是否存在回环, 假如存在死锁即唤醒等待事务.
而在 8.0.17 版本依然采用旧的死锁检测方法, 具体细节可以参考这篇文章: MySQL 死锁检测源码分析: 每次申请 lock 失败进入 wait 状态后触发一下死锁检测, 所以我们通过 gdb 调试的方法来梳理当时的锁依赖关系, 当我们执行完成 t1-1, 继而执行 t2 后, 事务 t2 进入了 wait 状态,当执行 t1-2 后 t2 回滚,说明触发 t2 回滚的死锁检测是由 t1-2 发起的, 我们 break 在死锁检测的路径上,然后 print 整个锁信息 (代码基于 8.0.17):
我们设置断点在死锁检测的路径上,因为可以明确是 t1-2 的死锁检测触发了 t2 的回滚,所以我们可以明确哪次 break 是我们想要的断点位置.
通过上述分析我们可以得出结论 t1 事务的 t1-2 语句触发了死锁检测,选择的 victim_trx 是事务 t2, 我们需要明确以下几个问题:
- 发起死锁检测的原因是因为事务 t1 无法立即获得 X record lock.
- 事务 t1 认为可能会发生的死锁原因是因为在整个 lock 的等待关系中存在一个环, 即 t1 不 commit 提交事务, t2 事务也无法获取 X record lock, 从而导致 t1-2 的 UPDATE 语句也无法获得 X record lock 组成 Next-key record lock, 即使 t1 已经持有了 X record lock.
根据 Bug ID, 可以通过 Github 的 MySQL 提交记录来查找这个 Patch:
Bug #23755664 DEADLOCK WITH 3 CONCURRENT DELETES BY UNIQUE KEY
PROBLEM: A deadlock was possible when a transaction tried to “upgrade” an already held Record Lock to Next Key Lock.
SOLUTION: This patch is based on observations that: (1) a Next Key Lock is equivalent to Record Lock combined with Gap Lock (2) a GAP Lock never has to wait for any other lock In case we request a Next Key Lock, we check if we already own a Record Lock of equal or stronger mode, and if so, then we either upgrade it to Next Key Lock, or if it is not possible (because the single lock_t struct is shared by more than one row) we change the requested lock type to GAP Lock, which we either already have, or can be granted immediately. (I don’t consider Insert Intention Locks a Gap Lock in above statements).
Reviewed-by: Debarun Banerjee RB:19879
经过验证确实是这个 Patch 修复了这个死锁的问题.
这个 Patch 具体的原理是当尝试获取 Next-key record lock 时,不再与旧的逻辑一样,旧的逻辑是先直接尝试申请 Next-key lock, 现在改为先判断当前 trx 是否持有 X record lock, 假如持有就复用这个 X record lock, 从而直接申请 GAP lock, 以达到 Next-key Lock 的效果.
所以在我们上面的例子中,申请 Next-key record lock 时跳过申请 X record lock, 就不会进入等待队列,也不会产生死锁的回环.