为什么先把口径写清楚
可靠性这一卷和机制卷有个区别:它的结论高度依赖「你怎么弄坏的」。
同样是停掉一个 broker,docker stop 和 docker kill 的结果完全不同——前者会先尝试把 leadership 迁走,后者是直接断电。用不同的手段做「同一个实验」,会得到相差几倍的恢复时间。如果不把这个前提写清楚,读者照着做对不上,就会怀疑结论本身。
所以这一节不是「环境介绍」,而是故障注入的判据说明:四种手段各自模拟什么故障、观察哪些指标、本机实测到多少、哪些做不出来。
读完这一节,你应该能自己判断:书上某个数字,在我的环境里大概是什么量级,以及能不能复现。
机器与工具链
实验全部在一台开发机上完成,宿主是 macOS,容器跑在 colima 里。
$ colima status
colima is running
$ docker version --format '{{.Server.Version}}'
29.5.2
$ docker info --format '{{.NCPU}} vCPU / {{.MemTotal}} bytes'
4 vCPU / 8307167232 bytes
8307167232 字节约等于 7.7 GiB,不是 8 GiB。容器里看到的内存比标称少一点是正常的,写容量估算时要注意这个差值——第 15.2 节算成本时会回到这一点。
宿主机 JDK:
$ ~/.jdks/jdk-25.0.5+8/Contents/Home/bin/java -version
openjdk version "25.0.5" 2026-04-21
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8, mixed mode, shared)
容器里的 JDK 不一样,这点很重要:
$ docker run --rm apache/kafka:4.3.0 java -version
openjdk version "21.0.11" 2026-04-21
OpenJDK Runtime Environment (build 21.0.11+9-LTS)
OpenJDK 64-Bit Server VM (build 21.0.11+9-LTS, mixed mode)
apache/kafka:4.3.0 镜像基于 Alpine,带的是一个精简 JRE,/opt/java/openjdk/bin/ 下只有 java jfr jrunscript jwebserver keytool rmiregistry。没有 jcmd、没有 jps、没有 jconsole。
这意味着两件事:
- 想在容器里看线程栈,只能通过 JMX 的
DiagnosticCommand或jfr。 - 所有 CLI 脚本要写全路径
/opt/kafka/bin/kafka-*.sh,镜像的PATH里没有它们。
宿主机 load average 随并行实验的数量波动很大。单个集群运行时大约是十几到二十几;四个集群并行时实测到过:
$ sysctl -n vm.loadavg
{ 57.97 32.58 26.89 }
这是 2026-10-29 做第 1 章实验时的采样,当时同时跑着四个三节点集群。这解释了为什么书中所有耗时数字都标「单次运行,不作基准」——量级可信,绝对值不可信。同一份代码在不同的并行度下,吞吐能差出两倍以上。
单节点底座
大部分「语义」类的实验(事务、幂等、提交、死信)只需要一个 broker。单节点启动最省资源:
docker run -d --name mq-r1 \
-p 19092:9092 \
-e KAFKA_NODE_ID=1 \
-e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:19092 \
-e KAFKA_CONTROLLER_QUORUM_VOTERS=1@127.0.0.1:9093 \
-e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT \
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
-e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
-e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
apache/kafka:4.3.0
几个必须显式设置的值,理由分别是:
| 参数 | 为什么单节点要改 |
|---|---|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 | 默认是 3,单节点建不出 __consumer_offsets,消费组功能直接用不了 |
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 | 同上,默认 3 会让事务初始化失败 |
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 | 与上一项配套,否则 min.insync.replicas=2 会让事务写入永远失败 |
这三项在单节点上如果不改,报错信息不会直接指向它们。典型的表现是消费者 poll() 卡住不返回、或者 initTransactions() 抛超时。第 3 章做事务实验时会再遇到一次。
9092 是容器内部的 PLAINTEXT 端口,映射到宿主机 19092。在容器内执行 CLI 用 127.0.0.1:9092,在宿主机用 127.0.0.1:19092,两者不能混。
三节点底座
需要副本、ISR、leader 切换的实验,用三个 broker 组成 KRaft 集群。四个节点(三个 broker + 一个独立 controller)会更干净,但 4 vCPU 的环境里资源不够,所以采用 broker 与 controller 同进程的三节点形态。
services:
mq-r1:
image: apache/kafka:4.3.0
container_name: mq-r1
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://mq-r1:9092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@mq-r1:9093,2@mq-r2:9093,3@mq-r3:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2
mq-r2:
image: apache/kafka:4.3.0
container_name: mq-r2
environment:
KAFKA_NODE_ID: 2
# 其余同上,ADVERTISED_LISTENERS 改成 mq-r2
mq-r3:
image: apache/kafka:4.3.0
container_name: mq-r3
environment:
KAFKA_NODE_ID: 3
# 其余同上,ADVERTISED_LISTENERS 改成 mq-r3
KAFKA_CONTROLLER_QUORUM_VOTERS 必须三个节点写同一份,写错任何一个会导致集群起不来。启动后用下面这条确认:
docker exec mq-r1 /opt/kafka/bin/kafka-broker-api-versions.sh \
--bootstrap-server 127.0.0.1:9092 | grep -E '^mq-r'
预期看到三行,每行带 id 与 rack(如果配置了 broker.rack)。
三节点集群的容错边界是一个节点,不是两个。 这一点在卷一第 4.3 节用实验确认过:停掉两个,controller quorum 失去多数派,整个集群连 listPartitionReassignments 都会超时。所以本卷所有「停一台」的实验成立,「停两台」只在特定场景下成立(例如第二台停下的瞬间观察 ISR 与拒写),且必须立即恢复。
客户端工程
所有示例是一个最小的 Maven 工程,依赖只有三个:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.20</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.20</version>
</dependency>
kafka-clients 不自带 slf4j 实现,漏加后启动只会打印一行 SLF4J: No SLF4J providers were found,之后所有日志静默。这一点在排查「消费者为什么没动静」时会误导很久。
运行方式统一用 mvn -q compile exec:java,或者打一个带依赖的 jar 再 java -cp。书中给出的代码片段省略了 import 与部分异常包装,完整版本在附录 B。
故障注入的四种手段
这是本节的核心。四种手段在「破坏程度」和「可控性」上是递进的,用哪一个决定了你能观察什么。
手段一:docker stop(受控关闭)
docker stop mq-r2
docker stop 发 SIGTERM,Kafka 会执行 controlled shutdown:把该节点上的 leader 分区逐个迁走,再关闭自己。
模拟的是:计划内维护、滚动重启、优雅下线。
观察什么:ISR 收缩的时机。卷一的实测是「ISR 收缩在同一秒内完成」,因为关闭前已经把 leadership 移交了。
看不到什么:突然断电的效果。受控关闭太温柔了,几乎所有客户端都感知不到。
手段二:docker kill(断电)
docker kill mq-r2
发 SIGKILL,进程立刻消失,没有任何清理。
模拟的是:机器宕机、OOM Killer、进程被强杀。
观察什么:ISR 收缩的延迟。卷一的实测是约 7 秒,接近 broker.session.timeout.ms=9000。在这 7 秒里,其他 broker 仍然认为它活着,acks=all 的写入仍然把它算在 ISR 里——但它是收不到数据的。
这 7 秒是很多「消息去哪了」问题的根源:生产者在 ISR 还包含死者时拿到了成功响应,而实际上只有 leader 收到了写入。
用法:第 14.1 节用它观察「宕机瞬间的写入窗口」,第 1.2 节用它对比 acks=1 与 acks=all 的丢失范围。
手段三:docker pause(冻结)
docker pause mq-r2
容器进程被 SIGSTOP 冻结,网络连接还在但不响应,看起来就像一台卡死的机器。
模拟的是:GC 停顿、磁盘挂起、CPU 被抢光、虚拟机冻结。
观察什么:HW 不前进时的读取行为。卷一 4.1 节最重要的实验就是靠它做的:leader 还活着、还能接收写入,但因为 follower 冻结,HW 停在原地,消费者看不到新消息。
那次实验的数字:leader 磁盘上有 19 条,消费者只能读到 16 条。生产者的 5 次 send() 全部返回了 offset 5–9。
为什么这个手段不可替代:stop 会让 ISR 收缩,集群知道少了一个副本;kill 也一样。只有 pause 能制造「副本还在 ISR 里但不干活」的状态,而这正是 HW 与 LEO 分离的典型场景。
恢复用 docker unpause mq-r2。
手段四:改配置(制造逻辑故障)
前三种是「弄坏进程」,第四种是「不改进程,改规则」。
docker exec mq-r1 /opt/kafka/bin/kafka-configs.sh \
--bootstrap-server 127.0.0.1:9092 \
--entity-type topics --entity-name libraryd.borrow \
--alter --add-config min.insync.replicas=2
模拟的是:配置错误、水位设置不当、限流策略生效。
观察什么:策略生效后的行为变化。第 13 章的多租户配额、第 11.1 节的 message.max.bytes 都靠这个手段。
两个坑,卷一都踩过:
kafka-configs.sh不接受多个--add-config参数,要用逗号合并成一次。- 动态配置写入后,broker 要先追到这条元数据日志才生效,中间有几秒窗口。卷一的实测是:限流配置写完后立刻停机做实验,前 5 秒仍以约 9 MiB/s 拉数据,之后才降到 1 MiB/s。
四种手段的对照
| 手段 | 模拟 | ISR 何时变 | 典型用途 |
|---|---|---|---|
docker stop | 优雅下线 | 同秒 | 滚动重启、维护窗口 |
docker kill | 断电 | 约 7 秒后 | 宕机、丢失窗口分析 |
docker pause | 卡死 | 超过 replica.lag.time.max.ms 后 | HW 不前进、可见性 |
| 改配置 | 策略变化 | 不涉及 | 配额、上限、限流 |
本机做不出来的故障
诚实地说清楚这一点,比多做几个实验更重要。
真正的网络分区。 本机没有 tc 或 iptables 工具做定向丢包与延迟注入,所有「网络」问题只能用 pause 近似。pause 让一个节点完全不响应,而真实分区可能只是延迟升高或丢包率 5%,两者的表现不同。
脑裂。 KRaft 靠多数派避免了传统意义的脑裂,要构造「两边都认为自己是 controller」需要更复杂的故障组合,三节点环境做不出来。
长时间慢故障。 告警阈值的调优需要几小时量级的缓慢劣化,本机只能做秒级的突变。
跨机架的真实网络代价。 容器都在同一台机器上,机架感知能验证「副本分布对不对」,但验证不了「跨机架多花多少延迟」。卷一 13.1 节只报告了分布形态与跨架链路占比,没有编造延迟数字。
这些在正文里全部标注「未在本机执行」,并给出命令形态与预期结果。
数据口径
几个反复出现的口径先说清楚,避免后面反复解释。
时间:所有耗时是 System.nanoTime() 差值或命令行 time 的 wall clock,不扣除 JVM 启动。涉及 JVM 启动的对比(例如建 topic 的耗时),正文会说明是否已扣除。
吞吐:kafka-producer-perf-test.sh 的 records/sec 与 MB/sec 原样引用,不换算成「按 1KB 消息估算」。消息大小不同,records/sec 不可横向比较。
字节:磁盘容量用十进制 GB(1 GB = 10^9 字节),内存用 GiB。两者混用是容量估算出错的常见原因。
lag:kafka-consumer-groups.sh --describe 的 LAG 列,等于该分区 LEO 减去已提交位移。第 5.1 节会讲另外两种口径。
「未在本机执行」的判定规则
一句话:没有原始输出,就不算执行过。
以下情况必须标注:
- 需要多于三个节点才能触发的场景。
- 需要网络隔离工具、而本机没有的场景。
- 需要第三方组件(Debezium、Schema Registry、MirrorMaker2),而本卷不引入的场景。
- 需要长时间运行(小时级)才能观察到的缓慢劣化。
标注的写法统一为:说明为什么做不出来、给出命令形态、给出预期结果、说明这个缺口会影响哪一条结论。
第 15.3 节的验收清单会把这些缺口集中列一次,作为遗留风险交给读者。
复现顺序
如果你想跟着跑,建议按下面的顺序,因为后者依赖前者:
- 起单节点集群,确认
__consumer_offsets建出来了。 - 跑一遍最简的生产者与消费者,确认能用。
- 起三节点集群,建一个 3 副本的 topic,确认 ISR 是三个。
- 做一次
docker pause,观察 HW 不前进。 - 做一次
docker kill,计时 ISR 收缩。 - 开始第 1 章。
前五步大约二十分钟。跳过它们直接读代码,会在「为什么这个实验结果和书上不一样」上浪费更多时间。
下一节是目录与学习路径,然后是第 1 章:先把「不丢」这件事按段拆开。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。