Caffeine 本地缓存:加载、过期与内存成本
商品标题、计价规则和编译后的表达式,可能在许多请求中被反复读取。把一次计算的结果保留在应用内,后续请求就可以复用同一份对象。Caffeine 为这种本地复用提供加载、过期、容量控制和统计。
本地缓存的代价也发生在应用进程内:条目占用 Java 堆,加载占用线程,不同实例保存各自的值。选用它之前,需要知道省下了哪次访问,以及这些对象应保留多久。
缓存项、读取成本与进程位置
一个条目包含什么
本地缓存
├── key:用于查找和删除的稳定标识
├── value:可复用的计算或查询结果
├── 时间:写入、访问、刷新与过期判断
├── 容量信息:条目数量,或应用提供的权重
└── 访问记录:支持容量策略与可选统计key 由影响结果的条件组成。同一个商品编号,在不同租户、语言或价格方案下可能产生不同结果;这些条件应进入键或拆分到不同缓存。反过来,把每次请求都不同的追踪号放入键,会让复用几乎消失。
普通强引用缓存按 equals 与 hashCode 查找。适合用不可变值对象表达键:
record ProductKey(String tenant, String sku, String locale) {}value 也宜使用不可变快照。如果调用者取得缓存中的 List 后直接修改,后续请求会读到同一个被修改的对象。缓存库保证自己的并发结构,并不替这个 List 实现业务隔离。
请求、响应、数据库连接和打开的输入流通常不适合当普通缓存值。这些对象有独立的关闭时机,把它们保留下来会延长连接或文件的占用。确需缓存昂贵资源时,还要单独处理借用计数、移除后仍在使用的调用者和关闭顺序。
什么时候能省下成本
设本地查找平均耗时为 L,未命中后的加载耗时为 D,命中比例为 h。先忽略刷新、写入与并发影响,平均读取耗时可粗略写成:
平均耗时 ≈ L + (1 - h) × D
例:L = 0.05 ms,D = 10 ms,h = 0.9
平均耗时 ≈ 1.05 ms这是容量估算的输入,不能当作压测结果。真正的 P99 还受加载排队、GC、下游延迟和热点失效影响;平均值降低时,少数 miss 仍可能很慢。
如果原查询只需很短的一次索引访问,缓存对象却巨大、更新频繁,序列化和失效处理可能吃掉收益。应比较打开与关闭缓存时的端到端延迟、数据库查询次数、堆占用及错误率。查询计划本身有问题时,也应先修复不必要的全表扫描,而不是用缓存遮住它。
命中率要与流量一起看。每秒十万次请求,即使只有 1% miss,也有每秒一千次潜在回源;能否承受取决于下游,而不是命中率数字是否漂亮。并发请求共享加载后,miss 次数与实际加载次数还会不同。
本地与远端怎样选择
| 位置 | 一次命中经过什么 | 更适合的对象 | 需要承担的代价 |
|---|---|---|---|
| 请求内复用 | 当前调用保存的结果 | 同一请求重复使用的数据 | 只在本次请求有效 |
| Caffeine 本地缓存 | 当前 JVM 内查找 | 小而热、读取频繁、可重新生成的对象 | 每个实例都有一份,占用应用堆 |
| Redis 共享缓存 | 编码、网络、服务端、解码 | 多实例共享的派生结果 | 网络失败、序列化与远端容量 |
| L1 + L2 | 先本地,miss 再访问远端 | 热点读取且允许明确的陈旧窗口 | 两套容量和失效过程 |
Caffeine 对象的作用范围是一个缓存实例。即使在同一个 JVM 内创建两个 LoadingCache,它们也会独立加载、独立删除;多 JVM 更需要额外的失效或版本机制。LocalCacheTest 中的两个实际缓存各加载一次,删除第一个条目后,第二个条目仍存在。
远端连接和共享副本的处理见 Redis 客户端运行链;三层读取怎样避免长期停留在旧 L1,见 BigKey 与多级缓存。
在 Linux 上运行一次真实加载
准备工程
使用具备 Docker 权限的普通 Linux 用户,准备 Bash、Docker Engine、unzip。Docker 权限允许控制容器和挂载主机目录,应只交给可信用户。下载 缓存实验工程,在下载目录执行:
test "$(id -u)" -ne 0 || { echo '请使用普通用户'; exit 1; }
ARCHIVE="$(pwd)/cache-runtime-lab.zip"
test -f "$ARCHIVE" || exit 1
LAB_DIR="$(mktemp -d)"
unzip "$ARCHIVE" -d "$LAB_DIR"
PROJECT_DIR="$LAB_DIR/cache-runtime-lab"
mkdir -p "$LAB_DIR/m2"
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=LocalCacheTest clean verify工程编译目标为 Java 17,使用 Boot 4.1.1 的依赖管理,其中 Caffeine 为 3.2.4。Boot 父项目在这里提供依赖版本和可执行 JAR 打包,local 命令不启动 Web 容器,也不连接 Redis。Boot 依赖表可查受管理坐标,Caffeine 的版本变化见 官方 Releases。
Maven 容器使用宿主 UID/GID,/work 与 /cache 分别映射到临时工程和可写依赖目录。首次运行需要访问镜像仓库和 Maven 仓库;企业内网应使用批准的仓库代理或已导入镜像,不要将不稳定第三方镜像地址固化进工程。
成功时 LocalCacheTest 的 9 项测试通过,生成 target/cache-runtime-lab-1.0.0.jar。计划中的刷新失败和异步失败会产生警告,测试同时断言后续缓存状态;判断整次构建以 Maven 退出码和测试结果为准。若依赖下载失败,先处理日志里的仓库地址;若提示 Permission denied,检查挂载目录属主与容器 UID。
连续读取与删除
docker run --rm --user 10001:10001 \
--read-only --tmpfs /tmp:rw,mode=1777,size=64m \
-v "$PROJECT_DIR/target/cache-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk \
java -jar /app/app.jar local输出中的业务行如下:
first=SKU-001-v1
second=SKU-001-v1
afterInvalidation=SKU-001-v2
loads=2
hits=1 misses=2第一次没有条目,loader 生成 v1;第二次返回已保存的 v1;invalidate 后再次读取,loader 生成 v2。loads 由实际 loader 增加,命中和未命中来自 Caffeine 的统计,不是由预设的调用次数计算。
核心代码如下,完整入口位于工程中的 CacheLab:
AtomicInteger loads = new AtomicInteger();
LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(Duration.ofMinutes(1))
.recordStats()
.build(key -> key + "-v" + loads.incrementAndGet());
cache.get("SKU-001");
cache.get("SKU-001");
cache.invalidate("SKU-001");
cache.get("SKU-001");这里生成字符串是为了直接观察 loader 是否执行,没有模拟数据库响应时间,也不据此报告性能收益。真实业务将 loader 替换为受超时约束的查询或计算,并保留同样的调用统计。
将构建镜像改为 maven:3.9.12-eclipse-temurin-17,可复跑同一组 Java 17 测试;运行镜像也可改用 eclipse-temurin:17.0.20_8-jdk。更换 JDK 后仍需 clean verify,避免误用旧 target 中的产物。
命令使用 --rm,退出后实验运行容器即删除。工程、Maven 缓存保留在 LAB_DIR;需要回看测试时,可读取 target/surefire-reports。不要把宿主根目录或已有工程目录当作临时清理目标。
加载、刷新与驱逐的不同路径
手动缓存、同步加载与异步加载
| 类型 | 常用入口 | 调用者得到什么 | miss 时由谁处理 |
|---|---|---|---|
| Cache | getIfPresent / put | 当前值或 null | 应用自行决定 |
| Cache | get(key, mappingFunction) | 计算后的值 | 该次提供的计算函数 |
| LoadingCache | get(key) | 加载后的值 | 创建缓存时配置的 loader |
| AsyncCache | get(key, mappingFunction) | CompletableFuture | 异步计算函数 |
| AsyncLoadingCache | get(key) | CompletableFuture | 统一配置的异步 loader |
Cache.get 的计算和 LoadingCache 的单键加载可以协调并发调用。手工拆开的 getIfPresent、查询、put 则是三次操作:许多线程都可能先读到 null,然后分别查询。
String value = cache.getIfPresent(key);
if (value == null) {
value = repository.load(key);
cache.put(key, value);
}这段写法在低并发时容易表现正常。LocalCacheTest 用八个真实线程,先让它们都读到 miss,再释放加载,得到八次调用;换成 LoadingCache 后,八个调用者等待同一个尚未完成的加载,只执行一次 loader。这个合并范围仍是当前缓存、当前 key;八个不同 key 可以各自加载,另一份缓存也有自己的加载过程。
异步缓存保存正在进行的 CompletableFuture,多个调用者可以共享它。返回 Future 能让调用者组合异步操作,但 loader 内部若继续使用阻塞 JDBC,仍会占用执行它的线程。加载方式、批量加载与异常处理见 Caffeine Population。
同步 loader 中避免递归加载相同 key,也不要把互相依赖的加载写成循环。两个 key 的加载各自等待对方时,外层增加缓存无法解除等待。
不存在、失败和超时
Caffeine 不把 null 保存成普通成功值。loader 返回 null 后,下一次读取仍可能重新调用它;抛出异常也应按失败处理。实验连续读取两次不存在值、两次失败值,实际发生四次加载。
确实查明数据不存在时,可以使用显式结果类型保存短期负缓存:
Cache<String, Optional<Product>> cache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(3))
.build();Optional.empty 表示“本次查询成功,未找到商品”。数据库超时则应抛出异常或返回明确失败结果,不能转换成 empty,否则一次故障会被保存成“商品不存在”。不同成功值与空结果需要不同 TTL 时,可使用自定义 Expiry,或拆分受控缓存。
异步加载失败后,对应失败条目会移除。实验让八个调用者取得同一个未完成 Future,再令它失败;下一次 get 真正重新执行 loader 并得到 recovered。这与把失败结果包装成一个正常对象写进缓存不同。
共享 Future 还有调用者超时问题。直接对缓存返回的同一个 Future 调用 orTimeout,会改变这个 Future 的完成状态,影响其他等待者。若只是某个 HTTP 请求的等待时间耗尽,可以对派生 Future 设置等待上限:
CompletableFuture<Product> shared = asyncCache.get(key);
CompletableFuture<Product> forThisRequest =
shared.copy().orTimeout(200, TimeUnit.MILLISECONDS);这个 200 ms 只约束当前派生结果,并不会自动停止共享查询。loader 的连接、SQL 或远程 HTTP 调用仍需自身的 deadline 与取消方式。copy 和 orTimeout 的区别见 CompletableFuture API。
过期控制“还能否读取”,刷新准备下一份值
expireAfterWrite(20s) + refreshAfterWrite(5s)
T0 写入 v1
T+6 已具备刷新资格,但没有访问时不会自动发起刷新
T+6 读 返回 v1,并开始异步 reload
刷新中 其他读取仍可得到 v1
刷新成功 写入 v2,写入计时随替换更新
刷新失败 保留 v1;原来的硬过期计时不会因为失败而重置expireAfterWrite 从创建或替换条目开始计时,适合限制一份缓存结果的保留期。expireAfterAccess 根据最近一次访问或写入续期,适合按空闲时间回收;一个持续被访问的旧值可能长时间存在。
LocalCacheTest 向真实 Caffeine 注入 Ticker 时间源:T+9 访问两个缓存,T+11 时写入过期缓存已 miss,而访问过期缓存仍命中;继续推进到 T+22,后者也 miss。测试只推进时钟,删除和到期判断由库执行。Eviction 文档说明了这些策略、自定义过期和 Ticker 的用途。
refreshAfterWrite 需要加载型缓存,它提供的是刷新资格。访问触发刷新期间可以继续读旧值;显式 refresh(key) 也可以请求刷新。同一 key 的在途刷新会合并。刷新失败保留原值,因此还应设置允许的硬过期,并观察失败次数。具体机制见 Refresh 文档。
如果硬过期先于刷新完成,后续读取可能需要等待新的加载。刷新线程池是否有空位、源查询是否及时完成,都会影响这一结果。设置“每五秒刷新”而完全没有后续访问,并不会产生一个每五秒执行的定时任务。
TTL 从缓存写入开始时,也没有描述数据在源系统中已经旧了多久。一个很慢的查询取得旧快照,稍后才写进缓存,会从这次写入重新计算 TTL。对业务陈旧度有要求时,还需要保存源版本或生成时刻,结合缓存一致性中的更新过程判断。
数量上限与权重上限
maximumSize 约束条目数量。若一条 value 是很短的字符串,另一条是几万个节点的树,使用相同条目数不能代表相同内存成本。
Cache<String, List<String>> cache = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((String key, List<String> value) -> value.size())
.build();这里的权重单位是 List 元素数,不是字节。若需要近似内存预算,应为 key、value 和对象图选择可解释的估算方式,再用实际堆占用校准;即使以序列化后的字节数计权,也没有包含全部 Java 对象开销。
weigher 在条目创建和更新时计算,之后不会跟踪 value 内部变化。实验放入两个元素的可变 List,随后原地增加到七个元素,策略中记录的权重仍为 2;重新 put 不可变副本后才重新计量。容量为 6 时,这个权重 7 的替换值不会留下。
容量维护可能异步推进。需要在测试里观察最终维护状态时可以调用 cleanUp,再读取策略和 estimatedSize;不要要求每次 put 返回时都已完成所有维护。大小策略及权重计算的限制同样由 Eviction 文档定义。
准入、移除和清理
Caffeine 的 Window TinyLFU 综合近期访问与历史频率,让新条目先有短期机会,再与已有候选比较是否值得保留。一次扫描带来的低复用数据因此不必全部挤走热点。应用仍需限制键空间;算法无法知道某个租户是否可以无限创建不同 key。
内部通过缓冲访问与写入记录,批量维护策略,减少每次读取都修改全局顺序的争用。并发顺序、历史访问和维护时机都会影响逐出者,所以测试应检查容量结果,不把“必定删掉某个扫描 key”写成 API 承诺。机制可查 Caffeine Design。
invalidate / replace → 显式移除或替换
expire → 条目不再满足时间条件
evict → 容量或引用策略回收
cleanUp → 推进待处理维护到期条目对读取不可见,与对象何时完成维护、被 GC 回收是两个时刻。需要更及时的维护可配置 Scheduler,但它仍不是业务准点任务系统。Cleanup介绍了维护的触发方式。
removalListener 可接收各种移除原因,通常通过配置的 executor 异步执行;evictionListener 用于策略逐出通知,执行位置更直接,应保持短小。监听器适合计数和受控清理,不适合无界查询。值被移除后,已有调用者仍可能持有其引用,不能只收到通知就贸然关闭共享资源。回调语义见 Removal。
接入应用后的容量与故障处理
缓存和线程池跟随应用生命周期
缓存应由受控的单例组件持有,不在每次请求里重新创建。否则每个请求都从冷缓存开始,命中统计也被不断清零。单例同时意味着它会保存跨请求对象,容量和数据权限必须随之设计。
异步加载和刷新使用的 executor 需要明确拥有者。Caffeine 默认使用公共 ForkJoinPool;慢 I/O 集中进入该池时,会和其他使用者竞争。可以给阻塞加载建立独立的有界线程池:
ThreadPoolExecutor refreshWorkers = new ThreadPoolExecutor(
2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(32),
new ThreadPoolExecutor.AbortPolicy());再通过 builder.executor(refreshWorkers) 交给缓存。数字只是一个小型配置示例,实际容量应根据源查询耗时、允许的并发和延迟目标计算。队列满时需要让请求或刷新显式失败并被计数,不能偷偷追加到另一个无界队列。
应用停机时,由创建者关闭这个线程池并等待受限时间,不在每个 loader 的 finally 中关闭公共执行器。缓存移除值与停止线程池分别处理;仅 invalidateAll 不会关闭外部线程和数据库连接。
接入 Spring 时,应由配置组件创建 CaffeineCacheManager 或明确的 Cache Bean。@Cacheable 的代理调用与 provider 选择见 Spring Cache 与读写模式。
怎样读统计值
recordStats 开启命中、加载和驱逐统计。CacheStats 是当前快照,常用字段如下:
| 字段或指标 | 回答的问题 |
|---|---|
| hitCount / missCount | 查找是否已有可用条目 |
| loadSuccessCount / loadFailureCount | 实际加载是否成功 |
| totalLoadTime / averageLoadPenalty | 加载累计及平均花费 |
| evictionCount / evictionWeight | 策略移除了多少条目或权重 |
| 应用自己的加载并发和等待时间 | miss 是否正在堆积 |
| 源版本、生成时间与陈旧读次数 | 返回的值是否满足业务新鲜度 |
同一 key 的多个 miss 可以合并成一次加载,因此 missCount 高于 loadCount 并不异常。显式 invalidate 不应被误读为容量驱逐。统计入口与接入方式见 Statistics。
把指标按 cache name 和结果类别聚合。完整 key、用户编号与请求 URL 不宜成为长期标签,它们会产生大量时间序列;必要的具体对象放在受控、采样且脱敏的诊断信息中。
如果命中率降低而加载次数没有同比上升,可能是合并仍然有效;若命中率很高但 refresh 排队增加,也可能已经影响新鲜度。结合缓存与源查询观察,才能知道应该增加容量、调整 TTL,还是先限制加载。
从现象找到对应操作
| 现象 | 首先检查 | 下一步 |
|---|---|---|
| 每次读取都执行 loader | 缓存是否每次新建、key 是否稳定、是否一直返回 null | 固定生命周期,明确不存在结果 |
| 某次调用后所有等待者一起超时 | 是否修改了共享 Future | 将请求等待超时放到派生 Future,检查源调用期限 |
| 访问频繁却一直旧 | expireAfterAccess 是否持续续期、刷新是否失败 | 采用合适硬过期,核对源版本 |
| 内存超过预计值 | value 是否可变、权重单位、键空间、对象图 | 固定值结构,降低预算并核对堆 |
| 缓存数量短时超过上限 | 待处理维护、executor 是否拥塞 | 检查维护进度和最终容量 |
| 大量刷新被拒绝 | 专用线程池、队列和源延迟 | 限制加载、保留允许的旧值或拒绝请求 |
| 一台实例更新后另一台仍旧 | 是否只调用了本地 invalidate | 增加跨实例失效及断线恢复方案 |
| 关闭缓存后数据库负载骤增 | 基础流量与 miss 回源预算 | 受限旁路、逐步预热,避免同时切换所有实例 |
停用本地缓存应保留受限加载路径。修改缓存键或 value 结构时,使用新的 cache name 或版本化键区分旧格式;滚动发布期间,新旧实例可能同时存在,两者要分别遵守数据权限和容量约束。
完成正确性检查后,再在代表性流量下比较有缓存与无缓存的实际开销。微小实验适合验证加载与生命周期,吞吐、延迟和堆容量则需要应用自己的数据分布与并发条件。
