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定时在数据库负载较低的时候写入数据库。可以看出,此方案的缓存与数据库为弱一致性,且有丢数据的风险,需做好缓存的高可用,此方案对于一致性要求高的系统应慎用。
适用场景:写入速度快,适用于大量写入的场景(实际上在大型互联网应用中大多都是读多写少的场景),电商秒杀场景中库存的扣减常用该模式。
Tip:InnoDB Buffer Pool 也用此机制
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