Redis系列之——缓存一致性问题

前言

        通常,在高并发请求的情况下,我们的MySQL数据库会因为系统性能的瓶颈(磁盘IO速度慢,QPS限制低),造成响应速度变慢,严重的情况下,请求过多可能会导致生产环境的MySQL宕机,这个后果很严重。

        为了避免上述问题的出现,开发者们会使用redis作为数据库的缓存来保护MySQL,这样,在客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用在去查询数据库,从而减轻数据库的压力,提高服务器的性能。

        不过由于数据存储在了两个地方,redis实例中和mysql实例中,开发者们需要面对缓存与数据库之间的同步问题。常见的挑战是如何确保缓存中的数据与数据库中的数据保持一致,尤其是在数据更新时。如果没有合适的策略,可能会导致缓存与数据库之间的数据不一致,进而影响系统的准确性和稳定性。

通用的缓存策略

通用的的缓存模式有三种

  • Cache Aside(旁路缓存)
  • Read/Write Through(读写穿透)
  • Write Behind Caching(异步缓存写入)

        介绍到的这三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。 

Cache Aside Pattern(旁路缓存模式)

        现在介绍的这种是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景

在该模式下,由缓存调用者(即开发人员)自己维护数据库和缓存的一致性,即开发者需要自己在代码中加入管理缓存的代码:

  • 读/查询时:从缓存中读取数据,读取到(命中)直接返回,没读取到则到数据库中查找读取,并写入缓存中。
  • 写/更新时:直接更新数据库数据并删除缓存,待查询时再更新缓存。

 旁路缓存的查询数据示意图

d15e689166e14d309ae30703b16f94a9.png 旁路缓存的更新数据示意图

2def0a20b38142d7b21e20eb28873217.png

Read/Write Through Pattern(读写穿透)

        现在这种模式下,缓存不仅是数据的临时存储,还可以通过缓存来访问和写入数据库。缓存对数据库的读写操作是透明的,应用程序只与缓存进行交互,缓存负责和数据库之间的同步工作:

  • 读/查询时:从缓存中读取数据,读取到(命中)直接返回,没读取到则先去数据库中查找读取,写入到缓存后响应
  • 写/更新时:判断缓存中是否存在,不存在直接操作数据库,存在则更新缓存,同步更新数据库(缓存服务自己去做)

 读写穿透的查询数据示意图8048a2fb0b784a6d9f7d5207f1aeb8df.png

 读写穿透的更新数据示意图16beec3085c74bffbf18d119f1bdabcf.png

Write Behind Caching Pattern(异步缓存写入)

        Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache(缓存) 服务来负责 cache 和 db(database的缩写代表,数据库) 的读写。

        但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。

        很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。

        这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。

        Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。

一致性方案

        介绍完通用的三种缓存模型后,咱们就要进入正题了,关于数据库数据和缓存数据的一致性问题。

        根据对于缓存的处理方式(是删除还是更新)以及操作缓存和数据库的顺序的不同,一共可以有四种方案:

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

我们依次来分析下

更新缓存

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

【写+写】并发请求:

        先来看正常情况,,有两个线程,线程1和线程2,线程1将缓存和数据库数据都更新完成后,线程2再去更新,此时缓存和数据库数据都是2,保持数据一致。

【写+写】并发请求正常情况

        异常情况是线程1先将缓存数据更新为1,在更新数据库前,线程2将缓存更新为2,紧接着把数据库也更新为2,然后线程1将数据库更新为1。

此时,缓存中的数据为2,但数据库中的数据为1,出现了缓存和数据库的数据不一致现象

【写+写】并发请求异常情况

【读+写】并发请求:

这种情况我就不画图了,简单给大家描述下,这种并发情况引发问题较小。

        最理想的情况是,线程2在线程1将缓存和数据库全都更新完后再到缓存中读取到数据,这样缓存和数据库中的数据保持一致,但是对于用户来说,他们的得到的都是最新数据。

        稍差的情况是,线程2在线程1更新完缓存后就读取了缓存中的数据,此时线程1还没有更新数据库,存在短时间的缓存和数据库数据不一致问题,但是等待数据库更新完成,数据就又一致了。此时,用户的得到的也是最新数据。

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

【写+写】并发请求:

        正常情况是,线程1将数据库和缓存数据都更新完成后,线程2再去更新,此时数据库和缓存数据都是2,保持数据一致。

【写+写】并发请求正常情况

        异常情况是线程1先将数据库数据更新为1,在更新缓存前,线程2将数据库更新为2,紧接着把缓存也更新为2,然后线程1将缓存更新为1。

此时,数据库中的数据为2,但缓存中的数据为1,出现了缓存和数据库的数据不一致现象

【写+写】并发请求异常情况

【读+写】并发请求:

        正常情况是,线程2在线程1更新完数据库和缓存后,再去缓存中读取数据,命中返回,此时用户读取到的数据就是数据库中存储的最新数据,缓存和数据库一致。

【读+写】并发请求正常情况

【读+写】并发请求异常情况

异常情况说明:

  • 线程2在线程1更新数据库前就读取了缓存中的旧数据
  • 或者是线程2在线程1更新完数据库,还未更新缓存的时候读取到了缓存中的旧数据
  • 这时返回给用户的数据和保存在数据库中的数据不同

总结

        无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,这两个方案都存在并发问题,当两个请求并发更新同一条数据时(写写并发),可能会发生缓存和数据库中的数据不一致的现象。而读写并发时,前者方案可能会导致用户获取到的数据不是最新的,影响用户体验。

删除缓存

        既然更新缓存的策略问题这么多,咱们就换一种,换成删除缓存的,并且这还有一个别的好处,那就是假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新,采用删除缓存方案也就是延迟更新,什么时候有人查询了,什么时候更新。

        现在这种不更新缓存,而是删除缓存中的数据,然后读取数据时,发现缓存中不存在该数据后,再去数据库中读取,而后写入缓存中的策略,即使咱们上边介绍过的 Cache Aside 策略。

        在删除方案中,我们只分析【读+写】并发请求的情况了,至于为什么不去分析【写+写】并发请求的情况,那是因为缓存都删掉了,一定会出现缓存和数据库数据不一致的问题的,但是当读请求发起后,读取数据库找到数据写入缓存后,就又一致了。

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

【读+写】并发请求:

【读+写】并发请求正常情况

【读+写】并发请求异常情况

 异常情况说明:

  • 线程1删除缓存后,还没来得及更新数据库,
  • 此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
  • 然后线程1更新数据库,此时数据库是新数据,缓存是旧数据

        由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高,即出现异常情况的概率很大。因此不推荐这种方案。 

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

【读+写】并发请求:

【读+写】并发请求正常情况

【读+写】并发请求异常情况

异常情况说明:

  • 线程1查询缓存未命中,于是去查询数据库,查询到旧数据
  • 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
  • 线程1执行写入缓存的操作,写入旧数据

        从上面的理论上分析,【先更新数据库,再删除缓存】也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。

        因为缓存的写入通常要远远快于数据库的写入(写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低),所以在实际中发生请求2已经更新了数据库并且删除了缓存,请求 1 才更新完缓存的情况概率极为苛刻。

        而一旦请求 1 早于请求 2删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

        所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,而且为了确保万无一失,还给缓存数据加上了【过期时间】,要就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。 

        综上:推荐使用【先更新数据库+再删除缓存】的方案

总结

        无论是【先更新数据库,再删除缓存】,还是【先删除缓存,再更新数据库】,这两个方案在读写请求并发的情况下都存在问题,不过后者发生的概率很小,原因在于缓存的写入速度远远快于数据库的写入,所以我们在项目中最为推荐的也是这种方案。

        同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。

扩展

缓存命中率

        【先更新数据库,再删除缓存】的方案虽然保证了数据库和缓存的数据一致性,但是每次更新数的时候,都会删除缓存的数据(缓存中对应的更新了的那一条,不是全部),这样会对缓存的命中率带来影响。

        所以如果我们的业务对于缓存命中率有很高的要求,我们可以采用【更新数据库+更新缓存】的方案,因为更新缓存并不会出现缓存未命中的情况。

        不过关于更新缓存的两种方案,我们前面也分析过,因为两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作相互独立,而我们又没有对操作进行任何的并发控制,那么当两个线程并发更新的话,就会因为写入顺序的不同造成数据的不一致。

下面介绍两种手段解决这个问题:

  • 在更新缓存之前加一个【分布式锁】,保证同一时间只有一个更新请求更新缓存,这样就不会产生并发问题了,当然,引入锁以后,对于写入性能就会产生影响。
  • 在更新完缓存后,给缓存加上一个较短的【过期时间】,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对于业务还是能接受的。

 延迟双删

        针对【先删除缓存,再更新数据库】的方案,在【读+写】并发请求而造成缓存不一致的解决办法就是 延迟双删

伪代码如下

#删除缓存
redis.delKey(X)

#更新数据库
db.update(X)

#睡眠
Thread.sleep(N)

#再删除缓存
redis.delKey(X)

        加了个睡眠时间,主要是为了确保请求 A在睡眠的时候,请求B能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A睡眠完,再删除缓存。

        所以,请求 A 的睡眠时间就需要大于请求 B「从数据库读取数据 +写入缓存」的时间。
        但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

        因此,还是比较建议用「先更新数据库,再删除缓存」的方案。 

优化

        在【先更新数据库,再删除缓存】的方案中,这其实是两个操作,如果第二个操作(删除缓存)失败了,那么缓存中依旧存着旧数据。不过好在,我们设置了过期时间的兜底方案,如果没有这个,那么后续的请求读到的都是是存在缓存中的旧数据,这样问题就大了。

举个例子:

删除缓存操作成功

删除缓存操作失败

        应用要把数据X的值从1更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除X的缓存,但是这个操作却失败了,这个时候数据库中X的新值为 2,Redis 中的 X的缓存值为 1,出现了数据库和缓存数据不一致的问题。

        那么,后续有访问数据X的请求,会先在 Redis 中查询,因为缓存并没有删除,所以会缓存命中,但是读到的却是旧值 1。
        其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。

所以新的问题来了,如何保证【先更新数据库,再删除缓存】的这两个操作都能执行成功呢?

两种办法:

  • 消息队列重试机制
  • 使用Canal订阅MySQL binlog,再操作缓存

消息队列重试机制

引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过一定次数,还是没有成功,我们就要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

这个优化方案的缺点就是,对代码入侵比较强,因为需要改造原本业务的代码。 

使用Canal订阅MySQL binlog,再操作缓存

        【先更新数据库,再删除缓存】的方案的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在binlog里。

        于是我们就可以通过订阅 binog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的Canal 中间件就是基于这个实现的。

         Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送dump 请求,MySQL收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

Canal的工作原理

        前面我们说到直接用消息队列重试机制方案的话,会对代码造成入侵,那么Canal方案就能很好的规避这个问题,因为它是直接订阅 binlog 日志的,和业务代码没有藕合关系,因此我们可以通过 Canal+ 消息队列的方案来保证数据缓存的一致性。

        具体的做法是:将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消费者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性

        这里有一个很关键的点,必须是删除缓存成功,再回 ack 机制给消息队列,否则可能会造成消息丢失的问题,比如消费服务从消息队列拿到事件之后,直接回了 ack,然后再执行删除缓存操作的话,如果删除缓存的操作还是失败了,那么因为提前给消息队列回 ack了,就没办重试了。 

总结 

所以,如果想保证【先更新数据库,再删除缓存】方案的第二个操作能成功执行,我们可以使用:

  • 消息队列来重试缓存的删除,优点是保证缓存一致性的问题,缺点会对业务代码入侵
  • 订阅 MySQL binog + 消息队列 +重试缓存的删除,优点是规避了代码入侵问题,也很好的保证缓存一致性的问题,缺点就是引入的组件比较多,对团队的运维能力比较有高要求。

这两个方法有一个共同的特点,那就是都采用异步操作缓存。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值