2
1
0
0
专栏/.../

元数据锁:DML 阻塞 DDL 的问题解读

 Jayjlchen  发表于  2024-06-10

背景

在使用 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 锁管理的设计看流程:

注:为了简洁和方便理解,省掉了部分细节内容,且下列步骤与图中的步骤序号并不相关。

  1. 用户通过 MySQL Client 发送 SQL 语句给 TiDB Server。
  2. TiDB 接收到 SQL 语句后,根据 SQL 类型将其分为 DDL 和 DML 两种处理方式。
  3. 对于 DDL 操作,TiDB 先执行元数据锁检查(checkMDLInfo),确认在 mysql.tidb_mdl_info 表中无冲突后,通过注册(registerMDLInfo)job_id, version, table_ids 信息到该表,以表明 DDL 可以执行。
  4. 在处理 DML 操作时,TiDB 将当前操作涉及的 table_id 及其 schema_version 存储于 Session Context( GetRelatedTableForMDL)中。开启元数据锁后,DML 不会被 DDL 操作阻塞,这是和 MySQL 最大的区别。
  5. 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 的版本推进。
  6. 执行 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 作业相关的元数据锁记录也被清理。
  7. 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 事务卡在waitSchemaSyncedForMDLOwnerCheckAllVersions版本检查环节,DDL 状态因此显示为持续卡住在'canceling'。

DDL 卡住,但在mysql.tidb_mdl_view表中未见 MDL 锁的记录,这与用户的直观预期不符。在用户视觉里,只要是卡住,就应该有 MDL 锁信息。后续咨询老师,这里确实有小问题,在 master 版本(比如 8.1 LTS)已修复 https://github.com/pingcap/tidb/pull/48728

后记

  • 由于个人能力所限,文中观点或知识如有不正确之处,欢迎指正,共同成长。
  • 感谢 Yinghua 老师的精心校对,感谢产研大佬的 MDL 流程图等帮助。
  • 感谢某大模型的辅助,需要阅读的代码非常多,需部分借助 AI 能力。

2
1
0
0

版权声明:本文为 TiDB 社区用户原创文章,遵循 CC BY-NC-SA 4.0 版权协议,转载请附上原文出处链接和本声明。

评论
暂无评论