专栏文章
专栏文章
存储算法系列
1. 存储算法 #01:LSM Tree 2. 存储算法 #02:Bloom Filter 3. 存储算法 #03:B+ Tree 4. 存储算法 #04:SSTable 5. 存储算法 #05:Copy-on-Write(COW) 6. 存储算法 #06:Roaring Bitmap 7. 存储算法 #07:WAL(Write-Ahead Log) 8. 存储算法 #08:Bitcask 9. 存储算法 #09:Redo 与 Undo Log 10. 存储算法 #10:Varint 与 ZigZag 编码 11. 存储算法 #11:Delta 编码 12. 存储算法 #12:LZ4 与 Snappy 与 Zstd

存储算法 #10:Varint 与 ZigZag 编码

发布于 2026-06-04 04:29 👁 7 次阅读

Varint 与 ZigZag 编码解决了固定长度整数编码浪费空间的问题,广泛应用于 Protobuf、Thrift、Avro 等序列化框架,小数值可节省 50%~75% 存储空间。

相关文章Delta 编码 · LZ4 与 Snappy 与 Zstd

varint zigzag

目录

章节 说明
问题背景 固定长度编码在小数值时浪费空间
Varint 编码原理 LEB128:每字节7位数据+1位续标志
encode_varint 伪代码 无符号整数编码完整流程
decode_varint 伪代码 字节流解码完整流程
执行追踪:编码 300 逐字节展示 300 的编码过程
ZigZag 编码原理 有符号整数映射为非负整数
执行追踪:ZigZag 编码 -1 逐步展示 -1 的编码过程
各框架的选择 Protobuf int32 / sint32 / fixed32 的适用场景
异常与边界场景 超长 Varint、溢出、截断等边界处理
参考资料 论文、官方文档和书籍

问题背景

在二进制序列化中,固定长度编码是最直接的整数表示方式:

类型 字节数 表示 1 所需字节 表示 300 所需字节
int32 4 4(浪费 3 字节) 4(浪费 2 字节)
int64 8 8(浪费 7 字节) 8(浪费 6 字节)
Varint 可变 1 2

实际业务场景中,大量整数字段(用户 ID 增量、状态码、枚举值、消息长度)的值都集中在较小范围内。Google 的内部统计显示,Protobuf 消息中约 80% 的整数字段值小于 128,可用 1 字节编码。

使用 Varint 的系统


Varint 编码原理(LEB128)

Varint 全称 Variable-length integer,采用 LEB128(Little-Endian Base-128) 编码格式:

每个字节的结构:
┌─────┬────────────────┐
│ MSB │   7-bit data   │
└─────┴────────────────┘
  ↑
  MSB = 1:后续还有字节
  MSB = 0:这是最后一个字节

核心规则

  1. 每字节仅用低 7 位存储数据,最高位(bit 7)为续位标志(MSB)
  2. MSB = 1:表示后续还有更多字节
  3. MSB = 0:表示当前字节是最后一个字节
  4. 多字节时,低位字节先输出(小端序)

空间对比

数值范围 Varint 字节数 int32 字节数 节省
0 ~ 127 1 4 75%
128 ~ 16383 2 4 50%
16384 ~ 2097151 3 4 25%
2097152 ~ 268435455 4 4 0%
≥ 268435456 5 4 -25%(更大!)

encode_varint 伪代码(无符号整数)

function encode_varint(n: uint64) -> bytes:
    result = []
    
    while n >= 128:                    // 还有高位需要编码
        byte = (n & 0x7F) | 0x80      // 取低7位,设置续位标志 MSB=1
        result.append(byte)
        n = n >> 7                     // 丢弃已编码的低7位,处理剩余高位
    
    result.append(n & 0x7F)           // 最后一组(n < 128),MSB=0(不设置)
    return result

关键操作说明


decode_varint 伪代码

function decode_varint(bytes: byte[]) -> (uint64, int):
    result = 0
    shift  = 0
    
    for i, b in enumerate(bytes):
        data = b & 0x7F                // 提取低7位数据(去掉续位标志)
        result = result | (data << shift)  // 将数据放到正确的位位置
        shift += 7                     // 下一个字节的数据位移偏量增加7
        
        if (b & 0x80) == 0:           // MSB=0:这是最后一个字节
            return (result, i + 1)    // 返回解码结果和消耗的字节数
    
    // 走到这里说明字节流不完整(所有字节 MSB 都是1)
    raise IncompleteVarintError("unexpected end of varint bytes")

关键操作说明


执行追踪:编码 300

输入:n = 300 = 0b100101100(二进制)

Step 1:n = 300 = 0b1_0010110_0(分组视角:低7位=0010110_0=44,高位=10=2)
         ↓ 更清晰:300 = 0b00000010_0101100
         低7位 = 0b0101100 = 44
         
         byte1 = (300 & 0x7F) | 0x80
               = 44 | 128
               = 0b00101100 | 0b10000000
               = 0b10101100
               = 0xAC = 172(十进制)
               
         n = 300 >> 7 = 2(还剩高位数据)

Step 2:n = 2 < 128,退出循环
         byte2 = 2 & 0x7F
               = 0b00000010
               = 0x02 = 2(十进制)

结果:[0xAC, 0x02] = [172, 2],共 2 字节

验证解码

b0 = 0xAC = 0b10101100,MSB=1(继续)
     data0 = 0xAC & 0x7F = 0b00101100 = 44
     result = 44 << 0 = 44
     shift = 7

b1 = 0x02 = 0b00000010,MSB=0(结束)
     data1 = 0x02 & 0x7F = 2
     result = 44 | (2 << 7) = 44 | 256 = 300 ✓

对比固定编码

int32 固定4字节:00 00 01 2C
Varint 2字节:   AC 02
节省:50%

ZigZag 编码原理

负数问题:有符号整数的补码表示使得小负数在 Varint 下非常占空间。

-1 的补码(int32):0xFFFFFFFF = 0b11111111_11111111_11111111_11111111
用 Varint 编码 -1:需要 5 组 7 位 = 5 字节(int32),或 10 字节(int64)

这完全抵消了 Varint 的优势。

ZigZag 映射:将有符号整数映射为非负整数,使绝对值小的数映射结果也小:

原始值 ZigZag 映射结果 Varint 字节数
0 0 1
-1 1 1
1 2 1
-2 3 1
2 4 1
-64 127 1
64 128 2

映射规律:正数 n → 2n,负数 n → -2n - 1(即正负数交替排列在非负整数轴上)

编码公式(位运算实现,无条件分支):

encode_zigzag(n: int32) -> uint32:
    return (n << 1) ^ (n >> 31)
    //      ↑             ↑
    //      乘以2        算术右移31位
    //      正数高位补0  正数→全0,负数→全1(0xFFFFFFFF)
    //      XOR 结果:正数末位为0,负数末位为1

解码公式

decode_zigzag(n: uint32) -> int32:
    return (n >>> 1) ^ -(n & 1)
    //      ↑              ↑
    //      逻辑右移1位    提取末位符号,生成掩码
    //                     n末位=0 → 0,n末位=1 → -1(0xFFFFFFFF)

执行追踪:ZigZag 编码 -1

输入:n = -1(int32)

Step 1:计算 n << 1
        -1 = 0xFFFFFFFF(32位补码)
        -1 << 1 = 0xFFFFFFFE = -2(算术左移,最低位变0)

Step 2:计算 n >> 31(算术右移)
        -1 >> 31:算术右移,符号位扩展
        -1 >> 31 = 0xFFFFFFFF = -1(全1,即-1)

Step 3:XOR
        0xFFFFFFFE ^ 0xFFFFFFFF
        = 0x00000001
        = 1

ZigZag(-1) = 1

Step 4:用 Varint 编码 1
        1 < 128,直接输出 [0x01]
        共 1 字节!(vs 不用 ZigZag 需要 5~10 字节)

验证解码

ZigZag解码 1:
n = 1
n >>> 1 = 0(逻辑右移)
n & 1 = 1 → -(n & 1) = -1 = 0xFFFFFFFF
0 ^ 0xFFFFFFFF = 0xFFFFFFFF = -1 ✓

编码 -64 示例

-64 = 0xFFFFFFC0
-64 << 1 = 0xFFFFFF80 = -128
-64 >> 31 = 0xFFFFFFFF = -1
-128 ^ -1 = 0x0000007F = 127
Varint(127) = [0x7F],1 字节

各框架的选择

Protobuf Wire Type 0

message Example {
    int32  a = 1;   // Varint,不适合负数(-1 需 5 字节)
    sint32 b = 2;   // ZigZag + Varint,适合有正有负
    uint32 c = 3;   // 无符号 Varint,适合非负整数
    fixed32 d = 4;  // 固定 4 字节,适合大数值(> 228 时比 Varint 更小)
    sfixed32 e = 5; // 固定 4 字节有符号
}

选型决策树

整数字段
├── 值域确定为非负 → uint32/uint64(Varint)
├── 可能为负数
│   ├── 负数罕见 → int32/int64(Varint,负数时较大)
│   └── 正负都常见 → sint32/sint64(ZigZag + Varint)
└── 大数值(> 2^28)→ fixed32/fixed64(固定长度更优)

Thrift Compact Protocol

i16/i32/i64 字段:均采用 ZigZag + Varint(统一处理有符号整数)

Avro

int/long 类型:ZigZag + Varint(与 Thrift 类似)

异常与边界场景

场景 1:Varint 超过最大字节数(防止无限读取)

问题:恶意或损坏的数据可能包含所有字节 MSB 都为 1 的无限序列。

处理:设置最大字节数限制

// int32 最多 5 字节,int64 最多 10 字节
if i >= MAX_VARINT_BYTES:
    raise MalformedVarintError(f"varint exceeds {MAX_VARINT_BYTES} bytes")

Protobuf 实现中,uint32 限制为 5 字节,uint64 限制为 10 字节,超过则抛出解析异常。

场景 2:ZigZag 64 位整数边界溢出

问题:对 int64 最小值 Long.MIN_VALUE = -9223372036854775808 = 0x8000000000000000 执行 ZigZag 编码时:

n = Long.MIN_VALUE = 0x8000000000000000
n << 1 = 0x0000000000000000 = 0(溢出!)
n >> 63 = 0xFFFFFFFFFFFFFFFF = -1
0 ^ -1 = 0xFFFFFFFFFFFFFFFF = Long.MAX_VALUE * 2 + 1 = 18446744073709551615

结果是 uint64 的最大值,仍然正确(是合法的 ZigZag 映射),Varint 编码需要 10 字节。解码时能正确还原 Long.MIN_VALUE。这是设计层面的边界,不是 Bug。

场景 3:网络传输截断(读到不完整的 Varint)

问题:TCP 流式传输可能在 Varint 中间截断,导致读取到部分字节。

现象:循环结束时所有字节 MSB 均为 1,从未遇到 MSB=0 的终止字节。

处理

// 读完所有可用字节仍未找到 MSB=0
raise IncompleteVarintError("stream ended in the middle of a varint")

// 上层应:
// 1. 等待更多数据到达(流式场景)
// 2. 记录读取位置,等数据补齐后重试
// 3. 标记连接为损坏状态,关闭并重连

场景 4:值为 0 的编码

问题:0 是否需要特殊处理?

分析

n = 0 < 128,跳过 while 循环
输出 [0 & 0x7F] = [0x00]
结果:[0x00],1 字节,正确

不需要特殊处理,算法自然覆盖。

场景 5:负数直接用 Varint(不用 ZigZag)

问题:如果 int32 字段存储 -1,不经过 ZigZag 直接 Varint 编码?

分析(Protobuf int32 的实际行为):

-1(int32)扩展为 int64 后 = 0xFFFFFFFFFFFFFFFF
Varint 编码需要 10 字节(每 7 位一组,共 10 组)
结果:FF FF FF FF FF FF FF FF FF 01(10字节)

对比 ZigZag(-1) = 1 → Varint([0x01])(1字节)
差异:10倍空间!

这就是 Protobuf 中 int32 不适合负数、应使用 sint32 的原因。


参考资料

  1. Protocol Buffers Encoding — Google官方文档
    https://protobuf.dev/programming-guides/encoding/

  2. LEB128 — Wikipedia
    https://en.wikipedia.org/wiki/LEB128

  3. DWARF Debugging Standard(LEB128 最早的规范化定义)
    http://dwarfstd.org/

  4. 《Designing Data-Intensive Applications》 — Martin Kleppmann
    第 4 章 Encoding and Evolution,详细讲解 Protobuf / Thrift / Avro 编码格式

  5. Apache Thrift Compact Protocol Spec
    https://github.com/apache/thrift/blob/master/doc/specs/thrift-compact-protocol.md

  6. Apache Avro Specification(int/long encoding 章节)
    https://avro.apache.org/docs/current/spec.html#binary_encoding

  7. Google Protocol Buffers Source Code(varint 实现参考)
    https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/io/coded_stream.cc

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论