Stream 出现后,很多团队会问:是不是可以不用 MQ 了?这个问题本身就有点危险。Stream 确实比 List 更像消息系统,但它仍然是 Redis 里的内存数据结构——一个会随消息条数线性吃内存的 key。
把 Stream 用明白,关键不在命令多不多,而在分清三层东西:存储(消息放在 rax/listpack,按 ID 范围读)、消费状态(每个消费组有自己的 PEL)、治理(裁剪、接管、幂等)。混在一起谈,就一定会踩坑。
先把机制边界说清楚
这一篇讨论 Stream 的存储与消费组模型,不讨论跨数据中心消息系统。只要消息堆积、重放和保留策略成为核心诉求,就必须先评估 Redis 的内存上限和持久化边界,而不是先写 XADD。Stream 解决的是 List 没有的「ack + 消费组 + 范围重放」,不是 Kafka 的分区与水平扩展。
整体路径
上面这张图先把主线铺开:消息存在 rax/listpack(节点大小受 stream-node-max-bytes 默认 4096 字节、stream-node-max-entries 默认 100 条控制),消费状态存在 PEL。所以 Stream 既不是简单链表,也不是无限增长的日志——它的存储形态和裁剪策略决定了内存占用。
Stream 和 List 队列的本质差在哪里
很多人把「List + BRPOP」当成轻量队列,再以为 Stream 只是它的升级版。差别其实落在三点上,每一点都对应一类线上事故。
第一,持久化与可重放。List 一旦 RPOP,元素就从内存里消失了,消费者宕机就丢;Stream 是追加日志,消息进去了就一直在,直到你显式裁剪,所以消费者可以按 ID 重放历史。第二,消费组。List 要靠业务自己写「谁在消费、消费到哪、谁失败了」;Stream 把这套状态收进消费组的 PEL 里,XREADGROUP 自动记账。第三,ack 语义。List 没有 ack,读走即删;Stream 必须显式 XACK,没 ack 的消息一直挂账。
一句话:List 是「拿走就拿走」,Stream 是「拿了要回执,没回执就挂账」。
PEL:消息到底确认了没有
理解 Stream 的可靠性,核心是理解 PEL(Pending Entries List)。每个消费组维护一张 PEL,记录所有「已通过 XREADGROUP 投递、但还没被 XACK」的消息。
链路是这样的:XREADGROUP GROUP g c COUNT n > 里的 > 是关键——它表示「只取本组还没投递过的新消息」。取走的同时,这些消息的 ID 被写进 PEL,并记下是投给消费者 c 的、投递时间是多少。消费者处理完,调 XACK 把对应 ID 从 PEL 里删掉,这条消息才算「确认完成」。
坑就在这里:只要没 ack,消息就永远留在 PEL。消费者进程崩了、网络断了、处理抛异常忘了 ack——这些消息会一直挂账,而且 XREADGROUP > 不会再发它们,因为 > 只发新消息。于是线上常见的现象是:积压在涨、新消息能消费、但 PEL 里的存量消息像黑洞一样没人理。
治理靠两条命令:XPENDING 看挂账清单(谁欠的、欠多久),XAUTOCLAIM 按 idle 时间用游标扫描 PEL,并把挂账消息接管给活着的消费者。XCLAIM 不是旧名,而是 Redis 5.0 起按指定 ID 主动转移 ownership;Redis 6.2 之前没有 XAUTOCLAIM 时,通常要用 XPENDING + XCLAIM 自己扫描。这本质上是故障转移——不是 Stream 帮你自动迁移,而是你主动按 idle 阈值把「看起来死了」的消费者手里的活儿转走。所以消息处理必须幂等:接管必然带来重复投递。
XREADGROUP 的两种读取姿势:> 和历史 ID
XREADGROUP 在消费组里最关键的是两类 ID:> 表示只取还没有投递过的新消息;具体历史 ID(常见写 0)表示读取本消费者名下已经进入 PEL 的挂账消息。$ 不属于普通 XREADGROUP 的读取姿势,它用于 XREAD 或创建消费组时定位到 Stream 末尾。
>(只取新消息):消费组的舒适区,只发还没进过 PEL 的消息。日常拉取用它。0(从 PEL 头开始):本消费者名下挂账的消息。常用于启动时先把上次没 ack 完的活儿干完,再继续拉新的。注意它只认本消费者名下的 PEL,别的消费者欠的不算。$(只看最新):用在XREAD(非 group)或创建消费组时定位末尾,表示「我不关心历史,只从现在往后」。普通XREADGROUP里不要把$当成消费组读取状态。
把这两类读取入口理顺,你就明白为什么「重启消费者后消息不消费了」——多半是 PEL 里挂着旧消息,而你的代码只调了 >,新消息不来它就空转。正确做法是先 XREADGROUP ... 0 把挂账清完,再切回 >。
裁剪:MAXLEN、~ 与 Stream 的内存账单
Stream 是 append-only,不裁剪就会一直涨。XADD 支持 MAXLEN,裁剪分两种写法,成本差很多:
XADD key MAXLEN 10000 * ...:精确裁剪,Redis 要遍历找到精确第 10000 条的位置再删,慢。XADD key MAXLEN ~ 10000 * ...:近似裁剪,~让 Redis 删到「大约」10000 条附近就停,可能多留几百条,但快得多。生产基本都用~。
裁剪成本的本质是:Stream 底层是 rax 串 listpack,删旧消息要拆宏节点。条数越大、节点越多,精确裁剪越贵。开 AOF 的实例上,AOF/复制传播的是确定的裁剪语义;风险不在“写入大量删除事件”,而在命令执行和后续重放都要承担更重的 trim 成本。
Stream 的内存随 ID 单调增长、不随读取释放。一条消息哪怕被所有消费组 ack 了,也仍占内存,直到被裁剪掉。这一点和很多人「ack 完就该释放」的直觉相反。
为什么 Stream 不适合海量消息
Stream 的硬伤是单 key。一个 Stream 就是一个 key,所有读写都在它身上,没法像 Kafka 那样靠分区把流量摊到多台机器。单 key 意味着单线程处理、单点内存、单点持久化压力。消息量到百万级/秒、或要求跨集群扩展时,Stream 必然撞墙。
业务侧的常见分桶做法:按业务维度拆 key(stream:order:pay、stream:order:ship),按时间拆(stream:events:20260623)。但这只是缓解,不是解决——分桶后你还是得自己管「这条消息该去哪个 key」,而 Kafka 的分区是中间件替你做的。
典型问题:用机制化例子排查
- 每个 Stream 必须定义裁剪策略,
MAXLEN ~ N或MINID = <id>,别让它无限长。 - 监控
XPENDING的总量和最大 idle,idle 涨说明消费者处理慢或挂了。 - 消费者重启先
XREADGROUP ... 0清 PEL,再切>,避免挂账堆积。 - 所有处理逻辑幂等,
XAUTOCLAIM接管会造成重复投递。 - 关键业务消息要长期保留、严格顺序、跨机房治理时,用专业 MQ,别让 Stream 硬撑。
收束:一句判断
Stream 是 Redis 的日志型数据结构,解决了 List 没有的 ack 和消费组,但它仍然是单 key 的内存结构,不是分布式消息中间件。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 Redis」,欢迎关注公众号 「十三Tech」。后续会继续按数据结构、底层机制、持久化、高可用和实战排查这条线更新。

