本地缓存Caffeine


0、JVM 进程缓存-Caffeine

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

  • 分布式缓存,例如Redis:
    • 优点:存储容量更大、可靠性更好、可以在集群间共享
    • 缺点:访问缓存有网络开销
    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
  • 进程本地缓存,例如HashMap、GuavaCache:
    • 优点:读取本地内存,没有网络开销,速度更快
    • 缺点:存储容量有限、可靠性较低、无法共享
    • 场景:性能要求较高,缓存数据量较小

我们今天会利用Caffeine框架来实现JVM进程缓存。

Caffeine(咖啡因)是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。GitHub地址:https://github.com/ben-manes/caffeine/wiki/Home-zh-CN

Caffeine的性能非常好,根据官方给出的性能对比,相比较于以下缓存方式Caffeine的性能遥遥领先!

  • ConcurrentLinkedHashMap
  • Guava

使用之前先了解一下一些概念

  • 驱逐 缓存元素因为策略被移除
  • 失效 缓存元素被手动移除
  • 移除 由于驱逐或者失效而最终导致的结果

依赖

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

以下 API 介绍摘自Caffeine官网

1、添加缓存

Caffeine提供了四种缓存添加策略:手动加载,自动加载,手动异步加载和自动异步加载。

(1)手动加载

Cache<Key, Graph> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)        // 设置超时时间
    .maximumSize(10_000)        // 最大容量
    .build();

// 查找一个缓存元素, 没有查找到的时候返回null
Graph graph = cache.getIfPresent(key);
// 查找缓存,如果缓存不存在则生成缓存元素(查询数据库),  如果无法生成则返回null
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.invalidate(key);

Cache 接口提供了显式搜索查找、更新和移除缓存元素的能力。

缓存元素可以通过调用 cache.put(key, value)方法被加入到缓存当中。如果缓存中指定的key已经存在对应的缓存元素的话,那么先前的缓存的元素将会被直接覆盖掉。因此,通过 cache.get(key, k -> value) 的方式将要缓存的元素通过原子计算的方式 插入到缓存中,以避免和其他写入进行竞争。值得注意的是,当缓存的元素无法生成或者在生成的过程中抛出异常而导致生成元素失败,cache.get 也许会返回 null
当然,也可以使用Cache.asMap()所暴露出来的ConcurrentMap的方法对缓存进行操作。

(2)自动加载

LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));

// 查找缓存,如果缓存不存在则生成缓存元素,  如果无法生成则返回null
Graph graph = cache.get(key);
// 批量查找缓存,如果缓存不存在则生成缓存元素
Map<Key, Graph> graphs = cache.getAll(keys);

一个LoadingCache是一个Cache 附加上 CacheLoader能力之后的缓存实现。

通过 getAll可以达到批量查找缓存的目的。 默认情况下,在getAll 方法中,将会对每个不存在对应缓存的key调用一次 CacheLoader.load 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖并开发CacheLoader.loadAll 方法来使你的缓存更有效率。

值得注意的是,你可以通过实现一个 CacheLoader.loadAll并在其中为没有在参数中请求的key也生成对应的缓存元素。打个比方,如果对应某个key生成的缓存元素与包含这个key的一组集合剩余的key所对应的元素一致,那么在loadAll中也可以同时加载剩下的key对应的元素到缓存当中。

(3)手动异步加载

AsyncCache<Key, Graph> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(10_000)
    .buildAsync();

// 查找一个缓存元素, 没有查找到的时候返回null
CompletableFuture<Graph> graph = cache.getIfPresent(key);
// 查找缓存元素,如果不存在,则异步生成
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.synchronous().invalidate(key);

一个AsyncCacheCache 的一个变体,AsyncCache提供了在 Executor上生成缓存元素并返回 CompletableFuture的能力。这给出了在当前流行的响应式编程模型中利用缓存的能力。

synchronous()方法给 Cache提供了阻塞直到异步缓存生成完毕的能力。

当然,也可以使用 AsyncCache.asMap()所暴露出来的ConcurrentMap的方法对缓存进行操作。

默认的线程池实现是 ForkJoinPool.commonPool() ,当然你也可以通过覆盖并实现 Caffeine.executor(Executor)方法来自定义你的线程池选择。

(4)自动异步加载

AsyncLoadingCache<Key, Graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    // 你可以选择: 去异步的封装一段同步操作来生成缓存元素
    .buildAsync(key -> createExpensiveGraph(key));
    // 你也可以选择: 构建一个异步缓存元素操作并返回一个future
    .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));

// 查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<Graph> graph = cache.get(key);
// 批量查找缓存元素,如果其不存在,将会异步进行生成
CompletableFuture<Map<Key, Graph>> graphs = cache.getAll(keys);

一个 AsyncLoadingCache是一个 AsyncCache 加上 AsyncCacheLoader能力的实现。

在需要同步的方式去生成缓存元素的时候,CacheLoader是合适的选择。而在异步生成缓存的场景下, AsyncCacheLoader则是更合适的选择并且它会返回一个 CompletableFuture

通过 getAll可以达到批量查找缓存的目的。 默认情况下,在getAll 方法中,将会对每个不存在对应缓存的key调用一次 AsyncCacheLoader.asyncLoad 来生成缓存元素。 在批量检索比单个查找更有效率的场景下,你可以覆盖并开发AsyncCacheLoader.asyncLoadAll 方法来使你的缓存更有效率。

值得注意的是,你可以通过实现一个 AsyncCacheLoader.asyncLoadAll并在其中为没有在参数中请求的key也生成对应的缓存元素。打个比方,如果对应某个key生成的缓存元素与包含这个key的一组集合剩余的key所对应的元素一致,那么在asyncLoadAll中也可以同时加载剩下的key对应的元素到缓存当中。

2、驱逐策略

Caffeine 提供了三种驱逐策略,分别是基于容量,基于时间和基于引用三种类型。

  • 基于容量:设置缓存的上限
  • 基于时间:设置缓存的有效期
  • 基于引用:设置缓存为软引用或弱引用,利用 GC 来回收缓存数据,性能较差,不建议使用

在默认情况下,当一个缓存元素过期是,Caffenine 不会自动立即将其清理和驱逐,而是在一个读或写操作后(基于时间),或者在空闲的时间完成对失效数据的驱逐(基于容量)

(1)基于容量

// 基于缓存内的元素个数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));

// 基于缓存内元素权重进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));

如果你的缓存容量不希望超过某个特定的大小,那么记得使用Caffeine.maximumSize(long)。缓存将会尝试通过基于就近度和频率的算法来驱逐掉不会再被使用到的元素。

另一种情况,你的缓存可能中的元素可能存在不同的“权重”–打个比方,你的缓存中的元素可能有不同的内存占用–你也许需要借助Caffeine.weigher(Weigher) 方法来界定每个元素的权重并通过 Caffeine.maximumWeight(long)方法来界定缓存中元素的总权重来实现上述的场景。除了“最大容量”所需要的注意事项,在基于权重驱逐的策略下,一个缓存元素的权重计算是在其创建和更新时,此后其权重值都是静态存在的,在两个元素之间进行权重的比较的时候,并不会根据进行相对权重的比较。

缓存清除方式

在默认情况下,当一个缓存元素过期是,Caffenine 不会自动立即将其清理和驱逐,而是在空闲的时间完成对失效数据的驱逐(基于容量)

@Test
public void test1() throws InterruptedException {
    Cache<Object, Object> cache = Caffeine.newBuilder()
        .maximumSize(1)     // 设置缓存大小上限为1,不会立即清除,会在空闲时或下次操作时清除
        .build();

    cache.put("c1",1);
    cache.put("c2",2);
    cache.put("c3",3);

    Thread.sleep(10L);

    System.out.println(cache.getIfPresent("c1"));
    System.out.println(cache.getIfPresent("c2"));
    System.out.println(cache.getIfPresent("c3"));
}

如上代码

  • 当不加 Thread.sleep(10L); 时,jvm 没有空闲时间,就不会清理缓存,所以输出结果123
  • 当加上 Thread.sleep(10L); 时,jvm 会在休眠的这个空闲时间清理缓存,所以输出结果为 null、null、3

(2)基于时间

// 基于固定的过期时间驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));

// 基于不同的过期驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfter(new Expiry<Key, Graph>() {
      public long expireAfterCreate(Key key, Graph graph, long currentTime) {
        // Use wall clock time, rather than nanotime, if from an external resource
        long seconds = graph.creationDate().plusHours(5)
            .minus(System.currentTimeMillis(), MILLIS)
            .toEpochSecond();
        return TimeUnit.SECONDS.toNanos(seconds);
      }
      public long expireAfterUpdate(Key key, Graph graph, 
          long currentTime, long currentDuration) {
        return currentDuration;
      }
      public long expireAfterRead(Key key, Graph graph,
          long currentTime, long currentDuration) {
        return currentDuration;
      }
    })
    .build(key -> createExpensiveGraph(key));

Caffeine提供了三种方法进行基于时间的驱逐:

  • expireAfterAccess(long, TimeUnit): 一个元素在上一次读写操作后一段时间之后,在指定的时间后没有被再次访问将会被认定为过期项。在当被缓存的元素时被绑定在一个session上时,当session因为不活跃而使元素过期的情况下,这是理想的选择。
  • expireAfterWrite(long, TimeUnit): 一个元素将会在其创建或者最近一次被更新之后的一段时间后被认定为过期项。在对被缓存的元素的时效性存在要求的场景下,这是理想的选择。
  • expireAfter(Expiry): 一个元素将会在指定的时间后被认定为过期项。当被缓存的元素过期时间收到外部资源影响的时候,这是理想的选择。

在写操作,和偶尔的读操作中将会进行周期性的过期事件的执行。过期事件的调度和触发将会在O(1)的时间复杂度内完成。

为了使过期更有效率,可以通过在你的Cache构造器中通过Scheduler接口和Caffeine.scheduler(Scheduler) 方法去指定一个调度线程代替在缓存活动中去对过期事件进行调度。使用Java 9以上版本的用户可以选择Scheduler.systemScheduler()利用系统范围内的调度线程。

当测试基于时间的驱逐策略的时候,不需要坐在板凳上等待现实时钟的转动。使用Ticker接口和 Caffeine.ticker(Ticker)方法在你的Cache构造器中去指定一个时间源可以避免苦苦等待时钟转动的麻烦。Guava的测试库也提供了FakeTicker去达到同样的目的。

(3)基于引用

// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

Caffeine 允许你配置你的缓存去让GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。记住 AsyncCache不支持软引用和弱引用。

Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。

Caffeine.weakValues()在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

Caffeine.softValues()在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

3、移除

术语:

  • 驱逐 缓存元素因为策略被移除
  • 失效 缓存元素被手动移除
  • 移除 由于驱逐或者失效而最终导致的结果

(1)显式移除

在任何时候,你都可以手动去让某个缓存元素失效而不是只能等待其因为策略而被驱逐。

// 失效key
cache.invalidate(key)
// 批量失效key
cache.invalidateAll(keys)
// 失效所有的key
cache.invalidateAll()

(2)移除监听器

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .evictionListener((Key key, Graph graph, RemovalCause cause) ->
        System.out.printf("Key %s was evicted (%s)%n", key, cause))
    .removalListener((Key key, Graph graph, RemovalCause cause) ->
        System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();

你可以为你的缓存通过Caffeine.removalListener(RemovalListener)方法定义一个移除监听器在一个元素被移除的时候进行相应的操作。这些操作是使用 Executor 异步执行的,其中默认的 Executor 实现是 ForkJoinPool.commonPool() 并且可以通过覆盖Caffeine.executor(Executor)方法自定义线程池的实现。

当移除之后的自定义操作必须要同步执行的时候,你需要使用 Caffeine.evictionListener(RemovalListener) 。这个监听器将在 RemovalCause.wasEvicted() 为 true 的时候被触发。为了移除操作能够明确生效, Cache.asMap() 提供了方法来执行原子操作。

记住任何在 RemovalListener中被抛出的异常将会被打印日志 (通过Logger)并被吞食。

4、项目中使用

通过 bean 进行访问

/**
 * Caffeine 缓存配置类
 */
@Configuration
public class CaffeineConfig {

    /**
     * 用户id,用户名缓存
     */
    @Bean
    public Cache<Long, String> userCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }

    /**
     * 订单id,订单缓存
     * @return
     */
    @Bean
    public Cache<Long, Order> orderCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

在项目中和其它本地缓存一样使用方式,用spring自带的缓存注解,简洁易懂。

/**
* Cacheable
* value:缓存key的前缀。
* key:缓存key的后缀。
* sync:设置如果缓存过期是不是只放一个请求去请求数据库,其他请求阻塞,默认是false(根据个人需求)。
* unless:不缓存空值,这里不使用,会报错
*/
@Cacheable(value = CacheConstants.GET_MENU, key = "#code", sync = true)
public MenuVO getMenu(String code) {
    //业务代码
}

@Cacheable(value = CacheConstants.GET_MENU_LIST, key = "#page+':'+#pageSize", sync = true)
public PaginationResult<MenuVO> getMenuList(Integer page, Integer pageSize) {
    //业务代码
}

  目录