《Java 消息中间件·可靠消息与消费治理》14.1 故障注入:broker 宕机

三节点集群停掉一台,5 万条消息里 0 条失败,停机那 5 秒的平均延迟只从 5.8 毫秒升到 6.3 毫秒——因为 ISR 在 3 秒内收缩到 2,leader 自动换了人。同一时刻另一个 min.insync.replicas=3 的主题在 740 毫秒内重试三次后被拒。两种结果的区别不在故障,在配置。

停一台 broker,什么都没发生

这是可靠性工作里最反直觉的一幕:你把一台 broker 停掉,监控上应该红灯一片,实际上生产端几乎无感,吞吐不掉,延迟只涨了不到一毫秒。

如果确实如此,说明配置做对了。如果生产端大面积报错,说明某个环节的保护没生效——而这正是本节的观测目的:不是去证明 Kafka 能扛住宕机,而是去确认你的这套配置确实扛住了。

本节用一个三节点集群做一次完整的停机与恢复,记录四组数据:生产端的结果、ISR 的收缩与恢复时间线、leader 的换人过程,以及一个对照组——同样停机,但 min.insync.replicas=3 的主题会怎样。

实验设置

三节点 KRaft(KAFKA_NODE_ID 1/2/3),单机容器。两个主题,都是 3 分区、副本数 3:

主题min.insync.replicas用途
rel14.probe2模拟「标准档」配置,停一台应继续可写
rel14.strict3对照组,停一台应拒写

生产者用 kafka-producer-perf-test.sh:

/opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic rel14.probe --num-records 50000 --throughput 500 --record-size 512 \
  --producer-props bootstrap.servers=127.0.0.1:9092 \
    acks=all enable.idempotence=true \
    delivery.timeout.ms=30000 request.timeout.ms=10000 retries=2147483647

500 条每秒的速率是为了让延迟数字可读——跑满带宽时,任何抖动都会被排队效应放大,看不出停机本身的影响。

时间线:生产者启动后第 20 秒 docker stop 掉 k2,第 60 秒 docker start 回来,生产者跑满 100 秒。

生产端:50000 条,0 失败

最终的汇总行:

50000 records sent, 499.890024 records/sec (0.24 MB/sec), 7.07 ms avg latency,
583.00 ms max latency, 6 ms 50th, 11 ms 95th, 22 ms 99th, 248 ms 99.9th.

50000 条全部发出,没有一条失败。

注意 583.00 ms max 出现在第一段——那是启动时的元数据获取与连接建立,不是停机造成的。把每 5 秒的分段统计摊开,停机窗口的位置就清楚了:

时间段条数平均延迟最大延迟发生了什么
第 1 段249731.2 ms583.0 ms启动抖动
第 2 段25047.7 ms45.0 ms稳态
第 3 段25005.8 ms21.0 ms稳态
第 4 段25006.3 ms28.0 ms停机发生在这里
第 5 段25036.9 ms34.0 msISR 已收缩
第 6 段24996.5 ms21.0 ms稳态

停机那一段的平均延迟是 6.3 毫秒,比稳态的 5.8 毫秒只高了 0.5 毫秒。最大延迟 28 毫秒,甚至比启动段的小得多。

这不是因为停机「很温柔」,而是因为停机在那个瞬间只影响了 3 个分区里的 1 个(k2 是分区 2 的 leader),其余两个分区的 leader 在别的节点上,完全没受影响。

唯一可见的异常是两行 WARN:

[06:17:47,186] WARN [Producer clientId=perf-producer-client] Got error produce response
  with correlation id 2784 on topic-partition rel14.probe-2, retrying (2147483646 attempts left).
  Error: NOT_LEADER_OR_FOLLOWER
[06:17:47,191] WARN [Producer clientId=perf-producer-client] Received invalid metadata error
  in produce request on partition rel14.probe-2 due to NotLeaderOrFollowerException ...

两条都指向 rel14.probe-2——就是 k2 做 leader 的那个分区。客户端拿到 NOT_LEADER_OR_FOLLOWER 后,立刻刷新元数据、找到新的 leader、重试成功。整个过程 5 毫秒。

这 5 毫秒说明了一件事:生产端的重试机制不是「故障后的补偿」,而是「故障时的常规路径」。 只要配了足够的重试预算(这里是 retries 拉满、delivery.timeout.ms=30 秒),一次 leader 切换对业务是不可见的。

为什么只影响三分之一

三个分区、三个 broker,leader 是分散的:

Partition: 0  Leader: 3
Partition: 1  Leader: 1
Partition: 2  Leader: 2   ← k2

停掉 k2,只有分区 2 失去了 leader。分区 0 和 1 的读写完全不受影响——它们的 leader 还在,follower 里虽然有 k2,但 follower 掉线不阻塞写入(只要 ISR 还有 2 个成员)。

这是 Kafka 的 leader 分散机制在起作用:同一个主题的不同分区,leader 会被尽量分配到不同 broker 上。 单台故障的影响面因此被限制在 1/副本数 左右,而不是整主题不可用。

反过来推:如果副本数是 1,这台机器上的所有分区在停机那一刻全部不可用,没有替补。这就是 RF=1 在单点故障面前毫无抵抗力的原因,也是「RF=2 在某些情况下也够」这个说法只在小规模、可接受短暂不可用的场景里成立的原因。

一个实践上的推论:broker 数量与副本数决定了单台故障的影响面。 3 台机器、RF=3,影响 1/3;6 台机器、RF=3,影响仍接近 1/3 但每台承载的分区更少,切换更快。想把影响面降到更低,靠的是增加 broker 数量,而不是继续加副本数。

docker stop 之后到底发生了什么

docker stop 发的是 SIGTERM,容器里的 Kafka 进程收到后开始有序关闭。这个关闭过程决定了 ISR 收缩发生在什么时候。

观测到的时序:

14:17:46  发出 docker stop
14:17:49  采样显示 ISR 已经是 2 个成员(Leader 也换了)
14:17:50  docker stop 返回,容器退出

ISR 在容器真正退出之前 1 秒就收缩了。原因是关闭流程里,broker 先停止对外服务、断开与协调器的连接,然后才持久化状态并退出。对集群来说,这个节点在 14:17:49 就已经「不可用」了,尽管它的进程还活着。

这个细节对故障演练有直接影响:用 docker stop 测出来的恢复时间,包含了一段「优雅关闭」的时间;用 docker kill(SIGKILL)测会更快,因为没有关闭流程。 两者的差别在 14.3 节讨论 RTO 时要算进去——真实故障更接近 kill,计划内重启更接近 stop。

还有一种更特殊的注入方式:docker pause。它冻结进程但不关闭连接,broker 既不发 FIN 也不响应请求。集群会把它当成「活着但不说话」的节点,ISR 靠 replica.lag.time.max.ms 超时才会收缩。这与 14.2 节里消费端 SIGSTOP 的对照实验是同一类故障形态。

ISR 与 leader 的完整时间线

每 2 秒采样一次 kafka-topics.sh --describe,把 ISR 变化抽出来:

14:17:45  Partition: 0  Leader: 3  Isr: 3,1,2
14:17:45  Partition: 1  Leader: 1  Isr: 1,2,3
14:17:45  Partition: 2  Leader: 2  Isr: 2,3,1

14:17:49  Partition: 0  Leader: 3  Isr: 3,1
14:17:49  Partition: 1  Leader: 1  Isr: 1,3
14:17:49  Partition: 2  Leader: 3  Isr: 3,1        ← leader 从 2 变成 3

14:18:37  Partition: 1  Leader: 1  Isr: 1,3        ← 停机期间一直如此
14:18:37  Partition: 2  Leader: 3  Isr: 3,1

14:18:41  Partition: 0  Leader: 3  Isr: 1,2,3      ← 恢复后逐个回来

三条信息:

  1. ISR 收缩发生在 14:17:49。 停机命令是 14:17:46 发出的,docker stop 返回是 14:17:50。也就是说 ISR 在进程真正退出之前就已经收缩了——k2 收到 SIGTERM 后开始关闭,关闭过程中不再响应副本拉取请求。

  2. leader 在同一个采样点就换了人。 分区 2 的 Leader 从 2 变成 3。切换与 ISR 收缩几乎同时发生,不是先收缩再选举两步分开的。

  3. 恢复用了 4 到 6 秒。 14:18:37 启动 k2,14:18:41 分区 0 回到 3 个成员,脚本在第 6 秒确认所有分区都齐了。这 4 到 6 秒里,k2 从零开始追数据——因为停机只有 40 秒、数据量很小,追赶几乎是瞬时的。数据量大时这个时间会长得多,卷一的追赶实验里 100 MiB 用了 5.2 秒,可以按比例外推。

对照组:min.insync.replicas=3 的拒写

同一个停机窗口内,往 rel14.strict(min.insync.replicas=3)写一条消息,结果是三次重试后失败:

[06:17:56,519] WARN Got error produce response with correlation id 6 on topic-partition
  rel14.strict-0, retrying (2 attempts left). Error: NOT_ENOUGH_REPLICAS
[06:17:56,631] WARN ... retrying (1 attempts left). Error: NOT_ENOUGH_REPLICAS
[06:17:56,869] WARN ... retrying (0 attempts left). Error: NOT_ENOUGH_REPLICAS
[06:17:57,258] ERROR Error when sending message to topic rel14.strict with key: null,
  value: 13 bytes with error:
org.apache.kafka.common.errors.NotEnoughReplicasException:
  Messages are rejected since there are fewer in-sync replicas than required.

从第一次失败到最后放弃,740 毫秒。

同一时刻、同一个故障、同一批机器,两个主题给出了完全相反的结果:

主题min.insync.replicas停一台后 ISR结果
rel14.probe22 个成员写入成功,延迟几乎不变
rel14.strict32 个成员740 毫秒后 NotEnoughReplicasException

区别不在故障,在配置。 这组对照说明 min.insync.replicas 不是「越高越安全」,而是一条明确的取舍线:

  • 设成 2:容忍 1 台机器故障,写入不中断。代价是故障期间数据实际上只存在于 2 份副本上,此时再坏一台就会丢。
  • 设成 3:任何一台故障都停止写入。代价是可用性直接下降,但数据永远不会处于「只有 2 份」的状态。

选哪个不是技术问题,是业务对「短暂不可写」与「极端情况下可能丢数据」的偏好。4.2 节讨论过 NotEnoughReplicasException 是保护在起作用,这里给出了它真实发生的时间和形态。

一个容易忽略的点:acks=all 的「all」是当前 ISR,不是副本数。 停一台之后 ISR 是 2,acks=all 要求这 2 个都确认——比故障前更容易满足,不是更难。真正拦住写入的是 min.insync.replicas,不是 acks。

恢复后怎么确认真的追上了

「ISR 回到 3」只说明三个副本都在回应,不说明落后的那一个已经把数据补齐了。回到 ISR 的瞬间,k2 的日志末端可能还差一段——它先恢复了心跳,再慢慢把数据拉到与 leader 一致。

两种确认方式,各有陷阱:

看 kafka-log-dirs.sh 的 offsetLag。

/opt/kafka/bin/kafka-log-dirs.sh --bootstrap-server 127.0.0.1:9092 \
  --topic-list rel14.probe --describe

输出里每个副本有一个 offsetLag 字段。注意这个字段对不在 ISR 内的副本会直接报 0,明明落后却显示 0——这是卷一里实测到的行为,13.3 节又遇到了一次。所以 offsetLag=0 本身不携带信息,必须和 ISR 状态、段文件大小一起看。

比对副本。

/opt/kafka/bin/kafka-replica-verification.sh --bootstrap-server 127.0.0.1:9092 \
  --topic-whitelist rel14.probe

这个工具会逐个分区比对 leader 与 follower 的实际日志内容,输出 max lag。它慢、会占带宽,但它是唯一能确认「内容真的一致」的手段。恢复后跑一次,max lag 归零才算真正追上。

这次的实验没有跑到这一步——停机 40 秒、数据量小,ISR 回到 3 时实际上已经追平。但如果停机时间长、写入量大,这两个数字会明显不同步。14.3 节会把「ISR 恢复」与「数据追平」区分成两个时间点,分别计入 RTO。

停两台会发生什么

这个实验没做,原因值得说清楚:三节点 KRaft 停掉两台就失去了 controller quorum。

KRaft 的 controller 用 Raft 选主,多数派是 2。停一台剩 2 个 controller,还能选;停两台剩 1 个,无法形成多数,整个集群的元数据操作停摆——包括创建主题、分区重分配、以及某些情况下 leader 选举本身。

这跟 topic 层面的 min.insync.replicas 是两个独立的约束:

停几台controller quorum数据面(topic 写入)元数据面
1 台2/3,正常ISR 2,minISR=2 时可写正常
2 台1/3,失联取决于剩余副本停摆

所以「三节点能容忍一台」这句话有两个含义叠加:副本层面能容忍一台,controller 层面也只能容忍一台。两者恰好一致,掩盖了一个事实——如果集群是 5 个 controller,副本层面仍可能只容忍 2 台,但 controller 层能容忍 2 台,两个边界就分开了。

要实测停两台的行为,需要专门的测试环境,且预期结果是「集群元数据操作不可用」。这部分未在本机执行,上面的表格是依据 Raft 多数派与 CRaft quorum 语义的推断,不是观测。

这次没有测的:消费端

上面的观测全部来自生产端与主题元数据。消费端在这次停机里会怎样?

严格来说,如果消费者连接的是 k2 上的分区 leader,它会在下次 fetch 时收到 NOT_LEADER_OR_FOLLOWER,客户端刷新元数据后继续拉——行为与生产端类似,因为它也有重试与元数据刷新机制。如果 k2 恰好是消费组的协调器(__consumer_offsets 的某个分区 leader),提交位移会短暂失败,可能触发一次 rebalance。

这两条我没有在本机单独观测,所以不写成实测结论。14.2 节测了消费端进程崩溃的场景,14.3 节会在一次完整演练里把 broker 宕机与消费端行为放在同一条时间线上。

要自己补测,最直接的办法是在消费者的日志里找 NOT_LEADER_OR_FOLLOWER 与 Rebalance 关键字,看停机窗口内出现了几次、间隔多久。

如果你没做这三件事

这次实验「看起来什么都没发生」,是由三个前提共同支撑的。少任何一个,结果都会很难看:

第一,副本数是 3。 如果主题是 RF=2 而 k2 挂了,剩下一个副本要独自撑住。写入可能继续(min.insync.replicas=1 时),但那份数据成了单点。RF=3 让「停一台」变成常规操作而不是事故。

第二,min.insync.replicas 与副本数匹配。 RF=3 配 min.insync.replicas=2 是合理组合。RF=3 却把 minISR 留在默认的 1,acks=all 就退化成 acks=1,这次实验里那两行 WARN 可能变成真正的丢消息。

第三,生产端有足够的重试预算。 停机的 5 毫秒里,客户端靠重试把 NOT_LEADER_OR_FOLLOWER 消化掉了。如果 delivery.timeout.ms 只有 1 秒、retries 只有 3 次,或者业务代码在 send() 的回调里把异常吞掉了,这 50000 条里就会有几条真的失败。

反过来看,这三条都是配置项,都可以在事故发生之前验证。这正是「故障注入」的价值:它把「我们的配置应该没问题」变成「我们的配置在这一次确实没问题」,并且留下了时间线。

小结

  • 三节点 RF=3、min.insync.replicas=2 的集群停掉一台,50000 条消息 0 失败,停机段的平均延迟 6.3 毫秒(稳态 5.8 毫秒)。
  • 唯一可见的是 2 条 NOT_LEADER_OR_FOLLOWER 警告,5 毫秒内自动重试成功。
  • ISR 在停机命令发出后 3 秒内收缩到 2,leader 同时完成切换;恢复后 4 到 6 秒回到 3。
  • 同一时刻 min.insync.replicas=3 的主题在 740 毫秒内重试三次后被拒——这是配置差异,不是故障差异。
  • 消费端在这次实验里没有单独观测,14.2 与 14.3 会补上。

所有数字为单次运行,受宿主机负载影响,不作基准。ISR 时间线的采样间隔是 2 秒,实际切换时刻在两个采样点之间。


阅读导航:上一节 13.3 跨集群复制 | 下一节 14.2 故障注入:消费端崩溃

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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