目录

从零起步学习Redis-第二章Cache-Aside-Pattern旁路缓存模式以及优化策略

从零起步学习Redis || 第二章:Cache Aside Pattern(旁路缓存模式)以及优化策略

[https://csdnimg.cn/release/blogv2/dist/pc/img/activeVector.png VibeCoding·九月创作之星挑战赛 10w+人浏览 2.2k人参与

https://csdnimg.cn/release/blogv2/dist/pc/img/arrowright-line-White.png]( )

前言:

今天继续我们的Redis学习,主要讲解一下Cache Aside Pattern(旁路缓存模式)

概念:

Cache Aside Pattern,又称 Lazy Loading(懒加载)模式,是一种典型的 应用程序主动去操作缓存 的策略。
核心思想是:应用程序先从缓存中读取数据,如果缓存中没有,再去数据库加载,并把数据写入缓存。

换句话说,缓存是“旁路”式的,数据的来源仍然是数据库,缓存只是辅助,应用程序负责管理缓存。

工作原理

读取数据流程:
  1. 应用程序尝试从缓存中读取数据。

  2. 如果缓存命中(cache hit),直接返回数据。

  3. 如果缓存未命中(cache miss):

    • 从数据库中读取数据。
    • 将数据写入缓存(可设置 TTL,防止缓存永久存在)。
    • 返回数据给客户端。
写入数据流程:
  1. 当数据更新时,先更新数据库。
  2. 再删除缓存中对应的数据(或直接更新缓存)。
  3. 下次读取时,会重新加载最新数据到缓存。

关键点:应用程序负责控制缓存和数据库的数据一致性。

重点:

前面这些可以看到都非常简单易懂,那么我的问题来了:

为什么要先操作数据库,再操作缓存?

答:

场景设定:高并发下的数据更新

  • 数据 X:初始值 X=10 (保存在数据库和缓存中)。
  • 请求 A (写请求):要将 X 更新为 20
  • 请求 B (读请求):在 A 的更新操作过程中读取 X
  • 目标状态:数据库 X=20,缓存 X=20 (或缓存无X,下次读时回填)。

分类讨论一下:

错误顺序剖析:先删缓存,再更新数据库 (删除Cache -> 更新DB)

https://i-blog.csdnimg.cn/direct/71801a160c1148d0be8f83d0e8a84ff5.png

关键步骤解读(错误顺序):

  1. A (写) 删缓存: 请求 A 成功删除了缓存中的 X。此时缓存为空。
  2. A (写) 开始更新DB(慢): 请求 A 开始执行数据库更新操作(这通常涉及网络I/O、磁盘I/O、事务处理等,相对较慢)。
  3. B (读) 读缓存(未命中): 在 A 的数据库更新完成之前,请求 B 来读取 X。发现缓存中没有 X(因为被 A 删了)。
  4. B (读) 读DB(读到旧值): 请求 B 转而查询数据库。关键点来了:此时请求 A 的 UPDATE 操作可能还在进行中(未提交事务),或者虽然开始了但还没执行到X所在的记录。因此,数据库返回给 B 的仍然是旧值 X=10
  5. B (读) 回填缓存(写入旧值!): 请求 B 遵循“读未命中则回填缓存”的原则,将从数据库读到的旧值 X=10 写入了缓存。
  6. A (写) 完成DB更新: 请求 A 终于完成了数据库更新,将 X 成功设置为 20
  7. 最终状态:
    • 数据库:X=20(新值,正确)。
    • 缓存:X=10(旧值!严重错误!)。
  8. 严重后果: 缓存中的旧数据 X=10 会一直存在,直到这个缓存项因为过期(TTL)被清除,或者下次有成功的写操作再次触发删除缓存(或下一次写操作再次触发删除缓存(或下一次读请求在缓存过期后触发回填)。在这段时间内,所有读取 X 的请求都会拿到错误的旧值 10!这就是脏数据长期驻留缓存问题。缓存完全失去了意义,甚至起到了反作用。

正确顺序剖析:先更新数据库,再删除缓存 (更新DB -> 删除Cache)

https://i-blog.csdnimg.cn/direct/d534eadca32a4a3fba084371755b5c63.png

关键步骤解读(正确顺序):

  1. A (写) 更新DB(慢): 请求 A 开始执行数据库更新操作(慢)。
  2. B (读) 读缓存(命中旧值): 在 A 的数据库更新完成之前,请求 B 读取 X。此时缓存中还有未被删除的旧值 X=10,B 直接命中缓存拿到旧值。这是一个短暂的(通常只有几毫秒到几十毫秒)不一致窗口。
  3. A (写) 完成DB更新: 请求 A 成功将数据库中的 X 更新为 20
  4. A (写) 删除缓存: 请求 A 立刻删除缓存中的 X这是最关键的一步!
  5. 处理并发读 (B):
    • 情况1 (B在A删缓存前读取完毕): B 已拿到旧值10,流程结束。缓存随后被A删除。下次读会触发回填新值20
    • 情况2 (最常见 - B已读完旧值): 缓存中可能还有B读过的旧值10,但被A在第4步强制删除掉了!
    • 情况3 (罕见 - B在DB更新后、缓存删除前读):
      • B读缓存未命中(可能刚好被其他操作清掉,或TTL到期)。
      • B读数据库,此时DB已是新值20(因为A的更新已经完成)。
      • B将**新值20**回填到缓存。
      • 紧接着,A执行第4步删除缓存的操作,将B刚写入的新值20也删除了!

最终状态&自我修复

  • 无论B在A的操作过程中做了什么,在A成功执行完第4步 删除缓存 之后,缓存中关于X的数据一定是不存在的(被删除了)
  • 数据库的状态是确定的新值 X=20
  • 后续任何一个读请求过来:
    • 读缓存:未命中(因为X已被删除)。
    • 读数据库:拿到新值20
    • 回填缓存:将新值20写入缓存。
  • 系统达成最终一致: 数据库 X=20,缓存 X=20。之前的短暂不一致(B读到旧值)或缓存短暂存放新值后被删除,都被后续的正常读流程自动修复了。不会出现旧数据长期污染缓存的情况!

为什么正确顺序更好?核心优势总结表

特性错误顺序:删缓存 -> 更新DB正确顺序:更新DB -> 删缓存优势说明
脏数据驻留缓存风险极高极低错误顺序导致旧数据被读请求回填并长期留存;正确顺序通过后续删除和读回填确保缓存最终是新数据或空。
不一致持续时间 (直到下次写/缓存过期) (通常毫秒级,直到下次读请求回填)正确顺序下不一致窗口仅限于并发读写重叠且读在DB更新后、缓存删除前的极短时间 + 下次读请求的处理时间。
操作失败后果严重 (删缓存成功+更新DB失败:缓存空+DB旧值)相对可控 (更新DB成功+删缓存失败:DB新值+缓存旧值)DB有正确的新值是底线。缓存旧值可通过重试删除、TTL过期或下次触发删除修复。缓存空+DB旧值会导致数据丢失错觉。
设计理念符合度低 (直接干预缓存作为写入口)高 (缓存是副本,写只操作数据源DB,失效缓存副本)逻辑更清晰,职责分离。
并发写影响可能导致缓存与DB都错乱无额外影响正确顺序只涉及DB并发写(DB自身事务保证)和独立的缓存删除。
如果最后缓存中还是有旧数据,可以怎么继续进行优化?

答:

常见优化方案(按复杂度递增)
  1. 双删(Double Delete)

    • 做法:事务提交后删一次缓存,稍微 sleep 几十毫秒,再删一次。
    • 作用:避免并发线程把旧值写回缓存。
    • 缺点:依赖 sleep,效果不稳定,算是“土办法”。
  2. 更新缓存(而不是删除)

    • 在 DB 提交成功后,直接 set(key, newValue)
    • 优点:减少旧值回写缓存的机会。
    • 缺点:需要处理并发写入的覆盖问题(可能要用版本号/CAS 机制)。
  3. 版本号 / 时间戳机制

    • 缓存中存储一个 version 字段,每次更新时带上版本号。
    • 写缓存时只在 newVersion > oldVersion 时才覆盖。
    • 优点:能有效防止旧值覆盖新值。
    • 缺点:实现复杂度稍高。
  4. 分布式锁(针对热点 Key)

    • 在回填缓存时对 Key 加分布式锁(如 Redis 的 SETNX)。
    • 确保同一时刻只有一个线程能写入缓存,避免并发覆盖。
    • 缺点:增加延迟,适合少量热点 Key,不适合全局使用。
  5. 事件驱动(消息队列同步)

    • DB 更新成功后,发消息到 MQ,各个服务节点订阅并失效本地缓存。
    • 优点:在分布式场景下保持多实例缓存一致性。
    • 缺点:引入 MQ,系统架构更复杂。

总结:

  • 基本原则

    • 先写数据库,再操作缓存(删除或更新),因为数据库是权威数据源。
    • 这样可以避免缓存中长期存在脏数据。
  • 仍然可能的问题

    • 在高并发下,缓存中仍可能被旧数据覆盖(短暂不一致)。
  • 常见优化手段

    • 双删(double delete):DB 提交后删一次缓存,再延迟一小段时间再删一次。
    • 更新缓存而不是删除:直接把新值写入缓存,减少 cache miss。
    • 版本号 / 时间戳:缓存数据携带版本,只有新版本才能覆盖旧版本。
    • 分布式锁:对热点 key 加锁,避免多个线程同时写缓存。
    • 事件驱动(消息队列):DB 更新后发事件,多个服务节点订阅,统一删除/更新缓存。
  • 取舍建议

    • 大部分业务:用「先 DB → 再删缓存」足够。
    • 高并发热点场景:结合版本号/锁/事件驱动,保证缓存更强一致性。

最后非常感谢大家的关注和支持,有任何问题都可以在评论区提出,我一定会认真解答!