背景
在使用 TiDB 版本 v6.5.3 的环境中,用户尝试中止一个正在进行的 ADD INDEX 操作时,尽管已成功执行 ADMIN CANCEL DDL JOBS 命令,并且在 mysql.tidb_mdl_view 表中未再观察到 MDL 锁的存在,但值得注意的是,DDL 任务的状态依然保持为 'cancelling',同时 tidb 日志有记录着该 ADD INDEX 操作正被其他事务阻塞的情况。用户希望了解这样的行为现象是否符合预期 ?
######最小复现步骤
#建表
use test;
create table job (
id bigint not null primary key,
job_state int
);
insert job select 1,66;
#假设 session A 长时间不提交
begin;
SELECT * FROM job WHERE id = 1;
#Session B
alter table job add INDEX idx_job_state(job_state);
#MDL查到锁
MySQL [test]> SELECT * FROM mysql.tidb_mdl_view \G
*************************** 1. row *************************** JOB_ID: 89 DB_NAME: test TABLE_NAME: job QUERY: alter table job add INDEX idx_job_state(job_state) SESSION_ID: 7213735261140681119 TxnStart: 05-21 16:19:56.492(449912398542798849)
SQL_DIGESTS: ["begin","select * from job where id = ?"]
1 row in set (0.01 sec)
#此时 cancel add index 语句,显示执行成功,且查不到 MDL。
MySQL [test]> ADMIN CANCEL DDL JOBS 89;
+--------+------------+
| JOB_ID | RESULT |
+--------+------------+
| 89 | successful |
+--------+------------+
1 row in set (0.01 sec)
mysql> SELECT * FROM mysql.tidb_mdl_view \G
Empty set (0.01 sec)
#通过 amdin show ddl 语句看到正在 cancelling
MySQL [test]> admin show ddl jobs \G
*************************** 1. row *************************** JOB_ID: 89 DB_NAME: test TABLE_NAME: job JOB_TYPE: add index /* ingest */
SCHEMA_STATE: delete only SCHEMA_ID: 2 TABLE_ID: 87 ROW_COUNT: 0 CREATE_TIME: 2024-05-21 16:20:02 START_TIME: 2024-05-21 16:20:02 END_TIME: NULL STATE: cancelling
#通过日志看到 add index 语句正被其它事务阻塞
[2024/05/21 16:45:46.730 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
[2024/05/21 16:45:46.751 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
[2024/05/21 16:45:46.772 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
[2024/05/21 16:45:46.787 +08:00] [INFO] [session.go:4167] ["old running transaction block DDL"] ["table ID"=87] [jobID=89] ["connection ID"=7213735261140681119] ["elapsed time"=25m50.295171579s]
[2024/05/21 16:45:46.794 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
[2024/05/21 16:45:46.815 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
[2024/05/21 16:45:46.836 +08:00] [INFO] [syncer.go:333] ["[ddl] syncer check all versions, someone is not synced"] [info="instance ip 172.16.201.108, port 4001, id 93cd10c3-e97d-4f0b-9190-20c21a1295b4"] ["ddl id"=89] [ver=47]
解读
元数据锁,简称 MDL ,原理参考:
https://docs.pingcap.com/zh/tidb/v6.5/metadata-lock#%E5%85%83%E6%95%B0%E6%8D%AE%E9%94%81%E7%9A%84%E5%8E%9F%E7%90%86
从问题日志看流程:
[ddl] add DDL jobs
[ddl] start DDL job
[ddl] run DDL job
[ddl] run add index job ---- 调用 onCreateIndex
diff load InfoSchema success ---- 调用 loadInfoSchema
[ddl] syncer check all versions, someone is not synced (20ms 打印一次) ---- 调用 OwnerCheckAllVersions
old running transaction block DDL (阻塞一分钟会打印,后续每 10 秒打印一次)---- 调用 RemoveLockDDLJobs
从 DDL 与 DML 交互及 MDL 锁管理的设计看流程:

注:为了简洁和方便理解,省掉了部分细节内容,且下列步骤与图中的步骤序号并不相关。
- 用户通过 MySQL Client 发送 SQL 语句给 TiDB Server。
- TiDB 接收到 SQL 语句后,根据 SQL 类型将其分为 DDL 和 DML 两种处理方式。
- 对于 DDL 操作,TiDB 先执行元数据锁检查(checkMDLInfo),确认在 mysql.tidb_mdl_info 表中无冲突后,通过注册(registerMDLInfo)job_id, version, table_ids 信息到该表,以表明 DDL 可以执行。
- 在处理 DML 操作时,TiDB 将当前操作涉及的 table_id 及其 schema_version 存储于 Session Context( GetRelatedTableForMDL)中。开启元数据锁后,DML 不会被 DDL 操作阻塞,这是和 MySQL 最大的区别。
- TiDB Domain 模块运行 mdlCheckLoop,通过调用
CheckOldRunningTxn(jobsVerMap, jobsIdsMap)来检查是否有旧的运行事务(DML 操作)正在持有阻碍 DDL 作业推进的元数据锁的版本。通过比对 mysql.tidb_mdl_info 记录与 Session Context 中的 schema 版本,若 DDL 尝试升级至新版本(例如从 10 升至 11),而存在依赖旧版 schema(如版本 9)的 DML 事务,DDL 将暂停推进版本,直至所有旧版本依赖事务完成,确保数据一致性。在此期间,DDL 的版本信息不会更新至 PD,从而阻止 DDL 的版本推进。 - 执行 DDL (runDDLJob),就会执行一个 schema 的变更,同时还要去把这个 MDL 的信息通过 registerMDLInfo 给把它更新进去,并通过 waitSchemaSynced 等待所有 TiDB 节点同步 MDL 信息至一致版本,随后安全推进 schema 版本至新水平。比如 加索引 None -> Delete Only -> Write Only -> Write Reorg -> Public 这四个变化,每次 schema 变更推进都会调用 runDDLJob、registerMDLInfo、waitSchemaSyncedForMDL、cleanMDLInfo,如此反复直至 DDL 最终完成,mysql.tidb_mdl_info 中该 DDL 作业相关的元数据锁记录也被清理。
- tidb-server 会定期根据 PD 里的 schema 版本,reload 更新自身的 schema 版本,确保 DML 使用都是最新版本的 schema 信息。
通过代码解读上述关键步骤:
checkMDLInfo
步骤 3 元数据锁检查(checkMDLInfo)
// checkMDLInfo 检查元数据锁信息是否存在。
// 如果存在这类信息,表示该数据库模式正被某些 TiDB 实例锁定。
func checkMDLInfo(jobID int64, pool *sessionPool) (bool, int64, error) {
// 根据 jobID 构造 SQL 查询语句,用于查询 mysql.tidb_mdl_info 表中相关的元数据锁信息。
sql := fmt.Sprintf("SELECT version FROM mysql.tidb_mdl_info WHERE job_id = %d", jobID)
// 从会话池中获取一个会话上下文。此上下文用于执行 SQL 查询。
// 使用 defer 确保执行完函数后,会话会被放回池中。
sctx, _ := pool.get()
defer pool.put(sctx)
// 基于获取的会话上下文创建一个新的会话实例。
sess := newSession(sctx)
// 默认使用新建的会话执行 SQL 查询,并标记此操作为"check-mdl-info"。
// 此操作返回查询结果的行集合以及可能的错误。
rows, err := sess.execute(context.Background(), sql, "check-mdl-info")
if err != nil {
// 如果执行查询时发生错误,直接返回false、0以及错误信息。
return false, 0, err
}
// 检查查询结果是否有行。如果没有,表示没有找到对应的元数据锁信息。
if len(rows) == 0 {
// 返回false表示未找到锁信息,伴随版本号0及nil错误,表示无异常。
return false, 0, nil
}
// 提取查询结果的第一行第一列(索引为0)的整型值作为版本号。
ver := rows[0].GetInt64(0)
// 返回 true 表示找到了元数据锁信息,同时返回锁的版本号,无错误。
return true, ver, nil
}
mdlCheckLoop
步骤 5 通过函数 mdlCheckLoop 完成的,日志信息如果打印“mdl gets lock, update to owner”说明 PD 成功更新信息,该 tidb-server 的 schema 版本可推进。
func (do *Domain) mdlCheckLoop() {
//省略大部分代码
for jobID, ver := range jobsVerMap { // 遍历当前需要检查的DDL作业。
if cver, ok := jobCache[jobID]; ok && cver >= ver {
continue // 如果作业已处理且版本相符,跳过。
}
logutil.BgLogger().Info("mdl gets lock, update to owner", zap.Int64("jobID", jobID), zap.Int64("version", ver))
if err := do.ddl.SchemaSyncer().UpdateSelfVersion(context.Background(), jobID, ver); err != nil {
//尝试更新DDL作业版本,记录日志并处理错误。
logutil.BgLogger().Warn("update self version failed", zap.Error(err))
jobNeedToSync = true // 需要再次同步,因为更新失败。
} else {
jobCache[jobID] = ver // 成功更新,记录到缓存。
}
}
case <-do.exit: // 当接收到退出信号时,退出循环。
return
}
}
}
registerMDLinfo
步骤 6 是调用 registerMDLinfo,注册和更新 mysql.tidb_mdl_info 都是通过它。
// registerMDLInfo 函数负责在 mysql.tidb_mdl_info 表中注册 DDL 作业的元数据锁信息。
func (w *worker) registerMDLInfo(job *model.Job, ver int64) error {
//省略代码
// 执行 SQL查询,从 mysql.tidb_ddl_job 表中根据 job.ID 获取关联的 table_ids。
// 使用 w.sess.execute 在独立的上下文中执行 SQL,便于管理和控制执行过程。
rows, err := w.sess.execute(context.Background(), fmt.Sprintf("select table_ids from mysql.tidb_ddl_job where job_id = %d", job.ID), "register-mdl-info")
if err != nil {
// 如果查询出错,直接返回错误。
return err
}
// 确保查询结果不为空,即对应的 DDL 作业存在。
if len(rows) == 0 {
// 如果找不到 DDL 作业记录,返回错误信息。
return errors.Errorf("can't find ddl job %d", job.ID)
}
// 从查询结果中提取 table_ids,并准备构建 UPDATE/INSERT SQL语句。
ids := rows[0].GetString(0)
// 构建 SQL 语句,使用 REPLACE INTO 操作来更新或插入 mysql.tidb_mdl_info 表。
// 这里会插入或更新一条记录,包含 DDL 作业ID、版本号以及关联的 table_ids。
sql := fmt.Sprintf("replace into mysql.tidb_mdl_info (job_id, version, table_ids) values (%d, %d, '%s')", job.ID, ver, ids)
// 执行构建好的 SQL 语句,用于实际的 MDL 信息注册。
// 再次使用 w.sess.execute,并标记操作为"register-mdl-info",便于追踪。
_, err = w.sess.execute(context.Background(), sql, "register-mdl-info")
// 返回执行结果的错误状态。
return err
}
waitSchemaSyncedForMDL
注:图里的 waitSchemaSynced 在当前版本实际变更成 waitSchemaSyncedForMDL
步骤 6 有调用 waitSchemaSyncedForMDL,主要是调用 OwnerCheckAllVersions 方法来等待,直到集群中所有 TiDB 实例的模式版本同步至 latestSchemaVersion。
// waitSchemaSyncedForMDL 该函数类似于 waitSchemaSynced,但它的主要职责是在执行 DDL 操作前确保能获取到当前 DDL 最新版本的元数据锁(MDL)。
func waitSchemaSyncedForMDL(d *ddlCtx, job *model.Job, latestSchemaVersion int64) error {
//省略代码
// 调用OwnerCheckAllVersions方法等待,直到集群中所有TiDB实例(排除孤立节点)的模式版本同步至latestSchemaVersion。
// 这一步是确保 DDL 变更前,整个集群已准备就绪的关键。
err := d.schemaSyncer.OwnerCheckAllVersions(context.Background(), job.ID, latestSchemaVersion)
if err != nil {
// 如果等待过程中出现错误,记录日志并返回错误,DDL 操作将在此终止。
logutil.Logger(d.ctx).Info("[ddl] wait latest schema version encounter error",
zap.Int64("ver", latestSchemaVersion), // 记录等待的目标版本号
zap.Error(err)) // 记录错误信息
return err
}
// 当所有节点成功同步至最新版本,记录一条日志,表明等待完成。
// 日志包含 DDL 操作的版本号、等待耗时以及 DDL 作业的详细信息。
logutil.Logger(d.ctx).Info("[ddl] wait latest schema version changed(get the metadata lock if tidb_enable_metadata_lock is true)",
zap.Int64("ver", latestSchemaVersion), // 成功等待的版本号
zap.Duration("take time", time.Since(timeStart)), // 等待耗时
zap.String("job", job.String())) // DDL 作业的字符串表示
// 如果一切顺利,返回 nil 表示操作成功。
return nil
}
OwnerCheckAllVersions
这段代码是日志频繁打印“[ddl] syncer check all versions, someone is not synced”的问题所在。
代码里 intervalCnt := int(time.Second / checkVersInterval),而 checkVersInterval = 20 * time.Millisecond,也就是 20 毫秒,所以代表“[ddl] syncer check all versions, someone is not synced”每秒打印 50 次。
这段代码,没有超时机制退出,只要检查不通过,是一个无限循环,所以一直会打印日志。
//核心代码部分
// 检查 updatedMap 是否为空,如果不为空,说明仍有服务器未同步到最新版本。
if len(updatedMap) > 0 {
succ = false // 设置总检查状态为失败
for _, info := range updatedMap { // 遍历 updatedMap 中剩余的未同步服务器信息
logutil.BgLogger().Info("[ddl] syncer check all versions, someone is not synced",
zap.String("info", info), // 记录未同步的服务器信息
zap.Any("ddl id", jobID), // 记录 DDL 作业ID
zap.Any("ver", latestVer)) // 记录最新版本号
}
}
}
cleanMDLInfo
步骤 7,调用 cleanMDLInfo 清理元数据锁(MDL)的相关信息。
// cleanMDLInfo 该函数用于清理元数据锁(MDL)的相关信息。
func cleanMDLInfo(pool *sessionPool, jobID int64, ec *clientv3.Client) {
//省略代码
// 构造SQL语句,用于删除 mysql.tidb_mdl_info 表中指定 jobID 的记录。
sql := fmt.Sprintf("delete from mysql.tidb_mdl_info where job_id = %d", jobID)
// 从 session 池中获取一个 session 上下文,并在 defer 中确保使用完后归还到池中。
sctx, _ := pool.get()
defer pool.put(sctx)
// 创建一个新的 session 实例,并设置磁盘满时的处理策略为允许接近满时仍执行操作。
sess := newSession(sctx)
sess.SetDiskFullOpt(kvrpcpb.DiskFullOpt_AllowedOnAlmostFull)
// 执行 SQL 以删除指定jobID的 MDL 信息记录,上下文使用 background 避免阻塞当前 goroutine。
// 操作标记为"delete-mdl-info",便于追踪。
_, err := sess.execute(context.Background(), sql, "delete-mdl-info")
// 捕获并记录执行删除操作时发生的任何错误。
if err != nil {
logutil.BgLogger().Warn("unexpected error when clean mdl info",
zap.Int64("job ID", jobID), // 记录发生错误的 jobID
zap.Error(err)) // 记录具体的错误信息
return
}
//省略代码
}
RemoveLockDDLJobs
这里是日志打印“old running transaction block DDL”的问题所在。
// RemoveLockDDLJobs 该函数用于移除那些未能获取到元数据锁(MDL)的DDL作业,这些作业信息由job2ver和job2ids提供。
func RemoveLockDDLJobs(s Session, job2ver map[int64]int64, job2ids map[int64]string, printLog bool) {
//省略代码
// 遍历当前会话持有 MDL 锁的表ID及相关版本信息。
sv.GetRelatedTableForMDL().Range(func(tblID, value any) bool {
// 对于 job2ver 中的每个 DDL作业,检查其涉及的表ID是否与会话持有的 MDL 锁相冲突。
for jobID, ver := range job2ver {
// 将 job2ids 中字符串形式的表ID映射转换为 int64 形式的 map。
ids := util.Str2Int64Map(job2ids[jobID])
// 如果当前遍历的表ID存在于 DDL 作业涉及的表ID中,并且持有的MDL版本小于DDL作业期望的版本,
// 则从 job2ver 中移除该 DDL 作业记录,表明该作业因MDL冲突无法继续。
if _, ok := ids[tblID.(int64)]; ok && value.(int64) < ver {
delete(job2ver, jobID)
// 计算当前事务开始至今的持续时间。
elapsedTime := time.Since(oracle.GetTimeFromTS(sv.TxnCtx.StartTS))
// 根据持续时间的长短和 printLog 标志决定记录日志级别。
if elapsedTime > time.Minute && printLog {
// 如果事务长时间未结束且需要打印日志,记录为 INFO 日志。
logutil.BgLogger().Info("old running transaction block DDL",
zap.Int64("table ID", tblID.(int64)), // 涉及的表ID
zap.Int64("jobID", jobID), // 被阻止的 DDL 作业ID
zap.Uint64("connection ID", sv.ConnectionID), // 发生冲突的连接ID
zap.Duration("elapsed time", elapsedTime)) // 事务持续时间
} else {
// 否则,作为 DEBUG 日志记录。
logutil.BgLogger().Debug("old running transaction block DDL",
zap.Int64("table ID", tblID.(int64)),
zap.Int64("jobID", jobID),
zap.Uint64("connection ID", sv.ConnectionID),
zap.Duration("elapsed time", elapsedTime))
}
}
}
// 继续遍历下一个表ID。
return true
})
}
DDL 在 PD 里的信息
#查询现在的 tidbUUID
curl http://172.16.201.22:10080/info/all | grep "ddl_id"
"ddl_id": "7d4969cc-d45d-44bf-93e1-39c5da0236ce",
"ddl_id": "d2120768-fb3e-4981-b64d-6d2c49537326",
#查出所有的信息,找到 ddljobID
export ETCDCTL_API=3
etcdctl --endpoints=http://172.16.201.22:2379 get --prefix ""
/tidb/ddl/all_schema_by_job_versions/0/7d4969cc-d45d-44bf-93e1-39c5da0236ce
104
/tidb/ddl/all_schema_by_job_versions/122/d2120768-fb3e-4981-b64d-6d2c49537326
104
结论
本文已说明了在 TiDB 中,遗留事务中的 DML 操作如何阻碍 DDL 进程:只要这些 DML 事务未完成(不论是提交还是回滚),它们就会陷入于OwnerCheckAllVersions的版本兼容性检查环节,导致 DDL 无法顺利推进并释放。
当用户尝试取消一个正在执行的ADD INDEX操作时,这一取消流程本质上遵循了相同的 schema 版本推进逻辑,涵盖了步骤如执行 DDL 任务(runDDLJob)、注册 MDL 信息(registerMDLInfo)、等待全集群 MDL 同步(waitSchemaSyncedForMDL)以及最终的 MDL 信息清理(cleanMDLInfo)。然而,由于先前步骤中 DDL 已被遗留 DML 事务卡在waitSchemaSyncedForMDL的OwnerCheckAllVersions版本检查环节,DDL 状态因此显示为持续卡住在'canceling'。
DDL 卡住,但在mysql.tidb_mdl_view表中未见 MDL 锁的记录,这与用户的直观预期不符。在用户视觉里,只要是卡住,就应该有 MDL 锁信息。后续咨询老师,这里确实有小问题,在 master 版本(比如 8.1 LTS)已修复 https://github.com/pingcap/tidb/pull/48728
后记
- 由于个人能力所限,文中观点或知识如有不正确之处,欢迎指正,共同成长。
- 感谢 Yinghua 老师的精心校对,感谢产研大佬的 MDL 流程图等帮助。
- 感谢某大模型的辅助,需要阅读的代码非常多,需部分借助 AI 能力。