2026-05-24 20:51:06 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags:
|
|
|
|
|
|
- MQ
|
2026-05-24 21:02:18 +08:00
|
|
|
|
- 消息模型
|
|
|
|
|
|
- ConsumerGroup
|
|
|
|
|
|
- 发布订阅
|
2026-05-24 20:51:06 +08:00
|
|
|
|
create time: 2026-05-24 19:52
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# MQ 消息模型
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
|
|
|
|
|
消息模型决定了消息如何从生产者流向消费者。本文对比队列模型与发布订阅模型,深入剖析 Consumer Group 机制,比较 Push/Pull 两种消费模式,并梳理主流 MQ 的消息模型设计。
|
|
|
|
|
|
|
|
|
|
|
|
## 正文
|
|
|
|
|
|
|
|
|
|
|
|
### 队列模型 vs 发布订阅模型
|
|
|
|
|
|
|
|
|
|
|
|
消息队列领域有两种基本模型,理解它们的区别是掌握所有 MQ 的基础。
|
|
|
|
|
|
|
|
|
|
|
|
**队列模型(Point-to-Point)**:一条消息只能被一个消费者消费。就像排队取号,每个号码只能被一个窗口叫到。
|
|
|
|
|
|
|
|
|
|
|
|
**发布订阅模型(Pub/Sub)**:一条消息可以被多个消费者组各自消费一次。就像广播,每个家庭都能听到同一条新闻。
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph TD
|
2026-06-08 23:08:57 +08:00
|
|
|
|
subgraph "Queue Model - Competing Consumers"
|
2026-05-24 20:51:06 +08:00
|
|
|
|
P1["Producer"] --> Q1["Queue"]
|
2026-06-08 23:08:57 +08:00
|
|
|
|
Q1 -->|"msg1"| C1["Consumer A"]
|
|
|
|
|
|
Q1 -->|"msg2"| C2["Consumer B"]
|
|
|
|
|
|
Q1 -->|"msg3"| C3["Consumer C"]
|
2026-05-24 20:51:06 +08:00
|
|
|
|
end
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!note] 关键区分
|
|
|
|
|
|
> 队列模型中,每条消息**只会投递给一个消费者**(竞争消费),三个消费者分摊消息而非各自接收全部。这就是"Point-to-Point"的含义。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
```mermaid
|
|
|
|
|
|
graph TD
|
|
|
|
|
|
subgraph "Pub/Sub Model"
|
|
|
|
|
|
P2["Producer"] --> T1["Topic"]
|
|
|
|
|
|
T1 --> SUB1["Consumer Group A"]
|
|
|
|
|
|
T1 --> SUB2["Consumer Group B"]
|
|
|
|
|
|
SUB1 --> CA1["Consumer A1"]
|
|
|
|
|
|
SUB1 --> CA2["Consumer A2"]
|
|
|
|
|
|
SUB2 --> CB1["Consumer B1"]
|
|
|
|
|
|
end
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> 如果队列模型中,你想让同一条消息被两个不同的服务分别处理,有什么办法?
|
|
|
|
|
|
|
|
|
|
|
|
### Consumer Group 机制详解
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
**Consumer Group 是发布订阅模型的机制**,队列模型(Point-to-Point)中没有这个概念——消息被一个消费者消费即完成,无需分组。Consumer Group 的出现是为了在发布订阅模型中同时解决**并行消费**和**组间独立**两个问题。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
**为什么需要 Consumer Group?**
|
|
|
|
|
|
|
|
|
|
|
|
假设一个 Topic 有 100 万条消息需要处理,单个消费者处理太慢。你需要多个消费者并行消费,但又不想同一条消息被重复处理——Consumer Group 就是为此而生的。
|
|
|
|
|
|
|
|
|
|
|
|
同一个 Consumer Group 内的消费者**分摊**消息(每条消息只被组内一个消费者处理),不同 Consumer Group **各自独立**消费全量消息。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
**各个 Consumer Group 内部消息如何分配?**
|
2026-05-24 20:51:06 +08:00
|
|
|
|
|
|
|
|
|
|
以 Kafka 为例,分配策略的核心逻辑:
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph TD
|
|
|
|
|
|
T["Topic: 6 Partitions"] --> P0["Partition 0"]
|
|
|
|
|
|
T --> P1["Partition 1"]
|
|
|
|
|
|
T --> P2["Partition 2"]
|
|
|
|
|
|
T --> P3["Partition 3"]
|
|
|
|
|
|
T --> P4["Partition 4"]
|
|
|
|
|
|
T --> P5["Partition 5"]
|
|
|
|
|
|
P0 --> C1["Consumer 1"]
|
|
|
|
|
|
P1 --> C1
|
|
|
|
|
|
P2 --> C2["Consumer 2"]
|
|
|
|
|
|
P3 --> C2
|
|
|
|
|
|
P4 --> C3["Consumer 3"]
|
|
|
|
|
|
P5 --> C3
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
常见的分配策略:
|
|
|
|
|
|
- **Range**:按范围连续分配,简单但容易不均衡
|
2026-06-08 23:08:57 +08:00
|
|
|
|
- **RoundRobin**:轮询分配,整体更均匀;但如果消费者订阅了不同 Topic,需要先按 Topic 分组再轮询,否则分配可能不均
|
2026-05-24 20:51:06 +08:00
|
|
|
|
- **Sticky**:粘性分配,Rebalance 时尽量保持原有分配,减少不必要的重分配
|
|
|
|
|
|
|
|
|
|
|
|
**Rebalance 触发条件**
|
|
|
|
|
|
|
|
|
|
|
|
当以下事件发生时,Consumer Group 会触发 Rebalance(重新分配 Partition):
|
|
|
|
|
|
1. 新消费者加入组
|
|
|
|
|
|
2. 消费者离开组(主动退出或心跳超时)
|
|
|
|
|
|
3. Topic 的 Partition 数量变化
|
|
|
|
|
|
|
|
|
|
|
|
Rebalance 期间所有消费者暂停消费,因此**Rebalance 的速度和频率直接影响系统可用性**。
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> 如果一个消费者处理消息特别慢,导致心跳超时被踢出组,会触发 Rebalance。Rebalance 又会导致更多消息积压——这是一个恶性循环,你有什么解决思路?
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> 恶性循环的本质是 **"处理速度"与"组成员管理"之间的矛盾**——心跳用于判断消费者是否存活,但处理慢会误判为"已死"。可以从**预防、缓解、兜底**三个层面拆解:
|
|
|
|
|
|
>
|
|
|
|
|
|
> **1. 预防:让心跳和消费解耦,别被误踢**
|
|
|
|
|
|
>
|
|
|
|
|
|
> - **调大 `max.poll.interval.ms`**:两次 `poll()` 之间允许的最大间隔,给慢处理留足时间
|
|
|
|
|
|
> - **减小 `max.poll.records`**:每次 poll 拉取的消息数减少,单批处理时间自然缩短——这是**最容易被忽视但最有效的参数**
|
|
|
|
|
|
>
|
|
|
|
|
|
> ```text
|
|
|
|
|
|
> max.poll.records: 500 → 100 // 减少单批处理量
|
|
|
|
|
|
> max.poll.interval.ms: 300000 // 根据实际处理速度设定
|
|
|
|
|
|
> ```
|
|
|
|
|
|
>
|
|
|
|
|
|
> **2. 缓解:让消费速度跟上来**
|
|
|
|
|
|
>
|
|
|
|
|
|
> - **增加 Partition 数量 + 消费者数量**:6 个 Partition 对 3 个消费者,每人背负 2 个 Partition;扩到 6 个消费者后单批处理时间减半。注意 **Partition 数必须 ≥ 消费者数**,否则多出来的消费者会空闲
|
|
|
|
|
|
> - **消费逻辑异步化**:poll 拉到消息后提交到内部线程池,poll 立即返回。这样 poll 间隔极短、心跳不会超时,同时并行处理提升吞吐
|
|
|
|
|
|
>
|
|
|
|
|
|
> ```go
|
|
|
|
|
|
> // 异步消费模式: poll 和处理解耦
|
|
|
|
|
|
> for {
|
|
|
|
|
|
> msgs := consumer.Poll(100)
|
|
|
|
|
|
> for _, msg := range msgs {
|
|
|
|
|
|
> taskPool.Submit(func() { // 提交到线程池,不阻塞 poll
|
|
|
|
|
|
> processOrder(msg)
|
|
|
|
|
|
> consumer.Commit(msg) // 处理完再提交 offset
|
|
|
|
|
|
> })
|
|
|
|
|
|
> }
|
|
|
|
|
|
> }
|
|
|
|
|
|
> ```
|
|
|
|
|
|
>
|
|
|
|
|
|
> > [!warning] 异步化的代价
|
|
|
|
|
|
> > offset 管理会变复杂——你需要手动跟踪哪些消息已处理完才能安全提交 offset,否则消费者崩溃时会重复消费。
|
|
|
|
|
|
>
|
|
|
|
|
|
> **3. 兜底:即使 Rebalance 发生,也别让事情更糟**
|
|
|
|
|
|
>
|
|
|
|
|
|
> - **Cooperative Rebalance(增量式 Rebalance)**:Kafka 2.4+ 引入的 `CooperativeStickyAssigner`,Rebalance 时只重新分配受影响的 Partition,其他消费者**不暂停消费**,对比旧的 Eager Rebalance 可用性大幅提升
|
|
|
|
|
|
> - **静态成员(Static Membership)**:设置 `group.instance.id`,消费者短暂断开后重新加入时,如果 ID 不变且在 `session.timeout.ms` 内回来,**不触发 Rebalance**,能有效应对重启、发版等场景
|
|
|
|
|
|
> - **限流降级**:监控消费 Lag,当 Lag 超过阈值时消费端自动降级——跳过非核心处理步骤、关闭耗时的下游调用,优先保证"不被踢出"
|
|
|
|
|
|
>
|
|
|
|
|
|
> | 层面 | 策略 | 本质 |
|
|
|
|
|
|
> |------|------|------|
|
|
|
|
|
|
> | 预防 | 调大 `max.poll.interval.ms`、减小 `max.poll.records` | 别让心跳超时 |
|
|
|
|
|
|
> | 缓解 | 增加 Partition + 消费者、消费异步化 | 提升消费速度 |
|
|
|
|
|
|
> | 兜底 | Cooperative Rebalance、静态成员 | 降低 Rebalance 代价 |
|
|
|
|
|
|
>
|
|
|
|
|
|
> 核心原则:**先从参数调优入手(成本最低),再考虑架构调整(异步化、扩容),最后用新特性兜底(增量 Rebalance、静态成员)**。多数生产问题在第一步就能解决。
|
|
|
|
|
|
|
|
|
|
|
|
### 分区键与消息路由
|
|
|
|
|
|
|
|
|
|
|
|
Consumer Group 解决了"消费者如何分摊消息"的问题,但还有一个前置问题:**Producer 发出的消息,应该进入哪个 Partition / Queue?**
|
|
|
|
|
|
|
|
|
|
|
|
这个决策直接影响两个关键特性——**顺序性**和**负载均衡**。
|
|
|
|
|
|
|
|
|
|
|
|
**常见路由策略:**
|
|
|
|
|
|
|
|
|
|
|
|
- **轮询(RoundRobin)**:默认行为,消息均匀分散到所有 Partition,最大化并行度,但**无法保证同一业务实体的消息有序**
|
|
|
|
|
|
- **Key Hash**:按消息 Key(如 `orderId`)哈希到固定 Partition,保证同一 Key 的消息有序,但可能导致热点 Partition
|
|
|
|
|
|
- **显式指定**:Producer 直接指定目标 Partition,最灵活但需要业务自行管理
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph LR
|
|
|
|
|
|
P["Producer"] -->|"orderId = 1001"| R["Route by Key Hash"]
|
|
|
|
|
|
R -->|"hash(1001) % 3 = 1"| P1["Partition 1"]
|
|
|
|
|
|
P -->|"orderId = 1002"| R
|
|
|
|
|
|
R -->|"hash(1002) % 3 = 2"| P2["Partition 2"]
|
|
|
|
|
|
P -->|"orderId = 1001"| R
|
|
|
|
|
|
R --> P1
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> 如果你需要保证同一个订单的"创建→支付→发货"三条消息严格有序消费,应该怎么设置分区键?如果某个大客户突然下了一百万单,又会带来什么问题?
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> **分区键设置**:将 `orderId` 设为消息的分区键。Key Hash 路由保证同一 `orderId` 的消息始终进入同一个 Partition,单 Partition 内消息严格有序(FIFO),配合单消费者绑定该 Partition,即可保证"创建→支付→发货"被顺序消费。注意端到端严格有序还需同步发送 + 单 Partition 内串行消费。
|
|
|
|
|
|
>
|
|
|
|
|
|
> ```go
|
|
|
|
|
|
> // 同一订单的所有事件,都使用 orderId 作为 Key
|
|
|
|
|
|
> producer.Send("order_events", &Message{
|
|
|
|
|
|
> Key: "orderId:1001", // 分区键
|
|
|
|
|
|
> Value: `{"event":"created","orderId":1001}`,
|
|
|
|
|
|
> })
|
|
|
|
|
|
> producer.Send("order_events", &Message{
|
|
|
|
|
|
> Key: "orderId:1001", // 同一个 Key → 同一个 Partition
|
|
|
|
|
|
> Value: `{"event":"paid","orderId":1001}`,
|
|
|
|
|
|
> })
|
|
|
|
|
|
> ```
|
|
|
|
|
|
>
|
|
|
|
|
|
> **大客户下一百万单的隐患——数据倾斜**:
|
|
|
|
|
|
>
|
|
|
|
|
|
> | 问题 | 表现 |
|
|
|
|
|
|
> |------|------|
|
|
|
|
|
|
> | 热点 Partition | 百万消息集中在少数 Partition,对应 Consumer 被压垮,其余空闲 |
|
|
|
|
|
|
> | 消费延迟飙升 | 单 Partition 队列积压严重,处理耗时远超正常水平 |
|
|
|
|
|
|
> | Rebalance 恶性循环 | 慢 Consumer 心跳超时 → 被踢出 → 触发 Rebalance → 全组暂停 → 积压更严重 |
|
|
|
|
|
|
> | 顺序性与并行度矛盾 | 为保顺序只能单 Partition 单 Consumer,无法水平扩容 |
|
|
|
|
|
|
>
|
|
|
|
|
|
> **常见应对方案**:
|
|
|
|
|
|
> 1. **二级分区键**:用 `orderId` 而非 `customerId` 作为分区键,同一订单有序,不同订单分散到不同 Partition
|
|
|
|
|
|
> 2. **子订单拆分**:按商品类目、仓库等维度拆分为多个独立路由的消息流
|
|
|
|
|
|
> 3. **热点检测 + 动态路由**:监控各 Partition 的 Lag,发现热点后将后续消息临时路由到其他 Partition
|
|
|
|
|
|
> 4. **增加 Partition 数量**:更大的 Key 散列空间,天然降低倾斜概率
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]
|
|
|
|
|
|
> 分区键的选择是在**顺序性**和**并行度**之间做权衡。详情参见 [[15-MQ-顺序性保障]]。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
### Push 模式 vs Pull 模式
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | Push 模式 | Pull 模式 |
|
|
|
|
|
|
|------|----------|----------|
|
|
|
|
|
|
| 实时性 | 高,消息到达即推送 | 取决于拉取频率 |
|
|
|
|
|
|
| 消费者压力 | 被动接收,容易被打爆 | 主动拉取,按自身能力控制速率 |
|
2026-06-08 23:08:57 +08:00
|
|
|
|
| 吞吐量 | 中等(可通过 prefetch 批量推送) | 高(批量拉取) |
|
2026-05-24 20:51:06 +08:00
|
|
|
|
| 实现复杂度 | Broker 端复杂(需维护推送状态) | Consumer 端复杂(需实现拉取循环) |
|
|
|
|
|
|
| 典型代表 | RabbitMQ | Kafka |
|
|
|
|
|
|
|
|
|
|
|
|
Push 模式就像外卖送到家门口——方便,但高峰期外卖员不停按门铃你也会崩溃。Pull 模式就像自己去快递柜取件——按自己的时间来,但需要你主动去"查"。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip] Pull 的实时性并没有想象中那么差
|
|
|
|
|
|
> Kafka 和 RocketMQ 都采用了**长轮询(Long Polling)**来弥补 Pull 模式的实时性短板:消费者发起拉取请求后,如果没有新消息就**阻塞等待**,直到有消息到达或超时才返回。效果上接近 Push 的实时性,同时保留了 Pull 模式的速率控制优势。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
### 主流 MQ 的消息模型
|
|
|
|
|
|
|
|
|
|
|
|
#### Kafka:Consumer Group + Pull
|
|
|
|
|
|
|
|
|
|
|
|
Kafka 的核心设计哲学是"消费者自己来拉":
|
|
|
|
|
|
- 每个 Topic 分为多个 Partition,是并行消费的基本单元
|
|
|
|
|
|
- Consumer Group 内的消费者与 Partition 一一绑定(一个 Partition 只能被组内一个消费者消费)
|
|
|
|
|
|
- 消费者通过 Offset 追踪消费进度,支持回溯消费
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// Kafka 消费者组: Pull 模式
|
|
|
|
|
|
func main() {
|
|
|
|
|
|
consumer, _ := kafka.NewConsumer(&kafka.ConfigMap{
|
|
|
|
|
|
"bootstrap.servers": "localhost:9092",
|
|
|
|
|
|
"group.id": "order-service-group", // Consumer Group
|
|
|
|
|
|
"auto.offset.reset": "earliest", // 从最早消息开始消费
|
|
|
|
|
|
})
|
|
|
|
|
|
consumer.SubscribeTopics([]string{"order_created"}, nil)
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
msg, _ := consumer.ReadMessage(-1) // 主动拉取,阻塞等待
|
|
|
|
|
|
processOrder(msg.Value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### RabbitMQ:Exchange + Push
|
|
|
|
|
|
|
|
|
|
|
|
RabbitMQ 使用 Exchange(交换器)实现灵活的消息路由:
|
|
|
|
|
|
- Producer 将消息发送到 Exchange,而非直接发送到 Queue
|
|
|
|
|
|
- Exchange 根据 Binding Rule(绑定规则)将消息路由到一个或多个 Queue
|
|
|
|
|
|
- Broker 主动将消息推送给订阅了 Queue 的 Consumer
|
|
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
```mermaid
|
|
|
|
|
|
graph LR
|
|
|
|
|
|
P["Producer"] -->|"routing_key = order.created"| EX["Topic Exchange"]
|
|
|
|
|
|
EX -->|"order.created"| QA["Queue: order-service"]
|
|
|
|
|
|
EX -->|"order.created"| QB["Queue: audit-log"]
|
|
|
|
|
|
EX -->|"order.*"| QC["Queue: analytics"]
|
|
|
|
|
|
QA --> CA["Order Consumer"]
|
|
|
|
|
|
QB --> CB["Audit Consumer"]
|
|
|
|
|
|
QC --> CC["Analytics Consumer"]
|
|
|
|
|
|
|
|
|
|
|
|
style EX fill:#4A90D9,color:#fff
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
一条消息进入 Exchange 后,可以被路由到多个 Queue——这意味着 RabbitMQ 天然支持发布订阅,不需要额外的 Consumer Group 概念。
|
2026-05-24 21:02:18 +08:00
|
|
|
|
|
|
|
|
|
|
Exchange 的四种类型:**Direct**(精确匹配 Routing Key)、**Topic**(通配符匹配,`*` 匹配一个词,`#` 匹配多个词)、**Fanout**(忽略 Routing Key,广播到所有绑定队列)、**Headers**(按消息头部属性匹配)。实际开发中 Topic 和 Direct 最常用。
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// RabbitMQ: 通过 Exchange 实现发布订阅
|
|
|
|
|
|
// 生产者: 发送到 Exchange,而非直接发送到 Queue
|
|
|
|
|
|
ch.ExchangeDeclare("order_events", "topic", true, false, false, false, nil)
|
|
|
|
|
|
ch.Publish("order_events", "order.created", false, false, amqp.Publishing{
|
|
|
|
|
|
Body: orderJSON,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 消费者 A: 精确订阅 "order.created"
|
|
|
|
|
|
ch.QueueBind("order-queue", "order.created", "order_events", false, false, nil)
|
|
|
|
|
|
msgs, _ := ch.Consume("order-queue", "", false, false, false, false, nil)
|
|
|
|
|
|
for msg := range msgs {
|
|
|
|
|
|
processOrder(msg.Body)
|
|
|
|
|
|
msg.Ack(false) // 手动确认
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 消费者 B: 通配符订阅所有 order 事件
|
|
|
|
|
|
ch.QueueBind("analytics-queue", "order.*", "order_events", false, false, nil)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> RabbitMQ 没有 Consumer Group 概念,它怎么实现"同一条消息在组内只消费一次"的语义?
|
2026-05-24 20:51:06 +08:00
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> RabbitMQ 的竞争消费(Competing Consumers)模式:多个消费者订阅**同一个 Queue**,Broker 以轮询方式将消息逐条分发给各消费者,天然保证同一条消息只被一个消费者处理。不同服务各自创建自己的 Queue 并绑定到同一个 Exchange,就能实现类似 Consumer Group 的效果——组内竞争消费、组间独立消费。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
#### RocketMQ:ConsumerGroup + Pull
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
RocketMQ 的消息模型介于 Kafka 和 RabbitMQ 之间,兼具两者的优点。它在 Kafka 的 ConsumerGroup 基础上,增加了更灵活的消息过滤和消费模式。
|
|
|
|
|
|
|
|
|
|
|
|
**存储模型:Topic → MessageQueue**
|
|
|
|
|
|
|
|
|
|
|
|
与 Kafka 的 Partition 概念类似,RocketMQ 中 Topic 下分为多个 **MessageQueue(MQ)**,它是并行消费和负载均衡的基本单元。每个 Broker 节点可以承载某个 Topic 的多个 MessageQueue,Producer 发送消息时通过轮询或指定的方式选择目标 MQ。
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph TD
|
|
|
|
|
|
T["Topic: order_events"] --> MQ0["Broker-A / MQ-0"]
|
|
|
|
|
|
T --> MQ1["Broker-A / MQ-1"]
|
|
|
|
|
|
T --> MQ2["Broker-B / MQ-2"]
|
|
|
|
|
|
T --> MQ3["Broker-B / MQ-3"]
|
|
|
|
|
|
CG["ConsumerGroup"] --> C1["Consumer 1"]
|
|
|
|
|
|
CG --> C2["Consumer 2"]
|
|
|
|
|
|
MQ0 --> C1
|
|
|
|
|
|
MQ1 --> C1
|
|
|
|
|
|
MQ2 --> C2
|
|
|
|
|
|
MQ3 --> C2
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
核心特征概览:
|
|
|
|
|
|
- **ConsumerGroup**:与 Kafka 类似,组内竞争消费、组间独立消费
|
|
|
|
|
|
- **Pull 模式(长轮询)**:默认使用长轮询 Pull 模式,`PushConsumer` 的底层实际上是对 Pull 的封装——Broker 并不主动推送,而是 Consumer 主动发起拉取请求后挂起等待,直到有消息到达或超时
|
|
|
|
|
|
- **集群消费 vs 广播消费**:集群模式(默认)下同组实例分摊消息;广播模式下每个实例都收到全量消息(适用于本地缓存刷新等场景,下方 Q&A 有详细展开)
|
|
|
|
|
|
- **顺序消费与延迟消息**:支持分区有序(同一 MessageQueue 内保序)和全局有序(单 MQ),以及预设的延迟级别(1s/5s/10s/30s/1m/2m 等)
|
|
|
|
|
|
- **Tag / SQL92 过滤**:消费者可以在 Broker 端按 Tag 或 SQL92 表达式过滤消息,只接收感兴趣的子集——这是 RocketMQ 相比 Kafka 的一个显著优势,下面详细展开
|
|
|
|
|
|
|
|
|
|
|
|
**Tag 与 SQL92 消息过滤**
|
|
|
|
|
|
|
|
|
|
|
|
Kafka 消费者只能按 Partition 消费整个 Topic 的全量消息,过滤逻辑必须在消费端实现。RocketMQ 则将过滤下推到 Broker 端,减少了不必要的网络传输。
|
|
|
|
|
|
|
|
|
|
|
|
- **Tag 过滤**:Producer 发送时指定 Tag(如 `order.created`、`order.paid`),Consumer 订阅时通过 Tag 表达式筛选。支持 `TagA || TagB` 多 Tag 订阅,也支持 `*` 订阅全部
|
|
|
|
|
|
- **SQL92 过滤**:基于消息属性(User Properties)的 SQL 表达式过滤,例如 `amount > 1000 AND region = 'cn'`,灵活性远超 Tag
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph LR
|
|
|
|
|
|
P["Producer"] -->|"Tag = created"| B["Broker"]
|
|
|
|
|
|
P -->|"Tag = paid"| B
|
|
|
|
|
|
P -->|"Tag = shipped"| B
|
|
|
|
|
|
B -->|"Tag = created OR paid"| C1["Order Service"]
|
|
|
|
|
|
B -->|"Tag = shipped"| C2["Logistics Service"]
|
|
|
|
|
|
B -->|"amount > 1000"| C3["Big Order Analytics"]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> Tag 过滤在 Broker 端执行,但 Broker 是怎么高效实现的?(提示:想想消息存储时的索引结构)
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> RocketMQ 的 CommitLog 存储中,每条消息的 Tag 会被写入 ConsumeQueue 的索引条目中(固定 20 字节的 Tag HashCode)。消费拉取时,Broker 先在 ConsumeQueue 层面用 Tag Hash 快速过滤,命中后才去 CommitLog 读取消息体。这意味着**未命中的消息不会产生磁盘 IO 到消息体**,过滤代价极低。但 SQL92 过滤需要读取消息属性后再判断,性能不如 Tag 过滤。
|
2026-05-24 20:51:06 +08:00
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
```go
|
2026-06-08 23:08:57 +08:00
|
|
|
|
// RocketMQ 消费者: 按 Tag 过滤订单事件
|
2026-05-24 21:02:18 +08:00
|
|
|
|
c, _ := rocketmq.NewPushConsumer(
|
|
|
|
|
|
consumer.WithGroupName("order-service-group"),
|
|
|
|
|
|
consumer.WithNameServer([]string{"127.0.0.1:9876"}),
|
|
|
|
|
|
)
|
2026-06-08 23:08:57 +08:00
|
|
|
|
// 只订阅 "created" 和 "paid" 两种 Tag 的消息
|
|
|
|
|
|
c.Subscribe("order_events",
|
|
|
|
|
|
consumer.MessageSelector{
|
|
|
|
|
|
Type: consumer.TAG,
|
|
|
|
|
|
Expression: "created || paid", // Tag 过滤表达式
|
|
|
|
|
|
},
|
2026-05-24 21:02:18 +08:00
|
|
|
|
func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
|
|
|
|
|
|
for _, msg := range msgs {
|
2026-06-08 23:08:57 +08:00
|
|
|
|
tag := msg.GetTags() // 获取消息 Tag
|
|
|
|
|
|
switch tag {
|
|
|
|
|
|
case "created":
|
|
|
|
|
|
handleOrderCreated(msg)
|
|
|
|
|
|
case "paid":
|
|
|
|
|
|
handleOrderPaid(msg)
|
|
|
|
|
|
}
|
2026-05-24 21:02:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
return consumer.ConsumeSuccess, nil
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
c.Start()
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!question]
|
|
|
|
|
|
> 既然 RocketMQ 的 `PushConsumer` 底层是 Pull 封装,那它和直接使用 `DefaultMQPullConsumer` 有什么区别?为什么官方推荐用 PushConsumer?
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> 核心区别在于**谁来管理消费循环和负载均衡**:
|
|
|
|
|
|
>
|
|
|
|
|
|
> | 维度 | PushConsumer | DefaultMQPullConsumer |
|
|
|
|
|
|
> |------|-------------|----------------------|
|
|
|
|
|
|
> | 消费循环 | SDK 自动管理(后台线程持续拉取) | 开发者自行实现拉取循环 |
|
|
|
|
|
|
> | Offset 管理 | 自动提交,支持手动覆盖 | 完全手动管理 |
|
|
|
|
|
|
> | 负载均衡 | 自动 Rebalance,Partition 分配开箱即用 | 需要自行实现分配逻辑 |
|
|
|
|
|
|
> | 消费失败重试 | 自动重试(最多 16 次),超限进死信队列 | 需要自行实现重试机制 |
|
|
|
|
|
|
> | 并发控制 | 通过 `consumeThreadMin/Max` 配置 | 开发者自行控制 |
|
|
|
|
|
|
>
|
|
|
|
|
|
> 简单说,`PushConsumer` 是**开箱即用的高层封装**,帮你处理了拉取循环、负载均衡、重试、并发控制等脏活累活;`DefaultMQPullConsumer` 是**底层原语**,灵活但需要自己造轮子。
|
|
|
|
|
|
>
|
|
|
|
|
|
> 官方推荐 PushConsumer 的原因:**大多数业务场景不需要精确控制每次拉取行为**,而 Pull 模式下手动管理 Offset 和 Rebalance 极易出错。只有在需要精确控制消费进度(如流处理、批量回放)时,才考虑 Pull Consumer。
|
|
|
|
|
|
>
|
|
|
|
|
|
> > [!warning] 版本说明
|
|
|
|
|
|
> > RocketMQ 5.x 中已废弃 `DefaultMQPullConsumer`,推荐使用 `DefaultLitePullConsumer` 替代,它在保留 Pull 灵活性的同时提供了自动 Rebalance 和 Offset 管理。
|
|
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
> [!question]
|
|
|
|
|
|
> RocketMQ 同时支持集群消费和广播消费——什么时候该用广播模式?想想"本地缓存刷新"这个场景。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
> [!tip]- 答案
|
|
|
|
|
|
> 核心区分:集群消费是**分工**——同一条消息只被组内一个实例消费;广播消费是**同步**——每个实例都收到全量消息。
|
|
|
|
|
|
>
|
|
|
|
|
|
> **为什么"本地缓存刷新"必须用广播?**
|
|
|
|
|
|
>
|
|
|
|
|
|
> 假设 3 个服务实例各自维护了一份本地缓存(商品价格、黑名单等)。当价格变更时:
|
|
|
|
|
|
> - 集群消费:消息只被其中一个实例消费 → 另外 2 个实例的缓存**还是脏数据**
|
|
|
|
|
|
> - 广播消费:3 个实例各自收到同一条消息 → **所有实例同时刷新**
|
|
|
|
|
|
>
|
|
|
|
|
|
> **广播模式的典型场景:**
|
|
|
|
|
|
>
|
|
|
|
|
|
> | 场景 | 说明 |
|
|
|
|
|
|
> |------|------|
|
|
|
|
|
|
> | 本地缓存刷新 | 价格变更、字典表更新、配置刷新 |
|
|
|
|
|
|
> | 路由表同步 | 网关实例同步最新路由规则 |
|
|
|
|
|
|
> | 降级开关下发 | 所有实例收到指令,关闭某项功能 |
|
|
|
|
|
|
> | 全量数据同步 | 每个实例本地维护一份只读数据副本 |
|
|
|
|
|
|
>
|
|
|
|
|
|
> **广播模式的代价:**
|
|
|
|
|
|
>
|
|
|
|
|
|
> - **不支持消息回溯**:offset 由各实例本地管理,消费者重启后**不会重新消费历史消息**
|
|
|
|
|
|
> - **消费失败无重试**:广播模式失败即丢失,不像集群模式最多重试 16 次
|
|
|
|
|
|
> - **新增实例的冷启动问题**:消息已广播过了,新实例缓存为空——需要额外的初始化加载(如启动时主动从 DB 全量拉取)
|
|
|
|
|
|
>
|
|
|
|
|
|
> 一句话总结:任何需要**"所有实例都感知"**的场景用广播;需要**"分工协作处理"**的场景用集群。
|
|
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
#### 三大 MQ 消息模型速查
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | Kafka | RabbitMQ | RocketMQ |
|
|
|
|
|
|
|------|-------|----------|----------|
|
|
|
|
|
|
| 基本模型 | 发布订阅(Consumer Group) | 发布订阅(Exchange + Queue) | 发布订阅(Consumer Group) |
|
|
|
|
|
|
| 消费模式 | Pull(长轮询) | Push(Broker 推送) | Pull(长轮询,Push 是封装) |
|
|
|
|
|
|
| 并行单元 | Partition | Queue | MessageQueue |
|
2026-06-08 23:08:57 +08:00
|
|
|
|
| 分组机制 | Consumer Group | 竞争消费(多消费者同一 Queue) | ConsumerGroup |
|
2026-05-24 21:02:18 +08:00
|
|
|
|
| 路由能力 | 基于 Partition Key | 极强(Exchange 四种类型) | 基于 Tag / SQL92 过滤 |
|
|
|
|
|
|
| 消息回溯 | 支持(Offset 重置) | 不支持(消费即删除) | 支持(按时间戳回溯) |
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]
|
|
|
|
|
|
> 选型时别只看吞吐量——**路由灵活性**、**消息回溯**、**消费模式**这些维度往往更影响日常开发体验。详见 [[27-MQ-选型对比]]。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
### 消费语义与可靠性保障
|
|
|
|
|
|
|
|
|
|
|
|
消息模型决定了"消息怎么分发",但还有一个同样重要的问题:"消息能不能只被处理一次?"
|
|
|
|
|
|
|
|
|
|
|
|
**三种消费语义:**
|
|
|
|
|
|
|
|
|
|
|
|
| 语义 | 含义 | 实现难度 | 典型场景 |
|
|
|
|
|
|
|------|------|---------|---------|
|
|
|
|
|
|
| At-most-once | 最多一次,可能丢失 | 低 | 日志采集、监控上报 |
|
|
|
|
|
|
| At-least-once | 至少一次,可能重复 | 中 | 大多数业务场景(默认语义) |
|
|
|
|
|
|
| Exactly-once | 恰好一次,不丢不重 | 高 | 金融交易、库存扣减 |
|
|
|
|
|
|
|
|
|
|
|
|
> [!warning]
|
|
|
|
|
|
> 几乎所有 MQ 的默认语义都是 **At-least-once**。网络抖动、消费者重启、Rebalance 都可能导致重复消费。"Exactly-once"往往需要 MQ 端幂等 + 业务端幂等配合才能实现。详见 [[12-MQ-消息确认与持久化]] 和 [[13-MQ-Exactly-Once-语义]]。
|
|
|
|
|
|
|
|
|
|
|
|
**死信队列(Dead Letter Queue, DLQ)**
|
|
|
|
|
|
|
|
|
|
|
|
当消息反复消费失败(达到最大重试次数),这条"毒消息"会被投递到死信队列,避免阻塞正常消费。三大 MQ 都支持 DLQ:
|
|
|
|
|
|
|
|
|
|
|
|
- **RabbitMQ**:通过 `x-dead-letter-exchange` 和 `x-dead-letter-routing-key` 参数配置
|
|
|
|
|
|
- **RocketMQ**:消费失败自动重试(16 次),超限后进入 `%DLQ%` Topic
|
|
|
|
|
|
- **Kafka**:原生不支持,需在消费者代码中自行实现重试 + DLQ 逻辑
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> 死信队列里的消息应该怎么处理?直接丢弃、人工介入、还是自动修复后重新投递?
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
### Go 代码示例:消费者组消费逻辑
|
|
|
|
|
|
|
|
|
|
|
|
下面是一个简化的消费者组实现,演示了 Partition 分配和消费的核心逻辑:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type ConsumerGroup struct {
|
|
|
|
|
|
GroupID string
|
|
|
|
|
|
Consumers []string
|
|
|
|
|
|
Partitions []int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Rebalance: 将 Partition 均匀分配给消费者
|
|
|
|
|
|
func (cg *ConsumerGroup) Rebalance() map[string][]int {
|
|
|
|
|
|
assignment := make(map[string][]int)
|
|
|
|
|
|
sort.Strings(cg.Consumers)
|
|
|
|
|
|
|
|
|
|
|
|
for i, partition := range cg.Partitions {
|
|
|
|
|
|
// 轮询分配: Partition i 分配给 Consumer i % len
|
|
|
|
|
|
consumer := cg.Consumers[i%len(cg.Consumers)]
|
|
|
|
|
|
assignment[consumer] = append(assignment[consumer], partition)
|
|
|
|
|
|
}
|
|
|
|
|
|
return assignment
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 消费循环: 拉取消息并处理
|
|
|
|
|
|
func Consume(broker Broker, topic string, groupID string, handler func(Message)) {
|
|
|
|
|
|
partitions := broker.GetPartitions(topic)
|
|
|
|
|
|
assignment := assignPartitions(groupID, partitions)
|
|
|
|
|
|
|
|
|
|
|
|
for _, partition := range assignment {
|
|
|
|
|
|
go func(p int) {
|
|
|
|
|
|
offset := broker.GetOffset(groupID, p)
|
|
|
|
|
|
for {
|
|
|
|
|
|
msgs := broker.Pull(p, offset, batchSize) // 批量拉取
|
|
|
|
|
|
for _, msg := range msgs {
|
|
|
|
|
|
handler(msg)
|
|
|
|
|
|
}
|
|
|
|
|
|
offset += len(msgs)
|
|
|
|
|
|
broker.CommitOffset(groupID, p, offset) // 提交消费进度
|
|
|
|
|
|
}
|
|
|
|
|
|
}(partition)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
这段代码展示了三个关键设计:
|
|
|
|
|
|
1. **轮询分配**保证 Partition 均匀分布到各消费者
|
|
|
|
|
|
2. **批量拉取**提升吞吐量,减少网络往返
|
|
|
|
|
|
3. **手动提交 Offset** 确保消息不丢失(处理完才提交)
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> Push 模式看起来更实时,为什么 Kafka 选择了 Pull?
|
|
|
|
|
|
|
|
|
|
|
|
Kafka 选择 Pull 模式有三个核心考量:
|
|
|
|
|
|
1. **消费者速率控制**:消费者按自己的处理能力拉取,天然避免被打爆。Push 模式下 Broker 不知道消费者的处理速度,需要额外的流控机制。
|
|
|
|
|
|
2. **批量消费**:Pull 模式允许消费者一次拉取一批消息,显著提升吞吐量。Push 模式逐条推送,网络开销大。
|
|
|
|
|
|
3. **消息回溯**:Pull 模式下消费者可以自由控制 Offset,实现"回放"历史消息。Push 模式下消息被推送后就消费掉了,回溯困难。
|
|
|
|
|
|
|
|
|
|
|
|
当然 Pull 模式的代价是实时性不如 Push——但 Kafka 通过**长轮询(Long Polling)**弥补了这个短板:消费者发起拉取请求,如果没有新消息就阻塞等待,直到有消息到达或超时。
|
|
|
|
|
|
|
2026-06-08 23:08:57 +08:00
|
|
|
|
### 幂等消费:防止重复处理
|
|
|
|
|
|
|
|
|
|
|
|
无论选择哪种消息模型,重复消费都是绕不开的问题。常见原因包括:消费者处理完消息但 ACK 失败(网络闪断)、Rebalance 导致消息被重新分配、Broker 重试投递。下面介绍几种常用思路,完整的幂等方案设计详见 [[14-MQ-消息幂等性]]。
|
|
|
|
|
|
|
|
|
|
|
|
**常用幂等方案:**
|
|
|
|
|
|
|
|
|
|
|
|
- **唯一消息 ID + 去重表**:消费前查询消息 ID 是否已处理过,利用数据库唯一约束保证幂等
|
|
|
|
|
|
- **业务天然幂等**:设计操作本身为幂等(如 `SET balance = 100` 而非 `balance += 10`)
|
|
|
|
|
|
- **乐观锁 / 版本号**:利用数据版本号避免覆盖更新
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// 幂等消费示例: 消费前先查去重表
|
|
|
|
|
|
func ProcessOrder(msg Message) {
|
|
|
|
|
|
// SELECT 1 FROM processed_messages WHERE msg_id = ?
|
|
|
|
|
|
if exists, _ := db.IsProcessed(msg.ID); exists {
|
|
|
|
|
|
return // 已处理,跳过
|
|
|
|
|
|
}
|
|
|
|
|
|
// 处理业务逻辑
|
|
|
|
|
|
db.CreateOrder(msg.Body)
|
|
|
|
|
|
// INSERT INTO processed_messages (msg_id) VALUES (?)
|
|
|
|
|
|
db.MarkProcessed(msg.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]
|
|
|
|
|
|
> 幂等设计的原则:**把去重判断放在消费的第一步**,而不是处理完再判断。这样即使重复投递,处理逻辑也只执行一次。
|
|
|
|
|
|
|
2026-05-24 20:51:06 +08:00
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
- [[1-MQ-基础概念]] — MQ 核心术语与基础架构
|
|
|
|
|
|
- [[2-MQ-适用场景与选型原则]] — 何时需要 MQ、选型决策框架
|
2026-06-08 23:08:57 +08:00
|
|
|
|
- [[12-MQ-消息确认与持久化]] — ACK 机制如何保证消息不丢
|
|
|
|
|
|
- [[13-MQ-Exactly-Once-语义]] — 端到端 Exactly-Once 语义的实现思路
|
|
|
|
|
|
- [[14-MQ-消息幂等性]] — 幂等消费的完整方案设计
|
|
|
|
|
|
- [[15-MQ-顺序性保障]] — 分区键如何影响顺序消费
|
|
|
|
|
|
- [[19-MQ-消息过滤与路由]] — 消息过滤与路由策略详解
|
|
|
|
|
|
- [[28-MQ-Competing-Consumers-模式]] — 竞争消费者模式深度剖析
|
2026-05-24 21:02:18 +08:00
|
|
|
|
- [[22-Kafka]] — Kafka 架构、存储与生态深度剖析
|
|
|
|
|
|
- [[23-RabbitMQ]] — RabbitMQ 的 Exchange 类型、高级特性与集群模式
|
|
|
|
|
|
- [[24-RocketMQ]] — RocketMQ 事务消息、延迟消息与 5.0 新特性
|
|
|
|
|
|
- [[27-MQ-选型对比]] — 七大维度横向对比各主流 MQ
|