Bootstrap

深入解析 Kafka 消费者组与分区分配机制

在分布式消息系统中,Apache Kafka 以其强大的可扩展性和容错能力脱颖而出。Kafka 的消费者组(Consumer Group)机制是其核心特性之一,它允许多个消费者实例协同工作,共同处理一个主题(Topic)的消息。本文将通过实例详细解析 Kafka 的消费者组如何与主题分区(Partition)进行分配,以及不同场景下的消息消费行为。
一、Kafka 消费者组与分区分配原理
在 Kafka 中,每个消费者组由多个消费者实例组成,这些实例共享一个组 ID。当多个消费者订阅同一个主题时,Kafka 会根据消费者组内的实例数量和主题的分区数量,将分区分配给不同的消费者实例。这种分配机制不仅实现了负载均衡,还提高了系统的容错能力。
如果消费者实例数量与主题分区数量相等,每个消费者将被分配一个分区。如果消费者实例数量少于分区数量,某些消费者会被分配多个分区;反之,如果消费者实例数量多于分区数量,部分消费者将不会接收到任何消息。
二、实例解析
(一)创建主题
在开始之前,我们需要创建一个包含 3 个分区的主题。以下是使用 Kafka Admin API 创建主题的代码示例:
java复制
package com.logicbig.example;

import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.NewTopic;
import java.util.Collections;
import java.util.Properties;
import java.util.stream.Collectors;

public class TopicCreator {
public static void main(String[] args) throws Exception {
createTopic(“example-topic”, 3);
}

private static void createTopic(String topicName, int numPartitions) throws Exception {
    Properties config = new Properties();
    config.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    AdminClient admin = AdminClient.create(config);

    // 检查主题是否已存在
    boolean alreadyExists = admin.listTopics().names().get().stream()
            .anyMatch(existingTopicName -> existingTopicName.equals(topicName));
    if (alreadyExists) {
        System.out.printf("主题已存在: %s%n", topicName);
    } else {
        // 创建新主题
        System.out.printf("创建主题: %s%n", topicName);
        NewTopic newTopic = new NewTopic(topicName, numPartitions, (short) 1);
        admin.createTopics(Collections.singleton(newTopic)).all().get();
    }

    // 描述主题
    System.out.println("-- 描述主题 --");
    admin.describeTopics(Collections.singleton(topicName)).all().get()
            .forEach((topic, desc) -> {
                System.out.println("主题: " + topic);
                System.out.printf("分区数量: %s, 分区 ID: %s%n", desc.partitions().size(),
                        desc.partitions()
                                .stream()
                                .map(p -> Integer.toString(p.partition()))
                                .collect(Collectors.joining(",")));
            });
    admin.close();
}

}
运行上述代码后,将创建一个名为 example-topic 的主题,包含 3 个分区。
(二)消息的发布与消费
接下来,我们将通过代码实现消息的发布和消费,并观察不同消费者数量下的分区分配情况。
java复制
package com.logicbig.example;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class ConsumerGroupExample {
private final static int PARTITION_COUNT = 3;
private final static String TOPIC_NAME = “example-topic”;
private final static int MSG_COUNT = 4;
private static int totalMsgToSend;
private static AtomicInteger msg_received_counter = new AtomicInteger(0);

public static void run(int consumerCount, String[] consumerGroups) throws Exception {
    int distinctGroups = Arrays.stream(consumerGroups).distinct().toArray().length;
    totalMsgToSend = MSG_COUNT * PARTITION_COUNT * distinctGroups;

    ExecutorService executorService = Executors.newFixedThreadPool(consumerCount + 1);
    for (int i = 0; i < consumerCount; i++) {
        String consumerId = Integer.toString(i + 1);
        int finalI = i;
        executorService.execute(() -> startConsumer(consumerId, consumerGroups[finalI]));
    }
    executorService.execute(ConsumerGroupExample::sendMessages);

    executorService.shutdown();
    executorService.awaitTermination(10, TimeUnit.MINUTES);
}

private static void startConsumer(String consumerId, String consumerGroup) {
    System.out.printf("启动消费者: %s, 组: %s%n", consumerId, consumerGroup);
    Properties consumerProps = getConsumerProps(consumerGroup);
    KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
    consumer.subscribe(Collections.singleton(TOPIC_NAME));

    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(2));
        for (ConsumerRecord<String, String> record : records) {
            msg_received_counter.incrementAndGet();
            System.out.printf("消费者 ID:%s, 分区 ID= %s, 键 = %s, 值 = %s, 偏移量 = %s%n",
                    consumerId, record.partition(), record.key(), record.value(), record.offset());
        }
        consumer.commitSync();
        if (msg_received_counter.get() == totalMsgToSend) {
            break;
        }
    }
}

private static void sendMessages() {
    Properties producerProps = getProducerProps();
    KafkaProducer producer = new KafkaProducer<>(producerProps);
    int key = 0;
    for (int i = 0; i < MSG_COUNT; i++) {
        for (int partitionId = 0; partitionId < PARTITION_COUNT; partitionId++) {
            String value = "消息-" + i;
            key++;
            System.out.printf("发送消息 主题: %s, 键: %s, 值: %s, 分区 ID: %s%n",
                    TOPIC_NAME, key, value, partitionId);
            producer.send(new ProducerRecord<>(TOPIC_NAME, partitionId, Integer.toString(key), value));
        }
    }
}

private static Properties getConsumerProps(String consumerGroup) {
    Properties props = new Properties();
    props.put("bootstrap.servers", "localhost:9092");
    props.put("group.id", consumerGroup);
    props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    props.put("auto.offset.reset", "earliest");
    return props;
}

private static Properties getProducerProps() {
    Properties props = new Properties();
    props.put("bootstrap.servers", "localhost:9092");
    props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    return props;
}

}
(三)不同消费者数量下的分区分配

  1. 启动 3 个消费者
    java复制
    public class Running3Consumers {
    public static void main(String[] args) throws Exception {
    String[] consumerGroups = new String[3];
    Arrays.fill(consumerGroups, “test-consumer-group”);
    ConsumerGroupExample.run(3, consumerGroups);
    }
    }
    运行结果如下:
    复制
    启动消费者: 1, 组: test-consumer-group
    启动消费者: 2, 组: test-consumer-group
    启动消费者: 3, 组: test-consumer-group
    ……
    消费者 ID:1, 分区 ID= 1, 键 = 2, 值 = 消息-0, 偏移量 = 4
    消费者 ID:2, 分区 ID= 2, 键 = 3, 值 = 消息-0, 偏移量 = 4
    消费者 ID:3, 分区 ID= 0, 键 = 1, 值 = 消息-0, 偏移量 = 4
    ……
    从结果可以看出,每个消费者被分配了一个分区。
  2. 启动 2 个消费者
    java复制
    public class Running2Consumers {
    public static void main(String[] args) throws Exception {
    String[] consumerGroups = new String[2];
    Arrays.fill(consumerGroups, “test-consumer-group”);
    ConsumerGroupExample.run(2, consumerGroups);
    }
    }
    运行结果如下:
    复制
    启动消费者: 1, 组: test-consumer-group
    启动消费者: 2, 组: test-consumer-group
    ……
    消费者 ID:1, 分区 ID= 0, 键 = 1, 值 = 消息-0, 偏移量 = 8
    消费者 ID:1, 分区 ID= 1, 键 = 2, 值 = 消息
;