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 服务。

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,启动失败。

创建数据目录并启动服务

cd ~/docker-project

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

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

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

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

image-20260304135237837

验证 Kafka 是否正常工作

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

docker exec -it kafka 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" 即成功

image-20260304135426186

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

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

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

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

# ========== 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

使用示例

# 查看 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

使用别名

# 查看消费者组
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 扩展:

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 容器:

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

$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环境:

docker exec -it php74-fpm bash

在 PHP 容器内执行:

php kafka_test.php

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

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

设计思路

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

生产者代码

~/docker-project/www/ 目录下创建 kafka_demo/kafka_producer.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));
}

执行方式:

# 进入项目目录
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
/**
 * 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);

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

docker exec -it php74-fpm bash
php kafka_demo/kafka_consumer.php

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

image-20260410163811282

在kafka中查看消息情况:

首先,进入 Kafka 容器:docker exec -it kafka 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。
检查方法:

# 进入 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(最简单)

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

    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

# 错误做法
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

ports:
  - "9093:9092"
environment:
  KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9093

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

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

environment:
  KAFKA_NUM_PARTITIONS: 3

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

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(外部访问)。

  # ---------- 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】关闭自动提交,改为手动提交

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

$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 干啥用的?

image-20260410172129914

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

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

然后重建 PHP 容器:

docker-compose build php
docker-compose up -d php

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


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

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

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

<?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 终止。再次启动,不会重复消费。用以下命令查看消费者组的偏移量:

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码农兴哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值