Appearance
再谈 MySQL 5.7 复制 DDL 死锁问题深度分析
背景
之所以愿意再谈这个死锁 bug,是因为在前段时间这个 bug 又发生了。虽然笔者本身已经修复了这个 bug,并且打上了新版本的 tag,但因为有一个站点没有发版,仍然使用旧版本的 gh-ost 再次复现了这个死锁问题,240个从库锁住了80多个。因此,笔者重新审视了这个 bug,更详细研究了死锁的原理,这里想来做一个更详尽的分析。
死锁产生的必要条件
死锁问题的产生有几个重要前提:
- MySQL 版本为 5.7,在 lock 表后在不同会话中并发执行 drop 这个 DDL 操作
- 存在主从复制
- 从库设置多个并行复制线程,例:
slave_parallel_workers=N(N>1) - 从库设置并行复制的标准:
slave_parallel_type=LOGICAL_CLOCK - 从库设置回放 binlog 顺序与主库一致:
slave_preserve_commit_order = ON
从库并行复制行为分析
在这个配置下,从库会有一个行为模板,关键点有以下:
- 当
last_committed一致的时候,并行执行 - 在主库上先执行的操作在从库上也要先执行
死锁产生的具体机制
我们以两个线程就触发这个死锁的情况为例来说明:
假设有两个会话 s1 和 s2,操作表 a:
t1 时刻:
s1上对表a上锁LOCK TABLES,拿到表锁+MDLs2无操作
t2 时刻:
s1执行DROP TABLE a,释放 MDLs2无操作
t3 时刻:
s1无操作s2执行DROP TABLE a,因为 MDL 锁已经释放,所以这里直接执行
关键问题:Group Commit 分组错误
group commit 阶段,依赖跟踪器回看执行阶段的锁等待关系,由于 LOCK TABLES + DROP 路径提前释放了 MDL,导致第二个 DROP 在执行时没有等待前一个事务,所以它"看起来"和前一个 DROP 没有依赖关系,算法就把两个事务错误地划到了同一组,设置了:
s1上的 drop 操作last_committed=9 sequence_number=10s2的last_committed=9 sequence_number=11
从库并行执行与死锁形成
写入 binlog,从库读取 binlog,发现 last_committed 一致,让执行线程并行执行。(在从库上,同一组(last_committed 相同)的事务被认为彼此无依赖,可并行回放;不同组之间则按 sequence_number 顺序串行提交。)
两个 drop 同时去抢占表 a 的 MDL 锁:
sequence_number=11的 drop 先拿到了锁,持有这个锁直到 drop 完成事务提交sequence_number=10的 drop 也尝试去拿锁,但是这个锁已经被sequence_number=11的 drop 抢到了,所以他陷入了锁等待- 而
sequence_number=11的 drop 因为设置了slave_preserve_commit_order = ON,所以他需要sequence_number=10的 drop 先执行,才能提交
死锁形成:
sequence_number=10等待sequence_number=11释放 MDL 锁sequence_number=11等待sequence_number=10执行后提交事务,释放 MDL 锁
互相等待,从库发生死锁。
Binlog 样例分析
这里可以贴一个实际的样例:
bash
# at 1935
#251210 11:38:44 server id 1 end_log_pos 2000 CRC32 0x45e53263 GTID last_committed=9 sequence_number=10 rbr_only=no original_committed_timestamp=0 immediate_commit_timestamp=0 transaction_length=0
# original_commit_timestamp=0 (1970-01-01 08:00:00.000000 CST)
# immediate_commit_timestamp=0 (1970-01-01 08:00:00.000000 CST)
/*!80001 SET @@session.original_commit_timestamp=0*//*!*/;
/*!80014 SET @@session.original_server_version=0*//*!*/;
/*!80014 SET @@session.immediate_server_version=0*//*!*/;
SET @@SESSION.GTID_NEXT= '86279be3-d579-11f0-9315-9a86fbdfbc83:21'/*!*/;
# at 2000
#251210 11:38:44 server id 1 end_log_pos 2139 CRC32 0x6fcd6bdd Query thread_id=4 exec_time=0 error_code=0
SET TIMESTAMP=1765337924/*!*/;
DROP TABLE IF EXISTS `drop_if_exists_1` /* generated by server */
/*!*/;
# at 2139
#251210 11:38:44 server id 1 end_log_pos 2204 CRC32 0x282d35e6 GTID last_committed=9 sequence_number=11 rbr_only=no original_committed_timestamp=0 immediate_commit_timestamp=0 transaction_length=0
# original_commit_timestamp=0 (1970-01-01 08:00:00.000000 CST)
# immediate_commit_timestamp=0 (1970-01-01 08:00:00.000000 CST)
/*!80001 SET @@session.original_commit_timestamp=0*//*!*/;
/*!80014 SET @@session.original_server_version=0*//*!*/;
/*!80014 SET @@session.immediate_server_version=0*//*!*/;
SET @@SESSION.GTID_NEXT= '86279be3-d579-11f0-9315-9a86fbdfbc83:22'/*!*/;
# at 2204
#251210 11:38:44 server id 1 end_log_pos 2343 CRC32 0x0e1480f7 Query thread_id=5 exec_time=0 error_code=0
SET TIMESTAMP=1765337924/*!*/;
SET @@session.pseudo_thread_id=5/*!*/;
DROP TABLE IF EXISTS `drop_if_exists_1` /* generated by server */
/*!*/;为什么直接并发执行 DROP 不会触发死锁?
可能这里就会有一个疑问,为什么在 LOCK TABLES 的情况下会触发,而直接并发执行 DROP 不会触发呢?
因为如果 LOCK TABLES + DDL 的情况下会走一条特殊的路线分支,在这条路径中,在真正进入事务提交之前,就把表 MDL 的锁释放掉了。而普通的并发执行 DROP 会发现 MDL 锁已经被持有了,不会分配到同一个复制组。
Bug 复现
有关 bug 的复现可以参考这个仓库:ghost-ddl
MySQL 新版本的修复方案
在比 5.7 更新的版本(例如 8.0)中,这个恶性 bug 得到了修复。
MySQL 内部通过引入 TABLESPACE 级别的元数据锁(MDL) 来约束 DDL 的执行顺序:
- 即使在某些路径下表级 MDL 被较早释放
- TABLESPACE 级 MDL 仍然会在事务整个生命周期内被持有
这样第二个 DROP 在执行阶段仍会被前一个 DROP 阻塞,依赖跟踪器能正确识别二者的先后关系,不会再把它们错误地划入同一复制组,从而避免从库并行复制死锁。
总结
这个 bug 的根本原因是 MySQL 5.7 在 LOCK TABLES + DROP 的特殊路径下提前释放了 MDL 锁,导致主库的依赖跟踪器无法正确识别事务间的真实依赖关系,从而将本应串行执行的两个 DROP 操作错误地分到了同一并行复制组。
在新版本中,通过引入 TABLESPACE 级别的 MDL 锁机制,确保了 DDL 操作之间的正确依赖关系能够被准确识别,从根本上解决了这个问题。
