bigywater.tech
数据编码与压缩 · 12

gzip 内部:一个 deflate 块里的 LZ77 加 Huffman

gzip 里面到底是什么?这篇自己写一个 inflate 解码器,拆开真实的 gzip 文件:头和尾、deflate 的块、动态 Huffman 块头里存了什么、压缩器如何选块类型,以及 gzip 头里的时间戳坑。

18 字节
gzip 封装比裸 deflate 多出的字节(10 字节头 + 8 字节尾:CRC32 和原始长度)
95.4%
products 1 MiB 的 deflate 流里,由匹配(而不是字面量)产生的输出比例
0.2%
动态 Huffman 块的块头占全部压缩数据的比例(8 个块共 427 字节)

gzip 的封装

实测 · 本机执行输出
  总长 41935 字节 = 头 10 + deflate 41917 + 尾 8;头:魔数 1f8b、方法 8(8 = deflate)、标志 0x0、mtime 1760000000、xfl 2、操作系统 255
  尾:CRC32 0xecc54463(我自己用 binascii.crc32 算 0xecc54463:一致)、原始长度 200000(等于输入长度 200000)
  同样数据,zlib 格式(2 字节头 + 4 字节 Adler-32 尾):41923 字节;raw deflate:41917 字节;三种封装差 18 / 6 字节

10 字节头(魔数 1f 8b、方法 8、标志、mtime、xfl、系统)、中间是 deflate 数据、8 字节尾(CRC32 和原始长度)。CRC32 保护解压后的数据;zlib 格式则是 2 字节头加 4 字节 Adler-32。

块

实测 · 本机执行输出
  压缩后 214172 字节,解出 1048576 字节,与原文一致(纯 Python 解码用时 0.3 秒);共 8 个块
    块 类型         输出字节     块占压缩字节     字面量     匹配     匹配覆盖   块头位数
    0 动态       133878      28590    7314   9069    94.5%    558
    1 动态       149154      30094    6538   9845    95.6%    414
    2 动态       148447      29985    6611   9772    95.5%    418
    3 动态       147924      30147    6489   9894    95.6%    407
    4 动态       147113      29924    6633   9750    95.5%    411
    5 动态       148216      30106    6537   9846    95.6%    409
    6 动态       148579      30080    6543   9840    95.6%    414
    7 动态        25265       5245    1178   1693    95.3%    388
  总计:字面量 47843 个,匹配 69709 个,匹配覆盖 95.4% 的输出

deflate 流由若干个块组成,每个块自带 Huffman 树。1 MiB 的 products 压成 8 个动态块,每块输出约 14.8 万字节、压缩后约 3 万字节。各块互相独立地建树,可以适应数据的变化。

块头

实测 · 本机执行输出
  HLIT = 279(字面量/长度码 279 个,257 起)、HDIST = 30、HCLEN = 17(码长码的 19 个中存了前 17 个,顺序 [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15])
  块头共 558 位 = 70 字节,占这个块压缩后 28590 字节的 0.2%;用到 82 个字面量/长度符号、30 个距离符号;字面量/长度码最长 14 位
  所有动态块的块头合计 427 字节,占全部压缩数据的 0.2%

动态块的块头先存“码长码”的码长,再用它存字面量/长度树和距离树的码长(第 10 篇讲的规范 Huffman 只需要码长)。第一个块的块头 70 字节,占块的 0.2%,可以忽略;块很短时才会明显。

LZ77 的选择

实测 · 本机执行输出
  zlib 级别 9:1 个块,字面量 7186 个,匹配 8882 个,匹配覆盖 94.5%,平均匹配长度 13.9 字节,压缩后 28019 字节
  (第 11 篇我的懒匹配 chain 64 是 14994 个 token:字面量 5351 个,匹配 9643 个,平均长度 13.0 字节,估算 28234 字节)

zlib 对同样的 128 KiB 选了 7186 个字面量和 8882 个匹配,我的懒匹配选了 5351 个和 9643 个,压缩后差不到 1%。LZ77 + Huffman 的框架里有很多合理的取舍。

块类型

实测 · 本机执行输出
  输入                        原字节  deflate 后 块类型序列(0 存储 / 1 固定 / 2 动态)
  空输入                         0          2 1(共 1 块)
  1 个字节                       1          3 1(共 1 块)
  "hello world"              11         13 1(共 1 块)
  50 字节的短文本                  50         48 1(共 1 块)
  300 字节的 JSON              300        225 2(共 1 块)
  2000 字节的 JSON            2000        791 2(共 1 块)
  随机 5000 字节               5000       5005 0(共 1 块)
  随机 1 MiB              1048576    1048896 000000000000000000000000000000…(共 64 块)
  全零 1 MiB              1048576       1033 2(共 1 块)

压缩器会为每个块选三种方式里最小的:存储、固定 Huffman、动态 Huffman。随机数据一块只多 5 字节;很短的输入用固定码,连 hello world 都会变长。

可复现

实测 · 本机执行输出
  gzip.compress 默认参数,间隔 1.1 秒压缩两次:字节完全相同(头里的 mtime 是 00000000 和 00000000)
  gzip.GzipFile 默认 mtime,间隔 1.1 秒写两次:字节不同,不同的位置 [4](都在头里 4–7 字节的 mtime:9b8bcc6a 对 9c8bcc6a);mtime=0 写两次:字节完全相同
  两个 gzip 文件直接拼在一起仍是合法的 gzip:gzip.decompress 得到 b'first second'(共 52 字节,每个成员各有 10 字节头 + 8 字节尾)

gzip 头里有 mtime,gzip.GzipFile 默认写当前时间,同一份数据不同时刻压缩的字节不同。需要稳定的字节(校验和、缓存、内容寻址)时显式传 mtime=0。gzip.compress 在 Python 3.14 里默认 mtime 为 0。

分步演示

deflate 内部
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8(系统 Python,自带 compression.zstd)/ zstandard 0.25.0 / lz4 4.4.5 / zstd、lz4、brotli、protoc 命令行(Homebrew);msgpack、protobuf、snappy 等第三方库临时装在临时目录里,用完删除;所有数据都是本机脚本生成的合成数据(固定随机种子),没有使用任何真实网站或用户的数据;计时用 time.perf_counter,取多次中位数,数字每次运行会有小幅抖动。inflate 解码器是我手写的纯 Python 实现(位读取器、规范 Huffman、动态块头、长度/距离码表),对所有测试输入都与 zlib.decompress 的结果一致;长度码和距离码表同第 11 篇;码长码的顺序 16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15 是我按记忆写的,解码一致可以旁证;所有字节数是确定性的
    • 没有验证:RFC 1951 / 1952 原文逐项核对;zlib 选块边界的具体规则;gzip 的其它头字段(文件名、注释、额外字段);bzip2、xz 的格式;zstd 的帧结构(见下一篇之后的 zstd 文章);流式压缩时块边界与 flush 的关系
    京ICP备2026067502号-1京公网安备11010502063487号