客户端缓存的不是「配置」,是一份集群快照
生产者要知道三件事才能发消息:topic 有哪些分区、每个分区的 leader 在哪个 broker、broker 的地址是什么。这些信息来自 Metadata 响应,缓存在 org.apache.kafka.clients.Metadata 对象里。
Metadata 这个类名容易让人以为它只是个容器。实际上它是一个有状态机、有退避、有过期策略的对象。5.2 的实验已经暴露了它的两个行为:分区扩容要等 metadata.max.age.ms,broker 掉线却立刻刷新。这一节从源码解释这两个行为为什么同时成立。
源码来自本机 Maven 仓库的 kafka-clients-4.3.0-sources.jar,解压命令:
unzip -p ~/.m2/repository/org/apache/kafka/kafka-clients/4.3.0/kafka-clients-4.3.0-sources.jar \
org/apache/kafka/clients/Metadata.java > /tmp/Metadata.java
4.3.0 的 Metadata.java 是 854 行,下面引用的行号都来自这个文件。
状态机的字段
先看字段(第 67–87 行,节选):
public class Metadata implements Closeable {
private final Logger log;
private final ExponentialBackoff refreshBackoff;
private final long metadataExpireMs;
private int updateVersion; // bumped on every metadata response
private int requestVersion; // bumped on every new topic addition
private long lastRefreshMs;
private long lastSuccessfulRefreshMs;
private long attempts;
private KafkaException fatalException;
private Set<String> invalidTopics;
private Set<String> unauthorizedTopics;
private volatile MetadataSnapshot metadataSnapshot = MetadataSnapshot.empty();
private boolean needFullUpdate;
private boolean needPartialUpdate;
private long equivalentResponseCount;
private final ClusterResourceListeners clusterResourceListeners;
private boolean isClosed;
private final Map<TopicPartition, Integer> lastSeenLeaderEpochs;
按职责分组:
| 组 | 字段 | 作用 |
|---|---|---|
| 缓存 | metadataSnapshot | 当前集群快照,volatile,读不加锁 |
| 版本 | updateVersion / requestVersion | 响应版本 / 请求版本,用于判断部分更新 |
| 时间 | lastRefreshMs / lastSuccessfulRefreshMs | 上次尝试 / 上次成功 |
| 标志 | needFullUpdate / needPartialUpdate | 是否被显式要求刷新 |
| 退避 | refreshBackoff / attempts / equivalentResponseCount | 失败退避与「等价响应」退避 |
| leader | lastSeenLeaderEpochs | 每个分区见过的最大 leader epoch |
metadataSnapshot 是 volatile 的,说明读路径(fetch())不加锁;所有写路径都在 synchronized 方法里。这是典型的读多写少设计。
核心:下一次刷新是什么时候
整个状态机的核心是 timeToNextUpdate(第 178 行):
public synchronized long timeToNextUpdate(long nowMs) {
long timeToExpire = updateRequested() ? 0 : Math.max(this.lastSuccessfulRefreshMs + this.metadataExpireMs - nowMs, 0);
return Math.max(timeToExpire, timeToAllowUpdate(nowMs));
}
两行,两个条件:
updateRequested()为真(needFullUpdate || needPartialUpdate),过期时间算 0,立刻可刷;- 否则算「上次成功 +
metadataExpireMs- 现在」,也就是缓存还剩多久过期。
最后和 timeToAllowUpdate 取最大值。timeToAllowUpdate(第 153 行)负责退避:
public synchronized long timeToAllowUpdate(long nowMs) {
// Calculate the backoff for attempts which acts when metadata responses fail
long backoffForAttempts = Math.max(this.lastRefreshMs +
this.refreshBackoff.backoff(this.attempts > 0 ? this.attempts - 1 : 0) - nowMs, 0);
// Periodic updates based on expiration resets the equivalent response count so exponential backoff is not used
if (Math.max(this.lastSuccessfulRefreshMs + this.metadataExpireMs - nowMs, 0) == 0) {
this.equivalentResponseCount = 0;
}
// Calculate the backoff for equivalent responses which acts when metadata responses are not making progress
long backoffForEquivalentResponseCount = Math.max(this.lastRefreshMs +
(this.equivalentResponseCount > 0 ? this.refreshBackoff.backoff(this.equivalentResponseCount - 1) : 0) - nowMs, 0);
return Math.max(backoffForAttempts, backoffForEquivalentResponseCount);
}
这里有两条退避线:
- 失败退避:
attempts在每次失败后加 1,退避时间指数增长; - 等价响应退避:连续收到内容等价的响应时,
equivalentResponseCount增长,也走指数退避。这是为了防止客户端在「元数据没变化但一直问」的情况下打爆 broker。
退避参数不是写死的。构造函数(第 99 行)用 retry.backoff.ms、retry.backoff.max.ms 和抖动参数初始化 ExponentialBackoff:
this.refreshBackoff = new ExponentialBackoff(
refreshBackoffMs,
CommonClientConfigs.RETRY_BACKOFF_EXP_BASE,
refreshBackoffMaxMs,
CommonClientConfigs.RETRY_BACKOFF_JITTER);
所以 retry.backoff.ms 不只影响发送重试,也影响元数据刷新。把它调到 1000 ms,元数据失败后的第一次重试也要等 1 秒。
注意 timeToNextUpdate 取的是最大值:即使缓存已经过期,只要退避还没结束,也不会刷。这意味着 metadata.max.age.ms 是刷新的必要条件,不是充分条件。设成 1 秒也不会每秒刷一次,实际频率受 retry.backoff.ms 下限约束。
两种 requestUpdate
请求刷新有两个入口:
public synchronized int requestUpdate(final boolean resetEquivalentResponseBackoff) {
this.needFullUpdate = true;
if (resetEquivalentResponseBackoff) {
this.equivalentResponseCount = 0;
}
return this.updateVersion;
}
public synchronized int requestUpdateForNewTopics() {
// Override the timestamp of last refresh to let immediate update.
this.lastRefreshMs = 0;
this.needPartialUpdate = true;
this.equivalentResponseCount = 0;
this.requestVersion++;
return this.updateVersion;
}
区别在语义:
| 方法 | 标志 | 是否重置时间 | 典型调用方 |
|---|---|---|---|
requestUpdate(boolean) | needFullUpdate | 否 | 断连、leader 错误、重试 |
requestUpdateForNewTopics() | needPartialUpdate | 是(lastRefreshMs=0) | 发送新 topic 的消息 |
requestUpdateForNewTopics 把 lastRefreshMs 直接置 0,绕过退避,所以新 topic 的第一条消息能立刻拿到元数据,不用等 retry.backoff.ms。这也解释了为什么「新建 topic 后马上发」通常很快——只要不是同一批 topic 已经失败过多次。
参数 resetEquivalentResponseBackoff 也值得注意。注释说明:重试类请求(leader 变更)传 false,新 topic 类传 true。传错会让退避计数在不该重置的时候重置,表现为元数据请求比预期频繁。
响应落地:update
update(第 337 行,节选)做四件事:
public synchronized void update(int requestVersion, MetadataResponse response, boolean isPartialUpdate, long nowMs) {
Objects.requireNonNull(response, "Metadata response cannot be null");
if (isClosed())
throw new IllegalStateException("Update requested after metadata close");
this.needPartialUpdate = requestVersion < this.requestVersion;
this.lastRefreshMs = nowMs;
this.attempts = 0;
this.updateVersion += 1;
if (!isPartialUpdate) {
this.needFullUpdate = false;
this.lastSuccessfulRefreshMs = nowMs;
}
this.equivalentResponseCount++;
String previousClusterId = metadataSnapshot.clusterResource().clusterId();
this.metadataSnapshot = handleMetadataResponse(response, isPartialUpdate, nowMs);
// ... 省略错误处理与 listener 通知
log.debug("Updated cluster metadata updateVersion {} to {}", this.updateVersion, this.metadataSnapshot);
}
几个细节:
needPartialUpdate = requestVersion < this.requestVersion:如果请求发出后又有新 topic 加入,这次响应的版本就落后了,还需要一次部分更新;- 只有全量更新才更新
lastSuccessfulRefreshMs。部分更新不重置过期计时,否则一个频繁发新 topic 的客户端会永远不做过期刷新; attempts清零,失败退避解除;equivalentResponseCount在handleMetadataResponse内部如果发现内容有变化会被清零,否则继续累加;updateVersion每次响应都加 1,日志里那句Updated cluster metadata updateVersion N to ...就是它。
update 是 synchronized 的,而 fetch() 读 volatile 快照。写入期间读线程看到的是旧快照或新快照,不会看到半成品。
失败:failedUpdate 只记时间,不抛异常
public synchronized void failedUpdate(long now) {
this.lastRefreshMs = now;
this.attempts++;
this.equivalentResponseCount = 0;
}
失败不设置错误状态,只把 attempts 加 1、记录时间。真正的异常在 maybeThrowAnyException 里按需抛出,由调用方(发送线程)决定怎么处理。这种设计让「一次元数据请求失败」不会立刻影响正在发送的消息,只是让下一次刷新退避更久。
谁在驱动刷新
Metadata 自己不跑线程。驱动它的是 NetworkClient.DefaultMetadataUpdater.maybeUpdate(第 1211 行):
public long maybeUpdate(long now) {
// should we update our metadata?
long timeToNextMetadataUpdate = metadata.timeToNextUpdate(now);
long waitForMetadataFetch = hasFetchInProgress() ? defaultRequestTimeoutMs : 0;
long metadataTimeout = Math.max(timeToNextMetadataUpdate, waitForMetadataFetch);
if (metadataTimeout > 0) {
return metadataTimeout;
}
// ... 选 leastLoadedNode,发 Metadata 请求
}
Sender 的主循环每次调用 client.poll(pollTimeout, now),pollTimeout 的一部分就来自这里。所以:
- 没有消息要发时,Sender 仍然按
timeToNextUpdate设置 poll 超时;到期后maybeUpdate返回 0,发 Metadata 请求; - 有请求在途时,
waitForMetadataFetch = defaultRequestTimeoutMs,避免并发发多个元数据请求; - 连接断开时,
handleServerDisconnect调requestUpdate(false),把needFullUpdate置真,下一次循环立刻刷。
这三条合起来,就是 5.2 里「扩容要等 age、掉线立刻恢复」的源码解释。
ProducerMetadata:topic 过期与 awaitUpdate
生产者用的不是裸 Metadata,而是子类 ProducerMetadata。它额外维护「每个 topic 上次使用时间」,并在响应处理时决定是否保留:
@Override
public synchronized boolean retainTopic(String topic, boolean isInternal, long nowMs) {
Long expireMs = topics.get(topic);
if (expireMs == null) {
return false;
} else if (newTopics.contains(topic)) {
return true;
} else if (expireMs <= nowMs) {
log.debug("Removing unused topic {} from the metadata list, expiryMs {} now {}", topic, expireMs, nowMs);
topics.remove(topic);
return false;
} else {
return true;
}
}
这就是 metadata.max.idle.ms 的作用:一段时间不用的 topic 会从刷新集合里移除,避免一个发过几千个 topic 的客户端每次都拉全量。默认 300000 ms,与 max.age 同量级但语义不同:max.age 管「多久刷一次」,max.idle.ms 管「不用的 topic 什么时候忘掉」。
发送时的等待逻辑在 awaitUpdate(第 120 行):
public synchronized void awaitUpdate(final int lastVersion, final long timeoutMs) throws InterruptedException {
long currentTimeMs = time.milliseconds();
long deadlineMs = currentTimeMs + timeoutMs < 0 ? Long.MAX_VALUE : currentTimeMs + timeoutMs;
time.waitObject(this, () -> {
// Throw fatal exceptions, if there are any.
maybeThrowFatalException();
// ...
}, deadlineMs);
}
send() 在元数据不可用时就是卡在这里,超时时间由 max.block.ms 决定。这就是 3.2 里 acks=0 实验出现 Topic a2.acks0 not present in metadata after 5000 ms 的来源。
实测:刷新频率由 max.age 决定
用 MetaRefreshLab 发 10 秒消息,再空闲 8 秒,开启 DEBUG 日志,统计 Sending metadata request 的行数:
java -Dorg.slf4j.simpleLogger.defaultLogLevel=debug \
-cp "target/classes:$(cat cp.txt)" lab.MetaRefreshLab a2.refresh 2000 10000 8000
本机实测(2026-10-29):
metadata.max.age.ms | 总时长 | Metadata 请求数 | 分布 |
|---|---|---|---|
| 2000 | 18 s | 9 | 活跃期 6 次,空闲期 3 次 |
| 60000 | 18 s | 1 | 仅启动时 1 次 |
活跃期大约每 2 秒一次,符合 max.age=2000。空闲期仍有 3 次,说明 Sender 线程在空闲时也按过期时间刷新,不需要 send() 触发。
18 秒是单次运行的墙钟时间,随调度波动,不作基准;两行的差异(9 次对 1 次)是数量级差别,不受几个百分点的抖动影响。
这个数字还有一个用途:估算客户端群的 Metadata 负载。1000 个生产者、max.age=2s,就是每秒约 500 个 Metadata 请求;max.age=300s(默认)时约每秒 3 个。差两个数量级。
过期导致的四类抖动
抖动一:扩容窗口
5.2 已经测过:max.age=600000 时,分区从 3 扩到 6,12 秒内客户端仍只看到 3 个分区。窗口上限就是一个 max.age。这不是 bug,是缓存语义。
抖动二:topic 删除重建
topic 被删除再创建同名 topic,topic id 会变。客户端日志里会出现:
Resetting the last seen epoch of partition a2.acks0-0 to 2 since the associated topicId changed from null to xetYQDXzRNWqPRc96l_p4Q
这是本机 acks=0 实验里 broker 重启后出现的真实日志。客户端发现 topic id 和缓存对不上,重置 leader epoch,重新拉元数据。如果只按 topic 名缓存而不看 id,就会把旧分区的 leader 信息用到新 topic 上。
抖动三:等价响应退避
如果集群元数据长时间不变,客户端每次刷新拿到的响应内容一样。equivalentResponseCount 累加,退避时间指数增长,实际刷新间隔会大于 max.age。这是有意的保护:没有变化就不要频繁问。
副作用是「刚扩完分区但客户端还没看到」的窗口可能比一个 max.age 更长,因为退避在起作用。要缩短窗口,靠调小 max.age 不够,得让变更触发错误路径,或者重启客户端。
抖动四:max.age 小于 retry.backoff
timeToNextUpdate 取 max(timeToExpire, timeToAllowUpdate)。把 max.age 设成 100 ms、retry.backoff.ms 用默认 100 ms,实际刷新不会快于退避。设得比退避还小只会让配置看起来激进,行为不变。
leaderEpoch:防止用旧视图写
客户端缓存的不是「分区 → leader」,而是「分区 → (leader, leaderEpoch)」。partitionMetadataIfCurrent(第 276 行)会检查缓存的 epoch 是否等于见过的最大 epoch:
public synchronized Optional<MetadataResponse.PartitionMetadata> partitionMetadataIfCurrent(TopicPartition topicPartition) {
Integer epoch = lastSeenLeaderEpochs.get(topicPartition);
Optional<MetadataResponse.PartitionMetadata> partitionMetadata = metadataSnapshot.partitionMetadata(topicPartition);
if (epoch == null) {
// old cluster format (no epochs)
return partitionMetadata;
} else {
return partitionMetadata.filter(metadata ->
metadata.leaderEpoch.orElse(NO_PARTITION_LEADER_EPOCH).equals(epoch));
}
}
如果缓存的 epoch 落后,就不返回这个分区的元数据,迫使上层重新拉取。5.2 里 controller 日志的 leaderEpoch: 1 -> 2 就是这里的 2。没有 epoch,客户端无法区分「同一个 broker 还是 leader」和「broker 重启后重新当上 leader」,会把发往旧 leader 的请求当成有效。
读源码的方法
这一节的每个结论都能复现:
- 从 sources jar 解出
Metadata.java、NetworkClient.java、ProducerMetadata.java; grep -n定位方法,读完整方法体,不只看签名;- 把类名、方法名、行号记在
source-notes/里; - 用 DEBUG 日志验证运行时行为,不用「我记得 3.x 是这样」代替。
本机的笔记在 /tmp/mq-a2-work/source-notes/metadata-and-partitioner.md,记录了每个方法的行号。这套方法在第 7–9 章会反复使用。
回到 libraryd
libraryd-mq 的生产者不需要改 metadata.max.age.ms。默认 5 分钟对借阅事件足够,滚动重启和 leader 变更走错误触发路径,不受这个值影响。
需要关注的是扩容和迁移:如果某次运维把 library.loan.created 从 6 个分区扩到 12 个,生产者在最长 5 分钟内仍只会往 0–5 发。这个窗口要写进变更方案,而不是假设「加完分区立刻生效」。
下一节进入消费侧:位移存在哪、__consumer_offsets 的 key 是什么、提交位移和消费位置有什么区别。
阅读导航:上一节 5.2 集群成员与元数据传播 | 下一节 6.1 位移与 __consumer_offsets
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。