本地 Cache 的老大哥Guava Cache 通过简单好用的 Client 可以快速构造出符合需求的 Cache 对象,不需要过多复杂的配置,大多数情况就像构造一个 POJO 一样的简单。
一、Guava Cache实现
这里我们是通过CacheLoader的方式来对Guava Cache进行实现,但是它还可以通过Callable的方式实现。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.0-jre</version>
</dependency>
/**
* @author zheyue
* @date 2021/11/3
**/
@Slf4j
@Service
public class CacheService{
// 缓存实现
private LoadingCache<Long, OrderInfoDTO> loadingCache = CacheBuilder
.newBuilder()
// 设置最大容量,超过容量有对应的淘汰机制
.maximumSize(100)
// 设置过期时间
.expireAfterWrite(5, TimeUnit.MINUTES)
// 设置刷新时间
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(new CacheLoader<Long, OrderInfoDTO>() {
@Override
public OrderInfoDTO load(Long orderId) throws Exception {
return loadOrder(orderId);
}
});
/**
* get order, and transfer
* @param orderId
* @return
*/
private OrderInfoDTO loadOrder(Long orderId){
OrderInfoDTO orderInfoDTO = new OrderInfoDTO();
OrderInfo orderInfo = orderInfoDAO.selectByOrderId(orderId);
if (orderInfo != null) {
BeanUtils.copyProperties(orderInfo, orderInfoDTO);
}
return orderInfoDTO;
}
/**
* 外部调用
* @param orderId
* @return
*/
public OrderInfoDTO getOrderInfoDTO(Long orderId){
try{
return loadingCache.get(orderId);
}catch (Exception e){
log.error("get order info from cache error,orderId:" + orderId, e);
return null;
}
}
}
构造 LoadingCache 的关键在于实现 load 方法,也就是在需要访问的缓存项不存在的时候 Cache 会自动调用 load 方法将数据加载到 Cache 中,同时还有设置缓存的一些性质,比如刷新策略,过期策略等。
二、缓存项加载、刷新机制
在这里跟大家讨论最关注的话题:缓存击穿和缓存雪崩,关于击穿和雪崩的定义在Redis一文中做了详细介绍,可以参考。
-
缓存项加载机制
对于缓存击穿的解决思路在于:我们只让一个线程去加载数据生成缓存项,其他线程等待然后读取生成好的缓存项即可解决击穿问题。其实 Guava Cache 在 load 的时候做了并发控制,在多个线程请求一个不存在或者过期的缓存项时保证只有一个线程进入 load 方法,其他线程等待直到缓存项被生成,这样就避免了大量的线程击穿缓存直达 DB 。不过试想下如果有上万 QPS 同时过来会有大量的线程阻塞导致线程无法释放,甚至会出现线程池满的尴尬场景,这也是说为什么这个方案解了 “缓存击穿” 问题但又没完全解。
上述机制其实就是 expireAfterWrite(expireAfterAccess也可以是实现) 来控制的,如果你配置了过期策略对应的缓存项在过期后被访问就会走上述流程来加载缓存项。
-
缓存项刷新机制
缓存刷新机制目的在于使数据处于最新的状态,他跟加载机制不同的是,刷新机制是一个主动触发的过程。如果缓存项不存在或者过期只有下次 get 的时候才会触发新值加载。而缓存刷新则更加主动替换缓存中的老值,所以说缓存刷新的项目一定是存在缓存中。
由于缓存项刷新的前提是该缓存项存在于缓存中,那么缓存的刷新就不用像缓存加载的流程一样让其他线程等待而是允许一个线程去数据源获取数据,其他线程都先返回老值直到异步线程生成了新缓存项,这个方案完美解决了上述遇到的 “缓存击穿” 问题
刷新机制通过 refreshAfterWrite 来实现,在配置刷新策略后,对应的缓存项会按照设定的时间定时刷新,避免线程阻塞的同时保证缓存项处于最新状态。
但他也不是完美的,比如他的限制是缓存项已经生成,并且如果恰巧你运气不好,大量的缓存项同时需要刷新或者过期, 就会有大量的线程请求 DB,这就是常说的 “缓存血崩”。
对于出现的雪崩问题,我们可以通过限制访问 DB 的数量来控制,可以通过添加一个异步线程池异步刷新数据(可以通过重写CacheLoader的reload方法实现)。private LoadingCache<Long, OrderInfoDTO> asynLoadingCache = CacheBuilder .newBuilder() .build( CacheLoader.asyncReloading(new CacheLoader<Long, OrderInfoDTO>() { @Override public OrderInfoDTO load(Long orderId) throws Exception { return loadOrder(orderId); } @Override public ListenableFuture<OrderInfoDTO> reload(Long orderId, OrderInfoDTO orderInfoDTO) throws Exception { return super.reload(orderId, orderInfoDTO); } }, new ThreadPoolExecutor( 5, 100, 1, TimeUnit.MINUTES, new SynchronousQueue<>())) );
看源码可以发现LocalCache本质是基于ConcurrentMap实现的,LocalCache缓存get流程实现:
参考于:阿里巴巴淘系技术团队