protobuf编码原理
1. 引言
本文将解释 Protocol Buffers(protobuf) 如何将数据编码到文件或传输线路(wire)上。
本文描述的是 protocol buffer 的“wire format”(传输格式)——“wire format”(传输格式)定义了消息在传输中的实际结构细节,以及在磁盘上占用的空间大小。
在日常开发中使用 protobuf 时通常不需要理解这些底层细节,
但如果想进行性能或空间优化,这些知识会非常有用。
Protoscope 是一种非常简单的语言,用于描述 protocol buffer 的底层传输格式片段。
将在示例中使用Protoscope来直观地展示不同消息的编码方式。
Protoscope 的语法由一系列 token(标记) 组成,每个标记都对应特定的字节序列,如:
- 反引号包裹的内容(
70726f746f6275660a)表示原始十六进制字节序列;会被直接编码为十六进制字节。 - 引号包裹的内容(
"Hello, Protobuf!")表示 UTF-8 字符串;等价于48656c6c6f2c2050726f746f62756621(即 “Hello, Protobuf!” 的 ASCII 编码)。
随着后续讲解的深入,会逐步引入更多 Protoscope 语法特性。
也可以使用 Protoscope 工具 将已编码的 protobuf 数据反解析为文本。
查看 https://github.com/protocolbuffers/protoscope/tree/main/testdata 了解更多示例。
本文的所有示例均假设使用的是 Edition 2023 或更高版本。
2. 一个简单的消息示例(A Simple Message)
假设有如下非常简单的消息定义:
message Test1 {int32 a = 1;
}
在应用程序中,创建了一个 Test1 消息,并将字段 a 设置为 150。
然后将此消息序列化(serialize)到输出流中。
如果查看序列化后的字节内容,会看到:
08 96 01
这三个字节非常短小,但它们代表什么呢?【第一个字节 0x08 是标签(tag): tag = ( field_number < < 3 ) ∣ wire_type \text{tag} = (\text{field\_number} << 3) | \text{wire\_type} tag=(field_number<<3)∣wire_type。】
如果使用 Protoscope 工具 来解析这些字节,它会显示出类似这样的结果:
1: 150
也就是说,Protoscope知道这个消息的字段编号是 1,值是 150。
接下来的内容将解释——protobuf 是如何知道这些信息的。
3. 基于 128 的变长整数(Base 128 Varints)
可变长度整数(varint) 是 Protocol Buffers 传输格式(wire format)的核心概念。
varint允许使用 1 到 10 个字节 编码一个无符号 64 位整数,数值越小,所需的字节数越少。
在 varint 的编码中,每个字节都包含一个 延续位(continuation bit),它用于表示后续字节是否仍属于当前 varint。
这个延续位是字节的 最高有效位(MSB, Most Significant Bit),有时也被称为“符号位(sign bit)”。
剩下的 低 7 位 才是真正的数值负载(payload)。
最终的整数值由这些 7 位负载按顺序拼接组成。
如,数字 1 的编码是:
01
它只有一个字节,因此 最高位(MSB)未被设置(为 0):
0000 0001
^ msb
而数字 150 的编码则是:
9601
即两个字节:
10010110 00000001
^ msb ^ msb
如何确认这确实表示 150 呢?
- 1)首先,从每个字节中去掉最高位(MSB)。
MSB仅用于标识“后续是否还有字节”。
因为这里的第一个字节 MSB=1,说明后面还有一个字节。 - 2)每个字节的低 7 位数据以 小端序(little-endian) 存储。
因此,需要将它们反转为 大端序(big-endian)。 - 3)将得到的 7 位片段拼接起来,并按无符号 64 位整数解释。
过程如下:
10010110 00000001 // 原始输入
0010110 0000001 // 去掉延续位
0000001 0010110 // 转换为大端序
00000010010110 // 拼接结果
计算得出:
128 + 16 + 4 + 2 = 150
这就是 varint 编码的解码过程。
由于 varint 在 protobuf 中非常关键,在 Protoscope 语法 中,通常直接将varint表示为普通整数。
如150 与 9601 等价:
150 ≡ `9601`
3. 消息结构(Message Structure)
一个 Protocol Buffers 消息(message) 是由一系列 键值对(key-value pairs) 组成的。
在二进制形式中,消息只使用字段的 编号(field number) 作为键。
字段的 名称和声明类型(name 与 declared type)只能在解码端通过 .proto 文件的消息定义来确定。
Protoscope 工具无法访问 .proto 定义文件,因此在展示时它只能显示字段编号,而无法显示字段名称或类型。
当一条消息被编码时,每个 键值对(key-value pair) 都会被转换为一个 记录(record),该记录由 字段编号(field number)、线类型(wire type) 和 数据负载(payload) 组成。
wire type 告诉解析器接下来负载(payload)的大小,从而使得旧版本的解析器能够跳过它无法识别的新字段。
这种结构化方案通常被称为 标签-长度-值(Tag-Length-Value, TLV)。
protobuf有 6种 Wire Type:
| ID | 名称 | 用途 |
|---|---|---|
| 0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | I64 | fixed64, sfixed64, double |
| 2 | LEN | string, bytes, embedded messages, packed repeated fields |
| 3 | SGROUP | group 起始(已弃用) |
| 4 | EGROUP | group 结束(已弃用) |
| 5 | I32 | fixed32, sfixed32, float |
一个记录的 标签(tag) 是通过以下公式计算得到的 varint:
tag = ( field_number < < 3 ) ∣ wire_type \text{tag} = (\text{field\_number} << 3) | \text{wire\_type} tag=(field_number<<3)∣wire_type
换句话说,当对字段的 varint 进行解码后:
- 低 3 位 表示 wire type;
- 其余高位 表示 字段编号(field number)。
再次回顾前面的简单示例。
现在知道,字节流中的第一个数字总是一个 varint 键(key),
在这里它是:
08
去掉最高位(MSB)后,可以写作:
000 1000
- 取 最后 3 位:
000→ 表示 wire type = 0(VARINT) - 向右移 3 位:
0001→ 表示 field number = 1
因此,在 Protoscope 语法中,这个标签可以写为:
1:VARINT
wire type 为 VARINT (0),由此知道接下来需要解析一个 varint 作为负载。
如前所述,字节序列 9601 解码为 150,于是得到了完整的记录:
1:VARINT 150
3.1 Protoscope 的类型推断
在 Protoscope 语法中,如果在冒号 (:) 后存在空格,则Protoscope会自动推断类型(即 wire type),方式是通过查看下一个 token 并根据其形式进行猜测。
这些规则在 Protoscope 的 language.txt 文档 中有详细描述。
如:
| 写法 | 推断结果 |
|---|---|
1: 150 | 由于后面是 varint,因此推断为 VARINT |
2: {} | 由于 {} 表示长度限定的结构,因此推断为 LEN |
3: 5i32 | 由于后缀为 i32,推断为 I32 |
4. 更多整数类型(More Integer Types)
4.1 布尔(bool)与枚举(enum)
bool 与 enum 在编码时都按照 int32 处理。
特别地,布尔值总是编码为:
| 值 | 编码 |
|---|---|
| false | 00 |
| true | 01 |
在 Protoscope 中,false 和 true 是这两个字节串的别名(aliases)。
4.2 有符号整数(Signed Integers)
正如上一节所示,所有与 wire type = 0 对应的 Protocol Buffers 类型都使用 varint 编码。
然而,varint 是无符号的(unsigned),因此不同的有符号类型(如 sint32 / sint64 与 int32 / int64)在表示负数时采用了不同的编码方式。
4.2.1 intN 类型(int32/int64)
intN 类型使用 二进制补码(two’s complement) 来编码负数。
这意味着它们在 64 位无符号整数的视角下,最高位始终为 1。
结果就是——负数编码后通常需要使用 完整的 10 个字节。
如,-2 在 Protoscope 中被转换为:
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
这是数字 2 的 二进制补码(two’s complement),
按照无符号算术定义为~0 - 2 + 1,其中 ~0 表示所有位都为 1 的 64 位整数。
理解这一过程有助于明白为什么会出现那么多的 1。
4.2.2 sintN 类型(sint32/sint64)
sintN 类型采用 ZigZag 编码 来表示有符号整数,而不是二进制补码。
- 对于 正整数
p:编码为2 * p(偶数) - 对于 负整数
n:编码为2 * |n| - 1(奇数)
这种方式在正负数之间“之字形”(zig-zag)分布,因此得名。
如:
| 原始值 (Signed Original) | 编码值 (Encoded As) |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| … | … |
| 0x7fffffff | 0xfffffffe |
| -0x80000000 | 0xffffffff |
换句话说,每个值 n 的 ZigZag 编码如下:
- 对于
sint32:(n << 1) ^ (n >> 31) - 对于
sint64:(n << 1) ^ (n >> 63)
当 sint32 或 sint64 被解析时,解码器会自动将其恢复为原始的有符号整数。
在 Protoscope 中,如果在整数后加上 z 后缀,表示该数使用 ZigZag 编码。
如:
-500z
等价于 varint 编码后的值:
999
4.3 非 Varint 数值类型(Non-varint Numbers)
非 varint 类型的数值编码方式相对简单:
- double 与 fixed64 →
wire type = I64表示一个 固定长度 8 字节 的数据块。
其中double使用 IEEE 754 双精度浮点格式 编码。
如:5: 25.4 // double 6: 200i64 // fixed64 - float 与 fixed32 →
wire type = I32表示一个 固定长度 4 字节 的数据块。
float使用 IEEE 754 单精度浮点格式 编码。
如:25.4i32 // float 200i32 // fixed32
在这些情况下,Protoscope 会根据数值的形式自动推断 I32 或 I64 类型。
5. 长度定界记录(Length-Delimited Records)
长度前缀(Length prefix) 是 Protocol Buffers 二进制格式中的另一个核心概念。
LEN 类型的字段具有动态长度,长度由紧跟在标签(tag)之后的 varint 指定,随后是实际的负载数据(payload)。
假设有如下消息定义:
message Test2 {string b = 2;
}
字段 b 是一个字符串,而字符串在编码时使用 LEN 类型。
如果将 b 设置为 "testing",那么它会被编码为一个 字段号为 2 的 LEN 记录,内容是 ASCII 字符串 "testing"。
编码结果如下:
12 07 74 65 73 74 69 6e 67
逐步拆解:
12 → tag = 00010 010 → 字段号 2,类型 LEN
07 → 长度 = 7
[74 65 73 74 69 6e 67] → UTF-8 编码的 "testing"
其中的 07 是一个 int32 varint,因此字符串的最大长度理论上可以达到 2GB。
在 Protoscope 语法中,这段编码可以写作:
2:LEN 7 "testing"
但手动重复写字符串长度很麻烦(尤其在引号包围的字符串中)。
Protoscope 支持使用花括号 {} 自动生成长度前缀,如:
{"testing"} // 等价于 7 "testing"
因此可以更简洁地写为:
2: {"testing"}
bytes 类型字段的编码方式与 string 相同。
5.1 子消息(Submessages)
子消息字段同样使用 LEN 类型。
如:
message Test1 {int32 a = 1;
}message Test3 {Test1 c = 3;
}
如果 Test1 中的字段 a (即 Test3 的 c.a 字段)被设置为 150,则编码结果为1a03089601,将其分解为:
1a 03 [08 96 01]
拆解如下:
1a → tag = 3:LEN 【0x1a = '0b11010' = 0x3<<3 | 0x2】
03 → 长度 = 3
[08 96 01] → 子消息 Test1 的编码结果(来自前面的示例)
也就是说,子消息的编码方式与字符串完全一致。在 Protoscope 中,可以更简洁地表示为:
3: {1: 150}
6. 缺失字段(Missing Elements)
未设置的字段不会被编码。
也就是说,缺失字段 = 直接省略。
因此,一个“很大”的 proto 消息如果只设置了少数字段,其实际编码会非常紧凑稀疏。\
7. 重复字段(Repeated Elements)
从 Edition 2023 开始,所有 基础类型(primitive type) 的重复字段(即除 string 和 bytes 外的scalar type 标量类型)默认采用 “packed” 模式 编码。
在 packed 模式 下,重复字段不会为每个元素分别生成一条记录,而是作为单个 LEN 类型的记录,其中所有元素的编码结果被依次拼接。
解码时,解析器会从 LEN 记录中逐个解析元素,直到负载耗尽。
如:
message Test4 {string d = 4;repeated int32 e = 6;
}
如果构造一个 Test4 实例:
d = "hello"e = [1, 2, 3]
那么它可以被编码为:
32 06 03 8e 02 9e a7 05
在 Protoscope 中表示为:
4: {"hello"}
6: {3 270 86942}
如果该重复字段被显式设置为 expanded 模式(覆盖默认的 packed 状态),
或字段类型本身不支持pack(如字符串或子消息),则每个元素会分别生成独立记录:
6: 1
6: 2
4: {"hello"}
6: 3
注意:
- 同一字段的记录不必连续出现;
- 仅要求相同字段的记录之间的顺序被保留;
- 不同字段的记录可以交错排列。
只有重复的基础数值类型字段(repeated primitive numeric types)可以被声明为 “packed” 模式。
这些类型通常使用 VARINT、I32 或 I64 作为其底层 wire type。
需要注意的是,尽管通常不会在一个 packed 重复字段中编码多个键值对(key-value pair),解析器必须能够接受多个这样的键值对。
在这种情况下,多个键值对的负载(payload)应被拼接起来。
每个键值对都必须包含完整数量的元素。
如,以下两段编码虽然分成了两个记录,但语义等价且都是有效的:
6: {3 270}
6: {86942}
解析器必须能够处理这种情况。
换句话说,解析器必须能将一个字段无论是以 packed 还是 非 packed 方式编码的内容都正确解析。
这也意味着可以前向兼容和后向兼容地为已有字段添加 [packed = true] 属性。
7.1 Oneof 字段
Oneof 字段 的编码方式与普通字段完全相同。
也就是说,它们的编码规则与是否属于 oneof 无关。
oneof 的规则仅在语义层面起作用,而不会影响底层 wire 格式。
7.2 “最后一个生效”原则(Last One Wins)
在正常情况下,消息中不会包含同一个 非重复字段(non-repeated field) 的多个实例。
但解析器必须能够正确处理这种情况。
- 对于数值类型和字符串:如果同一个字段多次出现,解析器会采用最后出现的值。
- 对于嵌套消息字段(embedded message fields):解析器会将多个实例合并(merge),类似于调用
Message::MergeFrom方法:- 后一个实例的单值字段会覆盖前一个;
- 嵌套消息会被合并;
- 重复字段的元素会被拼接。
这种机制的结果是:
- 解析连接的两个消息的效果,与分别解析它们再合并结果对象的效果完全相同。
也就是说,以下两种写法是等价的:
MyMessage message;
message.ParseFromString(str1 + str2);
等价于:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这种特性在某些场景下非常有用,因为它允许在不知道消息类型的情况下,仅通过拼接就能合并两个消息。
7.3 Map 字段
map 字段实际上只是一个特殊的 repeated 字段语法糖。
如:
message Test6 {map<string, int32> g = 7;
}
等价于:
message Test6 {message g_Entry {string key = 1;int32 value = 2;}repeated g_Entry g = 7;
}
因此,map 的编码方式几乎与重复消息字段完全相同:
- 编码为一系列
LEN类型的记录,每个记录包含两个字段(key 和 value)。
唯一的例外是:
- map 的键值顺序在序列化时不保证被保留。
8. Groups(组)
Group 是一种已经被弃用(deprecated)的旧特性,不应再使用,但它仍然存在于底层的 wire 格式中,因此需要简单说明。
Group 的作用类似于子消息(submessage),但不同之处在于它使用特殊的起止标签(tags)来标识边界,而不是用 LEN 长度前缀。
如,一个字段号为 8 的 group:
- 以 8:SGROUP 标签开始(SGROUP 记录无负载,表示 group 的起点);
- 包含该 group 的所有字段;
- 以 8:EGROUP 标签结束(EGROUP 记录也无负载,表示 group 的终点)。
字段号必须匹配。
如果遇到不匹配的结束标签,如在期望 8:EGROUP 时出现 7:EGROUP,则说明该消息格式错误(malformed)。
Protoscope 为书写 groups 提供了一种更简洁的语法。
传统写法如下:
8:SGROUP1: 23: {"foo"}
8:EGROUP
而使用 Protoscope,可以写成:
8: !{1: 23: {"foo"}
}
这段语法会自动生成相应的起始和结束组标记。
!{} 语法只能出现在一个无类型标签表达式(un-typed tag expression)之后,如 8:。
9. 字段顺序(Field Order)
在 .proto 文件中,字段编号(field numbers)可以以任意顺序声明。
字段的声明顺序不会影响消息的序列化方式。
当消息被序列化时,其字段的输出顺序并不固定。
无论是已知字段还是 未知字段,输出顺序都取决于实现细节,而这些细节在不同实现中可能有所不同,甚至在将来的版本中也可能发生变化。
因此,protocol buffer 解析器必须能够解析任意顺序的字段。
9.1 含义与注意事项(Implications)
- 不要假设序列化后消息的二进制输出是稳定的。
这在消息中嵌套其他序列化的 proto(如 bytes 字段)时尤为重要。 - 默认情况下,对同一个消息对象多次调用序列化方法,输出的字节流不一定完全相同。
即默认序列化是非确定性的(non-deterministic)。- 确定性序列化(deterministic serialization) 仅保证在同一个二进制程序内输出一致,不同版本的程序可能仍然输出不同的字节序列。
- 因此,下列检查可能会失败:
foo.SerializeAsString() == foo.SerializeAsString() Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString()) CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString()) FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString()) - 以下是一些逻辑上等价的消息(
foo和bar)却可能序列化出不同字节输出的情形:bar由旧版本服务器序列化,部分字段被视为未知;bar由另一种编程语言实现的服务器序列化,字段顺序不同;bar中的某个字段使用了非确定性序列化;bar中的某个字段本身包含一个以不同方式序列化的子消息;bar由新版本服务器序列化,由于实现变化,字段顺序不同;foo与bar是相同子消息的不同连接顺序。
10. 编码后的 Proto 大小限制(Encoded Proto Size Limitations)
序列化后的 Protocol Buffer 消息必须小于 2 GiB。
许多实现会拒绝序列化或解析超过该限制的消息。
11. 精简参考卡(Condensed Reference Card)
以下是 Protobuf 二进制格式的核心规则速查表:
message ::= (tag value)*tag ::= (field << 3) bit-or wire_type;encoded as uint32 varintvalue ::= varint for wire_type == VARINT,i32 for wire_type == I32,i64 for wire_type == I64,len-prefix for wire_type == LEN,<empty> for wire_type == SGROUP or EGROUPvarint ::= int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;encoded as varints (sintN are ZigZag-encoded first)i32 ::= sfixed32 | fixed32 | float;encoded as 4-byte little-endian(float is IEEE 754 single-precision);memcpy of equivalent C types (u?int32_t, float)i64 ::= sfixed64 | fixed64 | double;encoded as 8-byte little-endian(double is IEEE 754 double-precision);memcpy of equivalent C types (u?int64_t, double)len-prefix ::= size (message | string | bytes | packed);size encoded as int32 varintstring ::= valid UTF-8 string (e.g. ASCII);max 2GB of bytesbytes ::= any sequence of 8-bit bytes;max 2GB of bytespacked ::= varint* | i32* | i64*,consecutive values of the type specified in `.proto`
更多信息可参阅
- Protoscope 语言参考(Protoscope Language Reference)
其中:
message := (tag value)*
消息(message) 被编码为一系列 标签(tag) 与 值(value) 的配对序列,可包含零个或多个这样的对。tag := (field << 3) bit-or wire_type
标签(tag) 是由.proto文件中定义的 字段编号(field number) 与 wire_type 组合而成。
其中,最低的 3 个比特位存储 wire_type,剩余的高位表示字段编号。value := varint for wire_type == VARINT, ...
值(value) 的存储方式取决于标签中指定的 wire_type。varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
varint类型可用于存储上述任意数据类型。i32 := sfixed32 | fixed32 | float
fixed32(即 wire_type = I32)可用于存储上述任意数据类型。i64 := sfixed64 | fixed64 | double
fixed64(即 wire_type = I64)可用于存储上述任意数据类型。len-prefix := size (message | string | bytes | packed)
长度前缀类型(length-prefixed) 的值由两部分组成:- 长度(
size),以 varint 形式编码; - 实际数据内容,可为 message、string、bytes 或 packed。
- 长度(
string := valid UTF-8 string (e.g. ASCII)
字符串必须使用 UTF-8 编码(如 ASCII 也是 UTF-8 的子集)。
字符串长度上限为 2GB。bytes := any sequence of 8-bit bytes
bytes字段可存储任意二进制数据,大小上限同样为 2GB。packed := varint* | i32* | i64*
packed 类型 用于连续存储多个相同类型的数值(如在.proto中定义的 repeated 字段)。
在 packed 编码中,只有第一个元素包含标签(tag),后续元素仅存储值,从而节省空间。
参考资料
[1] protobuf doc Protocol Buffers Encoding
