面试经常会被问到如何解决缓存击穿问题,今天就来带你弄懂他!平时业务中也会经常使用到本地缓存,公司里使用比较多的本地缓存 loadingcache,其背后的架构就是Guava cache,Guava Cache 是一个全内存的本地缓存实现,它提供了线程安全的实现机制。 整体上来说Guava Cache 是本地缓存的不二之选。
适用场景
适合少量热点数据缓存(受限于内存大小),解决缓存击穿问题, 可以使用LRU作为淘汰缓存策略。
愿意以空间换时间,缓存数据到本地内存(没有网络IO,速度快)
允许在重新load前读到的是脏数据(对同一数据一直访问, 且间隔小于失效时间, 则不会去load数据)
可以监听Entry清除状态
支持缓存命中情况统计
com.google.guava
guava
24.1-jre
private final LoadingCache entityCache = CacheBuilder.newBuilder()
// 缓存池大小,在缓存数量到达该大小时, 开始回收旧的数据
.maximumSize(10000)
// 设置时间10s对象没有被读/写访问则对象从内存中删除
.expireAfterAccess(10, TimeUnit.SECONDS)
// 设置缓存在写入之后 设定时间10s后失效
.expireAfterWrite(10, TimeUnit.SECONDS)
// 定时刷新,设置时间5s后,当有访问时会重新执行load方法重新加载
.refreshAfterWrite(5, TimeUnit.SECONDS)
// 移除监听器,缓存项被移除时会触发
.removalListener(new RemovalListener() {
@Override
public void onRemoval(RemovalNotification rn) {
// 处理缓存键不存在缓存值时的**移除**处理逻辑
log.error(rn.getKey() + "remove");
}
})
// 处理缓存键对应的缓存值不存在时的处理逻辑
.build(new CacheLoader() {
@Override
public Entity load(Long id) {
return EntityService.getById(id);
}
});
public Entity getEntity(Long id) {
Entity entity = entityCache.get(id);
}
public ImmutableMap getAll(List ids) throws ExecutionException {
return cache.getAll(ids);
}
参数 |
说明 |
注意事项 |
maximumSize |
缓存的k-v最大数据,当总缓存的数据量达到这个值时,就会淘汰它认为不太用的一份数据,会使用LRU策略进行回收 |
|
expireAfterAccess |
缓存项在给定时间内没有被读/写访问,则回收,这个策略主要是为了淘汰长时间不被访问的数据 |
数据过期不是立即淘汰,而是有数据访问时才会触发 |
expireAfterWrite |
缓存项在给定时间内没有被写访问(创建或覆盖),则回收, 防止旧数据被缓存过久 |
同上 |
refreshAfterWrite |
缓存项在给定时间内没有被写访问(创建或覆盖),则刷新 |
同上 |
recordStats |
开启Cache的状态统计(默认是开启的) |
可能会影响到性能 |
单个清除:Cache.invalidate(key)
批量清除:Cache.invalidate(keys)
清除所有:Cache.invalidateAll()
底层数据结构是一个K.V的存储结构,这个图我想应很明显了,这分明就就是ConcurrentHashMap的结构,底层是一个segment数组,链表的节点和ConcurrentHashMap不太一样,是一个每一个segment是一个节点为ReferenceEntry
V get(K key, int hash, CacheLoader super K, V> loader) throws ExecutionException {
try {
if (count != 0) { // read-volatile
ReferenceEntry e = getEntry(key, hash);
if (e != null) {
long now = map.ticker.read();
//检查entry是否符合expireAfterAccess淘汰策略
V value = getLiveValue(e, now);
// value是有效的 则返回
if (value != null) {
// 记录该值的最近访问时间
recordRead(e, now);
statsCounter.recordHits(1);
// 内部实现了定时刷新,若未开启refreshAfterWrite则直接返回value
return scheduleRefresh(e, key, hash, value, now, loader);
}
ValueReference valueReference = e.getValueReference();
// 如果有别的线程已经在load value,则等到其他线程完成后再取结果
if (valueReference.isLoading()) {
return waitForLoadingValue(e, key, valueReference);
}
}
}
// 如果没拿到有效的value,则执行加载逻辑;
return lockedGetOrLoad(key, hash, loader);
} catch (ExecutionException ee) {
...
} finally {
postReadCleanup();
}
}
先获取未过期的值(指内存中已经存在的,未符合expireAfterAccess淘汰策略),recordRead方法则是记录该值的最近访问时间,然后判断执行scheduleRefresh方法。
这个方法里先是判断是否设置了refreshAfterWrite属性,并判断当前时间是否符合刷新策略。符合则调用refresh进行刷新操作
@GwtCompatible(emulated = true)
public abstract class CacheLoader {
public abstract V load(K key) throws Exception;
}
key对应的value不存在(或已过期)会触发load方法。
load方法是同步的,对于同一个key,多次请求只会触发一次加载。
在Thread1进行load加载完成之前,这些请求线程都会被hang等待。
// Guava的默认实现是同步的
public ListenableFuture reload(K key, V oldValue) throws Exception {
checkNotNull(key);
checkNotNull(oldValue);
return Futures.immediateFuture(load(key));
}
当cache中有值,但需要刷新该值的时候会触发reload方法。
LoadingCache的所有更新操作都是依靠读写方法触发的,因为其内部没有时钟或者定时任务。比如上一次写之后超过了refresh设置的更新时间,但之后没有cache的访问了,那么下次get的时候才会触发refresh。
对于同一个key,多次请求只会有一个线程触发reload,其他请求线程直接返回旧值。
同步模式,会阻塞用户请求线程。
new CacheLoader() {
@Override
public Entity load(Long entityId) {
return EntityService.getById(entityId);
}
}
根据Guava的API实现的异步CacheLoader,refresh操作不堵塞任何一个用户请求线程。
相对于Guava中默认实现的reload,只减少了“一个”线程的阻塞。
/**
* 这个类只是改写了reload方法,配合refreshAfterWrite异步刷新
* 避免因为使用expireAfterWrite造成缓存miss时请求线程回流影响用户请求
*
* 代价就是一个额外的线程调度更新
*
* @author w.vela
*/
public abstract class AsyncReloadCacheLoader extends CacheLoader {
/**
* 请务必要覆盖这个名字,不然有人会不开心的……
*/
protected String statsName() {
return "unknown_async_cache_reloader";
}
@Override
public ListenableFuture reload(K key, V oldValue) throws Exception {
ListenableFutureTask task = create(() -> load(key));
ExecutorHolder.execute(statsName(), task::run);
return task;
}
}
此类实现不推荐使用,存在一些问题:
共用一个全局线程池,线程池不为使用者所感知,不同使用方可能相互影响;
集中大量发生reload是出现频繁线程创建和销毁。
推荐替代方式:
直接使用CacheLoader,override reload方法,提供自己的异步实现。异步实现可以使用支持异步调用的API(如直接使用grpc异步)
如果没有异步调用API可以自己提供一个线程池用来做异步化。
如果有大量集中refresh的情况,可以使用BatchReloadCacheLoader 批量处理
相比于AyncReloadCacheReloader,优点在于:使用 BufferTrigger(本地归并消费)将单个 cache refresh 操作聚合成为批量 refresh,减少线程上下文切换,提升效率。BufferTrigger中会有一个额外线程去真正执行load操作,所以不会堵塞用户请求线程。
private final LoadingCache entityCache = KsCacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterAccess(5, TimeUnit.SECONDS)
.enablePerf(perfName)
.buildBatchReload(new CacheLoader() {
@Override
public Entity load(Long id) {
return EntityService.getById(id);
}
});
com.kuaishou.framework.concurrent.BatchReloadCacheLoader
private void doBatchReload(Queue> tasks) {
Stopwatch stopwatch = Stopwatch.createStarted();
Multimap> futureMap = tasks.stream()
.collect(toMultimap(ReloadTask::getKey, ReloadTask::getFuture, ArrayListMultimap::create));
try {
Map result = loadAll(futureMap.keySet());
futureMap.forEach((key, future) -> future.set(result.get(key)));
} catch (UnsupportedLoadingOperationException e) {
futureMap.forEach((key, future) -> {
try {
future.set(load(key));
} catch (Throwable e2) {
future.setException(e2);
rateLogger.warn("cache reload fail, biz:{}", bizName, e);
}
});
} catch (Throwable e) {
tasks.forEach(task -> task.getFuture().setException(e));
rateLogger.warn("cache reload fail, biz:{}", bizName, e);
} finally {
perf("batchReload", stopwatch.elapsed());
}
}
缓存在未失效的情况下,确实是保证了其可用性,却很难保证数据的正确性,传统意义上,需要等 缓存数据过期,命中缓存失败,才去DB中更新数据,导致缓存内的数据不是最新的数据,如果缓存的过期时间过长,数据的不一致的风险就越高。
如果想要及时的保证缓存与DB数据一致的话,另一种就是监听binlog,当DB中的数据发生变化的时候,主动触发ReloadableCache去更新缓存。
load操作,如果是调用外部接口, 接口RT变慢的情况, 会导致链路load调用 hang住
可以设置超时时间, 配置降级策略
Guava Cache 并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过指定时间则进行回源。
是先判断过期,再判断refresh,如果refreshTime 大于 expiredTime, 会直接返回旧值, 在另外一个线程再去reload
所以我们可以通过设置refreshAfterWrite为1s,将expireAfterWrite设为2s,当访问频繁的时候,会在每秒都进行refresh,而当超过2s没有访问,下一次访问必须load新值。
《本地缓存-loadingCache》
https://blog.csdn.net/String_guai/article/details/121109056