Grab是如何設計彈性系統:斷路器

banq發表於2018-12-26

Grab是東南亞(SEA)領先的交通平臺,截至2017年5月,Grab平臺每天處理230萬次乘車。本文重點介紹實現斷路器的使用案例,包括與斷路配置相關的不同選項。
但正如惡劣天氣不可避免且通常難以預測一樣,軟體和硬體故障也是如此。這就是為什麼軟體工程師計劃和解決故障很重要的原因。
我們將開始介紹和比較兩種常用的服務可靠性機制:斷路器和重試。在Grab,我們在眾多軟體系統中廣泛使用這兩種機制,以確保我們能夠應對失敗並繼續為我們的客戶提供他們期望的服務。但這兩種機制是否相同?我們在哪裡以及如何選擇其中一個?
在本系列中,我們將仔細研究這兩種方法及其用例,以幫助您在是否以及何時應用每種方法時做出明智的決定。但讓我們首先看看失敗的常見原因。由於我們的服務與眾多外部資源進行通訊,因此可能會導致故障:
  • 網路問題
  • 系統過載
  • 資源飢餓(例如記憶體不足)
  • 糟糕的部署/配置
  • 錯誤請求(例如缺少身份驗證憑據,缺少請求資料)

不考慮對上游服務的呼叫可能失敗的所有方式,通常更容易考慮成功請求是什麼。它應該是及時的,在期望的格式,並且包含預期的資料。如果我們遵循這個定義,那麼其他一切都是某種失敗,無論是:
  • 反應遲鈍
  • 根本沒有回應
  • 錯誤格式的回覆
  • 不包含預期資料的響應

在規劃失敗應對方案時,我們應該努力能夠處理這些錯誤,就像我們應該試圖阻止我們的服務發出它們一樣。因此,讓我們開始研究解決這些錯誤的不同技術。

(注意:本文中提到的所有示例和工具都在Go中。但是,不需要事先了解Go)

介紹斷路器
電氣箱中保護您的裝置稱為斷路器。軟體斷路器以相同的方式工作。軟體斷路器是一種位於兩段程式碼之間的機制,用於監控流經它的所有內容的健康狀況。但是,它不是在發生故障時停電,而是阻止請求。
當服務被請求淹沒時,服務可能會中斷。一旦服務超載,進行任何進一步的請求可能會導致兩個問題。首先,發出請求可能毫無意義,因為我們不會得到有效和/或及時的響應;其次,因為建立了更多請求,就無法讓上游服務從不堪重負中恢復,事實上,很可能更多地重創它。
斷路器不僅僅是保護我們的上游服務。它們對我們的服務也有好處,我們將在下一節中看到。

回退
斷路器,如Hystrix,包括定義回退的能力。
假設您正在編寫需要兩個位置之間的道路行駛距離的服務。
如果事情按預期工作,我們會稱之為“距離計算器服務”,為其提供起點和終點位置,並返回距離。但是,該服務目前正在當機。因此,在這種情況下合理的回退可能是透過使用一些三角法來估計距離。當然,以這種方式計算距離將是不準確的,但是使用允許我們繼續處理使用者請求的不準確值遠比完全失敗請求好得多。

在後備處理中,使用估計值而不是實際值而不是唯一選項,其他常見選項包括:

  • 使用不同的上游服務重試請求
  • 稍後安排請求
  • 從快取中載入可能過時的資料

當然,有一些情況沒有合理的後備。但即使在這些情況下,使用斷路器仍然是有益的。

考慮發出和等待最終失敗的請求的成本。有CPU,記憶體和網路資源,都被用於發出請求並等待響應。然後是對使用者的延遲響應,這些資源都處於等待之中。
當斷路開啟時,所有這些成本都被避免,因為沒有提出請求,而是立即失敗。雖然向使用者返回錯誤並不理想,但返回最快的錯誤是也是一種選擇,不過只是最糟糕的。

斷路器應該跟蹤所有錯誤嗎?
最簡潔的答案是不。我們不應該跟蹤由使用者引起的錯誤(即HTTP錯誤程式碼400和401),而是跟蹤網路或基礎設施(即HTTP錯誤程式碼503和500)。
如果我們跟蹤使用者造成的錯誤,那麼一個惡意使用者就有可能傳送大量錯誤請求,導致我們的斷路開啟並造成服務的中斷。

斷路恢復
我們已經討論了當出現太多錯誤時斷路器如何開啟電路並切斷請求。我們還應該知道斷路如何再次關閉。
與上面使用的電氣示例不同,使用軟體斷路器,您無需在黑暗中找到保險絲盒並手動關閉斷路。軟體斷路器可以自行閉合斷路。
在斷路器斷開電路後,它將等待一個可配置的週期,稱為睡眠視窗,之後它將透過允許一些請求來測試斷路。如果服務已恢復,它將關閉斷路並恢復正常操作。如果請求仍然返回錯誤,那麼它將重複睡眠/嘗試過程直到恢復。

Bulwark堡壘
在Grab,我們使用Hystrix-Go斷路器,這個實現包括一個壁壘bulwark。bulwark是一個軟體程式,它監視併發請求的數量,並且能夠防止超過配置的最大併發請求數。這是一種非常便宜的限速形式。
在我們的例子中,透過開啟斷路來實現防止太多請求(如上所述)。此過程不計入錯誤,也不會直接影響其他斷路計算。
那為什麼這很重要?正如我們之前談到的那樣,當服務收到太多併發請求時,服務可能會變得無響應(甚至崩潰)。

請考慮以下情形:駭客已決定使用DDOS攻擊攻擊您的服務。突然間,您的服務正在接收通常數量的請求的100倍。然後,您的服務可以向上遊提供100倍的請求數量。
如果您的上游沒有實現某種形式的速率限制,有了這麼多請求,它就會崩潰。透過在服務和上游之間引入一個舷牆,您可以實現兩件事:

  • 您不會使上游服務崩潰,因為您限制了無法處理的請求數量。
  • 失敗的“額外”請求既具有回退能力,又具有快速失敗的能力。


斷路器設定
 Hystrix-Go 具有五種設定,它們分別是:

1. 超時:
此持續時間是在被視為錯誤之前允許請求的最長時間。這考慮到並非所有對上游資源的呼叫都會立即失敗。
有了這個,我們可以透過定義我們願意等待上游的時間來限制我們處理請求所需的總時間。

2.最大併發請求
這是堡壘設定(如上所述)。
考慮預設值(10)表示同時發出請求而不是“每秒”。因此,如果請求通常很快(在幾毫秒內完成),則不需要允許更多。
此外,將此值設定得過高可能會導致您的服務缺少發出請求所需的資源(記憶體,CPU,埠)。

3.請求閾值
這是在開啟斷路之前必須在評估(滾動視窗)期間內進行的最小請求數。
此設定用於確保低請求量期間的少量錯誤不會開啟斷路。

4.睡眠視窗
這是電路在斷路器試圖檢查請求的健康狀況之前等待的持續時間(如上所述)。
將此設定得太低會限制斷路器的有效性,因為它經常開啟/檢查。但是,將此持續時間設定得太高會限制恢復時間。

5.錯誤百分比閾值
這是在斷路開啟之前必須失敗的請求的百分比。
設定此值時應考慮許多因素,包括:

  • 上游服務中的主機數(下一節中的更多資訊)
  • 上游服務的可靠性以及與之的連線
  • 服務對錯誤的敏感性
  • 個人喜好


斷路配置
在接下來的幾節中,我們將討論與斷路配置相關的一些不同選項,特別是每個主機和每個服務配置,以及我們作為程式設計師如何定義斷路。

Hystrix-Go中,典型的使用模式如下所示:

hystrix.Go("my_command", func() error {
    // talk to other services
    return nil
}, func(err error) error {
    // do this when services are down
    return nil
})

第一個引數“my_command”是斷路名稱。這裡要注意的第一件事是因為斷路名稱是一個引數,所以可以向斷路器的多個呼叫提供相同的值。

這有一些有趣的副作用。
假設您的服務呼叫上游服務的多個端點,稱為“列表”,“建立”,“編輯”和“刪除”。如果我們想分別跟蹤每個端點的錯誤率,您可以像這樣定義斷路:

func List() {
   hystrix.Go("my_upstream_list", func() error {
      // call list endpoint
      return nil
   }, nil)
}
func Create() {
   hystrix.Go("my_upstream_create", func() error {
      // call create endpoint
      return nil
   }, nil)
}

func Update() {
   hystrix.Go("my_upstream_update", func() error {
      // call update endpoint
      return nil
   }, nil)
}

func Delete() {
   hystrix.Go("my_upstream_delete", func() error {
      // call delete endpoint
      return nil
   }, nil)
}


您會注意到我已使用“my_upstream_”為所有斷路新增字首,然後附加端點的名稱。這為4個端點提供了4個斷路。
另一方面,如果我們想要跟蹤一個目的地的所有錯誤,我們可以像這樣定義我們的斷路:

func List() {
   hystrix.Go("my_upstream", func() error {
      // call list endpoint
      return nil
   }, nil)
}

func Create() {
   hystrix.Go("my_upstream", func() error {
      // call create endpoint
      return nil
   }, nil)
}

func Update() {
   hystrix.Go("my_upstream", func() error {
      // call update endpoint
      return nil
   }, nil)
}

func Delete() {
   hystrix.Go("my_upstream", func() error {
      // call delete endpoint
      return nil
   }, nil)
}

在上面的示例中,所有不同的呼叫都使用相同的斷路名稱。

那麼我們如何決定選擇哪個?在理想情況下,每個上游目的地一個斷路就足夠了。這是因為所有故障都與基礎設施(即網路)相關,並且在這些情況下,當對一個端點的呼叫失敗時,所有故障都肯定會失敗。這種方法將導致斷路在最短的時間內開啟,從而降低我們的錯誤率。
但是,這種方法假設我們的上游服務不會以一種某個端點被破壞而其他端點仍然工作的方式失敗。它還假設我們對上游響應的處理是從上游服務返回的錯誤時也不會發生問題。例如,如果我們不小心跟蹤我們的斷路器呼叫中的使用者發生的錯誤,我們很快就會發現自己無法呼叫上游。
因此,即使每個端點有一個斷路導致斷路開啟稍慢,這也是我推薦的方法。最好儘可能多地提出成功請求,而不是不恰當地開啟斷路。

每個服務一個斷路
我們已經將上游服務視為單個目標,並且在處理資料庫或快取時,它們可能就是這樣。但是在上游是API /服務時,就很少會出現這種情況。

為什麼這很重要?回想一下我們之前關於服務如何失敗的討論。如果執行上游服務的計算機出現資源問題(記憶體不足,CPU不足或磁碟已滿),則這些問題將本地化到該特定計算機。因此,如果一臺機器資源匱乏,這並不意味著支援該服務的所有其他機器都會遇到同樣的問題。

當我們有一個斷路器用於對特定資源或服務的所有呼叫時,我們在“按服務”模型中使用斷路器。讓我們看一些例子來研究它如何影響斷路器的行為。

首先,當我們只有1個目的地時,通常是資料庫的情況:
如果對單個目標(例如資料庫)的所有呼叫都失敗,那麼我們的錯誤率將為100%。
斷路肯定會開啟,這是可取的,因為資料庫無法正確響應,進一步的請求會浪費資源。

現在讓我們看看當我們新增負載均衡器和更多主機時會發生什麼:

假設一個簡單的輪詢負載平衡,對一個主機的所有呼叫都成功,對另一個主機的所有呼叫都失敗。這樣得到:1個壞主機/ 2個主機= 50%錯誤率。

如果我們將誤差百分比閾值設定為超過50%,則斷路不會開啟,我們會看到50%的請求失敗。或者,如果我們將誤差百分比閾值設定為小於50%,則斷路將開啟並且所有請求都快捷回退處理或失敗。

現在,如果我們要向上遊服務新增其他主機比如一共7個,7個前面有一個負載平衡器:
然後,一個壞例項的計算和影響發生了巨大變化。我們的結果變成:1個壞主機/ 6個主機= 16.66%錯誤率。

我們可以從這個擴充套件的例子中得到一些東西:

  • 一個不好的例項不會導致斷路開啟(這會阻止所有請求工作)
  • 設定一個非常低的錯誤率(例如10%),這將導致斷路因一個壞主機而開啟,這將是愚蠢的,因為我們有5個其他主機能夠為這些請求提供服務
  • 當大多數(或所有)目標主機不健康時,“按服務”配置的斷路器應該只有一個開路


每個主機一個斷路
如上所述,一個壞主機可能會影響您的斷路,因此您可能會考慮為每個上游目標主機使用一個斷路。
但是,要實現這一點,我們的服務必須瞭解上游主機的數量和身份。在前面的示例中,它只知道負載均衡器的存在。因此,如果我們從前面的示例中刪除負載均衡器,我們將留下7臺主機。
使用此配置,我們的一個壞主機不能影響跟蹤其他主機的電路。感覺就像一場勝利。
但是,在刪除負載均衡器後,我們的服務現在需要承擔其職責並執行客戶端負載平衡。
為了能夠執行客戶端負載平衡,我們的服務必須跟蹤上游服務中所有主機的存在和執行狀況,並在主機之間平衡請求。在Grab,我們的許多基於gRPC的服務都是以這種方式配置的。(banq注:SpringCloud中是基於erueka註冊伺服器)
使用我們的新配置,我們遇到了一些額外的複雜性,與客戶端負載平衡有關,我們也從1個斷路器變為6個。這些額外的5個斷路也會產生一些資源(即記憶體)成本。在這個例子中,它可能看起來不是很多,但隨著我們採用額外的上游服務並且這些上游主機的數量增加,成本確實成倍增加。
我們應該考慮的最後一件事是這種配置將如何影響我們滿足請求的能力。當主機首次出現故障時,我們的請求錯誤率將與之前相同:1個壞主機/ 6個主機總數= 16.66%錯誤率
但是,在將斷路開啟直到壞主機之後發生了足夠的錯誤,將能夠避免向該主機再次發出請求,然後會恢復,重新開始只有0%的錯誤率。

每個服務與每個主機的最終想法
根據上面的討論,您可能希望將所有斷路轉換為每個主機。但是,這樣做的額外複雜性不應低估。(banq注:微服務是每個服務一個主機,如果是SOA,需要服務和主機的區分)。
此外,我們還應該考慮當壞主機發生故障時,每個服務負載均衡器可能會有什麼響應。如果我們的每個服務示例中的負載均衡器配置為監視在每個主機上執行的服務的執行狀況(而不僅僅是主機本身的執行狀況),那麼它能夠從負載均衡器中檢測並刪除該主機並可能替換它有一個新的主機。
可以同時使用每個服務和每個主機(雖然我從未嘗試過)。在此配置中,每個服務電路應僅在幾乎沒有機會存在任何有效主機時開啟,並且透過這樣做可以節省在重試周期中執行的請求處理時間。其配置必須是: 斷路器(每個服務)→重試→斷路器(每個主機)。
我的建議是考慮上游服務失敗的方式和原因,然後根據您的情況使用最簡單的配置。


 

相關文章