Protocol Buffers 是什么?它相比 JSON 有什么优势和代价?
简化版
Protocol Buffers 是一种基于 schema 的二进制序列化协议,先用 .proto 定义字段编号和类型,再生成多语言代码。它通常比 JSON 更小、更快、类型更明确,适合内部 RPC 和高吞吐服务通信;代价是可读性差、调试需要工具、字段演进必须遵守兼容规则。
详细版
Protobuf 的核心不是“二进制所以快”,而是“字段有编号、类型固定、编码紧凑、代码可生成”。JSON 每个对象都要携带字段名,例如 {"user_id":123},而 Protobuf 在线上只传字段编号和编码后的值。对于大量重复结构,节省很明显。
| 维度 | JSON | Protobuf |
|---|---|---|
| 数据格式 | 文本 | 二进制 |
| schema | 可选,常靠约定 | 必须定义 .proto |
| 可读性 | 人能直接读 | 需要工具解码 |
| 体积 | 字段名重复,较大 | 字段编号,较小 |
| 类型 | 运行时解释 | 编译期生成类型 |
面试重点通常包括:字段编号不能随便改;删除字段要 reserved;新增字段一般向后兼容;默认值会影响“字段是否存在”的判断;Protobuf 不适合所有公网接口,因为调试和生态门槛比 JSON 高。
完整版教学
一、Protobuf 解决的是序列化效率和契约问题
服务之间通信时,内存对象不能直接在网络上传,需要变成字节流,这就是序列化。JSON 的优势是通用、可读、调试方便,但它没有强制 schema,并且字段名会在每条消息里重复出现。Protobuf 则要求先定义消息结构,再生成代码,发送时只传紧凑的二进制数据。
例如 100 万条用户消息,每条 JSON 都带 "user_id"、"name"、"active" 这些字段名,字段名本身就会产生大量重复开销。Protobuf 用字段编号表示字段,user_id 可以是编号 1,name 是编号 2,线上数据里不再重复传字段名。
message User {
int64 user_id = 1;
string name = 2;
bool active = 3;
}
二、字段编号为什么比字段名更重要
Protobuf 的兼容性围绕字段编号展开,而不是字段名。生成代码里你看到的是 user_id,但网络上传输时识别字段靠的是 1。如果把 user_id = 1 改成 user_id = 5,旧服务发来的字段 1,新服务就不再当成 user_id,兼容性会被破坏。
字段编号和 wire type 一起组成 key:
key = (field_number << 3) | wire_type
例如字段编号 1、wire type 0 的 key 是 (1 << 3) | 0 = 8。这就是为什么字段编号一旦发布就要稳定,字段名反而可以在代码层谨慎重命名。
记 Protobuf 兼容性时抓住一个点:线上认的是字段编号,不是字段名。
三、为什么 Protobuf 体积通常更小
Protobuf 对整数使用 varint 编码,小整数占用更少字节。例如数字 150 用普通 32 位整数固定要 4 字节,而 varint 只要 2 字节。再加上字段名不传输,所以对象越多、字段名越长、数值越小,Protobuf 优势越明显。
150 的 varint 编码:
150 = 0b10010110
拆成 7 位组后编码为两个字节: 0x96 0x01
但不是所有数据都会压缩到非常小。字符串、图片、已经压缩过的数据本身占主体时,Protobuf 只能省掉结构开销。面试里不要绝对化地说“Protobuf 一定比 JSON 小十倍”,要看字段类型和数据分布。
四、为什么 Protobuf 速度通常更快
JSON 解析需要读取文本、识别引号、冒号、逗号、转义字符,还要把字符串字段名映射到对象字段。Protobuf 解析的是结构化二进制,按字段编号和 wire type 读取,生成代码后可以直接写入强类型字段。
处理路径对比:
JSON: 字节 -> 字符串解析 -> 字段名匹配 -> 类型转换 -> 对象
PB: 字节 -> key/wire type -> 按编号赋值 -> 对象
速度优势在高 QPS 内部服务里明显。假设一个接口每秒 50000 次调用,每次 JSON 解析多消耗 40 微秒,就会多出约 2 秒 CPU 时间每秒,需要更多核数兜住。
五、schema 演进要遵守兼容规则
Protobuf 的强约束带来一个责任:schema 不能随便改。常见安全操作是新增字段;旧服务收到未知字段会忽略,新服务读不到旧数据里的新字段时使用默认值。危险操作是复用字段编号、改变字段类型、删除字段后又把编号给别人。
| 操作 | 是否安全 | 原因 |
|---|---|---|
新增 optional string email = 4 | 通常安全 | 旧版本忽略未知字段 |
修改 int32 id = 1 为 string id = 1 | 危险 | wire type 或语义变化 |
删除字段后 reserved 3 | 安全 | 防止编号被误复用 |
| 字段改名但编号不变 | 通常可行 | 网络上传输不依赖字段名 |
删除字段时最好写:
reserved 3;
reserved "old_field";
六、默认值和“字段是否存在”容易踩坑
在 Protobuf 里,数字默认是 0,布尔默认是 false,字符串默认是空串。问题是:字段没传和字段传了默认值,在某些版本或写法下可能区分不出来。比如年龄字段 age=0,到底是没有填写,还是新生儿年龄就是 0?
解决方式是使用 optional、wrapper 类型,或者业务上额外设计状态字段。面试中可以举例:账户余额为 0 是有效值,不能简单用 balance == 0 判断字段缺失。
message Account {
optional int64 balance_cent = 1;
}
七、Protobuf 适合内部 RPC,不一定适合所有开放 API
Protobuf 的生态和 gRPC 结合紧密,非常适合微服务内部通信。内部服务更看重性能、契约和多语言生成,调试工具也可以统一配置。但公网开放 API 往往更重视易接入、可读性、浏览器和开发者工具支持,JSON 仍然有优势。
选型时可以这样看:
| 场景 | 推荐 |
|---|---|
| 内部高 QPS RPC | Protobuf |
| 移动端弱网且 SDK 可控 | Protobuf 可考虑 |
| 第三方开放平台 | JSON 更友好 |
| 浏览器直接调用 | JSON 更自然 |
| 日志和配置文件 | JSON/YAML 更易读 |
所以 Protobuf 的价值不是替代 JSON,而是在明确 schema、高性能、多语言 RPC 的场景里更合适。
八、常见误区与追问
- 误区:Protobuf 快只是因为二进制。 更关键的是 schema 固定、字段编号紧凑、生成代码解析路径短。
- 误区:字段名不能改。 网络上传输依赖字段编号,字段名可谨慎改,但生成代码和业务引用要同步处理。
- 误区:删除字段后编号可以马上复用。 复用编号会让旧数据被新字段误读,应使用
reserved。 - 误区:Protobuf 一定适合公网 API。 公网 API 更看重可读、易调试和低接入门槛,JSON 经常更合适。
- 追问:新增字段为什么通常兼容? 旧版本解析时会忽略未知字段,新版本读旧消息时使用默认值。
- 追问:Protobuf 如何表示字段缺失? 可以用
optional、wrapper 类型或业务状态字段,不能总靠默认值判断。 - 追问:gRPC 和 Protobuf 是什么关系? gRPC 常用 Protobuf 定义服务和消息,但 Protobuf 本身只是序列化和接口描述能力。
九、加强记忆
Protobuf 要记住三件事:传输靠字段编号,不靠字段名;体积小来自字段编号和 varint 等紧凑编码;兼容性靠“不改编号、不乱改类型、删除要 reserved”。它很适合内部 RPC 和高吞吐通信,但调试和开放生态不如 JSON 直接。回答时把效率、契约、兼容演进和适用边界一起讲出来。