【Redis】缓存更新策略

一、缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,并且由于缓存会造成数据不一致的情况,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

在企业中常见的缓存更新策略主要有三种。

内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式),这种机制默认就是开启的,不用我们管。

在一定程度上也是可以保证数据的一致性,当内存不足时,它把一部分数据给淘汰了,拿着一部分数据在redis中就没有了,这个时候如果用户来查询这部分数据,按照我们以前写的业务逻辑,如果没命中就会去查询数据库,然后把数据库写入缓存,此时就一支了。

但是这种一致性首先不是我们能控制的,淘汰的时候它淘汰的是哪一部分数据?什么时候淘汰?我们都不知道,很有可能这些数据很长一段时间都不会被淘汰,因为内存都一直很充足。那这样一来这些不被淘汰的数据,每次来查询查到的就是旧的,此时就无法保证数据的一致性,因此这种机制的一致性是比较差的。不过好处是维护成本为无。


**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,数据一旦删除,下次用户来查未命中就会更新缓存了,从而确保一致性。它的一致性的强弱其实取决于你设置时间的长短,如果你的时间设置的短一点,例如30分钟,这样一来更新实际还是不错的。如果你设置时间长一点,例如一天,那么它的更新频率相对就会低一点,因此这个是我们可以控制的。

但是它也不是完全的一致,例如设置30分钟,如果在这30分钟内数据库发生了变化,那此时redis数据与数据库数据还是不一致的,因此它不是一种强的一致,因此一致性比较一般,但是肯定好于内存淘汰机制。

从维护成本来考虑,其实这种维护成本也不是很高,比较低,因为你只需要在原来设置缓存的那个逻辑上添加一个过期时间即可。


**主动更新:**简单来讲就是我们自己编写业务逻辑,每当我们修改数据库时,我们就将缓存也给它改了,这样一来就能确保两者一致了,这种它的一致性相对来讲是比较好的。注意是好,而不是完全一致,因为没有任何人能确保你的程序一定是健康运行的,总会有些意外。

但是它的成本相对来讲就比较高了,因为需要自己编码。

image-20240525175556031

那么我们该如何选择呢?其实这个就是看业务场景了。

  • 低一致性(长久不发生变化)需求:使用内存淘汰机制。例如店铺类型(美食、奶茶等),它也有可能变化,但变化的可能性极低,这个我们就可以完全采用默认的内存淘汰机制,你实在不放行,那就加一个超时时间就行了。
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,万一主动更新失败了,将来超时的时候还可以将它剔除,也能保证一致性。例如店铺详情查询的缓存、优惠券缓存。

二、数据库缓存不一致解决方案

刚刚说过,主动更新是最复杂的,那它到底复杂在哪里呢?

它的业务实现目前企业中有三种模式。

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后,将缓存也给更新了,也称之为双写方案。这个对于调用者来讲有些复杂,但是你是可以人为去控制它的。

Read/Write Through Pattern : 将缓存与数据库整合为一个服务,这个服务你不用管它底层到底用的是什么,对外来讲它就是一个透明的服务,这个服务因为内部同时处理了缓存数据库,所以它是可以保证两者的处理,同时成功和失败的,因此它可以来维护两者的一致性。

所以由它来维护两者的一个一致性,我们作为调用者来讲,假设我们今天要新增一个数据了,它可以保证缓存里有,数据库里也有,别的你就不用管了,你直接调即可,调用者无需关心一致性的问题,这样一来我们作为调用者就非常简单了。

但是它最大的问题就是:你想维护一个这样的服务是比较复杂的,并且市场上你想找到一个现成的这样的服务,可能也不太好找,因此你要开发起来成本还是挺高的。

Write Behind Caching Pattern 写回 :这种方式和方案二有类似之处,它们的作用都是为了简化调用者的开发,调用者无需关心一致性。区别在于:方案二是由服务来控制的,我们调用者不知道自己到底是操作的缓存还是数据库;而作为方案三来讲,我们调用者明确的知道我只操作缓存,不关心数据库,并且也不需要处理数据库,也不需要处理一致性,我增删改查全部在缓存里做。

此时缓存中就是最新的数据,而数据库中的就是旧的数据。

那么此时谁来保证一致性?有一个独立的线程,它会帮我们异步将缓存数据持久化到数据库,保证最终一致。并且这个写是异步的,它会隔一段时间就写入一次,这样有什么好处呢?

比方说我们在缓存中做了十次写操作,而在这十次结束后,刚好轮到了我们异步更新的操作,那么它会将这十个操作合并成一次写操作,往数据库写,做一个批量处理,这样就可以将多次往数据库中写合并成一次写了,此时它的效率也就得到了提升。

还有一种情况:在它的两侧异步更新之间,如果我们对缓存中的某一个key做了n次更新,事实上只有最后一次更新有效,那么我们在做异步更新的时候,我们只需要将最后一次的结果写入数据库就行了,这就是异步机制的好处,效率比较高。

那它最大的问题是什么呢?

第一:你要维护这样的异步的一个任务是比较复杂的,你需要实时监控缓存中的数据的变更。

第二:这里的一致性是难以保证的,我们先做的缓存,然后异步更新,如果我缓存已经执行了上百次操作了,这个时候还没有触发异步更新,那么在这一段时间内,我们的缓存数据和数据库完全是不一致。

第三:而且此时缓存如果出现了宕机,缓存大多数是内存存储,一旦宕机就会导致数据丢失,这个时候就等于这段数据就完全丢失了。

因此它的一致性和可靠性都会存在一定问题。

1653322857620


三、数据库和缓存不一致采用什么方案

综上所述,方案一虽然需要调用者自己编码,但是相对来讲可控性更高一点,因此一般情况下在企业中用的最多的就是这个方案 Cache Aside。由于这种方案需要调用者自己编码,因此在编码的过程中我们还是要去考虑三个问题

  • **删除缓存还是更新缓存?**因为我们需要在更新数据库的同时去更新缓存,这里就有两种

    • 更新缓存(数据库做什么样的更新,缓存也就做什么样的更新):每次更新数据库都更新缓存,如果我对数据库做了上百次操作,那么我就需要对缓存也做上百次操作,但是在这上百次操作的过程中,没有任何一个人来做查询,也就是写多读少,那么此时对缓存的无效写操作较多
    • 删除缓存(对数据库做了更新,但是我不更新缓存,而是直接删缓存):更新数据库时让缓存失效,假设我更新了一百次,只删一次就够了,而在这一百次之间,如果没有任何人来访问,那么我也不会去更新缓存,有人来访问时再更新缓存,这样就等于是一种延迟的加载模式,因此这种方案写的频率会更低,有效更新会更多。

    因此我们一般会选择删除缓存的方案。

  • **如何保证缓存与数据库的操作的同时成功或失败?**也就是保证两个操作的原子性。

    假设我更新数据库的时候,删缓存失败了,这不就没有意义了,因此你需要同时保证它同时成功和失败,那怎么保证同时删除成功和失败呢?

    • 如果是单体系统,例如现在的案例,因为单体系统缓存与数据库是在一个项目中,甚至是在一个方法里,因此可以将缓存与数据库操作放在一个事务,利用事务本身的特性,就能保证同时成功和失败了。
    • 分布式系统,此时缓存和数据库很有可能是两个不同的服务,那你怎么保证这两个东西的一致性?这个就不得不用到类似TCC等分布式事务方案

如果此时我们能保证这种原子性了,那是不是就意味着我们这个更新就一定能成功,但还不是。

最后我们还需要考虑线程安全的问题,是有缓存操作、数据库操作两个操作,此时在多线程并发的情况下,这两个操作之间可能会有多个线程同时你执行、我执行这样来回穿插,这样谁先操作、谁后操作就会带来线程不安全的问题。

  • 那我们应该先操作缓存还是先操作数据库?

    这个其实不太好说,其实两个都可以

    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存

具体选哪个,我们需要对比之后再来看。


1)先删除缓存,再操作数据库

缓存和数据库中的数字分别代表它们现在存储的值,最开始值都为10。然后有两个线程并发的来执行,我们知道线程的执行往往是无法控制的,CPU在这两个线程间做切换,你也不知道会先执行谁。这时候假设线程一要更新数据,因此此时要更新缓存那么就需要先删缓存再更新数据,将数据库更新成20,到这缓存操作其实就结束了,结束后此时不管是谁,任意一个人来查询都会出现一个情况:缓存未命中,缓存未命中就会查询数据库,此时数据库值为20,所以此时它得到的就是20,得到20后它就将数据写到缓存,此时缓存中的值也变成20了,此时两者就一致了,这是正常的情况。

image-20240525195431313

接下来讨论一下异常的情况,假设缓存和数据库现在存储的都是10,所谓异常情况指的就是:在线程执行的过程中,另外一个线程它也进来执行了,因为我们没有加锁,所以它们是可以并行执行的,此时就会遇到左图线程不安全的问题:由于更新的业务比较复杂,此时线程2趁虚而入了,它要去查询,此时线程1已经将缓存删了,因此缓存显然是未命中的,此时它就会去查数据库。又因为它是趁虚而入的,此时数据库还没有完全更新,它依然是旧的值,所以它查到的就是这个旧的值。它查到旧的值10,然后将10写入缓存,当它写入缓存的时候,写的就是这个旧的值。之后线程1终于执行更新的操作了,执行这个更新操作的时候把值改成20。此时数据库数据就与缓存数据产生了不一致的情况,这就是线程安全问题产生的原因。

这种线程安全问题发生的概率还是挺高的,为什么呢?因为你是删缓存,然后更新数据库。删缓存很快,但是更新数据库很慢,你首先要组织数据,然后去更新,而且这是写操作。而线程2是查询缓存,然后直接写缓存,写缓存因为写的是redis,因此写操作往往是非常快的,微妙级别的,因此它跟写数据库相比,肯定是写缓存快。

image-20240525200525049

2)先操作数据库,再删除缓存

缓存中的数据和数据库中的数据初始值同样还是10,目的是为了更新值为20。

同样先来看正常情况,假设线程2它去完成更新,它要完成更新是需要先操作数据库再缓存,如下图。

image-20240525200706352

但是它肯定也会出现两个线程穿插的情况,由于是先更新数据库,然后再删除缓存,因此就算线程1先来查询缓存,此时它查的就是缓存中的数据,没什么好说的。

这里我们假设一种特殊的情况,恰好缓存失效了。失效了以后,此时一定未命中,未命中就需要去查询数据库了,数据库现在是10,查到10后,因为未命中,此时就需要将数据写入缓存,但是正在此时另外一个线程2进来,线程2更新数据库,数据库值为20,然后删缓存,虽然缓存已经失效了,因此这个删等于没删。紧接着线程2结束了,此时线程1开始写缓存,此时一写缓存不得了,因为之前查的是旧数据,此时线程2更新了线程1也不知道,因为它此时已经查完了数据库,此时写入缓存的就是旧数据10,两者出现数据不一致。

这种情况发生有两个条件:1、有两个线程并行执行;2、线程1来查询的时候,恰好缓存失效了,恰好失效的同时,它查完了数据库要去写缓存,查缓存和写缓存往往是微妙级别的,就在这个微妙的范围内,突然来了一个线程,它先去更新数据库,更新数据库往往是比较慢的,更新完后又去删缓存。

由此可见,这种情况出现的可能性显然不高。

image-20240525200834920

3)总结

这三种都有可能出现线程不安全的问题,但是方案二相对来讲出现的可能性更低。

就算方案二的特殊情况真的发生了,我们将来加上一个超时时间就行了,万一写了旧数据也没关系,过一段时间就会清除。

因此从线程安全的概率角度来分析,最终胜出的就是方案二:先操作数据库,再删除缓存

1653323595206

四、总结

缓存更新策略有三种:内存淘汰超时剔除主动更新,具体如何选择,需要看业务需求:

1、低一致性需求:使用Redis自带的内存淘汰机制,最多加一个超时更新

2、高一致性需求:主动更新,并以超时剔除作为兜底方案,这样才能在发生意外的时候保证数据的恢复。

主动更新又有三种:Cache AsideRead/Write Through写回,但是后两种实现起来相对来讲比较复杂,而且也找不到比较好的第三方组件,因此一般情况下,企业里选择的都是 Cache Aside这猴子那个方案。

Cache Aside 又有好多问题要考虑。通过三个问题的讨论,最终的缓存的最佳实践的方案就确定下来了:

(所谓最佳实践,就是使用过程中总结的经验,最好的一种使用方式)

  • 读操作:
    • 缓存命中则直接返回
    • 缓存未命中则查询数据库,并写入缓存,设定超时时间,作为兜底方案
  • 写操作:
    • 先写数据库,然后再删除缓存,这种发生线程安全问题的可能性最低
    • 要确保数据库与缓存操作的原子性
  • 24
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值