《Java 消息中间件·Kafka 内核与客户端》5.2 集群成员与元数据传播

broker 重启后 brokerEpoch 会变,leader 变更会以一条记录进入 __cluster_metadata,再经 broker 的元数据缓存传到客户端。本篇实测分区扩容在 metadata.max.age.ms=5s 与 600s 下的可见时间差,并说明为什么 broker 掉线时客户端反而恢复得很快。

元数据传播是一条链,不是一次广播

客户端拿到「分区 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
leaderEpoch1 -> 2每次换 leader 递增,客户端用它拒绝旧 leader 的响应
lastKnownElr[1] -> []Eligible Leader Replicas 清空
leaderRecoveryStateRECOVERED -> RECOVERING分区处于恢复状态

「UNCLEAN」这个词不是贬义,而是准确描述:ISR 是空的,controller 没有「最新的副本」可选,只能从现有副本里挑一个,接受它可能缺数据。这正是 4.3 节讨论的不干净选举。单副本场景下没得选;多副本场景下,unclean.leader.election.enable 决定是否允许这种选择。

lastKnownElr 为空也说明 ELR(Eligible Leader Replicas)在这条路径上没有提供候选。ELR 是 4.x 的新概念,--describe 输出里有这一列,4.3 节会展开。

客户端什么时候看到变化

客户端不会订阅元数据变更,它只在三种情况下刷新:

  1. 自己的缓存过期(metadata.max.age.ms 到期);
  2. 遇到错误(连接断开、leader 不对、topic 不存在等);
  3. 主动请求(遇到新 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=0s3232
t=1s3232
t=2s(扩容)3232
t=3s3232
t=4s6232
t=5s6532
t=6s6432
t=8s6532
t=11s6332

结论很干脆:

  • 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:...148090
  • docker 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 --statusLeaderEpoch、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+0sdocker kill,TCP 连接断开客户端立刻 requestUpdate(false)
T+0.5scontroller 标记 fenced=1controller 日志
T+4sdocker start容器状态
T+4s 起JVM 启动、加载日志、注册 fenced=-1controller 日志
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 滚动重启时,客户端会依次看到:

  1. 连接断开 → 立即请求元数据;
  2. 新 broker 注册,brokerEpoch 变化;
  3. 分区 leader 变更,leaderEpoch 递增;
  4. 客户端拿到新 leader,重试被拒的批次。

这条路径不依赖 metadata.max.age.ms,所以滚动重启的恢复时间主要由 broker 启动和副本追赶决定,不由客户端轮询周期决定。13.2 节会把三个节点逐个重启,记录每一步的 ISR 变化。

反过来,如果你在业务高峰期扩分区,就要接受客户端最多一个 max.age 的延迟。下一节从客户端源码看这个「最多一个 max.age」到底是怎么算出来的。

阅读导航:上一节 5.1 KRaft:控制器与元数据日志 | 下一节 5.3 客户端的元数据刷新

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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