4、幂等性Producer原理:enable.idempotence配置、Producer ID与序列号机制
4.1 为什么需要幂等性?
做金融系统的朋友应该都懂,消息重复是噩梦。
我记得刚接手一个交易系统时,遇到过一个诡异的问题:明明下游只消费了一次,数据库里却插入了两条重复记录。排查了半天,发现是Producer重试导致的——网络抖动了一下,消息实际已经写进去了,但Producer没收到ack,于是又发了一遍。
嗯,这就是典型的「至少一次」语义带来的副作用。
在金融场景里,重复下单、重复扣款、重复转账,哪个都受不了。所以Kafka引入了幂等性Producer,说白了就是:无论重试多少次,消息在Broker端只会被持久化一次。
4.2 enable.idempotence配置
开启幂等性,只需要一行配置:
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
就这么简单?对,但背后的事情不简单。
当你把这个参数设为true时,Kafka会自动帮你做几件事:
- 把
acks强制设为all(你改不了,改了会报错) - 把
retries设为Integer.MAX_VALUE(无限重试) - 把
max.in.flight.requests.per.connection设为5(之前是1,现在放宽了)
我个人的习惯是,只要涉及金融交易类数据,一律开启幂等性。性能开销其实很小,但带来的数据一致性保障是巨大的。
acks=1 或 acks=0,同时又开启了幂等性,Kafka会直接抛异常。别问我怎么知道的——我曾经在配置中心改参数时踩过这个坑。
4.3 Producer ID(PID)机制
开启幂等性后,每个Producer在初始化时,Broker会分配一个全局唯一的Producer ID(简称PID)。
这个PID长什么样?其实就是一个long类型的数字,比如10001。它存储在Broker的内存里,Producer挂了重启后会重新申请一个新的PID。
你想想看,有了这个PID,Broker就能区分「这是同一个Producer发的消息」还是「另一个Producer发的消息」了。
我画了一张图,帮你理解PID在整个流程中的位置:
4.4 序列号机制
光有PID还不够。同一个Producer可能发多条消息到同一个分区,怎么保证这些消息不重复?
答案是:序列号(Sequence Number)。
每个Producer在向某个分区发送消息时,会为每条消息(或每个批次)分配一个从0开始递增的序列号。Broker端会为每个
判断逻辑很简单:
- 如果新消息的序列号 = 已接收最大序列号 + 1,正常写入
- 如果新消息的序列号 <= 已接收最大序列号,说明是重复消息,直接丢弃
- 如果新消息的序列号 > 已接收最大序列号 + 1,说明中间有消息丢失,Broker会返回
OutOfOrderSequenceException
说白了,Broker就是靠这个递增的序列号来做去重的。你重试一万次,序列号不变,Broker一看「哦,这个我已经收过了」,直接忽略。
<PID, 分区, 序列号> 三元组的。同一个Producer发往同一个分区的消息,序列号必须严格递增且不重复。
4.5 我在项目中遇到的坑
讲几个实战中容易踩的点:
坑一:Producer重启后PID变了
Producer挂了重启,会重新申请PID。新的PID意味着Broker端之前记录的序列号信息全部作废。所以幂等性只能保证单会话内的精确一次,跨会话是不行的。
我曾经在日志里看到过重复数据,排查了半天才发现是Producer重启导致的。解决方案?要么用事务性Producer,要么下游自己做幂等。
坑二:max.in.flight.requests.per.connection 设为5
开启幂等性后,这个参数默认是5。什么意思?就是允许5个未确认的请求同时在途。之前没开启幂等性时,为了保证顺序,很多人会把这个值设为1。现在有了序列号机制,Broker可以检测乱序,所以可以放宽到5,吞吐量能提升不少。
坑三:不要和事务混为一谈
幂等性Producer只解决单分区内的消息重复问题。如果你需要跨分区、跨Topic的原子性写入,那得用Kafka事务。我见过有人开了幂等性就觉得万事大吉了,结果跨分区消费时还是出现了数据不一致。
4.6 性能影响有多大?
很多人担心开启幂等性会影响性能。我直接说结论:影响微乎其微。
| 对比项 | 未开启幂等性 | 开启幂等性 |
|---|---|---|
| 额外内存开销 | 无 | 每个Producer维护序列号状态,约几十KB |
| 网络开销 | 无 | 每个批次多带8字节PID + 4字节序列号 |
| Broker端开销 | 无 | 内存中维护 |
| 吞吐量影响 | 基准 | 几乎无影响(实测<1%) |
4.7 代码示例
最后给个完整的配置示例,你们可以直接拿去用:
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringSerializer");
// 开启幂等性 —— 就这一行
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// 下面这些参数会被自动覆盖,但显式写出来更清晰
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
KafkaProducer<String, String> producer =
new KafkaProducer<>(props);
// 正常发送即可,幂等性在底层自动生效
producer.send(new ProducerRecord<>("my-topic", "key", "value"));
嗯,代码就这么简单。但背后的原理,我希望你能吃透。毕竟在金融系统里,每一笔数据都可能是真金白银,容不得半点马虎。