Redis畅通之_缓存一致性
前言
Github:https://github.com/HealerJean
一、缓存和数据库谁先动手?
非要数据库和缓存数据强一致: 不可能做到,缓存系统适用的场景就是非强一致性的场景,所以它属于
CAP中的AP,对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。
1、Cache aside
Cache aside也就是 旁路缓存,是比较常用的缓存策略。
1)先删缓存,再更新数据库
a、执行流程
(1)写请求删除缓存数据;
(2)读请求查询缓存未击中,紧接着查询数据库,将返回的数据回写到缓存中;
(3)写请求更新数据库。
整个流程下来发现数据库中 age为20,缓存中 age 为 18,缓存和数据库数据不一致,缓存出现了脏数据。

2)先更新数据库,后删除缓存
a、执行流程
(1)读请求先查询缓存,缓存未击中,查询数据库返回数据;
(2)写请求更新数据库,删除缓存;
(3)读请求回写缓存;
整个流程操作下来发现数据库age为20,缓存age为18,即数据库与缓存不一致,导致应用程序从缓存中读到的数据都为旧数据。

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,这就造成了缓存数据不是最新的,出现了脏数据。

b、问题出现
数据覆盖,线程
A更新了数据库;线程B更新了数据库;线程B更新了缓存;线程A更新了缓存;更新缓存失败,数据库更新成功以后,由于缓存和数据库是分布式的,更新缓存可能会失败
写多读少不适用,如果是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
4)先更新缓存,再更新数据库
不使用,分析: 更新缓存成功,更新数据库出现异常了,导致缓存数据与数据库数据完全不一致
2、Write behind
Write behind在一些地方也被成为Write back, 简单理解就是:应用程序更新数据时只更新缓存,Cache Provider每隔一段时间将数据刷新到数据库中。说白了就是延迟写入。

如上图,应用程序更新两个数据,Cache Provider 会立即写入缓存中,但是隔一段时间才会批量写入数据库中。
这种方式有优点也有缺点:
优点是数据写入速度非常快,适用于频繁写的场景。缺点是缓存和数据库不是强一致性,对一致性要求高的系统慎用。
3、Read/Write through
1)Read through
在
Cache Aside更新模式中,应用代码需要维护两个数据源头:一个是缓存,一个是数据库。而在Read-Through策略下,应用程序无需管理缓存和数据库,只需要将数据库的同步委托给缓存提供程序Cache Provider即可。所有数据交互都是通过抽象缓存层完成的。

如上图,应用程序只需要与Cache Provider交互,不用关心是从缓存取还是数据库。
在进行大量读取时,Read-Through 可以减少数据源上的负载,也对缓存服务的故障具备一定的弹性。如果缓存服务挂了,则缓存提供程序仍然可以通过直接转到数据源来进行操作。
Read-Through 适用于多次请求相同数据的场景,这与 Cache-Aside 策略非常相似,但是二者还是存在一些差别,这里再次强调一下:
- 在
Cache-Aside中,应用程序负责从数据源中获取数据并更新到缓存。 - 在
Read-Through中,此逻辑通常是由独立的缓存提供程序(Cache Provider)支持。
2)Write through
Write-Through策略下,当发生数据更新(Write)时,缓存提供程序Cache Provider负责更新底层数据源和缓存。缓存与数据源保持一致,并且写入时始终通过 抽象缓存层到达数据源。

二、采纳方案
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:如何消灭幽灵写
答案:
- 线程
A发起写数据操作,但在写入数据库前被阻塞,形成潜在的幽灵写。 - 线程
B尝试读取数据时检测到可能存在幽灵写(通过检查KeyStatus是否过期),并启动异步线程更新数据库中的KeyVersion(标准写流程包括读取和更新KeyVersion,不修改业务数据)。异步线程完成后,如果KeyVersion更新成功,则继续下一步。 - 幽灵写的实际发生会导致写数据库时
KeyVersion校验失败,从而不会影响任何业务数据。
注:若线程B触发的异步线程也出现超时情况,那么它的KeyVersion更新也会变成幽灵写,这种情况下可忽略该异常。
问题3:什么是 KeyLeases
答案:答案:通过给访问的键添加带令牌的租约机制,可以防止并发更新导致的问题。这一机制被称为KeyLeases。
1、元数据
这是一个通过元数据状态管理来保证数据库与缓存一致性的方案。该方案使用两个关键的元数据定义(
KeyStatus和KeyValidity)来控制缓存的读写行为,有效解决了缓存与数据库一致性问题。
1)KeyStatus
KeyStatus用于跟踪缓存键的写入状态,主要有三种值:
LastWriteSuccess(0):键的上一次写入操作成功完成- 读写行为:如果键在缓存中存在,则缓存数据与数据库一致,可以直接读取缓存
- 约束条件:
KeyStatus的过期时间必须比Key的过期时间长,这样才能保证只要Key在缓存中存在,其数据就与数据库一致
InWriting(1):当前有线程正在写入该键- 读写行为:读请求需要回源到数据库,不能读取缓存
- 约束条件:
KeyStatus值为1时的过期时间需要比应用访问数据库的超时时间长,否则可能导致读请求过早触发异步写操作
LastWriteExpired_or_LastStatusLost_or_NeverVisited(空)- 含义:键的上一次写入已超时,或
KeyStatus状态信息丢失,或键从未被访问过 - 读写行为:可能存在幽灵写,不能读缓存;可以读写数据库;可以通过更新数据中
version字段消灭幽灵写。
- 含义:键的上一次写入已超时,或
2)KeyValidity
keyValidity用于标记缓存数据是否有效,主要有两种值:
有效 (0): 键的数据是可靠的,允许直接从缓存读取。
无效 (非0或空): 数据不可信,即使缓存中有数据,也需要回源数据库进行读取。
2、写数据流程
-
**
APP写数据: **APP发起写数据请求。 -
查询数据库获取
KeyVersion:从数据库中查询并获取当前的KeyVersion,如果获取KeyVersion失败,则流程异常结束。 -
设置
Key状态:使用Lua脚本操作,保证原子性:设置KeyStatus为InWriting,有效期5秒。获取KeyLeases并设置过期时间。删除缓存中的Key。将KeyValidity设置为无效。 -
校验
KeyVersion并尝试写DB- 校验
KeyVersion是否正确,并尝试将数据写入数据库。 - 如果写
DB未成功(超时),则返回超时信息。 - 如果写
DB成功且修改了数据,则继续下一步。 - 如果写
DB成功但未修改数据(KeyVersion校验未通过),则返回失败。
- 校验
-
在
Lua中执行租约匹配:在Lua脚本中执行租约匹配操作:设置Key。租约不匹配时不写缓存。-
释放写锁(
setKeyStatus=LastWriteSuccess)(如果租约匹配) -
key置为有效(setKeyValidity= 0)(如果key存在并且租约匹配)
-
-
返回结果
- 如果所有步骤都成功完成,则返回成功。
- 如果在任何步骤中出现异常,则流程异常结束。
3、读数据流程
APP读数据:APP发起读数据请求- 查询缓存中
KEY是否存在- 存在:
KeyValidity是否有效- 有效:查询缓存返回
- 无效:判断
KeyStatus是否为空- 不为空:回源数据库,不写缓存
- 为空:回源数据库 + 异步调用完整写
keyVersion ++
- 不存在:
- 判断
KeyStatus数据LastWriteSuccess(0):- 拿租约
KeyLeases并设置过期时间 - 回源数据库 +
Lua脚本中:匹配租约,set key
- 拿租约
InWriting(1):回源数据库,不写缓存LastWriteExpired_or_LastStatusLost_or_NeverVisited空- 回源数据库 + 异步调用完整写
keyVersion ++
- 回源数据库 + 异步调用完整写
- 判断
- 存在:
4、强一致保障
- 写前失效/标记:使用
Lua脚本保证在写入数据库之前清除缓存,确保读请求能正确回源数据库。 - 数据库版本控制 (
KeyVersion):采用乐观锁策略,确保写操作基于最新的数据版本。 - 写后填充缓存(带租约):成功写入数据库后,仅持有正确租约的进程可更新缓存,确保缓存数据的准确性。
- 读流程的状态判断:依据
KeyStatus的不同状态,指导读请求正确处理缓存和数据库的交互。


