[譯]分佈式系統如何從故障中恢復?—重試、超時和退避

NO IMAGE

重試、超時和退避

分佈式系統很難。即使我們學了很多構建高可用性系統的方法,也常常會忽略系統設計中的彈性(resiliency)。

我們肯定聽說過容錯性,但什麼是“彈性”呢?個人而言,我喜歡將其定義為系統處理意外情況並最終從中恢復的能力。有很多方法使你的系統能從故障中回彈,但在這篇文章中,我們主要關注以下幾點:

超時

簡單來說,超時就是兩個連續的數據包之間的最大不活動時間。

假設我們在某個時刻已經使用過了數據庫驅動和 HTTP 客戶端。所有幫助你的服務連接到一個外部服務器的客戶端或驅動都有 Timeout 參數。這個參數通常默認為零或 -1,表示超時時間未定義,或是無限時間。

例如:參考 connectTimeoutsocketTimeout 的定義 Mysql Connector 配置

大多數對外部服務器的請求都附有一個超時時間。當外部服務器沒有及時響應時,超時的設置非常有必要。如果沒有設置超時,並使用默認值 0/-1,你的程序可能會阻塞幾分鐘或更長的時間。這是因為,當你沒有收到來自應用服務器的響應,並且你的超時時間無限或非常大時,這個連接會一直開著。隨著有更多的請求到來,更多的連接會打開,並永遠無法關閉。這會導致你的連接池耗盡,進而導致你的應用的故障。

那麼,每當你使用這樣的連接器來配置你的應用時,請務必在配置中設置顯式的超時值。

超時必須在前端和後端中都實現。如果一個讀/寫操作在一個 REST API 或 socket 接口上阻塞了太長時間,它應當拋出異常,並且斷開連接。這可以通知後端取消操作並關閉連接,從而防止連接始終打開。

重試

我們可能需要了解瞬時故障這個術語,因為我們後面會頻繁用到它。簡單地說,服務中的瞬時故障是一種暫時的失靈,例如網絡擁塞,數據庫過載,是一種在有足夠的冷卻週期之後也許能自己恢復的故障。

如何判斷一個故障是否是瞬時的?

答案取決於你的 API/Server 響應的實現細節。如果你有一個 REST API,請返回 503 Service Unavailable,而不是其他 5xx/4xx 錯誤碼。這可以讓客戶端知道超時是由“臨時的過載”引起的,而不是由於代碼層面的錯誤。

重試雖然有用,但如果沒有正確地配置,則會讓人討厭。下面闡述瞭如何找出正確的重試方法。

重試

如果從服務器收到的錯誤是瞬時的,例如網絡數據包在傳輸時損壞,應用程序可以立即重試請求,因為故障不太可能再次發生。

然而,這種方法非常激進。如果你的服務已經滿負荷運行,或是已經完全不可用,這種方法可能對你的服務有害。這種方法還會拖慢應用的響應時間,因為你的服務會嘗試不斷執行一個失敗的操作。

如果你的業務邏輯需要這樣的重試策略,你最好限制重試的次數,不向同一個源頭髮送過多的請求。

帶延遲的重試

如果是連接失敗或網絡上的過大流量導致的故障,應用程序則應當根據業務邏輯,在重試請求之前添加延遲時間。

for(int attempts = 0; attempts < 5; attempts++)
{
    try
    {
        DoWork();
        break;
    }
    catch { }
    Thread.Sleep(50); // 延遲
}

當使用一個連接至外部服務的庫時,請檢查它是否實現了重試策略,允許你配置重試的最大次數、重試之間的延遲等。

你還可以通過設置 Retry-After 響應頭,在服務器端實現重試的策略。

用日誌記錄操作失敗的原因也很重要。有時候操作失敗是因為缺少資源,這可以通過添加更多的服務實例來解決。也有時候操作失敗可能是因為內存洩漏或空指針異常。那麼,添加日誌跟蹤你的應用程序的行為就很重要了。

退避

如上所述,我們可以向重試策略中添加延遲。這種延遲通常稱為線性退避。這可能不是實現一個重試策略的最佳方法。

考慮這種情況:你的服務因為數據庫的過載發生了故障。我們的請求很可能在幾次重試之後會成功。但不斷髮送的請求也可能加重你的數據庫服務器的過載問題。因此,數據庫服務會在過載狀態停留更長時間,也會需要更多的時間從過載狀態中恢復。

有幾種策略可以用於解決這個問題。

1. 指數退避

顧名思義,指數退避不是在重試之間進行週期性的延遲(例如 5 秒),而是指數性地增加延遲時間。重試會一直進行到最大次數限制。如果請求始終失敗,就告訴客戶端請求失敗了。

你還必須設置最大延遲時間的限制。指數退避可能導致出現非常大的延遲時間,導致請求的 socket 保持無限期開啟,並使線程“永遠”休眠。這會耗盡系統資源,導致連接池的更多問題。

int delay = 50
for(int attempts = 0; attempts < 5; attempts++)
{
    try
    {
        DoWork();
        break;
    }
    catch { }
    
    Thread.sleep(delay);
    if (delay < MAX_DELAY)      // MAX_DELAY 可能依賴於應用程序和業務邏輯
    {
        delay *= 2;
    }
}

指數退避在分佈式系統中的一個主要缺點是,在同一時間開始退避的請求,也會在同一時間進行重試。這導致了請求簇的出現。那麼,我們並沒有減少每一輪進行競爭的客戶端數量,而是引入了沒有客戶端競爭的時期。固定的指數退避並不能減少很多競爭,並會生成負載峰值

2. 帶抖動的退避

為了處理指數退避的負載峰值問題,我們向退避策略中添加抖動。抖動是一種去相關性策略,在重試的間隔中添加隨機性,從而分攤了負載,避免了出現網絡請求簇。

抖動通常不是任何一項配置屬性,需要客戶端來實現。抖動所需要的只是一個可以加入隨機性的函數,可以在重試之前動態地計算出等待的時間。

引入抖動之後,最初的一組失敗的請求可能聚集在一個很小的窗口中,例如 100 ms。但是在每個重試周期之後,請求簇會攤開到越來越大的時間窗口中。當請求分攤在足夠大的窗口上時,服務就很可能能夠處理這些請求。

int delay = 50
for(int attempts = 0; attempts < 5; attempts++)
{
    try
    {
        DoWork();
        break;
    }
    catch { }
    
    Thread.sleep(delay);
    delay *= random.randrange(0, min(MAX_DELAY, delay * 2 ** i)) // 只是生成一個簡單的隨機數
}

長時間的瞬時故障的情況下,任何的重試可能都不是最好的方法。這種故障可能是由於連接失效,電力中斷(是的,非常真實的情況)導致的。客戶端最終會重試若干次,浪費了系統資源,並進一步導致了更多系統中的故障。

那麼,我們需要一種可以確定故障是否會長期持續的機制,並實現一種應對該情況的解決方案。

3. 斷路器

斷路器模式在處理服務的長時間瞬時故障時非常有用。它通過確定服務的可用性,防止客戶端重試註定會失敗的請求。

斷路器設計模式要求在一系列的請求中保留連接的狀態。讓我們看看 failsafe 實現的斷路器

CircuitBreaker breaker = new CircuitBreaker()
  .withFailureThreshold(5)
  .withSuccessThreshold(3)
  .withDelay(1, TimeUnit.MINUTES);

Failsafe.with(breaker).run(() -> connect());

當一切正常運行時,沒有故障,斷路器保持在關閉狀態。

當達到執行故障的閾值時,斷路器跳閘並進入打開狀態。這意味著,後續的所有請求會直接失敗,不會經過重試的邏輯。

經過一段延遲之後(如上述設置的 1 分鐘),斷路器會進入半開狀態,測試網絡請求的問題是否依然存在,並決定斷路器是應當關閉還是打開。如果請求成功,斷路器會重置為關閉狀態,否則會重新置為打開狀態。

這有助於在長時間的故障中避免重試執行的聚集,節省系統資源。

雖然斷路器可以用一個狀態變量在本地維護。但是如果你有一個分佈式系統,你可能需要一個外部存儲層。在多節點的配置中,應用服務器的狀態需要在多個實例之間共享。在這種場景下,你可以使用 Redis、memcached 來記錄外部服務的可用性。在向外部服務發送任何請求之前,從持久存儲中查詢服務的狀態。

分佈式系統中的冪等性

冪等的服務是指客戶端可以重複地發起相同的請求,並得到相同的最終結果。雖然服務器會對此操作產生相同的結果,但客戶端不一定作出相同的反應。

對於 REST API 而言,你需要記住 ——

  • POST 不是冪等的 —— POST 導致在服務器上創建新資源。n 個 POST 請求會在服務器上創建 n 個新的資源。
  • GETHEADOPTIONSTRACE 方法永遠不會改變服務器上資源的狀態。因此,它們總是冪等的。
  • PUT 請求是冪等的。n 個 PUT 請求會覆蓋相同的資源 n-1 次。
  • DELETE 是冪等的,因為它一開始會返回 200(OK),而後續的調用會返回 204(No Content)或 404(Not Found)。

為什麼關注冪等操作呢?

在分佈式系統中,有多個服務器和客戶端節點。如果你從客戶端向服務器 A 發送了請求,請求失敗或超時了,那麼你想能夠簡單地再次發送該請求,而不必擔心先前的請求是否有任何副作用。

這在微服務中是極其重要的,因為有很多獨立工作的組件。

冪等性的一些主要好處有 ——

  • 最小的複雜性 —— 不需要擔心副作用,可以簡單地重試任何請求,並得到相同的最終結果。
  • 易於實現 —— 你不需要添加邏輯來處理你的重試機制中先前失敗的請求。
  • 易於測試 —— 每個動作都會產生相同的結果,沒有意外。

結語

我們梳理了一系列構建更容錯系統的方法。然而,這些方法並不是全部。最後,我想指出幾個供你查看的要點,或許能幫助提高你係統的可用性和容錯性。

  • 在多節點配置中,如果一個客戶端重試了多次,這些請求很可能到達同一個服務器。此時,最好返回一個失敗的響應,讓客戶端從頭重試。
  • 對你的系統做性能統計,讓它們時刻準備最壞的情況。你可以查看 Netflix 的 Chaos Monkey —— 這是一個在系統中觸發隨機故障的彈性測試工具。這能讓你為可能發生的故障做好準備,構建一個有彈性的系統。
  • 如果你的系統由於某種原因處於過載狀態,你可以嘗試通過減載(load shedding)來分佈負載。Google 做了一個很棒的案例研究,可以作為一個很好的起點。

一些資源:

感謝!❤

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久鏈接 即為本文在 GitHub 上的 MarkDown 鏈接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章

React使用新版Context構建組件樹工具注入

如何在Koa集成Bigpipe首屏渲染服務

基於webpack工程化的思考

如何手寫一個簡單的parser