《Java 消息中间件·Kafka 内核与客户端》附录 B 集群与客户端模板

把全书用到的可运行件集中成一份模板:单节点 docker run、三节点 compose(每个节点显式写全 listeners,这是本机踩过的坑)、Maven 工程 pom、生产者与消费者最小可运行类、指标打点与优雅关闭。全部按 4.3.0 实跑验证过,复制后改端口即可用。

这份模板怎么用

四个部分:集群、工程、客户端代码、运维脚本。每一段都可以独立复制。

版本基线固定为 Kafka 4.3.0 + JDK 25。换成 3.x 会有一批参数不存在(broker.id、--zookeeper、ZooKeeper 部署),不要混用。

单节点:一条命令

日常验证、跑单个客户端、看一个行为,用这个就够:

docker run -d --name kafka-lab \
  -p 19092:19092 \
  -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:19092' \
  -e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://127.0.0.1:9092,EXTERNAL://127.0.0.1:19092' \
  -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 \
  -e KAFKA_HEAP_OPTS='-Xmx512m -Xms512m' \
  apache/kafka:4.3.0

三个要点:

  1. CONTROLLER_QUORUM_VOTERS 用 127.0.0.1 而不是容器名。默认 bridge 网络不解析容器名,写 kafka-lab:9093 会启动失败,日志里是 controller 连接超时。
  2. ADVERTISED_LISTENERS 必须是宿主机能访问的地址。写 127.0.0.1:19092 配合 -p 19092:19092 才能让宿主机客户端连上。
  3. 单节点建不了 replication.factor > 1 的 topic,会直接报 InvalidReplicationFactorException。

启动约 20–30 秒后可用。健康标志是日志末尾的汇总块:

   [error] 0 errors (0 unique)
   [warn] 0 warnings (0 unique)

三节点:compose 文件

每个节点必须显式写全 KAFKA_LISTENERS。这一条是本书踩过的坑:如果用 YAML 锚点继承再只覆盖 ADVERTISED_LISTENERS,容器内监听的端口和宿主机映射的端口会对不上,宿主机客户端报 topic not present in metadata,而 topic 本身完全正常。

services:
  k1:
    image: apache/kafka:4.3.0
    container_name: k1
    ports: ["29091:29091"]
    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:29091
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k1:9092,EXTERNAL://127.0.0.1:29091
      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_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
      KAFKA_HEAP_OPTS: -Xmx512m -Xms512m

  k2:
    image: apache/kafka:4.3.0
    container_name: k2
    ports: ["29092:29092"]
    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:29092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k2:9092,EXTERNAL://127.0.0.1:29092
      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_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
      KAFKA_HEAP_OPTS: -Xmx512m -Xms512m

  k3:
    image: apache/kafka:4.3.0
    container_name: k3
    ports: ["29093:29093"]
    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:29093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://k3:9092,EXTERNAL://127.0.0.1:29093
      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_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
      KAFKA_HEAP_OPTS: -Xmx512m -Xms512m

启动与检查:

docker-compose -f docker-compose.yml up -d
sleep 45
docker-compose -f docker-compose.yml ps
docker exec k1 /opt/kafka/bin/kafka-broker-api-versions.sh \
  --bootstrap-server k1:9092,k2:9092,k3:9092

本机只有独立的 docker-compose(5.5.0),没有 docker compose 插件。如果你的环境装了插件,把命令里的连字符去掉。

端口为什么要写 2909x:本机 19091 和 19093 被别的进程占着,Kafka 的端口映射虽然声明成功但实际绑定失败。换一组没被占用的端口最省事。选之前先查:

lsof -nP -iTCP:29091 -sTCP:LISTEN

Maven 工程

<properties>
  <maven.compiler.release>25</maven.compiler.release>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
  <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>
</dependencies>

一个容易忽略的点:kafka-clients 4.3.0 需要外部提供 slf4j-api。只加 kafka-clients 会在 new KafkaProducer 时抛:

NoClassDefFoundError: org/slf4j/LoggerFactory

不加 slf4j-simple 则能跑但不打印任何日志,连 broker 连不上都看不见。

生产者最小模板

import org.apache.kafka.clients.producer.*;
import java.util.Properties;
import java.util.concurrent.TimeUnit;

public class MinimalProducer {
    public static void main(String[] args) throws Exception {
        Properties p = new Properties();
        p.put("bootstrap.servers", "127.0.0.1:29091,127.0.0.1:29092,127.0.0.1:29093");
        p.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        p.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        p.put("acks", "all");
        p.put("enable.idempotence", "true");
        p.put("linger.ms", "5");
        p.put("delivery.timeout.ms", "30000");

        try (KafkaProducer<String, String> producer = new KafkaProducer<>(p)) {
            for (int i = 0; i < 10; i++) {
                RecordMetadata md = producer
                    .send(new ProducerRecord<>("demo", "k" + i, "v" + i))
                    .get(10, TimeUnit.SECONDS);
                System.out.printf("partition=%d offset=%d%n", md.partition(), md.offset());
            }
        }
    }
}

send().get() 会阻塞,适合实验和验证,不适合生产。生产环境用回调:

producer.send(new ProducerRecord<>("demo", key, value), (metadata, ex) -> {
    if (ex != null) {
        System.err.println("send failed: " + ex.getMessage());
    }
});

注意回调里不要做耗时操作,它在 Sender 线程上执行,会阻塞整个生产者的发送。

消费者最小模板

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.List;
import java.util.Properties;

public class MinimalConsumer {
    public static void main(String[] args) {
        Properties p = new Properties();
        p.put("bootstrap.servers", "127.0.0.1:29091,127.0.0.1:29092,127.0.0.1:29093");
        p.put("group.id", "demo-group");
        p.put("key.deserializer", StringDeserializer.class.getName());
        p.put("value.deserializer", StringDeserializer.class.getName());
        p.put("enable.auto.commit", "false");
        p.put("auto.offset.reset", "earliest");
        p.put("max.poll.records", "100");

        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p)) {
            consumer.subscribe(List.of("demo"));
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
                for (ConsumerRecord<String, String> r : records) {
                    System.out.printf("%s-%d@%d key=%s val=%s%n",
                        r.topic(), r.partition(), r.offset(), r.key(), r.value());
                }
                if (!records.isEmpty()) {
                    consumer.commitSync();
                }
            }
        }
    }
}

两个必须自己处理的地方:

  1. enable.auto.commit=false,在处理完成后手动提交。自动提交在 poll 返回时就可能提交位点,处理失败的消息会丢(8.3 节)。
  2. max.poll.interval.ms(默认 5 分钟)必须大于单批处理时间。超过就会被踢出组,触发再平衡,且这批消息会被重复消费。

优雅关闭要配合关闭钩子:

Runtime.getRuntime().addShutdownHook(new Thread(consumer::wakeup));

用 wakeup() 打断 poll,让主循环自然退出,而不是直接杀进程。

指标打点

打印一组关键指标:

producer.metrics().forEach((name, metric) -> {
    String n = name.name();
    if (n.startsWith("buffer-") || n.startsWith("record-")
        || n.startsWith("batch-size") || n.startsWith("request-latency")
        || n.equals("record-error-total") || n.equals("record-send-total")) {
        System.out.printf("%s{client=%s} = %s%n",
            n, name.tags().get("client-id"), metric.metricValue());
    }
});

metrics() 返回约 77 项。真正需要盯的几项:

指标正常反常的含义
buffer-available-bytes接近 buffer.memory持续接近 0 = 生产快于发送,send() 会阻塞
bufferpool-wait-time-total平稳持续增长 = 分配内存被阻塞,是背压的确切信号
record-queue-time-avg小变大 = 消息在缓冲里等太久,批次/网络有问题
record-error-total0大于 0 = 有发送失败,必须看日志
request-latency-avg稳定抖动 = broker 侧压力或网络问题

消费端的 lag 不要只看客户端指标:客户端只在 poll 时更新,进程停了指标也就不动了。积压以 kafka-consumer-groups.sh --describe 的 LAG 列为准。

运维脚本

按 follower → leader 的顺序滚动重启,每次等 ISR 恢复:

#!/bin/sh
set -e
K=/opt/kafka/bin
B=k1:9092,k2:9092,k3:9092

wait_isr() {
  for i in $(seq 1 30); do
    isr=$(docker exec k1 $K/kafka-topics.sh --bootstrap-server $B \
      --describe --topic demo 2>/dev/null | grep -o 'Isr: [0-9,]*')
    [ "$isr" = "Isr: 1,2,3" ] && return 0
    sleep 2
  done
  echo "ISR 未恢复,停止" >&2
  return 1
}

for n in 2 3 1; do
  docker restart k$n
  wait_isr
done

顺序不是随意的:先重启 follower,最后重启 leader,全程只发生一次 leader 选举。如果先重启 leader,每重启一个都可能触发一次选举和一轮 ISR 重建(4.3 节)。

三条底线

复制这些模板时,有三件事不要省:

  1. 每个节点显式写全 KAFKA_LISTENERS,不要用锚点继承再部分覆盖。
  2. enable.auto.commit=false,除非你能接受丢消息。
  3. 滚动重启前先确认 min.insync.replicas 和当前 ISR 的大小,ISR 已经等于 min.insync.replicas 时再停一个,写入会立刻被拒。

集群起不来时,按这个顺序查:容器日志的 [error] 汇总块 → kafka-broker-api-versions.sh 是否三个节点都通 → 端口是否被别的进程占用 → advertised 地址和映射端口是否一致。四步里通常第一步就能定位。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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