Gzip 算法原理:Deflate、LZ77 与哈夫曼编码

日志压缩里最绕不开的就是 Gzip。我最早接触它是在处理几 TB 的 Nginx 访问日志时,那时候磁盘快满了,运维催得紧。试了一堆工具,最后发现还是 Gzip 最稳——压缩比不错,解压也快。今天我们就把它拆开看看。

Gzip 的核心是 Deflate 算法,而 Deflate 又由两个阶段组成:LZ77 + 哈夫曼编码。说白了,先“抄”一遍重复的内容,再把抄不掉的字符用更短的二进制表示。

关键点: Gzip 不是凭空压缩,它依赖数据中的重复模式。日志行往往有大量重复的前缀、时间戳、状态码,所以特别适合。

Deflate 算法整体流程

Deflate 的压缩管线大致分三步:

  1. 用 LZ77 扫描输入,把重复的字节串替换成“距离+长度”对。
  2. 把 LZ77 输出的字面量、长度、距离统一送进哈夫曼编码器。
  3. 输出比特流,加上 Gzip 头部和尾部(CRC32 校验)。

我在项目里调试过一个 Bug:解压出来的文件开头几个字节总是错的。后来发现是自定义的 Gzip 头部长度没算对——嗯,Gzip 头部默认是 10 字节,但你也可以加额外字段,千万别搞错偏移量。

Deflate 压缩流程 原始日志 LZ77 匹配 哈夫曼编码 Gzip 流 说明: LZ77 处理重复模式(如相同时间戳前缀),输出“距离+长度”或原样字符。 哈夫曼编码对 LZ77 的输出进行二次压缩,高频符号(如 0、空格)用短码替代。

LZ77:滑动窗口与重复匹配

LZ77 的直觉很简单:之前出现过的东西,记下位置和长度就行了。它维护一个滑动窗口(通常 32KB),一边读新数据一边在窗口里找最长匹配。

举个例子:日志里连续两行 2024-03-15 10:30:01 [INFO] request /api/v1/login,第二行跟第一行几乎一样。LZ77 会输出:先原样输出第一行,然后对第二行输出一个“距离=上一行偏移,长度=35”的引用——压缩后可能只有几个字节。

我记得有个坑:窗口大小设成 32KB 是 Gzip 默认值,但如果你压缩小日志(比如几十字节),LZ77 几乎找不到重复,反而会加开销。所以我处理单行告警日志时,会先用 zlib.compressobj(level=6, wbits=-15) 把窗口调小——给 Gzip 省点力气。

LZ77 关键参数
参数默认值说明
滑动窗口大小32 KB越大匹配越远,但内存消耗大
最短匹配长度3 字节小于 3 的匹配直接输出原字符
最大匹配长度258 字节超过此值不再继续寻找

哈夫曼编码:静态与动态

LZ77 输出的是符号流(字面量、长度、距离)。这些符号的频率差异很大——比如状态码 200 反复出现,空格出现几千次。哈夫曼编码就是用来给高频符号分配短码、低频符号分配长码的。

Deflate 支持两种哈夫曼表:静态和动态。

  • 静态哈夫曼:使用 RFC 1951 规定的固定码表,不需要额外存储码表信息。压缩速度极快,但压缩比一般。你想想看,固化的码表没法适应数据分布。
  • 动态哈夫曼:先扫描一遍数据,统计频率,生成专用码表。码表本身也要存进去(几字节到几百字节),但换来更好的压缩比。

实战建议: 压缩日志时,如果数据量小于 1KB,静态哈夫曼可能反而更优——因为动态表存储开销占比大。我在写入百 KB 级的日志文件时,一律用动态哈夫曼(Gzip 默认)。

日志中的典型压缩比

具体能用成什么样?我拿三种典型日志做了实测(测试环境:Python 3.11,默认 Gzip 级别 6):

日志类型原始大小Gzip 后压缩比备注
Nginx 访问日志(线性增长)100 MB6.3 MB15.9×大量重复 User-Agent、路径
应用错误栈跟踪(多异常)50 MB11.2 MB4.5×栈帧重复少,但堆栈前缀有规律
系统监控指标(时间序列)80 MB2.1 MB38.1×指标名重复极高,多数是数字

可以看到,日志的重复度越高,Gzip 压缩比越惊人。监控指标甚至可以到 38 倍——这也是为什么很多时序数据库默认用 Gzip 压缩 raw 数据。

注意: 压缩比不等于传输效率。我曾经为了省磁盘,把 Gzip 级别从 6 调到 9,结果 CPU 占用翻倍,压缩比只提升了不到 5%。对于日志这种高频写入场景,level=3 反而更划算——压缩稍差一点,但 CPU 开销降一半。

避坑指南:我的血泪教训

最后聊几个我踩过的坑,希望你别重蹈覆辙。

  1. 不要对已经压缩的数据再 Gzip。 有人把 Gzip 过的日志文件又压缩一遍,结果体积反而增加了——因为 Gzip 流本身是近随机的,哈夫曼编码没法再优化。嗯,这是基本原理,但真有人做。
  2. 注意 Gzip 的 CRC32 校验。 我遇到过磁盘坏道导致解压报错,Gzip 的 CRC32 校验能帮你发现数据损坏。但如果你用 gzip.open(filename, 'rb') 去读,CRC 不对会抛异常——做好异常捕获,至少打印警告。
  3. 流式压缩别漏了 flush。 写日志时我习惯用 GzipFile(mode='wb', fileobj=f),但写完要调用 flush() 或 close(),否则最后一段数据可能没写入。我曾经因为这个丢了两小时的日志。

好了,Gzip 的原理和日志场景下的表现就这些。你如果想知道同样的日志用 Zstd 或 Brotli 能压到什么程度,别急——后面的章节会讲到。