状态:草稿(2026-10-17),实测。 所有字节均使用 python3(3.9,仅标准库)直接编码得出。没有使用官方 protobuf 库对相同消息进行序列化对比,因为这需要额外下载安装包(未实测,需要的内容:
protobufPython 包和protoc)。 规则参考了 protobuf.dev 的 Encoding 文档和 proto3 语言指南(2026-10-17 查阅)进行确认。
目录
- 1. 直观感受:用字段编号代替字段名
- 2. 图解:Rally 单条记录的 21 字节
- 3. 四大规则
- 4. 动手尝试:手写编码与 JSON 对比
- 5. 向后兼容:如何跳过未知的字段
- 6. 局限性
- 7. 与小型集群的连接
- 参考
1. 直观感受:用字段编号代替字段名
JSON 会像 {"rallyId":150} 那样 每次都写上字段名 。虽然易于阅读,但相同的名称在每条消息中都会重复出现。而 protobuf 认为双方已经拥有了契约文件(.proto),在字节中只记录 字段编号和值。“1 号字段:150”。由于名称在契约中,因此无需发送。
由此会带来两点特性:
- 体积小。 没有字段名,且整数值越小占用空间越少。
- 编号即约定。 字段名可以更改(因为字节中不存在字段名),但如果更改或重新分配编号,旧数据就会被读取到错误的字段中。
2. 图解:Rally 单条记录的 21 字节
填充第一篇中契约里的一个消息。 rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.
- 一种颜色代表一个字段。每个字段都以 标签字节开始(如图中的“标签”所示)。
- 字符串
"티티"是 UTF-8 的 6 字节(ed 8b b0两次),前面附加了长度06。 hits的 270 是两个字节(8e 02),因此打包长度为 4。
3. 四条规则
3.1 varint:每 7 位一组,从低位开始
将整数按 7 位切分,从 低位 开始写入,如果后面还有字节,则将最高位 (MSB) 置为 1。150 的二进制是 1001 0110 ,
所以低 7 位 001 0110 开启了后续位标志,变成 0x96,剩下的 1 是 0x01 ,组合起来是 96 01 。这是手动计算的值。
| 值 | 字节 | 大小 |
|---|---|---|
| 1 | 01 |
1 |
| 127 | 7f |
1 |
| 128 | 80 01 |
2 |
| 150 | 96 01 |
2 |
| 300 | ac 02 |
2 |
| 16,384 | 80 80 01 |
3 |
| 2,147,483,647 | ff ff ff ff 07 |
5 |
| int32 -1 | ff ff ff ff ff ff ff ff ff 01 |
10 |
最后一行是个陷阱。 int32 的负数会扩展为 64 位补码再写入,因此总是占 10 字节(见 Encoding 文档的“Signed Integers”小节)。
对于经常出现负数的字段,应使用 sint32/sint64 。通过 ZigZag 将 0, -1, 1, -2, 2 … 映射为 0, 1, 2, 3, 4 …,然后以 varint 写入。
| sint32 值 | ZigZag | 字节 |
|---|---|---|
| -1 | 1 | 01 |
| 1 | 2 | 02 |
| -2 | 3 | 03 |
| -64 | 127 | 7f |
| 64 | 128 | 80 01 |
3.2 标签 = (字段编号 << 3) | 导线类型
导线类型只告诉你值的 形状。包括 0 VARINT(int32·int64·uint·bool·enum·sint)、1 I64(fixed64·double)、2 LEN(string·bytes· 消息·packed 打包)、5 I32(fixed32·float),而 3 和 4 用于旧的 group 机制,不再使用。标签本身也是 varint,因此编号越大,长度越长。
| 字段编号 | 导线类型 | 标签字节 |
|---|---|---|
| 1 | 0 | 08 |
| 2 | 2 | 12 |
| 15 | 0 | 78 |
| 16 | 0 | 80 01 |
| 2047 | 0 | f8 7f |
| 2048 | 0 | 80 80 01 |
1 到 15 号的标签占 1 字节,16 到 2047 号占 2 字节。这就是为什么建议给经常填充的字段分配较小的编号。编号可使用 从 1 到 536,870,911(2²⁹-1),其中 19,000~19,999 保留用于内部实现(详见 proto3 指南)。
3.3 LEN:长度在前
字符串、字节、嵌套消息和 packed 打包使用 태그 + 길이(varint) + 내용 。在 proto3 中,数字类型的 repeated 默认是 packed 模式 ,
因此
hits=[3,270,1] 是标签 1a(第3项,LEN) 1个 + 长度 04 + 03 8e 02 01 。如果不是 packed,每个值都会附带标签,变成 7 字节(计算得出)。
3.4 不发送默认值
proto3 的常规标量字段(隐式存在)如果其值为 0、空字符串或 false,则会 不进行序列化。所有字段均为默认值的 Rally 大小为 0 字节。
接收方无法区分“发送了 0”还是“未发送”。如果需要区分,可以附加 optional 来明确其存在(proto3 指南中的 Field Presence)。
第 5 篇的健康检查请求 HealthCheckRequest{service:""} 实际上是 0字节也是因为这个规则。
4. 亲自尝试:手动编码与 JSON 对比
- 环境:笔记本电脑,Python 3.9.6,仅使用标准库(
struct,json) - 代码和原始数据:工作目录
measure/grpc/pb_by_hand.py,输出out-pb_by_hand.txt - 方法:按照第 3 节的规则,将字段逐个转换为字节并连接在一起。
def varint(n):
if n < 0:
n &= (1 << 64) - 1 # int32 음수는 64비트로 늘려 10바이트
out = bytearray()
while True:
b, n = n & 0x7F, n >> 7
out.append(b | 0x80 if n else b)
if not n:
return bytes(out)
def tag(field, wire):
return varint((field << 3) | wire)
结果(实测):
| 字段 | 字节 | 大小 |
|---|---|---|
1 rally_id=150 |
08 96 01 |
3 |
2 server="티티" |
12 06 ed 8b b0 ed 8b b0 |
8 |
3 hits=[3,270,1] |
1a 04 03 8e 02 01 |
6 |
4 won=true |
20 01 |
2 |
5 delta=-2 (sint32) |
28 03 |
2 |
| 总计 | 21 |
如果将相同的内容写入 JSON(字段名按照 proto3 JSON 惯例使用 lowerCamelCase),不带空格的单行大小为 72字节, indent=2 ,如果缩进则为
111字节。在此示例中,protobuf 的大小仅为单行 JSON 的 29%。消息中数字越多,差距越大;如果以长字符串为主,差距则会
缩小。未进行压缩(gzip)后的对比(未实测)。
delta 如果将 int32 声明为
5. 向后兼容:如何跳过未知字段
假设新版本添加了第 6 号字段 string note = 6; 并填入了 "hello" 。字节末尾会附加 32 05 68 65 6c 6c 6f 。
不认识第 6 号的旧版本解析器从标签 32 读取到线路类型 2 (LEN),并根据长度 5 跳过。实际上,脚本中模拟的“旧版本解析器”
(skip_fields) (6, 2, 6) 输出了结果并完成了读取。这正是因为标签指明了值的形态才得以实现。
proto3 这样不会丢弃未知字段,而是会 将其保留并在重新序列化时直接使用(proto3 指南中的 Unknown Fields)。但是,如果将其转换为 JSON 或逐个复制字段,可能会丢失。
需要遵守的规则总结如下(proto3 指南摘要)。
| 变更项 | 是否安全 | 原因 |
|---|---|---|
| 添加字段(新编号) | 安全 | 旧版本会跳过,新版本会读取默认值 |
| 修改字段名 | 二进制安全 | 字节中没有名称。但 JSON 和 TextFormat 使用名称 |
| 删除字段 | 用 reserved 封锁编号和名称则安全 |
如果重新使用,旧数据会被当作新含义读取 |
| 重复使用或更改编号 | 危险 | 解释会发生冲突,甚至导致数据损坏或泄露 |
| int32 ↔ int64 ↔ uint32 ↔ bool | 线路兼容 | 全部为 VARINT。超出范围会被截断 |
| int32 ↔ sint32 | 不兼容 | ZigZag 解释不同导致值发生变化(-2 ↔ 3) |
最后一行可以通过第 4 节的例子来确认。如果将 28 03 读取为 int32 ,则是 3;如果读取为 sint32 ,则是 -2。
6. 限制
- 消息大小上限:序列化后的消息必须小于 2GiB(Encoding 文档)。gRPC 另外设置了远小于此的默认上限(接收 4MiB)(第 3 篇)。
- 本文仅探讨了 proto3。未涉及 Editions (2023~) 的功能配置、proto2 的 required·group、
map的线路表示(嵌套子消息)。 - 手动编码是为了理解规则,并非验证实现。未对官方库输出进行字节级别的对照实测。
- 字段顺序:上述示例按编号顺序编写,但规范不保证序列化的顺序。不能假设相同的消息总是产生相同的字节(在用于哈希或签名时需注意)。
7. 与小型集群的连接
kubelet 与 containerd 之间的 CRI 消息也使用此格式。因此,即使 kubelet 比 containerd 的版本更新(CRI 中添加了新字段),旧版的 containerd 也能在跳过未知字段的同时正常工作。反之,CRI 方面不会随随便便更改字段编号也是这个原因。 kubelet 在接收 CRI 响应时,将上限从 gRPC 默认的 4MiB 提高到 16MiB(第 6 篇),是因为单个消息中包含了数百个容器的列表。
Titi 的小贴士。 即使在记分牌上只写着“1号栏:150”,只要彼此使用相同的格式就足够了。不过,如果在 int32 栏中写入负数会占用 10 个栏位,所以请使用 sint32 栏!
参考
所有资料均于 2026-10-17 查阅。
- protobuf.dev: Encoding(线路类型、varint、ZigZag、packed、大小上限), Language Guide (proto3)(字段编号范围、reserved、unknown fields、类型兼容性、field presence)
- 字节计算代码与输出:工作目录
measure/grpc/pb_by_hand.py,out-pb_by_hand.txt