消费者
在 Kafka 中, 我们把消费消息的一方称为 Consumer 即 消费者, 它是 Kafka 的核心组件之一。它的主要功能是将 Producer 生产的消息进行消费处理,完成消费任务。
消费者的消费方式
我们知道消息队列一般有两种实现方式,(1)Push(推模式) (2)Pull(拉模式),那么 Kafka Consumer 究竟采用哪种方式进行消费的呢?其实 Kafka Consumer 采用的是主动拉取 Broker 数据进行消费的即 Pull 模式。这两种方式各有优劣,我们来分析一下:
1)、 为什么不采用Push模式? 如果是选择 Push 模式最大缺点就是 Broker 不清楚 Consumer 的消费速度,且推送速率是 Broker 进行控制的, 这样很容易造成消息堆积,如果 Consumer 中执行的任务操作是比较耗时的,那么 Consumer 就会处理的很慢, 严重情况可能会导致系统 Crash。
2)、为什么采用Pull模式? 如果选择 Pull 模式,这时 Consumer 可以根据自己的情况和状态来拉取数据, 也可以进行延迟处理。但是 Pull 模式也有不足,Kafka 又是如何解决这一问题? 如果 Kafka Broker 没有消息,这时每次 Consumer 拉取的都是空数据, 可能会一直循环返回空数据。 针对这个问题,Consumer 在每次调用 Poll() 消费数据的时候,顺带一个 timeout 参数,当返回空数据的时候,会在 Long Polling 中进行阻塞,等待 timeout 再去消费,直到数据到达。
消费者组
为什么采用消费者组
传统的消息引擎模型分为点对点模型和发布/订阅模型。
1.点对点模型: 也叫消费队列。缺点在于消息一旦被消费,就会从队列中被删除,而且只能被下游的一个Consumer消费,显然,其伸缩性很差,因为下游的多个Consumer都要抢这个共享消息队列消息。
2.发布/订阅模型: 允许消息消息被多个 Consumer 消费,但它的问题也是伸缩性不高,因为每个订阅者都必须要订阅主题的所有分区。这种全量订阅的方式既不灵活,也会影响消息的真实投递效果。
为了解决上述的弊端,Kafka 的 Consumer Group避开这两种模型的缺陷,又兼具它们的优点。
Consumer Group 之间彼此独立,互不影响,它们能够订阅相同的一组主题而互不干涉。再加上 Broker 端的消息留存机制,Kafka 的 Consumer Group 完美地规避了上面提到的伸缩性差的问题。可以这么说,Kafka 仅仅使用 Consumer Group 这一种机制,却同时实现了传统消息引擎系统的两大模型:如果所有实例都属于同一个 Group,那么它实现的就是消息队列模型;如果所有实例分别属于不同的Group,那么它实现的就是发布 / 订阅模型。
简单点来说,组内是队列模式,组间是发布/订阅模式。
Consumer Group
Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制。
既然是一个组,那么组内必然可以有多个消费者或消费者实例(Consumer Instance),它们共享一个公共的 ID,这个 ID 被称为 Group ID。组内的所有消费者协调在一起来消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然,每个分区只能由同一个消费者组内的一个 Consumer 实例来消费。
特性:
1 . Consumer Group 下可以有一个或多个 Consumer 实例。这里的实例可以是一个单独的进程,也可以是同一进程下的线程。在实际场景中,使用进程更为常见一些。
-
Group ID 是一个字符串,在一个 Kafka 集群中,它标识唯一的一个 Consumer Group。
-
Consumer Group 下所有实例订阅的主题的单个分区,只能分配给组内的某个 Consumer 实例消费。这个分区当然也可以被其他的 Group 消费。
分区策略
一个consumer group中有多个consumer,一个topic有多个partition,所以必然会涉及到partition的分配问题,即确定哪个partition由哪个consumer来消费。Kafka提供了3种消费者分区分配策略:RangeAssigor、RoundRobinAssignor、StickyAssignor。
RangeAssigor(范围分区)
RangeAssignor对每个Topic进行独立的分区分配。对于每一个Topic,首先对分区按照分区ID进行排序,然后订阅这个Topic的消费组的消费者再进行排序,之后尽量均衡的将分区分配给消费者。这里只能是尽量均衡,因为分区数可能无法被消费者数量整除,那么有一些消费者就会多分配到一些分区。分配示意图如下:
存在的问题:
这种分配方式明显的一个问题是随着消费者订阅的Topic的数量的增加,不均衡的问题会越来越严重,比如上图中4个分区3个消费者的场景,C0会多分配一个分区。如果此时再订阅一个分区数为4的Topic,那么C0又会比C1、C2多分配一个分区,这样C0总共就比C1、C2多分配两个分区了,而且随着Topic的增加,这个情况会越来越严重。分配结果:
订阅2个Topic,每个Topic4个分区,共3个Consumer
- C0: [T0P0,T0P1,T1P0,T1P1]
- C1: [T0P2,T1P2]
- C2: [T0P3,T1P3]
RoundRobinAssignor(轮询分区)
RoundRobinAssignor的分配策略是将消费组内订阅的所有Topic的分区及所有消费者进行排序后尽量均衡的分配(RangeAssignor是针对单个Topic的分区进行排序分配的)。如果消费组内,消费者订阅的Topic列表是相同的(每个消费者都订阅了相同的Topic),那么分配结果是尽量均衡的(消费者之间分配到的分区数的差值不会超过1)。如果订阅的Topic列表是不同的,那么分配结果是不保证“尽量均衡”的,因为某些消费者不参与一些Topic的分配。
以上两个topic的情况,相比于之前RangeAssignor的分配策略,可以使分区分配的更均衡。不过考虑这种情况,假设有三个消费者分别为C0、C1、C2,有3个Topic T0、T1、T2,分别拥有1、2、3个分区,并且C0订阅T0,C1订阅T0和T1,C2订阅T0、T1、T2,那么RoundRobinAssignor的分配结果如下:
看上去分配已经尽量的保证均衡了,不过可以发现C2承担了4个分区的消费而C1订阅了T1,是不是把T1P1交给C1消费能更加的均衡呢?
StickyAssignor
尽管RoundRobinAssignor已经在RangeAssignor上做了一些优化来更均衡的分配分区,但是在一些情况下依旧会产生严重的分配偏差,比如消费组中订阅的Topic列表不相同的情况下。更核心的问题是无论是RangeAssignor,还是RoundRobinAssignor,当前的分区分配算法都没有考虑上一次的分配结果。显然,在执行一次新的分配之前,如果能考虑到上一次分配的结果,尽量少的调整分区分配的变动,显然是能节省很多开销的。
目标:
1. 分区的分配尽量的均衡
2. 每一次重分配的结果尽量与上一次分配结果保持一致
注意:当这两个目标发生冲突时,优先保证第一个目标。第一个目标是每个分配算法都尽量尝试去完成的,而第二个目标才真正体现出StickyAssignor特性的。
例子:
- 有3个Consumer:C0、C1、C2
- 有4个Topic:T0、T1、T2、T3,每个Topic有2个分区
- 所有Consumer都订阅了这4个分区
StickyAssignor的分配结果如下图所示(增加RoundRobinAssignor分配作为对比):
上面的例子中,Sticky模式原来分配给C0、C2的分区都没有发生变动,且最终C0、C1达到的均衡的目的。
再举一个例子:
- 有3个Consumer:C0、C1、C2
- 3个Topic:T0、T1、T2,它们分别有1、2、3个分区
- C0订阅T0;C1订阅T0、T1;C2订阅T0、T1、T2
分配结果如下图所示:
从以上两个例子的分配结果可以看出,StickyAssignor是比RangeAssignor和RoundRobinAssignor更好的分配方式,不过它的实现也更加的复杂。
StickyAssignor的实现代码是RangeAssignor和RoundRobinAssignor的十倍,复杂度则远远在十倍以上。
Group下的Consumer和partition
在实际使用场景中,我怎么知道一个 Group 下该有多少个 Consumer 实例呢?理想情况下,Consumer 实例的数量应该等于该 Group 订阅主题的分区总数。
举个简单的例子,假设一个 Consumer Group 订阅了 3 个主题,分别是 A、B、C,它们的分区数依次是 1、2、3(总共是 6 个分区),那么通常情况下,为该 Group 设置 6 个 Consumer 实例是比较理想的情形,因为它能最大限度地实现高伸缩性。
你可能会问,我能设置小于或大于 6 的实例吗?当然可以!如果你有 3 个实例,那么平均下来每个实例大约消费 2 个分区(6 / 3 = 2);如果你设置了 8 个实例,那么很遗憾,有 2 个实例(8 – 6 = 2)将不会被分配任何分区,它们永远处于空闲状态。因此,在实际使用过程中一般不推荐设置大于总分区数的 Consumer 实例。设置多余的实例只会浪费资源,而没有任何好处。
Group的rebalance
Rebalance 本质上是一种协议,规定了一个 Consumer Group 下的所有 Consumer 如何达成一致,来分配订阅 Topic 的每个分区。比如某个 Group 下有 20 个 Consumer 实例,它订阅了一个具有 100 个分区的 Topic。正常情况下,Kafka 平均会为每个 Consumer 分配 5 个分区。这个分配的过程就叫 Rebalance。
触发条件
那么 Consumer Group 何时进行 Rebalance 呢?Rebalance 的触发条条件有 3 个:
- 组成员数发生变更。比如有新的Consumer实例加入组或者离开组,亦或者是由Consumer实例崩溃被"踢出"组。
- 订阅主题数发生变更。Consumer Group可以使用正则表达式的方式订阅主题,比如consumer.subscribe(Pattern.compile("t.*c")) 就表明该 Group 订阅所有以字母 t 开头、字母 c 结尾的主题。在 Consumer Group 的运行过程中,你新创建了一个满足这样条件的主题,那么该 Group 就会发生 Rebalance。
- 订阅主题的分区数发生变更。Kafka 当前只能允许增加一个主题的分区数。当分区数增加时,就会触发订阅该主题的所有 Group 开启 Rebalance。
Rebalance的缺点
首先,Rebalance 过程对 Consumer Group 消费过程有极大的影响。如果你了解 JVM 的垃圾回收机制,你一定听过万物静止的收集方式,即著名的 stop the world,简称 STW。在 STW 期间,所有应用线程都会停止工作,表现为整个应用程序僵在那边一动不动。Rebalance 过程也和这个类似,在 Rebalance 过程中,所有 Consumer 实例都会停止消费,等待 Rebalance 完成。这是 Rebalance 为人诟病的一个方面。
首先,Rebalance 过程对 Consumer Group 消费过程有极大的影响。如果你了解 JVM 的垃圾回收机制,你一定听过万物静止的收集方式,即著名的 stop the world,简称 STW。在 STW 期间,所有应用线程都会停止工作,表现为整个应用程序僵在那边一动不动。Rebalance 过程也和这个类似,在 Rebalance 过程中,所有 Consumer 实例都会停止消费,等待 Rebalance 完成。这是 Rebalance 为人诟病的一个方面。(StickyAssignor)
最后,Rebalance 实在是太慢了。曾经,有个国外用户的 Group 内有几百个 Consumer 实例,成功 Rebalance 一次要几个小时!这完全是不能忍受的。
总结
- Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制。
- 3个特性:Consumer Group下可以有一个或多个Consumer实例;在一个Kafka集群中,Group ID标识唯一的一个Consumer Group;Consumer Group下所有实例订阅主题的单个分区,只能分配给组内的某个Consumer实例消费。
- kafka仅使用Consumer Group这一种机制,却同时实现了传统消息引擎系统的两大模型:即组内是队列模式,组间是发布/订阅模式。