news 2026/10/14 4:19:22

VarInt变长整数编码原理与二进制协议实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VarInt变长整数编码原理与二进制协议实战解析

最近在写一个二进制协议的解析模块,刚开始我沿用之前做业务接口的习惯,遇到整数就按固定 4 字节小端来读。后来把抓到的线上数据导出来一看,绝大部分字段的值都在 0 到 100 之间,一条消息里 id、seq、type、status 全是这种小数字,每个字段却固定占 4 字节,结果整条消息一半以上的字节都是 0x00。那一刻我才认真去翻 VarInt 和二进制协议里的位级编码方式,越看越觉得这里面的门道比想象中多得多。

VarInt 的全称是 Variable-Length Integer,中文叫变长整数,用 1 到多个字节表示一个整数。它几乎是现代紧凑型二进制协议的默认配置,因为它能在不牺牲解析速度的前提下,显著缩小小整数占用的体积。这篇笔记我从“为什么要用 VarInt”讲起,然后是编码解码原理、手写实现时容易踩的坑、如何把它放进真实消息协议,最后做了几种变长整数编码方案的横向对比。适合正在写协议解析器、网络消息帧、存储格式,或者想弄懂序列化底层逻辑的开发者看,也适合只听过名字但一直没搞懂的人作为入门参考。

1. 为什么二进制协议需要 VarInt:定长整数的一个隐藏痛点

1.1 先看一个最直接的浪费

在带宽不敏感的业务接口里,定长整数的问题可能不明显,但在物联网设备上报、移动网络长连接、后端服务之间高频小消息传输这些场景里,这就是实打实的成本。假设一个字段是 int32,固定占 4 字节,你只想传一个数字 1,那 4 个字节就是 0x01 00 00 00,高 3 个字节全是填充零。消息里这样的字段一多,实际有效数据占比可能不到三分之一。

为什么定长编码会这样浪费?因为设计定长字段时,你必须按“最大可能值”预留空间。你没法预知某个 id 以后会不会超过 2^31,于是干脆每个 int32 都预留 4 字节。就算它只有 1,也得跟着付 4 字节的运费。更麻烦的是,如果某个字段后来需要升级成 64 位整数,原来的 4 字节布局又不够用,要么破坏兼容性,要么再定义一个全新字段。这就是固定长度在真实协议设计里的隐藏痛点。

1.2 定长编码的优势确实存在,但代价不小

我不想全盘否定定长编码。定长 4 字节或 8 字节最大的优点是“可随机访问”。接收方拿到消息后,能直接按偏移量取字段,不需要从第一个字节开始逐字节解码;在没有对齐要求的平台上,也可以直接拷贝 4 字节。很多文件格式、记录型数据结构坚持用定长,就是因为这个可索引特性。

但消息型协议往往不需要这种随机访问。消息拿到手本来就要整体解码,大家更关心传输体积。当一个整数可能从 1 变到 2^31-1,定长 4 字节满足了上限,却对绝大多数小数值造成了冗余。VarInt 的思路正好相反:根据数值实际大小决定占几个字节。数字小就用 1 字节,中等就用 2 字节,只有真正的大数字才会多占。本质上是拿一部分解析复杂度,换传输效率。

1.3 VarInt 也不是银弹

VarInt 的优势建立在“数值分布偏向小值”这个前提上。如果协议里的整数全是均匀分布的大数字,比如哈希值、随机种子、密钥这类,VarInt 反而会放大体积。以 32 位无符号整数为例,定长是卡死 4 字节,VarInt 则从 1 字节到 5 字节浮动,超过 2^28 之后定长更省。下面这张表能直观看出差别:

数值范围VarInt 字节数定长 uint32 字节数
0 – 12714
128 – 1638324
16384 – 209715134
2097152 – 26843545544
268435456 – 2^32-154

所以协议设计者得先看自己的数据分布,再决定要不要用 VarInt。绝大多数 RPC 调用、消息队列、设备上报里的字段号、枚举值、长度、计数都是小数字,所以 VarInt 才会到处出现。这个取舍逻辑,比单纯记住编码公式更重要。

2. VarInt 编码的底层原理:从每字节7个数据位到连续最高位

2.1 基本结构:7 个数据位加 1 个续读标记

VarInt 解决的核心问题只有一个:让整数自己在字节流里声明“我占几个字节”。具体做法是:每个字节只使用低 7 位存放真实数据,最高位当作标志位。最高位是 0,表示这个整数到此结束;最高位是 1,表示下一个字节还是这个整数的一部分。

很多初学者不理解为什么非要牺牲最高位不用。原因很简单:字节流是连续的,没有任何天然分隔符告诉你整数在哪里结束。如果你把 8 位全用来存数据,那读 3 个字节到底是一个 24 位整数,还是三个 8 位整数?协议根本没法判断。VarInt 用最高位做“继续读/结束”的标记,相当于给每个整数动态画了一条边界线。代价是每个字节少存 1 位数据,却能换来任意长度的整数表示。

数据的排列顺序按“小端分组”存放:先出现的字节保存整数的最低 7 位,后面的字节依次保存更高位。可以理解为把整数拆成若干个“7 位数字块”,然后从低位块开始往字节流里写,每写完一个块就看一下还有没有剩余,有就在当前字节的最高位写 1,没有就写 0。

2.2 手动编码一次,胜过背十遍公式

手工编码数字 300。300 的二进制是 100101100。从低位开始,每 7 位切一组:低 7 位是 0101100,也就是十进制的 44(0x2C);剩余部分是 0000010,也就是十进制的 2(0x02)。按小端分组顺序,先写低字节 0x2C。因为后面还有剩余部分,所以在这个字节的最高位加上 1,变成 0xAC。接着写高字节 0x02,因为已经没有剩余,最高位保持 0。所以 300 的 VarInt 编码结果是两个字节:AC 02。

再验证一个容易混淆的:128。128 的二进制是 10000000,拆成低 7 位是 0000000,高 7 位是 0000001。低字节最高位置 1 后是 0x80,高字节是 0x01,所以 128 编码为 80 01。这里有个很容易踩的坑:0x80 这个字节并不是数字 0x80,而是“数值 0,但还有后续字节”的意思。如果解析代码只读一个字节就结束,就会得到 0 而不是 128。

2.3 编码与解码代码实现

这段 Python 代码非常适合用来理解逻辑。编码时循环取低 7 位,判断剩余,置最高位;解码时反向循环,每读一个字节取低 7 位,放到结果的对应位置,直到遇到最高位为 0 的字节。

def encode_varint(n): if n < 0: raise ValueError("varint requires non-negative integer") out = bytearray() while True: b = n & 0x7F n >>= 7 if n != 0: b |= 0x80 out.append(b) if n == 0: break return bytes(out) def decode_varint(data): result = 0 shift = 0 for i, b in enumerate(data): result |= (b & 0x7F) << shift if (b & 0x80) == 0: return result, i + 1 shift += 7 raise ValueError("truncated varint")

decode 返回两个值,一个是解出来的整数,一个是总共消费了多少字节。这个“消费字节数”在实际网络解析里非常重要,后面会专门讲。编码时要注意必须先判断负数。VarInt 本身只针对非负整数,负数需要先做映射,直接对它做位运算会陷入死循环。

2.4 字节序的误解是新手重灾区

我第一次拿到 AC 02 时,下意识按大端去读,以为它表示 0xAC02,也就是 44034,结果完全对不上。原因就是我还没理解 VarInt 的“小端分组”顺序:AC 是低 7 位组加续位标记,02 是高 7 位组,真正数值是 (0x02 << 7) | 0x2C = 300。这个顺序和普通小端整数在直觉上是一致的:低字节在前,低位组也在前。只要别把一个编码后的字节直接连起来当普通整数看,就不会乱。

另一个容易误解的点是:解码时每读到一个字节,低 7 位要放到最终结果的不同偏移上。第一个字节放在 bit0 到 bit6,第二个字节放在 bit7 到 bit13,以此类推。这跟把整个字节流拼成一个大整数再移位是完全不同的思路。把握住“一次只处理 7 位,shift 每次加 7”这个核心,任何语言都能写对。

3. 手写实现与踩坑记录:从移位、缓冲区到溢出检查

3.1 编码实现要注意符号和循环顺序

在真实项目里一般不会用 Python 写高频解析,但思路是一样的。用 C/C++ 或 Rust 实现编码时,第一坑是变量类型。如果对 signed int 做右移,C/C++ 是算术右移,负数会在高位补 1,导致 n >>= 7 永远不变成 0,循环死转。解决办法是统一用 uint32_t / uint64_t,或者先把负数转成无符号。第二坑是输出顺序。先取出低 7 位,判断是否还有剩余,再写当前字节;不要先把最高位处置好再取位,那样会污染数据位。

第三坑是缓冲区大小。无符号 64 位整数最多需要 10 个字节,因为 64 除以 7 向上取整是 10。编码前直接准备一个 uint8_t buf[10],用指针移动写,最后拿指针差算长度,比每次 append 一个字节要高效得多。如果按 128 进制理解,其实 VarInt 就是把数字改成 128 进制后逐位输出,只不过每个“位”被装进了一个字节的低 7 位。

3.2 解码必须处理截断和恶意数据

网络流是源源不断的,你很可能只收到半个 VarInt。比如对方发送 80 01,但这次网络包只到了 0x80,下一个字节还在路上。如果解码函数直接抛异常,上层协议就没法继续。更合理的做法是返回“数据不足,需要继续等”,而不是报错。我习惯把解码写成返回结果和消费字节数,同时额外返回一个状态:完成、需要更多数据、非法数据。

另一个必须处理的问题是最长字节数限制。恶意输入可以不停发送 0x80,理论上解析器会一直循环。所以解码前先约定:最多读 10 个字节,对应 64 位整数。我见过一个线上事故,就是因为解码循环没有上限,攻击者构造了一串很长的 0x80 把 CPU 打满。加上 10 字节限制后,第 11 个字节如果最高位还是 1,直接判定协议错误。

3.3 溢出和移位别等出了问题再查

当 shift 累加到 70 时,无论是什么语言,把 7 位数据移动到 64 位整数的高位都会产生未定义行为或者静默截断。所以在循环里,每次 shift += 7 之前都要检查 shift 是否小于最大位数。如果协议只支持 uint64,那 shift 最大不能超过 63,超过就说明数据异常。还有一个小细节:如果结果类型是 int64,但协议字段是无符号的,某些语言里把 0xFFFFFFFFFFFFFFFF 解码后可能变成 -1,需要明确你要的是 int64 还是 uint64。

负数处理也要单独约定。VarInt 本身只编非负数,如果业务需要传负数,常规做法是直接用 ZigZag 映射:n >= 0 时变成 2n,n < 0 时变成 -2n - 1。这样 -1 映射成 1,-2 映射成 3,编码后都是 1 字节;如果直接把 int32 的 -1 转成 uint32 再编,会得到 10 个 0xFF,体积爆炸。ZigZag 和解码后的逆映射公式很简单,但协议里必须写清楚,否则两端实现会不一致。

3.4 性能实测中的几个心得

VarInt 解析本身并不慢。绝大多数小整数在第一个字节就结束了,循环只执行一次,现代 CPU 分支预测几乎完美。真正影响性能的是函数调用层数和内存拷贝。解析大量消息时,把解码逻辑直接内联到消息读取循环里,能省掉不可忽略的函数调用开销。如果拿到的字节流有对齐要求,可以先 memcpy 到本地变量再解码,避免读未对齐内存。

我踩过最隐蔽的坑是缓冲区偏移没更新。每条消息解析完,解析函数返回了解码长度,但调用方忘记把整个 buffer 的偏移量加上去,导致下一条消息从错误位置开始读。这种问题在单元测试里很容易发现,但放到长连接高并发场景就特别难查。建议所有 VarInt 解码入口都返回“消费字节数”,并让调用方严格累加,不要自己偷偷假定每次只读一个字节。

4. 二进制协议里的 VarInt 实战:从字段级编码到完整消息帧

4.1 先用 VarInt 做消息长度前缀

把 VarInt 放进真实协议最常见的方式是作为长度前缀。假设要设计一个极简协议,每条消息由三部分组成:载荷长度、消息类型、载荷本身。载荷长度用 VarInt 编码,消息类型也用 VarInt 编码,后面的载荷是任意字节串。这样一条“hello”消息,类型为 1,长度是 5,整体字节就是 01 05 68 65 6C 6C 6F。

如果载荷长度用固定 4 字节表示,每条消息都要多 3 字节;如果消息类型也用固定 4 字节,再多 3 字节。几万条消息下来,多出来的空间相当可观。用 VarInt 后,长度字段从 1 字节到 5 字节浮动,对普通小消息非常友好,大消息又不会撑爆上限。接收方先读一个 VarInt 长度,再按长度去读后续字节,就完成了最基本的“分帧”。这里最需要处理的是半包问题:读到长度后,如果缓冲区里剩余的字节还不足这个长度,就继续等待,不要立刻尝试解析载荷。

4.2 字段级编码:tag 里同时塞进字段号和类型

只做长度前缀还不够,很多协议还需要区分字段。常见做法是用一个 VarInt 编码的 tag 来标识字段。tag 的低 3 位表示 wire type,也就是后面数据怎么编码;高几位表示字段编号。比如 wire type 0 表示 VarInt,wire type 2 表示长度前缀的字节串。那么字段编号 1、类型 VarInt 的 tag 是 (1 << 3) | 0 = 8,编码后 0x08;字段编号 2、类型字节串的 tag 是 (2 << 3) | 2 = 18,编码后 0x12。

一个完整的消息示例:表示“字段 1 的值是 300,字段 2 的值是字符串 hello”,可以这样写:0x08 是字段 1 的 tag,0xAC 0x02 是 300 的 VarInt;0x12 是字段 2 的 tag,0x05 是字符串长度,0x68 0x65 0x6C 0x6C 0x6F 是 hello。整体字节序列为 08 AC 02 12 05 68 65 6C 6C 6F。

这种设计的精妙之处在于:解析器根据 tag 里的 wire type,就知道后面要读一个 VarInt 还是一个带长度的字节串;遇到不认识的高字段编号时,还能跳过未知字段。很多二进制序列化方案能保证前后兼容,靠的不是魔法,而是每个字段都自带解码信息。VarInt 在这里既承担字段编号,又承担长度值,是整个机制的地基。

4.3 畸变数据防护:不能信任任何外来的长度

无论长度前缀还是字段级 VarInt,都只是语法层面的约定,不能防止语义错误。一个恶意或者损坏的包可能把长度声称成 1GB,而实际缓冲区只有 20 字节。如果解析器盲目分配,轻则内存爆炸,重则拖垮整台机器。所以实际协议里一般都要叠加“最大消息长度”限制,超过就直接断开连接或者丢弃,而不是尝试继续读。

解码 VarInt 本身也要加同样的防线。长度前缀的 VarInt 如果超过 10 字节还不见终止位,数据就是非法的,没必要继续。我习惯在紧挨着 VarInt 解析器的上层再包一个状态机,既保证不会像普通流式读取那样丢失半包,又保证任何情况下循环次数都有上限。这种双重防护看着啰嗦,但能省掉很多半夜告警。

4.4 外层格式可以给 VarInt 留一个后门

VarInt 很适合做字段级和消息级压缩。但如果整个消息全部是变长元素,调试和分析协议时会非常痛苦,字节流里很难一眼看出哪个字节是字段号、哪个是值。我自己的经验是:在最外层保留一个固定长度的消息头,用于放魔数、版本号等信息,内部字段再全部用 VarInt 编码。这样抓包时先看头部能快速判断消息来源,再深入解析内部数据。

这不算对 VarInt 的否定,而是协议设计里常用的“定长靠近边界,变长靠近语义”思路。边界上的少量定长字节承担定位和分帧职责,内部语义字段用变长来省空间。很多看起来纯粹的变长协议,仔细看也都会在头部留几个固定位。

5. 从 VarInt 延伸:几种变长整数编码方案的横向对比

5.1 LEB128、Base-128 和 VarInt 其实是同一族

你可能会在别的文档里看到 LEB128、Base-128 Varint 这些名词,它们和这里讲的 VarInt 核心思路完全一致:每个字节低 7 位存数据,最高位做“是否继续”的标记。LEB128 常用于调试信息和编译产物,Base-128 Varint 这个称呼是因为每 7 位恰好对应 128 进制的一个位。区别只在于对负数是否做了符号扩展,以及一些实现是否强制限制最大长度。

有符号 LEB128 要求解码后做符号扩展,编码时也按有符号右移处理;而无符号 VarInt 则直接拒绝负数。看到不同文档里的实现有细微差别时,不要急着说谁对谁错,先确认它针对的是无符号还是有符号场景。这个前提一旦错了,编码结果和解码结果都会南辕北辙。

5.2 不同整数编码的性价比对比

我整理了一张常用方案的粗略对比,方便选型时心里有数:

编码方式0 的字节数127 的字节数300 的字节数2^31-1 的字节数解析复杂度随机访问
定长 uint324444极低支持
无符号 VarInt1125低不支持
ZigZag + VarInt1225低不支持
定长 uint648888极低支持

从表里能清楚看出,没有一种编码在所有维度上都占优。VarInt 对小的非负整数压缩效果好,但对接近满范围的大整数反而会放大体积。ZigZag 让小的负数也变得只有 1 字节,代价是正数部分整体翻倍再编码,所以不是所有场景都划算。如果协议里大量出现随机分布的 64 位哈希值,用 VarInt 只会让每个值从 8 字节膨胀到 10 字节,这种场景就应该老老实实定长。

5.3 选型时我会先问三个问题

现在要设计一个新的二进制协议,我会先问自己三个问题。第一,这些整数的数值分布是什么?是大量小数字、少量大数字,还是接近随机分布?如果是前者,VarInt 明显合适;如果是后者,定长更省心。第二,解析器需不需要随机访问字段?如果需要按索引快速读某个字段,最好定长或者配合一个固定大小的索引表。第三,是否需要长期兼容升级?如果字段经常增删,tag 加 VarInt 的方案比固定结构体更容易做到向后兼容。

实际操作中,我见过不少协议在消息头用定长整数存版本号和魔数,在消息体用 VarInt 存序号、长度和字段号,复杂类型再用定长或长度前缀。这种混合方案往往比单一选择更容易落地。不要为了炫技让全部字段都变成变长,解析代码的维护成本和排查难度也是协议设计的一部分。

5.4 关于测试的一个私人心得

最后分享一个我每次实现 VarInt 都会做的事:把边界值全集放进单元测试。至少包括 0、1、127、128、255、16383、16384、2^31-1、2^32-1、2^64-1、被截断的 0x80、超过 10 字节的连续 0x80、负数输入。大部分问题都是在这组测试里现形的。我之前少写了 128,结果解码器把 80 01 只读了一个字节,得到 0 而不是 128,线上数据静默出错,排查了很久才定位到。从那以后,位级编码的实现我都会把边界测试放在最前面,先把编码器自己的行为钉死,再谈业务逻辑。变量再复杂,根子上的移位和边界稳住了,整个协议就不会歪。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 4:18:49

手写轻量级资源内联工具:把静态资源自动组装进HTML

先从我手头一个真实的项目说起吧。前段时间我在负责一个活动页的优化&#xff0c;页面不大&#xff0c;但静态资源特别多&#xff0c;十几个小图标、几段内联样式、一两个首屏脚本&#xff0c;单独发请求的话&#xff0c;移动端首屏时间很难看。当时的第一反应是引入打包工具&a…

作者头像 李华
网站建设 2026/10/14 4:18:17

【回眸】Ngram 2.0 文本分析与智能应用场景指南

在数据驱动的业务场景中&#xff0c;如何从海量文本中快速提取高价值信息&#xff0c;往往是决定决策效率的关键。很多团队在面对杂乱的搜索日志、社交媒体评论或客服对话记录时&#xff0c;常常陷入“数据很多&#xff0c;洞察很少”的困境。传统的关键词统计方法往往只能看到…

作者头像 李华
网站建设 2026/10/14 4:17:32

网络热词“rea”是如何火起来的?传播链与追词方法论

刷短视频的时候突然看到评论区里冒出一句“这也太rea了吧”&#xff0c;我愣了一下&#xff0c;以为是自己眼误。接着又在另一个社交平台的帖子下面刷到“真的惊到rea了”&#xff0c;再点开几个群聊&#xff0c;居然也有人顺手在打“rea”。一个来历不明、拼写都算不上完整的词…

作者头像 李华
网站建设 2026/10/14 4:17:25

Navicat for MySQL 实战指南:连接配置、数据操作与避坑技巧

简介&#xff1a;Navicat for MySQL 是一款专为 MySQL 与 MariaDB 设计的图形化数据库管理开发工具&#xff0c;面向数据库管理员、后端开发者及需要频繁操作数据库的运维人员。它把连接管理、SQL 编辑、数据导入导出、备份恢复、结构同步、性能监视等常用操作集中到统一界面&a…

作者头像 李华
网站建设 2026/10/14 4:16:51

WPF单实例启动全解析:Mutex互斥、事件唤醒与管道通信实战

1. 先想清楚&#xff1a;单实例启动到底要解决什么问题你有没有遇到过这种场景&#xff1a;用户手速快&#xff0c;或者电脑卡顿&#xff0c;双击了桌面图标两次&#xff0c;结果界面上弹出了两个一模一样的主窗口。如果这个应用只是看个时间倒还好&#xff0c;但如果它背后要占…

作者头像 李华
网站建设 2026/10/14 4:16:46

Python脚本打包与依赖管理:从pip安装到PyInstaller生成exe

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华