TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

protobuf 传输格式:逐字节拆解 21 字节

状态:草稿(2026-10-17),实测。 所有字节均使用 python3(3.9,仅标准库)直接编码得出。没有使用官方 protobuf 库对相同消息进行序列化对比,因为这需要额外下载安装包(未实测,需要的内容: protobuf Python 包和 protoc)。 规则参考了 protobuf.dev 的 Encoding 文档和 proto3 语言指南(2026-10-17 查阅)进行确认。

目录

1. 直观感受:用字段编号代替字段名

JSON 会像 {"rallyId":150} 那样 每次都写上字段名 。虽然易于阅读,但相同的名称在每条消息中都会重复出现。而 protobuf 认为双方已经拥有了契约文件(.proto),在字节中只记录 字段编号和值。“1 号字段:150”。由于名称在契约中,因此无需发送。

由此会带来两点特性:

2. 图解:Rally 单条记录的 21 字节

填充第一篇中契约里的一个消息。 rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.

Rally 메시지 21바이트를 한 칸씩 08 태그 96 01 12 태그 06 ed 8b b0 ed 8b b0 1a 태그 04 03 8e 02 01 20 태그 01 28 태그 03 읽는 법 필드 1 rally_id 태그 08 · 150=96 01 필드 2 server 태그 12 · 길이 06 · UTF-8 필드 3 hits 태그 1a · 길이 04 · 3,270,1 필드 4 won 태그 20 · 1 필드 5 delta 태그 28 · -2→3 태그 = (필드 번호 << 3) | 와이어 타입. 08 = 1번, varint 같은 값을 다른 형식으로 (실측, python3 표준 라이브러리) protobuf 21바이트 JSON 한 줄 72바이트 JSON 들여쓰기 111바이트
图解:按字段拆分的 Rally 消息 21 字节图以及与 JSON 的大小对比

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 对比

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. 限制

7. 与小型集群的连接

kubelet 与 containerd 之间的 CRI 消息也使用此格式。因此,即使 kubelet 比 containerd 的版本更新(CRI 中添加了新字段),旧版的 containerd 也能在跳过未知字段的同时正常工作。反之,CRI 方面不会随随便便更改字段编号也是这个原因。 kubelet 在接收 CRI 响应时,将上限从 gRPC 默认的 4MiB 提高到 16MiB(第 6 篇),是因为单个消息中包含了数百个容器的列表。

Titi 的小贴士。 即使在记分牌上只写着“1号栏:150”,只要彼此使用相同的格式就足够了。不过,如果在 int32 栏中写入负数会占用 10 个栏位,所以请使用 sint32 栏!

参考

所有资料均于 2026-10-17 查阅。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论