昨天把三大技術選型攤在桌上時,我留了一個沒拆開的選擇:Portal 的 web 層走的是 reactive,不是大家最熟悉的那套「一個請求配一條執行緒」。當時只下了結論——這個閘道整天在等外部回應,所以要 reactive。今天就把這句結論的底細翻開,看清楚 thread-per-request 到底卡在哪、reactive 的事件迴圈又是怎麼把「不空等」這件事做出來的。這是第二章的開篇,也是後面整整二十幾天會一再回頭引用的地基——你會發現「不空等」這三個字,後面幾乎每一站都會被拿出來用一次。
本篇結構:
先把 Portal 的工作性質講清楚,因為執行模型是被它決定的,不是憑喜好挑的。
Portal 自己幾乎不做運算。它不跑模型推論、不存資料庫、不碰業務邏輯。它的一天大概是這樣過的:收到行員的問題,轉手交給後端的 AI 引擎或某家 LLM 供應商,然後就在那裡等——等模型吐答案、等知識庫檢索、等地圖回分行資訊。一次 /chat 請求動輒等上幾百毫秒到數秒,而這幾百毫秒裡,Portal 真正在「動腦做事」的時間可能不到一毫秒,其餘全是純等待。
問題是,這種閘道偏偏要扛高並行。一整層樓的行員同時在問問題,在途(in-flight)連線數很容易上千。於是矛盾就出來了:每一條連線都掛著、長時間在等外部回應,但又得同時撐住很多條連線。少數執行緒,要服務一大堆「多半在發呆等回應」的請求——這就是 Portal 必須回答的執行模型問題。記住這個性質:等的時間,遠遠大於算的時間。 後面所有選擇都從這個結論長出來。
傳統 Java web 的做法叫 thread-per-request:一個請求進來,分派一條執行緒給它,這條執行緒從頭跟到尾,請求結束才釋放。寫起來很直覺,因為程式是「由上往下、一行等一行」的,剛好符合人腦的閱讀順序。
弱點就在「等」這個動作上。當你的程式碼寫下「呼叫 LLM、拿回應」這一步,這條執行緒就停在那裡,什麼也不做地空轉,直到 LLM 回來。它沒去服務任何其他人,只是站著等。執行緒是有限資源(現代 JVM 大概要開到數千條才會明顯吃緊,但終究有限),如果每條都站著等外部回應,那麼當在途的請求數超過執行緒數,新來的請求就只能排隊——即使這時候 CPU 幾乎是閒著的,系統照樣塞住。對 Portal 這種「等遠大於算」的閘道來說,這是致命的:你不是被算力耗光的,是被「站著空等」耗光的。
換個比喻就懂了。把每一條執行緒,想成餐廳裡的一個服務生(沒錯,又是服務生,總是服務生💁)。
thread-per-request 的服務生很死心眼:他幫一桌點完餐,就一路站在這桌旁邊,盯著廚房的方向,菜沒好他就一直站著,期間不去招呼任何別桌。客人一多,服務生全被「站著等菜」綁死,門口大排長龍——但其實廚房好得很、服務生也沒真的在忙,他們只是被「不准離開這桌」的規矩卡死了。
thread-per-request:一個服務生死守一桌,等菜期間全程空等
服務生 A ── 顧 1 號桌 ──[點餐]──────[空等廚房]──────[上菜]── 才換下一桌
服務生 B ── 顧 2 號桌 ──[點餐]──────[空等廚房]──────[上菜]──
服務生 C ── 顧 3 號桌 ──[點餐]──────[空等廚房]──────[上菜]──
↑
這段時間人在、但什麼都沒做
4 號桌之後:沒服務生了,門口排隊(連線耗盡)
reactive 的服務生就機靈多了:他幫 1 號桌把單送進廚房,不站著等,轉身就去招呼 2 號桌、3 號桌,一路把單都送出去;哪桌的菜好了,廚房叫他,他再回去那桌上菜。同一個服務生,在「等菜」的空檔裡服務了一整排桌子。少數幾個這樣的服務生,就能撐住整間爆滿的店。
reactive 事件迴圈:少數服務生不空等,誰好了就回頭服務誰
事件迴圈執行緒 ──[1號桌送單]→[2號桌送單]→[3號桌送單]→[1號菜好,上菜]→[3號菜好,上菜]→…
送完就走, 送完就走, 送完就走, 回呼才回來 回呼才回來
桌子在等廚房的時間,執行緒拿去推進別桌,從不站著發呆
把兩套執行模型擺在一起對照,差別就很清楚:
| 面向 | thread-per-request |
reactive 事件迴圈 |
|---|---|---|
| 服務生比喻 | 死守一桌,等菜全程站著 | 送單就走,誰好了回頭服務誰 |
| 一條執行緒對一個請求 | 從頭跟到尾、請求結束才釋放 | 等待期間立刻釋放、回頭推進別人 |
| 等待外部回應時 | 執行緒被綁死、空轉發呆 | 不佔用任何執行緒 |
| 執行緒數量級 | 隨在途請求數膨脹(數千條後明顯吃緊) | 約 CPU 核心數(個位數到十幾條) |
| 撐住的在途連線 | 上限就是執行緒數,超過即排隊 | 數量級高出許多的成千上萬條 |
| 耗光的原因 | 被「站著空等」耗光,CPU 卻閒著 | 不靠站著等,所以不會被空等耗光 |
| 寫起來 | 一行等一行,直覺好讀 | 拆成「之後再說」的回呼資料流 |
這就是 reactive 的核心,概括地說:執行緒不空等。 等待外部回應的那段時間,不再佔用任何一條執行緒;少數幾條執行緒組成一個事件迴圈(event loop),輪流推進大量請求,誰在等就先擱著、去推進別人,等外部回來了再回頭把它接著做完。
機制上,reactive 把「呼叫外部、拿回應」這件事,從「站在這裡等結果」改寫成「登記一個回呼,等結果到了再叫我」。
具體一點:當程式要呼叫 LLM 時,它不會卡在那裡硬要一個現成的答案,而是拿到一個「將來會有答案」的承諾(在 Spring WebFlux 裡就是 Mono/Flux 這類型別),同時告訴系統「答案回來時,請接著做這幾步」。交辦完,這條事件迴圈執行緒立刻被釋放,回頭去推進別的請求。等 LLM 真的回來了,系統再從池子裡找一條空著的執行緒,把後續那幾步接著跑完。
所以一次 /chat 在 reactive 下,其實是被切成好幾段的,每段可能落在不同的執行緒上跑,同一條執行緒並不從頭跟到尾:
傳統:
T1 ──收請求──呼叫LLM──[整條 T1 空等]──收到回應──組答案──回傳 (T1 全程被佔住)
reactive:
T1 ──收請求──發出LLM呼叫(登記回呼)──[T1 釋放,去服務別的請求]
… LLM 回來了 …
T7 ────────────────────────────接手──組答案──回傳 (換一條空閒執行緒接續)
上圖為示意。實務上
WebFlux的事件迴圈有 thread affinity(回呼傾向回到原執行緒)——回呼預設多半回到原本那條 event-loop 執行緒接續,未必真的換一條;圖裡用不同代號(T1/T7)只是強調「請求不被同一條執行緒從頭綁到尾」,不是說每一段都換執行緒。真正會換到別的執行緒池,是 Day 6 講的offload(把阻塞工作丟到彈性池)那種情形。
對 Portal 來說,這帶來的並行量級差很多。WebFlux 底下的事件迴圈執行緒,數量大約就是 CPU 核心數那個量級——個位數到十幾條而已。但因為它們從不站著空等,這十幾條執行緒就能同時「掛著」成千上萬個正在等外部回應的請求。在一個「等遠多於算」的閘道上,這正是我們要的:用接近 CPU 核心數的執行緒,撐住數量級高出許多的在途連線。這也是 Day 4 講「reactive、無持久化、宣告式抽換是一組互相支撐的選擇」時,reactive 那一角真正的份量——它讓 Portal 用很省的執行緒,做一件本質上「整天在等」的事。
不過在 2026 年談這個主題,有個問題該主動回答、不能跳過:既然弱點是「執行緒被站著等耗光」,那為什麼不直接用 virtual threads(Project Loom)就好? 這是個好問題。virtual threads 讓執行緒變得極廉價、可以開到上百萬條,等於從另一個方向解掉「執行緒不夠」——而且它保留了「一行等一行」的直覺寫法,不必把程式拆成回呼資料流。對一個單純請求進、回應出的閘道,virtual threads 其實是更省心的選項,值得認真擺上檯面。
那 Portal 為什麼仍選 reactive?關鍵在它要做的不只是「等一個回應」:
reactive 的運算子(Flux)天生就是拿來組合流的;virtual threads 解的是阻塞,不是串流組合。reactive 真正獨有、thread-per-request(即使配上 virtual threads)也給不了的能力——當下游(瀏覽器或更下游的服務)消化不過來時,reactive 能沿著資料流往上游回壓一個「慢一點」的訊號,而不是讓資料無止境地在記憶體裡堆積。對一個整天在轉發大量串流的閘道,這是個重要的安全閥。reactive 生態:WebClient、reactive SSE、把多路查詢 zip 在一起(Day 22),都是貼著 reactive 寫的。誠實說:如果 Portal 只做請求/回應、不碰串流與背壓,virtual threads 會是更簡單的選擇;reactive 這套複雜度,主要是被串流、背壓、以及既有 reactive client 這幾件事換來的,不是「因為它比較潮」。下面講代價時你會更有體感——這套複雜度是真金白銀付出去的。
要誠實講代價,因為 reactive 不是免費的升級。
第一個代價是寫法。thread-per-request 的程式「一行等一行」,符合直覺、好讀好除錯,堆疊追蹤(stack trace)也完整。reactive 把流程拆成一串「之後再說」的回呼,程式不再是線性往下讀,而是一條由 flatMap、zip 這類運算子串起來的資料流(pipeline);出錯時的堆疊也常常斷在框架內部,不像同步程式那樣一路指回你的業務碼。對寫慣傳統 Java 的人,這是要重新適應的思維,學習曲線確確實實存在。Day 4 把 reactive 列為三大選型之一,也是因為它會滲透到每一處寫法,不是換個函式庫就算了事。
第二個代價更要命,而且它正是這整個第二章的引線:事件迴圈上,絕對不能出現會卡住的呼叫。
道理回到餐廳——那個機靈的服務生之所以撐得住整間店,全靠他「送單就走、從不站著等」。但萬一他在某一桌前一時失神、真的站著乾等了三秒,那這三秒裡,所有等他回頭上菜的桌子全都跟著卡住。事件迴圈也一樣:它身上掛著成千個請求,一旦某段程式在這條執行緒上做了同步阻塞的事——比方一個會卡住的 DB 寫入、一個老式同步函式庫的呼叫——這條執行緒就被凍在那裡,它負責推進的所有請求全部一起停擺。少數幾條執行緒本來是 reactive 高並行的本錢,這下反而成了單點:卡一條,等於卡掉它背後的一大片。
高並行的本錢,和它的弱點,其實是同一件事的兩面。
這就是 Portal 必須遵守的鐵律。也因為這條鐵律,像寫稽核這類天生會阻塞的工作,得另想辦法處理——把它丟給旁邊一個專門的執行緒池去做,事件迴圈交辦完就立刻脫身(術語叫 offload,明天會仔細拆)。今天先把這條鐵律的「為什麼」鋪好就夠了。
明天 Day 6,我們把這條鐵律當主角,講清楚「一個阻塞呼叫凍住整條執行緒」具體是怎麼發生的、又該怎麼 offload 才不踩雷——這是 reactive 系統真正的關鍵。