原始碼分析 Alibaba sentinel 滑動視窗實現原理(文末附原理圖)

中介軟體興趣圈發表於2020-04-25

要實現限流、熔斷等功能,首先要解決的問題是如何實時採集服務(資源)呼叫資訊。例如將某一個介面設定的限流闊值 1W/tps,那首先如何判斷當前的 TPS 是多少?Alibaba Sentinel 採用滑動視窗來實現實時資料的統計。

溫馨提示:如果對原始碼不太感興趣,可以先跳到文末,看一下滑動視窗的設計原理圖,再決定是否需要閱讀原始碼。

@

1、滑動視窗核心類圖

在這裡插入圖片描述
我們先對上述核心類做一個簡單的介紹,重點關注核心類的作用與核心屬性(重點需要探究其核心資料結構)。

  • Metric
    指標收集核心介面,主要定義一個滑動視窗中成功的數量、異常數量、阻塞數量,TPS、響應時間等資料。
  • ArrayMetric
    滑動視窗核心實現類。
  • LeapArray
    滑動視窗頂層資料結構,包含一個一個的視窗資料。
  • WindowWrap
    每一個滑動視窗的包裝類,其內部的資料結構用 MetricBucket 表示。
  • MetricBucket
    指標桶,例如通過數量、阻塞數量、異常數量、成功數量、響應時間,已通過未來配額(搶佔下一個滑動視窗的數量)。
  • MetricEvent
    指標型別,例如通過數量、阻塞數量、異常數量、成功數量、響應時間等。

2、滑動視窗實現原理

2.1 ArrayMetric

滑動視窗的入口類為 ArrayMetric ,我們先來看一下其核心程式碼。

private final LeapArray<MetricBucket> data;   // @1
public ArrayMetric(int sampleCount, int intervalInMs) {    // @2
    this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
}
public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {   // @3
	if (enableOccupy) {
		this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
	} else {
		this.data = new BucketLeapArray(sampleCount, intervalInMs);
	}
}

程式碼@1:ArrayMetric 類唯一的屬性,用來儲存各個視窗的資料,這個是接下來我們探究的重點。

程式碼@2,程式碼@3 該類提供了兩個構造方法,其核心引數為:

  • int intervalInMs
    表示一個採集的時間間隔,例如1秒,1分鐘。
  • int sampleCount
    在一個採集間隔中抽樣的個數,預設為 2,例如當 intervalInMs = 1000時,抽象兩次,則一個採集間隔中會包含兩個相等的區間,一個區間就是滑動視窗。
  • boolean enableOccupy
    是否允許搶佔,即當前時間戳已經達到限制後,是否可以佔用下一個時間視窗的容量,這裡對應 LeapArray 的兩個實現類,如果允許搶佔,則為 OccupiableBucketLeapArray,否則為 BucketLeapArray。

注意,LeapArray 的泛型類為 MetricBucket,意思就是指標桶,可以認為一個 MetricBucket 物件可以儲存一個抽樣時間段內所有的指標,例如一個抽象時間段中通過數量、阻塞數量、異常數量、成功數量、響應時間,其實現的奧祕在 LongAdder 中,本文先不對該類進行詳細介紹,後續文章會單獨來探究其實現原理。

這次,我們先不去看子類,反其道而行,先去看看其父類。

2.2 LongAdder

2.2.1 類圖與核心屬性

在這裡插入圖片描述
LeapArray 的核心屬性如下:

  • int windowLengthInMs
    每一個視窗的時間間隔,單位為毫秒。
  • int sampleCount
    抽樣個數,就一個統計時間間隔中包含的滑動視窗個數,在 intervalInMs 相同的情況下,sampleCount 越多,抽樣的統計資料就越精確,相應的需要的記憶體也越多。
  • int intervalInMs
    一個統計的時間間隔。
  • AtomicReferenceArray<WindowWrap< T>> array
    一個統計時間間隔中滑動視窗的陣列,從這裡也可以看出,一個滑動視窗就是使用的 WindowWrap< MetricBucket > 來表示。

上面的各個屬性的含義是從其建構函式得出來的,請其看建構函式。

public LeapArray(int sampleCount, int intervalInMs) {
    AssertUtil.isTrue(sampleCount > 0, "bucket count is invalid: " + sampleCount);
    AssertUtil.isTrue(intervalInMs > 0, "total time interval of the sliding window should be positive");
    AssertUtil.isTrue(intervalInMs % sampleCount == 0, "time span needs to be evenly divided");
    this.windowLengthInMs = intervalInMs / sampleCount;
    this.intervalInMs = intervalInMs;
    this.sampleCount = sampleCount;
    this.array = new AtomicReferenceArray<>(sampleCount);
}

那我們繼續來看 LeapArray 中的方法,深入探究滑動視窗的實現細節。

2.2.2 currentWindow() 詳解

該方法主要是根據當前時間來確定處於哪一個滑動視窗中,即找到上圖中的 WindowWrap,該方法內部就是呼叫其過載方法,引數為系統的當前時間,故我們重點來看一下過載方法的實現。

public WindowWrap<T> currentWindow(long timeMillis) { 
	if (timeMillis < 0) {
		return null;
	}
	int idx = calculateTimeIdx(timeMillis);  // @1
	long windowStart = calculateWindowStart(timeMillis); // @2
	while (true) { // 死迴圈查詢當前的時間視窗,這裡之所有需要迴圈,是因為可能多個執行緒都在獲取當前時間視窗。
		WindowWrap<T> old = array.get(idx);  // @3
       		 if (old == null) {  // @4
			WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
           		 if (array.compareAndSet(idx, null, window)) {  // @5
				return window;
           		 } else {
				Thread.yield();
           		 }
       		 } else if (windowStart == old.windowStart()) { // @6
			return old;
       		 } else if (windowStart > old.windowStart()) {  // @7
			if (updateLock.tryLock()) {
               			 try {
					return resetWindowTo(old, windowStart);
                		} finally {
					updateLock.unlock();
              			}
           		 } else {
				Thread.yield();
            		}
        	} else if (windowStart < old.windowStart()) { // @8
            		return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        	}
    	}
}

程式碼@1:計算當前時間會落在一個採集間隔 ( LeapArray ) 中哪一個時間視窗中,即在 LeapArray 中屬性 AtomicReferenceArray <WindowWrap< T>> array 的下標。其實現演算法如下:

  • 首先用當前時間除以一個時間視窗的時間間隔,得出當前時間是多少個時間視窗的倍數,用 n 表示。
  • 然後我們可以看出從一系列時間視窗,從 0 開始,一起向前滾動 n 隔得到當前時間戳代表的時間視窗的位置。現在我們要定位到這個時間視窗的位置是落在 LeapArray 中陣列的下標,而一個 LeapArray 中包含 sampleCount 個元素,要得到其下標,則使用 n % sampleCount 即可。

程式碼@2:計算當前時間戳所在的時間視窗的開始時間,即要計算出 WindowWrap 中 windowStart 的值,其實就是要算出小於當前時間戳,並且是 windowLengthInMs 的整數倍最大的數字,Sentinel 給出是演算法為 ( timeMillis - timeMillis % windowLengthInMs )。

程式碼@3:嘗試從 LeapArray 中的 WindowWrap 陣列查詢指定下標的元素。

程式碼@4:如果指定下標的元素為空,則需要建立一個 WindowWrap 。 其中 WindowWrap 中的 MetricBucket 是呼叫其抽象方法 newEmptyBucket (timeMillis),由不同的子類去實現。

程式碼@5:這裡使用了 CAS 機制來更新 LeapArray 陣列中的 元素,因為同一時間戳,可能有多個執行緒都在獲取當前時間視窗物件,但該時間視窗物件還未建立,這裡就是避免建立多個,導致統計資料被覆蓋,如果用 CAS 更新成功的執行緒,則返回新建好的 WindowWrap ,CAS 設定不成功的執行緒繼續跑這個流程,然後會進入到程式碼@6。

程式碼@6:如果指定索引下的時間視窗物件不為空並判斷起始時間相等,則返回。

程式碼@7:如果原先存在的視窗開始時間小於當前時間戳計算出來的開始時間,則表示 bucket 已被棄用。則需要將開始時間重置到新時間戳對應的開始時間戳,重置的邏輯將在下文詳細介紹。

程式碼@8:應該不會進入到該分支,因為當前時間算出來時間視窗不會比之前的小。

2.2.3 isWindowDeprecated() 詳解

接下來我們來看一下視窗的過期機制。

public boolean isWindowDeprecated(/*@NonNull*/ WindowWrap<T> windowWrap) {
    return isWindowDeprecated(TimeUtil.currentTimeMillis(), windowWrap);
}
public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) {
	return time - windowWrap.windowStart() > intervalInMs;
}

判斷滑動視窗是否生效的依據是當系統時間與滑動視窗的開始時間戳的間隔大於一個採集時間,即表示過期。即從當前視窗開始,通常包含的有效視窗為 sampleCount 個有效滑動視窗。

2.2.4 getPreviousWindow() 詳解

根據當前時間獲取前一個有效滑動視窗,其程式碼如下:

public WindowWrap<T> getPreviousWindow(long timeMillis) {
    if (timeMillis < 0) {
		return null;
    }
    int idx = calculateTimeIdx(timeMillis - windowLengthInMs); // @1
    timeMillis = timeMillis - windowLengthInMs;
    WindowWrap<T> wrap = array.get(idx);
    if (wrap == null || isWindowDeprecated(wrap)) {                 // @2
		return null;
    }
   if (wrap.windowStart() + windowLengthInMs < (timeMillis)) {   // @3
		return null;
    }
    return wrap;
}

其實現的關鍵點如下:
程式碼@1:用當前時間減去一個時間視窗間隔,然後去定位所在 LeapArray 中 陣列的下標。
程式碼@2:如果為空或已過期,則返回 null。
程式碼@3:如果定位的視窗的開始時間再加上 windowLengthInMs 小於 timeMills ,說明失效,則返回 null,通常是不會走到該分支。

2.2.5 滑動視窗圖示

經過上面的分析,雖然還有一個核心方法 (resetWindowTo) 未進行分析,但我們應該可以畫出滑動視窗的實現的實現原理圖了。
在這裡插入圖片描述
接下來對上面的圖進行一個簡單的說明:下面的示例以採集間隔為 1 s,抽樣次數為 2。

首先會建立一個 LeapArray,內部持有一個陣列,元素為 2,一開始進行採集時,陣列的第一個,第二個下標都會 null,例如當前時間經過 calculateTimeIdx 定位到下標為 0,此時沒有滑動視窗,會建立一個滑動視窗,然後該滑動視窗會採集指標,隨著進入 1s 的後500ms,後會建立第二個抽樣視窗。

然後時間前進 1s,又會定位到下標為 0 的地方,但此時不會為空,因為有上一秒的採集資料,故需要將這些採集資料丟棄 ( MetricBucket value ),然後重置該視窗的 windowStart,這就是 resetWindowTo 方法的作用。

在 ArrayMetric 的建構函式出現了 LeapArray 的兩個實現型別 BucketLeapArray 與 OccupiableBucketLeapArray。

其中 BucketLeapArray 比較簡單,在這裡就不深入研究了, 我們接下來將重點探討一下 OccupiableBucketLeapArray 的實現原理,即支援搶佔未來的“令牌”。

3、OccupiableBucketLeapArray 詳解

所謂的 OccupiableBucketLeapArray ,實現的思想是當前抽樣統計中的“令牌”已耗盡,即達到使用者設定的相關指標的闊值後,可以向下一個時間視窗,即借用未來一個取樣區間。接下來我們詳細來探討一下它的核心實現原理。

3.1 類圖
在這裡插入圖片描述
我們重點關注一下 OccupiableBucketLeapArray 引入了一個 FutureBucketLeapArray 的成員變數,其命名叫 borrowArray,即為借用的意思。

3.2 建構函式

public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) {
    super(sampleCount, intervalInMs);
    this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs);
}

從建構函式可以看出,不僅建立了一個常規的 LeapArray,對應一個採集週期,還會建立一個 borrowArray ,也會包含一個採集週期。

3.3 newEmptyBucket

public MetricBucket newEmptyBucket(long time) {
	MetricBucket newBucket = new MetricBucket();   // @1
	MetricBucket borrowBucket = borrowArray.getWindowValue(time);  // @2
	if (borrowBucket != null) {  
		newBucket.reset(borrowBucket);  
	}
	return newBucket;
}

我們知道 newEmptyBucket 是在獲取當前視窗時,對應的陣列下標為空的時會建立。
程式碼@1:首先新建一個 MetricBucket。
程式碼@2:在新建的時候,如果曾經有借用過未來的滑動視窗,則將未來的滑動視窗上收集的資料 copy 到新建立的採集指標上,再返回。

3.4 resetWindowTo

protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) {      
    w.resetTo(time);
    MetricBucket borrowBucket = borrowArray.getWindowValue(time);
    if (borrowBucket != null) {
        w.value().reset();
        w.value().addPass((int)borrowBucket.pass());
    } else {
        w.value().reset();
    }
    return w;
}

遇到過期的滑動視窗時,需要對滑動視窗進行重置,這裡的思路和 newEmptyBucket 的核心思想是一樣的,即如果存在已借用的情況,在重置後需要加上在未來已使用過的許可,就不一一展開了。

3.5 addWaiting

public void addWaiting(long time, int acquireCount) {
	WindowWrap<MetricBucket> window = borrowArray.currentWindow(time);
	window.value().add(MetricEvent.PASS, acquireCount);
}

經過上面的分析,先做一個大膽的猜測,該方法應該是當前滑動視窗中的“令牌”已使用完成,借用未來的令牌。將在下文給出證明。

滑動視窗的實現原理就介紹到這裡了。大家可以按照上面的程式碼結合下圖做一個理解。
在這裡插入圖片描述

思考題,大家可以畫一下 OccupiableBucketLeapArray 滑動視窗的圖示。這部分內容也將在我的【中介軟體知識星球】中與各位星友一起探討,歡迎大家的加入。

推薦閱讀:原始碼分析 Alibaba Sentinel 專欄。
1、Alibaba Sentinel 限流與熔斷初探(技巧篇)
2、原始碼分析 Sentinel 之 Dubbo 適配原理


作者資訊:丁威,《RocketMQ技術內幕》作者,目前擔任中通科技技術平臺部資深架構師,維護 中介軟體興趣圈公眾號,目前主要發表了原始碼閱讀java集合、JUC(java併發包)、Netty、ElasticJob、Mycat、Dubbo、RocketMQ、mybaits等系列原始碼。點選連結:加入筆者的知識星球,一起探討高併發、分散式服務架構,分享閱讀原始碼心得。

相關文章