☰
Protobuf 底层编解码原理:从 Varint 到字节流的完整链路拆解
2026/10/6 9:12:49 网站建设 项目流程

很多人来找我聊 Protobuf 编解码,一开口就是怎么定义 proto、怎么生成代码,却很少关注字节流到底长什么样。我个人的看法是,协议层方案你天天在用,底层编解码原理可以不用手写,但不能不懂——线上一个兼容性问题,一次莫名其妙的解码失败,往往就藏在几个 bit 里。这篇就用大概 10 分钟的时间,把 Protobuf 从字段定义到字节落地的完整链路拆开看一遍,适合刚接触 Protobuf 的开发者,也适合已经在用但心里对“为什么这样编”没有底的工程师。

1. 先搞清楚:Protobuf 到底在解决什么问题

1.1 序列化和反序列化的本质

任何跨进程通信,本质都是把内存里的对象变成一段可传输的字节,到对端再还原成对象。这个过程叫序列化和反序列化,日常开发中也叫编解码。对象是结构化的,而网络和磁盘只认字节,所以在“结构”和“字节”之间必须有一套双方都认可的转换规则。

Protobuf 就是这套规则的实现之一。它由两样东西组成:一是描述数据结构的.proto文件,二是根据这个文件生成的读写代码。生成后的代码会把字段名、字段类型、字段顺序这些信息压缩成一套紧凑的二进制格式。相比明文格式,它少了大量重复的键名和分隔符,换来的是更小的体积、更快的解析速度。

需要明确一点:Protobuf 不是为人类阅读设计的,它给机器读。所以你会看到二进制流里全是数字、长度前缀和标志位,没有"name"这样的字符串。正因为如此,理解它的编码规则,比看 JSON 的字符串结构要费一点脑筋,但一旦看懂,收益非常直接。

1.2 为什么值得花 10 分钟研究底层编码

很多人在业务里直接用现成库,SerializeToString一把梭,从来没有打开过序列化后的字节。短时间内确实没问题,但遇到下面这些情况时,不懂底层会非常被动:

第一种是排查线上问题。某次升级后客户端解析旧数据报错,或者同一个字段打印出来是乱码,这时候如果知道 Protobuf 的 wire type 和字段编码方式,很快能定位是字段号冲突还是类型不匹配。

第二种是性能优化。部分高频接口可能对体积有硬性要求,比如日志上报、网关转发。知道 Varint 对负数的处理很差、知道字段编号越小越省空间,你就能在设计 proto 时提前避免浪费,而不是事后靠压缩算法硬扛。

第三种是跨语言调试。Java 写的服务端和 Go 写的客户端对同一份 proto 生成代码后,如果你能看懂字节流,就可以在两边对不上时直接从 bytes 层面判断问题出在哪一端,而不是互相甩锅。

所以这里说的“掌握原理”,不是要你自己实现一个 Protobuf 编译器,而是建立对编码过程的直觉。有了这种直觉,你读文档时能理解每句话在说什么,出问题时能快速缩小范围。

1.3 与 JSON/XML 的直观对比

拿同样一个用户对象来对比,假设包含id = 150、name = "protobuf"两个字段。JSON 大致长这样:

{"id":150,"name":"protobuf"}

不算缩进,至少十几个字节,其中id、name这两个键名还占了固定开销。如果字段名很长,比如organization_department_code,每个对象都要重复传一次,规模上来后体积非常可观。

XML 就更不用说了,标签对一套下来,同样的数据能膨胀成几百字节。这不是说 JSON/XML 不好,它们有可读性强、自描述、便于调试的优势,在很多场景仍然是首选。但它们本质是“文本协议”,键名重复、数字变字符串、分隔符多,在性能和带宽敏感的传输场景里吃亏。

Protobuf 则是“二进制协议”,字段编号代替字段名,类型用 wire type 表示,整数按 Varint 压缩。同样的数据,序列化下来通常只有十几个字节,比 JSON 小一个量级。代价是肉眼不可读,需要借助工具或额外代码才能还原。

2. 核心编码规则:Varint、WireType 和 ZigZag

2.1 从 key 说起:字段编号和 wire type 的组合

Protobuf 二进制流的每个字段,开头都是一个 key,用来告诉解析器“后面这个值属于第几号字段,是什么类型”。key 的计算公式是:

key = (field_number << 3) | wire_type

field_number 是你在.proto中定义的字段编号,例如id = 1。wire_type 是值类型在线上传输时使用的编码方式,取值分为几种:

Wire Type含义常见字段类型
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bit 固定宽度fixed64, sfixed64, double
2长度前缀string, bytes, 嵌套消息, packed repeated
3废弃的 group 开始标记不建议使用
4废弃的 group 结束标记不建议使用
532-bit 固定宽度fixed32, sfixed32, float

key 本身也是一个整数,所以它同样会被 Varint 编码。举例来说,如果字段编号是 1,wire type 是 0,那么(1 << 3) | 0 = 8,编码成 Varint 后就是单个字节0x08。如果字段编号是 3,wire type 是 2,那么(3 << 3) | 2 = 26,编码成0x1A。

这里就引出一个设计原则:字段编号不要乱用大的数值。编号 1 到 15 的字段,key 只要一个字节;编号 16 到 2047 的字段,key 就要两个字节。高频字段用小编号,能省下肉眼可见的传输量。

2.2 Varint:可变长整数是怎么编码的

Varint 是 Protobuf 最核心的编码方式,核心思想是用一个字节的“最高位”作为结束标记,剩下的 7 位用来表示数据。每个字节的低 7 位是有效数据,最高位为 0 表示这是最后一个字节,为 1 表示后面还有字节。数据按照“低位字节在前”的顺序排列。

用一个经典例子说明,整数 1 只需要一个字节:二进制是0000 0001,最高位是 0,直接表示结束,所以序列化结果是0x01。

再看整数 150。150 的二进制是10010110,一共 8 位,需要拆成每 7 位一组。从低到高拆:第一组是后 7 位0010110,对应十进制 22;第二组是剩下的0000001,对应十进制 1。然后给每组加最高位标记:第一组不是最后一段,最高位设为 1,得到10010110即0x96;第二组是最后一段,最高位设为 0,得到00000001即0x01。所以 150 编码成两个字节:0x96 0x01。

解码时从第一个字节读起,先不看最高位,把低 7 位取出来,然后判断最高位。如果是 1,继续读下一个字节,并把新读到的 7 位左移 7 位再加到结果上;如果是 0,结束。整个过程很像拼积木,每一块积木是 7 位,方向从低位向高位叠加。

Varint 对小的正整数非常友好。0 到 127 只占 1 个字节,128 到 16383 占 2 个字节。这也是为什么 Protobuf 适合传输大量小整数。

2.3 ZigZag:负数场景下的优化手段

Varint 对负数有一个很坑的地方。在大多数语言里,负数的二进制表示是补码,比如-1的 32 位表示是0xFFFFFFFF,也就是 32 个 1。如果直接按 Varint 编码,前 4 个字节是完整数据,后 5 个字节都是因为符号扩展产生的连续 1,最后补一个结束字节,一共 10 个字节。这比正数的 1 个字节大了整整 10 倍,性能很差。

所以 Protobuf 专门为有符号整数提供了sint32和sint64类型,配合 ZigZag 编码。ZigZag 的思路是把一个有符号整数映射成一个无符号整数,让绝对值小的数变为绝对值更小的正数,且正负数交替排列:

原始值ZigZag 编码值
00
-11
12
-23
24

映射公式为:(n << 1) ^ (n >> 31)对应 32 位,(n << 1) ^ (n >> 63)对应 64 位。这里>>是算术右移,负数高位补 1。这样-1变成1,只占 1 个字节;-2变成3,仍然只占 1 个字节。

如果你在.proto里使用int32来定义业务上可能为负的字段,就要小心了。它的编码范围是 0 到 2^32-1,超过 2^31-1 的负数会当作很大的无符号数处理,输出 10 个字节。正确做法是:明确会有负数的字段,尽量使用sint32/sint64。

2.4 固定宽度与长度前缀:string、bytes 和嵌套消息

不是所有数据都适合 Varint。浮点数、随机数、哈希值这类数据,如果按 Varint 拆分,大小没有规律,反而会浪费空间和 CPU。这时 Protobuf 提供了固定宽度编码:fixed32占 4 字节,fixed64和double占 8 字节,float占 4 字节。它们的优势是长度固定、解析简单,可以用 SIMD 等技巧加速,适合大量数值的数组场景。

对于string、bytes、嵌套消息,wire type 为 2,格式是“key + length + content”。length 本身也是 Varint,表示后续内容的字节数。比如字段name = "protobuf"如果编号是 2,key 就是(2 << 3) | 2 = 18,即0x12;字符串长度 8 编码为0x08;随后跟着 8 个 ASCII 字节0x70 0x72 0x6F 0x74 0x6F 0x62 0x75 0x66。完整字节流就是:

0x12 0x08 0x70 0x72 0x6F 0x74 0x6F 0x62 0x75 0x66

嵌套消息也是一样:外层先写 key,再写内层消息序列化后的总字节长度,然后写入内层内容。解析器遇到 wire type 2 时会先读 length,再按 length 切出子消息的字节范围,交给对应的子消息解析函数。

理解长度前缀机制后,你会明白为什么repeated string不采用 packed 编码,因为字符串本身就是变长的,每个元素前都必须要有一个长度前缀。

3. 手把手实操:从 proto 文件到字节流

3.1 环境准备与常见安装坑

先说环境。本地需要两个东西:第一是protoc编译器,用来把.proto生成目标语言的代码;第二是语言对应的 runtime 库。以 Python 为例,runtime 是protobuf包。

安装protoc的路径有很多,macOS 上可以用 Homebrew,Linux 可以用 apt,Windows 可以直接下载官方 release 包。装完建议验证一下版本:

protoc --version

Python 的 runtime 可以用 pip 安装:

pip install protobuf

这里很多人会遇到一个提示:

Attempting uninstall: protobuf Found existing installation: protobuf 5.29.6

如果你能看到这行,说明系统里已经装过某个版本的 protobuf,pip 在安装新版本时尝试先卸载旧版。这个提示本身不是错误,但要注意:如果你用了--user安装或者当前环境权限不足,卸载旧版可能失败,进而导致安装中断。我的建议是:

  • 项目内一定要用虚拟环境,不要往系统 Python 里硬塞。用python -m venv venv隔离依赖。
  • 如果已经碰到冲突,先看当前版本:pip show protobuf,再指定安装目标版本:pip install protobuf==4.25.3。
  • 如果用 uv、poetry 这类新工具,它们对重装和缓存处理更干净,能省掉不少焦虑。

protoc 的版本和 runtime 的版本尽量保持在同一个大版本内。版本差太远,生成的代码可能依赖 runtime 中不存在的 API,最常见的就是 import 阶段直接报错。

3.2 编写一个最小 proto 并生成代码

在合适的位置创建一个user.proto,内容如下:

syntax = "proto3"; message User { int32 id = 1; string name = 2; repeated int32 scores = 3; }

proto3是当前主流语法,字段默认值机制、可选字段语义和旧版proto2有一些差异,新项目直接选 proto3 基本没错。然后执行:

protoc --python_out=. user.proto

运行后目录里会出现user_pb2.py,这就是 Python 可以 import 的模块。简单测试一下:

import user_pb2 u = user_pb2.User() u.id = 150 u.name = "protobuf" u.scores.extend([1, 2, 3]) data = u.SerializeToString() print(data)

如果你在我这个版本下运行,会看到类似这样的 bytes 输出:

b'\x08\x96\x01\x12\x08protobuf\x1a\x03\x01\x02\x03'

对这个输出先别急着当黑盒用,接下来我们把它拆开,看每一个字节对应什么。

3.3 把对象序列化成字节流,逐字节拆解

上面这段b'\x08\x96\x01\x12\x08protobuf\x1a\x03\x01\x02\x03'一共包含三组字段。

第一组\x08\x96\x01:

  • 0x08是 key,二进制是00001000,解析规则:高 5 位是字段编号,低 3 位是 wire type。0x08 >> 3 = 1,字段编号为 1;0x08 & 0x07 = 0,wire type 为 0,也就是 Varint。
  • 0x96 0x01是 150 的 Varint 编码。按前面 2.2 的方法解码,先取第一个字节低 7 位得到0010110即 22,最高位为 1 所以继续读下一个字节,取低 7 位得到0000001即 1。由于这是最后一个字节,结束。组合时第二个字节左移 7 位:(22) | (1 << 7) = 22 + 128 = 150。完全匹配。

第二组\x12\x08protobuf:

  • 0x12 >> 3 = 2,字段编号为 2;0x12 & 0x07 = 2,wire type 为 2,长度前缀。
  • 0x08是长度,表示后面的内容有 8 个字节。
  • protobuf是 8 个 ASCII 字节。注意这里没有用任何转义,二进制里直接就是明文字节。

第三组\x1a\x03\x01\x02\x03:

  • 0x1a >> 3 = 3,字段编号为 3;0x1a & 0x07 = 2,wire type 为 2。
  • 0x03表示后面 3 个字节。
  • \x01\x02\x03是 scores 列表的三个元素。因为int32类型的repeated字段在 proto3 中默认采用 packed 编码,所以它只写一次长度,后面直接拼接所有元素的 Varint,而不是每个元素都带一个 key。这比非 packed 编码省了不少字节。

你可以自己试一下修改 id 的值,观察字节数变化。比如 id = 1 时,输出会变成\x08\x01,只有两个字节;id = 128 时,输出变成\x08\x80\x01。这种直观感受比背公式印象深得多。

3.4 手写一个最小解码器验证原理

光看还不过瘾,我建议你亲手写一个极简解析器,只解析上面这种简单消息。这样能彻底验证你对 key、Varint、长度前缀的理解。代码不需要处理所有类型,核心逻辑就三步:读 key,判断 wire type,按类型读取值。

def read_varint(data, pos): result = 0 shift = 0 while True: byte = data[pos] pos += 1 result |= (byte & 0x7F) << shift if not (byte & 0x80): break shift += 7 return result, pos def parse_simple(data): pos = 0 fields = {} while pos < len(data): key, pos = read_varint(data, pos) field_number = key >> 3 wire_type = key & 0x07 if wire_type == 0: value, pos = read_varint(data, pos) fields[field_number] = value elif wire_type == 2: length, pos = read_varint(data, pos) value = data[pos:pos + length] pos += length fields[field_number] = value else: raise ValueError(f"Unsupported wire type: {wire_type}") return fields data = b"\x08\x96\x01\x12\x08protobuf\x1a\x03\x01\x02\x03" print(parse_simple(data))

运行结果会是一个字典,字段 1 对应 150,字段 2 对应b'protobuf',字段 3 对应b'\x01\x02\x03'。这个程序漏洞很多,比如没有处理 int32 的符号扩展、没有真正解析嵌套消息,但它把核心骨架露出来了。

真正理解 Protobuf 编解码最有效的方法,就是像这样把手伸进字节流里走一遍。等你能不看源码手写出这个小解析器,再去用官方库就完全是另一个心态了。

4. 进阶协议设计:嵌套、repeated 和 map 在线上怎么编

4.1 repeated 字段的 packed 与非 packed

repeated字段是列表,在 proto3 里,标量数值类型默认采用 packed 编码,也就是上面看到的模式:一个 key,一个总长度,后面直接拼接所有元素值。打包后,列表越长,节省的 key 越多。比如 1000 个 int32 元素,每个元素如果是 1 字节,非 packed 需要 1000 个 key 加 1000 个值;packed 只需要一个 key、一个长度、1000 个值,省掉 999 个 key。

但这里有个容易踩的坑:不是所有 repeated 都 packed。repeated string和repeated bytes永远不可能 packed,因为每个字符串长度不同,必须逐个元素前缀 length。repeated嵌套消息同样不 packed,因为每个子消息长度不同。

还有一个坑是跨版本互操作。老版本 proto2 默认不启用 packed,如果你用 proto3 生成的数据发给只支持 proto2 的旧解析器,可能解析不了。官方文档里也强调,packed 编码对旧代码可能不兼容,改字段的 packed 标记要谨慎。

4.2 map 字段的底层表示

你在.proto里写map<string, int32> score_map = 1;,看起来是一个很方便的键值对结构。但 Protobuf 二进制协议里并没有专门的 map wire type。编译器实际上会把 map 展开成一个repeated消息,每个元素是一个包含 key 和 value 两个字段的消息。

举个例子,下面的定义:

message ScoreMap { map<string, int32> scores = 1; }

等价于编译器内部处理成:

message MapEntry { string key = 1; int32 value = 2; }

每个 MapEntry 就是一个普通消息,字段编号固定是key = 1、value = 2。外层字段编号仍然是 1,wire type 是 2,后面跟着每个 MapEntry 序列化后的字节流。

理解这一点对调试很重要。当你在日志里看到 decode 出来的 map 字段显示成类似[{"key": "math", "value": 95}]的结构,不要惊讶,那只是工具把底层 entry 消息展示出来了,语义上仍然是 map。

由于底层是消息数组,map 的字段顺序也不保证有序。如果业务上需要有序遍历,要么用repeated消息模拟,要么在读取后再自行排序。

4.3 字段编号选号策略与扩展性

Protobuf 的兼容性规则里,最不能碰的就是已经发布出去的字段编号。因为字段编号就是协议的一部分,改号等同于改造协议。添加新字段时,选一个没有用过的编号即可;删除字段时,最好把那几个编号加入reserved声明,防止未来有人无意中复用,导致新旧数据错乱。

字段编号的选择也直接影响编码体积。前面提到 key 是用 Varint 编码的,所以:

  • 字段编号 1 到 15,key 只占 1 个字节。
  • 字段编号 16 到 2047,key 占 2 个字节。
  • 字段编号越大,key 占的字节数越多。

因此高频字段、必选字段尽量放在 1 到 15。低频字段、扩展字段放在后面。比如一个订单消息,订单号、用户 id、金额这些每次都有的字段用小编号,某些只在统计场景才出现的 append-only 字段用大编号。

另一个容易忽略的地方:同一个消息里不要有两个字段共用同一个编号。这个错误在生成代码时会直接编译报错,但如果由多人维护同一个 proto,且没有认真走 code review,是有可能悄悄发生的。设计期预留一些字段号,可能比事后追认编号更省心。

5. 常见问题与排查技巧实录

5.1 解码出乱码或字段对不上,先查 wire type

我在实际排查中遇到最多的一类问题是:齐数据拿过来,解析出来的字段值完全对不上。比如一个字段本来应该是整数,解码后变成了一串不可读的字节;或者一个字符串字段读出来前面多了一截乱码。

这种问题十有八九是字段类型变了但字段编号没变。举个例子,老版本里字段 5 是int32,新版本里被人改成string。编码方按 string 写了个长度前缀,解码方按 int32 去解析,会把 length 当成一个很大的整数,后续字段也会跟着错位。

排查思路很简单:拿到字节流后,先打印前几个字节,手动算出第一个 key 的字段编号和 wire type,和 proto 定义比对。如果发现 wire type 对不上,直接沿着字段编号历史找问题。用工具时,可以用protoc --decode或者一些在线解码器先看一个字段一个字段的原始表示,不要一上来就陷入业务逻辑。

5.2 新旧版本兼容:新增、删除、修改字段的正确姿势

Protobuf 的兼容性规则不是靠编译器强制保证的,而是靠团队纪律。核心规则可以整理成一张速查表:

操作是否兼容正确做法
新增字段兼容使用新的字段编号,不要复用旧编号
删除字段兼容把字段编号和字段名加入 reserved
修改字段类型高风险尽量不动,特别不要改 wire type
重命名字段兼容字段编号不变即可,代码里相应调整
修改字段编号不兼容绝对禁止

新增字段最好也考虑默认值。proto3 里所有字段都有默认值,int32默认 0,string默认空串,bool默认 false。老客户端解析到不认识的字段时会直接跳过,不会报错,这是 Protobuf 前向兼容的基本能力。但是如果你给一个必选字段加了新默认值,逻辑层需要自己处理。

修改字段类型时尤其要小心 wire type 变化。从int32改成uint32是安全的,因为两者 wire type 相同;从int32改成fixed32就不安全,因为 wire type 从 0 变成 5,老数据解析会完全错位。实在要改,建议换一个新字段编号,旧字段标记废弃。

5.3 性能与空间优化:不该用 Varint 的地方别用

很多人以为 Protobuf 用了 Varint,所有整数都越短越好。实际上,Varint 在数据值很小的时候确实省空间,但它解析时需要逐字节判断结束位,CPU 开销比固定宽度编码更大。对于浮点、随机 id、哈希值这类数据,固定宽度可能更划算。

判断标准看数据分布:

  • 如果你知道一个整数绝大多数情况下小于 128,用int32或uint32,一个字节搞定。
  • 如果数据接近均匀分布,比如随机生成的 32 位整数,用fixed32,长度固定 4 字节,比 Varint 平均 5 字节省空间,解析也更快。
  • 如果是负数居多的统计数据,用sint32/sint64,配合 ZigZag 能压到最小。

另外还要关注字段频率。一个日志消息里可能有几十个字段,但真正每次都传的可能只有三五个。过度设计没必要,把高频字段的编号控制在 15 以内,已经能覆盖绝大多数场景。

5.4 不要把 Protobuf 和音视频编解码混为一谈

搜索“编解码”这个词时,很容易跳出来另一类完全不相关的内容:H.264、HEVC、VPU 硬件编解码。它们针对的是视频帧压缩,跟 Protobuf 这种数据序列化协议是两码事。我在分享时也经常被问到“Protobuf 能不能用来压缩视频”,答案是不要有这个想法。

Protobuf 的作用对象是结构化数据,比如订单、用户、配置,它擅长去掉键名冗余,但对视频这种二进制大块内容无能为力。如果你非要把视频塞进 Protobuf,通常会选择bytes字段直接搬运,实际字节数和原始压缩视频几乎一致,不可能做到转码级别的压缩。

所以做技术选型时先看清目标。数据交换、RPC 调用、事件消息,选 Protobuf;多媒体压缩、码流处理,去看专业的音视频编码方案。两者名字里都有“编解码”,但底层原理和优化方向完全不同。

我自己每次改完 proto 文件,都会顺手导出一段真实数据的字节流,对照着 key、Varint、长度前缀重新看一遍。这个习惯帮我在正式发布前就发现过好几次字段号冲突和 wire type 不匹配的问题。你也可以试一下,哪怕只打印一行字节,都比继续依赖黑盒来得踏实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询