Kafka 消费者重平衡机制详解

一、简介

1. 消费者概念

Kafka消费者是指从Kafka集群中读取消息的客户端应用程序。消费者使用Kafka提供的API来订阅一个或多个主题,然后从主题中拉取消息,并对消息进行处理。Kafka消费者能够以非常高效的方式读取海量、分布式的数据流,并将其转化为有用的业务实现。

2. 消费者群组

Kafka消费者群组是一组消费者实例的集合,它们共同订阅相同的主题,并从主题中拉取消息。在一个消费者群组中,每个消费者实例会处理不同分区的消息,从而提高了系统的吞吐量和可伸缩性。

二、消费者重平衡介绍

1. 重平衡概念

重平衡是指在消费者加入或离开消费者群组时,由消费者协调器(Coordinator)发起的重新分配分区的过程。在重平衡过程中,消费者会停止读取消息,释放已经持有的分区并重新分配新的分区,从而实现消费者负载均衡,避免某些消费者处理过多的消息,而其他消费者处于空闲状态。

2. 重平衡的作用

重平衡可以让消费者群组动态地适应新的场景,比如:

  • 新增消费者。当有新的消费者加入到消费者群组时,重平衡会重新分配所有分区,以确保负载均衡,并使新的消费者开始读取消息。
  • 消费者故障恢复。当某个消费者由于宕机或网络故障退出消费者群组时,重平衡会被触发,在不影响业务的情况下重新分配所有分区给剩余消费者,确保消息能够被消费。
  • 消费者离开群组。当消费者主动离开消费者群组时,重平衡会重新分配所有分区给剩余的消费者,使它们继续消费消息。

三、消费者重平衡机制

1. 协调器的作用

Kafka中,每个消费者组都有一个协调器(Coordinator),协调器负责处理消费者组的一些管理操作,比如协调消费者加入或者离开群组、分配分区等。

2. 重平衡阶段

重平衡是指当消费者组中的消费者个数发生改变或者某个消费者出现故障时,系统自动重新分配分区的过程。重平衡主要包括三个阶段:

a. 分区分配

协调器将当前可用的消费者进行排序,然后根据订阅的主题以及消费者的状态信息,计算出哪些消费者负责消费的哪些分区。

b. 分区再均衡

协调器将分配好的分区信息发送给消费者,并告诉它们是否需要停止消费某些分区或启动消费某些新的分区。消费者在接收到这些信息后,开始停止或启动相应的消费进程。

c. 分区负载均衡

如果某个消费者启动的时间较晚,导致在前两个阶段中无法分配到任何分区,那么这个消费者会继续等待,并在本阶段尝试获取其他消费者的剩余分区,以保证各个消费者之间的负载均衡。

3. 重平衡流程

重平衡的流程如下:

a. 启动协调器

每个消费者启动时,都会自动加入到所属的消费者组中,并通过向协调器发送心跳包来表示自己仍然是活动的。

b. 加入群组

当某个消费者启动后,首先向协调器发送加入群组的请求。如果消费者组中已经存在该消费者,则协调器直接返回成功;否则,协调器会将其添加到消费者列表中,并通知其他消费者进行重平衡。

c. 领取分区并获得分区数据

在新消费者加入后,协调器会按照订阅的主题计算出当前消费者负责消费的分区,并将分配结果发送给消费者。消费者在接收到分配结果后,即可根据分区信息开始消费对应的消息。

四、重平衡策略

Kafka是一款开源的分布式消息队列,支持多个消费者同时订阅同一个topic。为了保证消费者集群内各个节点的负载均衡,Kafka提供了四种重平衡策略。

1. 轮询策略

轮询策略是默认的重平衡策略。它将所有分区按照字典序排序后平均分配给每个消费者节点,如果某个消费者无法分配到足够的分区,那么剩余的分区将继续由其它消费者节点进行重平衡。

import org.apache.kafka.clients.consumer.RangeAssignor;
import org.apache.kafka.common.Cluster;

public class RoundRobinAssignor implements RangeAssignor {
    
    

    @Override
    public Map<String, List<TopicPartition>> assign(Cluster metadata, Map<String, Integer> assignments) {
    
    
        // 对metadata中的所有分区按照字典序排序
        List<PartitionInfo> sortedPartitions = metadata.partitions()
            .stream()
            .sorted(Comparator.comparing(PartitionInfo::toString))
            .collect(Collectors.toList());

        // 将所有消费者节点按照字典序排序
        List<String> sortedConsumers = assignments.entrySet()
            .stream()
            .sorted(Comparator.comparing(Map.Entry::getKey))
            .map(Map.Entry::getKey)
            .collect(Collectors.toList());

        // 平均分配所有分区给所有消费者
        Map<String, List<TopicPartition>> result = new HashMap<>();
        for (int i = 0; i < sortedConsumers.size(); i++) {
    
    
            String consumerId = sortedConsumers.get(i);
            List<TopicPartition> consumerPartitions = new ArrayList<>();
            for (int j = i; j < sortedPartitions.size(); j += sortedConsumers.size()) {
    
    
                PartitionInfo partitionInfo = sortedPartitions.get(j);
                TopicPartition partition = new TopicPartition(partitionInfo.topic(), partitionInfo.partition());
                consumerPartitions.add(partition);
            }
            result.put(consumerId, consumerPartitions);
        }

        return result;
    }

}

2. 范围策略

范围策略将所有分区按照分区ID范围排序后平均分配给每个消费者节点。如果某个消费者无法分配到足够的分区,则跳过该消费者,将剩余的分区再次进行平均分配。

import org.apache.kafka.clients.consumer.RangeAssignor;
import org.apache.kafka.common.Cluster;

public class RangeAssignor implements RangeAssignor {
    
    

    @Override
    public Map<String, List<TopicPartition>> assign(Cluster metadata, Map<String, Integer> assignments) {
    
    
        // 对metadata中的所有分区按照分区ID范围排序
        List<PartitionInfo> sortedPartitions = metadata.partitions()
            .stream()
            .sorted(Comparator.comparing(PartitionInfo::partition))
            .collect(Collectors.toList());

        // 计算每个消费者节点应该分配哪些分区
        Map<String, List<TopicPartition>> result = new HashMap<>();
        int consumerCount = assignments.size();
        int maxPartitionId = sortedPartitions.get(sortedPartitions.size() - 1).partition();
        for (Map.Entry<String, Integer> entry : assignments.entrySet()) {
    
    
            String consumerId = entry.getKey();
            int consumerIndex = entry.getValue();

            List<TopicPartition> consumerPartitions = new ArrayList<>();
            int startPartition = maxPartitionId;
            for (int i = 0; i < sortedPartitions.size(); i++) {
    
    
                PartitionInfo partitionInfo = sortedPartitions.get(i);
                TopicPartition partition = new TopicPartition(partitionInfo.topic(), partitionInfo.partition());
                if (i % consumerCount == consumerIndex) {
    
    
                    consumerPartitions.add(partition);
                    startPartition = partitionInfo.partition();
                } else if (partitionInfo.partition() <= startPartition) {
    
    
                    consumerPartitions.add(partition);
                }
            }

            result.put(consumerId, consumerPartitions);
        }

        return result;
    }

}

3. 模板匹配策略

模板匹配策略是根据消费者订阅的topic和pattern的相似度来决定分配分区的策略。如果两个消费者的订阅模板很相似,则它们会被分配到尽可能相同的一组分区上。

import org.apache.kafka.clients.consumer.RangeAssignor;
import org.apache.kafka.common.Cluster;

public class PatternMatchingAssignor implements RangeAssignor {
    
    

    @Override
    public Map<String, List<TopicPartition>> assign(Cluster metadata, Map<String, Integer> assignments) {
    
    
        // 获取每个消费者订阅的topics
        Map<String, List<String>> consumers = new HashMap<>();
        for (Map.Entry<String, Integer> entry : assignments.entrySet()) {
    
    
            String consumerId = entry.getKey();
            List<String> subscription = metadata.subscriptionForConsumerGroup(consumerId);
            consumers.put(consumerId, subscription);
        }

        // 计算每个消费者应该分配哪些分区
        Map<String, List<TopicPartition>> result = new HashMap<>();
        for (Map.Entry<String, Integer> entry : assignments.entrySet()) {
    
    
            String consumerId = entry.getKey();
            int consumerIndex = entry.getValue();

            List<List<TopicPartition>> partitions = new ArrayList<>();
            List<String> topics = consumers.get(consumerId);
            for (String topic : topics) {
    
    
                // 查找模板与topic相似度最高的消费者
                String matchConsumer = null;
                double maxScore = Double.MIN_VALUE;
                for (String otherConsumerId : consumers.keySet()) {
    
    
                    if (!consumerId.equals(otherConsumerId)) {
    
    
                        List<String> otherTopics = consumers.get(otherConsumerId);
                        double score = score(topic, otherTopics);
                        if (score > maxScore) {
    
    
                            maxScore = score;
                            matchConsumer = otherConsumerId;
                        }
                    }
                }

                // 将匹配到的消费者的分区加入当前消费者的分组中
                List<TopicPartition> topicPartitions = metadata.partitionsForTopic(topic)
                    .stream()
                    .map(partitionInfo -> new TopicPartition(partitionInfo.topic(), partitionInfo.partition()))
                    .collect(Collectors.toList());
                List<TopicPartition> assignedPartitions = new ArrayList<>();
                if (matchConsumer != null) {
    
    
                    List<TopicPartition> matchPartitions = result.get(matchConsumer)
                        .stream()
                        .filter(topicPartitions::contains)
                        .collect(Collectors.toList());
                    assignedPartitions.addAll(matchPartitions);
                }

                // 将剩余的分区平均分配给当前消费者
                List<TopicPartition> remainingPartitions = topicPartitions.stream()
                    .filter(partition -> !assignedPartitions.contains(partition))
                    .collect(Collectors.toList());
                int partitionCount = remainingPartitions.size();
                int consumerCount = assignments.size();
                int start = consumerIndex * partitionCount / consumerCount;
                int end = (consumerIndex + 1) * partitionCount / consumerCount;
                for (int i = start; i < end; i++) {
    
    
                    assignedPartitions.add(remainingPartitions.get(i));
                }

                partitions.add(assignedPartitions);
            }

            // 将所有分组合并
            List<TopicPartition> consumerPartitions = partitions.stream().flatMap(Collection::stream).collect(Collectors.toList());
            result.put(consumerId, consumerPartitions);
        }

        return result;
    }

    private double score(String topic, List<String> topics) {
    
    
        double maxScore = 0.0;
        for (String t : topics) {
    
    
            double score = similarity(topic, t);
            if (score > maxScore) {
    
    
                maxScore = score;
            }
        }
        return maxScore;
    }

    private double similarity(String s1, String s2) {
    
    
        // 计算两个字符串之间的相似度
        // ...
    }

}

4. 自定义策略

如果以上三种策略无法满足需求,我们可以自己实现一个自定义的重平衡策略。只需要实现org.apache.kafka.clients.consumer.RangeAssignor接口即可。

import org.apache.kafka.clients.consumer.RangeAssignor;
import org.apache.kafka.common.Cluster;

public class MyAssignor implements RangeAssignor {
    
    

    @Override
    public Map<String, List<TopicPartition>> assign(Cluster metadata, Map<String, Integer> assignments) {
    
    
        // 自定义的分配逻辑
        // ...
    }

}

五、重平衡的影响和处理

1. 重平衡对消费者的影响

当 Kafka 集群中添加或删除主题分区时,或者消费者加入或离开消费组时,就会发生重平衡。重平衡会导致消费者重新分配分区,这意味着该消费者可能需要重新加载从其他消费者分配来的分区数据。可能会出现以下一些影响:

  • 暂停消费:在重新分配分区期间,消费者可能无法消费消息。这可能会导致一段时间内的暂停,直到所有消费者都完成了重新分配。
  • 重复消费:如果重平衡过程中重新分配了分区,则某些消费者可能会收到之前他们已经处理过的消息。这种情况可能会导致消息被重复消费。
  • 消费延迟:重平衡会引起一些消费者重新加载分区数据,这可能会导致消费者在此期间无法消费消息,从而导致消费延迟。

2. 重平衡的处理方法

为了最小化重平衡带来的影响,我们可以采取以下措施:

  • 提高吞吐量:虽然重平衡会导致消费延迟,但可以利用多个消费者线程来提高吞吐量,从而缩短消费延迟时间。
  • 同步提交偏移量:消费者在处理完一批消息后,会将偏移量提交到 Kafka。在避免重复消费时,同步提交偏移量比异步提交更可靠。因为如果异步提交偏移量是在重新分配分区之前进行的,那么部分处理过的消息可能仍然未提交偏移量,此时就会导致消息被重复消费。
  • 避免长时间消费:长时间消费可能会导致消费者无法快速完成分配的所有分区并重新加入消费组。这可能会导致其他消费者需要等待较长的时间才能完成重平衡。为了避免这种情况,应尽可能限制消费者处理消息的时间。

下面的演示如何实现同步提交偏移量:

try {
    
    
    while (true) {
    
    
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
    
    
            // process the record
        }
        consumer.commitSync(); // 同步提交当前批次的偏移量
    }
} catch (WakeupException e) {
    
    
    // 不处理该异常,因为它只是用来关闭消费者线程的。
} finally {
    
    
    consumer.close(); // 关闭消费者
}

猜你喜欢

转载自blog.csdn.net/u010349629/article/details/130934596