Protobuf-04 schema 演进与兼容性:哪些改动会炸
本文是 Protobuf 系列的第 4 篇,也是我认为最值得读的一篇。
语法写错了编译器会拦你;但 schema 改错了,编译器和测试都不会报警——它会在半年后的某个滚动发布窗口里,以「某个字段莫名变成 0」的形式爆出来。这一篇就是那份清单。
1. 两个方向的兼容性
先把术语对齐,很多讨论是被这两个词搞乱的:
- 向后兼容(backward compatible):新代码能读旧数据。
- 向前兼容(forward compatible):旧代码能读新数据。
Protobuf 难得的地方在于默认同时提供两者,而这个能力完全来自第 1 篇讲的一个机制:tag 里带着 wire type,所以解析器遇到不认识的字段号也知道该跳过多少字节。
- 旧代码读新数据 → 新字段被当作未知字段跳过(proto3 从 3.5 起还会保留它们并在重新序列化时带回)。
- 新代码读旧数据 → 缺失的字段取默认值。
所以「加字段」几乎总是安全的,这是 Protobuf 演进的基本手法:能通过加字段解决的问题,就不要去改老字段。
2. 兼容性有三层,别只看 wire
这是最容易被忽略的一点。一个改动可能在二进制层面完全安全,却把下游打得编译不过。兼容性至少要分三层看:
| 层 | 依赖什么 | 什么会破坏它 |
|---|---|---|
| wire format | 字段号 + wire type | 改字段号、跨 wire type 改类型 |
| JSON / text format | 字段名 | 改字段名、改 message 名(影响 Any)、改 package |
| 生成代码 API | 字段名、类型名、包名 | 改任何名字、改字段类型(Go 类型跟着变) |
| gRPC 路由 | /package.Service/Method 字符串 |
改 package / service / rpc 名 |
最典型的例子就是改字段名:
// 改动前
string user_name = 1;
// 改动后
string username = 1;
- wire format:完全安全,名字根本不在字节流里。
- JSON:破坏。
userName变成username,所有 JSON 客户端读不到值了(可以用[json_name = "userName"]锁住老名字来补救)。 - 生成代码:破坏。Go 里
msg.UserName编译失败,下游全部要改。
第四行也值得单独强调:gRPC 的方法路径是 /包名.服务名/方法名 这样的字符串。所以改 package、改 service 名、改 rpc 名,都会让老客户端收到 Unimplemented 错误——即使消息定义一个字节都没动。
3. 安全改动清单
以下改动在 wire format 层面安全(是否影响 JSON / 生成代码另行标注)。
3.1 加字段
message User {
string name = 1;
int32 age = 2; // 新增
}
总是安全。旧读端跳过它,新读端读旧数据时该字段是默认值。
⚠️ 但注意:新字段对旧数据来说永远是零值,所以不要让新字段承担「必须有值」的语义。另外,如果新字段的零值恰好是个合法业务值,考虑用 optional(见第 3 篇)。
3.2 删字段(必须配 reserved)
安全,但必须三步走:
message User {
string name = 1;
// 2 号原本是 int32 age,已删除
reserved 2;
reserved "age";
}
漏掉 reserved 的后果:半年后有人加了 bool age = 2;,旧数据里 2 号的 varint 整数会被读成 bool,或者更糟——读到别人的数据当成自己的字段。这类事故排查起来极其痛苦。
更保守的做法是先弃用、不删除:
message User {
string name = 1;
int32 age = 2 [deprecated = true]; // 生成代码会带弃用标记
}
下游编译时会看到弃用警告,等所有调用方都迁移完了再真正删除。对外 API 建议一律先 deprecated 一个大版本周期。
3.3 改字段名
wire 安全,但破坏 JSON 和生成代码。见第 2 节。
如果必须改,且要保住 JSON 契约:
string username = 1 [json_name = "userName"];
3.4 同 wire type 内的类型互换
这一组的依据就是第 1 篇那张 wire type 表:
| 可互换的组 | wire type | 注意事项 |
|---|---|---|
int32 int64 uint32 uint64 bool enum |
0 | 值域截断:int64 的大值读成 int32 会被砍掉高位,行为等同 C++ 强转 |
sint32 sint64 |
0 | 只能互换,不能与 int32/int64 换(ZigZag 层) |
fixed32 sfixed32 |
5 | |
fixed64 sfixed64 |
1 | |
string bytes |
2 | bytes → string 要求内容是合法 UTF-8 |
嵌套 message ↔ bytes |
2 | bytes 内容就是该 message 的序列化结果 |
sint32 与 int32 不兼容是最阴的一条:wire type 相同,解析不报错,但因为多了一层 ZigZag,会静默读出完全错误的数值(同样的字节,int32 读到 2,sint32 读到 1)。
3.5 singular ↔ repeated
string/bytes/ message:安全。旧读端读到多个值时取最后一个(message 则递归合并)。- 数值类型(含
bool、enum):不安全。proto3 的数值型 repeated 默认 packed(wire type 2),而 singular 数值是 wire type 0,两者对不上,解析直接失败。
3.6 map ↔ repeated entry message
安全,因为 map 本来就是这个的语法糖:
map<string, Project> projects = 3;
// 等价于
message ProjectsEntry { string key = 1; Project value = 2; }
repeated ProjectsEntry projects = 3;
3.7 optional 字段 ↔ 单成员 oneof
安全。因为 proto3 的 optional 本身就是用合成 oneof 实现的(第 3 篇第 4 节),wire 层面二者完全一致。
3.8 packed ↔ unpacked
对 repeated 数值字段切换 [packed = true],在 protobuf 2.3.0 及以后是安全的——解析器对可打包字段两种格式都接受。这一条经常被误传成不安全,注意它和 3.5 节的 singular↔repeated 是两件事。
3.9 加枚举值
- proto3(open enum):安全。旧读端会把不认识的值原样保留在字段里。
- proto2(closed enum):有陷阱。未知值会落进 unknown fields,读出来是默认值,中间节点转发时可能丢失。详见第 7 篇。
无论哪种,消费端都必须写 default 分支。
4. 危险改动清单
以下改动会破坏兼容性。如果你必须做,跳到第 7 节看版本化策略。
| 改动 | 后果 |
|---|---|
| 改字段号 | 致命。旧数据的该字段变成未知字段(静默丢失),新数据在旧端也读不到 |
| 复用已删除的字段号 | 致命且危险。旧数据的残留值会被解读成新字段的值 |
跨 wire type 改类型(如 int32 → string、int32 → fixed32) |
解析失败或读出垃圾值 |
int32 ↔ sint32 |
不报错,静默读出错误数值 |
| 数值字段 singular ↔ repeated | 解析失败(packed 差异) |
| 改枚举值的数字 | 等同于改字段号,语义完全错位 |
| 把多个已有字段合并进一个 oneof | 运行时互相清除;旧写端可能同时设置多个,行为未定义 |
| 把 oneof 拆开成独立字段 | 同上,反向也不安全 |
required ↔ optional(proto2) |
一改就有一端拒绝解析整条消息 |
加/去 optional |
wire 层可解析,但改变了「零值是否上线」的行为,语义会漂移 |
| 改 package / service / rpc 名 | gRPC 路由路径变了,老客户端收到 Unimplemented |
| 改 message 名 | wire 安全,但破坏 Any 的 type_url、text format 和生成代码 |
| 把字段移进/移出嵌套 message | 字段号所属的 message 变了,等同于删了再加 |
5. 未知字段透传:一个容易漏的陷阱
proto3 自 3.5 起会保留未知字段,这让「旧版本中间节点透传新字段」成为可能:
新版 A ──▶ 旧版 B(不认识 new_field,但原样保留)──▶ 新版 C(读到 new_field)✅
但这个保证会在两个地方失效:
① 经过 JSON 转换的链路。 JSON 里没有「未知字段」的容身之处:
// proto → JSON:未知字段无法输出,直接丢
b, _ := protojson.Marshal(msg)
// JSON → proto:默认遇到未知字段直接报错
err := protojson.Unmarshal(b, msg) // 报错
err = protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(b, msg) // 丢弃
所以任何做 proto → JSON → proto 转码的中间层(包括某些 grpc-gateway 用法、消息总线、审计日志回放)都会静默丢掉未知字段。如果你的架构里有这类节点,「透传」这个假设就不成立了。
② 显式丢弃的场景。 有些库或代码路径会主动 DiscardUnknown,或者手工重建消息(&pb.T{Field: old.Field} 这种逐字段拷贝,未知字段自然没了)。转发消息时用 proto.Clone 而不是手工重建字段。
6. 滚动升级的部署顺序
改 schema 从来不只是改文件,还包括「按什么顺序发布」。核心原则一句话:
总是先部署读端(消费者),再部署写端(生产者)。
因为「旧读端 + 新数据」这个组合虽然大多安全,但它只保证「不崩」,不保证「行为正确」——旧读端不会处理新字段的业务逻辑。
具体到几种改动:
| 改动 | 顺序 |
|---|---|
| 加字段 | ① 读端全量升级 → ② 写端开始写新字段 |
| 加枚举值 | ① 所有读端确认有 default 分支 → ② 写端开始发新值 |
| 删字段 | ① 写端停止写 → ② 等旧数据过期/迁移完 → ③ 读端删除依赖 → ④ schema 里 reserved |
| 改语义(如单位从秒变毫秒) | 别改。加一个新字段,双写一段时间,再废弃老字段 |
最后一条值得展开:改语义是最危险的一类变更,因为它在所有三层兼容性检查里都是「安全」的——字段号没变、类型没变、名字没变,任何工具都拦不住你,只有线上数据会错。遇到这种需求,标准做法是:
message Task {
int32 timeout = 1 [deprecated = true]; // 老字段:秒
int64 timeout_ms = 2; // 新字段:毫秒
}
双写一段时间,读端优先读新字段、回退到老字段,等老字段确认没人写了再删。
7. 什么时候该开 v2
如果需求确实要求做破坏性变更,正确答案不是「小心地改」,而是并行一个新版本。这就是第 2 篇建议把版本写进 package 的原因:
proto/
example/search/v1/search.proto ← 冻结,继续服务老客户端
example/search/v2/search.proto ← 新语义
因为 package 不同,两套 message 和 service 是完全独立的类型和路由,可以在同一个进程里同时注册、同时提供服务,让客户端自己按节奏迁移。
判断标准:如果一个改动会让「某个现存客户端在不改代码的情况下行为出错」,那它就该进 v2,而不是原地改 v1。
8. 用 buf breaking 机器化拦截
上面这些清单,靠 code review 记住是不现实的。这件事应该交给工具,在 CI 里挡住。
buf 提供 buf breaking,把当前 schema 和一个基线(git 分支、tag、远程仓库)对比,自动报出破坏性变更。
配置 buf.yaml:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
本地检查:
# 与 main 分支对比
buf breaking --against '.git#branch=main'
# 与远程仓库对比(proto 在 proto/ 子目录)
buf breaking --against 'https://github.com/org/repo.git#branch=main,subdir=proto'
breaking.use 有四个由严到宽的档位,选哪个取决于你的消费者是谁:
| 类别 | 检查范围 | 适用场景 |
|---|---|---|
FILE |
最严,连生成代码的兼容性都管(改名、挪文件都算破坏) | 默认推荐。消费者会直接依赖生成代码(同一 monorepo、SDK 分发) |
PACKAGE |
同上但允许文件间移动定义 | 按 package 组织、文件布局常调整 |
WIRE_JSON |
只管 wire + JSON 兼容(允许改 Go 类型名等) | 消费者只通过网络交互,不共享生成代码 |
WIRE |
只管二进制兼容 | 纯内部二进制通信,不用 JSON |
CI 里加一步就行(GitHub Actions 示例):
- uses: bufbuild/buf-action@v1
with:
breaking_against: 'https://github.com/${{ github.repository }}.git#branch=main'
顺带把 buf lint 也开上——它会强制第 2 篇讲的命名风格、要求 enum 首值是 _UNSPECIFIED、要求 package 带版本号,这些规则本身就在防止后面出现无法演进的设计。
注意
buf breaking拦不住语义变更(第 6 节那个秒变毫秒的例子)。工具能守住结构,语义只能靠人和文档。
9. 检查清单
改 .proto 前问自己五个问题:
- 这个需求能不能只靠加字段解决?(能就别改老字段)
- 我改的是字段号吗?(是 → 停)
- 我改的东西出现在字节流里吗?字段名不在,字段号在。但字段名出现在 JSON、生成代码和 gRPC 路由里。
- 消费者是跨团队/跨版本的吗?是则按
FILE档要求自己,并走 deprecated 流程。 - 语义变了吗?(变了 → 加新字段或开 v2,别原地改)
10. 小结
- Protobuf 的双向兼容能力来自「tag 带 wire type,未知字段可跳过并保留」,因此加字段是万能手法。
- 兼容性有四层:wire format(看字段号 + wire type)、JSON/text(看字段名)、生成代码 API(看所有名字)、gRPC 路由(看
包.服务/方法)。改名 wire 安全但会打断另外三层。 - 同 wire type 内的类型大多可换,但
sint与int不能换(静默错值),数值的 singular ↔ repeated 不能换(packed)。 - 删字段必须
reserved编号和名字;对外 API 先deprecated一个周期。 - 未知字段透传会被 JSON 转码环节和手工重建消息破坏,转发用
proto.Clone。 - 发布顺序永远是先读端后写端;语义变更不要原地改,加新字段或开 v2。
- 把上述规则交给
buf breaking在 CI 里拦,人只负责工具拦不住的语义部分。
下一篇讲 Well-Known Types 和官方 JSON 映射规则。
系列目录
xingliuhua