Kafka PHP 客户端操作
PHP 中操作 Kafka 主要通过 rdkafka 扩展(基于 librdkafka 的 C 扩展),提供高性能的生产和消费能力。本节将详细讲解 rdkafka 的安装配置、消息生产、消息消费、分区策略、Consumer Group Rebalance 以及生产级封装实践。
安装与配置
安装 rdkafka 扩展
bash
# 安装 librdkafka(C 库)
# macOS
brew install librdkafka
# CentOS / RHEL
yum install -y librdkafka-devel
# Ubuntu / Debian
apt-get install -y librdkafka-dev
# 安装 PHP rdkafka 扩展
pecl install rdkafka
# 启用扩展
echo "extension=rdkafka.so" >> /path/to/php.ini
# 验证安装
php -m | grep rdkafka
php -r 'echo phpversion("rdkafka");'连接配置
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\Producer;
use RdKafka\KafkaConsumer;
class KafkaConfigFactory
{
/**
* 创建 Producer 配置
*/
public static function createProducerConfig(string $brokers = '127.0.0.1:9092'): Conf
{
$conf = new Conf();
// Broker 地址列表
$conf->set('bootstrap.servers', $brokers);
// 消息发送超时
$conf->set('message.timeout.ms', 5000);
// 请求超时
$conf->set('request.timeout.ms', 10000);
// 重试配置
$conf->set('retry.backoff.ms', 100);
$conf->set('message.send.max.retries', 3);
// ACK 确认级别
// 0(none):不等确认,最快但可能丢消息
// 1(leader):Leader 确认即可,折中方案
// all(-1):所有 ISR 副本确认,最可靠
$conf->set('acks', 'all');
// 启用幂等 Producer(防止重试导致重复)
$conf->set('enable.idempotence', 'true');
// 批量发送配置
$conf->set('linger.ms', 5); // 等待 5ms 累积批量消息
$conf->set('batch.size', 16384); // 批量大小 16KB
// 压缩
$conf->set('compression.type', 'snappy');
// 日志级别
$conf->set('log_level', 3); // 0=debug, 1=info, 2=notice, 3=warning, 4=error, 5=critical, 6=alert, 7=emerg
return $conf;
}
/**
* 创建 Consumer 配置
*/
public static function createConsumerConfig(
string $brokers = '127.0.0.1:9092',
string $groupId = 'php-consumer-group'
): Conf {
$conf = new Conf();
$conf->set('bootstrap.servers', $brokers);
$conf->set('group.id', $groupId);
// Offset 提交策略
$conf->set('enable.auto.commit', 'false'); // 关闭自动提交,手动控制
$conf->set('auto.commit.interval.ms', '5000');
// Offset 初始策略(当没有已提交的 Offset 时)
// earliest: 从最早的消息开始消费
// latest: 从最新的消息开始消费(默认)
// none: 抛出异常(需要手动指定 Offset)
$conf->set('auto.offset.reset', 'earliest');
// 心跳和会话超时
$conf->set('session.timeout.ms', 30000); // 会话超时 30 秒
$conf->set('heartbeat.interval.ms', 10000); // 心跳间隔 10 秒
$conf->set('max.poll.interval.ms', 300000); // 最大处理时间 5 分钟
// 每次拉取的最大消息数
$conf->set('max.poll.records', 500);
// 拉取超时
$conf->set('fetch.max.wait.ms', 500);
$conf->set('fetch.min.bytes', 1);
// Rebalance 回调
$conf->setRebalanceCb(function (RdKafka\KafkaConsumer $consumer, $err, array $partitions = null) {
match ($err) {
RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS => $consumer->assign($partitions),
RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS => $consumer->assign(),
default => null,
};
});
return $conf;
}
}消息生产
基础消息生产
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\Producer;
use RdKafka\Producer\Topic;
use RdKafka\Producer as RdKafkaProducer;
class KafkaProducerService
{
private RdKafkaProducer $producer;
private Topic $topic;
public function __construct(string $brokers = '127.0.0.1:9092')
{
$conf = KafkaConfigFactory::createProducerConfig($brokers);
$this->producer = new RdKafkaProducer($conf);
$this->topic = $this->producer->newTopic('order-events');
}
/**
* 发送消息(同步等待确认)
*/
public function send(string $message, ?string $key = null): bool
{
try {
// produce 方法
// RD_KAFKA_PARTITION_UA: 自动分配分区
// $key: 用于分区路由的 Key(相同 Key 到同一 Partition)
// $message: 消息内容
$this->topic->produce(
partition: RD_KAFKA_PARTITION_UA,
msgflags: 0,
key: $key,
payload: $message,
);
// 必须调用 poll 让底层处理事件(发送消息、回调等)
// 在实际应用中,应该在循环中调用
$this->producer->poll(0);
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 发送 JSON 消息
*/
public function sendJson(string $topicName, array $data, ?string $key = null): bool
{
$topic = $this->producer->newTopic($topicName);
$payload = json_encode(array_merge([
'event_id' => uniqid('evt_', true),
'event_time' => date('Y-m-d\TH:i:s\Z'),
'version' => '1.0',
], $data));
$topic->produce(
partition: RD_KAFKA_PARTITION_UA,
key: $key,
payload: $payload,
);
$this->producer->poll(0);
return true;
}
/**
* 刷新消息队列(确保所有消息发送完成)
*/
public function flush(int $timeoutMs = 10000): void
{
$this->producer->flush($timeoutMs);
}
public function __destruct()
{
$this->producer->flush(10000);
}
}异步生产与回调
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\Message;
use RdKafka\Producer;
class AsyncKafkaProducer
{
private Producer $producer;
private int $pendingCallbacks = 0;
public function __construct(string $brokers = '127.0.0.1:9092')
{
$conf = KafkaConfigFactory::createProducerConfig($brokers);
// 设置消息发送回调
$conf->setDrMsgCb(function (Producer $producer, Message $message): void {
if ($message->err === RD_KAFKA_RESP_ERR_NO_ERROR) {
// 发送成功
// log: "Message delivered to partition {$message->partition} at offset {$message->offset}"
} else {
// 发送失败
// log: "Delivery failed: {$message->errstr()}"
}
$this->pendingCallbacks--;
});
$this->producer = new Producer($conf);
}
/**
* 异步发送消息(不等待确认)
*/
public function sendAsync(string $topicName, string $payload, ?string $key = null): void
{
$topic = $this->producer->newTopic($topicName);
$topic->produce(
partition: RD_KAFKA_PARTITION_UA,
key: $key,
payload: $payload,
);
$this->pendingCallbacks++;
}
/**
* 事件循环(处理回调)
*/
public function poll(int $timeoutMs = 100): void
{
while ($this->pendingCallbacks > 0) {
$this->producer->poll($timeoutMs);
}
}
}消息消费
基础消息消费
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\KafkaConsumer;
use RdKafka\Message;
use RdKafka\TopicPartition;
class KafkaConsumerService
{
private KafkaConsumer $consumer;
public function __construct(
string $brokers,
string $groupId,
array $topics
) {
$conf = KafkaConfigFactory::createConsumerConfig($brokers, $groupId);
$this->consumer = new KafkaConsumer($conf);
$this->consumer->subscribe($topics);
}
/**
* 消费消息循环
*/
public function consume(callable $handler, int $timeoutMs = 1000): void
{
while (true) {
$message = $this->consumer->consume($timeoutMs);
if ($message === null) {
continue;
}
match ($message->err) {
RD_KAFKA_RESP_ERR_NO_ERROR => $this->handleMessage($message, $handler),
RD_KAFKA_RESP_ERR__PARTITION_EOF => null, // 到达分区末尾
RD_KAFKA_RESP_ERR__TIMED_OUT => null, // 消费超时
default => throw new \RuntimeException(
'Kafka consume error: ' . $message->errstr()
),
};
}
}
/**
* 处理单条消息
*/
private function handleMessage(Message $message, callable $handler): void
{
try {
$data = json_decode($message->payload, true) ?? $message->payload;
$result = $handler([
'topic' => $message->topic_name,
'partition' => $message->partition,
'offset' => $message->offset,
'key' => $message->key,
'headers' => $message->headers ?? [],
'timestamp' => $message->timestamp,
'payload' => $data,
]);
if ($result === true) {
// 同步提交当前 Offset
$this->consumer->commit($message);
}
// result === false: 不提交 Offset(消息将被重新消费)
} catch (\Throwable $e) {
// 异常处理:记录错误但不提交 Offset
error_log(sprintf(
"Kafka consume error: %s (topic=%s, partition=%d, offset=%d)",
$e->getMessage(),
$message->topic_name,
$message->partition,
$message->offset
));
}
}
/**
* 手动提交 Offset
*/
public function commit(): void
{
$this->consumer->commit();
}
/**
* 异步提交 Offset(性能更好,但不保证立即完成)
*/
public function commitAsync(): void
{
$this->consumer->commitAsync();
}
/**
* 关闭消费者
*/
public function close(): void
{
$this->consumer->close();
}
}指定 Offset 消费
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\KafkaConsumer;
use RdKafka\TopicPartition;
class OffsetSeeker
{
/**
* 从指定 Offset 开始消费
*/
public function seekToOffset(
KafkaConsumer $consumer,
string $topic,
int $partition,
int $offset
): void {
$topicPartition = new TopicPartition($topic, $partition, $offset);
$consumer->assign([$topicPartition]);
}
/**
* 从最早的消息开始消费
*/
public function seekToBeginning(KafkaConsumer $consumer, string $topic, int $partition): void
{
$topicPartition = new TopicPartition($topic, $partition);
$consumer->seekToBeginning([$topicPartition]);
}
/**
* 从最新的消息开始消费
*/
public function seekToEnd(KafkaConsumer $consumer, string $topic, int $partition): void
{
$topicPartition = new TopicPartition($topic, $partition);
$consumer->seekToEnd([$topicPartition]);
}
/**
* 获取指定 Partition 的水位(Watermark)
*/
public function getWatermarks(
KafkaConsumer $consumer,
string $topic,
int $partition
): array {
$consumer->queryWatermarkOffsets(
$topic,
$partition,
$low,
$high,
1000 // timeout ms
);
return [
'low' => $low, // 最早可用 Offset
'high' => $high, // 最新 Offset(下一条待写入的)
];
}
/**
* 获取消费者当前的 Offset(已提交的)
*/
public function getCommittedOffsets(
KafkaConsumer $consumer,
string $topic,
int $partition
): ?int {
$topicPartition = new TopicPartition($topic, $partition);
$committed = $consumer->getCommittedOffsets([$topicPartition], 1000);
if (isset($committed[$topic->partition])) {
return $committed[$topic->partition]->getOffset();
}
return null;
}
}分区策略
自定义分区器
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\Producer;
class CustomPartitioner
{
/**
* 创建带自定义分区器的 Producer
* rdkafka 默认使用 consistent_random 分区器(hash(key) + 随机)
*/
public static function createWithPartitioner(string $brokers, string $partitionerName): Producer
{
$conf = new Conf();
$conf->set('bootstrap.servers', $brokers);
// 内置分区器:
// - consistent_random(默认):Key 存在则 hash,否则随机
// - consistent:纯 hash
// - random:纯随机
// - murmur2:MurmurHash2(Java Kafka 默认)
// - murmur2_random:MurmurHash2 + 随机
$conf->set('partitioner', $partitionerName);
// 自定义分区回调(仅 consistent/consistent_random 支持)
$conf->setPartitionerCb(function (TopicPartition $topicPartition, array $key, array $partitions) {
// $key: [0 => key_bytes, 1 => key_length]
// $partitions: 可用分区 ID 列表
if ($key[1] > 0) {
// 有 Key,按 hash 分区
$hashValue = crc32($key[0]);
$partitionIndex = abs($hashValue) % count($partitions);
$topicPartition->setPartition($partitions[$partitionIndex]);
} else {
// 无 Key,轮询分区
static $counter = 0;
$partitionIndex = $counter % count($partitions);
$counter++;
$topicPartition->setPartition($partitions[$partitionIndex]);
}
});
return new Producer($conf);
}
}Consumer Group Rebalance
Rebalance 监听
php
<?php
declare(strict_types=1);
namespace App\MessageQueue\Kafka;
use RdKafka\Conf;
use RdKafka\KafkaConsumer;
use RdKafka\TopicPartition;
class RebalanceAwareConsumer
{
private KafkaConsumer $consumer;
public function __construct(string $brokers, string $groupId, array $topics)
{
$conf = KafkaConfigFactory::createConsumerConfig($brokers, $groupId);
// 注册 Rebalance 回调
$conf->setRebalanceCb(function (KafkaConsumer $consumer, int $err, ?array $partitions = null): void {
switch ($err) {
case RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS:
// 被分配了新的 Partition
$consumer->assign($partitions);
$this->onPartitionsAssigned($partitions);
break;
case RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS:
// Partition 被回收(Rebalance 前)
$this->onPartitionsRevoked($partitions);
$consumer->assign();
break;
default:
// 异常
error_log("Rebalance error: {$err}");
break;
}
});
$this->consumer = new KafkaConsumer($conf);
$this->consumer->subscribe($topics);
}
/**
* Partition 分配回调
* 可在此处初始化状态、恢复 Offset
*/
private function onPartitionsAssigned(array $partitions): void
{
foreach ($partitions as $tp) {
error_log(sprintf(
"Assigned partition: topic=%s, partition=%d, offset=%d",
$tp->getTopic(),
$tp->getPartition(),
$tp->getOffset()
));
}
}
/**
* Partition 回收回调
* 可在此处提交 Offset、清理资源
*/
private function onPartitionsRevoked(array $partitions): void
{
// 提交当前处理的 Offset
$this->consumer->commit();
foreach ($partitions as $tp) {
error_log(sprintf(
"Revoked partition: topic=%s, partition=%d",
$tp->getTopic(),
$tp->getPartition()
));
}
}
}实战示例
php
<?php
declare(strict_types=1);
// producer_example.php — 生产消息
require __DIR__ . '/vendor/autoload.php';
use App\MessageQueue\Kafka\KafkaProducerService;
$producer = new KafkaProducerService('127.0.0.1:9092');
// 发送订单创建事件
$producer->sendJson('order-events', [
'event_type' => 'order.created',
'order_id' => 1001,
'user_id' => 2001,
'amount' => 99.90,
'items' => [
['product_id' => 'SKU-001', 'quantity' => 2],
['product_id' => 'SKU-002', 'quantity' => 1],
],
], 'order_id_1001');
$producer->flush();
// consumer_example.php — 消费消息
require __DIR__ . '/vendor/autoload.php';
use App\MessageQueue\Kafka\KafkaConsumerService;
$consumer = new KafkaConsumerService(
brokers: '127.0.0.1:9092',
groupId: 'order-processor',
topics: ['order-events'],
);
$consumer->consume(function (array $message): bool {
$payload = $message['payload'];
$eventType = $payload['event_type'] ?? 'unknown';
error_log(sprintf(
"Processing event: %s (topic=%s, partition=%d, offset=%d)",
$eventType,
$message['topic'],
$message['partition'],
$message['offset']
));
// 处理业务逻辑...
// OrderService::handleEvent($payload);
return true; // ACK
});注意事项
- poll 调用:Producer 必须定期调用
poll()来处理底层事件,否则可能内存泄漏 - 手动提交:生产环境使用手动提交 Offset(
enable.auto.commit=false),确保消息处理成功后再提交 - 错误处理:消费异常时不要提交 Offset,让消息被重新消费
- Consumer Lag:监控消费者 Lag(消息积压量),及时扩容
- 资源清理:Consumer 进程退出前必须调用
close(),否则会触发 Rebalance
最佳实践
- 配置调优:根据业务调整
linger.ms、batch.size、compression.type等参数 - 幂等生产:开启
enable.idempotence=true防止网络重试导致消息重复 - 批量消费:在 Consumer 中批量处理消息后统一提交 Offset,减少提交次数
- 优雅退出:捕获 SIGTERM/SIGINT 信号,先提交 Offset 再关闭 Consumer
- 连接复用:Producer 和 Consumer 应长期运行,避免频繁创建销毁
下一节
继续学习:Kafka 最佳实践