数据库 Schema Migration 与发布兼容性
解释数据库 schema 变更为何不能等同于应用二进制回滚,并用 expand-and-contract、回填、双写和兼容窗口组织安全发布。
[!info] related notes
- 迁移工具:数据库迁移设计(golang-migrate)
- 协议:Schema Versioning
- 发布:CI/CD MOC
数据库 Schema Migration 与发布兼容性
核心结论
应用镜像通常可以切回旧版本,但数据库 migration 可能已经:
- 删除或重命名列;
- 改变类型;
- 重写或合并数据;
- 建立旧代码不理解的新约束;
- 在大表上持有长锁。
因此“应用回滚”不等于“数据库 down migration”。安全发布更依赖新旧应用与新旧 schema 的兼容窗口。
Expand-and-Contract
Expand
先做向后兼容扩展:
- 新增 nullable / 有默认策略的字段;
- 新建表或索引;
- 保留旧字段;
- 新代码同时理解旧数据和新数据。
Migrate
- 后台回填历史数据;
- 双写或由事件/CDC 同步,并定义部分成功、重试、顺序和一致性校验;
- 观察差异、失败和回填进度;
- 把读流量逐步切到新字段。
Contract
确认所有旧版本退出、回填完成、读写已切换后:
- 停止旧字段写入;
- 删除兼容代码;
- 最后删除旧列、表或索引。
Expand schema
→ Deploy compatible code
→ Backfill / dual-write
→ Switch reads
→ Verify
→ Remove old path
→ Contract schema
为什么 Down Migration 不一定安全
DROP COLUMN 的反向 SQL 可以重新建列,却不能恢复原数据;类型压缩、聚合和数据修正也可能不可逆。生产事故时通常优先:
- 停止扩量;
- 切回仍兼容新 schema 的应用版本;
- 禁用新功能或写路径;
- forward-fix;
- 只有确认无数据损失时才执行 down。
常见变更
新增 NOT NULL 字段
安全路径通常不是直接给大表加无默认值 NOT NULL:
- 新增 nullable 字段;
- 新代码开始写;
- 分批回填;
- 校验无 null;
- 再加约束。
具体数据库版本可能支持更高效的 metadata-only 操作,仍应按真实版本和表规模验证。
重命名字段
跨版本发布中可先新增新字段,双写或兼容读取,完成迁移后再删除旧字段。一次 RENAME 可能让旧应用立即失败。
新 enum 值
旧客户端或旧服务可能无法 parse 新值。先让消费者具备所需处理能力,再由生产者发出新值;只有语义允许时才能安全降级。权限、支付和终态等关键 enum 的未知值应明确拒绝或进入待处理状态,不能随意映射为默认成功。
建索引
大表建索引可能影响写入或持锁;使用数据库支持的 online/concurrent 方式时,也要观察资源、失败和 invalid index 状态。
多副本与 Migration Job
不要让每个应用副本在启动时无协调地执行 migration。更可控的是:
CI 验证空库与升级路径
→ 单独 Migration Job
→ 兼容性检查
→ 应用 rollout
→ post-deploy verification
迁移账号和运行时账号应分离权限。
回填任务
回填本身是生产数据任务,需要:
- batch / cursor;
- checkpoint;
- idempotency;
- rate limit;
- retry / dead-letter;
- 进度和差异指标;
- 暂停 / 恢复;
- 数据校验。
不要把几千万行更新塞进一个不可观测事务。
发布门禁
- 在空库跑完整 migration;
- 从最近生产 schema snapshot 跑升级;
- 验证旧应用 + 新 schema;
- 验证新应用 + 旧/过渡 schema(按策略);
- 测量大表 DDL / backfill 时间和锁;
- 准备 feature flag、停止扩量和 restore 方案;
- 记录 schema version 与部署 revision。
面试回答
应用二进制可以回滚,但数据库变更可能已经丢数据或改变约束,所以不能把 down migration 当普通回滚。更稳的是 expand-and-contract:先做向后兼容扩展,部署同时兼容新旧 schema 的代码,回填或双写并切换读路径,确认旧版本退出后才删除旧字段。事故时优先切回兼容应用、关闭新写路径或 forward-fix,而不是无脑逆向 DDL。