先決定要站在哪裡看

gin 的中介層鏈是一個很典型的「你以為你知道」的東西。你寫 r.Use(requestID(), accessLog(), auth()),腦子裡就有一張圖:請求依序穿過三個中介層,到達 handler。但有幾個問題這張圖回答不了:鏈被推進的次數是三次還是四次?請求被擋下來的時候,剩下的還跑不跑?找不到路由的時候,鏈跑了沒有? 答案不在原始碼裡,在執行時。

站在我自己的中介層裡,只看得到我寫的那一層;站在 http.Handler 外面,只看得到進和出。所以點掛在 gin 內部:ServeHTTP:667 數進來幾個、ServeHTTP:668–674 量端到端耗時、handleHTTPRequest:708 抓路由比對的 M={httpMethod} P={rPath}、Context.Next:189 數鏈被推進幾次、redirectTrailingSlash:782 數補斜線重導。

流量切成五個相位,每個 70 個請求、每秒一個,只改一個變因:A GET /health(200)、B GET /nope(404)、C PUT /health(405)、D 不帶 token(401,auth 中止)、E GET /health/(301,補斜線)。客戶端自己算得出它送了 350 個請求、預期 1,050 次 Next()、70 次重導;伺服器裡量到的三個數字完全相同。

六分鐘看到的

路由那個點記到 350 筆,去重之後只有四種形狀:M=GET P=/health 140 次、/nope 70、PUT /health 70、/health/ 70。五個相位、四種形狀——A(200)和 D(401)在路由比對那一刻是同一個東西。路由器不知道你會不會被擋下來。

圖上 決定路由 → 跑中介層 那條邊寫著 1,050 ÷ 350 = ×3。但把窗口按相位切開:

相位     結局   Next() 次數   每個請求
A        200    280           4
B        404    280           4
C        405    280           4
D        401    210           3
E        301      0           0

沒有任何一個相位是 3。 ×3 是 4、4、4、3、0 的平均,是那幾分鐘流量組成的性質,不是程式碼的性質。而三個值剛好回答前面三個問題:engine 自己推一次、三個中介層各推一次,所以是 4 不是 3;auth 擋下請求時沒有呼叫 c.Next(),少的正是它那一次;301 那 70 個請求,鏈一次都沒被推進——你掛在中介層裡的限流、稽核、追蹤,對它們全部沒有執行,而印 access log 的那一層也在那條沒跑到的鏈上,所以服務自己的 log 對這 70 個請求一行都沒有。

那條邊是虛線,標著 0–4 層沒有觀測:requestID、accessLog、auth、serveError。那正是我自己寫的三個中介層,圖知道它們跑過,只是我沒量它們。

我以為那是 gin 的成本

端到端那個點記到 350 個樣本:207 筆在 1 毫秒以下,29 筆在 54.374–55.581 毫秒,6.506 到 54.374 之間一筆都沒有——是兩個分開的平台。按相位切開,A 到 D 的 p95 全在 54.441–54.839,只有 E 是 1.573,它的 70 個請求沒有一個超過 40 毫秒。

因果好像自己寫好了:E 是唯一不跑中介層鏈的路徑,也是唯一沒有那個平台的路徑,所以那 55 毫秒在鏈裡面。故事完美到我差點就這樣寫了。

擋下我的是那個計時點自己附帶的一句話:它量的每個區間裡,平均跑了 5.2 個別的探針,「比較穿過不同數量探針的路徑,比較的就是探針」。A 到 C 的請求穿過 5 個收堆疊的點,E 只穿過 2 個。「E 比較快」和「E 穿過的探針比較少」是同一件事的兩種說法。

所以我換一把不依賴探針的尺——客戶端 curl 的 %{time_total}——同樣五個相位、各 50 個請求,從乾淨的行程開始一層一層加回去:

設定                     收堆疊的點   p50(全體)   >40 ms     最大
什麼都不掛                0            0.40 ms      0 / 250    2.0 ms
只有計時                  0            0.45 ms      0 / 250    1.4 ms
計時 + 一個收堆疊的計數點   1            0.62 ms      1 / 250    126.7 ms
五個點(主窗口)           4            1.33 ms     15 / 250    56.8 ms

沒有任何點在收堆疊的兩個設定,500 個請求沒有一個超過 2.0 毫秒。 只要有一個點在收堆疊,慢請求就回來了;四個在收,變成 15 個。計時本身只值 0.05 毫秒(0.45 對 0.40)。我差一點把「我的觀測器在哪裡比較密集」寫成「gin 的成本在哪裡」。

誠實說

這一輪的延遲數字只描述被插樁的行程。 p50 0.904、p95 54.578 毫秒是真的量到的,但它是「gin 加上四個收堆疊的點」的成本。gin 自己的成本,我能說的只有零探針那組客戶端數字:五個相位的 p95 在 1.04–1.21 毫秒之間。那是不同的儀器、不同的量測位置(含網路往返),所以兩組不能相減,我也不會去減。

「收堆疊會帶出慢請求」我有把握,「多常」我沒有。 四個設定的方向一致而乾淨(0、0、1、15),但頻率在不同輪之間差很多:同樣是「計時加一個收堆疊的點」,這輪 1/250,上一輪量到 31/300。所以我只寫方向,不寫比例。那 55 毫秒(或 126.7 毫秒)底下的機制我沒量到,所以不寫。

這一頁取代了它的前一版。 前一版拆掉鏈上三個點、尾巴沒動,就寫了「那條尾巴不是我」。可是它留下來的兩個點裡,有一個正在收堆疊——拆掉的是鏈上的點,不是收堆疊這件事。把「觀測」當成一個開關,而不是一個有結構的東西,就會得到那個結論。

三個中介層我分不開。 它們共用同一行 Next(),所以圖上只有一個 跑中介層,這篇也不會有「哪一層比較貴」這種話。另外,觀測工具當場的累計把 收下請求 → 決定路由 記成 349;回頭從原始事件逐筆看,350 筆的堆疊上都站著 收下請求,圖上用的是後者。這一輪的 SDK 回報不了掉包,每個計數嚴格說是「被記錄下來的次數」,但它們跟客戶端自己算的 350、1,050、70 完全吻合。

結果

我開始的時候相信兩件事:「寫了三個中介層,鏈就跑三次」,以及「量出來的毫秒數是這個服務的成本」。第一件是錯的,是四次,而且會因結局變成 3 或 0。第二件錯得更徹底,也更難發現,因為它產出的是一個看起來很有道理的因果。

我現在知道,看得見之後還有第二個問題:你怎麼知道你看到的是它,不是你自己?只把「有沒有觀測」當成開關去試,只會知道「跟我有沒有關係」;要知道「跟我的哪一部分有關係」,得把觀測本身拆開來量——計時是一層,收堆疊是另一層,價錢差了一個數量級。

這一輪的遠端面板已經沒有資料了,所以這頁不附連結;文章開頭那張互動圖就是這一輪的完整讀數,時間軸可以拖到任何一個相位。

這一輪對被觀測服務做了什麼:main.go 加了一行 blank import 把 SDK 帶進二進位檔,並用 -gcflags="all=-dwarflocationlists=true -N -l" 編譯。業務邏輯一個字沒動,沒有加任何 log 或計數器。五個觀測點全部是行程跑起來之後才掛上去的,除了計時點以外都收堆疊。窗口 2026-09-08 12:16:51.894Z → 12:22:48.151Z。對照組的四個設定各跑一次,「什麼都不掛」是把行程重啟到乾淨狀態(3 個請求產生 0 筆事件、複製額度回到 20/20)後量的,另外兩個設定在那個行程上一層一層加回去。