8.7 KiB
tags, create time
| tags | 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 的难度,先看三个分布式系统的"铁律":
- 网络不可靠:消息可能丢失、延迟、乱序。Producer 发送后收不到 ACK,不知道 Broker 到底有没有收到。
- 进程可能宕机:Consumer 处理完消息但还没来得及 ACK,崩溃重启后消息被重新投递。
- 没有全局时钟:分布式系统中无法通过时间戳判断消息是否重复。
这三条合在一起意味着:任何一跳的确认都可能丢失,而发送方无法区分"对方没收到"和"对方收到了但 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 <= 已知最大值,说明是重复消息,直接丢弃。
// 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 的基础上,增加了跨分区原子写入的能力。核心思路:
- Producer 开启事务,获取一个 Transaction ID。
- 向多个 Topic/Partition 发送消息,这些消息暂时对外不可见(标记为"未提交")。
- Producer 发送 Commit 标记,Broker 将所有未提交的消息一次性对外可见。
- 如果 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 提交和业务写入放在同一个事务中(需要目标数据库支持事务)。但这要求:
- 业务系统的目标存储支持事务。
- Consumer 的消费逻辑和 Offset 提交在同一个事务中完成。
大多数场景下,这个条件不满足。所以最务实的方案是:Consumer 端做业务幂等。无论消息被投递几次,业务处理的结果都一样。
Kafka Exactly-Once 实现详解
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 事务性生产者
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 就够了。