跳转至

Protocol Buffers 底层二进制编码机制

Protocol Buffers 能够实现极致性能与极小带宽占用的核心奥秘,在于其高度精炼的二进制线格式(Wire Format)。与 JSON、XML 等基于文本的自描述格式不同,Protobuf 在序列化时丢弃了所有的字段名称、注释以及冗余格式字符,仅通过字段编号(Field Number)与数据流进行绑定。


1. 字段键编码:Tag 与 Wire Type

Protobuf 消息是一连串的键值对序列。在二进制流中,每个字段的“键(Key)”并非字段名字符串,而是一个由 字段编号(Field Number)线类型(Wire Type) 组合计算而成的整数。

计算公式如下: $$\text{Key} = (\text{field_number} \ll 3) \mid \text{wire_type}$$

因为 wire_type 仅占用低 3 位(取值范围 $0 \sim 7$),而高位全部留给 field_number

Wire Type 名称 对应数据类型 数据载荷结构
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum 变长字节序列(1~10 字节)
1 64-bit fixed64, sfixed64, double 固定 8 字节(Little-Endian)
2 Length-delimited string, bytes, 嵌套消息(sub-message), packed repeated Varint 长度 + 数据字节内容
5 32-bit fixed32, sfixed32, float 固定 4 字节(Little-Endian)

(注:Wire Type 3 与 4 是早期的 Start/End group,已在 Proto3 中废弃)

[!TIP] 最佳实践:为什么推荐将高频核心字段定义在编号 1 ~ 15?field_number 在 $1 \sim 15$ 时,$(\text{field_number} \ll 3)$ 的结果小于 128,整个 Key 只需要 1 个字节 即可存储!如果编号大于 15,Key 本身就需要消耗 2 个甚至更多字节。


2. Varint 变长整型算法

Varint(Variable-length quantity) 是一种使用一个或多个字节序列化整数的方法。越小的数字消耗的字节越少。

编码核心规则

  1. 每个字节的最高有效位(MSB, Most Significant Bit)用作延续标志(Continuation Flag):
  2. MSB = 1:表示后续字节依然属于当前整数。
  3. MSB = 0:表示当前字节为该整数的最后一个字节。
  4. 每个字节剩余的 低 7 位(7 bits) 用于承载数字的实际二进制有效位。
  5. 按照 小端序(Little-Endian) 排列组合。

实战演示:将整数 300 编码为 Varint

  1. 写出 300 的二进制原码: $$300_{10} = 00000001\ 00101100_2$$
  2. 从低位起,以 7 位为一组进行拆分
  3. 低 7 位:0101100
  4. 高 7 位:0000010
  5. 颠倒顺序(小端序排列)
  6. 第一个 7 位:0101100
  7. 第二个 7 位:0000010
  8. 设置 MSB 标志位
  9. 第一个字节(后面还有数据,MSB 置 1):1 + 0101100 = 10101100(十六进制 0xAC
  10. 第二个字节(已到末尾,MSB 置 0):0 + 0000010 = 00000010(十六进制 0x02
  11. 最终输出300 最终被紧凑编码为两个字节:0xAC 0x02

3. 针对负数的 ZigZag 编码

在标准的计算机补码表示法中,负数的最高符号位为 1。例如 -1 的 64 位补码为 0xFFFFFFFFFFFFFFFF

如果直接对负数使用常规的 Varint 编码,由于高位全是 1,系统必须始终消耗整整 10 个字节 来存储!

为了解决该痛点,Protobuf 引入了 sint32 / sint64 类型,并使用 ZigZag 编码 将有符号数映射为无符号正数:

graph LR
    subgraph 有符号整数
        N1["-2"]
        N2["-1"]
        N3["0"]
        N4["1"]
        N5["2"]
    end

    subgraph ZigZag 映射后的正整数
        Z1["3"]
        Z2["1"]
        Z3["0"]
        Z4["2"]
        Z5["4"]
    end

    N3 --> Z3
    N4 --> Z4
    N2 --> Z2
    N5 --> Z5
    N1 --> Z1

ZigZag 数学转换公式

对于 32 位整型: $$\text{ZigZag}(n) = (n \ll 1) \oplus (n \gg 31)$$ (其中 $\gg$ 为算术右移)

通过该变换: - 0 映射为 0 - -1 映射为 1 - 1 映射为 2 - -2 映射为 3

这样极小的负数(如 -1-2)在经过 ZigZag 映射后变成了极小的正整数,随后再进行 Varint 编码,仅需 1 个字节 即可存储,效率提升高达 10 倍!


4. 长度界定类型(Length-delimited)编码

针对 stringbytes、嵌套消息(Sub-message)以及 Packed Repeated 字段(Wire Type = 2),Protobuf 采用标准的 TLV(Tag-Length-Value) 结构:

[Tag (Varint)] + [Length (Varint)] + [Value 实际二进制数据]

实例:序列化 string name = 2;(假设内容为 "testing"

  1. Tag 计算
  2. 字段编号 = 2,Wire Type = 2
  3. $\text{Key} = (2 \ll 3) \mid 2 = 16 \mid 2 = 18$(二进制 00010010,十六进制 0x12
  4. Length 计算
  5. 字符串 "testing" 的 UTF-8 字节长度为 7,Varint 编码为 0x07
  6. Value 内容
  7. "testing" 的 ASCII/UTF-8 编码为 0x74 0x65 0x73 0x74 0x69 0x6e 0x67
  8. 最终连缀字节流
    0x12 0x07 0x74 0x65 0x73 0x74 0x69 0x6e 0x67
    
    没有任何多余字符,结构清晰,解析速度达到了内存指针移动的理论极值。

5. Packed 数组压缩机制

在 Proto3 中,对于数值型的 repeated 数组(如 repeated int32 numbers = 4;),默认会自动开启 Packed 编码:

  • 非 Packed 模式(旧标准): Tag + Val1 + Tag + Val2 + Tag + Val3(Tag 被重复浪费)
  • Packed 模式: 整个数组被当作一个 Length-delimited(Wire Type 2)包处理: Tag + Total_Length + Val1 + Val2 + Val3

在包含成百上千个元素的数组场景下,Packed 模式直接省去了 99% 的重复 Tag 开销。