- 引言 — 这为何成了一个笑话
- 缓存做什么,以及根本问题所在
- TTL — 最简单的失效方式
- 写入策略 — write-through、write-behind、cache-aside
- 缓存踩踏 — Dogpile 问题
- 失效 — 显式地丢弃旧值
- 失效真正难在哪里
- 缓存的层级 — 从 CPU 到浏览器
- HTTP 缓存 — 网页世界的标准失效方式
- 实务指南小结
- 结语
- 参考资料
引言 — 这为何成了一个笑话
计算机科学界有一句流传甚广的话,据说出自菲尔·卡尔顿(Phil Karlton)之口。
"计算机科学里只有两件难事:缓存失效,以及命名。"
这句话之所以历久弥新,是因为缓存失效真的很难。造一个缓存本身很容易 — 把值存在某处,下次拿来复用就行。真正的难点在于准确知道那个存下来的值何时变旧了,因而何时该丢弃。如果源数据变了而缓存还攥着旧值,用户看到的就是错误信息。可如果每次都去核对源头,那存缓存就没有意义了。
本文正面处理这个难题。我们会讲清楚缓存是为了什么、有哪些写入策略、缓存踩踏这个陷阱以及如何缓解、失效具体怎么实现,以及从 CPU 到浏览器贯穿始终的缓存层级。
缓存做什么,以及根本问题所在
缓存的目的很简单:把昂贵计算或慢查询的结果存下来复用,以此节省时间。数据库查询、远程 API 调用、繁重的渲染 — 把这些的结果存在近处,下次就能瞬间给出答案。
缓存之所以有效,靠的是两条性质。
- 时间局部性(temporal locality):刚被用过的数据,很可能不久后又会被用到。
- 空间局部性(spatial locality):某数据被用到时,它附近的数据也很可能不久会被用到。
问题来自于缓存是源数据的一份副本这一事实。副本一旦源数据发生变化就会变旧。处理这种陈旧,是缓存一切难题的根源。这里要区分两种糟糕的情形。
- 陈旧(stale)数据:缓存持有的值比源数据旧。用户看到错误的值。
- 缓存未命中(miss):缓存里没有值,只能跑一趟源头。速度慢。
缓存策略归根到底是在这两者之间走钢丝。为了减少陈旧而频繁丢弃,未命中就会增多;为了减少未命中而长期保留,陈旧就会增多。没有"正确答案",只有契合数据本身特性的平衡点。
TTL — 最简单的失效方式
最广泛使用的失效方法是 TTL(Time To Live,存活时间)。给每个缓存条目附上一个寿命:"这个值只在 N 秒内有效"。时间一到,条目就过期,下一次请求会重新从源头取值。
TTL 的魅力在于简单和可预测。你不需要另外写失效逻辑 — 时间一到就自动被丢弃。哪怕在最坏情况下,陈旧程度也不会超过 TTL 时长。
但 TTL 本质上是陈旧度与负载之间的权衡。
- 长 TTL:命中率高,快且对源头负载小。但源数据变化后,最长会有 TTL 那么久展示旧值。
- 短 TTL:新鲜度好。但过期频繁,导致源头查询增多、响应变慢。
所以 TTL 适合"稍微旧一点也无妨"的数据 — 新闻列表、热门帖子、汇率这类能容忍几秒到几分钟延迟的数据。反过来,"绝不能陈旧"的数据(比如账户余额)光靠 TTL 是不够的,需要后面要讲的显式失效。
写入策略 — write-through、write-behind、cache-aside
根据缓存与源数据如何一起更新,存在几种典型模式。区分清楚这些模式,能让设计思路清晰很多。
Cache-aside(旁路缓存,又称惰性加载 lazy loading)。 最常见的模式。应用程序直接管理缓存。
读取:
1. 查看缓存
2. 如果存在(hit),返回该值
3. 如果不存在(miss),从源头读取,写入缓存后返回
写入:
1. 更新源头
2. 使缓存中对应条目失效(删除)
核心在于缓存"只在需要时"被填充。没被读取过的数据永远不会进入缓存。写入时通常不更新缓存,而只是删除它,让下一次读取自行用新值重新填充。这种方式简单、稳健,但每次未命中都要承受一次源头往返的延迟。
Write-through(直写)。 一次写入会同时更新缓存和源头。应用程序写入缓存,缓存随即也写入源头。
写入:
应用程序 --> 写入缓存 --> (立即)也写入源头
两者都成功才算完成
优点是缓存始终新鲜。读取总是直接从缓存处理,速度快。缺点是写入变慢,因为每次写入都要等待源头写入完成。此外,可能会把一段时间内不会被读取的数据也填进缓存,浪费空间。
Write-behind(write-back,延迟写入)。 写入先只记录到缓存,反映到源头的动作稍后异步进行。
写入:
应用程序 --> 写入缓存 (立即返回完成响应)
|
v (稍后,批量地)
反映到源头
优点是写入速度极快,还能把多次写入合并成一次反映到源头,从而降低负载。缺点则很危险:如果在已写入缓存但尚未反映到源头的这段间隙里缓存挂掉,数据就会丢失。因此 write-behind 只在能够承受丢失、或具备另外的持久化机制时使用。
三种策略一览对比如下。
| 策略 | 写入速度 | 读取新鲜度 | 丢失风险 | 典型场景 |
|---|---|---|---|---|
| cache-aside | 一般 | 未命中之后是新的 | 低 | 通用场景,读多写少 |
| write-through | 慢 | 始终新鲜 | 低 | 新鲜度至关重要 |
| write-behind | 非常快 | 始终新鲜(以缓存为准) | 高 | 写入激增,可容忍丢失 |
缓存踩踏 — Dogpile 问题
接下来处理缓存中最臭名昭著的陷阱。缓存踩踏(cache stampede),又称 dogpile 问题,或称缓存拥挤(cache rush)。
情形是这样的。设想一个热门缓存条目过期的瞬间。这个条目每秒被读取数千次。就在它过期的那一刻,数千个请求同时遭遇缓存未命中。而它们全都在同一时刻涌向源头(比如数据库),想要重新计算同一个值。原本一次就能搞定的源头查询,骤然爆炸成数千次,源头被这股负载压垮。最坏情况下源头会崩溃,进而引发更多未命中,连锁反应之下整个系统随之瓦解。
正常情况: 请求由缓存处理
请求 请求 请求 --> [缓存 hit] --> 快速响应
踩踏情况: 热门键过期的瞬间
请求 请求 请求 请求 ... (数千个)
全部 miss
|
v (同时涌入)
[源头数据库] <-- 被数千个相同的重复计算请求压垮
核心是一个悖论:"越热门的条目越危险"。因为一个值被读取得越频繁,它过期瞬间同时发生的未命中规模就越大。缓解这一问题的方法有几种。
1. 锁 / 互斥量。 出现未命中时,只让第一个请求去查询源头,并加锁。其余请求要么短暂等待那一个请求把值填好,要么暂时拿到旧值。这样一来源头查询就减少到一次。缺点是会产生等待,实现也稍显复杂。
2. 给 TTL 加入抖动(jitter)。 如果许多条目在完全相同的瞬间过期,踩踏就会放大。所以给 TTL 加上一点随机值,把过期时刻打散。举例来说,与其把 TTL 精确设为 300 秒,不如把它随机化到 270~330 秒之间,过期就会分散在时间轴上,同时发生的未命中随之减少。
import random
def ttl_with_jitter(base=300, spread=30):
# 打散过期时刻,避免同时过期
return base + random.randint(-spread, spread)
3. Stale-while-revalidate(重新验证期间先提供旧值)。 这是尤其优雅的一种方法。条目过期后,不立即丢弃旧值,而是"先把旧值原样返回,同时在后台悄悄取回新值并更新"。用户无需等待,立即拿到一个(可能略旧的)值,而更新只在后台发生一次。HTTP 缓存控制中的 stale-while-revalidate 指令,正是把这个概念标准化的结果。
条目过期
|
v
请求到达 --> 立即返回旧值 (用户不需要等待)
|
+--> 后台获取新值并更新缓存 (只发生一次)
4. 提前重算(early recomputation)。 还有一种技巧,是在条目即将过期前,以一定概率提前重新计算该值。越接近过期时刻,重算的概率越高,使得真正过期的那一刻新值已经准备就绪。这被称为概率性提前过期(probabilistic early expiration)。
实践中通常会把这些手段组合起来使用。比如用抖动打散过期时刻、用 stale-while-revalidate 消除等待、再用锁把更新合并为一次。
失效 — 显式地丢弃旧值
TTL 是一种被动式失效:"时间一到就丢弃"。但有些数据必须在源头一变化就立即丢弃缓存。这就是显式失效(invalidation),也是真正困难的部分。这里有两种代表性策略。
基于事件的失效(event-based)。 源数据变化时触发一个事件,立即删除(或更新)相关的缓存条目。比如,当"商品 42 的价格已变更"这个事件触发时,就使所有包含商品 42 的缓存条目失效。
这种方式的优势在于新鲜度。源头一变,缓存几乎立刻就会被清理干净。难点在于依赖追踪。"商品 42"可能同时存在于商品详情缓存、分类列表缓存、搜索结果缓存中。你必须准确知道哪些缓存条目依赖这份数据,才能做到无遗漏地全部失效。一旦这张依赖图管理失当,部分缓存就会悄悄停留在陈旧状态,变成一个 bug。
版本键 / 键版本化(versioned keys)。 一种非常实用且稳健的技巧。把版本号嵌入缓存键本身,把失效处理成"移到新键"而不是"删除"。
在缓存键中包含版本号:
user:42:v7:profile
当用户 42 的数据发生变化 --> 把版本号升级为 v8
此后代码查询的是 user:42:v8:profile
-> 不再有人查询 v7 这个键,它自然被丢弃(由 TTL 清理)
这种方式的美妙之处在于你不必亲自动手删除。只需把版本号往上加,旧版本的键就不再被任何人查询,最终会被 TTL 自然清理掉。它对竞态条件(失效与重新查询相互交错的问题)也很有韧性,因为旧键和新键在物理上是不同的键。一种常见应用是给某个实体或整个数据集维护一个"版本计数器",再把它附加到缓存键上。
这两种策略并不互斥。在需要细粒度即时失效的地方用基于事件的方式,在需要一次性清空大范围的地方用版本键,两者经常一起使用。
失效真正难在哪里
在这里我们停下来,梳理一下为什么这一切如此困难。缓存失效之所以难,并不仅仅是代码复杂,而是因为几个根本性的张力。
第一,分布式的问题。缓存通常散布在多台服务器、多个层级上。即便在一处完成了失效,其他缓存里仍可能残留旧值。要把所有缓存同时、原子性地全部失效,直接继承了分布式系统里那些困难问题(共识、顺序)。
第二,竞态条件(race condition)。如果"使缓存失效的那一刻"与"另一个请求重新读取旧值并把它写回缓存的那一刻"相互交错,刚刚被删除的旧值就会在缓存中复活。失效的顺序与重新查询的顺序微妙地纠缠在一起,制造出这类 bug。
第三,依赖关系的复杂性。正如前面所见,当一份源数据分散在多个缓存条目中时,很难完全掌握到底该失效哪些。漏掉一个依赖关系,就会变成一个陈旧 bug。
正因为这三者叠加在一起,它才难到足以和"命名"并列成为笑话的素材。完美的失效常常是不可企及的,所以实务中往往走向一种务实的折中:确定"能容忍多久的陈旧",据此把 TTL 与显式失效混合使用。
缓存的层级 — 从 CPU 到浏览器
缓存并不只存在于一处。整个计算技术栈都是由一层层缓存构成的。一次数据请求从浏览器出发,一路深入服务器内部的过程中,会经过好几层缓存。看清这整幅图景很重要。
最近、最快 (小而短命)
┌─────────────────────────────┐
│ CPU 缓存 (L1/L2/L3) │ 纳秒级,由硬件管理
├─────────────────────────────┤
│ OS 页面缓存 / 内存 │ 把磁盘读取结果保存在内存中
├─────────────────────────────┤
│ 应用内存缓存 │ 进程内部的本地缓存
├─────────────────────────────┤
│ 分布式缓存 (Redis/Memcached) │ 多台服务器共享
├─────────────────────────────┤
│ 数据库缓存 │ 查询/缓冲池缓存
├─────────────────────────────┤
│ CDN 边缘缓存 │ 内容存放在离用户近的地方
├─────────────────────────────┤
│ 浏览器缓存 │ 保存在用户设备上
└─────────────────────────────┘
最远,但离用户最近
各层的特点如下。
- CPU 缓存(L1/L2/L3):由硬件自动管理的最快缓存。失效也由硬件的缓存一致性协议来处理。我们不会直接触碰它,但它正是那些考虑了数据局部性的代码之所以快的原因。
- OS 页面缓存:操作系统把从磁盘读取的数据块保存在内存中,因此第二次读取同一文件时会快得多。
- 应用内存缓存:进程内部的本地缓存(例如本地哈希表、LRU 缓存)。速度最快,但每台服务器各自独立,一致性维护困难。
- 分布式缓存(Redis、Memcached):多台服务器共享的缓存层。前面讨论的那些策略大多发生在这一层。
- 数据库缓存:数据库自身的缓冲池与查询缓存。
- CDN 边缘缓存:把静态资源(以及日渐增多的动态内容)保存在离用户近的边缘节点。这里的失效(清除,purge)必须传播到全球各地的边缘节点,本身就是一个难题。
- 浏览器缓存:保存在用户设备上,由
Cache-Control、ETag这类 HTTP 头控制。
这种分层结构让缓存失效变得更加困难。哪怕只改动一处源头,该值的副本也可能散落在所有这些层级中。"为什么我看到的还是旧数据?"这个问题的答案,通常是"某一层的缓存还没更新"。所以排查时总要不断缩小范围,找出"陈旧究竟发生在哪一层"。
HTTP 缓存 — 网页世界的标准失效方式
网页层的缓存由 HTTP 头精细地控制。这是驾驭浏览器缓存与 CDN 缓存的标准语言。
Cache-Control:控制缓存行为的核心指令。它指定max-age(缓存多少秒)、no-cache(缓存但使用前需重新验证)、no-store(完全禁止缓存)、private/public(谁可以缓存)等。ETag与条件请求:在响应中附上一个类似内容指纹的ETag。下次请求时浏览器把这个值以If-None-Match发送,当内容未变化时,服务器只返回不带正文的304 Not Modified,能大幅节省传输量。stale-while-revalidate:就是前面讲过的那个概念。它允许立即提供已过期的响应,同时在后台重新验证。
HTTP 缓存的强大之处在于这些指令的组合使用。比如,很少变动的静态资源(JS、CSS)会把一个非常长的 max-age 与包含内容哈希的文件名组合起来使用。这样一来,文件内容一旦改变,文件名(也就是 URL)也随之改变,实际上就等于把前面讲过的版本键策略应用到了网页上。旧的 URL 不再被任何人请求,自然而然地失效。
实务指南小结
把前面的内容从实务角度压缩一下。
首先要确定这份数据能容忍多久的陈旧。这是一切决策的起点。如果旧个几分钟也无妨,TTL 就够用了。如果绝不能陈旧,就需要显式失效。不回答这个问题就去设计缓存,日后必然会以陈旧 bug 或过载的形式反噬回来。
其次,要为热门条目的踩踏做好准备。 一个键被读取得越频繁,它过期的瞬间就越危险。给 TTL 加入抖动,用 stale-while-revalidate 消除等待,必要时用锁把更新合并为一次。
如果需要失效,优先考虑版本键。 它比直接删除更稳健,也更能抵抗竞态条件。只在真正需要细粒度即时失效的地方,再加上基于事件的方式。
而且别忘了缓存存在于多个层级中。 浏览器、CDN、应用、分布式缓存、数据库。设计失效方案时,要明确它需要传播到哪一层;排查问题时,要缩小范围找出陈旧发生在哪一层。
最后,要具备可观测性。 如果不测量缓存命中率、未命中率、陈旧比例,你就无法判断缓存运转良好,还是正在悄悄积累问题。
结语
缓存失效之所以能和命名并列成为笑话的素材,是有原因的。造一个缓存很容易,但准确知道那份副本何时变旧了,却同时触及了分布式、竞态条件、依赖关系这三个根本性的难题。完美的失效常常是可望而不可即的。
所以现实中的解法不是魔法,而是务实的折中。确定"这份数据能容忍多旧",据此把 TTL 与显式失效混合使用。对热门条目,用抖动和 stale-while-revalidate 阻止踩踏;大范围失效用版本键,细粒度失效用事件。并且要时刻意识到,这一切都发生在从 CPU 到浏览器这一整套缓存层级之上。
缓存既是性能上最强大的杠杆,也是最微妙的 bug 源头。当你把这两面一起理解透彻时,缓存就不再是麻烦,而会成为一件值得信赖的工具。
参考资料
- "There are only two hard things in Computer Science" (Phil Karlton, TwoHardThings): https://www.martinfowler.com/bliki/TwoHardThings.html
- MDN, HTTP caching: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
- RFC 5861 (HTTP Cache-Control Extensions, stale-while-revalidate): https://www.rfc-editor.org/rfc/rfc5861
- "Cache stampede" (概念梳理): https://en.wikipedia.org/wiki/Cache_stampede
- AWS, "Caching best practices": https://aws.amazon.com/caching/best-practices/
- Redis, "Client-side caching": https://redis.io/docs/latest/develop/reference/client-side-caching/
현재 단락 (1/132)
计算机科学界有一句流传甚广的话,据说出自菲尔·卡尔顿(Phil Karlton)之口。