如果你正在用 Redis 做异步队列,或者正在 List 和 Stream 之间犹豫,这篇文章值得你花 10 分钟读完。 它不会停留在"Stream 有 ACK、List 没有"这种表面结论上,而是把三种方案的故障边界 拆开,讲清楚 Stream 的可靠性到底来自哪一行代码、代价在哪里,以及 Redis 8.2 到 8.8 这轮密集迭代对生产实践意味着什么。读完你至少能带走三个判断:List 和 Pub/Sub 的根本缺陷不是性能,而是没有"未确认状态" ;Stream 的 PEL 只负责记住,不负责恢复;Redis 8.4 之后,恢复这件事才真正变得"可运维"。
一、List 和 Pub/Sub 到底"缺"了什么
List:RPOP 返回的那一刻,消息就不归 Redis 管了
List 用 LPUSH / BRPOP 实现队列,头尾操作都是 O(1),消息存在内存里,不受 JVM 堆限制。但它的致命点在于语义:RPOP 是破坏性的。消息被弹出的瞬间就从 List 中永久移除,Redis 既不知道它被谁取走了,也不知道取走之后有没有处理成功。消费者拿到消息后如果进程崩溃,这条消息不会被任何一方记住。
List 只支持单消费者竞争,先到先得,无法让多个业务组独立消费同一批消息。
Pub/Sub:连"记住"这一步都省了
Pub/Sub 的解耦能力更强,支持多订阅者广播,但它在可靠性上的问题比 List 更彻底:消息不持久化 。发布时消费者不在线,消息直接消失,Redis 不保留任何副本。标准 Redis Pub/Sub 的投递保证是 at-most-once------消息投出去,至于有没有人收到、收到后有没有处理,服务端概不关心。
它适合配置推送、在线状态同步这类丢失可容忍的实时场景,不适合任何涉及资金或状态变更的业务。
共同的死穴:没有"已投递、未确认"这个中间态
把三者的生命周期并排看,差距一目了然:
| 行为 | List(LPUSH/RPOP) | Pub/Sub | Stream(消费者组) |
|---|---|---|---|
| 读取语义 | 破坏性(弹出即移除) | 无状态广播 | 非破坏性(移动游标) |
| 消费者离线 | 消息等待 | 消息丢失 | 消息等待(留在 Stream 中) |
| 消费者崩溃 | 已弹出消息永久丢失 | 消息已丢失 | 留在 PEL,可恢复 |
| 确认机制 | 无 | 无 | XACK 显式确认 |
| 多业务组独立消费 | 不支持 | 支持(但所有组同时收到) | 支持 |
| 内存行为 | 消费后收缩 | 不保留 | 不裁剪则无界增长 |
List 和 Pub/Sub 在"消费者拿走消息"那一刻就放弃了追踪。Stream 的消费者组把"已投递、未确认"变成一个 Redis 可以感知、可以查询、可以转移的状态,这才是分水岭。
二、Stream 的 PEL:可靠性的来源,也是复杂度的来源
> 和 0:两个语义完全不同的入口
XREADGROUP 的 ID 参数决定了你读什么:
-
>:只读取从未投递给任何消费者的新消息。 -
0:读取当前消费者自己 PEL 中已投递但未 ACK 的历史消息。
这个区别是整个恢复逻辑的入口。只用 > 的消费者,永远看不到自己上次崩溃前没来得及 ACK 的那批消息。正确的启动模式必须两阶段 :先用自己的消费者名读 0,把 PEL 历史清空,再切换到 > 处理新消息。跳过第一阶段,那些 pending 消息就真的变成了僵尸。
PEL 只负责"记住",不负责"恢复"
当 XREADGROUP 把消息投递给消费者时,Redis 在消费者组内部创建一个 pending entry (内部结构叫 streamNACK),记录四件事:消息的 Stream ID、是哪个消费者收到的、什么时候投递的、投递过几次。直到消费者显式调用 XACK,这条记录才会从 PEL 中移除。
这里有一个文档不会用大字警告你、但线上一定会咬人 的事实:Redis 不做任何超时检测 。如果一个消费者在拿到消息后崩溃了,它名下的 pending 消息会永远挂在那个死掉的消费者名下 ,Redis 不会自动把它们转给别人。SegmentFault 上那篇运维复盘把这句话说得最直白:"PEL 只负责记住,不负责恢复。恢复得自己做。"
XAUTOCLAIM:把"恢复"变成一个可运维的动作
Redis 6.2 引入的 XAUTOCLAIM 把"扫描 PEL + 转移所有权"合并成一个原子命令:扫描 PEL,把空闲时间超过阈值的消息直接认领给当前消费者。
bash
XAUTOCLAIM stream group consumer-2 60000 0-0 COUNT 50
min-idle-time(上面例子中的 60000 毫秒)是最关键的参数。设小了,会误抢正在处理中的慢任务,造成重复消费;设大了,故障恢复慢。 务实的原则是:按"最慢的那类任务的 P99 执行时间"再留一倍余量,而不是拍脑袋给 30 秒。
还有一个容易被忽略的细节:XAUTOCLAIM 一次最多扫 COUNT 条,返回的游标是下一轮的起点 。如果 PEL 积压很多,必须带着游标继续扫,而不是每次都从 0-0 开始------否则前面一批一直处理失败的消息会永久阻塞后面消息被发现。
Redis 8.4 的 CLAIM:一次命令完成"恢复 + 新消息"
在 8.4 之前,一个正确的消费者循环需要在 XPENDING / XAUTOCLAIM 和 XREADGROUP 之间来回切换。XAUTOCLAIM 按 Stream ID 顺序遍历 PEL,当 PEL 很大但其中只有少量条目空闲超时时,绝大部分扫描都是无效劳动。
Redis 8.4 给 XREADGROUP 加了一个可选参数 CLAIM min-idle-time:
bash
XREADGROUP GROUP group consumer [COUNT count] [BLOCK ms]
[CLAIM min-idle-time] STREAMS key id
指定 CLAIM 后,一条命令会先从 PEL 中认领空闲超过阈值的消息,再投递新消息 。Redis 官方基准显示,这一改动让恢复路径比 XAUTOCLAIM 快 22.5 倍 。但要注意:CLAIM 只简化了"找到并转移"这一步,XACK 仍然是应用层的硬性责任,不 ACK 消息依然会留在 PEL 里。
三、Stream 的运维代价:比"支持 ACK"更值得警惕的四件事
坑 1:PEL 是独立于 Stream 的内存结构,MAXLEN 裁剪不会碰它
XADD 的 MAXLEN ~ 近似裁剪确实比精确裁剪高效,但它只裁剪 Stream 本身 。如果一条消息已经被投递但尚未 ACK,随后因为 MAXLEN 裁剪从 Stream 中消失了,它的 ID 仍然留在 PEL 里,但 payload 已经不存在 。消费者重放 PEL 时会拿到一个字段为空的空壳。更隐蔽的是:"用 MAXLEN 把内存限住"在消费者持续失败时是不成立的------消息被裁剪了,但 PEL 的引用还在,内存并没有真正释放。
坑 2:消费者名字会永久堆积
消费者组会永久记住每一个出现过的消费者名字 。如果消费者名是 hostname + pid + 时间戳 动态生成的,每重启一次就多一个;容器环境下 Pod 滚动更新一次换一批。跑几个月之后,XINFO CONSUMERS 里会积累大量早已不存在的消费者记录,而认领死消费者的消息并不会删除它的消费者记录 ------必须显式 XGROUP DELCONSUMER 才能清理。
坑 3:直接 DEL 消费者,PEL 里的消息会变成"无主孤魂"
XGROUP DELCONSUMER 删掉一个消费者之后,它名下那些 pending 消息的 owner 就没了。虽然消息本身还在 Stream 里,但它们不再属于任何消费者 ,XAUTOCLAIM 也认领不到------等于卡死。清理消费者之前,必须先把它的 PEL 处理干净。
坑 4:死信队列与 PEL 的 ACK 取舍没有标准答案,但必须显式选
消息重试超限进入死信队列后,PEL 里那条记录要不要 XACK?
-
ACK 掉 :PEL 干净,
pending监控数字准确反映真实积压。但消息在消费组视角里彻底消失,以后从死信重投无法与原始记录对应。 -
不 ACK :可追溯,但
pending永远不为零,监控必须能区分"真积压"和"已标记死信",否则会一直误报。
选哪个取决于你更在乎可追溯性还是监控简洁性。但随手写一个"不处理"是最危险的。
监控应该盯什么
XPENDING 的 pending 数量持续上涨,通常意味着消费者在处理但没有 ACK,或者有消费者已经死亡但消息没有被认领。XINFO GROUPS 中的 lag(尚未投递给该组的条目数)上涨、pending 持平,则指向消费速度跟不上生产速度。两者需要分开告警,而不是混在一个指标里看。
四、性能定位:Redis Stream 在 2026 年的真实坐标
选型不能只看功能,延迟和吞吐量是另一半。综合 2025--2026 年的多组基准测试,Redis Stream 的定位已经比较清晰:
| 指标 | Redis Stream | Kafka | NATS JetStream |
|---|---|---|---|
| P99 延迟 | <1ms ~ 12.8ms(视配置) | 5--20ms | 1--5ms |
| 吞吐 | 4.2万 ~ 8.2万 msg/s(单组) | 100万+ msg/s | 80万+ msg/s |
| 持久化 | 内存 + AOF | 磁盘,可长期保留 | 磁盘或内存 |
| 运维复杂度 | 低 | 高 | 中 |
| 适用场景 | 低延迟、中小规模、已有 Redis 基础设施 | 海量数据管道、事件溯源 | 云原生微服务 |
一组在 4 核 16G 环境下实测的数据是:Redis Stream 单条生产耗时 0.15ms ,消费者吞吐约 8.2 万条/秒 。Go 生态的对比压测中,Redis Stream 的 QPS 约 4.2 万/秒 ,P99 延迟 12.8ms ,但可靠性评分只有三星(主从异步复制存在窗口丢失风险),运维复杂度被评到最高------部署容易,把它用对不容易。
dev.to 2026 年的对比则给出了另一组值得注意的数字:Redis Streams 的 p99 延迟 <1ms ,吞吐约 50 万 msg/s,定位是"低延迟、中小规模、速度优先于持久性"的场景。
结论不是"Redis Stream 比 Kafka 好",而是:如果你的系统已经在跑 Redis,场景对延迟敏感、吞吐在十万级以内、且你愿意承担 PEL 的运维责任,Redis Stream 是延迟极低且基础设施复用度最高的选择。 海量数据管道交给 Kafka,云原生微服务间通信看 NATS JetStream,各自有各自的生态位。
五、Redis 8.2 → 8.8:Stream 的可靠性正在从"应用层负担"往"服务端能力"迁移
Redis 从 8.2 开始对 Stream 的迭代节奏明显加快,且方向高度一致:把原本需要应用层用多个命令拼凑的可靠性逻辑,收进服务端的一条命令里。
| 版本 | Stream 关键变化 | 对生产实践的意义 |
|---|---|---|
| 8.2 | 简化多消费者组的消息确认与删除 | 多组消费的运维复杂度下降 |
| 8.4 | XREADGROUP ... CLAIM |
恢复 + 新消息一次命令完成,比 XAUTOCLAIM 快 22.5× |
| 8.6 | 幂等生产 (IDMP / IDMPAUTO),at-most-once 生产保证 |
生产端重试不再制造重复消息,从源头减少消费端幂等压力 |
| 8.8 | 消息 NACK (消费者显式释放 pending 消息),XREADGROUP 吞吐提升最高 83% | 处理失败可以主动"退回"消息,而不是等 min-idle-time 超时;消费吞吐大幅提升 |
8.6 的幂等生产 值得单独提一句:XADD 支持 IDMP 参数后,生产者可以用一个幂等键标记消息,服务端在窗口期内拒绝重复写入。这改变了一个长期以来的实践惯性------过去"至少一次"语义下的重复消息主要靠消费端幂等来兜底;现在生产端可以先挡掉一层,消费端的幂等压力会相应减小。
8.8 的 NACK 则是 PEL 管理的一个实质性补全。在它之前,消费者处理失败后想让消息尽快被别人接手,只能等 min-idle-time 到期;NACK 允许消费者主动释放 pending 消息,让它立即变为可被其他消费者优先消费的状态。
六、一个生产级消费循环应该长什么样
把上面的机制落到代码,一个可靠的消费者需要同时处理四件事:恢复自己的 PEL、读新消息、认领死消费者的消息、显式 ACK。Redis 8.4 之后,前两件事可以合并,但 ACK 和认领收割仍然是应用层的责任。
python
import redis
import os, socket
r = redis.Redis()
consumer_name = f"order-worker-{socket.gethostname()}-{os.getpid()}"
# Phase 1: 启动时先清空自己的 PEL 历史(读 ID "0")
while True:
pending = r.xreadgroup("order-group", consumer_name,
{"stream.orders": "0"}, count=50)
if not pending or not pending[0][1]:
break
for _, messages in pending:
for msg_id, fields in messages:
process_and_ack(msg_id, fields)
# Phase 2: 读新消息(8.4+ 可加 CLAIM 参数同时认领空闲消息)
while True:
resp = r.xreadgroup("order-group", consumer_name,
{"stream.orders": ">"},
count=10, block=2000)
if resp:
for _, messages in resp:
for msg_id, fields in messages:
process_and_ack(msg_id, fields)
def process_and_ack(msg_id, fields):
# 幂等落库:orderId 唯一约束,重复执行不产生副作用
order_repo.insert_if_absent(fields)
r.xack("stream.orders", "order-group", msg_id)
ACK 必须在业务落库成功之后调用,顺序不能颠倒。 ACK 之前崩溃,消息会被重新认领并再次处理;ACK 之后崩溃,消息已经标记为完成。落库层必须能承受同一条消息被执行两次------Stream 的投递语义是 at-least-once,不是 exactly-once。
后台还需要一个独立的认领收割任务 :每 30 秒左右执行一批 XAUTOCLAIM,min-idle-time 设为订单处理 P99 的两倍左右,把死消费者名下的 pending 消息转给存活消费者。Redis 8.4+ 也可以把 CLAIM 参数直接嵌进 XREADGROUP,省掉独立的收割循环。
小结
Redis Stream 不是"更好的 List"。它的本质是一个把消息状态暴露给应用层的日志结构 :List 和 Pub/Sub 在消费者拿走消息的瞬间就放弃了追踪,Stream 的 PEL 让"未确认"变成一个可查询、可转移、可告警的状态。但 Redis 只负责记住 ,不负责恢复 ------恢复靠两阶段启动加 XAUTOCLAIM(或 8.4+ 的 CLAIM),内存靠裁剪但要警惕 PEL 悬挂引用,消费者名靠显式 DELCONSUMER 清理,重复投递靠业务幂等兜底。
Redis 8.2 到 8.8 这一轮迭代正在改变一个长期以来的工程惯性:可靠性逻辑正在从"每个应用自己拼"往"服务端提供原语"迁移。 幂等生产、NACK、单命令 CLAIM,这些能力让 Stream 的"正确用法"越来越接近"开箱即用",但 XACK 的责任、PEL 的监控、死信与 PEL 的取舍,仍然在应用层 。选 Redis Stream 的理由不应该是"它比 List 可靠",而应该是:你愿意为这份可靠性承担 PEL 的运维成本,并且你的延迟和吞吐需求恰好落在它的生态位里。