最近在面试的过程中,有遇到面试官问我这个问题,觉得还是有必要看一下,那就是缓存与数据库的一致性问题
在自己开发单体应用的时候,往往是一个后端服务加一个数据库服务就ok了,但是,在实际开发中,还需要根据真实的业务需求,考虑上缓存,这就带来了上面的问题
我们通常会使用Redis作为缓存中间件,因为他是基于内存的,速度很快,那如果现在我们不仅要把数据放在数据库中,还要放入缓存中,这种具体过程要怎么存呢
最简单的方法是全量数据刷到缓存中
- 数据库的数据,全量刷入缓存(不设置过期时间)
- 写请求只更新数据库,不更新缓存
- 启动一个定时任务,定时把数据库的数据,更新到缓存中
这种方法的优点是,所有读请求都可以命中缓存,不再寻找数据库,但是缺点也明显,有两个问题,缓存利用率低,不经常访问的数据,一直留在缓存中,因为是定时刷新缓存,缓存和数据库可能存在着不一致
但是,如果面临着大型的业务,可能就需要考虑另外一种,很容易想到的是,缓存中只保留最热门的数据,所以可以采用另外一种优化方式
- 写请求依然只写数据库
- 读请求先读缓存,如果缓存不存在,则从数据库读取,并重新将数据刷回缓存
- 同时,写入缓存中的数据,都设置过期时间
这种方式使得缓存中不经常访问的数据,会随着时间推移,逐渐淘汰掉,最终缓存中保留的,都是经常被访问的热数据
这种情况很好的提升了缓存的利用率,但是,数据库和缓存同时更新,又存在着先后问题,对应的方案就有2个
- 先更新缓存,后更新数据库
- 先更新数据库,后更新缓存
下面来看下这两种方案的优缺点,首先可以明确,不管是先更新数据库还是先更新缓存,不考虑并发场景下,都可以保障数据的一致性,我们重点关注异常现象,也就是存在第一步成功,第二步失败的情况,来一个一个分析
如果缓存更新成功了,但是数据库更新失败,那么此时缓存中是最新的值,数据库中还是旧值,此时读请求依然可以命中缓存,但是一旦缓存失效,则请求会转向数据库中读取,这个时候读出的就是旧值,这样的话用户就会发现之前修改的数据又变回去了
如果数据库更新成功,缓存更新失败,那么此时数据库中是最新的值,缓存中是旧值,之后读请求都会先从缓存中读,读到的是旧的数据,只有当缓存失效后,才能从数据库读到正确的值,这个时候用户就会发现,自己刚刚修改了数据,但是得到的却还是原来的旧值,一段时间过后(缓存失效后),才会发现数据重新变更过来
所有可以看到,这两种方式都会造成读取异常现象发生
并发场景下的一致性问题
假如我们使用先更新数据库,再更新缓存的方案的方式,并且两步都保障“成功”的场景下,如果所有操作都处于并发场景下,该如何解决呢?
假设有两个线程,线程A和线程B,需要更新【同一条】数据,此时可能会出现这种情况:
- 线程A更新数据库(X = 1)
- 线程B更新数据库(X = 2)
- 线程A更新缓存(X = 2)
- 线程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.先更新数据库,在删除缓存方案下,读写分离+主从库延迟,也会导致缓存和数据库不一致,这种方式可以使用延迟双删,凭借经验发送延迟消息到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致出现的概率
|