缓存数据库一致性解决办法

引入缓存后的一致性挑战

所谓缓存,实际上就是用空间换时间,准确地说是用更高速的空间来换时间,从而整体上提升读的性能。
这篇文章讨论的是 Redis 与 MySQL 的缓存不一致问题,事实上就算是本地缓存,也是一样的问题。

根据CAP理论,要实现强一致性,就要牺牲可用性,这会极度降低系统性能。而使用缓存,本地就是为了提升性能。如此,目标是实现最终一致性,并且尽可能降低不一致的时间。

一致性方案

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

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

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

考虑两个写请求。 异常情况是线程1先将缓存数据更新为1,在更新数据库前,线程2将缓存更新为2,紧接着把数据库也更新为2,然后线程1将数据库更新为1。
此时,缓存中的数据为2,但数据库中的数据为1,出现了缓存和数据库的数据不一致现象
在这里插入图片描述

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

考虑两个写请求。异常情况是线程1先将数据库数据更新为1,在更新缓存前,线程2将数据库更新为2,紧接着把缓存也更新为2,然后线程1将缓存更新为1。
此时,数据库中的数据为2,但缓存中的数据为1,出现了缓存和数据库的数据不一致现象
在这里插入图片描述
无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,这两个方案都存在并发问题,当两个请求并发更新同一条数据时(写写并发)

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

在这里插入图片描述
异常情况说明:

线程1删除缓存后,还没来得及更新数据库,
此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
然后线程1更新数据库,此时数据库是新数据,缓存是旧数据
由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高,即出现异常情况的概率很大。因此不推荐这种方案。

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

在这里插入图片描述
异常情况说明:

  • 线程1查询缓存未命中,于是去查询数据库,查询到旧数据

  • 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存

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

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

而一旦请求 1 早于请求 2删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,而且为了确保万无一失,还给缓存数据加上了【过期时间】,要就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
综上:推荐使用【先更新数据库+再删除缓存】的方案

采用【先更新数据库,再删除缓存】带出缓存击穿问题

虽然保证了数据库和缓存的数据一致性,但是每次更新数的时候,都会删除缓存的数据,对于热点key,要引起缓存击穿的问题,导致数据库压力庞大。
为此,可以采用【更新数据库+更新缓存】的方案,因为更新缓存并不会出现缓存未命中的情况。
但是这个方案,会有缓存不一致问题,关键在于并没有

有两种手段解决这个问题:

  • 在更新缓存之前加一个【分布式锁】,保证同一时间只有一个更新请求更新缓存,这样就不会产生并发问题了,当然,引入锁以后,对于写入性能就会产生影响

  • 在更新完缓存后,给缓存加上一个较短的【过期时间】,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对于业务还是能接受的。

延迟双删

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

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

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

做个简单总结,足以适应绝大部分的互联网开发场景的决策:
针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。

更新数据库和删除缓存的最终一致性问题

不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
新的问题来了,如何保证【先更新数据库,再删除缓存】的这两个操作都能执行成功呢?

当我们无法确定 MySQL 更新完成后,缓存的更新/删除一定能成功,例如 Redis 挂了导致写入失败了,或者当时网络出现故障,更常见的是服务当时刚好发生重启了,没有执行这一步的代码。

这些时候 MySQL 的数据就无法刷到 Redis 了。为了避免这种不一致性永久存在,使用缓存的时候,我们必须要给缓存设置一个过期时间,例如 1 分钟,这样即使出现了更新 Redis 失败的极端场景,不一致的时间窗口最多也只是 1 分钟。

解决办法:

  • 消息队列重试机制,有业务侵入
  • 使用Canal订阅MySQL binlog + 消息队列重试机制。无业务侵入,运维复杂

消息队列重试机制,有业务侵入

在这里插入图片描述
引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

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

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

如何处理复杂的多缓存场景?

有些时候,真实的缓存场景并不是数据库中的一个记录对应一个 Key 这么简单,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能需要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。

我们以一个数据库记录对应多个 Key 的场景来举例。
假如系统设计上我们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝注销了,或者这个粉丝触发了打赏的行为,上面多个 Key 可能都需要更新。只是一个打赏的记录,你可能就要做:

updateMySQL();//更新数据库一条记录
deleteRedisKey1();//失效主页信息的缓存
updateRedisKey2();//更新打赏榜TOP10
deleteRedisKey3();//更新单日打赏榜TOP100

这就涉及多个 Redis 的操作,每一步都可能失败,影响到后面的更新。甚至从系统设计上,更新数据库可能是单独的一个服务,而这几个不同的 Key 的缓存维护却在不同的 3 个微服务中,这就大大增加了系统的复杂度和提高了缓存操作失败的可能性。最可怕的是,操作更新记录的地方很大概率不只在一个业务逻辑中,而是散发在系统各个零散的位置。

针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。如下图:
在这里插入图片描述
不同业务系统订阅MQ消息单独维护各自的缓存Key

在这里插入图片描述
专门更新缓存的服务订阅MQ消息维护所有相关Key的缓存操作

通过订阅MySQL binlog的方式处理缓存

上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。
在这里插入图片描述

利用Canel订阅数据库binlog变更从而发出MQ消息,让一个专门消费者服务维护所有相关Key的缓存操作

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值