缓存最佳实践

一、缓存设计模式

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

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


业务缓存的设计模式(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知识宝典》 

  • 33
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值