1977 年,Abraham Lempel 和 Jacob Ziv 在 IEEE Transactions on Information Theory 上发表了一篇不到十页的论文,提出了一种基于滑动窗口的无损压缩方法。一年后,他们又发表了另一篇论文,换用显式字典的思路重新解决同一问题。这两篇论文分别催生了 LZ77 和 LZ78 两大家族,至今仍然是几乎所有通用压缩算法的理论根基。
本文从原始论文出发,沿着”滑动窗口”和”显式字典”两条路线展开,覆盖 LZW 的专利风波、LZ4 和 Snappy 的工程取舍、LZMA 的极限压缩,并给出完整的 C 实现和基准测试数据,最后谈谈工程实践中的常见陷阱。
一、从信息论到字典压缩
Shannon 在 1948 年奠定了信息论的基础,证明了无损压缩的理论极限—信息熵。但 Shannon 编码本身并不实用,真正让压缩走进工程的是 Huffman 编码(1952 年)和算术编码(1976 年)。然而,这些方法都属于”统计模型”一族:它们对符号的出现频率建模,用短码字表示高频符号。
字典压缩走了一条完全不同的路:不去估计符号频率,而是直接在已处理过的数据中寻找重复片段,用简短的”引用”替换冗余内容。这个思路有几个天然的优势:
- 不需要预先扫描数据来建立频率表,可以单遍(single-pass)完成压缩。
- 隐式地利用了数据的高阶结构,而不仅仅是一阶或二阶的符号频率。
- 解压速度极快,因为解压器只需要做复制操作,没有复杂的算术运算。
Lempel 和 Ziv 的贡献在于,他们用严格的数学语言证明了:对于平稳遍历信源,他们的方法可以渐近地逼近信息熵率。换句话说,字典压缩在理论上并不比统计方法差,而在工程上往往更快。
这两条路线的分野在于”字典从哪里来”:
- LZ77 路线:字典就是已经编码的数据本身(滑动窗口)。
- LZ78 路线:编码器和解码器同步维护一个显式字典。
下面我们分别展开。
二、LZ77:滑动窗口
核心思想
LZ77 的编码器维护一个固定大小的滑动窗口,窗口分为两部分:
- 搜索缓冲区(Search Buffer):已经编码的数据,充当”字典”。
- 前瞻缓冲区(Lookahead Buffer):即将编码的数据。
编码器在搜索缓冲区中寻找与前瞻缓冲区最长的匹配,然后输出一个三元组
(offset, length, next_char):
offset:匹配位置相对于当前位置的偏移量。length:匹配的长度。next_char:匹配之后的下一个字符。
如果没有找到匹配,则输出
(0, 0, char),即直接编码一个字面字符。
编码过程示例
假设搜索缓冲区大小为 7,前瞻缓冲区大小为 5,输入数据为
ABCABCDABCDE:
步骤 搜索缓冲区 前瞻缓冲区 输出
1 (empty) ABCAB (0,0,'A')
2 A BCABC (0,0,'B')
3 AB CABCD (0,0,'C')
4 ABC ABCDA (3,3,'D') -- 回退3,复制ABC,下一个D
5 ABCDABC DABCD (3,3,'D') -- 不对,重新看
5 CABCDAB CDE.. (7,4,'E') -- 回退7,复制ABCD,下一个E
这里第 4 步值得注意:offset=3
意味着从当前位置往回数 3
个字符开始复制,length=3 表示复制 3
个字符。在搜索缓冲区 ABC 中找到了与前瞻缓冲区
ABC 的完全匹配。
滑动窗口的工程参数
| 参数 | DEFLATE (zlib) | LZ4 | LZMA |
|---|---|---|---|
| 窗口大小 | 32 KB | 64 KB | 最大 4 GB |
| 最小匹配长度 | 3 | 4 | 2 |
| 最大匹配长度 | 258 | 65535 | 无限制(实际受窗口约束) |
| 输出格式 | (len, dist) + Huffman | sequence format | range coder |
窗口越大,能够引用的历史数据越多,压缩率越高,但搜索时间和内存消耗也随之增长。这是一个工程上的核心权衡。
LZ77 的变体
原始 LZ77 输出三元组
(offset, length, next_char)
有一个缺点:即使没有匹配,也必须输出一个完整的三元组((0, 0, char)),浪费空间。后来的实际实现几乎都做了改进:
- LZSS(Lempel-Ziv-Storer-Szymanski, 1982):用一个标志位区分”字面字符”和”匹配引用”,不再强制输出三元组。DEFLATE 就是基于 LZSS 的。
- LZ4:进一步简化 token 格式,追求解压速度。
- LZMA:用范围编码器(range coder)替代 Huffman 编码来编码 LZ77 的输出,获得更高的压缩率。
三、LZ78:显式字典
与 LZ77 的本质区别
LZ78
不使用滑动窗口,而是维护一个从空开始逐步构建的显式字典。编码过程中,每遇到一个新的”短语”(phrase),就将其加入字典并分配一个编号。输出是二元组
(index, char):
index:字典中最长匹配条目的编号(0 表示空串)。char:最长匹配之后的下一个字符。
编码过程示例
输入 ABCABCABD:
步骤 当前串 最长匹配 输出 新增字典条目
1 A (none) (0,'A') 1: A
2 B (none) (0,'B') 2: B
3 C (none) (0,'C') 3: C
4 AB A -> 条目1 (1,'B') 4: AB
5 CA C -> 条目3 (3,'A') 5: CA
6 BD B -> 条目2 (2,'D') 6: BD
LZ78 的优势与劣势
优势:
- 解码器不需要维护滑动窗口,只需要一个字典表,随机访问友好。
- 字典条目可以用整数索引,编码紧凑。
- 适合硬件实现(字典查找可以用 trie 或哈希表加速)。
劣势:
- 字典会持续增长,需要某种策略来限制大小(如 LRU 淘汰、冻结、重置)。
- 对于局部性很强的数据(如日志文件),LZ77 的滑动窗口天然适配,而 LZ78 的全局字典可能不够灵敏。
- 在实际基准测试中,LZ78 家族的压缩率通常不如经过优化的 LZ77 变体。
LZ77 vs LZ78 对比
LZ77 LZ78
+--------------------------+ +--------------------------+
| 字典 = 滑动窗口 | | 字典 = 显式表 |
| 输出: (offset, len, char)| | 输出: (index, char) |
| 字典自动"遗忘"旧数据 | | 字典只增不减(除非重置) |
| 擅长局部重复 | | 擅长全局重复 |
| 搜索开销: O(W*L) | | 搜索开销: O(L) (trie) |
+--------------------------+ +--------------------------+
| |
v v
DEFLATE, LZ4, LZMA LZW, LZC, LZMW
四、LZW:专利风波与 GIF 争议
从 LZ78 到 LZW
1984 年,Terry Welch 在 LZ78
的基础上做了一个关键简化:去掉输出中的 char
部分,只输出字典索引。新短语的构造规则变为”当前匹配串 +
下一个匹配的首字符”。这个变体被称为
LZW(Lempel-Ziv-Welch)。
LZW 的编码过程:
输入: ABABCABC
初始字典: A=0, B=1, C=2 (所有单字符)
步骤 当前串 匹配? 输出 新增条目
1 A yes - -
2 AB no 0(A) 3: AB
3 B yes - -
4 BA no 1(B) 4: BA
5 A yes - -
6 AB yes - -
7 ABC no 3(AB) 5: ABC
8 C yes - -
9 CA no 2(C) 6: CA
10 A yes - -
11 AB yes - -
12 ABC yes - -
(EOF) 5(ABC)
输出序列: 0, 1, 3, 2, 5
LZW 的巧妙之处在于解码器可以同步重建字典,不需要传输字典本身。
专利风波
LZW 算法的专利归属引发了开源社区的一场重大争议:
- 1983 年:Sperry Corporation(后并入 Unisys)获得 LZW 专利(US Patent 4,558,302)。
- 1987 年:GIF 格式被创建,内部使用 LZW 压缩。当时 CompuServe 并不知道 LZW 已经被申请了专利。
- 1994 年:Unisys 开始对使用 LZW 的软件收取专利授权费。
- 1995 年:开源社区发起 “Burn All GIFs” 运动,推动 PNG 格式的诞生。PNG 使用 DEFLATE(LZ77 + Huffman),完全避开了 LZW 专利。
- 2003 年:LZW 在美国的专利到期。
- 2004 年:LZW 在全球范围内的所有专利到期。
这场争议深刻地影响了自由软件运动和文件格式的选择。直到今天,GIF 的使用已经不再有专利问题,但 PNG 在网络上的地位早已确立。
技术评价
抛开专利问题,LZW 在技术上有几个值得注意的特点:
- 编码输出只有字典索引,没有字面字符,格式非常简洁。
- 字典的初始内容是所有单字符(对于 8 位字节就是 256 个条目),不需要额外传输。
- 字典满了之后的策略对压缩率影响很大。GIF 选择的是”重置字典重新开始”,UNIX compress 命令也是如此。
- 由于字典条目是递增编号的,可以用变长编码(如 9-bit、10-bit、…、12-bit)来编码索引,进一步节省空间。
五、LZ4:速度优先的 LZ77 变体
设计哲学
LZ4 由 Yann Collet 在 2011 年发布,目标非常明确:解压速度优先,压缩率够用就行。
传统的 LZ77 实现(如 zlib)在压缩率和速度之间寻求平衡,而 LZ4 则激进地倾向速度一端。它的设计决策包括:
- 极简的 token 格式:一个字节的 token 同时编码字面量长度和匹配长度,避免位级别的操作。
- 固定的窗口大小(64 KB):使得偏移量始终可以用 2 字节表示。
- 最小匹配长度为 4:减少短匹配的编码开销。
- 不做熵编码:输出是字节对齐的,没有 Huffman 或算术编码这一步。
LZ4 帧格式
LZ4 Sequence:
+----------+----------------+---------+----------------+
| Token | Literal Len | Literals| Match Offset | Match Len
| (1 byte) | (0+ bytes) | (N bytes)| (2 bytes, LE) | (0+ bytes)
+----------+----------------+---------+----------------+
Token byte:
High 4 bits = literal length (0-14, 15 means more bytes follow)
Low 4 bits = match length - 4 (0-14, 15 means more bytes follow)
Length encoding (if nibble == 15):
Read additional bytes, each 0-254 adds to length, 255 means continue.
性能数据
在 Silesia 语料库上的典型表现(单线程,x86-64):
LZ4 default:
压缩速度: ~780 MB/s
解压速度: ~4200 MB/s
压缩率: ~2.1:1
LZ4-HC (level 9):
压缩速度: ~40 MB/s
解压速度: ~4200 MB/s (解压速度不变!)
压缩率: ~2.7:1
这里有一个关键洞察:LZ4-HC 通过更细致的匹配搜索来提高压缩率,但解压速度完全不受影响,因为解压器看到的是同样格式的数据。这种”压缩时间换压缩率,解压时间不变”的特性非常适合”写一次、读多次”的场景。
LZ4 的应用场景
- Linux 内核:从 3.11 版本开始支持 LZ4 作为内核解压算法,用于 initramfs 和 zram。
- ZFS:默认的压缩算法之一。
- 数据库:ClickHouse、RocksDB 等使用 LZ4 压缩数据块。
- 实时网络:帧间数据压缩,要求亚毫秒级延迟。
六、Snappy:Google 的工程选择
背景
Snappy 由 Google 工程师 Jeff Dean 等人开发,最初名为 “Zippy”,于 2011 年开源。和 LZ4 一样,Snappy 也是一个速度优先的 LZ77 变体,但设计目标略有不同:
- 稳定的压缩速度:在所有输入上都保持高速,不会因为不可压缩数据而显著变慢。
- 合理的压缩率:通常是 zlib 的 50%-60%,但速度是 zlib 的 10 倍以上。
- 64 位友好:充分利用 64 位寄存器和内存操作。
Snappy vs LZ4
两者的设计理念非常接近,但细节上有差异:
Snappy LZ4
最小匹配长度 4 4
最大偏移量 32768 (LZ4: 65535) 65535
压缩速度 ~550 MB/s ~780 MB/s
解压速度 ~1800 MB/s ~4200 MB/s
压缩率(Silesia) ~2.0:1 ~2.1:1
字节对齐 yes yes
流式支持 有限 完善(LZ4 Frame)
从数据上看,LZ4 在速度和压缩率上都略占优势。但 Snappy 在 Google 内部有深厚的生态基础,被广泛用于:
- LevelDB / RocksDB:SSTable 的默认压缩格式。
- Bigtable / Spanner:Google 内部存储系统。
- Protocol Buffers:与 gRPC 配合使用的压缩选项。
- Hadoop / Parquet:大数据生态中的标准压缩格式之一。
为什么 Google 没有直接用 LZ4
这是一个经常被问到的问题。主要原因是时间线:Snappy 在 Google 内部的前身 Zippy 诞生于 2005 年左右,而 LZ4 直到 2011 年才出现。当 LZ4 出现时,Google 的整个基础设施已经深度集成了 Snappy,迁移成本远大于性能收益。
这是工程中常见的”路径依赖”现象:技术选型往往不是在某个时间点上比较所有选项然后选最优的,而是随着时间推移逐步演化的结果。
七、匹配查找算法
匹配查找(match finding)是 LZ77 压缩器中最核心也最耗时的部分。给定前瞻缓冲区的内容,如何在搜索缓冲区中快速找到最长匹配?不同的算法在速度和压缩率之间做出不同的取舍。
哈希链(Hash Chain)
这是最简单也最常用的方法,zlib 默认就是用哈希链。
算法:
1. 对前瞻缓冲区的前 3 个字节计算哈希值 h。
2. hash_table[h] 存储了搜索缓冲区中哈希值为 h 的最近一个位置 p。
3. prev[p] 链接到上一个哈希值相同的位置,形成链表。
4. 沿着链表遍历,逐个比较实际内容,找到最长匹配。
5. 遍历的深度可以通过参数限制(max_chain_length)。
hash_table[h]
|
v
+-----+ +-----+ +-----+ +-----+
| pos4 | -> | pos3 | -> | pos1 | -> | NULL |
+-----+ +-----+ +-----+ +-----+
(newest) (oldest)
zlib 的 compression level 1-9
主要就是通过调整 max_chain_length 和
nice_match 来控制搜索深度:
Level max_chain nice_match 策略
1 4 8 快速,粗糙
4 16 16 中等
6 128 128 默认
9 4096 258 最大压缩
二叉搜索树(Binary Tree)
7-Zip 的 LZMA 编码器使用二叉搜索树来组织匹配候选。
思路:
将搜索缓冲区中的所有后缀插入一棵二叉搜索树,
按字典序排列。查找最长匹配等价于在 BST 中搜索。
优势:
- 每次搜索保证 O(log n) 的最坏情况。
- 可以同时获得多个候选匹配,方便"懒惰匹配"优化。
劣势:
- 维护 BST 的开销比哈希链大。
- 缓存局部性较差。
HC4 与 BT4
7-Zip/LZMA SDK 提供了两种主要的匹配查找器:
- HC4(Hash Chain 4):使用 4 字节哈希的哈希链。适合中等压缩级别。
- BT4(Binary Tree 4):使用 4 字节哈希的二叉搜索树。适合最高压缩级别。
/* HC4 哈希函数示例 (4 字节哈希) */
static uint32_t hash4(const uint8_t *p) {
uint32_t v = *(const uint32_t *)p;
/* 乘法哈希: 将 4 字节映射到哈希表索引 */
return (v * 2654435761U) >> (32 - HASH_BITS);
}匹配查找器的选择建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 实时压缩 | 哈希链,短链 | 速度优先 |
| 通用压缩 | 哈希链,中等链长 | 平衡 |
| 归档压缩 | 二叉树 (BT4) | 压缩率优先 |
| 流式压缩 | 哈希链 + 滚动哈希 | 内存受限 |
八、LZMA 与 LZMA2:极限压缩
LZMA 的架构
LZMA(Lempel-Ziv-Markov chain Algorithm)由 Igor Pavlov 为 7-Zip 开发,其核心思路是在 LZ77 的基础上叠加一个范围编码器(range coder)来替代 Huffman 编码。
LZMA 压缩流水线:
原始数据
|
v
LZ77 匹配查找 (BT4/HC4)
|
v
序列化为 LZ tokens:
- 字面字节 (literal)
- 短重复匹配 (short rep)
- 匹配 (match): distance + length
- 长重复匹配 (long rep)
|
v
Range Coder (按位编码)
|
v
压缩流
LZMA 的关键创新:
- 超大窗口:最大支持 4 GB 的字典窗口,远超 DEFLATE 的 32 KB。
- 范围编码器:比 Huffman 编码更接近理论极限,尤其在处理非均匀分布时。
- 上下文建模:根据前一个字节和匹配状态选择不同的概率模型,类似于 PPM 的思想。
- 重复距离缓存:维护最近 4 个匹配距离的缓存(rep0-rep3),对重复模式的编码非常高效。
LZMA2:为多线程而生
LZMA 有一个致命缺陷:它是严格串行的。LZMA2 通过将数据分成多个独立的块(chunk)来解决这个问题:
LZMA2 格式:
+--------+--------+--------+--------+
| Chunk1 | Chunk2 | Chunk3 | Chunk4 | ...
+--------+--------+--------+--------+
| | | |
v v v v
LZMA LZMA LZMA Uncompressed
(独立) (独立) (独立) (不可压缩块)
每个块可以独立压缩和解压,因此可以利用多核并行处理。7z 格式默认使用 LZMA2。xz 工具也是基于 LZMA2 的。
性能对比
LZMA (level 6, Silesia corpus):
压缩速度: ~3 MB/s
解压速度: ~80 MB/s
压缩率: ~3.5:1
对比:
zlib-6: ~30 MB/s 压缩, ~350 MB/s 解压, ~2.7:1
LZ4: ~780 MB/s 压缩, ~4200 MB/s 解压, ~2.1:1
LZMA 的压缩率显著优于 zlib,但速度慢了一个数量级。这使得 LZMA 非常适合”压缩一次、解压多次”且对存储空间敏感的场景,例如软件分发包和固件镜像。
九、完整 C 实现:LZ77 编码器与解码器
下面给出一个教学用的 LZ77 编码器和解码器的完整 C
实现。为了清晰起见,使用原始三元组格式
(offset, length, next_char)。
/*
* lz77.c -- 教学用 LZ77 编码器/解码器
*
* 编译: gcc -O2 -o lz77 lz77.c
* 用法: ./lz77 -c < input > output (压缩)
* ./lz77 -d < input > output (解压)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#define WINDOW_SIZE 4096 /* 搜索缓冲区大小 */
#define LOOKAHEAD_SIZE 18 /* 前瞻缓冲区大小 */
#define BUF_SIZE (WINDOW_SIZE + LOOKAHEAD_SIZE)
/* ---- 编码输出的三元组 ---- */
typedef struct {
uint16_t offset; /* 匹配偏移 (0 = 无匹配) */
uint8_t length; /* 匹配长度 */
uint8_t next; /* 下一个字符 */
} lz77_token_t;
/* ---- 写 / 读 token ---- */
static void write_token(FILE *out, const lz77_token_t *t)
{
fwrite(&t->offset, sizeof(t->offset), 1, out);
fwrite(&t->length, sizeof(t->length), 1, out);
fwrite(&t->next, sizeof(t->next), 1, out);
}
static int read_token(FILE *in, lz77_token_t *t)
{
if (fread(&t->offset, sizeof(t->offset), 1, in) != 1) return 0;
if (fread(&t->length, sizeof(t->length), 1, in) != 1) return 0;
if (fread(&t->next, sizeof(t->next), 1, in) != 1) return 0;
return 1;
}
/* ---- 在搜索缓冲区中查找最长匹配 ---- */
static void find_longest_match(
const uint8_t *buf, /* 整个缓冲区 */
int search_start, /* 搜索缓冲区起始位置 */
int lookahead_start, /* 前瞻缓冲区起始位置 */
int lookahead_len, /* 前瞻缓冲区有效长度 */
uint16_t *best_offset,
uint8_t *best_length)
{
*best_offset = 0;
*best_length = 0;
if (lookahead_len == 0)
return;
int search_len = lookahead_start - search_start;
for (int i = search_start; i < lookahead_start; i++) {
int match_len = 0;
while (match_len < lookahead_len - 1 &&
match_len < 255 &&
buf[i + match_len] == buf[lookahead_start + match_len]) {
match_len++;
}
if (match_len > *best_length) {
*best_length = (uint8_t)match_len;
*best_offset = (uint16_t)(lookahead_start - i);
}
}
}
/* ---- 编码器 ---- */
static void lz77_encode(FILE *in, FILE *out)
{
uint8_t *buf = (uint8_t *)calloc(BUF_SIZE * 2, 1);
if (!buf) {
fprintf(stderr, "out of memory\n");
exit(1);
}
/* 读取初始数据 */
int total = (int)fread(buf, 1, BUF_SIZE * 2, in);
int pos = 0;
while (pos < total) {
int search_start = (pos > WINDOW_SIZE) ? pos - WINDOW_SIZE : 0;
int lookahead_len = total - pos;
if (lookahead_len > LOOKAHEAD_SIZE)
lookahead_len = LOOKAHEAD_SIZE;
uint16_t best_offset = 0;
uint8_t best_length = 0;
find_longest_match(buf, search_start, pos, lookahead_len,
&best_offset, &best_length);
lz77_token_t token;
token.offset = best_offset;
token.length = best_length;
/* 匹配之后的下一个字符 */
int next_pos = pos + best_length;
if (next_pos < total) {
token.next = buf[next_pos];
} else {
token.next = 0;
/* 标记结束: length 高位 */
}
write_token(out, &token);
/* 前进: 匹配长度 + 1 (next_char) */
pos += best_length + 1;
}
free(buf);
}
/* ---- 解码器 ---- */
static void lz77_decode(FILE *in, FILE *out)
{
uint8_t *buf = (uint8_t *)calloc(BUF_SIZE * 16, 1);
if (!buf) {
fprintf(stderr, "out of memory\n");
exit(1);
}
int pos = 0;
lz77_token_t token;
while (read_token(in, &token)) {
if (token.offset > 0 && token.length > 0) {
/* 复制匹配: 从 (pos - offset) 开始复制 length 个字节 */
int src = pos - token.offset;
for (int i = 0; i < token.length; i++) {
buf[pos++] = buf[src + i];
}
}
/* 写入 next_char */
buf[pos++] = token.next;
}
fwrite(buf, 1, pos, out);
free(buf);
}
/* ---- 主函数 ---- */
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "usage: %s -c|-d\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "-c") == 0) {
lz77_encode(stdin, stdout);
} else if (strcmp(argv[1], "-d") == 0) {
lz77_decode(stdin, stdout);
} else {
fprintf(stderr, "unknown option: %s\n", argv[1]);
return 1;
}
return 0;
}代码说明
find_longest_match:暴力搜索,遍历搜索缓冲区中的每个位置,与前瞻缓冲区做字符比较。时间复杂度 O(W * L),其中 W 是窗口大小,L 是前瞻缓冲区大小。- 编码器将整个文件读入内存,然后逐步编码。实际实现会使用流式处理。
- 解码器逐个读取
token,执行复制操作。注意
src + i可能等于pos,这意味着”自引用复制”(run-length encoding 的效果)。 - 这个实现大约 150 行,加上注释和格式化约 200 行,足以展示 LZ77 的核心逻辑。
编译与测试
# 编译
gcc -O2 -o lz77 lz77.c
# 压缩
./lz77 -c < testfile.txt > testfile.lz77
# 解压
./lz77 -d < testfile.lz77 > testfile_restored.txt
# 验证
diff testfile.txt testfile_restored.txt && echo "OK"十、基准测试:速度与压缩率的权衡
以下数据基于 Silesia 语料库(211 MB),在 Intel i7-12700H、DDR5-4800、Ubuntu 22.04 环境下测得。单线程,每项测试取 3 次运行的中位数。
综合对比表
+------------+----------+----------+--------+---------+
| 算法 | 压缩速度 | 解压速度 | 压缩率 | 内存用量 |
| | (MB/s) | (MB/s) | | (MB) |
+------------+----------+----------+--------+---------+
| LZ4 fast | 780 | 4200 | 2.10 | 0.016 |
| LZ4 HC-9 | 40 | 4200 | 2.72 | 0.80 |
| Snappy | 550 | 1800 | 2.00 | 0.032 |
| zlib-1 | 110 | 350 | 2.42 | 0.26 |
| zlib-6 | 30 | 370 | 2.73 | 0.26 |
| zlib-9 | 12 | 380 | 2.74 | 0.26 |
| zstd-1 | 500 | 1600 | 2.88 | 0.50 |
| zstd-9 | 75 | 1500 | 3.15 | 8.0 |
| zstd-19 | 5 | 1400 | 3.38 | 64 |
| LZMA-6 | 3 | 80 | 3.49 | 32 |
| LZMA-9 | 1.5 | 80 | 3.52 | 192 |
+------------+----------+----------+--------+---------+
速度单位: MB/s (越大越好)
压缩率: 原始大小 / 压缩后大小 (越大越好)
内存用量: 压缩时的工作内存 (解压通常更小)
图形化理解
解压速度 (MB/s)
^
|
4200 | * LZ4
|
|
1800 | * Snappy
1600 | * zstd-1
1400 | * zstd-19
|
|
380 | * zlib
|
80 | * LZMA
|
+----+----+----+----+----+----+----> 压缩率
1.5 2.0 2.5 3.0 3.5 4.0
观察: 存在明显的 Pareto 前沿。
zstd 在 2.8-3.4 的压缩率范围内提供了最佳的速度/压缩率权衡。
选择指南
需要最快的解压? --> LZ4
需要合理的压缩率 + 极快的解压? --> zstd (level 1-3)
需要最大压缩率且不在乎时间? --> LZMA / LZMA2
需要广泛兼容的格式? --> zlib / gzip
已经在 Google 生态中? --> Snappy
十一、实际应用:内核、网络与存储
Linux 内核中的字典压缩
Linux 内核在多个子系统中使用字典压缩:
子系统 压缩算法 用途
------ -------- ----
initramfs gzip/lz4/lzma/ 启动时解压根文件系统
zstd
zram lzo/lz4/zstd 内存页压缩 (swap)
squashfs gzip/lz4/lzma/ 只读压缩文件系统
zstd
btrfs lzo/zlib/zstd 透明文件系统压缩
EROFS lz4/lzma 只读文件系统 (Android)
内核的特殊约束:
- 启动阶段不能使用虚拟内存管理,解压器必须使用固定大小的静态缓冲区。
- 解压器的代码必须极度精简,因为它在引导加载器之后、完整内核之前运行。
- LZ4 在内核中的实现(
lib/lz4/)是从 Yann Collet 的参考实现移植的,做了内核适配。
网络协议中的压缩
协议/场景 压缩方法 说明
-------- -------- ----
HTTP/1.1 gzip/deflate Content-Encoding 头
HTTP/2 HPACK (Huffman) 头部压缩
HTTP/3 QPACK 基于 HPACK
gRPC gzip/snappy/zstd 消息级压缩
Redis LZF RDB 持久化
MySQL zlib/zstd 页级压缩
PostgreSQL pglz (LZ variant) TOAST 压缩
Kafka snappy/lz4/zstd 消息批量压缩
工程陷阱表
在实际工程中使用字典压缩时,有一些常见的陷阱需要注意:
| 陷阱 | 症状 | 原因 | 解决方案 |
|---|---|---|---|
| 小数据压缩反而变大 | 压缩后文件比原始文件大 | 元数据开销超过压缩收益 | 设置最小压缩阈值(如 256 字节),小数据不压缩 |
| 流式压缩内存爆炸 | OOM | 窗口大小设置过大 | 限制字典窗口大小,使用 LZMA2 分块 |
| 解压时间不可预测 | 偶发的高延迟 | 遇到精心构造的”压缩炸弹” | 限制解压输出大小,设置超时 |
| 跨平台兼容问题 | 解压失败 | 字节序不一致,格式版本不匹配 | 使用标准帧格式(如 LZ4 Frame, gzip) |
| 重复压缩 | 浪费 CPU,文件变大 | 对已压缩数据再次压缩 | 检测输入是否已压缩(magic bytes) |
| 字典预热不足 | 前几个块压缩率极低 | 新连接/新文件开头没有历史数据 | 使用预置字典(zstd dict, gzip with preset dict) |
| 并行解压死锁 | 挂起 | LZMA 的串行依赖 | 使用 LZMA2 或 zstd 帧(支持并行解压) |
| 压缩级别选错 | 要么太慢要么太大 | 没有根据场景选择 | 离线数据用高级别,在线数据用低级别 |
压缩炸弹防御
压缩炸弹(zip bomb)是一种利用压缩算法的特性构造的恶意文件。一个只有几 KB 的压缩文件解压后可能产生几 GB 甚至几 TB 的数据。防御措施:
/* 解压时的安全检查 */
#define MAX_OUTPUT_SIZE (256 * 1024 * 1024) /* 256 MB */
#define MAX_RATIO 100 /* 最大压缩比 */
int safe_decompress(const uint8_t *input, size_t input_len,
uint8_t *output, size_t *output_len)
{
size_t produced = 0;
/* ... 解压循环 ... */
/* 检查 1: 绝对大小限制 */
if (produced > MAX_OUTPUT_SIZE) {
return ERROR_OUTPUT_TOO_LARGE;
}
/* 检查 2: 压缩比限制 */
if (input_len > 0 && produced / input_len > MAX_RATIO) {
return ERROR_SUSPICIOUS_RATIO;
}
*output_len = produced;
return OK;
}十二、个人思考与工程建议
字典压缩的本质
回顾整条技术线索,字典压缩的核心思想可以用一句话概括:用”指向过去的指针”替换重复的数据。LZ77 用滑动窗口中的偏移量作为指针,LZ78 用字典条目编号作为指针。所有的变体和优化都是在这个基本框架上做文章。
速度 vs 压缩率:没有银弹
从上面的基准测试数据可以清楚地看到,速度和压缩率之间存在一个不可调和的权衡。没有一种算法能在所有维度上胜出。选择的关键在于理解你的工作负载:
- 读多写少(如 CDN、软件分发):压缩时间不敏感,解压速度和压缩率都重要。zstd 或 LZMA 是好选择。
- 读写均衡(如数据库页面压缩):需要合理的压缩和解压速度。LZ4 或 zstd-1 是主流选择。
- 实时处理(如网络传输、日志收集):延迟敏感,选 LZ4 或 Snappy。
- 归档存储(如备份、冷数据):时间不是约束,选 LZMA 或 zstd-19。
预置字典的威力
zstd 和 zlib 都支持预置字典(pre-trained dictionary)。对于大量结构相似的小文件(如 JSON API 响应、日志条目),预置字典可以显著提高压缩率:
场景: 压缩 1 KB 的 JSON API 响应
无字典: 压缩率 1.3:1 (太小,字典来不及"预热")
有预置字典: 压缩率 3.5:1 (字典包含了 JSON 的常见结构)
Facebook 在其内部系统中广泛使用 zstd 的预置字典功能来压缩小型 RPC 消息。
关于 zstd
虽然本文主要讨论 LZ 家族的经典算法,但不得不提 zstd(由 LZ4 的作者 Yann Collet 在 Facebook 期间开发)。zstd 本质上也是 LZ77 家族的一员,但它在 LZ 匹配之后叠加了有限状态熵编码(Finite State Entropy, FSE),在速度和压缩率之间找到了一个之前不存在的最优平衡点。
从工程的角度看,如果你今天要选择一个通用压缩算法,zstd 几乎总是最好的起点。它的压缩级别覆盖了从 LZ4 到接近 LZMA 的整个范围,API 设计优秀,文档完善,社区活跃。
算法之外
最后想说的是,选择压缩算法只是工程问题的一小部分。更重要的往往是:
- 数据的组织方式:列式存储天然比行式存储更好压缩。排序后的数据比随机数据更好压缩。
- 压缩的粒度:按页压缩、按块压缩还是按文件压缩?粒度影响随机访问性能。
- 格式的演进:选择有版本化和前后兼容机制的格式。不要发明自己的压缩帧格式。
- 监控和调优:在生产环境中持续监控压缩率和延迟,根据实际数据特征调整参数。
字典压缩从 1977 年走到今天,核心思想从未改变,但工程实现已经精进了数个数量级。理解原理、尊重权衡、善用工具,才是工程师应有的态度。
参考资料
- Ziv, J. and Lempel, A. (1977). “A Universal Algorithm for Sequential Data Compression.” IEEE Transactions on Information Theory, 23(3): 337-343.
- Ziv, J. and Lempel, A. (1978). “Compression of Individual Sequences via Variable-Rate Coding.” IEEE Transactions on Information Theory, 24(5): 530-536.
- Welch, T.A. (1984). “A Technique for High-Performance Data Compression.” Computer, 17(6): 8-19.
- Storer, J.A. and Szymanski, T.G. (1982). “Data Compression via Textual Substitution.” Journal of the ACM, 29(4): 928-951.
- Collet, Y. (2011). “LZ4 - Extremely fast compression.” https://github.com/lz4/lz4
- Collet, Y. and Turner, C. (2018). “Smaller and faster data compression with Zstandard.” Facebook Engineering Blog.
- Pavlov, I. (2001). “LZMA SDK.” https://7-zip.org/sdk.html
- Deutsch, P. (1996). “DEFLATE Compressed Data Format Specification version 1.3.” RFC 1951.
上一篇: Huffman 编码与 DEFLATE 下一篇: zstd 深度解剖 相关阅读: - zstd 深度解剖 - 整数压缩