重點摘要
- 單純的指數退避(
delay = base * 2^attempt)會造成驚群效應(thundering herd),因為每個用戶端都在同一時間重試。 - 加上隨機抖動(jitter),把重試分散開來。其中等量抖動,也就是
base/2 + random(base/2),是最佳平衡點。 - 限制最大延遲、限制重試總時長,並把 4xx(不重試)和 5xx(要重試)分開處理。
- 以目的地為單位,在連續失敗大約 20 次後讓斷路器跳脫。重試修不好一個壞掉的端點。
- 低成本的重播勝過激進的重試:如果重播只要按一下,重試只需要處理暫時性錯誤。
我在實際專案裡看到的重試程式碼,多半長這樣:
for (let i = 0; i < maxRetries; i++) {
try {
return await deliver(event);
} catch {
await sleep(1000 * Math.pow(2, i)); // 1s, 2s, 4s, 8s...
}
}
這是指數退避,比固定間隔的重試好。但它錯的方式只會在正式環境浮現:數百個 Webhook 同時失敗,又在同一刻一起重試,把正在恢復的伺服器再次打趴。
解法是隨機抖動。以下說明原因,以及正確的做法。
驚群效應
想像你的端點在 T+0 當機。接下來 60 秒內,500 個 Stripe Webhook 抵達,全部失敗。現在每個重試佇列裡,都有 500 個第一次重試排在 T+5min(或你設定的任何基礎延遲),500 個第二次重試排在 T+10min,依此類推。
你的端點在 T+4min 恢復上線。恭喜:現在有 500 個並行的重試在 T+5min 同時打進來,把還在恢復的伺服器再次推過負載的臨界點。這就是驚群效應。
確定性的退避,天生就有這個問題。在同一時間失敗的每個用戶端或事件,都會在同一時間重試。
隨機抖動
隨機抖動在重試時間裡加入隨機性,讓事件分散重試,而不是擠成一個尖峰。三種常見的策略:
完全抖動(full jitter)
const base = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s, 8s
const sleep = Math.random() * base; // 0 to base
每次重試的等待時間,介於 0 到完整的退避間隔之間。分散程度最大,但有些重試幾乎會立刻觸發,感覺可能不太有效率。
等量抖動(equal jitter)
const base = 1000 * Math.pow(2, attempt);
const sleep = base / 2 + Math.random() * (base / 2);
每次重試的等待時間,介於退避間隔的一半到完整間隔之間。有下限,分散效果仍然不錯。
去相關抖動(decorrelated jitter)
let sleep = baseDelay;
// on each retry:
sleep = Math.min(maxDelay, baseDelay + Math.random() * (sleep * 3 - baseDelay));
每次重試的延遲,是前一次延遲的隨機倍數。適合想讓退避逐漸拉長、又不想套固定公式的情況。
對 Webhook 來說,等量抖動是最佳平衡點。它讓重試大致維持指數成長,同時化解驚群效應。
正式環境等級的重試排程長什麼樣子
除了隨機抖動,有經驗的團隊還會這樣做。
限制最大延遲。別讓第 10 次嘗試等上 17 分鐘。設一個上限,例如 60 秒,超過上限之後就維持在上限,直到放棄為止。
限制重試的總時長,而不只是嘗試次數。「重試 24 小時」是比「重試 8 次」更清楚的 SLA,因為後者取決於沒有人記得的延遲計算。
區分暫時性錯誤和不可重試的錯誤。目的地回的 4xx,尤其是 401、403 或 404,重試也不會成功,因為伺服器正在告訴你請求本身有錯。只對 5xx、429、逾時和連線錯誤重試。
讓重試狀態看得見。你的使用者需要知道某個事件正在進行第 3 次嘗試(共 5 次),以及下一次重試的確切時間。如果看不見,除錯會很痛苦。
以目的地為單位使用斷路器。如果某個目的地已經連續失敗 20 次,就暫停重試一段時間。這裡有重試修不好的問題,你只是在白白燒掉運算資源。
AnyHook 怎麼處理
AnyHook 的重試機制依方案調整,底層使用 QStash 代管的重試基礎設施。傳遞嘗試次數是 Free 方案 3 次、Pro 方案 5 次、Scale 方案 10 次,採用固定的指數階梯:5 分鐘、30 分鐘、3 小時、18 小時,之後每次嘗試間隔 24 小時。這些延遲沒有加上隨機抖動。傳遞逾時的上限分別是 60 秒、120 秒和 5 分鐘。連續失敗 20 次後,斷路器會暫停對該目的地的傳遞(10 分鐘,再次跳脫時依序為 30 分鐘、2 小時和 24 小時),並寄信通知你,所以不會有重試風暴。408 和 429 以外的 4xx 完全不會重試,失敗警示也會說明原因,因為在這種情況下重試沒有幫助,你需要修正請求的格式。
重試用盡不代表事件遺失。事件會以 status=failed 保存在你的事件紀錄裡,在方案的可重播期間內(Free 方案 3 天、Pro 方案 30 天、Scale 方案 90 天),你隨時可以從儀表板或 API 手動重播。
密技
會改變你看待重試方式的關鍵是:如果重播的成本很低,你就不需要激進的重試。
如果重播只要按一下,在一小時內重試 3 次就夠了。多數暫時性失敗會被自動接住;少見的長時間服務中斷,就等伺服器恢復正常後,由人重播那批失敗的事件。
對照只靠重試的系統:你為了以防萬一,把重試期間拉長到 72 小時,代表要對一個你明知已經當機的端點,白白傳遞 72 小時。
低成本的重播,嚴格來說就是比激進的重試好,而 AnyHook 正是圍繞這一點設計的。
結論
把重試當成處理暫時性失敗的快速路徑,而不是持久保存和重播的替代品。在糟糕的那幾週還能撐住的系統,都是失敗的傳遞永遠能復原的系統,因為事件在傳遞之前就已經記錄下來。
每個發送端實際的重試排程,都在我們的 Webhook 服務商參考資料裡。重試也有一個沒人能控制的硬上限:一直失敗下去,Shopify 可能會刪除訂閱,Stripe 則會停用端點。而重試,正是重複和順序錯亂一定會發生、而不是罕見的原因:同一個 Webhook 收到兩次。