事务补的是幂等补不上的那一块
幂等生产者解决的是「同一个批次因为重试被写两次」:broker 按 (PID, 分区) 记住序列号,重复批次丢弃但返回成功。它的作用范围是单个分区内的一次生产者会话,不跨分区,也不管消费位移。
事务要解决的是另外三件事:
| 能力 | 幂等生产者 | 事务生产者 |
|---|---|---|
| 同一分区内重试去重 | 有 | 有(事务隐含幂等) |
| 跨分区原子写 | 没有 | 有 |
| 消息与消费位移原子提交 | 没有 | 有(sendOffsetsToTransaction) |
| 跨会话接管与 fencing | 没有 | 有(transactional.id) |
所以「开了幂等」和「用了事务」不是强度递进关系,而是覆盖不同的失败面。本节先把事务生产者的完整流程跑通,再回答一个具体问题:read_committed 消费者到底能看到什么。
transactional.id 不是随便起的名字
4.3.0 的 ProducerConfig.TRANSACTIONAL_ID_DOC 写得很清楚:
The TransactionalId to use for transactional delivery. This enables reliability semantics which span
multiple producer sessions since it allows the client to guarantee that transactions using the same
TransactionalId have been completed prior to starting any new transactions. If no TransactionalId is
provided, then the producer is limited to idempotent delivery. If a TransactionalId is configured,
enable.idempotence is implied. By default the TransactionId is not configured, which means
transactions cannot be used. Note that, by default, transactions require a cluster of at least three
brokers which is the recommended setting for production; for development you can change this, by
adjusting broker setting transaction.state.log.replication.factor.
三个要点:
它是跨会话的身份,不是实例名。 同一个
transactional.id在任意时刻只允许一个生产者写。后来的实例通过initTransactions把 epoch 推高,前一个实例的写入会被拒绝。这就是 fencing,3.2 节会实测。配了它,幂等自动打开。 不需要再写
enable.idempotence=true;反过来,如果显式把幂等关掉再配transactional.id,构造时直接抛ConfigException。本机实测:org.apache.kafka.common.config.ConfigException: Cannot set a transactional.id without also enabling idempotence.默认要求三个 broker。 因为
transaction.state.log.replication.factor默认是 3。单节点实验必须同时把状态主题的副本因子和 min ISR 调成 1,否则事务根本无法初始化。
本机实验用的是单节点 KRaft,启动参数里带了这三项:
-e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
-e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
initTransactions 到底发了什么
KafkaProducer.initTransactions() 在 4.3.0 的实现很短,核心是把初始化请求交给 TransactionManager,然后阻塞等待结果:
public void initTransactions() {
throwIfNoTransactionManager();
throwIfProducerClosed();
long now = time.nanoseconds();
TransactionalRequestResult result = transactionManager.initializeTransactions(false);
sender.wakeup();
result.await(maxBlockTimeMs, TimeUnit.MILLISECONDS, INIT_TXN_TIMEOUT_MSG);
producerMetrics.recordInit(time.nanoseconds() - now);
transactionManager.maybeUpdateTransactionV2Enabled(true);
}
initializeTransactions 里构造的是 InitProducerIdRequest,带三样东西:transactionalId、transactionTimeoutMs、当前的 producerId 与 producerEpoch。第一次调用时后两者是 -1,coordinator 分配一个新的 PID 和 epoch 0。
本机把 broker 的 KafkaApis 调到 TRACE,跑一次单事务发送,抓到的生产者侧请求序列是:
FIND_COORDINATOR clientId=producer-libraryd-apicheck-2 keyType=1 coordinatorKeys=[libraryd-apicheck-2]
INIT_PRODUCER_ID clientId=producer-libraryd-apicheck-2 producerId=-1 producerEpoch=-1 transactionTimeoutMs=60000
PRODUCE clientId=producer-libraryd-apicheck-2 acks=-1
END_TXN clientId=producer-libraryd-apicheck-2 producerId=10 producerEpoch=0 committed=true
所以 initTransactions() 至少包含一次 FindCoordinator 和一次 InitProducerId。前者找事务协调器(也就是 __transaction_state 某个分区的 leader),后者拿 PID/epoch。这两步都可能因为 coordinator 未就绪而重试,initTransactions 的阻塞时间受 max.block.ms 限制。
还有一个版本细节:4.3.0 的集群默认 transaction.version=2。本机 kafka-features.sh describe 的输出是:
Feature: transaction.version SupportedMinVersion: 0 SupportedMaxVersion: 2 FinalizedVersionLevel: 2
事务协议 V2 会根据这个 feature 打开,客户端在 maybeUpdateTransactionV2Enabled 里读取并置位。它影响请求构成与 epoch 推进方式,3.3 节展开。
begin / commit / abort 的状态机
TransactionManager 用一组状态约束调用顺序。关键状态与合法迁移:
| 当前状态 | 允许的下一步 | 触发方法 |
|---|---|---|
UNINITIALIZED | INITIALIZING | initTransactions() |
READY | IN_TRANSACTION | beginTransaction() |
IN_TRANSACTION | COMMITTING_TRANSACTION / ABORTING_TRANSACTION | commitTransaction() / abortTransaction() |
COMMITTING_TRANSACTION | READY | EndTxn 成功 |
ABORTING_TRANSACTION | READY | EndTxn 成功 |
| 任意 | FATAL_ERROR | fencing、非法状态迁移 |
beginTransaction() 本身不发任何 RPC,只做一次状态迁移:
public synchronized void beginTransaction() {
ensureTransactional();
throwIfPendingState(TransactionOperation.BEGIN_TRANSACTION);
maybeFailWithError();
transitionTo(State.IN_TRANSACTION);
}
真正落盘发生在 commitTransaction() 与 abortTransaction():两者都调用 beginCompletingTransaction,构造 EndTxnRequest,区别只是 setCommitted(true) 还是 false。也就是说:
- 事务期间调用
send(),记录已经写进分区日志,但带着事务标记; commitTransaction()追加一条 COMMIT 控制批次;abortTransaction()追加一条 ABORT 控制批次;- 消费者是否返回这些记录,由隔离级别和控制批次共同决定。
实验环境与代码
实验主题 library.loan.created.txn,1 分区;broker 是 apache/kafka:4.3.0 单节点 KRaft;客户端 JDK 25.0.5+8、kafka-clients 4.3.0。生产者核心代码:
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:19192");
p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringSerializer");
p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringSerializer");
p.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, txnId);
p.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, "60000");
p.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, "15000");
try (KafkaProducer<String, String> producer = new KafkaProducer<>(p)) {
producer.initTransactions();
producer.beginTransaction();
for (int i = 0; i < count; i++) {
RecordMetadata md = producer.send(
new ProducerRecord<>(topic, "book-" + i, prefix + "-" + i)).get();
System.out.println("sent value=" + prefix + "-" + i + " offset=" + md.offset());
}
if ("commit".equals(action)) {
producer.commitTransaction();
} else {
producer.abortTransaction();
}
}
注意常量名是 ProducerConfig.TRANSACTION_TIMEOUT_CONFIG,不是很多人凭印象写的 TRANSACTION_TIMEOUT_MS_CONFIG;后者在 4.3.0 里不存在,编译期就会报找不到符号。
实验一:两条提交,一条中止
同一个 transactional.id=libraryd-txn-1,先做一次提交两条记录的事务,再做一次中止一条记录的事务:
initTransactions ok txnId=libraryd-txn-1
beginTransaction ok
sent value=committed-0 partition=0 offset=0
sent value=committed-1 partition=0 offset=1
commitTransaction ok
initTransactions ok txnId=libraryd-txn-1
beginTransaction ok
sent value=aborted-0 partition=0 offset=3
abortTransaction ok
中止事务里唯一那条记录的 offset 是 3,不是 2。原因是提交事务的 COMMIT 控制批次占用了 offset 2。控制批次也是日志里的真实记录,会消耗位移;这一点在按 offset 对账时经常被忽略。
read_committed 与 read_uncommitted 的对照
同一个主题、同一批数据,用两个只改 isolation.level 的消费者从 0 读到末尾:
isolation=read_committed topic=library.loan.created.txn beginning=0 endOffsets=5
off=0 key=book-0 value=committed-0
off=1 key=book-1 value=committed-1
read total=2 position=5 endOffsets=5
isolation=read_uncommitted topic=library.loan.created.txn beginning=0 endOffsets=5
off=0 key=book-0 value=committed-0
off=1 key=book-1 value=committed-1
off=3 key=book-0 value=aborted-0
read total=3 position=5 endOffsets=5
两个消费者的 endOffsets 都是 5,因为此刻没有打开的事务,LSO 与高水位(HW)重合。差别只在返回的记录数:read_committed 2 条,read_uncommitted 3 条。
这里要纠正一个常见说法:abort 的数据不是「没写进去」,而是「写了但被标记为不可见」。 broker 照样为它分配 offset、照样占磁盘、照样参与复制。read_committed 是在消费端读路径上把它过滤掉。
落盘形态:数据都在,标记决定可见性
用 kafka-dump-log.sh --print-data-log 看同一个分区的日志:
baseOffset: 0 count: 1 baseSequence: 0 lastSequence: 0 producerId: 0 producerEpoch: 0 isTransactional: true isControl: false
| offset: 0 key: book-0 payload: committed-0
baseOffset: 1 count: 1 baseSequence: 1 lastSequence: 1 producerId: 0 producerEpoch: 0 isTransactional: true isControl: false
| offset: 1 key: book-1 payload: committed-1
baseOffset: 2 count: 1 baseSequence: -1 lastSequence: -1 producerId: 0 producerEpoch: 1 isTransactional: true isControl: true
| offset: 2 endTxnMarker: COMMIT coordinatorEpoch: 0
baseOffset: 3 count: 1 baseSequence: 0 lastSequence: 0 producerId: 0 producerEpoch: 2 isTransactional: true isControl: false
| offset: 3 key: book-0 payload: aborted-0
baseOffset: 4 count: 1 baseSequence: -1 lastSequence: -1 producerId: 0 producerEpoch: 3 isTransactional: true isControl: true
| offset: 4 endTxnMarker: ABORT coordinatorEpoch: 0
四个可直接读出的事实:
- 事务数据批次
isTransactional: true、isControl: false,带正常的baseSequence; - 控制批次
isControl: true、baseSequence: -1,载荷是endTxnMarker; - 同一个
transactional.id两次initTransactions拿到同一个producerId=0,但 epoch 从 0 一路推进到 3; - COMMIT 与 ABORT 是两种不同的标记,消费者按标记决定是否返回前面对应的事务数据。
第 3 点值得单独说:本次 dump 里事务数据的 epoch 是偶数(0、2),控制标记的 epoch 是奇数(1、3),看起来 epoch 在每次事务完成时推进。这与 4.3.0 事务协议 V2 在事务边界推进 epoch 的实现一致;旧版本教材里「epoch 只在 init 时变化」的描述在这个版本上对不上。具体推进时机由协调器控制,应用层不应依赖某个固定的 epoch 数值。
LSO:事务打开时 read_committed 停在哪
上面的例子里事务都已经结束,两种隔离级别只差被中止的那一条。更有意思的是事务正在打开的时候。
实验:生产者写入一条 open-0 后保持事务打开 20 秒,再提交。期间用两个消费者去读:
# 事务打开期间,read_committed
isolation=read_committed topic=library.loan.open beginning=0 endOffsets=0
read total=0 position=0 endOffsets=0
# 事务打开期间,read_uncommitted
isolation=read_uncommitted topic=library.loan.open beginning=0 endOffsets=1
off=0 key=book-0 value=open-0
read total=1 position=1 endOffsets=1
# 提交之后,read_committed
isolation=read_committed topic=library.loan.open beginning=0 endOffsets=2
off=0 key=book-0 value=open-0
read total=1 position=2 endOffsets=2
read_committed 的 endOffsets 是 0,而不是记录已经写进去的 1。这不是消费者算错了,而是它看到的「末尾」被 LSO(last stable offset)限制住了。4.3.0 的 ISOLATION_LEVEL_DOC 原文:
If set to read_committed, consumer.poll() will only return transactional messages which have been
committed. ... Messages will always be returned in offset order. Hence, in read_committed mode,
consumer.poll() will only return messages up to the last stable offset (LSO), which is the one less
than the offset of the first open transaction. In particular any messages appearing after messages
belonging to ongoing transactions will be withheld until the relevant transaction has been completed.
As a result, read_committed consumers will not be able to read up to the high watermark when there
are in flight transactions.
LSO 的定义是「第一个未结束事务的 offset」。它带来一个常被低估的后果:一个长事务会把它后面所有已提交的消息一起挡住。假设 offset 100 的事务开了十分钟,offset 101 到 5000 都是别的事务已经提交的数据,read_committed 消费者也只能读到 100 之前。这是队头阻塞,不是「只过滤那一条」。
| 场景 | read_uncommitted 可见 | read_committed 可见 |
|---|---|---|
| 已提交事务的数据 | 是 | 是 |
| 已中止事务的数据 | 是 | 否 |
| 打开事务的数据 | 是 | 否 |
| 打开事务之后、已提交的数据 | 是 | 否(被 LSO 挡住) |
| 非事务消息 | 是 | 是 |
read_committed 不保证的事
把边界写清楚,比记住配置项更重要:
- 不保证不重复。 它只保证不读未提交和中止的数据。消费者处理完、提交位移前崩溃,重启后仍会重复消费。那是 2.2 与 2.3 的主题。
- 只管 Kafka 消费端。 同一条消息被事务提交后,消费者写数据库失败,Kafka 事务不会回滚数据库。跨系统的原子性要靠 outbox 或补偿。
- 非事务消息无条件返回。 同一个主题里混着事务消息和普通消息时,普通消息在两种隔离级别下都可见。
- 不解决跨集群。 事务状态在本地集群的
__transaction_state里,不随 MirrorMaker 复制。 - 长事务会放大读延迟。 见上面的 LSO 表。
消费端配置
读事务数据的消费者至少要改一项:
bootstrap.servers=127.0.0.1:19192
group.id=libraryd-reader
enable.auto.commit=false
isolation.level=read_committed
auto.offset.reset=earliest
如果消费者还要用 sendOffsetsToTransaction 把位移并入生产事务,必须 enable.auto.commit=false,并且不要同时调用 commitSync/commitAsync;否则位移提交会绕开事务,破坏「消息与位移一起生效」的前提。
回到 libraryd
libraryd 的借阅创建要同时产出两个事件:library.loan.created(key 是 bookId)和 library.inventory.adjusted(同一 key)。如果两个事件必须同时可见,就用一个事务包住两次 send,再提交。消费者用 read_committed 读,要么两个都看到,要么一个都看不到。
但要注意这只覆盖「Kafka 内的原子性」。如果借阅还要写业务库,事务帮不上,得在 7.1 节用 outbox 把数据库事务和消息发布接起来。
阅读导航:上一节 2.3 恰好一次的两种实现与成本 | 下一节 3.2 事务超时、fencing 与 abort
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。