1 前言
缓存可以说是高性能系统的奇兵,在很多系统中都能看到缓存的身影。当缓存资源紧张时,我们总是期望未来还会用到的缓存项(cache entry)继续保留在缓存中,而淘汰掉未来不会再使用的缓存项。根据缓存的局部性原理,越是最近访问过的缓存项,未来更有可能再次被使用到;最近访问次数最多的缓存项,未来更有可能再次被使用到。也就是说,理想的缓存项目淘汰机制主要需要考虑两个约束:最近访问的时间和缓存项的访问频率。当前大部分使用的堆内本地缓存,淘汰机制主要侧重于其中一个约束,要么侧重于最近访问时间(LRU),要么侧重于最近访问频率(LFU),而caffeine则综合考虑了这两个约束,是一个接近最优(near optimal)的缓存。当前来看,caffeine缓存可以说是java堆内本地缓存的王者,是本人了解到的比较理想的本地缓存方案,其设计思想有很多可以学习、借鉴的地方。
2 缓存的主流淘汰机制
缓存的淘汰机制主要回答在什么时候淘汰哪些缓存项目的问题,主流的淘汰机制有:
2.1 访问时间
只要缓存项最后访问时间超过一定时间值,就把缓存项从缓存中淘汰驱逐掉(evict),而不关心当前缓存实际使用了多少空间,也不考虑被淘汰掉的缓存项未来被使用的可能性高低。访问可以是写(expireAfterWrite),也可以是读写(expireAfterAccess)。对于expireAfterWrite,只要缓存项写入缓存后超过一定时间就淘汰掉;expireAfterAccess则是缓存项既没有读也没有写(更新)一定时间后才淘汰掉。触发淘汰判断的时机可以是缓存项访问时(读缓存项时判断缓存项是否超时),也可以是定时任务周期扫描缓存以判断是否有缓存项超时了,还可以是在缓存空间不够时,扫描缓存是否有缓存项超时了。
redis、caffeine等很多缓存都支持这种淘汰机制。
2.2 弱引用(weak reference)和软引用(soft reference)
缓存中缓存项的key和value采用弱引用或软引用,这样如果缓存项在业务中没有被使用了时(强引用),JVM在垃圾收集(GC)时自动会回收缓存项对象,实现对缓存项的淘汰。guava cache和caffeine都支持这种淘汰机制。
2.3 缓存空间
系统一般会限制缓存的最大空间,当缓存满了,又有新的缓存项需要写入时,需要有一个机制确定淘汰掉缓存中哪些缓存项
2.3.1 FIFO
设定FIFO的最大空间,先进入FIFO(写入缓存)的缓存项最先被淘汰,而不考虑被淘汰的缓存项后来有没有被访问过(读或写)。FIFO方式的实现非常简单,但是缓存命中率(hit rate)并不理想,通用缓存基本上不考虑。
2.3.2 LRU
LRU(Least Recent Use)其实是基于缓存项的最后访问时间(读或写)对缓存项进行排序,访问时间最早(也就是离当前时间最远,缓存项最旧)的缓存项最先被淘汰,最后访问时间离当前时间最近(least recent的含义)的缓存项被保留下来。LRU的优点是,因为把最近访问过的缓存项保留了下来,根据局部性原理,这些缓存项再次被使用的概率是比较高的,所以LRU的命中率还是不错的,同时,LRU的实现也比较简单(虽然比FIFO方式复杂点),一个中高级的java开发人员不用太大的代价就能实现一个基本功能的LRU缓存,所以LRU的使用频率非常高。我们在大部分系统中见到的缓存基本上都是LRU缓存。LRU可以非常好的处理少量稀疏流量(sparse burst,即短时间内使用几次后面就不被使用了),但是无法处理大量的稀疏流量,因为这时会淘汰掉使用频率很高的缓存项,而这些缓存后面还会经常访问到。比如系统中的一些巡检项目,将大量只使用一次的缓存项写入了缓存中,从而排挤掉了哪些真正被经常使用的缓存项。
2.3.3 LFU
LFU(Least Frequecy Use)把访问频率高的缓存项保留下来,同时考虑时间因素,比如一个缓存项最近100ms内使用了50次,另一个缓存项最近200ms内也使用了50次,毫无疑问应该优先保留最近100ms内使用了50次的缓存项。LFU的优点是把最近最经常使用的缓存项保留了下来,而这些缓存项在未来再次被使用的概率也非常高,非常符合系统的需要和目标。LFU的缺点是:
- 需要为每一个缓存项维护相应的频率统计信息,每一次访问都需要更新相应的统计信息,这需要一定的存储和性能开销。同时,维护的频率信息如何体现最近(least)也是一个不那么容易处理的问题。如果频率计数器只维护最近指定一段时间内的访问次数,那么需要有相应的逻辑清除掉这段时间之前的统计频率;
- 无法处理稀疏流量(sparse burst)的场景。因为稀疏流量只有少量的访问次数,在比较访问频率决定去留时处于劣势,可能导致稀疏流量缓存项频繁被淘汰,访问稀疏流量经常无法命中。
3 caffeine缓存核心原理
caffeine缓存采用了W-TinyLFU算法,该算法综合了LRU和LFU的优势,妥善改善了LRU和LFU的劣势
3.1 Count-Min Sketch频率统计算法
为了解决上面介绍的LFU的第一个缺点,caffeine缓存的LFU频率统计方法采用的是Count-Min Sketch算法。该算法借鉴了boomfilter的思想,只不过hash key对应的value不是表示存在的true或false标志,而是一个计数器。它采用了四个hash函数,四个hash函数同时对缓存项key计算hash值,将hash值对应的计数器加一。计数器只有4bit,所以计数器最大只能计数到15,超过15时则保持15不变,不再往上增加计数了。计数器最大只能计数到15,容量超乎预调的小,但是从实际测试来看,效果却超乎预调的好。因为bloomfilter存在positive false的问题(其实就是存在hash冲突),缓存项的频率值取四个计数器的最小值(这就是Count-Min的含义)。当所有计数器值的和超过设定的阈值(默认是缓存项最大数量的10倍)时,所有的计数器值都减到原来的一半。
Count-Min Sketch算法详细实现方案如下:
Count-Min Sketch维护了一个long[] table
数组,数组的大小为缓存空间容量(缓存项最大数量)向上取整为2的n次方。Count-Min Sketch的计数器是4bit,table数组的每个元素大小是64bit,相当于table元素包含了16个计数器,这16个计数器进一步分为4个group,那么每个group包含4个计数器,正好等于bloom hash函数的个数,同一个key的四个计数器分别使用group内相应位置的计数器。每个table元素包含16个计数器,4个hash计数器在相应table元素内计数器的偏移不一样,可以有效降低hash冲突。
缓存项计数统计过程为:先计算缓存项key的hash值,然后使用4个不同的种子值分别计算得到table数组四个元素的下标。然后根据hash值的低2bit确定table数组元素中的group,那么第一个计数器位置为第一个table数组元素相应group中的第一个计数器,第二个计数器位置为第二个table数组元素相应group中的第二个计数器,第三个计数器位置为第三个table数组元素相应group中的第三个计数器,第四个计数器位置为第四个table数组元素相应group中的第四个计数器。
从Count-Min Sketch频率统计算法描述可知,由于计数器大小只有4bit,极大地降低了LFU频率统计对存储空间的要求。同时,计数器统计上限是15,并在计数总和达到阈值时所有计数器值减半,相当于引入了计数饱和和衰减机制,可以有效解决短时间内突发大流量不能有效淘汰的问题。比如出现了一个突发热点事件,它的访问量是其他事件的成百上千倍,但是该热点事件很快冷却下去,传统的LFU淘汰机制会让该事件的缓存长时间地保留在缓存中而无法淘汰掉,虽然该类型事件已经访问量非常小或无人问津了。
3.2 W-TinyLFU算法
如上所述,caffeine缓存的LFU采用了Count-Min Sketch频率统计算法,该LFU的计数器只有4bit大小,所以称为TinyLFU。TinyLFU解决了上述LFU列出的第一个问题,但是并没有解决第二个问题。在TinyLFU算法基础上引入一个基于LRU的Window Cache,从而解决了上述LFU列出的第二个问题,这个新的算法叫就叫做W-TinyLFU(Window-TinyLFU)。
W-TinyLFU算法的框架如下所示:
W-TinyLFU将缓存存储空间分为两个大的区域:Window Cache和Main Cache,Window Cache是一个标准的LRU Cache,Main Cache则是一个SLRU(Segmemted LRU)cache,因为Main Cache进一步划分为Protected Cache(保护区)和Probation Cache(观察区)两个区域,这两个区域都是基于LRU的Cache。protected是一个受保护的区域,该区域中的缓存项不会被淘汰。Probation区域则是一个观察区,当有新的缓存项需要进入Probation区时,如果Probation区空间已满,则会将新进入的缓存项与Probation区中根据LRU规则需要被淘汰(evict)的缓存项进行比较,比较失败的缓存项会被淘汰,获胜的缓存项会进入或保留在Probation区。Window Cache默认为cache总大小的1%,Main Cache默认为cache总大小的99%。Protected Cache默认为Main Cache大小的80%,Probation Cache默认为Main Cache大小的20%。当然这些cache区域的大小会动态调整。
当有新的缓存项写入缓存时,会先写入Window Cache区域,当Window Cache空间满时,最旧的缓存项会被移出Window Cache。如果Probation Cache未满,从Window Cache移出的缓存项会直接写入Probation Cache;如果Probation Cache已满,则会根据TinyLFU算法确定从Window Cache移出的缓存项是丢弃(淘汰)还是写入Probation Cache。Probation Cache中的缓存项如果访问频率达到一定次数,会提升到Protected Cache;如果Protected Cache也满了,最旧的缓存项也会移出Protected Cache,然后根据TinyLFU算法确定是丢弃(淘汰)还是写入Probation Cache。
TinyLFU淘汰机制为:
从Window Cache或Protected Cache移出的缓存项称为Candidate,Probation Cache中最旧的缓存项称为Victim。如果Candidate缓存项的访问频率大于Victim缓存项的访问频率,则淘汰掉Victim。如果Candidate小于或等于Victim的频率,那么如果Candidate的频率小于5,则淘汰掉Candidate;否则,则在Candidate和Victim两者之中随机地淘汰一个。
从上面对W-TinyLFU的原理描述可知,caffeine综合了LFU和LRU的优势,将不同特性的缓存项存入不同的缓存区域,最近刚产生的缓存项进入Window区,不会被淘汰;访问频率高的缓存项进入Protected区,也不会淘汰;介于这两者之间的缓存项存在Probation区,当缓存空间满了时,Probation区的缓存项会根据访问频率判断是保留还是淘汰;通过这种机制,很好的平衡了访问频率和访问时间新鲜程度两个维度因素,尽量将新鲜的访问频率高的缓存项保留在缓存中。同时在维护缓存项访问频率时,引入计数器饱和和衰减机制,即节省了存储资源,也能较好的处理稀疏流量、短时超热点流量等传统LRU和LFU无法很好处理的场景。