《Java 消息中间件·Kafka 内核与客户端》13.2 滚动重启与最小可用

三节点逐个重启,先停 follower 还是先停 leader,差别有多大?本机实测:按 follower→leader 顺序,200 次写入零失败、最长 43ms;按 leader→新 leader 顺序,多了一次选举、一次写入卡了 2.6 秒。而 min.insync.replicas=2 时,再停一台,生产者 26 毫秒内收到 NotEnoughReplicasException。

同一个集群,两种停法

三节点 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.enabletruebroker 退出前主动把 leader 迁走并注销自己
broker.session.timeout.ms9000broker 心跳超时;进程被强杀后,controller 多久发现它没了
replica.lag.time.max.ms30000follower 多久不 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是刷新元数据后重发,通常毫秒级
RequestTimedOutExceptionleader 还活着但等不到 ISR 确认是受 request.timeout.ms 约束,默认 30 秒
NotEnoughReplicasExceptionISR 小于 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。

小结

三句话收束这一节:

  1. 先 follower 后 leader、每次等 ISR 恢复,是这套集群唯一安全的滚动顺序。 本机 200 次写入零失败,只发生一次选举。
  2. min.insync.replicas=2 时同时只能挂一个 broker。 ISR 掉到 1 的那一刻,写入 26 毫秒内被拒,异常是 NotEnoughReplicasException。
  3. 三节点 KRaft 的控制面容错边界也是 1 个节点。 数据面和控制面叠在一起,没有任何操作空间允许两台同时离线。

重启解决的是「计划内维护」,下一节处理「计划外落后」:一个 broker 停机一段时间,日志落后几百 MB,重新上线后怎么追、追多快、要不要限流。

阅读导航:上一节 13.1 集群拓扑与机架感知 | 下一节 13.3 副本重建与限流

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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