本文基于 Caffeine
2.8.5讨论。Maven 依赖:
xml<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>2.8.5</version> </dependency>
一、先给结论
Caffeine 里最容易混淆的三个时间配置是:
java
expireAfterWrite
expireAfterAccess
refreshAfterWrite
它们都和时间有关,但语义不是一回事。
text
expireAfterWrite = 写入后多久过期,读不会续命
expireAfterAccess = 访问后多久过期,读写都会续命
refreshAfterWrite = 写入后多久允许刷新,不等于过期
如果再加上 AsyncLoadingCache,可以这样理解:
text
LoadingCache = 缓存 miss 时当前线程同步加载
AsyncLoadingCache = 缓存 miss 时立即返回 CompletableFuture,异步加载
一个非常重要的区别是:
text
expire 会让旧值不可见
refresh 会让旧值继续可用,同时后台刷新
也就是说,expireAfterWrite 和 expireAfterAccess 是"过期策略",而 refreshAfterWrite 是"刷新策略"。
二、几个基础概念
1. 过期不等于立刻物理删除
Caffeine 的 entry 到期后,会对读操作不可见。但它不一定在到期那一刻立刻从内部结构中物理移除。
所以不要只用:
java
cache.estimatedSize();
来判断一个 key 是否已经过期。更准确的是看:
java
cache.getIfPresent(key);
或者 LoadingCache.get(key) 是否触发重新加载。
2. LoadingCache 的第一次加载是同步的
例如:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(this::getFromRedis);
String value = cache.get("A");
如果本地没有 A,当前线程会同步调用 getFromRedis("A"),并等待它返回。
3. refreshAfterWrite 的刷新是异步的
例如:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.refreshAfterWrite(30, TimeUnit.MINUTES)
.build(this::getFromRedis);
当 A 已经有值,并且超过 30 分钟后再次访问:
text
先返回旧值
再异步调用 loader 刷新
刷新成功后,新值替换旧值
注意:refreshAfterWrite 不是定时任务。不是 30 分钟一到就自动刷新,而是 30 分钟后"具备刷新资格",下一次访问才触发刷新。
三、本文的调试工具
为了不用真的等待 30 分钟,可以用 Caffeine 的 Ticker 手动推进时间。
下面这个工具类可以在后面的示例里复用:
java
import com.github.benmanes.caffeine.cache.Ticker;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public final class ManualTicker implements Ticker {
private final AtomicLong nanos = new AtomicLong();
@Override
public long read() {
return nanos.get();
}
public void advance(long time, TimeUnit unit) {
nanos.addAndGet(unit.toNanos(time));
}
}
再准备一个模拟 Redis 的 loader:
java
import java.util.concurrent.atomic.AtomicInteger;
public final class LoaderSupport {
private final AtomicInteger count = new AtomicInteger();
public String load(String key) {
int version = count.incrementAndGet();
System.out.println(Thread.currentThread().getName()
+ " load " + key + " -> v" + version);
return "v" + version;
}
public String slowLoad(String key) {
sleep(1500);
return load(key);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
四、只有 expireAfterWrite
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(loader::load);
语义:
text
从写入成功开始计时。
超过 5 分钟后过期。
读操作不会续命。
验证代码:
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // 第一次 miss,同步加载 v1
ticker.advance(4, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 未过期,还是 v1
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 距离写入已 6 分钟,过期,同步加载 v2
预期输出类似:
text
main load A -> v1
v1
v1
main load A -> v2
v2
结论:
text
expireAfterWrite 是固定 TTL。
读不会续命。
到期后旧值不可见。
LoadingCache.get 会同步重新加载。
适合场景:
text
商品价格缓存 5 分钟
业务配置缓存 1 分钟
权限规则缓存 10 分钟
只要你想表达"这个值最多只能旧这么久",就适合用 expireAfterWrite。
五、只有 expireAfterAccess
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(loader::load);
语义:
text
从最近一次读或写开始计时。
超过 5 分钟没有访问,才会过期。
每次读取都会续命。
验证代码:
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // v1
ticker.advance(4, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // v1,这次读取会续命
ticker.advance(4, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 仍然是 v1,因为距离上次访问才 4 分钟
ticker.advance(6, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 超过 5 分钟未访问,同步加载 v2
预期输出类似:
text
main load A -> v1
v1
v1
v1
main load A -> v2
v2
结论:
text
expireAfterAccess 是空闲 TTL。
只要一直被访问,就可以一直续命。
热点 key 可能长期存在。
适合场景:
text
用户会话
临时上下文
低频数据自动释放
如果你的诉求是"没人用就清掉",优先考虑 expireAfterAccess。
六、只有 refreshAfterWrite
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.build(loader::slowLoad);
语义:
text
从写入成功开始计时。
超过 5 分钟后,不是过期,而是具备刷新资格。
下一次访问时,先返回旧值,再异步刷新。
验证代码:
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r);
thread.setName("caffeine-refresh");
return thread;
});
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.build(loader::slowLoad);
long firstStart = System.currentTimeMillis();
System.out.println(cache.get("A"));
System.out.println("first cost = " + (System.currentTimeMillis() - firstStart) + " ms");
ticker.advance(6, TimeUnit.MINUTES);
long refreshStart = System.currentTimeMillis();
System.out.println(cache.get("A"));
System.out.println("refresh trigger cost = " + (System.currentTimeMillis() - refreshStart) + " ms");
Thread.sleep(2000);
System.out.println(cache.get("A"));
refreshExecutor.shutdown();
预期输出类似:
text
main load A -> v1
v1
first cost = 1505 ms
v1
refresh trigger cost = 2 ms
caffeine-refresh load A -> v2
v2
结论:
text
第一次 miss:同步加载。
已有旧值且超过 refresh 时间:立即返回旧值,后台异步刷新。
refreshAfterWrite 本身不会让值因为时间到了而消失。
只有 refreshAfterWrite 时,一个 key 第一次成功加载后,通常会长期存在,除非发生下面这些情况:
text
达到 maximumSize 后被淘汰
手动 invalidate
JVM 重启
loader/reload 返回 null
弱引用/软引用导致回收
七、refreshAfterWrite 下 loader 返回 null 或抛异常会怎样
这是很多业务缓存容易忽略的点。
1. refresh 抛异常
刷新异常时,Caffeine 会保留旧值。
示例:
java
ManualTicker ticker = new ManualTicker();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "v1";
}
@Override
public String reload(String key, String oldValue) {
throw new RuntimeException("redis timeout");
}
});
System.out.println(cache.get("A")); // v1
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 先返回旧值 v1,并触发刷新
Thread.sleep(500);
System.out.println(cache.getIfPresent("A")); // 仍然是 v1
结论:
text
refresh 异常不会让旧值消失。
旧值会继续作为兜底值保留。
2. refresh 返回 null
如果 reload 返回 null,mapping 可能会被移除。
示例:
java
ManualTicker ticker = new ManualTicker();
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "v1";
}
@Override
public String reload(String key, String oldValue) {
return null;
}
});
System.out.println(cache.get("A")); // v1
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 先返回旧值 v1,后台刷新返回 null
Thread.sleep(500);
System.out.println(cache.getIfPresent("A")); // null
refreshExecutor.shutdown();
结论:
text
如果你希望 Redis 查不到时继续保留本地旧值,不要让 reload 返回 null。
可以在 reload 中返回 oldValue 作为兜底。
更稳妥的写法:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.refreshAfterWrite(30, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return getFromRedis(key);
}
@Override
public String reload(String key, String oldValue) {
String newValue = getFromRedis(key);
return newValue == null ? oldValue : newValue;
}
});
八、expireAfterWrite + expireAfterAccess
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(10, TimeUnit.MINUTES)
.expireAfterAccess(3, TimeUnit.MINUTES)
.build(loader::load);
语义:
text
两个过期条件同时生效。
谁先到期,谁让旧值不可见。
场景 1:因为 access 空闲时间过期
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(10, TimeUnit.MINUTES)
.expireAfterAccess(3, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // v1
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // v1,访问续命
ticker.advance(4, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 距离上次访问 4 分钟,因 access 过期,加载 v2
场景 2:即使一直访问,也会因为 write 时间过期
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(10, TimeUnit.MINUTES)
.expireAfterAccess(3, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // v1
for (int i = 0; i < 4; i++) {
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 每 2 分钟访问一次,不会触发 access 过期
}
ticker.advance(2, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 距离首次写入已 10 分钟,因 write 过期,加载 v2
结论:
text
expireAfterAccess 控制"闲置多久清理"。
expireAfterWrite 控制"最多存活多久"。
两个一起用,就是既限制闲置时间,也限制最大存活时间。
九、expireAfterWrite + refreshAfterWrite
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(loader::slowLoad);
语义:
text
2 分钟后,访问可以触发异步刷新。
10 分钟后,如果值还没有被成功替换,旧值过期不可见。
场景 1:在过期前访问,触发异步刷新
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r);
thread.setName("caffeine-refresh");
return thread;
});
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(loader::slowLoad);
System.out.println(cache.get("A")); // v1
ticker.advance(3, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 返回旧值 v1,同时异步刷新
Thread.sleep(2000);
System.out.println(cache.get("A")); // v2
refreshExecutor.shutdown();
预期行为:
text
3 分钟时,超过 refreshAfterWrite,但没有超过 expireAfterWrite。
所以旧值还能返回,刷新在后台执行。
场景 2:长时间没人访问,超过 expireAfterWrite
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // v1
ticker.advance(11, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 旧值已过期,同步加载 v2
结论:
text
refreshAfterWrite 必须被访问触发。
如果访问发生在 refresh 时间之后、expire 时间之前,走异步刷新。
如果访问发生在 expire 时间之后,旧值不可见,走同步加载。
更常见的配置是:
java
.refreshAfterWrite(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
含义是:
text
正常情况下用后台刷新降低延迟。
如果长时间没人访问,或者刷新长期失败,最多 30 分钟后旧值过期。
不建议这样配:
java
.refreshAfterWrite(30, TimeUnit.MINUTES)
.expireAfterWrite(5, TimeUnit.MINUTES)
因为 5 分钟就过期了,大概率等不到 30 分钟后的 refresh 发挥作用。
十、expireAfterAccess + refreshAfterWrite
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(loader::slowLoad);
语义:
text
活跃 key 会被定期异步刷新。
超过 5 分钟没人访问的 key 会过期清理。
场景 1:活跃访问触发 refresh
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r);
thread.setName("caffeine-refresh");
return thread;
});
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(loader::slowLoad);
System.out.println(cache.get("A")); // v1
ticker.advance(3, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 返回旧值 v1,触发异步刷新,同时 access 续命
Thread.sleep(2000);
System.out.println(cache.get("A")); // v2
refreshExecutor.shutdown();
场景 2:冷数据因为 access 过期
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(loader::load);
System.out.println(cache.get("A")); // v1
ticker.advance(6, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 已经 6 分钟没访问,旧值过期,同步加载 v2
结论:
text
有人用,就后台刷新,尽量保持新。
没人用,就过期清理,释放内存。
这个组合适合:
text
用户维度缓存
活动配置缓存
业务字典缓存
热点数据局部活跃的场景
十一、expireAfterWrite + expireAfterAccess + refreshAfterWrite
配置:
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(loader::slowLoad);
三条规则同时存在:
text
refreshAfterWrite:值旧了,但还能先用旧值,同时后台刷新。
expireAfterAccess:值没人用了,就过期清理。
expireAfterWrite:值不能无限旧,最多只能存活这么久。
验证代码:
java
ManualTicker ticker = new ManualTicker();
LoaderSupport loader = new LoaderSupport();
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r);
thread.setName("caffeine-refresh");
return thread;
});
LoadingCache<String, String> cache = Caffeine.newBuilder()
.ticker(ticker)
.executor(refreshExecutor)
.refreshAfterWrite(2, TimeUnit.MINUTES)
.expireAfterAccess(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(loader::slowLoad);
System.out.println(cache.get("A")); // v1
ticker.advance(3, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // v1,触发异步刷新,access 续命
Thread.sleep(2000);
System.out.println(cache.get("A")); // v2
ticker.advance(6, TimeUnit.MINUTES);
System.out.println(cache.get("A")); // 超过 access 空闲时间,加载 v3
refreshExecutor.shutdown();
结论:
text
如果 key 很热:
读请求会不断重置 expireAfterAccess。
超过 refreshAfterWrite 后,读请求触发异步刷新。
刷新成功后,写入时间会重置。
如果 key 很冷:
没人访问,不会触发 refresh。
超过 expireAfterAccess 后过期。
如果 refresh 长期失败:
refresh 异常会保留旧值。
但 expireAfterWrite 到期后,旧值最终不可见。
这个组合适合对稳定性要求比较高的本地缓存:
text
平时用 refresh 降低请求延迟。
冷数据用 access 过期释放内存。
极端情况下用 write 过期限制最大陈旧时间。
十二、完整组合表
| 组合 | 行为 |
|---|---|
| 无 W/A/R | 不按时间过期,只会被 size、手动删除、JVM 重启等影响 |
| 只有 W | 固定 TTL,写入后到点过期,读不续命 |
| 只有 A | 空闲 TTL,读写都会续命,热点 key 可长期存在 |
| 只有 R | 不过期,只在访问时异步刷新,旧值可长期兜底 |
| W + A | 两个过期条件同时生效,谁先到期谁淘汰 |
| W + R | 先异步刷新,超过硬 TTL 后同步重载 |
| A + R | 活跃数据异步刷新,闲置数据过期清理 |
| W + A + R | 活跃时刷新,闲置时清理,最大存活时间受 W 限制 |
其中:
text
W = expireAfterWrite
A = expireAfterAccess
R = refreshAfterWrite
十三、结合 Redis 的业务写法分析
假设你的代码是:
java
private final LoadingCache<String, String> activityTypeCache = Caffeine
.newBuilder()
.maximumSize(200)
.refreshAfterWrite(30, TimeUnit.MINUTES)
.build(this::getFromRedis);
它的行为是:
text
第一次 miss:同步查 Redis。
30 分钟后再次访问:先返回旧值,再异步查 Redis 刷新。
Redis 异常:旧值保留。
Redis 返回 null:本地 mapping 可能被移除。
超过 maximumSize=200:可能被 Caffeine 淘汰。
应用重启:本地缓存全部消失。
所以这段代码不是"绝对永远存在",更准确的描述是:
text
只要没有 size 淘汰、手动删除、JVM 重启、loader 返回 null 等情况,
refreshAfterWrite 本身不会让值因为时间到期而消失。
如果你希望 Redis 查询失败或查不到时,本地旧值继续兜底,建议自定义 CacheLoader.reload:
java
private final LoadingCache<String, String> activityTypeCache = Caffeine
.newBuilder()
.maximumSize(200)
.refreshAfterWrite(30, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return getFromRedis(key);
}
@Override
public String reload(String key, String oldValue) {
try {
String newValue = getFromRedis(key);
return newValue == null ? oldValue : newValue;
} catch (Exception e) {
return oldValue;
}
}
});
十四、AsyncLoadingCache 到底有什么用
前面说的都是 LoadingCache。现在再看 AsyncLoadingCache。
LoadingCache 的调用方式是:
java
String value = loadingCache.get("A");
如果本地没有值,当前线程会阻塞,直到 loader 加载完成。
AsyncLoadingCache 的调用方式是:
java
CompletableFuture<String> future = asyncLoadingCache.get("A");
如果本地没有值,它不会直接返回 String,而是马上返回一个 CompletableFuture<String>。
1. 第一次 miss 的区别
LoadingCache:
java
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
.build(loader::slowLoad);
long start = System.currentTimeMillis();
String value = loadingCache.get("A");
System.out.println("value = " + value);
System.out.println("cost = " + (System.currentTimeMillis() - start) + " ms");
预期:
text
当前线程等待 slowLoad 执行完成。
如果 slowLoad 耗时 1500 ms,这里整体也会耗时 1500 ms 左右。
AsyncLoadingCache:
java
ExecutorService executor = Executors.newFixedThreadPool(4);
AsyncLoadingCache<String, String> asyncCache = Caffeine.newBuilder()
.executor(executor)
.buildAsync(new AsyncCacheLoader<String, String>() {
@Override
public CompletableFuture<String> asyncLoad(String key, Executor executor) {
return CompletableFuture.supplyAsync(() -> loader.slowLoad(key), executor);
}
});
long start = System.currentTimeMillis();
CompletableFuture<String> future = asyncCache.get("A");
System.out.println("get cost = " + (System.currentTimeMillis() - start) + " ms");
future.thenAccept(value -> System.out.println("value = " + value));
future.join();
executor.shutdown();
预期:
text
asyncCache.get("A") 很快返回。
真正的加载在异步线程中执行。
value 会在 future 完成后拿到。
所以区别不是:
text
第一次都是同步
而是:
text
LoadingCache 第一次 miss 是同步阻塞。
AsyncLoadingCache 第一次 miss 是异步返回 Future。
只有当你这样写时:
java
String value = asyncCache.get("A").join();
调用线程才会被你自己阻塞住。这个时候使用体验会接近 LoadingCache。
2. AsyncLoadingCache 可以合并并发加载
假设多个请求同时访问同一个不存在的 key:
java
ExecutorService executor = Executors.newFixedThreadPool(4);
AtomicInteger loadCount = new AtomicInteger();
AsyncLoadingCache<String, String> asyncCache = Caffeine.newBuilder()
.executor(executor)
.buildAsync(new AsyncCacheLoader<String, String>() {
@Override
public CompletableFuture<String> asyncLoad(String key, Executor executor) {
return CompletableFuture.supplyAsync(() -> {
int count = loadCount.incrementAndGet();
System.out.println("load count = " + count);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "value";
}, executor);
}
});
CompletableFuture<String> f1 = asyncCache.get("A");
CompletableFuture<String> f2 = asyncCache.get("A");
CompletableFuture<String> f3 = asyncCache.get("A");
CompletableFuture.allOf(f1, f2, f3).join();
System.out.println(f1.join());
System.out.println(f2.join());
System.out.println(f3.join());
System.out.println("loadCount = " + loadCount.get());
executor.shutdown();
预期:
text
load count = 1
value
value
value
loadCount = 1
结论:
text
同一个 key 正在加载时,后续请求会复用这次加载结果。
这样可以避免并发 miss 时打爆 Redis 或 DB。
3. AsyncLoadingCache 和 refreshAfterWrite
AsyncLoadingCache 也可以配 refreshAfterWrite:
java
AsyncLoadingCache<String, String> asyncCache = Caffeine.newBuilder()
.ticker(ticker)
.executor(executor)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.buildAsync(new AsyncCacheLoader<String, String>() {
@Override
public CompletableFuture<String> asyncLoad(String key, Executor executor) {
return CompletableFuture.supplyAsync(() -> loader.slowLoad(key), executor);
}
});
行为是:
text
第一次 miss:立即返回一个未完成的 CompletableFuture。
已有值且未达到 refresh 时间:返回已完成的 CompletableFuture。
已有值且超过 refresh 时间:先返回旧值对应的 Future,同时后台刷新。
也就是说,在"已有旧值 + refreshAfterWrite"这个场景里:
text
LoadingCache 和 AsyncLoadingCache 确实很像,都是先返回旧值,再后台刷新。
但在"第一次 miss"这个场景里:
text
LoadingCache 会阻塞当前线程。
AsyncLoadingCache 会立即返回 CompletableFuture。
这就是两者最大的差别。
4. AsyncLoadingCache 第一次 get 会不会是 null
不会直接返回 null。第一次访问时,它返回的是:
java
CompletableFuture<String> future = asyncCache.get("A");
如果 loader 还没执行完,future 只是"尚未完成",不是 null。
可以这样验证:
java
CompletableFuture<String> future = asyncCache.get("A");
System.out.println(future == null); // false
System.out.println(future.isDone()); // 大概率是 false,取决于 loader 是否已经完成
String value = future.join(); // 这里才真正等待结果
System.out.println(value);
如果异步 loader 最终返回 null:
java
AsyncLoadingCache<String, String> asyncCache = Caffeine.newBuilder()
.executor(executor)
.buildAsync(new AsyncCacheLoader<String, String>() {
@Override
public CompletableFuture<String> asyncLoad(String key, Executor executor) {
return CompletableFuture.supplyAsync(() -> null, executor);
}
});
CompletableFuture<String> future = asyncCache.get("A");
System.out.println(future.join()); // null
System.out.println(asyncCache.synchronous().getIfPresent("A")); // null
结论:
text
asyncCache.get(key) 本身不返回 null。
它返回 CompletableFuture。
CompletableFuture 最终的结果可能是 null,但一般不建议 loader 返回 null。
如果异步加载失败,future 会异常完成,缓存 entry 通常也不会保留。
十五、什么时候用 LoadingCache,什么时候用 AsyncLoadingCache
适合 LoadingCache 的情况
text
业务代码本身是同步调用链。
方法返回值就是普通对象。
接口不支持异步返回。
你最终总是马上 join/get。
例如:
java
public String getActivityType(String activityId) {
return activityTypeCache.get(activityId);
}
这种场景下,用 LoadingCache 更简单。
适合 AsyncLoadingCache 的情况
text
数据源本身是异步 Redis、异步 HTTP、异步 RPC。
业务链路支持 CompletableFuture。
你不想让请求线程阻塞在缓存加载上。
希望并发 miss 时复用同一个异步加载结果。
例如:
java
public CompletableFuture<String> getActivityTypeAsync(String activityId) {
return activityTypeCache.get(activityId)
.thenApply(value -> value == null ? "UNKNOWN" : value)
.exceptionally(ex -> "UNKNOWN");
}
如果你这样写:
java
public String getActivityType(String activityId) {
return asyncActivityTypeCache.get(activityId).join();
}
那它的优势会小很多。不是不能这么用,而是多数情况下不如直接使用 LoadingCache 清晰。
十六、推荐配置
1. 希望请求尽量不阻塞,允许短暂旧数据
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(200)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(loader::load);
含义:
text
5 分钟后访问触发后台刷新。
如果长时间没刷新成功,30 分钟后旧值过期。
2. 希望没人用就释放内存
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(200)
.expireAfterAccess(30, TimeUnit.MINUTES)
.build(loader::load);
含义:
text
30 分钟没人访问就过期。
热点 key 会持续保留。
3. 希望热点数据后台刷新,冷数据自动清理
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(200)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.expireAfterAccess(30, TimeUnit.MINUTES)
.build(loader::load);
含义:
text
有人访问的数据会被刷新。
没人访问的数据会被清理。
4. 希望同时具备刷新、冷数据清理、最大陈旧时间限制
java
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(200)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.expireAfterAccess(30, TimeUnit.MINUTES)
.expireAfterWrite(2, TimeUnit.HOURS)
.build(loader::load);
含义:
text
5 分钟后允许刷新。
30 分钟没人访问就清理。
同一个成功写入的值最多使用 2 小时。
刷新成功后,expireAfterWrite 会重新计时。
十七、最后总结
可以把 Caffeine 的时间策略记成三句话:
text
expireAfterWrite 管最大存活时间,读不会续命。
expireAfterAccess 管空闲存活时间,读写都会续命。
refreshAfterWrite 管后台刷新,它不是过期。
再补一句:
text
LoadingCache 的 miss 是同步加载,AsyncLoadingCache 的 miss 是异步返回 Future。
如果缓存背后是 Redis,常见业务建议是:
java
Caffeine.newBuilder()
.maximumSize(200)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(loader);
这样既可以用本地缓存降低 Redis 压力,又可以用后台刷新减少请求阻塞,还能用硬过期时间限制数据最大陈旧程度。
参考资料
- Caffeine Eviction Wiki: github.com/ben-manes/c...
- Caffeine Refresh Wiki: github.com/ben-manes/c...
- Caffeine 2.8.5 Javadoc: javadoc.io/doc/com.git...