缓存一致性的常见方案

Cache Aside Pattern - 旁路缓存模式
  • 读请求:如果未命中缓存则查询数据库并更新至缓存,否则返回缓存中数据
  • 写请求:先更新数据库,再删除缓存(非延迟双删)

在这里插入图片描述

写请求为啥不更完DB直接更缓存?

Cache Aside 模式的读请求处理流程应该很好理解,但对于写请求大家或许会有疑问,为何写完库不直接更缓存?从直觉上而言直觉更缓存似乎更容易被理解,但实际上要从两个方面考虑:性能与安全。

  • 从性能方面考虑,当写请求较多时,可能出现一个线程刚更完缓存就被重新更新了(此现象业界称之为缓存扰动),那么机器的性能会被白白浪费,缓存利用率也不高,而等到读请求读完再更新缓存,也符合懒加载的思想。
  • 从安全性上考虑,当出现写写线程并发执行时,缓存容易出现脏数据,下面会逐步踩坑。

开始踩坑

1、先更数据库,再更缓存?(写写情况下易出问题)

假如执行过程为:

1、线程1更新数据库

2、线程2更新数据库

3、线程2更新缓存

4、线程1更新缓存

很明显执行完毕后,缓存中的数据为脏数据,出现数据不一致问题。

而之所以大概率出现这个问题,是因为过程3与4的执行均为操作缓存,速度差不多,所以缓存出现脏数据的概率很大。

在这里插入图片描述

2、先删缓存,再更新数据库?(写读情况下易出问题)

假设执行过程为:

1、写请求先删除缓存

2、读请求查询缓存,发现不存在,则查询数据库

3、读请求写入缓存

3、写请求更新数据库

在这里插入图片描述

3、先更数据库,再删缓存(推荐)

假设执行过程为:

1、读请求先查询缓存,未击中,查询数据库返回18

2、写请求更新数据库,删除缓存(删了个寂寞)

3、读请求才回写缓存

同理也有可能出现数据不一致问题,但实际上出现概率会很小,因为数据库的更新操作要比内存操作慢几个数量级,所以理论上过程3回写入缓存速度会快于过程2更新数据库的操作。如何避免这种问题?通常我们会有一个兜底方案,就是设置缓存过期时间,允许一定时间内的数据库与缓存的数据不一致。

但加上过期时间并非就是完美的,假设过程2写完库后,删除缓存失败,那么也会导致数据不一致问题的出现,而考虑到这种场景,第一种方案是直接对更新数据库与删除缓存的操作加一把分布式锁,但加锁必然带来吞吐量的下降,需综合考虑,另外一种则是对删除操作做一些补偿的措施,下面谈及延迟删除策略会对删除缓存的补偿方案做进一步的解析。

在这里插入图片描述

4、结合2和3,做延迟双删(推荐)

1、先删除缓存
2、更新数据库
3、休眠一会儿(比如1s),之后再删一次缓存数据
在这里插入图片描述
上述方案在极端情况下,如果第三步删除失败仍然可能导致数据一致性问题,解决方案有:

1、引入MQ重试机制。假设更完库后删除失败,则把失败的key丢到MQ中,由mq消费端拉出来进行删除重试。这种方案的弊端是对于删除失败的处理逻辑需要基于业务代码的 trigger 来触发,对业务代码侵入性较为严重。

2、基于数据库binlog的方式增量解析、订阅和消费。为了保证删除成功,可以利用阿里巴巴开源中间件canal订阅binlog发送到MQ中,再利用MQ的ACK机制来保证删除成功,最终保证数据缓存一致性(比如更新了uid=2这个用户信息,那么可以读取binlog中uid=2的log,然后删除缓存中key={user:2}这个key)。

在这里插入图片描述

延伸1:Alibaba开源组件canal
  • canal的原理:
    模仿MySQL Slave发送dump请求到MySQL服务,MySQL服务接受到该请求后,会将binary log推送给canal server,由Canal Server解析binary log对象(byte流),也就是我们常说的binlog。再由canal client拉取进行消费,且canal server也支持投递到如Kafka、RocketMQ这样的MQ系统中,让其他系统可以消费到,在其ACK机制的加持下,无论推送或拉取都可以保证数据按预期被消费到。
  • canal依赖于zookeeper来实现HA,并且为了减轻MySQL dump的压力,canal同一时刻只允许一个server instance处于运行状态,其他instance则为standby状态。处理之外,为了保证消费的有序性,对于一个instance同一时刻只能由一个canal client进行get/ack等操作。
延伸2:MySQL主从部署,如何解决主从同步延迟?

**监听从库的binlog,**保证最终删除的操作一定发生在更新数据库之后。

如下图,就是A先删除缓存(不管成功或失败都不影响,因为失败了最终通过binlog会删除的),再更新DB,而此时因为主从可能存在延迟,所以B在cache miss之后从从库可能读取到旧数据写入缓存(脏数据)或者还有一种情况就是A删除缓存失败并且同步有延迟,那么B读取旧缓存,但是不影响,因为最后主从同步成功之后通过canal将binlog数据写入MQ,消费者可以根据更新的log数据删除缓存,并且通过ACK机制确认处理这条更新log,以保证数据缓存一致性。
在这里插入图片描述

Read Though Pattern - 读穿透模式

简单来说,区别于Cache Aside的是应用程序不需再管理缓存和数据库,只需从独立的缓存提供程序Cache Provider中获取即可。

好处:是独立管理可以减少数据源上的负载,也让应用端的容错性更佳(如果缓存服务挂了,Cache Provider仍然可以通过直接转到数据源上进行操作,不影响应用端的使用)

适用场景:多次请求相同数据的场景。(guavacache采用该模式)
在这里插入图片描述

Write Trough Pattern - 直写模式

与Read Aside类似,增加了一个Cache Provider代理层来处理更新底层数据源和缓存。与Cache Aside不同的是,在写请求更完库之后Write Through是直接写入缓存,而不是删除缓存。

适用场景:写操作较多,且一致性要求高的场景,并且为了避免前面提到并发双写导致的一致性问题,需要给更新数据库和更新缓存的操作加一把分布式锁,牺牲掉一部分的性能。
在这里插入图片描述

Write Behind Pattern - 异步回写模式

简单来说就是先写缓存,再由Cache Provider定时在数据库负载较低的时候写入数据库。可以看出,此方案的缓存与数据库为弱一致性,且有丢数据的风险,需做好缓存的高可用,此方案对于一致性要求高的系统应慎用。

适用场景:写入速度快,适用于大量写入的场景(实际上在大型互联网应用中大多都是读多写少的场景),电商秒杀场景中库存的扣减常用该模式。

在这里插入图片描述

Write Around

对于一些一致性要求较低的业务,也可以选择Wrtie Aound模式。在该模式下,读请求对于缓存的写入都需要设一个expired time,而写请求在更新数据库后不会对缓存有任何操作。这种方案的优点很明显,实现简单,缺点是数据的弱一致性。

总结

四种策略:
  • Cache Aside Pattern 首选先写入数据库后删除缓存的策略,再增加缓存时间兜底。对于一致性要求更高的场景,考虑用订阅MySQL binlog+MQ的方式做延迟双删。
  • Read/Write through Pattern 增加一个Cache Provider 对外提供读写操作,解耦且避免缓存服务挂掉给应用系统带来的影响。
  • Write Behind Pattern 应用只写缓存,再由Cache Provider定时入库,数据一致性差
  • Write Around 由读请求写缓存并设置一个过期时间,写请求只负责写库

参考文献
https://github.com/CoderLeixiaoshuai/java-eight-part/blob/master/docs/redis/高并发场景下,到底先更新缓存还是先更新数据库?.md#cache-aside

https://www.modb.pro/db/237472

https://xie.infoq.cn/article/1322475e05c11bd2aacd8bc73

https://cdmana.com/2022/01/202201191744387996.html

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值