缓存和数据库一致性更新原则
缓存是一种高性能的内存的存储介质,它通过key-value的形式来存储一些数据;而数据库是一种持久化的存储复杂关系的存储介质。使用缓存和数据库结合的模式就使得软件系统的性能得到了更好的提升(更好的存储介质,更贴近请求的存储距离,比如本地缓存),并且给系统提供了更简便的数据抽象。
缓存和数据库一致性更新的本质就是要保证用户访问缓存和数据库中的数据都是一样的!
。
数据一致性的必要性
那么为什么需要一致性的更新呢?下面我们举一个应用场景来说明一下,具体场景如下图:
A用户下订单时,查询了物品库存缓存,目前库存为1,于是拍了货物之后将缓存删除,缓存中已经没有了库存数据,但是还没有更新数据库的库存数据(库存仍为1);B用户此时(A用户还没更新数据库库存前)查询库存时查到缓存中没有了库存数据就去查询数据库的库存,查询到有库存,于是又更新了缓存的库存数据为1。在B更新了库存缓存之后,A此时才去更新数据库中库存的数据为0。这样,就造成了数据库库存信息和缓存库存信息不一致的情况。
这样类似的场景很多,数据的不一致会导致很多异常的请求从而造成不可预料的后果。那么从删除缓存到更新数据库这个中间过程,为什么会有这么长的时间呢?原因有以下几种可能:
- Java编程语言中,JVM可能在这段时间进行了GC,这样就会导致系统出现短暂的暂停;
- 在虚拟化的环境中,虚拟机可能被运维挂起或者迁移,这样也会造成系统的暂停;
- 如果系统负载过高,服务器上的操作系统会因为上下文的切换,造成这两个操作之间的时间变长,从而导致有其他的请求在此段时间内操作;
- 如果应用程序进行同步磁盘的IO操作,同样也会导致这两个操作之间的操作时间过长;
- 当Redis的内存剩余空间很少的时候,对Redis的操作也有可能很耗时;
- 还有Redis操作时因为网络的原因,导致操作时间过长
常见的缓存访问模式
cache aside模式
这种访问模式,缓存和数据库时相互分开的。这种访问模式又有以下几种:
- 应用服务中有本地缓存
本地缓存的优点是:
- 应用服务访问缓存的速度会很快;
- 缓存是针对每个应用服务的,更加精细;
- 应用服务在维护的时候就可以对缓存进行维护,减少了缓存的运维成本
本地缓存的缺点: - 当有很多应用服务的业务类似或者相同的时候,那么这样的话本地缓存就会显得很臃肿多余。这样其实可以使用缓存中间件来替代本地缓存。
- 本地缓存对于应用服务来说,就会增加维护的成本
- 如果一个应用服务有多个集群,如果这个应用中有本地缓存的话,它的容量不会太大,这样会因为服务访问轮询,对缓存失效的数据进行剔除,这样就会造成数据库的访问压力变大。
- 使用缓存中间件
使用缓存中间件,有两种架构模式,如下图:
这两种缓存架构各有优缺点,左边的就是省去了中间的数据访问的网络开销,吞吐量相对来说比第二种要大,但是它会增加应用服务的数据访问逻辑,对应用来说难度增大;右边的相对来说省去了应用服务的数据读取环节,对应用服务很友好,统一了数据读取的逻辑处理,但是它自己的服务吞吐量成为了一个瓶颈,并且增加了应用服务网络的开销。
Cache-Aside访问模式的操作方式
那么Cache-Aside访问模式具体的操作方式有哪几种呢?
- 缓存的读取方式
一般Redis缓存的读取步骤如下:① 数据访问服务先读取缓存,查找所要查找的数据;②缓存查找成功,则直接返回;查找失败则继续查找数据库;③查找数据库数据成功后,将查找数据写入缓存中从而方便之后相同数据的查找。
它在spring中的具体代码实现如下:
@Cacheable("default", key="#search.keyword")
public Record getRecordForSearch(Search search)
-
缓存的更新模式
当用户要更新数据库的数据时,就需要对于Redis的缓存进行更新,对于缓存的更新有以下几种不同的方式。-
方式1:先更新缓存,再更新数据库
这种方式下,假如用户在更新了缓存数据后,更新数据库失败了,那么就会导致缓存数据和数据库数据不一致的情况。
下面我们来介绍一种此模式下数据不一致的情况:如果在A用户查找一条数据D1(旧数据)时,缓存中没有查找到数据D1;在A没查到缓存之后,B用户需要更新数据D1,B先更新缓存数据D1(新数据);B更新缓存之后,A用户才去数据库查找该数据并且查找到了数据D1(旧数据),将D1(旧数据)写入缓存中;A查找到数据之后,B(因为上边讲的六种缓存和读取数据库中间时间过长的原因)才开始更新数据库,将D1(新数据)更新到数据库中。这样就又造成了数据不一致的状况,请看这种情况的具体图解:
-
方式2:先更新数据库,再更新缓存
这种方式下,数据的更新更加可靠,因为数据库更新成功,数据时持久存在的。但是有一种情况,数据库更新成功,缓存更新失败,下一次用户来读取数据时,先读取缓存读到的是旧数据,还是会出现数据不一致的情况;如果数据库更新成功,即使缓存服务出现异常,其他用户读取同样的数据时会在读取数据库后来更新缓存的数据;
下面我们来介绍一种此模式下数据不一致的情况:
用户A去更新了数据库的数据D1(A修改后的数据)后,用户B又更新了数据库的数据D1(B修改后的数据),接着又更新了缓存中的数据D1(B修改后的数据);在B更新了缓存中的数据D1后,此时用户A才更新缓存中的数据D1(A修改后的数据)。这样一来,B用户覆盖了A用户更新在数据库的数据,A用户覆盖了B用户在缓存中更新的数据,导致了缓存和数据库的数据又出现不一致情况。
-
在spring中,更新缓存的具体实现如下:
@CachePut("default", key="#search.keyword)
public Record updateRecordForSearch(Search search)
-
缓存的删除模式
-
方式1:先删除缓存,再更新数据库
这种方式在正常的情况下,使数据的读取更加及时有效。因为它能在用户更新的第一时间删除掉旧的缓存数据,将最新数据更新到数据库中去,但是它会造成读取数据库的压力变大。如果删除缓存数据不成功,则会造成用户读取的缓存数据为旧数据,这样就会造成数据的不一致情况。
下面我们来介绍一种此模式下数据不一致的情况:
用户A去更新数据D1时,先删除了缓存中的数据D1;然后此时用户B来读取数据D1时,查询缓存中没有D1数据,然后就去数据库查询到了数据D1(旧的数据D1),随后将数据D1重新写入缓存中(旧数据);这时(由于上文提到的6种原因),用户A才去更新数据库的D1数据(A修改后的数据),这样就造成了数据不一致的情况。
-
方式2:先更新数据库,再删除缓存
这种方式能够及时的将最新数据更新到数据库中,能够保证持久化数据更新的及时性。但是用户读取数据时,可能读取的数据不是最新的;如果用户再更新数据时没有删除缓存成功,也会造成缓存中的数据不是最新的数据。
下面我们来介绍一种此模式下数据不一致的情况:
用户A来读取数据D1,因为缓存失效的原因读取Redis缓存没有找到D1数据,然后读取了数据库中的数据D1;此时,用户B来更新数据D1,他先更新了数据库的数据D1(用户B更改后的),然后删除了缓存中的数据;这时,用户A(因为上边提到的6中原因)才对缓存进行写入操作,但它的缓存却是旧数据D1的缓存,这样就又造成了数据不一致的情况。
-
在spring中该模式的实现代码如下:
@CacheEvict("default", key="#search.keyword")
public Record updateRecordForSearch(Search search)
Cache Through模式
Cache Through模式中,缓存和数据库的操作是一个整体。应用需要先经过缓存,缓存来处理读和写的处理并且来代理数据库的读写操作
。
Read Through读取模式
这种模式下缓存和数据库是交互的,在数据读取的时候步骤如下:
- 先读取缓存,如果缓存存在则直接返回数据。
- 如果缓存不存在,则查询数据库中的数据,如果数据库中有则将数据写入缓存中并返回查询结果;如果数据库中也不存在,则返回数据不存在。
Write Through写入更新模式
数据写入或者更新的时候步骤如下:
- 查找缓存中是否有该数据,如果有该数据则更新缓存数据,然后再更新数据库的数据(更新缓存和数据库是一个同步操作)
- 如果该数据缓存不存在,则更新数据库的数据
Cache Through流程图如下:
Write Behind写入更新模式
Write Behind模式,数据写入或更新的时候,先对缓存进行更新,然后通过异步方式
在某个时间点收集所有的写操作批量写入或更新。因为是异步的方式进行数据的更新,所以这里就会使缓存的数据和数据库数据不一致的情况,所以当缓存数据和数据库数据库不一致时,缓存中的数据被标记为"dirty"。它的大概流程如下:
一致性更新目标
一致性更新的目标分为两种:一种是最终一致性
和强一致性
。最终一致性
它的结果是在系统能够容忍的时间内最终达到Redis缓存和数据库数据的一致性,它的性能会更好一点,但这种方式显然会在某一时间段内缓存和数据库的数据是不一致的,需要根据业务需求的容忍度来进行适当采用;强一致性
则保证缓存和数据库的数据是绝对一致的,也就是说缓存和数据库的数据更新操作可以看做一种原子性操作,实现相对比较复杂。这种方案常用在对数据一致性要求较高的场景,它的弊端就是性能没有最终一致性的方案好。
最终一致性的解决方案
-
设置缓存过期时间
最终一致性可以通过设置缓存的过期时间来实现。也就是说在缓存过期以后,用户再次读取数据就从数据库中读取最新的数据,然后再更新到缓存中,从而达到缓存和数据库最终的一致性。为了避免缓存在同一时间过期从而导致雪崩,我们可以根据业务可以容忍的过期时间范围设置随机的缓存过期时间。 -
异步更新
数据访问服务的所有读操作都来读Redis缓存,而所有的写操作都直接通过数据库来进行操作。然后在数据库和缓存之间增加一个数据同步服务
,定时的将数据库的数据同步到Redis缓存中。这里有一个弊端就是,Redis缓存的容量也会随数据库数据量的增大而增大。 -
重试机制
在数据的更新操作中,先进行数据库数据的更新,然后再删除Redis的缓存。如果删除缓存失败,就将删除失败的记录加入到消息队列中,然后消费消息队列中的Redis删除失败的记录进行重试删除操作。这样,就能够保证数据库和缓存数据的最终一致性了。
如果担心数据库中因为添加了这样一个重试删除机制而变得很复杂,可以将这个重试机制交给一个非业务的消息队列组件来实现。
强一致性的解决方案
-
添加第三方事务操作
我们可以把数据库和缓存的操作添加到同一个事务中,从而达到数据更新的原子性。 -
通过分布式锁来实现
我们也可以通过添加分布式锁,将要更新的资源进行一个分布式加锁的操作,实现资源操作的互斥性,等数据库和缓存的数据操作完成后再将此资源锁释放掉。
总结
关于具体的每种模式的实现,我会在后边的文章中继续补充,如果有解释不对的地方,还请大家不吝赐教。谢谢~