在线改大表实战:pt-osc 与 gh-ost 零停机

一张上千万行的订单表要加一列、或给热点字段补个索引,直接跑 ALTER TABLE,MySQL 5.6 之前会锁全表,线上交易直接卡死几小时;即便是支持 inplace 的版本,大表 DDL 也会引发主从延迟、undo 膨胀,风险一点没少。这就是为什么大表变更必须用「在线改表」工具。本文对比两款主流方案——Percona 的 pt-online-schema-change 与 GitHub 开源的 gh-ost,讲清原理、坑点和怎么选,作为 数据库迁移实战:Flyway 与 Liquibase 版本化管理 版本化迁移在大表执行环节的补齐。

一、为什么不能直接 ALTER TABLE

DDL 和 DML 最大的区别是它要改表结构本身。MySQL 的元数据锁(MDL)在大表上持有时间极长,期间所有写都被阻塞;即便用 inplace/online DDL,拷贝数据阶段仍会产生大量 redo/undo,并把 binlog 放大,从库回放直接落后,读写分离一旦把流量切过去就雪崩。所以「大表 DDL = 必须在线、必须可控暂停」,这正是 pt-osc 和 gh-ost 存在的理由。理解锁与执行计划,是评估风险的前提,建议先复习 MySQL 深度调优:核心参数与执行计划解读 的调优视角。

一个真实场景:某次在 6000 万行的订单表上加一个 NOT NULL 列,开发以为 MySQL 8.0 的 instant DDL「秒级完成」,结果该列带默认值、且历史行需要回填,依旧走了拷贝;低峰期启动后拷贝耗时 40 分钟,期间主从延迟一度冲到 8 分钟,监控告警轰炸。事后复盘:这种规模的变更,永远不该裸跑 ALTER,必须交给在线改表工具并设置低峰窗口。

二、pt-online-schema-change:触发器同步增量

pt-osc 的思路是「影子表 + 触发器」:先建一张带新结构的 _orders_new,把原表数据按 chunk 分批拷过去;同时在原表上挂 AFTER INSERT/UPDATE/DELETE 三个触发器,把变更实时同步到新表;全部追平后,用 RENAME 原子交换两张表。整个过程原表始终可写,业务无感知。

# pt-online-schema-change:给大表加一列,不锁原表
pt-online-schema-change \
  --alter "ADD COLUMN created_by BIGINT NOT NULL DEFAULT 0" \
  --no-drop-old-table \          # 留着 _old 表,出问题可回滚
  --max-load Threads_running=50 \ # 负载阈值,超了自动暂停
  --chunk-size=1000 \
  h=127.0.0.1,P=3306,u=ops,p=***,D=shop,t=orders \
  --execute

# 原理:建 _orders_new 影子表 → 按 chunk 拷贝原表 → 触发器同步增量 → 最后原子 rename 交换

它最稳的地方是「有触发器兜底,增量不丢」;代价是触发器本身有轻微写放大,且如果表上已经有别的触发器会冲突。–no-drop-old-table 会保留交换前的旧表,真出问题直接 rename 回去即可回滚。

三、gh-ost:用 binlog 取代触发器

gh-ost 是 GitHub 为了解决「触发器太重」而开源的方案。它不在原表挂触发器,而是连上从库订阅 binlog,把原表的增删改在 ghost 表上重放。因为没有触发器,对主库的写路径零侵入,还能精确控制「从库延迟一旦超阈值就暂停」,在生产环境更温柔。

# gh-ost:基于 binlog,无触发器,对主库更友好
gh-ost \
  --table=orders \
  --database=shop \
  --alter="ADD COLUMN created_by BIGINT NOT NULL DEFAULT 0" \
  --allow-on-master \
  --max-load=Threads_running=50 \
  --cut-over=atomic \
  --throttle-control-replicas=replica1,replica2 \  # 从库延迟超阈值自动暂停
  --execute

# 原理:在从库订阅 binlog → 在主库建 _orders_ghc/_orders_gho → 应用增量 → 原子 cut-over 切换

gh-ost 的 cut-over(切换)也是最讲究的一环:它用巧妙的锁顺序做原子切换,避免切换瞬间丢增量。正因为依赖 binlog,使用前提是 binlog 格式必须为 ROW,这也是 MySQL 主从复制与读写分离:高可用架构实战 主从架构下的标准配置。

四、两者怎么选:一张表说清

维度pt-online-schema-changegh-ost
增量同步原表触发器(AFTER DML)订阅 binlog 重放
对主库侵入有(触发器写入放大)几乎无(只读 binlog)
已有触发器冲突会冲突,需先清理无此问题
暂停策略–max-load 看负载–throttle 看从库延迟,更细
回滚保留 _old 表 rename 回保留 ghost 表,可回退
适用偏好老版本/无 binlog ROW 环境大表/高并发/严格控延迟

一句话:追求简单稳妥、环境老旧的用 pt-osc;大表高并发、想精细控延迟、怕触发器副作用的,优先 gh-ost。

五、上线前的三个必查坑

5.1 磁盘空间要翻倍

无论哪款,都会建一张等量大小的影子表,磁盘剩余必须 > 单表体积。空间不足会在拷贝中途失败,反而留下半成品表。

5.2 外键与唯一约束

pt-osc 默认不支持带外键的表(触发器会牵连子表),需用 –alter-foreign-keys-method 显式处理;gh-ost 对外键同样敏感。改表前先用 MySQL 索引底层原理:B+树、覆盖索引与最左前缀 的索引视角确认约束影响面。

5.3 低峰期 + 从库延迟监控

即便在线改表,拷贝过程仍会消耗 IO 和主从带宽。务必放在业务低峰,并盯住从库延迟——PostgreSQL 侧同理,可参考 PostgreSQL 慢查询优化:执行计划解读与索引调优实战 的慢查询与执行计划手段确认不会拖累读库。

5.4 进度与心跳监控

改表动辄几十分钟,必须能看清进度,绝不能无人盯屏裸跑。pt-osc 可定期 SHOW PROCESSLIST 看拷贝线程;gh-ost 会在 ghost 表的 _ghc 状态表里实时写入「已拷贝行数 / 剩余 / 当前延迟」,一条 SQL 就能盯:

-- gh-ost 实时进度(从 _ghc 状态表读取)
SELECT hint, total_rows, copied_rows, last_throttled_reason
  FROM `_orders_ghc` WHERE hint <> 'All caught up'\G
-- pt-osc 看拷贝线程
SHOW PROCESSLIST;  -- 找 State 含 'copying to tmp table' 的线程

给拷贝任务设好超时与告警,一旦卡住(比如从库延迟一直降不下来)能第一时间发现并暂停,而不是等凌晨被电话叫醒。

六、它和 Flyway 不是替代,是分工

容易混淆的一点:Flyway / Liquibase 管的是「结构变更的版本化与可追溯」(哪些环境执行过、版本到哪了),而 pt-osc / gh-ost 管的是「大表变更怎么安全地执行」。小表直接走 Flyway 的 ALTER 即可;唯独上千万行的大表,才需要把 Flyway 生成的迁移语句,用在线改表工具实际落地。两者配合:Flyway 记账,gh-ost 干活。

小结

大表 DDL 千万别裸跑 ALTER TABLE——锁表、主从延迟、undo 膨胀随便一个都能让线上事故。pt-online-schema-change 用触发器兜住增量、简单稳;gh-ost 用 binlog 取代触发器、对主库更友好、控延迟更精细。上线前盯死磁盘空间、外键约束、低峰期与从库延迟三件事,再把在线改表和 数据库迁移实战:Flyway 与 Liquibase 版本化管理 的版本化迁移组合起来,大表结构演进就既安全又可追溯了。

上一篇 Nginx 502/504 排查实录:从超时到连接池
下一篇 Terraform 实战:用代码管理云基础设施