4.1.3 消费者轮询的流程

按照消费者应用程序的示例,消费者订阅主题的下一步是“轮询”。前面分析的准备t作(确保协调者存在,确保分配分区,更新拉取偏移量)都内置在轮询操作里,所以本节的“轮询”主要指准备工作之后的拉取消息流程。这些准备工作不放在订阅主题中去做,是因为消费者订阅了主题不一定会消费消息,但消费者有轮询操作就表示它一定想要拉取并消费消息。

  1. 客户端轮询的两种方案

方案一是把准备工作放在循环外,虽然可以保证循环拉取消息,但是如果需要再平衡,就无法执行重新分配分区的处理逻辑。方案二是把准备工作放在轮询操作里,轮询操作包括准备工作和拉取消息。轮询在循环中运行,准备工作和拉取消息也会循环运行。但并不是每次轮询都要执行准备工作,只会在需要重新分配分区时执行。相关代码如下:

轮询python 轮询程序_客户端

下面列举了多次轮询事件,每次轮询的工作都不同,具体步骤如下。
(1)消费者订阅主题→轮询(执行准备工作→分配分区→拉取消息)。
(2)轮询(已经分配到分区,不再执行准备工作→拉取消息)→轮询(拉取消息)……。
(3)外部事件导致再平衡→轮询(需要重新分配分区→执行准备工作→拉取消息)。
(4)轮询(已经分配到分区,不再执行准备工作→拉取消息)→轮询(拉取消息)··

在消费者上的轮询和网络层的轮询有什么区别呢9先来回顾一下“网络层的轮询”。第2章生产者的发送线程通过NetworkClient发送生产请求或接收生产响应。网络层的轮询操作基于选择器,只用一个事件循环处理不同的读写请求。消费者相对于Kafka集群也是一种客户端,消费者拉取消息也会通过NetworkCli.ent发送拉取请求和接收拉取结果。Java版本的Kafka生产者和消费者都基于NetworkClient,不过消费者在NetworkCli.ent之上又封装了一个ConsumerNetworkClient。

如图4-9所示,生产者和消费者都需要“循环”调用NetworkClient的轮询方法,生产者的循环在发送线程控制,消费者则在客户端应用程序向己控制是否需要循环拉取消息。

轮询python 轮询程序_数据_02


“网络层的轮询”只负责发送请求和接收响应(还会额外调用一些回调方法等),所以在此之前,客户端宿指定要发送的请求 。 从这个意义上来看,“消费者的轮询”等价于“生产者发送线程的工作”,前者会准备拉取请求,后者会准备生产请求,最后都通过“网络层的轮询”,把请求发送出去 。 相关代码如下:

轮询python 轮询程序_网络层_03

Kafka消费者轮询的主要步骤包括:发送拉取请求、通过网络轮询把请求发送出去、获取拉取结果。在具体的实现细节上,做了下面的两点优化。

  • 循环调用pollOnce()方法,一次拉取少量数据,从而更快地拉取。
  • 并行拉取方式,返回拉取结果前再发起一次轮询,下次轮询可以更快地得到结果。
  1. 消费者的一次轮询

消费者客户端调用一次KafkaConsumer.poll()轮询方法只会返回一批结果记录。如果客户端想要一直消费消息,就要在客户端代码中手动循环调用轮询方法。

思考问题:为什么Kafka消费者不实现循环获取消息的逻辑,而是让用户向己循环调用轮询方法?实际上这跟新API的消费者线程模型有关,在4.1.4节消费消息时我再来回答这个问题。

因为消费者可以分配多个分区,所以轮询的结果数据会按照分区进行分组,尽盐保证同一个分区的消息一起返回给客户端。虽然最后返回给客户端的消费者记录集(ConsumerRecords)没有分区信息,但实际上它对轮询结果数据做了一层迭代器封装,所以还是可以保证分区级别的消息的有序性的。比如一次轮询的结果数据是{P0->List[(K1,Vl),(K1,V2)],Pl->List[(K3,V3)],P2->List[(K4,V4)]},

封装后的消费者记录集为:[(K1,Vl),(K2,V2),(K3,V3),(K4,V4)]。相关代码如下:

轮询python 轮询程序_客户端_04

客户端会为轮询操作指定一个最长的等待时间,如果达到超时时间,还是没有拉取到任何数据,就会返回空的集合给客户端。轮询方法只是一次轮询,但是为什么还要循环调用pollOnce()方法?先来看看调用一次轮询时,各个时间变量表示的含义。

  • 开始时间=1秒,剩余时间=超时时间=10秒。
  • 调用一次轮询花费了10秒、轮询后的当前时间=11秒。
  • 轮询花费的时间=当前时间一开始时间=Il秒-l秒=10秒。
  • 剩余时间=超时时间-轮询花费的时间=10秒一10秒=0秒。

最后的“剩余时间>0”不满足循环的条件,因此循环实际上只会执行一次,看起来根本就不需要循环。

实际上pollOnce()方法传递的“剩余时间”,并不一定就是一次轮询花费的时间。比如,超时时间设置为10秒,尽管第一次轮询剩余时间为10秒,但第一次轮询可能只会花费1秒。下一次轮询时剩余时间为9秒,但轮询可能会花费2秒。这样就需要调用多次轮询,并且剩余时间的值会不断减少。这就是为什么要在每次轮询后更新剩余时间、循环判断是否还有剩余时间、把最新剩余的时间再传给轮询方法。

“超时时间”参数保证了在这个时间内,如果没有数据就会一直重试,直到超时,如果一有数据就立即返回(不需要等到超时后再返回)。如果剩余时间小于等于0时,轮询的结果集还是空的,表示客户端在超时时间内没有拉取到任何消息。

如图4-10所示,假设超时时间是10秒,开始时间是0秒,每个竖线分隔都是一次轮询调用。在10秒之内,只要轮询到数据就会立即结束轮询流程(不一定非要在竖线分隔符所在的时刻才返回,剩余时间只是给一个截止时间,如果你在截止时间之前完成任务就可以立即结束)。如果每次轮询都没有返回数据,最终剩余时间工定会小于或等于O导致超时,只能返回空记录。

轮询python 轮询程序_网络层_05

  1. 串行和井行模式轮询
    客户端调用一次轮询,拉取消息并消费消息的流程是:发送请求→客户端轮询→获取结果→消费消息。客户端循环调用轮询和1消费消息的步骤是串行的,伪代码如下:

轮询python 轮询程序_网络层_06

并行模式会在一次轮询中发送多次请求。实际上,一次轮询最多只允许发送两个请求,而且发送第二个请求只能发生在上面流程中的步骤。)和步骤(4)之间。如果发送新请求在步骤(2)和步骤(3)之间,执行步骤(3)时获取的数据无法区分是哪个请求的,因为第一个请求和第二个请求都会产生结果数据。当然,也不要放在步骤(4)之后,那样就退化到前面的串行模式了。

如图4-11所示,消费者发生了3次轮询,一共发送了4次请求,得到了3个请求的结果。阁中粗体编号表示请求l的执行顺序,灰色背景部分是请求2。第一次轮询时发送了请求l,得到请求l的结果,发送请求2;第二次轮询时,先得到第一次轮询请求2的结果,并发送请求3;第三次轮询时,得到第二次轮询请求3的结果,并发送请求4。也就是说,每次轮询时,都会在步骤(3)获取当前请求的结果和步骤(4)处理结果之间发送新的请求,然后返回当前请求的结果,好处是可以尽可能增加相同时间内处理的请求量。

轮询python 轮询程序_数据_07

我们在每次轮询获取到当前请求的结果后,发送新的拉取请求,并通-过NetworkCllent的快速轮询
方法qulckPoll()将新请求发迭出去。下面的两个条件确保了新请求不会影响第一个请求。

  • 发送请求返回的是一个异步请求对象,调用发送请求会立即返回到主流程。
  • 快速轮询将新请求发送出去后,并不会等待获取响应结果,所以它也不会影响第一个请求。

快速轮询和普通轮询方法的不同点是前者的超时时间设置为0,而后者的超时时间等于剩余时间(通常大于0)。超时时间为0,这表示轮询时不会被阻塞而是立即返回。NetworkCllent的轮询会调用选择器轮询,再调用Java选择器的select()方法,如果超时时间为0,等价子选择器的selectNow()。

注意:如果仅仅调用发送请求的方法返回异步请求对象,只是将拉取请求暂存在网络通道中,必须手动触发网络层的轮询操作才会真正把请求发送出去。但是客户端需不需要等待轮询结果就看情况而定了。选择器根据阻塞和超时时间有3种方法:select()、select(ti.111eout)、selectNow()。第一个方法如果没有事件发生会永远阻塞,第二个方法在给定时间内没有事件会阻塞,最后一个方法即使没有事件也不会阻塞。新请求不应该使用阻塞模式的轮询,因为在每一次轮询时,只需要关注当前请求的结果,不需妥关注新请求的结果。如果新请求采用阻塞方式轮询,那么在本次轮询时也会产生新请求的结果。新请求的结果应该留给下一次轮询去获取,也就是说,一次轮询会发送两次拉取请求,
但只会获取和处理第一个请求的结果。

如图4-12(上)所示,传统的串行模式只能等上一次请求处理完毕后,才能开始下一次请求。如医14-12(下)所示,在获取到当前请求的结果之后、返回处理结果之前,执行无阻塞的“发送新请求和快速轮询”额外操作。并行模式只需花费很小的代价,就可以在相同时间内,处理相比串行模式更多的请求。换句话说,如果要处理相同的请求量,并行模式花费的时间比串行模式要少。

轮询python 轮询程序_轮询python_08

  1. 并行模式轮询的设计

消费者拉取消息的流程是:发送请求→客户端轮询→获取拉取结果→处理拉取结果。那么如何在不影响现有逻辑的前提下,设计出一个高性能的并行模式轮询呢?先来看看第一次轮询和后面几次轮询的不同点在哪里。如表4-3所示,第一次轮询有六个步骤一一发送第一个请求、客户端轮询、获取第一个请求的拉取结果、发送第二个请求、客户端快速轮询、返回第一个请求的拉取结果、处理拉取结果,这是一个标准的并行模式示例。第二次以及之后的每次轮询有四个步骤:获取(上一次)请求结果、发送下一次请求、返回请求结果、处理拉取结果。

轮询python 轮询程序_网络层_09

所有轮询都会执行后面的4个步骤,但第一次轮询比后续轮询多了最开始的两个步骤。第一次轮询必须先发送请求,否则直接获取结果肯定是没有数据的。后续的轮询不需要执行这两个步骤,是因为在上一次的轮询中已经发送过请求了,本次轮询时可以一开始就直接获取结果。

那么如何判断是不是第一次轮询呢?下面的伪代码中如果按照发送请求→轮询→获取记录的顺序执行,最后一定可以获取到结果。Kafka的做法是在pollOnce()方法中先获取记录,如果记录为空,就表示是第一次轮询,接着会执行发送请求→轮询→获取记录。第一次轮询在获取第一个请求的记录后,会发送第二个请求并快速轮询,最后返回第一个请求的记录。

第二次轮询时,也是先获取记录,因为第二个请求的发送请求→轮询已经在第一次轮询中完成了。所以第二次轮询时,pollOnce()中“获取记录”返回结果不为空。第二次轮询在获取第二个请求的记录后,会发送第三个请求并快速轮询,最后返回第二个请求的记录。后续的轮询以此类推。相关代码如下:

轮询python 轮询程序_客户端_10

如图4-13(左)所示,第一次轮询时依次执行右侧的(1)发送请求→(2)轮询→(3)获取记录集→(4)发送新请求→(5)快速轮询→(6)返回记录集。第二次之后的轮询依次执行左侧的(1)获取记录集→(2)发送新请求→(3)快速轮询→(4)返回记录集。如图4-13(右)所示,调用一次p~llOnce()方法如果没有返回记录,就会继续循环,直到超时后退出循环,返回空的记录(比图4-13(左)多了一个循环的过程)。只要有调用“获取记录集”,就需要判断记录集是否为空。如果记录集有数据,就会发送新请求→快速轮询,然后返回获取的记录集,客户端轮询就结束了。

轮询python 轮询程序_数据_11

如图4-14所示,每次轮询都会提前发送下一次请求。除了第一次轮询,一个完整的“请求发送、轮询和获取结果”流程分开在两个轮询中。比如:第二个请求的发送和轮询发生在第一次轮询里,而获取第二个请求的结果则在第二次轮询里。

轮询python 轮询程序_轮询python_12

  1. 轮询与结果
    KafkaConsumer的轮询操作中“发送拉取请求”和“获取拉取结果”都通过拉取器(Fetcher)完成,这两个步骤中间的“客户端轮询”起到了承上启下的作用。如图4-15(左)所示,客户端轮询会把请求通过网络真正发送出去,并且在收到响应结果后将结果设置到拉取器中,这样获取拉取结果时才有数据。

但实际上将拉取结果放到拉取器中并不应该交给客户端轮询去做,客户端的轮询如果涉及具体的请求,就要处理各种各样的请求类型。让客户端轮询只专注于网络层的发送和接收,和业务逻辑解耦才是正确的方法。如图4-15(有)所示,解决方法是在发送请求时定义一个回调方法,它会将响应结果放到拉取器中,具体步骤如下。

(1)发送请求时定义回调对象,其回调方法会处理响应结果。
(2)客户端轮询,将请求发送给服务端去执行。
(3)客户端轮询得到服务端返回的响应结果。
(4)客户端轮询调用发送请求的回调方法。
(5)自定义的回调方法会处理响应结果,然后放到拉取器中。
(6)消费者从拉取器中获取步骤(5)放入的结果。

上面已经分析了消费者的轮询流程,轮i旬操作定义了拉取消息的执行流程。只有拉取到消息,消费者客户端才可以对消息进行处理和消费。下面分析消费者拉取消息的具体实现。