Kafka技术内幕:消费者(高级和低级API)和 协调者
生产者发送消息时在客户端就按照节点和Partition进行分组,属于同一个目标节点的多个Partition会作为同一个请求传送到服务端,作为目标节点的服务端也可以处理来自不同生产者客户端的请求。如果从网络层通信来看,客户端和服务端都会使用队列的方式确保顺序地客户端发送请求,服务端接收请求,服务端发送响应,客户端接收响应。从存储层来看,生产者会将消息分发到不同节点的不同Partition上,服务端的
生产者发送消息时在客户端就按照节点和Partition进行分组,属于同一个目标节点的多个Partition会作为同一个请求传送到服务端,作为目标节点的服务端也可以处理来自不同生产者客户端的请求。如果从网络层通信来看,客户端和服务端都会使用队列的方式确保顺序地客户端发送请求,服务端接收请求,服务端发送响应,客户端接收响应。从存储层来看,生产者会将消息分发到不同节点的不同Partition上,服务端的一个Partition的数据会来源于多个生产者。多个服务端节点组成的Kafka集群在物理层将消息分布在不同节点的不同Partition上,并且是以提交日志的形式追加到每个Partition中。对消息进行分区的好处是可以将大量的消息分成多批数据同时写到不同节点上,将写请求分担负载到各个节点。
消息系统的组成是生产者,存储系统和消费者,消费者会从存储系统读取生产者写入的消息。Kafka作为分布式的消息系统支持多个生产者和多个消费者,生产者可以将消息分布到集群中不同节点的不同Partition上,消费者也可以消费集群中多个Partition的多个Partition。写消息时允许多个生产者写到同一个Partition中,不过如果读消息时有多个消费者要同时读取同一个Partition,就需要在Partition级别的日志文件上控制确保将日志文件的不同数据分配给不同的消费者(不应该将同一份数据分配给两个相同的消费者,否则同一条消息就被重复处理了,虽然Kafka本身在消费者出现故障时可能会重复处理消息,但是如果在正常消费时就开始重复处理,这条路显然走不通),这种控制手段通常采用加锁同步严重影响性能的方式,所以如果我们约定同一个Partition只允许被一个消费者处理就不需要加锁同步了,不存在并发访问了,可以大大提升消费者的处理能力,而且也并不违反消息的处理语义:原先需要多个消费者处理,现在交给一个消费者处理也不是不可以,只要有消费者处理消息就可以了。
图4-1举例了一种最简单的消息系统部署模式,生产者的数据源多种多样,它们都统一写入到Kafka集群中,处理消息时有多个消费者进行任务分担,这些消费者的处理逻辑都是相同的,每个消费者处理的Partition都是不会重复的。
图4-1 消息系统包括生产者、消费者和存储系统
不过实际应用中消息通常存在多种处理方式,将图4-1中的多个消费者放到同一个消费组中,不同的消费组都可以有数量不同的消费者,比如可以根据实际情况对业务逻辑比较重要的消费组分配更多的消费者资源。图4-2示例了将消息系统作为数据处理系统的核心,消费组1将消息存储到Hadoop供离线分析,消费组3将消息存储到搜索引擎中,消费组2读取出消息时使用Storm/Spark等流处理系统进行实时分析。
图4-2 不同消费组消费同一份消息
Kafka采用消费组保证了:一个Partition只允许被一个消费组中的一个消费者所消费,得出的结论是:在一个消费组中,一个消费者可以消费多个Partition,不同的消费者消费的Partition一定不会重复,所有消费者一起消费所有的Partition;在不同消费组中,每个消费组都会消费所有的Partition。也就是同一个消费组下消费者对Partition是互斥的,而不同消费组之间是共享的。比如有两个消费者订阅了一个topic,如果这两个消费者在不同的消费组中,则每个消费者都会获取到这个topic所有的记录;如果这两个消费者是在同一个消费组中,则它们会各自获取到一半的记录(两者的记录是对半分的,而且都是不重复的)。图4-3示例了多个消费者都在同一个消费组中(右图)或者各自组成一个消费组(左图)的不同消费场景,这样Kafka也可以实现传统消息队列的发布订阅模型和队列模型:
-
同一条消息会被多个消费组消费,如果有多个消费组,每个消费组只有一个消费者,实现广播(发布订阅模式)
-
只有一个消费组,这个消费组有多个消费者,一条消息只会被这个消费组的一个消费者所消费,实现单播(队列模式)
图4-3 传统消息队列的发布订阅模型和队列模型
实际应用中如图4-4消费者和消费组的组成通常是有多个消费组,并且每个消费组中也有多个消费组,这样既可以允许多种不同业务逻辑的消费组存在,也保证了同一个消费组内的多个消费者的协调工作,避免一个消费组只有一个消费者引起的数据丢失。
图4-4 Kafka集群的典型部署方式
图片引自:http://kafka.apache.org/documentation.html
Kafka使用消费组的概念,允许一组消费者进程对消费和读取记录的工作进行划分,每个消费者都可以配置一个所属的消费组并且订阅某些主题,Kafka会发送每条消息给每个消费组中的一个消费者线程(同一条消息广播给多个消费组,单播给同一组中的消费者),这是通过对每个消费组的所有消费者线程将订阅topic的所有partitions进行平衡负载rebalance,简单点说就是将topic的所有Partition平均负载给消费组中的所有消费者。比如一个topic有4个Partition,一个消费组有2个消费者,则每个消费者都会分配到两个Partition。
一个消费组有多个消费者,因此消费组需要维护所有的消费者,如果一个消费者当掉了,分配给这个消费者的Partition需要被重新分配给相同组的其他消费者;如果一个消费者加入了同一个组,之前分配给其他消费组的Partition需要分配给新加入的消费者。实际上一旦有消费者加入或退出消费组,导致消费组成员列表发生变化,即使Kafka集群的Partition没有变化,消费组中所有的消费者也都要触发重新rebalance的工作。当然如果集群的Partition发生变化,即使消费组成员没有变化,所有的消费者也都要重新rebalance。图4-5中模拟了加入一个新的消费者,导致Partition的分配发生变化从而触发所有消费者都发生了rebalance。
图4-5 消费组成员变化引起所有消费者发生rebalance
消费组中的所有消费者发生rebalance时,消费者在rebalance前后分配到的Partition会完全不同,那么消费者们之间是如何确保各自消费的消息平滑迁移和过渡,假设Partition1原先分配给消费者1,经过rebalance后被分配给了消费者2,在rebalalance前消费者1对Partition1的消费进度需要被保存下来,这样在rebalance后,消费者1可以从保存的进度位置继续读取Partition1,确保了Partition1不管分配给哪个消费者,消息并不会被重复处理。
由于消费者消费消息的最小单元是Partition,所以每个Partition都应该记录消费进度,而且这种数据应该面向消费组级别。假设面向的是消费者级别,relabalce前Partition1只记录到消费者1中,rebalance后Partition1属于消费者2,但是Partition1和消费者2之前没有记录任何信息就无法做到无缝迁移。而如果针对消费组,因为消费者1和消费者2都属于同一个消费组,rebalance前记录Partition1到消费组1,rebalance后消费者2可以正常地读取消费组1的Partition1进度,还是可以准确地还原出这个Partition在消费组1中的最新进度。保存Partition的消费进度通常借助外部的存储系统比如ZooKeeper或者Kafka内部的topic。这样发生reabalance前后Partition的不同拥有者因为读取的是同一份共享存储,消费者成员的变化并不会影响消息的消费和处理。
所以虽然Partition是以消费者级别被消费的,不过Partition的消费进度要保存成消费组级别。消费组虽然是一个包含所有消费者的逻辑概念,它并不执行具体的消息消费逻辑,但是它却把大家都统一起来,如果没有这一层总管,各个消费者之间持有各自的Partition消费进度,但是又不互相认识,在Partition发生变动时,进度消息就没有办法同步给其他消费者。举例现实社会在协作分工时通常都有一个管理员角色(消费组)负责管理所有的工人(消费者),任务(Partition)具体分配给哪些工人都是由管理员决定的。如果工人数量发生变化比如有人加入或离职,或者任务增加或减少,每个工人都会被重新分配到不同的任务。图4-6中消费者消费消息时需要定时地将最新的消费进度保存到ZooKeeper中,当发生rebalance时,新的消费者拥有的新的Partition都可以从ZooKeeper中读取出来恢复到最近的状态。
图4-6 消费进度的保存和恢复
负责消费Partition的每个消费者都是一个消费进程,而且消费者本身也可以是多线程的应用程序,因为一个Partition只能属于一个消费者线程,所以存在如下几种不同的场景:
-
线程数量多于Partition的数量,有部分线程无法消费该topic下任何一条消息
-
线程数量少于Partition的数量,有一些线程会消费多个Partition的数据
-
线程数量等于Partition的数量,则正好一个线程消费一个Partition的数据
图4-7分别对应了上面的三种场景,正常情况下采用第二种是最好的,这种方案既不会有第一种的资源浪费想象存在,而且也不会像第三种那样每个线程只负责一点点工作,通过让一个线程消费多个Partition,最大化地榨取每个线程的劳动能力。举例幼儿园的老师将一个蛋糕分成了四块,如果刚好有四个小朋友则每个小朋友都只能分到一块(但是每个人一块可能都吃不饱);如果有五个小朋友,那么有一个小朋友就要眼睁睁地看大家吃蛋糕了(那些分到蛋糕的小朋友很庆幸至少有蛋糕吃);如果有两个小朋友,那他们就可开心了,因为这两个小朋友都能吃到两份蛋糕(任务的资源比消费者多,每个消费者分到不止一个资源,这是最好的情况)。
图4-7 消费者线程和Partition的对应关系
虽然允许一个消费者线程消费多个Partition,但并不保证消费者接收到的消息是完全有序的,不过消费同一个Partition的消息则一定是有序的。图4-8的左图示例了消费者分配了Partition0和Partition1,有可能生产端写入不同Partition的消息速度不同,也有可能不同消费者线程之间的消费速度不同,到达消费者客户端的消息可能是Partition0和Partition1的消息混杂在一起的,不过如果单单从Partition0或Partition1而言,日志文件中是什么顺序,接收到的也一定是同样的顺序,比如P0的①②③虽然和P1的①②③鱼龙混杂,但并不会出现到达客户端后P0的①②③变成了其他顺序。
不过即使消费者每次读取的是一个完整的Partition(实际上是不可能的,因为生产者不断地往不同的Partition写数据,消费者要消费多个Partition,怎么判定完整地读取了一个Partition呢),由于生产者写消息时也将消息分散到多个Partition,输入源这边虽然保证了Partition级别的消息有序性,但是所有Partition之间并不是有序的,这就导致了图4-8右图中消费者读取多个Partition时从所有Partition级别上看消息也不是严格有序的。
图4-8 消费者读取不同Partition消息的顺序性
生产者的提交日志采用递增的offset连同消息内容一起写入到本地日志文件,生产者客户端本身不需要保存offset相关的状态,而消费者进程则要保存消费消息的offset,因此它是有状态的,这样消费者才能将消息的消费进度保存到ZK或者其他存储系统中。在消费者客户端进程保存offset状态的另一种决定是消费消息采用消费者主动向服务端pull拉取数据,而不是服务端主动向消费者push数据。如果由服务端push推送数据给消费者,消费者只要负责接收数据就可以了,不需要保存任何状态,但是这种方式加重了服务端的负载,因为要在服务端记录每条消息要分配给哪个消费者,还要记录消费者消费到哪里了。消费进度是决定消息是否会被重复处理的关键因素,如果没有记录进度,消费者读取到哪里就一无所知了。
图4-9中左图服务端主动push消息给消费者就要在服务端记录push给消费者的进度,右图中消费者主动pull就在消费者端记录拉取进度,谁掌握了主动权,谁就要负责存储offset。消费者的pull还需要额外依赖外部的ZK,因为每个消费者都是独立的个体,如果要获取所有消费者的消费进度,就要向各个消费者轮询,而使用一个统一的外部存储,每个消费者都往存储系统写数据,读取时只需要和存储系统打交道即可,不过这种方式需要保证消费者将最新的消费进度即使地写到存储系统中,如果没有及时写入就有可能读取出旧的消费进度了。
图4-9 消息的push和pull模型
服务端主动push并不需要外部存储是因为服务端本身可以充当管理所有的消费者的角色,但是这种方式的缺点是push只保证把消息推送出去,并没有考虑消费者是否能够及时地处理消息,如果消费者处理不够及时,服务端是否能够感知到并且做出正确的响应比如采用ack机制或者backpressure背压,这种方式实现起来总的来说比较复杂而且在服务端保存所有消费者的消费进度也占用一定的内存。而如果是消费者客户端主动pull,消费者可以按照自己的消费能力消费消息,正所谓能者多劳,性能强的自然消费的快点多点,性能差的消费的慢点少点也是可以接受的。
消费者客户端主动pull并且记录offset状态实际上还有诸多好处,因为消费者可以自己控制offset,如果业务需要,它可以回退到某个offset重新处理消息,或者消息一下子太多处理不过来而又不想处理,可以前进到最近的offset那里继续开始消费。而如果在服务端记录消费者的offset,这一切都无从谈起,因为服务端无法做这种特殊的定制,即使加入了这样的自定义逻辑,服务端的实现也会非常复杂。综合上面这些因素,Kafka的消费者实现采用更高效更具扩展性的push模式消费消息。
不过有时候应用程序从Kafka读取数据,并不太关心消息offset的处理,所以Kafka提供了两种层次的客户端API:1)Hight Level Consumer
高级API提供了一个从Kafka消费数据的高层抽象,消费者客户端代码不需要管理offset的提交,并且采用了消费组的自动负载均衡功能,确保消费者的增减不会影响消息的消费;2)Low Level Consumer
低级API通常针对特殊的消费逻辑(比如客消费者只想要消费某些特定的Partition),低级API的客户端代码需要自己实现一些和Kafka服务端相关的底层逻辑,比如选择Partition的Leader,处理Leader的故障转移等。
表4-1中高级API主要使用了ConsumerGroup语义实现消费者的自动负责均衡,低级API主要针对SimpleConsumer,不过选举Leader,拉取消息这些都要自己去实现,实际应用中高级API虽然功能简单但是用的还是比较多,毕竟越简单的东西越不容易出问题。实际上高级API也会使用SimpleConsumer类完成消息的拉取,不过其他的复杂工作都被封装起来,对客户端代码而言是透明的。
表4-1 消费者客户端的高级API和低级API
上面我们从消费者谈到消费组、消费者线程和Partition的关系、offset的外部存储和push模式,有了这些基础知识的铺垫后,读者最好带着下面这些问题思考Kafka的消费者是如何实现的:
-
消费组管理所有消费者,消费者领取消费组分配的任务是通过读取ZK完成的,消费者注册ZK监听器并触发rebalance操作
-
消费者线程拉取Partition数据,一个消费者进程允许有多个线程,客户端如何管理多个线程的消息拉取
-
消费者拉取到消息后,offset定时提交到ZK,那么什么时候会读取offset:发生rebalance后
[1]
,拉取消息之前[2]
消费组状态机
我们先假设初始时世界是混沌的还没有盘古的开天辟地,协调者也是一片荒芜人烟之地,没有保存任何状态,因为消费组的初始状态是Stable,在第一次的Rebalance时,正常的还没有向消费组注册过的消费者会执行状态为Stable
而且memberId=UNKNOWN_MEMBER_ID
条件分支。在第一次Rebalance之后,每个消费者都分配到了一个成员编号,系统又会进入Stable稳定状态(Stable稳定状态包括两种:一种是没有任何消费者的稳定状态,一种是有消费者的稳定状态)。因为所有消费者在执行一次JoinGroup后并不是说系统就一直保持这种不变的状态,有可能因为这样或那样的事件导致消费者要重新进行JoinGroup,这个时候因为之前JoinGroup过了每个消费者都是有成员编号的,处理方式肯定是不一样的。
所以定义一种事件驱动的状态机就很有必要了,这世界看起来是杂乱无章的,不过只要遵循着状态机的规则(万物生长的理论),任何事件都是有迹可循有路可走有条不紊地进行着。
private def doJoinGroup(group: GroupMetadata,memberId: String,clientId: String,
clientHost: String,sessionTimeoutMs: Int,protocolType: String,
protocols: List[(String, Array[Byte])],responseCallback: JoinCallback) {
if (group.protocolType!=protocolType||!group.supportsProtocols(protocols.map(_._1).toSet)) {
//protocolType对于消费者是consumer,注意这里的协议类型和PartitionAssignor协议不同哦
//协议类型目前总共就两种消费者和Worker,而协议是PartitionAssignor分配算法
responseCallback(joinError(memberId, Errors.INCONSISTENT_GROUP_PROTOCOL.code))
} else if (memberId != JoinGroupRequest.UNKNOWN_MEMBER_ID && !group.has(memberId)) {
//如果当前组没有记录该消费者,而该消费者却被分配了成员编号,则重置为未知成员,并让消费者重试
responseCallback(joinError(memberId, Errors.UNKNOWN_MEMBER_ID.code))
} else { group.currentState match {
case Dead =>
responseCallback(joinError(memberId, Errors.UNKNOWN_MEMBER_ID.code))
case PreparingRebalance =>
if (memberId == JoinGroupRequest.UNKNOWN_MEMBER_ID) { //2.第二个消费者在这里了!
addMemberAndRebalance(sessionTimeoutMs, clientId, clientHost,
protocols, group, responseCallback)
} else {
val member = group.get(memberId)
updateMemberAndRebalance(group, member, protocols, responseCallback)
}
case Stable =>
if (memberId == JoinGroupRequest.UNKNOWN_MEMBER_ID) { //1.初始时第一个消费者在这里!
//如果消费者成员编号是未知的,则向GroupMetadata注册并被记录下来
addMemberAndRebalance(sessionTimeoutMs, clientId, clientHost,
protocols, group, responseCallback)
} else { //3.第二次Rebalance时第一个消费者在这里,此时要分Leader还是普通的消费者了
val member = group.get(memberId)
if (memberId == group.leaderId || !member.matches(protocols)) {
updateMemberAndRebalance(group, member, protocols, responseCallback)
} else {
responseCallback(JoinGroupResult(members = Map.empty,memberId = memberId,
generationId = group.generationId,subProtocol = group.protocol,
leaderId = group.leaderId,errorCode = Errors.NONE.code))
}
}
}
if (group.is(PreparingRebalance))
joinPurgatory.checkAndComplete(GroupKey(group.groupId))
}
}
addMemberAndRebalance和updateMemberAndRebalance会创建或更新MemberMetadata,并且会尝试调用prepareRebalance
,消费组中只有一个消费者有机会调用prepareRebalance,并且一旦调用该方法,会将消费组状态更改为PreparingRebalance
,就会使得下一个消费者只能从case PreparingRebalance
入口进去了,假设第一个消费者是从Stable进入的,它更改了状态为PreparingRebalance,下一个消费者就不会从Stable进来的。不过进入Stable状态还要判断消费者是不是已经有了成员编号,通常是之前已经发生了Rebalance,这种影响也是比较巨大的,每个消费者走的路径跟第一次的Rebalance是完全不同的迷宫地图了。
1)第一次Rebalance如图6-18的上半部分:
-
第一个消费者,状态为Stable,没有编号,addMemberAndRebalance,成为Leader,执行prepareRebalance,更改状态为PreparingRebalance,创建DelayedJoin
-
第二个消费者,状态为PreparingRebalance,没有编号,addMemberAndRebalance(不执行prepareRebalance,因为在状态改变成PreparingRebalance后就不会被执行了);后面的消费者同第二个
-
所有消费者都要等协调者收集完所有成员编号在DelayedJoin完成时才会收到JoinGroup响应
图6-18 第一次和第二次Rebalance
2)第二次Rebalance,对于之前加入过的消费者都要成员编号如图6-18的下半部分:
-
第一个消费者是Leader,状态为Stable,有编号,updateMemberAndRebalance,更改状态为PreparingRebalance,创建DelayedJoin
-
第二个消费者,状态为PreparingRebalance,有编号,updateMemberAndRebalance;后面的消费者同第二个
-
所有消费者也要等待,因为其他消费者发送Join请求在Leader消费者之后。
3)不过如果有消费者在Leader之前发送又有点不一样了如图6-19:
-
第一个消费者不是Leader,状态为Stable,有编号,responseCallback,立即收到JoinGroup响应,好幸运啊!
-
第二个消费者如果也不是Leader,恭喜你,协调者也放过他,直接返回JoinGroup响应
-
第三个消费者是Leader(领导来了),状态为Stable(什么,你们之前的消费者竟然都没更新状态!,因为他们都没有add或update),有编号,updateMemberAndRebalance(还是我第一个调用add或update,看来还是只能我来更新状态),更改状态为PreparingRebalance,创建DelayedJoin
-
第四个消费者不是Leader,状态为PreparingRebalance,有编号,updateMemberAndRebalance(前面有领导,不好意思了,不能立即返回JoinGroup给你了,你们这些剩下的消费者都只能和领导一起返回了,算你们倒霉)
图6-19 Leader非第一个发送JoinGroup请求
4)如果第一个消费者不是Leader,也没有编号,说明这是一个新增的消费者,流程又不同了如图6-20:
-
第一个消费者不是Leader,状态为Stable,没有编号,addMemberAndRebalance,执行prepareRebalance(我是第一个调用add或update的哦,你们都别想跟我抢这个头彩了),更改状态为PreparingRebalance(我不是Leader但我骄傲啊),创建DelayedJoin(我抢到头彩,当然创建DelayedJoin的工作只能由我来完成了)
-
第二个消费者也不是Leader,恭喜你,协调者也放过他,直接返回JoinGroup响应
-
第三个消费者是Leader(领导来了),状态为PreparingRebalance(有个新来的不懂规矩,他已经把状态改了),有编号,updateMemberAndRebalance(有人已经改了,你老就不用费心思了),凡是没有立即返回响应的,都需要等待,领导也不例外
-
第四个消费者不是Leader(废话,只有一个领导,而且领导已经在前面了),不会立即返回响应(你看领导都排队呢)
-
虽然DelayedJoin是由没有编号的消费者创建,不过由于DelayedJoin是以消费组为级别的,所以不用担心,上一次选举出来的领导还是领导,协调者最终还是会把members交给领导,不会是给那个没有编号的消费者的,虽然说在他注册的时候已经有编号了,但是大家不认啊。不过领导其实不在意是谁开始触发prepareRebalance的,那个人要负责生成DelayedJoin,而不管是领导自己还是其他人一旦更改状态为PreparingRebalance,后面的消费者都要等待DelayedJoin完成了,而领导者总是要等待的,所以他当然无所谓了,因为他知道最后协调者总是会把members交给他的。
图6-20 新增消费组第一个发送JoinGroup请求
根据上面的几种场景总结下来状态机的规则和一些结论如下:
-
第一个调用addMemberAndRebalance或者updateMemberAndRebalance的会将状态改为PreparingRebalance,并且负责生成DelayedJoin
-
一旦状态进入PreparingRebalance,其他消费者就只能从PreparingRebalance状态入口进入,这里只有两种选择addMemberAndRebalance或者updateMemberAndRebalance,不过他们不会更改状态,也不会生成DelayedJoin
-
发生DelayedJoin之后,其他消费者的JoinGroup响应都会被延迟,因为如规则2中,他们只能调用add或update,无法立即调用responseCallback,所以就要和DelayedJoin的那个消费者一起等待
-
正常流程时,发生responseCallback的是存在成员编号的消费者在Leader之前发送了JoinGroup,或者新增加的消费者发送了JoinGroup请求之前
-
第一次Rebalance时,第一个消费者会创建DelayedJoin,之后的Rebalance,只有新增的消费者才有机会创建(如果他在Leader之前发送的话,如果在Leader之后就没有机会了),而普通消费者总是没有机会创建DelayedJoin的,因为状态为Stable时,他会直接开溜,有人(Leader或者新增加的消费者)创建了DelayedJoin之后,他又在那边怨天尤人只能等待
更多推荐
所有评论(0)