Caffeine高性能缓存设计

本文深入探讨了Caffeine缓存的设计与优化,包括是否需要缓存的判断、缓存选择、Caffeine的使用以及其高性能设计。重点讲解了Caffeine的W-TinyLFU淘汰算法,包括Count–Min Sketch频率统计和保新机制,以及异步读写和时间轮设计。此外,还分析了Caffeine的淘汰策略、缓存架构以及如何动态调整窗口大小以适应不同应用场景。
摘要由CSDN通过智能技术生成


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;
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值