本文汇总翻译自 Buf 官方博客的 Protobuf Tip 系列文章(共 10 篇)。该系列由 Buf 团队撰写,Tip #1–#9 的作者是 M. C. Sunny Young de la Sota,Tip #10 的作者是 Kevin McDonald。每节开头附有原文链接。系列文章中的插图由 D2 图表语言生成,本文直接引用原文 SVG 链接。

系列概览

# 标题 一句话总结 原文
1 字段名是永恒的 永远不要重命名字段 链接
2 压缩你的 Protos! 压缩无处不在,编码体积的权衡没那么重要 链接
3 枚举名需要前缀 枚举值的 FQN 不含枚举名,必须加前缀避免冲突 链接
4 接受我们无法修复的错误 分布式场景下有些错误不值得修,不破坏用户更重要 链接
5 避免 import public/weak 这两个特性是 C++ 历史包袱,不要用 链接
6 枚举别名的隐性危险 allow_alias 会坑到反射和 JSON,别用 链接
7 看清二进制的工具 buf convert + protoscope 调试 wire format 链接
8 永远不要用 required required 是 proto2 的大坑,无法安全移除 链接
9 有些数字比其他数字更"平等" 字段编号 1–15 解码最快 链接
10 选择正确的整数类型 默认用 int64 就对了 链接

Tip #1:字段名是永恒的

原文:Protobuf Tip #1: Field names are forever (2025-04-08)

“我每天早上醒来先抓起晨报,直接翻到讣告页。如果上面没有我的名字,我就起床。——本杰明·富兰克林”

TL;DR:不要重命名字段。 尽管有极少数情况你可以侥幸逃脱,但这几乎从来不值得,而且是潜在的 bug 来源。

名字与标签

Protobuf 消息字段有字段标签(field tag),用于在二进制 wire format 中区分字段。这意味着 wire format 的序列化实际上并不依赖字段的名字。例如,下面两个消息会使用完全相同的序列化格式:

message Foo {
  string bar = 1;
}

message Foo2 {
  string bar2 = 1;
}

事实上,Protobuf 的设计者本意是让重命名使用中的字段成为可能。但他们没有成功:重命名仍然可能是破坏性变更(breaking change)。

Schema 消费者需要更新

如果你的 schema 是公开的,生成的代码会变。例如,把字段从 first_name 改名为 given_name 会导致对应的 Go 访问器从 FirstName 变成 GivenName,可能破坏下游消费者。

仅仅因为这种破坏,把字段改成"更好"的名字几乎从来不是值得的变更。

JSON 序列化会坏

Wire format 序列化不看名字,但 JSON 看!这意味着上面的 FooFoo2 分别序列化为 {"bar":"content"}{"bar2":"content"},二者不可互换。

可以通过字段的 [json_name = "..."] 选项部分缓解。但这并不能真正解决问题,因为许多 Protobuf 运行时的 JSON 编解码器会同时接受 json_name 设置的名字字段名本身。所以 string given_name = 1 [json_name = "firstName"]; 会允许从名为 given_name 的 key 反序列化,但不再接受过去的 first_name。这仍然是一个破坏性的协议变更!

这是 Protobuf 本可以做得更好的地方——如果 json_namerepeated string,这种 wire format 层面的破坏本可以避免。不过,出于下文的原因,重命名依然是个坏主意。

反射!

即使你能避免源码和 JSON 层面的破坏,名字对反射(reflection)始终可见。虽然一般来说很难防范反射层面的破坏(反射甚至能看到字段声明的顺序),但这是反射中特别阴险的一部分——例如,调用方可能按字段名排序,或者某些中间件用字段名来标识频率,或者日志/脱敏的需求。

不要改名字,因为反射意味着你无法预知会出什么问题!

但我真的必须改!

想重命名字段是有正当理由的,比如扩展字段的语义。例如,first_namegiven_name 并不是同一个概念:在汉字文化圈以及匈牙利,一个人全名里排在前面的姓,而不是名。

或者,一个之前表示金额的字段,比如 cost_usd,要更新为不指定货币:

message Before {
  sint64 cost_usd = 1;
}

message After {
  enum Currency {
    CURRENCY_UNSPECIFIED = 0;
    CURRENCY_USD = 1;
    CURRENCY_EUR = 2;
    CURRENCY_JPY = 3;
    CURRENCY_USD_1000TH = 4; // 0.1 美分。
  }

  sint64 cost = 1;
  Currency currency = 2;
}

在这种情况下,重命名字段是个糟糕透顶的主意。先不说源码或 JSON 的破坏,新字段的语义完全不同了。如果一个期望 USD 价格的旧消费者,收到从 {"cost":990,"currency":"CURRENCY_USD_1000TH"} 序列化来的新 wire format 消息,它会把价格错误地解读为 990 美元,而不是 0.99 美元。那是灾难性的 bug!

正确的做法是把 costcurrencycost_usd 并列添加。读取方在读 cost 时应先检查 cost_usd,并据此推断 currencyCURRENCY_USD(如果 costcost_usd 同时存在,最好报错)。

之后可以把 cost_usd 标记为 [deprecated = true]。某些情况下甚至可以删除 cost_usd,比如你控制所有的读写方——但如果你做不到,风险非常高。而且,你多少需要能把 cost_usd 永久地重新解读为 cost 的值。

如果你最终真的删除了,务必保留(reserve)字段编号和名字,避免被意外复用:

reserved 1;
reserved "cost_usd";

但尽量别删。重命名字段带来的只有眼泪和痛苦。


Tip #2:压缩你的 Protos!

原文:Protobuf Tip #2: Compress your Protos! (2025-04-15)

“事实上,当压缩技术出现时,我们以为 1996 年的未来是关于语音的。我们想错了。未来是语音、视频和数据,也就是今天手机上的这一切。——Steve Buyer”

TL;DR: 压缩无处不在:CDN、HTTP 服务器,甚至 Connect 这样的 RPC 框架里都有。这种普遍性意味着,wire 体积的权衡已经不像二十年前 Protobuf 设计时那么重要了。

1998 年的 varint

Protobuf 的 wire format 旨在做到相对较小。它使用变宽整数(variable-width integer),让较小的值在网络上占用更少空间。定宽整数在网络上可能更大,但解码通常更快。

但如果我告诉你这些根本不重要呢?

要知道,大多数互联网流量都是被压缩过的。带宽很宝贵,CDN 运营商不想浪费时间发送塞满零的大块数据。压缩算法有很多,但 HTTP 请求(占全球互联网流量大头)的业界标杆是 Brotli ——Google 于 2013 年开发、2016 年在 IETF RFC7932 中标准化的算法。你现在看到的这篇文章,很可能就是以 Brotli 压缩块的形式送到浏览器的。

使用压缩

压缩在你的场景里如何落地因人而异,但 Connect RPC 和 gRPC 都原生支持压缩。例如 Connect 提供了注入压缩提供者的 API:connectrpc.com/connect#WithCompression

Connect 默认使用 gzip(DEFLATE 算法)。提供自己的压缩算法(比如 Brotli)也很简单,参见这个第三方包

其他服务可能会透明地替你压缩。任何像样的 CDN 都很可能用 Brotli(或 gzip 或 zlib,但大概率是 Brotli)压缩它为你服务的文件。(事实上,JS 和 HTML 的最小化也常常会被 HTTP 压缩变得无关紧要。)

重要的是记住:Protobuf 诞生于压缩普及之前。如果不是这样,它几乎肯定不会用变宽整数。它使用 varint 只是因为它们提供了一种原始的压缩形式,代价是解码更慢。如果这个权衡消失了,Protobuf 在网络上几乎肯定只会用定宽整数。

实际效果如何?

来做一些对等比较。考虑下面的 Protobuf 类型:

message Foo {
  repeated int32 varints = 1;
  repeated sfixed32 fixeds = 2;
}

两个字段包含本质相同的数据,可以用四种方式编码:旧式 repeated 字段、packed 字段,以及整数用 varint 或 fixed32 编码。

Protoscope 可以构造覆盖这四种情况的数据:

# a.pb, repeated varint
1: 1
1: 2
1: 3
# ...

# b.pb, packed varint
1: {
  1 2 3
  # ...
}

# c.pb, repeated fixed32
2: 1i32
2: 2i32
2: 3i32

# d.pb, packed fixed32
2: {
  1i32 2i32 3i32
  # ...
}

每个数据块包含 1 到 1000 的整数,以不同方式编码。分别用 gzip、zlib 和 Brotli 以默认压缩级别压缩,字节大小如下:

文件 未压缩 gzip (DEFLATE) zlib Brotli
a.pb 2875 1899 1878 1094
b.pb 1877 1534 1524 885
c.pb 5005 1577 1567 1140
d.pb 4007 1440 1916 1140

压缩效果惊人:Brotli 把所有文件都压到约 1.1 kB,packed varint 那份甚至再小约 250 字节!当然,这只是因为 repeated 字段里大部分值都很小。如果值域是 100000 到 101000,b.pb 和 d.pb 分别是 3006 和 4007 字节(注意 d.pb 的大小没变!),但用 Brotli 压缩后,b.pb 的领先开始消失:1039 字节对 1163 字节,只小 120 字节了。

varint 还更好吗?

应用压缩的效果通常与全部换成 varint 相当,但不完全一样:使用 varint 大概率总是略小一点,至少在使用 Brotli 这类最先进压缩算法时如此。但你几乎总是可以假设自己会用压缩——比如请求里的 HTTP 头和其他附属内容都会被压缩。压缩是通用且高度优化的——它作用于所有数据,不管 schema 是什么,而且往往比 Protobuf 库这类应用层编解码器优化得多。

更不用说,你在磁盘上存储的大数据块也 definitely 应该压缩!

因此,在设计 Protobuf 类型做权衡时,通常可以忽略很多编码体积问题。定宽整数解码更快,所以如果解码速度对你重要,而你又担心网络体积——别担心,协议栈的其他层几乎肯定已经搞定了。


Tip #3:枚举名需要前缀

原文:Protobuf Tip #3: Enum names need prefixes (2025-04-22)

“聪明人从自己的错误中学习。但真正敏锐的人从别人的错误中学习。——Brandon Mull”

TL;DR: enum 从 C++ 继承了一些不幸的行为。使用 Buf 的 lint 规则 ENUM_VALUE_PREFIXENUM_ZERO_VALUE_SUFFIX 来规避这个问题(它们属于 DEFAULT 类别)。

C++ 风格的枚举

Protobuf 的 enum 定义表示一小组合法值的数据类型。例如 google.rpc.Code 列出了 gRPC 等 RPC 框架使用的状态码。在底层,每个 enum 在网络上只是一个 int32,尽管代码生成后端会为枚举生成自定义类型和常量以便使用。

不幸的是,enum 最初被设计为与 C++ 枚举完全一致,并不小心复刻了 C++ 的许多行为。

枚举名需要前缀

如果你看 google.rpc.Code 的源码,再对比 google.protobuf.FieldDescriptorProto.Type,会注意到一个微妙的差别:

package google.rpc;
enum Code {
  OK = 0;
  CANCELLED = 1;
  UNKNOWN = 2;
  // ...
}

package google.protobuf;
message FieldDescriptorProto {
  enum Type {
    // 0 保留用于错误。
    TYPE_DOUBLE = 1;
    TYPE_FLOAT = 2;
    TYPE_INT64 = 3;
    // ...
  }
}

FieldDescriptorProto.Type 的值以 TYPE_ 开头,但 Code 的值没有 CODE_ 前缀。这是因为枚举值的全限定名(FQN)不包含枚举的名字。也就是说,TYPE_DOUBLE 实际上指的是 google.protobuf.FieldDescriptorProto.TYPE_DOUBLE(注意路径里 Type 不出现?不——嵌套枚举在消息内;而对顶层枚举,如 OK,它不是 google.rpc.Code.OK,而是 google.rpc.OK)。

这是因为这匹配了 C++ 无作用域枚举(unscoped enum)的行为。C++ 是"参考"实现,所以语言经常为 C++ 后端让路。

生成代码时,protoc 的 C++ 后端输出如下:

namespace google::rpc {
enum Code {
  OK = 0,
  CANCELLED = 1,
  UNSPECIFIED = 2,
  // ...
};
}

namespace google::protobuf {
class FieldDescriptorProto final {
 public:
  enum Type {
   TYPE_DOUBLE = 1;
   TYPE_FLOAT = 2;
   // ...
  };
};
}

在 C++ 中,enum 不给枚举值加作用域:你写 google::rpc::OK,而不是 google::rpc::Code::OK

如果你懂 C++,可能会想:“为什么不用 enum class?!” 枚举是 proto2(约 2007–2008 年开发)加入的,而 Google 直到很久很久以后才开始用引入 enum class 的 C++11。

现在,如果你是 Go 或 Java 程序员,可能想知道为什么要在意 C++。Go 和 Java 都会把枚举值约束在枚举类型作用域内(虽然 Go 的方式有点糙:rpcpb.Code_OK)。

不幸的是,这影响了 Protobuf 的名字冲突检测。你不能写这样的代码:

package myapi.v1;

enum Stoplight {
  UNSPECIFIED = 0;
  RED = 1;
  YELLOW = 2;
  GREEN = 3;
}

enum Speed {
  UNSPECIFIED = 0;
  SLOW = 1;
  FAST = 2;
}

因为枚举名不是枚举值 FQN 的一部分,这里的两个 UNSPECIFIED 的 FQN 都是 myapi.v1.UNSPECIFIED,所以 Protobuf 会报重复符号错误。

于是就有了 FieldDescriptorProto.Type 中的惯例:

package myapi.v1;

enum Stoplight {
  STOPLIGHT_UNSPECIFIED = 0;
  STOPLIGHT_RED = 1;
  STOPLIGHT_YELLOW = 2;
  STOPLIGHT_GREEN = 3;
}

enum Speed {
  SPEED_UNSPECIFIED = 0;
  SPEED_SLOW = 1;
  SPEED_FAST = 2;
}

Buf 提供了强制执行这个惯例的 lint 规则:ENUM_VALUE_PREFIX 。即使你觉得某个枚举名是唯一的,由于顶层枚举会把名字泄漏到所在包中,这个问题会跨包蔓延!

零值

proto3 严重依赖"零值"概念——所有既非 repeated 也非 optional 的非消息字段,如果不存在则隐式为零。因此 proto3 要求枚举必须有一个等于零的值。

按照惯例,这个值不应该是枚举的具体业务值,而应该是表示"未指定"的值。ENUM_ZERO_VALUE_SUFFIX 规则强制这一点,默认后缀是 _UNSPECIFIED。当然,有些场景下这可能不合适,_ZERO_UNKNOWN 这样的后缀可能更合理。

用一个具体的"良好默认值"做零值很诱人。但要小心,这个选择是永久的。选一个通用的"未知"作为默认值,能降低烧到自己的概率。

为什么 Google 自己的 Protobuf 文件不都这么做?

名字前缀和零值还教给我们重要一课:因为 Protobuf 名字是永恒的 ,修复风格错误非常困难,尤其是整个社区越来越擅长使用 Protobuf 的时候。

google.rpc.Code 要与非常古老的既有 C++ 代码保持源码兼容,所以豁出去了。FieldDescriptorProto.Type 没有零值,是因为在 proto2(其 wire format 没有零值陷阱)里不需要担心这个。这一课不只是"用 Buf 的 linter 避开已知坑",还要记住:即使是语言作者设计的 API 也会犯下无法修复的错误,所以与其他编程语言不同,模仿"现有实践"并不总是最佳策略。


Tip #4:接受我们无法修复的错误

原文:Protobuf Tip #4: Accepting mistakes we can’t fix (2025-04-29)

“糟糕的幽默是对现实的逃避;好的幽默是对现实的接纳。——Malcolm Muggeridge”

TL;DR: Protobuf 的分布式特性带来的演进风险,使得某些错误难以修复。有时最好的做法就是随它去。

换一种思维

通常,你会为自己参与的软件设计并实现一个功能,尽管你尽力测试,生产环境还是发生了可怕的事情。不过我们有一套预案:修掉程序里的 bug,把修复后的新版本发布或部署给用户。大故障可能意味着熬夜,但对大多数组织来说,周转时间是一天到一周。

不过大多数 bug 并不是紧急事件。有时只是函数名让人困惑,或者整数类型对真实数据来说稍微小了点,或者 API 把"零"和"null"混为一谈。你修复 API,在一个提交里重构所有调用处,合并,修复逐渐铺开。

当然,除非它是通信 API 里的 bug,比如序列化格式:你的 Protobuf 类型、JSON schema,或者从 YAML 文件构建的 dict 里解析字段的那些不太好看的代码。在这里,你无法原子地修复整个世界。修复 API(此处"API"指"Protobuf 定义")中的 bug,需要与修复普通代码中的 bug 不同的思维方式。

风险是什么?

Protobuf 的 wire format 设计成可以安全地向类型添加新字段或向枚举添加新值,而无需原子升级。但其他变更,比如重命名字段或修改类型,则非常危险。

这是因为 Protobuf 类型存在于时间轴上:同一类型的不同版本,同时存在于现场正在互相通信的程序中。这意味着来自未来的写入方(新的序列化代码)必须小心,不要迷惑众多的来自过去的读取方(旧版本的反序列化代码)。反过来,未来的读取方必须容忍过去的写入方产生的任何内容。

在现代分布式部署中,同时存在的版本数量可能相当大。自托管集群如此,涉及用户可升级软件时更甚——与你的服务器通信的移动应用、由第三方管理员管理的设备软件,甚至只是浏览器与服务的通信。

最重要的原则是:你很难控制旧版本的类型或服务何时不再相关。一个类型一旦逃出哪怕单个团队的范围,升级类型就成了整个部门的事。

学会与炸弹共存

Protobuf 有很多本可以让 schema 演进更容易、却没有做到的地方。例如,把 int32 foo = 1; 改成 sfixed32 foo = 1; 是破坏性变更,尽管在 wire format 层面,解析器有可能区分并正确接受两种形式的 foo。类似例子不胜枚举,重要的是理解:语言并不总是站在我们这边。

比如,我们发现某个 int32 值太小,本该是 64 位的,你无法升级它而不冒着旧读取方截断值的风险。但我们真的必须升级!有哪些选择?

  1. 发布该消息及所有依赖的新版本。这就是为什么在包名中固定版本号(如 Buf 的 PACKAGE_VERSION_SUFFIX lint 规则所强制的)如此重要。
  2. 硬着头皮升级并祈祷不出事。如果底层格式兼容,这在某些升级上可以奏效,但如果你不完全清楚自己在干什么,后果可能是灾难性的,尤其是对不完全局限于团队项目内部的类型。Buf 的破坏性变更检测 能帮你规避有破坏风险的变更。

当然还有第三个选项:接受有些东西不值得修。当修复成本高到离谱时,修复就不值得了,尤其当语言还在跟你作对的时候。

这意味着即使在 Buf 自己的 API 里,我们有时也会用不太理想、或与我们自己的最佳实践不一致的方式做事。有时生态的演进改变了最佳实践,但我们无法在不破坏用户的情况下升级。同样,如果新的、更好的语言特性会导致协议破坏,你不应该急于使用:有时正确的做法是什么都不做,因为不破坏你的用户更重要。


Tip #5:避免 import public/weak

原文:Protobuf Tip #5: Avoid import public/weak (2025-05-13)

“我爸有把吉他,但是木吉他的,所以我砸了面镜子,把碎玻璃粘上去让它看起来更金属。看起来蠢透了!——Max Cavalera”

TL;DR: 避免 import publicimport weak。Buf 的 lint 规则 IMPORT_NO_PUBLICIMPORT_NO_WEAK 默认帮你强制执行。

Protobuf 的 import 允许指定两种特殊模式:import publicimport weak。Buf CLI 默认会对它们做 lint,但你可能还是想试试——尤其因为有些 GCP API 用了 import public 。这些模式是什么?为什么存在?

Import 可见性

Protobuf 的 import 按文件路径进行,这一点深深烙印在语言及其反射模型中。

import "my/other/api.proto";

导入一个文件会把它所有的符号倾倒进当前文件。就名字解析而言,就像那个文件里的所有声明都被粘贴进了当前文件。但这不是传递性的。如果:

  • a.proto 导入 b.proto……
  • b.proto 导入 c.proto……
  • c.proto 定义了 foo.Bar……
  • 那么 a.proto 必须自己导入 c.proto 才能引用 foo.Bar,即使 b.proto 导入了它。

这类似于 Go 中以 . 方式导入包。写 import . "strings" 时,strings 包的所有声明被倾倒进当前文件,但 strings 导入的那些文件的声明不会。

Go 的好处是包可以拆分成多个文件,且对用户透明;包的用户导入的是,而不是包的文件。不幸的是 Protobuf 不是这样,所以包的文件结构会泄漏给调用方。

import public 本意是让 API 作者可以拆分失控的文件:为 big.proto 里的部分定义新建一个 new.proto,把它们挪过去,然后在 big.proto 里加 import public "new.proto";。既有的 big.proto 导入者不会坏掉,万岁!

但这个特性是为 C++ 设计的。在 C++ 中,每个 .proto 文件对应一个 .proto.h 头文件,你在应用代码里 #include 它。C++ 的 #include 行为就像 import public,所以把一个 import 标记为 public 只改变 Protobuf 的名字解析——C++ 后端无需做任何事就能在 import 改为 public 时保持源码兼容。

但其他后端(如 Go)不是这样工作的:Go 的 import 不会传递地引入符号,所以 Go 后端需要为通过 public import 进来的所有符号显式添加别名。也就是说,如果你有:

// foo.proto
package myapi.v1;
message Foo { ... }

// bar.proto
package myotherapi.v1;
import public "foo.proto";

那么 Go 后端必须在 bar.pb.go 里生成 type Foo = foopb.Foo 来模拟这个行为(事实上,得知 Go Protobuf 居然实现了这个时我也很惊讶)。Go 碰巧正确实现了 public import,但不是所有后端都这么细心,因为这个特性太冷门了。

spanner.proto 那个 import public 的例子甚至不是用来拆分文件的,而是用来不让一个大文件更大、避免调用方多写一行 import。这是对一个糟糕特性的糟糕使用

import public 来变相"隐藏" import,让人更难理解一个 .proto 文件到底引入了什么。如果 Protobuf 的 import 像 Go 或 Java 那样在包/符号级别,这个特性根本不需要存在。不幸的是,Protobuf 是紧贴 C++ 裁剪的,这就是后果之一。

与其用 import public 拆文件,不如计划在 API 的下一个版本里拆。

Buf 的 IMPORT_NO_PUBLIC lint 规则默认禁止任何人使用这个特性。它很诱人,但坑不值得。

Weak import

Public import 至少还有一个说得过去(尽管有缺陷)的存在理由,是它的实现细节拖了后腿。

而 weak import 根本就不应该存在。它被加入语言,是为了让 Google 一些巨型二进制避免链接器内存耗尽——通过允许消息类型在未被访问时被丢弃。这意味着 weak import 是"可选的"——如果运行时缺少对应的 descriptor,C++ 运行时可以优雅处理。

这带来了各种实现复杂度和跨运行时的微妙行为差异。大多数运行时对 import weak 的实现(或者曾经实现,对于那些已移除支持的运行时)要么有 bug 要么不一致。尽管 Google 努力过,这个特性不太可能被真正移除。

不要用 import weak。应该把它当作完全不可用。Buf 的 IMPORT_NO_WEAK lint 规则会替你把关。


Tip #6:枚举别名的隐性危险

原文:Protobuf Tip #6: The subtle dangers of enum aliases (2025-05-19)

“我整个职业生涯都非常幸运地躲过了绰号。我从来没有过。——Jimmie Johnson”

TL;DR: 枚举值可以有别名。这个特性设计得很差,不应该使用。ENUM_NO_ALLOW_ALIAS lint 规则默认禁止你使用它们。

困惑与破坏

Protobuf 允许多个枚举值拥有相同的编号。这样的枚举值互称别名(alias)。Protobuf 过去默认允许,现在必须设置特殊选项 allow_alias 编译器才不拒绝。

这可以用来在不破坏既有代码的前提下有效地"重命名"枚举值:

package myapi.v1;

enum MyEnum {
  option allow_alias = true;
  MY_ENUM_UNSPECIFIED = 0;
  MY_ENUM_BAD = 1 [deprecated = true];
  MY_ENUM_MORE_SPECIFIC = 1;
}

这完全没问题,而且是完全 wire 兼容的!与重命名字段(见 Tip #1 )不同,它不会造成源码破坏。

但如果你使用反射或 JSON,或者 Java 这种不能干净地支持多名枚举的运行时,你会有 nasty 的惊喜。

例如,用反射请求枚举值时(比如 protoreflect.EnumValueDescriptors.ByNumber()),你得到的会是文件中词法上先出现的那个。事实上,myapipb.MyEnum_MY_ENUM_BAD.String()myapipb.MyEnum_MY_ENUM_MORE_SPECIFIC.String() 返回相同的值,带来潜在的混乱——旧的"坏"值会出现在日志等打印输出中。

你可能会想:“哦,那我把别名的顺序换一下。“但那是一次真正的 wire format 破坏——不是二进制格式,而是 JSON。因为 JSON 会优先用枚举值的声明名(如果值在范围内)做字符串化。所以,重新排序意味着曾经序列化为 {"my_field": "MY_ENUM_BAD"} 的内容现在序列化为 {"my_field": "MY_ENUM_MORE_SPECIFIC"}

一个尚未添加新枚举值的旧二进制程序看到这个 JSON 文档时会解析失败,你就等着倒霉吧。

你可以说这是语言 bug,某种程度上确实是。Protobuf 应该为枚举值提供 json_name 的等价物,或者强制 JSON 将有多个名字的枚举值序列化为数字,而不是任选一个枚举名。这个特性本意是允许重命名枚举值,但不幸的是 Protobuf 把它残废到相当危险的程度。

相反,如果你真的需要出于可用性或合规原因重命名枚举值(最好不只是为了美观),更好的做法是在 API 的新版本里做一个新的枚举类型。只要枚举值的编号相同,就是二进制兼容的,而且多少能降低上述 JSON 混乱的风险。

Buf 提供了针对这个特性的 lint 规则 ENUM_NO_ALLOW_ALIAS ,而且 Protobuf 要求指定魔法选项才能启用该行为,所以实践中你不需要担心。但记住,枚举别名的后果远不止 JSON——它影响一切使用反射的东西。所以即使你不用 JSON,也可能被烧到。


Tip #7:看清二进制的工具

原文:Protobuf Tip #7: Scoping it out (2025-06-02)

“你需要一台非常专业的电子显微镜,才能真正看到单独一条 DNA 链。——Craig Venter”

TL;DR: buf convert 是检查 wire format 转储的强大工具——转成 JSON 后即可使用现成的 JSON 分析工具链。protoscope 可用于更底层的分析,比如调试被损坏的消息。

从 Protobuf 得到 JSON?

JSON 人类可读的语法是其流行的重要原因,可能仅次于浏览器和众多语言的内置支持。用在线美化工具和无可替代的 jq 检查任何 JSON 文档都很容易。

但 Protobuf 是二进制格式!这意味着你不能轻松地对它使用 jq 这类工具……真的不能吗?

buf convert 转码

Buf CLI 提供了一个在三种 Protobuf 编码格式(wire format、JSON 和 textproto)之间转码消息的工具,还支持 YAML。它就是 buf convert,非常强大。

执行一次转换需要四个输入:

  1. 一个用于获取类型的 Protobuf 源。可以是本地 .proto 文件、编码过的 FileDescriptorSet,或远程 BSR 模块。
    • 如果不提供但在本地 Buf 模块目录内运行,将使用该模块作为类型源。
  2. 要转码的消息的顶层类型名,通过 --type 标志指定。
  3. 输入消息,通过 --from 标志指定。
  4. 输出位置,通过 --to 标志指定。

buf convert 支持输入输出重定向,可以用作 shell 管道的一部分。假设本地 Buf 模块里有如下 Protobuf 代码:

// my_api.proto
syntax = "proto3";
package my.api.v1;

message Cart {
  int32 user_id = 1;
  repeated Order orders = 2;
}

message Order {
  fixed64 sku = 1;
  string sku_name = 2;
  int64 count = 3;
}

然后,假设我们从某个服务转储了一个 my.api.v1.Cart 类型的消息来调试。而且……你没法直接 cat 它。

$ cat dump.pb | xxd -ps
08a946121b097ac8e80400000000120e76616375756d20636c65616e6572
18011220096709b519000000001213686570612066696c7465722c203220
7061636b1806122c093aa8188900000000121f69736f70726f70796c2061
6c636f686f6c203730252c20312067616c6c6f6e1802

但是,我们可以用 buf convert 把它变成漂亮的 JSON,再管道给 jq 格式化:

$ buf convert --type my.api.v1.Cart --from dump.pb --to -#format=json | jq
{
  "userId": 9001,
  "orders": [
    {
      "sku": "82364538",
      "skuName": "vacuum cleaner",
      "count": "1"
    },
    {
      "sku": "431294823",
      "skuName": "hepa filter, 2 pack",
      "count": "6"
    },
    {
      "sku": "2300094522",
      "skuName": "isopropyl alcohol 70%, 1 gallon",
      "count": "2"
    }
  ]
}

现在 jq 的全部表达力都任你使用。比如提取购物车的用户 ID:

$ function buf-jq() { buf convert --type $1 --from $2 --to -#format=json | jq $3 }
$ buf-jq my.api.v1.Cart dump.pb '.userId'
9001

或者提取购物车中出现的所有 SKU:

$ buf-jq my.api.v1.Cart dump.pb '[.orders[].sku]'
[
  "82364538",
  "431294823",
  "2300094522"
]

或者计算购物车里总共有多少件商品:

$ buf-jq my.api.v1.Cart dump.pb '[.orders[].count] | add'
"162"

等等,这不对。答案应该是 9。这暴露了在 Protobuf 上使用 jq 的一个陷阱:Protobuf 有时会把数字序列化成带引号的字符串(C++ 参考实现只对超出 IEEE754 可表示范围的整数这么做,但 Go 更懒一些,对所有 64 位值都这么做)。

可以用这个非常简单的检查测试 x int64 是否在可表示的浮点范围内:int64(float64(x)) == x。参见 go.dev/play/p/T81SbbFg3brC++ 的等价版本 要复杂得多。

这意味着我们需要用 tonumber 转换函数:

$ buf-jq my.api.v1.Cart dump.pb '[.orders[].count | tonumber] | add'
9

jq 的立身之本就是 JSON,所以它也带来了 JSON 的所有陷阱。在 Protobuf 上对 64 位值做算术时尤其要小心。如上所见,Protobuf 会把超出 64 位浮点可表示范围的整数序列化成字符串(某些运行时中,范围内的部分整数也一样)。

例如,如果你有一个 repeated int64 想求和,可能因浮点舍入得到错误答案。关于 jq 转换的说明参见 jqlang.org/manual

protoscope 反汇编

protoscope 是 Protobuf 团队提供的工具,可以把任意数据当作 Protobuf wire format 编码来解码。这个过程叫反汇编(disassembly)。它设计为在没有 schema 的情况下工作,尽管输出不算特别干净。

$ go install github.com/protocolbuffers/protoscope/cmd/protoscope...@latest
$ protoscope dump.pb
1: 9001
2: {
  1: 82364538i64
  2: {"vacuum cleaner"}
  3: 1
}
2: {
  1: 431294823i64
  2: {
    13: 101
    14: 97
    4: 102
    13: 1.3518748403899336e-153   # 0x2032202c7265746ci64
    14: 97
    12:SGROUP
    13:SGROUP
  }
  3: 6
}
2: {
  1: 2300094522i64
  2: {"isopropyl alcohol 70%, 1 gallon"}
  3: 2
}

字段名没了,只显示字段编号。这个例子还暴露了 protoscope 一个非常明显的局限:它分不清字符串字段和消息字段,只能按启发式规则猜。第一和第三个元素它成功识别为字符串,但 orders[1].sku_name 被错误地猜成了消息,产出乱码。

作为交换,protoscope 不仅不需要 schema,还容忍几乎任何错误,因此可以分析被部分损坏的消息。如果我们在 orders[0] 的某处翻转一个随机比特,反汇编仍然成功:

$ protoscope dump.pb
1: 9001
2: {`0f7ac8e80400000000120e76616375756d20636c65616e65721801`}
2: {
  1: 431294823i64
  ...
}

尽管 protoscope 放弃了对损坏子消息的反汇编,它还是把转储的其余部分走完了。

buf convert 一样,我们可以给 protoscope 一个 FileDescriptorSet,让它的启发式更聪明:

$ protoscope \
  --descriptor-set <(buf build -o -) \
  --message-type my.api.v1.Cart \
  --print-field-names \
  dump.pb
1: 9001                   # user_id
2: {                      # orders
  1: 82364538i64          # sku
  2: {"vacuum cleaner"}   # sku_name
  3: 1                    # count
}
2: {                          # orders
  1: 431294823i64             # sku
  2: {"hepa filter, 2 pack"}  # sku_name
  3: 6                        # count
}
2: {                                      # orders
  1: 2300094522i64                        # sku
  2: {"isopropyl alcohol 70%, 1 gallon"}  # sku_name
  3: 2                                    # count
}

现在第二个订单不仅解码正确,protoscope 还显示了每个字段的名字(通过 --print-field-names)。在此模式下,protoscope 依然可以解码部分有效的消息。

protoscope 还提供了许多其他标志,在没有 FileDescriptorSet 时自定义启发式。这使它可以作为取证工具,调试棘手的数据损坏 bug。


Tip #8:永远不要用 required

原文:Protobuf Tip #8: Never use required (2025-06-03)

“毫无疑问,作曲家的第一个条件就是已经去世。——Arthur Honegger”

TL;DR: 不要用 required,无论它多么诱人。等你意识到这是个坏主意时,你已经甩不掉它了。

恰好一个

如果你用 proto2,应该熟悉 repeatedoptional 这两个字段修饰符,分别表示"零或多个"和"零或一个”。但你知道 proto2 还有第三个修饰符吗?它叫 required,含义和你想的一样:字段必须存在。

不幸的是,它的语义是个巨大的坑,所以永远不要用它!

初始化性(Initialized-ness)

大多数 Protobuf 运行时都有一个鲜为人知的设置,比如 Go 的 proto.Unmarshal.AllowPartial,指示解码器允许"部分消息”。如果你用 Protobuf 很久了,这听起来像是范畴错误:校验消息不变量是应用层的事!不过有一个检查是 Protobuf 自己知道的:required 字段检查,也叫"是否已初始化"检查。Protobuf 认为任何有未设置 required 字段的消息是"未初始化"的,涉及它的操作会报错。

未设置的 required 字段?尽管听起来像矛盾修辞法,required 字段生成的 API 并不像 proto3 中无修饰符的标量字段那样(后者没有 has() 函数),它生成的 API 与 optional 字段完全相同。这意味着当你构造一个带 required 字段的新消息时,这些字段全都是未设置的!那这个特性有什么意义?

如前所述,许多涉及缺失 required 字段的操作会报错。解析带 required 字段的消息时,先按与 optional 相同的方式解析,解析完成后运行时遍历消息验证每个 required 字段都已设置。如果有缺失,解析器返回错误并丢弃已解析的消息。这是 required 字段的第一个大坑:消息任何部分的问题都会导致整个消息被丢弃,即使错误在某个深层嵌套的 repeated 消息字段里。这就是 AllowPartial 存在的原因——让你能解析并检查可能缺字段的消息。

奇怪的是,序列化函数AllowPartial 设置:试图序列化带未设置 required 字段的消息同样会运行时报错。这是大多数序列化函数返回错误的唯一情形。

这一切意味着:本应发生在应用层的数据错误处理,被不透明地塞进了传输层。

required 是永恒的

required 的所有语义合谋,使得以下操作不可能安全进行:

  1. required 字段"降级"为 optional 字段。
  2. 弃用并删除 required 字段。

因为初始化检查失败无处不在,试图移除一个 required 字段需要:先把它在所有地方降级为 optional(但仍然确保处处设置它)、更新 schema 的所有用户、最后删除字段并用 reserved 声明替代。这种协调量与 Protobuf"让渐进发布安全"的核心原则完全背道而驰。

毫不意外,required 是 Google 早期使用 Protobuf 时多次宕机的根因之一。

proto3 移除了 required,基本用隐式存在性(implicit presence,无修饰符的非消息字段)替代。隐式存在性字段更接近大多数人对 required 的真实需求:字段不是可选的,所以它在网络上是否真的被设置了无关紧要。Google 内部版 protoc 不允许创建新的 required 字段;它包含一份现存 required 符号名的超大清单,新增不在清单里的符号会直接报错。

proto2 里你仍然可以创建新的 required 字段,但你会很快发现许多工具要么行为不可预测,要么干脆忽略 required 修饰符,它给你的消息用户埋了一颗雷。等你发现问题时已经太晚了:移除一个已经逃出团队范围的 required 字段是不可能的。这毫不夸张:如果 Google 用他们的批量重构工具都做不到,你也做不到。

不要用 required。真的不值得。


Tip #9:有些数字比其他数字更"平等"

原文:Protobuf Tip #9: Some numbers are more equal than others (2025-06-17)

“若要走得远、走得快,请轻装上路。——Cesare Pavese”

TL;DR: 前 15 个字段编号是特殊的:大多数运行时解码它们比其他字段编号快得多。为解码性能设计消息类型时,把最常出现的字段用在这些编号上是好的。

挑选字段编号

你知道吗,并非所有字段编号生而平等?你给字段绑定的编号实际上会影响某些运行时的解码速度,对于需要大量解析的消息有时会有差别。

这条 Tip 与其说是具体指导,不如说是个提醒——你几乎永远不会被这个级别的优化困扰。但这是理解 Protobuf 解码一些更微妙性能特征的好机会。

字段编号如何编码

在网络上,Protobuf 消息是一条条记录背靠背组成的序列。每条记录的第一部分叫标签(tag),同时包含字段编号和记录的 wire type。这就是 Protobuf 解析器能跳过不认识字段的原因。

tag 是一个 32 位整数,低 3 位是 wire type(共八种,其中两种未使用),其余 29 位是字段编号(这就是字段编号不能超过 2^29-1 的原因)。tag 被编码为变宽整数,即 varint

Protobuf 的 varint 很简单:32 位整数被拆成五个 7 位块(最后一块的最高三位总是零,因为 7 不能整除 32)。然后每个 7 位块编码为一个字节,若后面还有 7 位块,则字节的第八位被置位——这就是继续位(continuation bit)。最后一个非零块之后的零块都被丢弃。

例如,所有不超过 127 的 32 位整数都被编码为单个字节,等于把整数截断到 8 位。但 128 的二进制表示是 0b1000_0000,意味着它的第二个 7 位块是 0b000_0001。于是需要编码成两个字节:0x0180

两字节 varint 最大到 2^14 即 16384。三字节 varint 到 2^21,四字节到 2^28,其余 32 位整数占 5 字节。(练习:最大的 64 位整数编码成 varint 需要多少字节?64 位 varint 每种字节大小的阈值各是多少?)

解码 varint

解码 varint 有很多技巧,但大多数运行时都假设小 varint 更常见,因此对一字节和两字节 varint 有显式的特判。解码一个 varint 通常只需 handful 条指令,特判可以省去循环处理所有 7 位块的复杂开销。

在 C 里大概长这样:

// Parses a varint and advances p past it. Assumes p is followed
// by at least 10 bytes.
uint64_t varint(uint8_t* p) {
  // Fastest path for one byte.
  uint8_t x = *b++;
  uint64_t v = (uint64_t)x;
  if x & 0x80 == 0 {
    return v;
  }

  // Faster path for two bytes.
  x = *b++;
  v |= (uint64_t)x << 7;
  if x & 0x80 == 0 {
    return v;
  }

  // Loop fallback.
  for (int i = 2; i < 9; i++) {
    x = *b++;
    v |= (uint64_t)x << (i * 7);
    if x & 0x80 == 0 {
      return v;
    }
  }

  // Special case for the 10th byte. Why do we need this?
  x = *b++;
  v |= (uint64_t)x << 63;
  if x > 1 {
    return v;
  }

  fail(); // Handle the error condition.
}

如果我们真的在乎性能,可能希望所有字段编号都命中最快路径。不幸的是,没几个字段编号能做到。你可能会以为是前 127 个字段编号,但 wire type 占了整整 3 位,只剩字段编号 1 到 15 能用单字节编码(0 不是合法字段编号,不计入)。

两字节字段编号就多了:16 到 2048 之间的所有字段编号在网络上占用相同字节数。字段编号(extensions 除外)很少上三位数,所以你会发现只有前 15 个是特殊的。

这对我重要吗?

大概率不重要!大多数消息类型都很小,所有字段都能塞进 15 个"特殊快速字段"里。不过对于将成为大型 repeated 字段一部分、且只携带所有可能字段的一小部分的消息,值得记住这一点。这不算罕见,但确实少见。

低字段编号是一个好例子,说明理解 wire format 能揭示消息设计选择带来的意外后果。


Tip #10:选择正确的整数类型

原文:Protobuf Tip #10: Choosing the right integer type (2026-08-10)

int64

如果你一直凭直觉加 int64 整数字段然后继续过日子,你其实做对了。可以不往下读了。

Protobuf 有十种不同的整数类型,所以怀疑选错类型是否有影响是合理的。大多数时候,没有影响。但有几个差别值得了解。

这么多类型

Protobuf 的整数类型分为三个家族,按值在网络上如何编码划分:

家族 类型 编码 每值字节数
Varint int32, int64, uint32, uint64 Base-128 varint 1 到 10
ZigZag varint sint32, sint64 基于 ZigZag 映射的 Base-128 varint 1 到 10
定宽 fixed32, fixed64, sfixed32, sfixed64 小端序 4(32 位)或 8(64 位)

int32int64uint32uint64 都用 varint,较小的值占用更少字节。128 以下的值单字节,最大 64 位值到 10 字节。每个字节留出一位标记后面是否还有字节,解码器靠它知道值在哪里结束。

值 150 编码为 int64 varint 占 2 字节,每个字节的第一个比特是继续标志

sint32sint64ZigZag 编码优雅地处理负数:不直接存值,而是从零向外交替计数:0、-1、1、-2、2……绝对值接近零的数在网络上占更少字节,所以 -1 只占一字节而不是十字节。

定宽整数没有继续位那套东西。值永远是四字节(32 位)或八字节(64 位)小端序。没有继续位,也没有编解码 varint 的循环。

值 150 存为 fixed64 占 8 个平坦的小端字节,没有继续标志

Varint 多花点 CPU 换字节;定宽整数多花字节换解析器少干活。但我们说的到底是多少 CPU?

但其实无所谓

我想给"多花点 CPU"一个量化,所以用标准 google.golang.org/protobuf 运行时对含 1000 个整数的字段做了基准测试,覆盖小正数、大正数以及有符号类型的负数。下图只看 64 位类型;32 位变体使用相同编码,只是范围更小。

我还把同样的 1000 个整数跑了 encoding/jsonencoding/json/v2。JSON 是文本格式,网络上会占用更多空间,但在这里可以作为一个熟悉的参照点。

下图是反序列化一个含 1000 个整数字段的消息的耗时。序列化数据在文末附录,讲的是同一个故事。

在 Go 中反序列化 1000 个整数:所有 64 位 Protobuf 整数类型都在 1.2 到 5.8 微秒之间,而手写 JSON 需要 16 到 32 微秒

最慢的情形是 1000 个值 6 微秒不到。sfixed64 比负数 int64 快近五倍,但也就是 1.2 微秒对 5.8 微秒。连 JSON 也才 32 微秒封顶。这些数字都非常小。

一个例外是基本随机的 64 位值,比如哈希或生成的 ID。Varint 在这里帮不了你,因为几乎每个值都很大。fixed64 每次固定 8 字节,反而可能既更小又更省解码。

直接用 int64,继续生活

除此之外,我的默认答案仍是 int64。它能处理负数,让小值在网络上保持小,而且极不可能是你服务慢的原因。

如果你的 schema 里已经全是 int64,它们没问题。下次添加整数字段时,你已经知道该敲什么了。

基准测试设置

每个基准测试使用带单个 packed repeated 字段的消息。Packed 编码只写一次 tag 和长度前缀,确保测量的是整数解析而不是 tag 开销:

syntax = "proto3";

package bench.v1;

message Int64List {
  repeated int64 values = 1;
}

message Sint64List {
  repeated sint64 values = 1;
}

message Sfixed64List {
  repeated sfixed64 values = 1;
}

每条消息持有 1000 个来自三种分布之一的值:小正数(099)、大正数(2^502^50 + 999)、负数(-100-1)。负载预先构建,b.Loop 防止编译器把工作优化掉:

func benchmarkUnmarshal(b *testing.B, msg proto.Message) {
    payload, err := proto.Marshal(msg)
    if err != nil {
        b.Fatal(err)
    }

    dst := msg.ProtoReflect().Type().New().Interface()
    b.SetBytes(int64(len(payload)))
    b.ReportAllocs()
    b.ResetTimer()

    for b.Loop() {
        proto.Reset(dst)
        if err := proto.Unmarshal(payload, dst); err != nil {
            b.Fatal(err)
        }
    }
}

proto.Reset 会把 dst 清零而不是保留缓冲区,所以每次迭代都从 nil 重新增长目标 slice。

JSON 用例对普通 Go 结构体跑同样的循环,直接测量标准库,避开 Protobuf 反射开销:

type jsonList struct {
    Values []int64 `json:"values"`
}

JSON 只有一种整数表示,所以三种 Protobuf schema 在 JSON 中的表示完全相同。

由于 encoding/json/v2 在 Go 1.26 需要 GOEXPERIMENT=jsonv2,那个基准测试放在单独的文件里,带 //go:build goexperiment.jsonv2 标签。开启实验后,我发现 encoding/json 基准比不开时快了大约一倍。实验会把 v1 API 路由到新实现,所以下面两行 JSON 数据都来自开启实验的运行。

结果为 Apple M5 Pro(darwin/arm64)上五次独立的 5 秒运行的平均值,使用 Go 1.26.5 和 google.golang.org/protobuf v1.36.11:

GOEXPERIMENT=jsonv2 go test -run='^$' -bench=. -benchmem -benchtime=5s -count=5
反序列化 1000 个值 小正数 大正数 负数 内存分配次数
fixed64 1,131 ns 1,121 ns N/A 1
sfixed64 1,160 ns 1,206 ns 1,208 ns 1
int64 1,416 ns 5,040 ns 5,793 ns 1
uint64 1,470 ns 4,903 ns N/A 1
sint64 1,651 ns 5,054 ns 1,644 ns 1
encoding/json/v2 16,503 ns 24,691 ns 18,084 ns 12
encoding/json 20,876 ns 32,207 ns 23,898 ns 12
序列化 1000 个值 小正数 大正数 负数 内存分配次数
sfixed64 966 ns 965 ns 959 ns 1
fixed64 968 ns 966 ns N/A 1
int64 2,065 ns 3,453 ns 3,817 ns 1
uint64 2,073 ns 3,565 ns N/A 1
sint64 2,622 ns 3,652 ns 2,469 ns 1
encoding/json 6,280 ns 12,889 ns 6,447 ns 3
encoding/json/v2 6,334 ns 13,022 ns 6,548 ns 3

三种分布下的内存分配次数没有变化,所以合并为一列。无论选哪种整数类型,每个 Protobuf 用例都只有一次分配:反序列化分配目标 slice,序列化分配输出缓冲区。整数类型改变的是那次分配多大,而不是分配几次。


总结

这个系列贯穿始终的主题只有一个:Protobuf 的名字和编号一旦发布就是永恒的。字段名、枚举值名、字段编号、required 修饰符——所有跨越团队边界的东西都难以安全回收。Buf 团队给出的实践建议可以归纳为:

  1. 不重命名字段和枚举值,需要新语义就加新字段/新类型,旧的标记 deprecated
  2. 枚举值加前缀、零值用 _UNSPECIFIED,用 lint 规则强制;
  3. 不要用 import public/weakallow_aliasrequired,这些是 C++ 历史包袱;
  4. 性能上不必纠结:压缩无处不在,varint 与定宽、字段编号 1–15 的差异在绝大多数场景都是噪声;
  5. 工具链用起来buf convert + jqprotoscope、Buf 的 lint 与 breaking 检测,都是围绕"永恒性"设计的护栏。