bigywater.tech
gRPC · 27

protobuf 的向后兼容:服务升级时新旧客户端互通

服务升级时,新旧客户端和服务端混着跑,哪些改动是安全的?哪些会悄悄把数据弄错?这篇用动态构造的两个版本的 .proto,在字节层面逐项对比,并用真实 gRPC 验证了“加字段经过旧服务端不丢”。

11 字节
旧版本不认识新字段时保留的未知字段,经过旧服务端再回到新版本时仍可解出
5 → -3
int32 的 5 被读成 sint32 的值——类型能解析,但值悄悄变了
unknown field "name"
只改字段名,二进制兼容,但 JSON 解析会报这个错

线上只有编号

实测 · 本机执行输出
  新版本写出 0a0568656c6c6f10072209e696b0e5ad97e6aeb5(20 字节);旧版本读到:{msg=hello, n=7} + 未知字段 11 字节 2209e696b0e5ad97e6aeb5
  旧版本原样写回(20 字节),新版本再读:{msg=hello, n=7, extra=新字段}
  真实 gRPC:新版客户端(带 extra)→ 用旧版生成代码的服务端 → 服务端把收到的请求原样放进响应:err=<nil>;新版客户端解出 {msg=hi, n=1, extra=新字段}

protobuf 二进制只写字段编号和值,不写名字。编号和线上类型(wire type)决定兼容性,名字只影响 JSON 与代码。

加字段

实测 · 本机执行输出
  二进制:旧写 0a05616c696365,新读:{full_name=alice}
  JSON:旧写 {"name":"alice"},新版 protojson 解析的结果:{};err=proto: (line 1:2): unknown field "name"

安全:旧版本会保留不认识的字段,转发或写回时原样带出,经过旧服务端也不丢。

改名

实测 · 本机执行输出
  旧写 n=42 的 0a0178102a,新读:{msg=x} + 未知字段 2 字节 102a

二进制兼容,但 JSON 按名字,改名对 JSON 消费方(网关、日志、前端)是破坏性的。

改编号与复用编号

实测 · 本机执行输出
  int32 → int64          线上 0805,读到:{v=5}
  int32 → int64(值为 -1)   线上 08ffffffffffffffffff01,读到:{v=-1}
  int32 → sint32         线上 0805,读到:{v=-3}
  int32 → sint32(值为 -1)  线上 08ffffffffffffffffff01,读到:{v=-2147483648}
  int32 → bool           线上 0805,读到:{v=true}
  int32 → string         线上 0805,读到:{} + 未知字段 2 字节 0805
  int32 → fixed32        线上 0805,读到:{} + 未知字段 2 字节 0805
  string → bytes         线上 0a03616263,读到:{v=[97 98 99]}

改编号或复用已删字段的编号,会悄悄丢数据或读到含义错位的值,不一定有报错。删字段后要 reserved 编号。

改类型

实测 · 本机执行输出
  旧写 0a0178102a,新读(编号 2 当 string):{msg=x} + 未知字段 2 字节 102a
  如果复用成另一个 int32 字段(含义完全不同):{msg=x, label=42} ← 读到的是旧字段的值,但含义已不同,不会有任何报错

编码相同的类型可以互改(int32/int64、string/bytes);换成 sint32、bool、fixed32、string 会读出错值或变成未知字段。

枚举

实测 · 本机执行输出
  新写 0803(枚举值 3),旧读:{s=(未知枚举值 3)}
  旧版本原样写回,新版本再读:{s=RETRY}

proto3 的枚举是开放的,新值保留数值,旧代码要有 default 分支。

标量与 repeated

实测 · 本机执行输出
  标量 5 → repeated:线上 1005,读到:{nums=[5]}
  repeated [1 2 3] → 标量:线上 1203010203,读到:{} + 未知字段 5 字节 1203010203

标量可以改成 repeated(旧发送方的数据仍可读),反过来不行(packed 的 repeated 读不进标量)。

分步演示

兼容性
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1 / grpc-go v1.84.0 / protobuf-go v1.36.12 / golang.org/x/net v0.57.0 / protoc 36.2(Homebrew);服务端和客户端都是本机起的 Go 进程(127.0.0.1,端口 9800 以上,不访问任何外部服务);需要看线上字节时用我自己写的 TCP 抓包代理加 golang.org/x/net/http2 的 Framer 解析 HTTP/2 帧;计时用 time.Now 或 perf_counter,数字每次运行会有小幅抖动。两个版本的“.proto”是用 protodesc 在代码里构造的描述符(每个都在独立的 package),消息用 dynamicpb;“线上”的十六进制是 proto.Marshal 的真实输出;第一节的 gRPC 往返用旧版生成代码的服务端(把请求原样序列化放进响应)与带 extra 字段的动态消息客户端;所有结果都来自 protobuf-go(Go 实现)
    • 没有验证:proto2 与 required 字段;oneof 的兼容规则;map 字段;嵌套消息改成不同类型;其它语言的 protobuf 实现(Java、Python 对未知字段和未知枚举的处理有差别);protojson 的 DiscardUnknown 选项;golang/protobuf 老版本;buf breaking 等工具
    京ICP备2026067502号-1京公网安备11010502063487号