1、Redis发布订阅(Pub/Sub)介绍及使用场景
1.1 核心定义
Redis发布订阅(Publish/Subscribe)是Redis提供的一种简单、高效的消息通信模式,用于实现“一对多”的消息分发。其核心逻辑是:发布者(Publisher)向指定频道(Channel)发送消息,所有订阅了该频道的订阅者(Subscriber)都会实时接收到这条消息,订阅者被动接收消息,无法主动向发布者反馈,属于“广播式”通信。
1.2 核心组件
-
发布者(Publisher):消息的发送方,无需关注订阅者的数量和身份,只需向目标频道发送消息即可,发送后无需等待订阅者确认。
-
订阅者(Subscriber):消息的接收方,可同时订阅多个频道,也可取消订阅某个频道;在订阅频道后,才能接收该频道后续发布的所有消息,无法接收订阅前的历史消息。
-
频道(Channel):消息的载体,是一个虚拟的“消息通道”,发布者向频道发消息,订阅者通过订阅频道获取消息;频道无需提前创建,发布者发送消息时会自动创建。
-
模式订阅(Pattern Subscribe):扩展功能,订阅者可通过通配符(如*、?)订阅一类符合规则的频道(如“news*”可订阅news sports、news tech等频道),灵活适配多频道订阅需求。
1.3 核心特点
-
轻量高效:基于Redis原生机制实现,无复杂依赖,消息发送和接收延迟极低,适合高频、低延迟的消息场景。
-
无持久化(默认):默认情况下,Redis不会持久化发布的消息,若订阅者离线,期间发送的消息会丢失;需持久化可结合Redis列表(List)或Stream实现。
-
无确认机制:发布者发送消息后,Redis不会反馈订阅者是否接收成功,属于“fire-and-forget”(发后即忘)模式。
-
一对多通信:一个发布者可向一个或多个频道发送消息,一个频道可被多个订阅者订阅,轻松实现广播效果。
1.4 基础操作命令
|
命令 |
功能说明 |
|---|---|
|
PUBLISH channel message |
发布者向指定频道发送消息,返回接收消息的订阅者数量 |
|
SUBSCRIBE channel1 channel2 ... |
订阅者订阅一个或多个指定频道,进入阻塞状态,实时接收消息 |
|
UNSUBSCRIBE channel1 channel2 ... |
订阅者取消订阅一个或多个频道 |
|
PSUBSCRIBE pattern1 pattern2 ... |
订阅者通过通配符订阅一类频道(模式订阅) |
|
PUNSUBSCRIBE pattern1 pattern2 ... |
取消模式订阅 |
1.5 Redis发布订阅使用场景
Redis Pub/Sub适合“实时性要求高、消息无需持久化、无需确认反馈”的一对多广播场景,以下是典型使用场景,结合场景特点说明适配原因:
1.5.1 实时消息通知
适用场景:网站/APP的实时通知(如点赞通知、评论通知、系统公告)、后台任务完成通知(如文件上传完成、数据导出成功)。
适配原因:通知类消息实时性要求高,无需持久化(用户离线后可通过其他方式补查),且属于一对多广播(一个操作触发多个用户接收通知),Redis Pub/Sub轻量高效,可快速分发消息。
示例:用户A给用户B的文章点赞,后端通过PUBLISH向“user:b:notification”频道发送点赞消息,用户B的客户端已订阅该频道,实时接收点赞通知并展示。
1.5.2 系统日志实时推送
适用场景:后端服务的日志实时收集(如应用错误日志、访问日志),运维人员通过订阅日志频道,实时监控系统运行状态,无需频繁查询日志文件。
适配原因:日志消息产生频繁,需要实时推送,且属于“一对多”分发(多个运维人员/监控系统需同时接收日志),Redis Pub/Sub无并发压力,可支撑高频日志推送。
1.5.3 实时聊天/群聊
适用场景:简单的实时聊天场景(如小型群聊、客服在线聊天),无需复杂的消息漫游、已读回执功能。
适配原因:群聊消息属于典型的一对多广播(群主/成员发送消息,所有群成员接收),实时性要求高,Redis Pub/Sub可快速实现消息分发,且部署简单,无需额外搭建复杂的消息中间件。
注意:复杂聊天场景(如消息持久化、已读回执、消息撤回)需结合Redis Stream或专业消息中间件(如Kafka)。
1.5.4 分布式系统中的事件通知
适用场景:分布式系统中,多个节点之间的事件同步(如节点上线/下线通知、配置更新通知),确保所有节点状态一致。
适配原因:分布式节点间的事件通知需要实时性,无需持久化(节点离线后可重新同步状态),且属于一对多分发(一个节点触发事件,所有节点接收并更新状态),Redis Pub/Sub可实现节点间的轻量通信。
1.5.5 实时数据更新推送
适用场景:实时数据展示场景(如实时监控面板、股票行情推送、直播间在线人数更新),数据更新后实时推送给所有查看页面的用户。
适配原因:数据更新频繁,实时性要求极高,用户无需接收历史数据,只需获取最新数据,Redis Pub/Sub可实现数据更新后的即时广播,减轻数据库查询压力。
1.6 不适用于的场景
Redis Pub/Sub虽轻量高效,但存在局限性,以下场景不建议使用:
-
消息需持久化:若消息丢失会影响业务(如订单消息、支付消息),需使用Redis Stream、Kafka等支持消息持久化的组件。
-
需要消息确认/重试:若需确保订阅者一定接收并处理消息(如任务分发),需使用有确认机制的消息中间件。
-
复杂消息路由:若需根据消息内容进行复杂路由(如按用户标签分发消息),Redis Pub/Sub无法满足,需使用专业消息中间件。
-
高并发、高可靠要求:大规模高并发场景(如百万级订阅者),Redis Pub/Sub的性能和可靠性不足,需选择更专业的组件。
1.7 底层原理概要版
Redis 内部维护一个字典(哈希表):
- key:频道名
- value:订阅该频道的客户端链表
这个和redis对外提供的Hash数据结构不一样,是redis内部维护的一个数据结构:
// Redis 服务器核心结构体
struct redisServer {
// 普通频道订阅表:key=频道名(string),value=订阅客户端链表
dict *pubsub_channels;
// 模式订阅表:存储所有订阅模式的客户端(比如 news.*)
list *pubsub_patterns;
};
发布流程
- 当执行
PUBLISH channel msg时,Redis 会: - 用
channel作为 key 查pubsub_channels哈希表; - 找到对应的客户端链表后,遍历链表把消息推送给每个客户端;
- 同时检查模式订阅表,匹配到的频道也会推送消息。
订阅流程
- 执行
SUBSCRIBE channel - 客户端加入该频道的订阅链表
- 进入监听状态,等待消息推送
2、本文代码主要使用场景
用来实现分布式环境下缓存的更新,比如我在配置端改了一个数据,需要同步给所有节点同步修改所有节点的本地缓存(本文使用的Caffeine)
3、代码环境和依赖
环境:
JDK:17
SpringBoot:3.0+
依赖:
除了Spring全家桶之外,还需要依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
4、事件发布代码和注意事项
4.1 相关代码实现
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@AllArgsConstructor
@Component
public class RedisPublisher {
/**
* 使用 StringRedisTemplate
* 如果使用 RedisTemplate,GenericJackson2JsonRedisSerializer 序列化时会将字符串序列化成JSON格式,
* 比如 "2027331057288593410" -> "\"2027331057288593410\""(带引号的字符串)
*/
private final StringRedisTemplate stringRedisTemplate;
/**
* 发布消息工具方法
*/
public void publishMessage(String channel, String message) {
stringRedisTemplate.convertAndSend(channel, message);
}
}
4.2 注意事项
如果RedisTemplate使用的序列化类是 GenericJackson2JsonRedisSerializer 的话,序列化字符串时,会将字符串序列化成带引号的Json字符串,比如:
比如 "10086" -> "\"10086\""(带引号的字符串)
再消息订阅的地方如果没有特殊处理的话,会报错
有两种解决方案:
1、使用 private final StringRedisTemplate stringRedisTemplate; 来发布消息
2、修改使用其他的序列化工具
这里推荐使用方案1,影响范围少,改序列化工具的话,影响范围大
5、事件定于相关代码和注意事项
这里为了方便使用订阅方法,自定义了一个订阅的注解,通过Spring容器初始化时扫描所有被注解标识的方法,将方法注册订阅
5.1 订阅注解
import java.lang.annotation.*;
/**
* Redis 频道订阅注解
* 标记在方法上,自动订阅指定的 Redis 频道,收到消息时调用该方法
* attention: 使用 RedisSubscribe 注解的方法, 只能有一个入参
*/
@Target(ElementType.METHOD) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,可通过反射获取
@Documented
public @interface RedisSubscribe {
/**
* 要订阅的 Redis 频道名称,不能为空
*/
String[] channels();
}
说明:
1、只能用于方法
2、同一个方法支持订阅多个Channel(配置多个Channel就行)
5.2 扫描注解和注册订阅代码
/**
* Redis订阅注解处理器
* 基于 BeanPostProcessor 实现,只有在Spring容器内管理的Bean才会生效, 贴合 Spring Bean 生命周期
* 支持多方法订阅同一Channel
* 支持同一个方法订阅多个Channel
* 自动将消息对象反序列成方法入参对象
*
* @author jaylli
* @since 2026-02-01
*/
@Component
@Slf4j
public class RedisSubscribeProcessor implements BeanPostProcessor {
// 核心映射:频道名称 -> 该频道绑定的所有方法持有者(线程安全的 Map)
private final Map<String, List<MethodHolder>> channelMethodMap = new ConcurrentHashMap<>();
// 记录已订阅的频道,避免重复订阅 Redis 频道
private final Set<String> subscribedChannels = ConcurrentHashMap.newKeySet();
// RedisTemplate
private final RedisTemplate<String, Object> redisTemplate;
// redis监听器容器:用来添加监听器
private final RedisMessageListenerContainer listenerContainer;
// 构造器注入依赖
public RedisSubscribeProcessor(RedisTemplate<String, Object> redisTemplate,
RedisMessageListenerContainer listenerContainer) {
this.redisTemplate = redisTemplate;
this.listenerContainer = listenerContainer;
}
/**
* 每个 Bean 初始化完成后,扫描其带 @RedisSubscribe 的方法
*/
@Override
public Object postProcessAfterInitialization(@Nullable Object bean, @Nullable String beanName) throws BeansException {
// 扫描当前 Bean 的所有方法
scanAndRegisterSubscribeMethods(bean, beanName);
// 必须返回原 Bean,否则会破坏 Spring 容器
return bean;
}
/**
* 扫描单个 Bean 的注解方法,注册到对应频道的方法列表中
*/
private void scanAndRegisterSubscribeMethods(Object bean, String beanName) {
if (bean == null) {
return;
}
// 遍历当前 Bean 的所有声明方法(包括私有方法)
for (Method method : bean.getClass().getDeclaredMethods()) {
if (method.isAnnotationPresent(RedisSubscribe.class)) {
RedisSubscribe annotation = method.getAnnotation(RedisSubscribe.class);
// noinspection ConstantConditions
String[] channels = annotation.channels();
// 检查频道数组是否为空
if (channels == null || channels.length == 0) {
log.warn("Method [{}] in Bean [{}] has @RedisSubscribe but no channels specified", method.getName(), beanName);
continue;
}
// 为每个频道注册当前方法
for (String channel : channels) {
// 跳过空或空白频道名
if (channel == null || channel.trim().isEmpty()) {
log.warn("Method [{}] in Bean [{}] has empty or blank channel name, skipping", method.getName(), beanName);
continue;
}
// 规范化频道名(去除前后空格)
String normalizedChannel = channel.trim();
// 初始化频道对应的方法列表(不存在则创建)
channelMethodMap.computeIfAbsent(normalizedChannel, k -> new ArrayList<>()).add(new MethodHolder(bean, method));
// 仅当频道未订阅时,初始化 Redis 订阅
if (subscribedChannels.add(normalizedChannel)) {
// 订阅渠道
subscribeChannel(normalizedChannel);
// 首次订阅 Redis 频道
log.info("Subscribe to redis channel for the first time: {}", normalizedChannel);
}
// 成功注册频道订阅
log.info("Successfully registered channel subscription: Channel[{}] -> Bean[{}] method[{}]", normalizedChannel, beanName, method.getName());
}
}
}
}
/**
* 订阅指定 Redis 频道(每个频道仅订阅一次)
*/
private void subscribeChannel(String channel) {
ChannelTopic topic = new ChannelTopic(channel);
// 注册消息监听器,监听该频道
listenerContainer.addMessageListener(new RedisMessageListener(), topic);
}
/**
* Redis 消息监听器:收到消息时,触发该频道下所有绑定的方法
*/
private class RedisMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel(), java.nio.charset.StandardCharsets.UTF_8);
String messageContent = new String(message.getBody(), java.nio.charset.StandardCharsets.UTF_8);
// 收到 Redis 频道[{}] 消息
log.info("Received redis channel[{}] message:{}", channel, messageContent);
// 获取该频道绑定的所有方法
List<MethodHolder> methodHolders = channelMethodMap.get(channel);
if (methodHolders == null || methodHolders.isEmpty()) {
// 频道[{}] 无绑定的处理方法
log.warn("Processing method of channel [{}] without binding", channel);
return;
}
// 遍历所有方法,逐个调用,异常隔离确保每个方法都能被执行
for (MethodHolder holder : methodHolders) {
try {
invokeTargetMethod(channel, holder, messageContent);
} catch (Exception e) {
log.error("Failed to invoke method [{}] for channel [{}], continuing with next method", holder.method().getName(), channel, e);
}
}
}
/**
* 反射调用目标方法,并处理异常
*/
private void invokeTargetMethod(String channel, MethodHolder holder, String messageContent) {
try {
Method method = holder.method();
Object bean = holder.bean();
// 设置方法可访问(支持私有方法)
method.setAccessible(true);
// 支持多种参数形式:无参 / 单个参数(根据类型自动反序列化)
if (method.getParameterCount() == 0) {
method.invoke(bean);
} else if (method.getParameterCount() == 1) {
Parameter parameter = method.getParameters()[0];
Class<?> paramType = parameter.getType();
Object paramValue = parseParameter(messageContent, paramType);
method.invoke(bean, paramValue);
} else {
// 频道[{}] 绑定的方法[{}] 参数不合法,仅支持无参或单个参数
log.warn("""
The method [{}] parameter of channel [{}] binding is illegal.
Only no parameter or single parameter is supported
""",
channel, method.getName());
}
// 频道[{}] 方法[{}] 调用成功
log.info("Channel [{}] method [{}] called successfully", channel, method.getName());
} catch (Exception e) {
// 频道[{}] 方法[{}] 调用失败
log.error("Channel [{}] method [{}] call failed", channel, holder.method().getName(), e);
}
}
/**
* 根据参数类型解析消息内容
*
* @param messageContent JSON 字符串
* @param paramType 参数类型
* @return 解析后的参数对象
*/
private Object parseParameter(String messageContent, Class<?> paramType) {
// 空值处理
if (messageContent == null || messageContent.isEmpty()) {
return null;
}
// String 类型直接返回
if (paramType == String.class) {
return messageContent;
}
// 尝试去除 JSON 引号包装(处理 "123" 这种格式)
String unwrappedContent = unwrapJsonString(messageContent);
// 基本类型及其包装类
try {
if (paramType == int.class || paramType == Integer.class) {
return Integer.parseInt(unwrappedContent);
}
if (paramType == long.class || paramType == Long.class) {
return Long.parseLong(unwrappedContent);
}
if (paramType == double.class || paramType == Double.class) {
return Double.parseDouble(unwrappedContent);
}
if (paramType == float.class || paramType == Float.class) {
return Float.parseFloat(unwrappedContent);
}
if (paramType == boolean.class || paramType == Boolean.class) {
return Boolean.parseBoolean(unwrappedContent);
}
if (paramType == byte.class || paramType == Byte.class) {
return Byte.parseByte(unwrappedContent);
}
if (paramType == short.class || paramType == Short.class) {
return Short.parseShort(unwrappedContent);
}
if (paramType == char.class || paramType == Character.class) {
return unwrappedContent.charAt(0);
}
} catch (NumberFormatException | StringIndexOutOfBoundsException e) {
throw new IllegalArgumentException("Failed to parse message content [" + messageContent + "] to type [" + paramType.getName() + "]", e);
}
// 其他复杂对象使用听说很牛逼的 Fastjson2 反序列化
try {
return JSON.parseObject(messageContent, paramType);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to deserialize message content [" + messageContent + "] to type [" + paramType.getName() + "]", e);
}
}
/**
* 去除 JSON 字符串的引号包装
* 例如: "123" -> 123, "abc" -> abc
*/
private String unwrapJsonString(String content) {
content = content.trim();
if (content.length() >= 2 && content.startsWith("\"") && content.endsWith("\"")) {
return content.substring(1, content.length() - 1);
}
return content;
}
}
/**
* 内部类:存储 Bean 实例和对应的方法:用来订阅消息时找到对应的类和方法来反射调用
*/
private record MethodHolder(Object bean, Method method) {
}
/**
* 发布消息工具方法(测试用)
*/
public void publishMessage(String channel, String message) {
redisTemplate.convertAndSend(channel, message);
}
}
代码说明:
* Redis订阅注解处理器
* 基于 BeanPostProcessor 实现,只有在Spring容器内管理的Bean才会生效, 贴合 Spring Bean 生命周期
* 支持多方法订阅同一Channel
* 支持同一个方法订阅多个Channel
* 自动将消息对象反序列成方法入参对象
5.3 订阅消息代码示例
import com.alibaba.fastjson2.JSON;
import com.yfoe.base.common.annotations.RedisSubscribe;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 测试 Redis 订阅注解的使用
*/
@Component
@Slf4j
public class CacheClearHandler {
/**
* 订阅频道需要和发布频道保持一致
*/
private static final String CHANNEL_1 = "CHANNEL_1";
private static final String CHANNEL_2 = "CHANNEL_2";
private static final String CHANNEL_3 = "CHANNEL_3";
@Resource
private CacheManager cacheManager;
/**
* 清理本地缓存1
*
* @param message 消息
*/
@RedisSubscribe(channels = CHANNEL_1)
public void handleCache1(String message) {
log.info("handle cache1, channel = {}, key = {}", CHANNEL_1, message);
Cache cache = cacheManager.getCache("YOUR_CACHE_NAME");
String cacheKey = "YOUR_CACHE_PREFIX:" + message;
// noinspection ConstantConditions
cache.evict(cacheKey);
}
/**
* 清理本地缓存2
*
* @param message 消息
*/
@RedisSubscribe(channels = CHANNEL_2)
public void handleCache2(Long message) {
log.info("handle cache2, channel = {}, key = {}", CHANNEL_2, message);
Cache cache = cacheManager.getCache("YOUR_CACHE_NAME");
String cacheKey = "YOUR_CACHE_PREFIX:" + message;
// noinspection ConstantConditions
cache.evict(cacheKey);
}
/**
* 清理多个本地缓存
*
* @param message 消息
*/
@RedisSubscribe(channels = {CHANNEL_2, CHANNEL_3})
public void handleMultiCache(Long message) {
log.info("handle cache3, channel = {}, key = {}", CHANNEL_2 + CHANNEL_3, message);
Cache cache = cacheManager.getCache("YOUR_CACHE_NAME");
String cacheKey = "YOUR_CACHE_PREFIX:" + message;
// noinspection ConstantConditions
cache.evict(cacheKey);
}
/**
* 无输入参数
*/
@RedisSubscribe(channels = {CHANNEL_3})
public void noArg() {
log.info("handle cache3, channel = {}", CHANNEL_2 + CHANNEL_3);
Cache cache = cacheManager.getCache("YOUR_CACHE_NAME");
String cacheKey = "YOUR_CACHE_KEY";
// noinspection ConstantConditions
cache.evict(cacheKey);
}
/**
* 对象参数
* @param message 消息
*/
@RedisSubscribe(channels = {CHANNEL_3})
public void objectArg(List<Object> message) {
log.info("handle cache3, channel = {}, message = {}", CHANNEL_2 + CHANNEL_3, JSON.toJSONString(message));
Cache cache = cacheManager.getCache("YOUR_CACHE_NAME");
String cacheKey = "YOUR_CACHE_KEY";
// noinspection ConstantConditions
cache.evict(cacheKey);
}
}
6、以上
以上,生产验证

1090

被折叠的 条评论
为什么被折叠?



