静态资源压缩是投产比最高的一项优化:改动通常只有两个配置块,收益是首屏传输量直接下降百分之六十到八十,而且几乎没有副作用。但它也是「配完以为生效、其实没生效」的高发区。原因很朴素——压缩链路要经过浏览器、CDN、Nginx、上游四层,任何一层没协商成功,压缩就不会发生,而服务器不会告诉你「我跳过了」。
这篇把 gzip 和 brotli 的完整落地过程拆开写:先确认压缩到底有没有发生,再决定配多少类型、压到哪一档,最后处理三种最常见的失效场景。全部基于 Nginx,配置可以直接抄,但要按你站点的实际类型调整。
一、先测,别先配
动手前必须有一个基线。用 curl 直接问服务器:这份资源你打算怎么给我。
# 关键:必须带 Accept-Encoding,否则服务器会以为客户端不支持压缩
curl -sI -H "Accept-Encoding: gzip, br" \
"https://www.example.com/css/app.css?_=$(date +%s)"
# 看三个响应头
# Content-Encoding: br ← 用了哪种压缩
# Content-Length: 18234 ← 压缩后的字节数
# Vary: Accept-Encoding ← 有它,CDN 才不会串味返回的头里如果有 Content-Encoding,压缩已生效;如果没有,说明当前配置完全没起作用,后面所有调参都是空谈。同时记下 Content-Length,这是你的基线,改完要对比。Vary 头特别重要:它告诉 CDN「同一个 URL 因 Accept-Encoding 不同会有不同版本」,缺了它,CDN 可能把压缩版发给不支持的客户端,或者把未压缩版缓存给所有支持压缩的浏览器。
顺手再测一份纯文本 HTML 和一个 JS 文件,因为不同文件类型常常走不同分支。有个站点只在 CSS 上生效、JS 上不生效,最后查出是 JS 的 types 里漏写了 application/javascript 只写了 text/javascript,而现在的构建工具输出的 MIME 是新版那个。
二、gzip 的配置:类型、档位、与最小长度
gzip 是底线配置,所有浏览器都支持。一段保守而有效的配置:
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml;逐项说清楚为什么这么写。
gzip_vary on 必须开,作用就是上面说的 Vary: Accept-Encoding。这一行不开,前面 CDN 那一层就可能出问题,代价很大,收益为零。
gzip_comp_level 常用 4 到 6。压缩档位从 1 到 9,6 之后再往上,压缩率提升只有百分之一二,CPU 时间却接近翻倍。对访问量不大、但每个请求都要实时压缩的站点,5 或 6 是最佳平衡;如果资源是预压缩文件(见下一节),档位就无所谓了。反过来,如果 CPU 是你站点的瓶颈(比如同一台机器还跑着 MySQL 和 PHP),把它降到 3 到 4 更划算。
gzip_min_length 建议 1024。太小的响应压缩后反而更大——gzip 头本身就有十几字节,再加 TCP 传输的额外开销,200 字节的响应压完可能变成 220 字节。默认值本来就是 20 字节,几乎一定会压缩一些不值得压缩的小响应。
gzip_proxied any 是反向代理场景的关键。默认情况下,Nginx 遇到带 Via 头的请求(也就是经过了 CDN 或前置代理)会不压缩。你的站点上线了 CDN 之后突然「压缩消失了」,八成就是这一行没配。代价是:所有来自代理的请求都会被压缩,对自建站来说这是想要的效果。
gzip_types 要显式列举。text/html 默认就在压缩列表里,不需要也不能重复写——写了会产生一条警告并重启失败(duplicate MIME type "text/html"),这是个很常见的踩坑点。图片、视频、woff2 字体一律不要加:它们已经是压缩格式,再压一遍几乎不减体积,纯烧 CPU。
三、brotli:更好的压缩率,但要处理兼容
Brotli 在文本类资源上通常比 gzip 小百分之十五到二十五,尤其对体积大的 CSS 和 JS 效果明显。它是 Nginx 的第三方模块,需要额外安装编译,不是开箱即用。
# Debian / Ubuntu 常见装法
apt install libnginx-mod-brotli # 包名因发行版而异
# 确认模块已加载
nginx -V 2>&&1 | tr ' ' '\n' | grep -i brotli如果 nginx -V 的输出里能看到 --add-dynamic-module=.../ngx_brotli,说明可用。配置与 gzip 类似,但有两个必须注意的点:
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;第一,brotli 必须和 gzip 同时开着。绝大多数现代浏览器都支持 br,但不是全部——一些老旧客户端、部分爬虫、命令行工具默认只发 Accept-Encoding: gzip。如果只开 brotli 关掉 gzip,这些客户端会拿到未压缩的响应,体积反而更大。正确的做法是两个都开,让内容协商自己挑。
第二,brotli_comp_level 的甜点比 gzip 更低。它支持 0 到 11,但要清楚:动态压缩时(每次请求实时压)超过 5 之后 CPU 成本陡增;只有预压缩静态文件时才用得上 9 到 11。我在 1 核 2G 的小机器上实测过,把动态压缩从 5 调到 11,单请求耗时从 3 毫秒涨到 20 毫秒以上,而体积只小了不到百分之二。这种调法纯属自伤。
还有一条容易忽略的细节:brotli 对 text/html 默认也是启用的,和 gzip 不同,某些版本上写进 brotli_types 会报重复类型。改完务必 nginx -t,别直接 reload。
四、预压缩:把 CPU 从请求路径里挪走
动态压缩的每个请求都要算一次。但静态资源的内容是不变的,完全可以构建时压好、运行时直接发文件。这就是预压缩,也是唯一能安全使用高档位的地方。
# 构建阶段:同时生成 .gz 和 .br
find /var/www/site/assets -type f \( -name '*.css' -o -name '*.js' -o -name '*.svg' \) \
-exec sh -c 'gzip -9 -k -f "$1"; brotli -q 11 -k -f "$1"' _ {} \;
# Nginx 层:优先发预压缩文件
location ~* \.(css|js|svg)$ {
root /var/www/site;
gzip_static on;
brotli_static on;
expires 30d;
}gzip_static on 和 brotli_static on 会让 Nginx 优先寻找同名的 .gz / .br 文件,找到就直接发,找不到才回退到动态压缩。配合长效缓存,这部分请求的 CPU 成本降到接近零。
这套做法有一个必须记住的纪律:预压缩文件必须和源文件一起更新。如果你改了 CSS 但忘了重新生成 .css.br,用户会拿到旧版本——这种问题非常隐蔽,因为文件本身是对的,只是压缩版没更新。把上面那条 find 命令写进你的部署脚本,和构建产物一起发布,不要依赖人工记得。
五、三种「配了却不生效」的真凶
真凶一:CDN 在前面,压缩发生在边缘而不是源站。很多 CDN 默认会解压源站响应、再用自己的策略重新压缩。这时你在源站测 curl 看到 br,但看 CDN 返回的头时变成了 gzip,甚至没有压缩。判断方法是带一个唯一 query 参数绕过缓存,直接比对 CDN 与源站两边的响应头。如果 CDN 侧没有压缩,去 CDN 控制台开压缩功能,别在 Nginx 上反复试。
真凶二:文件类型对不上。用 curl -sI 看 Content-Type,再对照 gzip_types。.mjs 经常被识别成 application/octet-stream,.json 被识别成 text/plain(如果没有 default_type 配置),而 text/plain 恰好不在默认压缩列表里。修法是在 Nginx 里显式声明类型:types { application/javascript mjs; }。
真凶三:响应被上游设置成了不压缩。PHP 的 ob_gzhandler、某些框架的中间件、或者应用层设置了 Content-Encoding: identity,都会让 Nginx 认为压缩已经由上游处理了。检查方法是在源站关掉应用层压缩,让 Nginx 独占这件事。两处都压会出现双重压缩,浏览器直接显示乱码,这是最明显也最容易发现的错误形态。
六、用一份体积对比表确认收益
改完不要凭感觉说「快了」。抽三个代表性资源,记录改前改后的字节数,写进你的优化记录:
# 对比同一资源在三种协商下的实际传输大小
for enc in "identity" "gzip" "br"; do
size=$(curl -s -o /dev/null -w '%{size_download}' \
-H "Accept-Encoding: $enc" \
"https://www.example.com/css/app.css?cb=1")
echo "$enc: $size bytes"
done三个数字一列,收益一目了然,也能立刻发现「br 和 gzip 一样大」这种异常(通常说明实际没压缩,只是字节碰巧接近)。这张表还有个额外价值:它是你日后判断「某次改配置有没有回退」的对照基准。
七、别忘了这几类不该压的东西
最后列一份负面清单,这些都是新手容易顺手加进 gzip_types 的类型:
JPEG、PNG、WebP、AVIF —— 已经是压缩格式,再压收益接近于零。MP4、WebM —— 同理,而且会持续占用 CPU。woff2 字体 —— 内部已用 brotli 压缩过,再压一遍纯属浪费。ZIP、GZ 归档 —— 没有意义。小于 1KB 的任何文件 —— 由 gzip_min_length 兜住,但如果你把它设成了 0,记得改回来。
另外还有一个反直觉的点:压缩和 TLS 一起用是安全的,但压缩和「用户可控内容 + 密钥」一起用是不安全的。这就是历史上的 BREACH 攻击原理——攻击者通过观察压缩后长度变化推断出机密内容。对普通内容站,这个风险可以忽略;但如果你的页面里同时包含 CSRF token 和大量用户可提交的文本,压不压缩就值得权衡一下。这种场景很少,但知道有这么回事比不知道强。
八、小结:三步走完就算收工
第一步,用带 Accept-Encoding 的 curl 建立基线,确认当前到底压没压。不测就配,等于闭眼改配置。
第二步,gzip 打底、brotli 增强、两者同时开,动态压缩档位不贪高,静态资源改预压缩。把 CPU 成本从请求路径挪到构建路径。
第三步,用三种协商方式的体积对比表验证收益,并把这张表存档作为回归基准。没有数字的优化不算优化。
这三步做完,一个典型的内容站首页传输量通常能降一半以上,配置量不到三十行。在我做过的那台服务器上,这一步是整个性能优化里唯一「改一次、之后两年不用管」的动作。