事务的账本有四栏
讨论「事务慢不慢」之前,先把成本拆开。事务的代价至少分布在四个地方:
| 成本 | 表现形式 | 谁在付 |
|---|---|---|
| 额外 RPC | FindCoordinator、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);
这个枚举本身就是一张请求清单。一次完整的事务写链路至少涉及:
FindCoordinator:按transactional.id找到事务协调器;InitProducerId:拿 PID 与 epoch;Produce:真正的数据写入;EndTxn:提交或中止;- 如果调用
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 条) | 说明 |
|---|---|---|---|
PRODUCE | 6 | 6 | 每条都 get(),没有攒批 |
INIT_PRODUCER_ID | 1 | 1 | 非事务生产者默认开幂等,也要 PID |
FIND_COORDINATOR | 1 | 0 | 只有事务需要找协调器 |
END_TXN | 3 | 0 | 每个事务一次 |
ADD_PARTITIONS_TO_TXN | 3 | 0 | broker 内部验证,每事务一次 |
WRITE_TXN_MARKERS | 3 | 0 | 协调器往数据分区写 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」只能看量级,不能当成吞吐倍数。
真正可迁移的观察是两条:
- 每事务 100 条时,事务的每条成本从 23 微秒升到 237 微秒,多出约 10 倍;
- 每条一个事务时,每条成本升到约 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 和消费端先处理后提交;事务解决的是「多条写入要么全成要么全不成」。两件事不是同一个问题。
事务使用的工程约束
如果确定要用,下面几条比参数调优更重要:
- 一个
transactional.id一个生产者实例。 不要把同一个 ID 配给多个线程或多个 Pod;那会互相 fencing,表现为随机失败。 transaction.timeout.ms要覆盖最长事务,且小于 broker 的transaction.max.timeout.ms。 超时后的恢复路径是重建生产者,不是重试提交。- 事务体内不做外部调用。 外部调用失败无法回滚,只会把事务拖到超时。
- 准备好 abort 的代码路径。
catch到可中止异常时调用abortTransaction();ProducerFencedException与InvalidTxnStateException属于 fatal,直接关闭生产者。 - 监控三类指标。
txn-commit-time-ns-total、txn-abort-time-ns-total、以及 LSO 与 HW 的差值;只看生产成功率会漏掉读端阻塞。 - 算磁盘账时把 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 消费组设计:按业务还是按分区
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。