1. 元数据在 Kafka 里到底指什么:三份账本各管一段
先说结论:Kafka 的元数据不是“一份数据”,而是三份内容相同、用途不同、更新节奏也不同的账本。我最早看这块源码时,误以为客户端里的Cluster对象就是元数据本身,读了两天才发现客户端那份只是元数据最末端的缓存副本,真正决定集群长什么样的是控制面那份账本,broker 上那份则夹在两者中间,只负责推理。
判断自己是否真的搞懂了 Kafka 的元数据,有一个很简单的检验方法:当客户端报出UnknownTopicOrPartitionException、但你在控制台上明明能看到这个 topic 时,你要能立刻说出“现在三个账本里是哪两个不一致、为什么会不一致、下一次对齐由谁触发”。答不上来,说明你对这三份账本的边界还是模糊的。
1.1 客户端视角:一份会被整体替换的“路由表”
客户端里的元数据,逻辑上就是一个Map<TopicPartition, PartitionInfo>:你要往哪个分区发消息,得先知道这个分区的 leader 是哪台 broker、地址和端口是多少;如果要知道备份情况,就得知道 replicas 和 isr 是谁。生产者的Sender线程、消费者的Fetcher每次发请求前,都要通过Metadata对象做一次查询,查不到就阻塞等一次元数据刷新。
这份元数据最重要的性质是它只读不写。客户端永远不会自行“推断”一个分区的 leader,它要么从响应里拿到确切答案,要么就老老实实报错。这个设计看起来保守,实际上省掉了大量一致性问题——如果客户端能靠推测补全元数据,那补错了谁来负责?
还有一个容易被忽略的点:客户端元数据的更新方式是整体替换而不是增量修改。Metadata.update()拿到响应后,会构造一个全新的Cluster对象,然后一次性把内部的cluster引用换掉。这意味着任何一次元数据响应,都是对客户端认知的一次“全量覆盖”,而不是“打补丁”。后面讲并发时你会看到,这个设计带来了一些副作用。
1.2 Broker 视角:MetadataCache 与分区可用性判定
每台 broker 本地都维护一份MetadataCache,它的职责和客户端那份Cluster有关系但是判断逻辑完全不同。客户端只需要知道“leader 是谁,好去发请求”,broker 需要知道的是“我是不是 leader、我的 ISR 现在包含谁、这个分区现在到底能不能对外服务”。
所以MetadataCache里存的东西比客户端那份要多:除了 leader 和 replicas,还有LeaderAndIsr结构(包括 leader epoch、controller epoch、isr、addingReplicas、removingReplicas)。而且它对外提供元数据时,会做一层过滤和错误包装,这个过滤逻辑是很多线上问题的根源,第 4 章会展开讲。
在 ZooKeeper 时代,MetadataCache的内容由 controller 通过UpdateMetadataRequest推送过来;在 KRaft 模式下,它由 broker 自己从元数据日志中读出来并应用。两条路径的机制完全不同,但对外暴露的接口是一致的,这就是为什么很多人读源码时会在MetadataCache和KRaftMetadataCache之间来回切换,最终却没有搞清楚两者的差别。
1.3 控制面视角:权威版本的唯一来源
控制面才是元数据的权威来源。所有变更——建主题、删分区、扩副本、leader 选举、ACL 修改、配额调整——都先落到控制面,再由控制面同步给 broker,最终由 broker 提供给客户端。
ZooKeeper 模式下,权威数据散落在若干个 znode 里:/brokers/topics/<topic>存分区和副本分配,/brokers/ids/<id>存 broker 注册信息,/admin/reassign_partitions存副本重分配任务,/controller存当前 controller 是谁。controller 是这些 znode 的唯一写入者,其他 broker 只读。
KRaft 模式下,权威数据变成了一条只追加的日志。每个变更对应日志里的若干条 record,所有 broker 和 controller 共享同一条日志,靠 Raft 协议保证顺序和持久性。这个转变带来的最大好处是元数据同步从“推送”变成了“回放”,controller 不再需要给每台 broker 单独发UpdateMetadataRequest,只需要往日志里写一条记录,所有节点自己读、自己应用、自己追平。
下面这张表是我用来给自己做速查的,把三份账本的关键差异列在一起:
| 维度 | 客户端 Metadata | Broker MetadataCache | 控制面(ZK / KRaft) |
|---|---|---|---|
| 数据结构 | Cluster + PartitionInfo | PartitionMetadata + LeaderAndIsr | znode 树 / MetadataRecord 日志 |
| 更新方式 | 全量响应替换 | 控制器推送 / 日志回放 | 控制器写入 |
| 更新触发 | 定时过期、错误驱动 | 控制面变更 | 外部管理操作 |
| 一致性要求 | 最终一致即可 | 严格跟随控制面 | 强一致(Raft 多数派) |
| 谁负责纠偏 | 客户端自己重试 | 无,被动接受 | 控制器 / Raft leader |
看懂这张表,很多“为什么客户端还在往已经下线的 broker 发请求”这类问题就不用再查日志:客户端那份只是缓存,旧了很正常,要关注的是它多久会刷新、由什么触发。
2. 客户端 Metadata 类的状态机:何时刷新、刷新几次
org.apache.kafka.clients.Metadata这个类被很多人当工具类读,其实它是一个带状态机语义的并发容器。它的所有方法要么是synchronized的,要么是在synchronized块里改状态,原因很直接:Sender线程和用户线程会同时操作它。
2.1 五个字段,决定刷新行为的全部
剥掉日志和监听器,真正决定“何时刷新”的就五个量:
private final long refreshBackoffMs; // 最短暂停,避免疯狂重发 private final long refreshBackoffMaxMs; // 退避上限(3.x 引入) private final int metadataExpirationMs; // metadata.max.age.ms private long lastRefreshMs; // 上次发起刷新的时间 private long lastSuccessfulRefreshMs; // 上次成功刷新的时间 private int version; // 单调递增的成功刷新版本号我见过不少线上问题出在对refreshBackoffMs的理解上。这个值的语义是“两次元数据请求之间的最小间隔”,它的存在是为了防止错误响应把客户端打成一个死循环:拿到NOT_LEADER_OR_FOLLOWER就立刻重发、重发回来还是旧元数据、又报错、又重发。没有这个节流,一个元数据传播延迟就能把你的 broker 打满。
refreshBackoffMaxMs是后来加的,把固定间隔变成了指数退避。逻辑是:连续失败时逐步拉长间隔,成功一次就复位。这个改动对大规模集群意义很大,因为元数据请求虽然单次开销不高,但几千个客户端同时重试的时候,累积起来对 controller 和 broker 的压力相当可观。
metadataExpirationMs对应配置项metadata.max.age.ms,默认 5 分钟。它的作用是:即使一切正常、没有任何错误,客户端也要定期无条件刷新一次。这个“定期刷新”看起来浪费,实际上是为了对付“客户端拿着正确但过期的元数据”这种最难排查的状态——比如一台 broker 悄然下线,如果没有定期刷新,客户端可能永远不会主动去查,只会一直收到错误响应。
2.2 requestUpdate 与 requestUpdateForNewTopics 的差别
这两个方法名字像,语义差得很远,我在代码审查里见过把它们用混的。
requestUpdate()是从当前时刻起重新计时,把lastSuccessfulRefreshMs归零(不同版本实现略有差异,有的用needUpdate布尔量),意思是“下次maybeUpdate检查时,你应该认为元数据已经不可信了,立刻发一次请求”。
requestUpdateForNewTopics()除了做上面这件事,还会把topics集合的变化也纳入考虑。生产者订阅了一个新主题、消费者assign了一个之前没见过的分区时,必须走这个方法,否则后续的MetadataRequest里根本不会带上这个主题,这个主题永远无法获得元数据。
public synchronized int requestUpdateForNewTopics() { this.lastRefreshMs = 0; return this.requestUpdate(); }这个实现看起来平淡无奇,但它是整个客户端自动发现机制的入口。生产者KafkaProducer.partitionsFor()在订阅新主题时会调用它,消费者在subscribe()之后发现分区列表变了也会走到这里。如果这一步没有触发,后面所有“刷新元数据”的逻辑都跑不起来,因为请求里压根不含新主题。
还有一个变体requestUpdateForAllTopics(),它把needMetadataForAllTopics置为 true,对应MetadataRequest里 topics 为 null 的“全量拉取”语义。这个接口一般只在内部使用(比如某些管理类操作需要拿到全集群视图),业务代码里我建议不要手动设置它——全量元数据响应在几千个主题的集群上可以轻松达到几 MB,频繁请求会明显拖慢客户端。
2.3 update() 里的整体替换与 notifyAll
public synchronized void update(MetadataResponse response, long now) { this.lastSuccessfulRefreshMs = now; this.version += 1; if (topicExpiryEnabled) handleMetadataResponse(response); Cluster cluster; try { cluster = response.cluster(this.metadataVersion); } catch (Exception e) { log.warn("Ignoring metadata response because ... ", e); return; } this.cluster = cluster; notifyAll(); }三点值得说。
第一,version的递增发生在成功构造Cluster之前。这意味着如果有人正在awaitUpdate(oldVersion, timeout)里等待,它会被唤醒,但此时cluster可能还没来得及替换。不过因为整个方法是synchronized的,唤醒的线程必须等当前线程释放锁之后才能继续,而那时cluster已经赋值完毕,所以实际上是安全的。这种“靠锁顺序保证可见性”的写法在 Kafka 客户端里很常见,读代码时要留意。
第二,response.cluster()可能抛异常。当响应里出现客户端无法解析的结构时,Kafka 选择丢弃整次响应,保留旧元数据。这个取舍很关键:宁可继续用旧地图,也不要换上一张残缺的地图。但副作用是,如果服务端持续返回同一个非法响应,客户端会一直卡在旧元数据上,只在日志里反复打印 warn。这是个需要人盯的信号。
第三,notifyAll()而不是notify()。因为可能有多个线程同时等待不同版本的更新(生产者的多个业务线程 + Sender 线程),必须全部唤醒让它们各自判断。用notify()会导致部分线程永远醒不过来,这是并发编程里的经典坑,Kafka 在这里没有踩。
2.4 Topic ID 的引入,改变了什么
比较新的版本里,MetadataResponse会带上每个 topic 的 ID(Uuid)。这个改动表面看只是加了个字段,实际解决了一个非常隐蔽的问题:主题被删除后重建,名字相同但不是同一个主题。
旧版本里客户端只认名字,一旦元数据响应里的名字匹配上,就认为这是自己关心的主题。如果这期间主题被删掉重建、分区数变了,客户端会在旧的分区信息上发请求,然后收到一堆莫名其妙的错误。引入 topic ID 之后,客户端可以把“我关心的是这个 ID 的主题”和“我关心的是叫这个名字的主题”区分开,从根上避免这类语义错位。
代价是客户端的状态管理更复杂了:缓存的键从字符串变成了 ID、名字和 ID 的映射需要维护、响应里带 ID 的版本和不带 ID 的版本需要兼容处理。这部分代码读起来不像前面那么清晰,但值得花时间,因为很多“元数据对了但就是对不上”的诡异问题都出在这里。
3. 一次 MetadataRequest 的完整往返链路
单独看请求和响应两个结构体,会觉得这次交互很简单。但真正出问题的地方,全在“中间那一跳”:什么时候发、发给谁、发出去之后怎么处理超时、响应回来之后怎么通知。
3.1 leastLoadedNode 与 in-flight 互斥
NetworkClient有一个leastLoadedNode(),按“当前排队请求数最少的节点”来挑一台发元数据请求。这个策略的好处显而易见,但它有个隐性前提:元数据请求必须能被任何一台 broker 处理。这也是为什么 Kafka 的元数据请求不要求连接 leader 或 controller,任何一台 broker 都能根据本地缓存的元数据返回结果。
这里有个关键约束:任何时刻只允许有一个元数据请求在途(in-flight)。DefaultMetadataUpdater里用一个metadataFetchInProgress标志控制,maybeUpdate()只要发现已经有请求在飞,就直接返回,不做任何事。
为什么必须互斥?因为响应是全量替换的。如果两个请求同时在飞,先发的请求返回得比后发的晚,那最终生效的就是较旧的那份元数据,客户端就会陷入“刷新一次回到旧版本”的循环。这类错误如果发生,日志上看不出任何异常,只能靠抓包才能发现,排查代价极高。所以互斥是刚性要求,不是性能优化。
顺带说,maybeUpdate()的返回值在不同版本形态不太一样,有的版本返回布尔值,有的返回一个枚举(表示“该发请求”“该等结果”“别动”)。读源码时不要被版本差异带偏,抓住核心逻辑就行:判断是否需要刷新,判断是否有权限刷新,判断是否已有请求在途,三个判断全部通过才真正发请求。
3.2 Broker 侧对请求的过滤与自动创建
请求到了 broker 之后,走KafkaApis.handleTopicMetadataRequest。这里面做了几件事,每一件都可能成为线上问题的来源。
第一层是权限过滤。请求里的主题如果客户端没有 DESCRIBE 权限,不会被拒绝,而是从响应里“消失”,同时出现在unauthorizedTopics列表里。这个设计的好处是不泄露主题是否存在,坏处是客户端拿到的结果里少了一块,如果客户端没检查unauthorizedTopics,表现就是“主题不存在”。我排查过一个“权限明明配了但还是说主题不存在”的案例,最后发现是 ACL 里的主机匹配写错了,客户端的报错信息把注意力全带偏了。
第二层是主题是否存在。如果主题不存在且auto.create.topics.enable为 true,broker 会触发一次异步创建,然后返回LEADER_NOT_AVAILABLE,客户端收到后按可重试错误处理,等下一轮刷新时就拿到真正的元数据了。这个流程之所以设计成异步,是因为建主题要在 controller 上做,broker 处理请求时不能阻塞等 controller。
第三层是分区可用性判断。这部分细节最多,留到第 4 章讲。
响应里除了分区信息,还有clusterAuthorizedOperations和topicAuthorizedOperations。这两个字段的取舍很有意思:想要集群级权限信息就需要额外的开销,所以请求里带个标志位,客户端只要自己需要的那部分,不要省成默认全给。
3.3 响应处理与 listener 回调的时序陷阱
响应回到客户端后,Metadata.update()完成状态替换,然后调用所有注册的Listener.onMetadataUpdate(cluster, unavailableTopics)。这个回调是同步调用的,也就是说在synchronized块内部执行的。
我第一次写自定义监听器时,在回调里做了个同步的远程调用,直接把客户端的消息发送线程卡住了好几秒。这件事的教训是:Listener里的代码必须短小、无阻塞,最好只是设置一个标志位或者往队列里丢一个事件。任何在回调里做的重活,都会直接转换成生产者的发送延迟。
还有一个更隐蔽的问题:回调的触发时机。notifyMetadataUpdate是在cluster替换之后、锁释放之前调用的,所以监听器看到的一定是最新元数据。但如果监听器里调用了metadata.fetch()之类的同步方法,就会因为锁重入(Java 的 synchronized 是可重入的)拿到当前状态,不会死锁,但可能拿到一个“响应已处理但 listener 还没跑完”的中间态。这种细节在正常路径上完全看不出来,只在极端并发下才会暴露。
在错误路径上,如果响应里带回的是可重试错误(比如LEADER_NOT_AVAILABLE),Metadata不会更新版本号,只把lastRefreshMs更新一下作为节流依据。这意味着awaitUpdate()的等待者会继续等下去,直到超时。超时后会抛出TimeoutException,生产者上层再转换成一次可重试的发送失败。整条链路的错误处理是自洽的,但每一层都有超时设置,叠加起来的最大等待时间可能远超你的预期,这也是为什么生产者端经常出现“明明配了 30 秒超时,实际却等了两分钟”的情况。
4. MetadataCache 与分区状态:为什么 ISR 空了就不返回
这一章是整篇里最“脏”的部分,也是线上问题最集中的地方。
4.1 PartitionMetadata 的三个 Option 字段
MetadataCache里每个分区对应一个PartitionMetadata,核心就三个字段:
case class PartitionMetadata(leaderAndIsrOpt: Option[LeaderAndIsr], replicasOpt: Option[Seq[Int]], offlineReplicasOpt: Option[Seq[Int]]) { def hasLeaderAndIsr: Boolean = leaderAndIsrOpt.isDefined def isr: Seq[Int] = leaderAndIsrOpt.map(_.isr).getOrElse(Seq.empty) }为什么是Option?因为一个分区的信息可能是分段到达的。控制面发过来的变更可能只带副本分配、不带 leader 信息;也可能只更新 leader、不动副本列表。用Option表达“这个字段本次没有更新”,比用一个哨兵值(比如 -1)要安全得多,因为哨兵值会污染后续判断。
但这也带来一个副作用:你不能假设“有 replicas 就一定有 leader”。在元数据变更的中间态里,完全可能出现replicasOpt.isDefined而leaderAndIsrOpt.isEmpty的情况。这时分区对外表现就是不可用。
4.2 Current / Future 副本与可用性的双重判定
当一个分区的副本集合发生变化时(比如扩副本),新加的副本不会立刻进入 ISR,而是先标记为Future状态。只有 ISR 里所有副本都确认收到了新的元数据,Future 才会被提升为 Current。
对应的判断逻辑散落在PartitionMetadata、LeaderAndIsr和MetadataCache里。核心原则是:返回给客户端的分区信息,必须是“已经被 ISR 全体成员感知到”的版本。如果提前把新副本信息暴露出去,客户端可能会去连一个还不知道自己是副本的 broker,得到的会是错误。这个“宁可晚一点、不可早一点”的设计,是整个元数据传播机制的基调。
由此衍生出一个现象:集群做副本重分配时,MetadataCache里会出现一个分区同时有 Current 和 Future 两套副本视图,broker 会根据对端 broker 的版本号来决定返回哪一套。这个机制在源码里叫partitionMetadata的版本比对,逻辑不长但是分支很多。
4.3 ISR 为空时的处理:这个例子太典型了
MetadataCache.getTopicMetadata里有一段判断,我建议每个做 Kafka 运维的人都读一遍(大意):
如果 leaderAndIsr 缺失 或 isr 为空: 返回 LEADER_NOT_AVAILABLE 错误,而不是返回空的分区信息 否则: 正常构造 PartitionInfo,leader 和 isr 都填上为什么 ISR 空了要报错而不是“返回一个没有 leader 的分区”?因为这两者对客户端的行为影响是完全不同的:
- 返回
LEADER_NOT_AVAILABLE:客户端视作可重试错误,进入退避重试,等待下一次元数据刷新。整个流程是“等一下再说”。 - 返回一个 leader 为空的 PartitionInfo:客户端可能会尝试连接一个不存在的节点,或者抛出一个更难理解的异常。
这个选择背后是“错误要尽早、要明确”的原则。类似的判断在getTopicMetadata里还有好几处,比如对离线副本的处理、对不存在主题的处理。每一次都是同样的取舍:让客户端拿到一个能指导它下一步行为的错误码,而不是一个模棱两可的数据结构。
4.4 传播延迟两种模式的本质区别
这是我认为最值得深挖的一点,因为它直接决定了很多运维经验的适用性。
ZooKeeper 模式下,元数据传播是控制面主动推送。controller 处理完变更,遍历所有 broker,逐个发UpdateMetadataRequest。这意味着:
- 传播是串行的,broker 多的时候耗时会线性增长;
- 某台 broker 卡住或网络抖动,会拖慢整轮推送;
- 推送失败的 broker 会在下一轮被重试,但两轮之间有个空档期。
KRaft 模式下,元数据传播是各节点主动从日志追。controller 只负责往日志里写一条 record,broker 自己维护读取位置,读到就应用。这意味着:
- 没有“一轮推送”的概念,各节点进度天然可以不同;
- 一台慢节点不会拖累其他节点;
- 追上进度的时间取决于 broker 自身的处理能力和日志积压量。
对应的运维经验差别很大。ZK 模式下的经典问题是“controller 推送队列积压”,表现为某些 broker 的元数据版本明显落后;KRaft 模式下的经典问题是“某个 broker 的元数据加载卡住”,表现为该 broker 的读取位置长时间不动。两种现象看起来相似(都是元数据不同步),但排查方向完全不同:前者看 controller 的请求队列和网络,后者看 broker 的日志加载线程和磁盘。
5. KRaft 下的元数据:从 record 到 image 的演进
如果只读 ZK 时代的源码,你会觉得元数据就是“一堆 map 加上一个推送通道”。KRaft 把这件事彻底改了结构,值得单独拿一章来讲。
5.1 元数据日志里存了哪些 record
KRaft 的元数据日志是只追加的,每一条记录是一个“事件”而不是“状态”。这样设计的好处是回放简单、审计天然,坏处是回放整条日志才能知道当前状态——所以要配合快照。
主要 record 类型(按我的记忆整理,具体字段名各版本有微调):
| Record 类型 | 语义 | 关键字段 |
|---|---|---|
TopicRecord | 主题创建 | topicId、name |
PartitionRecord | 分区创建 | topicId、partitionId、replicas、isr、leader、epoch |
PartitionChangeRecord | 分区增量变更 | 各字段可空,只更新非空部分 |
ConfigRecord | 配置项变更 | 资源类型、资源名、键、值 |
BrokerRegistrationChangeRecord | broker 注册/隔离 | brokerId、brokerEpoch、fenced |
RemoveTopicRecord | 主题删除 | topicId |
IsrChangeRecord | ISR 变更 | topicId、partitionId、isr |
FeatureLevelRecord | 特性版本 | 名称、级别 |
ClientQuotaRecord | 配额变更 | 实体、键、值 |
AccessControlEntryRecord | ACL 变更 | 各类匹配条件 |
PartitionChangeRecord的设计尤其值得注意:它的所有字段都是可空的,null 表示“此项不变”。这是个非常典型的“补丁式”事件建模,好处是每次变更只需要记录真正变化的那几项(比如 leader 换了一台,就不用把 replicas 也再写一遍),日志体积小得多。坏处是应用逻辑变复杂了:每个字段都要做一次判空,漏掉一个就会导致状态更新不全。
PartitionRecord和PartitionChangeRecord的分工也很讲究:前者用于“从无到有”,后者用于“从有到变”。这种区分在很多事件溯源系统里都会用到,核心考虑是让“创建”和“修改”在语义上可区分,避免把一次创建当成一次修改来应用(会导致某些默认值丢失)。
5.2 MetadataDelta 到 MetadataImage 的不可变演进
日志里的 record 不能直接拿给 broker 用,中间要经过两层结构:
- MetadataDelta:可变,表示“这一批 record 带来的增量”。每个 record 应用到对应的 delta 上(
TopicsDelta、ClusterDelta、ConfigurationsDelta等)。 - MetadataImage:不可变,表示“应用完这批 record 之后的完整状态”。由上一版 image 加上 delta 生成。
这个模式的好处是读取方永远看到一个稳定快照。broker 在处理请求时拿着一个MetadataImage引用,整个处理过程中它都不会变;期间如果又来了新的一批 record,会生成一个新的 image 替换引用,老的 image 自然被回收。这就避免了在 ZK 模式下必须用锁保护MetadataCache的问题——读路径彻底无锁了。
不可变的代价是内存复制。每次有变更都要构造新的 image 对象,即使只有一个小字段变了。Kafka 的做法是用共享结构(大量字段直接引用老 image 中的对象,只新建真正变化的部分),实际内存开销远小于“全量复制”的朴素实现。读这部分代码时,你会看到大量new TopicsImage(oldImage.topics(), newTopicsById)这样的写法,就是这个思路。
MetadataDelta和MetadataImage之间的转换时机很关键:一批 record 全部应用完成才生成新 image,中间过程对其他线程不可见。这个“批”的粒度就是 Raft 提交的一批 record,通常对应一次 controller 操作。
5.3 MetadataLoader 与快照:追平进度的两条路径
broker 启动时如果从零开始回放整条元数据日志,在日志已经积累了几十万条 record 的集群上可能要花很久。所以有快照机制:日志定期被截断到某个位置,该位置之前的状态被写进一个.checkpoint文件。
broker 的上电流程因此分成两条路径:
- 找到最新的快照文件,读进内存,得到一个
MetadataImage; - 从快照对应的日志位置开始,顺序读取并应用后续 record。
MetadataLoader负责这条流程,它对外暴露的是“加载到某个位置”,内部则把快照读取和日志消费串起来。这里有个很容易被忽略的细节:快照文件的格式不是通用的(不是 JSON,也不是 Avro),而是 Kafka 自己的 record 二进制格式。这意味着你没法用现成工具直接打开看内容,排查问题时要靠专门的kafka-metadata-shell之类的工具。
快照的生成时机也是有讲究的。生成太频繁会占用磁盘 IO,生成太少则恢复时间过长。默认策略是基于日志条数和新记录写入速度综合决定的,具体阈值在不同版本里调过几次。如果你在运维一个元数据变更非常频繁的集群(比如大量动态建表),值得关注一下快照目录的增长速度和生成频率,否则某一天会发现 broker 重启时间长到无法接受。
还有一点:快照文件里只包含“状态”,不包含“事件”。所以审计场景(“是谁在什么时候删了这个主题”)不能只依赖快照,必须回放日志。这两者的定位是互补的,不是替代关系。
6. 排查元数据问题的实操清单
前面讲的都是机制,这一章讲怎么用。我把自己反复用到的几条整理成了一个清单,遇到元数据类问题基本可以顺着走一遍。
6.1 异常码到元数据动作的映射
客户端看到的错误码,往往能直接告诉你元数据处在什么状态。这张表是我最常用的:
| 客户端异常 | 说明 | 客户端会做什么 |
|---|---|---|
UNKNOWN_TOPIC_OR_PARTITION | broker 侧没有这个主题或分区 | 标记需要刷新元数据 |
LEADER_NOT_AVAILABLE | 有分区但 leader 未选定或 ISR 为空 | 退避后重试,等待刷新 |
NOT_LEADER_OR_FOLLOWER | 客户端认为的 leader 已不是当前 leader | 刷新元数据后重试 |
REPLICA_NOT_AVAILABLE | 副本不在线 | 刷新元数据后重试 |
TOPIC_AUTHORIZATION_FAILED | 权限不足 | 不刷新,直接失败 |
注意到规律没有:凡是“集群状态变了”类的错误,客户端都会触发元数据刷新;凡是“权限/参数不对”类的错误,都不会刷新,因为刷了也没用。这个区分很重要,它解释了为什么有些错误重试一万次也没用——它们本来就不在“靠刷新能解决”的范围内。
实践中有个常见误判:看到UNKNOWN_TOPIC_OR_PARTITION就认为是主题不存在,然后去查建主题的日志。实际上这个错误码也可能来自权限不足(当响应里少了主题,而客户端没有检查unauthorizedTopics字段时),也可能来自元数据还没传播到那台 broker。正确的做法是先确认主题在控制面确实存在,再确认所有 broker 的元数据版本是否一致,最后才去查权限。
6.2 值得打开的几处日志
org.apache.kafka.clients.Metadata的日志级别默认是 INFO,只打印成功刷新的摘要。遇到元数据问题时,我会临时把它调到 DEBUG,能看到每次刷新的触发原因、耗时、响应里的主题数量、被丢弃的响应等。这个信息量比看业务日志大得多。
broker 侧的元数据变更日志更密集,一般不需要开 DEBUG。但如果怀疑是元数据传播问题,可以观察 controller 侧有没有反复出现的“发送更新元数据请求超时”记录。在 KRaft 模式下,则应该关注 broker 的元数据读取位置是否停滞——同一个位置持续几分钟不动,基本可以确定是这个 broker 卡住了。
还有一个非常实用但少人用的手段:直接在客户端打印当前的Cluster对象。Cluster.toString()会输出节点列表、分区分布、可用分区等信息,把它和从控制台查到的实际状态对比,一眼就能看出客户端落后了多少。我经常在排查“客户端行为异常”时先做这一步,往往几秒钟就能定位方向。
6.3 我自己踩过的两个坑
第一个坑:把metadata.max.age.ms设得很小(比如 10 秒),以为这样能让元数据更“新鲜”。结果是一个两百个客户端的集群,元数据请求量直接翻了 30 倍,broker 的请求处理线程被元数据请求占满,业务请求延迟飙升。这个配置的含义是“最长容忍多久不刷新”,不是“刷新得越快越好”。真正需要快速感知变更的场景,应该靠错误驱动的刷新,而不是靠调小这个值。
第二个坑:在自定义的Listener里做主题级别的缓存清理,清理逻辑里又调用了metadata.fetch()。因为回调在持有锁的情况下执行,fetch()又需要同一把锁,虽然可重入不至于死锁,但整个清理过程被串行化了,在主题数量多的时候表现为发送线程周期性的长时间停顿。后来改成回调里只往一个并发队列里丢主题名,由单独的线程做清理,问题就消失了。
6.4 参数调整的建议配置
最后给一份我实际用下来比较稳的配置思路,不是具体数值,因为具体数值要看集群规模:
metadata.max.age.ms:保持默认的 5 分钟,除非你有明确证据表明是元数据过期导致的问题。大规模集群里调小它的代价远大于收益。retry.backoff.ms:和refreshBackoffMs联动看。如果发现重试风暴,优先调大这个而不是调大元数据刷新间隔。- 消费者侧的
metadata.max.idle.ms:这个和上面的metadata.max.age.ms不是一回事,它控制的是“多久没用的主题元数据要被驱逐出缓存”。在订阅了大量主题但实际只消费少数的场景里,适当调小能显著降低内存占用。 - 生产者侧的定期刷新相关配置:如果业务能接受较慢的元数据感知,可以适当调大,减少请求量;反之保持默认。
我在实际项目中更倾向于先观察再调整:先把元数据相关的日志打开观察一两天,看清楚刷新频率和驱动原因,再决定动哪个参数。直接照着别人的“最佳实践”抄配置,往往会在自己集群的特定负载下出问题。
还有一个小技巧:如果你怀疑是元数据传播延迟导致的问题,可以在客户端连续打几次partitionsFor()并打印时间戳,观察结果是否在几秒内从“部分不可用”变成“全部可用”。如果是,那基本可以确认是传播延迟而不是配置错误,处理方向应该是优化控制面或等待,而不是去改客户端的重试参数。这个判断方法比翻日志快得多,我已经用了很多次。