在同一个Consumer Group中,同一个Topic的不同分区会分配给不同的消费者进行消费,那么为消费者分配分区的操作是在Kafka服务端完成的吗?分区是如何进行分配呢?下面来分析Rebalance操作的原理。
Kafka最开始的解决方案是通过ZooKeeper的Watcher实现的。
每个Consumer Group在ZooKeeper下都维护了一个“/consumers/[group_id]/ids”路径,在此路径下使用临时节点记录属于此Consumer Group的消费者的Id,由Consumer启动时创建。
还有两个与ids节点同级的节点,它们分别是:
owners节点,记录了分区与消费者的对应关系;
offsets节点,记录了此Consumer Group在某个分区上的消费位置。
每个Broker、Topic以及分区在ZooKeeper中也都对应一个路径,如下所示。
每个Consumer都分别在“/consumers/[group_id]/ids”和“brokers/ids”路径上注册一个Watcher。
当“/consumers/[group_id]/ids”路径的子节点发生变化时,表示ConsumerGroup中的消费者出现了变化;
当“/brokers/ids”路径的子节点发生变化时,表示Broker出现了增减。
这样,通过Watcher,每个消费者就可以监控Consumer Group和Kafka集群的状态了。
这个方案看上去不错,但是严重依赖于ZooKeeper集群,有两个比较严重的问题:
羊群效应(Herd Effect):先解释一下什么是“羊群效应”,一个被Watch的ZooKeeper节点变化,导致大量的Watcher通知需要被发送给客户端,这将导致在通知期间其他操作延迟。
一般出现这种情况的主要原因就是没有找到客户端真正的关注点,也算是滥用Watcher的一种场景。
继续前面的分析,任何Broker或Consumer加入或退出,都会向其余所有的Consumer发送Watcher通知触发Rebalance,就出现了“羊群效应”。
脑裂(Split Brain):每个Consumer都是通过ZooKeeper中保存的这些元数据判断Consumer Group状态、Broker的状态以及Rebalance结果的,由于ZooKeeper只保证“最终一致性”,不保证“Simultaneously Consistent Cross-Client Views”,不同Consumer在同一时刻可能连接到ZooKeeper集群中不同的服务器,看到的元数据就可能不一样,这就会造成不正确的Rebalance尝试。
由于上述两个原因,Kafka的后续版本对Rebalance操作进行了改进,也对消费者进行了重新设计。
其核心设计思想是:将全部的Consumer Group分成多个子集,每个Consumer Group子集在服务端对应一个GroupCoordinator对其进行管理,GroupCoordinator是KafkaServer中用于管理Consumer Group的组件,消费者不再依赖ZooKeeper,而只有GroupCoordinator在ZooKeeper上添加Watcher。
消费者在加入或退出Consumer Group时会修改ZooKeeper中保存的元数据,这点与上文描述的方案一类似,此时会触发GroupCoordinator设置的Watcher,通知GroupCoordinator开始Rebalance操作。
下面简述这个过程:
当前消费者准备加入某Consumer Group或是GroupCoordinator发生故障转移时,消费者并不知道GroupCoordinator的网络位置,消费者会向Kafka集群中的任一Broker发送ConsumerMetadataRequest,此请求中包含了其Consumer Group的Groupld,收到请求的Broker会返回ConsumerMetadataResponse作为响应,其中包含了管理此ConsumerGroup的GroupCoordinator的相关信息。
消费者根据ConsumerMetadataResponse中的GroupCoordinator信息,连接到GroupCoordinator并周期性地发送HeartbeatRequest,HeartbeatRequest的具体格式在后面会详细介绍。
发送HeartbeatRequest的主要作用是为了告诉GroupCoordinator此消费者正常在线,GroupCoordinator会认为长时间未发送HeartbeatRequest的消费者已经下线,触发新一轮的Rebalance操作。
如果HeartbeatResponse中带有IllegalGeneration异常,说明GroupCoordinator发起了Rebalance操作,此时消费者发送JoinGroupRequest(具体格式在后面介绍)给GroupCoordinator,JoinGroupRequest的主要目的是为了通知GroupCoordinator,当前消费者要加入指定的Consumer Group。
之后,GroupCoordinator会根据收到的JoinGroupRequest和ZooKeeper中的元数据完成对此Consumer Group的分区分配。
GroupCoordinator会在分配完成后,将分配结果写入ZooKeeper保存,并通过JoinGroupResponse返回给消费者。消费者就可以根据JoinGroupResponse中分配的分区开始消费数据。
消费者成功成为Consumer Group的成员后,会周期性发送HeartbeatRequest。如果HeartbeatResponse包含IlegalGeneration异常,则执行步骤3。如果找不到对应的GroupCoordinator(HeartbeatResponse包含NotCoordinatorForGroup异常),则周期性地执行步骤1,直至成功。
这里只是简略地描述此方案的步骤,整个方案还是有点复杂的,其中比较严谨地描述了消费者和GroupCoordinator的状态图和各个阶段可能发生的故障以及故障转移处理,本文重点关注Consumer Group Rebalance方面。
上面这种方案虽然解决了“羊群效应”、“脑裂”问题,但是还是有两个问题:
分区分配的操作是在服务端的GroupCoordinator中完成的,这就要求服务端实现Partition的分配策略。当要使用新的Partition分配策略时,就必须修改服务端的代码或配置,之后重启服务,这就显得比较麻烦。
不同的Rebalance策略有不同的验证需求。当需要自定义分区分配策略和验证需求时,就会很麻烦。
为了解决上述问题,Kafka进行了重新设计,将分区分配的工作放到了消费者这一端进行处理,而Consumer Group管理的工作则依然由GroupCoordinator处理。
这就让不同的模块关注不同的业务,实现了业务的切分和解耦,这种思想在设计时很重要。
重新设计后的协议在上一版本的协议上进行了修改,将JoinGroupRequest的处理过程拆分成了两个阶段,分别是Join Group阶段和Synchronizing Group State阶段。
当消费者查找到管理当前Consumer Group的GroupCoordinator后,就会进入Join Group阶段,Consumer首先向GroupCoordinator发送JoinGroupRequest请求,其中包含消费者的相关信息;
服务端的GroupCoordinator收到JoinGroupRequest后会暂存消息,收集到全部消费者之后,根据JoinGroupRequest中的信息来确定Consumer Group中可用的消费者,从中选取一个消费者成为Group Leader,还会选取使用的分区分配策略,最后将这些信息封装成JoinGroupResponse返回给消费者。
虽然每个消费者都会收到JoinGroupResponse,但是只有Group Leader收到的JoinGroupResponse中封装了所有消费者的信息。
当消费者确定自己是Group Leader后,会根据消费者的信息以及选定的分区分配策略进行分区分配。
在Synchronizing Group State阶段,每个消费者会发送SyncGroupRequest到GroupCoordinator,但是只有Group Leader的SyncGroupRequest请求包含了分区的分配结果,GroupCoordinator根据Group Leader的分区分配结果,形成SyncGroupResponse返回给所有Consumer。
消费者收到SyncGroupResponse后进行解析,即可获取分配给自身的分区。
最后,我们来了解消费者的状态转移与各请求之间的关系,如图所示。