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

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

相关推荐
咩咩啃树皮8 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚8 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白10 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司10 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求
爱笑的源码基地11 小时前
高并发 Redis 缓存门诊HIS系统源码,含财务统计药房进销存
java·程序·门诊系统·诊所系统·云诊所源码
wuqingshun31415911 小时前
TCP超时重传机制是为了解决什么问题?
java
二宝哥12 小时前
CentOS 7.9 系统下 Redis 5.0.4 单机安装与源码编译安装指南
redis·centos
莫逸风14 小时前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope
隔窗听雨眠14 小时前
Spring Boot在云原生时代的编程范式革新研究
spring boot·后端·云原生
z1234567898614 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程