HTTP 缓存是 Web 性能优化的基石。一个正确的缓存策略可以将页面加载时间从秒级降到毫秒级,将服务端负载降低 80%+。但缓存也是 Web 工程中最容易出错的领域之一——缓存失效、缓存击穿、过期数据、CDN 与浏览器缓存不一致——每个问题都可能导致用户看到错误内容或服务过载。
本文从工程师视角系统剖析 HTTP 缓存体系,覆盖 Cache-Control 指令全表、条件请求机制、CDN 缓存行为和实际故障排查。
一、HTTP 缓存的分层架构
1.1 缓存存储位置
HTTP 缓存分布在从客户端到服务端的多个层级:
用户浏览器
│
├── 内存缓存(Memory Cache)
│ └── 当前标签页中已加载的资源(关闭标签页后清除)
│
├── 磁盘缓存(Disk Cache)
│ └── 持久化到磁盘的缓存(跨会话保留)
│
├── Service Worker 缓存
│ └── 由 Service Worker 脚本控制的缓存(完全可编程)
│
└── HTTP 缓存(Shared/Private Cache)
│
├── 代理缓存(Shared Cache)
│ ├── CDN 边缘节点
│ ├── 企业代理服务器
│ └── 反向代理(Nginx/Varnish)
│
└── 网关缓存(Origin Shield)
└── CDN 的中间层缓存
每一层缓存的行为由不同的 HTTP 头字段控制。理解这些控制机制是缓存工程的核心。
1.2 缓存命中的判定流程
flowchart TD
A[收到请求] --> B{缓存中有匹配的响应?}
B -- 否 --> C[转发到上游/源站]
B -- 是 --> D{响应是否新鲜?}
D -- 是 --> E[直接返回缓存响应<br/>Status: 200 from cache]
D -- 否 --> F{响应有验证器?<br/>ETag / Last-Modified}
F -- 否 --> C
F -- 是 --> G[发送条件请求<br/>If-None-Match / If-Modified-Since]
G --> H{源站返回 304?}
H -- 是 --> I[更新缓存元数据<br/>返回缓存响应]
H -- 否 --> J[用新响应更新缓存<br/>返回新响应]
1.3 缓存键(Cache Key)
缓存使用”缓存键”来匹配请求。默认的缓存键通常是:
Cache Key = HTTP Method + Effective Request URI
# 示例:
GET https://api.example.com/users?page=1&limit=10
Cache Key = "GET https://api.example.com/users?page=1&limit=10"
# 注意:Query String 的顺序会影响缓存命中
GET /users?page=1&limit=10 # 缓存键 A
GET /users?limit=10&page=1 # 缓存键 B(与 A 不同!)
CDN 通常允许自定义缓存键规则:
# Cloudflare 缓存键自定义示例
# 忽略 Query String 顺序
# 排除特定参数(如跟踪参数)
# 加入特定头字段(如 Accept-Language)
二、Cache-Control 指令全景
Cache-Control 是 HTTP 缓存的核心控制头字段。它定义了丰富的指令集,分为请求指令和响应指令。
2.1 响应指令(Response Directives)
# ── 缓存能力 ──
# public: 响应可以被任何缓存存储(包括 CDN 等共享缓存)
Cache-Control: public, max-age=3600
# 通常用于静态资源。注意:即使没有 public,只要响应有明确的
# max-age 或 Expires,共享缓存也会存储
# private: 响应只能被浏览器缓存,不能被 CDN/代理缓存
Cache-Control: private, max-age=600
# 用于包含用户个人数据的响应
# no-cache: 缓存可以存储,但每次使用前必须向源站验证
Cache-Control: no-cache
# 不是"不缓存"!是"必须重新验证"
# no-store: 禁止缓存存储响应(真正的"不缓存")
Cache-Control: no-store
# 用于极度敏感的数据(银行交易、一次性密码等)
# ── 过期控制 ──
# max-age: 响应的最大新鲜时间(秒)
Cache-Control: max-age=86400
# 从响应生成时算起,86400 秒(1 天)内视为新鲜
# s-maxage: 共享缓存(CDN)的最大新鲜时间(覆盖 max-age)
Cache-Control: max-age=60, s-maxage=3600
# 浏览器缓存 60 秒,CDN 缓存 3600 秒
# 常用于频繁变化但可以被 CDN 缓存更久的内容
# ── 验证控制 ──
# must-revalidate: 过期后必须重新验证,不能使用过期缓存
Cache-Control: max-age=3600, must-revalidate
# 与 no-cache 的区别:max-age 期间内可以直接使用缓存
# 过期后必须验证,如果源站不可达则返回 504 而非过期缓存
# proxy-revalidate: 同 must-revalidate,但只作用于共享缓存
Cache-Control: max-age=3600, proxy-revalidate
# ── 高级指令 ──
# immutable: 告诉客户端,在 max-age 期间内资源不会变化
Cache-Control: max-age=31536000, immutable
# 浏览器不会在页面刷新时发送条件请求
# 用于带 hash 的静态资源:/app.abc123.js
# stale-while-revalidate: 过期后仍可使用旧缓存,同时后台重新验证
Cache-Control: max-age=60, stale-while-revalidate=30
# 60 秒内:直接使用缓存
# 60-90 秒:使用旧缓存 + 后台向源站请求新响应
# 90 秒后:必须等待源站响应
# stale-if-error: 如果源站出错,可以使用过期缓存
Cache-Control: max-age=60, stale-if-error=86400
# 60 秒内:使用缓存
# 60 秒后:尝试验证,如果源站返回 5xx 或不可达,继续使用旧缓存(最多 1 天)
# 对可用性非常重要——源站故障时 CDN 不会把错误传给用户
# no-transform: 禁止中间代理转换响应(如压缩图片、转码视频)
Cache-Control: no-transform
# 某些运营商代理会自动压缩图片以节省带宽,这会导致图片质量下降
2.2 请求指令(Request Directives)
# 客户端可以在请求中使用 Cache-Control 指令来约束缓存行为
# no-cache: 要求缓存重新验证(即使缓存是新鲜的)
# 浏览器 Ctrl+Shift+R(强制刷新)发送
Cache-Control: no-cache
# no-store: 要求不使用缓存
Cache-Control: no-store
# max-age: 客户端可接受的最大缓存年龄
Cache-Control: max-age=0
# max-age=0 等价于 no-cache(要求重新验证)
# max-stale: 客户端愿意接受过期多久的缓存
Cache-Control: max-stale=300
# 可以使用过期不超过 300 秒的缓存
# min-fresh: 客户端要求响应至少还有多少秒的新鲜时间
Cache-Control: min-fresh=120
# 只接受至少还有 120 秒新鲜时间的缓存
# only-if-cached: 只接受缓存响应,不去源站
Cache-Control: only-if-cached
# 如果没有缓存命中,返回 504 Gateway Timeout
2.3 no-cache vs no-store:最常见的误解
| 行为 | no-cache |
no-store |
|---|---|---|
| 缓存存储响应 | ✅ 允许 | ❌ 禁止 |
| 使用前验证 | ✅ 每次都验证 | N/A(不存储) |
| 离线可用 | ✅ 如果验证失败可用旧缓存 | ❌ 无缓存可用 |
| 适用场景 | 经常变化但可缓存的数据 | 敏感数据、一次性数据 |
| 浏览器行为 | 发条件请求(304 可能很快) | 每次都获取完整响应 |
# 验证 no-cache 和 no-store 的区别
# no-cache: 浏览器发送条件请求,可能收到 304(无响应体)
curl -v -H "If-None-Match: \"abc123\"" https://example.com/api/data
# 如果数据未变:HTTP/2 304(几十字节)
# 如果数据已变:HTTP/2 200 + 完整响应体
# no-store: 每次都获取完整响应
curl -v https://example.com/api/sensitive-data
# 始终 HTTP/2 200 + 完整响应体三、条件请求:ETag 与 Last-Modified
3.1 ETag(实体标签)
ETag 是响应内容的唯一标识符。当内容变化时,ETag 也会变化。
# 服务端响应携带 ETag
HTTP/2 200 OK
ETag: "a1b2c3d4e5f6"
Cache-Control: no-cache
Content-Type: application/json
{"users": [...]}
# 客户端后续请求携带 If-None-Match
GET /api/users HTTP/2
If-None-Match: "a1b2c3d4e5f6"
# 如果内容未变:
HTTP/2 304 Not Modified
ETag: "a1b2c3d4e5f6"
# 无响应体,节省带宽
# 如果内容已变:
HTTP/2 200 OK
ETag: "x7y8z9w0a1b2"
Content-Type: application/json
{"users": [...]} # 新数据
强 ETag vs 弱 ETag:
# 强 ETag:字节级精确匹配(默认)
ETag: "a1b2c3d4e5f6"
# 响应体的每一个字节都相同
# 弱 ETag:语义等价(前缀 W/)
ETag: W/"v2.6"
# 内容在语义上等价,但可能有细微差异
# 例如:HTML 中的空格/换行不同,但渲染结果相同
# 弱 ETag 不能用于范围请求(Range Request)
3.2 Last-Modified
Last-Modified 基于时间戳验证:
# 服务端响应携带 Last-Modified
HTTP/2 200 OK
Last-Modified: Thu, 24 Jul 2025 10:30:00 GMT
Cache-Control: max-age=3600
# 缓存过期后,客户端发送条件请求
GET /style.css HTTP/2
If-Modified-Since: Thu, 24 Jul 2025 10:30:00 GMT
# 如果未修改:
HTTP/2 304 Not Modified
# 如果已修改:
HTTP/2 200 OK
Last-Modified: Thu, 24 Jul 2025 15:45:00 GMT
Content-Type: text/css
...
3.3 ETag vs Last-Modified 对比
| 特性 | ETag | Last-Modified |
|---|---|---|
| 精度 | 内容级别(精确) | 秒级别(最小粒度 1 秒) |
| 1 秒内多次修改 | ✅ 能区分 | ❌ 不能区分 |
| 负载均衡场景 | ⚠️ 多台服务器需要生成一致的 ETag | ⚠️ 需要时钟同步 |
| 性能开销 | 需要计算哈希或维护版本号 | 文件系统 mtime 直接可用 |
| CDN 支持 | 完整支持 | 完整支持 |
| 优先级 | ✅ 高(同时存在时优先使用) | 低 |
# Nginx: 默认同时生成 ETag 和 Last-Modified
# ETag 基于文件大小和 mtime 计算
etag on; # 默认开启
# 注意:Nginx 默认的 ETag 是弱比较
# ETag: "66a1b2c3-1f40"
# 格式:mtime(十六进制)- size(十六进制)3.4 ETag 的工程陷阱
多服务器 ETag 不一致:
# 如果 ETag 基于文件 inode 生成,不同服务器的 ETag 会不同
Server A: ETag: "abc-123-456" # inode-size-mtime
Server B: ETag: "def-123-456" # 不同 inode
# 用户先访问 Server A,缓存 ETag "abc-123-456"
# 下次请求到 Server B,If-None-Match: "abc-123-456" 匹配失败
# 本该 304 的请求变成了 200 → 缓存失效
# 解决方案:
# 1. Nginx 的 ETag 基于 mtime + size,不含 inode(默认安全)
# 2. Apache 需要配置:FileETag MTime Size(去掉 INode)
# 3. 使用内容哈希作为 ETag(最可靠)
gzip 压缩与 ETag 冲突:
# Nginx 对动态压缩的响应会自动移除强 ETag
# 因为压缩后的字节不同,强 ETag 不再有效
# 解决方案 1:使用弱 ETag
# Nginx 会自动将 "abc" 转为 W/"abc" 当启用 gzip 时
# 解决方案 2:预压缩静态文件
gzip_static on;
# 预先生成 .gz 文件,ETag 基于 .gz 文件计算,保持一致
四、Vary 头:缓存的第三维度
4.1 Vary 头的工作原理
Vary 头将额外的请求头字段加入缓存键,使同一个 URL 可以有多个缓存副本:
# 服务端响应:内容根据 Accept-Encoding 变化
HTTP/2 200 OK
Vary: Accept-Encoding
Content-Encoding: gzip
Cache-Control: public, max-age=3600
# 缓存存储:
# Cache Key 1: GET /style.css + Accept-Encoding: gzip → gzip 版本
# Cache Key 2: GET /style.css + Accept-Encoding: br → brotli 版本
# Cache Key 3: GET /style.css + Accept-Encoding: identity → 原始版本
# 每个 Accept-Encoding 变体独立缓存
4.2 Vary 头的工程陷阱
陷阱 1:Vary: User-Agent 导致缓存碎片化:
# 错误:按 User-Agent 变化响应
Vary: User-Agent
# 问题:User-Agent 有数千种不同的值
# 每种 User-Agent 生成一个独立的缓存条目
# 缓存命中率趋近于零
# 正确做法:在服务端将 User-Agent 归类
# 然后使用自定义头字段:
Vary: X-Device-Type # 值只有: mobile, tablet, desktop
陷阱 2:Vary: Cookie 使缓存失效:
# 问题:Cookie 值因用户而异
Vary: Cookie
# 效果:每个用户都有独立的缓存副本
# 共享缓存(CDN)的命中率为零
# 退化为 private 缓存
# 解决方案:如果响应确实依赖 Cookie,应该用 private
Cache-Control: private, max-age=600
# 而不是 Vary: Cookie + public
陷阱 3:遗漏 Vary 头导致内容错误:
# 场景:API 根据 Accept-Language 返回不同语言
# 但没有设置 Vary: Accept-Language
GET /api/greeting HTTP/2
Accept-Language: zh-CN
→ 200: {"msg": "你好"} # CDN 缓存了中文版本
GET /api/greeting HTTP/2
Accept-Language: en-US
→ 200: {"msg": "你好"} # CDN 返回了中文版本!
# 修复:
Vary: Accept-Language
4.3 Vary 与 CDN
不同 CDN 对 Vary 头的支持程度不同:
| CDN | Vary: Accept-Encoding | Vary: 其他头字段 | 最大变体数 |
|---|---|---|---|
| Cloudflare | ✅ 完整支持 | ⚠️ 需要 Enterprise 计划 | 有限 |
| Fastly | ✅ 完整支持 | ✅ 完整支持 | 可配置 |
| AWS CloudFront | ✅ 完整支持 | ✅ 需要配置白名单 | 有限 |
| Akamai | ✅ 完整支持 | ✅ 完整支持 | 可配置 |
# 检查响应的 Vary 头
curl -I https://example.com/style.css 2>/dev/null | grep -i vary
# Vary: Accept-Encoding五、Expires 与 Pragma(历史遗留)
5.1 Expires 头
Expires 是 HTTP/1.0 的缓存控制头字段,指定响应过期的绝对时间:
# Expires 使用 HTTP-date 格式
Expires: Thu, 01 Jan 2026 00:00:00 GMT
# 问题:
# 1. 依赖客户端时钟准确性(客户端时钟偏差会导致缓存行为异常)
# 2. 无法表达相对时间(如"缓存 1 小时")
# 3. 日期格式解析复杂
# Cache-Control: max-age 覆盖 Expires
# 当两者同时存在时,max-age 优先
Cache-Control: max-age=3600
Expires: Thu, 01 Jan 2026 00:00:00 GMT
# → max-age=3600 生效,Expires 被忽略
5.2 Pragma: no-cache
# HTTP/1.0 的缓存控制(不建议在新代码中使用)
Pragma: no-cache
# 等价于 Cache-Control: no-cache
# 仅用于向后兼容 HTTP/1.0 代理
# 现代系统应该只使用 Cache-Control
六、缓存策略设计
6.1 常见资源的缓存策略
# Nginx 缓存配置示例
# HTML 文档:不缓存或短缓存
location ~* \.html$ {
add_header Cache-Control "no-cache";
# 每次访问都验证,确保用户看到最新内容
# 但如果内容未变,返回 304(几十字节)
}
# 带 hash 的静态资源:长期缓存
location ~* \.(js|css)$ {
# 文件名包含内容哈希(如 app.abc123.js)
add_header Cache-Control "public, max-age=31536000, immutable";
# 1 年缓存 + immutable(浏览器刷新也不重新验证)
# 文件内容变化时,hash 变化 → URL 变化 → 自动获取新版本
}
# 图片:中等缓存
location ~* \.(png|jpg|gif|webp|svg)$ {
add_header Cache-Control "public, max-age=86400";
# 1 天缓存
}
# API 响应:按业务需求
location /api/ {
# 用户个人数据:私有缓存
add_header Cache-Control "private, no-cache";
# 公共只读数据(如配置):短期共享缓存
# add_header Cache-Control "public, max-age=60, s-maxage=300";
}
# 字体文件:长期缓存 + CORS
location ~* \.(woff2?|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
6.2 不可变资源策略(Content-Addressed)
现代前端构建工具(Webpack、Vite、esbuild)生成带内容哈希的文件名,这是最可靠的缓存策略:
构建输出:
dist/
├── index.html ← no-cache(入口)
├── assets/
│ ├── app.a1b2c3.js ← immutable, 1年(hash 变则 URL 变)
│ ├── vendor.d4e5f6.js ← immutable, 1年
│ ├── style.g7h8i9.css ← immutable, 1年
│ └── logo.j0k1l2.png ← immutable, 1年
└── favicon.ico ← max-age=86400
缓存更新流程:
1. 用户访问 index.html(no-cache → 每次验证)
2. index.html 引用 app.a1b2c3.js(缓存命中 → 直接使用)
3. 代码更新后,构建生成 app.x7y8z9.js(新 hash)
4. 新的 index.html 引用 app.x7y8z9.js
5. 用户刷新 → 获取新 index.html → 请求新的 app.x7y8z9.js
6. 旧的 app.a1b2c3.js 自然过期(或被 CDN purge)
6.3 stale-while-revalidate 策略
# 适用于可以容忍短暂过期的场景(新闻列表、商品信息等)
Cache-Control: public, max-age=60, stale-while-revalidate=300
# 时间线:
# 0-60秒: 使用缓存(新鲜)
# 60-360秒: 使用旧缓存 + 后台异步更新
# 用户不会感知到任何延迟
# 360秒后: 必须等待源站响应
# 结合 stale-if-error 提升可用性
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
# 即使源站故障,也能用旧缓存顶住最多 1 天
七、CDN 缓存工程
7.1 CDN 缓存层级
用户请求 → CDN 边缘 PoP → CDN Origin Shield → 源站
│ │
L1 Cache L2 Cache
(每个 PoP 独立) (全局共享)
L1 Cache(边缘):
- 最接近用户
- 每个 PoP 独立维护
- 缓存命中率取决于 PoP 流量
- 容量较小
L2 Cache(Origin Shield):
- 源站前的集中缓存
- 减少回源请求
- 更高的缓存命中率
- 所有边缘节点的缓存未命中汇聚到这里
7.2 CDN 缓存行为与头字段
# CDN 响应头解读
# X-Cache: 缓存命中状态
X-Cache: Hit from cloudfront # 缓存命中
X-Cache: Miss from cloudfront # 缓存未命中(回源)
X-Cache: RefreshHit from cloudfront # 重新验证后命中
# Age: 响应在缓存中存在的时间(秒)
Age: 1234
# 含义:这个响应在缓存中已存在 1234 秒
# 如果 max-age=3600,则剩余新鲜时间 = 3600 - 1234 = 2366 秒
# Via: 经过的代理
Via: 1.1 abc123.cloudfront.net (CloudFront)
# X-Cache-Hits: 缓存被命中的次数(Varnish)
X-Cache-Hits: 42
# CF-Cache-Status: Cloudflare 缓存状态
CF-Cache-Status: HIT # 缓存命中
CF-Cache-Status: MISS # 未命中
CF-Cache-Status: EXPIRED # 已过期
CF-Cache-Status: BYPASS # 跳过缓存(如有 Cookie)
CF-Cache-Status: DYNAMIC # 动态内容,不缓存# 检查 CDN 缓存行为
curl -sI https://example.com/style.css | grep -iE "cache-control|age|x-cache|cf-cache"
# Cache-Control: public, max-age=86400
# Age: 3421
# X-Cache: Hit from cloudfront
# 对比不同 PoP 的缓存状态
curl -sI --resolve example.com:443:1.2.3.4 https://example.com/style.css | grep x-cache
curl -sI --resolve example.com:443:5.6.7.8 https://example.com/style.css | grep x-cache7.3 CDN 缓存清除(Purge)
# Cloudflare: 清除特定 URL 的缓存
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/style.css"]}'
# Cloudflare: 清除所有缓存
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
# Fastly: 基于 Surrogate-Key 清除(更精确)
# 响应头: Surrogate-Key: product-123 category-shoes
curl -X POST "https://api.fastly.com/service/{service_id}/purge/product-123" \
-H "Fastly-Key: {api_key}"
# 只清除带有 Surrogate-Key: product-123 的缓存条目7.4 s-maxage 与 CDN 控制
# 通过 s-maxage 对浏览器和 CDN 设置不同的缓存时间
Cache-Control: public, max-age=0, s-maxage=86400
# 效果:
# 浏览器:max-age=0 → 每次都验证(发条件请求)
# CDN:s-maxage=86400 → 缓存 1 天
# 好处:CDN 吸收大部分流量,用户总能验证到最新内容
# CDN 专用指令(非标准,但广泛支持)
# Cloudflare:
CDN-Cache-Control: max-age=86400
# Surrogate-Control(Varnish/Fastly):
Surrogate-Control: max-age=86400
八、缓存实战:常见问题与排查
8.1 缓存未命中排查清单
# 1. 检查响应头
curl -sI https://example.com/resource | grep -iE "cache-control|vary|set-cookie|pragma"
# 常见未命中原因:
# ❌ Cache-Control: no-store(禁止缓存)
# ❌ Cache-Control: private(CDN 不缓存)
# ❌ Set-Cookie 存在(大多数 CDN 不缓存带 Set-Cookie 的响应)
# ❌ Vary: Cookie(每个用户独立缓存,命中率极低)
# ❌ Authorization 头(共享缓存不缓存需要认证的响应)
# 2. 检查 Query String
# /page?v=1 和 /page?v=2 是不同的缓存键
# 随机 Query String(如跟踪参数)会导致缓存碎片化
# 3. 检查 CDN 配置
# 是否缓存了正确的文件类型?
# Query String 排序是否规范化?
# Cookie 是否从缓存键中排除?8.2 缓存击穿(Cache Stampede)
缓存击穿发生在热点缓存过期的瞬间,大量请求同时穿透到源站:
T=0: 缓存有效,100 QPS 全部命中
T=60: 缓存过期
T=60.001: 100 个请求同时发现缓存过期 → 100 个回源请求
→ 源站瞬时负载 100x
防御策略:
1. stale-while-revalidate(最简单)
Cache-Control: max-age=60, stale-while-revalidate=300
过期后第一个请求触发后台更新,其他请求继续用旧缓存
2. 加锁回源(CDN 层面)
# Nginx proxy_cache_lock
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# 同一个缓存键只有第一个请求回源,其他等待
3. 提前续期(应用层面)
# 在缓存即将过期时主动更新
TTL remaining < 10%: 后台 goroutine 异步更新缓存
8.3 缓存穿透
缓存穿透是指查询不存在的数据,缓存永远无法命中,每次都打到数据库:
请求: GET /api/users/9999999999(不存在的用户 ID)
→ 缓存未命中 → 数据库查询 → 空结果 → 不缓存 → 下次继续穿透
防御策略:
1. 缓存空结果
Cache-Control: public, max-age=60
# 即使是 404,也缓存 60 秒
2. Bloom Filter 前置过滤
# 用 Bloom Filter 判断 ID 是否可能存在
# 如果 Bloom Filter 说"不存在",直接返回 404
3. 请求校验
# 在缓存层之前验证 ID 格式
# 无效格式直接拒绝
8.4 调试工具
# 完整的缓存调试流程
# 1. 查看所有缓存相关头字段
curl -sI https://example.com/style.css | \
grep -iE "^(cache-control|expires|etag|last-modified|age|vary|x-cache|cf-cache|pragma|set-cookie):"
# 2. 测试条件请求
ETAG=$(curl -sI https://example.com/style.css | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -v -H "If-None-Match: $ETAG" https://example.com/style.css 2>&1 | head -20
# 期望看到 304 Not Modified
# 3. 测试 Vary 行为
curl -sI -H "Accept-Encoding: gzip" https://example.com/style.css | grep -i "vary\|content-encoding"
curl -sI -H "Accept-Encoding: br" https://example.com/style.css | grep -i "vary\|content-encoding"
# 4. Chrome DevTools 缓存分析
# Network 面板 → 右键列头 → 勾选 "Cache-Control", "ETag", "Size"
# Size 列显示 "(memory cache)" 或 "(disk cache)" 表示缓存命中
# Size 列显示实际大小表示网络请求九、Nginx 代理缓存(proxy_cache)
9.1 基础配置
# 定义缓存区域
proxy_cache_path /var/cache/nginx/api
levels=1:2 # 目录层级(避免单目录文件过多)
keys_zone=api_cache:100m # 共享内存区域(存缓存键和元数据)
max_size=10g # 最大磁盘占用
inactive=60m # 60 分钟无访问则清除
use_temp_path=off; # 直接写入缓存目录(避免跨设备移动)
server {
location /api/ {
proxy_pass http://backend;
# 启用缓存
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
# 缓存有效期(如果上游没有设置 Cache-Control)
proxy_cache_valid 200 302 10m; # 200/302 缓存 10 分钟
proxy_cache_valid 404 1m; # 404 缓存 1 分钟
proxy_cache_valid any 0; # 其他不缓存
# 添加缓存状态头(调试用)
add_header X-Cache-Status $upstream_cache_status;
# 值:HIT / MISS / EXPIRED / STALE / BYPASS / REVALIDATED
# 防止缓存击穿
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 5s;
# 源站故障时使用过期缓存
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
# 后台更新
proxy_cache_background_update on;
}
}
9.2 选择性缓存绕过
# 根据条件跳过缓存
# 登录用户不缓存
map $cookie_session $skip_cache {
"" 0; # 无 session cookie → 使用缓存
default 1; # 有 session cookie → 跳过缓存
}
# POST/PUT/DELETE 不缓存
map $request_method $skip_method {
GET 0;
HEAD 0;
default 1;
}
server {
location /api/ {
proxy_cache api_cache;
proxy_cache_bypass $skip_cache $skip_method;
proxy_no_cache $skip_cache $skip_method;
}
}
十、缓存策略决策树
面对一个具体的资源,如何决定缓存策略?以下决策树覆盖了大部分场景:
资源是否包含敏感/用户个人数据?
├── 是 → 是否需要缓存?
│ ├── 否 → Cache-Control: no-store
│ └── 是 → Cache-Control: private, no-cache
│ (浏览器可缓存,每次验证)
│
└── 否 → 资源是否会变化?
├── 从不变化(带 hash 的静态资源)
│ └── Cache-Control: public, max-age=31536000, immutable
│
├── 很少变化(logo、字体)
│ └── Cache-Control: public, max-age=86400
│ + ETag/Last-Modified 用于验证
│
├── 定期变化(API 数据、新闻列表)
│ └── Cache-Control: public, max-age=60, s-maxage=300,
│ stale-while-revalidate=120, stale-if-error=86400
│
└── 实时性要求高(股票价格、聊天消息)
└── Cache-Control: no-cache
+ ETag 用于条件验证(减少带宽)
十一、总结
HTTP 缓存工程的核心挑战不在于理解单个指令的含义,而在于为每种资源选择正确的策略组合,并在以下维度之间取得平衡:
新鲜度 vs 性能:缓存时间越长,性能越好,但用户看到过期数据的风险越大。
stale-while-revalidate在两者之间找到了优雅的折中。缓存命中率 vs 正确性:
Vary头越多,缓存变体越多,命中率越低。不合理的Vary配置(如Vary: User-Agent)可以将缓存命中率降到接近零。CDN vs 浏览器:通过
s-maxage和private/public区分两层缓存的行为。CDN 层可以更激进(长 TTL + 主动 purge),浏览器层应该更保守。可用性 vs 一致性:
stale-if-error允许在源站故障时继续服务旧内容,牺牲了一致性,但保住了可用性。在大多数场景下,这是正确的权衡。
最后一个建议:在设计缓存策略之前,先用
curl -sI
检查你的服务现在返回了什么缓存头字段。很多缓存问题的根源是”以为设置了,其实没有”或”设置了,但被中间代理改掉了”。
参考文献
- RFC 7234: HTTP/1.1 Caching
- RFC 9111: HTTP Caching (Revised)
- RFC 5861: HTTP Cache-Control Extensions for Stale Content
- RFC 8246: HTTP Immutable Responses
- Jake Archibald, “Caching best practices & max-age gotchas”, 2016
上一篇:HTTP/2 完整解剖:流、帧、HPACK 与 Server Push
下一篇:Cookie 与 Session 工程:安全属性与现代限制
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】CDN 缓存策略:TTL、Purge 与 stale-while-revalidate
深入剖析 CDN 缓存策略的工程实践——TTL 设置方法论、Purge 机制与一致性保证、stale-while-revalidate 的工程价值、缓存命中率优化与常见缓存问题排查。
网络工程索引
汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。
【网络工程】CDN 架构原理:PoP、边缘节点与 Origin Shield
系统解剖 CDN 的多层缓存架构——从 DNS 调度到 PoP 内部结构、Origin Shield 回源保护、多 CDN 部署策略。结合实际配置和响应头分析,给出 CDN 架构的工程理解。
【网络工程】动态加速:TCP 优化、路由优化与边缘计算
CDN 对静态资源的加速已成共识,但动态 API 请求同样可以受益于 CDN 的网络基础设施。本文从 TCP 优化、智能路由到边缘计算三个层次,拆解动态加速的技术原理、架构选型与工程实践。