Kafka (1) - 分区策略以及Rebalance

  分区是Kafka中比较有特点的一个设计了,可以降低消费端的消费负载压力,提供更加灵活的消费场景。下面记录一下Kafka中分区相关的问题。

一、生产者的分区

  分区是Topic下的一个物理概念,那么从源头来说在生产者中,一个消息是按照什么规律投放到不同的分区的呢?这里我本地的 kafka-clients 用的是 2.0.0版本的。

首先我们通过send来追踪生产者发送的过程来寻找发送分区的逻辑:

int partition = partition(record, serializedKey, serializedValue, cluster);
tp = new TopicPartition(record.topic(), partition);

这里是调用了一个partition的方法,然后根据返回的分区值来创建 TopicPartition 对象

    /**
     * computes partition for given record.
     * if the record has partition returns the value otherwise
     * calls configured partitioner class to compute the partition.
     */
    private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
        Integer partition = record.partition();
        return partition != null ?
                partition :
                partitioner.partition(
                        record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
    }

这里可以看到如果消息指定了分区,那么就直接返回,如果没有指定分区那么会调用指定的分区对象partitioner来对消息进行分区,这里我们看一下系统默认的生产者分区对象

 .define(PARTITIONER_CLASS_CONFIG,
         Type.CLASS,
         DefaultPartitioner.class,
         Importance.MEDIUM, PARTITIONER_CLASS_DOC)

这里是一个DefaultPartitioner.class的对象,看一下里面的细节,具体的应该就是这个partition方法

    /**
     * Compute the partition for the given record.
     *
     * @param topic The topic name
     * @param key The key to partition on (or null if no key)
     * @param keyBytes serialized key to partition on (or null if no key)
     * @param value The value to partition on or null
     * @param valueBytes serialized value to partition on or null
     * @param cluster The current cluster metadata
     */
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (keyBytes == null) {
            int nextValue = nextValue(topic);
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                // no partitions are available, give a non-available partition
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            // hash the keyBytes to choose a partition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

这里面的逻辑也是比较的清晰,就是如果消息设置了key,那么就通过key来获取一个hash值,然后通过Topic的分区数进行取模,获取对应的分区,如果连key都没有,那就只能获取一个随机值,然后再执行取模操作获取分区值了。

  所以总结一下就是:

  1. 如果在发送消息的时候指定了分区,则将消息投递到指定的分区
  2. 如果没有指定分区,但是消息的key不为空,那么基于key的hash值来指定一个分区
  3. 如果没有指定分区,消息的key也没有指定,那么则通过随机值的方式来获取一个分区

二、消费者的分区

  生产者将消息投递到不同的分区了,那么消费者怎么去消费呢?在消费端是通过消费组的概念来进行消费处理的,也就是一个或多个消费者组成一个消费组,然后以组为最小单元来订阅一个主题。那么上面我们说了,一个Topic对应多个分区,那么一个消费者组里面的不同消费者怎么去消费这不同分区中的数据呢?下面一起来看一下消费者的分区策略。

  消费者的分区指的是一个Topic的不同分区按照什么策略交给不同的消费者来消费,也就是说一个Topic的一个分区只能有一个消费者组中的其中一个消费者来消费,于生产者的分区策略不同,消费者这里存在几种不同的分区策略,Kafka本身是提供了三种消费者分区策略: RangeAssignor,RoundRobinAssignor 和 StickyAssignor。

 

RangeAssignor:

  按照我本地的clients版本来看,这个是默认的策略,直接上源码看一下这个分区策略是按照什么方式对消费者进行分区的:

    @Override
    public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                    Map<String, Subscription> subscriptions) {
        Map<String, List<String>> consumersPerTopic = consumersPerTopic(subscriptions);
        Map<String, List<TopicPartition>> assignment = new HashMap<>();
        for (String memberId : subscriptions.keySet())
            assignment.put(memberId, new ArrayList<TopicPartition>());

        for (Map.Entry<String, List<String>> topicEntry : consumersPerTopic.entrySet()) {
            String topic = topicEntry.getKey();
            List<String> consumersForTopic = topicEntry.getValue();

            Integer numPartitionsForTopic = partitionsPerTopic.get(topic);
            if (numPartitionsForTopic == null)
                continue;

            Collections.sort(consumersForTopic);

            int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
            int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();

            List<TopicPartition> partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
            for (int i = 0, n = consumersForTopic.size(); i < n; i++) {
                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;
    }

  这里有两个比较重要的参数:

  • numPartitionsPerConsumer : 当前主题的分区数 / 订阅这个Topic的消费者数
  • consumersWithExtraPartition : 当前主题分区数 % 订阅这个Topic的消费者数

  然后在进行分区之前通过Collection.sort对所有消费者进行字典顺序排序,下面通过for循环进行消费者分区处理。这里可以range的分区是按照主题进行处理的,每个主题都是单独处理。

那么在这种情况下就会存在一个问题:

  比如一个主题现在有10个分区,然后有3个消费者,那么按照上面的分区逻辑处理后,第一个消费者分到:0,1,2,3 ,第二个分区分到:4,5,6,最后一个消费者分到:7,8,9

也就是说第一个消费者多分配了一个分区。

  那么试想如果这个消费者组一共订阅了5个主题,每个主题都是10个分区,那最终的情况就是第一个消费者会比其他消费者多处理5个分区,而且这种情况在消费者组订阅的主题越多,差异越明显。所以这个也是RangeAssigner的一个问题。

RoundRobinAssignor:

  轮询策略与range最大的不同就是它不再针对单个Topic,而是针对这个消费者组订阅的所有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()));
        for (TopicPartition partition : allPartitionsSorted(partitionsPerTopic, subscriptions)) {
            final String topic = partition.topic();
            while (!subscriptions.get(assigner.peek()).topics().contains(topic))
                assigner.next();
            assignment.get(assigner.next()).add(partition);
        }
        return assignment;
    }

  简单描述一下逻辑:首先是获取到当前消费者组的所有消费者,然后进行排序存储在assigner里面,然后是获取到所有订阅的主题的分区,排序,然后通过for循环的方式进行循环处理,选择一个分区,对其中的消费者进行选择:判断当前消费者是否订阅了当前的主题,如果没有订阅就选择下一个,如果订阅了就分配一个主题分区,这样循环处理。

  表面上看起来这样做解决了Range中订阅多个不同主题然后消费者分配不均衡的问题。但是这中分配算法需要一个前提,就是所有的消费者订阅的Topic是要相同的,也就是上面那个while判断的条件。如果一个消费者组中的消费者订阅的Topic不同,那么最终每个消费者分配到的分区数量其实也是不均衡的,所以在极端情况下这种分配策略也不完美。

StickyAssignor:

  这个是官方提供的第三个分区策略,通过名字可以看出这个策略是有粘性的,什么是粘性的,其实就是会参照于上一次的分区策略。因为分区策略是在rebalance阶段会发生的,如果一个消费组订阅的Tpoic发生比较大的变动,那么无论按照上面哪种方式都可能会出现分区于消费者大换血的情况,这中情况其实对消费的稳定性是非常不利的。

 

三、Rebalance

  重平衡其实是一种协议,规定了一个消费者组其中的消费者如何达成一致来分配订阅Topic的每个分区。那么触发rebalance的条件有以下三种:

  1. 消费者组内的消费者数量发生变化
  2. 消费者组订阅的主题发生变化
  3. 订阅的主题分区发生变化

  具体的策略就是上面提到的三种官方策略,或者是我们根据业务需求自己定义的分配策略。可以这里我们不聊策略的事,想聊一个谁来管理rebalance操作?

  Kafka内部提供了一个coordinator的角色来完成对消费者组的管理,这里的管理就包括rebalance操作和消费offset提交的管理。

  具体的流程就是:当消费组的第一个消费者启动的时候,他会去和kafka server去确认谁是他们消费组的coordinator,之后该组内的所有消费者都会与这个coordinator进行通信

Coordinator是什么:

  一般指的是运行在broker上的Group Coordinator,用于管理消费组中的每个成员,每个Broker都会存在一个Group Coordinator实例。那么消费组怎么来确定哪个Group Coordinator是负责自己这个消费组的呢?具体逻辑可以分为以下两步骤:

  1. 确定 consumer group 位移信息写入consumers_offsets 的哪个分区。具体计算公式:   consumers_offsets partition# = Math.abs(groupId.hashCode() % groupMetadataTopicPartitionCount) 注意:groupMetadataTopicPartitionCount 由 offsets.topic.num.partitions 指定,默认是 50 个分区。

  2. 该分区 leader 所在的 broker 就是被选定的 coordinator 

Rebalance过程:

  • Join阶段:所有的消费者都会向coordinator发送JoinGroup请求,传递订阅信息并告知coordinator自己已经正常启动。当所有的消费者都已经成功加入组后,coordinator会在其中选择一个消费者作为leader,并把组员信息以及订阅Topic的信息都发送给leader。这里的leader消费者负责制定分配计划,就是采用上一节提到的几种策略或者是自定义策略。
  • Sync阶段:所有的消费者会给coordinator发送SyncGroup请求,不同的是leader消费者会把分配计划发送过去,而其他消费者的请求体是空的。而coordinator在接收到leader的分配方案之后,会将内容通过response的方式返回给每个消费者,这样大家就都知道自己负责哪些主题的哪些分区了。

Coordinator的offset提交处理:

  上面我们说过coordinator负责一个消费组的分配和offset管理,分配说过了再来看一下对位移的管理:每执行一次rebalance操作,对应的generation都会加1,表示group进入到了一个新的版本,这样做的目的就是保证比如一个消费者因为一些原因退出了消费组,但是过了一段又添加进来了,那么他上次未执行完的位移提交操作是不能提交的,因为当前组的generation已经改变了。

   

四、Rebalance Listener

  在进行rebalance之前,我们无法确定当前消费者是否还会负责这个分区,而且在rebalance之后当前的offset也没法提交。所以最好的办法就是在我们常规提交逻辑的基础上添加一个Listener来处理意外情况。在subscribe的时候传入Listener对象,这样在rebalance之前和之后会有一个切入点可供我们进行人工干预,记录信息等等。

posted @ 2021-12-27 14:28  SyrupzZ  阅读(243)  评论(0)    收藏  举报