前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、缓存和数据库谁先动手?

非要数据库和缓存数据强一致: 不可能做到,缓存系统适用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP ,对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。

1、Cache aside

Cache aside也就是 旁路缓存,是比较常用的缓存策略。

1)先删缓存,再更新数据库

a、执行流程

(1)写请求删除缓存数据;

(2)读请求查询缓存未击中,紧接着查询数据库,将返回的数据回写到缓存中;

(3)写请求更新数据库。

整个流程下来发现数据库中 age为20,缓存中 age 为 18,缓存和数据库数据不一致,缓存出现了脏数据。

image-20250909143413225

2)先更新数据库,后删除缓存

a、执行流程

(1)读请求先查询缓存,缓存未击中,查询数据库返回数据;

(2)写请求更新数据库,删除缓存;

(3)读请求回写缓存;

整个流程操作下来发现数据库age为20缓存age为18,即数据库与缓存不一致,导致应用程序从缓存中读到的数据都为旧数据。

image-20250909143733447

b、问题出现

先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会比较低! 但是在大多数情况下,在不想做过多设计,增加太大工作量的情况下,原因是:数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少)这一情形很难出现。为了兜底通常还可以为数据设置合适的缓存时间

3)先更新数据库,再更新缓存

a、执行流程

(1)写请求1 更新数据库,将 age 字段更新为18;

(2)写请求2 更新数据库,将 age 字段更新为20;

(3)写请求2 更新缓存,缓存 age 设置为20;

(4)写请求1 更新缓存,缓存 age 设置为18;

执行完预期结果是数据库 age 为20,缓存 age 为20,结果缓存 age为18,这就造成了缓存数据不是最新的,出现了脏数据。

image-20250909143247057

b、问题出现

数据覆盖,线程 A 更新了数据库;线程 B 更新了数据库;线程 B 更新了缓存;线程 A 更新了缓存;

更新缓存失败,数据库更新成功以后,由于缓存和数据库是分布式的,更新缓存可能会失败

写多读少不适用,如果是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

4)先更新缓存,再更新数据库

不使用,分析: 更新缓存成功,更新数据库出现异常了,导致缓存数据与数据库数据完全不一致

2、Write behind

Write behind在一些地方也被成为Write back, 简单理解就是:应用程序更新数据时只更新缓存, Cache Provider每隔一段时间将数据刷新到数据库中。说白了就是延迟写入

image-20250909145226481

如上图,应用程序更新两个数据,Cache Provider 会立即写入缓存中,但是隔一段时间才会批量写入数据库中。

这种方式有优点也有缺点:

  • 优点 是数据写入速度非常快,适用于频繁写的场景。
  • 缺点 是缓存和数据库不是强一致性,对一致性要求高的系统慎用。

3、Read/Write through

1)Read through

Cache Aside 更新模式中,应用代码需要维护两个数据源头:一个是缓存,一个是数据库。而在 Read-Through 策略下,应用程序无需管理缓存和数据库,只需要将数据库的同步委托给缓存提供程序 Cache Provider 即可。所有数据交互都是通过抽象缓存层完成的。

image-20250909150732079

如上图,应用程序只需要与Cache Provider交互,不用关心是从缓存取还是数据库。

在进行大量读取时,Read-Through 可以减少数据源上的负载,也对缓存服务的故障具备一定的弹性。如果缓存服务挂了,则缓存提供程序仍然可以通过直接转到数据源来进行操作。

Read-Through 适用于多次请求相同数据的场景,这与 Cache-Aside 策略非常相似,但是二者还是存在一些差别,这里再次强调一下:

  • Cache-Aside 中,应用程序负责从数据源中获取数据并更新到缓存。
  • Read-Through 中,此逻辑通常是由独立的缓存提供程序(Cache Provider)支持。

2)Write through

Write-Through 策略下,当发生数据更新(Write)时,缓存提供程序 Cache Provider 负责更新底层数据源和缓存。缓存与数据源保持一致,并且写入时始终通过 抽象缓存层到达数据源。

image-20250909150908440

二、采纳方案

1、延迟双删 (MT )

1)流程

1、先写数据库

3、再删除缓存

3、发送延迟队列,延迟N秒,查缓存和查数据库,进行对比,如果不一致,则淘汰缓存

2)问题归纳

问题1:为什么是先淘汰缓存,再更新数据库呢?

答案:网上看到的都是先淘汰缓存,个人理解,使用延迟双删,其实也可以先写数据库,再删除缓存。

问题2:这个N秒,怎么确定呢?

答案:这个N秒,取决于业务代码执行时间,保证数据库已经修改完成

问题3:如果两次都没删除怎么办?

答案:消息积压,直到删除

2、方案 2:binlog

订阅 binlog 程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能

1、更新数据库数据

2、数据库会将操作信息写入binlog日志当中

3、订阅程序Canal中间提取出所需要的数据以及key

4、将这些信息发送至消息队列

5、尝试删除缓存操作,发现删除失败

7、重新从消息队列中获得该数据,重试操作。

三、缓存一致性方案

问题1:什么是幽灵写?

答案:当一个写操作因超时而未能成功完成,但其写请求仍可能在网络或数据库中滞留,并在未来某一时刻意外执行时,我们称这样的写操作为“幽灵写”。

问题2:如何消灭幽灵写

答案:

  1. 线程 A 发起写数据操作,但在写入数据库前被阻塞,形成潜在的幽灵写。
  2. 线程 B 尝试读取数据时检测到可能存在幽灵写(通过检查KeyStatus是否过期),并启动异步线程更新数据库中的KeyVersion(标准写流程包括读取和更新KeyVersion,不修改业务数据)。异步线程完成后,如果KeyVersion更新成功,则继续下一步。
  3. 幽灵写的实际发生会导致写数据库时KeyVersion校验失败,从而不会影响任何业务数据。

注:若线程B触发的异步线程也出现超时情况,那么它的KeyVersion更新也会变成幽灵写,这种情况下可忽略该异常。

问题3:什么是 KeyLeases

答案:答案:通过给访问的键添加带令牌的租约机制,可以防止并发更新导致的问题。这一机制被称为KeyLeases

1、元数据

这是一个通过元数据状态管理来保证数据库与缓存一致性的方案。该方案使用两个关键的元数据定义(KeyStatusKeyValidity )来控制缓存的读写行为,有效解决了缓存与数据库一致性问题。

1)KeyStatus

KeyStatus 用于跟踪缓存键的写入状态,主要有三种值:

  1. LastWriteSuccess(0):键的上一次写入操作成功完成
    • 读写行为:如果键在缓存中存在,则缓存数据与数据库一致,可以直接读取缓存
    • 约束条件KeyStatus 的过期时间必须比 Key 的过期时间长,这样才能保证只要 Key 在缓存中存在,其数据就与数据库一致
  2. InWriting(1):当前有线程正在写入该键
    • 读写行为:读请求需要回源到数据库,不能读取缓存
    • 约束条件KeyStatus 值为 1 时的过期时间需要比应用访问数据库的超时时间长,否则可能导致读请求过早触发异步写操作
  3. LastWriteExpired_or_LastStatusLost_or_NeverVisited (空)
    • 含义:键的上一次写入已超时,或 KeyStatus 状态信息丢失,或键从未被访问过
    • 读写行为:可能存在幽灵写,不能读缓存;可以读写数据库;可以通过更新数据中 version 字段消灭幽灵写。

2)KeyValidity

keyValidity 用于标记缓存数据是否有效,主要有两种值:

有效 (0): 键的数据是可靠的,允许直接从缓存读取。

无效 (非0或空): 数据不可信,即使缓存中有数据,也需要回源数据库进行读取。

2、写数据流程

  1. **APP写数据: **APP发起写数据请求。

  2. 查询数据库获取 KeyVersion:从数据库中查询并获取当前的 KeyVersion,如果获取 KeyVersion 失败,则流程异常结束。

  3. 设置 Key 状态:使用 Lua 脚本操作,保证原子性:设置KeyStatus InWriting,有效期 5 秒。获取 KeyLeases 并设置过期时间。删除缓存中的 Key。将KeyValidity 设置为无效。

  4. 校验 KeyVersion 并尝试写 DB

    • 校验 KeyVersion是否正确,并尝试将数据写入数据库。
    • 如果写 DB 未成功(超时),则返回超时信息。
    • 如果写 DB 成功且修改了数据,则继续下一步。
    • 如果写 DB 成功但未修改数据(KeyVersion 校验未通过),则返回失败。
  5. Lua 中执行租约匹配Lua 脚本中执行租约匹配操作:设置Key。租约不匹配时不写缓存。

    • 释放写锁( set KeyStatus = LastWriteSuccess)(如果租约匹配)

    • key 置为有效(set KeyValidity = 0)(如果 key 存在并且租约匹配)

  6. 返回结果

    • 如果所有步骤都成功完成,则返回成功。
    • 如果在任何步骤中出现异常,则流程异常结束。

3、读数据流程

  1. APP 读数据:APP 发起读数据请求
  2. 查询缓存中 KEY 是否存在
    • 存在:
      • KeyValidity 是否有效
        • 有效:查询缓存返回
        • 无效:判断 KeyStatus 是否为空
          • 不为空:回源数据库,不写缓存
          • 为空:回源数据库 + 异步调用完整写keyVersion ++
    • 不存在:
      • 判断 KeyStatus 数据
        • LastWriteSuccess(0)
          • 拿租约 KeyLeases 并设置过期时间
          • 回源数据库 + Lua 脚本中:匹配租约,set key
        • InWriting(1)回源数据库,不写缓存
        • LastWriteExpired_or_LastStatusLost_or_NeverVisited
          • 回源数据库 + 异步调用完整写keyVersion ++

4、强一致保障

  1. 写前失效/标记:使用 Lua 脚本保证在写入数据库之前清除缓存,确保读请求能正确回源数据库。
  2. 数据库版本控制 (KeyVersion):采用乐观锁策略,确保写操作基于最新的数据版本。
  3. 写后填充缓存(带租约):成功写入数据库后,仅持有正确租约的进程可更新缓存,确保缓存数据的准确性。
  4. 读流程的状态判断:依据KeyStatus的不同状态,指导读请求正确处理缓存和数据库的交互。

ContactAuthor