第 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 秒。如果资源在 1 秒内被改了两次,Last-Modified 分不出来。
  2. 它只代表”修改时间变了”,不代表”内容变了”。比如你 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 缓存的一个重大”缺失”,而且它是故意的、不得不的。

你:故意的?为什么?

墨叔:因为”主动清除缓存”这件事,违背了分层约束和可扩展性。

你想:如果要”主动清除”,服务器就得知道”全世界有哪些缓存节点,它们各自缓存了哪些资源”。这要求:

  1. 服务器跟踪所有中间设备——这在”分层”架构里根本做不到(服务器不知道前面有多少个 CDN、代理)。
  2. 向所有中间设备广播”清除指令”——需要一套”清除协议”,而中间设备五花八门,没法统一。

所以 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 题(设计)

你要设计一个”文章详情页”接口,要求:

  1. 文章内容更新后,用户能尽快看到新版
  2. 但文章内容不变时,用户访问要极快(走缓存)
  3. 登录用户和未登录用户看到的页面不同(登录用户能看到”我的收藏”按钮)
  4. 绝不能出现”用户 A 看到用户 B 的收藏状态”

请给出完整的缓存策略:Cache-Control、ETag/Last-Modified、Vary 该怎么配,并解释每个决策为什么。

第 4 题(生活类比)

墨叔:餐厅的”菜品图册”和 HTTP 缓存,有什么相似之处?

提示:想象——

  1. 图册上印了”本图册 2026 年 6 月印制”(类似 Expires)
  2. 你点菜前问服务员”今天的菜和图册一样吗?“(类似协商缓存)
  3. 服务员说”一样”(类似 304)
  4. 服务员说”今天的例汤换了”(类似 200 + 新内容)

请映射,并回答:为什么”图册印了有效期”还不够,还要”点菜前问一句”?

第 5 题(更深一层的发问)

8.7 节讲了:HTTP 没有”主动清除缓存”的机制,因为”主动清除”需要全局状态和协调,违背了分层约束。

现在反过来想:

  1. 如果让你强行在 HTTP 里加一个”主动清除”的机制(比如一个新的 PURGE 方法),它会遇到哪些不可逾越的困难?
  2. 为什么说”URL 版本化”是比”主动清除”更优雅的解决方案?它的代价是什么?
  3. 这个问题揭示了软件设计里的一个什么通用原则?(提示:想想”失效 vs 变更”、“命名 vs 命令”这些对立)

参考答案

第 1 题答案

区别:

指令含义一句话
no-cache能缓存,但每次使用前必须回源校验(协商缓存)“缓存可以,用之前先问”
no-store任何环节都不许缓存”压根别存”

真实场景:

  • no-cache 适用:内容会变、但变化有规律、且希望省流量的资源。比如一个”今日头条”列表——它可以缓存,但每次显示前要问服务器”有没有新头条”,有就更新,没有就 304 直接用缓存。这样既保证”新鲜”,又省了”内容没变时的全量传输”。
  • no-store 适用:涉及隐私或安全、绝不能留痕的数据。比如银行卡号、身份证号、个人健康记录、支付回调结果。这些数据一旦被任何缓存(浏览器、CDN、代理)存下来,就可能在你不受控的地方泄露(浏览器缓存文件、CDN 日志、代理磁盘)。

第 2 题答案

Last-Modified 的两个缺陷:

  1. 精度只有 1 秒:HTTP 的 Last-Modified 时间戳精度是秒。如果资源在 1 秒内被修改了两次,时间戳可能一样,导致”改了但被判成没改”,缓存不失效。
  2. 它标记”时间”,不标记”内容”: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,这是特性。