3.2 varint与ZigZag:整数压缩的原形


3.2 varint 与 ZigZag:整数压缩的原形

本节摘要:varint 用"每字节 7 位有效 + 1 位 continuation"的切分规则,把任意 64 位整数装进 1 到 10 个字节,值越小越省;ZigZag 用一次符号映射解决负数的 10 字节病态。本节给出两个算法的手算全过程、第 1 章悬案的最终解答,以及"为什么 300 编码成 AC 02"这类面试级问题的标准解法。读完你应当能手工编解码 4 字节以内的任何 varint。

承接 3.1 的骨架:tag 之后最常见的就是 varint 载荷。这一节把第 2 章类型选型的所有结论——sint 的存在理由、负数的代价、fixed 的反超条件——全部落到字节级机理上。

varint:七位一组的切分术

varint 的规则一句话可以说完:把整数的二进制位从低位起每 7 位切成一组,每组前面加一个续位标记,装进一串字节里。每个字节的最高位(第 8 位)是 continuation bit——1 表示后面还有字节,0 表示这是最后一组。

手算编码 150。150 的二进制是 10010110,只有 8 位,切成两组:低 7 位 0010110,高 1 位 0000001。低组装进第一个字节并置续位为 1:10010110,即十六进制 96;高组装进第二个字节、续位为 0:00000001,即 01。结果就是第 3.1 节 dump 里见过的 96 01

反向解码同样机械。读 AC 02(第 1 章导读悬案里的字节对):第一个字节 AC 二进制 10101100,续位是 1,有效 7 位是 0101100;第二个字节 02 续位是 0,有效位 0000010。按"低组在后"的顺序拼接:高位组 0000010 接上低位组 0101100,得 0000010 0101100,即二进制 100101100,十进制 300。所以导读里那段消息的末尾字段值是 300——没有任何工具,两个字节手算出来。

图 3-2 varint 编码的切分与续位机制

图 3-2 varint 编码的切分与续位机制

负数的十字节病态

varint 对小正数极度友好(值小于 128 恒 1 字节),对负数却病态地浪费。机理:int32 的 -1 在补码表示下是 32 个 1。序列化时它先被符号扩展成 64 位的全 1 模式,varint 按 7 位切组,64 个 1 切满 10 组——-1 编码成 FF FF FF FF FF FF FF FF FF 01,整整 10 个字节。而 -2、-100、-1000000 只要还在负数区间,统统都是满 10 字节:补码下负数的低位有大量的 1,切不出"高位全 0 可以省掉"的余量。

这就是 2.2 节"负数密集字段选 sint"的完整病理与药方出处。

ZigZag:一次映射救回九个字节

sint32/sint64 的载荷在 varint 之前先过一道 ZigZag 变换,把有符号数交错映射到非负数:

原始值: 0 -1 1 -2 2 -3 3 -4 4 ... 映射值: 0 1 2 3 4 5 6 7 8 ...

映射公式(用中文写就是"非负数翻倍,负数翻倍取反减一"):值 n 映射为 n 乘 2(n 为非负时)或 n 乘 2 的相反数减 1(n 为负时)。-1 映射成 1、-2 映射成 3、-3 映射成 5——幅值越小的负数,映射后越小,varint 越短。回到 -1:映射后是 1,varint 编码 01,一个字节。十字节对一个字节,这就是 ZigZag 全部的意义。

一个手算组合练习:编码 sint32 的 -3。先 ZigZag:-3 映射为 3 乘 2 加 1 等于 5(负数映射为绝对值乘 2 减 1)。再 varint:5 的二进制 101,单字节即可,续位 0,得 05。sint32 字段值为 -3 的载荷就是单字节 05。反方向:读到 sint32 载荷 05,先 varint 解出 5,ZigZag 逆映射:5 是奇数,对应负数 -(5+1)/2 = -3。

💡 一个帮助记忆的直觉:ZigZag 这个名字来自映射数轴的形状——0、-1、1、-2、2 在映射后的数轴上来回摆动,像一道闪电。设计者把"离 0 近"这个性质完整搬运到了非负半轴上,而 varint 恰好只偏爱离 0 近的数。两个算法咬合得严丝合缝。

定长整数:什么时候反超

varint 并非永远更小。值域恒大的字段(比如无符号 ID 高段、时间戳毫秒值),varint 需要 5 字节以上时,fixed32 恒 4 字节、fixed64 恒 8 字节反而占优,而且定长的解码免去了逐字节的循环与拼接,CPU 路径更短。判断式很简单:uint32 值超过 2 的 28 次方(约 2.7 亿)时 varint 用满 5 字节,与 fixed32 打平;再大就是 fixed 赢。第 2 章坐标字段选 fixed32 的案例,就是这条判断式的应用。

实战案例:第 1 章悬案的最终解答

背景:回看第 1 章的悬案 dump——测试环境与灰度环境的差异是 08 E9 07 变成了 08 EA 07。契约已知字段 1 是一个毫秒时间戳(int64)。操作:手算两个字段值。先解 E9 07:E9 二进制 11101001,续位 1,有效位 1101001;07 续位 0,有效位 0000111。拼接 0000111 1101001 得二进制 111101001,十进制 489。再解 EA 07:EA 有效位 1101010,拼接得 111101010,十进制 490。结果:两环境的字段值分别是 489 与 490——不是字段消失,是数值本身变了。489 到 490 恰好跨越了 varint 第二字节的进位边界(511 到 512 才是真正的双字节进位,但这里第二字节从 07 变 08 意味着跨过了 512 的整数倍边界附近),字段本身从未丢失。解读:最初"字段缺失"的告警来自下游一个手写的解析器——它对多字节 varint 的续位处理有 bug,只在特定字节组合(本例中第二字节的特定取值)下漏读字段。用对了 varint 语义的两端从来没错,错的是那个绕过标准库手搓解析的人。变式:这类"特定值触发"的解析器 bug 极其隐蔽——测试用例的数值永远在边界的一侧,灰度流量的真实数值跨过了另一侧。给手写解析器补测试时,要在每个 7 位边界(127/128、16383/16384、2 的 21 次幂前后)两侧各埋一个用例。

本节要点回顾

  • varint 规则:低 7 位一组切字节,最高位做续位标记,1 是还有、0 是结束;
  • 手算三步:二进制展开、七位分组、加续位拼字节——解码反着走;
  • 负数病态:int 系负数符号扩展后满 10 字节,值大小不影响这个下限;
  • ZigZag:有符号交错映射到非负,-1 从 10 字节到 1 字节,与 varint 咬合;
  • varint 不是永远赢:值过 2.7 亿后 fixed32 反超,定长还省 CPU。

整数载荷解决后,还剩最大的一族器物:wire type 2 的"长度前缀 + 字节串"。字符串、嵌套消息、repeated、map 全在里面,下一节看它们如何在字节层同构。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U