Caffeine高性能缓存设计
Caffeine是一个高性能,高命中率,低内存占用,near optimal 的本地缓存。Caffeine被普遍称为“现代缓存之王”。本文将重点讲解Caffeine的高性能设计,以及对应部分的源码分析。
本文基于 2.8.1 源码分析
是否需要缓存
在使用缓存之前,首先需要确认你的项目是否真的需要缓存。使用缓存会引入的一定的技术复杂度,后文也将会一一介绍这些复杂度。一般来说从两个方面来个是否需要使用缓存:
- CPU占用: 如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,而其又占用了很多CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。
- 数据库或则网络IO占用: 如果你发现你的数据库连接池比较空闲,那么不应该用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑缓存了。
如果并没有上述两个问题,那么你不必为了增加缓存而缓存。
选择合适的缓存
缓存分为本地缓存和分布式缓存两种。
对于本地缓存来说,如果不需要淘汰算法则选择ConcurrentHashMap,如果需要淘汰算法和一些丰富的API,这里推荐选择Caffeine。
分布式缓存,这里就不介绍了。
实际应用系统一般都会有多级缓存。
Caffeine 的使用
Caffeine的API和Guava非常的相似,下面给出一个创建cache的例子:
package com.example.caffeine;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.CacheWriter;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.sun.istack.internal.Nullable;
import org.checkerframework.checker.nullness.qual.NonNull;
public class Demo {
public static void main(String[] args) {
LoadingCache<String, String> cache = Caffeine.newBuilder()
//最大个数限制
.maximumSize(256L)
//初始化容量
.initialCapacity(1)
//访问后过期(包括读和写)
.expireAfterAccess(2, TimeUnit.DAYS)
//写后过期
.expireAfterWrite(2, TimeUnit.HOURS)
//写后自动异步刷新
.refreshAfterWrite(1, TimeUnit.HOURS)
//记录下缓存的一些统计数据,例如命中率等
.recordStats()
//cache对缓存写的通知回调
.writer(new CacheWriter<Object, Object>() {
@Override
public void write(@NonNull Object key, @NonNull Object value) {
System.out.printf("key={}, CacheWriter write", key);
}
@Override
public void delete(@NonNull Object key, @Nullable Object value, @NonNull RemovalCause cause) {
System.out.printf("key={}, cause={}, CacheWriter delete", key, cause);
}
})
//使用CacheLoader创建一个LoadingCache
.build(new CacheLoader<String, String>() {
//同步加载数据
@Nullable
@Override
public String load(@NonNull String key) throws Exception {
return "value_" + key;
}
//异步加载数据
@Nullable
@Override
public String reload(@NonNull String key, @NonNull String oldValue) throws Exception {
return "value_" + key;
}
});
cache.put("aaa", "abdunoias");
cache.get("aaa");
cache.getIfPresent("bnsodapo,");
}
}
这个API的设计和Guava非常像。
Caffeine高性能设计
判断一个本地缓存的好坏最核心指标就是命中率和内存占用,影响命中率的因素有很多,比如业务场景,淘汰策略,清理策略,缓存容量
W-TinyLFU 淘汰算法的整体设计
淘汰策略是影响缓存命中率的很重要的因素,我们常用的有LRU或则LFU。W-TinyLFU很明显是一种变种的 LFU 的淘汰算法。
LRU和LRU的缺点
LRU实现非常简单,性能也非常好。LRU对突发的稀疏流量(sparse bursts)表现很好,但同时也会产生缓存污染,比如偶然性的要对全量数据进行遍历,那么“历史访问记录”就会被刷走,造成污染。也就是冷数据会顶掉热数据
如果数据的分布在一段时间内是固定的话,那么LFU可以达到最高的命中率。但是有两个很大的缺点:
- 维护每个记录项的频率信息,这是个巨大的开销;
- 对突发性的稀疏流量无力。
针对LRU和LFU都有很多改良算法,比如基于LRU的ARC等。
TinyLFU
TinyLFU就是基于LFU的改良算法。
解决LFU的第一个缺点是采用了Count–Min Sketch算法。
解决LFU的第二个缺点是让记录尽量保持相对的“新鲜”(Freshness Mechanism),并且当有新的记录插入时,可以让它跟老的记录进行“PK”,输者就会被淘汰,这样一些老的、不再需要的记录就会被剔除。
下图是TinyLFU设计图:
统计频率Count–Min Sketch算法
统计频率的核心问题就是如果既可以对一个key进行统计,但是又可以节省空间。简单的hashmap肯定是不行的,这太消耗内存。对于缓存key的统计来说,这里的统计并不需要非常精确,只需要一个近似值就可以了。这个和Bloom Filter似乎非常相似,只不过Bloom Filter统计的是true或则false。Count–Min Sketch的原理跟Bloom Filter一样,只不过Bloom Filter只有0和1的值,那么你可以把Count–Min Sketch看作是“数值”版的Bloom Filter。
TODO:Count–Min Sketch实现的细节
频率统计Count–Min Sketch的保新机制
保新机制是为了让缓存保持“新鲜”,剔除掉过往频率很高但之后不经常使用的缓存,Caffeine有一个Freshness Mechanism。做法很简答,就是当整体的统计计数(当前所有记录的频率统计之和,这个数值内部维护)达到某一个值时,那么所有记录的频率统计除以2。
TODO:Count–Min Sketch关于reset的实现细节
增加一个小window
Caffeine通过测试发现 TinyLFU 在面对突发性的稀疏流量(sparse bursts)时表现很差,因为新的记录(new items)还没来得及建立足够的频率就被剔除出去了,这就使得命中率下降。
于是Caffeine设计出一种新的policy,即Window Tiny LFU(W-TinyLFU),并通过实验和实践发现W-TinyLFU比TinyLFU表现的更好。
W-TinyLFU的设计如下所示:
它主要包括两个缓存模块,主缓存是SLRU(Segmented LRU,即分段LRU),SLRU包括一个名为protected和一个名为probation的缓存区。通过增加一个缓存区(即Window Cache),当有新的记录插入时,会先在window区呆一下,就可以避免上述说的sparse bursts问题。
淘汰策略
Caffeine 中所有的缓存数据都存储在ConcurrentHashMap中。在caffeine中有三个记录引用的LRU队列:
- Eden队列(Window): caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。Eden区最舒服最安逸的区域,在这里很难被其他数据淘汰。
- Probation队列: 叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为size减去eden减去protected。
- Protected队列: 在这个队列中,可以稍微放心一下了,你暂时不会被淘汰,但是别急,如果Probation队列没有数据了或者Protected数据满了,你也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80% 如果size =100,就会是79。
经过实验测试,上面列出的Eden队列占比1%,剩余的99%当中的80%分给protected区,20%分给probation区时,这时整体性能和命中率表现得最好,所以Caffeine默认的比例设置就是这个。
不过这个比例Caffeine会在运行时根据统计数据(statistics)去动态调整,如果你的应用程序的缓存随着时间变化比较快的话,那么增加window区的比例可以提高命中率,相反缓存都是比较固定不变的话,增加Main Cache区(protected区 +probation区)的比例会有较好的效果。具体这块实现在后面的Pacer中介绍。
Caffeine的缓存淘汰策略就是基于这三个队列做的。Caffeine的淘汰策略都包含在函数 maintenance
中。
maintenance
的调用大部分情况下都会在 PerformCleanupTask
里面run。提交PerformCleanupTask
这个 Runnable 的Task的场景很多,最主要是在 afterRead
或则 afterWrite
之后。
这里主要来分析一下maintenance
函数主要做了什么,这里只先说跟“淘汰策略”有关的expireEntries
和evictEntries
函数。
@GuardedBy("evictionLock")
void maintenance(@Nullable Runnable task) {
// 异步读写处理
drainReadBuffer();
drainWriteBuffer();
drainKeyReferences();
drainValueReferences();
// 淘汰策略
expireEntries();
evictEntries();
// pacer
climb();
}
根据注释,该函数会执行挂起的维护工作,并在处理期间设置状态标志,以避免过多的调度尝试。执行之后读缓冲区、写缓冲区和引用队列会被耗尽,然后是过期和基于大小的回收。
先介绍一下Caffeine对上面说到的W-TinyLFU策略的实现用到的数据结构:
//最大的个数限制
long maximum;
//当前的个数
long weightedSize;
//window区的最大限制
long windowMaximum;
//window区当前的个数
long windowWeightedSize;
//protected区的最大限制
long mainProtectedMaximum;
//protected区当前的个数
long mainProtectedWeightedSize;
//下一次需要调整的大小(还需要进一步计算)
double stepSize;
//window区需要调整的大小
long adjustment;
//命中计数
int hitsInSample;
//不命中的计数
int missesInSample;
//上一次的缓存命中率
double previousSampleHitRate;
final FrequencySketch<K> sketch;
//window区的LRU queue(FIFO)
final AccessOrderDeque<Node<K, V>> accessOrderWindowDeque;
//probation区的LRU queue(FIFO)
final AccessOrderDeque<Node<K, V>> accessOrderProbationDeque;
//protected区的LRU queue(FIFO)
final AccessOrderDeque<Node<K, V>> accessOrderProtectedDeque;
以及默认比例设置(意思看注释)
/** The initial percent of the maximum weighted capacity dedicated to the main space. */
static final double PERCENT_MAIN = 0.99d;
/** The percent of the maximum weighted capacity dedicated to the main's protected space. */
static final double PERCENT_MAIN_PROTECTED = 0.80d;
/** The difference in hit rates that restarts the climber. */
static final double HILL_CLIMBER_RESTART_THRESHOLD = 0.05d;
/** The percent of the total size to adapt the window by. */
static final double HILL_CLIMBER_STEP_PERCENT = 0.0625d;
/** The rate to decrease the step size to adapt by. */
static final double HILL_CLIMBER_STEP_DECAY_RATE = 0.98d;
/** The maximum number of entries that can be transfered between queues. */
static final int QUEUE_TRANSFER_THRESHOLD = 1_000;
expireEntries 方法
expireEntries 主要是基于Access、Write、或则是variable过期entries的。比如Access后一小时过期.
TODO
evictEntries 方法
evictEntries用于window 区的size超过了其最大的capacity之后来 evict entries。
@GuardedBy("evictionLock")
void evictEntries() {
if (!evicts()) {
return;
}
// 淘汰window区的记录, 返回的是淘汰的记录数。
int candidates = evictFromWindow();
// 淘汰Main区的记录
evictFromMain(candidates);
}
当Window区域size超过了其最大值时候,从window区域淘汰元素到Main区域。
//根据W-TinyLFU,新的数据都会无条件的加到admission window
//但是window是有大小限制,所以要“定期”做一下“维护”
@GuardedBy("evictionLock")
int evictFromWindow() {
int candidates = 0;
//获取window区域的头部节点
Node<K, V> node = accessOrderWindowDeque().peek();
//如果window区超过了最大的限制,那么就要把“多出来”的记录做处理
while (windowWeightedSize() > windowMaximum()) {
// The pending operations will adjust the size to reflect the correct weight
if (node == null) {
break;
}
//下一个节点
Node<K, V> next = node.getNextInAccessOrder();
if (node.getPolicyWeight() != 0) {
//把node定位在probation区
//然后从window区去掉,并加到probation区,相当于把节点移动到probation区(晋升了)
node.makeMainProbation();
accessOrderWindowDeque().remove(node);
accessOrderProbationDeque().add(node);
candidates++;
//因为移除了一个节点,所以需要调整window的size
setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
}
node = next;
}
return candidates;