Kafka消费者如何分配分区

1,668 阅读10分钟

1 前言

在之前的文章中KafkaConsumer的poll分析1之加入消费者群组,说到主消费者执行分区分配时会有3种策略,分别是RangeAssignor,RoundRobinAssignor和StickyAssignor,下面就来分析下这三种策略具体是如何进行分区分配的。

2 配置说明

  • 核心接口:org.apache.kafka.clients.consumer.internals.PartitionAssignor
  • 内置策略:基于上面接口的抽象实现类org.apache.kafka.clients.consumer.internals.AbstractPartitionAssignor实现的三个具体类org.apache.kafka.clients.consumer.RangeAssignor、org.apache.kafka.clients.consumer.RoundRobinAssignor和org.apache.kafka.clients.consumer.StickyAssignor
  • 默认策略:org.apache.kafka.clients.consumer.RangeAssignor
  • 配置方式:在构造KafkaConsumer时增加参数partition.assignment.strategy,值为内置的三种策略中的一种,或者是一个实现了PartitionAssignor接口的全类名

3 源码分析

2.1 RangeAssignor

Range策略针对于每个Topic,各个Topic之间分配时没有任何关联。

  • 简介:

假设topic的partitions为numPartitionsForTopic,group中订阅这个topic的member数为consumersForTopic.size(),首先要计算出两个值:

  1. numPartitinosPerConsumer=numPartitionsForTopic/consumersForTopic.size():表示平均每个consumer会分配到几个partition;
  2. consumersWithExtraPartition=numPartitionsForTopic % consumersForTopic.size():表示平均分配后还剩下多少个partition未分配。

分配规则:对于剩下的那些partition分配到前consumersWithExtraPartition个consumer上,也就是前consumersWithExtraPartition个consumer获得TopicPartition列表会比后面多一个,如下所示

而如果 group 中有 consumer 没有订阅这个 topic,那么这个 consumer 将不会参与分配。下面再举个例子,将有两个 topic,一个 partition 有5个,一个 partition 有7个,group 有5个 consumer,但是只有前3个订阅第一个 topic,而另一个 topic 是所有 consumer 都订阅了,那么其分配结果如下:

  • 源码注释:
The range assignor works on a per-topic basis. For each topic, we lay out the available partitions in numeric order and the consumers in lexicographic order. We then divide the number of partitions by the total number of consumers to determine the number of partitions to assign to each consumer. If it does not evenly divide, then the first few consumers will have one extra partition.
For example, suppose there are two consumers C0 and C1, two topics t0 and t1, and each topic has 3 partitions, resulting in partitions t0p0, t0p1, t0p2, t1p0, t1p1, and t1p2.
The assignment will be:
C0: [t0p0, t0p1, t1p0, t1p1]
C1: [t0p2, t1p2]

说明:两个topic分区数无法整除消费者数,所以,第一个消费者C0会多分配一个分区。所以C0消费p0和p1两个分区,C1消费p2分区。(而Range策略只会针对单个Topic而言,所以t0,t1分配的结果一样)

  • 源码:
// partitionsPerTopic表示topic和分区关系,key是topic,value是分区数量
// subscriptions表示订阅关系,key是消费者,value是订阅的topic
@Override
public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                Map<String, Subscription> subscriptions) {
    // 得到topic和订阅的消费者集合信息,例如{t0:[c0, c1], t1:[C0, C1]}
    Map<String, List<String>> consumersPerTopic = consumersPerTopic(subscriptions);
    // 保存topic分区和订阅该topic的消费者关系结果map
    Map<String, List<TopicPartition>> assignment = new HashMap<>();
    for (String memberId : subscriptions.keySet())
        // memberId就是消费者client.id+uuid(kafka在client.id上追加的)
        assignment.put(memberId, new ArrayList<TopicPartition>());

    // 遍历每个topic和消费者集合信息组成的map(由这个遍历可知,range策略分配结果在各个topic之间互不影响)
    for (Map.Entry<String, List<String>> topicEntry : consumersPerTopic.entrySet()) {
        // topic名称
        String topic = topicEntry.getKey();
        // topic的消费者集合信息
        List<String> consumersForTopic = topicEntry.getValue();

        // 当前topic的分区数量
        Integer numPartitionsForTopic = partitionsPerTopic.get(topic);
        // 如果当天topic没有分区,那么继续遍历下一个topic
        if (numPartitionsForTopic == null)
            continue;

        // 消费者集合根据字典排序
        Collections.sort(consumersForTopic);
        // 每个topic分区数量除以消费者数量,得出每个消费者分配到的分区数量
        int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
        // 无法整除的剩余分区数量
        int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();
        // 根据topic名称和分区数量,得到分区集合信息
        List<TopicPartition> partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
        // 遍历订阅当前topic的消费者集合
        for (int i = 0, n = consumersForTopic.size(); i < n; i++) {
            // 分配到的分区的开始位置
            int start = numPartitionsPerConsumer * i + Math.min(i, consumersWithExtraPartition);
            // 分配到的分区数量(整除分配到的分区数量,加上1个无法整除分配到的分区--如果有资格分配到这个分区的话。判断是否有资格分配到这个分区:如果整除后余数为m,那么排序后的消费者集合中前m个消费者都能分配到一个额外的分区)
            int length = numPartitionsPerConsumer + (i + 1 > consumersWithExtraPartition ? 0 : 1);
            // 给消费者分配分区
            assignment.get(consumersForTopic.get(i)).addAll(partitions.subList(start, start + length));
        }
    }
    return assignment;
}
  • 总结:

对于topic来说,先按照分区进行从小到大排序(假设5个分区p0,p1,p2,p3,p4),再根据消费者数(假设3个消费者c0,c1,c2)进行计算,那么就会c0分配2个分区(p0,p1),c1分配2个分区(p2,p3)和c2分配1个分区(p4)。

2.2 RoundRobinAssignor

roundrobin策略针对于全局所有的topic和消费者

  • 简介:

roundrobin的实现原则:列出所有topic-partition和列出所有的consumer member,然后开始分配,一轮之后继续下一轮,假设有一个topic,它有7个partition,group有3个cousumner都订阅了这个topic,分配方式为

对于多个 topic 的订阅,将有两个 topic,一个 partition 有5个,一个 partition 有7个,group 有5个 consumer,但是只有前3个订阅第一个 topic,而另一个 topic 是所有 consumer 都订阅了,那么其分配结果如下

  • 策略图:

  • 源码注释
The round robin assignor lays out all the available partitions and all the available consumers. It then proceeds to do a round robin assignment from partition to consumer. If the subscriptions of all consumer instances are identical, then the partitions will be uniformly distributed. (i.e., the partition ownership counts will be within a delta of exactly one across all consumers.) For example, suppose there are two consumers C0 and C1, two topics t0 and t1, and each topic has 3 partitions, resulting in partitions t0p0, t0p1, t0p2, t1p0, t1p1, and t1p2. The assignment will be:
C0: [t0p0, t0p2, t1p1]
C1: [t0p1, t1p0, t1p2]
When subscriptions differ across consumer instances, the assignment process still considers each consumer instance in round robin fashion but skips over an instance if it is not subscribed to the topic. Unlike the case when subscriptions are identical, this can result in imbalanced assignments. For example, we have three consumers C0, C1, C2, and three topics t0, t1, t2, with 1, 2, and 3 partitions, respectively. Therefore, the partitions are t0p0, t1p0, t1p1, t2p0, t2p1, t2p2. C0 is subscribed to t0; C1 is subscribed to t0, t1; and C2 is subscribed to t0, t1, t2. Tha assignment will be:
C0: [t0p0]
C1: [t1p0]
C2: [t1p1, t2p0, t2p1, t2p2]

这段源码注释中,第一种情况比较好理解,第二种情况套用上面的分配步骤进行推算,过程如下:

  1. 消费者字典排序且构造成环形队列[C0, C1, C2];C0订阅了[t0],C1订阅了[t0, t1],C2订阅了[t0, t1, t2];

  2. topic字段排序即[t0, t1, t2],t0只有一个分区p0,t1有两个分区p0和p1,t2有三个分区p0,p1和p2。得到这三个topic下所有分区集合[t0p0, t1p0, t1p1, t2p0, t2p1, t2p2];

  3. 开始遍历所有分区。

  4. 遍历分区t0p0,同时消费者为C0,C0订阅了t0这个topic,所以分区t0p0分配给C0这个消费者;

  5. 遍历分区t1p0,同时消费者为C1(每次消费者都需要轮询),C1订阅了t1,所以分区t1p0分配给C1这个消费者;

  6. 遍历分区t1p1,同时消费者为C2,C2订阅了t1这个topic,所以分区t1p1分配给C1这个消费者;

  7. 遍历分区t2p0,同时消费者为C0,C0没有订阅t1,轮询到消费者C1,C1也没有订阅t2,轮询到C2,C2订阅了t2这个topic,所以分区t2p0分配给C2这个消费者;

  8. 遍历分区t2p1,同时消费者为C0,C0没有订阅t1,轮询到消费者C1,C1也没有订阅t2,轮询到C2,C2订阅了t2这个topic,所以分区t2p0分配给C2这个消费者;

  9. 遍历分区t2p2,同时消费者为C0,C0没有订阅t1,轮询到消费者C1,C1也没有订阅t2,轮询到C2,C2订阅了t2这个topic,所以分区t2p0分配给C2这个消费者;

  10. 遍历完所有分区,over。

  • 源码
// partitionsPerTopic表示topic和分区关系,key是topic,value是分区数量
// subscriptions表示订阅关系,key是消费者,value是订阅的topic信息
@Override
public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                Map<String, Subscription> subscriptions) {
    Map<String, List<TopicPartition>> assignment = new HashMap<>();
    for (String memberId : subscriptions.keySet())
        assignment.put(memberId, new ArrayList<TopicPartition>());

    // 将消费者集合先按照字典排序,再构造成一个环形迭代器
    CircularIterator<String> assigner = new CircularIterator<>(Utils.sorted(subscriptions.keySet()));
    // 以topic名称排序(SortedSet<String> topics = new TreeSet<>();TreeSet保存topic名称从而实现排序),遍历topic下的分区,得到全部分区(分区主要信息包括topic名称和分区编号)
    for (TopicPartition partition : allPartitionsSorted(partitionsPerTopic, subscriptions)) {
        final String topic = partition.topic();
        // assigner.peek()得到最后一次遍历的消费者。如果遍历的当前分区所属topic不在最后一次遍历的消费者订阅的topic范围内,那么从环形迭代器中轮询选择下一个消费者,直到选择的消费者订阅的topic集合包含当前topic。
        while (!subscriptions.get(assigner.peek()).topics().contains(topic))
            assigner.next();
        // 给消费者分配分区,并轮询到下一个消费者
        assignment.get(assigner.next()).add(partition);
    }
    return assignment;
}
  • CircularIterator

CircularIterator环形迭代器的实现比较简单,内部用一个List存储数据,next()迭代时稍作改造即可,这个环形迭代器的作用就是轮询取值,上面的源码是轮询取消费者:

@Override
public T next() {
    // i初始值为0
    T next = list.get(i);
    // 每次取值后,i的值+1,由于是环形迭代器,为了让i不超过List的最大下标,所以i对list.size()取模。
    i = (i + 1) % list.size();
    return next;
}

2.3 StickyAssignor

  1. 分区的分配要尽可能均匀
  2. 分区的分配尽可能与上次分配的保持相同

第一个目标优先于第二个目标。

假设消费组有3个消费者(c0,c1,c2),它们都订阅了4个主题(t0,t1,t2,t3),并且每个主题有2个分区,最终的分配结果如下:

c0:t0p0,t1p1,t3p0

c1:t0p1,t2p0,t3p1

c2:t1p0,t2p1

看起来,与roundrobin策略一致,

这时,消费者c1脱离了消费者组,那么消费组会执行再均衡操作,

假设是roundrobin策略,

c0:t0p0,t1p0,t2p0,t3p0

c2:t0p1,t1p1,t2p1,t3p1

如果是stickyAssignor策略,先保持c0,c2原来消费的分区不变,再将c1原有的分区均衡分配

c0:t0p0,t1p1,t3p0,t2p0

c2:t1p0,t2p1,t0p1,t3p1

  • 说明:

如果分区发生重分配,那么对于同一个分区而言,有可能之前的消费者和新指派的消费者不是同一个,之前消费者进行到一半的处理,还需要再新指派的消费者中再次复现一遍,这显然很浪费系统资源。

StickyAssignor让分配策略具备一定的“黏性”,尽可能让前后两次分配相同,进而减少系统资源的损耗及其他异常情况发生。

  • 举例说明:

消费者组内有3个消费者(c0,c1和c2),集群中有3个主题(t0,t1和t2),这3个主题分别有3个分区。但是只有c0订阅了t0,c1订阅了t0和t1,c2订阅了t0,t1,t2

此时roundrobin策略:

c0:t0p0

c1:t1p0

c2:t1p1,t2p0,t2p1,t2p2

sticky策略:

c0:t0p0

c1:t1p0,t1p1

c2:t2p0,t2p1,t2p2

假设c0脱离了消费组,

roundrobin策略:

c1:t0p0,t1p1

c2:t1p0,t2p0,t2p1,t2p2

sticky策略:

c1:t1p0,t1p1,t0p0

c2:t2p0,t2p1,t2p2