[超轻量级延时队列(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命令详细讲解请 👉点击👈


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

相关推荐
AI_56782 分钟前
阿里云OSS成本优化:生命周期规则+分层存储省70%
运维·数据库·人工智能·ai
送秋三十五3 分钟前
一次大文件处理性能优化实录————Java 优化过程
java·开发语言·性能优化
choke2335 分钟前
软件测试任务测试
服务器·数据库·sqlserver
龙山云仓5 分钟前
MES系统超融合架构
大数据·数据库·人工智能·sql·机器学习·架构·全文检索
雨中飘荡的记忆6 分钟前
千万级数据秒级对账!银行日终批处理对账系统从理论到实战
java
IT邦德7 分钟前
OEL9.7 安装 Oracle 26ai RAC
数据库·oracle
jbtianci11 分钟前
Spring Boot管理用户数据
java·spring boot·后端
Sylvia-girl14 分钟前
线程池~~
java·开发语言
编程彩机16 分钟前
互联网大厂Java面试:从Jakarta EE到微服务架构的技术场景深度解读
spring boot·分布式事务·微服务架构·java面试·jakarta ee
魔力军19 分钟前
Rust学习Day3: 3个小demo实现
java·学习·rust