如何安全修改生产数据库表结构?从兼容窗口到 Expand-Migrate-Contract
生产数据库变更最危险的地方,往往不是 SQL 写错,而是把“改表”和“发版”误认为同一个原子操作。现实中,旧实例、新实例、异步消费者、离线任务和临时脚本可能同时访问一张表;DDL 还可能等待元数据锁、触发表重建或放大复制延迟。即使应用已经回滚,已经删除的列和已经改写的数据也不会自动回来。
安全变更的核心不是寻找一条万能的 ALTER TABLE,而是设计一个足够长的兼容窗口:在窗口内,新旧版本都能读写,数据能被验证,任何一步失败都能停止或向前修复。
一、先区分四类风险
一次表结构变更至少同时包含四个维度:
- 结构兼容性:旧代码是否认识新结构,新代码能否容忍旧结构;
- 执行影响:DDL 会不会锁表、复制整表、耗尽 I/O 或阻塞事务;
- 数据语义:历史数据如何补齐,双写期间两个字段是否一致;
- 回退能力:失败后是回滚应用、停止迁移,还是从备份恢复数据。
下面的变更看起来都很普通,但风险完全不同:
| 变更 | 主要风险 | 推荐策略 |
|---|---|---|
| 新增可空列 | DDL 算法、旧 ORM 的 SELECT * | 先扩展结构,再发布读写代码 |
| 新增非空列 | 历史行无值、长事务回填 | 可空列 → 分批回填 → 校验 → 加约束 |
| 重命名列 | 新旧代码不能同时工作 | 新列 + 双写/回填 + 切读 + 删除旧列 |
| 删除列 | 旧实例和离线任务仍在读取 | 先停止所有读写,观察一个完整周期后再删 |
| 改字段类型 | 截断、溢出、表重建 | 新列迁移并校验,必要时在线变更工具 |
| 新建大索引 | I/O、redo、复制延迟、锁等待 | 低峰限速创建,持续观察数据库和副本 |
“MySQL 支持 Online DDL”不等于“业务无感”。INSTANT、INPLACE、COPY 的可用性取决于 MySQL 版本、存储引擎和具体变更;即使不长时间锁住 DML,开始和结束阶段仍可能获取 metadata lock。上线前必须在同版本、同量级数据上验证,而不是凭 SQL 语法猜测。
二、Expand-Migrate-Contract:把破坏性变更拆开
零停机变更通常分为三个阶段:
Expand(扩展) -> Migrate(迁移) -> Contract(收缩)
新增兼容结构 回填并切换流量 删除旧结构
每个箭头都不是一瞬间,而是一个可观测、可暂停的发布阶段。
假设需要把 users.nickname 重命名为 display_name。直接执行 RENAME COLUMN 会让旧代码立刻报错,更安全的步骤如下。
1. Expand:只增加,不破坏
ALTER TABLE users
ADD COLUMN display_name VARCHAR(128) NULL,
ALGORITHM=INSTANT;
先确认线上版本确实支持该算法;不支持时应让变更失败,而不是静默退化成昂贵的表复制。随后发布兼容版本:写入时优先维护两个字段,读取仍以旧字段为主,并记录不一致指标。
@Transactional
public void updateName(long userId, String value) {
userRepository.updateBothNames(userId, value, value);
}
数据库内双写只能保证同一事务内的两个字段一起提交,但无法自动覆盖绕过该服务的旧脚本和其他写入方。应用双写也不是最终状态,它只用于跨越迁移窗口。上线前应列出所有写入者,而不是只检查主应用仓库。
2. Migrate:限速回填与校验
不要用一条无边界的 UPDATE users SET display_name = nickname 扫描数亿行。它会制造大事务、长时间持锁、膨胀 undo/redo,并拖慢复制。以稳定主键游标分批处理:
UPDATE users
SET display_name = nickname
WHERE id > :last_id
AND id <= :end_id
AND display_name IS NULL;
每批提交后记录游标,依据主库延迟、磁盘吞吐和副本延迟动态调节批量与间隔。条件 display_name IS NULL 让任务可重入,也避免覆盖双写产生的新值。不要使用越来越慢的 LIMIT ... OFFSET ... 遍历大表。
回填完成不代表迁移正确,至少需要三层校验:
-- 完整性:还有多少未迁移
SELECT COUNT(*) FROM users WHERE display_name IS NULL;
-- 一致性:两个字段是否出现差异
SELECT COUNT(*)
FROM users
WHERE NOT (display_name <=> nickname);
-- 分桶校验:避免全表查询长期压垮主库
SELECT MOD(id, 100) AS bucket,
COUNT(*) AS total,
SUM(NOT (display_name <=> nickname)) AS mismatch
FROM users
WHERE MOD(id, 100) = :bucket
GROUP BY bucket;
校验任务应在只读副本或受控批次上运行,并考虑副本延迟。金额、状态等关键字段还应做业务不变量校验,而不是只比行数。
3. 切读:让回滚仍然有路
先灰度让少量实例读取 display_name,但继续双写。观察空值率、差异率、错误率和关键业务指标;再逐步扩大比例。若新读路径异常,只需切回旧字段,结构和旧数据仍然存在。
String visibleName(UserRow row, boolean readNewColumn) {
if (readNewColumn && row.getDisplayName() != null) {
return row.getDisplayName();
}
return row.getNickname();
}
兼容读中的 fallback 只能是过渡措施。若永久保留,会掩盖漏迁移并让旧字段永远无法退役,因此要为 fallback 次数建立指标和清零目标。
4. Contract:最后才删除
依次完成:全部读新列、停止写旧列、确认所有旧实例和任务下线、跨过至少一个完整业务与离线作业周期、保留可恢复备份。最后才执行:
ALTER TABLE users DROP COLUMN nickname;
删除列通常不应和应用发版放在同一个变更单元。它是独立、延后的清理动作。所谓数据库回滚,很多时候应理解为应用向后兼容并向前修复,而不是删除新列、恢复旧表。
三、新增 NOT NULL 字段的正确顺序
为订单增加 source,直接使用带默认值的非空列,可能把“未知历史来源”错误表达成某个真实来源。更稳妥的做法是:
-- 1. 扩展结构
ALTER TABLE orders ADD COLUMN source VARCHAR(32) NULL;
-- 2. 新代码始终写入 source
-- 3. 分批为历史订单回填明确的语义,例如 legacy
-- 4. 验证
SELECT COUNT(*) FROM orders WHERE source IS NULL;
-- 5. 最后增加约束
ALTER TABLE orders MODIFY source VARCHAR(32) NOT NULL;
如果 MySQL 版本或表规模使最后一步需要重建表,应使用影子表方案或在维护窗口执行。约束是数据质量的最后防线,但不应早于数据准备。
四、索引变更也要按生产任务管理
新增索引前,先证明它服务于真实查询,并检查列顺序、选择性、排序与覆盖关系:
EXPLAIN ANALYZE
SELECT id, created_at
FROM orders
WHERE tenant_id = 42 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 50;
候选索引可能是:
CREATE INDEX idx_orders_tenant_status_created
ON orders(tenant_id, status, created_at DESC);
创建期间需要观察:metadata lock 等待、活跃事务、buffer pool、磁盘利用率、redo 速率、主从延迟和业务 P99。对超大表,可在验证约束后选择 gh-ost、pt-online-schema-change 等工具;它们通过影子表和增量同步降低阻塞,但会增加 I/O、binlog、触发器或切表复杂度,并非“无成本在线”。
| 方案 | 适用场景 | 关键代价 |
|---|---|---|
原生 INSTANT | 版本明确支持的元数据级变更 | 仍有短暂 MDL,功能范围有限 |
原生 INPLACE | 中等规模、可接受后台扫描 | I/O 与复制延迟仍可能明显 |
| 影子表工具 | 大表重建、要求 DML 持续可用 | 双倍空间、增量同步、切换风险 |
| 维护窗口 | 高风险变更、业务可暂停 | 明确停机,但过程更可控 |
| 新表迁移 | 大幅改变模型或分区方式 | 应用双写、数据核对和切流复杂 |
工具选择应由表大小、写入速率、可用窗口、外键、复制拓扑和可用磁盘共同决定。
五、发布编排与回退点
一个可审计的变更计划应明确每一步的准入条件与回退动作:
| 阶段 | 准入条件 | 失败时动作 |
|---|---|---|
| 新增结构 | DDL 演练通过、空间充足、无长事务 | 取消 DDL 或停止后续发布 |
| 兼容版本 | 旧结构仍可用、指标已部署 | 回滚应用版本 |
| 数据回填 | 双写稳定、任务可断点续跑 | 暂停任务,不反向覆盖新数据 |
| 灰度切读 | 未迁移量和差异率达标 | 将读开关切回旧字段 |
| 停写旧列 | 所有写入者已升级 | 恢复双写 |
| 删除旧列 | 观察期结束、备份可恢复 | 原则上不即时执行,需恢复或向前修复 |
DDL 执行器还应设置合理的 lock_wait_timeout,遇到长事务时快速失败,避免变更语句排队后反过来堵住大量业务请求。执行前检查未提交事务和元数据锁,禁止“不断重试直到成功”。
六、常见失败模式
把 ORM 自动建表带进生产
开发环境中的 ddl-auto=update 无法表达灰度顺序、限速回填和人工审批。生产应使用版本化迁移文件,并保证同一版本只执行一次、执行结果可审计。
新代码与 DDL 同时上线
滚动发布期间总会有新旧实例共存。新代码若立即要求新列非空,旧实例又不会写该列,就会产生隐蔽脏数据。
用“事务”幻想 DDL 可整体回滚
不同数据库对 DDL 事务的支持不同;即使结构可回滚,长时间持锁也不可接受。数据回填更不能假定一个跨数小时的大事务是安全的。
只校验总行数
行数相同不能证明字段语义、关联关系和聚合结果正确。迁移后需要抽样、校验和、业务不变量及线上对账共同验证。
太早清理旧结构
清理带来的存储收益通常远小于失去回退路径的风险。尤其要覆盖月末、日终、账单等低频作业周期。
七、生产变更检查清单
- 是否列出了所有在线服务、消费者、脚本和离线任务的读写关系?
- 新旧应用版本能否在整个滚动发布窗口内同时工作?
- DDL 是否在同版本、近似数据量和写入速率下演练过?
- 是否明确
INSTANT、INPLACE或表复制算法,且避免静默降级? - 回填是否按主键分批、可限速、可暂停、可重入并记录游标?
- 是否监控 metadata lock、长事务、磁盘、redo、复制延迟和业务 P99?
- 是否同时做完整性、一致性和业务不变量校验?
- 每个阶段是否有明确准入指标、负责人和停止条件?
- 回退是切换读路径、回滚应用还是从备份恢复,是否真正演练过?
- 删除旧列前是否确认所有实例与低频任务已越过兼容周期?
总结
安全的数据库变更是一条状态机,而不是一条 SQL。先扩展兼容结构,再迁移和验证数据,最后延迟收缩;让旧版本、新版本和迁移任务在每个阶段都有定义清楚的行为。真正的零停机,不是 DDL 从不加锁,而是任何异常都能在影响用户之前被发现、暂停,并保留可靠的恢复路径。