vault backup: 2026-05-24 20:51:06
This commit is contained in:
@@ -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-顺序性保障]]
|
||||
Reference in New Issue
Block a user