凌晨两点被人从被窝里叫起来,说生产环境一张几亿行的流水表要加列,业务那边已经催到项目群里了。这种事当过DBA的基本都躲不过。当时我第一反应是先把影响面算清楚:表多大、数据量多少、加什么类型的列、有没有默认值、能不能接受锁表。如果在老一套数据库上,几亿行的大表加列基本等于把整张表复制一份重建,光等就可能等到天亮,还得担心磁盘被撑爆、日志把归档目录塞满。那次在达梦数据库上处理,比预想中顺利很多,这也让我后来专门把大表加列的各种做法整理了一遍。
这篇文章不打算讲太虚的原理,重点说清楚一件事:在达梦数据库上,面对大表加列需求,到底有哪些能落地的快速方案,每一步怎么操作、有哪些坑、怎么验证结果。适合正在做达梦数据库运维或从其他数据库迁到达梦的DBA、后端开发,也适合那种“加列不敢下手”的同学参考。
1. 加一列而已,为什么能让大表直接卡死
先说个基本认知:表加列这事,在不同的数据库里开销天差地别。有的数据库加一列只要改一下元数据,秒回;有的数据库则要把所有行的物理结构全部改写一遍,行数越多越痛苦。达梦数据库的加列行为也分场景,但很多人在实际操作前根本没意识到,风险和耗时差异能有多大。
1.1 传统加列的执行链路:你以为是打补丁,其实是重建房子
传统的表重建式加列,执行链路大概是这样的:解析DDL语句后,数据库会拿一把很重的锁,然后新建一张包含新结构的临时表,把老表的数据一行行搬过去,边搬边建索引,搬完后把老表删掉,临时表改名成老表的名字。整个过程里,磁盘空间需要近乎双倍,CPU和IO会持续打满,对业务的影响不是“几秒钟闪断”,而是持续几分钟甚至几十分钟的阻塞。
我见过一个很典型的例子:一张订单流水表,大概1.8亿行,存储本身并不算大,也就70GB左右,但在加列时因为走的是全量复制逻辑,跑了将近两个小时。期间所有针对这张表的写入全部排队,查询也开始堆积。那种加列方式本质上不是在“打补丁”,而是把整栋楼推倒重建,只为了在墙上多装一个插座。
1.2 慢只是一个表象,真正吓人的是空间和日志
很多人评估大表加列风险,只盯着“要跑多久”,其实更该关注的是空间和日志。
全量重建过程中,数据库要同时保留老表和新表的数据,空间占用会接近翻倍。如果表本身就有200GB,那么加列操作可能要求你至少有200GB的额外空闲空间。磁盘打满之后,数据库写不了redo、写不了归档,整个实例可能直接进入只读或挂起状态。
日志增长同样不容忽视。每一行数据被拷贝到新表,都会产生相应的重做日志,几亿行的表做一次重建,产生的日志量足以把归档目录撑爆。我在生产环境碰到过不止一次,因为一个看似简单的加列DDL,导致归档目录满了,最终所有业务写入都被阻断。
所以加列这件事,最核心的挑战并不是“怎么写这条DDL”,而是“怎么避免全量物理重写”。谁能把物理重写的工作量降下来,谁才能真正做到大表快速加列。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 达梦数据库上三条快速加列的路线,先想清楚再动手
需要先明确一件事:达梦数据库并不只有一种加列方式。面对大表,不同条件下最快、最稳的路径完全不同。我不建议任何人拿到需求就直接甩一条ALTER TABLE上去,先花几分钟判断可以走哪条路线,比事后救火划算得多。
2.1 路线一:期望数据库原生优化通道,只登记元数据
如果你的加列场景满足“新列允许为空”或“默认值是固定常量”,而且当前达梦版本对大表加列的默认值场景做了优化处理,那么整条语句可能只在秒级就返回。底层逻辑并不玄乎:新列的值如果对所有历史行都是同一个固定值,数据库就不需要把每一行的这个值物理写一遍,只需要把列定义和默认值登记到元数据里,查询的时候由数据库引擎自动补上。
我用一个不太严谨但容易理解的比喻:传统方案是“给一屋子人每人都发一张写着相同内容的名片”,优化方案是“在门口挂一张告示,告诉大家都有这张名片”。第二种方式显然不需要跑遍整个屋子。
但这条路线有前提条件。一个很关键的限制是:如果新列的默认值不是确定的、每行都一样的东西,比如用了序列、随机函数、当前时间这类“每行都可能不同”的表达式,那数据库还是得给每一行单独算值并回填,快速通道就走不了。另外,如果表上有复杂的行级触发器或者数据库版本对这类操作没有优化,也可能回退到逐行处理。
提示:别在我没确认你的达梦小版本号的情况下,就默认某条加列语句一定能秒回。最靠谱的做法是先在测试环境建一张同构的大表,插入和目标表量级接近的数据,实际跑一遍观察。
2.2 路线二:手工拆成“加空列-分批回填-收紧约束”
如果新列需要填充的值不是简单常量,或者达梦原生快速通道不满足条件,最常用的办法就是把大事务拆成三个小步骤:先加一个允许为空的列(这一步通常很快,因为不需要回填历史数据),再分批把默认值或业务值填进去,最后再对列加上非空约束。
这条路的精髓是“把一次全表重量级操作,换成一次轻量级结构变更加多次小事务”。每一批UPDATE只处理几万行,提交一次,事务短、锁持有时间短、日志增长可控,对在线业务的影响能压到最低。
2.3 路线三:影子表切换,最后兜底
有些场景下,光加一列还不够,你还想同时调整列的顺序、重组表空间、改变填充参数,或者中间经历了几轮失败尝试,已经不方便再在原始表上继续折腾。这时候我会选择影子表方案。
影子表的思路是另起一张和目标表结构一致的新表,把需要的新列直接建在新表上,然后通过数据同步或者分批插入把老数据搬过去,最后用一个RENAME完成切换。这张新表在建好之前不影响任何线上请求,算是所有方案里对生产影响最小、但操作成本最高的一种。
我自己并不会每次都用影子表,毕竟它涉及索引重建、约束重建、权限赋予、序列关系维护等大量收尾工作。但一旦前两条路线都走不通,它往往是最稳的退路。
3. 达梦实操:直接加列之前,先按这套流程探测
3.1 操作前先摸清表的分量和版本环境
拿到需求的第一件事,不是写ALTER TABLE,而是先搞清楚三件事:表的行数大概多少、表的物理存储多大、达梦的版本是什么。这些信息决定了后续的预期管理。
查询表行数可以通过统计信息或者直接COUNT,但大表COUNT本身也可能很慢,我是先用达梦管理工具或DBA_TABLES视图看统计行数。物理大小可以从数据文件或表空间维度估算,也可以在达梦的图形化管理工具里看表的占用空间。
版本信息我习惯用一条简单SQL确认:
sql复制SELECT * FROM v$version;
不同版本的达梦数据库,在DDL优化上表现可能有差异。我实际测试过的版本是DM8系列,印象中针对带固定默认值的大表加列表现不错,但不代表所有版本在所有参数组合下都一样。所以版本确认之后,如果条件允许,我强烈建议先克隆一张同构的小表做一轮“预演”。
3.2 直接加列的达梦SQL写法
达梦的语法整体上兼容Oracle风格,加列可以用标准的写法:
sql复制ALTER TABLE big_table ADD remark VARCHAR(64);
这句话的意思是加一个允许为空的列,不做历史数据回填。对于很多只需要“表里先多一个字段,后续应用自己会写入”的场景,这一句就够了,通常跑得非常快。
如果新列需要带一个固定的默认值,而且希望旧数据查询时也能直接看到这个值,可以写成:
sql复制ALTER TABLE big_table ADD remark VARCHAR(64) DEFAULT '待补充';
在我的测试环境里,像这种带确定默认值的加列操作,即使表数据量很大,执行时间也远好于逐行回填的方案。
如果想要一步到位把列设为非空,且默认值是常量,SQL可以写成:
sql复制ALTER TABLE big_table ADD remark VARCHAR(64) DEFAULT '待补充' NOT NULL;
这句话是否能走快速通道,不同达梦版本上行为可能不同。我自己更倾向的做法是:先加空列或默认值列,观察执行状况,随后根据业务要求再单独用MODIFY收紧约束,而不是把NOT NULL和加列绑在一次操作里赌性能。
注意:达梦对表名和列名的大小写处理受初始化参数影响,默认情况下如果建表时用了双引号,那么表名和列名会严格区分大小写。加列前最好先确认目标表的大小写风格,别在SQL里一会儿大写一会儿小写,容易出现“表或视图不存在”的报错。
3.3 怎么判断刚才的DDL是不是真的“快得名副其实”
很多人在达梦上执行完一条大表加列语句,看到秒回就欢呼,但我不建议只凭返回速度做判断。至少要交叉验证几项:
第一,看执行时间是否符合量级预期。如果表有几亿行,却只花了几秒甚至更短,说明数据库大概率没有对历史行逐行回填。
第二,看加列前后的空间变化。如果表段的数据空间在加列后几乎没有增长,说明没有发生全量复制和重写。如果是重建表式的加列,数据文件大小通常会有明显变化。
第三,看归档日志增量。加列前后观察一下归档目录里新增的日志文件大小,如果只是几分钟的正常写入量,那说明这条DDL没有产生巨量redo。
第四,直接查询列信息和默认值,确认数据库元数据层面已经完成登记:
sql复制SELECT COLUMN_NAME, DATA_TYPE, DATA_DEFAULT, NULLABLE
FROM USER_TAB_COLUMNS
WHERE TABLE_NAME = 'BIG_TABLE' AND COLUMN_NAME = 'REMARK';
如果列信息已经出现在结果里,默认值也正确,那就可以把ADD阶段视为完成。
4. 兜底实操:大表分批回填新列值的完整过程
如果条件不满足原生快速加列,或者新列要填充的是带有业务含义的不同值,那就得老老实实分批回填。这也是大多数情况下最考验耐心和细心的一步。
4.1 为什么不能一条UPDATE直接怼上去
我见过不少开发同学,加了一个空列以后,顺手就执行一条没有WHERE条件的UPDATE,想把默认值刷进去。如果表只有十万行,这么做问题不大;但如果是几千万、几亿行的表,一条UPDATE会形成一个超大事务,长时间锁表,日志暴涨,还可能因为回滚段不足直接失败。更重要的是,万一更新到一半发现业务逻辑有问题或更新条件写错了,回滚一个超大事务的成本比更新本身还可怕。
正确的思路是:把大事务拆成有序的小批次,每批几千到几万行,做完一批立刻提交。这样任何一批出问题,只需要回滚到上一个批次,或者直接从出错点继续,整体可控得多。
4.2 按主键分批回填的写法
假设表有一个数值型主键ID,我可以这样分批处理:
sql复制UPDATE big_table SET remark = '待补充'
WHERE id BETWEEN 1 AND 50000 AND remark IS NULL;
COMMIT;
执行完把范围往后推:
sql复制UPDATE big_table SET remark = '待补充'
WHERE id BETWEEN 50001 AND 100000 AND remark IS NULL;
COMMIT;
手工一次一次改范围显然不现实。在达梦里,可以用脚本或存储过程循环处理。下面是一个思路示意,基于达梦的Oracle兼容模式:
sql复制DECLARE
v_start NUMBER := 1;
v_step NUMBER := 50000;
v_count NUMBER;
BEGIN
LOOP
UPDATE big_table SET remark = '待补充'
WHERE id >= v_start AND id < v_start + v_step
AND remark IS NULL;
v_count := SQL%ROWCOUNT;
COMMIT;
EXIT WHEN v_count = 0;
v_start := v_start + v_step;
END LOOP;
END;
这个示例只是说明循环思路,具体到你的达梦版本和兼容模式,可能需要微调。关键在于两点:一是每批处理的数据量可以通过v_step调节;二是通过带WHERE条件,确保已经更新过的行不会在下一批里被重复处理。
批大小的设置没有绝对标准。我个人的实践是:单条更新量在5万行左右通常不会造成明显的锁阻塞,但如果表上有比较频繁的业务写入,批次可以降到1万行甚至几千行。整体时间会拉长,但对业务的影响会更平滑。批量回填的过程里,也建议避开业务高峰,尤其不要和大型跑批任务同时进行。
4.3 什么情况下需要加约束和索引
回填完成后,如果业务上要求该列不能为空,就可以收紧约束:
sql复制ALTER TABLE big_table MODIFY remark VARCHAR(64) NOT NULL;
执行这一步之前,先查一下是否还有未填充的NULL:
sql复制SELECT COUNT(*) FROM big_table WHERE remark IS NULL;
如果结果不为0,说明还有遗漏行,先补齐再执行MODIFY,否则非空约束会失败。
如果新列将来会频繁参与查询或关联条件,那么在做完非空约束后,还要根据实际SQL模式决定是否建索引。索引的创建在大表上同样是个体力活,需要评估是否能用在线方式、是否要并行,这个环节要单独排期,不要和加列绑在同一个变更窗口里硬做。
4.4 表没有主键的时候怎么办
不是每张大表都有主键。如果没有可靠的主键区间作为分批依据,我一般用下面几种替代办法:
一种是用一个能从物理上限定范围的过滤条件,比如按时间字段把一个月的数据拆成几批处理。只要条件能稳定圈定一部分数据且不发生重复更新,就可以作为分批键。
另一种是给源表临时增加一个序号列,先灌入连续的编号,再用这个编号分批。但这本身又涉及一次加列,可能又回到原点,所以适合那种本来就允许做一次结构变更的场景。
还有一种偏“土但有效”的思路是借助ROWID。达梦底层和Oracle一样有物理行标识,但直接依赖ROWID做范围批次不太直观,我通常还是建议优先找业务自然键或时间键。如果实在找不出分批依据,那基本可以考虑走影子表方案了,至少可控性比硬着头皮一条UPDATE强得多。
5. 生产环境加列的踩坑实录:锁、日志、回滚这些都不能漏
5.1 加列跑不动,先查锁而不是先怨数据库
有次同事反馈,一张表加列跑了半小时没结束,怀疑达梦性能有问题。我上去一看,根本不是数据库在COPY数据,而是有其他应用开启了事务没提交,一直占着表的资源。加列DDL在等待那把锁释放,所以整个会话就悬在那里。
这种问题在达梦上很常见。处理方法是先识别出锁和事务的状态:
sql复制SELECT * FROM V$LOCK;
SELECT * FROM V$TRX WHERE STAT = 'ACTIVE';
V$LOCK能看到当前锁的持有情况,V$TRX能看到活跃事务。通常定位到事务后,还要去会话视图里查对应的会话和SQL:
sql复制SELECT * FROM V$SESSIONS;
如果是自己人的测试会话或跑偏的定时任务,就可以和业务确认后结束会话,让DDL继续。如果是一个重要到不能随便杀的事务,那就要重新评估变更窗口,不能硬等。
5.2 加列过程里日志和空间的变化,必须提前算进去
大表重建式的加列最容易被忽略的是归档日志。我在执行任何大表DDL之前,会先看一眼归档目录所在磁盘的剩余空间,再估算一下这次操作可能的日志增量。如果是全量重建,日志增量大概率会达到表本身数据量的数倍,最好留出足够的缓冲。
加列结束后也要再确认磁盘水位有没有异常上涨。如果空间被吃了很多但DDL本身是秒级完成的,那就要回头怀疑是不是根本没走快速通道,而是后台还在回填。这种因为DDL“提前返回”而实际后台仍在跑任务的情况虽然不常见,但一旦出现,危害很大,因为你会误以为变更已经完成,后续的约束添加或索引操作会莫名其妙地失败。
5.3 加列完成后的验证清单,少一项都可能埋雷
加列不只包括结构变更。我习惯在变更收尾时把下面几项都过一遍:
| 检查项 | 操作方法 | 预期结果 |
|---|---|---|
| 新列是否可见 | 查询USER_TAB_COLUMNS | 能看到新列,类型、默认值正确 |
| 默认值是否生效 | 用SELECT TOP 10查看新列 | 历史行能读出预期默认值 |
| 非空约束是否生效 | 再次查询NULLABLE字段 | 已经是NOT NULL |
| 数据库对象状态 | 查询DBA_OBJECTS中相关对象STATUS | 均为VALID |
| 统计信息 | 如果新列即将参与条件查询,执行DBMS_STATS收集 | 无异常报错 |
| 应用错误 | 观察应用日志与慢SQL | 没有新增报错,没有新的全表扫描 |
不要小看统计信息这一项。如果一张大表加完列后,马上有人用新列做筛选条件,而数据库统计信息还是旧的,优化器可能选错执行计划,本来应该走索引的查询变成全表扫描,后果比加列本身更严重。
5.4 几个亲手踩过、希望你绕开的坑
第一个坑是默认值里放了随机函数或序列。表面上加列语句能执行,但如果你寄希望于“快速完成”,结果可能就是全表逐行更新,跑几个小时都不奇怪。如果你遇到的默认值是每行都不同的值,别犹豫,直接走分批回填或影子表,而不是硬等一条DDL。
第二个坑是试图在加列时同时给历史数据填充业务计算值。加列语句本质上只能处理默认值,它不会帮你从另外一张表取关联值填充进去。这种需求必须拆出来,在应用层面或分批SQL中处理。
第三个坑是变更窗口选在业务高峰期。即使走的是快速加列通道,也会有短暂的元数据锁停顿;如果走的是分批回填,每一批UPDATE造成的锁等待在高峰期都可能被放大。我一般会把这类DDL排在业务低谷期,同时准备好监控页面,一旦锁等待时间明显变长,立刻暂停下一批。
第四个坑是忽略“回滚方案”。大表加列最好尽量保证“可逆”。如果只是加了空列,回滚成本很低;但如果已经跑完大批量UPDATE,又发现数据填错了,回滚就会非常痛苦。所以大表回填前,至少要备份涉及的核心字段,或者预留一份原表的数据快照。真到了回滚那一步,你一定会庆幸当初多做了这一步。
6. 从一次凌晨加列里沉淀出的执行脚本习惯
如果团队里经常要处理达梦的大表变更,我建议把上面这些动作固化成一个可以反复执行的检查脚本,而不是每次都在现场临场发挥。
这个脚本至少应该包含:
- 加列前状态采集:版本、行数、表大小、磁盘剩余、活动事务数量。
- 执行策略判断:是普通加空列,还是带默认值加列,还是必须走分批回填。
- DDL或批次更新脚本:SQL本身提前准备好,参数化表名和批大小。
- 加列后自动验证:查询列信息、约束状态、抽样读取默认值、查看归档日志增量。
- 异常处理分支:锁等待超时后如何终止会话,页面日志打满后如何扩容。
把脚本做成标准化的最大好处,是让每一次变更都像走流程而不是靠胆量。尤其当业务方凌晨三点把你叫起来的时候,你手边有一套固定打法,整个人的底气是完全不一样的。
我个人在达梦上做这类操作后的真实体会是:大表加列并没有传说中那么可怕,但前提是你得愿意花时间做判断,而不是上来就执行一条重量级DDL。只要选对了路线,提前把锁、空间、日志这些问题估算清楚,多数所谓“加列事故”其实是完全可以避免的。
