JetCache 本地+远程缓存一致性完整指南
一、前置基础:JetCache 多级缓存模型
要理解一致性问题,首先要明确 JetCacheBOTH模式的底层结构:它是两级缓存组合(MultiLevelCache),并非单一存储:
- L1(本地缓存):基于 Caffeine 或 LinkedHashMap,存储在当前 JVM 堆内存中,读写无需网络开销,性能极高,但仅对当前 JVM 实例可见。
- L2(远程缓存):基于 Redis,存储在远端服务器,是所有 JVM 实例共享的数据源,读写需要网络 IO,但能保证多节点间的数据基准一致。
默认读写逻辑:
- 读:优先查 L1 → L1 命中直接返回 → L1 未命中查 L2 → 回填 L1 后返回。
- 写/删:先操作 L2 → 再操作 L1,保证共享数据源优先更新。
二、核心总览:不一致的本质与场景差异
JetCache 的不一致问题不是框架 Bug,而是分布式系统 CAP 理论的必然取舍,不同部署场景的一致性级别完全不同:
| 部署场景 | 一致性级别 | 核心风险 | 解决方案 |
|---|---|---|---|
| 多节点集群 | 最终一致(Eventual Consistency) | 跨 JVM 本地缓存无法感知变更、广播延迟/丢失 | 开广播失效 + 短本地 TTL + 核心场景弃用本地缓存 |
| 单节点单机 | 准强一致(Quasi-Strong Consistency) | 仅毫秒级瞬态窗口、配置错误(TTL 倒挂) | 严格遵守localExpire < remoteExpire |
| 强一致场景(库存/余额/价格) | 不满足 | 本地缓存副本导致脏读、并发操作原子性问题 | 纯远程缓存 + Lua 脚本 + 版本号校验 |
三、多节点集群:为什么必然存在不一致?
3.1 根本原因:本地缓存是跨 JVM 的「数据孤岛」
多节点部署时,每个 JVM 都有自己的独立 L1 缓存,彼此完全不可见:
- 请求打到节点 A,触发
@Cached,user:1=张三写入 A 的 L1 + Redis(L2)。 - 后台更新操作路由到节点 A,执行
@CacheInvalidate:A 清空自己的 L1 + 更新 Redis 为user:1=李四。 - 用户再次访问,请求被负载均衡到节点 B:B 的 L1 仍存在
user:1=张三,直接返回旧值。
此时Redis(L2)是正确的,但部分节点的 L1 是永久脏数据,直到本地 TTL 过期。
3.2 JetCache 的缓解方案:Redis Pub/Sub 广播失效
为解决跨节点失效问题,JetCache 提供了syncLocal + broadcastChannel机制:
- 当任意节点执行
put/remove/update时,除了操作 Redis,还会通过Redis Pub/Sub发送一条缓存失效消息。 - 集群内所有节点订阅该 Channel,收到消息后立即删除本地对应 Key 的缓存。
⚠️ 但该方案仍然是最终一致,无法消除不一致窗口,原因如下:
- 网络延迟:消息从发送到接收存在几十~几百毫秒的延迟,这段时间内其他节点仍会命中旧值。
- 消息丢失风险:Redis Pub/Sub 不保证消息持久化,连接闪断、Redis 主从切换都会导致消息丢失;如果自定义了 Redisson 编解码器(如
JsonJacksonCodec),还会导致消息反序列化失败,等于失效通知完全失效。 - 消费延迟:JVM 处理订阅消息需要排队,高负载下可能出现秒级延迟。
3.3 多节点下必踩的 5 个坑
- TTL 倒挂:
localExpire(本地 TTL)>remoteExpire(Redis TTL)。Redis 数据过期后,本地缓存成了无源之水,永远无法更新,直到本地 TTL 到期。 - Key 定义不一致:
@Cached、@CacheInvalidate、@CacheUpdate的area/name/key不匹配(比如 SpEL 表达式写法不同),导致清了错误的 Key 或没清到。 - AOP 失效:缓存注解用在 private 方法、同类方法内部调用、
@Async/@Transactional异常传播场景,导致注解未被 Spring AOP 拦截,缓存未清理。 - 广播配置遗漏:未配置
broadcastChannel或syncLocal=false,等于完全没开跨节点失效。 - 非标准更新路径:直接改 DB 不碰缓存、只改 Redis 不改 DB,导致缓存与 DB 基准都不一致。
四、单节点单机:为什么基本一致?
单节点部署时,最大的「跨 JVM 通信」障碍消失,JetCache 可以在同一个 JVM 内完美协调 L1 和 L2 的操作,不存在物理数据不一致。
4.1 核心保障:JVM 内的同步操作顺序
JetCache 的所有缓存操作都是同步顺序执行,没有并发隔离问题:
- 写操作:先写 L2(Redis)→ 再写 L1(本地),保证共享数据源优先更新,本地只是镜像。
- 删操作:先删 L2 → 再删 L1,保证外部请求先无法读到旧值,再清理内部副本。
- 读操作:先查 L1 → 未命中查 L2 → 回填 L1,保证 L1 永远是 L2 的最新镜像。
4.2 仅存的瞬态不一致场景(可自愈,不影响业务)
单节点下唯一可能出现的不一致是毫秒级瞬态窗口,不会造成永久脏数据:
- JVM 崩溃中断:刚写完 L2、还没更新 L1 时服务宕机,重启后 L1 随 JVM 销毁,下次读取会从 L2 加载新值,完全自愈。
- 并发竞态:线程 A 执行删除,已删 L2 但未删 L1 时,线程 B 抢先读了 L1 旧值,仅持续毫秒级,绝大多数业务可忽略。
- 配置错误(唯一人为风险):
localExpire > remoteExpire,导致 Redis 过期后本地仍返回旧值,这是单节点下唯一会造成永久脏数据的情况,严格遵守localExpire < remoteExpire即可完全避免。
五、强一致要求场景的定制方案(核心新增)
如果业务是高并发库存扣减、实时价格/账户余额、权限信息等对脏读零容忍的场景,默认的 BOTH 模式完全不满足要求,必须定制方案:
5.1 场景一:高并发库存扣减/优惠券核销
特点:并发极高、不允许超卖、不允许脏读,性能和安全同等重要。
❌ 禁用方案:默认 BOTH 模式、本地缓存存库存值、非原子性更新。
✅ 落地方案:纯 REMOTE 模式 + Lua 原子脚本 + 双删兜底
- 弃用本地缓存:缓存类型改为
type=REMOTE,所有读都走 Redis,从根源上消除本地副本不一致问题。 - 原子性扣减:库存扣减必须用 Redis Lua 脚本,保证「查询库存→校验库存→扣减库存」的原子性,避免并发超卖:
-- 库存扣减Lua脚本(KEYS[1]为库存key,ARGV[1]为扣减数量)if(redis.call('exists',KEYS[1])==1)thenlocalstock=tonumber(redis.call('get',KEYS[1]));if(stock>=tonumber(ARGV[1]))thenredis.call('decrby',KEYS[1],ARGV[1]);return1;-- 扣减成功endendreturn0;-- 库存不足- 分布式锁兜底:Lua 脚本执行失败时,用 Redisson 分布式锁串行化处理,防止缓存击穿打爆 DB。
- 写后双删:更新库存时走「先更 DB → 再删 Redis → 延迟 1s 再删一次」的旁路模式,应对并发读写导致的脏数据写回问题。
5.2 场景二:实时价格/账户余额/权限信息
特点:对数据准确性要求极高,但读 QPS 也很大,完全不用本地缓存会压垮 Redis。
✅ 折中方案:本地缓存 + 版本号校验 + 短 TTL
- 缓存结构调整:把缓存值改为
{data: 实际业务数据, version: 递增版本号},版本号随数据更新单调递增。 - 读流程:
① 先查本地 L1,拿到{data, version};
② 同步/异步查 Redis 中该 Key 的最新 version;
③ 若本地 version ≥ Redis version,直接返回本地 data;否则从 Redis 加载最新数据回填 L1。 - 写流程:
① 更新 DB,生成新 version;
② 更新 Redis 中的{data, newVersion};
③ 开syncLocal=true + broadcastChannel,广播清所有节点的 L1。 - 兜底:本地 TTL 设为 1~5s,即使版本号校验失效,最多 5s 后自动过期加载新值。
✅ 增强方案:把 version 单独存一个 Redis Key,仅用来做一致性校验,减少数据传输量。
5.3 场景三:普通低频配置/非核心数据
特点:允许秒级不一致,读 QPS 高,追求性价比。
✅ 方案:BOTH 模式 + syncLocal + 短 localExpire
直接用 JetCache 默认多级缓存,localExpire设为 1~5s,remoteExpire设为 30~60s,开广播失效即可,无需额外开发。
六、生产级配置与最佳实践
6.1 完整 jetcache.yml 配置模板(可直接抄)
jetcache:statIntervalMinutes:15# 统计间隔,输出缓存命中率日志,用于监控area:default:# -------------------------- 多节点集群通用配置 --------------------------type:both# 本地+远程多级缓存local:type:caffeine# 本地缓存类型,Caffeine性能最优limit:10000# 最大缓存条目数,防止OOMexpireAfterWriteInMillis:2000# 本地TTL=2秒,远小于远程TTLsyncLocal:true# 开跨节点本地缓存失效remote:type:redis.lettuce# 推荐Lettuce,响应式支持更好,也可选redissonkeyConvertor:fastjson# 不要用自定义编解码器!避免广播消息反序列化失败expireAfterWriteInMillis:30000# 远程TTL=30秒broadcastChannel:jetcache_invalidate_channel# 广播失效通道,必须配# Redis连接配置host:127.0.0.1port:6379password:database:0poolConfig:minIdle:5maxTotal:20# -------------------------- 强一致场景(库存/余额)配置 --------------------------# type: remote # 只用远程缓存,完全禁用本地缓存# remote:# type: redis.lettuce# keyConvertor: fastjson# expireAfterWriteInMillis: 30000# # 强一致场景不需要广播配置,因为根本没有本地缓存6.2 注解使用规范(避免 90% 的坑)
@Cached、@CacheInvalidate、@CacheUpdate必须使用相同的area、name、key定义,Key 的 SpEL 表达式要完全一致,避免清错 Key。- 注解只能用在public 方法上,且不能通过同类方法内部调用(Spring AOP 生效要求);不要在
@Async、@Transactional(自定义超时/传播行为)的方法上使用缓存注解,避免事务未提交就清缓存。 - 高并发读场景加
@CachePenetrationProtect,防止缓存击穿:多个线程同时请求同一个 Key 时,只有一个线程去加载 DB,其余线程阻塞等待。 - 更新数据严格走「先更 DB,后清缓存」的标准旁路模式,禁止「先清缓存再更 DB」(避免并发下脏数据写回)。
6.3 TTL 配置铁律
- 永远保证:
localExpire < remoteExpire,本地 TTL 就是愿意容忍的最大不一致窗口。 - 强一致场景:localExpire 设为 1~5s,非核心场景最多不超过 10s。
- remoteExpire 根据业务更新频率设置:核心数据 30~60s,静态数据(如类目)可设 10~30min。
七、兜底与监控
- 监控缓存命中率:JetCache 每
statIntervalMinutes输出命中率日志,若本地命中率过高但远程命中率低,说明广播失效或 Key 定义不一致,需排查。 - 定时一致性校验:非核心数据可开定时任务,随机抽查本地缓存和 Redis 的值是否一致,不一致则主动清本地缓存。
- 降级预案:Redis 故障时,本地缓存可临时作为只读缓存,但要关闭写操作,避免脏数据扩散;Redis 恢复后,清空所有本地缓存重新加载。
- 异常告警:监控 broadcastChannel 的消息发送/接收成功率,低于 99.9% 触发告警,排查 Redis 连接或编解码器问题。
八、最终总结
- JetCache 的 BOTH 模式是「性能优先、最终一致」的设计,天生不适合库存、余额等强一致场景,不要强行使用。
- 多节点集群的不一致是分布式系统的必然结果,只能通过缩短 TTL、开广播来降低影响,无法完全消除。
- 单节点单机的不一致是可忽略的瞬态问题,只要配置正确就不会有永久脏数据。
- 强一致场景必须放弃本地缓存,或用版本号校验兜底,核心操作要靠 Redis Lua/分布式锁保证原子性。