Redis具有高性能的数据读写功能,广泛应用于缓存场景。一来可以提高业务系统的性能,二来可以抵抗对数据库的高并发流量请求。使用Redis作为缓存组件需要防止以下问题,否则可能会导致生产事故。Redis缓存满了怎么办?如何解决缓存穿透、缓存击穿、缓存雪崩?Redis数据过期会立即删除吗?Redis突然变慢,如何查看性能并解决?Redis与MySQL数据一致性如何处理?今天就和大家一起探讨一下缓存的工作机制以及缓存一致性的解决方法。在本文正式开始之前,我们需要就以下两点达成共识:1)缓存必须有过期时间;2)保证数据库和缓存的最终一致性就可以了,没必要追求强一致性。目录如下:1.什么是数据库和缓存一致性2.缓存使用策略2.1Cache-Aside(旁路缓存)2.2Read-Through(直读)2.3Write-Through(同步直写)2.4Write-Behind3.3.1先更新缓存,再更新数据库3.2先更新数据库,再更新缓存3.3先删除缓存,再更新数据库3.4先更新数据库,再删除缓存4.一致性解决方案有哪些?4.1缓存延迟双删4.2删除缓存重试机制4.3读取binlog异步删除总结1.什么是数据库和缓存一致性数据一致性是指:缓存中有数据,缓存数据值=数据库中的值;缓存数据库中没有该数据,数据库中的值=最新值。反推缓存与数据库不一致:缓存的数据值≠数据库中的值;缓存或数据库中有旧数据,导致线程读取旧数据。为什么会出现数据一致性问题?在使用Redis做缓存时,当数据发生变化时,我们需要进行双写,以保证缓存与数据库中的数据保持一致。数据库和缓存毕竟是两个系统。如果要保证强一致性,就必须引入分布式一致性协议,比如2PC或者Paxos,或者分布式锁等等,这个很难实现,而且肯定会对性能有影响。如果对数据一致性的要求真的那么高,真的有必要引入缓存吗?2、缓存使用策略在使用缓存时,通常有以下几种缓存使用策略来提高系统性能:Cache-AsidePattern(绕过缓存,业务系统中常用)Read-ThroughPatternWrite-ThroughPatternWrite-BehindPattern1、Cache-Aside(绕过缓存)所谓“绕过缓存”,就是读取缓存、读取数据库、更新缓存的操作都在应用系统中完成。是业务系统最常用的缓存策略。1)读取数据读取数据的逻辑如下:当应用程序需要从数据库中读取数据时,首先检查缓存的数据是否命中。如果缓存未命中,则查询数据库获取数据,同时将数据写入缓存,这样后续读取相同的数据就会命中缓存,最后将数据返回给调用方。如果缓存命中,直接返回。时序图如下:BypassCacheReadTimingDiagram优点缓存只包含应用程序实际请求的数据,这有助于保持缓存大小具有成本效益。实现简单,可以实现性能提升。实现的伪代码如下:StringcacheKey="公众号:codebrotherbyte";StringcacheValue=redisCache.get(cacheKey);//缓存命中if(cacheValue!=null){returncacheValue;}else{//缓存缺失,从数据库获取数据cacheValue=getDataFromDB();//将数据写入缓存redisCache.put(cacheValue)}缺点由于缓存未命中后才将数据加载到缓存中,因此初始调用时的数据请求响应会增加一些开销,因为额外的缓存填充和数据库查询时间。2)使用cache-aside模式更新数据和写入数据时,流程如下。Bypasscachewritedata向数据库写入数据,使缓存中的数据失效或更新缓存数据使用cache-aside时,最常见的写入策略是直接向数据库写入数据,但缓存可能与数据库不一致。我们应该给缓存设置一个过期时间,这是保证最终一致性的一个解决方案。如果过期时间太短,应用程序会不断地向数据库查询数据。同样,如果过期时间过长,更新时缓存没有失效,缓存的数据很可能是脏的。最常见的方式是删除缓存,使缓存的数据失效。为什么不更新缓存?性能问题当缓存的更新成本较高,需要访问多张表联合计算时,建议直接删除缓存,而不是更新缓存数据,以保证一致性。安全问题在高并发场景下,查询到的数据可能是旧值,后面会分析。2.Read-Through(直读)当缓存未命中时,也从数据库中加载数据,同时写入缓存,返回给应用系统。尽管read-through和cache-aside非常相似,但在cache-aside中,应用程序负责从数据库中获取数据并填充缓存。另一方面,Read-Through将获取数据存储中的值的责任转移给缓存提供者。Read-ThroughRead-Through实现了关注点分离原则。代码只与缓存交互,缓存组件管理自身与数据库之间的数据同步。3.Write-Through(同步直写)类似于Read-Through。当有写请求发生时,Write-Through将写的责任交给缓存系统,由缓存抽象层完成缓存数据和数据库数据的更新。时序流程图如下:Write-ThroughWrite-Through的主要好处是应用系统不需要考虑故障处理和重试逻辑,交给缓存抽象层来管理实现。直接使用这个策略是没有意义的,因为这个策略需要先写缓存,再写数据库,给写操作带来了额外的延迟。当Write-Through与Read-Through结合使用时,可以充分发挥Read-Through的优势,同时保证数据的一致性,而不用考虑如何使缓存设置失效。Write-Through策略颠倒了Cache-Aside填充缓存的顺序。不是在缓存未命中后延迟加载到缓存,而是先将数据写入缓存,然后缓存组件将数据写入数据库。优点缓存和数据库数据始终是最新的;查询性能最好,因为要查询的数据可能已经写入了缓存。缺点不经常请求的数据也会写入缓存,导致缓存更大、更昂贵。4.Write-Behind的画面乍一看好像和Write-Through一样,其实不然。区别在于最后一个箭头的箭头:由实线变为线。这意味着缓存系统将异步更新数据库数据,应用系统只与缓存系统交互。应用程序不必等待数据库更新完成,从而提高了应用程序性能,因为对数据库的更新是最慢的操作。Write-Behind策略下,缓存和数据库的一致性不强,不推荐用于一致性高的系统。3.BypassCache下的一致性问题分析最常用的业务场景是Cache-Aside(旁路缓存)策略。在这种策略下,客户端先从缓存中读取数据。如果命中,则Return;如果是miss,则从数据库中读取数据并写入缓存,所以读操作不会造成缓存和数据库不一致。重点是写操作。数据库和缓存都需要修改,两者之间会有先后顺序,可能导致数据不再一致。对于写作,我们需要考虑两个问题:1)先更新缓存还是先更新数据库?2)当数据发生变化时,选择修改缓存(update)还是删除缓存(delete)?结合这两个问题,会有四种解决方案:先更新缓存,再更新数据库;先更新数据库,再更新缓存;先删除缓存,再更新数据库;先更新数据库,再删除缓存。您不必记住以下分析。关键是在推导的过程中,你只需要考虑以下两种场景是否会造成严重的问题:第一次运行成功,第二次失败会带来什么问题?会不会导致高并发下读取数据不一致?如果第一个失败,第二个不用执行,只要在第一步返回50x等异常信息即可,不会出现不一致。只有第一次成功,第二次失败才头疼。为了保证它们的原子性,就涉及到了分布式事务的范围。1、先更新缓存,再更新数据库先更新缓存,再更新数据库。如果先更新缓存,但数据库写入失败,缓存是最新数据,数据库是旧数据,那么缓存就是脏数据。之后,当其他查询马上进来的时候,就会得到这个数据,但是这个数据在数据库中是不存在的。对于数据库中不存在的数据,缓存并返回给客户端是没有意义的。方案直接通过。2.先更新数据库,再更新缓存。一切正常如下:先写数据库,成功;然后更新缓存,就成功了。1)更新缓存失败。这时候我们来推断一下,如果这两个操作的原子性被打破了:如果第一步成功,第二步失败会怎样?会导致数据库是最新数据,缓存是旧数据,造成一致性问题。这张图我就不画了,和上图差不多,只是调换了Redis和MySQL的位置。2)高并发场景下,谢八哥经常996,腰酸背痛,写的bug越来越多,想去推拿推拿提高编程水平。受疫情影响,订单难求,高端会所的技术人员都在争着接这个订单,高并发,兄弟们。进店后,前台将客户信息录入系统,执行服务人员setxx=初始值待定表示当前无人接待客户并保存在数据库和缓存中,以及然后安排技师的按摩服务。如下图所示:高并发先更新数据库,再更新缓存。98号技术员率先行动,向系统发送命令集谢八哥的服务技术员=98写入数据库。此时,系统网络波动,卡顿,数据还没有来得及写入缓存。接下来,520号技术员也将set谢八哥的servicetechnician=520发送给系统写入数据库,同时也将这个数据写入了缓存。此时开始执行98号技术员的写缓存请求,数据集谢八哥的服务技术员=98成功写入缓存。最后发现数据库的值=set谢八哥的维修技师=520,缓存的值=set谢八哥的维修技师=98,缓存中技师520的最新数据被技师旧数据覆盖98。所以在高并发场景下,如果多个线程同时写入数据,然后写入缓存,就会出现缓存是旧值,数据库是最新值的不一致。程序直接通过。如果第一步失败,直接返回50x的异常,不会出现数据不一致的情况。3.先删除缓存,再更新数据库。按照上面说的套路,假设第一次操作成功了,如果第二次操作失败会怎样呢?高并发场景下会发生什么?1)第二步写数据库失败。假设有两个请求:写请求A和读请求B。在写请求A的第一步,成功删除缓存,但是写入数据库失败,会导致写入的数据丢失,并且数据库将保存旧值。然后另一个读请求B进来,发现缓存不存在,从数据库中读取旧数据写入缓存。2)对于高并发下的问题,先删除缓存,再写入数据库。还是让98号技术员先行动比较好。系统收到删除缓存数据的请求。系统准备向数据库写入setXiaocaiji'sservicetechnician=98时,出现卡顿,来不及写入。此时,大堂经理向系统执行读取请求,查询小菜集是否有技术人员接收,以便安排技术人员服务。系统发现缓存中没有数据,于是从数据库中读取旧的数据集。小菜鸡客服=pending,写入缓存。这时原卡顿98号技术员将数据集小菜鸡的服务技术员=98写入数据库,完成操作。这样旧的数据就会被缓存起来,在缓存过期之前无法读取到最新的数据。小菜鸡已经被98号技师录取了,大堂经理却以为没人。这个方案通过了,因为第一步成功了,但是第二步失败了,会导致数据库中有旧数据。如果缓存中没有数据,会继续从数据库读取旧值写入缓存,导致数据不一致,多了一个cachche。无论是异常情况还是高并发场景,都会导致数据不一致。错过。4.先更新数据库,再删除缓存。前面三个解通过后,全部通过。让我们分析一下最终的解决方案是否有效。按照套路判断会出现什么异常和高并发的问题。这个策略可以知道,如果写数据库的阶段失败了,就会给客户端返回一个异常,不需要进行缓存操作。因此,如果第一步失败,则不会出现数据不一致。1)删除缓存的失败点在于,第一步是将最新的数据写入数据库成功。删除缓存失败怎么办?可以将这两个操作放在一个事务中,当缓存删除失败时,回滚对数据库的写入。不适合高并发场景,容易出现大事务,造成死锁问题。如果不回滚,会出现数据库是新数据,缓存还是旧数据,数据不一致。我应该怎么办?所以,我们得想办法让缓存删除成功,否则只能等到有效期到期了。使用重试机制。比如重试3次,如果3次都失败,则记录日志到数据库,使用分布式调度组件xxl-job进行后续处理。在高并发场景下,重试最好采用异步的方式,比如向mq中间件发送消息,实现异步解耦。或者使用Canal框架订阅MySQL的binlog日志,监听对应的更新请求,删除对应的缓存操作。2)下面分析一下高并发场景下高并发读写的问题...先写数据库再删缓存。98号技师率先行动。从小菜鸡手中接过业务后,数据库实现了set小菜鸡技术员=98的服务;网络还卡着,没来得及删缓存。主管Candy向系统执行一个读请求,查看小菜集是否有技术人员接收,发现缓存中有数据。小菜鸡客服=pending,直接将信息返回给客户。主管认为没人接。本来是98号技术员接单的,但是之前因为卡顿没有删除缓存的操作现在删除成功了。一次读请求可能会读到少量旧数据,但很快旧数据就会被删除,后续请求可以获取到最新数据,问题不大。还有一种更极端的情况。缓存自动失效时,遇到高并发读写情况。假设有两个请求,一个线程A做查询操作,另一个线程B做更新操作,那么就会出现如下情况发生:缓存突然失效。缓存失效时间到期,缓存失效。线程A读取请求读取缓存未命中,然后查询数据库得到一个旧值(因为B会写一个新值,相对来说是旧值),当数据写入缓存时,发送网络问题卡住了。线程B执行写操作,将新值写入数据库。线程B执行删除缓存。线程A继续,从冻结中唤醒,将查询到的旧值写入缓存。不一致的可能性非常小。出现上述情况的必要条件是步骤(3)中的写操作比步骤(2)中的读操作耗时更短且速度更快,因此步骤(4)可能先于步骤(5)。缓存刚刚达到其到期日期。通常MySQL单机的QPS在5K左右,TPS在1k左右(ps:Tomcat的QPS在4K左右,TPS=1k左右)。数据库的读操作比写操作快很多(正是因为这样才做了读写分离),所以步骤(3)很难比步骤(2)快,也需要配合缓存失败。因此,在使用旁路缓存策略时,推荐用于写操作:先更新数据库,再删除缓存。4、一致性解决方案有哪些最后,对于Cache-Aside(绕过缓存)策略,当写操作先更新数据库,再删除缓存时,我们来分析一下数据一致性的解决方案有哪些?1.缓存延迟双删先删除缓存再更新数据库如何避免脏数据?采用延迟双删策略。先删除缓存。写入数据库。在删除缓存之前休眠500毫秒。这样,最多只会有500毫秒的脏数据读取时间。关键是如何确定休眠时间?延迟时间的目的是保证读请求结束,写请求可以删除读请求造成的缓存脏数据。因此,我们需要自己评估项目的数据读取业务逻辑的耗时,在读取耗时的基础上增加几百毫秒作为延迟时间。2.删除缓存重试机制删除缓存失败怎么办?比如延迟双删的二次删除失败,说明脏数据无法删除。使用重试机制保证缓存删除成功。比如重试3次,如果3次都失败,会记录日志到数据库,并发出警告,需要人工干预。在高并发场景下,重试最好采用异步的方式,比如向mq中间件发送消息,实现异步解耦。在重试机制的第(5)步中,如果删除失败,没有达到最大重试次数,消息会重新入队,直到删除成功,否则记录到数据库中,人工干预。这个方案有一个缺点,就是对业务代码的侵入,所以接下来的方案就是启动一个专门订阅数据库binlog的服务来读取需要删除的数据,进行缓存删除操作。3、读取binlog异步删除binlog异步删除更新数据库;数据库会在binlog日志中记录操作信息;使用canal订阅binlog日志,获取目标数据和key;缓存删除系统获取渠道数据,解析目标key,尝试删除缓存。如果删除失败,则将消息发送到消息队列;缓存删除系统再次从消息队列中获取数据,再次执行删除操作。总结缓存策略的最佳实践是CacheAsidePattern。它们分为读缓存最佳实践和写缓存最佳实践。读缓存最佳实践:先读缓存,命中则返回;不命中就查询数据库,然后写入缓存。写缓存的最佳实践:先写数据库,再操作缓存;直接删除缓存而不是修改缓存,因为当缓存更新成本高,需要访问多张表联合计算时,建议直接删除缓存而不是更新缓存。另外,删除缓存操作简单,副作用只是多了一次chachemiss。建议您使用此策略。在以上最佳实践下,为了尽可能保证缓存和数据库的一致性,我们可以使用延迟双删。为了防止删除失败,我们使用异步重试机制来确保正确删除。通过异步机制,我们可以向mq消息中间件发送删除消息,或者使用canal订阅MySQLbinlog日志监听写请求,删除对应的缓存。那么,非要保证绝对一致性怎么办呢?我给个结论:没有办法做到绝对一致。这是由CAP理论决定的。属于CAP中的AP。所以,我们不得不妥协,才能做到BASE理论中所说的最终一致性。事实上,一旦在解决方案中使用了缓存,往往意味着我们放弃了数据的强一致性,但同时也意味着我们的系统可以在性能上得到一些提升。所谓权衡就是这样。
