Files
cs-note/hhs/MQ/02-消息模型/3-MQ-消息模型.md
T
2026-05-24 21:02:18 +08:00

294 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- MQ
- 消息模型
- ConsumerGroup
- 发布订阅
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
```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 概念,它怎么实现"同一条消息在组内只消费一次"的语义?
#### RocketMQ:ConsumerGroup + Pull
RocketMQ 的模型介于 Kafka 和 RabbitMQ 之间:
- 采用 ConsumerGroup 概念,与 Kafka 类似
- 默认使用长轮询 Pull 模式(Push 的底层实现其实是 Pull 封装)
- 支持集群消费(Clustering)和广播消费(Broadcasting)两种模式
- 支持顺序消费和延迟消息等高级特性
```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-选型对比]]。
### 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)**弥补了这个短板:消费者发起拉取请求,如果没有新消息就阻塞等待,直到有消息到达或超时。
## 关联笔记
- [[1-MQ-基础概念]] — MQ 核心术语与基础架构
- [[2-MQ-适用场景与选型原则]] — 何时需要 MQ、选型决策框架
- [[22-Kafka]] — Kafka 架构、存储与生态深度剖析
- [[23-RabbitMQ]] — RabbitMQ 的 Exchange 类型、高级特性与集群模式
- [[24-RocketMQ]] — RocketMQ 事务消息、延迟消息与 5.0 新特性
- [[27-MQ-选型对比]] — 七大维度横向对比各主流 MQ
- [[12-MQ-消息确认与持久化]] — ACK 机制如何保证消息不丢