Kafka Exactly Once 与 At Least Once:边界与取舍
对比 Kafka 的 At Least Once、幂等生产和 Exactly Once 语义,明确事务、偏移量、消费者配置与外部系统之间的边界。
Kafka 的 At Least Once 通常意味着:消息处理成功后才提交消费者偏移量,失败重启时可能重复处理,但不应因为提前提交而漏掉未完成消息。Exactly Once 则不是给一个配置项就能获得的“全链路魔法”,它至少需要幂等或事务生产者、正确的事务消费者配置,以及把输出写入和偏移量提交放在同一可恢复边界内。只要链路中有一个外部数据库、HTTP 接口或不支持事务的副作用,Kafka 的 Exactly Once 就不能直接推出外部结果也只发生一次。
先区分三个概念
At Least Once 关注的是不丢消息。消费者先处理,再提交偏移量;如果进程在处理完成但提交成功之前崩溃,重启会从上次已提交位置重新读取,于是发生重复。它的优势是模型简单、兼容范围广,代价是下游必须幂等,或接受重复事件。
幂等生产解决的是生产端重试造成的重复。Kafka 官方配置说明,启用 enable.idempotence=true 后,生产者可以避免同一生产者会话中由于重试导致的重复写入,并要求相容的 acks、重试和飞行中请求配置。它不等于消费者处理结果只发生一次,也不等于跨数据库操作具有事务性。
Exactly Once 需要事务把一组输出消息作为一个原子提交单元。配置 transactional.id 后,生产者可以让事务语义跨越多个生产者会话,并通过事务恢复识别前一会话未完成的事务。读取这些事务消息的消费者应使用 isolation.level=read_committed,否则可能读到后来被中止的事务记录。由此可见,Exactly Once 的有效范围是“参与该事务的 Kafka 读写链路”,边界外的动作仍需单独设计。
At Least Once 的实现路径
一个典型处理循环是:poll 一批消息,执行业务处理,确认目标端成功,再提交“下一条要读取的 offset”。KafkaConsumer Javadoc 明确区分了 position 和 committed position:position 是下一次要返回的消息位置,会随着 poll 前进;committed position 是安全存储的位置,重启或重新平衡后从这里恢复。提交的 offset 应是下一条待处理消息的 offset,而不是最后一条已经处理消息的 offset。
示意代码可以写成下面的顺序:
records = poll()
for record in records:
result = process(record)
if result.success is false:
stop_or_retry()
commitSync(next_offsets(records))这里的关键不是 commitSync 这个调用本身,而是提交发生在处理完成之后。如果使用异步提交,要处理回调错误和提交顺序;如果把处理放到线程池,要确保提交不会超过仍在处理的消息。Kafka Javadoc 还提醒,处理时间不可预测时可以让消费者继续 poll,但必须防止提交位置领先于实际处理位置,并在必要时暂停分区。
下游幂等通常有三种做法。第一,以事件唯一 ID 或业务主键做去重记录,重复到达时更新同一行;第二,把写入操作设计为 upsert,并利用版本号拒绝旧事件;第三,对允许重复的计数、通知或索引更新进行可重复计算。每种方式都有代价:去重表需要存储和清理,upsert 依赖目标数据库条件更新,计数类业务则可能需要事件 ID 集合或可重放的聚合模型。
实时开发 StreamWorks
覆盖采集、开发、运维全链路的云原生一站式实时数据开发平台
Exactly Once 的实现路径
如果处理逻辑只在 Kafka 内部读写,可以把消费、变换和输出发送放进一个事务:事务开始后读取一批消息,发送结果到目标 topic,再把输入偏移量作为事务的一部分提交;事务成功提交时,输出和偏移量一起可见,失败则一起回滚。下游消费者用 read_committed 读取,才能把中止事务排除在可见结果之外。
生产者还要设置稳定且唯一的 transactional.id。它通常应与有状态处理实例或分片身份对应,不能让多个实例意外共享同一个 ID,否则会互相 fencing。事务也有超时时间和协调开销,单个事务不能无限扩大;批次过大可能增加恢复时间和内存压力,批次过小则提交频率上升、吞吐下降。
Exactly Once 对可见性的要求也会改变“lag”的含义。在 read_committed 模式下,消费者读到的是已提交事务,分区的有效终点可能是 Last Stable Offset,而不是普通读取模式下的高水位线。一个事务长时间未提交时,生产端看似持续写入,消费者却可能在稳定边界等待。监控系统必须知道当前隔离级别,否则会把正常等待误判成普通消费停滞。
外部数据库是边界的分水岭
假设消费者从 Kafka 读取订单事件,先向 MySQL 写入,再提交 Kafka offset。若 MySQL 提交成功、进程在 offset 提交前崩溃,重启会重复写入;如果 MySQL 写入不是幂等,就会出现重复订单。反过来,若先提交 offset 再写 MySQL,进程崩溃可能造成漏写。Kafka 事务不会自动包住一个独立数据库事务。
可选方案包括:在目标端使用业务键和条件 upsert;使用本地事务日志或 outbox,让数据库写入和事件发布有可恢复记录;把外部副作用变成可重放命令,使用唯一请求 ID 做去重;或接受 At Least Once,并把重复处理纳入业务语义。选择哪种方案取决于目标系统是否支持事务、重复操作的成本以及是否允许最终一致,而不是取决于“Exactly Once”这个标签听起来更可靠。
金融行业
围绕监管报送、风险监测、客户经营与总分协同,统一数据口径和安全管控,让金融经营与风控有数可依。
什么时候选择哪一种
选择 At Least Once,适合目标端易于幂等、链路跨越多个非事务系统、需要较低延迟和较高吞吐的场景。应把重点放在提交时机、重试边界、去重键、死信处理和可重放能力上。
选择 Kafka 内部 Exactly Once,适合输入和输出都在 Kafka、重复结果会带来明显成本、并且团队能够运维事务 ID、事务超时和 read_committed 消费者的场景。要用端到端样例验证崩溃恢复,而不是只检查配置是否存在。
如果业务需要数据库和 Kafka 的强一致,优先评估 outbox、CDC 或受控的事务协作方案。它们会增加表结构、延迟和运维工作,但边界清楚;把“Kafka Exactly Once”直接写进跨系统 SLA,则属于未经验证的扩张。
三个失败模式
第一,启用了幂等生产者,却让应用层在发送失败后手工重新构造并发送同一业务事件。生产者只能识别它负责的发送序列,应用层制造的新记录仍可能重复,必须使用稳定事件 ID 做业务去重。
第二,生产端使用事务,消费端仍用默认的 read_uncommitted。消费者可能看到中止事务中的记录,最终统计与发送端的提交结果不一致。事务链路上线前,要同时核对生产者、目标 topic 和消费者隔离级别。
第三,把手动提交当作 Exactly Once。手动提交只能改变 offset 的安全点,不能让外部写入和 offset 成为一个原子动作。看到重复时,应先定位是处理后崩溃、提交重试、事务中止还是外部副作用重试,再决定改提交顺序还是改目标端幂等。
最终的验收应包含故障注入:处理前崩溃、处理后提交前崩溃、事务提交超时、消费者重平衡和目标端短暂不可用。记录输入事件数、输出可见事件数、重复数、漏数和恢复时间,只有这些结果在约定边界内稳定,才能准确地说系统选择了 At Least Once 或 Exactly Once。


