gzip 今年 34 岁了:我把 1 MB 的 bundle 压了几十遍,算清 brotli 和 zstd 这笔账
- 作者:Bougie
- 创建于:2026-10-05
昨天晚上发完版,我盯着构建产物看了一眼:最大的那个 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)。
所以压缩率实际上只由两件事决定:
- 能往前看多远去找重复(窗口大小)
- 对"这个符号该编几位"这件事猜得有多准(建模质量)
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 上实测获得,样本为本机真实文件。压缩率会随内容和版本变化,配生产环境前请在自己的产物上复跑一遍。)