面試場景題:一次關於執行緒池使用場景的討論。

why技术發表於2024-08-19

你好呀,我是歪歪。

來一起看看一個關於執行緒池使用場景上的問題,就當是個場景面試題了。

問題是這樣的:

字有點多,我直接給你上個圖你就懂了:

前端發起一個生成報表頁面的請求,這個頁面上的資料由後端多個介面返回,另外由於微服務化了,所以資料散落在每個微服務中,因此需要呼叫多個下游介面拿到資料進行整合。

呼叫多個下游介面的時候,由於介面之間不存在資料依賴,所以可以發起非同步呼叫同時請求不同的下游介面。

也就是這裡有個執行緒池:

這個執行緒池,核心執行緒數只有 30 個,最大執行緒數是 100,佇列長度是 1000。

一個報表頁面的請求發過來,為了整合資料,所以會呼叫下游 20 多個介面獲取資料。

針對這個情況,用提問者的原話就是:3 個人同時開啟,相應速度就會變慢了,因為任務超過空閒的核心執行緒數,就被放阻塞佇列了。

理論確實是這個理論。

該怎麼辦呢?

針對這個場景,大家能想到的一個最直接的一個方案,肯定是擴大核心執行緒數。

這個方案,提問者也想到了,同時還進行了進一步思考:

覺得應該改造一下 Spring 的這個執行緒池的工作模式,為了讓請求儘快的得到處理,可以借鑑 Tomcat 執行緒池的工作模式。

Spring 這個執行緒池的工作模式是先啟用核心執行緒,再啟用佇列,最後啟用非核心執行緒。

Tomcat 執行緒池的工作模式是先啟用核心執行緒,再啟用非核心執行緒,最後啟用佇列。

思路是不錯的,但是吧,我覺得在這個場景下,沒啥必要。

比如 Tomcat 執行緒池,你的配置是核心執行緒數 30,非核心執行緒數 300,佇列長度 1000。

其實你配置 Spring 執行緒池的時候,核心執行緒數 300,非核心執行緒數 300,佇列長度 1000,效果和 Tomcat 執行緒池是一樣的。

唯一的一點區別在非核心執行緒的回收,但是這一點點記憶體上的佔用,微乎其微,我個人覺得是可以忽略不計的。

另外,線上程池配置方面,除了調整核心執行緒數外,還有一個常用的配置是修改執行緒池的拒絕策略,採用 CallerRunsPolicy,即線上程池滿了的情況下,讓任務呼叫者執行緒執行該任務。

這個方案在討論的過程中也有提及到:

那這個方案可以用嗎?

可以,但是需要注意一個暗坑的存在。

在任務提交執行緒池之後,Tomcat 的執行緒有兩種情況:

  • 情況一:請求結束,返給前端,回到 Tomcat 執行緒池處理新請求。
  • 情況二:等著下游的返回,然後繼續執行。

我們先看情況一,你想想,在這個場景下,CallerRun,這個 Runner 是誰?

是 Tomcat 容器的執行緒。

好,現在我們來想象一下這個場景:你有一個自定義執行緒池,但是由於下游請求中有個慢介面,導致自定義的執行緒池滿了,觸發了拒絕策略。

這個時候拒絕策略是 CallerRunsPolicy。

於是 Tomcat 執行緒池裡面的一個執行緒就需要去呼叫這個慢介面,導致本來在提交任務到執行緒池之後就返回的 Tomcat 的執行緒被拿去呼叫慢介面了,產生了較長時間佔用。

那麼會出現一個什麼情況?

就是關鍵資源被長時間霸佔,嚴重的情況下,服務就對外不可用了。

你想想,假設 Tomcat 一共就 200 個執行緒。

其中 190 個都被你這個慢介面拖著了,只剩下 10 個執行緒能對外提供服務。

甚至有可能 200 個都被這個慢介面拖著,而你這個服務,對外肯定不只是這一個介面吧?

其他介面會因為,慢介面裡面的執行緒池的拒絕策略是 CallerRunsPolicy,把資源全部佔用完了,從而受到影響。

即使你其他功能的一個介面耗時只需要 10ms 也沒用,也要去佇列裡面等著,因為現在沒有資源來處理你這個請求。

而對於介面呼叫方來說,進入佇列等待的時候,也算在介面響應耗時裡面。

所以,在使用 CallerRunsPolicy 拒絕策略的時候,需要特別注意,分析一下是否會佔用關鍵資源,導致拖慢這個服務。

但是我們這個報表的場景,屬於情況二,Tomcat 的執行緒得等著資料返回。

等著,本來也是一種佔用。所以使用 CallerRunsPolicy 沒啥問題。

但是,Tomcat 執行緒屬於寶貴資源,如果出現長時間佔用,那就是一個效能瓶頸點。

所以,本質上還是不應該有明顯的慢介面存在。

關於“儘快釋放寶貴資源”這個點,你也可以看 Dubbo 服務段執行緒模型,這裡相當於是一個最佳實踐了:

https://cn.dubbo.apache.org/zh-cn/overview/mannual/java-sdk/advanced-features-and-usage/performance/threading-model/provider/

Dubbo 協議 Provider 執行緒模型的預設配置是 AllDispatcher。

針對 AllDispatcher 官方給了一個示意圖:

圖中有 IO thread pool 和 Dubbo thread pool 這兩個執行緒池。

為什麼要搞兩個執行緒池呢?

因為 IO 執行緒是非常寶貴的資源,它只是應該承擔傳送請求、傳送響應的功能。

搞個 Dubbo 執行緒池的目的就是為了儘快釋放寶貴的 IO 執行緒資源。

比如 received、connected、disconnected、caught 這些行為都是在 Dubbo 執行緒上執行的,反序列化的動作也是在 Dubbo 的執行緒池中做的。

類比到我們前面的例子中,IO 執行緒池就是 Tomcat 執行緒池,Dubbo 執行緒池就是我們專案中的自定義執行緒池。

模式,就是這個模式。

道理,就是這個道理。

繼續挖

如果這真的是一個面試場景題,增加核心執行緒數和 CallerRunsPolicy 這個方案,面試官肯定是不會滿意的,你還得繼續往下挖掘。

比如,為什麼下游返回的那麼慢?

是不是介面上有可以最佳化的空間?

是不是有慢 SQL?

是不是多返回了不需要的資訊?

是不是有不合理的資料結構?

是不是在介面裡面幹了一些其他的事情?

是不是下游的下游拉胯了?

...

不要老是從自己身上找原因,也合理指出其他人的問題,對吧?

總之,20 多個非同步介面,一定是有相對較慢的那個。

它,就是那塊短板,找到它,然後定向分析它。

如果下游說實在是沒有最佳化空間了,那就加點錢嘛,多搞幾臺機器,橫向擴充套件一下,花不了幾個錢的。

這種情況在實際工作中還真的挺常見的,歪師傅就遇到過。

上游服務有 8 臺機器,我只有 4 臺機器,上游併發量一起來,就說我介面響應慢了。

機器都差了一倍,請求來得又太多,都堆起來了,那可不得慢嘛。

當然了,把鍋甩給下游,下游不一定會接,問題還是得靠自己解決。

透過前面的分析,我們知道了可以調大核心執行緒數,但是面試官直接追問一句,調到多少合適呢?

合適,就是一個很微妙的詞了。

一般我們用“動態調整”來應對這個問題。

但是在這個“報表”的場景下,歪師傅覺得還真的可以調到一個合適的值,甚至可以用“精準”來形容這個值。

怎麼做呢?

上個圖:

在每個介面裡面搞個執行緒池,這個執行緒池的生命週期和一次請求繫結。

即一次請求結束,這個執行緒池就 shutdown 掉。

你一個介面背後需要呼叫下游的多少個非同步介面,你在寫的時候是知道的。

假設是 15 個,那麼你在這個請求裡面搞個核心執行緒數是 15 個的執行緒池。

我就問你,精不精準?

這種用法,就比較適用於這個較為特殊的場景。

特殊點就在於,需要對資料進行聚合處理,所以需要非同步呼叫多個下游服務,要拿到下游服務的資料返回之後,才能返回給呼叫方。

但是這裡上游服務發起呼叫具有不確定性,可能是同時來 10 個請求,也可能是同時來 1000 個請求。

這種情況導致怎麼去合理的定義一個全域性的執行緒池,是一個令人頭疼的問題。

所以,換個思路。

在不確定性中尋找確定性。

不確定性是不知道有多少個請求會過來。確定性是每個前端過來的請求,都會對應固定數量的下游介面。

那就不要用全域性的執行緒池了,給每個請求都單獨搞個執行緒池,及時建立,及時回收。

當然,這個方案的弊端之一在於不存線上程池的複用了,只是為了單純的非同步。

弊端之二就是有可能瞬間產生大量的執行緒,對記憶體造成一定的壓力,但是理論上這些執行緒都會被很快的回收,所有這點壓力應該是在可以接受的範圍內。

但是你想想,你調大核心執行緒數的根本目的是為了給每個非同步任務都分配一個執行緒。

我上面的這個方案能達到一樣的目的,而且,控制更加精準。

接著挖

其實你有沒有發現前面說的調整核心數、一個請求對應一個執行緒池這些方案都很彆扭,感覺都不得勁兒?

是的,我就是有這樣的感覺。

所以,我再看看問題:

“類似於報表的系統”。

如果我是剛剛工作三年的時候,我可能會在接到這個需求之後就去思考技術方案。

但是現在隨著工作年限的增加,我會帶著“質疑”的眼光去看待需求,去判斷是否是一個“偽需求”。

在需求和技術落地之間找到一個平衡點,讓業務和開發都舒服一點。

比如既然是報表為什麼要求要實時響應呢?

前端發起請求,後端收到請求之後先返回前端,給個提示:哥們,收到你的請求了,生成報表需要點時間,請十分鐘後到 xx 選單下訪問。

然後你後臺慢慢處理,其實五分鐘就生成好了,然後你給哥們發個簡訊提醒:資料已就位。

別人還會覺得:可以啊,挺快的,科技的哥們真厲害。

再說了,報表一般來說不都是 T-1 日的資料展示嗎?

既然是 T-1 日的資料,那為什麼不在凌晨做個定時任務,先主動把各個系統前一日的資料聚合一下,在本地放一份呢?

這樣就不用在前端呼叫的時候,實時去呼叫介面聚合了嘛。查本地資料,那不是很快的事情,效能一下就上去了。

或者再往前想一步:為什麼你要去呼叫別的系統的介面去獲取資料呢?

因為你自己系統沒有資料。

為什麼你自己系統沒有資料呢?

因為你們是微服務架構,資料散落在各個微服務系統裡面。

那在拆分微服務的時候,有沒有考慮過各種各樣的報表需求?

如果考慮過,是不是就應該建設一個大資料平臺,由大資料平臺將各個微服務系統的業務資料抽走,然後整合資料,基於這些資料出各種各樣的報表。

而微服務系統,只需要關注業務就好了。

如果你們沒有大資料平臺,你應該給領導充分闡述該平臺在當下的必要性,以及未來的重要性。

然後,這個功能就不需要你來做了。

相關文章