Kafka 进阶学习(十四)—— 分区分配策略

220 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情

前言

今天是我 Kafka 学习的第 14 天,今天我学习的内容是 Kafka 的分区分配策略。

我们知道,Kafka 中消息消息是以消费组为维度去消费 topic 中的消息,消费组中会有多个消费者,而 topic 中消息的存储实际上是以分区为维度进行存储的。主题下的每个分区只从属于组中的一个消费者,不可能出现组中的两个消费者负责同一个分区。同一时刻,一条消息只能被组中的一个消费者实例消费。

那么组中的每一个消费者负责哪些分区,这个分配关系是如何确定的呢?下面就让我们一起来学习一下 Kafka 中的分区分配策略。

RangeAssignor 分配策略

RangeAssignor 分配策略的原理是按照 消费者总数 和 分区总数 进行整除运算来获得一个跨度,然后将分区按照 跨度 进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。

对于每一个主题,RangeAssignor 策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。

为了更好的理解 RangeAssignor 策略,下面我们来举一个例子来看一看。

假设消费组内有 2 个消费者 C0 和 C1,都订阅了主题 t0 和 t1,并且每个主题都有 4 个分区 p0、p1、p2、p3,那么最终分区订阅的关系是:

  • C0:t0p0、t0p2、t1p0、t1p2
  • C1:t0p1、t0p3、t1p1、t1p3

从上面看,这种分配策略没有什么太大问题。那么如果每个主题变成三个分区呢,按照字典顺序分配的话,分配的结果如下:

  • C0:t0p0、t0p2、t1p0、t1p2
  • C1:t0p1、t1p1

消费者 C0 就会多分配些分区,可以明显地看到这样的分配并不均匀,如果将类似的情形扩大,则有可能出现部分消费者过载的情况。

RoundRobinAssignor 分配策略

RoundRobinAssignor分 配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过 轮询方式 逐个将分区依次分配给每个消费者。

与前面的 RangeAssignor 策略最大的不同就是 它不再局限于某个主题。如果所有的消费者实例的订阅都是相同的,那么这样最好了,可用统一分配,均衡分配。

还是上面那个例子,每一个 topic 有三个分区,那么首先 t0p0 分配给 C0,t0p1 分配给 C1,t0p2 分配给 C0,接下来在开始分配 t1p0 的分区……,最终的分配结果如下:

  • C0:t0p0、t0p2、t1p1
  • C1:t0p1、t1p0、t1p2

如果同一个消费组内的消费者订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询分配,有可能导致分区分配得不均匀。如果某个消费者没有订阅消费组内的某个主题,那么在分配分区的时候此消费者将分配不到这个主题的任何分区。

什么意思呢?也就是说,消费者组是一个逻辑概念,同组意味着同一时刻分区只能被一个消费者实例消费,换句话说,同组意味着一个分区只能分配给组中的一个消费者。事实上,同组也可以不同订阅,这就是说虽然属于同一个组,但是它们订阅的主题可以是不一样的。

举个例子,假设消费组内有 3 个消费者(C0、C1 和 C2),它们共订阅了 3 个主题(t0、t1、t2),这 3 个主题分别有 p0、p1、p2 个分区,即整个消费组订阅了 t0p0、t1p0、t1p1、t2p0、t2p1、t2p2 这 6 个分区。具体而言,消费者 C0 订阅的是主题 t0,消费者 C1 订阅的是主题 t0 和 t1,消费者 C2 订阅的是主题 t0、t1 和 t2,按照轮询分配的话,C0 应该负责 t0p0,C1 应该负责 t1p0,其余均由 C2 负责。

  • C0:t0p0
  • C1:t1p0
  • C2:t1p1、t2p0、t2p1、t2p2

可以看到上面这么分配也是非最优解的,那么为什么会出现这个情况呢,这是因为,按照轮询 t001 由 C0 负责,t1p0 由 C1 负责,由于同组,C2 只能负责 t1p1,由于只有 C2 订阅了 t2,所以 t2 所有分区由 C2 负责,综合起来就是这个结果。

StickyAssignor 分配策略

我们再来看一下 StickyAssignor 分配策略,“sticky”这个单词可以翻译为“黏性的”,Kafka从 0.11.x 版本开始引入这种分配策略,它主要有两个目的:

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

当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor 分配策略的具体实现要比 RangeAssignor 和 RoundRobinAssignor 这两种分配策略要复杂得多。我们举例来看一下 StickyAssignor 分配策略的实际效果。

订阅信息相同

假设消费组内有 3 个消费者(C0、C1 和 C2),它们都订阅了 4 个主题(t0、t1、t2、t3),并且每个主题有 2 个分区。也就是说,整个消费组订阅了 t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p 1这 8 个分区。最终的分配结果如下:

  • C0:t0p0、t1p1、t3p0
  • C1:t0p1、t2p0、t3p1
  • C2:t1p0、t2p1

这样初看上去似乎与采用 RoundRobinAssignor 分配策略所分配的结果相同,再假设此时消费者 C1 脱离了消费组,那么消费组就会执行再均衡操作,进而消费分区会重新分配。如果采用RoundRobinAssignor 分配策略,那么此时的分配结果如下:

  • C0:t0p0、t1p0、t2p0、t3p0
  • C2:t1p0、t1p1、t2p1、t3p1

如分配结果所示,RoundRobinAssignor 分配策略会按照消费者 C0 和 C2 进行重新轮询分配。如果此时使用的是 StickyAssignor 分配策略,那么分配结果为:

  • C0:t0p0、t1p1、t3p0、t2p0
  • C2:t1p0、t1p1、t2p1、t3p1

可以看到分配结果中保留了上一次分配中对消费者 C0 和 C2 的所有分配结果,并将原来消费者 C1 的“负担”分配给了剩余的两个消费者 C0 和 C2,最终 C0 和 C2 的分配还保持了均衡。

如果发生分区重分配,那么对于同一个分区而言,有可能之前的消费者和新指派的消费者不是同一个,之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。StickyAssignor 分配策略如同其名称中的“sticky”一样,让分配策略具备一定的“黏性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗及其他异常情况的发生

订阅信息不同

到目前为止,我们分析的都是消费者的订阅信息都是相同的情况,我们来看一下订阅信息不同的情况下的处理。

还是上面的例子,消费组内有 3 个消费者(C0、C1 和 C2),集群中有 3 个主题(t0、t1和t2),这 3 个主题分别有 1、2、3 个分区。也就是说,集群中有 t0p0、t1p0、t1p1、t2p0、t2p1、t2p 2这 6 个分区。消费者 C0 订阅了主题 t0,消费者 C 1订阅了主题 t0 和 t1,消费者 C2 订阅了主题 t0、t1 和 t2。

如果此时采用 RoundRobinAssignor 分配策略,那么最终的分配结果如分配如下所示,我们再看一下:

  • C0:t0p0
  • C1:t1p0
  • C2:t1p1、t2p0、t2p1、t2p2

如果此时采用的是 StickyAssignor 分配策略,那么最终的分配结果如下所示:

  • C0:t0p0
  • C1:t1p0、t1p1
  • C2:t2p0、t2p1、t2p2

可以看到这才是一个最优解(消费者 C0 没有订阅主题 t1 和 t2,所以不能分配主题 t1 和 t2 中的任何分区给它,对于消费者 C1 也可同理推断)。

假如此时消费者 C0 脱离了消费组,那么 RoundRobinAssignor 分配策略的分配结果为:

  • C0:t0p0
  • C1:t0p0、t1p1
  • C2:t1p0、t2p0、t2p1、t2p2

可以看到 RoundRobinAssignor 策略 保留了消费者 C1 和 C2 中原有的 3 个分区的分配:t2p0、t2p1 和 t2p2

如果采用的是 StickyAssignor 分配策略,那么分配结果为:

  • C1:t0p0、t1p0、t1p1
  • C2:t2p0、t2p1、t2p2

可以看到 StickyAssignor 分配策略 保留了消费者 C1 和 C2 中原有的 5 个分区的分配:t1p0、t1p1、t2p0、t2p1、t2p2。

如前所述,使用 StickyAssignor 分配策略的一个优点就是可以使分区重分配具备“黏性”,减少不必要的分区移动(即一个分区剥离之前的消费者,转而分配给另一个新的消费者)

自定义分区策略

当然,我们不仅可以任意选用 Kafka 提供的 3 种分配策略,还可以自定义分配策略来实现更多可选的功能。自定义的分配策略必须要实现org.apache.kafka.clients.consumer.internals.PartitionAssignor 接口。对于具体实现细节我们不过多赘述,感兴趣的同学可以自行学习。

参考文档

往期文章