周末你想做番茄炒蛋,先打开冰箱:有鸡蛋就直接开工,没有就得跑一趟超市。这里的冰箱是缓存,超市是数据库。冰箱离你近、拿东西快,但空间有限;超市什么都有,可一来一回太费时间。高并发系统里的缓存,本质上就是这套“冰箱备菜术”。 但冰箱也有烦恼:菜会过期、放不下、可能和超市不同步。于是聪明的人想出了三种用法:自己动手的 Cache-Aside、会自己补货的 Read-Through、先记在购物清单上再统一采购的 Write-Behind。这篇文章就带你把这三件事彻底搞明白,顺便学会让缓存不“翻车”。

岗位关联

对 AI 从业者来说,缓存设计是推理服务、特征服务、数据回流中最常见的性能杠杆。搞懂三种模式,你就能在高并发场景下用最低成本扛住流量,而不是只会“加机器”。

目录
  1. 可视化演示
  2. 缓存基础:给冰箱定个位
  3. 旁路读取:自己动手的 Cache-Aside
  4. 穿透读取:会补货的智能冰箱
  5. 延迟写入:先写清单再购物
  6. 一致失效:别让缓存变馊
  7. 性能实战:把冰箱用到极致
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示Cache-Aside的读命中/未命中流程与写失效流程,以及旁路/通读/通写/后写四种模式对比,滑杆调节缓存命中率。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Cache-Aside读流程 缓存命中直接返回数据 未命中查库并回填缓存 内存加速降低数据库负载

缓存基础:给冰箱定个位

周末你想做番茄炒蛋,先打开冰箱:有鸡蛋就直接开工,没有就得跑一趟超市。这里的冰箱是缓存,超市是数据库。冰箱离你近、拿东西快,但空间有限;超市什么都有,可一来一回太费时间。高并发系统里的缓存,本质上就是这套“冰箱备菜术”。

数据库往往放在磁盘或远端,一次查询可能要几毫秒甚至几十毫秒;而 Redis 这类缓存放在内存里,一次读取只要亚毫秒。于是我们让热点数据住在“冰箱”里,把数据库从高频请求中解放出来。这里有几个关键概念:存储层级决定谁离 CPU 更近;命中代表冰箱里有菜;穿透则意味着冰箱没有,请求只能穿过缓存去数据库。

穿透过一次没什么,可怕的是大量请求同时发现“冰箱空空”,一起涌向超市,数据库会被瞬间打垮。因此,我们需要先记住一个最朴素的质量指标——缓存命中率。

缓存不是免费的:它占用内存、需要维护、还可能带来数据不一致。所以先把冰箱的定位想清楚,后面三种模式才有讨论的基础。

命中率=NhitNhit+Nmiss\displaystyle \text{命中率} = \frac{N_{\text{hit}}}{N_{\text{hit}} + N_{\text{miss}}}
命中率越高,说明越多的请求只访问了“冰箱”,没有惊动“超市”。
  1. 命中数 \(N_{\text{hit}}\)指直接从缓存拿到数据的请求次数,代表“冰箱里正好有菜”。
    NhitN_{\text{hit}}
  2. 未命中数 \(N_{\text{miss}}\)指缓存里没有数据、需要回源数据库的请求次数,代表“冰箱空了,得跑超市”。
    NmissN_{\text{miss}}
  3. 分母总请求数把命中和未命中加起来,就是所有请求的总数。
    Nhit+NmissN_{\text{hit}} + N_{\text{miss}}

旁路读取:自己动手的 Cache-Aside

Cache-Aside 是三种模式里最像“自己动手做饭”的一种。读请求来了,应用先打开冰箱看:有菜就直接用;没有菜,就去超市买,回到家顺手把一份放进冰箱。整个过程由应用自己编排,缓存和数据库都被它直接操作。

写请求的规则要更讲究:先更新超市货架(数据库),然后把冰箱里对应的旧标签撕掉(删除缓存)。为什么不直接更新缓存?因为并发场景下,你刚把新值写进缓存,另一个线程可能正拿着旧值准备回填,晚到的旧值会把新值覆盖掉,冰箱里就成了过期货。直接删除缓存,让下一次读请求从数据库拉新值,反而更安全。

这套流程有一个很直观的平均延迟公式:

Cache-Aside 逻辑透明、容易控制,是大多数读多写少业务的首选。它像开手动挡汽车:司机需要知道什么时候换挡,但正因为“人在回路”,出了问题时最好排查。

Tˉ=h×Tc+(1−h)×(Tc+Ts)\displaystyle \bar{T} = h \times T_c + (1-h) \times (T_c + T_s)
\(h\) 越高,平均延迟越接近缓存延迟;\(h\) 越低,平均延迟越会被数据库回源时间拖累。
  1. 命中概率 \(h\)可以理解为“打开冰箱就有菜”的比例,通常用历史命中率估算。
    hh
  2. 缓存读取时间 \(T_c\)读一次 Redis 或本地缓存的开销,相当于开冰箱门拿菜的时间。
    TcT_c
  3. 回源时间 \(T_s\)缓存未命中时,查数据库并把结果写回缓存的总耗时,等于去超市买菜再放回冰箱。
    Tc+TsT_c + T_s

穿透读取:会补货的智能冰箱

Read-Through 像是换了一台会自己补货的“智能冰箱”。应用只需要对冰箱说“我要鸡蛋”,冰箱发现没有,就会自己打电话给超市,把货运回来再交给你。应用不再关心数据库在哪里,也不用写“查库再回填”这套样板代码。

从架构上看,Read-Through 把缓存从“被动存储”变成了“主动代理”。缓存组件内部定义了自动回源逻辑,应用只面向缓存读写。这样做的最大好处是职责收口:所有缓存的加载策略、过期策略、回填逻辑都集中在一个地方,不会散落在几十个调用方里。

它的读取耗时可以写成:

Read-Through 特别适合团队希望“让数据库对上层透明”的场景。只要缓存模式定下来,调用方永远只问冰箱,省心省力。

Tread=Tc+(1−ph)×Tload\displaystyle T_{\text{read}} = T_c + (1-p_h) \times T_{\text{load}}
只有未命中时,才需要额外支付一次“智能冰箱替你去超市采购”的时间。
  1. 缓存读取时间 \(T_c\)应用向缓存发起请求的基础耗时,等同于对冰箱说话。
    TcT_c
  2. 未命中概率 \(1-p_h\)冰箱里没有菜的概率,值越大,越依赖补货流程。
    1−ph1-p_h
  3. 加载耗时 \(T_{\text{load}}\)缓存组件回源数据库并写回缓存的总时间,代表“采购 + 入库”。
    TloadT_{\text{load}}

延迟写入:先写清单再购物

Write-Behind 的思路更像“把想买的东西先记在购物清单上,周末再去超市一次性采购”。应用收到写请求后,不立刻写数据库,而是先更新缓存,再往待写清单里记一笔;后台服务按照批次把清单统一写回数据库。

这样做的第一个好处是批量合并:一千次对同一个 key 的修改,可能只落库一次,数据库压力成倍下降。第二个好处是削峰填谷:双十一零点瞬间涌入的订单,不会直接砸向数据库,而是先被清单“缓冲”住,再匀速写库。可以用一个简单公式描述批处理效果:

代价也很明显:如果“购物清单”还没送出,冰箱突然起火,这批数据就丢了。所以 Write-Behind 适合日志、计数、非核心状态这类允许短暂丢失或可补偿的数据,不适合银行转账这种一分钱都不能错的场景。

fdb=λB\displaystyle f_{\text{db}} = \frac{\lambda}{B}
如果每秒写入 2000 条,每批合并 500 条,数据库每秒只需要处理 4 批。
  1. 写入速率 \(\lambda\)应用每秒产生的写请求数量,相当于购物清单上每秒新增的条目。
    λ\lambda
  2. 批量大小 \(B\)每次后台任务一次性合并落库的条数,相当于一次购物车能装多少东西。
    BB
  3. 落库频率 \(f_{\text{db}}\)数据库每秒需要接收的批次次数,数值越小,数据库越轻松。
    λB\frac{\lambda}{B}

一致失效:别让缓存变馊

冰箱里的菜放久了会馊,缓存里的数据放久了也会和数据库不一致。想让缓存一直“新鲜”,不能只靠过期时间,还要主动治理。

最经典的手法叫“双删”:更新数据库后删除一次缓存,等一小段时间,再删除一次。为什么要删两次?因为数据库主从同步有延迟,第一次删除后,可能还有从库读到旧数据,并把旧数据写回缓存;第二次删除可以把这些刚刚回填的旧值再清掉。除此之外,我们还可以用消息队列异步通知删除、用 TTL 做最后兜底。这样系统追求的就不是强一致,而是可接受的最终一致。

所有一致性手段加在一起,可以给“不新鲜”画一条上限:

别指望“把 TTL 调小”就能解决一切。治理缓存一致性,更像是定期清理冰箱:主动扔掉过期食材,同时让保质期作为最后防线。

Tstale≤Texpire+Tasync\displaystyle T_{\text{stale}} \le T_{\text{expire}} + T_{\text{async}}
即使异步通知都失败了,缓存也会在过期时间之后自动“扔掉旧菜”,不会永远脏下去。
  1. 过期时间 \(T_{\text{expire}}\)缓存键的 TTL,相当于保质期,到点自动作废。
    TexpireT_{\text{expire}}
  2. 异步延迟 \(T_{\text{async}}\)消息通知、双删间隔等异步操作带来的时间窗口。
    TasyncT_{\text{async}}
  3. 最大脏读窗口 \(T_{\text{stale}}\)在最坏情况下,用户可能读到旧数据的最长时间。
    Texpire+TasyncT_{\text{expire}} + T_{\text{async}}

性能实战:把冰箱用到极致

冰箱不是塞得越满越好,而是刚好存下你最常吃的菜。性能优化的核心,就是让缓存存得“准”而不是“多”。

第一步是提升命中率:启动时把热点数据预加载进缓存,给不同 key 设置合理 TTL,避免把冷数据也塞进冰箱。第二步是保护“缓存击穿”:当一个热点 key 过期,瞬间涌入的大量请求会同时冲向数据库,这时可以用互斥锁让只有一个线程回源重建,其他线程等待结果。第三步是监控容量:容量规划不能靠拍脑袋,缓存大小、淘汰率、命中率都要持续观测,否则冰箱早晚会被塞爆。

容量规划可以用一个很朴素的公式估算:

性能优化没有银弹,无非是让命中率更高、击穿更少、容量更合理。冰箱用得好,高并发也能游刃有余;用得不好,再大的机器也扛不住洪峰。

Ccache=K×Savg\displaystyle C_{\text{cache}} = K \times S_{\text{avg}}
假设有 100 万个 key,平均每个 value 1KB,那至少需要 1GB 缓存容量,还要再留出冗余。
  1. 键数量 \(K\)预计放进缓存的 key 总数,相当于冰箱里要存放的食材种类数。
    KK
  2. 平均对象大小 \(S_{\text{avg}}\)每个 key 对应 value 的平均字节数,代表每份食材占用的空间。
    SavgS_{\text{avg}}
  3. 总容量 \(C_{\text{cache}}\)缓存至少需要规划的内存大小,相当于冰箱的总容积。
    K×SavgK \times S_{\text{avg}}

AI 应用场景

在线推理特征读取(Cache-Aside)

阶段:在线推理 / 特征服务

解决的问题:推荐系统要在毫秒级读取用户特征,如果每次请求都查特征数据库,数据库连接会瞬间被打满,推理延迟飙升。用 Cache-Aside 先把用户特征放到 Redis“冰箱”里,未命中再回源特征库,并顺手回填。

具体案例:某推荐服务把用户最近 100 个行为特征缓存 5 分钟。用户请求到达时先查缓存,命中率 92%,特征库 QPS 从 8 万降到 6000,P99 延迟从 120ms 降到 18ms。

LLM 应用的 Prompt 组件自动加载(Read-Through)

阶段:LLM 应用编排

解决的问题:大模型应用经常要读取 few-shot 示例、系统提示词、知识库摘要。调用方如果自己写“先查缓存再查数据库”的逻辑,每个模块都复制一份,很难维护。用 Read-Through 让缓存组件自动回源,调用方只面向缓存。

具体案例:一个客服机器人把高频命中的行业问答模板交给 Read-Through 缓存管理。模板更新后,只需要刷新一次数据库,缓存组件会在下次访问时自动补上新内容。

训练样本异步落库(Write-Behind)

阶段:数据回流 / 样本管道

解决的问题:线上推理日志和用户反馈往往每秒产生几十万条,如果实时逐条写进数据湖,存储压力巨大。用 Write-Behind 先把样本写进消息队列或缓存,后台批量写入对象存储,削峰填谷。

具体案例:一个广告点击率模型训练管道把点击日志先写入 Redis,每 500 条合并一次写入数据湖。数据库写入次数降了 90%,训练样本延迟控制在 30 秒内,可接受。

Python 演示

python38 行
class SmartFridge:    def __init__(self, fridge_db):        self.cache = {}        self.fridge_db = fridge_db        self.todo = {}     def get_cache_aside(self, key):        if key in self.cache:            return "hit", self.cache[key]        value = self.fridge_db[key]        self.cache[key] = value        return "miss", value     def get_read_through(self, key):        if key not in self.cache:            self.cache[key] = self.fridge_db[key]        return self.cache[key]     def write_behind(self, key, value):        self.todo[key] = value        self.cache[key] = value     def flush_todo(self):        count = 0        for key, value in self.todo.items():            self.fridge_db[key] = value            count += 1        self.todo.clear()        return count db = {"apple": 3, "milk": 1}fridge = SmartFridge(db)print(fridge.get_cache_aside("apple"))print(fridge.get_cache_aside("apple"))print(fridge.get_read_through("milk"))fridge.write_behind("egg", 12)print("flush", fridge.flush_todo())print(db)
目的:用 40 行 Python 模拟冰箱缓存,分别演示 Cache-Aside、Read-Through 和 Write-Behind 三种模式的核心动作。
  1. def get_cache_aside(self, key):旁路读取的入口:先看冰箱,再看超市。
  2. if key in self.cache:判断缓存是否命中,相当于打开冰箱门检查。
  3. self.cache[key] = value未命中时把数据库结果回填缓存,买完菜放一份进冰箱。
  4. def get_read_through(self, key):把“查库 + 回填”封装在缓存对象内部,调用方只面向缓存。
  5. self.todo[key] = valueWrite-Behind 只更新缓存和待写清单,数据库暂时不动。
  6. def flush_todo(self):模拟后台任务把待写清单批量落库,返回本次写了几条。

常见误区

误区总感觉更新数据库后,把新值直接写进缓存更高效,省得下次再回源数据库。

正解直接更新缓存容易产生并发覆盖。比如 A 线程把新值写进缓存,B 线程随后把读到的旧值写回,新值就被覆盖了。正确做法是更新数据库后删除缓存,让下一次读请求从数据库拉新值,天然修复不一致。这就好比超市货架已经更新,冰箱里的旧标签必须撕掉,而不是随手贴一张新的,因为贴的过程中可能被另一只手贴上旧标签。

为何常见:“写缓存”听起来比“删缓存”更积极,也更符合直觉,所以很多初学者会忽略并发覆盖的风险。

误区以为反正都是“先查缓存,没查到再查数据库”,只是名字不同。

正解Cache-Aside 由应用自己编排缓存和数据库,像人开手动挡;Read-Through 由缓存组件自动回源,应用只面向缓存,像开自动挡。两者的回源逻辑放在哪里完全不同,这也决定了代码复用和故障边界。

为何常见:从调用方的视角看,两者都会在 miss 时回源数据库,因此只看到表面流程,忽略了职责边界。

误区既然数据不是立刻落库,那缓存一宕机,所有未落库的数据都没了,这模式根本不能用。

正解Write-Behind 确实有丢数据风险,但可以通过“先写日志再回放”“多副本保存缓存”“后台高频 flush”等方式把风险降到可接受范围。它适合日志、计数、非核心状态等对短暂丢失不敏感的数据。

为何常见:很多人把“强一致”当成唯一正确标准,忽略了工程上到处都存在成本和风险的权衡。

误区把 TTL 设成 1 秒,缓存最多只有 1 秒的旧数据,问题就解决了。

正解TTL 太短会大幅降低命中率,数据库压力反而上升,甚至导致雪崩。一致性要靠“删除缓存、双删、消息通知”等主动手段治理,TTL 只是最后一道兜底。就像冰箱里的菜,不能靠“扔得勤”来解决保鲜问题,要主动清理和及时补货。

为何常见:TTL 是最容易配置的参数,调一下立刻生效,于是大家总想用最简单的方式解决复杂问题。

面试题

Q1. Cache-Aside 读请求发生缓存未命中时,下列哪一步最先发生?

单选题 · easy

  • A.直接返回空结果
  • B.查询后端数据库← 正确答案
  • C.把空值写进缓存
  • D.删除缓存中的键

答案:B。Cache-Aside 未命中代表冰箱里没菜,正确顺序是先回源数据库,再把结果写回缓存并返回。直接返回空结果或先删缓存都不符合标准流程。

Q2. Cache-Aside 更新数据时,通常建议先更新数据库,然后做什么?

单选题 · easy

  • A.更新缓存中的旧值
  • B.删除对应的缓存键← 正确答案
  • C.清空整个缓存
  • D.重启缓存服务

答案:B。更新数据库后直接更新缓存会有并发覆盖风险,所以更稳妥的是删除缓存键。下一次读请求命不中时,再从数据库拉新值并回填。清空整个缓存和重启服务都太重了。

Q3. Read-Through 与 Cache-Aside 的核心区别是什么?

单选题 · easy

  • A.谁负责在未命中时回源← 正确答案
  • B.缓存使用 Redis 还是本地
  • C.数据库是 MySQL 还是 PG
  • D.请求走 HTTP 还是 RPC

答案:A。Read-Through 由缓存组件负责自动回源,Cache-Aside 由应用自己负责。数据库类型和通信协议不是两者的本质区别。

Q4. 采用 Write-Behind 模式后,数据库写入压力下降的主要原因是?

单选题 · mid

  • A.数据库减少了索引
  • B.多条写入合并成一批← 正确答案
  • C.缓存替代了数据库
  • D.请求被直接丢弃

答案:B。Write-Behind 把多次写请求先记到缓存/清单里,再按批次合并落库,数据库写入次数大幅减少。缓存不会替代数据库,请求也不应该被丢弃。

Q5. 以下哪个场景最适合采用 Cache-Aside?

单选题 · mid

  • A.强一致余额扣减
  • B.读多写少商品详情← 正确答案
  • C.每秒十万次写入日志
  • D.多人协作实时编辑

答案:B。Cache-Aside 适合读多写少、可接受短暂不一致的场景。余额扣减需要强一致,海量日志更适合 Write-Behind,多人实时编辑也不能容忍缓存滞后。

Q6. 热 key 过期瞬间大量请求打到数据库,通常称为?

单选题 · mid

  • A.缓存雪崩
  • B.缓存击穿← 正确答案
  • C.缓存穿透
  • D.缓存预热

答案:B。某个热点 key 失效导致大量并发请求直接打数据库,叫缓存击穿。穿透通常指请求的 key 本身不存在,缓存里自然也没有,每次都穿过缓存。预热是提前把热点数据加载进缓存。

Q7. “双删策略”中第二次删除前为什么要等待一小段时间?

单选题 · hard

  • A.让缓存自动扩容
  • B.避开数据库主从复制延迟← 正确答案
  • C.给慢查询留出超时
  • D.减少缓存占用的内存

答案:B。数据库主从复制有延迟,第一次删除后,从库可能读到旧数据并回填缓存。等一小段时间再删第二次,可以把这些“刚回填的旧菜”再次清掉。等待时间与缓存扩容或慢查询无关。

Q8. 关于缓存命中率,下列说法正确的有?

多选题 · mid

  • A.命中率越高数据库压力越小← 正确答案
  • B.TTL 越短命中率通常越低← 正确答案
  • C.预热热点数据可提升命中率← 正确答案
  • D.命中率不影响平均延迟

答案:ABC。命中率越高,回源数据库的请求越少,平均延迟越低。TTL 太短会让缓存频繁过期,命中率下降。预热能把热点数据提前放进去,提升命中率。D 说命中率不影响延迟显然是错的。

Q9. Write-Behind 的风险控制手段包括?

多选题 · hard

  • A.写操作先落日志← 正确答案
  • B.多副本保存缓存← 正确答案
  • C.接受最终一致补偿← 正确答案
  • D.每次同步写数据库

答案:ABC。Write-Behind 允许短暂不一致,可以通过先写日志、多副本、幂等补偿来降低数据丢失风险。D 一旦变成每次同步写数据库,就不再是 Write-Behind,也丧失了批处理优势。

Q10. 以下属于缓存一致性治理方案的有?

多选题 · extreme

  • A.更新数据库后删除缓存← 正确答案
  • B.用消息队列通知删缓存← 正确答案
  • C.设置过期时间兜底← 正确答案
  • D.更新缓存而非删除缓存

答案:ABC。删除缓存、消息通知、TTL 兜底都是常见的一致性治理手段。直接更新缓存虽然看起来省事,但在并发下容易把旧值写回,反而破坏一致性,所以 D 不是推荐方案。