跳到主内容

大表加字段不加锁:Online DDL 锁表现实测与 gh-ost 无锁变更实录 ​

给一张两亿行的用户表加一个备注字段,测试环境跑了四十分钟,业务同学在群里问「今晚能不能直接上生产」。这个问题的答案取决于三件事:这条 DDL 走哪种算法、执行前有没有清场长事务、变更窗口流量水位如何。MySQL 8.0 的 Online DDL 已经能覆盖大多数日常变更,但「Online」不等于「无风险」——真正让全站雪崩的往往不是 DDL 本身,而是它排队时引发的元数据锁连锁反应。这篇实录从锁表现讲起,给出一套从预检到执行的完整操作手册。

先认清三张牌:INSTANT、INPLACE、COPY ​

MySQL 8.0 的 DDL 引擎支持三种算法,锁表现天差地别:

操作8.0 默认算法并发 DML说明
末尾加列INSTANT支持8.0.12 起,仅改元数据,秒级完成
加/删二级索引INPLACE支持需构建索引树,开始与结束有短暂锁窗口
修改列数据类型COPY不支持写重建全表,全程阻塞写入,务必谨慎
删除列INPLACE支持实质是重建表,耗时与表大小成正比

实操上有一条铁律:显式指定 ALGORITHM,让不支持的 DDL 快速失败,而不是悄悄退化为全表重建:

sql
-- 显式声明算法:若 INSTANT 不被支持会直接报错,而不是悄悄退化成重建全表
ALTER TABLE user_info
  ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
  ALGORITHM = INSTANT;

-- 加索引走 INPLACE:构建期间允许并发读写,LOCK=NONE 表示全程不锁写
ALTER TABLE user_info
  ADD INDEX idx_created (created_at),
  ALGORITHM = INPLACE, LOCK = NONE;
sql
-- 加列 INSTANT 有数量上限:一张表累计最多 64 个 instant 列
-- 高频加列的表要规划周期性重建,把 instant 列「固化」进表结构
SELECT NAME, COUNT_INSTANT_ADDED_COLUMNS
FROM information_schema.innodb_tables
WHERE NAME LIKE 'shop/user_info%';

在动手之前还要做一次容量评估。INPLACE 重建类操作(加索引、删列)虽然不阻塞读写,但耗时与表体量成正比:重建要扫全表、构建新的数据结构、写约等于原表体积的临时空间,执行期间业务写入还要持续缓存在 online 日志里。判断标准可以简化为:预演耗时在十分钟以内的变更,低峰期原生 Online DDL 直接上;超过十分钟或磁盘水位高于七成的,直接走 gh-ost,不要赌运气。

真正的杀手是 MDL:一条长事务可以拖垮全站 ​

Online DDL 无论多轻量,开始与结束时都需要获取元数据锁(MDL)的排他锁。问题在于:任何未提交事务都持有表上的 MDL 共享锁,DDL 必须等它们全部结束;而 DDL 自己排在队首后,后续所有针对这张表的查询都要排在 DDL 后面。于是一条运行了半小时的报表事务,就能让这张表的全部读写雪崩。

所以 DDL 前的清场比 DDL 本身更重要:

sql
-- 预检 1:找出运行中的长事务,DDL 开始前必须清场
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;

-- 预检 2:把 MDL 等待从「默认一年」压缩到 5 秒,拿不到锁快速失败而不是全站排队
SET SESSION lock_wait_timeout = 5;

lock_wait_timeout 的默认值是 31536000 秒(一年),等价于无限等待——这正是「一条 DDL 挂住全站」的技术根源。把它压到秒级,最坏情况只是 DDL 失败重试,而不是业务查询连环超时。变更执行期间,再用 performance_schema 盯住 MDL 等待:

sql
-- 盯住元数据锁等待:发现 DDL 被长事务卡住可以立即决策终止
SELECT OBJECT_NAME, LOCK_TYPE, LOCK_DURATION, LOCK_STATUS
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = 'shop';

还有一个容易被忽视的细节:MDL 排队雪崩不只发生在重型变更里,INSTANT 这类秒级变更同样要走这个流程。判断要不要给业务方发变更公告,看的不该是 DDL 类型,而是这张表的实时流量与长事务风险——任何一条需要排队等待的 DDL,都值得提前广播并约定重试策略。

亿级大表实操:gh-ost 无锁改表 ​

当表达到亿级、或 INPLACE 重建预计超过十分钟时,原生 Online DDL 的构建耗时与主从延迟压力就难以接受了。这时应切换到 gh-ost:它创建一张影子表,在影子表上完成结构变更与数据拷贝,同时拉取源表的 binlog 增量回放,最后通过原子 cut-over 完成表名切换。全程不使用触发器(对比 pt-online-schema-change 的触发器方案),对源库的侵入小得多。前提条件有两个:binlog 必须是 ROW 格式,且目标表不能有外键。

bash
# gh-ost 在主库直连执行加列变更:限流与延迟保护都已声明
# 密码建议通过环境变量注入,不要写进脚本与命令历史
gh-ost \
  --host=127.0.0.1 --port=3306 --user=gh_user --password="$GH_OST_PWD" \
  --database=shop --table=user_info \
  --alter="ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT ''" \
  --max-load=Threads_running=50 \
  --max-lag-millis=1500 \
  --chunk-size=1000 \
  --initially-drop-ghost-table \
  --allow-on-master \
  --execute

三个参数就是 gh-ost 的「油门与刹车」:--max-load 在数据库负载超标时自动暂停拷贝,--max-lag-millis 在下游复制延迟超阈值时自动节流,--chunk-size 控制每批拷贝行数。变更过程可随时暂停、可先在影子表上验证数据正确性,整个流程对业务的最大冲击被压缩到最后一步。

cut-over 是全流程最敏感的阶段:gh-ost 通过原子改名把影子表与原表身份互换,期间需要短暂锁住两张表并让在途语句稍作等待。窗口只有秒级,但业务侧仍要有幂等与重试兜底,重要变更前建议先在影子库完整演练一遍,把节流参数按真实负载校准。

避坑总结 ​

  1. DDL 语句必须显式写 ALGORITHM 与 LOCK。 不写时 MySQL 会自动选择算法,一次看似平常的改列类型可能已经退化为 COPY 全程锁写;显式声明让不支持的变更当场报错,把风险拦在测试环境。
  2. 清场长事务比选低峰期更重要。 低峰期也可能藏着漏提交的事务与未关闭的报表连接;DDL 前查 information_schema.innodb_trx 清场,并把 lock_wait_timeout 压到秒级,是防止查询雪崩的两道保险。
  3. INPLACE 重建不省磁盘也不省 IO。 重建需要约等于原表的临时空间,执行期间持续写入的 DML 会缓存在 online alter 日志里(受 innodb_online_alter_log_max_size 控制,默认 128MB),高并发写入下缓冲溢出会导致 DDL 失败回滚,变更前要评估空间与写入压力。
  4. INSTANT 加列有 64 列上限。 敏捷迭代里高频加列的表要规划周期性重建固化 instant 列,别等报错才想起。
  5. gh-ost 的前提与切换窗口要提前演练。 外键表、非 ROW binlog 都会直接被拒;cut-over 瞬间存在秒级的连接与语句重试,业务侧要有幂等与重试兜底,重要变更前先在影子库完整走一遍流程。

写在最后 ​

大表变更的功夫在 DDL 之外:认清三种算法的锁表现,是判断「能不能直接跑」的依据;清场长事务与收紧锁超时,是保证「跑了不雪崩」的底线;gh-ost 则是亿级表「边跑边服务」的工程答案。把预检清单固化成变更模板,加字段这类高频操作就不再是悬在头顶的事故隐患。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。