Java 17 + Spring Boot 3.2.5 使用 Redis 实现“生产者–消费者”任务队列

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 队列中移除

    → 不会再被任何消费者收到

相关推荐
qq_124987075344 分钟前
基于SpringBoot+vue的小黄蜂外卖平台(源码+论文+部署+安装)
java·开发语言·vue.js·spring boot·后端·mysql·毕业设计
小二·1 小时前
Spring框架入门:TX 声明式事务详解
java·数据库·spring
烤麻辣烫1 小时前
黑马程序员苍穹外卖后端概览
xml·java·数据库·spring·intellij-idea
天天摸鱼的java工程师1 小时前
JDK 25 到底更新了什么?这篇全景式解读带你全面掌握
java·后端
毕设源码-邱学长1 小时前
【开题答辩全过程】以 个人博客网站为例,包含答辩的问题和答案
java
5***b971 小时前
SpringBoot(整合MyBatis + MyBatis-Plus + MyBatisX插件使用)
spring boot·tomcat·mybatis
BBB努力学习程序设计1 小时前
Java面向对象基础:类和对象初探
java
寻找华年的锦瑟2 小时前
Qt-QStackedWidget
java·数据库·qt
洲星河ZXH2 小时前
Java,比较器
java·开发语言·算法