Redis Stream:消息队列的进阶之路

之前聊过用 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,就能实现可靠的消息消费,强烈推荐尝试!

相关推荐
m0_514520572 小时前
如何分析Data Guard的网络瓶颈_Bandwidth与Redo传输速率的计算公式
jvm·数据库·python
weixin_458580122 小时前
如何查找SQL中未使用JOIN的数据行_利用IS NULL配合LEFT JOIN
jvm·数据库·python
吕源林2 小时前
c++如何利用filesystem--path--lexically_normal规范化路径名【详解】
jvm·数据库·python
IntMainJhy2 小时前
【Flutter for OpenHarmony 】第三方库 实战:`cached_network_image` 图片缓存+骨架屏鸿蒙适配全指南✨
flutter·缓存·harmonyos
a9511416422 小时前
解决Socket图像传输中断问题:基于分块接收与正确连接模型的稳定实现
jvm·数据库·python
2402_854808372 小时前
如何防止SQL注入泄露元数据_限制数据库信息查询权限
jvm·数据库·python
2401_837163892 小时前
JavaScript中rest参数(...args)取代arguments的优势
jvm·数据库·python
2401_871696522 小时前
c++如何利用C++23 std--expected处理复杂的IO链式调用错误【实战】
jvm·数据库·python
qq_372906932 小时前
如何用 CustomEvent 构造函数创建携带自定义数据的事件
jvm·数据库·python