目录
引言
对于互联网业务来说,传统的直接访问数据库方式,主要通过数据分片、一主多从等方式来扛住读写流量,但随着数据量的积累和流量的激增,仅依赖数据库来承接所有流量,不仅成本高、效率低、而且还伴随着稳定性降低的风险。鉴于大部分业务通常是读多写少(读取频率远远高于更新频率),甚至存在读操作数量高出写操作多个数量级的情况。因此,在架构设计中,常采用增加缓存层来提高系统的响应能力,提升数据读写性能、减少数据库访问压力,从而提升业务的稳定性和访问体验。
根据 CAP 原理,分布式系统在可用性、一致性和分区容错性上无法兼得,通常由于分区容错无法避免,所以一致性和可用性难以同时成立。对于缓存系统来说,如何保证其数据一致性是一个在应用缓存的同时不得不解决的问题。
需要明确的是,缓存系统的数据一致性通常包括持久化层和缓存层的一致性、以及多级缓存之间的一致性,这里我们仅讨论前者。持久化层和缓存层的一致性问题也通常被称为双写一致性问题,“双写”意为数据既在数据库中保存一份,也在缓存中保存一份。对于一致性来说,包含强一致性和弱一致性,强一致性保证写入后立即可以读取,弱一致性则不保证立即可以读取写入后的值,而是尽可能的保证在经过一定时间后可以读取到,在弱一致性中应用最为广泛的模型则是最终一致性模型,即保证在一定时间之后写入和读取达到一致的状态。对于应用缓存的大部分场景来说,追求的则是最终一致性,少部分对数据一致性要求极高的场景则会追求强一致性。
1、缓存一致性问题是什么
什么是缓存一致性:
缓存一致性是指缓存数据与落地数据一致,缓存一致性是并发场景下使用缓存带来的问题,非并发系统或者不使用缓存,都不会存在缓存一致性的问题。
缓存作为落地数据的备份,作用是以供快速读取热点数据。缓存设计的一个指标就是热点数据命中的概率。
为什么会出现缓存一致性的问题
更新缓存的数据与更新数据库的数据是两个独立的步骤,并发情况下这两个操作的顺序会出现不同的可能,每一种顺序都有可能导致的缓存数据与落地数据的不一致。
我们知道,缓存的工作原理是先从缓存中获取数据,如果有数据则直接返回给用户,如果没有数据则从慢速设备上读取实际数据并且将数据放入缓存。
同步:
异步:
但是,这样的架构是存在问题的,因为数据库与缓存是不同的组件,操作必须有先后顺序,无法像数据库的事务一样满足ACID的特性,所以就会出现数据在缓存中与在数据库中不一致的问题。
缓存一致性问题的表现:同一份数据,缓存中的数据与数据库中的数据不一致,那么上升到业务层面就有着千奇百怪的现象了,比如每次读都是读的老数据,或者每次读是一份过时的数据等。
2、解决方案
对于写入操作:
-
只写DB,不写Cache,依赖下次查询
-
先写DB,(同步/异步)再写Cache
-
先写Cache,再写DB
对于更新操作:
-
先删除Cache,再更新DB
-
先删除Cache,再更新DB,再删除Cache
2.1、只写DB,不写Cache,依赖下次查询
这种是我们常见的设计方案,这种方案只写数据库不写缓存,依赖下一次请求从数据库取出数据再放入缓存。细心的读者已经发现了,这种设计有可能引发新的问题:缓存击穿(复习缓存击穿:DB有数据,Cache无数据,瞬间流量将DB击穿)。
这种可能性是存在的,但是可能性比较小,因为缓存击穿的前提条件是大量请求透过缓存打入数据库层,但是因为我们讨论本次小标题的前提条件是新写入,一般不会有很大的瞬间流量进来。
2.2、先写数据库,再写缓存
这种也是我们常见的设计方案,先写数据库,再写缓存
所以在这种场景下,线程1再去读数据的时候,读数据则优先走缓存,缓存此时值为1,所以读到的值是1,此时线程1懵逼了啊......我刚才不是更新成2了吗?
在面临缓存穿透的时候,我们其中一个解决方案是:查询数据库如果没有数据,则约定一个空数据格式放入缓存中,当再次查询的时候,先查询缓存,发现是一个空数据格式,则直接返回空,避免数据库被瞬间流量击垮。在这个方案下,还有第二个步骤,当数据保存后,需要主动将数据放入缓存,以便下次能够查询。
所以如果你的系统中如果有做缓存穿透的防护,有可能你写完数据库后还需要记得写缓存。
2.3、先写缓存,再写数据库
顾名思义,就是一个写操作,先写缓存,成功后,再写数据库。
那么,如果写数据库失败呢?
如果写失败了,在下次读的时候那么就会读取到脏数据的情况。
如果写数据库失败,有两种方案
-
删除缓存
-
异步任务继续写数据库
这两个方案都有问题!
下面我们挨个分析。
删除缓存。如果删除缓存失败呢?再用异步任务重试删除?那你是否有考虑重试的时候这种短暂不一致的情况?还是说接受这种数据不一致的情况?系统复杂度被你提高了多少?
异步任务继续写数据库。异步任务如果写失败呢?重试?重试也一直失败呢?重试任务落库+定时任务兜底?可以,那么,短暂的数据不一致是否接受?系统复杂度被你提高了多少?
所以,这种先写缓存再写数据库的方案一般不会正式使用,一旦出问题,很难保证数据的最终一致性。
接下来我们讨论一下更新数据的情况
2.4、先删除缓存,再更新数据库
这种方式也比较容易理解,先删除缓存数据,再更新DB的数据,如果删除缓存失败了,直接返回失败;如果更新DB失败了,影响的也只是删除缓存而已,下次查询的时候重新种一次即可。
那如果,会不会因为删除了缓存的数据,从而导致DB被击穿呢?这种可能性是存在的,但是可能性比较小。
再说了,这种方案真的可以解决问题吗?如果在删除缓存后,马上有新线程查询缓存,新线程发现缓存不存在(刚被删),新线程查询数据库后将数据放于缓存,老线程删除数据库成功。此时数据库无数据,缓存有数据。
2.5、先删除缓存,再更新数据库,再删除缓存
基于2.4,在这个基础上可以做出一些改进,那就是延迟双删。
延迟双删的流程: 删除缓存->删除DB->延迟一段时间再删除缓存。
延迟双删能解决大部分的问题,但是在极端情况下,还是会出现问题,造成数据不一致。
这里存在一个问题,延迟一段时间,是延迟多久?1s?3s?这是一个经验值,一般情况是1s~2s。具体取值根据监控实际情况而定。那既然是估计值,那么就一定存在误差,所以必然极端情况下的数据不一致问题。
解决这个问题的方法之前也说了,监听数据库binlog增量数据更新缓存,或者还可以使用异步消息等。