元数据这块代码,我第一遍读 Kafka 源码时是直接跳过的。当时觉得它无非就是几个 JSON 和几个 ZNode,不如副本同步、日志存储那些逻辑带劲。后来线上遇到一次建 Topic 卡住、Controller 反复切换的事故,我才回过头把这条线从头到尾捋了一遍,才发现元数据几乎是所有诡异问题的源头:分区状态不对、ISR 收缩异常、客户端拿到过期的 Leader、请求被反复重定向,最后都能追到某一次元数据变更上。
这篇就聊 Kafka 的元数据,重点放在源码层面它是怎么被组织、存储、同步和下发的。内容会覆盖 ZooKeeper 时代的节点布局、KRaft 之后的日志化存储、Controller 的写入路径、Broker 与客户端各自的缓存,以及分区状态机这块最容易踩坑的地方。不管你是刚准备读 Kafka 源码,还是已经在维护集群、想搞清楚“为什么元数据和实际对不上”,下面这些内容都能直接拿去对照代码和线上现象看。
1. 先把“元数据”这个词在 Kafka 里说清楚
1.1 元数据不是一种数据,而是四层数据的统称
刚接触的人容易把元数据理解成“Topic 有多少分区”这一件事。真到源码里你会发现,Kafka 说的元数据至少包含四层,而且这四层分别由不同的类维护、走不同的同步链路。
第一层是集群拓扑:有哪些 Broker、每个 Broker 的 host/port、机架信息、监听器协议、Broker 的 epoch 或注册版本。第二层是主题结构:有哪些 Topic、每个 Topic 有多少分区、每个分区的副本分配在哪些 Broker 上。第三层是分区运行态:谁是 Leader、谁是 Follower、ISR 列表是什么、Leader epoch 是多少、有没有正在迁移的副本。第四层是配置与配额:Topic 级别的配置覆盖、Broker 级别的动态配置、客户端配额、特性开关(Feature Level)。
这四层的生命周期完全不同。集群拓扑跟着 Broker 上下线变,主题结构跟着管理操作变,分区运行态在故障时每秒都在变,配置则相对静态。源码里之所以把它们拆成ClusterImage、TopicsImage、ConfigImage这些独立的 Image 对象,就是因为它们的变更频率和一致性要求不一样,混在一起做快照代价太大。
读源码时的第一个建议:先别急着看 Controller 怎么处理请求,先把这几层数据分别对应到哪个类、存在哪个节点或哪条日志记录上,画一张自己的对照表。这张表建起来,后面看任何一条链路都不会迷路。
1.2 集群元数据和客户端元数据的边界
服务端的元数据和客户端的元数据是两套东西,名字都叫 Metadata,但职责差别很大。服务端那套是“真相”,由 Controller 单点写入、由 Broker 复制或监听;客户端那套是“缓存副本”,由 Producer/Consumer 自己按需拉取、按时间过期。
这个边界不清楚,排查问题时特别容易走偏。比如客户端报UNKNOWN_TOPIC_OR_PARTITION,有人第一反应是去集群上看 Topic 在不在。Topic 明明在,问题其实出在客户端缓存的元数据还停留在 Topic 创建之前,或者客户端连到了一个元数据还没同步过来的 Broker 上。反过来,服务端报NotControllerException,那是 Controller 切换后 Broker 侧的缓存还没更新,跟客户端一点关系没有。
我一般用一句话区分:能改变集群状态的操作走服务端元数据,只是找路用的信息走客户端元数据。Producer 要发消息,得知道分区 Leader 是谁,这是找路;Consumer 要提交位点,得知道__consumer_offsets的分区分布,这也是找路。这些都不改变集群状态,全部由客户端自己维护。
1.3 为什么读源码要先抓元数据这条线
Kafka 的请求处理链路很长,从 Socket 收到字节到最终写日志,中间要过网络层、请求队列、KafkaApis、副本管理器、日志管理器。如果一上来就扎进KafkaApis.handleProduceRequest,很容易被一堆参数淹没,因为里面大量的分支判断都依赖元数据:这个分区存不存在、当前 Broker 是不是 Leader、请求带的 Leader epoch 是不是过期了。
把元数据这条线先摸清楚,再回头看这些分支,逻辑会顺很多。你会明白handleProduceRequest里为什么要先查metadataCache.getPartitionInfo,为什么要比较partition.leaderEpoch,为什么FENCED_LEADER_EPOCH和UNKNOWN_LEADER_EPOCH要分开返回。这些判断本质上都是在拿请求里的“客户端认知”去和服务端的“当前真相”做比对,不一致就拒绝,让客户端重新拉元数据。
我个人的读法是:先看数据结构(有哪些字段),再看写入路径(谁改的、怎么改的),最后看读取路径(谁在用、缓存多久)。顺序反过来会很痛苦,因为你不知道某个字段是什么时候被填进去的。
2. ZooKeeper 时代的元数据长什么样
2.1 ZooKeeper 上的节点布局与 JSON 结构
老版本 Kafka 把 ZooKeeper 当成唯一的事实来源,所有元数据都以 ZNode 加 JSON 的形式存着。这套设计在今天看有点粗糙,但胜在直观,用zkCli.sh一连就能把整个集群看个大概。
核心路径有这么几个:
| ZNode 路径 | 存放内容 | 节点类型 |
|---|---|---|
/brokers/ids/{id} | Broker 注册信息 | 临时节点 |
/brokers/topics/{topic} | Topic 的分区与副本分配 | 持久节点 |
/brokers/topics/{topic}/partitions/{p}/state | 分区 Leader、ISR、epoch | 持久节点 |
/controller | 当前 Controller 的 Broker id | 临时节点 |
/controller_epoch | Controller 轮次,单调递增 | 持久节点 |
/admin/delete_topics/{topic} | 待删除 Topic 标记 | 持久节点 |
/admin/reassign_partitions | 副本重分配计划 | 持久节点 |
/config/topics/{topic} | Topic 配置覆盖 | 持久节点 |
/config/changes | 配置变更通知队列 | 持久顺序节点 |
/isr_change_notification | ISR 变更通知 | 持久顺序节点 |
Broker 注册节点里存的 JSON 大致长这样:
{ "listener_security_protocol_map": {"PLAINTEXT": "PLAINTEXT"}, "endpoints": ["PLAINTEXT://host-a:9092"], "jmx_port": -1, "host": "host-a", "timestamp": "1690000000000", "port": 9092, "version": 5 }这个version字段是节点格式版本,不是 Broker 版本,源码里对应BrokerRegistration的序列化逻辑。早期只有 host/port,后来为了支持多监听器才加了endpoints和协议映射。看到这里你就明白,为什么升级 Kafka 版本时如果降级,老代码可能读不懂新格式的节点——版本号就是为了这个兼容性设计的。
2.2 Topic 节点里到底存了什么
/brokers/topics/{topic}这个节点是最容易看错的地方。很多人以为它存的是分区列表,其实它存的是副本分配方案,也就是“每个分区的 AR(Assigned Replicas)是谁”,顺序还有讲究:第一个副本是 Preferred Leader。
它的 JSON 结构随着版本演进了几轮:
{ "version": 2, "partitions": {"0": [1001, 1002, 1003], "1": [1002, 1003, 1001]}, "adding_replicas": {}, "removing_replicas": {} }version: 1的时候只有partitions字段;到了version: 2加了adding_replicas和removing_replicas,用来支持分区副本重分配过程中的中间状态。这个改动很关键,因为副本重分配不是原子的,得让集群知道“哪些副本正在加进来、哪些正在挪走”,否则重分配中途 Broker 挂掉就恢复不了了。
源码里对应的类是ZkData和TopicZNode,反序列化时会校验版本号,版本过高直接抛异常。这个设计思路在 Kafka 里很常见:用版本号做格式兼容,读到不认识的高版本就拒绝,而不是猜着解析。猜着解析在分布式系统里是灾难,宁可失败也不能产生歧义。
2.3 监听机制与全量拉取的代价
ZK 时代元数据同步靠两样东西:Watcher 和全量拉取。Broker 启动后在关键路径上注册 Watcher,节点一变就触发回调,回调里通常是把整个子树重新读一遍。
这种模式的问题很直观。第一,Watcher 是一次性的,触发后必须重新注册,注册和读取之间有窗口期,可能漏掉中间的变更。第二,全量拉取开销大,一个几千分区的集群,/brokers/topics下面的数据量不小,每次变更都全量读一遍,ZK 的读压力和网络流量都很可观。第三,没有变更序号,Broker 没法判断自己拿到的数据是新是旧,只能靠时间戳和 Controller epoch 做粗略判断。
Kafka 后来打了几个补丁缓解这些问题,比如 ISR 变更不再靠 Topic 子树的全量监听,而是走/isr_change_notification这个专门的变更队列,Controller 往队列里写一条带序号的记录,Broker 消费后删除。但补丁终究是补丁,根本问题没解决:ZK 提供的是文件系统语义,不是日志语义,天生缺少“按序号增量同步”的能力。这也是后来 KRaft 要重做的直接原因。
一个实操细节:如果你还在维护 ZK 模式的集群,看
/brokers/topics下节点的mtime能大致判断元数据最近有没有被动过。但别用它做精确判断,ZK 的时间戳精度和时钟同步都不足以支撑这个用法。
3. KRaft:把元数据变成一条日志
3.1__cluster_metadata与控制器的角色
KRaft 的核心改动是把元数据从“一棵树”变成“一条日志”。所有变更以 Record 的形式追加到__cluster_metadata这个内部 Topic 上,Controller 负责写,Broker 负责读。这个 Topic 只有一个分区(保证全局顺序),副本数由 Controller 法定人数决定,通常配 3 个。
这个设计的好处是一下子解决了好几个老问题。第一,有了全局单调递增的 offset,每个 Broker 都能精确知道自己读到哪了,增量同步变成天然能力。第二,写入变成了追加,不用再操心 ZNode 的并发写冲突,因为只有 Leader Controller 能写。第三,状态可以从头重放,一个 Broker 的元数据坏了,把日志重放一遍就能恢复,不用去找快照或者人工修数据。
__cluster_metadata的物理存储和普通 Topic 没区别,就是一堆日志段文件。你可以用kafka-dump-log.sh加上--cluster-metadata-decoder参数直接把里面的 Record 解出来看。这个能力在排查问题时非常有用,相当于把集群的“变更历史”摊开在面前。
3.2 MetadataRecord 家族与 MetadataImage
日志里存的是各种MetadataRecord,每一种对应一类变更。常见的这么几种:
| Record 类型 | 作用 |
|---|---|
TopicRecord | 创建一个 Topic,记录名称和 Topic id |
PartitionRecord | 创建一个分区,记录所属 Topic、分区号、副本集合、目录 id |
PartitionChangeRecord | 修改分区状态,比如 ISR、Leader、Leader epoch |
ConfigRecord | 写入一条配置,可能是 Topic 级、Broker 级或客户端配额 |
BrokerRegistrationChangeRecord | Broker 注册或变更 |
FenceBrokerRecord/UnregisterBrokerRecord | Broker 隔离与注销 |
FeatureLevelRecord | 集群特性开关,比如是否启用某个新协议 |
RemoveTopicRecord | 删除 Topic |
这些 Record 一条条追加进去,回放之后形成一个内存中的不可变快照,就是MetadataImage。它下面挂着TopicsImage、ClusterImage、ConfigImage、FeaturesImage、ClientQuotasImage等一堆子 Image。每一个 Image 内部用的是TimelineHashMap,这个结构值得单独说一句:它保存了每个 key 在多个 offset 上的历史值,所以你拿着任意一个 offset 去查,都能拿到“那个时刻”的视图。
这个设计是为了解决读写并发。Controller 在 apply 新 Record 的时候,读请求可能正在处理,如果大家共享一个可变 Map,就得加锁;用了TimelineHashMap之后,读方拿着自己开始时的 offset 去查,天然看到一致的快照,完全不用锁。这是典型的用不可变数据结构换并发性能,代价是内存占用会随历史版本增长,所以需要定期做快照和清理。
3.3 从 Snapshot 加载到 Delta 回放
Broker 或 Controller 启动时不会把整个日志从头放一遍,那样太慢。流程是:先找最新的 Snapshot 文件,把MetadataImage一次性加载进来,然后从 Snapshot 记录的 offset 开始,往后回放 Delta 记录。
Snapshot 文件放在__cluster_metadata分区目录下,文件名带 offset 后缀,比如00000000000000123456.checkpoint。里面的格式和日志段类似,也是一批 Record,只不过是被压缩过的“最终状态”。加载 Snapshot 的代码在SnapshotFileReader和MetadataDelta这一套里,逻辑不复杂但很关键:Snapshot 的 offset 必须和日志段严格对齐,差一条都会导致状态错乱。线上如果遇到元数据莫名其妙不一致,先检查 Snapshot 文件有没有损坏、有没有被手工动过。
回放 Delta 的过程就是不断调用MetadataDelta.apply,每应用一条 Record,MetadataImage就产生一个新版本。这个过程是单线程的,在QuorumController的事件循环里跑,所以不用担心顺序问题。
提醒一句:KRaft 的 Snapshot 不是随时都能删的。日志段清理有自己的一套规则,手工删文件几乎一定会把集群搞坏。真要清理,走
kafka-metadata-shell.sh或者等自动清理,别直接rm。
4. Controller:元数据的唯一写入口
4.1 ControllerContext 维护的是“视图”而非“真相”
ZK 时代有个ControllerContext,里面缓存了partitionLeadershipInfo、replicasOnBrokers、partitionReplicaAssignment这些 Map。很多人误以为它就是元数据的存储,其实不是,它只是 Controller 在内存里维护的一份工作视图,真相在 ZK 上。
这个区分很重要。ControllerContext的数据是从 ZK 读出来、在内存里加工过的,用于快速做调度决策:比如某个 Broker 下线了,Controller 要从这个视图里快速找出“哪些分区的副本在这个 Broker 上”,然后批量发起 Leader 切换。如果每次决策都去 ZK 查,性能根本扛不住。
代价是这份视图可能和 ZK 不一致。Controller 刚切换上来的时候,得先把 ZK 全量读一遍重建视图,这个过程中集群是拒绝管理操作的。如果重建过程中又发生了变更,还得处理增量。源码里KafkaController.onControllerFailover这个方法就是干这个的,代码很长,但核心就三步:读全量、注册监听、把待处理的管理请求重新捡起来。
4.2 一次建 Topic 请求的完整落盘路径
拿建 Topic 举例,走一遍完整路径,能把 Controller 的职责看清楚。
客户端发CreateTopicsRequest到任意 Broker,Broker 发现这是管理请求,转发给 Controller(内部走ControllerApis)。Controller 收到后先做校验:Topic 名合法吗、分区数合理吗、副本数够不够、有没有重名。校验通过后,它要生成副本分配方案——如果用户没指定,就按机架感知的算法自动分配,源码里在AdminUtils.assignReplicasToBrokers或 KRaft 下的ReplicationControlManager里。分配算法要考虑机架分散、副本均匀、尽量让 Preferred Leader 轮转。
分配方案定了之后开始写。ZK 模式下是往/brokers/topics/{topic}写一个节点,然后 Controller 自己在内存里更新ControllerContext,再触发状态机把分区推到 Online 状态。KRaft 模式下是生成一条TopicRecord加若干条PartitionRecord,打包成一个批次交给RaftClient提交,提交成功(多数派确认)之后 apply 到MetadataImage,再由 Controller 触发后续的状态流转。
这里有个关键差异:ZK 模式写完节点就返回成功,但分区可能还没真正上线;KRaft 模式要等 Record 提交并 apply 之后才算成功。所以 KRaft 下建 Topic 的延迟会高一点点,但返回的时候状态是确定的,不会出现“建完了但查不到”的中间态。
4.3 Controller epoch 与 fencing
controller_epoch是个单调递增的整数,每次 Controller 切换就加一。它的作用是防止旧 Controller 的残留请求搞乱集群。
设想一个场景:Controller A 因为网络分区以为自己还活着,实际上集群已经选了 Controller B。这时候 A 发出一条LeaderAndIsrRequest给某个 Broker,如果 Broker 不看 epoch 直接执行,就会把 B 刚设好的 Leader 又改掉,集群直接乱套。有了 epoch,Broker 会拒绝 epoch 小于自己记录的请求,A 的请求直接被丢弃。
源码里这个校验在KafkaApis.handleLeaderAndIsrRequest里,比较的是请求里的controllerEpoch和metadataCache里存的controllerEpoch。KRaft 下对应的是controllerEpoch和brokerEpoch的双重校验,逻辑更严格:不仅要比 Controller 的轮次,还要比 Broker 自己的注册轮次,防止一个已经被隔离的 Broker 收到旧请求后误操作。
这个机制是很多“诡异现象”的解释。比如你看到日志里刷
NotControllerException,说明某个 Broker 认为的 Controller 和实际的不一致,通常是元数据还没同步过来。别急着重启,先等几秒或者查一下 Controller 是不是刚切换过。
5. Broker 与客户端各自的元数据缓存
5.1 Broker 侧 MetadataCache 与 MetadataLoader
Broker 自己不产生元数据,它只是消费者。KRaft 下每个 Broker 都跑一个MetadataLoader,通过RaftClient从 Controller 拉日志,拉到之后 apply 到本地的MetadataImage,然后通过监听器通知MetadataCache更新。
MetadataCache是 Broker 对外提供元数据查询的统一入口,接口在kafka.server.MetadataCache,实现有ZkMetadataCache(ZK 模式)和KRaftMetadataCache(KRaft 模式)。它提供的方法很直白:getTopicMetadata、getPartitionMetadata、getAliveBrokers、isrFor。KafkaApis里几乎所有请求处理都要先问它一句。
这里的设计有个细节值得注意:MetadataCache对外返回的TopicMetadata和PartitionMetadata都是不可变对象,每次更新是整体替换而不是原地修改。这样读的一方拿到的永远是一个自洽的快照,不会读到“Leader 已经改了但 ISR 还没改”的中间状态。代价是每次更新要重建对象,对 GC 有一点压力,但换来的是无锁读,很划算。
ZK 模式下 Broker 还会维护一个ZkMetadataCache,它监听 ZK 的/brokers/ids和/brokers/topics,变化时更新缓存。这套逻辑比 KRaft 复杂得多,因为要处理各种监听丢失、重连、全量刷新的边界情况,源码里KafkaZkClient那一坨代码的复杂度主要就来自这里。
5.2 客户端 Metadata 的更新触发条件
客户端的元数据刷新策略是“按需加定时”,不是一直拉。触发条件主要有这么几个:
- 第一次发消息或拉消息,本地没有对应 Topic 的元数据,主动拉一次。
- 缓存的元数据超过
metadata.max.age.ms(默认 5 分钟),后台线程定期刷新。 - 收到特定错误码,比如
LEADER_NOT_AVAILABLE、NOT_LEADER_OR_FOLLOWER、UNKNOWN_TOPIC_OR_PARTITION,立刻标记该 Topic 需要刷新。 - Producer 端还有个
topic.metadata.refresh.interval.ms,空闲时按这个间隔刷新;长时间不发消息则按metadata.max.idle.ms让元数据过期。
源码里对应org.apache.kafka.clients.Metadata这个类,内部有个MetadataCache(注意和服务端同名但完全不是一个东西),维护Map<String, Long> topics记录每个 Topic 的过期时间,还有一个Cluster对象保存当前视图。Metadata.update方法负责合并新拿到的MetadataResponse,返回一个布尔值表示视图有没有实质变化。这个返回值很关键,Producer 的 Sender 线程靠它判断要不要重新计算分区可用性。
5.3 元数据版本与请求路由的关系
客户端拿到的MetadataResponse里带着每个分区的leaderEpoch和partitionEpoch。发请求的时候会把这些 epoch 带上,服务端据此判断客户端的认知是否过期。
这个机制叫epoch fencing,解决的问题是“客户端拿着旧元数据发请求,恰好这个分区刚切换过 Leader”。如果没有 epoch,旧 Leader 可能已经变成了 Follower,但它不知道自己已经不是 Leader 了(比如因为网络分区),照样接收写入,就会产生数据不一致。有了 epoch,新 Leader 会把 epoch 加一,旧 Leader 收到 epoch 更高的请求时会主动降级,客户端带着旧 epoch 来的请求也会被拒绝并返回FENCED_LEADER_EPOCH,触发重新拉元数据。
这套逻辑在Partition类和ReplicaManager.appendRecords里实现,核心是maybeFenceReplicas和LeaderEpochFileCache。读源码时建议重点关注LeaderEpochFileCache,它维护了每个分区在本地磁盘上的 epoch 历史,是判断日志截断点的依据。
6. 分区状态机:元数据里最容易出事的一块
6.1 PartitionStateMachine 的状态流转
分区在 Controller 眼里有四个状态:NonExistentPartition、NewPartition、OnlinePartition、OfflinePartition。状态之间的流转由PartitionStateMachine控制,触发源有三类:Controller 切换、Broker 上下线、管理操作(比如副本重分配)。
NewPartition表示分区已创建但还没有 Leader,OnlinePartition表示有可用的 Leader 和 ISR。从NewPartition到OnlinePartition的转换要做的事是:从 AR 里选一个副本当 Leader,把 ISR 初始化为 AR(或者按min.insync.replicas过滤掉不可用的),然后把状态写下去,再给相关 Broker 发LeaderAndIsrRequest。
这块代码在PartitionStateMachine.handleStateChanges里,方法不算长,但分支很多。我建议看的时候把doHandleStateChanges单独拎出来读,它按目标状态分了几个 case,每个 case 里的逻辑相对独立。
一个常见的误解是“分区创建后 ISR 就等于 AR”。实际上如果创建时某些 Broker 不可用,ISR 会小于 AR,这时候如果min.insync.replicas配得比 ISR 大,分区虽然是 Online 的,但写入会直接失败。这个现象在生产上很常见,尤其是新扩容的 Broker 还没追上数据的时候。
6.2 ISR 收缩与扩张的判定逻辑
ISR 的维护在 Follower 和 Leader 两边都有逻辑。Follower 定期发FetchRequest给 Leader,Leader 根据 follower 的fetchOffset和上次成功 fetch 的时间判断它是否还“跟得上”。
判定参数是replica.lag.time.max.ms(默认 30 秒)。如果某个 Follower 超过这个时间没有发起有效的 fetch,或者它的 fetch offset 一直落后于 Leader 的 LEO 超过阈值,Leader 就会把它踢出 ISR,然后通过PartitionChangeRecord(ZK 时代是写/isr_change_notification)通知 Controller,Controller 更新元数据再下发。
扩张的逻辑反过来:Follower 追上 Leader 的 LEO 之后,Leader 会把它加回 ISR。这里有个容易忽略的细节——扩张不是实时的,要等下一次 ISR 变更检查周期,默认由replica.lag.time.max.ms相关的定时任务驱动。所以你会看到 Follower 明明已经追上了,ISR 里还是没有它,等几秒就好了。
实操心得:调小
replica.lag.time.max.ms能让 ISR 更快反映真实状态,但也更容易因为偶发的网络抖动把健康的 Follower 踢出去,导致 ISR 频繁抖动、写入可用性下降。这个参数不要轻易动,真要提高可用性,优先加副本数而不是调这个值。
6.3 LeaderAndIsr 与元数据下发
LeaderAndIsrRequest是 Controller 把分区状态推给 Broker 的主要手段。请求体里每个分区带这些字段:Topic、分区号、Controller epoch、Leader、Leader epoch、ISR、AR、正在重分配的副本。
Broker 收到后交给ReplicaManager.becomeLeaderOrFollower处理。如果自己是 Leader,就启动 Leader 逻辑,初始化LeaderEpochFileCache,开始接受 Produce;如果自己是 Follower,就启动 Follower 逻辑,开始向 Leader 发 Fetch。如果自己既不在 ISR 也不在 AR 里,就把本地副本标记为需要删除。
这个方法体量很大,是 Kafka 里最复杂的几个方法之一。读的时候建议分三段看:第一段做参数校验和 epoch 检查,第二段按分区类型分组(变成 Leader 的、变成 Follower 的、需要删除的),第三段分别处理并收集结果。别想着一次读完,会晕。
KRaft 下这个请求还在,但触发方式变了:不再由 Controller 主动推,而是 Broker 从MetadataLoader拿到变更后自己触发。也就是说,Controller 只负责写日志,Broker 自己决定什么时候应用。这个改动减少了 Controller 的推送压力,但也意味着元数据的生效有了一点延迟,取决于 Broker 拉取的及时性。
7. 排查与验证:把元数据“看见”
7.1 用 metadata shell 看控制器日志
KRaft 提供了一个很好用的工具kafka-metadata-shell.sh,直接连到一个 Snapshot 文件或者运行中的 Controller,进去之后可以像操作文件系统一样查看元数据。
kafka-metadata-shell.sh --snapshot /data/kraft/__cluster_metadata-0/00000000000000123456.checkpoint进去之后常用的命令是ls /topics、cat /topics/my-topic、ls /brokers。它展示的是 Snapshot 那个时刻的状态,很适合用来对比“我认为的状态”和“实际记录的状态”。
另一个利器是直接 dump 日志:
kafka-dump-log.sh --cluster-metadata-decoder \ --files /data/kraft/__cluster_metadata-0/00000000000000000000.log输出是每条 Record 的明文,包括 offset、类型和字段。排查“某个 Topic 到底什么时候被改过”这类问题,这个命令比翻应用日志靠谱得多。
7.2 典型不一致现象与定位路径
元数据不一致的表现有好几种,我按遇到频率排个序。
第一种是客户端反复报NOT_LEADER_OR_FOLLOWER。通常是客户端缓存的 Leader 信息过期,或者某个 Broker 的元数据同步滞后。定位方法是先在客户端开 debug 日志看它请求的是哪个 Broker,再去那个 Broker 上确认它认为的 Leader 是谁。两边对不上,就是同步问题。
第二种是管理操作卡住不返回。比如建 Topic 一直 pending,或者删 Topic 删不掉。ZK 模式下多半是/admin/delete_topics下的标记没被 Controller 消费,Controller 可能挂了或者在切换。KRaft 下要看 Controller 的RaftClient是否正常,日志里会有Metadata log相关的报错。
第三种是分区状态和实际不符。比如describe显示某个分区有 Leader,但 Produce 过去一直超时。这种情况先确认那个 Broker 是不是真的活着,再看它的MetadataCache里这个分区的leaderEpoch和实际的是不是一致。遇到过好几次是 Broker 的网络隔离恢复后,元数据没重新同步导致的。
7.3 常见问题速查表
| 现象 | 可能原因 | 快速验证 |
|---|---|---|
客户端报UNKNOWN_TOPIC_OR_PARTITION | 客户端缓存未刷新或 Topic 刚创建 | 看客户端日志的元数据刷新时间,用--describe确认 Topic 存在 |
报NOT_LEADER_OR_FOLLOWER | Leader 已切换,客户端元数据过期 | 对比客户端请求的 Broker 和实际 Leader |
报FENCED_LEADER_EPOCH | 请求带的 epoch 落后 | 检查是否有频繁的 Leader 切换 |
报NotControllerException | Broker 认为的 Controller 已失效 | 等元数据同步或确认 Controller 状态 |
| ISR 频繁抖动 | 网络抖动或replica.lag.time.max.ms过小 | 看 Broker 日志里 ISR 变更的频率和原因 |
| 建 Topic 卡住 | Controller 事件队列积压或 Raft 提交失败 | 看 Controller 日志的ControllerWriteEvent耗时 |
元数据与describe不一致 | Broker 侧MetadataCache滞后 | 对比不同 Broker 上同一分区的leaderEpoch |
8. 我踩过的几个坑
第一个坑是把controller_epoch和leader_epoch搞混。前者是 Controller 的轮次,后者是分区的 Leader 轮次,两者独立递增。有次排查请求被拒的问题,我盯着controller_epoch看了半天,实际上是leader_epoch对不上导致的。搞清楚这两个概念之后,看日志里的错误码就快多了。
第二个坑是以为 KRaft 下元数据是强一致的。实际上 Broker 侧读到的元数据是有延迟的,因为它要等MetadataLoader拉取并 apply。Controller 提交成功的瞬间,Broker 可能还没收到。所以做完管理操作立刻去查,偶尔会查到旧状态。这不是 bug,是设计上的取舍,等一两秒就好。如果业务对这一点敏感,管理操作之后可以主动触发一次元数据刷新,或者加重试。
第三个坑是手工动 Snapshot 文件。早期为了省磁盘,我删过几个看起来“很老”的 checkpoint 文件,结果 Controller 重启时找不到合适的 Snapshot,从很老的 offset 开始重放,启动时间从几秒变成了好几分钟。后来才知道 Snapshot 的保留策略是配合日志段清理的,删哪个、留哪个有严格规则,乱删的代价很大。
第四个坑跟 ISR 有关。有段时间集群的 ISR 老是少一个副本,查了半天网络和磁盘都没问题。最后发现是那个 Broker 上的num.replica.fetchers配得太小,Fetch 线程被其他分区的拉取占满了,导致这个分区的 fetch 间隔超过了replica.lag.time.max.ms。把 fetcher 数量调上去就正常了。这个问题的隐蔽之处在于,Broker 本身的负载看起来完全正常,只有细看 fetch 的延迟才能发现。
第五个坑是忽视了min.insync.replicas和 ISR 的关系。有个 Topic 配了 3 副本、min.insync.replicas=2,一次机房级故障后 ISR 只剩 1 个,Producer 用acks=all就一直报NOT_ENOUGH_REPLICAS。当时的直觉是“副本数够啊”,实际上副本数够不代表 ISR 够。这个参数约束的是 ISR 大小,不是 AR 大小,理解这一点能省很多排查时间。
这些坑说起来都不复杂,但每一个都是在线上真金白银换来的。元数据这块代码的特点是“逻辑不深但细节极多”,每一条规则背后都有具体的故障场景在支撑。读源码的时候多问一句“为什么要这么设计”,往往比记住代码本身更有价值。