《Java 消息中间件·Kafka 内核与客户端》8.2 Fetcher 与拉取管线

4.3.0 的 Fetcher 只剩 9,378 字节,因为拉取逻辑被拆进了 AbstractFetch、FetchCollector、CompletedFetch 和 FetchBuffer。本节把 poll 的一次完整路径走一遍,说明请求按 leader 节点合并、分区不按 lag 排序,以及 max.poll.records 与两个字节上限的真实关系。

先说结论:Fetcher 已经不是那个 Fetcher

如果你读过 3.x 时代的源码分析,会记得 Fetcher 是个几千行的大类:发 fetch 请求、解析响应、维护 CompletedFetch 队列、反序列化、算指标,全在里面。

4.3.0 不是这样。Fetcher.java 在 sources jar 里只有 9,378 字节,约 9.2 KiB。它只做四件事:继承 AbstractFetch 复用请求准备与响应处理,持有 FetchCollector 负责取记录,实现 sendFetches() 把请求发出去,提供 collectFetch() 转发。也就是说,拉取管线被拆成了四个协作对象:

类职责4.3.0 大小
AbstractFetch准备请求、按节点聚合分区、处理响应、维护会话约 30.7 KB
Fetcher绑定 ConsumerNetworkClient,发送请求与收集入口9,378 字节
FetchCollector从 FetchBuffer 取出记录,执行 max.poll.records 限制约 19.1 KB
CompletedFetch单个分区的响应缓冲、反序列化、位点推进约 17.7 KB
FetchBuffer已完成响应的队列约 8.9 KB

读 4.3.0 的拉取逻辑,如果只看 Fetcher.java,会得出「它几乎什么都没做」的错误印象。真正的逻辑在父类和协作类里。这一节按一条 poll() 的调用链把它们串起来。

poll 的一次完整路径

先看入口。ClassicKafkaConsumer.java 第 647–688 行:

private ConsumerRecords<K, V> poll(final Timer timer) {
    acquireAndEnsureOpen();
    try {
        this.kafkaConsumerMetrics.recordPollStart(timer.currentTimeMs());

        if (this.subscriptions.hasNoSubscriptionOrUserAssignment()) {
            throw new IllegalStateException("Consumer is not subscribed to any topics or assigned any partitions");
        }

        do {
            client.maybeTriggerWakeup();

            // try to update assignment metadata BUT do not need to block on the timer for join group
            updateAssignmentMetadataIfNeeded(timer, false);

            final Fetch<K, V> fetch = pollForFetches(timer);
            if (!fetch.isEmpty()) {
                // before returning the fetched records, we can send off the next round of fetches
                // and avoid block waiting for their responses to enable pipelining while the user
                // is handling the fetched records.
                //
                // NOTE: since the consumed position has already been updated, we must not allow
                // wakeups or any other errors to be triggered prior to returning the fetched records.
                if (sendFetches() > 0 || client.hasPendingRequests()) {
                    client.transmitSends();
                }

                if (fetch.records().isEmpty()) {
                    log.trace("Returning empty records from `poll()` "
                            + "since the consumer's position has advanced for at least one topic partition");
                }

                return this.interceptors.onConsume(new ConsumerRecords<>(fetch.records(), fetch.nextOffsets()));
            }
        } while (timer.notExpired());

        return ConsumerRecords.empty();
    } finally {
        release();
        this.kafkaConsumerMetrics.recordPollEnd(timer.currentTimeMs());
    }
}

三层含义:外层 do/while 在超时内反复尝试;updateAssignmentMetadataIfNeeded 处理协调器、加入组、拉取已提交位移,且不阻塞等再平衡完成(第二个参数是 false);pollForFetches 先看本地缓冲区,没有才发请求、等响应。

pollForFetches 本身也值得逐行看。同文件第 706–739 行:

private Fetch<K, V> pollForFetches(Timer timer) {
    long pollTimeout = coordinator == null ? timer.remainingMs() :
            Math.min(coordinator.timeToNextPoll(timer.currentTimeMs()), timer.remainingMs());

    // if data is available already, return it immediately
    final Fetch<K, V> fetch = fetcher.collectFetch();
    if (!fetch.isEmpty()) {
        return fetch;
    }

    // send any new fetches (won't resend pending fetches)
    sendFetches();

    // We do not want to be stuck blocking in poll if we are missing some positions
    // since the offset lookup may be backing off after a failure

    // NOTE: the use of cachedSubscriptionHasAllFetchPositions means we MUST call
    // updateAssignmentMetadataIfNeeded before this method.
    if (!cachedSubscriptionHasAllFetchPositions && pollTimeout > retryBackoffMs) {
        pollTimeout = retryBackoffMs;
    }

    log.trace("Polling for fetches with timeout {}", pollTimeout);

中间省略了位移缺失时把超时收紧到 retryBackoffMs 的分支(第 719–727 行)和 client.poll 的等待条件(第 730–735 行),末尾是第二次收集:

    timer.update(pollTimer.currentTimeMs());

    return fetcher.collectFetch();
}

关键顺序是:

collectFetch()  -> 本地有数据就立刻返回
sendFetches()   -> 没数据才发请求
client.poll()   -> 阻塞等响应,直到有可用数据或超时
collectFetch()  -> 再取一次

注意 collectFetch() 被调用了两次。第一次是零成本检查——缓冲区为空就直接返回,不发网络请求。这解释了一个常见现象:如果上一轮已经预取到数据,poll() 根本不走网络。

还有一个细节在第 670–672 行:拿到数据之后、返回给用户之前,会再发一轮 fetch。这是流水线优化——用户处理当前批次的这段时间,下一批已经在路上了。注释里那句「position has already been updated」很关键,8.3 节讲自动提交时会用到。

请求怎么合并:按 leader 节点聚合

Fetcher.sendFetches() 自己不决定拉哪些分区。它调用父类的 prepareFetchRequests(),拿到一个 Map<Node, FetchRequestData>,然后一个节点发一个请求。

AbstractFetch.prepareFetchRequests() 第 421–488 行的核心结构:

protected Map<Node, FetchSessionHandler.FetchRequestData> prepareFetchRequests() {
    // Update metrics in case there was an assignment change
    metricsManager.maybeUpdateAssignment(subscriptions);

    Map<Node, FetchSessionHandler.Builder> fetchable = new HashMap<>();
    long currentTimeMs = time.milliseconds();
    Map<String, Uuid> topicIds = metadata.topicIds();

    // This is the set of partitions that have buffered data
    Set<TopicPartition> buffered = Collections.unmodifiableSet(fetchBuffer.bufferedPartitions());

    // This is the list of partitions that are fetchable and have no buffered data
    List<TopicPartition> unbuffered = fetchablePartitions(buffered);

    if (unbuffered.isEmpty()) {
        // If there are no partitions that don't already have data locally buffered, there's no need to issue
        // any fetch requests at the present time.
        return Collections.emptyMap();
    }

    Set<Integer> bufferedNodes = bufferedNodes(buffered, currentTimeMs);

    for (TopicPartition partition : unbuffered) {
        SubscriptionState.FetchPosition position = positionForPartition(partition);
        Optional<Node> nodeOpt = maybeNodeForPosition(partition, position, currentTimeMs);

        if (nodeOpt.isEmpty())
            continue;

        Node node = nodeOpt.get();

对每个分区,接下来是四路分支。第一路是节点不可用,第二路是该节点已有 in-flight 请求,第三路最关键:

        } else if (bufferedNodes.contains(node.id())) {
            // While a node has buffered data, don't fetch other partition data from it. Because the buffered
            // partitions are not included in the fetch request, those partitions will be inadvertently dropped
            // from the broker fetch session cache. In some cases, that could lead to the entire fetch session
            // being evicted.
            log.trace("Skipping fetch for partition {} because its leader node {} hosts buffered partitions", partition, node);

第四路才真正把分区加进请求:

        } else {
            // if there is a leader and no in-flight requests, issue a new fetch
            FetchSessionHandler.Builder builder = fetchable.computeIfAbsent(node, k -> {
                FetchSessionHandler fetchSessionHandler = sessionHandlers.computeIfAbsent(node.id(), n -> new FetchSessionHandler(logContext, n));
                return fetchSessionHandler.newBuilder();
            });
            Uuid topicId = topicIds.getOrDefault(partition.topic(), Uuid.ZERO_UUID);
            FetchRequest.PartitionData partitionData = new FetchRequest.PartitionData(topicId,
                    position.offset,
                    FetchRequest.INVALID_LOG_START_OFFSET,
                    fetchConfig.fetchSize,
                    position.currentLeader.epoch,
                    Optional.empty());
            builder.add(partition, partitionData);

从这段代码可以提炼出四条规则:

规则一:一个节点一个请求。 fetchable 是 Map<Node, Builder>,多个分区如果 leader 在同一个 broker 上,会合并进同一个请求的 builder.add(...)。所以 sendFetches() 的返回值是请求数(节点数),不是分区数。

规则二:有缓冲数据的分区不拉。 fetchablePartitions(buffered) 把 FetchBuffer 里还有数据的分区排除了。理由在方法注释里:用户还没处理完已取到的数据,没必要再拉。

规则三:同一节点上有缓冲数据时,其它分区也不拉。 这是第三条 else if。注释给的理由是 fetch session 的缓存一致性——如果把有缓冲的分区排除出请求,broker 侧的 session 会认为这些分区被移除了,可能整体驱逐会话。这是一个容易被忽略的实现细节。

规则四:一个节点同时只有一个 in-flight 请求。 nodesWithPendingFetchRequests 记录了已发未回的节点。这保证了请求与响应的顺序,也让「同一分区的响应可能乱序到达」这件事在客户端侧被消除。

哪些分区先拉:不按 lag 排序

很多资料说「消费者优先拉取 lag 大的分区」。在 4.3.0 的经典客户端里,没有这回事。

SubscriptionState.fetchablePartitions 第 486–497 行:

public synchronized List<TopicPartition> fetchablePartitions(Predicate<TopicPartition> isAvailable) {
    // Since this is in the hot-path for fetching, we do this instead of using java.util.stream API
    List<TopicPartition> result = new ArrayList<>();
    assignment.forEach((topicPartition, topicPartitionState) -> {
        // Cheap check is first to avoid evaluating the predicate if possible
        if ((subscriptionType.equals(SubscriptionType.AUTO_TOPICS_SHARE) || isFetchableAndSubscribed(topicPartition, topicPartitionState))
                && isAvailable.test(topicPartition)) {
            result.add(topicPartition);
        }
    });
    return result;
}

它只是按 assignment 的迭代顺序收集分区。assignment 是 PartitionStates,内部是一个 LinkedHashMap,所以顺序是分配时的插入顺序,与 lag、与位点、与消息量都无关。

公平性靠轮转。CompletedFetch.drain() 第 146–149 行,读到数据后把分区移到队尾:

// we move the partition to the end if we received some bytes. This way, it's more likely that partitions
// for the same topic can remain together (allowing for more efficient serialization).
if (bytesRead > 0)
    subscriptions.movePartitionToEnd(partition);

FetchCollector 第 239–242 行对出错的分区做同样处理。效果是:取过数据的分区排到后面,下一轮优先照顾没取到的分区。这是 round-robin 式的公平,不是按积压量调度。

这个结论有实践含义:如果你有 100 个分区、只有一个分区严重积压,客户端不会自动优先喂它。它仍然按轮转顺序,每个分区轮流拿一小份。想让积压分区追上来,靠的是增加消费者实例或提高单次 fetch 的量,而不是指望客户端内部调度。14.2 节讲积压定位时会再回到这一点。

max.poll.records 只管出队,不管拉取

这一点在 ConsumerConfig 的注释里写得很直白,第 91–94 行:

private static final String MAX_POLL_RECORDS_DOC = "The maximum number of records returned in a single call to poll()."
    + " Note, that <code>" + MAX_POLL_RECORDS_CONFIG + "</code> does not impact the underlying fetching behavior."
    + " The consumer will cache the records from each fetch request and returns them incrementally from each poll.";
public static final int DEFAULT_MAX_POLL_RECORDS = 500;

「does not impact the underlying fetching behavior」——它不影响底层拉取。拉多少由 fetch.max.bytes 和 max.partition.fetch.bytes 决定;max.poll.records 只决定这次从缓冲区里取出多少条给你。

源码在 FetchCollector.collectFetch,第 91–124 行:

public Fetch<K, V> collectFetch(final FetchBuffer fetchBuffer) {
    final Fetch<K, V> fetch = Fetch.empty();
    final Queue<CompletedFetch> pausedCompletedFetches = new ArrayDeque<>();
    int recordsRemaining = fetchConfig.maxPollRecords;

    try {
        while (recordsRemaining > 0) {
            final CompletedFetch nextInLineFetch = fetchBuffer.nextInLineFetch();

中间是「取下一个已完成响应」「跳过暂停分区」两个分支,真正的取数在最后一段:

            } else {
                final Fetch<K, V> nextFetch = fetchRecords(nextInLineFetch, recordsRemaining);
                recordsRemaining -= nextFetch.numRecords();
                fetch.add(nextFetch);
            }
        }

recordsRemaining 从 maxPollRecords 开始,每取一批减去实际条数,减到 0 就退出。取不完的部分留在 CompletedFetch 里,下次 poll() 继续。

实测:100 条积压,max.poll.records=5

实验设计:先向单分区 topic b3.lab.fetch 写入 100 条,再用 max.poll.records=5、auto.offset.reset=earliest、enable.auto.commit=false 的消费者连续 poll,打印每次返回条数和当前 position。

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

poll #01 -> count=0累计=0 position={}
(#02–#06 均为 count=0,加入组期间)
poll #07 -> count=5累计=5 position={b3.lab.fetch-0=5}
poll #08 -> count=5累计=10 position={b3.lab.fetch-0=10}
...
poll #25 -> count=5累计=95 position={b3.lab.fetch-0=95}
poll #26 -> count=5累计=100 position={b3.lab.fetch-0=100}
poll #27 -> count=0累计=100 position={b3.lab.fetch-0=100}
total=100

三点观察:前 6 次 poll 返回 0 条不是 bug,是消费组加入与分配尚未完成(第一次 poll 触发 JoinGroup/SyncGroup,之后还要拉取已提交位移并重置到 earliest);稳定后每次恰好 5 条;position 在返回时就已推进——第 7 次 poll 返回 5 条,position 立即变成 5,而不是等下一次 poll。最后一点在 8.3 节会导致自动提交的丢消息场景。第 27 次 poll 返回 0 而 position 仍是 100,说明数据取完了,poll 等到超时才返回空集合。

三个字节参数的关系

拉取的量由三个参数共同决定,它们的作用层级不同。

参数默认值作用层级含义
fetch.min.bytes1请求broker 至少攒够这么多字节才回响应,否则等到 fetch.max.wait.ms
fetch.max.wait.ms500请求上面的最长等待时间
max.partition.fetch.bytes1 MiB请求中的分区项每个分区最多返回多少字节
fetch.max.bytes50 MiB请求一次响应最多返回多少字节

前三个在 AbstractFetch.createFetchRequest 里被写进请求:

final FetchRequest.Builder request = FetchRequest.Builder
        .forConsumer(maxVersion, fetchConfig.maxWaitMs, fetchConfig.minBytes, requestData.toSend())
        .isolationLevel(fetchConfig.isolationLevel)
        .setMaxBytes(fetchConfig.maxBytes)

forConsumer(...) 的后三个参数分别是版本、maxWaitMs、minBytes;setMaxBytes(fetchConfig.maxBytes) 对应 fetch.max.bytes;每个分区的 fetchSize(即 max.partition.fetch.bytes)在上面的 FetchRequest.PartitionData 构造里。

两个字节上限都是软上限:ConsumerConfig 的注释写明,如果第一个非空分区的第一个批次就超过 fetch.max.bytes,这个批次仍会被完整返回,否则消费者永远无法前进。max.partition.fetch.bytes 同理。

实测:2 KiB 的上限,返回了 16,000 字节

实验设计:写入 100 条 1000 字节的 value,生产者 linger.ms=50,让记录按默认 batch.size=16 KiB 成批;消费者设置 fetch.max.bytes=2048、max.partition.fetch.bytes=1024、max.poll.records=500,看单次 poll 实际拿到多少。

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

poll #01 count=0 payloadBytes=0 累计=0
(#02–#06 同为 0,加入组期间)
poll #07 count=16 payloadBytes=16000 累计=16
(#08–#11 每次 16 条、16000 字节,略)
poll #12 count=16 payloadBytes=16000 累计=96
total=96

fetch.max.bytes=2048、max.partition.fetch.bytes=1024,每次却拿到 16,000 字节的 payload,约为两个上限的 15.6 倍和 7.8 倍。原因就是批次粒度:16 条 × 约 1000 字节刚好凑满一个 16 KiB 批次,服务端把整个批次返回了。

这也说明 max.poll.records=500 在这轮里根本没起作用——每次只返回 16 条,是因为缓冲区里一次只有一个批次。循环到第 12 次停止,累计 96 条,剩下 4 条在下一个批次里。

这条管线对调优的含义

max.poll.records 控制的是处理节奏,不是网络流量。 把它调小不会减少网络请求,只会让同一批数据分更多次返回。真正影响网络的是 fetch.max.bytes、max.partition.fetch.bytes 和 fetch.min.bytes。

fetch.min.bytes 与 fetch.max.wait.ms 是一对。 调大 fetch.min.bytes 可以让 broker 攒更大的响应,提高吞吐,代价是低流量时延迟上升,最长等到 fetch.max.wait.ms。

有缓冲数据的分区会被跳过。 消费慢时客户端会自然停止向 broker 要更多数据,形成背压。「消费慢」不会表现为网络请求变多,而是表现为 poll 返回的数据一直取不完。

还有一个容易读错的返回值:sendFetches() 返回的是发出请求的节点数,不是分区数。ClassicKafkaConsumer 用它判断「是否需要 transmitSends()」,只关心有没有新请求产生。把返回值当成分区数会误判预取行为。

本节判据

读完这一节,你应该能回答四个问题:

  1. 4.3.0 的 Fetcher 为什么只有 9,378 字节? 答:拉取管线拆到了 AbstractFetch、FetchCollector、CompletedFetch、FetchBuffer,Fetcher 只剩发送与转发。
  2. fetch 请求怎么合并? 答:按 leader 节点聚合成 Map<Node, FetchRequestData>,一个节点一个请求,同一节点同时只有一个 in-flight。
  3. 分区按什么顺序拉? 答:不按 lag;按分配的插入顺序,取过数据或出错的分区移到队尾,形成轮转公平。
  4. max.poll.records 会减少网络请求吗? 答:不会。它只限制每次从缓冲区取出的条数;网络量由字节上限决定。

下一节进入提交侧:ConsumerCoordinator 什么时候把位移写回 __consumer_offsets,自动提交到底在哪个时刻触发,以及为什么「poll 之后处理很慢」会丢消息。那里的关键证据是 position 在 poll 返回时就已推进。

阅读导航:上一节 8.1 KafkaConsumer 的两套实现 | 下一节 8.3 ConsumerCoordinator 与位移提交

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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