缓存系统设计:缓存一致性问题

557 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

背景

当数据更新后,缓存也需要跟着更新。不然数据库和缓存的数据就会不一致,那具体应该怎么处理呢?

关键字:缓存一致性,延迟双删

缓存一致性问题

缓存一致性问题有两种。一种是持久层和缓存层的一致性,一种是多级缓存之间的一致性,本篇博客围绕的是前者。

另外,这里的一致性是指的最终一致性。持久层和缓存层是两个服务,如果要保证强一致性的话,就要引入两阶段提交、三阶段提交、Raft等分布式协议,系统的复杂度会提升,服务本身的性能也会受到影响,也就抹杀了缓存带来的好处。所以这里一般不会追求强一致性。

目前,解决缓存一致性问题有四个方式:

  • 先更新DB,后更新缓存
  • 先更新缓存,后更新DB
  • 先删除缓存,后更新DB
  • 先更新DB,后删除缓存

我们看看日常工作中,应该使用哪种。

先更新 DB,后更新缓存

假设初始数据是 V0,现在并行发起了 A、B 两个线程,A 线程把数据更新成 V1,B 线程把数据更新成 V2。 因为是并行发起的,线程调度上如果出现下面这类情况:A 线程先执行,之后 B 线程抢占到了 CPU 先执行完。就会导致缓存不一致。 两个线程全部执行完后,DB中的数据是 V1,但是缓存中是 V2。 image.png 所以出于线程安全考虑,不推荐这种方式。

另外,出于性能考虑,也不推荐这种方式。因为更新操作比较耗时,可能辛辛苦苦更新完,发现根本没人访问,白白浪费了系统资源。

所以,这一方式不行,因为有线程安全问题,性能也不佳。

先更新缓存,后更新 DB

同样,假设初始数据是 V0,现在并行发起了 A、B 两个线程,A 线程把数据更新成 V1,B 线程把数据更新成 V2。 可以看到,这种方式,也会出现一样的问题。两个线程全部执行完后,DB 中的数据是 V1,缓存中的数据是 V2。 image.png

所以,这一方式也不行,因为有线程安全问题,性能也不佳。

先删除缓存,后更新 DB

我们再来看看先删除缓存,后更新 DB 的方式。 假设初始数据是 V0,假设现在有 A、B 两个线程,A 线程把数据更新成 V1,B 线程是要读取数据。 因为是并行发起的,线程调度上如果出现下面这类情况,就会导致缓存不一致。线程B把老的数据加载进缓存,缓存中的数据是 V0,DB是 V1。 image.png

另外,现在大家处于性能和高可用的考虑,都是主从架构,主从架构就会有主从延迟的问题。引入缓存后,会因为主从延迟导致不一致。 如图,线程 A、线程 B 执行完后DB中的数据是 V1,缓存中的数据却仍然是老数据 V0 image.png

所以,这一方式也不行,因为有线程安全问题,也可能会因为主从延迟导致不一致

先更新 DB,后删除缓存

排除三个错误答案后,就剩这一个了,这个应该可以了吧?不,极端情况下,也是会的出现不一致的。

我们假设初始数据是 V0,假设现在有 A、B 两个线程,A 线程把数据更新成 V1,B 线程是要读取数据。 t1 时刻缓存过期,这个时候 B 线程先发起读取,A线程紧跟着发起更新,如果出现下图所示的调度顺序,两个线程执行完后,缓存中的数据是 V0,但是DB中的是会是 V1。 image.png

之所以说是极端场景,是因为要同时满足以下两个条件: 1.缓存到期后,同时来了一个更新请求和读取请求(高并发场景下,这个条件是有概率满足的) 2.读取请求先发起,但执行的比写请求慢(写操作涉及到加锁、删缓存等操作,理论上是比读请求慢的) 尤其是第二个条件,考虑到锁、写逻辑本身的复杂性,要满足是很困难的。

然后,由于主从延迟,也会导致不一致: image.png

所以,这一方式也不行,极端情况下会有不一致,也可能会因为主从延迟导致不一致。

这也不行,那也不行,光提出问题,不给解决方案,这分享了个什么玩意?!

image.png

不要关掉,还有还有!

延迟双删策略

先更新DB,后删除缓存出现缓存不一致的原因,在于删除缓存的操作没有起到作用,缓存仍然是老数据。那我等一会再删是不是就行了?这就是延迟双删的思路。步骤如图: image.png 再考虑以下细节:

  • 延迟删除操作,影响性能怎么办?我们可以异步的搞
  • 延迟删除失败了怎么办?那就重试,重试还失败就报警,然后手动处理
  • 延迟若干秒,那具体延迟多少呢?可以基于主从延迟的时间进行评估,防止主从延迟带来的不一致(极致一点的,可以监听binlog触发第二次的删除操作)

延迟双删策略本质其实是确保数据库更新后,缓存一定会被删除,减少了缓存不一致的时间。 两次删除的时间段内,仍然可能出现会有缓存不一致的问题。比如 t5 时刻,DB 已经更新成了 V1,缓存中的数据仍然是老的 V0。

已数据库 binlog 作为缓存更新的 trigger

延迟双删策略是圈子里比较流行的方式,但动手实现起来就会发现,实现成本是不小的,复杂性提高也会给后续的维护带来难度。

后来,我看见有的团队通过直接监听 binlog 来实现,实现成本比较低,对原有的代码也没有侵入,缓存命中率也会高一些,流程图如下。 image.png

这种方式实现成本低,但是缺点是会因为主从延迟导致缓存不一致,采用前一定要基于业务评估是否可以用这个方案

直接走主库

上述两种优化方案优化的是什么?优化的是减少数据库主从延迟带来的影响,把缓存不一致的时间努力控制在主从延迟的时间之内。大多数业务其实都是能接受的。

那么,什么方案可以保证缓存一致性不受主从延迟影响呢?那就是走主库了。

当缓存到期,直接走主库更新缓存,是非常实用的一种解决缓存一致性的方式。

使用这种方式,我们要考虑一下问题:

  • 接口隔离:如果对一致性要求不是很强的业务,也就没必要这么搞了,可以专门开一个简单的接口供他们使用,然后专门开一个一致性更强的接口给特定的业务方使用,这样可以减少主库的负载。
  • 注意缓存穿透和缓存击穿问题 缓存系统设计:缓存雪崩、缓存穿透、缓存击穿
  • 限流保护:最后一层保护伞,防止直接把数据库打卦

总结

解决缓存一致性问题的四个方式其实都有问题,好在有优化方案。日常工作中,我们要基于业务场景判定,具体使用哪种优化方案。

好,就酱,bye bye。下一篇文章分享 Cache Pattern,点个赞吧,求求你了! QAQ

引用