三档语义,三个失败窗口
「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 要同时解决两个问题:
- 生产端重试造成的重复写入。broker 已经写入,但响应丢了,生产者重试,同一条记录进了日志两次。
- 消费端「处理 + 提交」不原子。处理成功了,提交失败,重启后重处理;或者提交成功了,处理其实失败。
第一个问题靠幂等生产者。生产者拿到一个 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-once | acks=0,或不重试 | 先提交位移,再处理 | 可能丢,不重复 |
| at-least-once | acks=all、retries>0、enable.idempotence=true | 关闭自动提交,处理完再 commitSync | 不丢,可能重复 |
| exactly-once(Kafka 内) | transactional.id、enable.idempotence=true | isolation.level=read_committed、sendOffsetsToTransaction | Kafka 读写原子,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。前者标识一个事务生产者实例,后者标识一个消费组。两者可以不同,也不要求一一对应。
一个选择顺序
如果不知道该怎么选,按这个顺序问:
- 这条消息丢了会怎样? 如果能接受(缓存刷新、指标采样),at-most-once 最省事。
- 重复会产生副作用吗? 如果不会(幂等写、覆盖写),at-least-once 足够,而且实现最简单。
- 重复会产生副作用,且副作用只在 Kafka 内吗? 如果是,事务可以把「输出 + 位移」做成原子。
- 副作用在 Kafka 外(数据库、HTTP)吗? 如果是,事务不够,必须做业务幂等、outbox 或对账。这是卷二的战场。
这个顺序的价值在于:它把「exactly-once」从一句口号变成一个需要满足前提的选择。大多数业务系统停在第二或第四步,而不是第三步。承认这一点,比在配置里找一个「开启恰好一次」的开关要可靠得多。
本节判据
回答四个问题:
- 消费者先
commitSync再处理,崩溃后会丢还是重?——丢。位移已经推进,未处理的记录不会再被读到。 - at-least-once 下,一次崩溃最多可能重放多少条?——最多一个
poll批次,默认max.poll.records=500。 - 事务 abort 后,abort 的记录还在日志里吗?——在,offset=3 仍在段文件中,只是
read_committed不返回它。 read_committed能保证消费者不重复处理吗?——不能。它只过滤未提交数据,不解决进程崩溃后的重放。
下一节从语义回到环境:用一个三节点的最小集群,把 listener、advertised.listener 和 controller quorum 这三组地址的关系摆清楚。这是后面所有副本实验的地基。
阅读导航:上一节 1.2 分区:顺序与并行度的交换 | 下一节 2.1 三个节点的最小集群
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。