Java常用类库15:加载、刷新与失效的缓存状态
商品详情缓存需要减少重复查询,同时给价格更新留下明确的生效边界。一次查询返回旧价格,可能因为条目仍在有效期,也可能因为刷新正在执行或已经失败。调用 invalidate 以后仍然出现一个先前启动的加载结果,也有不同于“删除没有成功”的原因。只记录缓存里有没有这个 key,无法解释这些行为。
Guava LoadingCache 把缺失加载、已有值刷新和条目维护安排在不同路径中。固定到 33.5.0-jre 后,可以用受控时钟和 Future 把状态分别停住,观察哪个调用等待、哪个调用返回旧值、哪些失败会传给调用方。商品缓存的陈旧窗口和更新协议需要依据这些可见行为设计。
商品详情允许多旧,先写成约束
设缓存保存 sku -> 商品详情快照,加载器查询外部详情服务。此次实验用字符串版本号代替详情对象,只观察缓存协议;联网与 JSON 解析留给相应章节。详情快照应当不可变,否则命中相同对象仍可能观察到未受缓存协议约束的字段修改。
假设条目写入十秒后允许刷新,三十秒后过期。十秒的含义是刷新资格,不能据此承诺“后台每十秒更新一次”。三十秒定义从成功写入开始计算的过期时限,也不能替代外部服务的请求超时。初次加载如果迟迟不结束,尚不存在一个可以按这三十秒期限淘汰的正常值;加载线程和连接的生命周期需要独立控制。
已有值刷新失败时,可以暂时保留旧快照。这个选择适合允许短暂陈旧的描述字段,却不能直接用于结算金额的最终校验。旧值保持可读和旧值仍适合业务使用是两个判断。价格是否有效,应由价格版本、业务生效时间或最终确认接口决定,不能从缓存命中推导。
同一 key 的并发缺失查询应共享加载结果,避免请求数直接放大到后端。不同 key 则没有这项合并关系;缓存容量也不是后端并发预算。即使 maximumSize(1000),一千个尚未结束的不同 key 加载仍可能争夺连接和线程,需要在外部边界增加独立的资源限制。
| 可观察状态 | 是否存在可用值 | 调用方需要核对的结果 |
|---|---|---|
| 缺失且未加载 | 否 | get 启动加载,成功或失败传给调用方 |
| 同 key 正在首次加载 | 否 | 其他 get 等待已有加载结果 |
| 有效且未到刷新资格 | 是 | 命中当前值 |
| 有效且具备刷新资格 | 是 | 访问可能启动刷新;没人访问时不能期待周期执行 |
| 刷新中或刷新失败 | 旧值仍有效时是 | 可返回旧值;完成、失败和过期需要分别判断 |
| 过期 | 否 | 读入口不再返回该过期值;物理移除可能稍后发生 |
get、load 与 reload 怎样连接
LoadingCache.get(K) 的公开契约规定,同一个 key 正在加载时,调用会等待那个加载,而不同 key 可以并发加载。加载器抛出的受检异常由 ExecutionException 包装;运行时异常和 Error 有各自的包装路径。getUnchecked 改变异常接口,不会把失败变成缺失值。加载器返回 null 则违反加载契约,抛出 InvalidCacheLoadException。固定版本 LoadingCache 文档、CacheLoader 文档分别描述了调用和加载责任。
商品确实不存在时,null 不能同时承担“缓存没装入”和“后端查无商品”两种含义。可以缓存一个明确的缺失结果类型,并为负缓存单独确定期限。服务失败不应自动转换成商品不存在;这样转换后,后端恢复也可能继续命中那个缺失结果。
reload(K, V) 收到旧值,返回 ListenableFuture<V>。返回 Future 允许异步完成,但不保证实现一定异步:默认 reload 调用 load,并把结果放进已完成的 Future。耗时工作已经在产生 Future 之前执行,触发刷新的线程仍会等待。需要把耗时操作放到适当 executor,或使用明确的异步实现,才能改变这一点。
手工 refresh(key) 与自动刷新共用刷新机制,但入口条件不同。前者是明确请求刷新,后者通过配置和访问判断资格。已有值刷新可以保留旧值,缺失 key 的刷新则需要调用加载器取得新值。应用不能把 refresh 当作一个无需观察失败和资源成本的通知动作。
固定源码中的占位和回填
本篇源码固定在提交 8868c096cfdabbe38170b6e395369c315cfb72a1。LocalCache.Segment.lockedGetOrLoad 在锁内查找已有条目,必要时建立 LoadingValueReference,随后解锁并执行加载;遇到已有加载引用的调用转到 waitForLoadingValue。占位在耗时操作之前建立,使同 key 调用能够找到同一个加载结果。这里只解释固定实现,不把分段数量或内部类名称当成公开兼容承诺。加载与等待路径
同 key 的合并还限定在一个缓存实例及其匹配规则内。两个独立 JVM、两个缓存对象或两个不相等的复合 key,都会拥有各自的加载。商品身份包含商户编号时,缓存 key 也应包含商户编号,否则加载合并本身会把不同商户的数据混在一起。第 03 篇的身份契约在这里继续影响并发行为。
刷新入口 scheduleRefresh 检查是否启用刷新、写入后经过的时间以及条目是否已经在加载。满足条件后才进入 refresh。如果新结果已经同步完成,该次读取可以直接拿到新值;如果结果尚未完成,则返回旧值。因此“配置了刷新就总是先返回旧值”也不准确。本文用一个受控、尚未完成的 Future,专门观察可保留旧值的那条路径。刷新入口
loadAsync 为新 Future 安装 listener,listener 调用 getAndRecordStats 完成校验、统计和回填。失败路径记录警告并移除加载状态;它不把刷新失败重新抛给此前已经拿到旧值的读取调用。测试日志中出现 Exception thrown during refresh 是刻意注入失败的输出,不能通过删掉警告把实验改成只覆盖成功。
1 | |
写入时间会随成功的值替换而更新。具备刷新资格、刷新已经开始、刷新成功是不同事件;只有推进时钟,不能代替其中任何一次业务操作。这也是受控时钟实验比等待几十秒再看结果更容易解释的原因。
一个可以独立运行的时间实验
下面的 Java 8 程序展示无人访问、刷新进行中与刷新成功三个状态。使用 java -ea 启用断言;Guava 及其运行依赖使用本系列固定工程的 classpath。完整实验和构建入口可从附件取得。
1 | |
这个 Clock 只用于单线程演示。并发测试中的时钟使用 AtomicLong,避免把测试共享变量的数据竞争混入缓存结论。时间只向前推进;倒拨时间可能改变内部判断,不能作为正常状态转换的替代。
实验工程里的 Chapter15Test 另外安排了两条真实执行线程。第一条进入 loader 后在 latch 上等待,第二条查询同 key;释放前第二个 Future 无法取得结果,加载次数为 1。释放后两条调用都取得 detail-sku,最终加载次数仍为 1。等待有五秒上限,executor 在 finally 中关闭并确认结束,断言失败也不会遗留测试线程。
刷新失败、过期和清理的结果
五项 JUnit 测试分别在 Zulu JDK 8u472、Corretto JDK 21.0.11 下通过,两个环境均为 0 失败、0 错误、0 跳过。Wrapper 固定 Maven 3.9.14。本篇没有用这些数量推导吞吐量、所有线程交错或所有 Guava 方法的覆盖率。原始日志保留异常栈、命令与 Surefire XML。
| 受控输入与操作 | 两个 JDK 的结果 | 能支持的判断 |
|---|---|---|
| 同 key 两个查询,首次 load 被 latch 阻塞 | 释放前不能完成;释放后两个相同结果,load=1 | 该场景共享一次加载 |
| 写入后推进 11 秒,不访问 | reload=0 | 时钟推进不主动执行刷新 |
| 首次访问启动未完成的 reload,再次读取 | 两次都返回 v1,reload=1 | 刷新进行中可继续返回旧值 |
| 令刷新 Future 失败,再读取 | 返回 v1,启动第二次 reload | 失败没有向此前读取传播;可以再次尝试 |
| 完成第二次刷新,然后推进 31 秒 | 刷新后读新值;过期后首次 load 次数增至 2 | 成功回填与过期加载是不同路径 |
刷新失败不会无限延长旧值的有效期。如果旧值随后过期,读取需要走缺失加载路径,失败行为也随之改变。把自动刷新看成“永远可以读旧数据”的可用性保证,会遗漏这一转换。需要更长的陈旧回退时,应把回退窗口明确写成业务策略,并单独测试最终超过窗口时的拒绝行为。
另一个实验在写入十秒过期的条目后推进十一秒。访问前 size() 观察到 1,getIfPresent 却返回 null;调用 cleanUp() 后 size() 为 0。Guava 文档允许等待维护的过期条目仍计入大小,而正常读取不返回那些条目。size() 因而不能作为“还有一个可命中的商品”的证明。Cache 大小与清理契约
cleanUp() 执行维护,不承诺重新查询所有符合刷新资格的 key。后台线程周期调用清理和后台线程周期刷新是不同任务;第一个任务完成后,不能据此声称商品详情已更新。配置维护线程时还需要安排它自己的关闭过程,避免应用停机后任务继续持有缓存或服务引用。
invalidate 没有取消正在执行的加载
失效实验先启动一个受控加载,等待 loader 确认进入,再调用 invalidate("sku")。此时调用 Future 尚未完成;释放 loader 后,原查询获得 started-before-invalidation,缓存中随后也观察到这个值。这个结果直接限定了本次固定版本的更新协议:失效操作不等于取消后端执行,也没有给先前启动的加载加上业务版本围栏。
本实验的首次加载尚无有效旧值。Segment.remove 遇到这种未激活的加载引用时直接返回,没有删除加载占位;storeLoadedValue 随后可以替换原来的加载引用。因此失效后“查不到正常值”不证明后台工作或占位已被移除。固定移除路径、固定回填路径。回填函数还存在找不到匹配条目时建立新条目的分支,但上述实验没有据此声称覆盖了所有删除与回填交错。上游 issue #1881也讨论了加载与失效的这类竞态;历史 issue 的描述只用于定位风险,本篇结论由固定源码及本机受控实验支持。
如果业务要求“更新成功之后不再接受任何旧版本回填”,需要把版本放进加载结果或 key,回填前核对当前版本,或选择具有相应协议并经过同样回归的实现。缓存实例的线程安全无法替业务定义更新顺序。先读取旧值、后提交数据库更新、再 invalidate 三个动作,涉及缓存和数据库两个状态域,不能因为每个单独调用安全就推导组合原子性。
测试也建立了 maximumSize(2) 的独立缓存,放入三个条目并清理,最终大小为 2、驱逐计数为 1。这里断言容量预算和发生驱逐,没有固定被移除的 key;精确被移除条目依赖访问、实现和维护路径,不能把一个小数据集的结果写成所有情况下的替换政策。
选择 JDK Map 或加载缓存
只有一个静态、少量且不可变的商品目录时,明确构建的只读 Map 已经能表达需求。引入缓存后增加了过期状态、后台工作选择、统计和失败路径,不应仅为了把 map.get 写成 cache.get 而增加这些成本。
动态详情查询需要同 key 加载合并、过期和容量管理时,LoadingCache 提供了可复用机制。调用方仍需确定复合身份、详情快照、请求超时、可接受陈旧窗口、负缓存语义和更新围栏。第 31 篇比较 Caffeine 时,应重跑这些业务断言,再解释两种实现各自的刷新和维护;容量相同不要求每次驱逐完全相同的 key,也不构成性能等价的证明。
有两项判断可以迁移到其他缓存。第一项是按状态观察调用:记录 key、版本、首次加载或刷新、触发时间、完成时间及失败类别,才能解释一次旧值返回。第二项是把逻辑有效性与物理维护分开:是否能读到值、是否仍计入大小、是否已清理,分别对应不同入口,监控也应使用相应指标。
| 需求关键词 | 需要建立的约束 | 检查入口 |
|---|---|---|
| 每十秒刷新 | 资格与执行触发分开 | 无访问和有访问对照,reload 计数 |
| 避免重复查询 | 限定缓存实例与 key 相等关系 | 同 key 阻塞加载,多个等待者 |
| 更新后立即生效 | 明确旧加载回填如何处理 | 更新/失效/加载完成时间线 |
| 控制内存和后端资源 | 容量与调用并发分别预算 | size/驱逐,与连接和线程计数 |
手算题:t=0 写入 v1,刷新资格为 10 秒、过期为 30 秒;t=11 触发异步刷新,t=12 刷新失败,t=31 再次查询。最后一次调用还能把 v1 当有效命中返回吗?不能:失败未形成成功写入,原值已经超过过期时限,查询需要加载。改动练习是在完整测试中加入“刷新成功前数据库版本变化”的控制点,并检验带版本 key 的方案是否拒绝旧版本查询;新增结果必须单独留证,不能借用上述五项通过数。
实验附件与导航
实验运行说明;完整五项契约测试;单文件时间线示例。完整测试保存了加载失效反例,单文件示例只覆盖三种刷新状态。所列反例与日志均为合成输入;其他操作系统、长期压力、弱引用回收和完整更新围栏未运行。系列入口:Java常用类库00:从业务契约建立可复现基线。身份和不可变前置见系列 03、09;后续异步回调和缓存迁移分别在 16、31 展开。
