☰
TiDB Multi-Schema Change(多模式变更)设计与实现解析:单条 ALTER TABLE 原子执行多项 DDL
2026/10/24 16:30:20 网站建设 项目流程

TiDB Multi-Schema Change(多模式变更)设计与实现解析:单条 ALTER TABLE 原子执行多项 DDL

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

一条ALTER TABLE里同时加列、改列类型、加索引、改注释——这既是 MySQL 对 SQL 标准的扩展能力,也是 TiDB 长期缺失、从 MySQL 迁移用户频繁碰壁的功能点。TiDB 在 2022 年 4 月提交的设计方案 docs/design/2022-04-15-multi-schema-change.md 中给出了完整解决思路:引入SubJob子任务模型,把一条语句拆解为多个子任务装入同一个 DDL Job,再借助"可回滚/不可回滚"两阶段发布保证原子性。阅读本文后,你将掌握 Multi-Schema Change 的Job/SubJob数据结构、状态机与回滚语义、与在线 DDL 架构的衔接方式,以及 TiDB 与 MySQL 在该能力上的兼容性差异。

一、背景:为什么需要 Multi-Schema Change

MySQL 提供了一种超出 SQL 标准的能力:用户可以在一条ALTER TABLE语句中原子地完成多项模式变更(schema change),包括列与索引的ADD、ALTER、DROP、CHANGE以及表选项的修改。例如:

CREATE TABLE t (a INT, c INT); ALTER TABLE t ADD COLUMN b INT, MODIFY COLUMN c CHAR(5), ADD INDEX idx(a), ADD PRIMARY KEY (c), COMMENT = "comments for t";

而在设计文档提出的时间点,TiDB 每条 SQL 语句仅支持一项模式变更,只在少数特殊场景下有限地支持多项变更。对从 MySQL 系数据库迁移的用户来说,必须手动把一条 Multi-Schema Change DDL 改写成多条单变更 DDL;对于依赖 Flyway 这类 ORM 框架自动生成 SQL 的场景,改写工作繁琐且脚本难以维护。因此文档将"缺少该能力"定义为阻碍用户使用 TiDB 的阻塞性问题(blocking issue)。

目标与非目标

目标:支持 MySQL 兼容、且使用广泛的 Multi-Schema Change,包括ADD/DROP COLUMN、ADD/DROP INDEX、MODIFY COLUMN、RENAME COLUMN等。

明确不做的事(Non-Goals):

  • 不支持 TiDB 特有的多变更组合,如ADD TIFLASH REPLICA、ADD PARTITION、ALTER PARTITION等(在 executor.go 中对ActionSetTiFlashReplica等类型直接返回ErrRunMultiSchemaChanges可验证这一点);
  • 不解决 DDL 与 DML 并发执行时的 "schema is changed" 错误;
  • 不追求与 MySQL 100% 行为一致(MySQL 会对变更执行顺序做重排,有时反直觉);
  • 不直接优化 DDL 性能(虽然为长任务提速预留了空间,见"未来工作")。

二、总体设计:在线 DDL 架构之上的 SubJob 模型

Multi-Schema Change 的实现建立在 TiDB 的在线 DDL 架构之上(参见设计文档 docs/design/2018-10-08-online-DDL.md)。其核心思想是:不新增多个独立的 DDL Job,而是把多项变更打包进同一个 Job。

设计文档据此明确了两个概念:

  • Job:一条 DDL 语句在 TiDB 内部的通用表示;
  • SubJob:一个 DDL 模式变更的表示。一个 Job 可以包含零个(当 Multi-Schema Change 不适用时)或多个 SubJob。

数据结构:Job / SubJob / MultiSchemaInfo

设计文档给出的核心数据结构如下(文档中简写,正式实现在 pkg/meta/model/job.go):

// Job represents a DDL action. type Job struct { Type ActionType `json:"type"` State JobState `json:"state"` // ... MultiSchemaInfo *MultiSchemaInfo `json:"multi_schema_info"` } // MultiSchemaInfo contains information for multi-schema change. type MultiSchemaInfo struct { // ... SubJobs []SubJob `json:"sub_jobs"` } // SubJob represents one schema change in a multi-schema change DDL. type SubJob struct { Type ActionType `json:"type"` State JobState `json:"state"` // ... }

字段ActionType表示 DDL 类型:例如ADD COLUMN映射为ActionAddColumn,MODIFY COLUMN映射为ActionModifyColumn。

Multi-Schema Change 的 DDL Job 类型固定为ActionMultiSchemaChange。在当前 worker 模型中,有专门的代码路径onMultiSchemaChange()负责执行这类 Job,只有 Multi-Schema Change Job 才允许携带 SubJob。

举例,对于如下 DDL:

ALTER TABLE t ADD COLUMN b INT, MODIFY COLUMN a CHAR(10);

可以被建模为:

job := &Job { Type: ActionMultiSchemaChange, MultiSchemaInfo: &MultiSchemaInfo { SubJobs: []*SubJob { &SubJob { Type: ActionAddColumn, Args: ... }, &SubJob { Type: ActionModifyColumn, Args: ... }, } } }

这样多个变更就被打包进一个 Job,和其他 Job 一样进入持久化的 DDL Job 队列,等待合适的 worker 拾取并处理。

落地到真实代码的结构

上面是设计文档的示意代码。在当前仓库中,真实结构已演化为Job、SubJob、MultiSchemaInfo三个正式类型,均定义在 pkg/meta/model/job.go:

  • Job(job.go)含Type、State、SchemaState、MultiSchemaInfo等字段;注释明确写着MultiSchemaInfo keeps some warning now for multi schema change;
  • SubJob(job.go)注释与设计文档一致:"A SubJob is a representation of one DDL schema change. A Job may contain zero (when multi-schema change is not applicable) or more SubJobs.",并比设计文档多出Revertible、RowCount、SchemaVer、NeedReorg、ReorgTp、ReorgStage等运行时字段——这些字段承担了两阶段发布、行数统计与 reorg 状态记录职责;
  • MultiSchemaInfo(job.go)除持久化的SubJobs、Revertible、Seq、SkipVersion外,还有仅在内存中使用的AddColumns、DropColumns、ModifyColumns、AddIndexes、DropIndexes、AlterIndexes、AddForeignKeys、RelativeColumns、PositionColumns等集合,供校验与冲突检测使用。

三、Job / SubJob 的执行与状态机

状态迁移

设计文档给出Job与SubJob共有的State字段及全部可能的迁移路径:

┌-->--- Done ->------------------┐ | | None -> Running -> Rollingback -> RollbackDone -> Synced | | └-->--- Cancelling -> Cancelled -->-┘

这些状态可以被分为四类:

状态正常(Normal)异常(Abnormal)
未完成(Uncompleted)None、RunningRollingback、Cancelling
已完成(Completed)DoneRollbackDone、Cancelled

真实枚举值定义在 pkg/meta/model/job.go:JobStateRunning、JobStateRollingback、JobStateRollbackDone、JobStateDone、JobStateCancelled、JobStateSynced、JobStateCancelling、JobStateQueueing、JobStatePausing。SubJob 上的IsNormal()、IsFinished()辅助方法(job.go)即按此分类判断子任务属于"正常推进"还是"已终结"。

子任务选择顺序

由于一个 Job 由一个 DDL worker 单线程执行,SubJob 也就在单线程内逐个执行。设计文档给出的选择原则是:

  • 正常状态下:按升序选择第一个未完成的 SubJob,即序号小的先执行;
  • 异常状态下:按降序选择第一个未完成的 SubJob,即序号大的先执行(先回滚后执行的那几个变更)。

当任意一个 SubJob 进入异常状态时,父 Job 及其余所有 SubJob 都要切换到异常状态。这一点在 multi_schema_change.go 的handleRevertibleException中得到印证:一旦某个 SubJob 非正常,父 Job 置为Rollingback,正在Running的子任务置为Cancelling,处于None/Queueing的子任务置为Cancelled。

四、Schema 对象状态管理与两阶段发布(原子性核心)

为什么需要延迟发布

为保证 Multi-Schema Change 的原子执行,必须精细管理正在变更的 schema 对象的状态。以文档的示例说明:

ALTER TABLE t ADD COLUMN b INT, MODIFY COLUMN a CHAR(10);

如果第二个 SubJobMODIFY COLUMN a CHAR(10)因某种原因失败(例如某行数据超过十个字符),第一个 SubJob 必须能够回滚其变更(撤销已添加的列b)。

这个要求意味着:不能在一个 SubJob 完成时立即发布其 schema 对象。相反,它应当保持在用户不可见的状态,等待其余 SubJob 完成;只有确认所有 SubJob 都成功后,才一次性全部发布。设计文档指出该方式与 2PC 类似:只有所有 "prewrite" 完成,"commit" 才能开始。

各变更的可回滚/不可回滚状态表

以下是不同 DDL 中可能出现的 schema 状态表("Revertible States" 指对用户不可见的变更,即仍可整体回滚):

DDL(Schema Change)可回滚状态(Revertible States)不可回滚状态(Non-revertible States)
Add ColumnNone, Delete-Only, Write-Only, Write-ReorgPublic
Add IndexNone, Delete-Only, Write-Only, Write-ReorgPublic
Drop ColumnPublicWrite-Only, Delete-Only, Delete-Reorg, None
Drop IndexPublicWrite-Only, Delete-Only, Delete-Reorg, None
非 Reorg 的 Modify ColumnPublic(元数据变更前)Public(元数据变更后)
Reorg 的 Modify ColumnNone, Delete-Only, Write-Only, Write-ReorgPublic

借助"non-revertible"标记推进

为达成上述行为,设计文档在 SubJob 中引入名为non-revertible的标记。当某个 schema 对象到达最后一个可回滚状态时,该标记被置位;带此标记的 SubJob 被视为"临时完成",worker 得以选择下一个 SubJob。当所有 SubJob 都 non-revertible 后,所有相关 schema 对象在一个事务内统一切换到下一状态;之后各 SubJob 串行执行剩余步骤。

在 pkg/meta/model/job.go 中,MultiSchemaInfo.Revertible即承担该标记角色(NewMultiSchemaInfo()初始化为true),SubJob.Revertible则记录单个子任务的对应状态;而 onMultiSchemaChange 中调用job.MarkNonRevertible()表示所有子任务已越过最后可回滚点。

两阶段执行的源码印证

从 pkg/ddl/multi_schema_change.go 的实现可以看到,onMultiSchemaChange明确分为两个阶段执行:

  1. 可回滚阶段(job.MultiSchemaInfo.Revertible == true):逐个挑选尚未Revertible置假且未结束的子任务执行;一旦有异常则调用handleRevertibleException让整个 Job 进入回滚。当所有子任务都到达最后可回滚点时,代码将剩余 SubJob 统一"step"一次并写入 schema diff:

    // 只生成 1 个 schema version,把这些 sub-job 一次性推到不可回滚状态 for i, sub := range job.MultiSchemaInfo.SubJobs { proxyJob := sub.ToProxyJob(job, i) if schemaVersionGenerated { proxyJob.MultiSchemaInfo.SkipVersion = true } proxyJobVer, _, err := w.runOneJobStep(jobCtx, &proxyJob) // ... } // 合并成一个 schema diff,而不是每个子任务各生成一份 if err = metaMut.SetSchemaDiff(&model.SchemaDiff{ Version: ver, Type: job.Type, TableID: job.TableID, SchemaID: job.SchemaID, SubActionTypes: actionTypes, }); err != nil { ... } job.MarkNonRevertible()

    可以看到"跳过额外版本生成"与"多个子动作汇总为单个SchemaDiff"两处细节,从源码层面保证了多个 SubJob 的批量推进只对外暴露一次 schema 版本变化,这正是"一次性发布"的落地实现。

  2. 不可回滚阶段(Revertible == false):逐个串行执行其余不可回滚的子任务,最后调用finishMultiSchemaJob(multi_schema_change.go)将父 Job 置为JobStateDone。

回滚与异常处理

反过来,如果在所有 SubJob 变为 non-revertible 之前有任何子任务返回错误,整个 Job 进入Rollingback状态:已执行的 SubJob 置为Cancelling,未执行的置为Cancelled。回滚时子任务按逆序逐个撤销,对应onMultiSchemaChange开头for i := len(...) - 1; i >= 0; i--的逆序遍历(multi_schema_change.go)。

最后考虑极端情况:错误发生在所有 SubJob 都已 non-revertible 之后。此时错误只有两类可能:逻辑错误(违反唯一约束、数据超范围等)与物理错误(网络不可用、存储不可用等)。文档指出此时错误必然是物理性质的,通常可简单解决(如重试),这与现有 DDL 实现保持一致——例如DROP COLUMN,一旦列进入 "Write-Only" 状态便无法中止该 Job。源码中handleRollbackException(multi_schema_change.go)也印证了物理错误在回滚期间不可恢复、只能持续重试的处理策略。

可回滚性相关校验

除了状态管理,TiDB 还会在提交前做语义检查,确保变更组合本身是合法可回滚的:

  • checkOperateSameColAndIdx(multi_schema_change.go):同一列或同一索引不能被多次操作(对应错误如重复操作列名);
  • checkMultiSchemaInfo(multi_schema_change.go):汇总校验重复操作、可见列数量是否越界、外键依赖的索引是否被误删、加列后总列数是否超限;
  • checkOperateDropIndexUseByForeignKey(multi_schema_change.go):防止在同一语句中删掉仍被外键引用的索引。

五、从 SQL 到 SubJob:语句拆解与提交链路

理解了执行侧的状态机,再回看一条 Multi-Schema Change SQL 是如何在提交阶段被识别并拆成 SubJob 的。整条链路分布在 executor 与 jobsubmit 两个模块:

  1. 识别为 Multi-Schema Change:isMultiSchemaChanges(pkg/ddl/executor.go)判断条件为"spec 数量大于 1",或者"单条 spec 为ADD COLUMNS且新增列数大于 1"。此外若开启了行级校验和(row level checksum),会直接拒绝多模式变更。

  2. 开启收集模式:当len(validSpecs) > 1时,executor.go 在会话的StmtCtx中挂上一个model.NewMultiSchemaInfo()。随后AlterTable仍按原逻辑逐条调用各变更处理器(AddColumn、dropIndex、ModifyColumn等),但此时DoDDLJob并不会真正提交执行,而是把每个变更产生的 Job 逐一"收集"进MultiSchemaInfo——这正是注释所描述的 "DoDDLJob will collect all jobs into MultiSchemaInfo and skip running them. Then we will run them in multiSchemaChange all at once."。

  3. 聚合为一个父 Job:收集完成后,所有子 Job 被折叠为一个类型为ActionMultiSchemaChange的父 Job。appendToSubJobs(multi_schema_change.go)按各自类型把JobArgs、RawArgs、SchemaState、NeedReorg等塞入SubJob;fillMultiSchemaInfo(multi_schema_change.go)则根据ADD/DROP COLUMN、ADD/DROP INDEX、RENAME INDEX、MODIFY COLUMN、外键增删等类型填充AddColumns/DropColumns/ModifyColumns/AddIndexes/DropIndexes等元信息(不支持的类型会以ErrRunMultiSchemaChanges报错)。为提升"一次加多个索引"场景的效率,还存在mergeAddIndex(multi_schema_change.go)把多个ADD INDEX子任务合并成一个放到子任务末尾。

  4. 持久化入队:父 Job 进入持久化的 DDL Job 队列。序列化时fillArgsWithSubJobs(pkg/ddl/jobsubmit/submit.go)会为每个 SubJob 单独FillArgs;一旦入队成功,setJobStateToQueueing(submit.go)会把父 Job 与所有 SubJob 一并置为Queueing。这意味着整个多模式变更是作为一个整体被调度与持久化的,天然具备原子与可恢复性。

  5. Owner 执行:DDL owner 侧通过onMultiSchemaChange()专用路径执行。执行时通过SubJob.ToProxyJob(job.go)把每个子任务"包装"成一个临时 proxy Job,交给既有的单任务执行框架runOneJobStep跑完一步,再用SubJob.FromProxyJob(job.go)把结果状态回收——这样 Multi-Schema Change 得以复用全部既有单 DDL 的执行逻辑,包括 reorg、schema 版本推进、错误处理等,无需为每种变更重写一套执行引擎。这一设计是理解 Multi-Schema Change 最重要的实现细节之一。

六、兼容性分析

升级兼容性

集群滚动升级期间存在两种情形(对应设计文档):

  1. DDL owner 是新版本 TiDB,用户在旧版本TiDB 上执行 Multi-Schema Change 语句时,会收到 "unsupported multi-schema change" 错误;
  2. DDL owner 是旧版本 TiDB,用户在新版本TiDB 上执行 Multi-Schema Change 语句时,会收到 "invalid ddl job type" 错误。

两种情况下 Multi-Schema Change 语句都无法执行,因此滚动升级期间应避免执行 Multi-Schema Change。

MySQL 兼容性差异

MySQL 会对部分变更做重排。设计文档给出如下例子:

CREATE TABLE t (b INT, a INT, INDEX i(b)); ALTER TABLE t DROP COLUMN b, RENAME COLUMN a TO b, ADD INDEX i(b), DROP INDEX i; -- success! SHOW CREATE TABLE t;
+-------+-------------------------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +-------+-------------------------------------------------------------------------------------------------------------------------------+ | t | CREATE TABLE `t` ( `b` int DEFAULT NULL, KEY `i` (`b`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci | +-------+-------------------------------------------------------------------------------------------------------------------------------+

MySQL 的实际执行顺序为:

  1. DROP INDEX i
  2. DROP COLUMN b
  3. RENAME a TO b
  4. ADD INDEX i(b)

由于这种重排行为有些反直觉,TiDB决定不与 MySQL 完全兼容。TiDB 会基于执行前获取的快照 schema 结构校验各项变更,而忽略同一条语句内前面变更产生的影响,因此同样的语句在 TiDB 中会报错:

ALTER TABLE t DROP COLUMN b, RENAME COLUMN a TO b, ADD INDEX i(b), DROP INDEX i; ERROR 1060 (42S21): Duplicate column name 'b'

这种"基于快照校验"的语义也会影响一些常见用法,例如ALTER TABLE t ADD COLUMN a, ADD INDEX i1(a);目前不被支持——因为校验时列a尚不存在于快照结构中。不过文档也指出支持这类语句并不困难:去掉特定校验即可。

七、未来工作

设计文档指出该实现可以被用于发展其他能力或优化:

  • 表级数据重组(如ALTER TABLE CONVERT TO CHARSET)可以通过把一个 Job 拆成多个 SubJob 来实现;
  • 多索引添加性能优化:通过把多个ADD INDEX子任务合并为一个 SubJob,数据只读取一次而非多次(这一设想已在 mergeAddIndex 中部分落地);
  • EXPLAIN DDL:帮助用户理解多模式变更的工作方式,展示执行顺序、子任务等信息。

八、备选方案与取舍

设计文档还记录了几种被否定的备选方案,方便读者理解为何选择 SubJob 模型:

  • "Job group" 方案:用一组 job 来表示多个变更。问题在于它会把 job group 的内部细节暴露给 DDL Job 队列,使维护更困难;
  • 逐个 case 特殊实现(例如历史 PR #15540):某些场景虽可支持,但不可扩展;
  • 放弃原子性、使用多个独立 DDL Job:实现复杂度最低、工作量小,但会让用户看到混乱的中间状态。

三者相较,SubJob方案在"内部细节隔离、可扩展性与原子语义"之间取得平衡。

九、测试与验证

本特性有充分的测试覆盖,可作为深入研读的实现佐证:

  • pkg/ddl/multi_schema_change_test.go:单元级测试覆盖了各类组合与异常路径,例如TestMultiSchemaChangeAddColumnsCancelled、TestMultiSchemaChangeDropIndexedColumnsCancelled、TestMultiSchemaChangeRenameColumns、TestMultiSchemaChangeAddIndexesCancelled、TestMultiSchemaChangeModifyColumnsCancelled、TestMultiSchemaChangeMixCancelled、TestMultiSchemaChangeAdminShowDDLJobs、TestMultiSchemaChangeWithExpressionIndex、TestMultiSchemaChangeNoSubJobs、TestMultiSchemaChangeSchemaVersion、TestMultiSchemaChangeMixedWithUpdate、TestMultiSchemaChangeBlockedByRowLevelChecksum等,分别验证回滚语义、并行多变更、schema 版本生成、与 DML 混跑、限制条件等行为;
  • tests/realtikvtest/addindextest/multi_schema_change_test.go:真实 TiKV 环境下的集成测试,覆盖物理执行链路。

总结

Multi-Schema Change 的设计精髓在于:不引入并行的 DDL 执行,而是在单一 DDL Job 内部构造"子任务 + 两阶段发布"的抽象。数据结构上通过Job.MultiSchemaInfo.SubJobs把多项变更打包持久化;执行上通过onMultiSchemaChange与 proxy Job 机制复用既有单 DDL 引擎;一致性上通过"可回滚状态累积 → 统一推进到不可回滚点 → 串行收尾"模拟 2PC 语义。理解了这条主线,无论是排查回滚行为、研读在线 DDL 框架,还是设计 TiDB 上类似的分层 DDL 任务,都能事半功倍。若想继续深入,建议按本文引用的源码路径逐步阅读 pkg/ddl/multi_schema_change.go、pkg/meta/model/job.go 与对应的两组测试文件。

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询