iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

昨天結尾我說了這麼一段話:reactive 用少數幾條執行緒撐起高並行,本錢是「事件迴圈絕不空等」這個能力,但同一個本錢也是弱點——只要有一段程式在事件迴圈上一時失神、真的站著等了,它背後一整片請求就跟著停擺。今天我們把這條鐵律拉到聚光燈下,不再只說「不能卡」,而是把它拆到能動手的程度:一個阻塞呼叫到底怎麼凍住整條執行緒、該怎麼 offload 才不踩雷,以及為什麼這條鐵律會是後面每一站都要回頭引用的那一根樑。

本篇結構:

  • 一個阻塞呼叫,憑什麼凍住一整片請求
  • 鐵律與它的兌現方式:阻塞工作一律 offload
  • 取捨:fire-and-forget 不是萬靈丹,而這條鐵律後面會反覆兌現

一個阻塞呼叫,憑什麼凍住一整片請求

先把「凍住」這件事講到底,因為很多人嘴上認同「不能阻塞事件迴圈」,真寫的時候卻不覺得自己阻塞了。

Day 5 講過 reactive 的事件迴圈執行緒數量大約就是 CPU 核心數那個量級,個位數到十幾條,全靠「送單就走、從不站著等」才掛得住成千上萬個在途(in-flight)請求。這個設計有個沒明說的前提:每一段跑在事件迴圈上的程式,都必須很快做完、很快交還執行緒。它可以發出一個外部呼叫然後登記回呼走人,但它自己這一小段,不能停下來等任何東西。

問題在於,有些操作天生就會停下來等。常見的阻塞來源有兩類:

  • 同步資料庫寫入:你呼叫 insert,這個方法不會立刻回來,它會卡在那裡,直到資料庫回覆確認(ack)才往下走。
  • 老式同步函式庫:很多成熟的 client 是 thread-per-request 時代寫的,介面就是「呼叫、阻塞、回傳結果」,骨子裡假設「反正每個請求都有自己一條執行緒,卡住也只卡自己」。

這個假設在 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

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 的代價,不然只講好處並不誠實。

代價就在那句「丟完就忘」。你既然不等寫入結果,就等於接受:在極端情況下——比方那筆寫入正排在彈性池裡、還沒輪到跑,機器這時當掉了——這筆稽核可能永遠不會落地。換句話說,fire-and-forget 用「主流程絕不被副作用拖累」這個確定的好處,換來「極端當機時可能漏掉最後幾筆」這個小機率的損失。對 Portal 的稽核來說這個交換划算,因為法遵要的是「絕大多數互動可追溯」而非「一筆都不能少的強一致帳本」;但這條界線必須是你睜著眼睛劃的,不是「反正 offload 出去就沒事了」的錯覺。

如果哪天有個副作用是「一筆都不能漏」等級的,那它就不該用 fire-and-forget,得改用「至少寫成功才回應」之類更重的保證——代價是主流程又要為它等了,於是你回到了可用性與一致性的天平上重新權衡。

另一個容易被忽略的點是:offload 不是「把所有東西都丟進彈性池」的免死金牌。彈性池能長大,但不是無限大;什麼都往裡丟,一樣會把它撐爆、變成新的瓶頸。判準很單純,按工作性質分類處理:

工作性質 例子 該怎麼做 原因
天生會阻塞、又非做不可 同步寫入、老式同步 client offload 到彈性池 阻塞無可避免,得隔離到彈性池
本來就非阻塞的外部呼叫 reactive HTTP client 去問 LLM 不丟彈性池,直接發 本來就「發出去、登記回呼、走人」,丟進去只是平白多一次執行緒切換

最後把這條鐵律放回 30 天的全景,講清楚它的份量。它不是第二章內部的一個技術細節,而是後面每一站都會回頭靠的那根樑。它後面的兌現點包括:

  • Day 24(最直接):當我們講稽核的資料模型與寫入時,「寫稽核採 fire-and-forgetoffload 到彈性池、絕不阻塞也絕不反噬主流程」這句話會原封不動地再出現一次,那時你就會認得它正是今天這條鐵律的具體體現。
  • Day 11 起:接服務間認證、後面接知識庫檢索與地圖查詢時,凡是碰到「對外要等回應」的地方,背後都默默靠著同一條規矩。

總結:能非阻塞就非阻塞,非阻塞不了的就 offload,事件迴圈那十幾條精兵,從進門到留痕,一秒都不准被一件雜事卡死。

明天我們要解決一個 reactive 帶來的新麻煩:請求既然被切成好幾段、在不同執行緒間流轉,那「這個請求是誰發的」「它的追蹤碼是多少」要怎麼一路跟著走——傳統綁在執行緒上的 ThreadLocal 為什麼在這裡會斷,又該換成什麼。

敬請期待!


上一篇
Day 5|非阻塞與事件迴圈
下一篇
Day 7|reactive 下的上下文傳遞
系列文
轉生到全端工程師沒多久就要負責公司的大平台??9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言