四个线程同时请求同一个不存在的缓存 key。加载函数返回一个尚未完成的 Future,四个调用者都在等待,但加载计数只有一。完成这个 Future 后,四个调用者得到相同的 loaded;随后热读仍没有增加加载次数。

这不是从 AsyncCacheApi 的名称推出来的保证。实际使用的 Caffeine provider 把进行中的加载结果放进同一个 key 的槽位,后续调用共享该结果。更换 provider、换成两个 JVM 或改成先 get 再 set,都需要重新验证回源次数。

key、值与进行中的加载

本篇使用 Play 3.0.6 的 caffeine 模块和解析得到的 Caffeine 3.1.8,累计工程基于 JDK 21。CacheLab 注入 AsyncCacheApi,以唯一实验前缀隔离 key,结束时只删除这组 key,避免清空应用共享缓存。

状态 调用者可见结果 本实验加载计数
冷 key,加载未完成 四个 Stage 均 pending 1
完成受控加载 四个调用者均得到 loaded 1
同 key 热读 loaded 1
另一个 key 首次加载失败 IllegalStateException 1
失败条目移除后重读 reloaded 2

异步缓存里,“已有条目”可能意味着已有进行中的结果,还没有业务值。若只检查最终值是否存在,再决定是否回源,两个并发调用可能同时观察到空值并各自发起加载。加载合并需要把选择加载者与发布进行中结果联系起来。

实验中的四个调用线程先在 start 闸门前集合,加载函数立即返回 controlled Future,然后等待主控线程统一完成它。不能让 loader 等待“四个 loader 全部进入”的屏障:如果实现正确地只调用一个 loader,这种测试结构自身就会死锁。

从 Java API 跟到 Caffeine

Play 源码固定为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。DefaultAsyncCacheApi 把 Java Callable、CompletionStage 和到期参数转换给 Scala AsyncCacheApi,本身没有一个跨 provider 的全局锁。

CaffeineAsyncCacheApi.getOrElseUpdate 把业务 Future 映射为带有效期的 ExpirableCacheValue,再调用 Caffeine 的 cache.get。这个 Future 是共享加载的一部分,因此应检查缓存的是最终值还是进行中的 Future。

Caffeine 3.1.8 的提交为 b0723da5976ebb52069f6b0cccfcf44186c3fdf3。LocalAsyncCache.get 经 computeIfAbsent 建立映射,仅创建加载 Future 的路径注册完成处理。四个并发调用得到一个 loader,正是本地 provider 这一机制的可观察结果。

加载函数应尽快返回 Stage。如果它先同步执行慢 SQL,返回值仍叫 CompletionStage,也无法消除调用线程已经发生的阻塞。回源使用哪个执行器、连接池和截止时间,仍然需要前面异步与线程池章节的约束。

单 key 合并也不能限制所有 key 的总回源量。一千个不同冷 key 仍可能触发一千次加载;如果业务需要限制下游并发,必须增加独立的准入与容量控制。缓存的最大容量和加载中的下游并发是两个不同资源约束。

失败条目为什么能够重新加载

本次故障加载返回一个以 IllegalStateException 失败的 Future。测试先确认错误传到调用者,再在有界窗口中等待 key 缺失,然后重新调用 getOrElseUpdate。最终 failedEntryRemoved=true、reloadValue=reloaded、failureLoaderCalls=2。

Caffeine 完成处理在异步加载没有产生值时删除对应的 Future 映射。删除使用 key 与旧 Future 两个条件,避免一个迟到的失败清理误删已经替换的新映射。加载成功路径则更新权重与到期信息。

调用者观察到异常,不意味着所有维护回调都已被该线程观察到。测试使用最多 3 秒的轮询等待失败条目移除,随后才断言重载次数。直接在 catch 后立刻固定 sleep 1 毫秒,会把机器调度差异引入正确性判断。

失败可重载不等于故障期间无限回源安全。一个快速失败的下游可能导致条目反复移除和重新加载,单次加载合并只能合并同时重叠的调用。是否短暂缓存失败、采用退避或熔断,需要按错误类型定义;把权限拒绝与临时网络失败都缓存成同一个空值,会改变业务语义。

expiration 的单位与零值

Java API 的 int expiration 按秒解释。转换方法把 0 转成 Duration.Inf,其余值转成秒数。不能依据“零时长”这一直觉,把 Java API 的 0 解读为立即过期。

provider 的 DefaultCaffeineExpiry在创建和更新时计算到期时长,读取时返回 currentDuration。因此当前默认实现的普通读取不续期,持续热读也不能把一秒 TTL 延成永不过期。

实验写入 ttl=1 与 expiration=0 两个 key,确认短 TTL 条目初始可读,然后每 25 毫秒轮询,最多等待 4 秒。本次采样在 1025 毫秒观察到短条目缺失,而零值条目仍可读。1025 是一次观测值,受轮询与调度影响,不是到期精度保证。

“没有按时间到期”也不等于“永久存在”。显式删除、容量驱逐、进程重启以及 provider 的其他政策都可能移除条目。零值条目在这次约一秒窗口内存在,只是有限实验;无限时长的映射来自固定源码,两者不能互相替代。

到期、维护和可读性也应分开。应用关心 get 是否返回过期数据;内存中何时清掉内部节点属于 provider 维护细节。本实验只轮询 API 可读性,未测量内部内存回收的精确时刻。

业务 key 必须包含数据可见范围

Alice 与 Bob 的私有值都叫 item。如果 key 只有 item,后一次加载很可能命中前一用户的数据,缓存就跨越了授权范围。实验使用独立 alice:item 与 bob:item,得到 alice-private 和 bob-private,证明当前 key 构造的两条路径没有混用值。

租户、用户、资源、语言、版本等维度是否进入 key,取决于哪些输入能够改变响应。缓存命中之后还要维持授权检查;把用户 id 放入字符串并不证明调用者有权声明这个 id。

下面的完整类可放入累计工程 app 目录编译。示例对各个字符串分量做 Base64 URL 编码,避免原始分隔符造成 key 碰撞;调用者需要传入已经由认证授权确定的租户和主体。业务 schema 版本进入固定前缀,便于数据模型变化时切换命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletionStage;
import play.cache.AsyncCacheApi;

public final class PrivateCache {
private static String part(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}

public static CompletionStage<String> load(
AsyncCacheApi cache, String tenant, String subject,
String item, Callable<CompletionStage<String>> loader) {
String key = "private:v1:" + part(tenant) + ":"
+ part(subject) + ":" + part(item);
return cache.getOrElseUpdate(key, loader, 10);
}
}

Base64 只是编码,不能用于保密。若 key 会进入日志或监控标签,还应控制敏感信息暴露与基数。示例只展示无歧义组合,不建立日志脱敏策略。

当前缓存与旧调用者可以持有不同版本

先写数据库再删除缓存,无法仅凭顺序就保证所有并发读者看到新值。一个早先开始的加载可能已经读到旧版本,随后在删除之后才完成。要分析这类竞争,需要记录加载开始、业务提交、失效完成和旧加载完成的顺序,以及最后返回的版本。

更新实验先 set(v1)、读取,再等待 set(v2) 完成后读取,分别得到 v1、v2。随后为另一个 key 创建未完成的旧 loader。loader 启动后,主控线程直接 set(new-v2),读取当前缓存,再释放旧 loader 返回 old-v1。

观察时点 已持有旧加载的调用者 当前缓存读取
set(new-v2) 已完成 done=false new-v2
旧 loader 已完成 old-v1 new-v2

当前映射被更新,不会改写已经交给旧调用者的 Future。旧读者仍可能获得旧值,而这次旧加载完成没有覆盖当前缓存中的 new-v2。源码中按旧 Future 条件更新的完成处理与这个结果吻合;它不是“所有调用者从 set 返回起都读到新值”的线性切换。

这个实验使用同 JVM 的 cache.set 替换映射,没有数据库提交和分布式失效消息。把更新改成 remove,再允许并发新 loader 进入,会形成另一个事件序列,必须另验。跨进程还会出现两个 JVM 分别缓存旧值的情况,本地 computeIfAbsent 不协调另一个进程。

更新策略应由允许的陈旧范围决定。允许短时旧读时,可以明确 TTL 上限并监控旧版本持续时间;不允许旧权限结果时,应缩短或绕开这条缓存路径,并把授权版本纳入判断。失效消息、版本 key 或持久化事务各有自己的失败窗口,需要独立实验验证。

重跑与练习

从第00篇下载累计工程,在 play-lab 目录执行:

1
bash sbtw 'testOnly BudgetTest' stage

caffeineCoalescesColdLoadsEvictsFailuresExpiresAndSeparatesSubjects输出BUDGET cache JSON,原始记录在evidence/batch15-17/isolated/。保留四个调用者的完成数、加载计数、失败移除结果、TTL观察时间、用户值、旧加载者与当前缓存版本,以及allExperimentKeysRemoved与callersTerminated。隔离应用30项JUnit与stage通过;共享累计工程34项测试与stage通过,python3 lab/async_checks.py --dev和python3 lab/async_checks.py各通过198次HTTP检查,在GET /budget/cache重验本篇字段。这组结论限定为当前provider、配置和单JVM。

反例题:两个 JVM 同时读取同一个冷 key,每个进程内部各有四个调用者。本篇结果能否证明整个系统只回源一次?不能;它只验证每个本地缓存实例的加载合并。没有跨实例协议时,应允许至少两个加载者同时存在。

改动练习:增加一个可控的旧版本 loader,在它读取 v1 后暂停,主控线程写入 v2 并执行失效,再释放旧 loader。分别记录旧调用者、更新后的新调用者和最后一次缓存 get 的值;把断言限定在实测 provider 的替换语义。随后在两个独立应用实例上重跑,区分本地原子操作与跨实例一致性。

上一篇:WS客户端与请求预算。下一篇:HTTP流式响应。