本地缓存 - LoadingCache

发布时间:2024年01月04日

本地缓存

面试经常会被问到如何解决缓存击穿问题,今天就来带你弄懂他!平时业务中也会经常使用到本地缓存,公司里使用比较多的本地缓存 loadingcache,其背后的架构就是Guava cache,Guava Cache 是一个全内存的本地缓存实现,它提供了线程安全的实现机制。 整体上来说Guava Cache 是本地缓存的不二之选。

适用场景

  1. 适合少量热点数据缓存(受限于内存大小),解决缓存击穿问题, 可以使用LRU作为淘汰缓存策略。

  2. 愿意以空间换时间,缓存数据到本地内存(没有网络IO,速度快)

  3. 允许在重新load前读到的是脏数据(对同一数据一直访问, 且间隔小于失效时间, 则不会去load数据)

  4. 可以监听Entry清除状态

  5. 支持缓存命中情况统计

2. 使用方法

2.1 使用方式

<dependency>
	<groupId>com.google.guava</groupId>
	<artifactId>guava</artifactId>
	<version>24.1-jre</version>
</dependency>
	private final LoadingCache<Long, Entity> 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<Long, Entity>() {
                @Override
                public Entity load(Long id) {
                    return EntityService.getById(id);
                }
    });

    public Entity getEntity(Long id) {
        Entity entity = entityCache.get(id);
    }

    public ImmutableMap<Long, Entity> getAll(List<Long> ids) throws ExecutionException {
        return cache.getAll(ids);
    }

2.2 常用参数

参数

说明

注意事项

maximumSize

缓存的k-v最大数据,当总缓存的数据量达到这个值时,就会淘汰它认为不太用的一份数据,会使用LRU策略进行回收

expireAfterAccess

缓存项在给定时间内没有被读/写访问,则回收,这个策略主要是为了淘汰长时间不被访问的数据

数据过期不是立即淘汰,而是有数据访问时才会触发

expireAfterWrite

缓存项在给定时间内没有被写访问(创建或覆盖),则回收, 防止旧数据被缓存过久

同上

refreshAfterWrite

缓存项在给定时间内没有被写访问(创建或覆盖),则刷新

同上

recordStats

开启Cache的状态统计(默认是开启的)

可能会影响到性能

2.3 显示清除

  • 单个清除:Cache.invalidate(key)

  • 批量清除:Cache.invalidate(keys)

  • 清除所有:Cache.invalidateAll()

3. LoadingCache解析

3.1 数据结构

底层数据结构是一个K.V的存储结构,这个图我想应很明显了,这分明就就是ConcurrentHashMap的结构,底层是一个segment数组,链表的节点和ConcurrentHashMap不太一样,是一个每一个segment是一个节点为ReferenceEntry<K, V>数组,segment继承了ReentrantLock,缩小了锁的力度,体现了分段式锁的思想。

3.2 Get方法

  V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
    try {
      if (count != 0) { // read-volatile
        ReferenceEntry<K, V> 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<K, V> 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进行刷新操作

3.3 load方法

@GwtCompatible(emulated = true)
public abstract class CacheLoader<K, V> {

  public abstract V load(K key) throws Exception;

}

key对应的value不存在(或已过期)会触发load方法。

load方法是同步的,对于同一个key,多次请求只会触发一次加载。

在Thread1进行load加载完成之前,这些请求线程都会被hang等待。

3.4 reload方法

  • //  Guava的默认实现是同步的
    public ListenableFuture<V> 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,其他请求线程直接返回旧值。

3.5 CacheLoader

同步模式,会阻塞用户请求线程。

new CacheLoader<Long, Entity>() {
            @Override
            public Entity load(Long entityId) {
                return EntityService.getById(entityId);
            }
        } 

3.6 AyncReloadCacheLoader

根据Guava的API实现的异步CacheLoader,refresh操作不堵塞任何一个用户请求线程。

相对于Guava中默认实现的reload,只减少了“一个”线程的阻塞。

 
/**
 * 这个类只是改写了reload方法,配合refreshAfterWrite异步刷新
 * 避免因为使用expireAfterWrite造成缓存miss时请求线程回流影响用户请求
 * <p>
 * 代价就是一个额外的线程调度更新
 *
 * @author w.vela
 */
public abstract class AsyncReloadCacheLoader<K, V> extends CacheLoader<K, V> {

    /**
     * <WARNING> 请务必要覆盖这个名字,不然有人会不开心的……
     */
    protected String statsName() {
        return "unknown_async_cache_reloader";
    }

    @Override
    public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
        ListenableFutureTask<V> task = create(() -> load(key));
        ExecutorHolder.execute(statsName(), task::run);
        return task;
    }
}

此类实现不推荐使用,存在一些问题:

共用一个全局线程池,线程池不为使用者所感知,不同使用方可能相互影响;

集中大量发生reload是出现频繁线程创建和销毁。

推荐替代方式:

直接使用CacheLoader,override reload方法,提供自己的异步实现。异步实现可以使用支持异步调用的API(如直接使用grpc异步)

如果没有异步调用API可以自己提供一个线程池用来做异步化。

3.7 BatchReloadCacheLoader

如果有大量集中refresh的情况,可以使用BatchReloadCacheLoader 批量处理

相比于AyncReloadCacheReloader,优点在于:使用 BufferTrigger(本地归并消费)将单个 cache refresh 操作聚合成为批量 refresh,减少线程上下文切换,提升效率。BufferTrigger中会有一个额外线程去真正执行load操作,所以不会堵塞用户请求线程。

private final LoadingCache<Long, Entity> entityCache = KsCacheBuilder.newBuilder()
        .maximumSize(10000)
 			  .expireAfterAccess(5, TimeUnit.SECONDS)
        .enablePerf(perfName) 
        .buildBatchReload(new CacheLoader<Long, Entity>() {
            @Override
            public Entity load(Long id) {
              return EntityService.getById(id);
            }
         });

com.kuaishou.framework.concurrent.BatchReloadCacheLoader

    private void doBatchReload(Queue<ReloadTask<K, V>> tasks) {
        Stopwatch stopwatch = Stopwatch.createStarted();
        Multimap<K, SettableFuture<V>> futureMap = tasks.stream()
                .collect(toMultimap(ReloadTask::getKey, ReloadTask::getFuture, ArrayListMultimap::create));
        try {
            Map<K, V> 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());
        }

    }

4.思考与总结

4.1 本地缓存是一个被动更新的过程

缓存在未失效的情况下,确实是保证了其可用性,却很难保证数据的正确性,传统意义上,需要等 缓存数据过期,命中缓存失败,才去DB中更新数据,导致缓存内的数据不是最新的数据,如果缓存的过期时间过长,数据的不一致的风险就越高。

如果想要及时的保证缓存与DB数据一致的话,另一种就是监听binlog,当DB中的数据发生变化的时候,主动触发ReloadableCache去更新缓存。

4.2 小心外部接口调用超时

load操作,如果是调用外部接口, 接口RT变慢的情况, 会导致链路load调用 hang住

可以设置超时时间, 配置降级策略

4.3 refreshTime一定要小于expiredTime

Guava Cache 并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过指定时间则进行回源。

是先判断过期,再判断refresh,如果refreshTime 大于 expiredTime, 会直接返回旧值, 在另外一个线程再去reload

所以我们可以通过设置refreshAfterWrite为1s,将expireAfterWrite设为2s,当访问频繁的时候,会在每秒都进行refresh,而当超过2s没有访问,下一次访问必须load新值。


?

参考资料:

《本地缓存-loadingCache》

https://blog.csdn.net/String_guai/article/details/121109056

文章来源:https://blog.csdn.net/a821230/article/details/135374194
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。