黑马点评 Redis 消息队列三:Stream 和消费者组到底强在哪里?
本文继续整理黑马点评 Redis 实战篇第 7 章「Redis 消息队列」。
前两篇分别讲了为什么需要消息队列,以及 Redis List、PubSub 为什么不够。
这一篇进入第 7 章核心:Redis Stream、消费者组、ACK、pending-list 到底在解决什么问题。
1. 这篇文章解决什么问题
Stream 这一节很容易学成"背命令":
text
XADD
XREAD
XGROUP CREATE
XREADGROUP
XACK
但如果只背命令,很快会忘。
真正要理解的是:
text
每个命令到底补上了消息队列的哪块能力。
先给结论:
Redis Stream 是 Redis 5.0 引入的消息流数据结构。它能保存消息、给消息生成唯一 ID、支持阻塞读取、支持消费者组让多个消费者分工消费、支持 ACK 确认消息,并用 pending-list 记录已经投递但还没确认的消息,从而比 List 和 PubSub 更适合异步秒杀下单。
2. Stream 是什么
Stream 可以理解成:
text
Redis 里的消息日志队列。
它保存一条条消息。
每条消息都有:
text
1. 消息 ID
2. 消息内容,也就是多个 field-value
比如秒杀订单消息可以长这样:
text
stream.orders
1700000000000-0
userId 101
voucherId 10
id 123456789
这里:
text
stream.orders 是队列名称
1700000000000-0 是消息 ID
userId、voucherId、id 是消息字段
Stream 消息结构
#mermaid-svg-ty4oO40U4e9kYKfB{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-ty4oO40U4e9kYKfB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ty4oO40U4e9kYKfB .error-icon{fill:#552222;}#mermaid-svg-ty4oO40U4e9kYKfB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ty4oO40U4e9kYKfB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ty4oO40U4e9kYKfB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ty4oO40U4e9kYKfB .marker.cross{stroke:#333333;}#mermaid-svg-ty4oO40U4e9kYKfB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ty4oO40U4e9kYKfB p{margin:0;}#mermaid-svg-ty4oO40U4e9kYKfB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ty4oO40U4e9kYKfB .cluster-label text{fill:#333;}#mermaid-svg-ty4oO40U4e9kYKfB .cluster-label span{color:#333;}#mermaid-svg-ty4oO40U4e9kYKfB .cluster-label span p{background-color:transparent;}#mermaid-svg-ty4oO40U4e9kYKfB .label text,#mermaid-svg-ty4oO40U4e9kYKfB span{fill:#333;color:#333;}#mermaid-svg-ty4oO40U4e9kYKfB .node rect,#mermaid-svg-ty4oO40U4e9kYKfB .node circle,#mermaid-svg-ty4oO40U4e9kYKfB .node ellipse,#mermaid-svg-ty4oO40U4e9kYKfB .node polygon,#mermaid-svg-ty4oO40U4e9kYKfB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ty4oO40U4e9kYKfB .rough-node .label text,#mermaid-svg-ty4oO40U4e9kYKfB .node .label text,#mermaid-svg-ty4oO40U4e9kYKfB .image-shape .label,#mermaid-svg-ty4oO40U4e9kYKfB .icon-shape .label{text-anchor:middle;}#mermaid-svg-ty4oO40U4e9kYKfB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ty4oO40U4e9kYKfB .rough-node .label,#mermaid-svg-ty4oO40U4e9kYKfB .node .label,#mermaid-svg-ty4oO40U4e9kYKfB .image-shape .label,#mermaid-svg-ty4oO40U4e9kYKfB .icon-shape .label{text-align:center;}#mermaid-svg-ty4oO40U4e9kYKfB .node.clickable{cursor:pointer;}#mermaid-svg-ty4oO40U4e9kYKfB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ty4oO40U4e9kYKfB .arrowheadPath{fill:#333333;}#mermaid-svg-ty4oO40U4e9kYKfB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ty4oO40U4e9kYKfB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ty4oO40U4e9kYKfB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ty4oO40U4e9kYKfB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ty4oO40U4e9kYKfB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ty4oO40U4e9kYKfB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ty4oO40U4e9kYKfB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ty4oO40U4e9kYKfB .cluster text{fill:#333;}#mermaid-svg-ty4oO40U4e9kYKfB .cluster span{color:#333;}#mermaid-svg-ty4oO40U4e9kYKfB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ty4oO40U4e9kYKfB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ty4oO40U4e9kYKfB rect.text{fill:none;stroke-width:0;}#mermaid-svg-ty4oO40U4e9kYKfB .icon-shape,#mermaid-svg-ty4oO40U4e9kYKfB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ty4oO40U4e9kYKfB .icon-shape p,#mermaid-svg-ty4oO40U4e9kYKfB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ty4oO40U4e9kYKfB .icon-shape .label rect,#mermaid-svg-ty4oO40U4e9kYKfB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ty4oO40U4e9kYKfB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ty4oO40U4e9kYKfB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ty4oO40U4e9kYKfB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} stream.orders
消息ID: 1700000000000-0
userId: 101
voucherId: 10
id: 123456789
和 List 相比,Stream 更像"消息记录表"。
消息不是简单地一弹出就没了。
它会保留消息 ID 和内容,这就为消息回溯、确认、异常恢复提供了基础。
3. XADD:向 Stream 发送消息
讲义中介绍的发送消息命令是:
redis
XADD key ID field value [field value ...]
在秒杀业务里,Lua 脚本最后会执行:
lua
redis.call(
'xadd',
'stream.orders',
'*',
'userId', userId,
'voucherId', voucherId,
'id', orderId
)
它相当于执行:
redis
XADD stream.orders * userId 101 voucherId 10 id 123456789
这里每个参数都很重要。
key 是什么
text
stream.orders
这是 Stream 队列名称。
意思是:
text
所有秒杀订单消息都写到 stream.orders 这个队列里。
ID 是什么
text
*
* 表示让 Redis 自动生成消息 ID。
Redis 生成的 ID 通常类似:
text
时间戳-序号
比如:
text
1700000000000-0
我们不用自己算这个 ID。
field-value 是什么
text
userId 101
voucherId 10
id 123456789
这些就是订单任务需要的业务数据。
后台消费者拿到消息后,可以把它转成 VoucherOrder。
4. XREAD:普通读取消息
Stream 可以用 XREAD 读取消息:
redis
XREAD COUNT 1 STREAMS stream.orders 0
可以理解为:
text
从 stream.orders 读取消息。
从指定 ID 后面开始读。
最多读 1 条。
它也支持阻塞读取:
redis
XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders $
这里:
text
BLOCK 2000:没有消息时最多阻塞等待 2000 毫秒
$:读取最新消息
阻塞读取很好理解。
它和 BRPOP、BlockingQueue.take() 的思想类似:
text
没消息时不要空转,等一会儿。
5. 为什么 XREAD 用 $ 有漏读风险
讲义里特别提醒:
text
当指定起始 ID 为 $ 时,代表读取最新消息。
如果处理一条消息过程中,又有超过 1 条消息到达队列,
下次获取时也只能获取到最新的一条,会出现漏读消息的问题。
这个点刚学时很容易绕。
我们用一个具体例子讲。
消费者第一次执行:
redis
XREAD COUNT 1 BLOCK 2000 STREAMS stream.orders $
它读到了消息 A。
然后消费者开始处理 A。
处理 A 的过程中,队列里又来了:
text
消息 B
消息 C
消息 D
处理完 A 后,消费者又用 $ 读取:
text
$ 表示从最新位置开始等新消息
这样它可能会跳过已经进入队列的 B、C、D,只等待后续更新的消息。
这就是漏读风险。
所以普通 XREAD 可以用于理解 Stream,但不适合作为最终秒杀订单消费方案。
最终方案要用消费者组。
6. 消费者组解决什么问题
消费者组是 Stream 的核心能力。
它解决几个问题:
text
1. 多个消费者可以分工处理同一个队列。
2. 每条消息只会分配给组内某个消费者处理。
3. 消息投递后,如果没有 ACK,会进入 pending-list。
4. 失败消息可以从 pending-list 中重新读取处理。
可以把消费者组想成:
text
一个订单处理小组。
小组里有多个消费者:
text
c1
c2
c3
它们共同消费:
text
stream.orders
Redis 会把消息分配给组内消费者。
消费者组模型
#mermaid-svg-zqcUBbeXRkJiVAIG{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-zqcUBbeXRkJiVAIG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zqcUBbeXRkJiVAIG .error-icon{fill:#552222;}#mermaid-svg-zqcUBbeXRkJiVAIG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zqcUBbeXRkJiVAIG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zqcUBbeXRkJiVAIG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zqcUBbeXRkJiVAIG .marker.cross{stroke:#333333;}#mermaid-svg-zqcUBbeXRkJiVAIG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zqcUBbeXRkJiVAIG p{margin:0;}#mermaid-svg-zqcUBbeXRkJiVAIG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster-label text{fill:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster-label span{color:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster-label span p{background-color:transparent;}#mermaid-svg-zqcUBbeXRkJiVAIG .label text,#mermaid-svg-zqcUBbeXRkJiVAIG span{fill:#333;color:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG .node rect,#mermaid-svg-zqcUBbeXRkJiVAIG .node circle,#mermaid-svg-zqcUBbeXRkJiVAIG .node ellipse,#mermaid-svg-zqcUBbeXRkJiVAIG .node polygon,#mermaid-svg-zqcUBbeXRkJiVAIG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zqcUBbeXRkJiVAIG .rough-node .label text,#mermaid-svg-zqcUBbeXRkJiVAIG .node .label text,#mermaid-svg-zqcUBbeXRkJiVAIG .image-shape .label,#mermaid-svg-zqcUBbeXRkJiVAIG .icon-shape .label{text-anchor:middle;}#mermaid-svg-zqcUBbeXRkJiVAIG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zqcUBbeXRkJiVAIG .rough-node .label,#mermaid-svg-zqcUBbeXRkJiVAIG .node .label,#mermaid-svg-zqcUBbeXRkJiVAIG .image-shape .label,#mermaid-svg-zqcUBbeXRkJiVAIG .icon-shape .label{text-align:center;}#mermaid-svg-zqcUBbeXRkJiVAIG .node.clickable{cursor:pointer;}#mermaid-svg-zqcUBbeXRkJiVAIG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zqcUBbeXRkJiVAIG .arrowheadPath{fill:#333333;}#mermaid-svg-zqcUBbeXRkJiVAIG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zqcUBbeXRkJiVAIG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zqcUBbeXRkJiVAIG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zqcUBbeXRkJiVAIG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zqcUBbeXRkJiVAIG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zqcUBbeXRkJiVAIG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster text{fill:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG .cluster span{color:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zqcUBbeXRkJiVAIG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zqcUBbeXRkJiVAIG rect.text{fill:none;stroke-width:0;}#mermaid-svg-zqcUBbeXRkJiVAIG .icon-shape,#mermaid-svg-zqcUBbeXRkJiVAIG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zqcUBbeXRkJiVAIG .icon-shape p,#mermaid-svg-zqcUBbeXRkJiVAIG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zqcUBbeXRkJiVAIG .icon-shape .label rect,#mermaid-svg-zqcUBbeXRkJiVAIG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zqcUBbeXRkJiVAIG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zqcUBbeXRkJiVAIG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zqcUBbeXRkJiVAIG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} stream.orders
消费者组 g1
消费者 c1
消费者 c2
消费者 c3
处理部分订单消息
处理部分订单消息
处理部分订单消息
消费者组的价值是:
text
既能提高消费速度,又能记录消息是否处理完成。
7. XGROUP CREATE:创建消费者组
创建消费者组的命令是:
redis
XGROUP CREATE key groupName ID MKSTREAM
比如:
redis
XGROUP CREATE stream.orders g1 0 MKSTREAM
逐个解释。
key
text
stream.orders
表示给哪个 Stream 创建消费者组。
groupName
text
g1
消费者组名称。
后续消费者读取消息时,要指定自己属于哪个组。
ID
text
0
表示消费者组从哪条消息开始读。
常见选择:
text
0:从第一条消息开始读。
$:从当前最后一条消息之后开始读,只读新消息。
学习阶段可以记:
text
0 更适合"不想错过已有消息"。
$ 更适合"只关心创建组之后的新消息"。
MKSTREAM
text
MKSTREAM
表示如果 stream.orders 不存在,就自动创建。
不加它时,如果 Stream 不存在,创建消费者组可能失败。
8. XREADGROUP:消费者组读取消息
讲义中的命令结构:
redis
XREADGROUP GROUP group consumer
COUNT count
BLOCK milliseconds
STREAMS key ID
项目代码里对应:
java
stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
它相当于:
redis
XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
这里逐个拆。
GROUP g1 c1
text
g1:消费者组名称
c1:消费者名称
意思是:
text
消费者 c1 以 g1 组成员身份读取消息。
COUNT 1
一次最多读取 1 条消息。
BLOCK 2000
没有消息时最多阻塞等待 2 秒。
这样消费者线程不会疯狂空转。
STREAMS stream.orders >
这里最关键的是:
text
>
在消费者组里,> 表示:
text
读取从未投递给当前消费者组的新消息。
也就是正常消费新消息。
9. ACK:为什么处理完要确认
消费者组里,消费者读取消息后,Redis 不会立刻认为这条消息彻底完成。
它会先把消息放到 pending-list。
可以理解为:
text
这条消息已经交给某个消费者了,但还没确认处理成功。
当业务处理成功后,需要执行:
redis
XACK stream.orders g1 消息ID
项目代码对应:
java
stringRedisTemplate.opsForStream()
.acknowledge("stream.orders", "g1", record.getId());
ACK 的意思是:
text
这条消息我已经处理成功了,可以从 pending-list 中移除了。
ACK 流程图
MySQL 消费者 c1 Redis Stream MySQL 消费者 c1 Redis Stream #mermaid-svg-0JMi4iei6vhwPuiK{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-0JMi4iei6vhwPuiK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0JMi4iei6vhwPuiK .error-icon{fill:#552222;}#mermaid-svg-0JMi4iei6vhwPuiK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0JMi4iei6vhwPuiK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0JMi4iei6vhwPuiK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0JMi4iei6vhwPuiK .marker.cross{stroke:#333333;}#mermaid-svg-0JMi4iei6vhwPuiK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0JMi4iei6vhwPuiK p{margin:0;}#mermaid-svg-0JMi4iei6vhwPuiK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0JMi4iei6vhwPuiK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0JMi4iei6vhwPuiK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0JMi4iei6vhwPuiK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0JMi4iei6vhwPuiK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0JMi4iei6vhwPuiK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0JMi4iei6vhwPuiK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0JMi4iei6vhwPuiK .sequenceNumber{fill:white;}#mermaid-svg-0JMi4iei6vhwPuiK #sequencenumber{fill:#333;}#mermaid-svg-0JMi4iei6vhwPuiK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0JMi4iei6vhwPuiK .messageText{fill:#333;stroke:none;}#mermaid-svg-0JMi4iei6vhwPuiK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0JMi4iei6vhwPuiK .labelText,#mermaid-svg-0JMi4iei6vhwPuiK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0JMi4iei6vhwPuiK .loopText,#mermaid-svg-0JMi4iei6vhwPuiK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0JMi4iei6vhwPuiK .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-0JMi4iei6vhwPuiK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0JMi4iei6vhwPuiK .noteText,#mermaid-svg-0JMi4iei6vhwPuiK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0JMi4iei6vhwPuiK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0JMi4iei6vhwPuiK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0JMi4iei6vhwPuiK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0JMi4iei6vhwPuiK .actorPopupMenu{position:absolute;}#mermaid-svg-0JMi4iei6vhwPuiK .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-0JMi4iei6vhwPuiK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0JMi4iei6vhwPuiK .actor-man circle,#mermaid-svg-0JMi4iei6vhwPuiK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0JMi4iei6vhwPuiK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} XREADGROUP 读取消息投递消息,并放入 pending-list扣库存并保存订单处理成功XACK 确认消息从 pending-list 移除
ACK 是 Stream 比 List 更适合订单业务的关键能力。
List 的 BRPOP 是:
text
取出来就删除。
Stream 消费者组是:
text
投递给你
你处理成功后 ACK
Redis 再认为它完成
可靠性明显更好。
10. pending-list 是什么
pending-list 是消费者组里非常重要的概念。
它保存的是:
text
已经投递给消费者,但还没有 ACK 的消息。
比如:
text
消息 A 投递给 c1
c1 处理时宕机
c1 没来得及 ACK
这时消息 A 不会凭空消失。
它会留在 pending-list 中。
后续消费者可以从 pending-list 重新读取它,再处理一次。
这就是第 7 章异常恢复的核心。
pending-list 示意图
#mermaid-svg-M9sqOD5Vu8n4lEyV{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-M9sqOD5Vu8n4lEyV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M9sqOD5Vu8n4lEyV .error-icon{fill:#552222;}#mermaid-svg-M9sqOD5Vu8n4lEyV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M9sqOD5Vu8n4lEyV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .marker.cross{stroke:#333333;}#mermaid-svg-M9sqOD5Vu8n4lEyV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M9sqOD5Vu8n4lEyV p{margin:0;}#mermaid-svg-M9sqOD5Vu8n4lEyV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster-label text{fill:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster-label span{color:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster-label span p{background-color:transparent;}#mermaid-svg-M9sqOD5Vu8n4lEyV .label text,#mermaid-svg-M9sqOD5Vu8n4lEyV span{fill:#333;color:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .node rect,#mermaid-svg-M9sqOD5Vu8n4lEyV .node circle,#mermaid-svg-M9sqOD5Vu8n4lEyV .node ellipse,#mermaid-svg-M9sqOD5Vu8n4lEyV .node polygon,#mermaid-svg-M9sqOD5Vu8n4lEyV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .rough-node .label text,#mermaid-svg-M9sqOD5Vu8n4lEyV .node .label text,#mermaid-svg-M9sqOD5Vu8n4lEyV .image-shape .label,#mermaid-svg-M9sqOD5Vu8n4lEyV .icon-shape .label{text-anchor:middle;}#mermaid-svg-M9sqOD5Vu8n4lEyV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .rough-node .label,#mermaid-svg-M9sqOD5Vu8n4lEyV .node .label,#mermaid-svg-M9sqOD5Vu8n4lEyV .image-shape .label,#mermaid-svg-M9sqOD5Vu8n4lEyV .icon-shape .label{text-align:center;}#mermaid-svg-M9sqOD5Vu8n4lEyV .node.clickable{cursor:pointer;}#mermaid-svg-M9sqOD5Vu8n4lEyV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .arrowheadPath{fill:#333333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9sqOD5Vu8n4lEyV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M9sqOD5Vu8n4lEyV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9sqOD5Vu8n4lEyV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster text{fill:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV .cluster span{color:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M9sqOD5Vu8n4lEyV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M9sqOD5Vu8n4lEyV rect.text{fill:none;stroke-width:0;}#mermaid-svg-M9sqOD5Vu8n4lEyV .icon-shape,#mermaid-svg-M9sqOD5Vu8n4lEyV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M9sqOD5Vu8n4lEyV .icon-shape p,#mermaid-svg-M9sqOD5Vu8n4lEyV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M9sqOD5Vu8n4lEyV .icon-shape .label rect,#mermaid-svg-M9sqOD5Vu8n4lEyV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M9sqOD5Vu8n4lEyV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M9sqOD5Vu8n4lEyV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M9sqOD5Vu8n4lEyV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
stream.orders 新消息
XREADGROUP 投递给 c1
pending-list
c1 是否 XACK?
从 pending-list 移除
保留在 pending-list,等待恢复处理
11. > 和 0 的区别
这是第 7 章最容易混的点之一。
消费者组读取时,最后的 ID 可以是:
text
>
0
它们含义完全不同。
> 表示读新消息
redis
XREADGROUP GROUP g1 c1 STREAMS stream.orders >
表示:
text
读取从未被投递给消费者组的新消息。
这是正常消费流程。
0 表示读 pending-list
redis
XREADGROUP GROUP g1 c1 STREAMS stream.orders 0
表示:
text
从 pending-list 中读取已经投递但未确认的消息。
这是异常恢复流程。
可以记成一句话:
>找新活,0捡旧账。
项目代码里也是这样:
java
// 正常读取新消息
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
// 异常恢复读取 pending-list
StreamOffset.create("stream.orders", ReadOffset.from("0"))
这里 ReadOffset.lastConsumed() 在消费者组场景下对应的就是读取新消息的 > 语义。
12. Stream 消费者组完整消费流程
把消费者组正常流程串起来:
text
1. Lua 用 XADD 写入订单消息。
2. 消费者用 XREADGROUP ... > 读取新消息。
3. Redis 把消息投递给消费者,并放入 pending-list。
4. 消费者解析消息,执行数据库下单。
5. 数据库处理成功后,消费者执行 XACK。
6. Redis 从 pending-list 移除该消息。
如果第 4 步失败:
text
消息不会 ACK
它会留在 pending-list
后续 handlePendingList 可以用 ID=0 重新读取处理
完整流程图
#mermaid-svg-RSnOJIDTQxANA5c0{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-RSnOJIDTQxANA5c0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RSnOJIDTQxANA5c0 .error-icon{fill:#552222;}#mermaid-svg-RSnOJIDTQxANA5c0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RSnOJIDTQxANA5c0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RSnOJIDTQxANA5c0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RSnOJIDTQxANA5c0 .marker.cross{stroke:#333333;}#mermaid-svg-RSnOJIDTQxANA5c0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RSnOJIDTQxANA5c0 p{margin:0;}#mermaid-svg-RSnOJIDTQxANA5c0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster-label text{fill:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster-label span{color:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster-label span p{background-color:transparent;}#mermaid-svg-RSnOJIDTQxANA5c0 .label text,#mermaid-svg-RSnOJIDTQxANA5c0 span{fill:#333;color:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 .node rect,#mermaid-svg-RSnOJIDTQxANA5c0 .node circle,#mermaid-svg-RSnOJIDTQxANA5c0 .node ellipse,#mermaid-svg-RSnOJIDTQxANA5c0 .node polygon,#mermaid-svg-RSnOJIDTQxANA5c0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RSnOJIDTQxANA5c0 .rough-node .label text,#mermaid-svg-RSnOJIDTQxANA5c0 .node .label text,#mermaid-svg-RSnOJIDTQxANA5c0 .image-shape .label,#mermaid-svg-RSnOJIDTQxANA5c0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-RSnOJIDTQxANA5c0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RSnOJIDTQxANA5c0 .rough-node .label,#mermaid-svg-RSnOJIDTQxANA5c0 .node .label,#mermaid-svg-RSnOJIDTQxANA5c0 .image-shape .label,#mermaid-svg-RSnOJIDTQxANA5c0 .icon-shape .label{text-align:center;}#mermaid-svg-RSnOJIDTQxANA5c0 .node.clickable{cursor:pointer;}#mermaid-svg-RSnOJIDTQxANA5c0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RSnOJIDTQxANA5c0 .arrowheadPath{fill:#333333;}#mermaid-svg-RSnOJIDTQxANA5c0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RSnOJIDTQxANA5c0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RSnOJIDTQxANA5c0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RSnOJIDTQxANA5c0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RSnOJIDTQxANA5c0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RSnOJIDTQxANA5c0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster text{fill:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 .cluster span{color:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RSnOJIDTQxANA5c0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RSnOJIDTQxANA5c0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-RSnOJIDTQxANA5c0 .icon-shape,#mermaid-svg-RSnOJIDTQxANA5c0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RSnOJIDTQxANA5c0 .icon-shape p,#mermaid-svg-RSnOJIDTQxANA5c0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RSnOJIDTQxANA5c0 .icon-shape .label rect,#mermaid-svg-RSnOJIDTQxANA5c0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RSnOJIDTQxANA5c0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RSnOJIDTQxANA5c0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RSnOJIDTQxANA5c0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 成功
失败
XADD 写入订单消息
stream.orders
XREADGROUP ... > 读取新消息
消息进入 pending-list
消费者处理业务
处理是否成功?
XACK 确认消息
从 pending-list 移除
不 ACK,消息留在 pending-list
XREADGROUP ... 0 重新处理
这张图就是第 7 章的核心。
13. Stream 相比 List 和 PubSub 强在哪里
讲义最后有一个对比。
我们用自己的话总结:
相比 List
Stream 更强在:
text
1. 每条消息有 ID,可以回溯。
2. 支持消费者组。
3. 支持 ACK。
4. 支持 pending-list 异常恢复。
相比 PubSub
Stream 更强在:
text
1. 消息会保存,不是在线广播。
2. 消费者离线后,消息仍然可以后续处理。
3. 有确认机制。
4. 更适合订单这种关键业务。
相比 BlockingQueue
Stream 更强在:
text
1. 不依赖单个 JVM 内存。
2. 服务宕机后消息仍然在 Redis。
3. 多个服务实例可以共享同一个队列。
14. 本篇最容易混淆的几个点
1. Stream 是不是等于 Redis 里的 List
不是。
List 是简单链表队列。
Stream 是带消息 ID、消费者组、ACK、pending-list 的消息流结构。
2. XREAD 和 XREADGROUP 是不是一样
不一样。
XREAD 是普通读取。
XREADGROUP 是消费者组读取,能配合消费者组、pending-list、ACK 使用。
3. ACK 是不是删除 Stream 里的消息
不是简单删除 Stream 消息。
ACK 的核心是:
text
把该消息从消费者组的 pending-list 中移除,表示这个组已经处理完成。
4. pending-list 是不是一个新队列
不是。
它是消费者组内部维护的"已投递但未确认消息列表"。
5. > 和 0 怎么记
可以记:
text
>:读新消息
0:读 pending-list 里的旧消息
15. 面试怎么回答
如果面试官问:Redis Stream 为什么比 List 更适合做消息队列?
可以这样回答:
Redis List 可以模拟简单队列,但取出消息后消息就从队列中移除,缺少完善的消息确认和失败恢复机制。Redis Stream 是 Redis 5.0 引入的消息流结构,每条消息有唯一 ID,支持阻塞读取、消费者组、ACK 和 pending-list。消费者读取消息后,如果处理成功再 ACK;如果处理失败或宕机,消息会留在 pending-list 中,可以后续重新处理,因此更适合订单这类需要可靠消费的业务。
如果面试官问:Stream 消费者组中的 pending-list 是什么?
可以这样回答:
pending-list 保存的是已经被投递给消费者但还没有 ACK 的消息。当消费者读取消息后,Redis 会把消息记录到 pending-list;消费者业务处理成功后执行 XACK,消息才会从 pending-list 中移除。如果消费者处理过程中异常或宕机,没有 ACK,这条消息就会留在 pending-list 中,后续可以通过读取 pending-list 来恢复处理。
如果面试官问:XREADGROUP 中 > 和 0 有什么区别?
可以这样回答:
在消费者组模式下,
>表示读取从未投递给当前消费者组的新消息,是正常消费流程;0表示从 pending-list 中读取已经投递但未确认的消息,通常用于异常恢复。
16. 总结
这一篇的主线是:
text
Stream 保存消息并生成消息 ID
↓
XADD 写入消息
↓
XREAD 可以普通读取,但有漏读风险
↓
消费者组让多个消费者协作消费
↓
XREADGROUP 读取新消息
↓
处理成功后 XACK
↓
处理失败则消息留在 pending-list
↓
后续用 ID=0 从 pending-list 恢复处理
最重要的是记住:
Stream 的强大不是某一个命令,而是"消息保存 + 消费者组 + ACK + pending-list"这一整套机制组合起来,让 Redis 更像一个真正的消息队列。
下一篇把这些机制放回黑马点评业务:
text
Stream 如何改造异步秒杀下单?