2920 字
约 9 分钟
13
S11-A08 · 库表演进:在线 DDL、字段变更与数据迁移
2024-12-31
2026-11-09

一条 ALTER TABLE 把表锁了半小时,复盘只留下一句"下次挑业务低峰"。但低峰只降撞车概率,不改锁的机制。决定影响面的是:这次 DDL 走哪条执行路径、要拿哪种元数据锁、多久能完成。

库表演进是少见的"设计时看不见、运行时才付账"的工作。判断标准不是能不能改,而是锁窗口多长、回滚点在哪、复制链路还剩多少余量。


1. 先定位锁在哪一层

"锁了半小时"可能是三个位置,处置完全不同。

锁所在层 表现 成因 处置
元数据锁(MDL) 新请求全部卡住,老请求正常 DDL 等 MDL 写锁,被长事务挡住 找长事务,而不是等 DDL 结束
表拷贝期 整表不可写,持续到拷贝完成 ALGORITHM=COPY 重建整表 换 instant / in-place,或用在线工具
行锁与间隙锁 部分行阻塞,写入报锁等待 DML 与索引维护冲突 拆批次、缩事务

关键点:MDL 等待会插队。DDL 一旦在等写锁,其后所有读锁都排在它后面,形成"DDL 等长事务、业务等 DDL"。表现是连接数暴涨、接口超时,而 PROCESSLIST 里只有一排 Waiting for table metadata lock。

2. 三种执行方式与各自的锁级别

MySQL 8.0 的 DDL 有三条路径,代价差一个量级:

方式 是否重建表 并发 DML 磁盘开销 适用
COPY 是 全程阻塞 约等于表大小 无法 in-place 的变更
IN-PLACE 是(重建或原地) 多数支持 LOCK=NONE 需要额外空间 加索引、改索引、部分列变更
INSTANT 否,只改元数据 支持 近零 加列(末尾)、改默认值、重命名列

显式声明算法把"以为很快"变成"确认很快":不支持的变更直接报错,不会悄悄降级成全表拷贝。

-- 8.0:末尾加列走 instant,只改数据字典
ALTER TABLE t_order ADD COLUMN note VARCHAR(64) NULL,
  ALGORITHM=INSTANT, LOCK=NONE;

-- 不支持的变更会报 ER_ALGORITHM_NOT_SUPPORTED,而不是默默拷表
ALTER TABLE t_order MODIFY COLUMN amount BIGINT NOT NULL,
  ALGORITHM=INSTANT;

LOCK=NONE 声明允许并发读写,且失败时不会自动降级——这正是生产想要的行为:要么快,要么报错,不要偷偷跑四小时。

3. 哪些变更拿不到 instant

8.0 的 instant 有明确边界,且不同小版本行为有差异,按目标版本核对。

变更 8.0 行为 备注
末尾加列 instant 元数据里记录默认值,不重写行
加列到中间位置 需重建(部分版本支持 instant) 位置影响行格式
改列类型 不支持 instant 需重建或应用层迁移
改字符集 / 排序规则 不支持 instant 索引也要重建
加索引 in-place,不阻塞 DML 大表耗时长,占 IO
删除列 instant(8.0.29 起部分场景) 早期版本需重建

推论:加字段比改字段便宜一个量级。设计阶段留冗余列,比上线后改类型划算——这是唯一真正前置的决策。

4. 在线 DDL 工具的取舍

变更必须重建表、又不能接受锁窗口时,才轮到 gh-ost / pt-osc:把一张表的 DDL 换成影子表 + 增量同步 + 切换。

维度 gh-ost pt-osc
增量捕获 读 binlog(伪装从库) 表上建触发器
对主库写入影响 小,无触发器 每次 DML 多一次触发器开销
前置条件 binlog 为 ROW、有复制权限 需要触发器权限
磁盘开销 影子表 + binlog 解析 影子表 + 触发器表
切换方式 原子 rename,可暂停 rename,需短暂锁
主从延迟风险 回放压力集中在从库 同上
gh-ost --host=HOST --database=DB --table=t_order \
  --alter="MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0" \
  --allow-on-master --max-load='Threads_running=30' \
  --critical-load='Threads_running=80' \
  --chunk-size=1000 --max-lag-millis=1500 \
  --postpone-cut-over-flag-file=/tmp/gh-ost.postpone \
  --execute

三个参数决定它安不安全:--max-lag-millis 是刹车(延迟超标暂停拷贝),--max-load 是节流阈值,--postpone-cut-over-flag-file 把切换时机交回给人。缺了它们,风险只是从"锁表"换成"延迟打满"。

5. 真正的判据是复制延迟余量

"业务低峰"是时间维度的经验,复制延迟是容量的硬约束。大表 DDL 的回放压力落在从库,从库扛不住时读流量先坏。

-- 变更期间持续采样,别只看一眼
SHOW REPLICA STATUS\G   -- 关注 Seconds_Behind_Source、Relay_Log_Space
SELECT MAX(TIMESTAMPDIFF(SECOND, ts, NOW())) FROM monitor.heartbeat;
判据 取值方式 达标线
从库延迟 心跳表差值,非 Seconds_Behind_Source 单值 留出至少 2–3 倍冗余
主库写入压力 Threads_running 分位 变更期不超过日常峰值
复制线程余量 单线程回放 vs 主库并发 单线程回放是常见瓶颈
磁盘余量 影子表 + binlog 增长 至少留出一张表大小

"低峰做"常失效,因为低峰时从库在跑备份或批量分析,余量比高峰更小。看延迟曲线,不看时钟。

6. 字段类型变更的三步走

改类型不能一步到位,因为它同时影响读写两端。可回退的路径是四步:

阶段 动作 回滚点
1 加新列 ADD COLUMN amount_new DECIMAL(18,2),instant 删列即可,无副作用
2 双写 应用同时写新旧列,读仍旧列 关双写开关
3 回填 分批把旧列刷到新列,控节奏 停止回填,旧列仍可读
4 切读 灰度读新列,比对差异 切回旧列读
5 删旧列 观察一个发布周期后删 仅能靠备份恢复
-- 回填:按主键区间推进,避免长事务与大范围锁
UPDATE t_order SET amount_new = amount
WHERE id > :last_id AND id <= :last_id + :batch
ORDER BY id LIMIT :batch;

第 5 步不可逆,前提是"已有备份 + 代码里无引用"。提前删列是最常见的翻车点:灰度未完成,旧实例还在读旧列。

7. 大表回填的节奏控制

回填的敌人不是总量,而是长事务与复制延迟。旋钮只有三个:批大小、批间隔、延迟超标就停。

while true; do
  mysql -e "UPDATE ... WHERE id > $last AND id <= $((last+1000)) ..."
  last=$((last+1000))
  lag=$(mysql -N -e "SELECT MAX(delay) FROM monitor.heartbeat")
  [ "$lag" -gt 3 ] && sleep 10 || sleep 0.2
done
旋钮 典型起点 调大的后果
批大小 500–2000 行 单事务变长,行锁与 undo 增长
批间隔 0.1–0.5 s 总时长成倍拉长
延迟阈值 2–5 s 阈值太松,从库积压到不可用

批大小上限由单行大小决定,不是行数:一行 2 KB 的表,1000 行就是 2 MB redo。

8. 字符集、分区与归档的取舍

变更 成本 何时值得
utf8mb3 → utf8mb4 重建表 + 索引,锁窗口长 需要 emoji 或生僻字,且业务无法在应用层规避
改分区键 重建 + 数据重分布 单表已过亿且清理靠 DELETE
历史表归档 导出 + 删除,可分批 冷数据占比高、查询几乎不触达

分区的真实收益是删分区代替 DELETE,不是查询加速。清理靠 DELETE 每次几十万行时才值得;若只是"表太大看着难受",路由复杂度不划算。

9. MDL 与变更窗口

DDL 前必须先确认没有长事务。8.0 可以直接查锁等待链:

SELECT t.PROCESSLIST_ID, t.PROCESSLIST_INFO, l.OBJECT_NAME, l.LOCK_TYPE
FROM performance_schema.metadata_locks l
JOIN performance_schema.threads t ON l.OWNER_THREAD_ID = t.THREAD_ID
WHERE l.OBJECT_SCHEMA = 'DB' AND l.OBJECT_NAME = 't_order';

变更窗口与发布顺序只有一条硬规则:向后兼容的变更先 DDL 再发代码;删列删索引则相反,先停引用再删。搞反就是"读不存在的列"或"写非空列失败"。

10. 验证:三类证据

验证项 方法 判据
行数 COUNT(*) 与源表对比 完全一致(大表可比对分区之和)
内容 CHECKSUM TABLE 或分段 SUM/CRC32 分段比对,避免全表扫描
抽样 按主键随机取 N 行逐列对比 覆盖 NULL、边界值、新增列

CHECKSUM TABLE 会全表扫描加共享锁,大表慎用,分段校验更实用。验证不是为了证明没丢数据,而是在可接受成本内证明没有系统性偏差。

11. 排错:五种卡住的情况

现象 原因 处理
DDL 一直 Waiting for table metadata lock 有长事务或未提交的 SELECT 查 information_schema.innodb_trx,kill 长事务而非 kill DDL
ALGORITHM=INSTANT 报不支持 变更不在 instant 白名单 换 in-place 或在线工具,别去掉声明硬跑
工具跑完但数据不一致 切换前增量未追平 用 --postpone-cut-over 人工确认追平再切
从库延迟持续上升 单线程回放 + 大事务 减小批大小,提高批间隔,必要时暂停
第二次加列变慢 instant 行数上限或位置变更 核对版本行为,改到末尾加列

第一条最容易误操作:kill DDL 不解决长事务,还会让排队事务一次性涌入。顺序应是先结束长事务,再让 DDL 拿 MDL。

12. 边界:什么情况不必上工具

场景 判断 理由
小表(百万行以内) 直接 ALTER 锁窗口在秒级,工具引入的风险更大
有可停机窗口 停机窗口内直接改 工具的价值是"不停机",停机时它是负债
历史归档表 不改 只读冷数据,结构随归档格式走
只加末尾列 instant 即可 不需要影子表
无法接受主从延迟抖动 先扩容从库或改为离线变更 工具不能凭空造出复制余量
变更需要应用层语义配合 走三步走,工具只是其中一环 结构变了语义没变才是完整的变更

最后一条是分界线:DDL 只是让结构跟上语义的手段之一。语义本身在变(精度、时区、单位)时,变更必须与双写、回填、灰度一起设计。


13. 自测

  1. Waiting for table metadata lock 堆积时,为什么不能先 kill DDL?
  2. 显式写 ALGORITHM=INSTANT 在生产上的价值是什么?
  3. 加列与改列类型的成本差在哪一层?
  4. gh-ost 的 --max-lag-millis 与 --postpone-cut-over-flag-file 各自防的是什么?
  5. "业务低峰做 DDL"为什么会失效?该看什么指标?
  6. 字段类型变更的哪一步不可逆,前置条件是什么?
S11-A08 · 库表演进:在线 DDL、字段变更与数据迁移
https://www.clxhxhhr.top/posts/6129/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。