1. 项目缘起:为什么突然啃起了 Kafka 的 Exactly-Once
前阵子接手了一个数据同步任务,上游把业务库的变更日志打进 Kafka,下游要落一套数仓。起初一切正常,但某天凌晨值班电话响了——重跑任务后,数仓里出现了重复记录。排查了半天,发现是消费端在批量写入后、提交 offset 之前挂了,重启后把同一批数据又消费了一遍。问题本身不复杂,但当时让我意识到一个更深的事情:我对 Kafka 的 Exactly-Once 一直停留在“名词听过、原理模糊”的阶段。正好借这个契机,把 Kafka 原子更新、精确一次消费(Exactly-Once)这套东西系统梳理了一遍,这篇文章就是我的完整学习笔记。
这篇笔记适合谁看?如果你正在做实时数仓、流式计算,或者面试时被问过 Kafka 和 Exactly-Once 区别,那这篇内容会非常对口。我会从最基础的语义定义讲起,逐步深入到事务机制、幂等生产者、消费端事务恢复、隔离级别、常见参数配置,最后结合实践讲几个网上很少写透的坑。不保证你读完立马变成 Kafka 源码级大神,但绝对能让你在排查重复消费或设计消费端方案时,有一个清晰的地图。
整篇笔记的信息量不小,所以我会尽量用“我踩过的坑 + 原理拆解 + 可直接抄的配置”这种节奏来写,而不是干巴巴地罗列概念。
2. 先搞清楚 Exactly-Once 到底在说什么
2.1 三种消费语义的直观对比
很多初学者一上来就盯着“Exactly-Once”这个术语,反而把基本盘搞混了。其实 Kafka 的消费语义有三个档位:At-Most-Once、At-Least-Once、Exactly-Once。我用一个“外卖配送”的类比来拆解,应该一眼就能懂。
At-Most-Once,意思是“最多送一次”。外卖员把餐送到门口,不管你有没有收到,他都直接点“已完成”。优点是不会重复送,缺点是如果餐丢了,那你就饿着。对应到 Kafka 里,就是消费者在处理消息之前就先提交 offset,这样即使消费者在处理过程中挂掉,重启后也不会再消费到那条消息,因为 offset 已经提交了。代价是消息可能丢失。
At-Least-Once,意思是“至少送一次”。外卖员把餐送到门口,你确认收到了他才会点“已完成”。如果你没回应,他就再送一次。优点是消息不容易丢,缺点是可能重复送。这是 Kafka 默认的、也是大多数场景在用的语义,实现方式就是消费者先处理消息、再提交 offset。如果消费者在处理完但还没提交 offset 时崩溃,重启后这条消息会被再次消费。
Exactly-Once,意思是“精确一次”。外卖员送到后,既能保证不丢,也能保证不重。听起来很完美,但它不是靠“送一次就恰好成功”来实现的,而是靠“就算重复送,也能通过某种机制把重复的影响去掉”。
提示:不要把 Exactly-Once 理解为“底层只处理一次”。它更准确的说法应该是“端到端只生效一次”。重复发生不可怕,可怕的是重复后结果错。
2.2 为什么默认的 At-Least-Once 会产生重复
默认情况下,Kafka 消费者是“先处理后提交 offset”。如果处理动作本身不是原子的,就会出现经典的两难:
- 如果先提交 offset 再处理消息,挂掉后消息丢失(At-Most-Once)。
- 如果先处理消息再提交 offset,挂掉后消息重复(At-Least-Once)。
你可能会想:那就用分布式事务把“处理消息”和“提交 offset”绑在一起呗?没错,这就是 Kafka Exactly-Once 的核心思路。但这里的难点在于,“处理消息”这个动作不一定是写入 Kafka,也可能是写入数据库、调用外部 API、更新缓存。Kafka 官方解决得最彻底的是“处理消息并写回 Kafka”的场景,也就是流处理场景;对于外部系统,它只能保证消费端的读取语义,无法保证你外部写入的幂等性。
2.3 我最初的理解误区
我一开始以为,只要生产者设置了enable.idempotence=true,消费者设置isolation.level=read_committed,整个链路就做到 Exactly-Once 了。后来发现这个理解不全对。生产者的幂等只能保证“单分区、单会话内不重复”,消费者的事务读只能保证“不读到未提交的消息”,真正要把整条链路做成 Exactly-Once,还需要消费端有一个“事务性写入外部系统”的配合。也就是说,Kafka 官方提供的 Exactly-Once 主要适用于消费-处理-生产这种闭环场景,典型工具是 Kafka Streams。如果你的下游是你自己的数据库,那“原子更新”这个动作就必须由你自己设计。
这个理解偏差直接决定了后面查资料的方向。所以下面几节,我会按“生产者幂等 → 事务机制 → 消费者事务性读 → 跨系统原子更新”这条主线来展开。
3. Kafka 原子更新的技术底座:幂等生产者与事务机制
3.1 幂等生产者到底解决了什么问题
在 Kafka 0.11 之前,生产者重试可能导致消息重复。比如网络抖动导致 Broker 已经写入消息,但生产者没收到 ack,于是重试发送,Broker 又写了一条一模一样的。0.11 之后引入了幂等生产者,核心思路是给每个生产者会话分配一个ProducerId,并且每条消息带一个从 0 开始递增的SequenceNumber。Broker 端会缓存每个分区最近的若干条消息序号,如果收到序号不连续或重复的消息,就能判断出是否重试并拒绝重复写。
我当初有个困惑:既然幂等能去重,是不是就不用事务了?答案是不行。幂等生产者只管“生产者到 Broker”这一段的写入不重复,管不了“跨会话”的重复。你重启生产者、或者发生分区 Leader 切换后,ProducerId会变,序号会重新从 0 开始,这时 Broker 无法识别新旧会话之间的重复。此外,幂等生产者也不能解决“读-处理-写”场景中,消费端读取了消息但没提交 offset,随后重复消费导致重复写入的问题。
所以,幂等生产者是 Exactly-Once 的一个必要条件,但不是充分条件。
3.2 事务机制:跨分区原子写入
Kafka 的原子更新真正依托的其实是事务机制。Kafka 引入了一个新的组件叫Transaction Coordinator,专门负责管理生产者事务。它的核心特点包括:
- 生产者开启事务后,会先向 Coordinator 注册,并获取一个
TransactionalId对应的 PID。 - 事务提交时,Coordinator 会在一个内部的
__transaction_statetopic 中记录事务状态。 - 跨分区写入时,要么全部成功提交,要么全部中止回滚。
这里有个容易混淆的概念:Kafka 事务的“原子性”是体现在“多个分区的多条消息要么全部可见、要么全部不可见”上。它解决的并不是 MySQL 里的那种通用事务,而是流处理中“批量写多个 topic/分区”的一致性。
用生活化的例子来理解:你在淘宝下单后,库存扣减、订单生成、积分变动这三个动作分布在三个不同的系统里,如果其中一个失败,另外两个就不能生效。Kafka 事务就是为这三个动作在同一事务里提交提供基础设施。但注意,这三个动作如果都发生在 Kafka 内部(比如从 A topic 读、处理后写入 B topic 和 C topic),Kafka 事务就能完美支持。一旦其中一个动作发生在 MySQL,Kafka 就管不住了。
3.3 TransactionalId 与 ProducerId 的关系
这是很多文档里一笔带过的点,但我觉得挺重要。ProducerId是为了幂等而存在的,它是会话级的;TransactionalId是为了事务而存在的,它是业务级的。同一个TransactionalId在不同会话中会对应不同的ProducerId,但通过TransactionalId,Kafka 可以保证:
- 旧的生产者事务未完成时,新的生产者不能抢占这个事务 ID。
- 如果旧生产者挂了,新生产者可以继续执行未完成的事务,并保证状态一致。
这个设计解决了我之前提到的“跨会话重复”问题:只要你的生产者每次启动都带上同一个TransactionalId,即使重启,事务也能保持之前的约束。这一点在实现消费端“读-处理-写”闭环时,格外重要。
3.4 隔离级别:read_uncommitted 与 read_committed
如果你了解了事务机制,就一定会遇到隔离级别的选择。Kafka 消费者有两个隔离级别:
read_uncommitted:可以读到所有消息,包括未提交事务中的消息和已中止事务中的消息。这是旧版消费者的行为。read_committed:只能读到已提交事务中的消息。对于已中止事务的消息,消费者会直接跳过。
在实际排查重复消费时,我的经验是:如果你开启了下游事务性写入,消费者最好设置read_committed,否则你可能会读到事务最终回滚掉的消息,进而把“幽灵数据”写进下游系统。
注意:
read_committed只是让消费者“看不到未提交的消息”,并不意味着消费者端所有问题都自动解决了。真正端到端的 Exactly-Once,还需要结合下文说的事务性 offset 提交。
4. 消费端如何实现精确一次消费
4.1 从“只保证写”到“读写都在事务里”
很多教程讲了生产者的幂等和事务,但对消费端的描述比较模糊。实际上,Kafka Streams 中实现 Exactly-Once 的方式,是把“消费消息”和“提交 offset”放在同一个事务里。具体逻辑是:
- 消费者从源 topic 读取消息,不自动提交 offset。
- 业务处理后,将结果写入目标 topic,同时将源 topic 的 offset 信息作为一条“标记消息”写入一个内部 topic。
- 最终意图是:目标 topic 的消息、以及 offset 标记,要么一起提交,要么一起回滚。
但这个设计如果完全靠消费者自己写代码,非常复杂。Kafka Streams 已经把它封装好了,你只要设置processing.guarantee=exactly_once就能开启。可如果你用的是原生 KafkaConsumer,那就需要自己在“处理结果写入外部系统”和“offset 提交”之间做原子性控制。
4.2 最经典的实践:事务性输出到外部系统
当你把消息处理结果写入 MySQL 时,官方推荐的模式是“Kafka 事务 + 外部事务绑定”。网上有很多讲解,我觉得最直观的做法是这样:
- 在同一个事务中,将处理结果写入数据库。
- 同时,将这条消息的 offset 也写入数据库中的同一张表(或者使用消息本身的主键做唯一约束)。
- 数据库事务提交后,再手动提交 Kafka offset。
但这个方案有一个前提:你的数据库必须能提供事务,并且 offset 信息和业务数据写入的是同一个数据源。如果满足,数据库事务就是“跨系统原子更新”的枢纽。
我实际做过的方案是这样的:从 Kafka 消费一条订单消息,业务逻辑是把订单状态写入 MySQL,同时在 MySQL 里记录当前的 topic、partition、offset。因为“订单状态更新”和“offset 记录”是同一个数据库事务,所以它们要么同时成功、要么同时失败:
- 如果事务提交成功,但 Kafka offset 提交失败,重启后会重复消费这条消息,此时数据库里已经有了这条 offset 记录,业务处理逻辑就可以通过“去重键”跳过。
- 如果事务回滚,那 offset 也不会更新,重启后会重新消费并重新处理,数据最终一致。
这个方案的巧妙之处在于:Kafka 的 offset 不是唯一标准,数据库里的 offset 才是真正的“处理进度”。用生活化的话讲,就是“Kafka 提交的 offset 可以当作一个草稿,数据库里的 offset 才是盖章生效的版本”。
4.3 幂等写入:没有事务时的替代方案
如果下游不支持事务,或者你不想引入数据库事务,还有一个普适性更强的方案:幂等写入。也就是说,即使消息被重复消费,重复写入也不会产生重复数据。
实现幂等写的方式有很多,常见的有:
- 利用业务主键或唯一索引,直接覆盖写或 insert ignore。
- 在消息体中携带全局唯一的消息 ID,下游处理时先去重表查询,不存在再写入。
- 用状态存储或 Redis 做去重,记录最近处理过的消息 ID。
我用过最简单有效的方法,就是给目标表加一个“业务去重键”的唯一索引。消费端逻辑变成:先查一下这条记录是否存在;不存在则插入,存在则更新。这样 Kafka 重复投递多少次,最终数据都不会受影响。代价是你需要额外考虑并发插入时的唯一索引冲突处理,比如捕获 DuplicateKey 异常后转成更新。
实操心得:如果你要处理的数据量很大,去重表或唯一索引的方案需要谨慎评估。因为每次消费都多一次查询,对数据库压力不小。我的经验是,宁可让写入带上事务,也别每次都去查——除非你的瓶颈不在数据库。
4.4 消费端事务还有一个隐藏点:seek 与 reset
Kafka 消费者有一个特性,enable.auto.commit如果为 true,会自动定时提交 offset。这在普通 At-Least-Once 场景下没问题,但在 Exactly-Once 场景下就是灾难。因为自动提交可能发生在事务还没结束时,导致事务回滚后 offset 已经前移,消息就真的丢了。
所以,在设计与 Exactly-Once 相关的消费端时,一定要:
- 设置
enable.auto.commit=false。 - 手动在事务提交成功后再提交 offset。
- 如果使用 Kafka Streams,不要自己再额外手动提交 offset,交给框架处理。
此外,auto.offset.reset这个参数也要想清楚。它控制的是当消费者没有已提交的 offset 时,从哪开始消费。earliest表示从头开始,latest表示从最新开始。在 Exactly-Once 场景下,如果消费组是新创建的,通常需要根据业务需求选择从最早还是从最新开始,而不是无脑选 latest。
5. 核心参数与配置清单
5.1 生产者关键参数
如果你要用 Kafka 的事务能力和幂等能力,生产者的配置可以参考下面这份基础模板:
Properties props = new Properties(); props.put("bootstrap.servers", "kafka1:9092,kafka2:9092"); props.put("transactional.id", "tx-order-service-001"); props.put("enable.idempotence", "true"); props.put("acks", "all"); props.put("retries", "3"); props.put("max.in.flight.requests.per.connection", "1");这几个参数说明:
transactional.id:一旦设置,生产者会自动开启幂等。并且事务 ID 必须是稳定的字符串,不能每次重启都随机生成。enable.idempotence:显示开启幂等;如果设置了事务 ID,这个参数会被强制置为 true。acks=all:确保消息被所有副本确认,避免 Leader 切换时数据丢失。max.in.flight.requests.per.connection=1:保证同一个连接上最多只有一个未确认的请求,防止消息重排导致序号错乱。如果你已经开启了幂等,其实不需要手动设这个值,因为 Kafka 会自动保证有序;但为了兼容老版本,很多团队仍然显式设置。
注意:
transactional.id不要在不同业务逻辑之间复用。不同业务的写操作如果共用同一个事务 ID,会造成跨业务的相互阻塞。原则上每个业务处理单元应该有唯一的 ID。
5.2 消费者关键参数
消费者侧,我的建议配置:
props.put("enable.auto.commit", "false"); props.put("isolation.level", "read_committed"); props.put("auto.offset.reset", "earliest"); props.put("group.id", "order-consume-group");如果下游是 Kafka,希望由新生产者事务来管理 offset,消费者就不能开启自动提交。如果你直接用KafkaConsumer读取消息并手动提交 offset,事务性的 offset 提交可以这样写:
producer.sendOffsetsToTransaction( Collections.singletonMap(new TopicPartition(topic, partition), offset), consumerGroupId ); producer.commitTransaction();这种做法的含义是:把“源 topic 消费到的位置”写入到当前事务里。当这个事务提交时,下游目标 topic 的消息和源 topic 的 offset 更新就一起生效。如果事务回滚,两边的更新都会回滚。这就是 Kafka Streams 内部在做的事。
5.3 Kafka Streams 里的一行配置
如果你的场景适合直接用 Kafka Streams,那么做到 Exactly-Once 的难度会大幅降低。核心配置就两个:
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, "exactly_once"); props.put(StreamsConfig.APPLICATION_ID_CONFIG, "my-streams-app");Kafka Streams 会为你做的事情包括:
- 自动管理消费组和 offset。
- 将处理结果写入目标 topic 时,自动把 offset 放在同一个事务中。
- 对于状态存储(如窗口聚合、去重存储),也会用 Changelog topic 的方式保证状态的一致性。
如果你是新手,我建议先把这个框架跑通,再去看它的内部实现。因为直接用原生 KafkaProducer 写事务,细节很多,一旦没处理干净,反而比直接用 Streams 更容易出问题。
6. 实操案例:从 Kafka 消费并事务性写入 MySQL
6.1 场景设定与前置条件
为了把前面这些原理落到现场,我设计了一个实际案例。场景是:
- 一个 Kafka topic:
order-topic,里面是订单状态变更消息。 - 下游系统:MySQL,需要根据消息更新订单表。
- 目标:尽量做到不丢不重,即使重启消费者也不会产生脏数据。
前置条件:
- MySQL 使用 InnoDB 引擎,具备事务能力。
- 订单表有唯一键
order_id。 - Kafka 版本建议 0.11 以上,我用的是 2.8 版本。
- 消费者逻辑用 Java 原生 KafkaConsumer 编写,不使用 Kafka Streams,这样才能展示手工实现事务性写入的关键点。
6.2 核心代码结构与关键逻辑
整体思路是:在同一个数据库事务里,既更新业务数据,又更新消费进度表。如果数据库事务成功,再提交 Kafka offset。伪代码如下:
// 1. 准备 Kafka 消费者 KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps); consumer.subscribe(Collections.singletonList("order-topic")); // 2. 准备数据库连接 DataSource ds = getDataSource(); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000)); for (ConsumerRecord<String, String> record : records) { Connection conn = ds.getConnection(); try { conn.setAutoCommit(false); // 业务更新:根据消息内容修改订单状态 updateOrderStatus(conn, record.value()); // 消费进度更新:记录 topic/partition/offset insertOrUpdateOffset(conn, record.topic(), record.partition(), record.offset()); conn.commit(); // 数据库事务已提交,此时再提交 Kafka offset consumer.commitSync(); } catch (Exception e) { conn.rollback(); // 记录错误日志,可能需要人工介入或重试 throw e; } finally { conn.close(); } } }这个方法有一个细节值得注意:consumer.commitSync()是在数据库事务提交之后才调用的。如果这一步抛异常,Kafka 认为 offset 还没提交,那么重启后会重新消费相同的消息。但因为数据库里已经有了对应的 offset 记录,业务处理函数updateOrderStatus需要判断当前这条消息是否已经处理过。可以简单地在更新前查一下order_id对应的处理进度:
- 如果数据库里的 offset >= 当前消息的 offset,说明这条消息已经被处理过了,直接跳过。
- 否则,正常处理。
这个“查重”和“更新”同样在同一个数据库事务里,所以是线程安全的。
6.3 为什么数据库里要记录 partition 和 offset
很多第一次接触这个方案的人会问:既然订单表有唯一键 order_id,直接 upsert 不就行了,为什么还要额外记录 partition 和 offset?
我的经验里,至少有这三个理由:
- 唯一键去重只能覆盖“同一业务对象被重复投递”的情况,但如果一条消息对应多个业务对象的变更,比如一个订单消息同时要更新订单表和积分表,那订单表的唯一键不足以保护积分表。
- 消费进度表可以用于“优雅停机”和“断点续传”。如果消费者崩溃时数据已经写入但 Kafka offset 未提交,重启后你就可以从进度表里精确知道哪些 offset 已处理,避免从头查业务表来猜测。
- 当你想要做数据回溯、重放某些消息时,进度表是一个宝贵的索引。
不过,记录进度表也会增加写入成本。所以对于纯幂等、只更新单表的简单场景,你也可以省略进度表,只依赖唯一索引。
6.4 事务失败时的体验记录
我在实际测试中,故意在updateOrderStatus方法里抛出一个模拟异常,比如把订单状态更新为非法值。观察到的行为是:数据库事务回滚,业务表没有变化,进度表也没有更新,Kafka offset 依然停留在旧位置。整个消费者循环报错退出,但我手动重启后,同一批消息被重新拉取,重新执行数据库事务,最终正确写入了数据。
这说明“数据库事务+offset手动提交”的方案确实能把重复消费的后果控制住。但也要承认,它不是零成本的。每个消息都要开启一个数据库事务,吞吐量会受影响。如果你对吞吐量敏感,可以改成批量事务,每批消息攒够一定数量后再提交一次数据库事务。不过我建议初学者先用单条事务跑通,理解整个链路后再去优化性能。
另外,我还测过一个特殊场景:如果消费者的自动提交是 true,这个方案就会出问题。因为自动提交可能在你数据库事务还没提交时,就先行提交了 Kafka offset。一旦数据库事务失败,offset 已经前移,这条消息就永久丢失了。所以再次强调:enable.auto.commit=false是硬性要求。
7. 我踩过的坑:Exactly-Once 使用的典型问题
7.1 事务超时的坑
第一次测试事务时,我把transaction.timeout.ms设置成了一个很大的值,结果遇到了 Coordinator 超时错误。后来查文档发现,Broker 端的transaction.max.timeout.ms限制了这个值。如果你的生产者设置超过 Broker 限制,就会报错。解决办法是让两者保持一致,或者使用默认值。
这个参数控制的是事务从开始到提交的最长时间。如果业务处理时间很长,可能还没提交,事务就超时了。这时 Kafka 会强制中止事务,并且日志里出现类似Transaction timeout expired的错误。遇到这种情况,要么优化处理逻辑,要么调大事务超时,但要确保 Broker 端允许。
7.2 跨会话的孤儿事务
还有一个比较隐蔽的问题:生产者开启了事务但没正常提交,比如进程被 kill,那么 Coordinator 里会残留一个进行中的事务。这个事务在某些情况下会阻塞后续的事务。Kafka 的机制是,当一个新生产者使用相同TransactionalId启动时,会触发“事务超时后的恢复”,从而中止之前的孤儿事务。
但我在测试中发现,如果超时时间设置得很大,而这个孤儿事务一直不超时,新生产者就无法立即恢复。所以我建议事务超时时间不要贪大,一般 60 秒左右就够了。如果业务处理真的需要很久,考虑拆批次,而不是延长超时。
7.3 消费者 group 与事务结合时的坑
当多个消费者线程共享同一个 group.id 时,如果有消费者线程手动提交事务,要注意不要在不同线程里混用同一个KafkaProducer。Kafka 的事务模型要求,同一个事务上下文里的发送和提交,是线程安全的,但不同线程各自启动事务,就必须使用不同的 producer 实例。如果一个 producer 实例在多线程里同时开启多个事务,会导致状态错乱。
这个坑在 Kafka Streams 里已经被框架规避了,因为 Streams 会在内部管理好线程和 producer 的绑定关系。但如果你自己写代码,一定要注意。
7.4 重复消费并不是只能靠 Kafka 解决
我听到过一个说法:“用了 Exactly-Once 就不用处理重复了。”这句话对了一半。Kafka 的 Exactly-Once 能保证“消息在 Kafka 的读-处理-写闭环中只生效一次”,但如果你把数据写入了 Kafka 之外的系统,那么这个外部系统是否幂等,才是最终结果是否精确的关键。
举一个我遇到的真实例子:我们在消费端把处理结果写入了 Redis。Redis 的 SET 操作天然幂等,所以重复消费也没问题。但如果业务是“给用户积分加 10”,用 INCR 就出事了。重复消费后,积分被多加了一倍。后来我们把操作改成“记录消费过的消息 ID + 用 SETNX 做幂等标记”,才算解决。
所以,我的建议始终是:不要完全依赖 Kafka 的 Exactly-Once,一定要在下游设计一道幂等防线。两道防线合在一起,才能做到真正的端到端精确一次。
8. 实用排查工具与命令
8.1 查看消费者组消费进度
排查重复消费、堆积、延迟问题时,我习惯先用命令行确认消费组状态:
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-consume-group输出里会显示每个分区的 CURRENT-OFFSET、LOG-END-OFFSET、LAG。如果 LAG 长时间不变,但你在日志里看到消费者正在处理消息,那十有八九是 offset 提交逻辑出了问题。
8.2 查看事务的状态
如果你启用了 Kafka 事务,可以查内部 topic:
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic __transaction_state --from-beginning这里面的内容读起来比较费劲,但如果你了解消息格式,可以看到事务 ID、状态、超时时间等信息。对于排查“事务未提交导致阻塞”这类问题非常有用。
8.3 开启生产者日志
调问题时,我给生产者加过这些日志配置:
log4j.logger.org.apache.kafka.clients.producer.internals.TransactionManager=DEBUG log4j.logger.org.apache.kafka.clients.producer.internals.Sender=DEBUG这样能看到事务从初始化、加入分区、准备提交,到最终提交或中止的完整流程。比看业务日志直观得多。缺点是日志量大,生产环境建议只在排查时开启,用完就关。
8.4 手工测试幂等性的简单思路
如果你想验证自己的消费端和生产者是否真的做到不重复,可以设计一个“强制重启”测试:消费者每处理 100 条消息后强制 kill 进程,然后重启。观察最终目标表里的数据条数和预期是否一致。这个方法虽然粗暴,但比任何理论推导都有效。
我第一次做这个测试时,就暴露了自动提交 offset 没关干净的问题,导致丢数据。所以建议大家在自己熟悉的环境里,先把这种故障注入测试跑通。
9. 从学习笔记到实际落地的总结
9.1 我在项目中最终选择的方案
说实话,Kafka 的 Exactly-Once 并不是一个“开箱即用”的功能。它更像是一整套约束和协议的组合。在我自己的项目中,我最终选了“消费者手动关闭自动提交 + 数据库事务内更新业务数据和进度表 + Kafka offset 最后提交”的方案。这个方案兼容了我们现有的 MySQL 架构,也不要求把下游全部换成 Kafka。生产环境跑了几周,没有再出现重复数据问题。
如果你只是做纯流计算,比如从 Kafka 读数据、聚合后再写回 Kafka,那直接上 Kafka Streams,把processing.guarantee设为exactly_once是最省心的选择。但如果你要写数据库或调用外部 API,一定要自己设计幂等机制。
9.2 学习路径建议
如果你现在正被这块内容困扰,我建议按下面的顺序去看:
- 先搞懂三种语义的区别,尤其是 At-Least-Once 为什么是默认。
- 再把幂等生产者的序号机制和事务 Coordinator 的角色弄明白。
- 接着看 Kafka Streams 中 Exactly-Once 的使用方式,从配置入手,看框架帮你做了什么。
- 最后再尝试用原生消费者实现事务性写入,模拟故障,验证幂等方案。
- 有余力的话,看一遍
TransactionManager源码,很多疑惑会瞬间解开。
9.3 后续可以扩展的方向
这篇笔记主要聚焦在 Kafka 内部的原子更新机制,以及最简单的 MySQL 下游事务绑定实践。如果你继续深入,还可以研究这些方向:
- 使用 Kafka Connect 的 Exactly-Once 特性,减少自己写消费者的成本。
- 结合 KSQL/ksqlDB 做流式 ETL,在声明式 SQL 层面实现精确一次。
- 研究 Kafka + Flink 的端到端 Exactly-Once,因为 Flink 的 checkpoint 机制和 Kafka 事务,有自己的一套协同方式。
- 如果你做的是微服务间的异步事件传递,可以考虑 Outbox Pattern:把业务操作和事件写入同一个数据库事务,通过一个中继组件把事件发布到 Kafka,从源头避免“业务成功但事件丢失”或“事件发出但业务失败”的问题。
我在探索 Outbox Pattern 时,发现它和 Kafka 事务是互补关系。Kafka 事务解决的是“多个 topic 之间的原子写”,Outbox Pattern 解决的是“数据库和 Kafka 之间的原子写”。两者结合,才是非常强的数据一致性方案。
最后再分享一个个人体会:不要试图让所有场景都成为 Exactly-Once。很多时候,下游自然幂等加上 Kafka 的 At-Least-Once,已经能拿到和 Exactly-Once 一样的结果。如果你能识别出哪些场景可以不依赖事务,你的系统会简单很多。Exactly-Once 是一个很有用的工具,但它不是万能的银弹。只有当你清楚它解决的是什么、不能解决什么,你才真正掌握了这节课。