写在前面
写过 gRPC 或 Dubbo 的人都见过 .proto 文件:
| |
编译生成代码、序列化、传输——业务里只用到几行 API,但底层 Protobuf 的二进制格式做了非常多巧妙的设计。
理解 Protobuf 的内部原理对工程有几个直接价值:
- 能解释为什么 Protobuf 比 JSON 小一半——不只是"去掉了字段名"那么简单
- 能避免字段编号变更导致的兼容性灾难
- 能在性能敏感场景做出精准选型
本文从 wire format 到 Varint 到字段标签,把 Protobuf 的核心机制讲清楚。
一、为什么不是 JSON
JSON 的问题:
- 字段名占大量空间——
{"username": "alice", "age": 30}字段名比值还长 - 数字编码冗余——123456789 当字符串编码要 9 字节
- 解析慢——要 tokenize、要 parse、要 build object
- 无 schema——不知道字段类型,要靠运行时推断
Protobuf 的解法:
- 字段名替换为编号——
username变成1,age变成2 - 数字用 Varint——小数字用更少字节
- 二进制 stream 解析——按 schema 直接读
- schema 是契约——sender / receiver 都依赖同一个 .proto
二、Wire Format:字段的二进制表示
每个字段在 wire 上都是 (tag, value) 的形式。tag 由两部分组成:
| |
- field_number:在 .proto 里定义的字段编号(如
name = 2中的 2) - wire_type:3 位,表示 value 的编码方式
Wire Types
| Type | 名称 | 用途 |
|---|---|---|
| 0 | Varint | int32 / int64 / uint32 / uint64 / bool / enum |
| 1 | 64-bit | fixed64 / sfixed64 / double |
| 2 | Length-delim | string / bytes / message / packed |
| 5 | 32-bit | fixed32 / sfixed32 / float |
类型 3、4 是 group 类型(已弃用)。
例子
message Person { string name = 1; int32 age = 2; },对应数据 {name: "abc", age: 30}:
| |
只有 7 字节——而 JSON 的 {"name":"abc","age":30} 是 22 字节。
三、Varint:变长整数编码
Varint 是 Protobuf 的核心创新——小数字用更少字节,大数字才用多字节。
编码规则
每个字节的最高位(MSB)作为"还有更多字节"的标志:
- MSB = 1 → 后面还有
- MSB = 0 → 这是最后一个字节
- 其余 7 位是 payload
编码示例
数字 1:
| |
数字 300:
| |
数字越小占的字节越少:
| 数字范围 | Varint 字节数 |
|---|---|
| 0 - 127 | 1 |
| 128 - 16383 | 2 |
| 16384 - 2M | 3 |
| 2M - 268M | 4 |
| 268M+ | 5 |
业务里 ID 多数小于 2 亿——Varint 比定长 8 字节 int64 节省 50% 以上。
Varint 的代价
- 负数特别大:-1 在补码下是
0xFFFFFFFF...FFFF(10 字节),所有负数都用 10 字节 - 解决:
sint32 / sint64用 ZigZag 编码——正负数交替排列,让 -1, 1, -2, 2 都很小
| |
有负数的字段一定要用 sint32 / sint64——否则空间浪费惊人。
四、Length-delimited:字符串和子消息
string、bytes、嵌套 message、packed repeated 都用 length-delimited:
| |
length 也是 Varint——所以 1 字节就能表示 0-127 字节的内容。
嵌套 message
| |
嵌套消息不是"展开"——而是当作一段嵌入的二进制流:
| |
这意味着——Protobuf 的解析器是递归的,但内存效率极高。
五、字段编号:兼容性的命脉
字段编号是 Protobuf 兼容性的核心约定——一旦定了,永不能改:
| |
兼容规则
- 新增字段 → 老 client 解析时跳过未知字段(不报错)
- 删除字段 → 字段编号永不复用,加
reserved防止 - 改类型 → 大多数情况会破坏兼容(除少数兼容对,如
int32 ↔ int64) - 改字段名 → 不影响 wire(wire 只用编号)但破坏代码层面 API
reserved 的用法
| |
防止后人不小心复用了已删除的字段编号——会编译错误。
字段编号的成本
- 1-15 编号占 1 字节 tag
- 16-2047 占 2 字节
- 2048+ 占 3 字节
高频字段尽量用 1-15 编号——能省一字节。
六、Repeated 和 packed 编码
| |
默认编码(proto2):每个元素单独发一遍 tag + value:
| |
[packed = true](proto3 默认):合并成单个 length-delimited 块:
| |
packed 显著节省空间——尤其当数组元素都是小数字时。proto3 默认 packed,proto2 要显式声明。
七、Proto2 vs Proto3
Proto2
- 字段标记
required/optional/repeated - 默认值显式声明
- 区分"未设置"和"等于默认值"
Proto3
- 删掉
required和optional(后又部分恢复) - 字段总是 optional
- 默认值不发送
- packed 默认开启
现状
- 新项目用 proto3——更简洁
- 2020 年后 proto3 又支持
optional——能区分"没设" vs “等于默认值” - 老项目用 proto2 也没问题,但社区主要在 proto3
八、性能对比
| JSON | Protobuf | Avro | MessagePack | |
|---|---|---|---|---|
| 体积(典型业务对象) | 1× | 0.4-0.5× | 0.4× | 0.6× |
| 序列化速度 | 1× | 5-10× | 5× | 3× |
| 反序列化速度 | 1× | 5-10× | 5× | 3× |
| 跨语言 | ✓ | ✓ | ✓ | ✓ |
| 人类可读 | ✓ | ✗ | ✗ | ✗ |
| Schema 强约束 | ✗ | ✓ | ✓ | ✗ |
Protobuf 的核心优势:体积 + 速度 + schema 一致性。核心劣势:不可读 + 必须先有 schema。
九、什么场景用 Protobuf
✅ 适合:
- gRPC 服务调用——Protobuf 是 gRPC 默认序列化
- 微服务间通信——schema 强约束,避免字段约定问题
- 移动端 / IoT ——节省带宽
- 批量数据处理(Hadoop / Kafka)——高吞吐
- 存储优化——某些 KV 存储用 Protobuf 序列化 value
❌ 不适合:
- 对外 REST API——前端期望 JSON
- 小项目 / 调试期——要 .proto 文件 + 编译器,启动成本高
- schema 频繁变化——每次都要发版
- 需要人类可读——Protobuf 没法直接看
十、几个工程踩坑
1. 字段编号永不复用
| |
老 client 拿到新数据时会把 new_field 的内容当成 old_field 解析——数据被错乱。永远 reserved。
2. enum 默认值
| |
proto3 强制 enum 必须有 0 值——这个值代表"未设置"。业务设计 enum 时考虑这个。
3. 大消息分包
某些 RPC 框架对单个 message 大小有限制(如 gRPC 默认 4MB)——大数据要 stream 而非单包:
| |
4. 字符串编码
Protobuf string 必须是合法 UTF-8——塞二进制数据用 bytes 类型,否则解析会失败。
5. Optional 的 has 方法
proto3 的 optional 字段才能区分"没设"和"等于默认值"——普通字段无法区分:
| |
业务模型设计时考虑——重要的"是否存在"语义字段加 optional。
十一、Protobuf 与 gRPC 的关系
很多人误以为"Protobuf = gRPC"——其实它们独立:
- Protobuf:序列化协议
- gRPC:基于 HTTP/2 的 RPC 框架,默认用 Protobuf
Protobuf 也能用在:
- 不走 gRPC 的 RPC(如 Dubbo3 的 Triple 协议)
- Kafka 消息序列化
- 自己写的二进制协议
- 文件存储
小结
把全文压一句:
Protobuf 不只是『二进制 JSON』——它是 schema-driven 序列化的范式:用编号代替字段名、用 Varint 节省空间、用 wire type 保证可解析、用兼容规则保证演进。
工程要点:
- 字段编号永不复用
- 小数字用 Varint,负数用 sint
- 重要字段用 1-15 编号
- enum 必须有 0 值
- packed repeated 默认开
- 大数据用 stream RPC
理解 Protobuf 的内部原理——下次再排查"为什么我的 protobuf 数据 client 解不出来"时,你能瞬间想到是字段编号被改了还是类型不兼容。