bigywater.tech
数据编码与压缩 · 4

整数怎么变成更少的字节:varint、ZigZag 与定长

二进制格式为什么小?第一个原因是整数不再用固定宽度:数值小就少占字节。这篇自己写出 varint 和 ZigZag,用 protobuf 库核对,再量分布、差分、浮点和压缩各自的影响。

10 对 1 字节
protobuf 里 -1 用 int32 与用 sint32(ZigZag)的编码长度
5 对 4 字节
时间戳(约 1.7×10^9)用 varint 与用定长 4 字节的每个数字节数
46185 对 9005
5 万个递增时间戳:原始值 varint 后 zstd-3 与 差分 + ZigZag varint 后 zstd-3 的字节数

varint

实测 · 本机执行输出
             值 二进制(低位在后)                    手写 varint                protobuf(uint32)         一致
             0 0                            00                       (空:proto3 不写默认值 0)       否(库把默认值 0 整个省略了)
             1 1                            01                       01                       是
           127 1111111                      7f                       7f                       是
           128 10000000                     80 01                    80 01                    是
           300 100101100                    ac 02                    ac 02                    是
         16383 11111111111111               ff 7f                    ff 7f                    是
         16384 100000000000000              80 80 01                 80 80 01                 是
       2097152 1000000000000000000000       80 80 80 01              80 80 80 01              是
     268435455 1111111111111111111111111111 ff ff ff 7f              ff ff ff 7f              是
     268435456 10000000000000000000000000000 80 80 80 80 01           80 80 80 80 01           是
    4294967295 11111111111111111111111111111111 ff ff ff ff 0f           ff ff ff ff 0f           是
  每个字节装 7 位有效数据:1 字节存 0–127,2 字节存到 16383,3 字节到 2097151,4 字节到 268435455,5 字节到 uint32 上限

每字节低 7 位放数据、最高位表示是否继续。0–127 一个字节,到 16383 两个字节,依此类推。我手写的编码与 protobuf 库逐值一致。proto3 对等于默认值的 0 整个字段都不写。

负数与 ZigZag

实测 · 本机执行输出
         值  int32  int64  sint32  sint64  ZigZag 映射 (手写)  
         0      0      0       0       0  0 → 0  (还原 0, 手写与库一致:(0 被库省略))
         1      1      1       1       1  1 → 2  (还原 1, 手写与库一致:True)
        -1     10     10       1       1  -1 → 1  (还原 -1, 手写与库一致:True)
         2      1      1       1       1  2 → 4  (还原 2, 手写与库一致:True)
        -2     10     10       1       1  -2 → 3  (还原 -2, 手写与库一致:True)
        63      1      1       1       1  63 → 126  (还原 63, 手写与库一致:True)
       -64     10     10       1       1  -64 → 127  (还原 -64, 手写与库一致:True)
        64      1      1       2       2  64 → 128  (还原 64, 手写与库一致:True)
       -65     10     10       2       2  -65 → 129  (还原 -65, 手写与库一致:True)
      1000      2      2       2       2  1000 → 2000  (还原 1000, 手写与库一致:True)
     -1000     10     10       2       2  -1000 → 1999  (还原 -1000, 手写与库一致:True)
  2147483647      5      5       5       5  2147483647 → 4294967294  (还原 2147483647, 手写与库一致:True)
  -2147483648     10     10       5       5  -2147483648 → 4294967295  (还原 -2147483648, 手写与库一致:True)
  int32 -1 的字节:ff ff ff ff ff ff ff ff ff 01(10 字节,前 9 个是 ff,最后 01);sint32 -1 的字节:01

int32 把负数当 64 位补码,-1 要 10 字节;sint32 先 ZigZag(把 0, -1, 1, -2 映射成 0, 1, 2, 3),绝对值小的数不管正负都占得少。可能为负的字段应该用 sint。

分布

实测 · 本机执行输出
  分布                             varint      定长4      定长8   JSON 数组  MsgPack 数组   平均每个数 varint 字节
  小值 0–100                       100000   400000   800000    291117      100005    1.00
  0–10000 的均匀值                   198728   400000   800000    488997      296173    1.99
  任意 32 位整数                      493697   400000   800000   1074106      499997    4.94
  Unix 时间戳(2025–2026,秒)          500000   400000   800000   1100001      500005    5.00
  有序递增的时间戳                       500000   400000   800000   1100001      500005    5.00
  幂律(很多小值、少数大值)                  100319   400000   800000    206736      100476    1.00
  (varint 和定长两列只算数字本身;JSON、MsgPack 两列是整个数组,含逗号、括号或数组头)

varint 的好处取决于数值大小:小而集中的数据一个数只要 1 字节(比定长 4 字节小 4 倍);接近 32 位上限的数反而要 5 字节,比定长多。Unix 时间戳就属于后一种。

差分

实测 · 本机执行输出
  表示                               字节   zstd-3   gzip-6
  原始值 varint                   250000    46185    69627
  原始值定长 8 字节                   400000    50959    75842
  差分 + ZigZag varint            50004     9005     7383
  差分的差分 + ZigZag varint         50004     9098     7920
  ts 列的差值只有 [1, 2] 这几种(值的个数 2);JSON 文本 550001 字节

排序的、变化平缓的列(时间戳、自增 id)先存与前一项的差,再 varint,每个数 1 字节。实验里 5 万个时间戳从原始值压缩后的 46185 字节降到 9005 字节。这是编码层的预处理,让压缩器看到更有规律的数据。

浮点

实测 · 本机执行输出
  表示                                 字节   zstd-3
  float64 定长                     160000    63388
  float32 定长                      80000    61684
  乘 100 取整后 varint                41136    39571
  十进制文本(逗号分隔)                    118458    49794
  float32 的误差:最大 6.84e-05;0 个值(占 0.0%)转成 float32 再 round 到 2 位小数后与原值不同;例:113.4 → 113.4000015258789
  乘 100 取整再还原(/100)与原值完全一致:True

已知小数位数时(价格两位),乘 100 取整再 varint 只要 41136 字节,压缩后 39571;float64 定长 160000,压缩后 63388。float32 对这批价格没有出错,但 113.4 变成 113.4000015…,精度要求高时不能随便降。

编码之后再压缩

实测 · 本机执行输出
  分布                             varint    →zstd      定长4    →zstd     JSON    →zstd
  小值 0–100                       100000    84090   400000   117504   291117   114430
  0–10000 的均匀值                   198728   198743   400000   213125   488997   223633
  任意 32 位整数                      493697   450986   400000   400021  1074106   525942
  Unix 时间戳(2025–2026,秒)          500000   385940   400000   358762  1100001   464661
  有序递增的时间戳                       500000   321219   400000   279033  1100001   217055
  幂律(很多小值、少数大值)                  100319    39774   400000    62977   206736    50203

定长整数的高位是大批零字节,压缩器能去掉它们:时间戳的定长 4 字节压缩后(358762)比 varint 压缩后(385940)还小。所以“二进制紧凑编码”和“压缩”的收益会重叠,不能简单相加。

分步演示

varint 与 ZigZag
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8(系统 Python,自带 compression.zstd)/ zstandard 0.25.0 / lz4 4.4.5 / zstd、lz4、brotli、protoc 命令行(Homebrew);msgpack、protobuf、snappy 等第三方库临时装在临时目录里,用完删除;所有数据都是本机脚本生成的合成数据(固定随机种子),没有使用任何真实网站或用户的数据;计时用 time.perf_counter,取多次中位数,数字每次运行会有小幅抖动。protobuf 7.36.2 运行库,protoc 36.2 生成 Python 代码(临时目录,用完删除);varint 和 ZigZag 是我手写的,逐值与库对照;随机数据由固定种子生成;所有数字都是确定性的字节数,没有计时
    • 没有验证:压缩速度(这一篇只看体积);fixed32/fixed64 在 protobuf 里的字段头开销;packed repeated 字段(会省掉每个元素的字段头);更多差分方案(如 Frame-of-Reference、位打包);Parquet 的整数编码;Go 或 Java 的 protobuf 实现是否一致
    京ICP备2026067502号-1京公网安备11010502063487号