网络-13 HTTP 缓存
前置阅读:网络-12 HTTP 报文、方法与状态码
缓存是 Web 性能优化里投入产出比最高的一项——命中缓存意味着零网络请求、零服务端压力。HTTP 缓存分两大类:
- 强缓存:直接用本地副本,完全不发请求;
- 协商缓存:发一个请求去问服务端"我这份还能用吗",能用则返回 304(无 body),不能用才返回完整内容。
两者是先后顺序关系:先查强缓存,未命中或已过期才走协商缓存。
1. 强缓存
1.1 Expires(HTTP/1.0,已过时)
Expires: Wed, 29 Jul 2026 12:00:00 GMT
指定一个绝对过期时间。它的致命缺陷是依赖客户端本地时钟——用户把系统时间改了,或者时区不对,缓存策略就完全失效。
现在它只作为 Cache-Control 的降级兼容存在。两者同时出现时,Cache-Control 优先级更高。
1.2 Cache-Control(HTTP/1.1,现在的标准)
Cache-Control: max-age=31536000
用相对时间(秒)解决了时钟问题:从收到响应开始算,这么多秒内都算新鲜。
常用指令:
| 指令 | 作用 | 用在 |
|---|---|---|
max-age=<秒> |
缓存有效期 | 请求/响应 |
no-cache |
可以缓存,但每次使用前必须去服务端验证(即强制走协商缓存) | 请求/响应 |
no-store |
完全不缓存,不写磁盘也不写内存 | 请求/响应 |
private |
只允许浏览器缓存,CDN/代理不得缓存 | 响应 |
public |
允许任何中间节点缓存 | 响应 |
must-revalidate |
过期后必须验证,不许用过期副本 | 响应 |
s-maxage=<秒> |
专门给共享缓存(CDN)的有效期,覆盖 max-age |
响应 |
immutable |
承诺内容永不改变,即使用户刷新也不重新验证 | 响应 |
stale-while-revalidate=<秒> |
允许先用过期副本,后台异步更新 | 响应 |
no-cache 和 no-store 是最容易混的一对:
no-cache—— 字面意思有误导性。它不是"不缓存",而是"缓存但不直接用",每次都要向服务端确认(走协商缓存,可能拿到 304)。no-store—— 才是真正的"不缓存"。适用于含敏感信息的响应(银行账单、个人隐私页面)。
所以想禁用缓存应该用 no-store,用 no-cache 只是强制每次验证而已。
2. 协商缓存
强缓存过期后,浏览器不会直接重新下载,而是带上"验证器"去问服务端。有两组验证器:
2.1 Last-Modified / If-Modified-Since
① 首次响应:
Last-Modified: Wed, 29 Jul 2026 10:00:00 GMT
② 再次请求时浏览器带上:
If-Modified-Since: Wed, 29 Jul 2026 10:00:00 GMT
③ 服务端比对:
未改动 → 304 Not Modified(无 body)
已改动 → 200 + 新内容 + 新的 Last-Modified
它有三个固有缺陷:
- 精度只到秒。1 秒内的多次修改无法识别,会误判为"未修改"。
- 只看修改时间,不看内容。文件被改回原样(内容相同但 mtime 变了),会误判为"已修改"而重新传输。
- 分布式部署下不可靠。同一份文件部署到多台机器,mtime 各不相同,用户请求打到不同机器就会反复失效。
2.2 ETag / If-None-Match
① 首次响应:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
② 再次请求时浏览器带上:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
③ 服务端比对:
一致 → 304
不一致 → 200 + 新内容 + 新 ETag
ETag 是资源内容的唯一标识(通常是内容哈希),精确解决了上面三个问题。
强 ETag vs 弱 ETag:
ETag: "abc123" # 强验证器:字节级完全一致
ETag: W/"abc123" # 弱验证器:语义等价即可(如仅广告位不同)
优先级:两组验证器同时存在时,ETag 优先级高于 Last-Modified。服务端会优先比对 If-None-Match,只在没有 ETag 时才看 If-Modified-Since。
ETag 的代价是计算开销:每次响应都要算一遍内容哈希。对大文件或高 QPS 场景,Nginx 默认用 文件mtime + 文件大小 生成 ETag 而非完整哈希,就是为了避开这个开销。
3. 完整决策流程
浏览器发起请求
│
▼
┌─────────────────────┐
│ 本地有缓存副本吗? │── 无 ──► 发请求,走完整流程 (200)
└──────┬──────────────┘
│ 有
▼
┌─────────────────────────────────┐
│ 强缓存是否命中? │
│ (Cache-Control: max-age 未过期 │
│ 且非 no-cache) │
└──────┬───────────────┬──────────┘
│ 命中 │ 未命中/已过期
▼ ▼
直接用本地副本 ┌────────────────────────────────────┐
200 (from cache) │ 带验证器发请求: │
★ 零网络请求 │ If-None-Match / If-Modified-Since │
└──────┬─────────────────────────────┘
▼
┌────────────────┐
│ 服务端比对结果 │
└──┬────────┬────┘
│ 一致 │ 不一致
▼ ▼
304 无 body 200 + 新内容
★ 省流量 更新本地缓存
在 Chrome DevTools 的 Network 面板里,可以直接看出走了哪条路:
| Size 列显示 | 含义 |
|---|---|
(disk cache) / (memory cache) |
强缓存命中,没发请求 |
304 + 很小的体积 |
协商缓存命中,发了请求但没传内容 |
| 完整体积 | 未命中,完整下载 |
4. 实践:该怎么配
最经典的方案是按资源类型分治:
# 带哈希指纹的静态资源 —— 长期强缓存
# 文件名如 app.8f3a2b1c.js,内容变了文件名就变了
location ~* \.(js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML 入口文件 —— 绝对不能强缓存
# 它要负责引用最新的带指纹的资源
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
# API 接口 —— 通常不缓存
location /api/ {
add_header Cache-Control "no-store";
}
核心思路叫「内容指纹 + 永久缓存」:
- 构建工具(Webpack/Vite)给静态资源文件名加上内容哈希:
app.js→app.8f3a2b1c.js; - 这些文件设
max-age=31536000(一年)+immutable,浏览器一年内碰都不碰; - 内容一变,哈希就变,文件名就变,浏览器视作全新 URL 自然会去下载;
- HTML 入口文件设
no-cache,保证每次都去验证——它负责指向最新的资源文件名。
这样既拿到了强缓存的极致性能,又不存在"更新了但用户拿不到"的问题。
immutable 解决的具体问题:用户按 F5 刷新时,浏览器会对所有资源发起验证请求(即使 max-age 没过期),产生大量无意义的 304。加上 immutable 后浏览器会跳过这个验证。
4.1 缓存的"更新"难题
HTTP 缓存没有主动失效机制 —— 一旦响应带着 max-age=31536000 发出去,你就再也无法让已经缓存它的浏览器提前丢弃。这就是为什么 HTML 入口必须 no-cache:它是整个缓存体系里唯一可控的那一环。
CDN 层面有主动刷新接口(purge),但那只能清 CDN 的边缘节点,清不掉用户浏览器里的。所以给 HTML 或 API 设长 max-age 是非常危险的操作 —— 出了问题只能等它自然过期。
5. Go 里怎么处理
标准库的 http.ServeContent / http.ServeFile 已经自动处理了协商缓存,会根据 modtime 生成 Last-Modified 并处理 If-Modified-Since:
// 自动处理 Last-Modified / If-Modified-Since,命中就返回 304
http.ServeFile(w, r, "./static/app.js")
手动实现 ETag 协商:
func handler(w http.ResponseWriter, r *http.Request) {
body := getContent()
// 用内容哈希作为 ETag
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache") // 强制每次验证
// 客户端带来的 ETag 与当前一致 → 304,不传 body
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Write(body)
}
注意 304 响应必须不带 body,WriteHeader(304) 后直接 return。
6. 本章面试题
强缓存和协商缓存的区别?各由哪些头部控制?
- 强缓存:直接使用本地副本,完全不发网络请求。由
Cache-Control: max-age(HTTP/1.1,相对时间)和Expires(HTTP/1.0,绝对时间)控制,前者优先级更高。DevTools 显示(disk cache)。 - 协商缓存:发请求询问服务端是否有更新,未变更返回 304(无 body)。由
ETag/If-None-Match和Last-Modified/If-Modified-Since控制,前者优先级更高。
顺序是:先查强缓存,未命中或已过期才走协商缓存。
no-cache 和 no-store 的区别?
no-cache 的字面意思有误导性——它不是"不缓存",而是"缓存但每次使用前必须向服务端验证",即强制走协商缓存,可能返回 304。
no-store 才是真正完全不缓存,不写磁盘也不写内存。用于敏感信息(账单、隐私页)。
想禁用缓存应该用 no-store。
ETag 相比 Last-Modified 解决了什么问题?
Last-Modified 有三个缺陷:
- 精度只到秒,1 秒内的多次修改无法识别;
- 只看时间不看内容,文件改回原样也会因 mtime 变化而重传;
- 分布式部署下不可靠,多台机器的 mtime 不一致会导致缓存反复失效。
ETag 是内容的唯一标识(通常为哈希),精确解决了以上问题。代价是每次要计算哈希,有 CPU 开销——Nginx 因此默认用 mtime + 文件大小 生成而非完整哈希。
静态资源和 HTML 的缓存策略该怎么配?为什么?
内容指纹 + 永久缓存:
- 带哈希文件名的静态资源(
app.8f3a2b1c.js)→max-age=31536000, immutable。内容变则文件名变,浏览器视作新 URL,不存在更新不到的问题。 - HTML 入口文件 →
no-cache,每次都验证。它负责引用最新的资源文件名,是整个缓存体系里唯一可控的一环。 - API → 通常
no-store。
关键原因:HTTP 缓存没有主动失效机制。max-age 一旦发出就无法让浏览器提前丢弃(CDN 的 purge 清不掉用户浏览器),所以给 HTML/API 设长 max-age 极其危险。
immutable 是干什么的?
解决用户按 F5 刷新的问题:即使 max-age 未过期,刷新时浏览器仍会对资源发起验证请求,产生大量无意义的 304。
immutable 承诺内容永不改变,浏览器会跳过这次验证。配合内容哈希文件名使用最安全。
xingliuhua