Redis 两级缓存 + Pub/Sub 广播:一次为抗高并发做的缓存改造

Redis 两级缓存 + Pub/Sub 广播:一次为抗高并发做的缓存改造


一、背景:为什么会有这次改动

业务高峰期,相关接口 QPS 很高,而系统里几乎所有读操作都要过一遍 Redis,导致 Redis 频繁被打挂。

问题的本质是:读流量全部压在 Redis 上。而系统里有大量"热点、低频变化"的数据------配置、模板信息、角色权限、年度列表等------这些数据几乎每个请求都要读,但几天才改一次。

于是改造思路很自然:在前端进程里再加一层本地内存缓存,把绝大部分读请求拦在本地,只有少量请求真正落到 Redis。

这就是"两级缓存"。


二、核心概念:什么是"两级缓存"?本地缓存又是什么?

本地缓存 = 当前 JVM 进程自己堆内存里的缓存,不是 Redis。

本项目用 Google Guava Cache 实现:

java 复制代码
private Cache<String, Object> localCache = CacheBuilder.newBuilder()
        .maximumSize(10000)                          // 最多缓存 1 万个 key
        .expireAfterWrite(300, TimeUnit.SECONDS)     // 写入后 5 分钟过期
        .build();

它和 Redis 的区别很关键:

本地缓存(L1) Redis(L2)
位置 JVM 堆内存 独立进程
范围 单个进程 / 单节点私有 跨进程、跨节点共享
速度 纳秒级(无网络) 毫秒级(走网络)
一致性 多节点之间天然不一致 全局唯一数据源
容量 受 JVM 堆限制(这里限 10000 key) 可很大

合起来就是两级:

  • L1 本地缓存:最快,但每个节点各一份,彼此看不见
  • L2 Redis:慢一点,但是跨节点共享的唯一数据源

三、读写是怎么组合两级的

以 MyCacheManage / TwoLevelCache 为例。

读:先本地,未命中再 Redis,然后回填本地

java 复制代码
public Object get(Object key) {
    String fullKey = prefix + key.toString();

    // 1. 先查本地缓存(命中就返回,完全不碰 Redis)
    Object localValue = localCache.getIfPresent(fullKey);
    if (localValue != null) {
        return deepCopy(localValue);
    }

    // 2. 本地未命中,才查 Redis
    SimpleValueWrapper obj = (SimpleValueWrapper) redisUtil.get(fullKey);
    Object value = (obj == null ? null : obj.get());

    // 3. 回填本地缓存,下次就读本地了
    if (value != null) {
        localCache.put(fullKey, deepCopy(value));
    }
    return value;
}

写:双写 Redis + 本地

java 复制代码
public void put(Object key, Object value) {
    String fullKey = prefix + key.toString();
    redisUtil.put(fullKey, value);       // 写 Redis
    localCache.put(fullKey, deepCopy(value)); // 写本地
}

删:删 Redis + 删本地 + 广播

java 复制代码
public void delKey(String key) {
    String fullKey = prefix + key;
    redisUtil.evict(fullKey);             // 1. 删 Redis
    localCache.invalidate(fullKey);       // 2. 删自己节点的本地
    publishInvalidation("DEL:" + fullKey);// 3. 广播,让别的节点也删本地
}

一个细节:为什么要 deepCopy

每次读/写都做一次 Java 序列化深拷贝:

java 复制代码
private Object deepCopy(Object obj) {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(bos);
    oos.writeObject(obj);
    // ... 再反序列化回来
}

目的是让返回给调用方的对象 和缓存里存的对象互相独立,防止调用方改了返回值把缓存污染了。同时它和 Redis 反序列化的行为保持一致。代价是每次读多做一次序列化,属于用 CPU 换安全。


四、关键问题:多节点的本地缓存怎么保持一致?

本地缓存是"每个节点私有"的,这就带来一个大问题:

节点 A 更新/删除了数据,节点 B 的本地缓存里还是旧值,B 会一直返回脏数据。

解决办法就是本文的重点:Redis 发布订阅(Pub/Sub)广播失效。

4.1 设计思路

把 Redis 当作消息总线(不是当数据同步用):

复制代码
某节点删缓存 → 通过 Redis 发一条"这个 key 失效了"的消息
                ↓
        所有节点都订阅了这个频道,都能收到
                ↓
        各节点收到后,把自己本地的对应 key 删掉

4.2 发布端

任何一次删缓存,都会往固定频道 cache_invalidation 发消息:

java 复制代码
private static final String INVALIDATION_CHANNEL = "cache_invalidation";

private void publishInvalidation(String message) {
    redisUtil.getRedisTemplate().convertAndSend(INVALIDATION_CHANNEL, message);
}

消息有三种格式:

  • DEL:key ------ 删除单个 key
  • DELLIKE:前缀 ------ 按前缀批量删除
  • CLEAR:ALL ------ 清空全部

4.3 订阅端:根据前缀处理

java 复制代码
public void handleInvalidationMessage(String message) {
    if (message.startsWith("DEL:")) {
        String key = message.substring(4);
        localCache.invalidate(key);                       // 删单个
    } else if (message.startsWith("DELLIKE:")) {
        String keyPrefix = message.substring(8);
        invalidateLocalCacheByPrefix(keyPrefix);          // 按前缀批量删
    } else if ("CLEAR:ALL".equals(message)) {
        localCache.invalidateAll();                        // 清空
    }
}

五、spring-redis.xml 配置逐项讲解

这段配置就是让"订阅端"真正能收到广播的关键。之所以配了就能监听到,是因为 RedisMessageListenerContainer 在 Spring 启动时会主动建立一条连接去 SUBSCRIBE cache_invalidation,然后阻塞等待推送。

xml 复制代码
<!-- ========== Redis Pub/Sub: 集群间本地缓存失效通知 ========== -->

<!-- 1. 监听适配器:把普通 POJO 方法包装成 Redis 消息监听器 -->
<bean id="cacheMessageListener" class="org.springframework.data.redis.listener.adapter.MessageListenerAdapter">
    <constructor-arg ref="twoLevelCache" />                                    <!-- 目标对象 -->
    <property name="defaultListenerMethod" value="handleInvalidationMessage" /><!-- 收到消息调哪个方法 -->
    <property name="serializer">
        <bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
    </property>
</bean>

<bean id="myCacheMessageListener" class="org.springframework.data.redis.listener.adapter.MessageListenerAdapter">
    <constructor-arg ref="myCacheManage" />
    <property name="defaultListenerMethod" value="handleInvalidationMessage" />
    <property name="serializer">
        <bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
    </property>
</bean>

<!-- 2. 监听容器:真正干活的那个,负责订阅连接和消息分发 -->
<bean id="redisMessageListenerContainer" class="org.springframework.data.redis.listener.RedisMessageListenerContainer">
    <property name="connectionFactory" ref="connectionFactory" />
    <property name="messageListeners">
        <map>
            <entry key-ref="cacheMessageListener">     <!-- key = 监听器 -->
                <list>
                    <bean class="org.springframework.data.redis.listener.ChannelTopic">
                        <constructor-arg value="cache_invalidation" /> <!-- value = 订阅的频道 -->
                    </bean>
                </list>
            </entry>
            <entry key-ref="myCacheMessageListener">
                <list>
                    <bean class="org.springframework.data.redis.listener.ChannelTopic">
                        <constructor-arg value="cache_invalidation" />
                    </bean>
                </list>
            </entry>
        </map>
    </property>
</bean>

各标签含义:

配置 含义
MessageListenerAdapter 把普通 Java 方法适配成 Redis 消息监听器(内部用反射调用)
constructor-arg ref="twoLevelCache" 委托的目标对象,消息来了就调用它上面的方法
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 通知本地",也不是"删本地同步 Redis"

delKey 里三个动作是并列执行的:

java 复制代码
redisUtil.evict(fullKey);              // 删 Redis(共享的那份)
localCache.invalidate(fullKey);        // 删自己节点的本地
publishInvalidation("DEL:" + fullKey); // 广播:让别的节点删它们的本地

所以准确的说法是:

L1(本节点)失效 → 通过 Redis Pub/Sub 当总线 → 其他节点的 L1 失效

Redis 在这里是消息总线,不是被同步的数据对象;广播的消息内容是"失效通知",不是数据本身。

两个补充点:

  1. 发布者自己也会收到广播(Pub/Sub 会推给所有订阅者,包括自己),会再 invalidate 一次自己的 key,重复但无害。
  2. 广播是异步、尽力而为 的(异常被吞掉),加上本地 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.3 defaultListenerMethod 如果遇到同名方法怎么办

  • Java 不允许同一个类里存在两个签名完全相同的方法,所以"完全同名同参"不可能存在,编译就过不了。
  • 会出现的是重载 (同名、参数不同)。MessageListenerAdapter 用反射找方法,匹配条件是:方法名相同、参数个数相同、参数类型可赋值。
  • 消息经 StringRedisSerializer 反序列化后参数是 [String],因此只有能接受 String(或 Object / CharSequence 等父类型)的重载才会被匹配。
  • 如果两个重载都能接受 String (如 xxx(String) 与 xxx(Object)),它会取反射遍历到的第一个匹配项,结果不可预测。

结论:监听方法名最好保持唯一,避免产生歧义的重载。

6.4 删缓存的触发点:不只是那个管理页面

delRedisKey.jsp + deleteRedisKey 只是给运维/应急用的手动入口,而且限"部级超级用户"。

真正绝大多数的删缓存是业务代码自动触发的,调用点有 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 到底是什么意思

这里其实有两套完全独立的 TTL:

(1)本地缓存 TTL ------ Guava expireAfterWrite(300s)

  • 只做一件事:在它所属的那一个 JVM 内存里静默删掉这个条目。
  • 不发广播 (没有配 RemovalListener,过期不触发任何回调)。
  • 不删 Redis。
  • 下次读该 key,本地未命中 → 重新去 Redis 拉一次、回填本地。

(2)Redis 数据的 TTL

  • 由 RedisUtil.put 设置:liveTime = redis.expiresDays × 3600 × 24,当前配置 redis.expiresDays=1 → 1 天 (putWithExpire 可单独指定)。
  • 这才是数据在 Redis 里的真实生存时间。

对比表:

动作 本地缓存 Redis 发广播
主动 delKey / clear 删 删 是
本地 5 分钟到期 删 不动 否

一句话:5 分钟 TTL 的过期是本地单方面的静默清理,既不发广播也不删 Redis,它只是把"数据最坏能脏多久"限制在 5 分钟内;广播只在显式删除/清空时才发。



八、总结

  1. 两级缓存 = Guava 进程内本地缓存(L1)+ Redis 共享缓存(L2),核心目的是用本地缓存扛住读流量,给 Redis 减压。
  2. 本地缓存是"单节点私有"的,因此天然面临多节点不一致问题。
  3. 广播失效 用 Redis Pub/Sub 当消息总线解决多节点一致:任何节点删缓存时向 cache_invalidation 频道发消息,所有节点收到后清掉自己的本地缓存。
  4. 广播只在删除/清空时发,写入不广播,方向是"本节点 L1 失效 → 通知其他节点 L1 失效",不是数据同步。
  5. 本地 5 分钟 TTL 和 Redis 1 天 TTL 是两套独立机制;前者过期不发广播、不删 Redis,只用于限定最大脏数据时间窗口,兜底最终一致性。
相关推荐
yexianglunbai1 小时前
中间件(Middleware)详解:概念、作用与实战
java·开发语言·中间件
叶总没有会1 小时前
OpenFeign
java·开发语言·springcloud·openfeign
小七在进步1 小时前
类和对象(四)
java·javascript·ajax
白帽攻防录1 小时前
SRC 挖洞:Apache Tomcat 加密拦截器绕过深度复盘,CVE-2026-34486 fail-open 一行代码怎么打穿集群通信
java·网络安全·tomcat·apache
后端LV1 小时前
三级缓存的两个失效盲区,我用 binlog 和消费组广播补上了
java·架构
写了20年代码的老程序员1 小时前
想让 AI 改 Bug 快准狠?先给日志加个业务代码坐标
java·后端·apache log4j
SL_staff1 小时前
JVS-Rules vs Drools:金融风控团队为何转向业务可编辑的决策平台
java·spring cloud·github
Zhou1411361 小时前
SpringMVC_03_进阶功能
java
咖啡八杯1 小时前
常量与枚举设计规范:HttpStatus 自定义 601 警告码
java·架构·代码规范