ARTICLE DETAIL

资讯详情

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

深入拆解Protobuf编码:Varint、ZigZag与Wire Type

深入拆解Protobuf编码:Varint、ZigZag与Wire Type 1. 从一段hex字节说起同样的150为什么PB只要两个字节有一次排查线上数据异常我抓包后对着十六进制日志发呆08 96 01。三个字节表达的是日志里一个关键字段——请求量150。同样这个数字如果当初我用JSON它在线上长成三个ASCII字符150也是三个字节。表面上看没差但实际上PB这三字节同时携带了字段编号、类型和数值本身而JSON的三个字节只是数值本身字段名还要额外占位置。这就是Protocol Buffers编码最有意思的地方它不是在“压缩”数据而是在设计一种面向机器解析的紧凑二进制格式。JSON花在理解结构上的每一个引号、冒号和逗号在PB里全部被替换成了二进制位级标记。这套规则不是随便定的它背后有一套可以手算、可以推理、也可以用来反推工程决策的完整编码体系。这篇文章我不想讲怎么定义.proto文件、怎么生成代码而是想把这套编码原理从头到尾拆开。适合谁看正在用Protobuf但只停留在“定义message然后调API”层面的同学以及在纠结“字段编号到底怎么排、要不要嵌套、为什么负数会体积爆炸”这些问题的人。你会看到每个关键决策背后的理由包括一些常规文档里不写、但线上真的会踩到的坑。协议的目标很明确消息体积要小、编解码要快、前后兼容要简单。这三个目标在编码层是怎么同时实现的得一点一点看。2. Varint与ZigZag压缩整数的核心算法拆解2.1 7位一组、高位作标记连循环节都省了Protobuf默认的整数编码叫Varint直译就是“变长整数”。思路很简单小数字用少字节大数字才多用字节。普通固定4字节的int32无论存1还是存21亿永远占4字节Varint则让1只占1字节300占2字节50000占3字节。日常业务数据里大部分整数都很小所以收益非常明显。Varint的存储规则是把数字按二进制从低位往高位切每7位一组每组一个字节。每个字节的最高位用作“续位标记”如果这一组后面还有数据最高位设为1否则设为0。解析时看到最高位为1就知道还要继续读下一个字节看到最高位为0这个数字就在这个字节结束。所以一个字节的低7位能表示的最大值是127。128这个数就需要拆成两组低7位是0余下的高位是1于是第一个字节填0x80表示“还有后续”第二个字节填0x01最终是0x80 0x01两个字节。注意这里有个反直觉的点字节顺序是低位在前。Protobuf的Varint是Little-Endian风格低7位先发出高位后续再补。2.2 手算300从二进制到0xAC 0x02我们拿300来完整走一遍。300的二进制是100101100共9位。第一步切分9位除以7得到低7位0101100和高位10。 第二步低7位0101100等于十进制的44因为还有后续最高位置144加128等于172也就是0xAC。 第三步高位10等于十进制的2因为已经是最后一组最高位为0就是0x02。于是300编码成两个字节0xAC 0x02。一个4字节的固定整数被压成了2字节。如果这是字段1的值那这条消息在线上就是08 AC 0208是字段标记AC 02是值。反过来解码也很直接。读第一个字节0xAC去掉最高位得到0101100最高位是1继续读0x02去掉最高位得到10把后读到的组左移7位再拼上先读到的组10 7 | 0101100等于256 44恢复出300。提示Varint最多10字节。uint64的最大值18446744073709551615正好需要10个组来装。如果你看到一段hex里连续10个字节的最高位都是1那是错误数据正常Varint在最后一个字节必须为0开头。2.3 ZigZag负数问题的解法到这里有个明显的问题负数怎么办。以int32的-1为例它在内存里的补码表示是0xFFFFFFFF全部32位都是1。直接按Varint切会切成5组产生5字节而且作为int64类型序列化时符号扩展成64位直接暴涨到10字节。每个负数都占10字节这个开销在存储和传输上完全不可接受。Protobuf的解法是ZigZag编码把有符号整数映射成无符号整数负数映射成奇数正数映射成偶数。这样-1变成11变成2-2变成32变成4。一句话总结就是0映射0负数从小到大映射到1、3、5、7正数从大到小映射到2、4、6、8。编码公式是(n 1) ^ (n 31)n 31是算术右移-1右移31位还是-10右移31位还是0所以整个公式其实只是在做位运算速度极快。映射关系可以列一小段原始值ZigZag结果Varint字节数001-111121-2311272542-1282552这解释了为什么.proto里区分了int32和sint32。如果你确定字段可能为负就用sint32/sint64生成的代码会自动套ZigZag反过来普通int32遇到负数就按10字节编码。很多人没意识到这个区别结果线上负数一多消息体积直接翻几倍。3. Field Key与Wire Type一条消息如何被无歧义地解析3.1 一个字节同时携带字段号和类型Protobuf每条消息本质上不是“字段名值”的KV结构而是一串“字段标记值”的流。每个字段出现时第一个字节或前几个字节叫Key它其实是(字段编号 3) | 线类型一次编码就同时告诉解析器“这是几号字段”和“值采用什么格式”。这就是为什么一个只有int32 value 1的message只要设置value为150发出去就是08 96 01三个字节。08是Key字段号1左移3位得8线类型0表示Varint。后面的96 01就是上一节算过的Varint值150。如果你熟读了Key的构成看到任何一段hex都能立刻拆出字段号和值类型这种“人肉解码”能力在排查线上问题时很救命。字段号不能无限大。字段编号左移3位意味着编号最多占用29位二进制最大值是536870911。这也是为什么Proto文件里写字段号超过这个数编译器直接报错——不是不想支持你是编码格式在数学上就不允许。3.2 Wire Type全景与解析器的“跳过”策略线类型共定义了6种有效值对应不同的数据排布。这里先给全貌Wire Type编码值含义适用类型Varint0变长整数int32、int64、uint32、uint64、sint32、sint64、bool、enum64-bit1固定8字节fixed64、sfixed64、doubleLength-delimited2长度前缀string、bytes、embedded message、packed repeatedSGroup已废弃3group开始旧版groupEGroup已废弃4group结束旧版group32-bit5固定4字节fixed32、sfixed32、float最值得展开的是解析器遇到“不认识的字段”时的行为。假设老版本程序收到一个它没有定义过的新字段它不知道字段号对应什么业务含义但它会读Key里的Wire Type然后按类型跳过对应字节Varint就循环读到最高位为064-bit就跳8字节Length-delimited先读长度再按长度跳过去。跳完之后继续解析后面的字段整个消息一点不丢、不错。这个机制就是Protobuf向后兼容的基石。你在新版本里加字段老版本程序虽然看不见它但能安全跳过它。我见过很多第一次接触这个协议的人惊讶于“字段居然可以不认识还能跳过”这恰恰是二进制协议比JSON高明的地方JSON解析器碰到未知字段只能全部塞进一个通用对象里或者直接报错而PB在解码层就内置了容错。3.3 为什么group类型被废弃Wire Type 3和4是当年Proto2遗留的group语法它把一组字段用开始标记和结束标记包起来相当于没有长度前缀的嵌套消息。听起来只多两个标记但它有个致命问题无法跳过。如果解析器不认识某个group它不知道该跳到哪里结束只能靠结束标记去匹配一旦数据缺了结束标记或者嵌套层级乱了整个解析就崩了。Length-delimited则不一样先读一个长度就能安全跳过任意一段哪怕这段数据内部结构完全不认识。Protobuf在发展过程中保留Type 3和4只是为了兼容老数据新代码一律别碰group。4. Length-Delimited与嵌套消息复杂数据在底层怎么排布4.1 字符串和bytes的taglenpayload字符串、bytes、嵌套消息都走Wire Type 2Length-delimited格式统一为Key字节 长度Varint 原始payload。比如字段2是string name值为hello, world这12个字符它在线上的完整样子是12 0C 68 65 6C 6C 6F 2C 20 77 6F 72 6C 6412是Key字段号2左移3位线类型20C是长度12后面的12个字节就是ASCII内容。字段名、引号、冒号这些JSON里必须携带的结构信息在PB里全部被省略了。同样的数据用JSON表达紧凑格式也要22字节左右PB只要14字节。字段名越长PB的优势越大。如果你每个字段名动辄十几二十字符一万条消息就能省出两三百KB的纯字段名开销。4.2 嵌套消息的额外成本拿例子数一下嵌套消息在底层就是“外层字段的值用Length-delimited包一层完整的内层消息”。看个具体例子message Inner { int32 x 1; } message Outer { Inner inner 1; }当x等于300时内层消息编码为08 AC 023字节。外层套上字段1、Wire Type 2的标签后变成0A 03 08 AC 02也就是说一个本来3字节能表达的数据包一层嵌套就变成了5字节多出的两个字节是外层Key和长度字段。如果内层消息payload比较大长度字段本身也会膨胀。所以嵌套不是免费的每包一层就至少多两个字节的结构开销。这带来一个工程上的直觉如果某个字段只是“为了逻辑上归个类”而包了一层底层传输代价是实打实多出来的。反过来如果嵌套能显著减少重复字段的传输那这笔开销又很划算。具体怎么取舍等到后面“工程优化”部分再说。4.3 repeated与packed从6字节到5字节的差距repeated字段有两种底层排布。Proto2默认是“每个元素独立带标签”[1, 2, 3]三个值编码出来是08 01 08 02 08 03每个元素都带一遍08这个Key一共6字节。Proto3对基本类型默认启用packed编码成0A 03 01 02 03一个Key一个总长度三个元素连续排列总共5字节。元素只有3个时差距还不大元素是1000个时非packed会在每个元素上重复花费1字节的Keypacked只需要首尾加2字节。这就是packed对大数据量的意义。但要注意packed只对数值类型和enum有效字符串、bytes、嵌套消息不能packed因为它们本身就是Length-delimited结构没法简单合并。还有一个经典的兼容性坑如果你的消息要从老版本解析库读取而老库不支持packed可能直接解析失败。实际项目中如果确实要兼容很老的Proto2代码可以在.proto里对这个字段显式声明[packed false]代价就是存储和带宽换兼容稳定。我的经验是多数情况下升级解析库比重开非packed更划算但如果你无法控制客户端版本请务必做兼容测试。5. 边界条件与经典坑位负数、packed兼容性和字段号越界5.1 负数编码会膨胀到10字节别只用sint32这是我在生产环境见过最多的“莫名体积暴涨”原因。int32类型的字段如果你赋了一个负数编码时不会做ZigZag变换而是按普通Varint处理。因为负数补码全是1符号扩展后序列化出来的Varint要10字节。一个负数字段比一个最大值达2^31-1的正数字段还要多5字节。数据量小看不出来一旦你有大量带负数的指标数据比如温度、差价、偏移量消息体积能比预期大3倍以上。解决办法就是前面ZigZag那节提到的字段类型用sint32或sint64它会先把负数映射成正数再Varint同样是-1编码后只有1字节。枚举类型同理如果枚举值里有负数底层也按int处理一样膨胀。写.proto的时候请养成习惯业务字段可能为负就用sint。5.2 老解析库遇到packed字段时的表现packed还有一个不太常被讨论的兼容性问题。假设你有一个repeated int32字段proto3默认packed发送但接收端是一个非常老版本的解析库它只认识Proto2的非packed格式。当它读到packed的0A 03 01 02 03时由于它对这个字段的预期是Wire Type 0Varint而实际读到的是Wire Type 2它可能把整段packed数据当成一个奇怪的嵌套结构丢弃或解析错。这个问题在纯proto3环境中不会遇到但混合环境、跨语言老版本SDK、自定义解析器存在的系统里值得留意。线上如果出现“同样的.proto定义新客户端发数据老客户端读不全”优先怀疑packed问题。定位方法也很简单把消息dump成hex看看repeated字段的Key是不是0A开头而不是08。5.3 字段号不是越大越好1~15是稀缺资源很多人在设计message时字段号随便排甚至从100开始编。从功能上没问题但从编码效率上看这是实打实的浪费。前面说过Key是字段编号 3 | 线类型。字段号1到15的Key无论Wire Type是0、1、2还是5都只占1字节字段号16的Key直接变成2字节。uint32最大值的varint要5字节所以线上传输时对大部分整数而言真正拉开差距的就是字段号是15还是16之间的那1字节。1字节看起来微不足道但如果你在聊天系统里每秒转10万条消息每条消息里高频字段就多1字节就是100KB/s的额外带宽。这不是制造焦虑是想说明一个设计原则把最常出现、最核心的字段放在1~15号冷门字段和新扩展字段往后排。有人可能会问字段号用完了怎么办字段号是全局的加新字段选更大编号即可不需要重排旧字段因为编码依赖的是字段号本身不是顺序。1~15被占满后就接受16号以后字段的Key多1字节这个事实这通常是可接受的而如果把字段号用成三位数每个字段都白白多掏字节那才是设计失误。6. 从编码原理反推工程优化实际项目里的落地建议6.1 高频字段放前面、低频字段靠后和赋值顺序无关基于Key长度规律最直接的优化就是字段编号布局。我的做法是先把业务上最高频的字段比如ID、时间戳、状态码、用户标识编到1~15再排中等频率字段最后新增的、很少用的字段随机分配大号。注意一点Protobuf序列化时字段在wire上的顺序通常按字段号升序而不是你的赋值顺序所以布局设计要在.proto阶段就做好写代码阶段已经晚了。还有一种极端场景值得说明如果你有大量按固定频率采样的数据要存储字段号从16开始意味着每条记录多1字节Key。假设每天新增100万条记录一年下来就是3.65亿字节。字段号重排一次收益比任何应用层压缩都稳定。6.2 减少不必要的嵌套和重复嵌套消息每层至少多2字节结构开销。如果一个嵌套结构里有多个字段经常不同时出现可以考虑拆成两个平级message如果只是为了“可读性”而嵌套不要看不上的那2字节积少成多很可观。另外要提一个proto3特有的默认值省略机制一个普通int32字段如果值是0默认情况下不会写入wire流所以缺这个字段的体积就是0。但如果你用optional或oneof声明字段它带了“存在性标记”即使值等于默认值也会被序列化。有些团队在proto3里大量用optional追求可空语义结果消息体积悄悄变大却没察觉。我现在都建议能用普通字段表达就不要加optional除非你真的需要区分“没设置”和“设置成默认值”。6.3 调试技巧用hexdump和protoc解密一段真实消息排查Protobuf问题最直接的方式是把线上数据dump成hex然后手工或借助工具解析。我的通用流程是用xxd把二进制文件转成hex格式xxd message.bin如果只是快速查看wire内容不想写代码用protoc自带的decode_rawprotoc --decode_raw message.bin它会无视.proto定义纯粹按wire type把每个字段的输出打印出来。这个工具最大的价值在于即使你手上没有对应的.proto文件也能看出一个key是Varint还是Length-delimited长度多少内容大致什么样子。曾经有一次线上字段错位我用它几秒钟就定位到是字段号14的string被错误填了二进制内容少写了不知道多少排查时间。decode_raw属于那种“不常用但一到用就救命”的工具建议收藏这个命令。6.4 要不要在PB之上再套一层压缩很多人看到PB编码后还会问再套一层gzip是不是更省。答案要看场景。PB是结构化编码它去除的是字段名的冗余但它保留了很多可预测的模式时间戳可能大量重复枚举值可能集中在少数几个值字符串可能有公共前缀。这类数据再压一层gzip确实还能显著缩小但代价是CPU开销和编解码延迟。我的经验是RPC调用链路里一般不二次压缩因为延迟敏感而且内网带宽通常不是瓶颈日志落盘、离线批量存储场景适合加压缩因为存储成本和带宽成本更敏感CPU余量也大。如果你在两者之间犹豫先做线上数据采样压缩前后体积对比再决定不迟。6.5 从编码原理看“向后兼容”是怎么成立的理解了wire type的跳过机制你会发现Protobuf的兼容性并不是靠某种魔法。新增一个字段老版本通过wire type跳过它这是二进制层面保证的删除一个字段只要字段号不被重新用于其他类型老数据被新代码读到时会作为未知字段跳过改变一个字段的类型只在wire type不变的前提下才安全比如uint32改成uint64是安全的但uint32改成string会直接破坏解析。这个“只看wire type不看业务含义”的设计也是我们在发布新协议时要敬畏的地方。你在.proto里把一个字段的编号从5改成6本质上是删掉5号字段再新增6号字段线上老数据只要包含5号字段就会被当成未知数据跳过看起来“没报错”但数据已经丢了。所以字段号的稳定性是协议契约的一部分不是能随便动的实现细节。我见过不止一次因为“顺手整理字段号”导致线上数据静默丢失的事件都和“只看到接口层兼容没看到wire层不兼容”有关。回到开头那三个字节08 96 01。现在再看它你应该能读出三层含义这是一个字段号1、Varint类型、值为150的字段总长3字节。你也可以快速算一下相同内容如果走JSON算上字段名、引号、冒号通常是PB的2到4倍。这还只是单个字段的数量级差异当消息有几十个字段、上百万次传输时PB这套编码的收益会变得更加明显。最后分享一个我长期使用的小习惯每定义一个复杂message后我都会用decode_raw把生成的二进制dump一遍肉眼扫一下key和长度的分布。这个习惯帮我发现过不少问题——类型选错、字段号排布不合理、负数膨胀都能在开发阶段一眼看出来而不是等上线后在监控告警里追悔莫及。
返回列表