目录
- [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 对消息队列的要求
结合秒杀业务,这个队列至少要满足:
- 异步:请求线程只负责资格判断和投递消息,不等待数据库;
- 削峰:瞬时高并发消息先堆积,消费者按自己的能力慢慢处理;
- 持久化:应用重启后消息还在;
- 确认机制(ACK):处理成功才删除消息,失败可以重试;
- 消费组:多个消费者可以分担消息,且能追踪未确认消息;
- 不引入新中间件:项目已经重度使用 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
逐段说明:
- 参数顺序 :Java 按
voucherId, userId, orderId的顺序传ARGV,脚本按同样顺序取; - 库存 key :
seckill:stock:{voucherId},String 类型,存库存数量; - 订单集合 key :
seckill:order:{voucherId},Set 类型,存已经下单的用户 id,用于一人一单; - 库存判断 :key 不存在或值
<= 0都返回1; - 重复下单判断 :
SISMEMBER返回 1 表示该用户已经抢过,返回2; - 原子扣减 :
INCRBY -1和SADD在同一个脚本里完成; - 发送消息 :
XADD stream.orders * field value ...,*让 Redis 自动生成消息 ID; - 返回值 :
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);
}
这里做了三层保护:
- 数据库查重:防止消息重复投递导致重复订单;
- 条件扣库存 :
where stock > 0防止数据库库存变成负数; - 事务:查重、扣库存、插入订单在同一个事务里。
注意:如果 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. 练习题
-
List 队列 :为什么
LPUSH+BRPOP能实现 FIFO?如果消费者在BRPOP拿到消息后、处理前崩溃,消息会怎样?如何用 List 做一个"至少不丢"的可靠队列? -
PubSub 的适用边界:请说出 PubSub 不能用于秒杀订单队列的三个原因,并各举一个 PubSub 适合的场景。
-
Stream 单消费 vs 消费组 :单消费模式的
XREAD和消费组模式的XREADGROUP有什么本质区别?>和0分别代表什么?为什么消费组需要 ACK? -
项目链路 :请完整描述一条订单消息从
seckill.lua的XADD到最终写入数据库的路径,并说明在这条链路中,哪些位置会调用XACK、哪些位置不会。 -
失败分析 :在当前的
VoucherOrderServiceImpl中,请找出至少两个"消息已经被消费/ACK,但订单没有落库"或"订单可能永远不被处理"的场景,并说明原因和改进方向。
9. 练习题参考答案与逐题反馈
第 1 题:List 队列
标准答案
为什么 FIFO:
LPUSH 从左侧插入,新消息在队头;BRPOP 从右侧弹出,先进入的消息先到右侧,所以先被取出。整体是"左边进、右边出",符合先进先出。
也可以理解为:LPUSH 把消息依次压到左边,最早的消息被推到最右边;BRPOP 从右边取,自然先取最早的。
崩溃后的结果:
BRPOP 是破坏性读取,消息一旦弹出就从 List 中删除。如果消费者拿到消息后、处理前崩溃,这条消息就永久丢失了,List 里没有任何记录。
可靠队列的做法:
使用 RPOPLPUSH source backup(或 Redis 6.2+ 的 LMOVE):
- 从主队列取出消息的同时,放入备份队列;
- 业务处理成功后,用
LREM backup 1 message从备份队列删除; - 如果消费者崩溃,消息还在备份队列里,可以由补偿任务重新处理。
这相当于自己实现了一个"PEL + ACK",但需要业务代码维护。
常见误区
- ❌ "BRPOP 取出来看过了,消息就处理完了。"------取出 ≠ 处理成功,崩溃就丢。
- ❌ "List 是持久化的,所以消息不会丢。"------持久化的是"还在 List 里的消息",弹出后就没了。
- ❌ "多个消费者用 BRPOP 就能自动负载均衡。"------确实能分摊,但没有 ACK 和失败重试,某条消息丢了没人知道。
第 2 题:PubSub 的适用边界
标准答案
不能用于秒杀订单的三个原因:
- 消息不持久化 :没有订阅者在线时,
PUBLISH的消息直接丢弃,Redis 重启也会丢; - 没有 ACK:无法知道消费者是否处理成功,也无法重试;
- 没有消费组/负载均衡:所有订阅者都会收到同一条消息,无法做到"一条消息只被一个消费者处理";消费者离线期间的消息也不会补发。
补充原因:慢消费者可能因为客户端输出缓冲区上限被断开,消息丢失。
适合的场景:
- 实时通知:例如系统公告、聊天室消息;
- 缓存失效广播:多个实例订阅同一个频道,收到后清理本地缓存;
- 实时比分、行情推送等"错过就错过"的场景。
常见误区
- ❌ "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 |