引言:一个看似简单的缓存配置,为何“失灵”了?
在微服务架构中,缓存是提升性能、降低数据库压力的利器。Google 开源的 Caffeine 凭借高性能、易用性,成为 Java 生态中最受欢迎的本地缓存库之一。
但最近,一位同事向我求助:
“我明明设置了 .refreshAfterWrite(5, TimeUnit.MINUTES),为什么数据没有每 5 分钟自动刷新?”
这个问题看似简单,实则触及了 Caffeine 设计哲学的核心—— “懒加载 + 按需刷新”。今天,我们就从 源码剖析 + 实战案例 + 正确姿势 三个维度,彻底揭开 refreshAfterWrite 的神秘面纱!
一、问题复现:你以为的“定时刷新”,其实是“条件刷新”
1.1 常见错误写法
private final LoadingCache<String, ApiResultDO<BehaviorReqAnalysisListResp>> cache =
Caffeine.newBuilder()
.maximumSize(10)
.refreshAfterWrite(5, TimeUnit.MINUTES) // ⚠️ 关键配置
.build(key -> {
log.info(“LoadingCache:{}“, key);
return LoadingCache(key); // 耗时远程调用
});
开发者期望:每 5 分钟自动重新加载数据,保持缓存新鲜。
实际行为:只要没人访问这个 key,数据永远不会刷新!
1.2 真实案例:用户画像缓存“冻结”
某电商系统使用 Caffeine 缓存用户行为分析结果(如“近7天点击商品数”)。
配置了 refreshAfterWrite(5, MINUTES),希望每 5 分钟更新一次用户画像。
结果:
- 活跃用户的数据能及时更新(因为频繁访问)。
- 沉默用户(如30分钟未登录)的数据永远停留在第一次加载的状态!
- 运营活动基于过期数据推送,导致转化率暴跌。
根本原因:开发者误以为 refreshAfterWrite 是“定时任务”,而它其实是“触发式刷新”。
二、源码深挖:Caffeine 到底如何实现 refreshAfterWrite?
我们以 Caffeine 3.x 源码为基础(GitHub - ben-manes/caffeine),追踪关键路径。
2.1 缓存读取入口:LocalCache.get(K key)
当调用 cache.get(“key”) 时,最终会进入 BoundedLocalCache 的 computeIfAbsent 方法。
关键逻辑如下(简化版):
V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
Node<K, V> node = data.get(key);
if (node != null) {
// 检查是否需要刷新
if (refreshAfterWrite() && isExpiredForRefresh(node)) {
// 异步刷新(不阻塞当前线程)
scheduleRefresh(node, key, ...);
}
return node.getValue(); // ⚡️ 先返回旧值!
}
// 首次加载...
}
2.2 刷新判断:isExpiredForRefresh(Node)
boolean isExpiredForRefresh(Node<?, ?> node) {
long now = ticker.read();
return (now - node.getWriteTime()) >= refreshAfterWriteNanos;
}
重点:
- 刷新检查 只在
get() 时发生。
- 如果
node == null(即从未访问过或已驱逐),根本不会进入此逻辑!
2.3 异步刷新:scheduleRefresh(...)
void scheduleRefresh(Node<K, V> node, K key, ...) {
executor.execute(() -> {
try {
V newValue = loader.apply(key); // 调用你的 build() 中的 lambda
if (newValue != null) {
put(key, newValue); // 更新缓存,重置 writeTime
}
} catch (Exception e) {
logger.warn(“Refresh failed for key: “ + key, e);
// 默认保留旧值,不删除!
}
});
}
结论:
- 刷新是异步的 → 不阻塞主线程。
- 刷新依赖 get 触发 → 无访问=无刷新。
- 刷新失败保留旧值 → 避免缓存穿透。
三、三大缓存策略对比:别再混淆 expire 和 refresh!
| 策略 |
方法 |
是否主动刷新 |
是否返回旧值 |
适用场景 |
| 写后过期 |
expireAfterWrite(5, MINUTES) |
否 |
否(下次 get 会阻塞重载) |
数据强一致性要求高 |
| 读后过期 |
expireAfterAccess(5, MINUTES) |
否 |
否 |
会话类缓存(如 token) |
| 写后刷新 |
refreshAfterWrite(5, MINUTES) |
否(但异步) |
是(先返回旧值) |
容忍短暂不一致,追求低延迟 |
💡 记住:Caffeine 没有任何策略会启动后台线程主动刷新未访问的 key!
四、正确姿势:如何实现“真正的定时刷新”?
既然 Caffeine 本身不支持主动刷新,我们就“曲线救国”!
方案 1:定期 get() 触发刷新(推荐)
@Component
public class CacheRefresher {
private final LoadingCache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.build(this::loadData);
private final List<String> MONITORED_KEYS = Arrays.asList(“user_1001“, “user_1002“);
@PostConstruct
public void startRefresher() {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
() -> MONITORED_KEYS.forEach(cache::get), // 触发 refresh 检查
0,
4, // 间隔 < 5 分钟,确保及时
TimeUnit.MINUTES
);
}
private String loadData(String key) {
log.info(“Loading data for key: {}“, key);
return fetchDataFromRemote(key);
}
}
优点:
- 利用 Caffeine 原生机制,安全可靠。
- 返回旧值期间,用户体验无感知。
方案 2:直接调用 cache.refresh(key)
// 在定时任务中
MONITORED_KEYS.forEach(cache::refresh);
区别:
refresh()不返回值,纯粹触发异步加载。
- 不会因缓存未命中而同步加载(更轻量)。
方案 3:组合策略 —— refresh + expire 双保险
Caffeine.newBuilder()
.refreshAfterWrite(5, TimeUnit.MINUTES) // 5分钟后可异步刷新
.expireAfterWrite(10, TimeUnit.MINUTES) // 10分钟后强制过期
.build(...);
行为:
- 5~10 分钟:访问时返回旧值 + 后台刷新。
- > 10 分钟:数据真正过期,下次访问同步重载。
适用于:既想低延迟,又怕数据无限陈旧的场景。
五、避坑指南:常见误区与最佳实践
误区 1:“refreshAfterWrite = 定时任务”
纠正:它是“访问触发的异步刷新”,不是 cron job!
误区 2:“不设置 expire,数据会永久缓存”
纠正:maximumSize 会通过 LRU 驱逐冷数据!长期不访问的 key 会被淘汰。
最佳实践 1:关键数据必须主动维护
对业务核心 key(如 VIP 用户),务必用定时任务保活。
最佳实践 2:监控刷新日志
.build(key -> {
long start = System.currentTimeMillis();
ApiResultDO<?> result = loadHuangoAppData(key);
log.info(“Loaded cache for {} in {}ms“, key, System.currentTimeMillis() - start);
return result;
});
最佳实践 3:处理刷新异常
// 自定义 executor 捕获异常
.executor(Runnable::run) // 或提交到带监控的线程池
六、结语:理解设计哲学,才能用好工具
Caffeine 的 refreshAfterWrite 不是一个 bug,而是一种精妙的设计权衡,关于缓存策略的更深入讨论可以参考相关设计文档:
“只为需要的数据付出刷新成本” —— 这正是它高性能的秘诀!
作为开发者,我们必须:
- 认清机制本质:刷新 = 访问 + 超时。
- 主动管理关键数据:用定时任务“唤醒”沉默的缓存。
- 监控 + 日志:让缓存行为可观测。
下次当你再配置 refreshAfterWrite 时,请记住:它不会自己动,得你去“碰”它一下! 更多关于数据库与中间件的实战技巧和案例,欢迎在云栈社区交流探讨。对于框架的深入理解,仔细阅读官方文档总是最可靠的第一步。