Kafka 控制器与集群协调:Controller 选举与 KRaft

3 阅读40分钟

概述

在分布式系统中,没有完美的决策者,只有健壮的共识机制。Kafka 的 Controller 就像是集群的“大脑”,它通过 ZooKeeper 的临时节点机制(或 KRaft 的 Quorum 选举)确保任何一个瞬间只有一个真正的“大脑”在指挥集群。前文《Kafka 架构核心:Broker、Topic、Partition 与 ISR》深入剖析了集群的基本组件,而这一切分布式协调的关键角色正是 Controller。本文将从 Controller 的选举机制出发,深度对比 ZooKeeper 模式下的竞争逻辑与 KRaft 模式的 Raft 共识机制,彻底揭示 Kafka 集群大脑的运作机理。

核心要点

  • Controller 的核心职责:分区选举、ISR 维护、集群元数据广播。
  • ZK 模式选举:利用 ZK 临时节点的竞争与 controller_epoch 脑裂防护。
  • KRaft 模式:元数据日志、__cluster_metadata 物理存储、Quorum 多数派选举、Active/Standby 自动切换。
  • 集群变更实战:分区重分配流程、限流机制与 Cruise Control 工具链建议。
  • 故障模拟:ZK 模式宕机与假死恢复、KRaft 模式宕机切换、分区重分配监控。

文章组织架构

flowchart TD
    1["1. Zookeeper 模式下的 Controller 选举与脑裂防护"]
    2["2. KRaft 模式:移除了 ZK 的 Kafka 是如何工作的(含物理存储细节)"]
    3["3. Controller 的核心工作流程与集群元数据广播"]
    4["4. 集群扩缩容与分区重分配实战(含工具链建议)"]
    5["5. 故障模拟:ZK 与 KRaft 模式下的 Controller 宕机与假死演练"]
    6["6. 故障模拟:分区重分配与监控"]
    7["7. 面试高频专题"]
    1 --> 2 --> 3 --> 4 --> 5 --> 6 --> 7

1. ZooKeeper 模式下的 Controller 选举与脑裂防护

1.1 Controller 的职责模型

Controller 是 Kafka 集群中唯一的协调节点,其职责可以抽象为对以下几类事件的状态机管理:

  • Broker 变更:监听 ZooKeeper /brokers/ids 下临时节点的增删,维护集群成员列表。
  • Topic/Partition 变更:监听 /brokers/topics/brokers/topics/[topic]/partitions 下节点变化,处理 Topic 创建、删除、分区扩容。
  • 副本状态:通过每个 Broker 汇报的 replica lag 信息,动态调整 ISR 列表。
  • 分区 Leader 选举:当检测到分区 Leader 故障时,从当前 ISR 中选择新 Leader 并下发指令。
  • 元数据广播:将上述所有状态变化以 LeaderAndIsrRequestUpdateMetadataRequest 的形式推送给全集群的 Broker。

Controller 并不直接参与消息的 I/O 路径,而是通过异步事件驱动的方式持续运行。其内部维护了 ControllerContext,这是一个包含完整集群拓扑(所有 Broker、Topic、Partition、Replica 分配、Leader/ISR 等)的内存快照。任何状态变更都会先更新该上下文,再持久化到 ZooKeeper(KRaft 则为元数据日志),最后广播至其他 Broker。

1.2 基于 ZooKeeper 的选举与故障转移机制

竞争创建临时节点

每个 Broker 在启动时都会实例化 KafkaController,并通过 ZK 的临时节点机制争夺 Controller 角色。关键 ZK 路径为 /controller,该节点存储当前 Controller 的 brokeridtimestamp 等信息。代码入口在 KafkaController.elect() 方法中:

// kafka.controller.KafkaController
private def elect(): Unit = {
    activeControllerId = -1
    val timestamp = time.milliseconds()
    val controllerInfo = ControllerInfo(brokerConfig.brokerId, timestamp)
    try {
        // 创建 /controller 临时节点,仅当该节点不存在时才能成功
        zkClient.createController(controllerInfo)
        activeControllerId = brokerConfig.brokerId
        info(s"${brokerConfig.brokerId} successfully elected as the controller")
        onControllerFailover()
    } catch {
        case _: ZkNodeExistsException =>
            // 节点已存在,读取当前 controller 的 brokerid
            val existingControllerId = zkClient.getControllerId.getOrElse(-1)
            if (existingControllerId == brokerConfig.brokerId) {
                // 本地可能因 ZK 重连导致重复注册,但仍认为自己为 Controller
                info("Controller already elected by this broker")
            } else {
                activeControllerId = existingControllerId
                info(s"Broker $existingControllerId is the current controller")
            }
    }
}

createController 底层调用 createEphemeralPathExpectConflict,若节点已存在则抛出异常。这种“首抢即得”的策略保证了全局唯一 Controller 的产生。

ControllerChangeHandler 监听

所有非 Controller 的 Broker 也会在 /controller 上注册一个 Watcher(ControllerChangeHandler)。当 /controller 被删除(原 Controller 会话超时或主动关闭)时,Watcher 被触发,重新调用 elect() 发起竞争。

// kafka.controller.ControllerChangeHandler
class ControllerChangeHandler(eventManager: ControllerEventManager) extends ZkNodeChangeHandler {
  override val path: String = ControllerZNode.path
  override def handleCreation(): Unit = eventManager.put(ControllerChange)
  override def handleDeletion(): Unit = eventManager.put(Reelect)
}

注意,处理函数是通过向 ControllerEventManager 发送事件异步执行的,避免了在 ZK 回调线程中阻塞。

Controller Failover 过程:状态重建

当选出新 Controller 后,onControllerFailover 方法负责初始化集群状态。主要步骤:

  1. 读取 Controller epoch:从 /controller_epoch 节点读出当前 epoch 值,然后递增并写回,获取新的 epoch。
  2. 建立上下文:从 ZK 全量加载所有 /brokers/ids/brokers/topics 等信息,填入 ControllerContext
  3. 初始化状态机:启动 PartitionStateMachineReplicaStateMachine,将每个分区和副本从 ZK 记录的状态转换为实时运行状态。具体会选出 Leader、更新 ISR、必要时触发新的 Leader 选举。
  4. 启动副本管理器:开始接收 Follower 的 fetch 请求,监控他们的同步进度。
  5. 发送 UpdateMetadata 请求:将最新的元数据广播给所有 Broker,使他们能够更新本地缓存。
// kafka.controller.KafkaController.onControllerFailover
private def onControllerFailover(): Unit = {
    val newControllerEpoch = zkClient.incrementControllerEpoch()
    info(s"Controller epoch incremented to $newControllerEpoch")
    context.controllerEpoch = newControllerEpoch
    
    // 启动必要的后台任务和监听器
    initializeControllerContext()
    // ... 启动状态机,发送元数据 ...
}

1.3 controller_epoch 的脑裂防护机制

controller_epoch 是一个全局单调递增的整数,存储在 ZK 路径 /controller_epoch 下。每次 Controller 变更,epoch 都会递增。所有从 Controller 发出的状态变更请求(LeaderAndIsrRequestStopReplicaRequestUpdateMetadataRequest)均携带该 epoch 值。Broker 收到请求时,会将其与本地的 controllerEpoch 比较:

// kafka.server.KafkaApis.handleLeaderAndIsrRequest
val controllerEpoch = metadataCache.getControllerId.map(id => metadataCache.getControllerEpoch(id)).getOrElse(-1)
if (requestControllerEpoch < controllerEpoch) {
    warn(s"Ignoring LeaderAndIsr request from controller $controllerId with epoch $requestControllerEpoch " +
         s"since it is smaller than the current epoch $controllerEpoch")
    // 直接返回错误,不处理
    sendResponseExemptThrottle(request, response)
    return
}

如果旧 Controller 在 GC 停顿或网络隔离恢复后,依然认为自己是 Controller 并发送请求,其携带的 epoch 必然小于 Broker 已经记录的最新 epoch,请求会被拒绝。这防止了“僵尸 Controller”造成的元数据不一致。另外,每个分区还拥有独立的 partitionEpoch,进一步加强了版本控制。

假死风险与缓解

Controller 假死(例如长时间 GC 停顿)可能导致不必要的重新选举。因为其他 Broker 上的 /controller Watcher 会在 zookeeper.session.timeout.ms 后触发,引发新竞选。旧 Controller 苏醒后 epoch 已落后,被立即隔离。为减轻影响,可调整以下参数:

  • zookeeper.session.timeout.ms:默认 18 秒,过大则故障检测慢,过小则易误判(对网络抖动敏感)。建议 6000~12000ms。
  • GC 调优:使用低停顿的 GC 算法(如 G1GC 或 ZGC),并合理设置新生代大小,避免长时间 Full GC。
  • 监控与告警:监控 ActiveControllerCount(JMX 指标),若短时间内频繁变化,则可能存在问题。

ZooKeeper 模式 Controller 选举与脑裂防护序列图

sequenceDiagram
    participant Broker1 as Broker1 (Candidate)
    participant ZK as ZooKeeper
    participant Broker2 as Broker2 (Current Controller)
    participant Broker3 as Broker3 (普通 Broker)

    Note over Broker2: Controller 假死(GC 停顿)或会话超时
    Broker2--xZK: 会话超时,临时节点 /controller 被删除
    ZK-->>Broker1: Watcher 触发: /controller deleted
    ZK-->>Broker3: Watcher 触发
    
    Broker1->>ZK: create /controller (Ephemeral) 成功
    Broker1->>ZK: increment /controller_epoch (epoch += 1)
    Note over Broker1: 成为新 Controller,调用 onControllerFailover
    Broker1->>Broker3: LeaderAndIsrRequest (epoch=新)
    Broker3-->>Broker1: 处理成功
    
    Note over Broker2: 假死恢复,继续认为自己为 Controller
    Broker2->>Broker3: LeaderAndIsrRequest (epoch=旧)
    Broker3-->>Broker2: 拒绝,epoch 小于当前值
    Broker2->>ZK: 发现 /controller 节点已变,降级为普通 Broker

图表主旨概括:描绘 ZK 模式下 Controller 假死到新 Controller 产生,再到旧 Controller 被隔离的完整时序,突出临时节点删除触发竞争和 epoch 防护的作用。

逐层/逐元素分解

  • Broker2 与 ZK 会话超时,临时节点自动删除。
  • 其他 Broker 通过 Watcher 感知到变化,并发起竞争。
  • Broker1 抢先创建 /controller 节点成为新 Controller,并递增 epoch。
  • 新 Controller 开始发送携带新 epoch 的指令,Broker3 正常接收。
  • Broker2 苏醒后发送旧 epoch 指令被拒绝,此后读取 ZK 知晓身份变更并降级。

设计原理映射:借鉴了分布式系统中利用租约和代际(epoch/term)来防止过期领导者的模式。ZK 的临时节点提供存活检测,而 epoch 防止过期的操作生效。

工程联系与关键结论Controller 的高可用性完全依赖于 ZK 会话的超时检测和即时选举,生产环境中必须严格监控 GC 停顿和网络延迟,并通过 epoch 机制确保一致性,即使发生短暂双主也不会导致数据错误。


2. KRaft 模式:移除了 ZK 的 Kafka 是如何工作的(含物理存储细节)

2.1 KRaft 架构总览

Kafka 3.x 引入了 KRaft(Kafka Raft)模式,用内嵌的 Raft 共识协议替代了外部 ZooKeeper。在 KRaft 模式下:

  • 控制器节点(Controller Servers)组成一个 Raft 复制组,管理集群元数据。
  • Broker 节点可以不担任控制器角色,仅负责存储和流转数据,它们从控制器节点获取元数据快照和增量更新。
  • 元数据以消息日志的形式存储在内部 Topic __cluster_metadata 中,该 Topic 的每个分区副本恰好位于控制器节点上(通常只有一个分区,分区的 Leader 就是 Raft 的 Leader)。

这种架构消除了外部依赖,简化了部署和运维,同时利用 Raft 的强一致性保证元数据不会丢失,并支持更快的故障恢复(基于心跳而非会话超时)。

2.2 元数据日志与 __cluster_metadata 主题

元数据记录模型

每一种集群资源(Topic、Partition、Broker、Config 等)对应一种元数据记录类型,序列化为 Protocol Buffers 格式。记录的键为资源唯一标识(例如 TopicPartition),值为该资源的当前状态。Controller 将更新操作(如创建 Topic、修改 ISR)转化为日志条目追加到 Raft 日志中,一旦条目被提交,即代表元数据状态改变。

__cluster_metadata 的物理存储

__cluster_metadata 是一个内部 Compacted Topic。默认分区数为 1,以保证元数据更新的全局顺序;副本数等于 controller.quorum.voters 中配置的控制器节点数。该主题不能通过常规 API 操作(如 kafka-topics.sh),其创建和管理由 KRaft 内部自动完成。

物理存储结构与普通分区完全一致:每个控制器节点在本地日志目录(log.dirs)下存储该分区的日志段文件。例如:

/data/kafka/__cluster_metadata-0/
  00000000000000000000.log
  00000000000000000000.index
  00000000000000000000.timeindex
  00000000000000000123.snapshot
  leader-epoch-checkpoint

日志段文件(LogSegment) 包含一系列 Raft 日志条目,每条条目包含一个偏移量、时间戳、键(元数据记录键)和值(序列化后的记录)。.index 文件提供偏移量到物理位置的映射,以便快速查找。.snapshot 文件是定期生成的元数据全量快照,用于加速恢复和截断旧日志。

Compaction 机制

由于元数据更新频繁,为保持日志大小可控,__cluster_metadata Topic 启用了日志压缩(Log Compaction)。其后台 LogCleaner 线程会扫描日志段,对于拥有相同 Key 的记录,仅保留最新(或带有墓碑标记)的记录。例如,Topic foo 被创建后,其配置多次修改,最终日志中只保留最后一次配置修改的记录。当生成新的 Snapshot 后,Snapshot 偏移量之前的所有日志段可以被安全删除(这与普通 Topic 基于时间或大小的删除策略不同)。

快照(Snapshot)与恢复

为了进一步提高恢复效率,KRaft 会定期生成元数据快照。SnapshotGenerator 从内存中的全量元数据(由 Raft 日志重放得到)构建快照文件。节点启动时,优先加载最新的 snapshot 文件恢复大部分状态,然后重放 snapshot 之后的日志段,即可快速同步到最新状态。这个机制避免了从第一条日志重放整个历史。

__cluster_metadata 物理存储与 Compaction 示意图

flowchart LR
    subgraph ControllerA ["Controller 1 日志目录"]
        seg1["0000000000.log<br/>旧记录版本"]
        seg2["0000000100.log<br/>中间版本"]
        seg3["0000000200.log<br/>最新记录"]
        snap["0000000200.snapshot"]
        seg1 --> seg2 --> seg3
    end
    subgraph ControllerB ["Controller 2 日志目录"]
        segB1["0000000000.log"]
        segB2["0000000100.log"]
        segB3["0000000200.log"]
        snapB["0000000200.snapshot"]
    end
    MetaTopic[["__cluster_metadata-0"]]
    RaftLeader["Raft Leader"]
    Cleaner["LogCleaner"]

    RaftLeader -- "追加日志" --> MetaTopic
    MetaTopic --> ControllerA
    MetaTopic --> ControllerB
    Cleaner -.-> seg1
    Cleaner -.-> seg2
    Cleaner -.-> segB1
    Cleaner -.-> segB2

    Note1["Compaction 后旧段删除"]
    Note2["旧段删除"]
    seg1 -.-> Note1
    segB1 -.-> Note2

    classDef noteStyle fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5,color:#333;
    class Note1,Note2 noteStyle;
    classDef controllerStyle fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px;
    class ControllerA,ControllerB controllerStyle;

图表主旨概括:展示 __cluster_metadata 分区在各个控制器节点上的物理文件布局,以及 Log Compaction 如何清理过时版本,最终通过 Snapshot 截断日志。

逐层/逐元素分解

  • 每个控制器节点都有一份完整的日志副本,包含多个 log 文件。
  • Raft Leader 将元数据记录追加到日志中。
  • LogCleaner 进程会合并 key 相同的记录,删除旧数据,仅留下最新值和 tombstone。
  • 定期生成的快照使得快照偏移量之前的日志段可被删除。

设计原理映射:体现了 Kafka 的“自举”哲学——元数据管理复用 Kafka 本身的日志存储和压缩机制。Compaction 保证每一个键只有最新值,与元数据的键值本质高度契合。

工程联系与关键结论理解 __cluster_metadata 的物理结构有助于评估磁盘需求和分析恢复时间,元数据日志的大小与集群资源数量和变更频率相关,需监控日志段的实际占用并确保 Compaction 正常运行。

2.3 Quorum 选举与心跳维护

控制器节点通过静态配置 controller.quorum.voters 组成 Quorum(法定人数)。例如:

controller.quorum.voters=1@kafka1:9093,2@kafka2:9093,3@kafka3:9093

该配置指定了参与投票的节点 ID 及其通信地址。节点数必须为 2n+1,容忍 n 个节点故障。例如 3 节点容忍 1 个故障,5 节点容忍 2 个故障。

Active Controller 选举

基于 Raft 协议,每个节点启动时均为 Follower(Standby)。若在选举超时(controller.quorum.election.timeout.ms)内未收到当前 Leader 的心跳,Follower 会转换为 Candidate,增加自己的 term,并向其他节点发送 VoteRequest。如果获得多数票,则成为新的 Leader(Active Controller)

Leader 负责处理所有元数据变更,将操作序列化成日志条目复制给其他节点,并在多数节点确认后提交(commit)。提交的日志条目会被应用到状态机中(即 Controller 的内存元数据)。随后的心跳能够通知其他节点当前 Leader 仍存活。

心跳与日志复制复用 Fetch 通道

KRaft 中的心跳和日志复制均通过同一 Fetch 请求实现,这减少了连接数。Leader 定期(heartbeat.interval.ms)向所有 Follower 发送 FetchRequest,请求中可携带新的日志条目。Follower 在 FetchResponse 中应答自己的复制偏移量。如果 Follower 在 fetch.timeout.ms 内没有收到有效的 Fetch 请求,则会认为 Leader 失联并开始选举。

相关源码片段(kafka.raft.KafkaRaftClient):

// 心跳和日志追加都在 poll 循环中处理
@Override
public long poll(long timeoutMs) {
    if (state.isLeader()) {
        // 作为 Leader:向所有 Follower 发送 fetch 请求
        sendFetches();
    } else {
        // 作为 Follower:检查是否超时
        if (heartbeatElapsedTime >= electionTimeoutMs) {
            transitionToCandidate();
        }
    }
}

Quorum 选举与日志复制序列图

sequenceDiagram
    participant L as Leader (Active Controller)
    participant F1 as Follower 1
    participant F2 as Follower 2
    participant App as 元数据更新请求

    App->>L: 创建 Topic 请求
    L->>L: 生成元数据记录,追加到本地日志
    L->>F1: FetchRequest (日志条目)
    L->>F2: FetchRequest
    F1-->>L: FetchResponse (ack offset)
    F2-->>L: FetchResponse (ack offset)
    Note over L: 收到多数确认,推进 commit offset
    L->>F1: FetchRequest (携带 committed offset)
    L->>F2: FetchRequest
    F1->>F1: 应用日志到状态机
    F2->>F2: 应用日志到状态机
    L->>App: 操作成功

    loop 心跳周期
        L->>F1: FetchRequest (empty)
        L->>F2: FetchRequest (empty)
    end
    Note over L: Leader 宕机
    F1-xL: 心跳超时
    F1->>F1: 转为 Candidate,term++
    F1->>F2: VoteRequest
    F2-->>F1: VoteResponse granted
    F1->>L: 成为新 Leader,向其余节点发送心跳

图表主旨概括:呈现 KRaft 模式下从客户端请求到日志提交的完整流程,以及 Active Controller 宕机后的选举接管。

逐层/逐元素分解

  • Leader 收到元数据请求,将日志条目通过 Fetch 请求复制。
  • 多数 Follower 确认后提交。
  • 心跳复用同一 Fetch 请求,维持 Leader 地位。
  • 如果 Follower 未按时收到心跳,将发起投票,获得多数票的成为新 Leader。

设计原理映射:严格遵循 Raft 的 Leader 驱动的复制模型和基于 term 的选举,利用 Fetch 请求的请求/响应天然实现心跳和日志流。

工程联系与关键结论KRaft 的心跳设计较 ZK 模式更为高效,选举超时通常设置在数千毫秒,故障检测和切换时间远低于 ZK 的会话超时(18 秒),但同时也要求网络延迟较低且稳定,以避免不必要的选举。

2.4 生产部署考量与 metadata.version

控制器节点部署

  • 独立控制器节点:可将 process.roles=controller 的节点与 broker 节点分离,避免元数据工作负载影响数据路径。
  • 混部模式:小规模集群可让节点同时担任 controllerbroker 角色。
  • 配置 controller.quorum.voters 应使用静态列表,改变需滚动重启。

metadata.version 特性门控

类似于 inter.broker.protocol.versionmetadata.version 确保集群中所有控制器节点就元数据格式达成一致。例如,在从 Kafka 3.4 升级到 3.5 时,集群可以先升级二进制,但保持 metadata.version=3.4,待所有节点运行新版本后,再通过命令动态修改版本号为 3.5,启用新特性。这种渐进式升级避免了格式不匹配导致的不一致。

# 动态升级 metadata.version
kafka-features.sh --bootstrap-server localhost:9092 upgrade --metadata 3.5-IV2

2.5 ZooKeeper 模式 vs KRaft 模式架构差异

架构差异对比图

flowchart LR
    subgraph ZKMode ["ZooKeeper 模式"]
        ZK_Cluster["ZooKeeper Ensemble"]
        Controller_ZK["Active Controller"]
        Broker_ZK1["Broker"]
        Broker_ZK2["Broker"]
        Controller_ZK --- ZK_Cluster
        Broker_ZK1 --- ZK_Cluster
        Broker_ZK2 --- ZK_Cluster
        Controller_ZK -- "LeaderAndIsr" --> Broker_ZK1
        Controller_ZK -- "LeaderAndIsr" --> Broker_ZK2
    end
    subgraph KRaftMode ["KRaft 模式"]
        CR1["Controller 1 (Raft Node)"]
        CR2["Controller 2 (Raft Node)"]
        CR3["Controller 3 (Raft Node)"]
        CR1 <-. "Raft Log" .-> CR2
        CR2 <-. "Raft Log" .-> CR3
        CR1 <-. "Raft Log" .-> CR3
        Broker_K["Broker"]
        Broker_K -- "MetadataFetch" --> CR1
    end
    ZKMode --> KRaftMode

图表主旨概括:对比两种模式的组件交互,突出外部依赖和内部复制的差异。

设计原理映射:ZK 模式将共识委托给外部系统,KRaft 模式自包含共识,消除了运维复杂性和额外的网络跳数。

工程联系与关键结论迁移到 KRaft 可以显著简化部署,但需要重新设计节点角色,并合理规划 Quorum 节点的数量和位置,以保证高可用性。


3. Controller 的核心工作流程与集群元数据广播

3.1 分区 Leader 选举流程

当 Controller 检测到 Broker 故障(例如,通过 ZK /brokers/ids 节点删除或 KRaft 的心跳丢失),会触发受影响分区的 Leader 选举。重要步骤:

  1. 确定受影响分区:遍历 ControllerContext 中属于该 Broker 的所有分区。
  2. 尝试从 ISR 选举:从分区的当前 ISR 中挑选第一个存活且未离线的副本作为新 Leader。
  3. 若无 ISR 可选:若 unclean.leader.election.enable=true,则从其他 AR (Assigned Replicas) 中选择,但这可能导致数据丢失;若为 false,则分区不可用。
  4. 构建 LeaderAndIsr 请求:封装新的 Leader 信息、ISR 列表、leaderEpochcontrollerEpoch
  5. 发送请求:通过 ControllerChannelManager 向该分区所有副本发送 LeaderAndIsrRequest

ControllerChannelManager.sendLeaderAndIsrRequest 核心逻辑:

// kafka.controller.ControllerChannelManager
def sendLeaderAndIsrRequest(leaderAndIsrRequest: LeaderAndIsrRequest, ...) = {
    broker.send(request)
}
// 底层使用 NetworkClient 异步发送

当副本收到请求后,会更新本地 Leader 和 ISR 信息,并开始如果自己是 Leader 则等待生产者连接,如果是 Follower 则向新 Leader 发起 Fetch。

3.2 ISR 维护

ISR 列表由 Controller 集中维护,但依赖每个 Follower 定期发送的心跳(通过 Fetch 请求)和上报的 fetch offset。当 Follower 的副本滞后时间超过 replica.lag.time.max.ms(默认 30 秒),Controller 会将其从 ISR 中移除;如果之后该副本追赶上并且 lag 在允许范围内,则重新加入 ISR。任何 ISR 变更都会触发向该分区所有副本发送 LeaderAndIsrRequest 更新。

3.3 元数据广播

除了分区状态变更,Controller 还需要将 Topic 创建/删除、Broker 上下线等元数据变更告知所有 Broker。为此,Controller 会向每个 Broker 发送 UpdateMetadataRequest,该请求包含全量或增量的集群元数据(所有 Topic 分区、Broker 节点信息)。Broker 收到后更新本地 MetadataCache,从而能够正确路由客户端请求。

在 KRaft 模式下,元数据广播方式又有所优化:Broker 通过 MetadataFetch 机制从 Active Controller 定期拉取元数据快照和增量,避免全量推送带来的网络开销。

Leader 变更广播序列图

sequenceDiagram
    participant C as Controller
    participant L as New Leader (Broker2)
    participant F as Follower (Broker3)
    participant P as Producer

    Note over C: 检测原 Leader (Broker1) 宕机
    C->>C: 选举 Broker2 为新 Leader
    C->>L: LeaderAndIsrRequest(Leader=2, ISR=[2,3])
    C->>F: LeaderAndIsrRequest(Leader=2, ISR=[2,3])
    L-->>C: Response
    F-->>C: Response
    Note over L: 成为 Leader, 更新元数据
    F->>L: FetchRequest
    L-->>F: FetchResponse
    C->>L: UpdateMetadataRequest (广播全局元数据)
    C->>F: UpdateMetadataRequest
    P->>L: ProduceRequest (感知新 Leader)
    L-->>P: ACK

图表主旨概括:展示 Controller 在 Leader 选举后如何指令副本切换角色,并更新全部 Broker 的元数据。

设计原理映射:通过异步并发请求最小化切换时间,并保证所有 Broker 的元数据最终一致。

工程联系与关键结论Leader 选举的速度受到 Controller 与 Broker 间的网络延迟影响,可以通过调优 controller.socket.timeout.ms 等参数来提升。


4. 集群扩缩容与分区重分配实战(含工具链建议)

4.1 重分配流程

使用 kafka-reassign-partitions.sh 进行分区重分配的完整步骤:

  1. 生成计划:准备一个 JSON 文件指定要移动的 Topic。运行:
    kafka-reassign-partitions.sh --bootstrap-server broker:9092 --topics-to-move-json-file topics.json --broker-list "0,1,2" --generate
    
    输出 current 和 proposed 分配方案,可将 proposed 保存为 reassignment.json
  2. 执行
    kafka-reassign-partitions.sh --bootstrap-server broker:9092 --reassignment-json-file reassignment.json --execute
    
    Controller 会启动副本迁移过程。
  3. 监控进度
    kafka-reassign-partitions.sh --bootstrap-server broker:9092 --reassignment-json-file reassignment.json --verify
    
    输出每个分区的状态(in progresscompleted)。
  4. 限流调节:迁移可能影响生产流量,可通过 dynamic config 设置:
    kafka-configs.sh --alter --add-config 'follower.replication.throttled.rate=104857600' --bootstrap-server broker:9092 --entity-type brokers --entity-default
    

4.2 底层原理

ReassignPartitionsCommand 提交重分配计划后,Controller 将该计划持久化到 ZK(/admin/reassign_partitions),并在 onPartitionReassignment 方法中执行。它为每个要移动的副本创建一个 ReplicaFetcher 线程,从原副本所在 Broker 拉取数据到目标 Broker。一旦目标副本追上 Leader 的 LEO 并保持同步,就会被加入 ISR,随后旧副本被删除,分区列表更新。整个过程由 Controller 状态机驱动。

如果迁移期间原 Leader 宕机,Controller 会正常进行 Leader 选举,迁移任务不受影响,数据复制仍在新的 Leader 与目标副本之间进行。

4.3 Cruise Control 简介

手动重分配存在无法感知实时负载、需要精确指定 Broker 列表等局限。LinkedIn 开源的 Cruise Control 提供了自动化运维能力:

  • 持续采集 CPU、磁盘、网络、请求数等指标。
  • 根据配置的目标(如均衡资源、最小化领导负载)生成详细的执行计划。
  • 支持慢速迁移(self-healing)以避免影响线上服务。
  • 提供 REST API 和 Web UI,方便集成。

在大规模集群(>30 Broker)中,强烈推荐采用 Cruise Control 替代手动脚本。

分区重分配流程与限流示意图

flowchart TD
    A["管理员提交重分配 JSON"] --> B["Controller 持久化计划"]
    B --> C["启动 ReplicaFetcher 线程"]
    C --> D{"新副本是否追上 Leader"}
    D -- "否" --> E["限制速率拉取数据"]
    E --> D
    D -- "是" --> F["将新副本加入 ISR"]
    F --> G["更新分区 AR,删除旧副本"]
    G --> H["重分配完成"]
    
    subgraph throttle ["限流配置"]
        L1["leader.replication.throttled.rate"]
        L2["follower.replication.throttled.rate"]
    end
    
    E -.-> L1
    E -.-> L2

    classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333;
    classDef process fill:#f8f9fa,stroke:#333,stroke-width:1px,color:#333;
    class D decision;
    class A,B,C,E,F,G,H,L1,L2 process;

图表主旨概括:展示从计划到完成的分区迁移路径,突出限流在数据传输阶段的调控作用。

设计原理映射:通过限速防止带宽挤占,利用 ISR 的动态扩缩容机制保证数据一致性。

工程联系与关键结论分区重分配是一个长期过程,需密切监控 ISR 收缩和消费者延迟,限流值设置不当可能导致迁移过慢或影响正常业务。


5. 故障模拟:ZK 与 KRaft 模式下的 Controller 宕机与假死演练

5.1 环境准备与监控工具

为了完成后续的故障模拟,需要一套可操作的 Kafka 集群。建议使用 Docker Compose 快速搭建 ZK 模式和 KRaft 模式环境。监控方面,除了 kafka-broker-api-versions.shkafka-metadata-quorum.sh,我们还会用到 JMX 指标以及 Broker 日志。

Docker Compose 配置(ZK 模式)

version: '3'
services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
  kafka1:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kafka1
    depends_on: [zookeeper]
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka1:9092
      KAFKA_JMX_PORT: 9991
  kafka2:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kafka2
    depends_on: [zookeeper]
    environment:
      KAFKA_BROKER_ID: 2
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka2:9092
      KAFKA_JMX_PORT: 9992
  kafka3:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kafka3
    depends_on: [zookeeper]
    environment:
      KAFKA_BROKER_ID: 3
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka3:9092
      KAFKA_JMX_PORT: 9993

Docker Compose 配置(KRaft 模式)

version: '3'
services:
  kc1:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kc1
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: 'controller,broker'
      KAFKA_LISTENERS: 'PLAINTEXT://kc1:9092,CONTROLLER://kc1:9093'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kc1:9093,2@kc2:9093,3@kc3:9093'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kafka-data'
      CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
      KAFKA_JMX_PORT: 9991
  kc2:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kc2
    environment:
      KAFKA_NODE_ID: 2
      KAFKA_PROCESS_ROLES: 'controller,broker'
      KAFKA_LISTENERS: 'PLAINTEXT://kc2:9092,CONTROLLER://kc2:9093'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kc1:9093,2@kc2:9093,3@kc3:9093'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kafka-data'
      CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
      KAFKA_JMX_PORT: 9992
  kc3:
    image: confluentinc/cp-kafka:7.5.0
    hostname: kc3
    environment:
      KAFKA_NODE_ID: 3
      KAFKA_PROCESS_ROLES: 'controller,broker'
      KAFKA_LISTENERS: 'PLAINTEXT://kc3:9092,CONTROLLER://kc3:9093'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kc1:9093,2@kc2:9093,3@kc3:9093'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kafka-data'
      CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
      KAFKA_JMX_PORT: 9993

5.2 场景一:ZK 模式 Controller 宕机演练

目的:验证 Controller 故障后,集群自动选举新 Controller 并恢复元数据管理。

操作步骤

  1. 启动集群,检查当前 Controller。
    # 查看 /controller 节点
    zookeeper-shell.sh localhost:2181 get /controller
    
    输出示例:
    {"version":1,"brokerid":1,"timestamp":"1715000000000"}
    
  2. 启动一个持续生产/消费的任务,观察 offset 提交情况。
  3. 停止当前 Controller(假设为 broker 1):
    docker stop kafka1
    
  4. 立即观察 ZK 中 /controller 节点的变化,以及新 Controller 的选举。
    watch -n 1 'zookeeper-shell.sh localhost:2181 get /controller 2>/dev/null'
    

预期现象与验证

  • ZK 中 /controller 节点在 session.timeout.ms(默认约 18 秒)后消失。
  • 随即其他 Broker(例如 broker 2)创建该节点,节点内容更新为新的 broker ID。
  • 查看新 Controller 日志(docker logs kafka2),应包含类似:
    [Controller id=2] 2 successfully elected as the controller
    [Controller id=2] Controller epoch incremented to 2
    [Controller id=2] Elected as new controller, triggering controller failover
    
  • 使用 kafka-broker-api-versions.sh 查看 Controller:
    kafka-broker-api-versions.sh --bootstrap-server kafka2:9092 | grep -i controlling
    
    输出:
    kafka2:9092 (id: 2 rack: null) -> Controlling: true
    
  • JMX 指标监控:连接 kafka2:9992,查看 kafka.controller:type=KafkaController,name=ActiveControllerCount 值为 1。
  • 生产和消费客户端会自动重试并恢复,约在 20-30 秒内恢复正常。可以通过消费者延迟监控看到短暂的增加后回落。

实际输出解读与结论: Controller 切换完全自动化,集群中只要有一个 Broker 存活即可保证元数据管理继续。切换期间,已存在的 Leader 分区和正常的消息流不受影响,但无法处理新的 Leader 选举或 Topic 创建。因此,ZK 模式下 Controller 的高可用依赖于 ZK 会话超时,生产环境中建议将 zookeeper.session.timeout.ms 设置在 6000~12000ms 之间以加快故障检测

5.3 场景二:ZK 模式 Controller 假死(GC 停顿)演练

目的:模拟 Controller 因 GC 暂挂导致的假死场景,验证 controller_epoch 的隔离效果。

操作步骤

  1. 确认当前 Controller 为 broker 1。
  2. 在生产者和消费者正常运行的同时,向 Controller 发送暂停信号:
    # 获取 Controller 容器的 PID
    CTL_PID=$(docker inspect -f '{{.State.Pid}}' kafka1)
    # 暂停进程(模拟长时间 GC)
    kill -STOP $CTL_PID
    
  3. 等待约 25 秒(确保超过 zookeeper.session.timeout.ms 的默认 18 秒),观察新 Controller 选举。
  4. 恢复暂停的进程:
    kill -CONT $CTL_PID
    

预期现象与验证

  • 暂停期间,ZK 会话超时,节点 /controller 被删除,新 Controller(如 broker 2)产生。
  • 旧 Controller(broker 1)日志中无新活动,恢复后会出现类似记录:
    [Controller id=1] Ignoring LeaderAndIsr request from controller 1 with epoch 1 since it is smaller than the current epoch 2
    
    这表明 broker 1 苏醒后试图以旧 epoch 发送控制请求,但被拒绝。
  • 通过 docker logs kafka1 可以观察到 broker 1 最终识别到自己不再是 Controller:
    [Controller id=1] Resigned from controller
    
  • JMX 指标 ActiveControllerCount 会先出现短暂的 0(在选举过程中),然后变为 1,总体没有长时间双主。

结论controller_epoch 机制有效地阻止了假死 Controller 恢复后的误操作,保证了元数据的一致性。然而,假死导致的重新选举仍然会对集群造成短暂影响,务必通过 GC 调优和合理的超时设置来降低假死发生的概率和持续时间

5.4 场景三:KRaft 模式 Active Controller 宕机演练

目的:验证 KRaft 集群中 Active Controller 宕机后 Quorum 重新选举和元数据日志的一致性。

操作步骤

  1. 启动 KRaft 集群,使用 kafka-metadata-quorum.sh 确定当前 Leader:
    kafka-metadata-quorum.sh --bootstrap-server kc1:9092 describe --status
    
    输出示例:
    ClusterId:              MkU3OEVBNTcwNTJENDM2Qk
    LeaderId:               1
    LeaderEpoch:            15
    HighWatermark:          12345
    MaxFollowerLag:         10
    CurrentVoters:          [1,2,3]
    CurrentObservers:       []
    
  2. 停止 Active Controller(节点 1):
    docker stop kc1
    
  3. 立刻在其他节点上描述 Quorum 状态:
    kafka-metadata-quorum.sh --bootstrap-server kc2:9092 describe --status
    

预期现象与验证

  • 几秒钟内,LeaderId 会变为 2 或 3,LeaderEpoch 增加。
  • 观察生产者和消费者:出现短暂 NOT_LEADER_FOR_PARTITION 错误或网络异常,然后自动刷新元数据并重新连接到新 Leader。
  • 查看新 Leader 日志,包含选举记录:
    [RaftManager nodeId=2] Completed transition to Candidate from Follower
    [RaftManager nodeId=2] Became Leader for term 16 with epoch 16
    
  • 元数据日志的连续性通过 Raft 日志复制保证:原 Leader 的最后一次提交的元数据变更(如 Topic 创建)不会丢失,因为必须多数节点确认后才提交。
  • 可通过检查 __cluster_metadata 的 last committed offset 来确认数据同步情况。

结论:KRaft 的故障切换比 ZK 模式更迅速(通常 2~5 秒),且元数据日志强一致,不会出现 ZK 模式下新旧 Controller 共存的短暂风险。推荐在生产中对 KRaft 控制器节点使用独立硬件,并确保心跳网络低延迟。

故障模拟全链路观测序列图

sequenceDiagram
    participant User as 操作者
    participant Cluster as Kafka 集群
    participant Monitoring as 监控系统

    User->>Cluster: "故障注入 (docker stop / kill -STOP)"
    Cluster->>Monitoring: "Controller 离线警报 (ActiveControllerCount=0)"
    Cluster-->>Cluster: "新 Controller 选举 (ZK: 临节点删除/竞争, KRaft: Raft 投票)"
    Monitoring->>User: "新 Controller ID, Epoch 增长"
    User->>Cluster: "验证生产/消费状态 (kafka-console-producer/consumer)"
    Cluster-->>User: "短暂中断后恢复正常,offset 无丢失"
    opt 假死恢复 (ZK模式)
        User->>Cluster: "kill -CONT"
        Cluster->>Cluster: "旧 Controller 请求被 epoch 拒绝"
        Cluster->>User: "旧 Controller 日志: Ignoring ... epoch ... smaller"
    end

图表主旨概括:融合三种故障场景的观测流程,从故障注入到恢复验证的通用模式。


6. 故障模拟:分区重分配与监控

6.1 实验设计

本实验的目标是观察分区重分配对流量的影响,验证限流机制的有效性,并掌握监控方式。

环境:假设集群有三个 Broker(ID 0,1,2),创建一个 Topic reassign-demo,包含 10 个分区,每个分区 2 个副本:

kafka-topics.sh --bootstrap-server kafka1:9092 --create --topic reassign-demo --partitions 10 --replication-factor 2

准备重分配计划:将 reassign-demo 的所有分区从 0,1 迁移到 1,2。生成 JSON:

kafka-reassign-partitions.sh --bootstrap-server kafka1:9092 --topics-to-move-json-file topics.json --broker-list "1,2" --generate

将输出的 proposed 方案保存为 reassign.json

6.2 模拟过程与实时监控

第一步:不施加重分配限速,观察影响

kafka-reassign-partitions.sh --bootstrap-server kafka1:9092 --reassignment-json-file reassign.json --execute

同时,使用 kafka-consumer-groups.sh 监控某个消费者组的延迟,或通过 JMX 指标 kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions 观察未复制完成的分区数。

输出示例(未限速):

  • UnderReplicatedPartitions 瞬间升至 10(因为所有分区都在进行副本替换)。
  • 消费者 Lag 可能轻度增加,但很快随着新副本追上而回落。
  • 通过 kafka-reassign-partitions.sh --verify 可在几分钟内看到大部分分区 completed

第二步:施加严格限流,观察效果 在开始重分配之前或中途,设置极低的限流值(例如 1MB/s):

kafka-configs.sh --bootstrap-server kafka1:9092 --alter --add-config 'follower.replication.throttled.rate=1048576' --entity-type brokers --entity-default

再次执行重分配,或观察正在进行的迁移,速度会显著下降。监控显示 UnderReplicatedPartitions 保持高位的时间大大延长,消费者延迟几乎不变(如果限流得当)。

第三步:动态调节限流

# 提高限流到 100MB/s
kafka-configs.sh --bootstrap-server kafka1:9092 --alter --add-config 'follower.replication.throttled.rate=104857600' --entity-type brokers --entity-default

随后迁移进度明显加快,最终在数分钟内完成。

第四步:重分配过程中的故障恢复测试 在重分配进行时,强制停止某个 Broker(例如新的 Leader 所在 Broker)。观察 Controller 会为这些分区选出新 Leader,UnderReplicatedPartitions 暂时冲高,然后随着迁移继续而回落。最终所有分区成功完成重分配。

6.3 监控关键指标与命令总结

监控指标JMX 路径 / 获取方式说明
未复制分区数kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions值为 0 表示所有副本同步
活跃 Controller 数量kafka.controller:type=KafkaController,name=ActiveControllerCount必须为 1
当前重分配状态kafka-reassign-partitions.sh --bootstrap-server ... --verify列出每个分区进度
消费者延迟kafka.consumer:type=consumer-fetch-manager-metrics,client-id=...观察 records-lag-max
复制速率限制kafka.server:type=ReplicationQuotaManager,name=ThrottledRate当前限流速率

6.4 结论与生产建议

  • 限流至关重要:未限流的分区重分配可能瞬间占满网络带宽,导致生产者的请求超时和消费者的饥饿。
  • 分批次迁移:对于大规模集群,不要一次性重分配所有分区,应分批进行并监控每次的影响。
  • 自动化工具:使用 Cruise Control 能够根据实时负载自动规划迁移并施加适当的限速,比手动脚本更加安全和智能。
  • 回滚方案:如果重分配过程中出现严重问题(如性能急剧下降),可通过提交空 JSON 的 --execute 来取消重分配。

7. 面试高频专题

以下面试题围绕 Controller 的 “选举 — 协调 — 脑裂防护 — KRaft 原理 — 分区重分配”核心链路,每题从直接答案出发逐步深入,便于面试场景快速组织表达。


Q1:Kafka Controller 到底负责哪些事情?

一句话回答:Controller 是 Kafka 集群的唯一协调者,负责分区 Leader 选举、ISR 维护、元数据广播、Broker 上下线处理和 Topic 生命周期管理。

详细解释
Controller 就像集群的“大脑”,它不直接处理消息读写,但任何分布式状态的变更都要经过它。内部维护着一个 ControllerContext,缓存着全部 Broker、Topic、Partition、Replica 的拓扑信息。主要工作包括:

  • 监听 Broker 列表:借助 ZK 临时节点或 KRaft 心跳发现 Broker 的加入与退出。
  • 处理分区变化:当创建/删除 Topic 或增加分区时,Controller 分配副本、确定 Leader,并通知所有相关 Broker。
  • 维护 ISR:根据副本同步进度,动态扩充或收缩 ISR 列表,确保消息不丢失。
  • 分区 Leader 选举:当发现 Leader 故障时,从 ISR 中选取新 Leader 并通过 LeaderAndIsrRequest 下发。
  • 广播元数据:将以上所有变化封装为 UpdateMetadataRequest 推送给所有 Broker,使客户端能正确路由。

多角度追问

  • 追问1:Controller 挂了对集群有什么影响?
    新 Leader 选举、ISR 调整、Topic 创建等操作都无法进行,但已存在的消息生产和消费可以继续。直到其他 Broker 检测到 /controller 临时节点消失或 KRaft 心跳超时,新 Controller 会被选出,这通常需要几秒至十几秒。
  • 追问2:如何知道当前哪个 Broker 是 Controller?
    ZK 模式下执行 get /controller 或在 KRaft 下运行 kafka-metadata-quorum.sh --describe --status。也可以通过 kafka-broker-api-versions.sh 看到输出里的 Controlling: true
  • 追问3:Controller 可以和分区 Leader 共存吗?
    当然可以,常见的 Kafka 集群中 Controller 同时也承担数据 Broker 的角色。它们逻辑上是分开的,Controller 负责元数据,分区 Leader 负责消息 IO。

Q2:ZK 模式下的 Controller 怎么防止脑裂?

一句话回答:依靠 ZooKeeper 的临时节点保证同一时刻只有一个 Controller,并通过全局单调递增的 controller_epoch 拒绝旧 Controller 的任何操作。

详细解释

  • 唯一性:所有 Broker 竞争创建 /controller 临时节点,只有第一个成功的才能成为 Controller。ZK 会保证并发创建只有一个成功。
  • epoch 防护:每次 Controller 变更,会递增一个整数 controller_epoch,并持久化到 ZK。新 Controller 发送的每个控制请求(如 LeaderAndIsrRequest)都带有这个 epoch。Broker 在收到请求时会比较这个 epoch 和本地记录的最新 epoch,如果请求的 epoch 较小,就认为是旧 Controller 发出的“僵尸请求”,直接丢弃。
  • 假死隔离:如果旧 Controller 因为 GC 停顿后来恢复了,它还会误以为自己仍是 Controller,但发出的请求 epoch 会小于 Broker 已经接受的新 epoch,因此会被全面拒绝。它会从 ZK 读取到最新 /controller 节点已变,主动降级。

多角度追问

  • 追问1controller_epoch 是如何递增和传递的?
    新 Controller 在 onControllerFailover() 中调用 zkClient.incrementControllerEpoch() 将 ZK 节点 /controller_epoch 的值加一,然后把这个新 epoch 写进后面所有控制请求的头部里广播给各 Broker。
  • 追问2:假死导致的脑裂风险有哪些缓解手段?
    1. 调小 zookeeper.session.timeout.ms,加快故障检测;2. 选择 G1GC 或 ZGC 等低停顿垃圾回收器;3. 确保 Controller 节点资源充足,避免 swap;4. 升级到 KRaft 模式,从根本上避免 ZK 会话延迟问题。
  • 追问3:会不会出现两个 Controller 同时相信自己是 Leader?
    逻辑上不可能,因为 ZK 临时节点保证了唯一性。即使老 Controller 苏醒后仍自认为是 Leader,它的操作也会因 epoch 过期而被拒绝,不会对集群产生实际影响。因此,虽然短暂会出现“双主幻觉”,但防护机制确保了集群状态不会分裂。

Q3:KRaft 模式比 ZK 模式强在哪里?

一句话回答:移除 ZooKeeper 依赖,内建 Raft 共识,元数据操作更快,故障恢复时间从十秒级缩短到秒级,运维更简单。

详细解释
传统 ZK 模式下,Controller 需要和外部的 ZK 集群通信,元数据存储和通知都依赖 ZK 的 Watcher 机制,故障检测要等待 ZK 会话超时(默认 18 秒)。
KRaft 模式则把这些全都收敛到 Kafka 内部:控制器节点自己组成 Raft 复制组,元数据以 Compacted Topic __cluster_metadata 的形式存在本地。心跳和日志复制共用一个 Fetch 通道,故障检测基于心跳超时,可以配置到 2-6 秒,因此切换极快。同时也不再需要单独维护一套 ZK 集群,部署和监控都大大简化。

多角度追问

  • 追问1:KRaft 的 Quorum 节点数量怎么选?
    必须奇数,生产环境推荐 3 或 5。3 节点可容忍 1 个宕机,5 节点可容忍 2 个。节点越多,写操作的复制延迟越大,所以不是越多越好。
  • 追问2__cluster_metadata 这个内部 Topic 的 Compaction 是怎么工作的?
    与普通 Compacted Topic 完全一样。后台 LogCleaner 线程扫描日志段,对于同一个 Key(比如一个 Topic 的配置)只保留最新的一条记录。结合定期生成的 Snapshot,旧的日志段就可以被安全删除,保证元数据日志不会无限膨胀。
  • 追问3:元数据日志的 Leader 和普通数据分区的 Leader 是一回事吗?
    概念不同。元数据日志的 Leader 是 Raft 协议选出的 Active Controller,负责集群级元数据的写入顺序;普通数据分区的 Leader 是一条具体消息通道的负责人,处理生产消费请求。它们可能运行在同一个 Broker 进程上,但作用域完全不一样。

Q4:ZK 模式下新 Controller 是如何接管集群的?

一句话回答:其他 Broker 发现 /controller 节点消失后,并发竞争创建该临时节点,成功者成为新 Controller,随即递增 controller_epoch,从 ZK 加载全量元数据,初始化内部状态机,并把最新的集群信息广播给所有 Broker。

详细解释
整个过程分为四步:

  1. 故障检测:原 Controller 的 ZK 会话超时,它的 /controller 临时节点被删除,所有 Broker 上注册的 Watcher 收到通知。
  2. 竞选:每个 Broker 收到通知后执行 elect(),尝试创建 /controller 节点。只有第一个成功的成为新 Controller,其余记录当前 Controller ID。
  3. 初始化:新 Controller 运行 onControllerFailover(),做的事包括:从 ZK 读取 /controller_epoch 并递增、加载所有 /brokers/ids/brokers/topics 等信息到 ControllerContext,启动分区状态机和副本状态机,为每一个分区选举 Leader 并形成 ISR。
  4. 广播:构建 UpdateMetadataRequest 和必要的 LeaderAndIsrRequest 发给全部 Broker,使它们的本地缓存与 ZK 中最新元数据对齐。

多角度追问

  • 追问1:如果选举过程中 ZK 连接断了会怎样?
    竞选失败的 Broker 会等待 Watcher 再次触发后重试。已经成功创建的 Broker 如果在广播元数据前 ZK 断连,Controller 会失效,重新选举会发起。
  • 追问2:新 Controller 怎么保证自己拥有最新的元数据?
    因为 ZooKeeper 是所有元数据的权威来源,新 Controller 全量读取 ZK 并重建内存状态即可确保与 ZK 一致。
  • 追问3:旧 Controller 没执行完的请求会怎样?
    那些请求会因 epoch 过期被目标 Broker 拒绝,新 Controller 会基于最新状态重新发送指令覆盖。

Q5:为什么要做分区重分配?手动分配有什么坑?

一句话回答:当集群扩容或负载不均时,通过重分配把分区的副本迁移到合适的 Broker,实现资源平衡。手动执行很容易不均衡,并且缺少限速容易影响业务,还不支持自动回滚。

详细解释
Kafka 的分区分配决定了数据的物理分布。如果不做重分配,新 Broker 可能很长时间没有分区,旧 Broker 压力大。手动使用 kafka-reassign-partitions.sh 的典型问题:

  • 不够智能:只按照简单的轮询分配,无法考虑 CPU、网络、磁盘等实时负载,可能造成新的热点。
  • 容易操作失误:需要手动编写 JSON,容易写错 Broker 列表或分区名,导致迁移失败或数据异常。
  • 缺乏限速保护:如果忘记设置限流,突然的大量数据迁移会占满网络带宽,导致正常生产消费延迟飙升。
  • 难以监控与回滚:出现问题后,只能手动写空计划回退,没有进度监控和自动控制的手段。

多角度追问

  • 追问1:为什么生产环境不推荐手动用 kafka-reassign-partitions.sh
    正是因为它不能感知实时负载、容易犯错、没有细粒度限速和自动化能力,大量分区迁移时风险很高。
  • 追问2:Cruise Control 解决了哪些问题?
    它持续监控 CPU、磁盘、网络、请求数等指标,用数学模型生成优化的分区分布计划;可以按时间窗口设置不同的迁移限速;支持自我修复,当 Broker 故障时可自动重分配分区;提供 REST API 和 Web UI,集成到运维平台。
  • 追问3:重分配过程如果原 Leader 宕机了会怎样?
    Controller 会自动选出新 Leader,正在迁移的副本会转而从新 Leader 拉取数据,迁移任务继续执行,不会中断。

Q6:什么是 Preferred Leader Election?

一句话回答:把分区的 Leader 重新选举到初始分配方案中的第一个副本上,通常用于 Broker 重启后的 Leader 再平衡。

详细解释
每个分区在创建时会有一个优先副本(Preferred Replica),即 AR 列表第一位的副本。当 Broker 故障恢复后,它的副本会成为 ISR 成员但可能不是 Leader,导致 Leader 分布不均。执行 Preferred Leader Election 可以让 Leader 回到优先副本上,优化负载分布。

多角度追问

  • 追问1:怎么触发 Preferred Leader Election?
    手动执行 kafka-leader-election.sh --election-type PREFERRED;或者在 server.properties 中设置 auto.leader.rebalance.enable=true,让 Controller 定时自动执行。
  • 追问2:这样做会影响可用性吗?
    仅有极短暂的 Leader 切换,客户端会自动重试,对业务几乎无感。

Q7:metadata.version 特性门控是什么?

一句话回答:它是 KRaft 模式下控制集群元数据格式版本的开关,实现渐进式升级,避免新旧格式混用导致不一致。

详细解释
Kafka 在不同版本间可能增加新的元数据记录类型或修改现有格式。metadata.version 类似于 inter.broker.protocol.version,它强制集群所有控制器节点就同一个元数据格式版本达成一致。升级时,先把所有节点的二进制升级,将 metadata.version 保持不变;等都升级完了,再通过 kafka-features.sh upgrade --metadata <新版本> 动态启用新特性。这样做确保了升级过程中任何节点都能正确读写元数据。

多角度追问

  • 追问1metadata.versioninter.broker.protocol.version 的区别是?
    前者约束控制器节点间的元数据日志格式,后者约束 Broker 之间的通信协议。从旧版往新版升级时,常需要分步提升这两个版本。
  • 追问2:不更新 metadata.version,新版本 Broker 能跑吗?
    能跑,但只能运行在旧的元数据格式下,无法使用新特性。

Q8:Controller 假死会不会导致消息丢失?

一句话回答:不会。消息持久化由分区 Leader 和 ISR 保证,Controller 假死只短暂影响元数据操作,已提交的消息不会丢失。

详细解释
消息的写入和确认只涉及分区 Leader 和其 ISR,Controller 宕机或假死不影响已经完成的写入。假死期间,如果 Leader 也出现故障,新 Controller 会从 ISR 中选出新 Leader,ISR 内的副本都已同步所有已提交消息。即使最坏情况(ISR 为空且未启用 unclean 选举),分区只会不可用,不会丢失数据。

追问:极端情况下分区不可用怎么办?
一旦有 ISR 副本恢复,Controller 会马上让它重新上线并恢复服务。


Q9:如何监控 Controller 的健康?

一句话回答:重点看 JMX 指标 ActiveControllerCountOfflinePartitionsCount 以及 Controller 日志中的选举和降级事件;KRaft 下用 kafka-metadata-quorum.sh 观察 Leaders 和 epoch。

详细解释
关键信号:

  • ActiveControllerCount:必须为 1,如果变为 0 或频繁抖动,说明出现 Controller 切换或无法选举。
  • OfflinePartitionsCount:应该保持 0,若大于 0 表示有分区没有 Leader,通常是 Controller 故障导致。
  • 观察 Controller 日志中 successfully elected as the controllerresigned 事件,可以判断谁在什么时候成了 Controller。

追问ActiveControllerCount 频繁变 0 可能是什么原因?
常见有:Controller 节点 GC 停顿、ZK 或 KRaft 网络分区、超时配置太小、节点资源耗尽。


Q10:KRaft 模式下 Broker 怎么拿到元数据?

一句话回答:Broker 向 Active Controller 发起 MetadataFetch 请求,拉取完整的元数据快照和增量更新,不直接读 __cluster_metadata 日志。

详细解释
每个 Broker 都维护一个 MetadataCache,Controller 在内存中保存着已提交的元数据记录。Broker 启动或定期向 Active Controller 发送 MetadataFetch 请求,Controller 返回一个基于已提交日志记录的快照以及后续的增量。即使 Active Controller 切换,Broker 会向新的 Raft Leader 重新请求,确保得到最新数据。

追问:为什么 Broker 不直接去读 __cluster_metadata 分区?
因为该分区由 Raft 复制管理,只有 Leader 可以写入并对外提供读服务。Broker 通过 MetadataFetch 可以获得保证一致的视图,避免自己解析复杂日志。


Q11:现在新集群还推荐用 ZK 模式吗?

一句话回答:不推荐,KRaft 已经是生产就绪,未来 ZK 模式会被移除,新集群应该直接用 KRaft。

详细解释
从 Kafka 3.3 开始 KRaft 就已经标记为生产可用,它移除了外部依赖,提供了更快的恢复和更简单的运维。《Kafka 3.6》中已经计划将 ZK 模式标记为弃用,并在后续主版本中彻底移除。因此,任何新建集群都应采用 KRaft。

追问:可以混合模式迁移吗?
不可以,必须搭建新的 KRaft 集群,通过数据镜像迁移。


Q12(故障排查题):集群频繁发生 Controller 切换,从哪些方面排查?

一句话回答:依次检查 GC 日志、网络延迟与丢包、ZK/KRaft 超时配置、操作系统资源使用,定位是停顿还是通信问题。

详细解释与排查路径

  1. GC 停顿:查看 Controller 节点的 GC 日志,如果有长时间 Full GC 且时间点与切换吻合,说明是 GC 假死导致。对策:改用低停顿 GC,合理设置堆内存。
  2. 网络问题:检查 Controller 与 ZK(或 KRaft 对端)之间的 ping 延迟和丢包率,高的网络抖动会触发心跳超时。对策:优化网络路径或适当增大超时。
  3. 超时配置不合理zookeeper.session.timeout.ms 设得过小(如 4s),稍有一点网络延迟就超时。KRaft 下 controller.quorum.fetch.timeout.ms 也要与网络 RTT 匹配。
  4. 系统资源:用 topiostat 确认 CPU 是否被打满、磁盘 IO 是否阻塞,这些都会影响心跳发送。
  5. 查看 Broker 日志:搜索 Ignoring LeaderAndIsr ... epoch ... smaller 等记录,确认是否因旧 Controller 产生冲突。

追问:如果根本原因解决不了,怎么应急?
临时调大超时参数(如 zookeeper.session.timeout.ms 或 KRaft 的 fetch.timeout.ms)来减少选举次数,但这会延长故障检测时间;长期根治还是要解决 GC 或网络。


速查表

特性ZooKeeper 模式KRaft 模式
元数据存储ZK 节点__cluster_metadata Compacted Topic
Controller 选举ZK 临时节点竞争Raft 投票
故障检测ZK 会话超时(默认 18s)心跳超时(可配置,默认 2s)
脑裂防护controller_epochTerm + controller_epoch
心跳机制独立心跳 + ZK 会话复用 Fetch 请求
恢复速度秒级(依赖 ZK 超时)亚秒级~秒级
部署依赖需额外维护 ZK 集群无外部依赖
元数据广播LeaderAndIsr + UpdateMetadataMetadataFetch 拉取
迁移复杂度需维护双系统新集群直接启动

延伸阅读

本文从底层原理到生产故障模拟,全面拆解了 Kafka Controller 的选举、协调与高可用机制。掌握这些知识,你将能以架构师的视角设计、运维和故障排查大规模 Kafka 集群。