前几天我在一个数据库交流群里看到个提问:“给一张 8000 万行的订单表加个字段,直接 ALTER 行不行?”下面回帖清一色都是“想卷铺盖走人?”“公司还招 DBA 吗?”虽然是开玩笑,但这说明一个很现实的问题:数据库表字段的变更,远不是写一条 DDL 这么简单。很多人刚接触数据库时,学的第一句 DDL 就是ALTER TABLE users ADD COLUMN age INT;,在课程设计、面试题里也都是人畜无害的语法,可一旦放到生产环境,这条语句可能瞬间引发锁表、连接池被打满、从库延迟告警,甚至整个服务不可用。
1. 为什么说“直接 ALTER”会卷铺盖走人:这行代码没那么简单
1.1 一条 DDL 引发的多米诺效应
我最早意识到这件事,是在一次凌晨变更里。项目上线前要往订单表加一个buyer_message字段,负责执行的同事用的是 Navicat,点了个“保存”,顺手就把ALTER TABLE丢给了生产库。当时表已经有八千多万行,InnoDB 直接带着全表重建的代价开始执行。结果是什么呢?首先是写入请求被堵住,因为这条 DDL 需要拿锁;紧接着应用连接池里的连接全部卡死在“Waiting for table metadata lock”上,前端接口超时;然后是主库 CPU 和 IO 飙升,从库开始大量复制延迟。一条看起来人畜无害的 ALTER,最后把整条链路都带崩了。
很多人会觉得夸张:加字段又不是加索引,怎么会锁那么久?这里其实涉及数据库的一个基本策略:不是所有 DDL 都能原地改的。MySQL 里 ADD COLUMN 这种操作,在老版本或者不满足特定条件时,会退化成 COPY 模式:数据库建一张临时表,把老数据一条条拷贝过去,拷完了删掉老表、把临时表改成正式表。这个过程中一定要锁住表来保证一致性。一张几千万行的表,一两个小时可能都跑不完,你的应用能等这么久吗?更麻烦的是,如果 DDL 执行到一半你发现不对劲想停,KILL 掉那个 session 也不意味着马上恢复——很多版本里中断一个 ALTER,回滚同样要重放一遍,表还是会继续被锁着。这就是“直接 ALTER”最致命的地方:它把风险窗口拉得特别长,而你根本不知道锁什么时候能释放。
另外还有一个经常被忽视的点:DDL 本身也会在复制链路里被放大。主库执行完一条 ALTER,binlog 会把同一条 DDL 同步给所有从库,从库也要重新执行一遍。假设主库跑了 20 分钟,从库可能因为压力更大跑到 40 分钟,期间从库读到的数据是旧的,读写分离的项目会出现“主从数据不一致”的假象。如果从库还承担着报表查询,那报表任务会大面积失败。这些连锁反应,都不是一条 ALTER 语句本身能看到的,但只要在生产环境直接执行过一次,基本都能体会到。
1.2 “不是不让改,而是不让不看状态就改”
铺垫了这么多,不是说要禁止 ALTER。数据库设计里,表结构变更本来就是正常需求,字段加错了、长度要扩容、索引要调整,这些都要靠 DDL 完成。我真正反对的是“不看表大小、不看当前流量、不看数据库版本、不看复制架构,直接一条 SQL 丢上去”的做法。把这句话翻译成开发语言就是:没有做容量评估就上了大并发接口,不出故障才怪。
数据库不是 Excel,ALTER 也不只是列右键加一列;它背后牵扯到锁、事务、数据复制、存储空间、应用兼容性一整套链路。这也是为什么很多公司会把 DDL 做成平台化流程,专人审批、自动评估、低峰执行、旁路工具兜底。直接敲 ALTER 的那位同事,并不是因为用错了语法被劝退,而是因为对变更缺少敬畏,把整个业务当成了赌注。
2. 先搞清楚一个事实:ADD COLUMN 在不同的数据库里到底锁不锁表
2.1 MySQL:别说加字段,加个默认值都可能让你等到天亮
MySQL 的 ALTER 行为,可能是目前面试里最容易踩坑的地方。很多人背了“8.0 支持 INSTANT 算法”就以为所有 ALTER 都能秒加了,实际上没那么简单。INSTANT 通常要求你要加的列放在表末尾,而且不能用在压缩表、或者表上有全文索引的场景里;一旦条件不满足,InnoDB 会选择 INPLACE 或者 COPY 算法。INPLACE 也不代表“不影响线上”,它往往意味着需要重建聚簇索引、重建相关二级索引,会产生大量的 IO 和临时空间占用;COPY 就更不用说了,几乎等于把整张表复制一遍。所以在 MySQL 上判断一条 ADD COLUMN 的安全等级,关键不是你会不会写语法,而是你的表符不符合 ONLINE DDL 的条件。
我自己的习惯是:不管文档写得怎么漂亮,先在测试环境把完整语句跑一遍,并且显式指定算法和锁策略,例如:
ALTER TABLE order_info ADD COLUMN buyer_message VARCHAR(255) NULL, ALGORITHM=INSTANT, LOCK=NONE;这样 MySQL 不会偷偷帮你选一个高风险的执行计划:如果当前表不支持 INSTANT,它会直接报错,而不是退化成 COPY 慢慢跑。同理,也可以指定ALGORITHM=INPLACE, LOCK=NONE。执行前先看一眼版本:SHOW VARIABLES LIKE 'version';,再确认ROW_FORMAT、是否有全文索引、列是否加在末尾。这些条件一条不满足,你就得换别的思路,不要硬刚。
2.2 PostgreSQL / Oracle / SQL Server:同样一条语句,代价完全不同
如果一个团队只用过 MySQL,很容易把“ALTER 很危险”当成放之四海而皆准的真理;但换个数据库,完全不一样。拿 PostgreSQL 来说,纯粹执行ALTER TABLE t ADD COLUMN c int;(不加默认值),只修改系统目录,不扫描不重写旧行,所以通常毫秒级完成。即便要加一个常量默认值,PG 11 之后也只是把默认值记录在元数据里,不碰旧数据,执行照样很快。但也别高兴太早,PG 的 DDL 会拿ACCESS EXCLUSIVE锁,属于最重的一档锁,哪怕执行只要 10 毫秒,如果同一条语句在高并发下因为元数据锁排队迟迟拿不到,后面的读写全都会被堵住。所以低峰期执行、设置lock_timeout仍然很重要。
Oracle 和 SQL Server 也各有各的策略。Oracle 12c 以后,许多加默认值列的操作可以走元数据优化,避免全表更新;SQL Server 对添加一个 NULL 可空列也通常可以快速完成,但如果涉及NOT NULL默认值、加密、压缩表、大分区表,行为又会不一样。我并不打算在这里把每个发行版的细节都背出来,那太容易过时了;我想强调的是,不同数据库的“直接 ALTER”风险差异巨大,依赖某一个数据库的经验去迁移到另一个数据库,是生产事故最常见的来源之一。正确的做法永远是:先查官方文档,确认当前版本对这条 DDL 的算法和锁行为,然后在测试环境里用接近生产的数据量做一次压测,用数据说话,而不是用感觉。
3. 新增字段前的关键评估:表大小、流量、依赖方与窗口选择
3.1 先回答三个问题:表多大、流量多大、能容忍多久
如果现在有人来问我“我要加字段,怎么办”,我不会先给 SQL 答案,而是先让他回答三个问题:表多大?变更会持续多久?业务最多能忍受多久的写阻塞?这三个问题直接决定了你该走哪条路。
看表大小可以查 information_schema:
SELECT table_name, table_rows, ROUND(data_length / 1024 / 1024 / 1024, 2) AS data_gb, ROUND(index_length / 1024 / 1024 / 1024, 2) AS index_gb FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'order_info';注意table_rows对 InnoDB 只是估算值,不能完全当真,真正决定变更代价的是表的物理大小。逻辑上,一张表重建的耗时和它占用的磁盘 IO 更相关,而不是看 show table status 里那个估算行数。所以更靠谱的做法是看information_schema.innodb_sys_tablespaces或直接在服务器上du -sh数据文件,确定表占多少 GB。
流量的评估也不能只看总 QPS,要看这张表在当前时段的写入频率和锁竞争情况。比如表上如果有大量UPDATE、INSERT,那么哪怕一个在线 DDL 允许并发 DML,它内部的锁竞争也可能被放大。你可以先用SHOW ENGINE INNODB STATUS看当前有没有 long transaction,也可以用监控系统看近两周的相对稳定流量。变更窗口我一般选凌晨 2 点到 5 点,并且尽量避开月末、大促、批量任务结算日。不要只看“今天是周末”就动手,很多任务系统反而喜欢周末半夜跑批。
3.2 别忽略应用层和日志系统
评估完数据库本身,还得看看应用依赖。比如我们的接口是不是习惯用SELECT *?如果是,新增一个字段后返回结果集多一列,老代码如果按列下标解析就有可能越界;如果是强类型 ORM,新字段没有对应属性通常不会报错,但要注意序列化和 DTO 映射。还有一部分旧代码写死了列名,可能数据库加字段后接口返回的 JSON 少参数,这些都要在联调环境提前验证。
另一个容易被忽略的是下游同步。如果你的变更表同步到数仓、搜索引擎或消息队列,新增字段会影响同步任务的字段映射;有些 CDC(Change Data Capture)工具对 DDL 有特殊处理,比如 Canal、Debezium,默认可能把新增字段吞掉,或者因为 DDL 解析失败导致同步任务暂停。我见过不止一次:表加字段很顺利,第二天数据同步链路挂了,业务方一脸懵。所以评估阶段,一定要把下游数据消费方一起拉进评审清单。
3.3 什么时候可以直接 ALTER:一个简单的决策清单
我自己会按下面的表格来判断,不一定绝对,但至少不会犯方向性错误:
| 场景 | 可以直接 ALTER 吗 | 原因与建议 |
|---|---|---|
| 数据量很小(低于 100 万行,物理文件不大) | 可以,但仍建议低峰期 | 即便触发 COPY,时间也可控 |
| MySQL 8.0 且满足 INSTANT 条件 | 可以,显式指定 ALGORITHM=INSTANT | 秒级完成,但要防止退化 |
| 大表但业务能接受秒级写锁 | 可以考虑原生 ONLINE DDL | 需要实测锁等待时间 |
| 大表且几乎不能接受任何阻塞 | 不建议直接 ALTER | 使用 gh-ost、pt-osc 或影子表切换 |
| 有外键、全文索引、压缩表等限制 | 不建议直接 ALTER | 工具可能也受限,需要先解除约束 |
| 数据库版本过旧、无 ONLINE DDL | 绝对不能直接在高峰期 ALTER | 只能低峰期 + 工具或重建新表切换 |
这张表只是一个评估思路,真实环境要结合你前面的“表大小、流量、容忍度”三个答案去细化。核心原则是:宁可慢一点用工具,也不要拿一条 DDL 赌全链路稳定。
4. 安全执行的黄金流程:从备份、灰度到工具选型
4.1 备份、干跑、限流:执行前三件套
下面这部分是实操干货。不管最后选择哪条路,我都会建议按“备份、干跑、限流”三个步骤走。
先说备份。MySQL 可以用逻辑备份或者物理备份,但这里更推荐在变更前做一个可快速回滚的快照,比如云数据库的快照功能,或者用 Percona XtraBackup 备份一个临时实例。为什么要快照?因为字段变更一旦触发表重建,中途遇到磁盘满、误操作、工具异常,你没有能快速拉起的环境,就只能眼睁睁看着故障扩大。别说什么“我 SQL 写得很稳”,生产环境里稳不稳定,永远要看你的保底手段。
然后是干跑。在预发或测试环境找一套和生产表结构一致的数据,把完整的变更语句或工具命令跑一遍,记录耗时和锁等待情况。尤其要测的是“不满足 INSTANT 时会不会自动退化”这种边界条件,干跑时故意把ALGORITHM=INSTANT或者对应的锁策略写上去,让数据库直接拒绝或者走你想要的路经,避免正式执行时悄悄选错执行计划。
限流和超时设置也不能漏。MySQL 在客户端可以用SET SESSION lock_wait_timeout=5;来限制等待元数据锁的时间,避免 DDL 傻傻等事务;这样如果当前有其他事务持锁,这条 ALTER 会在 5 秒后放弃,而不是无限期等待。对于 MySQL 8.0,还可以用SET SESSION max_execution_time控制查询超时,但对 DDL 不一定会生效,所以主体还是靠锁等待超时保护。
4.2 用工具而不是用感觉:pt-osc / gh-ost / pg_repack 实测对比
如果评估后发现“直接 ALTER 扛不住”,就要引入在线表结构变更工具。MySQL 生态里最常听到的是 Percona Toolkit 里的pt-online-schema-change(简称 pt-osc)和 GitHub 开源的gh-ost。两者思路相似但是实现不同:pt-osc 会在原表上建触发器,把变更期间的新增、修改、删除同步到影子表,数据拷贝完后再通过RENAME TABLE原子切换;gh-ost 则不走触发器,它依赖 binlog,从主库或从库拉取增量变更日志,应用到影子表,最大优势是对原库的压力更小,支持动态限流和暂停。
我个人的选择逻辑是这样的:表上触发器已经很多、外键复杂,或者 binlog 格式不是 ROW 且没法改,那就选 pt-osc,它的兼容性更好;反过来,只要 binlog 是binlog_format=ROW,并且工具能连到数据库实例获取 binlog,我更愿意用 gh-ost,因为它在高峰期跑的时候更“温柔”,可以按线程数、秒级 sleep 限流,出问题还能暂停并续跑。两个工具都要额外留出足够的临时空间,因为影子表本质上还是会复制全表数据。
PostgreSQL 场景下,如果是真正需要重写表的变更,可以考虑pg_repack这类在线重建工具,也可以评估应用层双写 + 切换的方案。PG 的优势在于很多简单的 ADD COLUMN 本来就是元数据操作,不需要工具;真正麻烦的是带复杂默认值、not null 约束、或者重建索引的场景,这时候工具不是唯一解,却是一个稳妥的兜底。
在工具选型时,很多人容易忽略一个前提:工具再强大,也要人在旁边盯着。执行前要和团队对齐监控、通知、回滚流程,至少要有人盯在终端前,不能丢给 cron 就不管了。我自己跑 gh-ost 时,旁边会额外开三个终端分别刷SHOW PROCESSLIST、主从延迟和磁盘使用率,一有异常马上暂停工具。
4.3 执行过程中盯什么:进程、锁、复制延迟
变更开始后,别以为有了工具就万事大吉。第一条要盯的是进程状态,确认 DDL 或工具进程是在正常推进,还是卡在锁等待。看一眼SHOW FULL PROCESSLIST;,如果 State 是Waiting for table metadata lock,说明前面有未提交事务挡路,需要立刻找出来处理;如果 State 是正常的altering table或copy to tmp table,说明数据拷贝在推进,评估一下剩余时间和当前进度即可。
第二条是锁情况。用SHOW ENGINE INNODB STATUS;能看到当前是否有明显的锁等待,如果工具在跑但回话显示大量事务长期处于LOCK WAIT,就要重点排查是哪些 SQL 在频繁争抢。第三条是复制延迟,MySQL 用SHOW REPLICA STATUS\G(老版本是SHOW SLAVE STATUS\G)看Seconds_Behind_Source;PostgreSQL 可以查pg_stat_replication的replay_lag。一旦发现从库延迟持续增长且没有收敛趋势,宁可暂停变更,也不要让它拖垮下游消费链路。
4.4 不会用工具时的兜底方案:影子表 + 双写
有些团队可能因为权限、网络或者团队经验,暂时用不了 gh-ost、pt-osc,那还有一个最土但最可控的办法:影子表切换。具体做法是先新建一张结构完全符合需求的新表,比如order_info_v2,然后让应用层在拿代码开关之后同时写入老表和新表,历史数据通过专门的迁移脚本分批次导入,最后在低峰期把读流量切到新表,确认没问题后修改表名或者废弃老表。这个方案听起来笨,但在极端情况下反而最清晰,因为每一步都可以回退,应用开关就是你的总闸。
影子表方案的风险在于开发成本和业务逻辑复杂度。你需要保证对新表写入足够可靠,还要在迁移历史数据时处理好“老数据写入后又被修改”的竞态。所以如果不是实在没有工具可用,我不太建议所有需求都走这条路。它更适合那种“一定要在不停机的情况下完成海量数据重写”的终极场景,比如从 MySQL 迁移到新实例、修改主键、把表改成分区表等。
5. 一次真实的生产故障复盘:从锁等待到连接池被打满
5.1 事故经过:从一条 ALTER 到全站只读
下面讲一个我亲眼见过的真实故障,细节做了脱敏,但过程和排查链路很有代表性。某天下午 4 点左右,业务方反馈 App 下单接口开始大面积超时。最开始我们以为是网络抖动,看了监控才发现数据库主库的活跃连接数从平时的 50 多一路飙到了 3000,连接池全部打满。紧接着告警群里开始刷“订单写入失败”“库存扣减失败”,整条交易链路几乎变成只读状态。
我们第一反应是查慢 SQL,但拉出来的慢查询列表里没有一条业务 SQL,反而是ALTER TABLE order_detail ADD COLUMN ...这条 DDL 排在最前面。它的状态不是“running”,而是“Waiting for table metadata lock”。这时候我才意识到,问题不是这条 DDL 本身的执行速度,而是它被前面的长事务堵住了,反过来又把后面所有需要访问这张表的业务 SQL 全部挡在了元数据锁外面。一个事务堵住一条 DDL,一条 DDL 堵住全表业务读写,这就是连锁反应。
5.2 排查链路:我是怎么一步步定位到元数据锁的
当时我们是这样排查的。第一步,执行SHOW FULL PROCESSLIST;,看当前所有会话的状态和 SQL,重点筛选 Command 为 Query、State 为Waiting for table metadata lock的会话。第二步,查information_schema.innodb_trx,找出长时间未提交的事务,以及它对应的连接 ID 和事务开始时间。第三步,用performance_schema.metadata_locks看锁的持有和等待关系,确认是哪条事务的TABLE级别的共享锁挡住了 ALTER 的排他锁。
找到根因后,止损方案其实很简单:KILL 掉那个长时间未提交的业务事务。但我们没有立刻 KILL,而是先确认事务的来源。如果它只是开发手动连的一个会话,KILL 掉影响有限;如果它对应着应用连接池里一个正在跑批量任务的连接,直接 KILL 可能让一批数据回滚,甚至触发应用层重试风暴。最终我们还是选择先让应用侧暂停批量任务,再 KILL 那个持锁 session。几秒钟后,ALTER 拿到了锁,元数据锁排队消失,连接池恢复正常。整个过程前后持续了大概 40 分钟,业务影响面很大。
5.3 止损与复盘:KILL 不是银弹
这个案例里 KILL 起了作用,但它不是银弹。如果那条 ALTER 不是卡在锁等待,而是已经在 COPY 模式下跑了大半程,你 KILL 掉它,MySQL 需要把已经重建的临时数据全部丢掉并回滚 DDL 期间的日志,回滚时间很可能比执行时间还要长。那是一条更漫长的痛苦隧道。
所以复盘时我们定了几条铁律:第一,所有线上 DDL 必须经过 DBA 审批;第二,大表、热门表变更一律走 gh-ost 或 pt-osc,不给原生 ALTER 机会;第三,执行前设置lock_wait_timeout;第四,变更时间窗口只能放在凌晨低峰期,除非有紧急事故。这几条看起来很“笨”,但每一张事故单都是这样总结出来的。
6. 字段加完不算完:数据校验、回滚预案与应用发布顺序
6.1 加字段和版本发布,顺序错了照样出事
结构变更完成后,并不代表整个项目就结束了。最常见的坑是应用发布顺序。假设你要给用户表加一个member_level字段,并让新代码读取这个字段来展示权益。如果先发应用代码,代码里立刻会查到新字段,可这时数据库还没加字段,接口直接报错;反过来先加字段再发代码,老代码不会读这个字段,基本安全。所以通用原则是:加字段这种兼容性变更,先改数据库,再发布应用;删字段、改类型这种破坏性变更,则要反过来——先让应用不再依赖这个字段,再考虑删除。
但有一种情况要先改应用:如果新字段设置了NOT NULL DEFAULT,但老数据还不满足默认值的语义,直接加字段会导致老记录被迫写入一个无意义的默认值,后续应用逻辑可能把“没有会员等级”的用户错误地当成“普通会员”。这种时候更安全的做法是先把字段加成 NULL,让代码里兼容 NULL,等数据补齐后,再通过另一个变更改成 NOT NULL。加字段看起来是 DDL 操作,但本质上是数据结构演进,要和业务语义一起考虑。
6.2 数据校验与回滚预案
如果使用了 gh-ost、pt-osc 这类工具,变更完成后还要做数据校验。工具一般会做 chunk 级别的 checksum,迁移完成后还可以抽几条关键数据对比新老表;如果是自己写影子表迁移脚本,更要仔仔细细核对 COUNT、SUM、唯一键冲突数量。很多工具跑完会返回成功,但不代表数据完全一致,外键、自增序列、触发器副作用都可能造成差异。
回滚预案也要提前想好。ADD COLUMN 本身通常可以靠 DROP COLUMN 回滚,但如果表很大,DROP COLUMN 依然可能触发重建,时间未必比 ADD 短;而且如果你已经用工具跑过迁移,回滚时还要考虑工具残留的触发器、临时表是否清理干净。最简单的回滚思路是“备份恢复”,所以前面才强调变更前要有快照。真要回滚,尤其是线上已经写入新数据的情况,不要只想着 DROP 字段,先评估数据能否保留、下游是否符合预期。
6.3 长期维护:通过合理设计减少 DDL 变更
长期的运维经验告诉我,减少线上 DDL 的最好方法不是把工具练到出神入化,而是尽量在表结构设计阶段就把未来半年的需求想清楚。比如命名规范里预留几个扩展字段,很多业务初期的新增字段直接复用扩展字段,就能避免动表;字段类型能VARCHAR(255)就别到处用VARCHAR(32),为后续扩展留一点余量;唯一键、外键、索引的创建也尽量从需求层面统一规划。当然这不是让你把所有表都设计成一个大宽表,那是另一个极端,但“适度冗余”对于在线业务表来说,确实能减少结构变更频率。
最后再分享一个小建议:如果你所在团队还没有 DDL 自动化工单系统,至少要在变更前把“要执行的 SQL、影响表大小、预估耗时、回滚方案、发布顺序”这五项写成一条变更记录,拉上 DBA 和业务负责人一起看一眼。这个动作只需要十分钟,但可以避免很多个不眠之夜。数据库表字段的变更,真正的难点从来不是ALTER TABLE怎么写,而是你有没有能力预判它扔进生产环境之后会发生什么。