深入理解 VarInt 与二进制协议
做后台开发和网络协议的朋友,迟早会碰到 VarInt 这个东西。第一次在 Protobuf 的源码里看到它,或者在抓包工具里看到一串紧凑的十六进制字节时,很多人会愣一下:为什么一个整数不直接存 4 个字节或 8 个字节,非要搞成变长的形式?这篇文章想把我对 VarInt 的理解和实操经验完整地梳理一遍,从编码原理讲到二进制协议设计的整体思路,再到手写编解码函数,最后聊聊那些文档里不会写的坑。适合正在写自定义网络协议、研究序列化方案、或者纯属好奇协议底层实现的朋友,看完你不仅能彻底搞懂 VarInt,还能对二进制协议的设计逻辑有一个系统性的认知。
二进制协议的核心目标其实就两个字:紧凑。同样的数据,用文本协议传输,比如 JSON,每个数字都要转成 ASCII 字符再包上一堆括号和引号,一个 4 字节的整数可能变成 20 个字节。二进制协议直接操作字节流,用位运算来压榨每一比特的空间,而 VarInt 正是这个理念最典型的体现。
1. 二进制协议设计的核心思考
1.1 为什么需要二进制协议而不是 JSON
聊 VarInt 之前,得先搞明白它所在的舞台——二进制协议。我最早接触这块是做一个 IoT 设备的数据上报服务,设备端是一块内存只有几十 KB 的 MCU,服务器端是 Java 写的网关。最开始图省事,设备直接往服务器 POST JSON,数据长这样:{"temperature":25.6,"humidity":62.3,"battery":3.7}。设备端代码是好写了,但细看就有问题。
首先是体积。一条 30 字节左右的数据,JSON 序列化后膨胀到 50 字节以上。一个设备一天上报 1440 次,一万台设备一天就是 7200 万次请求,多出来的字节数乘一乘,带宽和存储费用都是实打实的成本。其次是解析开销。MCU 上没有现成的 JSON 解析库,自己写一个健壮性很难保证,稍微遇到个转义字符就出毛病。更麻烦的是内存碎片,设备端内存本来就紧张,JSON 需要的动态内存分配很容易触发问题。
改成二进制协议之后,同样的数据用固定结构来定义:温度用 2 字节有符号整数,湿度用 1 字节无符号整数,电量用 2 字节整数,整条报文加上消息头和校验字段,压缩到 10 个字节左右。解析逻辑就是按偏移量读内存,不需要逐字符扫描,MCU 端也能轻松消化。这就是二进制协议的第一个优势:解析效率高、内存占用小。第二个优势是确定性强,字段不存在“缺省值”的歧义,每个字节都有明确的语义。第三个优势是天然支持数据加密和压缩,字节流处理起来比字符串方便得多。
1.2 二进制协议里的变长思想
固定字段结构简单可靠,但有一个明显的浪费:消息头里那个表示“协议类型”的字段,取值范围可能只有 1 到 20,用 1 个字节够了;但消息头里那个“消息序号”,取值范围可能很大,要预留 4 字节。如果每条消息都固定按 4 字节来传消息序号,大部分场景下序号小,前面全是 0,等于每帧数据浪费 2 到 3 个字节。
变长编码的思路就是:根据数值的实际大小来决定存储空间,小数字用 1 字节,大数字用 2 字节,更大的用 5 字节。VarInt(Variable Length Integer,可变长度整数)就是这套思路的标准实现。Google Protobuf、SQLite、Redis 的某些底层格式、比特币协议都在用 VarInt。它的数学原理不复杂:每个字节只用 7 个比特来存数据,最高位作为“是否还有下一个字节”的标识。因为每 7 个比特一组的编码方式基于一个朴素的前提——绝大多数业务场景里的小整数远多于大整数,与其给每个整数固定分配 8 字节,不如让小于 128 的整数只占 1 字节,这才是 VarInt 真正的价值所在。
1.3 VarInt 与固定宽度编码的取舍辩证
举个例子直观说明:假设协议里有字段user_id,理论最大取值是 64 位整数,直接定义成uint64_t的话,每条消息固定占 8 字节。但如果实际场景里 99% 的 user_id 是 100 万以内的数字,用 VarInt 编码只需 3 个字节,剩下 5 个字节全省了。反过来说,如果字段本身就是 40 亿以上、接近 uint64 上限的随机大数,VarInt 反而要占 9 或 10 个字节,比固定 8 字节更费。
所以选型的关键在于理解数值分布。我的经验是:枚举值、布尔值、状态码、时间偏移量这些字段强烈建议用 VarInt;哈希值、随机 ID、文件大小这类接近均匀分布或双方都较大的,直接用固定宽度整数更稳。这跟压缩算法里的“熵”概念相通:编码的期望长度取决于数值的熵,值的分布越集中,VarInt 优势越大。
2. VarInt 编码原理逐层拆解
2.1 从 8 比特到 7 比特:一个标志位的价值
VarInt 最核心的设计决策,是每个字节只拿 7 个比特来存储实际数据,剩余 1 个比特(最高位)用来标记“连续字节”。这个设计初看是浪费的——明明 8 个比特能表示 256 种状态,现在只能表示 128 种。但正是这 1 个标志位,让解码端能够“实时”判断字节流是否结束,不需要额外的长度字段。
以数值 300 为例。300 的二进制是1 0010 1100,一共 9 位有效数据。VarInt 编码时从低到高切分为 7 位一组:第一组是010 1100,第二组是000 0010。第一组的最高位标记成 1,表示“后面还有字节”,该字节实际值为1 010 1100,即0xAC;第二组最高位标记成 0,表示“这是最后一字节”,实际值为0000 0010,即0x02。拼起来就是两个字节:0xAC 0x02。解码时拿到0xAC,一看最高位为 1,知道还得再读一字节;拿到0x02,最高位为 0,收工。整个过程不需要预设长度,边读边判断,这对流式解析非常友好。
2.2 组块与字节序的内部逻辑
编解码顺序上有一点容易绕晕:数值的二进制表示中,低位组先发送。也就是说,VarInt 采用小端字节序。为什么不是高位组先发?因为解码端拿到第一字节后,就能立刻开始处理低位组,底层的 7 比特信息已经可以确定下来了,无需等待后续完整字节。这种设计天然支持流式解析:无法预知总长度时,可以逐字节地读入,前几个字节的有效信息已经可以参与数值的低位部分计算。
打个比方,这就像填表时先填个位数,再填十位数,再填百位数——每填一位,都能立即知道已经形成的数有多大的轮廓。如果改成高位先传,解码端在没凑齐所有字节之前完全无法开始组装数值,必须缓存整串。
2.3 ZigZag 编码:负数如何用 VarInt 表达
VarInt 处理无符号正数很高效,但遇到负数就尴尬了。在常见的补码表示中,-1 的 64 位二进制是0xFFFFFFFFFFFFFFFF,14 个有效位组全部是 0xFF 加上高位的 0x01,VarInt 编码出来将近 10 个字节,比固定 8 字节还大。为了解决这个问题,Protocol Buffers 采用 ZigZag 映射:把有符号整数映射成无符号整数,原则是绝对值越小,映射出来的数越小。
数学公式是:n >= 0时映射为2n;n < 0时映射为-2n - 1(即2n - 1的按位补码后的反向结果)。-1 映射为 1,1 映射为 2,-2 映射为 3,2 映射为 4。这样处理后,负数不再有大量全 1 的比特,绝对值小的负数映射结果也小,VarInt 就能压到 1 到 2 个字节。ZigZag 的本质是“给符号位做一个交通管制”,让正数和负数按照绝对值大小重新排成一条无符号数轴。
3. 手写 VarInt 编解码函数的完整实现
3.1 编码器实现:逐字节压入缓冲区的通用写法
理解了原理,代码实现并不复杂。下面这段是 C 语言的编码函数,逻辑非常直观:
#include <stdint.h> #include <stddef.h> int varint_encode(uint64_t value, uint8_t *buf, size_t buf_size) { if (buf_size < 10) { return -1; // 64位整数VarInt编码最长占用10字节 } size_t i = 0; while (value > 0x7F) { buf[i++] = (uint8_t)((value & 0x7F) | 0x80); value >>= 7; } buf[i++] = (uint8_t)value; return (int)i; }循环条件判断value > 0x7F,意味着当前有效位不超过 14 位才循环一次输出两字节。每个循环里,先取低 7 位,或上0x80表示“继续位”,然后右移 7 位丢掉已经编码的组。循环结束后,剩余值必然小于 128,直接作为最后一字节输出。
有个细节值得留意:为什么最终组不需要或上0x80?因为它是终结字节,最高位必须为 0,否则解码端会认定还有后续字节,导致解析错位。编码器必须严格保证最后一个字节的 MSB 是 0,这是协议正确性的底线。
3.2 解码器实现:逐字节解析并处理长度溢出
解码函数是编码的逆过程,核心是移位与累加:
int varint_decode(const uint8_t *buf, size_t buf_size, uint64_t *out_value) { if (buf_size <= 0) { return -1; } uint64_t result = 0; int shift = 0; for (size_t i = 0; i < buf_size; i++) { uint8_t byte = buf[i]; result |= (uint64_t)(byte & 0x7F) << shift; if ((byte & 0x80) == 0) { *out_value = result; return (int)(i + 1); } shift += 7; if (shift >= 64) { return -2; // 超过64位可容纳范围,说明输入异常 } } return -3; // 数据未完整接收 }这里的边界条件必须认真处理。当i循环到第 10 个字节时,shift已经达到 63,第 10 个字节最多只能放 1 个有效比特。如果此时该字节最高位仍为 1(表示还有后续字节),那就意味着编码的数值超过 64 位,协议异常,必须要报错退出,不能继续拼下去。
解码输出值之前,建议顺便做一次格式校验:如果最后读到的字节是0x80且所有有效位都是 0,说明编码方用了非最短形式来表示 0,即“冗余编码”。有些协议允许冗余,有些协议明令禁止。安全起见,正式实现可以在检测到末尾字节为 0 且前面所有字节的低 7 位都是 0 时,认为是异常数据。这能有效堵住某些恶意输入无限循环的漏洞。
3.3 Protobuf 场景下的实际使用方式
在 Protobuf 里,VarInt 不需要你手动调用编码函数。定义好消息结构后,编译器会为每个字段生成序列化/反序列化代码,内部会自动调用 VarInt 编码。比如:
message SensorData { uint32 temperature = 1; uint32 humidity = 2; uint32 battery = 3; }在生成的 C++/Go 代码中,temperature会被编码为一个 key(字段号左移 3 位后或上 wire type),再加一个 VarInt 编码的值。wire type 为 0 时就表示这个字段是 VarInt 类型。如果你想自己手写一个二进制协议调试工具,或者要模拟一台不会用 Protobuf 库的设备的发包逻辑,就需要拿上面的编解码函数自己拼报文,再把字段 key 按照(field_number << 3) | wire_type拼出来。
4. 二进制协议中的字节序与字段排列策略
4.1 大端与小端的协议一致性坑
协议双方都是 Intel/AMD 处理器的话,通常采用小端字节序,内存里的字节顺序直接拷贝到缓冲区。但如果你要跟 MIPS、ARM 的一些特殊模式,或者跟 Java/Python 写的服务互通,就不能想当然了。Java 的ByteBuffer默认是大端,C 语言里直接memcpy一个int数组到缓冲区再发出去,到了 Java 端读出来的数就对不上。
有一个真实教训。我做某个网关对接时,C 端把一个uint32_t通过网络发给 Java 端,Java 端用readInt()读取,解析出来的值总是错的。排查半天发现 C 端本机是小端,Java 端默认按大端解析,数值的字节序完全反了。解决方法二选一:约定统一用网络字节序(大端),C 端用htonl转换,Java 端原样读;或者约定统一用小端,Java 端用readIntLE()。关键不是选哪个,而是必须在协议文档里写明,并且编解码两侧严格执行同一约定。
4.2 字段编号与 wire type 的排列设计
二进制协议里字段排列不像 JSON 有花括号分层,所有字段都在同一层字节流里顺序排列。为了让接收方能够识别“这个字段是什么”,编码时要在每个字段的值前加一个 tag。在 Protobuf 中,tag 是varint(field_number << 3 | wire_type)。field_number是字段号,从 1 开始;wire_type标识字段类型,0 表示 VarInt/int32/int64/uint32/bool,1 表示 64 位固定值,2 表示长度前缀字节串,5 表示 32 位固定值。
这个设计带来的好处是:同一个消息的不同版本,只要字段号不变,类型兼容,接收方就能跳过未知字段继续解析。这跟 JSON 遇到未知 key 直接忽略是一个道理,但二进制协议里通过 wire type 更严谨。
字段号选型上有个实操建议:1 到 15 用 1 个字节就能编码(tag 本体很小),16 到 2047 需要 2 个字节。所以频繁出现的字段号尽量排在 1 到 15 区间,冷门的大字段排在后面,能有效压缩消息体积。
5. 常见异常与排错技巧实录
5.1 解析错位:一个字节污染导致整条消息报废
二进制协议最让人头疼的问题是“错位”。固定字段结构通常能在解析前校验长度,但 VarInt 是自描述的,没有统一长度前缀。如果数据在传输中丢了一个字节,或者服务端逻辑有 bug 把字节流“推”偏了几个字节,从错位点开始的解析全部乱套。
我之前排查过一个线上异常:客户端上报的数据偶发性解析失败,报“未知字段”。抓包一看,消息中间多了一个额外字节。原因是设备端在某个分支里多写了一个帧标志,导致后面所有字段的 VarInt 编码组首尾相连的位置整体偏了一位。修复办法有二:一是在封包格式里加入固定长度的帧头,包含总长度校验和消息类型校验;二是在解析时对 VarInt 做最短形式校验,发现异常立即拒绝并进入重同步逻辑,而不是继续用错误偏移解析下去。
5.2 误把 VarInt 当定长整数解析
这种问题通常出现在“半懂不懂”的代码里。有人拿到协议文档,看到“字段 A 是 uint32”就直接当作固定 4 字节去读,但文档角落里其实写着“VarInt 编码”。结果第一个小于 128 的字段还能解析对,一旦数值超过 127,字段长度变成 2 字节,后面所有字段全错。
排错的通用思路是:拿到一帧样本数据,把字节流以十六进制打印出来,手工按 VarInt 编解码规则拆一遍,和工具解析结果对比。只要能手工拆一次,就能立刻发现是长度估算错还是位运算错。很多解析问题都是由于这个基本动作没做扎实导致的。
还有一些交互操作里的经典坑:把 VarInt 编码的数值强转成int32,导致正数溢出被解析成负数;或者在 32 位平台上用int存储解码中间结果,移位超过 31 位时发生未定义行为。这类问题定位慢,解决快,但必须在编码实现规范上从一开始就定死数据类型。
5.3 性能分析与优化建议
网上流传的“VarInt 性能差”的说法要辩证看待。逐个字节读的时候,每个字节多一次分支判断,比直接拷贝 4 字节要慢,这是事实。但对于大多数后台服务的吞吐量,单次解析几千条消息,实际上用不了多少 CPU。真正的性能热点通常出现在别处:比如反复分配缓冲区、内存拷贝、序列化后字符串拼接。把这些地方优化好,比纠结 VarInt 的几十个分支更有效。
如果确实要做极致性能优化,正规做法是查表法(lookup table)批量解码,或使用 SIMD 一次处理多个字节。不过动手之前建议先用 profiler 确认热点,别为了炫技引入复杂度。
6. 写在最后的十六进制直觉
接触 VarInt 和二进制协议久了,你会发现最值钱的能力不是背下某个编码规则,而是建立起“十六进制直觉”:看到0xAC 0x02能立刻反应出0x02组的最高位是 0、0xAC组的最高位是 1,从而判断这是一个“双字节 VarInt”;看到0x01知道它是“单字节 VarInt,数值 1”。这种直觉来自反复手动拆包和抓包,没别的捷径。
我个人的体会是,凡是涉及协议类的技术,一定要自己亲手写一遍编解码代码、抓一次包、对着字节流手工分析一次。文档看一遍觉得懂了,代码写一遍才发现里面有无数个边界条件没想清楚。比如有符号字段要不要 ZigZag、字段号是 1 还是 15、解析到一半发现数据截断怎么办、要不要支持跳过未知字段,这些问题没有标准答案,全看你的协议定义和应用场景。
最后再分享一个小技巧:开发新协议时,建议先用一个有打印能力的脚本语言(Python 或者 Node.js)把编解码原型写出来,配合随机生成的大量边界值做自动化测试,代码正确了以后再移植到 C/C++ 或 Java。这样既利用了脚本语言的快速迭代优势,又能在测试阶段就把溢出、错位、冗余编码这类问题全暴露出来。等二进制协议正式上线后,你踩坑的概率会小得多。