跳过导航

如何安全修改生产数据库表结构?从兼容窗口到 Expand-Migrate-Contract

约 11 分钟...次浏览
专栏微服务架构与工程治理第 5 篇

生产数据库变更最危险的地方,往往不是 SQL 写错,而是把“改表”和“发版”误认为同一个原子操作。现实中,旧实例、新实例、异步消费者、离线任务和临时脚本可能同时访问一张表;DDL 还可能等待元数据锁、触发表重建或放大复制延迟。即使应用已经回滚,已经删除的列和已经改写的数据也不会自动回来。

安全变更的核心不是寻找一条万能的 ALTER TABLE,而是设计一个足够长的兼容窗口:在窗口内,新旧版本都能读写,数据能被验证,任何一步失败都能停止或向前修复。

一、先区分四类风险

一次表结构变更至少同时包含四个维度:

  1. 结构兼容性:旧代码是否认识新结构,新代码能否容忍旧结构;
  2. 执行影响:DDL 会不会锁表、复制整表、耗尽 I/O 或阻塞事务;
  3. 数据语义:历史数据如何补齐,双写期间两个字段是否一致;
  4. 回退能力:失败后是回滚应用、停止迁移,还是从备份恢复数据。

下面的变更看起来都很普通,但风险完全不同:

变更主要风险推荐策略
新增可空列DDL 算法、旧 ORM 的 SELECT *先扩展结构,再发布读写代码
新增非空列历史行无值、长事务回填可空列 → 分批回填 → 校验 → 加约束
重命名列新旧代码不能同时工作新列 + 双写/回填 + 切读 + 删除旧列
删除列旧实例和离线任务仍在读取先停止所有读写,观察一个完整周期后再删
改字段类型截断、溢出、表重建新列迁移并校验,必要时在线变更工具
新建大索引I/O、redo、复制延迟、锁等待低峰限速创建,持续观察数据库和副本

“MySQL 支持 Online DDL”不等于“业务无感”。INSTANTINPLACECOPY 的可用性取决于 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-ostpt-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 是否在同版本、近似数据量和写入速率下演练过?
  • 是否明确 INSTANTINPLACE 或表复制算法,且避免静默降级?
  • 回填是否按主键分批、可限速、可暂停、可重入并记录游标?
  • 是否监控 metadata lock、长事务、磁盘、redo、复制延迟和业务 P99?
  • 是否同时做完整性、一致性和业务不变量校验?
  • 每个阶段是否有明确准入指标、负责人和停止条件?
  • 回退是切换读路径、回滚应用还是从备份恢复,是否真正演练过?
  • 删除旧列前是否确认所有实例与低频任务已越过兼容周期?

总结

安全的数据库变更是一条状态机,而不是一条 SQL。先扩展兼容结构,再迁移和验证数据,最后延迟收缩;让旧版本、新版本和迁移任务在每个阶段都有定义清楚的行为。真正的零停机,不是 DDL 从不加锁,而是任何异常都能在影响用户之前被发现、暂停,并保留可靠的恢复路径。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。