深入理解 Protocol Buffers:原理、实践与性能
深入理解 Protocol Buffers:原理、实践与性能
如果你曾经被 JSON 的”又大又慢”搞得头疼,或者接触过 gRPC 但对背后的数据格式一头雾水,这篇文章应该能帮你把这些拼图拼起来。
一、前言:JSON 挺好的,为什么还需要 Protobuf?
先说结论:JSON 确实挺好的。人类可读、调试方便、生态成熟,大多数场景用它完全没问题。
但有些场景会让你开始质疑它——
比如你在写一个每秒要处理几十万条消息的消息队列服务,或者你的移动端 App 要在弱网环境下传输大量结构化数据。这时候你会发现 JSON 有几个很现实的问题:
- 字段名占了大量空间。
{"user_id": 12345}里,"user_id"这个 key 本身就占了 9 个字节,而真正有用的数据12345只占 5 个字节。每条消息都要带着这些”说明书”,很浪费。 - 解析慢。JSON 是纯文本,机器读它需要做词法分析、字符串匹配,开销不小。
- 没有类型约束。
"age": "25"和"age": 25在 JSON 里都合法,但含义完全不同,字段类型全靠约定,出 bug 了还不好查。
Protocol Buffers(简称 Protobuf)就是 Google 为了解决这些问题而设计的一套数据序列化方案。它用二进制编码,体积通常比 JSON 小 3~10 倍,序列化速度快好几倍,而且有强类型约束。代价是:二进制你看不懂,需要一个”说明书”(.proto 文件)才能解析。
二、基础概念
什么是序列化?
序列化这个词听起来很高深,其实就是把内存里的数据结构转换成一串字节,方便存储或传输。反过来,把字节还原成数据结构就叫反序列化。
打个比方:你去宜家买了一张桌子,宜家不会把整张组装好的桌子塞进纸箱,而是把它拆开、压平、打包,你买回家再按说明书组装。”打包”就是序列化,”按说明书组装”就是反序列化。
JSON 是一种序列化格式(文本形式),Protobuf 也是(二进制形式),只是打包方式不一样。
Protobuf 的历史与定位
Protobuf 是 Google 内部用了很多年的东西,2008 年才对外开源。Google 自己几乎所有的内部服务通信都在用它,后来它成了 gRPC 的默认数据格式。
目前主流版本是 proto3,相比 proto2 简化了不少语法,本文使用 proto3。
三、快速上手:.proto 文件语法
Protobuf 的核心是 .proto 文件,你在里面定义数据结构,然后用编译器生成对应语言的代码。
基本数据类型
| .proto 类型 | 对应 C++ 类型 | 说明 |
|---|---|---|
int32 |
int32_t |
32 位整数 |
int64 |
int64_t |
64 位整数 |
float |
float |
单精度浮点 |
double |
double |
双精度浮点 |
bool |
bool |
布尔值 |
string |
std::string |
UTF-8 字符串 |
bytes |
std::string |
任意字节序列 |
message:定义结构体
message 是 Protobuf 的核心,类似于 C++ 的 struct:
1 | |
注意每个字段后面的 = 1、= 2、= 3,这是字段编号,不是默认值。字段编号在编码时用来标识字段,是 Protobuf 的关键设计(后面会详细讲)。
enum:枚举类型
1 | |
proto3 中枚举的第一个值必须是 0,作为默认值。
repeated:数组
repeated 关键字表示该字段可以有多个值,相当于数组:
1 | |
map:键值对
1 | |
嵌套消息
消息可以嵌套,也可以跨文件引用:
1 | |
四、代码生成与使用(C++)
安装 protoc 编译器
protoc 是 Protobuf 的官方编译器,负责把 .proto 文件生成各种语言的代码。
后端开发者用 Windows、macOS、Linux 的都有,下面三个平台都覆盖一下。
Windows:
推荐直接去 GitHub Releases 下载预编译的二进制包,找名字类似 protoc-xx.x-win64.zip 的文件下载解压,然后把 bin 目录加到系统环境变量 PATH 里就行。
如果你在用 Chocolatey 包管理器,也可以直接:
1 | |
macOS(推荐用 Homebrew):
1 | |
Linux(Ubuntu/Debian):
1 | |
注意:apt 源里的版本有时候比较老,如果需要最新版,同样建议去 GitHub Releases 下载对应的
protoc-xx.x-linux-x86_64.zip,手动解压并配置 PATH。
验证安装(三个平台通用):
1 | |
除了 protoc,C++ 项目还需要安装 Protobuf 的 C++ 运行时库。用 CMake 管理的项目推荐通过 vcpkg 安装(Windows 上尤其方便):
1 | |
或者用 Conan,也可以参考官方文档从源码编译。
定义 .proto 文件
创建文件 addressbook.proto:
1 | |
生成 C++ 代码
1 | |
执行后会生成两个文件:
addressbook.pb.h— 头文件,包含类定义addressbook.pb.cc— 实现文件
序列化与反序列化
1 | |
编译时需要链接 protobuf 库:
1 | |
五、二进制编码原理(核心章节)
这是 Protobuf 最有意思的部分,理解了它你就知道为什么它又小又快。
Tag-Value 结构
Protobuf 编码后的每个字段由两部分组成:Tag 和 Value。
- Tag 告诉你”这是哪个字段,用什么方式编码的”
- Value 是实际的数据
Tag 的计算方式:
1 | |
比如 name 字段编号是 1,字符串对应的 wire_type 是 2,那么:
1 | |
这就是为什么字段名在传输中完全消失了——接收方只需要根据字段编号(Tag 里包含的)对照 .proto 文件就能知道这是哪个字段。
Wire Type 说明
| Wire Type | 值 | 对应类型 |
|---|---|---|
| Varint | 0 | int32, int64, bool, enum |
| 64-bit | 1 | fixed64, double |
| Length-delimited | 2 | string, bytes, 嵌套 message |
| 32-bit | 5 | fixed32, float |
Varint 编码:为什么小整数只占 1 字节?
Varint 是 Protobuf 省空间的秘密武器。它用变长编码表示整数——小的数字用少字节,大的数字用多字节,而不是无论大小都固定占 4 字节。
编码规则:
- 每个字节的最高位(bit 7)是”续位标志”:1 表示后面还有字节,0 表示这是最后一个字节
- 每个字节剩余的 7 位用来存数据,采用小端序(低位在前)
手算一个例子:数字 300
300 的二进制是 100101100,共 9 位,超过 7 位,需要 2 个字节:
1 | |
所以 300 编码为 0xAC 0x02,只用了 2 个字节。而 JSON 里 “300” 是 3 个字符,加上字段名和引号更多。
再看数字 1:
1 | |
数字 1 只占 1 个字节!这就是 Protobuf 对小整数极其高效的原因。
图解:同样的数据,Protobuf vs JSON
假设有这样一条消息:
1 | |
JSON 编码:
1 | |
占 26 个字节。
Protobuf 编码(16进制):
1 | |
只占 9 个字节,解读如下:
| 字节 | 含义 |
|---|---|
08 |
Tag:字段1,Wire Type 0(Varint) |
01 |
值:1 |
12 |
Tag:字段2,Wire Type 2(字符串) |
05 |
字符串长度:5 |
41 6C 69 63 65 |
“Alice” 的 ASCII 编码 |
9 字节 vs 26 字节,省了 65%。这还只是一条简单消息,字段越多、嵌套越深,差距会更大。
六、字段编号与版本兼容性
为什么用编号而不是字段名?
这个设计选择让 Protobuf 具备了很强的前后兼容性。
用字段名的话,改个名字就完了——旧版本发来的消息你解析不了。用编号的话,字段名随便改,编号不变就行,旧消息照样能解析。
安全地新增和删除字段
新增字段: 直接加,使用一个从未用过的新编号。旧版本遇到不认识的编号会直接跳过,不会报错。
删除字段: 删掉字段后,那个编号要”封存”起来,用 reserved 标记,防止以后被复用:
1 | |
为什么不能复用编号?
这是最常见的坑。假设你删掉了编号 5 的字段(原来是 int32 age),然后又加了一个新字段也用编号 5(但类型是 string nickname)。旧版本的客户端发来消息,把 age 编码为 Varint,你的新服务器却把编号 5 解析为 string,类型不匹配,直接崩掉或者数据错乱。
七、与 JSON / XML 的对比
性能与体积
| Protobuf | JSON | XML | |
|---|---|---|---|
| 编码格式 | 二进制 | 文本 | 文本 |
| 可读性 | 不可读 | 可读 | 可读 |
| 消息体积 | 小 | 中 | 大 |
| 序列化速度 | 快 | 中 | 慢 |
| 是否需要 Schema | 必须 | 可选 | 可选(DTD/XSD) |
| 调试难度 | 高 | 低 | 中 |
实测数据因场景而异,但通常 Protobuf 比 JSON 体积小 310 倍,序列化速度快 510 倍。
选型建议
用 Protobuf 的场景:
- 微服务之间的内部通信(配合 gRPC)
- 高频消息队列(Kafka 消息体)
- 移动端弱网环境,需要压缩流量
- 对接口有严格约束,不希望出现类型混乱
继续用 JSON 的场景:
- 对外暴露的 REST API(调试方便,前端友好)
- 快速原型开发
- 数据结构经常变动,还没稳定下来
- 团队成员对 Protobuf 不熟悉,维护成本高
说白了:对内用 Protobuf,对外用 JSON,是很多大厂的通行做法。
八、gRPC 简介
提到 Protobuf 就绕不开 gRPC。gRPC 是 Google 开源的高性能 RPC 框架,它直接在 .proto 文件里定义服务接口:
1 | |
protoc 配合 gRPC 插件可以直接生成客户端和服务端的骨架代码,序列化和传输全部由 gRPC + Protobuf 自动处理,你只需要专注写业务逻辑。
可以参考官方文档:grpc.io,里面有各语言的完整教程。
如果只考虑数据传输,可以不适用gRPC,按需使用。
九、常见坑与最佳实践
坑 1:字段编号复用
上面已经说过了,删掉的字段编号一定要用 reserved 封存,绝对不能复用。这个错误在代码审查时很难发现,出了问题也很难排查。
坑 2:默认值的”隐患”
proto3 中,所有字段都有默认值:int32 默认 0,string 默认空字符串,bool 默认 false。
问题在于,你无法区分”字段没有被设置”和”字段被设置为默认值”。比如:
1 | |
如果 success 是 false,你不知道这是因为请求失败了,还是因为对方根本没设置这个字段(忘了写)。
解决方案:对于需要区分”未设置”和”默认值”的场景,使用 google.protobuf.BoolValue(Wrapper 类型),它允许 null 值:
1 | |
坑 3:proto3 里没有 required
proto2 有 required 关键字,proto3 把它删掉了。这意味着你在语言层面没办法强制调用方必须填某个字段。业务上的必填校验需要自己在代码里写。
坑 4:大 message 的性能
Protobuf 的序列化是一次性的,如果你把一个 100MB 的大文件塞进一个 message,整个 message 需要完整加载到内存才能处理。对于大文件或大数据集,考虑用流式传输(gRPC streaming)或者把数据拆成多个小 message 分批发送。
最佳实践小结
- 字段编号从 1 开始,1~15 号字段编码只占 1 字节(Tag),高频字段优先用小编号
- 删字段用
reserved,不要复用编号 - 在项目早期就把
.proto文件纳入版本管理,当成接口契约来维护 - 给每个字段加注释,
.proto文件就是文档