55.5 向后兼容

一句话

兼容不是“我改了但你应该还能跑”,而是旧消费者不改代码也能继续工作;判定标准写在契约上,不写在发布者的自信里。

先破除四个误区

误区 现实
“内部服务不用讲兼容” 滚动发布、灰度、回滚都要求 N-1 共存;不兼容 = 发布窗口随机报错
“加字段一定安全” 加必填字段、改默认值、改字段语义,都能让旧写入方/旧读者失败
“有版本号就万事大吉” 版本号只是允许破坏,代价是多维护一套契约;能不破坏就不要破坏
“兼容只是 API 的事” 事件 schema、数据库、配置、客户端,任何一面 breaking 都算不兼容

五个契约面

契约面 契约内容 谁在乎 检测手段
HTTP API 路径、方法、字段、状态码、错误码 前端、开放平台、第三方 openapi-diff、契约测试(见 53.5 契约测试)
RPC proto/thrift 的字段号、类型、必填性 内部服务 buf breaking
事件/消息 schema + 字段语义 + 顺序保证 下游消费者、数仓 schema registry 兼容性检查
数据库 表结构、约束、默认值 旧代码、报表、BI 迁移评审(见 55.4 数据库变更管理 (Expand-Contract))
配置 key、类型、默认值 各环境实例 schema 校验(见 54.5 配置注入)
客户端 已发布版本的请求格式与解析逻辑 移动端、桌面端 版本分布 + 埋点

HTTP 契约怎么放版本、错误码怎么定,见 47.6 API 设计规范 (REST / OpenAPI / 版本策略);服务版本与兼容策略见 30.6 服务版本与兼容;事件 schema 的演进规则见 31.4 事件 Schema 演进。

多数“兼容事故”不是 API 字段变了,而是语义变了:字段还在、类型没变,但单位从元变成分、时区从本地变成 UTC、status 多了个值。工具查不出语义变化,只能靠评审与文档。

变更分类:什么安全、什么破坏

变更 判定 说明
增加可选字段 / 可选参数 ✅ 安全 旧消费者忽略即可
增加新端点 / 新方法 ✅ 安全 不影响旧调用
增加必填字段,但带默认值 ✅ 安全(需评审默认值) 旧写入方可以不传;但默认值会改变行为,要单独确认
枚举增加值 ⚠️ 警告 旧消费者的 switch 必须能走 default,否则反序列化失败或逻辑漏分支
默认值变化 ⚠️ 警告 类型没变,行为变了;最隐蔽的一类
字段/端点标 deprecated ⚠️ 警告 兼容,但要给下线时间表
删除字段 / 重命名 ❌ 破坏 旧读者读不到;重命名等于“删 + 加”
改类型(int32 → int64、string → number) ❌ 破坏 线上传输可能兼容,但 JSON/强类型客户端会失败或截断
改语义(单位、时区、精度、排序) ❌ 破坏 工具查不出,靠评审
收紧校验(原来可空,现在必填) ❌ 破坏 旧调用方突然 400
删除枚举值 ❌ 破坏 旧数据/旧调用方仍可能传它
改错误码 / 改分页游标规则 ❌ 破坏 客户端按错误码分支、按游标翻页会错乱

🔑 判据只有一句:旧版本的消费者,不改一行代码,能不能继续正确工作? 答不上“能”,就是破坏性变更。

一个能跑的兼容性分类器

程序对比两份 schema,把每个变更归为 BREAKING / WARN / OK,有 BREAKING 就退出码 1。

package main

import (
	"fmt"
	"os"
)

type field struct {
	name     string
	typ      string
	required bool
	enum     []string
	def      string // 新增必填字段时,若带默认值则不算破坏
}

type schema struct {
	name   string
	fields []field
}

type finding struct {
	level, item, detail string
}

func has(fs []field, name string) (field, bool) {
	for _, f := range fs {
		if f.name == name {
			return f, true
		}
	}
	return field{}, false
}

func without(xs []string, drop string) []string {
	var out []string
	for _, x := range xs {
		if x != drop {
			out = append(out, x)
		}
	}
	return out
}

// compare 只看“旧消费者读新数据”这一侧:这是线上最常见的兼容方向。
func compare(old, new schema) []finding {
	var fs []finding
	add := func(level, item, detail string) { fs = append(fs, finding{level, item, detail}) }

	for _, o := range old.fields {
		n, ok := has(new.fields, o.name)
		if !ok {
			add("BREAKING", o.name, "字段被删除;旧消费者仍在读它")
			continue
		}
		if n.typ != o.typ {
			add("BREAKING", o.name, fmt.Sprintf("类型 %s → %s;旧解析器会失败或截断", o.typ, n.typ))
		}
		if len(o.enum) > 0 {
			for _, v := range o.enum {
				if len(without(n.enum, v)) == len(n.enum) {
					add("BREAKING", o.name, fmt.Sprintf("枚举值 %q 被删除;旧数据/旧调用方仍可能传它", v))
				}
			}
			if len(n.enum) > len(o.enum) {
				add("WARN", o.name, "枚举新增值;旧消费者的 switch 必须能走 default")
			}
		}
		if o.required != n.required && !n.required {
			add("WARN", o.name, "由必填改为可选;旧写入方可能仍按必填校验")
		}
	}
	for _, n := range new.fields {
		o, ok := has(old.fields, n.name)
		if !ok {
			if n.required && n.def == "" {
				add("BREAKING", n.name, "新增必填字段且无默认值;旧写入方无法构造合法数据")
			} else if n.required {
				add("OK", n.name, "新增必填字段但带默认值;旧写入方可以不传")
			} else {
				add("OK", n.name, "新增可选字段;旧消费者忽略即可")
			}
			continue
		}
		if o.def != n.def && n.def != "" && o.def != "" {
			add("WARN", n.name, fmt.Sprintf("默认值 %q → %q;语义可能变", o.def, n.def))
		}
	}
	return fs
}

func report(title string, old, new schema) bool {
	fs := compare(old, new)
	fmt.Println(title)
	fmt.Printf("%-9s %-14s %s\n", "LEVEL", "FIELD", "DETAIL")
	count := map[string]int{}
	for _, f := range fs {
		count[f.level]++
	}
	// 固定按严重程度输出,而不是按字母序(否则 OK 会排到 WARN 前面)
	levels := []string{"BREAKING", "WARN", "OK"}
	for _, l := range levels {
		for _, f := range fs {
			if f.level == l {
				fmt.Printf("%-9s %-14s %s\n", l, f.item, f.detail)
			}
		}
	}
	ok := count["BREAKING"] == 0
	verdict := "COMPATIBLE"
	if !ok {
		verdict = "INCOMPATIBLE"
	}
	fmt.Printf("结论:%s(BREAKING %d,WARN %d,OK %d)\n\n",
		verdict, count["BREAKING"], count["WARN"], count["OK"])
	return ok
}

func main() {
	v1 := schema{"OrderEvent v1", []field{
		{"id", "string", true, nil, ""},
		{"amount", "int32", true, nil, ""},
		{"status", "string", true, []string{"created", "paid"}, ""},
		{"note", "string", false, nil, ""},
		{"currency", "string", true, nil, ""},
	}}
	broken := schema{"OrderEvent v2", []field{
		{"id", "string", true, nil, ""},
		{"amount", "int64", true, nil, ""}, // 改类型
		{"status", "string", true, []string{"created", "paid", "refunded"}, ""}, // 加枚举
		{"currency", "string", true, nil, ""},
		{"coupon", "string", false, nil, ""},    // 新增可选
		{"channel", "string", true, nil, "web"}, // 新增必填但有默认
		// note 被删除
	}}
	ok1 := report("① 事件 schema:v1 → v2(删字段 + 改类型)", v1, broken)

	v2 := schema{"HTTP Resp 2.0", []field{
		{"code", "int", true, nil, ""},
		{"message", "string", true, nil, ""},
		{"data", "object", false, nil, ""},
	}}
	v21 := schema{"HTTP Resp 2.1", []field{
		{"code", "int", true, nil, ""},
		{"message", "string", true, nil, ""},
		{"data", "object", false, nil, ""},
		{"request_id", "string", false, nil, ""}, // 只加可选字段
	}}
	ok2 := report("② HTTP 响应:2.0 → 2.1(只增可选字段)", v2, v21)

	fmt.Printf("两次发布都通过?%v\n", ok1 && ok2)
	if !ok1 || !ok2 {
		os.Exit(1)
	}
}

本机运行结果(退出码 1):

① 事件 schema:v1 → v2(删字段 + 改类型)
LEVEL     FIELD          DETAIL
BREAKING  amount         类型 int32 → int64;旧解析器会失败或截断
BREAKING  note           字段被删除;旧消费者仍在读它
WARN      status         枚举新增值;旧消费者的 switch 必须能走 default
OK        coupon         新增可选字段;旧消费者忽略即可
OK        channel        新增必填字段但带默认值;旧写入方可以不传
结论:INCOMPATIBLE(BREAKING 2,WARN 1,OK 2)

② HTTP 响应:2.0 → 2.1(只增可选字段)
LEVEL     FIELD          DETAIL
OK        request_id     新增可选字段;旧消费者忽略即可
结论:COMPATIBLE(BREAKING 0,WARN 0,OK 1)

两次发布都通过?false

两个容易误判的点:

  1. int32 → int64 在 Protobuf 的二进制传输里是兼容的(变长整数),但对 JSON/强类型客户端是破坏性的:超出 int32 的值会解析失败或截断。“线上能传”不等于“消费者能懂”。
  2. channel 是新增必填但有默认值,判为 OK;但默认值 web 会让所有旧写入方的数据都变成 web,统计口径变了——这是行为变更,评审时要单独确认,不能只看“不报错”。

什么时候加版本,什么时候兼容演进

策略 做法 成本 什么时候用
兼容演进(首选) 只加可选字段、不删不改 低 绝大多数内部 API/事件
并行版本 /v2、新 topic、新方法,旧版继续服务 中(双份维护) 语义整体重构、旧契约无法表达
适配层 新契约 + 旧接口转发/转换 中–高 消费者无法快速升级(开放平台)
直接破坏 通知所有消费者一起上 低(自己的成本)/ 高(别人的) 只有你能确认“所有消费者同时发布”

版本号本身怎么升(major/minor/patch、0.x 的特殊性)见 51.3 语义化版本与变更日志。

内部 RPC 的经验规则:字段号只增不减、不复用;删字段用 reserved 占位,避免后来者复用导致解析错乱。事件 schema 用 schema registry 做兼容性门禁(BACKWARD / FORWARD / FULL),比口头约定可靠。

弃用流程:给时间表,不给突然死亡

① 标记 deprecated(代码注解 + 文档 + 响应头)
② 观测调用量:按 client、version、租户拆分
③ 通知消费者:影响、替代方案、截止日期
④ 双跑:旧接口转发到新实现,或返回适配后的数据
⑤ 灰度下线:先对内部/小流量返回 410 + 错误码,观察
⑥ 删除代码与文档
消费者类型 建议窗口 依据
内部服务 2–4 周 可以跟发布节奏对齐
Web 前端 1–2 个发布周期 刷新即生效,但要考虑缓存
移动端 12–24 个月 应用商店版本长尾(经验判断,按自己数据定)
开放平台 6–12 个月 + 合同约定 客户升级节奏不受你控制

HTTP 接口至少给两个信号:Deprecation 响应头标记已弃用,Sunset 响应头给出下线时间(RFC 8594)。没有这两个头,消费者只能靠公告栏,而公告栏没人看。

兼容窗口:你到底要支持几个版本

场景 支持窗口 原因
滚动/蓝绿/金丝雀 N-1(上一版) 发布窗口内新旧共存(见 55.3 回滚设计)
回滚 上一个可用 digest + 兼容 schema 回滚目标必须能读当前数据
开放平台 N-1 + 弃用期 第三方升级慢
移动端 最近 12–24 个月 用户不升级,商店审核也慢

“支持 N-1”不是口号,要落到测试矩阵:每次发布用上一版客户端跑一遍核心契约测试(53.5 契约测试),否则 N-1 只是文档里的一句话。

生产落地:指名 → 可抄 → 可验 → 可退

指名:Protobuf 用 buf 的 breaking 规则;OpenAPI 用 openapi-diff;事件用 schema registry(Confluent / Apicurio / Glue)的兼容模式;契约测试用 Pact(见 53.5 契约测试)。语义变更工具查不出,必须靠 API 评审清单。

可抄(本机未安装 buf / openapi-diff,以下命令未验证):

# 1) Protobuf:与主分支对比,任何 BREAKING 直接失败
buf breaking --against '.git#branch=main'

# 2) OpenAPI:比较两个版本,出现不兼容变更就让命令失败
openapi-diff --fail-on-incompatible old-openapi.yaml new-openapi.yaml

# 3) 事件 schema:registry 侧设置兼容模式(示例:BACKWARD)
curl -s -X PUT "$SCHEMA_REGISTRY/config/order-value" \
  -H 'Content-Type: application/vnd.schemaregistry.v1+json' \
  -d '{"compatibility":"BACKWARD"}'

可验:本节分类器真跑过,输出如上;把它接进 CI(go run compat.go || exit 1)能挡住字段级破坏。线上验证 N-1:用上一版客户端/上一版消费者跑核心链路,确认无解析错误、无 4xx 上升;再抽查事件消费端的死信队列。

可退:破坏性变更已经发出去时,退路只有三条——① 恢复旧字段(兼容层,转发/双写);② 让消费者升级(不现实时别选);③ 加版本并行,把旧契约保留到弃用窗口结束。这也是为什么“能不破坏就不破坏”:破坏之后的退路都很贵。

反模式

反模式 后果
删字段/改名,认为“没人用” 长尾消费者、报表、离线任务在几天后集中报错
改字段语义但类型不变 工具全绿,数据悄悄错(元→分、时区、精度)
收紧校验(可空变必填) 旧调用方突然 400,且往往在低峰才暴露
枚举加值不留 default 分支 旧消费者反序列化失败或走错分支
复用 Protobuf 字段号 新旧数据解析错乱,最难排查
用“版本号”代替兼容设计 多套契约长期并存,维护成本翻倍
弃用不发通知、不给日期 下线当天才发现有人用
只在发布前跑一次契约测试 N-1 兼容在发布窗口才被破坏

自检

Q1 “加字段一定安全”这句话哪里错了?

▸ 答案:加可选字段通常安全;但加必填字段(无默认值)会让旧写入方无法构造合法数据;加必填带默认值会改变行为与统计口径;加枚举值会让旧消费者的 switch 漏分支。安全与否取决于“旧消费者不改代码能否正确工作”,不是“字段是新增的”。

Q2 为什么 int32 → int64 在 Protobuf 里“能传”,却仍被判为破坏?

▸ 答案:二进制 wire format 用变长整数,传输层兼容;但消费者侧是强类型(Go int32、Java int、JSON Schema integer 的范围),超出范围会截断或解析失败。兼容要按“消费者能不能正确理解”判,不是按“字节能不能传过去”判。

Q3 语义变更(单位、时区、精度)为什么最危险?

▸ 答案:类型和字段都没变,所有静态检查、schema diff、契约测试都可能通过;但值变了含义,下游按旧语义计算就会错,且错误往往在报表/对账时才暴露。防法是把语义写进契约文档与字段命名(amount_cents、created_at_utc),并在评审清单里单列“语义是否变化”。

Q4 内部服务真的需要支持 N-1 吗?

▸ 答案:只要存在滚动发布、灰度或回滚,就存在新旧版本共存窗口,就必须支持 N-1。金丝雀期间新旧同时在线;回滚时旧版本要能读新数据。除非你能保证“所有实例同时切换且从不回滚”(停机发布 + 无状态 + 无数据变更),否则 N-1 是发布的物理前提。

Q5 弃用一个接口,最少要做哪几件事?

▸ 答案:四件:① 标记(代码注解 + 文档 + Deprecation 头);② 观测(按 client/version 统计调用量,确认谁在用);③ 通知与时间表(Sunset 头 + 书面通知 + 替代方案);④ 灰度下线(先小流量返回错误码,观察后再删)。缺任何一件,下线都会变成事故。

关联