消息中间件进阶:RocketMQ 消息的消费(三)
系列第三篇:存储与原理篇(下)
你好,我们又见面了。
前两篇我们聊了消息怎么存(CommitLog + ConsumeQueue)、怎么发(三种发送方式 + 负载均衡 + 重试机制)。现在,消息已经到了 Broker 的“仓库”里,安安静静地躺在 CommitLog 里等着被处理。
接下来就是最关键的一环——消息是怎么被消费的?
你可能已经听过“推模式”和“拉模式”这两个词,也可能听说过 Rebalance、消费位点、重试队列这些概念。今天我们就从 Consumer 启动的那一刻开始,把消息消费的每个环节彻底讲透。配合流程图,咱们把“消费”这回事看得明明白白。
七、消息的消费
Consumer 启动流程与路由获取
Consumer 的启动和 Producer 非常相似,但多了几个消费特有的步骤:
和 Producer 启动的关键区别:
- 订阅 Topic:Consumer 启动时需要明确告诉 Broker“我要消费哪个 Topic”
- 注册监听器:Consumer 需要注册
MessageListener,处理消息的业务逻辑就写在这里面 - 触发 Rebalance:这是消费特有的环节——Consumer 启动后会立即进行一次 Rebalance,确定自己负责消费哪些 Queue
- 消费位点:Consumer 需要知道从哪个位置开始消费(从最新的开始?还是从头开始?还是从上次消费的进度继续?)
Pull 模式与 Push 模式的本质区别
这是初学者最容易混淆的概念。表面上 RocketMQ 支持两种消费模式——Pull 和 Push,但在底层实现上,它们都是 Pull 模式。
本质区别:谁来控制拉取频率?
| 模式 | 控制权 | 实现方式 | 用户感知 |
|---|---|---|---|
| Pull | 用户自己控制 | 用户主动调用 pull() 方法 |
用户完全掌控拉取时机和批量大小 |
| Push | Broker“推” | 底层依然是不断拉取,但被封装成“推送”的感觉 | 用户只需实现监听器,消息来了就被调用 |
Push 模式的底层实现:
RocketMQ 的 Push 模式(DefaultMQPushConsumer)在底层其实是一个不断循环的 Pull 线程:
- Consumer 启动后,内部有一个
PullMessageService线程 - 这个线程不断从 Broker 拉取消息(每次拉取一批)
- 拉到的消息提交到线程池处理
- 处理完成后,继续拉取下一批
所以本质上,Push 模式 = 自动化的 Pull + 多线程消费。
长轮询机制的原理与实现
既然底层是 Pull,那 Broker 怎么做到“消息一来就推送给消费者”呢?答案就是长轮询(Long Polling)。
什么是长轮询?
传统的 Pull 是消费者定时去 Broker 问“有新消息吗?”——如果一直没有消息,每次请求都是空跑,浪费资源。
长轮询的优化是:如果 Broker 没有新消息,请求不立即返回,而是“挂起”等待一段时间(默认 15 秒)。如果在这段时间内有新消息到达,立即返回;如果超时了还没消息,返回空响应。
长轮询的好处:
- 实时性:消息一到达,挂起的请求立即被唤醒,Consumer 几乎实时收到消息
- 节省资源:相比短轮询(每秒一次),长轮询大大减少了空请求的数量
- 保护 Broker:通过超时机制避免请求无限挂起导致资源泄漏
💡 小贴士:长轮询的超时时间由
pullRequestHoldService控制,默认 15 秒。这个值不是越大越好——太大会占用 Broker 的线程资源,太小又会增加空请求的频率。
消费者组(Consumer Group)的概念与作用
什么是消费者组?
消费者组是一组具有相同消费逻辑的 Consumer 实例的集合。它们共同消费同一个 Topic 的消息。
消费者组的核心作用:
- 水平扩展:增加 Consumer 实例,就能线性提升消费吞吐量
- 消费进度管理:同一个 Group 共享一份消费进度(Offset),任意实例宕机后,其他实例可以接替继续消费
- 负载均衡:Group 内的实例自动分配 Queue,协同工作
💡 核心约束:同一个消费者组内的 Consumer,必须订阅相同的 Topic 和 Tag。如果订阅不同,会导致 Rebalance 混乱和消息丢失。
集群消费与广播消费
RocketMQ 提供了两种消费模式,它们的区别非常核心:
集群消费(Clustering)
- 一个 Topic 下的消息,只被同一个 Group 中的一个 Consumer 消费一次
- 多个 Consumer 实例之间负载均衡分配 Queue
- 这是 RocketMQ 的默认模式
广播消费(Broadcasting)
- 一个 Topic 下的消息,被同一个 Group 中的每个 Consumer 都消费一次
- 每个 Consumer 独立消费所有 Queue 的消息
- 每个 Consumer 独立管理自己的 Offset
| 对比维度 | 集群消费 | 广播消费 |
|---|---|---|
| 消息投递 | 每条消息只投递给一个 Consumer | 每条消息投递给所有 Consumer |
| Offset 管理 | Group 共享一份 | 每个实例各自独立 |
| 适用场景 | 负载均衡、水平扩展 | 每个节点都需要全量数据(如缓存刷新) |
| 是否默认 | ✅ 默认 | ❌ 需显式配置 |
Rebalance 机制的触发时机与算法实现
Rebalance 是什么?
Rebalance(重平衡)是指消费者组内的 Queue 分配发生变化时,重新分配 Queue 给各个 Consumer 实例的过程。
触发时机:
具体的触发场景包括:
- Consumer 启动或关闭:Group 内成员变化,需要重新分配
- Topic 的 Queue 数量变化:新增或减少 Queue,需要重新分配
- Consumer 订阅信息变化:订阅的 Topic 或 Tag 变化
- Broker 变化:某个 Broker 宕机或新增,影响 Queue 的可用性
Rebalance 的触发频率:Consumer 内部有一个定时任务,每隔 20 秒 检查一次是否需要 Rebalance。
Rebalance 的算法:
RocketMQ 支持多种队列分配策略,通过 AllocateMessageQueueStrategy 接口来定义:
Rebalance 过程中的队列分配策略详解
我们来详细看两种主要策略:
1. 平均分配策略(默认)
AllocateMessageQueueAveragely——这是 RocketMQ 的默认策略,核心思想是尽可能让每个 Consumer 分配的 Queue 数量相等。
算法逻辑:
平均分配算法(伪代码):
1. 将所有 Queue 排序,将所有 Consumer 排序
2. 计算每个 Consumer 的平均 Queue 数:avg = queueCount / consumerCount
3. 计算余数:mod = queueCount % consumerCount
4. 前 mod 个 Consumer 各多分 1 个 Queue
5. 按顺序分配
| Consumer | Queue 分配 | 数量 |
|---|---|---|
| C1 | Q0, Q1, Q2 | 3 |
| C2 | Q3, Q4 | 2 |
| C3 | Q5 | 1 |
(假设 6 个 Queue,3 个 Consumer)
2. 环形分配策略
AllocateMessageQueueAveragelyByCircle——按“发牌”的方式轮流分配。
环形分配算法(伪代码):
1. 将所有 Queue 和 Consumer 排序
2. 从第 1 个 Consumer 开始,依次分配第 1, 2, 3... 个 Queue
3. 到达最后一个 Consumer 后,回到第 1 个继续
| Consumer | Queue 分配 | 数量 |
|---|---|---|
| C1 | Q0, Q3 | 2 |
| C2 | Q1, Q4 | 2 |
| C3 | Q2, Q5 | 2 |
(假设 6 个 Queue,3 个 Consumer——环形分配在均匀分布的场景下表现更好)
Rebalance 过程中的消息重复与位点重置风险
这是 Rebalance 最关键、也最容易出问题的地方。Rebalance 过程中,消息可能被重复消费,也可能被跳过。
为什么会发生重复?
因为消费进度(Offset)的提交是异步的。Consumer 拉取了一批消息,可能在业务处理完之前,Rebalance 就触发了,Queue 被分配给了别的 Consumer。新的 Consumer 从上次提交的 Offset 开始消费,就会重复消费那些已经拉取但还没提交进度的消息。
如何规避风险?
- 幂等消费:这是最基本的防御手段——业务层保证同一消息被多次消费时,效果和消费一次相同
- 合理设置超时:避免消费超时导致 Offset 提交延迟
- 使用同步提交:在关键场景下,可以配置
commitOffset为同步模式 - 监控 Rebalance 频率:频繁 Rebalance 是系统不稳定的信号
💡 小贴士:RocketMQ 的设计哲学是 “至少一次(At Least Once)” 。它宁愿消息被重复消费,也不愿消息丢失。所以业务方一定要做好幂等处理。
消费位点(Offset)的管理与存储
什么是 Offset?
Offset 是 Consumer 消费进度的“书签”,记录了当前消费到了哪个位置。
在 RocketMQ 中,Offset 指的是 ConsumeQueue 中的逻辑偏移量(第几条消息),而不是 CommitLog 中的物理偏移量。
Offset 的数值含义:
offset = 0:从头开始消费offset = N:从第 N 条消息开始消费offset = -1(实际是-1的补码):从最新消息开始消费
Offset 的存储位置演变
RocketMQ 中 Offset 的存储位置经历了从“本地”到“远端”的演变。
早期(4.x 及之前):
Offset 存储在 Broker 端(默认是 ~/store/config/consumerOffset.json),通过 定期持久化 来保存。
5.x 的变化:
RocketMQ 5.x 引入了 Pop 消费模型,Offset 的管理更加灵活,支持将 Offset 存储到 独立的存储系统(如 Raft 集群),实现了 Offset 的高可用管理。
集群消费 vs 广播消费的 Offset 存储:
| 消费模式 | Offset 存储位置 | 说明 |
|---|---|---|
| 集群消费 | Broker 端 | Group 共享一份 Offset |
| 广播消费 | 客户端本地 | 每个实例独立存储 |
消息拉取的流程与线程模型
Consumer 的消息拉取流程是一个精心设计的多线程协作模型:
关键线程说明:
| 线程/组件 | 职责 |
|---|---|
| PullMessageService | 单线程,负责从 Broker 拉取消息,将消息提交到消费线程池 |
| RebalanceService | 单线程,定时检查并触发 Rebalance |
| Consumer 线程池 | 多线程,负责执行业务监听器,处理消息 |
| Offset 提交线程 | 定时或定频提交消费进度到 Broker |
消费线程池的配置:
// 设置消费线程池大小(默认 20)
consumer.setConsumeThreadMin(10);
consumer.setConsumeThreadMax(40);
- 线程池大小决定了消息消费的并行度
- 如果业务处理耗时较长(如调用第三方接口),需要适当增大线程池
- 但线程池过大会增加上下文切换开销,需要根据实际压测确定
并发消费与顺序消费的实现与区别
这是业务开发中最常遇到的选择:我的消息需要按顺序处理吗?
并发消费(Concurrently)
- 多个消息并行处理,不保证顺序
- 同一个 Queue 的消息,在不同的线程中并行处理
- 吞吐量最高,适合大多数业务场景
顺序消费(Orderly)
- 同一个 Queue 的消息串行处理,保证顺序
- 同一个 Queue 在同一时刻只能被一个线程处理
- 吞吐量低于并发消费,适合需要保证顺序的业务(如订单状态流转)
实现对比:
| 对比维度 | 并发消费 | 顺序消费 |
|---|---|---|
| 实现类 | MessageListenerConcurrently |
MessageListenerOrderly |
| 线程模型 | 多线程并行 | 单线程串行(每个 Queue) |
| 吞吐量 | 高 | 中 |
| 消息顺序 | 不保证 | 保证同 Queue 有序 |
| 适用场景 | 大部分业务 | 订单状态机、库存扣减等 |
顺序消息的实现原理
RocketMQ 的顺序消息依赖于 MessageQueue 级别的顺序保证。
顺序消息的三个关键点:
- 生产者端:相同业务 Key 的消息,通过一致性哈希或取模,保证落到同一个 Queue
- Broker 端:同一个 Queue 内的消息,在 CommitLog 中是顺序写入的(所有消息都是顺序写入,天然有序)
- 消费者端:使用
MessageListenerOrderly,同一个 Queue 串行消费,保证处理顺序
⚠️ 注意:顺序消息的吞吐量受限,因为每个 Queue 只能被一个线程串行处理。如果需要更高的吞吐量,可以增加 Queue 的数量来提升并行度(Queue 数越多,并行消费的线程越多)。
消息过滤的两种方式:Tag 与 SQL92
Consumer 在订阅 Topic 时,可以指定过滤条件,只消费感兴趣的消息。
Tag 过滤:
// 订阅时指定 Tag:只消费 order_create 和 order_pay
consumer.subscribe("order_topic", "order_create || order_pay");
Tag 过滤在 Broker 侧基于 哈希码 快速过滤,性能极高。
SQL92 过滤:
// 基于消息属性进行复杂过滤
consumer.subscribe("order_topic",
MessageSelector.bySql("amount > 100 AND region = 'SH'"));
最佳实践:能用 Tag 就不用 SQL92。
消费进度(Consumer Offset)的提交时机
Offset 提交的时机直接影响了消费的可靠性和性能。
RocketMQ 的 Offset 提交策略:
并发消费的 Offset 提交逻辑:
- Consumer 拉取一批消息(比如 32 条),提交到线程池并行处理
- 只有当这一批消息全部处理完成后,才提交 Offset
- 提交的位置是这批消息中最大的 Offset + 1
这意味着:如果 32 条消息中有 1 条处理失败,整批消息的 Offset 都不会提交。失败的那条消息会被重试,但已经成功处理的 31 条消息可能会被重复消费(因为下次拉取会从这 32 条的起始位置开始)。
这就是 RocketMQ “至少一次”语义的体现——宁可重复,不可丢失。
提交方式配置:
| 方式 | 说明 | 配置 |
|---|---|---|
| 同步提交 | 提交 Offset 后等待 Broker 确认 | autoCommit = false + 手动提交 |
| 异步提交 | 不等待确认,性能更好 | 默认方式 |
消息重试机制与重试队列
消息消费失败怎么办?RocketMQ 有完善的重试机制。
重试机制的核心设计:
- 重试队列:消息消费失败后,不直接丢弃,而是发送到
%RETRY%{ConsumerGroup}这个特殊的 Topic - 延迟重试:重试不是立即的,而是逐次增加延迟时间(1 秒 → 5 秒 → 10 秒 → 30 秒 → ...)
- 最大重试次数:默认 16 次(并发消费)或 10 次(顺序消费),可以通过
consumer.setMaxReconsumeTimes()配置
延迟递进的规律:
1s → 5s → 10s → 30s → 1m → 2m → 3m → 4m → 5m → 6m → 7m → 8m → 9m → 10m → 20m → 30m → 1h → 2h
这个延迟策略给系统足够的恢复时间(比如数据库重启、网络恢复等),避免无效的频繁重试。
如何判断失败需要重试?
- 如果
MessageListener返回ConsumeConcurrentlyStatus.RECONSUME_LATER,表示消费失败,需要重试 - 如果抛出异常,也视为消费失败
- 只有返回
CONSUME_SUCCESS才表示成功
死信队列(DLQ)的产生与处理
什么是死信队列(DLQ)?
当消息重试次数超过最大重试次数后,仍然消费失败,这条消息就会被投递到 死信队列(Dead Letter Queue)。
死信队列的命名规范:%DLQ%{ConsumerGroup}
死信队列的特点:
- 不再被自动消费:死信队列中的消息不会被 Consumer 自动拉取
- 需要人工处理:需要通过运维工具或 API 手动处理
- 包含完整信息:死信消息保留了原始消息的所有信息,包括原始 Topic、Tag 和消息体
- 有生命周期:死信消息也有过期时间,默认 72 小时后会被清理
死信处理的最佳实践:
- 监控告警:对 DLQ 中的消息数量设置监控,一旦有消息进入 DLQ,立即告警
- 人工补偿:从 DLQ 中拉取消息,分析失败原因,修复业务逻辑后重新发送
- 定期清理:设置合理的 DLQ 消息保留时间,避免堆积占用存储
// 从死信队列中重新消费
public void consumeFromDLQ() throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dlq_consumer");
consumer.setNamesrvAddr("localhost:9876");
// 订阅死信队列
consumer.subscribe("%DLQ%order_consumer_group", "*");
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
// 获取原始消息信息
String originalTopic = msgs.get(0).getProperty("RETRY_TOPIC");
// 人工处理后重新发送
resendMessage(msgs.get(0));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
}
消息积压的原因与处理方案
消息积压是 RocketMQ 生产中常见的问题,指的是 Consumer 处理消息的速度赶不上 Producer 生产消息的速度。
积压的常见原因:
| 原因 | 说明 | 解决方案 |
|---|---|---|
| 消费速度慢 | 业务处理逻辑复杂,单条消息处理时间长 | 优化业务逻辑,增加 Consumer 实例 |
| Consumer 数量不足 | Queue 数量远大于 Consumer 数量 | 增加 Consumer 实例(不超过 Queue 数量) |
| 下游依赖故障 | 消费时依赖的数据库/API 响应慢或不可用 | 快速恢复下游服务,或用异步处理 |
| 消息体过大 | 大消息导致网络传输和解析耗时 | 压缩消息体,或将大内容存 OSS |
| 顺序消费 | 顺序消费串行处理,吞吐量受限 | 考虑是否可以改为并发消费 |
积压处理方案:
积压处理的黄金原则:
- 先止损:快速扩容 Consumer,让消费速度先追上来
- 再定位:分析根本原因,防止再次发生
- 临时 vs 长期:扩容是临时手段,优化业务逻辑和架构才是根本
💡 小贴士:RocketMQ 提供了
getConsumerRunningInfo()和viewMessage()等 API,可以帮助快速定位积压原因。另外,RocketMQ Dashboard 提供了可视化的积压监控界面,建议在生产环境部署。
小结
这篇我们完整走通了消息消费的全链路,通过 9 张流程图,搞清楚了:
- Consumer 启动时如何订阅 Topic 并触发首次 Rebalance
- Pull 与 Push 的本质区别——底层都是 Pull,Push 是自动化的 Pull
- 长轮询机制如何实现“准实时”的消息推送
- 消费者组的概念和集群消费 vs 广播消费的区别
- Rebalance 的触发时机、分配算法以及在过程中的风险(消息重复与位点重置)
- Offset 的管理与存储演变(本地 → 远端)
- 拉取流程与线程模型——PullMessageService + 消费线程池的协作
- 并发消费 vs 顺序消费的选择和实现原理
- 顺序消息如何依赖 MessageQueue 级别的顺序保证
- 消息过滤的 Tag 和 SQL92 两种方式
- Offset 提交时机如何影响“至少一次”语义
- 消息重试机制和死信队列的产生与处理
- 消息积压的原因分析与处理方案
现在,你对 RocketMQ 消息消费的每个环节应该都了如指掌了。再加上前两篇的存储和发送机制,你已经形成了一个完整的 RocketMQ 知识闭环。下一篇,我们将进入事务消息篇,看看 RocketMQ 在本地事务+消息一致性上的表现。我们下期见!
系列文章:
- 入门认知篇 ✅
- 核心概念与架构篇 ✅
- 存储与原理篇(上)✅
- 存储与原理篇(中)✅
- 存储与原理篇(下)✅(本文)
- 事务消息(待续)
- ……
❤️ 如果你喜欢这篇文章,请点赞支持! 👍 同时欢迎关注我的博客,获取更多精彩内容!
本文来自博客园,作者:佛祖让我来巡山,转载请注明原文链接:https://www.cnblogs.com/sun-10387834/p/21303330

浙公网安备 33010602011771号