《Java 消息中间件·Kafka 内核与客户端》8.3 ConsumerCoordinator 与位移提交

自动提交不是后台定时器,它只在 poll() 里触发;提交的值是 position,而 position 在 poll 返回记录时就已推进。本节用源码和实测把这个时间差做成丢消息场景:处理 3 条,位移却已提交到 10,close() 时更进一步到 20。

提交的到底是什么

先把语义钉死,后面所有结论都从它推出来。

消费者提交的位移,是下一条要读的位置,不是「最后一条已处理的位置」。消费了 offset 0–9 这十条,提交的值是 10。这样重启后从 10 开始,不会重复读 9。

提交动作本身在经典协议里由 ConsumerCoordinator 负责。它把 Map<TopicPartition, OffsetAndMetadata> 发给消费组协调器,协调器写进内部 topic __consumer_offsets。6.1 节已经讲过这个 topic 的存储格式,这里只关心客户端侧的三个问题:

  1. 什么时候提交;
  2. 提交的是哪个值;
  3. 提交失败会怎样。

三个问题的答案合起来,解释了一个高频事故:消费者「处理很慢」时丢消息。

自动提交只在 poll 里触发

先看配置文档给人的印象。ConsumerConfig 对 enable.auto.commit 的说明是:

If true the consumer's offset will be periodically committed in the background.

「periodically committed in the background」——周期性、在后台。很多人据此认为有一个后台线程每隔 auto.commit.interval.ms 提交一次,与应用线程无关。

实现不是这样。ConsumerCoordinator.java 第 1198–1206 行:

public void maybeAutoCommitOffsetsAsync(long now) {
    if (autoCommitEnabled) {
        nextAutoCommitTimer.update(now);
        if (nextAutoCommitTimer.isExpired()) {
            nextAutoCommitTimer.reset(autoCommitIntervalMs);
            autoCommitOffsetsAsync();
        }
    }
}

这个方法只做三件事:更新计时器、判断是否到期、到期就发一次异步提交。它自己没有循环、没有线程。那谁调用它?

ConsumerCoordinator.poll(Timer, boolean) 的第 575 行:

maybeAutoCommitOffsetsAsync(timer.currentTimeMs());
return true;

而 ConsumerCoordinator.poll 又是被 ClassicKafkaConsumer.poll 里的 updateAssignmentMetadataIfNeeded 调用的。

所以真实链路是:consumer.poll() → ClassicKafkaConsumer.poll → updateAssignmentMetadataIfNeeded → ConsumerCoordinator.poll → maybeAutoCommitOffsetsAsync(now)。

没有 poll(),就没有自动提交。 所谓「后台」,指的是提交请求本身是异步发送的,不是指有一个独立的后台定时任务。

这个区别不是文字游戏。它意味着:如果你的应用在两次 poll() 之间处理了很久,这段时间里自动提交一次都不会发生。下一节用实测证明这一点。

提交的值是 position,不是「处理进度」

autoCommitOffsetsAsync 第 1229–1230 行:

private RequestFuture<Void> autoCommitOffsetsAsync() {
    Map<TopicPartition, OffsetAndMetadata> allConsumedOffsets = subscriptions.allConsumed();

allConsumed() 在 SubscriptionState.java 第 786–794 行:

public synchronized Map<TopicPartition, OffsetAndMetadata> allConsumed() {
    Map<TopicPartition, OffsetAndMetadata> allConsumed = new HashMap<>();
    assignment.forEach((topicPartition, partitionState) -> {
        if (partitionState.hasValidPosition())
            allConsumed.put(topicPartition, new OffsetAndMetadata(partitionState.position.offset,
                    partitionState.position.offsetEpoch, ""));
    });
    return allConsumed;
}

它取的是 partitionState.position.offset。position 在什么时候变?

FetchCollector.fetchRecords 第 178–186 行:

if (nextInLineFetch.nextFetchOffset() > position.offset) {
    SubscriptionState.FetchPosition nextPosition = new SubscriptionState.FetchPosition(
            nextInLineFetch.nextFetchOffset(),
            nextInLineFetch.lastEpoch(),
            position.currentLeader);
    log.trace("Updating fetch position from {} to {} for partition {} and returning {} records from `poll()`",
            position, nextPosition, tp, partRecords.size());
    subscriptions.position(tp, nextPosition);
    positionAdvanced = true;
}

注意这一段发生在记录被返回给 poll() 调用者之前。日志里写得很直白:「Updating fetch position … and returning N records from poll()」。

于是得到一个关键事实:

position 在 poll() 返回记录的那一刻就已经推进到这批记录的末尾,而不是在应用处理完之后。

自动提交取的就是这个 position。所以它提交的是「已经发给应用、但可能一条都没处理」的进度。

实测:处理 3 条,位移提交到 10

实验设计:

  • 单分区 topic b3.lab.autocommit,写入 30 条;
  • 消费者 enable.auto.commit=true、auto.commit.interval.ms=1000、max.poll.records=10;
  • poll 一次拿到 10 条,应用只「处理」其中 3 条;
  • 然后 sleep 3000,模拟慢处理,期间不调用 poll;
  • 查询已提交位移;
  • 再 poll 一次,再查询;
  • 最后 close(),再查询。

查询用 AdminClient.listConsumerGroupOffsets,异步提交需要等响应,所以加了一个最多 5 秒的轮询等待。

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

poll#1 返回 10 条, position=10, committed=null
应用只处理了 3 条, 进入慢处理 sleep 3000ms
sleep 结束(auto.commit.interval.ms=1000 早已过去), committed=null  <- 期间没有 poll(), 自动提交根本没跑
poll#2 返回 10 条, position=20
等异步提交可见后 committed=10  <- 提交发生在 poll#2 进入时, 提交的是 poll#1 之后就已推进的 position
processed=3, committed=10 -> 若此刻崩溃, 位移 3~9 共 7 条从未处理却被标记为已消费
close() 之后 committed=20  <- close() 里的同步自动提交又把 poll#2 返回的 10 条也提交了

五行输出,四个事实:

  1. 第一次 poll 后 committed 是 null。 新组还没有任何提交。
  2. sleep 3 秒后仍是 null。 auto.commit.interval.ms=1000 早就过了,但没有 poll(),自动提交没有触发。这一行直接推翻了「后台每 N 秒提交一次」的理解。
  3. 第二次 poll 后 committed=10。 提交发生在 poll#2 进入时(ConsumerCoordinator.poll 里的 maybeAutoCommitOffsetsAsync),提交的是 poll#1 结束时就已经推进到 10 的 position,而不是应用真正处理到的 3。
  4. close() 后 committed=20。 关闭时的同步自动提交(ConsumerCoordinator.close 第 1021 行的 maybeAutoCommitOffsetsSync(timer))把 poll#2 返回的 10 条也提交了,尽管它们一条都没处理。

再用命令行确认一次。程序退出后:

docker exec mq-b3 /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server 127.0.0.1:9092 --describe --group b3-g-autocommit

本机输出:

GROUP           TOPIC             PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID  HOST  CLIENT-ID
b3-g-autocommit b3.lab.autocommit 0          20              30              10   -            -     -

CURRENT-OFFSET=20,LOG-END-OFFSET=30,LAG=10。这 20 条里,应用真正处理过的只有 3 条。

丢消息的机制

把上面的数据整理成时间线:

时刻动作positioncommitted实际处理
t0poll#1 返回 10 条10null0
t1处理 3 条10null3
t2sleep 3s,未 poll10null3
t3poll#2 进入,触发自动提交10103
t4poll#2 返回 10 条20103
t5close(),同步自动提交20203
t6假设进程崩溃—203

如果崩溃发生在 t6,重启后从 20 开始消费,offset 3–19 这 17 条从未被业务处理却已被标记为消费完成。崩溃点不一定要在 close() 之后:t3 到 t5 之间任何时刻崩溃都会丢掉 offset 3–9 这 7 条,close() 只是把损失扩大到 17 条。

这也解释了为什么这类事故常常发生在「优雅关闭」场景:应用正常退出,以为没有数据损失,实际上关闭时的同步提交把最后一批未处理的数据也提交了。

同步与异步提交的语义差异

自动提交走的是异步路径。手动提交有两个 API,语义不同。

commitSync

ConsumerCoordinator.commitOffsetsSync 第 1142–1178 行:

public boolean commitOffsetsSync(Map<TopicPartition, OffsetAndMetadata> offsets, Timer timer) {
    invokeCompletedOffsetCommitCallbacks();

    if (offsets.isEmpty()) {
        // We guarantee that the callbacks for all commitAsync() will be invoked when
        // commitSync() completes, even if the user tries to commit empty offsets.
        return invokePendingAsyncCommits(timer);
    }

    long attempts = 0L;
    do {
        if (coordinatorUnknownAndUnreadySync(timer)) {
            return false;
        }

        RequestFuture<Void> future = sendOffsetCommitRequest(offsets);
        client.poll(future, timer);

        // We may have had in-flight offset commits when the synchronous commit began. If so, ensure that
        // the corresponding callbacks are invoked prior to returning in order to preserve the order that
        // the offset commits were applied.
        invokeCompletedOffsetCommitCallbacks();

        if (future.succeeded()) {
            if (interceptors != null)
                interceptors.onCommit(offsets);
            return true;
        }

        if (future.failed() && !future.isRetriable())
            throw future.exception();

        timer.sleep(retryBackoff.backoff(attempts++));
    } while (timer.notExpired());

    return false;
}

三个特征:

  • 阻塞,直到成功、超时或遇到不可重试错误;
  • 可重试错误自动重试,间隔是 retryBackoff;
  • 不可重试错误直接抛异常,例如 CommitFailedException、FencedInstanceIdException。

commitAsync

异步路径在 doCommitOffsetsAsync 第 1099–1129 行。它发一次请求,挂一个监听器,成功或失败都通过回调通知:

private RequestFuture<Void> doCommitOffsetsAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, final OffsetCommitCallback callback) {
    RequestFuture<Void> future = sendOffsetCommitRequest(offsets);
    inFlightAsyncCommits.incrementAndGet();

方法体后面挂一个监听器,成功时触发拦截器与回调,失败时把可重试异常包装成 RetriableCommitFailedException,遇到 FencedInstanceIdException 则置位 asyncCommitFenced。

关键差异:没有重试循环。失败只是把异常交给回调,是否重试由调用方决定。

为什么不自动重试?commitOffsetsAsync 第 1068–1070 行的注释给了理由:

// we don't know the current coordinator, so try to find it and then send the commit
// or fail (we don't want recursive retries which can cause offset commits to arrive
// out of order). Note that there may be multiple offset commits chained to the same
// coordinator lookup request. This is fine because the listeners will be invoked in
// the same order that they were added. Note also that AbstractCoordinator prevents
// multiple concurrent coordinator lookup requests.

提交乱序会导致小位移覆盖大位移,那比提交失败更危险。所以异步路径选择「不重试,交给调用方」。

混用的坑

commitSync 返回前会调用 invokePendingAsyncCommits,保证之前的异步提交回调都已执行,避免「异步晚到、覆盖同步」。但反过来不成立:先 commitSync(100) 再 commitAsync(50),50 可能后到并把位移退回去。手动提交时要么全用同步,要么保证异步值单调递增。

提交失败怎么处理

失败分三类,处理方式完全不同。

异常含义处理
RetriableCommitFailedException可重试错误,如协调器迁移、网络抖动退避后重试同一批位移
CommitFailedException再平衡已发生,当前成员不再拥有分区不能重试,应放弃本轮处理,等待重新分配
FencedInstanceIdException静态成员被同 group.instance.id 的新实例取代停止消费并退出,说明有重复实例

自动提交的失败处理最弱。autoCommitOffsetsAsync 第 1233–1245 行的回调:

return commitOffsetsAsync(allConsumedOffsets, (offsets, exception) -> {
    if (exception != null) {
        if (exception instanceof RetriableCommitFailedException) {
            log.debug("Asynchronous auto-commit of offsets {} failed due to retriable error.", offsets,
                exception);
            nextAutoCommitTimer.updateAndReset(rebalanceConfig.retryBackoffMs);
        } else {
            log.warn("Asynchronous auto-commit of offsets {} failed: {}", offsets, exception.getMessage());
        }
    } else {
        log.debug("Completed asynchronous auto-commit of offsets {}", offsets);
    }
});

可重试错误只打 debug 日志并把下次提交提前到 retryBackoffMs 之后;不可重试错误打 warn。两种情况都不抛异常,应用完全感知不到。 如果提交失败但应用继续跑,重启后会重复消费;如果应用以为已经提交并清理了本地状态,重启后可能丢状态。自动提交适合「重复消费可以接受」的场景,需要精确控制时必须关掉。

enable.auto.commit=true 的三种丢失形态

形态一:崩溃点在下一次 poll 之后、本批处理完成之前。 position 已推进但尚未提交时崩溃,只是重复消费——committed 还停在旧值。真正丢失需要等下一次 poll 触发提交。

形态二:下一次 poll 提前提交了上一批。 这正是实测展示的:处理 3 条,第二次 poll 把 10 提交了。只要应用在「处理完」之前调用下一次 poll(),就会发生。多线程处理、把记录丢进队列后立刻继续 poll,都属于这一类。

形态三:close() 的同步提交。 ConsumerCoordinator.close 第 1017–1021 行:

public void close(final Timer timer, CloseOptions.GroupMembershipOperation membershipOperation) {
    // we do not need to re-enable wakeups since we are closing already
    client.disableWakeups();
    try {
        maybeAutoCommitOffsetsSync(timer);

maybeAutoCommitOffsetsSync 会把当前 allConsumed() 同步提交一次。如果最后一批还没处理完就关闭,这批就被标记为已消费。

三种形态的共同点是:提交的值来自 position,而 position 与「业务处理完成」之间没有因果关系。 客户端不知道你处理到哪,它只知道发给你到哪。

要「至少一次」该怎么写

结论很简单:关掉自动提交,处理完之后同步提交处理过的位移。

Properties props = new Properties();
props.put("bootstrap.servers", "127.0.0.1:19213");
props.put("group.id", "b3-g-manual");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("enable.auto.commit", "false");
props.put("max.poll.records", "100");

try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
    consumer.subscribe(List.of("b3.lab.autocommit"));
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
        if (records.isEmpty()) continue;

        Map<TopicPartition, OffsetAndMetadata> done = new HashMap<>();
        for (ConsumerRecord<String, String> record : records) {
            handle(record);   // 业务处理失败会抛异常,不会进入下面的 put
            done.put(new TopicPartition(record.topic(), record.partition()),
                    new OffsetAndMetadata(record.offset() + 1));
        }
        consumer.commitSync(done);
    }
}

要点有三条:

  1. 提交 record.offset() + 1,因为提交的是下一条要读的位置;
  2. 只把处理成功的分区放进 done,处理失败的分区不提交,下次会重读;
  3. commitSync 在循环内,每批处理完提交一次。提交频率由 max.poll.records 决定,不由时间决定。

这个写法给出的是至少一次:崩溃会重复,但不会丢。要减少重复,可以把「业务写入」和「位移提交」放进同一个事务——那是卷二的话题,这里不展开。

如果吞吐要求高,可以用 commitAsync 加回调重试,但要自己保证提交值单调递增,并接受「关闭前最后一次异步提交可能没完成」。

一个容易忽略的细节

allConsumed() 只包含 hasValidPosition() 为真的分区。刚分配、还没拉取到位点信息的分区不会出现在提交里,所以再平衡刚完成时立刻提交,可能只提交了部分分区。另外,OffsetAndMetadata 里的 leaderEpoch 用于日志截断检测:如果 broker 返回的 epoch 比提交的小,说明发生了截断,客户端会触发 OffsetOutOfRange 或重置。14.3 节会用到。

本节判据

  1. 自动提交是后台定时任务吗? 答:不是。它只在 poll() 进入时由 ConsumerCoordinator.poll 调用,没有 poll 就不会提交。
  2. 自动提交提交的是哪个值? 答:position,即下一条要读的位置;而 position 在 poll 返回记录时就已推进。
  3. commitSync 和 commitAsync 的核心差异? 答:同步会重试并可能抛异常;异步只发一次、通过回调报告,不自动重试,以避免提交乱序。
  4. 自动提交在什么情况下丢消息? 答:下一次 poll 或 close() 把「已发出但未处理完」的记录提交掉,之后崩溃,这些记录不会再被消费。

下一节离开消费组,进入序列化与扩展点。9.1 会讲内置序列化器、Schema 边界,以及为什么不要在消息里放可变对象。

阅读导航:上一节 8.2 Fetcher 与拉取管线 | 下一节 9.1 序列化器与 Schema 边界

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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