Redis(六) 数据库和缓存的一致性问题

2022年9月19日08:17:34

参考:
缓存和数据库一致性问题
如何保持mysql和redis中数据的一致性
Redis缓存与数据库一致性解决方案

一 先上结论

一旦决定使用缓存,那必然要面临一致性问题,任何一种解决方案都无法保证绝对意义上的数据一致性。
“性能和一致性就像天平的两端,无法做到都满足要求。”

一致性问题需要 具体场景,具体分析。

缓存和数据库的一致性问题不是一成不变的,分析一致性问题,需要考虑以下3点:
缓存数据库操作的原子性(成功执行)并发缓存击穿

缓存有两种模式:读写缓存、只读缓存。
读写缓存:若要对数据进行增删改,需要在Cache进行。 同时根据采取的写回策略,决定是否同步写回DB。
只读缓存:若要对数据进行增删改,只需在DB进行。同时根据缓存更新策略,决定何时更新或者删除。
本文仅针对只读缓存,结合具体的场景进行讨论。

二 四种常用的缓存更新策略:

对于读操作,加了缓存后的读操作流程,此过程不存在不一致问题:
Redis(六) 数据库和缓存的一致性问题
对于新增操作,直接写到DB,不操作Cache。
对于删除操作,直接双删。先删DB再删Cache,或者先删Cache再删DB,同时保证两个操作的成功执行。
对于写操作,有四种常用的缓存更新策略,分别是:
先更新数据库再删除缓存
先删除缓存再更新数据库
先更新数据库再更新缓存
先更新缓存再更新数据库

三 写操作的缓存更新策略分析

由于机器卡顿、网络延迟、服务器距离不一致等原因,以下各情景都是有可能出现的。

一 双更策略:

第一个问题-无法保证数据库和缓存读操作的原子性:
当更新操作无法成功执行时,无论先更新哪一个,但凡第二步操作发生异常,就会导致数据不一致,对业务造成影响。

第二个问题-并发:(缓存脏数据)
场景:两个写操作线程并发更新同一条数据,此时可能会由于执行时序发生错乱,而导致数据不一致问题。
以先更DB再更Cache的策略为例,写操作A和写操作B先后更新key1,在操作A未完成时操作B便开始执行,假如执行顺序如下:A先更DB中的key1为a,B更新DB中的key1为b,B更新Cache中的key1为b,A更新Cache中的key为a。最终,DB中key1为b,Cache中的key1为a,数据不一致。
Redis(六) 数据库和缓存的一致性问题

第三个问题-缓存击穿:不存在

其他问题:
双更策略的缓存利用率不高,每次数据发生变更,都会更新缓存,但是缓存中的数据不一定会被立即读取,这就会导致缓存中可能存放了很多非热点数据,浪费缓存资源。(最主要的原因!)
而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列计算得出一个值再写到缓存中,对于非热点数据的一系列计算,还会造成机器性能的浪费

结论:因此,在大多数场景下,都不建议采用双更策略。

二 删除策略:

第一个问题-无法保证数据库和缓存读操作的原子性:
同上分析,无论先更新还是先删除,但凡第二步操作异常,都会导致数据不一致。

第二个问题-并发:需要分别考虑
1 先删除缓存再更新数据库
场景:写操作和读操作并发执行。
两个条件:先写再读,更新DB的时间 > 读DB+写Cache时间。
写操作A更新key1尚未完成时,读操作B查询key1,假如执行顺序如下:A先删了Cache中的key1,正在更新DB,此时,操作B从缓存中读key1不存在,去DB中读旧的key1,再写入Cache旧的key1,然后操作A更新DB的操作完成。此时,DB中是新值,Cache中是旧值,数据不一致。
2 先更新数据库再删除缓存
场景:写操作和读操作并发执行
三个条件:缓存中的key刚好被清除(也许是失效、也许是写操作执行的删除),先读再写,读DB+写Cache > 写DB+删Cache
缓存中的key刚好被清除,读操作查询key1未完成时,写操作B更新key1,假如执行顺序如下:A从缓存中读key1不存在,A去DB中读旧的key1,此时,B开始更新key1,B先将新的key1写入DB,B去Cache中删旧的key1发现不存在,此时,A操作再把从DB读出的旧的key1写入Cache。此时,DB中是新值,Cache中是旧值,数据不一致。
Redis(六) 数据库和缓存的一致性问题

后者这种场景出现的概率很低,尤其是第三个条件发生的概率其实是非常低的。因为,写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。

第三个问题-缓存击穿
任何删除Cache的行为,在高并发场景下,都有可能导致缓存击穿。可以采用读操作互斥、定时更新的方案,缓解缓存击穿问题。

结论:大多场景下,建议采用“先更新数据库,再删除缓存策略”,可以最大程度上保证数据一致性。
注:后删策略也是Spring-cache中使用的更新策略,Cache Aside Pattern旁路缓存模式中的更新策略。

三 延时双删策略:

用于解决后删策略产生的数据不一致问题,极端情况下的并发读写操作。
缓存都变成了旧值,解决这类问题最有效的办法就是,把缓存删掉。
但是不能立即删,而是需要延迟删,删除操作放入延迟队列中,这就是业界给出的方案:
缓存延迟双删策略,即在线程 A更新完数据库、 删除缓存之后,先休眠一会,再删除一次缓存

延迟时间要大于线程 B 读取数据库 + 写入缓存的时间。但是,这个时间在分布式和高并发场景下,其实是很难评估的。凭借经验大致估算这个延迟时间,只能尽可能地降低不一致的概率,极端情况下,还是会发生不一致现象。

所以实际使用中,还是建议采用先更新数据库,再删除缓存的策略。

其他策略:read-through write-through write-behind

四 失败重试

目的:针对后删策略中,更新操作时删除缓存失败的问题,用于保证缓存操作执行成功。(操作的原子性)

同步重试:

只要执行失败,就一直重试,直到删除成功。
缺点:立即重试可能仍会失败,重试多少次为止,持续占用线程,影响redis服务器为别的请求提供服务。

异步重试:

失败后把重试请求写入消息队列;

借助消息队列:

为了避免第二步执行失败,我们可以把操作缓存的请求,直接放到消息队列中,由消费者来操作缓存。

为什么一定要写入消息队列?
在执行失败的线程中一直重试时,如果项目重启了,那这次重试请求就会丢失,这条数据就会一直不一致。

消息队列的优势:
消息队列保证可靠性-写到队列中的消息,成功消费之前不会丢失,重启项目也不担心;
消息队列保证消息成功投递-下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者,符合重试的场景。

需要考虑的问题:写消息队列的操作也可能会失败,引入消息队列会增加维护成本。
操作缓存和写消息队列,同时失败的概率其实是很小的;项目中一般都会用到消息队列,维护成本并没有新增很多。引入消息队列来解决这个问题,是比较合适的。
此时架构模型如下图所示:
Redis(六) 数据库和缓存的一致性问题

订阅数据库变更日志:

Binlog-数据库变更日志
此时,更新数据时,只需修改数据库,无需操作缓存。根据订阅的变更日志异步操作缓存。

拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再去删除对应的缓存
订阅数据库变更日志,目前也有比较成熟的开源中间件,例如阿里的 canal。

使用这种方案的优点在于:
无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
自动投递到下游队列:canal 自动把数据库变更日志投递给下游的消息队列
缺点:需要投入精力去维护 canal 的高可用和稳定性。
此时架构模型如下图所示:
Redis(六) 数据库和缓存的一致性问题
总结:保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做

五 场景方案

业务体量小,且对数据一致性要求不高的业务场景:
可以考虑遇到写操作只更新DB不更新Cache,然后定期更新缓存的策略。
优点:所有读请求都可以直接命中缓存,不需要再查数据库,性能高。
缺点:因为是定时刷新缓存,缓存和数据库存在不一致,程度取决于更新缓存的频率

业务体量很大的场景,如何解决缓存利用率和一致性问题:
缓存利用率:缓存中只保留最近访问的热点数据。写入缓存中的数据,都设置失效时间。
缓存更新策略:先更新数据库,再删除缓存
失败重试策略:异步更新,借助消息队列 或 订阅变更日志

案例:使用虚引用优化redis与mysql数据同步的锁粒度问题降低在更新缓存时的锁粒度使用队列实现redis数据一致性

  • 作者:Cedar_Guo
  • 原文链接:https://blog.csdn.net/weixin_43741711/article/details/123557071
    更新时间:2022年9月19日08:17:34 ,共 3454 字。