Docker中安装Kafka以及基本配置和用法、踩坑记录

文章目录

书接上回:《Docker从入门到实践:安装配置、常用命令与开发环境搭建》

开篇:为什么要学习 Kafka?

什么是 Kafka?

Apache Kafka 是一个分布式消息队列系统。它最初由 LinkedIn 公司开发,后来成为 Apache 顶级项目。

你可以把 Kafka 想象成一个超级信箱

  • 写信人(生产者) 把信放进信箱,不需要知道谁会读信、什么时候读信。
  • 信箱本身(Kafka) 可以保存大量信件,并且可以按主题分类。
  • 收信人(消费者) 随时可以来取信,而且多个收信人可以分工合作,每人负责一部分信件。

Kafka 解决了什么问题?

在实际开发中,我们经常会遇到以下问题:

场景 传统方案的问题 Kafka 的解决方案
订单系统 用户下单后立即写数据库,高并发时数据库压力巨大 订单先写入 Kafka,后端系统异步拉取处理,削峰填谷
日志收集 每台服务器日志分散,排查问题需要登录多台机器 统一发送到 Kafka,集中存储分析
微服务通信 服务间直接 HTTP 调用,强耦合,失败难以处理 通过 Kafka 解耦,生产者和消费者不直接通信
实时计算 需要对数据流进行实时统计,传统数据库轮询效率低 Kafka 流式计算,毫秒级处理

一句话总结 :Kafka 是高性能、高可靠、分布式的消息中间件,是现代后端开发的必修课。

kafka的核心概念(新手必读)

术语 类比 说明
Producer(生产者) 写信人 发送消息的客户端
Consumer(消费者) 收信人 拉取消息的客户端
Topic(主题) 信箱分类标签 消息的类别,类似数据库的表
Partition(分区) 信箱的格子 每个 Topic 分成多个分区,实现并行读写
Broker(代理) 邮局服务器 Kafka 服务节点
Consumer Group(消费者组) 一个收信团队 组内消费者共同消费一个 Topic,每个分区只能被组内一个消费者消费
Zookeeper 邮局管理员 负责协调 Kafka 集群,选举 Leader,存储元数据

项目架构设计:将 Kafka 融入现有 Docker 环境

为什么不用独立项目?

很多教程会新建一个 docker-kafka 目录从头部署,但实际开发中 ,我们的服务往往是多组件协同的------Nginx 提供网页服务,PHP 处理业务逻辑,MySQL 存储数据,Redis 做缓存,Kafka 负责消息队列。

上一篇文章中使用的 docker-project 源代码:https://gitee.com/rxbook/docker-demo-2026

本篇内容,将 Kafka 直接集成到已有的 ~/docker-project 项目中,好处是:

  • 统一编排 :一个 docker-compose.yml 管理所有服务,启动/停止一键搞定。
  • 网络互通 :所有服务自动加入同一网络,PHP 容器可以直接用服务名 kafka 访问 Kafka,无需额外配置。
  • 数据集中 :所有持久化数据存放在 ~/docker-project/data/ 下,备份迁移只需拷贝整个项目目录。
  • 环境一致 :无论是本地开发、测试环境,还是交付给同事,docker-compose.yml 加上项目文件夹就是完整的环境定义。

当前项目结构回顾:

复制代码
~/docker-project/
├── docker-compose.yml          # 主编排文件(即将加入 Kafka)
├── data/                       # 统一数据持久化目录(提前创建)
│   ├── mysql/
│   ├── redis/
│   ├── zookeeper/             # 新增,用于存放 Zookeeper 数据
│   └── kafka/                 # 新增,用于存放 Kafka 数据
├── logs/                      # 日志目录
├── nginx/                     # Nginx 配置
├── php/                       # PHP 自定义镜像
│   ├── Dockerfile
│   └── extensions.sh          # (可选)扩展安装脚本
├── mysql/                     # MySQL 自定义配置
└── www/                       # 网站代码,PHP 测试脚本就放这里

Docker Compose 整合 Kafka

镜像选择说明

  • Zookeeper :官方 zookeeper:3.8,稳定且轻量。
  • Kafka :社区最流行的 wurstmeister/kafka:latest包含完整的 Kafka 命令行工具,便于学习和调试。

为什么不用 confluentinc/cp-kafka

因为:Confluent 是企业版,虽然功能强大,但其镜像不包含 kafka-topics.sh 等常用命令 ,新手进入容器后发现命令找不到会非常困惑。wurstmeister/kafka 是学习阶段的最佳选择。

编辑 docker-compose.yml

打开 ~/docker-project/docker-compose.yml,在 services 末尾添加 Zookeeper 和 Kafka 服务。

yaml 复制代码
version: '3.8'

services:
  # ---------- 原有服务(PHP、Nginx、MySQL、Redis)请保持原样,此处省略 ----------
  # ... 你的 php, nginx, mysql, redis 配置 ...

  # ---------- 新增:Zookeeper(Kafka 依赖)----------
  zookeeper:
    image: zookeeper:3.8
    container_name: zookeeper
    restart: always
    ports:
      - "2181:2181"                # 宿主机可通过 2181 访问 Zookeeper
    environment:
      ZOO_MAX_CLIENT_CNXNS: 60     # 最大客户端连接数
    volumes:
      - ./data/zookeeper:/data     # 数据持久化
    networks:
      - lnmp                      # 加入已有网络,与 PHP 等容器互通

  # ---------- 新增:Kafka Broker ----------
  kafka:
    image: wurstmeister/kafka:latest
    container_name: kafka
    restart: always
    ports:
      - "9092:9092"               # 宿主机通过 9092 访问 Kafka
    environment:
      # Kafka Broker 唯一 ID,单节点固定为 1
      KAFKA_BROKER_ID: 1
      
      # Zookeeper 连接地址:使用服务名 zookeeper,端口 2181
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      
      # Kafka 监听地址:0.0.0.0 表示接受所有网络接口的连接
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
      
      # 关键配置:客户端应该连接的地址
      # 宿主机客户端(如宿主机运行的 PHP 代码)使用 localhost:9092
      # 容器内客户端(如 php-fpm 容器)使用服务名 kafka:9092(Docker 网络自动解析)
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      
      # 开发环境允许自动创建不存在的主题(生产环境建议 false)
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
      
      # 单节点集群,副本因子必须设为 1,否则会因无法同步副本而报错
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
    volumes:
      - ./data/kafka:/kafka        # Kafka 数据目录
    depends_on:
      - zookeeper                # 确保 Zookeeper 先启动
    networks:
      - lnmp

networks:
  lnmp:
    driver: bridge

部分参数解读:

环境变量 作用 常见错误
KAFKA_ADVERTISED_LISTENERS 告知客户端"你应该连接哪个地址"。这是 Kafka 最坑的配置,没有之一! 如果填错,客户端能连上 Broker,但 Broker 返回给客户端的是错误地址,导致后续操作超时。 填成 127.0.0.10.0.0.0、或者忘记改 IP 导致服务器部署时客户端连不上。
KAFKA_AUTO_CREATE_TOPICS_ENABLE 当生产者向一个不存在的 Topic 发送消息时,是否自动创建该 Topic。 生产环境不关,可能导致大量无效 Topic。
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR 位移主题(__consumer_offsets)的副本数。单节点集群必须设为 1。 设为大于 1 时,Kafka 会尝试创建副本但找不到其他 Broker,启动失败。

创建数据目录并启动服务

bash 复制代码
cd ~/docker-project

# 创建 Zookeeper 和 Kafka 的数据持久化目录
mkdir -p data/{zookeeper,kafka}

# 启动新增的服务(不会影响已经在运行的 php/nginx 等)
docker-compose up -d zookeeper kafka

# 查看所有服务状态
docker-compose ps

预期输出 :所有服务均为 Up 状态,包括新加的 zookeeperkafka

验证 Kafka 是否正常工作

首先,进入 Kafka 容器执行基础命令

bash 复制代码
docker exec -it kafka bash

在容器内执行以下命令,逐条测试:

bash 复制代码
# 1. 查看当前所有主题(刚启动应只包含系统主题)
kafka-topics.sh --bootstrap-server localhost:9092 --list

# 2. 创建一个测试主题,3个分区,1个副本
kafka-topics.sh --create \
  --topic test-topic \
  --bootstrap-server localhost:9092 \
  --partitions 3 \
  --replication-factor 1

# 3. 查看主题详细信息(分区、副本分布)
kafka-topics.sh --describe \
  --topic test-topic \
  --bootstrap-server localhost:9092

# 4. 发送一条消息
echo "Hello Docker Kafka" | kafka-console-producer.sh \
  --broker-list localhost:9092 \
  --topic test-topic

# 5. 消费消息(从开始消费)
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic test-topic \
  --from-beginning \
  --max-messages 1

# 看到 "Hello Docker Kafka" 即成功

如果所有命令都正常执行,恭喜你,Kafka 容器化部署成功!

宿主机快捷命令封装(Zsh 别名)

每次敲 docker exec ... 太长,我们利用上一篇文章配置的 Zsh 别名系统,添加 Kafka 专属快捷命令。

编辑 ~/.zshrc,在 # Docker 容器命令别名 区域追加以下内容:

bash 复制代码
# ========== Docker Kafka 快捷命令(集成到主项目)==========
# 注意:使用 -f 指定主 Compose 文件,避免切换到 kafka 子目录

# 服务管理
alias kafka-ps='docker-compose -f ~/docker-project/docker-compose.yml ps kafka zookeeper'
alias kafka-logs='docker-compose -f ~/docker-project/docker-compose.yml logs -f kafka'
alias kafka-start='docker-compose -f ~/docker-project/docker-compose.yml up -d kafka zookeeper'
alias kafka-stop='docker-compose -f ~/docker-project/docker-compose.yml stop kafka zookeeper'
alias kafka-restart='docker-compose -f ~/docker-project/docker-compose.yml restart kafka'

# 进入容器
alias kafka-bash='docker exec -it kafka bash'

# Kafka 命令行工具(直接宿主机执行)
alias kafka-topics='docker exec -it kafka kafka-topics.sh --bootstrap-server localhost:9092'
alias kafka-producer='docker exec -i kafka kafka-console-producer.sh --broker-list localhost:9092'
alias kafka-consumer='docker exec -it kafka kafka-console-consumer.sh --bootstrap-server localhost:9092'
alias kafka-groups='docker exec -it kafka kafka-consumer-groups.sh --bootstrap-server localhost:9092'

# 运行我们编写的 PHP 示例(基于容器执行)
alias php-kafka-producer='docker exec -i php74-fpm php /var/www/html/kafka_producer.php'
alias php-kafka-consumer='docker exec -i php74-fpm php /var/www/html/kafka_consumer.php'

重新加载配置

复制代码
source ~/.zshrc

使用示例

bash 复制代码
# 查看 Kafka 日志
kafka-logs

# 创建主题
kafka-topics --create --topic order-topic --partitions 3 --replication-factor 1

# 运行 PHP 生产者
php-kafka-producer

# 运行 PHP 消费者
php-kafka-consumer

Kafka 常用命令速查表(收藏备用)

命令 说明 示例
kafka-topics.sh --list 查看所有主题 kafka-topics --list
kafka-topics.sh --create 创建主题 --topic test --partitions 3 --replication-factor 1
kafka-topics.sh --describe 查看主题详情 --topic test
kafka-console-producer.sh 命令行生产者 --broker-list localhost:9092 --topic test
kafka-console-consumer.sh 命令行消费者 --bootstrap-server localhost:9092 --topic test --from-beginning
kafka-consumer-groups.sh --list 查看所有消费者组 --bootstrap-server localhost:9092
kafka-consumer-groups.sh --describe 查看消费者组详情 --group my-group --bootstrap-server localhost:9092
kafka-run-class.sh kafka.tools.GetOffsetShell 查看分区偏移量 --topic test --time -1 --broker-list localhost:9092

使用别名

bash 复制代码
# 查看消费者组
kafka-groups --list

# 查看消费者组详情
kafka-groups --describe --group php-demo-group

给PHP安装 rdkafka 扩展

PHP 通过 rdkafka 扩展与 Kafka 通信,它是对高性能 C 库 librdkafka 的封装。我这里的 PHP 容器是基于 php:7.4-fpm 官方镜像,默认没有安装该扩展,需要手动添加这个扩展。

首先,修改 Dockerfile:

编辑 ~/docker-project/php/Dockerfile,在原有内容基础上添加 librdkafka-devrdkafka 扩展:

dockerfile 复制代码
FROM php:7.4-fpm

# 安装系统依赖(合并所有安装命令以减少层数)
RUN apt-get update && apt-get install -y \
    libfreetype6-dev \
    libjpeg62-turbo-dev \
    libpng-dev \
    librdkafka-dev \          # Kafka C 库依赖
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd pdo_mysql mysqli bcmath opcache

# 安装 Redis 扩展
RUN pecl install redis && docker-php-ext-enable redis

# 安装 rdkafka 扩展(PHP 7.4 兼容版本)
RUN pecl install rdkafka && docker-php-ext-enable rdkafka

# 安装 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

然后,重新构建并重启 PHP 容器:

bash 复制代码
cd ~/docker-project

# 重新构建 PHP 镜像
docker-compose build php

# 重新创建 PHP 容器(热重启)
docker-compose up -d php

# 验证扩展是否安装成功
docker exec php74-fpm php -m | grep rdkafka

输出 rdkafka 即表示安装成功。

接下来,在PHP环境中测试一下基本的连通性。

创建kafka_test.php,写入如下内容:

php 复制代码
<?php

$conf = new RdKafka\Conf();
$conf->set('metadata.broker.list', 'kafka:9092');
$producer = new RdKafka\Producer($conf);
$brokers = $producer->getMetadata(true, null, 5000)->getBrokers();
foreach ($brokers as $broker) {
    echo 'Broker: ' . $broker->getHost() . ':' . $broker->getPort() . PHP_EOL;
}

进入docker的PHP环境:

bash 复制代码
docker exec -it php74-fpm bash

在 PHP 容器内执行:

bash 复制代码
php kafka_test.php

预期输出:Broker: localhost:9092

如果超时或报错:请检查 kafka 容器名是否正确,以及两个容器是否在同一网络。

PHP 原生生产者/消费者实战

设计思路

  • **模拟场景:**不同用户购买不同商品下单场景
  • 生产者 :创建 Producer 实例,指定 Broker 列表,向特定 Topic 发送消息。
  • 消费者 :创建 Consumer 实例,加入消费者组,订阅 Topic,循环拉取消息。

生产者代码

~/docker-project/www/ 目录下创建 kafka_demo/kafka_producer.php

php 复制代码
<?php
/**
 * Kafka 生产者 - 订单消息示例
 * 持续生成模拟订单消息
 */

/** @noinspection PhpUndefinedClassInspection */
/** @noinspection PhpUndefinedNamespaceInspection */

// 1. 创建配置对象
$conf = new RdKafka\Conf();
$conf->set('metadata.broker.list', 'kafka:9092');
$conf->set('socket.timeout.ms', '5000');

// 2. 创建生产者实例
$producer = new RdKafka\Producer($conf);
$topic = $producer->newTopic('order-events');

echo "订单生产者已启动,按 Ctrl+C 退出...\n";
echo str_repeat("-", 50) . "\n";

$orderId = 10000;
while (true) {
    // 模拟真实订单数据
    $timestamp = date('Y-m-d H:i:s');
    $users = ['张三', '李四', '王五', '赵六', '小明', '小红'];
    $products = ['iPhone 15 Pro', 'MacBook Pro', 'AirPods Pro', 'iPad Air', 'Apple Watch'];

    $user = $users[array_rand($users)];
    $product = $products[array_rand($products)];
    $amount = rand(999, 9999) / 100;
    $orderId++;

    $message = json_encode([
        'order_id' => $orderId,
        'user' => $user,
        'product' => $product,
        'amount' => $amount,
        'created_at' => $timestamp,
    ], JSON_UNESCAPED_UNICODE);

    // 发送消息
    // $producer->produce() 只是把消息放入本地发送队列,并不是立即发送到 Kafka; 真正的网络发送是异步的,由 poll() 触发
    $topic->produce(RD_KAFKA_PARTITION_UA, 0, $message);
    $producer->poll(0);

    echo "[{$timestamp}] 订单已发送: {$user} 购买了 {$product},金额 ¥{$amount}\n";

    // 模拟真实订单频率(每3-8秒一个订单)
    sleep(rand(3, 8));
}

执行方式:

bash 复制代码
# 进入项目目录
cd ~/docker-project

# 宿主机执行
# docker exec -i php74-fpm php /var/www/html/kafka_demo/kafka_producer.php

# 进入容器执行:
docker exec -it php74-fpm bash
cd /var/www/html
php kafka_demo/kafka_producer.php
$producer->poll说明

在 Kafka 生产者代码中,经常会看到类似这样的循环:

复制代码
for ($i = 0; $i < 10; $i++) {
 $producer->poll(100);
}

这段代码的作用是确保本地发送队列中的所有消息都被真正发送到 Kafka Broker 之后,脚本才退出

为什么需要这段代码?

$topic->produce() 只是将消息放入生产者的本地发送队列 ,并不会立即通过网络发送出去。真正的网络 I/O 是由 poll() 方法触发的。

  • poll(0):立即触发一次发送尝试,但不等待结果,非阻塞。
  • poll(100):等待最多 100 毫秒,给 librdkafka 足够的时间将队列中的消息发送出去。

当生产者脚本需要发送一批消息后立即退出(例如命令行脚本、定时任务),如果直接退出而不调用 poll(),很可能最后几条消息还在队列中,尚未发送,导致消息丢失。

循环 10 次 poll(100) 意味着什么?

  • 每次 poll(100) 最多等待 100 毫秒。
  • 循环 10 次,总等待时间最多 1 秒。
  • 这 1 秒足够让生产者将积压的少量消息全部发送完毕。

什么时候不需要这段代码?

如果你的生产者是无限循环 (如 while(true) 持续生成消息),则不需要这段代码。因为循环中的 poll(0) 已经足够触发发送,且脚本永远不会主动退出。

更严谨的做法:

生产环境推荐使用 $producer->flush($timeout_ms) 方法,它会阻塞直到所有消息发送完成或超时:

复制代码
$producer->flush(5000);  // 最多等待 5 秒

flush() 比手动循环 poll() 更简洁、可靠。

消费者代码

创建 ~/docker-project/www/kafka_demo/kafka_consumer.php

php 复制代码
<?php
/**
 * Kafka 消费者 - 订单处理示例
 * 持续监听订单消息,处理订单业务
 */

/** @noinspection PhpUndefinedClassInspection */
/** @noinspection PhpUndefinedNamespaceInspection */

// 1. 创建配置对象
$conf = new RdKafka\Conf();

// 2. 设置消费者组 ID
$conf->set('group.id', 'order-processor-group');

// 3. 设置 Broker 列表
$conf->set('metadata.broker.list', 'kafka:9092');

// 4. 设置起始偏移量策略(首次启动时生效)
$conf->set('auto.offset.reset', 'earliest');

// 5. 设置自动提交偏移量(处理完消息后自动提交)
$conf->set('enable.auto.commit', 'true');
$conf->set('auto.commit.interval.ms', '1000');

// 6. 创建消费者实例
$consumer = new RdKafka\Consumer($conf);
$consumer->addBrokers('kafka:9092');

// 7. 订阅主题
$topic = $consumer->newTopic('order-events');

// 8. 开始消费分区 0,从最新消息开始(不消费历史消息)
//    如果想从头消费历史消息,改为 RD_KAFKA_OFFSET_BEGINNING
$topic->consumeStart(0, RD_KAFKA_OFFSET_STORED);

echo "订单消费者已启动,等待订单消息...\n";
echo str_repeat("-", 50) . "\n";

$processedCount = 0;

// 9. 持续阻塞监听(永远不会退出)
while (true) {
    // 阻塞等待消息,超时时间 1000ms
    $msg = $topic->consume(0, 1000);

    if ($msg && $msg->err == RD_KAFKA_RESP_ERR_NO_ERROR) {
        $processedCount++;

        // 解析 JSON 消息
        $order = json_decode($msg->payload, true);
        if ($order) {
            echo "\n收到订单 #{$processedCount}:\n";
            echo "   ├─ 订单号: {$order['order_id']}\n";
            echo "   ├─ 用户: {$order['user']}\n";
            echo "   ├─ 商品: {$order['product']}\n";
            echo "   ├─ 金额: ¥{$order['amount']}\n";
            echo "   └─ 时间: {$order['created_at']}\n";

            // 模拟处理订单业务(扣库存、发短信等)
            echo "   正在处理订单...";
            sleep(1); // 模拟业务处理耗时
            echo "处理完成\n";
        } else {
            echo "收到无效的订单消息: {$msg->payload}\n";
        }

        // 手动提交偏移量(如果设置了 enable.auto.commit = false)
        // $consumer->commit($msg);

    } elseif ($msg && $msg->err != RD_KAFKA_RESP_ERR__PARTITION_EOF) {
        // 打印非正常错误
        echo "错误: " . rd_kafka_err2str($msg->err) . "\n";
    }
}

// 停止消费(实际不会执行到这里)
$topic->consumeStop(0);

执行方式(进入容器执行):

bash 复制代码
docker exec -it php74-fpm bash
php kafka_demo/kafka_consumer.php

执行效果示例(先启动消费者):

在kafka中查看消息情况:

首先,进入 Kafka 容器:docker exec -it kafka bash

bash 复制代码
# 查看所有主题
kafka-topics.sh --bootstrap-server localhost:9092 --list

# 查看指定主题的详细信息(分区数、副本状态)
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic order-events

# 查看主题中的消息内容(从开始位置消费),这个命令会输出主题中的前 10 条消息内容
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic order-events \
  --from-beginning \
  --max-messages 10

# 实时监控新产生的消息,按 Ctrl+C 退出
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic order-events
  
# 重置消费者组偏移量到最新位置,重置后,重启消费者,就不会消费历史消息了
kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --group order-processor-group \
  --reset-offsets --to-latest \
  --execute --topic order-events
  
# 清除所有消息(删除主题)
kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic order-events

踩坑记录汇总

坑1:PHP 连接 Kafka 超时

现象:生产者/消费者执行后卡住,最终报 i/o timeoutConnection refused

根本原因:容器间通信必须使用服务名,而不是 localhost。

检查方法:

bash 复制代码
# 进入 PHP 容器
docker exec -it php74-fpm bash

# 尝试 ping Kafka 容器
ping kafka

如果 ping: kafka: Name or service not known,说明网络配置错误------请确保 PHP 容器和 Kafka 容器在同一网络(也就是 lnmp),且 Kafka 容器名确实为 kafka

解决方案

  • PHP 代码中 Broker 列表写 kafka:9092
  • 宿主机运行 PHP 脚本(如 php producer.php)时,写 localhost:9092(因为宿主机端口映射)

坑2:消费者收不到任何消息(无报错)

现象 :生产者成功,消费者无输出,也不报错。
根本原因消费者组已经存在,并且该组已消费过该主题的所有消息,偏移量已提交到最新位置
解决方案

  1. 更换 group.id(最简单)

    php 复制代码
    $conf->set('group.id', 'php-demo-group-' . time());
  2. 重置消费者组的偏移量(高级)

    bash 复制代码
    kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group php-demo-group --reset-offsets --to-earliest --execute --topic test-topic
  3. 设置 auto.offset.reset = earliest ,并确保消费者组第一次启动(此配置仅首次生效)。

坑3:Kafka 容器反复重启

查看日志报 exec /usr/bin/start-kafka.sh: no such file or directory

现象:docker-compose ps 显示 Restarting,日志文件报错如上。

根本原因:使用了 docker export/import 迁移镜像,导致启动脚本丢失执行权限。

解决方案:永远用 docker save/load 迁移镜像,禁止 export/import

bash 复制代码
# 错误做法
docker export kafka -o kafka.tar
docker import kafka.tar wurstmeister/kafka:latest

# 正确做法
docker save wurstmeister/kafka:latest -o kafka.tar
docker load -i kafka.tar

坑4:宿主机端口 9092 被占用

现象 :启动 Kafka 时报 Error: port is already allocated
解决方案 :修改 docker-compose.yml 中的宿主机映射端口,例如 "9093:9092",并同步修改 KAFKA_ADVERTISED_LISTENERSlocalhost:9093

yaml 复制代码
ports:
  - "9093:9092"
environment:
  KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9093

坑5:自动创建的主题分区数不符合预期

现象 :自动创建的主题只有 1 个分区,但希望有更多分区。
解决方案:设置全局默认分区数:

yaml 复制代码
environment:
  KAFKA_NUM_PARTITIONS: 3

或在创建主题时显式指定分区数(推荐):

bash 复制代码
kafka-topics --create --topic my-topic --partitions 3 --replication-factor 1 --bootstrap-server localhost:9092

坑6:连接失败Connection refused问题

现象:生产者报 Connection refused,日志显示连接 localhost:9092

原因:KAFKA_ADVERTISED_LISTENERS 配置错误,Broker 向客户端通告了错误的地址。

解决:确保docker-compose.yml配置的kafka的 ADVERTISED_LISTENERS 使用 Docker 服务名(容器间通信)或宿主机 IP(外部访问)。

yaml 复制代码
  # ---------- Kafka Broker ----------
  kafka:
    image: wurstmeister/kafka:latest
    container_name: kafka
    restart: always
    ports:
      - "9092:9092"               # 宿主机通过 9092 访问 Kafka
    environment:
      # xxxx

      # 关键配置:客户端应该连接的地址
      # 宿主机客户端(如宿主机运行的 PHP 代码)使用 localhost:9092
      # 容器内客户端(如 php-fpm 容器)使用服务名 kafka:9092(Docker 网络自动解析)
      # KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092

重复消费问题处理和踩坑经历

在使用上面的生产者/消费者示例时,你可能会遇到一个令人困惑的现象:消费者明明已经"消费"了消息,但重启消费者后(Ctrl+C后再次启动),同样的消息又被重新消费了一遍。这并非 Kafka 的 Bug,而是 Kafka 与普通消息队列(如 Redis 队列)在消息删除机制上的根本差异。

为什么 Kafka 不会自动删除消息?

传统消息队列(如 RabbitMQ、Redis List)在消息被消费后会立即从队列中删除。但 Kafka 的设计哲学完全不同:消息不会因为被消费而删除。消息的删除只与两个因素有关:

  • 保留时间(默认 168 小时 = 7 天)
  • 分区大小限制(默认无限制)

也就是说,即使所有消费者都已经读过了某条消息,它依然会留在 Kafka 中,直到 7 天后才被自动清理。

那么 Kafka 如何知道哪些消息已经被消费了呢?答案是通过偏移量(offset) 。每条消息在分区内都有一个唯一的递增序号(offset)。消费者消费完消息后,会**提交(commit)**一个偏移量,表示"我已经处理到这个位置了,下次请从这里开始"。偏移量不是存储在消息里的,而是由消费者提交到 Kafka 的内部主题 __consumer_offsets 中,并且与 消费者组(group.id 绑定。

重复消费的原因

运行上面的消费者代码(使用 RdKafka\Consumer + 自动提交),你会观察到:

  1. 生产者发送了 4 条订单消息。
  2. 第一次启动消费者,正常消费了这 4 条消息,控制台输出处理日志。
  3. Ctrl+C 强制停止消费者。
  4. 再次启动消费者,同样的 4 条消息又被重复消费一遍

虽然在代码中设置了 enable.auto.commit = true,但自动提交是在后台每隔 auto.commit.interval.ms 秒执行一次。当消费者被 Ctrl+C 强制终止时,可能正好错过了提交窗口,导致偏移量没有更新。更关键的是,RD_KAFKA_OFFSET_STORED 表示"从已存储的偏移量开始消费",而由于从未成功提交过,存储的偏移量始终是 0,所以每次启动都会从头开始。

解决方案演进

为了解决这个问题,我和DeepSeek进行了长达2小时的沟通个分析,最终比较完美的解决了这个问题。

【1】关闭自动提交,改为手动提交

最直接的思路是关闭自动提交,在业务处理成功后手动提交偏移量。

php 复制代码
$conf->set('enable.auto.commit', 'false');
...
if ($msg && $msg->err == RD_KAFKA_RESP_ERR_NO_ERROR) {
    // 处理业务...
    $consumer->commit($msg);   // 手动提交
}

然而,在这个过程中,我遇到了致命错误:

复制代码
Fatal error: Uncaught Error: Call to undefined method RdKafka\Consumer::commit()

这是因为我的 rdkafka 扩展版本过低(php -i | grep "rdkafka version" 显示为 1.6.0)。旧版本的 RdKafka\Consumer 类根本没有 commit() 方法,而且整体 API 已过时。


【2】升级扩展(遵循 Docker 原则)

一开始,DeepSeek一直告诉我在容器内操作PHP的kafka环境,但我发出了"致命一问":为什么又要在容器内瞎搞呢?要 docker-compose 干啥用的?

后来,经过多次沟通和讨论,得出结论:不要手动进入容器执行 pecl install,那会破坏"代码即环境"的 Docker 哲学。正确的做法是修改 php/Dockerfile,在构建阶段就安装好指定版本的扩展:

dockerfile 复制代码
# 安装 rdkafka 扩展(指定 6.0.5 版本)
RUN pecl install rdkafka-6.0.5 && docker-php-ext-enable rdkafka

然后重建 PHP 容器:

bash 复制代码
docker-compose build php
docker-compose up -d php

升级后,$consumer->commit($msg) 就可以正常工作了。


【3】终极方案:使用现代 API RdKafka\KafkaConsumer

在排查过程中,我发现 rdkafka 官方早已推荐使用 RdKafka\KafkaConsumer 类,而不是老旧的 RdKafka\Consumer。新版 API 的订阅方式更清晰,分区分配自动处理,错误处理也更规范。

推荐的生产级消费者代码:

php 复制代码
<?php
$conf = new RdKafka\Conf();
$conf->set('group.id', 'order-processor-group');
$conf->set('metadata.broker.list', 'kafka:9092');
$conf->set('bootstrap.servers', 'kafka:9092');
$conf->set('auto.offset.reset', 'earliest');
$conf->set('enable.auto.commit', 'false');   // 手动提交

$consumer = new RdKafka\KafkaConsumer($conf); //使用 KafkaConsumer 类, 替代已过时的 RdKafka\Consumer
$consumer->subscribe(['order-events']);

echo "消费者已启动,等待消息...\n";

while (true) {
    $msg = $consumer->consume(1000);
    if ($msg === null) continue;

    if ($msg->err == RD_KAFKA_RESP_ERR_NO_ERROR) {
        // 处理消息(如 JSON 解析、业务逻辑)
        $order = json_decode($msg->payload, true);
        echo "处理订单: {$order['order_id']}\n";

        // 关键:业务成功后手动提交偏移量
        $consumer->commit($msg);
    } elseif ($msg->err == RD_KAFKA_RESP_ERR__PARTITION_EOF) {
        continue;   // 分区已读完
    } elseif ($msg->err == RD_KAFKA_RESP_ERR__TIMED_OUT) {
        continue;   // 正常超时
    } else {
        echo "错误: " . rd_kafka_err2str($msg->err) . "\n";
    }
}

验证手动提交是否生效:

启动消费者,消费几条消息后按 Ctrl+C 终止。再次启动,不会重复消费。用以下命令查看消费者组的偏移量:

bash 复制代码
docker exec -it kafka kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --group order-processor-group \
  --describe

输出中 CURRENT-OFFSET 等于 LOG-END-OFFSET 时,表示偏移量已正确提交。


踩坑总结:

坑点 现象 解决方案
消费者重启后重复消费 每次启动都从头消费 关闭自动提交,手动提交偏移量
Call to undefined method commit() 手动提交代码报错 升级 rdkafka 扩展到 6.x 版本
在容器内手动安装扩展 破坏环境一致性 修改 Dockerfile,重建容器
KAFKA_ADVERTISED_LISTENERS 配置错误 客户端始终连接 localhost 设置为 kafka:9092(容器间通信)
使用过时的 RdKafka\Consumer API 代码冗长、易出错 改用 RdKafka\KafkaConsumer
  • 消息不删除,只移动偏移量:Kafka 的消息不会因消费而消失,只通过偏移量记录消费进度。
  • 消费者组:同一组内的消费者共享偏移量,重启后从上次提交的位置继续。
  • 手动提交:确保业务处理成功后提交偏移量,避免重复消费。
  • 至少一次语义:手动提交 + 幂等性处理是生产环境的标准做法。

本文中使用的 docker-project 源代码:https://gitee.com/rxbook/docker-demo-2026

相关推荐
却话巴山夜雨时i4 小时前
互联网大厂Java面试实录:从Spring Boot到Kafka的技术问答
spring boot·redis·flink·kafka·java面试·rest api·互联网大厂
杼蛘4 小时前
Kali下载与简单使用/MariaDB安装/Docker安装/MySQL镜像安装
mysql·docker·kali·mariadb
IT_陈寒4 小时前
Python的异步陷阱:我竟然被await坑了一整天
前端·人工智能·后端
流觞 无依4 小时前
DedeCMS plus/digg.php 顶踩注入(SQL注入)修复教程
sql·安全·php
weixin_408099674 小时前
【保姆级教程】易语言调用 OCR 文字识别 API(从0到1完整实战 + 示例源码)
图像处理·人工智能·后端·ocr·api·文字识别·易语言
一定要AK4 小时前
SpringBoot 教程 IDEA 版
spring boot·后端·intellij-idea
成为你的宁宁4 小时前
【docker镜像加速器配置】
运维·docker·容器
天启HTTP4 小时前
HTTP代理和隧道代理的底层区别与适用场景分析
开发语言·网络协议·tcp/ip·php
M-Ellen4 小时前
从零搭建 Windows + WSL2 + Docker + GitLab CI/CD 完整手册
ci/cd·docker·gitlab