深入分析Kafka架构(三):消费者消费方式、三种分区分配策略、offset维护

一、前言

在之前的两篇文章里,我们分析了kafka的工作流程、存储机制、分区策略、数据可靠性、故障处理。从而弄清楚了kafka的整体架构以及生产者生产的数据是怎么存储,保证可靠性以及遇到故障时进行处理的。

深入分析Kafka架构(一):工作流程、存储机制、分区策略

深入分析Kafka架构(二):数据可靠性、故障处理

那么接下来,我们将分析kafka架构里的消费者是如何工作的,本文将重点分析kafka消费者的消费方式,三种分区分配策略(Range分配策略、RoundRobin分配策略、Sticky分配策略) 以及offset的维护。

二、消费者消费方式

先说结论:消费者采用pull(拉)模式从broker中读取数据。

为什么不采用push(推,填鸭式教学)的模式给消费者数据呢?首先回想下咱们上学学习不就是各种填鸭式教学吗?不管你三七二十一,就是按照教学进度给你灌输知识,能不能接受是你的事,并美其名曰:优胜略汰!

这种push方式在kafka架构里显然是不合理的,比如一个broker有多个消费者,它们的消费速率不同,一昧的push只会给消费者带来拒绝服务以及网络拥塞等风险。而kafka显然不可能去放弃速率低的消费者,因此kafka采用了pull的模式,可以根据消费者的消费能力以适当的速率消费broker里的消息。

当然让消费者去pull数据自然也是有缺点的。同样联想上学的场景,如果把学习主动权全部交给学生,那有些学生想学的东西老师那里没有怎么办?那他不就陷入了一辈子就在那不断求索,然而别的也啥都学的这个死循环的状态了。kafka也是这样,采用pull模式后,如果kafka没有数据,消费者可能会陷入循环中,一直返回空数据。为了解决这个问题,Kafka消费者在消费数据时会传入一个时长参数timeout,如果当前没有数据可供消费,消费者会等待一段时间之后再返回,这段时长即为timeout。

三、分区分配策略

我们在第一篇文章里分析了kafka存储数据的分区策略,这里对于消费者来说,一个consumer group中有多个consumer,一个 topic有多个partition,所以肯定会涉及到partition的分配问题,即确定每个partition由哪个consumer来消费,这就是分区分配策略(Partition Assignment Strategy)。

3.1、分配分区的前提条件

首先kafka设定了默认的消费逻辑:一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费。

在这个消费逻辑设定下,假设目前某消费组内只有一个消费者C0,订阅了一个topic,这个topic包含6个分区,也就是说这个消费者C0订阅了6个分区,这时候可能会发生下列三种情况:

  1. 如果这时候消费者组内新增了一个消费者C1,这个时候就需要把之前分配给C0的6个分区拿出来3个分配给C1;
  2. 如果这时候这个topic多了一些分区,就要按照某种策略,把多出来的分区分配给C0和C1;
  3. 如果这时候C1消费者挂掉了或者退出了,不在消费者组里了,那所有的分区需要再次分配给C0。

总结一下,这三种情况其实就是kafka进行分区分配的前提条件:

  1. 同一个 Consumer Group 内新增消费者;
  2. 订阅的主题新增分区;
  3. 消费者离开当前所属的Consumer Group,包括shuts down 或 crashes。

只有满足了这三个条件的任意一个,才会进行分区分配 。分区的所有权从一个消费者移到另一个消费者称为重新平衡(rebalance),如何rebalance就涉及到本节提到的分区分配策略。kafka提供了消费者客户端参数partition.assignment.strategy用来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为:org.apache.kafka.clients.consumer.RangeAssignor,即采用range分配策略。除此之外,Kafka中还提供了roundrobin分配策略和sticky分区分配策略。消费者客户端参数partition.asssignment.strategy可以配置多个分配策略,把它们以逗号分隔就可以了。

3.2、Range分配策略

Range分配策略是面向每个主题的,首先会对同一个主题里面的分区按照序号进行排序,并把消费者线程按照字母顺序进行排序。然后用分区数除以消费者线程数量来判断每个消费者线程消费几个分区。如果除不尽,那么前面几个消费者线程将会多消费一个分区。

我们假设有个名为T1的主题,包含了7个分区,它有两个消费者(C0和C1),其中C0的num.streams(消费者线程) = 1,C1的num.streams = 2。排序后的分区是:0,1,2,3,4,5,6;消费者线程排序后是:C0-0,C1-0,C1-1;一共有7个分区,3个消费者线程,进行计算7/3=2…1,商为2余数为1,则每个消费者线程消费2个分区,并且前面1个消费者线程多消费一个分区,结果会是这样的:

消费者线程 对应消费的分区序号
C0-0 0,1,2
C1-0 3,4
C1-1 5,6

这样看好像还没什么问题,但是一般在咱们实际生产环境下,会有多个主题,我们假设有3个主题(T1,T2,T3),都有7个分区,那么按照咱们上面这种Range分配策略分配后的消费结果如下:

消费者线程 对应消费的分区序号
C0-0 T1(0,1,2),T2(0,1,2),T3(0,1,2)
C1-0 T1(3,4),T2(3,4),T3(3,4)
C1-1 T1(5,6),T2(5,6),T3(5,6)

我们可以发现,在这种情况下,C0-0消费线程要多消费3个分区,这显然是不合理的,其实这就是Range分区分配策略的缺点。

3.3、RoundRobin分配策略

RoundRobin策略的原理是将消费组内所有消费者以及消费者所订阅的所有topic的partition按照字典序排序,然后通过轮询算法逐个将分区以此分配给每个消费者。

使用RoundRobin分配策略时会出现两种情况:

  1. 如果同一消费组内,所有的消费者订阅的消息都是相同的,那么 RoundRobin 策略的分区分配会是均匀的。
  2. 如果同一消费者组内,所订阅的消息是不相同的,那么在执行分区分配的时候,就不是完全的轮询分配,有可能会导致分区分配的不均匀。如果某个消费者没有订阅消费组内的某个 topic,那么在分配分区的时候,此消费者将不会分配到这个 topic 的任何分区。

我们分别举例说明:

第一种:比如我们有3个消费者(C0,C1,C2),都订阅了2个主题(T0 和 T1)并且每个主题都有 3 个分区(p0、p1、p2),那么所订阅的所有分区可以标识为T0p0、T0p1、T0p2、T1p0、T1p1、T1p2。此时使用RoundRobin分配策略后,得到的分区分配结果如下:

消费者线程 对应消费的分区序号
C0 T0p0、T1p0
C1 T0p1、T1p1
C2 T0p2、T1p2

可以看到,这时候的分区分配策略是比较平均的。

第二种:比如我们依然有3个消费者(C0,C1,C2),他们合在一起订阅了 3 个主题:T0、T1 和 T2(C0订阅的是主题T0,消费者C1订阅的是主题T0和T1,消费者C2订阅的是主题T0、T1和T2),这 3 个主题分别有 1、2、3 个分区(即:T0有1个分区(p0),T1有2个分区(p0、p1),T2有3个分区(p0、p1、p2)),即整个消费者所订阅的所有分区可以标识为 T0p0、T1p0、T1p1、T2p0、T2p1、T2p2。此时如果使用RoundRobin分配策略,得到的分区分配结果如下:

消费者线程 对应消费的分区序号
C0 T0p0
C1 T1p0
C2 T1p1、T2p0、T2p1、T2p2

这时候显然分配是不均匀的,因此在使用RoundRobin分配策略时,为了保证得均匀的分区分配结果,需要满足两个条件:

  1. 同一个消费者组里的每个消费者订阅的主题必须相同;
  2. 同一个消费者组里面的所有消费者的num.streams必须相等。

如果无法满足,那最好不要使用RoundRobin分配策略。

3.4、Sticky分配策略

最后介绍一下Sticky分配策略,这种分配策略是在kafka的0.11.X版本才开始引入的,是目前最复杂也是最优秀的分配策略。

Sticky分配策略的原理比较复杂,它的设计主要实现了两个目的:

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

如果这两个目的发生了冲突,优先实现第一个目的。

我们举例进行分析:比如我们有3个消费者(C0,C1,C2),都订阅了2个主题(T0 和 T1)并且每个主题都有 3 个分区(p0、p1、p2),那么所订阅的所有分区可以标识为T0p0、T0p1、T0p2、T1p0、T1p1、T1p2。此时使用Sticky分配策略后,得到的分区分配结果如下:

消费者线程 对应消费的分区序号
C0 T0p0、T1p0
C1 T0p1、T1p1
C2 T0p2、T1p2

哈哈,这里可能会惊呼,怎么和前面RoundRobin分配策略一样,其实底层实现并不一样。这里假设C2故障退出了消费者组,然后需要对分区进行再平衡操作,如果使用的是RoundRobin分配策略,它会按照消费者C0和C1进行重新轮询分配,再平衡后的结果如下:

消费者线程 对应消费的分区序号
C0 T0p0、T0p2、T1p1
C1 T0p1、T1p0、T1p2

但是如果使用的是Sticky分配策略,再平衡后的结果会是这样:

消费者线程 对应消费的分区序号
C0 T0p0、T1p0、T0p2
C1 T0p1、T1p1、T1p2

看出区别了吗?Stiky分配策略保留了再平衡之前的消费分配结果,并将原来消费者C2的分配结果分配给了剩余的两个消费者C0和C1,最终C0和C1的分配还保持了均衡。这时候再体会一下sticky(翻译为:粘粘的)这个词汇的意思,是不是豁然开朗了。

为什么要这么处理呢?

这是因为发生分区重分配后,对于同一个分区而言有可能之前的消费者和新指派的消费者不是同一个,对于之前消费者进行到一半的处理还要在新指派的消费者中再次处理一遍,这时就会浪费系统资源。而使用Sticky策略就可以让分配策略具备一定的“粘性”,尽可能地让前后两次分配相同,进而可以减少系统资源的损耗以及其它异常情况的发生。

接下来,再来看一下上一节RoundRobin存在缺陷的地方,这种情况下sticky是怎么分配的?

比如我们依然有3个消费者(C0,C1,C2),他们合在一起订阅了 3 个主题:T0、T1 和 T2(C0订阅的是主题T0,消费者C1订阅的是主题T0和T1,消费者C2订阅的是主题T0、T1和T2),这 3 个主题分别有 1、2、3 个分区(即:T0有1个分区(p0),T1有2个分区(p0、p1),T2有3个分区(p0、p1、p2)),即整个消费者所订阅的所有分区可以标识为 T0p0、T1p0、T1p1、T2p0、T2p1、T2p2。此时如果使用sticky分配策略,得到的分区分配结果如下:

消费者线程 对应消费的分区序号
C0 T0p0
C1 T1p0、T1p1
C2 T2p0、T2p1、T2p2

由于C0消费者没有订阅T1和T2主题,因此如上这样的分配策略已经是这个问题的最优解了!

这时候,再补充一个例子,加入C0挂了,发生再平衡后的分配结果,RoundRobin和Sticky又有什么区别呢?

RoundRobin再平衡后的分配情况:

消费者线程 对应消费的分区序号
C1 T0p0、T1p1
C2 T1p0、T2p0、T2p1、T2p2

而如果使用Sticky策略,再平衡后分分配情况:

消费者线程 对应消费的分区序号
C1 T1p0、T1p1、T0p0
C2 T2p0、T2p1、T2p2

这里我们惊奇的发现sticky只是把之前C0消耗的T0p0分配给了C1,我们结合资源消耗来看,这相比RoundRobin能节省更多的资源。

因此,强烈建议使用sticky分区分配策略。

四、offset维护

在现实情况下,消费者在消费数据时可能会出现各种会导致宕机的故障问题,这个时候,如果消费者后续恢复了,它就需要从发生故障前的位置开始继续消费,而不是从头开始消费。所以消费者需要实时的记录自己消费到了哪个offset,便于后续发生故障恢复后继续消费。Kafka 0.9版本之前,consumer默认将offset保存在Zookeeper中,从0.9版本开始,consumer默认将offset保存在Kafka一个内置的topic中,该topic为 __consumer_offsets

offset的维护很简单,之所以单独列出来,是因为offset维护针对不同的kafka版本进行的处理是不同的,这点需要注意。

五、总结

本文我们分析了Kafka的消费者消费方式、分区分配策略、offset维护,其中分区分配策略是重点,我们非常详细的举例对比说明了Range,RoundRobin,Sticky这三种策略的优缺点,相信读完一定会有所收获。

到这里kafka架构已经分析完毕,这三篇文章系统性的从整体工作流程,存储机制,分区策略入手,到生产者生产数据,如何保证数据可靠性,遇到故障如何处理,到最后的消费者如何去消费数据,如何把分区分配给消费者等问题都能找到解释。

因本人能力有限,如果对kafka架构的分析有误,还请您不吝赐教,谢谢。

发布了114 篇原创文章 · 获赞 202 · 访问量 26万+

猜你喜欢

转载自blog.csdn.net/qq_26803795/article/details/105562691