图解 Kafka Consumer 常用 API:poll、seek、pause、wakeup 到底在控制什么?

版本基线:Apache Kafka 4.3.x、Java Client 4.3.x。本文讨论 Java KafkaConsumer API,示例默认使用 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 发出 OffsetCommitListOffsetsFetch 请求。

第一条规则: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() 的情况下混用,客户端会抛出 IllegalStateExceptionunsubscribe() 会同时清除当前订阅和手工 Assignment。

poll():它不只是"拉一批消息"

poll(Duration timeout) 至少同时承担三类工作:

  1. 组协调:首次加入 Group、处理 Rebalance、执行 Rebalance Listener;
  2. 数据 Fetch:根据每个 Partition 的当前 position 获取数据,并管理客户端缓存;
  3. 应用交付 :把缓存中的记录组成 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.bytesmax.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() 不会:

  1. 取消 Topic 订阅;
  2. 释放 Partition 所有权或触发 Rebalance;
  3. 撤回已经由 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.bytesmax.partition.fetch.bytes 都不是绝对硬上限,超大首批仍可能被返回以保证进展
权衡吞吐与等待延迟 fetch.min.bytesfetch.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 是否只出现在计划停机期间。

最后压缩成七句话

  1. subscribe 决定订阅意图,assign 直接决定 Partition 所有权,两者不要混用。
  2. poll 同时推进组协调、Fetch 和应用交付,不是一条普通读取 RPC。
  3. position 是下一次读取位置,committed 是故障恢复位置。
  4. seek 只修改客户端 position;后续 poll 才可能按新位置远程 Fetch,它不会自动修改 committed offset。
  5. pause 做 Partition 级背压,但 Consumer 仍应持续 poll
  6. wakeup 是唯一可安全跨线程调用的 Consumer API,它负责打断,不负责关闭。
  7. 所有提交都应基于"连续处理完成的下一条 offset"。

真正掌握 Consumer API,不是记住方法名,而是知道每个方法改变的是:所有权、读取位置、恢复位置,还是本地流量状态。

参考资料

相关推荐
lazy H4 小时前
不同的消息队列有什么区别?Kafka、RabbitMQ、RocketMQ、Pulsar、ActiveMQ 选型对比
后端·中间件·kafka·rabbitmq·rocketmq
数据库小学妹5 小时前
集群与分布式啥区别?从主从集群到分布式实战
分布式·分布式数据库·数据库架构·集群·分库分表·主从集群·集群与分布式
江畔柳前堤5 小时前
AgentScope 设计与原理全解:从消息原语到分布式智能体工程底座
大数据·人工智能·分布式·目标检测·机器学习·语言模型·架构
萧瑟余晖5 小时前
Java深入解析篇三十四之分布式事务
java·开发语言·分布式
北i5 小时前
高并发请求折叠:从 Hystrix 到埋点聚批
hystrix·linq
零域码客5 小时前
Redis 双刃剑:缓存与分布式锁的本质区别
redis·分布式·缓存·并发控制·后端架构
hopsky7 小时前
kafka 对发送端的容错处理
数据库·分布式·kafka
ltl16 小时前
3D 并行深度:数据 / 张量 / 流水 / 序列 / ZeRO
分布式
FserSuN1 天前
回顾Spark的概念与应用
大数据·分布式·spark