深入Kafka客户端

1、分区分配策略

Kafka提供了消费者客户端参数 partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为org.apache.kafka.clients.consumer.RangeAssignor,即采用 RangeAssignor分配策略。除此之外,Kafka还提供了另外两种分配策略: RoundRobinAssignor和 StickyAssignor。消费者客户端参数 partition.assignment.strategy可以配置多个分配策略,彼此之间以逗号分隔。

1.1、RangeAssignor分配策略

RangeAssignor分配策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。对于每一个主题, RangeAssignor策略会将消费组内所有订阅这个主题的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。

假设n=分区数/消费者数量,m=分区数%消费者数量,那么前m个消费者每个分配n+1个分区,后面的(消费者数量-m)个消费者每个分配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个主题都只有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:

消费者C0:t0p0、t0p1、t1p0、t1p1
消费者C1:t0p2、t1p2
可以明显地看到这样的分配并不均匀,如果将类似的情形扩大,则有可能出现部分消费者过载的情况。对此我们再来看另一种 RoundRobinAssignor策略的分配效果如何。

1.2、RoundRobinAssignor分配策略

RoundrobinAssignor分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者。 RoundRobinAssignor分配策略对应的 partition.assignment.strategy参数值为 org.apache.kafka.clients.consumer.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分配策略

我们再来看一下 StickyAssignor分配策略,“sticky”这个单词可以翻译为“黏性的”, Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:
(1)分区的分配要尽可能均匀。
(2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标, StickyAssignor分配策略的具体实现要比 RangeAssignor和 RoundrobinAssignor这两种分配策略要复杂得多。我们举例来看一下 Sticky Assignor分配策略的实际效果。
假设消费组内有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:top0、t1p0、t2p0、t3p0
消费者C2:t0p1、t1p1、t2p1、t3p1
如分配结果所示, RoundRobinAssignor分配策略会按照消费者C0和C2进行重新轮询分配。如果此时使用的是 StickyAssignor分配策略,那么分配结果为:
消费者C0:top0、t1p1、t3p0、t2p0
消费者C2:t1p0、t2p1、t0p1、t3p1
可以看到分配结果中保留了上一次分配中对消费者C0和C2的所有分配结果,并将原来消费者C1的“负担”分配给了剩余的两个消费者C0和C2,最终C0和C2的分配还保持了均衡。
如果发生分区重分配,那么对于同一个分区而言,有可能之前的消费者和新指派的消费者不是同一个,之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。 StickyAssignor分配策略如同其名称中的“sticky”一样,让分配策略具备一定的“黏性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗及其他异常情况的发生。
到目前为止,我们分析的都是消费者的订阅信息都是相同的情况,我们来看一下订阅信息不同的情况下的处理。
举个例子,同样消费组内有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分配策略,那么最终的分配结果如分配清单7-1所示(和讲述 RoundRobin Assignor分配策略时的一样,这样不妨赘述一下):

分配清单7-1 RoundRobinAssignor分配策略的分配结果
消费者C0:t0p0
消费者C1:t1p0
消费者C2:t1p1、t2p0、t2p1、t2p2

如果此时采用的是 StickyAssignor分配策略,那么最终的分配结果如分配清单7-2所示。

分配清单7-2 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
可以看到 RoundRobinAssignor策略保留了消费者C1和C2中原有的3个分区的分配:t2p0、t2p1和t2p2(针对分配清单7-1)。如果采用的是 StickyAssignor分配策略,那么分配结果为:
消费者C1:t1p0、t1p1、t0p0
消费者C2:t2p0、t2p1、t2p2
可以看到 StickyAssignor分配策略保留了消费者C1和C2中原有的5个分区的分配:t1p0、t1p1、t2p0、t2p1、t2p2。
对 ConsumerRebalanceListener而言, StickyAssignor分配策略可以提供一定程度上的优化:

如前所述,使用 StickyAssignor分配策略的一个优点就是可以使分区重分配具备“黏性”,减少不必要的分区移动(即一个分区剥离之前的消费者,转而分配给另一个新的消费者)。

1.4、自定义分区分配策略

自定义的分配策略必须要实现 org.apache.kafka.clients.consumer.internals.PartitionAssignor接口。 PartitionAssignor接口的定义如下:

public interface PartitionAssignor {
    Subscription subscription(Set<String> topics);

    Map<String, Assignment> assign(Cluster metadata, Map<String, Subscription> subscriptions);

    void onAssignment(Assignment assignment);

    default void onAssignment(Assignment assignment, int generation) {
        onAssignment(assignment);
    }

    String name();

    class Subscription {
        private final List<String> topics;
        private final ByteBuffer userData;
    }

    class Assignment {
        private final List<TopicPartition> partitions;
        private final ByteBuffer userData;
    }
}

PartitionAssignor接口中定义了两个内部类: Subscription和 Assignment。
Subscription类用来表示消费者的订阅信息,类中有两个属性: topics和 userData,分别表示消费者的订阅主题列表和用户自定义信息。 PartitionAssignor接口通过 subscription方法来设置消费者自身相关的 Subscription信息,注意到此方法中只有一个参数 topics,与Subscription类中的 topics的相呼应,但并没有体现有关 userData的参数。为了增强用户对分配结果的控制,可以在 subscription()方法内部添加一些影响分配的用户自定义信息赋予userData,比如权重、IP地址、host或机架(rack)等。

举个例子,在 subscription方法中提供机架信息,标识此消费者所部署的机架位置,在分区分配时可以根据分区的 leader副本所在的机架位置来实施具体的分配,这样可以让消费者与所需拉取消息的 broker节点处于同一机架。参考图7-1,消费者 consumer1和 broker1都部署在机架rack1上,消费者 consumer2和 broker2都部署在机架rack2上。如果分区的分配不是机架感知的,那么有可能与图7-1(上半部分)中的分配结果一样, consumer1消费 broker2中的分区,而 consumer2消费 broker1中的分区;如果分区的分配是机架感知的,那么就会出现图7-1(下半部分)的分配结果, consumer1消费 broker1中的分区,而 consumer2消费 broker2中的分区,这样相比前一种情形,既可以减少消费延时,又可以减少跨机架带宽的占用。

再来说一下 assignment类,它用来表示分配结果信息,类中也有两个属性: partitions和 userData,分别表示所分配到的分区集合和用户自定义的数据。 PartitionAssignor接口中的onAssignment()方法是在每个消费者收到消费组 leader分配结果时的回调函数,例如在
StickyAssignor分配策略中就是通过这个方法保存当前的分配方案,以备在下次消费组再均衡(rebalance)时可以提供分配参考依据。

接口中的 name()方法用来提供分配策略的名称,对 Kafka提供的3种分配策略而言,RangeAssignor对应的 protocol name为“range”, RoundRobinAssignor对应的 protocol name为“roundrobin”, StickyAssignor对应的 protocol name为“sticky”,所以自定义的分配策略
中要注意命名的时候不要与已存在的分配策略发生冲突。这个命名用来标识分配策略的名称,在后面所描述的加入消费组及选举消费组 leader的时候会有涉及。

真正的分区分配方案的实现是在 assign方法中,方法中的参数 metadata表示集群的元数据信息,而 subscriptions表示消费组内各个消费者成员的订阅信息,最终方法返回各个消费者的分配信息。Kafka还提供了一个抽象类 org.apache.kafka.clients.consumer.internals. AbstractPartitionAssignor,它可以简化实现 PartitionAssignor接口的工作,并对assign()方法进行了详细实现,其中会将Subscription中的 userData信息去掉后再进行分配。 Kafka提供的3种分配策略都继承自这个抽象类。如果开发人员在自定义分区分配策略时需要使用 userData信息来控制分区分配的结果,那么就不能直接继承 AbstractPartitionAssignor这个抽象类,而需要直接实现 PartitionAssignor接口。

下面笔者参考 Kafka的 RangeAssignor分配策略来自定义一个随机的分配策略,这里笔者称之为 RandomAssignor,具体代码实现如下。

在使用时,消费者客户端需要添加相应的 Properties参数,示例如下:
properties.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,RandomAssignor.class.getName());

这里只是演示如何自定义实现一个分区分配策略, RandomAssignor的实现并不是特别理想,并不见得会比Kafka自身提供的 RangeAssignor之类的策略要好。

按照Kafka默认的消费逻辑设定,一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费。但这一设定不是绝对的,我们可以通过自定义分区分配策略使一个分区可以分配给多个消费者消费。
考虑一种极端情况,同一消费组内的任意消费者都可以消费订阅主题的所有分区,从而实现了一种“组内广播(消费)”的功能。针对7个分区和3个消费者的情形,如果采用组内广播的分配策略,那么就会变成图7-2中的这种分配结果。

下面展示了组内广播分配策略的具体代码实现

:

注意组内广播的这种实现方式会有一个严重的问题—默认的消费位移的提交会失效。所有的消费者都会提交它自身的消费位移到 consumer offsets中,后提交的消费位移会覆盖前面提交的消费位移。

假设消费者 consumer1提交了分区tp0的消费位移为10,这时消费者 consumer2紧接着提交了同一分区tp0的消费位移为12,如果此时消费者 consumer1由于某些原因重启了,那么consumer1就会从位移12之后重新开始消费,这样 consumer1就丢失了部分消息。

再考虑另一种情况,同样消费者 consumer1提交了分区p0的消费位移为10,这时消费者consumer2紧接着提交了同一分区的消费位移为8,如果此时消费者 consumer1由于某些原因重启了,那么 consumer1就会从位移8之后重新开始消费,这样 consumer1就重复消费了消息。很多情形下,重复消费少量消息对于上层业务应用来说可以忍受。但是设想这样一种情况,消费组内的消费者对于分区tp0的消费位移都在10000后了,此时又有一个新的消费者consumer3加入进来,消费了部分消息之后提交了tp0的消费位移为9,那么此时原消费组内的任何消费者重启都会从这个消费位移9之后再开始重新消费,这样大量的重复消息会让上层业务应用猝不及防,同样会造成计算资源的浪费。

针对上述这种情况,如果要真正实现组内广播,则需要自己保存每个消费者的消费位移。笔者的实践经验是,可以通过将消费位移保存到本地文件或数据库中等方法来实现组内广播的位移提交。

虽然说可以通过自定义分区分配策略来打破 Kafka中“一个分区只能被同一个消费组内的一个消费者消费”的禁忌(参考图7-3中的消费者C0和C1),但想要通过自定义分区分配策略来实现图7-3中的消费者C3和C4共同分享单个分区的消息是不现实的。更加通俗一点来说图7-3中的消费者C3和C4都处于正常稳定的状态,此时它们想要共同分享分区3中的消息,即C3消费0、1、2这3条消息,而C4消费3、4这2条消息,紧接着C3再消费5、6、7这3条消息,这种分配是无法实现的。不过这种诉求可以配合 KafkaConsumer中的seek方法来实现,实际应用价值不大。

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

了解了 Kafka中消费者的分区分配策略之后是否会有这样的疑问:如果消费者客户端中配置了两个分配策略,那么以哪个为准呢?如果有多个消费者,彼此所配置的分配策略并不完全相同,那么以哪个为准?多个消费者之间的分区分配是需要协同的,那么这个协同的过程又是怎样的呢?这一切都是交由消费者协调器(ConsumerCoordinator)和组协调器(GroupCoordinator)来完成的,它们之间使用一套组协调协议进行交互。

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

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

每个消费组(<group>)在 ZooKeeper中都维护了一个/consumers/<group>/ids路径,在此路径下使用临时节点记录隶属于此消费组的消费者的唯一标识(consumerldString)consumerldString由消费者启动时创建。消费者的唯一标识由 consumer id+主机名+时间戳+UUID的部分信息构成,其中 consumer.id是旧版消费者客户端中的配置,相当于新版客户端中的client.id。比如某个消费者的唯一标识为 consumerld_localhost-1510734527562-64b3775,那么其中 consumed为指定的 consumer.id, localhost为计算机的主机名 ,1510734527562代表时间戳,而64b37765表示UUID的部分信息。

参考图7-4,与/consumers/<group>/ids同级的还有两个节点: owners和 offsets,/consumers/<group>/owner路径下记录了分区和消费者的对应关系,/consumers/<group>/offsets路径下记录了此消费组在分区中对应的消费位移。

每个 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请求)。比如客户端调用了unsubscribed方法取消对某些主题的订阅、
  • 消费组所对应的 GroupCoordinator节点发生了变更。
  • 消费组内所订阅的任一主题或者主题的分区数量发生变化。

下面就以一个简单的例子来讲解一下再均衡操作的具体内容。当有消费者加入消费组时,消费者、消费组及组协调器之间会经历一下几个阶段

第一阶段(FIND_COORDINATOR)

消费者需要确定它所属的消费组对应的 GroupCoordinator所在的 broker,并创建与该 broker相互通信的网络连接。如果消费者已经保存了与消费组对应的 GroupCoordinator节点的信息并且与它之间的网络连接是正常的,那么就可以进入第二阶段。否则,就需要向集群中的某个节点发送 FindCoordinatorRequest请求来查找对应的 GroupCoordinator,这里的“某个节点”并非是集群中的任意节点,而是负载最小的节点,即2.2.2节中的 leastLoadedNode。

如图7-5所示, FindCoordinatorRequest请求体中只有两个域(Field): coordinator_key和 coordinator_type。 coordinator_key在这里就是消费组的名称,即 groupId,coordinator_type置为0。这个 FindCoordinatorRequest请求还会在Kafka事务中提及,为了便于说明问题,这里我们暂且忽略它。

Kafka在收到 FindCoordinatorRequest请求之后,会根据 coordinator_key(也就是groupId)查找对应的 GroupCoordinator节点,如果找到对应的 GroupCoordinator则会返回其相对应的 node_id、host和port信息。

具体査找 GroupCoordinator的方式是先根据消费组 groupId的哈希值计算__consumer_offsets中的分区编号,具体算法如代码清单7-1所示。

代码清单7-1消费组所对应的分区号的计算方式
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的角色,又扮演保存分区分配方案和组内消费者位移的角色,这样可以省去很多不必要的中间轮转所带来的开销。

第二阶段(JOIN_GROUP)

在成功找到消费组所对应的 GroupCoordinator之后就进入加入消费组的阶段,在此阶段的消费者会向 GroupCoordinator发送JoinGroupRequest请求,并处理响应。

如图7-6所示, JoinGroupRequest的结构包含多个域:

  • group_id就是消费组的id,通常也表示为 groupId。
  • session_timout对应消费端参数 session.timeout.ms,默认值为10000,即10秒。 GroupCoordinator超过 session_timeout指定的时间内没有收到心跳报文则认为此消费者已经下线。
  • rebalance_timeout对应消费端参数max.poll.interva.ms,默认值为300000即5分钟。表示当消费组再平衡的时候, GroupCoordinator等待各个消费者重新加入的最长等待时间。
  • member_id表示 GroupCoordinator分配给消费者的id标识。消费者第一次发送JoinGroupRequest请求的时候此字段设置为nul。
  • protocol_type表示消费组实现的协议,对于消费者而言此字段值为“consumer”。

JoinGroupRequest中的 group_protocols域为数组类型,其中可以囊括多个分区分配策略,这个主要取决于消费者客户端参数 partition.assignment.strategy的配置。如果配置了多种策略,那么 JoinGroupRequest中就会包含多个 protocol_name和 protocol_meta。其中 protocol_name对应于 PartitionAssignor接口中的 name()方法,我们在讲述消费者分区分配策略的时候提及过相关内容(参考7.14节)。而 protocol_metadata和PartitionAssignor接口中的 subscription方法有直接关系, protocol_metadata是一个 bytes类型,其实质上还可以更细粒度地划分为 version、 topics和 user_data,如图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请求后会交由 GroupCoordinator来进行处理。 GroupCoordinator首先会对JoinGroupRequest请求做合法性校验,比如 group_id是否为空、当前 broker节点是否是请求的消费者组所对应的组协调器、 rebalance_timeout的值是否在合理的范围之内。如果消费者是第一次请求加入消费组,那么JoinGroupRequest请求中的 member_id值为null,即没有它自身的唯一标志,此时组协调器负责为此消费者生成一个 member_id。这个生成的算法很简单,具体如以下伪代码所示。
String memberId = clientId + "-" + UUID.randomUUID().tostring();
其中 clientId为消费者客户端的 clientId,对应请求头中的 client_id。由此可见消费者的member_id由 clientId和UUID用“-”字符拼接而成。

选举消费组的 leader

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

解释一下这2行代码:在 GroupCoordinator中消费者的信息是以 HashMap的形式存储的,其中key为消费者的 member_id,而 value是消费者相关的元数据信息。 leaderld表示 leader消费者的 member_id,它的取值为 HashMap中的第一个键值对的key,这种选举的方式基本上和随机无异。总体上来说,消费组的 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。

      

第三阶段(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所示。

与 JoinGroupRequest请求中的 protocol_metadata类似,都可以细分为3个更具体的字段,只不过 protocol_metadata存储的是主题的列表信息,而 member_assignment存储的是分区信息, member_assignment中可以包含多个主题的多个分区信息。

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

这里所说的响应就是指 SyncGroupRequest请求对应的 SyncGroupResponse, SyncGroupResponse的内容很简单,里面包含的就是消费者对应的所属分配方案, SyncGroupResponse的结构如图7-14所示,具体字段的释义可以从前面的内容中推测出来,这里就不赘述了。

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

消费组元数据信息

我们知道消费者客户端提交的消费位移会保存在 Kafka的 __consumer_offsets主题中,这里也一样,只不过保存的是消费组的元数据信息(GroupMetadata)。具体来说,每个消费组的元数据信息都是一条消息,不过这类消息并不依赖于具体版本的消息格式,因为它只定义了消息中的key和vaue字段的具体内容,所以消费组元数据信息的保存可以做到与具体的消息格式无关。

图7-15中对应的就是消费组元数据信息的具体内容格式,上面是消息的key,下面是消息的 value。可以看到key和 value中都包含 version字段,用来标识具体的key和 value的版本信息,不同的版本对应的内容格式可能并不相同,就目前版本而言,key的 version为2,而 value的 version为1,读者在理解时其实可以忽略这个字段而探究其他具备特定含义的内容。key中除了 version就是 group字段,它表示消费组的名称,和 JoinGroupRequest或SyncGroupRequest请求中的 group_id是同一个东西。虽然key中包含了 version字段,但确定这条信息所要存储的分区还是根据单独的 group字段来计算的,这样就可以保证消费组的元数据信息与消费组对应的 GroupCoordinator处于同一个 broker节点上,省去了中间轮转的开销。

value中包含的内容有很多,可以参照和 JoinGroupRequest i或 SyncGroupRequest请求中的内容来理解,具体各个字段的释义如下。

  • protocol_type:消费组实现的协议,这里的值为“consumer”。
  • generation:标识当前消费组的年代信息,避免收到过期请求的影响。
  • protocol:消费组选取的分区分配策略。
  • leader:消费组的 leader消费者的名称。
  • members:数组类型,其中包含了消费组的各个消费者成员信息,图7-15中右边部分就是消费者成员的具体信息,每个具体字段都比较容易辨别,需要着重说明的是subscription和 assignment这两个字段,分别代码消费者的订阅信息和分配信息。

第四阶段(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.max.session.timeout.ms(默认值为30000,即5分钟)允许的范围内。

还有一个参数max.poll.interval.ms,它用来指定使用消费者组管理时poll()方法调用之间的最大延迟,也就是消费者在获取更多消息之前可以空闲的时间量的上限。如果此超时时间期满之前poll()没有调用,则消费者被视为失败,并且分组将重新平衡,以便将分区重新分配给别的成员。

除了被动退出消费组,还可以使用 LeaveGroupRequest请求主动退出消费组,比如客户端调用了 unsubscribe()方法取消对某些主题的订阅,这个比较简单,这里就不再赘述了。

3、__consumer_offsets剖析

位移提交是使用消费者客户端过程中一个比较“讲究”的操作,3.2.5节也使用了较大的篇幅来介绍它。位移提交的内容最终会保存到Kafka的内部主题 __consumer_offsets中,对于主题__consumer_offsets的深度掌握也可以让我们更好地理解和使用好位移提交。

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

如果已经掌握了6.1节和72节的内容,那么就很容易理解 OffsetCommitRequest的结构请求体第一层中的 group_id、 generation_id和 member_id在前面的内容中已经介绍过多次了, retention_time表示当前提交的消费位移所能保留的时长,不过对于消费者而言
这个值保持为-1。也就是说,按照 broker端的配置 offsets.retention.minutes来确定保留时长。 offsets.retention.minutes的默认值为10080,即7天,超过这个时间后消费位移的信息就会被删除(使用墓碑消息和日志压缩策略)。注意这个参数在2.0.0版本之前的默认值为1440,即1天,很多关于消费位移的异常也是由这个参数的值配置不当造成的。有些定时消费的任务在执行完某次消费任务之后保存了消费位移,之后隔了一段时间再次执行消费任务,如果这个间隔时间超过 offsets.retention.minutes的配置值,那么原先的位移信息就会丢失,最后只能根据客户端参数auto.offset.reset来决定开始消费的位置,遇到这种情况时就需要根据实际情况来调配 offsets.retention.minutes参数的值。

OffsetCommitRequest中的其余字段大抵也是按照分区的粒度来划分消费位移的: topic表示主题名称, partition表示分区编号等。注意这里还有一个 metadata字段。在3.2.5节中讲到手动位移提交时提到了可以通过Map<TopicPartition, OffsetAndMetadata> offsets参数来指定要提交的分区位移,相关使用方法可以参考代码清单3-3和代码清单3-4。 OffsetAndMetadata中包含2个成员变量(offset和metadata),与此对应的有两个构造方法,详细如下
public OffsetAndMetadata(long offset)
public OffsetAndMetadata(long offset, String metadata)

代码清单3-3和代码清单3-4等示例都只用到了第一种构造方法而忽略了 metadata。metadata是自定义的元数据信息,如果不指定这个参数,那么就会被设置为空字符串,注意metadata的长度不能超过 offset.metadata.max.bytes参数(broker端配置,默认值为4096)所配置的大小。

同消费组的元数据信息一样,最终提交的消费位移也会以消息的形式发送至主题__consumer_offsets,与消费位移对应的消息也只定义了key和 value字段的具体内容,它不依赖于具体版本的消息格式,以此做到与具体的消息格式无关。

图7-17中展示了消费位移对应的消息内容格式,上面是消息的key,下面是消息的 value可以看到key和vale中都包含了 version字段,这个用来标识具体的key和 value的版本信息,不同的版本对应的内容格式可能并不相同。就目前版本而言,key和 value的 version值都为1。key中除了verslon字段还有 group、 topic、 partition字段,分别表示消费组的 groupId、主题名称和分区编号。虽然key中包含了4个字段,但最终确定这条消息所要存储的分区还是根据单独的 group字段来计算的,这样就可以保证消费位移信息与消费组对应的GroupCoordinator处于同一个 broker节点上,省去了中间轮转的开销,这一点与消费组的元数据信息的存储是一样的。

value中包含了5个字段,除 version字段外,其余的 offset、 metadata、 commit_timestamp、 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所示。 OffsetCommitResponse中各个域的具体含义可以通过前面内容中推断出来,这里就不再赘述了。

我们可以通过 kafka-console-consumer.sh脚本来查看 __consumer_offsets中的内容,不过要设定 formatter参数为 kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter。
假设我们要査看消费组“consumerGroupld”的位移提交信息,首先可以根据代码清单7-1中的计算方式得出分区编号为20,然后查看这个分区中的消息,相关示例如下:

般情况下,使用 OffsetsMessageFormatter打印的格式可以概括为

这里面几个字段的含义可以参照图7-17来理解。这里需要说明的是,如果某个key(version+ group+ topic+ partition的组合)对应的消费位移过期了,那么对应的vaue就会被设置为null也就是墓碑消息(主题 __consumer_offsets使用的是日志压缩策略),对应的打印结果也会变成如下的形式:


有时候在查看主题 consumer offsets中的内容时有可能出现下面这种情况:

这说明对应的消费位移已经过期了。在Kafka中有一个名为“delete-expired-group-metadata”的定时任务来负责清理过期的消费位移,这个定时任务的执行周期由参数 offsets.retention.check.interval.ms控制,默认值为60000即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,否则会报出ConfigException:
org.apache.kafka.common.config.ConfigException: Must set retries to non-zero when using the idempotent producer

如果用户还显式地指定了acks参数,那么还需要保证这个参数的值为-1(all),如果不为-1(这个参数的值默认为1),那么也会报出 ConfigException:
org.apache.kafka.common.config.ConfigException: Must set acks to all in order to use the i dempotent producer. otherwise we cannot guarantee idempotence
如果用户没有显式地指定这个参数,那么 KafkaProducer会将它置为-1。开启幂等性功能之后,生产者就可以如同未开启幂等时一样发送消息了。

为了实现生产者的幂等性, Kafka为此引入了 producer id(以下简称PID)和序列号(sequence number)这两个概念,这两个概念其实在5.2.5节中就讲过,分别对应v2版的日志格式中RecordBatch的 producer id和 first regence这两个字段(参考图5-7)。每个新的生产者实例在初始化的时候都会被分配一个PID,这个PID对用户而言是完全透明的。对于每个PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。生产者每发送条消息就会将<PID,分区>对应的序列号的值加1。

broker端会在内存中为每一对<PID,分区>维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比 broker端中维护的对应的序列号的值(SN_old)大1(即 SN_new = SN_old+1)时, broker才会接收它。如果 SN_new< SN_old+1,那么说明消息被重复写入,broker可以直接将其丢弃。如果 SN_new> SN_old+1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出 OutOfOrderSequenceException,这个异常是个严重的异常,后续的诸如 send()、beginTransaction()、commitTransaction()等方法的调用都会抛出 IllegalStateException的异常。引入序列号来实现幂等也只是针对每一对<PID,分区>而言的,也就是说,Kafka的幂等只能保证单个生产者会话(session)中单分区的幂等。
ProducerRecord<String, String> record =new ProducerRecord<>(topic, "key","msg");
producer. send(record);

producer. send(record);
注意,上面示例中发送了两条相同的消息,不过这仅仅是指消息内容相同,但对 Kafka而言是两条不同的消息,因为会为这两条消息分配不同的序列号。 Kafka并不会保证消息内容的幂等。

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开启两个生产者,那么前一个开启的生产者会报出如下的错误:

producer epoch同PID和序列号一样在5.2.5节中就讲过了,对应v2版的日志格式中RecordBatch的 producer epoch字段(参考图5-7)。从生产者的角度分析,通过事务, Kafka可以保证跨生产者会话的消息幂等发送,以及跨生产者会话的事务恢复。前者表示具有相同 transactional的新生产者实例被创建且工作的时候,旧的且拥有相同 transactional的生产者实例将不再工作。后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交(Commit),要么被中止(Abort),如此可以使新的生产者实例从一个正常的状态开始工作。

而从消费者的角度分析,事务能保证的语义相对偏弱。出于以下原因, Kafka并不能保证已提交的事务中的所有消息都能够被消费:

  • 对釆用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前面写入的消息)。
  • 事务中消息可能分布在同一个分区的多个日志分段(LogSegment)中,当老的日志分段被删除时,对应的消息可能会丢失。
  • 消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
  • 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所有消息。

KafkaProducer提供了5个与事务相关的方法,详细如下

initTransactions()方法用来初始化事务,这个方法能够执行的前提是配置了transactionalId,如果没有则会报出IllegalStateException:
java.lang.IllegalStateException: Cannot use transactional methods without enabling transactions by setting the transactionalid configuration property

beginTransaction()方法用来开启事务; sendOffsetsToTransaction0方法为消费者提供在事务内的位移提交的操作; commitTransaction()方法用来提交事务; abortTransaction()方法用来中止事务,类似于事务回滚。
一个典型的事务消息发送的操作如代码清单7-2所示。

在消费端有一个参数isolation.level,与事务有着莫大的关联,这个参数的默认值为“read_uncommitted”,意思是说消费端应用可以看到(消费到)未提交的事务,当然对于已提交的事务也是可见的。这个参数还可以设置为“read_committed”,表示消费端应用不可以看到尚未提交的事务内的消息。举个例子,如果生产者开启事务并向某个分区值发送3条消息msg1、msg2和msg3,在执行commitTransaction()或 abortTransaction()方法前,设置为“read_committed"的消费端应用是消费不到这些消息的,不过在 Kafka Consumer内部会缓存这些消息,直到生产者执行 commitTransaction()方法之后它才能将这些消息推送给消费端应用。反之,如果生产者执行了 abortTransaction()方法,那么 KafkaConsumer会将这些缓存的消息丢弃而不推送给消费端应用。日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(ControlBatch)。控制消息一共有两种类型: COMMIT和ABORT,分别用来表征事务已经成功提交或已经被成功中止。 KafkaConsumer可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数 isolation.level配置的隔离级别来决定是否将相应的消息返回给消费端应用,如图7-19所示。注意 ControlBatch对消费端应用不可见,后面还会对它有更加详细的介绍。

        

本节开头就提及了 consume-transform-produce这种应用模式,这里还涉及在代码清单7-2中尚未使用的 sendOffsetsToTransaction方法。该模式的具体结构如图7-20所示。与此对应的应用示例如代码清单7-3所示。

注意:在使用 KafkaConsumer的时候要将 enable.auto.commit参数设置为 false,代码里也不能手动提交消费位移。
为了实现事务的功能, Kafka还引入了事务协调器(TransactionCoordinator)来负责处理事务,这一点可以类比一下组协调器(GroupCoordinator)。每一个生产者都会被指派一个特定的TransactionCoordinator,所有的事务逻辑包括分派PID等都是由TransactionCoordinator来负责实施的。 TransactionCoordinator会将事务状态持久化到内部主题 __transaction_state中。下面就以最复杂的 consume-transform-produce的流程(参考图7-21)为例来分析Kafka事务的实现原理。

1、查找TransactionCoordinator

TransactionCoordinator负责分配PID和管理事务,因此生产者要做的第一件事情就是找出对应的TransactionCoordinator所在的 broker节点。与查找 GroupCoordinator节点一样,也是通过 FindCoordinatorRequest请求来实现的,只不过 FindCoordinatorRequest中的 coordinator_type就由原来的0变成了1,由此来表示与事务相关联(FindCoordinatorRequest请求的具体结构参考图7-5)。

Kafka在收到 FindCoorinatorRequest请求之后,会根据 coordinator_key(也就是transactionalId)查找对应的TransactionCoordinator节点。如果找到,则会返回其相对应的 node_id、host和port信息。具体查找 TransactionCoordinator的方式是根据 transactionalId的哈希值计算主题 __transaction_state中的分区编号,具体算法如代码清单7-4所示。

代码清单7-4计算分区编号
Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount

其中 transactionTopicPartitioncount为主题 __transaction_state中的分区个数,这个可以通过 broker端参数 transaction.state.log.num.partitions来配置,默认值为50。

找到对应的分区之后,再寻找此分区 leader副本所在的 broker节点,该 broker节点即为这个 transactionalId对应的 TransactionCoordinator节点。细心的读者可以发现,这一整套的逻辑和查找 GroupCoordinator的逻辑如出一辙(参考7.2.2节)。

2、获取PID

在找到 TransactionCoordinator节点之后,就需要为当前生产者分配一个PID了。凡是开启了幂等性功能的生产者都必须执行这个操作,不需要考虑该生产者是否还开启了事务。生产者获取PID的操作是通过 InitProducerldRequest请求来实现的, InitProducerldRequest请求体结构如图7-22所示,其中 transactional_id表示事务的 transactionalId, transaction_timeout_ms表示 TransactionCoordinaor等待事务状态更新的超时时间,通过生产者客户端参数 transaction.timeout.ms配置,默认值为60000。

保存PID

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

其中 transaction_status包含 Empty(0)、 Ongoing(1)、 PrepareCommit(2)、PrepareAbort(3)、CompleteCommit4)、CompleteAbort(5)、Dead(6)这几种状态。在存入主题__transaction_state之前,事务日志消息同样会根据单独的 transactionalId来计算要发送的分区,算法同代码清单7-4一样。

与 InitProducerldRequest对应的InitProducerldResponse响应体结构如图7-24所示,除了返回PID, InitProducerldRequest还会触发执行以下任务:

  • 增加该PID对应的 producer_epoch。具有相同PID但 producer_epoch小于该producer_epoch的其他生产者新开启的事务将被拒绝。
  • 恢复(Commit)或中止(Abort)之前的生产者未完成的事务。

3、开启事务

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

4、Consume-Transform-Produce

这个阶段囊括了整个事务的数据处理过程,其中还涉及多种请求。注:如果没有给出具体的请求体或响应体结构,则说明其并不影响读者对内容的理解,笔者为了缩减篇幅而将其省略。

1) AddPartitionsToTxnRequest

当生产者给一个新的分区( TopicPartition)发送数据前,它需要先向 TransactionCoordinator发送 AddPartitionsToTxnRequest请求( AddPartitionsToTxnRequest请求体结构如图725所示),这个请求会让 TransactionCoordinator将<transactionld, TopicPartition>的对应关系存储在主题__transaction_state中,如图7-21步骤4.1所示。有了这个对照关系之后,我们就可以在后续的步骤中为每个分区设置 COMMIT或 ABORT标记,如图7-21步骤5.2所示。

如果该分区是对应事务中的第一个分区,那么此时 TransactionCoordinator还会启动对该事务的计时。

2) ProduceReques

这一步骤很容易理解,生产者通过 ProduceRequest请求发送消息(ProducerBatch)到用户自定义主题中,这一点和发送普通消息时相同,如图7-21步骤4.2所示。和普通的消息不同的是, ProducerBatch中会包含实质的PID、 producer_epoch和 sequence number,可以对照5.2.5节的内容。

3) AddOffsetsToTxnRequest

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

4)TxnOffsetCommitRequest

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

5、提交或者中止事务

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

1) EndTxnRequest

无论调用 commitTransaction()方法还是 abortTransaction()方法,生产者都会向TransactionCoordinator发送 EndTxnRequest请求(对应的EndTxnRequest请求体结构如图7-27所示),以此来通知它提交(Commit)事务还是中止(Abort)事务。

TransactionCoordinator在收到 EndTxnRequest请求后会执行如下操作:
(1)将 PREPARE_COMMIT或 PREPARE_ABORT消息写入主题 __transaction_state,如图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) WriteTxnMarkersRequest

WriteTxnMarkersRequest请求是由 TransactionCoordinator发向事务中各个分区的 leader节点的,当节点收到这个请求之后,会在相应的分区中写入控制消息(ControlBatch)。控制消息用来标识事务的终结,它和普通的消息一样存储在日志文件中,图5-7中提及了控制消息,RecordBatch中 attributes字段的第6位用来标识当前消息是否是控制消息。如果是控制消息,那么这一位会置为1,否则会置为0,如图7-28所示。

attributes字段中的第5位用来标识当前消息是否处于事务中,如果是事务中的消息那么这一位置为1,否则置为0。由于控制消息也处于事务中,所以 attributes字段的第5位和第6位都被置为1。 ControlBatch中只有一个 Record, Record中的 timestamp delta字段和offset delta字段的值都为0,而控制消息的key和 value的内容如图7-29所示。

就目前的Kafka版本而言,key和vaue内部的 version值都为0,key中的type表示控制类型:0表示 ABORT,1表示 COMMIT;value中的 coordinator_epoch表示TransactionCoordinator的纪元(版本), TransactionCoordinator切换的时候会更新其值。

3)写入最终的 COMPLETE_COMMIT或 COMPLETE_ABORT

TransactionCoordinator将最终的 COMPLETE_COMMIT或 COMPLETE_ABORT信息写入主题 __transaction_state以表明当前事务已经结束,此时可以删除主题 __transaction_state中所有关于该事务的消息。由于主题 __transaction_state采用的日志清理策略为日志压缩,所以这里的删除只需将相应的消息设置为墓碑消息即可。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值