目录

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 bytesstring 要求内容是合法 UTF-8
嵌套 message ↔ bytes 2 bytes 内容就是该 message 的序列化结果

sint32int32 不兼容是最阴的一条:wire type 相同,解析不报错,但因为多了一层 ZigZag,会静默读出完全错误的数值(同样的字节,int32 读到 2,sint32 读到 1)。

3.5 singular ↔ repeated

  • string / bytes / message:安全。旧读端读到多个值时取最后一个(message 则递归合并)。
  • 数值类型(含 boolenum):不安全。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 改类型(如 int32stringint32fixed32 解析失败或读出垃圾值
int32sint32 不报错,静默读出错误数值
数值字段 singular ↔ repeated 解析失败(packed 差异)
改枚举值的数字 等同于改字段号,语义完全错位
把多个已有字段合并进一个 oneof 运行时互相清除;旧写端可能同时设置多个,行为未定义
把 oneof 拆开成独立字段 同上,反向也不安全
requiredoptional(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 前问自己五个问题:

  1. 这个需求能不能只靠加字段解决?(能就别改老字段)
  2. 我改的是字段号吗?(是 → 停)
  3. 我改的东西出现在字节流里吗?字段名不在,字段号在。但字段名出现在 JSON、生成代码和 gRPC 路由里。
  4. 消费者是跨团队/跨版本的吗?是则按 FILE 档要求自己,并走 deprecated 流程。
  5. 语义变了吗?(变了 → 加新字段或开 v2,别原地改)

10. 小结

  • Protobuf 的双向兼容能力来自「tag 带 wire type,未知字段可跳过并保留」,因此加字段是万能手法
  • 兼容性有四层:wire format(看字段号 + wire type)、JSON/text(看字段名)、生成代码 API(看所有名字)、gRPC 路由(看 包.服务/方法)。改名 wire 安全但会打断另外三层。
  • 同 wire type 内的类型大多可换,但 sintint 不能换(静默错值),数值的 singular ↔ repeated 不能换(packed)。
  • 删字段必须 reserved 编号和名字;对外 API 先 deprecated 一个周期。
  • 未知字段透传会被 JSON 转码环节手工重建消息破坏,转发用 proto.Clone
  • 发布顺序永远是先读端后写端;语义变更不要原地改,加新字段或开 v2。
  • 把上述规则交给 buf breaking 在 CI 里拦,人只负责工具拦不住的语义部分。

下一篇讲 Well-Known Types 和官方 JSON 映射规则。


系列目录

  1. 从 wire format 开始:一个字节一个字节读懂编码
  2. proto3 语法完全指南
  3. 存在性 presence:Protobuf 最难的一章
  4. schema 演进与兼容性:哪些改动会炸(本篇)
  5. Well-Known Types 与 JSON 映射
  6. Go 工程化实践:buf 工具链与 API 用法
  7. proto2 与 Protobuf Editions