在上一节的“单消费模式”中,我们虽然解决了消息持久化和防丢失的问题,但它无法应对大型互联网架构中最常见的场景:集群部署(负载均衡) 。如果你的后台服务部署了 3 台服务器,使用单消费模式,这 3 台服务器会收到一模一样的全量订单,导致数据库被重复插入。
为了让多台服务器“协同作战、分摊压力”,并且拥有更强大的容错机制,Redis 借鉴了 Kafka 的设计,在 Stream 中引入了消费者组。
📚 实战篇 29. Redis 消息队列 - Stream 的消费者组模式学习文档
一、 核心概念:什么是消费者组?
消费者组(Consumer Group) 是将多个消费者划分到一个组内,共同去消费同一个 Stream 的消息。
加入消费者组后,我们会获得三大无与伦比的企业级特性:
- 消息分发(负载均衡): 同一个组内的多个消费者,会竞争队列里的消息。一条消息只要被组内的消费者 A 拿走了,消费者 B 就绝对不会再收到这条消息。这就完美实现了集群环境下的任务分摊,极大地加快了写库速度!
- 游标记忆(自动记录进度): 消费者组在 Redis 底层维护了一个游标(
last_delivered_id),记录组内到底消费到哪条消息了。就算所有的消费者都重启了,连上之后依然能接着上次的进度继续消费,不需要我们在 Java 代码里手动维护lastId了。 - 消息确认与重投递机制(PEL 机制 - 最强兜底): 消费者拿走消息后,消息处于“未确认”状态,会被放入一个叫 PEL (Pending Entries List,待处理列表) 的地方。只有消费者成功执行完业务并向 Redis 发送一条 ACK(确认应答) ,这条消息才会真正从 PEL 中移除。如果消费者宕机了,消息会永远躺在 PEL 里,等待被重新拉取消费,绝对保证消息不丢失!
二、 核心命令实操
要使用消费者组,我们需要掌握以下几个核心命令:
1. 创建消费者组:XGROUP CREATE
- 语法:
XGROUP CREATE key groupName ID [MKSTREAM] - 演示:
XGROUP CREATE stream:orders g1 0 MKSTREAM - 解析: 创建一个名为
g1的组来监听stream:orders。0代表从队列的第一条消息开始读(如果是$代表只读未来的新消息)。MKSTREAM代表如果这个队列不存在,就自动创建它。
2. 分组读取消息:XREADGROUP
-
语法:
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...] -
演示(读取最新消息):
XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream:orders > -
解析: 消费者
c1作为g1组的成员去读取消息。- 核心考点:特殊的 ID
>。在这里,>代表读取组内从未被任何消费者读取过的新消息!
- 核心考点:特殊的 ID
3. 确认消息:XACK
- 语法:
XACK key group ID [ID ...] - 演示:
XACK stream:orders g1 1678901234567-0 - 解析: 告诉 Redis,这条消息我已经成功写入 MySQL 了,你可以把它从 PEL (待处理列表) 中彻底删除了。
4. 查看未确认的消息:XPENDING
-
语法:
XPENDING key group(查看 PEL 的总体概况) -
演示(重新读取 PEL 中的消息):
XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream:orders 0- 核心考点:特殊的 ID
0。当 ID 传入0时,它不是去拿新消息,而是去获取 PEL 中自己已经读取过、但还没来得及 ACK 的历史积压消息(比如上次处理到一半宕机了)。
- 核心考点:特殊的 ID
三、 完美的 Java 后台消费逻辑 (企业级标准模板)
结合新消息(>)和 积压消息(0),我们可以写出一个绝对安全、不漏消息、支持集群分发的消费者死循环逻辑。
这也是你在项目中替换 BlockingQueue 的最终代码骨架:
Java
// 在项目启动时运行的后台线程逻辑
while(true) {
try {
// 1. 尝试读取【新消息】 (使用 >)
// XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream:orders >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(...);
// 2. 判断是否拿到新消息
if (list == null || list.isEmpty()) {
continue; // 没拿到,说明没人在抢购,进入下一次阻塞等待
}
// 3. 拿到新消息,解析订单对象,执行数据库操作 (扣库存、写订单)
MapRecord<String, Object, Object> record = list.get(0);
VoucherOrder voucherOrder = parse(record);
handleVoucherOrder(voucherOrder);
// 4. 【最关键的一步】:业务执行成功,发送 ACK 确认!
stringRedisTemplate.opsForStream().acknowledge("stream:orders", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常,消息尚未 ACK,准备进入 PEL 异常处理逻辑", e);
// 走到这里,说明 handleVoucherOrder 报错了,或者断电了没执行 ACK。
// 此时必须进入死循环,不断去处理 PEL 中的积压消息,直到处理成功为止!
while (true) {
try {
// 5. 尝试读取【PEL 积压消息】 (注意:这里不用阻塞,且 ID 使用 0)
// XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream:orders 0
List<MapRecord<String, Object, Object>> pendingList = stringRedisTemplate.opsForStream().read(...);
if (pendingList == null || pendingList.isEmpty()) {
break; // PEL 空了,说明异常消息都处理完了,跳出内层循环,回去接着读新消息
}
// 6. 重新执行业务逻辑
MapRecord<String, Object, Object> pendingRecord = pendingList.get(0);
VoucherOrder pendingOrder = parse(pendingRecord);
handleVoucherOrder(pendingOrder);
// 7. 处理成功,补发 ACK!
stringRedisTemplate.opsForStream().acknowledge("stream:orders", "g1", pendingRecord.getId());
} catch (Exception innerE) {
// 如果处理 PEL 依然报错,就休眠一会儿再重试,防止死循环把 CPU 打满
Thread.sleep(20);
}
}
}
}
学习总结 (秒杀优化大结局)
至此,我们的秒杀下单架构演进彻底通关!我们经历了:
- 同步下单: 纯 MySQL 抗压,响应极慢,极其容易崩溃。
- 异步下单 (BlockingQueue): 引入内存队列,速度起飞,但面临 OOM 和 宕机丢数据 风险。
- 异步下单 (Redis Stream 单消费): 解决了持久化,但不方便集群化部署。
- 终极架构 (Redis Stream 消费者组): 完美实现了 异步削峰 + 集群负载均衡 + 宕机断点续传 + 强一致性 ACK 确认。这就是目前大型互联网公司最标准的轻量级高并发处理方案。