Spring Boot集成Redis Stream消息队列:从入门到实战 - 实践

Spring Boot集成Redis Stream消息队列:从入门到实战

在现代分布式系统中,消息队列是实现系统解耦、异步处理和流量削峰的重要组件。Redis Stream作为Redis 5.0引入的新数据类型,提供了完整的消息队列功能,成为轻量级消息中间件的优秀选择。

前言

早期我使用 redis pubsub 的方式实现了消息订阅,但是在使用过程,发现如果部署多实例将会重复处理消息事件,导致业务重复处理的情况。

根本原因是 redis pubsub 都是直接广播,无法控制多实例重复订阅消息的情况。

为了解决这个问题,则需要改动 Redis Stream 的方式来构建消息队列。

以下我分别三个案例逐步实践,达到一个可以适应生产环境要求的成熟方案:

  • 简单使用 StringRedisTemplate 构建一个消费队列
  • 使用 StringRedisTemplate 构建多个消费队列
  • 使用 RedissonClient 构建多个消费队列,包含消费队列的运维监控、消费队列查询/重置 等功能

一、Redis Stream简介与核心特性

Redis Stream是Redis 5.0版本专门为消息队列场景设计的数据结构,它借鉴了Kafka的设计理念,提供了消息持久化消费者组消息确认机制等核心功能。

Redis Stream核心优势

  • 高性能:基于内存操作,吞吐量高
  • 持久化:消息可持久化保存,支持RDB和AOF
  • 消费者组:支持多消费者负载均衡,确保消息不被重复消费
  • 阻塞操作:支持类似Kafka的长轮询机制

Redis Stream基础操作示例

1. 添加消息到Stream
# 自动生成消息ID
XADD mystream * name "订单创建" order_id "1001" amount "299.99"
# 限制Stream最大长度(保留最新1000条消息)
XADD mystream MAXLEN 1000 * name "订单支付" order_id "1001"
2. 查询消息
# 查询所有消息
XRANGE mystream - +
# 分页查询,每次返回10条
XRANGE mystream - + COUNT 10
# 反向查询(从新到旧)
XREVRANGE mystream + - COUNT 5
3. 监控新消息
# 阻塞监听新消息(0表示不超时)
XREAD BLOCK 0 STREAMS mystream $

二、Spring Boot 简单集成案例

1. 添加依赖

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
  <dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-pool2</artifactId>
  </dependency>
</dependencies>

2. 配置文件

# redis 配置
spring:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
# redis stream 消费组配置
app:
stream:
key: "order_stream"
group: "order_group"

3. 消息生产者服务

package com.lijw.mp.event;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
@Service
@Slf4j
public class MessageProducerService {

@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${app.stream.key}")
private String streamKey;
/**
* 发送消息到Redis Stream
*/
public String sendMessage(String messageType, Map<String, String> data) {
  
  try {
  
  // 添加消息类型和时间戳
  data.put("messageType", messageType);
  data.put("timestamp", String.valueOf(System.currentTimeMillis()));
  data.put("messageId", UUID.randomUUID().toString());
  RecordId messageId = stringRedisTemplate.opsForStream()
  .add(streamKey, data);
  log.info("消息发送成功: {}", messageId);
  return messageId.toString();
  } catch (Exception e) {
  
  log.error("消息发送失败: {}", e.getMessage());
  throw new RuntimeException("消息发送失败", e);
  }
  }
  }

4. 消息消费者服务(支持幂等性)

package com.lijw.mp.event;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamListener;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
@Component
@Slf4j
public class MessageConsumerService implements StreamListener<String, MapRecord<String, String, String>> {
  
  @Autowired
  private StringRedisTemplate stringRedisTemplate;
  @Value("${app.stream.group}")
  private String groupName;
  @Value("${app.stream.key}")
  private String streamKey;
  @Override
  public void onMessage(MapRecord<String, String, String> message) {
    
    String messageId = message.getId().toString();
    Map<String, String> messageBody = message.getValue();
      // 幂等性检查:防止重复处理[1](@ref)
      if (isMessageProcessed(messageId)) {
      
      log.info("消息已处理,跳过: {}", messageId);
      acknowledgeMessage(message);
      return;
      }
      try {
      
      log.info("消费者收到消息 - ID: {}, 内容: {}", messageId, messageBody);
      // 处理业务逻辑
      boolean processSuccess = processBusiness(messageBody);
      if (processSuccess) {
      
      // 标记消息已处理
      markMessageProcessed(messageId);
      // 清除重试次数
      clearRetryCount(messageId);
      // 手动确认消息
      acknowledgeMessage(message);
      log.info("消息处理完成: {}", messageId);
      } else {
      
      log.error("业务处理失败,消息将重试: {}", messageId);
      handleRetry(messageId, messageBody, message, "业务处理失败");
      }
      } catch (Exception e) {
      
      log.error("消息处理异常: {}", messageId, e);
      handleRetry(messageId, messageBody, message, "消息处理异常");
      }
      }
      /**
      * 幂等性检查
      */
      private boolean isMessageProcessed(String messageId) {
      
      // 使用Redis存储,判断redis是否已存在处理key,如果存在则说明消息已处理,确保多实例间幂等性
      return stringRedisTemplate.opsForValue().get("processed:" + messageId) != null;
      }
      /**
      * 标记消息已处理
      */
      private void markMessageProcessed(String messageId) {
      
      // 使用Redis存储事件处理ID,设置过期时间
      stringRedisTemplate.opsForValue().set("processed:" + messageId, "1",
      Duration.ofHours(24));
      }
      /**
      * 业务处理逻辑
      */
      private boolean processBusiness(Map<String, String> messageBody) {
        
        try {
        
        String messageType = messageBody.get("messageType");
        String orderId = messageBody.get("orderId");
        log.info("处理{}消息,订单ID: {}", messageType, orderId);
        // 模拟业务处理
        Thread.sleep(5000);
        return true;
        } catch (Exception e) {
        
        log.error("业务处理异常", e);
        return false;
        }
        }
        /**
        * 手动确认消息
        */
        private void acknowledgeMessage(MapRecord<String, String, String> message) {
          
          try {
          
          stringRedisTemplate.opsForStream()
          .acknowledge(groupName, message);
          } catch (Exception e) {
          
          log.error("消息确认失败: {}", message.getId(), e);
          }
          }
          /**
          * 增加重试次数
          */
          private int incrementRetryCount(String messageId) {
          
          String retryKey = "retry:count:" + messageId;
          String countStr = stringRedisTemplate.opsForValue().get(retryKey);
          int retryCount = countStr == null ? 0 : Integer.parseInt(countStr);
          retryCount++;
          // 设置重试次数,过期时间为24小时
          stringRedisTemplate.opsForValue().set(retryKey, String.valueOf(retryCount), Duration.ofHours(24));
          return retryCount;
          }
          /**
          * 清除重试次数
          */
          private void clearRetryCount(String messageId) {
          
          String retryKey = "retry:count:" + messageId;
          stringRedisTemplate.delete(retryKey);
          }
          /**
          * 处理消息重试逻辑
          */
          private void handleRetry(String messageId, Map<String, String> messageBody,
            MapRecord<String, String, String> message, String errorDescription) {
              
              // 记录重试次数
              int retryCount = incrementRetryCount(messageId);
              // 检查是否超过最大重试次数
              int maxRetryCount = 3; // 最大重试次数,可根据业务需求调整
              if (retryCount >= maxRetryCount) {
              
              log.error("消息{}重试次数已达上限({}),将停止重试并记录: {}", errorDescription, maxRetryCount, messageId);
              // 记录失败消息到死信队列或告警(可根据业务需求实现)
              handleMaxRetryExceeded(messageId, messageBody, retryCount);
              // 确认消息,避免无限重试
              acknowledgeMessage(message);
              } else {
              
              log.warn("消息{},当前重试次数: {}/{}, 消息ID: {}", errorDescription, retryCount, maxRetryCount, messageId);
              // 不确认消息,等待重试
              }
              }
              /**
              * 处理超过最大重试次数的消息
              */
              private void handleMaxRetryExceeded(String messageId, Map<String, String> messageBody, int retryCount) {
                
                try {
                
                // 记录失败消息详情(可根据业务需求实现,如存储到数据库、发送告警等)
                String failedKey = "failed:message:" + messageId;
                String failedInfo = String.format("消息ID: %s, 重试次数: %d, 消息内容: %s, 失败时间: %s",
                messageId, retryCount, messageBody, System.currentTimeMillis());
                stringRedisTemplate.opsForValue().set(failedKey, failedInfo, Duration.ofDays(7));
                log.error("失败消息已记录: {}", failedInfo);
                // TODO: 可根据业务需求添加其他处理逻辑,如:
                // 1. 发送告警通知
                // 2. 存储到数据库死信表
                // 3. 发送到死信队列
                } catch (Exception e) {
                
                log.error("处理超过最大重试次数消息异常: {}", messageId, e);
                }
                }
                }

5. 消费者容器配置(支持多实例负载均衡)

package com.lijw.mp.config.redisstream;
import com.lijw.mp.event.MessageConsumerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.Consumer;
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.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamMessageListenerContainer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.net.InetAddress;
import java.time.Duration;
import java.lang.management.ManagementFactory;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
@Configuration
@Slf4j
public class RedisStreamConfig {

@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${app.stream.key}")
private String streamKey;
@Value("${app.stream.group}")
private String groupName;
/**
* 创建消费者组(如果不存在)
*/
@EventListener(ApplicationReadyEvent.class)
public void createConsumerGroup() {

try {

stringRedisTemplate.opsForStream()
.createGroup(streamKey, groupName);
log.info("创建消费者组成功: {}", groupName);
} catch (Exception e) {

log.info("消费者组已存在: {}", e.getMessage());
}
}
/**
* 配置Stream消息监听容器
*/
@Bean
public StreamMessageListenerContainer<String, MapRecord<String, String, String>>
  streamMessageListenerContainer(MessageConsumerService messageConsumerService) {
  
  // 容器配置
  StreamMessageListenerContainer<String, MapRecord<String, String, String>> container =
    StreamMessageListenerContainer.create(redisConnectionFactory,
    StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder()
    .pollTimeout(Duration.ofSeconds(2))
    .batchSize(10) // 批量处理提高性能
    .executor(createThreadPool()) // 自定义线程池
    .build());
    // 为每个实例生成唯一消费者名称
    String consumerName = generateUniqueConsumerName();
    // 配置消费偏移量
    StreamOffset<String> offset = StreamOffset.create(streamKey, ReadOffset.lastConsumed());
      // 创建消费者
      Consumer consumer = Consumer.from(groupName, consumerName);
      // 构建读取请求(手动确认模式)
      StreamMessageListenerContainer.StreamReadRequest<String> request =
        StreamMessageListenerContainer.StreamReadRequest.builder(offset)
        .consumer(consumer)
        .autoAcknowledge(false) // 手动确认确保可靠性[2](@ref)
        .cancelOnError(e -> false) // 错误时不停止消费
        .build();
        container.register(request, messageConsumerService);
        container.start();
        log.info("Redis Stream消费者启动成功 - 消费者名称: {}", consumerName);
        return container;
        }
        /**
        * 生成唯一消费者名称(支持多实例部署的关键)
        * 使用IP+进程ID确保集群环境下唯一性
        */
        private String generateUniqueConsumerName() {
        
        try {
        
        String hostAddress = InetAddress.getLocalHost().getHostAddress();
        String processId = ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
        return hostAddress + "_" + processId + "_" + System.currentTimeMillis();
        } catch (Exception e) {
        
        //  fallback:使用UUID
        return "consumer_" + UUID.randomUUID().toString().substring(0, 8);
        }
        }
        /**
        * 创建专用线程池
        */
        private ExecutorService createThreadPool() {
        
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("redis-stream-");
        executor.setDaemon(true);
        executor.initialize();
        return executor.getThreadPoolExecutor();
        }
        }

6. 待处理消息重试机制

package com.lijw.mp.config.redisstream;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.stream.PendingMessage;
import org.springframework.data.redis.connection.stream.PendingMessages;
import org.springframework.data.redis.connection.stream.PendingMessagesSummary;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@Slf4j
public class PendingMessageRetryService {

@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${app.stream.key}")
private String streamKey;
@Value("${app.stream.group}")
private String groupName;
/**
* 定时处理未确认的消息
* 执行时机:每30秒执行一次(通过@Scheduled注解配置)
*/
@Scheduled(fixedDelay = 30000) // 每30秒执行一次
public void retryPendingMessages() {

try {

// 获取待处理消息摘要
PendingMessagesSummary pendingSummary = stringRedisTemplate.opsForStream()
.pending(streamKey, groupName);
if (pendingSummary != null) {

log.info("检查待处理消息,消费者组: {}", groupName);
// TODO: 根据Spring Data Redis版本,使用正确的API获取详细的pending消息列表
// 例如:使用pendingRange方法或其他方法获取PendingMessages
// 获取到PendingMessages后,调用processPendingMessages方法进行处理
// 
// 示例调用(需要根据实际API调整):
// PendingMessages pendingMessages = stringRedisTemplate.opsForStream()
//         .pendingRange(streamKey, groupName, ...);
// if (pendingMessages != null && pendingMessages.size() > 0) {

//     processPendingMessages(pendingMessages);
// }
}
} catch (Exception e) {

log.error("处理待处理消息异常", e);
}
}
/**
* 处理待处理消息列表
*
* 执行时机:
* 1. 由 retryPendingMessages() 定时任务调用(每30秒执行一次)
* 2. 当 retryPendingMessages() 获取到 PendingMessages 列表后调用
* 3. 用于处理Redis Stream中未被确认(ACK)的消息
*
* 处理逻辑:
* - 遍历每条pending消息
* - 记录消息ID和消费者名称
* - 检查消息空闲时间,如果超过阈值则重新分配
*/
private void processPendingMessages(PendingMessages pendingMessages) {

pendingMessages.forEach(message -> {

String messageId = message.getId().toString();
String consumerName = message.getConsumerName();
// 注意:根据Spring Data Redis版本,PendingMessage的API可能不同
// 获取空闲时间的方法名可能是getIdleTimeMs()、getElapsedTimeMs()等
// 这里提供基础框架,需要根据实际API调整
log.info("处理待处理消息: {}, 消费者: {}", messageId, consumerName);
// 如果消息空闲时间超过阈值(如5分钟),重新分配
// 示例逻辑(需要根据实际API调整):
// Duration idleTime = Duration.ofMillis(message.getIdleTimeMs());
// if (idleTime.toMinutes() > 5) {

//     log.info("重新分配超时消息: {}, 原消费者: {}, 空闲时间: {}分钟",
//             messageId, consumerName, idleTime.toMinutes());
//     // 可以使用XCLAIM命令将消息重新分配给其他消费者
//     // stringRedisTemplate.opsForStream().claim(...);
// }
});
}
}

7. REST控制器

package com.lijw.mp.controller;
import com.lijw.mp.event.MessageProducerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/message")
@Slf4j
public class MessageController {

@Autowired
private MessageProducerService messageProducerService;
@PostMapping("/send-order")
public ResponseEntity<Map<String, Object>> sendOrderMessage(
  @RequestParam String orderId,
  @RequestParam String amount) {
  
  Map<String, String> message = new HashMap<>();
    message.put("orderId", orderId);
    message.put("amount", amount);
    message.put("messageType", "ORDER_CREATED");
    try {
    
    String messageId = messageProducerService.sendMessage("ORDER", message);
    Map<String, Object> result = new HashMap<>();
      result.put("success", true);
      result.put("messageId", messageId);
      result.put("timestamp", System.currentTimeMillis());
      return ResponseEntity.ok(result);
      } catch (Exception e) {
      
      log.error("发送消息失败", e);
      Map<String, Object> result = new HashMap<>();
        result.put("success", false);
        result.put("error", e.getMessage());
        return ResponseEntity.status(500).body(result);
        }
        }
        @PostMapping("/send-custom")
        public ResponseEntity<Map<String, Object>> sendCustomMessage(
          @RequestBody Map<String, String> messageData) {
            
            try {
            
            String messageId = messageProducerService.sendMessage("CUSTOM", messageData);
            Map<String, Object> result = new HashMap<>();
              result.put("success", true);
              result.put("messageId", messageId);
              return ResponseEntity.ok(result);
              } catch (Exception e) {
              
              log.error("发送自定义消息失败", e);
              Map<String, Object> result = new HashMap<>();
                result.put("success", false);
                result.put("error", e.getMessage());
                return ResponseEntity.status(500).body(result);
                }
                }
                }
7.1 测试发送订单消息

在这里插入图片描述

POST http://localhost:8083/api/message/send-order
orderId=order123456
amount=500
7.2 测试发送自定义消息

在这里插入图片描述

POST http://localhost:8083/api/message/send-custom
{

"key1": "value1",
"key2": "valuie2"
}

8.启动多个SpringBoot实例,测试实例是否会重复处理事件

8.1 通过修改端口号,启动多个实例
  • 实例1

    server:
    port: 8083
  • 实例2

    server:
    port: 8084
8.2 发送自定义事件消息

在这里插入图片描述
发送多条事件消息

8.3 查看实例日志,确认事件未被重复消费
  • 实例1
    在这里插入图片描述

  • 实例2
    在这里插入图片描述
    可以看到 1764271408044-0 只在实例2处理,并没有在实例1处理。说明多个实例并不会重复消费同一事件。

三、SpringBoot 使用 StringRedisTemplate 集成 Redis Stream 进阶:配置多个消费组

1.配置文件

配置多个消费组key

# redis 配置
spring:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
# redis stream 配置多个消费组key
app:
stream:
groups:
- key: "order_stream"
group: "order_group"
consumer-prefix: "consumer_"
- key: "payment_stream"
group: "payment_group"
consumer-prefix: "consumer_"
- key: "notification_stream"
group: "notification_group"
consumer-prefix: "consumer_"

2.Redis Stream消费组配置属性

package com.lijw.mp.config.redisstream;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Redis Stream消费组配置属性
* - 读取 app.stream 配置前缀下的所有配置项
*
* @author Aron.li
* @date 2025/11/30 11:21
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.stream")
public class StreamGroupProperties {

/**
* 消费组列表
*/
private List<StreamGroupConfig> groups;
  /**
  * 单个消费组配置
  */
  @Data
  public static class StreamGroupConfig {
  
  /**
  * Stream键名
  */
  private String key;
  /**
  * 消费组名称
  */
  private String group;
  /**
  * 消费者名称前缀
  */
  private String consumerPrefix;
  }
  }

3. 消息生产者服务

package com.lijw.mp.event;
import com.lijw.mp.config.redisstream.StreamGroupProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
/**
* Redis Stream消息生产者服务
* 负责向Redis Stream发送消息
*/
@Service
@Slf4j
public class MessageProducerService {

/**
* Redis字符串模板
*/
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* Stream消费组配置属性
*/
@Autowired
private StreamGroupProperties streamGroupProperties;
/**
* 发送消息到Redis Stream(根据消息类型自动选择stream)
*
* @param messageType 消息类型
* @param data 消息数据
* @return 消息ID
*/
public String sendMessage(String messageType, Map<String, String> data) {
  
  // 根据消息类型自动选择stream key
  String streamKey = getStreamKeyByMessageType(messageType);
  return sendMessage(streamKey, messageType, data);
  }
  /**
  * 发送消息到指定的Redis Stream
  *
  * @param streamKey Stream键名
  * @param messageType 消息类型
  * @param data 消息数据
  * @return 消息ID
  */
  public String sendMessage(String streamKey, String messageType, Map<String, String> data) {
    
    try {
    
    // 添加消息类型和时间戳
    data.put("messageType", messageType);
    data.put("timestamp", String.valueOf(System.currentTimeMillis()));
    data.put("messageId", UUID.randomUUID().toString());
    RecordId messageId = stringRedisTemplate.opsForStream()
    .add(streamKey, data);
    log.info("消息发送成功 - Stream: {}, MessageId: {}", streamKey, messageId);
    return messageId.toString();
    } catch (Exception
posted @ 2026-02-11 17:51  clnchanpin  阅读(77)  评论(0)    收藏  举报