如何设计数据库迁移与在线演进
不要把迁移当跑脚本;先解释“分类字段改成 category_id,为什么不能直接上线”
这一页只解决一个问题: 业务数据库已经上线以后,表结构和数据模型怎样继续变化而不打断旧应用、不写坏历史数据、不让新旧读写分叉。Schema 评审、在线 DDL、索引回填和锁风险都收敛到这条迁移主线里;迁移不是一次 SQL,而是一段可验证的过渡期。
Web 记账系统最初在账目表里存 category 文本。后来要支持分类改名、预算科目和多级分类,于是准备迁移成 categories 表 + category_id。如果直接改字段、跑脚本、上线新代码,旧应用、历史数据和报表会一起出问题。
| 失败 | 为什么严重 | 逼出的设计 |
旧应用还读 category | 应用版本不可能瞬间全部升级 | 新旧结构要共存 |
| 回填脚本跑到一半失败 | 一半数据有 category_id,一半没有 | 回填要分批、幂等、可续跑 |
| 新旧字段同时存在但不一致 | 事实源不清楚,报表口径分叉 | 双写和差异校验 |
| 代码回滚后数据库回不去 | 数据状态已经改变 | 按阶段定义回滚边界 |
| 预审问题 | 最低通过标准 | 迁移里防什么 |
| 每张表保存什么事实 | 核心表能说清记录什么、不记录什么 | 改表时把事实、文案、快照混在一起 |
| 数据归属边界是谁 | owner、actor、tenant 分开建模 | 回填或切读后归属口径漂移 |
| 哪些不变量必须兜底 | 唯一性、金额、状态、归属写清由谁保证 | 新旧字段共存时制造坏数据 |
| 真实查询样本是什么 | 首页、月报、流水、导出都有对应索引和样本 | 结构改完以后核心查询变慢或查错 |
| 坏数据怎么修 | 能圈定对象、找到旧值、dry-run、小批次修复和复核 | 迁移失败后只能靠人工猜 |
判断: Schema 评审不是字段清单,而是提前问“哪里会改不动、查不动、修不动”。这些问题答不清,就不要进入迁移执行。
| 风险 | 上线前要证明 | 停止条件 |
| 元数据锁和长事务 | 执行前能发现长事务、锁等待和危险会话 | 发现等待链或写入排队立即暂停 |
| 大表扫描和日志放大 | 估算行数、索引大小、WAL / binlog、磁盘和复制延迟 | 复制延迟、磁盘水位或 IO 超阈值就限速 |
| 索引收益不清楚 | 每个索引都有 SQL fingerprint、读收益和写成本 | 没有明确服务的 SQL 就不加 |
| 约束过早变硬 | nullable 字段、新写入、历史回填、缺失率巡检、硬约束分阶段完成 | 缺失率没有归零前不加 NOT NULL / UNIQUE / FK |
| 回填不可重入 | 批次、进度、失败样本、幂等键和续跑策略都可见 | 不能暂停和续跑的脚本不上生产 |
吸收点: DDL 不是普通发布命令。它要和迁移计划绑定,留下目标、风险、前置检查、停止条件、owner 和验收样本。
| 问题 | 例子 |
| 旧结构还能保留多久 | entries.category 暂时不删,给旧代码留兼容窗口 |
| 新结构怎样先长出来 | 先建 categories,再给 entries 加 nullable category_id |
| 新旧写入怎样保持一致 | 新账目同时写 category 和 category_id,失败可补偿 |
| 历史数据怎样补齐 | 按账本或主键分批回填,记录进度和失败样本 |
| 切读前怎样证明等价 | 影子读比较旧字段和新 join 结果、月报分类金额 |
| 旧结构什么时候退场 | 确认没有应用、报表、脚本再读旧字段后再 Contract |
migration_batches
- migration_id
- batch_key
- range_start
- range_end
- status: pending | running | done | failed
- scanned_count
- migrated_count
- failed_count
- started_at
- finished_at
migration_diffs
- migration_id
- entry_id
- old_category
- new_category_id
- diff_reason
- status
吸收点: 回填不是一次性脚本,而是临时数据系统。它要有批次、进度、失败样本、差异和停止能力。
| 阶段 | 做什么 | 不要做什么 |
| Expand | 新增新表 / 新字段,让新旧结构共存 | 不要立刻删旧字段或强制 NOT NULL |
| Migrate | 双写新旧结构,分批回填历史数据 | 不要用不可重入的一次性 UPDATE |
| Switch | 影子校验通过后灰度切到新读路径 | 不要没对账就一刀切 |
| Contract | 确认旧依赖归零后删除旧字段和旧逻辑 | 不要让旧结构永久残留 |
关键边界: 应用回滚不等于数据回滚。每个阶段都要写清还能退回哪里,哪里已经不可逆。
| 样本 | 通过标准 |
| 旧应用仍在运行 | 新字段上线后旧读写不报错 |
| 新增账目 | category 和 category_id 同时正确,失败能补偿 |
| 回填中断 | 重启后从批次进度继续,重复执行不污染数据 |
| 历史脏分类 | 无法映射的 entry 进入差异表,不硬猜 |
| 影子读对比 | 旧读和新读在账目列表、月报分类金额上对得上 |
| 旧字段退场 | 真实读取和写入依赖归零后再删除 |
总结: 数据库迁移的核心不是“怎么跑脚本”,而是先制造兼容窗口,再让新旧写入对齐,历史数据分批补齐,切读前影子校验,最后旧结构退场。只要 Expand、Migrate、Switch、Contract 任何一步说不清,就不要把迁移当完成。