一段「看起来成功」的代码
下面这段消费循环在很多项目里都能见到:
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
for (ConsumerRecord<String, String> r : records) {
handle(r); // 业务处理
}
consumer.commitAsync(); // 处理完就提交,不阻塞
}
它的诱人之处在于:处理完一批就提交一次,吞吐高、不阻塞、代码短。日志里也没有异常,进程正常退出,退出码 0。
但如果这一次提交失败了,会发生什么?commitAsync() 的返回值是 void,调用方拿不到任何信号。位移没提交上去,进程却认为自己提交了。重启之后,同一批消息会被重新投递——重复是可见的,丢失是不可见的,而这里更糟:你以为已经提交的进度,其实一条都没落地。
本篇把这件事拆开:先读 4.3.0 的 API 契约,再用一个「被踢出组」的真实场景复现静默失败,最后说清回调里能做什么、不能做什么。
先读契约:错误到底去了哪
KafkaConsumer.commitAsync 的 Javadoc 写得很明确:
This is an asynchronous call and will not block. Any errors encountered are either passed to the callback
(if provided) or discarded.
Offsets committed through multiple calls to this API are guaranteed to be sent in the same order as
the invocations. Corresponding commit callbacks are also invoked in the same order. Additionally note that
offsets committed through this API are guaranteed to complete before a subsequent call to commitSync() returns.
三句话,三个结论:
- 错误要么给回调,要么被丢弃。 不传回调时不是「抛出来」,而是「丢弃」。
- 4.x 的异步提交是有序的。 多次调用的发送顺序和回调顺序都保证与调用顺序一致。旧教材里「回调乱序导致位移回退」的说法在 4.3.0 不成立,不要照抄。
- 异步提交会在随后的
commitSync返回前完成。 这是同步提交之前的一次隐式 flush。
「不传回调」在实现上对应一个默认回调:
private class DefaultOffsetCommitCallback implements OffsetCommitCallback {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
if (exception != null)
log.error("Offset commit with offsets {} failed", offsets, exception);
}
}
也就是说,commitAsync() 不带参数时,失败只会变成客户端日志里的一行 ERROR。应用层没有异常、没有返回值、没有状态变化。如果你的日志级别调到了 OFF,或者你根本不看客户端日志,这次失败就是彻底静默的。
还有更隐蔽的一层:回调不是在 commitAsync 里执行的,而是在下一次 poll() 的开头被统一取出执行。
public boolean poll(Timer timer, boolean waitForJoinGroup) {
invokeCompletedOffsetCommitCallbacks();
// ... 之后才是心跳、协调器、拉取
}
这意味着:如果提交失败后进程再也没调用过 poll()(比如处理完最后一批就退出),回调可能一直不执行;如果进程被 kill -9,连 close() 里的最后一次回调都不会有。
实验一:被踢出组之后的异步提交
环境:Temurin 25.0.5+8、kafka-clients 4.3.0、apache/kafka:4.3.0 单节点 KRaft。以下为单次运行,随调度波动。
构造场景:
- 单分区主题,预写 50 条记录;
- 消费者 A 加入消费组,
max.poll.records=10,max.poll.interval.ms=6000,session.timeout.ms=6000,heartbeat.interval.ms=2000,关闭自动提交; - A 拉到前 10 条后停止
poll(),模拟「处理了 9 秒」——超过max.poll.interval.ms; - 与此同时,同组的消费者 B 启动,在 A 被踢出后接管分区;
- A 处理完这 10 条,调用
commitAsync(offsets, null)。
A 被踢出的证据是心跳线程打出的 WARN:
[kafka-coordinator-heartbeat-thread | libraryd-async-kick-90718-A] WARN
org.apache.kafka.clients.consumer.internals.ConsumerCoordinator -
consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than
the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time
processing messages.
接下来是关键的几行输出:
[kick] A polled=10 firstOffset=0 nextOffset=10 at t=0
[kick] commitAsync(no callback) returned, threwSynchronously=false
commitAsync 正常返回,没有同步异常。失败的信息在一行 ERROR 日志里:
[main] ERROR org.apache.kafka.clients.consumer.internals.ConsumerCoordinator -
Offset commit with offsets {library.loan.created.async.90718-0=OffsetAndMetadata{offset=10, ...}} failed
org.apache.kafka.clients.consumer.CommitFailedException: Offset commit cannot be completed since the
consumer is not part of an active group for auto partition assignment; it is likely that the consumer
was kicked out of the group.
注意这条日志的线程名是 main:它不是 commitAsync 打的,是随后那次 poll() 在开头排空回调队列时打的。
最后查组的提交位移:
[kick] group committed after failure={} (none)
[kick] A processed=10, committedOffset=none, exceptionReachedCaller=false
[kick] drive poll returned 10 records (callback queue drained)
四个事实同时成立:A 处理了 10 条;commitAsync 没有抛异常;组的 committed 是空的;下一次 poll() 又把 0 到 9 这 10 条重新投给了 A。位移没提交,消息被重复投递,而调用方全程没有收到任何异常。
对照:同步提交会直接抛
同样的场景换成 commitSync:
[kick-sync] commitSync threw org.apache.kafka.clients.consumer.CommitFailedException:
Offset commit cannot be completed since the consumer is not part of an active group for auto partition
assignment; it is likely that the consumer was kicked out of the group.
[kick-sync] committed after failure={} (none)
异常直接抛到调用栈上,想忽略都难。这是同步提交最大的价值:失败是显式的。代价是每次提交都要等一次网络往返。
实验二:提交点跑到处理前面
比异步提交更常见的变体是「处理异步、提交同步」:在「拉取线程 + 工作池」模型里,拉取线程 poll 完就把位置提交了,工作线程还没处理完。
ConsumerRecords<String, String> batch = consumer.poll(Duration.ofMillis(200));
for (ConsumerRecord<String, String> r : batch) {
pool.submit(() -> handle(r)); // 处理是异步的
}
long next = batch.records(tp).get(batch.count() - 1).offset() + 1;
consumer.commitSync(Map.of(tp, new OffsetAndMetadata(next))); // 提交却同步了
pool.shutdownNow(); // 模拟崩溃:不等工作线程
实测输出:
[commit-ahead] polled=10, committed=10, actuallyProcessed=0
[commit-ahead] committed before crash={library.loan.created.async.51879-0=OffsetAndMetadata{offset=10, ...}}
[commit-ahead] consumer closed without waiting for workers (simulated crash)
[commit-ahead] restarted consumer firstOffset=10 (offsets 0..9 never processed)
[commit-ahead] lost=10
提交位移是 10,实际处理是 0,重启后从 10 开始读——0 到 9 永远不会再被投递。这次不是「重复」,是彻底的丢失。
这段代码的危险之处在于它看起来很像「先处理后提交」:commitSync 在前面,handle 也在前面。但 handle 是提交给线程池的,submit 返回不代表处理完成。提交的是「poll 到的位置」,不是「处理完的位置」。
「提交顺序」与「处理顺序」是两件事
把两个实验放在一起,可以看到位移管理的三个位置:
| 位置 | 含义 | 谁在推进 |
|---|---|---|
| fetch position | 下一条要拉取的位移 | poll() 返回时前进 |
| 处理进度 | 已经处理完的记录 | 业务线程 / 回调 |
| committed offset | 组对外承诺的位移 | commitSync / commitAsync |
异步提交只解决「提交不要在业务线程上阻塞」,它不解决「提交哪个位置」。要提交的位置必须由处理进度决定,而不是由 poll 的位置决定。
在每线程一个消费者的模型里,这三者天然对齐:poll、处理、提交都在同一条线程上,提交时处理一定已经完成。在多线程模型里,三者会分离,必须自己维护「连续完成」的位移——12.1 里那段 pending 队列就是干这个的。
回调里不能做的事
先看回调在哪条线程上执行。实测输出:
[callback] callbackThread=main exception=null
[callback] callback latency=52 ms (executed inside poll)
线程名是 main,也就是调用 poll() 的那条线程。这不是巧合:回调是在 poll() 内部、由 invokeCompletedOffsetCommitCallbacks() 取出来执行的。
由此推出三条禁令。
一、不要在回调里阻塞
把回调改成睡 2 秒:
consumer.commitAsync(offsets, (o, e) -> {
try {
Thread.sleep(2000); // 反例
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
});
实测:
[reentry] callback thread=main enter
[reentry] callback slept 2000 ms inside poll
[reentry] this poll took 2024 ms
一次 poll() 被拉长到 2024 毫秒。心跳线程是独立的,不会因为这一次阻塞立刻掉组,但 max.poll.interval.ms 是按两次 poll() 调用的间隔算的——回调里每阻塞一秒,都在消耗这个预算。回调里做 HTTP 调用、查数据库、写日志到慢盘,本质上都是同一件事。
二、不要让回调抛异常
consumer.commitAsync(offsets, (o, e) -> {
throw new IllegalStateException("boom from callback");
});
实测:
[callback] throwing callback: poll threw java.lang.IllegalStateException: boom from callback
[callback] throwing callback: exceptionSeen=true
异常会从 poll() 里直接冒出来,打断整个消费循环。回调里只做「记录状态」这种不会失败的事;要重试也是在主循环里重试,不是在回调里。
三、不要在回调里再操作消费者
4.3.0 的消费者锁是同线程可重入的,所以在回调里调用 commitSync 不会立刻报 ConcurrentModificationException:
[reentry] commitSync inside callback returned normally
但这不代表可以这么做。它会在一次 poll() 内部再发起一轮阻塞的提交请求,把「异步」变成「同步 + 同步」。如果回调里提交的位移和主循环维护的「连续完成」位置不一致,两边会互相覆盖,最终提交的是哪个取决于执行顺序。回调只做记账,不发起新的网络请求。
正确的异步提交写法
异步提交不是不能用,而是要把「失败」当成正常分支处理:
private final AtomicReference<Map<TopicPartition, OffsetAndMetadata>> lastCommitted
= new AtomicReference<>(Map.of());
private final AtomicReference<Map<TopicPartition, OffsetAndMetadata>> pendingRetry
= new AtomicReference<>();
private final AtomicReference<Exception> fatal = new AtomicReference<>();
void commitAsyncSafely(KafkaConsumer<String, String> consumer,
Map<TopicPartition, OffsetAndMetadata> offsets) {
consumer.commitAsync(offsets, (committed, e) -> {
if (e == null) {
lastCommitted.set(Map.copyOf(committed));
} else if (e instanceof RetriableException) {
// 可重试:记下来,下一轮 poll 后重提
pendingRetry.set(Map.copyOf(committed));
} else {
// 不可重试(越权、被 fencing、组已失效):停止消费,交给上层重启
fatal.set(e);
}
});
}
三条规则:
- 必须传回调。 不传回调的
commitAsync()等于把错误交给默认回调,只留下一行日志。 - 区分可重试与不可重试。
RetriableException下一轮再提;CommitFailedException、FencedInstanceIdException、AuthorizationException说明这个消费者已经不该继续提交了。 - 关闭前用
commitSync收尾。commitSync会等待之前所有异步提交完成,也保证最后的位置落地。
如果觉得上面这套太复杂,还有一个更保守的选择:只在固定间隔用 commitSync,比如每 500 条或每 200 毫秒一次。吞吐会低一些,但失败会立刻炸出来。对大多数业务来说,这个交换是划算的。
排障时的四个观察点
| 现象 | 先看什么 | 常见原因 |
|---|---|---|
面板 lag 不降,客户端 records-lag 也高 | 组 committed 是否在前进 | 异步提交全部失败,但没人看回调 |
| 重启后重复消费一段 | 上次退出前有没有 commitSync | 进程直接退出,异步提交还在路上 |
| 重复量正好等于一次 poll 的批次 | 提交位置是不是 poll 位置 | 提交跑到了处理前面 |
日志里有 CommitFailedException 但没崩 | 回调是否只打了日志 | 默认回调把异常降级成日志 |
四个现象都不需要看 broker 指标,先看客户端这一侧就能定位大半。
回到 libraryd
借阅投影的消费循环最后改成了这样:
- 每处理完 500 条提交一次,用
commitSync,不用异步; - 提交失败时抛出,让进程退出并由编排系统重启——重启后从上一个提交点重放,重复由幂等消费兜底(第 2 章);
- 关闭钩子里先停止拉取,再
commitSync,最后close(Duration.ofSeconds(10))。
通知服务对延迟更敏感,用了异步提交,但回调里只做两件事:成功时更新 lastCommitted,失败时设置 fatal 标志。主循环每轮检查 fatal,一旦置位就停止拉取、commitSync 收尾、退出。
两种写法的共同点是:提交失败必须能改变控制流。只要失败只停留在日志里,这段代码就还没有处理完可靠性问题。
阅读导航:上一节 12.1 消费者多线程模型 | 下一节 12.3 消费端性能调优
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。