架構演變帶來的問題
當我們使用傳統的 CS 架構時,服務端由於故障等原因將請求堵塞,可能會導致客戶端的請求失去響應,進而在一段時間後導致一批使用者無法獲得服務。而這種情況可能影響範圍是有限,可以預估的。然而,在微服務體系下,您的伺服器可能依賴了若干其他微服務,而這些微服務又依賴其它更多的微服務,這種情況下,某個服務對於下游的堵塞,可能會瞬間(數秒內)因為級聯的資源消耗造成整條鏈路上災難性的後果,我們稱之為“服務血崩”。
解決問題的幾種方式
- 熔斷模式:顧名思義,就如同家用電路一樣,如果一條線路電壓過高,保險絲會熔斷,防止火災。在使用熔斷模式的系統中,如果發現上游服務呼叫慢,或者有大量超時的時候,直接中止對於該服務的呼叫,直接返回資訊,快速釋放資源。直至上游服務好轉時再恢復呼叫。
- 隔離模式:將不同的資源或者服務的呼叫分割成幾個不同的請求池,一個池子的資源被耗盡並不會影響其它資源的請求,防止某個單點的故障消耗完全部的資源。這是非常傳統的一種容災設計。
- 限流模式:熔斷和隔離都是一種事後處置的方式,限流模式則可以在問題出現之前降低問題出現的概率。限流模式可以對某些服務的請求設定一個最高的 QPS 閾值,超出閾值的請求直接返回,不再佔用資源處理。但是限流模式,並不能解決服務血崩的問題,因為往往引起血崩並不是因為請求的數量大,而是因為多個級聯層數的放大。
斷路器的機制和實現
斷路器的存在,相當於給了我們一層保障,在呼叫穩定性欠佳,或者說很可能會呼叫失敗的服務和資源時,斷路器可以監視這些錯誤並且在達到一定閾值之後讓請求失敗,防止過度消耗資源。並且,斷路器還擁有自動識別服務狀態並恢復的功能,當上遊服務恢復正常時,斷路器可以自動判斷並恢復正常請求。
讓我們看一下一個沒有斷路器的請求過程:
使用者依賴 ServiceA 來提供服務,ServiceA 又依賴 ServiceB 提供的服務,假設 ServiceB 此時出現了故障,在一段時間內,對於每個請求都會延遲 10 秒響應。
那麼假設我們有 N 個 User 在請求 ServiceA 的服務時,幾秒鐘內,ServiceA 的資源就會因為對 ServiceB 發起的請求被掛起而消耗一空,從而拒絕 User 之後的任何請求。對於使用者來說,這就等於 ServiceA 和 ServiceB 同時都出現了故障,引起了整條服務鏈路的崩潰。
而當我們在 ServiceA 上裝上一個斷路器後會怎麼樣呢?
- 斷路器在失敗次數達到一定閾值後會發現對 ServiceB 的請求已經無效,那麼此時 ServiceA 就不需要繼續對 ServiceB 進行請求,而是直接返回失敗,或者使用其他Fallback 的備份資料。此時,斷路器處於 開路 狀態。
- 在一段時間過後,斷路器會開始定時查詢 ServiceB 是否已經恢復,此時,斷路器處於 半開 狀態。
- 如果 ServiceB 已經恢復,那麼斷路器會置於 關閉 狀態,此時 ServiceA 會正常呼叫 ServiceB 並且返回結果。
斷路器的狀態圖如下:
由此可見,斷路器的幾個核心要點如下:
- 超時時間:請求達到多久,算引起了一次失敗
- 失敗閾值:即斷路器觸發開路之前,需要達到的失敗次數
- 重試超時:當斷路器處於開路狀態後,隔多久開始重新嘗試請求,即進入半開狀態
有了這些知識,我們可以嘗試建立一個斷路器:
class CircuitBreaker {
constructor(timeout, failureThreshold, retryTimePeriod) {
// We start in a closed state hoping that everything is fine
this.state = 'CLOSED';
// Number of failures we receive from the depended service before we change the state to 'OPEN'
this.failureThreshold = failureThreshold;
// Timeout for the API request.
this.timeout = timeout;
// Time period after which a fresh request be made to the dependent
// service to check if service is up.
this.retryTimePeriod = retryTimePeriod;
this.lastFailureTime = null;
this.failureCount = 0;
}
}
構造斷路器的狀態機:
async call(urlToCall) {
// Determine the current state of the circuit.
this.setState();
switch (this.state) {
case 'OPEN':
// return cached response if no the circuit is in OPEN state
return { data: 'this is stale response' };
// Make the API request if the circuit is not OPEN
case 'HALF-OPEN':
case 'CLOSED':
try {
const response = await axios({
url: urlToCall,
timeout: this.timeout,
method: 'get',
});
// Yay!! the API responded fine. Lets reset everything.
this.reset();
return response;
} catch (err) {
// Uh-oh!! the call still failed. Lets update that in our records.
this.recordFailure();
throw new Error(err);
}
default:
console.log('This state should never be reached');
return 'unexpected state in the state machine';
}
}
補充剩餘功能:
// reset all the parameters to the initial state when circuit is initialized
reset() {
this.failureCount = 0;
this.lastFailureTime = null;
this.state = 'CLOSED';
}
// Set the current state of our circuit breaker.
setState() {
if (this.failureCount > this.failureThreshold) {
if ((Date.now() - this.lastFailureTime) > this.retryTimePeriod) {
this.state = 'HALF-OPEN';
} else {
this.state = 'OPEN';
}
} else {
this.state = 'CLOSED';
}
}
recordFailure() {
this.failureCount += 1;
this.lastFailureTime = Date.now();
}
使用斷路器時,只需要將請求包裹在斷路器例項中的 Call 方法裡呼叫即可:
...
const circuitBreaker = new CircuitBreaker(3000, 5, 2000);
const response = await circuitBreaker.call('http://0.0.0.0:8000/flakycall');
成熟的 Node.js 斷路器庫
Red Hat 很早就建立了一個名叫 Opossum 的成熟 Node.js 斷路器實現,連結在此:Opossum 。對於分散式系統來說,使用這個庫可以極大提升你的服務的容錯能力,從根本上解決服務血崩的問題。
作者:ES2049
文章可隨意轉載,但請保留此原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com