FlowingDev

Protobuf 详解:极其高效却又有点傲娇的二进制格式

深入理解 Protocol Buffers,这是 Google 开发的快如闪电的二进制序列化格式,它能把你的数据压缩成极小且类型安全的包。

试用工具: Protobuf 解码器

一句话总结

Protocol Buffers (Protobuf) 是一种与语言无关、极其快速的结构化数据序列化二进制格式——你可以把它看作是 JSON 那个经过极限优化、浑身肌肉的表哥,完全用紧凑压缩的二进制流来交流。

它解决的问题

在 2000 年代初,Google 遇到了一个大麻烦:他们内部有成吨的服务在互相通信,而这通信量实在太重了。那个年代,计算机间通信的行业标准是 XML。如果你是个“尖括号”狂热爱好者,XML 当然棒极了,但它出了名的臃肿,解析起来也慢得要命。每当服务器想发送一条简单的数据(比如一个 user ID),它都得把数据包在文本标签里,像这样 <userId>12345</userId>,然后接收方的服务器还得解析这串字符串,匹配标签,再把 "12345" 转回 integer。

当 JSON 闪亮登场时,情况变轻量了,但它依然面临同样的核心低效问题:它是个靠传输原始字符串活着的文本格式。如果你要发一百万条带有 "emailAddress" 字段的记录,你就在网络上来来回回发了一百万次 "emailAddress" 这个字符串。

Google 的工程师们意识到,这简直是对带宽和 CPU 算力的巨大浪费。他们需要一种这样的格式:

  1. 网络传输极小: 不要发字段名,只发数据。
  2. 解析极快: 计算机更喜欢读 bit 和 byte,而不是解析文本字符。
  3. 强类型: 如果我发了一个 integer,你就应该收到一个 integer,而不是在心里默默祈祷接收方的字符串能解析正确。
  4. 向后兼容: 我应该能把服务升级到新的数据格式,同时不搞崩那些还没更新的老 client。

他们的解决方案就是 Protocol Buffers。与其发送自带描述的文本,Protobuf 依赖于一份预先共享的“契约”(也就是 schema)。既然发送方和接收方早就对 schema 心知肚明,网络上实际传输的数据就能扒光所有的 metadata,只留下最原始、高度压缩的二进制值。

它是怎么工作的(底层原理)

要搞懂 Protobuf,你得把它拆成三个阶段:契约(contract)、编译器(compiler)和传输格式(wire format)。

Schema 契约 (.proto)

一切都从一个 .proto 文件开始。这是一份与语言无关的契约,你在里面定义数据的精确形状。

syntax = "proto3";

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

注意到那些数字(= 1, = 2)了吗?它们叫作 Field Tags(字段标签)。这可是 Protobuf 的秘密武器。在 JSON 里,我们用 key "name" 来标识字段;而在 Protobuf 里,用数字 1 来标识。

编译阶段

你可不需要手写 Protobuf 的解析代码。你只要用编译器工具(protoc)去跑你的 .proto 文件,告诉它你想输出什么语言。它就会自动吐出原生类,不管是 C#、TypeScript、Python、Go,还是你能想到的其它什么语言。

当你的 app 想要发送一个 Person 时,只要把生成的类实例化,塞满数据,然后调用一下 Serialize() 方法。那些恶心的二进制打包工作,生成的代码全替你包揽了。

Wire Format 传输格式(二进制黑魔法)

当你把那个 Person 对象序列化之后,网络上实际传输的到底是什么?

它用的是一种 [Key] [Value] 的套路,不过是在 byte 级别。这里的 Key 可不是字符串,它是一个(或几个) byte,把你定义的 Field Tag 和数据的 Wire Type(传输类型) 揉合在了一起。

Wire Type 负责告诉接收方:接下来的 value 有 多长。以下是几个核心的 wire type:

Wire Type 含义 用途
0 Varint int32, int64, uint32, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, 嵌套的 message
5 32-bit fixed32, sfixed32, float

举个栗子,如果你设置 is_active = true(Field 3,Wire Type 0),Protobuf 会把 key(3 和 0 组合)和 value(用 1 表示 true)编码成仅仅 两个 byte。作为对比,在 JSON 里写 "is_active": true 可是要花掉 17 个 byte 的。

Varint 把戏

Protobuf 使用“Base-128 Varints”来压缩数字。一个标准的 32-bit integer 在内存里永远占 4 个 byte,哪怕这个数字仅仅是 5。Protobuf 就不服气了:“凭啥浪费 3 个 byte 的零?”

借助 Varints,每个 byte 都会保留 1 个 bit(最高有效位,MSB)当作标记,意思是:“后面还有 byte 吗?”如果数字很小,MSB 就是 0,数字正好塞进 1 个 byte 里。这就意味着,越小的数字在网络传输中占用的空间就越少。

没 Schema 也能解码

因为传输格式里包含了 Wire Type,解析器完全可以遍历一段原始的 Protobuf 字节流,然后 哪怕没有 .proto 文件 也能摸清它的基本骨架。它当然不会知道 Field 1 叫作 "name",但它能看到:“Field 1 是个按长度分隔(length-delimited)的字段,占 4 个 byte,内容是 'John'。”这就是为什么你可以把一坨原始的 hex 或者 Base64 Protobuf 数据扔进解码器里,还能还原出一棵堪称好用的、类似 JSON 的标签与值的树状结构。

现实世界的故事

喘不过气的物联网传感器集群

一家初创公司部署了一大批智能农业传感器,用来测量方圆数百英里农田的土壤湿度。一开始,他们让传感器每分钟都通过蜂窝网络给中央服务器 ping 一下 JSON payload。不到一个月,发生了两件事:他们的 3G 流量账单直接爆炸,传感器的电池也从原本能撑几个月变成了几周就死机。

于是他们重写了传感器固件,把数据序列化成 Protocol Buffers。因为他们发送的都是小数字和 boolean,把那些类似 {"sensorId": 14, "moistureLevel": 42} 的 JSON 字符串全部干掉后,payload 的体积锐减了 80% 以上。 学到的教训: 当带宽很贵且电池寿命有限时,传输原始字符串是一种你根本负担不起的奢侈行为。二进制序列化能真真切切地拯救物理资源。

2018 年的微服务大拥堵

一家电商巨头决定把他们的单体(monolithic)后端拆分成 50 个独立的微服务。他们选了带 JSON 的 REST API 来搞通信。突然之间,整体延迟疯狂飙升。给服务器跑一下 Profiling,结果令人震惊:当数据从 Auth 服务蹦到 Cart 服务,再跳到 Inventory 服务时,后端服务居然把 30% 的 CPU 算力都花在了 JSON 的 stringify 和解析上。

他们赶紧把内部通信全部迁移到了 gRPC——一个完全基于 Protobuf 构建的框架。因为解析 Protobuf 本质上就是读读内存偏移量、做做位移(bit shifting),CPU 使用率应声暴跌。数据瞬间就能在各项服务间嗖嗖穿梭。 学到的教训: 在高吞吐量的内部后端系统里,解析文本就是一笔隐形税。而二进制序列化,就是那个合法的避税漏洞。

网络抓包里的神秘 Payload

一位安全研究员试图对某款手机 App 未公开的 API 进行逆向工程,看看它到底在收集什么数据。她通过代理把 App 的流量拦截出来,本以为能看到清爽易读的 JSON,结果却收到了一堆乱码。HTTP header 证实了她的猜想:Content-Type: application/x-protobuf。

她手里当然没有 .proto schema(App 开发人员都好好地存在自己服务器上呢)。但她一把抓取了 Base64 payload,扔进了一个 raw Protobuf 解码器里。结果蹦出来一棵结构化的树:Field 1: "johndoe",Field 2: "user@example.com",Field 3: 40.7128,Field 4: -74.0060。她一眼就认出字段 3 和 4 是 GPS 坐标。 学到的教训: Protobuf 只是编码(encoded),可不是加密(encrypted)。即便没有那张“地图”,Wire Types 留下的结构化“面包屑”也足够让你把数据的骨架逆向工程拼凑出来。

常见错误与踩坑指南

  • 重复使用 field tags: 数字才是绝对的 Source of Truth(唯一事实来源)。如果你删掉了一个字段(比如 string name = 1;),你 绝对不能 把数字 1 挪用给新的字段,比如搞个 int32 age = 1;。因为要是老 client 发来一个 string,而新 server 试图把它当 integer 读,那就彻底原地爆炸了。
  • 忘了 reserve 删掉的字段: 为了防止上面的惨剧,Protobuf 提供了 reserved 关键字。如果你删掉了字段 2,你应该在 schema 里明确写上 reserved 2;。这会让编译器狠狠教训以后任何试图复用这个数字的开发老哥。
  • 作死乱改数据类型: 有些类型你可以安全地修改(比如把 int32 改成 int64,因为它们用的都是 Varint 这种 wire type),但把 string 改成 int32 绝对是个 breaking change。它俩的 wire type 从根本上就是水火不容的。
  • 以为 Protobuf 很安全: 它只是个序列化格式,不是加密。如果你在 HTTP 上用 Protobuf 发送敏感的用户数据,而且没加上 TLS/SSL,任何截获这波流量的人都能轻轻松松把原始值解码出来。
  • 在浏览器端盲目跟风使用: 尽管现在有 gRPC-web 这样的技术,但浏览器天生说的是 JSON 这种“方言”。硬要把 Protobuf 塞给前端 web app,就意味着得在你的 JavaScript bundle 里打包一个巨无霸解析库,这有时候反而把二进制格式省下来的带宽全给抵消掉了。

为什么它值得你关注

别在对外暴露的公共 API 里用 Protobuf——在那片领地里,JSON 依旧是无可争议的王,因为它对人类友好、灵活,而且地球上每一个浏览器都能原生懂它。

但是,只要你掌控了 管道的两端,你就应该毫不犹豫地掏出 Protobuf。如果你在做微服务内部通信、移动端 App 和后端打交道、实时多人游戏或者是物联网设备,Protobuf 简直就是超能力。它在团队间强制实行严格的数据契约(因为大家都拿同一个 .proto 文件去编译),能在编译期就把数据类型错误逮个正着,还能把你网络基础设施里的每一滴性能都榨得干干净净。

深度阅读

理论搞定,动手试试吧——100% 在你的浏览器中运行。

试用工具: Protobuf 解码器