目录

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 里最核心的编码技巧:小的数字占用少的字节

规则只有两条:

  1. 每个字节用低 7 位存数据,最高位(MSB)是延续位——为 1 表示「后面还有字节」,为 0 表示「我是最后一个字节」。
  2. 字节序是低位组在前(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 的解法

sint32sint64 在 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。

stringbytes、嵌套 message 的 repeated 不能 packed(它们本身就是 LEN,长度不定),所以只有数值类型(含 boolenum)才有 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。stringbytes 总是安全;bytesstring 要求内容是合法 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)。

⑪ 为什么 maprepeated entry message 兼容? 因为 map 只是语法糖,编译器把它展开成 repeated MapEntry,其中 key = 1value = 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 的完整写法过一遍——你会发现大部分规则都在本篇有了解释。


系列目录

  1. 从 wire format 开始:一个字节一个字节读懂编码(本篇)
  2. proto3 语法完全指南
  3. 存在性 presence:Protobuf 最难的一章
  4. schema 演进与兼容性:哪些改动会炸
  5. Well-Known Types 与 JSON 映射
  6. Go 工程化实践:buf 工具链与 API 用法
  7. proto2 与 Protobuf Editions