线上库表已经跑到千万级,甚至快到亿级,业务方过来说要加个字段,你脑子里蹦出的第一个念头是什么?如果你还停留在“ALTER TABLE 一下不就完事了吗”,那这篇文章建议你认真读完。我见过太多因为直接 ALTER 把线上业务干趴下的案例,有的连接池被打满,有的主从延迟飙到几十分钟,甚至有人因为一条 ALTER 把整个库搞到不可用。千万级大表加字段,真不是随手一条 DDL 就能解决的。
这篇内容主要讲清楚三件事:为什么直接 ALTER 在千万级大表上非常危险;如果情况允许,有哪些替代方案可以选;每个方案具体怎么落地,有哪些坑是文档里不会写、但实战中一定会遇到的。适合正在维护线上 MySQL 的 DBA、后端开发,以及所有要面对“大表变更”这件事的人。
1. 直接 ALTER 的危险不止是“慢”,而是 MDL 锁带来的雪崩
先别急着喊“我用的是 MySQL 8.0,ALTER 很快”,版本新不代表你可以无脑执行。直接 ALTER 在大表上的核心风险,从来不是单纯的时间消耗,而是它获取元数据锁(MDL)之后,会对整个表的读写造成什么样的连锁反应。
1.1 从 MDL 排队说起
MySQL 从 5.6 开始引入了 MDL(Metadata Lock)机制,它用一张隐式的锁表来管理对表结构的访问。任何 DDL 操作,比如 ALTER TABLE,都需要拿到这张表的 MDL 写锁。问题在于,这个写锁的获取不是即时的——如果当前有任何一个事务正在读写这张表,这个事务持有了 MDL 读锁,那 ALTER 就只能排队等。
等也就罢了,真正要命的是,在 ALTER 等待期间,这张表上所有的新请求——不管是 SELECT 还是 UPDATE——也都会被阻塞,因为它们在申请 MDL 读锁的时候发现前面有一个写锁在排队。 这是一个典型的“一辆车占道、后面所有车全部堵死”的场景。正常业务下,表上总会有持续不断的查询流量,哪怕每条查询只要几毫秒,ALTER 也会被无限期卡住;而 ALTER 卡在那,后面的业务流量就全部开始堆积。
1.2 多久算“大表”,为什么千万级已经足够危险
我经常被问到“到底多大的表才不能直接 ALTER”。说实话,没有一个绝对的阈值,但千万级绝对已经到了需要警惕的临界点。
原因很简单:InnoDB 的默认 ALTER 策略是 COPY,也就是新建一张临时表,把老数据一行行拷贝过去,拷贝完成后再切换。这个过程有多慢?和两个因素强相关:表的数据量、以及表行记录的平均大小。
我们按千万行、平均行大小 500 字节来估算:
text复制数据总量 = 10,000,000 * 500 = 5,000,000,000 字节 ≈ 4.66 GB
在普通 SSD 上,顺序拷贝 4.66GB 的数据可能需要 3~8 分钟不等。但在 ALGORITHM=COPY 的过程中,表上是加了 MDL 写锁的,这 3~8 分钟里这张表基本不可写,读请求也会因为 MDL 阻塞而不可用。对于千万级表,这个窗口已经不是“业务能忍一下”的范畴了。如果是亿级、几十亿级的表,这个窗口会被拉到小时级,根本没法接受。
所以“千万别直接 ALTER”不仅仅是经验之谈,而是由 MDL 锁机制和数据拷贝开销共同决定的必然结论。
1.3 来自生产环境的真实事故
我接手过一个比较典型的案例。某次业务上线新功能,需要在一张订单表上加个索引。这张表当时大约 3000 万行,库在广州机房,业务量高峰期 QPS 在两万左右。当时负责的同事没多想,直接在服务器上执行了 ALTER TABLE ADD INDEX,结果呢?
- 第 1 分钟,ALTER 开始获取 MDL 写锁,因为排队等待,开始阻塞新查询。
- 第 30 秒后,数据库连接数从正常的 200 暴涨到 1500,因为所有请求都在等待锁,连接池不断创建新连接。
- 第 2 分钟,数据库连接数打满,load average 飙升到 80 多。
- 最终,这条 ALTER 还没执行完,业务方被迫重启了应用,再手动 KILL 掉 ALTER 进程,才勉强恢复。
这件事之后,我把团队里的 DDL 流程彻底改掉了。所有 DDL 必须经过方案评审,千万级以上的表默认走在线变更方案,不允许直接 ALTER。
提示:无论你用哪个方案,执行 ALTER 前第一件事是检查当前表上的活跃事务。
SELECT * FROM information_schema.innodb_trx;如果发现长时间未提交的事务,先处理它们,否则 MDL 写锁根本拿不到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型前想清楚这五件事,少踩一半坑
面对千万级大表的字段新增,可选的方案不是只有一个,但每个方案的适用条件、风险和成本差别很大。在做选择之前,有几个关键问题必须先回答清楚。
2.1 你的 MySQL 版本到底支持什么
MySQL 5.6 引入了 Online DDL,支持一部分操作在 INPLACE 算法下执行,不阻塞 DML。MySQL 5.7 对 INPLACE 支持得更完善。MySQL 8.0 又引入了一个杀手级特性——INSTANT 算法,某些加列操作可以在瞬间完成,不需要拷贝数据、不需要重建表。
如果连版本都不确定,方案选型就没有基础。先查一下:
sql复制SELECT VERSION();
我建议不管后续用哪个方案,先把 ALGORITHM 和 LOCK 两个参数显式地写出来,不要依赖数据库默认值。原因后面会讲。
2.2 业务能不能接受短暂的写阻塞
虽然我们一直在说“不要直接 ALTER”,但并不是所有场景都必须绕开。某些低峰期、或者说对写容忍度极高的业务,在凌晨 2 点做一次分钟级甚至秒级的写锁,是可以接受的。
如果你确定表上流量非常低、并且业务方明确同意有一段时间不可写,那直接在维护窗口执行 ALTER 也是合理的。但千万级表即便在网络流量低的情况下,INPLACE 算法加索引通常也比 COPY 快得多,我们要尽量把锁窗口压缩到最小。
2.3 从库数量和主从延迟现状
所有在线 DDL 工具,最终都逃不过主从复制这一关。如果从库延迟已经很高,再跑 DDL 只会雪上加霜。选方案前一定要确认:
- 有几个从库
- 各从库当前的延迟情况
- 从库所在机房的网络带宽和磁盘性能
我曾经在从库延迟超过 30 秒的情况下跑 gh-ost,结果从库延迟直接飙到 10 分钟,业务方半夜打电话问我是不是把库搞挂了。后来学乖了:任何 DDL 之前,先看从库延迟,哪怕要为此等一小时。
2.4 磁盘空间够不够
不同方案对磁盘空间的需求差异巨大:
- 直接 ALTER(COPY 算法):需要额外一份完整表空间,约为表大小的 1.0 倍,另外还有日志和临时文件。
- pt-osc:同样需要建影子表,额外占用一份表空间。
- gh-ost:它通过 binlog 在外部重建表,也需要额外一份表空间,但它不像 pt-osc 那样必须在原库上建触发器,负载形态略有不同。
- INSTANT 加列:只修改元数据,不复制数据,几乎不占额外空间。
磁盘空间没算好,在拷贝中途把磁盘写满,带来的麻烦比 ALTER 本身大得多。
2.5 表有没有外键、触发器、特殊主键
外键和触发器是很多在线工具的“死穴”。pt-osc 对外键支持还算可以,通过 --alter-foreign-keys-method 参数可以处理,但 gh-ost 默认不支持有外键的表。触发器也一样,如果表上已经存在触发器,pt-osc 会直接拒绝工作。
主键/唯一键同样关键,gh-ost 要求表必须有主键或唯一键,否则它没法在拷贝阶段高效定位“已拷贝到哪一行”。这些约束条件没梳理清楚,工具跑到一半报错,你只能两手一摊。
3. MySQL 8.0 的 INSTANT 加列:千万级大表秒变“小菜一碟”的正确姿势
说实话,如果让我推荐首选方案,MySQL 8.0.12 及以上版本的 INSTANT 算法绝对排第一。它最优雅的地方在于:真正意义上的“秒完成”,不碰数据,只改元数据。
3.1 INSTANT 加列的原理
INSTANT 算法在 MySQL 8.0.0 中首次引入,最初只支持在表末尾加列。从 8.0.12 开始,支持在任意位置加列。 它的原理非常直接:不重建表,不拷贝数据,只在数据字典中记录一下“表结构变了”,新增的列拥有一个默认值(或 NULL),旧的行记录在读取时按默认值处理。
也就是说不论表里有 1000 万行还是 10 亿行,INSTANT 加列都只需要做元数据层面的修改,耗时基本在秒级甚至毫秒级。
3.2 实际执行的 SQL 怎么写
假设有一张 user_order 表,1 亿行,需要在末尾加一个 remark 字段:
sql复制ALTER TABLE user_order
ADD COLUMN remark VARCHAR(50) DEFAULT NULL,
ALGORITHM=INSTANT;
如果你不确定当前环境是否支持 INSTANT,可以加一个 LOCK=NONE 参数,让它能不加锁地执行:
sql复制ALTER TABLE user_order
ADD COLUMN remark VARCHAR(50) DEFAULT NULL,
ALGORITHM=INSTANT,
LOCK=NONE;
执行完通过 SHOW WARNINGS; 看看有没有降级提示。
3.3 什么情况下 INSTANT 会失效
我一开始以为 INSTANT 是万能的,直到在机房踩了几次坑才摸清它的限制:
- 表上不允许存在全文索引。
- 表不能是 TEMPORARY TABLE。
- 压缩表(ROW_FORMAT=COMPRESSED)不支持。
- 新增列如果指定了非默认值的
DEFAULT表达式,比如DEFAULT RAND(),在某些版本下会受限。 - 不是所有加列操作都能走 INSTANT,比如修改列类型、加索引等依然需要 INPLACE 或 COPY。
- 如果表中已有 INSTANT 列,且新增列数达到一定限制,后续加列会自动降级为 INPLACE。
还有很重要的一点:MySQL 8.0 里,即使指定 ALGORITHM=INSTANT,如果因为某些原因无法使用 INSTANT,数据库不会报错,而是会静默降级为 INPLACE 甚至 COPY。 这时候如果你没注意,实际执行时间可能是几分钟甚至几小时。
提示:执行完 DDL 后一定要看
SHOW WARNINGS;的输出,如果看到类似 “ALGORITHM=INSTANT is not supported for this operation” 的提示,那就说明降级了,要立刻评估是否需要中止。
3.4 线上实测:1 亿行订单表加字段
用我们线上一个 1 亿行的订单表做过一次实测。表大小约 120GB,RDS 高可用版,主库 IO 压力中高。执行 ALTER TABLE ... ADD COLUMN, ALGORITHM=INSTANT 后:
- 执行耗时:0.7 秒
- 主从延迟:无变化
- 业务 QPS:无影响
- 磁盘空间:无额外消耗
这个结果放在 MySQL 5.7 时代是不可想象的。所以如果你还在 5.6/5.7 上苦哈哈地做 DDL 规划,升级到 8.0 带来的收益是立竿见影的。
4. gh-ost 与 pt-osc 怎么选,实战操作与参数调优全记录
如果因为版本原因或者业务场景限制,不能使用 INSTANT 算法,那接下来的两个主流工具——gh-ost 和 pt-osc 就是你的主力方案。它们的目标一致:在尽可能不影响业务的前提下完成表结构变更。
4.1 两者的核心原理差异
pt-osc(pt-online-schema-change,Percona Toolkit 的一部分)走的是“触发器”路线:
- 创建一张和原表结构一致的空影子表(_table_new)。
- 在影子表上执行 ALTER 操作,让它拥有目标结构。
- 在原表上创建 3 个触发器,分别捕获 INSERT、UPDATE、DELETE,把增量变更同步到影子表。
- 分批把原表数据拷贝到影子表(chunk by chunk)。
- 数据拷贝完成后,通过原子性的 RENAME TABLE 交换原表和影子表。
- 删除旧表和触发器。
gh-ost(GitHub 开发的工具)走的是“binlog 解析”路线:
- 不创建触发器,而是启动一个模拟从库的角色,连接主库拉取 binlog。
- 创建影子表,执行 ALTER,获得目标结构。
- 从原表分批拷贝数据到影子表。
- 同时应用 binlog 中解析出的增量变更到影子表。
- 数据追平后,在切换时短暂锁表,RENAME 交换,或使用原子性 RENAME 操作。
gh-ost 最大的优势是:不依赖触发器,所以不会给原库带来额外的触发器相关负载,对主库的侵入性更小。
4.2 pt-osc 实际命令与关键参数
我在 MySQL 5.7 时代主力用 pt-osc,命令大概是这样的:
bash复制pt-online-schema-change \
--host=127.0.0.1 \
--user=admin \
--password=your_password \
D=test_db,t=user_order \
--alter "ADD COLUMN remark VARCHAR(50) DEFAULT NULL" \
--chunk-size=1000 \
--max-lag=5 \
--critical-load="Threads_running=100" \
--max-load="Threads_running=50" \
--alter-foreign-keys-method=auto \
--execute
参数含义简单说:
--chunk-size=1000:每次拷贝 1000 行,太大容易造成主库 IO 尖刺,太小则拷贝太慢。--max-lag=5:从库延迟超过 5 秒就暂停拷贝,等延迟追平再继续。--critical-load:当 Threads_running 超过 100 个时,工具直接中止,这是最后一道保险。--max-load:超过 50 个活跃线程时暂停工作,低于阈值再继续。--alter-foreign-keys-method=auto:自动选择处理外键的方式,如果表上有外键,这个参数必须有。
4.3 gh-ost 实际命令与关键参数
后来切到 gh-ost,主要是在 5.7 的库上跑。典型命令:
bash复制gh-ost \
--host=127.0.0.1 \
--user=admin \
--password=your_password \
--database=test_db \
--table=user_order \
--alter="ADD COLUMN remark VARCHAR(50) DEFAULT NULL" \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--critical-load="Threads_running=100" \
--max-lag-millis=5000 \
--execute
gh-ost 的关键参数和 pt-osc 有些类似,但有几个值得特别注意:
--max-lag-millis=5000:从库延迟阈值,超过 5 秒就节流。--throttle-control-replicas:指定要监控延迟的从库 Host,不设置的话 gh-ost 会探测所有从库。--initially-drop-ghost-table/--initially-drop-old-table:处理上一次遗留的影子表或用旧表。
gh-ost 还有一个非常实用的功能:支持暂停和恢复,通过往 socket 文件发送命令实现:
bash复制echo "throttle" | nc -U /tmp/gh-ost.test_db.user_order.sock
echo "no-throttle" | nc -U /tmp/gh-ost.test_db.user_order.sock
echo "status" | nc -U /tmp/gh-ost.test_db.user_order.sock
这在业务高峰临时来临时,可以做到秒级限流,非常有用。
4.4 两个工具怎么选,我的判断标准
没有哪一个工具是绝对最优的,要根据场景选:
| 对比维度 | pt-osc | gh-ost |
|---|---|---|
| 原理 | 触发器 + 影子表 | binlog 解析 + 影子表 |
| 对主库侵入性 | 较高(触发器本身有写入成本) | 较低(无触发器) |
| 对从库的依赖 | 有,通过 max-lag 控制 | 有,通过 max-lag-millis 控制 |
| 支持外键 | 支持,通过参数控制 | 不支持(默认拒绝) |
| 支持触发器 | 不支持(表上已有触发器会拒绝) | 支持,不依赖触发器 |
| 是否要求 ROW 格式的 binlog | 不要求 | 必须(要求 binlog_format=ROW) |
| 可暂停性 | 可调参数暂停 | 通过 socket 命令实时控制,体验更好 |
| 成熟度 | 非常成熟,社区老牌 | 成熟,GitHub 生产验证 |
简单总结我的选择逻辑:
- 表上有外键,选 pt-osc,否则 gh-ost 直接报错。
- 主库压力高,选 gh-ost,因为它不引入额外触发器写入。
- MySQL 版本较老(5.5/5.6),binlog 格式可能是 STATEMENT,这时候 gh-ost 无法使用,只能选 pt-osc。
- 希望有更精细化的限流控制,选 gh-ost。
4.5 跑工具过程中最容易翻车的三个地方
用 gh-ost 或者 pt-osc 时,我踩过最深的坑有这么几个:
第一个坑是 gh-ost 要求表必须有主键或唯一键。 千万级大表如果没有主键,gh-ost 会直接报错:gh-ost: no unique key found。可以先在业务表上加主键,再跑 DDL。加主键本身也是一次 DDL,也需要评估。
第二个坑是 binlog 格式不对。 gh-ost 要求 binlog_format=ROW,且 binlog_row_image=FULL。很多老库为了省空间设置了 binlog_row_image=MINIMAL,这时候 gh-ost 拉到的 binlog 数据不全,同步会出现数据不一致。我建议在上 gh-ost 前先执行:
sql复制SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'binlog_row_image';
如果 binlog_format 不是 ROW,要么改配置重启(生产上很难),要么直接换 pt-osc。
第三个坑是磁盘空间。 无论 gh-ost 还是 pt-osc,影子表都会占用一份完整表空间。以我们那个 120GB 的表为例,跑的时候要确保磁盘至少有 130GB 以上的剩余空间,如果算上 binlog 增量、临时文件,建议预留 1.5 倍空间。
5. 拆表重命名:不依赖工具的大表字段变更思路
在某些极端情况下,工具方案不可用:比如云数据库某些版本限制了 binlog 格式、某安全策略禁止创建触发器、或者表大到工具跑完的时间不可接受。这时候可以考虑一种最“原始”但也最可控的方案:拆表 + 双写 + 重命名。
5.1 核心思路
不直接在原表上动刀,而是提前准备好新表:
- 创建一张新表
user_order_new,结构包含目标字段。 - 应用层开启双写:所有写入同时写旧表和新表。
- 把历史数据分批从旧表迁移到新表。
- 迁移完成并校验后,通过原子性的 RENAME TABLE 把新表切换成正式表。
- 移除旧表。
这个方案本质上和 pt-osc 做的事情一模一样,只不过把“工具自动执行”改成了“业务代码 + DBA 手动控制”。好处是每一步都可控、可回滚、可监控。
5.2 具体操作步骤
第一步,建表:
sql复制CREATE TABLE user_order_new LIKE user_order;
ALTER TABLE user_order_new ADD COLUMN remark VARCHAR(50) DEFAULT NULL;
第二步,应用层加一个配置开关,比如 is_double_write,开启后写操作同时写入两张表。这一步尤其重要,因为如果不双写,搬数据期间的新增/修改记录会丢。
第三步,分批搬数据。不要直接 INSERT INTO user_order_new SELECT * FROM user_order,这会锁住整张表,或者产生超大事务。用分批的写法:
sql复制-- 按主键范围分批,每次搬 10000 条
INSERT INTO user_order_new
SELECT * FROM user_order
WHERE id > 1000000 AND id <= 1010000;
第四步,循环执行上面的分批 INSERT,直到把全部数据搬完。可以写一个存储过程或者脚本控制。
第五步,校验两边数据量是否一致:
sql复制SELECT COUNT(*) FROM user_order;
SELECT COUNT(*) FROM user_order_new;
这里注意,做 COUNT 的时候可能还有双写的增量写入,所以最稳妥的做法是先暂停写请求,确认两边一致后再切换,或者对比最大主键和业务自定义的更新时间。
第六步,切换:
sql复制RENAME TABLE user_order TO user_order_old, user_order_new TO user_order;
RENAME TABLE 是一个原子操作,应用层双写开关可以同时关闭,切换的瞬间对业务的影响极小,几乎是毫秒级。
第七步,确认无问题后,删除旧表:
sql复制DROP TABLE user_order_old;
5.3 这个方案的优缺点
优点是完全不依赖在线 DDL 工具,也不依赖特定 MySQL 版本,只要 SQL 能跑就能做。缺点是必须改造应用层代码,引入双写逻辑,而且整个迁移期间新旧表同时存在,需要额外的存储空间和运维精力。
对于有专职 DBA、且有足够开发资源的团队,这套方案是最可控的;对于小团队,我更推荐优先用 gh-ost 或 pt-osc。
6. 实战中那些文档里不会写的经验与坑
最后这部分,我尽量把这些年做 DDL 积累下来的、不那么“教科书”的经验写出来。每一条都是真金白银换来的。
6.1 先看历史慢查询,再决定执行时机
执行任何大表 DDL 前,先看这张表最近的慢查询情况:
sql复制SELECT * FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT LIKE '%user_order%'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
如果表上存在大量长事务、大查询,比如那种跑几十秒的报表 SQL,那么 DDL 几乎一定会被卡住。要等到这类查询少的时候再操作,或者提前和业务方沟通,把大查询挪出这个时间段。
6.2 关于 LOCK=NONE 的一个反直觉真相
很多人以为加 LOCK=NONE 就能保证不锁表,这是误解。LOCK=NONE 的意思是“允许 DML 并发执行”,但它不保证 ALTER 过程中完全不阻塞 DML。MySQL 在执行某些 DDL 阶段时,依然需要短暂获取 MDL 排他锁,比如准备阶段和提交阶段。这两个阶段虽然时间极短,但如果你在高并发场景下连续跑几十个 DDL,依然会让业务出现毛刺。
所以哪怕参数写的再“在线”,也要选择业务低峰期执行。
6.3 主从复制也不能完全信任
很多人在主库上执行完 DDL,扫一眼主库成功,就认为万事大吉。但主从复制有一个很常见的坑:如果你在主库上用 INSTANT 或 INPLACE 算法完成了 DDL,从库在应用这个 DDL 时,可能因为版本不一致、参数不一致而使用不同的执行计划,从而出现延迟爆表或者直接复制中断。
我建议每次 DDL 后不仅看主库耗时,还要观察所有从库的 Seconds_Behind_Master 至少 5 分钟,确保没有隐藏的复制风险。
6.4 gh-ost 切换时的 binlog 事件大小设置
gh-ost 在切换阶段会执行一个原子性的 RENAME,这个操作会生成一个 DDL 事件写入 binlog。默认情况下,如果表的列数很多、且有大量 INSTANT 列,binlog 事件的大小可能超出 max_allowed_packet,导致复制中断。我的经验是:在执行 gh-ost 前,检查主从两边的 max_allowed_packet 是否一致,尽量调到至少 64MB。
6.5 执行完 DDL 后立刻做一次 ANALYZE TABLE
ALTER TABLE 之后,表的统计信息可能不会自动更新,尤其是在大表上。代价是什么?优化器可能拿着过时的统计信息,生成错误的执行计划,导致某些查询突然变慢。所以 DDL 完成后,建议立即执行:
sql复制ANALYZE TABLE user_order;
这算是收尾动作,但很多人会忘记。
6.6 一种低成本验证方案的正确打开方式
如果你拿不准某个 DDL 在当前大表上执行要多久、会不会锁表,有一个低成本的验证方式:先在测试环境创建同样结构的表,灌入百万行数据,再模拟线上流量压测,看看 ALTER 耗时和锁等待情况。但我不建议只灌百万行,因为百万行和千万行的行为差异非常大,尤其是 MDL 等待和拷贝时间是非线性的。
更靠谱的验证是在生产环境的一个从库上先执行一次 DDL。 从库上的 DDL 不会影响主库业务,但可以真实反映大表 DDL 的耗时和资源消耗。确认没问题后,再规划主库的执行方案。
6.7 最后一条:永远准备好回滚方案
执行大表 DDL 前,一定要想清楚:如果执行到一半失败了怎么办?工具被 KILL 了怎么办?磁盘满了怎么办?从库延迟飙升怎么办?
我的习惯是在执行 DDL 之前,把以下内容写成文档:
- 当前表结构 DDL
- 表大小、行数
- 从库拓扑
- 磁盘剩余空间
- 回滚步骤(比如重建表、恢复备份)
- 业务方联系人、确认窗口
所有大表 DDL 都在凌晨执行,并且至少两个人同时在线。一个人盯执行,一个人盯监控。出了问题,第一时间 KILL 进程,绝不死扛。
个人经验是,在整个 DDL 生命周期里,真正让团队翻车的往往不是 DDL 本身,而是执行前的判断失误和执行后的监控缺失。只要把这两块做扎实,千万级大表加字段这件事并没有想象中那么可怕。
