《Java 消息中间件·Kafka 内核与客户端》1.3 交付语义的真实成本

at-most-once、at-least-once、exactly-once 不是三个开关,而是三组失败窗口的取舍。本节先打出 4.3.0 的真实默认值,再分别复现「提交早于处理」的丢失、「处理完未提交」的重复、事务提交与 abort 的可见性,最后划清 exactly-once 只在 Kafka 读写链路内成立的边界。

三档语义,三个失败窗口

「Kafka 支持三种交付语义」这句话本身没错,但它容易让人以为这是三个配置项,勾一下就行。实际情况是:每一档语义都是对一组失败窗口的取舍,窗口在哪,代价就在哪。

一条消息从生产者到被业务处理完,至少经过三个可能失败的环节:

环节可能发生什么谁来决定怎么处理
生产 → broker请求丢了、leader 换了、响应没回来生产者的 acks、重试、幂等
broker 存储副本没追上、leader 挂了副本与 min.insync.replicas(第 4 章)
消费 → 处理 → 提交处理到一半崩溃、提交早了或晚了消费者的提交时机与事务

三档语义就是在这三个环节上做不同的选择:

  • at-most-once:宁可丢,不重复。消息最多被处理一次,失败就没了。
  • at-least-once:宁可重复,不丢。消息至少被处理一次,失败会重来。
  • exactly-once:在限定范围内,不丢也不重。

「限定范围」这四个字是本节的重点。很多团队以为开了事务就「端到端恰好一次」,结果在一次数据库写入失败之后发现并不是。我们先把三档各自的配置与行为跑出来,再划边界。

先看 4.3.0 的默认值

在写任何配置之前,先把客户端的真实默认值打出来。用 ProducerConfig.configDef().defaultValues() 和 ConsumerConfig 的同名方法读取:

var defs = ProducerConfig.configDef().defaultValues();
for (String k : new String[]{
        ProducerConfig.ACKS_CONFIG,
        ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,
        ProducerConfig.RETRIES_CONFIG,
        ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG,
        ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION,
        ProducerConfig.LINGER_MS_CONFIG,
        ProducerConfig.BATCH_SIZE_CONFIG}) {
    System.out.println(k + "=" + defs.get(k));
}

本机实测(Temurin 25.0.5+8 / kafka-clients 4.3.0 / 2026-10-29):

acks=all
enable.idempotence=true
retries=2147483647
delivery.timeout.ms=120000
max.in.flight.requests.per.connection=5
linger.ms=5
batch.size=16384
compression.type=none

本节涉及的等待时间与耗时都是单次运行,随机器调度波动,不作基准。

消费端的默认值:

enable.auto.commit=true
auto.commit.interval.ms=5000
isolation.level=read_uncommitted
max.poll.records=500
session.timeout.ms=45000

这张表里有两个容易看漏的地方。

第一,4.x 的默认值已经是「最强写入」:acks=all、enable.idempotence=true、retries=2147483647。也就是说,一个什么都不配的生产者,默认就在 at-least-once 加上生产端幂等的档位上,不是「默认会丢」。

第二,linger.ms 的默认值是 5,不是 0。这是 4.x 相对旧版本的一个变化。它意味着生产者在没有更多消息时也会等 5 毫秒凑批,用一点延迟换批次效率。很多人按旧印象写「linger 默认 0,所以延迟低」,在 4.3.0 上会对不上。

默认值好,不等于链路就是 at-least-once。消费端的 enable.auto.commit=true 与 auto.commit.interval.ms=5000 仍然会制造重复窗口;而如果你把提交放在处理之前,就会掉到 at-most-once。下面分别做出来。

at-most-once:提交早于处理

at-most-once 的核心不是「生产者不等响应」,而是进度先于处理。消费者先把位移提交到 broker,再去处理消息;处理到一半崩溃,重启后从提交过的位置继续,没处理完的那几条就永远跳过了。

本机用一个 1 分区 topic、5 条消息复现。消费者先 poll 出 5 条,把当前位置(5)提交,然后逐条处理,处理到 offset=3 时模拟崩溃:

committed-before-processing=[5]
handled off=0 value=v0
handled off=1 value=v1
handled off=2 value=v2
crashed at off=3 before handling
simulated crash

注意 committed-before-processing=[5]:位移已经到 5 了,但 offset=3、4 从未被处理。用同一个 group 重启,消费端看到的是:

re-read total=0

再查消费组:

GROUP           TOPIC            PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
a1.amo          a1.semantics-amo 0          5               5               0

CURRENT-OFFSET=5、LAG=0,从 Kafka 的角度看这个组「已经消费完了」。但 offset=3、4 对应的两条记录,业务从来没处理过。这就是 at-most-once 的代价:监控上一切正常,数据已经悄悄少了。

在代码层面,造成它的写法通常长这样:

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    consumer.commitSync();          // 先提交
    for (ConsumerRecord<String, String> r : records) {
        handle(r);                  // 再处理;这里崩溃就丢了
    }
}

这段代码不是「写错了 API」,而是明确选择了 at-most-once。它适合「丢了也无所谓」的场景,比如可重建的缓存刷新、幂等的周期性快照。只要业务能接受丢,这个选择就是合理的;不能接受丢,就不要让提交跑在处理前面。

生产端也有 at-most-once 的写法:acks=0。生产者把记录写进 socket 就认为成功,不等 broker 响应。leader 没收到、网络断了、broker 拒绝,生产者都不知道。这条路径的丢失窗口在第 3.2 节会用杀 leader 的实验展开,这里只需要记住:acks=0 是另一条独立的丢消息路径,和消费端提交早晚无关。

at-least-once:处理完再提交

把顺序反过来,就是 at-least-once:先处理,处理完再提交。崩溃发生在提交之前,重启后会从上一个已提交的位置重读,于是已经处理过但没提交的记录会被再处理一次。

同样 5 条消息,消费者处理完全部 5 条,但只提交到 offset=3(相当于「处理到 3 的时候崩溃,4 还没提交」):

processed off=0 key=k0 value=v0
processed off=1 key=k1 value=v1
processed off=2 key=k2 value=v2
processed off=3 key=k3 value=v3
processed off=4 key=k4 value=v4
committed=[3]
simulating crash before the rest is committed

重启后从已提交的 offset=3 继续:

re-read off=3 key=k3 value=v3
re-read off=4 key=k4 value=v4
re-read total=2

offset=3、4 被处理了两次。消费组的进度停在 3,lag 是 2:

GROUP           TOPIC            PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
a1.alo          a1.semantics-alo 0          3               5               2

这就是 at-least-once 的代价:不丢,但可能重。重复的多少取决于「崩溃时有多少条已处理未提交」,最坏情况是一整个 poll 批次。max.poll.records 默认 500,所以一次崩溃最多可能重放 500 条(如果这 500 条都处理完了但没提交)。

at-least-once 的代码形态是:

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    for (ConsumerRecord<String, String> r : records) {
        handle(r);
    }
    consumer.commitSync();          // 处理完再提交
}

这段代码保证了「只要处理成功,位移最终会推进」;代价是「处理成功但提交失败」时会重来。业务必须能承受重复,或者自己做幂等。卷二整卷都在处理「怎么让重复不产生副作用」,本卷只把重复的来源钉死。

还有一个细节:自动提交不是 at-least-once 的保证。enable.auto.commit=true 时,提交发生在 poll 的某个时刻,可能在你处理完之前,也可能在你处理完之后。它既可能丢(提交跑在处理前),也可能重(提交落后于处理)。要稳定的 at-least-once,应该关掉自动提交,在处理完成后显式提交。这是卷二 3.2 节的主题,这里先记住结论。

exactly-once:事务能做什么

exactly-once 要同时解决两个问题:

  1. 生产端重试造成的重复写入。broker 已经写入,但响应丢了,生产者重试,同一条记录进了日志两次。
  2. 消费端「处理 + 提交」不原子。处理成功了,提交失败,重启后重处理;或者提交成功了,处理其实失败。

第一个问题靠幂等生产者。生产者拿到一个 producer id(PID),每个分区维护一个递增的 sequence;broker 按 <PID, partition, sequence> 去重,重复的批次直接丢弃。enable.idempotence=true 在 4.x 默认开启,所以「生产端重试导致重复」这条路径默认已经被挡住。它挡不住的是「应用自己发了两条相同的业务消息」,那是业务幂等,不是客户端幂等。

第二个问题靠事务。事务把「写多个分区」和「提交消费位移」放进同一个原子单元:要么都可见,要么都不可见。先用一个最小实验看可见性。

生产者配置 transactional.id,开事务,写两条记录后提交;再开一个事务,写一条记录后 abort:

producer.initTransactions();

producer.beginTransaction();
producer.send(new ProducerRecord<>("a1.semantics-tx", "k0", "committed-0")).get();
producer.send(new ProducerRecord<>("a1.semantics-tx", "k1", "committed-1")).get();
producer.commitTransaction();

producer.beginTransaction();
producer.send(new ProducerRecord<>("a1.semantics-tx", "k9", "aborted-9")).get();
producer.abortTransaction();

本机实测的发送结果:

in-txn1 key=k0 p=0 off=0
in-txn1 key=k1 p=0 off=1
txn1 committed
in-txn2 key=k9 p=0 off=3
txn2 aborted

注意 abort 的那条拿到了 offset=3,不是 2。因为 offset=2 被事务的控制记录占用了。用 kafka-dump-log.sh 看段文件,能看到两条 isControl: true 的记录:

baseOffset: 2 lastOffset: 2 ... isTransactional: true isControl: true ... endTxnMarker: COMMIT coordinatorEpoch: 0
baseOffset: 3 lastOffset: 3 ... isTransactional: true isControl: false ... key: k9 payload: aborted-9
baseOffset: 4 lastOffset: 4 ... isTransactional: true isControl: true ... endTxnMarker: ABORT coordinatorEpoch: 0

控制记录也是日志里的真实记录,占位移、占空间,只是普通消费者看不到。read_committed 消费者读到的是:

Offset:0	committed-0
Offset:1	committed-1

read_uncommitted 消费者读到的是:

Offset:0	committed-0
Offset:1	committed-1
Offset:3	aborted-9

这就是 isolation.level 的作用:它不删除 abort 的记录,只是在读取时过滤掉未提交事务的数据与控制记录。日志里 offset=3 一直都在,只是 read_committed 不返回它。

事务的边界:只在 Kafka 读写链路内

上面的实验很容易让人产生一个错误结论:「开了事务就不会重复、不会丢」。

事务真正保证的是:在一个 Kafka 事务里,写入的记录和提交的位移,对外表现为一个原子操作。它不保证事务之外的系统。

具体说,事务覆盖的范围是:

在事务内不在事务内
向一个或多个 Kafka topic 写记录写入数据库、缓存、HTTP 接口
提交消费位移(sendOffsetsToTransaction)发送邮件、调用支付
多个分区之间的原子可见性下游消费者自己的处理与提交

所以「消费 → 处理 → 写数据库 → 提交位移」这条链路,如果数据库写入不在 Kafka 事务里,事务只能保证「位移和 Kafka 输出一起提交」,不能保证「数据库写入和位移一起提交」。数据库写成功、事务 abort,或者反过来,都会出现不一致。要解决它,需要 outbox、幂等写、或者数据库侧的事务,这些属于卷二的范围。

用 sendOffsetsToTransaction 可以把「位移提交」也放进事务。本机做了一个 consume-transform-produce 的实验:

producer.beginTransaction();
producer.send(new ProducerRecord<>("a1.eos-out", first.key(), "transformed-" + first.value())).get();
Map<TopicPartition, OffsetAndMetadata> offsets = Map.of(
        new TopicPartition(first.topic(), first.partition()),
        new OffsetAndMetadata(first.offset() + 1));
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction();

第二个事务做同样的事,但最后 abortTransaction()。结果:

txn1 committed: output for off=0 + offset 1
txn2 aborted: output for off=1 and offset 2 rolled back

消费组的位移停在 1,没有推进到 2:

GROUP           TOPIC      PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
a1.eos          a1.eos-in  0          1               3               2

输出 topic 用 read_committed 读,只有第一条:

Offset:0	transformed-in-0

用 read_uncommitted 读,能看到被 abort 的第二条:

Offset:0	transformed-in-0
Offset:2	transformed-in-1

这就是「位移和输出一起回滚」的硬证据。它成立的前提是:输出只写 Kafka,位移只提交 Kafka。一旦中间插入一个数据库,原子性就断了。

还有一个常见误解:read_committed 不保证「不重复」。它只保证「不读未提交」。如果消费者处理完、提交前崩溃,重启后仍会从上一个提交位置重读,重复照样发生。事务减少的是「未提交数据被看到」和「位移与输出不一致」,不是「消费者进程崩溃后的重放」。要端到端不重,仍然需要业务幂等或下游去重。

三档语义的配置对照

把上面的内容压成一张表,方便对照。注意「生产端」和「消费端」要分开看,端到端语义是两边组合的结果。

目标生产端关键配置消费端关键配置失败时的表现
at-most-onceacks=0,或不重试先提交位移,再处理可能丢,不重复
at-least-onceacks=all、retries>0、enable.idempotence=true关闭自动提交,处理完再 commitSync不丢,可能重复
exactly-once(Kafka 内)transactional.id、enable.idempotence=trueisolation.level=read_committed、sendOffsetsToTransactionKafka 读写原子,Kafka 外不保证

几个配置之间的关系要特别说清:

  • enable.idempotence=true 隐含 acks=all。开启幂等后,acks 必须为 all,否则客户端会报配置冲突。4.x 默认就是两者都开。
  • retries 与 delivery.timeout.ms 共同决定重试窗口。delivery.timeout.ms 默认 120000,重试会在这个窗口内进行;窗口用完仍失败,才把异常交给应用。把 retries 设为很大但 delivery.timeout.ms 很小,效果等于不重试。
  • max.in.flight.requests.per.connection 与顺序。默认 5;开启幂等后,broker 会按 sequence 处理,最多 5 个在途请求仍能保持分区内顺序。关掉幂等再把这个值调大,重试可能导致乱序。
  • transactional.id 不是 group.id。前者标识一个事务生产者实例,后者标识一个消费组。两者可以不同,也不要求一一对应。

一个选择顺序

如果不知道该怎么选,按这个顺序问:

  1. 这条消息丢了会怎样? 如果能接受(缓存刷新、指标采样),at-most-once 最省事。
  2. 重复会产生副作用吗? 如果不会(幂等写、覆盖写),at-least-once 足够,而且实现最简单。
  3. 重复会产生副作用,且副作用只在 Kafka 内吗? 如果是,事务可以把「输出 + 位移」做成原子。
  4. 副作用在 Kafka 外(数据库、HTTP)吗? 如果是,事务不够,必须做业务幂等、outbox 或对账。这是卷二的战场。

这个顺序的价值在于:它把「exactly-once」从一句口号变成一个需要满足前提的选择。大多数业务系统停在第二或第四步,而不是第三步。承认这一点,比在配置里找一个「开启恰好一次」的开关要可靠得多。

本节判据

回答四个问题:

  1. 消费者先 commitSync 再处理,崩溃后会丢还是重?——丢。位移已经推进,未处理的记录不会再被读到。
  2. at-least-once 下,一次崩溃最多可能重放多少条?——最多一个 poll 批次,默认 max.poll.records=500。
  3. 事务 abort 后,abort 的记录还在日志里吗?——在,offset=3 仍在段文件中,只是 read_committed 不返回它。
  4. read_committed 能保证消费者不重复处理吗?——不能。它只过滤未提交数据,不解决进程崩溃后的重放。

下一节从语义回到环境:用一个三节点的最小集群,把 listener、advertised.listener 和 controller quorum 这三组地址的关系摆清楚。这是后面所有副本实验的地基。

阅读导航:上一节 1.2 分区:顺序与并行度的交换 | 下一节 2.1 三个节点的最小集群

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收