gzip 今年 34 岁了:我把 1 MB 的 bundle 压了几十遍,算清 brotli 和 zstd 这笔账

昨天晚上发完版,我盯着构建产物看了一眼:最大的那个 chunk 是 1080 KB。

这个数字其实没有意义。用户下载的从来不是它,而是它被 Content-Encoding 压过之后的那串字节。于是我打开 Node 的 zlib,把这个文件喂给 gzip、brotli、zstd,跑出了这三个数:

原始             1080.1 KB
gzip -9           293.3 KB   (27.2%)
brotli q11        205.4 KB   (19.0%)
zstd -19          220.2 KB   (20.4%)

gzip 和 brotli 之间差了 88 KB。同一个文件、同一台机器、同一分钟。

我一直模模糊糊知道 brotli 比 gzip 强,但从没算过强在哪、值不值。这一篇就是把这个账算清楚的过程——不是"brotli 比 gzip 小 20%"这种结论,而是这 20% 具体是从哪几个地方一分一分抠出来的。

一台巨大的压力机把一个大纸箱压成小纸箱的手绘示意图

下面所有数字都是我在 Node v24.16.0(Linux x86_64)上调 node:zlib 跑出来的,样本全部是这台机器上真实存在的文件:VuePress 构建产物的 JS chunk、打包后的 CSS、产物首页 HTML、package-lock.json、mermaid 的 min.js,以及一张已经压过的 JPEG。

# 一、先把三个算法摆在同一张表上

样本 原始 gzip -9 brotli q11 zstd -19
JS bundle(VuePress chunk) 1080.1 KB 293.3 KB 205.4 KB 220.2 KB
JS min(mermaid) 1080.8 KB 293.5 KB 205.9 KB 220.6 KB
CSS(打包产物) 28.5 KB 6.5 KB 5.7 KB 6.3 KB
HTML(产物首页) 25.0 KB 7.4 KB 5.6 KB 6.9 KB
JSON(package-lock) 1092.4 KB 230.2 KB 168.5 KB 171.7 KB
JPEG(已压缩图片) 22.7 KB 22.5 KB 22.1 KB 22.3 KB

压完占原始的比例:

样本 gzip -9 brotli q11 zstd -19 brotli 相对 gzip 又小了
JS bundle 27.2% 19.0% 20.4% 30.0%
JSON 21.1% 15.4% 15.7% 26.8%
HTML 29.6% 22.3% 27.7% 24.3%
CSS 22.7% 20.1% 22.0% 12.3%

第一行就是最典型的收益:在 JS bundle 上,brotli 比 gzip 又小了 30%。 最后两行则提醒你这件事有前提——CSS 只有 12%,而 JPEG 那一栏三个算法全都在 97% 以上,等于什么都没压到。

第一张表还有一个藏着的数字。同样的 1 MB,压缩耗时是这样:

JS bundle:   gzip -9  18.2 ms   |   brotli q11  962.8 ms   |   zstd -19  120.4 ms
JSON:        gzip -9  17.0 ms   |   brotli q11  571.1 ms   |   zstd -19  150.5 ms

brotli 换来那 88 KB,花了 gzip 的 53 倍 时间。这笔账后面第五节要单独算。

# 二、它们到底在压什么

三个算法长得不一样,但骨架是同一套:先找重复,再给符号编短码。

第一步是 LZ77 那一支——编码器一路往前扫,遇到前面出现过的字节串,就不写内容本身,改写一个 (距离, 长度) 的指针。文章里第 300 个字符又出现了一遍 "backgroundColor",第二次就不用写了,写"往前 297 个字符、抄 14 个"。

第二步是熵编码——把"哪些符号出现得多"统计出来,给高频符号短编码、低频符号长编码。DEFLATE 用的是 Huffman 编码,brotli 和 zstd 用的是更精细的算术编码(ANS / FSE)。

所以压缩率实际上只由两件事决定:

  1. 能往前看多远去找重复(窗口大小)
  2. 对"这个符号该编几位"这件事猜得有多准(建模质量)

gzip 是 1992 年的东西,DEFLATE 的窗口写死在 32 KB。brotli(Google,2013)和 zstd(Facebook,2015)都是二十多年后的产物,第一件事就把窗口放大了两个数量级,第二件事换了更好的编码器和一份预置字典。

接下来的三节,就是把这三个差别分别量化。

# 三、第一笔账:窗口

这是最大的一笔。

我做了一个简单的对照:造一段文字,里面有一个 4 KB 的片段出现了两次,两次之间隔着另一段不重复的正文。改变"隔多远",看各算法压完的体积。

两次重复的间隔 原文 gzip -9 brotli q5 zstd -19
8 KB 23.6 KB 7.3 KB 7.0 KB 6.6 KB
23 KB 54.9 KB 18.1 KB 17.6 KB 16.3 KB
30 KB 68.5 KB 24.1 KB 22.3 KB 20.6 KB
39 KB 86.1 KB 28.1 KB 26.3 KB 24.2 KB
63 KB 133.0 KB 46.0 KB 34.2 KB 31.4 KB
125 KB 258.0 KB 88.4 KB 40.5 KB 36.9 KB

为了确认"差距确实来自那个重复、而不是别的",我还跑了一份对照组——把第二次出现换成另一段 4 KB 的不同文字,用压缩后体积的差值算出"抓到这次重复省了多少":

间隔 gzip 省下 brotli 省下 zstd 省下
8 KB 1378 B 1381 B 1262 B
23 KB 1321 B 1363 B 1248 B
30 KB -26 B 1355 B 1225 B
39 KB -1 B 1369 B 1220 B
63 KB 3 B 1364 B 1234 B
125 KB -9 B 1417 B 1304 B

那条分界线清清楚楚:间隔一超过 30 KB,gzip 省下的字节数掉到 0。

因为 32 KB 是 DEFLATE 窗口的硬上限。超出这个距离的历史,编码器根本不记得了——哪怕那里明摆着躺着一份一模一样的 4 KB 副本,它也只能老老实实再抄一遍。

而 brotli(最大 16 MB 窗口)和 zstd 在 125 KB 上依然稳稳抓着,省下的字节数和 8 KB 时几乎没有区别。

一条长文本带上一个滑动窗口的手绘图,窗口外的重复块之间连着虚线

这件事为什么在真实项目里重要?因为一个 1 MB 的 bundle 里,重复出现的模式往往隔得非常远——同一个工具函数被两个相距很远的模块各引一次、同一种 AST 结构在文件头尾各出现一次、同一串错误信息散落在几十 KB 之外。gzip 看不见这些,brotli 看得见。

窗口参数可以单独调。我把 brotli 的 lgwin(窗口的 log2)从 10 扫到 24,样本是 bundle 的前 256 KB,quality 固定 6:

lgwin 窗口大小 压完体积
10 1 KB 88.4 KB
12 4 KB 82.7 KB
14 16 KB 62.5 KB
16 64 KB 44.9 KB
18 256 KB 44.4 KB
20 / 22 / 24 1 MB ~ 16 MB 44.4 KB

曲线在 18 就平了——窗口只要大到能装下整个输入就够了,再大纯属浪费。 这也是为什么 brotli 编码器一般会根据文件大小自动设 lgwin,你不手动调通常也不会吃亏。

# 四、第二笔账:预置字典

窗口解决的是"离得远的重复",字典解决的是"第一次出现就别写了"。

brotli 自带一份大约 120 KB 的静态字典,里面塞了上万个从真实网页里统计出来的常见词和短语——<!DOCTYPE html>、function、background-color、</div>、各种 HTTP 头字段名,甚至还有常见的多词组合。这些内容不需要在文件里真的出现过一次就能被引用。

在小文件上,这个差别是碾压性的。我拿了三个几百字节的片段:

片段 原始 gzip -9 brotli q11 zstd -19 brotli 是 gzip 的
一段 HTML 182 B 160 B 77 B 150 B 48.1%
一段 CSS 157 B 143 B 73 B 134 B 51.0%
一段 JS 91 B 99 B 70 B 87 B 70.7%

HTML 片段被压到 gzip 的一半不到。gzip 只能老老实实把 <!DOCTYPE html> 这十几个字节写进输出;brotli 直接说"字典第 3721 项,抄"。

顺带看第三行那个反常的数字:91 字节的 JS 片段,gzip -9 压完变成 99 字节。 这是"越压越大",下一节专门说。

我把这个规律在小响应上完整跑了一遍(用不重复的英文文本,避免"内容本身就很重复"污染结果):

原始 gzip -9 节省 brotli q11 节省
100 B 92 B 8 B (8%) 63 B 37 B (37%)
200 B 145 B 55 B (28%) 97 B 103 B (52%)
300 B 192 B 108 B (36%) 145 B 155 B (52%)
500 B 285 B 215 B (43%) 211 B 289 B (58%)
800 B 410 B 390 B (49%) 319 B 481 B (60%)
1000 B 497 B 503 B (50%) 386 B 614 B (61%)
2000 B 892 B 1108 B (55%) 730 B 1270 B (64%)

gzip 在 100 字节上只省了 8 个字节—— barely worth it。brotli 靠字典省了 37%。这就是"几百字节的 API 响应值不值得压"这个问题的答案:用 gzip 不值,用 brotli 值。

一本摊开的字典,常见单词从书页里飞向一个网页图标的手绘图

# 五、第三笔账:档位,以及那 53 倍的时间

回到第一节那个刺眼的数字:brotli q11 用了 962.8 ms,gzip -9 只用了 18.2 ms。

我把 bundle 上的每个档位都扫了一遍(体积那栏是相对 gzip -9 的百分比):

档位 体积 相对 gzip -9 压缩耗时 解压耗时
gzip 1 338.2 KB 115.3% 5.5 ms 1.71 ms
gzip 4 306.9 KB 104.6% 7.7 ms 1.59 ms
gzip 6(默认) 294.6 KB 100.4% 12.3 ms 1.54 ms
gzip 9 293.3 KB 100.0% 17.4 ms 1.57 ms
brotli 1 310.6 KB 105.9% 2.5 ms 1.85 ms
brotli 4 258.2 KB 88.0% 6.7 ms 1.45 ms
brotli 6 231.7 KB 79.0% 12.4 ms 1.70 ms
brotli 9 228.2 KB 77.8% 23.9 ms 1.72 ms
brotli 11 205.4 KB 70.0% 965.3 ms 1.73 ms
zstd 1 302.5 KB 103.1% 1.7 ms 0.60 ms
zstd 3 261.9 KB 89.3% 2.1 ms 0.62 ms
zstd 9 238.0 KB 81.1% 8.1 ms 0.58 ms
zstd 15 234.1 KB 79.8% 41.2 ms 0.58 ms
zstd 19 220.2 KB 75.1% 123.6 ms 0.69 ms
zstd 22 220.2 KB 75.1% 143.0 ms 0.69 ms

几件事一眼能看出来:

gzip 的档位几乎是摆设。 1 档到 9 档,338 KB → 293 KB,多花 3 倍时间换 13%。默认 6 档和 9 档只差 1.3 KB。这也是为什么很多 CDN 的 gzip 默认就跑 6。

brotli 的高档位是另一个物种。 9 档 23.9 ms,11 档 965.3 ms——慢了 40 倍,只换回 22.8 KB。这是典型的收益悬崖,所以 brotli 的推荐档位通常是 4~6(实时压缩)或 11(离线预压)。

zstd 的曲线最健康。 3 档 2.1 ms 就能拿到 89.3%,19 档 123.6 ms 拿 75.1%,22 档开始完全不再变小(220.2 KB,和 19 档一模一样)——也就是说 zstd 在高档位上不存在 brotli 那种"最后一步血亏"的问题。

换算成吞吐更直观:

压缩(1080 KB bundle)        解压
gzip -1        192.7 MB/s     gunzip             687.0 MB/s
gzip -6         85.9 MB/s     brotliDecompress   628.4 MB/s
gzip -9         60.2 MB/s     zstdDecompress    1476.1 MB/s
brotli q5       95.5 MB/s
brotli q11       1.1 MB/s
zstd -3        492.4 MB/s
zstd -19         8.8 MB/s

关键在右边那一列:解压速度全部在 600 MB/s 以上,而且三者差得不多。 这意味着一个很重要的结论——

brotli q11 那 1.1 MB/s 的压缩慢,用户一个字节都感受不到。慢的只是构建阶段(或者 CDN 首次回源的那一次)。解压是浏览器的事,628 MB/s,1 MB 的 bundle 解压大约 1.7 毫秒。

所以正确用法是:静态资源在构建时离线压成 .br 文件(compression-webpack-plugin、Vite 的 vite-plugin-compression 都干这个),线上直接发预压产物;只有动态响应才需要实时压缩,那时候用 q4~q5。

一架天平,一边是很小的文件,另一边是走得极慢的时钟的手绘图

# 六、什么时候压缩反而让文件变大

压缩不是免费的,它的输出里必然带着"怎么解回来"这本书。如果内容本身没有重复可抓,这本书就白写了。

随机数据压不动。 我拿 100 KB 的 crypto.randomBytes:

原始      100.0 KB
gzip -9    97.7 KB
brotli     97.7 KB
zstd       97.7 KB

三个都是 97.7 KB——没有任何变小,反而略微变大(因为要加上容器头和熵编码的最低开销)。100 字节的随机数据更明显:gzip → 123 B,brotli → 104 B,都超过了原始的 100 B。

已经压过的东西再压一遍,基本白搭。 那张 22.7 KB 的 JPEG:

gzip -9   22.5 KB (99.2%)
brotli    22.1 KB (97.5%)
zstd      22.3 KB (98.6%)

brotli 抠出 2.5%,剩下的全是容器开销层面的东西。JPEG/PNG/WebP/MP4/WOFF2 这些格式本身就是压缩格式,再套一层 HTTP 压缩纯属浪费 CPU。这就是为什么 Nginx 的 gzip_types 默认只列文本类型,不包含图片。

太小的文件不值得。 上面第四节的表格里,100 字节的文本 gzip 只省 8 个字节,而 HTTP 头、TLS 记录、TCP 包这些开销一个都比 8 字节大。业界常见的一条经验阈值是 1 KB 以下不压缩(Nginx 的 gzip_min_length 默认 20 字节,很多配置会调成 1024)。

还有一个纯好奇的数字:gzip 容器本身的固定开销是多少?

1000 B 文本:   deflateRaw -9  483 B   |   gzip -9  501 B   |   zlib -9  489 B
1080 KB bundle: deflateRaw -9  300355 B | gzip -9  300373 B

差值精确等于 18 字节——10 字节的 gzip 头(magic、方法、flag、mtime、XFL、OS)加 8 字节的尾部(CRC32 + 原始长度)。zlib 格式的头尾是 6 字节(Adler-32 校验)。18 字节在 1 MB 上无关紧要,在 100 字节上就是 18%。

# 七、10 MB 变成 14 个字节

既然"找重复"是核心,那最容易找重复的数据是什么?一堆一样的字节。

10 MB 全 0      gzip →  10221 B  (放大 1026 倍)
                brotli →    14 B  (放大 748983 倍)
                zstd →     338 B  (放大 31023 倍)

10 MB 随机      gzip → 10488983 B  (放大 1 倍)
                brotli → 10485780 B
                zstd →  10486010 B

10 MB 的零,brotli 用 14 个字节表示完了。反过来说:一个 14 字节的请求体,能让服务端解出 10 MB 内存。

这就是压缩炸弹(zip bomb / decompression bomb)。真实攻击更狠——把压缩流再压一遍、再压一遍,套几十层:

1 MB 的 'a'  →  gzip  →  1052 B  →  64 B  →  83 B  →  103 B
                                    (后面几层因为内容已经是压缩流,体积不再单调下降)

外层 103 字节,解开是 83 字节,再解是 64 字节,再解是 1052 字节,再解是 1 MB。多层嵌套可以把"输入体积"和"解开后的体积"拉开几十万倍。

顺带一提,这也是为什么杀毒软件、邮件网关、文件上传服务必须在解压前后都设上限:只校验"上传文件 ≤ 1 MB"是完全没用的,得校验"解压后的字节数 ≤ N",而且要在流式解压的过程中提前中断,不能等它全解完。

一个极小的信封炸开,涌出堆满整个房间的纸张的手绘图

# 八、压缩体积会泄密:BREACH

这一节是我这次跑实验时最意外的收获。

压缩器的输出长度,取决于输入里有多少重复。那么如果一个响应里既有秘密、又有攻击者能控制的内容呢?

我造了这样一个页面——页面上有个 CSRF token,同时把用户的搜索词回显在同一个 HTML 里(这是真实业务里极常见的写法):

const secret = 'SUPER-SECRET-TOKEN-12345'
const page = (inject) =>
  `<html><body><h1>Account</h1><div class="token">${secret}</div><div class="q">${inject}</div></body></html>`

攻击者能控制 class="q" 里的内容(比如通过 URL 参数)。我把注入内容固定成 8 个字符,只改变"猜对了秘密的前几个字符":

猜中的前缀 注入内容(补齐 8 字符) 响应原始 gzip -9 brotli q11
"" (0) lKM6v8dX 120 B 117 B 80 B
"S" (1) SVNehogf 120 B 117 B 77 B
"SU" (2) SUpqVo5M 120 B 115 B 73 B
"SUP" (3) SUPPIK7s 120 B 114 B 77 B
"SUPER" (5) SUPERHFW 120 B 112 B 72 B
"SUPER-S" (7) SUPER-SR 120 B 110 B 70 B
"SUPER-SE" (8) SUPER-SE 120 B 109 B 68 B

响应原始长度全是 120 字节,一个字节都没变。但 gzip 后的长度从 117 一路单调降到 109。

原因就是第三节那个窗口:注入内容和页面上的 token 重合的字节越多,编码器就越能把它写成"往前 N 个字符抄 M 个",输出就越短。

于是攻击者拿到一把尺子:每次试一个候选前缀,看响应压缩后是几个字节,最短的那个就是猜对了。逐字节推进,一个 20 位的 token 几百次请求就能试完。

这就是 BREACH(2013 年,针对 HTTPS 上的 HTTP 压缩)。它和更早的 CRIME(2012,针对 TLS 压缩和 SPDY 头压缩)是同一类攻击——CRIME 打的是"压缩在加密之前"这个顺序,所以业界的解法是把 TLS 压缩整个禁掉(RFC 7525 明确要求);HTTP 压缩禁不掉,只能从应用层防。

(顺带说明:上面表里 brotli 那一列噪声更大——80、77、73、77——因为它的建模更复杂,长度变化不是严格单调的。做这类攻击时 gzip 的信号反而更干净。)

一把尺子靠着一把挂锁,一个小人正在读尺子刻度的手绘图

几个实际可用的防御:

  • 给每个响应的压缩加随机长度的填充,把长度信号淹没掉(最直接,也最影响性能)
  • 对同一个请求限制速率,让逐字节爆破在时间上不可行
  • 把秘密从"会被回显的页面"里挪走——同一个响应里不要同时出现秘密和用户输入,这一条最根本
  • 敏感响应当独加 Cache-Control: no-store,否则共享缓存会帮你把答案留下来

# 九、线上那一步:Accept-Encoding 和 Vary

前面全是在本地压文件。真到线上,还有一步协商。

我起了个本地 server,对同一个 25.6 KB 的 HTML 按客户端的 Accept-Encoding 决定压不压、压成什么:

客户端发的 Accept-Encoding 服务端回的 Content-Encoding Content-Length
identity (无) 25650 B
gzip gzip 7590 B
br br 5717 B
zstd zstd 7110 B
gzip, deflate, br, zstd br 5717 B
br;q=1.0, gzip;q=0.8, *;q=0.1 br 5717 B

(这个 server 用的是"br 优先"的策略;真实世界的服务端顺序由自己的配置决定。)

这里有个一不小心就出事的细节:只要响应内容会随 Accept-Encoding 变化,就必须回 Vary: Accept-Encoding。

我上面那个 server 每次都带了。如果漏掉,CDN 或中间代理会认为"这个 URL 的响应对所有人都一样",于是把一份 br 的响应缓存下来,发给下一个只支持 gzip 的客户端——那个客户端会拿到一堆解不开的二进制。这类 bug 的诡异之处在于:你自己的浏览器永远复现不了,只有某个特定 UA 的用户会 reporting 页面是乱码。

关于 zstd 的那一行:它是 2021 年才进 RFC 8878 的,浏览器支持到 2024 年后才铺开(Chrome 123+、Firefox 126+)。从表上也能看出,它在 HTML 上的表现(7110 B)并不比 brotli 好。zstd 真正的主场是大文件和内部服务间通信——它 492 MB/s 的压缩速度和 1476 MB/s 的解压速度,比 brotli 高一个数量级。用在 CDN 边缘节点之间的回源、日志归档、Docker 镜像层上,比用在浏览器上合理得多。

# 十、所以该怎么配

把我这几十遍压下来的结论收成一张清单:

构建产物 / 静态资源

  • 离线预压 .br,用 q11。那 1.1 MB/s 的压缩慢只发生在构建机上,用户感受到的是 628 MB/s 的解压
  • 保留一份 .gz 做回退(面向不支持 br 的老客户端)
  • 不要去压图片、字体、视频——它们已经是压缩格式了(第六节那张表:JPEG 压完还是 97.5%)

动态响应 / API

  • 实时压缩用 brotli q4~q5,或者干脆 gzip -6。q11 的 965 ms 不能放在请求链路里
  • 1 KB 以下别压(gzip 在 100 字节上只省 8 个字节)
  • 如果响应里同时有秘密和用户输入,认真读一遍第八节

CDN / 网关

  • 响应头带上 Vary: Accept-Encoding,否则缓存会串味
  • 内容类型按白名单走,别把 image/* 也塞进去

内部服务 / 大数据

  • zstd -3 是个很好的默认值:492 MB/s 压缩、1476 MB/s 解压,压缩率只比最高档差一点点(89.3% vs 75.1% 那栏是 1 MB 文本上的极端情况,实际业务数据上差别更小)
  • 任何解压的地方都要设"解压后字节数"的上限,不只是"输入字节数"

回头看这篇文章的起点,其实就是那 88 KB。

gzip 是 1992 年的设计,34 岁了。它没做错什么——32 KB 的窗口在 1992 年是个合理的取舍,那时候的内存和今天的完全不是一个量级。brotli 和 zstd 能从它手里再抠出 20~30%,靠的不是更聪明的数学,而是二十多年后"往前看多远"这件事终于不再贵了。

有意思的是,这笔账里真正值钱的不是"brotli 更小"这个结论,而是几个不起眼的地方:为什么同一个文件在不同间隔下差距会差 30%、为什么 100 字节的响应用 brotli 值而用 gzip 不值、以及为什么压缩体积本身就是一条侧信道。

最后一个留给我自己的提醒:压缩从来不是"变小"这么一件事,它是"把信息重新编码"这件事。 既然重新编码了,输出的字节数就必然携带着输入的信息。第七节的炸弹和第八节的 BREACH,是同一枚硬币的两面。

(本文所有数字均于 2026 年 10 月在 Node v24.16.0 / zlib 上实测获得,样本为本机真实文件。压缩率会随内容和版本变化,配生产环境前请在自己的产物上复跑一遍。)