vault backup: 2026-05-24 20:51:06

This commit is contained in:
hhs
2026-05-24 20:51:06 +08:00
parent 910d16682b
commit 686c0a5bd5
52 changed files with 9246 additions and 0 deletions
@@ -0,0 +1,159 @@
---
tags: [MQ, 消息确认, 持久化, ACK, 可靠性]
create time: 2026-05-24 19:52
---
# MQ 消息确认与持久化
## 概述
消息从 Producer 发出到 Consumer 处理完成,中间经历多个环节,任何一个环节出问题都可能导致消息丢失或重复。本文从三种投递语义出发,分别剖析 Producer 端、Broker 端、Consumer 端的确认与持久化机制,帮助你理解如何在可靠性与性能之间做出合理取舍。
## 正文
### 三种投递语义
消息投递语义定义了"一条消息最多/至少/恰好被投递几次"的承诺,是所有可靠性设计的理论基础。
| 语义 | 含义 | 典型场景 | 代价 |
|------|------|----------|------|
| **At-Most-Once** | 消息最多投递一次,可能丢失 | 日志采集、监控指标上报 | 不丢就行,丢了也无所谓 |
| **At-Least-Once** | 消息至少投递一次,可能重复 | 订单创建、支付通知 | 业务端需做幂等处理 |
| **Exactly-Once** | 消息恰好投递一次 | 资金转账、库存扣减 | 实现复杂,性能开销大 |
> [!question]
> 实际工程中,At-Most-Once 和 At-Least-Once 哪个更常用?为什么 Exactly-Once 很少被端到端保证?
严格意义上的 Exactly-Once 在分布式系统中几乎不可能端到端实现。大多数 MQ 选择 At-Least-Once 作为默认语义,把去重的责任交给业务层。这样设计的原因是:网络不可靠、进程可能宕机、任何一跳的 ACK 都可能丢失——与其追求完美的一次投递,不如让每层做好自己的事。
### Producer 端确认
Producer 发送消息后,需要确认 Broker 是否成功接收。不同确认方式对应不同的可靠性级别。
**同步发送 + ACK**:Producer 发送后阻塞等待 Broker 返回 ACK。最可靠,但吞吐最低。适合对可靠性要求极高的场景,如金融交易。
**异步发送 + 回调**:Producer 发送后立即返回,通过回调函数处理结果。吞吐高,但回调可能乱序,且如果 Producer 在回调前宕机,消息可能丢失。
**重试机制**:网络抖动或 Broker 暂时不可用时,Producer 会自动重试。需要注意:重试可能导致重复消息,所以 Broker 端需要配合去重(如 Kafka 的幂等 Producer)。
```go
// 同步发送:阻塞等待 Broker 确认
msg := &sarama.ProducerMessage{
Topic: "order-events",
Value: sarama.StringEncoder(`{"order_id": "1001"}`),
}
partition, offset, err := producer.SendMessage(msg)
if err != nil {
// 发送失败,根据策略决定是否重试
log.Printf("send failed: %v", err)
}
// partition 和 offset 说明 Broker 已确认收到
log.Printf("sent to partition %d, offset %d", partition, offset)
```
同步发送的优势在于语义简单——`SendMessage` 返回成功就代表 Broker 已经持久化了这条消息。代价是每条消息都要等一次网络往返,吞吐受限于 RTT。
### Broker 端持久化
Broker 收到消息后,需要将其写入存储。这里有两个关键决策点:**刷盘策略**和**主从同步模式**。
**同步刷盘 vs 异步刷盘**:
- **同步刷盘**:消息写入 Page Cache 后立即调用 `fsync` 落盘。可靠性高,但每次写入都要等磁盘 I/O,吞吐受限于磁盘性能。
- **异步刷盘**:消息写入 Page Cache 后立即返回,由后台线程批量刷盘。吞吐极高(利用了操作系统的 Page Cache),但 Broker 突然宕机时,Page Cache 中未刷盘的消息会丢失。
**主从同步模式**:
- **同步复制**:Producer 发送的消息必须被所有(或多数)副本确认后才算写入成功。可靠但延迟更高。
- **异步复制**:主节点确认后即返回,副本异步拉取。延迟低,但主节点宕机时可能丢失尚未同步的数据。
> [!question]
> Kafka 的 ISR(In-Sync Replicas)机制是如何在同步复制和异步复制之间找到平衡的?
Kafka 的 ISR 是一个动态集合,只有与 Leader 保持同步的 Follower 才在 ISR 中。Producer 通过 `acks=all` 配置要求 ISR 中所有副本确认。如果某个 Follower 落后太多,会被踢出 ISR。这样既保证了可靠性(ISR 中的副本都有最新数据),又避免了慢副本拖累整体性能。
### Consumer 端确认
Consumer 从 Broker 拉取消息后,需要告知 Broker "我已经处理完了",这就是 Consumer 端的 ACK。
**手动 ACK vs 自动 ACK**:
- **自动 ACK**:Consumer 拉取消息后立即自动确认。实现简单,但如果 Consumer 在处理消息前宕机,消息就丢了——因为 Broker 以为它已经被消费了。
- **手动 ACK**:Consumer 处理完业务逻辑后显式调用 ACK。更可靠,但需要开发者自己管理确认时机。
**ACK 时机的选择**:
- **收到即 ACK**:拉取到消息就确认,然后在本地处理。风险等同于自动 ACK。
- **处理完再 ACK**:业务逻辑执行成功后再确认。如果处理过程中 Consumer 崩溃,消息会被重新投递(At-Least-Once)。这是生产环境的推荐做法。
### 端到端消息投递流程
下图展示了从 Producer 到 Consumer 的完整投递链路,标注了每个环节的确认方式:
```mermaid
sequenceDiagram
participant P as "Producer"
participant B as "Broker"
participant C as "Consumer"
P->>B: "发送消息"
Note right of P: "同步等待 / 异步回调 / 重试"
B->>B: "写入 Page Cache"
Note over B: "同步刷盘 / 异步刷盘"
B->>B: "主从同步复制"
Note over B: "同步复制 / 异步复制"
B-->>P: "ACK 确认"
C->>B: "拉取消息"
B-->>C: "返回消息"
C->>C: "处理业务逻辑"
Note over C: "手动 ACK / 自动 ACK"
C-->>B: "ACK 确认消费完成"
B->>B: "更新消费偏移量"
```
整个链路中,每一跳的 ACK 都可能因为网络或进程故障而丢失。这就是为什么端到端的 Exactly-Once 极难实现——你需要在每个环节都做好容错。
### Go 代码:手动 ACK 的消费者示例
```go
func consumeWithManualAck(consumer sarama.ConsumerGroup) error {
handler := &consumerGroupHandler{}
// 消费者组自动管理分区分配和 Rebalance
for {
if err := consumer.Consume(context.Background(), []string{"order-events"}, handler); err != nil {
return err
}
}
}
// consumerGroupHandler 实现 sarama.ConsumerGroupHandler 接口
type consumerGroupHandler struct{}
func (h *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *consumerGroupHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
for msg := range claim.Messages() {
// 先处理业务逻辑
if err := processOrder(msg.Value); err != nil {
log.Printf("process failed: %v, will retry", err)
continue // 不 ACK,消息会被重新投递
}
// 业务处理成功后再手动确认
sess.MarkMessage(msg, "")
}
return nil
}
```
这段代码的核心逻辑是:**先消费,再确认**。如果 `processOrder` 返回错误,我们选择不调用 `MarkMessage`,这样消息不会被标记为已消费,下次拉取时会再次投递。这就是 At-Least-Once 的典型实现——宁可重复处理,也不能丢失消息。
## 关联笔记
- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]]
- [[05-可靠性保障/14-MQ-消息幂等性|MQ 消息幂等性]]
- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]]
- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]]
- [[07-主流MQ对比/22-Kafka|Kafka]]
@@ -0,0 +1,178 @@
---
tags: [MQ, Exactly-Once, 幂等, 事务, Kafka]
create time: 2026-05-24 19:52
---
# MQ Exactly-Once 语义
## 概述
Exactly-Once 是消息投递语义中最理想也最难实现的一种。本文从"为什么难"出发,拆解 Producer、Broker、Consumer 三个环节各自的 Exactly-Once 机制,重点剖析 Kafka 的幂等 Producer 和事务 Producer 实现原理,最后解释为什么端到端的 Exactly-Once 严格意义上不可能实现。
## 正文
### Exactly-Once 为什么难
要理解 Exactly-Once 的难度,先看三个分布式系统的"铁律":
1. **网络不可靠**:消息可能丢失、延迟、乱序。Producer 发送后收不到 ACK,不知道 Broker 到底有没有收到。
2. **进程可能宕机**:Consumer 处理完消息但还没来得及 ACK,崩溃重启后消息被重新投递。
3. **没有全局时钟**:分布式系统中无法通过时间戳判断消息是否重复。
这三条合在一起意味着:**任何一跳的确认都可能丢失,而发送方无法区分"对方没收到"和"对方收到了但 ACK 丢了"**。后者会导致重试,重试导致重复。
> [!question]
> 如果网络永远可靠、进程永不宕机,还需要 Exactly-Once 吗?
不需要。Exactly-Once 的所有复杂性都来自于对故障的容忍。在理想环境下,At-Most-Once 就够了。但现实是分布式系统必须面对故障,所以我们才需要在各个层面做额外的工作。
### 端到端 vs 单环节 Exactly-Once
这是一个非常重要的区分:
- **单环节 Exactly-Once**:在某一个环节(如 Producer→Broker)保证消息不丢不重。这是 MQ 可以做到的。
- **端到端 Exactly-Once**:从 Producer 发送到 Consumer 处理完成,整条链路保证恰好一次。这几乎不可能由 MQ 单独实现。
为什么端到端做不到?因为 Consumer 处理消息涉及外部系统(数据库、API、文件系统等),MQ 无法控制这些外部操作的原子性。消息确认和业务处理是两个独立的操作,无法在一个事务中完成。
所以实践中常说的"Exactly-Once",通常是:**MQ 保证 Producer→Broker 不重复 + Consumer 端业务幂等** = 业务层面的效果等价于 Exactly-Once。
### Producer 端:幂等与事务
#### 幂等 Producer
Kafka 0.11 引入了幂等 Producer,核心机制是 **PID(Producer ID)+ SequenceNumber**。
每个 Producer 实例启动时会被分配一个唯一的 PID。每条消息携带一个单调递增的 SequenceNumber。Broker 端为每个 `<PID, Partition>` 维护一个最近收到的 SequenceNumber。如果收到的消息 SequenceNumber <= 已知最大值,说明是重复消息,直接丢弃。
```go
// Kafka Producer 开启幂等模式(sarama 配置)
config := sarama.NewConfig()
config.Producer.Idempotent = true // 开启幂等
config.Net.MaxOpenRequests = 1 // 幂等要求同一时刻只有一个未确认请求
config.Producer.RequiredAcks = sarama.WaitForAll // 必须所有 ISR 副本确认
config.Producer.Return.Successes = true
producer, err := sarama.NewSyncProducer(brokers, config)
```
幂等 Producer 解决的是**单分区、单会话**内的重复问题。Producer 重启后 PID 会变,跨会话的重复无法处理。跨分区的原子写入也需要更高级的机制。
#### 事务 Producer
事务 Producer 在幂等 Producer 的基础上,增加了**跨分区原子写入**的能力。核心思路:
1. Producer 开启事务,获取一个 Transaction ID。
2. 向多个 Topic/Partition 发送消息,这些消息暂时对外不可见(标记为"未提交")。
3. Producer 发送 Commit 标记,Broker 将所有未提交的消息一次性对外可见。
4. 如果 Producer 在 Commit 前崩溃,Broker 会根据 Transaction ID 回滚未完成的事务。
> [!question]
> 事务 Producer 的 Transaction ID 和 PID 有什么区别?为什么需要两个标识?
PID 是 Broker 分配的,Producer 重启后会变,用于幂等去重。Transaction ID 是用户配置的,跨会话保持不变,用于标识"同一个业务事务"。当一个 Producer 以相同的 Transaction ID 重启时,Broker 会主动终止上一个未完成的事务(通过 Epoch 机制),避免僵尸事务。
### Broker 端:去重与事务日志
Broker 在 Exactly-Once 中扮演关键角色:
**去重机制**:基于 PID + SequenceNumber 的幂等去重,确保同一条消息不会被写入两次。Broker 为每个 `<PID, Partition>` 维护状态,定期清理过期数据。
**事务日志**:Kafka 内部有一个特殊的 Topic `__transaction_state`,记录所有事务的状态(Prepare、Commit、Abort)。即使 Broker 重启,也能从事务日志恢复事务状态,保证事务的持久性。
### Consumer 端:为什么只能靠业务幂等
Consumer 端的 Exactly-Once 面临的根本问题是:**消费和确认是两个独立操作,无法原子化**。
考虑这个场景:Consumer 拉取消息 → 写入数据库 → 提交 Offset。如果在"写入数据库"和"提交 Offset"之间崩溃,重启后会重复消费。
Kafka 提供了 **Consumer Offset 事务化** 的能力——Consumer 可以将 Offset 提交和业务写入放在同一个事务中(需要目标数据库支持事务)。但这要求:
1. 业务系统的目标存储支持事务。
2. Consumer 的消费逻辑和 Offset 提交在同一个事务中完成。
大多数场景下,这个条件不满足。所以最务实的方案是:**Consumer 端做业务幂等**。无论消息被投递几次,业务处理的结果都一样。
### Kafka Exactly-Once 实现详解
```mermaid
flowchart TD
A["Producer 启动"] --> B["获取 PID"]
B --> C["开启事务"]
C --> D["发送消息到多个 Partition"]
D --> E["Broker 写入消息"]
E --> F{"消息是否重复?"}
F -->|"是"| G["Broker 丢弃重复消息"]
F -->|"否"| H["写入 Partition Log"]
H --> I["发送 Commit 标记"]
I --> J["Broker 写入事务日志"]
J --> K["消息对外可见"]
K --> L["Consumer 拉取消息"]
L --> M["处理业务逻辑"]
M --> N["业务幂等校验"]
N --> O{"是否已处理过?"}
O -->|"是"| P["跳过,提交 Offset"]
O -->|"否"| Q["执行业务操作"]
Q --> P
```
这个流程展示了 Kafka 的 Exactly-Once 如何在各环节协同工作:Producer 端通过幂等 + 事务保证不重复写入,Broker 端通过去重 + 事务日志保证存储层一致性,Consumer 端通过业务幂等保证最终效果。
### Go 代码:Kafka 事务性生产者
```go
func transactionalProducer() error {
config := sarama.NewConfig()
config.Producer.Idempotent = true
config.Producer.Transaction.ID = "order-service-tx-001" // Transaction ID
config.Producer.RequiredAcks = sarama.WaitForAll
config.Net.MaxOpenRequests = 1
producer, err := sarama.NewSyncProducer(brokers, config)
if err != nil {
return err
}
defer producer.Close()
// 开启事务
if err := producer.BeginTxn(); err != nil {
return fmt.Errorf("begin txn: %w", err)
}
// 在事务中发送多条消息(原子操作)
msgs := []*sarama.ProducerMessage{
{Topic: "order-events", Value: sarama.StringEncoder(`{"order_id": "1001"}`)},
{Topic: "inventory-events", Value: sarama.StringEncoder(`{"sku": "A001", "delta": -1}`)},
}
for _, msg := range msgs {
if _, _, err := producer.SendMessage(msg); err != nil {
// 发送失败,回滚整个事务
_ = producer.AbortTxn()
return fmt.Errorf("send failed, txn aborted: %w", err)
}
}
// 所有消息发送成功,提交事务
if err := producer.CommitTxn(); err != nil {
return fmt.Errorf("commit txn: %w", err)
}
return nil
}
```
这段代码的关键在于:`BeginTxn` 和 `CommitTxn` 之间的所有消息要么全部可见,要么全部不可见。如果中间任何一条消息发送失败,`AbortTxn` 会回滚整个事务。这保证了跨 Topic/Partition 的原子写入。
> [!question]
> 事务 Producer 的性能比普通 Producer 差多少?在什么场景下值得使用?
事务 Producer 的性能开销主要来自:事务协调器的额外网络往返、事务日志的写入、未提交消息的暂存。通常吞吐会下降 10%-30%。如果你的业务需要跨 Topic/Partition 的原子写入(如同时更新订单和库存),值得使用。如果只是单 Partition 写入,幂等 Producer 就够了。
## 关联笔记
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
- [[05-可靠性保障/14-MQ-消息幂等性|MQ 消息幂等性]]
- [[06-高级特性/18-MQ-事务消息|MQ 事务消息]]
- [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]]
- [[07-主流MQ对比/22-Kafka|Kafka]]
@@ -0,0 +1,160 @@
---
tags: [MQ, 幂等性, 去重, 分布式系统]
create time: 2026-05-24 19:52
---
# MQ 消息幂等性
## 概述
消息重复是分布式系统的常态,而不是异常。无论是网络超时重试、Consumer Rebalance 还是 Producer 重试,都可能产生重复消息。幂等性保证"执行多次与执行一次效果相同",是处理重复消息的核心手段。本文介绍四种常见的幂等实现方案,并给出适用场景对比。
## 正文
### 为什么会产生重复消息
在讨论幂等方案之前,先搞清楚重复消息的来源。知己知彼,才能对症下药。
**网络超时重试**:Producer 发送消息后等待 ACK 超时,不确定 Broker 是否收到,于是重试。如果 Broker 其实已经收到了第一条,重试就会产生重复。这是最常见的重复来源。
**Consumer Rebalance**:Consumer Group 发生 Rebalance 时(如消费者加入或退出),分区会被重新分配。旧 Consumer 可能已经拉取了消息但还没来得及提交 Offset,新 Consumer 又会重新拉取同一批消息。
**Producer 重试**:Broker 返回一个可重试的错误(如 NotLeaderForPartition),Producer 自动重试发送。如果上一次请求其实已经成功写入,重试就会重复。
> [!question]
> 能不能通过"先查询再处理"的方式避免重复?比如先查数据库,如果订单已存在就跳过?
这种方案存在经典的 **TOCTOU(Time of Check to Time of Use)** 问题:查询时订单不存在,但在你插入之前,另一个重复消息刚好完成了插入。两个消费者都认为订单不存在,都执行了插入操作。所以"先查询再处理"不能保证幂等,必须借助原子性的去重机制。
### 幂等的定义
幂等(Idempotent)的数学定义是:**f(f(x)) = f(x)**。应用到消息处理中,意味着同一条消息被消费一次和被消费 N 次的结果完全一样。
注意幂等不等于"不处理"。幂等是"处理了但效果只生效一次"。比如扣减库存,幂等的做法是记录已处理的消息 ID,重复消息直接跳过扣减逻辑。
### 方案一:唯一 ID + 去重表
最直观的方案:每条消息携带一个全局唯一的 MessageID,消费前先查去重表,如果已存在则跳过。
**Redis Set 实现**:将 MessageID 写入 Redis Set,利用 `SADD` 的原子性实现"检查 + 写入"一步完成。
**数据库唯一索引**:在去重表上对 MessageID 建唯一索引,插入重复记录时会触发唯一约束冲突,直接跳过。
两种实现各有优劣:Redis 性能高但需要处理过期策略;数据库可靠但吞吐受限于磁盘 I/O。
```go
// 基于 Redis 的消息去重实现
func processWithDedup(rdb *redis.Client, msg *Message) error {
dedupKey := fmt.Sprintf("mq:dedup:%s", msg.ID)
// SADD 原子操作:如果 key 不存在则添加并返回 1,已存在则返回 0
added, err := rdb.SAdd(context.Background(), dedupKey, "1").Result()
if err != nil {
return fmt.Errorf("redis sadd: %w", err)
}
if added == 0 {
// 已经处理过,直接跳过
log.Printf("duplicate message %s, skipped", msg.ID)
return nil
}
// 设置过期时间,避免 Redis 内存无限增长
rdb.Expire(context.Background(), dedupKey, 24*time.Hour)
// 执行业务逻辑
if err := processOrder(msg); err != nil {
// 业务处理失败,删除去重标记,允许重试
rdb.Del(context.Background(), dedupKey)
return err
}
return nil
}
```
这段代码利用了 Redis `SADD` 的原子性,把"检查是否重复"和"标记已处理"合成一个操作,避免了 TOCTOU 问题。注意业务处理失败时要删除去重标记,否则消息就永远无法被重试了。
### 方案二:乐观锁(版本号控制)
适用于"更新已有数据"的场景。每条记录带一个版本号,更新时带上版本号条件:
```sql
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE sku = 'A001' AND version = 5;
```
如果版本号不匹配(说明已经被其他消息更新过),`affected_rows` 返回 0,业务层判定为重复操作。
这种方案不需要额外的去重表,利用了数据库自身的并发控制能力。适合库存扣减、账户余额变更等场景。
> [!question]
> 乐观锁方案能处理"插入"类操作吗?比如创建订单?
不太适合。乐观锁天然适合"更新"场景,因为更新时已有记录和版本号。对于"插入"类操作,还是用唯一 ID + 去重表更直接。当然你也可以用 `INSERT ... ON CONFLICT DO NOTHING`(PostgreSQL)来实现插入幂等。
### 方案三:Token 机制
Token 机制的核心思路是:**先获取令牌,再执行操作**。
1. Consumer 处理消息前,先向 Token 服务申请一个全局唯一的 Token。
2. 消费者带着 Token 执行业务操作。
3. 业务服务端校验 Token 是否有效,有效则执行并销毁 Token,无效则拒绝。
Token 服务保证每个 Token 只能使用一次,天然实现了幂等。这种方案适合跨系统的幂等控制,比如下游 API 的幂等调用。
缺点是多了一次网络调用获取 Token,且 Token 服务本身需要高可用。
### 方案四:状态机约束
很多业务天然具有状态流转特性,利用状态机的单向性可以实现幂等。
比如订单状态:`待支付 → 已支付 → 已发货 → 已完成`。消费"支付成功"消息时,执行:
```sql
UPDATE orders SET status = '已支付'
WHERE order_id = '1001' AND status = '待支付';
```
如果订单已经是"已支付"状态,`affected_rows` 为 0,自然幂等。不需要额外的去重表,业务逻辑和幂等校验融为一体。
```mermaid
flowchart TD
A["收到消息"] --> B["提取消息 MessageID"]
B --> C["查询去重表"]
C --> D{"是否已处理?"}
D -->|"是"| E["跳过,记录日志"]
D -->|"否"| F["执行业务逻辑"]
F --> G{"业务执行成功?"}
G -->|"是"| H["写入去重表"]
H --> I["提交消费 Offset"]
G -->|"否"| J["不写去重表"]
J --> K["等待重试"]
```
### 各方案适用场景对比
| 方案 | 实现复杂度 | 性能影响 | 适用场景 | 局限性 |
|------|-----------|---------|----------|--------|
| 唯一 ID + 去重表 | 低 | 低(Redis)/ 中(DB) | 通用场景,最常用 | 需要额外存储,需处理过期清理 |
| 乐观锁 | 低 | 低 | 更新类操作(库存、余额) | 不适合插入操作,存在 ABA 问题 |
| Token 机制 | 高 | 中(额外网络调用) | 跨系统 API 幂等 | Token 服务需高可用 |
| 状态机约束 | 低 | 低 | 有明确状态流转的业务 | 业务模型必须支持状态机 |
实际项目中,这几种方案往往会组合使用。比如:订单系统用状态机约束核心流转,同时用唯一 ID + 去重表作为兜底。没有银弹,关键是理解每种方案的适用边界。
> [!question]
> 如果消息体本身很大,用 Redis Set 存 MessageID 会不会有内存问题?怎么优化?
Redis Set 存的是 MessageID(通常是 36 字符的 UUID),不是消息体本身,所以内存占用可控。但如果消息量极大(每天数亿条),可以考虑以下优化:
1. **Bloom Filter**:用极小的内存(每元素几个 bit)判断消息"可能存在"或"一定不存在"。存在误判(false positive),但不会漏判。适合作为第一层快速过滤。
2. **分片 + 过期**:按日期分 Key(如 `mq:dedup:2026-05-24:{msgID}`),过期后自动清理。
3. **数据库归档**:Redis 只保留近期去重数据,历史数据归档到数据库。去重窗口通常只需要几天到一周。
## 关联笔记
- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]]
- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]]
- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]]
- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]]
@@ -0,0 +1,181 @@
---
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-死信队列与消息回溯]]
@@ -0,0 +1,140 @@
---
tags:
- MQ
create time: 2026-05-24 19:52
---
# 死信队列与消息回溯
## 概述
死信队列(DLQ)是消息消费失败后的"兜底方案",消息回溯则是重新处理历史数据的能力。本文详解死信队列的产生机制、各主流 MQ 的实现差异,以及消息回溯的实践策略。
## 正文
### 死信队列的产生原因
消息进入死信队列通常有三种触发条件:
**消费失败超过最大重试次数**:这是最常见的情况。消费者处理消息时抛出异常,经过 N 次重试后仍然失败,消息被转移到死信队列。
**消息过期(TTL)**:消息在队列中等待时间超过设定的 TTL(Time To Live),始终未被消费,成为"死信"。
**队列容量满**:队列达到最大长度限制,新消息无法入队,部分消息可能被丢弃或转入死信队列。
> [!question]
> 消费失败和消费超时是同一种情况吗?在设计重试策略时需要区别对待吗?
### 死信消息的特征与处理策略
死信消息有几个显著特征:**携带原始元数据**(生产时间、重试次数、失败原因)、**语义已不确定**(不知道业务状态是否已被部分修改)、**需要人工判断**。
处理策略通常有三种:
1. **人工介入排查**:最常见的做法。运维或开发人员分析失败原因,修复问题后手动重发。
2. **自动告警 + 限流处理**:当死信消息数量超过阈值时触发告警,同时对死信队列的消费进行限流,避免雪崩。
3. **转存到其他系统**:将死信消息写入数据库或 ES,便于后续分析和批量重处理。
```mermaid
flowchart TD
P["Producer"]
Q["Normal Queue"]
C["Consumer"]
DLQ["Dead Letter Queue"]
R["Retry"]
A["Alert System"]
DB["Database / ES"]
H["Human Intervention"]
P --> Q
Q --> C
C -->|"Success"| ACK["ACK"]
C -->|"Fail"| R
R -->|"Retry < Max"| C
R -->|"Retry >= Max"| DLQ
DLQ --> A
DLQ --> DB
DLQ --> H
```
### 各主流 MQ 的 DLQ 实现
**RabbitMQ**:通过 `x-dead-letter-exchange` 和 `x-dead-letter-routing-key` 属性配置。当消息被拒绝(reject/nack)且 `requeue=false` 时,自动路由到指定的死信交换机。
```go
// 声明带死信配置的队列
args := amqp.Table{
"x-dead-letter-exchange": "dlx-exchange",
"x-dead-letter-routing-key": "dlq-routing-key",
"x-message-ttl": 30000, // 30 秒 TTL
}
ch.QueueDeclare("order-queue", true, false, false, false, args)
```
**RocketMQ**:内置 `%DLQ%+ConsumerGroup` 命名的死信 Topic。消费失败超过最大重试次数(默认 16 次)后自动进入,无需额外配置。
**Kafka**:没有原生 DLQ 机制,需要自行实现。常见的做法是在消费者 catch 异常后,将失败消息发送到专门的死信 Topic。
### 消息重试机制
合理的重试策略是减少死信消息的关键。核心原则是**间隔递增 + 次数上限**:
```go
func retryDelay(attempt int) time.Duration {
// 指数退避:1s, 2s, 4s, 8s...
base := time.Second
delay := base * time.Duration(1<<uint(attempt))
if delay > 5*time.Minute {
delay = 5 * time.Minute // 设置上限
}
return delay
}
```
为什么用指数退避?如果下游服务暂时不可用,立即重试只会加重负担。递增间隔给下游恢复的时间,也能减少无效的重试次数。
> [!question]
> 重试间隔设多长合适?太短可能压垮下游,太长又影响消息时效性。你会怎么平衡?
### 消息回溯
消息回溯是指将消费位点回退到某个历史位置,重新消费已处理过的消息。典型场景包括:**业务逻辑 Bug 修复后需要重新处理**、**数据丢失后从消息中恢复**、**新上线的消费者需要历史数据初始化**。
**按时间回溯**:Kafka 支持 `offsetsForTimes` API,可以找到指定时间戳对应的偏移量:
```go
// 回溯到 2024-01-01 00:00:00
targetTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local)
offset, err := client.GetOffset(topic, partition, targetTime.UnixMilli())
if err == nil {
// 从该偏移量开始重新消费
consumer.Seek(topic, partition, offset)
}
```
**按偏移量回溯**:直接指定要回退到的偏移量位置,精确但需要提前记录关键节点的偏移量。
**重放消费**:创建新的消费组,从头消费整个 Topic 的消息。适用于数据恢复或新消费者冷启动。
```mermaid
flowchart LR
T["Timeline"]
N["Now"]
B["Bug Introduced"]
F["Bug Fixed"]
R["Replay From B"]
T --> B
B --> F
F --> N
F -.->|"Seek to offset"| R
R -->|"Re-consume"| N
```
> [!question]
> 如果消息回溯时发现部分消息已经被下游系统消费并产生了副作用(比如扣款),你会如何处理这种"幂等性"问题?
## 关联笔记
- [[14-MQ-消息可靠性]]
- [[15-MQ-顺序性保障]]