
项目里需要一个消息队列的时候,你第一反应是什么?
Kafka?RabbitMQ?RocketMQ?
但很多时候,我们并不是真的需要一个"重型 MQ"。
前段时间我在项目里遇到一个很典型的场景:
- 需要异步解耦一些业务逻辑
- 消息量不算小,但也远没到 MQ 的级别
- 运维成本希望尽量低
- 最关键的是:Redis 一般是我们项目中的基础设施
1、Redis Stream 概念速览
Stream(消息流)是 Redis 5.0 引入的数据结构,本质上是一个"可回溯的、有消费状态的消息日志"。
它提供了很多消息队列才有的能力:
- 消息持久化
发布订阅模式(Pub/Sub) 是负责消息转发,而不负责存储的广播事件 ;
5.0新引入的 Stream 是会先把消息持久化下来,再由消费者按进度消费的可回溯的消息流。
- Consumer Group(消费者组)
Pub/Sub:每个订阅者都会收到消息;
Stream + Group:组内只会有一个消费者拿到消息
- ACK 确认机制
将收到的消息,标记为已读
- Pending Entries List(失败可恢复)
服务宕机、没有ACK,这条消息会永远存到PEL中
2、Stream命令
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 不存在则自动创建
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
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 的消息
XACK确认消息
bash
XACK key group id [id ...]
XACK: 从消费者组的Pending List 中移除这些消息
参数讲解:1、id :要确认的 消息 ID
2、 [id ...]:一次可以 确认多条消息
3、用原生命令模拟 订单、支付 MQ业务场景
- 创建消费者组
支付消息队列(pay)
bash
XGROUP CREATE redis:mq:pay pay-group $ MKSTREAM
订单消息队列(order)
bash
XGROUP CREATE redis:mq:order order-group $ MKSTREAM
注:
$:从最新消息开始消费
- 生产消息
往支付 Stream 写消息
bash
XADD redis:mq:pay * msg "这里是支付业务"
往订单 Stream 写消息
bash
XADD redis:mq:order * msg "这里是订单业务"
注:
*:自动生成消息 ID
- 消费消息
消费支付业务消息
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:一次读一条
- 确认消息
确认支付消息
bash
XACK redis:mq:pay pay-group 1767679949417-0
确认订单消息
bash
XACK redis:mq:order order-group 1767679953770-0
注:
1、消息被确认后,会从 Pending Entries List(PEL) 中移除
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"}
若文中存在不足或错误,欢迎在评论区留言指正,大家共同交流、共同进步。
如果本文对你有所帮助,欢迎关注小名给予支持 😄
点赞 👍、评论 ✍、收藏 🤞 是对小名莫大的鼓励,感谢大家的支持 ♥️