Nginx 压缩优化实战:gzip_types 白名单、gzip_vary 与 CDN 缓存污染、gzip_static 预压缩与 Brotli 取舍

为什么压缩开了页面反而更大:gzip 参数的真实代价

很多站长在 Nginx 里加上 gzip on; 就以为万事大吉,结果用 Chrome DevTools 一看,HTML 确实被压缩了,但 CSS、JS、字体、接口 JSON 全都没压。更离谱的是有些站点开了 gzip 之后首屏反而变慢,TLS 握手之后多了一次压缩协商的等待。压缩这件事看起来只有一行指令,真正踩进去才知道参数之间的耦合关系非常强。这篇文章把我这两年在一台 1 核 1G 的小机器上做压缩调优的完整过程写下来,包含 gzip 的每一个关键参数、Brotli 的编译与取舍、以及一个特别容易被忽略的点:静态资源已经用构建工具压过了,再让 Nginx 压一次纯属浪费 CPU。

第一步:先搞清压缩发生在链路的哪一层

在动手改配置之前,必须先确认压缩是在哪里做的。一个典型的个人站链路是:浏览器 → Cloudflare(或其它 CDN)→ Nginx → PHP-FPM。这四层里,CDN 和 Nginx 都可能做压缩,而它们的行为是叠加的,不是替代的。

如果 CDN 已经开了自动压缩(Cloudflare 默认对 HTML/CSS/JS 启用 gzip 甚至 Brotli),那源站再压一次的意义就不大了,因为 CDN 回源拿到压缩内容之后会解压缓存再按客户端能力重新压缩。真正需要源站压缩的场景是:你直连源站、或者 CDN 关了压缩、或者你在 CDN 上配了"回源保留原始压缩头"的策略。

判断方法很简单,用 curl 带不同的 Accept-Encoding 请求,看 Content-Encoding 和 Content-Length:

# 不带压缩协商,拿原始体积
curl -sI -H "Accept-Encoding: identity" https://www.example.com/index.html | grep -iE "content-length|content-encoding"

# 带 gzip 协商
curl -sI -H "Accept-Encoding: gzip" https://www.example.com/index.html | grep -iE "content-length|content-encoding|vary"

# 带 br 协商(看对方支不支持 Brotli)
curl -sI -H "Accept-Encoding: br,gzip" https://www.example.com/index.html | grep -iE "content-length|content-encoding|vary"

这里有个细节:curl -I 发的是 HEAD 请求,某些 PHP 应用对 HEAD 的处理和 GET 不同,可能不返回真实长度。要准确测量体积,用下面这个方式拿到真实字节数:

for enc in identity gzip br; do
  printf "%-10s " "$enc"
  curl -s -o /dev/null -w "%{size_download} bytes, %{time_total}s\n" \
    -H "Accept-Encoding: $enc" https://www.example.com/index.html
done

如果 gzip 那一行和 identity 那一行的字节数完全一样,说明压缩根本没生效,先别急着调参数,去看 MIME 类型白名单。

gzip_types 才是压缩失效的头号元凶

Nginx 的 gzip_types 默认值只有 text/html 一项。这句话很多老站长知道,但坑在于:默认只压缩 text/html,而且 Nginx 压缩 text/html 时是无条件的,即使你没写 gzip_types。所以你看到"HTML 压了、CSS 没压"是必然现象,不是配置写错了。

正确做法是把常见类型列全。注意 text/html 不需要写进去(写了也无害),关键是要覆盖 CSS、JS、JSON、SVG、XML:

gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_disable "msie6";
gzip_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/x-javascript
    application/json
    application/xml
    application/rss+xml
    application/atom+xml
    application/vnd.api+json
    application/manifest+json
    image/svg+xml
    font/ttf
    font/otf
    application/x-font-ttf;

关于 gzip_comp_level,网上流传"必须开到 9 才能压到最小"是典型的以讹传讹。6 到 9 之间的体积差异通常不到 1%,但 CPU 耗时可能翻倍。我的实测数据(一个 180KB 的未压缩 JS 文件,单线程):

level=1  耗时 1.2ms  体积 52KB
level=4  耗时 3.8ms  体积 45KB
level=6  耗时 6.1ms  体积 43KB
level=9  耗时 14.7ms 体积 42KB

从 6 提到 9,体积只少 1KB(约 2%),耗时却涨了 2.4 倍。对小机器来说,gzip_comp_level 56 是最优解。真正该关注的是 gzip_min_length:小于这个字节数的响应不压缩,因为压完可能反而更大(gzip 有固定头部开销),而且白白消耗 CPU。设 1024 是通行做法,对纯 API 站可以设 256。

gzip_vary 漏了会引发 CDN 缓存污染

这是最隐蔽也最危险的坑。gzip_vary on; 的作用是让 Nginx 在响应头里加上 Vary: Accept-Encoding。如果漏了这行,而你的站点前面又挂了 CDN 或用了 Nginx 自身的 proxy_cache,就会出现这样的灾难:

  1. 第一个访客用的是老浏览器,只发 Accept-Encoding: gzip 或不发,CDN 缓存了一份未压缩的响应;
  2. 后续所有支持 Brotli 的现代浏览器,都从 CDN 拿到那份未压缩内容,白白浪费带宽;
  3. 反过来更糟:如果先缓存的是压缩版,而某个不支持的客户端来取,会拿到一堆乱码——因为 CDN 认为这份缓存对所有请求都有效。

所以 gzip_vary on; 不是可选项,是必选项。同理,如果你用了 fastcgi_cacheproxy_cache,缓存键(cache key)里必须带上编码维度,否则同一个 URL 的压缩版和未压缩版会互相覆盖。这个坑我在另一篇文章里专门写过,这里只强调结论:凡是响应内容会因请求头而变化的东西,缓存键里就必须包含那个请求头。

gzip_static:让压缩发生在部署阶段而不是请求阶段

压缩最理想的形态是"编译期压缩",也就是磁盘上同时放一份 app.js 和一份 app.js.gz,请求来了直接发 .gz 文件,零 CPU 开销。Nginx 的 gzip_static 模块就是干这个的,但默认安装的 Nginx 往往没编译这个模块,需要先确认:

nginx -V 2>&1 | tr ' ' '\n' | grep -E "gzip_static|brotli"

如果没有输出,说明需要换用带该模块的包(Debian 的 nginx-full 通常包含 ngx_http_gzip_static_module),或者自己编译。启用方式:

gzip_static on;

然后构建时生成预压缩文件:

find /var/www/site/static -type f \( -name "*.js" -o -name "*.css" -o -name "*.svg" \) \
  -exec gzip -9 -k -f {} \;
# -k 保留原文件,-9 因为这里压缩是一次性的,CPU 不是瓶颈

注意 gzip_staticgzip 可以同时开,前者优先,后者兜底。但有一个陷阱:如果 .gz 文件比源文件还旧(比如源文件更新了但忘了重新压缩),Nginx 依然会发旧的 .gz,导致前端拿到过期内容。所以预压缩脚本必须写进部署流程,不能手动执行。用一个简单的守卫:

#!/bin/bash
# 只在源文件比 .gz 新时才重新压缩
find /var/www/site/static -type f \( -name "*.js" -o -name "*.css" \) | while read -r f; do
  if [ ! -f "$f.gz" ] || [ "$f" -nt "$f.gz" ]; then
    gzip -9 -k -f "$f"
    echo "re-compressed: $f"
  fi
done

Brotli 值不值得折腾

Brotli 在文本类型上比 gzip 小 15%–25%,尤其是 CSS 和 JS。但它有两个现实门槛:一是 Nginx 原生不支持,需要编译 ngx_brotli 第三方模块;二是 CDN 支持程度参差,Cloudflare 对 Brotli 的支持是默认开启且不暴露开关的。

如果你的站点前面挂了 Cloudflare,那源站配 Brotli 收益几乎为零——CDN 回源时未必用 br,但它面向终端用户时会用自己的压缩。真正需要考虑 Brotli 的是直连源站、或者用了自建 CDN(比如前面写过的那套 Gost + Nginx 反代方案)的场景。

编译 Brotli 的大致流程(以 Nginx 1.24 为例):

cd /usr/local/src
git clone --recurse-submodules https://github.com/google/ngx_brotli.git
cd nginx-1.24.0
./configure --with-compat --add-dynamic-module=/usr/local/src/ngx_brotli
make modules
cp objs/ngx_http_brotli_filter_module.so /etc/nginx/modules/
cp objs/ngx_http_brotli_static_module.so /etc/nginx/modules/

然后加载模块并配置:

load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;

这里有个关键点:Brotli 的 1–11 级里,5 是动态压缩的甜点,10 和 11 只适合离线预压缩。brotli 11 级压缩一个 200KB 的 JS 可能要几秒钟,放在请求路径上就是灾难。

不要压缩的东西:已经压过的格式

把 PNG、JPEG、WebP、MP4、ZIP、woff2 加进 gzip_types 是纯粹的反优化。这些格式内部已经是压缩数据,gzip 再压一遍:

  • 体积基本不变(甚至因为 gzip 头部而变大几十字节);
  • 白白消耗 CPU,在高并发下成为瓶颈;
  • 某些情况还会破坏 Content-LengthRange 请求的配合,导致视频拖动进度失效。

唯一例外是 SVG(它是 XML 文本,压缩收益很大),所以 SVG 要加,其它图片格式一律不加。实在不确定某个类型该不该压,就用 gzip 手测一下

gzip -9 -c photo.jpg | wc -c   # 和原始大小对比
ls -l photo.jpg

如果压缩后没明显变小,就别放进白名单。

验证压缩是否真正生效的完整清单

改完配置重载之后(nginx -t && nginx -s reload),按这个清单逐项验证:

  1. HTML 压缩curl -s -o /dev/null -w "%{size_download}\n" -H "Accept-Encoding: gzip" https://site/,对比 identity 的结果,应该明显更小。
  2. CSS/JS 压缩:把上一步的 URL 换成 /static/app.css,如果两者字节相同,说明 gzip_types 没生效或者 MIME 类型不匹配。
  3. Vary 头存在curl -sI -H "Accept-Encoding: gzip" https://site/ | grep -i vary,必须看到 Vary: Accept-Encoding
  4. Content-Length 与实际一致:开启压缩后 Nginx 会改用 chunked 或者给出压缩后的长度,如果两者矛盾,说明中间有代理在篡改头部。
  5. 小文件不压缩:找一个小型 CSS(比如 500 字节),确认它没有被压缩(这验证了 gzip_min_length 生效)。
  6. 图片不压缩:请求一张 JPG,确认 Content-Encoding 头不存在。

还有一个常见的验证误区:Chrome DevTools 的 Network 面板显示的 Size 列是"传输大小",但如果响应来自内存或磁盘缓存,它会显示"从缓存获取",此时你无法判断压缩是否生效。验证时务必勾选 "Disable cache" 或者用 curl。另外 DevTools 显示的 Content-Encoding 是解压前还是解压后常常让人困惑——它给的是传输时的编码,所以看到 gzip 就是生效了。

小结与落地顺序

压缩优化的落地顺序应该是:先确认链路里谁在压缩,再补齐 gzip_types,再把 gzip_vary 加上,然后根据负载情况把 gzip_comp_level 降到 5 或 6,接着尝试 gzip_static 把 CPU 开销挪到部署阶段,最后才考虑 Brotli 这种进阶方案。绝大多数个人站的压缩问题,前三步就能解决 90%。剩下那 10% 的收益,往往不如你把功夫花在减少请求数量和优化图片上。

最后提醒一句:压缩不是目的,降低用户的等待时间才是。如果服务器 CPU 本来就吃紧(比如 1 核 VPS 上跑着 MySQL + PHP-FPM + Nginx),把 gzip_comp_level 开到 9 反而会让首字节时间(TTFB)变长,得不偿失。abwrk 压一下,用数据说话:

wrk -t2 -c20 -d30s --latency https://www.example.com/

对比 level 5 和 level 9 时的平均延迟与 99 分位,你就知道该选哪个了。

Last modification:September 23rd, 2026 at 07:23 pm

Leave a Comment