缓存最佳实践

一、缓存设计模式

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

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

业务缓存的设计模式(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,再删cache | miss后查询DB回写cache | 高一致性 |
| 穿透 | hit则更新DB和cache,miss仅更新DB | miss后查询DB回写cache | 冷热分区 |
| 异步 | 只更新cache,异步更新DB | miss后查询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知识宝典》

相关推荐
RoboWizard3 小时前
双接口移动固态硬盘兼容性怎么样?
人工智能·缓存·智能手机·电脑·金士顿
honortech6 小时前
外部连接 redis-server 相关配置
数据库·redis·缓存
不会写程序的未来程序员6 小时前
Redis 的内存回收机制详解
数据库·redis·缓存
不会写程序的未来程序员7 小时前
Redis 主从同步原理详解
数据库·redis·缓存
嘻哈baby7 小时前
Redis突然变慢,排查发现是BigKey惹的祸
数据库·redis·缓存
TDengine (老段)8 小时前
TDengine 数据缓存架构及使用详解
大数据·物联网·缓存·架构·时序数据库·tdengine·涛思数据
键来大师8 小时前
Android16 RK3576 系统清理缓存
android·缓存·framework·rk3588·android15
Ghost Face...8 小时前
深入解析dd命令:缓存与磁盘速度之谜
linux·缓存
我要精通C++8 小时前
从源码看nginx的缓存功能
运维·nginx·缓存
Mr.Pascal17 小时前
Redis:主动更新,读时更新,定时任务。三种的优劣势对比
数据库·redis·缓存