《Java 消息中间件·Kafka 内核与客户端》2.1 三个节点的最小集群

单节点能验证命令,三节点才能验证副本。本节先把 listeners、advertised.listeners 与 controller quorum 三组地址的分工讲透,再用真实的单节点启动日志和一个故意写错 advertised 的失败实验,解释为什么客户端连上了却读不到元数据,最后给出可直接使用的三节点 compose。

单节点能做什么,不能做什么

第 1 章的所有实验都在单节点上完成:一个 broker,一个 controller,所有分区只有一个副本。这足够验证「日志不删数据」「分区落点」「交付语义」这些机制,因为它们的答案不依赖副本。

但有一类问题单节点回答不了:一台机器挂了,数据还在不在? 要回答它,至少需要三个 broker,因为副本因子(replication factor)大于 1 的前提是至少有那么多个 broker。单节点上强行建 replication-factor=3 的 topic,会直接失败,本书不会用「假装三副本」的方式绕过这一点。

所以实验底座有两套:

底座用途能力边界
单节点 KRaft日常命令、客户端代码、日志结构、压缩与保留不能验证副本、ISR、leader 选举
三节点 KRaft副本、故障转移、滚动重启、副本重建资源开销大,本机需要限堆

这一节把两套都交代清楚。单节点这套会真实启动并贴出日志;三节点这套给出 compose 文件并做语法校验,但不在本机启动——三节点的运行结果留给第 4 章和第 13 章,避免和单节点实验抢端口与内存。

三组地址,各自回答不同的问题

Kafka 的 listener 配置是初学阶段最容易卡住的地方,因为它不是一个地址,而是三组地址,各自回答不同的问题。

配置回答的问题谁使用
listenersbroker 在容器/主机内绑定到哪个地址和端口broker 自己,启动时监听
advertised.listenersbroker 告诉客户端「再来找我时用这个地址」客户端、其它 broker
controller.quorum.voterscontroller 之间互相投票用哪个地址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

这里有四个决定:

  1. listeners 用 0.0.0.0。容器内的进程要接受来自任意网卡的连接,绑定到 0.0.0.0 才能同时服务容器内和端口映射进来的流量。绑定到 127.0.0.1 的话,Docker 的端口映射进不来。
  2. advertised.listeners 用 127.0.0.1。宿主机上的客户端是通过 -p 19201:19201 访问的,它需要拿到一个宿主机能解析的地址。容器 IP(比如 172.17.0.9)在 macOS 宿主机上不可达,容器名在默认 bridge 网络里也不解析。
  3. PLAINTEXT 与 EXTERNAL 分开。PLAINTEXT 给 broker 之间和容器内使用,EXTERNAL 给宿主机客户端使用。两者绑不同的端口,advertised 也不同。
  4. 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:9093127.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.

这个过程值得拆开看:

  1. 客户端连 127.0.0.1:19302,成功,因为端口映射和监听都正确。
  2. broker 返回元数据,里面写的地址是 mq-a1-bad:19302。
  3. 客户端尝试连 mq-a1-bad:19302,宿主机 DNS 解析失败,反复重试。
  4. 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_IDKRaft 用 node.id,不是旧版的 broker.id;三个节点必须不同
KAFKA_PROCESS_ROLES三节点都同时做 broker 和 controller,省资源;生产可拆成独立的 controller 节点
KAFKA_LISTENERS绑定 0.0.0.0,容器内任意网卡可连
KAFKA_ADVERTISED_LISTENERSPLAINTEXT 用服务名,给 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 的地址是从哪来的。

本节判据

回答四个问题:

  1. listeners 和 advertised.listeners 的区别是什么?——前者是 broker 绑定的地址,后者是 broker 告诉客户端「以后来找我」的地址。
  2. 单节点的 controller quorum 为什么用 127.0.0.1?——默认 bridge 网络不解析容器名,controller 与 broker 同容器,回环地址一定可达。
  3. 三节点的 PLAINTEXT advertised 为什么可以用 k1:9092?——compose 自定义网络给服务名提供了 DNS。
  4. docker-compose config --services 通过,能说明集群能起来吗?——不能,只说明 YAML 合法;还要看容器状态、broker 列表和 quorum。

下一节进入 Topic 的物理结构:一个分区在磁盘上到底是哪几个文件,段什么时候滚动,稀疏索引怎么把一次读请求定位到具体的字节偏移。

阅读导航:上一节 1.3 交付语义的真实成本 | 下一节 2.2 Topic 的物理结构

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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