第 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 入口层的主体能力建设:
- 第一篇确定 Nginx 在后端架构中的位置;
- 第二篇讲清配置层级以及
server、location的匹配过程; - 第三篇完成反向代理、Header、超时、缓冲、WebSocket 与 SSE;
- 第四篇将单实例代理扩展为多实例负载均衡与高可用;
- 第五篇完成 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 与业务数据源每一层都必须回答四个问题:
- 缓存什么:静态文件、公开 API,还是用户私有响应;
- 如何区分:Cache Key 是否包含查询参数、语言、租户、认证状态;
- 缓存多久:浏览器 TTL、共享缓存 TTL 和陈旧响应窗口分别多长;
- 如何失效:内容哈希、版本化 URL、TTL、主动刷新还是紧急清理。
独立插图 01:多层缓存与静态资源优化全景
对应文件:01-cache-layered-architecture.png
下一篇将在缓存与性能能力之上补齐限流、结构化日志、Trace ID 和生产故障排查。
学习目标
学完本文后,你应当能够:
- 区分浏览器私有缓存、CDN 共享缓存、Nginx 静态文件和
proxy_cache; - 解释一次静态资源请求为什么不应该进入 Spring Boot;
- 正确使用
root、alias、try_files、index和 MIME Type; - 说明
sendfile、tcp_nopush和open_file_cache分别优化什么; - 解释
open_file_cache为什么不是 HTTP 内容缓存; - 区分强缓存、协商缓存、
ETag、Last-Modified和 304; - 准确解释
no-cache与no-store的差异; - 为 HTML 和带内容哈希的 JS/CSS 设计不同缓存策略;
- 配置 gzip 动态压缩并理解
gzip_types、gzip_vary、gzip_min_length; - 说明
gzip_static的编译边界以及 Brotli 不是 Nginx 默认模块; - 判断哪些内容适合压缩,哪些内容再次压缩收益很低;
- 理解
proxy_cache_path、keys_zone、max_size、inactive和levels; - 解释 Cache Key 如何影响正确性与命中率;
- 正确处理
Vary、Set-Cookie、Authorization、private和no-store; - 解释
proxy_cache_bypass与proxy_no_cache的区别; - 使用
proxy_cache_lock缓解热点缓存击穿; - 使用
proxy_cache_revalidate、proxy_cache_background_update和proxy_cache_use_stale; - 识别
HIT、MISS、BYPASS、EXPIRED、STALE、UPDATING和REVALIDATED; - 设计 Nginx 与 CDN 的分层缓存、回源和失效方案;
- 完成 Docker Compose + Spring Boot 的静态资源、压缩和代理缓存实验。
一、问题背景:性能优化不是“把所有内容缓存起来”
1.1 没有缓存时发生了什么
假设首页包含:
- 1 个 HTML;
- 2 个 JavaScript;
- 1 个 CSS;
- 3 个字体文件;
- 20 张图片;
- 5 个公共 API 请求。
如果所有资源每次都重新从 Spring Boot 获取,用户刷新一次页面可能产生三十多个应用请求。即使静态文件没有变化,后端仍要:
- 接收连接;
- 匹配 Controller 或资源处理器;
- 读取文件;
- 构造响应 Header;
- 占用应用线程、内存和网络带宽;
- 经过日志、监控、鉴权过滤器等完整链路。
当用户数量扩大后,系统会把大量资源消耗在“重复发送没有变化的内容”上。
1.2 缓存优化的真正目标
缓存不是为了让某个单次请求“理论上更快”,而是为了改变整个系统的负载分布:
- 浏览器命中:请求甚至不需要到达服务器;
- CDN 命中:请求不需要跨地域回源;
- Nginx 静态文件:请求不进入 Java 应用;
- Nginx 代理缓存命中:请求不进入后端计算和数据库;
- 陈旧响应降级:后端短暂失败时仍能返回可接受的数据。
因此,缓存优化至少同时影响:
- 用户延迟;
- 后端 QPS;
- 数据库压力;
- 出口带宽;
- 磁盘 I/O;
- CPU 压缩成本;
- 数据一致性;
- 故障时可用性。
1.3 缓存的最大风险不是“没命中”,而是“命中了错误内容”
缓存未命中最多导致性能下降;错误命中可能造成安全事故。
例如:
GET /api/profileAuthorization: Bearer user-a-token如果共享缓存 Key 只有 /api/profile,并且入口忽略了认证与私有缓存限制,那么用户 A 的响应可能被缓存,再返回给用户 B。这不是普通缓存 Bug,而是跨用户数据泄露。
所以缓存策略的优先级应当是:
正确性与数据隔离 > 一致性边界 > 可用性 > 命中率 > 节省资源二、缓存体系全景:四种“缓存”不要混为一谈
2.1 浏览器缓存
浏览器缓存位于用户设备,由 HTTP 响应 Header 控制。
它主要解决:
- 同一用户重复访问;
- 页面刷新;
- 前端路由切换;
- 静态资源重复下载。
典型 Header:
Cache-Control: public, max-age=31536000, immutableETag: "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

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 变化→ 长缓存 + immutable3.2 root 的路径拼接
location /assets/ { root /usr/share/nginx/html;}请求:
/assets/app.8f3a1c.js目标文件:
/usr/share/nginx/html/assets/app.8f3a1c.jsroot 会把规范化后的完整 URI 追加到根目录。
3.3 alias 的替换语义
location /downloads/ { alias /data/public-files/;}请求:
/downloads/manual.pdf目标文件:
/data/public-files/manual.pdfalias 用指定目录替换匹配的 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.html | no-cache 或短 TTL | 发布后需要尽快获取新入口 |
app.<hash>.js | 一年 + immutable | 内容变化会产生新 URL |
| 用户上传文件 | 根据权限与更新模型决定 | URL 可能固定且内容可变 |
| API JSON | 按公共/私有和一致性决定 | 不能照搬静态资源策略 |
四、浏览器缓存:强缓存与协商缓存
独立插图 03:浏览器强缓存与协商缓存决策
对应文件:03-browser-cache-decision.png

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 的区别
| 维度 | ETag | Last-Modified |
|---|---|---|
| 判断依据 | 服务端生成的实体标签 | 文件或资源修改时间 |
| 精度 | 可由应用精确控制 | 通常受时间精度影响 |
| 分布式一致性 | 多节点需生成一致 ETag | 多节点文件时间可能不同 |
| 计算成本 | 强 ETag 可能需要内容摘要 | 通常较低 |
| 适用 | 需要精确表示版本 | 静态文件和简单资源 |
4.3 no-cache 不是“不缓存”
Cache-Control: no-cache表示:
缓存可以存储响应,但每次复用前必须向源站验证。
真正不允许存储:
Cache-Control: no-store例如包含敏感数据的响应:
Cache-Control: private, no-store4.4 max-age 与 s-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

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

5.1 内容编码协商
客户端发送:
Accept-Encoding: br, gzip服务端选择一种编码:
Content-Encoding: gzipVary: 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-Encodinggzip_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.jsapp.js.gz可以使用:
gzip_static on;Nginx 在客户端支持 gzip 时直接发送预压缩文件,避免每次动态压缩。
但必须注意:
ngx_http_gzip_static_module不是 Nginx 默认编译模块。必须通过nginx -V检查--with-http_gzip_static_module,或确认发行版/镜像是否包含。
验证:
nginx -V 2>&1 | tr ' ' '\n' | grep gzip_static5.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;这些指令只有在对应模块已安装并加载时才有效。
生产建议:
- 优先让构建系统生成
.br与.gz; - 静态资源优先使用预压缩文件;
- 动态 API 根据 CPU 和流量评估;
- 如果前面已有 CDN,考虑让 CDN 完成客户端侧压缩;
- 不要在 CDN 和源站重复执行高成本动态压缩;
- 升级 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

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

缓存目录
/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-Expires、Expires 或 Cache-Control 提供明确缓存时间时,使用配置的有效期。
需要谨慎缓存 404:
- 可以减少针对不存在资源的重复回源;
- 但资源刚创建后,用户可能在 404 TTL 内继续看到不存在。
6.4 上游响应 Header 的优先级
Nginx 会处理:
X-Accel-Expires;Expires;Cache-Control;Set-Cookie;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
设计 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=1Nginx 不会自动理解业务参数语义。需要由应用或入口层做规范化,且不能错误地删除真正影响结果的参数。
6.6 HEAD 与 GET
默认情况下,Nginx 可以将 HEAD 转换为 GET 以便共享缓存对象。
如果关闭:
proxy_cache_convert_head off;则 Cache Key 应包含 $request_method,否则不同方法的缓存语义可能混淆。
6.7 proxy_cache_bypass 与 proxy_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
启用后,同一 Cache Key 只有一个请求负责填充新缓存,其余请求等待缓存生成或锁释放。
proxy_cache_lock_timeout 到期后,等待请求可以继续回源,但该请求返回的响应不会写入缓存。
proxy_cache_lock_age 用于处理负责填充缓存的请求过慢:超过该时间后允许再放一个请求回源。
7.3 proxy_cache_revalidate
proxy_cache_revalidate on;过期缓存可以通过条件请求向后端验证:
If-None-MatchIf-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
这会在指定场景使用过期缓存。
适合:
- 公共文章;
- 公告;
- 产品目录;
- 非关键统计;
- 可容忍数十秒陈旧的配置数据。
不适合:
- 余额;
- 权限;
- 库存扣减结果;
- 支付状态;
- 实时风控;
- 用户隐私数据。
不能因为“高可用”就无限返回旧数据。需要明确:
最大允许陈旧时间+ 哪些错误允许 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 的默认指令。
开源场景常见选择:
- 缩短 TTL;
- 版本化 URL;
- 使用经过评估的第三方 purge 模块;
- 由部署系统切换新的缓存命名空间;
- 在维护窗口清理缓存目录并控制并发;
- 让 CDN 平台执行边缘刷新,同时处理 Nginx 源站缓存。
不要在 Nginx 正在高并发读写时随意:
rm -rf /var/cache/nginx/*至少需要评估文件句柄、正在写入的临时文件、权限、并发回源和缓存雪崩。
8.4 多 Nginx 节点一致性
两个 Nginx 节点各自使用本地磁盘:
Nginx A → Cache ANginx 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

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, immutableCDN 与浏览器都可长缓存。
公共 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

10.1 实验目标
本实验验证:
- Nginx 直接托管静态资源;
- HTML 使用
no-cache; - Hash 静态资源使用一年缓存和
immutable; - JSON 和文本响应启用 gzip;
- 公共 API 第一次
MISS、后续HIT; - TTL 过期后出现
EXPIRED或更新; nocache=1、Authorization、Session Cookie 触发BYPASS;Set-Cookie和私有响应不写缓存;- 并发访问慢接口时
proxy_cache_lock限制回源; - 后端失败时可按配置使用
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.sh10.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;
@SpringBootApplicationpublic 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 builderWORKDIR /workspaceCOPY pom.xml .COPY src ./srcRUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=builder /workspace/target/cache-lab-1.0.0.jar /app/app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app/app.jar"]10.12 启动与配置检查
docker compose build --pulldocker compose up -d
docker compose psdocker compose exec nginx nginx -tdocker compose exec nginx nginx -T10.13 验证 HTML 与静态资源
curl -I http://localhost:8088/index.html预期:
Cache-Control: no-cache静态 Hash 资源:
curl -I http://localhost:8088/assets/app.8f3a1c.js预期:
Cache-Control: public, max-age=31536000, immutableExpires: ...Content-Type: application/javascript10.14 验证 gzip
curl --compressed -sD - \ http://localhost:8088/api/cacheable/time \ -o /dev/null时间接口响应可能低于 gzip_min_length,使用大 JSON 接口验证:
curl --compressed -sD - \ http://localhost:8088/api/cacheable/large \ -o /dev/null检查:
Content-Encoding: gzipVary: Accept-Encoding10.15 验证 MISS 与 HIT
第一次:
curl -sD - http://localhost:8088/api/cacheable/time预期:
X-Cache-Status: MISS立即再次执行:
curl -sD - http://localhost:8088/api/cacheable/time预期:
X-Cache-Status: HIT两次响应中的 generatedAt 与 backendInvocation 应相同。
等待超过共享 TTL 后再次请求:
sleep 16curl -sD - http://localhost:8088/api/cacheable/time可能观察到:
X-Cache-Status: EXPIRED或在后台更新场景中看到 STALE / UPDATING,具体取决于请求时机。
10.16 验证显式 bypass
curl -sD - \ 'http://localhost:8088/api/cacheable/time?nocache=1'预期:
X-Cache-Status: BYPASS10.17 验证 Authorization 绕过
curl -sD - \ -H 'Authorization: Bearer demo' \ http://localhost:8088/api/cacheable/time预期为 BYPASS。
10.18 验证 Session Cookie 绕过
curl -sD - \ -H 'Cookie: session=demo-session' \ http://localhost:8088/api/cacheable/time预期为 BYPASS。
10.19 验证私有响应不缓存
curl -sD - http://localhost:8088/api/private/profilecurl -sD - http://localhost:8088/api/private/profile每次 generatedAt 都应变化,并包含:
Cache-Control: private, no-storeSet-Cookie: session=...X-Cache-Status: BYPASS10.20 验证 proxy_cache_lock
先使用不同查询参数制造新 Key:
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 查看缓存目录
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 查看日志
docker compose logs -f nginx backend重点观察:
cache_status;request_time;upstream_time;- 后端实际调用次数;
- 缓存目录权限和磁盘错误。
10.23 停止与清理
保留缓存卷:
docker compose down删除缓存卷:
docker compose down -v十一、核心指令逐项解释
| 指令 | 上下文 | 解决的问题 | 默认或关键行为 | 常见错误 | 验证方式 |
|---|---|---|---|---|---|
expires | http/server/location | 生成浏览器缓存 Header | 默认 off | HTML 长缓存导致旧入口 | curl -I |
add_header Cache-Control | http/server/location | 明确缓存策略 | 需注意状态码与继承 | 子 location 重定义后丢失父级 Header | nginx -T、curl -I |
sendfile | http/server/location | 优化静态文件传输 | 默认关闭 | 当成 HTTP 缓存 | nginx -T、性能测试 |
open_file_cache | http/server/location | 缓存文件描述符和元数据 | 默认关闭 | 误认为缓存文件正文 | strace、文件更新实验 |
gzip | http/server/location/if | 动态压缩响应 | 默认关闭 | 对图片视频重复压缩 | curl --compressed -I |
gzip_vary | http/server/location | 添加 Vary: Accept-Encoding | 默认关闭 | 缓存层混用压缩表示 | 查看 Header |
gzip_static | http/server/location | 发送 .gz 预压缩文件 | 模块非默认编译 | 配置指令不存在 | nginx -V |
proxy_cache_path | http | 定义缓存目录和 zone | 必须在 http | 写在 server 导致语法错误 | nginx -t |
proxy_cache | http/server/location | 启用缓存 zone | 默认 off | 只定义 path 没启用 | $upstream_cache_status |
proxy_cache_key | http/server/location | 定义缓存对象身份 | 默认近似 scheme+proxy_host+URI | 忽略参数/租户造成串数据 | 请求不同维度对比 |
proxy_cache_valid | http/server/location | 无明确上游 TTL 时设有效期 | 按状态码配置 | 缓存错误状态太久 | 重复请求与时间验证 |
proxy_cache_bypass | http/server/location | 跳过读取缓存 | 非空且非 0 条件触发 | 误认为同时禁止写入 | 查看 BYPASS 与后续 HIT |
proxy_no_cache | http/server/location | 禁止写入缓存 | 条件触发 | 误认为同时绕过读取 | 对比当前与后续请求 |
proxy_cache_lock | http/server/location | 缓解缓存击穿 | 默认关闭 | 锁超时过短导致多次回源 | 并发慢接口 |
proxy_cache_revalidate | http/server/location | 条件验证过期缓存 | 默认关闭 | 后端没有验证器 | 抓取 If-None-Match |
proxy_cache_background_update | http/server/location | 后台更新缓存 | 默认关闭 | 未配 stale updating | 观察 UPDATING |
proxy_cache_use_stale | http/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=1 与 page=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:20mmax_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

14.1 第一步:确认旧值来自哪一层
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
nginx -T检查:
- 是否进入静态资源 location;
- 是否被更高优先级的正则 location 抢占;
- 是否进入正确的
proxy_cachelocation; - 子 location 是否覆盖 Header 配置;
- SPA fallback 是否错误接管静态文件。
14.3 第三步:检查静态文件
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 第四步:检查压缩
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 第五步:检查代理缓存条件
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 第六步:检查缓存目录
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 发布与回滚
发布前:
nginx -t;- 在测试环境验证 Header;
- 使用小流量灰度;
- 比较命中率和后端 QPS;
- 检查私有接口是否 BYPASS;
- 检查 CDN 与 Nginx Header 是否冲突。
回滚时:
- 回滚配置不一定自动清除已经生成的错误缓存;
- 需要判断是否清理或切换 Cache Zone;
- 防止回滚和大规模 MISS 同时冲击后端;
- 保留配置、Header 和缓存状态证据用于复盘。
十六、面试与复习问题
16.1 核心问题
1. 浏览器缓存、Nginx 静态文件和 proxy_cache 有什么区别?
浏览器缓存位于客户端;Nginx 静态文件是入口直接读取文件;proxy_cache 缓存后端 HTTP 响应。它们的存储位置、Cache Key、失效和安全边界不同。
2. no-cache 和 no-store 有什么区别?
no-cache 允许存储,但复用前必须验证;no-store 表示不应存储请求或响应内容。
3. 为什么 Hash 静态资源适合一年缓存?
内容变化时文件名和 URL 改变,因此旧 URL 可以安全地长期保持不变。
4. 为什么 index.html 不适合一年强缓存?
URL 通常固定,但内容和引用资源会随发布变化,长缓存可能让用户长期停留在旧入口。
5. keys_zone=20m 是否表示缓存只能存 20MB?
不是。它限制共享内存元数据区,缓存实体通常在磁盘,磁盘目标上限由 max_size 控制。
6. proxy_cache_bypass 与 proxy_no_cache 有何区别?
前者控制是否读取缓存,后者控制当前上游响应是否写入缓存。
7. 为什么有 Set-Cookie 的响应默认不缓存?
它通常包含用户会话或个性化状态,进入共享缓存可能导致数据串用。
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;
- 是否忽略了
private、no-store、Set-Cookie; - CDN 是否也缓存了私有响应。
立即措施:禁用该接口共享缓存、清理所有层错误对象、审计访问日志和影响范围。
场景二:发布后部分用户仍看到旧页面
逐层检查:
- 浏览器强缓存;
- Service Worker;
- CDN;
- Nginx 静态文件或 proxy_cache;
- 多 Nginx 节点版本不一致;
- 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 判断响应来自哪一层最重要的结论有七条:
- 缓存正确性和数据隔离优先于命中率;
- HTML 与内容哈希静态资源必须使用不同策略;
no-cache不是禁止存储,no-store才是;open_file_cache不是 HTTP 内容缓存;keys_zone是缓存元数据内存,不是磁盘实体上限;- Brotli 与
gzip_static都需要核验模块能力,不能假设默认存在; - 多层缓存必须拥有统一的 Key、TTL、失效和排障责任。
下一篇将进入生产入口的可观测性和保护能力:限流、防刷、Access Log、Error Log、Trace ID,以及 400、413、429、499、502、504 等状态码的系统化排查。
参考资料
- Nginx 官方下载页:https://nginx.org/en/download.html,访问日期:2026-07-07。
- Nginx
ngx_http_proxy_module:https://nginx.org/en/docs/http/ngx_http_proxy_module.html,访问日期:2026-07-07。 - Nginx
ngx_http_gzip_module:https://nginx.org/en/docs/http/ngx_http_gzip_module.html,访问日期:2026-07-07。 - Nginx
ngx_http_gzip_static_module:https://nginx.org/en/docs/http/ngx_http_gzip_static_module.html,访问日期:2026-07-07。 - Nginx
ngx_http_headers_module:https://nginx.org/en/docs/http/ngx_http_headers_module.html,访问日期:2026-07-07。 - Nginx
ngx_http_core_module:https://nginx.org/en/docs/http/ngx_http_core_module.html,访问日期:2026-07-07。 - RFC 9111, HTTP Caching:https://www.rfc-editor.org/rfc/rfc9111。
- RFC 9110, HTTP Semantics:https://www.rfc-editor.org/rfc/rfc9110。
- RFC 5861, HTTP Cache-Control Extensions for Stale Content:https://www.rfc-editor.org/rfc/rfc5861。
- RFC 9211, The Cache-Status HTTP Response Header Field:https://www.rfc-editor.org/rfc/rfc9211。
- Google
ngx_brotli:https://github.com/google/ngx_brotli,访问日期:2026-07-07。 - Docker Official Image: Nginx:https://hub.docker.com/_/nginx,访问日期:2026-07-07。
- Spring Boot 官方项目页:https://spring.io/projects/spring-boot,访问日期:2026-07-07。
- 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-cache与no-store; - 避免缓存私有响应;
- 区分
keys_zone与max_size。
配置可运行性
- 提供完整
nginx.conf; - 提供 Docker Compose;
- 提供 Spring Boot Controller、POM 与 Dockerfile;
- 提供启动、验证、日志和清理命令;
- 提供
nginx -t与nginx -T; - 使用固定镜像版本;
- 正文未嵌入图片,仅保留独立图片标记。