最近在梳理openGauss内核里DDL的执行链路正好有个模拟项目卡在ALTER TABLE上于是花了两天时间把这块代码完整走了一遍。说实话alter table这种看似简单的一条SQL往下钻进去才发现里面的调度逻辑远比想象中复杂。从语法解析到元数据变更再到可能的表数据重写环环相扣每一层都有自己的约束和取舍。尤其openGauss在继承PG思路的同时又对分区表、列存表这些场景做了大量扩展直接看代码容易在分支里迷路。这篇文章就按我实际走读的顺序把ALTER TABLE的实现流程拆开揉碎从整体链路到关键函数再到排查技巧一次讲清。1. 整体链路一条ALTER TABLE的前半生1.1 从客户端到内核的第一站用户发来一条ALTER TABLE t1 ADD COLUMN c1 INT;这条SQL在openGauss里走的路径非常长但可以粗略分成两大段一段是进入执行引擎之前的解析、分析、改写另一段是从执行引擎进入DDL专用处理框架后的落地执行。第一段相对通用任何DDL语句都要走第二段才是alter table的真正核心。第一站的入口是客户端把SQL文本发给服务端后由词法分析器和语法分析器gram.y把文本翻译成一棵能被内核识别的语法树。在gram.y里ALTER TABLE相关的语法规则会映射出AlterTableStmt这个节点里面包含relation目标表、cmds命令列表、objtype操作对象类型比如OBJECT_TABLE、OBJECT_INDEX、OBJECT_COLUMN等。这一步虽然繁琐但机械化真正有意思的是后续怎么把一个AlterTableStmt里混合的多个子命令分出先后顺序。比如一条SQL里可以同时写ADD COLUMN、ALTER COLUMN TYPE、DROP COLUMN这三个操作对系统元数据和物理存储的影响完全不同。有的只需要改pg_attribute里的一行有的要把整张表的数据读出来重写一遍有的又要求必须先删索引再删列。所以openGauss不会简单按语法顺序执行而是在后面专门设计了一个“三段式”调度框架来做任务编排这个框架就是ATController。1.2 两条路改元数据与重写数据理解alter table的实现最关键的是意识到它不是一条单一路径而是会分叉成两条性质完全不同的子路径。第一条路径是“纯元数据操作”。比如修改列的默认值、重命名列、修改表空间这些操作只涉及系统表比如pg_attribute、pg_class、pg_constraint里记录的更新不需要碰表里的业务数据。这类操作通常很快小表大表都没什么区别。第二条路径是“数据重写操作”。典型的是修改列类型比如从INT改成BIGINT或者把VARCHAR(10)改成VARCHAR(100)甚至修改列的存储方式。这些操作会改变行数据在磁盘上的物理布局openGauss的常规策略是新建一个临时表把旧表数据一条条读出来按新结构转换后写入临时表再把临时表切换成正式表。这个过程中表会被排他锁锁住业务写入全部阻塞大表的alter type往往就在这里卡住业务。还有一类介于两者之间的操作比如ADD COLUMN。如果新列带非空默认值在旧版本里也需要重写全表因为要回填默认值但在现代版本里单纯加一个允许NULL的列或者带常量默认值的列可以只更新元数据把默认值记在pg_attrdef里读取的时候再合成。openGauss在这点上对普通表做了优化但列存表有自己的一套处理逻辑后面会专门讲到。这两条路径的分叉点就在ATController里搞清楚它的调度规则alter table的代码走读就成功了一大半。2. ATController整条流程的调度中枢2.1 调度框架的三阶段设计源码里看alter table的核心入口是ATController这个函数。它接收AlterTableStmt、关系对象、重写标志等参数内部再把工作拆成三个明确的阶段ATPrepCmd、ATExecCmd、ATPostAlterCmd。理解这三个阶段的职责就理解了整个alter table的设计哲学。static void ATController(AlterTableStmt *stmt, Relation rel, List *cmds, bool recurse, LOCKMODE lockmode, bool forDDL, bool forStorage) { List *wqueue NIL; ListCell *lcmd; List *subcmds NIL; List *allowddl NIL; int expected_refs; ATPrepCmd(stmt, rel, cmds, recurse, lockmode, wqueue, forDDL, forStorage); ATRewriteCatalogs(wqueue, lockmode, forDDL, forStorage); subcmds ATExecCmd(stmt, rel, (List *)list_copy(cmds), recurse, lockmode, forDDL, forStorage); ATPostAlterCmd(stmt, rel, subcmds, recurse, lockmode); }ATPrepCmd阶段不直接修改任何东西它的任务是“检查”和“规划”。遍历每条AlterTableCmd判断这个操作合不合法、需要在哪个阶段做、会不会触发表重写、需要什么级别的锁。如果一条命令需要先做另一个前置操作比如DROP COLUMN之前要先处理依赖这个列的索引还会往wqueue队列中追加额外的任务。这个阶段就像施工前的图纸会审把所有问题暴露在动工之前避免执行到一半发现做不了。ATRewriteCatalogs是根据prep阶段的规划来更新系统表元数据。这一步本质上是把“我们要把表结构改成什么样”记录到pg_attribute、pg_class、pg_constraint等系统表里涉及的操作包括添加列的记录、删除列的定义、更新约束的定义、修改表在pg_class中的reloptions等。注意这一步执行完之后系统表里的结构已经变成新结构但磁盘上表数据的物理布局很可能还是旧样子这种“元数据先行”的设计是后续重写数据的基础。ATExecCmd才是真正执行数据层面操作的地方比如ALTER COLUMN TYPE会在这里扫描旧数据转换成新类型写入新表。ATPostAlterCmd是收尾阶段处理那些必须等前面所有操作完成之后才能做的事情比如重建索引、刷新统计信息。这三个阶段在一条alter table语句里可能被嵌套调用多次比如多级分区表递归处理分区时父表的命令执行会递归带上子分区。2.2 命令分类与执行顺序的秘密ATPrepCmd内部有典型的“分派器”逻辑可以根据AlterTableCmd的subtype分发到不同的处理函数。比如AT_AddColumn对应ATPrepAddColumnAT_AlterColumnType对应ATPrepAlterColumnType。但这里有个细节很多人初读代码容易忽略prep阶段的循环里会多次遍历命令列表第一遍先做通用检查比如确认目标表不是系统表、确认用户有权限第二遍才根据subtype做具体的预处理。执行顺序的编排比表面看要细致得多。比如同时提交ADD COLUMN和ALTER COLUMN TYPEopenGauss会强制把ADD COLUMN排到前面因为后面ALTER COLUMN TYPE有可能引用到刚添加的列。再比如DROP COLUMN和ADD CONSTRAINT组合DROP COLUMN要先处理约束依赖再删除列否则删除列时会因为约束引用而报错。这些顺序不是靠运气而是ATPrepCmd在扫描cmds列表时通过ATCheckCmdType这类逻辑调整出来的。还有一个有意思的点是wqueue任务队列。它专门用来收集“因当前命令而派生的额外命令”。比如对一个有继承关系的表执行ADD COLUMN如果声明了RECURSIVE那子表也要加列子表的ADD COLUMN命令就会追加到wqueue里。这个队列在ATPrepCmd末尾被传给ATRewriteCatalogs统一处理所以递归操作不是一层层靠外部驱动而是在框架内部自然完成。我在走读时踩过一个认知上的坑以为ATExecCmd阶段会重新处理一遍所有原始命令后来看到代码里对cmds做了list_copy并且在ATRewriteCatalogs之后执行这才意识到ATExecCmd处理的是“经过元数据更新之后的命令上下文”很多参数在prep阶段已经计算好存在AlterTableCmd结构里。这也是为什么读openGauss代码不要只盯着一个函数状态在多个阶段之间流转必须把三个阶段当整体看。2.3 锁的强度与事务边界ALTER TABLE的锁策略同样值得单独讲。ATController入口收到的lockmode参数在最上层从utility.c传下来的值是ALTER_TABLE_LOCK_MODE也就是ACCESS EXCLUSIVE锁。这意味着ALTER TABLE执行期间任何其他事务都不能对该表做读写操作。这个锁在ATPrepCmd阶段就通过ATPrepCmd里的lockmode一路传递下来。实际执行时保护分两层。Inherited表处理时父表和所有子表都要加锁普通表情况下锁只在当前表上。锁的持有时间是整个DDL事务周期从进入ATController开始一直持有到提交或回滚这是为了保证表结构变更原子性的必要代价。openGauss的在线DDL能力没有完全铺开大量ALTER TABLE操作依然是阻塞式的所以业务侧做表结构变更要特别注意窗口期。代码层面如果想确认某个子命令是否必须重写表可以看ATPrepCmd里对rel-rd_rel-relkind的判断比如RELKIND_RELATION和RELKIND_PARTITIONED_TABLE的处理就不同列存表和行存表的某个alter操作也可能走完全不同的实现分支。3. 核心数据结构与系统表的联动3.1 AlterTableStmt、AlterTableCmd与AlterTableRec代码走读离不开数据结构。这个场景下最重要的三个结构是AlterTableStmt、AlterTableCmd和AlterTableRec。AlterTableStmt在上面已经提过它是语法分析阶段的产物核心字段是relation和cmds。AlterTableCmd描述一条具体的子命令核心字段是subtype和一个联合体联合体里根据subtype不同存着列名、约束定义、默认表达式等等。typedef struct AlterTableCmd { NodeTag type; AlterTableType subtype; char *name; int16 num; bool is_partition; bool missing_ok; Node *def; Node *transform; Node *newVal; List *newvals; List *partition_state; } AlterTableCmd;在执行过程中系统还会构造一个AlterTableRec结构来记录每一层递归处理时的“当前状态”比如当前处理的表OID、分区键信息、重写是否需要等。如果你想调试一个alter操作卡在哪个环节在这几个结构体的关键字段上打断点是最方便的手段。另一个常被忽略的点是AlterTableCmd里的missing_ok。这个字段对应SQL语法里的IF EXISTS。openGauss通过它来控制“列不存在”时是直接跳过还是报错在代码走读时如果你看到某个函数在处理前先判断missing_ok那就能对上SQL语义。3.2 系统表更新路径pg_attribute与pg_class的幕后变更ALTER TABLE最终都要落在系统表的增删改上。最核心的几个系统表包括pg_class记录表自身信息比如relnatts列数、reloptions、reltablespace。pg_attribute记录每一列的信息比如列名attname、类型atttypid、长度attlen、偏移attnum、是否可空attnotnull。pg_attrdef记录列的默认值。pg_constraint记录约束定义。pg_depend记录对象间的依赖关系。以ADD COLUMN为例ATExecAddColumn最终会调用AddNewAttributeTuple这类函数往pg_attribute里插入一条新记录同时可能向pg_attrdef插入默认值记录向pg_depend插入依赖记录。列数增加后还要更新pg_class里的relnatts。这些系统表更新都在ATRewriteCatalogs阶段完成。RENAME COLUMN走的是另一个函数比如ATExecRenameColumn。它检查列是否存在然后更新pg_attribute里对应的attname。这里有个很容易出错的地方如果有视图或者外键依赖这个列还需要级联更新引用方。openGauss的做法是通过pg_depend关系找到依赖方然后递归去更新。代码里能看到类似renameatt这样的函数处理这些依赖传播。ALTER COLUMN TYPE的实现更复杂。它不只是改pg_attribute里的atttypid还要考虑新类型和旧类型之间是否有隐式转换如果有是否会导致数据长度或对齐方式变化。如果类型变化改变了行的物理尺寸那就必须安排表重写。代码里对应的是ATPrepAlterColumnType里对alter_column_type_requires_rewrite的调用它返回一个布尔值决定后续是否走ATRewriteTables。3.3 依赖关系与级联处理机制关于级联处理值得单独展开。ALTER TABLE DROP COLUMN时如果有索引和视图依赖这个列直接删除会报错除非SQL里指定了CASCADE。这个机制在代码里对应ATExecDropColumn里的performDeletion调用第二个参数是DROP_RESTRICT或DROP_CASCADE。走代码的时候可以看这个函数怎么通过pg_depend递归找依赖对象。还有一个与普通用户直接相关的场景是ALTER TABLE DROP CONSTRAINT。如果这个约束是主键或唯一约束背后有索引openGauss会先删除索引再删约束顺序不能反。代码里通常的处理方式是先找到约束关联的索引OID调用index_drop再删除pg_constraint里的元组。这也解释了为什么在主键约束删除时偶尔能看到索引文件被异步回收。我在排查一个字段类型修改导致依赖视图失效的case时就靠的是阅读ATRewriteCatalogs阶段如何调用find_composite_type_dependencies来发现依赖这个表结构的所有复合类型和视图。如果这类依赖处理不干净后续查询会奇奇怪怪地报错。4. 两类高频操作的深度拆解类型变更与分区操作4.1 ALTER COLUMN TYPE的重写路径ALTER COLUMN TYPE应该是最能体现ALTER TABLE复杂度的操作因为它可能触发全表重写。整体重写路径可以概括为ATPrepCmd阶段检查类型变化是否需要重写。如果需要ATRewriteCatalogs阶段先更新pg_attribute中的类型定义。ATExecCmd阶段调用ATRewriteTables进入表重写流程。ATRewriteTables内部会创建一个临时表使用新的结构定义。然后从旧表顺序扫描每一行用类型转换函数把旧的列值转换为新类型插入临时表。最后把旧表和临时表做交换删除旧表数据文件。重建相关索引。源码里对应的核心函数有ATRewriteTable、copy_heap_data、make_new_heap、finish_heap_swap等。走读建议把目光集中在copy_heap_data上它是数据转换的逐行处理核心。static void copy_heap_data(Oid OIDNewHeap, Oid OIDOldHeap, LOCKMODE lockmode) { ... if (newrel-rd_rel-relkind RELKIND_RELATION || newrel-rd_rel-relkind RELKIND_PARTITIONED_TABLE) { if (is_column_rel(newrel)) { copy_column_data(newrel, oldrel, ...); } else { copy_heap_data_internal(newrel, oldrel, ...); } } ... }这里openGauss做了一个明显分支列存表和行存表的拷贝逻辑不同列存走copy_column_data行存走copy_heap_data_internal。因为列存表数据按列存储重写时要按照列存格式构造CUCompression Unit写到新表的列存文件中而行存表按行存储直接tuple by tuple写就行。值得注意的坑是重写过程中数据文件的落盘时机和WAL日志的关系。如果开启了full_page_writes每个数据页首次修改都会写一整页到WAL大表重写时WAL量会剧增。代码层面不太容易直接看出来但DBA在变更大表前需要提前考虑磁盘空间和WAL增长。另一个细节ALTER COLUMN TYPE如果是在事务中执行的并且中途出错回滚系统表的变更会随事务回滚但已经创建出来的临时文件和表OID会变成垃圾对象。openGauss有后台清理机制兜底但大事务回滚后的清理时间通常很长。这也是为什么生产环境做大表alter type前建议先小范围演练。4.2 分区表ALTER的分支处理与递归分区表在openGauss里是重头戏它和普通表的ALTER实现差异极大。普通表alter操作基本围绕单表做系统表更新和物理重写而分区表在这基础上还要处理“分区键定位”、“递归到子分区”、“部分分区操作”等逻辑。一个典型例子是ALTER TABLE partitioned_table ADD PARTITION。它路由到ATExecAddPartition这个函数需要解析ADD PARTITION子句里的边界值判断和现有分区边界是否冲突然后在pg_partitionopenGauss扩展的存储分区元数据的系统表里插入新分区记录同时创建新的分区物理表。比ADD PARTITION更复杂的还有SPLIT PARTITION、MERGE PARTITION、EXCHANGE PARTITION。以EXCHANGE PARTITION为例实际上是交换普通表和分区表的物理存储文件逻辑上openGauss会在代码里重新映射relfilenode。这条路径在ATExecCmd阶段根据subtype分派跟普通ALTER COLUMN完全走不同的函数。处理分区表时代码里会出现大量的递归调用。比如对分区表执行TRUNCATE PARTITION会递归到每个叶子分区执行截断对分区表执行ALTER COLUMN TYPE需要判断是否所有子分区都要一起重写。这时候wqueue机制会派上大用场每个子分区的重写任务都会作为一个独立命令追加到队列中最终在处理阶段逐个执行。openGauss在分区实现上相对社区版PG做了大改系统表里增加了pg_partition分区键的管理和相关检查函数也都自成一派。如果你是从PG源码切过来读openGauss的分区ALTER代码建议先看pg_partition表的定义再看ATExecAddPartition这类函数怎么操作这张表否则很容易被各种PartitionKey和PartitionState结构绕晕。4.3 行存与列存的不同实现分支行列存的分支几乎贯穿了openGauss的DDL实现。列存表的ALTER TABLE包含大量特殊处理比如增加列时列存表不能在原地加列因为列存文件格式是按列族组织的新列需要额外的文件段来存储。openGauss对这个场景的处理是普通列存表的ADD COLUMN在部分条件下可以做“轻量级”的元数据更新但数据写入新列时要走增量写机制。代码里你能看到很多类似is_column_rel的判断控制执行路径。比如前面提到的copy_heap_data里就有列存分支ATRewriteTables里也会根据表的存储类型选择不同的重写策略。列存表的ALTER TYPE操作通常代价比行存表更高因为要重建整个列存文件甚至做CU重写。如果要深入走读列存分支建议打开gdb设置条件断点条件是rel的relkind是列存相关类型否则你会一直在行存代码里打转。还有一个实用技巧是设置enable_online_ddl相关guc参数某些online DDL操作会被引入到分支判断中影响是否阻塞写入。5. 常见问题与代码走读实操技巧5.1 线上环境ALTER TABLE卡住怎么定位正常走读代码是一回事线上出了问题又是另一回事。我经历过一次大表ADD COLUMN长时间不返回的情况最后定位到是有个长事务提前持有了表上的锁ALTER TABLE在等锁。这种问题在代码层面看不出端倪需要从锁等待视图入手。openGauss提供了pg_locks视图可以查询锁的持有和等待关系。先用pg_blocking_pids找到阻塞ALTER TABLE的PID再看这个PID在跑什么事务。常见场景是业务系统里有未提交的写事务或者某个后台任务长时间运行。结合ATController中ACCESS EXCLUSIVE锁的特性只要别的会话持有ACCESS SHARE或更低级别的锁ALTER就会排队。如果确认当前没有其他阻塞但ALTER TABLE还是慢那多半是进入了表重写阶段。可以用pg_stat_progress_ddl这类视图观察重写进度openGauss也为DDL提供了一些进度统计能力。判断重写的依据包括命令是否涉及类型变化、是否加了非空默认列、是否修改存储参数。这时候关注temp文件目录的空间情况表重写会生成临时文件空间不足直接报错。5.2 元数据不一致系统表与实际数据对不上ALTER TABLE执行异常中断后偶尔会出现系统表里记录的列结构与实际数据文件不一致的情况。这种问题最容易出现在测试环境反复跑DDL又不提交的时候。定位思路是先通过查询系统表确认期望结构再用实际的表数据页做抽查。代码走读时我们能看到openGauss很多ALTER操作都依赖“通过重建新表来切换”的策略只要没到最后的交换点旧表数据其实一直没动。所以如果你怀疑物理数据和元数据不一致先确认relfilenode指向的文件是否还是旧文件如果是那说明alter操作可能还没进入真正的数据重写阶段。反过来如果relfilenode已经变了说明新表已经切换过来旧文件是残留不影响查询。一个小经验测试环境里反复ALTER同一张表要注意relfilenode是否在频繁变化这能帮你判断是不是每次ALTER都因为类型不兼容而触发了全表重写。如果是check一下是否有cast转换没有建立这往往是容易被忽略的原因。5.3 源码走读的断点设置与日志打法直接推荐几个适合打gdb断点的位置。第一是ATController入口确认一条ALTER TABLE是否正常进入DDL框架以及拿到cmds的数量和subtype列表。第二是ATPrepCmd内部每个subtype的分派位置查看预处理逻辑有没有走到预期分支。第三是ATRewriteTables入口确认是否触发表重写以及rewrite标志位的内容。第四是copy_heap_data和copy_column_data看数据重写的逐行处理是否符合预期。第五是ATExecCmd里的各个ATExec函数比如ATExecAddColumn、ATExecAlterColumnType、ATExecDropColumn确认实际执行的操作。c代码打断点建议用条件断点比如只对特定表名的表触发。openGauss里的Relation结构能拿到relname可以这样下断点break ATController if strcmp(RelationGetRelationName(rel), t1) 0这样能避免在系统表自身的ALTER操作上频繁停下。如果不想用gdb还可以临时在源码里加elog日志重新编译后看执行日志。比如在ATPrepCmd和ATExecCmd的关键分支加上elog(LOG, alter table %s subtype %d, ...)然后执行一条SQL观察输出顺序能非常直观地理解三阶段的执行顺序。我自己的习惯是先在测试环境用一个最小复现场景走通全流程比如建一张只有几行的表执行ALTER TABLE ADD COLUMNgdb断在ATRewriteCatalogs里看系统表的变更再用step into进入下一步。比上来就分析复杂SQL要高效得多。5.4 从PG迁移源码经验时容易踩的坑openGauss的ALTER TABLE代码大量继承自PG早期版本但又做了很多本地化改造。最明显的区别是普通表处理逻辑里增加了列存和行存分支分区表处理逻辑里引入了pg_partition表。直接套用PG源码的阅读路径很容易卡壳。比如ATExecAddPartition在PG里是对分区表元数据做insert在openGauss里可能还要同步更新分区表和分区索引的关系函数签名和调用链有差异。另一个差异点是online DDL相关参数的影响。PG 9.2年代基本没有在线DDLopenGauss增加了在线DDL模式比如在线加列导致代码里出现了一些ATPrep阶段的额外判断。如果读者发现某个操作的执行路径和文档描述不一致先检查GUC参数是不是把在线DDL打开了。走读openGauss代码建议始终带着问题去读不要从头到尾线性翻阅函数列表。先选定一个具体操作比如“ALTER TABLE t ADD COLUMN”画出它经过的每个函数调用序列再逐步填补每个函数内部细节。用gdb验证调用序列是最可靠的方式比主观推断分支走向强很多。6. 写在走读之后这次把alter table的实现流程从语法解析一路跟到系统表更新和物理数据重写最大的感受是数据库的DDL没有表面看起来那么轻量一个ADD COLUMN在不同的存储引擎、不同的表类型、不同的参数组合下背后可能是完全不同的工程量。从一个开发者的视角看最值得记住的是ATController的三阶段设计这种“先规划、再改元数据、最后执行物理操作”的思路在很多系统里都适用。它能保证失败回滚时不会留下一半脏数据也能让各种复杂DDL组合在一个框架下有序执行。理解了这一层再看openGauss里各种ALTER类型心里就有了坐标。如果你正准备上手内核源码我建议找一条最简单的路径切入比如普通表的ADD COLUMN把断点打全一条条指令跟下来比自己漫无目的翻代码效率高太多。代码里的信息密度其实很高但需要耐心去跟着函数调用去还原全貌。希望这篇走读记录能帮你节省一些在源码里摸索的时间。