Protobuf-01 从 wire format 开始:一个字节一个字节读懂编码
本文是 Protobuf 系列的第 1 篇。
大多数教程从「怎么写 .proto」讲起,本系列反过来,先讲字节。原因很简单:不懂编码格式的人,只能把「字段号不能改」「1–15 号珍贵」「required 有害」「string 和 bytes 可以互换」当成一堆互不相干的戒律死记;懂了编码格式,它们全都是同一个事实的推论。这一篇读完,后面几篇的规则你基本可以自己推出来。
1. 先做个实验
定义一个最简单的消息:
syntax = "proto3";
message Test1 {
int32 a = 1;
}
给 a 赋值 150,序列化,然后把结果按十六进制打印出来:
m := &pb.Test1{A: 150}
b, _ := proto.Marshal(m)
fmt.Printf("% x\n", b) // 08 96 01
三个字节:08 96 01。
对比一下 JSON 的 {"a":150}——9 个字节,而且其中 6 个字节花在字段名和标点上。Protobuf 用 3 个字节表达了同样的信息,秘密就在这三个字节的结构里:
08 96 01
└─ tag └─ value
Protobuf 的消息,本质上就是一串 (tag, value) 记录的顺序拼接,没有开头、没有结尾、没有分隔符、没有字段名。就这么简单。剩下的所有复杂性,都在于 tag 和 value 各自怎么编码。
2. tag:字段号与 wire type 的合体
第一个字节 0x08 是 tag,它是一个 varint(见下一节),解开后是一个整数,内部由两部分位拼成:
tag = (field_number << 3) | wire_type
也就是低 3 位是 wire type,其余高位是字段号。
0x08 = 0b0000_1000:低 3 位 000 = wire type 0,高位 1 = 字段号 1。正好对应我们定义的 int32 a = 1。
wire type 只有 3 位,所以最多 8 种,实际用到 6 种:
| wire type | 名称 | 含义 | 对应的 .proto 类型 |
|---|---|---|---|
| 0 | VARINT | 变长整数 | int32 int64 uint32 uint64 sint32 sint64 bool enum |
| 1 | I64 | 固定 8 字节 | fixed64 sfixed64 double |
| 2 | LEN | 长度前缀 + 内容 | string bytes 嵌套 message、packed repeated |
| 3 | SGROUP | group 开始 | 已废弃 |
| 4 | EGROUP | group 结束 | 已废弃 |
| 5 | I32 | 固定 4 字节 | fixed32 sfixed32 float |
这张表是整个 Protobuf 兼容性规则的源头,第 6 节会回来算账。
3. varint:变长整数编码
varint 是 Protobuf 里最核心的编码技巧:小的数字占用少的字节。
规则只有两条:
- 每个字节用低 7 位存数据,最高位(MSB)是延续位——为 1 表示「后面还有字节」,为 0 表示「我是最后一个字节」。
- 字节序是低位组在前(little-endian group order)。
手工编码 150:
150 = 0b1001_0110
按 7 位分组 = 0b0000001 0010110 (高位组 低位组)
低位组在前 = 0010110 0000001
第一组加延续位 1 = 1_0010110 = 0x96
最后一组延续位 0 = 0_0000001 = 0x01
结果 = 96 01
反过来解码 96 01:
0x96 = 1_0010110 → MSB=1,还有后续,取 7 位 0010110
0x01 = 0_0000001 → MSB=0,结束, 取 7 位 0000001
拼接(后来的在高位)= 0000001 0010110 = 0b10010110 = 150
于是我们得到 varint 的字节数规律:
| 数值范围 | 字节数 |
|---|---|
| 0 ~ 127 | 1 |
| 128 ~ 16,383 | 2 |
| 16,384 ~ 2,097,151 | 3 |
| … | … |
| 最大 64 位无符号 | 10 |
3.1 负数的陷阱:一个 -1 要 10 个字节
varint 没有对「高位全是 1」做任何优化,而负数在补码表示下高位全是 1。更糟的是,int32 的负数会先被符号扩展成 64 位再编码:
m := &pb.Test1{A: -1}
b, _ := proto.Marshal(m)
fmt.Printf("% x\n", b)
// 08 ff ff ff ff ff ff ff ff ff 01 ← tag 1 字节 + value 整整 10 字节
所以官方文档里那句「如果字段可能有负值,请改用 sint32」不是玄学:int32 存任何负数都固定占 10 字节。
3.2 ZigZag:sint32 / sint64 的解法
sint32、sint64 在 varint 之前先做一层 ZigZag 变换,把有符号数「折叠」到无符号数上,让绝对值小的数(不论正负)都映射成小的无符号数:
sint32: encoded = (n << 1) ^ (n >> 31)
sint64: encoded = (n << 1) ^ (n >> 63)
解码: n = (encoded >> 1) ^ -(encoded & 1)
效果就是正负交替排列(这也是 ZigZag 这个名字的来历):
| 原值 | 编码后 |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| 2 | 4 |
| … | … |
| 2147483647 | 4294967294 |
| -2147483648 | 4294967295 |
于是 -1 只需 1 个字节,而不是 10 个。
选型结论:
- 值几乎总是非负 →
int32/int64 - 值可能为负、且绝对值通常较小 →
sint32/sint64 - 值几乎总是很大(比如哈希、随机 ID) →
fixed32/fixed64(固定长度反而更省,varint 编码大数要 5 或 10 字节)
4. LEN:长度前缀类型
wire type 2 的结构是 tag + 长度(varint) + 内容。它承载了四类东西:字符串、字节串、嵌套 message、packed repeated 字段。
4.1 字符串
message Test2 {
string b = 2;
}
b = "testing":
12 tag:(2 << 3) | 2 = 18 = 0x12
07 长度 7
74 65 73 74 69 6e 67 "testing" 的 UTF-8 字节
4.2 嵌套 message
message Test3 {
Test1 c = 3;
}
c.a = 150:
1a tag:(3 << 3) | 2 = 26 = 0x1a
03 长度 3
08 96 01 ← Test1 自己的完整编码,原封不动嵌在这里
嵌套 message 的编码就是它独立序列化的结果,前面加一个长度。这也解释了为什么把一个 message 字段的类型改成 bytes 在 wire 层面是兼容的——它们本来就长得一样。
4.3 packed:repeated 数值的打包
假设:
message Test5 {
repeated int32 f = 6; // proto3 中数值型 repeated 默认 packed
}
赋值 [3, 270, 86942]:
32 tag:(6 << 3) | 2 = 50 = 0x32 ← 注意是 LEN,不是 VARINT
06 总长度 6 字节
03 3
8e 02 270
9e a7 05 86942
如果不 packed(proto2 的默认行为),每个元素都要重复一遍 tag:
30 03 tag + 3
30 8e 02 tag + 270
30 9e a7 05 tag + 86942
9 字节 vs 8 字节,这里差别不大;但存 100 个小整数时差别是 200 字节 vs 102 字节——省掉的正是 99 个重复的 tag。
string、bytes、嵌套 message 的 repeated 不能 packed(它们本身就是 LEN,长度不定),所以只有数值类型(含bool、enum)才有 packed 概念。
5. 完整解码练习
给你一段字节流,不看 .proto 文件,试着读出结构:
0a 03 61 62 63 10 e8 07 1a 04 08 01 10 02 28 96 01
一步步来:
| 字节 | 解析 |
|---|---|
0a |
tag = 10 → 字段号 1,wire type 2(LEN) |
03 61 62 63 |
长度 3,内容 "abc" |
10 |
tag = 16 → 字段号 2,wire type 0(VARINT) |
e8 07 |
varint = 0x68 + (0x07 « 7) = 104 + 896 = 1000 |
1a |
tag = 26 → 字段号 3,wire type 2(LEN) |
04 08 01 10 02 |
长度 4,是个嵌套 message:字段 1 = 1,字段 2 = 2 |
28 |
tag = 40 → 字段号 5,wire type 0 |
96 01 |
varint = 150 |
所以原始定义大约长这样(注意字段 4 不存在——可能被删了,也可能只是没赋值,从字节流无法区分):
message Demo {
string name = 1; // "abc"
int32 count = 2; // 1000
Inner inner = 3; // {1, 2}
int32 extra = 5; // 150
}
不想手算的话,官方工具可以直接干这件事,连 .proto 文件都不需要:
# 十六进制转二进制文件
echo "0a0361626310e8071a040801100228 9601" | tr -d ' ' | xxd -r -p > msg.bin
# 无需 schema,直接猜结构
protoc --decode_raw < msg.bin
# 有 schema 时,解出字段名
protoc --decode=Demo demo.proto < msg.bin
更专业的检视工具是官方的 protoscope,能双向转换字节流和可读文本:
go install github.com/protocolbuffers/protoscope/cmd/protoscope@latest
protoscope msg.bin
6. 于是,那些规则都不用背了
现在回头看常见的 Protobuf 规则,它们全部是上面这套编码的直接推论。
① 为什么字段号一旦使用就不能改? 因为字节流里根本没有字段名,字段号就是字段唯一的身份。改了号,旧数据里的那个字段对新代码来说就变成了「一个不认识的字段」。
② 为什么改字段名是安全的? 同上——名字不参与二进制编码。但要注意:JSON 和 text format 是用名字的,所以改名对这两种格式是破坏性的,对外 API 要谨慎。
③ 为什么字段号 1–15 要留给高频字段?
tag 本身是 varint。字段号 15 时 tag 最大是 15 << 3 | 7 = 127,刚好在单字节 varint 的上限内;字段号 16 时 tag ≥ 128,就要占 2 个字节了。每个高频字段每条消息省 1 字节,量大时很可观。
④ 为什么最大字段号是 2^29 - 1?
tag 作为 32 位 varint 最多能表达 32 位,低 3 位被 wire type 占掉,剩下 29 位给字段号 → 最大 2^29 - 1 = 536,870,911。
⑤ 为什么解析器能跳过不认识的字段(向前兼容的根基)? 因为 tag 里带着 wire type,解析器即使不认识字段号,也知道该跳过多少字节:VARINT 一直读到 MSB=0,LEN 读出长度直接跳,I32/I64 固定跳 4/8 字节。正因如此,新版发的数据老版能读,老版转发时还能把这些未知字段原样带回去(proto3 从 3.5 起恢复了这一行为)。
⑥ 为什么 int32 int64 uint32 uint64 bool enum 之间可以互换?
它们共用 wire type 0,字节流完全同构。但要小心值域截断:把 int64 的大值读成 int32,行为等同于 C++ 里的强制转换(高位被砍掉)。
⑦ 为什么 sint32 不能和 int32 互换?
虽然都是 wire type 0,但 sint 多了一层 ZigZag。同样的字节,用 int32 读到 2,用 sint32 读到 1——不报错,静默读出错误的值,这类 bug 极难排查。
⑧ 为什么 string / bytes / 嵌套 message 之间兼容?
都是 LEN。string 换 bytes 总是安全;bytes 换 string 要求内容是合法 UTF-8;嵌套 message 换 bytes 得到的就是它的序列化结果。
⑨ 为什么同一个非 repeated 字段重复出现不报错?
解析器就是顺序扫描:标量字段后来的覆盖先前的(last-one-wins),message 字段则递归合并。这正是「string/bytes/message 的 singular ↔ repeated 可以互换」的原因——多个值来了,取最后一个即可。
⑩ 那为什么数值类型的 singular ↔ repeated 不安全?
因为 proto3 里数值型 repeated 默认 packed,wire type 是 2;而 singular 数值是 wire type 0。两者对不上,解析直接失败(某些库会报 expects wire type 0 but found 2)。
⑪ 为什么 map 和 repeated entry message 兼容?
因为 map 只是语法糖,编译器把它展开成 repeated MapEntry,其中 key = 1、value = 2。字节层面二者完全一致。
⑫ 为什么 required 是个错误的设计?
序列化格式本身完全没有「必填」的表达方式——required 纯粹是运行时的校验逻辑。而校验规则一旦写进 schema 就无法安全地改(放宽会让旧版拒绝解析,收紧会让旧数据全部失效),所以 proto3 干脆移除了它,把校验交回业务层。
7. 一个容易踩的坑:不要拿序列化结果做等价判断
看到这里你可能会想:既然序列化是确定的,那比较两个消息是不是相等,直接比字节不就行了?
不行。 Protobuf 明确不保证序列化字节的稳定性:
- 字段的写出顺序不作保证(实现通常按字段号升序,但这是实现细节而非规范);
- map 的迭代顺序是随机的,Go 的 map 遍历顺序每次都不同;
- 未知字段会被原样保留并透传,不同来源的消息可能带着不同的未知字段;
- 同一个值的 varint 编码在极端情况下可以有冗余表示。
所以:
// ❌ 错:既可能漏判也可能误判
b1, _ := proto.Marshal(m1)
b2, _ := proto.Marshal(m2)
same := bytes.Equal(b1, b2)
// ✅ 对:用语义比较
same := proto.Equal(m1, m2)
同理,不要对「重新序列化的结果」做签名或哈希校验。Go 里虽然有确定性选项:
b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(m)
但它只保证同一个二进制版本内 map 顺序稳定,官方明确不承诺跨语言、跨版本的字节一致。需要签名时,请对收到的原始字节签名,而不是解析后重新编码的字节。
8. 小结
- Protobuf 消息 = 一串
(tag, value),没有字段名、没有分隔符,这是它比 JSON 小的根本原因。 tag = (字段号 << 3) | wire type,低 3 位决定「该怎么读接下来的字节」,也决定了「不认识的字段该跳过多少字节」。- 六种 wire type 里真正常用的是三种:VARINT(0)、LEN(2)、I32/I64(5/1)。同 wire type 的类型之间大多可以安全互换,跨 wire type 一律不行。
- varint 让小整数省空间,代价是负数极其浪费(
int32存负数固定 10 字节),sint用 ZigZag 解决这个问题。 - proto3 的数值型 repeated 默认 packed,省掉重复 tag,同时也让它和 singular 数值不再兼容。
- 序列化字节不是规范化表示,判等用
proto.Equal,签名用原始字节。
下一篇进入语法层,把 proto3 的完整写法过一遍——你会发现大部分规则都在本篇有了解释。
系列目录
- 从 wire format 开始:一个字节一个字节读懂编码(本篇)
- proto3 语法完全指南
- 存在性 presence:Protobuf 最难的一章
- schema 演进与兼容性:哪些改动会炸
- Well-Known Types 与 JSON 映射
- Go 工程化实践:buf 工具链与 API 用法
- proto2 与 Protobuf Editions
xingliuhua