一张几千万行的表要加个字段,ALTER TABLE 敲下去,从库延迟飙到几千秒,业务开始报超时,最后 DBA 手动 kill。这不是运气问题,而是算法选错了:ALTER 在 8.0 里可能只改元数据,也可能把整张表拷一遍再改名,中间差着几个数量级。
按"算法 → 哪些操作走哪种 → INSTANT/INPLACE 的代价 → MDL → 工具 → 分步策略"拆开。
1. 三种 DDL 算法
| 算法 | 是否重建表 | 是否阻塞 DML | 相对耗时(典型量级) |
|---|---|---|---|
INSTANT |
否,只改元数据 | 不阻塞 | 秒级以内 |
INPLACE |
可能重建(取决于操作) | 多数情况允许并发 DML | 与表大小线性相关 |
COPY |
是,建临时表逐行拷 | 阻塞写 | 最慢,且需要双倍磁盘 |
ALTER TABLE orders ADD COLUMN channel VARCHAR(16) NULL, ALGORITHM=INSTANT;
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
显式写 ALGORITHM= 与 LOCK= 是唯一可靠的防线。不写时 MySQL 会自动挑一个它能做到的算法——你以为在跑在线 DDL,实际可能已经退化成 COPY 并锁了写。写上之后,做不到就直接报错,而不是静默降级:
ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: ...
报错比锁表好得多。
2. 哪些操作能 INSTANT,哪些必须重建
| 操作 | 算法 | 备注 |
|---|---|---|
ADD COLUMN 在末尾 |
INSTANT |
8.0.12 起支持 |
ADD COLUMN 指定 AFTER/FIRST |
INSTANT |
8.0.29 起支持,更早版本需 INPLACE |
DROP COLUMN |
INSTANT |
8.0.29 起支持 |
改列 DEFAULT |
INSTANT |
8.0.13 起支持 |
RENAME COLUMN |
INSTANT |
只改元数据 |
| 加普通/唯一索引 | INPLACE |
可 LOCK=NONE |
| 加全文索引 | INPLACE |
需 LOCK=SHARED,期间阻塞写 |
改列类型 / 字符集 / ROW_FORMAT |
COPY |
必须重建 |
OPTIMIZE TABLE |
INPLACE |
重建以回收空间 |
一条经验:改"表的结构描述"多为 INSTANT,改"每一行的字节布局"必然重建。列类型、字符集、行格式都属于后者。
3. INSTANT 的隐藏代价
INSTANT 快,但它不是免费的:
| 代价 | 说明 |
|---|---|
| 行格式被打上 instant 标记 | 新列值不进主行,读时需额外解析 |
| 删列不返还磁盘 | DROP COLUMN ... ALGORITHM=INSTANT 只标记不可见,空间要重建才回收 |
| 多次 INSTANT 叠加 | 按 8.0.x 文档核对,早期版本对 instant 列数量有限制 |
| 老版本工具解析异常 | 从库或旧客户端可能读不对 |
-- 要让 INSTANT 留下的空洞真正回收,只能重建
ALTER TABLE orders FORCE, ALGORITHM=INPLACE, LOCK=NONE;
-- 或
OPTIMIZE TABLE orders;
重建本身是一次全表扫描 + 全表写,仍然是重操作——"先 INSTANT 后重建"只是把一次重活拆成两次,不是消除它。
4. INPLACE 的在线重建机制
INPLACE 分三段:
| 阶段 | 做什么 | 锁 |
|---|---|---|
prepare |
取 MDL 排他,检查、建新索引的中间结构 | 极短的排他 MDL |
execute |
扫描聚簇索引、排序、构建新索引;期间 DML 写入 row log | 允许并发 DML |
commit |
再次取排他 MDL,应用 row log,替换 | 极短的排他 MDL |
SHOW VARIABLES LIKE 'innodb_online_alter_log_max_size'; -- 默认 128M
SHOW VARIABLES LIKE 'innodb_sort_buffer_size';
innodb_online_alter_log_max_size 是 DDL 期间并发 DML 的缓冲上限。写流量大或 DDL 跑得久,缓冲撑满就直接失败:
ERROR 1799 (HY000): Creating index 'idx_x' required more than
'innodb_online_alter_log_max_size' bytes of modification log. Please try again.
这个失败是安全的:表没坏,只是白跑一趟。处理是调大缓冲或低峰重跑,不是改小并发度就完事。
5. COPY 为什么锁写
COPY 的流程是:建一张带新结构的临时表 → 对原表加共享锁(阻塞写、允许读)→ 逐行拷数据 → 改名替换。阻塞写的根本原因是拷贝期间无法保证新写入也符合新结构,只能把写挡在外面。
| 阶段 | 做什么 | 后果 |
|---|---|---|
| 建临时表 | 按新结构建空表 | 需整表大小的磁盘 |
| 加共享锁、逐行拷 | 允许读、阻塞写 | 写入全部排队 |
RENAME 替换 |
原子改名 | 切换要拿 MDL |
6. MDL 的排队效应
DDL 要拿元数据锁的排他模式,DML 拿共享模式。共享之间兼容,共享与排他互斥。于是出现这个链条:
| 时刻 | 事件 | 后果 |
|---|---|---|
| T0 | 一个长查询持有共享 MDL | 正常 |
| T1 | DDL 请求排他 MDL,排队 | DDL 等待 |
| T2 | 新查询请求共享 MDL | 排在 DDL 后面 |
| T3 | 新查询越积越多 | 连接池打满,全站超时 |
关键点是 T2:MDL 队列不允许后来的共享锁插到排他锁前面,否则 DDL 永远等不到。所以一个慢查询就能让后面的 DDL 变成雪崩的起点。
SELECT * FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE' AND OBJECT_SCHEMA = 'app'\G
SELECT * FROM performance_schema.threads WHERE PROCESSLIST_ID = 812\G
-- 先杀长查询,再执行 DDL
KILL 812;
-- 或给 DDL 的 MDL 等待设上限(默认极大)
SET SESSION lock_wait_timeout = 5;
lock_wait_timeout 默认值非常大,意味着 DDL 会一直等下去并堵住所有人。执行 DDL 前把它设成几秒,是在给自己留退路。
7. pt-online-schema-change 与 gh-ost
| 维度 | pt-osc |
gh-ost |
|---|---|---|
| 同步机制 | 在原表建三个触发器,把变更写进影子表 | 解析 binlog,回放到影子表 |
| 原表额外开销 | 每次 DML 多三次触发器执行 | 几乎为零,但多一个 binlog 读取连接 |
| 与已有触发器冲突 | 冲突,需先处理 | 有触发器的表直接不支持 |
| 权限要求 | 较高(建触发器、改表) | 需要 REPLICATION 权限读 binlog |
| 限速手段 | --max-load、--critical-load、--chunk-time |
--max-load、--critical-load、--throttle-* |
| 切换 | RENAME TABLE 原子替换 |
同样靠 RENAME,配合 --cut-over |
pt-online-schema-change \
--alter "ADD COLUMN channel VARCHAR(16) NULL" \
--max-load Threads_running=30 --chunk-time 0.5 \
D=app,t=orders,h=127.0.0.1
切换瞬间的风险两边一样:RENAME TABLE 需要拿到新旧两张表的排他 MDL。如果此刻有长事务或长查询,切换会卡住,而工具已经把写引流到影子表一会儿了——这段窗口里丢写是最常见的翻车方式。对策是先确认 Threads_running 平稳、无长事务,再切;或改用 --cut-over=two 这类显式两步切换。
8. 分步变更策略
能不重建就不重建。加字段、改字段这类变更,用 expand-contract 拆成六步:
| 阶段 | 动作 | 可回滚点 |
|---|---|---|
| 1 expand | ADD COLUMN new_col ... NULL,ALGORITHM=INSTANT |
随便删 |
| 2 双写 | 应用同时写旧列与新列 | 关掉双写即可 |
| 3 回填 | 分批把旧列值刷进新列 | 停回填即可 |
| 4 切读 | 读路径改用新列 | 切回旧列 |
| 5 停旧写 | 观察一个业务周期后停止写旧列 | 重新开双写 |
| 6 contract | DROP COLUMN old_col,ALGORITHM=INSTANT |
需恢复则重新加 |
-- 阶段 1
ALTER TABLE orders ADD COLUMN channel VARCHAR(16) NULL, ALGORITHM=INSTANT;
-- 阶段 6(8.0.29+ 可直接 INSTANT)
ALTER TABLE orders DROP COLUMN old_channel, ALGORITHM=INSTANT;
每一步都可回滚,代价是工期变长。改列类型(如 VARCHAR(32) → VARCHAR(64))无法用 INSTANT,只能走这套流程,这是绕开 COPY 的正路。
9. 回填的限速与分批
| 手段 | 写法 | 注意 |
|---|---|---|
| 主键区间分批 | WHERE id BETWEEN ? AND ? |
区间固定,最可控 |
| 游标分批 | WHERE id > ? ORDER BY id LIMIT 500 |
适合边回填边有新增 |
| 副本延迟保护 | 监控 Seconds_Behind_Master 即停 |
需要外部脚本 |
UPDATE orders SET channel = 'legacy'
WHERE id > 0 AND id <= 100000 AND channel IS NULL
ORDER BY id LIMIT 500;
分批回填的关键不是批大小,而是每批都是一个独立小事务:不要包在一个大事务里,否则 undo 与锁会一起膨胀,反倒制造长事务。
10. 变更前后检查清单
| 检查项 | 命令 | 判断 |
|---|---|---|
| 表大小 | SELECT table_rows, data_length, index_length FROM information_schema.TABLES WHERE ... |
估算 DDL 时长与磁盘需求 |
| 磁盘余量 | df -h |
至少要留 1.2 倍表大小(COPY 需 2 倍) |
| 从库延迟 | SHOW REPLICA STATUS |
变更前必须接近 0 |
| 长事务 | SELECT * FROM information_schema.INNODB_TRX |
有超过几十秒的直接推迟 |
| 长查询 | SELECT * FROM information_schema.PROCESSLIST WHERE TIME > 60 |
先清理,避免 MDL 排队 |
| 业务低峰 | 看访问曲线 | DDL 起始时间点 |
| 备份 | 逻辑或物理备份 | 大变更前必须有一次可恢复点 |
11. 排错:五种"DDL 把库搞挂了"
| 现象 | 原因 | 处理 |
|---|---|---|
ALTER 卡住不返回 |
等 MDL,前面有长查询 | 查 metadata_locks,kill 长查询 |
报 1846 不支持 INPLACE |
操作本身不支持该算法 | 换算法或改分步方案,别硬上 |
| 报 1799 row log 溢出 | 并发 DML 超过缓冲 | 调大 innodb_online_alter_log_max_size 或低峰重跑 |
| DDL 期间主从延迟暴涨 | 单条 DDL 在从库串行回放 | 分片、分批、低峰;必要时 pt-osc --max-load |
| 加索引把磁盘打满 | 新索引与临时排序文件同时占用 | 预留空间,先看 df 再动手 |
12. 边界:什么时候该换方案
| 场景 | 建议 | 理由 |
|---|---|---|
| 表几十亿行、改列类型 | 影子表 + binlog 同步(gh-ost) |
单机 COPY 已不可行 |
| 需要跨实例迁移 | 逻辑复制 / DTS | DDL 只是迁移的一部分 |
| 分区键调整 | 重建分区表 | 无在线算法 |
| 只增字段、业务允许可空 | INSTANT 足够 |
秒级完成,零风险 |
一句话收束:能用 INSTANT 就别用 INPLACE,能用 INPLACE ... LOCK=NONE 就别用 COPY;做不到时,把一次大 DDL 拆成六步可回滚的小变更。
13. 自测
- 不写
ALGORITHM=时 MySQL 会怎么选算法?为什么危险? - 哪三类操作一定需要重建表?
DROP COLUMN用INSTANT之后,磁盘空间什么时候才真的回来?innodb_online_alter_log_max_size撑满会导致什么?是数据损坏还是执行失败?- 一个长查询是怎么把后续所有查询都堵在 DDL 后面的?
pt-osc与gh-ost在同步机制上的差别,各带来什么限制?