阶段二 · 架构原理与调优

Controller 与元数据管理

前置回顾

  • 第 09 篇的动手实验里,停掉 Leader Broker 后新 Leader 自动上位——那个"自动"背后有一个决策者,本篇揭开它
  • 第 02 篇提过 Producer 通过 metadata 知道谁负责哪个 Partition,本篇讲这份 metadata 从哪来、怎么传播
  • 第 04 篇的 Group Coordinator 与本篇的 Controller 是两个独立角色,末尾 QA 对比

一句话总结

Controller 是集群的"大脑":某个 Broker 兼任的特殊角色,同一时刻只有一个,负责选举 Partition Leader、管理 ISR、处理 Topic 增删改,并把最新的集群地图广播出去.
地图(元数据)存在哪,决定了架构分成两代:ZooKeeper 模式(旧)与 KRaft 模式(新).理解 Controller,就理解了集群怎么自治.

从一次故障复盘开始

第 09 篇的实验:停掉 kafka-0,几秒后 Partition 0 的 Leader 从 0 自动换成 1.把这"几秒"拆开,背后是一连串动作:

kafka-0 宕机
│
▼
1. 故障检测:谁发现 kafka-0 不再心跳?
│
▼
2. 决策:盘点 kafka-0 上所有 Leader 分区,从各自 ISR 选新 Leader
│ (选举规则是第 09 篇讲的:只从 ISR 选)
│
▼
3. 通知:新 Leader 接到上岗通知,开始接收写入
│
▼
4. 广播:所有 Broker 和客户端更新"谁是 Leader"的地图

这四步必须由同一个角色来做.如果各 Broker 自行其是,可能两个 Broker 同时认为自己是 P0 的 Leader——脑裂:两边都收写入,数据分叉,这是分布式系统最怕的结局.

集群需要一个单一决策者:Controller.


Controller 是什么

一句话定义:Controller 是集群中某个 Broker 兼任的特殊角色,同一时刻集群里只有一个 active controller.它不存业务数据,只做两件事:决策与通知.

职责全景:

职责 说明
Broker 上下线检测 维护存活 Broker 列表,发现宕机
Partition Leader 选举 按第 09 篇的规则从 ISR 选新 Leader
ISR 管理 ISR 伸缩最终由它落定并广播
Topic 管理 创建/删除 Topic、增加分区
分区重分配 分区在 Broker 间迁移的调度者
元数据传播 把最新集群地图推给所有 Broker

Controller = 值班经理:每个员工照常干活,值班经理额外负责排班、应对突发、把变更通知到所有人.
值班经理也是员工(Broker)之一,只是多了一副职责.他请假(宕机)了,换一个人顶上,业务照转.

注意角色叠加:一台 Broker 可能同时是全局 Controller、P0 的 Leader、P1 的 Follower.三个维度互不冲突,只是同一进程承担了多份职责.


元数据:Controller 的依据和产物

Controller 决策的依据、决策后的产物,都是元数据——集群状态的全局地图:

元数据 = {
brokers: [0, 1, 2],
topics: {
"order-events": [
partition 0 → leader=1, isr=[1,2],
partition 1 → leader=2, isr=[2,0],
...
]
}
}

它有两个消费方:

消费方 获取方式 更新时机
Broker Controller 主动推送 元数据变更时
客户端 连任意 Broker 拉取(MetadataRequest) 定期刷新 + 出错立即刷新

客户端的行为在第 02 篇见过:Producer 发消息前先通过 metadata 知道目标 Partition 的 Leader 在哪.这套机制的细节:

  • 定期刷新:metadata.max.age.ms,默认 5 分钟
  • 错误触发:请求打到旧 Leader 收到 NotLeader 错误,立即刷新 metadata 并重试,这是主力路径
  • 不依赖 Controller 存活:客户端连的是任意 Broker,不是 Controller

老架构:ZooKeeper 模式

ZooKeeper(下文简称 ZK)是独立的分布式协调服务,提供树形数据存储、临时节点(会话断开自动消失)、watch(变更通知).Kafka 用了几十年,直到被 KRaft 取代.

ZK 里存了什么

/brokers/ids/0                    ← Broker 0 注册的临时节点,会话断开即消失
/brokers/ids/1 ← Broker 1 的节点
/brokers/topics/order-events/partitions/0/state
← P0 的 leader / isr 信息
/controller ← 谁是 Controller(临时节点,抢建)

Controller 选举:抢临时节点

所有 Broker 抢着创建 /controller 临时节点,谁先创建成功谁当 Controller.临时节点绑定创建者的会话:Controller 宕机,会话结束,节点自动消失,其余 Broker 通过 watch 察觉到,再次开抢.

为什么被淘汰

痛点 说明
元数据双写 状态同时存在 ZK 树和 Broker 本地日志,两处要对齐,历史上 bug 集中地
切换慢 新 Controller 上位要先从 ZK 拉全量元数据重建视图,分区越多越慢
多一套系统 ZK 集群要单独部署、监控、调优,还要和 Kafka 版本匹配
规模瓶颈 ZK 的节点数和 watch 数撑不起百万分区

新架构:KRaft 模式

KRaft(Kafka Raft)把元数据搬回 Kafka 自己手里.时间线:

版本 事件
3.3 KRaft 生产可用
3.5 ZooKeeper 模式标记弃用
4.0 ZooKeeper 模式移除

新集群一律 KRaft,没有例外.

核心变化

元数据不再放 ZK,而是存进 Kafka 自己的内部日志 __cluster_metadata,用 Raft 共识协议复制到专门的 controller 节点上:

KRaft 集群两类角色(可合一):

controller 节点: 只存元数据日志,3-5 个组成 quorum
broker 节点: 存业务数据(Partition)

combined 模式:一台机器同时兼两角 ← 第 07 篇的 docker-compose 就是这个

Controller 选举:Raft 协议

quorum 内部走 Raft 选举,选出 metadata leader,它同时就是 active controller.因为元数据日志已经复制到 quorum 所有成员,新 Leader 无需从外部加载任何状态,秒级接任——这正是 ZK 模式"切换慢"的解药.

两代架构对比

维度 ZooKeeper 模式 KRaft 模式
外部依赖 ZK 集群 无
元数据存储 ZK 树 + Broker 本地(双写) __cluster_metadata 日志(单源)
Controller 选举 抢临时节点 Raft 选举
切换速度 重新加载全量元数据 日志已复制,秒级
分区规模 数十万级 百万级

回看第 07 篇 compose 的参数,现在都能对上号:

KAFKA_CFG_PROCESS_ROLES=broker,controller        # combined 模式
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka-0:9093,1@kafka-1:9093,2@kafka-2:9093
# quorum 三成员
KAFKA_CFG_LISTENERS=...,CONTROLLER://:9093 # quorum 间通信端口

合起来:Leader 换人的完整流程

第 09 篇讲了选举规则(只从 ISR 选、截断到 HW),本篇补上"谁来执行".KRaft 模式下完整链路:

Broker 0 宕机
│
▼ 心跳超时(broker 定期向 controller 报活)
Controller: 标记 Broker 0 下线
│
▼
盘点 Broker 0 上的 Leader 分区,逐个处理:
│
▼ P0:ISR=[0,1,2] → 移除 0 → 从 [1,2] 选新 Leader(规则见第 09 篇)
通知:向 Broker 1, 2 发 LeaderAndIsr 请求
│
▼
更新元数据,广播给所有 Broker
│
▼
客户端:写旧 Leader 收到 NotLeader 错误 → 立即刷新 metadata → 连新 Leader

客户端这条链路值得单独强调:5 分钟定期刷新只是兜底,真正让故障在秒级恢复的是错误触发.对幂等 Producer,这次"失败 + 重试"不会产生重复消息(第 03 篇讲过 PID + Seq 去重),所以链路里没有消息丢失也没有重复.


动手验证

环境沿用第 07 篇的 compose(3 个 combined 节点).找当前 Controller:

docker compose exec kafka-0 bash

# 查看 quorum 状态,LeaderId 就是 active controller
kafka-metadata-quorum.sh --describe --status --bootstrap-server localhost:19092
ClusterId:              local-kraft-cluster-001
LeaderId: 1
LeaderEpoch: 7
HighWatermark: 1442
MaxFollowerLag: 0
MaxFollowerLagTimeMs: 0
CurrentVoters: [0,1,2]
CurrentObservers: []
  • CurrentVoters=[0,1,2]:quorum 的三个成员
  • LeaderId=1:kafka-1 在当 active controller
  • HighWatermark:元数据日志的长度,每次 Topic 增删、Leader 变更都会增长

验证切换:

# 停掉当前 controller
docker compose stop kafka-1

# 进 kafka-0 再查:LeaderId 已换成 0 或 2,LeaderEpoch 加一
docker compose exec kafka-0 bash
kafka-metadata-quorum.sh --describe --status --bootstrap-server localhost:19092

# 期间开一个消费者持续消费,验证读写不受影响
docker compose start kafka-1

生产注意事项

新集群直接 KRaft

4.0 起 ZK 模式已被移除,新集群没有选择成本.老集群的 ZK 迁移有官方工具(kraft 迁移命令),规划好窗口逐步执行.

quorum 用奇数节点

Raft 靠多数派共识:3 节点容忍 1 个故障,5 节点容忍 2 个.偶数没有收益还降低可用性.
规模再大也不建议超过 5 个 controller,quorum 的写延迟会随成员数上升.

元数据变更操作节制

Topic 创建/删除、分区重分配这类操作全部打到 Controller.批量脚本创建上千个 Topic 等于对 Controller 发起风暴,会拖慢整个集群的元数据响应.
需要大量建 Topic 时,分批、限速.

combined 模式的隐患

combined 节点身兼 broker 与 controller 两角:业务流量高峰会挤压 Controller 的 CPU 与网络,元数据操作变慢;Controller 故障时它上面的业务分区也要跟着迁移.
大规模集群建议 controller 分离部署,独立小规格机器即可,不存业务数据.

监控 ActiveControllerCount

第 07 篇列过:ActiveControllerCount 必须恒等于 1.出现 0(选举中)或 2(脑裂,极少见)都要立即告警.


Controller 挂了集群还能用吗?

读写不受影响:各分区的 Leader 继续服务,客户端已有 metadata 照常工作.
受影响的是管理操作:期间无法创建 Topic、无法切换 Leader.直到新 Controller 选出(秒级),这段时间如果恰好还有 Broker 宕机,它的 Leader 切换会被推迟——但不会丢数据.

Controller 和 Group Coordinator 是什么关系?

两个完全独立的角色,可能落在同一台 Broker 上,职责无关:

  • Controller:管集群级状态(Broker、Topic、Partition),本篇主角
  • Group Coordinator:管消费者组(成员关系、offset 提交),第 04 篇主角

客户端 5 分钟才刷新一次 metadata,期间会不会一直写错 Leader?

不会.5 分钟刷新只是兜底,主力是错误触发:写旧 Leader 收到 NotLeader,客户端立即拉新 metadata 重试,通常几百毫秒内恢复.
所以生产代码里别把 NotLeader 这类错误静默吞掉,它正是恢复机制的一部分.

combined 模式下 controller 节点挂了,元数据会丢吗?

不会.元数据日志通过 Raft 复制到 quorum 全部成员,任一成员都有完整副本;业务数据由第 09 篇的副本机制保护.
两个维度各丢各的,互不连累.

为什么元数据日志叫 `__cluster_metadata`,它和我们业务 Topic 有什么区别?

机制上几乎没区别:单分区日志 + Raft 复制 + compact 清理,和 __consumer_offsets 同一套路.
区别在于它不对外,客户端读不到,只由 Controller 写——这就是"Log 即数据库"在集群治理上的第一次应用,阶段三第 15 篇会展开.


快速回顾

  • Controller:集群唯一决策者,兼任于某个 Broker,负责选举 Leader、管理 ISR、Topic 增删改、广播元数据
  • 元数据:broker/topic/partition → leader 的全局地图,Broker 靠推送、客户端靠拉取 + 错误触发刷新
  • ZooKeeper 模式:元数据存外部 ZK,抢临时节点当 Controller,双写与切换慢是硬伤,4.0 已移除
  • KRaft 模式:元数据进 __cluster_metadata 日志,Raft 选举,日志已复制所以切换快,支撑百万分区
  • 换主链路:检测 → 盘点 → 选主 → LeaderAndIsr 通知 → 广播,客户端靠错误触发秒级恢复

动手练习

  1. 找 Controller:用 kafka-metadata-quorum.sh 查看 quorum 状态,记录 LeaderId 与 LeaderEpoch.
  2. 观察选举:停掉 Controller 所在 Broker,再查 quorum,对比 LeaderId 与 LeaderEpoch 的变化;期间跑一个消费者,验证消费不断.
  3. 看元数据增长:创建一个 Topic 再删除,对比操作前后 HighWatermark 的增量.
  4. 客户端视角:用 kcat -L -b localhost:9092 看客户端拉到的 metadata 全貌(第 07 篇用过),对照本篇的元数据结构.
  5. 复盘一次切换:停 Leader Broker,同时在另一终端持续生产,观察 Producer 经历错误 → 刷新 → 重试后恢复,日志里找到 NotLeader 错误.
  6. 读 compose 参数:重读第 07 篇 compose 里所有 CONTROLLER 开头的参数,逐个说出它们在本篇机制中的角色.

下一篇进入分区再平衡深入:第 04 篇只讲了 rebalance 的概念与两种协议的外在表现,下篇拆开 Eager 与 Cooperative 的实现细节、sticky assignor 的分配算法,以及频繁 rebalance 的排查思路.