个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL Online DDL 完全指南:INSTANT/INPLACE/COPY 三种算法、MDL 锁事故与 gh-ost 实战](#MySQL Online DDL 完全指南:INSTANT/INPLACE/COPY 三种算法、MDL 锁事故与 gh-ost 实战)
-
- 一、先把"Online"这个词掰清楚
- 二、三种算法的执行过程
-
- [2.1 COPY:最原始,也最危险](#2.1 COPY:最原始,也最危险)
- [2.2 INPLACE:原地改,执行阶段放行 DML](#2.2 INPLACE:原地改,执行阶段放行 DML)
- [2.3 INSTANT:只改元数据,8.0 的救命特性](#2.3 INSTANT:只改元数据,8.0 的救命特性)
- [三、一张表看懂常见 DDL 走哪条路](#三、一张表看懂常见 DDL 走哪条路)
- [四、MDL 锁:Online DDL 事故的第一杀手](#四、MDL 锁:Online DDL 事故的第一杀手)
-
- [4.1 一次典型的雪崩](#4.1 一次典型的雪崩)
- [4.2 执行前必做的检查](#4.2 执行前必做的检查)
- [4.3 让 DDL 自己别变成塞子](#4.3 让 DDL 自己别变成塞子)
- [五、INPLACE 期间的两个隐藏上限](#五、INPLACE 期间的两个隐藏上限)
-
- [5.1 row log 溢出](#5.1 row log 溢出)
- [5.2 磁盘与临时目录](#5.2 磁盘与临时目录)
- [5.3 8.0 给大表 DDL 的性能开关](#5.3 8.0 给大表 DDL 的性能开关)
- [六、什么时候该上 gh-ost / pt-osc](#六、什么时候该上 gh-ost / pt-osc)
-
- [6.1 两个工具的原理差异](#6.1 两个工具的原理差异)
- [6.2 对比选型](#6.2 对比选型)
- [6.3 gh-ost 实战](#6.3 gh-ost 实战)
- [6.4 pt-osc 的写法](#6.4 pt-osc 的写法)
- [七、一套可复制的 DDL 操作流程](#七、一套可复制的 DDL 操作流程)
- 八、常见误区
- 小结
MySQL Online DDL 完全指南:INSTANT/INPLACE/COPY 三种算法、MDL 锁事故与 gh-ost 实战
"MySQL 不是支持 Online DDL 吗?为什么我加个字段,业务就雪崩了?"
这是 DBA 日常被问得最多的问题之一。真相是:
Online DDL 说的是"执行期间不阻塞 DML",但它没说"拿锁的时候不阻塞"。
而真正的生产事故,几乎都发生在那拿锁的几秒里。
这篇把三种算法、MDL 锁、inplace 的 row log 上限、以及 gh-ost / pt-osc 怎么选一次讲透。
一、先把"Online"这个词掰清楚
业界口语里的 "Online DDL" 其实混了三件事,必须先分开:
| 概念 | 含义 | 层级 |
|---|---|---|
ALGORITHM=COPY |
建临时表、逐行拷数据、再 rename | 执行方式 |
ALGORITHM=INPLACE |
原地操作,可能重建表,执行期间允许 DML | 执行方式 |
ALGORITHM=INSTANT |
只改数据字典元数据,秒级完成(8.0.12+) | 执行方式 |
LOCK=NONE / SHARED / EXCLUSIVE |
执行期间允许什么级别的并发 | 并发度 |
INPLACE ≠ 不锁表。
INPLACE 只说明"不拷贝整表数据",它在准备阶段和提交阶段依然要拿排他 MDL 锁 。
真正决定"能不能写"的,是 LOCK 子句:LOCK=NONE 才表示全程可读写。
语法长这样:
sql
ALTER TABLE orders
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '下单渠道',
ALGORITHM = INPLACE,
LOCK = NONE;
⚠️ 生产上不建议硬写 ALGORITHM 和 LOCK 。
MySQL 不指定时会按 INSTANT → INPLACE → COPY 自动挑最优的;
你一旦硬指定,本来能 INPLACE 的被你指定成 INSTANT 会直接报错,
指定成 COPY 就是一场事故。让 MySQL 自己选,才是默认正确的做法。
硬指定的价值在于验证 :写 ALGORITHM=INSTANT 能跑通,就说明它确实是 instant 的。
二、三种算法的执行过程
2.1 COPY:最原始,也最危险
准备阶段 ─ 拿排他 MDL → 建临时表(server 层 create like)
执行阶段 ─ 逐行 copy 数据到临时表(全程持排他锁,DML 全部阻塞)
提交阶段 ─ rename 临时表为原表名 → 删原表 → 释放锁
特征:执行期间只读、写全阻塞 ,需要约 1 倍表大小的额外磁盘;8.0 里仍有一批操作只能走它。
2.2 INPLACE:原地改,执行阶段放行 DML
准备阶段 ─ 共享锁升级为排他 MDL → 判断是否 rebuild → 若 rebuild 则申请 row log 空间
执行阶段 ─ 降级为共享 MDL(DML 放行)→ 扫描/重建数据 → 并发 DML 记入 row log
提交阶段 ─ 再次升级为排他 MDL → 回放 row log → rename → 释放锁
关键点:执行阶段不阻塞 DML ,但首尾各有一小段排他锁窗口;
rebuild 类操作 (加主键、删列、改数据类型)虽是 INPLACE 却要重建整表,耗时与 COPY 同量级;
no-rebuild 类操作(加/删二级索引、改默认值、改名)只动元数据或局部数据,很快。
判断是哪种:看 ALTER 返回的 rows affected。0 rows affected 说明没拷数据,有行数说明重建了表。
2.3 INSTANT:只改元数据,8.0 的救命特性
8.0.12 引入(由腾讯互娱 DBA 团队贡献),只修改数据字典,不动数据页,不加排他 MDL,秒级完成。
8.0.12 起支持:
- 在表末尾追加列
- 新增 / 删除虚拟列
- 设置 / 删除列默认值
- 修改 ENUM / SET 定义(往后追加值)
- 修改索引类型、表重命名
8.0.29 起进一步增强 :支持在任意位置 instant 加列,以及 instant 删列。
限制要记住:
| 限制 | 说明 |
|---|---|
| 行格式 | 不支持 ROW_FORMAT=COMPRESSED 的表 |
| 全文索引 | 表上有 FULLTEXT 索引时不支持 instant 加列 |
| 累计次数 | 同一张表 instant 加列有累计上限,超了需要重建(看 INFORMATION_SCHEMA.INNODB_TABLES.INSTANT_COLS) |
| 版本 | 8.0.12 之前完全没有这个能力;5.7 加列一定是 INPLACE rebuild,很慢 |
sql
-- 验证是不是真的 instant
ALTER TABLE t ADD COLUMN ext JSON DEFAULT NULL, ALGORITHM = INSTANT;
-- 不报错就说明是 instant;报 ERROR 1846 就说明不支持,得换方案
三、一张表看懂常见 DDL 走哪条路
基于 MySQL 8.0(InnoDB):
| 操作 | INSTANT | INPLACE | 重建表 | 可并行 DML | 只改元数据 |
|---|---|---|---|---|---|
| 加二级索引 | ❌ | ✅ | ❌ | ✅ | ❌ |
| 删二级索引 | ❌ | ✅ | ❌ | ✅ | ✅ |
| 索引重命名 | ❌ | ✅ | ❌ | ✅ | ✅ |
| 修改索引可见性 | ❌ | ✅ | ❌ | ✅ | ✅ |
| 加主键 | ❌ | ✅ | ✅ | ✅ | ❌ |
| 删主键 | ❌ | ❌ | ✅ | ❌ | ❌ |
| 删并同时加主键 | ❌ | ✅ | ✅ | ✅ | ❌ |
| 末尾加列 | ✅ 8.0.12+ | ✅ | ❌ | ✅ | ✅ |
| 任意位置加列 | ✅ 8.0.29+ | ✅ | ✅ | ✅ | ❌ |
| 删列 | ✅ 8.0.29+ | ✅ | ✅ | ✅ | ❌ |
| 修改列数据类型 | ❌ | ❌ | ✅ | ❌ | ❌ |
| 扩展 VARCHAR 长度 | ❌ | ✅ | ❌ | ✅ | ✅(≤255 或同档位) |
| 列重命名 | ❌ | ✅ | ❌ | ✅ | ✅ |
| 改列默认值 | ✅ | ✅ | ❌ | ✅ | ✅ |
| 加 STORED 虚拟列 | ❌ | ❌ | ✅ | ❌ | ❌ |
| 加 VIRTUAL 虚拟列 | ✅ | ✅ | ❌ | ✅ | ✅ |
| 转换表字符集 | ❌ | ❌ | ✅ | ❌ | ❌ |
OPTIMIZE TABLE |
❌ | ✅ | ✅ | ✅ | ❌ |
| 表重命名 | ✅ | ✅ | ❌ | ✅ | ✅ |
几个值得单独拎出来的点:
- VARCHAR 扩长度 :
VARCHAR(50) → VARCHAR(100)这种"255 以内"的变化是纯元数据操作,秒完;
但VARCHAR(100) → VARCHAR(300)跨过了字节数档位(1 字节长度前缀 → 2 字节),就要重建表。 - 加自增列 / 把列改成自增:会锁表并重建,属于高危操作。
- 第一个全文索引 :5.6+ 会自动补
FTS_DOC_ID从而避免 COPY,但仍会重建表,且阻塞写。 - 带 FULLTEXT 索引的表做
OPTIMIZE TABLE/ALTER TABLE ... ENGINE=InnoDB:退化成 COPY 并阻塞写。
四、MDL 锁:Online DDL 事故的第一杀手
4.1 一次典型的雪崩
sql
-- 会话 A:一个跑了 40 分钟还没提交的慢事务(或忘了 COMMIT 的手工操作)
BEGIN;
SELECT * FROM orders WHERE id = 1; -- 事务没提交,一直持有 MDL 共享锁
-- 会话 B:DBA 加字段
ALTER TABLE orders ADD COLUMN channel VARCHAR(32) DEFAULT '';
-- INPLACE,看上去很安全,但准备阶段要拿排他 MDL → 卡住,等待 A 释放
-- 会话 C/D/E......:所有业务 SQL
SELECT * FROM orders WHERE user_id = 100; -- 全部卡住!
为什么会连锁? MySQL 的 MDL 获取是排队 的。
B 的排他锁请求排在 A 后面,而 C 之后的共享锁请求又排在 B 后面。
于是明明只读的 SELECT 也被阻塞,连接数迅速打满,业务雪崩。
这才是"Online DDL 造成事故"的真实机制------不是 DDL 本身慢,而是它变成了队列里的一个塞子。
4.2 执行前必做的检查
sql
-- 1. 有没有长事务(重点关注 trx_started 很早的)
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id AS conn_id, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60 ORDER BY trx_started;
-- 2. 谁在等 MDL(需先开启 instrument)
UPDATE performance_schema.setup_instruments SET ENABLED='YES', TIMED='YES'
WHERE NAME = 'wait/lock/metadata/sql/mdl';
SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, OWNER_THREAD_ID
FROM performance_schema.metadata_locks WHERE OBJECT_TYPE = 'TABLE';
-- 3. 当前连接都在干什么
SELECT id, user, db, command, time, state, LEFT(info, 100) AS sql_text
FROM information_schema.PROCESSLIST
WHERE db = 'your_db' AND command <> 'Sleep' ORDER BY time DESC;
确认没有长事务和慢查询之后再动手。
4.3 让 DDL 自己别变成塞子
sql
-- 给 DDL 设一个短的锁等待超时,拿不到锁就快速失败,不要堵住后面的人
SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders ADD COLUMN channel VARCHAR(32) DEFAULT '';
-- MySQL 8.0 还可以直接写在语句上
ALTER TABLE orders NOWAIT ADD COLUMN channel VARCHAR(32) DEFAULT '';
ALTER TABLE orders WAIT 10 ADD COLUMN channel VARCHAR(32) DEFAULT '';
快速失败比默默等待安全得多:失败了你重试就行,等待会把整个库拖挂。
五、INPLACE 期间的两个隐藏上限
5.1 row log 溢出
INPLACE 执行期间,并发 DML 会记到一块叫 row log 的区域里,最后回放。
这块区域的大小由 innodb_online_alter_log_max_size 控制,默认 128MB。
如果表很大、写入很猛、DDL 又跑得久,就会撞上:
ERROR 1799 (HY000): Creating index 'idx_xxx' required more than
'innodb_online_alter_log_max_size' bytes of modification log
后果是 DDL 直接失败回滚,前面跑的几个小时白费。
应对:
sql
SET GLOBAL innodb_online_alter_log_max_size = 1073741824; -- 临时调到 1G
但更根本的办法是在业务低峰做,或者直接用 gh-ost(它没有这个限制)。
5.2 磁盘与临时目录
- INPLACE rebuild 需要约等于表大小的额外空间
- 建二级索引时的排序会在临时目录产生大文件,
tmpdir所在分区要够大
(8.0 可以用innodb_tmpdir单独给 DDL 指定临时目录)
sql
SHOW VARIABLES LIKE 'innodb_tmpdir';
SHOW VARIABLES LIKE 'tmpdir';
SHOW VARIABLES LIKE 'innodb_online_alter_log_max_size';
5.3 8.0 给大表 DDL 的性能开关
| 参数 | 版本 | 作用 |
|---|---|---|
innodb_ddl_threads |
8.0.27+ | 并行创建二级索引的线程数 |
innodb_ddl_buffer_size |
8.0.27+ | 并行建索引时的 buffer 大小 |
innodb_parallel_read_threads |
8.0.14+ | 并行扫描聚簇索引,加速建表/校验 |
sql
-- 大表加索引前临时调大(会话级即可)
SET SESSION innodb_ddl_threads = 8;
SET SESSION innodb_ddl_buffer_size = 8388608;
ALTER TABLE big_table ADD INDEX idx_created (created_at);
六、什么时候该上 gh-ost / pt-osc
先说结论:除了 8.0 上确认是 INSTANT 的那些操作,其余大表 DDL 都建议用工具做。
原因很简单,原生 Online DDL 有几个绕不过去的问题:
- 无法真正限速:一旦开始就是全速跑,IO 和 CPU 打满你只能 kill,而 kill 大 DDL 的回滚代价极高。
- 无法暂停:发现主库扛不住了,停不下来。
- 主从延迟:DDL 在主库跑完后,从库要串行回放同一个 DDL,大表会造成严重延迟(8.0 的并行复制对 DDL 帮助有限)。
- row log 溢出风险。
6.1 两个工具的原理差异
pt-online-schema-change(Percona):
1. 建新表 _tbl_new(新结构)
2. 在原表上建 3 个触发器(INSERT / UPDATE / DELETE),把增量同步到 _tbl_new
3. 按 chunk 拷贝存量数据
4. RENAME TABLE 切换
gh-ost(GitHub):
1. 建幽灵表 _tbl_gho(新结构)+ 变更记录表 _tbl_ghc
2. 伪装成一个 slave,拉取 ROW 格式的 binlog 获取增量
3. 按 chunk 拷贝存量数据(INSERT IGNORE ... SELECT)
4. 持续 apply binlog 增量
5. cut-over:短暂拿一次表锁,原子 RENAME 切换
核心区别就一句话:pt-osc 用触发器捕获增量,gh-ost 用 binlog 捕获增量。
6.2 对比选型
| 维度 | pt-online-schema-change | gh-ost |
|---|---|---|
| 增量捕获 | 触发器 | ROW 格式 binlog |
| 对主库额外负载 | 触发器同步执行,开销大(实测约 12% 吞吐下降) | 异步读 binlog,开销极小(接近 0) |
| 能否限速 / 暂停 | 支持但粒度粗 | ✅ 非常灵活(chunk-size、max-load、nice-ratio、throttle 文件) |
| 已有触发器 | ❌ 冲突,不支持 | ✅ 不受影响 |
| 外键 | 需要 --alter-foreign-keys-method 额外处理 |
❌ 不支持有外键的表 |
| 需要主键/唯一键 | 需要 | 需要(用于 chunk 切分和行定位) |
| binlog 要求 | 无特殊要求 | 必须 ROW 格式,且 binlog_row_image=FULL |
| 追增量能力 | 触发器并行,能扛高写入 | 单线程 apply binlog,超高写入下可能永远追不上 |
| 可测试性 | 一般 | ✅ --test-on-replica 先在从库演练 |
| 写入停顿 | 切换瞬间 | cut-over 瞬间有毫秒到秒级锁等待 |
| 空负载下的速度 | 更快(约 gh-ost 的两倍) | 稍慢 |
⚠️ gh-ost 那个"追不上"的坑是真实的 :
社区 benchmark 显示,在 25% 满负载写入下,gh-ost 因为单线程回放 binlog 而永远无法完成迁移。
所以高写入表用 gh-ost 前,先观察它的 Backlog 指标 ,如果持续打满 100/100 且 Applied 增长缓慢,就要调小 chunk-size 或改在低峰执行。
6.3 gh-ost 实战
bash
# 最简执行:gh-ost --host=127.0.0.1 --user=dba --password='***' --database=shop \
# --table=orders --alter="ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT ''" \
# --allow-on-master --execute
# 生产推荐:带限速、带阈值、可随时暂停
gh-ost \
--host=127.0.0.1 --user=dba --password='***' \
--database=shop --table=orders \
--alter="ADD INDEX idx_created (created_at)" \
--allow-on-master \
--chunk-size=1000 \
--max-lag-millis=1500 \
--max-load='Threads_running=50' \
--critical-load='Threads_running=200' \
--throttle-flag-file=/tmp/gh-ost.throttle \
--throttle-query="SELECT IF(HOUR(NOW()) BETWEEN 9 AND 21, 1, 0)" \
--nice-ratio=0.5 \
--serve-socket-file=/tmp/gh-ost.orders.sock \
--execute
运行时的输出要看懂:
Copy: 3200000/9872432 32.4%; Applied: 15840; Backlog: 3/100;
Time: 12m30s(total), 12m20s(copy); streamer: mysql-bin.000018:641578191;
State: migrating; ETA: 26m10s
Copy--- 存量数据拷贝进度Applied--- 已回放的 binlog 事件数Backlog--- 待回放堆积量。持续 100/100 说明跟不上,要降速State---migrating正常;throttled说明被你限流了;postponing cut-over说明在等切换时机
运行时可动态控制 :touch /tmp/gh-ost.throttle 暂停,rm 恢复;
也可以通过 socket 在线改参数:echo "chunk-size=500" | nc -U /tmp/gh-ost.orders.sock。
先演练再上线 :--test-on-replica 会在从库上完整跑一遍但不切换,用来验证 SQL 正确性和耗时。
6.4 pt-osc 的写法
bash
pt-online-schema-change --host=127.0.0.1 --user=dba --password='***' \
D=shop,t=orders \
--alter "ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT ''" \
--chunk-size=1000 --max-lag=1.5 --check-interval=1 \
--recursion-method=processlist --no-check-replication-filters --execute
外键表要额外指定 --alter-foreign-keys-method=rebuild_constraints(或 drop_swap,更快但有短暂窗口)。
七、一套可复制的 DDL 操作流程
- 分类 :先用
ALGORITHM=INSTANT在测试库试一下,判断它是 INSTANT / INPLACE / COPY 中的哪一类。 - INSTANT? → 直接执行,但仍避开业务高峰。
- 小表(< 100MB)? → 原生 Online DDL 直接跑,几秒钟的事。
- 大表? → 上 gh-ost / pt-osc。
- 执行前检查 :有没有长事务 / 慢查询、磁盘剩余空间够不够(至少 1 倍表大小)、
主从延迟是否健康、innodb_online_alter_log_max_size是否要临时调大。 - 执行中监控:主从延迟、Threads_running、磁盘 IO、gh-ost 的 Backlog。
- 执行后清理 :gh-ost 会留下
_tbl_old(旧表)和_tbl_ghc,确认无误后再DROP,并ANALYZE TABLE刷新统计信息。
sql
ANALYZE TABLE orders;
DROP TABLE IF EXISTS _orders_ghc;
DROP TABLE IF EXISTS _orders_old; -- 确认新结构没问题之后再删!
八、常见误区
误区 1:INPLACE 就不锁表。 ------INPLACE 在 prepare 和 commit 各要一次排他 MDL,真正的杀手是排队效应。
误区 2:加个字段而已,很快。 ------8.0.12 之前加列是 INPLACE + 重建整表,5000 万行要跑几十分钟到几小时。这就是 INSTANT ADD COLUMN 被称为"救命特性"的原因。
误区 3:DDL 卡住了就 kill 掉。 ------大 DDL 的回滚可能比执行还慢,且期间锁一直不释放。正确做法是先找源头长事务。
误区 4:gh-ost 比 pt-osc 快。 ------空负载下 pt-osc 通常更快;gh-ost 的优势是可控、低侵入、可暂停,不是绝对速度,高写入下甚至可能永远跑不完。
误区 5:Online DDL 可以在高峰期随便做。 ------黄金准则:DDL 永远不要在业务高峰期执行,哪怕它是 INSTANT。
误区 6:8.0 了就不需要 gh-ost。 ------INSTANT 只覆盖"加列 / 删列 / 改默认值 / 加虚拟列"这一小撮;改数据类型、转字符集、删主键、加全文索引照样是重活。
小结
- Online DDL 不等于不锁表 :INPLACE 在准备和提交阶段各要一次排他 MDL,真正的风险是 MDL 排队导致的连锁阻塞
- 三种算法:COPY (拷数据、阻塞写)、INPLACE (原地、执行期放行)、INSTANT(8.0.12+ 只改元数据,秒级)
- 不要硬写
ALGORITHM/LOCK,让 MySQL 自动选;硬指定只用于验证 - 8.0.29 起 instant 支持任意位置加列和删列;COMPRESSED 行格式、有全文索引的表不支持
- 动手前必查 长事务和 MDL 等待 ,并给 DDL 设置
lock_wait_timeout让它快速失败 - INPLACE 的 row log 上限默认 128MB ,大表要调
innodb_online_alter_log_max_size - 8.0.27+ 可用
innodb_ddl_threads/innodb_ddl_buffer_size加速建索引 - 大表 DDL 用工具:pt-osc 靠触发器 (更快但侵入大),gh-ost 靠 binlog(低侵入、可限速可暂停,但单线程追增量有上限)
- gh-ost 要求 ROW 格式 binlog +
binlog_row_image=FULL,且不支持有外键的表 - 上线前用
--test-on-replica演练,运行时盯住 Backlog 指标 - 最后一条:DDL 永远不要在业务高峰期执行
下一篇聊备份与恢复------DDL 能改回来的前提是,你有一份能用的备份。