Files
cs-note/hhs/MQ/05-可靠性保障/13-MQ-Exactly-Once-语义.md
T
2026-05-24 20:51:06 +08:00

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