← 寫作 · Writing Article
寫作 · Writing

我把重試從 6 次降到 1 次,結果對單一端點的請求速率暴增了

我一個人開發 MsgMesh。實作與 code review 分別由不同視角的 AI agent 跑,我做最後裁決; 這篇裡的「我」指這條協作鏈的產出,判斷失誤與最終決定都算我的。

起點是一個看起來無可爭議的優化

我的 webhook 投遞邏輯很標準:送訊息到租戶提供的網址,失敗就重試,總共 6 次,每次之間指數退避(0.2 → 0.4 → 0.8 → 1.6 → 3.2 秒),加起來 6.2 秒。試完還是失敗就丟進死信佇列。

某天做健檢時注意到一件事:如果對方回的是 404,重試 6 次是純粹的浪費。 網址錯了就是錯了,再送一百次還是 404。而這 6 次全落在租戶自己的伺服器上——等於在對一個明確說「這裡沒有東西」的端點反覆敲門 6 次。

於是開了一條 issue:對「這個請求再送一百次也不會成功」的狀態碼(400 / 404 / 405 / 410 / 413 / 414 / 415 / 422 / 501)直接快速失敗,不重試,直接進死信。

保守起見還特別排除了幾個:408、409、425、429 維持重試;所有 5xx 維持重試;401 / 403 也維持重試——租戶的端點在自己部署或輪替憑證時短暫回 401 很常見,把它當永久失敗會直接死信掉一批其實健康的 webhook。

代碼寫完,測試全綠,PR 描述寫著「每則訊息的請求數從 6 降到 1,對租戶端點的騷擾減少 6 倍」。

這句話是對的。而整個改動讓騷擾嚴重了三個數量級。

退避的第二個職責

投遞是一條迴圈:從佇列拿一則訊息 → 送出去 → 處理完 → 才拿下一則。同一條迴圈裡,前一則沒處理完,下一則不會開始。

所以改之前,面對一個壞掉的端點:

訊息1: 送→404 …等0.2s… 送→404 …等0.4s… 送→404 …等0.8s…
       送→404 …等1.6s… 送→404 …等3.2s… 送→404 → 死信
       ├──────────────── 6.2 秒 ────────────────┤
訊息2: (等到這裡才開始)

那 6.2 秒的等待是為了給對方時間恢復而寫的。但它順便做了另一件沒人寫下來的事:把整條迴圈的吞吐量壓在每秒約 1 個請求。

改之後,沒有等待了:

訊息1: 送→404 → 死信 (0.15ms)
訊息2: 送→404 → 死信 (0.15ms)
訊息3: 送→404 → 死信 (0.15ms)
...

一則訊息的處理時間只剩一次 HTTP 來回。迴圈全速跑。

兩個數字往相反方向走

每則訊息的請求數每則訊息花的時間請求速率
改之前66.2 秒0.97 req/s
改之後(loopback 實測)10.154 ms6488 req/s
改之後(真實網路推估)1~20 ms~50 req/s

速率 = 請求數 ÷ 時間。分子降了 6 倍,分母降了好幾個數量級。

那個 6488 是打 loopback 假端點量出來的,是上界不是實況。 真實的 webhook 打的是租戶在網際網路上的端點,一次來回通常 10–100 ms,同一條序列迴圈的實際上限因此在每秒數十次這個量級。用真實數字說:從 0.97 req/s 變成大約 50 req/s,放大約 50 倍

我刻意把兩個數字並排,因為這篇講的正是「別拿一個看起來很有說服力的代理指標,去代替你真正想知道的那件事」——標題如果只寫 6700 倍,我就自己犯了同一個錯。

而不論是 50 倍還是 6700 倍,結論不變:我當初只盯著分子看。

最糟的時機正好是最容易發生的時機

平常訊息零星進來時,這個問題不會出現——迴圈本來就在閒等。

放大只發生在有積壓的時候。 而積壓正是「端點壞掉一陣子」的必然結果:訊息在佇列裡越堆越多。

所以一旦某個租戶的端點壞了幾小時、積了幾萬則訊息,這個改動會讓那幾萬則用最高速度一次全部砸過去。而對方可能只是在部署、路由暫時 404。

一個以「不要一直敲人家門」為目的的改動,結果變成拿攻城槌撞門。

為什麼測試看不到

三個原因,每個都很典型:

  1. 測試用的是假的下游(httptest + loopback)。它斷言的是「請求次數對不對」「有沒有走對分支」,沒有任何一條斷言「單位時間內打了幾次」。
  2. 正確性測試通過了,因為邏輯確實是對的。永久狀態碼不該重試——這個判斷沒錯。錯的是它的副作用。
  3. PR 描述只寫了一半,而那一半是真的。 「每則訊息少打 5 發」是可驗證的事實,審查者很容易就順著這個框架去看。

怎麼抓到的

對抗式 code review,而且我給審查者指定了一個特定視角:blast radius——這個改動在線上會怎麼壞?

不是「代碼寫對了嗎」,是「假設它上線了,什麼東西會以什麼方式爆炸」。

兩個獨立的 agent 從不同角度各自算出了同一件事。其中一個實際跑了一版:餵 20 則訊息給一個恆回 404 的本機假端點,量出 6488 req/s——如同上面說的,那是 loopback 上的上界,真實網路上會低一到兩個數量級,但足以看出「兩個數字往相反方向走」這件事。

這個改動在合併前就被攔下來了,從來沒有上過線

修法:用系統裡已經有的週期

第一個直覺是「快速失敗後也睡一下」。但那個睡多久是拍腦袋的,而且把等待加回了單則訊息的處理路徑——本來就是為了拿掉它才做這個改動。

第二個直覺是 token bucket。但那要新增狀態、調參數、決定粒度(per-endpoint?per-tenant?)。

最後選的做法:同一條投遞迴圈連續 5 次碰到永久拒絕,就把這條迴圈停掉。

停掉不是死了。系統本來就有個巡檢每 10 秒掃一次「哪條 webhook 沒有迴圈在跑」,發現了就重開。所以節奏變成:

連發 5 個 → 停線 → 等 10 秒巡檢 → 重開 → 連發 5 個 → 停 → ...

平均每秒 0.5 個請求,比改動前的 0.97 還低。

關鍵是那個 10 秒不是新做的。 巡檢本來就在跑,「停線 → 等巡檢重啟」這個形狀在另一條路徑(死信也寫不進去時)早就在用。等於白撿一個節流閥,沒有新的狀態、沒有新的參數要調。

兩個設計細節:

這個修法的代價

節流換來的是排空變慢:積壓幾萬則時,用每秒 0.5 個請求要跑到天荒地老,而且排在後面那些其實可以成功投遞的訊息也跟著被卡住(每 10 秒才推進一輪)。

我接受這個代價,因為觸發節流的前提是「這個端點已經持續在拒絕」——那些訊息本來就是要進死信的,快點慢點不改變結局;而「排在後面的好訊息被卡住」在單一 webhook 的序列迴圈裡本來就存在,不是節流引入的。

真的要兼顧的話有兩條路:停線期間仍以低速排空、或者對永久失敗改成批次直接進死信而完全不打端點。兩者都比「拍腦袋睡一下」複雜,而現在的量級還用不到。

實測(餵 20 則訊息給一個恆回 404 的端點),三個版本並排:

版本對方被打幾次分散在多久
原本(重試 6 次)120約 124 秒
快速失敗(沒有節流)20約 3 毫秒
快速失敗 + 節流5分散在 10 秒一輪

中間那一列才是問題所在:次數少了 6 倍,但全部擠在 3 毫秒內砸過去。

可以帶走的東西

指數退避通常身兼兩職:

  1. 給對方時間恢復(顯性目的,寫在註解裡)
  2. 限制對單一目標的請求速率(隱性副作用,沒人寫下來)

當你優化掉第 1 個,你會連帶消滅第 2 個——而且你的指標會顯示「改善」。

同樣的形狀還會出現在:

通則是:每當你移除一個「等待」,先問一句——這個等待除了它宣稱的目的之外,還在順便擋住什麼?

如果答案是「它在限速」,那就得顯式地把限速補回來。最好用系統裡已經存在的週期性機制,而不是新發明一個。


MsgMesh 是一個架在 Kafka 之上的多租戶事件總線。這個 bug 出現在 webhook 投遞路徑上,由對抗式 code review 在合併前攔下,從未上線

← 回到所有文章[email protected]