【Kafka】Kafka 架构的演化

Posted by 西维蜀黍 on 2023-02-19, Last Modified on 2023-05-02

在这个过程中,你会看到 Kafka 在处理请求的过程中会遇到哪些高性能和高并发问题,以及架构为什么要这样演进,从而理解 Kafka 这么设计的意义和精妙之处。

顺序处理模式

我们从最简单的网络编程思路处理方式讲起。

因为对于 Kafka Broker 来说就是用来接收生产者发送过来的请求,那这个时候最简单的实现大概是这样的:

while (true){
	Request request := accpet(connection);
  handle(request)
}

如上述代码所示:我们可以理解 Kafka 每个服务器启动起来后就是一个 while 循环, 不断的 accept 生产者提交上来的请求, 然后进行处理并存储到磁盘上,这种方式实现最简单,也非常好理解,但是这种方式存在2个致命的缺陷?

  1. 请求阻塞: 只能顺序处理每个请求,即每个请求都必须等待前一个请求处理完毕才能得到处理。
  2. 吞吐量非常差: 由于只能顺序处理,无法并发,效率太低,所以吞吐量非常差,只适合请求发送非常不频繁的系统。

从上面来看很明显,如果你的 Kafka 系统请求并发量很大,意味着要处理的时间就会越久。那按照前面我们提到的 Kafka「吞吐量」的标准,这个方案远远无法满足我们对高性能、高并发的要求。

那有什么更好的方案可以快速处理请求吗?

接下来我们可以试着采取这个方案:独立线程异步处理模式

多线程并行处理模式(connection per thread)

既然同步方式会阻塞请求,吞吐量差, 我们可以尝试着使用独立线程异步方式进行处理, 即经典的 connection per thread 模型, 那这个时候的实现大概是这样的:

while (true){
	Request request := accpet(connection);
  Thread thread = new Thread(handle(request));
  thread.start()
}

如上述代码所示:同上还是一个 while 循环不断的 accept 生产者提交上来的请求,但是这时候 Kafka 系统会为每个请求都创建一个「单独的线程」来处理。

这个实现方案的好处就是:

  1. 吞吐量稍强: 相对上面同步方式的方案,一定程度上极大地提高了服务器的吞吐量。
  2. 非阻塞: 它是完全异步的,每个请求的处理都不会阻塞下一个请求。

但同样缺陷也同样很明显:即为每个请求都创建线程的做法开销很大,在某些高并发场景下会压垮整个服务。可见,这个方案也只适用于请求发送频率很低的业务场景。还是无法满足我们对高性能、高并发的要求。

既然这种方案还是不能满足, 那么我们究竟该使用什么方案来支撑高并发呢?

这个时候我们可以想想我们日常开发用到的7层负载Nginx或者Redis在处理高并发请求的时候是使用什么方案呢?

Reactor 模式

在高性能网络编程领域,有一个非常著名的模式——Reactor模式。那么何为「Reactor模式」,首先它是基于事件驱动的,有一个或多个并发输入源,有一个Service Handler,有多个Request Handler;这个Service Handler会同步的将输入的请求轮询地分发给相应的Request Handler进行处理。

借助于 Doug Lea(就是那位让人无限景仰的大爷)的 “Scalable IO in Java” 中讲述的Reactor模式。

“Scalable IO in Java” 的地址是:

http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf

简单来说,Reactor 模式特别适合应用于处理多个客户端并发向服务器端发送请求的场景。这里借用大神 PDF 中的一幅图来说明 Reactor 架构:

从上面这张图中,我们可以看出多个客户端会发送请求给到 Reactor。Reactor 有个请求分发线程 Dispatcher,也就是图中的绿色的 Acceptor,它会将不同的请求下分发到多个工作线程中处理。

在这个架构中,Acceptor 线程只是用来进行请求分发,所以非常轻量级,因此会有很高的吞吐量。而这些工作线程可以根据实际系统负载情况动态调节系统负载能力,从而达到请求处理的平衡性。

基于上面的 Reactor 架构, 我们来看看如果是我们该如何设计 Kafka 服务端的架构

  1. 这里我们采用多路复用方案,Reactor 设计模式,并引用 Java NIO 的方式可以更好的解决上面并发请求问题。
  2. 当 Client 端将请求发送到 Server 端的时候, 首先在 Server 端有个多路复用器(Selector),然后会启动一个 Accepter 线程将 OP_CONNECT 事件注册到多路复用器上, 主要用来监听连接事件到来。
  3. 当监听到连接事件后,就会在多路复用器上注册 OP_READ 事件, 这样 Cient 端发送过来的请求, 都会被接收到。如果请求特别多的话, 我们这里进行优化, 创建一个 Read HandlePool 线程池
  4. 当 Read HandlePool 线程池接收到请求数据后,最终会交给 Handler ThreadPool 线程池进行后续处理。比如如果是生产者发送过来的请求,肯定会解析请求体,处理并最终存储到磁盘中,待处理完后要返回处理结果状态, 这时候就由它在多路复用器上注册 OP_WRITE 事件来完成。这样多路复用器遍历到 OP_WRITE 事件后就会将请求返回到 Client 端。
  5. 在上图中我们看到在整个流程中还有一个 MessageQueue 的队列组件存在, 为什么要加这个组件呢? 我们可以想象一下, 如果请求量非常大,直接交给 Handler ThreadPool 线程池进行处理, 可能会出现该线程池处理不过来的情况发生,如果处理不过来,也会出现阻塞瓶颈。所以这里我们在 Server 端内部也设计一个消息队列, 起到一个缓冲的作用,Handler ThreadPool 线程池会根据自己的负载能力进行处理。

以上就是我们引用了「多路复用」的设计方案,但是 Kafka Broker 端就是这样的架构设计方案吗?如果我们是 Kafka 系统架构的设计者,采用这样的架构设计方案会不会还是有什么问题,有没有哪个环节会出现系统性能瓶颈呢?

这是个值得思考的问题, 很考验你的架构设计能力。

细心的读者可能会发现:对于 Kafka 这种超高并发系统来说,一个 Selector 多路复用器是 Hold 不住的,从上图可以得出,我们监听这些连接、接收请求、处理响应结果都是同一个 Selector 在进行处理,很容易成为系统性能瓶颈。

接下来,我们将进一步进行优化,为了减轻当前 Selector 的处理负担,引入另外一个Selector 处理队列,如下图所示:

  1. 首先上图是目前我认为最接近 Kafka Broker 真实架构设计方案的。
  2. 整体架构跟上一版的类似,只不过这里多引入了一个多 Selector 处理队列,原来的 Selector 只负责监听连接, 这时候有读者就会有疑问,请求量超级大的时候,一个 Selector 会不会成为瓶颈呢? 这里可以大可放心, 这时候它的工作非常单一,是完全能 hold 住的。
  3. 那么对于我们接收请求、处理请求、返回状态操作都会交由多 Selector 处理队列,至于这里到底需要多少个 Selector,又会跟什么参数和配置有关系,我们后续再进行分析,总之这里记住有多个 Selector 就行了,这样系统压力就会被分散处理。
  4. 另外我们要搞清楚的一点就是对于 Kafka 服务端指的是每个 Broker 节点,如果我们的服务集群总共有10个节点, 每个节点内部都是上面的这样的架构,这样我们就有理由相信如果采用这样的架构设计方案,是可以支持高并发和高性能的。

架构设计方案演进到这里,基本上已经差不多了,接下来我们看看 Kafka 真实超高并发的网络架构是如何设计的。

Kafka 超高并发网络架构

在上面 Kafka 高性能、高吞吐量架构演进的时候,我们提到了 Java NIO 以及 Reactor 设计模式。实际上,搞透了「Kafka 究竟是怎么使用 NIO 来实现网络通信的」,不仅能让我们掌握 Kafka 请求处理全流程处理,也能让我们对 Reactor 设计模式有更深的理解,还能帮助我们解决很多实际问题。

那么接下来我们就来深入剖析下 Kafka 的 NIO 通讯机制吧。

我们先从整体上看一下完整的网络通信层架构,如下图所示:

  1. 从上图中我们可以看出,Kafka 网络通信架构中用到的组件主要由两大部分构成:SocketServerRequestHandlerPool
  2. SocketServer 组件是 Kafka 超高并发网络通信层中最重要的子模块。它包含 Acceptor 线程、Processor 线程和 RequestChannel 等对象,都是网络通信的重要组成部分。它主要实现了 Reactor 设计模式,主要用来处理外部多个 Clients(这里的 Clients 可能包含 Producer、Consumer 或其他 Broker)的并发请求,并负责将处理结果封装进 Response 中,返还给 Clients。
  3. RequestHandlerPool 组件就是我们常说的 I/O 工作线程池,里面定义了若干个 I/O 线程,主要用来执行真实的请求处理逻辑
  4. 这里注意的是:跟 RequestHandler 相比, 上面所说的Acceptor、Processor 线程 还有 RequestChannel 等都不做请求处理, 它们只是请求和响应的「搬运工」

Reference