REDIS 5.0+ · STREAM · XADD · XREADGROUP · XACK · PEL · XAUTOCLAIM
一本只能往后写的流水账,每个班组夹着自己的书签
Stream 是 Redis 5.0 加的消息队列类型。生产者只往末尾追加,消息不会因为被读走就消失;每个消费组记着自己读到哪了,组里的每个人还要记着“我领了哪些还没处理完”。这样消费者挂了、重启了,消息都还找得回来。
两个消费组读的是同一本账,各读各的进度,互不影响。真实 ID 前半段是毫秒时间戳,这里缩短了。
Stream只追加的消息日志,读了不删,每条有唯一递增的 ID。
要自己控制长度consumer group一个组夹一个书签,组内分活,组间各拿一份。
一般一个服务一个组consumer组里的一个实例,领到的消息只归它处理。
名字要固定PEL领了但还没 XACK 的消息,都记在这里。
消息不丢的关键Redis 做消息队列的三代
在 Stream 之前,大家用 List 或 Pub/Sub 凑合。它们各缺一块:要么没有确认,要么不保存,要么不能多个服务各收一份。
ListLPUSH + BRPOP
BRPOP queue 0
简单好用,消息会持久化。但一取走就从列表里没了,处理失败就丢;一条消息只能给一个消费者,没法让多个服务各收一份。可以用 BLMOVE 先挪到“处理中”列表来补救。
Pub/SubPUBLISH / SUBSCRIBE
PUBLISH order-events msg
一条消息发给所有在线的订阅者,但不保存:不在线就收不到,也没有确认。订阅者处理太慢,缓冲区超限会被断开(client-output-buffer-limit pubsub 32mb 8mb 60)。
Stream5.0 起
XREADGROUP GROUP g c1 STREAMS orders >
消息持久化、有 ID、能回溯;有消费组和确认机制;多个组各收一份,组内多个实例分着处理。
| 遇到这种情况 | List | Pub/Sub | Stream |
|---|---|---|---|
| 消费者暂时下线 | 消息留在列表里 | 这段时间的消息丢了 | 消息还在,回来接着读 |
| 处理到一半崩溃 | 已经取走,丢了 | 丢了 | 还在 PEL 里,可以重新处理 |
| 两个服务都要收到 | 只能一个收到 | 都收到 | 两个消费组各收一份 |
| 一个服务多个实例分活 | 可以 | 每个实例都收到,重复处理 | 同一组内自动分 |
| 回头再看历史消息 | 取走就没了 | 不保存 | 按 ID 范围读 |
Stream 长什么样
每条消息是一组“字段-值”,外加一个由 Redis 生成的 ID。ID 一定递增,所以消息天然有序,也能按时间范围查。
XADD 时传 *,Redis 自动生成。如果机器时钟往回调了,Redis 会沿用上一个 ID 的时间戳、只加序号,保证 ID 不会变小。
一棵基数树(rax)按 ID 前缀索引,每个节点里是一个 listpack,紧凑地装着一批消息(Redis 第 04 篇)。按 ID 查找和范围读都很快。
XADD orders MAXLEN ~ 100000 * orderId 1001 amount 99 # 追加,并把长度大约控制在 10 万条
→ "1730793600123-0"
XLEN orders # 一共多少条
XRANGE orders - + COUNT 10 # 从头读 10 条;- 最小 + 最大
XREAD COUNT 10 BLOCK 5000 STREAMS orders $ # 不用消费组:阻塞等 5 秒,只要新来的($)
消费组:组内分活,组间各拿一份
每个消费组有一个书签(last_delivered_id),记着组里最后发出去的是哪条。组里谁来要消息,就从书签后面拿下一条给它,并记进它的待确认清单(PEL),直到它 XACK。
同一组的两个实例分着处理,一条消息只给其中一个。
另一个组从头读,所有消息它也都能收到一份,和发货组互不影响。
XGROUP CREATE orders ship $ MKSTREAM # 建组:$ 只要以后的新消息,0 从头开始
XREADGROUP GROUP ship c1 COUNT 10 BLOCK 2000 STREAMS orders > # >:要组里还没发给任何人的新消息
XACK orders ship 1730793600123-0 # 处理完了,从 PEL 里划掉
XREADGROUP GROUP ship c1 STREAMS orders 0 # 0:不要新的,把我 PEL 里没 ACK 的再给我一遍
XPENDING orders ship - + 10 # 看谁手上压着哪些、压了多久、投递过几次
XAUTOCLAIM orders ship c1 60000 0-0 COUNT 10 # 6.2 起:把闲置超过 60 秒的消息认领到 c1 名下
消费组实验台
一个 Stream orders,消费组 g1 里有 c1、c2 两个消费者。生产、读取、确认、让 c2 崩溃、重启、让 c1 认领超时消息,再建一个组 g2 从头读。下面会显示每条消息在各组里的状态、每个人的待确认清单,以及实际执行的命令。
怎么做到消息不丢
Stream 的保证是“至少一次”:只要没 ACK,消息就一直在某个人的待确认清单里,迟早会被再处理一次。代价是可能重复,所以消费逻辑必须幂等。
0 读回没 ACK 的,再用 > 读新的。orders:dlq 再 ACK,别让它无限重试。var options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder().pollTimeout(Duration.ofSeconds(2)).batchSize(10).build();
var container = StreamMessageListenerContainer.create(connectionFactory, options);
container.receive(
Consumer.from("ship", hostName), // 消费者名字固定
StreamOffset.create("orders", ReadOffset.lastConsumed()), // 等于 >
msg -> {
shipService.handle(msg.getValue()); // 必须幂等
redis.opsForStream().acknowledge("orders", "ship", msg.getId());
});
container.start();
// 发消息
RecordId id = redis.opsForStream()
.add("orders", Map.of("orderId", "1001", "amount", "99"));
// 控制长度:大约保留最近 10 万条
redis.opsForStream().trim("orders", 100_000, true);
// 启动时建组;组已存在会报 BUSYGROUP,忽略即可
try {
redis.opsForStream().createGroup("orders", ReadOffset.from("0"), "ship");
} catch (RedisSystemException e) {
if (!String.valueOf(e.getMessage()).contains("BUSYGROUP")) throw e;
}
延迟消息:Stream 不支持,用 ZSet 补
“30 分钟后取消未支付订单”这种需求,Stream 没有现成的办法。常见做法是用 ZSet 当延迟区:分数是到期时间,定时把到期的挪进 Stream 或直接处理。
orders:cancellocal due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 100)
for _, m in ipairs(due) do
redis.call('ZREM', KEYS[1], m)
redis.call('XADD', KEYS[2], '*', 'msg', m)
end
return #due
集群下 KEYS[1] 和 KEYS[2] 要在同一个槽,可以都加上同一个 hash tag。也可以直接用 Redisson 的 RDelayedQueue,它就是这个思路。
和专门的消息队列比
已经有 Redis、消息量不大、能接受极端情况下丢一点,Stream 很省事。否则用专门的消息队列。
| Redis Stream | Kafka | RabbitMQ | RocketMQ | |
|---|---|---|---|---|
| 存在哪 | 内存,靠 RDB / AOF 落盘 | 磁盘日志,多副本 | 磁盘,可配镜像 / 仲裁队列 | 磁盘,多副本 |
| 能堆多少 | 受内存限制 | 很多,按时间或大小保留 | 中等,堆多了变慢 | 很多 |
| 扩展 | 一个 key 只在一个节点,要自己拆多个 key | 分区,天然水平扩展 | 按队列扩 | 按队列扩 |
| 回溯历史 | 按 ID | 按 offset 或时间 | 消费后删除 | 按时间 |
| 死信 / 重试 | 自己实现 | 自己实现 | 死信交换机 | 自带重试和死信 |
| 延迟消息 | 不支持,用 ZSet 补 | 不支持 | 插件或 TTL + 死信 | 支持 |
| 运维成本 | 已有 Redis 就几乎为零 | 高 | 中 | 中 |
| 适合 | 轻量异步任务、站内通知、小流量事件 | 日志、埋点、海量事件流 | 复杂路由、业务消息 | 订单、交易类业务消息 |
Redis 版本,以及 JDK 8 和 JDK 17
Stream 在 5.0 加入后一直在补功能。Java 这边 Spring Data Redis 2.2 起支持 Stream,Boot 2 和 3 用法一样。
Redis 5.0 ~ 6.0 · Boot 2
JDK 8+- 5.0:Stream、消费组、XCLAIM(要先 XPENDING 查出来再一个个认领)
- Spring Data Redis 2.2 起有
StreamOperations和StreamMessageListenerContainer - 配置前缀
spring.redis.*
Redis 6.2 ~ 7 · Boot 3
JDK 17+- 6.2:
XAUTOCLAIM一条命令扫描并认领;XADD 支持MINID按 ID 裁剪、NOMKSTREAM;XPENDING 能按闲置时间过滤 - 7.0:
XINFO GROUPS多了lag,直接看到这个组还有多少没读 - 监听容器可以传自己的 Executor;JDK 21 下可以给它虚拟线程
常踩的坑
Stream 的坑大多出在“没 ACK”和“没裁剪”上。
读过的消息不会自动删。XADD 时带 MAXLEN ~,或定时 XTRIM。但裁剪会删掉还没被慢消费者读到的消息,要盯着各组的 lag。
处理完忘了 XACK,或者用了自动 ACK 又在别处手动 ACK。用 XPENDING 看看谁手上压着消息。
消息卡在它的 PEL 里。其他实例定时用 XAUTOCLAIM 认领闲置太久的消息。
消费者名字用了随机 UUID,重启后是个“新人”,旧名字下的消息没人管。用固定名字,比如主机名或 Pod 名。
至少一次投递,消息可能重复:处理完还没来得及 ACK 就崩了,或者被别人认领后又处理一遍。业务用订单号做幂等。
每次都失败、都不 ACK,又被认领回来。看 XPENDING 里的投递次数,超过阈值就转进死信 Stream 并 ACK。
每次启动都 XGROUP CREATE,组已经存在就报这个错。捕获后忽略,或者先用 XINFO GROUPS 查一下。
Pub/Sub 不保存消息。要可靠送达的用 Stream;Pub/Sub 只适合“丢了也无所谓”的通知,比如本地缓存失效广播。
一个 key 只在一个节点上。按业务拆成多个 Stream,比如 orders:0~orders:7,生产者按订单号取模。
组里多个消费者并行处理,先领的不一定先完成。需要按用户有序,就按用户 ID 把消息分到固定的 Stream,每个 Stream 只让一个消费者处理。