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) 是一种使用一个或多个字节序列化整数的方法。越小的数字消耗的字节越少。
编码核心规则¶
- 每个字节的最高有效位(MSB, Most Significant Bit)用作延续标志(Continuation Flag):
MSB = 1:表示后续字节依然属于当前整数。MSB = 0:表示当前字节为该整数的最后一个字节。- 每个字节剩余的 低 7 位(7 bits) 用于承载数字的实际二进制有效位。
- 按照 小端序(Little-Endian) 排列组合。
实战演示:将整数 300 编码为 Varint¶
- 写出 300 的二进制原码: $$300_{10} = 00000001\ 00101100_2$$
- 从低位起,以 7 位为一组进行拆分:
- 低 7 位:
0101100 - 高 7 位:
0000010 - 颠倒顺序(小端序排列):
- 第一个 7 位:
0101100 - 第二个 7 位:
0000010 - 设置 MSB 标志位:
- 第一个字节(后面还有数据,MSB 置 1):
1+0101100=10101100(十六进制0xAC) - 第二个字节(已到末尾,MSB 置 0):
0+0000010=00000010(十六进制0x02) - 最终输出:
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)编码¶
针对 string、bytes、嵌套消息(Sub-message)以及 Packed Repeated 字段(Wire Type = 2),Protobuf 采用标准的 TLV(Tag-Length-Value) 结构:
实例:序列化 string name = 2;(假设内容为 "testing")¶
- Tag 计算:
- 字段编号 = 2,Wire Type = 2
- $\text{Key} = (2 \ll 3) \mid 2 = 16 \mid 2 = 18$(二进制
00010010,十六进制0x12) - Length 计算:
- 字符串
"testing"的 UTF-8 字节长度为 7,Varint 编码为0x07 - Value 内容:
"testing"的 ASCII/UTF-8 编码为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 开销。