之前聊过用 Redis List 实现消息队列(Redis 实现消息队列),但 List 方案有个致命问题:无法支持多消费者。
用一个例子感受一下:
场景:订单系统,消息队列处理订单
List 方案的问题:
- 消费者A拿了消息,机器挂了 → 消息丢了
- 消费者A和消费者B可能拿到同一条消息 → 重复消费
- 不知道消费者处理到哪了 → 无法追踪消费进度
Redis 5.0 引入的 Stream 就是来解决这些问题的。它是 Redis 专门为消息队列设计的数据结构,支持:
- 消费者组 - 多个消费者分工合作,消息只会被一个消费者处理
- 消息持久化 - 未处理的消息不会丢失
- ACK 确认 - 消费者处理完手动确认,才算真正消费
- 消息追溯 - 可以查看Pending 列表,处理失败重新投递
- 独立消费 - 每个消费者有自己的消费位置,互不影响
先看个例子
bash
# 1. 添加订单消息到 Stream
XADD orders * orderId 1001 amount 299.00 status pending
# "1703123456789-0" (消息ID,自动生成)
XADD orders * orderId 1002 amount 158.00 status pending
# "1703123456790-0"
XADD orders * orderId 1003 amount 99.00 status pending
# "1703123456791-0"
# 2. 读取所有消息
XRANGE orders - +
# 1) 1) "1703123456789-0"
# 2) 1) "orderId" "1001" "amount" "299.00" "status" "pending"
# 2) 1) "1703123456790-0"
# 2) (1) "orderId" "1002" "amount" "158.00" "status" "pending"
# ...
# 3. 创建一个消费者组
XGROUP CREATE orders order_processors $ MKSTREAM
# OK
# 4. 消费者A读取消息
XREADGROUP GROUP order_processors consumer_A COUNT 2 BLOCK 3000 STREAMS orders >
# 1) 1) "orders"
# 2) 1) 1) "1703123456789-0"
# 2) 1) "orderId" "1001" "amount" "299.00" "status" "pending"
# 2) 1) "1703123456790-0"
# 2) (1) "orderId" "1002" "amount" "158.00" "status" "pending"
# 5. 消费者A处理完成后确认
XACK orders order_processors 1703123456789-0 1703123456790-0
# 2 (确认了2条消息)
核心概念
Stream 的数据结构
Stream 本质上是一个紧凑的追加日志,类似 Redis 的有序集合,但又不太一样:
Stream 结构:
┌─────────────────────────────────────────────┐
│ Stream: orders │
├─────────────────────────────────────────────┤
│ 1703123456789-0 → [orderId=1001, amt=299] │
│ 1703123456790-0 → [orderId=1002, amt=158] │
│ 1703123456791-0 → [orderId=1003, amt=99] │
└─────────────────────────────────────────────┘
Radix Tree(基数树)存储,内存效率很高
消息 ID
Stream 的消息 ID 是一个时间戳 + 序号的组合:
格式:timestamp-sequence
例如:1703123456789-0
- timestamp:毫秒时间戳
- sequence:该毫秒内的序号(从0开始)
好处:
- ID 本身就有序,可以直接范围查询
- 支持按照时间范围筛选消息
- 自动保证唯一性
消费者组(Consumer Group)
消费者组是 Stream 最核心的特性:
Stream: orders
│
├── Consumer Group: order_processors
│ ├── Consumer A (消费位置: 1703123456789-0)
│ ├── Consumer B (消费位置: 1703123456791-0)
│ └── Consumer C (消费位置: -) // 新加入,还没开始消费
│
└── Consumer Group: analytics
└── Consumer X (消费位置: 1703123456789-0)
关键点:
- 一个 Stream 可以有多个消费者组
- 同一个消费者组内的消费者互斥消费 - 一条消息只会被一个消费者处理
- 不同消费者组独立消费同一条消息
- 每个消费者有自己的消费位置(last_delivered_id)
Pending 列表
消息被 XREADGROUP 读取后,进入 Pending 状态(待确认):
Pending 消息的特征:
- 消息已投递给消费者,但还没 XACK
- 如果消费者挂了,可以重新投递给其他消费者
- 记录了每个消费者"正在处理"的消息
可以用 XPENDING 查看 pending 消息:
bash
XPENDING orders order_processors
# 1) (integer) 2 # pending 消息数量
# 2) "1703123456789-0" # 最早 pending 的消息ID
# 3) "1703123456790-0" # 最新 pending 的消息ID
# 4) 1) 1) "consumer_A" # 消费者A有1条 pending
# 2) "1" # idle 时间(毫秒)
常用命令
添加消息:XADD
bash
# 基本用法,自动生成 ID
XADD orders * orderId 1001 amount 299.00
# "1703123456789-0"
# 指定 ID(必须是递增的)
XADD orders 1703123456790-0 orderId 1002 amount 158.00
# "1703123456790-0"
# ID 支持通配符 *,表示用当前时间自动生成
XADD orders 1703123456791-* orderId 1003 amount 99.00
# "1703123456791-0"
# 设置最大长度(类似 Ring Buffer)
XADD orders MAXLEN ~ 1000 * orderId 1004 amount 200.00
# ~ 是近似模式,会自动清理旧消息,性能更好
# 也可以用 MINID 策略,保留指定 ID 之后的消息
XADD orders MINID 1703123456700-0 * orderId 1005 amount 300.00
读取消息:XREAD
bash
# 读取新消息(从最新开始)
XREAD STREAMS orders 0
# 0 表示从最新消息开始
# 从指定 ID 开始读取(不包含该ID)
XREAD STREAMS orders 1703123456789-0
# 读取多条 Stream
XREAD STREAMS orders notifications 0 0
# 阻塞读取(等待新消息)
XREAD BLOCK 5000 STREAMS orders $
# $ 表示只等待新消息,5秒超时返回 nil
# 按范围读取
XRANGE orders 1703123456789-0 1703123456790-0
# - 表示最小,+ 表示最大
# 反向读取
XREVRANGE orders + - COUNT 10
消费者组:XREADGROUP
bash
# 创建消费者组
XGROUP CREATE orders order_processors $ MKSTREAM
# $ 表示该组从"当前最新消息"开始消费
# MKSTREAM 表示如果 Stream 不存在,自动创建
# 从头开始消费
XGROUP CREATE orders order_processors 0
# 消费新消息(> 表示只读取 new messages)
XREADGROUP GROUP order_processors consumer_A COUNT 10 STREAMS orders >
# 读取 Pending 消息(用于重试处理)
XREADGROUP GROUP order_processors consumer_A STREAMS orders 0
# 阻塞读取
XREADGROUP GROUP order_processors consumer_A BLOCK 3000 COUNT 10 STREAMS orders >
确认消息:XACK
bash
# 确认单条消息
XACK orders order_processors 1703123456789-0
# 批量确认
XACK orders order_processors 1703123456789-0 1703123456790-0 1703123456791-0
# 确认所有 Pending 消息
XPENDING orders order_processors | xargs -I {} XACK orders order_processors {}
查询相关
bash
# 获取 Stream 长度
XLEN orders
# (integer) 3
# 查看消费者组信息
XINFO GROUPS orders
# 1) 1) "name"
# 2) "order_processors"
# 3) "consumers"
# 4) "2"
# 5) "pending"
# 6) "2"
# 7) "last-delivered-id"
# 8) "1703123456790-0"
# 查看消费者信息
XINFO CONSUMERS orders order_processors
# 1) 1) "name"
# 2) "consumer_A"
# 3) "pending"
# 4) "1"
# 5) "idle"
# 6) "1000" # idle 1000ms
# 查看 Pending 详情
XPENDING orders order_processors - + 10
# - + 表示范围,10 表示最多返回10条
消息转移:XCLAIM
当某个消费者挂了,需要把它的 Pending 消息转给其他消费者:
bash
# 把 consumer_A 的 pending 消息转给 consumer_B
# 消息需要空闲至少 30 秒(30000ms)才能转移
XCLAIM orders order_processors consumer_B 30000 1703123456789-0
# 强制转移(不需要等待 idle 时间)
XCLAIM orders order_processors consumer_B 0 1703123456789-0
# XCLAIM 还会返回消息内容
XCLAIM orders order_processors consumer_B 30000 1703123456789-0 1703123456790-0
删除和管理
bash
# 删除消息(不是真的删除,只是标记)
XDEL orders 1703123456789-0
# 截断 Stream
XTRIM orders MAXLEN 1000
# 删除消费者组
XDELGROUP orders order_processors
# 列出所有 Stream
XRANGE orders - + COUNT 1000
# 或
XINFO STREAM orders
Java 实战
Spring Boot 集成
引入依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置:
yaml
spring:
redis:
host: localhost
port: 6379
基础操作
java
@Service
@RequiredArgsConstructor
public class StreamService {
private final StringRedisTemplate redisTemplate;
/**
* 添加消息
*/
public String addMessage(String streamKey, Map<String, String> fields) {
return redisTemplate.opsForStream()
.add(StreamRecords.newRecord()
.in(streamKey)
.ofMap(fields))
.getValue();
}
/**
* 发送订单消息
*/
public String sendOrder(Long orderId, BigDecimal amount) {
Map<String, String> fields = new HashMap<>();
fields.put("orderId", orderId.toString());
fields.put("amount", amount.toString());
fields.put("status", "pending");
fields.put("timestamp", String.valueOf(System.currentTimeMillis()));
return addMessage("orders", fields);
}
/**
* 读取新消息
*/
public List<MapRecord<String, String, String>> readNewMessages(
String streamKey, String group, String consumer, int count) {
return redisTemplate.opsForStream()
.read(Consumer.from(group, consumer),
StreamReadOptions.empty().count(count).block(Duration.ofSeconds(3)),
StreamOffset.create(streamKey, ReadOffset.lastConsumed()));
}
/**
* 读取 Pending 消息(重试)
*/
public List<MapRecord<String, String, String>> readPendingMessages(
String streamKey, String group, String consumer, int count) {
return redisTemplate.opsForStream()
.read(Consumer.from(group, consumer),
StreamReadOptions.empty().count(count),
StreamOffset.create(streamKey, ReadOffset.from("0")));
}
/**
* 确认消息
*/
public long ackMessages(String streamKey, String group, String... messageIds) {
return redisTemplate.opsForStream()
.acknowledge(streamKey, group, messageIds);
}
}
消费者组封装
java
@Service
@RequiredArgsConstructor
public class OrderConsumer {
private final StreamService streamService;
@Autowired
private OrderMapper orderMapper;
private static final String STREAM_KEY = "orders";
private static final String GROUP = "order_processors";
private static final String CONSUMER = "consumer_" + UUID.randomUUID().toString().substring(0, 8);
/**
* 初始化消费者组
*/
@PostConstruct
public void initConsumerGroup() {
try {
redisTemplate.opsForStream().createGroup(STREAM_KEY, ReadOffset.from("0"), GROUP);
} catch (Exception e) {
// 消费者组已存在,忽略
log.info("消费者组已存在: {}", GROUP);
}
}
/**
* 消费消息
*/
public void consume() {
// 1. 先尝试处理 Pending 消息(之前失败的消息)
processPendingMessages();
// 2. 读取新消息
List<MapRecord<String, String, String>> messages = streamService.readNewMessages(
STREAM_KEY, GROUP, CONSUMER, 10
);
if (messages == null || messages.isEmpty()) {
return;
}
for (MapRecord<String, String, String> record : messages) {
processMessage(record);
}
}
private void processPendingMessages() {
List<MapRecord<String, String, String>> pending = streamService.readPendingMessages(
STREAM_KEY, GROUP, CONSUMER, 10
);
if (pending == null || pending.isEmpty()) {
return;
}
log.info("发现 {} 条 Pending 消息需要重试", pending.size());
for (MapRecord<String, String, String> record : pending) {
processMessage(record);
}
}
private void processMessage(MapRecord<String, String, String> record) {
String messageId = record.getId().getValue();
Map<String, String> fields = record.getValue();
try {
Long orderId = Long.parseLong(fields.get("orderId"));
BigDecimal amount = new BigDecimal(fields.get("amount"));
log.info("开始处理订单: orderId={}, amount={}", orderId, amount);
// 模拟业务处理
Order order = orderMapper.selectById(orderId);
if (order != null) {
order.setStatus("processing");
orderMapper.updateById(order);
}
// 确认消息
streamService.ackMessages(STREAM_KEY, GROUP, messageId);
log.info("订单处理成功: orderId={}", orderId);
} catch (Exception e) {
log.error("处理订单失败: messageId={}, error={}", messageId, e.getMessage());
// 不 ACK,让消息留在 Pending 里,稍后重试
}
}
}
定时任务消费
java
@Configuration
public class StreamConsumerConfig {
@Bean
@Scheduled(fixedDelay = 1000)
public Runnable orderConsumerTask(OrderConsumer orderConsumer) {
return orderConsumer::consume;
}
}
消息转移(处理僵尸消费者)
java
@Service
public class StreamPendingProcessor {
private final StringRedisTemplate redisTemplate;
private static final String STREAM_KEY = "orders";
private static final String GROUP = "order_processors";
/**
* 处理僵死消息 - 把某个消费者超过5分钟没处理的的消息转移给活跃消费者
*/
public void claimDeadMessages(String deadConsumer, String activeConsumer, long idleTimeMs) {
// 1. 查看僵死消费者的 Pending 消息
List<MapRecord<String, String, String>> pending = redisTemplate.opsForStream()
.read(Consumer.from(GROUP, deadConsumer),
StreamReadOptions.empty().count(100),
StreamOffset.create(STREAM_KEY, ReadOffset.from("0")));
if (pending == null || pending.isEmpty()) {
return;
}
// 2. 转移消息
for (MapRecord<String, String, String> record : pending) {
String messageId = record.getId().getValue();
// 获取消息的详细信息(包括 idle 时间)
List<MapRecord<String, String, String>> claimed = redisTemplate.opsForStream()
.claim(STREAM_KEY, GROUP, activeConsumer, idleTimeMs,
RecordId.of(messageId));
if (claimed != null && !claimed.isEmpty()) {
log.info("成功转移消息: {} 从 {} 到 {}", messageId, deadConsumer, activeConsumer);
}
}
}
/**
* 定时任务:每分钟检查一次僵死消息
*/
@Scheduled(fixedRate = 60000)
public void processDeadMessages() {
// 获取消费者组信息
List<MapRecord<String, String, String>> groupInfo = redisTemplate.opsForStream()
.read(Consumer.from(GROUP, "admin"),
StreamReadOptions.empty().count(100),
StreamOffset.create(STREAM_KEY, ReadOffset.from("0")));
// 查找 idle 时间过长的消费者,把它们的 Pending 消息转移
// 具体实现省略...
}
}
完整流程图
┌─────────────────────────────────────────┐
│ Redis Stream │
│ │
生产者 ──XADD──▶ │ msg1 ── msg2 ── msg3 ── msg4 ── msg5 │
│ │
└─────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Consumer │ │ Consumer │
│ Group: A │ │ Group: B │
├──────────────┤ ├──────────────┤
│ Consumer A1 │ │ Consumer B1 │
│ Consumer A2 │ │ Consumer B2 │
└──────────────┘ └──────────────┘
消费流程:
1. XREADGROUP 读取消息 → 消息进入 Pending
2. 业务处理
3. XACK 确认 → 消息从 Pending 删除
重试流程:
1. 消费者挂了 → 消息卡在 Pending
2. XPENDING 查看僵死消息
3. XCLAIM 转移给其他消费者
与其他消息队列对比
| 特性 | Redis Stream | RabbitMQ | Kafka |
|---|---|---|---|
| 消息可靠性 | ✅ ACK确认 | ✅ 持久化+ACK | ✅ 持久化+ACK |
| 消费者组 | ✅ 原生支持 | ✅ RabbitMQ 本身就支持 | ✅ 原生支持 |
| 消息顺序 | ✅ 单分区内有序 | ✅ 单队列有序 | ✅ 单分区有序 |
| 延迟消息 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 事务消息 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 消息回溯 | ✅ 可读 Pending | ✅ 可从持久化重放 | ✅ 从 offset 重放 |
| 吞吐量 | 万级/秒 | 万级/秒 | 百万级/秒 |
| 消息堆积 | 受内存限制 | 支持百万级 | 支持海量堆积 |
| 部署复杂度 | 低(Redis 自带) | 中等 | 高 |
Redis Stream 适用场景:
- 数据量千万以下
- 不想引入额外中间件
- 需要快速集成
- 对可靠性要求不是极端严格
RabbitMQ/Kafka 适用场景:
- 数据量巨大(百万级以上)
- 需要事务、延迟消息
- 需要消息回溯
- 对可靠性要求极高
注意事项
1. 内存问题
Stream 数据存在内存里,消息堆积太大会OOM:
bash
# 方案1:设置 MAXLEN
XADD orders MAXLEN ~ 10000 * orderId 1
# 方案2:设置 MINID(只保留新消息)
XADD orders MINID 1703123456000-0 * orderId 1
# 定期清理(定时任务)
XTRIM orders MAXLEN 10000
2. 消费者故障处理
消费者挂了,它的 Pending 消息需要处理:
java
// 方案1:定时扫描 Pending,超过阈值自动转移
@Scheduled(fixedRate = 30000)
public void autoClaimDeadMessages() {
// 扫描所有消费者,查找 idle > 5 分钟的
// 批量 XCLAIM 转移
}
// 方案2:重启消费者时主动拉取自己的 Pending
// (上面代码已实现)
3. 消息重复
XACK 之前消息丢失会重复投递给其他消费者,业务需要幂等处理:
java
// 订单处理幂等
public void processOrder(Long orderId) {
// 1. 检查是否已处理
if (orderMapper.selectById(orderId).getStatus().equals("completed")) {
return; // 幂等,直接返回
}
// 2. 状态机流转
orderMapper.updateStatus(orderId, "processing", "completed");
// 3. 发送下游通知
notificationService.notify(orderId);
}
4. 分区/分片
Redis Stream 不像 Kafka 那样支持多分区,但可以:
java
// 按业务 key hash 到不同的 Stream
public String getStreamKey(Long userId) {
int shard = userId % 10;
return "orders:shard-" + shard;
}
// 消费时遍历所有 shard
for (int i = 0; i < 10; i++) {
String streamKey = "orders:shard-" + i;
// 读取并处理...
}
总结
| 命令 | 作用 |
|---|---|
| XADD | 添加消息 |
| XREAD | 读取消息 |
| XREADGROUP | 消费者组读取 |
| XACK | 确认消息 |
| XPENDING | 查看 Pending |
| XCLAIM | 转移消息 |
| XGROUP | 管理消费者组 |
| XLEN | 消息数量 |
Redis Stream 是 Redis 5.0 带来的重磅功能,它让 Redis 从一个缓存工具变成了可以独立承担轻量级消息队列的角色。
适合场景:
- 订单异步处理
- 实时通知推送
- 日志收集
- 排行榜定时计算
不需要部署额外的 Kafka/RabbitMQ,就能实现可靠的消息消费,强烈推荐尝试!