Files
cs-note/hhs/MQ/02-消息模型/3-MQ-消息模型.md
T
2026-06-08 23:08:57 +08:00

569 lines
28 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 - Competing Consumers"
P1["Producer"] --> Q1["Queue"]
Q1 -->|"msg1"| C1["Consumer A"]
Q1 -->|"msg2"| C2["Consumer B"]
Q1 -->|"msg3"| C3["Consumer C"]
end
```
> [!note] 关键区分
> 队列模型中,每条消息**只会投递给一个消费者**(竞争消费),三个消费者分摊消息而非各自接收全部。这就是"Point-to-Point"的含义。
```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 是发布订阅模型的机制**,队列模型(Point-to-Point)中没有这个概念——消息被一个消费者消费即完成,无需分组。Consumer Group 的出现是为了在发布订阅模型中同时解决**并行消费**和**组间独立**两个问题。
**为什么需要 Consumer Group?**
假设一个 Topic 有 100 万条消息需要处理,单个消费者处理太慢。你需要多个消费者并行消费,但又不想同一条消息被重复处理——Consumer Group 就是为此而生的。
同一个 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,需要先按 Topic 分组再轮询,否则分配可能不均
- **Sticky**:粘性分配,Rebalance 时尽量保持原有分配,减少不必要的重分配
**Rebalance 触发条件**
当以下事件发生时,Consumer Group 会触发 Rebalance(重新分配 Partition):
1. 新消费者加入组
2. 消费者离开组(主动退出或心跳超时)
3. Topic 的 Partition 数量变化
Rebalance 期间所有消费者暂停消费,因此**Rebalance 的速度和频率直接影响系统可用性**。
> [!question]
> 如果一个消费者处理消息特别慢,导致心跳超时被踢出组,会触发 Rebalance。Rebalance 又会导致更多消息积压——这是一个恶性循环,你有什么解决思路?
> [!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-顺序性保障]]。
### Push 模式 vs Pull 模式
| 维度 | Push 模式 | Pull 模式 |
|------|----------|----------|
| 实时性 | 高,消息到达即推送 | 取决于拉取频率 |
| 消费者压力 | 被动接收,容易被打爆 | 主动拉取,按自身能力控制速率 |
| 吞吐量 | 中等(可通过 prefetch 批量推送) | 高(批量拉取) |
| 实现复杂度 | Broker 端复杂(需维护推送状态) | Consumer 端复杂(需实现拉取循环) |
| 典型代表 | RabbitMQ | Kafka |
Push 模式就像外卖送到家门口——方便,但高峰期外卖员不停按门铃你也会崩溃。Pull 模式就像自己去快递柜取件——按自己的时间来,但需要你主动去"查"。
> [!tip] Pull 的实时性并没有想象中那么差
> Kafka 和 RocketMQ 都采用了**长轮询(Long Polling)**来弥补 Pull 模式的实时性短板:消费者发起拉取请求后,如果没有新消息就**阻塞等待**,直到有消息到达或超时才返回。效果上接近 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 概念,它怎么实现"同一条消息在组内只消费一次"的语义?
> [!tip]- 答案
> RabbitMQ 的竞争消费(Competing Consumers)模式:多个消费者订阅**同一个 Queue**,Broker 以轮询方式将消息逐条分发给各消费者,天然保证同一条消息只被一个消费者处理。不同服务各自创建自己的 Queue 并绑定到同一个 Exchange,就能实现类似 Consumer Group 的效果——组内竞争消费、组间独立消费。
#### RocketMQ:ConsumerGroup + Pull
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 过滤。
```go
// RocketMQ 消费者: 按 Tag 过滤订单事件
c, _ := rocketmq.NewPushConsumer(
consumer.WithGroupName("order-service-group"),
consumer.WithNameServer([]string{"127.0.0.1:9876"}),
)
// 只订阅 "created" 和 "paid" 两种 Tag 的消息
c.Subscribe("order_events",
consumer.MessageSelector{
Type: consumer.TAG,
Expression: "created || paid", // Tag 过滤表达式
},
func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
for _, msg := range msgs {
tag := msg.GetTags() // 获取消息 Tag
switch tag {
case "created":
handleOrderCreated(msg)
case "paid":
handleOrderPaid(msg)
}
}
return consumer.ConsumeSuccess, nil
},
)
c.Start()
```
> [!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 管理。
> [!question]
> RocketMQ 同时支持集群消费和广播消费——什么时候该用广播模式?想想"本地缓存刷新"这个场景。
> [!tip]- 答案
> 核心区分:集群消费是**分工**——同一条消息只被组内一个实例消费;广播消费是**同步**——每个实例都收到全量消息。
>
> **为什么"本地缓存刷新"必须用广播?**
>
> 假设 3 个服务实例各自维护了一份本地缓存(商品价格、黑名单等)。当价格变更时:
> - 集群消费:消息只被其中一个实例消费 → 另外 2 个实例的缓存**还是脏数据**
> - 广播消费:3 个实例各自收到同一条消息 → **所有实例同时刷新**
>
> **广播模式的典型场景:**
>
> | 场景 | 说明 |
> |------|------|
> | 本地缓存刷新 | 价格变更、字典表更新、配置刷新 |
> | 路由表同步 | 网关实例同步最新路由规则 |
> | 降级开关下发 | 所有实例收到指令,关闭某项功能 |
> | 全量数据同步 | 每个实例本地维护一份只读数据副本 |
>
> **广播模式的代价:**
>
> - **不支持消息回溯**:offset 由各实例本地管理,消费者重启后**不会重新消费历史消息**
> - **消费失败无重试**:广播模式失败即丢失,不像集群模式最多重试 16 次
> - **新增实例的冷启动问题**:消息已广播过了,新实例缓存为空——需要额外的初始化加载(如启动时主动从 DB 全量拉取)
>
> 一句话总结:任何需要**"所有实例都感知"**的场景用广播;需要**"分工协作处理"**的场景用集群。
#### 三大 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-选型对比]]。
### 消费语义与可靠性保障
消息模型决定了"消息怎么分发",但还有一个同样重要的问题:"消息能不能只被处理一次?"
**三种消费语义:**
| 语义 | 含义 | 实现难度 | 典型场景 |
|------|------|---------|---------|
| 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]
> 死信队列里的消息应该怎么处理?直接丢弃、人工介入、还是自动修复后重新投递?
### 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)**弥补了这个短板:消费者发起拉取请求,如果没有新消息就阻塞等待,直到有消息到达或超时。
### 幂等消费:防止重复处理
无论选择哪种消息模型,重复消费都是绕不开的问题。常见原因包括:消费者处理完消息但 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]
> 幂等设计的原则:**把去重判断放在消费的第一步**,而不是处理完再判断。这样即使重复投递,处理逻辑也只执行一次。
## 关联笔记
- [[1-MQ-基础概念]] — MQ 核心术语与基础架构
- [[2-MQ-适用场景与选型原则]] — 何时需要 MQ、选型决策框架
- [[12-MQ-消息确认与持久化]] — ACK 机制如何保证消息不丢
- [[13-MQ-Exactly-Once-语义]] — 端到端 Exactly-Once 语义的实现思路
- [[14-MQ-消息幂等性]] — 幂等消费的完整方案设计
- [[15-MQ-顺序性保障]] — 分区键如何影响顺序消费
- [[19-MQ-消息过滤与路由]] — 消息过滤与路由策略详解
- [[28-MQ-Competing-Consumers-模式]] — 竞争消费者模式深度剖析
- [[22-Kafka]] — Kafka 架构、存储与生态深度剖析
- [[23-RabbitMQ]] — RabbitMQ 的 Exchange 类型、高级特性与集群模式
- [[24-RocketMQ]] — RocketMQ 事务消息、延迟消息与 5.0 新特性
- [[27-MQ-选型对比]] — 七大维度横向对比各主流 MQ