下游只是抖了三秒,我們的無腦重試把它掛了半小時
System Design·2026年7月16日·7 分鐘閱讀

下游只是抖了三秒,我們的無腦重試把它掛了半小時

L
Leo Wu

Leo Wu,主力 Go 的後端工程師,做過金流與高併發系統,信奉重試是為了熬過抖動、不是拿來對抗故障。

那三秒,我們自己把它變成半小時

事故那天下午,我們的訂單服務所依賴的一個內部帳務 API,因為背後資料庫做了一次主從切換,短暫不可用了大概三秒。三秒,這種等級的抖動其實每個月都會發生幾次,正常來說沒人會察覺——重試一兩下就過去了。

但那天不一樣。三秒之後,帳務 API 不但沒恢復,反而 CPU 直接打滿、連線池爆掉,錯誤率從 0 衝到 90% 以上,而且一路撐了將近半個小時才慢慢救回來。更慘的是,帳務 API 掛掉又拖累了另外三個共用它的服務,值班群組裡一連串告警像放鞭炮。

事後覆盤時最讓我難受的一句話是:下游本來只是想休息三秒,是我們——還有其他所有呼叫方——一起把它活活打死的。

我一開始以為是下游自己爛掉了

剛開始我完全甩鍋給下游。「一定是它們資料庫切換沒做好」「主從切換三秒本來就不該讓服務不可用」,我在群組裡打字的手都是抖的。畢竟從我們的角度看,就是下游回了一大堆 5xx 和連線逾時,看起來確實是它壞了。

直到我把帳務 API 那半小時的入流量畫出來,我才閉嘴。它平常的 QPS 大概是 4000,但在故障那段時間,進來的請求量飆到了將近 25000,是平常的六倍。問題是那天並沒有什麼行銷活動,真實使用者的下單量根本沒變。

那多出來的兩萬多 QPS 是哪來的?是我們自己。是所有呼叫方在收到失敗之後,瞬間、同時、無腦地重打出來的。下游那三秒只是點了火,把火燒成森林大火的,是我們的重試邏輯。

下游服務的入流量 QPS:下游只抖動三秒,所有呼叫方同時無腦重試,把流量從 4k 放大到 25k、六倍尖峰把它徹底壓垮,加上指數退避、jitter 與熔斷後恢復
下游服務的入流量 QPS:下游只抖動三秒,所有呼叫方同時無腦重試,把流量從 4k 放大到 25k、六倍尖峰把它徹底壓垮,加上指數退避、jitter 與熔斷後恢復

真正的根因:大家約好了同一秒一起重打

我去翻我們的 client 重試設定,看到的是這種很典型、很「看起來沒問題」的寫法:

  • 失敗就重試,最多重試很多次(有一段甚至根本沒設上限)
  • 重試間隔固定,比如每次隔 200ms 再打一次
  • 只要不是成功,就全部一視同仁地重試

這裡藏了兩顆地雷。

第一顆是 thundering herd(驚群效應)。當下游在同一瞬間對所有人回失敗,這些客戶端就在同一時刻收到錯誤、又用同一個固定間隔去算下一次重試時間,於是大家會非常整齊地在 200ms 後「一起」再打一次。原本分散在時間軸上的流量,被下游這一次集體失敗給「對齊」成了一根一根的尖峰。剛要爬起來的下游,每 200ms 就被兩萬多個請求同步捶一次,根本站不起來。

第二顆是沒有上限、也沒有整體 deadline 的重試。單一請求失敗重試個十次八次,每個 client 自己看好像很有韌性,但把所有 client 疊起來,就是把原始流量放大好幾倍灌回去。重試在這裡不是在「熬過抖動」,而是在「主動製造流量」。下游越慢,逾時越多,逾時越多就觸發越多重試,重試又讓它更慢——一個很漂亮的正回饋雪崩。

說穿了,我們寫的不是重試機制,是一台在下游最虛弱的時候幫它加壓的 DDoS 產生器。

修法一:指數退避加上 jitter,把大家打散

第一件事是把固定間隔換成 指數退避(exponential backoff):第一次重試等 200ms,第二次 400ms,第三次 800ms,依此類推,讓重試的壓力隨時間指數級地退下來,給下游喘息的空間,而不是用固定節奏持續捶它。

但光有 backoff 還不夠,因為如果大家都是「200ms、400ms、800ms」這種一模一樣的序列,尖峰只是從一根變成間隔拉大的好幾根,大家還是同步的。真正的解法是加上 jitter(隨機抖動),在退避時間上加一段隨機量,把每個 client 的下一次重試時間打散到一個區間裡。

我最後選的是所謂的 full jitter:下一次等待時間不是固定的 base * 2^n,而是在 [0, base * 2^n] 這個區間裡取一個隨機值。這樣即使一萬個 client 同時失敗,它們的重試時間也會被均勻抹平在時間軸上,尖峰就不見了。這個改動聽起來很小,但它是把「驚群」變回「細雨」最關鍵的一步。

同時我也補上兩條硬邊界:最大重試次數(我們設 3 次)和整體 deadline。deadline 特別重要——重試的總時間必須被上游的逾時預算框住,不能為了重試而讓使用者傻等十幾秒,最後還是失敗。重試是為了在時間預算內偷偷把抖動蓋過去,不是無限期地跟故障拔河。

修法二:只重試「值得重試」的,再加熔斷與 retry budget

退避和 jitter 解決的是「怎麼重試」,但更根本的問題是「該不該重試」。我們原本是無腦全部重試,這其實很危險:

  • 對 4xx(例如參數錯誤、驗證失敗)重試,只是把一個必然失敗的請求打好幾遍,純浪費。
  • 非冪等操作重試,可能造成重複扣款、重複建單這種真正的資料事故。金流系統裡這條線我踩過,很痛。

所以現在的原則是:只對暫時性、可重試的錯誤(連線逾時、503、連不上)做重試,而且只對冪等的操作重試;非冪等的寫入要嘛先靠冪等鍵(idempotency key)保護,要嘛就乾脆不重試、直接把錯往上拋。

再往上一層,我加了兩道整體性的防線:

  • circuit breaker(熔斷器):當對某個下游的錯誤率在短時間內衝過門檻,就直接把它標記為「壞掉」,接下來的請求快速失敗、根本不打出去,等一段冷卻時間再放少量探測流量試水溫。這樣在下游明顯掛掉時,我們是第一時間停手,而不是拼命重試去補刀。
  • retry budget(重試預算):限制重試流量占總流量的比例,比如重試最多只能是正常請求的 10%。一旦超過這個預算,多出來的重試就直接放棄。它的意義是:即使前面所有判斷都失手,也有一條總閘門保證我們永遠不會把六倍流量灌回下游。

這三層(單次請求的退避與 jitter、連線層級的熔斷、系統層級的 retry budget)合起來,才算真的把「重試會放大故障」這件事關進籠子裡。

小結

這次事故給我最深的一句話是:重試是用來掩蓋短暫抖動的,不是拿來對抗真正的故障的。 當下游只是打個噴嚏,聰明的重試能讓使用者無感地滑過去;但當下游真的病了,還在拼命重試,就只是在幫它挖墳墓。

幾條我現在會寫在 code review 檢查表上的原則:

  • 重試一定要配 exponential backoff,而且一定要加 jitter,不然你只是把驚群的節奏拉長而已。
  • 一定要有最大重試次數整體 deadline,沒有上限的重試就是流量放大器。
  • 想清楚該不該重試:只重試暫時性錯誤與冪等操作,別碰 4xx 和非冪等寫入。
  • circuit breaker 在下游明顯壞掉時快速失敗,用 retry budget 給整個系統一條保命的總閘門。

重試是雙面刃。沒有退避與 jitter 的重試,會忠實地把一個三秒的小問題,放大成半小時的大事故——這次是我親手示範的。

#重試#退避#Jitter#熔斷器#彈性設計#事故覆盤

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章