You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

112 lines
12 KiB
Markdown

2 years ago
# 24 | 请求是怎么被处理的?
你好我是胡夕。今天我要和你分享的主题是Kafka请求是怎么被处理的。
无论是Kafka客户端还是Broker端它们之间的交互都是通过“请求/响应”的方式完成的。比如客户端会通过网络发送消息生产请求给Broker而Broker处理完成后会发送对应的响应给到客户端。
Apache Kafka自己定义了一组请求协议用于实现各种各样的交互操作。比如常见的PRODUCE请求是用于生产消息的FETCH请求是用于消费消息的METADATA请求是用于请求Kafka集群元数据信息的。
总之Kafka定义了很多类似的请求格式。我数了一下截止到目前最新的2.3版本Kafka共定义了多达45种请求格式。**所有的请求都是通过TCP网络以Socket的方式进行通讯的**。
今天我们就来详细讨论一下Kafka Broker端处理请求的全流程。
## 处理请求的2种常见方案
关于如何处理请求,我们很容易想到的方案有两个。
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模式**。
## Kafka是如何处理请求的
谈到Reactor模式大神Doug Lea的“[Scalable IO in Java](http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)”应该算是最好的入门教材了。即使你没听说过Doug Lea那你应该也用过ConcurrentHashMap吧这个类就是这位大神写的。其实整个java.util.concurrent包都是他的杰作
好了我们说回Reactor模式。简单来说**Reactor模式是事件驱动架构的一种实现方式特别适合应用于处理多个客户端并发向服务器端发送请求的场景**。我借用Doug Lea的一页PPT来说明一下Reactor的架构并借此引出Kafka的请求处理模型。
Reactor模式的架构如下图所示
![](https://static001.geekbang.org/resource/image/5b/3c/5bf8e3e8d35d1ac62yydb092700b683c.jpg?wh=3770*1801)
从这张图中我们可以发现多个客户端会发送请求给到Reactor。Reactor有个请求分发线程Dispatcher也就是图中的Acceptor它会将不同的请求下发到多个工作线程中处理。
在这个架构中Acceptor线程只是用于请求分发不涉及具体的逻辑处理非常得轻量级因此有很高的吞吐量表现。而这些工作线程可以根据实际业务处理需要任意增减从而动态调节系统负载能力。
如果我们来为Kafka画一张类似的图的话那它应该是这个样子的
![](https://static001.geekbang.org/resource/image/b3/81/b3yy7c6ca1c6244f3f9d2268209a9081.jpg?wh=3618*1716)
显然这两张图长得差不多。Kafka的Broker端有个SocketServer组件类似于Reactor模式中的Dispatcher它也有对应的Acceptor线程和一个工作线程池只不过在Kafka中这个工作线程池有个专属的名字叫网络线程池。Kafka提供了Broker端参数num.network.threads用于调整该网络线程池的线程数。其**默认值是3表示每台Broker启动时会创建3个网络线程专门处理客户端发送的请求**。
Acceptor线程采用轮询的方式将入站请求公平地发到所有网络线程中因此在实际使用过程中这些线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单同时也避免了请求处理的倾斜有利于实现较为公平的请求处理调度。
好了你现在了解了客户端发来的请求会被Broker端的Acceptor线程分发到任意一个网络线程中由它们来进行处理。那么当网络线程接收到请求后它是怎么处理的呢你可能会认为它顺序处理不就好了吗实际上Kafka在这个环节又做了一层异步线程池的处理我们一起来看一看下面这张图。
![](https://static001.geekbang.org/resource/image/41/95/41e0a69ed649f9c5yyea390edcd79a95.jpg?wh=3537*2088)
当网络线程拿到请求后它不是自己处理而是将请求放入到一个共享请求队列中。Broker端还有个IO线程池负责从该队列中取出请求执行真正的处理。如果是PRODUCE生产请求则将消息写入到底层的磁盘日志中如果是FETCH请求则从磁盘或页缓存中读取消息。
IO线程池处中的线程才是执行请求逻辑的线程。Broker端参数**num.io.threads**控制了这个线程池中的线程数。**目前该参数默认值是8表示每台Broker启动后自动创建8个IO线程处理请求**。你可以根据实际硬件条件设置此线程池的个数。
比如如果你的机器上CPU资源非常充裕你完全可以调大该参数允许更多的并发请求被同时处理。当IO线程处理完请求后会将生成的响应发送到网络线程池的响应队列中然后由对应的网络线程负责将Response返还给客户端。
细心的你一定发现了请求队列和响应队列的差别:**请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的**。这么设计的原因就在于Dispatcher只是用于请求分发而不负责响应回传因此只能让每个网络线程自己发送Response给客户端所以这些Response也就没必要放在一个公共的地方。
我们再来看看刚刚的那张图图中有一个叫Purgatory的组件这是Kafka中著名的“炼狱”组件。它是用来**缓存延时请求**Delayed Request的。**所谓延时请求,就是那些一时未满足条件不能立刻处理的请求**。比如设置了acks=all的PRODUCE请求一旦设置了acks=all那么该请求就必须等待ISR中所有副本都接收了消息后才能返回此时处理该请求的IO线程就必须等待其他Broker的写入结果。当请求不能立刻处理时它就会暂存在Purgatory中。稍后一旦满足了完成条件IO线程会继续处理该请求并将Response放入对应网络线程的响应队列中。
讲到这里Kafka请求流程解析的故事其实已经讲完了我相信你应该已经了解了Kafka Broker是如何从头到尾处理请求的。但是我们不会现在就收尾我要给今天的内容开个小灶再说点不一样的东西。
## 控制类请求和数据类请求分离
到目前为止我提及的请求处理流程对于所有请求都是适用的也就是说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之后Broker 0上的Leader变为了Follower副本也要执行显式的日志截断Log Truncation即原Leader副本成为Follower后会将之前写入但未提交的消息全部删除依然做了很多无用功。
再举一个例子同样是在积压大量数据类请求的Broker上当你删除主题的时候Kafka控制器我会在专栏后面的内容中专门介绍它向该Broker发送StopReplica请求。如果该请求不能及时处理主题删除操作会一直hang住从而增加了删除主题的延时。
基于这些问题社区于2.3版本正式实现了数据类请求和控制类请求的分离。其实在社区推出方案之前我自己尝试过修改这个设计。当时我的想法是在Broker中实现一个优先级队列并赋予控制类请求更高的优先级。这是很自然的想法所以我本以为社区也会这么实现的但后来我这个方案被清晰地记录在“已拒绝方案”列表中。
究其原因,这个方案最大的问题在于,它无法处理请求队列已满的情形。当请求队列已经无法容纳任何新的请求时,纵然有优先级之分,它也无法处理新的控制类请求了。
那么社区是如何解决的呢很简单你可以再看一遍今天的第三张图社区完全拷贝了这张图中的一套组件实现了两类请求的分离。也就是说Kafka Broker启动后会在后台分别创建两套网络线程池和IO线程池的组合它们分别处理数据类请求和控制类请求。至于所用的Socket端口自然是使用不同的端口了你需要提供不同的**listeners配置**,显式地指定哪套端口用于处理哪类请求。
## 小结
讲到这里Kafka Broker请求处理流程的解析应该讲得比较完整了。明确请求处理过程的最大意义在于它是你日后执行Kafka性能优化的前提条件。如果你能从请求的维度去思考Kafka的工作原理你会发现优化Kafka并不是一件困难的事情。
## 开放讨论
坦白来讲,我对社区否定优先级队列方案是有一点不甘心的。如果是你的话,你觉得应该如何规避优先级队列方案中队列已满的问题呢?
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。