流程圖
下圖展示了當你使用 Hystrix 來包裝你請求依賴服務時的流程:
接下來將詳細介紹如下問題:
- 1.構建
HystrixCommand
或者HystrixObservableCommand
對象 - 2.執行命令(即上述 Command 對象包裝的邏輯)
- 3.結果是否有緩存
- 4.請求線路(類似電路)是否是開路
- 5.線程池/請求隊列/信號量占滿時會發生什么
- 6.使用
HystrixObservableCommand.construct()
還是HystrixCommand.run()
- 7.計算鏈路健康度
- 8.失敗回退邏輯
- 9.返回正常回應
1. 構建HystrixCommand
或者HystrixObservableCommand
對象
使用 Hystrix 的第一步是創建一個HystrixCommand
或者HystrixObservableCommand
對象來表示你需要發給依賴服務的請求。你可以向構造器傳遞任意參數。
若只期望依賴服務每次返回單一的回應,按如下方式構造一個HystrixCommand
即可:
HystrixCommand command = new HystrixCommand(arg1, arg2);
若期望依賴服務返回一個 Observable,并應用『Observer』模式監聽依賴服務的回應,可按如下方式構造一個HystrixObservableCommand
:
HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);
2. 執行命令
Hystrix 命令提供四種方式(HystrixCommand
支持所有四種方式,而HystrixObservableCommand
僅支持后兩種方式)來執行你包裝的請求:
execute()
—— 阻塞,當依賴服務響應(或者拋出異常/超時)時,返回結果queue()
—— 返回Future
對象,通過該對象異步得到返回結果observe()
—— 返回Observable
對象,立即發出請求,在依賴服務響應(或者拋出異常/超時)時,通過注冊的Subscriber
得到返回結果toObservable()
—— 返回Observable
對象,但只有在訂閱該對象時,才會發出請求,然后在依賴服務響應(或者拋出異常/超時)時,通過注冊的Subscriber
得到返回結果
K value = command.execute();
Future<K> fValue = command.queue();
Observable<K> ohValue = command.observe(); // hot observable(注:調用observe()方法時,請求立即發出)
Observable<K> ocValue = command.toObservable(); // cold observable(注:只有在返回的ocValue上調用subscribe時,才會發出請求)
在內部實現中,execute()
是同步調用,內部會調用queue().get()
方法。queue()
內部會調用toObservable().toBlocking().toFuture()
。也就是說,HystrixCommand
內部均通過一個Observable
的實現來執行請求,即使這些命令本來是用來執行同步返回回應這樣的簡單邏輯。
3. 結果是否有緩存
如果請求結果緩存這個特性被啟用,并且緩存命中,則緩存的回應會立即通過一個Observable
對象的形式返回。
4. 請求線路是否是開路
當執行一個命令時,Hystrix 會先檢查熔斷器狀態,確定請求線路是否是開路
如果請求線路是開路,Hystrix 將不會執行這個命令,而是直接使用『失敗回退邏輯』
5. 線程池/請求隊列/信號量占滿時會發生什么
如果和當前需要執行的命令相關聯的線程池和請求隊列(或者信號量,如果不使用線程池),Hystrix 將不會執行這個命令,而是直接使用『失敗回退邏輯』
6. 使用HystrixObservableCommand.construct()
還是HystrixCommand.run()
Hystrix 將根據你使用類的不同,內部使用不同的方式來請求依賴服務:
HystrixCommand.run()
—— 返回回應或者拋出異常HystrixObservableCommand.construct()
—— 返回 Observable 對象,并在回應到達時通知 observers,或者回調onError
方法通知出現異常
若run()
或者construct()
方法耗時超過了給命令設置的超時閾值,執行請求的線程將拋出TimeoutException
(若命令本身并不在其調用線程內執行,則單獨的定時器線程會拋出該異常)。在這種情況下,Hystrix 將會執行失敗回退邏輯,并且會忽略最終(若執行命令的線程沒有被中斷)返回的回應。
若命令本身并不拋出異常,并正常返回回應,Hystrix 在添加一些日志和監控數據采集之后,直接返回回應。Hystrix 在使用run()
方法時,Hystrix 內部還是會生成一個Observable
對象,并返回單個請求,產生一個onCompleted
通知;而在 Hystrix 使用construct()
時,會直接返回由construct()
產生的Observable
對象
7. 計算線路健康度
Hystrix 會將請求成功,失敗,被拒絕或超時信息報告給熔斷器,熔斷器維護一些用于統計數據用的計數器。
這些計數器產生的統計數據使得熔斷器在特定的時刻,能短路某個依賴服務的后續請求,直到恢復期結束,若恢復期結束根據統計數據熔斷器判定線路仍然未恢復健康,熔斷器會再次關閉線路。
8. 失敗回退邏輯
當命令執行失敗時,Hystrix 將會執行失敗回退邏輯,失敗原因可能是:
-
construct()
或run()
方法拋出異常 - 當線路是開路,導致命令被短路時
- 當命令對應的線程池或信號量被占滿
- 超時
失敗回退邏輯包含了通用的回應信息,這些回應從內存緩存中或者其他固定邏輯中得到,而不應有任何的網絡依賴。如果一定要在失敗回退邏輯中包含網絡請求,必須將這些網絡請求包裝在另一個HystrixCommand
或HystrixObservableCommand
中。
當使用HystrixCommand
時,通過實現HystrixCommand.getFallback()
返回失敗回退時的回應。
當使用HystrixObservableCommand
時,通過實現HystrixObservableCommand.resumeWithFallback()
返回 Observable 對象來通知 observers 失敗回退時的回應。
若失敗回退方法返回回應,Hystrix 會將這個回應返回給命令的調用者。若 Hystrix 內部調用HystrixCommand.getFallback()
時,會產生一個 Observable 對象,并包裝用戶實現的getFallback()
方法返回的回應;若 Hystrix 內部調用HystrixObservableCommand.resumeWithFallback()
時,會將用戶實現的resumeWithFallback()
返回的 Observable 對象直接返回。
若你沒有實現失敗回退方法,或者失敗回退方法拋出異常,Hystrix 內部還是會生成一個 Observable 對象,但它不會產生任何回應,并通過onError
通知立即中止請求。Hystrix 默認會通過onError
通知調用者發生了何種異常。你需要盡量避免失敗回退方法執行失敗,保持該方法盡可能的簡單不易出錯。
若失敗回退方法執行失敗,或者用戶未提供失敗回退方法,Hystrix 會根據調用執行命令的方法的不同而產生不同的行為:
execute()
—— 拋出異常queue()
—— 成功返回Future
對象,但其get()
方法被調用時,會拋出異常observe()
—— 返回Observable
對象,當你訂閱它的時候,會立即調用 subscriber 的onError
方法中止請求toObservable()
—— 返回Observable
對象,當你訂閱它的時候,會立即調用 subscriber 的onError
方法中止請求
9. 返回正常回應
若命令成功被執行,Hystrix 將回應返回給調用方,或者通過Observable
的形式返回。根據上述調用命令方式的不同(如第2條所示),Observable
對象會進行一些轉換:
execute()
—— 產生一個Future
對象,行為同.queue()
產生的Future
對象一樣,接著調用其get()
方法,生成由內部產生的Observable
對象返回的回應queue()
—— 將內部產生的Observable
對象轉換(Decorator模式)成BlockingObservable
對象,以產生并返回Future
對象observe()
—— 產生Observable
對象后,立即訂閱(ReplaySubject)以使命令得以執行(異步),返回該Observable
對象,當你調用其subscribe
方法時,重放產生的回應信息和通知給用戶提供的訂閱者toObservable()
—— 返回Observable
對象,你必須調用其subscribe
方法,以使命令得以執行。
熔斷器
下圖展示了HystrixCommand
或HystrixObservableCommand
如何與HystrixCircuitBreaker
進行交互,以及HystrixCircuitBreaker
的決策邏輯流程,包括熔斷器內部計數器如何工作。
線路的開路閉路詳細邏輯如下:
- 1.假設線路內的容量(請求QPS)達到一定閾值(通過
HystrixCommandProperties.circuitBreakerRequestVolumeThreshold()
配置) - 2.同時,假設線路內的錯誤率達到一定閾值(通過
HystrixCommandProperties.circuitBreakerErrorThresholdPercentage()
配置) - 3.熔斷器將從『閉路』轉換成『開路』
- 4.若此時是『開路』狀態,熔斷器將短路后續所有經過該熔斷器的請求,這些請求直接走『失敗回退邏輯』
- 5.經過一定時間(即『休眠窗口』,通過
HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()
配置),后續第一個請求將會被允許通過熔斷器(此時熔斷器處于『半開』狀態),若該請求失敗,熔斷器將又進入『開路』狀態,且在休眠窗口內保持此狀態;若該請求成功,熔斷器將進入『閉路』狀態,回到邏輯1循環往復。
依賴隔離
Hystrix 通過使用『艙壁模式』(注:將船的底部劃分成一個個的艙室,這樣一個艙室進水不會導致整艘船沉沒。將系統所有依賴服務隔離起來,一個依賴延遲升高或者失敗,不會導致整個系統失敗)來隔離依賴服務,并限制訪問這些依賴服務的并發度。
線程&線程池
通過將對依賴服務的訪問執行放到單獨的線程,將其與調用線程(例如 Tomcat 線程池中的線程)隔離開來,調用線程能空出來去做其他的工作而不至于被依賴服務的訪問阻塞過長時間。
Hystrix 使用獨立的,每個依賴服務對應一個線程池的方式,來隔離這些依賴服務,這樣,某個依賴服務的高延遲只會拖慢這個依賴服務對應的線程池。
當然,也可以不使用線程池來使你的系統免受依賴服務失效的影響,這需要你小心的設置網絡連接/讀取超時時間和重試配置,并保證這些配置能正確正常的運作,以使這些依賴服務在失效時,能快速返回錯誤。
Netflix 在設計 Hystrix 時,使用線程/線程池來實現隔離,原因如下:
多數系統同時運行了(有時甚至多達數百個)不同的后端服務,這些服務由不同開發組開發。
每個服務都提供了自己的客戶端庫
客戶端庫經常會發生變動
客戶端庫可能會改變邏輯,加入新的網絡請求
客戶端庫可能會包含重試邏輯,數據解析,緩存(本地緩存或分布式緩存),或者其他類似邏輯
客戶端庫對于使用者來說,相當于『黑盒』,其實現細節,網絡訪問方式,默認配置等等均對使用者透明
In several real-world production outages the determination was “oh, something changed and properties should be adjusted” or “the client library changed its behavior.”
即使客戶端庫本身未發生變化,服務自身發生變化,也可能會影響其性能,從而導致客戶端配置不再可靠
中間依賴服務可能包含一些其依賴服務提供的客戶端庫,而這些庫可能不受控且配置不合理
絕大多數網絡訪問都采用同步的方式進行
客戶端代碼可能也會有失效或者高延遲,而不僅僅是在網絡訪問時
線程池的優勢
將依賴服務請求通過使用不同的線程池隔離,其優勢如下:
系統完全與依賴服務請求隔離開來,即使依賴服務對應線程池耗盡,也不會影響系統其它請求
降低了系統接入新的依賴服務的風險,若新的依賴服務存在問題,也不會影響系統其它請求
當依賴服務失效后又恢復正常,其對應的線程池會被清理干凈,相對于整個 Tomcat 容器的線程池被占滿需要耗費更長時間以恢復可用來說,此時系統可以快速恢復
若依賴服務的配置有問題,線程池能迅速反映出來(通過失敗次數的增加,高延遲,超時,拒絕訪問等等),同時,你可以在不影響系統現有功能的情況下,處理這些問題(通常通過熱配置等方式)
若依賴服務的實現發生變更,性能有了很大的變化(這種情況時常發生),需要進行配置調整(例如增加/減小超時閾值,調整重試策略等)時,也可以從線程池的監控信息上迅速反映出來(失敗次數增加,高延遲,超時,拒絕訪問等等),同時,你可以在不影響其他依賴服務,系統請求和用戶的情況下,處理這些問題
線程池處理能起到隔離的作用以外,還能通過這種內置的并發特性,在客戶端庫同步網絡IO上,建立一個異步的 Facade(類似 Netflix API 建立在 Hystrix 命令上的 Reactive、全異步化的那一套 Java API)
簡而言之,通過線程池提供的依賴服務隔離,可以使得我們能在不停止服務的情況下,更加優雅地應對客戶端庫和子系統性能上的變化。
注:盡管線程池能提供隔離性,但你仍然需要對你的依賴服務客戶端代碼增加超時邏輯,并且/或者處理線程中斷異常,以使這些代碼不會無故地阻塞或者拖慢 Hystrix 線程池。
線程池的弊端
使用線程池的主要弊端是會增加系統 CPU 的負載,每個命令的執行,都包含了 CPU 任務的排隊,調度,上下文切換。
Netflix 在設計 Hystrix 時,認為相對于其帶來的好處,其帶來的負載的一點點升高對系統的影響是微乎其微的。
線程池的開銷
Hystrix 的開發人員測試了在子線程中執行construct()
或run()
方法帶來的額外時延,以及在父線程中整個請求的耗時,通過這個測試,你能直觀了解 Hystrix 使用線程池帶來的一點點系統負載的升高影響(線程,監控,日志,熔斷器等)。
Netflix API 使用線程池來隔離依賴服務,每天可以處理超過 100 億的 Hystrix 命令,每個 API 實例有超過 40 個線程池,每個線程池有 5 到 20 個工作線程(絕大部分設置為 10 個線程)。
下圖展示了一個HystrixCommand
以 60QPS 的速度,在一個 API 實例(每臺服務器每秒運行的線程數峰值為 350)上被執行的耗時監控:
(注:有 User 的表示使用線程池來隔離依賴服務后的耗時)
中位數顯示二者(未使用線程池和使用線程池)沒有差別。
90% 的情況下,使用線程池有 3ms 的延遲
99% 的情況下,使用線程池有 9ms 的延遲,盡管如此,相對于請求的總時間(2ms28ms),延遲(0ms9ms)基本可以忽略不計
90% 的情況下,這些延遲和在使用了熔斷器之后更高的延遲,在絕大多數 Netflix 的需求來看,是微不足道的,更何況其能帶來系統穩定性和魯棒性上的巨大提升。
對于那些本來延遲就比較小的請求(例如訪問本地緩存成功率很高的請求)來說,線程池帶來的開銷是非常高的,這時,你可以考慮采用其他方法,例如非阻塞信號量(不支持超時),來實現依賴服務的隔離,使用信號量的開銷很小。但絕大多數情況下,Netflix 更偏向于使用線程池來隔離依賴服務,因為其帶來的額外開銷可以接受,并且能支持包括超時在內的所有功能。
信號量
除了線程池,隊列之外,你可以使用信號量(或者叫計數器)來限制單個依賴服務的并發度。Hystrix 可以利用信號量,而不是線程池,來控制系統負載,但信號量不允許我們設置超時和異步化,如果你對客戶端庫有足夠的信任(延遲不會過高),并且你只需要控制系統負載,那么你可以使用信號量。
HystrixCommand
和HystrixObservableCommand
在兩個地方支持使用信號量:
失敗回退邏輯:當 Hystrix 需要執行失敗回退邏輯時,其在調用線程(Tomcat 線程)中使用信號量
執行命令時:如果設置了 Hystrix 命令的
execution.isolation.strategy
屬性為SEMAPHORE
,則 Hystrix 會使用信號量而不是線程池來控制調用線程調用依賴服務的并發度
你可以通過動態配置(即熱部署)來決定信號量的大小,以控制并發線程的數量,信號量大小的估計和使用線程池進行并發度估計一樣(僅訪問內存數據的請求,一般能達到耗時在 1ms 以內,且能達到 5000rps,這樣的請求對應的信號量可以設置為 1 或者 2。默認值為 10)。
注意:如果依賴服務使用信號量來進行隔離,當依賴服務出現高延遲,其調用線程也會被阻塞,直到依賴服務的網絡請求超時。
信號量在達到上限時,會拒絕后續請求的訪問,同時,設置信號量的線程也無法異步化(即像線程池那樣,實現『提交-做其他工作-得到結果』模式)
請求合并
你可以在HystrixCommand
之前放置一個『請求合并器』(HystrixCollapser
為請求合并器的抽象父類),該合并器可以將多個發往同一個后端依賴服務的請求合并成一個。
下圖展示了在兩種場景(未增加『請求合并器』和增加『請求合并器』)下,線程和網絡連接數量(假設所有請求在一個很小的時間窗口內,例如 10ms,是『并發』的):
為什么要使用請求合并?
在并發執行HystrixCommand
時,利用請求合并能減少線程和網絡連接數量。通過使用HystrixCollapser
,Hystrix 能自動完成請求的合并,開發者不需要對現有代碼做批量化的開發。
全局上下文(適用于所有 Tomcat 線程)
理想情況下,合并過程應該發生在系統全局層面,這樣用戶發起的,由 Tomcat 線程執行的所有請求都能被執行合并操作。
例如,有這樣一個需求,用戶需要獲取電影評級,而這些數據需要系統請求依賴服務來獲取,對依賴服務的請求使用HystrixCommand
進行包裝,并增加了請求合并的配置,這樣,當同一個 JVM 中其他線程需要執行同樣的請求時,Hystrix 會將這個請求同其他同樣的請求合并,只產生一個網絡請求。
注意:合并器會傳遞一個HystrixRequestContext
對象到合并的網絡請求中,因此,下游系統需要支持批量化,以使請求合并發揮其高效的特點。
用戶請求上下文(適用于單個 Tomcat 線程)
如果給HystrixCommand
只配置成針對單個用戶進行請求合并,則 Hystrix 只會在單個 Tomcat 線程(即請求)中進行請求合并。
例如,如果用戶想加載 300 個視頻對象的書簽,請求合并后,Hystrix 會將原本需要發起的 300 個網絡請求合并到一個。
對象模型和代碼復雜度
很多時候,當你創建一個對象模型,適用于對象的消費者邏輯,結果發現這個模型會導致生產者無法充分利用其擁有的資源。
例如,這里有一個包含 300 個視頻對象的列表,需要遍歷這個列表,并對每一個對象調用getSomeAttribute()
方法,這是一個顯而易見的對象模型,但如果簡單處理的話,可能會導致 300 次的網絡請求(假設getSomeAttribute()
方法內需要發出網絡請求),每一個網絡請求可能都會花上幾毫秒(顯然,這種方式非常容易拖慢系統)。
當然,你也可以要求用戶在調用getSomeAttribute()
之前,先判斷一下哪些視頻對象真正需要請求其屬性。
或者,你可以將對象模型進行拆分,從一個地方獲取視頻列表,然后從另一個地方獲取視頻的屬性。
但這些實現會導致 API 非常丑陋,且實現的對象模型無法完全滿足用戶使用模式。 并且在企業級開發時,很容易因為開發者的疏忽導致錯誤或者不夠高效,因為不同的開發者可能有不同的請求方式,這樣一個地方的優化不足以保證在所有地方都會有優化。
通過將合并邏輯下沉到 Hystrix 層,不管你如何設計對象模型,或者以何種方式去調用依賴服務,又或者開發者是否意識到這些邏輯需要不需要進行優化,這些都不需要考慮,因為 Hystrix 能統一處理。
getSomeAttribute()
方法能放在它最適合的位置,并且能以最適合的方式被調用,Hystrix 的請求合并器會自動將請求合并到合并時間窗口內。
請求合并帶來的額外開銷
請求合并會導致依賴服務的請求延遲增高(該延遲為等待請求的延遲),延遲的最大值為合并時間窗口大小。
若某個請求耗時的中位數是 5ms,合并時間窗口為 10ms,那么在最壞情況下(注:合并時間窗口開啟時發起請求),請求需要消耗 15ms 才能完成。通常情況下,請求不太可能恰好在合并時間窗口開啟時發起,因此,請求合并帶來的額外開銷應該是合并時間窗口的一般,在此例中是 5ms。
請求合并帶來的額外開銷是否值得,取決于將要執行的命令,高延遲的命令相比較而言不會有太大的影響。同時,緩存 Key 的選擇也決定了在一個合并時間窗口內能『并發』執行的命令數量:如果一個合并時間窗口內只有 1~2 個請求,將請求合并顯然不是明智的選擇。事實上,如果單線程循環調用同一個依賴服務的情況下,如果將請求合并,會導致這個循環成為系統性能的瓶頸,因為每一個請求都需要等待 10ms 的合并時間周期。
然而,如果一個命令具有高并發度,并且能批量處理多個,甚至上百個的話,請求合并帶來的性能開銷會因為吞吐量的極大提升而基本可以忽略,因為 Hystrix 會減少這些請求所需的線程和網絡連接數量。
請求合并器的執行流程
請求緩存
在HystrixCommand
和HystrixObservableCommand
的實現中,你可以定義一個緩存的 Key,這個 Key 用于在同一個請求上下文(全局或者用戶級)中標識緩存的請求結果,當然,該緩存是線程安全的。
下例展示了在一個完整 HTTP 請求周期內,兩個線程執行命令的流程:
請求緩存有如下好處:
- 不同請求路徑上針對同一個依賴服務進行的重復請求(有同一個緩存 Key),不會真實請求多次
這個特性在企業級系統中非常有用,在這些系統中,開發者往往開發的只是系統功能的一部分。(注:這樣,開發者彼此隔離,不太可能使用同樣的方法或者策略去請求同一個依賴服務提供的資源)
例如,請求一個用戶的Account
的邏輯如下所示,這個邏輯往往在系統不同地方被用到:
Account account = new UserGetAccount(accountId).execute();
//or
Observable<Account> accountObservable = new UserGetAccount(accountId).observe();
Hystrix 的RequestCache
只會在內部執行run()
方法一次,上面兩個線程在執行HystrixCommand
命令時,會得到相同的結果,即使這兩個命令是兩個不同的實例。
- 數據獲取具有一致性
因為緩存的存在,除了第一次請求需要真正訪問依賴服務以外,后續請求全部從緩存中獲取,可以保證在同一個用戶請求內,不會出現依賴服務返回不同的回應的情況。
- 避免不必要的線程執行
在construct()
或run()
方法執行之前,會先從請求緩存中獲取數據,因此,Hystrix 能利用這個特性避免不必要的線程執行,減小系統開銷。
若 Hystrix 沒有實現請求緩存,那么HystrixCommand
和HystrixObservableCommand
的實現者需要自己在construct()
或run()
方法中實現緩存,這種方式無法避免不必要的線程執行開銷。