想象你是一家大型食堂的掌勺师傅。食堂有两个地方存放饭菜:一个是打饭窗口的保温餐盘(速度快,顾客直接从这里取餐),一个是后厨的大锅(容量大,是饭菜的真正来源)。每天中午高峰期,几百号人排队打饭,如果阿姨每次都要跑到后厨去盛菜,队伍会排到门外。所以阿姨会把受欢迎的菜先盛一部分到窗口的保温餐盘里,卖完再回后厨添。 现在问题来了:如果后厨的师傅觉得今天的红烧肉不够味,重新加料炒了一锅,但窗口保温餐盘里还是旧的那份。顾客打到的是“过期版本”的红烧肉——这可不行! 这就是分布式系统中缓存与数据库的关系:缓存是窗口的保温餐盘,数据库是后厨的大锅。缓存一致性,就是解决“窗口的菜”和“后厨的锅”之间如何保持同步的问题。在这篇文章里,我们会一起探索食堂的阿姨们如何用各种策略——有的勤快(Cache-Aside),有的谨慎(Write-Through),有的用妙招(延迟双删)——确保每位顾客都能吃到新鲜出锅的菜。你会发现,这些策略并不复杂,它们就是日常生活中我们都在用的智慧。

岗位关联

对于 AI 从业者而言,缓存一致性是设计高并发数据系统的必修课。掌握它,你才能自信地应对“缓存与数据库谁先更新、失败了怎么办”这类系统设计面试题,并在实际项目中交付既快又准的数据服务。

目录
  1. 可视化演示
  2. 一致性基础
  3. Cache-Aside 旁路缓存
  4. Write-Through 穿透写
  5. 延迟双删
  6. 最终一致性
  7. 一致性实战方案
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示Cache-Aside旁路缓存读写流程、Write-Through与Write-Behind写入模式对比,以及延迟双删策略消除脏缓存的时序过程。

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

Cache-Aside旁路缓存 读时先查缓存,未命中回源数据库 写时先更新DB再删除缓存 Read: Cache miss -> DB -> Set cache 旁路缓存模式简单高效但存在短暂不一致窗口

一致性基础

食堂的窗口保温餐盘和后厨的大锅,本质上就是两种完全不同的“菜容器”。窗口的优势是离顾客近、取菜快,但容量有限;后厨的大锅能装下所有菜,但离得远、取菜慢。在分布式系统里,缓存就像窗口的保温餐盘——它用内存存储数据,读取速度是纳秒级;数据库就像后厨的大锅——它用磁盘存储数据,读取速度是毫秒级。这之间 10 万倍的差距,就是缓存存在的意义。

那么什么是不一致?假设后厨师傅炒了一锅新菜,阿姨却没把窗口的旧菜换掉,顾客就吃到了过期的菜。在系统里,当数据库中的数据被更新,而缓存中还保留着旧数据时,就发生了数据不一致。这种不一致会导致用户读到“过期”的信息——比如你在电商 App 上看到商品还有库存,点进去却提示已售罄。

根据业务对一致性的容忍程度,我们通常把一致性分为几类模型:强一致性(任何时刻读到的一定是最新数据)、弱一致性(不保证读到最新数据)和最终一致性(经过一段时间后,所有副本最终会一致)。这三者的关系,就像食堂在“午高峰”(强一致)、“打烊后”(弱一致)和“晚市前补菜”(最终一致)三个时段的供餐策略。

理解了这三个模型,你就明白了——一致性不是“全有或全无”的选择,而是性能与准确性的权衡。就像食堂管理者需要根据客流决定“多久必须换一次菜”,系统架构师也需要根据业务需求选择合适的一致性模型。

一致性程度:强一致性>最终一致性>弱一致性\text{一致性程度:强一致性} > \text{最终一致性} > \text{弱一致性}
一致性程度越高,系统付出的同步代价越大,这就好比食堂想保证每个窗口随时都是最新鲜的菜,需要安排更多阿姨来回跑动。
  1. 强一致性任何读操作都能读到最近一次写操作的结果,就像食堂刚出锅的菜立刻出现在每个窗口。
    Strong Consistency: read⇒latest write\text{Strong Consistency: } \text{read} \Rightarrow \text{latest write}
  2. 最终一致性系统不保证立即一致,但保证在有限时间内所有副本达到一致状态,就像食堂承诺“今天内”所有窗口都会换上新鲜的菜。
    Eventual Consistency: lim⁡t→Tall replicas=latest\text{Eventual Consistency: } \lim_{t \to T} \text{all replicas} = \text{latest}
  3. 弱一致性系统不做出任何一致性的时间承诺,就像食堂说“什么时候换菜看阿姨心情”。
    Weak Consistency: no guarantee on read staleness\text{Weak Consistency: } \text{no guarantee on read staleness}

Cache-Aside 旁路缓存

Cache-Aside 是食堂里最常用的一种供餐策略,它的流程像极了我们日常点外卖:先看看窗口有没有这道菜(读缓存),有就直接拿走(缓存命中);窗口没有,阿姨去后厨盛一份放到窗口(回源数据库并写入缓存),再把菜递给顾客。

写操作则是另一套流程:顾客点了“不要香菜”,阿姨不直接改窗口的菜(因为窗口只是展示),而是先去后厨大锅把菜重新做一份(更新数据库),然后把窗口的旧菜倒掉(删除缓存)。这样一来,下一次有顾客来点这道菜时,窗口没有,阿姨就会去后厨拿新菜,自然就一致了。

Cache-Aside 的核心精髓就是:“写操作更新数据库 + 删除缓存;读操作命中缓存就直接返回,未命中则回源数据库并回填缓存”。这种策略之所以被广泛应用,是因为它把“读多写少”场景的性能发挥到了极致——读操作几乎全部命中缓存,写操作只需要一次数据库更新和一次缓存删除。

Cache-Aside 就像是一个聪明的食堂管理员制定的规则:在高峰期,大部分顾客能快速从窗口取餐;偶尔需要回后厨补菜的,也只发生在窗口未命中时。这种“懒加载”策略虽然可能导致窗口短暂没有菜(缓存穿透),但配合过期时间,整体上实现了效率与一致性的极佳平衡。

Cache-Aside: {Read: check cache→miss→read DB→write cacheWrite: update DB→delete cache\text{Cache-Aside: } \begin{cases} \text{Read: check cache} \to \text{miss} \to \text{read DB} \to \text{write cache} \\ \text{Write: update DB} \to \text{delete cache} \end{cases}
这个策略的精妙之处在于“删除缓存”而不是“更新缓存”——就像食堂选择倒掉旧菜而不是试图在窗口修改剩菜,因为重建永远比重构简单可靠。
  1. 读缓存先查窗口有没有菜,如果有直接返回,这是最优路径。
    check cache→hit→return\text{check cache} \to \text{hit} \to \text{return}
  2. 缓存未命中回源窗口没菜时,阿姨去后厨取菜,并把新菜放到窗口供后续顾客使用。
    cache miss→read DB→write cache→return\text{cache miss} \to \text{read DB} \to \text{write cache} \to \text{return}
  3. 写操作更新库顾客提出修改需求,阿姨去后厨把大锅里的菜重新做一遍。
    update DB\text{update DB}
  4. 删除缓存后厨菜更新了,窗口的旧菜必须倒掉,否则顾客会拿到过期菜品。
    delete cache\text{delete cache}

Write-Through 穿透写

如果说 Cache-Aside 是“窗口没菜才去后厨”,那 Write-Through 就是“后厨一炒菜,必须立刻同步到窗口”。这种策略就像高档餐厅的“明厨亮灶”:顾客点的每道菜,厨师在后厨做好后,会立即在展示台放一份一模一样的样品,确保顾客看到的永远和实际供应完全一致。

Write-Through 的流程是:写操作先写缓存,然后由缓存组件同步写入数据库。在这两次写入都成功之后,写操作才算完成。这保证了缓存和数据库“要么同时更新,要么同时不更新”,从机制上消除了“窗口有旧菜”的可能。

但代价也很明显:每次写操作都要等到两个地方都写成功才能返回,这比直接写数据库还慢。就像食堂要求每炒一道菜就立刻端一份到窗口,阿姨们会忙得不可开交。因此在写多读少的场景中,Write-Through 会拖累整体性能。

Write-Through 特别适合那些对一致性要求极高、写操作不频繁的系统,比如配置中心、用户权限表。它就像食堂里的“示范窗口”,虽然每次炒菜都要多跑一趟,但保证了顾客任何时候看到的都是最新鲜的菜——这种可靠性在某些场合比速度更重要。

Write-Through: write cache→write DB→respond success\text{Write-Through: } \text{write cache} \to \text{write DB} \to \text{respond success}
Write-Through 用写入延迟换来了读取的强一致性——写操作变慢了,但任何时刻读到的数据都是最新的。
  1. 写缓存操作先到达缓存层,更新缓存中的数据。
    write cache\text{write cache}
  2. 写数据库缓存组件立即将同样的写操作同步到数据库。
    write DB\text{write DB}
  3. 返回成功两个写操作都成功后,才向应用确认写入完成。
    respond success  ⟺  cache=DB\text{respond success} \iff \text{cache} = \text{DB}

延迟双删

在日常开发中,Cache-Aside 有个让人头疼的并发陷阱:假如两个顾客同时操作同一个菜——一个在窗口打菜(读),一个去后厨要求换菜(写)。如果后厨刚更新完大锅,阿姨把窗口的旧菜倒了,但这时第一个顾客刚好把旧菜端走,随后阿姨又从后厨取了一份新菜放到窗口……最终结果是,那个顾客吃到的是旧菜,而窗口放的是新菜。

延迟双删就是专门对付这种“读-写竞态”的战术。它的流程像一个精明的食堂管理员:先倒掉窗口的旧菜(第一次删缓存),再去后厨更新大锅(更新数据库),然后等上一小段时间(比如 500ms),再次倒掉窗口的菜(第二次删缓存)。为什么要等?因为要留出时间让那些“正在端旧菜”的顾客完成操作,等他们走了再把窗口清理干净,确保没有漏网的旧菜。

这个“延迟”的时长通常设置为略大于一次读操作的最长耗时。它不需要精确,只要覆盖绝大多数慢读即可。延迟双删本质上是“最终一致性”思想在并发场景下的落地:我们不追求每毫秒都一致,但保证在延迟窗口过后,系统必然收敛到一致状态。

延迟双删的巧妙之处在于它承认了“无法完全避免竞态”,但通过两次删除,把不一致的时间窗口压缩到了最小。这就像食堂管理员明知道无法阻止顾客偶尔拿到微凉的菜,但通过“及时补新菜”和“定期清理”双重手段,确保绝大多数时间窗口是新鲜的。

延迟双删: del cache→update DB→sleep(t)→del cache\text{延迟双删: } \text{del cache} \to \text{update DB} \to \text{sleep}(t) \to \text{del cache}
延迟双删中的 sleep 是“等旧读者离开”的时间窗,就像食堂阿姨倒掉剩菜后要等客人们都离开才彻底清理桌面。
  1. 先删缓存立刻把窗口的旧菜倒掉,防止后续读操作拿到旧数据。
    del cache(立即执行)\text{del cache} \text{(立即执行)}
  2. 更新数据库去后厨把大锅的菜换成新版本。
    update DB\text{update DB}
  3. 延迟等待休眠一段时间,等待并发的读操作完成。
    sleep(t)t≈max read latency\text{sleep}(t) \quad t \approx \text{max read latency}
  4. 再删缓存第二次删除缓存,清掉任何在这期间被回填的旧数据。
    del cache(延迟后再次执行)\text{del cache} \text{(延迟后再次执行)}

最终一致性

在真实的分布式系统中,“绝对一致”往往是昂贵的奢侈品。就像食堂午高峰时,要求每个窗口每秒钟都与后厨保持一致是疯狂的——阿姨们根本忙不过来。所以,现实中的食堂会采用一种务实的策略:高峰期先把菜大致供应上,等峰值过去再慢慢把各窗口的菜补齐更新。这就是“最终一致性”。

实现最终一致性的常用手段是消息队列(MQ)。具体做法是:后厨的大锅更新后,阿姨往“消息队列”(相当于食堂的公告板)上贴一张便条,写着“红烧肉换新版了”。各窗口的阿姨看到便条后,在自己方便的时候去后厨取新菜。这样做的好处是“解耦”——后厨不用等窗口确认,可以先忙自己的事;窗口也不用被后厨催着跑,按自己的节奏更新。

为了让最终一致“最终”能够达成,还需要一个兜底机制:缓存设置过期时间。就像食堂规定“所有窗口的菜最长展示 30 分钟,不管有没有人买都必须换新”。这样,即使同步机制出现了故障,缓存也会在过期后自然失效,下次读取时强制回源数据库,从而恢复一致。兜底过期是系统自愈的“最后一道保险丝”。

最终一致性是分布式系统中最常用的一致性策略,它源于一个朴素的智慧:在复杂系统里,追求“每时每刻都一致”不如保证“最终一定能一致”。就像食堂不会因为某个窗口的菜暂时不新鲜就关门整顿,而是通过公告板、时限等机制,让系统在动态中保持最终的稳定。

最终一致性=异步同步+过期兜底+补偿重试\text{最终一致性} = \text{异步同步} + \text{过期兜底} + \text{补偿重试}
如果 MQ 是食堂的公告板,那么过期时间就是“最晚换菜时限”——即使公告没看到,时限一到也得强制更新。
  1. 异步同步数据更新后通过消息队列异步通知各副本,不阻塞主流程。
    update DB→MQasync notify replicas\text{update DB} \xrightarrow{\text{MQ}} \text{async notify replicas}
  2. 过期兜底缓存设置 TTL,过期后自动失效,下次读取强制回源。
    cache TTL expiry→forced refresh\text{cache TTL expiry} \to \text{forced refresh}
  3. 补偿重试如果同步失败,通过重试机制确保最终完成同步。
    failure→retry until success\text{failure} \to \text{retry until success}

一致性实战方案

掌握了各种策略之后,最重要的能力是“选型”——就像食堂管理员要根据菜品种类和客流特点来决定管理模式。下面给出实战中的选型建议:

读多写少的业务(如商品详情页、用户信息查询),首选 Cache-Aside + 合理 TTL。读操作几乎全部走缓存,写操作通过“更新库+删缓存”保证下一次读取拿到新数据。

写操作频繁且一致要求高的业务(如库存扣减、账户余额),考虑 Write-Through 或分布式锁 + Cache-Aside。前者用性能换一致,后者通过锁来串行化并发写操作。

并发极高的热点数据(如秒杀商品的库存),使用延迟双删 + MQ 异步补偿,并配合监控告警。如果同步失败超过阈值,触发补偿任务从数据库重建缓存。

无论选哪种策略,都要配套监控和补偿机制:记录缓存命中率、缓存与数据库的差异检测任务、失败重试的补偿队列。就像食堂需要“试菜员”定期检查窗口菜品是否新鲜,分布式系统也需要一个“对账服务”定期扫描缓存与数据库的一致性状态。

最终,缓存一致性方案的设计是“权衡的艺术”。它像管理食堂一样,需要综合考虑流量、成本、团队维护能力——但只要你理解了各种策略的本质和适用场景,就能组合出适合自己业务的方案。这份能力,正是从“会用缓存”到“精通缓存”的关键跃迁。

选型原则: {读多→Cache-Aside写多且严格→Write-Through并发高→延迟双删容忍延迟→最终一致性\text{选型原则: } \begin{cases} \text{读多} \to \text{Cache-Aside} \\ \text{写多且严格} \to \text{Write-Through} \\ \text{并发高} \to \text{延迟双删} \\ \text{容忍延迟} \to \text{最终一致性} \end{cases}
一致性策略没有银弹,只有根据业务场景在“性能、一致性、复杂度”三者之间做出的权衡。
  1. 评估读写比例通过监控数据判断业务是读多还是写多,这是选型的第一步。
    IOPSread:IOPSwrite→ratio\text{IOPS}_{read} : \text{IOPS}_{write} \to \text{ratio}
  2. 评估一致性要求分析业务能否容忍短暂的脏读,能容忍则优先最终一致性方案。
    business tolerance→consistency level\text{business tolerance} \to \text{consistency level}
  3. 设计补偿机制无论选哪种策略,都设计失败重试、对账任务和告警系统。
    compensation: retry + reconcile + alert\text{compensation: retry + reconcile + alert}

AI 应用场景

场景一:AI推荐系统的特征缓存同步

阶段:特征工程 / 在线推断

解决的问题:AI推荐系统需要实时读取用户特征(如点击历史、兴趣标签)进行推断。传统做法是每次请求都查询数据库,延迟高、吞吐低;使用缓存加速后,又面临特征更新的同步问题——用户刚点击了一个商品,缓存中却没有体现,导致推荐结果滞后。

具体案例:某电商平台的推荐系统使用 Redis 缓存用户最近 100 条行为特征,数据库存储在 HBase。当用户产生新行为时,系统先更新 HBase,再删除对应的 Redis 缓存键;同时通过消息队列异步触发特征预加载,将新特征写入缓存。这样保证了推荐服务读到的特征数据在秒级内收敛到最新状态。

场景二:大模型应用的Prompt缓存一致性

阶段:模型服务 / 推理优化

解决的问题:大模型应用中,系统提示词(System Prompt)和常用工具定义会被缓存以减少重复计算。但当提示词版本更新或工具配置变更时,缓存中仍是旧版本,导致模型输出不符合新要求。

具体案例:某 AI Agent 平台使用 Write-Through 策略管理 Prompt 缓存:更新提示词时同步写入缓存和持久化存储,确保所有推理节点立即使用新版本。对于需要灰度发布的场景,则通过版本号在缓存 Key 中做隔离,实现无感切换。

场景三:向量数据库的索引缓存一致性

阶段:数据预处理 / 向量检索

解决的问题:RAG(检索增强生成)系统中,文档向量化后写入向量数据库,但为了加速检索,系统通常会在内存中维护一份索引缓存。当文档被删除或更新时,缓存索引若未同步,会导致检索结果包含已删除的内容。

具体案例:某 RAG 知识库系统在文档更新时采用延迟双删策略:先删除内存索引中的对应向量,更新向量数据库,等待 500ms 后再次清理索引缓存。配合定期的全量索引重建任务,确保最终所有副本一致。

Python 演示

python134 行
import time class CacheAside:    """Cache-Aside 策略: 更新数据库后删除缓存"""     def __init__(self, cache, db):        self.cache = cache        self.db = db     def get(self, key):        """读操作: 先查缓存, 未命中则回源数据库"""        val = self.cache.get(key)        if val is not None:            print(f"[Cache-Aside] 缓存命中: {key}={val}")            return val        val = self.db.get(key)        if val is not None:            self.cache.set(key, val)        print(f"[Cache-Aside] 缓存未命中, 回源数据库: {key}={val}")        return val     def set(self, key, val):        """写操作: 更新数据库, 然后删除缓存"""        self.db.set(key, val)        self.cache.delete(key)        print(f"[Cache-Aside] 已更新数据库并删除缓存: {key}={val}")  class WriteThrough:    """Write-Through 策略: 同步双写, 保证强一致"""     def __init__(self, cache, db):        self.cache = cache        self.db = db     def get(self, key):        val = self.cache.get(key)        print(f"[Write-Through] 读取缓存: {key}={val}")        return val     def set(self, key, val):        self.cache.set(key, val)        self.db.set(key, val)        print(f"[Write-Through] 已同步双写: {key}={val}")  class DelayDoubleDelete:    """延迟双删策略: 删缓存 -> 更新DB -> 延迟 -> 再删缓存"""     def __init__(self, cache, db, delay=0.5):        self.cache = cache        self.db = db        self.delay = delay     def get(self, key):        val = self.cache.get(key)        if val is not None:            print(f"[延迟双删] 缓存命中: {key}={val}")            return val        val = self.db.get(key)        if val is not None:            self.cache.set(key, val)        print(f"[延迟双删] 缓存未命中, 回源: {key}={val}")        return val     def set(self, key, val):        self.cache.delete(key)        print(f"[延迟双删] 第一次删除缓存: {key}")        self.db.set(key, val)        print(f"[延迟双删] 已更新数据库: {key}={val}")        time.sleep(self.delay)        self.cache.delete(key)        print(f"[延迟双删] 延迟{self.delay}s后第二次删除缓存: {key}")  class SimpleCache:    """简易内存缓存"""     def __init__(self):        self.store = {}     def get(self, key):        return self.store.get(key)     def set(self, key, val):        self.store[key] = val     def delete(self, key):        if key in self.store:            del self.store[key]  class SimpleDB:    """简易数据库"""     def __init__(self):        self.store = {}     def get(self, key):        return self.store.get(key)     def set(self, key, val):        self.store[key] = val  def demo():    print("=== Cache-Aside 演示 ===")    cache = SimpleCache()    db = SimpleDB()    db.set("stock:1001", 50)    strategy = CacheAside(cache, db)    print(strategy.get("stock:1001"))    strategy.set("stock:1001", 30)    print(strategy.get("stock:1001"))     print("\n=== Write-Through 演示 ===")    cache = SimpleCache()    db = SimpleDB()    strategy = WriteThrough(cache, db)    strategy.set("config:model", "gpt-4")    print(strategy.get("config:model"))     print("\n=== 延迟双删 演示 ===")    cache = SimpleCache()    db = SimpleDB()    db.set("kv:1", "old")    strategy = DelayDoubleDelete(cache, db, delay=0.2)    cache.set("kv:1", "old")    strategy.set("kv:1", "new")    print(strategy.get("kv:1"))  if __name__ == "__main__":    demo()
目的:用最小可运行的 Python 代码演示三种主流缓存一致性策略的执行流程和核心差异。
  1. class CacheAside:定义 Cache-Aside 策略类,对应正文中“先更新数据库再删缓存”的方法。
  2. val = self.cache.get(key); if val is not None:读操作先查缓存,命中直接返回。
  3. self.cache.delete(key)Cache-Aside 写操作的关键:更新库后删缓存而非更新缓存。
  4. class WriteThrough:定义 Write-Through 策略类,对应“同步双写”的强一致方案。
  5. self.cache.set(key, val); self.db.set(key, val)先写缓存再写数据库,两步都成功才返回。
  6. class DelayDoubleDelete:定义延迟双删策略类,处理并发读写竞态问题。
  7. self.cache.delete(key)第一次删除缓存,防止旧数据被读到。
  8. time.sleep(self.delay)延迟等待,留出时间窗口让并发的读操作完成。
  9. self.cache.delete(key)最后再删除一次缓存,清掉延迟期间可能回填的旧数据。
  10. if __name__ == "__main__":提供可执行入口,方便直接运行查看三种策略的差异。

常见误区

误区很多人觉得,数据库更新了,把缓存里的值也改成新值不就行了,为什么要删除缓存?删除缓存后下一次读还要回源数据库,多浪费一次查询。

正解更新缓存的问题在于,两个并发写操作可能使缓存与数据库不一致。比如 A 请求先更新数据库为“10“,B 请求后更新数据库为”20“,但网络延迟导致 B 先更新缓存为”20“,A 后更新缓存为”10“——最终数据库是 20 但缓存是 10。而删除缓存则没有这个问题:每次读未命中都会从数据库取最新值,相当于用”回源”来保证数据新鲜。

为何常见:人脑天然倾向于“修改”数据而非“删除”数据——就像剩菜我们一般加热而不是直接倒掉。但缓存作为临时存储,删除并重建往往比同步更新更简单可靠。

误区既然延迟双删是为了等并发读操作完成,那把延迟设成 5 秒甚至 10 秒,是不是能 100% 保证不出问题?反正最终会删掉。

正解延迟时间过长有两个坏处:一是缓存长时间没有被删除,期间读请求可能一直打到数据库,导致缓存失效、数据库压力激增;二是在延迟窗口内,如果再有新的读请求将旧数据回填到缓存,第二次删除会清掉它,但随后又可能有新的读请求再次回填旧数据——理论上只要“删除-回填”的竞争持续,就可能永远不一致。实际工程中一般将延迟设为 500ms~1s,配合消息队列的异步重试来兜底。

为何常见:人们容易把“保险”理解为“时间越长越保险”,但在分布式系统中,时间窗口越长意味着不确定性越大。

误区Write-Through 每次写操作都同步写缓存和数据库,那应该任何时刻读到的都是最新数据,妥妥的强一致。

正解Write-Through 只能保证“通过该组件写入的数据”在缓存与数据库之间一致。但如果存在多个应用实例直连数据库绕过缓存写入,或者缓存组件本身做了多副本复制,那 Write-Through 并不能保证全局强一致。真正的强一致需要配合分布式事务、共识算法(如 Raft)等更底层的机制。

为何常见:人们习惯将“机制”与“结果”划等号,忽略了机制本身的适用范围和外部干扰因素。

误区每次写操作都要删两次缓存、还要 sleep 一段时间,性能开销太大了,这种方案在生产环境根本不可用。

正解延迟双删实际上是“理论最优”和“工程可行”之间的折中。在大多数业务中,写操作本身就不是高频路径(比如商品信息修改、用户资料更新),相比于写操作增加的几十毫秒延迟,读操作获得的性能提升和一致性保障是值得的。而且第二次删除可以改为异步执行(丢到消息队列里),完全不影响主流程的性能。

为何常见:初学者容易把“额外操作”等同于“性能灾难”,但忽略了操作的频率和代价是相对的。

误区删除缓存可能会失败(比如 Redis 超时),但没关系,缓存有 TTL,过一会儿自动过期了,数据最终会一致的。

正解依赖 TTL 兜底是一个被动的方案,如果 TTL 设置得比较长(比如 1 小时),在这 1 小时内用户会一直读到旧数据。对于某些业务(如库存、价格)这是不可接受的。正确的做法是:将删除失败的 key 写入消息队列或重试队列,进行补偿删除;同时配合监控告警,对删除失败率超过阈值的场景及时告警。

为何常见:人总是倾向于相信“系统会自动修复”,但分布式系统的可靠不是靠运气,而是靠设计出来的补偿机制。

面试题

Q1. 在 Cache-Aside 策略中,写操作的正确顺序是?

单选题 · mid

  • A.先更新缓存,再更新数据库
  • B.先更新数据库,再删除缓存← 正确答案
  • C.先删除缓存,再更新数据库
  • D.先更新缓存,再删除数据库

答案:B。Cache-Aside 要求先更新数据库再删除缓存。如果先更新缓存,并发场景下可能使缓存与数据库不一致;如果只更新缓存而不删除,缓存会永久保留旧数据。选项 B 通过“更新库+删缓存”保证下一次读操作会从数据库获取最新数据。

Q2. Write-Through 策略与 Cache-Aside 相比,最明显的劣势是什么?

单选题 · mid

  • A.读取速度变慢
  • B.写操作延迟增加← 正确答案
  • C.缓存命中率降低
  • D.数据库压力增大

答案:B。Write-Through 每次写操作都需要同步写缓存和数据库,写操作的延迟明显增加。它读操作仍然很快(缓存命中),缓存命中率也不受影响,数据库压力也未必增大——只是写路径变长了。

Q3. 延迟双删中第二次删除缓存的目的包括哪些?

多选题 · mid

  • A.清理读请求回填的旧数据← 正确答案
  • B.提高缓存命中率
  • C.解决并发读写竞态问题← 正确答案
  • D.减少数据库查询次数

答案:AC。第一次删除后、更新数据库完成前,可能有并发读请求把旧数据回填到缓存。第二次删除就是为了清除这些“漏网之鱼”,解决并发读写竞态。它不会提高命中率,反而短期降低了命中率;也不会减少数据库查询次数。

Q4. 缓存过期时间(TTL)在一致性保障中扮演什么角色?

单选题 · easy

  • A.使缓存永远不失效
  • B.作为最终一致性的兜底机制← 正确答案
  • C.提高缓存写入速度
  • D.替代消息队列的作用

答案:B。TTL 确保缓存即使没有收到删除通知,也会在过期后自然失效,强制回源数据库。它是一种“最终兜底”机制,保证数据最终收敛一致。它不会让缓存永远有效,也不负责写入加速或替代 MQ。

Q5. 当 Redis 删除缓存失败时,以下哪种方案是工程上最合理的补偿手段?

单选题 · hard

  • A.忽略错误,等待 TTL 自动过期
  • B.将失败 key 写入消息队列异步重试← 正确答案
  • C.立即重启 Redis 服务
  • D.直接用新值覆盖缓存

答案:B。将失败的 key 丢入 MQ 异步重试是常见的补偿方案,它不阻塞主流程,还能保证最终删除成功。依赖 TTL 是备选兜底而非主要手段;重启服务影响面太大;直接用新值覆盖缓存可能引发并发下的一致性问题。

Q6. 以下哪些场景更适合使用最终一致性而非强一致性?

多选题 · hard

  • A.用户商品浏览历史记录← 正确答案
  • B.银行账户余额查询
  • C.商品详情页展示信息← 正确答案
  • D.秒杀系统的库存扣减

答案:AC。浏览历史和商品详情允许短暂读到旧数据,适合最终一致性。银行余额和库存扣减对一致性要求极高,必须使用强一致或串行化方案,否则会引发资损或超卖。

Q7. 缓存与数据库不一致的根本原因是什么?

单选题 · easy

  • A.缓存存储空间不足
  • B.两者的存储介质不同
  • C.数据库更新了但缓存未被同步更新← 正确答案
  • D.缓存命中率太低

答案:C。不一致的本质是“数据库状态已改变,而缓存仍保留旧状态”。存储介质差异和空间问题只是缓存的特性,不是不一致的直接原因;命中率低导致性能问题而非一致性问题。

Q8. 在高并发读多写少场景下,以下哪种缓存策略最为合适?

单选题 · mid

  • A.Write-Through 穿透写
  • B.Cache-Aside 旁路缓存← 正确答案
  • C.延迟双删
  • D.Write-Back 回写

答案:B。Cache-Aside 在写操作时只做“更新库+删缓存”,读操作绝大多数命中缓存,性能最优。Write-Through 会拖慢每次写操作;延迟双删更适合并发激烈的特定场景;Write-Back 是回写策略,不是主流的一致性保障方案。

Q9. 关于延迟双删,下列说法正确的有?

多选题 · mid

  • A.延迟时间通常设为略大于一次读操作的耗时← 正确答案
  • B.延迟时间越长系统一致性越高
  • C.延迟双删可以完全消除数据不一致
  • D.第二次删除可以异步执行← 正确答案

答案:AD。延迟设为“略大于读耗时”即可覆盖绝大多数并发场景,太长反而导致缓存空窗期增长;延迟双删只是缩小不一致窗口,不能百分百消除;第二次删除可以异步化处理,不影响主流程。

Q10. 在 Cache-Aside 中,如果“更新数据库成功但删除缓存失败”,不采取补偿措施会怎样?

单选题 · hard

  • A.数据立即一致
  • B.缓存永远保留旧值,直到过期或下次写操作← 正确答案
  • C.数据库自动回滚
  • D.缓存自动更新为新值

答案:B。删除缓存失败意味着缓存中仍是旧数据。如果没有补偿(如 MQ 重试),缓存只能等 TTL 过期才会恢复一致。这期间所有读请求都会命中旧缓存,导致用户看到过期数据。因此需要对删除失败做监控和补偿。