干掉GuavaCache:Caffeine才是本地缓存的王

话说,中间件的选择上,Spring(SpringBoot)一直是业界的风向标。比如Spring一直使用「Jackson」,而没有使用Gson和fastjson。SpringBoot2.0默认数据库连接池从TomcatPool换到了「HikariCP」。在本地缓存方面,SpringFramework5.0(SpringBoot2.0)放弃了Google的GuavaCache,选择了「Caffeine」(Drop Guava caching - superseded by Caffeine [SPR-13797] #18370)。那么Caffeine有什么魔力,能干掉Google的Guava呢?

压力测试

我们用数据说话。Caffeine官方利用最权威的压测工具「JMH」对Caffeine、ConcurrentMap、GuavaCache、ehcache等做了详细的压测对比,结果如下:

更多压测对比请参考:https://github.com/ben-manes/caffeine/wiki/Benchmarks。从官方的压测结果来看,无论是全读场景、全写场景、或者读写混合场景,无论是8个线程,还是16个线程,Caffeine都是完胜、碾压,简直就是拿着望远镜都看不到对手。

简介

官方介绍Caffeine是基于JDK8的高性能本地缓存库,提供了几乎完美的命中率。它有点类似JDK中的ConcurrentMap,实际上,Caffeine中的LocalCache接口就是实现了JDK中的ConcurrentMap接口,但两者并不完全一样。最根本的区别就是,ConcurrentMap保存所有添加的元素,除非显示删除之(比如调用remove方法)。而本地缓存一般会配置自动剔除策略,为了保护应用程序,限制内存占用情况,防止内存溢出。

Caffeine提供了灵活的构造方法,从而创建可以满足如下特性的本地缓存:

  1. 自动把数据加载到本地缓存中,并且可以配置异步;

  2. 基于数量剔除策略;

  3. 基于失效时间剔除策略,这个时间是从最后一次访问或者写入算起;

  4. 异步刷新;

  5. Key会被包装成Weak引用;

  6. Value会被包装成Weak或者Soft引用,从而能被GC掉,而不至于内存泄漏;

  7. 数据剔除提醒;

  8. 写入广播机制;

  9. 缓存访问可以统计;

使用

Caffeine使用还是非常简单的,如果你用过GuavaCache,那就更简单了,因为Caffeine的API设计大量借鉴了GuavaCache。首先,引入Maven依赖:

  1.  
    <dependency>
  2.  
    <groupId>com.github.ben-manes.caffeine</groupId>
  3.  
    <artifactId>caffeine</artifactId>
  4.  
    <version>2.8.4</version>
  5.  
    </dependency>

然后构造Cache使用即可:

  1.  
    Cache<String, String> cache = Caffeine.newBuilder()
  2.  
    // 数量上限
  3.  
    .maximumSize(1024)
  4.  
    // 过期机制
  5.  
    .expireAfterWrite(5, TimeUnit.MINUTES)
  6.  
    // 弱引用key
  7.  
    .weakKeys()
  8.  
    // 弱引用value
  9.  
    .weakValues()
  10.  
    // 剔除监听
  11.  
    .removalListener((RemovalListener<String, String>) (key, value, cause) ->
  12.  
    System.out.println("key:" + key + ", value:" + value + ", 删除原因:" + cause.toString()))
  13.  
    .build();
  14.  
    // 将数据放入本地缓存中
  15.  
    cache.put("username", "afei");
  16.  
    cache.put("password", "123456");
  17.  
    // 从本地缓存中取出数据
  18.  
    System.out.println(cache.getIfPresent("username"));
  19.  
    System.out.println(cache.getIfPresent("password"));
  20.  
    System.out.println(cache.get("blog", key -> {
  21.  
    // 本地缓存没有的话,从数据库或者Redis中获取
  22.  
    return getValue(key);
  23.  
    }));

当然,使用本地缓存时,我们也可以使用异步加载机制:

  1.  
    AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
  2.  
    // 数量上限
  3.  
    .maximumSize(2)
  4.  
    // 失效时间
  5.  
    .expireAfterWrite(5, TimeUnit.MINUTES)
  6.  
    .refreshAfterWrite(1, TimeUnit.MINUTES)
  7.  
    // 异步加载机制
  8.  
    .buildAsync(new CacheLoader<String, String>() {
  9.  
    @Nullable
  10.  
    @Override
  11.  
    public String load(@NonNull String key) throws Exception {
  12.  
    return getValue(key);
  13.  
    }
  14.  
    });
  15.  
    System.out.println(cache.get("username").get());
  16.  
    System.out.println(cache.get("password").get(10, TimeUnit.MINUTES));
  17.  
    System.out.println(cache.get("username").get(10, TimeUnit.MINUTES));
  18.  
    System.out.println(cache.get("blog").get());

接下来,我们对一些重要特性进行更加深入的分析。

过期机制

本地缓存的过期机制是非常重要的,因为本地缓存中的数据并不像业务数据那样需要保证不丢失。本地缓存的数据一般都会要求保证命中率的前提下,尽可能的占用更少的内存,并可在极端情况下,可以被GC掉。

Caffeine的过期机制都是在构造Cache的时候申明,主要有如下几种:

  1. expireAfterWrite:表示自从最后一次写入后多久就会过期;

  2. expireAfterAccess:表示自从最后一次访问(写入或者读取)后多久就会过期;

  3. expireAfter:自定义过期策略;

刷新机制

在构造Cache时通过refreshAfterWrite方法指定刷新周期,例如refreshAfterWrite(10, TimeUnit.SECONDS)表示10秒钟刷新一次:

  1.  
    .build(new CacheLoader<String, String>() {
  2.  
    @Override
  3.  
    public String load(String k) {
  4.  
    // 这里我们就可以从数据库或者其他地方查询最新的数据
  5.  
    return getValue(k);
  6.  
    }
  7.  
    });

需要注意的是,Caffeine的刷新机制是「被动」的。举个例子,假如我们申明了10秒刷新一次。我们在时间T访问并获取到值v1,在T+5秒的时候,数据库中这个值已经更新为v2。但是在T+12秒,即已经过了10秒我们通过Caffeine从本地缓存中获取到的「还是v1」,并不是v2。在这个获取过程中,Caffeine发现时间已经过了10秒,然后会将v2加载到本地缓存中,下一次获取时才能拿到v2。即它的实现原理是在get方法中,调用afterRead的时候,调用refreshIfNeeded方法判断是否需要刷新数据。这就意味着,如果不读取本地缓存中的数据的话,无论刷新时间间隔是多少,本地缓存中的数据永远是旧的数据!

剔除机制

在构造Cache时可以通过removalListener方法申明剔除监听器,从而可以跟踪本地缓存中被剔除的数据历史信息。根据RemovalCause.java枚举值可知,剔除策略有如下5种:

  • 「EXPLICIT」:调用方法(例如:cache.invalidate(key)、cache.invalidateAll)显示剔除数据;

  • 「REPLACED」:不是真正被剔除,而是用户调用一些方法(例如:put(),putAll()等)盖了之前的值;

  • 「COLLECTED」:表示缓存中的Key或者Value被垃圾回收掉了;

  • 「EXPIRED」: expireAfterWrite/expireAfterAccess约定时间内没有任何访问导致被剔除;

  • 「SIZE」:超过maximumSize限制的元素个数被剔除的原因;

GuavaCache和Caffeine差异

  1. 剔除算法方面,GuavaCache采用的是「LRU」算法,而Caffeine采用的是「Window TinyLFU」算法,这是两者之间最大,也是根本的区别。

  2. 立即失效方面,Guava会把立即失效 (例如:expireAfterAccess(0) and expireAfterWrite(0)) 转成设置最大Size为0。这就会导致剔除提醒的原因是SIZE而不是EXPIRED。Caffiene能正确识别这种剔除原因。

  3. 取代提醒方面,Guava只要数据被替换,不管什么原因,都会触发剔除监听器。而Caffiene在取代值和先前值的引用完全一样时不会触发监听器。

  4. 异步化方方面,Caffiene的很多工作都是交给线程池去做的(默认:ForkJoinPool.commonPool()),例如:剔除监听器,刷新机制,维护工作等。

内存占用对比

Caffeine可以根据使用情况延迟初始化,或者动态调整它内部数据结构。这样能减少对内存的占用。如下图所示,使用了gradle memoryOverhead对内存占用进行了压测。结果可能会受到JVM的指针压缩、对象Padding等影响:

LRU P.K. W-TinyLFU

缓存的驱逐策略是为了预测哪些数据在短期内最可能被再次用到,从而提升缓存的命中率。由于简洁的实现、高效的运行时表现以及在常规的使用场景下有不错的命中率,LRU(Least Recently Used)策略或许是最流行的驱逐策略,,它在保持算法简单的前提下,效果还不错。但LRU对未来的预测有明显的局限性,它会认为「最后到来的数据是最可能被再次访问」的,从而给予它最高的优先级。

现代缓存扩展了对历史数据的使用,结合就近程度(recency)和访问频次(frequency)来更好的预测数据。其中一种保留历史信息的方式是使用「popularity sketch」(一种压缩、概率性的数据结构)来从一大堆访问事件中定位频繁的访问者。可以参考「CountMin Sketch」算法,它由计数矩阵和多个哈希方法实现。发生一次读取时,矩阵中每行对应的计数器增加计数,估算频率时,取数据对应是所有行中计数的最小值。这个方法让我们从空间、效率、以及适配矩阵的长宽引起的哈希碰撞的错误率上做权衡:

Window TinyLFU(W-TinyLFU)算法将Sketch作为过滤器,当新来的数据比要驱逐的数据高频时,这个数据才会被缓存接纳(admission)。这个许可窗口给予每个数据项积累热度的机会,而「不是立即过滤掉」。这避免了持续的未命中,特别是在突然流量暴涨的的场景中,一些短暂的重复流量就不会被长期保留。为了刷新历史数据,一个时间衰减进程被周期性或增量的执行,给所有计数器减半:

对于长期保留的数据,W-TinyLFU使用了分段LRU(Segmented LRU,缩写SLRU)策略。起初,一个数据项存储被存储在试用段(probationary segment)中,在后续被访问到时,它会被提升到保护段(protected segment)中(保护段占总容量的80%)。保护段满后,有的数据会被淘汰回试用段,这也可能级联的触发试用段的淘汰。这套机制确保了访问间隔小的热数据被保存下来,而被重复访问少的冷数据则被回收:

如图中数据库和搜索场景的结果展示,通过考虑就近程度和频率能大大提升LRU的表现。一些高级的策略,像ARC,LIRS和W-TinyLFU都提供了接近最理想的命中率。想看更多的场景测试,请查看相应的论文,也可以在使用simulator来测试自己的场景:

Guava迁移

那么,如果我的项目之前用的是GuavaCache,如何以尽可能低的成本迁移到Caffeine上来呢?嘿嘿,Caffeine已经想到了这一点,它提供了一个适配器,让你用Guava的接口操作它的缓存。代码片段如下所示:

  1.  
    // Guava's LoadingCache interface
  2.  
    LoadingCache<Key, Graph> graphs = CaffeinatedGuava.build(
  3.  
    Caffeine.newBuilder().maximumSize(10_000),
  4.  
    new CacheLoader<Key, Graph>() { // Guava's CacheLoader
  5.  
    @Override public Graph load(Key key) throws Exception {
  6.  
    return createExpensiveGraph(key);
  7.  
    }
  8.  
    });

参考

https://github.com/ben-manes/caffeine/wiki/Design 

http://highscalability.com/blog/2016/1/25/design-of-a-modern-cache.html

posted @ 2021-05-31 15:17  姚春辉  阅读(784)  评论(0编辑  收藏  举报