ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

第五篇:《Producer 深度实战:ACK 机制、幂等性与事务消息》

第五篇:《Producer 深度实战:ACK 机制、幂等性与事务消息》

在上一篇文章中,我们确立了 Kafka 数据模型的核心约束:分区数事实上永久、副本因子创建时定好、有序性只在分区内保证。现在,视角从“数据放在哪里”转向“数据如何被可靠地写入”。Producer 是数据进入 Kafka 的唯一入口,它的配置决定了三个关键问题的答案:一条消息在什么条件下才算“写入成功”?重复发送会不会产生重复消息?跨多个分区的写入能否原子完成? 这三个问题分别对应 ACK 机制、幂等性生产者和事务消息。 本文从 acks 的三档语义讲起,深入拆解 acks=all 与 min.insync.replicas 的配合关系——为什么只设 acks=all 是不够的;然后剖析幂等性生产者的去重边界,以及事务消息如何实现跨分区的原子写入和生产者 fencing。

一、acks 的三档语义:从“发出即忘”到“全 ISR 确认”
acks 控制 Producer 在认为一条消息“写入成功”之前,需要等待多少个副本的确认。

1.1 acks=0:发出即返回
Producer 将消息放入网络缓冲区后立即认为成功,不等待 Broker 的任何响应。

语义:最多一次(at-most-once) 。Broker 是否收到、是否写入磁盘,Producer 完全不知道。如果 Broker 宕机或网络中断,消息静默丢失。

适用场景:允许丢失的指标采集、日志聚合等对吞吐量极度敏感、对可靠性要求极低的场景。这是吞吐量最高的设置,因为网络开销最小。

1.2 acks=1:Leader 确认
Producer 等待分区 Leader 将消息写入本地日志后返回成功。Leader 的响应是同步的,但副本的复制是异步的——Leader 在返回确认时,Follower 可能还没有复制这条消息。

语义:可能丢失。如果 Leader 在 Follower 完成复制之前宕机,而新的 Leader 是从未包含这条消息的 Follower 中选举出来的,这条已确认的消息就丢失了。

适用场景:对可靠性要求不极致、但希望比 acks=0 更有保障的普通业务。不是生产环境推荐的默认选择。

1.3 acks=all(或 -1):全 ISR 确认
Producer 等待所有同步副本(ISR) 都确认写入后才返回成功。Leader 会检查 ISR 中是否有足够的副本(由 min.insync.replicas 控制),请求在缓冲区中等待,直到 Leader 观察到所有 Follower 都复制了这条消息,才向客户端发送成功确认。

语义:最强的耐久性保证。在 min.insync.replicas 得到满足的前提下,已确认的消息至少存在于指定数量的副本上。

注意 Kafka 3.0 的默认值变化:Kafka 3.0 之前,Producer 的默认 acks 是 1;从 Kafka 3.0 开始,默认值改为 all。这是一个重要的基线提升——如果你从旧版本升级到 3.x/4.x,没有显式设置 acks 的 Producer 会在无感知的情况下获得更强的耐久性保证。

二、acks=all 不够:min.insync.replicas 是另一半
这是 Producer 耐久性配置中最容易被误解的关系。

2.1 为什么只设 acks=all 是不够的
acks=all 的语义是“等待 当前 ISR 中所有副本确认”。问题在于:ISR 不是一个固定的集合,它会收缩。

假设一个 Topic 的副本因子是 3,min.insync.replicas 保持默认值 1。如果两个 Follower 因为网络或磁盘问题落后,被踢出 ISR,ISR 收缩到只剩 Leader 一个副本。此时:

acks=all 仍然会等待“所有 ISR”——但 ISR 只有一个节点。

acks=all 在行为上退化成了 acks=1。

如果这个唯一的 Leader 随后宕机,数据丢失。

acks=all 本身不提供“至少 N 个副本确认”的保证,它只提供“所有当前 ISR 确认”的保证。ISR 的大小是可以缩小的。

2.2 min.insync.replicas 的作用
min.insync.replicas 在 Topic 或 Broker 级别设置,定义了一个写入可被接受的最低 ISR 大小。只有当 ISR 中的副本数至少等于这个值时,写入才会被接受;如果 ISR 收缩到低于这个值,Broker 拒绝写入,Producer 收到 NotEnoughReplicasException,而不是在一个不安全的单副本上“成功”写入。

min.insync.replicas=2 的含义:至少 2 个 ISR 副本(包括 Leader)确认收到了数据,写入才被认定为已提交。如果 ISR 只剩 1 个副本,写入被拒绝——这比“静默地写入一个不安全的副本”要安全得多。

2.3 生产环境的经典组合
Conduktor 的文档明确推荐:

replication.factor=3 + acks=all + min.insync.replicas=2 是生产环境中最流行的组合,在数据耐久性和可用性之间取得平衡,最多容忍 1 台 Broker 故障。

一个必须记住的结论:acks=all 和 min.insync.replicas 必须成对理解。只设 acks=all 而不设 min.insync.replicas,就像锁了门但没关窗——看起来安全,实际上存在一个静默失效的路径。

三、幂等性生产者:去重的第一层,但边界明确
幂等性生产者解决的是 Producer 重试导致的重复写入问题。当 Producer 发送一条消息后没有收到确认(网络超时、Broker 故障),它会重试。在没有幂等性的情况下,重试可能导致同一条消息在日志中出现两次。

3.1 幂等性的工作原理
幂等性生产者通过为每个 Producer 分配一个 Producer ID(PID) ,并为每条消息附加一个序列号,Broker 在写入时检查 (PID, 分区, 序列号) 是否已经存在。如果存在,静默丢弃重复消息,返回成功确认。

启用方式:enable.idempotence=true。现代 Kafka 客户端中这是默认值。

3.2 幂等性的两个硬边界
幂等性生产者的保证范围有明确的边界,必须提前理解:

边界一:单分区。幂等性只能保证单个分区内不出现重复消息。如果一个 Producer 向多个分区发送消息,幂等性不提供跨分区的去重。

边界二:单会话。幂等性只在一个 Producer 进程的生命周期内有效。Producer 重启后,PID 会重新分配,之前的幂等性状态不再有效。这意味着:幂等性不能防止“Producer 崩溃重启后重复发送上一批未确认消息”的情况。

3.3 幂等性的“静默关闭”陷阱
如果开启了 enable.idempotence=true,但同时设置了冲突的配置(如 acks=1),幂等性会被静默关闭,而不会报错。这是一个危险的陷阱:你以为开启了幂等性,实际上它没有生效。

冲突配置清单:acks 必须为 all;retries 必须大于 0;max.in.flight.requests.per.connection 必须小于等于 5(在旧版本中)或任意值(在新版本中,幂等性已经解除了这个限制)。任何冲突都会导致幂等性被静默禁用。

实践建议:在显式设置 enable.idempotence=true 的同时,显式设置 acks=all,并检查 Producer 启动日志中幂等性是否被确认启用。

四、事务消息:跨分区原子写入与生产者 Fencing
幂等性解决了“单分区单会话内的重复写入”,但它不能保证“多条消息要么全部写入、要么全部不写入” 。事务消息填补了这个空白。

4.1 事务的核心语义
事务消息保证:一组跨多个分区(甚至多个 Topic)的写入,要么全部对 read_committed 消费者可见,要么全部不可见。

producer.initTransactions();producer.beginTransaction();producer.send(newProducerRecord<>("orders",orderId,orderEvent));producer.send(newProducerRecord<>("inventory",productId,deductEvent));producer.commitTransaction();// 如果 commitTransaction 之前发生异常,abortTransaction 回滚

消费者侧:设置 isolation.level=read_committed 的消费者永远不会看到已中止事务的消息。Broker 通过 Last Stable Offset(LSO) 实现这一点:LSO 是所有事务都已经做出决定的那个位移位置,read_committed 消费者只能读到 LSO 之前的消息。过滤发生在客户端侧——Broker 仍然会返回 LSO 以下的原始批次,但 read_committed 客户端会根据批次中的 AbortedTransactions 信息自行丢弃已中止的记录。

4.2 生产者 Fencing:防止“僵尸生产者”写入
transactional.id 是事务生产者的稳定身份标识。在任意时刻,一个 transactional.id 只能有一个活跃的 Producer 实例。

Fencing 机制的工作方式:当一个新的 Producer 实例以相同的 transactional.id 启动时,它会递增该 transactional.id 对应的 epoch。旧的 Producer 实例在下次执行 produce、commit 或 abort 时,会收到 ProducerFencedException,被强制踢出,无法再写入任何事务消息。

Fencing 解决的现实问题:一个常见的故障场景是“应用重启时旧实例没有干净关闭”——旧进程可能仍然存活并持有 TCP 连接,但新实例已经启动。如果没有 fencing,两个实例可能同时以相同的 transactional.id 写入,导致事务状态混乱。Fencing 保证只有一个实例能“合法地”写入。

4.3 事务的代价
事务消息不是免费的:

延迟增加:beginTransaction → produce → commitTransaction 是一个多步协调过程,涉及事务协调器的交互。

transaction.timeout :事务有一个超时窗口(默认 60 秒),超时后事务会被协调器中止,对应的 Producer 也会被 fenced。

transactional.id 的稳定性要求:transactional.id 必须在应用重启后保持不变,否则 fencing 机制失效。通常从配置中读取,而不是每次随机生成。

五、小结
acks 三档:0(发出即忘,可能丢失)、1(Leader 确认,Leader 故障时可能丢失)、all(全 ISR 确认)。Kafka 3.0 起 acks 默认值已改为 all。

acks=all 必须配合 min.insync.replicas :ISR 会收缩,只设 acks=all 而不设 min.insync.replicas,在 ISR 收缩到单副本时,耐久性会静默退化。生产推荐组合是 RF=3 + acks=all + min.insync.replicas=2,容忍 1 台 Broker 故障。

幂等性生产者:通过 PID + 序列号去重,边界是“单分区 + 单会话” 。生产者重启后幂等性失效;冲突配置(如 acks=1)会导致幂等性被静默关闭。

事务消息:实现跨分区原子写入,read_committed 消费者永远看不到已中止事务。Fencing 机制保证同一个 transactional.id 在任意时刻只有一个活跃实例,旧实例在下次操作时被强制踢出。

配置检查:如果开启了幂等性,务必显式设置 acks=all 并确认没有冲突配置,否则幂等性可能被静默禁用而不报错。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进