在Kafka中,一个主题对应有多个分区,每一个分区必须且只能被同一个消费组的一个消费者消费。为了实现上面的约束,Kafka支持了三种分区分配策略来将分区分配给消费者。另外,在系统运行过程中总是会出现各种变化,例如消费者宕机,新的消费者加入等等,Kafka为了应对这种变化,引入了再均衡重新分配分区,从而保证各种场景下的分区分配的合理性。
分区分配策略
分区分配策略指的就是将分区分配给消费者的具体实现方案。Kafka提供了消费者客户端参数partition.assignment.strategy
来设置消费者与订阅主题之间的分区分配策略。默认是RangeAssignor
,此外还支持RoundRobinAssignor
和StickyAssignor
。
RangeAssignor
分配策略
RangeAssignor
分配策略的原理是按照消费者总数和分区总数进行整除来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能地均匀分配给所有消费者。对于每一个主题,RangeAssignor
分配策略会将消费组内的所有消费者按照名称的字典序排列,然后为每个消费者划分分区分为,如果不能够平均分配,那么子字典序靠前的消费者会多分配一个分区。
假设n=分区数/消费者数量
,m=分区数%消费者数量
,那么前m个消费者每个分配n+1
个分区,后面的消费者每个分配n
个分区。
假设有2个消费者(C0, C1)订阅了一个主题,这个主题有3个分区(p0,p1,p2)。那么第1
个消费者每个分配2
个分区,最后1个消费者分配1个分组。
消费者C0:p0、p1
消费者C1:p2
复制代码
在只有一个主题的时候,消费者C2要少一个分区,如果有很多这种主题,那么消费者C2分配的分区数就会明显小于其他分区。因此,RangeAssignor
分配策略的缺点是:在分区数不是消费者整数倍时,容易造成分配不均匀,并且随着主题增多,分配不均匀的情况就越严重。
推荐分区数是消费者的整数倍
RoundRobinAssignor
分配策略
RoundRobinAssignor
分配策略的原理是将消费组内所有消费者和消费者订阅的所有主题的分区按照字典排序,然后通过轮询的方式逐个将分区分配给每个消费者。
比如还是有2个消费者C0、C1,他们订阅了两个主题t0,t1,每个主题都有3个分区。
如果按照RangeAssignor
分配,结果是:
消费者C0:t0p0、t0p1、t1p0、t1p1
消费者C1:t0p2、t1p2
复制代码
如果按照RoundRobinAssignor
分配,结果是:
消费者C0:t0p0、t0p2、t1p1
消费者C1:t0p1、t1p0、t1p2
复制代码
在同一个消费组订阅的主题相同时,使用RoundRobinAssignor
分配能够做到均匀分配。但是如果同一个消费组订阅的主题不相同时 ,仍然会导致分配不均匀。
假设有3个消费者(C0,C1,C2),有个3个主题(t0,t1,t2),这三个主题分别有1、2、3个分区。消费者C0订阅了t0,消费者C1订阅了t0和t1,消费者C2订阅了t0,t1和t2。那么最终的分配结果是:
消费者C0:t0p0
消费者C1:t1p0
消费者C2:t1p1、t2p0、t2p1、t2p2
复制代码
可以看到,这种分配方式并不是最优解,因为t1p1可以分配给消费者C1。
StickyAssignor
分配策略
sticky
的意思是”黏性的“,StickyAssignor
分配策略主要有两个目的:
- 分区的分配要尽可能均匀。
- 分区的分配要尽可能与上次分配保持相同。
还是上面的例子。假设有3个消费者(C0,C1,C2),有个3个主题(t0,t1,t2),这三个主题分别有1、2、3个分区。消费者C0订阅了t0,消费者C1订阅了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
复制代码
可以看到,StickAssignor
分配更加均匀。
如果此时,消费者C0脱离了消费组,RoundRobinAssignor
分配策略的结果是:
消费者C1:t0p0、t1p1
消费者C2:t1p0、t2p0、t2p1、t2p2
复制代码
而StickyAssignor
分配策略的结果是:
消费者C1:t1p0、t1p1、t0p0
消费者C2:t2p0、t2p1、t2p2
复制代码
在发生再均衡的时候,StickyAssignor
保证了分区重分配具有”黏性“,只是将下线的消费者分配的分区均匀地分配给已有消费者,从而减少了不必要的分区移动。
再均衡触发时机
分区分配实际上就是将分区分配给消费者,因此,分区数量变化和消费者数量变化都会触发再均衡。具体来说,有以下几种情况:
- 有新的消费者加入消费组。
- 有消费者下线。各种因素导致的消费者长时间发送心跳,就会认为消费者下线了。
- 有消费者主动退出消费组。
- 消费组内所订阅的任意主题分区数量发生变化。
协调者
分区分配需要将分区所属权分配给消费者,因此需要和所有消费者通信,Kafka使用协调者(Coordinator)来实现的。每个消费者客户端都有一个消费者,而每组消费者在服务端有一个对应的协调者。我们都知道,Kafka集群是有多个Broker节点组成,实际上,每个Broker节点上就有一个协调者。每个消费组对应的协调者就在某个Broker节点上。根据消费组确定对应的协调者需要如下两步:
- 计算消费位移分区号
协调者除了处理分区分配和再均衡之外,还负责处理消费位移提交。消费位移提交是通过内部主题__consumer_offsets
实现的,它的分区数是offsetsTopicPartitionCount
。一个消费组根据如下公式计算分区号:
partition = Math.abs(groupID.hashCode() % offsetsTopicPartitionCount)
复制代码
这个消费组的位移都会提交到该分区。
- 找到分区leader所在的broker
该分区leader所在的broker上的协调者就是我们要找的。
交互方式
消费者和协调者通过心跳机制进行交互。在消费者中有一个专门负责心跳的线程以heartbeat.interval.ms
间隔发送心跳给协调者。协调者也会返回响应,当需要再均衡的时候,响应中会加上相关信息告诉消费者。
在前面提到过,分区数量和消费者数量变化会触发再均衡。一般情况下,分区数量变化和消费者增加都是人为操作的,算是计划之内的再均衡,我们无须关注太多。但是消费者数量减少一般是因为系统或者网络问题导致的,这也是计划之外的再均衡,因此也是我们应该重点关注的。再均衡的开销很大,在此期间所有消费者都会停止工作,所以我们应当避免不必要的再均衡。下面我们看看影响消费者数量减少的参数有哪些:
session.timeout.ms
:Broker端参数,消费者的存活时间,默认10秒。如果在这段时间内,协调者没收到任何心跳,则认为该消费者已崩溃离组;heartbeat.interval.ms
:消费者端参数,发送心跳的频率,默认 3 秒;max.poll.interval.ms
:消费者端参数,两次调用 poll 的最大时间间隔,默认 5 分钟,如果 5 分钟内无法消费完,则会主动离组。
其中session.timeout.ms
和 heartbeat.interval.ms
是相关的,这里给出一个建议参考的公式:
session.timeout.ms ≥ 3 * heartbeat.interval.ms
复制代码
max.poll.interval.ms
主要和下游处理时间有关,例如下游处理时间需要6分钟,那按默认值是不合理的,消费者会频繁主动离组。所以需要把值设置的比下游处理时间大一点,避免不必要的再均衡。
再均衡流程
- 当消费者收到协调者的再均衡开始通知时,需要立即提交位移;
- 消费者在收到提交位移成功的响应后,再发送
JoinGroup
请求,重新申请加入组,请求中会含有订阅的主题信息; - 当协调者收到第一个
JoinGroup
请求时,会把发出请求的消费者指定为 Leader消费者,同时等待rebalance.timeout.ms
,在收集其他消费者的JoinGroup
请求中的订阅信息后,将订阅信息放在JoinGroup
响应中发送给Leader消费者,并告知他成为了Leader,同时也会发送成功入组的JoinGroup
响应给其他消费者; - Leader消费者收到
JoinGroup
响应后,根据消费者的订阅信息制定分配方案,把方案放在 SyncGroup请求中,发送给协调者。普通消费者在收到响应后,则直接发送SyncGroup
请求,等待 Leader的分配方案; - 协调者收到分配方案后,再通过 SyncGroup 响应把分配方案发给所有消费者。
- 当所有消费者收到分配方案后,就意味着再均衡的结束,可以正常开始消费工作了。
参考:
《深入理解Kafka》