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
两个容易误判的点:
int32 → int64在 Protobuf 的二进制传输里是兼容的(变长整数),但对 JSON/强类型客户端是破坏性的:超出 int32 的值会解析失败或截断。“线上能传”不等于“消费者能懂”。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 头 + 书面通知 + 替代方案);④ 灰度下线(先小流量返回错误码,观察后再删)。缺任何一件,下线都会变成事故。
关联
- 前置:51.3 语义化版本与变更日志、53.5 契约测试
- 并列:55.4 数据库变更管理 (Expand-Contract)、55.3 回滚设计
- 下游:47.6 API 设计规范 (REST / OpenAPI / 版本策略)(待补)
- 下游:31.4 事件 Schema 演进