天天看点

万字详解本地缓存之王 Caffeine

caffeine[1]是一个高性能,高命中率,低内存占用,near optimal 的本地缓存,简单来说它是 guava cache 的优化加强版,有些文章把 caffeine 称为“新一代的缓存”、“现代缓存之王”。

本文将重点讲解 caffeine 的高性能设计,以及对应部分的源码分析。

如果你对 guava cache 还不理解的话,可以点击这里[2]来看一下我之前写过关于 guava cache 的文章。

大家都知道,spring5 即将放弃掉 guava cache 作为缓存机制,而改用 caffeine 作为新的本地 cache 的组件,这对于 caffeine 来说是一个很大的肯定。为什么 spring 会这样做呢?其实在 caffeine 的benchmarks[3]里给出了好靓仔的数据,对读和写的场景,还有跟其他几个缓存工具进行了比较,caffeine 的性能都表现很突出。

万字详解本地缓存之王 Caffeine

caffeine 为了方便大家使用以及从 guava cache 切换过来(很有针对性啊~),借鉴了 guava cache 大部分的概念(诸如核心概念cache、loadingcache、cacheloader、cachebuilder等等),对于 caffeine 的理解只要把它当作 guava cache 就可以了。

使用上,大家只要把 caffeine 的包引进来,然后换一下 cache 的实现类,基本应该就没问题了。这对与已经使用过 guava cache 的同学来说没有任何难度,甚至还有一点熟悉的味道,如果你之前没有使用过 guava cache,可以查看 caffeine 的官方 api 说明文档[4],其中population,eviction,removal,refresh,statistics,cleanup,policy等等这些特性都是跟 guava cache 基本一样的。

下面给出一个例子说明怎样创建一个 cache:

更多从 guava cache 迁移过来的使用说明,请看这里[5]

判断一个缓存的好坏最核心的指标就是命中率,影响缓存命中率有很多因素,包括业务场景、淘汰策略、清理策略、缓存容量等等。如果作为本地缓存, 它的性能的情况,资源的占用也都是一个很重要的指标。下面

我们来看看 caffeine 在这几个方面是怎么着手的,如何做优化的。

(注:本文不会分析 caffeine 全部源码,只会对核心设计的实现进行分析,但我建议读者把 caffeine 的源码都涉猎一下,有个 overview 才能更好理解本文。如果你看过 guava cache 的源码也行,代码的数据结构和处理逻辑很类似的。

源码基于:caffeine-2.8.0.jar)

上面说到淘汰策略是影响缓存命中率的因素之一,一般比较简单的缓存就会直接用到 lfu(least frequently used,即最不经常使用) 或者lru(least recently used,即最近最少使用) ,而 caffeine 就是使用了 w-tinylfu 算法。

w-tinylfu 看名字就能大概猜出来,它是 lfu 的变种,也是一种缓存淘汰算法。那为什么要使用 w-tinylfu 呢?

lru 实现简单,在一般情况下能够表现出很好的命中率,是一个“性价比”很高的算法,平时也很常用。虽然 lru 对突发性的稀疏流量(sparse bursts)表现很好,但同时也会产生缓存污染,举例来说,如果偶然性的要对全量数据进行遍历,那么“历史访问记录”就会被刷走,造成污染。

如果数据的分布在一段时间内是固定的话,那么 lfu 可以达到最高的命中率。但是 lfu 有两个缺点,第一,它需要给每个记录项维护频率信息,每次访问都需要更新,这是个巨大的开销;第二,对突发性的稀疏流量无力,因为前期经常访问的记录已经占用了缓存,偶然的流量不太可能会被保留下来,而且过去的一些大量被访问的记录在将来也不一定会使用上,这样就一直把“坑”占着了。

无论 lru 还是 lfu 都有其各自的缺点,不过,现在已经有很多针对其缺点而改良、优化出来的变种算法。

tinylfu 就是其中一个优化算法,它是专门为了解决 lfu 上述提到的两个问题而被设计出来的。

解决第一个问题是采用了 count–min sketch 算法。

解决第二个问题是让记录尽量保持相对的“新鲜”(freshness mechanism),并且当有新的记录插入时,可以让它跟老的记录进行“pk”,输者就会被淘汰,这样一些老的、不再需要的记录就会被剔除。

下图是 tinylfu 设计图(来自官方)

万字详解本地缓存之王 Caffeine

如何对一个 key 进行统计,但又可以节省空间呢?(不是简单的使用hashmap,这太消耗内存了),注意哦,不需要精确的统计,只需要一个近似值就可以了,怎么样,这样场景是不是很熟悉,如果你是老司机,或许已经联想到布隆过滤器(bloom filter)的应用了。

没错,将要介绍的 count–min sketch 的原理跟 bloom filter 一样,只不过 bloom filter 只有 0 和 1 的值,那么你可以把 count–min sketch 看作是“数值”版的 bloom filter。

更多关于 count–min sketch 的介绍请自行搜索。

在 tinylfu 中,近似频率的统计如下图所示:

万字详解本地缓存之王 Caffeine

对一个 key 进行多次 hash 函数后,index 到多个数组位置后进行累加,查询时取多个值中的最小值即可。

caffeine 对这个算法的实现在frequencysketch类。但 caffeine 对此有进一步的优化,例如 count–min sketch 使用了二维数组,caffeine 只是用了一个一维的数组;再者,如果是数值类型的话,这个数需要用 int 或 long 来存储,但是 caffeine 认为缓存的访问频率不需要用到那么大,只需要 15 就足够,一般认为达到 15 次的频率算是很高的了,而且 caffeine 还有另外一个机制来使得这个频率进行衰退减半(下面就会讲到)。如果最大是 15 的话,那么只需要 4 个 bit 就可以满足了,一个 long 有 64bit,可以存储 16 个这样的统计数,caffeine 就是这样的设计,使得存储效率提高了 16 倍。

caffeine 对缓存的读写(afterread和afterwrite方法)都会调用onaccesss 方法,而onaccess方法里有一句:

这句就是追加记录的频率,下面我们看看具体实现

知道了追加方法,那么读取方法frequency就很容易理解了。

通过代码和注释或者读者可能难以理解,下图是我画出来帮助大家理解的结构图。

注意紫色虚线框,其中蓝色小格就是需要计算的位置:

万字详解本地缓存之王 Caffeine

为了让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常的缓存,caffeine 有一个 freshness mechanism。做法很简答,就是当整体的统计计数(当前所有记录的频率统计之和,这个数值内部维护)达到某一个值时,那么所有记录的频率统计除以 2。

从上面的代码

看到reset方法就是做这个事情

关于这个 reset 方法,为什么是除以 2,而不是其他,及其正确性,在最下面的参考资料的 tinylfu 论文中 3.3 章节给出了数学证明,大家有兴趣可以看看。

caffeine 通过测试发现 tinylfu 在面对突发性的稀疏流量(sparse bursts)时表现很差,因为新的记录(new items)还没来得及建立足够的频率就被剔除出去了,这就使得命中率下降。

于是 caffeine 设计出一种新的 policy,即 window tiny lfu(w-tinylfu),并通过实验和实践发现 w-tinylfu 比 tinylfu 表现的更好。

w-tinylfu 的设计如下所示(两图等价):

万字详解本地缓存之王 Caffeine
万字详解本地缓存之王 Caffeine

它主要包括两个缓存模块,主缓存是 slru(segmented lru,即分段 lru),slru 包括一个名为 protected 和一个名为 probation 的缓存区。通过增加一个缓存区(即 window cache),当有新的记录插入时,会先在 window 区呆一下,就可以避免上述说的 sparse bursts 问题。

当 window 区满了,就会根据 lru 把 candidate(即淘汰出来的元素)放到 probation 区,如果 probation 区也满了,就把 candidate 和 probation 将要淘汰的元素 victim,两个进行“pk”,胜者留在 probation,输者就要被淘汰了。

而且经过实验发现当 window 区配置为总容量的 1%,剩余的 99%当中的 80%分给 protected 区,20%分给 probation 区时,这时整体性能和命中率表现得最好,所以 caffeine 默认的比例设置就是这个。

不过这个比例 caffeine 会在运行时根据统计数据(statistics)去动态调整,如果你的应用程序的缓存随着时间变化比较快的话,那么增加 window 区的比例可以提高命中率,相反缓存都是比较固定不变的话,增加 main cache 区(protected 区 +probation 区)的比例会有较好的效果。

下面我们看看上面说到的淘汰策略是怎么实现的:

一般缓存对读写操作后都有后续的一系列“维护”操作,caffeine 也不例外,这些操作都在maintenance方法,我们将要说到的淘汰策略也在里面。

这方法比较重要,下面也会提到,所以这里只先说跟“淘汰策略”有关的evictentries和climb。

先说一下 caffeine 对上面说到的 w-tinylfu 策略的实现用到的数据结构:

以及默认比例设置(意思看注释)

重点来了,evictentries和climb方法:

evictfrommain方法:

climb方法主要是用来调整 window size 的,使得 caffeine 可以适应你的应用类型(如 olap 或 oltp)表现出最佳的命中率。

下图是官方测试的数据:

万字详解本地缓存之王 Caffeine

我们看看 window size 的调整是怎么实现的。

调整时用到的默认比例数据:

下面分别展开每个方法来解释:

以上,是 caffeine 的 w-tinylfu 策略的设计原理及代码实现解析。

一般的缓存每次对数据处理完之后(读的话,已经存在则直接返回,不存在则 load 数据,保存,再返回;写的话,则直接插入或更新),但是因为要维护一些淘汰策略,则需要一些额外的操作,诸如:

计算和比较数据的是否过期

统计频率(像 lfu 或其变种)

维护 read queue 和 write queue

淘汰符合条件的数据

等等。。。

这种数据的读写伴随着缓存状态的变更,guava cache 的做法是把这些操作和读写操作放在一起,在一个同步加锁的操作中完成,虽然 guava cache 巧妙地利用了 jdk 的 concurrenthashmap(分段锁或者无锁 cas)来降低锁的密度,达到提高并发度的目的。但是,对于一些热点数据,这种做法还是避免不了频繁的锁竞争。caffeine 借鉴了数据库系统的 wal(write-ahead logging)思想,即先写日志再执行操作,这种思想同样适合缓存的,执行读写操作时,先把操作记录在缓冲区,然后在合适的时机异步、批量地执行缓冲区中的内容。但在执行缓冲区的内容时,也是需要在缓冲区加上同步锁的,不然存在并发问题,只不过这样就可以把对锁的竞争从缓存数据转移到对缓冲区上。

在 caffeine 的内部实现中,为了很好的支持不同的 features(如 eviction,removal,refresh,statistics,cleanup,policy 等等),扩展了很多子类,它们共同的父类是boundedlocalcache,而readbuffer就是作为它们共有的属性,即都是用一样的 readbuffer,看定义:

上面提到 caffeine 对每次缓存的读操作都会触发afterread

重点看boundedbuffer

它是一个 striped、非阻塞、有界限的 buffer,继承于stripedbuffer类。下面看看stripedbuffer的实现:

这个stripedbuffer设计的思想是跟striped64类似的,通过扩展结构把竞争热点分离。

具体实现是这样的,stripedbuffer维护一个buffer[]数组,每个元素就是一个ringbuffer,每个线程用自己threadlocalrandomprobe属性作为 hash 值,这样就相当于每个线程都有自己“专属”的ringbuffer,就不会产生竞争啦,而不是用 key 的hashcode作为 hash 值,因为会产生热点数据问题。

看看stripedbuffer的属性

offer方法,当没初始化或存在竞争时,则扩容为 2 倍。

实际是调用ringbuffer的 offer 方法,把数据追加到ringbuffer后面。

最后看看ringbuffer,注意ringbuffer是boundedbuffer的内部类。

注意,ring buffer 的 size(固定是 16 个)是不变的,变的是 head 和 tail 而已。

总的来说readbuffer有如下特点:

使用 striped-ringbuffer来提升对 buffer 的读写

用 thread 的 hash 来避开热点 key 的竞争

允许写入的丢失

writebuffer跟readbuffer不一样,主要体现在使用场景的不一样。本来缓存的一般场景是读多写少的,读的并发会更高,且 afterread 显得没那么重要,允许延迟甚至丢失。写不一样,写afterwrite不允许丢失,且要求尽量马上执行。caffeine 使用mpsc(multiple producer / single consumer)作为 buffer 数组,实现在mpscgrowablearrayqueue类,它是仿照jctools的mpscgrowablearrayqueue来写的。

mpsc 允许无锁的高并发写入,但只允许一个消费者,同时也牺牲了部分操作。

mpsc 我打算另外分析,这里不展开了。

除了支持expireafteraccess和expireafterwrite之外(guava cache 也支持这两个特性),caffeine 还支持expireafter。因为expireafteraccess和expireafterwrite都只能是固定的过期时间,这可能满足不了某些场景,譬如记录的过期时间是需要根据某些条件而不一样的,这就需要用户自定义过期时间。

先看看expireafter的用法

通过自定义过期时间,使得不同的 key 可以动态的得到不同的过期时间。

注意,我把expireafteraccess和expireafterwrite注释了,因为这两个特性不能跟expireafter一起使用。

而当使用了expireafter特性后,caffeine 会启用一种叫“时间轮”的算法来实现这个功能。更多关于时间轮的介绍,可以看我的文章hashedwheeltimer 时间轮原理分析[6]。

好,重点来了,为什么要用时间轮?

对expireafteraccess和expireafterwrite的实现是用一个accessorderdeque双端队列,它是 fifo 的,因为它们的过期时间是固定的,所以在队列头的数据肯定是最早过期的,要处理过期数据时,只需要首先看看头部是否过期,然后再挨个检查就可以了。但是,如果过期时间不一样的话,这需要对accessorderqueue进行排序&插入,这个代价太大了。于是,caffeine 用了一种更加高效、优雅的算法-时间轮。

时间轮的结构:

万字详解本地缓存之王 Caffeine

因为在我的对时间轮分析的文章里已经说了时间轮的原理和机制了,所以我就不展开 caffeine 对时间轮的实现了。

caffeine 对时间轮的实现在timerwheel,它是一种多层时间轮(hierarchical timing wheels )。

看看元素加入到时间轮的schedule方法:

caffeine 还有其他的优化性能的手段,如使用软引用和弱引用、消除伪共享、completablefuture异步等等。

caffeien 是一个优秀的本地缓存,通过使用 w-tinylfu 算法, 高性能的 readbuffer 和 writebuffer,时间轮算法等,使得它拥有高性能,高命中率(near optimal),低内存占用等特点。