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,因为还有后续,最高位置1,44加128等于172,也就是0xAC。 第三步,高位10等于十进制的2,因为已经是最后一组,最高位为0,就是0x02。
于是300编码成两个字节:0xAC 0x02。一个4字节的固定整数被压成了2字节。如果这是字段1的值,那这条消息在线上就是08 AC 02:08是字段标记,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变成1,1变成2,-2变成3,2变成4。一句话总结就是:0映射0,负数从小到大映射到1、3、5、7,正数从大到小映射到2、4、6、8。编码公式是(n << 1) ^ (n >> 31),n >> 31是算术右移,-1右移31位还是-1,0右移31位还是0,所以整个公式其实只是在做位运算,速度极快。
映射关系可以列一小段:
| 原始值 | ZigZag结果 | Varint字节数 |
|---|---|---|
| 0 | 0 | 1 |
| -1 | 1 | 1 |
| 1 | 2 | 1 |
| -2 | 3 | 1 |
| 127 | 254 | 2 |
| -128 | 255 | 2 |
这解释了为什么.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 | 编码值 | 含义 | 适用类型 |
|---|---|---|---|
| Varint | 0 | 变长整数 | int32、int64、uint32、uint64、sint32、sint64、bool、enum |
| 64-bit | 1 | 固定8字节 | fixed64、sfixed64、double |
| Length-delimited | 2 | 长度前缀 | string、bytes、embedded message、packed repeated |
| SGroup(已废弃) | 3 | group开始 | 旧版group |
| EGroup(已废弃) | 4 | group结束 | 旧版group |
| 32-bit | 5 | 固定4字节 | fixed32、sfixed32、float |
最值得展开的是解析器遇到“不认识的字段”时的行为。假设老版本程序收到一个它没有定义过的新字段,它不知道字段号对应什么业务含义,但它会读Key里的Wire Type,然后按类型跳过对应字节:Varint就循环读到最高位为0;64-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的tag+len+payload
字符串、bytes、嵌套消息都走Wire Type 2(Length-delimited),格式统一为:Key字节 + 长度Varint + 原始payload。比如字段2是string name,值为hello, world这12个字符,它在线上的完整样子是:
12 0C 68 65 6C 6C 6F 2C 20 77 6F 72 6C 64
12是Key(字段号2左移3位,线类型2),0C是长度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 02(3字节)。外层套上字段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字节的Key,packed只需要首尾加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 0(Varint),而实际读到的是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_raw:
protoc --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和长度的分布。这个习惯帮我发现过不少问题——类型选错、字段号排布不合理、负数膨胀,都能在开发阶段一眼看出来,而不是等上线后在监控告警里追悔莫及。