ARTICLE DETAIL

资讯详情

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

Protobuf 编码原理拆解:从 Varint 到 Wire Type 的字节级优化

Protobuf 编码原理拆解:从 Varint 到 Wire Type 的字节级优化 1. 为什么需要深入理解 Protobuf 编码1.1 黑盒之外的现实场景做后端开发这些年跟 Protocol Buffers 打交道的时间不算短。RPC 通信、配置下发、数据落盘几乎处处都有 proto 文件编译出来的代码在工作。但说实话很长一段时间里我对它的认知停留在定义好 .proto 文件编译成代码调用序列化接口这个层面本体到底是怎么把结构化数据变成一坨字节的从来没仔细看过。直到有一次排查跨语言兼容性问题对着十六进制报文发呆才痛下决心把 Protocol Buffers 的编码原理整个啃了一遍。那之后我才意识到这个知识点不是学院派才需要的东西。线上出了问题最常见的三种场景全部依赖对编码格式的理解第一两个服务用的 proto 版本不一致老服务解析新字段时报错或者静默丢数据你要能从抓包数据里看出端倪第二性能优化时纠结某个字段该用 int32 还是 sint32、该放 1 号字段还是 17 号字段不懂编码结构就只能靠猜第三日志或者 trace 里打出来一串十六进制别人对着 Wireshark 能一眼定位问题你只能干瞪眼。这三种场景我全都经历过而且都是在毫无准备的情况下被问题推到面前。1.2 适合谁以及能解决什么问题这篇文章不是给纯小白科普Protobuf 是什么的至少你要能写出一个简单的 .proto 文件编译过跑通过一次序列化反序列化。在此基础上我会带你从字节层面彻底拆解一条消息Varint 怎么编码、tag 怎么组成、字符串和多层嵌套消息怎么带长度前缀、packed repeated 字段到底省在哪里。学完之后你至少能做到三件事拿到一段十六进制数据能徒手还原出原始消息的大致结构在字段选的类型不合适导致包体膨胀时能准确说出膨胀发生在哪几个字节排查跨语言、跨版本的解析异常时不再是靠重启和加日志碰运气。需要说明的是文章里所有手写编码的过程都是基于 Protocol Buffers 官方二进制格式的常见实践不同语言库在边界行为上略有差异但核心的线上格式是一致的只要理解了这个层面的规则任何语言的库对你来说都不再是黑盒。2. VarintProtobuf 最底层的整数编码2.1 七个比特一组的 base-128 方案Protocol Buffers 里面最常用、也最核心的整型编码方式叫 Varint可变长整数。它的核心思路和 UTF-8 的变长机制有点像一个整数不一定非得用固定 4 字节或 8 字节来存而是根据数值大小动态决定占用几个字节数值小就省空间数值大才扩展。具体规则是把整数按 7 个比特一组从低位向高位切分每一组成为一个块byte chunk然后在每个块的最高位第 8 个比特即 MSB打上继续或结束的标记。如果这个块后面还有后续块MSB 置 1如果这是最后一个块MSB 置 0。这样解码器读到一个字节时先看最高位是 1 就接着读是 0 就说明这个 Varint 结束了。本质上这就是一个 base-128 的数字表示法只不过每个数字位占 1 个字节而不是 1 个字符而且用 MSB 作为连续的标志位。它的好处是显而易见的0 到 127 的整数只占 1 个字节128 到 16383 占 2 个字节绝大多数业务里的 ID、状态码、计数都落在很小的范围内所以整个消息体可以被压得非常小。2.2 亲手算一遍300 怎么变成 AC 02只看规则容易飘直接算一遍最踏实。Google 官方文档里最经典的例子就是整数 300 被编码成两个字节AC 02这个例子我建议每个人都亲手推一遍推完 Varint 基本就懂了一半。先把 300 转成二进制300 256 32 8 4 0b100101100也就是 9 个比特1 0010 1100。按 7 比特一组从低位切分低 7 位是010 1100即十进制的 44高位的剩余部分是10即十进制的 2。所以 300 需要两组块来存。第一个块存 44044 0x2C因为它不是最后一个块MSB 置 1得到0xAC。第二个块存 2002 0x02它是最后一个块MSB 置 0所以还是0x02。合起来就是AC 02。解码时读0xAC发现 MSB 是 1取低 7 位0x2C 44继续读读0x02发现 MSB 是 0取低 7 位0x02 2Varint 结束。然后按权重拼回来44 2 * 128 44 256 300。注意这里是低块在前、高块在后也就是小端顺序千万别把权重算反。我在初学的时候犯过一个低级错误就是把AC 02算成了172 2 174然后怎么都对不上。后来才意识到这里的字节顺序和十六进制阅读习惯正好相反第一个字节反而是低位部分。建议你用类似uint32_t value byte 0x7F;然后value | (byte 0x7F) 7这种方式写一段小验证脚本把乱序问题彻底钉死。2.3 负数与 sint32ZigZag 的取舍Varint 有一个天然的坑负数。在计算机里负数是用补码表示的-1在 32 位整数里是0xFFFFFFFF如果直接拿这个值做 Varint会被切分成整整 5 个字节int32 补码 32 位全 1而且因为 Protobuf 对 int32/int64 采用符号扩展再编码一个-1甚至会被编码成 10 个字节FF FF FF FF FF FF FF FF FF 01。对于以负数为主的业务场景这简直是灾难级膨胀。解决办法是换用 sint32/sint64 配合 ZigZag 编码。ZigZag 的思路是把有符号整数映射成无符号整数让绝对值小的负数映射成小的正数。规则非常直观0 - 0-1 - 11 - 2-2 - 32 - 4以此类推。公式是zigzag32(n) (n 1) ^ (n 31)对 64 位则右移 63 位。举个例子-1通过 ZigZag 映射后变成1编码成一个字节011映射成2也是01之外的第二个选择-2映射成3。对比一下默认的 int32 编码-1占 10 字节sint32 占 1 字节。这就是为什么我强烈建议凡是业务上可能出现负数的地方直接用 sint32/sint64不要因为默认类型看起来通用就用 int32。默认的 int 类型是给非负整数准备的最优解一旦引入负号它反而是最差的选择。3. 字段标识与 Wire Type 的设计逻辑3.1 tag 的构成字段号为什么从 1 开始理解了 Varint下一个关键是字段标识 TLVType-Length-Value中的 T。Protobuf 的每个字段在线上格式里并不是靠字段名来标识的而是靠一个叫作 tag 的 Varint它同时携带两个信息字段号field number和线类型wire type。tag 的计算公式是tag (field_number 3) | wire_type。也就是说字段号向左移 3 位腾出低 3 位来放 wire type。这也是为什么 proto 文件中字段号必须从 1 开始的根本原因字段号是编码的一部分直接影响 tag 占用的字节数。字段号 1 到 15 的 tag 只用 1 个字节16 到 2047 的 tag 用了 2 个字节因为16 3已经超过 7 位2048 以上则更长。这个设计带来的直接经验是高频字段、必填字段、核心字段尽量占住 1 到 15 的字段号位置低频的、扩展预留的字段放到大号段。很多团队不重视字段号规划随手把新字段加到 16 以后结果每个消息多出 1 到 2 字节在日调用量过亿的服务里这是实打实的带宽浪费。3.2 Wire Type 全表wire type 就是 tag 低 3 位的值它告诉解析器这个字段后面跟的数据到底是哪种形状。官方定义了 6 种其中两种已废弃Wire Type含义典型字段类型0Varintint32/64、uint32/64、sint32/64、bool、enum164-bit定长fixed64、sfixed64、double2Length-delimited带长度前缀string、bytes、嵌入消息、packed repeated3Start group已废弃老版本 group4End group已废弃老版本 group532-bit定长fixed32、sfixed32、float这个表是整个 Protobuf 编码地图的索引。解析器拿到一个 tag 后只关心低 3 位的 wire type决定后面怎么读高位的字段号则用来匹配对应的字段定义。值得注意的是正因为 wire type 是编码固有的一部分即使接收方不认识某个字段号也能根据 wire type 正确跳过对应的字节这就是前后兼容性的底层保障。3.3 一个字节的 tag 怎么读举几个最常见的 tag 例子帮助建立直觉。字段号 1、wire type 0Varint(1 3) | 0 0x08这一个字节就完成了标识。字段号 2、wire type 2Length-delimited(2 3) | 2 0x12。字段号 3、wire type 20x1A。字段号 5、wire type 164-bit(5 3) | 1 0x29。字段号 1、wire type 532-bit(1 3) | 5 0x0D。当你看到一段十六进制数据以08开头时立刻就能反应出第一个字段字段号 1Varint 类型。这种直觉在调试时极其有用。如果 tag 的数值超过 127它就变成了一个多字节 Varint解码方式和前面完全一样只是字段号比较大而已。关于 tag 还有一个容易被忽略的安全细节因为字段号是移位拼出来的解析器在还原字段号时必须处理字段号 0 的情况官方明确规定字段号 0 是非法保留值。同时如果 tag 的 Varint 长度异常大也要在解析层做限制防止恶意构造的超长 Varint 耗尽 CPU。这些在自研解析器时尤其要注意直接用官方库通常不需要操心但了解边界能帮助你在报错时更快定位。4. 手写一段完整的编码逐字节拆解4.1 定义消息并准备数据前面都是局部零件现在把它们组装起来完整地手工编码一条消息。我定义这样一个最简单的模型message Test { int32 id 1; string name 2; repeated int32 nums 3 [packed true]; }要编码的数据是id 300name protobufnums [1, 2, 3]。这三个字段覆盖了三种最主要的 wire typeVarint、Length-delimited、packed 的 Length-delimited。编码后的完整字节流是08 AC 02 12 08 70 72 6F 74 6F 62 75 66 1A 03 01 02 03一共 18 个字节。如果是 JSON这段数据大概长这样{id:300,name:protobuf,nums:[1,2,3]}占用几十个字节。对比一下就明白 Protobuf 省空间的底气从哪来了。4.2 逐字节编码全过程从第一个字节开始拆。08字段号 1wire type 0是 Varint 类型的字段对应 proto 里的id。因为 id 的值是 300按前面 Varint 的规则编码成两字节AC 02。所以前三字节08 AC 02完整表达了id 300。接下来12这是 tag。字段号 2wire type 2Length-delimited对应name字段。紧跟着的08是长度前缀表示后面的数据有 8 个字节。然后 8 个字节分别是protobuf这个字符串的 ASCII 码70 72 6F 74 6F 62 75 66。这里能看到 Length-delimited 的精髓先告诉解码器后面多长再给数据本体这样解码器不需要像 Varint 那样靠 MSB 判断结束位置直接按长度切就行。再往后1A字段号 3wire type 2对应nums。因为nums是repeated int32且标记了packed true所以三个整数不再单独每个都带 tag而是打包成一个连续的数据块。03是长度表示后面 3 个字节。01 02 03就是三个被编码成 Varint 的整数 1、2、3。每个整数恰好占 1 字节所以长度是 3。4.3 长度为前缀的嵌套消息如果字段类型是自定义消息message编码规则和 string 一样都是 Length-delimited先写 tag再写整个子消息的字节长度最后写子消息的完整编码。这个过程是递归的子消息内部依然按 TLV 结构组织。举个例子如果有一个User消息包含一个Address字段那么编码时是先把Address内部所有字段编码成一段连续的字节然后计算这段字节的长度把长度写在前头最后把 tag 写在最外层。这种设计让嵌套消息在二进制层面的读法跟 string 完全一致好处是解析器只需要一套通用的长度前缀逻辑就能处理任意深度的嵌套坏处是如果要修改嵌套内部某个字段必须重建整棵子树。这对理解大消息的性能特性很重要局部修改不等于局部编码Protobuf 序列化通常是对整个对象树重新编码的。嵌套消息还有两个值得注意的实践要点。第一子消息的长度前缀本身是一个 Varint如果子消息很大超过 127 字节这个长度占两个字节这是实时计算出来的很多日志打印时会把长度和 tag 混在一起看容易误判。第二如果子消息为空编出来的就是 tag 加上长度 0 两个字节XX 00如果你在抓包里看到大量这种空壳消息说明业务层有很多空对象传递这是可以优化的点。5. 常用类型的编码细节与性能取舍5.1 定长类型与浮点数Varint 擅长小整数但对种子类的 double、fixed64 来说变长编码反而没有意义因为它们的 8 个字节几乎无法压缩。所以 Protobuf 为它们专门设计了 wire type 164-bit和 wire type 532-bit编码时直接写入固定长度的字节。这里有个反直觉的坑double 类型无论是 1.0 还是 100000.0都固定占 8 字节且按小端字节序直接写入 IEEE 754 的二进制表示float 固定占 4 字节。你可能会想小数 0 是不是能省答案是省不了定长就是定长。所以如果你的数据是大量的小数但数值范围有限可以考虑把 double 换成用整数表示比如毫秒时间戳用 int64这会带来数量级的空间差异。fixed32/fixed64 则适配另一类场景某些字段的值在业务上分布均匀比如哈希值、随机数、ID 的散列结果用 Varint 反而因为高位经常有值而占更多字节定长反而稳定。Google 官方的字段类型选型建议里就说得很清楚大部分场景用 int32/int64负数用 sint32/sint64值分布均匀或需要定长计算时用 fixed32/fixed64。5.2 packed 与 repeated 字段在 proto2 里repeated 数字字段默认是不打包的每个元素都有自己的 tagproto3 里默认是 packed。这两者的编码差异非常明显。以repeated int32 nums 3为例不打包的编码是1A 01 ... 1A 01 ... 1A 01 ...每个元素都带一遍 tag打包的编码是1A 03 01 02 03只带一个 tag、一个总长度然后所有元素连续排列。打包能省多少空间假设有 100 个值都是 1 到 127 的小整数打包版大约 100 2 字节非打包版是 100 * 2 字节。差了一倍。所以在 proto2 里只要不是必须保持元素顺序和边界可随意跳过的场景都建议加上[packed true]。在 proto3 里这是默认行为但要注意旧版本 proto2 服务如果读到 proto3 客户端发的 packed 包兼容性可能会出问题需要通过[packed false]临时规避这属于兼容性治理的范畴。5.3 map、oneof、enum 在二进制层的样子map 在编码层面并没有特殊的 wire type它是语法糖每个mapK,V对应一个 repeated 的 2 字段消息条目字段号固定 1 是 key字段号 2 是 value。比如mapstring, int32 scores编码时每个键值对就是一个嵌入消息key 对应字段号 1、value 对应字段号 2。这意味着 map 无法保证元素顺序而且每次序列化的顺序可能随机这点和 JSON 对象类似。oneof 在二进制层就是普通的字段编码只是保证同一时刻只有一个字段会被写入。enum 本质上是 Varint 编码的整数tag 的 wire type 是 0。这里有个容易踩的坑enum 是int32的别名如果你定义的枚举值是负数它同样会触发 10 字节膨胀所以枚举值尽量不要用负数。字符串和 bytes 类型都是 Length-delimited唯一的区别是 bytes 不要求 UTF-8 合法性string 在部分语言的解析器里会做 UTF-8 校验。跨语言传输时如果一端塞了非法 UTF-8 进 string另一端解析可能直接抛错这个问题的定位往往非常隐蔽因为这个差异只在二进制解析层存在。6. 实操中常见的坑与排查实录6.1 三个真实踩过的坑第一个坑是 int32 负数的 10 字节膨胀。我有一次做统计上报字段用的是 int32 存增量增量经常为负结果发现上报包体比预期大了好几倍。抓包一看一个 -1 刷了一整行FF当时就意识到类型选错了。改成 sint32 之后负增量的包体瞬间降下来整个上报链路的带宽占用减少了大约 40%。这个经历让我养成了一个习惯定义 proto 字段前先问一句这个字段会不会出现负值。第二个坑是 packed 与非 packed 混跑。线上有两个服务一个用 proto2 定义没有[packedtrue]另一个升级到 proto3 后重复字段默认 packed结果老服务解析新服务的数据时出现了字段读取异常。排查到最后才发现是打包方式不一致导致的。后来我们在所有跨版本接口中明确标注打包策略并且用protoc --decode对比验证两端解码结果一致后才放量。第三个坑是嵌套层级过深导致的内存放大。某个网关服务把收到的请求整体塞进一个外层 message请求里又有数组数组元素又嵌套 message最深处有七八层。当时做压测发现内存占用远大于原始数据大小。一查才知道每一层嵌套消息在解析时都会带上整棵子对象树的元数据层级深了之后内存放大系数可以达到 10 倍以上。这个问题后来通过精简消息结构、把深层嵌套改成平铺结构解决。6.2 用 protoc 和 hexdump 定位问题遇到二进制数据看不懂时我的标准流程是先用hexdump -C拿到原始字节再用 protoc 自带的解码工具做对照。protoc --decodeTest test.proto msg.bin protoc --decode_raw msg.bin第一条命令会用你定义的 proto 文件来解析二进制输出带字段名的可读结构适合确认语义。第二条命令不依赖 proto 文件直接把字节流按 wire type 拆成 tag-length-value 的形式输出适合在没有定义文件时快速定性。我通常先跑--decode_raw看整体结构再用--decode对具体字段语义。这个组合几乎能解决所有线上数据到底长什么样的疑问。还有个实用技巧用xxd或者 Wireshark 的 ProtoBuf 解析插件。Wireshark 对 gRPC 流量能自动识别 protobuf 载荷并展开成字段树对排查跨服务调用问题极其方便。不过要注意Wireshark 需要 .proto 文件才能解析出字段名没有定义文件时只能看到 wire type 和原始值。6.3 编码层优化的小经验最后分享几条从编码原理推导出来的优化经验。字段号规划要趁早。字段号 1 到 15 的 tag 是 1 字节16 到 2047 是 2 字节。一个消息如果有 10 个热字段都在 16 以后每个消息就多出 10 字节的 tag 开销。在字段号分配时把高频字段锁死在 15 以内低频扩展字段放后面这是零成本优化。凡是可能出现负数的整型字段直接用 sint32/sint64。默认的 int32 遇到负数会膨胀成 10 字节这是编码格式决定的不是库的性能问题改了字段类型立刻见效。大量的小整数数组务必确认是 packed 编码。proto3 默认已经打包proto2 要手动加[packedtrue]。这个优化对批量 ID 查询、批量上报这类接口影响非常大。字符串长度前缀本身是 Varint如果你的业务里经常出现超过 127 字节的字符串长度前缀会从 1 字节变成 2 字节。对大字符串字段来说这可以忽略但如果一个消息里有几十个大字符串字段积少成多也值得关注。我个人在实际操作中还有一个体会不要只依赖官方库的序列化结果做正确性验证偶尔手工用--decode_raw解一段线上数据会逼着自己把 tag 结构、长度前缀、Varint 边界都过一遍。这个过程本身比读十篇文档都管用遇到跨语言兼容问题时的排查速度会有质的提升。
返回列表