数据库缓存一致性解决方案

最近在面试的过程中,有遇到面试官问我这个问题,觉得还是有必要看一下,那就是缓存与数据库的一致性问题

在自己开发单体应用的时候,往往是一个后端服务加一个数据库服务就ok了,但是,在实际开发中,还需要根据真实的业务需求,考虑上缓存,这就带来了上面的问题

我们通常会使用Redis作为缓存中间件,因为他是基于内存的,速度很快,那如果现在我们不仅要把数据放在数据库中,还要放入缓存中,这种具体过程要怎么存呢

最简单的方法是全量数据刷到缓存中

  • 数据库的数据,全量刷入缓存(不设置过期时间)
  • 写请求只更新数据库,不更新缓存
  • 启动一个定时任务,定时把数据库的数据,更新到缓存中

这种方法的优点是,所有读请求都可以命中缓存,不再寻找数据库,但是缺点也明显,有两个问题,缓存利用率低,不经常访问的数据,一直留在缓存中,因为是定时刷新缓存,缓存和数据库可能存在着不一致

但是,如果面临着大型的业务,可能就需要考虑另外一种,很容易想到的是,缓存中只保留最热门的数据,所以可以采用另外一种优化方式

  • 写请求依然只写数据库
  • 读请求先读缓存,如果缓存不存在,则从数据库读取,并重新将数据刷回缓存
  • 同时,写入缓存中的数据,都设置过期时间

这种方式使得缓存中不经常访问的数据,会随着时间推移,逐渐淘汰掉,最终缓存中保留的,都是经常被访问的热数据

这种情况很好的提升了缓存的利用率,但是,数据库和缓存同时更新,又存在着先后问题,对应的方案就有2个

  1. 先更新缓存,后更新数据库
  2. 先更新数据库,后更新缓存

下面来看下这两种方案的优缺点,首先可以明确,不管是先更新数据库还是先更新缓存,不考虑并发场景下,都可以保障数据的一致性,我们重点关注异常现象,也就是存在第一步成功,第二步失败的情况,来一个一个分析

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

如果缓存更新成功了,但是数据库更新失败,那么此时缓存中是最新的值,数据库中还是旧值,此时读请求依然可以命中缓存,但是一旦缓存失效,则请求会转向数据库中读取,这个时候读出的就是旧值,这样的话用户就会发现之前修改的数据又变回去了

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

如果数据库更新成功,缓存更新失败,那么此时数据库中是最新的值,缓存中是旧值,之后读请求都会先从缓存中读,读到的是旧的数据,只有当缓存失效后,才能从数据库读到正确的值,这个时候用户就会发现,自己刚刚修改了数据,但是得到的却还是原来的旧值,一段时间过后(缓存失效后),才会发现数据重新变更过来

所有可以看到,这两种方式都会造成读取异常现象发生

并发场景下的一致性问题

假如我们使用先更新数据库,再更新缓存的方案的方式,并且两步都保障“成功”的场景下,如果所有操作都处于并发场景下,该如何解决呢?

假设有两个线程,线程A和线程B,需要更新【同一条】数据,此时可能会出现这种情况:

  1. 线程A更新数据库(X = 1)
  2. 线程B更新数据库(X = 2)
  3. 线程A更新缓存(X = 2)
  4. 线程A更新缓存(X = 1)

可以发现,最终X在缓存中的结果是1,在数据库中是2,这就出现了不一致的现象,这种在时序角度出现操作混乱,对于先更新缓存,再更新数据库这种场景下,也会发生

删除缓存可以保障数据一致性吗

对于删除步骤也可以如上面所示,分为两种

1.先删除缓存,再删除数据库
2.先删除数据库,再删除缓存

上面我们也都进行了分析,只要第二步操作失败,都会出现数据不一致的情况发生,我们来一个个看

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

如果有2个线程要并发读写数据,可能会发生以下场景

1.线程A要更新X=2(原来的值X=1)
2.线程A先删除缓存
3.线程B读缓存,发现缓存不存在,于是从数据库中读取旧值(X=1)
4.线程A将新值写入到数据库(X=2)
5.线程B将旧值写入缓存(X=1)

最终X的值在缓存中是1(旧值),在数据库中是2(新值),发生不一致,可以看到这种方式,还是会出现数据不一致的现象

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

在这里,依然是线程A和线程B并发发生读写操作
1.缓存中X不存在(数据库X=1)
2.线程A读取数据库,得到旧值(X=1)
3.线程B更新数据库(X=2)
4.线程B删除缓存
5.线程A将旧值写入到缓存(X=1)

最终X的值在缓存中是1(旧值),在数据库中是2(新值),也发生了不一致,但是这种情况理论上是可能发生的,实际上发生的概率很低,因为需要满足以下三个条件

  • 缓存刚好已失效
  • 读请求+写请求并发
  • 更新数据库+删除缓存的时间,要比读数据库,通常要比读数据库更长时间的

其实仔细想一下,条件3发生的概率是非常低的,因为写数据库一般都会先加锁,所以写数据库,通常是比读数据库时间要长的,所以,先更新数据库,再删除缓存,其实在理论上是可以保证数据一致性的

所以,再次回到最根本的问题,就是如何保障两步都能够执行成功

前面我们分析到,无论是更新缓存还是删除缓存,只要第二步发生失败,就会导致缓存和数据库的数据不一致现象发生,所以保证第二步成功执行,是解决问题的关键

一般来说,程序在执行过程中发生异常,最简单的解决方法是什么? 答案是重试

在这里,其实我们也可以这样做,无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,就可以发起重试,尽最大可能去弥补数据的不一致带来的信息损失,但是,是否只要遇到执行失败,就得不断无脑重试呢?答案肯定是否定的,失败后立即重试的问题在于:

  • 立即重试很大概率还会失败
  • 重试次数设置为多少才合理
  • 重试会一直占用线程资源,无法服务其他客户端请求

所以在这里,更好的一种重试方法是异步重试,其实就是将重试请求放入消息队列中,然后由专门的消费者来重试,直到成功,或者更加直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直到放到消息队列中,由消费者来操作缓存

这里可能会产生这样的考虑,加入了消息队列是不是会增加整个系统的复杂程度?但是,我们要考虑到,如果不拆分服务,而是在一个服务中起一个线程去不断的执行异步重试任务,当服务挂掉,那就意味着这次执行重试的线程操作也就失败了,但是引入外部的消息队列并不会因为其他服务挂掉而挂掉,只要数据库服务重新启动,就可以重新从消息队列中拉数据

这是因为消息队列又两个好的优势
1.消息队列保证可靠性:写到队列中的数据,成功消费之前不会丢失(重启也不会对服务造成影响)
2.消息队列会保证消息的成功投递,下游从队列中拉取消息,成功消费后才会删除消息,否则会继续将消息传递给消费者

那如果不想使用这种接入中间件的形式,是否有其他的方案,方案还是有的,那就是近几年比较流行的方案,比如订阅数据库变更日志,再操作缓存,具体来说,就是我们的业务在修改数据时,只需要修改数据库,无需操作缓存

比如使用mysql,当一条数据发生修改时,mysql就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体的数据,然后根据这条数据,去删除对应的缓存

主从库延迟和延迟双删问题

在这里,还有2个问题,是我们没有重点分析过的,第一个就是前面讲到的先删除缓存,再更新数据库的方案,导致的不一致现象

此时有两个线程要并发的读写数据,可能会发生以下场景

  • 线程A要更新X=2(原X=1)
  • 线程A先删除缓存
  • 线程B读缓存,发现不存在,从数据库中读取旧值(X=1)
  • 线程A将新值写入到数据库(X=2)
  • 线程B将旧值写入到缓存(X=1)

最终X的值在缓存中是1(旧值),在数据库中是2(新值),发生了不一致,第二个问题是关于读写分离+主从复制延迟,其实也会导致数据不一致的现象

比如出现这种情况

  • 线程A更新主库X=2(原值X=1)
  • 线程A删除缓存
  • 线程B查询缓存,没有命中,查询从库得到了旧值(从库=1)
  • 从库同步完成(主从库X=2)
  • 线程B将旧值写入缓存(X=1)

最终X的值在缓存中是1(旧值),在主从库中是2(新值),也发生了不一致,最终X在缓存中是旧的,在主从库是新的,也会发生不一致的现象,最有效的方法就是将缓存删掉

但是不能够立刻删,而是要延迟删,这是业界给出的一个标准方案,缓存延迟双删策略,按照延迟双删策略,解决方法可以是这样的

  • 问题1: 在线程A删除缓存,更新完数据库之后,先休眠一会,再删除一次缓存
  • 问题2: 线程A可以生成一条延迟消息,写到消息队列中,消费者延迟删除缓存,这两种方式,都是为了能够把缓存清理掉,这样一来,下次就可以从数据库读取到最新值,写入缓存,但是问题来了,这个延迟删除缓存的时间,要设置多久?
  • 问题1: 延迟时间要大于主从复制的延迟时间
  • 问题2: 延迟时间要大于线程B读取数据库+写入缓存的时间

但是很多时候,这个时间在分布式和高并发场景下,其实是很难进行评估的,很多时候,我们都是凭借经验大致进行估算,比如延迟了1-5s,只能尽可能降低不一致的概率,所以在这里进行了总结,在实际使用中,还是选择采用

先更新数据库,再删除缓存的方案,尽可能保证主从复置,不要有太大的延迟,降低出问题的概率

总结

在这里进行了总结,总结了这篇文章的重点

1.想要提高应用的性能,可以引入缓存来解决
2.引入缓存后,会需要考虑缓存和数据一致性问题,可选的方案有,更新数据库+更新缓存,更新数据库+删除缓存方式
3.更新数据库+更新缓存方案,在并发场景下无法保证缓存和数据一致性,且存在缓存资源浪费,和机器性能的情况出生
4.在更新数据库+删除缓存的方案中,先删除缓存,再更新数据库,在并发场景下依然有数据不一致的现象,解决方法是延迟双删,但延迟时间很难评估,所以推荐使用先更新数据库,再更新缓存的方案
5.先更新数据库,再删除缓存方案下,为了保证两步都成功执行,需要配合消息队列和订阅变更日志两种方案来做,本质上是通过重试的方式来保证数据一致性
6.先更新数据库,在删除缓存方案下,读写分离+主从库延迟,也会导致缓存和数据库不一致,这种方式可以使用延迟双删,凭借经验发送延迟消息到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致出现的概率

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值