天天看點

Hystrix淺入淺出:(二)斷路器和滑動視窗

上文《Hystrix淺入淺出:(一)背景與功能初探》已經提到過,使用Hystrix我們需要将自動熔斷的業務邏輯通過Command模式來包裝,于是,我們隻需要繼承HystrixCommand抽象類,實作run、getFallback等方法,你就擁有了一個具有基本熔斷功能的類。從使用來看,所有的核心邏輯都由AbstractCommand(即HystrixCommand的父類,HystrixCommand隻是對AbstractCommand進行了簡單包裝)抽象類串起來,從功能上來說,AbstractCommand必須将如下功能聯系起來:

Hystrix淺入淺出:(二)斷路器和滑動視窗

政策配置:Hystrix有兩種降級模型,即信号量(同步)模型和線程池(異步)模型,這兩種模型所有可定制的部分都展現在了HystrixCommandProperties和HystrixThreadPoolProperties兩個類中。然而還是那句老話,Hystrix隻提供了配置修改的入口,沒有将配置界面化,如果想在頁面上動态調整配置,還需要自己實作。

資料統計:Hystrix以指令模式的方式來控制業務邏輯以及熔斷邏輯的調用時機,是以說資料統計對它來說不算難事,但如何高效、精準的在記憶體中統計資料,還需要一定的技巧。

斷路器:斷路器可以說是Hystrix内部最重要的狀态機,是它決定着每個Command的執行過程。

監控露出:能通過某種可配置方式将統計資料展現在儀表盤上。

一. Hystrix内部流程

本文将主要闡述【斷路器】和【資料統計】兩大元件的設計和實作。在介紹兩大元件之前,我們先簡單了解下Hystrix工作時的内部流程,官方的圖有些複雜(https://github.com/Netflix/Hystrix/wiki/How-it-Works),過于細節,這裡畫個簡單的(隻顯示了關鍵環節):

Hystrix淺入淺出:(二)斷路器和滑動視窗

上圖簡單羅列的一個請求(即我們包裝的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來確定當條件滿足時,隻有一筆請求能成功改變狀态。各狀态流轉順序如下:

Hystrix淺入淺出:(二)斷路器和滑動視窗

那麼,什麼條件下斷路器會改變狀态?

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秒的資料統計。滑動視窗包含的總時間以及其中的桶數量都是可以配置的,來張官方的截圖認識下滑動視窗:

Hystrix淺入淺出:(二)斷路器和滑動視窗

上圖的每個小矩形代表一個桶,可以看到,每個桶都記錄着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的持有操作,是以滑動視窗看起來如下:

Hystrix淺入淺出:(二)斷路器和滑動視窗

我們可以看到,由于預設一個滑動視窗包含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設計的過于複雜,加上響應式程式設計模型,有一定的入門門檻。

繼續閱讀