Gzip、Brotli 与 Zstd:Web 资源压缩之争
Web 资源的 Brotli、gzip 与 zstd 对比:何时选用各算法、压缩级别如何影响体积,以及如何预压缩静态文件。
静态文本资源用 Brotli,gzip 作为通用兜底方案,而在服务器和 CDN 都由你掌控的动态响应场景下使用 zstd。
大量产物包至今仍以 gzip 形式分发,只因为多年前某个构建工具或托管平台的默认配置做了这个选择,而此后再没有人重新审视过算法或压缩级别。本文接下来将探讨:为什么静态内容和动态内容是两个不同的问题;为什么压缩级别对结果的影响往往超过算法本身;如何在构建时预压缩;浏览器与服务器如何协商编码;以及哪些内容不应该压缩。
核心要点
- 对静态 JavaScript 和 CSS,最高级别的 Brotli 是正确选择,因为压缩速度慢的代价只在构建时支付一次,而非每次请求都付出。
- Zstd 适合动态响应:Cloudflare 实测其压缩速度比 Brotli 快 42%,而压缩体积与 Brotli 相当接近——平均压缩比分别为 gzip 2.56:1、zstd 2.86:1、Brotli 3.08:1。
- 级别这个旋钮和算法名称同样重要:如果只是切换到 Brotli 却保留了偏向速度的默认级别,就白白丢掉了一部分收益。
- nginx 的
gzip_comp_level默认为 1,ngx_brotli 的brotli_comp_level默认为 6;两者都不是最高级别。 - 编码协商完全通过
Accept-Encoding和Content-Encoding请求头完成,因此切换算法只是服务器或 CDN 的配置项改动,无需修改应用代码。
Brotli、Gzip 与 Zstd:推荐方案
对静态 JavaScript 和 CSS,Brotli 是正确的默认选择,因为压缩在构建时只发生一次,最高级别带来的耗时在请求时没有任何成本。
Gzip 应当保留在技术栈中作为兜底方案,因为所有浏览器都会在 Accept-Encoding 中列出它;它在任何场景下都不是最优选择,但它是唯一永远不会失效的选择。
Zstd 适用于动态响应——这类场景每次请求都要支付压缩成本——因为它能以 Brotli 所需 CPU 时间的一小部分,达到接近 Brotli 的压缩比。
静态 vs 动态:压缩成本在何时支付?
算法之间的取舍,本质上是在决定 CPU 成本何时支付。静态产物包每次发布只压缩一次,却要被分发成千上万次,因此只有压缩比重要,压缩速度无关紧要。而按请求渲染的 HTML 页面每次命中都要压缩一次,此时压缩速度会变成与压缩比同等重要的延迟和服务器成本问题。
Cloudflare 在 2024 年 9 月宣布支持 Zstandard 时提供了动态场景下的数据。在 2024 年第三季度的一项测试中,他们将免费套餐流量从 Brotli 切换到 zstd 持续 24 小时,其中 zstd 使用默认级别 3,Brotli 和 gzip 的级别未披露。结果显示 zstd 的压缩速度比 Brotli 快 42%,而压缩体积接近 Brotli。实测平均压缩比为 gzip 2.56:1、zstd 2.86:1、Brotli 3.08:1,Cloudflare 自己的结论是:zstd 更适合动态响应,包括 HTML。
结论:在压缩比是唯一标准的场景下 Brotli 胜出,这描述的正是所有静态资源。而在压缩需要按请求执行的场景下 zstd 胜出。
压缩级别与算法同样重要
每种算法都提供了级别旋钮,而级别对结果的影响往往超过切换算法本身。在 ngx_brotli 中 Brotli 的级别范围是 0 到 11,在 nginx 中 gzip 是 1 到 9,而 zstd CLI 是 1 到 19(默认为 3),加上 --ultra 后还可达到 20 到 22。
旋钮对结果的影响程度完全取决于输入数据,因此别人发布的产物包压缩前后对比数据,对你自己的情况参考价值极低。把你的文件放进压缩对比工具跑一遍,它可以在浏览器中以任意级别用 gzip、Brotli 和 zstd 进行压缩,你可以自行对比体积。
默认值解释了为什么这么多站点都停留在偏向速度的一端。nginx 的 gzip_comp_level 默认为 1,这是 1 到 9 中最快的一档。ngx_brotli 的 brotli_comp_level 在 0 到 11 中默认为 6。两者都不是最高值,而且对于实时压缩来说都很合理——但这恰恰是静态资源最不该套用的场景。你所选择的级别,代价完全由压缩端承担。zstd 项目自己的 README 指出,无论文件由哪个级别压缩而成,解码速度基本相同,这一点对 zlib 和 lzma 同样成立。
结论:有意识地设置级别。凡是预压缩的内容一律用最高级别;凡是按请求压缩的内容用中间级别。
如何在构建时预压缩资源?
预压缩是指构建步骤为每个资源在旁边生成一个 .br、.gz(可选还有 .zst)的同名文件,服务器直接挑选匹配的文件返回,请求时不做任何压缩。
brotli -q 11 app.js # writes app.js.br; source kept by default
gzip -9 -k app.js # writes app.js.gz; -k keeps the source
zstd -19 app.js # writes app.js.zst; source kept by default
brotli CLI 默认保留输入文件,并接受 -q 参数指定 0 到 11 的质量级别。GNU gzip 则需要 -k 或 --keep 才会保留原文件。
在 nginx 中提供这些同名文件只需两条指令:
load_module modules/ngx_http_brotli_static_module.so;
http {
gzip_static on; # serves .gz siblings; module needs --with-http_gzip_static_module
brotli_static on; # serves .br siblings; default off
gzip_vary on; # adds Vary: Accept-Encoding; default off
}
ngx_http_gzip_static_module 默认不会编译进来。brotli_static 来自 ngx_brotli。nginx 本身不提供 zstd 模块;第三方的 zstd-nginx-module 为 .zst 同名文件添加了 zstd_static 指令(默认关闭),若不使用它,静态文件的 zstd 就只能在 CDN 侧配置。
CDN 通常会直接透传预压缩文件。Cloudflare 的压缩文档指出,当访客浏览器支持且未启用任何响应重写功能(Rocket Loader、Email Address Obfuscation、Polish 等)时,它会保留源站的 content-encoding: br 或 gzip;它向源站发起请求时携带的是 accept-encoding: br, gzip,因此源站的 zstd 不会被透传。Akamai 的 Brotli Support 行为会分发并缓存源站压缩的 Brotli 内容,对不接受 br 的客户端返回非 Brotli 版本,且自身不做边缘压缩。
结论:在构建时以最高级别压缩,并配置服务器或 CDN 来分发这些同名文件。
浏览器和服务器如何协商编码?
浏览器在 Accept-Encoding 中列出自己能解码的编码方式,服务器或 CDN 从中选定一种,并用 Content-Encoding 标注响应,整个交换过程不需要任何应用代码参与。
GET /app.3f2a1b.js HTTP/1.1
Accept-Encoding: gzip, deflate, br, zstd
HTTP/1.1 200 OK
Content-Encoding: br
Vary: Accept-Encoding
br 是 Brotli 在 HTTP content-coding 注册表中的标识符,定义于 RFC 7932 第 13 节。Vary: Accept-Encoding 告知共享缓存,不要把 Brotli 响应体返回给只请求了 gzip 的客户端;除非设置了 gzip_vary on,否则 nginx 不会输出该头。
要查看一个站点当前实际分发的编码,请求任意一个产物包文件并读取返回的那一个响应头即可:
curl -sI -H 'Accept-Encoding: gzip, br, zstd' https://example.com/app.js | grep -i content-encoding
同样的信息也可以在 DevTools 的 Network 面板的响应头中看到。
浏览器支持情况决定了兜底顺序。所有当前主流浏览器都支持 Brotli。caniuse 显示 zstd 在 Chrome 和 Edge 自 123 起、Firefox 自 126 起、Opera 自 109 起、Safari 自 26 起支持,其中桌面版 Safari 标记为部分支持。任何未在 Accept-Encoding 中包含 zstd 的浏览器,只会被返回 Brotli 或 gzip。这里不存在失败模式,只有兜底降级。
结论:切换算法只是配置变更,而兜底链条让这一变更足够安全。
哪些内容不应该压缩?
重新压缩 JPEG、MP4 或 WOFF2 会在两端浪费 CPU,而收益几乎为零,因为这些格式内部已经过压缩。WOFF2 是最典型的例子:RFC 7932 第 1.2 节记载,该 RFC 所定义的格式已内置于 WOFF 2.0,因此 .woff2 文件本身就是 Brotli 的输出结果。
另一类需要排除的是体积极小的响应。低于几十字节时,编码开销会超过节省的体积,这也是为什么 nginx 的 gzip_min_length 和 ngx_brotli 的 brotli_min_length 默认都是 20 字节,而 Cloudflare 对 gzip 只压缩至少 48 字节的响应,对 Brotli 和 zstd 则是 50 字节。
本文的结论适用于浏览器实际接收的文件,体积从几 KB 到几 MB 不等。那些显示 Brotli 需要耗时数分钟的基准测试,用的都是几百 MB 的文件,这类文件根本不会经由 Content-Encoding 传输。
结论:压缩文本(HTML、CSS、JavaScript、JSON、SVG);跳过媒体文件、字体和超小响应。
结论
算法选择的问题已有定论:构建时压缩的内容一律用最高级别的 Brotli,按请求压缩的内容用 zstd,gzip 作为所有客户端都能理解的兜底底线。而级别问题,才是大多数技术栈从未提出过的问题。对你自己的产物包跑一遍 curl 检查,看清编码方式,再从体积推断出级别——如果答案是”快速默认级别的 gzip”,那么距离用 Brotli 预压缩,也就差一个构建步骤和两条服务器指令。
常见问题
为什么浏览器支持 Brotli,我的站点却仍在发送 gzip?
几乎所有情况都可归结为三种原因。第一,Chrome 和 Firefox 只在 HTTPS 下才会在 Accept-Encoding 中声明 'br',因此纯 HTTP 请求(包括大多数 localhost 开发环境)会退回 gzip。第二,服务器没有加载 Brotli 模块;nginx 需要 ngx_brotli,而它并非内置模块。第三,Cloudflare 这类 CDN 直接透传了源站已标记为 content-encoding: gzip 的响应,而没有将其转码为 Brotli。
Content-Encoding 中的 'deflate' 和 'gzip' 有什么区别?
两者承载的都是 RFC 1951 中的同一套 DEFLATE 算法,区别仅在于外层封装。在 HTTP 中,'deflate' 指 RFC 1950 定义的 zlib 格式(两字节头部,Adler-32 校验和),而 'gzip' 指带 CRC-32 尾部的 RFC 1952 容器格式。早期的服务器和浏览器有时会以 'deflate' 之名发送裸 DEFLATE 数据,迫使客户端自行猜测,因此 gzip 成为了可靠选择,而 deflate 已很少值得提供。
Node.js 原生支持 Brotli 和 zstd 压缩吗?
支持。内置的 node:zlib 模块无需任何第三方包即可实现 gzip、deflate、br 和 zstd 这几种 content-encoding。Brotli 自 Node.js 11.7.0 起通过 zlib.brotliCompress 和 zlib.createBrotliCompress 提供;Zstandard 在 Node.js 23.8.0 中随 zlib.zstdCompress 和 zlib.createZstdCompress 到来,而 Node.js 24.6.0 为 zstd API 增加了字典支持(https://nodejs.org/en/blog/release/v24.6.0)。早于这些版本的压缩中间件可能仍然只协商 gzip,因此请检查它实际输出的 content-encoding。