Sentinel Go 核心統計結構滑動視窗的深度解析

惜暮發表於2021-01-01


本文主要分析 Sentinel Go 流量指標統計底層滑動視窗的實現。

在高可用流量防護場景中,比如流控、熔斷等,不管基於什麼策略,底層最核心的一部分是系統指標(如請求數、rt、異常數、異常比例等等)的統計結構。本文主要分析 Sentinel Go 中統計結構滑動視窗的實現。

什麼是滑動時間視窗

應用指標統計很重要一點是要與時間對齊,比如流控可能希望的是拿到前一秒的總請求數,所以在我們統計的指標都是需要與時間對齊。

滑動視窗基本執行模式

滑動時間視窗有兩個很重要設定:
(1)滑動視窗的統計週期:表示滑動視窗的統計週期,一個滑動視窗有一個或多個視窗。
(2)滑動視窗中每個視窗長度:每個視窗(也叫格子,後文格子都是指一個視窗)的統計週期。

這裡先假設我的滑動時間視窗長度是1000ms,每個視窗統計時間長是200ms,那麼就會有5個視窗。假設我一個視窗記錄的起始時間是第1000ms,那麼一個基本的滑動視窗的示意圖如下圖:(注意,這裡忽略了每個格子裡面具體的統計結構)
在這裡插入圖片描述
(1)滑動視窗裡面每個格子都是一個統計結構,可以理解成一個抽象的結構(比如Java的Object或則Go的interface{}),使用者可以自己決定統計的具體資料結構。
(2)每個格子都會有自己的統計開始時間,在 [開始時間,開始時間+格子長度)這個時間範圍內,所有統計都會落到這個格子裡面。

怎麼計算當前時間在哪一個格子裡面呢? 這裡假設滑動視窗長度是 interval 表示,每個格子長度是 bucketLength 表示,當前時間是 now,前面的數值都是毫秒單位。那麼計算方式就是:

當前時間所在格子計算方式 index = (now/bucketLength)%interval

也就是說我們知道當前時間就能知道當前時間對應在滑動視窗的第幾個格子。舉一些例子來說明:
(1)假設當前時間是1455ms,那麼經過計算,index 就是 2,也就是第三個格子。
(2)假設當前時間是1455000000000 ms,那麼經過計算,index 就是 0,也就是第一個格子。

隨著時間的滑動,滑動視窗類似於一個固定長度的環形佇列。

前面圖示的滑動視窗的時間範圍是[1000, 2000),假設當前時間滑動到了2001ms時候會是怎麼樣呢?看下圖:
在這裡插入圖片描述

根據前面計算方式,now=2001,算出來index是0,也就是當前時間打到了第一個格子。然後計算當前時間對應的格子的起始時間,很明顯就是2000,這個時候發現 2000 > 1000(格子原始統計起始時間),說明當前格子進入了新的統計週期,所以需要把當前格子重置,重置包括兩部分:(1)格子起始時間到2000;(2)統計結構清空。

滑動視窗的週期和格子長度怎麼設定?

滑動視窗的設定主要是兩個引數:
(1)滑動視窗的長度;
(2)滑動視窗每個格子的長度。
那麼這兩個設定應該怎麼設定呢?

這裡主要考慮點是:抗脈衝流量的能力和精確度之間的平衡。
(1)如果格子長度設定的小那麼統計就會更加精確,但是格子太多,會增加競爭的可能性,因為視窗滑動必須是併發安全的,這裡會有競爭。
(2)如果滑動視窗長度越長,對脈衝的平滑能力就會越強。

滑動視窗長度一致,格子長度不一致

這裡首先來個對比:
在這裡插入圖片描述
很直觀,假設這兩種case下的統計資料如下:
在這裡插入圖片描述
在[1000,1500) 區間統計都是600,[1500, 2000) 之間統計都是500。我們獲取滑動視窗的統計時候,兩者的統計總和都是1100。

但是,當前時間如果滑動到了2001ms的時候,按照前面滑動視窗的邏輯,我們需要將滑動視窗的第一個格子覆蓋成新的統計格子。如下圖:
在這裡插入圖片描述
從上圖可以看出來,到覆蓋第一個格子時候,兩個滑動視窗的統計結果就完全不一樣了:
(1)第一個滑動視窗第一個格子(500ms長度)清零了,整個統計總計數變成了 501;
(2)第二個滑動視窗第一個格子(100ms長度)清零了,整個統計總計數變成了 981;
但是隨著時間的繼續往後滑動,在[2000, 2500) 之間,時間越往後,兩者之間精度差會越來越小。

很明顯結論:在滑動視窗統計週期一樣情況下,格子劃分的越多,那麼統計的精度就越高。

格子長度一致,滑動視窗長度不一致

滑動視窗整體的統計長度怎麼設定呢?有哪些需要考慮的點呢?這裡一個非常重要的因素是對流量脈衝的抵抗能力。

什麼是流量脈衝?什麼是非脈衝流量?

(1)下圖就是一個典型的類似脈衝流量,視窗統計長度是2000ms,格子長度是200ms。在[1400ms,2200ms)之間來了一波峰值。
在這裡插入圖片描述

(2)下圖類似於非脈衝流量,視窗統計長度是2000ms,格子長度是200ms。每個格子內請求數都在160左右。
在這裡插入圖片描述
我們統計滑動視窗內的請求數總數時候,也就是把所有格子的統計值求和。
(1)對於上圖(1)中脈衝流量,在整個滑動視窗的總和是1600個請求數左右。平均下來每200ms大概是160個請求,但是會在1600ms這個格子來一個峰值,達到了640個請求。 這裡明顯是一個很大的脈衝。
(2)對於上圖(2)中非脈衝流量,在整個滑動視窗的總和是1600個請求數左右。而且以200ms粒度來看,整體流量非常平滑。

這裡帶來問題是:從整個滑動視窗統計週期(2000ms)來看,兩者最後的效果是一樣的。因為統計週期長,導致脈衝流量被平均掉了(也可以理解成被平滑了)。

那麼假設我的滑動視窗統計週期只有400ms呢?對於[1400ms, 1600ms)區間,如下圖:
在這裡插入圖片描述
視窗長度是400ms,兩個格子,每個格子200ms。 整個視窗統計總請求數:650。 按照前面2000ms視窗來平均,400ms視窗應該是400個請求數。明顯650 > 400。 所以視窗長度越小,抗脈衝能力越差。

結論就是:滑動視窗的長度設定的越長,整體統計的結果抗脈衝能力會越強;滑動視窗的長度設定的越短,整體統計結果對脈衝的抵抗能力越弱。

具體怎麼平衡還是要依據系統對脈衝流量的處理能力。

總結

(1)滑動視窗長度設定主要影響對脈衝流量的平滑效果,視窗越長,抗脈衝能力越強;
(2)滑動視窗的格子數量(固定滑動視窗長度下的格子長度),主要影響統計的精度,格子數越多,精度越高,但是也不是越多越好,太多了活影響併發效能。

Sentinel Go時間滑動視窗實現

Sentinel Go 滑動視窗 的實現基本和前一個章節描述的一樣。

整個實現分為兩部分:
(1)使用可設定的定長陣列來表示滑動視窗(無鎖),陣列每個元素就是一個格子,格子是一個抽象物件,可以儲存任意統計實體。
(2)一套基於時間滑動的演算法,保證滑動視窗的滑動符合預期。

長度可設定的原子陣列

滑動視窗最基礎部分是一個無鎖的原子陣列。 Sentinel Go 是基於 slice 來實現原子陣列的。原子陣列可以無鎖實現併發下的讀寫。

先看定義:

// AtomicBucketWrapArray forbit appending or shrinking after initializing
type AtomicBucketWrapArray struct {
	// The base address for real data array
	base unsafe.Pointer
	// The length of slice(array), it can not be modified.
	length int
	data   []*BucketWrap
}

// BucketWrap represent a slot to record metrics
type BucketWrap struct {
	// The start timestamp of this statistic bucket wrapper.
	BucketStart uint64
	// The actual data structure to record the metrics (e.g. MetricBucket).
	Value atomic.Value
}

原子陣列AtomicBucketWrapArray裡面通過一個切片 []*BucketWrap 來表示一個滑動視窗,視窗長度通過 length 欄位表示,base 欄位表示的是切片底層實際儲存資料的第一個元素的首地址。這裡有一點需要再次強調的:原子陣列AtomicBucketWrapArray建立時候必須制定長度,一旦建立了之後不允許再增加元素或則縮容

BucketWrap 表示滑動視窗中每個格子實際的儲存實體,裡面包含兩個欄位:BucketStart 表示當前格子統計的開始時間;Value是一個原子變數,可以簡單理解成一個併發安全的 void* 指標。

那麼我們是怎麼保證AtomicBucketWrapArray是一個併發安全的呢?在建立 AtomicBucketWrapArray 物件的原始碼可以找到答案:

// SliceHeader is a safe version of SliceHeader used within this project.
type SliceHeader struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

func NewAtomicBucketWrapArrayWithTime(len int, bucketLengthInMs uint32, now uint64, generator BucketGenerator) *AtomicBucketWrapArray {
	// Step1: new
	ret := &AtomicBucketWrapArray{
		length: len,
		data:   make([]*BucketWrap, len),
	}
	// Step2: ......
	// initilize sliding windows start time (BucketWrap)
	
	// Step3: calculate base address for real data array
	sliHeader := (*SliceHeader)(unsafe.Pointer(&ret.data))
	ret.base = unsafe.Pointer((**BucketWrap)(sliHeader.Data))
	return ret
}

下面一步步解釋 NewAtomicBucketWrapArrayWithTime 函式是怎麼做的:
(1)根據輸入的原子陣列長度來建立AtomicBucketWrapArray例項,底層slice的時候必須要指定長度,即data: make([]*BucketWrap, len)。 這裡會新建一個長度為 len 的slice,底層提前分配了len個元素的陣列,並且初始化為nil指標。
(2)上面貼出程式碼省略部分為預初始化滑動視窗中每個格子的元素,也就是提前初始化 *BucketWrap,這塊在後面再聊。
(3)根據 slice 的記憶體佈局,拿到slice底層實際儲存陣列結構的首元素的地址,然後更新到AtomicBucketWrapArray.base欄位。這裡基於slice記憶體佈局以及unsafe的指標轉換(二級指標**BuckerWarp),可以拿到底層實際儲存陣列的首元素的記憶體地址。(Note:因為陣列儲存元素的是*BucketWarp,所以陣列首地址實際上一個二級指標**BuckerWarp

這裡基於 slice 的記憶體轉換拿到底層首元素記憶體地址的指標轉換原理,可以參考另一篇文章 Go unsafe.Pointer 使用基本原則

AtomicBucketWrapArray 的建立過程為了避免一些邊界邏輯,這裡採用了建立物件時就預分配的所有格子的其實時間的邏輯,下面的程式碼片段實際上也就是NewAtomicBucketWrapArrayWithTime 函式中省略的Step2:

timeId := now / uint64(bucketLengthInMs)
idx := int(timeId) % len
startTime := calculateStartTime(now, bucketLengthInMs)

for i := idx; i <= len-1; i++ {
	ww := &BucketWrap{
		BucketStart: startTime,
		Value:       atomic.Value{},
	}
	ww.Value.Store(generator.NewEmptyBucket())
	ret.data[i] = ww
	startTime += uint64(bucketLengthInMs)
}
for i := 0; i < idx; i++ {
	ww := &BucketWrap{
		BucketStart: startTime,
		Value:       atomic.Value{},
	}
	ww.Value.Store(generator.NewEmptyBucket())
	ret.data[i] = ww
	startTime += uint64(bucketLengthInMs)
}

(1)這裡首先會根據建立AtomicBucketWrapArray 時候的當前時間now計算出其所對在的格子在陣列中的下標idx以及該格子的統計其實時間startTime
(2)從[idx, len-1] 會預先分配每個格子結構*BucketWrap,並且基於計算出的startTime依次往後填入對應格子的開始時間;
(3)從[0,idx-1] 這個區間也會預先分配每個格子結構*BucketWrap,不過需要注意的是,[0,idx-1] 裡面的格子預先分配的時間也是未來的時間。

這裡採用預分配主要是為了在滑動視窗在初始化之後,降低時間滑動時候邏輯的複雜性(避免判空然後新建*BucketWrap的流程)。下面以一個圖示來說明:假設原子陣列長度是1000ms,每個格子長度是200ms,當前時間是1401ms。首先1401ms計算出對應的格子索引是 2,格子起始時間是1400ms;那麼新建AtomicBucketWrapArray 時候建立的物件實際儲存如下圖:
AtomicBucketWrapArray建立示意圖
前面解釋了新建原子陣列AtomicBucketWrapArray的原理,那麼我們拿到一個時間時候,怎麼能夠在併發環境下訪問呢?這裡避免不了的指標的原子操作,還是從原始碼出發:

func (aa *AtomicBucketWrapArray) elementOffset(idx int) (unsafe.Pointer, bool) {
	if idx >= aa.length || idx < 0 {
		logging.Error(errors.New("array index out of bounds"),
			"array index out of bounds in AtomicBucketWrapArray.elementOffset()",
			"idx", idx, "arrayLength", aa.length)
		return nil, false
	}
	basePtr := aa.base
	return unsafe.Pointer(uintptr(basePtr) + uintptr(idx*PtrSize)), true
}

func (aa *AtomicBucketWrapArray) get(idx int) *BucketWrap {
	// aa.elementOffset(idx) return the secondary pointer of BucketWrap, which is the pointer to the aa.data[idx]
	// then convert to (*unsafe.Pointer)
	if offset, ok := aa.elementOffset(idx); ok {
		return (*BucketWrap)(atomic.LoadPointer((*unsafe.Pointer)(offset)))
	}
	return nil
}

func (aa *AtomicBucketWrapArray) compareAndSet(idx int, except, update *BucketWrap) bool {
	// aa.elementOffset(idx) return the secondary pointer of BucketWrap, which is the pointer to the aa.data[idx]
	// then convert to (*unsafe.Pointer)
	// update secondary pointer
	if offset, ok := aa.elementOffset(idx); ok {
		return atomic.CompareAndSwapPointer((*unsafe.Pointer)(offset), unsafe.Pointer(except), unsafe.Pointer(update))
	}
	return false
}

基於滑動視窗的背景,我們期望輸入是當前時間 now,基於這個輸入我們可以在併發環境下拿到對應的格子。通過時間對齊的計算,我們根據now可以計算出當前時間在滑動視窗中對應的格子的下標索引idx。那麼問題也就是轉換成基於下標idx併發安全的拿到對應格子。

(1)根據idx拿到陣列中對應元素的首地址:elementOffset 函式:
前面我們知道AtomicBucketWrapArray.base已經儲存了底層儲存陣列的首元素的儲存地址。那麼只需要通過指標的運算就可以拿到第idx個元素的儲存地址,計算邏輯:unsafe.Pointer(uintptr(basePtr) + uintptr(idx*PtrSize))

(2)get(idx) 函式拿到第idx個元素實際儲存的物件:
這裡首先通過elementOffset(idx)拿到第idx個元素的地址,然後通過指標的原子操作atomic.LoadPointer 函式可以原子的拿到第idx個元素實際儲存的*BucketWrap物件。核心邏輯簡單理解成:(*BucketWrap)(atomic.LoadPointer((*unsafe.Pointer)(elementOffset(idx))))

(3)compareAndSet(idx int, except, update *BucketWrap) bool 原子更新的邏輯
compareAndSet的邏輯和get函式基本一致,這裡就不再細說。

理解上面三個函式的邏輯有一個背景就是要理解好二級指標。原子陣列每個元素實際儲存的是一個指標*BucketWrap,下面用一個圖示:
原子陣列儲存示意圖
base表示第一個元素的儲存地址,那麼*base 表示的是第一個元素。假設是64位機器,指標型別佔用8個位元組,那麼*(base+8)就表示第二個元素。

基於時間的滑動視窗實現

前面的AtomicBucketWrapArray 已經提供了原子陣列的能力,在AtomicBucketWrapArray 之上就是滑動視窗的能力。先貼上出原始碼:

type LeapArray struct {
	bucketLengthInMs uint32
	sampleCount      uint32
	intervalInMs     uint32
	array            *AtomicBucketWrapArray
	// update lock
	updateLock mutex
}

// Generic interface to generate bucket
type BucketGenerator interface {
	// called when timestamp entry a new slot interval
	NewEmptyBucket() interface{}

	// reset the BucketWrap, clear all data of BucketWrap
	ResetBucketTo(bw *BucketWrap, startTime uint64) *BucketWrap
}

func (la *LeapArray) currentBucketOfTime(now uint64, bg BucketGenerator) (*BucketWrap, error) {
	if now <= 0 {
		return nil, errors.New("Current time is less than 0.")
	}

	idx := la.calculateTimeIdx(now)
	bucketStart := calculateStartTime(now, la.bucketLengthInMs)

	for { //spin to get the current BucketWrap
		old := la.array.get(idx)
		if old == nil {
			// because la.array.data had initiated when new la.array
			// theoretically, here is not reachable
			newWrap := &BucketWrap{
				BucketStart: bucketStart,
				Value:       atomic.Value{},
			}
			newWrap.Value.Store(bg.NewEmptyBucket())
			if la.array.compareAndSet(idx, nil, newWrap) {
				return newWrap, nil
			} else {
				runtime.Gosched()
			}
		} else if bucketStart == atomic.LoadUint64(&old.BucketStart) {
			return old, nil
		} else if bucketStart > atomic.LoadUint64(&old.BucketStart) {
			// current time has been next cycle of LeapArray and LeapArray dont't count in last cycle.
			// reset BucketWrap
			if la.updateLock.TryLock() {
				old = bg.ResetBucketTo(old, bucketStart)
				la.updateLock.Unlock()
				return old, nil
			} else {
				runtime.Gosched()
			}
		} else if bucketStart < atomic.LoadUint64(&old.BucketStart) {
			if la.sampleCount == 1 {
				// if sampleCount==1 in leap array, in concurrency scenario, this case is possible
				return old, nil
			}
			// TODO: reserve for some special case (e.g. when occupying "future" buckets).
			return nil, errors.New(fmt.Sprintf("Provided time timeMillis=%d is already behind old.BucketStart=%d.", bucketStart, old.BucketStart))
		}
	}
}

底層儲存是基於AtomicBucketWrapArray,這裡就不細說了。

BucketGenerator是一個抽象介面,該介面能力基於兩個函式:NewEmptyBucket是用於建立格子裡面的實際統計的資料結構;ResetBucketTo 函式是用於時間滑動時,將某個格子的起始時間以及統計結構重置。BucketGenerator介面是保證滑動視窗裡面的格子可以複用於統計任何儲存結構的關鍵。

下面重點在於無鎖的滑動視窗實際執行流程,也就是currentBucketOfTime函式是怎麼執行的:

  1. 首先會根據當前時間now計算出其在滑動視窗中是哪個格子,也就是格子在陣列中的下標,以及所對應的統計起始時間bucketStart
  2. 進入一個迴圈獲取對應格子統計結構*BucketWrap
    1. 基於 AtomicBucketWrapArray 能力,原子獲取格子當前儲存的*BucketWrap,也就是程式碼中的old指標物件;
    2. 如果old是空,也就是表示當前格子還沒有初始化,新建*BucketWrap,然後通過CAS設定;併發情況下,如果有協程設定失敗,就會重新進入for迴圈;
    3. 如果old非空而且old格子的統計開始時間old.BucketStartnow計算出來的統計起始時間一致,也就是說明當前時間now對應的格子有效,直接返回統計的格子。
    4. 如果old非空而且old格子的統計開始時間old.BucketStart小於 now計算出來的統計起始時間;這種case也就是說明當前時間now已經走到了下一輪或則下多輪了,這個時候需要更新當前格子的統計結構了(更新格子的統計開始時間以及統計結構清零);需要注意的是,在併發下,可能有多個協程同時走到了這裡,所以這裡通過一個鎖的TryLock操作保證只有一個協程會執行格子更新操作。這裡獲取鎖失敗協程會再次走到for迴圈。
    5. 如果old非空而且old格子的統計開始時間old.BucketStart大於 now計算出來的統計起始時間;也就是說當前時間已經落後了,這是一種別的協程已經預佔了未來格子的case,目前邏輯沒有用到。

下圖展示滑動視窗隨著時間滑動過程中變化:
滑動視窗滑動過程中示意圖

參考文件:
Sentinel Go原始碼
golang slice實踐以及底層實現
golang unsafe.Pointer使用原則以及 uintptr 隱藏的坑

相關文章