先说结论:为什么「实时压缩」不如「预压缩」
给静态资源开 gzip 是几乎每个站长都会做的第一件事——在 Nginx 里写上 gzip on;,重启,用浏览器的开发者工具看到 Content-Encoding: gzip,就觉得性能优化完成了。但这里藏着一个长期被忽视的性能陷阱:Nginx 的 gzip on 是「运行时压缩」,也就是每次有请求进来,它都现压一次再发出去。
压缩本身是要消耗 CPU 的,尤其是 gzip 默认级别 6 的压缩率,对每个请求都做一遍,在并发高的时候会成为实打实的 CPU 瓶颈。更糟的是,同一个 CSS 文件被一万个用户请求,Nginx 就压缩一万次——明明内容一模一样,结果却每次都重算一遍。这是纯粹的浪费。
gzip_static 和 Brotli 的解决思路是「把压缩提前到构建阶段」:在发布资源的时候,用构建脚本一次性生成 app.css.gz 和 app.css.br,Nginx 收到请求时直接找到对应的预压缩文件发出去,运行时零 CPU 开销。而 Brotli 则是比 gzip 更现代的压缩算法,同样的内容能再小 15% 到 20%,对文本类资源(HTML、CSS、JS、JSON)效果尤其明显。
这篇文章讲清楚预压缩的完整落地:算法怎么选、构建管线怎么写、Nginx 怎么配、以及那个最常见的坑——「配置写了但压缩根本没生效」到底怎么排查。
gzip 与 Brotli 的取舍:什么时候用哪个
先建立一张对比的直觉。
- gzip:1992 年的算法,兼容性无敌,所有浏览器都支持,压缩率中等,速度快。是「无论如何都要有」的底线。
- Brotli:Google 2015 年发布,对文本压缩率更高(比 gzip 小 15% 到 20%),解压速度和 gzip 相当,但压缩速度较慢——所以特别适合「预压缩」场景(压一次,服务无数次)。现代浏览器(Chrome、Firefox、Edge、Safari 11+)都支持,返回
Content-Encoding: br。
实践中的正确策略是两个都生成、两个都配:构建时既产出 .gz 也产出 .br,Nginx 根据客户端 Accept-Encoding 头自动挑选——支持 Brotli 的给 .br,只支持 gzip 的给 .gz。这样既拿到了 Brotli 的加速红利,又保住了老浏览器的兼容性。
有一点必须注意:Brotli 不建议对已经很小的文件使用。比如一个 300 字节的 favicon.svg,压缩后可能反而变大(要带压缩字典头),而且省下的那点体积还不够多一次 HTTP 协商的开销。经验值是「小于 1KB 的文本资源不值得压缩」,大于 1KB 才考虑。
构建阶段:批量生成 .gz 和 .br
预压缩的关键是「在发布前把压缩文件生成好」。手动压几个文件当然可以,但真正可持续的是写进构建脚本。假设你的静态资源放在 /var/www/site/static/,用一个 shell 脚本遍历生成:
#!/bin/bash
set -euo pipefail
ROOT="/var/www/site/static"
find "$ROOT" -type f \( -name "*.css" -o -name "*.js" -o -name "*.html" -o -name "*.svg" -o -name "*.json" \) | while read -r f; do
# 跳过已经压过的文件,避免生成 .gz.gz
case "$f" in *.gz|*.br) continue;; esac
# gzip 预压缩,-9 最高压缩率,-k 保留原文件
gzip -9 -k -f "$f"
# brotli 预压缩,-q 11 最高质量
if command -v brotli > /dev/null; then
brotli -q 11 -k -f "$f"
fi
done
echo "precompress done"
这里有几个细节值得强调。gzip -k 是关键参数,没有它 .gz 生成后原文件会被删掉——那你就把站点搞崩了。-f 强制覆盖已有压缩文件,处理增量更新时不会因文件存在而中断。Brotli 的命令行工具在 Debian/Ubuntu 上是 apt install brotli,它的 -q 11 是最高质量(也是最慢的),正因为慢,才更要预压缩而不是实时压。
更现代的做法是在前端构建管线(Vite、webpack)里用插件自动完成,比如 Vite 的 vite-plugin-compression 可以同时输出 gzip 和 brotli 版本。无论用哪种方式,原则一致:压缩发生在构建/发布阶段,而不是请求阶段。
Nginx 配置:让预压缩文件真正被使用
光有 .gz 和 .br 文件还不够,得让 Nginx 知道「请求 app.css 时,如果客户端接受 br,就发 app.css.br」。配置分两部分,缺一不可。
# ---- gzip_static:让 Nginx 优先使用 .gz 文件 ----
gzip_static on;
# ---- Brotli:需要 ngx_brotli 模块 ----
brotli_static on;
# 兜底:如果预压缩文件不存在,就实时压缩(可选)
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_vary on;
gzip_static on; 是核心指令,它告诉 Nginx:收到 app.css 的请求时,先去找同目录下的 app.css.gz,找到就直接发(自动带上 Content-Encoding: gzip),找不到再退回正常处理。brotli_static on; 同理,但它需要一个单独编译的模块 ngx_brotli——Nginx 官方发行版默认不含这个模块,这是后面「配置了没生效」的头号原因,稍后细说。
gzip_vary on; 很容易被漏掉,但很重要。它会让响应带上 Vary: Accept-Encoding 头,告诉 CDN 和代理缓存「同一 URL 针对不同 Accept-Encoding 有不同版本」。少了这个头,中间的 CDN 可能把 gzip 版本缓存后发给只接受 br 的客户端,或者反之,导致客户端拿到无法解码的内容。
最关键的一节:为什么「配了没生效」
这是本节的主题,也是无数站长卡住的地方。你以为配好了 gzip_static on;,结果 curl -I 一看,响应头里根本没有 Content-Encoding: gzip。按下面这个顺序排查,能定位 99% 的情况。
排查点一:ngx_brotli 模块是否真的装上了。 运行 nginx -V 2>&1 | tr ' ' '\n' | grep brotli,如果没有输出,说明你的 Nginx 没编译 brotli 模块,brotli_static on; 会直接报错「unknown directive」让你起不来,或者(如果你把指令写在注释里)根本不生效。安装方式要么是换用带 brotli 的发行版包(比如某些第三方的 Nginx 源),要么自己编译。Debian/Ubuntu 上比较省事的做法是用 libnginx-mod-brotli 这个包(如果可用),它会自动放进模块目录。
排查点二:预压缩文件是否真的存在、名字对不对。 Nginx 查找的规则严格依赖文件名后缀。gzip_static on 找的是 .gz,brotli_static on 找的是 .br,而且必须和原文件在同一目录。检查一下:
ls -la /var/www/site/static/app.css*
# 应该看到三个文件:app.css app.css.gz app.css.br
如果只有 app.css,说明构建脚本没跑或者路径不对。如果压缩文件在,但名字是 app.css.gzip 而不是 app.css.gz,Nginx 也找不到。
排查点三:客户端有没有发 Accept-Encoding。 预压缩只在客户端声明支持时才启用。curl 默认不发 Accept-Encoding,所以你直接 curl -I 是看不到压缩的——这是一个极其常见的自欺欺人时刻。正确的验证方式是手动带上头:
# 测试 gzip
curl -sI -H "Accept-Encoding: gzip" https://你的域名/static/app.css | grep -i content-encoding
# 测试 brotli
curl -sI -H "Accept-Encoding: br" https://你的域名/static/app.css | grep -i content-encoding
前者应该返回 Content-Encoding: gzip,后者返回 Content-Encoding: br。如果都没返回,回到排查点一和二。
排查点四:静态文件的 location 有没有覆盖掉配置。 如果你为静态资源单独写了一个 location ~* \.(css|js)$ 块,那 gzip_static / brotli_static 要么写在全局、要么写在每个 location 里。指令的作用域是「继承自上级」,如果某个 location 里用 gzip_static off; 显式关了,就会覆盖全局。同理,如果静态资源被 try_files 改写了路径,Nginx 可能会去找一个不存在的压缩文件。检查方法是把配置贴到 nginx -T 看最终生效的合并结果。
排查点五:CDN 或反代吃掉了 Content-Encoding。 如果你前面挂了 CDN,CDN 可能自己重新压缩或者解压后原样回源,导致你 Nginx 发出的 br/gz 到了用户端变成别的。验证方法是直接访问源站 IP(绕过 CDN)对比,如果源站有压缩、经过 CDN 就没了,那就是 CDN 的处理逻辑。很多 CDN 默认对文本资源开启自己的压缩,这时需要关掉 CDN 侧的压缩,让它尊重回源头的 Content-Encoding。
验证压缩效果:别只看有没有,要看好不好
配好之后,量化一下收益是很有必要的。用 curl 同时拿到压缩前后大小最直观:
URL="https://你的域名/static/app.css"
echo -n "原始: "; curl -s "$URL" -H "Accept-Encoding: identity" | wc -c
echo -n "gzip: "; curl -s "$URL" -H "Accept-Encoding: gzip" | wc -c
echo -n "brotli: "; curl -s "$URL" -H "Accept-Encoding: br" | wc -c
典型的文本资源(比如一个 200KB 的 CSS)压缩后 gzip 大约到 45KB,Brotli 大约到 38KB。看到 brotli 明显小于 gzip,就说明预压缩真的生效了。如果 brotli 那一行跟原始大小一样,说明 br 文件没被使用。
另外一个容易忽略的验证点是缓存头。预压缩文件应该和原文件一样带长缓存(Cache-Control: max-age=31536000 加指纹文件名),否则每次请求还是要走一遍协商。用构建产物的内容哈希做文件名(比如 app.a1b2c3.css),内容一变文件名就变,可以放心设置一年长缓存,这对重复访问的用户体验提升巨大。
踩坑复盘
第一个坑是把 gzip_static 和 gzip 混为一谈。这两个指令同时开是合理的(预压缩优先,兜底实时压),但如果只开了 gzip on 而没开 gzip_static on,那你的 .gz 文件就是死文件,永远不被使用,CPU 该烧还是烧。检查配置时按指令名精确核对。
第二个坑是Brotli 质量等级用错场景。-q 11 压得最小但最慢,如果误用在实时压缩(brotli on 那种动态压缩)上,会让每个请求都卡几百毫秒。记住:-q 11 只配预压缩,动态压缩用 -q 5 左右,甚至干脆不做动态 Brotli。
第三个坑是压缩了不该压的类型。JPEG、PNG、WebP、MP4、woff2 这些已经是压缩格式,再套一层 gzip 得到的收益接近零,甚至可能因为压缩头而略微变大,纯粹浪费 CPU。gzip_types 里只列文本类(text、application/javascript、application/json、image/svg+xml),别图省事写 *。
第四个坑是忘记在发布脚本里重新生成压缩文件。你更新了 app.css,但没重新生成 app.css.gz,结果用户拿到的是旧版本的压缩内容——现象是「改了样式刷新没变化」,排查半天怀疑缓存,其实是预压缩文件过期了。把预压缩步骤固化进发布脚本,并在部署后用 ls -la 对比原文件和压缩文件的修改时间,能立刻发现问题。
小结
预压缩(gzip_static + brotli_static)是个人站长能拿到的、性价比最高的性能优化之一:它把 CPU 开销从「每次请求」挪到「每次发布」,同时用 Brotli 再省下 15% 到 20% 的传输体积。落地要点就三条——构建阶段生成 .gz 和 .br、Nginx 里同时开 gzip_static on; 和 brotli_static on;(并确认 brotli 模块真的装了)、用带 Accept-Encoding 的 curl 验证而不是裸 curl -I。
最后再强调那个最坑的验证细节:裸 curl -I 看不到压缩是正常的,因为它不发 Accept-Encoding。很多站长在这一步误判「没生效」,然后反复折腾配置,其实配置一直是对的。记住用 -H "Accept-Encoding: br",这个习惯能帮你省下大量排查时间。