本文汇总翻译自 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 看!这意味着上面的 Foo 和 Foo2 分别序列化为 {"bar":"content"} 和 {"bar2":"content"},二者不可互换。
可以通过字段的 [json_name = "..."] 选项部分缓解。但这并不能真正解决问题,因为许多 Protobuf 运行时的 JSON 编解码器会同时接受 json_name 设置的名字和字段名本身。所以 string given_name = 1 [json_name = "firstName"]; 会允许从名为 given_name 的 key 反序列化,但不再接受过去的 first_name。这仍然是一个破坏性的协议变更!
这是 Protobuf 本可以做得更好的地方——如果 json_name 是 repeated string,这种 wire format 层面的破坏本可以避免。不过,出于下文的原因,重命名依然是个坏主意。
反射!
即使你能避免源码和 JSON 层面的破坏,名字对反射(reflection)始终可见。虽然一般来说很难防范反射层面的破坏(反射甚至能看到字段声明的顺序),但这是反射中特别阴险的一部分——例如,调用方可能按字段名排序,或者某些中间件用字段名来标识频率,或者日志/脱敏的需求。
不要改名字,因为反射意味着你无法预知会出什么问题!
但我真的必须改!
想重命名字段是有正当理由的,比如扩展字段的语义。例如,first_name 和 given_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!
正确的做法是把 cost 和 currency 与 cost_usd 并列添加。读取方在读 cost 时应先检查 cost_usd,并据此推断 currency 为 CURRENCY_USD(如果 cost 和 cost_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_PREFIX
和 ENUM_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 位的,你无法升级它而不冒着旧读取方截断值的风险。但我们真的必须升级!有哪些选择?
- 发布该消息及所有依赖的新版本。这就是为什么在包名中固定版本号(如 Buf 的
PACKAGE_VERSION_SUFFIXlint 规则所强制的)如此重要。 - 硬着头皮升级并祈祷不出事。如果底层格式兼容,这在某些升级上可以奏效,但如果你不完全清楚自己在干什么,后果可能是灾难性的,尤其是对不完全局限于团队项目内部的类型。Buf 的破坏性变更检测 能帮你规避有破坏风险的变更。
当然还有第三个选项:接受有些东西不值得修。当修复成本高到离谱时,修复就不值得了,尤其当语言还在跟你作对的时候。
这意味着即使在 Buf 自己的 API 里,我们有时也会用不太理想、或与我们自己的最佳实践不一致的方式做事。有时生态的演进改变了最佳实践,但我们无法在不破坏用户的情况下升级。同样,如果新的、更好的语言特性会导致协议破坏,你不应该急于使用:有时正确的做法是什么都不做,因为不破坏你的用户更重要。
Tip #5:避免 import public/weak
原文:Protobuf Tip #5: Avoid import public/weak (2025-05-13)
“我爸有把吉他,但是木吉他的,所以我砸了面镜子,把碎玻璃粘上去让它看起来更金属。看起来蠢透了!——Max Cavalera”
TL;DR: 避免 import public 和 import weak。Buf 的 lint 规则 IMPORT_NO_PUBLIC
和 IMPORT_NO_WEAK
默认帮你强制执行。
Protobuf 的 import 允许指定两种特殊模式:import public 和 import 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,非常强大。
执行一次转换需要四个输入:
- 一个用于获取类型的 Protobuf 源。可以是本地
.proto文件、编码过的FileDescriptorSet,或远程 BSR 模块。- 如果不提供但在本地 Buf 模块目录内运行,将使用该模块作为类型源。
- 要转码的消息的顶层类型名,通过
--type标志指定。 - 输入消息,通过
--from标志指定。 - 输出位置,通过
--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/T81SbbFg3br 。C++ 的等价版本 要复杂得多。
这意味着我们需要用 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,应该熟悉 repeated 和 optional 这两个字段修饰符,分别表示"零或多个"和"零或一个”。但你知道 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 的所有语义合谋,使得以下操作不可能安全进行:
- 把
required字段"降级"为optional字段。 - 弃用并删除
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 位) |
int32、int64、uint32、uint64 都用 varint,较小的值占用更少字节。128 以下的值单字节,最大 64 位值到 10 字节。每个字节留出一位标记后面是否还有字节,解码器靠它知道值在哪里结束。
sint32 和 sint64 用 ZigZag 编码优雅地处理负数:不直接存值,而是从零向外交替计数:0、-1、1、-2、2……绝对值接近零的数在网络上占更少字节,所以 -1 只占一字节而不是十字节。
定宽整数没有继续位那套东西。值永远是四字节(32 位)或八字节(64 位)小端序。没有继续位,也没有编解码 varint 的循环。
Varint 多花点 CPU 换字节;定宽整数多花字节换解析器少干活。但我们说的到底是多少 CPU?
但其实无所谓
我想给"多花点 CPU"一个量化,所以用标准 google.golang.org/protobuf 运行时对含 1000 个整数的字段做了基准测试,覆盖小正数、大正数以及有符号类型的负数。下图只看 64 位类型;32 位变体使用相同编码,只是范围更小。
我还把同样的 1000 个整数跑了 encoding/json 和 encoding/json/v2。JSON 是文本格式,网络上会占用更多空间,但在这里可以作为一个熟悉的参照点。
下图是反序列化一个含 1000 个整数字段的消息的耗时。序列化数据在文末附录,讲的是同一个故事。
最慢的情形是 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 个来自三种分布之一的值:小正数(0 到 99)、大正数(2^50 到 2^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 团队给出的实践建议可以归纳为:
- 不重命名字段和枚举值,需要新语义就加新字段/新类型,旧的标记
deprecated; - 枚举值加前缀、零值用
_UNSPECIFIED,用 lint 规则强制; - 不要用
import public/weak、allow_alias、required,这些是 C++ 历史包袱; - 性能上不必纠结:压缩无处不在,varint 与定宽、字段编号 1–15 的差异在绝大多数场景都是噪声;
- 工具链用起来:
buf convert+jq、protoscope、Buf 的 lint 与 breaking 检测,都是围绕"永恒性"设计的护栏。