在Kafka的事务机制中,Transaction Coordinator是如何通过类似于两阶段提交(2PC)的机制保证跨Partition写入原子性的?
在 Kafka 中,实现跨 Partition 的写入原子性(即 Exactly-Once Semantics,EOS 的核心部分)依赖于Transaction Coordinator(事务协调器,简称 TC)和内部的 __transaction_state 主题。
Kafka 的事务机制确实采用了类似于两阶段提交(2PC)的协议,但为了适应高吞吐量的流处理场景,它对传统的 2PC 进行了改良(去除了同步阻塞和投票阶段)。
以下是 Transaction Coordinator 如何通过这种“类 2PC”机制保证跨 Partition 写入原子性的详细原理解析:
核心组件概述
- Transactional ID (TID):开发者指定的全局唯一事务 ID。
- Producer ID (PID) & Epoch:Kafka 自动分配的生产者 ID 和纪元,用于防范“僵尸生产者”(Zombie Fencing)。
- Transaction Coordinator (TC):运行在 Broker 上的后台组件,负责管理事务状态。
__transaction_state:Kafka 的内部 Topic,充当 TC 的预写式日志(WAL),持久化记录事务的各种状态转变。
事务的完整生命周期与 2PC 执行流
假设一个 Producer 要在一个事务中向 Partition A 和 Partition B 分别写入一条消息。整个过程如下:
0. 初始化与开启事务
initTransactions():Producer 向 TC 发送自己的 TID。TC 为其分配 PID 和 Epoch,并将映射关系写入__transaction_state。此时如果之前有同名 TID 的“僵尸” Producer,其 Epoch 会过期,从而被拒绝写入。beginTransaction():Producer 本地标记事务开始。
1. 注册参与者(事务准备期)
在 Producer 真正向 Partition A 和 B 发送数据之前,它必须先向 TC 发送 AddPartitionsToTxnRequest。
- TC 收到请求后,将 Partition A 和 B 记录为当前事务的参与者(Participants)。
- TC 会在
__transaction_state中记录当前事务的状态为Ongoing。
2. 写入实际数据
Producer 直接向 Partition A 和 Partition B 的 Leader Broker 发送实际的消息数据。
- 这些消息带有 PID、Epoch 和序列号。
- 注意: 此时数据已经写入到 Partition 的日志中了,但是对配置了
read_committed的 Consumer 来说,这些数据是不可见的。
3. 类两阶段提交(2PC)的核心过程
当 Producer 调用 commitTransaction()(或 abortTransaction())时,真正的 2PC 机制开始运转:
Phase 1:准备阶段(Prepare)—— 记录决议
- Producer 向 TC 发送
EndTxnRequest(请求 Commit 或 Abort)。 - TC 接收到请求后,第一件事不是去通知 Partition,而是先写自己的日志。
- TC 向内部主题
__transaction_state写入一条PrepareCommit(或PrepareAbort)记录。 - 原子性分水岭: 一旦
PrepareCommit成功写入__transaction_state,这个事务就一定会被提交。即使此时 TC 宕机,新的 TC 选举出来后读取到这条记录,也会继续完成后续的提交动作。这就保证了单点故障下的原子性。
Phase 2:提交/回滚阶段(Commit/Rollback)—— 写入控制标记
- TC 查阅在“第1步”中注册的参与者列表(Partition A 和 B)。
- TC 向 Partition A 和 B 的 Leader 异步发送
WriteTxnMarkerRequest。 - Partition Leader 收到请求后,在自己的数据日志(Log)中写入一条特殊的非数据消息,称为 控制记录(Control Record / Transaction Marker)。这个标记是
<COMMIT>或<ABORT>。 - 当所有的参与者(Partition A 和 B)都成功写入了控制记录后,TC 向
__transaction_state写入最终的CompleteCommit(或CompleteAbort)记录,标记事务彻底结束。
消费者(Consumer)如何配合实现原子性?
光有写入侧的 2PC 是不够的,读取侧也必须配合。
如果 Consumer 的隔离级别设置为 read_committed:
- Kafka 会维护一个叫做 LSO (Last Stable Offset) 的水位线。
- LSO 之前的消息都是已提交的(或者非事务消息)。
- Consumer 消费时,如果遇到属于某个活跃事务(未遇到 Commit/Abort 标记)的数据,Consumer 会将其缓存或暂不返回给用户,直到在 Log 中读取到
<COMMIT>标记,才会将这批数据交给应用;如果读取到<ABORT>标记,这批数据就会被直接丢弃。
通过这种方式,不管跨了多少个 Partition,只要 <COMMIT> 标记没有写入,Consumer 就看不见这些数据;一旦 Phase 1 完成,TC 保证所有 Partition 最终都会被写上 <COMMIT> 标记,从而实现了跨 Partition 的原子性(要么全部可见,要么全不可见)。
Kafka 的 2PC 与 传统数据库 2PC 的区别
Kafka 之所以能保持极高的吞吐量,是因为它对传统的 2PC 做了流处理场景下的改造:
| 特性 | 传统数据库 2PC (如 XA 协议) | Kafka 的类 2PC 机制 |
|---|---|---|
| Prepare 阶段 | 协调者需要向所有参与者询问:“你们能提交吗?”,需要等待所有节点投票。 | 无需投票。只要 Producer 把数据成功写给 Partition,TC 直接在本地记录 PrepareCommit,单向决定。 |
| 锁机制 | 强锁。事务未提交前,参与者(数据行/表)会被阻塞加锁,严重影响并发。 | 无锁。数据直接追加写入 Partition,并发的其他事务继续追加写。隔离性通过 Consumer 端的 LSO 和控制标记来过滤。 |
| 性能 | 极差,受限于最慢的参与者网络和磁盘 I/O。 | 极高。完全基于追加写(Append-only log)和异步 Marker 下发,不阻塞其他事务。 |
总结
Kafka 的 Transaction Coordinator 通过充当“大脑”,结合充当“预写日志”的 __transaction_state 主题,实现了一个高效的异步 2PC 模型。Phase 1(PrepareCommit)保证了即使宕机决议也不会丢失,Phase 2(Transaction Marker)保证了数据可见性在各个 Partition 上的统一。 搭配 Consumer 端的 read_committed 隔离级别,最终完美实现了跨 Partition 的原子写入。