一次 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 是怎么选的
触发选举的原因有几种:
- 当前 leader 下线(停机、崩溃、被移出 ISR);
- 手动执行
kafka-leader-election.sh; - 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 秒的切换,数据零丢失。原因链条是这样的:
- 新 leader 从 ISR 里选;
- ISR 里的每个副本,按定义都拥有到 HW 为止的全部消息;
- 消费者最多只能读到 HW;
- 所以消费者读过的所有消息,新 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 生效,需要两个条件:
- topic 或 broker 开启
eligible.leader.replicas.enable=true; - 分区经历过 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 切换对客户端是透明的,但透明不等于无感。生产者的处理路径是:
- 向旧 leader 发请求,收到
NotLeaderForPartitionException或连接断开; NetworkClient把该节点标记为不可用,触发元数据刷新;- 拿到新 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 秒起步。如果你的业务对分区不可用时间敏感,先测清楚崩溃场景的真实耗时,再决定副本数和部署拓扑。
小结
三句话收束这一节:
- 分区 leader 和 controller leader 是两个独立的角色,KRaft 把 controller 内嵌进 broker 之后,两者的故障域重叠了。
- 干净选举从 ISR 里选,不丢已提交数据;不干净选举从 ISR 外选,静默丢数据,默认关闭。
- 三节点 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:控制器与元数据日志
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。