一、简介
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,笔者分享的应用形式能够作为参考。
五、参考文献
- TinyLFU: A Highly Efficient Cache Admission Policy
- Design Of A Modern Cache
- Caffeine Github
作者:Zhang Zhenglin
发表回复