Spring Boot 3.2.5 + Redis 5 Stream Demo
目录结构:
src/main/java/com/example/streamdemo
├── StreamDemoApplication.java
├── config/RedisStreamConfig.java
├── producer/TaskProducer.java
└── consumer/TaskConsumer.java
application.yml 位于 src/main/resources/application.yml
java
// ===============================
// StreamDemoApplication.java
// ===============================
package com.example.streamdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 程序入口:Spring Boot 应用启动类
* 这是最基础的 Spring Boot 应用启动器,负责引导 Spring 容器并加载配置。
*/
@SpringBootApplication
public class StreamDemoApplication {
public static void main(String[] args) {
SpringApplication.run(StreamDemoApplication.class, args);
}
}
// ===============================
// RedisStreamConfig.java
// ===============================
package com.example.streamdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;
import java.time.Duration;
/**
* Redis Stream 相关配置类
* - 定义 Stream 名称、消费组名
* - 提供一个 StreamMessageListenerContainer Bean,用于将消息监听器注册到 Redis 客户端
*
* 说明:StreamMessageListenerContainer 是 Spring Data Redis 为 Streams 提供的一个高级抽象,
* 它会内部管理线程/重连/轮询策略,让我们专注于消息处理逻辑。
*/
@Configuration
public class RedisStreamConfig {
// Stream key(Redis 中的 stream 名称)
public static final String STREAM_KEY = "task-stream";
// 消费组名称
public static final String GROUP = "task-group";
/**
* 创建并暴露 StreamMessageListenerContainer
*
* @param factory Redis 连接工厂(由 Spring Boot 自动配置)
* @return 配置好的监听容器
*/
@Bean
public StreamMessageListenerContainer<String, MapRecord<String, String, String>> listenerContainer(
RedisConnectionFactory factory
) {
// 配置容器选项:pollTimeout 控制底层 XREAD 的阻塞等待超时时间(短轮询备份)
// 这里设置为 2 秒:如果 Redis 长时间没有新消息,XREAD 对应的阻塞会每 2s 返回一次(用于让容器可响应停止等操作)
var options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ZERO)
.build();
// 创建容器实例并返回
return StreamMessageListenerContainer.create(factory, options);
}
}
// ===============================
// TaskProducer.java
// ===============================
package com.example.streamdemo.producer;
import com.example.streamdemo.config.RedisStreamConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.connection.stream.StreamRecords;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 任务生产者:负责将任务发送到 Redis Stream
*
* 说明:
* - 使用 StringRedisTemplate 的 opsForStream().add(...) 方法将消息添加到 stream 中(等同于 XADD)
* - 每条消息在 Stream 中会有一个自动生成的 ID(RecordId),生产者可以根据需要记录或返回该 ID
*/
@Component
public class TaskProducer {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 发送任务到 stream
*
* @param data 任意字符串形式的任务数据(生产环境可改为 JSON 或 Map)
* @return Redis 返回的记录 ID(可用于追踪)
*/
public RecordId sendTask(String data) {
// redisTemplate.opsForStream() - 获取 Redis Stream 操作接口。
// .add() - 向 Stream 添加一条新记录
// StreamRecords.string() 用于构造一个简单的字符串消息记录
// RedisStreamConfig.STREAM_KEY - 从配置类获取 Stream 的名称(键),这指定了消息要发送到哪个 Stream
return redisTemplate.opsForStream().add(
StreamRecords.newRecord()
.in(RedisStreamConfig.STREAM_KEY)
.ofMap(Map.of("data", data))
);
/*
.ofMap(Map.of("data", data)) - 设置消息内容:
Map.of("data", data) - 创建一个不可变的 Map,包含一个键值对
键:"data" - 字段名
值:data - 方法参数,即要发送的任务数据
这样消息的结构就是 {"data": "实际任务内容"}
*/
}
}
// ===============================
// TaskConsumer.java
// ===============================
package com.example.streamdemo.consumer;
import com.example.streamdemo.config.RedisStreamConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.ReadOffset;
import org.springframework.data.redis.connection.stream.StreamOffset;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;
import org.springframework.stereotype.Component;
/**
* 任务消费者:在应用启动后初始化消费组并启动监听器
*
* 关键点:
* - 创建消费组(如果已存在,会抛异常,我们捕获并忽略)
* - 使用 listenerContainer.receive(...) 注册监听器,该监听器会在收到消息时回调处理
* - 在处理完成后,手动 ACK 这条消息(告知 Redis 该消息已被成功消费)
*/
@Component
public class TaskConsumer {
@Autowired
private StringRedisTemplate redisTemplate;
// 注入我们在配置中创建的监听容器
@Autowired
private StreamMessageListenerContainer<String, MapRecord<String, String, String>> listenerContainer;
@Autowired
private RedisConnectionFactory factory;
/**
* 应用启动完成后执行初始化:创建消费组并启动监听
*/
@EventListener(ApplicationReadyEvent.class)
public void init() {
try {
// 尝试创建消费组:XGROUP CREATE task-stream task-group 0 MKSTREAM
// ReadOffset.from("0-0") 表示从 stream 的开头开始(也可以使用 ReadOffset.latest())
// 注意:如果 stream 不存在且不使用 mkstream=true 会失败。Spring 的 createGroup 有重载可以传 mkstream 参数,
// 这里使用默认行为可能会根据 Spring Data Redis 版本有差异,生产中可先确保 stream 存在或使用 mkstream。
redisTemplate.opsForStream().createGroup(
RedisStreamConfig.STREAM_KEY,
ReadOffset.from("0-0"),
RedisStreamConfig.GROUP
);
System.out.println("消费组创建成功");
} catch (Exception e) {
// 如果消费组已存在,会触发异常;这时可以安全忽略,应用继续
System.out.println("消费组可能已存在: " + e.getMessage());
}
// 注册消费者回调:当有新消息可读时,容器会调用我们的 lambda
listenerContainer.receive(
// 这里声明当前 listener 属于哪个消费组和哪个 consumer
org.springframework.data.redis.connection.stream.Consumer.from(RedisStreamConfig.GROUP, "consumer-1"),
// 从上次消费点(last consumed)继续读取
StreamOffset.create(RedisStreamConfig.STREAM_KEY, ReadOffset.lastConsumed()),
// 处理函数:收到 MapRecord(key-value)时执行
message -> {
// message.getValue() 返回 Map<String,String>,本例中我们放了 data 字段
String data = message.getValue().get("data");
System.out.println("收到任务: " + data + " , msgId=" + message.getId());
try {
// 这里是处理业务逻辑的地方,例如解析、执行任务、调用外部接口等
// 处理成功后需手动 ACK(告知 Redis 该消息已被消费)
// ACK 当前消息
redisTemplate.opsForStream().acknowledge(
RedisStreamConfig.GROUP,
message
);
System.out.println("ACK 成功: " + message.getId());
} catch (Exception ex) {
// 如果处理出错,不要 ack,这样这条消息会进入 PENDING(待确认)状态
// 你可以在此处记录日志、存入 DB、发告警,或触发重试逻辑
System.err.println("处理失败,消息保持 pending: " + message.getId() + " , "+ ex.getMessage());
}
}
);
// 启动容器(内部会启动线程池开始 XREADGROUP 循环)
listenerContainer.start();
System.out.println("Redis Stream 消费者已启动...");
}
}
// ===============================
// application.yml
// ===============================
// 将以下内容放到 src/main/resources/application.yml
//
// spring:
data:
redis:
host: localhost
port: 6379
# password: your_redis_password # 如有密码请取消注释
database: 0
timeout: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
//
// 说明:生产环境通常通过 spring.redis.url 或者 添加密码和 SSL 等配置
// ===============================
// ===============================
// TaskController.java(新增 REST 接口)
// ===============================
package com.example.streamdemo.controller;
import com.example.streamdemo.producer.TaskProducer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.web.bind.annotation.*;
/**
* REST API:用于通过 HTTP POST 生产任务,方便调试与联调
* 示例:curl -X POST http://localhost:8080/task -H "Content-Type: application/json" -d '{"data":"hello"}'
*/
@RestController
@RequestMapping("/task")
public class TaskController {
@Autowired
private TaskProducer taskProducer;
/**
* 提供 HTTP POST,接收 JSON,写入 Redis Stream
*/
@PostMapping
public String createTask(@RequestBody TaskRequest request) {
RecordId id = taskProducer.sendTask(request.getData());
return "Task sent. RecordId=" + id;
}
/**
* 简单的请求体对象
*/
public static class TaskRequest {
private String data;
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}
}
// ===============================
// TaskQueueInfoController.java(新增:pending + info 接口)
// ===============================
package com.example.streamdemo.controller;
import com.example.streamdemo.config.RedisStreamConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* REST API:查询 Redis Stream 消费堆积、Stream 详情
*/
@Slf4j
@RestController
@RequestMapping("/task")
public class TaskQueueInfoController {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private TaskProducer taskProducer;
@PostMapping("/testSend")
public String testSendTask() {
Map<String, Object> map = Map.of("data", LocalDateTime.now().toString());
RecordId recordId = taskProducer.sendTask(JSON.toJSONString(map));
log.info("Task send. recordId={}", recordId);
return "Task send. recordId=" + recordId;
}
/**
* GET /task/pending
* 查询当前消费组的 pending(待 ACK)消息列表(积压任务)
*/
@GetMapping("/pending")
public Object getPending() {
PendingMessagesSummary summary = redisTemplate.opsForStream()
.pending(RedisConfig.STREAM_KEY, RedisConfig.GROUP);
// 查询前 20 个 pending 详细信息
PendingMessages pendingList = redisTemplate.opsForStream().pending(
RedisConfig.STREAM_KEY,
Consumer.from(RedisConfig.GROUP, "consumer-1"),
Range.unbounded(),
20
);
return Map.of(
"summary", Map.of(
"count", summary.getTotalPendingMessages(),
"minId", summary.minMessageId(),
"maxId", summary.maxMessageId()
),
"details", pendingList.stream()
.map(p -> Map.of(
"msgId", p.getIdAsString(),
"consumer", p.getConsumerName(),
"idleMs", p.getId(),
"deliveryCount", p.getTotalDeliveryCount()
))
.collect(Collectors.toList())
);
}
/**
* GET /task/info
* 显示 Stream 基本信息,包括:长度、消费组、消费者、pending 数等
*/
@GetMapping("/info")
public Object getStreamInfo() {
StreamInfo.XInfoStream info = redisTemplate.opsForStream()
.info(RedisConfig.STREAM_KEY);
return Map.of(
"length", info.streamLength(),
"lastGeneratedId", info.lastGeneratedId(),
"groups", redisTemplate.opsForStream().groups(RedisConfig.STREAM_KEY)
.stream()
.map(g -> {
return Map.of(
"group", g.groupName(),
"consumers", g.consumerCount(),
"pending", g.pendingCount(),
"lastDeliveredId", g.lastDeliveredId()
);
}).toList()
);
}
}
// 使用说明(快速上手)
// ===============================
// 1. 启动 Redis(确保启用了 stream 功能,Redis 5.0+)
// 2. 启动 Spring Boot 应用
// 3. 在任意组件中注入 TaskProducer 并调用 sendTask("hello"),会看到消费者控制台打印并 ACK
//
// 常见扩展:
// - 当消息处理失败时,可使用 XCLAIM 将 pending 状态的消息转移到另一个消费者继续处理
// - 可定期运行监控任务,使用 XPENDING 查看 pending 列表并实现重试或 DLQ(死信队列)
// - 对于高吞吐,可以配置多个消费者实例(相同 GROUP,不同 consumer name)实现负载均衡
🔍 查看 PENDING(未被 ACK 的积压任务)
curl http://localhost:8080/task/pending
返回示例:
{
"summary": {
"count": 3,
"minId": "1699278123123-0",
"maxId": "1699279123999-0"
},
"details": [
{
"msgId": "1699278123123-0",
"consumer": "consumer-1",
"idleMs": 100232,
"deliveryCount": 1
}
]
}
🔎 查看 Stream 基本信息(长度、消费组、消费者、pending)
curl http://localhost:8080/task/info
返回示例:
{
"length": 125,
"lastGeneratedId": "1699279123999-0",
"groups": [
{
"group": "task-group",
"consumers": 1,
"pending": 3,
"lastDeliveredId": "1699278123123-0"
}
]
}
| 特性 | 方案(阻塞式 ListenerContainer) |
|---|---|
| 消费方式 | 阻塞式 XREADGROUP,持续监听 |
| 延迟 | 低延迟(毫秒级) |
| 性能(CPU) | 低消耗(阻塞等待) |
| Redis 负载 | 极低(阻塞式长轮询) |
| 可靠性 | 高(容器自动重连) |
| 能否用于高并发 | 强(多 consumer instance) |
| 是否生产级推荐 | ⭐ 官方推荐方式 + 最常用于生产 |
1.消费过程中若出现 error,是否影响队列正常接收任务?
不会。
ListenerContainer 的行为是:
-
某条消息处理失败 → 只影响这条消息
-
不会影响消费组继续接收下一条消息
-
不会阻塞整个队列
-
不会导致 Redis Stream 停止工作
listener 抛异常只会导致:
该消息不会被 ACK → 进入 PENDING 列表(待处理消息)。
队列仍然继续正常消费新消息。
2.消费失败(抛异常),消息是不是已经不在队列中了?
不是!消息仍然在 Redis 中。
Redis Stream 的状态是这样的:
| 状态 | 消息位置 |
|---|---|
| 未消费 | 在 Stream 主体里(类似 topic) |
| 被消费组读取但未 ACK | 在 PENDING 中 |
| ACK 成功 | 留在 Stream 主体,但标记为已消费(可删除) |
| DEL 删除 | 手动删除后才真正从 Redis 中消失 |
当消费失败时:
-
消息仍然在 Stream 里
-
并且 在 PEL(Pending Entries List)列表中
-
等待重新消费(claim)
因此失败消息 不会丢。
3.已经成功消费(已 ACK)的任务会不会被重复消费?
不会重复消费,只要你 ACK 了该消息。
Redis Stream 的消费组机制(XREADGROUP)保证:
-
同一个消费组里的多个消费者是 负载均衡 的
-
一条消息只会分配给一个消费者
-
消费者成功处理并执行
XACK后→ 该消息从消费组的 pending 队列中移除
→ 不会再被任何消费者收到