缓存、压缩与静态资源优化
缓存是性价比最高的性能优化:一个静态资源缓存一年,用户第二次访问就是 0 请求;一个接口缓存 30 秒,突发流量就少打 30 倍的数据库。但缓存也是最容易出事故的优化:HTML 被长缓存后发布不生效,带登录态的接口被共享缓存后用户看到别人的数据。本章把静态缓存、gzip、proxy_cache 三件事的边界划清楚。
要解决的问题
- 什么资源能缓存一年,什么资源一秒都不能缓存?
expires与add_header Cache-Control同时写会怎样?- gzip 开了为什么响应还是没压缩?预压缩(
gzip_static/brotli)怎么用? - 动态接口能不能缓存,缓存了怎么保证不串号?
- CDN 清缓存了,为什么用户还是旧页面?
静态资源缓存策略
核心思路是「内容变则文件名变」:构建工具给资源文件名加内容哈希,文件名一变就是新 URL,旧缓存自然失效。有了这个前提,静态资源可以放心缓存一年。
| 资源类型 | 文件名特征 | 推荐 Cache-Control | 原因 |
|---|---|---|---|
| HTML | index.html | no-cache | 每次都要回源校验,否则发布不生效 |
| JS / CSS(带哈希) | app.a1b2c3.js | public, max-age=31536000, immutable | 内容变文件名就变 |
| 图片 / 字体(带哈希) | logo.9f8e7d.png | public, max-age=31536000, immutable | 同上 |
| 公开列表接口 | /api/public/list | public, max-age=30, stale-while-revalidate=60 | 短缓存抗突发,过期后先给旧的 |
| 用户相关接口 | /api/me | private, no-store | 绝不能进共享缓存 |
| robots / sitemap | /robots.txt | public, 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;
}
expires 与 add_header Cache-Control 的区别与冲突:
| 维度 | expires 1y; | add_header Cache-Control "..."; |
|---|---|---|
| 生成的头 | Expires + Cache-Control: max-age=31536000 | 只生成 Cache-Control,值完全由你写 |
| 表达能力 | 只能表达 max-age | 能表达 immutable、no-store、private、stale-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(默认就会压);图片、woff2、zip 本身就是压缩格式,再压只耗 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/HEAD,POST 结果不能进共享缓存 | 缓存下单结果 |
| 必须能观测 | 加 X-Cache-Status 响应头,否则你永远不知道命中没有 | 以为命中了一直在查应用日志 |
max_size=1g 与 keys_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 on | CDN 把压缩版响应发给不支持 gzip 的客户端 | 开 gzip_vary |
add_header 一加就丢父级头 | HSTS 等安全头在某个 location 里莫名消失 | 用 always,必要时在块内重复声明 |
| 只刷 CDN 不改文件名 | 用户仍拿到旧 JS,接口不兼容直接报错 | 前端产物带哈希,HTML 走刷新 |
open_file_cache 让发布「不生效」 | 换了目录仍是旧内容 | reload 清缓存,或降低 inactive 时间 |
小结:缓存策略就一句话——「文件名带哈希的资源缓存一年并加 immutable,HTML 一律 no-cache」;expires 与 add_header Cache-Control 不要混用,推荐统一用 add_header ... always 明确表达;gzip 只压文本类并开 gzip_vary,能用预压缩就上 gzip_static/brotli;proxy_cache 只对匿名、幂等的公开接口开,且必须加 X-Cache-Status 观测,带登录态的接口连试都不要试。