kafka08-深入客户端

1、分区分配策略

  • Kafka消费者客户端使用参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。
    • 分区分配策略有三种:RangeAssignor、RoundRobinAssignor和StickyAssignor。
    • 默认分配策略是RangeAssignor,即参数的值为org.apache.kafka.clients.consumer.RangeAssignor。
  • 消费者客户端参数partition.assignment.strategy可以配置多个分配策略,彼此之间以逗号分隔。

1.1、RangeAssignor分配策略

  • RangeAssignor分配策略对应的partition.assignment.strategy参数值为org.apache.kafka.clients.consumer.RangeAssignor。
  • RangeAssignor分配策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。对于每一个主题,RangeAssignor策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。
    • 假设n=分区数/消费者数量,m=分区数%消费者数量,那么前m个消费者每个分配n+1个分区,后面的消费者每个分配n个分区
  • 假设消费组内有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有4个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:
    • 消费者C0:t0p0、t0p1、t1p0、t1p1
    • 消费者C1:t0p2、t0p3、t1p2、t1p3
  • 这样分配得很均匀,那么这个分配策略能够一直保持这种良好的特性吗?我们不妨再来看一个示例。
  • 假设消费组内有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
    • 消费者C0:t0p0、t0p1、t1p0、t1p1
    • 消费者C1:t0p2、t1p2
  • 可以明显地看到这样的分配并不均匀,如果将类似的情形扩大,则有可能出现部分消费者过载的情况。

1.2、RoundRobinAssignor分配策略

  • RoundRobinAssignor分配策略对应的partition.assignment.strategy参数值为org.apache.kafka.clients.consumer.RoundRobinAssignor。
  • RoundRobinAssignor分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者。
  • 如果同一个消费组内所有的消费者的订阅信息都是相同,那么RoundRobinAssignor分配策略的分区分配会是均匀的。
  • 假设消费组中有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
    • 消费者C0:t0p0、t0p2、t1p1
    • 消费者C1:t0p1、t1p0、t1p2
  • 如果同一个消费组内的消费者订阅的信息是不相同,那么在执行分区分配的时候就不是完全的轮询分配,有可能导致分区分配得不均匀。如果某个消费者没有订阅消费组内的某个主题,那么在分配分区的时候此消费者将分配不到这个主题的任何分区。
  • 假设消费组内有3个消费者(C0、C1和C2),它们共订阅了3个主题(t0、t1、t2),这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。具体而言,消费者C0订阅的是主题t0,消费者C1订阅的是主题t0和t1,消费者C2订阅的是主题t0、t1和t2,那么最终的分配结果为:
    • 消费者C0:t0p0
    • 消费者C1:t1p0
    • 消费者C2:t1p1 、t2p0 、t2p1 、t2p2
  • 可以看到RoundRobinAssignor策略也不是十分完美,这样分配其实并不是最优解,因为如果将分区t1p1分配给消费者C1会更好。

1.3、StickyAssignor分配策略

  • Kafka从0.11.x版本开始引入StickyAssignor分配策略("sticky"这个单词可以翻译为“黏性的”),它主要有两个目的:
    • (1)分区的分配要尽可能均匀。
    • (2)分区的分配尽可能与上次分配的保持相同。
  • 当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor分配策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂得多。

1.3.1、消费者订阅信息相同

  • 假设消费组内有3个消费者(C0、C1和C2),它们都订阅了4个主题(t0、t1、t2、t3),并且每个主题有2个分区。也就是说,整个消费组订阅了t0p0、t0p1、t1p0、t1p1,t2p0、t2p1、t3p0、t3p1这8个分区。最终的分配结果如下:
    • 消费者C0:t0p0、t1p1、t3p0
    • 消费者C1:t0p1、t2p0、t3p1
    • 消费者C2:t1p0、t2p1
  • 这样初看上去似乎与采用RoundRobinAssignor分配策略所分配的结果相同,但事实是否真的如此呢?再假设此时消费者C1脱离了消费组,那么消费组就会执行再均衡操作,进而消费分区会重新分配。如果采用RoundRobinAssignor分配策略,那么此时的分配结果如下:
    • 消费者C0:t0p0、t1p0、t2p0、t3p0
    • 消费者C2:t0p1、t1p1、t2p1、t3p1
  • 如分配结果所示,RoundRobinAssignor分配策略会将所有订阅的分区按照消费者C0和C2进行重新轮询分配。如果此时使用的是StickyAssignor分配策略,那么分配结果为:
    • 消费者C0:t0p0、t1p1、t3p0、t2p0
    • 消费者C2:t1p0、t2p1、t0p1、t3p1
  • 可以看到分配结果中保留了上一次分配中对消费者C0和C2的所有分配结果,并将原来消费者C1的“负担”分配给了剩余的两个消费者C0和C2,最终C0和C2的分配还保持了均衡。
  • 如果发生分区重分配,那么对于同一个分区而言,有可能之前的消费者和新指派的消费者不是同一个,之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。StickyAssignor分配策略如同其名称中的"sticky”一样,让分配策略具备一定的“黏性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗及其他异常情况的发生。

1.3.2、消费者订阅信息不相同

  • 假设消费组内有3个消费者(C0、C1和C2),它们共订阅了3个主题(t0、t1、t2),这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。具体而言,消费者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
  • 可以看到这才是一个最优解(消费者C0没有订阅主题t1和t2,所以不能分配主题t1和t2中的任何分区给它,对于消费者C1也可同理推断)。
  • 假如此时消费者C0脱离了消费组:
    • 如果采用的是RoundRobinAssignor分配策略,分配结果是:
      • 消费者C1: t0p0、t1p1
      • 消费者C2: t1p0、t2p0、t2p1、t2p2
    • 如果采用的是StickyAssignor分配策略,分配结果是:
      • 消费者C1: t1p0、t1p1、t0p0
      • 消费者C2: t2p0、t2p1 、t2p2
  • 可以看到StickyAssignor分配策略保留了消费者C1和C2中原有的5个分区的分配。

1.4、自定义分区分配策略

  • 自定义的分配策略必须要实现org.apache.kafka.clients.consumer.internals.PartitionAssignor接口。
  • PartitionAssignor接口中定义了两个内部类:Subscription和Assignment。

2、消费者协调器和组协调器

  • 消费者协调器和组协调器的概念是针对新版的消费者客户端而言的,Kafka建立之初并没有它们。旧版的消费者客户端是使用ZooKeeper的监听器(Watcher)来实现这些功能的。

2.1、旧版消费者客户端的问题

  • 每个消费组(<group>)在ZooKeeper中都维护了一个/consjmers/<group>/ids路径,在此路径下使用临时节点记录隶属于此消费组的消费者的唯一标识(consumerIdString),consumerIdString由消费者启动时创建。
  • 消费者的唯一标识由consumer.id+主机名+时间戳+UUID的部分信息构成,其中consumer.id是旧版消费者客户端中的配置,相当于新版客户端中的client.id。比如某个消费者的唯一标识为consumerId_localhost-1510734527562-64b377f5,那么其中consumerid为指定的consumer.id,localhost为计算机的主机名,1510734527562代表时间截,而64b377f5表示UUID的部分信息。
  • 与/consumers/<group>/ids同级的还有两个节点:owners和offsets,/consumers/<group>/owner路径下记录了分区和消费者的对应关系,/consumers/<group>/offsets路径下记录了此消费组在分区中对应的消费位移,如图7-4所示。

  • 每个broker、主题和分区在ZooKeeper中也都对应一个路径:
    • /brokers/ids/<id>记录了host、port及分配在此broker上的主题分区列表。
    • /brokers/topics/<topic>记录了每个分区的leader副本、ISR集合等信息。
    • /brokers/topics/<topic>/partitions/<partition>/state记录了当前leader副本、leader_epoch等信息。
  • 每个消费者在启动时都会在/consumers/<group>/ids和/brokers/ids路径上注册个监听器。当/consumers/<group>/ids路径下的子节点发生变化时,表示消费组中的消费者发生了变化;当/brokers/ids路径下的子节点发生变化时,表示broker出现了增减。这样通过ZooKeeper所提供的Watcher,每个消费者就可以监听消费组和Kafka集群的状态了。
  • 这种方式下每个消费者对ZooKeeper的相关路径分别进行监听,当触发再均衡操作时,个消费组下的所有消费者会同时进行再均衡操作,而消费者之间并不知道彼此操作的结果,这样可能导致Kafka工作在一个不正确的状态。与此同时,这种严重依赖于ZooKeeper集群的做法还有两个比较严重的问题。
    • (1)羊群效应(Herd Effect):所谓的羊群效应是指ZooKeeper中一个被监听的节点变化,大量的Watcher通知被发送到客户端,导致在通知期间的其他操作延迟,也有可能发生类似死锁的情况。
    • (2)脑裂问题(Split Brain):消费者进行再均衡操作时每个消费者都与ZooKeeper进行通信以判断消费者或broker变化的情况,由于ZooKeeper本身的特性,可能导致在同一时刻各个消费者获取的状态不一致,这样会导致异常问题发生。

2.2、再均衡的原理

  • 新版的消费者客户端对此进行了重新设计,将全部消费组分成多个子集,每个消费组的子集在服务端对应一个GroupCoordinator对其进行管理,GroupCoordinator是Kafka服务端中用于管理消费组的组件。而消费者客户端中的ConsumerCoordinator组件负责与GroupCoordinator进行交互
  • ConsumerCoordinator与GroupCoordinator之间最重要的职责就是负责执行消费者再均衡的操作,包括前面提及的分区分配的工作也是在再均衡期间完成的。就目前而言,一共有如下几种情形会触发再均衡的操作:
    • 有新的消费者加入消费组。
    • 有消费者宕机下线。消费者并不一定需要真正下线,例如遇到长时间的GC、网络延迟导致消费者长时间未向GroupCoordinator发送心跳等情况时,GroupCoordinator会认为消费者已经下线。
    • 有消费者主动退出消费组(发送LeaveGroupRequest请求)。比如客户端调用了unsubscrible()方法取消对某些主题的订阅。
    • 消费组所对应的GroupCoorinator节点发生了变更。
    • 消费组内所订阅的任一主题或者主题的分区数量发生变化。
  • 下面就以一个简单的例子来讲解一下再均衡操作的具体内容。当有消费者加入消费组时,消费者、消费组及组协调器之间会经历一下几个阶段。

2.2.1、第一阶段(FIND_COORDINATOR)

  • 消费者需要确定它所属的消费组对应的GroupCoordinator所在的broker,并创建与该broker相互通信的网络连接。
    • 如果消费者已经保存了与消费组对应的GroupCoordinator节点的信息,并且与它之间的网络连接是正常的,那么就可以进入第二阶段。
    • 否则,就需要向集群中的某个节点发送FindCoordinatorRequest请求来查找对应的GroupCoordinator,这里的“某个节点”并非是集群中的任意节点,而是负载最小的节点(即leastLoadedNode)。
  • FindCoordinatorRequest请求体(如图7-5所示)中只有两个域(Fieid):coordinator_key和coordinator_type。
    • coordinator_key在这里就是消费组的名称,即groupid。
    • coordinator_type置为0。

  • Kafka服务器在收到FindCoordinatorRequest请求之后,会根据coordinator_key(也就是groupid)查找对应的GroupCoordinator节点,如果找到对应的GroupCoordinator则会返回其相对应的node_id、host和port信息。
  • 具体查找GroupCoordinator的方式是先根据消费组groupid的哈希值计算_consumer_offsets中的分区编号,具体算法如下:
    • Utils.abs(groupid.hashCode) % groupMetadataTopicPartitionCount
    • 其中groupId.hashCode就是使用Java中String类的hashCode()方法获得的,groupMetadatatopicPartitionCount为主题__consumer_offsets的分区个数,这个可以通过broker端参数offsets.topic.num.partitions来配置,默认值为50。
  • 找到对应的_consumer_offsets中的分区之后,再寻找此分区leader副本所在的broker节点,该broker节点即为这个groupId所对应的GroupCoordinator节点。消费者groupId最终的分区分配方案及组内消费者所提交的消费位移信息都会发送给此分区leader副本所在的broker节点,让此broker节点既扮演GroupCoordinator的角色,又扮演保存分区分配方案和组内消费者位移的角色,这样可以省去很多不必要的中间轮转所带来的开销。

2.2.2、第二阶段(JOIN_GROUP)

  • 在成功找到消费组所对应的GroupCoordinator之后就进入加入消费组的阶段,在此阶段的消费者会向GroupCoordinator发送JoinGroupRequest请求,并处理响应。
  • JoinGroupRequest的结构包含多个域:group_id就是消费组的id,通常也表示为groupId。如图7-6所示:

    • session_timout对应消费端参数session.timeout.ms,默认值为10000,即10秒。GroupCoordinator超过session_timeout指定的时间内没有收到心跳报文则认为此消费者已经下线。
    • rebalance_timeout对应消费端参数max.poll.interval.ms,默认值为300000,即5分钟。表示当消费组再平衡的时候,GroupCoordinator等待各个消费者重新加入的最长等待时间。
    • member_id表示GroupCoordinator分配给消费者的id标识。消费者第一次发送JoinGroupRequest请求的时候此字段设置为null。
    • protocol_type表示消费组实现的协议,对于消费者而言此字段值为"consumer"。
  • JoinGroupRequest中的group_protocols域为数组类型,其中可以囊括多个分区分配策略,这个主要取决于消费者客户端参数partition.assignment.strategy的配置。如果配置了多种策略,那么JoinGroupRequest中就会包含多个protocol_name和protocolmetadata
    • protocol_name对应于PartitionAssignor接口中的name()方法。
    • protocolmetadata和PartitionAssignor接口中的subscription()方法有直接关系,protocol_metadata是一个bytes类型,其实质上还可以更细粒度地划分为version、topics和userdata,如图7-7所示。

    • version占2个字节,目前其值固定为0。topics对应PartitionAssignor接口的subscription()方法返回值类型Subscription中的topics,代表一个主题列表。user_data对应Subscription中的userData,可以为空。
  • 如果是原有的消费者重新加入消费组,那么在真正发送JoinGroupRequest请求之前还要执行一些准备工作:
    • (1)如果消费端参数enable.auto.commit设置为true(默认值也为true),即开启自动提交位移功能,那么在请求加入消费组之前需要向GroupCoordinator提交消费位移。这个过程是阻塞执行的,要么成功提交消费位移,要么超时。
    • (2)如果消费者添加了自定义的再均衡监听器(ConsumerRebalanceListener),那么此时会调用onPartitionsRevoked()方法在重新加入消费组之前实施自定义的规则逻辑,比如清除一些状态,或者提交消费位移等。
    • (3)因为是重新加入消费组,之前与GroupCoordinator节点之间的心跳检测也就不需要了,所以在成功地重新加入消费组之前需要禁止心跳检测的动作。
  • 消费者在发送JoinGroupRequest请求之后会阻塞等待Kafka服务端的响应。服务端在收到JoinGroupRequest请求后a交由GroupCoordinator进行处理GroupCoordinator首先会对JoinGroupRequest请求做合法性校验,比如group_id是否为空、当前broker节点是否是请求的消费者组所对应的组协调器、rebalance_timeout的值是否在合理的范围之内。如果消费者是第一次请求加入消费组,那么JoinGroupRequest请求中的member_id值为null,即没有它自身的唯一标志,此时组协调器负责为此消费者生成一个memberid。这个生成的算法很简单,具体如以下伪代码所示:
    • String memberid = clientId + "-" + UUID.randomUUID().toString();
    • 其中clientId为消费者客户端的clientId,对应请求头中的client_id。由此可见消费者的member_id由clientId和UUID用“-”字符拼接而成。

选举消费组的leader

  • GroupCoordinator需要为消费组内的消费者选举出一个消费组的leader,这个选举的算法也很简单,分两种情况分析。
    • 如果消费组内还没有leader,那么第一个加入消费组的消费者即为消费组的leader。
    • 如果某一时刻leader消费者由于某些原因退出了消费组,那么会重新选举一个新的leader,这个重新选举leader的过程就“随意”了。
  • 消费组的leader选举过程是很随意的

选举分区分配策略

  • 每个消费者都可以设置自己的分区分配策略,对消费组而言需要从各个消费者呈报上来的各个分配策略中选举一个彼此都“信服”的策略来进行整体上的分区分配。这个分区分配的选举并非由leader消费者决定,而是根据消费组内的各个消费者投票来决定的。这里所说的“根据组内的各个消费者投票来决定”不是指GroupCoordinator还要再与各个消费者进行进一步交互,而是根据各个消费者呈报的分配策略来实施。最终选举的分配策略基本上可以看作被各个消费者支持的最多的策略,具体的选举过程如下:
    • (1)收集各个消费者支持的所有分配策略,组成候选集candidates。
    • (2)每个消费者从候选集candidates中找出第一个自身支持的策略,为这个策略投上一票。
    • (3)计算候选集中各个策略的选票数,选票数最多的策略即为当前消费组的分配策略。
  • 如果有消费者并不支持选出的分配策略,那么就会报出异常(IllegalArgumentException:Member does not support protocol)。
  • 注意,这里所说的“消费者所支持的分配策略”是指partition.assignment.strategy参数配置的策略,如果这个参数值只配置了RangeAssignor,那么这个消费者客户端只支持RangeAssignor分配策略,而不是消费者客户端码中实现的3种分配策略及可能的自定义分配策略
  • 在此之后,Kafka服务端就要发送JoinGroupResponse响应给各个消费者,leader消费者和其他普通消费者收到的响应内容并不相同,首先我们看一下JoinGroupResponse的具体结构,如图7-8所示。

    • JoinGroupResponse包含了多个域,其中generation_id用来标识当前消费组的年代信息,避免受到过期请求的影响。leader_id表示消费组leader消费者的member_id。
    • Kafka发送给普通消费者的JoinGroupResponse中的members内容为空,而只有leader消费者的JoinGroupResponse中的members包含有效数据。members为数组类型,其中包含各个成员信息。
    • member_metadata为消费者的订阅信息,与JoinGroupRequest中的protocol_metadata内容相同,不同的是JoinGroupRequest可以包含多个<protocol_name,protocol_metadata>的键值对。
  • 在收到JoinGroupRequest之后,GroupCoordinator已经选举出唯一的分配策略。也就是说,protocol_name已经确定(group_protocol),那么对应的protocol_metadata也就确定了,最终各个消费者收到的JoinGroupResponse响应中的member_metadata就是这个确定了的protocol_metadata。
  • 由此可见,Kafka把分区分配的具体分配交还给客户端,自身并不参与具体的分配细节,这样即使以后分区分配的策略发生了变更,也只需要重启消费端的应用即可,而不需要重启服务端。
  • 本阶段的内容可以简要概括为图7-9 和图7-10。

2.2.3、第三阶段(SYNC GROUP)

  • leader消费者根据在第二阶段中选举出来的分区分配策略来实施具体的分区分配,在此之后需要将分配的方案同步给各个消费者,此时leader消费者并不是直接和其余的普通消费者同步分配方案,而是通过GroupCoordinator这个“中间人”来负责转发同步分配方案的。
  • 在第三阶段,也就是同步阶段,各个消费者会向GroupCoordinator发送SyncGroupRequest请求来同步分配方案,如图7-11所示。

  • 我们再来看一下SyncGroupRequest请求的具体结构,如图7-12所示。
    • SyncGroupRequest中的group_id、generation_id和member_id前面都有涉及,这里不再赞述。
    • 只有leader消费者发送的SyncGroupRequest请求中才包含具体的分区分配方案,这个分配方案保存在group_assignment中,而其余消费者发送的SyncGroupRequest请求中的group_assignment为空。

  • group_assignment是一个数组类型,其中包含了各个消费者对应的具体分配方案:
    • member_id表示消费者的唯一标识。
    • member_assignment是与消费者对应的分配方案,它还可以做更具体的划分。
  • member_assignment的结构如图7-13所示。
    • member_assignment存储的是分区信息,member_assignment中可以包含多个主题的多个分区信息。

  • 服务端在收到消费者发送的SyncGroupRequest请求之后会交由GroupCoordinator来负责具体的逻辑处理。GroupCoordinator同样会先对SyncGroupRequest请求做合法性校验,在此之后会将从leader消费者发送过来的分配方案提取出来,连同整个消费组的元数据信息一起存入Kafka的__consumer_offsets主题中,最后发送响应给各个消费者以提供给各个消费者各自所属的分配方案。
  • SyncGroupResponse的很简单,里面包含消费者对应的所属分配方案,SyncGroupResponse的结构如图7-14所示:

  • 当消费者收到所属的分配方案之后会调用PartitionAssignor中的onAssignment()方法。随后再调用ConsumerRebalanceListener中的OnPartitionAssigned()方法。之后开启心跳任务,消费者定期向服务端的GroupCoordinator发送HeartbeatRequest来确定彼此在线。

消费组元数据信息

  • 消费者客户端提交的消费位移会保存在Kafka的__consumer_offsets主题中。
  • 消费组的元数据信息(GroupMetadata)也会保存在Kafka的__consumer_offsets主题中。
    • 具体来说,每个消费组的元数据信息都是一条消息,不过这类消息并不依赖于具体版本的消息格式,因为它只定义了消息中的key和value字段的具体内容,所以消费组元数据信息的保存可以做到与具体的消息格式无关。
  • 消费组元数据信息的具体内容格式如图7-15所示,上面是消息的key,下面是消息的value。
    • key和value中都包含version字段,用来标识具体的key和value的版本信息,不同的版本对应的内容格式可能并不相同,就目前版本而言,key的version为2,而value的version为1。
    • 虽然key中包含了version字段,但确定这条信息所要存储的分区还是根据单独的group字段来计算的,这样就可以保证消费组的元数据信息与消费组对应的GroupCoordinator处于同一个broker节点上,省去了中间轮转的开销。
    • key中的group字段,它表示消费组的名称,和JoinGroupRequest或SyncGroupRequest请求中的group_id是同一个东西。
    • value中的protocol_type:消费组实现的协议,这里的值为"consumer”
    • value中的generation:标识当前消费组的年代信息,避免收到过期请求的影响。
    • value中的protocol:消费组选取的分区分配策略。
    • value中的leader:消费组的leader消费者的名称。
    • value中的members:数组类型,其中包含了消费组的各个消费者成员信息,图7-15中右边部分就是消费者成员的具体信息,每个具体字段都比较容易辨别,需要着重说明的是subscription和assignment这两个字段,分别是消费者的订阅信息和分配信。

 

2.2.4、第四阶段(HEARTBEAT)

  • 进入这个阶段之后,消费组中的所有消费者就会处于正常工作状态
  • 在正式消费之前,消费者还需要确定拉取消息的起始位置。假设之前已经将最后的消费位移提交到了GroupCoordinator,并且GroupCoordinator将其保存到了Kafka内部的__consumer_offsets主题中,此时消费者可以通过OffsetFetchRequest请求获取上次提交的消费位移并从此处继续消费。
  • 消费者通过向GroupCoordinator发送心跳来维持它们与消费组的从属关系,以及它们对分区的所有权关系。
    • 只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明它还在读取分区中的消息。
    • 心跳线程是一个独立的线程,可以在轮询消息的空档发送心跳。如果消费者停止发送心跳的时间足够长,则整个会话就被判定为过期,GroupCoordinator也会认为这个消费者已经死亡,就会触发一次再均衡行为。
    • 消费者的心跳间隔时间由参数heartbeat.interval.ms指定,默认值为3000,即3秒,这个参数必须比session.timeout.ms参数设定的值要小,般情况下heartbeat.interval.ms的配置值不能超过session.timeout.ms配置值的1/3。这个参数可以调整得更低,以控制正常重新平衡的预期时间。
  • 如果一个消费者发生崩溃,并停止读取消息,那么GroupCoordinator会等待一小段时间,确认这个消费者死亡之后才会触发再均衡。在这一小段时间内,死掉的消费者并不会读取分区里的消息。这个一小段时间由session.timeout.ms参数控制,该参数的配置值必须在broker端参数group.min.session.timeout.ms(默认值为6000,即6秒)和group.maxsession.timeout.ms(默认值为300000,即5分钟)允许的范围内。
  • 还有一个参数max.poll.interval.ms,它用来指定使用消费者组管理时poll()方法调用之间的最大延迟,也就是消费者在获取更多消息之前可以空闲的时间量的上限。如果此超时时间期满之前poll(没有调用,则消费者被视为失败,并且分组将重新平衡,以便将分区重新分配给别的成员。
  • 除了被动退出消费组,还可以使用LeaveGroupRequest请求主动退出消费组,比如客户端调用了unsubscrible()方法取消对某些主题的订阅,这个比较简单,这里就不再赘述了。

3、__consumer _offsets剖析

  • 位移提交的内容最终会保存到Kafka的内部主题__consumer_offsets中。
  • 一般情况下,当集群中第一次有消费者消费消息时会自动创建主题__consumer_offsets,不过它的副本因子还受offsets.topic.replication.factor参数的约束,这个参数的默认值为3(下载安装的包中此值可能为1),分区数可以通过offsets.topic.num.partitions参数设置,默认为50。
  • 客户端提交消费位移是使用OffsetCommitRequest请求实现的,OffsetCommitRequest的结构如图7-16所示,

  • 请求体第一层中的group_id、generation_id和member_id在前面的内容中已经介绍过多次了。
  • retentiontime表示当前提交的消费位移所能保留的时长,不过对于消费者而言这个值保持为-1。也就是说,按照broker端的配置offsets.retention.minutes来确定保留时长。offsets.retention.minutes的默认值为10080,即7天,超过这个时间后消费位移的信息就会被删除(使用墓碑消息和日志压缩策略)。注意这个参数在2.0.0版本之前的默认值为1440,即1天,很多关于消费位移的异常也是由这个参数的值配置不当造成的。有些定时消费的任务在执行完某次消费任务之后保存了消费位移,之后隔了一段时间再次执行消费任务,如果这个间隔时间超过offsets.retention.minutes的配置值,那么原先的位移信息就会丢失,最后只能根据客户端参数auto.offset.reset来决定开始消费的位置,遇到这种情况时就需要根据实际情况来调配offsets.retention.minutes参数的值。
  • 图7-17中展示了消费位移对应的消息内容格式,上面是消息的key,下面是消息的value。
    • 可以看到key和value中都包含了version字段,这个用来标识具体的key和value的版本信息,不同的版本对应的内容格式可能并不相同。就目前版本而言,key和value的version值都为1。
    • key中除了version字段还有group、topic、partition字段,分别表示消费组的groupId、主题名称和分区编号。
      • 虽然key中包含了4个字段,但最终确定这条消息所要存储的分区还是根据单独的group字段来计算的,这样就可以保证消费位移信息与消费组对应的GroupCoordinator处于同一个broker节点上,省去了中间轮转的开销,这一点与消费组的元数据信息的存储是一样的。
    • value中包含了5个字段,除version字段外,其余的offset、metadata、committimestamp、expire_timestamp字段分别表示消费位移、自定义的元数据信息、位移提交到Kafka的时间戳、消费位移被判定为超时的时间戳。其中offset和metadata与OffsetCommitRequest请求体中offset和metadata对应,expire_timestamp和OffsetCommitRequest请求体中retention_time有关联,commit_timestamp值与offsets.retention.minutes参数值之和即为expire_timestamp(默认情况下)

  • 在处理完消费位移之后,Kafka返回OffsetCommitResponse给客户端,OffsetCommitResponse的结构如图7-18所示

  • 可以通过kafka-console-consumer.sh脚本来查看__consumer_offsets中的内容,不过要设定formatter参数为kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter。假设要查看消费组"consumerGroupId"的位移提交信息,首先计算方式得出分区编号为20,然后查看这个分区中的消息,相关示例如下:
#计算__consumer_offsets中的分区编号
Utils.abs(groupid.hashCode) % groupMetadataTopicPartitionCount
#查看__consumer_offsets中的内容
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic _consumer-offsets --partition 20 --formatter 'kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter'
  • 有时候在查看主题__consumer_offsets中的内容时有可能出现下面这种情况:
    • [consumerGroupId, topic-offsets, 21]::null
  • 这说明对应的消费位移已经过期了。在Kafka中有一个名为"delete-expired-group-metadata"的定时任务来负责清理过期的消费位移,这个定时任务的执行周期由参数offsets.retention.check.interval.ms控制,默认值为600000,即10分钟。
  • 还有metadata,一般情况下它的值要么为null要么为空字符串,出现这种情况时,OffsetsMessageFormatter 会把它展示为"NO_METADATA”,否则就按实际值进行展示。
  • 如果有若干消费者消费了某个主题中的消息,并且也提交了相应的消费位移,那么在删除这个主题之后会一并将这些消费位移信息删除

4、事务

4.1、消息传输保障

  • 一般而言,消息中间件的消息传输保障有3个层级,分别如下。
    • (1)at most once:至多一次。消息可能会丢失,但绝对不会重复传输
    • (2)at least once:最少一次。消息绝不会丢失,但可能会重复传输。
    • (3)exactly once:恰好一次。每条消息肯定会被传输一次且仅传输一次。
  • Kafka的消息传输保障机制非常直观。当生产者向Kafka发送消息时,一旦消息被成功提交到日志文件,由于多副本机制的存在,这条消息就不会丢失。如果生产者发送消息到Kafka之后,遇到了网络问题而造成通信中断,那么生产者就无法判断该消息是否已经提交。虽然Kafka无法确定网络故障期间发生了什么,但生产者可以进行多次重试来确保消息已经写入Kafka,这个重试的过程中有可能会造成消息的重复写入,所以这里Kafka提供的消息传输保障为at least once
  • 对消费者而言,消费者处理消息和提交消费位移的顺序在很大程度上决定了消费者提供哪种消息传输保障。
    • 如果消费者在拉取完消息之后,应用逻辑先处理消息再提交消费位移,那么在消息处理之后且在位移提交之前消费者宕机了,待它重新上线之后,会从上一次位移提交的位置拉取,这样就出现了重复消费,因为有部分消息已经处理过了只是还没来得及提交消费位移,此时就对应at least once。
    • 如果消费者在拉完消息之后,应用逻辑先提交消费位移再进行消息处理,那么在位移提交之后且在消息处理完成之前消费者宕机了,待它重新上线之后,会从已经提交的位移处开始重新消费,但之前尚有部分消息未进行消费,如此就会发生消息丢失,此时就对应at most once。
  • Kafka从0.11.0.0版本开始引入了幕等和事务这两个特性,以此来实现EOS(exactly once semantics,精确一次处理语义)

4.2、幕等

  • 所谓的幕等,简单地说就是对接口的多次调用所产生的结果和调用一次是一致的。生产者在进行重试的时候有可能会重复写入消息,而使用Kafka的幕等性功能之后就可以避免这种情况。
  • 开启幕等性功能的方式很简单,只需要显式地将生产者客户端参数enable.idempotence设置为true即可(这个参数的默认值为false),参考如下:
    • properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,true);
    • 或者 properties.put("enable.idempotence",true);
  • 不过如果要确保幕等性功能正常,还需要确保生产者客户端的retries、acks、max.in.flight.requests.per.connection这几个参数不被配置错,否则将会报错。实际上在使用幕等性功能的时候,用户完全可以不用配置(也不建议配置)这几个参数
    • 如果用户显式地指定了retries参数,那么这个参数的值必须大于0。
    • 如果用户没有显式地指定retries参数,那么KafkaProducer会将它置为Integer.MAXVALUE。同时还需要保证max.in.flight.requests.per.connection参数的值不能大于5(这个参数的值默认为5)
    • 如果用户还显式地指定了acks参数,那么还需要保证这个参数的值为-1(all)。
  • 为了实现生产者的幕等性,Kafka为此引入了producerid(以下简称PID)和序列号(sequencenumber)这两个概念,分别对应v2版的日志格式中RecordBatch的producerid和firstseqence这两个字段。每个新的生产者实例在初始化的时候都会被分配一个PID,这个PID对用户而言是完全透明的。对于每个PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。生产者每发送条消息就会将<PID, 分区>对应的序列号的值加1。
  • broker端会在内存中为每一对<PID, 分区>维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应的序列号的值(SN_oId)大1(即SN_new=SN_oId+1)时,broker才会接收它。
    • 如果SN_new<SN_oId+1,那么说明消息被重复写入,broker可以直接将其丢弃。
    • 如果SN_new>SN_oId+1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出OutOfOrderSequenceException,这个异常是个严重的异常,后续的诸如send()、beginTransaction()、commitTransaction()等方法的调用都会抛出IllegalStateException的异常。
  • 引入序列号来实现幕等也只是针对每一对<PID, 分区>而言的,也就是说,Kafka的幕等只能保证单个生产者会话(session)中单分区的幕等
  • 示例中发送了两条相同的消息,不过这仅仅是指消息内容相同,但对Kafka而言是两条不同的消息,因为会为这两条消息分配不同的序列号。Kafka并不会保证消息内容的幕等
ProducerRecord<string, String> record=new ProducerRecord<> (topic, "key", "msg");
producer. send (record);
producer. send (record); 

4.3、事务

  • 幕等性并不能跨多个分区运作,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性操作的原子性是指多个操作要么全部成功,要么全部失败,不存在部分成功、部分失败的可能
  • 对流式应用(Stream Processing Applications)而言,一个典型的应用模式为"consume-transform-produce"。在这种模式下消费和生产并存:应用程序从某个主题中消费消息,然后经过一系列转换后写入另一个主题,消费者可能在提交消费位移的过程中出现问题而导致重复消费,也有可能生产者重复生产消息。Kafka中的事务可以使应用程序将消费消息、生产消息、提交消费位移当作原子操作来处理,同时成功或失败,即使该生产或消费会跨多个分区。
  • 为了实现事务,应用程序必须提供唯一的transactionalId,这个transactionalId通过客户端参数transactional.id来显式设置,参考如下:
    • properties.put (ProducerConfig.TRANSACTIONAL_ID_CONFIG, "transactionId");
    • 或者properties.put ("transactional.id", "transactionId");
  • 事务要求生产者开启幕等特性,因此通过将transactional.id参数设置为非空从而开启事务特性的同时需要将enable.idempotence设置为true(如果未显式设置,则KafkaProducer 默认会将它的值设置为true),如果用户显式地将enable.idempotence设置为false,则会报出ConfigException。
    • org.apache.kafka.common.config.ConfigException: Cannot set a transactional.id  without also enabling idempotence.
  • transactionalId与PID一一对应,两者之间所不同的是transactionalId由用户显式设置,而PID是由Kafka内部分配的。另外,为了保证新的生产者启动后具有相同transactionalId的旧生产者能够立即失效,每个生产者通过transactionalId获取PID的同时,还会获取一个单调递增的producer epoch(对应下面要讲述的KafkaProducer.initTransactions()方法)。如果使用同一个transactionalId开启两个生产者,那么前一个开启的生产者会报出如下的错误:
    • org.apache.kafka. common.errors.ProducerFencedException: Producer attempted an operation with an oId epoch. Either there is a newer producer with the same transactionalId, or the producer's transaction has been expired by the broker.
  • producer epoch同PID 和序列号一样,对应v2版的日志格式中RecordBatch的 producer epoch 字段。
  • 从生产者的角度分析,通过事务,Kafka可以保证跨生产者会话的消息幕等发送,以及跨生产者会话的事务恢复。
    • 前者表示具有相同transactionalId的新生产者实例被创建且工作的时候,旧的且拥有相同transactionalId的生产者实例将不再工作。
    • 后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交(Commit),要么被中止(Abort)如此可以使新的生产者实例从一个正常的状态开始工作。
  • 从消费者的角度分析,事务能保证的语义相对偏弱。出于以下原因,Kafka并不能保证已提交的事务中的所有消息都能够被消费:
    • 对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前面写入的消息)。
    • 事务中消息可能分布在同一个分区的多个日志分段(LogSegment)中,当老的日志分段被删除时,对应的消息可能会丢失。
    • 消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
    • 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所有消息。

4.3.1、事务相关的方法

  • KafkaProducer提供了5个与事务相关的方法:
    • initTransactions()方法用来初始化事务,这个方法能够执行的前提是配置了transactionalId,如果没有则会报出IllegalStateException:
      • java.lang. IllegalStateException: Cannot use transactional methods without enabling transactions by setting the transactional.id configuration property.
    • beginTransaction()方法用来开启事务。
    • sendOffsetsToTransaction()方法为消费者提供在事务内的位移提交的操作。
    • commitTransaction()方法用来提交事务。
    • abortTransaction()方法用来中止事务,类似于事务回滚。
  • 消费端参数isolation.level,与事务有着莫大的关联,这个参数的值可以是"read_uncommitted"(默认值)或"read_committed":
    • read_uncommitted:消费端应用可以看到(消费到)未提交的事务,当然对于已提交的事务也是可见的。
    • read_committed:表示消费端应用不可以看到尚未提交的事务内的消息。
  • 举个例子,如果生产者开启事务并向某个分区值发送3条消息msg1、msg2和msg3,在执行commitTransaction()或abortTransaction()方法前,将isolation.level设置为"read_committed”的消费端应用是消费不到这些消息的,不过在KafkaConsumer内部会缓存这些消息,直到生产者执行commitTransaction()方法之后它才能将这些消息推送给消费端应用。反之,如果生产者执行了abortTransaction()方法,那么KafkaConsumer会将这些缓存的消息丢弃而不推送给消费端应用。
  • 日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(ControlBatch)。
    • 控制消息一共有两种类型:COMMIT和ABORT,分别用来表征事务已经成功提交或已经被成功中止。
    • KafkaConsumer可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数isolation.level配置的隔离级别来决定是否将相应的消息返回给消费端应用,如图7-19所示。
    • 注意,ControlBatch对消费端应用不可见

4.3.2、事务的实现原理

  • 为了实现事务的功能,Kafka还引入了事务协调器(TransactionCoordinator)来负责处理事务,这一点可以类比一下组协调器(GroupCoordinator)。
    • 每一个生产者都会被指派一个特定的TransactionCoordinator,所有的事务逻辑包括分派PID等都是由TransactionCoordinator来负责实施的。
    • TransactionCoordinator会将事务状态持久化到内部主题transaction_state中。
  • 下面就以最复杂的consume-transform-produce的流程(参考图7-21)为例来分析Kafka事务的实现原理。

1、查找TransactionCoordinator

  • TransactionCoordinator负责分配PID和管理事务,因此生产者要做的第一件事情就是找出对应的TransactionCoordinator所在的broker节点。与查找GroupCoordinator节点一样,也是通过FindCoordinatorRequest请求来实现的,只不过FindCoordinatorRequest中的coordinatortype就由原来的0变成了1。
  • Kafka在收到FindCoorinatorRequest请求之后,会根据__coordinator_key(也就是transactionalId)查找对应的TransactionCoordinator节点。如果找到,则会返回其相对应的node_id、host和port信息。具体查找TransactionCoordinator的方式是根据transactionalId的哈希值计算主题transactionstate中的分区编号,具体算法:
    • Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount
    • 其中transactionTopicPartitionCount为主题__transaction_state中的分区个数,可以通过broker端参数transaction.state.log.num.partitions来配置,默认值为50。
  • 找到对应的分区之后,再寻找此分区leader副本所在的broker节点该broker节点即为这个transactionalId对应的TransactionCoordinator节点。细心的读者可以发现,这一整套的逻辑和查找GroupCoordinator的逻辑如出一辙。

2、获取PID

  • 在找到TransactionCoordinator节点之后,就需要为当前生产者分配一个PID了。凡是开启了幕等性功能的生产者都必须执行这个操作,不需要考虑该生产者是否还开启了事务。生产者获取PID的操作是通过InitProducerIdRequest请求来实现的。

保存PID

  • 生产者的InitProducerIdRequest请求会被发送给TransactionCoordinator。注意,如果未开启事务特性而只开启幕等特性,那么InitProducerIdRequest请求可以发送给任意的broker。当TransactionCoordinator第一次收到包含该transactionalId的InitProducerIdRequest请求时,它会把transactionalId和对应的PID以消息(我们习惯性地把这类消息称为“事务日志消息”)的形式保存到主题transaction_state中,如图7-21步骤2.1所示。这样可以保证<transaction_Id,PID>的对应关系被持久化,从而保证即使TransactionCoordinator启机该对应关系也不会丢失。

3、开启事务

  • 通过KafkaProducer的beginTransaction()方法可以开启一个事务,调用该方法后,生产者本地会标记已经开启了一个新的事务,只有在生产者发送第一条消息之后TransactionCoordinator才会认为该事务已经开启。

4、Consume-Transform-Produce

  • 这个阶段囊括了整个事务的数据处理过程,其中还涉及多种请求。

1)AddPartitionsToTxnRequest

  • 当生产者给一个新的分区(TopicPartition)发送数据前,它需要先向TransactionCoordinator发送AddPartitionsToTxnRequest请求,这个请求会让TransactionCoordinator将<transactionId, TopicPartition>的对应关系存储在主题__transaction_state中,如图7-21步骤4.1所示。有了这个对照关系之后,我们就可以在后续的步骤中为每个分区设置COMMIT或ABORT标记,如图7-21步骤5.2所示。
  • 如果该分区是对应事务中的第一个分区,那么此时TransactionCoordinator还会启动对该事务的计时。

2)ProduceRequest

  • 生产者通过ProduceRequest请求发送消息(ProducerBatch)到用户自定义主题中,这一点和发送普通消息时相同,如图7-21步骤4.2所示。和普通的消息不同的是,ProducerBatch中会包含实质的PID,producer_epoch和sequencenumber。

3)AddOffsetsToTxnRequest

  • 通过KafkaProducer的sendOffsetsToTransaction()可以在一个事务批次里处理消息的消费和发送,方法中包含2个参数:Map<TopicPartition, OffsetAndMetadata>offsets和groupId。这个方法会向TransactionCoordinator节点发送AddOffsetsToTxnRequest请求,TransactionCoordinator收到这个请求之后会通过groupId来推导出在consumer_offsets中的分区,之后TransactionCoordinator会将这个分区保存在transaction_state中,如图7-21步骤4.3所示。

4)TxnOffsetCommitRequcst

  • 这个请求也是sendOffsetsToTransaction()方法中的一部分,在处理完AddOffsetsToTxnRequest之后,生产者还会发送TxnOffsetCommitRequest请求给GroupCoordinator,从而将本次事务中包含的消费位移信息offsets存储到主题__consumer_offsets中,如图7-21步骤4.4所示。

5、提交或者中止事务

  • 一旦数据被写入成功,我们就可以调用KafkaProducer的commitTransaction()方法或abortTransaction()方法来结束当前的事务。

1)EndTxnRequest

  • 无论调用commitTransaction()方法还是abortTransaction()方法,生产者都会向TransactionCoordinator送EndTxnRequest请求,以此来通知它提交(Commit)事务还是中止(Abort)事务。
  • TransactionCoordinator在收到EndTxnRequest请求后会执行如下操作:
    • (1)将PREPARE_COMMIT或PREPARE_ABORT消息写入主transactionstate,如图7-21步骤5.1所示。
    • (2)通过WriteTxnMarkersRequest请求将COMMIT或ABORT信息写入用户所使用的普通主题和__consumer_offsets,如图7-21步骤5.2所示。
    • (3)将COMPLETE_COMMIT或COMPLETE_ABORT信息写入内部主题__transaction_state,如图7-21步骤5.3所示。

2)WritcTxnMarkersRcqucst

  • WriteTxnMarkersRequest请求是由TransactionCoordinator发向事务中各个分区的leader节点的,当节点收到这个请求之后,会在相应的分区中写入控制消息(ControlBatch)。控制消息用来标识事务的终结,它和普通的消息一样存储在日志文件中。

3)写入最终的COMPLETE_COMMIT或COMPLETE_ABORT

  • TransactionCoordinator将最终的COMPLETE_COMMIT或COMPLETE_ABORT信息写入主题__transaction_state以表明当前事务已经结束,此时可以删除主题__trahsaction_state中所有关于该事务的消息。由于主题__transaction_state采用的日志清理策略为日志压缩,所以这里的删除只需将相应的消息设置为墓碑消息即可。
#                                                                                                                        #
posted @ 2022-02-21 09:53  麦恒  阅读(52)  评论(0编辑  收藏  举报