版本基线:Apache Kafka 4.3.x、Java Client 4.3.x。本文讨论 Java
KafkaConsumerAPI,示例默认使用 Consumer Group、手工提交 offset,并以同步业务处理为基线。
同一个 Consumer,线上经常会遇到完全不同的控制需求:
- 正常消费:持续
poll(); - 从某个 offset 重放:调用
seek(); - 下游变慢:对部分 Partition
pause(); - 阻塞退出:从关闭线程调用
wakeup(); - 重启后继续:提交
commitSync()或commitAsync(); - 按时间补数:先
offsetsForTimes(),再seek()。
这些 API 看起来像一组零散方法,实际上分别控制四种状态:
text
Partition 所有权:subscribe / assign
下一次读取位置:poll / position / seek
故障恢复位置:commitSync / commitAsync / committed
本地流量与生命周期:pause / resume / wakeup / close
如果把"读取位置"和"恢复位置"混成一个概念,就很容易写出重复消费、跳过消息或无法优雅停机的代码。
先看全局:常用 API 分别控制哪一层

可以先把常用 API 分成六组:
| 类别 | 常用 API | 核心作用 |
|---|---|---|
| 订阅与分配 | subscribe()、assign()、unsubscribe() |
决定 Consumer 可以读取哪些 Partition |
| 拉取 | poll() |
推进组协调、获取数据并把记录交给应用 |
| 位点控制 | position()、seek()、seekToBeginning()、seekToEnd() |
查看或改变下一次 poll() 使用的位置 |
| 位点查询 | committed()、beginningOffsets()、endOffsets()、offsetsForTimes()、currentLag() |
查询恢复点、边界、时间对应位置和 Lag |
| 流量控制 | pause()、resume()、paused() |
暂停或恢复指定 Partition 的 Fetch |
| 提交与生命周期 | commitSync()、commitAsync()、wakeup()、close() |
保存恢复点,并安全结束 Consumer |
还有一些排障时很有用的观察 API:
assignment():当前真正分配给本 Consumer 的 Partition;subscription():当前订阅的 Topic;partitionsFor():查询某个 Topic 的 Partition 元数据;listTopics():查询有权限看到的 Topic 与 Partition;groupMetadata():当前 Consumer Group 的元数据;metrics():客户端指标快照。
不要试图在每次循环里调用所有查询 API。部分方法可能发起远程请求并阻塞,更适合初始化、管理操作或低频诊断。
先分清:哪些 API 只改本地,哪些会访问 Broker

Consumer API 最容易产生的误解,是把"调用了一个方法"都理解成"立刻向 Broker 发了一次请求"。实际上,KafkaConsumer 同时维护本地状态、客户端缓存和网络请求,三者不是一回事。
| API | 调用时主要发生什么 | 是否立即访问 Broker |
|---|---|---|
seek(tp, offset) |
修改该 Partition 的本地 position | 否 |
pause()、resume()、paused() |
修改或读取本地暂停状态 | 否 |
assignment()、subscription() |
读取本地所有权或订阅状态 | 否 |
wakeup() |
设置客户端唤醒标记,打断阻塞调用 | 否 |
subscribe() |
记录订阅意图;真正入组通常由后续 poll() 推进 |
通常不会在这里完成入组 RPC |
poll() |
推进协调、交付缓存,并在需要时等待网络数据 | 可能;缓存有数据时也可能直接返回 |
position(tp) |
返回下一条位置;本地位置未知时需要初始化 | 可能 |
seekToBeginning()、seekToEnd() |
标记要移动到边界,边界可以惰性解析 | 后续 poll() 或 position() 可能查询 |
beginningOffsets()、endOffsets()、offsetsForTimes() |
查询日志边界或时间对应位置 | 是 |
committed() |
查询 Group 保存的恢复位置 | 通常是 |
commitSync()、commitAsync() |
把恢复位置提交给 Group Coordinator | 是 |
把 seek(tp, 101) 单独展开,顺序是:
text
调用 seek(tp, 101)
-> 客户端把 P0 的 position 改成 101
-> seek 本身不发送 Fetch,也不提交 offset
-> 后续 poll() 按新 position 交付或发起 Fetch
-> Broker 收到 Fetch(offset=101)
新版客户端实现可能先把该操作交给 KafkaConsumer 内部的后台线程处理,但这仍然是客户端内部状态同步 ,不等于向 Broker 发出 OffsetCommit、ListOffsets 或 Fetch 请求。
第一条规则:KafkaConsumer 不是线程安全的
KafkaConsumer 的绝大多数 API 必须由同一个 Consumer 线程串行调用。不能让业务线程、定时线程和关闭线程同时执行 poll()、seek()、commitSync() 或 pause()。
唯一明确支持从其他线程调用的例外是:
java
consumer.wakeup();
因此推荐的线程模型是:
text
一个 Consumer 实例
-> 一个专属 Consumer 线程
-> 该线程负责所有 poll / seek / pause / resume / commit / close
其他线程
-> 通过线程安全队列传递命令或处理结果
-> 需要停止时只调用 wakeup()
如果多个业务线程需要并行处理,可以把记录交给 Worker Pool,但 Consumer 状态仍由 Consumer 线程统一管理。否则常见结果不是"偶尔慢一点",而是 ConcurrentModificationException、错误提交和难以复现的位点跳跃。
subscribe() 与 assign():自动协调和手工所有权二选一
subscribe():让 Consumer Group 自动分配
java
consumer.subscribe(List.of("order-events"));
它表达的是"我订阅这些 Topic",真正的 Partition 由 Group Coordinator 和 Assignor 分配。新成员加入、旧成员离开或 Partition 数量变化时,可能发生 Rebalance。
需要管理自定义 offset、缓存或 Partition 级资源时,应注册 ConsumerRebalanceListener:
java
consumer.subscribe(List.of("order-events"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
commitCompletedOffsets(partitions);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
restorePartitionState(partitions);
}
@Override
public void onPartitionsLost(Collection<TopicPartition> partitions) {
discardPartitionState(partitions);
}
});
assign():应用自己指定 Partition
java
consumer.assign(List.of(
new TopicPartition("order-events", 0),
new TopicPartition("order-events", 3)
));
assign() 会关闭动态 Partition 分配,不使用 Consumer Group 的成员协调,也不会因为成员数量变化而 Rebalance。它适合:
- 定向回放某几个 Partition;
- 离线补数或审计工具;
- 外部系统已经负责分片;
- 测试和故障定位。
但它不是普通在线 Consumer 的"性能优化开关"。使用 assign() 后,扩缩容、故障接管和 Partition 均衡都要由应用自己处理。
两者不能混用
text
subscribe(...) -> 自动组管理
assign(...) -> 手工分配
在没有先 unsubscribe() 的情况下混用,客户端会抛出 IllegalStateException。unsubscribe() 会同时清除当前订阅和手工 Assignment。
poll():它不只是"拉一批消息"

poll(Duration timeout) 至少同时承担三类工作:
- 组协调:首次加入 Group、处理 Rebalance、执行 Rebalance Listener;
- 数据 Fetch:根据每个 Partition 的当前 position 获取数据,并管理客户端缓存;
- 应用交付 :把缓存中的记录组成
ConsumerRecords返回给业务代码。
因此,下面两个理解都不准确:
text
误解一:subscribe() 返回后已经拿到 Partition。
误解二:poll(1000ms) 一定会阻塞整整 1 秒。
使用 subscribe() 时,Consumer 通常在第一次 poll() 才真正加入 Group。若客户端缓存里已经有数据,poll() 可以立即返回;若没有数据,它最多等待传入的 timeout,但 Rebalance Listener 的执行可能使实际时间超过该 timeout。
一个最小但正确的消费循环
java
consumer.subscribe(List.of("order-events"));
while (running) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> record : records) {
handle(record);
}
}
max.poll.records 限制的是"返回",不是底层 Fetch
properties
max.poll.records=200
它限制单次 poll() 返回给应用的最大记录数。Consumer 仍可能从 Broker Fetch 更多数据并缓存在客户端,然后分多次 poll() 交付。
这意味着:
- 降低
max.poll.records可以缩短单批业务处理时间; - 它不等于限制单次网络 Fetch 的字节数;
- 内存还受
fetch.max.bytes、max.partition.fetch.bytes、Partition 数量和并行 Fetch 影响。
poll() 的时间预算
业务循环需要满足:
text
一次 poll 返回后的处理时间
+ 提交时间
+ GC / 网络 / 下游抖动余量
< max.poll.interval.ms
如果处理超过 max.poll.interval.ms,Consumer 可能失去 Partition,之后提交时出现 CommitFailedException。解决方式通常是减小批次、隔离慢任务或建立背压,而不是无限调大超时。
position()、committed()、seek():三个完全不同的动作

这是 Consumer API 中最重要的一组区别。
position(tp):下一条准备交付的位置
如果 position(P0) == 103,含义是下一次将从 offset 103 或更高的有效记录开始读取,不是"已经处理到 103"。
position 会随着 poll() 返回数据自动前进。
committed(Set<TopicPartition>):故障恢复的位置
它查询 Group 已经存入 Kafka 的 committed offset。Consumer 重启或 Partition 交给新 Owner 后,通常从这个位置继续。
java
Map<TopicPartition, OffsetAndMetadata> offsets =
consumer.committed(Set.of(tp), Duration.ofSeconds(3));
committed() 可能发起远程请求,不要把它放进每条消息的热路径。
seek(tp, offset):修改本地 position
java
consumer.seek(tp, 101L);
它只修改本 Consumer 的本地 position,使下一次 poll() 使用 101;seek() 本身不向 Broker 提交或查询 offset,也不会修改 Group 的 committed offset。真正读取数据时,后续 poll() 才会在需要时向 Broker 发起 Fetch。
text
seek 改"这次运行接下来从哪读"
commit 改"故障或重启后从哪恢复"
所以调用 seek(tp, 101) 后,如果进程在提交前崩溃,重启仍会回到旧 committed offset。
例如 Broker 中保存的 committed offset 是 80:
text
seek 前:committed=80,position=120
调用: seek(P0, 1)
seek 后:committed=80,position=1
后续: poll() 尝试从 offset=1 读取
这不代表 offset 1 一定还存在。如果 Retention 已经把最早可用位置推进到 300,那么 1 已经越界。真正想"从头开始",应该使用 seekToBeginning(),或者先调用 beginningOffsets() 查询当前最早可用位置。
提交的是"下一条 offset"
成功处理 offset 42 后,应该提交 43:
java
offsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
如果提交 42,恢复时 offset 42 会再次被读取。OffsetAndMetadata 表达的是下一条要消费的位置,不是最后一条完成的位置。
seekToBeginning() 与 seekToEnd()
java
Set<TopicPartition> assigned = consumer.assignment();
consumer.seekToBeginning(assigned); // 从可用的最早位置读取
consumer.seekToEnd(assigned); // 移到当前末端
二者只适用于当前已经分配给 Consumer 的 Partition,并且是惰性求值:真正解析边界可能发生在后续 poll() 或 position()。
注意,日志最早 offset 不一定是 0,Retention 或 Log Compaction 可能已经删除早期记录。
offsetsForTimes():按时间找到重放起点
"重放昨天 10:00 之后的数据"不应该先猜 offset。正确流程是先按时间查询每个 Partition 对应的位置,再 seek()。
java
long targetTimestamp = Instant.parse("2026-08-16T02:00:00Z")
.toEpochMilli();
Map<TopicPartition, Long> query = consumer.assignment().stream()
.collect(Collectors.toMap(tp -> tp, tp -> targetTimestamp));
Map<TopicPartition, OffsetAndTimestamp> found =
consumer.offsetsForTimes(query, Duration.ofSeconds(5));
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : found.entrySet()) {
TopicPartition tp = entry.getKey();
OffsetAndTimestamp result = entry.getValue();
if (result != null) {
consumer.seek(tp, result.offset());
} else {
// 该时间之后没有消息;根据业务选择跳过或移到末端
consumer.seekToEnd(List.of(tp));
}
}
返回的是时间戳大于等于目标时间的第一条记录位置。某个 Partition 找不到符合条件的记录时,对应值可能是 null。
在线 Group 中应该在哪里 seek
如果使用 subscribe(),不要在尚未获得 Assignment 时调用 seek()。常见做法是:
- 第一次
poll()完成分配后,根据assignment()执行;或 - 在
onPartitionsAssigned()中为新获得的 Partition 执行。
还要记录"重放任务已经初始化",避免每次 Rebalance 都无条件回到同一时间,形成无限重复。
pause() / resume():做 Partition 级背压

当下游数据库变慢、某个 Partition 的 Worker 队列堆积时,可以暂停指定 Partition:
java
consumer.pause(slowPartitions);
后续 poll() 暂时不会返回这些 Partition 的新记录,直到:
java
consumer.resume(slowPartitions);
查询当前暂停集合:
java
Set<TopicPartition> paused = consumer.paused();
pause 不会做的三件事
pause() 不会:
- 取消 Topic 订阅;
- 释放 Partition 所有权或触发 Rebalance;
- 撤回已经由
poll()返回给应用的记录。
因此,背压必须在队列尚未耗尽内存前触发。已经塞进 Worker Pool 的任务,pause() 无法收回来。
暂停后仍然要持续 poll
正确思路不是:
text
pause -> 业务线程 sleep 10 分钟 -> resume
而是:
text
pause 慢 Partition
-> Consumer 线程继续短周期 poll
-> 处理协调、Rebalance,并消费未暂停 Partition
-> 队列降到低水位
-> resume
另一个关键点是:Rebalance 不会保留 pause 状态。Assignment 变化后,应用要根据当前队列和新 Assignment 重新计算哪些 Partition 需要暂停。
背压伪代码
所有 Consumer API 仍然只在 Consumer 线程执行:
text
loop:
completed = drainWorkerResults()
advanceSafeOffsetsOnlyWhenContiguous(completed)
for each assigned partition:
if queueSize(partition) >= HIGH_WATERMARK:
consumer.pause(partition)
else if queueSize(partition) <= LOW_WATERMARK:
consumer.resume(partition)
records = consumer.poll(200ms)
dispatchByPartition(records)
commitOnlyContiguousCompletedOffsets()
为什么强调"连续完成"?假设同一 Partition 的 offset 100 已完成、101 仍在处理、102 已完成,此时安全提交点仍然是 101,不能因为 102 先完成就提交 103,否则崩溃恢复时会跳过 101。
commitSync() 与 commitAsync():保存恢复点
commitSync()
java
consumer.commitSync(offsets, Duration.ofSeconds(3));
它会阻塞直到成功、遇到不可恢复错误或超时。优点是结果明确,适合:
- Partition 即将被撤销;
- 应用准备关闭;
- 低频批量任务;
- 必须知道本次提交是否成功的边界。
代价是提交延迟会直接占用消费循环的时间。
commitAsync()
java
consumer.commitAsync(offsets, (committed, exception) -> {
if (exception != null) {
log.warn("async commit failed: {}", committed, exception);
}
});
它不阻塞消费循环,适合常规批次提交。多个异步提交会按调用顺序发送,回调也按顺序执行。
但不要在回调里无限、无脑重试旧 offset。应用层重试如果缺少单调性检查,可能在更新的提交之后又写入更旧的位置。
一种常见组合是:
text
正常循环:commitAsync(明确的安全 offsets)
撤销或关闭:commitSync(最新的安全 offsets)
无论同步还是异步,都要提交应用真正处理完成的位置,而不是单纯提交 poll() 已经返回的位置。
wakeup():打断阻塞,不是关闭 Consumer

wakeup() 会让正在阻塞的 poll() 或其他可中断调用抛出 WakeupException。如果当时没有可中断调用正在阻塞,异常会留给下一次相关调用。
它不会自动:
- 修改关闭标记;
- 提交 offset;
- 离开 Group;
- 关闭网络连接。
所以完整停机需要"状态 + wakeup + finally close"。
java
public final class ConsumerRunner implements Runnable, AutoCloseable {
private final KafkaConsumer<String, String> consumer;
private final AtomicBoolean closing = new AtomicBoolean(false);
private final Map<TopicPartition, OffsetAndMetadata> safeOffsets =
new HashMap<>();
public ConsumerRunner(KafkaConsumer<String, String> consumer) {
this.consumer = consumer;
}
@Override
public void run() {
try {
consumer.subscribe(List.of("order-events"));
while (!closing.get()) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(500));
for (TopicPartition tp : records.partitions()) {
for (ConsumerRecord<String, String> record
: records.records(tp)) {
handle(record);
safeOffsets.put(tp,
new OffsetAndMetadata(record.offset() + 1));
}
}
if (!safeOffsets.isEmpty()) {
consumer.commitAsync(Map.copyOf(safeOffsets),
(offsets, error) -> {
if (error != null) {
log.warn("commit failed: {}", offsets, error);
}
});
}
}
} catch (WakeupException e) {
if (!closing.get()) {
throw e;
}
} finally {
try {
if (!safeOffsets.isEmpty()) {
consumer.commitSync(Map.copyOf(safeOffsets),
Duration.ofSeconds(3));
}
} finally {
consumer.close();
}
}
}
@Override
public void close() {
closing.set(true);
consumer.wakeup();
}
}
close() 应由 Consumer 线程在 finally 中执行。外部关闭线程只设置标记并调用 wakeup()。
一个实用配置基线
下面不是所有系统的统一答案,而是同步处理、手工提交时便于继续调优的起点:
properties
bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092
group.id=order-service
client.id=order-consumer-pod-3
# 使用 Kafka 4.x Consumer Rebalance Protocol
group.protocol=consumer
# 处理完成后由应用提交
enable.auto.commit=false
# 没有历史 offset 时从哪里开始
auto.offset.reset=earliest
# 控制每次交给业务层的数量
max.poll.records=200
# 必须大于单批 P99 处理时间,并保留抖动余量
max.poll.interval.ms=300000
# 每个 Partition 单次 Fetch 的目标上限
max.partition.fetch.bytes=1048576
# 整个 Fetch 响应的目标上限
fetch.max.bytes=52428800
# 吞吐与等待延迟的权衡
fetch.min.bytes=1
fetch.max.wait.ms=500
# 只读取已提交的事务数据时启用
isolation.level=read_committed
调参时要分清三个维度:
| 目标 | 主要参数 | 不要误解 |
|---|---|---|
| 控制业务批次耗时 | max.poll.records |
不限制底层 Fetch 总字节 |
| 控制客户端 Fetch 内存 | fetch.max.bytes、max.partition.fetch.bytes |
都不是绝对硬上限,超大首批仍可能被返回以保证进展 |
| 权衡吞吐与等待延迟 | fetch.min.bytes、fetch.max.wait.ms |
不是业务处理超时 |
| 防止业务线程长期不推进 | max.poll.interval.ms |
不是 Broker 网络请求超时 |
常见场景应该选哪个 API
| 场景 | 推荐 API | 关键注意点 |
|---|---|---|
| 普通在线消费 | subscribe + poll |
让 Group 自动分配 Partition |
| 固定 Partition 补数 | assign + seek + poll |
故障接管和并行切分由应用负责 |
| 从指定 offset 重放 | seek |
不会自动修改 committed offset |
| 从指定时间重放 | offsetsForTimes + seek |
对每个 Partition 分别查询,处理 null |
| 从最早可用数据读取 | seekToBeginning |
最早 offset 不一定为 0 |
| 跳到当前末端 | seekToEnd |
read_committed 下末端是 LSO |
| 下游变慢 | pause + 持续 poll + resume |
pause 状态不会跨 Rebalance 保留 |
| 查看本 Consumer Lag | currentLag |
可能因本地尚无 end offset 而返回空值 |
| 优雅停机 | 外部线程 wakeup,Consumer 线程 close |
只在确认关闭时吞掉 WakeupException |
| 保存恢复点 | commitAsync / commitSync |
提交下一条 offset,只提交已完成记录 |
六个高频误区
误区一:poll(1000) 每次都会向 Broker 发一次请求
Consumer 可能直接从客户端缓存返回数据。poll() 还会推进协调状态,并不等同于一条固定的 Fetch RPC。
误区二:seek() 会把 Group offset 一起改掉
seek() 只改变当前运行实例的 Fetch position;要改变恢复点仍需提交,或者由外部 offset 存储维护。
误区三:成功处理 offset 42 就提交 42
应提交 43,因为 committed offset 表示下一条要读取的位置。
误区四:pause() 后可以停止 poll()
暂停的是指定 Partition 的数据交付,不是 Consumer 的组协调职责。长时间不 poll() 仍可能超过 max.poll.interval.ms。
误区五:Worker 线程可以直接 resume() 或 commitAsync()
KafkaConsumer 不是线程安全的。Worker 应把"队列已下降""offset 已完成"等事件放入线程安全队列,由 Consumer 线程执行 API。
误区六:wakeup() 等于 close()
它只是制造一个可控的中断点。资源释放、最终提交和离组仍要在 Consumer 线程的 finally 中完成。
最佳实践:围绕 Partition 维护状态
Consumer 的顺序、offset、pause 和 Assignment 都是 Partition 级语义。生产代码最好也按 Partition 管理:
text
PartitionState
- nextSafeCommitOffset
- inFlightRecords
- queueSize
- paused
- lastPollTime
- lastSuccessfulProcessTime
这会让下面的问题变得可回答:
- 哪个 Partition 在拖慢整个实例?
- 哪个 Partition 可以安全提交到哪里?
- Rebalance 时应该清理哪些任务?
- 恢复时为什么从这个 offset 开始?
- pause 的高低水位是否发生抖动?
监控上至少同时观察:
- Consumer Lag 与 Lag 增长速度;
poll()间隔和单批处理 P99;- 每个 Partition 的队列深度与 in-flight 数量;
- commit 成功率、延迟和失败类型;
- Rebalance 次数、撤销/丢失的 Partition 数量;
WakeupException是否只出现在计划停机期间。
最后压缩成七句话
subscribe决定订阅意图,assign直接决定 Partition 所有权,两者不要混用。poll同时推进组协调、Fetch 和应用交付,不是一条普通读取 RPC。position是下一次读取位置,committed是故障恢复位置。seek只修改客户端 position;后续poll才可能按新位置远程 Fetch,它不会自动修改 committed offset。pause做 Partition 级背压,但 Consumer 仍应持续poll。wakeup是唯一可安全跨线程调用的 Consumer API,它负责打断,不负责关闭。- 所有提交都应基于"连续处理完成的下一条 offset"。
真正掌握 Consumer API,不是记住方法名,而是知道每个方法改变的是:所有权、读取位置、恢复位置,还是本地流量状态。