iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

昨天 Day 6 結尾埋了一個問題:既然 reactive 下一個請求會在不同執行緒間流轉,那「這位使用者是誰」「這次請求的追蹤碼是什麼」這些隨身資訊,要怎麼跟著資料流走,而不會在執行緒一切換就斷掉?今天就把這個問題講透。它聽起來只是個工程細節,實際上是 reactive 模型逼出來的一個真實坑,踩進去的人通常要到上線且並行量一高,才會發現自己讀出來的身分是別人的。

本篇結構:

  • 要解決的問題:請求在執行緒間跳,隨身資訊跟不跟得上
  • ThreadLocal 為什麼會斷:行李放在執行緒上,人卻換了執行緒
  • 機制:行李改放進框架的請求上下文(Reactor Context
  • 切入角度:這不是免費的,也不是萬靈丹

要解決的問題:請求在執行緒間跳,隨身資訊跟不跟得上

先把場景定清楚。一個 /chat 請求進到 Portal,最前面會先做兩件跟「身分與追蹤」有關的事:給這次請求發一組追蹤碼(correlation id,例如 a1b2c3d4),以及讀 cookie、驗章(驗證 cookie 上的簽章)、還原出「這位使用者是誰」。這兩樣東西得一路陪著請求走到最後,不能用完即丟——它們是這次請求的隨身行李

隨身行李 範例值 一路走到最後要拿來做什麼
追蹤碼(correlation id a1b2c3d4 寫日誌時要印追蹤碼
身分(使用者是誰) corpId=12345678 呼叫模型時要知道是誰問的、寫稽核時 userId 要填對人

問題出在「一路走到最後」這幾個字。Day 5、Day 6 講過,reactive 不是一條執行緒從頭跟到尾。同一個請求,可能先在事件迴圈的某條執行緒上做了守門,等外部模型回應時被擱下,回應到了又換另一條執行緒接著推進,中間若有 Day 6 講的 offload,還會切到彈性執行緒池上。整個生命週期裡,這個請求踩過好幾條不同的執行緒。

於是隨身行李要怎麼存,就變成一個必須認真回答的問題。傳統 Java 後端的標準答案是 ThreadLocal——一個「綁在當前執行緒上」的儲存格,同一條執行緒上的任何程式碼都讀得到。在 thread-per-request 的世界裡這招天衣無縫,因為一個請求從頭到尾就佔著那一條執行緒,行李放在執行緒上等於放在請求上。但在 reactive 下,這個等號不成立了。

ThreadLocal 為什麼會斷:行李放在執行緒上,人卻換了執行緒

ThreadLocal 想成「貼在某條執行緒身上的便利貼」。請求進門時,你在「執行緒 A」上貼了一張便利貼,寫著 corpId=12345678req=a1b2c3d4。接著請求去等模型回應——這一等,執行緒 A 就被 reactive 收回去服務別人了,便利貼還黏在 A 身上。模型回應到了,事件迴圈挑了「執行緒 B」來接手這個請求,B 身上根本沒有那張便利貼,它去讀 ThreadLocal,讀到的是空的,或者更可怕——讀到的是別人剛剛貼在 B 上、還沒清掉的便利貼。

這就是 ThreadLocalreactive 下斷裂的根因,概括地說:ThreadLocal 把資訊綁在執行緒上,但 reactive 下「請求」和「執行緒」不再是一對一。行李綁錯了載體。一個請求在它的生命週期裡會換好幾條執行緒,而 ThreadLocal 認的是執行緒、不是請求,執行緒一換,行李就丟了。

最危險的不是「讀到空值」,而是「讀到別人的值」。事件迴圈上的執行緒是高度複用的——剛服務完小林的執行緒,下一刻就去服務別的行員。如果某段程式碼在執行緒切換後還傻傻去讀 ThreadLocal,它很可能讀到的是上一個或下一個共用這條執行緒的請求殘留的身分。對一個要拿 userId 去寫稽核、去做權限判斷的系統來說,這不是「偶爾少印一行日誌」的小事,是「把行員 A 的操作記成行員 B 做的」這種等級的資料污染。而且它幾乎不會在開發機上單人測試時出現——只有並行夠高、執行緒複用夠頻繁,才會冒出來,跟 Day 6 講的阻塞地雷一樣,是那種「測得過、上線炸」的坑。

把這個交錯攤上時間軸,污染是怎麼發生的就一目了然——便利貼黏在執行緒身上不動,請求卻在執行緒之間跳:

https://ithelp.ithome.com.tw/upload/images/20260808/20183385ZF8yUQrK3t.png

機制:行李改放進框架的請求上下文(Reactor Context

既然問題是「行李綁錯了載體」,解法就順理成章:別把它綁在執行緒上,綁在「請求」這個資料流本身上。WebFlux 底層的 reactive 框架提供了一個叫 Reactor Context 的東西,它的設計初衷正是為此——一個跟著資料流(而不是執行緒)一起傳遞的不可變鍵值容器。請求流到哪條執行緒,這個 Context 就跟到哪條執行緒,因為它黏的是請求的處理鏈,不是底下那條會被換掉的執行緒。

具體在 Portal 裡,請求最前面那幾道處理會把追蹤碼和還原出來的身分塞進 Reactor Context,後面整條鏈想用的時候從 Context 裡取,而不是從 ThreadLocal 取。用便利貼的比喻講:以前是把便利貼貼在執行緒身上、人換執行緒就掉了;現在是把行李掛在請求自己身上,請求走到哪都帶著,底下換幾條執行緒都無所謂。

對照兩種寫法的差別,重點不在程式碼長相,而在「行李掛在誰身上」:

// ✗ ThreadLocal:行李掛在執行緒上
//   執行緒 A 寫入,請求被擱下、換到執行緒 B 接手後讀出來——空的,或別人的
contextHolder.set(identity);   // 貼在「當前執行緒」A 上
... 等模型回應,執行緒可能已換成 B ...
contextHolder.get();           // B 身上沒這張便利貼

// ✓ Reactor Context:行李掛在請求的資料流上
//   不管底下換幾條執行緒,沿著這條鏈走的程式碼都讀得到同一份
chain
  .contextWrite(ctx -> ctx.put("identity", identity)
                          .put("reqId", "a1b2c3d4"))   // 掛在請求鏈上
  .flatMap(req -> Mono.deferContextual(ctx ->          // 鏈上任一處都取得回來
      handle(req, ctx.get("identity"), ctx.get("reqId"))));

整條請求的資料流動長這樣,注意身分與追蹤碼是怎麼「貼著請求走」、而不是「留在某條執行緒上」的:

請求進門
   │  發追蹤碼 req=a1b2c3d4
   │  讀 cookie、驗章、還原身分 corpId=12345678
   │
   └─► 寫進 Reactor Context(掛在「這次請求」身上,不是執行緒上)
          │
   ┌──────┴───────────── 同一個請求,跨多條執行緒 ─────────────┐
   │                                                            │
 [執行緒A] 守門 ─ 等模型回應(擱下) ─ [執行緒B] 組裝 ─ [彈性池] offload 寫稽核
   │            │                     │                 │
   └── 從 Context 取 reqId / 身分 ─────┴─────────────────┘
          全程取到的都是同一份,不會因換執行緒而斷

這也是為什麼 Portal 的身分不走某些框架預設那套「把身分塞進 ThreadLocal 式持有者」的做法,改成自己把身分放進 Reactor Context 隨請求帶著走——因為在 WebFlux 上,前者就是會斷的那條路。Day 12 之後會看到,Portal 對「框架預設」常常是有保留地採用、甚至刻意繞開,這裡是第一個例子:

預設值本身沒有好壞,只是它們往往為 thread-per-request 設計,搬到 reactive 上得重新評估。

切入角度:這不是免費的,也不是萬靈丹

誠實講代價。Reactor Context 解決了斷裂問題,但它換來兩個得吞下去的麻煩:

代價 ThreadLocal 的對照 Reactor Context 的處境
寫起來比較囉嗦 隨地可取,呼叫方無感 得顯式寫、顯式取
只在 reactive 鏈上有效 整條呼叫堆疊都讀得到 出了 reactive 鏈,得手動接力

第一,寫起來比 ThreadLocal 囉嗦ThreadLocal 最大的便利在於「隨地可取」——任何一段同步程式碼,不管藏在多深的呼叫堆疊裡,一行 get() 就拿到行李,呼叫方完全無感。Reactor Context 做不到這種隱形傳遞,它要求你在 reactive 的鏈上顯式地 contextWrite 寫、deferContextual 取,行李的傳遞變成了流程的一部分,看得見、也得自己接。對習慣了 ThreadLocal 那種「全域隨手拿」的人,這是一種思維上的不適應:在 reactive 裡,沒有真正的「全域當前請求」,只有「沿著這條資料流傳下來的 Context」。

第二,它只在 reactive 鏈上有效。一旦你的程式碼跳出了 Reactor 的世界——比方說 Day 6 講的把阻塞工作 offload 到彈性執行緒池,在那個池子裡跑的是普通的命令式程式碼,不在 reactive 鏈上——Reactor Context 就不會自動跟過去。需要的話得在交辦出去的那一刻,手動把要用的值(例如追蹤碼)一起帶過去。這也是為什麼 Day 6 的稽核例子裡,offload 出去寫日誌時,追蹤碼是被當成參數明確傳進去的,而不是指望它「自己還在」。reactive 的上下文傳遞不是一張覆蓋全場的網,它只罩住 reactive 鏈本身,邊界之外要自己接力。

把這兩點合起來看,結論很樸素:Reactor Context 不是比 ThreadLocal「更高級」的東西,它是「在 reactive 下唯一不會斷的那個選項」。你不是因為它優雅才選它,是因為原始的 ThreadLocal 在這個執行模型下根本不能用,而隨身行李又非傳不可。這跟 Day 6 那條鐵律是同一種味道的取捨——reactive 給了你高並行,但你得用它的方式重新思考每一件原本理所當然的事,連「怎麼存一個當前請求的身分」這種小事都不例外。

這裡得補一個 2026 年的時效註腳,免得把上面的「囉嗦」說成是 reactive 永遠的宿命。Reactor 後來補上了自動橋接:引入 micrometer-context-propagation 函式庫、開 Hooks.enableAutomaticContextPropagation(),框架就能在執行緒切換的邊界自動ThreadLocalReactor Context 雙向同步——於是你可以繼續用 ThreadLocal 式的 API、寫起來像同步那樣隨手取,框架在背後幫你接過去。這也正是現代 WebFlux 上 MDC(Day 9 會講)、tracing、Reactive Security 能運作的底層機制(它們骨子裡都大量依賴 ThreadLocal)。

那為什麼這篇還在講「手動 contextWritedeferContextual」?因為這份程式碼目前就是手動做的:它在最前面的 WebFilter 用 contextWrite 把追蹤碼與身分塞進 Reactor Context,再用 doOnEach 手動把追蹤碼寫進 MDC、doFinally 清掉,沒有開自動橋接、也沒引入那個函式庫。這是個有意識的取捨:手動的代價是上面那些樣板程式碼(boilerplate,filter 得記得 contextWrite、記得同步 MDC),換來的是「零魔法、每一處 context 流動都看得見、好測」。所以這篇描述的「囉嗦」,是這份程式碼現況的真實樣子,不是說 reactive 沒有更省的寫法——有,只是這個專案還沒用上;要不要換成自動橋接,是另一個明擺著的選擇。

到這裡,第二章「底層執行模型」就講完了。這三條湊起來,就是 Portal 整個底座的脾氣:

Day 鐵律
Day 5 不空等
Day 6 不阻塞
Day 7 不靠 ThreadLocal

而今天特別把兩件隨身行李拎出來反覆講——追蹤碼 a1b2c3d4 和「使用者是誰」——不是隨手舉例,是刻意埋的兩條伏筆。它們各自會在接下來的章節長成一個獨立主題:追蹤碼會成為貫穿端到端可觀測性的主軸,而「使用者是誰」這件事,從「怎麼確立」到「怎麼可信」,正是第三章整章要回答的。

明天 Day 8,我們就從這裡接下去,正式進入第三章:先回答最根本的那個問題——「使用者是誰」這件事到底是什麼時候、用什麼方式確立的,為什麼說身分始於 SSO。


上一篇
第陸天|事件迴圈的鐵律
下一篇
Day 8|身分始於 SSO
系列文
轉生到全端工程師沒多久就要負責公司的大平台??9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言