一 前言
互联网时代的飞速发展,用户的体验度是判断一个软件好坏的重要原因,所以缓存就是必不可少的一个神器。缓存的种类有很多,需要根据不同的应用场景来需要选择不同的cache。比如分布式缓存如redis跟本地缓存如Caffeine。今天简单聊聊Caffeine。由于笔者自身水平有限,如果有不对或者任何建议欢迎批评和指正~
1.1 热点数据
在某段时间范围内,经常被访问的数据可以称为热点数据。以视频网站为例:热点视频主要是来自于观众的观看,那么热点视频可以分为如下几类:
永久性热点数据
周期性热点: 例如最近的欧洲杯,比赛视频观看频次都会随着比赛场次呈现出周期性的波动。
突发性热点: 主要来源于突发事件:例如地震报道,明星直播等等。
1.2 缓存回收策略
1 基于空间:
即设置缓存的【存储空间】,如设置为10MB,当达到存储空间时,按照一定的策略移除数据。
2 基于容量:
指缓存设置了最大大小,当缓存的条目超过最大大小时,按照一定的策略移除数据。如Caffeine Cache可以通过 maximumSize 参数设置缓存容量,当超出 maximumSize 时,按照算法进行缓存回收。
public static void maximumSizeTest() {
Cache
.maximumSize(1)
.build();
maximumSizeCaffeineCache.put(“A”, “A”);
String value1 = maximumSizeCaffeineCache.getIfPresent(“A”);
System.out.println(“key:key1” + " value:" + value1);
maximumSizeCaffeineCache.put(“B”, “B”);
String value1AfterExpired = maximumSizeCaffeineCache.getIfPresent(“A”);
//输出null
System.out.println(“key:key1” + " value:" + value1AfterExpired);
String value2 = maximumSizeCaffeineCache.getIfPresent(“B”);
//输出B
System.out.println(“key:key2” + " value:" + value2);
}
3 基于时间
TTL(Time To Live):存活期,即缓存数据从创建开始直到到期的一个时间段(不管在这个时间段内有没有被访问,缓存数据都将过期)。
TTI(Time To Idle):空闲期,即缓存数据多久没被访问后移除缓存的时间。
如Caffeine Cache可以通过 expireAfterWrite跟expireAfterAccess参数设置过期时间。
public static void ttiTest() {
Cache
.maximumSize(100).expireAfterAccess(1, TimeUnit.SECONDS)
.build();
ttiCaffeineCache.put(“A”, “A”);
//输出A
System.out.println(ttiCaffeineCache.getIfPresent(“A”));
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//输出null
System.out.println(ttiCaffeineCache.getIfPresent(“A”));
}
1.3 缓存回收算法
如果所有的数据都能存储到进程内存中,岂不是能大大提升应用性能?但是,实际情况却不是这样的。内存中缓存的对象越多,GC也会越频繁,GC总体时间也会越长。缓存有项重要的指标:命中率,就是访问某个数据时,该数据正好在缓存中,即为命中。所以命中率本质上跟内存大小有直接的联系。在命中率和内存大小之间权衡是项技术活,于是各种各样的淘汰算法各显神通。
FIFO算法: 先进先出算法,即先放入缓存的先被移除。
优点: 最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现
缺点: 这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉
LRU算法:如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据
优点: LRU可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。
缺点: 对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降。
LFU算法:如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据
优点: LFU也可以有效的保护缓存,相对场景来讲,比LRU有更好的缓存命中率。因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率
缺点: 因为LFU需要记录数据的访问频率,因此需要额外的空间;当访问模式改变的时候,算法命中率会急剧下降,这也是他最大弊端。
二 Caffeine简介
Caffeine 是一个基于Java8开发的高性能,高命中率,低内存占用的本地缓存,简单来说它是 Guava Cache 的优化加强版,有些文章把 Caffeine 称为“新一代的缓存”、“现代缓存之王”。
三 Caffeine高性能揭秘
判断一个缓存的好坏最核心的指标就是命中率,影响缓存命中率有很多因素,包括业务场景、淘汰策略、清理策略、缓存容量等等。如果作为本地缓存, 它的性能的情况,资源的占用也都是一个很重要的指标。下面我们来看看 Caffeine 在这几个方面是怎么着手的,如何做优化的。
3.1 W-TinyLFU算法
为了改进上述 LRU 和 LFU 存在的问题前Google工程师在 TinyLfu的基础上发明了 W-TinyLFU 缓存算法。Caffeine 因使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。算法整体流程如下图所示:
3.2 Count–Min Sketch算法
如何对一个 key 进行统计,但又可以节省空间呢?(不是简单的使用HashMap,这太消耗内存了)没错,将要介绍的 Count–Min Sketch 的原理跟 Bloom Filter 一样,只不过 Bloom Filter 只有 0 和 1 的值,那么你可以把 Count–Min Sketch 看作是“数值”版的 Bloom Filter。
Count–Min Sketch 算法类似布隆过滤器 (Bloom filter)思想,对于频率统计我们其实不需要一个精确值。存储数据时,对key进行多次 hash 函数运算后,二维数组不同位置存储频率【Caffeine 实际实现的时候是用一维 long 型数组,每个 long 型数字切分成16份,每份4bit,默认15次为最高访问频率,每个key实际 hash 了四次,落在不同 long 型数字的16份中某个位置】。读取某个key的访问次数时,会比较所有位置上的频率值,取最小值返回。对于所有key的访问频率之和有个最大值,当达到最大值时,会进行reset即对各个缓存key的频率除以2。
3.3 异步高性能读写
数据的读写伴随着缓存状态的变更,Guava Cache 的做法是把这些操作和读写操作放在一起,在一个同步加锁的操作中完成,虽然 Guava Cache 巧妙地利用了 JDK 的 ConcurrentHashMap(分段锁或者无锁 CAS)来降低锁的密度,达到提高并发度的目的。
但是,对于一些热点数据,这种做法还是避免不了频繁的锁竞争。Caffeine 借鉴了数据库系统的 WAL(Write-Ahead Logging)思想,即先写日志再执行操作,这种思想同样适合缓存的,执行读写操作时,先把操作记录在缓冲区,然后在合适的时机异步、批量地执行缓冲区中的内容。但在执行缓冲区的内容时,也是需要在缓冲区加上同步锁的,不然存在并发问题,只不过这样就可以把对锁的竞争从缓存数据转移到对缓冲区上。
高性能读
传统的缓存实现将会为每个操作加锁,以便能够安全的对每个访问队列的元素进行排序。一种优化方案是将每个操作按序加入到缓冲区中进行批处理操作。读完把数据放到环形队列 RingBuffer 中,为了减少读并发,采用多个 RingBuffer,每个线程都有对应的 RingBuffer。环形队列是一个定长数组,提供高性能的能力并最大程度上减少了 GC所带来的性能开销。数据丢到队列之后就返回读取结果,类似于数据库的WAL机制,和ConcurrentHashMap 读取数据相比,仅仅多了把数据放到队列这一步。异步线程并发读取 RingBuffer 数组,更新访问信息,这边的线程池使用的是下文实战小节讲的 Caffeine 配置参数中的 executor。
高性能写
与读缓冲类似,写缓冲是为了储存写事件。读缓冲中的事件主要是为了优化驱逐策略的命中率,因此读缓冲中的事件完整程度允许一定程度的有损。但是写缓冲并不允许数据的丢失,因此其必须实现为一个安全的队列。Caffeine 写是把数据放入MpscGrowableArrayQueue 阻塞队列中,它参考了JCTools里的MpscGrowableArrayQueue ,是针对 MPSC- 多生产者单消费者(Multi-Producer & Single-Consumer)场景的高性能实现。多个生产者同时并发地写入队列是线程安全的,但是同一时刻只允许一个消费者消费队列。
3.4 TimerWheel
除了支持expireAfterAccess和expireAfterWrite之外(Guava Cache 也支持这两个特性),Caffeine 还支持expireAfter。因为expireAfterAccess和expireAfterWrite都只能是固定的过期时间,这可能满足不了某些场景,譬如记录的过期时间是需要根据某些条件而不一样的,这就需要用户自定义过期时间。
对expireAfterAccess和expireAfterWrite的实现是用一个AccessOrderDeque双端队列,它是 FIFO 的,因为它们的过期时间是固定的,所以在队列头的数据肯定是最早过期的,要处理过期数据时,只需要首先看看头部是否过期,然后再挨个检查就可以了。但是,如果过期时间不一样的话,这需要对accessOrderQueue进行排序&插入,这个代价太大了。于是,Caffeine 用了一种更加高效、优雅的算法-时间轮。
3.5 其他
Caffeine 还有其他的优化性能的手段,如使用软引用和弱引用、消除伪共享、CompletableFuture异步等等。
3.6 Benchmarks
大家都知道,Spring5 即将放弃掉 Guava Cache 作为缓存机制,而改用 Caffeine 作为新的本地 Cache 的组件,这对于 Caffeine 来说是一个很大的肯定。为什么 Spring 会这样做呢?其实在 Caffeine 的Benchmarks[3]里给出了好靓仔的数据,对读和写的场景,还有跟其他几个缓存工具进行了比较,Caffeine 的性能都表现很突出。
基准测试
以下数据来自Caffeine官方输出。基准测试将运行在一台Azure G4之上,是主要云服务商能够在免费试用期内提供的最大实例。这台机器的具体配置是单插槽 Xeon E5-2698B v3 @ 2.00GHz (16 核, 禁用超线程),224 GB,Ubuntu 15.04。