资讯动态

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

发布时间:2026/10/14 4:19:25 来源:尧图企业网站定制
最近在写一个二进制协议的解析模块刚开始我沿用之前做业务接口的习惯遇到整数就按固定 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 – 12714128 – 163832416384 – 2097151342097152 – 26843545544268435456 – 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也就是十进制的 440x2C剩余部分是 0000010也就是十进制的 20x02。按小端分组顺序先写低字节 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 时变成 2nn 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 表示 VarIntwire type 2 表示长度前缀的字节串。那么字段编号 1、类型 VarInt 的 tag 是 (1 3) | 0 8编码后 0x08字段编号 2、类型字节串的 tag 是 (2 3) | 2 18编码后 0x12。一个完整的消息示例表示“字段 1 的值是 300字段 2 的值是字符串 hello”可以这样写0x08 是字段 1 的 tag0xAC 0x02 是 300 的 VarInt0x12 是字段 2 的 tag0x05 是字符串长度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线上数据静默出错排查了很久才定位到。从那以后位级编码的实现我都会把边界测试放在最前面先把编码器自己的行为钉死再谈业务逻辑。变量再复杂根子上的移位和边界稳住了整个协议就不会歪。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑