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

0、前言:

上一篇文章【Redis 不只是缓存:我用 Redis Stream 实现了一个普通MQ】小名和大家聊到了用Redis 5.0引入的新数据结构(Stream),实现了一个自定义注解形式的消息队列,这篇文章旨在上一次的代码上,稍作改动,改造为一个用Redis实现的延时队列

1、回顾

实现延时队列之前,咱们先来简单回顾一下实现的普通MQ的核心内容:

很多项目里,Redis 可能一直只承担着"缓存工具人"的角色。但其实,它还有一个经常被忽略、却非常实用的能力:用 Stream 做消息队列。
一、为什么选择 Redis Stream 作为轻量级 MQ?

Redis Stream 的定位是:

可回溯、可确认、具备消费状态的消息日志结构。

相比 Redis Pub/Sub 只负责"转发",Stream 补齐了消息队列的核心能力:

1.消息持久化:消息先存储再消费,支持历史回放

2.消费者组机制:组内消息只会被一个消费者处理

3.ACK 确认机制:明确消息是否被成功消费

4.失败可恢复:未 ACK 消息进入 PEL,服务重启后仍可继续处理
二、Redis Stream 的核心命令与使用场景

在实际业务(如订单、支付异步处理)中,主要使用了以下四个核心命令:

1.XGROUP CREATE

2.XADD

3.XREADGROUP

4.XACK
三、Spring Boot + 自定义注解的整体实现思路

1.自定义注解@RedisStreamListener 用于声明:Stream Key、消费者组、消费者名称、是否自动 ACK、监听容器

2.SpringBoot负责持续拉取消息

3、实现思路

3.1、整体思路变化:Stream 消费 → ZSET 延迟

1、原方案

1、Producer:XADD 直接写 Stream

2、Consumer:XREADGROUP 消费

3、容器:StreamMessageListenerContainer.receive(...) 负责 阻塞拉取 + 分发 + ack

2、改造方案

因为之前,我们是交给Spring Boot来帮助我们 阻塞拉取 + 分发。而延时队列需要利用 ZSET 的时间分数,只能自己做 定时轮询。

改造方案:从 Stream + 容器拉取 改成 ZSET 延迟 + 定时拉取

1、Producer:ZADD 写入延时 ZSET(score = 到期时间)

2、定时调度:扫描到期 ZSET,XADD 写入 Stream

3、Consumer:依旧 XREADGROUP 消费

4、新增:ZSET 定时扫描器(替代 "阻塞拉取" 的能力)

3.3、差异点拆解

  1. 为什么 Stream 不能实现延时队列?
    Redis Stream 没有原生延时投递机制
    Stream 只有 "追加 + 消费" 的模型,没有 "到期后自动可见"
    如果要延时,只能自己加一层调度(即现在的 ZSET + 扫描)
  2. 为什么选择 ZSET 做延时队列?
    ZSET 支持 score 排序,天然适合 "时间戳排序"
    能高效执行 ZRANGEBYSCORE 0 now 拉取到期任务
    Redis 官方最常见延时队列实践之一就是 ZSET(高效、简单、无需额外组件)

4、普通队列 和 延时队列 代码对比

  1. Producer 行为不一样

普通队列:直接 XADD stream

延时队列:先 ZADD delay_queue,到期再 XADD


  1. 监听器注册逻辑不同

普通队列:只需要注册进容器

延时队列:还要维护 streamKey 集合 → 供延时扫描器使用


  1. 消费端模型没变

  1. 新增延时调度器

普通队列:把到期 ZSET 消息 转投 XADD


  1. 新增Key/成员编码

延时队列:需要 ZSET 队列 key 规则与唯一化的成员编码。


5、完整代码

5.1、引入依赖

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

5.2、自定义注解(添加到业务代码的消费者上)

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

    // Stream key
    String streamKey();

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

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

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

5.3、创建消息监听容器

java 复制代码
@Configuration
public class DelayAnnotainContainerConfig {

    @Bean(initMethod = "start", destroyMethod = "stop")
    @Primary
    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);
    }
}

5.4、新增Key/成员编码

java 复制代码
final class StreamZsetDelayKeys {
    static final String QUEUE_PREFIX = "streamZsetDelay:";

    private StreamZsetDelayKeys() {
    }

    static String delayQueueKey(String streamKey) {
        return QUEUE_PREFIX + streamKey;
    }

    static String encodePayload(String message) {
        String safeMessage = message == null ? "" : message;
        return UUID.randomUUID() + "|" + safeMessage;
    }

    static String decodePayload(String member) {
        if (member == null) {
            return "";
        }
        int idx = member.indexOf('|');
        if (idx < 0) {
            return member;
        }
        if (idx == member.length() - 1) {
            return "";
        }
        return member.substring(idx + 1);
    }
}

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

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

    private ApplicationContext applicationContext;

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

    @Autowired
    private StringRedisTemplate redisTemplate;

    private final Set<String> streamKeys = ConcurrentHashMap.newKeySet();

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

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

                if (listener != null) {
                    // 注册为 Redis Stream 消费者
                    registerListener(bean, method, listener);
                }
            }
        });
    }

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

        // 读取注解配置
        String streamKey = listener.streamKey();
        String group = listener.group();
        streamKeys.add(streamKey);
        // 生成 Consumer 名称
        String consumer = listener.consumer().isEmpty()
                ? UUID.randomUUID().toString()
                : listener.consumer();

        // 确保 group 存在
        try {
            redisTemplate.opsForStream().createGroup(streamKey, group);
        } catch (Exception ignore) {
        }

        container.receive(
                Consumer.from(group, consumer), // 指定消费组 & 消费者
                StreamOffset.create(streamKey, ReadOffset.lastConsumed()), // 从未 ack 的位置开始
                message -> invokeString(bean, method, message, listener) // 消息回调
        );
    }

    Set<String> getStreamKeys() {
        return streamKeys;
    }

    private void invokeString(
            Object bean, // Spring 容器里的 真实 Bean 实例
            Method method, // Spring 容器里的 真实 Bean 实例
            MapRecord<String, String, String> record, // Redis Stream 的一条消息
            DelayRedisStreamListener 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();
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }
}

5.6、延时调度器

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

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private DelayRedisStreamListenerRegistrar listenerRegistrar;

    @Value("${batch-size:100}")
    private long batchSize;

    @Scheduled(fixedDelayString = "${poll-interval-ms:1000}")
    public void dispatchDelayMessages() {
        Set<String> streamKeys = listenerRegistrar.getStreamKeys();
        if (streamKeys.isEmpty()) {
            return;
        }

        long now = System.currentTimeMillis();
        for (String streamKey : streamKeys) {
            String delayKey = StreamZsetDelayKeys.delayQueueKey(streamKey);
            drainDelayQueue(delayKey, streamKey, now);
        }
    }

    /**
     * 从指定的延时 ZSET 里批量拉出"已到期"的成员(score <= now),
     * 逐条投递到对应的 Stream(XADD),
     * 投递成功后再从 ZSET 删除,直到本轮没有更多到期消息或数量不足 batchSize 为止。
     */
    private void drainDelayQueue(String delayKey, String streamKey, long now) {
        long limit = Math.max(1L, batchSize);
        while (true) {
            // 从延时 ZSET 中批量取出到期消息(score <= now)
            // 对应 Redis 命令:ZRANGEBYSCORE {delayKey} 0 {now} LIMIT 0 {limit}
            Set<String> payloads = redisTemplate.opsForZSet().rangeByScore(delayKey, 0, now, 0, limit);
            if (payloads == null || payloads.isEmpty()) {
                return;
            }
            for (String payload : payloads) {
                String message = StreamZsetDelayKeys.decodePayload(payload);
                try {
                    Map<String, String> body = new HashMap<>();
                    body.put("data", message);
                    // 投递到 Stream
                    // 对应 Redis 命令:XADD {streamKey} * data "{message}"
                    redisTemplate.opsForStream()
                            .add(StreamRecords.mapBacked(body).withStreamKey(streamKey));
                    // 投递成功后,从延时 ZSET 移除该条消息
                    // 对应 Redis 命令:ZREM {delayKey} "{payload}"
                    redisTemplate.opsForZSet().remove(delayKey, payload);
                } catch (Exception e) {
                    log.error("Delay dispatch failed. streamKey={}, delayKey={}", streamKey, delayKey, e);
                }
            }
            if (payloads.size() < limit) {
                return;
            }
        }
    }
}

5.7、消息生产者

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

    public void send(String streamKey, String message) {
        send(streamKey, message, 0L);
    }

    public void send(String streamKey, String message, long delayMillis) {
        String delayKey = StreamZsetDelayKeys.delayQueueKey(streamKey);
        String payload = StreamZsetDelayKeys.encodePayload(message);
        long score = System.currentTimeMillis() + Math.max(0L, delayMillis);
        redisTemplate.opsForZSet().add(delayKey, payload, score);
    }

}

6、模拟业务

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

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

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

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

}

6.2、写一个测试接口

java 复制代码
    @GetMapping("/send")
    public void sendMessage(){
        RedisMQPayDTO redisMQPayDTO = new RedisMQPayDTO();
        redisMQPayDTO.setId(1);
        redisMQPayDTO.setPayOrderId("order-1");
        // 支付业务立刻输出
        delayAnnotainProducer.send("delay:mq:redis:pay", JSON.toJSONString(redisMQPayDTO));
        	// 订单业务延时10秒
        delayAnnotainProducer.send("delay:mq:redis:order","orderId=1",10000);
    }

6.3、控制台输出

符合预期:

1、支付业务立刻输出

2、订单业务与支付业务之间差10秒

java 复制代码
2026-01-11T15:45:29.814+08:00  INFO 72643 --- [cTaskExecutor-2] DelayListenerImpl : 支付业务:{"id":1,"payOrderId":"order-1"}
2026-01-11T15:45:39.686+08:00  INFO 72643 --- [cTaskExecutor-1] DelayListenerImpl : 订单业务:orderId=1

上述代码是Redis延时队列的实现,从普通队列改为延时队列,在第4小节有讲解,其他代码以及Redis命令详细讲解请 👉点击👈


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

相关推荐
短剑重铸之日2 小时前
《7天学会Redis》Day 6 - 内存&性能调优
java·数据库·redis·缓存·7天学会redis
石头wang2 小时前
jmeter java.lang.OutOfMemoryError: Java heap space 修改内存大小,指定自己的JDK
java·开发语言·jmeter
鱼跃鹰飞2 小时前
面试题:解释一下什么是全字段排序和rowid排序
数据结构·数据库·mysql
yaoxin5211232 小时前
292. Java Stream API - 使用构建器模式创建 Stream
java·开发语言
阮松云2 小时前
code-server 配置maven
java·linux·maven
y***n6142 小时前
springboot项目架构
spring boot·后端·架构
Aloudata技术团队2 小时前
完美应对千亿级明细数据计算:Aloudata CAN 双引擎架构详解
数据库·数据分析·数据可视化
Dxy12393102162 小时前
MySQL连表查询讲解:从基础到实战
数据库·mysql
DemonAvenger2 小时前
Redis数据迁移与扩容实战:平滑扩展的技术方案
数据库·redis·性能优化