高性能缓存 Caffeine 原理及实战

1、简介

Caffeine 是基于Java 8 开发的、提供了近乎最佳命中率的高性能本地缓存组件,Spring5 开始再也不支持 Guava Cache,改成使用 Caffeine。html

下面是 Caffeine 官方测试报告java

高性能缓存 Caffeine 原理及实战

高性能缓存 Caffeine 原理及实战

高性能缓存 Caffeine 原理及实战

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

本文先介绍 Caffeine 实现原理,再讲解如何在项目中使用 Caffeine 。github

 2、Caffeine 原理

2.1 淘汰算法

2.1.1 常见算法

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

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

它是优先淘汰掉最早缓存的数据、是最简单的淘汰算法。缺点是若是先缓存的数据使用频率比较高的话,那么该数据就不停地进进出出,所以它的缓存命中率比较低。数据库

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

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

LFU(Least Frequently Used):缓存

最近最少频率使用。它是优先淘汰掉最不常用的数据,须要维护一个表示使用频率的字段。

主要有两个缺点:

1、若是访问频率比较高的话,频率字段会占据必定的空间;

2、没法合理更新新上的热点数据,好比某个歌手的老歌播放历史较多,新出的歌若是和老歌一块儿排序的话,就永无出头之日。

2.1.2 W-TinyLFU 算法

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

它主要干了两件事:

1、采用 Count–Min Sketch 算法下降频率信息带来的内存消耗;

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

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

高性能缓存 Caffeine 原理及实战

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

高性能缓存 Caffeine 原理及实战

总的来讲,经过 reset 衰减,避免历史热点数据因为频率值比较高一直淘汰不掉,而且经过对访问队列分红三段,这样避免了新加入的热点数据早早地被淘汰掉。

2.2 高性能读写

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

2.2.1 读缓冲

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

高性能缓存 Caffeine 原理及实战

2.2.2 写缓冲

与读缓冲相似,写缓冲是为了储存写事件。读缓冲中的事件主要是为了优化驱逐策略的命中率,所以读缓冲中的事件完整程度容许必定程度的有损。可是写缓冲并不容许数据的丢失,所以其必须实现为一个安全的队列。Caffeine 写是把数据放入MpscGrowableArrayQueue 阻塞队列中,它参考了JCTools里的MpscGrowableArrayQueue ,是针对 MPSC- 多生产者单消费者(Multi-Producer & Single-Consumer)场景的高性能实现。多个生产者同时并发地写入队列是线程安全的,可是同一时刻只容许一个消费者消费队列。

 3、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 表达式经过注解的方式使用,仁者见仁智者见智,笔者主要考虑三点:

1、EL 表达式上手须要学习成本;

2、引入注解须要注意动态代理失效场景;

获取缓存时经过以下方式:

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%左右。

 4、总结

Caffeine 是目前比较优秀的本地缓存解决方案,经过使用 W-TinyLFU 算法,实现了缓存高命中率、内存低消耗。若是以前使用过 Guava Cache,看下接口名基本就能上手。若是以前使用的是 Ehcache,笔者分享的使用方式能够做为参考。

5、参考文献

  1. TinyLFU: A Highly Efficient Cache Admission Policy

  2. Design Of A Modern Cache

  3. Caffeine Github

做者:Zhang Zhenglin

相关文章
相关标签/搜索