《Java 消息中间件·可靠消息与消费治理》3.2 事务超时、fencing 与 abort

事务不会永远开着:transaction.timeout.ms 到期后由协调器主动中止,客户端下一次提交只会收到一个致命错误。本篇实测超时事务的 abort 时点与数据形态,再用两个同 transactional.id 的生产者复现 fencing,说明僵尸写入为什么会被拒绝。

两个超时参数,两个责任方

事务相关的超时不是一个参数,而是两层:

参数配置在哪4.3.0 默认值作用
transaction.timeout.ms生产者60000客户端向协调器声明「这个事务最多开多久」
transaction.max.timeout.msbroker900000协调器允许的上限,超过就拒绝

4.3.0 的 TRANSACTION_TIMEOUT_DOC 原文:

The maximum amount of time in milliseconds that a transaction will remain open before the coordinator
proactively aborts it. The start of the transaction is set at the time that the first partition is
added to it. If this value is larger than the transaction.max.timeout.ms setting in the broker, the
request will fail with a InvalidTxnTimeoutException error.

这里有两个容易记错的点。

第一,超时由协调器执行,不是客户端定时器。客户端只是把 transactionTimeoutMs 放进 InitProducerIdRequest,真正到点中止的是事务协调器。所以客户端进程卡死、网络断开、GC 停顿,都不影响协调器把这个事务清掉。

第二,计时起点是「第一个分区加入事务」,不是 beginTransaction()。只调 beginTransaction()、一条消息都没发的空事务,不会因为超时被中止;但一旦第一条记录写入某个分区,钟就开始走。

本机的 broker 配置实测:

transaction.abort.timed.out.transaction.cleanup.interval.ms=10000
transaction.max.timeout.ms=900000
transaction.state.log.num.partitions=50

transaction.abort.timed.out.transaction.cleanup.interval.ms=10000 意味着过期检查每 10 秒跑一次。所以一个 transaction.timeout.ms=5000 的事务,实际被中止的时点会落在 5 到 15 秒之间,而不是精确的 5 秒。

实验:让事务超时

主题 library.loan.timeout,1 分区。生产者设 transaction.timeout.ms=5000,发两条记录后睡 20 秒,再尝试提交:

p.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, txnId);
p.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, Integer.toString(5000));
p.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, "20000");

producer.initTransactions();
producer.beginTransaction();
for (int i = 0; i < 2; i++) {
    RecordMetadata md = producer.send(
            new ProducerRecord<>(topic, "book-" + i, "timeout-" + i)).get();
    System.out.println("sent value=timeout-" + i + " offset=" + md.offset());
}
Thread.sleep(20000);
try {
    producer.commitTransaction();
    System.out.println("commitTransaction UNEXPECTED ok");
} catch (Exception e) {
    System.out.println("commit FAILED: " + e.getClass().getName() + ": " + e.getMessage());
}

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

initTransactions ok transaction.timeout.ms=5000
sent value=timeout-0 offset=0 at 1793250941798
sent value=timeout-1 offset=1 at 1793250941809
TXN_OPEN sleeping 20000 ms
commit FAILED after 15 ms: org.apache.kafka.common.errors.InvalidTxnStateException: The producer attempted a transactional operation in an invalid state.
begin after failure FAILED: org.apache.kafka.common.KafkaException: Cannot execute transactional method because we are in an error state
producer closed

提交失败只花了 15 毫秒,说明不是又等了一次协调器超时,而是本地已经处于错误状态。错误类型也不是 TimeoutException,而是 InvalidTxnStateException。

中止发生在什么时候

从落盘日志能倒推出协调器的动作。两条记录的创建时间是 1793250941773 和 1793250941799,ABORT 控制批次的创建时间是 1793250950138:

baseOffset: 0 producerId: 2 producerEpoch: 0 isTransactional: true isControl: false
| offset: 0 payload: timeout-0
baseOffset: 1 producerId: 2 producerEpoch: 0 isTransactional: true isControl: false
| offset: 1 payload: timeout-1
baseOffset: 2 producerId: 2 producerEpoch: 1 isTransactional: true isControl: true
| offset: 2 endTxnMarker: ABORT coordinatorEpoch: 0

差值约 8339 毫秒。transaction.timeout.ms=5000,清理间隔 10 秒,落在 [5000, 15000] 区间内,与配置推算一致。这条 ABORT 标记是协调器写的,不是客户端写的;客户端在睡满 20 秒后才知道。

用事务工具查状态:

$ kafka-transactions.sh --bootstrap-server 127.0.0.1:9092 list
TransactionalId      Coordinator  ProducerId  TransactionState
libraryd-timeout-1   1            2           CompleteAbort

CompleteAbort 而不是 Ongoing,说明协调器已经把这个事务结束了。

超时之后数据长什么样

同一份数据,两个消费者:

isolation=read_committed topic=library.loan.timeout beginning=0 endOffsets=3
read total=0 position=3 endOffsets=3

isolation=read_uncommitted topic=library.loan.timeout beginning=0 endOffsets=3
  off=0 key=book-0 value=timeout-0
  off=1 key=book-1 value=timeout-1
read total=2 position=3 endOffsets=3

三点值得注意:

  1. read_committed 读到 0 条,但 position 是 3,说明 LSO 已经越过 ABORT 标记(offset 2),不是被卡住;这两条记录是被判定为中止而过滤掉的。
  2. read_uncommitted 照样读到两条。abort 不等于删除,数据、offset、磁盘空间都还在。
  3. 高水位是 3,其中 offset 2 是控制批次,不承载业务数据。

为什么抛的是 InvalidTxnStateException

协调器收到客户端迟到的 EndTxnRequest 时,事务已经处于 CompleteAbort,没有「进行中的事务」可以提交,于是返回 INVALID_TXN_STATE。本机把协调器日志开到 DEBUG 再跑一次超时实验,抓到的原文是:

TRACE [KafkaApi-1] Handling request: apiKey=END_TXN ... transactionalId='libraryd-timeout-2'
  producerId=13 producerEpoch=0 committed=true
DEBUG [TransactionCoordinator id=1] Aborting append of COMMIT to transaction log with coordinator
  and returning INVALID_TXN_STATE error to client for libraryd-timeout-2's EndTransaction request
TRACE [KafkaApi-1] Completed libraryd-timeout-2's EndTxnRequest with committed: true,
  errors: INVALID_TXN_STATE from client producer-libraryd-timeout-2.

客户端 TransactionManager.EndTxnHandler 把这个错误当成致命错误:

} else if (error == Errors.INVALID_PRODUCER_EPOCH || error == Errors.PRODUCER_FENCED) {
    // We could still receive INVALID_PRODUCER_EPOCH from old versioned transaction coordinator,
    // just treat it the same as PRODUCE_FENCED.
    fatalError(Errors.PRODUCER_FENCED.exception());
} else if (error == Errors.TRANSACTIONAL_ID_AUTHORIZATION_FAILED ||
        error == Errors.INVALID_TXN_STATE || error == Errors.INVALID_PRODUCER_ID_MAPPING) {
    fatalError(error.exception());
}

TransactionalRequestResult.await 又是直接把 error 抛出去,不再包一层:

isAcked = true;
if (error != null) {
    throw error;
}

所以应用看到的就是原始的 InvalidTxnStateException。之后 beginTransaction() 再调用 maybeFailWithError(),抛的是 KafkaException: Cannot execute transactional method because we are in an error state。

正确处置只有一种:关掉这个生产者,重新构造、重新 initTransactions。 不要重试 commitTransaction,也不要尝试 abortTransaction 把它「救回来」。事务已经在协调器侧结束了,本地这个实例的状态是 fatal。

fencing:同一个 transactional.id 只允许一个活着的写入者

超时是「事务开太久」,fencing 是「同一个身份出现了第二个实例」。两者都会让旧实例进入 fatal 状态,但触发路径不同。

设想一个本书构造的场景:libraryd 的借阅事务生产者跑在虚拟机 A 上,进程因为长时间 Full GC 假死,编排系统判定不健康,在虚拟机 B 上拉起同一个 transactional.id 的新实例。A 随后恢复,继续往 library.loan.created 写。如果没有 fencing,A 和 B 会交替写入,同一个借阅可能产生两组互相矛盾的事件。

Kafka 的解法是 epoch。新实例 initTransactions 时,协调器把该 transactional.id 的 producerEpoch 推高;旧实例再用旧 epoch 发 Produce 或 EndTxn,broker 返回 PRODUCER_FENCED(旧协调器可能返回 INVALID_PRODUCER_EPOCH,客户端按同一类处理)。TransactionManager.maybeFailWithError 把错误还原成带身份信息的异常:

if (lastError instanceof ProducerFencedException) {
    throw new ProducerFencedException("Producer with transactionalId '" + transactionalId
            + "' and " + producerIdAndEpoch + " has been fenced by another producer "
            + "with the same transactionalId");
}

关键点:fencing 不会杀死旧进程,也不会阻止它继续跑普通代码;它只让旧进程的下一次事务写入失败。 旧进程必须自己识别 ProducerFencedException 并退出,否则它会一直停在 fatal 状态。

本机复现 fencing

两个生产者用同一个 transactional.id=libraryd-fence-1。A 先开事务写一条但不提交;B 随后初始化、写入并提交;A 再尝试提交:

KafkaProducer<String, String> a = producer(txnId);
KafkaProducer<String, String> b = producer(txnId);

a.initTransactions();
a.beginTransaction();
a.send(new ProducerRecord<>(topic, "book-1", "from-A")).get();

b.initTransactions();          // 同一个 transactional.id,推高 epoch
b.beginTransaction();
b.send(new ProducerRecord<>(topic, "book-1", "from-B")).get();
b.commitTransaction();

try {
    a.commitTransaction();     // 应被 fence
} catch (Exception e) {
    System.out.println("A commit FAILED: " + e.getClass().getName() + ": " + e.getMessage());
}

本机实测输出:

A initTransactions ok
A sent from-A offset=0 (txn still open)
B initTransactions ok (same transactional.id)
B sent from-B offset=2
B commitTransaction ok
A commit FAILED: org.apache.kafka.common.errors.ProducerFencedException: There is a newer producer with the same transactionalId which fences the current one.
A send after fence FAILED: java.util.concurrent.ExecutionException: org.apache.kafka.common.errors.ProducerFencedException: Producer with transactionalId 'libraryd-fence-1' and (producerId=3, epoch=0) has been fenced by another producer with the same transactionalId

这段输出解答了一个常见疑问:B 初始化之后,A 那条已经写进去的 from-A 会怎样?答案是协调器把 A 的打开事务中止掉。落盘形态:

offset 0  producerId=3 producerEpoch=0 isControl=false  payload: from-A
offset 1  producerId=3 producerEpoch=1 isControl=true   endTxnMarker: ABORT
offset 2  producerId=3 producerEpoch=2 isControl=false  payload: from-B
offset 3  producerId=3 producerEpoch=3 isControl=true   endTxnMarker: COMMIT

再看消费者:

isolation=read_committed topic=library.loan.fence beginning=0 endOffsets=4
  off=2 key=book-1 value=from-B
read total=1 position=4 endOffsets=4

isolation=read_uncommitted topic=library.loan.fence beginning=0 endOffsets=4
  off=0 key=book-1 value=from-A
  off=2 key=book-1 value=from-B
read total=2 position=4 endOffsets=4

read_committed 只看到 B 的写入。fencing 在数据层面确实把僵尸实例的那一条隔离掉了,但隔离靠的是 ABORT 标记,不是删除。

fencing 覆盖不到的地方

场景fencing 是否生效
两个事务生产者,同一个 transactional.id生效,后者接管
两个事务生产者,不同 transactional.id不生效,各写各的
事务生产者与普通生产者写同一主题不生效,普通写入没有 epoch
两个实例写同一个业务主键但不同主题不生效,Kafka 不知道业务主键
旧实例只做只读、不再发送不需要 fencing

所以 fencing 解决的是「身份冲突」,不是「业务冲突」。业务层面的唯一键与去重仍然要做,那是 2.2 节的内容。

abort 之后的数据清理

ABORT 之后,数据不会立刻消失:

  • 业务记录仍然在分区日志里,直到保留期(retention.ms)或清理策略把它带走;
  • ABORT 控制批次占一个 offset,但不占业务数据的解读空间;
  • read_committed 每次读都要跳过中止事务的数据,读放大是持续存在的;
  • 如果主题是 cleanup.policy=compact,abort 数据与正常数据的清理规则不同,不要把「compact 会删掉它」当成即时保证。

真正影响容量规划的是第一点:中止的事务照样计入磁盘与网络流量。一个频繁 abort 的生产者,磁盘账单和正常写入差不多。

配置建议

配置建议理由
transaction.timeout.ms大于单事务最长处理时间,留 2 到 3 倍余量太小会误杀正常长事务
transaction.max.timeout.ms保持默认 900000,或按业务上限调低它是最后一道闸,防止客户端声明一个巨大的超时
enable.idempotence不用显式设配了 transactional.id 就隐含开启
max.in.flight.requests.per.connection不超过 5幂等约束,超过构造时报错
delivery.timeout.ms小于 transaction.timeout.ms否则批次还没发完,事务先超时

还有一条不是配置能解决的:事务里不要做慢操作。事务从第一条记录写入开始计时,中间调一次外部 HTTP、等一次数据库锁,都会消耗 transaction.timeout.ms。把慢操作放到事务外,事务只包住「必须原子的那几次写」。

回到 libraryd

libraryd 的借阅事务生产者应该遵守两条规则:

  1. 一个 transactional.id 在同一时刻只对应一个活跃实例;实例启动时先 initTransactions,把可能残留的旧实例 fence 掉,再开始处理。
  2. 事务体内只放 Kafka 写入与必要的位移提交;库存校验、会员资格查询放在事务外,用业务状态机兜住。

如果实例收到 ProducerFencedException,正确动作是停止拉取、停止发送、记录告警并退出,让编排系统决定是否重启。继续运行只会不断产生 fatal 错误。

阅读导航:上一节 3.1 事务生产者与 read_committed | 下一节 3.3 事务的性能与运维代价

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收