深入理解VarInt:二进制协议编码原理与手写实现

发布时间:2026/10/8 10:18:48
深入理解VarInt:二进制协议编码原理与手写实现 深入理解 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 字节。VarIntVariable 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 位二进制是0xFFFFFFFFFFFFFFFF14 个有效位组全部是 0xFF 加上高位的 0x01VarInt 编码出来将近 10 个字节比固定 8 字节还大。为了解决这个问题Protocol Buffers 采用 ZigZag 映射把有符号整数映射成无符号整数原则是绝对值越小映射出来的数越小。数学公式是n 0时映射为2nn 0时映射为-2n - 1即2n - 1的按位补码后的反向结果。-1 映射为 11 映射为 2-2 映射为 32 映射为 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/bool1 表示 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。这样既利用了脚本语言的快速迭代优势又能在测试阶段就把溢出、错位、冗余编码这类问题全暴露出来。等二进制协议正式上线后你踩坑的概率会小得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询