179 lines
8.7 KiB
Markdown
179 lines
8.7 KiB
Markdown
---
|
||
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]]
|