【Redis】数据库和缓存如何保证一致性?

【Redis】数据库和缓存如何保证一致性?

常见方案

通常情况下,我们使用缓存的主要目的是为了提升查询的性能。 大多数情况下,我们是这样使用缓存的:

image-20230207172343410

  1. 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。
  2. 如果缓存没数据,再继续查数据库。
  3. 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。
  4. 如果数据库也没数据,则直接返回空。

这是缓存非常常见的用法。一眼看上去,好像没有啥问题。

但你忽略了一个非常重要的细节:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?

数据库和缓存的数据不一致问题,大都是产生在更新数据时。

在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:

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

一个一个分析,为什么会产生数据不一致问题?

先更新缓存,再更新数据库

操作流程大致如下:问题出现在第四个操作上

image-20230207015344523

如果我成功更新了缓存,但是在执行更新数据库的那一步,服务器突然宕机了,那么此时,我的缓存中是最新的数据,而数据库中是旧的数据

脏数据就因此诞生了,并且如果我缓存的信息(是单独某张表的),而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。

先更新数据库,再更新缓存

先更新数据库,再更新缓存,其实还是存在类似的问题。

image-20230207015455959

只有等到缓存过期之后,才能访问到正确的信息。那么在缓存没过期的时间段内,所看到的都是脏数据。

从上面两张图中,大家也能看出,无论咋样,只要执行第二步时失败了,就必然会产生脏数据。

思考:如果如果如果两步都能执行成功?能保证数据一致性吗?

其实也不能,因为还有Java常考的并发

并发情况下的思考

如果上面的两小节,两步操作都能成功,在并发情况下是怎么样的呢?

image-20230207015536496

换成是先更新数据库,再更新缓存,也是一样的。

image-20230207015545605

在这里可以看到当执行时序被改变,那么就必然会产生脏数据

看到这里,也许学过 Java 锁知识的小伙伴可能会说,咱们可以加锁啊,这样就不会产生这样的问题啦~

在这里确实可以加锁,以保证用户的请求顺序,来达到数据一致性。


虽然加锁确实可以通过牺牲一些性能来保证一定数据一致性,但我还是不推荐更新缓存的方式。

原因如下:

  1. 首先加入缓存的主要作用是提高系统性能。
  2. 其次更新缓存的代价并不低。
    • 复杂场景下:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
    • 可能一些场景是需要这样的。
  3. 缓存利用率问题。一个频繁更新的缓存,它是否会被频繁的访问呢?
    • 一个缓存在很短的时间内,更新10次,20次或者更多,但是实际访问次数只有1、2次,这其实也是一种浪费。
    • 如果采用删除缓存就不会这样,删除了缓存,那么就只会等到有人要使用缓存的时候,才会重新查询数据,放入缓存中。这其实也是懒加载的思想,等到要使用了,再加载。

当然业务场景确实有这样的场景,这么使用也未免不可, 一切都要实事求是,而并非空谈。

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

这种方式在没有高并发的情况下,是可能保持数据一致性的。

image-20230207015640649

如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。

但如果是处于读写并发的情况下,还是会出现数据不一致的情况:

image-20230207015705705

执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的~

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

如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。

image-20230207015806536

和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢

image-20230207015833843

还是会造成数据的不一致性。

但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难

  • 时刻1:读请求的时候,缓存正好过期
  • 时刻2:读请求在写请求更新数据库之前查询数据库,
  • 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。

因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~

一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。

为了确保万无一失,还给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。

「先更新数据库, 再删除缓存」其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值

好在之前给缓存加上了过期时间,所以才会出现过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。

小小总结

1、无论选择下列那种方式

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

如果是在多服务或是并发情况下,其实都有可能产生数据不一致性。

不过在这四种选择中,平常都会优先考虑后两种方式。并且市面上对于这后两种选择,也已经有一些解决方案。

在谈解决方案之前,我们先看看需要解决的问题

  1. 我们要如何保证这两段代码一起执行成功

  2. 【先删除缓存,再更新数据库】在读写并发时,会产生一个缓存旧数据,而数据库是新数据的问题,这该如何解决呢?

    image-20230207020021754

关于数据一致性的补充

简单说,只要使用缓存,那么必然就会产生缓存和数据库数据不一致的问题。

在这首先我们要明确一个问题,就是我们的系统是否一定要做到“缓存+数据库”完全一致性?是否能够接受偶尔的数据不一致性问题?能够接受最长时间的数据不一致性?

强一致性

如果缓存和数据库要达到数据的完全一致,那么就只能读写都加锁,变成串行化执行,系统吞吐量也就大大降低了,一般不是必须达到强一致性,不采用这样的方式。

并且实在过于要求强一致性,会采用限流+降级,直接走MySQL,而不是特意加一层 Redis 来处理。

弱一致性(最终一致性)

一般而言,大都数项目中,都只是要求最终一致性,而非强一致性。

最终一致性是能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。

例如缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。

数据一致性解决方案

如何保证这两段代码一起执行成功

要想第二段代码成功执行,那么重试是必不可少的啦

重试的思想,在学习Java的道路会遇到很多次的哈,

1)引子

像如果学习过Java中锁相关知识的朋友,应该会记得自旋锁和互斥锁~

自旋锁:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,它不用将线程阻塞起来(NON-BLOCKING);

互斥锁:把自己阻塞起来,等待重新调度请求。

自旋锁的思想其实也就是一个while(true)一直重试罢了。

还有使用过openfegin的朋友会知道,它在发送请求时,也包含有一个重试机制,很多高可用的场景,都会加上重试~

2)重试

但是重试存在的问题,也有很多,需要重试几次呢?重试的间隔时间是多少呢?重试再失败该如何补偿呢?在重试的过程中,如果程序宕机,重试也就丢失啦

看到这些你有没有头大,有的话,就对了,认真思考每一个点,你都会发现很多其他的知识,这往往比老老实实的学习更有效。

我们如果仍然像锁机制或者是openfeign的机制一样,采取同步重试的方式的话,是解决不了问题的,如同步重试是可能会失败的,如果一直失败,则会一直占用线程资源,导致其他用户的请求无法正常被执行。

应该很容易想到,同步的对立面就是异步,异步重试,交由别人来做这件事情,自己不用去管这件事情即可。

谈到异步,并且是第三方来做的,最快想到的无疑就是消息队列啦~

3)消息队列-异步重试

如果学习过消息队列的朋友,应该很快就能get到,或者自己思考到这一点;

如果没有学习过的话,我觉得学习消息队列还是非常有必要的一件事情。

我们可以把第二步操作交由消息队列去做,达到一个异步重试的效果。并且引入消息队列来实现,代价并非想象中的那么大。

当然大家也会说,如果发送消息也失败呢?

有这种可能,但真的不算高,另外消息队列自身是很好的支持高可用的。

  1. 首先消息队列在高并发的场景下,可以毋庸置疑的说是一个非常重要的组件啦,所以引入消息队列以及维护消息队列,其实都不能算是额外的负担。
  2. 其次消息队列具有持久化,即使项目重启也不会丢失。
  3. 最后消息队列自身可以实现可靠性
    • 保证消息成功发送,发送到交换机;
    • 保证消息成功从交换机发送至队列;
    • 消费者端接收到消息,采用手动ACK确认机制,成功消费后才会删除消息,消费失败则重新投递~

图:

image-20230207020304659

4)定时任务-异步重试

使用定时任务重试的具体方案如下:

当用户操作写完数据库,但删除缓存失败了,需要将用户数据写入重试表中。如下图所示:

image-20230207172605179

在定时任务中,异步读取重试表中的用户数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则我们需要在重试表中记录一个失败的状态,等待后续进一步处理。

image-20230207172644044

5)Canal 订阅日志实现

消息队列虽然已经比较简单,但是仍然要手动的进行代码的编写,以及写一个消费者来进行监听,可以说还是比较麻烦,每个地方都还要引入消息队列,发送一个消息~,有没有办法省去这一步呢?有的勒,偷懒的人大有人在勒

现有的解决方案中,可以使用 alibaba 的开源组件 Canal,订阅数据库变更日志,当数据库发生变更时,我们可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。

当然Canal 也是要配合消息队列一起来使用的,因为其Canal本身是没有数据处理能力的。

相应的流程图大致变成下列这样:

image-20230207020333206

优点:

  • 算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由 Canal来发送。

缺点:

  • 引入了Canal中间件,需要一定的维护成本,需要实现高可用的话,也需考虑集群等,架构也会进一步变得复杂。

这套方案中业务接口确实简化了一些流程,只用关心数据库操作即可,而在binlog订阅者中做缓存删除工作。

但如果只是按照图中的方案进行删除缓存,只删除了一次,也可能会失败。

如何解决这个问题呢?

答:这就需要加上前面聊过的重试机制了。如果删除缓存失败,写入重试表,使用定时任务重试。或者写入mq,让mq自动重试。

在这里推荐使用mq自动重试机制

image-20230207172808220

在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试5次。如果有任意一次成功,则直接返回成功。如果重试5次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。

延时双删策略

问题:【先删除缓存,再更新数据库】在读写并发时,会产生缓存是旧数据,而数据库是新数据的问题,这该如何解决呢?

image-20230207020406023

(图片说明:上图为产生数据不一致性的情况)

image-20230207020425017

解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性,这也就是市面上给出的主流解决方案–延时双删

延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

补充:并发问题的解决,最常用的方式无疑就是加锁,那到底是加什么锁呢?在分布式系统中,对于并发,加的无疑就是分布式锁。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小颜-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值