REST API 的形状变化大多能在浏览器响应里直接看到;gRPC API 的变化发生在 .proto 文件里,而 Protocol Buffers 的二进制线上格式对「客户端能承受什么」有一套自己的规则,这套规则跟字段名怎么写没有关系。diff 里两个看起来同样微不足道的改动——重新编号一个字段和添加一个新字段——落在破坏性变更那条线的两侧:一个对每个现有客户端都不可见,另一个同时破坏它们全部。区分真正的破坏性变更和安全的改动,靠的是读线上格式自己的规则,而不是猜这个改动在 .proto diff 里看起来怎么样。
线上格式按编号编码,名字只是给人看的
线上格式按字段编号而不是按名字编码。每种语言生成的代码读写这些编号;.proto 里的 email 字段名只是给人看的便利,从来不会碰到在网络上传输的二进制字节。把 email 重命名成 email_address,只要编号不变,在二进制线上格式里就是安全的——这会让习惯了 REST 的工程师意外,因为 REST 里重命名 JSON 键恰恰会破坏客户端。
例外恰恰就是 REST 那种情况:ProtoJSON 和文本格式会序列化字段名,所以重命名会破坏 JSON 转码(例如 grpc-gateway)、文本格式文件和字段掩码。给同一个字段重新编号,保持名字不变但把 1 改成 7,则恰恰相反:在只显示名字的代码审查里不可见,却会破坏客户端从那一刻起发送或接收的每一条消息。
判定表
| 改动 | 在线上是否安全 | 原因 |
|---|---|---|
| 重命名字段,保留编号 | 二进制是,JSON 和文本否 | 二进制编码用编号;ProtoJSON 和文本格式用名字 |
| 更改字段编号 | 否 | 每一条现有消息现在都会被读成另一个字段 |
| 用新编号添加新字段 | 是 | 旧客户端会忽略它们不认识的字段 |
| 删除一个字段,把它的旧编号重新用于别的东西 | 否 | 旧数据会被解码进错误的新字段 |
| 不兼容地更改字段类型(例如 int32 → string) | 否 | 不同类型的线上编码方式不同 |
编号会变得带有放射性
Protobuf 自己的指引建议把已删除字段的编号标记为 reserved,而不是允许它被重新使用,因为真正的损害恰恰发生在重用那一刻:一个仍然运行着几个月前生成代码的客户端,为旧值发送旧的字段编号,而服务端现在期望那个编号表示别的东西,于是它没有直接拒绝数据,而是悄悄地把数据解释错了。
REST 没有对应的陷阱,因为被删除的 JSON 键只是不再出现而已,没有任何方式能让旧客户端的请求被悄悄重新解释成别的东西。
reserved 4, 9, 12;
.proto 消息开头这一行是一道永久的伤疤,这正是它的意义:阻止那个编号被不了解其历史的人交给一个新字段。
新增字段的规则一样,暴露面不同
GraphQL 只让客户端收到自己明确请求的字段,增量变化本质上没有风险,只有删除才是真正的危险。相比之下 gRPC 客户端会收到服务端发送的一切内容,并针对自己编译好的模式副本解码全部内容;客户端的暴露程度不由它请求了什么决定,而仅由它生成的代码能读懂什么决定。写变更日志时这个区别很重要:GraphQL 的条目可以合理假设客户端不会受它没请求的字段影响,gRPC 的条目完全不能做这种假设。
类型变更只能在文档化的兼容组里
字段类型能否在不破坏线上格式的前提下修改?只能在 Protobuf 文档化的特定兼容组里,例如某些情况下把 int32 扩成 int64。除非对照过 Protobuf 自己的兼容表,否则把任何类型变更当作破坏性的;靠语言类型系统的类比去假设兼容,正是出错的方式。
如果你控制每一个客户端,重新编号是否就安全?完全封闭的系统里原则上可以,但这样消除了字段编号存在的全部安全属性,而且「我们控制每个客户端」这句话,只要一个 build 被缓存、一个部署被推迟、或有人加了一个没人记得的客户端,就不再成立。即使在内部,也保留编号而不是重用它。
proto3 官方规则:wire-unsafe / wire-safe / wire-compatible
Wire-unsafe changes:用旧 schema 序列化的数据不能再用新 schema 解析,反之亦然;只有确定所有序列化/反序列化方都已切换到新 schema 才能做。
- 更改任何现有字段的字段编号不安全。改字段编号等价于删除该字段再加一个同类型的新字段,想重编号就按删除字段的说明做。
- 把字段移入一个已存在的 oneof 不安全。
Binary wire-safe changes:安全演进,不会丢数据或产生新的解析失败。
- 添加新字段是安全的。旧格式序列化的消息能被新代码解析;新代码产生的消息能被旧代码解析,旧二进制解析时会忽略新字段。
- 删除字段是安全的,但更新后的消息类型里不得再使用同一个字段编号。可以给字段加
"OBSOLETE_"前缀重命名,或把编号 reserved。 - 给 enum 添加值是安全的。
- 把一个单独的 explicit presence 字段或 extension 改成一个新 oneof 的成员是安全的。
- 把只含一个字段的 oneof 改成 explicit presence 字段是安全的。
- 把字段改成同编号同类型的 extension 是安全的。
任何 wire-safe 改动对某语言的应用代码仍可能是破坏性变更。例如给已有 enum 加值,对任何写穷举 switch 的代码都是编译中断;Google 因此在公共消息上会避免某些此类改动。
Binary wire-compatible changes 是条件安全:同一份数据在改动前后都能解析,但解析可能有损。把 int32 改成 int64 是兼容改动,但如果写入了大于 INT32_MAX 的值,按 int32 读的客户端会丢弃高位。只有能谨慎管理发布才能做:先只写合法的 int32 值,等新 schema 部署到所有端点后再开始写更大的值。schema 发布到组织外部时通常不应做 wire-compatible 改动,因为无法管理新 schema 的部署。
buf breaking 的四种检查层级
buf breaking 之类 CI 检查会拿 schema 和上一版比较并标出具体行。破坏性变更分四类:
WIRE:最严重。包括改字段编号或用不兼容类型(如 string → int32)。新旧端点在互相解码时会数据损坏;永远不要做。WIRE_JSON:JSON 表示的破坏。重命名字段在二进制线上安全,但期望旧 JSON key 的客户端会失败。可用[json_name="old_name"]注解缓解。PACKAGE:包级别的源码破坏。以 wire 兼容方式改类型(如 int32 → int64)传输安全,但开发者更新生成的代码后,构建会失败直到更新类型。FILE:最严格,保证到单个文件级别的源码兼容。把消息移到另一个文件可能破坏依赖特定文件 import 的代码生成。
删除字段的三步
因为线上按编号识别,删字段要小心:老客户端或数据库可能仍持有用那些编号序列化的数据,不能删完就复用编号。
- 弃用:加
[deprecated = true],通过生成的@Deprecated注解在 IDE 里警告开发者不要在新功能里用。 - 停用:等指标显示该字段流量为零。
- reserved:彻底移除字段,把编号/名字加进
reserved块,防止后来者误用编号、损坏可能仍在数据库里的旧数据。
message Product {
reserved 1, "price_cents";
int64 price_micros = 2;
}
二进制线格式的稳定性边界
二进制线协议格式不会改变,即使在主版本更新时也一样:你能从新版本读旧二进制数据,新生成序列化到二进制的绑定能被旧二进制解析。这是基本设计原则。注意 JSON 和 textproto 格式不提供相同的稳定性保证。descriptor.proto、.proto 语言语法、代码生成与运行时 API 都可能在主版本里以不兼容方式变更;修复或以其他方式影响未定义行为的改动不被视为破坏性更改。
参考链接
- Changeloop《Protobuf 破坏性变更:线上格式到底能保住什么》:
- protobuf.dev Language Guide (proto 3) — Updating A Message Type:
- protobuf.com — Advanced: Schema Evolution & Breaking Changes:
- protobuf 版本支持: