【Redis | 黑马点评】基于Redis的消息队列 | stream消息队列 源码解读

目录

  • [0. 业务背景:为什么秒杀需要消息队列](#0. 业务背景:为什么秒杀需要消息队列)
  • [1. 认识消息队列](#1. 认识消息队列)
  • [2. 基于 List 实现消息队列](#2. 基于 List 实现消息队列)
  • [3. 基于 PubSub 实现消息队列](#3. 基于 PubSub 实现消息队列)
  • [4. Stream 的单消费模式](#4. Stream 的单消费模式)
  • [5. Stream 的消费组模式](#5. Stream 的消费组模式)
  • [6. 基于 Stream 实现异步秒杀(项目真实代码)](#6. 基于 Stream 实现异步秒杀(项目真实代码))
  • [7. 三种方案对比与选型结论](#7. 三种方案对比与选型结论)
  • [8. 练习题](#8. 练习题)
  • [9. 练习题参考答案与逐题反馈](#9. 练习题参考答案与逐题反馈)
  • 附录:命令与源码索引

0. 业务背景:为什么秒杀需要消息队列

0.1 上一版方案的遗留问题

在"基于阻塞队列实现异步秒杀"这一版里,订单被放进了 JVM 内存队列:

java 复制代码
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

它能解决"请求线程不等待数据库"的问题,但带来了新的可靠性问题:

问题 后果
队列在 JVM 内存里 应用重启/崩溃,队列中未处理的订单全部丢失
没有 ACK 机制 消费者取走消息后崩溃,无法知道哪些消息没处理完
每个实例一个队列 多实例之间不共享,无法统一协调
没有消息追溯 出问题后无法查看"哪些订单还没落库"

秒杀订单涉及库存和钱,不能接受"进程一挂订单就没了"。

0.2 对消息队列的要求

结合秒杀业务,这个队列至少要满足:

  1. 异步:请求线程只负责资格判断和投递消息,不等待数据库;
  2. 削峰:瞬时高并发消息先堆积,消费者按自己的能力慢慢处理;
  3. 持久化:应用重启后消息还在;
  4. 确认机制(ACK):处理成功才删除消息,失败可以重试;
  5. 消费组:多个消费者可以分担消息,且能追踪未确认消息;
  6. 不引入新中间件:项目已经重度使用 Redis,最好直接用 Redis 实现。

Redis 提供了三种常见的消息队列实现方式:List、PubSub、Stream。项目最终选择 Stream 的消费组模式。


1. 认识消息队列

1.1 什么是消息队列

消息队列(Message Queue,MQ)是在生产者 和消费者之间传递消息的中间组件:

复制代码
生产者 ──投递消息──> 消息队列(Broker) ──拉取/推送──> 消费者

它把"产生消息"和"处理消息"在时间上解耦:

  • 生产者不需要等待消费者处理完;
  • 消费者可以按自己的速度处理;
  • 消息可以在队列中暂存,起到缓冲作用。

1.2 消息队列解决的四个问题

问题 含义 秒杀场景
异步 主流程不必等待耗时操作 下单请求不等待数据库事务
解耦 生产者和消费者互不依赖实现 秒杀接口不需要知道订单如何落库
削峰 瞬时流量先进入队列,消费者匀速处理 瞬时上万请求,数据库只按能力消费
缓冲 消费者故障时消息不丢 消费者重启后继续处理

1.3 消息队列的几个核心概念

概念 含义
消息(Message) 生产者投递的数据,例如一条订单信息
主题/队列(Topic/Queue) 消息的分类或存放位置
生产者(Producer) 发送消息的一方
消费者(Consumer) 接收并处理消息的一方
ACK(确认) 消费者告诉队列"这条消息我处理完了"
消费组(Consumer Group) 一组消费者共同分担一个队列的消息
待处理列表(Pending List) 已投递但还没 ACK 的消息
至少一次(At-least-once) 消息可能重复,但不会丢
至多一次(At-most-once) 消息可能丢,但不会重复
精确一次(Exactly-once) 不丢不重,实现成本最高

Redis 的 Stream 消费组提供的是至少一次 语义:消息处理完要 ACK,没 ACK 的消息会留在 pending list 里,可能被重新投递。因此业务侧必须做幂等(重复处理不产生副作用)。

1.4 Redis 的三种消息队列方案

特性 List PubSub Stream
消息持久化 是(保存在 List 中) 否(发完即丢) 是(追加日志)
阻塞读取 支持(BLPOP/BRPOP) 推送,不需轮询 支持(XREAD BLOCK)
ACK 确认 不支持 不支持 支持(XACK)
消费组 不支持 不支持 支持
消息回溯 有限(未删除的都在) 不支持 支持(按 ID 读取)
广播 不支持(一条消息一个消费者) 支持(所有订阅者都收到) 多个消费组之间广播
消费者离线 消息还在,上线后可取 消息丢失 消息还在,可从游标继续
可靠性 低(出队即消失) 很低(在线才收得到) 较高(PEL + ACK)
典型用途 简单任务队列 实时通知、广播 可靠异步任务、订单队列

2. 基于 List 实现消息队列

2.1 List 的基本操作

Redis List 是一个双向链表,支持两端插入和弹出:

命令 作用
LPUSH key value 从左侧插入
RPUSH key value 从右侧插入
LPOP key 从左侧弹出
RPOP key 从右侧弹出
BLPOP key timeout 阻塞地从左侧弹出
BRPOP key timeout 阻塞地从右侧弹出
LLEN key 列表长度
LRANGE key start end 查看范围元素
LREM key count value 删除指定元素

2.2 用 List 做 FIFO 队列

最常见的是 LPUSH + BRPOP:

bash 复制代码
# 生产者:从左侧放入
LPUSH queue:orders "order:1001"
LPUSH queue:orders "order:1002"

# 消费者:从右侧阻塞弹出
BRPOP queue:orders 0

LPUSH 让新消息进入队头,BRPOP 从队尾取出,整体就是先进先出(FIFO)。BRPOP 的超时时间设为 0 表示一直阻塞,队列没消息时消费者线程不会空转。

也可以用 RPUSH + BLPOP,方向相反,效果相同。

2.3 Java 演示(不在项目中)

Spring Data Redis 的 ListOperations:

java 复制代码
// 生产者
stringRedisTemplate.opsForList().leftPush("queue:orders", "order:1001");

// 消费者:最多阻塞 2 秒
String message = stringRedisTemplate.opsForList()
        .rightPop("queue:orders", 2, TimeUnit.SECONDS);
if (message != null) {
    // 处理消息
}

2.4 List 方案的可靠性问题

RPOP/BRPOP 是破坏性读取:消息一旦弹出,就从 List 里消失了。

复制代码
消费者 BRPOP 拿到消息
      ↓
消费者进程崩溃(还没来得及处理)
      ↓
消息永久丢失

解决思路是"可靠队列"模式:

bash 复制代码
# 从主队列取出,同时放入备份队列
RPOPLPUSH queue:orders queue:orders:backup

# 处理成功后,从备份队列删除
LREM queue:orders:backup 1 "order:1001"
  • 如果消费者崩溃,消息还在 backup 里,可以由补偿任务重新处理;
  • Redis 6.2 之后推荐用 LMOVE/BLMOVE 替代 RPOPLPUSH/BRPOPLPUSH。

即使这样,List 仍然没有原生的 ACK、消费组、pending list 和消息 ID 管理,可靠队列需要业务自己实现。

2.5 List 方案的优缺点

优点 缺点
实现简单,命令少 无 ACK,出队即消失
支持阻塞读取,不空转 无消费组,多消费者难以协调
消息在 Redis 中,短时间可持久 崩溃后消息可能丢,需要额外备份队列
性能高 无消息 ID/回溯/pending 管理

适用场景:对可靠性要求不高的简单任务队列。秒杀订单不适合直接用 List。


3. 基于 PubSub 实现消息队列

3.1 PubSub 的基本操作

Redis 的发布订阅是"广播"模型:

命令 作用
PUBLISH channel message 向频道发布消息
SUBSCRIBE channel 订阅频道
PSUBSCRIBE pattern 按模式订阅
UNSUBSCRIBE 取消订阅
bash 复制代码
# 消费者先订阅
SUBSCRIBE channel:orders

# 生产者发布
PUBLISH channel:orders "order:1001"

所有当前订阅了 channel:orders 的客户端都会收到这条消息。

3.2 Java 演示(不在项目中)

Spring Data Redis 用 RedisMessageListenerContainer 管理订阅:

java 复制代码
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);

container.addMessageListener((message, pattern) -> {
    System.out.println("收到消息: " + new String(message.getBody()));
}, new ChannelTopic("channel:orders"));

container.start();   // 必须启动容器

生产者:

java 复制代码
stringRedisTemplate.convertAndSend("channel:orders", "order:1001");

3.3 PubSub 的致命限制

PubSub 是推送模型,消息不会存储:

限制 说明
消费者必须在线 不在线的订阅者收不到消息,且没有补发
消息不持久化 Redis 重启、频道无订阅者时,消息直接丢弃
没有 ACK 不知道消费者是否处理成功
没有消费组 所有订阅者都收到同一条消息,无法"一条消息只被一个消费者处理"
没有积压/回溯 不能查看历史消息
慢消费者 客户端输出缓冲区有上限,消息可能被丢弃

因此 PubSub 适合实时通知、广播、缓存失效通知这类"丢了也能接受"的场景,不适合订单、支付这类不能丢的消息。

3.4 PubSub 与 List 的对比

维度 List PubSub
模型 拉取(消费者主动取) 推送(Redis 主动发)
消息存储 是 否
离线消费者 消息还在 消息丢失
多消费者 一条消息只被一个消费者拿到 所有订阅者都收到
可靠性 中等(可做备份队列) 低

4. Stream 的单消费模式

4.1 Stream 是什么

Redis Stream 是 Redis 5.0 引入的追加日志数据结构:

  • 每条消息有一个唯一 ID,格式是 时间戳-序号,例如 1698000000000-0;
  • 消息按添加顺序追加,可以按 ID 范围读取;
  • 支持阻塞读取、消费组、ACK、pending list;
  • 数据保存在 Redis 中,可以持久化。

4.2 基本命令

命令 作用
XADD key * field value ... 追加一条消息,* 表示自动生成 ID
XLEN key 消息数量
XRANGE key start end 按 ID 范围读取
XREVRANGE key end start 倒序读取
XREAD COUNT n BLOCK ms STREAMS key id 读取消息
XDEL key id 删除消息
XTRIM key MAXLEN n 截断历史消息

示例:

bash 复制代码
# 添加消息
XADD stream.orders * userId 101 voucherId 1 id 9001

# 查看全部
XRANGE stream.orders - +

# 长度
XLEN stream.orders

4.3 单消费模式的读取

单消费模式不使用消费组,直接 XREAD:

bash 复制代码
# 从最新位置开始,只读之后的新消息
XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders $

# 从头开始读
XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders 0

$ 的语义是"从现在开始的新消息",它不会返回之前已经存在的消息。如果消费者重启后想继续上次的位置,需要自己记录最后一条消息的 ID,然后用:

bash 复制代码
XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders 1698000000000-0

4.4 Java 演示(不在项目中)

java 复制代码
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
        StreamOffset.create("stream.orders", ReadOffset.latest())
);

要点:

  • ReadOffset.latest() 对应 $,只读新消息;
  • 如果要"从指定位置继续",用 ReadOffset.from("1698000000000-0");
  • 返回的 MapRecord 包含消息 ID 和字段 Map。

4.5 单消费模式的问题

问题 说明
没有 ACK 消费失败无法标记,消息不会自动重投
没有消费组 多个消费者各自从头读,每条消息会被每个消费者读到(不是分担)
游标由客户端维护 重启后要么用 $ 跳过积压,要么自己保存最后 ID
无法追踪未处理消息 没有 pending list

单消费模式适合"一个消费者、可接受重复/丢失、只要简单日志消费"的场景。订单队列需要更强的保证。


5. Stream 的消费组模式

5.1 消费组的三个核心概念

概念 含义
消费组(Group) 一组消费者共同消费一个 Stream
消费者(Consumer) 组内的一个成员,有唯一名字
Pending List(PEL) 已投递给某个消费者、但还没 ACK 的消息列表
  • 组内多个消费者:消息被分摊,一条消息只会投递给组内的一个消费者;
  • 多个消费组:每个组都会收到一份完整消息,相当于广播;
  • PEL:记录"谁拿了哪条消息、投递了几次、空闲了多久",用于失败重试和转移。

5.2 消费组的基本命令

bash 复制代码
# 创建消费组,MKSTREAM 表示 Stream 不存在时自动创建
XGROUP CREATE stream.orders g1 0 MKSTREAM

# 消费者 c1 读取组 g1 的新消息,从 > 开始
XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >

# 处理成功后确认
XACK stream.orders g1 1698000000000-0

# 查看 pending 概况
XPENDING stream.orders g1

# 查看 pending 详情(指定范围)
XPENDING stream.orders g1 - + 10

# 把空闲超过 60 秒的消息转给 c2
XCLAIM stream.orders g1 c2 60000 1698000000000-0

# Redis 6.2+ 自动转移空闲消息
XAUTOCLAIM stream.orders g1 c2 60000 0-0

# 查看消费组信息
XINFO GROUPS stream.orders

5.3 > 和 0 的区别

这是 Stream 消费组最容易混淆的地方:

读取位置 含义
> 读取从未投递给本组任何消费者的新消息
0 读取当前消费者自己的 PEL 中的消息(从最小 ID 开始)
具体 ID 读取该 ID 之后的消息(包括 pending 中的)

因此:

  • 正常消费新消息用 >;
  • 处理失败后重试自己的 pending 消息用 0;
  • 不能指望再次用 > 读到已经投递过但没 ACK 的消息。

5.4 消息的完整生命周期

复制代码
1. XADD 写入消息
        ↓
2. XREADGROUP ... > 投递给某个消费者
        ↓
3. 消息进入该消费者的 PEL(未确认)
        ↓
4. 业务处理成功
        ↓
5. XACK 从 PEL 删除

如果第 4 步失败或消费者崩溃:

  • 消息留在 PEL;
  • 其他消费者可以用 XCLAIM/XAUTOCLAIM 接管;
  • 也可以由原消费者用 0 重新读取并重试。

5.5 消费组与单消费模式对比

维度 单消费模式 消费组模式
命令 XREAD XREADGROUP
多消费者 每个消费者都读到全部消息 组内分摊,一条消息只给一个消费者
ACK 无 XACK
失败重试 自己做 PEL + XCLAIM/XAUTOCLAIM
游标 客户端维护 Redis 维护每个消费者的 PEL
广播 所有读者都读全部 多个组之间广播
适用 简单日志消费 可靠任务队列

5.6 Spring Data Redis API 对照

Redis 命令 Spring Data Redis
XADD opsForStream().add(key, map)
XGROUP CREATE opsForStream().createGroup(key, group) 或 createGroup(key, ReadOffset, group)
XREADGROUP opsForStream().read(Consumer.from(group, consumer), options, StreamOffset.create(key, ReadOffset.lastConsumed()))
XACK opsForStream().acknowledge(key, group, recordId)
XPENDING opsForStream().pending(key, group)
XCLAIM opsForStream().claim(...)
XLEN opsForStream().size(key)
XRANGE opsForStream().range(key, Range.unbounded())

一个重要细节(已核对 Spring Data Redis 2.3.9 字节码):

  • createGroup(key, group) 内部使用的是 ReadOffset.latest(),也就是 $;
  • 它的底层调用带 MKSTREAM = true,Stream 不存在时会自动创建;
  • 因为用的是 $,创建组之前已经存在的消息不会分配给这个组 。如果希望从头消费,要显式传 ReadOffset.from("0")。

6. 基于 Stream 实现异步秒杀(项目真实代码)

6.1 整体结构

MySQL 消费者线程(c1) Redis VoucherOrderServiceImpl 客户端 MySQL 消费者线程(c1) Redis VoucherOrderServiceImpl 客户端 #mermaid-svg-i6LszXYALi4yZf7K{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i6LszXYALi4yZf7K .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i6LszXYALi4yZf7K .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i6LszXYALi4yZf7K .error-icon{fill:#552222;}#mermaid-svg-i6LszXYALi4yZf7K .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i6LszXYALi4yZf7K .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i6LszXYALi4yZf7K .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i6LszXYALi4yZf7K .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i6LszXYALi4yZf7K .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i6LszXYALi4yZf7K .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i6LszXYALi4yZf7K .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i6LszXYALi4yZf7K .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i6LszXYALi4yZf7K .marker.cross{stroke:#333333;}#mermaid-svg-i6LszXYALi4yZf7K svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i6LszXYALi4yZf7K p{margin:0;}#mermaid-svg-i6LszXYALi4yZf7K .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i6LszXYALi4yZf7K text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-i6LszXYALi4yZf7K .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i6LszXYALi4yZf7K .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-i6LszXYALi4yZf7K .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-i6LszXYALi4yZf7K .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-i6LszXYALi4yZf7K #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-i6LszXYALi4yZf7K .sequenceNumber{fill:white;}#mermaid-svg-i6LszXYALi4yZf7K #sequencenumber{fill:#333;}#mermaid-svg-i6LszXYALi4yZf7K #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-i6LszXYALi4yZf7K .messageText{fill:#333;stroke:none;}#mermaid-svg-i6LszXYALi4yZf7K .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i6LszXYALi4yZf7K .labelText,#mermaid-svg-i6LszXYALi4yZf7K .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-i6LszXYALi4yZf7K .loopText,#mermaid-svg-i6LszXYALi4yZf7K .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-i6LszXYALi4yZf7K .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i6LszXYALi4yZf7K .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-i6LszXYALi4yZf7K .noteText,#mermaid-svg-i6LszXYALi4yZf7K .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-i6LszXYALi4yZf7K .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i6LszXYALi4yZf7K .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i6LszXYALi4yZf7K .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i6LszXYALi4yZf7K .actorPopupMenu{position:absolute;}#mermaid-svg-i6LszXYALi4yZf7K .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-i6LszXYALi4yZf7K .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i6LszXYALi4yZf7K .actor-man circle,#mermaid-svg-i6LszXYALi4yZf7K line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-i6LszXYALi4yZf7K :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 库存判断 + 一人一单判断 扣库存 + SADD + XADD stream.orders alt 返回 1 / 2 返回 0 POST /voucher-order/seckill/{id} 生成 orderId,获取 proxy 执行 seckill.lua 库存不足 / 不能重复下单 立即返回 orderId XREADGROUP GROUP g1 c1 ... > 订单消息(进入 PEL) BeanUtil 转 VoucherOrder Redisson 用户锁 tryLock 事务:查重、扣库存、insert XACK

和上一版阻塞队列相比,变化在于:

  • 消息从 JVM 内存队列换成了 Redis Stream;
  • 增加了消费组 g1 和消费者 c1;
  • 增加了 ACK 和 pending-list 处理;
  • 应用重启后消息仍在 Redis 中。

6.2 Lua 脚本:资格判断 + 发送消息

src/main/resources/seckill.lua 当前版本:

lua 复制代码
-- 参数
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]

-- key
local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId

-- 1. 判断库存
local stock = redis.call('get', stockKey)
if (not stock or tonumber(stock) <= 0) then
    return 1
end

-- 2. 判断用户是否已经下单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2
end

-- 3. 扣库存、记录用户
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)

-- 4. 发送消息到 Stream
redis.call('xadd', 'stream.orders', '*',
        'userId', userId,
        'voucherId', voucherId,
        'id', orderId)

return 0

逐段说明:

  1. 参数顺序 :Java 按 voucherId, userId, orderId 的顺序传 ARGV,脚本按同样顺序取;
  2. 库存 key :seckill:stock:{voucherId},String 类型,存库存数量;
  3. 订单集合 key :seckill:order:{voucherId},Set 类型,存已经下单的用户 id,用于一人一单;
  4. 库存判断 :key 不存在或值 <= 0 都返回 1;
  5. 重复下单判断 :SISMEMBER 返回 1 表示该用户已经抢过,返回 2;
  6. 原子扣减 :INCRBY -1 和 SADD 在同一个脚本里完成;
  7. 发送消息 :XADD stream.orders * field value ...,* 让 Redis 自动生成消息 ID;
  8. 返回值 :0 成功,1 库存不足,2 重复下单。

为什么 XADD 放在 Lua 里?

如果先扣 Redis 库存、再在 Java 里单独 XADD,中间可能失败或进程崩溃,导致"库存扣了、消息没发"。把扣减和发消息放在同一个脚本里,至少保证这两步不会被其他客户端插入。注意 Lua 的原子性是"不被交错执行",不是回滚:如果 XADD 本身报错,前面的 INCRBY 和 SADD 不会撤销。因此脚本里的命令必须保证正确(例如 Stream key 不能被别的类型占用)。

6.3 请求入口:seckillVoucher

VoucherOrderServiceImpl.java:185-208:

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    // 获取用户
    Long userId = UserHolder.getUser().getId();
    // 获取订单id
    long orderId = redisIdWorker.nextId("order");
    // 获取代理对象(事务),必须在发送消息前赋值,否则消费者可能拿不到 proxy
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    // 执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(),
            String.valueOf(orderId)
    );
    // 判断结果是否为0
    int r = result.intValue();
    if (r != 0) {
        // 不为0,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 返回订单id
    return Result.ok(orderId);
}

几个关键设计:

设计 原因
订单 id 在 Lua 之前生成 要在消息里携带 id,并立即返回给前端
proxy 在 Lua 之前赋值 Lua 会发送消息,消费者可能立刻消费,必须提前准备好事务代理
只传 ARGV,KEYS 为空 当前脚本自己拼接 key,单机 Redis 可用;Redis Cluster 需要把 key 放进 KEYS
返回 Result.ok(orderId) 前端拿到订单号,但此时订单还没落库

6.4 启动消费者:创建消费组

VoucherOrderServiceImpl.java:63-76:

java 复制代码
private static final String QUEUE_NAME = "stream.orders";
private static final String GROUP_NAME = "g1";
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

@PostConstruct
private void init() {
    try {
        // 消费组不存在时创建;已存在会抛 BUSYGROUP,忽略即可
        stringRedisTemplate.opsForStream().createGroup(QUEUE_NAME, GROUP_NAME);
    } catch (Exception e) {
        log.debug("消费组已存在: {}", e.getMessage());
    }
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

要点:

  • createGroup(key, group) 使用 $(最新位置)创建组,并且会自动创建 Stream;
  • 组已存在时 Redis 返回 BUSYGROUP 错误,Spring 抛异常,这里捕获并忽略;
  • 创建组必须在消费者启动前完成,否则 XREADGROUP 会报 NOGROUP;
  • 消费者线程用单线程执行器启动,应用启动后就开始阻塞读取。

当前 Redis 中 stream.orders 已经存在,g1 组也存在,pending 为 0(已实际查询确认)。

6.5 消费者主循环

VoucherOrderServiceImpl.java:79-108:

java 复制代码
@Override
public void run() {
    while (true) {
        try {
            // XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
            List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from(GROUP_NAME, "c1"),
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create(QUEUE_NAME, ReadOffset.lastConsumed())
            );
            // 没有消息,继续下一次循环
            if (list == null || list.isEmpty()) {
                continue;
            }
            // 解析消息
            MapRecord<String, Object, Object> record = list.get(0);
            Map<Object, Object> values = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
            // 下单
            handleVoucherOrder(voucherOrder);
            // ACK 确认
            stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId());
        } catch (Exception e) {
            log.error("处理订单异常", e);
            // 处理异常消息(pending-list)
            handlePendingList();
        }
    }
}

参数对照:

Java 代码 Redis 命令 含义
Consumer.from("g1", "c1") GROUP g1 c1 组 g1 的消费者 c1
count(1) COUNT 1 一次读 1 条
block(Duration.ofSeconds(2)) BLOCK 2000 最多阻塞 2 秒
StreamOffset.create(QUEUE_NAME, ReadOffset.lastConsumed()) STREAMS stream.orders > 读取组内从未投递的新消息

ReadOffset.lastConsumed() 在这里对应 >,是消费组读新消息的专用语义,不是"上次消费的位置"。

解析消息:

  • record.getValue() 返回 Stream 消息的字段 Map;
  • BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true) 把 id、userId、voucherId 填进实体;
  • 第三个参数 true 表示忽略类型转换错误。字段值在 Stream 里是字符串,Hutool 会自动转成 Long。

6.6 处理订单:分布式锁 + 事务

VoucherOrderServiceImpl.java:161-181:

java 复制代码
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    // 获取用户
    Long userId = voucherOrder.getUserId();
    // 创建锁对象
    RLock lock = redissonClient.getLock("order:" + userId);
    // 获取锁
    boolean isLock = lock.tryLock();
    // 判断是否获取锁成功
    if (!isLock) {
        log.error("不允许重复下单!");
        return;
    }
    try {
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

这把锁的作用:

  • Lua 的 SISMEMBER 保护的是 Redis 层的判重;
  • 数据库里的"查询是否已下单 → 插入订单"是两步操作,需要串行化;
  • 多个应用实例的消费者可能同时处理同一用户的订单,所以要用分布式锁做第二道保险;
  • 锁在消费者线程里,不增加秒杀请求的响应时间。

6.7 落库:createVoucherOrder

VoucherOrderServiceImpl.java:287-313:

java 复制代码
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    // 一人一单
    Long userId = voucherOrder.getUserId();
    Long voucherId = voucherOrder.getVoucherId();

    // 查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
        log.error("用户已经购买过一次!");
        return;
    }
    // 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock=stock-1")
            .eq("voucher_id", voucherId).gt("stock", 0)
            .update();
    if (!success) {
        log.error("库存不足");
        return;
    }
    // 保存订单
    save(voucherOrder);
}

这里做了三层保护:

  1. 数据库查重:防止消息重复投递导致重复订单;
  2. 条件扣库存 :where stock > 0 防止数据库库存变成负数;
  3. 事务:查重、扣库存、插入订单在同一个事务里。

注意:如果 count > 0 或扣库存失败,方法会直接 return,但这并不算"处理失败"。调用方 handleVoucherOrder 正常返回后,消费者仍然会执行 XACK,消息被确认删除。这是当前代码的一个行为特点,见 6.10。

6.8 pending-list 处理

VoucherOrderServiceImpl.java:113-140:

java 复制代码
private void handlePendingList() {
    while (true) {
        try {
            List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from(GROUP_NAME, "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(QUEUE_NAME, ReadOffset.from("0"))
            );
            if (list == null || list.isEmpty()) {
                break;
            }
            MapRecord<String, Object, Object> record = list.get(0);
            Map<Object, Object> values = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
            handleVoucherOrder(voucherOrder);
            stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, GROUP_NAME, record.getId());
        } catch (Exception e) {
            log.error("处理pending-list异常", e);
            try {
                Thread.sleep(20);
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

关键点:

  • ReadOffset.from("0") 对应 STREAMS stream.orders 0,读取当前消费者 c1 的 PEL;
  • 没有 pending 消息时 list 为空,break 退出;
  • 处理成功就 ACK,从 PEL 删除;
  • 处理失败时 sleep 20ms 后重试,形成重试循环。

6.9 一次订单的完整生命周期

复制代码
1. Lua 返回 0,消息已 XADD 到 stream.orders
2. 消费者 c1 用 XREADGROUP > 读到消息
3. 消息进入 c1 的 PEL
4. BeanUtil 转成 VoucherOrder
5. Redisson 获取用户锁
6. proxy.createVoucherOrder 开启事务:
   6.1 查是否重复
   6.2 扣数据库库存
   6.3 插入订单
7. 释放锁
8. XACK 从 PEL 删除消息

6.10 失败场景与当前代码的行为

场景 消息状态 数据状态 当前代码是否处理
Lua 成功,XADD 成功,消费者还没读 Stream 中 Redis 库存已扣,DB 无订单 会正常消费
消费者读到消息后、处理前崩溃 PEL 中 Redis 库存已扣,DB 无订单 pending 消息保留,但见下方说明
数据库事务抛异常 PEL 中 事务回滚,DB 无订单 catch → handlePendingList 重试
获取 Redisson 锁失败 消息仍在 PEL 中,但随后 ACK DB 无订单 ❌ 会丢单:handleVoucherOrder 直接 return,调用方仍然 ACK
count > 0(重复下单) 随后 ACK 已有订单 视为成功,幂等
扣库存失败(DB stock = 0) 随后 ACK DB 无新订单 视为成功并 ACK,该消息不再重试
ACK 失败 PEL 中 DB 已提交订单 可能重复投递,靠数据库查重幂等
XADD 本身报错 消息未写入 前面的 INCRBY/SADD 已执行 ❌ Lua 不回滚,库存和用户记录已改变

6.11 当前实现的已知问题

(1)pending 消息只在异常时才会被处理

handlePendingList() 只在主循环的 catch (Exception e) 里调用。消费者在"读到消息、还没 ACK"时崩溃,重启后主循环用 > 读取新消息,不会主动扫描 PEL,这些 pending 消息可能一直留在 PEL 中,直到下一次主循环发生异常。

更稳妥的做法:

  • 应用启动时先调用一次 handlePendingList();
  • 或者用定时任务周期性检查 XPENDING/XAUTOCLAIM;
  • 或者使用 XAUTOCLAIM 把长时间空闲的消息转移给其他消费者。
(2)锁失败会丢单

handleVoucherOrder 在锁获取失败时只是 log.error 然后 return,调用方无法区分"处理成功"和"跳过",随后仍然执行 XACK,消息被删除。

更稳妥的做法:

  • 让 handleVoucherOrder 返回 boolean;
  • 锁失败时抛异常或返回 false,调用方不 ACK,让消息留在 PEL 中重试;
  • 或者把锁改成带等待时间的 tryLock(waitTime, leaseTime, unit)。
(3)pending 重试可能无限循环

handlePendingList 的 while(true) 里,如果某条消息一直处理失败(例如数据损坏、数据库持续异常),会不断 sleep 20ms 重试,永远不退出,新的消息也得不到处理。生产环境需要:

  • 最大重试次数;
  • 超过次数后写入死信队列/人工处理;
  • 记录失败原因和消息 ID。
(4)Stream 没有设置上限

Lua 里的 XADD 没有 MAXLEN,消息会一直增长。ACK 只是从 PEL 删除,消息本身还在 Stream 中。长期运行会占用内存。

可以在 XADD 时加近似截断:

lua 复制代码
redis.call('xadd', 'stream.orders', 'MAXLEN', '~', 1000, '*', ...)

或者定期 XTRIM。

注意:截断会删除历史消息,如果需要审计/回溯,要先把消息落到数据库或归档。

(5)消费者名字固定为 c1

Consumer.from(GROUP_NAME, "c1") 使用了硬编码的消费者名。多个应用实例同时运行时,它们在消费组里都被视为同一个消费者 c1:

  • 消息仍可能被不同实例取走;
  • 但 PEL 的归属混乱,无法区分哪条消息属于哪个实例;
  • XCLAIM 和 pending 排查会变得困难。

生产环境通常用 hostname + 端口 或 UUID 作为消费者名。

(6)createGroup 从最新位置开始

createGroup(key, group) 用的是 ReadOffset.latest()($)。如果消费组创建之前 Stream 里已经有消息,这些消息不会被该组消费。当前项目在启动时创建组、之后才有订单,所以通常没问题;但如果 Stream 已有积压数据,需要显式用 ReadOffset.from("0")。

(7)Redis 与数据库的一致性

如果数据库持续失败,Redis 库存已经扣减、用户在 Set 中已记录,但订单始终无法落库。当前没有补偿机制。需要:

  • 对账任务:对比 Redis 与 DB 的库存/订单;
  • 失败消息的死信处理;
  • 必要时回滚 Redis 库存和用户记录。

6.12 如何验证

前提:Redis 里要有库存 key。可以通过新增秒杀券接口触发 addSeckillVoucher,或者手动设置:

bash 复制代码
SET seckill:stock:1 100

观察:

bash 复制代码
XLEN stream.orders
XINFO GROUPS stream.orders
XPENDING stream.orders g1
XRANGE stream.orders - +
  • XLEN:Stream 中的消息总数;
  • XINFO GROUPS:组的 last-delivered-id、pending 数量、消费者数量;
  • XPENDING:未 ACK 的消息;
  • XRANGE:消息内容。

当前本机状态(已查询):stream.orders 存在,g1 组存在,pending = 0,但 Redis 中暂无 seckill:stock:* key,测试前需要先初始化库存。


7. 三种方案对比与选型结论

维度 List PubSub Stream
消息持久化 是 否 是
阻塞读取 是 推送 是
ACK 否 否 是
消费组 否 否 是
pending/重试 自行实现 不支持 内置 PEL
多消费者负载均衡 需自行控制 不支持(广播) 组内自动分摊
历史消息 可以查看未删除部分 不支持 支持按 ID 读取
内存风险 队列长度需控制 无积压 需 MAXLEN/XTRIM
实现复杂度 低 低 中
秒杀订单适合度 低 很低 高

选型结论:

  • List:简单任务队列,能接受丢失或自己实现可靠队列时使用;
  • PubSub:实时广播/通知,能接受离线丢失时使用;
  • Stream 消费组:需要持久化、ACK、重试、多消费者分担时使用。秒杀订单属于这一类,所以项目最终选择它。

8. 练习题

  1. List 队列 :为什么 LPUSH + BRPOP 能实现 FIFO?如果消费者在 BRPOP 拿到消息后、处理前崩溃,消息会怎样?如何用 List 做一个"至少不丢"的可靠队列?

  2. PubSub 的适用边界:请说出 PubSub 不能用于秒杀订单队列的三个原因,并各举一个 PubSub 适合的场景。

  3. Stream 单消费 vs 消费组 :单消费模式的 XREAD 和消费组模式的 XREADGROUP 有什么本质区别?> 和 0 分别代表什么?为什么消费组需要 ACK?

  4. 项目链路 :请完整描述一条订单消息从 seckill.lua 的 XADD 到最终写入数据库的路径,并说明在这条链路中,哪些位置会调用 XACK、哪些位置不会。

  5. 失败分析 :在当前的 VoucherOrderServiceImpl 中,请找出至少两个"消息已经被消费/ACK,但订单没有落库"或"订单可能永远不被处理"的场景,并说明原因和改进方向。


9. 练习题参考答案与逐题反馈

第 1 题:List 队列

标准答案

为什么 FIFO:

LPUSH 从左侧插入,新消息在队头;BRPOP 从右侧弹出,先进入的消息先到右侧,所以先被取出。整体是"左边进、右边出",符合先进先出。

也可以理解为:LPUSH 把消息依次压到左边,最早的消息被推到最右边;BRPOP 从右边取,自然先取最早的。

崩溃后的结果:

BRPOP 是破坏性读取,消息一旦弹出就从 List 中删除。如果消费者拿到消息后、处理前崩溃,这条消息就永久丢失了,List 里没有任何记录。

可靠队列的做法:

使用 RPOPLPUSH source backup(或 Redis 6.2+ 的 LMOVE):

  1. 从主队列取出消息的同时,放入备份队列;
  2. 业务处理成功后,用 LREM backup 1 message 从备份队列删除;
  3. 如果消费者崩溃,消息还在备份队列里,可以由补偿任务重新处理。

这相当于自己实现了一个"PEL + ACK",但需要业务代码维护。

常见误区
  • ❌ "BRPOP 取出来看过了,消息就处理完了。"------取出 ≠ 处理成功,崩溃就丢。
  • ❌ "List 是持久化的,所以消息不会丢。"------持久化的是"还在 List 里的消息",弹出后就没了。
  • ❌ "多个消费者用 BRPOP 就能自动负载均衡。"------确实能分摊,但没有 ACK 和失败重试,某条消息丢了没人知道。

第 2 题:PubSub 的适用边界

标准答案

不能用于秒杀订单的三个原因:

  1. 消息不持久化 :没有订阅者在线时,PUBLISH 的消息直接丢弃,Redis 重启也会丢;
  2. 没有 ACK:无法知道消费者是否处理成功,也无法重试;
  3. 没有消费组/负载均衡:所有订阅者都会收到同一条消息,无法做到"一条消息只被一个消费者处理";消费者离线期间的消息也不会补发。

补充原因:慢消费者可能因为客户端输出缓冲区上限被断开,消息丢失。

适合的场景:

  • 实时通知:例如系统公告、聊天室消息;
  • 缓存失效广播:多个实例订阅同一个频道,收到后清理本地缓存;
  • 实时比分、行情推送等"错过就错过"的场景。
常见误区
  • ❌ "PubSub 是 Redis 的,所以消息一定可靠。"------Redis 的 PubSub 是"发完即忘",可靠性远低于 Stream。
  • ❌ "订阅者多,消息会分摊。"------PubSub 是广播,所有订阅者都收到同一条消息。
  • ❌ "消费者离线后上线还能收到。"------不能,PubSub 没有离线消息。

第 3 题:Stream 单消费 vs 消费组

标准答案

本质区别:

  • XREAD 是"读日志":每个读者独立维护自己的读取位置,多个消费者会各自读到全部消息,没有分摊;
  • XREADGROUP 是"组内分摊":消息只投递给组内的一个消费者,并由 Redis 记录每个消费者的 PEL。

> 和 0:

  • >:读取从未投递给本组任何消费者的新消息;
  • 0:读取当前消费者自己的 PEL 中尚未 ACK 的消息(从最小 ID 开始)。

为什么需要 ACK:

消费组把消息投递给消费者后,并不知道消费者是否处理成功。ACK 的作用是告诉 Redis:"这条消息处理完了,可以从 PEL 删除。"没有 ACK 的消息会一直留在 PEL 中,便于重试、转移和排查。

常见误区
  • ❌ "> 是读取最新一条消息。"------它是"组内从未投递的新消息",不是简单的"最新"。
  • ❌ "已经读过的消息再用 > 能读到。"------不能,已投递的消息在 PEL 里,要用 0 或具体 ID。
  • ❌ "ACK 是自动的。"------需要业务代码显式调用 XACK。

第 4 题:项目链路

标准答案

完整路径:

复制代码
1. seckillVoucher 生成 orderId,获取 proxy
2. 执行 seckill.lua:
   - 判断库存
   - 判断一人一单
   - INCRBY 扣 Redis 库存
   - SADD 记录用户
   - XADD stream.orders * userId voucherId id
   - 返回 0
3. 请求线程返回 Result.ok(orderId)
4. 消费者 c1 执行 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
5. 消息进入 c1 的 PEL
6. MapRecord → BeanUtil.fillBeanWithMap → VoucherOrder
7. handleVoucherOrder:Redisson 获取 order:{userId} 锁
8. proxy.createVoucherOrder(voucherOrder):
   - 数据库查重
   - update stock = stock - 1 where stock > 0
   - save(voucherOrder)
9. 释放锁
10. XACK stream.orders g1 <recordId>

XACK 的位置:

  • 主循环中,handleVoucherOrder 正常返回后执行 XACK;
  • handlePendingList 中,重试成功后执行 XACK;
  • 如果 handleVoucherOrder 抛异常,主循环进入 catch,不会 执行当前消息的 XACK,消息留在 PEL,随后由 handlePendingList 处理。

需要注意:handleVoucherOrder 在"锁获取失败"或 createVoucherOrder 内部提前 return 时都不会抛异常,主循环仍会 ACK,这些消息会被确认删除。

常见误区
  • ❌ "XADD 之后就可以 ACK 了。"------ACK 必须在业务处理成功后。
  • ❌ "handleVoucherOrder 返回就代表订单创建成功。"------它可能因为锁失败直接 return。
  • ❌ "消息在 Stream 里,所以数据库一定会有订单。"------两者之间还隔着消费、锁、事务。

第 5 题:失败分析

标准答案

至少可以找出以下场景:

场景一:锁获取失败导致丢单

  • 位置:handleVoucherOrder 的 if (!isLock) { log.error(...); return; }
  • 原因:返回后,主循环继续执行 XACK,消息被删除;
  • 后果:Redis 库存已扣、用户已记录,但数据库没有订单,且消息不会重试;
  • 改进:让 handleVoucherOrder 返回 boolean,锁失败时不 ACK;或使用带等待时间的 tryLock。

场景二:消费者崩溃后 pending 消息不被主动处理

  • 位置:run() 的 catch 才调用 handlePendingList()
  • 原因:正常重启后主循环用 > 读取新消息,不会扫描 PEL;
  • 后果:崩溃前已投递但未 ACK 的消息可能长期留在 PEL;
  • 改进:启动时先处理 pending,或定时扫描 XPENDING/使用 XAUTOCLAIM。

场景三:createVoucherOrder 提前 return 后被 ACK

  • 位置:createVoucherOrder 中 count > 0 或扣库存失败时 return
  • 原因:方法正常返回,调用方认为处理成功并 ACK;
  • 后果:如果是"库存不足",Redis 已经扣过库存,但 DB 没有订单;如果是"重复下单",属于幂等,问题不大;
  • 改进:区分"业务成功""业务失败可重试""业务失败不可重试",对可重试的情况不 ACK 或进入死信。

场景四:pending 重试无限循环

  • 位置:handlePendingList 的 while(true) + sleep(20)
  • 原因:某条消息持续失败时永远不退出;
  • 后果:后续新消息得不到处理,消费者卡死;
  • 改进:最大重试次数 + 死信队列 + 监控告警。

场景五:XADD 失败但 Lua 不回滚

  • 位置:seckill.lua 中 XADD 之前的 INCRBY/SADD
  • 原因:Redis Lua 保证原子执行,不保证回滚;
  • 后果:库存被扣、用户被记录,但消息没写入;
  • 改进:保证 Stream key 类型正确;监控脚本错误;必要时做补偿。

答出其中两个并说明原因和改进方向即可。

常见误区
  • ❌ "有 ACK 就不会丢消息。"------ACK 只是确认,代码在 ACK 前丢弃消息同样会丢。
  • ❌ "Redis Stream 是持久化的,所以不会丢。"------持久化解决进程重启,解决不了业务逻辑主动丢弃或未处理 PEL。
  • ❌ "pending 消息会自动重投。"------不会自动;需要业务代码读取 PEL 或使用 XCLAIM/XAUTOCLAIM。
  • ❌ "Lua 报错会回滚。"------不会,前面的写命令仍然生效。

附录:命令与源码索引

Redis 命令速查

用途 命令
List 生产 LPUSH queue msg
List 消费 BRPOP queue 0
List 可靠队列 RPOPLPUSH queue backup + LREM backup 1 msg
PubSub 订阅 SUBSCRIBE channel
PubSub 发布 PUBLISH channel msg
Stream 添加 XADD stream.orders * k v
Stream 长度 XLEN stream.orders
Stream 范围 XRANGE stream.orders - +
单消费读取 XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders $
创建消费组 XGROUP CREATE stream.orders g1 0 MKSTREAM
消费组读取 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
读取自己的 pending XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0
确认 XACK stream.orders g1 <id>
查看 pending XPENDING stream.orders g1
转移 pending XCLAIM stream.orders g1 c2 60000 <id>
自动转移 XAUTOCLAIM stream.orders g1 c2 60000 0-0
组信息 XINFO GROUPS stream.orders
相关推荐
CodeGL1 小时前
9、Python基础之IO编程
开发语言·数据库·python
每天一道题1 小时前
Redis 缓存为什么会出问题?穿透、击穿、雪崩与缓存一致性
数据库·redis·缓存
aibo8603081 小时前
基于 Spring Boot + Vue3 的博物馆藏品管理系统设计与实现
java·spring boot·后端
fanruitian1 小时前
python 进程 线程
java·前端·python
\光辉岁月/1 小时前
javaSE8-新特性-Stream&Option
java·stream·option
zzu123zsw1 小时前
把条件校验收敛到枚举接口:Spring Boot 统一断言模式与零污染异常切面工程化
java·spring boot·后端·架构设计
SL_staff1 小时前
计划系统落地难?从开发者视角重构执行入口体验
java·开源·全栈
tongyiixiaohuang1 小时前
物料成本核算单同步返回信息策略实战教程:从金蝶云星空回写 MySQL 的闭环设计
数据库·mysql
Qianxueban1 小时前
pexpire key底层逻辑是如何实现这个延迟功能?
redis