springboot+redisTemplate实现消息队列(pub/sub,list,监听键过期)

redis实现方式主流的有两种,一种是lpush rpop,一种是pub/sub机制,下面来做个演示的例子

大概的分为两个角色,生产者和消费者,然后大概结构是这样的:

主要的角色就是维护主题和消费者关系的一个表,生产者、消费者、监听键过期机制,还有定时任务,这次写出来的例子只是支持滞后消费的重投,超前消费的情况没有得到很好解决。

首先是生产者,生产者主要是根据主题发布消息

package com.gdut.redisdemo.producer;

import com.gdut.redisdemo.VO.MessageVO;
import com.gdut.redisdemo.config.PubSubTable;
import com.google.gson.Gson;
import io.netty.util.CharsetUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

/**
 * @author lulu
 * @Date 2019/6/22 16:47
 */
@Component
public class Producer {

    @Autowired
    private Gson gson;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private PubSubTable pubSubTable;



    public void sendMessage(String topic, MessageVO messageVO) {
        //这里给订阅该主题的链接的每个队列进行广播该消息
        pubSubTable.boradCast(topic, messageVO.getMessageId());
        redisTemplate.getConnectionFactory().getConnection().publish(topic.getBytes(CharsetUtil.UTF_8), gson.toJson(messageVO).getBytes());
    }
}

而维护他们关系的表,这个主要是用来发布list和监听键

package com.gdut.redisdemo.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.*;

/**
 * @author lulu
 * @Date 2019/6/22 17:39
 */

public class PubSubTable {
    //Map的key为主题,Set里面的元素为队列里面的消费者的队列key,该队列用来检测消息是否被正确接受
    private Map<String, Set<String>> pubSubMap = new HashMap();

    @Autowired
    private StringRedisTemplate redisTemplate;
//添加关系
    public Boolean addComsumer(String topic, String comsumer) {
        Set<String> comsumerList = pubSubMap.get(topic);

        if (comsumerList == null) {
            comsumerList = new HashSet<>();
        }
        Boolean b = comsumerList.add(comsumer);
        pubSubMap.put(topic, comsumerList);
        return b;
    }
//删除关系
    public Boolean removeComsumer(String topic, String comsumer) {
        Set<String> comsumerList = pubSubMap.get(topic);
        Boolean b =false;
        if(comsumerList!=null){
           b= comsumerList.remove(comsumer);
            pubSubMap.put(topic, comsumerList);
        }
        return b;
    }


    //广播消息
    public void boradCast(String topic, String messageId) {
        if(pubSubMap.get(topic)!=null){
            for (String comsumer : pubSubMap.get(topic)) {
                //这里不再次进行入队和设监听键的原因是已经有了(对应滞后消费的情况)
                if(!redisTemplate.hasKey("fail_"+topic+"_"+comsumer+"_"+messageId)){
                    //设置监听键
                    redisTemplate.opsForValue().set(topic+"_"+comsumer + "_" + messageId, messageId);
                    //为该队列传入消息id,为后面校验使用
                    redisTemplate.opsForList().leftPush(topic+"_"+comsumer, topic + "_" + messageId);
                }
            }
        }

    }

}

消费者,这里的订阅要对监听容器操作,该容器(RedisMessageListenerContainer)有订阅频道和取消订阅的方法,一开始没找到。。

package com.gdut.redisdemo.comsumer;

import com.gdut.redisdemo.VO.MessageVO;
import com.gdut.redisdemo.VO.UserVO;
import com.gdut.redisdemo.config.PubSubTable;
import com.google.gson.Gson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @author lulu
 * @Date 2019/6/22 16:54
 */
@Component
public class Comsumer1 implements MessageListener {
    @Autowired
    private Gson gson;

    @Autowired
    private PubSubTable pubSubTable;
    @Autowired
    private StringRedisTemplate redisTemplate;

    public void addChannel(String topic) {

        pubSubTable.addComsumer(topic, this.getClass().getSimpleName());
        System.out.printf("%s 订阅一个主题%s%n", this.getClass().getSimpleName(), topic);

    }


    @Override
    public void onMessage(Message message, @Nullable byte[] pattern) {
        String name = this.getClass().getSimpleName();
        String topic = new String(message.getChannel());
        String content = new String(message.getBody());
        MessageVO messageVO = gson.fromJson(content, MessageVO.class);
        //如果这个取出来的不是正确的id,丢弃并记录。
        String b = redisTemplate.<String>opsForList().rightPop(topic + "_" + name);
        if (b != null && b.equals(topic + "_" + messageVO.getMessageId())) {
            UserVO userVO = gson.fromJson(messageVO.getContent(), UserVO.class);
            System.out.printf("%s从主题%s收到消息:%s%n", name, topic, content);//业务处理
            System.out.printf("消息内容:%s%n", userVO.toString());
            redisTemplate.expire(topic + "_" + name + "_" + messageVO.getMessageId(), 1, TimeUnit.NANOSECONDS);
        } else {
            //把他设为fail,准备重新处理
            redisTemplate.opsForValue().set("fail_" + topic + "_" + name + "_" + messageVO.getMessageId(), content);
        }
    }
}

监听器:使用这个的前提是配置文件要开启事件监听:notify-keyspace-events Ex

package com.gdut.redisdemo.comsumer;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.KeyspaceEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;

/**
 * @author lulu
 * @Date 2019/6/22 18:40
 */
public class CheckKeyExpire extends KeyspaceEventMessageListener {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * Creates new {@link MessageListener} for {@code __keyevent@*__:expired} messages.
     *
     * @param listenerContainer must not be {@literal null}.
     */
    public CheckKeyExpire(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    @Override
    protected void doHandleMessage(Message message) {
        String channel = new String(message.getChannel());
        if (channel.equals("__keyevent@0__:expired")) {

            String body = new String(message.getBody());
            System.out.println("消息id为:" + body + "成功处理");
            redisTemplate.delete("fail_" + new String(message.getChannel())+"_"+ body);
            //如果这里有状态改变为接受成功的消息之类的,可以在这里操作数据库
        }
    }
}

大概的几个核心部分就是这样,然后配置方面,因为不同的消费者不能公用同一个client所以把获取连接的bean设为多例的。

这里一开始做的时候遇到好多坑,后来发现template构造函数有个factory了,然后试着注入到配置类里面,果然可以,解决了一开始的bug

package com.gdut.redisdemo.config;

import com.gdut.redisdemo.comsumer.CheckKeyExpire;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author lulu
 * @Date 2019/6/22 17:31
 */
@Configuration
public class RedisConfig {
@Autowired
RedisConnectionFactory redisConnectionFactory;

    @Bean
    @Scope(BeanDefinition.SCOPE_PROTOTYPE)
    public RedisConnection redisConnection() {
        return redisConnectionFactory.getConnection();
    }

    @Bean
    public RedisMessageListenerContainer container() {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisConnectionFactory);
        return container;
    }

    @Bean
    public PubSubTable pubSubTable() {
        return new PubSubTable();
    }

    @Bean
    public CheckKeyExpire checkKeyExpire(
    ) {
        return new CheckKeyExpire(container());
    }

}

下面看一下正常的演示,此时消费者1和消费者2均订阅了user这个主题,如果匹配模式的话要用监听容器的PatternTopic这个类。

然后进行广播:

遍历关系表,设置监听键和消息id确认的队列。此时有这四个键

然后执行onMessage的方法操作数据

结果如下

由于消息被消费完了,会把监听键设置过期,然后执行监听过期的方法,这里是对0数据库的过期事件才进行操作,打印出消息,并删除相关键如果有的话

 protected void doHandleMessage(Message message) {
        String channel = new String(message.getChannel());
        if (channel.equals("__keyevent@0__:expired")) {
            String body = new String(message.getBody());
            System.out.println("消息id为:" + body + "成功处理");
            redisTemplate.delete("fail_" + new String(message.getChannel())+"_"+ body);
            //如果这里有状态改变为接受成功的消息之类的,可以在这里操作数据库
        }
    }

此时redis的数据为空

那么看一下消息滞后的情况,就是队列里面多了几个奇怪的元素,然后里面的消息没有被及时消费,下面模拟一下这个情况:

这里我预先创好存放i消息d的队列,该队列主要是用来查看当前消息是否和收到的内容是一致的,如果不一致,则认为该消息发送失败了,这里先放四个元素。

然后向消费者1发送消息:

这个注解的意思是每15秒执行一次,延迟20s执行

@Scheduled(initialDelay = 20000,fixedRate = 15000)

结果:

重投三次之后,没有再投递了,此时还消息id队列还有这个值,会被下次消息获取时去掉

然后看取消订阅的例子:

订阅主题:

http://localhost:8080/subTopic/1?topic=test

发送消息:

http://localhost:8080/sendMessage/test?name=lele&age=21

业务处理:

消息确认:

通过队列的消息校验和经过业务处理后,会使监听键过期,否则设为失败的消息投递,进行重投

取消订阅:这里面不仅仅要把hashMap里的关系去掉,还要移出监听容器

http://localhost:8080/unSubTopic/1?topic=test

再次发消息,没有结果。可见取消订阅成功了

其实这个例子一开始写的不是很好,第一天做的过程也有很多坑,好像那个配置那些都是迷迷糊糊弄出来的,后来发现也不对,整个设计的也有待完善,但是就当对spring-data-redis一个实操吧,就瞎鼓捣,一些尝试啥的,感觉用起来很多坑,为什么这么说呢,因为用一开始的版本的话,从配置还有监听容器上面和取消订阅有挺多问题的,当时没有仔细考虑这个东西,然后就出现了消费者两个以上出现read timeout的情况,我怀疑是哪里锁住了,第二天把他放到监听容器上就没有了,还可以取消订阅,其实这里对于消息投递失败的处理也不是很正宗,都是收到消息才丢弃的消息,从某种意义看消息投递其实是成功了的,我github的other分支里面就用了一个监听成功、一个监听失败的键(默认5s,即5s消息没有成功就进行重投)的做法,写得不是那么好和完善,就当随便看看吧,这个例子就只是拿来熟悉下redisTemplate,听说还有5.0以上stream也可以实现消息队列,到时候看看。完整代码已放上github

如何实现简单的延时队列?这里要用到sorted set结构,拿时间戳作为score,消息id作为key,至于消息的内容可以存放在redis或者其他数据库上,计算n秒前的时间戳,再用zrangebyscore获取当前时间戳-ns前的时间戳的消息id集合进行消费

 

<think>好的,我现在需要帮助用户了解如何使用Redis的发布订阅(Pub/Sub)功能在分布式系统中实现通知机制的最佳实践。首先,我应该回顾一下Redis Pub/Sub的基本概念和命令,比如PUBLISH、SUBSCRIBE、PSUBSCRIBE等,确保自己对这些基础内容有正确的理解。用户提到的引用[1]和[2]提到了Redis Pub/Sub与其他消息队列系统的对比,特别是扩展性和消息可靠性,这些可能在最佳实践中需要考虑。 接下来,我需要确定用户的具体需求。他们希望在分布式环境中实现通知机制,所以可能需要考虑多个服务实例之间的通信、消息的可靠传递、扩展性以及如何处理可能出现的故障。根据引用[1],Redis的扩展性有限,所以可能需要建议如何克服这一点,比如分片或者结合其他技术。同时,消息可靠性也是一个关点,因为Redis Pub/Sub不保证消息持久化,如果订阅者断开连接可能会丢失消息,所以需要提出解决方案,比如确认机制或补充持久化存储。 然后,我需要整理最佳实践的步骤。首先,频道设计,合理的命名空间和结构可以避免混乱。比如使用特定的前缀和层级结构,如“notifications:order:created”。其次,订阅者的实现,可能需要使用多路复用或者多线程来处理消息,避免阻塞。例如在Python中使用redis-py库,创建单独的线程来监听消息。同时,错误处理和重连机制也很重要,订阅者需要能够自动重新连接,防止网络问题导致的中断。 关于消息格式,建议使用结构化的数据,比如JSON,方便解析和扩展。同时,消息大小需要控制,避免影响性能。另外,消息确认机制可能需要引入额外的组件,比如结合Redis Streams来跟踪处理状态,或者使用数据库记录已处理的消息ID,确保消息不会丢失。 扩展性方面,由于Redis是单线程的,单个实例可能成为瓶颈,所以可以考虑分片策略,将不同的频道分配到不同的Redis实例上。或者使用集群模式,但需要注意Pub/Sub在集群中的行为,可能需要每个节点单独订阅。 监控和维护也是不可忽视的部分,需要记录消息的发布和订阅情况,使用Redis的监控命令或者集成外部监控工具。此外,设置合理的过期时间,避免频道堆积未处理的消息,尤其是在高负载情况下。 最后,需要考虑Redis Pub/Sub的适用场景。如果系统对消息可靠性要求极高,可能需要结合其他消息队列如Kafka或RabbitMQ,或者在发布消息的同时写入数据库备份,确保即使Redis出现问题,消息也能恢复。 总结这些点,我需要以清晰的结构将这些最佳实践分点列出,涵盖频道设计、订阅者实现、消息格式、确认机制、扩展性、监控、过期策略和适用场景。同时,生成的相关问题需要引导用户进一步探索,比如可靠性保障、扩展性方案、与Streams对比等。</think>### Redis Pub/Sub 实现分布式通知机制的最佳实践 #### 1. **频道设计与命名规范** - **层级化命名**:使用`:`分隔符定义频道层级,例如 `notifications:order:created`,便于分类管理[^2] - **命名空间隔离**:为不同业务模块添加前缀,如 `payment:success` 和 `inventory:low` - **通配符订阅**:通过 `PSUBSCRIBE notifications:*` 实现批量订阅相关频道 #### 2. **订阅者实现方案** ```python import redis import threading def message_handler(msg): print(f"收到通知: {msg['data']}") def subscribe_worker(): r = redis.Redis(host='redis-host', port=6379) pubsub = r.pubsub() pubsub.psubscribe('notifications:*') for msg in pubsub.listen(): if msg['type'] == 'pmessage': message_handler(msg) # 启动独立订阅线程 threading.Thread(target=subscribe_worker, daemon=True).start() ``` #### 3. **消息可靠性增强** - **消息格式标准化**:建议使用 JSON 结构化数据: $$\{ "event": "order_created", "timestamp": 1629999999, "data": \{...\} \}$$ - **补充持久化**:重要消息可同步写入 Redis Streams 或数据库[^1] - **心跳检测**:定期发送 `PING/PONG` 维持连接 #### 4. **扩展性优化** - **分片策略**:按业务维度分配不同 Redis 实例 ```bash # 订单类通知 redis-cli -h shard1 publish orders:update "{...}" # 库存类通知 redis-cli -h shard2 publish inventory:alert "{...}" ``` - **集群模式**:注意需在每个节点单独订阅 #### 5. **异常处理机制** - **自动重连**:在订阅循环中增加异常捕获 - **退避策略**:重连间隔采用指数退避算法 - **死信处理**:通过 `__keyspace@0__:频道名` 监听异常事件 #### 6. **监控指标** - 通过 `INFO PUBSUB` 获取活跃频道数 - 监控 `pubsub_patterns` 字段观察模式订阅数量 - 使用 `CLIENT LIST` 跟踪订阅者连接状态
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值