元数据传播是一条链,不是一次广播
客户端拿到「分区 0 的 leader 是谁」这个信息,中间经过四层:
controller 状态机
→ __cluster_metadata 日志(Raft 提交)
→ broker 的 MetadataCache(从日志拉取)
→ 客户端 Metadata RPC
→ 客户端 Metadata 对象(缓存)
每一层都有自己的延迟和触发条件。排查「为什么客户端还在往旧 leader 发」时,必须先确定卡在哪一层,否则调什么参数都是猜。
这一节按这条链从上往下走:先看 broker 怎么进入集群、leader 变更怎么写进日志,再看客户端什么时候会来取新元数据。
broker 注册:brokerEpoch 与 fenced
broker 启动后第一件事是向 controller 注册。注册不是「打个招呼」,而是一条要提交到元数据日志的记录。5.1 已经看过 REGISTER_BROKER_RECORD 的字段,这里看它在重启前后的变化。
本机把单节点容器 docker kill 再 docker start,controller 日志里出现三条连续的注册变更(2026-10-29,摘录):
[03:56:03,444] INFO [QuorumController id=1] Replayed BrokerRegistrationChangeRecord modifying the registration for broker 1:
BrokerRegistrationChangeRecord(brokerId=1, brokerEpoch=1369, fenced=1, inControlledShutdown=0, logDirs=[], ...)
[03:56:04,591] INFO [QuorumController id=1] Replayed BrokerRegistrationChangeRecord modifying the registration for broker 1:
BrokerRegistrationChangeRecord(brokerId=1, brokerEpoch=2063, fenced=-1, inControlledShutdown=0, logDirs=[], ...)
[03:56:04,592] INFO [QuorumController id=1] Replayed BrokerRegistrationChangeRecord modifying the registration for broker 1:
BrokerRegistrationChangeRecord(brokerId=1, brokerEpoch=2063, fenced=0, inControlledShutdown=0, logDirs=[], ...)
三个字段值得解释:
- brokerEpoch:每次注册递增。1369 是上一次会话的 epoch,2063 是重启后的新 epoch。epoch 变化告诉 controller「这是一个新会话,旧会话的请求要作废」;
- fenced:
1表示 broker 已被隔离(关闭时由 controller 标记),-1表示注册进行中,0表示正常服务; - logDirs:broker 上报的日志目录身份,controller 用它判断副本数据还在不在。
这三条记录不是瞬间完成的。关闭时先 fenced=1,重启后先 fenced=-1 注册,再 fenced=0 解隔离。中间任何一步失败,broker 都不会接流量。
对本机单节点来说,这三条记录发生在同一个 controller 上;三节点集群里,它们要先经过 Raft 多数派提交,再被所有 broker 拉到。多出来的是提交延迟,不是逻辑差异。
leader 变更:一条 UNCLEAN 记录
broker 从 fenced=1 恢复到 fenced=0 的同时,它上面的分区要重新选 leader。本机 a2.recovery-0 的记录是:
[03:56:04,592] INFO [QuorumController id=1] UNCLEAN partition change for a2.recovery-0 with topic ID QWDXQJf1S_i_PapDQCan7g:
isr: [] -> [1], leader: -1 -> 1, leaderRecoveryState: RECOVERED -> RECOVERING,
leaderEpoch: 1 -> 2, lastKnownElr: [1] -> [], partitionEpoch: 2 -> 3
逐字段读:
| 字段 | 变化 | 含义 |
|---|---|---|
isr | [] -> [1] | broker 掉线后 ISR 清空,重启后重新加入 |
leader | -1 -> 1 | 没有 leader,重新选为 1 |
leaderEpoch | 1 -> 2 | 每次换 leader 递增,客户端用它拒绝旧 leader 的响应 |
lastKnownElr | [1] -> [] | Eligible Leader Replicas 清空 |
leaderRecoveryState | RECOVERED -> RECOVERING | 分区处于恢复状态 |
「UNCLEAN」这个词不是贬义,而是准确描述:ISR 是空的,controller 没有「最新的副本」可选,只能从现有副本里挑一个,接受它可能缺数据。这正是 4.3 节讨论的不干净选举。单副本场景下没得选;多副本场景下,unclean.leader.election.enable 决定是否允许这种选择。
lastKnownElr 为空也说明 ELR(Eligible Leader Replicas)在这条路径上没有提供候选。ELR 是 4.x 的新概念,--describe 输出里有这一列,4.3 节会展开。
客户端什么时候看到变化
客户端不会订阅元数据变更,它只在三种情况下刷新:
- 自己的缓存过期(
metadata.max.age.ms到期); - 遇到错误(连接断开、leader 不对、topic 不存在等);
- 主动请求(遇到新 topic、新分区)。
第一种是周期性的,后两种是事件驱动的。理解这个区分,才能理解下面两组实验的差异。
实验一:分区扩容的可见时间
实验设计:创建一个 3 分区的 topic,生产者跑起来后用 AdminClient 把它扩到 6 分区,继续发消息,每秒记录「生产者缓存里的分区数」和「这一秒用到的最大分区号」。两组只改 metadata.max.age.ms。
程序 MetadataLab 的关键逻辑:
p.put(ProducerConfig.METADATA_MAX_AGE_CONFIG, Long.toString(maxAgeMs));
// 运行 2 秒后:
admin.createPartitions(Map.of(topic, NewPartitions.increaseTo(6))).all().get();
// 每秒打印 producer.partitionsFor(topic).size() 与 maxPartitionUsed
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):
| 时间 | maxAge=5000 缓存分区数 | maxAge=5000 最大分区 | maxAge=600000 缓存分区数 | maxAge=600000 最大分区 |
|---|---|---|---|---|
| t=0s | 3 | 2 | 3 | 2 |
| t=1s | 3 | 2 | 3 | 2 |
| t=2s(扩容) | 3 | 2 | 3 | 2 |
| t=3s | 3 | 2 | 3 | 2 |
| t=4s | 6 | 2 | 3 | 2 |
| t=5s | 6 | 5 | 3 | 2 |
| t=6s | 6 | 4 | 3 | 2 |
| t=8s | 6 | 5 | 3 | 2 |
| t=11s | 6 | 3 | 3 | 2 |
结论很干脆:
maxAge=5000时,扩容后约 2 秒(即上次成功刷新约 5 秒后)客户端看到 6 个分区,随后开始使用 3、4、5 号分区;maxAge=600000时,12 秒内客户端始终只看到 3 个分区,所有消息继续落在 0–2。
也就是说,分区扩容对生产者的可见时间由 metadata.max.age.ms 决定,前提是没有错误触发刷新。扩容本身不会给客户端发任何通知。
表中的秒数是单次运行,随调度波动,不作基准;有意义的差别是 2 秒与「12 秒内完全看不到」,不是秒级精度的抖动。
这解释了一个运维场景:业务高峰想靠「加分区」紧急扩容,加完发现吞吐没变。不是分区没生效,是生产者还在用旧视图。客户端数量越多、max.age 越大,这个窗口越长。
实验二:broker 掉线时为什么恢复得快
如果按实验一的结论推论,「metadata.max.age.ms=600000 的客户端在 broker 重启后要等 10 分钟」,那就错了。
实验设计:生产者 acks=1、metadata.max.age.ms=600000、每 100 ms 发一条,运行 45 秒。第 4 秒 docker kill,第 8 秒 docker start。每秒打印成功/失败累计数。
本机实测结果(摘录,时间戳为 epoch 毫秒):
ts=...144316 ok=0 fail=0
ts=...15354 ok=10 fail=0
ts=...16392 ok=20 fail=0
ts=...17447 ok=30 fail=0
ts=...151981 ok=34 fail=1 <- broker 已被 kill
ts=...156093 ok=34 fail=2
ts=...160197 ok=34 fail=3
ts=...164303 ok=34 fail=4
ts=...165362 ok=42 fail=4 <- 恢复后的第一条成功
关键时间点:
docker kill:...148090docker start:...152408- 生产者恢复成功:
...165362,距docker start约 13 秒 - broker 可服务(轮询
kafka-broker-api-versions.sh):...166858,但每次docker exec本身有 1 秒级开销,这个时间点是上界
生产者恢复的 13 秒,绝大部分是 broker 启动时间(JVM 启动、日志恢复、注册、选 leader),不是元数据传播。metadata.max.age.ms=600000 没有造成额外等待。
原因在客户端源码:连接断开时,NetworkClient.DefaultMetadataUpdater.handleServerDisconnect 会立刻请求刷新,不等 max.age:
// If we have a disconnect while an update is due, we treat it as a failed update
// so that we can backoff properly
if (isUpdateDue(now))
handleFailedRequest(now, Optional.empty());
maybeFatalException.ifPresent(metadata::fatalError);
// The disconnect may be the result of stale metadata, so request an update
metadata.requestUpdate(false);
requestUpdate(false) 把 needFullUpdate 置为 true,下一次 timeToNextUpdate 返回 0,Sender 立刻发 Metadata 请求(退避时间到了的话)。这条路径不受 metadata.max.age.ms 影响。
所以两种场景要分开看:
| 场景 | 触发方式 | 受 max.age 影响 |
|---|---|---|
| 分区扩容、配置变更 | 没有错误,只能等缓存过期 | 是 |
| broker 掉线、leader 变更、topic 不存在 | 连接错误或响应错误 | 否,立即触发 |
四层各自的观测点
| 层 | 观测手段 | 看到什么 |
|---|---|---|
| controller 状态机 | kafka-metadata-quorum.sh describe --status | LeaderEpoch、HighWatermark |
| 元数据日志 | kafka-dump-log.sh --cluster-metadata-decoder | 具体记录与批次 |
| broker 缓存 | broker 日志、kafka-topics.sh --describe | 分区 leader、ISR、ELR |
| 客户端 | DEBUG 日志 Updated cluster metadata updateVersion | 客户端缓存的版本 |
客户端这一层最容易观察。把 slf4j-simple 的级别调到 debug:
java -Dorg.slf4j.simpleLogger.defaultLogLevel=debug \
-cp "target/classes:$(cat cp.txt)" lab.MetaRefreshLab a2.refresh 2000 10000 8000
日志里会出现:
DEBUG org.apache.kafka.clients.NetworkClient - Sending metadata request ... to node 127.0.0.1:19202
DEBUG org.apache.kafka.clients.Metadata - Updated cluster metadata updateVersion 3 to MetadataSnapshot{...}
updateVersion 每收到一次响应加 1。它不是集群的版本号,是客户端自己的计数器;两台客户端看到同一个 updateVersion 不代表它们的状态一致。
一次 leader 变更的完整时间线
把上面的碎片拼起来,单节点一次「杀 broker 再启动」的时间线是这样的(本机 a2.recovery,2026-10-29):
| 时刻 | 事件 | 观察点 |
|---|---|---|
| T+0s | docker kill,TCP 连接断开 | 客户端立刻 requestUpdate(false) |
| T+0.5s | controller 标记 fenced=1 | controller 日志 |
| T+4s | docker start | 容器状态 |
| T+4s 起 | JVM 启动、加载日志、注册 fenced=-1 | controller 日志 |
| T+16.5s | 注册完成 fenced=0,分区 leader 重新选出 | UNCLEAN partition change |
| T+17s | 生产者第一次成功 | 客户端计数 |
表中 T 是 kill 的时刻,不是 start 的时刻。换成「距 start」的刻度,broker 可服务约在 12–13 秒,生产者在其后 1 秒内恢复。这 12–13 秒是启动成本,和元数据传播无关。
整条时间线也是单次运行,随调度波动,不作基准。特别是 docker start 到 fenced=0 这一段,本机 load 在十几到二十几之间,换一台机器数字会明显不同;能复现的是「启动是主要成本、元数据刷新不额外等 max.age」这个结构。
如果这是三节点集群,时间线会多两段:Raft 提交注册记录要多数派确认,副本要从 leader 追赶。这两段的耗时取决于 replica.lag.time.max.ms 和实际数据量,第 13 章会测。
一个容易误读的指标是 MaxFollowerLagTimeMs。单节点时它恒为 0,因为唯一的 voter 就是 leader。三节点时它才反映 follower 的追赶延迟;拿单节点输出判断「复制很健康」没有意义。
全量刷新与增量刷新
客户端发出的 Metadata 请求不总是「拉全部 topic」。Metadata.newMetadataRequestAndVersion(第 718 行)按条件选择:
public synchronized MetadataRequestAndVersion newMetadataRequestAndVersion(long nowMs) {
MetadataRequest.Builder request = null;
boolean isPartialUpdate = false;
// Perform a partial update only if a full update hasn't been requested, and the last successful
// hasn't exceeded the metadata refresh time.
if (!this.needFullUpdate && this.lastSuccessfulRefreshMs + this.metadataExpireMs > nowMs) {
request = newMetadataRequestBuilderForNewTopics();
isPartialUpdate = true;
}
if (request == null) {
request = newMetadataRequestBuilder();
isPartialUpdate = false;
}
return new MetadataRequestAndVersion(request, requestVersion, isPartialUpdate);
}
翻译成规则:
- 有
needFullUpdate,或者缓存已经过期 → 全量请求; - 否则只请求「还没缓存的新 topic」→ 增量请求。
这解释了 DEBUG 日志里的 MetadataRequestData(topics=[MetadataRequestTopic(topicId=..., name='a2.refresh')]):本机生产者只关心一个 topic,请求里就只有它。topic 多的客户端,全量请求的响应会很大;增量请求是控制响应体积的手段。
也解释了为什么 requestUpdateForNewTopics 要单独存在:它走增量路径,不需要把整个集群重新拉一遍。
该不该调 metadata.max.age.ms
默认值是 300000(5 分钟),定义在 ProducerConfig 与 ConsumerConfig 共同的 metadata.max.age.ms。调它有明确的取舍:
| 调小 | 调大 |
|---|---|
| 扩容、迁移更快可见 | 扩容可见变慢 |
| Metadata 请求变多 | 请求变少 |
| 客户端 CPU 与 broker 负载上升 | 负载下降 |
5.3 节会给出刷新频率的实测:maxAge=2000 时,18 秒内发了 9 次 Metadata 请求;maxAge=60000 时只有 1 次。18 秒里包含 8 秒空闲期,空闲期也会刷新,因为 Sender 线程按缓存过期时间驱动。
实践建议:
- 不要低于 30 秒。低于这个值,Metadata 请求会成为可观测的负载;
- 不要指望靠调小它来解决「客户端看不到新分区」。更好的做法是让变更走错误路径,或者接受一个窗口;
- 生产环境做分区扩容时,把「客户端最长一个
max.age的窗口」写进操作手册,而不是假设立即生效。
回到 libraryd
libraryd-mq 的 broker 滚动重启时,客户端会依次看到:
- 连接断开 → 立即请求元数据;
- 新 broker 注册,
brokerEpoch变化; - 分区 leader 变更,
leaderEpoch递增; - 客户端拿到新 leader,重试被拒的批次。
这条路径不依赖 metadata.max.age.ms,所以滚动重启的恢复时间主要由 broker 启动和副本追赶决定,不由客户端轮询周期决定。13.2 节会把三个节点逐个重启,记录每一步的 ISR 变化。
反过来,如果你在业务高峰期扩分区,就要接受客户端最多一个 max.age 的延迟。下一节从客户端源码看这个「最多一个 max.age」到底是怎么算出来的。
阅读导航:上一节 5.1 KRaft:控制器与元数据日志 | 下一节 5.3 客户端的元数据刷新
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。