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

nettee發表於2019-05-06

重試、超時和退避

分散式系統很難。即使我們學了很多構建高可用性系統的方法,也常常會忽略系統設計中的彈性(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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章