缓存Caffeine之W-TinyLFU淘汰策略

我们常见的缓存是基于内存的缓存,但是单机的内存是有限的,不能让缓存数据撑爆内存,所有需要缓存淘汰机制。https://mp.csdn.net/editor/html/115872837 中大概说明了LRU的缓存淘汰机制,以及基于LRU的著名实现guava cache。除了LRU淘汰策略外,其是常见的还有FIFO以及LFU,只是说目前用的最多的是LRU。

LRU

LRU记录了缓存中数据项的访问时间,在缓存数据量达到一定成都的时候,淘汰掉最远访问的那些数据项。

LRU的理论基础就是局部性原理,认为最近访问过的数据,在未来还会继续访问的概率是比较大的,所以当缓存满的时候,首先将那些很久没有访问过的数据项给淘汰出缓存。

优点:实现简单(链表+hash表),且命中率还是相对比较高的。所以LRU也是当前使用的最多的缓存淘汰算法。

更多更具体的介绍可参考:LRU算法及其优化策略——算法篇 - 掘金

缺点:对于偶发的批量操作支持不友好,偶发的批量操作会降低缓存命中率。这种批量操作会将非热点数据打量加载到内存,按照LRU可能就将真正的热点数据挤出缓存。

mysql的优化

LRU算法中,对批量支持不友好的本质原因其实还是LRU算法淘汰的依据仅仅是访问时间,缺少数据项的访问次数的统计,当需要淘汰的时候,只能根据访问时间来进行淘汰,所以就会导致偶尔的一次批量操作将访问频率低的数据项读入到缓存。所以解决这个问题的办法就是加入对数据项访问量的统计,在淘汰策略的时候,将访问频率也加入到决策因素中。

mysql将整个LRU分成了两段来避免这个问题,为每个数据项增加了一个统计时间窗口来解决这个问题(这个其本质就是增加了统计访问频率),如下图:

缓存Caffeine之W-TinyLFU淘汰策略_第1张图片

  1. 缓存淘汰的位置是从old读队尾开始淘汰的,即当缓存不够的时候,优先将old端队尾位置的元素剔除缓存

  1. 当数据读入的时候,是将该数据项放到了old的队头。 然后给这个数据项一个统计时间窗口,即innodb_old_blocks_time参数指定的时间段,只有在指定时间窗口内,这个数据项再次被访问到的时候,就会将这个数据项挪到yong端。

这样,热点数据都是放在了yong端,优先淘汰的数据都是在old端。偶发的批量操作读取的数据都在old,会优先于yong端数据被淘汰,从而避免批量操作将热点数据淘汰出缓存,降低命中率。

LFU

LFU为缓存中的每个数据项都维护了一个计数器,会统计每个缓存项的访问次数,当缓存使用量达到一定程度的时候,会优先淘汰那些访问次数比较小的数据项

LFU是基于这种思想进行设计:一定时期内被访问次数最少的数据项,在将来被访问到的几率也是最小的。具体可参考LFU算法及其优化策略——算法篇 - 掘金

优点:很明显,LFU是没有LRU偶发批量操作导致的将热点数据挤出缓存的问题。

但是LFU的缺点也很明显:

  1. 对于那种大热门而又迅速冷却的数据,比较难以淘汰出缓存。比如某明星出轨消息,当刚曝出时,浏览量特别高,可能段时间内就是上千万次的读取,但是第二天就没人关心这个信息了,那几乎就没什么读取量了,但是因为第一天太热门了,远远超过了其他数据项的读取次数

  1. 因为淘汰是基于数据项的访问次数的,那么对于新加入到缓存的数据,更容易被淘汰,除了偶发的批量操作导致的新加入到缓存的数据以外,更多的情况可能是新加入到缓存的数据被后续操作访问的可能性是比较大的。这种数据被提出缓存,就有可能造成很快又被加载进来的。

  1. 因为LFU需要为每个数据项维护访问频率的数据,一方面实现复杂度相对就较高了,而且会额外占用空间。另外一个问题就是,这个访问次数如果是有史以来的访问次数和,那其实根据这个访问次数实现的淘汰策略必定不是很理想,所以这个访问次数一定是最近一段时间的访问次数,这就有个问题了,最近一段时间是多长时间?以及到期的时候这些访问次数的清理其实都是比较麻烦的事情。

不管是FIFO、LRU还是LFU,解决了一定问题,但是还是会有其他的问题出现,归根接地,其原因无外乎是以为淘汰策略参考的因素单一:FIFO只是参考了进入队列时间、LRU只是参考了最近访问时间、LFU只是参考了访问次数。所以都会有些问题。

所以在实际生产中,一个缓存框架的淘汰策略,实际上很少会单独使用某一个指标来决定淘汰策略,比如mysql的缓存,虽然整体上是LRU,但是实际上也引入了访问次数的因素。所以往往都是将多个维度相结合,共同来决定应该淘汰缓存转给你的哪些数据,达到既不会将缓存撑爆,又能够维持一个比较搞的缓存命中率,使得缓存效果最大化。

W-TinyLFU

这个淘汰算法其实就是结合LRU和LFU算法的优缺点的一个缓存淘汰算法,说白了就是要保留LRU和LFU的这些优点(淘汰策略要结合最近访问时间和访问次数),并规避掉上述各自的问题。

参考:W-TinyLFU论文阅读与原理分析

TinyLFU

要为缓存中的每个数据项维护一个计数器,最基本的结构那就是一个HashMap,key=缓存项的key,value就是这个缓存项的访问计数。这个结构实现基本功能是没问题的,但是问题在于效率和空间上:key是数据项的key,会占据额外的空间的,另外每次访问缓存都需要修改对应数据项的计数器,这里就会有并发问题,且如果当hash有冲突,那么这个访问效率就会直线下降,导致缓存的访问不稳定。对于一个高效的缓存来说,这都是致命的问题。

所以就需要找到一个减少空间占用的方式:那就是压缩,但需要考虑怎么从压缩后的数据里获得对应key的频率。对于使用HashMap的场景中,如果需要空间考虑,那么可以参考存在性判断的思路:使用BitMap,但是BitMap的问题就是hash冲突,解决它的方式就是布隆过滤器,用多个hash函数来减少hash冲突,然后容忍小概率的hash冲突导致的不准确(这也是为啥说boomfilter对于不存在判断一定是准的,但是存在判断存在误差的原因)。

Count-Min Sketch也是采用了类似布隆过滤的方式来为数据项维护计数,和布隆过滤器的区别在于,布隆过滤器的值,只需要一个bit就足够用来表示是否存在在了,但是Count-Min Sketch,对应的值需要是一个计数值,就不能是一个bit了,采用4bit来进行计数。

Caffeine的实现中认为,一个数据项的访问频率到了15就算是很高了,就属于热点数据了,所以采用4bit来存储计数就已经够了。当计数值达到15的时候,就将技术值减半,这也起到了一个衰减的作用,达到计数器记录的是"最近"访问次数的效果,从而也解决了那种热极一时的数据不容易淘汰的问题。

为了减少hash冲突的问题,Caffeine使用了4个hash函数,为每个数据项保存了4个计数器,即一个数据项需要4*4=16bit来存储访问频率,而在使用淘汰的时候,取的是这4个计数值中的最小值来进行访问频率比较;只要一个计数器的值达到15,则中4个计数值都将减半。

Caffeine的TinyLFU的实现在FrequencySketch中,

获取一个数据项的技术统计频率,是取四个计数器中的最小值,这也是为啥叫TinyLFU的原因:

缓存Caffeine之W-TinyLFU淘汰策略_第2张图片

数据项的访问次数+1

缓存Caffeine之W-TinyLFU淘汰策略_第3张图片

Caffeine是用一个long数组来保存数据项的访问计数的,一个long有64位,可以充当16个计数器,但其实要通过位运算完全利用上这64,java提供的能力还有有点困难,所以Caffeine取了数据项key的hash值的后左移两位,然后加上hash四次,可以利用到16个中的13个,利用率挺高的,或许有更好的算法能将16个都利用到。

ps:FrequencySketch里大量使用了位运算,代码阅读其实很不直观,可以先了解原理,然后结合debug、注释看。

W-TinyLFU

Count-Min Sketch算法解决了LFU中频率统计的问题,但是对于新加入数据更容易被淘汰、热极一时的数据项更难被淘汰出去的问题还存在,而这些问题正式LRU不会存在的。所以W-TinyLFU就是在TinyLFU前面增加了一个LRU缓存。它将缓存分成了两大部分:Window cache和main cache,不同区域采用不同的淘汰策略

  • window cache:这部分是一个LRU缓存。当读入缓存的时候,都是先进入到window cache;当window cache满的时候,按照LRU淘汰出数据项,对于从window cache淘汰出的数据项,再根据TinyLFU来决策是否进入到main cache中。在Caffine的实现中,window cache默认是整个cache的1%,缓存数据是通过一个ConcurrentHashMap来实现,而window cache的LRU策略是通过一个双端队列:accessOrderEdenDeque来实现的。

  • main cache:进一步划分为两部分

  • protected部分:在Caffine的实现中,这部分默认占main cache的80%,其淘汰策略的实现是通过一个双端队列accessOrderProtectedDeque来实现的。这里面存放的其实是访问频率比较高的热数据,这是整个缓存的核心部分,是高缓存命中的保证。

  • probation部分:在Caffine的实现中,这部分默认占main cache的20%,其淘汰策略的实现是通过一个双端队列accessOrderProbationDeque来实现的。里面存放的其实就是访问频率比较少的冷数据,即即将别淘汰出缓存的数据。

Caffeine的整体实现在BoundedLocalCache中(源码分析的可参考http://events.jianshu.io/p/77e90f79f075,Caffeine的使用可参考https://www.chinacion.cn/article/5629.html,缓存的概要介绍可参考https://my.oschina.net/u/4072299/blog/3019442)

缓存Caffeine之W-TinyLFU淘汰策略_第4张图片

从整个实现上可以发现,在思路上和mysql对lru的优化都是打通小异的,都是将整个LRU缓存进行分区,然后在实际执行淘汰的时候加上数据项的访问次数。

  • 对于mysql的优化,对于偶发的批量操作数据全都在old端,当整体缓存不够的时候会被优先淘汰,真正的热数据在yong端,yong端不够用了,就会按照LRU淘汰到old,old的数据如果再次被频繁访问又会被放到yong端,从而达更高的缓存命中。

  • caffeine其实是类似的,新加载进来的数据首先会进入到window cache,包括偶发的批量操作的数据项,而真正将数据项淘汰出缓存是从probation,所以新加入的数据项,只会等到window cache满的时候,才会去比较频率,而这个时候新加入的数据项可能访问次数又变高了,在跟probation淘汰出来的数据项比较频率的时候,最终淘汰掉的是probation中的数据项,即window cache出来的数据项访问频率并不高,也不一定会被淘汰,因为有个访问次数小于5,是随机淘汰的机制。所以window cache就解决了LFU新加入数据项访问频率低容易被淘汰的问题。

tinyLFU解决了数据项频率统计占用空间的问题,而且计数器满自动筛检一半的机制又避免了热极一时的数据项不容易被淘汰的问题。

时间过期策略

不管是guava cache、Caffine这种内存缓存框架,还是redis这种分布式缓存框架,都提供了一个功能,那就是给数据加上过期时间。特别是redis,可以对每个key都支持配置一个不同的过期时间。

当满足过期时间后,再从缓存中获取数据的时候讲返回空。那么这种过期的数据是什么时候从内存中清楚的呢?

  1. 定时清除:起一个定时任务,监视所有的key,当有key过期了就直接将其删除。这种方式最大的问题就在于定时人对cpu的消耗。而缓存都是内存操作,所以是cpu密集型的场景,如果清楚过期key占用了大量cpu,则会影响缓存的响应

  1. 访问时清除:get(key)查询的时候,都会检查这个key有没有过期,如果没有过期才返回数据;如果过期了,就会返回null。当如果发现key已经过期了,返回null的同时,删除这个过期key。

guava的cache、Caffeine都是这种方式:过期时间相关的功能都是在数据访问的时候去检查实现的,如下是guava的get()方法的实现

缓存Caffeine之W-TinyLFU淘汰策略_第5张图片
  1. 不清除,依靠lru等缓存过期算法,当内存不够的时候自动淘汰

你可能感兴趣的:(guava,缓存,java,淘汰策略,Caffeine)