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

8.7 KiB
Raw Blame History

tags, create time
tags create time
MQ
Exactly-Once
幂等
事务
Kafka
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 <= 已知最大值,说明是重复消息,直接丢弃。

// 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 实现详解

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 就够了。

关联笔记