Java:Caffeine 本地缓存

是什么

Caffeine 是 Java 的高性能本地缓存库。它本质上是JVM 内存中的 Key-Value 缓存,类似 Map<K, V>

Map<Long, User> map = new HashMap<>(); 有哪些不足

  • 不会自动过期,数据永远存在。
  • 没有容量控制,内存可能爆炸
  • 多线程问题,并发不安全
  • 热点数据和冷数据混在一起,没有淘汰机制
  • 无法自动加载。每次都要手写逻辑:if (map.get(id) == null)

最核心的三个类型:

类型 作用
Cache<K,V> 手动缓存
LoadingCache<K,V> 自动加载缓存
AsyncLoadingCache<K,V> 异步缓存

引入依赖:

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

第一个 Caffeine 示例

package com.example.java_learn.Caffeine;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

@SpringBootTest
public class caffeine1 {
    @Test
    public void testCaffeine() {
        Cache<String, String> cache =
                Caffeine.newBuilder()
                        .build();
        // put 放入缓存
        cache.put("name", "Tom");
        // get 从缓存中获取
        String value = cache.getIfPresent("name");
        // 打印缓存值
        System.out.println(value); // Tom
    }
}

Caffeine的创建

Caffeine 的 API 设计非常统一,核心流程永远是:

Caffeine.newBuilder()
    .配置各种策略()
    .build();
// 或者
.build(loader)

Cache手动缓存

常见API

/*
Caffeine.newBuilder() : 所有配置都从这里开始。它返回Caffeine<Object, Object>,然后链式调用。

build(): 真正创建缓存对象
*/
Cache<String, String> cache =Caffeine.newBuilder().build();


cache.put("name", "Tom"); // 写入缓存:key -> value
String value = cache.getIfPresent("name"); // 读取缓存。如果不存在返回 null
cache.invalidate("name"); // 删除缓存。
cache.invalidateAll();// 清空缓存。
long size = cache.estimatedSize(); // 估算缓存大小。

get(key, mappingFunction)

在 Caffeine 中 cache.get(...) 不是普通Map.get(),它实际上是 缓存读取 + 缓存加载 + 并发控制 三者合一。

方法 含义
getIfPresent(key) 只查缓存
get(key, loader) 不存在则自动加载

语法结构如下:

V value = cache.get(
    key,
    k -> loadData(k)
);
部分 含义
key 缓存 key
k -> xxx 数据加载函数mappingFunction
返回值 最终缓存值

Caffeine 内部逻辑:

先查缓存
   ↓
存在 -> 直接返回
不存在 -> 执行 lambda
         ↓
       返回数据
         ↓
     自动写入缓存
         ↓
       返回结果
@Test
public void testCaffeine5() throws InterruptedException {

    Cache<Long, String> cache =Caffeine.newBuilder().build();

    // 第一次
    String v1 = cache.get(
            1L,
            key -> {
                System.out.println("查询数据库");
                return "Tom";
            });

    System.out.println(v1);

    // 第二次
    String v2 = cache.get(
            1L,
            key -> {
                System.out.println("查询数据库");
                return "Tom";
            });

    System.out.println(v2);
}
/*
查询数据库
Tom 
Tom : 第二次没有查询数据库,直接从缓存中获取
*/

为什么必须推荐 get(key, loader)

因为它解决缓存击穿问题

突然1000 个请求同时进来,这个时候缓存过期

String value = cache.getIfPresent(key);

if (value == null) {
    value = queryDb(); // 查询数据库
    cache.put(key, value);
}

这个时候,1000 个线程同时查数据库,数据库直接被打爆。

cache.get(key, k -> queryDb());

但是Caffeine 内部保证同一个 key 只加载一次。内部是只有1个线程查询数据库,其他线程等待。

loader 不可以返回 null

cache.get(
    1L,
    k -> null
);

上面代码会抛异常。

因为 Caffeine 默认不允许缓存 null,否则无法区分是真没数据,还是缓存了 null

getAll()

getAll():批量获取。

Map<K, V> map = cache.getAll(keys, mappingFunction);

getAll()可以减少数据库的查询

@Test
public void testCaffeine6() throws InterruptedException {
    Cache<Long, String> cache =Caffeine.newBuilder().build();
    List<Long> ids =
    Arrays.asList(1L, 2L, 3L);

    Map<Long, String> result = cache.getAll(ids, keys -> {

        System.out.println("批量查询数据库");

        Map<Long, String> map =
                new HashMap<>();

        map.put(1L, "Tom");
        map.put(2L, "Jack");
        map.put(3L, "Lucy");

        return map;
    });
    System.out.println(result); // {1=Tom, 2=Jack, 3=Lucy}
}

配置

maximumSize

maximumSize():设置最多多少个元素,防止缓存无限增长,最终OOM

@Test
public void testCaffeine2() throws InterruptedException {
    Cache<Integer, String> cache =
    Caffeine.newBuilder()
            .maximumSize(3)
            .build();
    cache.put(1, "A");
    cache.put(2, "B");
    cache.put(3, "C");
    cache.put(4, "D");
    /*
      缓存淘汰的时机 :Caffeine的淘汰策略可能不是立即执行的。淘汰可能发生在后续操作中,或者有延迟。
      等1秒,让缓存淘汰的时机可以有时间执行
      在实际项目中不需要等待1秒,因为会有足够的时间让缓存淘汰的时机执行。
     */
    Thread.sleep(1000);
    // 打印缓存大小
    System.out.println(cache.estimatedSize()); // 3
    // 遍历打印所有缓存值
    cache.asMap().forEach((key, value) -> System.out.println(key + " -> " + value));
  /*
    2 -> B
    3 -> C
    4 -> D
  */
}

expireAfterWrite

expireAfterWrite()写入后过期。

.expireAfterWrite(10, TimeUnit.MINUTES):写入 10 分钟后失效

@Test
public void testCaffeine3() throws InterruptedException {
    // 5秒后缓存过期
    Cache<String, String> cache =
    Caffeine.newBuilder()
            .expireAfterWrite(
                    5,
                    TimeUnit.SECONDS
            )
            .build();

    cache.put("name", "Tom");

    Thread.sleep(6000);

    System.out.println(
            cache.getIfPresent("name")
    ); // null
}

expireAfterAccess

expireAfterAccess():访问后过期。

.expireAfterAccess(5, TimeUnit.MINUTES) :5 分钟没人访问 -> 过期

@Test
public void testCaffeine4() throws InterruptedException {
    // 5秒后缓存过期
    Cache<String, String> cache =
    Caffeine.newBuilder()
            .expireAfterAccess(
                    3,
                    TimeUnit.SECONDS
            )
            .build();

    cache.put("name", "Tom");
    Thread.sleep(2000);
    System.out.println(cache.getIfPresent("name")); // Tom
    Thread.sleep(2000);
    // 4秒后,缓存还没有过去,因为前2秒内有访问
    System.out.println(cache.getIfPresent("name")); // Tom
    Thread.sleep(4000);
    // 再过去4秒后,缓存过期
    System.out.println(cache.getIfPresent("name")); // null
}
策略 含义
expireAfterWrite 写入后固定时间失效
expireAfterAccess 最后一次访问后失效

refreshAfterWrite

refreshAfterWrite():后台刷新。

  • expire:过期后数据不存在,用户可能等待加载。
  • refresh:缓存写入一段时间后,自动刷新。是“刷新”,不是“立即过期”。
    • refresh 的核心特点:不会阻塞当前请求
    • 先返回旧值,后台异步刷新

.refreshAfterWrite(5, TimeUnit.SECONDS):5 秒后,下次访问时触发后台刷新。并且旧值仍然可以返回。

@Test
public void testCaffeine9() throws InterruptedException {
    Cache<Integer, String> cache =
    Caffeine.newBuilder()
            .maximumSize(1)
            .removalListener(
                (key, value, cause) -> {
                    System.out.println(
                        key + " -> " + value + " 被删除: " + cause
                    );
                }
            )
            .build();

    cache.put(1, "A");

    cache.put(2, "B"); // 1 -> A 被删除: SIZE

}
@Test
public void testCaffeine10() throws InterruptedException {
      LoadingCache<Long, String> cache =
            Caffeine.newBuilder()

                    // 5秒后允许刷新
                    .refreshAfterWrite(
                            5,
                            TimeUnit.SECONDS
                    )

                    .build(id ->{
                             System.out.println("查询数据库");
                             Thread.sleep(500); // 模拟数据库查询时间
                             return "Tom-" + System.currentTimeMillis();
                    });

    // 第一次
    System.out.println(cache.get(1L));

    Thread.sleep(2000);

    // 第二次
    System.out.println(cache.get(1L));

    Thread.sleep(5000); // 5秒后,缓存过期

    // 第三次 :线程不会阻塞,直接从缓存中获取数据(旧值),同时异步更新缓存数据
    System.out.println(cache.get(1L)); 

    Thread.sleep(1000);

    // 第四次
    System.out.println(cache.get(1L));
}

完整时间线如下:

t=0
第一次访问
→ 查数据库
→ 缓存 Tom-1

t=2
第二次访问
→ 直接返回 Tom-1

t=3
第三次访问
→ 返回旧值 Tom-1
→ 后台刷新

t=8
第四次访问
→ 返回新值 Tom-2

recordStats

recordStats():开启统计。

Cache<String, String> cache =
        Caffeine.newBuilder()
                .recordStats()
                .build();

查看统计

System.out.println(cache.stats());

会有类似输出:

CacheStats{
 hitCount=10,
 missCount=2
}

命中率 :$\text{Hit Rate}=\frac{hitCount}{hitCount+missCount}$

例如95% 意味着 95% 请求不访问数据库

@Test
public void testCaffeine11() throws InterruptedException {
            LoadingCache<Long, String> cache =
            Caffeine.newBuilder()

                    // 最大缓存数量
                    .maximumSize(100)

                    // 开启统计
                    .recordStats()

                    // 10分钟过期
                    .expireAfterWrite(
                            10,
                            TimeUnit.MINUTES
                    )

                    // loader
                    .build(id -> {
                            System.out.println("查询数据库: " + id);
                            return "User-" + id;
                    });

    // 第一次访问: 查询数据库,缓存中没有,所以查询数据库,缓存中没有,所以查询数据库
    System.out.println(cache.get(1L));

    // 第二次访问(命中缓存)
    System.out.println(cache.get(1L));

    // 第三次访问(新key):查询数据库
    System.out.println(cache.get(2L));

    // 第四次访问(命中缓存)
    System.out.println(cache.get(2L));

    // 输出统计信息
    System.out.println(cache.stats());
    /*
    CacheStats{hitCount=2, 
            missCount=2, 
            loadSuccessCount=2,
            loadFailureCount=0,
            totalLoadTime=327865, 
            evictionCount=0,
            evictionWeight=0}
    */
}
  • hitCount:缓存命中次数,即没查数据库的次数
  • missCount:缓存未命中次数,即需要加载数据的次数
  • loadSuccessCount:成功加载次数,即loader 成功执行的次数
  • loadFailureCount:加载失败次数,例如 throw new RuntimeException();
  • totalLoadTime:缓存淘汰次数,例如.maximumSize(100) 超限后,导致淘汰缓存的次数
  • evictionWeight:因缓存淘汰(eviction)而被移除的数据“总权重”

removalListener

removalListener():监听被删除的缓存。

语法:

.removalListener(
    (key, value, cause) -> {}
)

被删除的原因RemovalCause 有:

原因 含义
SIZE 超大小淘汰
EXPIRED 过期
EXPLICIT 手动删除
REPLACED 被替换
@Test
public void testCaffeine9() throws InterruptedException {
    Cache<Integer, String> cache =
    Caffeine.newBuilder()
            .maximumSize(1)
            .removalListener(
                (key, value, cause) -> {
                    System.out.println(
                        key + " -> " + value + " 被删除: " + cause
                    );
                }
            )
            .build();

    cache.put(1, "A"); // 1 -> A 被删除: SIZE (由于超过 maximumSize 被删除)

    cache.put(2, "B"); // 触发 removalListener

}

LoadingCache

LoadingCache:自动加载缓存。

LoadingCache<Long, User> cache =Caffeine.newBuilder().build(id -> loadUser(id));

cache.get(key, loader):每次 get 时写 loader。

LoadingCache:loader 在 build 时固定。之后cache.get(key)即可。

@Test
public void testCaffeine7() throws InterruptedException {
    LoadingCache<Long, String> cache =Caffeine.newBuilder()
            .maximumSize(100)
            .build(id -> {
                System.out.println("查询数据库");
                return "User-" + id;
            });
    // User-1: 第一次查询数据库,缓存中没有,所以查询数据库
    System.out.println(cache.get(1L)); 
    // User-1: 第二次查询数据库,缓存中有,所以直接从缓存中获取
    System.out.println(cache.get(1L)); 
}

AsyncLoadingCache

AsyncLoadingCache 异步缓存。

为什么需要异步缓存:因为数据库 / RPC / Redis 查询是 IO,同步等待会阻塞线程

@Test
public void testCaffeine12() throws Exception {
    AsyncLoadingCache<Long, String> cache =Caffeine.newBuilder()
    .maximumSize(100)
    // 创建异步加载器,用于查询数据库
    // 要求 return CompletableFuture<String>
    .buildAsync(
    // id : 缓存key
    // executor : Caffeine 提供的线程池,用于执行异步任务
    (id, executor) ->
            // CompletableFuture :是Java8中引入的异步任务框架,用于处理异步任务的结果和异常
            CompletableFuture
                    // 异步执行工具,用于执行异步任务
                    /*
                    CompletableFuture.supplyAsync(
                            supplier,
                            executor
                    ) 
                    含义:在线程池里异步执行 supplier 方法,返回 CompletableFuture 对象
                    */
                    .supplyAsync(() -> {

                            return queryUser(id);

                    }, executor)
    );

    System.out.println("开始查询");
    // “发起异步查询” : 这里只是发起,不是拿到结果
    // 返回 CompletableFuture<String>,表示“未来会完成的结果”
    // 这里的流程:查缓存 --> 未命中 --> 提交异步任务, 后台线程池里执行异步任务 --> 立刻返回 Future ,立刻返回 Future 
    CompletableFuture<String> future = cache.get(1L);

    System.out.println("主线程没阻塞");
    // “等待异步结果”,直到异步查询完成,才会返回结果
    // 可以理解为:“把 Future 里的结果拿出来”
    // 也就是等到要使用结果时,才会返回结果
    // 如果这个时候异步查询还没有完成,就会阻塞主线程,等待异步查询完成,才会返回结果
    String user = future.get();

    System.out.println(user); // User-1

}

最常用的实际配置

LoadingCache<Long, User> cache =
        Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(
                        10,
                        TimeUnit.MINUTES
                )
                .recordStats()
                .build(this::queryUser);
配置 作用
maximumSize 防 OOM
expireAfterWrite TTL
recordStats 监控
build(loader) 自动加载

×

喜欢就点赞,疼爱就打赏