无论是 Kafka 客户端还是 Broker 端,它们之间的交互都是通过“请求 / 响应”的方式完成的。比如,客户端会通过网络发送消息生产请求给 Broker,而 Broker 处理完成后,会发送对应的响应给到客户端。
Apache Kafka 自己定义了一组请求协议(在TCP基础上),用于实现各种各样的交互操作。比如常见的有
-
PRODUCE 请求是用于生产消息的,
-
FETCH 请求是用于消费消息的
-
METADATA 请求是用于请求 Kafka 集群元数据信息的
总之,Kafka 定义了很多类似的请求格式。我数了一下,截止到目前最新的 2.3 版本,Kafka 共定义了多达 45 种请求格式。所有的请求都是通过 TCP 网络以 Socket 的方式进行通讯的。
关于如何处理请求?
很容易想到两个方案
1.顺序处理请求:如果写成伪代码,大概是这个样子:
while (true) {
Request request = accept(connection);
handle(request);
}
这个方法实现简单,但是有个致命的缺陷,那就是吞吐量太差。由于只能顺序处理每个请求,因此,每个请求都必须等待前一个请求处理完毕才能得到处理。这种方式只适用于请求发送非常不频繁的系统。
2**.每个请求使用单独线程处理**。也就是说,我们为每个入站请求都创建一个新的线程来异步处理。我们一起来看看这个方案的伪代码。
while (true) {
Request = request = accept(connection);
Thread thread = new Thread(() -> {
handle(request);});
thread.start();
}
这个方法反其道而行之,完全采用异步的方式。系统会为每个入站请求都创建单独的线程来处理。
优点:它是完全异步的,每个请求的处理都不会阻塞下一个请求
缺点:为每个请求都创建线程的做法开销极大,在某些场景下甚至会压垮整个服务。
还是那句话,这个方法只适用于请求发送频率很低的业务场景。
既然这两种方案都不好,那么,Kafka 是如何处理请求的呢?用一句话概括就是,Kafka 使用的是 Reactor 模式。
谈到 Reactor 模式,大神 Doug Lea 的“Scalable IO in Java”应该算是最好的入门教材了。
简单来说,Reactor 模式是事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务器端发送请求的场景。我借用 Doug Lea 的一页 PPT 来说明一下 Reactor 的架构,并借此引出 Kafka 的请求处理模型。
Reactor模式
Reactor 模式的架构如下图所示:
Acceptor: 请求分发线程
从这张图中,我们可以发现,多个客户端会发送请求给到Reactor。Reactor 有个请求分发线程 Dispatcher,也就是图中的Acceptor它会将不同的请求下发到多个工作线程中处理。
在这个架构中,Acceptor 线程只是用于请求分发,不涉及具体的逻辑处理,非常得轻量
因此有很高的吞吐量表现。而这些工作线程可以根据实际业务处理需要任意增减,从而动态调节系统负载能力。
Kafka中的Reactor模式(类似于多Reactor多线程)
如果我们来为 Kafka 画一张类似的图的话,那它应该是这个样子的:
Kafka 的 Broker 端有个 SocketServer 组件,类似于 Reactor 模式中的 Dispatcher,它也有一个对应的Acceptor线程和一个工作线程池,只不过在Kafka中,这个工作线程池有个专属的名字,叫网络线程池
Kafka 提供了 Broker 端参数 num.network.threads,用于调整该网络线程池的线程数。其默认值是 3,表示每台 Broker 启动时会创建 3 个网络线程,专门处理客户端发送的请求。
那么Acceptor是如何分发请求的呢?
Acceptor 线程采用轮询的方式将入站请求公平地发到所有网络线程中,因此,在实际使用过程中,这些线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单,同时也避免了请求处理的倾斜,有利于实现较为公平的请求处理调度。
好了,你现在了解了客户端发来的请求会被 Broker 端的 Acceptor 线程分发到任意一个网络线程中,由它们来进行处理。那么,当网络线程接收到请求后,它是怎么处理的呢?
网络线程接收到请求后,它是怎么处理的呢(双队列设计)
实际上,Kafka 在这个环节又做了一层异步线程池的处理,我们一起来看一看下面这张图。
当网络线程拿到请求,他不是自己处理,而是将请求放入到一个共享请求队列中,Broker端还有一个IO线程池,负责从该队列取出请求,执行真正的处理,如果是 PRODUCE 生产请求,则将消息写入到底层的磁盘日志中;如果是 FETCH 请求,则从磁盘或页缓存中读取消息。
IO 线程池处中的线程才是执行请求逻辑的线程。Broker 端参数 num.io.threads 控制了这个线程池中的线程数。目前该参数默认值是 8,表示每台 Broker 启动后自动创建 8 个 IO 线程处理请求。你可以根据实际硬件条件设置此线程池的个数。
比如,如果你的机器上 CPU 资源非常充裕,你完全可以调大该参数,允许更多的并发请求被同时处理。当 IO 线程处理完请求后,会将生成的响应发送到网络线程池的响应队列中,然后由对应的网络线程负责将 Response 返还给客户端。
请求队列和相应队列的差别:
请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的。
这么设计的原因就在于:Dispatcher只是用于请求分发而不负责响应回传,因此每个网络线程只能自己发送Response给客户端,所以这些Response也就没有必要放在一个公共的地方(不然分不清是哪个的response,类似于:有了任务分发器分给大家大家一起做,做完了自己单独汇报就行了)。
关于图中 Purgatory 的组件,这是 Kafka 中著名的“炼狱”组件。它是用来缓存延时请求(Delayed Request)的。所谓延时请求,就是那些一时为满足条件不能立刻处理的请求。比如设置了 acks=all 的 PRODUCE 请求,一旦设置了 acks=all,那么该请求就必须等待 ISR(In-sync Replicas同步副本集合) 中所有副本都接收了消息后才能返回,此时处理该请求的 IO 线程就必须等待其他 Broker 的写入结果。当请求不能立刻处理时,它就会暂存在 Purgatory 中。稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中。
控制类请求单独处理,以提高响应速度
到目前为止,我提及的请求处理流程对于所有请求都是适用的,也就是说,Kafka Broker 对所有请求是一视同仁的。但是,在 Kafka 内部,除了客户端发送的 PRODUCE 请求和 FETCH 请求之外,还有很多执行其他操作的请求类型,比如
- 负责更新 Leader 副本、Follower 副本以及 ISR 集合的 LeaderAndIsr 请求,
- 负责勒令副本下线的 StopReplica 请求等。
与 PRODUCE 和 FETCH 请求相比,这些请求有个明显的不同:它们不是数据类的请求,而是控制类的请求。也就是说,它们并不是操作消息数据的,而是用来执行特定的 Kafka 内部动作的。Kafka 社区把 PRODUCE 和 FETCH 这类请求称为数据类请求,把 LeaderAndIsr、StopReplica 这类请求称为控制类请求。
当前这种一视同仁的处理方式对控制类请求是不合理的。为什么呢?因为控制类请求有这样一种能力:它可以直接令数据类请求失效!
看如下这个例子:
假设我们有个主题只有 1 个分区,该分区配置了两个副本
其中Leader 副本保存在 Broker 0 上,Follower 副本保存在 Broker 1 上
假设 Broker 0 这台机器积压了很多的 PRODUCE 请求,此时你如果使用 Kafka 命令强制将该主题分区的 Leader、Follower 角色互换,那么 Kafka 内部的控制器组件(Controller)会发送 LeaderAndIsr 请求给 Broker 0,显示的告诉它,前它不再是 Leader,而是 Follower 了,而 Broker 1 上的 Follower 副本因为被选为新的 Leader,因此停止向 Broker 0 拉取消息。
这时,一个尴尬的场面就出现了:如果刚才积压的 PRODUCE 请求都设置了 acks=all(也就是要等所有(本例中的两个)副本都完成了该请求),那么这些在LeaderAndIsr发送之前的请求都无法完成了,就像前面说的,它们会被暂存在 Purgatory 中不断重试,直到最终请求超时返回给客户端。
设想一下:如果 Kafka 能够优先处理 LeaderAndIsr 请求,Broker 0 就会立刻抛出 NOT_LEADER_FOR_PARTITION 异常,快速地标识这些积压 PRODUCE 请求已失败,这样客户端不用等到 Purgatory 中的请求超时就能立刻感知,从而降低了请求的处理时间。
即使 acks 不是 all,积压的 PRODUCE 请求能够成功写入 Leader 副本的日志,但处理LeaderAndIsr之后,Broker0上的leader变为follwer副本,也要执行显示的日志截断(Log Truncation,即原 Leader 副本成为 Follower 后,写入但未提交的消息全部删除),依然做了很多无用功
再举一个例子,同样是在积压大量数据类请求的 Broker 上,当你删除主题的时候,Kafka 控制器(我会在专栏后面的内容中专门介绍它)向该 Broker 发送 StopReplica 请求。如果该请求不能及时处理,主题删除操作会一直 hang 住,从而增加了删除主题的延时。
基于这些问题,那么社区是如何解决的呢?很简单,你可以再看一遍今天的第三张图,社区完全拷贝了这张图中的一套组件,实现了两类请求的分离。也就是说,Kafka Broker 启动后,会在后台分别创建两套网络线程池和 IO 线程池的组合,它们分别处理数据类请求和控制类请求。至于所用的 Socket 端口,自然是使用不同的端口了,你需要提供不同的 listeners 配置,显式地指定哪套端口用于处理哪类请求。
总结:就是开了两个socket端口,提供不同的配置,但是执行逻辑是差不多的,每套都是网络线程池+IO线程池
参考:
Kafka核心技术与实战