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------ 删除单个 keyDELLIKE:前缀------ 按前缀批量删除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 在这里是消息总线,不是被同步的数据对象;广播的消息内容是"失效通知",不是数据本身。
两个补充点:
- 发布者自己也会收到广播(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.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 分钟内;广播只在显式删除/清空时才发。
八、总结
- 两级缓存 = Guava 进程内本地缓存(L1)+ Redis 共享缓存(L2),核心目的是用本地缓存扛住读流量,给 Redis 减压。
- 本地缓存是"单节点私有"的,因此天然面临多节点不一致问题。
- 广播失效 用 Redis Pub/Sub 当消息总线解决多节点一致:任何节点删缓存时向
cache_invalidation频道发消息,所有节点收到后清掉自己的本地缓存。 - 广播只在删除/清空时发,写入不广播,方向是"本节点 L1 失效 → 通知其他节点 L1 失效",不是数据同步。
- 本地 5 分钟 TTL 和 Redis 1 天 TTL 是两套独立机制;前者过期不发广播、不删 Redis,只用于限定最大脏数据时间窗口,兜底最终一致性。