先从一个重复数据的凌晨说起
很多团队是在这样一个凌晨开始认真研究 Exactly-Once 的:实时任务跑了半年都没出过问题,某天 Kafka 集群滚动重启,消费者组触发重平衡,几个分区的消息被重复消费。第二天早上,订单表里多出几条一模一样的记录,业务方问,不是说数据管道很可靠吗,怎么会重复。

更麻烦的是,任务日志里没有任何异常,记录也回滚不掉。于是有人得出结论:我们要上 Exactly-Once。
这个结论不算错,但大部分讨论对 Exactly-Once 的理解都停留在概念层面。“每个消息恰好被处理一次”这句话听起来很美,在分布式流系统里却几乎不可能实现。真正能做到的,是让“处理结果”只生效一次。这个差别,才是整个问题的起点。
先分清三种投递语义
围绕数据投递,业界习惯用三种语义描述一条数据的处理保证:
- At-Most-Once(至多一次):消息可能丢失,但不会重复。
- At-Least-Once(至少一次):消息不会丢,但可能被处理多次。
- Exactly-Once(恰好一次):消息既不丢,重复处理也不会产生重复结果。
注意第三种语义的表述,它没有承诺“不重复处理”,而是承诺“不产生重复结果”。原因是分布式环境下,重发、重试、重放是常态,物理意义上的“只处理一次”无法被所有参与者同时确认。可靠的系统能做的是:即使处理了两次,最终写入外部系统的状态也等价于只处理了一次。
这个“等价只处理一次”,通常靠两条路径实现:要么让整条链路具备事务性,把处理与输出打包成原子操作;要么让输出侧具备幂等性,同一输入无论来几次结果都不变。后者的工程代价通常小得多。
单个组件 Exactly-Once,不等于端到端 Exactly-Once
这是最典型的误解,也是最容易让人放松警惕的地方。一条实时数据管道至少包含三段:消息中间件、流处理引擎、外部存储或服务。每一层都有自己的 Exactly-Once 方案,但它们覆盖的范围并不重叠。
Kafka 的 Exactly-Once 由生产端幂等(enable.idempotence=true)和事务(transactional.id)构成。它解决的是:同一个 producer 会话内,因重试不会在 Kafka 内部产生重复消息;一批消息跨分区写入时,要么全部可见,要么全部不可见。消费端开启 read_committed 后,只能读到已提交的事务消息。
Flink 的 Exactly-Once 依赖检查点机制。周期性对齐上游输入屏障,把算子状态和消费位点一起做成快照,故障后从快照恢复,并通过两阶段提交把结果输出给下游。它解决的是:引擎内部状态与输出的原子性。
问题出在外部系统。假设 Flink 把结果写进 MySQL,MySQL 并不参与 Flink 的检查点协议,Flink 也无法把这次 MySQL 写入纳入自己的事务。任务重启后,从检查点重放的算子会把同样的数据再写一遍。数据不会丢,但外部世界的副作用无法被回滚。所以即使 Kafka 和 Flink 各自开启 Exactly-Once,整条管道仍然等价于至少一次。
用一个配置片段来看会更直观:
// Flink 作业中开启 Exactly-Once 的关键配置
env.enableCheckpointing(10000);
env.getCheckpointConfig().setCheckpointingMode(
CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(5000);
// 输出到 Kafka 时使用事务性 Sink
KafkaSink sink = KafkaSink.builder()
.setTransactionalIdPrefix("order-pipeline")
.build();
这段配置能保证 Flink 到 Kafka 这一段的事务性,但对下游 MySQL 或业务 API 的重复写入没有任何约束力。配置是起点,不是终点。
为了 Exactly-Once,实际要付出什么
精确一致性从来不是免费的,账单通常出现在三个地方:延迟、吞吐、运维复杂度。
先说检查点对齐。Flink 在 EXACTLY_ONCE 模式下,每个算子要等所有上游分区的屏障都到达后才能生成快照并继续处理。如果某个上游分区积压,整个作业都会被拖慢。改用非对齐检查点能缓解,但代价是更大的状态体积和更复杂的恢复过程。
再看事务开销。Kafka 事务每次提交都有额外的网络往返和元数据同步,事务日志主题在事务量大的链路上会变成新的瓶颈。生产环境里,开启事务后吞吐出现明显下降并不罕见,具体幅度取决于消息大小、分区数和 commit 频率。吞吐不是白来的,你要为每一次原子提交买单。
还有恢复时间。故障后作业会从最近的检查点恢复,检查点间隔越长,需要重放的数据越多,恢复时间越长。在这一窗口内,重复消费和端到端延迟放大是不可避免的。换句话说,Exactly-Once 提升的是正确性,不是可用性,它甚至可能让故障窗口变得更长。
最后是跨系统协调成本。真正的端到端 Exactly-Once 要求下游参与事务或提供幂等接口,比如改造表结构、引入事务表、增加唯一键。对于跨团队的系统,这部分改造往往比流处理任务本身还大。
三个常见的判断误区
误区一:Exactly-Once 意味着输出没有重复
实际上开启 Exactly-Once 的管道,输出端依旧可能出现重复记录。Flink 恢复期间会重放数据,Kafka 事务清理和消费者组重平衡都可能让同一条数据被读到两次。没有重复的是“结果”,不是“记录”。只要下游用唯一索引或 upsert 合并,重复写入就能等价于一次写入。
误区二:引擎开了 Exactly-Once,端到端就安全了
前面已经说明,外部系统不参与引擎的事务协议,重复副作用仍然存在。兜底只能靠下游的幂等语义,或者靠对账任务定期修正。
误区三:At-Least-Once 是低配,有条件就该升级
这个判断把正确性想得太便宜了。点击流分析、监控聚合、日志检索这类场景,少量重复对最终结果几乎没有影响,硬上 Exactly-Once 等于用吞吐和运维复杂度换一个没人需要的保证。
三条工程路径怎么选
下面这张表总结了当前常见的做法,以及它们各自的位置。
| 路径 | 重复结果 | 延迟影响 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| At-Least-Once + 幂等写入 | 记录可能重复,结果不重复 | 低 | 中:需要设计唯一键和 upsert | 订单、库存、用户状态等大多数业务管道 |
| 引擎级 Exactly-Once(Flink + Kafka 事务) | 引擎输出不重复 | 中:检查点与事务同步开销 | 高:需要深入理解检查点和事务 | 财务聚合、对账、计费前置链路 |
| 端到端事务(引擎 + 支持事务的存储 + 两阶段提交) | 业务结果严格一次 | 高 | 很高:跨系统改造与排障成本大 | 货币结算、风控裁决等极少数链路 |
仔细观察会发现,大多数场景更适合第一行。重复到达是常态,但用唯一键加条件更新就能把重复变成无害。Exactly-Once 越往底层走,越像一种需要长期运维的工程奢侈品。
如果要落地,先把这几件事做掉
先问下游。数据最终写数据库、发消息还是调 API?目标系统有没有天然唯一键?能不能承受一次重复副作用?大部分情况下用幂等 upsert 就能解决,不必急着上事务。
做一次故障演练。开启 Exactly-Once 之前,模拟任务在写入后半段崩溃,观察恢复后是否有重复、延迟放大多少、是否存在消息空洞。很多配置问题都是在这种演练里暴露的,而不是在发版之后。
把检查点设计当容量规划的一部分。检查点间隔、状态大小、恢复时间三者强相关。你要的不是尽可能频繁的检查点,而是业务能接受的恢复时间和可承受的状态开销之间的平衡。
监控端到端指标。别只看引擎内部的 processed 计数,要在输出端对账,用唯一键统计重复率,用时间戳统计端到端延迟。数据管道的一致性,最终只能由下游的结果来证明。
什么时候可以放心用 At-Least-Once
当下游存储天然具备去重能力时——比如带主键的数据库表、按事件时间去重的聚合场景、以及 append-only 的日志系统——At-Least-Once 配合幂等逻辑通常已经足够。它成本低、容易排查、对团队的运维能力要求也低。真正的风险不是多处理了一次,而是多处理了却没人发现。所以关键不是把语义升级到 Exactly-Once,而是给管道配一个定期对账的机制。
Exactly-Once 是一种能力,更是一种约束。它逼你去想清楚整条管道的边界在哪里、谁负责去重、谁承担故障恢复的成本。想清楚这三个问题,最后把语义参数改成 exactly_once,不过是确认一下而已。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/632/