同一个集群,两种停法
三节点 KRaft,topic 是 1 分区 3 副本,min.insync.replicas=2。现在要重启所有三个节点。
有两种顺序:
- 顺序 A:先停两个 follower,每次等 ISR 恢复到三个再停下一个,最后停 leader;
- 顺序 B:先停 leader,再停刚选出来的新 leader。
两种顺序都能完成重启,但过程完全不同。这一节用同一套探针把两种顺序各跑一遍,记录 ISR 变化、leader 切换次数和生产者看到的异常。
探针是一个 Java 生产者:acks=all、enable.idempotence=true、每秒发一条、delivery.timeout.ms=20000、request.timeout.ms=5000、max.block.ms=5000。后三个参数是特意缩短的,否则一次失败要等默认的 120 秒才暴露,实验窗口不够。下面的异常与耗时都基于这组参数,换回默认值后时间尺度会变长。
先看清三个配置
在动手之前,先把和重启窗口有关的三个配置查出来:
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--describe --entity-type brokers --entity-name 1 --all
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 三节点 / 2026-10-29),过滤后的输出:
controlled.shutdown.enable=true synonyms={DEFAULT_CONFIG:controlled.shutdown.enable=true}
broker.session.timeout.ms=9000 synonyms={DEFAULT_CONFIG:broker.session.timeout.ms=9000}
replica.lag.time.max.ms=30000 synonyms={DEFAULT_CONFIG:replica.lag.time.max.ms=30000}
min.insync.replicas=2 synonyms={DYNAMIC_DEFAULT_BROKER_CONFIG:min.insync.replicas=2, STATIC_BROKER_CONFIG:min.insync.replicas=2, DEFAULT_CONFIG:min.insync.replicas=1}
三个值分别管三件事:
| 配置 | 本机值 | 作用 |
|---|---|---|
controlled.shutdown.enable | true | broker 退出前主动把 leader 迁走并注销自己 |
broker.session.timeout.ms | 9000 | broker 心跳超时;进程被强杀后,controller 多久发现它没了 |
replica.lag.time.max.ms | 30000 | follower 多久不 fetch 就被踢出 ISR |
controlled.shutdown.enable=true 是默认值,本机没有显式设置。它的效果在后面的 docker stop 与 docker kill 对比里能直接看到。
另外注意一个 4.x 的变化:3.x 时代常见的 controlled.shutdown.max.retries 和 controlled.shutdown.retry.backoff.ms,在本机 4.3.0 的 broker 配置清单里已经查不到。用 --all 过滤 shutdown 只有 controlled.shutdown.enable 一项。升级脚本里如果还依赖这两个参数,要先确认当前版本是否还接受它们,不要照着旧文档配。
顺序 A:先 follower,后 leader
topic 的初始状态是 Leader: 1 Replicas: 1,3,2 Isr: 1,2,3。leader 是 broker 1,follower 是 3 和 2。
按 3 → 2 → 1 的顺序重启,每次重启后等 ISR 回到三个副本再动下一个。本机时间线:
12:41:54 docker restart mq-d3(follower)
ISR: 1,2,3 → 1,2 立即收缩
12:42:06 ISR: 1,2 → 1,2,3 12 秒恢复
12:42:19 docker restart mq-d2(follower)
ISR: 1,2,3 → 1,3 立即收缩
12:42:31 ISR: 1,3 → 1,2,3 12 秒恢复
12:42:44 docker restart mq-d1(leader)
12:42:45 leader: 1 → 3,ISR: 2,3 触发一次选举
12:42:53 ISR: 2,3 → 1,2,3 9 秒恢复
整个过程只发生一次 leader 选举,就是最后停 leader 的那一下。前两次停 follower 只让 ISR 从 3 缩到 2,分区始终可读写。
同一个窗口里,生产者探针的记录:
共 200 次 send().get(),200 次成功,0 次失败
最长一次 43ms(发生在 leader 切换那一秒)
其余绝大多数在 5–30ms
零失败。 原因不复杂:任何时刻 ISR 都至少有 2 个副本,满足 min.insync.replicas=2;leader 切换期间虽然有一次短暂的不可用,但重试在 43 毫秒内就完成了。
顺序 A 的关键不是「先停 follower」这句话本身,而是每次只动一个,并且等 ISR 恢复再动下一个。如果停完 d3 不等恢复就停 d2,ISR 会直接掉到 1,写入立刻被拒。
那 12 秒里发生了什么
三次重启的恢复耗时分别是 12 秒、12 秒、9 秒。容器本身在 3 秒内就回到了 Up 状态:
12:41:54 restart 开始
12:41:57 docker restart 返回(进程已启动)
12:42:06 ISR 恢复
也就是说,「进程起来了」和「副本可用了」之间还有 9 秒。这段时间至少包含三段:broker 启动并重新注册、follower 把停机期间落后的日志追平、controller 把副本加回 ISR。本机只记录了首尾两个时间点,中间没有逐段埋点,所以不给出各段的拆分数字。
这 9 秒是滚动重启真正的等待成本。十台机器的集群按这个节奏走,光等待就要一分半以上。想缩短只能减少每台机器落后的数据量(停机时间短、写入量小),或者接受更小的 ISR 余量——后者会直接吃掉故障容忍度,不划算。
顺序 B:先停 leader
同样的集群,换一个顺序。当前 leader 是 broker 3,先停它:
12:44:16 docker restart mq-d3(leader)
12:44:18 leader: 3 → 1,ISR: 1,2 选举 1,ISR 掉到 2
12:44:27 docker restart mq-d1(刚选出的新 leader)
12:44:29 leader: 1 → 2,ISR: 2 选举 2,ISR 掉到 1
12:44:31 ISR: 2 → 2,3
12:44:44 ISR: 2,3 → 1,2,3 全部恢复
两次重启触发了两次 leader 选举,中间还有一段 ISR 只剩 broker 2 的窗口。生产者在那个窗口里的记录:
12:44:29 OK offset=184 2658ms
这条消息最终成功了,但耗时 2.6 秒。它正好落在 ISR=1 的窗口里:leader 收到写入后返回 NOT_ENOUGH_REPLICAS,生产者的默认 retries=2147483647 把它当作可重试错误,退避后重发,等 ISR 恢复才成功。
这就是「顺序错了但没报错」的典型形态:客户端没有抛异常,只是延迟变长。 如果业务只看错误率,这次重启看起来完全正常;只有看 P99 延迟或者看 broker 的 ISR 曲线,才能发现中间有一段单副本窗口。
顺序 B 的第二个问题在控制面:停掉两个 broker 时,三节点 KRaft 的 controller quorum 也只剩一个投票者,凑不出多数派。本机这次之所以还能读到 describe 输出,是因为第二个节点是优雅关闭,ISR 收缩请求在关闭瞬间被 controller 提交了。kill -9 再叠加停一台的组合,本机没有执行;按 4.3 节的实测,controller quorum 失去多数派后,listPartitionReassignments 这类元数据请求会直接超时。
同时能挂几个 broker
这是运维最常问的问题,答案要分成两层。
数据面:min.insync.replicas=2 时只能挂 1 个。 本机实测:
12:45:43 docker stop mq-d3(follower)
ISR: 1,2,3 → 1,2
生产者连续 6 条写入全部成功,耗时 11–33ms
12:45:58 docker stop mq-d1(follower)
ISR: 1,2 → 2
下一条写入 26ms 后失败
失败信息原文:
NotEnoughReplicasException: Messages are rejected since there are fewer
in-sync replicas than required.
从 ISR 掉到 1 到生产者收到异常,只用了 26 毫秒。之后每 2 秒一条,连续 13 条全部是同一个异常:
12:45:58 FAIL NotEnoughReplicasException 26ms
12:46:00 FAIL NotEnoughReplicasException 15ms
12:46:02 FAIL NotEnoughReplicasException 48ms
...
12:46:22 FAIL NotEnoughReplicasException 11ms
12:46:24 OK offset=284 15ms (两个节点恢复后)
控制面:三节点 combined 模式只能挂 1 个。 三个节点同时是 broker 和 controller,停两个就失去 controller quorum 的多数派。这时即使某个分区数据上还能读,集群也无法完成建 topic、改配置、选 leader 这些元数据操作。这个边界在 4.3 节 已经实测过:是 1 个节点,不是 2 个。
两层限制叠在一起,结论是一样的:这套三节点集群的重启窗口里,任何时刻只能有一台机器离线。
docker stop 和 docker kill 的差别
controlled.shutdown.enable=true 到底做了什么,用一个对比就能看清。
优雅关闭:docker stop mq-d3。SIGTERM 到达后,broker 主动注销,ISR 在 250 毫秒的轮询粒度内就收缩了——上一节时间线里,停机和 ISR 变化落在同一秒。
强制杀死:docker kill mq-d3(SIGKILL,没有机会做受控关闭)。本机实测:
12:47:30 docker kill mq-d3
12:47:37 ISR: 1,2,3 → 1,2 约 7 秒后才收缩
12:47:46 docker start mq-d3
12:47:49 ISR: 1,2 → 1,2,3 3 秒恢复
ISR 收缩用了约 7 秒,接近 broker.session.timeout.ms=9000 的量级。差异的来源就是受控关闭:优雅退出会主动告诉 controller「我要走了」,强杀只能等心跳超时。
7 秒本身不算长,但它是每个 broker 每次强杀都要付的代价。滚动重启十个节点,如果都用 kill,累计的不可用窗口和 ISR 抖动会明显放大。
所以 controlled.shutdown.enable 不要关。它的默认值就是 true,除非有非常明确的反向理由,保持默认。
重启窗口里生产者会看到什么
把本机观察到的异常按触发条件列出来:
| 异常 | 触发时机 | 是否可重试 | 客户端表现 |
|---|---|---|---|
NotLeaderOrFollowerException | 请求发到了旧 leader | 是 | 刷新元数据后重发,通常毫秒级 |
RequestTimedOutException | leader 还活着但等不到 ISR 确认 | 是 | 受 request.timeout.ms 约束,默认 30 秒 |
NotEnoughReplicasException | ISR 小于 min.insync.replicas | 是 | leader 立即拒绝,重试直到 ISR 恢复 |
NetworkException | 连接被重置或对端进程消失 | 是 | 重连后重发 |
TimeoutException(send().get() 侧) | 上述重试耗尽 delivery.timeout.ms | 否 | 最终失败,交给业务处理 |
两个容易踩的点:
第一,NotEnoughReplicasException 是可重试的。 默认 retries=2147483647、delivery.timeout.ms=120000,只要 ISR 在 2 分钟内恢复,业务代码根本看不到这个异常,只会看到延迟尖峰。前面顺序 B 里那次 2.6 秒的写入就是例子。不要把「没报错」当成「没发生」。
第二,delivery.timeout.ms 决定最终失败的时间尺度。 本机探针把它压到 20 秒,所以顺序 B 的窗口里还能成功;如果重启导致 ISR 长时间不恢复,超过这个阈值后 send().get() 才会抛 TimeoutException。生产上要按「最长可接受的重启窗口」来设这个值,而不是反过来。
消费者侧的影响小一些:leader 切换会让正在进行的 fetch 失败一次,客户端刷新元数据后重拉。真正需要注意的是位移提交——位移提交发给消费组协调器,不是分区 leader;如果重启的正好是协调器所在的 broker,提交会收到 CoordinatorNotAvailableException,客户端重新发现协调器后重试即可。这个话题在第 6 章已经展开,这里不重复。
重启前的三个检查
动手之前先确认三件事,否则顺序再对也会翻车:
# 1. 有没有分区本身就处于 ISR 不完整的状态
kafka-topics.sh --bootstrap-server mq-d1:9092 --describe --under-replicated-partitions
# 2. 目标 topic 的写入门槛
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--describe --entity-type topics --entity-name d1.restart
# 3. controller quorum 的投票者是否齐全
kafka-metadata-quorum.sh --bootstrap-server mq-d1:9092 describe --status
第 1 条输出必须为空。如果重启前就已经有分区 ISR 不完整,说明集群已经在降级运行,这时候再停一台就是在赌运气。
第 2 条确认 min.insync.replicas。本机 topic 是 2,意味着任何时刻必须保留两个同步副本;如果某个 topic 被单独设成了 3,那么连一台 broker 都不能停。
第 3 条看 CurrentVoters 和 LeaderId。三个投票者都在线才说明控制面健康;缺一个时,重启窗口里再失去一个就会丢掉多数派。
一套可执行的重启顺序
把本机的结论整理成步骤,适用于三节点、min.insync.replicas=2 的集群:
1. 记录当前每个分区的 leader 与 ISR
2. 选一个 follower,重启它
3. 轮询 describe,等该分区的 ISR 恢复到 RF 个副本
4. 重复 2–3,直到只剩 leader
5. 重启 leader,接受一次选举
6. 等 ISR 恢复到 RF 个副本
7. 检查所有分区的 ISR 与 under-replicated 数量归零
第 3 步是整套流程的核心。没有这一步,「先停 follower」只是把两次故障叠在一起,和随机停没有区别。
轮询可以用命令行:
kafka-topics.sh --bootstrap-server mq-d1:9092 \
--describe --under-replicated-partitions
输出为空才代表所有分区都恢复了。不要只看某一个分区的 describe,也不要用「进程起来了」代替「ISR 恢复了」——本机的时间线里,容器在 3 秒内就回来了,ISR 却要 9–12 秒才恢复。
三个实践判断
第一,重启顺序决定选举次数,不决定数据安全。 顺序 A 只选举一次,顺序 B 选举两次;两种顺序在 min.insync.replicas=2 且每次只停一个的前提下都不会丢数据。顺序的价值在于缩短不可用窗口,不是防止丢数据。
第二,三节点集群的重启窗口只能容忍一台机器离线。 这既是 min.insync.replicas=2 的数据面限制,也是 controller quorum 的控制面限制。要同时维护两台,必须先把副本数或 controller 拓扑升级,而不是调小 min.insync.replicas。
第三,优雅关闭和强杀的差别是可观测的。 本机 ISR 收缩从「同一秒」变成「约 7 秒」,就是 controlled.shutdown.enable 的作用。滚动重启时用 docker stop / systemctl stop 这类能发 SIGTERM 的方式,不要用 kill -9。
小结
三句话收束这一节:
- 先 follower 后 leader、每次等 ISR 恢复,是这套集群唯一安全的滚动顺序。 本机 200 次写入零失败,只发生一次选举。
min.insync.replicas=2时同时只能挂一个 broker。 ISR 掉到 1 的那一刻,写入 26 毫秒内被拒,异常是NotEnoughReplicasException。- 三节点 KRaft 的控制面容错边界也是 1 个节点。 数据面和控制面叠在一起,没有任何操作空间允许两台同时离线。
重启解决的是「计划内维护」,下一节处理「计划外落后」:一个 broker 停机一段时间,日志落后几百 MB,重新上线后怎么追、追多快、要不要限流。
阅读导航:上一节 13.1 集群拓扑与机架感知 | 下一节 13.3 副本重建与限流
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。