Protocol Buffers 设计哲学:向前向后兼容 + 极致紧凑。这两点的实现细节都在 wire format 里——理解 wire format 你就懂了 protobuf 的灵魂。
Wire format 总览
每个 protobuf 消息是一连串”字段记录”。每个记录的格式:
[varint tag][字段值]
tag 包含两部分:
tag = (field_number << 3) | wire_type
读 tag varint,右移 3 位拿到 field_number,最低 3 位是 wire_type。
五种 wire type
| Type | Code | 用于 | 字段值格式 |
|---|---|---|---|
| varint | 0 | int32/int64/uint32/uint64/sint*/bool/enum | 变长 1-10 字节 |
| i64 | 1 | fixed64/sfixed64/double | 固定 8 字节 |
| len | 2 | string/bytes/message/packed repeated | varint 长度 + 字节 |
| sgroup | 3 | 已废弃 | - |
| egroup | 4 | 已废弃 | - |
| i32 | 5 | fixed32/sfixed32/float | 固定 4 字节 |
只有 5 种活跃的 wire type,3 / 4 是 proto2 老语法的 group 类型,proto3 不再使用。
Varint 怎么编码
Varint 把整数拆成 7 位一组,从低位开始放,每字节最高位(continuation bit):
- 1 = 后面还有
- 0 = 这是最后一字节
例:编码数字 300。
300 的二进制(去掉前导零): 1 0010 1100
拆成 7 位组(从低位开始,不足补 0):
低组: 010 1100
高组: 000 0010
加 continuation bit:
低组: 1 010 1100 ← 后面还有
高组: 0 000 0010 ← 最后一字节
字节序: 0xAC 0x02
解码时反向——读到 continuation bit = 0 停止,把每组 7 位从低到高拼起来即可。
优势——小数字非常紧凑:
| 范围 | 字节数 |
|---|---|
| 0-127 | 1 |
| 128-16383 | 2 |
| 16384-2097151 | 3 |
| … | |
| 0 - 2^64-1 | 最多 10 |
业务字段大多在 0-127 范围(如布尔、枚举、小整数 ID),1 字节就够,比 JSON 强很多。
ZigZag:负数的妙招
标准 varint 编码负数会爆炸——-1 是 64 位全 1,要 10 字节。
ZigZag 把符号位和绝对值”交错”映射:
| 原值 | ZigZag 编码值 |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| 2 | 4 |
| -3 | 5 |
公式:encoded = (n << 1) ^ (n >> 63),反向 decoded = (encoded >> 1) ^ -(encoded & 1)。
所以 sint32 / sint64 表面上是 varint,实际编码值是 ZigZag 后的结果。负数小(绝对值小)的字段都能压到 1-2 字节。
经验法则——业务字段可能为负 → sint32/sint64;只可能非负 → int32/uint32。很多人混用,导致负数 ID 占 10 字节,是 protobuf 优化常见 case。
Length-delimited:万能容器
wire type 2 是最灵活的——结构是:
[varint tag][varint length][L 字节内容]
L 字节内容可能是:
- UTF-8 字符串(string 字段)
- 任意字节(bytes 字段)
- 嵌套 message(递归同样的 wire format)
- packed repeated 标量(多个 varint / fixed 紧凑排列)
wire 上没有任何线索说明 L 字节是哪一种——只有 .proto 能告诉你。
无 schema 解码器的策略:
- 尝试递归当 message 解 → 解通就显示嵌套结构
- 否则尝试 UTF-8 解码 → 全部字符可读就显示为字符串
- 都不行 → 显示为 hex / base64 bytes
有时会猜错——比如某个 string 字段恰好整体是合法的 protobuf 字节序列,工具会优先解成嵌套 message。这种情况贴 .proto 升级即可。
i32 / i64:定长字段
固定字节数,小端序(little-endian):
fixed32 / sfixed32 / float → 4 字节
fixed64 / sfixed64 / double → 8 字节
为什么有 fixed 类型 —— varint 对大数字(接近上界)效率反而低于定长。fixed64 永远 8 字节,int64 最多 10 字节——存高位经常占满的字段(如时间戳纳秒、UUID 高 64 位)用 fixed64 更小。
但业务字段很少需要 fixed——绝大多数 int 在低值范围,varint 更划算。fixed 一般用于哈希、UUID、固定宽度标识符。
一个完整例子
.proto:
message User {
int32 id = 1;
string name = 2;
repeated int32 tags = 3 [packed = true];
}
填充数据:id=300, name="Alice", tags=[1, 2, 300]。
字节序列(hex):
08 AC 02 # field 1, varint, 300
12 05 # field 2, length-delimited, length=5
41 6C 69 63 65 # "Alice"
1A 04 # field 3, length-delimited, length=4
01 02 AC 02 # packed varints: 1, 2, 300
手算分析:
08= 0b00001000 = (1 << 3) | 0 = field 1, wire type 0 (varint)AC 02= varint 30012= 0b00010010 = (2 << 3) | 2 = field 2, wire type 2 (len)05= length 541 6C 69 63 65= ASCII “Alice”1A= 0b00011010 = (3 << 3) | 2 = field 3, wire type 2 (len)04= length 401 02 AC 02= packed: varint 1, varint 2, varint 300
总长 17 字节——同样的数据 JSON {"id":300,"name":"Alice","tags":[1,2,300]} 是 41 字节,protobuf 节省 58%。
Unknown fields 与兼容性
protobuf 的兼容性核心:老解析器遇到不认识的 field number 不报错,保留为”unknown fields”。
这意味着:
- 加新字段(新 number)—— 老服务忽略,新服务能读
- 删字段 —— 老服务以为是 unknown,不影响
- 改 field number —— 灾难,等于改了字段身份
- 改字段类型 —— 部分兼容(int32 ↔ int64 ↔ bool ↔ enum 互通;string ↔ bytes 互通;其它要小心)
proto3 早期默认丢弃 unknown fields,导致代理转发场景下数据被吞——3.5 起改回保留。
本工具的 _unknown_<n> 显示就是把 wire 上没在 .proto 里声明的 tag 都列出来,方便你发现”哎对方加了新字段”。
调试 gRPC 的关键
抓包到 gRPC body 后永远先剥前 5 字节 framing:
[1 byte compression flag][4 bytes big-endian length][message bytes]
如果 compression flag = 1,还要按 grpc-encoding header(如 gzip、deflate、identity)解压。
忘记剥 framing 是新手最常见错误——前 5 字节会被当成第一个字段的 tag + length,解出莫名其妙的结构。
Wire format vs Text format
protobuf 有两种序列化格式:
| Format | 用途 | 文件后缀 |
|---|---|---|
| Wire format | RPC / 存储 / 网络传输 | 二进制(无固定后缀) |
| Text format | 调试 / 配置文件 | .textproto / .pb.txt |
Text format 是 field_name: value 的人类可读形式(看起来像 JSON 但语法不同)。两者互相转换需要 .proto——不能直接 wire ↔ text。本工具只处理 wire format。
配套工具
- PCAP 抓包查看 — gRPC 通过 HTTP/2 走 TLS,要解密需要 SSLKEYLOGFILE
- Hex 二进制查看 — 看二进制结构、识别魔数
- JSON 工具 — 解出 JSON 后做 JSONPath 分析
理解 wire format 三件事——tag 编码、varint、length-delimited——你就懂了 protobuf 80% 的二进制内部。剩下 20% 是历史包袱(group / proto2 default values / extensions),新项目大多碰不到。