一条 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. 自测
Waiting for table metadata lock堆积时,为什么不能先 kill DDL?- 显式写
ALGORITHM=INSTANT在生产上的价值是什么? - 加列与改列类型的成本差在哪一层?
- gh-ost 的
--max-lag-millis与--postpone-cut-over-flag-file各自防的是什么? - "业务低峰做 DDL"为什么会失效?该看什么指标?
- 字段类型变更的哪一步不可逆,前置条件是什么?