Sentinel 原理-滑動視窗

逅弈逐碼發表於2019-01-09

逅弈 轉載請註明原創出處,謝謝!

系列文章

Sentinel 原理-全解析

Sentinel 原理-呼叫鏈

Sentinel 原理-實體類

Sentinel 實戰-限流篇

Sentinel 實戰-控制檯篇

Sentinel 實戰-規則持久化

上篇文章中,我們瞭解了sentinel是如何構造資源呼叫鏈的,以及每種Slot的具體作用,其中最重要的一個Slot非StatisticSlot莫屬,因為他做的事是其他所有的Slot的基礎。包括各種限流,熔斷的規則,都是基於StatisticSlot統計出來的結果進行規則校驗的。本篇文章我將深入研究下sentinel是如何進行qps等指標的統計的,首先要確定的一點是,sentinel是基於滑動時間視窗來實現的。

化整為零

我們已經知道了Slot是從第一個往後一直傳遞到最後一個的,且當資訊傳遞到StatisticSlot時,這裡就開始進行統計了,統計的結果又會被後續的Slot所採用,作為規則校驗的依據。我們先來看一段非常熟悉的程式碼,就是StatisticSlot中的entry方法:

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
    try {
        // 觸發下一個Slot的entry方法
        fireEntry(context, resourceWrapper, node, count, args);
        // 如果能通過SlotChain中後面的Slot的entry方法,說明沒有被限流或降級
        // 統計資訊
        node.increaseThreadNum();
        node.addPassRequest();
        // 省略部分程式碼
    } catch (BlockException e) {
        context.getCurEntry().setError(e);
        // Add block count.
        node.increaseBlockedQps();
        // 省略部分程式碼
        throw e;
    } catch (Throwable e) {
        context.getCurEntry().setError(e);
        // Should not happen
        node.increaseExceptionQps();
        // 省略部分程式碼
        throw e;
    }
}
複製程式碼

上面的程式碼註釋寫的已經很清晰了,簡單的來說,StatisticSlot中就是做了三件事:

  • 1.通過node中的當前的實時統計指標資訊進行規則校驗
  • 2.如果通過了校驗,則重新更新node中的實時指標資料
  • 3.如果被block或出現了異常了,則重新更新node中block的指標或異常指標

從上面的程式碼中可以很清晰的看到,所有的實時指標的統計都是在node中進行的。這裡我們拿qps的指標進行分析,看sentinel是怎麼統計出qps的,這裡可以事先透露下他是通過滑動時間視窗來統計的,而滑動視窗就是本篇文章的重點。

DefaultNode和ClusterNode

我們可以看到 node.addPassRequest() 這段程式碼是在fireEntry執行之後執行的,這意味著,當前請求通過了sentinel的流控等規則,此時需要將當次請求記錄下來,也就是執行 node.addPassRequest() 這行程式碼,現在我們進入這個程式碼看看。具體的程式碼如下所示:

@Override
public void addPassRequest() {
	super.addPassRequest();
	this.clusterNode.addPassRequest();
}
複製程式碼

首先我們知道這裡的node是一個 DefaultNode 例項,這裡特別補充一個 DefaultNodeClusterNode 的區別:

DefaultNode:儲存著某個resource在某個context中的實時指標,每個DefaultNode都指向一個ClusterNode

ClusterNode:儲存著某個resource在所有的context中實時指標的總和,同樣的resource會共享同一個ClusterNode,不管他在哪個context中

StatisticNode

好了,知道了他們的區別後,我們再來看上面的程式碼,其實都是執行的 StatisticNode 物件的 addPassRequest 方法。進入這個方法中看下具體的程式碼:


private transient Metric rollingCounterInSecond = new ArrayMetric(1000 / SampleCountProperty.sampleCount, IntervalProperty.INTERVAL);

private transient Metric rollingCounterInMinute = new ArrayMetric(1000, 2 * 60);

@Override
public void addPassRequest() {
    rollingCounterInSecond.addPass();
    rollingCounterInMinute.addPass();
}
複製程式碼

Metric

從程式碼中我們可以看到,具體的增加pass指標是通過一個叫 Metric 的介面進行操作的,並且是通過 ArrayMetric 這種實現類,現在我們在進入 ArrayMetric 中看一下。具體的程式碼如下所示:

private final WindowLeapArray data;

public ArrayMetric(int windowLength, int interval) {
    this.data = new WindowLeapArray(windowLength, interval);
}

@Override
public void addPass() {
    WindowWrap<Window> wrap = data.currentWindow();
    wrap.value().addPass();
}
複製程式碼

LeapArray和Window

本以為在ArrayMetric中應該可以看到具體的統計操作了,誰知道又出現了一個叫 WindowLeapArray 的類,不過從名字上看有點 「視窗」 的意思了。繼續跟程式碼,發現 wrap.value().addPass() 是執行的 wrap 物件所包裝的 Window 物件的 addPass 方法,這裡就是最終的增加qps中q的值的地方了。進入 Window 類中看一下,具體的程式碼如下:

private final LongAdder pass = new LongAdder();
private final LongAdder block = new LongAdder();
private final LongAdder exception = new LongAdder();
private final LongAdder rt = new LongAdder();
private final LongAdder success = new LongAdder();

public void addPass() {
    pass.add(1L);
}
public void addException() {
    exception.add(1L);
}
public void addBlock() {
    block.add(1L);
}
public void addSuccess() {
    success.add(1L);
}
public void addRT(long rt) {
    this.rt.add(rt);

    // Not thread-safe, but it's okay.
    if (rt < minRt) {
        minRt = rt;
    }
}
複製程式碼

看到這裡是不是就放心了,原來 Window 是通過 LongAdder 來儲存各種指標的值的,看到 LongAdder 是不是立刻就想到 AtomicLong 了?但是這裡為什麼不用 AtomicLong ,而是用 LongAdder 呢?主要是 LongAdder 在高併發下有更好的吞吐量,代價是花費了更多的空間,典型的以空間換時間。

完整的流程

分析到這裡我們已經把指標統計的完整鏈路理清楚了,可以用下面這張圖來表示整個過程:

add-pass-request.png

有人可能會問了,你不是要分析滑動視窗的嗎?搞了半天只畫了一張圖,而且圖上還多了一個 timeId 之類的東西,這個根本沒在上面出現過。

好了,現在我們就可以來分析具體的滑動視窗了,這裡的 timeId 是用來表示一個 WindowWrap 物件的時間id。為什麼要用 timeId 來表示呢?我們可以看到每一個 WindowWrap 物件由三個部分組成:

  • windowStart: 時間視窗的開始時間,單位是毫秒
  • windowLength: 時間視窗的長度,單位是毫秒
  • value: 時間視窗的內容,在 WindowWrap 中是用泛型表示這個值的,但實際上就是 Window

我們先大致的瞭解下時間視窗的構成,後面會再來分析 timeId 的作用。首先一個時間視窗是用來在某個固定時間長度內儲存一些統計值的虛擬概念。有了這個概念後,我們就可以通過時間視窗來計算統計一段時間內的諸如:qps,rt,threadNum等指標了。

繼續深入

我們再回到 ArrayMetric 中看一下:

private final WindowLeapArray data;

public ArrayMetric(int windowLength, int interval) {
    this.data = new WindowLeapArray(windowLength, interval);
}
複製程式碼

首先建立了一個 WindowLeapArray 物件,看一下 WindowLeapArray 類的程式碼:

public class WindowLeapArray extends LeapArray<Window> {
	public WindowLeapArray(int windowLengthInMs, int intervalInSec) {
	    super(windowLengthInMs, intervalInSec);
	}
}  
複製程式碼

該物件的構造方法有兩個引數:

  • windowLengthInMs :一個用毫秒做單位的時間視窗的長度
  • intervalInSec ,一個用秒做單位的時間間隔,這個時間間隔具體是做什麼的,下面會分析。

然後 WindowLeapArray 繼承自 LeapArray ,在初始化 WindowLeapArray 的時候,直接呼叫了父類的構造方法,再來看一下父類 LeapArray 的程式碼:

public abstract class LeapArray<T> {

    // 時間視窗的長度
    protected int windowLength;
    // 取樣視窗的個數
    protected int sampleCount;
    // 以毫秒為單位的時間間隔
    protected int intervalInMs;

    // 取樣的時間視窗陣列
    protected AtomicReferenceArray<WindowWrap<T>> array;

    /**
     * LeapArray物件
     * @param windowLength 時間視窗的長度,單位:毫秒
     * @param intervalInSec 統計的間隔,單位:秒
     */
    public LeapArray(int windowLength, int intervalInSec) {
        this.windowLength = windowLength;
        // 時間視窗的取樣個數,預設為2個取樣視窗
        this.sampleCount = intervalInSec * 1000 / windowLength;
        this.intervalInMs = intervalInSec * 1000;

        this.array = new AtomicReferenceArray<WindowWrap<T>>(sampleCount);
    }
}
複製程式碼

可以很清晰的看出來在 LeapArray 中建立了一個 AtomicReferenceArray 陣列,用來對時間視窗中的統計值進行取樣。通過取樣的統計值再計算出平均值,就是我們需要的最終的實時指標的值了。

可以看到我在上面的程式碼中通過註釋,標明瞭預設取樣的時間視窗的個數是2個,這個值是怎麼得到的呢?我們回憶一下 LeapArray 物件建立,是通過在 StatisticNode 中,new了一個 ArrayMetric ,然後將引數一路往上傳遞後建立的:

private transient Metric rollingCounterInSecond = new ArrayMetric(1000 / SampleCountProperty.sampleCount,IntervalProperty.INTERVAL);
複製程式碼

SampleCountProperty.sampleCount 的預設值是2,所以第一個引數 windowLengthInMs 的值是 500ms,那麼1秒鐘是1000ms,每個時間視窗的長度是500ms,也就是說總共分了兩個取樣的時間視窗。

現在繼續回到 ArrayMetric.addPass() 方法:

@Override
public void addPass() {
    WindowWrap<Window> wrap = data.currentWindow();
    wrap.value().addPass();
}
複製程式碼

獲取當前Window

我們已經分析了 wrap.value().addPass() ,現在只需要分析清楚 data.currentWindow() 具體做了什麼,拿到了當前時間視窗就可以 了。繼續深入程式碼,最終定位到下面的程式碼:

@Override
public WindowWrap<Window> currentWindow(long time) {
    long timeId = time / windowLength;
    // Calculate current index.
    int idx = (int)(timeId % array.length());

    // Cut the time to current window start.
    long time = time - time % windowLength;

    while (true) {
        WindowWrap<Window> old = array.get(idx);
        if (old == null) {
            WindowWrap<Window> window = new WindowWrap<Window>(windowLength, time, new Window());
            if (array.compareAndSet(idx, null, window)) {
                return window;
            } else {
                Thread.yield();
            }
        } else if (time == old.windowStart()) {
            return old;
        } else if (time > old.windowStart()) {
            if (addLock.tryLock()) {
                try {
                    // if (old is deprecated) then [LOCK] resetTo currentTime.
                    return resetWindowTo(old, time);
                } finally {
                    addLock.unlock();
                }
            } else {
                Thread.yield();
            }
        } else if (time < old.windowStart()) {
            // Cannot go through here.
            return new WindowWrap<Window>(windowLength, time, new Window());
        }
    }
}
複製程式碼

初次看到這段程式碼時,可能會覺得有點懵,但是細細的分析一下,實際可以把他分成以下幾步:

  • 1.根據當前時間,算出該時間的timeId,並根據timeId算出當前視窗在取樣視窗陣列中的索引idx
  • 2.根據當前時間算出當前視窗的應該對應的開始時間time,以毫秒為單位
  • 3.根據索引idx,在取樣視窗陣列中取得一個時間視窗old
  • 4.迴圈判斷知道獲取到一個當前時間視窗
    • 4.1.如果old為空,則建立一個時間視窗,並將它插入到array的第idx個位置,array上面已經分析過了,是一個 AtomicReferenceArray
    • 4.2.如果當前視窗的開始時間time與old的開始時間相等,那麼說明old就是當前時間視窗,直接返回old
    • 4.3.如果當前視窗的開始時間time大於old的開始時間,則說明old視窗已經過時了,將old的開始時間更新為最新值:time,下個迴圈中會在步驟4.2中返回
    • 4.4.如果當前視窗的開始時間time小於old的開始時間,實際上這種情況是不可能存在的,因為time是當前時間,old是過去的一個時間

上面的程式碼有個比較容易混淆的地方,就是計算出來的當前時間視窗的開始時間,沒有使用一個新的變數來表示,而是直接用time來表示。

另外timeId是會隨著時間的增長而增加,當前時間每增長一個windowLength的長度,timeId就加1。但是idx不會增長,只會在0和1之間變換,因為array陣列的長度是2,只有兩個取樣時間視窗。至於為什麼預設只有兩個取樣視窗,個人覺得因為sentinel是比較輕量的框架。時間視窗中儲存著很多統計資料,如果時間視窗過多的話,一方面會佔用過多記憶體,另一方面時間視窗過多就意味著時間視窗的長度會變小,如果時間視窗長度變小,就會導致時間視窗過於頻繁的滑動。

經過分析,加上註釋,並將表示當前視窗開始時間的time變數,重新命名成其他變數,使得程式碼更具可讀性,調整後的程式碼如下:

@Override
public WindowWrap<Window> currentWindow(long time) {
    // time每增加一個windowLength的長度,timeId就會增加1,時間視窗就會往前滑動一個
    long timeId = time / windowLength;
    // Calculate current index.
    // idx被分成[0,arrayLength-1]中的某一個數,作為array陣列中的索引
    int idx = (int)(timeId % array.length());

    // Cut the time to current window start.
    long currentWindowStart = time - time % windowLength;

    while (true) {
        // 從取樣陣列中根據索引獲取快取的時間視窗
        WindowWrap<Window> old = array.get(idx);
        // array陣列長度不宜過大,否則old很多情況下都命中不了,就會建立很多個WindowWrap物件
        if (old == null) {
            // 如果沒有獲取到,則建立一個新的
            WindowWrap<Window> window = new WindowWrap<Window>(windowLength, currentWindowStart, new Window());
            // 通過CAS將新視窗設定到陣列中去
            if (array.compareAndSet(idx, null, window)) {
                // 如果能設定成功,則將該視窗返回
                return window;
            } else {
                // 否則當前執行緒讓出時間片,等待
                Thread.yield();
            }
        // 如果當前視窗的開始時間與old的開始時間相等,則直接返回old視窗
        } else if (currentWindowStart == old.windowStart()) {
            return old;
        // 如果當前時間視窗的開始時間已經超過了old視窗的開始時間,則放棄old視窗
        // 並將time設定為新的時間視窗的開始時間,此時視窗向前滑動
        } else if (currentWindowStart > old.windowStart()) {
            if (addLock.tryLock()) {
                try {
                    // if (old is deprecated) then [LOCK] resetTo currentTime.
                    return resetWindowTo(old, currentWindowStart);
                } finally {
                    addLock.unlock();
                }
            } else {
                Thread.yield();
            }
        // 這個條件不可能存在
        } else if (currentWindowStart < old.windowStart()) {
            // Cannot go through here.
            return new WindowWrap<Window>(windowLength, currentWindowStart, new Window());
        }
    }
}
複製程式碼

看圖理解

為了更好的理解,下面我用幾幅圖來描述下這個過程。

slide-window-1.png

初始的時候arrays陣列中只有一個視窗(可能是第一個,也可能是第二個),每個時間視窗的長度是500ms,這就意味著只要當前時間與時間視窗的差值在500ms之內,時間視窗就不會向前滑動。例如,假如當前時間走到300或者500時,當前時間視窗仍然是相同的那個:

slide-window-2.png

時間繼續往前走,當超過500ms時,時間視窗就會向前滑動到下一個,這時就會更新當前視窗的開始時間:

slide-window-3.png

時間繼續往前走,只要不超過1000ms,則當前視窗不會發生變化:

slide-window-4.png

當時間繼續往前走,當前時間超過1000ms時,就會再次進入下一個時間視窗,此時arrays陣列中的視窗將會有一個失效,會有另一個新的視窗進行替換:

slide-window-5.png

以此類推隨著時間的流逝,時間視窗也在發生變化,在當前時間點中進入的請求,會被統計到當前時間對應的時間視窗中。計算qps時,會用當前取樣的時間視窗中對應的指標統計值除以時間間隔,就是具體的qps。具體的程式碼在StatisticNode中:

@Override
public long totalQps() {
    return passQps() + blockedQps();
}

@Override
public long blockedQps() {
    return rollingCounterInSecond.block() / IntervalProperty.INTERVAL;
}

@Override
public long passQps() {
    return rollingCounterInSecond.pass() / IntervalProperty.INTERVAL;
}
複製程式碼

到這裡就基本上把滑動視窗的原理分析清楚了,還有不清楚的地方,最好能夠藉助程式碼繼續分析下,最好的做法就是debug,這裡貼一下筆者在分析 currentWindow 方法時採取的測試程式碼:

public static void main(String[] args) throws InterruptedException {
    int windowLength = 500;
    int arrayLength = 2;
    calculate(windowLength,arrayLength);

    Thread.sleep(100);
    calculate(windowLength,arrayLength);

    Thread.sleep(200);
    calculate(windowLength,arrayLength);

    Thread.sleep(200);
    calculate(windowLength,arrayLength);

    Thread.sleep(500);
    calculate(windowLength,arrayLength);

    Thread.sleep(500);
    calculate(windowLength,arrayLength);

    Thread.sleep(500);
    calculate(windowLength,arrayLength);

    Thread.sleep(500);
    calculate(windowLength,arrayLength);

    Thread.sleep(500);
    calculate(windowLength,arrayLength);
}

private static void calculate(int windowLength,int arrayLength){
    long time = System.currentTimeMillis();
    long timeId = time/windowLength;
    long currentWindowStart = time - time % windowLength;
    int idx = (int)(timeId % arrayLength);
	System.out.println("time="+time+",currentWindowStart="+currentWindowStart+",timeId="+timeId+",idx="+idx);
}
複製程式碼

這裡假設時間視窗的長度為500ms,陣列的大小為2,當前時間作為輸入引數,計算出當前時間視窗的timeId、windowStart、idx等值。執行上面的程式碼後,將列印出如下的結果:

time=1540629334619,currentWindowStart=1540629334500,timeId=3081258669,idx=1
time=1540629334721,currentWindowStart=1540629334500,timeId=3081258669,idx=1
time=1540629334924,currentWindowStart=1540629334500,timeId=3081258669,idx=1
time=1540629335129,currentWindowStart=1540629335000,timeId=3081258670,idx=0
time=1540629335633,currentWindowStart=1540629335500,timeId=3081258671,idx=1
time=1540629336137,currentWindowStart=1540629336000,timeId=3081258672,idx=0
time=1540629336641,currentWindowStart=1540629336500,timeId=3081258673,idx=1
time=1540629337145,currentWindowStart=1540629337000,timeId=3081258674,idx=0
time=1540629337649,currentWindowStart=1540629337500,timeId=3081258675,idx=1
複製程式碼

可以看出來,windowStart每增加500ms,timeId就加1,這時就是時間視窗發生滑動的時候。

更多原創好文,請關注「逅弈逐碼」

更多原創好文,請關注公眾號「逅弈逐碼」

相關文章