服務一旦拆開,呼叫就從「函式呼叫」變成「網路呼叫」。函式呼叫要嘛回傳、要嘛 panic,幾乎是瞬間的事;網路呼叫多了第三種狀態——「卡住」。我看過太多次線上事故,根因都不是某個服務掛掉,而是某個服務「變慢」,然後慢的代價沿著呼叫鏈一路傳染,最後整個系統一起倒。這篇講的就是怎麼防這件事:逾時、重試、熔斷,還有它們背後的艙壁隔離與降級。這些都是我在交易所和金流系統上真的踩過、調過、半夜被叫起來救過的東西。
最致命的坑:沒設逾時
我先把結論講在前面:沒設逾時是分散式系統裡最常見、也最致命的坑。不是之一,就是第一名。
原因很簡單。每個服務都有一個有限的資源池——連線池、執行緒、goroutine、檔案描述符。當你呼叫一個下游服務而沒設逾時,這個請求就會一直佔著一條連線等回應。如果下游只是慢,沒有掛,那條連線就會卡在那裡,直到天荒地老。
我講一個真實的場景。我們的下單服務要呼叫風控服務做額度檢查,風控服務又要查一個外部徵信來源。某天那個外部來源網路抖動,回應從 50 毫秒掉到 30 秒,但它沒有回傳錯誤,就是慢。風控服務沒設逾時,於是每個請求都卡 30 秒;下單服務也沒設逾時,於是下單的連線池在幾秒內被佔滿。接著前端 API gateway 呼叫下單服務也開始卡,gateway 的連線池也滿了。最後使用者看到的是整站打不開——但其實除了那個外部徵信來源,我們自己的服務一個都沒掛。
這就是雪崩式連鎖故障(cascading failure)。一個慢服務,透過共享的連線資源,把慢傳染給所有上游。而傳染的媒介,就是「沒有上限的等待」。
我的原則:每一個跨網路的呼叫都必須有逾時
沒有例外。HTTP client、gRPC client、資料庫查詢、Redis、訊息佇列、呼叫第三方 API——全部都要有逾時。很多語言的預設 client 是「永不逾時」或「逾時很長」,Go 的 http.Client 零值就是沒有逾時,這是個陷阱,一定要自己設。
逾時的意義不只是「放棄這個請求」,更重要的是把被佔用的資源還回去。一個有逾時的請求,最壞情況也只佔資源到逾時為止;一個沒逾時的請求,可能永遠佔著。前者讓系統有自癒的可能,後者只會越積越多直到崩潰。
逾時要分層設,不是一個數字
很多人以為逾時就是設一個「總共等多久」的數字,這太粗糙了。實務上逾時至少要分兩層:
- 連線逾時(connection timeout):建立連線要等多久。這通常很短,內網服務之間設個 1 到 3 秒就很夠,因為連線建不起來通常代表對方根本不在或網路斷了,等再久也沒用,快速失敗反而好。
- 讀取逾時(read timeout):連線建好後,等對方把回應送完要多久。這要看業務,一個簡單查詢可能 500 毫秒,一個複雜報表可能要好幾秒。
把這兩個分開設很重要。因為它們代表的問題本質不同:連線建不起來是「對方不在」,讀取超時是「對方在但處理不完」。混在一起設一個大數字,你會在連線都建不起來的情況下還傻等好幾秒,白白浪費資源。
整體 deadline 要沿呼叫鏈傳遞
這是比分層更進階、但更關鍵的觀念。單一呼叫設逾時還不夠,你要管理的是整條呼叫鏈的總時間預算。
想像一個請求進來:gateway → 下單服務 → 風控服務 → 外部徵信。如果 gateway 對使用者承諾 3 秒內回應,那這 3 秒就是整條鏈的 deadline。下單服務拿到請求時,應該知道「我最多只剩 3 秒」;它呼叫風控時,要把「剩下多少時間」傳下去;風控呼叫外部徵信時,又要再扣掉自己花的時間。
為什麼這很重要?因為如果每一層都各自設一個固定逾時,數字會打架。假設每層都設 5 秒逾時,但 gateway 只願意等 3 秒。結果是:gateway 在 3 秒時放棄了,回給使用者錯誤;但下游的風控和徵信還傻傻地跑,因為它們的 5 秒還沒到。這就是做白工——使用者已經走了,你的服務還在為一個沒人要的結果消耗資源。在高併發下,這種白工會累積成可觀的資源浪費。
Go 的 context.Context 就是為這件事設計的。我的做法是:deadline 從入口建立一次,然後用 context 一路往下傳。每一個下游呼叫都吃這個 context,當上層 deadline 到了,context 被取消,所有還在進行的下游呼叫會一起收到取消訊號,立刻停手。這樣整條鏈的時間預算是一致的,沒有人做白工。
我的原則是:寧可在入口設好一個合理的總 deadline,沿鏈傳遞,也不要每層各設一個拍腦袋的逾時數字。前者是一個協調好的整體,後者是一盤各自為政的散沙。
重試:看起來簡單,其實最容易出事
逾時之後自然會想到重試——失敗了再試一次嘛,網路抖動很常見,重試一下常常就好了。這個直覺沒錯,但重試是這幾個機制裡最容易做錯、做錯後果最嚴重的一個。
只重試冪等操作
這是鐵則。冪等(idempotent)的意思是同一個操作做一次和做多次,結果一樣。查詢是冪等的,重試一百次也只是查一百次,沒差。但「扣款」「下單」「轉帳」這種寫入操作,通常不是冪等的——重試一次可能就多扣一次錢。
我做金流串接時,這是最戰戰兢兢的部分。串接第三方支付,送出扣款請求後連線斷了,你根本不知道對方到底收到沒、扣了沒。這時候你敢直接重試嗎?不敢。盲目重試很可能變成重複扣款,然後就是客訴、對帳對不平、半夜被叫起來。
正確的做法是:寫入操作要嘛設計成冪等,要嘛不要自動重試。讓寫入冪等的標準手法是冪等鍵(idempotency key)——每一筆扣款請求帶一個唯一的鍵,第三方(或你自己的服務)用這個鍵去重,同一個鍵的請求只會真正執行一次,後續重試只會拿到第一次的結果。成熟的支付閘道幾乎都支援冪等鍵,如果你串的對象支援,一定要用。如果不支援,那寧可不重試,改成「查詢狀態再決定」——先查那筆交易到底成沒成,再決定要不要補送。
指數退避加抖動
就算是可以重試的操作,也不能無腦地「立刻重試、再立刻重試」。要用指數退避(exponential backoff):第一次失敗等 100 毫秒,第二次等 200 毫秒,第三次 400 毫秒,以此類推。理由是:如果下游是因為過載而失敗,你立刻重試只是火上加油,讓它更難復原。退避給下游喘息的空間。
但光退避還不夠,還要加抖動(jitter)——在退避時間上加一個隨機量。為什麼?設想一個下游服務短暫掛掉,這瞬間有一千個請求同時失敗。如果大家都用一樣的退避策略,它們會在「完全相同的時間點」一起重試,形成一波同步的尖峰打在剛要復原的下游上,可能直接再把它打掛。加了抖動之後,這一千個重試會散在一段時間裡,平滑很多。
我的預設配方是:指數退避為基底,疊加隨機抖動,設一個退避上限避免等太久。
限制重試次數,警惕重試風暴
重試一定要有上限。我的習慣是內部服務之間最多重試 2 到 3 次,超過就放棄,把錯誤往上拋。不要設成「重試到成功為止」,那在下游真的掛掉時會變成無限迴圈。
更要警惕的是重試風暴(retry storm)。這是分散式系統裡很陰險的一種放大效應。假設你的呼叫鏈有三層,每層都設「重試 3 次」。下游掛掉時,最底層重試 3 次,中間層對每次也重試 3 次,於是中間層發了 9 次;最上層再對中間層重試 3 次,變成 27 次。本來一個請求,放大成 27 個請求打在一個已經掛掉的服務上。你以為重試在幫忙,其實是在補刀。
避免重試風暴有幾個原則:
- 只在呼叫鏈的某一層做重試,不要每層都重試。通常放在最接近故障點的那層,或統一在邊緣處理。
- 重試要尊重整體 deadline。如果 context 已經沒時間了,就別重試了,直接失敗。
- 配合熔斷器(下面講)。當下游已經被熔斷,連第一次都不該發,更別說重試。
熔斷器:給系統一個「先別打了」的開關
逾時讓單一請求不會卡死,重試讓暫時的抖動能自癒。但如果下游是真的壞了——不是抖一下,而是持續性故障——那繼續發請求(甚至重試)只是浪費資源、製造白工,還拖累自己。這時候需要的是熔斷器(circuit breaker)。
熔斷器的概念借自電路的保險絲:當電流異常,保險絲熔斷,切斷電路保護後面的設備。軟體的熔斷器一樣——當對某個下游的呼叫失敗率高到一個程度,就「跳開」,接下來一段時間直接快速失敗,根本不發請求。
三態:關閉、開啟、半開
熔斷器有三個狀態,理解這三態就理解了它的全部:
- 關閉(Closed):正常狀態。請求照常發給下游,熔斷器在旁邊記帳——統計失敗率或連續失敗次數。當失敗超過設定的門檻(比如「最近 20 個請求裡失敗超過 50%」),熔斷器跳到開啟。
- 開啟(Open):熔斷狀態。所有請求直接被拒絕,立刻回錯誤,完全不碰下游。這是熔斷器的核心價值:在下游已經壞掉時,不要再浪費資源去等一個註定失敗的呼叫,也讓下游有空間自己復原,不被持續的流量壓著。開啟狀態會維持一段冷卻時間(比如 30 秒)。
- 半開(Half-Open):冷卻時間到了之後,熔斷器不會直接回到關閉——它怎麼知道下游好了沒?所以進入半開,放少量的試探性請求過去。如果這幾個試探成功了,代表下游復原了,切回關閉;如果還是失敗,代表還沒好,退回開啟,再等一個冷卻週期。
半開這個設計很關鍵。它解決的是「怎麼安全地探測復原」。如果沒有半開,冷卻時間一到就把全部流量放回去,萬一下游還沒好,等於瞬間又被打掛,白白浪費剛才的冷卻。半開用少量請求試水溫,安全得多。
為什麼需要熔斷,逾時不夠嗎
有人會問:我每個呼叫都設了逾時,下游壞掉時不就是每個請求等到逾時然後失敗嗎,為什麼還要熔斷?
差別在資源和時間。假設逾時設 2 秒,下游持續壞掉。沒有熔斷的話,每一個請求都要乖乖等滿 2 秒才失敗,這 2 秒裡它佔著一條連線、一個 goroutine。高併發下,這些「註定失敗但還在等逾時」的請求會大量堆積,一樣會把資源池佔滿。逾時保護的是單一請求的上限,熔斷保護的是不要去發那些註定失敗的請求。下游壞掉時,熔斷讓你的失敗是「立刻失敗」而不是「等 2 秒才失敗」,這在規模上差很多。
我的原則是:逾時是底線,每個呼叫都要有;熔斷是針對重要的、會持續性故障的下游額外加的保護。不是每個呼叫都需要熔斷器,但對外部第三方、對核心相依服務,我會加。
艙壁隔離:不要把雞蛋放在同一個資源池
前面講的雪崩,本質是共享資源被單一故障佔滿。連線池是共享的,一個慢下游把它佔滿,所有用這個池的呼叫都遭殃。艙壁隔離(bulkhead)就是針對這個問題。
名字來自船的設計:船艙用隔板分成幾個獨立的水密艙,某一艙破了進水,水被隔板擋住,不會淹沒整條船。軟體上的對應就是:把資源池按下游或按重要性分開,讓一個下游的故障不會耗盡所有資源。
具體怎麼做:
- 分開連線池。呼叫風控服務用一個連線池,呼叫外部徵信用另一個。徵信慢掉佔滿它自己的池,但風控的池不受影響,下單的主流程還能走。
- 限制並發。對每個下游設一個並發上限——比如「同時最多 50 個請求打到徵信服務」。超過的請求直接快速失敗或排隊。這樣即使徵信慢,被它拖住的也最多 50 個 goroutine,而不是無上限地堆積到把整個服務拖垮。
我做活動搶購系統時對這點體會很深。搶購的瞬間流量極大,如果不限制對庫存服務的並發,瞬間幾萬個請求全打過去,庫存服務直接被打死,然後反過來拖垮整個搶購流程。限制並發加排隊,雖然會讓一部分使用者等久一點或直接被擋,但保住了系統不崩——讓一部分請求失敗,好過讓全部請求失敗。這是艙壁隔離的核心取捨。
降級與快速失敗:壞掉的時候怎麼優雅地壞
機制都到位了,最後一個問題是:當熔斷開啟、或請求被艙壁擋下、或逾時了,這個請求要回什麼給上層?這就是降級。
降級 fallback
降級(fallback)是指:當主要路徑失敗時,提供一個次要的、可能品質較低但可用的結果,而不是直接報錯。
舉幾個我用過的例子:
- 商品頁要顯示「推薦商品」,這個推薦服務掛了,與其讓整個商品頁開不出來,不如降級成顯示一組預設的熱門商品。使用者幾乎無感。
- 風控的某個非關鍵加分項服務掛了,可以降級成「用一個保守的預設值」繼續走流程,而不是讓整筆交易卡死。但注意——這要業務上能接受才行,有些風控檢查是絕對不能降級跳過的。
- 快取掛了,降級成直接打資料庫(同時要小心別把資料庫打垮,這裡又要配合並發限制)。
降級的精神是:區分「核心功能」和「錦上添花的功能」。錦上添花的東西掛了就降級,別讓它影響核心;核心的東西掛了,那就該誠實地失敗。
快速失敗
降級不是萬靈丹,不是所有東西都有合理的 fallback。扣款不能降級,你不能「假裝扣款成功」。這種情況下正確的做法是快速失敗(fail fast)——既然註定失敗,就立刻、明確地失敗,不要拖。
快速失敗的價值在於:它不浪費資源等一個沒希望的結果,也讓上層能盡快知道「這條路不通」,去走它的降級或回報使用者。熔斷器開啟時的立刻拒絕,本質上就是一種快速失敗。拖泥帶水的失敗比乾脆的失敗更糟,因為它佔著資源、誤導上層、累積延遲。
這些機制怎麼搭在一起
講到這裡,逾時、重試、熔斷、艙壁、降級——這五個其實是一套組合拳,不是五選一。我把一個對外部第三方的呼叫,理想的防護順序整理一下,從外到內大致是這樣:
- 最外層是艙壁:這個呼叫用獨立的連線池和並發上限,確保它最壞情況也不會吃掉別人的資源。
- 進來先過熔斷器:如果熔斷開啟,直接快速失敗,根本不發請求,連下面都不用走。
- 熔斷關閉時,發出請求,套上逾時(分層的連線逾時和讀取逾時,並尊重整條鏈的 deadline)。
- 如果失敗且操作冪等,進入重試(指數退避加抖動,有次數上限,尊重 deadline)。重試的失敗會回饋給熔斷器計數。
- 最終還是失敗的話,執行降級:有 fallback 就走 fallback,沒有就乾淨地把錯誤往上拋。
順序是有道理的:熔斷在重試外面,這樣熔斷開啟時連重試都省了,避免重試風暴;艙壁在最外面,因為它是資源層面的保護,跟成功失敗無關,任何時候都該生效。
實務上你不用每個呼叫都把五個全配齊。我的判斷是:逾時是所有跨網路呼叫的最低標準,一個都不能少。重試只給冪等操作。熔斷、艙壁、降級則看這個下游有多重要、有多容易壞——對核心相依和外部第三方,值得全套配上;對一個無關緊要、自己內部又穩定的小服務,逾時加個合理的重試可能就夠了。過度防護也是成本,會增加複雜度和除錯難度,要按需要來。
小結
分散式系統的可靠性,很大一部分不是來自「讓每個服務都不出錯」——那不可能——而是來自「當某個服務出錯時,讓故障被隔離、被吸收,不要擴散」。逾時防止單一慢請求卡死資源,重試吸收暫時的抖動,熔斷在持續故障時切斷無謂的呼叫,艙壁把故障關在一個艙裡,降級讓非核心功能優雅退場。它們各自解決一塊,合起來才是完整的防線。
我最想留下的一句話是:系統不是因為某個服務掛了而倒,而是因為沒有一道防線去吸收那次故障。把這些機制當成你系統的免疫系統——平常看不出價值,真正出事的那一夜,它就是你能不能好好睡覺的差別。