基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

Kafka 是如何支持“恰好一次”(Exactly-Once)语义的?

知识点图片

在分布式系统中,实现“恰好一次”(Exactly-Once Semantics, EOS)是非常困难的,因为网络故障、节点宕机随时可能导致消息丢失或重复。

Apache Kafka 从 0.11 版本开始,正式支持了 Exactly-Once 语义。Kafka 实现这一目标的基石可以分为三个核心维度:幂等性生产者(Idempotent Producer)事务(Transactions),以及读已提交(Read Committed)消费者

以下是 Kafka 支持 Exactly-Once 语义的详细底层机制:


第一层:幂等性(Idempotence)—— 解决单分区单会话的重复

在没有幂等性之前,如果 Producer 发送消息到 Broker 后,网络出现闪断,Producer 没有收到 ACK,就会触发重试,从而导致 Broker 端产生重复消息。

开启方式: Producer 配置 enable.idempotence=true

底层机制:

  1. PID (Producer ID): 当 Producer 启动时,Broker 会为其分配一个唯一的 PID。
  2. Sequence Number (序列号): Producer 发送的每一条消息(精确地说是每一个 Batch)都会带有一个单调递增的 Sequence Number。
  3. Broker 端的去重逻辑: Broker 的每个分区会在内存中维护每个 PID 对应的最大 Sequence Number(SN_old)。
    • 当收到新消息(SN_new)时,如果 SN_new == SN_old + 1,Broker 正常接收。
    • 如果 SN_new <= SN_old,说明是重复消息,Broker 直接丢弃并返回成功(防止 Producer 继续重试)。
    • 如果 SN_new > SN_old + 1,说明出现了乱序或消息丢失,抛出 OutOfOrderSequenceException

局限性: 幂等性只能保证单个 Producer 会话单个目标分区内的 Exactly-Once。如果 Producer 挂掉重启(PID 改变),或者要向多个分区发送消息,单纯的幂等性就无能为力了。


第二层:Kafka 事务(Transactions)—— 解决跨分区、跨会话的原子性

为了在跨多个分区、多个 Topic 写入时也能保证 Exactly-Once,并且在 Producer 重启后依然生效,Kafka 引入了事务机制。

核心组件:

  1. Transactional ID (TID): 由用户显式配置。TID 具有持久性,Producer 重启后 TID 保持不变。Kafka 通过 TID 来映射并恢复之前的 PID。
  2. Producer Epoch(纪元): 每次 Producer 使用相同的 TID 启动时,Epoch 会递增。这实现了僵尸隔离(Zombie Fencing),如果网络分区导致出现了两个相同的 Producer,Broker 只会接受 Epoch 更大的那个,旧的“僵尸”Producer 会被拒绝。
  3. Transaction Coordinator(事务协调器): 运行在 Broker 上的模块,负责管理事务的状态。
  4. Transaction Log(事务日志): 一个内部 Topic (__transaction_state),用于持久化记录事务的状态(如 Ongoing、PrepareCommit、CompleteCommit 等),类似于数据库的 Write-Ahead Log (WAL)。

事务执行流程(两阶段提交的变种):

  1. 开启事务: Producer 找到 Transaction Coordinator,注册 TID,获取 PID 和递增的 Epoch。
  2. 发送数据: Producer 开始向各个业务分区发送数据。同时,Coordinator 会记录这个事务涉及了哪些分区。
  3. 提交/回滚事务: Producer 调用 commitTransaction()
    • Coordinator 首先将 PrepareCommit 状态写入事务日志 Topic。
    • 写控制消息(Control Marker): Coordinator 向该事务涉及的所有业务分区写入一个特殊的控制消息(Commit Marker 或 Abort Marker)。这个 Marker 标识着这批消息是被提交还是废弃。
    • Coordinator 将 CompleteCommit 状态写入事务日志,事务结束。

第三层:消费端隔离级别(Isolation Level)—— 保证消费者只读到有效数据

既然 Producer 写入了带有事务的数据,Consumer 就必须有能力辨别哪些数据是提交的,哪些是回滚的。

开启方式: Consumer 配置 isolation.level=read_committed(默认是 read_uncommitted)。

底层机制:

  1. LSO (Last Stable Offset): Broker 为每个分区维护一个 LSO。LSO 以下的事务要么已经 Commit,要么已经 Abort。处于开启状态的事务消息都在 LSO 之上。
  2. 过滤逻辑: 当 Consumer 读取数据时,Broker 只会返回 LSO 之前的数据。
  3. 识别 Marker: 当 Consumer 读到业务分区的消息时,如果碰到 Abort Marker,Consumer 在底层会自动丢弃该事务对应的所有消息,不会将其交给应用程序;如果碰到 Commit Marker,才会将数据交付给上层。

第四层:流处理中的“消费-处理-生产”循环 (Consume-Transform-Produce)

在实际业务(如 Kafka Streams 或 Flink)中,真正的 Exactly-Once 是指:从上游 Topic 消费一条消息,进行计算,将结果写入下游 Topic,且保证消费者位移(Offset)的提交和下游数据的写入是原子操作。

Kafka 事务完美支持了这一点,因为 消费者位移(Offset)本质上也是存储在 Kafka 内部 Topic (__consumer_offsets) 中的消息

原子操作流程:

  1. 开启事务。
  2. 从上游 Topic 消费数据。
  3. 处理数据。
  4. 将处理结果 send() 到下游 Topic。
  5. 最关键的一步: 调用 sendOffsetsToTransaction(),将当前消费的 Offset 作为事务的一部分,发送给 Transaction Coordinator(最终写入 __consumer_offsets)。
  6. commitTransaction()

结果: 业务数据写入下游 Topic 和 Offset 提交变成了同一个事务。要么同时成功(消费完成且结果产出),要么同时失败(数据没写进下游,Offset 没提交,重启后重新消费)。这就实现了端到端的 Exactly-Once 语义。

总结

Kafka 的 Exactly-Once 语义是一个系统工程:

  • 单分区防重: 依赖 PID 和 Sequence Number 的幂等性机制。
  • 多分区原子写入: 依赖 TID、Epoch 防僵尸、Transaction Coordinator 以及两阶段提交写入 Control Marker 的事务机制。
  • 端到端 Exactly-Once: 将“数据写入”和“Offset 提交”打包进同一个事务,配合消费端的 read_committed 隔离级别。
00:00
00:00