先说结论: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.bytes | 1 | 请求 | broker 至少攒够这么多字节才回响应,否则等到 fetch.max.wait.ms |
fetch.max.wait.ms | 500 | 请求 | 上面的最长等待时间 |
max.partition.fetch.bytes | 1 MiB | 请求中的分区项 | 每个分区最多返回多少字节 |
fetch.max.bytes | 50 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()」,只关心有没有新请求产生。把返回值当成分区数会误判预取行为。
本节判据
读完这一节,你应该能回答四个问题:
- 4.3.0 的
Fetcher为什么只有 9,378 字节? 答:拉取管线拆到了AbstractFetch、FetchCollector、CompletedFetch、FetchBuffer,Fetcher只剩发送与转发。 - fetch 请求怎么合并? 答:按 leader 节点聚合成
Map<Node, FetchRequestData>,一个节点一个请求,同一节点同时只有一个 in-flight。 - 分区按什么顺序拉? 答:不按 lag;按分配的插入顺序,取过数据或出错的分区移到队尾,形成轮转公平。
max.poll.records会减少网络请求吗? 答:不会。它只限制每次从缓冲区取出的条数;网络量由字节上限决定。
下一节进入提交侧:ConsumerCoordinator 什么时候把位移写回 __consumer_offsets,自动提交到底在哪个时刻触发,以及为什么「poll 之后处理很慢」会丢消息。那里的关键证据是 position 在 poll 返回时就已推进。
阅读导航:上一节 8.1 KafkaConsumer 的两套实现 | 下一节 8.3 ConsumerCoordinator 与位移提交
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。