Skip to content

再谈 MySQL 5.7 复制 DDL 死锁问题深度分析

背景

之所以愿意再谈这个死锁 bug,是因为在前段时间这个 bug 又发生了。虽然笔者本身已经修复了这个 bug,并且打上了新版本的 tag,但因为有一个站点没有发版,仍然使用旧版本的 gh-ost 再次复现了这个死锁问题,240个从库锁住了80多个。因此,笔者重新审视了这个 bug,更详细研究了死锁的原理,这里想来做一个更详尽的分析。

死锁产生的必要条件

死锁问题的产生有几个重要前提:

  1. MySQL 版本为 5.7,在 lock 表后在不同会话中并发执行 drop 这个 DDL 操作
  2. 存在主从复制
  3. 从库设置多个并行复制线程,例:slave_parallel_workers=N(N>1)
  4. 从库设置并行复制的标准slave_parallel_type=LOGICAL_CLOCK
  5. 从库设置回放 binlog 顺序与主库一致slave_preserve_commit_order = ON

从库并行复制行为分析

在这个配置下,从库会有一个行为模板,关键点有以下:

  • last_committed 一致的时候,并行执行
  • 在主库上先执行的操作在从库上也要先执行

死锁产生的具体机制

我们以两个线程就触发这个死锁的情况为例来说明:

假设有两个会话 s1s2,操作表 a

t1 时刻:

  • s1 上对表 a 上锁 LOCK TABLES,拿到表锁+MDL
  • s2 无操作

t2 时刻:

  • s1 执行 DROP TABLE a,释放 MDL
  • s2 无操作

t3 时刻:

  • s1 无操作
  • s2 执行 DROP TABLE a,因为 MDL 锁已经释放,所以这里直接执行

关键问题:Group Commit 分组错误

group commit 阶段,依赖跟踪器回看执行阶段的锁等待关系,由于 LOCK TABLES + DROP 路径提前释放了 MDL,导致第二个 DROP 在执行时没有等待前一个事务,所以它"看起来"和前一个 DROP 没有依赖关系,算法就把两个事务错误地划到了同一组,设置了:

  • s1 上的 drop 操作 last_committed=9 sequence_number=10
  • s2last_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 操作之间的正确依赖关系能够被准确识别,从根本上解决了这个问题。