二进制格式为什么小?第一个原因是整数不再用固定宽度:数值小就少占字节。这篇自己写出 varint 和 ZigZag,用 protobuf 库核对,再量分布、差分、浮点和压缩各自的影响。
值 二进制(低位在后) 手写 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 整个字段都不写。
值 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 的字节:01int32 把负数当 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)还小。所以“二进制紧凑编码”和“压缩”的收益会重叠,不能简单相加。