Appearance
诡异的 DROP 死锁
在了解故事背景之前要先介绍几个东西。
DDL、DCL、DML
DDL(Data Definition Language)
DDL 是定义或者修改数据结构的语句。例如:
CREATE TABLE
ALTER TABLE
DROP TABLE
DCL(Data Control Language)
DCL 是控制数据访问权限和事务的语句。例如:
GRANT
REVOKE
COMMIT
ROLLBACK
DML(Data Manipulation Language)
DML 是操作数据的语句。例如:
INSERT
UPDATE
DELETE
MDL 锁的诞生
因为 MySQL 本身想尽量榨干资源,所以它肯定需要并发,需要锁控制。它内部的锁远远不止行锁、表锁这样的,还有各种各样的锁。其中,有一种锁叫做 MDL锁(Metadata Lock),它是针对于 DDL 这种表修改语句的。
MySQL 早期版本的一个著名 bug:如果在查询一个表的过程中,即使是 RR 级别,如果另一个会话当中删除了这个表,那么这两次查询结果就不一致了,不满足可重复读的标准。
MDL 应运而生。你可以理解为在 DDL 语句执行前,先获取 MDL 锁,这样其他会话就不能修改表结构,从而避免了上述 bug。但是今天我们要说的 bug 也是因此诞生的。
sequence_number 的分配逻辑
在理解主从同步的组提交机制之前,需要先了解 sequence_number
是如何分配的。
sequence_number 的分配时机
关键点:sequence_number 是在事务提交过程中,binlog 缓存刷盘时分配的,而不是在事务开始时分配。
具体的分配流程如下:
mysql-server/sql/binlog.cc#L2373-2374
trn_ctx->sequence_number = mysql_bin_log.m_dependency_tracker.step();
这个分配发生在 binlog_cache_data::flush()
方法中,该方法是在事务准备写入 binlog 文件时调用的。
step() 方法的实现
sequence_number
的生成是通过逻辑时钟(Logical_clock)的原子递增实现的:
cpp
int64 Logical_clock::step() {
static_assert(SEQ_UNINIT == 0, "");
DBUG_EXECUTE_IF("logical_clock_step_2", ++state;);
return ++state;
}
这是一个简单的原子递增操作,确保每个事务都获得一个唯一的序列号。
分配的具体时序
- 事务执行阶段:事务在执行 SQL 语句时,
sequence_number
尚未分配,此时为SEQ_UNINIT
(值为 0) - 准备提交阶段:当事务准备提交时,调用
binlog_cache_data::flush()
- 分配时刻:在 flush 方法中,通过
m_dependency_tracker.step()
分配sequence_number
- 依赖计算:紧接着计算
last_committed
,如果是第一次分配则设为sequence_number - 1
cpp
if (trn_ctx->last_committed == SEQ_UNINIT)
trn_ctx->last_committed = trn_ctx->sequence_number - 1;
为什么不在事务开始时分配
这种设计有以下考虑:
- 避免浪费序列号:如果事务回滚,就不会消耗 sequence_number
- 保证顺序性:sequence_number 的分配顺序与事务实际提交到 binlog 的顺序一致
- 简化逻辑:只有真正需要写入 binlog 的事务才会获得序列号
这个设计对于我们要讨论的死锁问题至关重要:两个 DROP 语句即使几乎同时进入 flush 阶段,也会获得不同的 sequence_number(因为是原子递增),真正的问题在于 last_committed 的计算逻辑。
gh-ost
gh-ost 是一个在线的表修改工具,它会通过复制、重命名的方式提供在线修改表结构的能力。
主要流程:
- 创建一个临时的幽灵表
- 数据复制阶段
- 2.1 复制监控点前的行数据到临时表
- 2.2 持续复制监控点后的 binlog 数据到临时表
- 验证数据一致性
- 替换原表
- 删除临时表
MySQL 的主从同步
可能很多人都和我一开始一样,只知道可以主从同步,但其实完全没了解,这就是八股害人的地方了。
在 5.7 之前,主库在处理事务的时候每个事务都需要独立完成 binlog 写入,然后 fsync。为了减少这个刷盘次数,你肯定就需要 IO 聚合成批次。每一个批次是一个组,组内事务的 binlog 写入是串行的,组间是并行的。
第一阶段:Prepare
这些线程会竞争一个 binlog 提交的锁,获取到锁的线程将 binlog 从内存写入到磁盘,但不进行 fsync。
在这个过程中会生成一个 sequence_number
,它是一个自增的序列号,用来标识组内事务的顺序。
第二阶段:Commit
一整个组进行一次 fsync,将 binlog 写入到磁盘。更新一个名为 last_committed
的变量,它是一个组提交的 ID。
第三阶段:Slave 并行复制
从库提供并行复制的能力,如果last_committed
事务相同,同时执行。
并行复制的核心判断逻辑:
相同 last_committed 的事务可以并行执行
- 如果多个事务有相同的
last_committed
值,说明它们在主库是并行提交的 - 从库认为这些事务之间没有依赖关系,可以并行复制
- 如果多个事务有相同的
sequence_number 用于确定执行顺序
- 即使可以并行执行,
sequence_number
小的事务仍需要先开始 - 这保证了事务的逻辑顺序不会完全颠倒
- 即使可以并行执行,
依赖关系的传递
- 如果事务 B 的
last_committed
等于事务 A 的sequence_number
- 说明事务 B 必须等待事务 A 完成后才能开始
- 如果事务 B 的
具体的调度算法:
cpp
ptr_group->sequence_number = sequence_number =
static_cast<Gtid_log_event *>(ev)->sequence_number;
ptr_group->last_committed = last_committed =
static_cast<Gtid_log_event *>(ev)->last_committed;
从库的多线程复制协调器会根据这些值来决定:
- 哪些事务可以分配给同一个工作线程池
- 哪些事务必须等待其依赖的事务完成
通过这个方式,每一个具有相同 last_committed
的事务组就是一个逻辑上可以被并行处理的集合。
事故发生
在正常流程当中,主库执行 DROP a
,再执行 DROP a
。由于在 DROP 的过程中有 MDL 锁进行对元数据唯一的修改,导致两个操作是串行执行的,存在等待关系,第二个 DROP 会等待第一个操作,然后获得 MDL 锁。所以肯定是被放到两个不同的复制组当中,它们肯定会被串行执行。
但是有一个很诡异的情况。假设我们使用如下语句:
sql
LOCK TABLES a WRITE, b WRITE;
锁住两张表,我们会获得一个 MDL 锁。
然后我们在会话1当中,执行:
sql
DROP TABLE a;
关键问题:这个时候它会直接释放所有的 MDL 锁!
这是 MySQL MDL 锁的一个设计特点:当执行 DROP TABLE
时,会释放当前会话持有的所有相关 MDL 锁,而不仅仅是针对被删除表的锁。
在会话2当中,我们同样执行:
sql
DROP TABLE a;
由于在会话1当中释放了 MDL 锁,所以会直接获取这个锁。也就是说,第二个 DROP 动作不需要等待 DROP TABLE a
对于锁的释放,二者被视作处于并行关系。
悲剧由此酿成:错误的并行分组
让我们详细分析真正的问题所在。关键在于 MySQL 5.7 中的组提交逻辑缺陷。
正确的分配流程应该是:
cpp
trn_ctx->sequence_number = mysql_bin_log.m_dependency_tracker.step();
if (trn_ctx->last_committed == SEQ_UNINIT)
trn_ctx->last_committed = trn_ctx->sequence_number - 1;
第一个 DROP 语句的执行:
- 获得 sequence_number = N(通过原子递增)
- 由于 last_committed 初始为 SEQ_UNINIT,设置 last_committed = N-1
- 执行 DROP TABLE a,释放所有 MDL 锁
第二个 DROP 语句的执行:
- 获得 sequence_number = N+1(原子递增,与第一个不同)
- 关键问题:由于没有等待第一个 DROP 完成,last_committed 仍被设置为 (N+1)-1 = N
但是,这里还有更深层的问题!
实际上真正的问题在于组提交的批处理逻辑:
在 MySQL 5.7 的组提交中,如果两个事务在很短时间内连续进入 flush 阶段,并且没有明显的锁冲突,它们可能会被归到同一个提交批次中。在这种情况下:
- 两个 DROP 都获得了不同的 sequence_number(N 和 N+1)
- 但在组提交过程中,后面的事务会被重新标记为与前面事务有相同的 last_committed
- 最终 binlog 记录:
# 第一个 DROP TABLE a
last_committed=N-1 sequence_number=N
# 第二个 DROP TABLE a - 被错误地标记为可并行
last_committed=N-1 sequence_number=N+1 # 错误!应该是 last_committed=N
这种错误分组的根本原因是 MySQL 5.7 只基于 MDL 锁的物理等待关系来判断依赖,没有考虑资源访问的逻辑依赖关系。
从库并发执行导致死锁
在从库复制的过程当中,由于 last_committed
相同,调度器认为这两个事务可以并行执行:
cpp
// 从库解析到相同的 last_committed,分配给并行工作线程
ptr_group->last_committed = last_committed = N-1; // 相同值
死锁产生的具体机制:
当两个工作线程同时执行 DROP TABLE a
时,需要获取 MySQL 内部的多个资源锁,包括:
- 数据字典锁(Dictionary lock)
- 表定义缓存锁(Table definition cache lock)
- 表空间锁(Tablespace lock)
在底层会有一系列的锁需要在执行的过程中获取,然后释放。
假设说 DROP a
一共需要用到 MySQL 内部的多个锁,比如说 x、y:
- 第一个复制线程进来拿到了 x,要去获取 y
- 第二个复制线程获取了 y,要去获取 x
二者就会发生循环等待,造成死锁。
锁排序问题:
你可能会觉得,为什么不先拿到A再拿到B再拿到C这样的排序方案,强制必须拿到前面一个锁再拿后一个锁,来避免这个循环等待的场景。 锁排序(Lock Ordering) 确实是死锁预防的经典方法。如果所有线程都按照相同的顺序获取锁(比如先A锁,再B锁,最后C锁),就不会出现循环等待。
但是在这个 DROP TABLE 的场景下,问题比想象的复杂:
- MySQL 内部锁的复杂性:DROP TABLE 操作涉及数百种不同类型的锁,跨越 SQL 层、存储引擎层、操作系统层
- 动态锁获取顺序:锁的获取顺序依赖于表的具体状态、存储引擎类型、缓存状态等,不是静态固定的
- 性能权衡:强制全局锁排序会严重影响并发性能
MySQL 选择了"死锁检测+回滚"的方式来处理底层死锁,而在复制层面通过依赖跟踪来避免这种冲突场景的发生。
MySQL 错误日志中的典型表现:
[ERROR] Slave SQL: Deadlock found when trying to get lock;
try restarting transaction, Error_code: 1213
[ERROR] Error in Xid_log_event: Commit could not be completed,
'Deadlock found when trying to get lock'
这个时候从库就会因为这两次 DROP 导致无法进入下一步,进入一个超高延时的状态,谁也无法完成 DROP。
MySQL 新版本是如何修复这个问题的
MySQL 8.0 及更新版本通过增强的依赖跟踪器 (Transaction Dependency Tracker) 机制,从根本上解决了这个主库组提交分组错误的问题。
问题的本质回顾
5.7 版本的问题本质在于:主库的组提交分组逻辑有缺陷。
在我们的例子中:
sql
-- 会话1: LOCK TABLES a WRITE, b WRITE;
-- 会话1: DROP TABLE a; -- 这里释放了所有MDL锁
-- 会话2: DROP TABLE a; -- 不需要等待,直接获取锁
由于第二个 DROP 不需要等待第一个 DROP 释放锁,主库误认为它们可以并行执行,给了它们相同的 last_committed
,导致从库并行执行时发生死锁。
新版本的核心修复:智能依赖跟踪
旧版本(MySQL 5.7)的分配逻辑:
- 只看当前锁状态:判断
last_committed
时,只检查"现在是否持有锁" - 忽略历史访问:不会检查之前是否有事务访问过相同的资源
- 简单粗暴的判断:如果当前没有锁冲突,就认为可以并行执行,分配相同的
last_committed
在我们的例子中:
- 第一个 DROP 执行后释放了所有锁
- 第二个 DROP 检查时发现"当前没有锁冲突"
- 错误地被分配了相同的
last_committed
新版本(MySQL 8.0+)的改进逻辑:
- 资源访问历史追踪:会检查"在更小的 sequence_number 时,是否有事务访问过相同资源"
- 逻辑依赖检测:即使当前没有物理锁冲突,也能识别逻辑上的依赖关系
- 智能依赖计算:通过 writeset_history 记录资源访问历史
MySQL 新版本引入了依赖跟踪器,它不再仅仅依赖 MDL 锁的物理等待关系,而是通过多种机制来正确识别事务间的依赖关系。
1. 资源访问历史追踪
新版本的依赖跟踪器会记住哪些事务访问了哪些资源,即使没有物理锁冲突也能识别逻辑依赖:
cpp
class Transaction_dependency_tracker {
public:
void get_dependency(THD *thd, bool parallelization_barrier,
int64 &sequence_number, int64 &commit_parent);
private:
Commit_order_trx_dependency_tracker m_commit_order;
Writeset_trx_dependency_tracker m_writeset;
};
2. 针对我们例子的具体修复逻辑
让我们看看在我们的例子中,新版本是如何工作的:
第一个 DROP TABLE a (会话1):
- 获得 sequence_number = 100
- 由于是第一个操作,last_committed = 99 (前一个已提交事务)
- 关键:依赖跟踪器记录到
writeset_history
中:表 a 被 sequence_number=100 的事务访问过
第二个 DROP TABLE a (会话2):
- 获得 sequence_number = 101
- 新版本的智能检查:cpp
// 检查writeset_history,发现表a在sequence_number=100时被访问过 if (hst->second > last_parent && hst->second < sequence_number) last_parent = hst->second; // last_parent = 100 // 设置依赖关系 commit_parent = std::min(last_parent, commit_parent); // = 100
- 结果:last_committed = 100(而不是错误的99)
这样,即使物理上没有锁冲突,从库也会看到:
- 第一个 DROP: last_committed=99, sequence_number=100
- 第二个 DROP: last_committed=100, sequence_number=101
从库识别出依赖关系,串行执行这两个 DROP,避免了死锁
3. 核心代码实现
主库在写 binlog 时的依赖计算:
cpp
int64 sequence_number, last_committed;
/* Generate logical timestamps for MTS */
m_dependency_tracker.get_dependency(thd, parallelization_barrier,
sequence_number, last_committed);
依赖跟踪器的核心逻辑:
cpp
void Transaction_dependency_tracker::get_dependency(
THD *thd, bool parallelization_barrier, int64 &sequence_number,
int64 &commit_parent) {
// 首先通过提交顺序计算基础依赖
m_commit_order.get_dependency(thd, parallelization_barrier, sequence_number,
commit_parent);
// 然后通过写集合进一步优化依赖关系
m_writeset.get_dependency(thd, sequence_number, commit_parent);
}
4. 两层保护机制
新版本实际上有两层保护:
第一层 - 提交顺序依赖跟踪:
- 跟踪资源访问历史,确保对同一资源的操作有正确依赖关系
- 即使没有锁等待,也能识别逻辑依赖
第二层 - 写集合依赖优化:
- 对于 DML 操作,通过行级写集合进一步优化并行度
- 对于 DDL,保持保守的依赖关系
修复效果总结
通过这个智能依赖跟踪机制:
- 解决了根本问题:主库能够正确识别事务间的依赖关系,不再仅依赖物理锁等待
- 保持高性能:对于真正无冲突的事务,仍然可以并行执行
- 向后兼容:不影响现有应用的使用方式
在我们的 LOCK TABLES
+ DROP TABLE
例子中,新版本主库会正确地将两个 DROP 标记为有依赖关系,从库串行执行,彻底避免了死锁问题。
解决方案
因为这个场景会出现在使用 gh-ost 进行在线修改表的场景,在机理上就是重复执行了 DROP 操作。因为ghost表也就是新建出来要替换原表的表和原表要进行drop操作,所以这个情况是非常非常容易发生的。
治标不治本的解决方案是在ghost执行DROP操作的时候加入 sync.Once
字段,确保 DROP 操作整个流程当中只会执行一次。
但是总的来说,由于受到 5.7 版本的桎梏,所以只能修改 gh-ost。这是个治标不治本的办法,最好的办法还是更新 MySQL 版本,如果条件允许。