Featured image of post Protobuf 协议原理解析:为什么它能比 JSON 小一半还快几倍

Protobuf 协议原理解析:为什么它能比 JSON 小一半还快几倍

Protobuf 不只是『二进制 JSON』——它的紧凑编码、字段编号、Varint、wire type 都有精心设计。本文讲透原理

写在前面

写过 gRPC 或 Dubbo 的人都见过 .proto 文件:

1
2
3
4
5
message User {
    int64 id = 1;
    string name = 2;
    repeated string tags = 3;
}

编译生成代码、序列化、传输——业务里只用到几行 API,但底层 Protobuf 的二进制格式做了非常多巧妙的设计。

理解 Protobuf 的内部原理对工程有几个直接价值:

  • 能解释为什么 Protobuf 比 JSON 小一半——不只是"去掉了字段名"那么简单
  • 能避免字段编号变更导致的兼容性灾难
  • 能在性能敏感场景做出精准选型

本文从 wire format 到 Varint 到字段标签,把 Protobuf 的核心机制讲清楚。


一、为什么不是 JSON

JSON 的问题:

  • 字段名占大量空间——{"username": "alice", "age": 30} 字段名比值还长
  • 数字编码冗余——123456789 当字符串编码要 9 字节
  • 解析慢——要 tokenize、要 parse、要 build object
  • 无 schema——不知道字段类型,要靠运行时推断

Protobuf 的解法:

  • 字段名替换为编号——username 变成 1age 变成 2
  • 数字用 Varint——小数字用更少字节
  • 二进制 stream 解析——按 schema 直接读
  • schema 是契约——sender / receiver 都依赖同一个 .proto

二、Wire Format:字段的二进制表示

每个字段在 wire 上都是 (tag, value) 的形式。tag 由两部分组成:

1
tag = (field_number << 3) | wire_type
  • field_number:在 .proto 里定义的字段编号(如 name = 2 中的 2)
  • wire_type:3 位,表示 value 的编码方式

Wire Types

Type名称用途
0Varintint32 / int64 / uint32 / uint64 / bool / enum
164-bitfixed64 / sfixed64 / double
2Length-delimstring / bytes / message / packed
532-bitfixed32 / sfixed32 / float

类型 3、4 是 group 类型(已弃用)。

例子

message Person { string name = 1; int32 age = 2; },对应数据 {name: "abc", age: 30}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
field 1, wire_type 2 (string):
  tag = (1<<3) | 2 = 0x0A
  length = 3 (varint)
  value = "abc"  61 62 63

field 2, wire_type 0 (varint):
  tag = (2<<3) | 0 = 0x10
  value = 30 (varint)  0x1E

整个二进制:
  0A 03 61 62 63 10 1E

只有 7 字节——而 JSON 的 {"name":"abc","age":30} 是 22 字节。


三、Varint:变长整数编码

Varint 是 Protobuf 的核心创新——小数字用更少字节,大数字才用多字节

编码规则

每个字节的最高位(MSB)作为"还有更多字节"的标志:

  • MSB = 1 → 后面还有
  • MSB = 0 → 这是最后一个字节
  • 其余 7 位是 payload

编码示例

数字 1:

1
2
binary: 00000001
Varint: 00000001  (1 byte)

数字 300:

1
2
3
4
5
binary:    100101100   (9 bits)
groups of 7: 0000010 0101100
按小端:      0101100 0000010
加 MSB:      1010 1100 0000 0010
hex:         AC 02

数字越小占的字节越少:

数字范围Varint 字节数
0 - 1271
128 - 163832
16384 - 2M3
2M - 268M4
268M+5

业务里 ID 多数小于 2 亿——Varint 比定长 8 字节 int64 节省 50% 以上

Varint 的代价

  • 负数特别大:-1 在补码下是 0xFFFFFFFF...FFFF(10 字节),所有负数都用 10 字节
  • 解决sint32 / sint64 用 ZigZag 编码——正负数交替排列,让 -1, 1, -2, 2 都很小
1
2
3
4
5
sint32 编码: (n << 1) ^ (n >> 31)
-1 → 1
1 → 2
-2 → 3
2 → 4

有负数的字段一定要用 sint32 / sint64——否则空间浪费惊人。


四、Length-delimited:字符串和子消息

string、bytes、嵌套 message、packed repeated 都用 length-delimited:

1
[tag][length][bytes...]

length 也是 Varint——所以 1 字节就能表示 0-127 字节的内容。

嵌套 message

1
2
3
4
5
message Address { string city = 1; }
message Person {
    string name = 1;
    Address addr = 2;
}

嵌套消息不是"展开"——而是当作一段嵌入的二进制流:

1
2
3
4
field 2 (addr):
  tag = (2<<3) | 2 = 0x12
  length = N (varint)
  value = [Address 的完整序列化字节]

这意味着——Protobuf 的解析器是递归的,但内存效率极高。


五、字段编号:兼容性的命脉

字段编号是 Protobuf 兼容性的核心约定——一旦定了,永不能改:

1
2
3
4
5
message User {
    int64 id = 1;
    string name = 2;
    string email = 3;     // 之后加的
}

兼容规则

  • 新增字段 → 老 client 解析时跳过未知字段(不报错)
  • 删除字段 → 字段编号永不复用,加 reserved 防止
  • 改类型 → 大多数情况会破坏兼容(除少数兼容对,如 int32 ↔ int64
  • 改字段名 → 不影响 wire(wire 只用编号)但破坏代码层面 API

reserved 的用法

1
2
3
4
5
6
7
8
message User {
    reserved 4, 5;
    reserved "old_field";

    int64 id = 1;
    string name = 2;
    // ...
}

防止后人不小心复用了已删除的字段编号——会编译错误。

字段编号的成本

  • 1-15 编号占 1 字节 tag
  • 16-2047 占 2 字节
  • 2048+ 占 3 字节

高频字段尽量用 1-15 编号——能省一字节。


六、Repeated 和 packed 编码

1
repeated int32 ids = 4;

默认编码(proto2):每个元素单独发一遍 tag + value

1
tag value tag value tag value ...

[packed = true](proto3 默认):合并成单个 length-delimited 块:

1
tag length value value value ...

packed 显著节省空间——尤其当数组元素都是小数字时。proto3 默认 packed,proto2 要显式声明。


七、Proto2 vs Proto3

Proto2

  • 字段标记 required / optional / repeated
  • 默认值显式声明
  • 区分"未设置"和"等于默认值"

Proto3

  • 删掉 requiredoptional(后又部分恢复)
  • 字段总是 optional
  • 默认值不发送
  • packed 默认开启

现状

  • 新项目用 proto3——更简洁
  • 2020 年后 proto3 又支持 optional——能区分"没设" vs “等于默认值”
  • 老项目用 proto2 也没问题,但社区主要在 proto3

八、性能对比

JSONProtobufAvroMessagePack
体积(典型业务对象)0.4-0.5×0.4×0.6×
序列化速度5-10×
反序列化速度5-10×
跨语言
人类可读
Schema 强约束

Protobuf 的核心优势:体积 + 速度 + schema 一致性。核心劣势:不可读 + 必须先有 schema。


九、什么场景用 Protobuf

✅ 适合:

  • gRPC 服务调用——Protobuf 是 gRPC 默认序列化
  • 微服务间通信——schema 强约束,避免字段约定问题
  • 移动端 / IoT ——节省带宽
  • 批量数据处理(Hadoop / Kafka)——高吞吐
  • 存储优化——某些 KV 存储用 Protobuf 序列化 value

❌ 不适合:

  • 对外 REST API——前端期望 JSON
  • 小项目 / 调试期——要 .proto 文件 + 编译器,启动成本高
  • schema 频繁变化——每次都要发版
  • 需要人类可读——Protobuf 没法直接看

十、几个工程踩坑

1. 字段编号永不复用

1
2
3
4
message User {
    // 删除了 string old_field = 4;
    string new_field = 4;    // ❌ 不能复用编号 4!
}

老 client 拿到新数据时会把 new_field 的内容当成 old_field 解析——数据被错乱。永远 reserved

2. enum 默认值

1
2
3
4
enum Status {
    PENDING = 0;     // 必须有 0 值(默认)
    ACTIVE = 1;
}

proto3 强制 enum 必须有 0 值——这个值代表"未设置"。业务设计 enum 时考虑这个。

3. 大消息分包

某些 RPC 框架对单个 message 大小有限制(如 gRPC 默认 4MB)——大数据要 stream 而非单包

1
2
3
service FileService {
    rpc Upload(stream FileChunk) returns (UploadResp);
}

4. 字符串编码

Protobuf string 必须是合法 UTF-8——塞二进制数据用 bytes 类型,否则解析会失败。

5. Optional 的 has 方法

proto3 的 optional 字段才能区分"没设"和"等于默认值"——普通字段无法区分

1
2
3
4
message User {
    int32 age = 1;            // 普通:age = 0 和"没设"无法区分
    optional int32 score = 2; // 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 解不出来"时,你能瞬间想到是字段编号被改了还是类型不兼容。

使用 Hugo 构建
主题 StackJimmy 设计