
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、差异点拆解
- 为什么 Stream 不能实现延时队列?
Redis Stream 没有原生延时投递机制
Stream 只有 "追加 + 消费" 的模型,没有 "到期后自动可见"
如果要延时,只能自己加一层调度(即现在的 ZSET + 扫描) - 为什么选择 ZSET 做延时队列?
ZSET 支持 score 排序,天然适合 "时间戳排序"
能高效执行 ZRANGEBYSCORE 0 now 拉取到期任务
Redis 官方最常见延时队列实践之一就是 ZSET(高效、简单、无需额外组件)
4、普通队列 和 延时队列 代码对比
- Producer 行为不一样
普通队列:直接 XADD stream

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

- 监听器注册逻辑不同
普通队列:只需要注册进容器

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

- 消费端模型没变
- 新增延时调度器
普通队列:把到期 ZSET 消息 转投 XADD

- 新增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命令详细讲解请 👉点击👈
若文中存在不足或错误,欢迎在评论区留言指正,大家共同交流、共同进步。
如果本文对你有所帮助,欢迎关注小名给予支持 😄
点赞 👍、评论 ✍、收藏 🤞 是对小名莫大的鼓励,感谢大家的支持 ♥️