@[toc]应该很多朋友在面试的时候遇到过类似的问题。如何保证缓存和数据库的一致性?如果你对这个问题做过研究,你应该能发现这个问题其实很容易回答。如果您是第一次听到或遇到这个问题,您可能会有些困惑。今天就来聊聊这个话题。一、问题分析首先我们来看看为什么会出现这个问题!在我们日常的开发中,为了提高数据的响应速度,我们可能会在缓存中保存一些热点数据,这样就不用每次都去数据库查询,可以有效的提高数据的响应速度服务器,所以目前最常用的缓存是Redis。使用Redis做缓存并不代表缓存就是Redis,还是需要结合业务的具体情况。我们可以根据不同业务对数据的实时性要求,将数据分为三个层次。以电商项目为例:第一层:订单数据和支付流水数据:这两块数据对实时性和准确性要求很高,一般不需要加缓存,可以直接操作数据库。Level2:与用户相关的数据:这些数据是与用户相关的,具有读多写少的特点,所以我们使用redis来做缓存。Level3:支付配置信息:这些数据与用户无关,具有数据量小、读取频繁、几乎不修改的特点,所以我们使用本地内存进行缓存。选择合适的数据存入Redis后,接下来,每当要读取数据的时候,就去Redis看看有没有,有就直接返回;如果没有就去数据库读取,会从数据库中读取从数据库中读取的数据缓存在Redis中,基本上就是这么一个过程。读取数据的过程其实还是比较清晰简单的,没什么好说的。但是,数据存入缓存后,如果需要更新,往往会带来另一个问题:当有数据需要更新时,是先更新缓存还是先更新数据库?如何保证更新缓存和更新数据库的原子性?更新缓存时如何更新?修改还是删除?该怎么办?通常,我们有四种选择:先更新缓存,再更新数据库。先更新数据库,再更新缓存。先清除缓存,再更新数据库。先更新数据库,再清除缓存。使用哪一个?在回答这个问题之前,我们先来看看三种经典的缓存模式:Cache-AsideRead-Through/WritethroughWriteBehind2.Cache-AsideCache-Aside,中文也叫旁路缓存模式,如果我们能使用Cache-Aside,那么缓存和数据库数据不一致的问题就尽量解决了。注意是尽量解决,不能绝对解决。Cache-Aside分为读缓存和写缓存。让我们分别看看它们。2.1读缓存首先看一张流程图:它的流程是这样的:读数据。检查缓存中是否有需要的数据,如果命中缓存(CacheHit),则直接返回数据。如果没有命中缓存,也就是CacheMiss,那么先去数据库。将从数据库读取的数据设置到缓存中。返回数据。这就是Cache-Aside的读缓存过程。其实对于读缓存的过程,大家一般都没有异议。主要反对意见是写入过程。让我们继续看吧。2.2写缓存首先看一张流程图:写缓存的过程比较简单,先更新数据库中的数据,然后删除旧的缓存。过程虽然简单,却引出了两个问题:为什么老缓存没有更新,而是删除了?为什么不先删除旧的缓存,然后再更新数据库呢?我们分别回答这两个问题。为什么要删除旧缓存而不是更新旧缓存?更新缓存说起来容易做起来难。很多时候我们更新缓存并不是简单的更新一个Bean。很多时候,我们缓存的是一些复杂的操作或者计算的结果(比如大量的表连接操作,一些分组计算)。没有缓存,不仅不能满足高并发,还会给MySQL数据库带来巨大的负担。所以对于这样的缓存,其实更新起来并不容易,此时选择删除缓存会比较好。对于一些写频繁的应用,如果按照updatecache->updatedatabase的方式,很浪费性能,因为先写cache麻烦,其次每次都写cache,但是可能要写十次和只读一次。读取时,读取的缓存数据为第十次,前九次写入缓存无效。这种情况下,最好采用先写入数据库,再删除缓存的策略。在多线程环境下,这样的更新策略也可能导致数据逻辑错误。看下面的流程图:可以看到有两个线程A和B并发:首先,线程A更新数据库。下一个线程B更新数据库。由于网络等原因,线程B首先更新了缓存。线程更新缓存。那么此时缓存中存储的数据是不正确的,删除??缓存就不会出现这个问题。为什么不先删除旧的缓存,然后再更新数据库呢?这也是考虑并发请求。假设我们先删除旧的缓存,然后更新数据库,那么可能会出现以下情况:这个操作是这样的,有两个线程,A和B,其中A写入数据,B读取数据,具体过程如下如下:一个线程先删除缓存。线程B读取缓存,发现缓存中没有数据。线程B读取数据库。线程B将从数据库读取的数据写入缓存。一个线程更新数据库。经过一组操作,发现数据库和缓存中的数据不一致!所以在Cache-Aside中,先更新数据库,再删除缓存。2.3延迟双删其实无论是先更新数据库再删除缓存,还是先删除缓存再更新数据库,在并发环境下都可能存在问题:假设有两个并发请求A和B:先更新数据库再删除缓存:当请求A更新数据库后,没有时间清除缓存。此时请求B查询并使用Cache中的旧数据。先删除缓存再更新数据库:请求A清空缓存后,数据库还没有更新,此时请求B进行查询,找到旧数据写入Cache。当然,我们之前已经分析过了,尝试先操作数据库,再操作缓存,但即便如此,还是有可能出现问题。解决问题的方法是延迟双删。延迟双删是这样的:先进行清缓存操作,再进行数据库更新操作,延迟N秒后再进行清缓存操作,这样就不用担心数据之间不一致了在缓存中,数据在数据库中。那么这个延迟是N秒,N的大小是多少合适呢?一般来说,N大于写操作的时间。如果延迟时间小于写入缓存的时间,会导致请求A延迟清缓存,但此时请求B的缓存还没有写入。具体多少要结合自己的业务来算这个值。2.4如何保证原子性但是更新数据库和删除缓存毕竟不是原子操作。数据库更新后删除缓存失败怎么办?对于这种情况,常见的解决方案是使用消息中间件实现删除重试。大家知道,MQ一般都有自己的消费失败重试机制。当我们要删除缓存时,我们向MQ中抛出一条消息。缓存服务读取消息并尝试删除缓存。如果删除失败,会自动重试。.不知道RabbitMQ怎么用的小伙伴,可以在公众号江南一点鱼后台回复rabbitmq,里面有免费的视频+文档。3、Read-Through/Write-Through的缓存运行方式,松哥印象最深的是在OracleCoherence中的应用。不知道你有没有用过OracleCoherence。这是内存中的数据网格。通过这一点,应用程序开发人员和管理人员可以快速访问键值数据,Coherence可以提供集群低延迟数据存储、多语言网格计算和异步事件流处理,从而为客户的企业应用程序提供超高水平的可扩展性和性能。我们不讨论OracleCoherence,我们来谈谈Read-Through。3.1Read-Through这里为了省事,就不自己画图了。在网上找了一张图,如下:乍一看,很多人觉得这和Cache-Aside一样,没有区别!是的,光看过程是不容易看出区别的。Read-Through是一种类似于Cache-Aside的缓存方式。不同的是,在Cache-Aside中,应用决定是读取缓存还是读取数据库,这会导致很多业务无关的代码;在Read-Through中,相当于多了一个中间层CacheMiddleware,用于读取缓存或者数据库,简化了应用层的代码。宋兄之前写过SpringCache的用法,回想一下SpringCache中的@Cacheable注解是不是有Read-Through的感觉?画个简单的流程图给大家看一下:可以看到,和Cache-Aside相比,其实相当于多了一个CacheMiddleware,这样我们在应用中只需要正常读写数据即可。抛开底层具体逻辑不谈,相当于把缓存相关的代码从应用中剥离出来,应用只需要关注业务即可。3.2Write-ThroughWrite-Through其实也是类似的,所有的操作都交给Cache中间件来完成,应用只是简单的更新,我们看一下流程:在Write-Through策略中,所有的写操作都是在AfterCacheMiddleware,每次写入,CacheMiddleware都会将数据存入DB和Cache中。这两个操作发生在一个事务中,所以只有两者都写入成功,一切才会成功。这种写数据的好处是应用程序只和缓存中间件对话,所以它的代码更干净、更简单。4.WriteBehindWrite-Behind缓存策略类似于Write-Through缓存。应用程序只与缓存中间件通信,缓存中间件保留与应用程序通信的接口。Write-Behind和Write-Through最大的区别是前者先将数据写入缓存,然后在一段时间后(或通过其他触发器)将数据写入Database,而这里涉及的写入是异步的手术。这样Cache和DB数据的一致性不强,对一致性要求高的系统慎用。如果有人在数据还没有写入数据源之前就直接从数据源获取数据,可能会导致获取过期的数据,但是对于频繁写入的场景,这其实是非常适合的。将数据写入DB可以通过多种方式完成:一种是收集所有写入,然后在某个时间点(例如,当DB负载较低时)对数据源进行批量写入。另一种方法是将写入组合成更小的批次,比如一次写入五次,然后批量写入数据源。我不想画这个流程图。在网上找了一个,小伙伴们可以参考一下:如果您有任何问题,请留言讨论。参考资料:https://www.jianshu.com/p/a8e...https://catsincode.com/cachin...
