灰度发布时新旧版本必然同时在线,数据一致性靠”schema 向后兼容 + 双写双读 + 最终清理”的 Expand-Contract 模式守住。新版本涉及数据库结构变更时,绝不能一次 ALTER 就切流,必须分”扩阶段—过渡—收阶段”三步推进,保证任一时刻运行中的所有版本代码都能读写同一套表结构。下文给出具体落地路径与可复用迁移片段。
一、为什么灰度期数据会错乱
滚动更新或金丝雀发布期间,v1 与 v2 实例会并行服务数分钟到数十分钟。如果 v2 依赖新列而 v1 不知道它的存在,就会出现三种故障:v1 写入时丢字段、v2 读到 NULL 报错、回滚后 v2 写入的数据 v1 无法解析。腾讯云一份灰度实战复盘把”灰度版本和数据库不兼容”列为五大坑之一,解法只有一句话——数据库变更要向前兼容,先加字段后删字段。
数据库 schema 无法像流量那样按比例灰度。一份基于 Istio 的灰度部署指南明确指出,流量染色解决了路由问题,但 schema 变更必须全局先行,且所有变更必须向后兼容——只能加列、不能删列或改字段类型,违反即必然失败。
二、核心策略:Expand-Contract 模式
Expand-Contract(扩展—收缩)把一次不兼容变更拆成三个阶段,让数据库在每个瞬间都兼容所有在线版本。
| 阶段 | 动作 | 兼容状态 |
|---|---|---|
| Expand(扩) | 加新列 / 新表,旧代码忽略新列 | v1、v2 同时可读写 |
| Migrate(过渡) | 后台任务回填历史数据 | 新旧列并存 |
| Contract(收) | 删旧列,仅留新结构 | 只剩 v2 在用新列 |
交易系统的落地案例把这套流程拆成三个子版本:先发 V1.1 让新旧代码都认 memo 新列(NULL 可空),再跑后台任务迁历史数据,最后发 V1.2 以 memo 为唯一数据源,稳定后删旧字段 ALTER TABLE orders DROP COLUMN old_memo_field。这个过程繁琐,但保证了任意时间点 schema 对全部在线版本向后兼容,杜绝因结构不匹配导致的数据错乱。
2.1 实施三步
- 在发布 v2 前单独执行 schema 变更,只增不减,确保 v1 完全不受影响;
- 部署 v2 并开启双写:写入新列的同时保留旧列写入,读路径优先新列、缺省回退旧列;
- 待全量切到 v2 且数据迁移完成,发一个纯清理版本删除旧列与兼容代码。
灰度监控与自动暂停要配合:观察 HTTP 错误与异常 SQL,超过阈值即暂停发布。CI/CD 可把 DROP COLUMN、RENAME COLUMN 标记为人工审核项,不靠字符串拦截。
三、双写双读的代码实现
以订单表新增 memo 字段为例,兼容期 v1.1 的读写逻辑如下:
// 写:兼容期双写,新列与旧列都落
func (r *OrderRepo) Save(o Order) error {
_, err := r.db.Exec(`
UPDATE orders SET old_memo=?, memo=?, updated_at=NOW WHERE id=?`,
o.Memo, o.Memo, o.ID)
return err
}
// 读:优先新列,缺失回退旧列
func (r *OrderRepo) Get(id int64) (Order, error) {
var o Order
err := r.db.QueryRow(`
SELECT id, memo, old_memo FROM orders WHERE id=?`, id).
Scan(&o.ID, &o.Memo, &o.OldMemo)
if err != nil {
return o, err
}
if o.Memo == "" {
o.Memo = o.OldMemo // 回退兼容 v1.0
}
return o, nil
}
这种写法保证 v1.0(不认 memo)和 v1.1(认 memo)在同一张表上共存时不报错,回退到旧版本也不丢数据。
四、无结构变更时的数据一致性
不涉及 schema 变更时,一致性风险主要来自缓存与共享状态。新旧版本写入的缓存格式不同会互相覆盖,解决方式是在缓存 Key 上加版本号,或保证序列化向后兼容。
| 场景 | 风险 | 对策 |
|---|---|---|
| 仅代码变更 | 缓存格式冲突 | Key 带 version |
| 加列不加删 | 低风险 | 双写双读 |
| 改列类型 / 删列 | 高失败 | 必须 Expand-Contract |
| 跨服务共享表 | 个别服务未升级 | 影子列 + 触发器同步 |
五、回滚时的数据兜底
回滚应用但没回滚数据,是最常见的”假回滚”。Expand-Contract 的好处是:扩阶段加的列是 NULL 可空的,回退到 v1 时旧代码直接忽略,不会因缺列崩溃。若必须重构旧字段,先通过数据库触发器或影子列(Shadow Column)保持两列双向同步,确保旧版本切回时数据链路完好。完整回滚要应用、配置、数据一起回,灰度监控显示错误率超阈值应自动回滚。
常见问题(FAQ)
Q1:灰度能按流量比例灰度数据库吗?
不能。schema 必须全局先变更,靠向后兼容让新旧版本并存。
Q2:回滚后新列数据会丢吗?
扩阶段新列可空且双写,回退到旧代码时忽略即可,不丢。
Q3:删列为什么放最后做?
旧代码仍可能运行,删早了会读不到列而报错,需全量切新后再收。