Caffeine高性能缓存设计

Posted 惜暮

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Caffeine高性能缓存设计相关的知识,希望对你有一定的参考价值。

Caffeine高性能缓存设计


Caffeine是一个高性能,高命中率,低内存占用,near optimal 的本地缓存。Caffeine被普遍称为“现代缓存之王”。本文将重点讲解Caffeine的高性能设计,以及对应部分的源码分析。

本文基于 2.8.1 源码分析

是否需要缓存

在使用缓存之前,首先需要确认你的项目是否真的需要缓存。使用缓存会引入的一定的技术复杂度,后文也将会一一介绍这些复杂度。一般来说从两个方面来个是否需要使用缓存:

  1. CPU占用: 如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,而其又占用了很多CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。
  2. 数据库或则网络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可以达到最高的命中率。但是有两个很大的缺点:

  1. 维护每个记录项的频率信息,这是个巨大的开销;
  2. 对突发性的稀疏流量无力。

针对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队列:

  1. Eden队列(Window): caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。Eden区最舒服最安逸的区域,在这里很难被其他数据淘汰。
  2. Probation队列: 叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为size减去eden减去protected。
  3. 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 函数主要做了什么,这里只先说跟“淘汰策略”有关的expireEntriesevictEntries 函数。

@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高性能缓存设计的主要内容,如果未能解决你的问题,请参考以下文章

万字详解本地缓存之王 Caffeine 的高性能设计之道!

最佳内存缓存框架Caffeine

缓存之王Caffeine Cache,性能比Guava更强,命中率更高!

Redis+Caffeine两级缓存,让访问速度纵享丝滑

高性能缓存 Caffeine 原理及实战

Caffeine高性能本地缓存框架初探