深入理解 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
2
3
4
5
6
7
syntax = "proto3";

message Person {
int32 id = 1;
string name = 2;
string email = 3;
}

注意每个字段后面的 = 1= 2= 3,这是字段编号,不是默认值。字段编号在编码时用来标识字段,是 Protobuf 的关键设计(后面会详细讲)。

enum:枚举类型

1
2
3
4
5
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}

proto3 中枚举的第一个值必须是 0,作为默认值。

repeated:数组

repeated 关键字表示该字段可以有多个值,相当于数组:

1
2
3
4
5
message Person {
int32 id = 1;
string name = 2;
repeated string phones = 3; // 可以有多个电话号码
}

map:键值对

1
2
3
message Config {
map<string, string> settings = 1;
}

嵌套消息

消息可以嵌套,也可以跨文件引用:

1
2
3
4
5
6
7
8
9
10
message Address {
string city = 1;
string country = 2;
}

message Person {
int32 id = 1;
string name = 2;
Address address = 3; // 嵌套另一个 message
}

四、代码生成与使用(C++)

安装 protoc 编译器

protoc 是 Protobuf 的官方编译器,负责把 .proto 文件生成各种语言的代码。

后端开发者用 Windows、macOS、Linux 的都有,下面三个平台都覆盖一下。

Windows:

推荐直接去 GitHub Releases 下载预编译的二进制包,找名字类似 protoc-xx.x-win64.zip 的文件下载解压,然后把 bin 目录加到系统环境变量 PATH 里就行。

如果你在用 Chocolatey 包管理器,也可以直接:

1
choco install protoc

macOS(推荐用 Homebrew):

1
brew install protobuf

Linux(Ubuntu/Debian):

1
sudo apt install -y protobuf-compiler

注意:apt 源里的版本有时候比较老,如果需要最新版,同样建议去 GitHub Releases 下载对应的 protoc-xx.x-linux-x86_64.zip,手动解压并配置 PATH。

验证安装(三个平台通用):

1
2
protoc --version
# 输出类似:libprotoc 27.x

除了 protoc,C++ 项目还需要安装 Protobuf 的 C++ 运行时库。用 CMake 管理的项目推荐通过 vcpkg 安装(Windows 上尤其方便):

1
vcpkg install protobuf

或者用 Conan,也可以参考官方文档从源码编译。

定义 .proto 文件

创建文件 addressbook.proto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
syntax = "proto3";

package tutorial;

message Person {
string name = 1;
int32 id = 2;
string email = 3;

enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}

message PhoneNumber {
string number = 1;
PhoneType type = 2;
}

repeated PhoneNumber phones = 4;
}

message AddressBook {
repeated Person people = 1;
}

生成 C++ 代码

1
protoc --cpp_out=. addressbook.proto

执行后会生成两个文件:

  • addressbook.pb.h — 头文件,包含类定义
  • addressbook.pb.cc — 实现文件

序列化与反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <fstream>
#include <iostream>
#include "addressbook.pb.h"

int main() {
// ---- 序列化 ----
tutorial::AddressBook address_book;

tutorial::Person* person = address_book.add_people();
person->set_name("Alice");
person->set_id(1234);
person->set_email("alice@example.com");

tutorial::Person::PhoneNumber* phone = person->add_phones();
phone->set_number("555-4321");
phone->set_type(tutorial::Person::HOME);

// 写入文件
std::ofstream output("addressbook.bin", std::ios::binary);
address_book.SerializeToOstream(&output);
output.close();

std::cout << "序列化完成,写入 addressbook.bin" << std::endl;

// ---- 反序列化 ----
tutorial::AddressBook loaded_book;

std::ifstream input("addressbook.bin", std::ios::binary);
loaded_book.ParseFromIstream(&input);
input.close();

for (const auto& p : loaded_book.people()) {
std::cout << "Name: " << p.name() << std::endl;
std::cout << "ID: " << p.id() << std::endl;
std::cout << "Email: " << p.email() << std::endl;
for (const auto& ph : p.phones()) {
std::cout << "Phone: " << ph.number() << std::endl;
}
}

return 0;
}

编译时需要链接 protobuf 库:

1
g++ -o main main.cpp addressbook.pb.cc -lprotobuf -lpthread

五、二进制编码原理(核心章节)

这是 Protobuf 最有意思的部分,理解了它你就知道为什么它又小又快。

Tag-Value 结构

Protobuf 编码后的每个字段由两部分组成:TagValue

  • Tag 告诉你”这是哪个字段,用什么方式编码的”
  • Value 是实际的数据

Tag 的计算方式:

1
Tag = (field_number << 3) | wire_type

比如 name 字段编号是 1,字符串对应的 wire_type 是 2,那么:

1
Tag = (1 << 3) | 2 = 0x0A

这就是为什么字段名在传输中完全消失了——接收方只需要根据字段编号(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
2
第一组(低7位): 0101100  → 加续位标志110101100 = 0xAC
第二组(高2位): 0000010 → 加续位标志000000010 = 0x02

所以 300 编码为 0xAC 0x02,只用了 2 个字节。而 JSON 里 “300” 是 3 个字符,加上字段名和引号更多。

再看数字 1:

1
2
1 的二进制是 0000001,7 位以内,只需 1 个字节:
加续位标志000000001 = 0x01

数字 1 只占 1 个字节!这就是 Protobuf 对小整数极其高效的原因。

图解:同样的数据,Protobuf vs JSON

假设有这样一条消息:

1
{"id": 1, "name": "Alice"}

JSON 编码:

1
{"id": 1, "name": "Alice"}

占 26 个字节。

Protobuf 编码(16进制):

1
08 01 12 05 41 6C 69 63 65

只占 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
2
3
4
5
6
7
message Person {
reserved 5, 6; // 这两个编号已废弃,不能再用
reserved "old_field"; // 这个字段名也封存

string name = 1;
int32 id = 2;
}

为什么不能复用编号?

这是最常见的坑。假设你删掉了编号 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";

service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc ListUsers (ListUsersRequest) returns (stream User); // 服务端流式
}

message GetUserRequest {
int32 user_id = 1;
}

message GetUserResponse {
string name = 1;
string email = 2;
}

protoc 配合 gRPC 插件可以直接生成客户端和服务端的骨架代码,序列化和传输全部由 gRPC + Protobuf 自动处理,你只需要专注写业务逻辑。

可以参考官方文档:grpc.io,里面有各语言的完整教程。

如果只考虑数据传输,可以不适用gRPC,按需使用。


九、常见坑与最佳实践

坑 1:字段编号复用

上面已经说过了,删掉的字段编号一定要用 reserved 封存,绝对不能复用。这个错误在代码审查时很难发现,出了问题也很难排查。

坑 2:默认值的”隐患”

proto3 中,所有字段都有默认值:int32 默认 0,string 默认空字符串,bool 默认 false

问题在于,你无法区分”字段没有被设置”和”字段被设置为默认值”。比如:

1
2
3
message Response {
bool success = 1;
}

如果 successfalse,你不知道这是因为请求失败了,还是因为对方根本没设置这个字段(忘了写)。

解决方案:对于需要区分”未设置”和”默认值”的场景,使用 google.protobuf.BoolValue(Wrapper 类型),它允许 null 值:

1
2
3
4
5
import "google/protobuf/wrappers.proto";

message Response {
google.protobuf.BoolValue success = 1;
}

坑 3:proto3 里没有 required

proto2 有 required 关键字,proto3 把它删掉了。这意味着你在语言层面没办法强制调用方必须填某个字段。业务上的必填校验需要自己在代码里写。

坑 4:大 message 的性能

Protobuf 的序列化是一次性的,如果你把一个 100MB 的大文件塞进一个 message,整个 message 需要完整加载到内存才能处理。对于大文件或大数据集,考虑用流式传输(gRPC streaming)或者把数据拆成多个小 message 分批发送。

最佳实践小结

  • 字段编号从 1 开始,1~15 号字段编码只占 1 字节(Tag),高频字段优先用小编号
  • 删字段用 reserved,不要复用编号
  • 在项目早期就把 .proto 文件纳入版本管理,当成接口契约来维护
  • 给每个字段加注释,.proto 文件就是文档

深入理解 Protocol Buffers:原理、实践与性能
https://silengzi.github.io/cube-fluid-blod/2026/03/02/深入理解 Protocol Buffers:原理、实践与性能/
作者
silengzi
发布于
2026年3月2日
许可协议