《Java 消息中间件·Kafka 内核与客户端》5.1 KRaft:控制器与元数据日志

KRaft 把集群元数据本身写成一条 Raft 日志:topic 创建、broker 注册、leader 变更都是 __cluster_metadata 里的记录。本篇用 kafka-dump-log.sh 解出 4.3.0 单节点的真实记录序列,说明 controller quorum 如何选举、元数据如何提交,以及为什么 4.x 不再需要 ZooKeeper。

元数据放在哪里,决定了故障怎么恢复

一个 Kafka 集群要回答很多「谁在哪」的问题:哪些 broker 活着、每个 topic 有几个分区、每个分区的 leader 是谁、ISR 里有谁、配置是什么。这些信息统称元数据。

3.x 把这些信息放在 ZooKeeper 里,broker 通过 watch 感知变化。这套架构能工作,但有几个长期代价:

  • 两套系统要分别部署、监控、做安全配置;
  • 元数据变更要走 ZK 的 znode 和 watch,controller 重启后要重新从 ZK 加载全量状态;
  • controller 选举依赖 ZK 的临时节点,故障恢复时间和 ZK 会话超时绑在一起;
  • 运维要同时理解 ZK 和 Kafka 两套一致性模型。

KRaft 的做法是把元数据本身变成一条日志。控制器是一个 Raft quorum,元数据变更以记录的形式追加到 __cluster_metadata,再复制到多数派。broker 不再 watch 外部系统,而是像消费者一样从这条日志拉取。

4.0 已经移除 ZooKeeper,4.3.0 的镜像只支持 KRaft。容器里只有 Kafka 进程,没有 QuorumPeerMain:

appuser  1  0  ... java ... kafka.Kafka /opt/kafka/config/server.properties

ps 里没有 ZooKeeper。这不是配置问题,是这一代 Kafka 的默认形态。

单节点也是一个 quorum

本机用一条 docker run 起单节点,同时承担 broker 和 controller:

-e KAFKA_NODE_ID=1
-e KAFKA_PROCESS_ROLES=broker,controller
-e KAFKA_CONTROLLER_QUORUM_VOTERS='1@127.0.0.1:9093'
-e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER

KAFKA_PROCESS_ROLES=broker,controller 叫 combined mode:同一个进程既处理生产消费,也参与 controller quorum。三节点生产集群可以把两种角色拆开,本机不拆,因为资源只有 4 vCPU。

启动日志里能看到 Raft 的完整状态迁移。下面是 4.3.0 的真实输出(截取):

[RaftManager id=1] Starting voters are VoterSet(voters={1=VoterNode(...)})
[RaftManager id=1] Attempting durable transition to UnattachedState(epoch=0, leaderId=OptionalInt.empty, votedKey=Optional.empty, voters=[1], electionTimeoutMs=1895, highWatermark=Optional.empty) from null
[RaftManager id=1] Completed transition to ProspectiveState(epoch=0, ...) from UnattachedState(...)
[RaftManager id=1] Completed transition to CandidateState(localId=1, ..., epoch=1, ...) from ProspectiveState(...)
[RaftManager id=1] Completed transition to Leader(localVoterNode=..., epoch=1, epochStartOffset=0, ...) from CandidateState(...)

四个状态:Unattached → Prospective → Candidate → Leader。单节点也要走一遍选举,因为没有「默认 leader」这种捷径。electionTimeoutMs 在 1528–1895 之间随机,这是 Raft 用来避免多节点同时竞选的标准做法。

一个必须说清楚的边界:单节点 quorum 没有容错。唯一 voter 挂掉,controller 就没了,即使 broker 进程还在也无法处理元数据变更。三节点 quorum 才能容忍一个 controller 故障;本机三节点的 leader 选举数据在 4.3 节。

元数据日志的物理位置

容器里的日志根目录是 /tmp/kafka-logs,元数据分区是 __cluster_metadata-0:

docker exec mq-a2 sh -c 'ls -la /tmp/kafka-logs/__cluster_metadata-0/'
-rw-r--r-- 1 appuser appuser 10485760 00000000000000000000.index
-rw-r--r-- 1 appuser appuser    14654 00000000000000000000.log
-rw-r--r-- 1 appuser appuser 10485756 00000000000000000000.timeindex
-rw-r--r-- 1 appuser appuser        8 leader-epoch-checkpoint
-rw-r--r-- 1 appuser appuser       43 partition.metadata
-rw-r--r-- 1 appuser appuser      125 quorum-state

结构和普通 topic 的分区目录一样:.log 存记录,.index 是位移索引,.timeindex 是时间索引。区别在内容:普通分区存业务消息,这个分区存元数据记录。

partition.metadata 的内容:

version: 0
topic_id: AAAAAAAAAAAAAAAAAAAAAQ

__cluster_metadata 的 topic id 是全零加末尾 01,固定值。meta.properties 则是这个节点的身份:

cluster.id=5L6g3nShT-eMCtK--X86sw
directory.id=dnelrdZUUTaf6aeTn95kFQ
node.id=1
version=1

注意 node.id,不是 3.x 的 broker.id。4.x 移除了 broker.id 配置,节点身份统一用 node.id;zookeeper.connect 这个配置项也不存在了。照着 3.x 文档改配置文件,第一步就会卡在这里。

解出元数据记录

kafka-dump-log.sh 加 --cluster-metadata-decoder 可以把二进制记录解成 JSON:

docker exec mq-a2 /opt/kafka/bin/kafka-dump-log.sh \
  --cluster-metadata-decoder \
  --files /tmp/kafka-logs/__cluster_metadata-0/00000000000000000000.log

从空集群启动到创建一个 3 分区 topic,记录序列如下(本机实测,2026-10-29)。

offset 0 是一条控制记录,记录 quorum 的 leader 变更:

| offset: 0 ... LeaderChange: {"version":0,"leaderId":1,"voters":[{"voterId":1}],"grantingVoters":[{"voterId":1}]}

offset 1 到 9 是一个事务批次,名字叫 Bootstrap records:

| offset: 1 ... {"type":"BEGIN_TRANSACTION_RECORD","version":0,"data":{"name":"Bootstrap records"}}
| offset: 2 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"metadata.version","featureLevel":30}}
| offset: 3 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"eligible.leader.replicas.version","featureLevel":1}}
| offset: 4 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"group.version","featureLevel":1}}
| offset: 5 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"share.version","featureLevel":1}}
| offset: 6 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"streams.version","featureLevel":1}}
| offset: 7 ... {"type":"FEATURE_LEVEL_RECORD","data":{"name":"transaction.version","featureLevel":2}}
| offset: 8 ... {"type":"CONFIG_RECORD","data":{"resourceType":4,"resourceName":"","name":"min.insync.replicas","value":"1"}}
| offset: 9 ... {"type":"END_TRANSACTION_RECORD","version":0,"data":{}}

这一段信息量很大:

  • 元数据记录支持事务,BEGIN 和 END 之间的记录要么全生效、要么全不生效;
  • 特性版本(feature level)也写在日志里,metadata.version=30 是 4.3.0 的元数据版本;
  • 集群级默认配置 min.insync.replicas=1 是一条 CONFIG_RECORD,不是某个配置文件里的静态值。

offset 10 和 11 是节点注册:

| offset: 10 ... {"type":"REGISTER_CONTROLLER_RECORD","data":{"controllerId":1,...,"endPoints":[{"name":"CONTROLLER","host":"127.0.0.1","port":9093}]}}
| offset: 11 ... {"type":"REGISTER_BROKER_RECORD","version":4,"data":{"brokerId":1,...,"brokerEpoch":11,"endPoints":[{"name":"PLAINTEXT","host":"127.0.0.1","port":9092},{"name":"EXTERNAL","host":"127.0.0.1","port":19202}],"features":[...],"rack":null,"fenced":true,"inControlledShutdown":false,"logDirs":["dnelrdZUUTaf6aeTn95kFQ"]}}

REGISTER_BROKER_RECORD 里带着这个 broker 的全部对外信息:监听端点、支持的协议特性、日志目录、机架。fenced=true 表示注册刚开始、还没准备好接流量;注册完成的记录会把 fenced 改成 0。

offset 178 到 181 是 kafka-topics.sh --create 产生的四条记录,它们在同一个批次里:

baseOffset: 178 lastOffset: 181 count: 4 ... size: 319
| offset: 178 ... {"type":"TOPIC_RECORD","data":{"name":"a2.probe","topicId":"1K4bD7mCS8uhLsch-DB6VA"}}
| offset: 179 ... {"type":"PARTITION_RECORD","data":{"partitionId":0,"topicId":"1K4b...","replicas":[1],"isr":[1],"leader":1,"leaderEpoch":0,...}}
| offset: 180 ... {"type":"PARTITION_RECORD", ... partitionId:1 ...}
| offset: 181 ... {"type":"PARTITION_RECORD", ... partitionId:2 ...}

一个 topic 记录加三个分区记录必须一起生效,否则会出现「topic 存在但分区不全」的状态。放进同一个批次,事务语义就保证了原子性。

这也解释了一个日常现象:创建 topic 后立刻就能生产和消费,因为创建动作在 controller 提交完成时就写进了元数据日志,broker 随后拉取到。

NO_OP_RECORD 是什么

继续往下看日志,会发现大量这样的记录,大约每 500 毫秒一条:

| offset: 182 ... {"type":"NO_OP_RECORD","version":0,"data":{}}
| offset: 183 ... {"type":"NO_OP_RECORD","version":0,"data":{}}

本机这次 dump 的统计是:

1 BEGIN_TRANSACTION_RECORD
1 BROKER_REGISTRATION_CHANGE_RECORD
1 CONFIG_RECORD
1 END_TRANSACTION_RECORD
6 FEATURE_LEVEL_RECORD
210 NO_OP_RECORD
3 PARTITION_RECORD
1 REGISTER_BROKER_RECORD
1 REGISTER_CONTROLLER_RECORD
1 TOPIC_RECORD

210 条 NO_OP 占了绝大多数。它们没有业务含义,作用是让 quorum 的提交位置在空载时也能向前推进。Raft 的 high watermark 只有在多数派确认后才能前进;没有新数据时,leader 定期追加一条空操作,副本确认它,提交位置就动了。

这带来两个实际影响:

  • 元数据日志会持续增长,即使集群没有任何变更。它不是无限保留:同样按段滚动,并由内部清理策略处理;
  • 用 kafka-metadata-quorum.sh 看到的 high watermark 和 log end offset 可能差几条,差值通常就是刚追加、还没被多数派确认的 NO_OP。

本机采样时正好看到这个差值:

HighWatermark:          1608
LogEndOffset:           1611

差 3 条,不是丢数据,是尚未提交。

用 quorum 命令看状态

kafka-metadata-quorum.sh 是 KRaft 专属工具,3.x 的 ZooKeeper 模式没有这个命令:

docker exec mq-a2 /opt/kafka/bin/kafka-metadata-quorum.sh \
  --bootstrap-server 127.0.0.1:9092 describe --status

本机输出:

ClusterId:              5L6g3nShT-eMCtK--X86sw
LeaderId:               1
LeaderEpoch:            4
HighWatermark:          1608
MaxFollowerLag:         0
MaxFollowerLagTimeMs:   0
CurrentVoters:          [{"id": 1, "endpoints": ["CONTROLLER://127.0.0.1:9093"]}]
CurrentObservers:       []

describe --replication 给出每个 voter 的复制进度:

NodeId  DirectoryId            LogEndOffset  Lag  LastFetchTimestamp  LastCaughtUpTimestamp  Status
1       AAAAAAAAAAAAAAAAAAAAAA  1611          0    1793245985930       1793245985930         Leader

DirectoryId 是日志目录的身份,Lag=0 说明没有落后。三节点集群里这一列会显示 follower 的追赶情况,是排查 controller 故障的第一手数据。

LeaderEpoch: 4 值得注意。本机这个容器中途被 docker kill 过几次,每次重启都会触发一次 controller 选举,epoch 递增。单节点也会换 leader,只是换的还是自己。

quorum-state 文件

每个 voter 在自己的元数据目录里维护 quorum-state,内容是当前选举状态的持久化:

{"clusterId":"","leaderId":1,"leaderEpoch":4,"votedId":-1,"appliedOffset":0,"currentVoters":[{"voterId":1}],"data_version":0}

leaderId 和 leaderEpoch 与 describe --status 对得上,votedId=-1 表示这一轮没有投给别的候选人。这个文件的作用是崩溃恢复:进程重启后先读它,避免在同一 epoch 里重复投票。

本机看到的 appliedOffset 是 0,与日志的 1611 不一致。这个字段记录的是 voter 状态的应用进度,不是元数据日志的消费位置,不要把它当成「元数据只应用了 0 条」。判断元数据是否完整,看 describe --replication 的 LogEndOffset 和 Lag。

controller 与 broker 的分工

在 combined mode 下,同一个进程里跑两套逻辑:

角色负责数据来源
controller处理元数据变更、选 leader、维护 ISR__cluster_metadata 日志
broker处理生产消费、复制分区数据业务 topic 的分区日志

broker 不直接改元数据。它把「我要注册」「这个分区我落后了」这类请求发给 controller,controller 变成记录追加到日志,提交后 broker 再从日志里读到结果。这条路径比 ZK 的 watch 更直白:所有变更都有位移,都能重放。

分离部署(dedicated controllers)时,controller 节点不处理生产消费,broker 节点的 process.roles 只有 broker。本机没有做这种部署,不给启动参数和资源数据;需要时按官方镜像的 KAFKA_PROCESS_ROLES=controller 单独起三个节点,并把 KAFKA_CONTROLLER_QUORUM_VOTERS 指向它们。

常见误判

误判实际
「4.x 还要装 ZooKeeper」4.0 起移除,4.3.0 镜像只有 KRaft
「controller 就是 broker 里的一个线程,没有选举」单节点也要走 Raft 选举,日志里有完整状态迁移
「元数据日志不会增长」空载也有 NO_OP,按段滚动和保留策略管理
「broker.id 还能用」4.x 用 node.id,旧配置不生效
「--zookeeper 参数还能连」4.x 命令只认 --bootstrap-server

最后一行在本卷所有命令里都适用。附录 A 的命令速查也只给 --bootstrap-server 形式。

回到 libraryd

libraryd-mq 创建 library.loan.created 等六个 topic 时,每一次 --create 都会在 __cluster_metadata 里留下一条 TOPIC_RECORD 和若干 PARTITION_RECORD。这些记录的位移,就是集群元数据的版本号。

下一节讨论这些记录怎么到达客户端:broker 什么时候注册、leader 变更怎么传播、客户端为什么要等 metadata.max.age.ms 才看到新分区。

阅读导航:上一节 4.3 Leader 选举与不干净选举 | 下一节 5.2 集群成员与元数据传播

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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