本地缓存之王Caffeine浅析

379 阅读3分钟

1.简介

在提高并发、降低接口耗时和保护MySql数据库等场景中,缓存是一个常用手段,本地缓存则是缓存中一个不可忽视的技术方案。本地缓存常用的有Guava,而Caffeine则是它的升级版,提供更丰富的特性以及更优的淘汰算法。

2.常用淘汰算法

2.1 LRU(Least Recently Used)

LRU算法会记录每个数据的访问时间戳,当达到缓存使用上限时,会优先淘汰最老的数据。该算法适用于局部流量高峰的场景。如果类似全量访问的场景,则会导致缓存污染.同时,无法应对周期性访问的请求。

2.2 LFU(Least Frequently Used)

LFU算法会记录每个数据的访问次数,需要淘汰数据时优先淘汰访问次数最少的数据。该算法适合于存在高频访问的数据的场景,能够有效应对全量访问带来的缓存污染,但是没法应对局部流量高峰。同时访问次数的维护也是一大消耗。

2.3 TinyLFU

为了降低LFU算法中访问次数统计信息的空间占用,TinyLFU使用CountMinSketch算法近似实现了访问次数的统计。算法类似布隆过滤器,只是将布隆过滤器中的bit换成4bit,一个long存了16个4bit的频率统计值. 部分源码:

com.github.benmanes.caffeine.cache.FrequencySketch
// 频率存储,类似布隆过滤器中的bitmap
long[] table;

// 获取某个元素的频率
// 使用hash值算了四个位置,返回四个位置里最小的频率
public int frequency(@Nonnull E e) {
  if (isNotInitialized()) {
    return 0;
  }

  int hash = spread(e.hashCode());
  int start = (hash & 3) << 2;
  int frequency = Integer.MAX_VALUE;
  for (int i = 0; i < 4; i++) {
    int index = indexOf(hash, i);
    int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
    frequency = Math.min(frequency, count);
  }
  return frequency;
}

// 增加统计频率
// 根据hash酸的四个位置的频率值都+1,如果已经达到最大频率值15,则不加
// 如果四个位置任一一个没达到最大值且数据已满,则所有频率值衰减为原来的一半
public void increment(@Nonnull E e) {
  if (isNotInitialized()) {
    return;
  }

  int hash = spread(e.hashCode());
  int start = (hash & 3) << 2;

  // Loop unrolling improves throughput by 5m ops/s
  int index0 = indexOf(hash, 0);
  int index1 = indexOf(hash, 1);
  int index2 = indexOf(hash, 2);
  int index3 = indexOf(hash, 3);

  boolean added = incrementAt(index0, start);
  added |= incrementAt(index1, start + 1);
  added |= incrementAt(index2, start + 2);
  added |= incrementAt(index3, start + 3);

  if (added && (++size == sampleSize)) {
    reset();
  }
}

2.4 W-TinyLFU

W-TinyLFU在TinyLFU的基础上引入了LRU的特性,从而能够应对突发性流量。使用LRU策略的区域占总缓存空间的1%,剩余的99%则是使用LFU策略。Main Cache进一步分为Protected Cache(保护区)和Probation Cache(考察区),保护区占了Main Cache的80%。新增的数据,先放入LRU区域,如果Window Cache满了且考察区也满了,LRU淘汰的数据则和TinyLFU中的频率最低的PK,频率更低的被淘汰,高的进入考察区。考察区的数据访问频率超过阈值,则会进入保护区,如果保护区也满了,则选出最小频率的数据放入考察区,如果考察区也满了就要pk.有点像JVM堆内存分代管理的感觉。

image.png

3 实践

本地缓存caffeine+redis分布式缓存实现的多级缓存

// 初始化缓存
private final LoadingCache<Long, Optional<ProductData>> localProductDataCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .recordStats()
        .build(new CacheLoader<Long, Optional<ProductData>>() {
               @Override
                public Optional<ProductData> load(final Long bgId) {
                    final ProductData productData = getProductData(brandGood);
                    if (productData == null) {
                        return Optional.absent();
                    }
                    return Optional.of(productData);
                }

                @Override
                public Map<Long, Optional<ProductData>> loadAll(
                        final Iterable<? extends Long> bgIds) throws Exception {                   
                        // 批量接口实现......
    
                }
            });

 private Optional<ProductData> getProductData(final long bgId) {
     Optional<ProductData> dataOpt = redisCache.get(bgId, ProductData.class);
     if (dataOpt.isPresent()) {
        return dataOpt;
     } else {
        dataOpt = getProductDataFromRPC(bgId);
     }
     if (dataOpt.isPresent()) {
        redisCache.put(bgId, productData, EXPIRE_TIME);
     }
     return dataOpt;

}