
1. 场景与选型
先说说我为什么折腾这个。之前项目里要处理订单事件,下单之后要发通知、扣库存、更新积分、生成报表。全都同步做?响应时间直接爆炸。直接调一堆服务?耦合度惨不忍睹,下游一挂,主流程跟着遭殃。后来想上消息队列,但团队本来就维护着一套 Redis,为了几个消息再搭一套 Kafka 或者 RabbitMQ,运维成本受不了。后来试了试 Redis 从 5.0 带来的 Stream,发现这玩意儿比想象中能打。
日志采集也是类似场景。业务日志量不小,要异步写到 ES 或者对象存储。日志允许延迟,但绝对不能丢。Redis Stream 有 AOF 和 RDB 持久化,加上消费确认机制,重启之后消息还能捞回来,正好满足。
有人可能问:Kafka 吞吐高、RabbitMQ 可靠,为啥不用?说实话,Kafka 要管 Zookeeper(现在 KRaft 也好点),RabbitMQ 也得单独部署。对于中小规模系统,Redis 本来就是基础设施,Stream 相当于送的。单实例跑个十万级消息/秒没问题,业务量不大,完全够用。你要是每天几千万订单,那还是老老实实上 Kafka,别拿 Redis 硬扛。
2. Redis Stream 的核心概念
Redis Stream 本质上是一个日志结构的数据类型,内部实现不是简单的 listpack,而是用 listpack 存储消息数据,用基数树(rax)来索引消息 ID,这样按 ID 范围查询就非常高效。每条消息有一个自动生成的递增 ID,通常长这样:1700000000000-0。
几个必须搞明白的概念:
- Entry:一条消息,包含 ID 和一堆 field-value 对。
- Consumer Group:消费组。同一个 stream 可以被多个消费组独立消费,每个组内部,一条消息只能被组内的一个消费者拿到。
- PEL(Pending Entries List) :待确认消息列表。消费者读了一条消息,这条消息的 ID 就会进到该消费者的 PEL,直到它显式发
XACK才会被移除。 - last_delivered_id:消费组当前投递到的最后一个消息 ID。
- XREADGROUP :消费组读消息的命令,用
>表示只读新消息,或者指定一个 ID 从 PEL 里读历史消息。 - XACK:确认消息处理完成。
PEL 是可靠性的核心,可以说没有 PEL,Stream 和普通发布订阅就没本质区别了。
3. Spring Boot 接入:封装一个 StreamTemplate
Spring Data Redis 从 2.3 开始支持 Stream,但 API 藏在 opsForStream() 里,用起来比较啰嗦。我习惯自己包一层,把发送、消费、确认、重投这些操作收敛到一个类里,业务代码看着干净些。
3.1 依赖和配置
Spring Boot 2.7+ 直接用 spring-boot-starter-data-redis,里面带了 Spring Data Redis 3.x,Stream 支持已经很完整。
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml 里常规配置就行,单机版:
yaml
spring:
data:
redis:
host: localhost
port: 6379
timeout: 5s
lettuce:
pool:
max-active: 16
max-idle: 8
3.2 StreamTemplate 代码
我直接贴代码,注释我写清楚,省得回头忘了。
java
@Component
public class StreamTemplate {
private final StringRedisTemplate redisTemplate;
public StreamTemplate(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 发送消息,XADD
* @param key stream名称
* @param map 消息字段
* @return 消息ID
*/
public String send(String key, Map<String, String> map) {
return redisTemplate.opsForStream().add(key, map);
}
/**
* 创建消费组,已存在就忽略
* 注意,默认从当前最新位置开始消费,如果要从头消费,
* 可以用 createGroup(key, group, ReadOffset.from("0"))
*/
public void createGroup(String key, String group) {
try {
redisTemplate.opsForStream().createGroup(key, group);
} catch (RedisSystemException e) {
// 组已存在会抛 BUSYGROUP,这个不算错误
if (!e.getMessage().contains("BUSYGROUP")) {
throw e;
}
}
}
/**
* 读新消息,XREADGROUP ... >
* @param key stream名
* @param group 消费组
* @param consumer 消费者名,最好用 主机名+随机数 保证唯一
* @param count 最多读多少条
*/
public List<MapRecord<String, Object, Object>> readNew(String key, String group, String consumer, int count) {
return redisTemplate.opsForStream().read(
Consumer.from(group, consumer),
StreamReadOptions.empty().count(count).block(Duration.ofSeconds(5)),
StreamOffset.create(key, ReadOffset.latest()) // 记住用 latest(),代表 ">"
);
}
/**
* 读PEL里的历史消息(未确认的),XREADGROUP ... 0
* 用于重启后先捞之前的遗留消息
*/
public List<MapRecord<String, Object, Object>> readPending(String key, String group, String consumer, int count) {
return redisTemplate.opsForStream().read(
Consumer.from(group, consumer),
StreamReadOptions.empty().count(count),
StreamOffset.create(key, ReadOffset.from("0"))
);
}
/**
* 确认消息,XACK
*/
public void ack(String key, String group, String... recordIds) {
redisTemplate.opsForStream().acknowledge(key, group, recordIds);
}
/**
* 查看消费组的pending信息
*/
public PendingSummary pendingSummary(String key, String group) {
return redisTemplate.opsForStream().pending(key, group);
}
}
注意几个坑:
ReadOffset.latest()对应的是命令里的>,只读新消息。ReadOffset.from("0")才是从 PEL 里捞。之前看有人用lastConsumed(),那是拿当前消费者处理的最后一条 ID,用来定位,不是读新消息用的。createGroup默认从$(当前最新)开始,如果想从头消费,得用重载版本传ReadOffset.from("0")。- 如果 Redis 是集群版的,Stream 的 key 本身会被分片,消费组的状态跟着 key 走。但跨节点搞消费者组?不行,所以大规模场景得用多个 Stream 分片,或者干脆让位给 Kafka。
3.3 发消息:订单事件
下面是一个订单服务的发布者:
java
@Service
public class OrderEventPublisher {
private final StreamTemplate streamTemplate;
public OrderEventPublisher(StreamTemplate streamTemplate) {
this.streamTemplate = streamTemplate;
}
public void publishOrderCreated(Order order) {
Map<String, String> event = new HashMap<>();
event.put("eventId", UUID.randomUUID().toString());
event.put("type", "ORDER_CREATED");
event.put("orderId", order.getId());
event.put("userId", order.getUserId());
event.put("amount", order.getAmount().toPlainString());
event.put("timestamp", String.valueOf(System.currentTimeMillis()));
String messageId = streamTemplate.send("order:events", event);
log.info("订单事件发布成功,messageId={}", messageId);
}
}
发送的时候不用指定 ID,让 Redis 自己生成,天然有序。
3.4 消费消息:定时拉取 + 手动 ACK
消费者我用了个最简单的方案:定时任务拉取。生产环境你要是追求实时性,可以换成线程池 + while(true) 阻塞读,但逻辑差不多。
java
@Component
public class OrderEventConsumer {
private static final String STREAM_KEY = "order:events";
private static final String GROUP = "order-consume-group";
private final StreamTemplate streamTemplate;
private final OrderEventHandler orderEventHandler;
public OrderEventConsumer(StreamTemplate streamTemplate, OrderEventHandler orderEventHandler) {
this.streamTemplate = streamTemplate;
this.orderEventHandler = orderEventHandler;
}
@PostConstruct
public void init() {
streamTemplate.createGroup(STREAM_KEY, GROUP);
}
@Scheduled(fixedDelay = 1000)
public void consumeNewMessages() {
// 消费者名字必须保持唯一,否则同一实例重启后会被当成不同消费者
String consumer = String.format("%s-%d",
InetAddress.getLocalHost().getHostName(),
UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE);
List<MapRecord<String, Object, Object>> records = streamTemplate.readNew(STREAM_KEY, GROUP, consumer, 10);
for (MapRecord<String, Object, Object> record : records) {
try {
boolean success = orderEventHandler.handle(record.getValue());
if (success) {
streamTemplate.ack(STREAM_KEY, GROUP, record.getId().getValue());
} else {
// 业务处理明确返回失败,说明这消息本身可能有问题,丢死信然后 ack,否则会卡在 PEL 里
sendToDeadLetter(record, "business failure");
streamTemplate.ack(STREAM_KEY, GROUP, record.getId().getValue());
}
} catch (Exception e) {
// 没 ack,消息留在 PEL,等会儿再捞
log.error("消费异常,暂不确认,ID={}", record.getId(), e);
}
}
}
}
关键点:只有 XACK 了,Redis 才认为这条消息处理完成了 。假如消费过程中抛了异常,别 ack,消息就会留在 PEL 里,之后你能通过 XAUTOCLAIM 或者手动读 PEL 重新捞起来。
4. 消费组里的竞争和负载均衡
同一个 Stream 能被多个消费组独立消费,互不干扰。一个组里可以注册多个消费者。那 Redis 怎么分配消息呢?
我觉得**"竞争"这个词比"分区"更贴切**。Kafka 是把 Topic 分成多个 Partition,每个消费者负责几个分区,这是静态的。Redis 消费组里,多个消费者同时执行 XREADGROUP ... > 的时候,Redis 内部会维护一个 last_delivered_id,按顺序把新消息逐个分给当前正在等待的消费者。每个消息只给一个消费者,不会重复。谁有本事谁先抢,不是严格轮询,但整体上是均衡的。
比如酒店预订系统,三个实例都跑着相同的消费者代码,消费者名各不相同,redis 就会把消息分发到不同的消费者,实现扩展。想提高吞吐,增加消费者实例就行。
不过有一点要注意:Redis 的分配不会像 Kafka 那样做 rebalance,如果某个消费者处理特别慢,它也没办法把它的负载分担出去。所以设计消费任务时,尽量保证耗时相近,或者干脆别搞太重的业务逻辑,毕竟 Stream 不是批处理引擎。
5. 消息幂等去重,避不开的坑
正常情况下,一条消息只会在一个消费者的 PEL 里挂着。但是消费者故障转移的时候,消息可能被 XAUTOCLAIM 分配到另一个消费者重新处理。所以业务侧必须做幂等。
最简单的做法是拿消息里的业务 ID(比如 eventId)去重。可以用 Redis 的 Set:
java
public boolean isProcessed(String eventId) {
return redisTemplate.opsForSet().isMember("processed:event:ids", eventId);
}
public void markProcessed(String eventId) {
redisTemplate.opsForSet().add("processed:event:ids", eventId);
}
不过 Redis 的 Set 有丢失风险(不是数据丢失,是可能被淘汰),更稳妥的方式是业务表加唯一索引。比如消费订单事件,插入一张 order_event 表,event_id 设为主键。先插数据,如果主键冲突说明处理过了,直接 ack。
java
@Transactional
public boolean handleOrderCreated(Map<String, String> data) {
String eventId = data.get("eventId");
try {
eventMapper.insertEvent(eventId, data);
} catch (DuplicateKeyException e) {
return true; // 已经处理过,没必要再处理
}
// 真正的业务逻辑...
return true;
}
我的建议是:Redis Set 做前置过滤,数据库唯一键兜底,两者结合效果最好。如果你只在乎不丢,不在乎重复,那上面都不用了。
6. 可靠性:PEL、XAUTOCLAIM 和死信
6.1 PEL 到底干了啥?
消费者读取消息后,消息 ID 会进入消费者的 PEL。PEL 保存在 Redis 里,所以消费者即使宕机了,消息也不会丢,只是没人处理。要恢复就得靠重投。
6.2 消费者重启后先处理 PEL
假设消费者 A 处理消息到一半,突然宕机了。重启后它自己内存里的东西全没了,但 Redis 里 PEL 还在。这时候可以先用 readPending 方法把 ID=0 开始的历史消息读出来,重新处理。注意,这要求业务处理必须幂等------之前可能已经处理一半了,所以重来不能出事。
6.3 XAUTOCLAIM:自动转移超时消息
如果消费者 A 一直没起来,PEL 里的消息就得有人接管。Redis 6.2 开始提供了 XAUTOCLAIM 命令,可以自动把空闲时间超过指定阈值的消息从别的消费者名下抢过来,同时递增投递次数。Spring Data Redis 提供了封装:
java
public List<MapRecord<String, Object, Object>> autoClaim(String key, String group, String consumer, Duration minIdleTime, long count) {
return redisTemplate.opsForStream().autoClaim(
key, group, consumer,
ClaimOptions.minIdle(minIdleTime),
StreamReadOptions.empty().count(count)
).getRecords();
}
定时任务认领:
java
@Scheduled(cron = "0 0/1 * * * ?")
public void claimTimeoutMessages() {
String consumer = "claim-worker-" + instanceId;
List<MapRecord<String, Object, Object>> records = streamTemplate.autoClaim(STREAM_KEY, GROUP, consumer, Duration.ofMinutes(5), 100);
for (MapRecord<String, Object, Object> record : records) {
// 照常处理,然后 ACK
}
}
这里要注意 autoClaim 返回的记录里可能会有投递次数信息(取决于版本),你可以拿出来判断是不是该扔进死信队列了。
6.4 死信队列
死信队列(DLQ)是个老话题。Redis Stream 本身没有内置死信概念,需要自己建一个 Stream 专门接收处理失败的消息。不过怎么判断一条消息是否该死信?我一般维护一个计数,比如用 Redis 的 hash:
java
String attemptKey = "attempt:" + record.getId().getValue();
Long attempt = redisTemplate.opsForValue().increment(attemptKey);
if (attempt > 5) {
sendToDeadLetter(record);
streamTemplate.ack(STREAM_KEY, GROUP, record.getId().getValue());
redisTemplate.delete(attemptKey);
}
投递次数上限自己定,超过就 ack 掉,然后丢进 order:events:dead-letter。这样既不会无限重试,也不会阻塞组里的新消息。
7. 消费者故障和集群下的可靠性
7.1 消费者实例崩溃
就一个消费者崩溃了,只要其他消费者还在,那 PEL 里的消息依赖 XAUTOCLAIM 就能被接管。这里有个前提:业务处理必须无状态、幂等,否则可能会出岔子。
7.2 Redis 主从切换
Redis Sentinel 或者 Cluster 模式,Stream 数据会随着主从复制到从节点上,消费组的状态也会同步。主节点宕机后,从节点晋升,Stream 数据不会丢------前提是你开了 AOF,且配置合理。我一般建议 appendfsync everysec,最多丢一秒,可以接受;如果非要严格不丢,用 always,但性能会打折。另外客户端这边 Spring Data Redis 的 Lettuce 会自动感知拓扑变化,你只需要在操作时做好异常重试就行。
7.3 Redis Cluster 下的 Stream
Redis Cluster 里一个 Stream 对应一个 key,会分片到某个 slot。也就是说,一个 Stream 的所有消息都在同一个节点上,没法像 Kafka 那样分区到多台机器。想要横向扩展,只能手动建多个 Stream,比如 order:events:0、order:events:1,然后客户端按订单号 hash 路由。这样搞确实麻烦,所以如果有数据量大到单节点撑不住,建议直接换 Kafka,别在 Redis 上死磕。
7.4 优雅停机
消费者服务要重启时,最好不要直接强杀。在 @PreDestroy 里把运行的标志位设为 false,让消费循环自然退出。此时有些消息可能已经被拉下来了但还没处理完,别 ack,留在 PEL 里,别的消费者会通过 XAUTOCLAIM 把它们接走。这样基本可以做到"零丢失"。
8. 跟 RabbitMQ、Kafka 的选型对比
网上有太多表格,我不爱做表,说点实在的。
Redis Stream 就是轻量级的选择,运维成本最低,性能也够用。缺点是没有死信队列、延迟队列这些内置组件,都得自己造轮子,而且消息是存在内存里的,虽然可以持久化,但内存成本高。适合消息量不大(十万级/秒以下)、团队不想维护额外中间件的场景。
RabbitMQ 是老牌专业选手,路由灵活,死信、延迟队列都是现成的,可靠性很高。不过吞吐量受限于 Erlang VM,一般也就万级/秒,做企业级业务集成很合适。运维要管 broker、vhost,还有镜像队列,比 Redis 麻烦很多。
Kafka 就不用多说了,百万级/秒的吞吐,分区顺序,消息回溯,流式处理,都是它的强项。但运维成本高,要管理 Zookeeper 或者 KRaft,还得调参数,一般不打算上大数据场景根本没必要惹它。
用一句话概括:如果你手里已经有 Redis,而且业务消息量不大,那就用 Stream 顶上去,别额外造出一个要运维的"动物园"来。
9. 监控和容量那些事
上了 Stream 之后,你得盯着点它的健康状态,线上出了积压可不是闹着玩的。
- 消息积压 :
XLEN看看 Stream 里有多少条消息。如果积压数量一直上涨,说明消费者来不及处理。 - 消费组状态 :用
XINFO GROUPS看每个消费组的未确认消息数(就是 PEL 里的数量),这个数字越大,说明消费者越不给力。 - 消费者状态 :
XINFO CONSUMERS可以查看每个消费者 PEL 大小和空闲时间,空闲时间过长说明这个消费者可能挂了。 - Redis 本身 :关注内存使用、AOF rewrite 频率,别让 Redis 因为内存淘汰把消息给删了(
maxmemory-policy建议设置成noeviction,宁可直接报错也不能丢消息)。
如果你接了 Prometheus,redis_exporter 会把 Stream 这些指标都导出来,自己写几条告警规则。比如:
yaml
- alert: RedisStreamMsgBacklog
expr: redis_stream_consumers_lag > 1000
for: 5m
容量估算,一条消息按 200 字节算,每天 1000 万条就是 2GB 左右,加上 PEL 的元数据,大概 3GB。如果 Redis 内存紧张,先想想业务上能不能剪裁消息格式,或者是不是该换 Kafka 了。
10. 收个尾
Redis Stream 这东西,本质上是用 Redis 的数据结构模拟了一套可靠消息机制。它不像 Kafka 那样宏大,也不像 RabbitMQ 那样"专业",但在很多业务场景下它就是最合适的那个------前提是你已经会 Redis,并且不想为了几个队列再增加一套运维负担。
集成到 Spring Boot 也不难,自己封装一个 StreamTemplate 就能用得很顺手。但你要记住三个原则:第一,业务必须幂等 ,别拿"不会重复"当不做幂等的借口;第二,持久化要开 AOF ,别拿"Redis 很快"来对抗宕机;第三,盯住 PEL 的积压和消费者的空闲时间,别等问题爆发了才想起来看监控。
代码我就不贴完整工程了,上面这些足够你起步。要是真用起来遇到什么玄学问题,欢迎评论区聊聊。
