原创

高性能缓存 Caffeine 原理及实战

温馨提示:
本文最后更新于 2023年12月06日,已超过 413 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

引言

上一篇我们写了一个非常简单的监控告警系统,对我们线上的数据库、服务及中间件进行定时探测及异常告警,但我们发现,偶尔会因为一些其它情况(如网络抖动)导致误报警出现,所以我们需要设置一个异常阈值,在一定时间内达到设定阈值则说明应用确实出现了异常,此时我们才发送告警信息。

技术选型

对于上述需求,我们首先想到的就是缓存策略,日常开发过程中我们常用的缓存就是Redis。但这是一个独立的监控系统,其目的就是为了监控包括Redis在内的其他应用的健康状态,显然此时不能选择Redis作为我们的缓存,而应该选择Java内存缓存来实现上述需求;

对于Java内存缓存,常见的有Guava CacheCaffeineEncache等。当然,我们也可以使用HashMap来自己实现本地缓存,此时我们的场景非常简单,也是可以的;其中Caffeine的表现最为出色,此点从Spring官方都已经弃用Guava Cache选择Caffeine就很有说服力。

参数说明

Caffeine提供了多种灵活的构造方法,可根据自己的具体需求来灵活配置,其参数如下:

  • initialCapacity 设置内部数据结构的最小总大小
  • maximumSize 指定缓存可以包含的最大条目数,此功能 不能 与maximumWeight一起使用
  • expireAfterWrite 设置最后一次写入后多久过期
  • expireAfterAccess 设置在最后一次 读或写 后多久过期
  • expireAfter 自定义淘汰策略,该策略下 Caffeine 通过时间轮算法来实现不同key 的不同过期时间
  • evictionListener 设置缓存淘汰监听器
  • executor 设置自定义线程池,默认的线程池实现是 ForkJoinPool.commonPool()
  • removalListener 设置缓存删除监听器
  • scheduler 设置一个调度器,用于定时执行缓存操作。默认情况下,Caffeine 使用 ScheduledExecutorService 作为调度器
  • softValues 设置为 true 时,缓存项的值会被包装成软引用,在内存溢出前可以直接淘汰
  • ticker 设置一个 Ticker,用于定期检查缓存的状态。可以用于监控缓存的性能
  • weakKeys 设置为 true 时,缓存项的键会被包装成弱引用,在 GC 时可以直接淘汰
  • weakValues 设置为 true 时,缓存项的值会被包装成弱引用,在 GC 时可以直接淘汰
  • weigher 用于计算缓存项的权重。权重越高,缓存项越容易被选中
  • maximumWeight 设置缓存项的最大权重。当缓存项的权重超过这个值时,缓存项不会被选中
  • recordStats 设置为 true 时,Caffeine 会记录缓存的统计信息,例如命中率、加载率等
  • refreshAfterWrite 设置写入后间隔多久刷新,该刷新是基于访问被动触发的,支持异步刷新和同步刷新,如果和 expireAfterWrite 组合使用,能够保证即使该缓存访问不到、也能在固定时间间隔后被淘汰,否则如果单独使用容易造成OOM

使用

引入maven依赖

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

使用缓存

Cache

Cache 是 Caffeine 中的一个核心接口,它代表了一个本地的、有限的、并发的数据结构,用于存储键值对。当调用 get(key) 方法尝试获取一个缓存项时,需要带入一个参数为 key 的 Function, 如果这个缓存项不存在或已经被过期,Cache 会创建一个值并插入到缓存中然后返回该值。在计算进行期间,其他线程在此缓存上尝试的一些更新操作可能会被阻止,因此计算应简短而简单,并且不得尝试更新此缓存的任何其他映射

示例

// 初始化一个缓存,并设置过期时间及内存大小。
public static void main(String[] args) {
    Cache<String, Integer> cache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(1))
            .maximumSize(10)
            .build();

    String key = "test";
    // 使用 `getIfPresent` 方法获取缓存值,如果不存在则返回 null
    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.get(key, s -> 1));

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    cache.put(key, 2);

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    // 手动移除
    cache.invalidate(key);

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));
}

日志

获取键为 test 的缓存值: null
获取键为 test 的缓存值: 1
获取键为 test 的缓存值: 1
获取键为 test 的缓存值: 2
获取键为 test 的缓存值: null

LoadingCache

LoadingCache 是一个接口,它定义了如何将数据加载到缓存中的方式。当调用 get(key) 方法尝试获取一个缓存项时,如果这个缓存项还没有被加载到内存中,LoadingCache 会使用指定的加载器(Loader)来异步地加载这个缓存项,并在加载完成后将其添加到缓存中。这种方式的好处是,它允许你为不同的键提供不同的加载器,从而实现更复杂的缓存逻辑。例如,你可以根据键的前缀来决定从哪个后端存储系统中加载数据。

示例

private static Integer queryAgeByName(String name){
    return Math.toIntExact(Math.round(Math.random() * 100));
}
public static void main(String[] args) {
    LoadingCache<String, Integer> cache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(1))
            .maximumSize(10)
            .build(new CacheLoader<String, Integer>() {
                @Override
                public @Nullable Integer load(@NonNull String key) {
                    return queryAgeByName(key);
                }
            });

    String key = "test";

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.get(key));

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    cache.put(key, 2);

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    List<String> keys = Arrays.asList("xiaoming", "zhangsan");

    LOGGER.info("获取键为 {} 的缓存值: {}", keys, cache.getAll(keys));
}

日志

获取键为 test 的缓存值: null
获取键为 test 的缓存值: 12
获取键为 test 的缓存值: 12
获取键为 test 的缓存值: 2
获取键为 [xiaoming, zhangsan] 的缓存值: {xiaoming=37, zhangsan=82}

AsyncCache

这是 Cache 的一个变体,AsyncCache 提供了在 Executor 上异步生成缓存元素并返回 CompletableFuture 的能力。这意味着当你需要一个缓存项时,如果这个缓存项还没有被加载到内存中,AsyncCache 会在后台线程中异步地加载这个缓存项,并在加载完成后立即返回。这种机制特别适合于读取密集型的应用,因为它可以减少等待时间,提高系统的响应速度。此外,synchronous() 方法可以阻塞当前线程,直到异步缓存被完全生成。

示例

private static Integer queryAgeByName(String name){
    return Math.toIntExact(Math.round(Math.random() * 100));
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
    AsyncCache<String, Integer> cache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(1))
            .maximumSize(10)
            .buildAsync();

    String key = "test";

    LOGGER.info("获取键为 {} 的缓存值: {}", key, cache.getIfPresent(key));

    CompletableFuture<Integer> value = cache.get(key, MonitorApplicationTests::queryAgeByName);
    cache.put(key, value);

    LOGGER.info("键为 {} 的插入值为 {} 获取的缓存值 : {}", key, value.get(), Objects.requireNonNull(cache.getIfPresent(key)).get());
}

日志

获取键为 test 的缓存值: null
键为 test 的插入值为 4 获取的缓存值 : 4

正文到此结束
本文目录