大家好,我是李哥。

上次我们讨论了在分布式系统下的缓存架构体系,从浏览器缓存到客户端缓存,再到CDN缓存,再到反向代理缓存,再到本地缓存,再到分布式缓存。整个链路中有非常多的缓存。

在整个缓存链路,存在各种各样的问题,常见的问题有缓存穿透、缓存击穿、缓存雪崩、缓存数据一致性问题等。不常见的问题有缓存倾斜、缓存阻塞、缓存慢查询、缓存主从一致性问题、缓存高可用、缓存故障发现与故障恢复、集群扩容收缩、大Key热Key等等。

今天我们来聊聊缓存一致性问题,对于这个问题,不管在工作中还是面试中,都是一个非常常见的问题。

今天我们的主题是:缓存一致性问题

老规矩,上大纲:

缓存系列:缓存一致性问题的解决思路

1、缓存一致性问题是什么

我们知道,缓存的工作原理是先从缓存中获取数据,如果有数据则直接返回给用户,如果没有数据则从慢速设备上读取实际数据并且将数据放入缓存。就像这样:

缓存系列:缓存一致性问题的解决思路
缓存系列:缓存一致性问题的解决思路

但是,这样的架构是存在问题的,因为数据库与缓存是不同的组件,操作必须有先后顺序,无法像数据库的事务一样满足ACID的特性,所以就会出现数据在缓存中与在数据库中不一致的问题

缓存一致性问题的表现:同一份数据,缓存中的数据与数据库中的数据不一致,那么上升到业务层面就有着千奇百怪的现象了,比如每次读都是读的老数据,或者每次读是一份过时的数据等。

2、解决方案

对于写入操作:

  1. 只写DB,不写Cache,依赖下次查询
  2. 先写DB,(同步/异步)再写Cache
  3. 先写Cache,再写DB

对于更新操作:

  1. 先更新DB,再删除Cache
  2. 先删除Cache,再更新DB
  3. 先删除Cache,再更新DB,再删除Cache

2.1、只写DB,不写Cache,依赖下次查询

这种是我们常见的设计方案,这种方案只写数据库不写缓存,依赖下一次请求从数据库取出数据再放入缓存。细心的读者已经发现了,这种设计有可能引发新的问题:缓存击穿(复习缓存击穿:DB有数据,Cache无数据,瞬间流量将DB击穿)。

这种可能性是存在的,但是可能性比较小,因为缓存击穿的前提条件是大量请求透过缓存打入数据库层,但是因为我们讨论本次小标题的前提条件是新写入,一般不会有很大的瞬间流量进来。就算有,那也不属于本文缓存一致性问题的讨论范围了。

缓存系列:缓存一致性问题的解决思路

2.2、先写数据库,再写缓存

这种也是我们常见的设计方案,先写数据库,再写缓存,上面的图也有体现这一点。

缓存系列:缓存一致性问题的解决思路

所以在这种场景下,线程1再去读数据的时候,读数据则优先走缓存,缓存此时值为1,所以读到的值是1,此时线程1懵逼了啊……我刚才不是更新成2了吗?

缓存系列:缓存一致性问题的解决思路

大家还记得之前的一篇文章《 缓存击穿 》,在面临缓存穿透的时候,我们其中一个解决方案是:查询数据库如果没有数据,则约定一个空数据格式放入缓存中,当再次查询的时候,先查询缓存,发现是一个空数据格式,则直接返回空,避免数据库被瞬间流量击垮。在这个方案下,还有第二个步骤,当数据保存后,需要主动将数据放入缓存,以便下次能够查询。

所以如果你的系统中如果有做缓存穿透的防护,有可能你写完数据库后还需要记得写缓存。

2.3、先写缓存,再写数据库

顾名思义,就是一个写操作,先写缓存,成功后,再写数据库。

那么,如果写数据库失败呢?

缓存系列:缓存一致性问题的解决思路

如果写失败了,在下次读的时候那么就会读取到脏数据的情况。

如果写数据库失败,有两种方案

  1. 删除缓存
  2. 异步任务继续写数据库

这两个方案都有问题!

缓存系列:缓存一致性问题的解决思路

下面我们挨个分析。

删除缓存。如果删除缓存失败呢?再用异步任务重试删除?那你是否有考虑重试的时候这种短暂不一致的情况?还是说接受这种数据不一致的情况?系统复杂度被你提高了多少?

异步任务继续写数据库。异步任务如果写失败呢?重试?重试也一直失败呢?重试任务落库+定时任务兜底?可以,那么,短暂的数据不一致是否接受?系统复杂度被你提高了多少?

所以,这种先写缓存再写数据库的方案一般不会正式使用,一旦出问题,很难保证数据的最终一致性。

缓存系列:缓存一致性问题的解决思路

接下来我们讨论一下更新数据的情况。

2.4、先更新数据库,再删除缓存

缓存系列:缓存一致性问题的解决思路

这种情况下,你可能想说这是你们现在正在使用的技术方案,但是我想说是这个方案是存在问题的,别慌反驳,大家看看这张图:

缓存系列:缓存一致性问题的解决思路

首先,这种技术方案,确实是我们在日常开发中是最常见的,但是作为开发的我们,也应该明白它存在什么问题,以及能够有哪些应对措施,下面谈谈我个人对这个解决方案的一些改进。

  1. 延迟删除缓存。
  2. 先删除缓存,再更新数据库。
  3. 延迟双删策略。
  4. 定时任务增量/全量更新缓存数据。
  5. 监听数据库binlog增量数据更新缓存。

方案一:延迟删除缓存。这种改进方案的优点是能有效的防止数据不一致,但不能够完全防止。为什么说不能够完全防止呢?因为查询数据的那个线程有可能也延迟了一定时间才去更新缓存。这个改进方案的缺点是无法严格的控制时间,这个时间需要开发人员根据经验给出,第二个缺点是延迟行为有可能让系统引入一些新的依赖,你可能想说是否可以用jdk自带的延迟队列呢?可以,但是如果延迟期间,服务重启了,怎么处理?第三个缺点是可能导致系统的复杂度提高、维护成本提高、可读性降低。

方案二:先删除缓存,再更新数据库。这个方案我们下面单独细说,这里咱不介绍。

方案三:延迟双删策略。这个方案我们下面单独细说,这里咱不介绍。

方案四:定时任务增量/全量更新缓存数据。这种解题方式是最直接最暴力的,它的优点是能够保证数据的最终一致性。它的缺点有:可能需要引入分布式调度任务(如果不引入则又存在多实例同时更新的情况,纯属浪费资源,或者加分布式锁)、如果是增量同步的话则需要有一种方式方法区分出什么数据才是增量数据,这种方式可能有业务侵入和性能影响、如果是全量同步的话数据量太多又太耗时,严重的话可能导致任务阻塞以及加重数据不一致的问题。经过分析,优点很明显,一般情况下,异步主动的对缓存数据更新是一种不可采取的方式。但是也会有一些业务场景,数据变更不太频繁,但是访问非常频繁,并且更新数据更新时间已经同步更新缓存了,再使用这种异步将DB数据载入缓存作为兜底的策略是可行的。

方案五:监听数据库binlog增量数据更新缓存。

这种方式让开发不再关注缓存层,专注于业务开发,只关注于数据库,而不用关心缓存。

缓存系列:缓存一致性问题的解决思路

可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。

2.5、先删除缓存,再更新数据库

这种方式也比较容易理解,先删除缓存数据,再更新DB的数据,如果删除缓存失败了,直接返回失败;如果更新DB失败了,影响的也只是删除缓存而已,下次查询的时候重新种一次即可。

那如果,会不会因为删除了缓存的数据,从而导致DB被击穿呢?这种可能性是存在的,但是可能性比较小。

再说了,这种方案真的可以解决问题吗?如果在删除缓存后,马上有新线程查询缓存,新线程发现缓存不存在(刚被删),新线程查询数据库后将数据放于缓存,老线程删除数据库成功。此时数据库无数据,缓存有数据。

2.6、先删除缓存,再更新数据库,再删除缓存

基于2.5,在这个基础上可以做出一些改进,那就是延迟双删。

延迟双删的流程: 删除缓存->删除DB->延迟一段时间再删除缓存。

延迟双删能解决大部分的问题,但是在极端情况下,还是会出现问题,造成数据不一致。

这里存在一个问题,延迟一段时间,是延迟多久?1s?3s?这是一个经验值,一般情况是1s~2s。具体取值根据监控实际情况而定。那既然是估计值,那么就一定存在误差,所以必然极端情况下的数据不一致问题。

解决这个问题的方法之前也说了,监听数据库binlog增量数据更新缓存,或者还可以使用异步消息等。

3、总结

在实际的工作中,或者在面试中,如果有人问你各种没有场景化的纯粹的技术问题,比如说有人看了上面的种种方案还是会提出疑问,你的这些方案仍然存在数据不一致的问题啊,那怎么解决呢?

技术是为了业务服务的,所以,在所有不同的业务场景下,对于技术的选择,和方案的设计都是不同的。我们需要反问他,具体的业务场景是什么?我们需要根据具体的业务场景来选择最合适的技术方案。

我们要明确的是:一个技术方案不可能cover住所有的场景,脱离业务的技术都是刷流氓。

缓存系列:缓存一致性问题的解决思路