• 缓存最佳实践


    一、缓存设计模式

            在实际业务场景中,会经常使用到数据库和缓存,缓存一般用来提升效率,数据库用于保证数据完整性,那如何使用缓存呢?缓存和数据库如何同步呢?其中就诞生了有很多的方案。那实际上我们该使用哪种方案呢,其实,基于性能和一致性的权衡,在不同的场景可以使用不同的策略。

    接下来详细介绍一下各种缓存策略并且他们适用的一些业务场景:


    业务缓存的设计模式(DB泛指数据源,cache泛指快速路径上的局部数据源

    1. 旁路缓存策略

      • 写时:先更新数据库再删除缓存。
      • 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
      • 适用场景:需要高一致性的场景,如金融交易系统或者账户余额查询等。
    2. 读写穿透策略

      • 写时:先查缓存,如果缓存命中,则更新数据库和缓存;如果缓存未命中,则只更新数据库。
      • 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
      • 适用场景:针对热点数据和冷热分区的系统,如热门商品查询或者地区性数据查询。
    3. 异步写入策略:

      • 写时:只更新缓存,并异步更新数据库。
      • 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
      • 适用场景:需要高写入频率的场景,如社交网络中的消息发布和评论等。
    4. 兜底策略:

      • 写时:直接写入数据库。
      • 读时:如果数据库查询失败,则读取缓存;如果数据库查询成功,则回写缓存。
      • 适用场景:对可用性要求较高的系统,如在线支付系统或者实时监控系统。
    5. 只读策略:

      • 写时:直接写入数据库。
      • 读时:只能读取缓存数据,不能写入。其他更新缓存的操作采用异步方式。
      • 使用场景:适用于读取频繁、写入不频繁的场景,如新闻资讯类应用或者商品展示页面。
    6. 回源策略:

      • 写时:直接写入数据库。
      • 读时:直接从数据库读取数据,不使用缓存。
      • 适用场景:在缓存降级期间,需要直接从数据源获取数据的场景,如系统升级或者缓存失效时的数据访问。

    实际业务中,旁路缓存策略、读写穿透策略、异步写入策略用的最多。

    1. 旁入缓存策略:一致性高,所以经常用来显示一些实时数据;缺点:在大数据量或者频繁操作的时候,性能不是很好;
    2. 读写穿透策略:缓存中存在的数据会直接更新缓存及数据库,无需经过查询数据库再写入缓存的过程,主打一个性能的提升;而缓存中不存在的数据就需要从数据库拿了。所以对存在的数据性能比较高,无需下次再查写入缓存,常用于冷热分区;缺点:一致性不高,数据库和缓存可能会出现数据不一致的问题;
    3. 异步写入策略:针对于并发量比较高或者写多读少的场景,每次只更新缓存,异步同步至数据库,可以用RocketMq进行一个异步同步;缺点:可能会出现更新缓存失败或者同步至数据库失败的问题,需要做一些补偿机制去保证最终一致性。

    那说到底,还是一致性和性能的一个权衡。

    二、缓存一致性探讨

    在实际业务场景中,会涉及到缓存一致性相关的问题,那保证一致性有很多的方案,如下:

    1. 先更新数据库,再更新缓存
    2. 先更新缓存,再更新数据库;
    3. 先更新数据库,再删除缓存
    4. 先删除缓存,再更新数据库
    5. 只更新数据库,异步更新缓存

    等等......

    那接下来我们以MySQL和Redis为例,探讨一下它的各种保证一致性的方案,看哪种方案能够最大限度的保证数据一致性

    1、第一种,先更新数据库再更新缓存

            由图可知,线程A最开始抢到了资源,将数据库更改为 1,但是它最后才更新缓存,导致缓存 1 把 2 覆盖掉了。此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。所以第一种方案是不行的。

    2、第二种,先更新缓存再更新数据库

             同样,线程A最开始抢到了资源,把缓存更新为 1,但是它最后才更新数据库,导致数据库 1 把 2 覆盖掉了。此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。所以第二种方案也不行。

    3、第三种,先删除缓存,再更新数据库

            线程A先抢到资源,将缓存进行删除,准备把数据库更新为 21,但是途中线程B突然冒出来,查redis的数据,而此时redis数据被删了,只能从数据库拿并放回redis,也就是把 20放入了缓存中,而此时在数据库中是 21,同样出现了缓存和数据库的数据不一致的问题。

            可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。所以方案三也不行。

    4、第四种,先更新数据库,再删除缓存,也就是旁路缓存策略

            同样线程A先抢到资源,但是此时线程A是从redis拿数据,因为redis没有,所以会从数据库拿到 20 的值,这时线程B进行一个更新数据库并删除缓存,线程A刚好在线程B删除完以后,再将 20回写至缓存,仍然出现了数据不一致的问题。

            虽然从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。

            因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

            而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

    所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。

    但是有没有可能,就是说在删除缓存的时候,失败了,导致缓存中的值还是旧值,那怎么办呢?

    有两种方法:

    • 重试机制。
    • 订阅 MySQL binlog,再操作缓存。
    1)重试机制

    我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

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

    举个例子,来说明重试机制的过程。

    2)订阅 MySQL binlog,再操作缓存

            「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

            于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

            Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

    下图是 Canal 的工作原理:

            所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

    5、第五种,延迟双删

    延迟双删,其实两种方案都行;

    1. 先删除缓存 + 更新数据库 + 延时 + 再次删除缓存
    2. 先更新数据库 + 删除缓存 + 延时 + 再次删除缓存

    那这两种有什么区别呢?其实主要就是一个延时时间的区别。

    1)假设是第一种,两个线程

    • 线程A:删除缓存 + 更新数据库为 1 +  延时 + 删除缓存
    • 线程B:从数据库获取数据 + 将数据写入缓存

    为了避免数据不一致性,那我线程A只需要等线程B完成【从数据库获取数据 + 将数据库写入缓存】这段时间过去再第二次删除即可。

    即延时时间为:从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)

    2)假设是第二种,两个线程

    • 线程A: 更新数据库为 2 + 删除缓存 + 延时 + 删除缓存
    • 线程B:从数据库获取数据 + 将数据写入缓存

    延时时间在上一个的基础上,即【从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)】,再减去个第一次删除缓存的时间即可!

    即延时时间为:从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)-  删除一次缓存时间

    总结

    1、首先针对不同的场景,我们可以做不同的一个缓存策略,如图:
    缓存策略写入时读取时适用场景
    旁路先更新DB,再删cachemiss后查询DB回写cache高一致性

    穿透

    hit则更新DB和cache,miss仅更新DBmiss后查询DB回写cache冷热分区
    异步只更新cache,异步更新DBmiss后查询DB回写cache高频写入
    兜底直接写DB先读DB,hit则更新cache,miss则读cache高可用
    只读直接写DB只读cache,并通过其它更新方式异步更新缓存最终一致性
    回源直接写DB查询DB回写cache缓存降级

    其中DB代表数据库,cache代表缓存。

    2、【先更新数据库 + 删除缓存】就可以解决数据库和缓存数据不一致问题,对于删除失效场景,可以使用消息队列重试机制,或者使用binlog的canal组件进行一个监听;
    3、正常来说,缓存的写入通常要远远快于数据库的写入,所以几乎不会出现一个A线程写完了数据库,又删除了缓存,这个时候另一个B线程才开始写缓存的情况;
    4、当然,保险起见,你可以使用延迟双删策略,等B线程读取数据库并将数据写入缓存之后,A线程再进行第二次删除,这时如果删除失败也和之前一样重试机制或者使用cancal组件!!!

    ps:以下是我整理的java面试资料,感兴趣的可以看看。最后,创作不易,觉得写得不错的可以点点关注!

    链接:https://www.yuque.com/u39298356/uu4hxh?# 《Java知识宝典》 

  • 相关阅读:
    面试突击77:Spring 依赖注入有几种?各有什么优缺点?
    Python可以处理Excel文件
    阿里云物联网平台app端一直上线下线
    dev C++5.11的使用技巧:调试、快捷键等(备战蓝桥杯)
    分账管理有哪些功能?
    记一次Docker容器失败的逃逸
    【python画画】蘑菇云爱心
    基于HTML游戏网站项目的设计与实现 HTML+CSS+JavaScript电竞游戏介绍响应式网页(7页)
    希望所有计算机学生都知道这些宝藏网站
    Linux (五)- mv 命令
  • 原文地址:https://blog.csdn.net/qq_53281187/article/details/137215023