
Redis 两级缓存 Pub/Sub 广播一次为抗高并发做的缓存改造一、背景为什么会有这次改动业务高峰期相关接口 QPS 很高而系统里几乎所有读操作都要过一遍 Redis导致Redis 频繁被打挂。问题的本质是读流量全部压在 Redis 上。而系统里有大量热点、低频变化的数据——配置、模板信息、角色权限、年度列表等——这些数据几乎每个请求都要读但几天才改一次。于是改造思路很自然在前端进程里再加一层本地内存缓存把绝大部分读请求拦在本地只有少量请求真正落到 Redis。这就是两级缓存。二、核心概念什么是两级缓存本地缓存又是什么本地缓存 当前 JVM 进程自己堆内存里的缓存不是 Redis。本项目用 Google Guava Cache 实现privateCacheString,ObjectlocalCacheCacheBuilder.newBuilder().maximumSize(10000)// 最多缓存 1 万个 key.expireAfterWrite(300,TimeUnit.SECONDS)// 写入后 5 分钟过期.build();它和 Redis 的区别很关键本地缓存L1RedisL2位置JVM 堆内存独立进程范围单个进程 / 单节点私有跨进程、跨节点共享速度纳秒级无网络毫秒级走网络一致性多节点之间天然不一致全局唯一数据源容量受 JVM 堆限制这里限 10000 key可很大合起来就是两级L1 本地缓存最快但每个节点各一份彼此看不见L2 Redis慢一点但是跨节点共享的唯一数据源三、读写是怎么组合两级的以MyCacheManage/TwoLevelCache为例。读先本地未命中再 Redis然后回填本地publicObjectget(Objectkey){StringfullKeyprefixkey.toString();// 1. 先查本地缓存命中就返回完全不碰 RedisObjectlocalValuelocalCache.getIfPresent(fullKey);if(localValue!null){returndeepCopy(localValue);}// 2. 本地未命中才查 RedisSimpleValueWrapperobj(SimpleValueWrapper)redisUtil.get(fullKey);Objectvalue(objnull?null:obj.get());// 3. 回填本地缓存下次就读本地了if(value!null){localCache.put(fullKey,deepCopy(value));}returnvalue;}写双写 Redis 本地publicvoidput(Objectkey,Objectvalue){StringfullKeyprefixkey.toString();redisUtil.put(fullKey,value);// 写 RedislocalCache.put(fullKey,deepCopy(value));// 写本地}删删 Redis 删本地 广播publicvoiddelKey(Stringkey){StringfullKeyprefixkey;redisUtil.evict(fullKey);// 1. 删 RedislocalCache.invalidate(fullKey);// 2. 删自己节点的本地publishInvalidation(DEL:fullKey);// 3. 广播让别的节点也删本地}一个细节为什么要deepCopy每次读/写都做一次 Java 序列化深拷贝privateObjectdeepCopy(Objectobj){ByteArrayOutputStreambosnewByteArrayOutputStream();ObjectOutputStreamoosnewObjectOutputStream(bos);oos.writeObject(obj);// ... 再反序列化回来}目的是让返回给调用方的对象和缓存里存的对象互相独立防止调用方改了返回值把缓存污染了。同时它和 Redis 反序列化的行为保持一致。代价是每次读多做一次序列化属于用 CPU 换安全。四、关键问题多节点的本地缓存怎么保持一致本地缓存是每个节点私有的这就带来一个大问题节点 A 更新/删除了数据节点 B 的本地缓存里还是旧值B 会一直返回脏数据。解决办法就是本文的重点Redis 发布订阅Pub/Sub广播失效。4.1 设计思路把 Redis 当作消息总线不是当数据同步用某节点删缓存 → 通过 Redis 发一条这个 key 失效了的消息 ↓ 所有节点都订阅了这个频道都能收到 ↓ 各节点收到后把自己本地的对应 key 删掉4.2 发布端任何一次删缓存都会往固定频道cache_invalidation发消息privatestaticfinalStringINVALIDATION_CHANNELcache_invalidation;privatevoidpublishInvalidation(Stringmessage){redisUtil.getRedisTemplate().convertAndSend(INVALIDATION_CHANNEL,message);}消息有三种格式DEL:key—— 删除单个 keyDELLIKE:前缀—— 按前缀批量删除CLEAR:ALL—— 清空全部4.3 订阅端根据前缀处理publicvoidhandleInvalidationMessage(Stringmessage){if(message.startsWith(DEL:)){Stringkeymessage.substring(4);localCache.invalidate(key);// 删单个}elseif(message.startsWith(DELLIKE:)){StringkeyPrefixmessage.substring(8);invalidateLocalCacheByPrefix(keyPrefix);// 按前缀批量删}elseif(CLEAR:ALL.equals(message)){localCache.invalidateAll();// 清空}}五、spring-redis.xml配置逐项讲解这段配置就是让订阅端真正能收到广播的关键。之所以配了就能监听到是因为RedisMessageListenerContainer在 Spring 启动时会主动建立一条连接去SUBSCRIBE cache_invalidation然后阻塞等待推送。!-- Redis Pub/Sub: 集群间本地缓存失效通知 --!-- 1. 监听适配器把普通 POJO 方法包装成 Redis 消息监听器 --beanidcacheMessageListenerclassorg.springframework.data.redis.listener.adapter.MessageListenerAdapterconstructor-argreftwoLevelCache/!-- 目标对象 --propertynamedefaultListenerMethodvaluehandleInvalidationMessage/!-- 收到消息调哪个方法 --propertynameserializerbeanclassorg.springframework.data.redis.serializer.StringRedisSerializer//property/beanbeanidmyCacheMessageListenerclassorg.springframework.data.redis.listener.adapter.MessageListenerAdapterconstructor-argrefmyCacheManage/propertynamedefaultListenerMethodvaluehandleInvalidationMessage/propertynameserializerbeanclassorg.springframework.data.redis.serializer.StringRedisSerializer//property/bean!-- 2. 监听容器真正干活的那个负责订阅连接和消息分发 --beanidredisMessageListenerContainerclassorg.springframework.data.redis.listener.RedisMessageListenerContainerpropertynameconnectionFactoryrefconnectionFactory/propertynamemessageListenersmapentrykey-refcacheMessageListener!-- key 监听器 --listbeanclassorg.springframework.data.redis.listener.ChannelTopicconstructor-argvaluecache_invalidation/!-- value 订阅的频道 --/bean/list/entryentrykey-refmyCacheMessageListenerlistbeanclassorg.springframework.data.redis.listener.ChannelTopicconstructor-argvaluecache_invalidation//bean/list/entry/map/property/bean各标签含义配置含义MessageListenerAdapter把普通 Java 方法适配成 Redis 消息监听器内部用反射调用constructor-arg reftwoLevelCache委托的目标对象消息来了就调用它上面的方法defaultListenerMethod收到消息时调用的方法名消息体作为参数传入serializer StringRedisSerializer把 Redis 消息的字节反序列化成 String必须与发布端一致RedisMessageListenerContainer监听容器启动时建立订阅连接、后台线程接收并分发消息connectionFactory用哪套 Redis 连接map/entry监听器 → 它订阅的频道列表的映射ChannelTopic频道名这里统一是cache_invalidation生效链路应用启动 → Spring 实例化 redisMessageListenerContainer → 容器发 SUBSCRIBE cache_invalidation阻塞等待 ↓ 任一节点 delKey → convertAndSend(cache_invalidation, DEL:xxx) ↓ Redis 把消息推给所有订阅者 ↓ 容器收到 → 分发到 adapter → 反射调用 handleInvalidationMessage(String) ↓ 各节点 localCache.invalidate(...)六、把几个容易搞混的点澄清6.1 广播的方向不是删 Redis 通知本地也不是删本地同步 RedisdelKey里三个动作是并列执行的redisUtil.evict(fullKey);// 删 Redis共享的那份localCache.invalidate(fullKey);// 删自己节点的本地publishInvalidation(DEL:fullKey);// 广播让别的节点删它们的本地所以准确的说法是L1本节点失效 → 通过 Redis Pub/Sub 当总线 → 其他节点的 L1 失效Redis 在这里是消息总线不是被同步的数据对象广播的消息内容是失效通知不是数据本身。两个补充点发布者自己也会收到广播Pub/Sub 会推给所有订阅者包括自己会再 invalidate 一次自己的 key重复但无害。广播是异步、尽力而为的异常被吞掉加上本地 5 分钟 TTL 兜底所以是最终一致最坏情况某个节点脏最多 5 分钟。6.2 进程内有效和考生请求的对应关系一个 Tomcat 实例 一个 JVM 一个进程所有考生的请求都由这个进程里的不同线程处理。所以本地缓存不是按考生分的也不是按请求分的它是整个 JVM 共享的一个 Map。考生 A 首次请求加载了某个 key之后任何考生打到同一节点都能命中本地缓存。Guava Cache 内部是分段并发结构多线程并发读写安全、锁竞争低。效率高不高取决于 key 是不是热点共享数据考试配置、模板、权限这类与考生无关的热点 key→ 收益极大Redis 的 GET 量能降几个数量级。如果 key 里带了考生 id每人一份 →完全不能共享还容易撑满maximumSize(10000)互相顶掉收益很低。另一个限制本地缓存只在单节点内共享。多节点部署时每台各预热一份所以 N 个节点 N 次 Redis 读取——这正是需要广播失效的原因。6.3defaultListenerMethod如果遇到同名方法怎么办Java 不允许同一个类里存在两个签名完全相同的方法所以完全同名同参不可能存在编译就过不了。会出现的是重载同名、参数不同。MessageListenerAdapter用反射找方法匹配条件是方法名相同、参数个数相同、参数类型可赋值。消息经StringRedisSerializer反序列化后参数是[String]因此只有能接受 String或 Object / CharSequence 等父类型的重载才会被匹配。如果两个重载都能接受 String如xxx(String)与xxx(Object)它会取反射遍历到的第一个匹配项结果不可预测。结论监听方法名最好保持唯一避免产生歧义的重载。6.4 删缓存的触发点不只是那个管理页面delRedisKey.jspdeleteRedisKey只是给运维/应急用的手动入口而且限部级超级用户。真正绝大多数的删缓存是业务代码自动触发的调用点有 80 多处例如控制器SysCfgController几十处、ListExamController、ConfirmManangeAction、ImportGlobalTemplateController、LoginController等定时任务AutoScoreOpenTask成绩开放时清examlist_score_*、AutoStatShdJfTask注解式CacheEvict如ClManageServieImpl、SystemInfoServiceImp配合Cacheable使用即只要有代码路径调用delKey/delKeyLike/clear或被CacheEvict命中就会发广播。⚠️ 一个值得注意的设计缺口put只写 Redis 自己的本地不发广播。所以某节点新写的数据其他节点的本地旧值要等 5 分钟 TTL 自然过期才会更新。删除才广播写入不广播——所以本系统里写入通常都配了显式delKey才没事。6.5 5 分钟 TTL 到底是什么意思这里其实有两套完全独立的 TTL1本地缓存 TTL —— GuavaexpireAfterWrite(300s)只做一件事在它所属的那一个 JVM 内存里静默删掉这个条目。不发广播没有配RemovalListener过期不触发任何回调。不删 Redis。下次读该 key本地未命中 → 重新去 Redis 拉一次、回填本地。2Redis 数据的 TTL由RedisUtil.put设置liveTime redis.expiresDays × 3600 × 24当前配置redis.expiresDays1→1 天putWithExpire可单独指定。这才是数据在 Redis 里的真实生存时间。对比表动作本地缓存Redis发广播主动delKey/clear删删是本地 5 分钟到期删不动否一句话5 分钟 TTL 的过期是本地单方面的静默清理既不发广播也不删 Redis它只是把数据最坏能脏多久限制在 5 分钟内广播只在显式删除/清空时才发。七、今天的问题清单问答回顾Q1这是为了解决高并发时 Redis 挂掉做的改进本地缓存是怎么给 Redis 分担压力的A核心是读请求大部分不碰 Redis。热数据在 5 分钟有效期内所有读都命中本地堆内存Redis 的 GET 量从每次读一次降到每节点每 5 分钟最多一次。Redis 挂了的时候本地命中的读依然正常只有本地未命中或写操作才受影响。Q2本地缓存是进程内有效那高 QPS 下考生首次请求落到当前进程的缓存还能给谁共用效率高吗A一个 JVM 就是一个进程所有考生的请求都在这个进程里所以本地缓存是全 JVM 共享的A 考生预热后任何考生都能用。对热点共享 key效率极高对带考生 id 的个性化 key几乎没用。局限是只在单节点内共享多节点需要广播来保持一致。Q3defaultListenerMethod指定的方法名如果有两个同名方法呢AJava 不允许两个签名完全相同的方法重载会被反射按参数个数 参数类型可赋值筛选。若多个重载都能接受 String 参数取第一个匹配项、结果不可预测。所以监听方法名应保持唯一。Q4广播只在删/清缓存时发这个删除是页面上部级超级管理员操作的吗A不是。那个页面只是手动应急入口。绝大多数删缓存是业务代码自动触发的控制器、定时任务、CacheEvict注解调用点 80 多处。Q55 分钟 TTL 是什么意思本地缓存过期时会自动发广播并删除 Redis 吗A不会。本地 TTL 过期只是本地静默清理不发广播、不删 Redis。Redis 数据有自己独立的 TTL当前 1 天。广播只在显式delKey/clear时触发。八、总结两级缓存 Guava 进程内本地缓存L1 Redis 共享缓存L2核心目的是用本地缓存扛住读流量给 Redis 减压。本地缓存是单节点私有的因此天然面临多节点不一致问题。广播失效用 Redis Pub/Sub 当消息总线解决多节点一致任何节点删缓存时向cache_invalidation频道发消息所有节点收到后清掉自己的本地缓存。广播只在删除/清空时发写入不广播方向是本节点 L1 失效 → 通知其他节点 L1 失效不是数据同步。本地 5 分钟 TTL和Redis 1 天 TTL是两套独立机制前者过期不发广播、不删 Redis只用于限定最大脏数据时间窗口兜底最终一致性。