Author: 翊云
在上一篇文章《白话 MySQL Online DDL 2》中,笔者从代码层面梳理了 MySQL DDL 的执行过程,并且在文章的最后留下了一个问题:INT 列转为 BIGINT 可以不锁表吗?
社区 MySQL 的答案并不理想。虽然 INT 的所有取值都可以使用 BIGINT 表示,整个转换过程不会发生数据溢出,但是社区 MySQL 仍然只能使用 Copy DDL。Copy DDL 会在 Server 层创建临时表,然后逐行读取老表、转换数据并写入新表。为了保证拷贝期间的数据一致性,整个过程不允许并发 DML。对于一张大表来说,执行一次看似安全的类型扩宽,可能意味着业务需要经历几个小时甚至更长时间的写入阻塞。
上一篇文章已经给出了结论:INT 列转为 BIGINT 完全可以不长时间阻塞 DML,并且 AliSQL 已经实现了对应的 Online DDL。不过,实现 Online 只是这个问题的起点。把 Copy DDL 改成 Inplace DDL 之后,如何正确地转换全量数据和 DDL 期间产生的增量数据?Inplace DDL 仍然需要重建整张表,能不能使用多核并行完成?即使并行重建已经足够快,能不能像 Instant ADD COLUMN 一样,完全不读取和重写已有数据?
本文将沿着这三个问题,介绍 AliSQL 中 INT 列转 BIGINT 从 Online、并行到 Instant 的实现过程。和前两篇文章一样,本文所讨论的“不锁表”是指 DDL 的主要执行阶段允许并发 DML,并不代表整个 DDL 生命周期完全不需要 MDL X 锁。Inplace 和 Instant DDL 在开始和结束阶段仍然需要短暂持有 MDL X 锁,这一点在上一篇文章中已经做过详细介绍,本文不再重复。
MySQL Server 层和 InnoDB 层使用了两套不同的类型系统。Server 层使用 Field 描述列,能够区分 MYSQL_TYPE_TINY、MYSQL_TYPE_SHORT、MYSQL_TYPE_LONG、MYSQL_TYPE_LONGLONG 等 SQL 类型;InnoDB 层则将这些整数统一描述成 DATA_INT,再通过 len 和 prtype 区分长度以及 signed/unsigned 属性。
当 Server 层处理 ALTER TABLE 时,会调用 Field::is_equal 判断新老列定义是否兼容,再由 fill_alter_inplace_info 将判断结果转换成 Handler 接口能够识别的 flags。对于社区版本来说,INT 和 BIGINT 是两个不同的 SQL 类型,存储长度也分别是 4 字节和 8 字节,所以 Field::is_equal 会认为两者不兼容。Server 层随后设置 ALTER_STORED_COLUMN_TYPE,InnoDB 在 check_if_supported_inplace_alter 中发现自己不支持这种操作,最终只能退回到 Copy DDL。
社区版本的处理过程如下:

这里其实存在一个信息差。Server 层知道 INT 和 BIGINT 是不同的类型,但是没有进一步区分“安全的类型扩宽”和“可能丢失数据的类型转换”;InnoDB 层虽然将两者都视为 DATA_INT,但是并不知道 Server 层是否已经确认转换安全。为了保证通用性,社区版本只能将所有列类型转换留在 Server 层,通过 Copy DDL 完成。
因此,要让 INT 转 BIGINT 支持 Inplace DDL,不能简单地让 InnoDB 忽略 ALTER_STORED_COLUMN_TYPE。这样做虽然能够进入 Inplace 流程,但扫描老表和应用增量日志时仍然会把 4 字节的 INT 当成 8 字节的 BIGINT,得到错误的数据。完整的实现至少需要解决两个问题:
AliSQL 在 Field::is_equal 的返回值中增加了 IS_EQUAL_UPGRADE,用来表示新老列定义并不相同,但是这个变化属于安全的单向升级。对于整数类型,Field_num::is_upgrade 会按照类型的取值范围进行判断,例如:
类型范围只是第一个条件。新老列的 signed/unsigned 属性必须保持一致,AUTO_INCREMENT 属性也不能在这个过程中发生变化。INT UNSIGNED 转 BIGINT UNSIGNED 是安全升级,但是 INT 转 BIGINT UNSIGNED 并不是简单的长度扩展,不能进入这个逻辑。反方向的 BIGINT 转 INT 可能发生溢出,自然也不属于安全升级。
在 Online 转换这条路径中,Server 层识别出 IS_EQUAL_UPGRADE 后,不再将其作为普通的 ALTER_STORED_COLUMN_TYPE 交给 InnoDB,而是将其标记为 RECREATE_TABLE,转换成一次需要重建表的 Inplace DDL。这个过程和 OPTIMIZE TABLE 很相似:创建新的 InnoDB 表和 IBD 文件,扫描老表并构建全部索引,最后用新表替换老表。两者最重要的区别是,INT 转 BIGINT 在重建过程中还需要修改记录格式。
主要代码路径可以简化为:
|--> fill_alter_inplace_info
| |--> Field_long::is_equal
| | |--> Field_num::is_upgrade
| | |--> return IS_EQUAL_UPGRADE
| |
| |--> RECREATE_TABLE
| |
| |--> ha_innobase::check_if_supported_inplace_alter
| | |--> INNOBASE_ALTER_REBUILD
修改后的执行路径如下:

从 Copy 到 Inplace 的关键并不是少创建了一张临时表。Inplace DDL 同样会创建新的 IBD 文件,也同样需要额外的数据空间。真正的变化是数据重建从 Server 层下沉到了 InnoDB 层,因此可以复用 InnoDB 的 Online DDL 框架,在扫描老表的同时允许业务继续进行增删改。
上一篇文章介绍过,MySQL 8.0 的 Inplace DDL 使用 Parallel_reader 扫描老聚簇索引,并通过 Builder 为每一个目标索引生成记录。对于普通的表重建,Builder::copy_columns 只需要按照新表的列映射复制数据;对于 INT 转 BIGINT,复制后的 dfield 使用的是新列定义,数据内容却仍然来自老记录,两者的长度并不相同。
以 INT 转 BIGINT 为例,老记录中的数据长度是 4 字节,目标 dfield 的 len 是 8 字节。AliSQL 在复制列数据后检查目标长度是否大于实际数据长度,如果是安全的 DATA_INT 升级,就调用 dfield_upgrade_data 完成扩宽:
|--> Builder::add_row
| |--> Builder::copy_columns
| | |--> dfield_copy
| | |--> dfield_upgrade_data
| | | |--> dfield_upgrade_data_int
| | | | |--> upgrade_data_int
InnoDB 为了让整数记录可以直接按照字节顺序进行比较,磁盘中的 DATA_INT 并不是 Server 层使用的小端整数格式,而是按照大端顺序保存,并且有符号整数还会翻转最高位的符号位。因此,4 字节扩展到 8 字节并不是在数据末尾简单补 0:
整个全量转换过程可以表示为:

转换发生在 Builder 生成目标索引记录的过程中,因此不论被修改的列属于主键、二级索引,还是只存在于聚簇索引的非键字段,最终写入新表的记录都会使用新的 8 字节格式。对于 NULL 值则不需要转换,只需保留 NULL 状态。
完成全量数据转换并不意味着 Online DDL 已经正确。Inplace DDL 的主要阶段允许并发 DML,在扫描和构建新表的几分钟甚至几个小时内,老表上还可能发生大量 INSERT、UPDATE 和 DELETE。这些操作以老表为准,生成的数据自然也是老表格式。
对于需要重建表的 Online DDL,InnoDB 会将这些变化记录到老聚簇索引的 row_log 中。全量数据构建完成后,row_log_table_apply 会将日志中的记录转换成新表的行,再应用到新聚簇索引。INT 转 BIGINT 必须在这条路径中执行和全量扫描相同的格式转换,否则 DDL 开始前存在的行是 8 字节,而 DDL 期间插入或更新的行仍然是 4 字节,新表中就会出现两种无法正确解析的记录。
增量转换的主要路径如下:
|--> row_log_table_apply
| |--> row_log_table_apply_ops
| | |--> row_log_table_apply_convert_mrec
| | | |--> 根据 col_map 构造目标 row
| | | |--> dfield_validate_upgrade
| | | |--> dfield_upgrade_data
| | |
| | |--> row_log_table_apply_insert/update/delete
row_log_table_apply_convert_mrec 首先根据 col_map 将老索引中的字段映射到新表。对于长度发生扩宽的字段,它会使用目标列定义构造 dfield,然后调用 dfield_upgrade_data。这样,全量扫描和增量回放最终都会产生完全相同的新记录格式。

上一篇文章还介绍过,row_log 会在 Inplace 主阶段结束时应用一次,在 commit 阶段持有更严格的锁之后再次应用一次。第一次应用用于控制 commit 阶段需要追赶的日志量,第二次应用负责处理两次应用之间产生的最后一批 DML。INT 转 BIGINT 的增量转换会同时覆盖这两次应用。
这也是 Online 类型转换最容易遗漏的地方:只修改 DDL 路由,功能无法正确执行;只修改全量扫描,功能只能在没有并发写入时正确。只有 Server 层路由、全量构建和 row_log 应用三条路径同时打通,INT 转 BIGINT 才真正成为一个完整的 Online DDL。
需要强调的是,IS_EQUAL_UPGRADE 只表示 MySQL 能够明确判断安全的单向类型升级,并不是放宽所有列类型转换。只有前面介绍的取值范围和属性检查全部通过,才能进入 Online 转换;其他类型变化和组合操作仍然沿用 MySQL 原有的校验与执行路径。
到这里,INT 转 BIGINT 已经从 Copy DDL 变成了 Inplace DDL。业务不再需要等待整张表拷贝完成后才能继续写入,但是 Inplace 并没有消除表重建:老表仍然要被完整扫描,新表中的聚簇索引和全部二级索引仍然要重新构建。对于大表来说,接下来的问题自然变成了:这个过程能不能更快?
上一篇文章已经详细介绍了 MySQL 8.0 的 Parallel DDL。简单回顾一下,社区版本将索引构建分成 scan 和 load 两个阶段:scan 阶段使用 Parallel_reader 扫描老聚簇索引,将目标索引记录写入内存 buffer 或临时文件;load 阶段对临时文件进行排序,再使用 Btree_load 构建最终的 B+tree。
从这个描述看,MySQL 8.0 已经支持 Parallel DDL,似乎只要把 innodb_parallel_read_threads 和 innodb_ddl_threads 调大,INT 转 BIGINT 的表重建就可以并行执行。实际情况并不是这样,社区版本的并行能力有两个重要限制。
第一个限制是聚簇索引不能并行构建。表重建时,新聚簇索引的记录顺序和老聚簇索引的扫描顺序一致,因此 Builder 会跳过临时文件排序,扫描到一批记录后直接调用 Btree_load 构建 B+tree。这种方式避免了一次昂贵的落盘和外部排序,但是要求送给 Btree_load 的记录严格有序。
社区 Parallel_reader 的目标是通用并行扫描,它会把一棵 B+tree 切分成多个 scan context,再由工作线程动态领取任务。同一个线程可能先处理左侧的一个区间,再处理右侧的另一个区间;从全局看所有区间互不重叠,但是单个线程处理的数据并不一定连续。如果多个线程同时把这些记录直接写入同一棵新聚簇索引,就无法保证写入顺序。社区版本因此做了一个保守选择:只要 DDL 需要重建聚簇索引,scan 阶段就退化为单线程。
第二个限制是二级索引的并行粒度不够细。对于不需要重建表的 ADD INDEX,社区版本可以并行扫描老表,每个扫描线程生成一份临时文件,load 阶段也可以并行排序这些文件。但是到了最终的 BTREE_BUILD 状态,一个 Builder 只生成一个构建任务,而一个 Builder 对应一个索引。换句话说,多个二级索引之间可以并行,单个二级索引内部的 B+tree 构建仍然是单线程。如果一张表只有一个很大的二级索引,大量 DDL 线程在最终构建阶段仍然无事可做。
对于 INT 转 BIGINT 这种需要重建表的 DDL,两个限制还会叠加:聚簇索引迫使 scan 阶段使用单线程,每个二级索引也就只有一份 Thread_ctx 和临时文件;load 阶段虽然可以同时处理不同索引,但是并行度受索引数量限制。

要让表重建真正并行,不能只增加几个工作线程,而是要同时解决两个问题:如何让多个线程在保持主键全局顺序的前提下独立构建聚簇索引,以及如何把单个二级索引拆成多个可以并行执行的构建任务。
一棵 B+tree 从左到右天然有序。如果能够把老聚簇索引切分成 N 个连续、互不重叠的区间,那么每个线程就可以只扫描其中一个区间,并按照主键顺序生成一棵独立的子树。因为任意一个左侧区间中的记录都小于右侧区间中的记录,最后不需要重新排序所有数据,只需要按照区间顺序把 N 棵子树连接起来。
这个思路看起来很直接,实现时最重要的是“连续”。社区 Parallel_reader 为了负载均衡,允许一个线程处理多个不连续的 context;并行 B-tree 构建则要求一个线程只处理一个连续区间,否则一个线程生成的子树内部就可能出现主键跳跃,后续无法低成本合并。
AliSQL 为 DDL 增加了专门的 range split 逻辑。切分过程会根据目标线程数在 B+tree 的非叶子层选择边界,并在必要时进一步统计下一层的记录分布,尽量生成大小均衡的连续区间。与普通 Parallel_reader 不同,这些区间不会再由线程反复领取:一个线程最多执行一个 context,一个 context 对应一个确定的主键范围。
每个扫描线程都有自己的 Thread_ctx、Key_sort_buffer 和 Btree_load。由于单个范围内的扫描顺序和主键顺序一致,不同 buffer 之间也是连续的,所以 Key_sort_buffer 写满后不需要排序,就可以直接交给对应的 Btree_load。每个线程最终都能够独立生成一棵合法的 B+tree 子树。
整个过程如下:

这里的每一个 Subtree 都不是临时的记录集合,而是一棵已经包含叶子页和非叶子页的 B+tree。与“多个线程并发向同一棵树插入”相比,每个线程构建自己的子树几乎不需要在索引结构上互相同步,并行度也更容易扩展。
scan context 在切分时会得到一个单调递增的 context id,它同时代表了对应主键范围在整棵老聚簇索引中的位置。扫描结束后,Builder 收集所有有效的 Btree_load,按照 context id 排序,然后从左到右依次调用 merge_btree_low 合并相邻子树。
两棵子树的高度不一定相同,合并时主要存在三种情况:
前两种情况可以在右子树根节点所在的层级找到左子树最右侧页面,然后连接两个子树在更低层级的左右页面,并将右子树根节点中的记录合并到左侧对应页面。如果右子树更高,则先提升左子树的高度,再按照相同方式处理。
在叶子层,合并过程会将左子树最右页面的 next 指向右子树最左页面,同时将右侧页面的 prev 指回左侧页面。对于非叶子层,也要维护相应的页面连接和父节点记录。合并前还会比较左子树的最后一条记录与右子树的第一条记录,确认边界严格有序,防止 range split 或构建过程中的错误被带入最终索引。

社区版本中,聚簇索引 Builder 在 ADD 状态结束后直接进入 FINISH;并行构建增加了 BTREE_MERGE 状态,只有所有子树完成合并后才会进入最终清理阶段:
|--> Parallel_cursor::scan
| |--> create_ranges_simple_for_ddl
| |--> Parallel_reader::run
| | |--> 每个线程扫描一个连续 context
| | |--> 每个 Btree_load 构建一棵子树
| |
| |--> Builder::btree_merge
| | |--> 按 context id 排序
| | |--> merge_btree_low
| | |--> finalize_all
最终的子树合并仍然包含一段串行收尾,但是它操作的是已经构建好的 B+tree 页面,不需要重新读取、排序和插入全部记录。表重建中最重的扫描和页面构建工作已经分散到多个线程。
聚簇索引能够并行构建后,表重建的 scan 阶段不再被迫使用单线程。每个二级索引 Builder 也会得到多个 Thread_ctx,每个扫描线程将生成的二级索引记录写入自己的 Key_sort_buffer 和临时文件。这样解决了扫描阶段的并行问题,但社区版本在 BTREE_BUILD 状态仍然只为每个二级索引生成一个任务:一个 Merge_cursor 同时读取所有排好序的临时文件,再由一个 Btree_load 构建整棵二级索引。
如果一张表有很多二级索引,不同 Builder 可以被 innodb_ddl_threads 并行调度;如果只有一个体积很大的二级索引,最终构建仍然只能使用一个线程。这是一种“索引之间并行”,而不是“单个索引内部并行”。
要并行构建单个二级索引,仍然可以使用聚簇索引的子树思路,但输入已经不再是老表上的连续主键范围,而是多个分别排好序的临时文件。AliSQL 将这条路径分成四个阶段:

split key 只是根据样本得到的近似分界点,因此各分区的数据量不一定完全相同,但是不影响正确性。每个构建任务会在所有临时文件中找到属于自己范围的数据,最终生成的子树仍然互不重叠并且全局有序。
对于普通二级索引,只要检查相邻子树的完整记录顺序即可。唯一二级索引还需要注意一个细节:InnoDB 的物理二级索引记录会在用户定义的唯一键后面追加聚簇索引字段,因此两条用户 key 相同的记录在完整物理记录上仍然可能有序。合并边界除了比较完整记录,还必须再比较用户定义的唯一键前缀,才能发现跨子树的重复键。
经过这个扩展,二级索引不再只能按照索引数量获得并行度。即使表上只有一个很大的二级索引,也可以拆成多个 key range,由多个 DDL 线程同时构建。
B+tree 构建并行化之后,新的瓶颈会出现在页面分配上。社区 Btree_load 在需要新页面时逐页从索引 segment 申请空间。单线程构建时这不是主要问题,但是多个 Btree_load 同时为同一个索引构建子树,会频繁竞争 tablespace 和 segment 上的锁。
AliSQL 为 Btree_load 增加了 extent 粒度的页分配。表空间大小达到 innodb_ddl_big_alloc_threshold 后,Page_alloc 会一次从索引 segment 中预留一个 extent,后续页面直接从已经预留的范围中分配。一个 extent 中没有使用完的页面会在构建结束后归还给 segment,虽然不会立即缩小 IBD 文件,但可以继续被后续操作使用。
这项优化并不改变 B+tree 的逻辑结构,却减少了并行线程进入全局空间管理路径的次数。只有同时降低数据处理和页面分配两方面的竞争,并行构建才能真正转化成更高的 CPU 与 IO 利用率。
至此,表重建的主要过程已经从社区版本的有限并行,扩展成了主键和二级索引的全链路并行:

这里所说的“全链路并行”,是指聚簇索引和单个二级索引的主要扫描、排序和页面构建工作都具备了线程级并行能力,并不意味着每一个收尾步骤都可以并行,也不意味着线程数增加后性能一定线性提升。实际执行时间仍然会受到数据分布、索引数量、CPU、存储带宽、Buffer Pool 和并发业务负载的共同影响。
对于 INT 转 BIGINT 来说,Inplace 解决了“业务能不能继续写”的问题,并行 B-tree Build 进一步解决了“整张表能不能更快重建”的问题。但是不论使用多少个线程,算法仍然需要读取老表的每一行,并在新 IBD 文件中重新写入每一个索引,时间复杂度还是 O(N),还需要接近一张新表的额外空间。既然 INT 的所有值本来就能由 BIGINT 表示,能不能连这次表重建也省掉?
如果只从数值范围考虑,INT 转 BIGINT 似乎只要把数据字典中的列类型改掉即可,因为任何一个合法的 INT 都是合法的 BIGINT。问题在于,列定义不仅决定了数值范围,还决定了记录的物理格式。
假设一张表包含 id、c1、c2 三列,其中 c1 是 INT。DDL 执行前,聚簇索引中的 c1 只占 4 字节,c2 紧跟在它后面。如果直接把数据字典中的 c1 改成 BIGINT,InnoDB 会按照 8 字节读取旧记录中的 c1,其中后 4 字节实际上属于 c2,c1 和 c2 都会被错误解析。

Online 转换选择重建表,就是为了把所有旧记录统一改成新格式。Instant DDL 不允许扫描和重写已有数据,所以必须接受一个新的事实:DDL 完成后,同一张表中会同时存在 4 字节的旧 c1 和 8 字节的新 c1。要正确读取数据,InnoDB 不仅要知道当前列定义,还要知道每一行是在哪一个表结构版本下写入的。
因此,Instant Modify 的核心并不是“瞬间修改列类型”,而是为同一个逻辑列保留多个物理版本,并在读取每条记录时选择正确的版本。
MySQL 8.0 最早支持的 Instant ADD COLUMN 主要依赖 instant default:老记录中不存在新列,读取时直接返回数据字典中保存的默认值。后续的 Instant ADD/DROP V2 进一步在聚簇记录中引入 row version,使同一张表可以同时包含多个物理列布局。
每执行一次 Instant ADD 或 DROP,表的 current_row_version 就会向前推进。新写入的聚簇记录保存当前 row version;DDL 之前已经存在的老记录如果没有版本信息,则视为 version 0。数据字典中的列通过以下信息描述自己在哪些版本中存在:
读取记录时,InnoDB 先取得 row version,再判断目标列在这个版本中是否存在,并找到它对应的 physical_pos。Instant DROP 并不会立即从数据字典和老记录中彻底删除一个列,而是将它改成 Server 不可见的隐藏列,继续保留解析历史记录所需的类型和位置。
这套机制已经解决了“不同记录具有不同列集合”的问题,但是 Modify 和独立的 Drop、Add 仍然存在区别。对于普通 Drop,隐藏旧列和当前表中剩余的列没有关系;对于普通 Add,新列在老记录中没有值,可以使用默认值。INT 转 BIGINT 则要求 InnoDB 明白:新 BIGINT 和隐藏的旧 INT 是同一个逻辑列的两个版本,读取旧行时不能返回 BIGINT 的默认值,而是要找到旧 INT 的数据并完成扩宽。
AliSQL 在社区 Instant ADD/DROP V2 的基础上,将一次 Instant Modify 抽象成有关联关系的 Drop + Add:
假设 c1 在 version 0 时是 INT,第一次 Instant Modify 发生在 version 1,数据字典中的关系可以表示为:

如果以后同一个列再次进行 Instant Modify,新的当前列还可以继续通过 parent_physical_pos 指向前一个版本,最终形成一条从当前列回溯到最老物理列的版本链。InnoDB 在加载表定义时,会根据这些关系将历史 dict_col_t 组织到当前逻辑列下面,使一个索引字段能够按照 row version 找到对应的列元数据。
为什么需要同时保存 version_modified 和 parent_physical_pos?version_modified 描述的是时间关系,表示旧列在哪一个版本被替换;parent_physical_pos 描述的是结构关系,表示当前列的上一个物理版本在哪里。两者结合后,InnoDB 才能在多次 Modify、Add 和 Drop 交错发生时恢复完整的列版本链。
Server 层仍然使用前面介绍的 IS_EQUAL_UPGRADE 判断 INT 转 BIGINT 是否安全。当 Instant 类型升级开关打开,并且本次 DDL 满足 Instant 条件时,fill_alter_inplace_info 会设置 UPGRADE_STORED_COLUMN_TYPE,并在 Create_field 中标记这是一个 upgraded field。
InnoDB 的 innobase_support_instant 将这种操作识别成 INSTANT_MODIFY_COLUMN。它复用了社区 Instant ADD/DROP 的执行框架:prepare_inplace_alter_table 不创建新 IBD 文件,inplace_alter_table 不扫描老聚簇索引,真正的修改集中在 commit_instant_ddl 中完成。
主要代码路径如下:
|--> innobase_support_instant
| |--> INSTANT_MODIFY_COLUMN
|
|--> Instant_ddl_impl::commit_instant_ddl
| |--> populate_to_be_instant_columns
| | |--> old INT 放入 cols_to_drop
| | |--> new BIGINT 放入 cols_to_add
| |
| |--> commit_instant_drop_col
| |--> commit_instant_add_col
| |--> current_row_version++
populate_to_be_instant_columns 会将被 Modify 的老列和新列分别加入 drop、add 集合。commit_instant_drop_col 把老 INT 转换成隐藏列,保存 version_modified、physical_pos 以及已有的 parent_physical_pos;commit_instant_add_col 为新 BIGINT 保存 version_added、physical_pos 和指向老列的 parent_physical_pos。最后推进 current_row_version,并使内存中的 dict_table_t 失效,后续重新打开表时按照新的 DD 信息构造列版本链。
整个过程只修改数据字典和 InnoDB 的表定义,没有读取用户记录,也没有创建新的 IBD 文件。DDL 的工作量不再随着表中记录数增长,这就是 Instant 相比并行 Inplace 更进一步的地方。
沿用上面的首次 Instant Modify 示例,DDL 完成后的表中可能同时存在两种记录格式:DDL 之前写入的 version 0 记录,以及 DDL 之后新写入的 version 1 记录。两类记录面对的是同一个 Server 列定义 c1 BIGINT,但是物理位置和数据长度不同。

读取一条聚簇记录时,rec_get_nth_field_instant 首先从记录头中取得 row version。对于没有版本标记的老记录,row version 使用 0。随后,get_field_col_version 沿着当前列的历史版本链查找在这个 row version 下有效的 dict_col_t,get_field_off_pos_version 再返回该历史列对应的物理位置。
如果读到的是 version 0 记录,InnoDB 会从 P_old 取出 4 字节 INT;如果读到的是 version 1 记录,则从 P_new 取出 8 字节 BIGINT。Server 层的 mysql_row_templ 在两种情况下都要求一个 8 字节 BIGINT,所以 row_sel_field_store_in_mysql_format 发现目标长度大于实际数据长度时,会调用和 Online rebuild 相同的 upgrade_data_int,对旧 INT 进行零扩展或符号扩展。
读取路径可以简化为:
|--> rec_get_nth_field_instant
| |--> 读取 row version
| |--> get_field_col_version
| |--> get_field_off_pos_version
| |--> 按历史 physical_pos 读取数据
|
|--> row_sel_field_store_in_mysql_format
| |--> mysql_col_len > stored_len
| |--> upgrade_data_int
这套逻辑的关键是将“逻辑列”和“物理列”分开。对于 Server 层来说,表中始终只有一个 c1 BIGINT;对于 InnoDB 来说,c1 可能对应多个 dict_col_t,每个 dict_col_t 描述某一批历史记录中的实际类型和位置。
除了普通查询,其他可能读取聚簇记录的路径也需要理解 row version。例如,Instant Modify 之后再执行一次 Online rebuild,扫描到的老行可能仍然保存 INT,新行则已经保存 BIGINT。Builder 和 row_log apply 必须先根据记录版本找到正确的历史列类型,再决定是否需要扩宽,最终才能将所有记录统一写成当前格式。也就是说,Instant Modify 不只是一次 DDL 元数据改动,它会让后续所有记录解析路径面对多版本列格式。
DDL 完成后,新 INSERT 使用表的 current_row_version,直接按照 BIGINT 定义将 c1 写到 P_new。旧记录不会因为 DDL 自动发生变化,它们仍然在 P_old 保存 4 字节 INT。只有当旧记录后续因为 UPDATE 等操作被重新生成时,才会按照当前表定义写成最新格式。
这种设计把一次集中式的全表重建,变成了读取时兼容旧格式、写入时使用新格式。对于一张很少更新的历史大表,大部分记录可能长期保持 version 0;对于更新频繁的表,旧格式记录则会随着业务写入逐渐减少。不论新旧记录的比例如何,用户看到的列定义和查询结果始终都是 BIGINT。
当然,省掉 DDL 时的数据转换并不意味着转换成本完全消失。读取旧记录时仍然需要判断 row version、查找列版本并把 4 字节扩宽为 8 字节。与重建整张大表相比,单行转换的开销很小,但它说明 Instant DDL 本质上是在“DDL 时一次性转换”和“后续读取时按需转换”之间做了取舍。
Instant Modify 依赖聚簇记录中的 row version 和列 physical_pos,因此它不能无条件替代 Online rebuild。
最典型的限制是索引列。INT 和 BIGINT 不仅在聚簇记录中的长度不同,在索引记录中的 key 格式也不同。已有索引页是按照 4 字节 INT 排列和比较的,不能只修改聚簇记录的列版本就把它们变成 8 字节 BIGINT。特别是二级索引记录没有完整复制聚簇记录的列版本关系,无法让同一棵已有索引同时按照两种 key 格式工作。因此,被修改列属于主键或二级索引时,仍然需要通过 Online rebuild 重建相关索引。
对于这类必须重建索引的场景,仍然可以使用第一章介绍的 Online 转换,并由第二章的 Parallel B-tree Build 加速。Instant 是在条件允许时能够跳过表重建的更优路径,而 Online rebuild 则是能够统一所有物理记录格式的基础路径。
现在可以完整回答上一篇文章留下的问题:INT 列转为 BIGINT 可以不锁表吗?答案不只是“可以”,而是有一条逐步演进的实现路径。
第一步是从 Copy 到 Inplace。AliSQL 在 Server 层区分安全的类型扩宽和普通类型转换,将 INT 转 BIGINT 下沉到 InnoDB,并在全量扫描和 row_log 应用两条路径中完成相同的数据格式转换。这样,表重建期间可以继续执行 DML,解决了 Copy DDL 长时间阻塞业务写入的问题。
第二步是从串行构建到全链路并行。社区版本为了保持聚簇索引的主键顺序,表重建的扫描和主键构建主路径只能使用单线程,并且二级索引的构建并行度受索引数量限制。AliSQL 将聚簇索引切分成连续范围,由多个线程独立构建 B+tree 子树;又通过采样和 key range 切分,让单个二级索引也能够并行构建,最后统一合并子树。配合 extent 粒度的页面分配,Online 类型转换可以更充分地利用多核和存储带宽。
第三步是从 Inplace 到 Instant。Inplace 无论多快,仍然需要 O(N) 的数据读取和写入。AliSQL 复用社区 Instant ADD/DROP V2 的 row version,在隐藏旧列和当前列之间增加 version_modified 与 parent_physical_pos,为同一个逻辑列建立物理版本链。旧行继续保存 4 字节 INT,新行使用 8 字节 BIGINT,读取时根据 row version 选择正确的列版本。整个 DDL 只修改元数据,不再扫描和重建已有数据。
从 Copy、Inplace、Parallel Inplace 到 Instant,这几个方案并不是彼此独立的功能。Online rebuild 提供了正确性和通用性,并行构建降低了重建成本,Instant 则在条件允许时完全绕过重建。对于必须重建索引的场景,仍然可以回到并行 Online 路径;当后续执行表重建时,又可以将 Instant 留下的多版本记录统一成最新格式。
一个看起来很简单的 INT 转 BIGINT,背后涉及 Server 和 InnoDB 类型系统、Online DDL 增量日志、Parallel Reader、B+tree Bulk Load,以及 Instant DDL 的行版本管理。也正是把这些机制串联起来之后,这个在社区版本中必须阻塞写入的操作,才真正变成了可以 Online、可以并行,也可以 Instant 的 DDL。
最后打个广告:本文提到的所有 AliSQL 的优化都已经在阿里云数据库 RDS MySQL 上线,欢迎使用。