30.6 服务版本与兼容
一句话
服务独立部署之后,线上不再有“一个版本”,而是 N 个服务 × 各自在线版本 的组合状态;兼容不是“接口没变”,而是“在共存窗口内,任意新旧组合都能正确工作”——组合数按
2^N爆炸,所以只能按流量权重抽样验证,不可能全测。
版本不是一个数字,是一个矩阵
一次发布期间,5 个服务各有新旧两个版本在线,理论组合数是 2^5 = 32:
| 组合类型 | 数量 | 说明 |
|---|---|---|
| 全旧 | 1 | 发布前的常态 |
| 全新 | 1 | 发布后的常态 |
| 1 新 4 旧 | 5 | 灰度刚开始 |
| 2 新 3 旧 | 10 | 分批推进 |
| 3 新 2 旧 | 10 | 过半 |
| 4 新 1 旧 | 5 | 收尾 |
| 合计 | 32 | 每多一个服务,组合数翻倍 |
32 个组合不可能都测。可行的办法是按流量权重抽样:先算每个组合会被多少请求打到,再决定测哪些。
package main
import (
"fmt"
"sort"
)
var services = []string{"order", "payment", "inventory", "pricing", "notify"}
// 灰度期每个服务的流量分布:旧版 90%,新版 10%(常见的 10% 金丝雀)
const newShare = 0.10
// 已经测过的组合(用位掩码表示:第 i 位为 1 表示第 i 个服务是新版本)
var tested = []int{
0, // 全旧
31, // 全新
1, // 只有 order 新
3, // order + payment 新
7, // order + payment + inventory 新
16, // 只有 notify 新
}
func weight(mask int) float64 {
w := 1.0
for i := range services {
if mask>>i&1 == 1 {
w *= newShare
} else {
w *= 1 - newShare
}
}
return w
}
func name(mask int) string {
s := ""
for i, svc := range services {
if mask>>i&1 == 1 {
if s != "" {
s += "+"
}
s += svc + "@new"
}
}
if s == "" {
return "全旧"
}
return s
}
func main() {
total := 1 << len(services)
seen := map[int]bool{}
for _, m := range tested {
seen[m] = true
}
// ① 组合数覆盖 vs 流量覆盖
wAll, wTested := 0.0, 0.0
for m := 0; m < total; m++ {
w := weight(m)
wAll += w
if seen[m] {
wTested += w
}
}
fmt.Printf("=== 版本矩阵:%d 个服务,灰度 %v 新流量 ===\n", len(services), newShare)
fmt.Printf("组合总数 %d\n", total)
fmt.Printf("已测组合 %d(组合覆盖率 %.1f%%)\n", len(tested), float64(len(tested))*100/float64(total))
fmt.Printf("流量覆盖率 %.1f%%\n", wTested*100/wAll)
// ② 按权重找最该补测的组合
fmt.Println("\n=== 未测组合里流量最大的前 5 个(补测优先级)===")
type row struct {
mask int
w float64
}
var rows []row
for m := 0; m < total; m++ {
if !seen[m] {
rows = append(rows, row{m, weight(m)})
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].w > rows[j].w })
for i, r := range rows {
if i == 5 {
break
}
fmt.Printf("%-32s 流量占比 %5.2f%%\n", name(r.mask), r.w*100/wAll)
}
// ③ 组合数随服务数增长
fmt.Println("\n=== 每加一个双版本服务,组合数翻倍 ===")
for n := 2; n <= 10; n++ {
fmt.Printf("%2d 个服务 -> %4d 种组合\n", n, 1<<n)
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 版本矩阵:5 个服务,灰度 0.1 新流量 ===
组合总数 32
已测组合 6(组合覆盖率 18.8%)
流量覆盖率 73.0%
=== 未测组合里流量最大的前 5 个(补测优先级)===
payment@new 流量占比 6.56%
inventory@new 流量占比 6.56%
pricing@new 流量占比 6.56%
inventory@new+notify@new 流量占比 0.73%
payment@new+notify@new 流量占比 0.73%
=== 每加一个双版本服务,组合数翻倍 ===
2 个服务 -> 4 种组合
3 个服务 -> 8 种组合
4 个服务 -> 16 种组合
5 个服务 -> 32 种组合
6 个服务 -> 64 种组合
7 个服务 -> 128 种组合
8 个服务 -> 256 种组合
9 个服务 -> 512 种组合
10 个服务 -> 1024 种组合
两个覆盖率差了 4 倍,这是本节最有用的一条读数:
| 指标 | 数值 | 含义 |
|---|---|---|
| 组合覆盖率 | 18.8% | 只测了 32 个组合里的 6 个,看起来“测得太少” |
| 流量覆盖率 | 73.0% | 但这 6 个组合覆盖了 73% 的真实流量,因为“全旧/全新/单个新”本来就是流量主体 |
🔑 结论:选测试用例不能按组合数平均,要按流量权重。10% 金丝雀时,“单服务新”组合各占 6.6%,“全旧”就占 59%;而“两个服务同时新”只有 0.7%。所以合理的抽样是“全旧 + 全新 + 每个服务的单新 + 高压业务的关键配对”,而不是随机抽 6 个。
共存窗口:新旧同时在线多久
| 发布方式 | 共存窗口 | 谁必须兼容 |
|---|---|---|
| 滚动发布(5 批 × 2 分钟) | ≈ 10 分钟 + 观察期 | 新旧版本互调要正确(双向兼容) |
| 蓝绿(瞬时切换) | ≈ 秒级(切换瞬间有少量请求落在旧版) | 旧版要能读新版数据(schema 向前兼容) |
| 金丝雀(5% 流量,跑 24 小时) | 24 小时 | 新旧长期共存,数据与消息格式都要双向可用 |
| 后端任务重放 | 可能数天 | 旧消息要被新代码读懂(事件 schema 的演进规则见 31.4 事件 Schema 演进) |
⚠️ 共存窗口的长度决定了兼容的严格程度:滚动发布只要“几分钟内新旧能互调”,金丝雀需要“一天内新旧能同时读写同一份数据”。很多人按“滚动发布”的兼容标准去做金丝雀,结果在新版写入了旧版读不懂的数据格式时炸掉——窗口越长,越要靠“只加不改”的 schema 演进(55.5 向后兼容)。
兼容责任:三方分工
| 角色 | 责任 | 具体做法 |
|---|---|---|
| 提供方(服务 owner) | 向后兼容:只加字段、不删不改语义、给默认值 | 变更分级表 + 兼容性检查(53.5 契约测试) |
| 消费方(调用方) | 向前兼容:忽略未知字段、缺字段用默认值、不依赖字段顺序 | tolerant reader;禁止 DisallowUnknownFields 这类严格解析 |
| 双方共同 | 契约测试:把“谁依赖什么”固化成可执行断言 | consumer-driven contract(53.5) |
🔑 兼容是双方的责任,只要求一方都做不到:提供方永远“只加不改”会让接口无限膨胀(字段越来越多、含义越来越模糊),消费方永远“什么都能忍”会让错误静默传播(字段改名后读到默认值,业务悄悄算错)。健康的做法是:提供方保证 N-1 兼容 + 明确的弃用时间表,消费方保证能忽略新增字段。
反模式
| 反模式 | 后果 |
|---|---|
| 用“接口没变”当兼容标准 | 字段语义变了(amount 从元变分)、枚举加了值,接口签名一模一样但行为不兼容 |
| 灰度期只测“全旧 + 全新” | 中间组合(部分新部分旧)才是故障高发区,恰好没测 |
| 按组合数平均抽样 | 把时间花在只占 0.01% 流量的组合上,73% 流量的组合反而没测 |
| 版本号只写进镜像 tag,接口不带版本 | 消费者无法按版本路由,出问题只能整体回滚(版本号怎么定见 51.3 语义化版本与变更日志) |
| 依赖“同时发布就不会有新老混用” | 只要有滚动/灰度/重试/延迟消息,就一定有混用 |
| 弃用不给时间表,直接删字段 | 消费方在下一个发布周期集体故障 |
| 用数据库做版本兼容(新代码写新表,旧代码读旧表) | 两套数据双写,最终对不上账 |
自检
Q1 5 个服务灰度发布,只测了“全旧 + 全新”两个组合。组合覆盖率 6%,但团队认为“够用了”。这个判断在什么条件下成立、什么条件下不成立?
▸ 答案:只在“没有任何跨服务契约变化”时成立:如果这次发布每个服务的改动都只在自己进程内(不改接口、不改事件、不改数据格式),那么任意组合都等价,测两个极端足够。一旦有任何一个服务改了对外契约(新增必填字段、改字段语义、改消息格式),中间组合就必然出问题——例如 order 新版调用 payment 旧版,旧版不认识新字段而报错。判据是:这次发布有没有跨边界变更;有 → 必须补“单服务新”的 5 个组合(它们各占 6.6% 流量,是中间态的主体)。
Q2 为什么金丝雀发布对兼容性的要求比滚动发布高得多?
▸ 答案:因为共存窗口长了一个数量级。滚动发布的新旧共存只有几分钟,最坏情况是“少量请求失败,回滚即可”;金丝雀 5% 流量跑 24 小时,意味着 24 小时内新旧版本要同时读写同一份数据、同时消费同一个消息队列。这时任何“旧版读不懂新版写入的格式”都会在 24 小时里持续产生脏数据,而不是几分钟的抖动。所以金丝雀必须配合“只加不改”的 schema 演进与双向兼容测试。
Q3 提供方说“我们保证向后兼容,只加字段”,消费方是否就可以什么都不做?
▸ 答案:不可以,还有三件事必须做:① 忽略未知字段——很多语言的默认反序列化会因未知字段报错(如 Go 的 DisallowUnknownFields、部分强类型 IDL),加字段就成了破坏性变更;② 给缺省值——新增字段在老数据/老消息里不存在,消费方必须有合理默认,否则空指针;③ 不依赖顺序与数量——列表顺序、字段顺序变化不算破坏性变更,但依赖它的代码会挂。所以“提供方兼容”只解决了半个问题,另外半个在消费方的解析策略里。
Q4 一个团队把“服务版本”定义成镜像 tag(order:v1.2.3),接口和消息里都不带版本。灰度出问题时他们怎么定位?
▸ 答案:几乎无法定位。镜像 tag 只告诉你“哪个 Pod 跑哪个构建”,但一次请求可能跨 5 个服务、每个服务有多个版本同时在线,出问题时你不知道是“哪个组合”失败。正确做法是让版本随请求传播:接口带版本(URL/Header)、消息带 schema_version、日志与 trace 带 service.version 标签(50.3 链路)。这样灰度期可以直接按“组合”聚合错误率,而不是靠猜。