为什么你开了 gzip,页面却一点没变小
很多个人站长在 Nginx 里加上 gzip on; 之后就以为压缩优化完成了,然后拿站长工具一测,发现传输体积几乎没有变化。这种情况非常普遍,原因也往往不在 gzip 本身,而在于一个更基础的问题:你有没有搞清楚地告诉 Nginx,哪些文件类型需要压缩、压缩级别是多少、什么时候该压什么时候不该压。默认的 gzip_types 只包含 text/html,也就是说你的 CSS、JS、JSON、SVG 这些真正的大头全都没被压。本文就把 gzip 配置里最常见的四类静默失效点拆开讲清楚,并给出可以直接抄的配置与验证方法。
失效点一:gzip_types 默认只压 HTML,你的 CSS/JS 全是裸传
Nginx 的 gzip_types 默认值只有 text/html。这个设计的理由是 HTML 通常不大、且实时压缩消耗 CPU,而现代的 HTML 又往往是动态生成的。但对个人站来说,真正占传输体积的是主题的 style.css、打包后的 app.js、图标字体和 JSON 接口数据。
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
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
font/woff2;
gzip_disable "msie6";几个关键点:gzip_comp_level 用 5 就够了,从 5 提到 9 压缩率通常只多 1%–3%,但 CPU 时间会翻好几倍,对本身 CPU 就吃紧的小机器非常不划算。gzip_min_length 设成 1024,太小的文件压缩后可能反而更大(gzip 本身有头部开销)。gzip_vary on 必须开,它会给响应加上 Vary: Accept-Encoding,否则中间 CDN 可能把压缩版发给不支持 gzip 的客户端,或者反过来把未压缩版发给支持 gzip 的客户端,导致缓存错配。
注意 font/woff2:woff2 本身已经是 Brotli 压缩过的,再 gzip 一次基本没有收益,加不加都行,但写进去不会有害。真正需要压的是 image/svg+xml,SVG 是纯文本 XML,压缩率经常能到 70% 以上。
失效点二:gzip 开了但被 CDN 或上游压了两遍
这种情况在「Nginx 反向代理 + 后面还有一层 Nginx/Apache/PHP-FPM」的架构里极常见。如果上游已经输出了 Content-Encoding: gzip 的响应,你在最外层再开 gzip,Nginx 不会重复压(它会识别 Content-Encoding),但如果上游压过、你又开了 gzip_proxied 不为 any,就可能在代理链中间出现「压过又被解压再传」的来回折腾。
判断方法也很直接,看响应头:
curl -sI -H 'Accept-Encoding: gzip, br' https://你的域名/ | grep -iE 'content-encoding|content-length|vary|server'如果看到 content-encoding: gzip 但 content-length 是空的,说明用了分块传输(chunked),这本身正常。真正要警惕的是:明明开了 gzip,响应头里却完全没有 content-encoding。那通常是三种原因之一:客户端 UA 被 gzip_disable 命中、文件小于 gzip_min_length、或者请求的是一个 gzip_types 里没列出的 MIME 类型。建议直接用 -H 'Accept-Encoding: gzip' 指定,不要依赖 curl 默认只发 br 或什么都不发。
失效点三:静态文件没开缓存,每次都在读磁盘
压缩解决的是「体积」,缓存解决的是「要不要再传一次」。这两件事经常被混在一起做,结果两个都没做好。静态资源(图片、CSS、JS、字体)如果文件名里带了哈希或者版本号,就应该设很长的缓存时间:
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|ico|woff2?|ttf)$ {
expires 365d;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~* \.(html|xml|txt)$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}immutable 是告诉浏览器「这个 URL 的文件永远不会变」,在缓存有效期内连条件请求(304 校验)都不会发,这是目前对带哈希文件名资源最彻底的优化。但前提是你的文件名真的带哈希,如果只是 style.css?v=1.2 这种查询串,那就不能加 immutable,而应该用 must-revalidate。
还有一个非常常见的坑:add_header 在 location 里写了之后,会覆盖 http 或 server 块里继承来的所有 add_header。比如你在 server 里全局加了安全头 X-Frame-Options,然后在静态资源 location 里加了 Cache-Control,结果安全头在这个 location 里就全丢了。解决办法是把安全头也在这个 location 里再写一遍,或者用 Nginx 1.7.5+ 的 add_header ... always 并注意作用域。这个坑排查起来非常费时间,因为浏览器里只看到「首页有安全头、静态资源没有」,很容易误判成 CDN 干的。
失效点四:sendfile 与 tcp_nopush 没配对,小文件反而更慢
Nginx 传静态文件的三件套是 sendfile、tcp_nopush、tcp_nodelay。它们的正确组合是:sendfile on(内核态零拷贝)、tcp_nopush on(攒够一个包再发,只在 keepalive 下有效)、tcp_nodelay on。
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;tcp_nopush 和 tcp_nodelay 看起来矛盾,其实 Nginx 的处理逻辑是:tcp_nopush 负责把响应头和数据帧攒在一起发出(避免先发一个小头、再发数据,多一个 RTT),在数据发完后自动切到 tcp_nodelay 立刻发出尾包。两者同时开是最优解,只开一个反而可能更慢。这一点很多老教程没讲清楚,导致有人只开 tcp_nodelay 而关掉 tcp_nopush,结果大文件传输的吞吐明显下降。
另外,如果服务器前面的 CDN 已经做了 TCP 优化,本地这几个参数收益会变小,但不会有害。真正需要注意的是 keepalive_requests,默认只有 100,意味着一个连接传 100 个请求后就断开重建。现代页面动辄 60–100 个资源请求,调大到 1000 能明显减少握手开销,对 HTTPS 站点尤其有效,因为每次重建连接都要走一次 TLS 握手。
顺带说清楚:Brotli 和 gzip 该选哪个
如果你的 Nginx 编译了 ngx_brotli 模块(现在不少发行版和面板都默认带),强烈建议同时开 Brotli。Brotli 的压缩率比 gzip 高 15%–20%,尤其是对 HTML 和 JS 这种重复字符串多的文本文件。推荐的组合是:
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_static on;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;同时保留 gzip 作为兜底,因为仍有极少数老客户端不支持 Brotli。Nginx 会根据请求头里的 Accept-Encoding 优先级自动选择,客户端支持 Brotli 就发 br,否则退回 gzip。这里最容易被忽略的是 brotli_static,它让 Nginx 优先去找磁盘上预生成的 .br 文件,命中时零 CPU 开销。你可以在打包流程里让构建工具顺手输出 app.js.br 和 app.js.gz,这样运行时服务器只做一次磁盘读,压缩成本全部提前到构建阶段,对小内存 VPS 特别友好。
有一点必须注意:brotli 和 gzip 对已经压缩过的格式(JPEG、PNG、MP4、woff2)都无效,写进 types 里只是白费 CPU。如果你的站点图片很多,优化重心应该是换成 WebP/AVIF 和用 srcset 做响应式尺寸,而不是纠结文本压缩。
静态文件服务还有一个隐形杀手:open_file_cache
当站点有大量小文件(比如一个装满缩略图的目录,或者 WordPress 主题下上百个独立资源)时,每次请求 Nginx 都要做一次 stat() 系统调用,去确认文件存在、大小和修改时间。文件多、并发高的时候,这些 stat() 会实打实地吃掉 CPU。开启文件元数据缓存可以直接消除这部分开销:
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;含义是:缓存最多 10000 个文件句柄的元数据,30 秒内没被访问过的条目淘汰,每 60 秒重新校验一次有效性,至少被访问过 2 次的文件才进缓存。open_file_cache_errors on 会把「文件不存在」这个结论也缓存下来——这一步很重要,能挡住大量针对 /wp-login.php、/.env、/.git/config 的扫描请求,那些请求每次都白白碰一次磁盘。
但这里有个必须知道的副作用:缓存生效期间你改文件、Nginx 可能看不到。上线新版本时如果文件名没变(比如直接覆盖了 style.css),最长可能 60 秒内用户仍拿到旧内容。所以 inactive 和 valid 不要设得太长,30s/60s 是实践里比较稳的区间。如果你的发布流程是「直接覆盖同名文件」,那就把这两个值再调小一点,或者干脆采用带哈希的文件名策略,从根本上绕开这个问题。
怎么验证你到底优化成功了
依次做三件事,基本上就能定位问题:
# 1. 看响应头是否真的压缩了
curl -sI -H 'Accept-Encoding: gzip' https://你的域名/static/app.js | grep -iE 'content-encoding|content-length'
gzip -d <<< ...
# 2. 用 curl 的 -w 看各阶段耗时
curl -o /dev/null -s -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' -H 'Accept-Encoding: gzip' https://你的域名/
# 3. 压缩前后体积对比
curl -s -H 'Accept-Encoding: gzip' -o /tmp/a.gz https://你的域名/static/app.js
curl -s -o /tmp/a.raw https://你的域名/static/app.js
ls -l /tmp/a.gz /tmp/a.raw第 2 条里的 ttfb 是首字节时间,它反映的是「服务器处理 + 首次写回」的耗时;total 减去 ttfb 才是「传输耗时」。如果你的 TTFB 是 800ms,那再怎么调 gzip 也没用,问题在后端(数据库慢查询、PHP 阻塞、上游超时),应该去看慢日志而不是压静态资源。
小结
gzip 和静态缓存这两件事,最容易出问题的地方从来不是「有没有开」,而是「开得对不对、作用域有没有打偏、有没有被上一层覆盖」。建议按本文的顺序检查一遍:gzip_types 是否覆盖了你的实际文件类型、Content-Encoding 是否真的出现在响应头里、静态资源的 Cache-Control 是否因为 add_header 覆盖而丢失、以及 ttfb 和 total 的比值是否说明瓶颈其实在后端。把这四点确认完,绝大多数「开了没效果」的问题都能找到答案。改动配置后记得先 nginx -t 再 nginx -s reload,reload 不会断开现有连接,比 restart 安全得多。