Spring Boot整合Caffeine-集群本地缓存如何保证分布式一致性
问题现场: Caffeine 适合保存访问频繁、体积有限的本地数据,但每个应用实例都拥有自己的缓存副本。单机缓存一旦进入集群,更新问题就从一次 put 变成多个节点怎样同时失效。
只设置过期时间意味着窗口期内读取旧值;只依赖消息通知又要处理丢消息、节点重启和并发回填。企业级本地缓存必须明确一致性等级,而不是把"速度快"当成完整方案。
MetaLite 在本地缓存之外保留统一失效与二级缓存协作入口。本文先比较几种集群失效策略,再结合缓存封装说明它解决了哪些一致性问题、仍接受哪些短暂不一致。
一、本地缓存一致性问题从哪里产生
假设用户信息被缓存到两个实例:
text
A.local[user-1] = 旧数据
B.local[user-1] = 旧数据
更新请求落到 A:
java
@CacheEvict(cacheNames = "local", key = "#userId")
public void updateUser(String userId) {
// 更新数据库
}
Spring 调用 A 的 Caffeine Cache 执行 evict(userId)。如果没有额外机制,B 的本地内存不会发生任何变化。
TTL 最终可以让旧值过期,但在过期前仍然存在陈旧窗口。
二、第一步必须先删除当前实例
CaffeineCache.evict 当前先操作本地 native cache:
java
public void evict(Object key) {
this.cache.invalidate(key);
caffeineHelper.cacheEvict(this.cacheName, key);
}
这个顺序意味着更新请求所在实例立即失效,不等待网络通知完成。
随后 CaffeineHelper 记录缓存调用日志,并发起其他实例通知。
当前实例删除与远端通知不是一个分布式原子操作。设计目标是让本实例快速完成更新路径,再尽力缩短其他实例的陈旧窗口。
三、为什么通知使用同服务内部 HTTP
通知参数只有两个字段:
text
cacheName
cacheKey
框架将它们包装成 InternalBizParamReq<CacheEvictParam>,然后调用:
java
internalServiceClient.callAllInstance(
RpcRequest.builder()
.provider(EnvUtil.APPLICATION_NAME)
.endpoint("/api/cache/caffeine/evict")
.timeoutMillis(3000)
.param(internalReq)
.build(),
true
);
provider 使用当前应用名,表示只通知同一个服务的其他实例;true 表示忽略自身。
这种方式复用了已有能力:
- Nacos 服务发现;
- 内部请求协议;
- HttpClient 连接池和超时;
- RPC 调用日志。
它不需要为缓存通知额外引入 Redis Pub/Sub 或 MQ,但也继承了内部 HTTP 广播的可用性边界。
四、callAllInstance 实际上是顺序广播
Nacos 代理先调用 getAllInstances(provider) 获取实例列表,再逐个执行 HTTP 调用:
java
for (ServiceInstance instance : instances) {
rpcRequest.setProvider(instance.getApiPrefix());
callOneInstance(provider, rpcRequest, httpClient);
}
因此,当前广播是顺序 fan-out,不是并行请求。
实例数量较少时实现简单;实例很多时,总耗时可能累积。外层通知使用异步执行,不会让业务线程同步等待整个广播,但异步任务本身仍然逐个调用。
另一个边界是"忽略自身"当前只比较 IP。如果同一主机使用不同端口部署多个同名实例,同机其他实例也可能被一并过滤。
五、远端为什么不能再次调用普通 evict
其他实例收到内部接口:
text
POST /api/cache/caffeine/evict
CaffeineApi 将请求交给 CaffeineCacheManager.evictCaffeine。远端代码直接操作 native cache:
java
caffeineCache.getNativeCache()
.invalidate(cacheEvictParam.getCacheKey());
它没有再次调用 CaffeineCache.evict()。
如果远端也走普通 evict,B 收到 A 的通知后会再次广播给 A、C;C 又继续广播,最终形成通知风暴甚至循环。
"发起端走带广播的入口,接收端走纯本地失效入口"是这条链路最关键的职责分离。
六、为什么通知放到异步任务中
notifyCaffeineEvict 使用:
java
CompletableFuture.runAsync(() -> {
// 枚举并通知其他实例
});
业务请求完成本地删除后,不需要同步等待全部实例响应。这降低了缓存通知对更新接口延迟的影响。
但当前 runAsync 没有显式传入专用 Executor,因此使用的是默认异步执行设施,不能根据类注释宣传成"缓存通知一定使用专用线程池"。
异步也意味着:接口返回成功时,其他实例可能还没删除完成。
七、通知失败以后会发生什么
广播过程被 try/catch 包裹:
java
catch (Throwable throwable) {
log.warn("notifyCaffeineEvict failed ...");
}
当前实现没有:
- 自动重试;
- 失败实例列表持久化;
- MQ 补偿;
- 缓存版本号校验;
- 定时全量对账。
所以它不是强一致协议。
通知失败时,更新接口不会回滚,其他实例可能继续读取旧缓存,直到本地 TTL 到期、进程重启或后续同 Key 删除再次成功通知。
更准确的定位是:异步尽力通知,缩短最终一致窗口。
八、当前只覆盖单 Key 删除
evict(key) 和 evictIfPresent(key) 都会触发通知。
但 clear() 与 invalidate() 当前只执行:
java
cache.invalidateAll();
没有发现对应的跨实例全量清理广播。
因此,文章不能笼统声称"所有 Caffeine 清理操作都会自动同步集群"。当前设计覆盖的是单 Key 失效链路。
全量清理需要额外考虑接口保护、广播规模和误操作风险,不能简单复用单 Key 参数。
九、哪些数据适合这种策略
异步最终一致的本地缓存更适合:
- 允许短暂旧值的展示数据;
- 更新频率较低的配置;
- 有合理 TTL 兜底的数据;
- 读取性能价值明显的数据。
以下数据应更谨慎:
- 权限刚被撤销后必须立即生效;
- 账户余额、库存扣减等强一致数据;
- 安全黑名单和强制下线状态;
- 旧值会导致不可逆业务操作的数据。
这些场景可能需要共享缓存、版本校验、同步确认或直接绕过本地缓存。
十、本地缓存集群化的核心不是"发个通知"
完整设计必须回答:
- 当前实例何时删除?
- 怎样找到同服务的其他实例?
- 接收端怎样避免再次广播?
- 通知同步还是异步?
- 失败是否重试,陈旧窗口由什么兜底?
- 单 Key 与全量清理是否使用同一策略?
MetaLite 当前给出了一个轻量答案:本地先删、内部 HTTP 异步顺序广播、远端 native cache 失效、失败只记录警告。
把这些边界同时写清楚,比简单宣称"解决了多实例缓存一致性"更有工程价值。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026