我一個人開發 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 來回。迴圈全速跑。
兩個數字往相反方向走
| 每則訊息的請求數 | 每則訊息花的時間 | 請求速率 | |
|---|---|---|---|
| 改之前 | 6 | 6.2 秒 | 0.97 req/s |
| 改之後(loopback 實測) | 1 | 0.154 ms | 6488 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。
一個以「不要一直敲人家門」為目的的改動,結果變成拿攻城槌撞門。
為什麼測試看不到
三個原因,每個都很典型:
- 測試用的是假的下游(httptest + loopback)。它斷言的是「請求次數對不對」「有沒有走對分支」,沒有任何一條斷言「單位時間內打了幾次」。
- 正確性測試通過了,因為邏輯確實是對的。永久狀態碼不該重試——這個判斷沒錯。錯的是它的副作用。
- 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 秒不是新做的。 巡檢本來就在跑,「停線 → 等巡檢重啟」這個形狀在另一條路徑(死信也寫不進去時)早就在用。等於白撿一個節流閥,沒有新的狀態、沒有新的參數要調。
兩個設計細節:
- 計數器綁在迴圈的生命週期上,不是每批重置。 佇列是一批一批送過來的,一批可能只有 3 則;每批歸零的話永遠湊不到 5,節流形同虛設。
- 是「連續」不是「累計」。 只要有一次送成功就歸零。健康的端點偶爾吐一則壞 payload,下一則成功就重置,永遠碰不到那個 5。被節流的只有持續在拒絕的端點。
這個修法的代價
節流換來的是排空變慢:積壓幾萬則時,用每秒 0.5 個請求要跑到天荒地老,而且排在後面那些其實可以成功投遞的訊息也跟著被卡住(每 10 秒才推進一輪)。
我接受這個代價,因為觸發節流的前提是「這個端點已經持續在拒絕」——那些訊息本來就是要進死信的,快點慢點不改變結局;而「排在後面的好訊息被卡住」在單一 webhook 的序列迴圈裡本來就存在,不是節流引入的。
真的要兼顧的話有兩條路:停線期間仍以低速排空、或者對永久失敗改成批次直接進死信而完全不打端點。兩者都比「拍腦袋睡一下」複雜,而現在的量級還用不到。
實測(餵 20 則訊息給一個恆回 404 的端點),三個版本並排:
| 版本 | 對方被打幾次 | 分散在多久 |
|---|---|---|
| 原本(重試 6 次) | 120 | 約 124 秒 |
| 快速失敗(沒有節流) | 20 | 約 3 毫秒 |
| 快速失敗 + 節流 | 5 | 分散在 10 秒一輪 |
中間那一列才是問題所在:次數少了 6 倍,但全部擠在 3 毫秒內砸過去。
可以帶走的東西
指數退避通常身兼兩職:
- 給對方時間恢復(顯性目的,寫在註解裡)
- 限制對單一目標的請求速率(隱性副作用,沒人寫下來)
當你優化掉第 1 個,你會連帶消滅第 2 個——而且你的指標會顯示「改善」。
同樣的形狀還會出現在:
- 把同步呼叫改成 fire-and-forget(移除了背壓)
- 把重試從指數退避改成固定次數
- 提升快取命中率之後,cache miss 打到下游的絕對流量反而增加,因為前端 QPS 上去了
通則是:每當你移除一個「等待」,先問一句——這個等待除了它宣稱的目的之外,還在順便擋住什麼?
如果答案是「它在限速」,那就得顯式地把限速補回來。最好用系統裡已經存在的週期性機制,而不是新發明一個。
MsgMesh 是一個架在 Kafka 之上的多租戶事件總線。這個 bug 出現在 webhook 投遞路徑上,由對抗式 code review 在合併前攔下,從未上線。