[超轻量级消息队列(MQ)] Redis 不只是缓存:我用 Redis Stream 实现了一个 MQ(自定义注解方式)

项目里需要一个消息队列的时候,你第一反应是什么?

Kafka?RabbitMQ?RocketMQ?

但很多时候,我们并不是真的需要一个"重型 MQ"。

前段时间我在项目里遇到一个很典型的场景:

  • 需要异步解耦一些业务逻辑
  • 消息量不算小,但也远没到 MQ 的级别
  • 运维成本希望尽量低
  • 最关键的是:Redis 一般是我们项目中的基础设施

1、Redis Stream 概念速览

Stream(消息流)是 Redis 5.0 引入的数据结构,本质上是一个"可回溯的、有消费状态的消息日志"。

它提供了很多消息队列才有的能力:

  1. 消息持久化

发布订阅模式(Pub/Sub) 是负责消息转发,而不负责存储的广播事件

5.0新引入的 Stream 是会先把消息持久化下来,再由消费者按进度消费的可回溯的消息流

  1. Consumer Group(消费者组)

Pub/Sub:每个订阅者都会收到消息;

Stream + Group:组内只会有一个消费者拿到消息

  1. ACK 确认机制

将收到的消息,标记为已读

  1. Pending Entries List(失败可恢复)

服务宕机、没有ACK,这条消息会永远存到PEL中

2、Stream命令

  1. XGROUP CREATE 创建消费者组
bash 复制代码
XGROUP CREATE key groupname id|$ [MKSTREAM]

XGROUP CREATE: 在某个Stream上创建一个消费者组,让多个消费者可以协作消费消息,并支持消息确认、重试等机制
参数讲解:

1、key :创建消费者组的Stream名称,一般用来区分业务

2、groupname :消费者组名称,一个Stream可以有多个消费者组

3、id|$ :消费者组的起始消费位置

$表示:历史消息不会被消费,只消费创建消费者组之后的新消息

id表示:指定一个 消息 ID 作为起点,从该 ID 开始(包含之后)的消息
MKSTREAM:Stream 不存在则自动创建


  1. XADD 发送消息
bash 复制代码
XADD key ID field string [field string ...]

XADD: 向 Redis Stream(消息流) 中 追加一条消息,并返回该消息的 ID。
参数讲解:

1、ID : 手动设置id,格式为<毫秒时间戳>-<序列号>

*:由 Redis 自动生成 ID

2、field :消息中的字段名,Stream消息是key-value格式的,field就是key(例如: msg "redis不只是缓存!")

3、[field string ...]:一条消息可以包含 多组 field-value


  1. XREADGROUP 监听读取消息
bash 复制代码
XREADGROUP GROUP group consumer
          [COUNT count]
          [BLOCK milliseconds]
          [NOACK]
          STREAMS key [key ...]
          ID  [ID ...]

XREADGROUP GROUP:读取消费者组中的消息
参数讲解:

1、group :Stream 中已存在的消费者组名称

2、consumer :当前读取消息的消费者实例名称

3、[COUNT count] :本次最多读取多少条消息

4、[BLOCK milliseconds] :如果当前没有消息,阻塞等待指定毫秒数

0: 一直阻塞,直到有消息

5000:最多等 5 秒

5、[NOACK] :不需要 ACK 确认

6、key [key ...] :要读取的 Stream 列表

7、ID [ID ...] :指定 从哪个位置开始读

>(最常用): 读取 从未被任何消费者读取过的新消息

0 / 0-0: 读取 Pending List 中未 ACK 的消息


  1. XACK 确认消息
bash 复制代码
XACK key group id [id ...]

XACK: 从消费者组的Pending List 中移除这些消息
参数讲解:

1、id :要确认的 消息 ID

2、 [id ...]:一次可以 确认多条消息


3、用原生命令模拟 订单、支付 MQ业务场景

  1. 创建消费者组

支付消息队列(pay)

bash 复制代码
XGROUP CREATE redis:mq:pay pay-group $ MKSTREAM

订单消息队列(order)

bash 复制代码
XGROUP CREATE redis:mq:order order-group $ MKSTREAM

注:

$:从最新消息开始消费

  1. 生产消息

往支付 Stream 写消息

bash 复制代码
XADD redis:mq:pay * msg "这里是支付业务"

往订单 Stream 写消息

bash 复制代码
XADD redis:mq:order * msg "这里是订单业务"

注:

*:自动生成消息 ID

  1. 消费消息

消费支付业务消息

bash 复制代码
XREADGROUP GROUP pay-group pay-consumer-1 COUNT 1 BLOCK 5000 STREAMS redis:mq:pay >

消费订单业务消息

bash 复制代码
XREADGROUP GROUP order-group order-consumer-1 COUNT 1 BLOCK 5000 STREAMS redis:mq:order >

注:

1、>:表示读取 尚未被消费的新消息

2、BLOCK 5000:最多阻塞 5 秒

3、COUNT 1:一次读一条

  1. 确认消息

确认支付消息

bash 复制代码
XACK redis:mq:pay pay-group 1767679949417-0

确认订单消息

bash 复制代码
XACK redis:mq:order order-group 1767679953770-0

注:

1、消息被确认后,会从 Pending Entries List(PEL) 中移除

2、表示该消息已被成功处理

  1. 命令运行全过程

  2. 可视化界面效果:

4. Spring Data Redis实现自定义注解MQ

4.1、引入依赖

java 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

4.2 自定义注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisStreamListener {

    // Stream key
    String streamKey();

    // 消费组
    String group() default "default-group";

    // 消费者名
    String consumer() default "";

    // 是否自动 ack
    boolean autoAck() default true;
}

4.3、创建消息监听容器

等价于redis的 [XREADGROUP GROUP] 这个命令

java 复制代码
@Configuration
public class AnnotainContainerConfig {

    @Bean(initMethod = "start", destroyMethod = "stop")
    public StreamMessageListenerContainer<String, MapRecord<String, String, String>> annotainStreamContainer(
            RedisConnectionFactory redisConnectionFactory,
            ErrorHandler errorHandler) {
            
        StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
                StreamMessageListenerContainer.StreamMessageListenerContainerOptions
                        .builder()
                        .pollTimeout(Duration.ofSeconds(1))
                        .build();

        return StreamMessageListenerContainer.create(redisConnectionFactory, options);
    }
}

@Bean(initMethod = "start", destroyMethod = "stop")解释:交给Spring来调用container.start(), 在创建 Bean 时,自动反射调用start()

当然你也可以像这样显式调用:

container.start()作用:创建并启动一个 Redis Stream 的"消息消费引擎",负责持续执行 XREADGROUP,把 Redis Stream 中的消息拉取到 JVM 内,并分发给已注册的消费者。


4.4、把监听方法注册到容器

⚠️此方法为重点方法,小名在后面附上了大量图文详解

java 复制代码
@Component
public class RedisStreamListenerRegistrar implements SmartInitializingSingleton, ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Autowired
    private StreamMessageListenerContainer<String, MapRecord<String, String, String>> container;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }
    
    @Override
    public void afterSingletonsInstantiated() {
        Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);

        beans.values().forEach(bean -> {
            for (Method method : bean.getClass().getDeclaredMethods()) {
                RedisStreamListener listener =
                        AnnotationUtils.findAnnotation(method,
                        RedisStreamListener.class);

                if (listener != null) {
                    registerListener(bean, method, listener);
                }
            }
        });
    }

    private void registerListener(Object bean, Method method,RedisStreamListener listener) {

        String streamKey = listener.streamKey();
        String group = listener.group();
        String consumer = listener.consumer().isEmpty()
                ? UUID.randomUUID().toString()
                : listener.consumer();
        try {
            redisTemplate.opsForStream()
                    .createGroup(streamKey, group);
        } catch (Exception ignore) {
        }
			
        container.receive(
                Consumer.from(group, consumer),
                StreamOffset.create(streamKey, ReadOffset.lastConsumed()),
                message -> invokeString(bean, method, message, listener)
        );
    }
    
    private void invokeString(
            Object bean,
            Method method,
            MapRecord<String, String, String> record,
            RedisStreamListener listener) {

        try {
            String message = record.getValue().get("data");

            method.setAccessible(true);
            method.invoke(bean, message);

            if (listener.autoAck()) {
                redisTemplate.opsForStream().acknowledge(
                        record.getStream(),
                        listener.group(),
                        record.getId()
                );
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

代码拆解

1、下面这个就是咱们上面 AnnotainContainerConfig.annotainStreamContainer() 创建的消息监听容器


2、扫描所有 Spring 管理的 Bean,找出方法上标注了 @RedisStreamListener 的方法,将这些方法注册为 Redis Stream 消费者


3、注册 Redis Stream 消费者

等价于redis的[XGROUP CREATE]的命令


4、从 Redis Stream record 中取出业务消息,反射调用对应的业务方法,ACK

等价于redis的[XACK]的命令


4.5、消息生产者

等价于redis的[XADD]的命令

java 复制代码
@Component
@Slf4j
public class AnnotainProducer {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public void send(String streamKey, String message) {
        Map<String, String> body = new HashMap<>();
        body.put("data", message);

        redisTemplate.opsForStream()
                .add(StreamRecords.mapBacked(body).withStreamKey(streamKey));
    }

}

5、模拟业务逻辑,测试上面的代码

5.1 通过自定义注解,模拟业务消费者

java 复制代码
@Component
@Slf4j
public class ListenerDemoImpl {

    @RedisStreamListener(streamKey = "demo:mq:redis:order")
    public void OrderListener(String msg) {
        log.info("订单业务:{}", msg);
    }

    @RedisStreamListener(streamKey = "demo:mq:redis:pay")
    public void PayListener(String msg){
        log.info("支付业务:{}", msg);
    }

}

5.2 写一个测试接口

java 复制代码
    @GetMapping("/send")
    public void sendMessage(){
        annotainProducer.send("demo:mq:redis:order","orderId=1");
        RedisMQPayDTO redisMQPayDTO = new RedisMQPayDTO();
        redisMQPayDTO.setId(1);
        redisMQPayDTO.setPayOrderId("order-1");
        annotainProducer.send("demo:mq:redis:pay", JSON.toJSONString(redisMQPayDTO));
    }
java 复制代码
@Data
public class RedisMQPayDTO {
    Integer id;
    String payOrderId;
}

控制台输出:

bash 复制代码
订单业务:orderId=1
支付业务:{"id":1,"payOrderId":"order-1"}

若文中存在不足或错误,欢迎在评论区留言指正,大家共同交流、共同进步。
如果本文对你有所帮助,欢迎关注小名给予支持 😄
点赞 👍、评论 ✍、收藏 🤞 是对小名莫大的鼓励,感谢大家的支持 ♥️

相关推荐
列御寇9 小时前
MongoDB分片集群——分片键(Shard Keys)概述
数据库·mongodb
oMcLin9 小时前
如何在Ubuntu 22.04 LTS上通过配置ZFS存储池,提升高吞吐量数据库的读写性能与可靠性?
linux·数据库·ubuntu
何中应9 小时前
@Autowrited和@Resource注解的区别及使用场景
java·开发语言·spring boot·后端·spring
Cx330❀9 小时前
脉脉平台深度测评:【AI创作者xAMA】从职场社交到AI创作赋能
数据库·人工智能·脉脉
f***24119 小时前
Bug侦探团:破解技术悬案的秘密武器
数据库
Li_7695329 小时前
Redis 进阶(八)—— 分布式锁
数据库·redis·分布式
Li_7695329 小时前
Redis 进阶(七)—— 缓存
数据库·redis·缓存
claem9 小时前
Mac搭建postgreSQL 一些基础命令与注意事项
数据库·postgresql
程序猿202310 小时前
MySQL的逻辑存储结构
java·数据库·mysql