昨天結尾我說了這麼一段話:reactive 用少數幾條執行緒撐起高並行,本錢是「事件迴圈絕不空等」這個能力,但同一個本錢也是弱點——只要有一段程式在事件迴圈上一時失神、真的站著等了,它背後一整片請求就跟著停擺。今天我們把這條鐵律拉到聚光燈下,不再只說「不能卡」,而是把它拆到能動手的程度:一個阻塞呼叫到底怎麼凍住整條執行緒、該怎麼 offload 才不踩雷,以及為什麼這條鐵律會是後面每一站都要回頭引用的那一根樑。
本篇結構:
先把「凍住」這件事講到底,因為很多人嘴上認同「不能阻塞事件迴圈」,真寫的時候卻不覺得自己阻塞了。
Day 5 講過 reactive 的事件迴圈執行緒數量大約就是 CPU 核心數那個量級,個位數到十幾條,全靠「送單就走、從不站著等」才掛得住成千上萬個在途(in-flight)請求。這個設計有個沒明說的前提:每一段跑在事件迴圈上的程式,都必須很快做完、很快交還執行緒。它可以發出一個外部呼叫然後登記回呼走人,但它自己這一小段,不能停下來等任何東西。
問題在於,有些操作天生就會停下來等。常見的阻塞來源有兩類:
insert,這個方法不會立刻回來,它會卡在那裡,直到資料庫回覆確認(ack)才往下走。這個假設在 reactive 底下徹底翻車。事件迴圈的執行緒是「一大片請求共用」的,不歸某個請求專屬。所以同樣一個卡三秒的同步寫入,在 thread-per-request 裡只卡住發起它的那一個請求,其他請求各有自己的執行緒顧著;但一旦它落在事件迴圈執行緒上,這三秒裡這條執行緒掛著的所有請求,全部一起被凍住——它們明明早就該被推進了,卻因為這條執行緒被一件無關的事佔死,集體陪等。
事件迴圈執行緒 EL-1,身上同時掛著一大片在途請求:
正常:EL-1 ─[推進 req-A]→[推進 req-B]→[推進 req-C]→[推進 req-A 下一步]→…
每段都很快做完就交還,雨露均霑,誰好了推進誰
踩雷:EL-1 ─[推進 req-A]→[req-A 裡做了同步 DB 寫入]──[整條 EL-1 卡住 3 秒]──┐
│
req-B、req-C…全在排隊等 EL-1 回來,這 3 秒一個也推不動 ←──────┘
CPU 幾乎是閒的,但請求全堵住——這就是「凍住一整片」
回到那個餐廳比喻:機靈的服務生之所以能顧一整排桌,全靠他從不在任一桌前久留。某一桌讓他真的站著等了三秒,這三秒就不只是那一桌的損失——是所有等他回頭上菜的桌子一起被晾著。事件迴圈執行緒就是那個服務生,而同步阻塞呼叫,就是逼他在一桌前站死的那件事。 少數執行緒原是高並行的本錢,被卡住的瞬間反而成了放大災難的單點。
鐵律本身簡言之:會卡住的工作,絕不在事件迴圈上做。 但光有禁令沒有用,得有個可執行的替代動作——那就是 offload。
offload 的意思是,把這件天生會阻塞的工作,從事件迴圈手上接過來,交給旁邊一個專門的、彈性的執行緒池去做。事件迴圈執行緒只負責「交辦」這個動作——把任務丟進池子,這一下是瞬間的、不阻塞的——交辦完它立刻脫身,回頭去推進別的請求。真正的阻塞發生在那個彈性池的某條執行緒上,而那些執行緒本來就是「準備被卡住」的,卡住一條不影響事件迴圈那十幾條的運轉。
關鍵的設計分界是這個:事件迴圈池跟彈性池,職責完全不同,絕不能混用。
| 事件迴圈池(event loop) | 彈性池(elastic pool) | |
|---|---|---|
| 執行緒數量 | 少數幾條(約 CPU 核心數量級) | 可長大縮小,按需擴張 |
| 是否准許阻塞 | 絕對不准阻塞 | 專門吸收阻塞工作 |
| 角色 | 不准站著等的精兵 | 準備被卡住的雜役 |
| 卡住一條的後果 | 凍住一整片在途請求 | 只卡那一條,不影響事件迴圈 |
在 Spring WebFlux 的世界裡,你會用一個專門承接阻塞工作的 scheduler(概念上就是這個彈性池)把工作排過去,明確標示「這段是阻塞的,請在池子裡跑,別在事件迴圈上跑」。
把這條鐵律落到最具體的對照上——就用 Portal 寫稽核這個動作。對話結束、答案要送回給行員的同時,系統要寫下一筆稽核紀錄(audit log),而寫入是天生的阻塞工作:
// ✗ 直接在事件迴圈上做阻塞寫入 —— 凍住整條執行緒
// 這一行會卡到資料庫回 ack 為止,這段時間 EL 上掛著的所有請求集體陪等
auditDb.insert(record)
// ✓ offload 到彈性執行緒池 —— 事件迴圈交辦完立刻脫身
// 阻塞發生在彈性池裡,事件迴圈完全不等
elasticPool.submit { auditDb.insert(record) }
上面那行 ✗ 看起來人畜無害,就是「寫一筆紀錄」這麼回事。它危險就危險在「看起來無害」——它沒有迴圈、沒有大運算,純粹只是在等一個外部系統回應,但這個「等」恰恰是事件迴圈最不能承受的。下面那行 ✓ 多包了一層「丟進彈性池」,事件迴圈碰到的只剩「丟」這個瞬間動作,「等」這件事被搬到別處去了。
稽核這一站還更進一步,把 offload 推到 fire-and-forget(交辦即忘):事件迴圈不只不在原地等寫入,連「交辦出去之後寫得成不成」都不回頭追究。主流程把這筆紀錄丟進彈性池就當沒事,立刻把答案回給行員;那筆寫入在背景慢慢完成,就算失敗,也只在背景記一則 WARN,絕不反過來影響已經送出的回應。資料流上看大致是這樣:
主流程(事件迴圈上跑)
│ 產生答案
│
├──────────────► 回傳答案給行員(不等稽核)
│
└──┐ 把「寫稽核」丟進彈性池(fire-and-forget,丟完就忘)
│
▼
彈性池某條執行緒 ── auditDb.insert(record) ── 慢慢寫,卡也只卡這條
失敗 → 記一則 WARN,不回頭驚動主流程
對照一下兩種失敗的後果,就知道這個取捨為什麼成立:
| 失敗情境 | 受害的是 | 性質 | 有無補救 |
|---|---|---|---|
| 稽核寫入拖慢主流程 | 每一個正在等答案的行員 | 面對使用者的可用性 | 難補,當下就傷 |
| 稽核寫入偶爾漏一筆 | 事後的紀錄完整性 | 副作用的資料完整性 | 可用監控告警與補寫機制接住 |
兩害相權,讓副作用的失敗留在副作用裡、絕不反噬主流程,是清楚的選擇。
要誠實講 fire-and-forget 的代價,不然只講好處並不誠實。
代價就在那句「丟完就忘」。你既然不等寫入結果,就等於接受:在極端情況下——比方那筆寫入正排在彈性池裡、還沒輪到跑,機器這時當掉了——這筆稽核可能永遠不會落地。換句話說,fire-and-forget 用「主流程絕不被副作用拖累」這個確定的好處,換來「極端當機時可能漏掉最後幾筆」這個小機率的損失。對 Portal 的稽核來說這個交換划算,因為法遵要的是「絕大多數互動可追溯」而非「一筆都不能少的強一致帳本」;但這條界線必須是你睜著眼睛劃的,不是「反正 offload 出去就沒事了」的錯覺。
如果哪天有個副作用是「一筆都不能漏」等級的,那它就不該用
fire-and-forget,得改用「至少寫成功才回應」之類更重的保證——代價是主流程又要為它等了,於是你回到了可用性與一致性的天平上重新權衡。
另一個容易被忽略的點是:offload 不是「把所有東西都丟進彈性池」的免死金牌。彈性池能長大,但不是無限大;什麼都往裡丟,一樣會把它撐爆、變成新的瓶頸。判準很單純,按工作性質分類處理:
| 工作性質 | 例子 | 該怎麼做 | 原因 |
|---|---|---|---|
| 天生會阻塞、又非做不可 | 同步寫入、老式同步 client | offload 到彈性池 |
阻塞無可避免,得隔離到彈性池 |
| 本來就非阻塞的外部呼叫 | 走 reactive HTTP client 去問 LLM |
不丟彈性池,直接發 | 本來就「發出去、登記回呼、走人」,丟進去只是平白多一次執行緒切換 |
最後把這條鐵律放回 30 天的全景,講清楚它的份量。它不是第二章內部的一個技術細節,而是後面每一站都會回頭靠的那根樑。它後面的兌現點包括:
fire-and-forget、offload 到彈性池、絕不阻塞也絕不反噬主流程」這句話會原封不動地再出現一次,那時你就會認得它正是今天這條鐵律的具體體現。總結:能非阻塞就非阻塞,非阻塞不了的就 offload,事件迴圈那十幾條精兵,從進門到留痕,一秒都不准被一件雜事卡死。
明天我們要解決一個 reactive 帶來的新麻煩:請求既然被切成好幾段、在不同執行緒間流轉,那「這個請求是誰發的」「它的追蹤碼是多少」要怎麼一路跟著走——傳統綁在執行緒上的 ThreadLocal 為什麼在這裡會斷,又該換成什麼。
敬請期待!