理解压缩算法
这篇文章想聊聊软件中的压缩算法。
我曾负责改进一个公司内部项目的部署流程。它需要把体积很大的构建产物上传到 S3,我也由此切身体会到:构建目录的大小会直接影响上传时间与存储成本。于是一个问题自然出现了:怎样才能更高效地压缩并上传这些文件?
真正开始研究后,我发现压缩格式比想象中多得多:zip、gzip、zstd、bzip2、xz 等等。名字都很相似,但要找到一份清楚说明它们有何不同、分别适合什么场景的资料并不容易。(我原以为压缩都差不多,后来才发现世界很大,让文件变小的方法也很多。)
因此,我想借这个机会比较主要压缩格式的原理和特点,并整理当时为什么选择了其中一种方案。
什么是无损压缩?
无损压缩(Lossless Compression)是一种能够完整还原原始数据的压缩方式。它不同于图像和音频中常见的有损压缩(Lossy Compression):解压后的数据与原始数据连一个比特都不会不同。源代码和构建产物对数据完整性要求很高,因此必须使用无损压缩。
无损压缩的核心思想是利用数据中存在的统计冗余。把反复出现的模式替换成更短的表示,整体体积就会减小。
其中,基于字典(Dictionary-Based)的方法是应用最广泛的无损压缩算法家族之一。这里的“字典”不是解释词义的词典,而是一张把之前出现过的数据片段映射成短代码的查找表。Abraham Lempel 和 Jacob Ziv 在 1977 年的论文 "A Universal Algorithm for Sequential Data Compression"(IEEE Transactions on Information Theory)中提出的 LZ77,以及第二年发表的 LZ78,正是这个家族的始祖。“LZ”取自两位研究者姓氏的首字母。后来几乎所有基于字典的压缩算法,包括 DEFLATE、LZMA、LZ4 和 Zstd,都能追溯到这两种算法。(说大部分压缩算法的家谱最终都汇聚到这两个人身上,并不夸张。)
举个简单的例子。如果“Linux”这个单词在文本中出现 100 次,压缩器可以在第一次出现时把它登记进字典,后续出现时则替换成表示“字典第 1 项”的短引用,也就是指针。“Linux”占 5 个字节,而指针通常可以用更少的字节表示,整体大小因此下降。
那么,LZ77 和 LZ78 具体有什么区别?
LZ77:滑动窗口方式
LZ77 不会单独建立一份显式字典,而是把输入流中的一段区域直接当作字典。这段区域称为滑动窗口,因为它会随着数据处理不断向前移动。(算法题里也经常见到这个词。)
窗口分成两个区域。
- 搜索缓冲区(Search Buffer):已经处理过的数据,承担字典的作用。
- 前向缓冲区(Look-ahead Buffer):尚未处理、接下来要压缩的数据。
算法会检查前向缓冲区的开头是否曾在搜索缓冲区中出现。如果找到相同模式,就把这次匹配编码为**(距离、长度、下一个字符)**的元组。距离表示要向后退多少字符才能找到匹配的起点,长度表示匹配持续多少字符。
假设用 LZ77 压缩字符串 "banana_banana"。遇到第二个 "banana" 时,算法实际上是在说:“向后退 7 个字符,然后复制 6 个字符。” 原本 6 字节的字符串就可以只用两个数字表示。
这种方式的关键在于不必另外保存或传输字典。解码器在解压过程中会自然重建搜索缓冲区,因此字典被隐式地嵌入数据本身。代价是解压必须从数据开头按顺序进行。从原理上说,无法从中间任意位置开始解压。
窗口大小与压缩率之间存在直接的权衡。窗口越大,就越能引用距离更远的模式,压缩率通常也越高;但匹配搜索所需的计算量和内存占用也会随之增加。
LZ78:显式字典方式
与 LZ77 不同,LZ78 会在压缩过程中构建一份显式字典,不使用滑动窗口。它把之前见过的模式保存成带索引的字典项,之后遇到相同模式时用索引替换。
LZ78 的输出单位是**(字典索引、下一个字符)**形式的标签。编码器先找到字典中最长的匹配项,再输出该项的索引和打破匹配的下一个字符,随后把 “刚才匹配的项+新字符” 加入字典。字典会在处理过程中逐步增长。
LZ78 最著名的变体是 LZW(Lempel-Ziv-Welch)。Terry Welch 在 1984 年发表了这项改进,它被用于 GIF 图像格式和 Unix 的 compress 工具(扩展名为 .Z)。(LZW 曾处于专利纠纷的中心,这件事也成为 PNG 格式诞生的原因之一。)
现代压缩算法属于哪一支?
有趣的是,今天几乎所有主流压缩算法都是 LZ77 的后代。
Storer 和 Szymanski 在 1982 年发表的 LZSS 是 LZ77 的改进版。它加入一个 1 比特标记,用来区分每次输出是“字面量,也就是原始字符”,还是“长度与距离的组合”。如果匹配太短,引用反而不划算,编码器就直接输出原字符。
1993 年,Phil Katz 把 LZSS 与霍夫曼编码结合,创造了 DEFLATE。霍夫曼编码是一种为高频符号分配更短比特串的熵编码。ZIP、GZIP 和 PNG 都使用 DEFLATE。也就是说,我们每天接触的 .zip、.gz、.png 文件都是 LZ77 的直系后代。
后来出现的 LZMA(7-Zip、XZ)、LZ4 和 Zstd 也都以 LZ77 的滑动窗口思想为起点,继续改进匹配搜索的数据结构和熵编码方法。相比之下,LZ78 家族在 LZW 之后基本退出了主流舞台。
理论上已经证明,两种算法在 “完整解压全部数据” 时具有等价的能力。LZ77 最终胜出的原因,是把字典嵌入数据的设计在实现和扩展两方面都更灵活。滑动窗口的大小、匹配搜索算法以及后续熵编码器都可以自由组合,因此能够随时代需求继续演化。
评价压缩性能时通常要看两个维度:压缩率,也就是能缩小多少;以及压缩速度,也就是多久能完成。追求更高压缩率通常需要更多计算,压缩时间也会更长。现实中的压缩策略,核心就是在两者之间找到合适的平衡点。
有了这些基础,下面逐一比较几种常见格式。
ZIP
ZIP 是 Phil Katz 在 1989 年创造的文件格式,内部通常使用 DEFLATE 算法,也就是 LZ77 与 Huffman coding 的组合。重要的是,ZIP 本身不是“压缩算法”,而是“文件格式,也就是容器”。ZIP 这个容器里装着由 DEFLATE 等算法压缩的数据。
ZIP 的特点是分别压缩每个文件。这被称为非固实归档(Non-solid Archive),因此可以只取出归档中的某一个文件。另一方面,它无法利用文件之间重复的数据,所以压缩率可能低于后面要介绍的 tar.gz。
Windows、macOS、Linux 等大多数操作系统都能在不安装额外软件的情况下直接支持 ZIP,因此当跨平台兼容性很重要时,它通常是最稳妥的选择。
GZIP(GNU Zip)
GZIP 和 ZIP 一样,内部使用 DEFLATE 算法。既然算法相同,为什么还需要另一种格式?ZIP 同时承担把多个文件放进一个归档的容器职责,而 GZIP 专门用于压缩单个文件或单个数据流。
如果要用 GZIP 压缩多个文件或一个目录,需要先用 TAR 把它们打包成一个归档,再用 GZIP 压缩。这两步最终生成 .tar.gz 或 .tgz 文件。
GZIP 的文件结构由 RFC 1952 规定,非常简单:一个固定的 10 字节头部、可选的扩展头部(包含原文件名、注释等)、DEFLATE 压缩数据,以及包含 CRC-32 校验值和原始大小的8 字节尾部。CRC-32 用于验证解压数据是否与原数据一致。因此,GZIP 可以理解为包裹 DEFLATE 数据流的一层轻量封装。
DEFLATE 使用的滑动窗口最大为 32KB。这个大小会限制 GZIP 的压缩率,因为相隔 32KB 以上的模式无法互相引用。GZIP 还提供 1 到 9 的压缩级别。级别 1 速度快但压缩率较低,约为 60%;级别 9 较慢,但能达到约 75%。默认级别 6 是速度和压缩率之间的平衡点。
在 Unix/Linux 环境中,GZIP 长期被当作分发源代码、压缩日志和软件包的标准工具。它也仍然广泛用于通过 Content-Encoding: gzip 进行 HTTP 压缩,不过在这一领域正逐渐被 Brotli 取代。
ZSTD(Zstandard)
ZSTD 是 Meta(原 Facebook)的 Yann Collet 开发的压缩算法,于 2016 年开源。它最大的优势是:在保持与 GZIP 相近压缩率的同时,压缩和解压速度都快得多。
ZSTD 的内部结构大致分成三步。首先,LZ77 系的匹配查找器(Match Finder)从输入中寻找重复模式。然后把找到的字面量、匹配长度、偏移量等信息编码成序列。最后用熵编码压缩这些序列。它不只使用 GZIP 的霍夫曼编码,还使用 FSE(Finite State Entropy)。FSE 是基于 ANS(Asymmetric Numeral Systems)理论的熵编码器,结合了霍夫曼编码与算术编码(Arithmetic Coding)的优点。霍夫曼编码只能为每个符号分配整数个比特,而 FSE 可以表达相当于小数比特的概率,因此更接近理论最优值。(名字听起来很宏大,核心其实只是“用更聪明的方法,以更少比特表示同样的数据”。)
匹配查找器也会随压缩级别改变策略。低级别 1 至 4 使用简单哈希表快速搜索;中等级别 5 至 12 比较多个候选,并用 Lazy 策略选择更好的匹配;高级别 13 至 22 使用二叉树和动态规划寻找接近最优的匹配。级别可以从 1 精细调到 22,因此实时传输可以用低级别,归档则可以用高级别。
在 Silesia Corpus 基准测试中,ZSTD 默认级别 3 的压缩速度约为 300MB/s,解压速度约为 1,200MB/s。GZIP 默认级别 6 则只有约 34MB/s 和 380MB/s。ZSTD 压缩约快 8 倍、解压约快 3 倍,压缩率还以 3.17 略高于 GZIP 的 3.09。 这些数字最直观地说明了 ZSTD 如何改善传统权衡。
它的生态采用也在快速扩大。ZSTD 被用于 Linux 内核模块压缩和文件系统透明压缩,Arch Linux、Fedora、Debian、Ubuntu 等主要发行版也把它用于软件包。从 2025 年 2 月发布的 v1.5.7 开始,最多使用 4 个线程的多线程压缩默认启用,与单线程 GZIP 的实际速度差进一步拉大。AWS 也曾表示,把内部服务从 gzip 切换到 zstd 后,S3 存储减少了约 30%。
BZIP2
BZIP2 通过多阶段转换来压缩数据,核心流程如下。
- RLE(Run-Length Encoding):减少原始数据中连续的重复
- BWT(Burrows-Wheeler Transform):重新排列数据,使其更容易压缩
- MTF(Move-to-Front Transform):把 BWT 输出转换成数字序列
- RLE:再次减少 MTF 结果中的重复
- Huffman Coding:最后按频率进行编码
BZIP2 的压缩率高于 GZIP,但压缩和解压都较慢。它长期用于压缩率重要而速度不那么重要的归档场景。
不过,它最后一次发布是 2019 年的 v1.0.8,活跃开发基本停止。越来越多基准测试表明 ZSTD 在压缩率和速度两方面都胜过 BZIP2,因此新项目更常选择 ZSTD。
XZ
XZ 是使用 LZMA2 的压缩格式。Igor Pavlov 开发的 LZMA(Lempel-Ziv-Markov chain Algorithm)把基于 LZ77 的字典压缩与范围编码(Range Encoding)结合起来。LZMA2 与其说是“改进版 LZMA”,不如说更接近包裹 LZMA 数据流的容器格式。它主要增加了多线程压缩与解压,以及对不可压缩数据的高效处理。
在本文介绍的格式中,XZ 拥有最高的压缩率。代价是压缩速度很慢,内存占用也很大。它适合把节省存储空间放在最高优先级的归档场景。
但在 2024 年 3 月,XZ 的核心库 xz-utils 被发现存在后门,引发严重的供应链安全事件 CVE-2024-3094。攻击者通过长达两年的社会工程获得维护者权限,该漏洞获得最高的 CVSS 10.0 分。主要发行版立刻回滚到安全版本,但这起事件给开源供应链安全敲响了警钟。(XZ 本身的技术价值依然存在,不过选择工具时也值得了解这段背景。)
TAR
TAR(Tape Archive)本身不是压缩算法,而是一种把多个文件和目录组合成一个归档文件的工具与格式。顾名思义,它最初是为磁带备份设计的。磁带是一种顺序介质,因此连续拼接数据是很自然的结构。
TAR 的内部结构出乎意料地简单。所有数据都按512 字节块处理。每个文件前有一个 512 字节头部,其中记录文件名(最多 100 字节)、文件模式、所有者 UID/GID、大小、修改时间和校验值等元数据。文件数据紧跟在头部之后,并填充到 512 字节的整数倍。归档结尾用两个 512 字节的全零块表示。现代大多数 TAR 实现遵循 POSIX 定义的 **UStar(Unix Standard TAR)**格式,支持最长 256 字节的文件名和更多元数据字段。
关键在于,TAR 会原样保留权限、所有权、时间戳和符号链接等Unix 文件系统元数据。ZIP 有时无法完整保留这些 Unix 特有信息,因此在服务器部署中 TAR 往往更合适。
TAR 本身不会缩小数据。因为头部和填充,它甚至可能比原始数据略大。真正的压缩需要与 GZIP、BZIP2、XZ、ZSTD 等工具结合完成。这就是 .tar.gz、.tar.bz2、.tar.xz、.tar.zst 等扩展名存在的原因。TAR 负责“打包”,压缩工具负责“缩小”,这是 Unix “做好一件事”哲学的典型例子。
TAR 是 Unix/Linux 环境的标准归档方式,而 Windows 可能需要 7-Zip 等额外软件。
也了解一下 Brotli
前端开发者也应该了解 Brotli。它是 Google 开发的压缩算法,并于 2015 年作为 HTTP 数据流压缩规范 Content-Encoding: br 标准化。
所有主流浏览器都在 HTTPS 环境中支持 Brotli,全球支持率超过 96%。它通常比 GZIP 多压缩约 15% 至 25%,尤其适合 JavaScript、CSS、HTML 等文本型静态文件。Cloudflare 等主要 CDN 已将它作为默认压缩方式,现代 Web 优化的常见做法可以概括为“优先 Brotli,回退到 GZIP”。
如果构建产物上传到 S3 并通过 CDN 提供服务,预先用 Brotli 压缩静态文件可以显著减少网络传输。(当时还没有足够的项目资料支持立刻引入它,但作为开发者,了解并在以后重新评估这项选择仍有价值。)
为什么 tar.gz 的压缩率高于 ZIP?
原因在于**固实归档(Solid Archive)与非固实归档(Non-solid Archive)**的区别。
tar.gz 会先用 TAR 把所有文件组合成一段连续数据流,再由 GZIP 一次压缩整个数据流。这样就能识别和利用跨文件的重复数据,这就是固实归档。如果构建目录里有几十个结构相似的 JavaScript bundle,文件 A 中出现的模式在文件 B 再次出现时就可以引用。由于不必为每个压缩流分别记录头部、校验值和目录(Table of Contents),元数据开销也会减少。
ZIP 属于非固实方式,会独立压缩每个文件,因此无法利用文件之间的冗余。即使文件 A 和 B 包含相同代码块,两条独立的 DEFLATE 数据流也不知道对方存在。这就是 tar.gz 通常比 ZIP 获得高 5% 至 15% 压缩率的原因。构建产物中结构相似的文件越多,差距往往越大。
固实归档也有明显缺点。
- 如果只想取出某一个文件,可能仍要先解压它之前的全部数据。所有文件处于同一数据流,无法直接跳到中间位置。ZIP 可以随机访问单个文件,因此在经常提取特定文件的场景里可能更合适。
- 如果归档的一部分损坏,损坏点之后的所有数据都可能无法恢复。非固实格式有时只会丢失受损文件,其余内容仍能保留。
2026 年补充
2024 年选择了 tar.gz,现在会怎么选?
当时选择 tar.gz 是因为兼容性和稳定性。产物上传 S3 后需要在多种环境中解压,因此几乎随处可用的 tar.gz 是安全选择。
但如果今天再次遇到同样的情况,我会认真考虑 tar.zst(TAR + ZSTD)。回想前面的基准数字。
GZIP 默认级别的压缩速度为 34MB/s,ZSTD 默认级别则为 300MB/s。对一个 2GB 构建目录做简单计算,GZIP 约需 60 秒,ZSTD 约需 7 秒。从 ZSTD v1.5.7 开始默认启用的多线程能力最多使用 4 个线程,实际体感差距可能更大。在 CI/CD 流水线中,这些时间会在每次部署时不断累积。
# tar.zst 생성 (멀티스레드 자동 활용)
tar --zstd -cf archive.tar.zst directory/
# 또는 압축 레벨 지정 (-T0은 사용 가능한 모든 코어 활용)
tar -cf archive.tar.zst -I 'zstd -3 -T0' directory/ZSTD 的压缩率也与 GZIP 相当甚至更高,因此“为了速度而牺牲压缩率”的权衡实际上已经不存在。它既更快,结果也更小。
不过,仍然必须确认接收环境能够解压 zstd。主要 Linux 发行版已经包含它,macOS 也可以通过 Homebrew 的 brew install zstd 轻松安装。老旧系统或最小化安装环境可能需要额外安装,因此应提前检查团队使用的所有环境。如果兼容性是第一优先级,tar.gz 仍然是最稳妥的通用选择。
一览对比
| 格式 | 算法 | 压缩率 | 速度 | 主要特点 |
|---|---|---|---|---|
| ZIP | DEFLATE | 中等 | 快 | 跨平台、非固实 |
| GZIP | DEFLATE | 中等 | 快 | 单一数据流、常与 TAR 结合 |
| ZSTD | Zstandard | 高 | 非常快 | 级别可调、现代标准 |
| BZIP2 | BWT+MTF+Huffman | 高 | 慢 | 开发基本停滞 |
| XZ | LZMA2 | 非常高 | 非常慢 | 最高压缩率、需注意安全背景 |
| Brotli | Brotli | 高 | 中等 | 专为 Web 优化 |
结语
深入研究压缩之前,我坦率地想过:“直接用 zip 打包不就行了吗?”真正处理超过 2GB 的构建目录后,我才直观感受到,算法选择会显著改变上传时间和成本。
每种压缩格式都有自己的设计哲学和权衡:ZIP 的兼容性、GZIP 的通用性、ZSTD 的速度、XZ 的压缩率。不存在适用于所有情况的“最佳”方案,正确选择取决于项目约束。
理解那些平时不假思索就使用的工具,能帮助我们在下一次遇到类似问题时作出更好的判断。希望这篇文章能给需要选择压缩算法的人提供一点参考。