1、本地缓存
根据缓存是否与应用进程属于同一进程,可以将内存分为本地缓存和分布式缓存。相对于分布式缓存与应用进程部署在不同的机器,需要通过网络来完成缓存数据读写不同,本地缓存在同一个进程的内存空间中缓存数据,并完成数据读写过程。
1.1、本地缓存的使用场景
场景一:二级缓存
当服务访问量出现逾期外的陡增,可能会导致分布式缓存性能变差,甚至被击穿,大量请求将缓存服务打挂,从而影响正常业务功能。此时,可以将本地缓存用作二级缓存,降低服务的压力,提高访问速度,保障业务的稳定性。
场景二:常规流量
是指请求流量稳定,在业务系统中相对重要且访问频繁的热点数据。例如,内部员工手机号数据量不大且修改频率低、但线索量较大时,查询qps较高,是线索转化过程中必须进行的校验,通过使用本地缓存,可以有效提升访问速度,降低对数据库的查询频率,减轻数据库压力。
1.2、本地缓存的优、缺点
序号 | 优点 | 缺点 |
1 | 数据不需要跨网络传输,访问速度较快 | 占用应用进程的内存空间,无法进行大数据存储 |
2 | 有效减小后端数据库或服务器压力 | 一般无法被其他应用进程访问,数据一致性差 |
3 | 支持离线访问,提高了系统稳定性 | 数据存在过期时间和淘汰策略,缓存易失效 |
1.3、本地缓存对比
对比项 | ConcurrentHashMap | GuavaCache | CaffeineCache |
读写性能(并发) | 很好 | 较好 | 很好 |
淘汰算法 | 无 | LRU算法 | W-TinyLFU算法 |
命中率 | 较好 | 较好 | 极好 |
功能丰富度 | 简单 | 丰富 | 丰富 |
读 (100%) 读 (75%) / 写 (25%) 写 (100%)
ConcurrentHashMap 可以作为进程内缓存,不受外部系统影响,读写性能好,速度快。但是无法进行缓存淘汰,需要限定 ConcurrentHashMap的容量范围,此时命中率将会下降,否则内存会无限制的增长。
Guava Cache提供了异步刷新和 LRU 淘汰策略解决上述问题,但偶发性、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
Caffeine Cache则是为解决Guava Cache存在的问题而提出的,采用了 W-TinyLFU算法淘汰策略(LFU+LRU变种),有效提高了缓存的命中率,避免了缓存污染的问题。
2、Caffeine高性能读写
2.1、不同缓存读写策略差异
缓存数据的读写过程往往伴随着一些额外的操作,可以讲这些操作定义为缓存事务,数据过期的计算、判断,访问频率的统计,执行数据的淘汰,这些事务的处理策略是不同缓存读写性能差异点。
Guava Cache 的事务处理在读取缓存时同步进行,这样的好处是不需要后台线程定期扫描处理事务,保证数据的实时性,但会增加一定的耗时。
Caffeine 借鉴了数据库系统的 WAL(Write-Ahead Logging)思想来减轻并发带来的锁竞争问题,在执行读写操作时,先把操作记录在缓冲区,后台线程在合适的时机异步、批量地执行缓冲区中的内容,读写的性能更高。
Guava读写策略 Caffeine读写策略
2.2、Caffeine缓冲队列读写操作
在Caffeine的内部实现中,通过RingBuffer支持不同的操作(如 Removal,Refresh,Cleanup 等等)来降低锁竞争。
RingBuffer 的实现是一个Buffer[]数组,每个元素就是一个RingBuffer,每个线程都有自己对应的RingBuffer。
写入缓存成功后,缓存事务会写入到RingBuffer中,如果RingBuffer已满&&调度状态满足条件,会触发一个异步任务,异步的执行缓存事务。RingBuffer满之后,后续写入该队列的读操作事务会直接被丢弃。
Caffeine认为写操作的量远小于读,且不允许写操作有损,因此所有的写操作共享同一个传统的有界队列,统一由一个消费者按序处理。
3、Caffeine的高命中率
缓存性能关键指标之一就是缓存命中率(命中请求数 / 请求总数),命中率取决于缓存组件的数据淘汰算法。
在缓存清理中,常见的淘汰算法包括LRU、LFU等,而Caffeine则基于LRU算法提出了更高效率的W-TinyLFU算法,该算法同时具备LRU和LFU两者的优点。
3.1、LRU算法
- 思想:根据数据的最近访问记录进行淘汰,算法认为如果缓存最近被命中,那么在以后被访问的概率也较大。
- 优点:可以有效的对访问频繁的热点数据进行保护
- 缺点:对于周期性和偶发性高频率访问,易对缓存造成数据污染,使热点数据被淘汰,反而保留这些偶发性的非热点数据,导致此后的缓存命中率下降。
3.2、LFU算法
- 思想:LFU会统计一段时间内的访问频率,并保留访问频率较高的数据,进行数据淘汰。
- 优点:LFU也可以有效的保护缓存,相对周期性和偶发性高频率访问场景来讲,比LRU有更好的缓存命中率。
- 缺点:对突发性的稀疏流量无力,需要针对每个记录项维护Long类型的频率信息,每次访问都要更新频率,占用较大的空间记录所有出现过的 key 和其对应的频次;
3.3、W-TinyLFU算法
3.3.1、算法数据结构
W-TinyLFU的数据结构如图所示,它由两个缓存单元组成,主缓存使用SLRU驱逐策略和TinyLFU准入策略,窗口缓存仅使用LRU驱逐策略,无准入策略。
- 准入窗口(Admission Window)是一个较小的LRU队列,其容量只有缓存大小的1%,这个窗口的作用主要是为了保护一些新进入缓存的数据,给予一定的成长时间来积累使用频率,避免被快速淘汰。
- 频次过滤器(TinyLFU)是Caffeine数据淘汰策略的核心所在,他依赖CountMin Sketch非精确的记录数据的历史访问次数,从而决定主缓存区数据的淘汰策略。
- 主缓存区(Main region)用于存放大部分的缓存数据,数据结构为一个分段LRU队列(SLRU),包括保护区和观察区两部分,该部分使用TinyLFU策略进行数据的淘汰,一些访问频次很低的数据可以被快速淘汰掉,避免了主缓存区被新缓存污染。
3.3.2、频率统计方法
由于LFU算法基于hashMap来记录缓存项的频率,需要占用较大内存空间。为解决这个问题,在W-TinyLFU中使用Count-Min Sketch方法,来实现在有限的空间高效的记录缓存项访问频率。Count-Min Sketch算法详细实现方案如下:
- Count-Min Sketch维护了一个
long[] table
数组,通过四次hash过程可以有效降低hash冲突的概率。 - Count-Min Sketch的每个计数器占用4bit,而table数组的每个元素大小占用64bit,相当于单个table元素包含16个计数器,相对于LFU算法而言,统计频率占用的内存就减小了16倍。
- 16个计数器进一步分为4个group,那么每个group包含4个计数器,正好等于hash函数的个数,同一个key的四个计数器分别使用group内相应位置的计数器。
3.3.3、整体流转过程
1、所有进入缓存区的数据会首先被add进窗口区,当该队列长度达到容量限制后,会触发窗口区的淘汰操作,超出的元素会被下放至主缓存区的观察区队列,这些数据被称为竞争者。
2、如果观察区未满,且观察区队列中的数据在被主缓存区淘汰之前获得了一次访问,该节点会被add进保护区队列,节点完成晋升。
3、如果观察区也满了,对窗口区竞争者和观察区受害者的访问频次进行比较,而频次通过Count-Min Sketch方法获取。
4、保护区队列如果达到其容量限制会触发节点降级过程,同样通过比较竞争者与受害者的访问频次,如果受害者频次较低,队列首部的元素将会被下放到观察区队列。
4、Caffeine主要功能概括
5、回顾
- 有效的数据结构(缓存分区)
caffeine综合了LFU和LRU的优势,将不同特性的缓存项存入不同的缓存区域,通过这种机制,很好的保障了访问时间新鲜程度因素,尽量将新鲜的缓存项保留在缓存中,能较好的处理稀疏流量、短时超热点流量等传统LRU和LFU无法很好处理的场景。
- 高效的频率统计方法(概率计数)
在维护缓存项访问频率时,使用CountMin-Sketch算法进行概率计数,同时引入计数器饱和和衰减机制,较大程度上节省了存储资源,将访问频率高的缓存项保留在缓存中,实现了LFU更加高效的模拟实现。
- 异步的缓存读写策略(WAL思想)
借鉴了数据库系统的 WAL(Write-Ahead Logging)思想,即:先写日志,再执行操作,执行读写操作时,先把操作记录在缓冲区,然后在合适的时机异步、批量地执行缓冲区中的内容,有效提升了caffeine缓存读写性能。