单节点能做什么,不能做什么
第 1 章的所有实验都在单节点上完成:一个 broker,一个 controller,所有分区只有一个副本。这足够验证「日志不删数据」「分区落点」「交付语义」这些机制,因为它们的答案不依赖副本。
但有一类问题单节点回答不了:一台机器挂了,数据还在不在? 要回答它,至少需要三个 broker,因为副本因子(replication factor)大于 1 的前提是至少有那么多个 broker。单节点上强行建 replication-factor=3 的 topic,会直接失败,本书不会用「假装三副本」的方式绕过这一点。
所以实验底座有两套:
| 底座 | 用途 | 能力边界 |
|---|---|---|
| 单节点 KRaft | 日常命令、客户端代码、日志结构、压缩与保留 | 不能验证副本、ISR、leader 选举 |
| 三节点 KRaft | 副本、故障转移、滚动重启、副本重建 | 资源开销大,本机需要限堆 |
这一节把两套都交代清楚。单节点这套会真实启动并贴出日志;三节点这套给出 compose 文件并做语法校验,但不在本机启动——三节点的运行结果留给第 4 章和第 13 章,避免和单节点实验抢端口与内存。
三组地址,各自回答不同的问题
Kafka 的 listener 配置是初学阶段最容易卡住的地方,因为它不是一个地址,而是三组地址,各自回答不同的问题。
| 配置 | 回答的问题 | 谁使用 |
|---|---|---|
listeners | broker 在容器/主机内绑定到哪个地址和端口 | broker 自己,启动时监听 |
advertised.listeners | broker 告诉客户端「再来找我时用这个地址」 | 客户端、其它 broker |
controller.quorum.voters | controller 之间互相投票用哪个地址 | controller 集群内部 |
三个概念分开之后,很多「连上了但读不到数据」的问题就有了解释:客户端第一次连的是 bootstrap 地址,连上之后 broker 会返回元数据,元数据里的地址来自 advertised.listeners。如果 advertised 写了一个客户端解析不了或连不上的地址,第一次连接成功,后续请求全部失败。
对单节点 Docker 来说,三组地址的取值如下:
listeners = PLAINTEXT://0.0.0.0:9092,
CONTROLLER://0.0.0.0:9093,
EXTERNAL://0.0.0.0:19201
advertised.listeners = PLAINTEXT://127.0.0.1:9092,
EXTERNAL://127.0.0.1:19201
controller.quorum.voters = 1@127.0.0.1:9093
这里有四个决定:
listeners用0.0.0.0。容器内的进程要接受来自任意网卡的连接,绑定到0.0.0.0才能同时服务容器内和端口映射进来的流量。绑定到127.0.0.1的话,Docker 的端口映射进不来。advertised.listeners用127.0.0.1。宿主机上的客户端是通过-p 19201:19201访问的,它需要拿到一个宿主机能解析的地址。容器 IP(比如172.17.0.9)在 macOS 宿主机上不可达,容器名在默认 bridge 网络里也不解析。- PLAINTEXT 与 EXTERNAL 分开。PLAINTEXT 给 broker 之间和容器内使用,EXTERNAL 给宿主机客户端使用。两者绑不同的端口,advertised 也不同。
- controller 用回环地址。单节点里 controller 和 broker 在同一个进程、同一个网络命名空间,
127.0.0.1:9093一定可达;用容器名反而不解析。
第 4 条是本节的第一个坑,下面会用命令证明。
单节点的真实启动
完整命令(与前言一致,只是把对外端口换成 19201,避免和读者已有的 19092 冲突):
docker run -d --name mq-a1 -p 19201:19201 \
-e KAFKA_NODE_ID=1 \
-e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS='PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19201' \
-e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://127.0.0.1:9092,EXTERNAL://127.0.0.1:19201' \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT' \
-e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT \
-e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
-e KAFKA_CONTROLLER_QUORUM_VOTERS='1@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 \
apache/kafka:4.3.0
本机实测(Kafka 4.3.0 / 2026-10-29),等 25 秒后容器日志的汇总块:
Log Summary
[error] 0 errors (0 unique)
[warn] 0 warnings (0 unique)
[info] 17 info messages
本节涉及的启动等待与超时时间都是单次运行,随机器调度波动,不作基准。
broker 的 server.log 里能看到三个 listener 都在监听:
INFO Awaiting socket connections on 0.0.0.0:9093. (kafka.network.DataPlaneAcceptor)
INFO Awaiting socket connections on 0.0.0.0:19201. (kafka.network.DataPlaneAcceptor)
INFO Awaiting socket connections on 0.0.0.0:9092. (kafka.network.DataPlaneAcceptor)
INFO [KafkaRaftServer nodeId=1] Kafka Server started (kafka.server.KafkaRaftServer)
用容器内的脚本确认能连上,并建一个 smoke topic:
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic a1.smoke --partitions 1 --replication-factor 1
Created topic a1.smoke.
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 --list
a1.smoke
--describe 的输出里,Leader、Replicas、Isr 都是 1,Elr 为空:
Topic: a1.smoke TopicId: ccxtYLEzRb2u-MqEDWBX0A PartitionCount: 1 ReplicationFactor: 1 Configs: min.insync.replicas=1
Topic: a1.smoke Partition: 0 Leader: 1 Replicas: 1 Isr: 1 Elr: LastKnownElr:
这就是单节点的全部能力:能建 topic、能读写、能验证日志结构,但不能验证副本。
为什么 controller 不能用容器名
在默认 bridge 网络里,Docker 不会把容器名写进其它容器的 DNS。直接在容器里查自己的名字:
docker exec mq-a1 getent hosts mq-a1
(无输出,退出码 2)
容器自己的名字都解析不了,controller quorum 如果写成 1@mq-a1:9093,启动时就会报解析失败。这就是单节点必须用 127.0.0.1 的原因。
三节点不一样。compose 会创建一个自定义网络,服务名在这个网络里是 DNS 记录,k1、k2、k3 可以互相解析。所以三节点的 quorum voters 可以写成 1@k1:9093,2@k2:9093,3@k3:9093,broker 之间的 PLAINTEXT advertised 也可以写成 PLAINTEXT://k1:9092。
两种网络的差别可以用一张表记住:
| 网络 | 容器名解析 | controller quorum 写法 | advertised PLAINTEXT 写法 |
|---|---|---|---|
默认 bridge(docker run) | 否 | 1@127.0.0.1:9093 | 127.0.0.1:9092 |
| compose 自定义网络 | 是 | 1@k1:9093,... | k1:9092 |
注意这是两件不同的事:controller 走的是 controller listener,broker 之间走的是 PLAINTEXT listener,宿主机客户端走的是 EXTERNAL listener。三组地址各自独立,不能混用。
一个故意的错误配置
为了把「advertised 写错会怎样」变成可见的现象,本机起了第二个隔离实例,只把 EXTERNAL 的 advertised 改成容器名:
docker run -d --name mq-a1-bad -p 19302:19302 \
-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,EXTERNAL://0.0.0.0:19302' \
-e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://127.0.0.1:9092,EXTERNAL://mq-a1-bad:19302' \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL: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 \
apache/kafka:4.3.0
broker 本身启动正常,建 topic 也正常。但从宿主机用 127.0.0.1:19302 连上去发消息,客户端日志会反复出现:
WARN org.apache.kafka.clients.NetworkClient - [Producer clientId=producer-1] Error connecting to node mq-a1-bad:19302 (id: 9 rack: null isFenced: false)
最后在 delivery.timeout.ms 到期时抛异常:
Exception in thread "main" java.util.concurrent.ExecutionException: org.apache.kafka.common.errors.TimeoutException: Expiring 1 record(s) for a1.bad-0:120002 ms has passed since batch creation. The request has not been sent, or no server response has been received yet.
这个过程值得拆开看:
- 客户端连
127.0.0.1:19302,成功,因为端口映射和监听都正确。 - broker 返回元数据,里面写的地址是
mq-a1-bad:19302。 - 客户端尝试连
mq-a1-bad:19302,宿主机 DNS 解析失败,反复重试。 - 120 秒后
delivery.timeout.ms到期,send().get()抛ExecutionException,根因是TimeoutException。
第一次连接成功,不代表后面能成功。bootstrap 只是「问路」的入口,真正的读写地址来自元数据。这是 listener 排错时最该先想到的一句话。
三节点的 compose
三节点用 compose 起,共享一个 controller quorum。文件放在 mq-ops/compose/docker-compose.yml:
name: libraryd-mq
services:
k1:
image: apache/kafka:4.3.0
container_name: k1
hostname: k1
ports: ["19091:19091"]
environment:
KAFKA_NODE_ID: "1"
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19091
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k1:9092,EXTERNAL://127.0.0.1:19091
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@k1:9093,2@k2:9093,3@k3:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "2"
KAFKA_DEFAULT_REPLICATION_FACTOR: "3"
KAFKA_MIN_INSYNC_REPLICAS: "2"
KAFKA_HEAP_OPTS: -Xmx512m -Xms512m
k2:
image: apache/kafka:4.3.0
container_name: k2
hostname: k2
ports: ["19092:19092"]
environment:
KAFKA_NODE_ID: "2"
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k2:9092,EXTERNAL://127.0.0.1:19092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@k1:9093,2@k2:9093,3@k3:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "2"
KAFKA_DEFAULT_REPLICATION_FACTOR: "3"
KAFKA_MIN_INSYNC_REPLICAS: "2"
KAFKA_HEAP_OPTS: -Xmx512m -Xms512m
k3:
image: apache/kafka:4.3.0
container_name: k3
hostname: k3
ports: ["19093:19093"]
environment:
KAFKA_NODE_ID: "3"
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k3:9092,EXTERNAL://127.0.0.1:19093
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@k1:9093,2@k2:9093,3@k3:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "3"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "2"
KAFKA_DEFAULT_REPLICATION_FACTOR: "3"
KAFKA_MIN_INSYNC_REPLICAS: "2"
KAFKA_HEAP_OPTS: -Xmx512m -Xms512m
几个参数的含义:
| 参数 | 为什么是这个值 |
|---|---|
KAFKA_NODE_ID | KRaft 用 node.id,不是旧版的 broker.id;三个节点必须不同 |
KAFKA_PROCESS_ROLES | 三节点都同时做 broker 和 controller,省资源;生产可拆成独立的 controller 节点 |
KAFKA_LISTENERS | 绑定 0.0.0.0,容器内任意网卡可连 |
KAFKA_ADVERTISED_LISTENERS | PLAINTEXT 用服务名,给 broker 互相找;EXTERNAL 用 127.0.0.1:宿主机端口,给宿主机客户端 |
KAFKA_CONTROLLER_QUORUM_VOTERS | 三票的 quorum,全部写进每个节点;compose 网络里服务名可解析 |
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR | 内部 topic 也要 3 副本,否则单点故障会丢消费进度 |
KAFKA_MIN_INSYNC_REPLICAS | 与 acks=all 联合决定写入门槛,4.2 节会用故障实验展开 |
KAFKA_HEAP_OPTS | 每个 broker 限 512 MB 堆,三个加起来 1.5 GB,7.7 GiB 的容器资源才够 |
启动命令是独立的 docker-compose 二进制(本机没有 docker compose 插件):
docker-compose -f docker-compose.yml up -d
这三节点在本机未启动。原因不是跑不起来,而是本节要保证单节点实验的端口和资源不被占用;三节点的启动、故障转移、ISR 收缩由第 4 章和第 13 章的独立环境完成。这里只对 compose 文件做语法校验:
docker-compose -f docker-compose.yml config --services
k1
k2
k3
校验通过只说明 YAML 结构和服务定义合法,不代表 broker 一定能起来。真正的三节点判据是:三个容器都 Up,kafka-broker-api-versions.sh 返回三个节点,__cluster_metadata 的 quorum 里三票都在。这三条会在第 4 章用真实集群验证。
排错表
把这一节遇到的和常见的 listener 问题压成一张表:
| 现象 | 最可能的原因 | 处理 |
|---|---|---|
客户端连 bootstrap 成功,随后 Error connecting to node <名字> | advertised.listeners 写了宿主机解析不了的容器名 | 改成宿主机可达的地址,通常是 127.0.0.1:映射端口 |
启动时报 Unable to resolve / quorum 连不上 | 默认 bridge 网络里用了容器名 | 单节点用 127.0.0.1;多节点用 compose 自定义网络 |
docker run 报端口已分配 | 宿主机端口被别的容器占用 | 换 -p 端口,同时改 listeners 与 advertised 的 EXTERNAL 端口 |
客户端连上后一直 TimeoutException,日志里没有连接错误 | 元数据里的地址被防火墙/网络隔离 | 检查 advertised 地址从客户端机器是否可达 |
单节点建 replication-factor=3 失败 | 只有一个 broker | 改用三节点;单节点只能 RF=1 |
| 内部 topic 创建失败 | offsets.topic.replication.factor 大于 broker 数 | 单节点设为 1;三节点设为 3 |
--zookeeper 参数报「不认识」 | 4.x 已移除 ZooKeeper | 所有命令改用 --bootstrap-server |
这张表里最值得记的是第一行。它把「连接成功」和「能读写」分开了:前者只证明 bootstrap 可达,后者才证明 advertised 正确。以后遇到「Kafka 连上了但发不出去」,先看客户端日志里有没有 Error connecting to node,再看那个 node 的地址是从哪来的。
本节判据
回答四个问题:
listeners和advertised.listeners的区别是什么?——前者是 broker 绑定的地址,后者是 broker 告诉客户端「以后来找我」的地址。- 单节点的 controller quorum 为什么用
127.0.0.1?——默认 bridge 网络不解析容器名,controller 与 broker 同容器,回环地址一定可达。 - 三节点的 PLAINTEXT advertised 为什么可以用
k1:9092?——compose 自定义网络给服务名提供了 DNS。 docker-compose config --services通过,能说明集群能起来吗?——不能,只说明 YAML 合法;还要看容器状态、broker 列表和 quorum。
下一节进入 Topic 的物理结构:一个分区在磁盘上到底是哪几个文件,段什么时候滚动,稀疏索引怎么把一次读请求定位到具体的字节偏移。
阅读导航:上一节 1.3 交付语义的真实成本 | 下一节 2.2 Topic 的物理结构
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。