性能指南

开发者需要知道的 gzip 压缩

DEFLATE 的工作方式、gzip 与 zlib 两种容器的区别、Content-Encoding 与 Transfer-Encoding 的分工、gzip 在真实数据上与 brotli 和 zstd 的对比,以及压缩反而更糟的那些情形。

压缩是少数几乎不花钱、却仍然被反复配错的优化之一。常见错误就那么几种:压已经压过的东西、压小到压了也没用的响应、把两个长得很像其实并不通用的 HTTP 头搞混,以及压了一份里面带秘密的响应。本文讲清机制、容器、头部语义,以及那些告诉你「该停手了」的实测数字。

DEFLATE 是算法,gzip 和 zlib 是包在它外面的容器

有三个名字几乎被混用,而它们其实是三样东西。DEFLATE 由 RFC 1951 规定,是压缩算法本身。zlib 即 RFC 1950,是套在 DEFLATE 流外面的六字节封装:两字节头部加四字节 Adler-32 校验和。gzip 即 RFC 1952,封装更大:十字节头部携带魔数、时间戳和标志位,可选地带上文件名,外加八字节尾部存放 CRC-32 和原始长度。

实际后果是:期待某种容器的解压器会拒绝另外两种,哪怕里面的压缩载荷完全相同。这正是经典报错「incorrect header check」的成因 —— 把 zlib 流交给了 gzip 读取器,通常是因为在一个提供两个函数的 API 里选错了那一个。开头几个字节就能告诉你手里是哪种:1f 8b 08 是 gzip,78 9c 是最常见的 zlib 头,而裸 DEFLATE 流根本没有可识别的前缀。

容器之间的体积差是固定的、也是很小的,只有在量级最低端才有意义。压缩单个字节 "a",裸 DEFLATE 得到 3 字节,zlib 得到 9 字节,gzip 得到 21 字节。对一兆字节的 HTML 来说,gzip 那 18 字节开销无关紧要;对一个 40 字节的接口响应来说,它就是全部。

三者内部的压缩载荷完全相同,区别只在外层封装。

容器规范起始字节额外开销完整性校验典型用途
裸 DEFLATERFC 19510 字节ZIP 条目、PNG 的 IDAT 块、HTTP 的 deflate
zlibRFC 195078 01 / 78 9c / 78 da6 字节Adler-32Git 对象、WOFF 1.0、PDF 流、众多协议
gzipRFC 19521f 8b 0818 字节CRC-32 加原始长度HTTP Content-Encoding、.gz 文件、tar 包

DEFLATE 到底靠什么省出字节

DEFLATE 由两个想法组合而成。第一个是 LZ77:编码器一边扫描输入,一边把滑动窗口内已经见过的序列替换成一个回溯引用 —— 一个距离加一个长度 —— 而不是再写一遍。窗口是 32 KiB,这是整个格式里最重要的一个数字。相距 30 KB 的两段相同文字压得很好;相距 40 KB 的同样两段就压不好了,因为第二段到来时第一段已经滑出窗口。

第二个想法是霍夫曼编码:剩下的字面量和回溯引用用变长码写出,常见符号得到短码,罕见符号得到长码。DEFLATE 可以使用固定码表,也可以为每个块构造并内嵌自定义码表,编码器会选体积更小的那个。

这两点解释了这个格式的全部行为。重复文本压得极好:一百个相同字节变成一个字面量加一个回溯引用和一个长度 —— 所以一百个字母 a 压完是 24 字节,其中 18 字节还是 gzip 的封装。结构化数据压得好,是因为它在重复自己的键:四十条用户记录的 JSON 数组,绝大部分内容就是 id、name、email、role 这几个字符串来回出现。随机数据则完全压不动,因为没有序列重复、也没有哪个符号更常见 —— 10000 字节随机数据经 gzip 出来是 10023 字节,比进去时还略大一点。

压缩级别是一个「搜索力度」旋钮,而不是另一种算法。级别越高,花在寻找更长、更远匹配上的时间越多。在下文那份 JSON 样本上,从 gzip 6 级提到 9 级只省下 17 字节,代价却是明显更多的 CPU —— 这就是为什么 6 级几乎是所有地方的默认值,也是为什么调高它很少能带来人们期待的那种优化。

Content-Encoding 与 Transfer-Encoding 不是二选一

Content-Encoding 是「表示形式」的属性。它声明这份资源已被变换过,并且这个变换端到端保持:穿过每一个代理、进入每一个缓存,直到客户端解码为止。客户端用 Accept-Encoding 声明自己能接受什么,服务端选一种并写进 Content-Encoding,而 Content-Length 描述的是压缩后的长度 —— 因为线上传的就是它。ETag 是针对编码后的表示计算的,所以做压缩的服务端要么让 ETag 随编码变化,要么把它标为弱校验。

Transfer-Encoding 是「单跳」的属性。它描述消息在相邻两方之间如何分帧,每个中间节点都可以拆掉再重做。实践中只有一个取值有意义,就是 chunked,它让服务端能流式返回长度未知的响应。规范确实允许 Transfer-Encoding: gzip,但几乎没有任何客户端或服务端实现它,尝试使用它是稳定收到乱码的可靠途径。在 HTTP/2 和 HTTP/3 中这个头根本不存在,因为分帧由协议本身负责;而 Content-Encoding 的行为完全照旧。

最容易被忘掉的头是 Vary: Accept-Encoding。没有它,一个存下了 gzip 编码响应的共享缓存,可能把它发给一个从未要求 gzip 的客户端 —— 这是一个真实且由来已久的页面损坏来源。凡是协商编码的服务端都必须带上它。

一次交互长什么样
请求
  GET /api/users HTTP/1.1
  Accept-Encoding: br, gzip, zstd

响应
  HTTP/1.1 200 OK
  Content-Type: application/json; charset=utf-8
  Content-Encoding: gzip          <- 端到端,穿过每个代理都保持
  Content-Length: 431             <- 压缩后的长度
  Vary: Accept-Encoding           <- 没有它,缓存会发错响应体
  ETag: W/"c1f0a2"                <- 弱校验,因为字节内容取决于编码

Transfer-Encoding: chunked 是逐跳的,只描述分帧方式。
它不是请求压缩的另一种办法,并且在 HTTP/2 和 HTTP/3 中不存在。

同一份数据上的 gzip、brotli 与 zstd

gzip 是通用底线。过去二十五年里造出来的每一个 HTTP 客户端都接受它,所以它是正确的兜底选项;对不少接口来说,它也是唯一值得配置的编码。它的上限由 32 KiB 窗口,以及一个自 1996 年以来就没变过的格式所决定。

面向 Web 内容,brotli 在压缩率上胜出,而且幅度超出窗口大小所能解释的范围。它内置了约 120 KB 的常见 Web 字符串词典 —— HTML 标签、HTTP 头名、常见英文与 JavaScript 片段 —— 所以它一上来就在「Web 实际提供的那类内容」上占了先手,而它的窗口最大可达 16 MiB。在下文实测的那份 3370 字节 JSON 样本上,brotli 在默认质量下压出 254 字节,而 gzip 是 431 字节。代价是最高质量档的编码耗时,所以 brotli 11 应当放进静态资源的构建步骤,动态响应则用 brotli 4 或 5 —— 在 CPU 上它与 gzip 6 大致相当。

Zstandard 用一点压缩率换来了巨大的解压速度优势和便宜得多的压缩曲线,并且支持训练词典 —— 对「大量彼此相似的小载荷」(比如 JSON 接口响应)而言,这是颠覆性的。主流浏览器的当前版本已经把它作为 Content-Encoding 接受,但它是三者中最新的一个,也最可能在老客户端、企业代理或嵌入式 HTTP 库里缺席。

四十条键名重复的用户记录,也就是普通的接口输出。绝对数值完全取决于输入,可推广的是顺序关系。

编码输出体积占原始备注
3370 字节100%未压缩的原文档
gzip 6 级431 字节12.8%几乎所有地方的默认值,通用支持
gzip 9 级414 字节12.3%多省 17 字节,CPU 明显更高
zlib 6 级419 字节12.4%载荷相同,封装比 gzip 少 12 字节
裸 DEFLATE 6 级413 字节12.3%完全没有容器
brotli 默认质量254 字节7.5%内置 Web 词典;质量 11 档编码较慢

什么能压,什么只是白白烧 CPU

文本形态的数据能压,因为它在重复。HTML、CSS、JavaScript、JSON、XML、SVG、CSV、纯文本、源代码和 WebAssembly 都获益明显,通常落在原体积的五分之一到十分之一之间。格式越结构化、越重复,效果越好 —— 所以同样长度下,记录整齐的 JSON 数组比散文压得更好。

已经压过的数据压不动,硬压只会在两端消耗 CPU,还让载荷略微变大。这涵盖了除未压缩 BMP 和 TIFF 之外的所有常见图片格式 —— JPEG、PNG、GIF、WebP 和 AVIF 内部都做了压缩 —— 也涵盖 MP4、WebM、MP3、AAC、ZIP、gz、7z 等全部归档格式。加密数据同样在内,因为它在设计上就与随机数据不可区分。实测很直白:10000 字节随机数据经 gzip 出来是 10023 字节。

字体需要单独说明,因为很容易配错。WOFF 2.0 内部已经用了 Brotli,再用 Content-Encoding: brotli 提供它,就是在压一个压过的文件,毫无收益。WOFF 1.0 内部用的是 zlib,处境相同。裸 TTF 和 OTF 确实压得动 —— 但正因如此,它们应当被转成 WOFF2,而不是套一层 gzip。

如果你对某个具体载荷拿不准,那就去测,而不是去推理。压一份有代表性的样本只要一秒钟,本站的 Gzip 工具会直接报出粘贴内容压缩后的体积。一次测量就能了结一场否则每次性能评审都要重演的争论。

压缩率是重复度的函数
输入                                    gzip 输出
--------------------------------------------------------------
一百个字母 "a"                          24 字节    (其中 18 字节是封装)
3370 字节 JSON,40 条同构记录            431 字节
10000 字节密码学随机数据                 10023 字节  <- 比输入还大
2 字节,字符串 "OK"                      22 字节     <- 大了 11 倍

gzip 封装固定为 18 字节:10 字节头部,加 8 字节尾部
(存放 CRC-32 与原始长度)。当输入小到一百字节左右以下时,
封装本身就主导了结果。

什么时候不该压

第一种情形是体积。几百字节以下时,固定封装加上短输入上糟糕的匹配效果,意味着压缩省不下什么甚至什么也省不下;而在大约 50 字节以下,它稳定地让体积变大。还有一个网络层面的理由:一个本来就装得进单个 TCP 段的响应,再变小也不会更早到达。nginx 的 gzip_min_length 默认是 20 字节,低到毫无意义;多数团队会把它提到 256 到 1400 字节之间。

第二种情形是 CPU。在高吞吐的动态接口上,对每个响应都用高级别压缩,可能带来比传输节省更多的延迟 —— 尤其是在带宽本来就不是瓶颈的内网上。静态资源应当在构建期压一次并以预压缩形式提供;动态响应用中等级别即可。在线实时跑 9 级压缩,几乎总是错误的取舍。

第三种情形是安全,也是最出人意料的一种。压缩率会泄露关于内容的信息。如果一份响应里同时包含秘密(CSRF 令牌、账号)和攻击者可控的字符串,攻击者就可以不断变换自己的输入并观察压缩后长度的变化:当他的猜测与秘密的一部分吻合时,两者会被压到一起,响应随之变短。这就是 BREACH 攻击,它在 TLS 之上照样成立,而 TLS 对此无能为力 —— 无论是否加密,长度都是可见的。缓解手段是:不要压缩那些把秘密与回显输入混在一起的响应;用每次请求随机的掩码去掩蔽 CSRF 令牌;把携带秘密的接口与回显用户输入的接口分开。它的前身 CRIME 攻击的是请求头压缩,这也是 TLS 层压缩被彻底移除的原因。

第四种情形是解压不可信输入。如果你的服务接受 gzip 编码的请求体,一个很小的上传就可能膨胀成一个巨大的缓冲区 —— 这就是经典的解压炸弹。解压时务必对输出体积设硬上限、对膨胀比例设限,超限时直接拒绝而不是截断。

多数时候都对的默认配置

几乎所有服务都可以采用同一套配置然后收工:只压文本形态的内容类型,把最小长度设在几百字节这个量级,静态资源在构建期用最高质量的 brotli 加上兜底的 gzip 预压缩,动态响应用中等级别。

剩下的决策都是测量问题,不是观点问题。有人提议调高压缩级别,就请他给出前后的字节数和 CPU 代价。有人提议压缩图片,就指出它们本来就是压过的。而当客户端报告响应损坏时,先去查容器和头部,再去怀疑压缩器 —— 这类报告里绝大多数要么是「zlib 流进了 gzip 读取器」,要么是某个缓存从来没被告知要 Vary。

  • 压文本、JSON、XML、SVG、CSS 和 JavaScript;绝不压图片、视频、音频、归档和加密数据。
  • 设置一个最小响应体积 —— nginx 默认的 20 字节不是一个有用的阈值。
  • 只要响应体依赖于请求的编码,就必须发送 Vary: Accept-Encoding。
  • 不要压缩同时包含秘密和攻击者可控文本的响应。
  • 解压任何不是自己产出的数据时,都要给输出体积设上限。

要点回顾

  • DEFLATE 是算法,gzip 与 zlib 只是包在相同压缩字节外面的不同封装,所以「incorrect header check」是容器不匹配,而不是数据损坏。
  • Content-Encoding 是端到端的,也是你用 Accept-Encoding 协商的那个;Transfer-Encoding 是逐跳的,实际上只意味着 chunked,并且在 HTTP/2 和 HTTP/3 中不存在。
  • 压缩只能利用重复,所以结构化文本能大幅缩小,而图片、视频、归档和加密数据完全压不动,出来还会略大一点。
  • gzip 的固定开销是 18 字节,这让压缩几百字节以下的响应变得毫无意义甚至有害。
  • 绝不要压缩把秘密与攻击者可控输入混在一起的响应,因为压缩后的长度本身就会泄露秘密,与是否启用 TLS 无关。

继续了解相关检查与工具