《Java 消息中间件·可靠消息与消费治理》3.3 事务的性能与运维代价

事务不是免费的:多一次协调器往返、多一个状态主题、多一段 LSO 阻塞,还要为 abort 的数据付磁盘钱。本篇用 broker 请求日志数出每个事务多发了哪些 RPC,用两组单次运行对比事务与非事务的发送耗时,并给出判断「什么时候不该用事务」的工程判据。

事务的账本有四栏

讨论「事务慢不慢」之前,先把成本拆开。事务的代价至少分布在四个地方:

成本表现形式谁在付
额外 RPCFindCoordinator、InitProducerId、EndTxn 等生产者与协调器
额外延迟每次提交至少一次协调器往返端到端延迟
额外状态__transaction_state 主题、epoch、进行中事务broker 磁盘与内存
额外运维卡住的事务、abort 的数据、fencing 处置值班的人

只看「发送吞吐」会把后两栏漏掉,而它们往往才是生产环境里更贵的部分。

一个事务到底多发了哪些请求

4.3.0 的 TransactionManager 用优先级队列安排事务请求,顺序写在 Priority 枚举里:

private enum Priority {
    FIND_COORDINATOR(0),
    INIT_PRODUCER_ID(1),
    ADD_PARTITIONS_OR_OFFSETS(2),
    END_TXN(3),
    EPOCH_BUMP(4);

这个枚举本身就是一张请求清单。一次完整的事务写链路至少涉及:

  1. FindCoordinator:按 transactional.id 找到事务协调器;
  2. InitProducerId:拿 PID 与 epoch;
  3. Produce:真正的数据写入;
  4. EndTxn:提交或中止;
  5. 如果调用 sendOffsetsToTransaction(),还有 AddOffsetsToTxn 与 TxnOffsetCommit。

但 4.3.0 有一个版本相关的分支必须说清楚。集群默认 transaction.version=2,客户端在 TV2 下不再自己发送 AddPartitionsToTxn。TransactionManager.maybeAddPartition 的分支是:

} else if (isTransactionV2Enabled()) {
    txnPartitionMap.getOrCreate(topicPartition);
    partitionsInTransaction.add(topicPartition);
    transactionStarted = true;
} else if (transactionContainsPartition(topicPartition) || isPartitionPendingAdd(topicPartition)) {
    return;
} else {
    log.debug("Begin adding new partition {} to transaction", topicPartition);
    txnPartitionMap.getOrCreate(topicPartition);
    newPartitionsInTransaction.add(topicPartition);
}

TV2 分支只更新本地映射,不把分区放进 newPartitionsInTransaction,所以不会触发 AddPartitionsToTxnHandler。不过这不等于「分区登记消失了」:broker 侧还有一个 AddPartitionsToTxnManager 会主动发验证请求。实测日志里能看到它的内部客户端:

ADD_PARTITIONS_TO_TXN  apiVersion=5  clientId=AddPartitionsManager-client-1
  transactionalId='libraryd-apicheck-2' producerId=10 producerEpoch=0 verifyOnly=false
  topics=[AddPartitionsToTxnTopic(name='library.loan.apicheck', partitions=[0])]

这是 KIP-890 的事务验证机制。请求仍然发生,只是发起方从生产者变成了 broker 内部的线程。写文章时如果只说「TV2 不发 AddPartitionsToTxn」,会漏掉这一半。

本机请求计数

把 broker 的 kafka.server.KafkaApis 调到 TRACE,跑两组对照:

  • 事务组:一个生产者连续 3 个事务,每个事务 2 条记录,每条 send().get();
  • 非事务组:一个普通生产者同步发 6 条记录。

从 broker 日志按 apiKey 与 clientId 聚合,事务组结果:

   6 PRODUCE                  producer-libraryd-reqcount-1
   3 WRITE_TXN_MARKERS        broker-1-txn-marker-sender
   3 END_TXN                  producer-libraryd-reqcount-1
   3 ADD_PARTITIONS_TO_TXN    AddPartitionsManager-client-1
   2 METADATA                 producer-libraryd-reqcount-1
   2 API_VERSIONS             producer-libraryd-reqcount-1
   1 INIT_PRODUCER_ID         producer-libraryd-reqcount-1
   1 FIND_COORDINATOR         producer-libraryd-reqcount-1

非事务组:

   6 PRODUCE            plain-reqcount
   2 API_VERSIONS       plain-reqcount
   1 METADATA           plain-reqcount
   1 INIT_PRODUCER_ID   plain-reqcount

逐项对照:

请求事务组(3 事务 / 6 条)非事务组(6 条)说明
PRODUCE66每条都 get(),没有攒批
INIT_PRODUCER_ID11非事务生产者默认开幂等,也要 PID
FIND_COORDINATOR10只有事务需要找协调器
END_TXN30每个事务一次
ADD_PARTITIONS_TO_TXN30broker 内部验证,每事务一次
WRITE_TXN_MARKERS30协调器往数据分区写 COMMIT 标记

结论很直接:事务的额外开销与「事务个数」成正比,与「消息条数」关系不大。 3 个事务多出 1 次找协调器、3 次 EndTxn、3 次内部验证、3 次标记写入;如果把这 6 条记录放进 1 个事务,这些额外请求会降到 1 次。

反过来,这也说明「把更多消息塞进一个事务」能摊薄成本,但会拉长事务时间、放大 LSO 阻塞(3.1 节),并增加超时风险(3.2 节)。这是一个需要按业务调的折中,不是一个可以照抄的最优值。

发送耗时对照

程序 CostLab 做两组:

  • 非事务:一个生产者异步发 total 条,linger.ms=5,最后等所有 future;
  • 事务:每 batch 条一个事务,begin → 发一批 → 等 future → commit。

同一台机器、同一个单节点 broker,先跑 200 条预热,再计时。

total=2000, batch=100:

total=2000 batch=100 transactions=20
plainMs=46 perRecordUs=23.0
txnMs=474 perRecordUs=237.0
ratio=10.30

total=500, batch=1(每条消息一个事务):

total=500 batch=1 transactions=500
plainMs=44 perRecordUs=88.0
txnMs=8480 perRecordUs=16960.0
ratio=192.73

这些数字是单次运行,随机器调度波动,不作基准。本机还有别的进程在跑,plainMs 只有几十毫秒,固定开销占比很高,所以「ratio」只能看量级,不能当成吞吐倍数。

真正可迁移的观察是两条:

  1. 每事务 100 条时,事务的每条成本从 23 微秒升到 237 微秒,多出约 10 倍;
  2. 每条一个事务时,每条成本升到约 17 毫秒,几乎全部是协调器往返与标记写入。

也就是说,事务的成本单位是「事务」,不是「消息」。批量越小,摊到每条消息上的协调成本越高。

客户端指标怎么说

同一个 CostLab 在 500 个单条事务结束时打印了生产者的事务指标:

metric txn-init-time-ns-total=1.32398583E8
metric txn-begin-time-ns-total=2850642.0
metric txn-commit-time-ns-total=2.070301968E9
metric txn-send-offsets-time-ns-total=0.0
metric txn-abort-time-ns-total=0.0

换算一下:

  • txn-init-time-ns-total 约 132 毫秒,只发生一次;
  • txn-begin-time-ns-total 约 2.85 毫秒,500 次合计,平均每次 5.7 微秒,基本是本地状态迁移;
  • txn-commit-time-ns-total 约 2.07 秒,500 次平均每次 4.1 毫秒。

但整个事务组墙钟是 8480 毫秒,平均每个事务约 17 毫秒。差额在哪?在 Produce 的同步等待、EndTxn 的网络往返、以及 broker 写标记的时间。客户端指标只统计 commitTransaction() 调用内部的等待,不包含前面 send().get() 的时间,所以不能拿它当「一个事务的总成本」。

状态主题:事务的账本放在哪

事务协调器把每个 transactional.id 的状态写进 __transaction_state。本机 --describe 的输出:

Topic: __transaction_state  PartitionCount: 50  ReplicationFactor: 1
  Configs: compression.type=uncompressed,min.insync.replicas=1,cleanup.policy=compact,
           segment.bytes=104857600,unclean.leader.election.enable=false

50 个分区来自 broker 配置 transaction.state.log.num.partitions=50。本机为了单节点实验把副本因子和 min ISR 都调成了 1;生产默认是副本 3、min ISR 2。相关默认值:

transaction.state.log.num.partitions=50
transaction.state.log.replication.factor=3
transaction.state.log.min.isr=2
transactional.id.expiration.ms=604800000

cleanup.policy=compact 意味着这个主题按 key 保留最新状态,不按时间删除。transactional.id.expiration.ms 默认 7 天,超过这个时间没有任何活动的事务 ID 才会被清理。

状态记录长什么样?以超时实验的 libraryd-timeout-1 为例,它的状态落在 __transaction_state-37,日志里有 4 条记录。这个条数与状态机「初始化 → 进行中 → 准备中止 → 完成中止」的四步吻合;下面的 valueSize 是 dump 的原始值,本文没有解码 value 的具体字段:

| offset: 0 valueSize=45
| offset: 1 valueSize=76
| offset: 2 valueSize=76
| offset: 3 valueSize=49

key 是带长度前缀的 transactional.id,value 是二进制编码的事务状态、分区列表、超时时间等。它不是给人读的,排障时应该用 kafka-transactions.sh list 或 describe,而不是直接 dump。

另外,本次实验的 5 个 transactional.id 落在 5 个不同的状态分区:

__transaction_state-46  cost-lab-txn
__transaction_state-36  libraryd-txn-1
__transaction_state-13  libraryd-fence-1
__transaction_state-37  libraryd-timeout-1
__transaction_state-18  libraryd-open-1

这说明协调器是按 transactional.id 做分区归属的。事务 ID 越多、越分散,协调器负载越均匀;但如果某个业务把所有事务都塞进少数几个 ID,就会形成热点。

长事务的隐性代价:LSO

3.1 节已经测过:事务打开期间,read_committed 消费者的 endOffsets 停在 LSO,读不到后面的任何数据,包括其他事务已经提交的数据。

把这条和本节的开销放在一起看,会得到一个反直觉的结论:事务的性能问题往往不是「写慢了」,而是「读被挡住了」。 一个持续 30 秒的批处理事务,可能让下游 read_committed 消费者在这 30 秒里完全停住,即使它订阅的分区上有大量已提交数据。

所以调优的方向不是「把事务开得更大」,而是:

  • 控制单事务时长,通常控制在秒级;
  • 把大批量拆成多个短事务;
  • 读端如果不需要事务语义,明确评估是否可以用 read_uncommitted;
  • 监控 LSO 与 HW 的差值,而不只是监控 lag。

什么时候不该用事务

场景建议原因
只写一个分区、一个主题幂等生产者 + 业务幂等事务的原子性用不上
下游是数据库或 HTTP 服务outbox 或业务补偿Kafka 事务管不到外部系统
允许重复、可用唯一键去重幂等消费事务成本换不来额外收益
吞吐优先、延迟敏感非事务 + 幂等每事务一次协调器往返
事务体内有慢操作先拆事务边界超时与 LSO 双重放大
需要跨集群复制业务幂等或对账事务状态不跨集群
消费端不是 Kafka 客户端不要依赖 read_committed过滤发生在 Kafka 消费端

一个常见的误用是「为了不丢消息上事务」。不丢消息靠的是 acks=all、副本、min.insync.replicas 和消费端先处理后提交;事务解决的是「多条写入要么全成要么全不成」。两件事不是同一个问题。

事务使用的工程约束

如果确定要用,下面几条比参数调优更重要:

  1. 一个 transactional.id 一个生产者实例。 不要把同一个 ID 配给多个线程或多个 Pod;那会互相 fencing,表现为随机失败。
  2. transaction.timeout.ms 要覆盖最长事务,且小于 broker 的 transaction.max.timeout.ms。 超时后的恢复路径是重建生产者,不是重试提交。
  3. 事务体内不做外部调用。 外部调用失败无法回滚,只会把事务拖到超时。
  4. 准备好 abort 的代码路径。 catch 到可中止异常时调用 abortTransaction();ProducerFencedException 与 InvalidTxnStateException 属于 fatal,直接关闭生产者。
  5. 监控三类指标。 txn-commit-time-ns-total、txn-abort-time-ns-total、以及 LSO 与 HW 的差值;只看生产成功率会漏掉读端阻塞。
  6. 算磁盘账时把 abort 算进去。 中止的数据仍然占空间,频繁 abort 的集群容量规划要按「写入量」而不是「提交量」算。

回到 libraryd

libraryd 里真正需要事务的场景不多。借阅创建同时写 library.loan.created 与 library.inventory.adjusted,如果两个主题必须原子可见,可以用一个短事务包住两次 send。但如果借阅还要写业务库,事务就不够了,应该走 7.1 节的 outbox:数据库事务写业务表与 outbox 表,发布器再异步把 outbox 记录发到 Kafka。

判断标准可以压缩成一句话:事务只覆盖 Kafka 内部的多次写入;一旦有 Kafka 之外的系统参与,就需要 outbox 或补偿,而不是更多的事务配置。

阅读导航:上一节 3.2 事务超时、fencing 与 abort | 下一节 4.1 消费组设计:按业务还是按分区

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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