商品详情缓存从 Guava 换成 Caffeine,编译器通常能识别 get 的签名变化,却不会指出“商品不存在”的语义变化。Guava 加载器返回 null 会报错;Caffeine 的同步加载器可以返回 null,这个结果又不会被缓存。一个原本把异常记录为后端故障的服务,迁移后可能返回空结果,并在下次请求时再次访问后端。缓存命中率与调用方错误分类都会改变。

迁移应围绕同一份商品查询约束展开:商户和商品编号共同构成键,找不到商品与请求失败分开表达,允许多长时间的旧价格必须明确,失效之后是否接受此前开始的刷新结果也要有答案。缓存库不能替应用决定这些约束。Guava加载刷新与失效 已建立的状态时间线可以继续作为对照输入。

本文固定 Guava 33.5.0-jre 与 Caffeine 3.2.4。Caffeine 3.x 的最低运行要求是 Java 11,示例放在独立 modern 工程,以 release17 编译,实际在 Corretto 21.0.11 执行。这个实验没有在 Java 11 或 17 运行,也不适用于 Java 8 运行时;Java 8 项目应保留现有方案,或另外验证兼容的旧版本,不能只替换 Maven 坐标。

加载结果与异常分类

缓存里的“没有映射”与业务上的“商品不存在”需要区分。Caffeine LoadingCache.get 在键未缓存时调用加载器,返回的 null 不会建立映射。两次连续查询一个不存在的商品,加载器会执行两次。Guava 则要求 CacheLoader.load 返回非空值;null 触发 InvalidCacheLoadException。两份固定版本加载契约 与 Guava CacheLoader 明确了这一区别。

不存在的商品也可能构成热点。如果服务允许短时间缓存“不存在”,加载结果可以采用具名的查询结果类型,或 Optional<Product>。两种缓存保存的都是非空结果对象,负缓存的 TTL 由业务决定。把“异常”也包成 empty 会让临时后端故障进入负缓存,故障恢复后仍持续返回不存在;不存在、失败、授权不足应分别处理。

检查异常分类还要考虑包装类型。实验加载器抛出 IOException:Guava get 抛出 ExecutionException,Caffeine 同步 get 抛出 CompletionException,两者的 cause 都是 IOException。此前只捕获 ExecutionException 的代码,迁移后不会进入原有处理分支。对于运行时异常与 Error,应继续以固定 API 的声明为准,不能把这一个检查异常的结果扩展成所有异常的统一规则。

所谓“同键只加载一次”也有时间范围。Caffeine 合并尚在执行的同步加载,同键查询线程等待这次计算完成。再次缺失、失效后查询或失败后重试,都可能重新加载。实验用两个真实线程、进入信号和释放信号阻塞首次加载,确认第二个查询尚未返回且计数为一,再释放并要求两个结果均为 v1。等待和退出均有上限,避免以一次恰好没有重叠的执行来证明请求合并。

刷新资格、执行器与旧值

refreshAfterWrite 表示条目达到刷新资格的时间。时钟前进十一秒,十秒刷新阈值已经超过,但如果没有访问,实验中的刷新计数仍然是零。随后的访问提交刷新;新结果尚未完成时返回旧值。刷新失败保留旧值,并使之后的访问可以再尝试。刷新成功写入新值,写入时间也随之变化。这个过程与“每隔十秒后台自动拉取商品”不同;需要主动轮询时,应单独实现并管理调度器。

Caffeine 默认 asyncReload 借助配置的 executor 执行 reload,默认 reload 再调用 load。这比直接复制 Guava 同步默认 reload 的成本假设更值得检查。但是自定义 asyncReload 本身由触发刷新请求的线程调用,必须先获得 future;如果这个方法在返回 future 之前阻塞,读取请求仍会等待。executor(Runnable::run) 同样会让提交的工作直接在调用线程运行。Caffeine 固定源码的刷新说明 和 默认 asyncReload 可以分别定位提交位置与执行位置。

实验采用直接执行器和可控 CompletableFuture,目的是让状态变化可重放,不能把这种线程设置照搬到需要隔离后端 IO 的服务。生产线程池需要有队列、拒绝和关闭策略,并限制加载器的请求时间;把无界阻塞请求放到共享执行器,会把缓存访问与其他异步任务的资源预算混在一起。

Caffeine 的刷新还有一个明确的空值行为:刷新结果完成为 null 时,旧映射会被移除。这与“刷新失败继续使用旧商品”不同。业务删除可以使用这个行为,也可以返回带删除状态的非空对象;选择之后应把刷新为空、刷新异常和显式失效各自写入回归用例。

失效之后完成的刷新

失效的争议通常来自操作重叠。缓存已有 old,随后启动刷新并拿到未完成的 future;应用失效该键;最后刷新得到 late。这里存在两个可观察结果:等待刷新的人是否得到 late,以及缓存是否重新包含 late。它们没有必须相同的理由。

本文固定版本的实验得到:Caffeine refresh 返回的 future 完成为 late,缓存仍然没有该键;Guava 同样时序下重新包含 late。Guava 这一场景是已有值的刷新,与15篇“首次加载期间失效”的时序不同,不能因为输出相似就混用源码解释。

Caffeine 显式刷新走 LocalLoadingCache.refresh。开始时保存旧值引用,完成时通过 compute 检查当前值是否仍是那个旧值,并确认刷新记录是否仍可条件移除。实验中旧值非空,失效后当前值为空,身份条件不成立,因此保留空映射,迟到结果会作为丢弃结果进入通知路径。显式刷新固定源码 支撑的是这个特定时序。

自动刷新另走 BoundedLocalCache.refreshIfNeeded,完成分支还检查节点写入时间;当前值已经不存在时直接丢弃刷新结果,其他写入改变值或时间时保留当前映射。自动刷新完成分支 解释了为何两条路径不能仅凭方法名混为一谈。固定上游测试也保留了 refresh_invalidate 对照。

2017 年的 issue193 讨论的是 Caffeine 2.5.3 的异步缓存刷新。这个历史报告能说明为何需要覆盖时序,但不能直接当成 3.2.4 的行为证据。固定版本的可控实验与当前源码才决定本文的结论;没有测试的首次异步加载失效、并发覆盖和平台组合应另外验证。

完整示例只展示 Caffeine 刷新 future 与缓存映射的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package blog.libraries;

import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

public final class CacheMigrationDemo {
public static void main(String[] args) {
CompletableFuture<String> pending = new CompletableFuture<>();
LoadingCache<String, String> cache = Caffeine.newBuilder().executor(Runnable::run)
.build(new CacheLoader<String, String>() {
@Override public String load(String key) { return "initial"; }
@Override public CompletableFuture<String> asyncReload(
String key, String oldValue, Executor executor) { return pending; }
});
cache.put("sku", "old");
CompletableFuture<String> observed = cache.refresh("sku");
cache.invalidate("sku");
pending.complete("late");
if (!"late".equals(observed.join()) || cache.getIfPresent("sku") != null) {
throw new AssertionError("refresh result and cache contents are different contracts");
}
System.out.println("refresh-result=late, cache=absent");
}
}

示例工程中的同名类、完整迁移测试 与 运行说明 一起交付,另附 完整运行类。这个示例不涉及后台线程和网络请求,所以 future 的完成在当前进程中可控;真实后端的中断、连接释放需要在请求层另行验证。

异步查询的共享 future

Caffeine AsyncLoadingCache.get 可以把同键查询指向同一个 CompletableFuture。实验中加载器直接返回一个尚未完成的后端 future,两次 get 获取到同一对象。第一次调用 cancel(false) 后,第二个调用也观察到取消,缓存删除失败映射,后端这个同一对象再 complete("late") 返回 false。

这个反例有两个边界。返回给调用方的 future 是可变对象,任意调用方都可能影响其他共享调用;如果需要每个调用方独立取消,应返回一个不修改缓存共享 future 的派生观察结果,并单独设计中断传播。另一方面,cancel(false) 在实验中改变的是 future 完成状态,没有运行中的 HTTP 请求,也不证明网络请求已经停止。CompletableFuture 的取消契约 说明该参数不能被用作“后台任务已经中断”的证明。

固定 LocalAsyncCache 对异常或空结果进行完成后处理,移除与本次 future 对应的映射,而非只根据键删除任意新值。这类条件删除保留了并发替换的空间。读源码时应追踪“保存的是哪个 future”和“完成时仍是哪一个映射”,不能只看到一个 remove 就推断所有交错都相同。

统计、容量与移除通知

迁移后的指标需要重新定义。一次命中和一次不存在键查询,实验记录 hitCount=1、missCount=1;随后逻辑过期的访问增加 miss。统计默认需要显式开启,计数属于缓存访问,不等于 HTTP 请求数:合并加载、批量加载和刷新会使两种计数产生差异。应用的后端调用次数、加载失败率和商品不存在比例应分别记录,避免只监控命中率。

过期之后,访问已经不可见,但物理节点的维护未必同步完成。测试推进假时钟后确认 getIfPresent 返回空,再执行 cleanUp,检查估计大小为零。容量为二时写入三个键,维护后要求大小不超过二;没有要求特定商品必须被淘汰。把 Guava 的一个淘汰观察写成跨库的精确键断言,会把实现策略误当成应用契约。

Caffeine 的容量策略使用准入窗口、频率估计与主区域组织记录。频率历史可以在扫描负载下产生不同的保留结果,维护也会批处理读写记录。本文只以固定实现与官方设计说明解释为什么不应要求与 Guava 完全相同的淘汰次序;没有进行命中率轨迹回放,也不声称在任何商品访问分布下更快或更省内存。关于不同访问分布的比较,原始 TinyLFU论文提供了准入策略的研究背景。

移除通知又是一种独立输出。实验依次写入 v1、替换为 v2、显式失效,获得 REPLACED(v1) 与 EXPLICIT(v2),直接执行器使监听回调发生在调用线程。默认线程设置及异步通知的时机不能由这个结果推断。回调若负责关闭资源,还须考虑多个映射是否共享同一对象、请求是否仍在使用该对象,以及关闭是否阻塞;缓存映射消失不能作为资源无人使用的证据。

可迁移的两种设计

一种设计是把业务查询结果放在缓存之外定义。加载成功、不存在和失败先有明确的应用表示,再把可缓存结果交给缓存实现。这样可以用相同的缺失输入验证 Guava 与 Caffeine,而不会随着 null 的处理规则变化而改变接口含义。代价是需要明确负缓存期限,以及更新、删除和权限变化时的失效范围。

另一种设计是把并发操作的结果与映射状态分别验收。刷新结果可以完成,而映射仍不存在;共享 future 可以取消,而外部请求继续运行;过期数据可以不可见,而维护尚未清理节点。这种验收同样适用于异步数据库访问和连接池:分别检查调用方收到什么、共享状态如何变化、外部资源何时释放,才能形成可解释的失败路径。

检查位置 实验观察 迁移时的调用方责任
加载为空 Caffeine空结果不缓存;Guava抛异常 单独定义不存在与负缓存期限
检查异常 CompletionException/ExecutionException不同 更新分类并保留cause
刷新后失效 future完成与映射保留可以不同 规定删除后的旧结果处理
异步取消 本实验两次get共享可取消future 分离调用方取消和后端请求取消
容量与通知 维护后检查上限;通知原因独立 避免精确淘汰键与默认线程假设

实验记录与练习

JDK21 上七项测试全部通过:缺失与失败、刷新时序、失效与迟到结果、同键并发、共享取消、通知原因、TTL/容量/统计。完整日志保留预期的刷新异常记录,不能把日志里出现异常堆栈误判为测试失败;JUnit 的失败数为零。Java8为不适用,JDK11/17实际运行、默认执行器线程压力、弱引用GC、其他操作系统和性能测试均未运行。

一个可继续复现的练习是给刷新时间线加入 put("sku","newer"),位置放在旧刷新开始之后、旧future完成之前。先规定未来请求应得到 newer 还是 late,再在相同测试里验证两种缓存;这项额外交错不属于本文七项通过结果。另一个练习是将负缓存期限与商品创建事件结合,测试新商品是否被旧“不存在”记录暂时隐藏。

参考资料