SpringCloud 的限流、降級和熔斷——Hystrix

一、前言

分佈式系統環境中,服務間類似依賴非常常見,一個業餘調用通常依賴多個基礎服務。如下圖,對於同步調用,當庫存服務不可用時,商品服務請求線程被阻塞,當有大批量請求調用庫存服務時,最終可能導致整個商品服務資源耗盡,無法繼續對外提供服務。並且這種不可用可能沿請求調用鏈向上傳遞,這種現象稱爲雪崩效應。

二、雪崩效應

1、常見場景

(1)硬件故障:如服務器宕機,機房斷電,光纖被挖斷等。

(2)流量激增:如異常流量,重試加大流量等。

(3)緩存穿透:一般發生在應用重啓,所有緩存失效時,以及短時間內大量緩存失效時。大量的緩存不命中,使請求直擊後端服務,造成服務提供者超負荷運行,引起服務不可用。

(4)程序 bug:如程序邏輯導致內存泄漏,JVM 長時間 FullGC 等。

(5)同步等待:服務間採用同步調用模式,同步等待造成的資源耗盡。

2、應對策略

針對造成雪崩效應的不同場景,可以使用不同的應對策略,沒有一種通用所有場景的策略。

(1)硬件故障:多機房容災、異地多活等。

(2)流量激增:服務自動擴容、流量控制(限流、關閉重試)等。

(3)緩存穿透:緩存預加載、緩存異步加載等。

(4)程序 bug:修改程序 bug、及時釋放資源等。

(5)同步等待:資源隔離、MQ 解耦。、不可用服務調用快速失敗等。資源隔離通常指不同服務調用採取不同的線程池;不可用服務調用快速失敗一般通過熔斷模式結合超時機制實現。

綜上所述,如果一個應用不能對來自依賴的故障進行隔離,那該應用本身就處在被拖垮的風險中。因此,爲了構建穩定、可靠的分佈式系統,我們的服務應當具有自我保護能力,當依賴服務不可用時,當前服務啓動自我保護功能,從而避免發生雪崩效應。本文將重點介紹使用 Hystrix 解決同步等待的雪崩問題。

三、初探 Hystrix

Hystrix,中文含義是豪豬,因其背上長滿荊棘,從而擁有了自我保護的能力。本文所說的 Hystrix 是 Netflix 公司開源的一款容錯框架,同樣具有自我保護能力。爲了實現容錯和自我保護,下面我們看看 Hystrix 如何設計和實現的。

Hystrix 設計目標:

Hystrix 遵循的設計原則:

Hystrix 如何實現這些設計目標?

四、Hystrix 處理流程

(一)Hystrix 整個工作流程如下:

1、構造一個 HystrixCommand 或 HystrixObservableCommand 對象, 用於封裝請求,並在構造方法配置請求被執行需要的參數;

2、執行命令, Hystrix 提供了 4 種執行命令的方法,後面詳述;

3、判斷是否使用緩存響應請求,若啓用了緩存,且緩存可用,直接使用緩存響應請求。 Hystrix 支持請求緩存,但需要用戶自定義啓動;

4、判斷熔斷器是否打開,如果打開,調到第 8 步;

5、判斷線程池、隊列、信號量是否已滿,已滿則調到第 8 步;

6、執行 HystrixObservableCommand.construct() 或 HystrixCommand.run(), 如果執行失敗或者超時,跳到第 8 步;否者,跳到第 9 步;

7、統計熔斷器監控指標;

8、走 Fallback 降級方法;

9、返回請求響應。

從流程圖上可知道,第 5 步線程池、隊列、信號量已滿時,還會執行第 7 步邏輯,更新熔斷器統計信息,而第 6 步無論成功與否,都會更新熔斷器統計信息。

(二)執行命令的幾種方法:

Hystrix 提供了 4 種執行命令的方法,execute() 和 queue() 適用於 HystrixCommand 對象,而 observer() 和 toObservable() 適用於 HystrixObservableCommand 對象。

1、execute()

以同步阻塞方法執行 run(),只支持接收一個值對象。 Hystrix 會從線程池中取一個線程來執行 run(),並等待返回值。

2、queue()

以異步非阻塞方法執行 run(),只支持接收一個值對象。調用 queue() 就直接返回一個 Future 對象。可通過 Future.get() 拿到 run() 的返回結果,但 Future.get() 是阻塞執行的。若執行成功, Future.get() 返回單個返回值。當執行失敗時,如果沒有重寫 fallback, Future.get() 拋出異常。

3、observe()

事件註冊前執行 run()/construct(),支持接收多個值對象,取決於發射源。調用 observe() 會返回一個 hot Observable,也就是說,調用 observe() 自動觸發執行 run()/construct(),無論是否存在訂閱者。

如果繼承的是 HystrixCommand,hystrix 會從線程池中取一個線程以非阻塞方式執行 run();如果繼承的是 HystrixObservableCommand,將以調用線程阻塞執行 construct()。

observe() 使用方法:

(1)調用 observe() 會返回一個 Observable 對象

4、toObservable()

事件註冊後執行 run()/construct(),支持接收多個值對象,取決於發射源。調用 toObservable() 會返回一個 cold  Observable,也就是說,調用 toObservable() 不會立即觸發執行 run()/construct(),必須有訂閱者訂閱 Observable 時纔會執行。

如果繼承的是 HystrixComman,hystrix 會從線程池中取一個線程以非阻塞方式執行 run(),調用線程不必等待 run();如果繼承的是 HystrixObservableCommand ,將以調用線程堵塞執行 construct(),調用線程需等待 construct() 執行完才能繼續往下走。

toObservable() 使用方法:

(1)調用 observe() 會返回一個 Observable 對象

(2)調用這個 Observable 對象的 subscribe() 方法完成事件註冊,從而獲取結果

需注意的是, HystrixCommand 也支持 toObservable() 和 observe(), 但是即使將 HystrixCommand 轉換成 Observable,它也只能發射一個值對象。只有 HystrixObservableCommand 才支持發射多個值對象。

(三)幾種方法的關係

  Hystrix 總是以 Observable 的形式作爲相應返回,不同執行命令的方法只是進行了相應的轉換。

五、 Hystrix 容錯

Hystrix 的容錯主要是通過添加容許延遲和容錯方法,幫助控制這些分佈式服務之間的交互。還通過隔離服務之間的訪問點,阻止它們之間的級聯故障以及提供退回選項來實現這一點,從而提高系統的整體彈性。 Hystrix 主要提供了一下幾種容錯方法:

(一)資源熔斷

資源隔離主要指對線程的隔離。 Hystrix 提供了兩種線程隔離的方式:線程池和信號量。

1、線程隔離 - 線程池

Hystrix 還通過命令模式對發送請求的對象和執行請求的對象進行解耦,將不同類型的業務請求封裝爲對應的命令請求。如訂單服務查詢商品,查詢商品請求 -> 商品 command;商品服務查詢庫存,查詢庫存請求 -> 庫存 command。並且爲每個類型的 command 配置一個線程池,當第一次創建 command 時,根據配置創建一個線程池,並放入 ConcurrentHashMap,如商品 command:

final static ConcurrentHashMap<String, HystrixThreadPool> threadPools = new ConcurrentHashMap<String, HystrixThreadPool>();
...
if (!threadPools.containsKey(key)) {
    threadPools.put(key, new HystrixThreadPoolDefault(threadPoolKey, propertiesBuilder));
}

後續查詢商品的請求創建 command 時,將會重用已創建的線程池。線程池隔離之後的服務依賴關係:

通過發送請求線程與執行請求的線程分離,可有效防止發生級聯故障。當線程池或請求隊列飽和時,Hystrix 將拒絕服務,使得請求線程可以快速失敗,從而避免依賴問題擴散。

線程池隔離優點:

注意:儘管線程池提供了線程隔離,我們的客戶端底層代碼也必須要有超時設置或響應線程中斷,不能無限制的阻塞以致線程池一直飽和。

缺點:

線程池的主要缺點是增加了計算開銷。每個命令的執行都在單獨的線程完成,增加了排隊、調度和上下文切換的開銷。因此,要使用 Hystrix ,就必須接受它帶來的開銷,以換取它所提供的的好處。

通常情況下,線程池引入的開銷足夠小,不會有重大的成本和性能影響。但對於一些訪問延遲極低的服務,如只依賴內存緩存,線程池引入的開銷就比較明顯了,這時候使用線程池隔離技術就不合適了,我們需要考慮更輕量級的方式,如信號量隔離。

2、線程隔離 - 信號量

上面提到了線程池隔離的缺點,當依賴延遲極低的服務時,線程池隔離技術引入的開銷超過了它所帶來的好處。這時候可以使用信號量隔離技術來代替,通過設置信號量來限制對任何給定依賴的併發調用量。下圖說明了線程池隔離和信號量隔離的主要區別:

使用線程池時,發送請求的線程和執行依賴服務的線程不是同一個,而使用信號量時,發送請求的線程和執行依賴服務的線程時同一個, 都是發起請求的線程。

3、線程隔離總結

線程池和信號量都可以做線程隔離,但各有各的優缺點和支持的場景,對比如下:

MSuVnh

線程池和信號量都支持熔斷和限流。相比線程池,信號量不需要線程切換,因此避免了不必要的開銷。但是信號量不支持異步,也不支持超時,也就是說當所請求的服務不可用時,信號量會控制超過限制的請求立即返回,但是已經持有信號量的線程只能等待服務響應或從超時中返回,即可能出現長時間等待。線程池模式下,當超過指定時間未響應的服務, Hystrix 會通過響應中斷的方式通知線程立即結束並返回。

(二)熔斷器

現實生活中,可能大家都有注意到家庭電路中通常會安裝一個保險盒,當負載過載時,保險盒中的保險絲會自動熔斷,以保護電路及家裏的各種電器,這就是熔斷器的一個常見例子。Hystrix 中的熔斷器 (Circuit Breaker) 也是起類似作用,Hystrix 在運行過程中會向每個 commandKey 對應的熔斷器報告成功、失敗、超時和拒絕的狀態,熔斷器維護並統計這些數據,並根據這些統計信息來決策熔斷開關是否打開。如果打開,熔斷後續請求,快速返回。隔一段時間(默認是 5s)之後熔斷器嘗試半開,放入一部分流量請求進來,相當於對依賴服務進行一次健康檢查,如果請求成功,熔斷器關閉。

熔斷器配置,Circuit Breaker 主要包括如下 6 個參數:

1、circuitBreaker.enabled

是否啓用熔斷器,默認是 TRUE。
2 、circuitBreaker.forceOpen

熔斷器強制打開,始終保持打開狀態,不關注熔斷開關的實際狀態。默認值 FLASE。
3、circuitBreaker.forceClosed
熔斷器強制關閉,始終保持關閉狀態,不關注熔斷開關的實際狀態。默認值 FLASE。

4、circuitBreaker.errorThresholdPercentage
錯誤率,默認值 50%,例如一段時間(10s)內有 100 個請求,其中有 54 個超時或者異常,那麼這段時間內的錯誤率是 54%,大於了默認值 50%,這種情況下會觸發熔斷器打開。

5、circuitBreaker.requestVolumeThreshold

默認值 20。含義是一段時間內至少有 20 個請求才進行 errorThresholdPercentage 計算。比如一段時間了有 19 個請求,且這些請求全部失敗了,錯誤率是 100%,但熔斷器不會打開,總請求數不滿足 20。

6、circuitBreaker.sleepWindowInMilliseconds

半開狀態試探睡眠時間,默認值 5000ms。如:當熔斷器開啓 5000ms 之後,會嘗試放過去一部分流量進行試探,確定依賴服務是否恢復。

(三)熔斷器工作原理

下圖展示了 HystrixCircuitBreaker 的工作原理:

熔斷器工作的詳細過程如下:

第一步,調用 allowRequest() 判斷是否允許將請求提交到線程池

1、允許熔斷器強制打開, circuitBreaker.forceOpen 爲 true,不允許放行,返回。

2、如果熔斷器強制關閉, circuitBreaker.forceOpen 爲 true,允許放行。 此外不必關注熔斷器實際狀態,也就是說熔斷器仍然會維護統計數據和開關狀態,只是不生效而已。

第二步,調用 isOpen() 判斷熔斷器開關是否打開

1、 如果熔斷器開關打開,進入第三步,否則繼續;

2、 如果一個週期內總的請求數小於 circuitBreaker.requestVolumeThreshold 的值,允許請求放行,否則繼續;

3、 如果一個週期內錯誤率小於 circuitBreaker.errorThresholdPercentage 的值,允許請求放行。否則,打開熔斷器開關,進入第三步。

第三步, 調用 allowSingleTest() 判斷是否允許單個請求通行,檢查依賴服務是否恢復

如果熔斷器打開,且距離熔斷器打開的時間或上一次試探請求放行的時間超過 circuitBreaker.sleepWindowInMilliseconds 的值時,熔斷器器進入半開狀態,允許放行一個試探請求;否則,不允許放行。

此外,爲了提供決策依據,每個熔斷默認維護了 10 個 bucket,每秒一個 bucket,當心的 bucket 被創建時,最舊的 bucket 會被拋棄。其中每個 bucket 維護了請求、失敗、超時、拒絕的計數器,Hystrix 負責收集並統計這些計數器。

(四)回退降級

降級,通常指事務高峯期,爲了保證核心服務正常運行,需要停掉一些不太重要的業務,或者某些服務不可用時,執行備用邏輯從故障服務中快速失敗或快速返回,以保障主體業務不受影響。 Hystrix 提供的降級主要是爲了容錯,保證當前服務不受依賴服務故障的影響,從而提高服務的健壯性。要支持回退或降級處理,可以重寫 HystrixCommand 的 getFallBack 方法或 HystrixObservableCommand 的 resumeWithFallback 方法。

1、Hystrix 在以下幾種情況下會走降級邏輯:

2、降級回退方式

(1)Fail Fast 快速失敗

快速失敗是最普通的命令執行方法,命令沒有重寫降級邏輯。 如果命令執行發生任何類型的故障,它將直接拋出異常。

(2)Fail Fast 無聲失敗

指在降級方法中通過返回 null,空 Map,空 List 或其他類似的響應來完成。

(3)FallBack:Static

指在降級方法中返回靜態默認值。這不會導致服務以 “無聲失敗” 的方式被刪除,而是導致默認行爲發生。如:應用根據命令執行返回 true / false 執行相應邏輯,但命令執行失敗,則默認爲 true。

(4)FallBack:Stubbed

當命令返回一個包含多個字段的複合對象時,適合以 Stubbed 的方式回退。

(5)FallBack:Cache via Network

有時,如果調用依賴服務失敗,可以從緩存服務(如 redis)中查詢舊數據版本。由於又會發起遠程調用,所以建議重新封裝一個 Command,使用不同的 ThreadPoolKey,與主線程池進行隔離。

(6)Primary+Secondary with FallBack

有時系統具有兩種行爲 - 主要和次要,或主要和故障轉移。主要和次要邏輯涉及到不同的網絡調用和業務邏輯,所以需要將主次邏輯封裝在不同的 Command 中,使用線程池進行隔離。爲了實現主從邏輯切換,可以將主次 command 封裝在外觀 HystrixCommand 的 run 方法中,並結合配置中心設置的開關切換主從邏輯。由於主次邏輯都是經過線程池隔離的 HystrixCommand,因此外觀 HystrixCommand 可以使用信號量隔離,而沒有必要使用線程池隔離引入不必要的開銷。原理圖如下:

主次模型的使用場景還是很多的。如當系統升級新功能時,如果新版本的功能出現問題,通過開關控制降級調用舊版本的功能。

通常情況下,建議重寫 getFallBack 或 resumeWithFallback 提供自己的備用邏輯,但不建議在回退邏輯中執行任何可能失敗的操作。

六、總結

本文介紹了 Hystrix 及其工作原理,還介紹了 Hystrix 線程池隔離、信號量隔離和熔斷器的工作原理,以及如何使用 Hystrix 的資源隔離,熔斷和降級等技術實現服務容錯,從而提高系統的整體健壯性。

雖然 Hystrix 已經停更很久了,Spring Cloud 體系的使用者和擁護者一片哀嚎,實際上,spring 作爲 java 最大的家族,根本不需要擔心其中一兩個組件的廢棄, Hystrix 的停更,只會催生更多更好的組件替代它,但是 Hystrix 既然存在過,就一定就它存在的價值,既然存在,我就必須搞懂它。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/paZmZJ5MmcsuiJ65RHqirg