Redisson RLocalCachedMap 事务缓存一致性完整排查指南
【免费下载链接】redissonRedisson: Valkey & Redis Java Client and Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, Deque, Semaphore, Lock, AtomicLong, Map Reduce, Bloom filter, Spring, Tomcat, Scheduler, JCache API, Hibernate, RPC, local cache..项目地址: https://gitcode.com/GitHub_Trending/re/redisson
凌晨 02:14,对账任务报警:优惠券ACT-618在 Redis 主节点剩余 12 张,而三台应用节点的RLocalCachedMap本地缓存仍读到 18。当晚的核销写入都走 Redisson 事务,读走本地缓存,问题指向事务提交与缓存失效通知之间的时间差。
现象长什么样
排查入口是让对账任务逐节点比对 Redis 主节点值与本地缓存值,再结合事务监控定位窗口期。典型症状有三条:
- 值差持续数秒:事务提交后,本地读出的库存高于主节点值(核销场景里"偏高"即未扣减),过一段时间自行收敛
- 节点漂移:同一 key 提交后,各节点本地值不一致,收敛时刻各不相同
- 命中率掩盖问题:本地缓存命中率很高,常规采样采不到偏差,往往靠对账或下游投诉暴露
WARN ReconcileJob - coupon=ACT-618 node=app-03 redis=12 local=18 delta=6 WARN ReconcileJob - coupon=ACT-618 node=app-05 redis=12 local=15 delta=3 INFO TxMonitor - tx=TX-9912 state=COMMIT_OK elapsed_ms=41机制拆解
先说事务侧。Redisson 事务把写入先缓存在客户端的事务对象中,提交时以 MULTI/EXEC 形式批量下发,隔离级别为READ_COMMITTED,详见事务管理官方说明。也就是说,EXEC 之前 Redis 里的 key 完全不变,写入在 EXEC 那一刻才落地。
再说缓存侧。RLocalCachedMap的一致性依赖另一条通道:服务端数据变化后,向各客户端实例广播失效或更新消息。本地缓存配置支持三种同步策略——INVALIDATE(删除对应条目,默认值)、UPDATE(广播完整键值)、NONE(不主动同步),定义见客户端缓存文档的本地缓存小节。
时间差由此产生:失效消息由服务端真实变更触发,而真实变更只发生在 EXEC 之后;消息从生成、投递到各实例处理,还要再走一个网络往返。窗口期内,读请求命中的仍是本地旧值。类似快递延迟——货先走了,通知后到。NONE策略连消息都没有;INVALIDATE只删条目不回写新值,窗口期读要么拿到旧值、要么回源,两种表现都不理想。
| 时刻 | 事务侧 | 本地缓存侧 |
|---|---|---|
| T0 | 写入缓存在事务对象,未落到 Redis | 缓存不动,读命中旧值 |
| T1 | EXEC 批量提交,主节点确认 | 仍持有提交前旧值 |
| T2 | commit 返回,写锁释放 | 服务端开始广播失效消息 |
| T3 | — | 消息在途,部分实例尚未收到 |
| T4 | — | 各实例逐条删除或覆盖条目,收敛完成 |
三条修复路径
路径一:调同步策略,让框架收尾
适用边界:读多写少、多节点部署、能接受完整键值广播的带宽开销。核心是把同步策略切到UPDATE,让各节点不必回源即可收敛;同时在事务层挂一个提交回调,提交成功后主动触发一次本地重算,覆盖消息投递窗口。
// 本地缓存选项:广播完整键值,各节点无需回源 LocalCachedMapOptions<String, Integer> cacheOptions = LocalCachedMapOptions.<String, Integer>defaults() .syncStrategy(SyncStrategy.UPDATE) .reconnectionStrategy(ReconnectionStrategy.CLEAR) .cacheSize(2048); RLocalCachedMap<String, Integer> stockCache = redisson.getLocalCachedMap("coupon:stock", cacheOptions); // 事务提交回调:commit 成功后主动触发一次本地重算 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { stockCache.recompute(); // 覆盖消息在途的窗口期 } });什么时候别用它:值对象很大或写频率极高时不要选它,UPDATE每次变更都要广播完整键值,带宽和内存放大明显。
路径二:提交后显式刷新,用代码补偿
适用边界:键数量少、更新频率低、不想动全局同步策略。做法是在事务外持有同名对象,commit 成功后立刻对该 key 调refresh从主节点回拉;单个键补偿不生效时,退化为清空整块本地缓存。
RTransaction tx = redisson.createTransaction(TransactionOptions.defaults()); RLocalCachedMap<String, Integer> txStock = tx.getLocalCachedMap("coupon:stock", LocalCachedMapOptions.<String, Integer>defaults().cacheSize(512)); // 事务内写入核销结果 txStock.put("ACT-618", 11); tx.commit(); // 事务外补偿本地缓存 RLocalCachedMap<String, Integer> stock = redisson.getLocalCachedMap("coupon:stock"); Boolean refreshed = stock.refresh("ACT-618"); if (!Boolean.TRUE.equals(refreshed)) { stock.clearLocalCache(); // 单键刷新未命中时整块清空 }什么时候别用它:提交率高且键多时别用,每次 commit 都追加补偿请求,提交 RT 会被明显抬上去。
路径三:值内嵌版本号,改造数据模型
适用边界:写并发极高、正确性优先、能接受值结构变更与乐观重试。把值组织成剩余:版本形式,写入时比较并交换旧值,版本已变就重试,保证更新不会被静默覆盖。
// 值布局 "剩余:版本",例如 "12:7" String key = "ACT-618"; RLocalCachedMap<String, String> stock = redisson.getLocalCachedMap("coupon:stock"); while (true) { String raw = stock.get(key); if (raw == null) { throw new IllegalStateException("券库存未初始化"); } String[] parts = raw.split(":"); int remaining = Integer.parseInt(parts[0]); long version = Long.parseLong(parts[1]); if (remaining <= 0) { throw new CouponExhaustedException(key); } // 比较并交换:旧值未变才替换,否则重试 if (stock.replace(key, raw, (remaining - 1) + ":" + (version + 1))) { return; } }什么时候别用它:多字段联动更新、或值本身无法自然版本化时别用它,重试循环会迅速复杂化且吞吐下降。
选型速查
| 路径 | 一致性上限 | 吞吐影响 | 改造成本 | 建议场景 |
|---|---|---|---|---|
| UPDATE + 提交回调 | 高,窗口内框架收敛 | 中,完整键值广播 | 低,改选项加回调 | 多节点读为主、写较稀疏 |
| 提交后显式刷新 | 中,单点补偿 | 略升,逐提交追加请求 | 低,业务代码局部改 | 键少、写频率低 |
| 值内嵌版本号 | 最高,写入级正确 | 最低,重试串行化 | 中,改模型和写链路 | 写并发极高、强正确 |
上线前验证清单
- ✅ 构造并发:测试环境起 4 个进程共享同一 map key,2 个跑事务写循环、2 个持续读并记录本地与主节点的差值
- ✅ 测窗口:每次 commit 后以 100ms 间隔采样本地值,记录差值归零耗时,对比
INVALIDATE与UPDATE的收敛曲线 - ✅ 验刷新:显式刷新路径提交后立即读本地值,断言与主节点一致
- ✅ 验版本:双线程并发替换同一 key,断言最终版本增量等于提交总数,确认无丢失更新
- ✅ 盯指标:缓存命中率、广播消息量、提交 RT p99,发布后观察一个完整核销高峰
一句话结论:事务提交与缓存收敛之间的窗口是物理约束,三条路径只是在这个窗口上做出的不同取舍——先测出窗口,再决定站在哪一层。
进阶阅读:
- 事务参数选项与隔离级别
- 客户端侧缓存原生实现
- Map 本地缓存与数据分区
【免费下载链接】redissonRedisson: Valkey & Redis Java Client and Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, Deque, Semaphore, Lock, AtomicLong, Map Reduce, Bloom filter, Spring, Tomcat, Scheduler, JCache API, Hibernate, RPC, local cache..项目地址: https://gitcode.com/GitHub_Trending/re/redisson
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考