Caffeine高性能缓存设计
Posted 惜暮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Caffeine高性能缓存设计相关的知识,希望对你有一定的参考价值。
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;
淘汰Main区的记录的evictFromMain
方法:
//根据W-TinyLFU,从window晋升过来的要跟probation区的进行“PK”,胜者才能留下
@GuardedBy("evictionLock")
void evictFromMain(int candidates)
int victimQueue = PROBATION;
//victim是probation queue的头部
Node<K, V> victim = accessOrderProbationDeque().peekFirst();
//candidate是probation queue的尾部,也就是刚从window晋升来的
Node<K, V> candidate = accessOrderProbationDeque().peekLast();
//当cache不够容量时才做处理
while (weightedSize() > maximum())
// Stop trying to evict candidates and always prefer the victim
if (candidates == 0)
candidate = null;
// 对candidate为null且victim为null的处理
if ((candidate == null) && (victim == null))
if (victimQueue == PROBATION)
// 当PROBATION区为空的时候,就切换到从PROTECTED区去取元素做淘汰PK
victim = accessOrderProtectedDeque().peekFirst();
victimQueue = PROTECTED;
continue;
else if (victimQueue == PROTECTED)
// Main区整个都空的时候,从WINDOW区去取元素做淘汰PK
victim = accessOrderWindowDeque().peekFirst();
victimQueue = WINDOW;
continue;
// The pending operations will adjust the size to reflect the correct weight
break;
//忽略weight为0的victim节点
if ((victim != null) && (victim.getPolicyWeight() == 0))
victim = victim.getNextInAccessOrder();
continue;
else if ((candidate != null) && (candidate.getPolicyWeight() == 0))
//对weight为0的candidate处理
candidate = candidate.getPreviousInAccessOrder();
candidates--;
continue;
// Evict immediately if only one of the entries is present
if (victim == null)
@SuppressWarnings("NullAway")
// 直接淘汰 candidate
Node<K, V> previous = candidate.getPreviousInAccessOrder();
Node<K, V> evict = candidate;
candidate = previous;
candidates--;
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
else if (candidate == null)
// 直接淘汰 victim
Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
// 当entry的key已经因为引用关系被回收时候,直接evict entry。
K victimKey = victim.getKey();
K candidateKey = candidate.getKey();
if (victimKey == null)
@NonNull Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.COLLECTED, 0L);
continue;
else if (candidateKey == null)
candidates--;
@NonNull Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.COLLECTED, 0L);
continue;
//放不下的节点直接处理掉
if (candidate.getPolicyWeight() > maximum())
candidates--;
Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
continue;
//根据节点的统计频率frequency来做比较,看看要处理掉victim还是candidate
//admit是具体的比较规则
candidates--;
if (admit(candidateKey, victimKey))
//如果candidate胜出则淘汰victim
Node<K, V> evict = victim;
victim = victim.getNextInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
candidate = candidate.getPreviousInAccessOrder();
else
//如果是victim胜出,则淘汰candidate
Node<K, V> evict = candidate;
candidate = candidate.getPreviousInAccessOrder();
evictEntry(evict, RemovalCause.SIZE, 0L);
admit
函数就是 Count–Min Sketch
获取key的频率然后根据频率做PK的逻辑。具体可以参考Count–Min Sketch的实现部分。
上面的函数 evict entry都是通过调用 evictEntry
函数来实现的。这里evictEntry的cause有两种,一个是因为GC已经回收没有引用关系的entry,一个是因为缓存的size。
@GuardedBy("evictionLock")
// 尝试去 evict entry,如果entry已经有了一些update,这次更新可能会忽略。
boolean evictEntry(Node<K, V> node, RemovalCause cause, long now)
K key = node.getKey();
V[] value = (V[]) new Object[1];
// 标记entry是否已经被remove
boolean[] removed = new boolean[1];
// 标记entry是否已经复活。
boolean[] resurrect = new boolean[1];
RemovalCause[] actualCause = new RemovalCause[1];
// 如果entry的key还存在于缓存中,就通过mappingFunction函数重新计算新值并替换旧值。
// 如果entry已经复活了,就会直接return,否则就会将evict entry(从缓存中删除)。
data.computeIfPresent(node.getKeyReference(), (k, n) ->
if (n != node)
return n;
synchronized (n)
value[0] = n.getValue();
actualCause[0] = (key == null) || (value[0] == null) 以上是关于Caffeine高性能缓存设计的主要内容,如果未能解决你的问题,请参考以下文章