作者:江星河-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 -> {
orderRepo.updateStatus(orderId, OrderStatus.SHIPPED);
ops.send("order-state", orderId.toString(),
new OrderEvent(orderId, OrderStatus.SHIPPED));
return null;
});
} catch (UnknownProducerFenceException e) {
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 监控与告警
kafka-transactions-transactional-id的fence-count跳变 → 立刻告警;- consumer 侧的
kafka-consumer-fetch-committed-vs-abort差值(如果你们有自研 exporter)→ 持续 > 0 即有「假提交」风险; - 业务侧的对账任务必须覆盖「订单状态 = SHIPPED 但库存未扣」这种跨系统不一致,这是最后一道兜底。
四、事故后 3 个月我们又踩到的坑:coordinator 切换时 epoch 跳变(修订新增)
这次事故复盘做完 3 个月,KRaft 集群做了一次 coordinator 迁移(原 leader broker 升级),同一批 producer 在 200ms 内集体抛 UnknownProducerFenceException——和 9 月那次完全一样的症状,但根因不同:
- 9 月那次的根因:producer 端 STW 47s,超过
transaction.timeout.ms=60s,broker 主动 fence; - 这次(coordinator 切换)的根因:coordinator 迁移后,新 coordinator 接管时对正在 in-flight 的事务做了保守 abort(Kafka 3.5 行为:coordinator 切换会把 pending 事务 abort 掉,等 producer 重新 init)。
关键差异:这次 producer 端没有 GC、没有 STW,业务代码完全正常,但仍然全部被 fence。这告诉我们一件事——fence 的触发源不止「超时」一种,至少还有:
- producer STW / 网络分区导致心跳超时;
- coordinator 切换(KRaft 迁移、broker 滚动升级、coordinator controller 切换);
- broker 端
kafka-transactions日志 compact 时的 epoch 清理(罕见)。
修订后的兜底策略:
- 不要假设「没 STW 就不会 fence」——所有 producer 必须把
UnknownProducerFenceException当作常态分支处理,而不是「异常分支」; - coordinator 切换前主动 drain:滚动升级 broker 前,先用
kafka-metadata-quorum.sh把 coordinator 切走,再 drain producer 的 in-flight 事务(kafkaTemplate.flush()),避免在切换窗口里有 in-flight; - 告警口径调整:原来「fence-count 跳变 → P0」改为「fence-count 跳变 + 同时段有 broker 升级/迁移事件 → 静默」,否则每次 KRaft 迁移都要 P0 升级。
一句话补充:fence 是「broker 主动告诉你旧 PID 无效」,触发源不止超时一种;把它当常态分支处理,比事后排障便宜一个数量级。