走向未来-caffeine

原文链接: https://www.cnblogs.com/liujinhua306/p/9808500.html

整理自:
https://juejin.im/post/5b7593496fb9a009b62904fa#heading-12
https://www.cnblogs.com/liujinhua306/p/9808500.html

guava cache的功能的确是很强大,满足了绝大多数的人的需求,但是其本质上还是LRU的一层封装,所以在众多其他较为优良的淘汰算法中就相形见绌了。而caffeine cache实现了W-TinyLFU(LFU+LRU算法的变种)。下面是不同算法的命中率的比较:
走向未来-caffeine_第1张图片
其中Optimal是最理想的命中率,LRU和其他算法相比的确是个弟弟。而我们的W-TinyLFU 是最接近理想命中率的。当然不仅仅是命中率caffeine优于了guava cache,在读写吞吐量上面也是完爆guava cache。

走向未来-caffeine_第2张图片

Caffeine原理简介

W-TinyLFU

传统的LFU受时间周期的影响比较大。所以各种LFU的变种出现了,基于时间周期进行衰减,或者在最近某个时间段内的频率。同样的LFU也会使用额外空间记录每一个数据访问的频率,即使数据没有在缓存中也需要记录,所以需要维护的额外空间很大。

可以试想我们对这个维护空间建立一个hashMap,每个数据项都会存在这个hashMap中,当数据量特别大的时候,这个hashMap也会特别大。

再回到LRU,我们的LRU也不是那么一无是处,LRU可以很好的应对突发流量的情况,因为他不需要累计数据频率。
所以W-TinyLFU结合了LRU和LFU,以及其他的算法的一些特点。

频率记录

首先要说到的就是频率记录的问题,我们要实现的目标是利用有限的空间可以记录随时间变化的访问频率。在W-TinyLFU中使用Count-Min Sketch记录我们的访问频率,而这个也是布隆过滤器的一种变种。如下图所示:

走向未来-caffeine_第3张图片
如果需要记录一个值,那我们需要通过多种Hash算法对其进行处理hash,然后在对应的hash算法的记录中+1。

为什么需要多种hash算法呢?由于这是一个压缩算法必定会出现冲突,比如我们建立一个Long的数组,通过计算出每个数据的hash的位置。比如:张三和李四,他们两有可能hash值都是相同,比如都是1那Long[1]这个位置就会增加相应的频率,张三访问1万次,李四访问1次那Long[1]这个位置就是1万零1,如果取李四的访问评率的时候就会取出是1万零1,但是李四命名只访问了1次啊。如下图:
走向未来-caffeine_第4张图片

为了解决这个问题,所以用了多个hash算法可以理解为long[][]二维数组的一个概念,比如在第一个算法张三和李四冲突了,但是在第二个,第三个中很大的概率不冲突,比如一个算法大概有1%的概率冲突 ,那四个算法一起冲突的概率是1%的四次方。通过这个模式我们取李四的访问率的时候取所有算法中,李四访问最低频率的次数。所以他的名字叫Count-Min Sketch。
如下图:走向未来-caffeine_第5张图片这里和以前的做个对比,简单的举个例子:如果一个hashMap来记录这个频率,如果我有100个数据,那这个HashMap就得存储100个这个数据的访问频率。哪怕我这个缓存的容量是1,因为Lfu的规则我必须全部记录这个100个数据的访问频率。如果有更多的数据我就有记录更多的。

在Count-Min Sketch中,我这里直接说caffeine中的实现吧(在FrequencySketch这个类中),如果你的缓存大小是100,他会生成一个long数组大小是和100最接近的2的幂的数,也就是128。而这个数组将会记录我们的访问频率。在caffeine中规定频率最大为15,15的二进制位1111,总共是4位,而Long型是64位。所以每个Long型可以放16种算法,但是caffeine并没有这么做,只用了四种hash算法,每个Long型被分为四段,每段里面保存的是四个算法的频率。这样做的好处是可以进一步减少Hash冲突,原先128大小的hash,就变成了128X4。
一个Long的结构如下:
走向未来-caffeine_第6张图片

4个段分为A,B,C,D,在后面我也会这么叫它们。而每个段里面的四个算法我叫他s1,s2,s3,s4。下面举个例子如果要添加一个访问50的数字频率应该怎么做?我们这里用size=100来举例。

  1. 首先确定50这个hash是在哪个段里面,通过hash & 3(3的二进制是11)必定能获得小于4的数字,假设hash & 3=0,那就在A段。
  2. 对50的hash再用其他hash算法再做一次hash,得到long数组的位置,也就是在长度128数组中的位置。假设用s1算法得到1,s2算法得到3,s3算法得到4,s4算法得到0。
  3. 因为S1算法得到的是1,所以在long[1]的A段里面的s1位置进行+1,简称1As1加1,然后在3As2加1,在4As3加1,在0As4加1。

走向未来-caffeine_第7张图片
这个时候有人会质疑频率最大为15的这个是否太小?没关系在这个算法中,比如size等于100,如果他全局提升了1000次就会全局除以2衰减,衰减之后也可以继续增加,这个算法再W-TinyLFU的论文中证明了其可以较好的适应时间段的访问频率。

读写性能

在guava cache中我们说过其读写操作中夹杂着过期时间的处理,也就是你在一次Put操作中有可能还会做淘汰操作,所以其读写性能会受到一定影响,可以看上面的图中,caffeine的确在读写操作上面完爆guava cache。主要是因为在caffeine,对这些事件的操作是通过异步操作,他将事件提交至队列,这里的队列的数据结构是RingBuffer,不清楚的可以看看这篇文章,你应该知道的高性能无锁队列Disruptor。然后通过会通过默认的ForkJoinPool.commonPool(),或者自己配置线程池,进行取队列操作,然后在进行后续的淘汰,过期操作。

当然读写也是有不同的队列,在caffeine中认为缓存读比写多很多,所以对于写操作是所有线程共享一个Ringbuffer。

走向未来-caffeine_第8张图片

对于读操作比写操作更加频繁,进一步减少竞争,其为每个线程配备了一个RingBuffer:

走向未来-caffeine_第9张图片

数据淘汰策略

在caffeine所有的数据都在ConcurrentHashMap中,这个和guava cache不同,guava cache是自己实现了个类似ConcurrentHashMap的结构。在caffeine中有三个记录引用的LRU队列:

  • Eden队列:在caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。伊甸区,最舒服最安逸的区域,在这里很难被其他数据淘汰。
  • Probation队列:叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为size减去eden减去protected。
  • Protected队列:在这个队列中,可以稍微放心一下了,你暂时不会被淘汰,但是别急,如果Probation队列没有数据了或者Protected数据满了,你也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80% 如果size =100,就会是79。

这三个队列关系如下:

走向未来-caffeine_第10张图片

  1. 所有的新数据都会进入Eden。
  2. Eden满了,淘汰进入Probation。
  3. 如果在Probation中访问了其中某个数据,则这个数据升级为Protected。
  4. 如果Protected满了又会继续降级为Probation。

对于发生数据淘汰的时候,会从Probation中进行淘汰,会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者做PK,通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:

  • 如果攻击者大于受害者,那么受害者就直接被淘汰。
  • 如果攻击者<=5,那么直接淘汰攻击者。这个逻辑在他的注释中有解释: 他认为设置一个预热的门槛会让整体命中率更高。
    走向未来-caffeine_第11张图片
  • 其他情况,随机淘汰。

你可能感兴趣的:(缓存,caffeine,java,缓存)