问题画像
线上最危险的不是“改表”,而是“业务和改表耦合发布”:应用已读新字段,但 DDL 还没完成;或者 DDL 成功了,回滚却读不懂新结构。
迁移模型:Expand -> Migrate -> Contract
- Expand:只做向后兼容变更(加列、加索引、双写入口)。
- Migrate:后台回填与数据校验,逐步把读流量切向新字段。
- Contract:确认没有旧路径后,再删旧列/旧索引。
Go 侧发布顺序
// 第一步发布:双写 + 旧读优先
func SaveOrder(ctx context.Context, o Order) error {
row := model.OrderRow{
ID: o.ID,
// old_total 保持兼容
OldTotal: o.Total,
// new_total 为新字段
NewTotal: decimalPtr(o.Total),
}
return repo.Upsert(ctx, row)
}
// 第二步发布:读新字段,失败回退旧字段
func LoadOrderTotal(r model.OrderRow) decimal.Decimal {
if r.NewTotal != nil {
return *r.NewTotal
}
return r.OldTotal
}
回填策略
- 按主键区间分页,避免大事务长时间占锁。
- 每批记录校验
checksum,把异常写入死信表。 - 回填作业限速,和在线业务共享数据库 QPS 预算。
观测与止损
- 指标:回填进度、回填错误率、慢 SQL、锁等待时间。
- 开关:双写开关、读路径开关、回填暂停开关。
- 预案:任意阶段都能回到“旧读+旧写”。
常见坑
- 在 Expand 阶段做非兼容 DDL(例如直接改列类型)。
- 回填任务不幂等,重跑会污染数据。
- 只关注“DDL 成功”,忽略“业务一致性成功”。
小结
零停机迁移不是一条 SQL,而是一条发布流水线。把数据库变更当成可灰度、可观测、可回滚的工程流程,风险会从“不可控事故”变成“可管理演进”。