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
|
|
|
|
|
|
subgraph "Queue Model"
|
|
|
|
|
|
P1["Producer"] --> Q1["Queue"]
|
|
|
|
|
|
Q1 --> C1["Consumer A"]
|
|
|
|
|
|
Q1 --> C2["Consumer B"]
|
|
|
|
|
|
Q1 --> C3["Consumer C"]
|
|
|
|
|
|
end
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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 机制详解
|
|
|
|
|
|
|
|
|
|
|
|
**为什么需要 Consumer Group?**
|
|
|
|
|
|
|
|
|
|
|
|
假设一个 Topic 有 100 万条消息需要处理,单个消费者处理太慢。你需要多个消费者并行消费,但又不想同一条消息被重复处理——Consumer Group 就是为此而生的。
|
|
|
|
|
|
|
|
|
|
|
|
同一个 Consumer Group 内的消费者**分摊**消息(每条消息只被组内一个消费者处理),不同 Consumer Group **各自独立**消费全量消息。
|
|
|
|
|
|
|
|
|
|
|
|
**如何分配?**
|
|
|
|
|
|
|
|
|
|
|
|
以 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**:按范围连续分配,简单但容易不均衡
|
|
|
|
|
|
- **RoundRobin**:轮询分配,更均匀但要求 Topic 数一致
|
|
|
|
|
|
- **Sticky**:粘性分配,Rebalance 时尽量保持原有分配,减少不必要的重分配
|
|
|
|
|
|
|
|
|
|
|
|
**Rebalance 触发条件**
|
|
|
|
|
|
|
|
|
|
|
|
当以下事件发生时,Consumer Group 会触发 Rebalance(重新分配 Partition):
|
|
|
|
|
|
1. 新消费者加入组
|
|
|
|
|
|
2. 消费者离开组(主动退出或心跳超时)
|
|
|
|
|
|
3. Topic 的 Partition 数量变化
|
|
|
|
|
|
|
|
|
|
|
|
Rebalance 期间所有消费者暂停消费,因此**Rebalance 的速度和频率直接影响系统可用性**。
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> 如果一个消费者处理消息特别慢,导致心跳超时被踢出组,会触发 Rebalance。Rebalance 又会导致更多消息积压——这是一个恶性循环,你有什么解决思路?
|
|
|
|
|
|
|
|
|
|
|
|
### Push 模式 vs Pull 模式
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | Push 模式 | Pull 模式 |
|
|
|
|
|
|
|------|----------|----------|
|
|
|
|
|
|
| 实时性 | 高,消息到达即推送 | 取决于拉取频率 |
|
|
|
|
|
|
| 消费者压力 | 被动接收,容易被打爆 | 主动拉取,按自身能力控制速率 |
|
|
|
|
|
|
| 吞吐量 | 低(频繁推送开销大) | 高(批量拉取) |
|
|
|
|
|
|
| 实现复杂度 | Broker 端复杂(需维护推送状态) | Consumer 端复杂(需实现拉取循环) |
|
|
|
|
|
|
| 典型代表 | RabbitMQ | Kafka |
|
|
|
|
|
|
|
|
|
|
|
|
Push 模式就像外卖送到家门口——方便,但高峰期外卖员不停按门铃你也会崩溃。Pull 模式就像自己去快递柜取件——按自己的时间来,但需要你主动去"查"。
|
|
|
|
|
|
|
|
|
|
|
|
### 主流 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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
一张消息进入 Exchange 后,可以被路由到多个 Queue——这意味着 RabbitMQ 天然支持发布订阅,不需要额外的 Consumer Group 概念。
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
#### RocketMQ:ConsumerGroup + Pull
|
|
|
|
|
|
|
|
|
|
|
|
RocketMQ 的模型介于 Kafka 和 RabbitMQ 之间:
|
|
|
|
|
|
- 采用 ConsumerGroup 概念,与 Kafka 类似
|
|
|
|
|
|
- 默认使用长轮询 Pull 模式(Push 的底层实现其实是 Pull 封装)
|
2026-05-24 21:02:18 +08:00
|
|
|
|
- 支持集群消费(Clustering)和广播消费(Broadcasting)两种模式
|
2026-05-24 20:51:06 +08:00
|
|
|
|
- 支持顺序消费和延迟消息等高级特性
|
|
|
|
|
|
|
2026-05-24 21:02:18 +08:00
|
|
|
|
```go
|
|
|
|
|
|
// RocketMQ 消费者: 集群消费模式
|
|
|
|
|
|
// 同一 ConsumerGroup 内的实例分摊消息
|
|
|
|
|
|
c, _ := rocketmq.NewPushConsumer(
|
|
|
|
|
|
consumer.WithGroupName("order-service-group"),
|
|
|
|
|
|
consumer.WithNameServer([]string{"127.0.0.1:9876"}),
|
|
|
|
|
|
)
|
|
|
|
|
|
// 订阅 Topic, * 表示不过滤 Tag
|
|
|
|
|
|
c.Subscribe("order_created", consumer.MessageSelector{},
|
|
|
|
|
|
func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
|
|
|
|
|
|
for _, msg := range msgs {
|
|
|
|
|
|
processOrder(msg.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
return consumer.ConsumeSuccess, nil
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
c.Start()
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question]
|
|
|
|
|
|
> RocketMQ 同时支持集群消费和广播消费——什么时候该用广播模式?想想"本地缓存刷新"这个场景。
|
|
|
|
|
|
|
|
|
|
|
|
#### 三大 MQ 消息模型速查
|
|
|
|
|
|
|
|
|
|
|
|
| 维度 | Kafka | RabbitMQ | RocketMQ |
|
|
|
|
|
|
|------|-------|----------|----------|
|
|
|
|
|
|
| 基本模型 | 发布订阅(Consumer Group) | 发布订阅(Exchange + Queue) | 发布订阅(Consumer Group) |
|
|
|
|
|
|
| 消费模式 | Pull(长轮询) | Push(Broker 推送) | Pull(长轮询,Push 是封装) |
|
|
|
|
|
|
| 并行单元 | Partition | Queue | MessageQueue |
|
|
|
|
|
|
| 分组机制 | Consumer Group | 无原生分组(用多个 Queue 模拟) | ConsumerGroup |
|
|
|
|
|
|
| 路由能力 | 基于 Partition Key | 极强(Exchange 四种类型) | 基于 Tag / SQL92 过滤 |
|
|
|
|
|
|
| 消息回溯 | 支持(Offset 重置) | 不支持(消费即删除) | 支持(按时间戳回溯) |
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip]
|
|
|
|
|
|
> 选型时别只看吞吐量——**路由灵活性**、**消息回溯**、**消费模式**这些维度往往更影响日常开发体验。详见 [[27-MQ-选型对比]]。
|
|
|
|
|
|
|
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-05-24 21:02:18 +08:00
|
|
|
|
- [[1-MQ-基础概念]] — MQ 核心术语与基础架构
|
|
|
|
|
|
- [[2-MQ-适用场景与选型原则]] — 何时需要 MQ、选型决策框架
|
|
|
|
|
|
- [[22-Kafka]] — Kafka 架构、存储与生态深度剖析
|
|
|
|
|
|
- [[23-RabbitMQ]] — RabbitMQ 的 Exchange 类型、高级特性与集群模式
|
|
|
|
|
|
- [[24-RocketMQ]] — RocketMQ 事务消息、延迟消息与 5.0 新特性
|
|
|
|
|
|
- [[27-MQ-选型对比]] — 七大维度横向对比各主流 MQ
|
|
|
|
|
|
- [[12-MQ-消息确认与持久化]] — ACK 机制如何保证消息不丢
|