引言
HTTP 缓存是性能优化里「性价比最高、也最容易被搞错」的一环。一个 Cache-Control 头写错,轻则用户看到旧页面,重则 CDN 缓存了用户私有数据造成越权。它同时横跨协议语义(哪些响应可缓存、如何验证新鲜度)与工程实践(指纹化、分层缓存、失效策略)。
本文从缓存的三个层级讲起,逐条拆解 Cache-Control 指令,讲透强缓存与协商缓存的配合、ETag 与条件请求的往返、Vary 的陷阱,最后落到资源指纹化与 CDN 实战。目标是让你能针对每一类资源,写出精确的缓存头。
相关:HTTP 状态码与请求语义 、curl 与 HTTP 调试实战 。CDN 与代理层落地见 nginx 缓存与压缩 与 network 专题 。
1. 缓存的三个层级
1.1 谁在缓存
| 层级 | 位置 | 典型代表 | 缓存键 |
|---|---|---|---|
| 浏览器缓存 | 客户端 | Chrome、Safari | URL + Vary |
| 共享代理 | 中间 | 企业代理、Squid | URL + Vary |
| CDN 边缘 | 网络边缘 | Cloudflare、Akamai | URL + Vary |
私有缓存(private) 只服务单个用户(浏览器),可存用户专属响应;共享缓存(shared) 服务多个用户(CDN、代理),绝不能存带用户信息的响应。这个区分是所有缓存安全的基石。
1.2 缓存决策流程
收到响应
→ 该响应可缓存吗?(方法、状态码、Cache-Control)
→ 是:存入缓存,标记新鲜度
请求到达
→ 缓存中有新鲜副本吗?
→ 有:直接用(不发请求)
→ 没有(陈旧):发条件请求验证
→ 304:用副本
→ 200:更新副本
2. 强缓存:Cache-Control
2.1 新鲜度指令
Cache-Control 是缓存控制的唯一权威,优先级高于 Expires。
Cache-Control: max-age=31536000, immutable
| 指令 | 含义 | 适用 |
|---|---|---|
max-age=N | N 秒内视为新鲜(私有/共享通用) | 通用 |
s-maxage=N | 仅对共享缓存生效,覆盖 max-age | CDN |
no-cache | 可存但每次必须验证 | 需实时校验 |
no-store | 完全不缓存 | 敏感数据 |
private | 仅私有缓存可存 | 用户数据 |
public | 共享缓存也可存 | 公开资源 |
must-revalidate | 陈旧后禁止使用,必须验证 | 严格一致 |
immutable | 新鲜期内绝不验证 | 指纹化静态资源 |
stale-while-revalidate=N | 陈旧后 N 秒内先用旧值,后台更新 | 性能优先 |
stale-if-error=N | 源站错误时可用陈旧副本 N 秒 | 可用性 |
2.2 no-cache vs no-store:最常混淆的一对
no-cache:可以缓存,但每次使用前必须向服务器验证(发条件请求)。名字有误导性,它其实是「必须验证」。no-store:完全禁止缓存,任何副本都不留。用于密码、token、支付信息。
# 错误:以为 no-cache 是不缓存,用它保护敏感数据
Cache-Control: no-cache
# 正确:敏感数据必须 no-store
Cache-Control: no-store, private
2.3 Expires 与 Cache-Control 的关系
Expires 是 HTTP/1.0 的绝对时间,Cache-Control: max-age 是相对时间。两者同时存在时 max-age 优先。只用 Cache-Control 即可,Expires 主要用于兼容极老客户端。
# 兼容写法
Cache-Control: max-age=3600
Expires: Wed, 21 Oct 2026 07:28:00 GMT
Expires 依赖客户端时钟,时钟不准会失效;max-age 是相对的,更可靠。
3. 协商缓存:ETag 与 Last-Modified
强缓存过期后,不一定要重新下载——如果内容没变,服务器只需回一个 304 Not Modified。
3.1 Last-Modified / If-Modified-Since
基于文件的最后修改时间:
# 首次响应
Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
# 后续请求
If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT
# 未变则响应
HTTP/1.1 304 Not Modified
局限:
- 精度只到秒:一秒内多次修改无法区分。
- 时间不可靠:文件被重新生成但内容不变,时间会变。
- 分布式不一致:多台服务器的文件时间可能不同。
3.2 ETag / If-None-Match
ETag 是内容的指纹(通常是哈希):
# 首次响应
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# 后续请求
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# 未变
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
| 类型 | 形式 | 特点 |
|---|---|---|
| 强 ETag | "abc123" | 字节级完全一致才匹配 |
| 弱 ETag | W/"abc123" | 语义等价即可(忽略细微差异) |
优先级:If-None-Match 优先于 If-Modified-Since。两者都发时,服务器应以 ETag 为准。
3.3 如何生成 ETag
import hashlib
from flask import Flask, request, Response
app = Flask(__name__)
@app.route("/data")
def data():
body = render_content() # 生成响应体
etag = '"%s"' % hashlib.md5(body.encode()).hexdigest()
if request.headers.get("If-None-Match") == etag:
return Response(status=304, headers={"ETag": etag})
return Response(body, headers={
"ETag": etag,
"Cache-Control": "no-cache", # 可存,但每次验证
})
注意:ETag 常被加引号包裹,比较时必须包含引号。W/ 前缀表示弱验证。
3.4 nginx 的自动 ETag
nginx 默认对静态文件生成基于 mtime + size 的弱 ETag:
# 关闭自动 ETag(若用自己计算的强 ETag)
etag off;
# 或保留,让 nginx 处理
# etag on; # 默认
nginx 的 ETag 由文件元数据生成,多机部署时若文件时间不一致会导致 ETag 不一致——多机场景建议改用内容哈希生成 ETag。更细的多层缓存配置见 nginx 多级缓存 。
4. 条件请求的往返
4.1 完整往返示例
# 第一次请求
curl -i https://example.com/app.js
# HTTP/1.1 200 OK
# Cache-Control: max-age=0, must-revalidate
# ETag: "abc123"
# Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
# (响应体 ...)
# 缓存过期后再次请求
curl -i https://example.com/app.js \
-H 'If-None-Match: "abc123"' \
-H 'If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT'
# HTTP/1.1 304 Not Modified
# ETag: "abc123"
# (无响应体,省下传输)
304 的收益:省下响应体传输(大文件收益巨大),但仍有一次往返(RTT)。所以对静态资源,用长 max-age + 指纹化文件名比频繁 304 更优——直接零往返。
4.2 条件请求的其他用途
| 头 | 用途 |
|---|---|
If-None-Match | GET 条件获取(缓存验证) |
If-Match | PUT/DELETE 乐观并发控制(防覆盖) |
If-Modified-Since | 同上,时间粒度 |
If-Unmodified-Since | 仅在未修改时执行 |
If-Range | 断点续传的验证 |
If-Match 在 API 里用于乐观锁:
PUT /articles/42
If-Match: "v5-etag"
若当前 ETag 不是 v5-etag,服务器返回 412 Precondition Failed,防止并发覆盖。这与 API 缓存与性能
中的并发控制密切相关。
5. Vary 与内容协商
5.1 Vary 的作用
同一 URL 可能因请求头不同返回不同内容。Vary 告诉缓存「按哪些请求头区分副本」:
Vary: Accept-Encoding, Accept-Language
若不设 Vary,缓存可能把 gzip 版本返回给不支持 gzip 的客户端,或把中文页面返回给英文用户。
5.2 Vary: * 与缓存失效
Vary: *
Vary: * 意味着「任何请求头都可能影响响应」,等于告诉缓存不要缓存(无法命中)。除非确实需要,否则别用。
5.3 Vary 的常见陷阱
| 陷阱 | 后果 | 对策 |
|---|---|---|
漏设 Accept-Encoding | 压缩/非压缩串味 | 显式声明 |
设了 User-Agent | 缓存碎片化(UA 无穷多) | 归一化或改用其他维度 |
设了 Cookie | 几乎无法命中 | 用 private 或拆分 URL |
Vary: User-Agent 是经典反模式:UA 字符串千变万化,缓存命中率趋近于零。
6. 缓存策略与实战
6.1 资源指纹化(fingerprinting)
最有效的策略是「内容变了,URL 就变」:
/app.3f2a1b.js ← 内容哈希进文件名
/app.js ← 不带哈希
对指纹化资源,设超长缓存:
Cache-Control: public, max-age=31536000, immutable
因为内容变时 URL 也变,不存在「过期」问题。HTML 入口文件则相反:
Cache-Control: no-cache
让它每次验证,从而及时引用到新的资源 URL。
6.2 按资源类型定策略
| 资源 | Cache-Control | 理由 |
|---|---|---|
| HTML 入口 | no-cache | 必须及时更新,但可用 ETag 省流量 |
| 指纹化 JS/CSS | public, max-age=31536000, immutable | URL 变化即失效 |
| 图片/字体 | public, max-age=604800 | 变动少 |
| API 数据 | private, no-cache 或按需 | 依业务 |
| 用户私有 | private, no-store | 安全 |
| 用户上传(带哈希) | public, max-age=31536000 | 内容寻址 |
6.3 stale-while-revalidate
Cache-Control: max-age=60, stale-while-revalidate=600
含义:60 秒内新鲜;60~660 秒内立即返回陈旧副本,同时在后台异步更新。用户零等待,下次拿到新数据。这是现代 CDN 性能优化的利器。
Cache-Control: max-age=60, stale-if-error=86400
源站故障时,可用陈旧副本最长 86400 秒,提升可用性。
6.4 CDN 分层
CDN 通常有边缘节点与中间层(shield)两级。用 s-maxage 控制共享缓存:
Cache-Control: public, max-age=0, s-maxage=600
含义:浏览器不缓存(max-age=0),但 CDN 缓存 600 秒。这样用户每次拿到 CDN 的最新副本,而回源被 CDN 挡住。边缘与中心的分层策略见 nginx CDN 边缘缓存
。
7. 常见错误与调试
7.1 典型错误
| 错误 | 后果 |
|---|---|
用 no-cache 保护敏感数据 | 数据仍被缓存,越权 |
Cache-Control: max-age=0 当「不缓存」 | 仍会条件请求,非完全禁用 |
漏设 Vary: Accept-Encoding | 压缩内容串味 |
| 强缓存私有页面 | CDN 返回他人数据 |
| ETag 基于文件时间 | 多机不一致 |
Expires 用了本地时间 | 时区错误 |
7.2 调试缓存
# 看响应头
curl -I https://example.com/app.js
# 模拟条件请求,验证 304
curl -i https://example.com/app.js -H 'If-None-Match: "abc123"'
# 看是否命中 CDN 缓存
curl -I https://example.com/app.js | grep -i -E 'age|x-cache|cf-cache'
# 强制绕过本地缓存
curl -i -H 'Cache-Control: no-cache' https://example.com/app.js
响应头 Age 表示副本已在缓存中存活的秒数。Age: 0 或缺失说明是回源响应;Age: 300 说明命中了已缓存 300 秒的副本。
浏览器 DevTools 的 Network 面板会标注 (from disk cache)、(from memory cache)、304,是排查缓存问题最直接的工具。更完整的 HTTP 调试方法见 curl 与 HTTP 调试实战
。
7.3 缓存清除(purge)
内容更新后若 URL 未变,需要主动清除 CDN 缓存:
# Cloudflare 示例
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/app.js"]}'
指纹化之所以优于 purge,就是因为它不需要这一步——新内容对应新 URL,旧缓存自然淘汰。
8. 小结
HTTP 缓存的核心是两条链路:强缓存(Cache-Control: max-age,命中则零请求)与协商缓存(ETag/Last-Modified + 条件请求,命中则省响应体)。设计缓存策略时按资源分类:入口 HTML 用 no-cache,指纹化静态资源用长 max-age + immutable,私有数据用 private(必要时 no-store),共享缓存用 s-maxage 控制。三个最常见的错误是:把 no-cache 当 no-store 用、漏设 Vary、对私有内容开了共享缓存。把这三条守住,再配合 stale-while-revalidate 与指纹化,缓存就能从「事故源」变成「性能引擎」。相关语义细节回到 HTTP 状态码与请求语义
,CDN 侧配置见 nginx 多级缓存
与 nginx CDN 边缘缓存
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。