第 8 章 缓存:HTTP 里最复杂、最被低估的子系统
缓存是 HTTP 里最能暴露工程师功力的一部分。会写业务的人满大街,懂缓存的人,能帮公司省下 90% 的带宽和一台又一台的服务器。
8.0 引子:发了新版,用户看到的还是旧的
小美:老哥,前端刚发的新版,为什么用户刷新了好几次,看到的还是旧页面?
你:浏览器缓存了?让用户强制刷新(Ctrl+F5)试试。
小美:不行。有的用户清缓存也没用。而且 CDN 那边也没回源。
老张:更严重的是,昨天有个用户投诉,他登录之后看到了别人的购物车。
你:什么?!看到别人的数据?
墨叔:两个问题,一个是”缓存没失效”,一个是”缓存污染”。它们指向同一个被严重低估的事实:
你以为你”什么都没配置缓存”,但实际上,从浏览器到 CDN,一路上有 N 个环节在”替你做缓存决策”。而它们的默认行为,往往就是事故的来源。
8.1 先把问题问对:为什么 HTTP 里必须有缓存
墨叔:先问一个根本问题:为什么 HTTP 里要有缓存?为什么不在应用层自己搞?
你:为了省流量、加速?
墨叔:对,但这不是”可选的优化”,而是协议架构的必然推论。
回第 1 章的”分层约束”:HTTP 假设了”客户端和源站之间可能有无数中间设备”。那么问题来了——如果一个响应在 1 秒钟内被请求了 100 万次,凭什么每次都打到源站?
答案是:中间设备有权缓存响应,直接返回给请求方,而不必回源站。
你:所以缓存是”分层约束”的直接后果?
墨叔:精确。如果 HTTP 不内建缓存,那 CDN 这种东西根本不可能存在——CDN 的整个商业模式,就是”在离用户更近的地方缓存源站的内容”。
缓存不是 HTTP 的”附加功能”,它是 HTTP 架构得以撑起今天这个互联网规模的根本原因之一。
想一想:一个热门视频,一天几亿次播放。如果每次都回源站,源站早就被打死了。是缓存让”亿级访问”分摊到了”边缘的千万个节点”上。
8.2 私有缓存 vs 共享缓存:最关键的区分
墨叔:讲缓存,必须先讲一个被 90% 的人忽略的区分——私有缓存 vs 共享缓存。
| 私有缓存(private) | 共享缓存(shared) | |
|---|---|---|
| 在哪 | 用户自己的浏览器里 | 中间设备(CDN、公司代理、ISP 缓存) |
| 服务谁 | 只服务这一个用户 | 服务所有经过它的用户 |
| 风险 | 低(只坑自己) | 高(能坑所有人) |
你:共享缓存风险高,是因为它缓存的内容会被别人看到?
墨叔:对。这是引子里”用户看到别人购物车”的根源:
假设你的购物车接口返回:
HTTP/1.1 200 OK
Cache-Control: max-age=3600 <- 只说了"能缓存 1 小时",没说"私有还是共享"- 中间有一个 CDN(共享缓存)。
- 用户 A 请求
/cart,CDN 回源拿到 A 的购物车,缓存了下来。 - 用户 B 请求
/cart,CDN 一看”有缓存,1 小时内直接返回”,于是把 A 的购物车返回给了 B。
这就是缓存污染事故的完整机制。 而它本可以用一个单词避免:
Cache-Control: private, max-age=3600 <- private = 只能存浏览器里,CDN 不许存你:所以 private 和 public 的区别,是”这条响应能不能被中间设备缓存”?
墨叔:对。准确说:
private:只能被单个用户的浏览器缓存,CDN 这类共享缓存不许存。public:可以被共享缓存存。- 什么都不写(只有
max-age):默认可以被共享缓存。
这就是引子里”什么都没配”最危险的地方——默认行为倾向于”能缓存”,而不是”不缓存”。
8.3 强制缓存:Cache-Control 的一堆指令
墨叔:现在系统讲 Cache-Control。它是缓存的”总开关”,一堆指令,每个都有讲究。
核心指令
| 指令 | 含义 | 常见误解 |
|---|---|---|
max-age=3600 | 缓存 3600 秒内有效,直接用,不问服务器 | 这是”相对时间”,从响应生成算起 |
s-maxage=3600 | 仅对共享缓存有效,覆盖 max-age | 专门给 CDN 用的 |
no-cache | 不是”不缓存”! 是”能缓存,但每次用之前必须先问服务器” | 最大的误解点 |
no-store | 真正的不缓存,任何缓存都不许存 | 和 no-cache 天差地别 |
must-revalidate | 过期后必须回源校验,不许用过期缓存兜底 | 防止用”脏数据” |
immutable | 资源永不改变,连校验都不用 | 用于带指纹的静态资源 |
stale-while-revalidate | 过期了先用旧的顶着,同时后台回源更新 | 现代”用户体验优先”的技巧 |
你:no-cache 不是”不缓存”?这个我一直搞错了!
墨叔:这是全行业最常见的误解之一。记住:
no-cache= 缓存,但每次用之前先问(协商缓存);no-store= 完全不缓存。
很多人想”禁止缓存”,结果写了 no-cache,发现还是被缓存了(只是每次都回源校验),于是以为”缓存控制失效了”。其实是他们用错了指令。
你:那 Expires 呢?我看到很多老代码用 Expires。
墨叔:Expires: Wed, 21 Oct 2026 07:28:00 GMT 是 HTTP/1.0 时代的”绝对过期时间”。它的坑在于:依赖客户端和服务器的时钟同步。时钟歪了,过期判断就错。所以 HTTP/1.1 用 max-age(相对时间)取代了它。max-age 优先级高于 Expires,两者都写时以 max-age 为准。
8.4 协商缓存:只问一句”变没变”
墨叔:max-age 到期后怎么办?不是直接回源全量下载,而是——先问服务器”变没变”。这就是协商缓存(也叫”条件请求”)。
机制有两种,一旧一新:
旧方案:Last-Modified / If-Modified-Since
# 首次响应,服务器带上资源的最后修改时间
HTTP/1.1 200 OK
Last-Modified: Sat, 29 Aug 2026 10:00:00 GMT
# 下次请求,客户端带上这个时间
GET /a.js HTTP/1.1
If-Modified-Since: Sat, 29 Aug 2026 10:00:00 GMT
# 服务器对比:没变 → 返回 304,无 body(省流量)
HTTP/1.1 304 Not Modified你:304 就是”没变,别重传了”。
墨叔:对。但 Last-Modified 有两个致命缺陷:
- 精度只有 1 秒。如果资源在 1 秒内被改了两次,
Last-Modified分不出来。 - 它只代表”修改时间变了”,不代表”内容变了”。比如你 touch 了一下文件(改了时间戳,内容没变),
Last-Modified变了,但内容一样,白缓存失效。
新方案:ETag / If-None-Match
ETag 是”内容指纹”——根据内容算出来的一个标识,内容变,ETag 一定变。
HTTP/1.1 200 OK
ETag: "a3f5c8d9"
GET /a.js HTTP/1.1
If-None-Match: "a3f5c8d9"
HTTP/1.1 304 Not Modified你:ETag 比 Last-Modified 好在哪?
墨叔:精确。它是”内容”的指纹,不是”时间”的标记。内容没变,ETag 就不变,缓存就不会误失效。
但 ETag 也有代价:它要计算(哈希内容),对服务器有 CPU 开销。所以实践中两者常一起用:Last-Modified 做粗筛(快),ETag 做精判(准)。
强弱校验器:ETag 的 W/ 前缀
墨叔:还有一个细节——ETag 分”强”和”弱”:
ETag: "abc123"= 强校验器,字节级完全一致才算一致。ETag: W/"abc123"= 弱校验器(Weak),语义上等价即可(比如把 JSON 里的空格删了,语义没变,弱校验器可以判为”没变”)。
你:为什么要”弱”?
墨叔:因为有些资源,服务器每次生成的表示字节都不一样,但语义相同。比如一个 HTML 页面,服务器每次生成时,时间戳、广告位、随机 ID 都不同,字节永远不一样。如果要求”字节级一致”,那缓存永远失效,永远 200 全量传输。弱校验器允许”内容等价即命中缓存”,省下大量传输。
8.5 启发式缓存:什么都没配,为什么还是被缓存了?
墨叔:现在回答引子里第一个问题的关键——你什么都没配,响应为什么还是被缓存了?
你:因为……默认行为?
墨叔:对,这个”默认行为”有个名字,叫启发式缓存(Heuristic Caching)。
当响应没有任何缓存头(没有 Cache-Control,没有 Expires)时,缓存不是”不缓存”,而是根据其它线索”猜”一个缓存时间:
经典的启发式规则:
(Date - Last-Modified) × 10%也就是”这个资源上次修改是多久前,就把它缓存’这个时长的 10%’”。
你:还能这么猜?!
墨叔:很离谱对吧。比如你的静态资源 Last-Modified 是 10 天前,那缓存就”猜”可以缓存 1 天。这就是为什么你什么都没配,浏览器还是”固执地”缓存了你的资源。
你:那怎么彻底禁掉这个”猜”?
墨叔:两个办法,都要显式:
Cache-Control: no-store # 完全不缓存
# 或者,允许缓存但强制每次校验:
Cache-Control: no-cache # 每次用之前问服务器关键是”显式”。只要你什么都不写,启发式缓存就可能替你做决定,而它的决定往往不是你想要的。
8.6 Vary:缓存的”维度键”,以及缓存污染的真凶
墨叔:现在讲缓存污染的正主——Vary。
你:Vary 是干嘛的?
墨叔:Vary 回答一个关键问题:“同一个 URL,是不是在所有情况下都返回同样的内容?”
大多数情况不是。同一个 URL /user-info,登录用户看到的是自己的信息,未登录用户看到的是”请登录”。同一个 URL,不同客户端,内容不同。
Vary 就是用来声明:“我的内容会随着 XX 头而变化,你缓存时要按 XX 头分开存。”
HTTP/1.1 200 OK
Vary: Accept-Encoding意思是:这个资源,gzip 压缩版和 br 压缩版是不同的内容,缓存时要把”压缩编码”作为缓存键的一部分,分开存。
你:那缓存污染跟 Vary 什么关系?
墨叔:关系大了。看这个事故:
- 你的接口
/api/user,对登录用户返回”用户信息”,对未登录返回”请登录”。 - 但你没有加
Vary: Cookie(或Vary: Authorization)。 - CDN 缓存时,只按 URL 作为键(它不知道内容还跟 Cookie 有关)。
- 于是:用户 A(登录)请求
/api/user,CDN 缓存了 A 的信息;用户 B(登录)请求同一个 URL,CDN 命中缓存,把 A 的信息返回给了 B。
这就是”缓存污染”的完整机制:
缓存的默认键是”URL”,如果内容还取决于 URL 之外的东西(Cookie、Accept、User-Agent),而你不用
Vary声明,缓存就会把”不同内容的响应”当成”同一个响应”,张冠李戴。
你:所以 Vary: Cookie 是标配?
墨叔:对,但注意一个陷阱——Vary 声明太多,缓存命中率会暴跌。
比如 Vary: User-Agent:因为每个浏览器的 UA 都不同,等于”每个 UA 一个缓存副本”,命中率趋近于零。曾经有团队为了”给 IE 和 Chrome 返回不同内容”而加 Vary: User-Agent,结果 CDN 命中率从 95% 掉到 5%。
所以 Vary 是一把双刃剑:
不加
Vary,会缓存污染(张冠李戴);加太多Vary,会缓存失效(命中率暴跌)。 你需要精确地知道”我的内容到底依赖哪些维度”,只声明那些真正影响内容的维度。
8.7 设计者视角:HTTP 为什么没有”主动清除缓存”的机制?
墨叔:现在讲一个能体现 HTTP 缓存”设计权衡”的深刻问题。
你:如果我改了资源,想立刻让全世界清掉旧缓存,怎么办?HTTP 有”主动清除缓存”的指令吗?
墨叔:没有。 这是 HTTP 缓存的一个重大”缺失”,而且它是故意的、不得不的。
你:故意的?为什么?
墨叔:因为”主动清除缓存”这件事,违背了分层约束和可扩展性。
你想:如果要”主动清除”,服务器就得知道”全世界有哪些缓存节点,它们各自缓存了哪些资源”。这要求:
- 服务器跟踪所有中间设备——这在”分层”架构里根本做不到(服务器不知道前面有多少个 CDN、代理)。
- 向所有中间设备广播”清除指令”——需要一套”清除协议”,而中间设备五花八门,没法统一。
所以 HTTP 选择了”被动失效”:不是”主动清除”,而是”让缓存自然过期,或让新资源用新 URL”。
你:所以业界是怎么绕过的?
墨叔:三个套路,你现在天天在用但可能没意识到:
套路一:URL 版本化(指纹)。
把资源的哈希打进文件名:app.8f14e45f.js。内容一变,哈希一变,URL 一变——新 URL 根本没有旧缓存,天然”失效”。这就是现代前端构建工具(webpack/vite)默认做的事。
套路二:CDN 厂商的 Purge API。 CDN 厂商(阿里云、Cloudflare)提供”主动清除”的 API,让你清掉某个 URL 的缓存。但注意——这是 CDN 厂商的私有能力,不是 HTTP 协议的。它只在”你用了那家 CDN”时有效。
套路三:Surrogate-Key(代用键)。 给一组资源打一个标签,清除时按标签批量清。同样是 CDN 私有能力。
你:所以”主动清除”本质上是”在 HTTP 协议之外,靠 CDN 厂商的私有 API 补上的”?
墨叔:对。这个”缺失”恰恰揭示了协议设计的边界:
HTTP 的缓存模型,是在”分层、不可知、无全局状态”的约束下设计的。 它选择了”被动失效”(靠过期时间和 URL 版本化),放弃了”主动清除”(需要全局状态和协调)。而现实世界对”主动清除”的需求,是靠厂商私有能力补上的,代价是锁定了厂商。
这是一个绝佳的例子:协议设计,很多时候不是”做不到”,而是”做了会违背更根本的约束”,于是留白,把需求下放给生态去补。
8.8 Go 落地:一个正确的缓存中间件
墨叔:用 Go 写一个带 ETag 协商缓存的中间件,体会一下缓存怎么落地:
func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 静态资源:强制缓存 + 指纹
if strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
// 2. 个性化数据:私有缓存,禁共享
if strings.HasPrefix(r.URL.Path, "/api/user") {
w.Header().Set("Cache-Control", "private, max-age=60")
w.Header().Set("Vary", "Authorization, Cookie")
}
next.ServeHTTP(w, r)
})
}
// ETag 协商:根据内容哈希生成 ETag
func ETagMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 用一个 recorder 捕获响应体,算出哈希
rec := newRecorder(w)
next.ServeHTTP(rec, r)
etag := fmt.Sprintf("\"%x\"", sha256.Sum256(rec.body))
w.Header().Set("ETag", etag)
// 客户端带了 If-None-Match,且匹配 → 304
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Write(rec.body)
})
}你:这个 Vary: Authorization, Cookie 就是防止缓存污染的关键。
墨叔:对。记住三件套:
静态资源:
public, max-age=31536000, immutable(一年,永不改)。 个性化数据:private+Vary正确的维度。 敏感数据:no-store(比如支付、账户信息)。
8.9 排查清单:缓存问题的定位
第 1 步:是"没缓存"还是"缓存了没失效"?
→ 响应头有没有 Cache-Control / Expires?
→ 没有 → 可能是启发式缓存在作怪
第 2 步:是私有还是共享缓存在作怪?
→ 浏览器缓存问题 → 强刷/无痕窗口验证
→ CDN 缓存问题 → 看 CDN 的 Age 头、看是否回源
第 3 步:no-cache 和 no-store 用对了吗?
→ 想"不缓存"却写了 no-cache = 还是会缓存
第 4 步:Vary 声明对了吗?
→ 缓存污染(看到别人的数据)= 缺 Vary 或 Vary 维度不对
第 5 步:URL 版本化了吗?
→ 带指纹的资源,天然不会"改了没失效"
思考题
第 1 题(概念)
no-cache 和 no-store 的区别是什么?请分别说出它们对应的真实场景(各举一例)。
第 2 题(概念)
Last-Modified 和 ETag 都是协商缓存的校验器。请说出 Last-Modified 的两个缺陷,以及为什么 ETag 能解决它们。并说明”弱 ETag(W/)“存在的意义。
第 3 题(设计)
你要设计一个”文章详情页”接口,要求:
- 文章内容更新后,用户能尽快看到新版
- 但文章内容不变时,用户访问要极快(走缓存)
- 登录用户和未登录用户看到的页面不同(登录用户能看到”我的收藏”按钮)
- 绝不能出现”用户 A 看到用户 B 的收藏状态”
请给出完整的缓存策略:Cache-Control、ETag/Last-Modified、Vary 该怎么配,并解释每个决策为什么。
第 4 题(生活类比)
墨叔:餐厅的”菜品图册”和 HTTP 缓存,有什么相似之处?
提示:想象——
- 图册上印了”本图册 2026 年 6 月印制”(类似 Expires)
- 你点菜前问服务员”今天的菜和图册一样吗?“(类似协商缓存)
- 服务员说”一样”(类似 304)
- 服务员说”今天的例汤换了”(类似 200 + 新内容)
请映射,并回答:为什么”图册印了有效期”还不够,还要”点菜前问一句”?
第 5 题(更深一层的发问)
8.7 节讲了:HTTP 没有”主动清除缓存”的机制,因为”主动清除”需要全局状态和协调,违背了分层约束。
现在反过来想:
- 如果让你强行在 HTTP 里加一个”主动清除”的机制(比如一个新的
PURGE方法),它会遇到哪些不可逾越的困难? - 为什么说”URL 版本化”是比”主动清除”更优雅的解决方案?它的代价是什么?
- 这个问题揭示了软件设计里的一个什么通用原则?(提示:想想”失效 vs 变更”、“命名 vs 命令”这些对立)
参考答案
第 1 题答案
区别:
| 指令 | 含义 | 一句话 |
|---|---|---|
no-cache | 能缓存,但每次使用前必须回源校验(协商缓存) | “缓存可以,用之前先问” |
no-store | 任何环节都不许缓存 | ”压根别存” |
真实场景:
no-cache适用:内容会变、但变化有规律、且希望省流量的资源。比如一个”今日头条”列表——它可以缓存,但每次显示前要问服务器”有没有新头条”,有就更新,没有就 304 直接用缓存。这样既保证”新鲜”,又省了”内容没变时的全量传输”。no-store适用:涉及隐私或安全、绝不能留痕的数据。比如银行卡号、身份证号、个人健康记录、支付回调结果。这些数据一旦被任何缓存(浏览器、CDN、代理)存下来,就可能在你不受控的地方泄露(浏览器缓存文件、CDN 日志、代理磁盘)。
第 2 题答案
Last-Modified 的两个缺陷:
- 精度只有 1 秒:HTTP 的
Last-Modified时间戳精度是秒。如果资源在 1 秒内被修改了两次,时间戳可能一样,导致”改了但被判成没改”,缓存不失效。 - 它标记”时间”,不标记”内容”:
Last-Modified变了,只代表”文件被碰过”(比如 touch 一下、重新上传了相同内容),不代表”内容真的变了”。这会导致假失效(内容没变,缓存却被废弃,白回源一次)。
ETag 为什么能解决:ETag 是内容指纹(通常是对内容算哈希)。内容变,ETag 必变;内容不变,ETag 必不变。所以它精确对应”内容是否变化”,而不是”时间是否变化”。
弱 ETag(W/)的意义:
有些资源每次生成的字节都不同,但语义等价。比如一个动态生成的 HTML:每次生成时,里面嵌的时间戳、随机广告、调试信息都不同,导致字节永远不一样。如果强制”字节级一致”(强 ETag),这个资源就永远无法命中缓存(每次都 200 全量)。
弱校验器 W/"..." 允许”语义等价即可命中”——服务器自己判断”这次生成的和缓存里的,语义上是不是同一个东西”,是的话就返回 304。它用”一点语义判断的代价”,换来了”这类动态资源的缓存能力”。
第 3 题答案
Cache-Control: private, no-cache
Vary: Authorization, Cookie
ETag: "文章内容的哈希"
逐条解释:
1. private:文章详情页带”我的收藏”状态,是个性化内容,绝不能进 CDN 共享缓存(否则用户 A 的收藏状态会被缓存下来,给用户 B 看)。private 限定只能存浏览器私有缓存。
2. no-cache(而不是 max-age):要求”更新后尽快看到新版”。如果写 max-age=3600,那么文章更新后,用户可能在 1 小时内都看到旧版。no-cache 保证”每次都用之前先问服务器”——服务器说”没变”(304),走缓存,快;服务器说”变了”(200),拿新版。
3. Vary: Authorization, Cookie:这是防污染的关键。因为内容随登录状态变化(登录用户多一个”收藏”按钮),而缓存的默认键只是 URL。Vary 声明”内容还依赖 Authorization/Cookie”,缓存就会按”URL + 登录态”分开存,绝不会把 A 的收藏状态给 B。
4. ETag:配合 no-cache,实现”没变就 304 不重传,变了就 200 全量”。文章内容哈希做 ETag,内容一变,ETag 就变,协商自然失效。
完整流程:
- 用户第 1 次访问:
200+ 文章内容 +ETag: "hash1"+Cache-Control: private, no-cache。 - 用户第 2 次访问(文章没变):请求带
If-None-Match: "hash1",服务器比对 →304,浏览器直接用缓存,快。 - 文章更新了:ETag 变成
"hash2",比对不匹配 →200+ 新内容,新。 - 用户 B(另一个登录态):因为
Vary按登录态分桶,绝不会拿到 A 的缓存。
第 4 题答案
| 餐厅 | HTTP |
|---|---|
| 图册印”2026 年 6 月印制” | Expires / Cache-Control: max-age |
| 点菜前问”和今天的一样吗?“ | 协商缓存(If-None-Match / If-Modified-Since) |
| 服务员说”一样” | 304 Not Modified |
| 服务员说”例汤换了” | 200 OK + 新内容 |
为什么”印了有效期”还不够,还要”点菜前问一句”?
因为”有效期”是一个盲目的、一刀切的时间承诺,而现实是内容的变化是不确定的、突发的:
- 图册印着”6 月印制”,但厨师今天临时换了例汤——有效期还没到,内容已经变了。如果顾客只信”有效期”,就会点到”已经不存在的菜”。
- 反过来,如果图册过期了,但今天的菜其实和图册一模一样——如果顾客”过期就重新看一遍全部菜单”,就白费了时间(对应”过期就全量重新下载”)。
所以聪明的做法是:“有效期”做粗筛(没过期就先信),“点菜前问一句”做兜底(拿不准就问)。这正是 HTTP 缓存的完整模型:
强制缓存(max-age)= “有效期内的盲信”,解决”大部分情况”的效率;协商缓存(304)= “拿不准时的确认”,解决”变化”的准确性。
两者配合,才能既快又准。只有强制缓存,会”改了看不到”;只有协商缓存,会”每次都问,太慢”。
第 5 题答案
1. “主动清除”会遇到什么不可逾越的困难?
- 不知道”谁在缓存”:分层架构下,源站完全不知道前面有几层、多少个缓存节点(浏览器、公司代理、ISP 缓存、CDN 多级节点)。你无法枚举出”清除指令要发给谁”。
- 无法保证”清除到位”:即使你向一个 CDN 发了 PURGE,CDN 下面还有更边缘的节点、还有用户的浏览器缓存,它们可能各自还存着旧内容。
- 时序问题:清除指令和请求是并发的——你发 PURGE 的同时,可能正有请求在回源”重新缓存”旧内容。清除和新缓存竞速,结果不确定。
- 安全与权限:谁有权清除?如果任何人都能 PURGE 掉别人的缓存,那 CDN 就成了攻击面。
2. 为什么”URL 版本化”更优雅?代价是什么?
“主动清除”是**“命令式”的(去告诉缓存”删掉 XX”),而”URL 版本化”是”声明式”**的(新内容 = 新名字 = 天然无旧缓存)。
- 优雅在:它把”失效”问题,转化成了”命名”问题。你不需要通知任何人”清除”,你只需要”换个名字”。旧缓存留着就留着(指向旧名字),新名字自动指向新内容,互不干扰。
- 代价是:①需要引用处同步更新——HTML 里引用
app.js的地方,要改成引用app.8f14e45f.js,这需要一个”构建系统”来自动改(webpack/vite 干的活);②旧文件不能立刻删,因为可能还有客户端正在用旧 HTML 引用旧文件(要保留一段时间)。
3. 揭示了什么通用原则?
揭示了软件设计里一个非常深刻的原则:
“失效(invalidation)“是计算机科学里最难的难题之一,而”变更(change)“比”失效”容易得多。 一个设计,如果能用”变更新名字”来规避”失效旧缓存”,就比”主动去清除”优雅得多。
这背后的对立是:
- 命令式 vs 声明式:命令式(清除)要求你掌握”全局状态、去指挥每个节点”;声明式(改名)只要求你”描述新的状态”,系统自己收敛。
- 状态 vs 不可变:可变的缓存需要”失效管理”;不可变的(带指纹的)资源根本不需要失效——它永远不会变,也就不存在”过期”问题。
这和第 6 章第 5 题的结论(“接口版本化 = 把会变的资源冻结成不可变快照”)是同一个思想:用”不可变”来消灭”失效”这个难题。函数式编程里的不可变数据结构、事件溯源里的 append-only 日志、HTTP 里的 URL 版本化——都是同一个智慧的在不同领域的投影:能不变,就别让它变,因为”变”会带来一整套”同步和失效”的麻烦。
一页纸小结
缓存是分层约束的必然推论:中间设备有权缓存响应,这是 CDN 存在的基础。
私有 vs 共享缓存:私有只服务单用户(浏览器),共享服务所有人(CDN)。private 限定不共享,是防”缓存污染”(用户 A 看到 B 的数据)的关键。
强制缓存:Cache-Control。no-cache ≠ 不缓存(是”用前先问”);no-store = 真不缓存。
协商缓存:Last-Modified(有精度和”时间≠内容”缺陷)→ ETag(内容指纹)。304 = “没变,别重传”。
启发式缓存:什么都没配时,缓存会”猜”((Date-Last-Modified)×10%),这就是”我没配缓存却被缓存”的原因。
Vary:声明”内容还依赖哪些维度”,缺了会污染,多了会命中率暴跌。
HTTP 没有主动清除:违背分层约束。业界靠 URL 版本化(指纹)+ CDN 私有 Purge API 绕过。
核心思想:能不变(URL 指纹),就别让它变——用”不可变”消灭”失效”这个难题。
下一章:缓存讲完了,但有个问题我一直憋着没说——HTTP 不是”无状态”的吗?那”登录状态""购物车”这些”有状态”的东西,是怎么在”无状态”的协议上活下来的? 答案是一个用了 30 年、至今还在给全世界制造安全事故的”临时方案”——Cookie。我要问你:为什么 HttpOnly 的 Cookie,前端 JavaScript 就是读不到?这不是 bug,这是特性。