拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Protobuf编码原理深度解析:varint与zigzag实战

Protobuf编码原理深度解析:varint与zigzag实战

1. 为什么说编码原理才是 Protocol Buffers 的“内功心法”

Protocol Buffers 这个名字,搞后端或者客户端通信的人应该都不陌生。但说实话,大部分人对它的使用停留在“写个 .proto 文件,跑一下 protoc,然后调 .SerializeToString() 和 .ParseFromString()”的层面。我之前也是这个状态,直到有一次线上排查问题被逼着去读了二进制消息流,才意识到:如果不理解编码原理,Protocol Buffers 在你眼里就是个黑盒,出了问题只能瞎猜。

那次的问题很典型:某个服务消息体从几百字节暴涨到几 KB,带宽和磁盘双双报警。我最初怀疑是有人往消息里塞了大字段,结果查了一圈,最后发现是有人把一个 int32 字段从正数改成了负数,还在循环里塞了几百次。为什么负数会导致体积暴涨?这就涉及 Protocol Buffers 的 varint 编码规则。可以说,凡是涉及性能优化、数据体积控制、跨语言兼容性排查,不懂编码原理寸步难行。

这篇文章我会把 Protocol Buffers 底层的编码规则拆开揉碎讲清楚,包括 varint、zigzag、key 结构、wire type、length-delimited 机制,以及这些原理在工程里的实际影响。适合正在用 Protocol Buffers 但想深入一层的开发者,也适合刚接触序列化协议、想在方案选型时做出理性判断的朋友。你不用背代码,跟着我手算一遍字节,以后看十六进制消息流会像看 JSON 一样自然。

2. Varint 编码:Protocol Buffers 节省空间的基石

2.1 Base 128 编码与连续位机制

Varint 是 variable-length integer 的缩写,核心思想是用尽量少的字节表示尽量小的数字。Protocol Buffers 默认所有整数类型都走 varint(除 fixed32/fixed64/sfixed32/sfixed64 外),这决定了它对小数字极度友好,对大数字则不一定。

Varint 的编码规则可以概括为:把数字按每 7 个 bit 为一组切分,低 7 位放在第一个字节,高位依次放后面;每个字节的最高位(第 8 位)作为 continuation bit,值为 1 表示后面还有字节,为 0 表示这是最后一个字节。换言之,每个字节真正有效的数据位只有 7 位。

我用十进制 300 来手算一遍。300 的二进制是 100101100,从低到高每 7 位切分:低位组是 0101100(十进制的 44),高位组是 0000010(十进制的 2)。编码时低位组在前,且因为高位组后面没有数据,所以第二个字节的 continuation bit 为 0。结果就是两个字节:第一个字节是 44 + 128 = 172(0xAC),第二个字节是 2 + 0 = 2(0x02)。所以整体写入字节流就是AC 02。

实际解码时反过来:读取第一个字节 0xAC,低 7 位是 0x2C(十进制的 44),最高位是 1,说明还要继续;读取第二个字节 0x02,低 7 位是 2,最高位是 0,停止。把这两组拼起来:44 + (2 << 7) = 44 + 256 = 300。注意这里不是大端也不是小端,而是小数字在低地址、高位数据左移的逻辑,用“按 7 位一组的自然二进制拼接”来理解最准确。

这个设计的巧妙之处在于,0 到 127 的数字只占 1 个字节,128 到 16383 占 2 个字节,以此类推。对照表如下:

数值范围需要字节数
0 ~ 1271 字节
128 ~ 163832 字节
16384 ~ 20971513 字节
2097152 ~ 2684354554 字节
最大 uint64(18446744073709551615)10 字节

2.2 负数陷阱:为什么 int32 负数会占 10 个字节

这是我在实际工程里踩过最深的坑。Protocol Buffers 对 int32/int64 的类型在设计上区分了“有符号整数”和“实际存储方式”,它在编码负 int32 时,会先做一次符号扩展,把负数转成 64 位的无符号整数表示,然后再走 varint。

举例来说,-1 在 int32 里是 0xFFFFFFFF,但编码时会被符号扩展成 0xFFFFFFFFFFFFFFFF,即 2^64 - 1,这是一个极大的数,varint 需要 10 个字节才能装下。这意味着,字段里每出现一个负数,都要付出 10 字节的代价。

如果业务场景里负数很常见,或者字段值有可能在正负之间波动,就一定要把类型改为 sint32/sint64。这两种类型在编码前会先经过 zigzag 变换,把负数映射成正数再走 varint。一张表可以说明 zigzag 的映射关系:

原始值zigzag 后
00
-11
12
-23
24
21474836474294967294
-21474836484294967295

数学公式是(n << 1) ^ (n >> 31)(int32)或(n << 1) ^ (n >> 63)(int64),即左移一位后,按符号位做异或。这样映射后,-1 变成 1,编码后只有 1 字节。所以我的经验是:凡是业务语义上“有可能为负”的整数,一律用 sint32/sint64;如果业务上明确非负,用 uint32/uint64 反而能比 int32 多一倍的正数范围,因为 uint32 的 4 字节可以完整表达 0 到 4294967295,而 int32 的负数要占 10 字节,正数也只能用到 2147483647。

3. 字段 Key 的结构:字段编号与 wire type 的配合

3.1 一个字节里的双层信息

Protocol Buffers 的每一个字段在序列化结果中都不是裸数据,前面必须带着一个 key(也叫 tag)。key 的本质是(field_number << 3) | wire_type,也就是把字段编号左移 3 位,再和 wire type 做按位或。低 3 位表示该字段的数据类型应该怎么解析,高位的剩余 bit 表示字段编号。

这里用我上面提到过的 message 举例。假如:

message User { int32 id = 1; string name = 2; }

字段id = 1的 key 就是(1 << 3) | 0= 8,十六进制是0x08。字段name = 2的 key 是(2 << 3) | 2= 18,十六进制是0x12,其中低 3 位的 2 表示这是 length-delimited 类型(string 属于这一类)。

这个设计的精妙之处是:key 本身自带“边界信息”。解析器读到 key 就能知道后面应该读几个字节、怎么解释这些字节,完全不需要外部 schema 参与解码。这也解释了为什么 Protocol Buffers 能做到向前向后兼容——老版本的程序读到新加的字段,能根据 key 的 wire type 跳过未知字节,不至于解析失败。

key 的字段编号部分也是用 varint 编码的。也就是说,字段编号 1 到 15 时,整个 key 只要 1 个字节;字段编号 16 开始,key 需要 2 个字节。换算成直观数字:

字段编号key 的十六进制key 占用字节
1081 字节
2101 字节
15781 字节
1680 012 字节
1788 012 字节

因为低 3 位被 wire type 占了,字段编号实际上是从 1 开始的,所以 key 的计算不会和 wire type 冲突。但这也意味着字段编号不能为 0。

3.2 为什么字段编号规划是数据体积的第一道关

很多团队在设计 proto 文件时完全不重视字段编号,随手往下排。这在字段数量少的时候没什么问题,一旦消息里有上百个字段,热度差异就很明显了。

热字段(高频访问、高频写入)放在 1 到 15,意味着每次序列化都少付 1 字节。一次调用少 1 字节看不出来,一天几亿次调用,量的积累就很客观。冷字段全部放到 16 以上,让它们付 2 字节的 key 成本。这个规则不仅适用于 RPC 消息,也适用于存数据库的持久化消息——存储空间和成本是长期存在的。

我见过有种做法是“一次性排完所有字段编号”,把 1 到 100 全部占满,然后后面新加的字段只能用 101 开始。这个做法的问题在于,新加的字段如果有高频访问场景,key 就得从 2 字节甚至 3 字节起步。更好的做法是保守规划:1 到 15 留给最核心的热字段,16 到 2047 留给常用但没那么热的字段,2048 以上才留给冷门字段或未来扩展。这里的逻辑很简单——字段编号越靠前,key 越小,但前 15 个编号是稀缺资源,一定要省着用。

再补充一个容易忽略的点:不要轻易复用已删除的字段编号。如果老版本的程序还在线上运行,它会把新复用编号的那个字段解析成旧的类型,轻则丢数据,重则 panic。正确做法是:废弃字段用reserved关键字保留编号,让后续开发者知道这些编号已经被占用。

4. Wire Type 全解析:六种类型各自的编码规律

4.1 类型对照表与各 wire type 的存储方式

前面说 key 的低 3 位表示 wire type,实际上 Protocol Buffers 定义了 6 种 wire type,但经常用的只有 0、1、2、5 这四种。完整对照如下:

Wire Type含义对应类型存储方式
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum变长 varint
164-bitfixed64, sfixed64, double固定 8 字节,小端序
2Length-delimitedstring, bytes, embedded messages, packed repeated固定写入长度后再写数据
3Start groupdeprecated group已废弃
4End groupdeprecated group已废弃
532-bitfixed32, sfixed32, float固定 4 字节,小端序

wire type 1 和 5 是固定长度类型,不管数字大小都占 4 或 8 字节。这就是为什么 fixed32 适合存储“值不太大但分布均匀”的数据,比如时间戳、哈希值、随机数。反过来,如果大部分值是 0 到几十的小数字,用 fixed 类型反而不如 varint 划算。很多人在选型时只知道 fixed 更快,却忽略了它可能更费空间。我这里给一个简单的选型标准:

  • 值大部分在 0 到 100000 之间波动,分布集中在低位,用 varint。
  • 值接近 2 的整数次幂附近,或者均匀分布在 32 位/64 位全范围内,用 fixed 类型。
  • 对 CPU 性能极敏感,且空间不是瓶颈,用 fixed 类型减少解码时的分支判断。

4.2 Length-delimited 的嵌套结构

wire type 2 是数量最多的类型,string、bytes、嵌套 message、packed repeated 都走这个通道。它的编码结构是:key 之后,先写入一个 varint,表示后面数据的字节长度;然后再写入数据的原始字节。

举个例子,string name = 2;里的值是"hello",编码结果是:

  1. key 为 0x12
  2. 长度 varint 为 5
  3. 紧接着 5 个 ASCII 字节 68 65 6c 6c 6f

合在一起就是12 05 68 65 6c 6c 6f。

嵌套 message 的编码也是这样。假设:

message Order { int32 order_id = 1; } message User { Order order = 3; }

如果order.order_id = 150,那么order字段先被序列化成内部字节流08 96 01(key 0x08,varint 0x96 0x01),然后在 User 的层面上,字段 3 的 key 是(3 << 3) | 2= 26(0x1A),后面跟上内部字节流的长度 3,再跟上08 96 01。整体是1A 03 08 96 01。

从这里能看出一个特点:Protocol Buffers 嵌套 message 的序列化结果是不带自我描述的,它只是一个“黑盒字节流”,由外层长度字段限定边界。这个设计让解析器可以跳过任意嵌套消息而不需要递归解析内部结构,在读取效率和流式处理上优势很大。

另外,proto3 里 repeated 标量字段默认启用 packed 编码,即把多个值打包成一个 length-delimited 数据块。比如:

message Scores { repeated int32 scores = 1; }

三个值 1、2、3 的 packed 编码是:key 0x0A,长度 0x03,然后依次是 01 02 03,合起来0A 03 01 02 03。如果是非 packed 的老格式,则是08 01 08 02 08 03,每多一个元素就多付一个 key 的字节,数据量大时差距非常明显。这里有个兼容性细节:虽然生成代码能同时解析 packed 和非 packed 数据(proto3 默认兼容两者),但不同语言生成的解析器对未知 wire type 的处理行为可能有细微差异,做跨语言联调时最好统一生成器版本。

5. 实操:手算一条 User 消息的完整字节流

5.1 从 proto 定义到字节流的逐步拆解

纸上谈兵再多,不如完整算一个实例。我定义一个稍微复杂一点的 message:

message User { int32 id = 1; string name = 2; repeated int64 scores = 3; float score_avg = 4; bool active = 5; }

假设一条实际消息:

  • id = 150
  • name = "tom"
  • scores = [1, 2, 3]
  • score_avg = 3.5
  • active = true

我逐步写出最终的十六进制字节流。

第一步,id = 150。id 是字段 1,wire type 0,key = 0x08。150 的 varint 是96 01(150 = 128 + 22,低 7 位是 0x16,加 0x80 得 0x96;高位组是 0x01)。所以这一块是08 96 01。

第二步,name = "tom"。字段 2 是 string,wire type 2,key = 0x12。长度是 3,ASCII 是 74 6f 6d。所以是12 03 74 6f 6d。

第三步,scores = [1, 2, 3]。字段 3 是 repeated 标量,proto3 下默认 packed。wire type 2,key = (3 << 3) | 2 = 26,即 0x1A。长度是 3,数据是 01 02 03。所以是1A 03 01 02 03。

第四步,score_avg = 3.5。字段 4 是 float,wire type 5。key = (4 << 3) | 5 = 37,即 0x25。3.5 在 IEEE 754 单精度下的十六进制是 0x40600000,小端序写入是00 00 60 40。所以是25 00 00 60 40。

第五步,active = true。字段 5 是 bool,wire type 0。key = (5 << 3) | 0 = 40,即 0x28。布尔值 true 在 varint 里是01。所以是28 01。

把五块拼起来就是:

08 96 01 12 03 74 6f 6d 1A 03 01 02 03 25 00 00 60 40 28 01

5.2 解码时的实际过程与边界处理

如果用解析器读上面那串字节,过程是这样的:先读到 0x08,低 3 位为 0,知道是 varint;字段编号是 0x08 >> 3 = 1,所以是字段 1。然后读 varint 得到 150。接着读到 0x12,低 3 位为 2,字段编号是 2;读 varint 得到长度 3,再连续读 3 个字节作为字符串内容。然后读到 0x1A,低 3 位为 2,字段编号 3;读长度 3,再读 3 个字节的 varint 数组。再读到 0x25,低 3 位为 5,字段编号 4;固定读 4 字节小端字节序作为 float。最后读到 0x28,低 3 位为 0,字段编号 5;读 varint,值 1 表示 true。

注意一个细节:字段顺序在编码时并没有强制要求按字段编号升序排列。序列化器完全可以把字段 5 写在字段 1 前面,解析器照样能正确解析。但 protoc 生成的标准序列化器默认按字段编号升序输出,这是为了确定性,而不是协议强制要求。

在实际调试时,我常用的一个技巧是用protoc --decode_raw来盲解未知消息。比如把上面的二进制内容存到user.bin,执行:

cat user.bin | protoc --decode_raw

输出会长这样:

1: 150 2: "tom" 3: 1 3: 2 3: 3 4: 3.5 5: 1

--decode_raw的好处是不需要 .proto 文件也能解析出字段编号和值。这在排查线上异常数据、确认某个字段的真实 wire type 时极其好用。我还习惯同时打开--decode配合正式的 .proto 文件做对照,能迅速找出“字段编号定义和实际数据不匹配”的问题。

除了protoc命令行,写代码时也可以直接用生成的语言 API 把消息 dump 成 hex。C++ 可以用DebugString()和ShortDebugString(),Go 可以用proto.Marshal后打%x,Java 可以用toString()。无论哪种方式,只要你有能力把字节流和字段编号对应起来,很多诡异问题都能当场定位。

6. 编码原理背后的兼容性设计与版本演进

6.1 新增字段为什么不会破坏旧版本

Protocol Buffers 的兼容性承诺是它敢大规模替换 JSON 序列化方案的核心底气。这种兼容性不是靠魔法,而是完全建立在编码原理之上。

旧版本程序解析新版本数据时,遇到未知字段编号,会先根据 wire type 确定该字段占用多少字节(或如何跳过),然后完整跳过。新版本程序解析旧版本数据时,由于旧的字段编号和 wire type 完全没变,能正常解析旧有字段。因此,新增字段是天然安全的。

但这有一个隐含前提:同一个字段编号的 wire type 不能变。举例来说,字段 3 原来是 int32,后来想改成 string,这就不行了。int32 是 wire type 0,string 是 wire type 2,解析器读到字段编号 3 时,发现 wire type 和旧版本期望的不一致,旧程序会直接丢弃或报错。所以字段类型可以升级的情形很有限,最稳妥的方式是新增字段编号,而不是改造已有字段。

还有一个容易忽略的坑:删除字段时,如果只把字段从 .proto 中删掉,但之后又新增字段恰好复用了这个编号,就会出现新老程序把同一个编号解释成不同类型的问题。这个在前面提到过,工程上必须用reserved关键字显式保留编号。

6.2 packed 编码的兼容性细节

在 proto3 里 repeated 标量默认 packed,但 proto2 不默认。这个差异常常被跨版本团队忽视。

实际场景中,如果 A 服务用 proto3 生成代码发送 packed 数据给 B 服务,B 服务用 proto2 生成代码解析,绝大多数语言的标准实现都能正确解析,因为协议规范允许解析器同时接受两种格式。但异常情况也存在:某些早期版本的解析器对 packed 字段的repeated声明有严格校验,如果 .proto 里没标[packed=true],解析器可能跳过整个字段。这个问题在 C++ 的老版本和部分第三方语言的实现里出现过。

我的建议是,跨团队协作时统一 proto 版本,或者至少统一生成代码的工具链版本。在不统一的场景下,尽量在 .proto 里显式声明[packed=true],避免依赖默认值来保证行为一致。

6.3 枚举与默认值的边界坑

枚举在编码上走 varint wire type 0。proto3 里第一个枚举值必须是 0,且当字段值为 0 时,序列化器会直接不输出该字段,解析时认为它是默认值 0。这也引出一个小坑:细看 wire 上传输的数据,字段缺失并不一定表示字段不存在,有可能只是值为默认值被省略了。

对于 bool 类型同理,false默认省略,true编码为01。对于 string,空字符串默认省略,长度直接不写入。这些省略行为完全符合编码规则,但会让不懂原理的新手误以为数据丢了。我之前排查一个问题,日志里看到请求消息缺少某个 string 字段,实际上是因为值是空字符串,编码时被跳过。只要用HasField检查存在性而不是直接比较值,才能区分“未设置”和“设置为默认值”,这一点在跨语言对接时尤其重要。

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

7.1 消息体积突增的排查套路

我整理一个我实际用过的排查步骤,供遇到类似问题的人参考。

第一步,确认体积涨幅发生在哪个字段。用protoc --decode_raw解析线上捕获的二进制数据,对比基线看哪个字段编号出现了超预期的大值或长字符串。

第二步,检查数值类字段是否有负数。如果业务上允许负数,但类型用的是 int32/int64,就会出现 10 字节的巨型 varint。修正方法是改类型为 sint32/sint64,或者把负数绝对值转换成非负再处理。

第三步,检查是否有大量 repeated 字段没有被 packed。proto2 里如果不显式声明 packed,每个元素带一个 key,数据量翻倍甚至更多。参考字段数量的增长幅度,如果一条消息里有几千上万个重复元素,非 packed 的额外 key 开销会非常可观。

第四步,检查是否错误地把字节数组存成了 string。很多语言里 string 是按 UTF-8 编码处理的,如果直接往里塞二进制数据,比如图片或加密结果,序列化后会和 bytes 类型的处理路径不同,某些语言实现还会做 base64 再编码,额外膨胀 33%。

这套排查流程我建议直接固化到团队的告警处理手册里,每次消息体积异常就按这个顺序走,比从头猜高效得多。

7.2 跨语言联调时的高频故障表

现象根因解决办法
解析出的 int64 精度不对JavaScript 的 number 无法精确表示超过 2^53 的整数,而 proto 字段类型为 int64生成 JS 代码时开启 int64 转字符串的选项,避免用 number 类型接收
字段顺序错乱但值正确序列化器未按字段编号排序,或使用了流式写入这是合法行为,不要依赖字节流固定顺序做校验
旧客户端解析新数据时字段丢失新增字段编号未规划,或使用了 15 以下的热编号与旧字段冲突检查版本差异,保留字段编号规划
枚举值解析失败线上传入的值超过了 .proto 定义的枚举范围proto3 会把未知枚举值保留为字段的数值,不会报错,但也因此可能产生非预期值,需在业务层拦截
同一消息不同语言序列化结果不一致字段顺序、packed 声明、默认字段跳过策略不同以 protoc 官方生成代码为准,自定义序列化器极易破坏兼容性

跨语言联调最大的坑是:两个语言各自生成的代码在“省略默认值”和“repeated 编码”上可能不一致,导致抓包对比时字节流不同。这不一定是 bug,我通常的做法是直接解析语义,而不是对比字节流。

7.3 性能优化实战:字段编号与编码类型的选择

性能优化方面,编码原理能直接指导的几点包括:

一是减少 key 开销。热字段用 1 到 15 的编号,保证 key 只有 1 字节。数据量大时这个优化立竿见影。

二是合理选择整数编码类型。业务值集中在 0 到 100000 用 varint,集中在靠近 2^32 或 2^64 用 fixed;负数必用 sint。

三是避免深层嵌套。嵌套 message 虽然序列化灵活,但每层都要多写 length 字段,解码时也会增加层级判断。把深度控制在 3 层以内,性能通常最好。

四是用 bytes 替代 string 传输二进制数据,避免字符编码开销和潜在的 base64 膨胀。

五是如果同一个消息要序列化多次,建议复用序列化器对象,并且预分配 buffer。C++ 的Arena机制、Java 的CodedOutputStream复用,都能显著减少分配开销。

7.4 调试工具与日常习惯

我平时调试 Protocol Buffers 相关问题的工具清单:

  • protoc --decode_raw:无 .proto 文件时盲解二进制流。
  • protoc --encode:把文本形式的消息编码成二进制,用于构造测试数据。
  • Wireshark 的 Protobuf 解析插件:抓 RPC 流量时直接看解析后的字段。
  • 自写 hex dump 小工具:把二进制按字节打印成十六进制,配合字段编号手动核对。

日常习惯方面,我强烈建议在日志里输出DebugString()而不是二进制。DebugString()输出的是人类可读的字段键值对,即使线上日志量很大,也能快速定位问题字段。另外,每次修改 .proto 文件后跑一遍protoc --decode_raw对比新旧字节流,能提前发现很多兼容性问题。

最后再分享一个我近期整理的经验:Protocol Buffers 的编码原理不仅适用于排查问题,也适用于设计协议本身。比如你在设计一个高性能网关的透传协议,可以把原始字节流作为 bytes 字段包进新的 message 里,利用 length-delimited 的边界特性实现零拷贝转发。掌握 encoding 层的能力,你就不只是“会用 protobuf”,而是“能驾驭 protobuf”了。这里面的分水岭,就是能不能拿着十六进制字节流,像读 JSON 一样轻松看出每个字节对应的字段、类型和数据。

返回列表