一、前言:为什么消费者组是 Redis Stream 的灵魂?
在高并发系统中,单个消费者处理能力有限,必须支持多实例并行消费 。
而 Redis Stream 的 消费者组(Consumer Group) 正是为此而生!
它实现了:
✅ 组内竞争消费 (负载均衡)
✅ 组间广播消费 (多业务订阅)
✅ 消息状态跟踪 (Pending Entries)
✅ 失败自动重试(通过 Pending 机制)
本文将带你彻底掌握消费者组的核心原理与 Spring Boot 实战技巧。
二、消费者组核心概念图解
┌───────────────┐
│ Stream │
│ (消息日志) │
└───────┬───────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Consumer Group│ │ Consumer Group│ │ Consumer Group│
│ order_group │ │ notify_group │ │ audit_group │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ worker-1 │ │ sms-sender│ │ log-audit │
├───────────┤ ├───────────┤ ├───────────┤
│ worker-2 │ │ email-svr │ │ db-sync │
└───────────┘ └───────────┘ └───────────┘
📌 关键规则:
- 同一组内的消费者竞争消费(一条消息只被一个 consumer 处理)
- 不同组之间独立消费(每条消息被每个组各消费一次)
三、消费者组工作流程(含 ACK 与重试)
1️⃣ 创建消费者组
bash
XGROUP CREATE mystream mygroup 0 MKSTREAM
0表示从第一条消息开始消费MKSTREAM自动创建 Stream(若不存在)
2️⃣ 消费者读取消息(组内)
bash
XREADGROUP GROUP mygroup consumer-1 COUNT 1 STREAMS mystream >
>表示只读未被组内任何消费者读过的新消息- 消息被读取后,进入 Pending Entries List(PEL)
3️⃣ 处理成功 → ACK
bash
XACK mystream mygroup 1698765432123-0
- 消息从 PEL 中移除
- 不再被重复投递
4️⃣ 处理失败?→ 自动重试!
- 若未 ACK,消息一直留在 PEL 中
- 其他消费者可通过
XPENDING发现积压消息,并重新消费:
bash
# 查看组内所有 pending 消息
XPENDING mystream mygroup
# 某消费者主动认领超时消息(实现重试)
XCLAIM mystream mygroup consumer-2 60000 1698765432123-0
✅ 无需死信队列,原生支持失败重试!
四、Spring Boot 实战:多消费者协同处理订单
场景:订单创建后,需异步完成「扣库存」「发通知」「记审计日志」
→ 使用 三个消费者组,每组可部署多个实例
步骤 1:定义通用消息结构
java
public class StreamMessage {
private String messageId; // Redis 消息 ID
private String stream; // Stream 名称
private Map<String, String> payload;
// ...
}
步骤 2:封装消费者组管理器
java
@Component
public class StreamConsumerGroupManager {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 安全创建消费者组(幂等)
*/
public void createGroup(String stream, String group) {
try {
redisTemplate.execute((RedisCallback<Void>) conn -> {
conn.streamCommands().xGroupCreate(
stream.getBytes(),
group.getBytes(),
ReadOffset.from("0-0"),
true
);
return null;
});
} catch (Exception e) {
if (!e.getMessage().contains("BUSYGROUP")) {
throw new RuntimeException("创建消费者组失败", e);
}
}
}
/**
* 从指定组读取消息
*/
public List<MapRecord<String, String, String>> read(
String stream, String group, String consumer, int count) {
return redisTemplate.opsForStream().read(
Consumer.from(group, consumer),
StreamReadOptions.empty().count(count).block(Duration.ofSeconds(2)),
StreamOffset.create(stream, ReadOffset.lastConsumed())
);
}
/**
* ACK 消息
*/
public void ack(String stream, String group, String... ids) {
redisTemplate.opsForStream().acknowledge(group, stream, ids);
}
/**
* 获取 Pending 消息(用于监控/重试)
*/
public PendingMessagesSummary pending(String stream, String group) {
return redisTemplate.opsForStream().pending(stream, Consumer.from(group, "*"));
}
}
步骤 3:实现通用消费者基类
java
public abstract class AbstractStreamConsumer {
@Autowired
protected StreamConsumerGroupManager manager;
protected final String stream;
protected final String group;
protected final String consumerName;
public AbstractStreamConsumer(String stream, String group, String consumerName) {
this.stream = stream;
this.group = group;
this.consumerName = consumerName;
}
@PostConstruct
public void start() {
manager.createGroup(stream, group);
Thread thread = new Thread(this::consumeLoop);
thread.setDaemon(true);
thread.setName("stream-consumer-" + group + "-" + consumerName);
thread.start();
}
private void consumeLoop() {
while (!Thread.currentThread().isInterrupted()) {
try {
List<MapRecord<String, String, String>> records =
manager.read(stream, group, consumerName, 10);
for (MapRecord<String, String, String> record : records) {
try {
handleMessage(record.getValue());
manager.ack(stream, group, record.getId().getValue());
} catch (Exception e) {
log.error("处理消息失败, id={}", record.getId(), e);
// 不 ACK,留待重试
}
}
if (records.isEmpty()) Thread.sleep(500);
} catch (Exception e) {
log.error("消费异常", e);
try { Thread.sleep(1000); } catch (InterruptedException ie) { break; }
}
}
}
protected abstract void handleMessage(Map<String, String> message);
}
步骤 4:具体业务消费者实现
java
// 扣库存消费者组
@Component
public class InventoryConsumer extends AbstractStreamConsumer {
public InventoryConsumer() {
super("order_stream", "inventory_group", "inv-worker-" + UUID.randomUUID().toString().substring(0, 8));
}
@Override
protected void handleMessage(Map<String, String> msg) {
Long orderId = Long.parseLong(msg.get("orderId"));
inventoryService.decreaseStock(orderId);
}
}
// 通知消费者组
@Component
public class NotifyConsumer extends AbstractStreamConsumer {
public NotifyConsumer() {
super("order_stream", "notify_group", "notify-worker-" + ...);
}
@Override
protected void handleMessage(Map<String, String> msg) {
String userId = msg.get("userId");
notificationService.sendOrderSuccess(userId);
}
}
✅ 优势:
- 每个业务逻辑独立部署、独立扩展
- 一个组挂了,不影响其他组
- 组内可水平扩展多个实例
五、消费者组高级技巧
🔧 1. 消费者命名建议
- 使用
服务名-实例ID(如order-service-8081) - 便于监控和排查问题
🔧 2. Pending 消息监控
java
// 定时任务:检查积压
@Scheduled(fixedRate = 30000)
public void checkPending() {
PendingMessagesSummary pending = manager.pending("order_stream", "inventory_group");
if (pending.getTotalPendingMessages() > 100) {
alertService.sendAlert("库存组消息积压: " + pending.getTotalPendingMessages());
}
}
🔧 3. 手动重试超时消息
java
// XCLAIM:将长时间未 ACK 的消息转移给新消费者
List<ClaimRecords> claims = redisTemplate.opsForStream().claim(
"order_stream",
Consumer.from("inventory_group", "new-worker"),
Duration.ofMinutes(5), // 超时5分钟
"1698765432123-0"
);
六、消费者组 vs 传统队列对比
| 特性 | Redis Stream 消费者组 | RabbitMQ Queue | Kafka Consumer Group |
|---|---|---|---|
| 消息持久化 | ✅ | ✅ | ✅ |
| ACK 机制 | ✅ | ✅ | ✅(offset 提交) |
| 多组广播 | ✅(天然支持) | ❌(需 Exchange) | ✅ |
| 消息回溯 | ✅(XRANGE) | ❌ | ✅ |
| 运维复杂度 | ⭐(仅 Redis) | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 适用规模 | 中小项目 | 中大型 | 超大规模 |
📌 结论 :中小项目首选 Redis Stream 消费者组!
七、结语:消费者组,让异步处理更智能
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!