先把这个面试题拿出来一下:

1000 多个并发线程,10 台机器,每台机器 4 核,设计线程池大小。

这题给的信息非常的简陋,但是简陋的好处就是想象空间足够大。

第一眼看到这题的时候,我直观的感受到了两个考点:

  1. 线程池设计。

  2. 负载均衡策略。

我就开门见山的给你说了,这两个考点,刚好都在我之前的文章的射程范围之内:

《如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答》

《吐血输出:2万字长文带你细细盘点五种负载均衡策略》

下面我会针对我感受到的这两个考点去进行分析。

线程池设计

我们先想简单一点:1000 个并发线程交给 10 台机器去处理,那么 1 台机器就是承担 100 个并发请求。

100 个并发请求而已,确实不多。

而且他也没有说是每 1 秒都有 1000 个并发线程过来,还是偶尔会有一次  1000 个并发线程过来。

先从线程池设计的角度去回答这个题。

要回答好这个题目,你必须有两个最基本的知识贮备:

  1. 自定义线程池的 7 个参数。

  2. JDK 线程池的执行流程。

先说第一个,自定义线程池的 7 个参数。

java.util.concurrent.ThreadPoolExecutor#ThreadPoolExecutor

              

害,这 7 个参数我真的都不想说了,你去翻翻历史文章,我都写过多少次了。你要是再说不出个头头是道的,你都对不起我写的这些文章。

而且这个类上的 javadoc 已经写的非常的明白了。这个 javadoc 是 Doug Lea 老爷子亲自写的,你都不拜读拜读?

为了防止你偷懒,我把老爷子写的粘下来,我们一句句的看。

  1. corePoolSize:the number of threads to keep in the pool, even if they are idle, unless {@code allowCoreThreadTimeOut} is set (核心线程数大小:不管它们创建以后是不是空闲的。线程池需要保持 corePoolSize 数量的线程,除非设置了 allowCoreThreadTimeOut。)

  2. maximumPoolSize:the maximum number of threads to allow in the pool。(最大线程数:线程池中最多允许创建 maximumPoolSize 个线程。)

  3. keepAliveTime:when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating。(存活时间:如果经过 keepAliveTime 时间后,超过核心线程数的线程还没有接受到新的任务,那就回收。)

  4. unit:the time unit for the {@code keepAliveTime} argument (keepAliveTime 的时间单位。)

  5. workQueue:the queue to use for holding tasks before they are executed.  This queue will hold only the {@code Runnable} tasks submitted by the {@code execute} method。(存放待执行任务的队列:当提交的任务数超过核心线程数大小后,再提交的任务就存放在这里。它仅仅用来存放被 execute 方法提交的 Runnable 任务。所以这里就不要翻译为工作队列了,好吗?不要自己给自己挖坑。)

  6. threadFactory:the factory to use when the executor creates a new thread。(线程工程:用来创建线程工厂。比如这里面可以自定义线程名称,当进行虚拟机栈分析时,看着名字就知道这个线程是哪里来的,不会懵逼。)

  7. handler :the handler to use when execution is blocked because the thread bounds and queue capacities are reached。(拒绝策略:当队列里面放满了任务、最大线程数的线程都在工作时,这时继续提交的任务线程池就处理不了,应该执行怎么样的拒绝策略。)

第一个知识贮备就讲完了,你先别开始背,这玩意你背下来有啥用,你得结合着执行流程去理解。

接下来我们看第二个:JDK 线程池的执行流程。

一图胜千言:

                        

关于 JDK 线程池的 7 个参数和执行流程。

虽然我很久没有参加面试了,但是我觉得这题属于必考题吧。

所以如果你真的还不会,麻烦你写个 Demo ,换几个参数调试一下。把它给掌握了。

而且还得多注意由这些知识点引申出来的面试题。

比如从图片也可以看出来,JDK 线程池中如果核心线程数已经满了的话,那么后面再来的请求都是放到阻塞队列里面去,阻塞队列再满了,才会启用最大线程数。

但是你得知道,假如我们是 web 服务,请求是通过 Tomcat 进来的话,那么 Tomcat 线程池的执行流程可不是这样的。

Tomcat 里面的线程池的运行过程是:如果核心线程数用完了,接着用最大线程数,最后才提交任务到队列里面去的。这样是为了保证响应时间优先。

所以,Tomcat 的执行流程是这样的:

                            

其技术细节就是自己重写了队列的 offer 方法。在这篇文章里面说的很清楚了,大家可以看看:

《每天都在用,但你知道 Tomcat 的线程池有多努力吗?》

好的,前面两个知识点铺垫完成了。

这个题,从线程池设计的角度,我会这样去回答:

前面我们说了,10 个机器,1000 个请求并发,平均每个服务承担 100 个请求。服务器是 4 核的配置。

那么如果是 CPU 密集型的任务,我们应该尽量的减少上下文切换,所以核心线程数可以设置为 5,队列的长度可以设置为 100,最大线程数保持和核心线程数一致。

如果是 IO 密集型的任务,我们可以适当的多分配一点核心线程数,更好的利用 CPU,所以核心线程数可以设置为 8,队列长度还是 100,最大线程池设置为 10。

当然,上面都是理论上的值。

我们也可以从核心线程数等于 5 开始进行系统压测,通过压测结果的对比,从而确定最合适的设置。

同时,我觉得线程池的参数应该是随着系统流量的变化而变化的。

所以,对于核心服务中的线程池,我们应该是通过线程池监控,做到提前预警。同时可以通过手段对线程池响应参数,比如核心线程数、队列长度进行动态修改。

上面的回答总结起来就是四点:

  1. CPU密集型的情况。
  2. IO密集型的情况。
  3. 通过压测得到合理的参数配置。
  4. 线程池动态调整。

前两个是教科书上的回答,记下来就行,面试官想听到这两个答案。

后两个是更具有实际意义的回答,让面试官眼前一亮。

基于这道面试题有限的信息,设计出来的线程池队列长度其实只要大于 100 就可以。

甚至还可以设置的极限一点,比如核心线程数和最大线程数都是 4,队列长度为 96,刚好可以承担这 100 个请求,多一个都不行了。

所以这题我觉得从这个角度来说,并不是要让你给出一个完美的解决方案,而是考察你对于线程池参数的理解和技术的运用。

面试的时候我觉得这个题答到这里就差不多了。

接下来,我们再发散一下。

比如面试官问:如果我们的系统里面没有运用线程池,那么会是怎么样的呢?

首先假设我们开发的系统是一个运行在 Tomcat 容器里面的,对外提供 http 接口的 web 服务。

系统中没有运用线程池相关技术。那么我们可以直接抗住这 100 个并发请求吗?

答案是可以的。

Tomcat 里面有一个线程池。其 maxThreads 默认值是 200(假定 BIO 模式):

maxThreads 用完了之后,进队列。队列长度(acceptCount)默认是 100:

在 BIO 的模式下,Tomcat 的默认配置,最多可以接受到 300 (200+100)个请求。再多就是连接拒绝,connection refused。

所以,你要说处理这 100 个并发请求,那不是绰绰有余吗?

但是,如果是每秒 100 个并发请求,源源不断的过来,那就肯定是吃不消了。

这里就涉及到两个层面的修改:

  1. Tomcat 参数配置的调优。
  2. 系统代码的优化。

针对 Tomcat 参数配置的调优,我们可以适当调大其 maxThreads 等参数的值。

针对系统代码的优化,我们就可以引入线程池技术,或者引入消息队列。总之其目的是增加系统吞吐量。

同理,假设我们是一个 Dubbo 服务,对外提供的是 RPC 接口。

默认情况下,服务端使用的是 fixed 线程池,核心线程池数和最大线程数都是 200。队列长度默认为 0:

那么处理这个 100 个并发请求也是绰绰有余的。

同样,如果是每秒 100 个并发请求源源不断的过来,那么很快就会抛出线程池满的异常:

解决套路其实是和 Tomcat 的情况差不多的,调参数,改系统,加异步。

这个情况下的并发,大多数系统还是抗住的。

面试官还可以接着追问:如果这时由于搞促销活动,系统流量翻了好倍,那你说这种情况下最先出现性能瓶颈的地方是什么?

最先出问题的地方肯定是数据库嘛,对吧。

那么怎么办?

分散压力。分库分表、读写分离这些东西往上套就完事了。

然后在系统入口的地方削峰填谷,引入缓存,如果可以,把绝大部分流量拦截在入口处。

对于拦不住的大批流量,关键服务节点还需要支持服务熔断、服务降级。

实在不行,加钱,堆机器。没有问题是不能通过堆机器解决的,如果有,那么就是你堆的机器不够多。

面试反正也就是这样的套路。看似一个发散性的题目,其实都是有套路可寻的。

好了,第一个角度我觉得我能想到的就是这么多了。

首先正面回答了面试官线程池设计的问题。

然后分情况聊了一下如果我们项目中没有用线程池,能不能直接抗住这 1000 的并发。

最后简单讲了一下突发流量的情况。

接下来,我们聊聊负载均衡。

负载均衡策略

我觉得这个考点虽然稍微隐藏了一下,但还是很容易就挖掘到的。

毕竟题目中已经说了:10 台机器。

而且我们也假设了平均 1 台处理 100 个情况。

这个假设的背后其实就是一个负载均衡策略:轮询负载均衡。

如果负载均衡策略不是轮询的话,那么我们前面的线程池队列长度设计也是有可能不成立的。

还是前面的场景,如果我们是运行在 Tomcat 容器中,假设前面是 nginx,那么 nginx 的负载均衡策略有如下几种:

  1. (加权)轮询负载均衡
  2. 随机负载均衡
  3. 最少连接数负载均衡
  4. 最小响应时间负载均衡
  5. ip_hash负载均衡
  6. url_hash负载均衡

如果是 RPC 服务,以 Dubbo 为例,有下面几种负载均衡策略:

  1. (加权)轮询负载均衡
  2. 随机负载均衡
  3. 最少活跃数负载均衡
  4. 最小响应时间负载均衡
  5. 一致性哈希负载均衡

哦,对了。记得之前还有一个小伙伴问我,在 Dubbo + zookeeper 的场景下,负载均衡是 Dubbo 做的还是 zk 做的?

肯定是 Dubbo 啊,朋友。源码都写在 Dubbo 里面的,zk 只是一个注册中心,关心的是自己管理着几个服务,和这几个服务的上下线。

你要用的时候,我把所有能用的都给你,至于你到底要用那个服务,也就是所谓的负载均衡策略,这不是 zk 关心的事情。

不扯远了,说回来。

假设我们用的是随机负载均衡,我们就不能保证每台机器各自承担 100 个请求了。

这时候我们前面给出的线程池设置就是不合理的。

常见的负载均衡策略对应的优缺点、适用场景可以看这个表格:

关于负载均衡策略,我的《吐血输出:2万字长文带你细细盘点五种负载均衡策略》这篇文章,写了 2 万多字,算是写的很清楚了,这里就不赘述了。

说起负载均衡,我还想起了之前阿里举办的一个程序设计大赛。赛题是《自适应负载均衡的设计实现》。

赛题的背景是这样的:

负载均衡是大规模计算机系统中的一个基础问题。灵活的负载均衡算法可以将请求合理地分配到负载较少的服务器上。

理想状态下,一个负载均衡算法应该能够最小化服务响应时间(RTT),使系统吞吐量最高,保持高性能服务能力。

自适应负载均衡是指无论处在空闲、稳定还是繁忙状态,负载均衡算法都会自动评估系统的服务能力,更好的进行流量分配,使整个系统始终保持较好的性能,不产生饥饿或者过载、宕机。

具体题目和获奖团队答辩可以看这里:

题目:https://tianchi.aliyun.com/competition/entrance/231714/information?spm=a2c22.12849246.1359729.1.6b0d372cO8oYGK

答辩:https://tianchi.aliyun.com/course/video?spm=5176.12586971.1001.1.32de8188ivjLZj&liveId=41090

推荐大家有兴趣的去看一下,还是很有意思的,可以学到很多的东西。

扩展阅读

这一小节,我截取自《分布式系统架构》这本书里面,我觉得这个示例写的还不错,分享给大家:

这是一个购物商场的例子:

系统部署在一台 4C/8G 的应用服务器上、数据在一台 8C/16G 的数据库上,都是虚拟机。

假设系统总用户量是 20 万,日均活跃用户根据不同系统场景稍有区别,此处取 20%,就是 4 万。

按照系统划分二八法则,系统每天高峰算 4 小时,高峰期活跃用户占比 80%,高峰 4 小时内有 3.2 万活跃用户。

每个用户对系统发送请求,如每个用户发送 30 次,高峰期间 3.2 万用户发起的请求是 96 万次,QPS=960 000/(4x60x60)≈67 次请求,每秒处理 67 次请求,处理流程如下图有所示:

一次应用操作数据库增删改查(CRUD)次数平均是操作应用的三倍,具体频率根据系统的操作算平均值即可。一台应用、数据库能处理多少请求呢?

                                        

具体分析如下。

  1. 首先应用、数据库都分别部署在服务器,所以和服务器的性能有直接关系,如 CPU、内存、磁盘存储等。

  2. 应用需要部署在容器里面,如 Tomcat、Jetty、JBoss 等,所以和容器有关系,容器的系统参数、配置能增加或减少处理请求的数目。

  3. Tomcat 部署应用。Tomcat 里面需要分配内存,服务器共 8GB 内存,服务器主要用来部署应用,无其他用途,所以设计 Tomcat 的可用内存为8/2=4GB (物理内存的1/2),同时设置一个线程需要 128KB 的内存。由于应用服务器默认的最大线程数是 1000(可以参考系统配置文件),考虑到系统自身处理能力,调整 Tomcat 的默认线程数至 600,达到系统的最大处理线程能力。到此一台应用最大可以处理 1000 次请求,当超过 1000 次请求时,暂存到队列中,等待线程完成后进行处理。

  4. 数据库用 MySQL。MySQL 中有连接数这个概念,默认是 100 个,1 个请求连接一次数据库就占用 1 个连接,如果 100 个请求同时连接数据库,数据库的连接数将被占满,后续的连接需要等待,等待之前的连接释放掉。根据数据库的配置及性能,可适当调整默认的连接数,本次调整到 500,即可以处理 500 次请求。

显然当前的用户数以及请求量达不到高并发的条件,如果活跃用户从 3.2 万扩大到 32 万,每秒处理 670 次请求,已经超过默认最大的 600 ,此时会出现高并发的情况,高并发分为高并发读操作和高并发写操作。

好了,书上分享的案例就是这样的。


来源:why技术 why技术