12541 字
63 分钟
第 6 篇:Nginx 缓存、压缩与静态资源优化

第 6 篇:Nginx 缓存、压缩与静态资源优化——从浏览器缓存到代理缓存与 CDN 协作#

专栏:《Nginx 从入口代理到云原生流量治理》
文章序号:第 6 篇
Nginx 实验版本:Nginx Open Source 1.30.3(稳定版)
当前主线版本:1.31.2
后端实验版本:Spring Boot 4.1.0、Java 21
实验环境:Docker Compose、Linux、curl
更新时间:2026-07-07


本文在专栏中的位置#

前五篇已经完成了 Nginx 入口层的主体能力建设:

  1. 第一篇确定 Nginx 在后端架构中的位置;
  2. 第二篇讲清配置层级以及 serverlocation 的匹配过程;
  3. 第三篇完成反向代理、Header、超时、缓冲、WebSocket 与 SSE;
  4. 第四篇将单实例代理扩展为多实例负载均衡与高可用;
  5. 第五篇完成 TLS、证书自动续期和安全入口。

此时,请求已经能够安全地到达 Nginx,再由 Nginx 稳定地转发给后端服务。但系统仍然可能面临以下性能问题:

  • 每次访问都重新下载没有变化的 JavaScript、CSS、字体和图片;
  • 前端静态资源仍然穿透到 Spring Boot,占用应用线程和网络连接;
  • 大量相同的公共查询请求重复进入数据库;
  • JSON、HTML、CSS 等文本响应没有压缩,浪费公网带宽;
  • 热点缓存刚失效时,大量请求同时回源,击穿后端;
  • 后端短暂故障时,入口层明明有旧缓存,却直接向用户返回 502 或 504;
  • 浏览器、CDN、Nginx 和应用都在缓存,但没有统一 TTL、Cache Key 和失效责任;
  • 用户看到旧数据时,团队无法判断旧值究竟来自浏览器、CDN、Nginx 还是应用缓存。

第六篇要解决的不是“给 Nginx 加一行 gzip on”,而是建立一套分层缓存和内容交付模型:

浏览器私有缓存
↓ MISS / 需要验证
CDN 边缘共享缓存
↓ MISS / 回源
Nginx 静态文件或代理缓存
↓ MISS / 回源
Spring Boot 与业务数据源

每一层都必须回答四个问题:

  1. 缓存什么:静态文件、公开 API,还是用户私有响应;
  2. 如何区分:Cache Key 是否包含查询参数、语言、租户、认证状态;
  3. 缓存多久:浏览器 TTL、共享缓存 TTL 和陈旧响应窗口分别多长;
  4. 如何失效:内容哈希、版本化 URL、TTL、主动刷新还是紧急清理。

独立插图 01:多层缓存与静态资源优化全景
对应文件:01-cache-layered-architecture.png

01 cache layered architecture 下一篇将在缓存与性能能力之上补齐限流、结构化日志、Trace ID 和生产故障排查。


学习目标#

学完本文后,你应当能够:

  1. 区分浏览器私有缓存、CDN 共享缓存、Nginx 静态文件和 proxy_cache
  2. 解释一次静态资源请求为什么不应该进入 Spring Boot;
  3. 正确使用 rootaliastry_filesindex 和 MIME Type;
  4. 说明 sendfiletcp_nopushopen_file_cache 分别优化什么;
  5. 解释 open_file_cache 为什么不是 HTTP 内容缓存;
  6. 区分强缓存、协商缓存、ETagLast-Modified 和 304;
  7. 准确解释 no-cacheno-store 的差异;
  8. 为 HTML 和带内容哈希的 JS/CSS 设计不同缓存策略;
  9. 配置 gzip 动态压缩并理解 gzip_typesgzip_varygzip_min_length
  10. 说明 gzip_static 的编译边界以及 Brotli 不是 Nginx 默认模块;
  11. 判断哪些内容适合压缩,哪些内容再次压缩收益很低;
  12. 理解 proxy_cache_pathkeys_zonemax_sizeinactivelevels
  13. 解释 Cache Key 如何影响正确性与命中率;
  14. 正确处理 VarySet-CookieAuthorizationprivateno-store
  15. 解释 proxy_cache_bypassproxy_no_cache 的区别;
  16. 使用 proxy_cache_lock 缓解热点缓存击穿;
  17. 使用 proxy_cache_revalidateproxy_cache_background_updateproxy_cache_use_stale
  18. 识别 HITMISSBYPASSEXPIREDSTALEUPDATINGREVALIDATED
  19. 设计 Nginx 与 CDN 的分层缓存、回源和失效方案;
  20. 完成 Docker Compose + Spring Boot 的静态资源、压缩和代理缓存实验。

一、问题背景:性能优化不是“把所有内容缓存起来”#

1.1 没有缓存时发生了什么#

假设首页包含:

  • 1 个 HTML;
  • 2 个 JavaScript;
  • 1 个 CSS;
  • 3 个字体文件;
  • 20 张图片;
  • 5 个公共 API 请求。

如果所有资源每次都重新从 Spring Boot 获取,用户刷新一次页面可能产生三十多个应用请求。即使静态文件没有变化,后端仍要:

  1. 接收连接;
  2. 匹配 Controller 或资源处理器;
  3. 读取文件;
  4. 构造响应 Header;
  5. 占用应用线程、内存和网络带宽;
  6. 经过日志、监控、鉴权过滤器等完整链路。

当用户数量扩大后,系统会把大量资源消耗在“重复发送没有变化的内容”上。

1.2 缓存优化的真正目标#

缓存不是为了让某个单次请求“理论上更快”,而是为了改变整个系统的负载分布:

  • 浏览器命中:请求甚至不需要到达服务器;
  • CDN 命中:请求不需要跨地域回源;
  • Nginx 静态文件:请求不进入 Java 应用;
  • Nginx 代理缓存命中:请求不进入后端计算和数据库;
  • 陈旧响应降级:后端短暂失败时仍能返回可接受的数据。

因此,缓存优化至少同时影响:

  • 用户延迟;
  • 后端 QPS;
  • 数据库压力;
  • 出口带宽;
  • 磁盘 I/O;
  • CPU 压缩成本;
  • 数据一致性;
  • 故障时可用性。

1.3 缓存的最大风险不是“没命中”,而是“命中了错误内容”#

缓存未命中最多导致性能下降;错误命中可能造成安全事故。

例如:

GET /api/profile
Authorization: Bearer user-a-token

如果共享缓存 Key 只有 /api/profile,并且入口忽略了认证与私有缓存限制,那么用户 A 的响应可能被缓存,再返回给用户 B。这不是普通缓存 Bug,而是跨用户数据泄露。

所以缓存策略的优先级应当是:

正确性与数据隔离
> 一致性边界
> 可用性
> 命中率
> 节省资源

二、缓存体系全景:四种“缓存”不要混为一谈#

2.1 浏览器缓存#

浏览器缓存位于用户设备,由 HTTP 响应 Header 控制。

它主要解决:

  • 同一用户重复访问;
  • 页面刷新;
  • 前端路由切换;
  • 静态资源重复下载。

典型 Header:

Cache-Control: public, max-age=31536000, immutable
ETag: "asset-v7"
Last-Modified: Tue, 07 Jul 2026 03:00:00 GMT

浏览器缓存属于私有缓存,它能够保存仅属于当前用户的内容,但仍应遵守 no-store 等指令。

2.2 CDN 缓存#

CDN 缓存位于边缘节点,通常是共享缓存。

它主要解决:

  • 用户距离源站过远;
  • 大量静态资源跨区域下载;
  • 公共内容回源压力;
  • 源站公网带宽压力。

CDN 是否缓存以及缓存多久,通常受以下因素影响:

  • Cache-Control
  • s-maxage
  • CDN 平台规则;
  • Cache Key;
  • Query String;
  • Cookie 和 Authorization;
  • 状态码;
  • 内容类型;
  • 手工刷新或版本化 URL。

2.3 Nginx 静态文件服务#

Nginx 直接读取本地文件并返回,严格来说它不一定代表“HTTP 缓存命中”。

例如:

location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
}

每次请求仍可能读取文件系统,只是这个过程由 Nginx 高效完成,不再进入 Spring Boot。

sendfile 可以优化内核中的文件传输路径,open_file_cache 可以缓存文件描述符和文件元数据,但它们与浏览器 Cache-Control、代理响应缓存是不同概念。

2.4 Nginx proxy_cache#

proxy_cache 缓存后端返回的 HTTP 响应。

首次请求:

客户端 → Nginx → 缓存 MISS → Spring Boot → 写入缓存 → 返回客户端

后续请求:

客户端 → Nginx → 缓存 HIT → 直接返回

它适合:

  • 公共查询接口;
  • 内容页;
  • 配置与字典数据;
  • 可容忍短时间陈旧的数据;
  • 高并发且生成成本高的公共响应;
  • 微缓存场景。

它通常不适合:

  • 用户资料;
  • 余额;
  • 权限结果;
  • 个性化推荐;
  • 订单写操作;
  • 返回 Set-Cookie 的响应;
  • 携带敏感 Authorization 的响应;
  • 无法接受短暂陈旧的数据。

三、静态资源托管:先让不需要业务计算的请求离开应用层#

独立插图 02:静态资源路径映射与 sendfile
对应文件:02-static-file-serving-path.png

02 static file serving path

3.1 基础目录结构#

/usr/share/nginx/html/
├── index.html
├── assets/
│ ├── app.8f3a1c.js
│ ├── style.4d2e1a.css
│ └── logo.a21d9f.svg
└── downloads/
└── manual.pdf

推荐将前端构建产物按两类处理:

index.html
→ 文件名通常固定
→ 内容可能频繁更新
→ 短缓存或 no-cache
assets/app.<content-hash>.js
→ 文件名由内容哈希决定
→ 内容变化时 URL 变化
→ 长缓存 + immutable

3.2 root 的路径拼接#

location /assets/ {
root /usr/share/nginx/html;
}

请求:

/assets/app.8f3a1c.js

目标文件:

/usr/share/nginx/html/assets/app.8f3a1c.js

root 会把规范化后的完整 URI 追加到根目录。

3.3 alias 的替换语义#

location /downloads/ {
alias /data/public-files/;
}

请求:

/downloads/manual.pdf

目标文件:

/data/public-files/manual.pdf

alias 用指定目录替换匹配的 location 前缀。

常见错误:

location /downloads/ {
alias /data/public-files;
}

尾部斜杠不一致可能产生难以理解的路径结果。正则 location 中使用 alias 时,还应通过捕获组明确构造路径。

3.4 使用 try_files 明确失败行为#

location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
}

这比让请求继续落入 SPA fallback 或后端代理更安全。

如果静态资源不存在,应返回 404,而不是返回 index.html

错误:GET /assets/missing.js → 返回 index.html
结果:浏览器按 JavaScript 解析 HTML,出现 MIME 或语法错误

SPA fallback 应限制在页面路由:

location / {
try_files $uri $uri/ /index.html;
}

静态资源目录单独定义:

location /assets/ {
try_files $uri =404;
}

3.5 MIME Type#

Nginx 应加载 MIME 映射:

include /etc/nginx/mime.types;
default_type application/octet-stream;

如果 JavaScript 被返回为错误类型,浏览器可能拒绝执行;字体、SVG、WebAssembly 等资源也依赖正确的 Content-Type

不要为了“解决静态资源访问失败”把所有文件统一写成:

default_type text/plain;

3.6 sendfile#

sendfile on;

在支持的平台上,sendfile 允许内核直接在文件描述符与网络 Socket 之间传输数据,减少用户态复制和上下文切换。

它不是“文件缓存开关”,也不会自动添加 Cache-Control。

常见搭配:

sendfile on;
tcp_nopush on;

tcp_nopush 的具体效果依赖操作系统,并且通常在使用 sendfile 时才有意义。不要把它描述成对所有响应都无条件提升性能。

3.7 open_file_cache#

示例:

open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

它可以缓存:

  • 打开的文件描述符;
  • 文件大小和修改时间;
  • 目录存在性;
  • 文件查找错误,例如不存在或权限拒绝。

它不缓存:

  • 文件正文的 HTTP 响应副本;
  • 浏览器缓存;
  • 后端 API 响应;
  • CDN 内容。

如果部署系统通过原路径直接覆盖文件,过长的 open_file_cache_valid 可能让变更观察延迟。更推荐使用原子发布:构建新目录或新文件,再切换软链接或版本路径,而不是边写边让 Nginx 读取。

3.8 静态资源推荐配置#

location = /index.html {
root /usr/share/nginx/html;
try_files $uri =404;
# 可以保存,但每次复用前向服务器验证。
add_header Cache-Control "no-cache" always;
}
location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}

为什么 HTML 与 Hash 资源不能使用同一策略#

如果 index.html 缓存一年,发布新版本后用户可能长期拿到旧 HTML,而旧 HTML 引用的静态资源可能已经删除。

如果带内容哈希的静态资源只缓存一分钟,则每个用户都会频繁重新验证永远不会变化的文件,浪费 RTT。

正确策略:

资源推荐策略原因
index.htmlno-cache 或短 TTL发布后需要尽快获取新入口
app.<hash>.js一年 + immutable内容变化会产生新 URL
用户上传文件根据权限与更新模型决定URL 可能固定且内容可变
API JSON按公共/私有和一致性决定不能照搬静态资源策略

四、浏览器缓存:强缓存与协商缓存#

独立插图 03:浏览器强缓存与协商缓存决策
对应文件:03-browser-cache-decision.png

03 browser cache decision

4.1 强缓存#

当缓存副本仍然新鲜时,浏览器可以直接使用本地内容,不向服务器发起验证请求。

典型配置:

Cache-Control: public, max-age=31536000, immutable

含义:

  • public:允许共享缓存存储;
  • max-age=31536000:从响应生成时间起,一年内保持新鲜;
  • immutable:在新鲜期内资源不会变化,不需要因为刷新操作而重新验证。

它适合带内容哈希的静态资源。

4.2 协商缓存#

缓存副本过期或响应要求验证时,浏览器发起条件请求。

ETag#

服务端响应:

ETag: "a91f-18c2"

客户端后续请求:

If-None-Match: "a91f-18c2"

资源未变化:

HTTP/1.1 304 Not Modified

客户端继续使用本地实体。

Last-Modified#

服务端响应:

Last-Modified: Tue, 07 Jul 2026 03:10:00 GMT

客户端后续请求:

If-Modified-Since: Tue, 07 Jul 2026 03:10:00 GMT

未修改时返回 304。

ETag 与 Last-Modified 的区别#

维度ETagLast-Modified
判断依据服务端生成的实体标签文件或资源修改时间
精度可由应用精确控制通常受时间精度影响
分布式一致性多节点需生成一致 ETag多节点文件时间可能不同
计算成本强 ETag 可能需要内容摘要通常较低
适用需要精确表示版本静态文件和简单资源

4.3 no-cache 不是“不缓存”#

Cache-Control: no-cache

表示:

缓存可以存储响应,但每次复用前必须向源站验证。

真正不允许存储:

Cache-Control: no-store

例如包含敏感数据的响应:

Cache-Control: private, no-store

4.4 max-ages-maxage#

Cache-Control: public, max-age=60, s-maxage=300

表示:

  • 浏览器缓存新鲜 60 秒;
  • CDN、Nginx 等共享缓存新鲜 300 秒;
  • 共享缓存中的 s-maxage 覆盖 max-age

这样可以让浏览器较快看到新内容,同时利用共享缓存降低源站压力。

独立插图 04:Cache-Control 指令作用域
对应文件:04-cache-control-scope.png

04 cache control scope

4.5 Expires#

Nginx 可以使用:

expires 7d;

自动生成或修改:

Expires: <未来时间>
Cache-Control: max-age=<秒数>

现代系统通常优先以 Cache-Control 表达相对新鲜度。Expires 使用绝对时间,容易受时钟影响,但仍可作为兼容性补充。

4.6 Vary#

压缩内容通常返回:

Vary: Accept-Encoding

多语言内容可能返回:

Vary: Accept-Language

含义是:同一 URI 在这些请求 Header 不同时可能代表不同响应。缓存复用时必须考虑这些维度。

Vary: * 表示缓存无法直接用普通匹配方式复用该响应。


五、压缩:减少传输字节,但不要把 CPU 换带宽做成无底洞#

独立插图 05:gzip、Brotli 与内容编码协商
对应文件:05-compression-negotiation.png

05 compression negotiation

5.1 内容编码协商#

客户端发送:

Accept-Encoding: br, gzip

服务端选择一种编码:

Content-Encoding: gzip
Vary: Accept-Encoding

客户端根据 Content-Encoding 解压。

如果缓存层不区分 Accept-Encoding,可能把 Brotli 响应错误地返回给只支持 gzip 的客户端。因此 Vary: Accept-Encoding 非常重要。

5.2 gzip 基础配置#

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml
application/rss+xml
image/svg+xml;

gzip on#

启用动态 gzip 过滤器。

gzip_vary on#

添加:

Vary: Accept-Encoding

gzip_min_length#

小响应的 Header、压缩状态和 CPU 成本可能超过节省的字节,因此可以设置最小长度。

gzip_comp_level#

压缩等级越高,压缩率通常越高,但 CPU 成本也上升。

生产上不要因为“9 是最高”就默认使用:

gzip_comp_level 9;

常见的均衡范围是 4—6,但最终应以真实内容、CPU、带宽价格和延迟测试决定。

gzip_types#

text/html 由 gzip 模块默认处理,不需要重复列出。应重点加入 JSON、CSS、JavaScript、XML、SVG 等文本格式。

5.3 哪些内容不适合再次压缩#

通常不值得再次压缩:

  • JPEG;
  • PNG;
  • WebP;
  • AVIF;
  • MP4;
  • ZIP;
  • GZIP;
  • 大多数 PDF;
  • 已经压缩过的字体格式。

再次压缩不仅收益很低,还可能消耗 CPU 并增加延迟。

5.4 gzip_static#

如果构建阶段已经生成:

app.js
app.js.gz

可以使用:

gzip_static on;

Nginx 在客户端支持 gzip 时直接发送预压缩文件,避免每次动态压缩。

但必须注意:

ngx_http_gzip_static_module 不是 Nginx 默认编译模块。必须通过 nginx -V 检查 --with-http_gzip_static_module,或确认发行版/镜像是否包含。

验证:

Terminal window
nginx -V 2>&1 | tr ' ' '\n' | grep gzip_static

5.5 Brotli#

Brotli 通常能为文本资源提供更高压缩率,但它不是 Nginx Open Source 默认模块。

常见来源:

  • google/ngx_brotli 第三方模块;
  • 操作系统发行版提供的动态模块包;
  • 自定义构建镜像;
  • CDN 在边缘自动进行 Brotli 压缩。

典型指令可能是:

brotli on;
brotli_static on;
brotli_comp_level 5;
brotli_types text/plain text/css application/json application/javascript;

这些指令只有在对应模块已安装并加载时才有效。

生产建议:

  1. 优先让构建系统生成 .br.gz
  2. 静态资源优先使用预压缩文件;
  3. 动态 API 根据 CPU 和流量评估;
  4. 如果前面已有 CDN,考虑让 CDN 完成客户端侧压缩;
  5. 不要在 CDN 和源站重复执行高成本动态压缩;
  6. 升级 Nginx 时检查动态模块二进制兼容性。

5.6 压缩与缓存的关系#

如果 Nginx 在代理缓存前后启用压缩,需要确认缓存中存储的是上游原始内容还是经过过滤后的表示,以及 Cache Key/Vary 是否正确。

最稳妥的思路是:

  • 后端返回未压缩内容;
  • Nginx 或 CDN 根据客户端 Accept-Encoding 做内容协商;
  • 响应包含 Vary: Accept-Encoding
  • 静态资源使用预压缩文件;
  • 不通过手工删除 Vary 来提高表面命中率。

六、proxy_cache:把公共后端响应缓存在 Nginx#

独立插图 06:proxy_cache MISS/HIT 时序
对应文件:06-proxy-cache-hit-miss-sequence.png

06 proxy cache hit miss sequence

6.1 最小配置#

proxy_cache_path 必须位于 http 上下文:

proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:20m
max_size=512m
inactive=30m
use_temp_path=off;

在 location 中启用:

location /api/cacheable/ {
proxy_cache api_cache;
proxy_pass http://backend;
}

只有定义缓存路径并在请求处理位置启用对应 zone,缓存才真正生效。

6.2 proxy_cache_path 参数#

独立插图 07:proxy_cache_path 参数解剖
对应文件:07-proxy-cache-path-anatomy.png

07 proxy cache path anatomy

缓存目录#

/var/cache/nginx/api

缓存响应实体通常存储为磁盘文件。

levels=1:2#

根据缓存文件名哈希创建目录层级,避免大量文件集中在一个目录。

keys_zone=api_cache:20m#

定义共享内存区名称与大小。

共享内存中保存活跃 Key 和缓存元数据,不是响应实体总大小。

因此:

keys_zone=20m

不等于“缓存最多 20MB”。

max_size=512m#

限制缓存实体在磁盘上的目标最大空间。缓存管理器会按策略清理内容,但磁盘占用在清理周期之间可能短暂超过目标值。

inactive=30m#

如果缓存对象在指定时间内没有被访问,即使其新鲜期更长,也可能被移除。

inactive 不是响应 TTL。

use_temp_path=off#

让临时文件直接写入最终缓存目录,降低临时目录与缓存目录位于不同文件系统时的复制成本。

6.3 缓存新鲜度#

proxy_cache_valid 200 15s;
proxy_cache_valid 404 5s;

表示在后端没有通过 X-Accel-ExpiresExpiresCache-Control 提供明确缓存时间时,使用配置的有效期。

需要谨慎缓存 404:

  • 可以减少针对不存在资源的重复回源;
  • 但资源刚创建后,用户可能在 404 TTL 内继续看到不存在。

6.4 上游响应 Header 的优先级#

Nginx 会处理:

  1. X-Accel-Expires
  2. Expires
  3. Cache-Control
  4. Set-Cookie
  5. Vary

默认情况下:

  • 返回 Set-Cookie 的响应不会被缓存;
  • Vary: * 的响应不会被缓存;
  • 其他 Vary 值会参与表示匹配;
  • X-Accel-Expires: 0 可以禁止缓存。

可以通过 proxy_ignore_headers 忽略这些 Header,但这相当于绕过应用给出的缓存安全信号,必须有非常明确的理由。

危险配置:

proxy_ignore_headers Set-Cookie Cache-Control;

它可能把私有响应变成共享缓存内容。

6.5 Cache Key#

Nginx 的默认 Cache Key 接近:

$scheme$proxy_host$request_uri

也可以显式配置:

proxy_cache_key "$scheme$proxy_host$request_uri";

独立插图 08:Cache Key、Vary 与串数据风险
对应文件:08-cache-key-and-vary.png

08 cache key and vary 设计 Cache Key 时需要检查:

  • URI;
  • 查询参数;
  • Host 或租户;
  • 接口版本;
  • 语言;
  • 设备类型;
  • 内容编码;
  • AB 实验分组;
  • 用户认证与 Cookie。

Key 过粗#

proxy_cache_key $uri;

问题:

/api/items?page=1
/api/items?page=2

可能被视为同一对象。

Key 过细#

如果将所有追踪参数、随机请求 ID、无关 Cookie 都放入 Key,每个请求几乎都是新 Key,命中率会极低。

查询参数规范化#

以下两个 URI 语义可能相同,但字符串不同:

/search?a=1&b=2
/search?b=2&a=1

Nginx 不会自动理解业务参数语义。需要由应用或入口层做规范化,且不能错误地删除真正影响结果的参数。

6.6 HEAD 与 GET#

默认情况下,Nginx 可以将 HEAD 转换为 GET 以便共享缓存对象。

如果关闭:

proxy_cache_convert_head off;

则 Cache Key 应包含 $request_method,否则不同方法的缓存语义可能混淆。

6.7 proxy_cache_bypassproxy_no_cache#

proxy_cache_bypass#

决定本次请求是否跳过读取已有缓存:

proxy_cache_bypass $skip_cache;

命中条件时,请求直接回源。

proxy_no_cache#

决定本次上游响应是否写入缓存:

proxy_no_cache $skip_cache $upstream_http_set_cookie;

二者应分别理解:

场景读取缓存写入缓存
普通请求
bypass可能是
no_cache可能是
两者同时

一个安全的跳过规则#

map $request_method $skip_method {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $skip_authorization {
default 1;
"" 0;
}
map $cookie_session $skip_session {
default 1;
"" 0;
}
map $arg_nocache $skip_query {
default 0;
1 1;
}
map "$skip_method:$skip_authorization:$skip_session:$skip_query" $skip_cache {
default 1;
"0:0:0:0" 0;
}

location:

proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache $upstream_http_set_cookie;

这不是适用于所有业务的万能规则,但它建立了保守默认:非 GET/HEAD、认证请求、会话请求和显式 nocache 请求都不进入共享缓存。

6.8 缓存状态#

使用:

add_header X-Cache-Status $upstream_cache_status always;

常见值:

状态含义
MISS未找到可用缓存,已回源
HIT直接使用新鲜缓存
BYPASS根据 bypass 条件跳过缓存读取
EXPIRED缓存已过期,本次回源更新
STALE后端异常时使用了陈旧缓存
UPDATING其他请求正在后台更新,当前返回旧缓存
REVALIDATED通过条件请求确认缓存仍有效

开发和测试环境可暴露该 Header。生产公网是否暴露,应考虑信息泄露与平台规范。


七、缓存击穿、后台更新与陈旧响应#

7.1 热点缓存击穿#

某个热点 Key 在 12:00:00 过期,一秒内有 500 个请求到达。如果全部回源,缓存从“保护后端”变成“瞬间放大流量”。

7.2 proxy_cache_lock#

proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 5s;

独立插图 09:缓存击穿与 proxy_cache_lock
对应文件:09-cache-lock-thundering-herd.png

09 cache lock thundering herd 启用后,同一 Cache Key 只有一个请求负责填充新缓存,其余请求等待缓存生成或锁释放。

proxy_cache_lock_timeout 到期后,等待请求可以继续回源,但该请求返回的响应不会写入缓存。

proxy_cache_lock_age 用于处理负责填充缓存的请求过慢:超过该时间后允许再放一个请求回源。

7.3 proxy_cache_revalidate#

proxy_cache_revalidate on;

过期缓存可以通过条件请求向后端验证:

If-None-Match
If-Modified-Since

若后端返回 304,Nginx 更新缓存元数据而不重新传输完整实体。

7.4 proxy_cache_background_update#

proxy_cache_background_update on;
proxy_cache_use_stale updating;

当缓存过期时,可以先把旧响应返回给用户,再在后台更新缓存,降低用户等待时间。

7.5 proxy_cache_use_stale#

proxy_cache_use_stale
updating
error
timeout
http_500
http_502
http_503
http_504;

独立插图 10:过期缓存与后台更新
对应文件:10-stale-background-update.png

10 stale background update 这会在指定场景使用过期缓存。

适合:

  • 公共文章;
  • 公告;
  • 产品目录;
  • 非关键统计;
  • 可容忍数十秒陈旧的配置数据。

不适合:

  • 余额;
  • 权限;
  • 库存扣减结果;
  • 支付状态;
  • 实时风控;
  • 用户隐私数据。

不能因为“高可用”就无限返回旧数据。需要明确:

最大允许陈旧时间
+ 哪些错误允许 stale
+ 哪些接口绝不允许
+ 如何在响应或日志中标记
+ 后端恢复后如何刷新

八、缓存一致性与失效#

8.1 TTL 失效#

最简单方案:等待缓存自然过期。

优点:

  • 简单;
  • 可预测;
  • 不依赖额外控制接口。

缺点:

  • 更新后存在陈旧窗口;
  • TTL 太短降低命中率;
  • TTL 太长影响一致性。

8.2 版本化 URL#

静态资源推荐:

/app.8f3a1c.js
/app.291ab7.js

内容变化后生成新 URL,不需要修改旧对象。旧资源可继续长缓存,HTML 更新后引用新资源。

这通常优于频繁清理缓存。

8.3 主动 Purge 的能力边界#

Nginx 官方的:

proxy_cache_purge ...;

属于商业订阅能力,不应写成 Nginx Open Source 的默认指令。

开源场景常见选择:

  1. 缩短 TTL;
  2. 版本化 URL;
  3. 使用经过评估的第三方 purge 模块;
  4. 由部署系统切换新的缓存命名空间;
  5. 在维护窗口清理缓存目录并控制并发;
  6. 让 CDN 平台执行边缘刷新,同时处理 Nginx 源站缓存。

不要在 Nginx 正在高并发读写时随意:

Terminal window
rm -rf /var/cache/nginx/*

至少需要评估文件句柄、正在写入的临时文件、权限、并发回源和缓存雪崩。

8.4 多 Nginx 节点一致性#

两个 Nginx 节点各自使用本地磁盘:

Nginx A → Cache A
Nginx B → Cache B

它们不是一个共享缓存。

可能出现:

  • A 已刷新,B 仍是旧值;
  • 流量切换后用户看到不同版本;
  • 两个节点分别回源填充;
  • 节点扩容后命中率突然下降;
  • 节点重建导致缓存全部丢失。

解决思路:

  • 接受最终一致并使用较短 TTL;
  • 使用 CDN 作为更统一的边缘缓存层;
  • 通过版本化 URL 避免主动一致性;
  • 使用专门的分布式缓存或对象存储;
  • 不把 Nginx 文件缓存当作业务强一致缓存。

8.5 微缓存#

对于高并发公共动态页面,可设置 1—10 秒的短 TTL:

proxy_cache_valid 200 3s;

即使只有 3 秒,也可能把每秒上千个相同请求压缩为极少量后端请求。

微缓存只适合:

  • 返回内容在短时间内相同;
  • 不包含用户隐私;
  • 短暂陈旧可接受;
  • Cache Key 正确;
  • 写操作不参与缓存。

九、CDN 与 Nginx 协作#

独立插图 11:CDN 与 Nginx 分层缓存协作
对应文件:11-cdn-nginx-cache-cooperation.png

11 cdn nginx cache cooperation

9.1 为什么有 CDN 还需要 Nginx#

CDN 主要解决边缘分发,Nginx 仍可以承担:

  • 源站静态文件;
  • 回源路由;
  • 缓存未命中的代理缓存;
  • Header 规范化;
  • 源站保护;
  • 日志与 Trace ID;
  • HTTPS 回源;
  • 灰度路由。

但如果 CDN 已经对公共内容提供高命中率,Nginx 再缓存同一内容的收益可能变小。是否双层缓存需要根据回源流量、源站成本和失效复杂度决定。

9.2 推荐分层策略#

HTML#

Cache-Control: no-cache

或:

Cache-Control: public, max-age=0, s-maxage=30, must-revalidate

浏览器每次验证,CDN 可以短时间复用。

Hash 静态资源#

Cache-Control: public, max-age=31536000, immutable

CDN 与浏览器都可长缓存。

公共 API#

Cache-Control: public, max-age=10, s-maxage=60

共享缓存 60 秒,浏览器 10 秒。

私有 API#

Cache-Control: private, no-store

不进入 CDN 或 Nginx 共享缓存。

9.3 回源 Host 与真实 IP#

CDN 回源时需要明确:

  • 回源域名;
  • Host
  • SNI;
  • 源站证书;
  • 客户端真实 IP Header;
  • 只有受信任 CDN 地址才能覆盖 Real IP;
  • 源站是否允许绕过 CDN 直接访问。

9.4 缓存状态可观测性#

不同层可能提供:

  • Age
  • 标准化 Cache-Status
  • CDN 自定义命中 Header;
  • Nginx $upstream_cache_status
  • 后端响应时间。

建议日志同时记录:

log_format cache_json escape=json
'{'
'"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"host":"$host",'
'"uri":"$request_uri",'
'"status":$status,'
'"cache_status":"$upstream_cache_status",'
'"upstream":"$upstream_addr",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time",'
'"bytes":$body_bytes_sent'
'}';

缓存命中时 $upstream_addr$upstream_response_time 可能为空,需要在日志分析中正确处理。

9.5 多层失效顺序#

更新内容后至少检查:

应用数据是否已更新
→ Nginx proxy_cache 是否仍有旧值
→ CDN 是否仍有旧值
→ 浏览器是否仍有强缓存

紧急更新时只清理 CDN,Nginx 仍然可能向 CDN 提供旧值;只清理 Nginx,CDN 仍可能继续命中旧对象。


十、最小可运行实验#

独立插图 13:最小实验请求链路
对应文件:13-lab-request-flow.png

13 lab request flow

10.1 实验目标#

本实验验证:

  1. Nginx 直接托管静态资源;
  2. HTML 使用 no-cache
  3. Hash 静态资源使用一年缓存和 immutable
  4. JSON 和文本响应启用 gzip;
  5. 公共 API 第一次 MISS、后续 HIT
  6. TTL 过期后出现 EXPIRED 或更新;
  7. nocache=1、Authorization、Session Cookie 触发 BYPASS
  8. Set-Cookie 和私有响应不写缓存;
  9. 并发访问慢接口时 proxy_cache_lock 限制回源;
  10. 后端失败时可按配置使用 STALE

10.2 目录结构#

nginx-cache-lab/
├── docker-compose.yml
├── nginx/
│ ├── nginx.conf
│ └── html/
│ ├── index.html
│ └── assets/
│ ├── app.8f3a1c.js
│ └── style.4d2e1a.css
├── backend/
│ ├── Dockerfile
│ ├── pom.xml
│ └── src/main/java/com/example/cachelab/
│ ├── CacheLabApplication.java
│ └── CacheController.java
└── scripts/
└── verify.sh

10.3 docker-compose.yml#

services:
backend:
build:
context: ./backend
container_name: nginx-cache-backend
environment:
SERVER_PORT: "8080"
expose:
- "8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
networks:
- cache-lab
nginx:
image: nginx:1.30.3-alpine3.23
container_name: nginx-cache-entry
depends_on:
backend:
condition: service_healthy
ports:
- "8088:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
- nginx_cache:/var/cache/nginx/api
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 5s
timeout: 3s
retries: 10
networks:
- cache-lab
networks:
cache-lab:
driver: bridge
volumes:
nginx_cache:

说明:

  • 镜像固定为 1.30.3-alpine3.23,不使用 latest
  • 缓存目录使用命名卷;
  • Compose 不再需要旧式 version: 字段;
  • depends_on.condition: service_healthy 只负责启动顺序,不代表运行期自动故障恢复;
  • Nginx reload 与配置发布仍需要独立流程。

10.4 完整 nginx.conf#

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format cache_json escape=json
'{'
'"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"cache_status":"$upstream_cache_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time",'
'"bytes":$body_bytes_sent'
'}';
access_log /var/log/nginx/access.log cache_json;
sendfile on;
tcp_nopush on;
keepalive_timeout 65s;
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml
application/rss+xml
image/svg+xml;
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:20m
max_size=512m
inactive=30m
use_temp_path=off;
map $request_method $skip_method {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $skip_authorization {
default 1;
"" 0;
}
map $cookie_session $skip_session {
default 1;
"" 0;
}
map $arg_nocache $skip_query {
default 0;
1 1;
}
map "$skip_method:$skip_authorization:$skip_session:$skip_query" $skip_cache {
default 1;
"0:0:0:0" 0;
}
upstream backend {
server backend:8080;
keepalive 32;
}
server {
listen 80 default_server;
server_name _;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = /index.html {
root /usr/share/nginx/html;
try_files $uri =404;
add_header Cache-Control "no-cache" always;
}
location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
location /api/cacheable/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache api_cache;
proxy_cache_key "$scheme$proxy_host$request_uri";
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 15s;
proxy_cache_valid 404 5s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 5s;
proxy_cache_revalidate on;
proxy_cache_background_update on;
proxy_cache_use_stale
updating
error
timeout
http_500
http_502
http_503
http_504;
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache $upstream_http_set_cookie;
add_header X-Cache-Status $upstream_cache_status always;
}
location /api/private/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header X-Cache-Status BYPASS always;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
}

10.5 index.html#

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nginx Cache Lab</title>
<link rel="stylesheet" href="/assets/style.4d2e1a.css" />
</head>
<body>
<main>
<h1>Nginx Cache Lab</h1>
<button id="load">请求可缓存时间接口</button>
<pre id="result"></pre>
</main>
<script src="/assets/app.8f3a1c.js"></script>
</body>
</html>

10.6 app.8f3a1c.js#

const button = document.querySelector('#load');
const result = document.querySelector('#result');
button.addEventListener('click', async () => {
const response = await fetch('/api/cacheable/time');
const body = await response.json();
result.textContent = JSON.stringify({
cacheStatus: response.headers.get('X-Cache-Status'),
body
}, null, 2);
});

10.7 style.4d2e1a.css#

body {
font-family: system-ui, sans-serif;
margin: 0;
background: #f5f7fb;
color: #172033;
}
main {
max-width: 760px;
margin: 80px auto;
padding: 32px;
background: white;
border-radius: 16px;
}

10.8 Spring Boot pom.xml#

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>cache-lab</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

10.9 CacheLabApplication.java#

package com.example.cachelab;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CacheLabApplication {
public static void main(String[] args) {
SpringApplication.run(CacheLabApplication.class, args);
}
}

10.10 CacheController.java#

package com.example.cachelab;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class CacheController {
private final AtomicLong invocationCounter = new AtomicLong();
@GetMapping("/cacheable/time")
public ResponseEntity<Map<String, Object>> cacheableTime() {
long invocation = invocationCounter.incrementAndGet();
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL,
"public, max-age=5, s-maxage=15")
.body(Map.of(
"generatedAt", Instant.now().toString(),
"backendInvocation", invocation));
}
@GetMapping("/cacheable/slow")
public ResponseEntity<Map<String, Object>> slow() throws InterruptedException {
long invocation = invocationCounter.incrementAndGet();
Thread.sleep(2000);
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL,
"public, max-age=5, s-maxage=15")
.body(Map.of(
"generatedAt", Instant.now().toString(),
"backendInvocation", invocation,
"message", "slow response"));
}
@GetMapping("/cacheable/large")
public ResponseEntity<Map<String, Object>> large() {
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL,
"public, max-age=5, s-maxage=15")
.body(Map.of(
"generatedAt", Instant.now().toString(),
"payload", "x".repeat(4096)));
}
@GetMapping("/cacheable/language")
public ResponseEntity<Map<String, Object>> language(
@RequestHeader(value = "Accept-Language", defaultValue = "en") String language) {
String message = language.toLowerCase().startsWith("zh")
? "你好"
: "hello";
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL,
"public, max-age=10, s-maxage=30")
.header(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE)
.body(Map.of("language", language, "message", message));
}
@GetMapping("/private/profile")
public ResponseEntity<Map<String, Object>> privateProfile() {
ResponseCookie cookie = ResponseCookie.from("session", "demo-session")
.httpOnly(true)
.sameSite("Lax")
.path("/")
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(Map.of(
"user", "demo-user",
"generatedAt", Instant.now().toString()));
}
}

10.11 后端 Dockerfile#

FROM maven:3.9.11-eclipse-temurin-21 AS builder
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /workspace/target/cache-lab-1.0.0.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

10.12 启动与配置检查#

Terminal window
docker compose build --pull
docker compose up -d
docker compose ps
docker compose exec nginx nginx -t
docker compose exec nginx nginx -T

10.13 验证 HTML 与静态资源#

Terminal window
curl -I http://localhost:8088/index.html

预期:

Cache-Control: no-cache

静态 Hash 资源:

Terminal window
curl -I http://localhost:8088/assets/app.8f3a1c.js

预期:

Cache-Control: public, max-age=31536000, immutable
Expires: ...
Content-Type: application/javascript

10.14 验证 gzip#

Terminal window
curl --compressed -sD - \
http://localhost:8088/api/cacheable/time \
-o /dev/null

时间接口响应可能低于 gzip_min_length,使用大 JSON 接口验证:

Terminal window
curl --compressed -sD - \
http://localhost:8088/api/cacheable/large \
-o /dev/null

检查:

Content-Encoding: gzip
Vary: Accept-Encoding

10.15 验证 MISS 与 HIT#

第一次:

Terminal window
curl -sD - http://localhost:8088/api/cacheable/time

预期:

X-Cache-Status: MISS

立即再次执行:

Terminal window
curl -sD - http://localhost:8088/api/cacheable/time

预期:

X-Cache-Status: HIT

两次响应中的 generatedAtbackendInvocation 应相同。

等待超过共享 TTL 后再次请求:

Terminal window
sleep 16
curl -sD - http://localhost:8088/api/cacheable/time

可能观察到:

X-Cache-Status: EXPIRED

或在后台更新场景中看到 STALE / UPDATING,具体取决于请求时机。

10.16 验证显式 bypass#

Terminal window
curl -sD - \
'http://localhost:8088/api/cacheable/time?nocache=1'

预期:

X-Cache-Status: BYPASS

10.17 验证 Authorization 绕过#

Terminal window
curl -sD - \
-H 'Authorization: Bearer demo' \
http://localhost:8088/api/cacheable/time

预期为 BYPASS

Terminal window
curl -sD - \
-H 'Cookie: session=demo-session' \
http://localhost:8088/api/cacheable/time

预期为 BYPASS

10.19 验证私有响应不缓存#

Terminal window
curl -sD - http://localhost:8088/api/private/profile
curl -sD - http://localhost:8088/api/private/profile

每次 generatedAt 都应变化,并包含:

Cache-Control: private, no-store
Set-Cookie: session=...
X-Cache-Status: BYPASS

10.20 验证 proxy_cache_lock#

先使用不同查询参数制造新 Key:

Terminal window
seq 1 20 | xargs -I{} -P20 \
curl -s 'http://localhost:8088/api/cacheable/slow?key=lock-test' \
-o /tmp/cache-lock-{}.json

观察总耗时、响应中的 backendInvocation 以及后端日志。

在缓存锁正常工作且未超时时,同一 Key 不应产生 20 次并发后端计算。

10.21 查看缓存目录#

Terminal window
docker compose exec nginx sh -c \
'find /var/cache/nginx/api -type f | head'
docker compose exec nginx sh -c \
'du -sh /var/cache/nginx/api'

不要根据文件名直接推断原始 URL;缓存文件名由 Key 哈希生成。

10.22 查看日志#

Terminal window
docker compose logs -f nginx backend

重点观察:

  • cache_status
  • request_time
  • upstream_time
  • 后端实际调用次数;
  • 缓存目录权限和磁盘错误。

10.23 停止与清理#

保留缓存卷:

Terminal window
docker compose down

删除缓存卷:

Terminal window
docker compose down -v

十一、核心指令逐项解释#

指令上下文解决的问题默认或关键行为常见错误验证方式
expireshttp/server/location生成浏览器缓存 Header默认 offHTML 长缓存导致旧入口curl -I
add_header Cache-Controlhttp/server/location明确缓存策略需注意状态码与继承子 location 重定义后丢失父级 Headernginx -Tcurl -I
sendfilehttp/server/location优化静态文件传输默认关闭当成 HTTP 缓存nginx -T、性能测试
open_file_cachehttp/server/location缓存文件描述符和元数据默认关闭误认为缓存文件正文strace、文件更新实验
gziphttp/server/location/if动态压缩响应默认关闭对图片视频重复压缩curl --compressed -I
gzip_varyhttp/server/location添加 Vary: Accept-Encoding默认关闭缓存层混用压缩表示查看 Header
gzip_statichttp/server/location发送 .gz 预压缩文件模块非默认编译配置指令不存在nginx -V
proxy_cache_pathhttp定义缓存目录和 zone必须在 http写在 server 导致语法错误nginx -t
proxy_cachehttp/server/location启用缓存 zone默认 off只定义 path 没启用$upstream_cache_status
proxy_cache_keyhttp/server/location定义缓存对象身份默认近似 scheme+proxy_host+URI忽略参数/租户造成串数据请求不同维度对比
proxy_cache_validhttp/server/location无明确上游 TTL 时设有效期按状态码配置缓存错误状态太久重复请求与时间验证
proxy_cache_bypasshttp/server/location跳过读取缓存非空且非 0 条件触发误认为同时禁止写入查看 BYPASS 与后续 HIT
proxy_no_cachehttp/server/location禁止写入缓存条件触发误认为同时绕过读取对比当前与后续请求
proxy_cache_lockhttp/server/location缓解缓存击穿默认关闭锁超时过短导致多次回源并发慢接口
proxy_cache_revalidatehttp/server/location条件验证过期缓存默认关闭后端没有验证器抓取 If-None-Match
proxy_cache_background_updatehttp/server/location后台更新缓存默认关闭未配 stale updating观察 UPDATING
proxy_cache_use_stalehttp/server/location故障时使用旧缓存默认 off对敏感数据返回旧值停止后端验证

十二、从开发配置升级到生产配置#

阶段 1:静态资源离开应用层#

目标:

  • Nginx 托管前端构建产物;
  • try_files 明确 404;
  • MIME Type 正确;
  • HTML 与 Hash 资源分开。

阶段 2:浏览器缓存策略#

目标:

  • HTML no-cache
  • Hash 资源一年缓存;
  • 更新使用内容哈希;
  • 私有内容 no-store

阶段 3:压缩#

目标:

  • gzip 仅用于文本内容;
  • 通过真实响应大小选择最小长度;
  • 根据 CPU 压力选择压缩等级;
  • 静态资源尽量使用构建期预压缩;
  • Brotli 先验证模块与升级策略。

阶段 4:公共 API 缓存#

目标:

  • 只缓存明确的公共 GET/HEAD;
  • 默认绕过 Authorization 和 Session Cookie;
  • 使用应用 Cache-Control 或明确 proxy_cache_valid
  • 暴露缓存状态用于验证。

阶段 5:抗击穿与故障降级#

目标:

  • 开启缓存锁;
  • 为热点接口设置合理 lock timeout;
  • 允许部分内容后台更新;
  • 按业务定义 stale 边界;
  • 监控陈旧响应比例。

阶段 6:多层缓存治理#

目标:

  • 统一浏览器、CDN、Nginx TTL;
  • 建立 Cache Key 文档;
  • 建立刷新与回滚流程;
  • 结构化记录 Cache Status;
  • 定期检查命中率、缓存占用、淘汰和回源率。

阶段 7:容量与压测#

至少评估:

  • 缓存对象数量;
  • 平均响应大小;
  • keys_zone 内存;
  • max_size
  • 磁盘吞吐;
  • inode 数量;
  • 缓存加载时间;
  • 命中率;
  • 后端 QPS 降幅;
  • gzip CPU 开销;
  • P50/P95/P99 延迟;
  • 发布或清缓存后的回源峰值。

十三、常见错误与反例#

错误一:所有资源统一缓存一年#

错误现象#

发布后用户仍然看到旧页面或旧入口文件。

错误配置#

location / {
expires 1y;
}

根本原因#

index.html URL 没变,但被强缓存一年。

修复#

HTML 使用 no-cache 或短 TTL,Hash 静态资源使用长 TTL。


错误二:把 no-cache 理解为禁止存储#

错误认知#

no-cache = 浏览器完全不缓存

根本原因#

no-cache 允许存储,只是复用前必须验证。

修复#

敏感响应使用:

Cache-Control: private, no-store

错误三:Cache Key 忽略查询参数#

错误配置#

proxy_cache_key $uri;

现象#

page=1page=2 返回相同缓存内容。

修复#

使用包含查询参数的 Key:

proxy_cache_key "$scheme$proxy_host$request_uri";

同时处理无关追踪参数,避免 Key 爆炸。


错误四:缓存用户私有响应#

错误配置#

proxy_ignore_headers Cache-Control Set-Cookie;
proxy_cache api_cache;

现象#

不同用户可能收到相同的缓存响应。

修复#

  • 不忽略应用私有缓存 Header;
  • Authorization、Session Cookie 默认 bypass;
  • 返回 Set-Cookie 的响应不缓存;
  • 对私有 API 使用单独 location。

错误五:把 keys_zone=20m 当成磁盘缓存 20MB#

根本原因#

keys_zone 是共享内存元数据区,响应实体存储在磁盘。

修复#

同时规划:

keys_zone=api_cache:20m
max_size=512m

并监控磁盘与 inode。


错误六:对 JPEG、MP4、ZIP 开最高等级 gzip#

现象#

CPU 升高,但网络体积几乎不变。

修复#

只压缩适合的文本 MIME,使用真实内容压测。


错误七:配置 Brotli 却没有模块#

错误现象#

unknown directive "brotli"

根本原因#

Brotli 不是 Nginx 默认模块。

修复#

  • 安装匹配当前 Nginx 的动态模块;
  • 或自定义编译;
  • 或交给 CDN;
  • 使用 nginx -V 与模块加载配置验证。

错误八:开启缓存但从未看到 HIT#

可能原因#

  • 只配置了 proxy_cache_path,没有 proxy_cache
  • 请求每次带不同查询参数;
  • Authorization 或 Cookie 触发 bypass;
  • 后端返回 Set-Cookie
  • 后端返回 Cache-Control: no-store/private
  • 响应状态不在有效策略内;
  • 缓存目录不可写;
  • 每次请求都经过不同 Nginx 节点;
  • Key 中包含随机 Header 或请求 ID。

验证#

记录 $upstream_cache_status 和 Cache Key 关键维度。


错误九:清空缓存后立刻发生后端雪崩#

根本原因#

所有热点 Key 同时变成 MISS。

修复#

  • 分批失效;
  • 使用版本化 URL;
  • 预热热点 Key;
  • 开启缓存锁;
  • 限制回源并发;
  • 清理前评估后端容量。

错误十:把 open_file_cache 当作内容缓存#

错误认知#

开启后浏览器会直接命中缓存。

根本原因#

open_file_cache 缓存文件描述符和元数据,不控制 HTTP 缓存。

修复#

浏览器缓存仍需 Cache-Control/Expires;代理响应缓存仍需 proxy_cache


错误十一:多层缓存只清理其中一层#

现象#

CDN 已刷新,但仍然从 Nginx 获取旧值;或 Nginx 已刷新,但浏览器继续强缓存。

修复#

建立分层缓存地图和完整失效 Runbook。


错误十二:无限允许 stale#

现象#

后端故障数小时后用户仍看到严重过期的数据。

修复#

  • 明确最大陈旧窗口;
  • 对关键接口禁用 stale;
  • 日志与监控区分 STALE;
  • 超过边界返回明确错误,而不是悄悄继续旧值。

十四、故障排查流程#

独立插图 12:缓存、压缩与静态资源故障排查
对应文件:12-cache-compression-troubleshooting.png

12 cache compression troubleshooting

14.1 第一步:确认旧值来自哪一层#

Terminal window
curl -v -I 'http://localhost:8088/api/cacheable/time?debug=1'

关注:

  • Age
  • Cache-Control
  • Expires
  • ETag
  • Last-Modified
  • Vary
  • Content-Encoding
  • X-Cache-Status
  • CDN 自定义 Header;
  • Via

浏览器 DevTools 中的 “from memory cache” 或 “from disk cache” 表示请求可能没有到达 Nginx。

14.2 第二步:确认 location#

Terminal window
nginx -T

检查:

  • 是否进入静态资源 location;
  • 是否被更高优先级的正则 location 抢占;
  • 是否进入正确的 proxy_cache location;
  • 子 location 是否覆盖 Header 配置;
  • SPA fallback 是否错误接管静态文件。

14.3 第三步:检查静态文件#

Terminal window
ls -l /usr/share/nginx/html/assets/
namei -l /usr/share/nginx/html/assets/app.8f3a1c.js

检查:

  • 文件存在;
  • Nginx Worker 有读取权限;
  • 父目录有执行权限;
  • root/alias 路径正确;
  • MIME Type 正确;
  • 文件是否被原地覆盖。

14.4 第四步:检查压缩#

Terminal window
curl -sD - \
-H 'Accept-Encoding: gzip' \
http://localhost:8088/assets/app.8f3a1c.js \
-o /dev/null

检查:

  • Content-Encoding: gzip
  • Vary: Accept-Encoding
  • 响应是否大于 gzip_min_length
  • MIME 是否在 gzip_types
  • 上游是否已经压缩;
  • Brotli 或 gzip_static 模块是否存在;
  • CPU 是否成为瓶颈。

14.5 第五步:检查代理缓存条件#

Terminal window
curl -sD - http://localhost:8088/api/cacheable/time -o /dev/null

根据状态判断:

  • MISS:首次请求或没有可用对象;
  • HIT:正常命中;
  • BYPASS:检查 map 条件、Authorization、Cookie、参数;
  • EXPIRED:检查 TTL;
  • STALE:检查后端错误和 stale 条件;
  • 空值:可能未经过启用 proxy_cache 的 location。

14.6 第六步:检查缓存目录#

Terminal window
docker compose exec nginx sh -c \
'id && ls -ld /var/cache/nginx/api && df -h && df -i'

检查:

  • 权限;
  • 磁盘空间;
  • inode;
  • 只读文件系统;
  • Volume 是否正确挂载;
  • SELinux/AppArmor;
  • 临时目录与缓存目录是否跨文件系统。

14.7 第七步:关联后端日志#

如果 Nginx 显示 HIT,但后端仍每次收到请求:

  • 检查是否请求了不同 Key;
  • 检查是否有其他并行 API;
  • 检查日志是否来自其他 Nginx 节点;
  • 检查浏览器是否先发 OPTIONS;
  • 检查后台更新请求;
  • 检查缓存锁是否超时。

十五、生产实践与能力边界#

15.1 什么场景适合 Nginx 静态资源#

适合:

  • 单机或少量节点;
  • 前端构建产物随应用部署;
  • 内网管理系统;
  • 小中型网站;
  • 对全球分发要求不高。

应考虑对象存储 + CDN:

  • 大规模图片和视频;
  • 全球用户;
  • 高出口带宽;
  • 海量静态对象;
  • 多地区容灾;
  • 独立前端发布体系。

15.2 什么场景适合 proxy_cache#

适合:

  • 公共响应;
  • 高读低写;
  • 热点明显;
  • 短暂陈旧可接受;
  • 后端生成成本高;
  • 需要简单源站保护。

不应替代:

  • Redis 等业务缓存;
  • 数据库一致性机制;
  • 用户会话存储;
  • 权限系统;
  • 消息驱动的缓存失效;
  • 专业 CDN 的全球分发。

15.3 缓存选择公式#

可以使用以下决策思路:

可缓存价值
= 请求重复度
× 单次生成成本
× 可接受陈旧时间
× 公共响应比例
÷ 失效复杂度
÷ 数据泄露风险

只要数据泄露风险不可接受,即使重复度极高也不能直接进入共享缓存。

15.4 关键指标#

建议监控:

  • Cache HIT Ratio;
  • MISS、BYPASS、EXPIRED、STALE 比例;
  • 回源 QPS;
  • 缓存响应 P95/P99;
  • 回源响应 P95/P99;
  • 缓存目录大小;
  • inode 使用;
  • 磁盘 IOPS 与吞吐;
  • 缓存锁等待;
  • 压缩 CPU;
  • 压缩前后字节数;
  • CDN 回源率;
  • 后端在清缓存后的峰值。

15.5 发布与回滚#

发布前:

  1. nginx -t
  2. 在测试环境验证 Header;
  3. 使用小流量灰度;
  4. 比较命中率和后端 QPS;
  5. 检查私有接口是否 BYPASS;
  6. 检查 CDN 与 Nginx Header 是否冲突。

回滚时:

  • 回滚配置不一定自动清除已经生成的错误缓存;
  • 需要判断是否清理或切换 Cache Zone;
  • 防止回滚和大规模 MISS 同时冲击后端;
  • 保留配置、Header 和缓存状态证据用于复盘。

十六、面试与复习问题#

16.1 核心问题#

1. 浏览器缓存、Nginx 静态文件和 proxy_cache 有什么区别?#

浏览器缓存位于客户端;Nginx 静态文件是入口直接读取文件;proxy_cache 缓存后端 HTTP 响应。它们的存储位置、Cache Key、失效和安全边界不同。

2. no-cacheno-store 有什么区别?#

no-cache 允许存储,但复用前必须验证;no-store 表示不应存储请求或响应内容。

3. 为什么 Hash 静态资源适合一年缓存?#

内容变化时文件名和 URL 改变,因此旧 URL 可以安全地长期保持不变。

4. 为什么 index.html 不适合一年强缓存?#

URL 通常固定,但内容和引用资源会随发布变化,长缓存可能让用户长期停留在旧入口。

5. keys_zone=20m 是否表示缓存只能存 20MB?#

不是。它限制共享内存元数据区,缓存实体通常在磁盘,磁盘目标上限由 max_size 控制。

6. proxy_cache_bypassproxy_no_cache 有何区别?#

前者控制是否读取缓存,后者控制当前上游响应是否写入缓存。

它通常包含用户会话或个性化状态,进入共享缓存可能导致数据串用。

8. Vary: Accept-Encoding 的作用是什么?#

声明响应表示受 Accept-Encoding 影响,缓存不能把 Brotli/gzip/未压缩表示错误复用给能力不同的客户端。

9. proxy_cache_lock 解决什么问题?#

解决热点 Key 失效时大量请求同时回源的缓存击穿问题。

10. open_file_cache 缓存什么?#

缓存文件描述符、文件元数据、目录存在性和可选的查找错误,不缓存 HTTP 响应正文。

16.2 场景分析题#

场景一:用户 A 看到用户 B 的个人资料#

优先检查:

  • 是否缓存了认证接口;
  • Key 是否只有 URI;
  • Authorization/Cookie 是否 bypass;
  • 是否忽略了 privateno-storeSet-Cookie
  • CDN 是否也缓存了私有响应。

立即措施:禁用该接口共享缓存、清理所有层错误对象、审计访问日志和影响范围。

场景二:发布后部分用户仍看到旧页面#

逐层检查:

  1. 浏览器强缓存;
  2. Service Worker;
  3. CDN;
  4. Nginx 静态文件或 proxy_cache;
  5. 多 Nginx 节点版本不一致;
  6. HTML 是否引用旧 Hash 文件。

场景三:开启 gzip 后 CPU 飙升#

检查:

  • gzip_comp_level
  • 是否压缩图片和视频;
  • 是否对很小响应压缩;
  • 是否可使用预压缩;
  • CDN 是否能承担压缩;
  • 压缩前后节省字节是否值得。

场景四:缓存命中率很低#

检查:

  • Cache Key 是否包含随机参数;
  • 查询参数是否存在无意义变化;
  • Cookie 是否让所有请求 bypass;
  • TTL 是否太短;
  • 多节点本地缓存是否分散;
  • 后端是否返回 no-store/Set-Cookie;
  • 请求是否真的重复。

场景五:后端宕机后仍希望返回文章列表#

对文章列表可配置 proxy_cache_use_stale error timeout http_500...,但必须设定允许陈旧边界、监控 STALE 比例,并确保内容不包含用户私有数据。

16.3 配置排错题#

排错一#

proxy_cache_path /cache keys_zone=my_cache:10m;
location /api/ {
proxy_pass http://backend;
}

问题:定义了 zone,但 location 未使用 proxy_cache my_cache;,不会产生代理缓存。

排错二#

location /api/profile {
proxy_cache api_cache;
proxy_ignore_headers Cache-Control Set-Cookie;
}

问题:用户私有响应可能进入共享缓存,是严重数据泄露风险。

排错三#

location /assets/ {
gzip on;
gzip_types image/png image/jpeg video/mp4;
}

问题:这些格式通常已经压缩,再压缩收益低且增加 CPU。应压缩文本类 MIME。


十七、本文总结#

本文解决的是 Nginx 入口层的内容交付效率问题。

核心链路可以概括为:

先让浏览器避免重复请求
→ 再让 CDN 避免跨地域回源
→ 让 Nginx 直接处理静态文件
→ 对明确的公共响应使用 proxy_cache
→ 对文本内容执行合理压缩
→ 用缓存锁、后台更新与 stale 控制回源峰值和故障
→ 用日志和 Header 判断响应来自哪一层

最重要的结论有七条:

  1. 缓存正确性和数据隔离优先于命中率;
  2. HTML 与内容哈希静态资源必须使用不同策略;
  3. no-cache 不是禁止存储,no-store 才是;
  4. open_file_cache 不是 HTTP 内容缓存;
  5. keys_zone 是缓存元数据内存,不是磁盘实体上限;
  6. Brotli 与 gzip_static 都需要核验模块能力,不能假设默认存在;
  7. 多层缓存必须拥有统一的 Key、TTL、失效和排障责任。

下一篇将进入生产入口的可观测性和保护能力:限流、防刷、Access Log、Error Log、Trace ID,以及 400、413、429、499、502、504 等状态码的系统化排查。


参考资料#

  1. Nginx 官方下载页:https://nginx.org/en/download.html,访问日期:2026-07-07。
  2. Nginx ngx_http_proxy_modulehttps://nginx.org/en/docs/http/ngx_http_proxy_module.html,访问日期:2026-07-07。
  3. Nginx ngx_http_gzip_modulehttps://nginx.org/en/docs/http/ngx_http_gzip_module.html,访问日期:2026-07-07。
  4. Nginx ngx_http_gzip_static_modulehttps://nginx.org/en/docs/http/ngx_http_gzip_static_module.html,访问日期:2026-07-07。
  5. Nginx ngx_http_headers_modulehttps://nginx.org/en/docs/http/ngx_http_headers_module.html,访问日期:2026-07-07。
  6. Nginx ngx_http_core_modulehttps://nginx.org/en/docs/http/ngx_http_core_module.html,访问日期:2026-07-07。
  7. RFC 9111, HTTP Caching:https://www.rfc-editor.org/rfc/rfc9111
  8. RFC 9110, HTTP Semantics:https://www.rfc-editor.org/rfc/rfc9110
  9. RFC 5861, HTTP Cache-Control Extensions for Stale Content:https://www.rfc-editor.org/rfc/rfc5861
  10. RFC 9211, The Cache-Status HTTP Response Header Field:https://www.rfc-editor.org/rfc/rfc9211
  11. Google ngx_brotlihttps://github.com/google/ngx_brotli,访问日期:2026-07-07。
  12. Docker Official Image: Nginx:https://hub.docker.com/_/nginx,访问日期:2026-07-07。
  13. Spring Boot 官方项目页:https://spring.io/projects/spring-boot,访问日期:2026-07-07。
  14. Docker Compose Specification:https://docs.docker.com/reference/compose-file/,访问日期:2026-07-07。

文章验收清单#

内容完整性#

  • 说明缓存、压缩和静态资源优化解决的架构问题;
  • 区分浏览器、CDN、静态文件与代理缓存;
  • 说明请求命中与回源链路;
  • 提供 Docker Compose + Spring Boot 完整实验;
  • 提供生产演进方案;
  • 提供常见错误和排查流程;
  • 说明 Open Source、商业版和第三方模块边界;
  • 与第五篇和第七篇形成衔接。

技术准确性#

  • 使用 Nginx 1.30.3 稳定版实验;
  • 标注当前主线版 1.31.2;
  • 明确 gzip_static 非默认编译模块;
  • 明确 Brotli 为额外模块;
  • 明确官方 proxy_cache_purge 为商业订阅能力;
  • 区分 no-cacheno-store
  • 避免缓存私有响应;
  • 区分 keys_zonemax_size

配置可运行性#

  • 提供完整 nginx.conf
  • 提供 Docker Compose;
  • 提供 Spring Boot Controller、POM 与 Dockerfile;
  • 提供启动、验证、日志和清理命令;
  • 提供 nginx -tnginx -T
  • 使用固定镜像版本;
  • 正文未嵌入图片,仅保留独立图片标记。
第 6 篇:Nginx 缓存、压缩与静态资源优化
https://jupiter-ws.cn/posts/backend/nginx/06_nginx_cache_compression_static_optimization/
作者
Jupiter
发布于
2026-07-08
许可协议
CC BY-NC-SA 4.0