面試官:一個 SpringBoot 專案能處理多少請求?(小心有坑)

2023-07-17 15:03:00

你好呀,我是歪歪。

這篇文章帶大家盤一個讀者遇到的面試題哈。

根據讀者轉述,面試官的原問題就是:一個 SpringBoot 專案能同時處理多少請求?

不知道你聽到這個問題之後的第一反應是什麼。

我大概知道他要問的是哪個方向,但是對於這種只有一句話的面試題,我的第一反應是:會不會有坑?

所以並不會貿然答題,先追問一些訊息,比如:這個專案具體是幹什麼的?專案大概進行了哪些引數設定?使用的 web 容器是什麼?部署的伺服器設定如何?有哪些介面?介面響應平均時間大概是多少?

這樣,在幾個問題的拉扯之後,至少在面試題考察的方向方面能基本和麵試官達成了一致。

比如前面的面試問題,經過幾次拉扯之後,面試官可能會修改為:

一個 SpringBoot 專案,未進行任何特殊設定,全部採用預設設定,這個專案同一時刻,最多能同時處理多少請求?

能處理多少呢?

我也不知道,但是當問題變成上面這樣之後,我找到了探索答案的角度。

既然「未進行任何特殊設定」,那我自己搞個 Demo 出來,壓一把不就完事了嗎?

坐穩扶好,準備發車。

Demo

小手一抖,先搞個 Demo 出來。

這個 Demo 非常的簡單,就是通過 idea 建立一個全新的 SpringBoot 專案就行。

我的 SpringBoot 版本使用的是 2.7.13。

整個專案只有這兩個依賴:

整個專案也只有兩個類,要得就是一個空空如也,一清二白。

專案中的 TestController,裡面只有一個 getTest 方法,用來測試,方法裡面接受到請求之後直接 sleep 一小時。

目的就是直接把當前請求執行緒佔著,這樣我們才能知道專案中一共有多少個執行緒可以使用:

@Slf4j
@RestController
public class TestController {

    @GetMapping("/getTest")
    public void getTest(int num) throws Exception {
        log.info("{} 接受到請求:num={}", Thread.currentThread().getName(), num);
        TimeUnit.HOURS.sleep(1);
    }
}

專案中的 application.properties 檔案也是空的:

這樣,一個「未進行任何特殊設定」的 SpringBoot 不就有了嗎?

基於這個 Demo,前面的面試題就要變成了:我短時間內不斷的呼叫這個 Demo 的 getTest 方法,最多能呼叫多少次?

問題是不是又變得更加簡單了一點?

那麼前面這個「短時間內不斷的呼叫」,用程式碼怎麼表示呢?

很簡單,就是在迴圈中不斷的進行介面呼叫就行了。

public class MainTest {
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            new Thread(() -> {
                HttpUtil.get("127.0.0.1:8080/getTest?num=" + finalI);
            }).start();
        }
        //阻塞主執行緒
        Thread.yield();
    }
}

當然了,這個地方你用一些壓測工具,比如 jmeter 啥的,會顯得逼格更高,更專業。我這裡就偷個懶,直接上程式碼了。

答案

經過前面的準備工作,Demo 和測試程式碼都就緒了。

接下來就是先把 Demo 跑起來:

然後跑一把 MainTest。

當 MainTest 跑起來之後,Demo 這邊就會快速的、大量的輸出這樣的紀錄檔:

也就是我前面 getTest 方法中寫的紀錄檔:

好,現在我們回到這個問題:

我短時間內不斷的呼叫這個 Demo 的 getTest 方法,最多能呼叫多少次?

來,請你告訴我怎麼得到這個問題的答案?

我這裡就是一個大力出奇跡,直接統計「接受到請求」關鍵字在紀錄檔中出現的次數就行了:

很顯然,答案就是:

所以,當面試官問你:一個 SpringBoot 專案能同時處理多少請求?

你裝作仔細思考之後,篤定的說:200 次。

面試官微微點頭,並等著你繼續說下去。

你也暗自歡喜,幸好看了歪歪歪師傅的文章,背了個答案。然後等著面試官繼續問其他問題。

氣氛突然就尷尬了起來。

接著,你就回家等通知了。

200 次,這個回答是對的,但是你只說 200 次,這個回答就顯得有點尬了。

重要的是,這個值是怎麼來的?

所以,下面這一部分,你也要背下來。

怎麼來的?

在開始探索怎麼來的之前,我先問你一個問題,這個 200 個執行緒,是誰的執行緒,或者說是誰在管理這個執行緒?

是 SpringBoot 嗎?

肯定不是,SpringBoot 並不是一個 web 容器。

應該是 Tomcat 在管理這 200 個執行緒。

這一點,我們通過執行緒 Dump 也能進行驗證:

通過執行緒 Dump 檔案,我們可以知道,大量的執行緒都在 sleep 狀態。而點選這些執行緒,檢視其堆疊訊息,可以看到 Tomcat、threads、ThreadPoolExecutor 等關鍵字:

at org.apache.Tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1791)
at org.apache.Tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.Tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
at org.apache.Tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
at org.apache.Tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)

基於「短時間內有 200 個請求被立馬處理的」這個現象,結合你背的滾瓜爛熟的、非常紮實的執行緒池知識,你先大膽的猜一個:Tomcat 預設核心執行緒數是 200。

接下來,我們就是要去原始碼裡面驗證這個猜測是否正確了。

我之前分享過閱讀原始碼的方式,《我試圖通過這篇文章,教會你一種閱讀原始碼的方式。》,其中最重要的一條就是打一個有效的斷點,然後基於斷點處的呼叫棧去定位原始碼。

這裡我再教你一個不用打斷點也能獲取到呼叫棧的方法。

在前面已經展示過了,就是執行緒 Dump。

右邊就是一個執行緒完整的呼叫棧:

從這個呼叫棧中,由於我們要找的是 Tomcat 執行緒池相關的原始碼,所以第一次出現相關關鍵字的地方就是這一行:

org.apache.Tomcat.util.threads.ThreadPoolExecutor.Worker#run

然後我們在這一行打上斷點。

重啟專案,開始偵錯。

進入 runWorker 之後,這部分程式碼看起來就非常眼熟了:

簡直和 JDK 裡面的執行緒池原始碼一模一樣。

如果你熟悉 JDK 執行緒池原始碼的話,偵錯 Tomcat 的執行緒池,那個感覺,就像是回家一樣。

如果你不熟悉的話,我建議你儘快去熟悉熟悉。

隨著斷點往下走,在 getTask 方法裡面,可以看到關於執行緒池的幾個關鍵引數:

org.apache.Tomcat.util.threads.ThreadPoolExecutor#getTask

corePoolSize,核心執行緒數,值為 10。

maximumPoolSize,最大執行緒數,值為 200。

而且基於 maximumPoolSize 這個引數,你往前翻程式碼,會發現這個預設值就是 200:

好,到這裡,你發現你之前猜測的「Tomcat 預設核心執行緒數是 200」是不對的。

但是你一點也不慌,再次結合你背的滾瓜爛熟的、非常紮實的執行緒池知識。

並在心裡又默唸了一次:當執行緒池接受到任務之後,先啟用核心執行緒數,再使用佇列長度,最後啟用最大執行緒數。

因為我們前面驗證了,Tomcat 可以同時間處理 200 個請求,而它的執行緒池核心執行緒數只有 10,最大執行緒數是 200。

這說明,我前面這個測試用例,把佇列給塞滿了,從而導致 Tomcat 執行緒池啟用了最大執行緒數:

嗯,一定是這樣的!

那麼,現在的關鍵問題就是:Tomcat 執行緒池預設的佇列長度是多少呢?

在當前的這個 Debug 模式下,佇列長度可以通過 Alt+F8 進行檢視:

wc,這個值是 Integer.MAX_VALUE,這麼大?

我一共也才 1000 個任務,不可能被佔滿啊?

一個執行緒池:

  • 核心執行緒數,值為 10。
  • 最大執行緒數,值為 200。
  • 佇列長度,值為 Integer.MAX_VALUE。

1000 個比較耗時的任務過來之後,應該是隻有 10 個執行緒在工作,然後剩下的 990 個進佇列才對啊?

難道我八股文背錯了?

這個時候不要慌,嗦根辣條冷靜一下。

目前已知的是核心執行緒數,值為 10。這 10 個執行緒的工作流程是符合我們認知的。

但是第 11 個任務過來的時候,本應該進入佇列去排隊。

現在看起來,是直接啟用最大執行緒數了。

所以,我們先把測試用例修改一下:

那麼問題就來了:最後一個請求到底是怎麼提交到執行緒池裡面的?

前面說了,Tomcat 的執行緒池原始碼和 JDK 的基本一樣。

往執行緒池裡面提交任務的時候,會執行 execute 這個方法:

org.apache.Tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable)

對於 Tomcat 它會呼叫到 executeInternal 這個方法:

org.apache.Tomcat.util.threads.ThreadPoolExecutor#executeInternal

這個方法裡面,標號為 ① 的地方,就是判斷當前工作執行緒數是否小於核心執行緒數,小於則直接呼叫 addWorker 方法,建立執行緒。

標號為 ② 的地方主要是呼叫了 offer 方法,看看佇列裡面是否還能繼續新增任務。

如果不能繼續新增,說明佇列滿了,則來到標號為 ③ 的地方,看看是否能執行 addWorker 方法,建立非核心執行緒,即啟用最大執行緒數。

把這個邏輯捋順之後,接下來我們應該去看哪部分的程式碼,就很清晰了。

主要就是去看 workQueue.offer(command) 這個邏輯。

如果返回 true 則表示加入到佇列,返回 false 則表示啟用最大執行緒數嘛。

這個 workQueue 是 TaskQueue,看起來一點也不眼熟:

當然不眼熟了,因為這個是 Tomcat 自己基於 LinkedBlockingQueue 搞的一個佇列。

問題的答案就藏在 TaskQueue 的 offer 方法裡面。

所以我重點帶你盤一下這個 offer 方法:

org.apache.Tomcat.util.threads.TaskQueue#offer

標號為 ① 的地方,判斷了 parent 是否為 null,如果是則直接呼叫父類別的 offer 方法。說明要啟用這個邏輯,我們的 parent 不能為 null。

那麼這個 parent 是什麼玩意,從哪裡來的呢?

parent 就是 Tomcat 執行緒池,通過其 set 方法可以知道,是線上程池完成初始化之後,進行了賦值。

也就是說,你可以理解為,在 Tomcat 的場景下,parent 不會為空。

標號為 ② 的地方,呼叫了 getPoolSizeNoLock 方法:

這個方法是獲取當前執行緒池中有多個執行緒。

所以如果這個表示式為 true:

parent.getPoolSizeNoLock() == parent.getMaximumPoolSize()

就表明當前執行緒池的執行緒數已經是設定的最大執行緒數了,那就呼叫 offer 方法,把當前請求放到到佇列裡面去。

標號為 ③ 的地方,是判斷已經提交到執行緒池裡面待執行或者正在執行的任務個數,是否比當前執行緒池的執行緒數還少。

如果是,則說明當前執行緒池有空閒執行緒可以執行任務,則把任務放到佇列裡面去,就會被空閒執行緒給取走執行。

然後,關鍵的來了,標號為 ④ 的地方。

如果當前執行緒池的執行緒數比執行緒池設定的最大執行緒數還少,則返回 false。

前面說了,offer 方法返回 false,會出現什麼情況?

是不是直接開始到上圖中標號為 ③ 的地方,去嘗試新增非核心執行緒了?

也就是啟用最大執行緒數這個設定了。

所以,朋友們,這個是什麼情況?

這個情況確實就和我們背的執行緒池的八股文不一樣了啊。

JDK 的執行緒池,是先使用核心執行緒數設定,接著使用佇列長度,最後再使用最大執行緒設定。

Tomcat 的執行緒池,就是先使用核心執行緒數設定,再使用最大執行緒設定,最後才使用佇列長度。

所以,以後當面試官給你說:我們聊聊執行緒池的工作機制吧?

你就先追問一句:你是說的 JDK 的執行緒池呢還是 Tomcat 的執行緒池呢,因為這兩個在執行機制上有一點差異。

然後,你就看他的表情。

如果透露出一絲絲遲疑,然後輕描淡寫的說一句:那就對比著說一下吧。

那麼恭喜你,在這個題目上開始掌握了一點主動權。

最後,為了讓你更加深刻的理解到 Tomcat 執行緒池和 JDK 執行緒池的不一樣,我給你搞一個直接複製過去就能執行的程式碼。

當你把 taskqueue.setParent(executor) 這行程式碼註釋掉的時候,它的執行機制就是 JDK 的執行緒池。

當存在這行程式碼的時候,它的執行機制就變成了 Tomcat 的執行緒池。

玩去吧。

import org.apache.tomcat.util.threads.TaskQueue;
import org.apache.tomcat.util.threads.TaskThreadFactory;
import org.apache.tomcat.util.threads.ThreadPoolExecutor;

import java.util.concurrent.TimeUnit;

public class TomcatThreadPoolExecutorTest {

    public static void main(String[] args) throws InterruptedException {
        String namePrefix = "歪歪歪-exec-";
        boolean daemon = true;
        TaskQueue taskqueue = new TaskQueue(300);
        TaskThreadFactory tf = new TaskThreadFactory(namePrefix, daemon, Thread.NORM_PRIORITY);
        ThreadPoolExecutor executor = new ThreadPoolExecutor(5,
                150, 60000, TimeUnit.MILLISECONDS, taskqueue, tf);
        taskqueue.setParent(executor);
        for (int i = 0; i < 300; i++) {
            try {
                executor.execute(() -> {
                    logStatus(executor, "建立任務");
                    try {
                        TimeUnit.SECONDS.sleep(2);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        Thread.currentThread().join();
    }

    private static void logStatus(ThreadPoolExecutor executor, String name) {
        TaskQueue queue = (TaskQueue) executor.getQueue();
        System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
                "核心執行緒數:" + executor.getCorePoolSize() +
                "\t活動執行緒數:" + executor.getActiveCount() +
                "\t最大執行緒數:" + executor.getMaximumPoolSize() +
                "\t總任務數:" + executor.getTaskCount() +
                "\t當前排隊執行緒數:" + queue.size() +
                "\t佇列剩餘大小:" + queue.remainingCapacity());
    }
}

等等

如果你之前確實沒了解過 Tomcat 執行緒池的工作機制,那麼看到這裡的時候也許你會覺得確實是有一點點收穫。

但是,注意我要說但是了。

還記得最開始的時候面試官的問題嗎?

面試官的原問題就是:一個 SpringBoot 專案能同時處理多少請求?

那麼請問,前面我講了這麼大一坨 Tomcat 執行緒池執行原理,這個回答,和這個問題匹配嗎?

是的,除了最開始提出的 200 這個數值之外,並不匹配,甚至在面試官的眼裡完全是答非所問了。

所以,為了把這兩個「並不匹配」的東西比較順暢的連結起來,你必須要先回答面試官的問題,然後再開始擴充套件。

比如這樣答:一個未進行任何特殊設定,全部採用預設設定的 SpringBoot 專案,這個專案同一時刻最多能同時處理多少請求,取決於我們使用的 web 容器,而 SpringBoot 預設使用的是 Tomcat。

Tomcat 的預設核心執行緒數是 10,最大執行緒數 200,佇列長度是無限長。但是由於其執行機制和 JDK 執行緒池不一樣,在核心執行緒數滿了之後,會直接啟用最大執行緒數。所以,在預設的設定下,同一時刻,可以處理 200 個請求。

在實際使用過程中,應該基於服務實際情況和伺服器設定等相關訊息,對該引數進行評估設定。

這個回答就算是差不多了。

但是,如果很不幸,如果你遇到了我,為了驗證你是真的自己去摸索過,還是僅僅只是看了幾篇文章,我可能還會追問一下:

那麼其他什麼都不動,如果我僅僅加入 server.tomcat.max-connections=10 這個設定呢,那麼這個時候最多能處理多少個請求?

你可能就要猜了:10 個。

是的,我重新提交 1000 個任務過來,在控制檯輸出的確實是 10 個,

那麼 max-connections 這個引數它怎麼也能控制請求個數呢?

為什麼在前面的分析過程中我們並沒有注意到這個引數呢?

首先我們看一下它的預設值:

因為它的預設值是 8192,比最大執行緒數 200 大,這個引數並沒有限制到我們,所以我們沒有關注到它。

當我們把它調整為 10 的時候,小於最大執行緒數 200,它就開始變成限制項了。

那麼 max-connections 這個引數到底是幹啥的呢?

你先自己去摸索摸索吧。

同時,還有這樣的一個引數,預設是 100:

server.tomcat.accept-count=100

它又是幹什麼的呢?

「和連線數有關」,我只能提示到這裡了,自己去摸索吧。

再等等

通過前面的分析,我們知道了,要回答「一個 SpringBoot 專案預設能處理的任務數」,這個問題,得先明確其使用的 web 容器。

那麼問題又來了:SpringBoot 內建了哪些容器呢?

Tomcat、Jetty、Netty、Undertow

前面我們都是基於 Tomcat 分析的,如果我們換一個容器呢?

比如換成 Undertow,這個玩意我只是聽過,沒有實際使用過,它對我來說就是一個黑盒。

管它的,先換了再說。

從 Tomcat 換成 Undertow,只需要修改 Maven 依賴即可,其他什麼都不需要動:

再次啟動專案,從紀錄檔可以發現已經修改為了 Undertow 容器:

此時我再次執行 MainTest 方法,還是提交 1000 個請求:

從紀錄檔來看,發現只有 48 個請求被處理了。

就很懵逼,48 是怎麼回事兒,怎麼都不是一個整數呢,這讓強迫症很難受啊。

這個時候你的想法是什麼,是不是想要看看 48 這個數位到底是從哪裡來的?

怎麼看?

之前找 Tomcat 的 200 的時候不是才教了你的嘛,直接往 Undertow 上套就行了嘛。

打執行緒 Dump,然後看堆疊訊息:

發現 EnhancedQueueExecutor 這個執行緒池,接著在這個類裡面去找構建執行緒池時的引數。

很容易就找到了這個構造方法:

所以,在這裡打上斷點,重啟專案。

通過 Debug 可以知道,關鍵引數都是從 builder 裡面來的。

而 builder 裡面,coreSize 和 maxSize 都是 48,佇列長度是 Integer.MAX_VALUE。

所以看一下 Builder 裡面的 coreSize 是怎麼來的。

點過來發現 coreSize 的預設值是 16:

不要慌,再打斷點,再重啟專案。

然後你會在它的 setCorePoolSize 方法處停下來,而這個方法的入參就是我們要找的 48:

順藤摸瓜,重複幾次打斷點、重啟的動作之後,你會找到 48 是一個名為 WORKER_TASK_CORE_THREADS 的變數,是從這裡來的:

而 WORKER_TASK_CORE_THREADS 這個變數設定的地方是這樣的:

io.undertow.Undertow#start

而這裡的 workerThreads 取值是這樣的:

io.undertow.Undertow.Builder#Builder

取的是機器的 CPU 個數乘以 8。

所以我這裡是 6*8=48。

哦,真相大白,原來 48 是這樣來的。

沒意思。

確實沒意思,但是既然都已經替換為 Undertow 了,那麼你去研究一下它的 NIO ByteBuffer、NIO Channel、BufferPool、XNIO Worker、IO 執行緒池、Worker 執行緒池...

然後再和 Tomcat 對比著學,

就開始有點意思了。

最後再等等

這篇文章是基於「一個 SpringBoot 專案能同時處理多少請求?」這個面試題出發的。

但是經過我們前面簡單的分析,你也知道,這個問題如果在沒有加一些特定的前提條件的情況下,答案是各不一樣的。

比如我再給你舉一個例子,還是我們的 Demo,只是使用一下 @Async 註解,其他什麼都不變:

再次啟動專案,發起存取,紀錄檔輸出變成了這樣:

同時能處理的請求,直接從 Tomcat 的預設 200 個變成了 8 個?

因為 @Async 註解對應的執行緒池,預設的核心執行緒數是 8。

之前寫過這篇文章《別問了,我真的不喜歡@Async這個註解!》分析過這個註解。

所以你看,稍微一變化,答案看起來又不一樣了,同時這個請求在內部流轉的過程也不一樣了,又是一個可以鋪開談的點。

在面試過程中也是這樣的,不要急於答題,當你覺得面試官問題描述的不清楚的地方,你可以先試探性的問一下,看看能不能挖掘出一點他沒有說出來的預設條件。

當「預設條件」挖掘的越多,你的回答就會更容易被面試官接受。而這個挖掘的過程,也是面試過程中一個重要的表現環節。

而且,有時候,面試官就喜歡給出這樣的「模糊」的問題,因為問題越模糊,坑就越多,當面試者跳進自己挖好的坑裡面的時候,就是結束一次交鋒的時候;當面試者看出來自己挖好的坑,並繞過去的時候,也是結束一輪交鋒的時候。

所以,不要急於答題,多想,多問。不管是對於面試者還是面試官,一個好的面試體驗,一定不是沒有互動的一問一答,而是一個相互拉鋸的過程。