bigywater.tech
Kafka 内部原理 · 35

幂等 Producer:Producer ID 和 Sequence Number

开启幂等后,Broker 靠两样东西识别重复:Producer ID(PID,每个 Producer 实例一个)和每个分区上单调递增的序列号(Sequence Number)。这次先直接看日志文件里这两个字段长什么样,再看幂等对配置的强制要求,最后在的 Leader 宕机场景下,对比开启和关闭幂等时 topic 里的重复和乱序情况。

验证:日志里每个批次都带着 PID 和序列号,换一个 Producer 实例就换一个 PID、序列号从 0 重新开始
同一个 Producer 实例写入的每个批次,baseSequence 严格按 0、1、2……递增,PID 不变;
验证:开启幂等时对配置有强制要求,不满足就直接创建失败
这个版本的默认值里 enable.idempotence 已经是 true,同时默认 acks=all、max.in.f

验证:日志里每个批次都带着 PID 和序列号,换一个 Producer 实例就换一个 PID、序列号从 0 重新开始

命令 · shell # 三个 Producer 实例依次往同一个分区各同步发送 25 条: # runA: 默认配置(幂等);runB: 另一个默认配置的实例;runC: enable.idempotence=false, acks=1 $ kafka-dump-log.sh --files /private/tmp/kafka-lab/node1/idem-demo-0/00000000000000000000.log | grep baseOffset
真实实测 · 本机执行输出
baseOffset: 0   baseSequence: 0   producerId: 14011  producerEpoch: 0
baseOffset: 24  baseSequence: 24  producerId: 14011  producerEpoch: 0
baseOffset: 25  baseSequence: 0   producerId: 16002  producerEpoch: 0     <- runB:新的 PID,序列号从 0 重来
baseOffset: 26  baseSequence: 1   producerId: 16002  producerEpoch: 0
baseOffset: 49  baseSequence: 24  producerId: 16002  producerEpoch: 0
baseOffset: 50  baseSequence: -1  producerId: -1     producerEpoch: -1    <- runC:关闭幂等,没有 PID 也没有序列号
baseOffset: 74  baseSequence: -1  producerId: -1     producerEpoch: -1

各 producerId 出现的批数:25 个 -1,25 个 14011,25 个 16002

同一个 Producer 实例写入的每个批次,baseSequence 严格按 0、1、2……递增,PID 不变;第二个实例拿到了另一个 PID(16002),序列号从 0 重新开始;关闭幂等的实例写入的批次,PID 和序列号都是 -1,Broker 对它们没有任何去重信息可用。这里因为是同步逐条发送,每个批次只有 1 条消息,所以 baseSequence 恰好等于这个实例内的消息序号;批次里有多条消息时,baseSequence 是这个批次第一条的序号。Broker 就是靠"PID 加分区加序列号"这个三元组判断一条消息是不是重复,以及是不是按顺序到达。

验证:开启幂等时对配置有强制要求,不满足就直接创建失败

命令 · java new KafkaProducer<>(props); // 分别设置几种冲突配置
真实实测 · 本机执行输出
默认 enable.idempotence = true,默认 acks = all,默认 max.in.flight.requests.per.connection = 5,默认 retries = 2147483647
enable.idempotence=true + acks=1        -> ConfigException: Must set acks to all in order to use the idempotent producer.
enable.idempotence=true + max.in.flight=6 -> ConfigException: ... max.in.flight.requests.per.connection must be set to at most 5. Current value is 6.
enable.idempotence=true + retries=0     -> ConfigException: Must set retries to non-zero when using the idempotent producer.
enable.idempotence=false + acks=1 + max.in.flight=6 -> 创建成功
只设置 acks=1(不显式设置幂等) -> 创建成功

这个版本的默认值里 enable.idempotence 已经是 true,同时默认 acks=all、max.in.flight.requests.per.connection=5、retries 是最大整数——也就是说不做任何配置,Producer 默认就是幂等的。显式开启幂等的同时,把 acks 改成 1、把并发请求数改成 6、或者把重试次数设成 0,都会在创建 Producer 时被拒绝。最后一行值得注意:只设置了 acks=1 而不显式设置幂等时,Producer 可以正常创建。我没有去确认此时幂等是否被悄悄关闭了,只知道创建没有报错;结合上一节日志里 runC 的 PID 是 -1(那次显式关闭了幂等),这个场景下的实际 PID 状态没有单独查。

验证:Leader 宕机时,关闭幂等会出现乱序和重复,开启幂等三次都是零

命令 · java // 同一个程序:异步发送 30000 条 "seq-N"(max.in.flight=5, acks=all, batch.size=2000, 每 50 条 sleep 1ms), // 发到第 6000 条时 kill -9 分区的 Leader 所在 Broker;发送结束后从头消费,统计重复条数和乱序次数 // (乱序 = 出现了比前面已经见过的序号更小的序号);每次都让 bootstrap 不包含被杀的 Broker
真实实测 · 本机执行输出
关闭幂等:
  第 1 次: 回调成功 30000 / 失败 0   topic 共 30000 条,重复   0 条,乱序 150 次
  第 2 次: 回调成功 30000 / 失败 0   topic 共 30100 条,重复 100 条,乱序 450 次
  第 3 次: 回调成功 30000 / 失败 0   topic 共 30100 条,重复 100 条,乱序 400 次
开启幂等:
  第 1 次: 回调成功 30000 / 失败 0   topic 共 30000 条,重复   0 条,乱序   0 次
  第 2 次: 回调成功 30000 / 失败 0   topic 共 30000 条,重复   0 条,乱序   0 次
  第 3 次: 回调成功 30000 / 失败 0   topic 共 30000 条,重复   0 条,乱序   0 次

六次运行里,Producer 的回调都报告"成功 30000、失败 0",从客户端看不出任何区别。差别在 topic 里:关闭幂等的三次都出现了乱序(150、450、400 次),其中两次还多出 100 条重复的消息(30100 条,唯一 30000);开启幂等的三次,重复和乱序都是 0。乱序的原因是 Leader 被杀后,多个在途的批次被重试,先失败的批次可能在后面的批次之后才被写入;重复来自"消息其实已经写入但确认没有回到客户端,于是重发"。这两个解释是按现象和机制的推断,我没有单独抓取重试日志去逐条证实。每组只有三次,重复条数两次都恰好是 100,这个数字的原因我没有去查(可能与批次大小有关,但没有验证)。

结论

幂等 Producer 的机制是"PID 加序列号":日志里每个批次都记录着 PID 和 baseSequence,Broker 据此拒绝重复和乱序的批次。在这次 Leader 宕机的对照里,关闭幂等的三次都出现了乱序,其中两次有 100 条重复,开启幂等的三次没有任何重复和乱序。这个版本默认已经开启幂等,但它对 acks、max.in.flight.requests.per.connection、retries 有硬性要求。幂等只覆盖"同一个 Producer 实例在一个分区上的重试",换一个实例就是新的 PID,不受这个保护。

分步演示:三个Producer实例的PID,和的Leader宕机对照

每一步都是本文上面几节用kafka-dump-log.sh读取日志、kill -9 Leader后统计得到的原始结果。

PID+序列号是怎么让Leader宕机场景下不再重复不再乱序的
这一步新验证的实验

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群;故障注入实验的 topic 都是 1 分区 3 副本,每次杀掉当前分区 Leader 所在的 Broker 进程(kill -9),并在下一次实验前重启它。我先跑了一对(关闭幂等、开启幂等)后,又用不同的 topic 和 Leader 补跑了两对,上表是这三对。
    • 中间我的补跑脚本第一次启动得太早:当时 broker2 还没有重启完成,杀 Leader 会让 KRaft 失去多数派,我发现后立刻中止并重启了 broker2,再用新的 topic 名重新开始,被中止的那次没有采用任何数据。
    • 和《Kafka 能不能做到 Exactly Once》一起看:那篇用事务把"消费—处理—生产"整体原子化,事务型 Producer 是否同样使用 PID 和序列号,这里没有单独验证。
    • 没有验证:Broker 端拒绝重复批次时返回的具体错误码(DuplicateSequenceException 在客户端被当作成功处理,这里没有抓取);PID 过期(transactional.id.expiration.ms、producer.id.expiration.ms)之后的行为;多分区下序列号是按分区独立计数的(日志里只看了单分区)。
    京ICP备2026067502号-1京公网安备11010502063487号