Kafka服务端之网络模型

[TOC]
在开始介绍Kafka服务端的代码之前,先从整体上对其架构了解一下。Kafka Server的架构如图4-1所示。本章会详细介绍每个组件的功能和实现

image.png

4.1 网络层

通过前面的介绍我们已经了解到,Kafka的客户端会与服务端的多个Broker创建网络连接,在这些网络连接上流转着各种请求及其响应,从而实现客户端与服务端之间的交互。客户端一般情况下不会碰到大数据量访问、高并发的场景,所以客户端使用NetworkClient组件管理这些网络连接足矣。Kafka服务端与客户端的运行场景不同,面对高并发、低延迟的需求,Kafka服务端使用Reactor模式实现其网络层。Kafka的网络层管理的网络连接中不仅有来自客户端的,还会有来自其他Broker的网络连接。

4.1.1 Reactor模式

Kafka网络层采用的是Reactor模式,是一种基于事件驱动的模式。熟悉Java编程的读者应该了解Java NIO提供了实现Reactor模式的API。常见的单线程Java NIO的编程模式如图4-2所示

image.png

简单介绍其工作原理:

(1)首先创建ServerSocketChannel对象并在Selector上注册OP_ACCEPT事件,ServerSocketChannel负责监听指定端口上的连接请求。
(2)当客户端发起到服务端的网络连接时,服务端的Selector监听到此OP_ACCEPT事件,会触发Acceptor来处理OP_ACCEPT。

(3)当Acceptor接收到来自客户端的Socket连接请求时会为这个连接创建相应的SocketChannel,将SocketChannel设置为非阻塞模式,并在Selector上注册其关注的I/O事件,例如,OP_READ、OP_WRITE。此时,客户端与服务端之间的Socket连接正式建立完成。
(4)当客户端通过上面建立的Socket连接向服务端发送请求时,服务端的Selector会监听到OP_READ事件,并触发执行相应的处理逻辑(图4-2中的Reader Handler)。当服务端可以向客户端写数据时,服务端的Selector会监听到OP_WRITE事件,并触发执行相应的处理逻辑(图4-2中的Writer Handler)。

注意,这里的所有事件处理逻辑都是在同一线程中完成的,读者可以回顾KafkaProducer中的Sender线程以及KafkaConsumer的代码,会发现它们都是这种设计。这种设计适合客户端这种并发连接数较小、数据量较小的场景,对于服务端来说就有些缺点。例如,某请求的处理过程比较复杂会造成线程阻塞,那么所有后续请求都无法被处理,这就会导致大量的请求超时。为了避免这种情况,要求服务端在读取请求、处理请求以及发送响应等各个环节必须能迅速完成,这就提高了编程难度,在有的场景下是不可能完成的任务。而且这种模式不能利用服务器多核多处理器的并行处理能力,造成了资源浪费。

为了满足高并发的需求,也为了充分利用服务器的资源,服务端需要使用多线程来执行业务逻辑。我们对上述架构稍作调整,将网络读写的逻辑与业务处理的逻辑进行拆分,让其由不同的线程池来处理,从而实现多线程处理。设计架构如图4-3所示。

image.png

图4-3中的Acceptor单独运行在一个线程中,也可以使用单线程的ExecutorService实现,因为ExecutorService会在线程异常退出时,创建新线程进行补偿,所以可以防止出现线程异常退出后整个服务端不能接收请求的异常情况。Reader ThreadPool线程池中的所有线程都会在Selector上注册OP_READ事件,负责服务端读取请求的逻辑,当然也是一个线程对应处理多个Socket连接。Reader ThreadPool中的线程成功读取请求后,将请求放入MessageQueue这个共享队列中。Handler ThreadPool线程池中的线程会从MessageQueue 这个共享队列中。Handler ThreadPool线程池中的线程会从MessageQueue中取出请求,然后执行业务逻辑对请求进行处理。这种模式下,即使处理某个请求的线程阻塞了,池中还有其他线程继续从MessageQueue中获取请求并进行处理,从而避免了整个服务端阻塞。当请求处理完成后,Handler线程还负责产生响应并发送给客户端,这就要求Handler ThreadPool中的线程在Selector中注册OP_WRITE事件,实现发送响应的功能。

最后需要注意的是,当读取请求与业务处理之间的速度不匹配时,MessageQueue队列长度的选择就显得尤为重要,尤其是MessageQueue队列是固定的大小的时候。如果队列长度太小,就会出现拒绝请求的情况;如果不限制MessageQueue队列的长度,则可能因为堆积过多未处理请求而导致内存溢出。这就需要设计人员根据实际的业务需求进行权衡和设计

通过将网络处理与业务逻辑进行切分后实现了上述设计,此设计中读取、写入、业务处理都实现了多线程处理,不再存在性能瓶颈。但是,如果同一时间出现大量I/O事件,单个Selector就可能在分发事件时阻塞(或延时)而成为瓶颈。我们可以将上述设计中单独的Selector对象扩展成多个,让它们监听不同的I/O事件,这样就可以避免单个Selector带来的瓶颈问题。设计如图4-4所示。

image.png

一般情况下,Acceptor单独占用一个Selector。当Acceptor Selector监听到OP_ACCEPT时,会创建相应的SocketChannel,在图4-4的设计中,我们使用一定的策略,例如轮训Selector集合或选择注册连接数最少的Selector,让不同的连接在不同的Selector上注册I/O事件。之后就由此Selector负责监听此SocketChannel上的事件。这样,就可以缓解单个Selector带来的瓶颈问题。

4.1.2 SocketServer

Kafka的网络层是采用多线程、多个Selector的设计实现的。核心类是 SocketServer,其中包含一个Acceptor用于接受并处理所有的新连接,每个Acceptor对应多个Processor线程,每个Processor线程拥有自己的Selector,主要用于从连接中读取请求和写回响应。每个Acceptor对应多个Handler线程,主要用于处理请求并将产生响应返回给Processor线程。Processor线程与Handler线程之间通过RequestChannel进行通信。整个网络层的结构如图4-5所示。

image.png

下面介绍SocketServer的具体实现。首先来看SocketServer依赖的组件,如图4-6所示。

image.png

SocketServer的核心字段如下所述。

  • endpoints:Endpoint集合。一般的服务器都有多块网卡,可以配置多个IP,Kafka可以同时监听多个端口。Endpoint类中封装了需要监听的host、port及使用的网络协议。每个Endpoint都会创建一个对应的Acceptor对象。

  • ·numProcessorThreads:Processor线程的个数。

  • ·totalProcessorThreads:Processor线程的总个数,即numProcessorThreads *endpoints.size。

  • ·maxQueuedRequests:在RequestChannel的requestQueue中缓存的最大请求个数。

  • ·maxConnectionsPerIp:每个IP上能创建的最大连接数。

  • ·maxConnectionsPerIpOverrides:Map[String,Int]类型,具体指定某IP上最大的连接数,这里指定的最大连接数会覆盖上面maxConnectionsPerIp字段的值。

  • ·requestChannel:Processor线程与Handler线程之间交换数据的队列

  • ·acceptors:Acceptor对象集合,每个Endpoint对应一个Acceptor对象。

  • ·processors:Processor线程的集合。此集合中包含所有Endpoint对应的Processors线程,如图4-7所示。

image.png
  • ·connectionQuotas:ConnectionQuotas类型的对象。在ConnectionQuotas中,提供了控制每个IP上的最大连接数的功能。底层通过一个Map对象,记录每个IP地址上建立的连接数,创建新Connect时与maxConnectionsPerIpOverrides指定的最大值(或maxConnectionsPerIp)进行比较,若超出限制,则报错。因为有多个Acceptor线程并发访问底层的Map对象,则需要synchronized进行同步。

介绍完SocketServer中各个字段的功能,再来看一下SocketServer的初始化流程,读者可以先大体了解此过程,具体每个组件的实现在后面会详细介绍。SocketServer在初始化时会创建遍历所有的Endpoint,创建与其对应的Acceptor和Processor集合。

image.png

SocketServer的关闭操作比较简单,它会关闭所有的Acceptor和Processor,代码如下:


image.png

4.1.3 AbstractServerThread
Acceptor和Processor都继承了AbstractServerThread,如图4-8所示,AbstractServerThread是实现了Runnable接口的抽象类。在AbstractServerThread中为Acceptor和Processor提供了一些启动关闭相关的控制类方法。

image.png

AbstractServerThread中的关键字段有四个。

  • ·alive:标识当前线程是否存活,在初始化时设置为true,在shutdown()方法中会将alive设置为false。
  • ·shutdownLatch:count为1的CountDownLatch对象,标识了当前线程的shutdown操作是否完成。
  • ·startupLatch:count为1的CountDownLatch对象,标识了当前线程的startup操作是否完成。

·在awaitStartup()和shutdown()方法中会调用CountDownLatch.await()方法,阻塞等待启动和关闭操作操作完成。在startupComplete()和shutdownComplete()方法中调用CountDownLatch.countDown()方法,唤醒阻塞的线程。

  • ·connectionQuotas:在close()方法中,根据传入的ConnectionId,关闭SocketChannel并减少ConnectionQuotas中记录的连接数。

AbstractServerThread中的方法主要是操作上面的四个字段,简单分析一下其中比较常用的几个方法:


image.png

4.1.4 Acceptor
Acceptor的主要功能是接收客户端建立连接的请求,创建Socket连接并分配给Processor处理。Acceptor中有两个比较重要的字段:一个是Java NIO Selector, 二是用于接收客户端请求的ServerSocketChannel对象。在创建Acceptor时会初始化上面两个字段,同时还会创建并启动其管理的Processors线程。

image.png

Acceptor.run()方法是Acceptor的核心逻辑,其中完成了对OP_ACCEPT事件的处理。具体实现如下


image.png
image.png

Acceptor.accept()方法实现了对OP_ACCEPT事件的处理,它会创建SocketChannel并将其交给Processor.accept()方法处理,同时还会增加ConnectionQuotas中记录的连接数。accept()方法的代码如下:

image.png

4.1.5 Processor
Processor主要用于完成读取请求和写回响应的操作,Processor不参与具体业务逻辑的处理。Processor的核心字段如下所述,在创建Processor对象时会初始化这些字段。

  • ·newConnections:ConcurrentLinkedQueue[SocketChannel]类型,其中保存了由此Processor处理的新建的SocketChannel。
  • ·inflightResponses:保存未发送的响应。有读者可能会将inflightResponses与客户端的InFlightRequests进行类比,但也要注意其区别,客户端并不会对服务端发送的响应消息再次发送确认,所以inflightResponse中的响应会在发送成功后移除,而InFlightRequests中的请求是在收到响应后才移除。
  • ·selector:KSelector类型,负责管理网络连接。
  • ·requestChannel:Processor与Handler线程之间传递数据的队列。

在Acceptor.accept()方法中创建的SocketChannel会通过Processor.accept()方法交给Processor进行处理。Processor.accpet()方法接收到一个新的SocketChannel时会先将其放入newConnections队列中,然后会唤醒Processor线程来处理newConnections队列。注意,newConnections队列由Acceptor线程和Processor线程并发操作,所以选择线程安全的ConcurrentLinkedQueue。下面是accept()方法的代码:

image.png

在Processor.run()方法中实现了从网络连接上读写数据的功能。run()方法的流程如图4-9所示。

image.png

(1)首先调用startupComplete()方法,标识Processor的初始化流程已经结束,唤醒阻塞等待此Processor初始化完成的线程。
(2)处理newConnections队列中的新建SocketChannel。队列中的每个SocketChannel都要在nioSelector上注册OP_READ事件。这里有个细节,SocketChannel会被封装成KafkaChannel,并附加(attach)到SelectionKey上,所以后面触发OP_READ事件时,从SelectionKey上获取的是KafkaChannel类型的对象

下面是configureNewConnections()方法的代码:


image.png

(3)获取RequestChannel中对应的responseQueue队列,并处理其中缓存的Response。

如果Response是SendAction类型,表示该Response需要发送给客户端,则查找对应的KafkaChannel,为其注册OP_WRITE事件,并将KafkaChannel.send字段指向待发送的Response对象。同时还会将Response从responseQueue队列中移出,放入inflightResponses中。如果读者关心OP_WRITE事件的取消时机,可以回顾KafkaChannel.send()方法,即发送完一个完整的响应后,会取消此连接注册的OP_WRITE事件。

如果Response是NoOpAction类型,表示此连接暂无响应需要发送,则为KafkaChannel注册OP_READ,允许其继续读取请求。

如果Response是CloseConnectionAction类型,则关闭对应的连接

下面是processNewResponses()方法的代码:


image.png

(4)调用SocketServer.poll()方法读取请求,发送响应。poll()方法底层调用的是KSelecor.poll()方法。

image.png

KSelector.poll()方法每次调用都会将读取的请求、发送成功的请求以及断开的连接放入其completedReceives、completedSends、disconnected队列中等待处理,下面就处理进行相应的队列。

(5)调用processCompletedReceives()方法处理KSelector.completedReceives队列。首先,遍历completedReceives,将NetworkReceive、ProcessorId、身份认证信息一起封装成RequestChannel.Request对象并放入RequestChannel.requestQueue队列中,等待Handler线程的后续处理。之后,取消对应KafkaChannel注册的OP_READ事件,表示在发送响应之前,此连接不能再读取任何请求了。

image.png

(6)调用processCompletedSends()方法处理KSelector.completedSends队列。首先,将inflightResponses中保存的对应Response删除。之后,为对应连接重新注册OP_READ事件,允许从该连接读取数据。

image.png

(7)调用processDisconnected()方法处理KSelector.disconnected队列。先从inflightResponses中删除该连接对应的所有Response。然后,减少ConnectionQuotas中记录的连接数,为后续的新建连接做准备。

image.png

(8)当调用SocketServer.shutdown()关闭整个SocketServer时,将alive字段设置为false,上述循环结束。然后调用shutdownComplete()方法执行一系列关闭操作:关闭Processor管理的全部连接,减少ConnectionQuotas中记录的连接数量,标识自身的关闭流程已经结束,唤醒等待该Processor结束的线程。

介绍完Processor.run()方法的执行流程和每一步的具体实现后,来看一下run()方法的代码:

image.png

4.1.6 RequestChannel

Processor线程与Handler线程之间传递数据是通过RequestChannel完成的。在RequestChannel中包含了一个requestQueue队列和多个responseQueues队列,每个Processor线程对应一个responseQueue。Processor线程将读取到的请求存入requestQueue中,Handler线程从requestQueue队列中取出请求进行处理;Handler线程处理请求产生的响应会存放到Processor对应的responseQueue中,Processor线程从其对应的responseQueue中取出响应并发送给客户端。RequestChannel的结构如图4-10所示。

image.png

下面来看一下RequestChannel的核心字段。

  • requestQueue:ArrayBlockingQueue[RequestChannel.Request]类型。Processor线程向Handler线程传递请求的队列。因为多个Processor线程和多个Handler线程并发操作,所以选择线程安全的队列。

  • ·responseQueues:LinkedBlockingQueue [RequestChannel.Response]队列的数组( private val responseQueues = new ArrayBlockingQueue[RequestChannel.Response]
    ),Handler线程向Processor线程传递响应的队列,每个Processor对应该数组中的一个队列。

  • ·numProcessors:Processor线程个数,也是responseQueues这个数组的长度。

  • ·queueSize:缓存请求的最大个数,即requestQueue的长度。

  • ·responseListeners:List[(Int) => Unit]类型,该字段是监听器列表,其中的监听器的主要作用是Handler线程向responseQueue存放响应时唤醒对应的Processor线程。

RequestChannel提供了增删requestQueue队列、responseQueues集合以及responseListeners列表中元素的方法。在SocketServer的初始化过程中,有向RequestChannel. responseListeners集合中添加一个唤醒对应Processor线程的监听,代码如下所示。

image.png

需要注意的是,每次向responseQueues添加请求时都要触发responseListeners列表中的监听器。具体实现如下:

image.png

在RequestChannel中保存的是RequestChannel.Request和RequestChannel.Response两个类的对象。RequestChannel.Request会对请求进行解析,形成requestId(请求类型ID)、header(请求头)、body(请求体)等字段,供Handler线程使用,并提供了一些记录操作时间的字段供监控程序使用。简单看一下RequestChannel.Request的解析过程:

image.png

RequestChannel.Response需要注意其responseAction字段,有SendAction、NoOpAction、CloseConnectionAction三种类型,在前面介绍Processor的processNewResponses()方法时也介绍过这三种类型的含义,此处不再赘述。

介绍到这里,有读者可能会问:当请求放入RequestChannel.requestQueue之后,会有多个Handler线程并发处理从其中取出请求处理,那如何保证客户端请求的顺序性呢?请读者返回前面查看Processor.run()方法的介绍,其中有多处注册/取消OP_READ事件以及注册/取消OP_WRITE事件的操作,通过这些操作的组合可以保证每个连接上只有一个请求和一个对应的响应,从而实现请求的顺序性。

现在回头来总结一个请求数据从生产者发送到服务端的流转过程,如图4-11所示。


image.png

KafkaProducer线程创建ProducerRecord后,会将其缓存进RecordAccumulator。Sender线程从RecordAccumulator中获取缓存的消息,放入KafkaChannel.send字段中等待发送,同时放入InFlightRequests队列中等待响应。之后,客户端会通过KSelector将请求发送出去。在服务端,Processor线程使用KSelector读取请求并暂存到stageReceives队列中,KSelector.poll()方法结束后,请求被移转移到completeReceives队列中。之后,Processor将请求进行一些解析操作后,放入RequestChannel.requestQueue队列。

Handler线程会从RequestChannel.requestQueue队列中取出请求进行处理,将处理之后生成的响应放入RequestChannel.responseQueue队列。Processor线程从其对应的RequestChannel.responseQueue队列中取出响应并放入inflightResponses队列中缓存,当响应发送出去之后会将其从inflightResponse中删除。生产者读取响应的过程与服务端读取请求的过程类似,主要的区别是生产者需要对InFlightRequest中的请求进行确认。消费者与服务端之间的请求和响应的流转过程与上述过程类似,不再赘述了。

Kafka网络层的设计原理和实现就介绍到这里了。在高性能的分布式框架中经常采用这种Reactor模式的设计,例如,HDFS RPC框架的服务端、ZooKeeper等。也有实现了Reactor模式的框架,例如,Netty和Mina。希望读者能够通过本章的描述理解Reactor模式及其在Kafka服务端的实现。

4.2 API层

通过上一节对Kafka网络层的介绍我们知道,Handler线程会取出Processor线程,放入RequestChannel的请求进行处理,并将产生的响应通过RequestChannel传递给Processor线程。Handler线程属于Kafka的API层,Handler线程对请求的处理通过调用KafkaApis中的方法实现。本节主要分析Kafka的API层实现。

4.2.1 KafkaRequestHandler
KafkaRequestHandler的主要职责是从RequestChannel获取请求并调用KafkaApis.handle()方法处理请求。KafkaRequestHandler的具体实现如下:

image.png

API层使用KafkaRequestHandlerPool来管理所有的KafkaRequestHandler线程,KafkaRequestHandlerPool是一个简易版的线程池,其中创建了多个KafkaRequestHandler线程。KafkaRequestHandlerPool的代码如下:

image.png
4.2.2 KafkaApis

KafkaApis是Kafka服务器处理请求的入口类。它负责将KafkaRequestHandler传递过来的请求分发到不同的handl*()处理方法中,分发的依据是RequestChannel.Request中的requestId,此字段保存了请求的ApiKeys的值,不同的ApiKeys值表示不同请求的类型。下面来看一下实现了此分发功能handle()方法的具体实现:

image.png

这里不一一列举每个handle()方法,我们将在后面介绍Kafka各个子系统时穿插相关请求的handle()处理方法

你可能感兴趣的:(Kafka服务端之网络模型)