文章目录
-
- [开篇:为什么要学习 Kafka?](#开篇:为什么要学习 Kafka?)
-
- [什么是 Kafka?](#什么是 Kafka?)
- [Kafka 解决了什么问题?](#Kafka 解决了什么问题?)
- kafka的核心概念(新手必读)
- [项目架构设计:将 Kafka 融入现有 Docker 环境](#项目架构设计:将 Kafka 融入现有 Docker 环境)
- [Docker Compose 整合 Kafka](#Docker Compose 整合 Kafka)
-
- 镜像选择说明
- [编辑 `docker-compose.yml`](#编辑
docker-compose.yml) - 创建数据目录并启动服务
- [验证 Kafka 是否正常工作](#验证 Kafka 是否正常工作)
- [宿主机快捷命令封装(Zsh 别名)](#宿主机快捷命令封装(Zsh 别名))
- [Kafka 常用命令速查表(收藏备用)](#Kafka 常用命令速查表(收藏备用))
- [给PHP安装 rdkafka 扩展](#给PHP安装 rdkafka 扩展)
- [PHP 原生生产者/消费者实战](#PHP 原生生产者/消费者实战)
- 踩坑记录汇总
-
- [坑1:PHP 连接 Kafka 超时](#坑1:PHP 连接 Kafka 超时)
- 坑2:消费者收不到任何消息(无报错)
- [坑3:Kafka 容器反复重启](#坑3:Kafka 容器反复重启)
- [坑4:宿主机端口 9092 被占用](#坑4:宿主机端口 9092 被占用)
- 坑5:自动创建的主题分区数不符合预期
- [坑6:连接失败Connection refused问题](#坑6:连接失败Connection refused问题)
- 重复消费问题处理和踩坑经历
书接上回:《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.1、0.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 状态,包括新加的 zookeeper 和 kafka。

验证 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-dev 和 rdkafka 扩展:
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 timeout 或 Connection 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:消费者收不到任何消息(无报错)
现象 :生产者成功,消费者无输出,也不报错。
根本原因 :消费者组已经存在,并且该组已消费过该主题的所有消息,偏移量已提交到最新位置 。
解决方案:
-
更换 group.id(最简单)
php$conf->set('group.id', 'php-demo-group-' . time()); -
重置消费者组的偏移量(高级)
bashkafka-consumer-groups.sh --bootstrap-server localhost:9092 --group php-demo-group --reset-offsets --to-earliest --execute --topic test-topic -
设置 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_LISTENERS 为 localhost: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 + 自动提交),你会观察到:
- 生产者发送了 4 条订单消息。
- 第一次启动消费者,正常消费了这 4 条消息,控制台输出处理日志。
- 按
Ctrl+C强制停止消费者。 - 再次启动消费者,同样的 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