的设计与实现:影子列 + 影子索引的全量重写方案)
TiDB 在线修改列类型MODIFY/CHANGE COLUMN的设计与实现影子列 影子索引的全量重写方案【免费下载链接】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/tidbTiDB 早期版本只支持同一类型内的长度“加长”不允许真正改变存储层的数据类型。本文以 docs/design/2020-07-07-change-column-types.md 这份设计文档为骨架讲解 TiDB 如何通过“新增影子列changing column 影子索引changing index 在线回填backfill”的方式把ALTER TABLE ... MODIFY/CHANGE COLUMN从“仅加长”扩展为支持有损类型转换的完整能力并结合当前仓库源码如 pkg/ddl/modify_column.go、pkg/table/tables/tables.go还原该方案在工程上的落地形态与演进。读完本文你将理解该功能的 DDL 状态机、数据/索引重写路径、回滚机制以及 MySQL 兼容边界并能据此判断自己业务中哪些列类型变更可以安全执行。背景当时列类型修改的语法与能力边界该功能使用的 SQL 语法与 MySQL 保持一致通过ALTER TABLE下的CHANGE与MODIFY两个子句完成。设计文档给出的语法骨架如下ALTER TABLE tbl_name [alter_specification] alter_specification: CHANGE [COLUMN] old_col_name new_col_name column_definition [FIRST | AFTER col_name] | MODIFY [COLUMN] col_name column_definition [FIRST | AFTER col_name]其中CHANGE可以同时重命名列并修改定义old_col_name 与 new_col_name 不同MODIFY只改定义不改名FIRST | AFTER col_name用于调整新列在表中的物理偏移offset。DDL 语句本身在 TiDB 的 job 体系中已有对应动作类型ActionModifyColumn。在该设计文档写作时2020 年TiDB 对列类型的修改仅支持“同类型变长”即不触碰存储层已有行数据限制如下不支持有损变更lossy changes例如BIGINT→INTEGER或VARCHAR(255)→VARCHAR(10)不支持修改DECIMAL的精度不支持修改UNSIGNED属性字符集仅支持从utf8改为utf8mb4。这些限制的根源是存储层的行数据编码与索引键编码都取决于列类型类型一旦变化历史数据必须重新按新类型编码无法“原地”完成因此需要一个真正涉及数据与索引重建的在线 DDL 方案。总体设计为被改列复制一份“影子列”与“影子索引”设计文档的核心思想非常直接给将被修改的列做一份“备份副本”副本的列类型就是目标类型在后台按批把存量行从旧类型重写成新类型期间所有新写入INSERT/UPDATE同步按新类型写入副本。等到副本数据追平存量后一次性切换删掉旧列旧索引、让副本“转正”。假设把colA的类型从originalType改为newType且colA上存在一个普通二级索引idxA与生成列无关。则新建影子列changingColA类型为newType新建影子索引changingIdxA结构与idxA一致但其包含列换成changingColA。影子列/索引的相关元数据在表结构TableInfo中通过两个字段串联起来供执行阶段与前后端读写路径互相查找列的ChangeStateInfo中记录DependencyColumnOffset即被依赖的旧列偏移参见 pkg/meta/model/column.go列上以ChangingFieldType记录本次要改成的新类型见同文件 L100-L101影子的命名由工具函数生成例如GenUniqueChangingColumnName/GenUniqueChangingIndexName见 pkg/meta/model/column.go 与 pkg/meta/model/index.go。写入阶段的实时同步文档明确要求对正在被改类型的列执行 INSERT / UPDATE即AddRecord/UpdateRecord路径时必须同时按newType把值写入影子列与影子索引。这一要求在 pkg/table/tables/tables.go 的实现中得到印证插入路径中对ChangeStateInfo ! nil State ! StatePublic的列直接用table.CastColumnValue把用户在旧列依赖列上提供的值按新类型转换后写入pkg/table/tables/tables.go更新路径中同样依据col.ChangeStateInfo ! nil分支用CastColumnValue从oldData/newData中对应DependencyColumnOffset的值实时推导影子列的新旧值pkg/table/tables/tables.go。也就是说“双写”并非各写各的而是由旧列值经过类型转换后派生从而保证同一行数据的旧、新两种编码始终一致。执行流程三阶段状态推进设计文档把执行过程概括为三个阶段更新元数据为changingColA、changingIdxA建好列/索引元数据并追加到TableInfo的列数组、索引数组末尾。影子对象的状态推进方式与“加索引add index”类似尤其是进入StateWriteReorganization状态时需要初始化 reorg 信息。重组处理阶段reorg与最初实现 add index 类似地分批处理行数据与索引数据。先取批处理范围再按newType构造changingColA/changingIdxA的写入该操作需要锁住对应行与索引列一旦遇到 “data truncated” 之类的转换错误需要上报错误并回滚退出。收尾切换分三个动作锁表禁止写入删除colA、idxA把changingColA、changingIdxA状态置为 public并修正其名称、偏移offset等元数据解锁。从当前源码 pkg/ddl/modify_column.go 看这套设计最终落成一套严格的六阶段状态机对象状态依次迁移StateNone → StateDeleteOnly → StateWriteOnly → StateWriteReorganization → StatePublic对应主处理函数doModifyColumnTypeWithData同文件 L924 起中的switch changingCol.StateStateNone校验目标位置pos若涉及 NULL→NOT NULL 变更给旧列打上PreventNullInsertFlag标记以拦截新 NULL 写入把影子列/索引推入StateDeleteOnly并完成索引 reorg 的初始化initForReorgIndexes最后把 job 参数args.ChangingColumn、args.ChangingIdxs持久化便于中途崩溃续跑。StateDeleteOnly → StateWriteOnly在StateDeleteOnly阶段先对存量数据做一次全表校验见下文“数据越界预检”通过后才允许把状态推到StateWriteOnly。StateWriteOnly → StateWriteReorganization进入真正的数据回填。这个设计与文档“在 StateWriteReorganization 初始化重组信息”的描述一致。StateWriteReorganization依据ReorgMeta.Stage分阶段推进——先是ReorgStageModifyColumnUpdateColumn行数据回填doReorgWorkForModifyColumn随后若存在含该列的索引则进入ReorgStageModifyColumnRecreateIndex重建影子索引doReorgWorkForCreateIndex全部完成后把旧列置为StateWriteOnly、影子列/索引置为StatePublic并把旧对象标记为 removing、交换列偏移。StatePublic把旧列继续降级StateWriteOnly → StateDeleteOnly最终删除旧列与旧索引removeOldObjects/removeOldIndexes写入 finished args包含需要清理的旧索引 ID 列表并job.FinishTableJob结束。旧索引 ID 会被登记到 delete range用于后续异步清理物理数据。由此可以提炼出实现层比文档更明确的一条铁律旧对象“降级删除”与影子对象“升级公开”严格反向交错旧列 WriteOnly ⇄ 影子列 Public 同步发生任一时刻表上都同时存在可用数据从而保证在线、无锁写、可回滚。数据越界预检把失败提前到“改元数据”之前文档提到 reorg 阶段遇到 data truncated 需要报错回滚。工程实现进一步把这一检查“前移”在StateDeleteOnly阶段通过受限 SQL 扫描表中是否存在新类型装不下的存量数据pkg/ddl/modify_column.go 的checkModifyColumnData与buildCheckSQLFromModifyColumn。其按目标类型生成不同的越界条件整数收窄按目标类型的有符号/无符号上下界拼出col lower OR col upperbuildCheckRangeForIntegerTypes依据types.IntegerSignedLowerBound/UpperBound等变长字符收窄检查LENGTH(col) 新flenNULL→NOT NULL检查col IS NULL。扫描若发现违规行job 转入 Rollingback并返回与 MySQL 语义一致的错误例如ErrInvalidUseOfNull或带具体越界值的 “Data truncated for column …”。扩展的实现分类并非所有变更都要重写行数据设计文档的最初动机是“有些变更其实不触碰存储层”。当前实现把modify column按是否需要重组拆成四类pkg/ddl/modify_column.go 的getModifyColumnType分类适用场景含义ModifyTypeNoReorg新类型取值范围是旧类型的超集无需重写任何数据仅更新元数据ModifyTypeNoReorgWithCheck新类型范围是旧类型的子集、无索引需先做数据越界预检通过后仅更新元数据ModifyTypeIndexReorg新类型范围是旧类型的子集、但列上有索引只重建索引走doModifyColumnIndexReorg行数据无需回填ModifyTypeReorg其它如跨类型、有符号↔无符号、字符集/排序规则不兼容等行数据 索引全部按影子列/影子索引回填实现中还有专门的短路判定needRowReorg整数间互转可跳过行重写字符类型之间只要排序规则兼容且非二进制串也无需行重写与needIndexReorg按编码键是否需要变化判断共同决定到底要不要真重组。若新类型与旧类型编码后字节不一致例如整数 UNSIGNED 翻转、排序规则不兼容则强制走全量 Reorg避免 stats 中按 codec 编码存储的字节失效。此外还保留了针对VARCHAR → CHAR的ModifyTypePrecheck特殊路径用于兼容带尾部空格字符串的转换校验。回滚把状态一步步退回原位在线 DDL 必须支持用户中途CANCEL或失败自动回滚。设计文档给出的回滚原则是把colA、idxA置回StatePublic删除changingColA、changingIdxA若已改动 flag 等属性一并还原。当前实现按类别提供不同回滚函数见 pkg/ddl/modify_column.go 与 pkg/ddl/rollingback.go无需 reorg 的 jobrollbackModifyColumnJob清理旧列上的PreventNullInsertFlag、NotNullFlag与ChangingFieldType标记即可需要行 reorg 的 jobrollbackModifyColumnJobWithReorg额外把处于中间态追加的影子列与影子索引从TableInfo中摘除removeChangingColAndIdxs并把已建的索引 ID 交给 delete range 异步清理仅索引 reorg 的 jobrollbackModifyColumnJobWithIndexReorg遍历旧索引找到带UseChangingType标记的影子索引并删除。由于每次状态推进都先持久化 schema version 再继续DDL 在任何中间状态崩溃后重启都能依据 job 里持久化的ChangingColumn/ChangingIdxs从断点续跑或回滚这也正是文档强调“把 job 参数落盘、兼容新老版本”的工程价值所在。兼容性考量与 MySQL 的兼容边界设计文档明确给出第一阶段首版实现不支持的类型变更场景主键列当时集群索引clustered index支持尚不完整因此暂不支持修改主键列类型。这一限制延续至今当前源码仍显式拒绝例如 pkg/ddl/modify_column.go 会以ErrUnsupportedModifyColumn报错cant modify column in primary key分区表、生成列、表达式索引首版均不支持对这三类对象涉及的列做类型变更。需要说明的是作为 2020 年的设计文档这些“暂不支持”描述的是首版边界。后续实现逐步放宽并加上了专项校验例如对分区表的列类型变更已有checkPartitionModifiableColumn系列检查要求分区列保持类型兼容详见 pkg/ddl/modify_column.go对生成列依赖、列存索引等也有专门的约束检查因此判断具体版本能否执行某类变更应以该版本实际报错信息为准。与 TiDB 自身的滚动升级兼容文档特别指出由于modify/change column语句本就存在ActionModifyColumn一旦开放更多能力滚动升级rolling upgrade期间新旧节点对同一个 DDL job 的理解就会不一致。当时的处理方案是引入全局开关变量tidb_enable_change_column_type新集群在 bootstrap 阶段默认置为 true需要滚动升级的存量集群默认 false由用户显式开启。从当前仓库的活跃系统变量集合看该变量已不在其中说明随着 DDL job 参数版本化job args 显式携带OldColumnID、ModifyColumnType、ChangingColumn等参见 pkg/meta/model/job_args.go以及整体能力成熟这类“大开关”逐渐退出了历史舞台——这正是设计文档中“分多个 PR 落地、用开关隔离”策略的演进结果。与其它组件的联动列类型变更会改写行与索引编码因此 TiDB 需要与周边组件保持同步文档点名了 Tools导入导出工具、TiFlash 与 BR备份恢复修改了编码方式老备份在恢复后必须能正确解码或触发相应 DDL 重放存在 TiFlash 副本时TiFlash 侧的列编码也要跟随变更这也是当前实现把“带 TiFlash 副本的表”强制归入ModifyTypeReorg、并发出notifier.NewModifyColumnEvent通知事件给下游的原因之一。生成列与表达式索引的特殊性当被改列参与生成列或表达式索引时文档要求实现上必须遵守三条特性生成列stored 或 virtual与被改列相关的生成列其自身类型保持不变执行 INSERT 时被改列的新值会经由包含该列的生成列表达式求值影响相关生成列的值随被改列的值同步变化。表达式索引涉及被改列的表达式索引其Type、DBType、Max_length等元数据可能被同步修改INSERT 时被改列的值会受到表达式索引中该列表达式的影响。这两段描述的工程含义是不能只“翻译”列本身还必须联动重建表达式的求值结果。当前实现为表达式索引建立了内部隐藏列Hidden column修改列类型时其 FieldType 需保持一致或同步迁移对生成列依赖代码中也有checkModifyColumnWithGeneratedColumnsConstraint、isGeneratedRelatedColumn等约束检查来拦截可能破坏表达式语义的修改。测试与验证路径若想深入验证上述行为可重点阅读以下测试与实现文件pkg/ddl/modify_column.goonModifyColumn主入口及全部状态机、预检、回滚逻辑pkg/ddl/column_type_change_test.go覆盖整数类型互转、回滚、显示长度忽略、default 值、CAST 失败、取消/崩溃恢复等场景如TestColumnTypeChangeStateBetweenInteger、TestRollbackColumnTypeChangeBetweenInteger、TestCastDateToTimestampInReorgAttributepkg/ddl/modify_column_test.go 与 pkg/ddl/column_modify_test.go列定义修改与类型变更的系统级用例pkg/table/tables/tables.goDML 写路径对 changing column 的双写/派生实现以及GetChangingColVal读取时按依赖列恢复影子列值的解析逻辑pkg/table/tables/tables.gopkg/meta/model/column.go、pkg/meta/model/job_args.go、pkg/meta/model/table.goDDL job 参数与列/索引元数据模型。小结TiDB 的“修改列类型”功能本质上是一个以旧列值派生、新类型落盘为核心的在线 DDL它通过在TableInfo中临时增列、分批回填、同步双写、最终切换四步把「重写整表数据 重建相关索引」这样一个看似不可能在线完成的动作拆解为可暂停、可续跑、可回滚的状态机。理解这份 2020 年的设计文档及其在 pkg/ddl/modify_column.go 等文件中的落地形态你就能解释为什么ALTER TABLE ... MODIFY COLUMN有的变更瞬间完成、有的变更需要长时间回填也能在真实业务中预判哪些类型转换会被拒绝主键列、越界数据、不兼容的分区列变更等——这些判断依据都可以在源码与测试中直接检索、复现。【免费下载链接】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),仅供参考