昨天 Day 8 把身分的源頭講清楚了:使用者是誰,在他登入 SSO(單一登入)那一刻就由帶 HMAC 簽章的 cookie 確立,往後每個請求靠驗章重新確認,不必存 session。身分有了,請求正式進門。但這裡馬上冒出另一個問題:行員小林這一次的提問,接下來會被拆成好幾段非同步工作、在不同執行緒之間跳來跳去——當你事後想知道「他那題到底發生了什麼事」,要去哪裡找它的足跡?今天就來談進門時做的第一件雜事,一件不起眼、出事時卻能救你一命的事:發一組追蹤碼(correlation id)。
本篇結構:
先說清楚痛點。傳統 thread-per-request 的系統裡,一個請求從頭到尾佔著同一條執行緒,日誌自然都在同一條執行緒的脈絡裡,按時間順著讀就好。但 Portal 是 reactive 的——Day 5、Day 6 講過,它靠少數幾條執行緒組成的 event loop 輪流推進大量請求,誰在等外部回應就先擱著、去推進別人。這帶來的副作用是:小林這一題,在日誌裡不會是連續的一段。
想像同一瞬間有三十個行員在問問題,三十題的日誌行交錯地印在同一個輸出裡。小林那題的「身分還原成功」「派給哪個助理」「呼叫了哪家模型」「寫了一筆稽核」——這幾行之間,可能夾著另外二十幾題的日誌。更麻煩的是,這些工作不見得在同一條執行緒上:身分驗證在 event loop、知識庫檢索的回呼可能切到別條執行緒、稽核寫入又 offload 到彈性執行緒池(Day 6 那條「阻塞工作一律 offload」的鐵律)。靠執行緒名稱去串?串不起來,因為同一題會跨好幾條執行緒。攤開日誌大概是這副亂麻:
09:15:03.412 INFO dispatch : agent=builtin llm=gemini-http
09:15:03.418 INFO guardrail : input scan passed
09:15:03.401 INFO dispatch : agent=builtin llm=openai-http
09:15:03.660 INFO audit : emit status=success durationMs=259
09:15:11.632 INFO audit : emit status=error durationMs=8231
09:15:03.420 INFO guardrail : input scan passed
每一行都對,但它們之間「誰跟誰是一夥的」這個關聯整個丟失了。那筆 durationMs=8231 的慢請求,對應的是上面哪一次 dispatch?是 gemini-http 那次還是 openai-http 那次?光看時間戳根本對不起來——reactive 下時間是亂序交織的,同一次請求的相鄰兩步反而可能差好幾百毫秒、中間還夾著別人的日誌。
所以問題很具體:在一個天生會把單次請求切碎、打散到多條執行緒的系統裡,怎麼把屬於同一次請求的所有產出重新黏回去? 出事時——某個行員回報「我問解鎖步驟它給我亂答」——你需要能精準撈出「就是他那一題」的完整足跡,而不是在三十題交錯的日誌海裡用時間戳猜。
做法是業界的標準手法:請求一進門,在最前面那道 filter 就生一組唯一的追蹤碼(correlation id),讓它貫穿這次請求的所有產出。Portal 用的是 logging 框架的 MDC(Mapped Diagnostic Context,診斷上下文映射)——你可以把它想成「掛在當前執行脈絡上的一組鍵值對」,logging 框架格式化每一行日誌時會自動把 MDC 裡的值填進去。所以你只要在 log pattern 裡放一個 [req=%X{reqId}] 這樣的佔位,這條請求往後印的每一行日誌開頭就都會自動帶上同一組 id,程式碼裡完全不用每行手動拼。
回到剛剛那片亂麻,貼上 id 之後是這樣:
2026-06-22 09:15:03.318 INFO [req=a1b2c3d4] dispatch : received chat request
2026-06-22 09:15:03.351 INFO [req=a1b2c3d4] sso-filter : identity resolved corpId=******** roles=[EMP]
2026-06-22 09:15:03.412 INFO [req=a1b2c3d4] dispatch : agent=builtin llm=gemini-http
2026-06-22 09:15:03.555 INFO [req=a1b2c3d4] guardrail : input scan pii=[EMPLOYEE_ID] injection=none
2026-06-22 09:15:03.661 INFO [req=a1b2c3d4] audit : emit status=success durationMs=249
2026-06-22 09:15:11.632 INFO [req=9f8e7d6c] audit : emit status=error durationMs=8231
關鍵在那個 [req=a1b2c3d4]。它是每一行的固定前綴,無論這幾行是不是在同一條執行緒上印出來的、中間夾了多少別題的日誌。那筆 req=9f8e7d6c 的慢請求也一眼可辨,跟小林那題不會再混淆。事後排查時,你只要做一件事:
grep 'req=a1b2c3d4' portal.log
整題的足跡就被精準地撈成一段——從進門收件、身分還原、派工、守門掃描,到最後落了一筆稽核,時間軸完整、毫不缺漏。在三十題交錯的日誌海裡,這一行 grep 就是把碎片拼回全貌的那把鑷子。
這組 id 從哪來、怎麼跟著請求走,這裡要接上 Day 7 埋的伏筆。Day 7 講過:reactive 下一個請求會在執行緒之間流轉,傳統的 ThreadLocal 會在執行緒切換的瞬間斷裂。而 MDC 底層也是 ThreadLocal 那一類「綁在執行緒上」的機制——請求一換執行緒,MDC 就空了,這跟 Day 7 講的身分傳遞是同一個坑。所以 Portal 的做法跟身分一致:correlation id 進門就生成、當成隨身行李掛上框架的 request context(跟著資料流走),再在每一段真正要印日誌的執行脈絡裡,把它從 context 取出、臨時寫回該段的 MDC。換句話說,兩者的分工是這樣:
| request context | MDC | |
|---|---|---|
| 角色 | 跨執行緒不掉的權威來源 | 讓 logging 框架印得出來的當地副本 |
| 是否跟著資料流走 | 是(行李掛在請求身上) | 否(綁在當前執行緒上,換緒就空) |
| 何時寫入 | 進門生成時一次塞進去 | 每段要印日誌的執行脈絡裡臨時寫回 |
還原出來的身分(Day 8)往下傳靠的是同一套機制;correlation id 不過是搭了這班順風車——它本來就是 Day 7 順手塞進 context 的兩筆值之一,今天只是把它的價值單獨展開講。
順帶接上 Day 7 那個時效註腳:「從 context 取出、臨時寫回 MDC」這道手動工,正是現代 Reactor 的自動橋接(micrometer-context-propagation + Hooks.enableAutomaticContextPropagation())能替你代勞的——開了它,框架會在執行緒切換時自動同步 MDC,這段就不用自己寫。本專案目前選的是手動寫回:看得見、好測,代價就是這道得自己接的工。所以上表那個「每段臨時寫回 MDC」是這份程式碼的現況,不是 reactive 唯一的做法。
光印進自家日誌還不夠。Portal 還會把這組 id 回填到 HTTP 回應的 header:
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: a1b2c3d4
這一步是給前端對帳用的。當小林在前端遇到怪事、按下「回報問題」,前端能把這個 X-Request-Id 一起送回來;後端拿到 a1b2c3d4,就能直接 grep 出那次請求的全貌,不必再問「你大概幾點幾分、用哪個帳號點的」這種兜不攏的問題。一個 id,把使用者回報、後端日誌、稽核紀錄三邊接在了同一個座標上。
順帶一提,這組 id 是「進門當場生成」,不依賴上游有沒有傳;但如果前端或上游閘道已經帶了 X-Request-Id 進來,沿用它(而不是另生一組)能讓同一次端到端的請求共用一個編號,對帳更乾淨。
不過沿用有個必須補的安全前提,否則就跟整個系列「輸入預設不可信」的立場打架:上游帶進來的 header 是外部可控的輸入,要是未經校驗就塞進 MDC、印進每一行日誌,那就是一條 log injection 破口(CWE-117)——攻擊者可以在 id 裡塞換行偽造出假的日誌行、灌入控制字元,甚至自己指定一個 id 把惡意請求的足跡混進別人的。所以沿用之前一定要先校驗:限定字元集與長度(例如只收 [A-Za-z0-9-]{8,64}),格式不合就丟掉、改回自生一組。
再補一個方向:真要做跨服務對帳,比自家 X-Request-Id 更標準的是 W3C Trace Context(traceparent header)那套——它本來就為跨服務傳播設計、格式固定、驗起來明確,也正好是下一節「這不等於分散式追蹤」要往前走的那一步。
correlation id 這件事,技術上沒什麼深的——生一個夠唯一的字串、塞進 MDC、回填一個 header,如此而已。但它的邊界得講清楚,免得高估它。
第一個誠實分界:correlation id 是「把一次請求在單一服務內部散落的日誌串起來」,它本身不等於分散式追蹤(distributed tracing)。兩者的差別放在一起看最清楚:
| correlation id(Portal 現況) | distributed tracing | |
|---|---|---|
| 串接範圍 | 單一服務內部的日誌 | 一次請求跨多個服務的完整時間軸 |
| 用到的東西 | 一組 id + MDC + log pattern | trace id/span id、傳播標準、collector |
| 跨服務時 | 串不到下游,除非刻意往下傳且下游都認得 | 天生就為跨服務設計 |
| 工程量級 | 進門一道 filter 即可 | 另一個層級的工程 |
當這次請求從 Portal 往下打到後端 Engine,Engine 再打知識庫,光靠 Portal 自己生成的 id,是串不到下游服務的日誌的——除非你刻意把這組 id(或一組標準的 trace context)透過 header 一路往下傳,而且每個下游服務都認得它、也印進自己的日誌。Portal 目前在日誌這層用 correlation id 把單服務內部串起來,已經能應付絕大多數的現場除錯需求;跨服務的端到端追蹤是還能再強化的方向。
第二個取捨,是 id 的生成成本與可讀性之間的平衡:
UUID(128 bit,碰撞機率近乎零),但它又臭又長,貼在每一行日誌前面很佔版面、人眼也難一眼比對。Portal 選的是一組較短的 id(像 a1b2c3d4 這種長度),代價是碰撞機率比 UUID 高一點點——但在「一個服務、滾動保存的日誌」這個情境裡,兩次請求剛好撞到同一組短 id、又剛好同時落在你正在查的時間窗內,機率低到可以接受,換來的是人去 grep、去比對時順手得多。要提醒一句:a1b2c3d4 只是示意,別把它讀成「實際就這麼短」——真按 8 碼十六進位(32 bit)算,流量一大生日碰撞就不能忽略了。規模上去時更穩的折衷,是內部存一組夠長的 id(至少 64 bit,或直接用標準 trace id),日誌只顯示前幾碼給人眼比對,長度與可讀性就能兼得,這也剛好接到下一段要講的 W3C Trace Context。這是個務實的取捨:correlation id 是給「人在出事時追查」用的,可讀性的權重在這個場景下高於數學上的絕對唯一。
第三點要記住的是:這組 id 的價值,完全取決於「貫不貫徹」。只要有一條日誌漏印了它、有一段非同步流程沒把 context 搭過去,那段就成了斷點,你追到那裡就斷線。所以它不能是「想到才加」的東西,得做成框架層級的預設:
這也呼應 Day 6 那條「阻塞一律 offload」的鐵律:reactive 系統裡很多正確性,靠的不是某段聰明的程式碼,而是一條被貫徹到每個角落的簡單規矩。 與其說 correlation id 是一個功能,不如說它是整個系統得共同遵守的一個習慣,而這個習慣的價值跟系統的複雜度成正比。
歸結起來,今天講的 correlation id 小到不能再小——進門生成一串字、印進每行日誌、回填一個 header——但它撐起的是整個系統的「可追溯性」。把這同一組 id 從「單服務日誌的分組鍵」升級成「串起 metrics、tracing 與稽核的那根主軸」,是 Day 26 可觀測性那篇要展開的事;到時你會看到,整條流程都圍著這組從進門就發出去的 a1b2c3d4 轉。
明天 Day 10,我們回頭處理一個 Day 8 按下不表的問題:當身分服務當下故障,這個請求到底該放行還是該擋下——也就是 fail-open 與 fail-closed 這組容錯上的不對稱選擇。