缓存成本模型与 Caffeine:本地副本如何有界命中和驱逐
把 ConcurrentHashMap 包一层就叫缓存,通常会在扫描流量、对象增长或配置更新时付出代价。一个可用的本地缓存必须回答:什么值得留下、何时过期、谁负责加载、并发 miss 是否合并、实例之间允许分叉多久。
Caffeine 当前正式线提供自动加载、按大小或权重驱逐、按访问或写入过期、异步刷新和统计等能力。官方 Caffeine 项目与用户指南 说明这些机制;Window TinyLFU 是实现的准入/驱逐策略,不是 JDK Map 的通用语义。
本地缓存省掉网络,也复制了进程状态
本地缓存把读取留在进程内,适合小而热、重建成本可控、允许实例间短暂不一致的数据。每个实例各有副本,滚动扩容会制造冷实例,配置或权限更新也不会自动广播。不要缓存请求对象、持有 classloader 的值或没有边界的租户集合;它们会把生命周期从一次调用延长到整个进程。
Caffeine 的 maximumSize 控制条目数,maximumWeight 配合 weigher 控制近似成本。权重在写入时计算,不会随可变对象内部增长自动更新,因此缓存值最好不可变。Window TinyLFU 结合频率与近期性做准入和驱逐,能抵抗一次性扫描污染,但它仍无法替应用判断某个值是否值得缓存。
expireAfterWrite 从写入计时,适合固定新鲜度;expireAfterAccess 让热数据续期,可能让业务上已经过期的错误值长期存活;refreshAfterWrite 在一次过期后的访问触发异步刷新,旧值通常仍可返回,它不是 TTL。把 refresh 和 expire 组合时要明确刷新失败后还能容忍旧值多久。
LoadingCache 的 get 能合并同 key 的并发加载,避免应用自己写 get-if-null-put 的竞态。loader 不应递归读取同一 key,也不应在持有其他资源时无限等待。异步缓存的 executor、队列、deadline 和异常缓存策略必须显式配置;公共 ForkJoinPool 被慢 I/O 占满会把本地优化变成全进程阻塞。
removal listener 适合释放缓存值独占资源或记录驱逐原因,但回调不能做无界阻塞工作。统计 recordStats 有采集成本,仍应开启于可控 cache 并按命中、加载、驱逐权重观察。手工 invalidate 只影响当前实例,跨实例一致性需要消息、版本检查或短 TTL 兜底。
TTL:过期时间是业务设计,不是随手写个 30 分钟
TTL 决定缓存的生命周期,也决定数据库回源压力和脏数据窗口。不要所有 key 都写同一个过期时间。
基础 TTL
SET shop:prod:config:pay-channel:v1 '{"enabled":true}' EX 300
TTL shop:prod:config:pay-channel:v1如果 TTL 返回 -1,说明 key 没有过期时间。缓存数据没有 TTL,通常是生产风险,除非你有明确的主动失效和巡检机制。
随机 TTL 防雪崩
大量 key 同时过期,会造成瞬时回源。应用侧可以给 TTL 加随机扰动:
long baseSeconds = 1800;
long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 300);
Duration ttl = Duration.ofSeconds(baseSeconds + jitterSeconds);常见坑是只给热点 key 加随机 TTL,普通 key 全部固定 30 分钟。实际雪崩往往来自批量预热、批量导入或发布后统一回填,所有 key 的过期时间非常接近。
空值 TTL 要短
缓存不存在数据可以防穿透,但空值 TTL 不能太长。
真实对象 TTL:30 分钟到 2 小时,按业务更新频率调整
空值 TTL:30 秒到 5 分钟,按误伤容忍度调整
热点保护 TTL:逻辑过期 + 异步刷新生产建议:TTL 要按缓存类型分层管理。配置、字典、详情、列表、排行榜、空值和热点数据不应该使用同一套过期策略。
过期字典:TTL 到点不等于立刻消失
给 key 设置过期时间以后,Redis 会把过期时间维护在过期索引里。开发侧最容易误解的一点是:TTL 到点不代表 key 在那个毫秒立刻从内存消失。Redis 会结合两条路径清理:
惰性删除:客户端访问 key 时发现已经过期,顺手删除并返回不存在。主动过期:后台周期性抽样检查带过期时间的 key,发现过期就删除。
最小实验:
SET shop:prod:tmp:expire-demo "1" EX 2
TTL shop:prod:tmp:expire-demo
GET shop:prod:tmp:expire-demo
INFO stats
CONFIG GET active-expire-effort验证结果:
TTL 倒计时到 -2 说明 key 不存在。INFO stats 里的 expired_keys 增长,说明过期删除发生过。如果 key 很少被访问,删除可能由主动过期周期完成;如果刚好被访问,就走惰性删除。
active-expire-effort 调高后要观察 CPU、延迟和 expired_keys,不能只看内存下降。
常见坑:
业务把“TTL 到期”当成精确调度器,结果延迟删除导致判断偏差。大批量 key 使用相同 TTL,主动过期和回源压力叠在一起。只看 GET 结果,不看 expired_keys、evicted_keys 和命中率,排查时分不清过期和淘汰。
生产建议:TTL 是缓存生命周期,不是业务定时任务。需要精确触发的场景,用任务调度、延迟队列或可靠 MQ,不要指望 Redis 过期事件承担强语义。
淘汰策略:内存满了以后谁先走
TTL 解决“业务上什么时候可以失效”,淘汰策略解决“内存超过上限时 Redis 怎么自救”。这两个概念不能混在一起。
淘汰也不是全局精确排序。LRU、LFU、TTL 类策略都会在候选 key 中采样,maxmemory-samples 越大,近似结果越接近理想淘汰,但 CPU 成本也越高。开发侧要记住:淘汰策略是实例自救,不是业务容量规划。
先看当前配置:
CONFIG GET maxmemory
CONFIG GET maxmemory-policy
CONFIG GET maxmemory-samples
INFO memory
INFO stats常见策略怎么选:
| 策略 | 行为 | 开发侧适用判断 |
|---|---|---|
noeviction | 内存超过上限后,可能写入失败 | 不能接受缓存悄悄丢 key,愿意让写入显式失败 |
allkeys-lru | 从所有 key 里近似淘汰最近最少使用 | 纯缓存实例常见默认选择,要求数据都可回源 |
allkeys-lfu | 从所有 key 里近似淘汰低频访问 | 访问频率差异明显的缓存 |
volatile-lru | 只淘汰设置了 TTL 的 key | 同实例里混有不该淘汰的 key,但更推荐拆实例 |
volatile-ttl | 优先淘汰 TTL 更短的 key | 业务能用 TTL 表达优先级 |
allkeys-random | 随机淘汰 | key 访问分布接近均匀,或临时压测验证 |
验证是否正在淘汰:
INFO stats
INFO memory重点看:
写命令是否因为 noeviction 被拒绝。maxmemory-samples 是否被改过,改动前后 P99 和 CPU 是否有对照。
生产建议:不要把永久数据和缓存数据塞在同一个 Redis 实例里再靠 volatile-* 策略“精细治理”。能拆实例就拆实例,不能拆时至少保证所有缓存 key 都有 TTL,并且明确哪些 key 被淘汰后能回源、哪些不能。
用状态模型验证缓存窗口
两个 Java 17 模型不连接真实 Redis,也不复刻 Caffeine 内部结构。它们把本篇必须守住的状态、版本和容量不变量变成确定输出;真实客户端与 Redis 集成测试继续验证协议、超时、拓扑和服务器行为。
javac --release 17 -Xlint:all -Werror examples/backend-development/cache/cache-models-local/WeightedEvictionDemo.java examples/backend-development/cache/cache-models-local/SingleFlightLoadDemo.java
java -cp examples/backend-development/cache/cache-models-local WeightedEvictionDemo
java -cp examples/backend-development/cache/cache-models-local SingleFlightLoadDemomaximumWeight=6 retained=[hot] currentWeight=3 scanRejected=true
concurrentMisses=8 loaderCalls=1 sharedValue=v7缓存正确性不是“命中返回值”,而是命中值仍在允许的陈旧窗口内、加载不会放大到权威存储、旧版本不能覆盖新版本、失败能够在预算内退化。模型输出任一项改变,都应触发对应集成用例,而不是只调整命中率告警。
命中率之外还要看放大率和陈旧年龄
把缓存当可丢失的派生状态验收
缓存可以丢,权威数据和业务不变量不能丢。单元模型固定版本与状态迁移,真实 Redis/Caffeine 测试固定过期、驱逐、原子命令和异常行为,数据库集成测试固定回源与更新顺序,压测固定热点和冷启动放大。变更前后比较的不是单一命中率,而是端到端延迟、权威存储负载、陈旧窗口、内存与失败恢复曲线。
