182 lines
5.6 KiB
Markdown
182 lines
5.6 KiB
Markdown
|
|
---
|
||
|
|
tags:
|
||
|
|
- MQ
|
||
|
|
create time: 2026-05-24 19:52
|
||
|
|
---
|
||
|
|
|
||
|
|
# MQ 顺序性保障
|
||
|
|
|
||
|
|
## 概述
|
||
|
|
|
||
|
|
消息队列中的顺序性是分布式系统设计的经典难题。本文深入分析消息乱序的根源,对比全局有序与分区有序的适用场景,并详解 Kafka、RocketMQ 等主流 MQ 的顺序保障机制。
|
||
|
|
|
||
|
|
## 正文
|
||
|
|
|
||
|
|
### 为什么消息会乱序
|
||
|
|
|
||
|
|
在分布式消息系统中,消息乱序几乎是个"天然"现象,根源在于三个层面:
|
||
|
|
|
||
|
|
**多 Partition 并行**:消息被分散到多个分区,每个分区独立消费,跨分区的顺序自然无法保证。
|
||
|
|
|
||
|
|
**多 Consumer 并发**:同一消费组内多个消费者并行拉取消息,处理速度不同,先收到的消息可能后处理完。
|
||
|
|
|
||
|
|
**重试机制干扰**:消费失败后重试,重试的消息可能在后续消息处理完之后才被再次消费。
|
||
|
|
|
||
|
|
> [!question]
|
||
|
|
> 既然乱序几乎不可避免,那什么场景下我们才真正需要严格有序?付出的代价值得吗?
|
||
|
|
|
||
|
|
### 全局有序 vs 分区有序
|
||
|
|
|
||
|
|
**全局有序**要求所有消息严格按照生产顺序被消费,实现代价极大——通常只能用单分区 + 单消费者,完全牺牲了并行能力。
|
||
|
|
|
||
|
|
**分区有序**只要求同一业务维度(如同一用户、同一订单)的消息有序,不同维度之间允许乱序。这是绝大多数业务场景的合理选择。
|
||
|
|
|
||
|
|
| 维度 | 全局有序 | 分区有序 |
|
||
|
|
|------|---------|---------|
|
||
|
|
| 吞吐量 | 极低 | 高 |
|
||
|
|
| 实现复杂度 | 简单但受限 | 中等 |
|
||
|
|
| 适用场景 | 金融流水号等 | 订单状态变更等 |
|
||
|
|
|
||
|
|
### 分区有序的实现
|
||
|
|
|
||
|
|
核心思路:**相同业务 Key 路由到同一 Partition**。
|
||
|
|
|
||
|
|
生产者根据业务 Key(如用户 ID、订单 ID)计算哈希值,对分区数取模,确保同一 Key 的消息总是发到同一个分区。再配合单分区内单消费者(或顺序消费模式),就能保证该维度的消息有序。
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart LR
|
||
|
|
P["Producer"]
|
||
|
|
H["Hash by Key"]
|
||
|
|
P0["Partition 0"]
|
||
|
|
P1["Partition 1"]
|
||
|
|
P2["Partition 2"]
|
||
|
|
C0["Consumer 0"]
|
||
|
|
C1["Consumer 1"]
|
||
|
|
C2["Consumer 2"]
|
||
|
|
|
||
|
|
P --> H
|
||
|
|
H -->|"Key = OrderA"| P0
|
||
|
|
H -->|"Key = OrderB"| P1
|
||
|
|
H -->|"Key = OrderC"| P2
|
||
|
|
P0 --> C0
|
||
|
|
P1 --> C1
|
||
|
|
P2 --> C2
|
||
|
|
```
|
||
|
|
|
||
|
|
### Kafka 的顺序保障
|
||
|
|
|
||
|
|
Kafka 的顺序性建立在两个基础之上:
|
||
|
|
|
||
|
|
1. **Partition 内有序**:单个 Partition 内的消息严格按写入顺序分配偏移量,消费时也按偏移量顺序拉取。
|
||
|
|
2. **单 Partition 单 Consumer**:同一消费组内,一个 Partition 只能被一个 Consumer 消费。
|
||
|
|
|
||
|
|
要实现分区有序,生产者需要指定分区策略:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// 按订单 ID 路由到固定 Partition
|
||
|
|
func partitionByOrderID(key []byte, numPartitions int32) int32 {
|
||
|
|
hash := fnv.New32a()
|
||
|
|
hash.Write(key)
|
||
|
|
return int32(hash.Sum32()) % numPartitions
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生产者发送时指定 Key
|
||
|
|
msg := &sarama.ProducerMessage{
|
||
|
|
Topic: "order-events",
|
||
|
|
Key: sarama.StringEncoder(orderID), // 相同 orderID 路由到同一 Partition
|
||
|
|
Value: sarama.StringEncoder(payload),
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
这段代码利用 FNV 哈希将订单 ID 映射到固定分区,确保同一订单的所有事件(创建、支付、发货)都进入同一个 Partition,从而保证消费顺序。
|
||
|
|
|
||
|
|
> [!question]
|
||
|
|
> Kafka 的 Partition 数量在创建 Topic 时确定,如果后期需要扩容 Partition,已经按 Key 路由的消息会怎样?
|
||
|
|
|
||
|
|
### RocketMQ 的顺序保障
|
||
|
|
|
||
|
|
RocketMQ 提供了更显式的顺序消费 API:
|
||
|
|
|
||
|
|
**生产者**通过 `MessageQueueSelector` 自定义路由逻辑:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// 自定义选择器:按订单 ID 选择队列
|
||
|
|
selector := func(mqs []MessageQueue, msg Message, arg interface{}) MessageQueue {
|
||
|
|
orderID := arg.(string)
|
||
|
|
hash := hashCode(orderID)
|
||
|
|
index := hash % len(mqs)
|
||
|
|
return mqs[index]
|
||
|
|
}
|
||
|
|
|
||
|
|
// 发送顺序消息
|
||
|
|
err := producer.SendOneWay(ctx, msg, selector, orderID)
|
||
|
|
```
|
||
|
|
|
||
|
|
**消费者**启用顺序消费模式:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// 顺序消费模式:同一队列串行消费
|
||
|
|
consumer, _ := rocketmq.NewPushConsumer(
|
||
|
|
consumer.WithGroupName("order-group"),
|
||
|
|
consumer.WithConsumeOrderly(true), // 关键:启用顺序消费
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
RocketMQ 的顺序消费模式保证同一 MessageQueue 内的消息严格按顺序处理,配合 `MessageQueueSelector` 实现分区有序。
|
||
|
|
|
||
|
|
### 多消费者场景下的顺序挑战
|
||
|
|
|
||
|
|
当消费逻辑需要并行处理时,保序变得更加棘手。常见方案是在消费者内部引入**内存队列 + 分发器**:
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
flowchart TD
|
||
|
|
MQ["MessageQueue"]
|
||
|
|
D["Dispatcher"]
|
||
|
|
Q1["Memory Queue - Key A"]
|
||
|
|
Q2["Memory Queue - Key B"]
|
||
|
|
W1["Worker Goroutine A"]
|
||
|
|
W2["Worker Goroutine B"]
|
||
|
|
|
||
|
|
MQ --> D
|
||
|
|
D -->|"Key = A"| Q1
|
||
|
|
D -->|"Key = B"| Q2
|
||
|
|
Q1 --> W1
|
||
|
|
Q2 --> W2
|
||
|
|
```
|
||
|
|
|
||
|
|
消费者拉取到消息后,根据业务 Key 分发到不同的内存队列,每个队列由独立的 Worker 串行处理。这样既保证了同一 Key 的消息有序,又能充分利用多核并行。
|
||
|
|
|
||
|
|
Go 代码实现思路:
|
||
|
|
|
||
|
|
```go
|
||
|
|
type OrderedConsumer struct {
|
||
|
|
queues map[string]chan Message // Key -> 内存队列
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *OrderedConsumer) Dispatch(msg Message) {
|
||
|
|
key := msg.BusinessKey
|
||
|
|
ch, ok := c.queues[key]
|
||
|
|
if !ok {
|
||
|
|
ch = make(chan Message, 100)
|
||
|
|
c.queues[key] = ch
|
||
|
|
go c.processLoop(ch) // 为新 Key 启动独立处理协程
|
||
|
|
}
|
||
|
|
ch <- msg
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *OrderedConsumer) processLoop(ch chan Message) {
|
||
|
|
for msg := range ch {
|
||
|
|
// 串行处理,保证顺序
|
||
|
|
process(msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
> [!question]
|
||
|
|
> 如果某个 Key 的消息量突然暴增,会导致对应的内存队列积压。你会如何设计背压机制?
|
||
|
|
|
||
|
|
## 关联笔记
|
||
|
|
|
||
|
|
- [[14-MQ-消息可靠性]]
|
||
|
|
- [[16-MQ-死信队列与消息回溯]]
|