消息中间件进阶:RocketMQ 消息的消费(三)

系列第三篇:存储与原理篇(下)

你好,我们又见面了。

前两篇我们聊了消息怎么存(CommitLog + ConsumeQueue)、怎么发(三种发送方式 + 负载均衡 + 重试机制)。现在,消息已经到了 Broker 的“仓库”里,安安静静地躺在 CommitLog 里等着被处理。

接下来就是最关键的一环——消息是怎么被消费的?

你可能已经听过“推模式”和“拉模式”这两个词,也可能听说过 Rebalance、消费位点、重试队列这些概念。今天我们就从 Consumer 启动的那一刻开始,把消息消费的每个环节彻底讲透。配合流程图,咱们把“消费”这回事看得明明白白。

七、消息的消费

Consumer 启动流程与路由获取

Consumer 的启动和 Producer 非常相似,但多了几个消费特有的步骤:

flowchart TD Start([创建 Consumer 实例]) --> Step1[1. 设置 ConsumerGroup 名称<br>(同一业务/应用的消费者属于同一个 Group)] Step1 --> Step2[2. 设置 NameServer 地址列表] Step2 --> Step3[3. 配置消费模式<br>(集群消费/广播消费)和消费位点(从哪开始消费)] Step3 --> Step4[4. 订阅 Topic<br>(指定 Topic 和 Tag 过滤表达式)] Step4 --> Step5[5. 注册消息监听器<br>(MessageListener,处理收到的消息)] Step5 --> Step6[6. 调用 start 方法启动 Consumer] Step6 --> Step7[7. 初始化 MQClientInstance<br>(和 Producer 共用同一个实例)] Step7 --> Step8[8. 启动 Netty 客户端<br>(连接 NameServer 和 Broker)] Step8 --> Step9[9. 从 NameServer 拉取订阅 Topic 的路由信息] Step9 --> Step10[10. 触发首次 Rebalance<br>(分配 Queue 给当前 Consumer)] Step10 --> Step11[11. 启动定时任务<br>(每 30 秒更新路由 + 定时 Rebalance)] Step11 --> End([Consumer 就绪,开始消费消息]) style Step10 fill:#ffcdd2 style Step11 fill:#c8e6c9

和 Producer 启动的关键区别:

  1. 订阅 Topic:Consumer 启动时需要明确告诉 Broker“我要消费哪个 Topic”
  2. 注册监听器:Consumer 需要注册 MessageListener,处理消息的业务逻辑就写在这里面
  3. 触发 Rebalance:这是消费特有的环节——Consumer 启动后会立即进行一次 Rebalance,确定自己负责消费哪些 Queue
  4. 消费位点:Consumer 需要知道从哪个位置开始消费(从最新的开始?还是从头开始?还是从上次消费的进度继续?)

Pull 模式与 Push 模式的本质区别

这是初学者最容易混淆的概念。表面上 RocketMQ 支持两种消费模式——Pull 和 Push,但在底层实现上,它们都是 Pull 模式。

本质区别:谁来控制拉取频率?

模式 控制权 实现方式 用户感知
Pull 用户自己控制 用户主动调用 pull() 方法 用户完全掌控拉取时机和批量大小
Push Broker“推” 底层依然是不断拉取,但被封装成“推送”的感觉 用户只需实现监听器,消息来了就被调用
flowchart TB subgraph PullMode[Pull 模式] U1[用户代码] -->|主动调用 pull| C1[Consumer] C1 -->|拉取请求| B1[Broker] B1 -->|返回消息| C1 C1 -->|返回消息| U1 NotePull["用户自己控制:<br>什么时候拉、拉多少条、间隔多久"] end subgraph PushMode[Push 模式(底层也是 Pull)] U2[用户代码] -->|注册监听器| C2[Consumer] C2 -.->|后台线程<br>循环拉取| B2[Broker] B2 -.->|返回消息| C2 C2 -->|自动回调| U2 NotePush["用户无感知:<br>消息来了自动回调监听器<br>底层是长轮询或定时拉取"] end style PullMode fill:#fff3e0 style PushMode fill:#e3f2fd

Push 模式的底层实现:

RocketMQ 的 Push 模式(DefaultMQPushConsumer)在底层其实是一个不断循环的 Pull 线程:

  1. Consumer 启动后,内部有一个 PullMessageService 线程
  2. 这个线程不断从 Broker 拉取消息(每次拉取一批)
  3. 拉到的消息提交到线程池处理
  4. 处理完成后,继续拉取下一批

所以本质上,Push 模式 = 自动化的 Pull + 多线程消费。

长轮询机制的原理与实现

既然底层是 Pull,那 Broker 怎么做到“消息一来就推送给消费者”呢?答案就是长轮询(Long Polling)。

什么是长轮询?

传统的 Pull 是消费者定时去 Broker 问“有新消息吗?”——如果一直没有消息,每次请求都是空跑,浪费资源。

长轮询的优化是:如果 Broker 没有新消息,请求不立即返回,而是“挂起”等待一段时间(默认 15 秒)。如果在这段时间内有新消息到达,立即返回;如果超时了还没消息,返回空响应。

sequenceDiagram participant C as Consumer participant B as Broker Note over C,B: 有消息时的长轮询 C->>B: 拉取请求(offset = 100) B->>B: 检查 ConsumeQueue<br>发现 offset=100 处有消息 B-->>C: 立即返回消息 Note over C: ✅ 快速响应 Note over C,B: 没有消息时的长轮询 C->>B: 拉取请求(offset = 200) B->>B: 检查 ConsumeQueue<br>offset=200 处暂无消息 B->>B: 将请求挂起,放入 pending 队列 Note over B: 等待新消息(最长 15 秒) alt 15 秒内有新消息到达 B->>B: 新消息写入,触发唤醒 B-->>C: 返回新消息 else 15 秒超时 B-->>C: 返回空响应 C->>C: 继续发起下一次长轮询 end

长轮询的好处:

  1. 实时性:消息一到达,挂起的请求立即被唤醒,Consumer 几乎实时收到消息
  2. 节省资源:相比短轮询(每秒一次),长轮询大大减少了空请求的数量
  3. 保护 Broker:通过超时机制避免请求无限挂起导致资源泄漏

💡 小贴士:长轮询的超时时间由 pullRequestHoldService 控制,默认 15 秒。这个值不是越大越好——太大会占用 Broker 的线程资源,太小又会增加空请求的频率。

消费者组(Consumer Group)的概念与作用

什么是消费者组?

消费者组是一组具有相同消费逻辑的 Consumer 实例的集合。它们共同消费同一个 Topic 的消息。

graph TB subgraph ConsumerGroup[消费者组:order_consumer_group] C1[Consumer 实例 1<br>部署在服务器 A] C2[Consumer 实例 2<br>部署在服务器 B] C3[Consumer 实例 3<br>部署在服务器 C] end subgraph Topic[Topic: order_topic] Q1[Queue 0] Q2[Queue 1] Q3[Queue 2] Q4[Queue 3] end Q1 --> C1 Q2 --> C1 Q3 --> C2 Q4 --> C3 Note1["同一个 Group 内的 Consumer 实例<br>共同消费 Topic 的所有消息(集群消费)"] Note2["不同实例之间负载均衡<br>每个 Queue 只分配给一个实例"]

消费者组的核心作用:

  1. 水平扩展:增加 Consumer 实例,就能线性提升消费吞吐量
  2. 消费进度管理:同一个 Group 共享一份消费进度(Offset),任意实例宕机后,其他实例可以接替继续消费
  3. 负载均衡:Group 内的实例自动分配 Queue,协同工作

💡 核心约束:同一个消费者组内的 Consumer,必须订阅相同的 Topic 和 Tag。如果订阅不同,会导致 Rebalance 混乱和消息丢失。

集群消费与广播消费

RocketMQ 提供了两种消费模式,它们的区别非常核心:

集群消费(Clustering)

  • 一个 Topic 下的消息,只被同一个 Group 中的一个 Consumer 消费一次
  • 多个 Consumer 实例之间负载均衡分配 Queue
  • 这是 RocketMQ 的默认模式
flowchart LR subgraph Topic[Topic: order_topic] M1[消息1] --> Q1[Queue0] M2[消息2] --> Q2[Queue1] M3[消息3] --> Q3[Queue2] M4[消息4] --> Q4[Queue3] end subgraph Group[消费者组] C1[Consumer A] -->|消费| Q1 C2[Consumer B] -->|消费| Q2 C1 -->|消费| Q3 C2 -->|消费| Q4 end NoteCluster["每条消息只被消费一次<br>多个 Consumer 分摊负载"]

广播消费(Broadcasting)

  • 一个 Topic 下的消息,被同一个 Group 中的每个 Consumer 都消费一次
  • 每个 Consumer 独立消费所有 Queue 的消息
  • 每个 Consumer 独立管理自己的 Offset
flowchart LR subgraph Topic[Topic: order_topic] M1[消息1] --> Q1[Queue0] M2[消息2] --> Q2[Queue1] end subgraph Group[消费者组] C1[Consumer A] -->|消费| Q1 C1 -->|消费| Q2 C2[Consumer B] -->|消费| Q1 C2 -->|消费| Q2 end NoteBroadcast["每条消息被每个 Consumer 都消费一次<br>每条消息被消费 N 次(N = 消费者数量)"]
对比维度 集群消费 广播消费
消息投递 每条消息只投递给一个 Consumer 每条消息投递给所有 Consumer
Offset 管理 Group 共享一份 每个实例各自独立
适用场景 负载均衡、水平扩展 每个节点都需要全量数据(如缓存刷新)
是否默认 ✅ 默认 ❌ 需显式配置

Rebalance 机制的触发时机与算法实现

Rebalance 是什么?

Rebalance(重平衡)是指消费者组内的 Queue 分配发生变化时,重新分配 Queue 给各个 Consumer 实例的过程。

触发时机:

flowchart TD Start([触发 Rebalance]) --> T1{消费者实例<br>启动或关闭?} T1 -->|是| Do[执行 Rebalance] T1 -->|否| T2{Topic 的 Queue 数量<br>发生变化?} T2 -->|是| Do T2 -->|否| T3{消费者组<br>订阅信息变化?} T3 -->|是| Do T3 -->|否| T4{Broker 变化<br>(新增/宕机)?} T4 -->|是| Do T4 -->|否| End([不触发]) Do --> Result([重新分配所有 Queue])

具体的触发场景包括:

  1. Consumer 启动或关闭:Group 内成员变化,需要重新分配
  2. Topic 的 Queue 数量变化:新增或减少 Queue,需要重新分配
  3. Consumer 订阅信息变化:订阅的 Topic 或 Tag 变化
  4. Broker 变化:某个 Broker 宕机或新增,影响 Queue 的可用性

Rebalance 的触发频率:Consumer 内部有一个定时任务,每隔 20 秒 检查一次是否需要 Rebalance。

Rebalance 的算法:

RocketMQ 支持多种队列分配策略,通过 AllocateMessageQueueStrategy 接口来定义:

flowchart LR subgraph Strategy[队列分配策略] A1[平均分配<br>AllocateMessageQueueAveragely] A2[环形分配<br>AllocateMessageQueueAveragelyByCircle] A3[自定义<br>实现接口] end subgraph Example[分配示例<br>4 个 Queue + 3 个 Consumer] E1[平均分配] E2[环形分配] end E1 --> E1R["C1: Q0,Q1 | C2: Q2 | C3: Q3<br>(尽量均匀)"] E2 --> E2R["C1: Q0,Q3 | C2: Q1 | C3: Q2<br>(轮流分配,类似发牌)"] style A1 fill:#c8e6c9 style A2 fill:#e3f2fd

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 过程中,消息可能被重复消费,也可能被跳过。

flowchart TD subgraph Risk1[⚠️ 消息重复风险] R1a[Consumer A 消费 Queue0<br>进度已更新到 Offset 100] R1b[Rebalance:Queue0 分配给 Consumer B] R1c[Consumer B 从 Offset 95 开始消费<br>(因为进度还没同步到最新的 100)] R1d[结果:Offset 95-100 的消息被重复消费] end subgraph Risk2[⚠️ 消息跳过风险] R2a[Consumer A 消费 Queue0<br>拉取了 Offset 100-120 的消息<br>但还没提交消费进度] R2b[Rebalance:Queue0 分配给 Consumer B] R2c[Consumer B 从 Offset 100 开始消费] R2d[但 Offset 100-120 的消息已经被 A 拉取<br>如果 A 处理失败或被关闭,这些消息可能丢失] R2e[结果:消息未被确认就被重新分配<br>可能导致消息丢失或重复] end style Risk1 fill:#ffcdd2 style Risk2 fill:#ffcdd2

为什么会发生重复?

因为消费进度(Offset)的提交是异步的。Consumer 拉取了一批消息,可能在业务处理完之前,Rebalance 就触发了,Queue 被分配给了别的 Consumer。新的 Consumer 从上次提交的 Offset 开始消费,就会重复消费那些已经拉取但还没提交进度的消息。

如何规避风险?

  1. 幂等消费:这是最基本的防御手段——业务层保证同一消息被多次消费时,效果和消费一次相同
  2. 合理设置超时:避免消费超时导致 Offset 提交延迟
  3. 使用同步提交:在关键场景下,可以配置 commitOffset 为同步模式
  4. 监控 Rebalance 频率:频繁 Rebalance 是系统不稳定的信号

💡 小贴士:RocketMQ 的设计哲学是 “至少一次(At Least Once)” 。它宁愿消息被重复消费,也不愿消息丢失。所以业务方一定要做好幂等处理。

消费位点(Offset)的管理与存储

什么是 Offset?

Offset 是 Consumer 消费进度的“书签”,记录了当前消费到了哪个位置。

在 RocketMQ 中,Offset 指的是 ConsumeQueue 中的逻辑偏移量(第几条消息),而不是 CommitLog 中的物理偏移量。

flowchart LR subgraph ConsumeQueue[ConsumeQueue 内容] E1[Entry 0<br>消息 A] E2[Entry 1<br>消息 B] E3[Entry 2<br>消息 C] E4[Entry 3<br>消息 D] E5[Entry 4<br>消息 E] E6[Entry 5<br>消息 F] end subgraph Offset[消费进度] O1["已消费到 Offset = 3<br>(Entry 3 之前都已消费)"] end E1 -->|已消费| D1[✅] E2 -->|已消费| D2[✅] E3 -->|已消费| D3[✅] E4 -->|未消费| D4[⏳ 当前位置] E5 -->|未消费| D5[⏳] E6 -->|未消费| D6[⏳] O1 -.-> E4 style O1 fill:#e3f2fd

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 的高可用管理。

flowchart TB subgraph Offset存储方式 A[传统方式<br>Broker 本地存储] B[5.x 新方式<br>远端存储] end subgraph A_detail[Broker 本地存储] A1[Consumer 提交 Offset] A2[Broker 写入 consumerOffset.json] A3[每隔 5 秒持久化一次] A4[⚠️ Broker 宕机可能丢失最近 5 秒的 Offset] end subgraph B_detail[远端存储(5.x)] B1[Consumer 提交 Offset] B2[通过 gRPC 提交到 Proxy] B3[Proxy 写入 Raft 集群] B4[✅ 高可用,不丢失] end A --> A1 --> A2 --> A3 --> A4 B --> B1 --> B2 --> B3 --> B4 style A fill:#fff3e0 style B fill:#c8e6c9

集群消费 vs 广播消费的 Offset 存储:

消费模式 Offset 存储位置 说明
集群消费 Broker 端 Group 共享一份 Offset
广播消费 客户端本地 每个实例独立存储

消息拉取的流程与线程模型

Consumer 的消息拉取流程是一个精心设计的多线程协作模型:

flowchart TB subgraph Main[主线程] M1[Consumer.start] --> M2[启动 PullMessageService] M2 --> M3[启动 RebalanceService] M3 --> M4[启动 Consumer 线程池] end subgraph Pull[PullMessageService 线程] P1[不断从 pullRequestQueue<br>取出 PullRequest] --> P2[拉取消息] P2 --> P3[收到消息] P3 --> P4[提交到 Consumer 线程池] P4 --> P5[创建新的 PullRequest<br>继续拉取] end subgraph Consume[Consumer 线程池] C1[执行消息监听器] --> C2{消费成功?} C2 -->|是| C3[提交 Offset] C2 -->|否| C4[重试或投递到 DLQ] end M4 --> Consume Pull --> P5 P5 --> P1 style Pull fill:#e3f2fd style Consume fill:#c8e6c9

关键线程说明:

线程/组件 职责
PullMessageService 单线程,负责从 Broker 拉取消息,将消息提交到消费线程池
RebalanceService 单线程,定时检查并触发 Rebalance
Consumer 线程池 多线程,负责执行业务监听器,处理消息
Offset 提交线程 定时或定频提交消费进度到 Broker

消费线程池的配置:

// 设置消费线程池大小(默认 20)
consumer.setConsumeThreadMin(10);
consumer.setConsumeThreadMax(40);
  • 线程池大小决定了消息消费的并行度
  • 如果业务处理耗时较长(如调用第三方接口),需要适当增大线程池
  • 但线程池过大会增加上下文切换开销,需要根据实际压测确定

并发消费与顺序消费的实现与区别

这是业务开发中最常遇到的选择:我的消息需要按顺序处理吗?

并发消费(Concurrently)

  • 多个消息并行处理,不保证顺序
  • 同一个 Queue 的消息,在不同的线程中并行处理
  • 吞吐量最高,适合大多数业务场景

顺序消费(Orderly)

  • 同一个 Queue 的消息串行处理,保证顺序
  • 同一个 Queue 在同一时刻只能被一个线程处理
  • 吞吐量低于并发消费,适合需要保证顺序的业务(如订单状态流转)
flowchart LR subgraph Concurrent[并发消费] Q1[Queue: 消息1,2,3,4,5] Q1 --> T1[线程1: 消息1] Q1 --> T2[线程2: 消息2] Q1 --> T3[线程3: 消息3] Q1 --> T4[线程4: 消息4] NoteCon["消息顺序不保证<br>处理完成顺序: 2→1→4→3→5"] end subgraph Orderly[顺序消费] Q2[Queue: 消息1,2,3,4,5] Q2 --> T5[同一个线程<br>串行处理] T5 --> R1[消息1] T5 --> R2[消息2] T5 --> R3[消息3] NoteOrd["消息顺序严格保证<br>处理顺序: 1→2→3→4→5"] end style Concurrent fill:#e3f2fd style Orderly fill:#fff3e0

实现对比:

对比维度 并发消费 顺序消费
实现类 MessageListenerConcurrently MessageListenerOrderly
线程模型 多线程并行 单线程串行(每个 Queue)
吞吐量 高 中
消息顺序 不保证 保证同 Queue 有序
适用场景 大部分业务 订单状态机、库存扣减等

顺序消息的实现原理

RocketMQ 的顺序消息依赖于 MessageQueue 级别的顺序保证。

flowchart TB subgraph Producer[生产者端] P1[消息: order_123: 创建] -->|哈希取模| Q1[Queue 0] P2[消息: order_123: 支付] -->|哈希取模| Q1 P3[消息: order_123: 发货] -->|哈希取模| Q1 P4[消息: order_456: 创建] -->|哈希取模| Q2[Queue 1] P5[消息: order_456: 支付] -->|哈希取模| Q2 end subgraph Broker[Broker 存储] Q1 --> Store1[CommitLog<br>顺序存储] Q2 --> Store1 end subgraph Consumer[消费者端] Store1 --> Q1_Consume[Queue 0<br>单线程串行消费] Store1 --> Q2_Consume[Queue 1<br>单线程串行消费] end NoteOrder["同一个 Key(订单号)的消息<br>始终落在同一个 Queue<br>该 Queue 内消息严格有序"] style Q1 fill:#c8e6c9 style Q2 fill:#e3f2fd

顺序消息的三个关键点:

  1. 生产者端:相同业务 Key 的消息,通过一致性哈希或取模,保证落到同一个 Queue
  2. Broker 端:同一个 Queue 内的消息,在 CommitLog 中是顺序写入的(所有消息都是顺序写入,天然有序)
  3. 消费者端:使用 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'"));
flowchart TB subgraph Tag[Tag 过滤] T1[消息到达 Broker] --> T2[ConsumeQueue 中存储了 Tag 哈希码] T2 --> T3[根据哈希码快速比对] T3 -->|匹配| T4[发送给 Consumer] T3 -->|不匹配| T5[丢弃] NoteTag["性能:极高<br>适用:简单分类"] end subgraph SQL[SQL92 过滤] S1[消息到达 Broker] --> S2[需要解析消息的所有属性] S2 --> S3[执行 SQL 表达式计算] S3 -->|TRUE| S4[发送给 Consumer] S3 -->|FALSE| S5[丢弃] NoteSQL["性能:消耗 CPU<br>适用:复杂业务规则"] end style Tag fill:#c8e6c9 style SQL fill:#fff3e0

最佳实践:能用 Tag 就不用 SQL92。

消费进度(Consumer Offset)的提交时机

Offset 提交的时机直接影响了消费的可靠性和性能。

RocketMQ 的 Offset 提交策略:

flowchart TB subgraph Concurrent[并发消费 - 批量提交] C1[拉取一批消息<br>(默认 32 条)] --> C2[投递给线程池<br>并行处理] C2 --> C3[所有消息处理完成后<br>批量提交 Offset] C3 --> C4[提交位置 = 这批消息<br>的最大 Offset + 1] end subgraph Orderly[顺序消费 - 逐个提交] O1[拉取消息] --> O2[逐个处理] O2 --> O3[每条消息处理完成后<br>立即提交 Offset] O3 --> O4[提交位置 = 当前处理<br>消息的 Offset + 1] end style Concurrent fill:#e3f2fd style Orderly fill:#fff3e0

并发消费的 Offset 提交逻辑:

  • Consumer 拉取一批消息(比如 32 条),提交到线程池并行处理
  • 只有当这一批消息全部处理完成后,才提交 Offset
  • 提交的位置是这批消息中最大的 Offset + 1

这意味着:如果 32 条消息中有 1 条处理失败,整批消息的 Offset 都不会提交。失败的那条消息会被重试,但已经成功处理的 31 条消息可能会被重复消费(因为下次拉取会从这 32 条的起始位置开始)。

这就是 RocketMQ “至少一次”语义的体现——宁可重复,不可丢失。

提交方式配置:

方式 说明 配置
同步提交 提交 Offset 后等待 Broker 确认 autoCommit = false + 手动提交
异步提交 不等待确认,性能更好 默认方式

消息重试机制与重试队列

消息消费失败怎么办?RocketMQ 有完善的重试机制。

flowchart TB Start[消息消费] --> Check{消费成功?} Check -->|是| Done[✅ 提交 Offset] Check -->|否| Retry[进入重试] Retry --> Level1{重试次数<br>≤ 最大重试次数?} Level1 -->|是| SendRetry[发送到<br>%RETRY%ConsumerGroup] SendRetry --> Delay[延迟投递<br>延迟时间逐次递增] Delay --> Consumer[再次消费] Consumer --> Check Level1 -->|否| DLQ[投递到<br>%DLQ%ConsumerGroup<br>死信队列] DLQ --> Manual[需要人工介入处理] style Done fill:#c8e6c9 style DLQ fill:#ffcdd2 style Manual fill:#ffcdd2

重试机制的核心设计:

  1. 重试队列:消息消费失败后,不直接丢弃,而是发送到 %RETRY%{ConsumerGroup} 这个特殊的 Topic
  2. 延迟重试:重试不是立即的,而是逐次增加延迟时间(1 秒 → 5 秒 → 10 秒 → 30 秒 → ...)
  3. 最大重试次数:默认 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}

flowchart LR subgraph Process[消费流程] M[消息] -->|重试 16 次都失败| DLQ[死信队列<br>%DLQ%order_consumer_group] DLQ --> Alert[告警通知] DLQ --> Manual[人工介入] Manual --> Fix[修复问题] Fix --> Resend[重新发送到业务 Topic] Resend --> Consume[重新消费] end style DLQ fill:#ffcdd2 style Manual fill:#ffcdd2

死信队列的特点:

  1. 不再被自动消费:死信队列中的消息不会被 Consumer 自动拉取
  2. 需要人工处理:需要通过运维工具或 API 手动处理
  3. 包含完整信息:死信消息保留了原始消息的所有信息,包括原始 Topic、Tag 和消息体
  4. 有生命周期:死信消息也有过期时间,默认 72 小时后会被清理

死信处理的最佳实践:

  1. 监控告警:对 DLQ 中的消息数量设置监控,一旦有消息进入 DLQ,立即告警
  2. 人工补偿:从 DLQ 中拉取消息,分析失败原因,修复业务逻辑后重新发送
  3. 定期清理:设置合理的 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 生产消息的速度。

flowchart TB subgraph Normal[正常情况] P1[生产 1000条/s] --> Q1[Queue] Q1 -->|消费 1000条/s| C1[Consumer] NoteNormal["生产速度 = 消费速度"] end subgraph Backlog[积压情况] P2[生产 5000条/s] --> Q2[Queue] Q2 -->|消费 1000条/s| C2[Consumer] NoteBacklog["⚠️ 生产速度 > 消费速度<br>消息堆积越来越多"] end style Normal fill:#c8e6c9 style Backlog fill:#ffcdd2

积压的常见原因:

原因 说明 解决方案
消费速度慢 业务处理逻辑复杂,单条消息处理时间长 优化业务逻辑,增加 Consumer 实例
Consumer 数量不足 Queue 数量远大于 Consumer 数量 增加 Consumer 实例(不超过 Queue 数量)
下游依赖故障 消费时依赖的数据库/API 响应慢或不可用 快速恢复下游服务,或用异步处理
消息体过大 大消息导致网络传输和解析耗时 压缩消息体,或将大内容存 OSS
顺序消费 顺序消费串行处理,吞吐量受限 考虑是否可以改为并发消费

积压处理方案:

flowchart TD Start[发现消息积压] --> Step1[1. 确认积压程度<br>有多少条消息未消费?] Step1 --> Step2[2. 定位原因<br>Consumer 挂了?下游慢了?生产突发?] Step2 --> Step3{原因判断} Step3 -->|Consumer 挂了| Fix1[重启 Consumer<br>或扩容实例] Step3 -->|消费速度慢| Fix2[临时增加 Consumer 实例<br>注意不超过 Queue 数量] Step3 -->|生产突增| Fix3[扩容 Consumer 实例<br>或临时开启更多 Queue] Step3 -->|顺序消息积压| Fix4[增加 Topic 的 Queue 数量<br>重新分配 Key 让消息更均匀] Fix1 --> Monitor[持续监控消费进度] Fix2 --> Monitor Fix3 --> Monitor Fix4 --> Monitor Monitor --> Done[积压消除后<br>逐步恢复原配置] style Start fill:#ffcdd2 style Done fill:#c8e6c9

积压处理的黄金原则:

  1. 先止损:快速扩容 Consumer,让消费速度先追上来
  2. 再定位:分析根本原因,防止再次发生
  3. 临时 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 在本地事务+消息一致性上的表现。我们下期见!


系列文章:

  1. 入门认知篇 ✅
  2. 核心概念与架构篇 ✅
  3. 存储与原理篇(上)✅
  4. 存储与原理篇(中)✅
  5. 存储与原理篇(下)✅(本文)
  6. 事务消息(待续)
  7. ……
posted @ 2026-07-15 12:56  佛祖让我来巡山  阅读(284)  评论(0)    收藏  举报

佛祖让我来巡山博客站 - 创建于 2018-08-15

开发工程师个人站,内容主要是网站开发方面的技术文章,大部分来自学习或工作,部分来源于网络,希望对大家有所帮助。

Bootstrap中文网