在实际业务场景中,会经常使用到数据库和缓存,缓存一般用来提升效率,数据库用于保证数据完整性,那如何使用缓存呢?缓存和数据库如何同步呢?其中就诞生了有很多的方案。那实际上我们该使用哪种方案呢,其实,基于性能和一致性的权衡,在不同的场景可以使用不同的策略。
业务缓存的设计模式(DB泛指数据源,cache泛指快速路径上的局部数据源)
旁路缓存策略:
- 写时:先更新数据库再删除缓存。
- 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
- 适用场景:需要高一致性的场景,如金融交易系统或者账户余额查询等。
读写穿透策略:
- 写时:先查缓存,如果缓存命中,则更新数据库和缓存;如果缓存未命中,则只更新数据库。
- 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
- 适用场景:针对热点数据和冷热分区的系统,如热门商品查询或者地区性数据查询。
异步写入策略:
- 写时:只更新缓存,并异步更新数据库。
- 读时:先查缓存,命中则直接返回缓存数据;如果缓存未命中,则查询数据库并回写缓存。
- 适用场景:需要高写入频率的场景,如社交网络中的消息发布和评论等。
兜底策略:
- 写时:直接写入数据库。
- 读时:如果数据库查询失败,则读取缓存;如果数据库查询成功,则回写缓存。
- 适用场景:对可用性要求较高的系统,如在线支付系统或者实时监控系统。
只读策略:
- 写时:直接写入数据库。
- 读时:只能读取缓存数据,不能写入。其他更新缓存的操作采用异步方式。
- 使用场景:适用于读取频繁、写入不频繁的场景,如新闻资讯类应用或者商品展示页面。
回源策略:
- 写时:直接写入数据库。
- 读时:直接从数据库读取数据,不使用缓存。
- 适用场景:在缓存降级期间,需要直接从数据源获取数据的场景,如系统升级或者缓存失效时的数据访问。
实际业务中,旁路缓存策略、读写穿透策略、异步写入策略用的最多。
那说到底,还是一致性和性能的一个权衡。
在实际业务场景中,会涉及到缓存一致性相关的问题,那保证一致性有很多的方案,如下:
等等......
由图可知,线程A最开始抢到了资源,将数据库更改为 1,但是它最后才更新缓存,导致缓存 1 把 2 覆盖掉了。此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。所以第一种方案是不行的。
同样,线程A最开始抢到了资源,把缓存更新为 1,但是它最后才更新数据库,导致数据库 1 把 2 覆盖掉了。此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。所以第二种方案也不行。
线程A先抢到资源,将缓存进行删除,准备把数据库更新为 21,但是途中线程B突然冒出来,查redis的数据,而此时redis数据被删了,只能从数据库拿并放回redis,也就是把 20放入了缓存中,而此时在数据库中是 21,同样出现了缓存和数据库的数据不一致的问题。
可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。所以方案三也不行。
同样线程A先抢到资源,但是此时线程A是从redis拿数据,因为redis没有,所以会从数据库拿到 20 的值,这时线程B进行一个更新数据库并删除缓存,线程A刚好在线程B删除完以后,再将 20回写至缓存,仍然出现了数据不一致的问题。
虽然从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
但是有没有可能,就是说在删除缓存的时候,失败了,导致缓存中的值还是旧值,那怎么办呢?
有两种方法:
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
举个例子,来说明重试机制的过程。
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
延迟双删,其实两种方案都行;
- 先删除缓存 + 更新数据库 + 延时 + 再次删除缓存
- 先更新数据库 + 删除缓存 + 延时 + 再次删除缓存
那这两种有什么区别呢?其实主要就是一个延时时间的区别。
1)假设是第一种,两个线程
为了避免数据不一致性,那我线程A只需要等线程B完成【从数据库获取数据 + 将数据库写入缓存】这段时间过去再第二次删除即可。
即延时时间为:从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)
2)假设是第二种,两个线程
延时时间在上一个的基础上,即【从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)】,再减去个第一次删除缓存的时间即可!
即延时时间为:从数据库获取数据 + 将数据写入缓存 + 网络抖动时间(一般20ms-30ms)- 删除一次缓存时间
缓存策略 | 写入时 | 读取时 | 适用场景 |
旁路 | 先更新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代表缓存。
ps:以下是我整理的java面试资料,感兴趣的可以看看。最后,创作不易,觉得写得不错的可以点点关注!
链接:https://www.yuque.com/u39298356/uu4hxh?# 《Java知识宝典》