作者:江星河-385|15 年全栈|分布式一致性与消息中间件方向 栈:Kafka 3.5.1(KRaft,3 broker,8C32G)、Spring Kafka 3.1.5、Java 17(G1 GC)、MySQL 8.0.36 场景:订单状态机 + 库存扣减,producer 端用 Kafka 事务把「业务库 UPDATE + 订单 topic 发消息」打包成原子操作
一、现象:一次 47 秒的 STW,换来 2.3 万笔订单更新失败 + 87 条「半提交」脏数据
去年 9 月 12 日 03:17:42,一台 producer Pod(8C16G,JVM heap 4g)因一次 humongous allocation 失败触发了 G1 的 Full GC,STW 持续 47.3s(GC log 里 pause full (G1 Humongous Allocation) 2.1s + concurrent mark 后的 mixed GC 又拖了 45s)。
事故分两段,都很典型:
第一段:producer 端 fence 风暴
- Pod 重启后,Spring Kafka 自动调
KafkaTemplate.initializeTransactions(),立刻抛UnknownProducerFenceException; - 重试 3 次全失败(每次间隔 2s),业务侧 47s 内 2.3 万笔订单状态更新全部失败(订单表 UPDATE 成功但消息没发出去,下游库存服务没收到扣减事件);
- 监控里看到 broker 端
kafka-transactions-transactional-id的fence-count在 03:17:42 → 03:18:29 窗口内从 0 跳到 1。
第二段:consumer 端的「半提交」窗口(更隐蔽)
- 另一台 consumer(
isolation.level=read_committed,消费order-statetopic,16 分区)在同一窗口内读到了 87 条「半提交」消息; - 这些消息的特征:producer 端已经拿到 commit ack(
RecordMetadata正常返回),broker 端 commit 已经走完,但 abort index 还没打上——consumer 按min(各分区 abort index)裁剪可见 offset 时,这 87 条恰好落在「已 commit、未 abort」的窗口里; - 下游业务库落了 87 条脏数据(订单状态 = SHIPPED 但库存没扣),对账任务 2 小时后才发现。
关键点是:UnknownProducerFenceException 不是 bug,是 broker 在告诉你「你的 PID 已经被 fence 了,请重新 init」——它和「broker 丢了你的消息」是两件事。把这两件事混为一谈,是这次事故里我们排障花了 3 小时的根本原因。
二、原理:PID + epoch 的围栏机制,以及 fence 的「异步性」
把 Kafka 事务的写路径拆开看:
- PID 分配:每个 producer 实例在
initTransactions()时,向 coordinator 申请一个(cluster_id, producer_id)对。cluster_id是 broker 集群的 UUID,producer_id是一个 64 位单调递增的 long。同一transactional.id在同一集群里只能对应一个 PID,这是 fence 的基础。 - epoch 围栏:每个 PID 配一个单调递增的
epoch(0 开始)。每次initTransactions()或 fence 触发时,epoch +1。旧 epoch 的 PID 永远无效——broker 端收到旧 epoch 的写入请求,直接返回UnknownProducerFenceException。 - transaction.timeout.ms 的真实语义:这是 broker 端判定「事务超时」的阈值。超时后 broker 会:
- 把该事务标记为 aborted;
- 异步补写 abort index(不是同步的!);
- bump epoch,fence 掉旧 PID。
- max.transaction.ms 的语义:单个事务允许的最长时长(默认 900000ms = 15min),超过直接报错。它和
transaction.timeout.ms是两个独立的旋钮,前者管「事务能不能跑这么久」,后者管「broker 等多久判定超时」。 - read_committed 的可见性边界:consumer 端按
min(各分区 abort index)裁剪可见 offset。但 abort index 的写入是 broker 端异步补写的,所以存在一个「已 commit、未 abort」的窗口——在这个窗口里,consumer 会读到「假提交」消息。
这就是为什么 read_committed 不能 100% 防「半提交」,它只是把窗口从「无界」缩到「有界」(bounded by transaction.timeout.ms)。
三、具体做法:配置 + 代码 + 监控
3.1 producer 端配置(application.yml)
spring:
kafka:
producer:
transactional-id: "order-state-machine-${INSTANCE_ID}" # 实例级唯一且稳定
transaction-timeout-ms: 45000 # 调小,让 fence 更快触发(默认 60000)
max-transaction-ms: 600000 # 单事务最长 10min,超过直接报错
acks: all
retries: 0 # 事务 producer 不要重试,避免重复
transactional.id必须「实例级」唯一且稳定:如果多实例用同一个transactional.id,broker 端会互相 fence,导致「永远在 fence 中」。推荐用service-name-{instanceId}格式,instanceId用 Pod 名或 UUID。
3.2 Spring Kafka 代码
@Configuration
public class KafkaTxConfig {
@Bean
public NewTopic orderStateTopic() {
return TopicBuilder.name("order-state")
.partitions(16)
.replicas(3)
.compact() // 如果下游只关心最新状态
.build();
}
@Bean
public ProducerFactory<String, OrderEvent> producerFactory(
Map<String, Object> baseProps) {
baseProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,
"order-state-machine-" + instanceId);
baseProps.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, 45000);
baseProps.put(ProducerConfig.MAX_TRANSACTION_SIZE_CONFIG, 104857600);
return new DefaultKafkaProducerFactory<>(baseProps);
}
@Bean
public KafkaTemplate<String, OrderEvent> kafkaTemplate(
ProducerFactory<String, OrderEvent> pf) {
KafkaTemplate<String, OrderEvent> t = new KafkaTemplate<>(pf);
t.initializeTransactions(); // 启动时 init,拿 PID+epoch
return t;
}
@Service
public class OrderStateService {
private final KafkaTemplate<String, OrderEvent> template;
private final OrderRepo orderRepo;
public void shipOrder(Long orderId) {
try {
template.executeInTransaction(ops -> {
// 1) 业务库 UPDATE
orderRepo.updateStatus(orderId, OrderStatus.SHIPPED);
// 2) 发消息(同一事务内)
ops.send("order-state", orderId.toString(),
new OrderEvent(orderId, OrderStatus.SHIPPED));
return null;
});
} catch (UnknownProducerFenceException e) {
// fence 了:必须重新 init,不能简单重试
log.error("PID fenced, re-init required, orderId={}", orderId, e);
template.initializeTransactions();
// 业务侧:把这笔事务标记为「待重试」,走对账补偿
alertCompensation(orderId, "PID_FENCED");
}
}
}
}
3.3 consumer 端配置
spring:
kafka:
consumer:
group-id: order-state-consumer
isolation-level: read_committed # 必须
auto-offset-reset: earliest
read_committed把「半提交」窗口从「无界」缩到「bounded bytransaction.timeout.ms」(我们设 45s),但不能消除。必须配合业务幂等(唯一索引或去重表),这是最后一道兜底。
3.4 监控与告警
// 周期性抓取 broker 端 metrics,fence 次数是核心告警指标
@Scheduled(fixedDelay = 30_000)
public void checkFence() {
// KafkaAdmin 或 JMX 拿 kafka-transactions-transactional-id 的 fence-count
long fenceCount = jmx.getAttribute("fence-count");
if (fenceCount > lastFenceCount) {
log.warn("PID fence detected, delta={}", fenceCount - lastFenceCount);
alert("PID_FENCED", "producer instance=" + instanceId);
lastFenceCount = fenceCount;
}
}
告警规则:
fence-count1 分钟内增量 > 0 → P1 告警(业务侧立刻走对账补偿);transaction-commit-latencyP99 > 500ms → P2 告警(broker 端写 abort index 变慢,可能是盘 IO 抖动);kafka-producer-metrics的buffer-exhaustion-rate> 0 → P2 告警(producer 端积压,事务超时风险升高)。
四、踩过的坑与反直觉之处
UnknownProducerFenceException不是「broker 丢了你的消息」:它是 broker 在告诉你「你的 PID 已被 fence,请重新 init」。把这两件事混为一谈,是排障最大的坑。正确做法:重新initializeTransactions()+ 业务侧对账补偿。transaction.timeout.ms不能调太大:调到 300s 看似安全,实际上 fence 窗口拉到 5min,consumer 端read_committed的「半提交」窗口也拉到 5min。45s 是我们在「不 fence」和「不半提交」之间折出来的值。transactional.id必须「实例级」唯一且稳定:多实例用同一个transactional.id,broker 端会互相 fence,导致「永远在 fence 中」。推荐service-name-{instanceId}格式。read_committed不能 100% 防「半提交」:它只是把窗口从「无界」缩到「有界」(bounded bytransaction.timeout.ms)。必须配合业务幂等,这是最后一道兜底。- PID 复用规则:Kafka 3.0+ 有「PID 复用」机制(PID 空间回收),但 epoch 是单调递增的,旧 epoch 永远无效。所以「重启后复用旧 PID」是合法的,但「复用旧 epoch」永远不合法。
- 事务 producer 不要开
retries > 0:重试会导致「同一事务内重复写」,破坏原子性。正确做法:retries=0+ 业务侧幂等 + 对账补偿。
五、结论与可复用清单
Kafka 事务的「原子性」是有边界的:它保证「业务库写 + 发消息」在同一事务内,但 不保证「commit ack = consumer 可见」(因为 abort index 是异步补写的)。三个旋钮的取舍建议:
| 旋钮 | 推荐值 | 理由 |
|---|---|---|
| transaction.timeout.ms | 30–60s | 覆盖 GC 抖动,又不至于 fence 窗口过长 |
| max.transaction.ms | 5–10min | 单事务不应该跑这么久,超过直接报错 |
| transactional.id | service-name-{instanceId} | 实例级唯一且稳定,避免互相 fence |
可复用清单:
- [ ] 把
fence-count纳入 P1 告警; - [ ] consumer 端
isolation.level=read_committed+ 业务幂等双保险; - [ ] 业务侧:把「事务超时」和「fence」区分开,前者重试,后者必须重新 init + 对账补偿;
- [ ] 压测时模拟「producer 端 STW」(
jcmd <pid> GC.run或jmap -dump触发 Full GC),观察 fence 行为; - [ ] 在变更 checklist 里明确:
transactional.id变更需要 producer + consumer 双签。
最后留一个开放问题给大家讨论:如果业务能接受「commit ack 成功但 consumer 端 45s 内可能读到半提交」这种弱一致性,Kafka 事务还有必要开吗?还是说直接用「业务库本地消息表 + outbox 模式 + consumer 端幂等」,反而更简单可控? 欢迎在评论区聊聊你们线上的取舍。