Kafka为Consumer分派分区:RangeAssignor和RoundRobinAssignor

看过Kafka关于消费者的join Group的代码以后,我们可以知道,每一个Consumer的代理对象ConsumerCoordinator代表这个Consumer进行join Group操作,实际上是向GroupCoordinator发起JoinGroup请求。GroupCoordinator只负责告知Consumer其角色信息,即成为Leader还是Follower,并不负责分区分配,即管理Consumer和TopicPartition的对应关系。这种管理是被选举为Leader 的ConsumerCoordinator根据配置的分区分派规则来决定的。我们一起来从代码层面分析Kafka内置的分区分派算法RangeAssigner和RoundRobinAssigner。

为了不让读者感觉突兀,我们从ConsumerCoordinator收到JoinGroupResponse开始分析代码,即我们创建了一个消费者对象,开始消费消息的时候,底层会向远程的GroupCoordinator发起joinGroup请求,GroupCoordinator会进行处理并响应:

private class JoinGroupResponseHandler extends CoordinatorResponseHandler<JoinGroupResponse, ByteBuffer> {

            @Override
            public JoinGroupResponse parse(ClientResponse response) {
                return new JoinGroupResponse(response.responseBody());
            }

            //收到JoinGroup响应以后的回调方法
            @Override
            public void handle(JoinGroupResponse joinResponse, RequestFuture future) {
                Errors error = Errors.forCode(joinResponse.errorCode());
                if (error == Errors.NONE) {//没有发生错误
                    log.debug("Received successful join group response for group {}: {}", groupId, joinResponse.toStruct());
                    AbstractCoordinator.this.memberId = joinResponse.memberId();
                    AbstractCoordinator.this.generation = joinResponse.generationId();
                    AbstractCoordinator.this.rejoinNeeded = false;
                    AbstractCoordinator.this.protocol = joinResponse.groupProtocol();
                    sensors.joinLatency.record(response.requestLatencyMs());
                    if (joinResponse.isLeader()) {
                        //责任链模式,将future加入到当前这个RequestFuture所维护的List
                        onJoinLeader(joinResponse).chain(future);//我是leader

                    } else {
                        onJoinFollower().chain(future);
                    }
                } 
            }
            else{//存在错误
              //略
           }
        }

在发送ConsumerCoordinator发送JoinGroup的时候,会保存一个回调,用来对JoinGroup的响应进行处理。JoinGroup的response信息携带了以下信息:

  • memberId:远程的GroupCoordinator分配给这个Consumer的唯一id;
  • generationId:年代信息,由于每当有一个consumer加入group都会发生一次rebalance,每次rebalance叫做一个generation并且generationId自增1,因此response中携带该generationId,用来防止由于丢包、重复包等信息,造成ConsumerCoordinator和GroupCoordinator之间发生误解;
  • groupProtocol:组协议,看似非常抽象,其实就是指远程的GroupCoordinator确定下来的分区分派方法,即协商一致的分区分派算法。远程的GroupCoordinator会从ConsumerCoordinator的JoinGroup请求中提取该Consumer所支持的分区分派算法,然后选择一个大多数Consumer都支持的算法。如果我们在配置文件里面不进行显式配置,则使用RangeAssigner;
  • isLeader:是否为leader,只有Leader 才有资格根据返回的分区分派规则为所有的consumer分配分区;
  • leaderId:leaderId代表了被选举为leader的consumer的memberId;
  • members 如果这个Consumer被选举为leader,它会收到group中所有成员的信息(上帝视角),即所有的memberId以及每个member订阅的topic的信息都会告知这个Leader Consumer。

如果当前选举为leader,则会执行onJoinLeader()进行分区分配:

      /**
         *  Consumer Group Leader收到response并且自己是leader 的时候会被调用
         *  与onJoinFollower的区别是,此时SyncGroupRequest请求中的groupAssignment不是空的
         * @param joinResponse
         * @return
         */
        private RequestFuture onJoinLeader(JoinGroupResponse joinResponse) {
            try {
                // perform the leader synchronization and send back the assignment for the group
                //join group成功以后,如果自己是leader,那么 joinResponse.groupProtocol()中应该保存了assignor的名字,
                //根据该名字获取对应的assignor,然后进行分区分配,查看ConsumerCoordinator.performAssignment()
                Map groupAssignment = performAssignment(joinResponse.leaderId(), joinResponse.groupProtocol(),
                        joinResponse.members());

                SyncGroupRequest request = new SyncGroupRequest(groupId, generation, memberId, groupAssignment);
                log.debug("Sending leader SyncGroup for group {} to coordinator {}: {}", groupId, this.coordinator, request);
                return sendSyncGroupRequest(request);
            } catch (RuntimeException e) {
                return RequestFuture.failure(e);
            }
        }
    /**
         * 这个方法会在AbstractCoordinator.onJoinLeader()中被调用,即只有leader身份的coordinator才会调用这个方法
         */
        @Override
        protected Map<String, ByteBuffer> performAssignment(String leaderId,
                                                            String assignmentStrategy,
                                                            Map<String, ByteBuffer> allSubscriptions) {
            //从配置的assignors中,寻找名字为assignmentStrategy 的assignor
            //查看KafkaConsumer的构造函数中可以看到,this.assignors来自配置项@code ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG
            PartitionAssignor assignor = lookupAssignor(assignmentStrategy);
            if (assignor == null)
                throw new IllegalStateException("Coordinator selected invalid assignment protocol: " + assignmentStrategy);

          //将所有consumer订阅的topic的集合加入到allSubscribedTopics中
            Set<String> allSubscribedTopics = new HashSet<>();
            Map<String, Subscription> subscriptions = new HashMap<>();
            for (Map.Entry<String, ByteBuffer> subscriptionEntry : allSubscriptions.entrySet()) {
                Subscription subscription = ConsumerProtocol.deserializeSubscription(subscriptionEntry.getValue());
                subscriptions.put(subscriptionEntry.getKey(), subscription);
                allSubscribedTopics.addAll(subscription.topics());
            }

            // the leader will begin watching for changes to any of the topics the group is interested in,
            // which ensures that all metadata changes will eventually be seen
            this.subscriptions.groupSubscribe(allSubscribedTopics);
            metadata.setTopics(this.subscriptions.groupSubscription());

            // update metadata (if needed) and keep track of the metadata used for assignment so that
            // we can check after rebalance completion whether anything has changed
            client.ensureFreshMetadata();
            assignmentSnapshot = metadataSnapshot;//分区分配以前备份当前的元数据

            log.debug("Performing assignment for group {} using strategy {} with subscriptions {}",
                    groupId, assignor.name(), subscriptions);

            Map<String, Assignment> assignment = assignor.assign(metadata.fetch(), subscriptions);

            log.debug("Finished assignment for group {}: {}", groupId, assignment);

            Map<String, ByteBuffer> groupAssignment = new HashMap<>();
            for (Map.Entry<String, Assignment> assignmentEntry : assignment.entrySet()) {
                ByteBuffer buffer = ConsumerProtocol.serializeAssignment(assignmentEntry.getValue());
                groupAssignment.put(assignmentEntry.getKey(), buffer);
            }

            return groupAssignment;
        }

performAssignment()方法的第三个参数记录了group中所有的成员的Subscription信息,ConsumerProtocol.deserializeSubscription用来把字节码解析成对应的Subcription对象,具体解析方式读者有兴趣自行阅读代码。我们只需看一下Subscription的代码就可以知道这个对象所代表的含义:

    class Subscription {
            private final List topics;//某个member订阅的topic集合
            private final ByteBuffer userData;//用户自定义数据,在subscription()中,用户可以添加自定义数据

            public Subscription(List topics, ByteBuffer userData) {
                this.topics = topics;
                this.userData = userData;
            }

           //getter and setter
        }

每一个Subscription对象代表了这个consumer的订阅信息,即所订阅的topic的集合以及一些额外数据userData。userData与Partition分派无关,不做讲解。

方法lookupAssignor()用来根据协商一致的分区分派协议的名字,获得这个分派协议的对象:

        //查找assignor
        private PartitionAssignor lookupAssignor(String name) {
            for (PartitionAssignor assignor : this.assignors) {
                if (assignor.name().equals(name))
                    return assignor;
            }
            return null;
        }

this.assignors是我们在构造KafkaConsumer对象的时候读取配置文件所配置的分区分派算法后初始化的assignor对象。大多数情况下,我们不进行任何配置,则会使用内置的RangeAssigner。无论是什么assigner,都必须实现PartitionAssignor

接口中的方法 Map assign(Cluster metadata, Map subscriptions);是核心方法,用来执行分区分派。metadata是集群的元数据信息,subscriptions则是group中所有的member的订阅信息,key为memberId,value为记录订阅信息的Subscription对象。关于Subscription对象,上面已经讲解。

AbstractPartitionAssignor对接口中定义的Map assign(Cluster metadata, Map subscriptions);方法进行了实现:

    @Override
        public Map<String, Assignment> assign(Cluster metadata, Map<String, Subscription> subscriptions) {
            Set<String> allSubscribedTopics = new HashSet<>();//保存所有member订阅的topic
            Map<String, List<String>> topicSubscriptions = new HashMap<>();
            for (Map.Entry<String, Subscription> subscriptionEntry : subscriptions.entrySet()) {
                List<String> topics = subscriptionEntry.getValue().topics();
                allSubscribedTopics.addAll(topics);
                topicSubscriptions.put(subscriptionEntry.getKey(), topics);//取出userData信息
            }

            Map<String, Integer> partitionsPerTopic = new HashMap<>();//每个topic的partition数量信息
            for (String topic : allSubscribedTopics) {//对于这个group里面所有member订阅的所有的topic
                Integer numPartitions = metadata.partitionCountForTopic(topic);
                if (numPartitions != null && numPartitions > 0)
                    partitionsPerTopic.put(topic, numPartitions);
                else
                    log.debug("Skipping assignment for topic {} since no metadata is available", topic);
            }


            Map<String, List<TopicPartition>> rawAssignments = assign(partitionsPerTopic, topicSubscriptions);

            // this class has maintains no user data, so just wrap the results
            Map<String, Assignment> assignments = new HashMap<>();
            for (Map.Entry<String, List<TopicPartition>> assignmentEntry : rawAssignments.entrySet())
                assignments.put(assignmentEntry.getKey(), new Assignment(assignmentEntry.getValue()));
            return assignments;
        }

assign()方法的主要逻辑,就是从集群元数据metadata中提取Group 中所有的topic的partition数目,同时,从所有topic的订阅信息subscriptions中获取每一个topic所订阅的topic的list,根据这两个数据,进行分区分配。我们很直观地想一下,进行分区分配,的确需要并且仅仅需要这两个信息:

  • 每个topic的分区数目;
  • 每个consumer所订阅的分区

其实,AbstractPartitionAssignor.assign()方法没有执行具体的分配算法,具体算法是通过定义了一个抽象方法:

    public abstract Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                        Map<String, List<String>> subscriptions);

然后交给具体的分区分派算法RangeAssigner和RoundRobinAssigner去实现的。

由于是抽象方法,因此强制RangeAssigner和RoundRobinAssignor实现这个抽象方法。我们看RangeAssigner.assign():

        public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                        Map<String, List<String>> subscriptions) {

            Map<String, List<String>> consumersPerTopic = consumersPerTopic(subscriptions);
            Map<String, List<TopicPartition>> assignment = new HashMap<>();//key为consumerID,value为分配给该consumer的TopicPartition
            for (String memberId : subscriptions.keySet())//对于每一个consumer
                assignment.put(memberId, new ArrayList<TopicPartition>());//初始化

            //对于每一个topic,进行分配
            for (Map.Entry<String, List<String>> topicEntry : consumersPerTopic.entrySet()) {
                String topic = topicEntry.getKey();
                List<String> consumersForTopic = topicEntry.getValue();

                //这个topic的partition数量
                Integer numPartitionsForTopic = partitionsPerTopic.get(topic);
                if (numPartitionsForTopic == null)//partition数量为null,直接跳过,忽略
                    continue;

                Collections.sort(consumersForTopic);//对consumer进行排序

                //计算每个consumer分到的partition数量
                int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
                //计算平均以后剩余partition数量
                int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();

                //从0开始作为Partition Index, 构造TopicPartition对象
                List<TopicPartition> partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
                for (int i = 0, n = consumersForTopic.size(); i < n; i++) {//对于当前这个topic的每一个consumer
                    //一定是前面几个consumer会被分配一个额外的TopicPartitiion
                    int start = numPartitionsPerConsumer * i + Math.min(i, consumersWithExtraPartition);
                    int length = numPartitionsPerConsumer + (i + 1 > consumersWithExtraPartition ? 0 : 1);
                    assignment.get(consumersForTopic.get(i)).addAll(partitions.subList(start, start + length));
                }
            }
            return assignment;
        }

RangeAssignor的伪代码为:

       将consumer-topicList的映射转换为topic -> consumerList的映射
       for(每一个topic):
          获取订阅了这个topic的consumerList
          对consumer进行排序
          计算每个consumer平均至少分配到的partition数量 numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
          计算平均分配以后的剩余partition数量 consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();
          for(该topic对应的每一个consumer)
            从partitions中顺序领取自己的最少分排量numPartitionsPerConsumer
            if(剩余分排量consumersWithExtraPartition没有领取完)
               从剩余分排量中领取一个额外的partition
             end
          end
       end

RangeAssignor是逐个topic进行分区计算的。因此,我们可以拿一个topic为例,直观演示其分派过程:

topic的名字为t,含有5个分区(tp0,tp1,tp2,tp3,tp4 ),消费者3个( c0,c1,c2 ):

单个consumer所分配到的最小分区数量:numPartitionsPerConsumer = 5/3 = 1

平均分配以后的剩余partition数量 consumersWithExtraPartition = 5 % 3 = 2

下面逐个消费者开始分配

  • c0 :领取最小分区数量1,由于此时存在剩余额外分区,因此也从中领取一个,因c0会被分配tp0,tp1两个分区
  • c1:由于c0已经领取了tp0,tp1,因此c1会从tp_2开始领取,同样,剩余分区consumersWithExtraPartition还有一个没有领取,因此c1领取。这样,c1会被分配tp2,tp3
  • c2:由于c1已经领取到了tp2,tp3,因2c1会首先领取属于自己的最小分区数1,即领取分区tp4,同时由于consumersWithExtraPartition中的剩余分区已经全部被领取,因此不用领取剩余分区。
    最终,分区分配结果为:

    C0:[tp0,tp1]
    C1:[tp2,tp3]
    C2:[tp4]

现在,我们接着来看RoundRobinAssignor的分区算法,并将其与RangeAssigner进行对比:

     @Override
        public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                        Map<String, List<String>> 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()));//assigner存放了所有的consumer
            for (TopicPartition partition : allPartitionsSorted(partitionsPerTopic, subscriptions)) {//所有的consumer订阅的所有的TopicPartition的List
                final String topic = partition.topic();
                while (!subscriptions.get(assigner.peek()).contains(topic))// 如果当前这个assigner(consumer)没有订阅这个topic,直接跳过
                    assigner.next();
                //跳出循环,表示终于找到了订阅过这个TopicPartition对应的topic的assigner
                //将这个partition分派给对应的assigner
                assignment.get(assigner.next()).add(partition);
            }
            return assignment;
        }
        public List<TopicPartition> allPartitionsSorted(Map<String, Integer> partitionsPerTopic,
                                                        Map<String, List<String>> subscriptions) {
            SortedSet<String> topics = new TreeSet<>();
            for (List<String> subscription : subscriptions.values())//对于所有的订阅的topic的list
                topics.addAll(subscription);//收集所有的topic,并去重

            List<TopicPartition> allPartitions = new ArrayList<>();
            for (String topic : topics) {
                Integer numPartitionsForTopic = partitionsPerTopic.get(topic);// 当前这个topic的数量
                if (numPartitionsForTopic != null)
                    allPartitions.addAll(AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic));
            }
            return allPartitions;//所有的partition的List
        }

      group中所有consumer订阅的topic求并集
      for(每一个topic)
         根据topic的partition数量,构造TopicPartition对象,partitition序号从0开始
      end
      归并所有TopicPartition
      for(每一个TopicPartition)
        以RoundRobin的方式选择一个订阅了这个Topic的Consumer,将这个TopicPartition分派给这个Consumer
      end

在assign()方法中,CircularIterator是一个封装类,让一个有限大小的list变成一个RoundRobin方式的无限遍历。有兴趣的读者可以自行阅读代码。

RoundRobinAssignor与RangeAssignor最大的区别,是进行分区分配的时候不再逐个topic进行,即不是为某个topic完成了分区分派以后,再进行下一个topic的分区分派,而是首先将这个group中的所有consumer订阅的所有的topic-partition按顺序展开,然后,依次对于每一个topic-partition,在consumer进行round robin,为这个topic-partition选择一个consumer。

加入我们有两个topic :t0和t1,每个topic都有3个分区,分区编号从0开始,因此展开以后得到的TopicPartition的List是:[t0p0,t0p1,t0p2,t1p0,t1p1,t1p2]

我们有两个consumer C0,C1,他们都同时订阅了这两个topic。

然后,在这个展开的TopicParititon的List开始进行分派:

  • t0p0 分配给C0
  • t0p1 分配给C1
  • t0p2 分配给C0
  • t1p0 分配给C1
  • t1p1 分配给C0
  • t1p1 分配给C1

从以上分配流程,我们可以很清楚地看到RoundRobinAssignor的两个基本特征:

  1. 对所有topic的topic partition求并集
  2. 基于consumer进行RoundRobin轮询

最终分配结果是:

C0: [t0p0, t0p2, t1p1]
C1: [t0p1, t1p0, t1p2]

RoundRobinAssignor与RangeAssignor的重大区别,就是RoundRobinAssignor是在Group中所有consumer所关注的全体topic中进行分派,而RangeAssignor则是依次对每个topic进行分派。

假如现在有两个Topic:Ta和Tb ,每个topic都有两个partition,同时,有两个消费者Ca和Cb与Cc,这三个消费者全部订阅了这两个主题,那么,通过两种不同的分区分派算法得到的结果将是:

RangeAssignor:

先对Ta进行处理:会将TaP0分派给Ca,将TaP1分派给Cb

在对Tb进行处理:会将TbP0分派给Ca,将TbP1分派给Cb

最终结果是:

Ca:[TaP0,TbP0]
Cb:[TaP1,TbP1]
Cc:[]

RoundRobinAssignor:

将所有TopicPartition展开,变成:[TaP0,TaP1,TbP0,TbP1]

将TaP0分派给Ca,将TaP1分派给Cb,将TbP0分派给Cc,将TbP1分派给Ca

最终分派结果是:

Ca:[TaP0,TbP1]
Cb:[TaP1]
Cc:[TbP0]

可见,RoundRobinAssignor更能够在整个Group的范围内将所有的TopicPartition尽量分散,而RangeAssignor由于是逐个Topic单独进行分派,因此有可能让某些Consumer被分派到较多的partition而另外一些consumer却十分空闲。

你可能感兴趣的:(kafka)