Redis:基于Java发布订阅快速实现

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;
};

发布流程

  1. 当执行 PUBLISH channel msg 时,Redis 会:
  2. channel 作为 key 查 pubsub_channels 哈希表;
  3. 找到对应的客户端链表后,遍历链表把消息推送给每个客户端;
  4. 同时检查模式订阅表,匹配到的频道也会推送消息。

订阅流程

  1. 执行 SUBSCRIBE channel
  2. 客户端加入该频道的订阅链表
  3. 进入监听状态,等待消息推送

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、以上

以上,生产验证

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值