一個第三方介面變慢,沒設逾時,把我整個下單服務拖垮了
後端工程師,做過金流與高併發系統,被一個沒設逾時的第三方呼叫拖垮過整條下單線。
一根沒設逾時的呼叫,拖垮一整條線
那次故障的起點,小到你會覺得不可思議:一個第三方的金流查詢介面,變慢了。
不是掛掉,是變慢——本來幾十毫秒回應的東西,那天開始要等三十秒、六十秒才回。而我們呼叫它的那行程式碼,沒有設逾時。於是每一個打到這個介面的請求,都癡癡地等在那裡,佔著一個連線、一個 worker。幾分鐘之內,我的下單服務把整個連線池、整個 worker 都耗光了,連跟這個第三方完全無關的請求也一起排隊、一起卡死。一個「別人家的介面變慢」,就這樣升級成「我整條下單線掛掉」。
這篇講我從那次學到的逾時、重試、熔斷。它們聽起來是三個獨立的技術,但骨子裡是同一件事:別讓一個出問題的服務,把還健康的部分一起拖下水。
最致命的坑:沒設逾時
先講最痛的。那次事故的根因,不是第三方變慢——它變慢是它的事,你控制不了。根因是我預設相信它會很快回來。
網路呼叫沒有逾時,等於你把「我要等多久」這個決定權,交給了對面那個你完全控制不了的服務。它想讓你等一分鐘,你就得等一分鐘。這在單機測試時完全看不出問題,因為本地一切都很快。但正式環境只要對面抖一下,這個「無限等待」就會像那晚一樣,順著連線池一路把你吃乾抹淨。
所以我後來的鐵律很簡單:每一個跨網路的呼叫,都必須有逾時。 沒有例外。DB、Redis、HTTP、gRPC,只要它會走網路,就要有一個「等到這個時間還沒回,我就放棄」的上限。
逾時要分層設,不是一個數字
踩過一次之後,我也發現逾時不是隨便塞一個數字就好。
一個對外的請求,底下可能串了好幾個下游呼叫。如果每一層都各設各的、彼此不相干,你會遇到一種很尷尬的狀況:使用者早就等到不耐煩(比如整體超過三秒)走了,你後端某個下游卻還設了五秒逾時、傻傻地在那邊跑。所以我現在會讓整體的 deadline 沿著呼叫鏈往下傳——上游剩多少時間,下游最多就用這麼多,絕不超過。Go 的 context 帶 deadline 正好是幹這個的。
重試:看起來簡單,其實最容易出事
逾時之後很自然會想到重試——失敗了再試一次嘛。但重試是我看過最容易好心辦壞事的東西。
- 只重試冪等操作。查詢重試沒問題。但「扣款」這種非冪等的,你重試前一定要想清楚:上一次到底成功了沒?搞不好對方已經扣了,你重試又扣一次。這種我寧可不自動重試,也不要造成重複扣款。
- 一定要退避加抖動。失敗了立刻重試、大家又同時重試,只會讓已經奄奄一息的下游被補刀。要指數退避(等的時間逐次拉長),還要加隨機抖動,把重試的時間點打散,別讓所有 client 像講好一樣同一秒一起衝。
- 限制次數,警惕重試風暴。這點我另外吃過一次虧:下游慢,上游狂重試,重試本身的流量把下游徹底壓垮,形成惡性循環。重試次數一定要有上限。
熔斷器:給系統一個「先別打了」的開關
那次事故如果有熔斷器,就不會燒那麼久。
熔斷器的想法很像家裡的保險絲:當它發現某個下游的失敗率高到不對勁,就「跳閘」——接下來一段時間,直接讓打到這個下游的請求快速失敗,根本不真的送出去。這麼做有兩個好處:一是不再浪費你的資源去等一個註定會失敗的呼叫(正是那晚拖垮我的東西),二是給下游一個喘息、恢復的空間,不要在它最虛弱的時候還一直捶它。
它通常有三態:關閉(正常放行)、開啟(跳閘、快速失敗)、半開(過一陣子放幾個請求去試探,好了就恢復、還是不行就繼續跳閘)。
我到現在還在調的一件事
老實說,熔斷跟重試的那些參數——失敗率多少該跳閘、退避等多久、重試幾次——我到現在都沒有一組「放諸四海皆準」的數字。每個服務的特性不一樣,設太敏感會誤傷、設太鈍又擋不住雪崩。我的做法是先給一組保守的預設,再根據線上實際的表現慢慢調。這塊沒有標準答案,別人給你的數字頂多是起點。
小結
那晚讓我印象最深的,不是任何一個技術細節,是那個「一根沒設逾時的呼叫,可以拖垮一整條線」的畫面。逾時、重試、熔斷,本質上都是在回答同一個問題:當你依賴的某個東西出問題時,你的系統是跟著一起死,還是能把傷害隔離在一個角落、讓其他部分繼續活。做後端久了你會發現,你花很多力氣寫的不是「怎麼成功」,而是「別人失敗的時候,我怎麼不要跟著死」。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。