Skip to content

필사 모드: 缓存失效全解析

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 这为何成了一个笑话

计算机科学界有一句流传甚广的话,据说出自菲尔·卡尔顿(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-ControlETag 这类 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 源头。当你把这两面一起理解透彻时,缓存就不再是麻烦,而会成为一件值得信赖的工具。

参考资料

현재 단락 (1/132)

计算机科学界有一句流传甚广的话,据说出自菲尔·卡尔顿(Phil Karlton)之口。

작성 글자: 0원문 글자: 7,302작성 단락: 0/132