前阵子帮朋友看了个站,他很确定自己没有写错配置,因为「gzip on 那行明明在了」,而且 gzip_types 也照教程抄了。但他在浏览器开发者工具里看到的 HTML 响应,传输体积一直是 128KB 一点没变。更奇怪的是,换个浏览器打开就又压了。折腾了一个下午,最后发现是三层缓存叠在一起,中间一层把未压缩的响应存了下来,之后所有请求拿到的都是那份缓存副本,gzip 配置写得再对也不会再生效。
这类问题在个人站长里非常普遍:配置本身没错,错的是「缓存的响应早于配置变化」以及「压缩发生在链条的哪一层」。这篇把 Nginx 压缩这条链路的五个可调项、三处最常见的失效、以及一套能定位到具体层级的排查方法写清楚。
第一件要知道的事:压缩只发生在「经过 Nginx 的那一刻」
Nginx 的 ngx_http_gzip_module 是一个响应过滤器:它拿到上游返回的 body,如果满足条件就压,然后发出去。它不压缩已经存在的静态文件——静态文件走的是另一条路(gzip_static,后面讲)。也不压缩「已经压缩过」的内容,靠的是响应头里的 Content-Encoding。
所以理解这条链路的关键是分清方向:浏览器发 Accept-Encoding: gzip, br,Nginx 判断这个请求要不要压,压完之后给响应加 Content-Encoding: gzip 和 Vary: Accept-Encoding。如果上游(PHP-FPM、另一台 Nginx、CDN 回源)自己已经压过了,并且带了 Content-Encoding,Nginx 就直接透传,不再压,也不会因为你配置了 gzip on 而改变什么。
# 看响应到底压了没有:编码头 + 传输字节数
curl -s -o /dev/null -H 'Accept-Encoding: gzip' \
-w 'code=%{http_code} size=%{size_download} enc=%{content_type}\n' \
-D - https://www.example.com/ | head -20这一步很关键:很多人只看页面「感觉快了」,或者用某个在线工具测得「支持 gzip」,但没看实际传输的字节数。把 size_download 和一个不带 Accept-Encoding 的请求对比,差值才是真实的压缩收益。
# 对比:不压 vs 压
for h in "" "Accept-Encoding: gzip"; do
curl -s -o /dev/null ${h:+-H "$h"} \
-w "req=[$h] size=%{size_download}\n" https://www.example.com/
done五个可调项,以及其中最常被误解的两个
gzip 是总开关。它在 http、server、location、if in location 上下文都能用,子块会覆盖父块。
gzip_types 决定哪些 MIME 类型要压。这里有一个高频误解:text/html 永远会被压,不需要也不应该写进 gzip_types,因为那是默认行为。反过来,application/json、application/xml、image/svg+xml、text/css、application/javascript 这些都要显式列进去。
还有一个坑更隐蔽:gzip_types 匹配的是 响应头里的 Content-Type,去掉参数部分。如果上游返回的是 application/json; charset=utf-8,你写 application/json 是能命中的;但如果你上游返回的是 application/vnd.api+json,写 application/json 就不会命中,必须单独列。
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
application/json
application/xml
application/rss+xml
application/javascript
application/x-javascript
image/svg+xml;
gzip_static on;gzip_comp_level 从 1 到 9。1 最快、压缩比最低,9 最慢、压缩比最高。实测下来 5 到 6 是拐点:从 6 到 9,压缩比大概只再多 1%~2%,但 CPU 时间翻倍。对 2 核的机器,选 5 是合理的;如果 CPU 有富余又是纯静态站,可以上 6,不必更高。
gzip_min_length 是「小于这个大小的不压」。默认 20 字节太低了,压缩一个小响应反而会因为 gzip 头部(大约 18~20 字节)而变大。设成 1024 是常见值;对小 JSON 接口可以降到 256。
gzip_proxied 决定「请求来自代理时是否压」。默认是 off,意思是带 Via 头的请求不压。如果你的站前面有 CDN,回源请求可能带 Via 头,这时就必须设 gzip_proxied any,否则 CDN 拿到的全是未压缩的响应——这正是开头那个故事的第二种成因。
这里有一个更细的点:gzip_proxied any 很省事,但它也意味着「不看 Authorization 头就压」。如果你的站有 API 用 Authorization 鉴权,理论上存在 BREACH 攻击面(利用压缩比推断明文)。个人站的风险极低,但如果接口里有敏感 token 回显,可以写成 gzip_proxied any; 再加一条 location /api/ { gzip off; } 精确关掉。
第二件事:静态文件的压缩应该在构建时做
gzip_static on 打开之后,Nginx 收到 /style.css 的请求会先找 /style.css.gz,存在就直接发,不需要实时压缩。这比 gzip on 的运行时压缩省 CPU,而且可以预先用 gzip -9 压到最高比。
要求是文件必须真的存在,所以部署脚本要负责生成。用一条 find 就够:
find /var/www/site -type f \
\( -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.json' \) \
-newer /var/www/site/.last_gzip \
-exec gzip -9 -k -f {} \;
touch /var/www/site/.last_gzipgzip_static 有三种值:on(有 .gz 就用,没有就走 gzip on 的运行时压缩)、off、always(不管客户端 Accept-Encoding 里有没有 gzip,只要有 .gz 就发——这个值很危险,会让不支持 gzip 的客户端拿到乱码,基本不该用)。
还有一个必须知道的坑:gzip_static 需要模块存在。Debian/Ubuntu 的官方包默认带 ngx_http_gzip_static_module,但如果是自己编译或者用了某些精简镜像,配置里有这一行而模块不存在,nginx -t 会直接报 unknown directive "gzip_static"——这个报错很明确,不会静默失效,算好消息。
在 Nginx 1.21.6 之后还有个更强的 ngx_http_brotli_static_module(需要第三方模块,1.25.3 起部分发行版内置)。Brotli 对文本的压缩比 gzip 高 15%~20%,代价是压缩慢(所以静态预压缩 + brotli_static 是最佳组合)。配置顺序是按 Accept-Encoding 的偏好来的,客户端支持 br 就发 .br:
brotli_static on;
gzip_static on;第三件事:三层缓存各自都可能把「未压缩」存下来
这是本文的重点,因为它是「配置对了但没效果」的最大来源。
第一层是 CDN。CDN 的缓存键通常包含 URL 和少数指定的头,一般不包含 Accept-Encoding,目的是避免同一资源存两份。所以 CDN 会在回源时用固定的 Accept-Encoding(常见是 gzip, deflate 或者干脆不带),然后把它拿到的响应缓存起来给所有用户。如果那次回源因为某种原因拿到了未压缩的响应,之后所有人拿到的都是未压缩的。
CDN 这一层最关键的是 Vary: Accept-Encoding 头。gzip_vary on 就是干这个的,它告诉中间的缓存层「同一个 URL 因为有这个头不同,要分开缓存」。少了它,CDN 可能把一个「给支持 gzip 的浏览器」的压缩副本发给不支持 gzip 的客户端(或者反过来)。
# 确认 Vary 头存在
curl -sI -H 'Accept-Encoding: gzip' https://www.example.com/ | grep -i -E 'vary|content-encoding'第二层是 proxy_cache / fastcgi_cache。如果站上开了整页缓存,Nginx 自己的 proxy_cache 存的也是「缓存那一刻的响应」。这里有一个非常容易踩的点:缓存键里如果不含 Accept-Encoding,就会出现「文本响应被存成压缩版、然后发给一个不支持 gzip 的客户端」的问题,客户端看到乱码。反过来也可能「缓存了未压缩版,配置开了 gzip 也不生效」。
正规做法是在 proxy_cache_key 里带上编码:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=fullpage:32m
max_size=2g inactive=30m use_temp_path=off;
map $http_accept_encoding $cache_enc {
default "";
"~*gzip" "-gz";
"~*br" "-br";
}
proxy_cache_key "$scheme$host$request_uri$cache_enc";注意这个 map 里 gzip 和 br 的顺序:如果客户端同时支持 gzip 和 br,两条都会命中,Nginx 的 map 是「先匹配先返回」,所以 br 应该写在前面才能得到更优的键。写反了不会出错,只是键的分布不符预期。
另外 default "" 中那条空字符串很重要:map 的 default 必须是一个不参与缓存键变化的值,如果写成 default $http_accept_encoding,那就等于把整个头放进键里,gzip, deflate, br, zstd 和 gzip, deflate 会存成两份完全相同的副本,缓存命中率直接掉一半。
第三层是浏览器。现代浏览器的开发者工具会「显示解压后的内容」,所以从 Network 面板的体积列看是解压后的大小,让人误以为没压。要看真实体积,得看 Content-Encoding 响应头或者用 curl 量。这一层最容易造成误判,我遇到过好几次「客户说没压,其实是浏览器显示的解压体积」。
第四件事:一套按顺序的排查流程
按「从外往内」的顺序排查,每一步只花一条命令:
第一步,直连源站看有没有压。绕过 CDN,用 --resolve 把域名指到源站 IP:
curl -s -o /dev/null -D /tmp/h.txt \
--resolve www.example.com:443:203.0.113.10 \
-H 'Accept-Encoding: gzip' \
-w 'size=%{size_download}\n' https://www.example.com/
grep -i -E 'content-encoding|vary|content-length' /tmp/h.txt源站有 content-encoding: gzip → 说明 Nginx 配置生效了,问题在 CDN;源站没有 → 问题在 Nginx 或上游。
第二步,看 Nginx 实际加载的配置。nginx -T 会打印合并后的完整配置,比翻各个 include 快得多:
nginx -T 2>/dev/null | grep -n -A3 -B3 'gzip'这里能看出两件事:gzip 是不是真的在某处被 off 掉了(包括某个 location 里的 gzip off),以及 gzip_types 是不是被后面的 include 覆盖。常见的现象是站点配置里写了 include /etc/nginx/mime.types; 之后某个 snippet 又写了一行 gzip_types text/css;,把前面的整条列表覆盖掉——gzip_types 是「设置」语义而不是「追加」语义。
第三步,看响应头里的 Content-Type 对不对。如果 Content-Type 是 text/html; charset=utf-8,那 gzip on 就该压,如果没压,只可能是 gzip_min_length 限制、或者是上游已经带了 Content-Encoding、或者请求的 Accept-Encoding 不含 gzip。
curl -sI -H 'Accept-Encoding: gzip' https://www.example.com/api/list | \
grep -i -E 'content-type|content-encoding|content-length'如果 Content-Type 是 application/octet-stream,那不管怎么配 gzip_types 都不会压——因为类型没在列表里。这类问题在上传的文件、CDN 回源的静态资源上特别常见。
第四步,清 CDN 缓存再测。如果前三步都正常,就把那个 URL 在 CDN 上手动刷新,然后再测一次。刷完就压了,说明是「缓存了旧的未压缩副本」,需要在 CDN 侧调整回源规则(强制 Accept-Encoding: gzip)或者在源站加上 Vary。
第五件事:几个「不该压」的东西
压缩不是越多越好,有几类内容压了反而有害:
- 图片、字体、视频(
image/jpeg、image/png、font/woff2、video/mp4)。这些格式本身就是压缩过的,再 gzip 一遍只会浪费 CPU,体积甚至变大。所以gzip_types里千万不要出现*。 - 小响应(小于 1KB)。gzip 头和尾部加起来接近 20 字节,压缩收益可能为负。
- 已经压缩过的二进制流,比如
.gz、.br、.zip、.7z。 - 大文件下载(几十 MB 以上),实时压缩会让 Nginx worker 长时间占着 CPU,而且对已经是压缩格式的文件没收益。如果确实要下发大文件,用预压缩版本。
# 检查 gzip_types 里有没有危险的 * 或者二进制类型
nginx -T 2>/dev/null | grep -E 'gzip_types' | grep -E '\*|image/jpeg|application/zip|font/woff'第六件事:压缩之外,还有一件事更值得做
这里想诚实地说一句:压缩的收益上限是确定的。一份 128KB 的 HTML 压到 22KB 是 82% 的节省,听起来很多,但如果这个 HTML 是从 300KB 优化到 128KB 的,那前面的优化(去掉没用的 CSS、延迟加载脚本、内联关键 CSS)贡献了更大的绝对量。压缩是「几乎无成本就能拿到」的收益,但不要指望它解决所有问题。
另外一个常被忽略的关联项是 sendfile 与 tcp_nopush。静态文件配合 sendfile on 可以零拷贝,但开了 gzip_static 之后,文件已经是 .gz 了,走的路径不太一样。这里没有坑,只要记得 tcp_nopush on 在 sendfile on 之上才能把响应头和 body 合到同一个包里发出去:
sendfile on;
tcp_nopush on;
tcp_nodelay on;tcp_nopush 和 tcp_nodelay 看起来矛盾(一个攒包一个不攒),实际不矛盾:tcp_nopush 只在 sendfile 场景下把 header 和文件开头合包,tcp_nodelay 保证最后的碎片立刻发出。两个都开是推荐组合。
一个可以抄的完整片段
# 放在 http 块
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_disable "msie6";
gzip_types
text/plain text/css text/xml text/javascript
application/json application/xml application/rss+xml
application/javascript image/svg+xml;
# 放在 server 或 location 块
location ~* \.(css|js|svg|json)$ {
gzip_static on;
expires 7d;
add_header Cache-Control "public";
}
location ~* \.(png|jpe?g|gif|webp|avif|woff2?|mp4|zip)$ {
gzip off;
expires 30d;
add_header Cache-Control "public, immutable";
}两个注意点。gzip_disable "msie6" 是历史遗留,IE6 的 gzip 实现有问题,现在基本可以删掉,但留着无害。add_header 在 location 里会完全覆盖父块的 add_header,所以如果 server 块里配了 HSTS、X-Frame-Options,这两个 location 里必须重复写一遍,否则那些安全头会在这两个 location 里静默消失。这是 Nginx 里最著名的一个陷阱,nginx -t 不会报,只能靠人为记得。
收尾:把它变成一次可重复的验证
配置改完之后,我给自己留了一个脚本,跑一次就能确认「HTML、CSS、JS、JSON 都压了,图片没压,Vary 头都有」,避免以后某次改 nginx.conf 时不小心覆盖掉:
#!/bin/bash
BASE=${1:-https://www.example.com}
for u in / /style.css /app.js /api/list /logo.png; do
hdr=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' --max-time 10 "$BASE$u")
enc=$(printf '%s' "$hdr" | grep -i '^content-encoding' | tr -d '\r')
vary=$(printf '%s' "$hdr" | grep -ci '^vary:.*accept-encoding')
printf '%-14s %-24s vary=%s\n' "$u" "${enc:-none}" "$vary"
done注意这里没有 set -e,因为 grep -c 在无匹配时返回 1,会让脚本在第一项就静默退出。这类脚本我一般都在开头显式写着「不用 set -e」的注释,因为半年后自己会忘。