缓存、压缩与静态资源优化

缓存是性价比最高的性能优化:一个静态资源缓存一年,用户第二次访问就是 0 请求;一个接口缓存 30 秒,突发流量就少打 30 倍的数据库。但缓存也是最容易出事故的优化:HTML 被长缓存后发布不生效,带登录态的接口被共享缓存后用户看到别人的数据。本章把静态缓存、gzip、proxy_cache 三件事的边界划清楚。

要解决的问题

  • 什么资源能缓存一年,什么资源一秒都不能缓存?
  • expiresadd_header Cache-Control 同时写会怎样?
  • gzip 开了为什么响应还是没压缩?预压缩(gzip_static/brotli)怎么用?
  • 动态接口能不能缓存,缓存了怎么保证不串号?
  • CDN 清缓存了,为什么用户还是旧页面?

静态资源缓存策略

核心思路是「内容变则文件名变」:构建工具给资源文件名加内容哈希,文件名一变就是新 URL,旧缓存自然失效。有了这个前提,静态资源可以放心缓存一年。

资源类型文件名特征推荐 Cache-Control原因
HTMLindex.htmlno-cache每次都要回源校验,否则发布不生效
JS / CSS(带哈希)app.a1b2c3.jspublic, max-age=31536000, immutable内容变文件名就变
图片 / 字体(带哈希)logo.9f8e7d.pngpublic, max-age=31536000, immutable同上
公开列表接口/api/public/listpublic, max-age=30, stale-while-revalidate=60短缓存抗突发,过期后先给旧的
用户相关接口/api/meprivate, no-store绝不能进共享缓存
robots / sitemap/robots.txtpublic, max-age=3600一天一次足够
# 带哈希指纹的资源:长缓存 + immutable 告诉浏览器「刷新也别来问了」
location ~* \.(?:js|css|png|jpe?g|gif|webp|svg|ico|woff2?)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    access_log off;
}

# HTML 必须每次校验,no-cache 表示可以缓存但要先问服务器
location = /index.html {
    add_header Cache-Control "no-cache, must-revalidate" always;
}

expiresadd_header Cache-Control 的区别与冲突:

维度expires 1y;add_header Cache-Control "...";
生成的头Expires + Cache-Control: max-age=31536000只生成 Cache-Control,值完全由你写
表达能力只能表达 max-age能表达 immutableno-storeprivatestale-while-revalidate
覆盖关系add_header 同时出现时,最终头以 curl -I 实测为准,同一响应出现两个 Cache-Control 是常见现象建议二选一
推荐只用它表达简单的 max-age推荐:明示意图、配 always 保证错误响应也带上

实践结论:只用 add_header Cache-Control ... always,不要和 expires 混用。另外 add_header 的继承规则很坑——子配置块里只要出现任意一条 add_header,父级块的全部 add_header 都不再继承。

压缩:gzip 与预压缩

# http 块
gzip on;
gzip_comp_level 5;                 # 1~9,5 是压缩率与 CPU 的平衡点
gzip_min_length 1k;                # 小于 1k 压缩后可能更大
gzip_types text/plain text/css application/json application/javascript
           application/xml text/xml image/svg+xml font/ttf;
gzip_vary on;                      # 加 Vary: Accept-Encoding,避免 CDN 缓存错版本
gzip_static on;                    # 直接读 .gz 预压缩文件(需 ngx_http_gzip_static_module)

# brotli:需第三方模块 ngx_brotli,未编译时 nginx -t 会直接报 unknown directive
brotli on;                         # 需第三方模块 ngx_brotli
brotli_types text/plain text/css application/json application/javascript image/svg+xml;

三个要点:text/html 不必写进 gzip_types(默认就会压);图片、woff2zip 本身就是压缩格式,再压只耗 CPU 不省带宽;gzip_static 让构建流程预先压好 .gz 文件,请求时零 CPU 开销,高并发站点值得开。

静态文件与内核参数

sendfile on;                       # 零拷贝,静态文件必开
tcp_nopush on;                     # 配合 sendfile,攒满一个包再发
tcp_nodelay on;                    # 长连接上立即发送,降低延迟
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_errors on;         # 缓存「文件不存在」的结果,防遍历攻击

open_file_cache 缓存的是文件句柄与元信息,能省掉大量 stat 系统调用;但发布换目录(软链切换)后若出现「读到旧文件」,先想到它——systemctl reload nginx 即可清空。

动态接口缓存:proxy_cache

# http 块:定义缓存区
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:10m
                 max_size=1g inactive=10m use_temp_path=off;

server {
    location /api/public/ {
        proxy_cache            api_cache;
        proxy_cache_key        "$scheme$request_method$host$request_uri";
        proxy_cache_valid      200 302 60s;
        proxy_cache_valid      404 10s;
        proxy_cache_valid      any 1m;
        proxy_cache_bypass     $cookie_session $arg_nocache;   # 有登录态或带 nocache 参数就绕过
        proxy_no_cache         $cookie_session $arg_nocache;   # 且不写入缓存
        proxy_cache_use_stale  error timeout updating http_500 http_502 http_503 http_504;
        add_header X-Cache-Status $upstream_cache_status always;   # HIT / MISS / BYPASS / STALE
        proxy_pass http://app_backend;
    }
}

proxy_cache 的三条铁律:

铁律说明反例
只缓存匿名请求Cookie/Authorization 的响应默认不缓存,显式缓存必须加 bypass缓存了 /api/me,A 用户看到 B 用户的数据
只缓存幂等方法默认只缓存 GET/HEADPOST 结果不能进共享缓存缓存下单结果
必须能观测X-Cache-Status 响应头,否则你永远不知道命中没有以为命中了一直在查应用日志

max_size=1gkeys_zone=10m 的关系:keys_zone 存的是键与元数据(10m 约能放 8 万个键),max_size 是磁盘上限,两者要一起给。

CDN 与两层缓存

CDN 打开后缓存变成两层:浏览器一层、边缘节点一层。所有缓存策略、Cache-Control、刷新动作的最终依据都是源站返回的头——源站头写错了,CDN 配置再花哨也没用。发版时的正确顺序是:产物文件名带新哈希 → 上传源站 → 刷新 CDN 上的 HTML 与 index.html → 用户拿到新 HTML,进而请求新哈希的静态资源。只刷 HTML 就够,别去刷一大片静态资源(刷新配额有限,且会瞬间打满回源)。

验证方法

# 静态资源:应有长 max-age 与 immutable
curl -sI https://static.example.com/assets/app.a1b2c3.js | grep -i -E 'cache-control|expires|content-encoding|vary'
# HTML:应为 no-cache
curl -sI https://static.example.com/index.html | grep -i cache-control
# 主动带 Accept-Encoding 验证压缩是否真的生效
curl -sI -H 'Accept-Encoding: gzip, br' https://static.example.com/assets/app.a1b2c3.js | grep -i -E 'content-encoding|content-length'
# 动态缓存命中情况
for i in 1 2 3; do curl -sI https://api.example.com/api/public/list | grep -i x-cache-status; done
# 缓存目录占用与文件数
du -sh /var/cache/nginx/api && find /var/cache/nginx/api -type f | wc -l

常见坑

现象做法
HTML 被长缓存发布后用户还看到旧页面HTML 用 no-cache,只有带哈希资源才长缓存
缓存了带登录态的接口用户看到别人的数据,最严重的一类事故proxy_cache_bypass/proxy_no_cache$cookie_session
忘了 gzip_vary onCDN 把压缩版响应发给不支持 gzip 的客户端gzip_vary
add_header 一加就丢父级头HSTS 等安全头在某个 location 里莫名消失always,必要时在块内重复声明
只刷 CDN 不改文件名用户仍拿到旧 JS,接口不兼容直接报错前端产物带哈希,HTML 走刷新
open_file_cache 让发布「不生效」换了目录仍是旧内容reload 清缓存,或降低 inactive 时间

小结:缓存策略就一句话——「文件名带哈希的资源缓存一年并加 immutable,HTML 一律 no-cache」;expiresadd_header Cache-Control 不要混用,推荐统一用 add_header ... always 明确表达;gzip 只压文本类并开 gzip_vary,能用预压缩就上 gzip_static/brotli;proxy_cache 只对匿名、幂等的公开接口开,且必须加 X-Cache-Status 观测,带登录态的接口连试都不要试。

笔记加载中…