本地缓存 - LoadingCache

本地缓存

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

适用场景

  1. 适合少量热点数据缓存(受限于内存大小),解决缓存击穿问题, 可以使用LRU作为淘汰缓存策略。
  2. 愿意以空间换时间,缓存数据到本地内存(没有网络IO,速度快)
  3. 允许在重新load前读到的是脏数据(对同一数据一直访问, 且间隔小于失效时间, 则不会去load数据)
  4. 可以监听Entry清除状态
  5. 支持缓存命中情况统计

创作不易,你的关注分享就是博主更新的最大动力, 每周持续更新

微信搜索【企鹅君】关注还能领取学习资料喔,第一时间阅读(比博客早两到三篇)

求关注❤️ 求点赞❤️ 求分享❤️ 对博主真的非常重要

该篇已经被GitHub项目收录github.com/JavaDance 欢迎Star和完善

公众号

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 数据结构

img

底层数据结构是一个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方法。

img

这个方法里先是判断是否设置了refreshAfterWrite属性,并判断当前时间是否符合刷新策略。符合则调用refresh进行刷新操作

3.3 load方法

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

  public abstract V load(K key) throws Exception;

}

img

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

img

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

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

3.6 AyncReloadCacheLoader

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

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

img

/**
 * 这个类只是改写了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);
            }
         });
    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新值。


公众号

创作不易,你的关注分享就是博主更新的最大动力, 每周持续更新

微信搜索【 企鹅君 】第一时间阅读(比博客早一到两篇), 关注还能领取资料

求关注❤️ 求点赞❤️ 求分享❤️ 对博主真的非常重要

该篇已经被GitHub项目收录github.com/JavaDance 欢迎Star和完善

参考资料:

《本地缓存-loadingCache》

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

  • 50
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值