上文《Hystrix淺入淺出:(一)背景與功能初探》已經提到過,使用Hystrix我們需要将自動熔斷的業務邏輯通過Command模式來包裝,于是,我們隻需要繼承HystrixCommand抽象類,實作run、getFallback等方法,你就擁有了一個具有基本熔斷功能的類。從使用來看,所有的核心邏輯都由AbstractCommand(即HystrixCommand的父類,HystrixCommand隻是對AbstractCommand進行了簡單包裝)抽象類串起來,從功能上來說,AbstractCommand必須将如下功能聯系起來:
政策配置:Hystrix有兩種降級模型,即信号量(同步)模型和線程池(異步)模型,這兩種模型所有可定制的部分都展現在了HystrixCommandProperties和HystrixThreadPoolProperties兩個類中。然而還是那句老話,Hystrix隻提供了配置修改的入口,沒有将配置界面化,如果想在頁面上動态調整配置,還需要自己實作。
資料統計:Hystrix以指令模式的方式來控制業務邏輯以及熔斷邏輯的調用時機,是以說資料統計對它來說不算難事,但如何高效、精準的在記憶體中統計資料,還需要一定的技巧。
斷路器:斷路器可以說是Hystrix内部最重要的狀态機,是它決定着每個Command的執行過程。
監控露出:能通過某種可配置方式将統計資料展現在儀表盤上。
一. Hystrix内部流程
本文将主要闡述【斷路器】和【資料統計】兩大元件的設計和實作。在介紹兩大元件之前,我們先簡單了解下Hystrix工作時的内部流程,官方的圖有些複雜(https://github.com/Netflix/Hystrix/wiki/How-it-Works),過于細節,這裡畫個簡單的(隻顯示了關鍵環節):
上圖簡單羅列的一個請求(即我們包裝的Command)在Hystrix内部被執行的關鍵過程。
【建立Command對象】這一過程也包含了政策、資源的初始化,參看AbstractCommand的構造函數:
protected AbstractCommand(...) {
// 初始化group,group主要是用來對不同的command key進行統一管理,比如統一監控、告警等
this.commandGroup = initGroupKey(...);
// 初始化command key,用來辨別降級邏輯,可以了解成command的id
this.commandKey = initCommandKey(...);
// 初始化自定義的降級政策
this.properties = initCommandProperties(...);
// 初始化線程池key,相同的線程池key将公用線程池
this.threadPoolKey = initThreadPoolKey(...);
// 初始化監控器
this.metrics = initMetrics(...);
// 初始化斷路器
this.circuitBreaker = initCircuitBreaker(...);
// 初始化線程池
this.threadPool = initThreadPool(...);
// Hystrix通過SPI實作了插件機制,允許使用者對事件通知、處理和政策進行自定義
this.eventNotifier = HystrixPlugins.getInstance().getEventNotifier();
this.concurrencyStrategy = HystrixPlugins.getInstance().getConcurrencyStrategy();
HystrixMetricsPublisherFactory.createOrRetrievePublisherForCommand(this.commandKey, this.commandGroup, this.metrics, this.circuitBreaker, this.properties);
this.executionHook = initExecutionHook(executionHook);
this.requestCache = HystrixRequestCache.getInstance(this.commandKey, this.concurrencyStrategy);
this.currentRequestLog = initRequestLog(this.properties.requestLogEnabled().get(), this.concurrencyStrategy);
/* fallback semaphore override if applicable */
this.fallbackSemaphoreOverride = fallbackSemaphore;
/* execution semaphore override if applicable */
this.executionSemaphoreOverride = executionSemaphore;
}
上一篇《Hystrix淺入淺出:(一)背景與功能初探》說過,因為Command對象是有狀态的(比如每次請求參數可能不同),是以每次請求都需要新建立Command,這麼多初始化工作,如果并發量過高,會不會帶來過大的系統開銷?其實構造函數中的很多初始化工作隻會集中在建立第一個Command時來做,後續建立的Command對象主要是從靜态Map中取對應的執行個體來指派,比如監控器、斷路器和線程池的初始化,因為相同的Command的command key和線程池key都是一緻的,在HystrixCommandMetrics、HystrixCircuitBreaker.Factory、HystrixThreadPool中會分别有如下靜态屬性:
private static final ConcurrentHashMap<String, HystrixCommandMetrics> metrics = new ConcurrentHashMap<String, HystrixCommandMetrics>();
private static ConcurrentHashMap<String, HystrixCircuitBreaker> circuitBreakersByCommand = new ConcurrentHashMap<String, HystrixCircuitBreaker>();
final static ConcurrentHashMap<String, HystrixThreadPool> threadPools = new ConcurrentHashMap<String, HystrixThreadPool>();
可見所有Command對象都可以在這裡找到自己對應的資源執行個體。
二. Hystrix的斷路器設計
斷路器是Hystrix最核心的狀态機,隻有了解它的變更條件,我們才能準确掌握Hystrix的内部行為。上面的内部流程圖中【斷路器狀态判斷】這個環節直接決定着這次請求(或者說這個Command對象)是嘗試去執行正常業務邏輯(即run())還是走降級後的邏輯(即getFallback()),斷路器HystrixCircuitBreaker有三個狀态,
CLOSED關閉狀态:允許流量通過。
OPEN打開狀态:不允許流量通過,即處于降級狀态,走降級邏輯。
HALF_OPEN半開狀态:允許某些流量通過,并關注這些流量的結果,如果出現逾時、異常等情況,将進入OPEN狀态,如果成功,那麼将進入CLOSED狀态。
為了能做到狀态能按照指定的順序來流轉,并且是線程安全的,斷路器的實作類HystrixCircuitBreakerImpl使用了AtomicReference:
enum Status {
CLOSED, OPEN, HALF_OPEN;
}
// 斷路器初始狀态肯定是關閉狀态
private final AtomicReference<Status> status = new AtomicReference<Status>(Status.CLOSED);
斷路器在狀态變化時,使用了AtomicReference#compareAndSet來確定當條件滿足時,隻有一筆請求能成功改變狀态。各狀态流轉順序如下:
那麼,什麼條件下斷路器會改變狀态?
1. CLOSED -> OPEN :
時間視窗内(預設10秒)請求量大于請求量門檻值(即circuitBreakerRequestVolumeThreshold,預設值是20),并且該時間視窗内錯誤率大于錯誤率門檻值(即circuitBreakerErrorThresholdPercentage,預設值為50,表示50%),那麼斷路器的狀态将由預設的CLOSED狀态變為OPEN狀态。看代碼可能更直接:
// 檢查是否超過了我們設定的斷路器請求量門檻值
if (hc.getTotalRequests() < properties.circuitBreakerRequestVolumeThreshold().get()) {
// 如果沒有超過統計視窗的請求量門檻值,則不改變斷路器狀态,
// 如果它是CLOSED狀态,那麼仍然是CLOSED.
// 如果它是HALF-OPEN狀态,我們需要等待請求被成功執行,
// 如果它是OPEN狀态, 我們需要等待睡眠視窗過去。
} else {
if (hc.getErrorPercentage() < properties.circuitBreakerErrorThresholdPercentage().get()) {
//如果沒有超過統計視窗的錯誤率門檻值,則不改變斷路器狀态,,
// 如果它是CLOSED狀态,那麼仍然是CLOSED.
// 如果它是HALF-OPEN狀态,我們需要等待請求被成功執行,
// 如果它是OPEN狀态, 我們需要等待【睡眠視窗】過去。
} else {
// 如果錯誤率太高,那麼将變為OPEN狀态
if (status.compareAndSet(Status.CLOSED, Status.OPEN)) {
// 因為斷路器處于打開狀态會有一個時間範圍,是以這裡記錄了變成OPEN的時間
circuitOpened.set(System.currentTimeMillis());
}
}
}
這裡的錯誤率是個整數,即errorPercentage= (int) ((double) errorCount / totalCount * 100);,至于睡眠視窗,下面會提到。
2. OPEN ->HALF_OPEN:
前面說過,當進入OPEN狀态後,會進入一段睡眠視窗,即隻會OPEN一段時間,是以這個睡眠視窗過去,就會“自動”從OPEN狀态變成HALF_OPEN狀态,這種設計是為了能做到彈性恢複,這種狀态的變更,并不是由排程線程來做,而是由請求來觸發,每次請求都會進行如下檢查:
@Override
public boolean attemptExecution() {
if (properties.circuitBreakerForceOpen().get()) {
return false;
}
if (properties.circuitBreakerForceClosed().get()) {
return true;
}
// circuitOpened值等于1說明斷路器狀态為CLOSED
if (circuitOpened.get() == -1) {
return true;
} else {
if (isAfterSleepWindow()) {
// 睡眠視窗過去後隻有第一個請求能被執行
// 如果執行成功,那麼狀态将會變成CLOSED
// 如果執行失敗,狀态仍變成OPEN
if (status.compareAndSet(Status.OPEN, Status.HALF_OPEN)) {
return true;
} else {
return false;
}
} else {
return false;
}
}
}
// 睡眠視窗是否過去
private boolean isAfterSleepWindow() {
// 還記得上面CLOSED->OPEN時記錄的時間嗎?
final long circuitOpenTime = circuitOpened.get();
final long currentTime = System.currentTimeMillis();
final long sleepWindowTime = properties.circuitBreakerSleepWindowInMilliseconds().get();
return currentTime > circuitOpenTime + sleepWindowTime;
}
3. HALF_OPEN ->CLOSED :
變為半開狀态後,會放第一筆請求去執行,并跟蹤它的執行結果,如果是成功,那麼将由HALF_OPEN狀态變成CLOSED狀态:
@Override
public void markSuccess() {
if (status.compareAndSet(Status.HALF_OPEN, Status.CLOSED)) {
//This thread wins the race to close the circuit - it resets the stream to start it over from 0
metrics.resetStream();
Subscription previousSubscription = activeSubscription.get();
if (previousSubscription != null) {
previousSubscription.unsubscribe();
}
Subscription newSubscription = subscribeToStream();
activeSubscription.set(newSubscription);
// 已經進入了CLOSED階段,是以将OPEN的修改時間設定成-1
circuitOpened.set(-1L);
}
}
4. HALF_OPEN ->OPEN :
變為半開狀态時,如果第一筆被放去執行的請求執行失敗(資源擷取失敗、異常、逾時等),就會由HALP_OPEN狀态再變為OPEN狀态:
@Override
public void markNonSuccess() {
if (status.compareAndSet(Status.HALF_OPEN, Status.OPEN)) {
// This thread wins the race to re-open the circuit - it resets the start time for the sleep window
circuitOpened.set(System.currentTimeMillis());
}
}
三. 滑動視窗(滾動視窗)
上面提到的斷路器需要的時間視窗請求量和錯誤率這兩個統計資料,都是指固定時間長度内的統計資料,斷路器的目标,就是根據這些統計資料來預判并決定系統下一步的行為,Hystrix通過滑動視窗來對資料進行“平滑”統計,預設情況下,一個滑動視窗包含10個桶(Bucket),每個桶時間寬度是1秒,負責1秒的資料統計。滑動視窗包含的總時間以及其中的桶數量都是可以配置的,來張官方的截圖認識下滑動視窗:
上圖的每個小矩形代表一個桶,可以看到,每個桶都記錄着1秒内的四個名額資料:成功量、失敗量、逾時量和拒絕量,這裡的拒絕量指的就是上面流程圖中【信号量/線程池資源檢查】中被拒絕的流量。10個桶合起來是一個完整的滑動視窗,是以計算一個滑動視窗的總資料需要将10個桶的資料加起來。
我們現在來具體看看滑動視窗和桶的設計,如果将滑動視窗設計成對一個長度為10的整形數組的操作,第一個想到的應該是AtomicLongArray,AtomicLongArray中每個位置的資料都能線程安全的操作,提供了譬如incrementAndGet、getAndSet、compareAndSet等常用方法。但由于一個桶需要維護四個名額,如果用四個AtomicLongArray來實作,做法不夠進階,于是我們想到了AtomicReferenceArray<Bucket>,Bucket對象内部可以用AtomicLong來維護着這四個名額。滑動視窗和桶的設計特别講究技巧,需要盡可能做到性能、資料準确性兩方面的極緻,我們來看Hystrix是如何做到的。
桶的資料統計簡單來說可以分為兩類,一類是簡單自增計數器,比如請求量、錯誤量等,另一類是并發最大值,比如一段時間内的最大并發量(或者說線程池的最大任務數),下面是桶類Bucket的定義:
class Bucket {
// 辨別是哪一秒的桶資料
final long windowStart;
// 如果是簡單自增統計資料,那麼将使用adderForCounterType
final LongAdder[] adderForCounterType;
// 如果是最大并發類的統計資料,那麼将使用updaterForCounterType
final LongMaxUpdater[] updaterForCounterType;
Bucket(long startTime) {
this.windowStart = startTime;
// 預配置設定記憶體,提高效率,不同僚件對應不同的數組index
adderForCounterType = new LongAdder[HystrixRollingNumberEvent.values().length];
for (HystrixRollingNumberEvent type : HystrixRollingNumberEvent.values()) {
if (type.isCounter()) {
adderForCounterType[type.ordinal()] = new LongAdder();
}
}
// 預配置設定記憶體,提高效率,不同僚件對應不同的數組index
updaterForCounterType = new LongMaxUpdater[HystrixRollingNumberEvent.values().length];
for (HystrixRollingNumberEvent type : HystrixRollingNumberEvent.values()) {
if (type.isMaxUpdater()) {
updaterForCounterType[type.ordinal()] = new LongMaxUpdater();
// initialize to 0 otherwise it is Long.MIN_VALUE
updaterForCounterType[type.ordinal()].update(0);
}
}
}
...略...
}
我們可以看到,并沒有用所謂的AtomicLong,為了友善的管理各種事件(參見com.netflix.hystrix.HystrixEventType)的資料統計,Hystrix對不同的事件使用不同的數組index(即枚舉的順序),這樣對于某個桶(即某一秒)的指定類型的資料,總能從數組中找到對應的LongAdder(用于統計前面說的簡單自增)或LongMaxUpdater(用于統計前面說的最大并發值)對象來進行自增或更新操作。對于性能有要求的中間件或庫類都避不開要CPUCache優化的問題,比如cache line,以及cache line帶來的false sharing問題。Bucket的内部并沒有使用AtomicLong,而是使用了JDK8新提供的LongAdder,在高并發的單調自增場景,LongAdder提供了比AtomicLong更好的性能,至于LongAdder的設計思想,本文不展開,感興趣的朋友可以去拜讀Doug Lea大神的代碼(有意思的是Hystrix沒有直接使用JDK中的LongAdder,而是copy過來改了改)。LongMaxUpdater也是類似的,它和LongAddr一樣都派生于Striped64,這裡不再展開。
滑動視窗由多個桶組成,業界一般的做法是将數組做成環,Hystrix中也類似,多個桶是放在AtomicReferenceArray<Bucket>來維護的,為了将其做成環,需要儲存頭尾的引用,于是有了ListState類:
class ListState {
/*
* 這裡的data之是以用AtomicReferenceArray而不是普通數組,是因為data需要
* 在不同的ListState對象中跨線程來引用,需要可見性和并發性的保證。
*/
private final AtomicReferenceArray<Bucket> data;
private final int size;
private final int tail;
private final int head;
private ListState(AtomicReferenceArray<Bucket> data, int head, int tail) {
this.head = head;
this.tail = tail;
if (head == 0 && tail == 0) {
size = 0;
} else {
this.size = (tail + dataLength - head) % dataLength;
}
this.data = data;
}
...略...
}
我們可以發現,真正的資料是data,而ListState隻是一個時間段的資料快照而已,是以tail和head都是final,這樣做的好處是我們不需要去為head、tail的原子操作而苦惱,轉而變成對ListState的持有操作,是以滑動視窗看起來如下:
我們可以看到,由于預設一個滑動視窗包含10個桶,是以AtomicReferenceArray<Bucket>的size得達到10+1=11才能“滑動/滾動”起來,在确定的某一秒内,隻有一個桶被更新,其他的桶資料都沒有變化。既然通過ListState可以拿到所有的資料,那麼我們隻需要持有最新的ListState對象即可,為了能做到可見性和原子操作,于是有了環形桶類BucketCircularArray:
class BucketCircularArray implements Iterable<Bucket> {
// 持有最新的ListState
private final AtomicReference<ListState> state;
...略...
}
我們注意到BucketCircularArray實作了疊代器接口,這是因為我們輸出給斷路器的資料需要計算滑動視窗中的所有桶,于是你可以看到真正的滑動視窗類HystrixRollingNumber有如下屬性和方法:
public class HystrixRollingNumber {
// 環形桶數組
final BucketCircularArray buckets;
// 擷取該事件類型目前滑動視窗的統計值
public long getRollingSum(HystrixRollingNumberEvent type) {
Bucket lastBucket = getCurrentBucket();
if (lastBucket == null)
return 0;
long sum = 0;
// BucketCircularArray實作了疊代器接口環形桶數組
for (Bucket b : buckets) {
sum += b.getAdder(type).sum();
}
return sum;
}
...略...
}
斷路器就是通過監控來從HystrixRollingNumber的getRollingSum方法來擷取統計值的。
到這裡斷路器和滑動視窗的核心部分已經分析完了,當然裡面還有不少細節沒有提到,感興趣的朋友可以去看一下源碼。Hystrix中通過RxJava來實作了事件的釋出和訂閱,是以如果想深入了解Hystrix,需要熟悉RxJava,而RxJava在服務端的應用沒有像用戶端那麼廣,一個原因是場景的限制,還一個原因是大多數開發者認為RxJava設計的過于複雜,加上響應式程式設計模型,有一定的入門門檻。