关于java:高性能缓存-Caffeine-原理及实战

67次阅读

共计 5805 个字符,预计需要花费 15 分钟才能阅读完成。

一、简介

Caffeine 是基于 Java 8 开发的、提供了近乎最佳命中率的高性能本地缓存组件,Spring5 开始不再反对 Guava Cache,改为应用 Caffeine。

上面是 Caffeine 官网测试报告。

由下面三幅图可见:不论在并发读、并发写还是并发读写的场景下,Caffeine 的性能都大幅当先于其余本地开源缓存组件。

本文先介绍 Caffeine 实现原理,再解说如何在我的项目中应用 Caffeine。

 二、Caffeine 原理

2.1 淘汰算法

2.1.1 常见算法

对于 Java 过程内缓存咱们能够通过 HashMap 来实现。不过,Java 过程内存是无限的,不可能有限地往里面放缓存对象。这就须要有适合的算法辅助咱们淘汰掉应用价值绝对不高的对象,为新进的对象留有空间。常见的缓存淘汰算法有 FIFO、LRU、LFU。

FIFO(First In First Out):先进先出。

它是优先淘汰掉最先缓存的数据、是最简略的淘汰算法。毛病是如果先缓存的数据应用频率比拟高的话,那么该数据就不停地进进出出,因而它的缓存命中率比拟低。

LRU(Least Recently Used):最近最久未应用。

它是优先淘汰掉最久未拜访到的数据。毛病是不能很好地应答偶尔的突发流量。比方一个数据在一分钟内的前 59 秒拜访很屡次,而在最初 1 秒没有拜访,然而有一批冷门数据在最初一秒进入缓存,那么热点数据就会被冲刷掉。

LFU(Least Frequently Used):

最近起码频率应用。它是优先淘汰掉最不常常应用的数据,须要保护一个示意应用频率的字段。

次要有两个毛病:

一、如果拜访频率比拟高的话,频率字段会占据肯定的空间;

二、无奈正当更新新上的热点数据,比方某个歌手的老歌播放历史较多,新出的歌如果和老歌一起排序的话,就永无出头之日。

2.1.2 W-TinyLFU 算法

Caffeine 应用了 W-TinyLFU 算法,解决了 LRU 和 LFU 上述的毛病。W-TinyLFU 算法由论文《TinyLFU: A Highly Efficient Cache Admission Policy》提出。

它次要干了两件事:

一、采纳 Count–Min Sketch 算法升高频率信息带来的内存耗费;

二、保护一个 PK 机制保障新上的热点数据可能缓存。

如下图所示,Count–Min Sketch 算法相似布隆过滤器 (Bloom filter) 思维,对于频率统计咱们其实不须要一个准确值。存储数据时,对 key 进行屡次 hash 函数运算后,二维数组不同地位存储频率(Caffeine 理论实现的时候是用一维 long 型数组,每个 long 型数字切分成 16 份,每份 4bit,默认 15 次为最高拜访频率,每个 key 理论 hash 了四次,落在不同 long 型数字的 16 份中某个地位)。读取某个 key 的拜访次数时,会比拟所有地位上的频率值,取最小值返回。对于所有 key 的拜访频率之和有个最大值,当达到最大值时,会进行 reset 即对各个缓存 key 的频率除以 2。

如下图缓存拜访频率存储次要分为两大部分,即 LRU 和 Segmented LRU。新拜访的数据会进入第一个 LRU,在 Caffeine 里叫 WindowDeque。当 WindowDeque 满时,会进入 Segmented LRU 中的 ProbationDeque,在后续被拜访到时,它会被晋升到 ProtectedDeque。当 ProtectedDeque 满时,会有数据降级到 ProbationDeque。数据须要淘汰的时候,对 ProbationDeque 中的数据进行淘汰。具体淘汰机制:取 ProbationDeque 中的队首和队尾进行 PK,队首数据是最先进入队列的,称为受害者,队尾的数据称为攻击者,比拟两者 频率大小,大胜小汰。

总的来说,通过 reset 衰减,防止历史热点数据因为频率值比拟高始终淘汰不掉,并且通过对拜访队列分成三段,这样防止了新退出的热点数据早早地被淘汰掉。

2.2 高性能读写

Caffeine 认为读操作是频繁的,写操作是偶然的,读写都是异步线程更新频率信息。

2.2.1 读缓冲

传统的缓存实现将会为每个操作加锁,以便可能平安的对每个拜访队列的元素进行排序。一种优化计划是将每个操作按序退出到缓冲区中进行批处理操作。读完把数据放到环形队列 RingBuffer 中,为了缩小读并发,采纳多个 RingBuffer,每个线程都有对应的 RingBuffer。环形队列是一个定长数组,提供高性能的能力并最大水平上缩小了 GC 所带来的性能开销。数据丢到队列之后就返回读取后果,相似于数据库的 WAL 机制,和 ConcurrentHashMap 读取数据相比,仅仅多了把数据放到队列这一步。异步线程并发读取 RingBuffer 数组,更新访问信息,这边的线程池应用的是下文实战大节讲的 Caffeine 配置参数中的 executor。

2.2.2 写缓冲

与读缓冲相似,写缓冲是为了贮存写事件。读缓冲中的事件次要是为了优化驱赶策略的命中率,因而读缓冲中的事件残缺水平容许肯定水平的有损。然而写缓冲并不容许数据的失落,因而其必须实现为一个平安的队列。Caffeine 写是把数据放入 MpscGrowableArrayQueue 阻塞队列中,它参考了 JCTools 里的 MpscGrowableArrayQueue,是针对 MPSC- 多生产者单消费者(Multi-Producer & Single-Consumer)场景的高性能实现。多个生产者同时并发地写入队列是线程平安的,然而同一时刻只容许一个消费者生产队列。

 三、Caffeine 实战

3.1 配置参数

Caffeine 借鉴了 Guava Cache 的设计思维,如果之前应用过 Guava Cache,那么 Caffeine 很容易上手,只须要扭转相应的类名就行。结构一个缓存 Cache 示例代码如下:

Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();

Caffeine 类相当于建造者模式的 Builder 类,通过 Caffeine 类配置 Cache,配置一个 Cache 有如下参数:

  • expireAfterWrite:写入距离多久淘汰;
  • expireAfterAccess:最初拜访后距离多久淘汰;
  • refreshAfterWrite:写入后距离多久刷新,该刷新是基于拜访被动触发的,反对异步刷新和同步刷新,如果和 expireAfterWrite 组合应用,可能保障即便该缓存拜访不到、也能在固定工夫距离后被淘汰,否则如果独自应用容易造成 OOM;
  • expireAfter:自定义淘汰策略,该策略下 Caffeine 通过工夫轮算法来实现不同 key 的不同过期工夫;
  • maximumSize:缓存 key 的最大个数;
  • weakKeys:key 设置为弱援用,在 GC 时能够间接淘汰;
  • weakValues:value 设置为弱援用,在 GC 时能够间接淘汰;
  • softValues:value 设置为软援用,在内存溢出前能够间接淘汰;
  • executor:抉择自定义的线程池,默认的线程池实现是 ForkJoinPool.commonPool();
  • maximumWeight:设置缓存最大权重;
  • weigher:设置具体 key 权重;
  • recordStats:缓存的统计数据,比方命中率等;
  • removalListener:缓存淘汰监听器;
  • writer:缓存写入、更新、淘汰的监听器。

3.2 我的项目实战

Caffeine 反对解析字符串参数,参照 Ehcache 的思维,能够把所有缓存项参数信息放入配置文件外面,比方有一个 caffeine.properties 配置文件,外面配置参数如下:

users=maximumSize=10000,expireAfterWrite=180s,softValues
goods=maximumSize=10000,expireAfterWrite=180s,softValues

针对不同的缓存,解析配置文件,并退出 Cache 容器外面,代码如下:


@Component
@Slf4j
public class CaffeineManager {private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);
 
    @PostConstruct
    public void afterPropertiesSet() {String filePath = CaffeineManager.class.getClassLoader().getResource("").getPath() + File.separator +"config"+ File.separator +"caffeine.properties";
        Resource resource = new FileSystemResource(filePath);
        if (!resource.exists()) {return;}
        Properties props = new Properties();
        try (InputStream in = resource.getInputStream()) {props.load(in);
            Enumeration propNames = props.propertyNames();
            while (propNames.hasMoreElements()) {String caffeineKey = (String) propNames.nextElement();
                String caffeineSpec = props.getProperty(caffeineKey);
                CaffeineSpec spec = CaffeineSpec.parse(caffeineSpec);
                Caffeine caffeine = Caffeine.from(spec);
                Cache manualCache = caffeine.build();
                cacheMap.put(caffeineKey, manualCache);
            }
        }
        catch (IOException e) {log.error("Initialize Caffeine failed.", e);
        }
    }
}

当然也能够把 caffeine.properties 外面的配置项放入配置核心,如果须要动静失效,能够通过如下形式:

至于是否利用 Spring 的 EL 表达式通过注解的形式应用,仁者见仁智者见智,笔者次要思考三点:

一、EL 表达式上手须要学习老本;

二、引入注解须要留神动静代理生效场景;

获取缓存时通过如下形式:

caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));

Caffeine 这种带有回源函数的 get 办法最终都是调用 ConcurrentHashMap 的 compute 办法,它能确保高并发场景下,如果对一个热点 key 进行回源时,单个过程内只有一个线程回源,其余都在阻塞。业务须要确保回源的办法耗时比拟短,避免线程阻塞工夫比拟久,零碎可用性降落。

笔者理论开发中用了 Caffeine 和 Redis 两级缓存。Caffeine 的 cache 缓存 key 和 Redis 外面统一,都是命名为 redisKey。targetClass 是返回对象类型,从 Redis 中获取字符串反序列化成理论对象时应用。supplier 是函数式接口,是缓存回源到数据库的业务逻辑。

getTFromRedis 办法实现如下:

private <T> T getTFromRedis(String redisKey, Class targetClass, Supplier supplier) {
    String data;
    T value;
    String redisValue = UUID.randomUUID().toString();
    if (tryGetDistributedLockWithRetry(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue, 30)) {
        try {data = getFromRedis(redisKey);
            if (StringUtils.isEmpty(data)) {value = (T) supplier.get();
                setToRedis(redisKey, JackSonParser.bean2Json(value));
            }
            else {value = json2Bean(targetClass, data);
            }
        }
        finally {releaseDistributedLock(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue);
        }
    }
    else {value = json2Bean(targetClass, getFromRedis(redisKey));
    }
    return value;
}

因为回源都是从 MySQL 查问,尽管 Caffeine 自身解决了过程内同一个 key 只有一个线程回源,须要留神多个业务节点的分布式状况下,如果 Redis 没有缓存值,并发回源时会穿透到 MySQL,所以回源时加了分布式锁,保障只有一个节点回源。

留神一点:从本地缓存获取对象时,如果业务要对缓存对象做更新,须要深拷贝一份对象,不然并发场景下多个线程取值会相互影响。

笔者我的项目之前都是应用 Ehcache 作为本地缓存,切换成 Caffeine 后,波及本地缓存的接口,同样 TPS 值时,CPU 使用率能升高 10% 左右,接口性能都有肯定水平晋升,最多的晋升了 25%。上线后察看调用链,均匀响应工夫升高 24% 左右。

 四、总结

Caffeine 是目前比拟优良的本地缓存解决方案,通过应用 W-TinyLFU 算法,实现了缓存高命中率、内存低消耗。如果之前应用过 Guava Cache,看下接口名根本就能上手。如果之前应用的是 Ehcache,笔者分享的应用形式能够作为参考。

五、参考文献

  1. TinyLFU: A Highly Efficient Cache Admission Policy
  2. Design Of A Modern Cache
  3. Caffeine Github

作者:Zhang Zhenglin

正文完
 0