压缩是软件开发中非常常见的一种数据处理手段。做服务端开发时,我们会把响应体压缩后再传输,减少网络带宽和跨机房流量;也会把日志、消息、缓存快照或数据库块压缩后保存,少占磁盘、对象存储和页缓存。数据库系统更进一步,通常还会先利用列、类型和排序里的规律做编码,再交给通用压缩算法处理。
判断一种压缩方案是否合适,不能只看压缩后体积,还要把 CPU、内存、I/O 和延迟放在同一条数据路径上衡量。
本文从数据中可被利用的冗余出发,依次回答每种算法解决了什么问题、怎样工作、在什么数据上有效,以及实际系统把它用在数据路径的哪个位置。最后使用三类数据验证这些判断,并给出面向工程约束的选型方法。
范围说明
本文讨论服务端和数据库开发中的无损压缩方案,不仅包含常见的通用压缩算法(如 Snappy、LZ4、Zstandard 等),也包含面向字节流(LZ77)和面向字段的压缩编码(如 RLE、Dictionary 等),还会介绍熵编码的相关内容(如 Huffman、FSE、ANS 等),以及它们在实际系统中的应用。
1. 一笔交易#
服务端里,数据很少只是安静地躺在磁盘上。它会经过网卡、内存、页缓存、磁盘、对象存储和跨机房链路;一次查询、一条日志、一批消息,真正花钱和拖慢速度的往往是这些路径上反复搬运的字节。压缩的直接收益,就是让这些字节变少。原本要从对象存储读 1 GiB,压缩后只读 200 MiB,哪怕后面要解压,端到端延迟也可能下降,因为系统绕开了更慢的 I/O。
但压缩不是白送的。写入端要花 CPU 找重复、建字典、计算概率或改写字段表示;读取端要解压,还可能要把编码后的值恢复成执行引擎能处理的类型。压缩块本身也会带来工作内存和读放大:只想读一小段数据时,可能必须先把整个块取回来再解压。
所以压缩选型本质上是在换资源。在线接口可能愿意用一点 CPU 换更少的公网流量,但如果机器本来就卡在 CPU 上,压缩会把延迟打高;冷数据归档通常能接受更慢的压缩速度,因为少占对象存储更重要;数据库的 flush、compaction 或 merge 路径可以把一部分成本挪到后台,而前台查询更关心解压速度和最小读取块。
这也是服务端压缩和普通归档文件压缩的区别。归档文件常常只关心最终体积,服务端还要看压缩发生在写入前台、后台任务、网络传输还是读取路径上。算法没有脱离场景的“最好”,只有在某条数据路径里是否划算。
2. 不只一层#
今天的压缩已经渗进了服务端的很多基础路径。HTTP 响应会压缩,RPC 和消息批次可能压缩,日志落盘和对象存储归档会压缩,数据库页、SST block、列式文件和时间序列 chunk 也经常压缩。很多时候它不是一个单独功能,而是数据格式、存储引擎和执行引擎的一部分。
这也让问题变复杂了。调用方说“用 LZ4”或“用 Zstd”,通常只说到了通用字节流 Codec;数据库和分析文件格式还会在它前面做一层数据感知编码。通用 Codec 只看到字节,不知道其中八个字节是一条时间戳,也不知道一列字符串只有几十个不同值。数据库知道 schema、列边界、排序键和空值信息,就可以先把这些确定的信息写进更紧凑的表示,再交给通用 Codec 继续处理字节级重复。
在数据库中,一条典型但并非所有系统都完整采用的链路是:写入时,原始列先经过 RLE、Dictionary、Delta、Prefix Compression 之类的编码,形成整数 ID、差值、偏移或前缀/后缀记录;系统再把编码后的流组织成页或块,交给 LZ4、Snappy、gzip 或 Zstandard。读取时先定位并解压相关块,再按算子需要解码字段值;字典过滤、RLE 扫描等操作也可能直接作用于编码表示,不必先还原整列。
列式布局特别容易暴露这种规律。把一百万行的 status 放在一起,看到的是少量状态码反复出现;把时间戳放在一起,看到的是相近且大多单调的整数。若按行把时间戳、随机请求 ID、消息文本和状态码交错保存,任一字段的规律都会被其他字段打断。列式并不会自动产生压缩,但它让编码器更容易在相同类型和相似分布中工作。
这不是在给数据库编码和通用 Codec 排高低,而是在把它们放回各自的位置。Dictionary、Delta、RLE 先改写数据表示,让 LZ4、Snappy 或 Zstd 看到另一种字节流。后者再处理剩下的重复片段和符号频率。
所以技术选型时尤其是在实现一个基础服务时,不仅仅是从一堆 Codec 中选出来一个压缩倍数很高的。可以先看字段本身有没有连续重复、低基数、相近整数或公共前缀;有的话,RLE、Dictionary、Delta、Prefix Compression 往往能先把表示改短。改写之后剩下的字节流,才轮到 LZ 去找重复片段,Huffman/FSE 去利用概率偏斜。最后再比较 LZ4、Snappy、gzip、Zstandard 这些完整 Codec,这样可以把压缩利用到极致,也避免遗漏关键优化。
为了避免“压缩率”这个词产生歧义,本文用
compressed_size / original_size表示压缩后体积占比,数值越低,留下的字节越少。需要描述“原来是现在的多少倍”时,才使用original_size / compressed_size计算压缩倍数。压缩和解压速度统一写成MiB/s,分子始终是原始数据体积。后文会反复用到几个容易混在一起的术语:
术语 本文中的含义 例子 数据感知编码 理解字段类型、顺序或分布后,改写这一列的表示 RLE、Dictionary、Delta、Prefix Compression 压缩原语 完整压缩器内部利用某类冗余的机制 LZ77 匹配、Huffman、FSE Codec 把字节流编码为可独立解码表示的完整实现及配置 LZ4、Snappy、gzip-6、Zstandard 格式 规定页、块、元数据以及允许编码方式的容器协议 Parquet、ORC、gzip 压缩后体积占比 compressed_size / original_size0.25表示保留原体积的 25%压缩倍数 original_size / compressed_size体积占比 0.25对应4x同一算法的结果还会随块大小、压缩级别、字典、实现版本和 CPU 改变。一个“4x”的数字若没有数据分布和实验口径,几乎不能用于选型。
3. 编码1:利用字段的规律#
下面几类方法都依赖字段的类型或排列。它们不会尝试理解任意字节流,而是针对某一种清晰的数据特征,把原始值改写成更容易保存和继续压缩的表示。
这类方法的出发点,是数据库块中的值通常不会覆盖数据类型的全部可能状态。一个 uint64 类型允许很大的范围,不代表当前页的一千个值真的需要 64 bit;一个字符串列允许每行不同,也不代表当前 stripe 没有反复出现的地区名。RLE、Dictionary、Delta 和 FOR 分别把“当前这一小段数据”的规律写进表示,代价则是额外元数据以及更明确的解码边界。
3.1 连续重复:RLE#
RLE(Run-Length Encoding)要解决的是“同一个值连续写很多次”时的重复计数问题。输入 A A A A B B C C C 不必保存九个值,可以写成 (A, 4) (B, 2) (C, 3):每个游程只保留一次值和连续次数。解码时按次数展开即可,既不需要全局字典,也不需要搜索历史窗口。
用 C 看最朴素的定宽记录,它只是值和次数两个字段:
#include <stdint.h>
typedef struct {
uint32_t value;
uint32_t count;
} Run32;若输入原本每个值占 4 bytes,这两个字段按 8 bytes 序列化,那么长度为 1 的游程会把体积扩大一倍,长度为 2 也没有净节省,超过 2 才开始摊薄记录成本。实际格式会使用更窄的值、Varint、bit packing 或混合模式,但判断始终一样:连续次数必须足以覆盖 count 和边界元数据。
它利用的是连续性,而不是低基数本身。一个只有 A、B 两种值的列若交替排列为 A B A B ...,每个游程长度都接近 1,计数字段会使结果比固定宽度输入更大。排序、聚簇或列式组织之所以常与 RLE 一起出现,是因为它们可能把相同值放到相邻位置;若为了制造长游程而改变排序,又要同时评估查询和写入代价。
RLE 适合长而稳定的连续游程,也适合排序天然聚集相同状态、枚举值或层级值的列。仅仅“值种类少”还不够。
3.2 低基数:Dictionary Encoding#
Dictionary Encoding 面对的是另一种重复:值不一定相邻,但不同值的总数很少。以 北京 上海 北京 深圳 为例,可以保存字典 0=北京 1=上海 2=深圳,数据流只留下 ID 0 1 0 2。当原值是较长字符串而 ID 只需几个 bit 或 byte 时,数据页、比较操作和后续压缩都可能受益。
字典不是免费的。它需要保存唯一值、长度、ID 位宽和页级元数据;读取相关数据时还要取得字典。基数上升会同时扩大字典和 ID 位宽,更新频繁或跨页分布不稳定时,还要决定字典作用域和回退策略。若几乎每个值都唯一,字典只是把原数据搬到另一处,再额外增加 ID 流。
字符串或维度列的唯一值集合明显小于记录数,并且字典能在合适的页、stripe 或列范围内复用时,Dictionary Encoding 才有稳定收益。
3.3 相近整数:Delta、FOR 与 Bit Packing#
固定宽度整数必须为最大可能值预留位数,即使当前块中的值都很接近。对输入 1000 1005 1007 1012,FOR(Frame of Reference)路径选择 base = 1000,把同一块改写成 offset 0 5 7 12;Delta 路径则保存首值 1000 和相邻差值 +5 +2 +5。FOR 的最大 offset 是 12,只需 4 bit,四个 offset 可以按统一位宽紧密排列。
Delta 与 FOR 是两条并行选择,都可以进入位打包或其他紧凑整数表示,并不构成固定流水线。Delta 关注相邻值之间的变化,适合单调且步长较小的序列;FOR 关注一个块相对共同基值的范围,即使块内顺序有抖动,只要最大值与最小值接近,offset 仍可能很窄。
Varint 用较少字节保存较小的无符号整数,但大值会占用更多字节;ZigZag 则把绝对值较小的正负数映射到较小的无符号数,常与 Varint 配合保存有符号差值。它们解决的是整数表示问题,不等同于 Delta 或 FOR 所利用的数据关系。
这类编码最怕离群值和错误的块边界。一个极大的差值可能抬高整组位宽;乱序会让 Delta 变大甚至要求有符号表示;块太小会增加 base、位宽等元数据,块太大又更容易包含异常范围。工程实现通常按 block 或 miniblock 记录位宽,以限制单个离群值的影响。
整数在局部块内单调、步长小或取值范围窄时,值得并行测试 Delta + Bit Packing 与 FOR + Bit Packing,再用真实离群值和块大小验证位宽。
3.4 有序 Key:Prefix Compression#
排序后的 Key 往往有很长的公共前缀。customer:0001、customer:0002、customer:0008 中,后两个 Key 与前一个 Key 各共享 12 个字节,因此可以保存“共享前缀长度 12 + 后缀 2”和“共享前缀长度 12 + 后缀 8”,而不重复写入 customer:000。
若每条记录都依赖前一条,随机定位到块中间就必须从块首顺序恢复。restart point 用空间换局部可寻址性:每隔若干条保存一个完整 Key 和 restart offset,查找先定位附近重启点,再恢复有限数量的后续 Key。间隔越大,完整 Key 越少,但最坏情况下要重放的条目更多;间隔越小则相反。
Prefix Compression 的起测条件是 Key 已按字节顺序排列、公共前缀明显,而且读取可以从局部重启点恢复少量条目。restart interval 必须同时进入随机读取测试。
3.5 稳定时间序列:Delta-of-Delta 与 Gorilla XOR#
固定采样的时间戳不仅相邻差值小,差值本身也常保持不变。1000 1010 1020 1030 的一阶 Delta 是 10 10 10,后续 Delta-of-Delta 为 0 0;大量零可以用很短的控制位或整数表示。采样抖动越小,这种二阶规律越稳定。
浮点值不能直接照搬整数差分,因为数值接近不保证算术差值拥有方便的整数分布。Gorilla 按 IEEE-754 位模式比较相邻 float64:先 XOR 前值与当前值,XOR 为零表示位模式完全相同;否则记录前导零、有效变化区间和尾随零,只保存中间有效位。后续 XOR 若落在已有窗口内,还可以复用窗口元数据。
这两条路径分别利用时间和值的规律。时间戳乱序、采样间隔剧烈变化会放大 Delta-of-Delta;浮点值随机跳变、低位噪声或本身接近随机位流时,XOR 的有效区间会变宽。Gorilla 的收益来自相邻位模式,不是“所有浮点数都容易压缩”。
Gorilla 原论文 将 Delta-of-Delta 和 XOR 编码用于监控时序点。
采样周期稳定、相邻浮点位模式只在局部变化,并且读取通常按时间块进行时,可以分别测试 Delta-of-Delta 与 Gorilla XOR。两条流要单独测量,各自的收益取决于对应的数据分布。
4. 编码2:利用字节流中的重复片段#
RLE 只能利用相邻的相同值,而服务端数据里更常见的是“隔了一段又重复”的字节片段,比如 JSON 字段名、协议头、路径和标签。它们不一定落在字段边界,却仍可以用历史内容代替再次写入。
这和前面依赖字段语义的编码不同。LZ 类方法不关心字节来自整数、时间戳还是字符串,只把序列化结果当作连续字节流;只要片段在已处理历史中出现过,就能写成一次引用。也因为够通用,常见通用 Codec 多以 LZ77 类历史匹配为主干,再叠加熵编码、frame 或校验。
相比于字典编码,如果先为整份输入建立静态短语表,就要保存字典并决定哪些短语值得收入;流式输入也无法提前看完。LZ77 直接把最近历史当作滑动字典,不要求调用方提供 schema 或预训练词表,只需在有限窗口内回答“这段内容刚才是否出现过”。代价是必须记录或搜索历史;窗口、块大小和搜索强度会同时影响体积、CPU 和内存。
4.1 LZ77:用距离和长度引用历史#
LZ77 原论文 给出的核心模型,是在有限的最近历史窗口中寻找与待编码位置相同的最长片段,再用向后位置(工程格式通常写成距离)和长度替代它。找不到合适匹配的字节仍作为 literal 保存。因此一条 LZ 流至少要能区分两种信息:原样字面量,以及“回到历史某处复制若干字节”的 match。
以 ABCABCABCX 为例,先不考虑具体格式怎样排列 token,只看 LZ77 要表达什么:
| 待处理位置 | 此时已有历史 | 编码器写出的概念记录 | 解码后的输出 |
|---|---|---|---|
0..2 |
空 | literal("ABC") |
ABC |
3..8 |
ABC |
match(distance=3, length=6) |
ABCABCABC |
9 |
ABCABCABC |
literal("X") |
ABCABCABCX |
第二条记录看起来有个疑问:历史里明明只有 3 个字节,为什么能复制 6 个?因为解码器按字节回拷,并且刚写出的字节立刻成为新的历史。它先从当前位置向前 3 字节复制 ABC,输出变成 ABCABC;距离仍为 3,再继续复制刚生成的 ABC,就得到第三组 ABC。这种重叠复制让很短的历史也能表示周期性重复。
因此,这段输入可以概念化为:
literal("ABC") -> match(distance=3, length=6) -> literal("X")
这个短例子未必真的会变小,因为真实格式还要保存 token、长度扩展和块边界。放到日志批次中,同样的过程会作用在重复的 {"level":"info","service":、路径或协议头上,变化的 ID 和时间戳则继续作为 literal;LZ77 不需要知道这些字节分别是什么字段。
窗口决定最远能引用多久以前的数据,搜索策略决定为找到匹配要花多少计算。更长历史可能找到更好的 match,也会扩大状态和搜索空间;更积极的候选比较可能缩小输出,却会拖慢写入。独立块又形成硬边界:若每个块单独解码,默认不能引用前一块,随机读取更简单,但跨块重复随之丢失。
输入包含跨字段、跨记录的重复片段,而调用方只能提供普通字节流时,LZ 类 Codec 是合适的起测点。窗口、块大小和搜索强度必须作为配置的一部分记录。
5. 熵编码:利用符号出现概率#
LZ 把重复片段换成 literal、length、distance 等符号后,这些符号仍要落到 bitstream 中。如果所有符号都用相同位数,而少数符号出现得特别频繁,定长编码就会把许多 bit 花在“区分几乎不会出现的情况”上。
设符号分布为 A: 50%、B: 25%、C: 12.5%、D: 12.5%。把它落成 8 个符号,可以写成 AAAABBCD。四种符号的定长表示每个需要 2 bit,整串共 16 bit;但 A 占一半,若能给 A 更短表示,把较长表示留给 C、D,总位数就可能下降。
Shannon 1948 年论文 用 H = -sum(p_i log2 p_i) 描述离散源的平均信息量。这个分布的熵是 1.75 bit / symbol。熵在这里不是某个 Codec 的压缩承诺。在给定概率模型和唯一可译码约束下,它给出长期平均码率的下界;有限数据块还要承担码表、状态和边界等额外开销。概率越偏斜,定长表示通常留下越多可利用空间。
这类按符号出现概率调整表示方式的后端,通常称为熵编码;Huffman 和 FSE 都属于这个方向。
5.1 Huffman:使用更短前缀码#
Huffman 从最低权重符号开始合并:先把权重同为 1 的 C、D 合成权重 2,再把这个节点与权重 2 的 B 合成权重 4,最后与权重 4 的 A 合并。把左、右分支分别记为 0、1,可以得到 A=0、B=10、C=110、D=111。
现在把 AAAABBCD 真正写成 bit:
| 方法 | 逐符号表示 | 总位数 |
|---|---|---|
| 定长码 | 00 00 00 00 01 01 10 11 |
16 |
| Huffman | 0 0 0 0 10 10 110 111 |
14 |
Huffman 行去掉空格后是 00001010110111。解码器从左到右读取:遇到 0 立即得到 A;遇到 1 还不能停,继续读到 10 得到 B,读到 110 或 111 才得到 C 或 D。任一码字都不是另一个码字的前缀,所以不需要在符号之间另加分隔符。
平均码长为:
50% * 1 + 25% * 2 + 12.5% * 3 + 12.5% * 3 = 1.75 bit / symbol
这个例子恰好达到熵,是因为各概率能对应 1、2、3 bit 的整数码长。一般分布不会如此整齐;逐符号前缀码的码长只能是整数 bit,平均值可以是小数,却不一定等于熵。Huffman 原论文 的 minimum-redundancy 结论也有明确范围:已知权重、可即时解码的逐符号二进制码。
Huffman 表本身也有成本。小块若单独统计和保存一棵树,元数据可能抵消收益;固定表省去传表成本,却未必适合当前分布。解码还要在查表大小、缓存占用与每次读取 bit 数之间取舍。
HTTP/2 HPACK 提供一个边界清楚的应用:字符串 literal 由
H标志选择是否使用 Appendix B 固定 canonical Huffman code。Huffman 只作用于字符串 literal;HPACK 的静态表、动态表和索引表示是另外的机制。
5.2 FSE:用状态分摊码长#
先看同一串输入在两种编码下得到什么。AAABAAAB 只有 A、B 两种符号,其中 A 出现 6 次,B 出现 2 次。二元 Huffman 即使知道 A 更常见,也只能给每个符号分配至少 1 bit,例如 A=0、B=1:
AAABAAAB -> 00010001 -> 8 bit下面这张 8 状态 FSE 教学表可以把同一输入表示为:
AAABAAAB -> 111 | 1 00 0 -> 7 bit
状态 补充选择位这里的 111 是最终编码状态 7,也是解码器的初始状态,占 3 bit;1 | 00 | 0 是按解码器消费顺序写出的补充选择位,共 4 bit。先不要把这 7 bit 当成完整文件大小:它不包含概率描述、字符数量、字节对齐、结束标记和外层 block,只是一份用来说明状态机如何工作的逻辑排布,也不是 Zstandard bitstream 的物理位序。
先把表当作双方共享的规则。
FSE(Finite State Entropy)是 tANS(Table-based ANS)的表驱动实现。这张教学表有 8 个状态槽,按照 A:75%、B:25% 的比例分配:
| 符号 | 次数 | 状态槽数 | 本例对应状态 | 表项实际读取 |
|---|---|---|---|---|
| A | 6 | 6 | 0、1、3、4、6、7 |
0 或 1 bit |
| B | 2 | 2 | 2、5 |
2 bit |
概率只决定 A 占 6 格、B 占 2 格;具体落在哪些位置,由确定的建表算法决定。编码格式通常传递归一化频率、表大小等紧凑描述,或者使用预定义表、复用旧表。编码器和解码器按照同一套规则重建完整表,不需要逐行保存下面这些结果。
| 当前状态 | 输出 | 读取补充选择位 | 下一状态 |
|---|---|---|---|
| 0 | A | 1 bit | 4-5 |
| 1 | A | 1 bit | 6-7 |
| 2 | B | 2 bit | 0-3 |
| 3 | A | 0 bit | 0 |
| 4 | A | 0 bit | 1 |
| 5 | B | 2 bit | 4-7 |
| 6 | A | 0 bit | 2 |
| 7 | A | 0 bit | 3 |
每一行都是一条小型解码指令:当前状态先确定输出字符,再从压缩数据流读取指定数量的补充选择位,最后得到下一状态。例如状态 0 已经确定输出 A,但下一状态可能是 4 或 5,所以读取 1 bit:读到 0 去状态 4,读到 1 去状态 5。状态 7 输出 A 后只能去状态 3,因此不需要读取 bit。
“读取 0 bit”不表示这个 A 被删除了。状态已经确定本步输出 A,也唯一确定了下一状态,所以不需要再从压缩数据流补充选择信息。
先从 7 bit 正向解码。
解码器取得初始状态 111 = 7,并把 1000 当作等待读取的补充选择位流:
| 当前状态 | 输出 | 消费 | 下一状态 | 剩余补充选择位 |
|---|---|---|---|---|
| 7 | A | 无 | 3 | 1000 |
| 3 | A | 无 | 0 | 1000 |
| 0 | A | 1 |
5 | 000 |
| 5 | B | 00 |
4 | 0 |
| 4 | A | 无 | 1 | 0 |
| 1 | A | 0 |
6 | 空 |
| 6 | A | 无 | 2 | 空 |
| 2 | B | 停止 | - | 空 |
完整状态路径是 7 -> 3 -> 0 -> 5 -> 4 -> 1 -> 6 -> 2。把每一步输出连起来,正好得到 AAABAAAB。字符数量由外层元数据提供;解码器输出第 8 个字符后停止,不再为了不存在的第 9 个字符执行状态 2 的迁移。
再反过来看编码器。
FSE 的状态在逻辑上像一个栈:编码把字符压入状态,解码再按相反顺序弹出。为了让解码器从左到右输出 AAABAAAB,编码器需要从最右侧的 B 开始,按照 BAAABAAA 的顺序处理。
解码表描述“当前状态怎样输出字符并到达下一状态”,编码表则是它的反向索引:输入“要加入的字符”和“已经表示后缀的状态”,直接得到前驱状态以及需要保存的补充选择位。
本例还需要明确一条教学初始化规则:
seed(B) = 状态 2状态 5 同样对应 B,因此不能仅凭“最后一个字符是 B”推出状态一定是 2。真实实现由编码表和初始化规则选定合法状态;选择状态 5 也可能形成另一份合法编码。编码结果不要求唯一,只要求给定表、状态和 bit 流后能够唯一解码。
采用上面的教学初始化规则,编码器从右向左构造后缀:
| 加入 | 新后缀 | 状态 | 本步压入 | 累计补充位 |
|---|---|---|---|---|
| 初始化 | B |
2 | 无 | 空 |
| A | AB |
6 | 无 | 空 |
| A | AAB |
1 | 0 |
0 |
| A | AAAB |
4 | 无 | 0 |
| B | BAAAB |
5 | 00 |
00 | 0 |
| A | ABAAAB |
0 | 1 |
1 | 00 | 0 |
| A | AABAAAB |
3 | 无 | 1 | 00 | 0 |
| A | AAABAAAB |
7 | 无 | 1 | 00 | 0 |
以第三行为例,编码器查询 encode[A][6],得到前驱状态 1 和补充选择位 0。解码时状态 1 先输出 A,再读取 0 进入状态 6;而状态 6 已经表示后缀 AB,所以继续解码就能得到 AAB。编码过程最后停在状态 7,把它写成 111,再加上逻辑补充选择位 1 | 00 | 0,就得到开头展示的 7 bit。
FSE 到底省了什么?
把两种编码的成本按字符排开:
字符: A A A B A A A B
Huffman: 1 1 1 1 1 1 1 1 = 8 bit
FSE 补充选择位:0 0 1 2 0 1 0 0 = 4 bit
FSE 状态: + 3 bitFSE 没有删除任何 A。八个字符仍由状态机逐个输出;它省掉的是高概率符号的独立码字,只有不能唯一确定下一状态时才读取补充选择位。A 占据更多状态,所以更多 A 表项能够用 0 或 1 bit 完成迁移;B 占据较少状态,本例的普通 B 表项需要 2 bit。
因此,连续的 AAA 不是 FSE 生效的必要条件。FSE 利用的是整条符号流的概率偏斜;把重复片段替换成 length、distance 等引用,是 LZ 负责的工作。这个分布的熵约为 0.811 bit / symbol,8 个符号的理想信息量约为 6.49 bit,教学结果 7 bit 正好说明状态编码可以越过二元 Huffman 的 1 bit / symbol 限制。
表不是免费的。
对只有 8 个字符的输入,正文虽然从 8 bit 降到教学口径的 7 bit,但归一化频率、表大小、字符数量和块头肯定会吃掉这 1 bit 收益,所以完整输出通常更大。流足够长时,表和初始状态的固定成本才能被摊薄。Zstandard 的 sequence symbol 会在 Predefined、RLE、FSE Compressed 和 Repeat 等模式之间选择,收益不足时不必建立新表。
RFC 8878 对 FSE 的定义给出了归一化计数、表项字段和状态更新规则。建表涉及符号铺排、计数器规范化和编码表反向索引,细节明显超出这段心智模型;需要实现 FSE 时,应继续沿规范或成熟实现深入,而不是从这张教学表反推生产代码。
6. 常见 Codec 怎样组合压缩原语#
这一节只讨论 LZ4、Snappy、gzip、Brotli 和 Zstandard 这五种 LZ 系 Codec,不把结论外推到所有通用压缩算法。它们接收的是不带字段、类型或记录边界语义的字节流。选择 LZ 压缩路径时,共同起点是寻找已经出现的片段,把输入拆成未匹配字节和向后引用;各格式对这些内容使用的字段名、合法范围和物理布局并不相同。
可以先记住一条共同主干:
字节流 -> 找重复 -> 紧凑引用记录 -> 可选的熵编码 -> bitstream“可选”很重要。LZ4 和 Snappy 把 literal/copy 类指令直接序列化为字节,不接熵编码后端;DEFLATE 和 Brotli 继续用 Huffman 表示符号;Zstandard 先把 literals 与 sequences 分开,再让各类流选择自己的表示。这里画的是压缩路径,也不表示一个格式支持的每种 block 都必须压缩。
| Codec | 重复消除 | 熵编码或特有机制 | 记忆点 |
|---|---|---|---|
| LZ4 | LZ77-like | 无熵编码 | 格式轻、解压快、压缩倍数较低 |
| Snappy | LZ77-like | 1-byte tag、无熵编码 | 字节对齐、吞吐优先 |
| gzip | DEFLATE / LZ77 | fixed / dynamic Huffman | 广泛兼容、带 CRC32 |
| Brotli | LZ77 + 静态字典引用 | Huffman + 上下文建模 | Web 传输、体积优先 |
| Zstandard | LZ77-like | Huffman / FSE,可跳过或复用 | 分流编码、兼顾体积与速度 |
最后一列只是理解设计和安排基准测试的起点,不是跨数据、实现和配置都成立的性能保证。下面沿用同一个输入:LZ4 讲清引用怎样替代重复字节,DEFLATE 再加一层 Huffman,Zstandard 最后展示分流;Snappy 和 Brotli 只展开它们特有的部分。
6.1 LZ4:找到重复,换成回拷指令#
输入是 17 bytes:
abcabcabcabc12345
-> literal("abc")
-> match(offset=3, length=9)
-> literal("12345")解码器先输出 abc。执行 match 时,它从当前位置向前看 3 bytes,连续回拷 9 bytes,得到 abcabcabc。原输入中这 9 个重复字节不再逐字保存,只需记录“向前 3 bytes、复制 9 bytes”。
按照 LZ4 v1.10.0 block format,这组逻辑可以写成一份合法的 raw block:
35 61 62 63 03 00 | 50 31 32 33 34 350x35 = 0011 0101:高 4 bit 表示后面有 3-byte literal;低 4 bit 的值为 5,加上minmatch=4,得到 match length 9。61 62 63是abc;03 00是 little-endian offset 3。0x50的高 4 bit 表示最后还有 5-byte literal,后面的31 32 33 34 35就是12345。LZ4 最后一条 sequence 只含 literals,因此到这里直接结束,不再读取 offset。
token 的任一长度 nibble 达到 15 时,格式会继续读取扩展长度。这个例子没有触发扩展:9 个重复字节由 0x35 的低半字节和 2-byte offset 描述,整个 raw block 从 17 bytes 变成 12 bytes。它不包含 frame 或解码所需的外部长度信息。
这是格式允许的一种表示,不代表每个 compressor 都会选中同一个 match。匹配搜索属于实现策略。LZ4 的心智模型很短:找到重复,把它换成回拷指令,然后停手。
6.2 Snappy:1-byte tag 是一条指令的入口#
Snappy format description 也使用 LZ77 类 literal/copy 表示,但 wire format 与 LZ4 不同。同一个输入可以写成下面这份合法的 Snappy raw block:
11 | 08 61 62 63 | 15 03 | 10 31 32 33 34 35第一个数字并不是 tag:
0x11 = 未压缩长度 17 的 little-endian Varint,不是 tag
tag = 每个 element 固定 1 byte 的头
element = tag + 按操作类型解释的后续字段或 literal 数据每个 tag 的低 2 bit 先告诉解码器这是什么操作:
00 Literal
01 Copy with 1-byte offset(Copy_1)
10 Copy with 2-byte offset(Copy_2)
11 Copy with 4-byte offset(Copy_4)tag 确实固定为 1 byte,但整个 element 不一定只有 1 byte。高 6 bit 也不能笼统叫作 length,它的含义由低 2 bit 的操作类型决定:
- 短 Literal 用高 6 bit 保存
length - 1;值 60 至 63 则表示后面用 1 至 4 bytes 保存更长的length - 1。 - Copy_1 把 bits 2-4 用作
length - 4,bits 5-7 用作 offset 的高 3 bit,后续 1 byte 保存 offset 的低 8 bit。 - Copy_2 和 Copy_4 用高 6 bit 保存
length - 1,再分别读取 2-byte 或 4-byte little-endian offset。
现在拆开例子里的两个 tag:
0x08 = 000010 | 00
length-1 Literal
-> 2 + 1 = 3 bytes,即 abc
0x15 = 000 | 101 | 01
offset高3 length-4 Copy_1
-> offset 高位为 0,低 8 bit 从后续 0x03 读取
-> offset=3,length=5+4=9末尾的 0x10 同样是 Literal tag:高 6 bit 为 4,因此后面跟 5 bytes 的 12345。中间 9 个重复字节只用 15 03 两个字节表示;算上原始长度 Varint 和两个 Literal tag,整个 raw block 是 13 bytes。
1-byte tag 的好处是解码器读一个字节就知道操作类型,以及接下来要读取什么,整个过程保持字节对齐。代价也直接写在格式里:每个 element 至少有一个 tag,较长 literal 或更远的 copy 还要追加固定字段,而且这些 tag 与 copy 参数不会再经过 Huffman/FSE 压缩。Snappy 因此容易解析,但会留下尚未利用的概率冗余。
Snappy raw block、Snappy framed format、LZ4 raw block 和 LZ4 frame 都是不同格式。格式规定怎样解释合法字节流;compressor 怎样寻找 match,仍由实现决定。本文实验使用 github.com/pierrec/lz4/v4 v4.1.22 的独立 frame 和 github.com/golang/snappy v1.0.0 的 raw block,后文数字只适用于这些实现与配置。
6.3 DEFLATE 与 gzip:回拷指令继续进入 Huffman#
DEFLATE 可以先得到与前面相同的逻辑匹配:
'a' 'b' 'c' Match(length=9, distance=3) '1' '2' '3' '4' '5'区别在下一步。RFC 1951 §3.2.5 把 literal 和 length code 放在同一个 alphabet,把 distance code 放在另一个 alphabet。本例对应的逻辑符号是:
Literal / Length alphabet:
'a' 'b' 'c' LengthCode(263) '1' '2' '3' '4' '5' EndOfBlock(256)
Distance alphabet:
DistanceCode(2)规范表中的映射是精确的:
LengthCode 263 -> length 9,0 extra bits
DistanceCode 2 -> backward distance 3,0 extra bitsDEFLATE 再用 Huffman code 表示这些 literal、length 和 distance symbol。Fixed Huffman block 使用 RFC 规定的固定 code lengths,不传当前块的新表;dynamic Huffman block 则把 literal/length 与 distance alphabet 的 code lengths 写在 block 中,这批 code lengths 又由第三套 code-length Huffman code 表示。
这里不手写最终 bitstream,因为它还取决于 block header、fixed/dynamic 选择、实际 Huffman code 和 bit packing。能确定的心智模型是:LZ 先省掉重复内容,Huffman 再缩短描述 literal、length 和 distance 的符号。
gzip 是外层封装,不是 DEFLATE bitstream 的另一个名字。RFC 1952 中,CM=8 表示 member 内使用 DEFLATE,结构可以简化为:
gzip member = header + DEFLATE compressed data + CRC32 + ISIZECRC32 和 ISIZE 位于 trailer。一个 gzip 文件还可以顺序包含多个 member。
6.4 Brotli:格式内置字典和上下文#
Brotli 仍然沿用 LZ77 + Huffman,但比 DEFLATE 多了两个值得单独记住的机制。
第一个是格式内置的静态字典。RFC 7932 §8 规定,解码出的 distance 超过当前位置允许的最大历史回看距离时,它不再指向已有输出,而是指向 Appendix A 的静态字典数据。copy length 必须在 4 至 24 之间;解码器用 length 与 distance 算出基础词索引和 transform ID,再应用 Appendix B 定义的 121 种 word transformation。
普通 HTTP br 不会在连接中另发一份这张字典:
Accept-Encoding: br
Content-Encoding: br前者表示客户端接受 Brotli content coding,后者表示响应实际应用了它。双方之所以能解释同一个字典引用,是因为字典数据和 transformation 都属于 RFC 7932 格式;合规解码器按规范实现,编码器可以选择是否使用字典。这里没有独立的字典协商。外部共享字典使用 RFC 9842 定义的 dcb content coding,它在 IANA 注册表中也是独立条目,不属于普通 br 路径。
第二个机制是上下文建模。Brotli 可以为同类符号准备多套 prefix code:literal 根据前两个已解码字节等上下文选择 code,distance 则可以根据本条命令的 copy length 选择 code。上下文在这里用于选择概率模型,并不是字段级语义。
Brotli 的心智模型可以写成:LZ + Huffman,再加格式内置词表和按上下文切换的 prefix code。它常见于 HTTP 响应压缩,是否使用仍取决于客户端、网关、CDN 与服务端支持范围。
6.5 Zstandard:先拆 section,再分别选择表示#
Zstandard frame 中的 block 可以是 Raw、RLE 或 Compressed。只有进入 Compressed block,才会继续拆成 Literals Section 和 Sequences Section。RFC 8878 还建议:如果压缩块比原始块更大,应改发 Raw block。
沿用同一个输入,一份逻辑分流可以写成:
Literals Section:
a b c 1 2 3 4 5
Sequences Section(逻辑值):
literals_length = 3
match_length = 9
backward distance = 3执行这条 sequence 时,解码器先从 Literals Section 取出 abc,再从当前输出向前 3 bytes 回拷 9 bytes。所有 sequences 执行完后,Literals Section 还剩 12345,它们被追加到块尾,最终仍是 abcabcabcabc12345。
这里的 backward distance = 3 只是便于理解的语义值。Zstandard 的物理格式保存 offset code/value,并带有 recent-offset 规则,不能把语义距离直接当成 wire symbol。
分流以后,各部分独立选择表示:
Block:
Raw / RLE / Compressed
Literals Section:
Raw / RLE / Compressed(Huffman) / Treeless(复用 Huffman 表)
Literal Length、Match Length、Offset 三类 sequence codes:
Predefined / RLE / FSE Compressed / Repeat(复用 sequence table)这个小例子只有一条 sequence,因此每类 sequence code 也只有一个符号。它只能解释“怎样分流”,不能证明 FSE 能省多少。RFC 8878 §3.1.1.3.2 明确规定,只有单一符号时不得使用 FSE_Compressed_Mode,应该使用 RLE_Mode;规范也允许其他可工作的模式。更长的符号流出现稳定概率偏斜时,建立或复用 FSE table 才可能值得。
所以 Zstandard 并不是“所有内容依次经过 LZ、Huffman、FSE”。它先把原文字节和回拷指令分流,再让不同符号流单独决定是原样保存、RLE、熵编码还是复用已有表。压缩级别调整的是 compressor 的匹配搜索与表示决策预算,不是把 FSE “开得更强”。
7. 系统里的组合#
在实际使用中,同一个软件系统可以在列、页、索引 Key、SST block 或持久化对象上使用不同压缩。
-
Parquet 与 ORC
在 Parquet Format 2.13.0 中,column chunk 内的 page 先按物理类型和数据分布选择表示:布尔值、字典 ID 和层级流可走 RLE/bit-packing hybrid,字符串等值可进入 dictionary page,
INT32/INT64还可选择DELTA_BINARY_PACKED。形成 encoded data block 后,文件元数据再指定 SNAPPY、GZIP、ZSTD、LZ4_RAW 等通用 Codec。该规范没有全局默认 Codec;旧LZ4标识已经废弃,与LZ4_RAW是不同的格式枚举。Apache ORC 2.3.1 发布的 ORC v1 规范 把列拆成不同 stream:byte、boolean 和 integer stream 分别有对应的 RLE/bit packing 规则,字符串列可在 direct 与 dictionary encoding 之间选择。这里讨论的是 stripe/column stream 的表示方式;不同 stream 采用各自的编码规则,文件级默认 Codec 不在这项证据范围内。
这两种格式都有分层能力,但实现并不相同:先让列编码利用类型信息,再让页或流的字节块进入通用压缩。
-
ClickHouse
ClickHouse 26.7.3.19-stable 允许列声明
CODEC(...)pipeline。Delta、DoubleDelta、Gorilla等先利用列类型和相邻值改写表示,再与LZ4、ZSTD等通用 Codec 组合;Delta和DoubleDelta不能单独作为完整 pipeline。显式列配置与环境默认必须分开:该固定版本文档写明 self-managed 默认 LZ4,ClickHouse Cloud 默认 Zstd。低基数字符串还有另一层选择:只有显式声明
LowCardinality(T)的列使用字典和短引用,高基数时可能适得其反。简化写入链路可以写成“列值 -> 可选数据准备 Codec / LowCardinality 表示 -> LZ4 或 ZSTD -> 列压缩块”。 -
RocksDB 与 LevelDB
RocksDB 11.8.0
BlockBuilder在 BlockBasedTable 的 key entry 中保存与前一个 Key 的共享前缀,并周期性写shared_bytes = 0的完整 restart key 与 offset。value 不走这条前缀编码。形成 SST block 后,ColumnFamilyOptions::compression再决定通用块压缩:编译时支持 LZ4 则该版本默认 LZ4,否则默认 Snappy;两者都不可用时不压缩。其他 Codec 和 bottommost compression 可以显式配置。LevelDB 固定 commit 同样只对 table data block 的 Key 保存 shared prefix 和 suffix,value 原样保存;默认
block_restart_interval = 16。LevelDB 的行为以该固定 commit 为准。这里比较的范围限于 table data block,不涉及 WAL、memtable、filter block 或 compaction 策略。 -
Lucene
Lucene 10.5.0 的 Lucene104 postings writer 先对有序 doc ID 形成 delta block,再按分布在 FOR packed integers 与 unary/bit-set 等路径之间选择。它利用的是文档编号的局部范围;这一选择适用于该版本 Lucene104 postings writer 的 doc ID block,其他 postings 与整数编码路径可能不同。
term dictionary 是另一种表示。Lucene103 BlockTree 把共享公共前缀的 terms 放入 block,entry 保存相对 block prefix 的 suffix,term index/trie 再把前缀映射到 block。其格式与 RocksDB 的“前一 Key + restart interval”不同。这里讨论的是 postings 与 term dictionary 的局部编码;stored fields、doc values 等其他文件格式各有自己的编码路径。
-
Redis
Redis 8.10.0
rdb.c在保存 RDB string object 时按对象决定是否使用 LZF。rdbcompression yes默认开启,但字符串要长于 20 bytes,并且压缩后至少节省 4 bytes,才保存压缩长度、原长度和 LZF 数据。条件不满足就写原字符串。这个路径说明,真实系统通常不会无条件压缩每个对象。对象较小、可压缩性差异很大,或者格式允许逐对象选择时,“尝试压缩 + 最小收益门槛 + 原样回退”比强制进入同一 Codec 更实用。这个结论适用于 RDB string object;Redis 内存对象、AOF 和网络协议是其他数据路径。
-
Prometheus
Prometheus 3.13.2
chunkenc.XORChunk把 float sample 的时间戳和值一起编码进 chunk:时间戳保存 delta-of-delta,浮点值按 IEEE-754 位模式 XOR,并复用 leading/trailing-zero window。Prometheus 为毫秒时间戳调整了 Gorilla 的范围,因此它沿用 Gorilla 的思路,但 bitstream 与原论文并不完全相同。这一机制针对 float sample chunk。Histogram chunk 以及 chunk 外层是否另用 LZ4 或 Zstandard 属于其他路径,应分别确认数据对象与读取粒度。
8. 用三类数据验证#
实验口径#
实验在 Apple M2 上完成,通用 Codec 统一使用 1 MiB 独立块,不共享跨块状态,也不使用预训练字典。三类输入均约 256 MiB:结构化日志、单调 uint64,以及 timestamp + float64 时序样本。
表中数据是在这组输入、库版本、单线程和块大小下测得的中位结果。数据感知编码轨道验证字节数和往返 SHA,不测吞吐;通用 Codec 轨道直接测成熟库。两条轨道用途不同:先看原始表示,再决定是否进入通用 Codec。
通用 Codec 的 1 MiB 分块结果如下。体积占比越低,留下的字节越少;压缩和解压速度都是按原始体积计算的中位 MiB/s。
结构化日志#
| Codec | 体积占比 | 压缩 MiB/s | 解压 MiB/s |
|---|---|---|---|
| LZ4 | 0.237 | 566 | 1421 |
| Snappy | 0.242 | 1506 | 3143 |
| gzip-6 | 0.130 | 115 | 466 |
| Zstd-1 | 0.131 | 481 | 1102 |
| Zstd-3 | 0.137 | 341 | 1024 |
| Zstd-9 | 0.128 | 315 | 1193 |
结构化日志中,Snappy 以 1506 MiB/s 压缩到 0.242;Zstd-9 体积最低,为 0.128。日志有重复字段名和相似消息,CPU 紧就选快速路径,带宽贵再考虑更小体积。
整数列#
| Codec | 体积占比 | 压缩 MiB/s | 解压 MiB/s |
|---|---|---|---|
| LZ4 | 0.501 | 385 | 743 |
| Snappy | 0.501 | 771 | 1793 |
| gzip-6 | 0.190 | 18 | 237 |
| Zstd-1 | 0.130 | 317 | 535 |
| Zstd-3 | 0.130 | 329 | 551 |
| Zstd-9 | 0.129 | 239 | 511 |
整数列中,Zstandard 三个级别都在 0.130 左右,LZ4/Snappy 约 0.501。同一数据在教学轨道里经 Delta + Bit Packing 后到 0.048,说明这类数据应该先试数值编码。
时序数据#
| Codec | 体积占比 | 压缩 MiB/s | 解压 MiB/s |
|---|---|---|---|
| LZ4 | 0.850 | 205 | 884 |
| Snappy | 0.815 | 720 | 2543 |
| gzip-6 | 0.641 | 26 | 133 |
| Zstd-1 | 0.594 | 213 | 470 |
| Zstd-3 | 0.621 | 91 | 426 |
| Zstd-9 | 0.622 | 54 | 354 |
时序数据中,原始输入把 timestamp 和 float64 交错保存,Zstd-1 最小,为 0.594;Snappy 压缩最快,为 720 MiB/s。若把时间和值拆成两条流,Delta-of-Delta 和 Gorilla 才有发挥空间。
9. 总结:先识别数据,再选择算法#
三个常见误区校正:
- 编码不是低配版压缩器。 它利用的是通用字节 Codec 看不到的类型信息,数据库常把两层串起来。
- 最高压缩倍数不等于最低总成本。 少读 I/O 可能值得更多 CPU,也可能让在线写入和尾延迟更差。
- 解压通常更靠近在线查询关键路径,但并非所有系统都读多写少。 数据生命周期、compaction 与恢复路径会改变 CPU 应该花在哪里。
服务端压缩可以按三层理解。第一层是数据感知编码,利用字段类型、顺序和分布;第二层是 LZ 这种面向字节的重复模式;第三层是 Huffman、FSE 等熵编码,利用普通字节流中的重复和概率。至于常说的 LZ4、Snappy、Zstandard 这种通用 Codec,一般都是利用 第二层 和 第三层进行组合参数调优的一层包装。
落到具体系统,第一问不该是“LZ4 还是 Zstd”,而是数据是否有 schema、顺序和稳定分布。若能识别低基数、长游程、局部整数范围、公共 Key 前缀或固定采样周期,先测试数据感知编码;它可能比提高通用 Codec 级别更直接,也可能让后续 Codec 看到完全不同的字节流。
然后按约束缩小候选:
- 读路径延迟: 在线查询是否每次都要解压?重点看解压尾延迟、批量解码和最小读取块,而不只看压缩吞吐。
- CPU 与生命周期: 写入能否把工作移到 compaction、merge 或离线归档?若写入也在关键路径,就要给压缩 CPU 明确预算。
- 空间与网络: SSD、对象存储和跨机房流量哪一项最贵?只有真实节省的字节才能抵扣 CPU。
- 兼容性: 对端要求 gzip member、Snappy block、LZ4 frame 还是某个文件格式枚举?同名 LZ 思路不代表 wire format 可交换。
- 随机访问: 页、块和 restart interval 会决定点查读放大与工作内存,不能只用整文件体积选择。
- 高熵与小对象: 保留“不压缩”和收益门槛;若压缩后没有至少覆盖 header 与 CPU 的收益,直接回退。
下面的矩阵给出起测点,不是生产默认答案:
| 场景 | 先处理的数据与块 | Codec 起点 | 重点验证 |
|---|---|---|---|
| 在线查询 | 列编码、较小可寻址块 | LZ4、Snappy、Zstandard 低级别 | 解压尾延迟、读放大、缓存与并发内存 |
| 写入 / Compaction | 在后台整理 Delta、FOR、Dictionary | Zstandard 与快速 Codec 对照 | 压缩 CPU 是否挤占写入,compaction 总时长 |
| 日志 / 消息传输 | 批量相似记录,固定 batch 边界 | LZ4、Snappy、Zstandard;兼容时 gzip | 生产者 CPU、broker 是否转换、网络节省 |
| 时序 chunk | Delta-of-Delta、Gorilla XOR 分流 | 快速 Codec、Zstandard、不再压缩 | 样本抖动、chunk 查询范围、二次压缩收益 |
| 冷数据 / 备份 | 较大块,恢复边界清楚 | Zstandard;要求既有格式时 gzip | 离线压缩时间、恢复吞吐、长期兼容性 |
最后,先识别数据分布,再把 CPU、块边界和读取路径一起测出来;这比单看压缩倍数可靠得多。
参考资料#
- C. E. Shannon, A Mathematical Theory of Communication, 1948;D. A. Huffman, A Method for the Construction of Minimum-Redundancy Codes, 1952;J. Ziv, A. Lempel, A Universal Algorithm for Sequential Data Compression, 1977
- T. Pelkonen 等, Gorilla: A Fast, Scalable, In-Memory Time Series Database, VLDB 2015;Prometheus 3.13.2 XORChunk
- RFC 与标准:DEFLATE / RFC 1951、HPACK / RFC 7541、Zstandard / RFC 8878、Brotli / RFC 7932、IANA HTTP Content Coding Registry
- 快速 LZ 格式:LZ4 v1.10.0 Block Format、Snappy 1.2.1 format description
- 列式格式文档:Apache Parquet Format 2.13.0 的 Encodings 与 Compression;Apache ORC 2.3.1 / ORC v1 Specification
- ClickHouse 26.7.3.19-stable:LowCardinality、Column Compression Codecs
- LSM 与索引源码:RocksDB 11.8.0 的 BlockBuilder 与 compression options;LevelDB BlockBuilder 与 restart
- Lucene 10.5.0:Lucene104 postings、Lucene103 BlockTree
- Redis 8.10.0:RDB LZF path、
rdbcompressiondefault