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 链路)。这样灰度期可以直接按“组合”聚合错误率,而不是靠猜。

关联