《Java 消息中间件·Kafka 内核与客户端》4.3 Leader 选举与不干净选举

停掉 leader 之后,分区多久能用?答案取决于集群能不能「干净」地选出新 leader。本节实测三节点 KRaft:停 leader 后 1.8 秒新 leader 就位、数据零丢失;但停掉两个 broker 会让 controller quorum 失去多数派,整个集群连元数据都查不了——这是 KRaft 和 ZooKeeper 时代最容易被忽略的差别。

一次 leader 切换的现场

先看一段本机记录。三节点集群,topic 是 1 分区 3 副本,当前 leader 在 broker 2。执行:

docker stop k2

然后每 0.5 秒查一次分区状态,本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 三节点 / 2026-10-29):

=== 当前 leader ===
Leader: 2

t=1.8s Leader: 3 Isr: 1,3
新 leader 就位,耗时 1.8s

1.8 秒。这期间发生的事:broker 2 优雅关闭并注销,controller 得知分区 0 的 leader 不在了,从 ISR 里挑一个副本,把它提升为新 leader。

写完的数据一条没丢,因为新 leader 是从 ISR 里选的,而 ISR 里的副本按定义都拥有到 HW 为止的全部消息。这正是 4.1 节讨论的「已提交」概念在起作用。

但并不是每次选举都能这么干净。Kafka 里有一类选举,选出来的新 leader 会丢数据,而且不报错——这类选举叫「不干净选举」(unclean leader election)。

这一节讲清三件事:分区 leader 是怎么选的、什么时候选举会变脏、以及为什么本机这个三节点集群做不出不干净选举。

两个 leader,不要混

Kafka 集群里有两种 leader,它们完全独立:

角色管什么挂了会怎样
controller leader集群元数据:topic 创建、分区分配、ISR 变更、leader 选举元数据操作停摆,无法建 topic、无法选举
分区 leader某个分区的读写该分区短暂不可用,其他分区不受影响

本机同一时刻的两种 leader:

== controller leader ==
LeaderId:               1

== 分区 unclean.demo:0 的 leader ==
Leader: 2

controller leader 在 broker 1 上,分区 leader 在 broker 2 上,两者毫无关系。

这个区分在 4.x 尤其重要,因为 KRaft 模式下 controller 已经内嵌在 broker 进程里。三个节点既是 broker 又是 controller(process.roles=broker,controller),所以一次 docker stop 同时影响两个层面:它既可能触发分区重选,也消耗掉一个 controller 投票权。

后面会看到,正是这一点让三节点集群的容错边界变得比想象中窄。

分区 leader 是怎么选的

触发选举的原因有几种:

  1. 当前 leader 下线(停机、崩溃、被移出 ISR);
  2. 手动执行 kafka-leader-election.sh;
  3. preferred leader 回切(见下)。

选谁?规则不复杂:从 ISR 里挑。ISR 里可能有多个候选,Kafka 用 replica-selection 策略选,实际效果是尽量让 leader 在副本之间均衡分布。

如果 ISR 是空的——比如所有副本都挂了——默认情况下分区会进入「无 leader」状态(Leader: -1),不可读写,等 ISR 里的副本回来。

这里有个容易被忽略的细节:副本列表的顺序有意义。创建 topic 时指定的副本,第一个叫 preferred leader。集群会定期检查(auto.leader.rebalance.enable,默认 true)preferred leader 是否在线且在自己的位置上,如果不在,就做一次「回切」。

回切的意义是负载均衡:如果 broker 1 反复重启,它上面的分区 leader 会逐渐漂到其他 broker 上,集群变得不均衡。回切把分区领导权拉回原设计的位置。

代价是回切本身是一次 leader 切换,有短暂不可用。生产环境里,有人会把 auto.leader.rebalance.enable 关掉,改用运维窗口手动执行:

kafka-leader-election.sh --bootstrap-server k1:9092 \
  --election-type preferred --all-topic-partitions

选举之后,数据为什么没丢

上面那次 1.8 秒的切换,数据零丢失。原因链条是这样的:

  1. 新 leader 从 ISR 里选;
  2. ISR 里的每个副本,按定义都拥有到 HW 为止的全部消息;
  3. 消费者最多只能读到 HW;
  4. 所以消费者读过的所有消息,新 leader 都有。

这条链条的每一环都成立时,选举是「干净」的。断开任何一环,就可能丢数据。

最脆弱的一环是第 2 条。ISR 里的副本真的拥有到 HW 的全部消息吗?在正常情况下是的。但如果集群配置了允许不干净选举,ISR 里可能一个副本都没有,此时只能从「不在 ISR 但还活着」的副本里选——那些副本的日志可能落后很多。

不干净选举:当 ISR 全灭

设想一个具体场景。分区有 3 个副本 A、B、C,A 是 leader。生产者在持续写入,HW 推到 1000。

现在 A 和 B 同时失联(机器故障、机架断电、网络分区)。C 一个人留在 ISR 里,继续接受写入,HW 从 1000 推到 1200。

然后 C 也挂了。此刻 ISR 为空,分区离线。

过了一阵,A 回来了。A 的日志只到 1000。

此时有两个选择:

选择一(默认):等 C 回来。分区继续离线,直到 C 恢复,C 成为 leader,1200 之后的数据还在。代价是这段时间该分区完全不可用。

选择二(unclean):让 A 立刻当 leader。A 的日志只到 1000,所以 HW 从 1000 开始。offset 1000 到 1199 的二百条消息消失了,而且不会有任何错误——消费者只是发现「最新位点变成了 1000」。

选择二就是 unclean.leader.election.enable=true。

这个参数的默认值是 false,也就是宁可停服也不丢数据。但在某些场景下,团队会打开它:

  • 业务允许丢数据,但不能停服(比如埋点、日志聚合);
  • 分区长时间不可用的损失大于数据丢失的损失。

打开它之前必须想清楚一件事:丢消息是静默的。消费者不会收到异常,位点只是「变小了」或「不再前进」,事后很难发现,更难定位。

本机这个参数是可以设的:

kafka-topics.sh --bootstrap-server k1:9092 --create --topic unclean.demo \
  --partitions 1 --replication-factor 3 \
  --config min.insync.replicas=1 \
  --config unclean.leader.election.enable=true

topic 创建成功,配置也写进去了:

Configs: min.insync.replicas=1,unclean.leader.election.enable=true

但本机做不出不干净选举

要触发不干净选举,必须让 ISR 全部不可用。本机三节点集群里,这意味着停掉全部三个 broker 里的两个——先停 leader 和某个 follower 让 ISR 缩到 1,再停掉最后一个。

我照做了,结果不是「分区离线」,而是整个集群不可用:

$ docker stop k3
$ docker stop k1
$ docker exec k2 kafka-topics.sh --bootstrap-server k2:9092 --describe --topic unclean.demo

Error while executing topic command :
java.util.concurrent.ExecutionException:
  org.apache.kafka.common.errors.TimeoutException:
  Call(callName=listPartitionReassignments, ...) timed out after 1 attempt(s)

原因在 controller quorum:三个节点都是 CurrentVoters,停掉两个之后只剩一个投票者,凑不出多数派。controller 无法选举出 controller leader,也就无法处理任何元数据请求——包括「这个分区的 leader 是谁」这种只读查询。

CurrentVoters: [{"id":1,...},{"id":2,...},{"id":3,...}]

这个限制值得单独说,因为它和 ZooKeeper 时代的直觉相反。

在 ZooKeeper 方案里,broker 和 ZK 是两个独立的集群。三个 broker 加三个 ZK 节点,停掉两个 broker 只影响 broker 侧,ZK 的三节点 quorum 完好,元数据照常服务,你还能用命令行查状态、做运维。

KRaft 把 controller 内嵌进 broker 之后,停两个 broker 就同时停掉两个 voter。三节点集群的真实容错边界是 1 个节点,不是 2 个。

所以本机无法在这个拓扑上演示不干净选举。要演示,需要:

  • 方案 A:至少 5 个节点,其中 3 个是 dedicated controller(process.roles=controller),broker 单独部署。停掉 2 个 broker 时 controller quorum 仍是 3 个中的 3 个。
  • 方案 B:单节点集群,replication.factor=1,用 kill -9 加日志截断来模拟。但这不是真正的 unclean 选举,只是数据丢失。

本书采用方案 A 的描述性说明,不在本机执行。给出预期形态:

# 预期(未在本机执行)
1. 停掉 leader 与一个 follower,ISR 剩 1 个副本 C
2. 向 C 写入 200 条,HW 推进
3. 停掉 C,分区 Leader: -1
4. 启动 A(日志落后 200 条,且 unclean=true)
5. 分区 Leader: A,HW 回退到 200 条之前
6. 消费者从 A 读到的最新位点少了 200

最后一行就是代价。它是本卷唯一无法实跑的核心实验,原因不是命令写错,是拓扑不够。

回切的实测与手动选举的边界

preferred leader 这个概念,本机可以完整演示一遍。unclean.demo 的副本列表是 3,1,2,所以 preferred leader 是 broker 3。但因为之前停过 broker 3,分区领导权漂到了 broker 2:

Topic: unclean.demo  Partition: 0  Leader: 2  Replicas: 3,1,2  Isr: 1,2,3

执行回切:

kafka-leader-election.sh --bootstrap-server k1:9092,k2:9092,k3:9092 \
  --election-type preferred --topic unclean.demo --partition 0

本机输出:

Successfully completed leader election (PREFERRED) for partitions unclean.demo-0

再看:

Topic: unclean.demo  Partition: 0  Leader: 3  Replicas: 3,1,2  Isr: 1,2,3

leader 从 2 回到 3,ISR 没有变化,说明这是一次在 ISR 内部的领导权转移,不涉及副本进出。

这里有个值得注意的细节:--election-type 只有两个取值,PREFERRED 和 UNCLEAN,没有「指定某个副本当 leader」的选项。

--election-type <[PREFERRED,UNCLEAN]: Type of election to attempt. Possible

想强制让某个特定副本当 leader,没有直接命令。能做的只有:等它在 ISR 里排到副本列表第一位(即控制 Replicas 的顺序,通常要重新分配分区),然后回切。

UNCLEAN 这个取值的存在本身值得警惕:它意味着运维可以手动触发一次不干净选举。如果集群里存在「不在 ISR 但活着」的副本,一条命令就能让日志更短的副本上位,静默丢掉它没有的那部分数据。自动化脚本里绝不要出现这个参数,除非你明确知道当时 ISR 的构成。

ELR:4.x 给不干净选举加的第三档

4.1 节看 --describe 输出时提过,4.x 多了 Elr 和 LastKnownElr 两列。本机输出里这两列一直是空的:

Isr: 1,2,3  Elr:   LastKnownElr:

它们对应 Kafka 4.0 引入的 ELR(Eligible Leader Replicas,合格 leader 副本)。

ELR 想解决的问题正是前面那个两难:ISR 全灭时,要么停服等 ISR 回来,要么做不干净选举丢数据。ELR 在两者之间加了第三档——把「不在 ISR、但已经追到曾经 HW 的副本」单独记录下来。这些副本虽然落后于当前 LEO,但它们拥有的数据是「曾经被提交过的」。

当 ISR 真的全灭时,ELR 里的副本可以安全地当 leader,因为它们至少不丢已提交数据。这比不干净选举安全,比干等可用性更好。

要让 ELR 生效,需要两个条件:

  1. topic 或 broker 开启 eligible.leader.replicas.enable=true;
  2. 分区经历过 ISR 收缩,让「曾经的 ISR 成员」进入 ELR。

本机从头到尾没开这个配置,所以两列始终为空。这不是版本问题,是配置没开。

ELR 目前:

  • 对生产者/消费者完全透明,不需要改客户端;
  • 不属于默认行为,需要显式开启;
  • 文档和社区实践还不多,本机也没有对应的可运行实验。

所以本节只交代它是「干净」与「不干净」之外的第三档,具体行为按官方文档(KIP-966)为准,本书不给出实测数字。提到它的理由是:读 4.x 的 --describe 输出时,空着的两列不该让你困惑。

一次受控重启的顺序

理解了「停 leader → 1.8 秒重选」,就能推出运维上一个实用结论:重启顺序决定停机时长。

假设要重启全部三个 broker。两种顺序:

顺序一:随机停,随机起。

很可能先停到 leader,分区立刻进入重选;再停第二个,又触发一次;第三个停掉,controller quorum 也失去多数派,集群整体不可用。等它们陆续起来,还要经历一轮又一轮选举和 ISR 重建。

顺序二:先停 follower,再停 leader。

1. 停 follower A          → ISR 收缩到 2,分区仍可用,不触发选举
2. 起 A,等 ISR 回到 3    → 无影响
3. 停 follower B          → 同上
4. 起 B,等 ISR 回到 3
5. 停 leader C            → 触发一次选举,1.8 秒级
6. 起 C,等 ISR 回到 3

整个过程只发生一次 leader 选举,其余时间分区都在正常服务,controller quorum 任何时候都至少有两个投票者在线。

顺序二的关键是第 2、4 步的等待:必须等 ISR 恢复到三个副本再动下一个。如果 ISR 只有两个就接着停,min.insync.replicas=2 的 topic 会直接拒绝写入。

这条经验适用于所有 KRaft 三节点集群,代价只是慢一点。

客户端怎么感知切换

leader 切换对客户端是透明的,但透明不等于无感。生产者的处理路径是:

  1. 向旧 leader 发请求,收到 NotLeaderForPartitionException 或连接断开;
  2. NetworkClient 把该节点标记为不可用,触发元数据刷新;
  3. 拿到新 leader 的地址,重发。

这里的关键参数是 retries 和 delivery.timeout.ms。默认 delivery.timeout.ms=120000(2 分钟),只要切换在这段时间内完成,生产者重试就能成功。本机那次 1.8 秒的切换,远在这个窗口内。

消费者侧稍复杂:如果切换发生在 poll 过程中,会收到 NotLeaderForPartitionException,消费者会自动刷新元数据重新拉取。需要额外注意的是位点提交——如果消费者在切换瞬间提交位点,可能提交到旧 leader 而被拒绝,重试即可。

一个常见误解是「leader 切换会导致重复消费」。准确的表述是:切换本身不产生重复,但切换导致的位点提交失败重试可能产生重复。这也是 7.x 讲幂等和事务的动机之一。

三个实践判断

第一,unclean.leader.election.enable 保持默认 false。 除非你能明确回答「这个 topic 丢数据可以接受吗」,否则不要打开。打开了要配合监控:分区的 HW 出现回退是唯一信号,而这个信号不会出现在业务日志里。

第二,三节点 KRaft 只能容忍 1 个节点故障。 规划可用性时按这个算,不要按「3 副本能挂 2 个」算。要容忍 2 个,需要 5 节点(controller quorum 3/5)或 dedicated controller 部署。

第三,leader 切换的耗时是观测项,不是配置项。 本机 1.8 秒是优雅关闭下的数字。崩溃场景(进程被 kill -9)要走 broker session 超时,默认 9 秒起步。如果你的业务对分区不可用时间敏感,先测清楚崩溃场景的真实耗时,再决定副本数和部署拓扑。

小结

三句话收束这一节:

  1. 分区 leader 和 controller leader 是两个独立的角色,KRaft 把 controller 内嵌进 broker 之后,两者的故障域重叠了。
  2. 干净选举从 ISR 里选,不丢已提交数据;不干净选举从 ISR 外选,静默丢数据,默认关闭。
  3. 三节点 KRaft 的容错边界是 1 个节点,因为停两个 broker 会让 controller quorum 失去多数派。这一点和 ZooKeeper 时代的直觉不同。

副本、ISR、HW、选举,这四件事构成本卷第一部分的「可靠性底座」。下一章转向控制平面:KRaft 的 controller 到底怎么工作,元数据如何从 controller 传播到每一个 broker 和客户端。5.1 节从 controller 的 raft 日志里把元数据记录一条条解出来。

阅读导航:上一节 4.2 ISR 收缩与 min.insync.replicas | 下一节 5.1 KRaft:控制器与元数据日志

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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