痛点
某生产 Aurora MySQL 集群上有两张日历相关的大表,需要把两个 varchar(255) 字段扩展到 varchar(500)。看似只是一条 ALTER TABLE MODIFY COLUMN,但这两张表分别是 6000 万行 / 37GB 和 3500 万行 / 28GB,而且集群还被另一套业务共用。
如果直接执行原生 ALTER TABLE,会踩到 MySQL 的一个经典陷阱:
InnoDB 的
MODIFY COLUMN在涉及字符集 / COLLATE 变更时,会强制走ALGORITHM=COPY(全表重建),即使你显式写ALGORITHM=INPLACE也会被拒绝。
全表重建意味着:长时间持有 MDL(元数据锁)、消耗大量 CPU/IO。在几十 GB 的大表上,这会让集群 CPU 飙到 90%+、锁住在线业务的读写——事实上我们之前正是因为一次未评估的生产 DDL,导致 Writer CPU 冲到 96%、连锁触发上层服务 OOM、整条链路雪崩。
所以结论很明确:几十 GB 的生产大表,绝不能直接 ALTER,必须用在线 DDL 工具。 本文用的是 gh-ost。
为什么选 gh-ost
gh-ost(GitHub Online Schema Transmogrifier)的核心思路是影子表 + binlog 增量同步,全程几乎不锁原表:
- 建一张与原表结构相同的「影子表」,在影子表上执行目标 DDL;
- 把原表的历史数据分块(chunk)拷贝到影子表;
- 同时把原表的实时增量变更(通过订阅 binlog)应用到影子表;
- 数据追平后,用一次毫秒级的原子
RENAME把影子表切换成正式表(cut-over)。
关键点:拷贝历史数据 + 同步增量的整个过程,原表都是可读可写的;只有最后 cut-over 的一瞬间有极短暂的锁。这正是它替代会长时间锁表的原生 ALTER 的意义。
对比原生 DDL:
| 原生 ALTER(COPY) | gh-ost | |
|---|---|---|
| 锁表时间 | 全程持 MDL,可能数小时 | 仅 cut-over 毫秒级 |
| 在线读写 | 阻塞 | 正常 |
| 负载控制 | 无,一口气跑满 | 自动 throttle,可运行时调 |
| 中途可暂停 | 否 | 是(暂停/恢复/中止都安全) |
前置条件(务必先检查)
gh-ost 不是装上就能跑,Aurora 上尤其有几个坑:
1. binlog 必须开启且为 ROW 格式
gh-ost 靠订阅 binlog 捕获增量。Aurora 默认可能没开 binlog。需要在集群参数组(不是实例参数组)确认:
-- 在 writer 上查运行值(不是看参数组的待生效值)
SHOW VARIABLES LIKE 'binlog_format'; -- 需为 ROW
SHOW VARIABLES LIKE 'binlog_row_image'; -- FULL(Aurora 8.0 引擎默认即 FULL)
⚠️ 踩坑提醒:binlog_format 是 Aurora 的集群级静态参数,改完必须重启实例才生效(reader 先重启、writer 走 failover)。别等到执行当天才发现没开——重启有中断,要提前排期。
另外,如果你在 reader 端点上查会看到 log_bin=OFF——这是 Aurora reader 的正常现象(reader 不产生 binlog),要以 writer 的运行值为准。
2. 专用账号 + 最小权限
CREATE USER 'ghost_user'@'%' IDENTIFIED BY '<强密码>';
-- 库级 DML/DDL(建影子表、回填、换表)
GRANT ALTER, CREATE, DELETE, DROP, INDEX, INSERT, LOCK TABLES, SELECT, TRIGGER, UPDATE
ON `<db>`.* TO 'ghost_user'@'%';
-- 读 binlog(复制权限只能授在全局 *.*)
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'ghost_user'@'%';
FLUSH PRIVILEGES;
Aurora 不开放
SUPER给用户,但 gh-ost 不需要 SUPER——加--assume-rbr跳过需要 SUPER 的 binlog 格式检查即可。
3. 表结构:无外键、无触发器
gh-ost 遇到外键或触发器会直接拒绝执行。先确认:
-- 外键
SELECT * FROM information_schema.key_column_usage
WHERE table_schema='<db>' AND (table_name='<t>' OR referenced_table_name='<t>')
AND referenced_table_name IS NOT NULL;
-- 触发器
SELECT * FROM information_schema.triggers
WHERE trigger_schema='<db>' AND event_object_table='<t>';
若有外键/触发器,改用 pt-online-schema-change。
4. 评估表大小和耗时
SELECT table_rows,
ROUND((data_length+index_length)/1024/1024/1024,2) AS total_gb
FROM information_schema.tables
WHERE table_schema='<db>' AND table_name='<t>';
经验值(4 vCPU / 32GB 实例 + I/O 优化存储):几十 GB 的表,gh-ost 拷贝耗时 2–4 小时(生产因业务负载 throttle 会更久)。一个 1 小时的维护窗口远远不够——但没关系,gh-ost 可以后台跑数小时,只有 cut-over 需要挑低负载时刻。
实操步骤
Step 1:封装一个执行脚本
把 gh-ost 参数固化成脚本,默认 dry-run,加 --execute 才真跑:
#!/usr/bin/env bash
set -euo pipefail
GHOST_BIN="./gh-ost"
DB_HOST="<cluster-writer-endpoint>" # 用集群 writer 端点,自动指向当前 writer
DB_PORT="3306"
DB_USER="ghost_user"
DB_PASS="${DB_PASS:-}" # 从环境变量传,避免明文/特殊字符问题
DB_NAME="<db>"
DB_TABLE="$1"
ALTER_STMT="$2"
POSTPONE="/tmp/ghost.${DB_TABLE}.postpone"
MODE="" # 默认 dry-run
[[ "${3:-}" == "--execute" ]] && MODE="--execute" && touch "$POSTPONE"
"${GHOST_BIN}" \
--host="${DB_HOST}" --port="${DB_PORT}" \
--user="${DB_USER}" --password="${DB_PASS}" \
--database="${DB_NAME}" --table="${DB_TABLE}" \
--alter="${ALTER_STMT}" \
--assume-rbr \
--allow-on-master \
--max-load="Threads_running=30,Threads_connected=1200" \
--critical-load="Threads_running=60" \
--critical-load-interval-millis=1000 \
--chunk-size=1000 \
--dml-batch-size=10 \
--initially-drop-ghost-table --initially-drop-old-table \
--postpone-cut-over-flag-file="${POSTPONE}" \
--cut-over=default \
--verbose \
--skip-metadata-lock-check \
${MODE} 2>&1 | tee "ghost_${DB_TABLE}_$(date +%Y%m%d_%H%M%S).log"
几个关键参数:
--assume-rbr --allow-on-master:Aurora 无经典 replica 拓扑,直接连 writer;--max-load=Threads_running=30:活跃线程超 30 就自动暂停拷贝(保护业务);--critical-load=Threads_running=60:超 60 则自动中止(保命兜底);--postpone-cut-over-flag-file:数据追平后不自动 cut-over,等你手动删这个文件才切——把毫秒级锁放到你盯着的低负载时刻;--skip-metadata-lock-check:Aurora 默认没开 performance_schema 的 MDL instrument,加这个跳过检查(否则 gh-ost 会 bail out)。
Step 2:先 dry-run 校验
DB_PASS='xxx' ./run_ghost.sh my_big_table \
"MODIFY COLUMN col_a varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \
MODIFY COLUMN col_b varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL"
不加 --execute 就是 dry-run。重点看输出里:binary logs validated(binlog OK)、账号权限校验通过、影子表建/改/删成功、无 FK/触发器报错、退出码 0。
Step 3:正式执行
命令末尾加 --execute。gh-ost 会建影子表 → binlog 增量同步 + 逐块回填 → 追平后因 postpone 文件停下等待。日志类似:
Copy: 21312000/64747231 32.9%; Applied: 154014; Backlog: 1/1000;
Lag: 0.02s; State: migrating; ETA: 1h10m
Step 4:全程监控
另开终端,通过 socket 实时看状态、控节流:
echo status | nc -U /tmp/gh-ost.<db>.<table>.sock # 查状态
echo throttle | nc -U /tmp/gh-ost.<db>.<table>.sock # 手动暂停拷贝
echo no-throttle | nc -U /tmp/gh-ost.<db>.<table>.sock # 恢复
盯三个指标:
- Lag:复制延迟。<1s 健康,持续 >30s 且爬升才危险;
- Backlog:binlog 事件积压队列(如 1/1000)。只要 Lag 低,Backlog 满也不危险;
- 数据库 Writer CPU:这才是要保护的对象。
Step 5:cut-over(挑低负载时刻)
数据拷到 100%、Lag 低时,删 postpone 文件触发切换:
rm /tmp/ghost.<db>.<table>.postpone
注意:不用死等 Backlog 恰好为 0。删文件只是「授权 cut-over」,gh-ost 会自己追平剩余增量、再在毫秒级窗口原子换表。
cut-over 成功的日志:
Lock & rename duration: 958ms. During mgtr time, queries were blocked
Tables renamed
Done migrating
整个 cut-over 只阻塞了不到 1 秒——对在线业务几乎无感。
避坑
1. 生产 CPU 被拷贝顶高,怎么调节
实战中,全速拷贝叠加业务高峰,把 Writer CPU 顶到了 88% 并持续了近 10 分钟。处置手段有三层,全部运行时生效、不用重启:
| 手段 | 效果 | 对总耗时 |
|---|---|---|
echo throttle |
完全暂停拷贝,CPU 立即降 | 暂停时长直接叠加 |
max-load=Threads_running=15(调低阈值) |
业务稍忙就让路 | 变慢 |
nice-ratio=0.5(拷贝间加延迟) |
CPU 平缓降,拷贝不停 | 增约 1/3 |
实战中我们用了 nice-ratio=0.5(比完全 throttle 温和——拷一批就 sleep 半批时间),CPU 从 88% 降到 71–74%;等进入业务低谷窗口后再 nice-ratio=0 恢复全速。
echo "nice-ratio=0.5" | nc -U /tmp/gh-ost.<db>.<table>.sock # 温和降速
echo "nice-ratio=0" | nc -U /tmp/gh-ost.<db>.<table>.sock # 恢复全速
理解区别:throttle 是「停车等」(完全不动,时长叠加);nice-ratio 是「开慢车」(一直在开,只是慢)。想降 CPU 又不想完全停,用 nice-ratio。
2. chunk-size 不能运行时改
chunk-size(每批拷多少行)影响 CPU 冲击强度和速度,但它是启动参数,运行中改不了。想调只能重启 gh-ost。所以启动时就要设合理值(默认 1000 比较稳;对超大表想温和点可设 500)。
3. 密码含特殊字符导致 Access denied
如果 gh-ost 报 Access denied 但手动 mysql -p 能连,多半是密码含 $ ` 等特殊字符,在 shell 里被解析了。解法:用 --conf 配置文件传密码,或用环境变量(<<'EOF' 写入配置文件,单引号 heredoc 防解析)。
4. cut-over 后旧表不会自动删
gh-ost 默认保留旧表(重命名为 _<table>_del),保守起见留个回滚余地。确认新表没问题、观察几天后再手动删回收空间:
DROP TABLE `<db>`.`_<table>_del`;
5. 先在克隆实例演练
上生产前,强烈建议用生产快照建一个克隆实例,把完整流程(dry-run → execute → cut-over → 验证)跑一遍。克隆实例没有在线业务,速度是「理想值」——生产因为 throttle 会慢 2–4 倍,但流程、结果正确性、cut-over 机制都能提前验证。
验证
cut-over 后确认结构和数据:
-- 列定义生效
SELECT column_name, column_type, character_maximum_length, collation_name, is_nullable
FROM information_schema.columns
WHERE table_schema='<db>' AND table_name='<table>'
AND column_name IN ('col_a','col_b');
-- 行数一致
SELECT COUNT(*) FROM `<db>`.`<table>`;
总结
| 维度 | 原生 ALTER | gh-ost |
|---|---|---|
| 适用表大小 | 小表(几百 MB 内) | 大表(几十 GB+) |
| 锁表 | 全程 MDL,数小时 | 仅 cut-over 毫秒级 |
| 业务影响 | 阻塞,可能雪崩 | 几乎无感 |
| 可控性 | 无 | 运行时可 throttle / nice-ratio / 中止 |
| 复杂度 | 一条 SQL | 需前置检查 + 监控 |
核心结论:
- 几十 GB 的生产大表改结构,一律用 gh-ost(或 pt-osc),严禁直接
ALTER——尤其涉及 COLLATE/字符集变更时,原生 DDL 必然全表重建、锁表雪崩。 - Aurora 上先确认 writer 的 binlog 已开为 ROW(集群参数组静态参数,改完要重启),这是 gh-ost 的命根子。
- 用
--postpone-cut-over-flag-file把毫秒级 cut-over 放到你盯着的低负载窗口,别让它自动切。 - CPU 高时用
nice-ratio温和降速,比完全 throttle 更实用;chunk-size要在启动时设好。 - 务必先在克隆实例演练,生产耗时按克隆的 2–4 倍预估。
一次 37GB / 6000 万行的表结构变更,用 gh-ost 后台跑了约 2 小时,cut-over 只阻塞了 0.96 秒,在线业务全程无感——这就是在线 DDL 工具的价值。
本文基于一次真实的 AWS Aurora MySQL 生产 DDL 实战整理。