三台 broker 同时停下的一次现场
先看一段不是设计出来的记录。2026-10-29 的实验中,三节点集群在创建新 topic 时同时退出,docker ps 显示三个容器都是 Exited (1)。翻 server.log,三个节点给出了同一条原因:
[2026-10-29 04:45:29,752] ERROR Error while creating log for e1.lag.demo-5 in dir /tmp/kafka-logs (org.apache.kafka.storage.internals.log.LogDirFailureChannel)
java.nio.file.FileSystemException: /tmp/kafka-logs/e1.lag.demo-5: No space left on device
at java.base/sun.nio.fs.UnixException.translateToIOException(Unknown Source)
...
[2026-10-29 04:45:29,769] ERROR Shutdown broker because all log dirs in /tmp/kafka-logs have failed (kafka.log.LogManager)
这是本机共享 VM 磁盘被瞬时写满时留下的真实日志,不是主动用 dd 灌出来的。它说明了一件比「磁盘满」本身更重要的事:Kafka 把「所有 log dir 都不可写」当作致命错误,直接关闭 broker。一个节点磁盘满,最终表现为三个节点一起不可用,因为新分区的目录要在每个副本节点上都创建一次。
故障排查的难点从来不是不认识某个错误,而是不知道一个现象背后有几条因果链。这一节先把常见故障摊成一张表,再挑能实跑的几项逐个验证。
故障对照表
下表是本节的主干。每一行回答四个问题:看到什么、指标长什么样、先查什么、通常是什么引起的。
| 故障 | 现象 | 指标特征 | 先看什么 | 常见根因 |
|---|---|---|---|---|
| URP | 部分分区副本数不足,写入仍可能成功 | UnderReplicatedPartitions > 0 | kafka-topics.sh --describe --under-replicated-partitions | broker 下线、follower 复制慢、磁盘故障 |
| 离线分区 | 分区不可读写,Leader: -1 | OfflinePartitionsCount > 0 | --describe、controller 日志 | ISR 全空且不允许不干净选举 |
| 脑裂 | 两个节点都认为自己该做主 | LeaderEpoch 异常、元数据操作互相覆盖 | kafka-metadata-quorum.sh describe --status | 网络分区、错误的 quorum 配置 |
| 磁盘满 | 写入报错、broker 退出 | log dir failed、No space left on device | server.log、df -h | 保留策略失效、日志暴涨、盘太小 |
| fd 耗尽 | 新连接失败、启动直接退出 | Too many open files、No file descriptors available | server.log、/proc/<pid>/fd、ulimit -n | 分区/连接数增长、limit 过低 |
| ISR 抖动 | ISR 反复收缩又扩张 | IsrShrinksPerSec / IsrExpandsPerSec 同时抬头 | 连续采样 --describe、server.log | 复制延迟、GC 停顿、网络抖动 |
| controller 不可用 | 建 topic、改配置、查元数据超时 | ActiveControllerCount、quorum LeaderId 变化 | kafka-metadata-quorum.sh、controller 日志 | quorum 失去多数派、controller 节点故障 |
指标名不是背出来的。本机在 apache/kafka:4.3.0 镜像里核对过它们确实存在于服务端 jar:
docker exec mq-e1 sh -c "unzip -p /opt/kafka/libs/kafka-metadata-4.3.0.jar \
2>/dev/null | strings | grep -oE 'ActiveControllerCount|OfflinePartitionsCount|UncleanLeaderElectionsPerSec' | sort -u"
ActiveControllerCount
OfflinePartitionsCount
UncleanLeaderElectionsPerSec
UnderReplicatedPartitions、IsrShrinksPerSec、IsrExpandsPerSec、UnderMinIsrPartitionCount 则在本机 kafka_2.13-4.3.0.jar 的 kafka/server/ReplicaManager.class 里(用 unzip -l 定位过,kafka-server-4.3.0.jar 里没有这个类)。本节没有接 JMX 导出,所以只给指标名与判读方式,不贴数值。
实验一:停一个 broker,抓 URP 原始输出
先建一个 3 分区 3 副本的 topic,写入 3000 条:
docker exec mq-e1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--create --topic e1.fault.demo --partitions 3 --replication-factor 3
docker exec mq-e1 /opt/kafka/bin/kafka-producer-perf-test.sh \
--topic e1.fault.demo --num-records 3000 --record-size 200 --throughput -1 \
--producer-props acks=all bootstrap.servers=mq-e1:9092,mq-e2:9092,mq-e3:9092
健康状态下三个分区的 ISR 都是 1,2,3。然后停掉 mq-e3:
docker stop mq-e3
sleep 3
docker exec mq-e1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--describe --under-replicated-partitions
本机实测(Kafka 4.3.0 KRaft 三节点,容器内 CLI / 2026-10-29):
[2026-10-29 04:41:01,256] WARN Couldn't resolve server mq-e3:9092 from bootstrap.servers as DNS resolution failed for mq-e3 (org.apache.kafka.clients.ClientUtils)
Topic: e1.fault.demo Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,1 Elr: LastKnownElr:
Topic: e1.fault.demo Partition: 1 Leader: 1 Replicas: 3,1,2 Isr: 1,2 Elr: LastKnownElr:
Topic: e1.fault.demo Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2 Elr: LastKnownElr:
三行就是全部输出。--under-replicated-partitions 只打印 ISR 长度小于副本数的分区,不打印 topic 头。停掉一个节点后,三个分区全部从 Isr: 1,2,3 变成两个副本,所以三行都在。
两个细节值得注意:
- 第一行 WARN 是 bootstrap 列表里仍然写着已经停掉的
mq-e3。命令行工具能靠另外两个节点拿到元数据,所以查询照常成功。把 bootstrap 写全是为了容错,不是必须全部活着。 Partition: 1的 leader 从 3 变成了 1。mq-e3原本是 p1 的 leader,优雅关闭后 controller 从 ISR 里选了 broker 1。Replicas的顺序没变,说明副本分配没动,只是领导权转移。
恢复 mq-e3 后再查,ISR 回到三个副本,URP 列表为空:
Topic: e1.fault.demo TopicId: zANDjZZ_S2G0h1J-UliCSw PartitionCount: 3 ReplicationFactor: 3 Configs: min.insync.replicas=2
Topic: e1.fault.demo Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 1,2,3 Elr: LastKnownElr:
Topic: e1.fault.demo Partition: 1 Leader: 1 Replicas: 3,1,2 Isr: 1,2,3 Elr: LastKnownElr:
Topic: e1.fault.demo Partition: 2 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Elr: LastKnownElr:
注意 --under-replicated-partitions 的退出状态是成功的,「没有输出」就是健康。脚本里不要用「命令有没有报错」判断 URP,要判断输出行数。
实验二:把 fd 限制调到 128,broker 起不来
fd(文件描述符)耗尽在生产里很常见,但演示起来有前提:不能拿正在承载数据的节点做。本机用同一个镜像另起一个探针容器,只改 --ulimit nofile,让它以单节点 KRaft 启动:
docker run --rm --name mq-fd128 --network mqe-net --ulimit nofile=128:128 \
-e KAFKA_NODE_ID=9 -e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS='PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093' \
-e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://mq-fd128:9092' \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT' \
-e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
-e KAFKA_CONTROLLER_QUORUM_VOTERS='9@127.0.0.1:9093' \
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
-e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
-e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
-e KAFKA_HEAP_OPTS='-Xmx256m -Xms256m' \
apache/kafka:4.3.0
本机实测(Kafka 4.3.0 单节点探针,Alpine 镜像 / 2026-10-29):容器 20 秒内以 exit 1 退出,docker logs 里的关键行是:
[2026-10-29 04:43:54,087] ERROR Encountered fatal fault: caught exception (org.apache.kafka.server.fault.ProcessTerminatingFaultHandler)
org.apache.kafka.common.KafkaException: java.io.IOException: No file descriptors available
at org.apache.kafka.common.network.Selector.<init>(Selector.java:162)
at kafka.network.Processor.createSelector(SocketServer.scala:881)
...
Caused by: java.io.IOException: No file descriptors available
at java.base/sun.nio.ch.EventFD.eventfd0(Native Method)
失败点在 Selector 初始化:网络层要为每个 processor 创建 selector 和 eventfd,128 个 fd 连「把服务端组件装起来」都不够。
再往下压到 64,情况更彻底:容器同样 exit 1,但 /opt/kafka/logs/server.log 根本没有生成,docker logs 只有入口脚本的前 9 行。JVM 连日志框架初始化都没完成。
作为对照,正常节点的 fd 用量并不小。三个 broker 在只有 1 个业务 topic、但 __consumer_offsets 默认 50 个分区的状态下:
mq-e1: 196
mq-e2: 185
mq-e3: 181
ulimit -n 的软限制是 1024。这意味着默认 1024 的 fd 上限,在几十个分区、几十个客户端连接时就可能不够。生产的 fd 规划在 13.1 与 10.3 已经算过,这里只强调观测方法:
docker exec mq-e1 sh -c 'ls /proc/1/fd | wc -l'
在本机镜像里 PID 1 就是 Kafka 的 java 进程,cat /proc/1/cmdline 能看到结尾的 kafka.Kafka,所以 /proc/1/fd 就是 broker 自身的 fd。换成用启动脚本拉起子进程的镜像时,要先 ps 找到 java 进程再数 /proc/<pid>/fd,否则数到的是 shell 的 fd。
实验三:controller 不可用与重新选举
KRaft 里 controller 是 quorum 选出来的。停掉当前 controller leader,剩下的节点会选一个新的。先记录状态:
LeaderId: 1
LeaderEpoch: 1
CurrentVoters: [{"id": 1, ...}, {"id": 2, ...}, {"id": 3, ...}]
停止 mq-e1 后,用另外两个节点查询:
LeaderId: 2
LeaderEpoch: 2
本机从发出 docker stop 到第一次读到新 LeaderId 用了 7.46 秒。这个数字包含容器优雅关闭、CLI 启动 JVM 和 0.5 秒轮询间隔,不是纯粹的切换时延,不能当基准。
LeaderEpoch 从 1 变成 2,说明这是一次真正的 controller 任期更替,不是同一个 leader 的重复上报。排查 controller 问题时,LeaderEpoch 是否单调递增比 LeaderId 更有信息量:epoch 不前进而 leader 频繁变化,通常意味着 quorum 在反复选举。
还要看清一个边界:三节点 KRaft 停两个就失去多数派。这一点在 4.3 节已经实测过,表现不是「选出新 controller」,而是所有元数据请求超时:
org.apache.kafka.common.errors.TimeoutException:
Call(callName=listPartitionReassignments, ...) timed out after 1 attempt(s)
所以「controller 不可用」在 3 节点拓扑上的容错边界是 1 个节点。要容忍 2 个,需要 5 节点或 dedicated controller 部署。
磁盘满:一条 ENOSPC 如何让三个节点一起退出
回到开头那段日志。磁盘满在 Kafka 里的传导路径是:
- 创建分区的日志目录失败,
LogManager把该 log dir 标记为 failed; - 如果所有 log dir 都 failed,broker 认为无法继续服务,主动关闭;
- 三个副本节点同时创建目录,同时失败,于是整个集群退出;
- 客户端看到的是所有请求超时,而不是「磁盘满」。
这条链路解释了为什么「磁盘满」的告警经常来自客户端而不是服务端。等 broker 已经退出,df 才有机会被人看到。
本机的实际磁盘状态(事件之后测量):
Filesystem Size Used Available Use% Mounted on
overlay 19.5G 11.6G 6.9G 63% /
事件发生时空间被瞬时占满,随后被其他负载释放。本文没有主动灌盘,也不建议在共享环境里用 dd 复现。要验证机制,安全的做法是用 retention.bytes 控制单个 topic 的占用,或用 df/du 的数据做推演:
docker exec mq-e1 sh -c 'du -sh /tmp/kafka-logs/* | sort -h | tail -5'
docker exec mq-e1 sh -c 'df -h /tmp/kafka-logs'
恢复顺序也有讲究。磁盘腾出空间后,broker 不是自动复活:容器已经退出,需要重新启动;启动时 LogManager 会重新检查目录,之前 failed 的目录如果可写,日志才能加载。生产中还要检查是否有半写入的段文件,必要时让副本从 leader 重建,这部分在 13.3 与 14.3 展开。
没能在本机执行的三项
诚实标出边界比编一个漂亮的结果有用。以下三项在本机三节点拓扑上做不出来,给出命令与预期形态。
离线分区(Leader: -1)。要制造它,必须让某个分区的 ISR 全空。三副本下意味着停掉三个副本所在的 broker;而三节点 KRaft 停两个就失去 quorum,第三个也查不了。预期形态:
# 未在本机执行
Topic: T Partition: 0 Leader: -1 Replicas: 1,2,3 Isr:
此时 OfflinePartitionsCount > 0,生产写入报 LEADER_NOT_AVAILABLE,消费者停在该分区。需要 5 节点或 dedicated controller 才能安全演示。
脑裂。KRaft 下不存在 ZooKeeper 时代那种「两个 controller 各自认为自己是主」的常态,因为写入元数据日志需要多数派。少数派一侧无法提交任何元数据变更,只能不断重试。真正要观察的是 quorum 的状态:
docker exec mq-e1 /opt/kafka/bin/kafka-metadata-quorum.sh \
--bootstrap-server mq-e1:9092 describe --status
健康的输出里 CurrentVoters 是全集,LeaderId 唯一。把 leader 与外界隔离(而不是停掉)才能看到「旧 leader 还在,但提交不了」的状态;本机没有可用的网络隔离工具,未执行。
ISR 抖动。它的判据是 IsrShrinksPerSec 与 IsrExpandsPerSec 同时抬头,且 --describe 连续采样能看到同一个分区反复进出。本机可以用 docker pause 制造一次收缩,但「反复抖动」需要复制速度在阈值附近震荡,不好稳定复现。机制与三条收缩路径已在 4.2 节实测,这里不重复。
排查顺序:先分层,再选命令
面对「集群不对劲」,不要从最细的日志开始翻。按层往下走:
- 客户端层:是所有 topic 都失败,还是单个 topic?全部失败先看网络与 DNS。
- 元数据层:
kafka-metadata-quorum.sh describe --status能否返回?不能返回就是 controller quorum 问题,后面的分区查询都没有意义。 - 分区层:
--describe看 leader 与 ISR;--under-replicated-partitions看范围。 - 节点层:
docker ps/ 进程、df -h、/proc/<pid>/fd、server.log的 ERROR。 - 客户端日志:最后才看生产者的重试与消费者的 rebalance。
这条顺序的依据是故障的传播方向:元数据层坏了,分区层查出来的任何东西都不可信;节点层坏了,分区层的 ISR 只是结果。
三个容易踩的判断错误
第一,URP 不等于不可写。 3 副本、min.insync.replicas=2 时,URP=1 仍然可以 acks=all 写入。URP 是「冗余度下降」,不是「服务中断」。真正的写入门槛是 ISR 数量与 min.insync.replicas 的比较。
第二,broker 退出不等于进程崩溃。 磁盘满、所有 log dir failed 都是主动关闭,日志里是 Shutdown broker,退出码可能是 0 或 1,取决于关闭路径。看退出码不如看最后一条 ERROR。
第三,controller 切换的耗时不是配置项。 本机 7.46 秒包含了工具开销;真实切换在秒级,但崩溃场景要走 session 超时。把「切换很快」当成设计假设,是容量规划里常见的错误。
小结
这一节把故障摊成了一张可查的表,并用四组真实输出验证了其中四项:
- URP 用
--under-replicated-partitions看,输出为空才是健康;停一个 broker 会让所有相关分区进入列表。 - fd 耗尽在启动阶段就会杀死 broker,128 个 fd 连
Selector都建不起来;正常节点空闲时已有约 190 个 fd。 - controller 不可用先看 quorum 的
LeaderId与LeaderEpoch;三节点只能容忍一个节点。 - 磁盘满会让所有 log dir failed,broker 主动关闭,最终表现为全集群超时。
故障图谱的价值不在于覆盖所有情况,而在于让你在看到第一个现象时知道下一层该查什么。下一节从一个最常见的数字出发:消费组的 LAG。它看起来只是一个差值,实际上至少有三种成因,定位路径完全不同。
阅读导航:上一节 13.3 副本重建与限流 | 下一节 14.2 积压的分层定位
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。