订单消费者先提交 MySQL,再发布积分事件;进程恰在两步之间崩溃。恢复后重放订单,积分被再次发放。Producer 的幂等开关没有失效------它根本看不见这次业务重放。
幂等 Producer 消除的是 Kafka 发送重试产生的重复;Kafka 事务原子化的是 Kafka 内的写入与位点;外部数据库副作用仍要由业务协议闭环。

四层保证不要混成一个词
| 层次 | 能解决 | 不能解决 |
|---|---|---|
enable.idempotence=true |
同一生产者协议下的重试重复与顺序约束 | 应用重新调用 send()、外部副作用 |
transactional.id |
跨 Producer 会话恢复事务身份、隔离僵尸实例 | 自动让消费者只读已提交数据 |
| Kafka consume-transform-produce 事务 | 输出记录与消费位点原子提交 | MySQL、HTTP、邮件等外部结果 |
| 业务幂等 / Outbox / Inbox | 跨系统重放与副作用去重 | 无设计地宣称"全局 Exactly-Once" |
Kafka 4.3.1 默认在无冲突配置时启用幂等;它要求 acks=all、retries>0、max.in.flight.requests.per.connection<=5。配置 transactional.id 会隐式启用幂等,并让事务身份跨 Producer 会话恢复。Producer Configs
为什么数据库提交后崩溃必然暴露缺口
text
poll(order@42)
→ UPDATE points # 外部系统已提交
→ process crashes # Kafka 位点尚未提交
restart
→ poll(order@42) again
→ UPDATE points again
Producer sequence number 只能判断一次 Kafka 发送是否是同一协议批次的重试,不能判断两次业务调用是否代表同一个订单。若应用重启后重新构造消息,这就是一条新的发送意图。
反过来先提交 offset 再写数据库也不安全:中间崩溃会使订单永久跳过。换顺序只能在"可能重复"和"可能丢失"之间移动窗口。
Kafka 内部闭环怎么形成
对于"消费 A、生成 B"且所有结果都在 Kafka 内的链路,事务 Producer 可以把输出记录和输入 Consumer Group 的 offsets 一起提交:
java
producer.beginTransaction();
producer.send(outputRecord);
producer.sendOffsetsToTransaction(offsets, groupMetadata);
producer.commitTransaction();
下游还必须使用 isolation.level=read_committed,否则可能读到后来被中止的事务记录。KafkaProducer 4.3.1 API 也明确要求端到端事务保证包含事务 Producer、耐久 Topic 与只读已提交记录的 Consumer。KafkaProducer API Consumer Configs
commitTransaction() 报超时也不能被简单解释成"失败":客户端可能没有看到结果,而 Broker 已经提交。正确动作是由事务协议恢复和隔离旧实例,不是用普通 Producer 盲目补发。
外部数据库有三种常见闭环
方案 A:业务键幂等
以 order_id + event_type 建唯一约束,数据库事务中同时写业务结果和处理记录。重复消费命中唯一键后返回已处理结果。适合能定义稳定业务身份的副作用。
方案 B:Transactional Outbox
在同一个本地数据库事务中写业务表和 outbox 表,由独立发布器把 outbox 事件投递 Kafka。发布器可能重复发送,因此下游仍按事件 ID 幂等;它解决的是"数据库已提交但事件没发出"的原子缺口。
方案 C:Inbox + 状态机
先把事件按唯一 ID 记入 inbox,再由状态机驱动外部调用和重试。对于支付、短信等不能简单回滚的系统,还要保存请求键、响应与补偿状态。
不存在免费方案:唯一约束增加写竞争,Outbox 增加延迟和清理成本,Kafka 事务增加协调与隔离要求。选择依据是副作用落在哪里,而不是团队偏爱哪个术语。
生产排查:先画出崩溃窗口
- 为输入记录保留
topic-partition-offset、业务事件 ID 和外部请求幂等键。 - 核对重复结果是同一个 Kafka offset 被重放,还是两个不同 offset 承载同一业务事件。
- 查 Consumer commit 时点、事务 abort/commit 错误与实例重启时间。
- 在外部系统按业务键查询请求次数和最终状态。
bash
bin/kafka-consumer-groups.sh --bootstrap-server broker:9092 \
--describe --group points-service
该命令只读;它只能展示已提交位点与 Lag,不能证明某条记录的业务副作用已经完成。Basic Operations
若同一 offset 对应两次数据库提交,缺口在"处理完成---位点提交"之间;若不同 offset 使用同一业务 ID,缺口更早,可能是上游业务重发或事件身份设计失败。
安全修复与验证
- 先冻结重复扩散:对高价值副作用启用业务唯一键或暂停受影响分区,不要直接重置 offset。
- 按业务主键核对真实状态,再决定补偿、冲正或跳过。
- 修复后用故障注入覆盖三个窗口:外部提交前、外部提交后位点前、事务提交结果未知。
- 技术验收看重复键拦截、事务 abort/commit、Consumer 重放;业务验收看每个订单最终只产生一次有效权益。
Offset reset 是写操作,且会改变重放范围。执行前必须让 Consumer Group 无活动实例,先用默认预览确认分区和目标 offset,再经审批添加 --execute;保存原 offset,设置最大重放条数、停止条件与恢复方案。
面试表达
不要回答"Kafka 支持 Exactly-Once"。更准确的表达是:
Kafka 能在规定边界内提供幂等写入和事务性 consume-transform-produce;一旦结果跨出 Kafka,必须用业务幂等、Outbox/Inbox 或可恢复状态机完成端到端语义。
源码与 Java:让输出记录与输入位点同事务提交
KafkaProducer 事务 API 委托 TransactionManager 管理 PID、epoch、分区与 offset 请求,服务端由 TransactionCoordinator 持久化事务状态。
以下示例按 Kafka 4.3.1 API 静态审阅,未在本环境运行事务故障注入。
java
import java.time.Duration;
import java.util.*;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.*;
public class KafkaOnlyTransaction {
public static void main(String[] args) {
Properties cp = new Properties();
cp.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
cp.put(ConsumerConfig.GROUP_ID_CONFIG, "points-transformer");
cp.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
cp.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
cp.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
Properties pp = new Properties();
pp.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
pp.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "points-transformer-0");
pp.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
pp.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
try (KafkaConsumer<String,String> c = new KafkaConsumer<>(cp);
KafkaProducer<String,String> p = new KafkaProducer<>(pp)) {
p.initTransactions(); c.subscribe(List.of("orders"));
ConsumerRecords<String,String> rs = c.poll(Duration.ofSeconds(10));
if (rs.isEmpty()) {
System.out.println("NO_RECORDS");
return;
}
p.beginTransaction();
try {
rs.forEach(r -> p.send(new ProducerRecord<>("points", r.key(), r.value())));
Map<TopicPartition,OffsetAndMetadata> os = new HashMap<>();
rs.partitions().forEach(tp -> os.put(tp, new OffsetAndMetadata(rs.records(tp).get(rs.records(tp).size()-1).offset()+1)));
p.sendOffsetsToTransaction(os, c.groupMetadata()); p.commitTransaction();
} catch (RuntimeException e) { p.abortTransaction(); throw e; }
}
}
}
下游需 isolation.level=read_committed。映射为事务 API → TransactionManager → Coordinator marker → 输出可见与 offset 同步推进。示例没有 MySQL,恰好证明外部副作用仍不在 Kafka 事务边界内。
结论
可靠性不是一个开关,而是一条跨系统状态转移链。只有能指出事件身份、原子边界、重放策略、外部副作用和失败恢复,Exactly-Once 才是可验证设计,而不是配置标签。