问题画像

线上最危险的不是“改表”,而是“业务和改表耦合发布”:应用已读新字段,但 DDL 还没完成;或者 DDL 成功了,回滚却读不懂新结构。

迁移模型:Expand -> Migrate -> Contract

  1. Expand:只做向后兼容变更(加列、加索引、双写入口)。
  2. Migrate:后台回填与数据校验,逐步把读流量切向新字段。
  3. 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,而是一条发布流水线。把数据库变更当成可灰度、可观测、可回滚的工程流程,风险会从“不可控事故”变成“可管理演进”。