☰
Kafka元数据缓存刷新与故障排查源码解析
2026/10/28 3:30:14 网站建设 项目流程

最近在翻 Kafka 的源码,发现很多人跟 producer、consumer 的主流程时很顺,一到元数据这块就卡住:查不到、刷新不了、动不动就抛UnknownTopicOrPartitionException。起因是之前线上出过一次批量告警,一批消费任务突然集体报NOT_LEADER_OR_FOLLOWER,重启就好了,但第二天又犯。当时把Metadata这个类从头到尾读了一遍,又顺着看了MetadataRequest的服务端处理链路,才把问题的根子摸清楚。这篇就把 Kafka 元数据这条线拆开讲:它存什么、存在哪、客户端怎么缓存、什么时候刷新、源码里几个关键类各自管哪摊事。不管你是刚接手 Kafka 集群运维,还是在写依赖 Kafka 的业务代码,看完至少能判断:这个报错到底是服务端元数据真的变了,还是客户端缓存的还是旧的那一份。

1. 从一个"找不到 Leader"的报错说起:Kafka 元数据到底缓存了什么

1.1 报错现场:客户端为什么"看不见"新 Leader

先说个最常见的场景。某个分区的 leader 从 broker 1 迁到了 broker 2,正常情况下几十毫秒到几秒内客户端就该感知到。但实际跑起来,你会看到一部分 producer 依然往 broker 1 发,然后收到NotLeaderOrFollowerException,重试几次之后才恢复正常。新手第一反应是"Kafka 出 bug 了",其实不是,这是元数据缓存的正常代价。

Kafka 客户端并不在每次发送前都去问服务端"这个分区的 leader 是谁",那样一次发送就得多两次网络往返,吞吐直接废掉。它的做法是:本地维护一份元数据快照,发送时直接查本地,只有在碰到特定错误、或者缓存过期时,才发起一次MetadataRequest去刷新。所以"客户端看到的世界"和"服务端真实的世界"之间,天然存在一个时间窗口。理解元数据,本质上就是理解这个窗口有多大、什么时候会被强制关上。

这里有个容易被忽略的点:不同客户端(Java、Go、librdkafka 系)的刷新策略不完全一样,但核心逻辑都是"错误驱动 + 定时兜底"这两条腿走路。你在 Java 客户端上调的各种参数,比如metadata.max.age.ms,影响的就是"定时兜底"这条腿的频率;而"错误驱动"这条腿是不可配置的,只要收到特定错误码,客户端就会立刻标记需要刷新。

1.2 元数据的三个层次:集群、主题分区、节点映射

把 Kafka 元数据按粒度拆开看,其实是三层,理解这三层对排查问题特别有用。

集群层:有哪些 broker 活着,每个 broker 的 id、host、port、机架信息,谁是 controller,集群 id 是什么。这一层变化频率最低,broker 上下线才会动。

主题分区层:有哪些 topic,每个 topic 有多少分区,每个分区的副本分布在哪些 broker 上,当前 leader 是谁,ISR 列表是什么,有没有 offline 副本。这一层是变化最频繁的,一次 leader 选举、一次副本同步滞后,都会引起变动。

节点映射层:从 broker id 到 Node 对象的映射,以及反过来从 Node 到它承载了哪些分区的映射。这一层是派生的,由前两层算出来,但在发送时用得最多——producer 拿到一条消息,算出目标分区后,需要快速找到该分区的 leader 对应的 Node,才能建立连接。

这三层在客户端Metadata类里对应不同的数据结构,在服务端则对应不同的存储位置。排查问题时先想清楚"是哪一层不对",能省掉一大半瞎猜的时间。比如你发现 producer 连的 broker 列表是老的,那问题在第一层;如果 broker 列表是新的但分区 leader 是老的,问题在第二层。

1.3 一次元数据请求的往返长什么样

客户端发起一次元数据交互,大致是这样一条链路:Sender线程在poll循环里调用metadataUpdater.maybeUpdate(),判断当前是否需要刷新;需要的话构造一个MetadataRequest,通过NetworkClient发给"当前认为的任意一个可用节点";服务端KafkaApis收到后从本地缓存里取出元数据组装成MetadataResponse返回;客户端handleCompletedMetadataResponse把响应解析出来,更新本地的 brokers、partitions 等容器,同时唤醒因为"元数据不可用"而阻塞的请求。

这里面有个细节值得注意:请求是发给"任意一个已知节点"的,不一定非得是 controller。因为每个 broker 本地都有一份完整的集群元数据缓存(老版本靠 controller 推送UpdateMetadataRequest,新版本靠从元数据日志拉取),所以问谁都一样。这个设计让元数据请求不会成为单点瓶颈,但也带来了一个新的问题:如果某个 broker 的元数据缓存落后了,客户端刚好问到它,就会拿到旧数据。实践中偶尔出现的"重启客户端就好、过一阵又坏"的怪现象,很多时候就是这个原因。

提示:元数据请求走的是"任意节点"策略,所以拿到旧数据的概率和集群里各 broker 元数据同步的及时性直接相关。如果集群规模大、controller 压力高,这个窗口会被拉长。

2. 服务端元数据的存储形态:从 ZK 节点到元数据日志

2.1 ZK 时代的节点布局:一棵树装下整个集群

老版本 Kafka 把元数据放在 ZooKeeper 里,结构是一棵树,几个关键路径必须记住,因为排查问题时经常要直接去看。

/brokers/ids/{brokerId}是临时节点,broker 启动时创建、会话断开时自动消失,里面存 host、port、机架、时间戳。这个节点的存在与否,就是 broker "活着"的定义。

/brokers/topics/{topicName}是持久节点,存的是分区和副本分配的映射关系,形如一个 JSON,里面 partitions 字段把每个分区号映射到一个副本 broker id 列表,第一个就是优先副本。

/brokers/topics/{topicName}/partitions/{partitionId}/state存的是这个分区的动态状态:当前 leader、ISR 列表、leader epoch、controller epoch。注意这个节点是会频繁变的,每次 leader 选举都会重写。

/controller是临时节点,谁抢到谁就是 controller,节点里存的是那个 broker 的 id。/controller_epoch是持久节点,存一个单调递增的整数,用来防止"脑裂"——旧 controller 以为自己还是 controller 时,它发出的请求会因为 epoch 太小被拒绝。

还有/config/topics/{topicName}、/admin/delete_topics/{topicName}、/admin/reassign_partitions、/isr_change_notification这些辅助节点,分别管配置覆盖、删除标记、副本重分配、ISR 变更通知。要理解 Kafka 元数据,把这几个路径背下来比看十篇博客都管用。

2.2 Controller 怎么把变更写进 ZK,再推给所有 Broker

ZK 只是存储,真正干活的是 controller。controller 启动时会往 ZK 上注册一大堆 watcher,监听 broker 上下线、topic 创建删除、分区状态变化、副本重分配等各种事件。任何一个事件触发,controller 就会走一遍自己的状态机。

以 leader 选举为例:controller 发现某个分区的 leader 挂了,它会从 ISR 里挑一个新的(默认策略是挑 ISR 里第一个活着的),算出新的 leader 和 ISR,先把新状态写到 ZK 的 state 节点,然后给受影响的 broker 发LeaderAndIsrRequest让它们切换角色,最后给集群里所有 broker 发UpdateMetadataRequest把新元数据推过去。

这里的关键是UpdateMetadataRequest——它是全量推送,里面包含所有 topic 的所有分区状态和所有 live broker 列表。集群大了之后,这个请求的体积会很可观,一次推送可能是几 MB。所以老版本 Kafka 在 broker 数量多、分区数量大的场景下,controller 很容易成为瓶颈,元数据推送把网络打满的情况并不罕见。这也是后来推动 KRaft 改造的直接动因之一。

注意:LeaderAndIsrRequest和UpdateMetadataRequest是两条不同的链路,前者只发给副本所在的 broker,后者发给所有 broker。收不到UpdateMetadataRequest的 broker,本地元数据就会落后,表现出来就是"客户端问到它时拿到旧 leader"。

2.3 KRaft 把这套东西搬进了一个内部 Topic

新版本用 KRaft 替代 ZK,元数据的存储形态发生了根本变化:不再是一棵 ZK 树,而是一个单分区的内部 topic,通常叫__cluster_metadata,副本数一般配 3。所有元数据变更都以"记录"的形式追加写进这个 topic 的日志里。

这些记录有不同的类型,对应不同的语义。比如TopicRecord表示创建了一个 topic,PartitionRecord表示一个分区的初始状态,PartitionChangeRecord表示分区状态发生了变化(leader 变了、ISR 变了),ConfigRecord表示配置变更,还有一组与 broker 上下线相关的记录类型,负责注册、隔离、移除 broker。每种记录都是不可变的,日志只追加不修改,当前状态靠"回放全部记录"算出来。

这个设计的好处很直接:一是元数据变更有了完整的顺序和持久化保证,回放日志就能重建任意时刻的集群状态;二是 broker 不用再被动等 controller 推送全量数据了,改成主动从日志拉取增量,网络开销小得多;三是 controller 不再是单点,而是成为一个 quorum,通过一致性协议选主,可用性大幅提升。

对使用者的影响是:排查元数据问题的工具变了。以前是zookeeper-shell去看节点,现在得用kafka-metadata-shell.sh去查询元数据日志,或者用kafka-dump-log.sh直接 dump__cluster_metadata的内容看记录序列。这两个工具值得花时间熟悉,尤其是当你想确认"某个 topic 的某个分区到底在服务端是什么状态"时,它们给出的答案是权威的。

3. 客户端源码主线:Metadata、MetadataCache 与刷新触发条件

3.1 Metadata 类里的几个关键容器,各管什么

打开 Java 客户端的Metadata类,会看到几个核心字段。brokers是一个Map<Integer, Node>,存 broker id 到节点信息的映射;partitionsByTopic是Map<String, List<PartitionInfo>>,存每个 topic 下的全部分区信息;availablePartitionsByTopic只存"有 leader 的"那些分区,这个区分很重要——写消息时如果目标分区没有 leader,应该走"分区不可用"的分支而不是"leader 未知"的分支;partitionsByNode是反查表,从 broker 到它承载的分区列表,主要在连接建立和断开时用来做关联处理。

还有一个cluster字段,是上面这些数据的一个不可变快照封装。每次元数据更新完,会重新构造一个Cluster对象替换掉旧的。这个设计的用意是让读取方拿到一个一致的视图,避免读到"broker 列表是新的、分区信息还是旧的"这种中间状态。

代码里所有对元数据的读取都走Cluster,所有对元数据的写入都在Metadata的更新方法里,读写分离得很清楚。如果你要自己写工具去解析客户端状态,认准Cluster这个入口就行,别去碰那些内部 Map。

另外值得提一句topicIds这个映射。较新版本引入了 topic ID 的概念,每个 topic 创建时会分配一个全局唯一的 ID,即使 topic 被删除重建、名字复用,ID 也不会重复。这对客户端意义很大:以前只靠名字判断,删除重建后客户端可能把新 topic 的元数据当成旧的,导致奇怪的错误;有了 ID 之后就能明确区分。

3.2 刷新到底由谁触发:错误驱动加定时兜底

刷新逻辑集中在MetadataUpdater里,具体是DefaultMetadataUpdater这个实现。触发刷新的路径主要有这么几条。

第一条是主动标记。Metadata.requestUpdate()会把内部一个needUpdate标志置为 true,下一次maybeUpdate检查时就会真正发出请求。生产者发送时发现目标分区的 leader 未知、消费者拉取时收到需要刷新元数据的错误码,都会调这个方法。

第二条是错误码驱动。客户端在handleCompletedReceive里会检查响应中的错误码,有几个错误码会直接触发元数据刷新:UNKNOWN_TOPIC_OR_PARTITION(topic 或分区不存在,可能是刚创建还没同步)、LEADER_NOT_AVAILABLE(分区正在选举,暂时没 leader)、NOT_LEADER_OR_FOLLOWER(发给了错的 broker)、REPLICA_NOT_AVAILABLE、KAFKA_STORAGE_ERROR,还有和 leader epoch 相关的FENCED_LEADER_EPOCH与UNKNOWN_LEADER_EPOCH。这里的设计意图很清楚:这些错误都意味着"我本地的元数据和实际情况对不上",唯一的解法就是重新拉一份。

第三条是定时兜底。maybeUpdate会检查距离上次成功刷新是否超过了metadata.max.age.ms(默认 300000,也就是 5 分钟),超了就强制刷一次。这条路径保证即使一直没出错,元数据也不会无限期地陈旧下去——比如某个分区的 leader 悄悄换了但客户端一直没往那个分区发消息,就不会触发错误,靠这条兜底才能纠正过来。

提示:metadata.max.age.ms调小能让客户端更快感知变化,但会增加元数据请求的频率。集群规模大的时候,几万个客户端同时每 30 秒刷一次元数据,对 broker 也是不小的压力。这个值不要拍脑袋改。

3.3 Leader Epoch 怎么防止你用到过期元数据

这是个很多人不知道但很有用的机制。光靠"leader id"判断是不够的:假设分区 leader 从 broker 1 迁到 broker 2,又迁回 broker 1,那么客户端缓存的"leader 是 broker 1"看起来依然正确,实际上中间已经发生过两次选举,broker 1 上的数据可能已经不是最新的了。

为了解决这个问题,引入了 leader epoch,一个单调递增的版本号,每次 leader 变更就加一。客户端缓存里存的是(leaderId, leaderEpoch)这个组合,发送请求时带上 epoch 一起发过去。broker 收到后会比较:如果请求里的 epoch 比我当前的小,说明客户端拿到的是过期信息,直接返回FENCED_LEADER_EPOCH;如果比我当前的大,说明客户端知道的我还不清楚,返回UNKNOWN_LEADER_EPOCH。这两种情况客户端都会触发元数据刷新,重新拿到正确的 epoch。

这套机制的价值在于把"元数据是否过期"从一个概率问题变成了确定问题。以前只能靠超时和重试硬扛,现在可以在协议层面明确拒绝过期请求,避免了数据一致性的隐患。看源码时留意PartitionInfo里的leaderEpoch字段,以及各类请求里携带的currentLeaderEpoch,就是这套机制的具体落地。

4. MetadataRequest 与 MetadataResponse 的处理链路

4.1 请求参数的三种语义,别搞混

MetadataRequest里最关键的字段是topics,它的取值有三种语义,源码里靠 null 和空列表区分,很容易看漏。

传null:表示请求所有topic 的元数据。客户端在完全不知道有哪些 topic 时(比如刚启动)会这么发。

传空列表:表示一个 topic 都不要,只要 broker 列表和集群信息。这个用法比较隐蔽,但在某些场景下有用——比如你只想知道集群里有哪些 broker 活着,不关心 topic。

传具体列表:只请求列表里的这些 topic。这是最常见的情况,producer 知道自己要发哪些 topic,就只请求这些,避免响应体过大。

除了topics,还有几个参数值得注意。allowAutoTopicCreation控制如果 topic 不存在,服务端要不要自动创建,默认是 true(老版本行为),很多团队会在 broker 端配置里关掉自动创建,避免拼错名字就悄悄建出一堆垃圾 topic。includeClusterAuthorizedOperations和includeTopicAuthorizedOperations用于请求返回授权操作列表,开了鉴权之后才会用到,会显著增加响应体积。

注意:把allowAutoTopicCreation保持默认 true 是个常见隐患。拼错一个 topic 名字,producer 一发送就触发自动创建,topic 建出来了但没人消费,白白占着分区资源。建议在 broker 端统一关掉,需要建 topic 走显式流程。

4.2 服务端 handleTopicMetadataRequest 做了哪些事

服务端入口在KafkaApis.handleTopicMetadataRequest。它拿到请求后,第一件事是判断topics是不是 null,是 null 就从元数据缓存里取全部 topic 列表,否则用请求里给的列表。

接下来会对每个 topic 做一轮校验:这个 topic 存在吗?如果不存在且允许自动创建,就走创建流程(这一步在 KRaft 和 ZK 下的实现路径不一样);如果不存在且不允许创建,就给这个 topic 返回UNKNOWN_TOPIC_OR_PARTITION错误码。注意这个错误是按 topic 粒度返回的,一次请求里可能有的 topic 成功、有的失败,客户端需要逐个处理。

然后从缓存里取出每个分区的状态:leader、leader epoch、副本列表、ISR、offline 副本。这里读的是 broker 本地的元数据缓存,不是去问 controller,所以响应速度很快。

最后组装MetadataResponse,把 brokers、cluster id、controller id、各 topic 的分区信息都塞进去返回。整个过程是只读的,不涉及任何锁竞争严重的操作,所以元数据请求虽然频繁但开销可控。

有一类特殊情况需要留意:如果 broker 自己正在关闭或者处于某种不可用状态,它可能会返回一个空的分区信息,客户端看到 leader 是 -1 就要走"分区不可用"的处理路径。这也是为什么Metadata里要区分partitionsByTopic和availablePartitionsByTopic。

4.3 响应体字段逐个拆解,以及客户端怎么更新

MetadataResponse的结构其实不复杂,但每个字段都有对应的用途,梳理一遍有助于理解客户端内部的数据流转。

顶层的brokers是一个数组,每个元素包含 node_id、host、port、rack。客户端拿这个重建brokers映射,同时会检查有没有新增或消失的 broker,触发相应的连接建立或关闭。

cluster_id是集群的唯一标识,controller_id是当前 controller 的 broker id。后者主要给管理工具用,普通收发消息用不上,但在排查"controller 是否频繁切换"时很有价值。

topics是个数组,每个元素包含 error_code、name、topic_id、is_internal,以及一个partitions数组。每个分区元素包含:error_code、partition_index、leader_id、leader_epoch、replica_nodes、isr_nodes、offline_replicas。客户端用这些信息重建partitionsByTopic,并从中筛出 leader_id >= 0 的分区放进availablePartitionsByTopic。

更新过程是构建一个新的Cluster对象,然后原子地替换掉旧引用。所有正在等待元数据的请求会被唤醒,重新尝试。这个"构建新对象再替换"的模式值得学习——比原地修改 Map 安全得多,读线程不会看到半成品状态。

我在读这段代码时踩过一个坑:误以为availablePartitionsByTopic里的分区一定可以发送消息。实际上它只保证"有 leader",不保证"ISR 足够"。如果min.insync.replicas配得比当前 ISR 大,acceptor 依然会拒绝写入并返回NOT_ENOUGH_REPLICAS。这两个概念要分清楚。

5. 元数据相关的常见故障与排查实录

5.1 典型症状对照表,先定位再动手

遇到元数据相关问题,先别急着重启,按症状对号入座能省很多时间。

症状大概率原因优先检查项
批量抛NOT_LEADER_OR_FOLLOWER,重启后短暂恢复客户端元数据缓存落后于服务端broker 端 topic 描述与客户端缓存对比,看 leader epoch
新建 topic 后立刻写入报UNKNOWN_TOPIC_OR_PARTITION元数据还没同步到目标 broker元数据日志回放进度、controller 到 broker 的同步延迟
部分 producer 连不上 broker,连接被拒客户端缓存的 broker 列表是老的该 broker 是否已下线但客户端未刷新
消费者反复 rebalance 且日志里有元数据刷新分区 leader 频繁切换controller 日志、broker 是否频繁上下线
元数据请求耗时陡增单次响应体过大(topic 数量多)是否请求了全部 topic、响应体积、网络带宽

这张表的用法是"先看症状落在哪一行,再按第三列去查"。不要跳过定位直接改参数,那样只会把问题挪到别处。

举个具体的例子。前面提到的那个线上问题,症状是第一行。按表去查,用kafka-topics.sh --describe看服务端的 leader 和 epoch,再对比客户端日志里打印的缓存信息,发现客户端的 epoch 比服务端小了 2。这就说明中间发生过两次选举但客户端没刷新。再往前追,发现是那台 broker 所在的机架网络抖动导致了几次瞬间的会话超时,触发了 leader 漂移。根因不在 Kafka 配置,在网络层。

5.2 排查顺序:从服务端权威状态往下捋

我的习惯是从服务端往外查,因为服务端的状态是权威的,客户端只是它的一个可能过期的副本。

第一步,确认服务端的真实状态。ZK 模式下直接看/brokers/topics/{topic}/partitions/{p}/state节点,KRaft 模式下用kafka-metadata-shell.sh查,或者用kafka-dump-log.shdump 元数据日志看最近的记录。这一步拿到的是 leader id、ISR、leader epoch 的准确值。

第二步,确认所有 broker 的缓存是否一致。如果集群里不同 broker 对同一个分区给出的 leader 不一样,说明元数据同步出了问题。ZK 模式下这通常是 controller 推送UpdateMetadataRequest失败或被延迟;KRaft 模式下则是某个 broker 从日志拉取的进度落后。KRaft 下可以看 broker 的元数据拉取相关指标,确认 lag 是否在增长。

第三步,才去看客户端。把客户端日志里的元数据刷新记录捞出来,看最后一次成功刷新是什么时候、拿到的 leader 和 epoch 是多少。如果发现客户端很久没刷新过,那问题就在客户端的刷新触发上,检查是不是metadata.max.age.ms配得过大,或者客户端线程被卡住了导致poll循环没跑起来。

第四步,如果以上都正常,考虑是不是请求路由的问题。客户端可能在向一个"自认为活着但实际已经隔离"的 broker 发请求,这种情况在网络分区场景下会出现。检查一下 broker 的存活判定和实际连通性是否一致。

提示:KRaft 模式下排查元数据问题,__cluster_metadata这个 topic 的日志是最有价值的信息源。养成 dump 一下看最近记录的习惯,比猜快得多。

5.3 实操心得:几个文档里不会写的经验

第一,别把metadata.max.age.ms当万能药。有人一遇到元数据问题就把它从 5 分钟调到 10 秒,短期看确实缓解了,但集群里客户端一多,元数据请求量会翻几十倍。我见过一个几千客户端的集群,调小之后 broker 的元数据请求 QPS 直接涨到让 GC 都变频繁了。正确的做法是先定位为什么刷新不及时,而不是无脑提高刷新频率。

第二,requestUpdate()不是免费的。它只是打了个标记,真正发请求要等下一次poll。如果业务代码在循环里频繁调用,标记会被反复设置但合并成一次请求,看起来没浪费;但如果poll的间隔本身很长(比如某些批处理场景),标记设了也得等很久才生效。这种场景下要考虑是不是 poll 循环的节奏有问题。

第三,topic 数量对元数据的影响是非线性的。几千个 topic、每个几十个分区,一次全量元数据响应能到几 MB。如果客户端每次都用topics = null请求全部,那这个开销很难避免。所以 producer 和 consumer 都应该明确指定自己关心的 topic 列表,不要图省事传 null。这一条在共享集群里尤其重要,一个不守规矩的客户端能拖慢所有人的元数据交互。

第四,leader epoch 相关的错误不要简单重试。FENCED_LEADER_EPOCH和UNKNOWN_LEADER_EPOCH这两个错误,正确做法是先刷新元数据再重试,直接用原参数重试大概率还是失败。有些重试框架不区分错误码,一律退避重试,在这两个错误上会浪费好几次往返。

第五,关注offline_replicas这个字段。很多人看元数据只看 leader 和 ISR,忽略了 offline 副本。这个字段列出的是"分配了副本但当前不可用"的 broker,它的存在意味着副本数不足。如果这个列表长期非空,一旦 leader 挂掉就可能出现分区不可用。定期检查这个字段,比等出问题再抢救划算得多。

第六,自己写工具时优先用 AdminClient。早期很多人直接调MetadataRequest去拿元数据,现在 AdminClient 提供了describeCluster、describeTopics等封装,处理了协议版本兼容和错误重试,没必要再造轮子。只有在需要看协议层细节(比如确认 leader epoch)时,才值得下沉到请求层。

最后分享一个排查小技巧:如果怀疑是元数据版本不一致导致的问题,可以在客户端和服务端同时打时间戳日志。服务端在处理MetadataRequest时记录当前各分区的 epoch,客户端在收到响应时记录解析出的 epoch,两边一对,什么时候开始分叉一目了然。这个方法比抓包分析协议内容省事得多,尤其是在协议版本混用、字段位置对不上的时候。

这套排查思路我用了两年多,从 ZK 集群迁到 KRaft 集群也没换过——底层的元数据模型没变,变的只是存储形态和分发方式,把"服务端权威状态 → 各 broker 缓存一致性 → 客户端缓存 → 请求路由"这条链走一遍,绝大多数元数据相关的怪问题都能落在某个具体环节上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询