关于缓存的几个常见问题分析和处理方案
一、分布式缓存同步
首先要搞清楚同步的目的,是为了尽可能保证分布式缓存的一致性。目前支持通过Redis
和 Kafka
的发布订阅功能
来实现分布式缓存下不同节点的缓存同步。当然该框架留好扩展点,可以快速便捷的扩展其他MQ来实现缓存同步。
二、缓存更新
缓存更新
包含了对Caffeine
和 redis
的操作,同时会通知其他缓存节点进行缓存更新
操作。
1、主动更新
1)获取缓存时,若缓存不存在或缓存已过期,则重新加载缓存。
*2)源数据变更后,可调用
CacheManagerController.refresh(cacheName,key)
接口重新加载缓存(只对已存在的key重新加载)。
在重构后的版本中已经去掉CacheManagerController
的实现,因为很少会有场景会使用到。
2、自动更新
通过
定期刷新过期缓存
(只对过期缓存进行重新加载),尽可能的保证分布式缓存的一致性。每一个
cacheName
对应一个刷新任务,通过任务调度线程池实现调度。相比第一个版本,粒度更细。如果
L1Cache
是LoadingCache
,并且自定义CuntomCacheLoader
中L2Cache
不为空,则同时刷新L1Cache
和L2Cache
。详见CaffeineCache
。
三、缓存淘汰
缓存淘汰
包含了对Caffeine
和 redis
的操作,同时会通知其他缓存节点进行缓存淘汰
操作。
1、主动淘汰
1)获取缓存时去检查缓存是否过期,若过期则淘汰缓存。
2)结合
@CacheEvict
在源数据修改前或修改后,淘汰缓存。3)源数据变更后,可调用
CacheManagerController.clear(cacheName,key)
接口淘汰缓存。
2、自动淘汰
第一个版本
redis
中的缓存数据是利用redis
的淘汰策略来管理的。具体可参考redis的6种淘汰策略。第二个版本是基于
redisson
实现,而其是通过org.redisson.EvictionScheduler
实例来实现定期清理的,也就是redis
中的缓存不设置过期时间,由应用自身来进行维护。
四、缓存预热
1、手动预热
直接调用标记了
@Cacheable
或CachePut
注解的业务接口进行缓存的预热即可。
2、自动预热
系统启动完毕后,自动调用业务接口将数据加载到缓存。
注:
缓存预热
逻辑需要业务系统自行实现。
五、热点数据
定义:
缓存集群中的某个key瞬间被数万甚至十万的并发请求打爆。
方案:
1、采用本地缓存来缓解缓存集群和数据库集群的压力。本二级缓存框架可完全应对该场景。
2、应用层面做限流熔断保护,保护后面的缓存集群和数据库集群不被打死。
问:怎么保证
redis
中的数据都是热点数据?当
redis
内存数据集上升到一定大小时,通过redis
的淘汰策略来保证。通过maxmemory
设置最大内存。
六、缓存雪崩
1、定义:
由于大量缓存失效,导致大量请求打到DB上,DB的CPU和内存压力巨大,从而出现一系列连锁反应,造成整个系统崩溃。
2、方案:
Caffeine
默认使用异步机制加载缓存数据,可有效防止缓存击穿(防止同一个key或不同key被击穿的场景)。注:结合refreshAfterWrite
异步刷新缓存,。
3、如何预防缓存雪崩
1)缓存高可用
缓存层设计成高可用,防止缓存大面积故障。例如
Redis Sentinel
和Redis Cluster
都实现了高可用。
2)缓存降级
利用本地缓存,一定程度上保证服务的可用性(即使是有损的)。但主要还是通过对源服务的访问进行限流、熔断、降级等手段。
3)提前演练
建议项目上线前,演练缓存层宕机后,应用以及后端的负载情况以及可能出现的问题,对高可用提前预演,提前发现问题。
七、缓存击穿
1、定义:
在平常高并发的系统中,大量的请求同时查询一个 key 时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿。
注:缓存击穿也可以理解为是热点数据的一种场景。
2、方案:
Caffeine
默认使用异步机制加载缓存数据,可有效防止缓存击穿(防止同一个key或不同key被击穿的场景)。
八、缓存穿透
1、定义:
请求根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透。
2、方案:
通过对不存在的key缓存空值,来防止缓存穿透。
注:也可以采用
BloomFilter
来对key进行过滤(暂未实现)。
注:对于高并发系统,可以结合
Hystrix
或Sentinel
来做应用级别的限流和降级,以保护下游系统不会被大量的请求给打死。
九、分布式缓存一致性保证
尽可能保证集群环境下各个节点中L1
的一致性。
1、缓存不一致场景分析:
1、请求走
节点A
获取数据key1,本地缓存和redis中无缓存,则从DB加载数据,并添加到本地缓存和redis
。然后发送redis
消息,通知其他节点。2、请求走
节点B
获取数据key1,本地缓存无,redis
中有,则添加到本地缓存3、请求走
节点A
获取数据key1,缓存过期,则从DB加载数据,并添加到本地缓存和redis
。
然后发送redis
消息,通知节点B
重新加载缓存key1,来保证不同节点的缓存一致性。
2、描述:
因为
Caffeine
在初始化时就指定了缓存过期时间,所以同一个缓存下的key的过期时间是固定的。那么节点B
通过消息重新加载缓存到本地后,该key1在节点B
的过期时间与在节点A
上的过期时间是不一致的,实质表现是节点A
的缓存key1已过期,但节点B
的缓存key1未过期。
假设后续的请求一直落在节点B
上,也就会出现获取到过期缓存key1,这种现象的本质是缓存一致性问题,要怎么解决呢?
3、分析:
如果可以让
节点B
上的缓存key1在同一时间点10过期,那岂不是完美。
4、方案:
1)
节点B
在获取缓存key1时就设置过期时间点为10。具体通过自定义
Caffeine
的Expiry
来实现。缺点:使用了自定义
Expiry
后,如果并发获取key1,那么只有一个线程会去加载数据,其他线程均会阻塞。2)
节点A
上的key1在过期时通知节点B
。具体可以通过定时任务来刷新过期缓存。
缺点:该方案在时间窗口内会出现缓存不一致的情况。
注:本组件采用
redis发布订阅功能
和定时刷新过期缓存
来尽可能保证缓存一致性。
十、定期刷新过期缓存的实现
本来是想通过LoadingCache.refresh(key)
来刷新缓存,但refresh()
不管key有没有过期都会重新加载数据,所以不合适;期望是只加载过期缓存,那么该怎么实现呢? 经分析发现可以通过LoadingCache.get(key)
来达到只对过期缓存重新加载的目的。
十一、其他
经过一些途径发现市面上已经存在一些二级缓存的解决方案的实现,如:
1、Redisson PRO 支持的Spring缓存功能,其中 RedissonSpringLocalCachedCacheManager 支持本地缓存。
https://github.com/redisson/redisson/wiki/14.-Integration-with-frameworks#142-spring-cache
2、J2Cache 是 OSChina 目前正在使用的两级缓存框架。l2cache与J2Cache 相似,但又发展出不同的特性。
https://gitee.com/ld/J2Cache