Protocol Buffers 技术解析:为什么叫「协议缓冲区」
摘要
Protocol Buffers 是GRpc的序列化层,其核心设计理念源于「协议定义+二进制容器」的高效数据交换模式。
通过预定义数据结构作为协议约定,序列化时将字段名替换为数字编号,结合二进制编码、Varint压缩、省略默认值等多重优化,实现远超JSON/XML的传输效率。
这种结构化二进制容器的思想在网络协议、数据库存储等领域有着广泛应用。
本文介绍了Protobuf的原理,GRpc如何识别一个protobuffer结构, 以及varint技术。
目录
- [1. 名称解析:Protocol Buffers 的由来](#1. 名称解析:Protocol Buffers 的由来 "#1-%E5%90%8D%E7%A7%B0%E8%A7%A3%E6%9E%90protocol-buffers%E7%9A%84%E7%94%B1%E6%9D%A5")
- [2. 核心优化机制](#2. 核心优化机制 "#2-%E6%A0%B8%E5%BF%83%E4%BC%98%E5%8C%96%E6%9C%BA%E5%88%B6")
- [3. 二进制编码详解](#3. 二进制编码详解 "#3-%E4%BA%8C%E8%BF%9B%E5%88%B6%E7%BC%96%E7%A0%81%E8%AF%A6%E8%A7%A3")
- [4. 与其他技术的对比](#4. 与其他技术的对比 "#4-%E4%B8%8E%E5%85%B6%E4%BB%96%E6%8A%80%E6%9C%AF%E7%9A%84%E5%AF%B9%E6%AF%94")
- [5. 类似模式的技术实践](#5. 类似模式的技术实践 "#5-%E7%B1%BB%E4%BC%BC%E6%A8%A1%E5%BC%8F%E7%9A%84%E6%8A%80%E6%9C%AF%E5%AE%9E%E8%B7%B5")
- [6. 设计哲学总结](#6. 设计哲学总结 "#6-%E8%AE%BE%E8%AE%A1%E5%93%B2%E5%AD%A6%E6%80%BB%E7%BB%93")
- 附1 GRpc如何识别一个protobuffer结构
- 附2 Varint压缩
1. 名称解析:Protocol Buffers 的由来
gRpc框架的序列化工具有一个名字,叫做protobuf, 又叫 protocol buffer, 我对这个名字一直觉得很奇怪,因此在这里拆解一下。
Protobuffer 就是 Protocol Buffer 的常用简称和昵称。
类似于:
JavaScript → JS
HyperText Markup Language → HTML
Portable Network Graphics → PNG
1.1 Protocol(协议)的含义
Protocol 指数据交换的预先约定,体现在 .proto 文件中定义的结构化数据契约:
protobuf
message Person {
string name = 1; // 字段编号1
int32 id = 2; // 字段编号2
string email = 3; // 字段编号3
}
这种定义明确了数据格式、字段类型和编号规则,构成了通信双方必须遵守的协议规范。
1.2 Buffer(缓冲区)的含义
Buffer 并非传统意义的临时缓冲区,而是指序列化后的结构化二进制数据容器。它是一个精心设计的字节序列,承载着按照协议规范编码的完整数据单元。
2. 核心优化机制
2.1 字段编号替代字段名
通过用数字编号替换字符串字段名,大幅减少数据传输量:
- 字段名
"name"(4字节)→ 编号1(通常1字节) - 字段名
"email"(5字节)→ 编号3(通常1字节)
name和email本身,因为proto定义已经是全局知晓这个结构的定义,所以
2.2 二进制编码优势
- 无冗余字符:省略引号、括号、逗号等格式符号
- 紧凑布局:数据连续排列,无填充对齐开销
- 类型集成:字段编号与类型信息合并编码
2.3 Varint 可变长度整数
小数值占用更少字节:
- 值
1→01(1字节) - 值
300→AC 02(2字节) - 而非固定4字节存储
2.4 默认值省略
如果字段值为类型默认值(0、false、空字符串等),直接不传输该字段。
3. 二进制编码详解
3.1 实际编码示例
对于数据:{name: "Alice", id: 123}
Protocol Buffers 编码结果:
0A 05 41 6C 69 63 65 10 7B
9个字节。
3.2 编码拆解分析
name字段部分 0A 05 41 6C 69 63 65:
0A=00001010:前5位00001(字段编号1),后3位010(字符串类型),05:字符串长度5字节41 6C 69 63 65:"Alice"的ASCII码
id字段部分 10 7B:
10=00010000:后3位000(Varint类型),前5位00010(字段编号2)7B:十进制123的Varint编码
3.3 效率对比
相同数据的JSON需要约40字节,而Protocol Buffers仅需9字节,压缩比超过4:1。
4. 与其他技术的对比
| 特性 | JSON/XML | Protocol Buffers |
|---|---|---|
| 数据格式 | 文本 | 二进制 |
| 字段传输 | 完整字段名 | 数字编号 |
| 元数据 | 引号、括号等 | 无 |
| 序列化大小 | 大 | 小 |
| 解析性能 | 慢 | 快 |
| 可读性 | 好 | 差 |
5. 类似模式的技术实践
5.1 Apache Thrift
几乎相同的设计理念:
thrift
struct Person {
1: string name,
2: i32 id,
3: string email
}
5.2 网络协议设计
TCP/IP包头、HTTP/2帧结构等都采用类似的「固定格式二进制容器」设计。
5.3 数据库存储格式
MySQL行格式、列式存储等都使用预定义结构+二进制编码的模式。
5.4 多媒体容器
MP4、AVI等媒体格式使用类似的box/chunk结构承载编码数据。
6. 设计哲学总结
Protocol Buffers 的成功源于以下几个关键设计理念:
- 关注点分离:协议定义与具体实现解耦
- 效率优先:从编码层到传输层的全方位优化
- 兼容性设计:通过字段编号机制支持向前向后兼容
- 通用性:跨语言、跨平台的统一解决方案
- 工具链支持:代码生成、验证等完整生态
这种「协议定义+二进制容器」的模式之所以在计算机科学中广泛存在,是因为它完美平衡了效率、可维护性和扩展性,是构建高性能分布式系统的基石技术。
核心价值:Protocol Buffers 不是发明了新概念,而是将久经考验的二进制数据交换模式标准化、工具化,让开发者能够专注于业务逻辑而非数据传输细节。
附1 GRpc如何识别一个protobuffer结构
protobuffer本身对字段名进行了压缩,它怎么知道一个结构体是这个结构体?
Protobuf 在编码时去掉了字段名 (比如 username, user_id),这确实极大地压缩了数据。但它之所以能"知道"一个结构体是哪个结构体,关键在于它依赖的是一套预先严格定义好的"契约" ------也就是 .proto 文件。
这个过程可以分解为以下几个关键点:
1. 契约先行:.proto 文件是核心
所有使用 Protobuf 的通信双方(发送方和接收方)必须 在通信之前就拥有完全相同 的 .proto 文件定义。
例如,我们定义一个用户消息:
protobuf
// user.proto
message User {
string user_name = 1;
int64 favorite_number = 2;
}
这个 .proto 文件就是"契约"。它规定了:
- 有一个叫做
User的消息类型。 - 这个类型有两个字段。
- 每个字段都有一个唯一的数字标签 (1 和 2)和一个数据类型 (
string,int64)。
2. 编码:用数字标签代替字段名
当你的程序要序列化一个 User 对象时(比如 user_name="Alice", favorite_number=42),Protobuf 编码器不会把字符串 "user_name" 和 "favorite_number" 写入二进制流。
它写入的是:
- 字段1 :标签
1+ 值"Alice" - 字段2 :标签
2+ 值42
这些标签(1, 2)就是二进制数据中唯一标识字段的密钥。
3. 解码:按图索骥
接收方拿到二进制数据后,用自己的 User 消息定义(即同一个 .proto 文件编译生成的代码)来解码。
解码过程是这样的:
- 读取一个字段的标签(比如
1)和它的数据类型。 - 在本地的
User消息定义中查找:"哪个字段的标签是1?" - 找到了!标签
1对应着user_name字段,类型是string。 - 于是,它正确地创建一个
User对象,并将解码出的字符串"Alice"填入该对象的user_name属性。 - 继续读取下一个字段,重复此过程。
关键特性:灵活性、兼容性与未知字段
这种基于数字标签的机制带来了巨大的优势:
-
向后/向前兼容 :如果接收方的
.proto版本比较老,缺少发送方数据中的某些新字段(比如发送方用了email = 3;),接收机在解码时看到不认识的标签(如3),它会简单地忽略这个字段,而不会报错。这就是向前兼容。同样,老版本的客户端发送缺少新字段的数据给新版本的服务端,新服务端也能正常处理(向后兼容)。 -
结构体类型的识别 :Protobuf 消息本身是自描述 的,但只描述到字段级别,不描述消息类型。那么,一个更根本的问题是:接收方如何知道这段二进制数据应该被解码成
User消息,而不是Product消息?答案在于上层的应用程序协议。 Protobuf 通常被用作更高级别协议的数据载体。识别消息类型的方法通常有:
- 包装在一个外层消息中:定义一个顶级的、包含所有可能消息类型的包装消息。
protobufmessage TopLevelMessage { oneof message_type { User user = 1; Product product = 2; // ... } }解码时先解码
TopLevelMessage,根据其中的oneof字段判断具体类型。- 在协议头中指定:比如,在 gRPC 中,HTTP 请求路径就隐含了要调用的服务和方法,从而确定了消息类型。
- 预先约定 :在简单的点对点通信中,双方可能就约定好"这个Socket连接上只发送
User消息"。
总结
Protobuf 能知道一个结构体是哪个结构体,并不是靠二进制数据本身携带了类型名,而是依赖于:
- 通信双方共享的
.proto契约:这是解码的"密码本"。 - 唯一的数字标签:这是二进制流中识别字段的"钥匙"。
- 上层的应用程序协议 :这负责告诉接收方"请使用
User.proto这个密码本来解码接下来的数据"。
这种设计实现了数据的高度压缩,同时提供了出色的版本兼容性。
附2 Varint 压缩:小数字占用更少字节的编码艺术
在Proto Buffer中,我们提到过 Varint, 这里我做一个补充。
什么是 Varint?
Varint (Variable-length Integer,可变长度整数)是一种智能的整数编码技术,核心思想是:小数值占用更少字节,大数值才占用更多字节。
思想类似于哈夫曼编码,但是不完全相等,哈夫曼编码基于频率,而Varint基于数字本身的大小。
传统整数的局限
在大多数系统中,整数类型有固定长度:
int32:固定 4 字节(32位)int64:固定 8 字节(64位)
这意味着即使数值很小(比如数字 1),也要占用完整的 4 个字节,造成空间浪费。
Varint 编码原理
基本规则
-
每个字节只用 7 位存储数据,最高位(MSB)作为继续标志:
0:表示这是最后一个字节1:表示后面还有更多字节
-
数据按小端序排列:低位字节在前
编码过程示例
例1:数字 1 的编码
scss
二进制:00000001
Varint:00000001 (1字节)
- 只有1个字节,最高位为
0
例2:数字 300 的编码
scss
二进制:100101100 (300的二进制)
分组: 0000010 0101100 (按7位分组)
反转: 0101100 0000010 (小端序)
加标志:10101100 00000010 (最高位:1-继续, 0-结束)
十六进制:AC 02
- 占用 2 字节:
0xAC 0x02
例3:数字 1 的传统 vs Varint 对比
protobuf
int32 value = 1;
- 传统固定长度:
00 00 00 01(4字节) - Varint 编码:
01(1字节) - 节省 75% 空间!
Protocol Buffers 中的 Varint 应用
实际编码示例
protobuf
message Test {
int32 id = 1; // 含义:字段名为id的值为1
...
}
不同取值的编码结果:
python
int32 id = 1 → 编码: 08 01 (2字节)
int32 id = 300 → 编码: 08 AC 02 (3字节)
int32 id = 65536 → 编码: 08 80 80 04 (4字节)
08 01 = 0000 1000 0000 0001
0000 1000 前5位表示 字段序号,后3位表示字段类型,表示字段头,第一个字段,int类型
0000 0001 表示值为1。
字段头也是 Varint
字段编号和类型的组合也使用 Varint 编码:
python
08 = 00001000 # 字段1,Varint类型
优势与局限
✅ 优势
- 空间高效:小数字占用极少空间
- 自描述:从数据本身可知长度
- 通用性强:适合各种整数类型
❌ 局限
- 大数代价:极大数字可能比固定长度更占空间
- 解析开销:需要动态解析字节数
这样将小负数转换为小正数,再利用 Varint 的高效编码。
实际影响
在真实业务数据中:
- 大多数 ID、状态码、数量都是小数值
- 90% 的整数可以用 1-2 个字节表示
- 整体数据大小减少 30-50% 很常见
总结
Varint 压缩是 Protocol Buffers 高效性的关键技术之一,它通过「按需分配」的智能编码方式,在二进制级别实现了数据的最小化表示,特别适合网络传输和存储优化场景。