当前 Kafka 默认提供了 3 种分区分配策略:RangeAssignor、 RoundRobinAssignor、StickyAssignor。
Kafka 提供了消费者客户端参数 partition.assignment.strategy 用来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为:org.apache.kafka.clients.consumer.RangeAssignor,即采用 RangeAssignor 分配策略。
RangeAssignor
RangeAssignor 策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。
对于每一个 Topic,RangeAssignor 策略会将消费组内所有订阅这个 Topic 的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。
假设 n=分区数/消费者数量,m=分区数%消费者数量,那么前 m 个消费者每个分配 n+1 个分区,后面的(消费者数量-m)个消费者每个分配 n 个分区。
为了更加通俗的讲解 RangeAssignor 策略,我们不妨再举一些示例。假设消费组内有 2 个消费者 c0 和 c1,都订阅了主题 t0和 t1,并且每个主题都有 4 个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:
1 | 消费者c0:t0p0、t0p1、t1p0、t1p1 |
这样分配的很均匀,那么此种分配策略能够一直保持这种良好的特性呢?我们再来看下另外一种情况。假设上面例子中 2 个主题都只有 3 个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
1 | 消费者c0:t0p0、t0p1、t1p0、t1p1 |
可以明显的看到这样的分配并不均匀,如果将类似的情形扩大,有可能会出现部分消费者过载的情况。
RoundRobinAssignor
RoundRobinAssignor 策略的原理是将消费组内所有消费者以及消费者所订阅的所有 Topic 的 Partition 按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者。
如果同一个消费组内所有的消费者的订阅信息都是相同的,那么 RoundRobinAssignor 策略的分区分配会是均匀的。
举例,假设消费组中有 2 个消费者 c0 和 c1,都订阅了主题 t0 和 t1,并且每个主题都有 3 个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
1 | 消费者c0:t0p0、t0p2、t1p1 |
如果同一个消费组内的消费者所订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询分配,有可能会导致分区分配的不均匀。如果某个消费者没有订阅消费组内的某个 Topic,那么在分配分区的时候此消费者将分配不到这个 Topic 的任何分区。
举例,假设消费组内有 3 个消费者 c0、c1和c2,它们共订阅了3个主题:t0、t1、t2,这3个主题分别有1、2、3个分区,即整个消费组订阅了 t0p0、t1p0、t1p1、t2p0、t2p1、t2p2 这 6 个分区。具体而言,消费者 c0 订阅的是主题 t0,消费者 c1 订阅的是主题 t0和 t1,消费者 c2 订阅的是主题 t0、t1和t2,那么最终的分配结果为:
1 | 消费者c0:t0p0 |
可以看到 RoundRobinAssignor 策略也不是十分完美,这样分配其实并不是最优解,因为完全可以将分区 t1p1 分配给消费者 c1。
RoundRobinAssignor 策略对应的 partition.assignment.strategy 参数值为:org.apache.kafka.clients.consumer.RoundRobinAssignor。
StickyAssignor
“Sticky”这个单词可以翻译为“粘性的”,Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:
- 分区的分配要尽可能的均匀。
- 分区的分配尽可能的与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor 策略的具体实现要比 RangeAssignor 和 RoundRobinAssignor 这两种分配策略要复杂很多。
我们举例来看一下 StickyAssignor 策略的实际效果。
假设消费组内有 3 个消费者:c0、c1 和 c2,它们都订阅了4个主题:t0、t1、t2、t3,并且每个主题有 2 个分区,也就是说整个消费组订阅了 t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1 这 8 个分区。最终的分配结果如下:
1 | 消费者c0:t0p0、t1p1、t3p0 |
这样初看上去似乎与采用 RoundRobinAssignor 策略所分配的结果相同,但事实是否真的如此呢?再假设此时消费者 c1 脱离了消费组,那么消费组就会执行再平衡操作,进而消费分区会重新分配。如果采用 RoundRobinAssignor 策略,那么此时的分配结果如下:
1 | 消费者c0:t0p0、t1p0、t2p0、t3p0 |
如分配结果所示,RoundRobinAssignor 策略会按照消费者 c0和 c2 进行重新轮询分配。
而如果此时使用的是 StickyAssignor 策略,那么分配结果为:
1 | 消费者c0:t0p0、t1p1、t3p0、t2p0 |
可以看到分配结果中保留了上一次分配中对于消费者 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、t2p2 这 6 个分区。消费者 c0 订阅了主题 t0,消费者 c1 订阅了主题 t0 和 t1,消费者 c2 订阅了主题 t0、t1 和 t2。
如果此时采用 RoundRobinAssignor 策略,那么最终的分配结果如下所示(和讲述RoundRobinAssignor策略时的一样,这样不妨赘述一下):
1 | 消费者c0:t0p0 |
如果此时采用的是 StickyAssignor 策略,那么最终的分配结果为:
1 | 消费者C0:t0p0 |
可以看到这是一个最优解(消费者 c0 没有订阅主题 t1 和t2,所以不能分配主题 t1 和 t2 中的任何分区给它,对于消费者 c1 也可同理推断)。
假如此时消费者c0脱离了消费组,那么RoundRobinAssignor策略的分配结果为:
1 | 消费者c1:t0p0、t1p1 |
可以看到 RoundRobinAssignor 策略保留了消费者 c1 和 c2 中原有的 3 个分区的分配:t2p0、t2p1 和 t2p2(针对结果集1)。
而如果采用的是StickyAssignor策略,那么分配结果为:
1 | 消费者C1:t1p0、t1p1、t0p0 |
可以看到 StickyAssignor 策略保留了消费者 c1 和 c2 中原有的 5 个分区的分配:t1p0、t1p1、t2p0、t2p1、t2p2。
从结果上看 StickyAssignor 策略比另外两者分配策略而言显得更加的优异,这个策略的代码实现也是异常复杂。
Hash 路由
这种策略依靠一定的策略生成的一个 key,然后按照 key 的哈希值选择分区。
比如按照业务、机器 ip 等方式去生成的,一旦 key 被定义了,那么所有该类 key 的消息都会被存放到相同的分区里。
其实在现在的 Kafka 版本中,如果指定了消息的 key 的话,就会使用这种策略,如果没指定的话就会使用轮询策略。
这个负载均衡有一个很大的用处,就是实现局部业务的顺序消息,比如我们有 3 个业务的消息需要顺序推送,如果只设置单分区,靠单分区来满足顺序性的话,Kafka 的优势就被限制住了,在这里就可以针对这 3 个业务线设置产生 key 策略,不同业务的 key 放到不同的分区上,相同的 key 在一个分区内是绝对顺序的。这样的话既保证了消息的顺序性,也利用了 Kafka 的高吞吐量的特性。(注意:之前提到过 Kafka 保证 Topic 下的消息顺序,会保证分区的消息顺序,是用追加文件的日志方式记录的消息)