­

如何使用數組實現滑動窗口

FireflySoft.RateLimit之前的版本中,進程內滑動窗口的實現是基於MemoryCache做的,雖然能夠正確的實現滑動窗口的算法邏輯,但是性能比較差,其吞吐量只有其它算法的1/4。性能為何如此之差呢?

滑動窗口的原理

我們先來看下滑動窗口的原理,這裡給一張圖:

 

如上圖所示:

  • 滑動窗口的時間跨度是5秒,每個計數周期的時間跨度是1秒,所以此處的滑動窗口包含5個計數周期。
  • 隨着時間的前進,滑動窗口包含的計數周期會以秒為單位向前移動,但始終是包含5個計數周期。
  • 判斷是否限流時,需要將當前滑動窗口包含的5個計數周期的計數值加起來。
  • 相比固定窗口計數器算法,滑動窗口允許一些突發流量,如上圖中的第7個計數周期。

MemoryCache實現的滑動窗口

使用MemoryCache時,存儲結構如下:

每個計數周期都作為一個緩存項目添加到MemoryCache中,緩存Key為計數周期的絕對時間,緩存值為當前周期的計數值,緩存過期時間為一個大於滑動窗口時間跨度的相對過期時間,這樣不用自己編碼刪除過期的計數周期,而滑動窗口內的計數周期都能正常保留。

另外為了獲得滑動窗口內部每個計數周期對應的緩存項,這裡還額外緩存了第一個計數周期的緩存Key(即對應的絕對時間),這樣就可以根據當前時間和計數周期的時間跨度計算出當前周期的緩存Key,從而可以再逐步向前推出4個計數周期的緩存Key。

如有興趣,具體實現可以點擊這裡進入Github查看

這個實現有兩個問題:

  • 每個計數周期都是一個單獨的緩存項,隨着時間的前進需要不斷申請內存,在堆上申請內存是一個相對耗時的操作。
  • 每次計數都要先計算出滑動窗口內當前的所有計數周期,然後再把它們一個個取出來,求計數值的和,計算量較多。

這應該就是這個算法實現性能比較差的主要原因。

基於數組的滑動窗口

為什麼要使用數組來實現滑動窗口呢?首先當然是數組可以實現滑動窗口,其次它可以解決MemoryCache實現中的兩個問題,一是數組創建時就申請了固定大小的內存,後續計數都使用這塊內存,不用再新申請;二是計算滑動窗口內的計數值只要把數組中每個元素的值加起來就行了,不用再一個個的尋找它們。

學過操作系統的同學可能比較了解,在操作系統中很多地方使用了環形隊列,而環形隊列是用數組實現的;滑動窗口可以理解為環形隊列的一個特例,每次窗口滑動時,隊列彈出一個,然後再進入一個。

理解數組實現的滑動窗口,看下邊這個圖就可以了。

假設滑動窗口的時間跨度還是5s,計數周期的時間跨度是1秒。

首先我們初始化一個容量為5的空數組,此時還沒有開始計數,所以只是一個空數組。

  • 第1秒,開始計數,此時數組內開始存入計數周期,保存在數組第1個位置,(1)表示這是當前滑動窗口內的第1個計數周期。如果我們把滑動窗口看成一個環形隊列,那麼隊列的頭尾此時都是數組的第1個元素。
  • 第2秒,數組又存入一個新的計數周期,保存在數組第2個位置,(2)表示這是當前滑動窗口內的第2個計數周期;此時隊列的尾部來到了數組的第2個元素。
  • 以此類推,時間來到第5秒,此時數組內的每個位置都會存入一個計數周期,滑動窗口內的計數周期也達到了5個;隊列的尾部也來到了數組的最後一個元素。
  • 第6秒,數組已經放不下第6個元素了,因為滑動窗口只需要最近的5個元素,所以我們可以丟棄數組中第1個元素中保存的計數周期,重新創建一個計數周期放進去。從滑動窗口的角度看,丟棄了一個計數周期,新創建的這個計數周期還是滑動窗口的第5個計數周期,原來的第(2)、(3)、(4)、(5)就變成了(1)、(2)、(3)、(4)。如果從循環隊列的角度看,則隊列頭部彈出了一個元素,然後隊列尾部增加了一個元素。
  • 以此類推,時間來到第7秒,代表滑動窗口的循環隊列又彈出了一個過期的計數周期,然後插入新的計數周期。
  • 第9秒也是如此,只不過第9秒結束後,數組的存儲結構又回到了第5秒時的樣子,此時數組內的每個位置都有一個計數周期,這些計數周期在滑動窗口中的位置編號和數組中的位置編號完全相同。

然後隨着時間的前進,滑動窗口的處理就是循環第5秒至第9秒之間的處理邏輯。

再說計數的處理:

  • 時間來到每一秒後, 首先會創建這一秒的計數周期,保存到數組中,在隨後的這一秒的請求中,繼續使用原來的計數周期,並累加計數值,直到下一秒到來。
  • 每次計算滑動窗口內的計數值時,因為數組的容量和滑動窗口的計數周期數保持一致,所以就是簡單的把數組中每個小計數周期的計數值加起來,就可以了。

這裡摘抄一些代碼,讓大家感受更深入一些:

// 幾個重要變量:保存計數周期的數組、代表滑動窗口的循環隊列的頭和尾
SlidingWindowPeriod[] _queue;
int _head = 0;
int _tail = 0;

// 省略很多代碼....

// 創建一個計數周期,這裡是一個結構體
var newPeriod = new SlidingWindowPeriod()
{
    // 為了方便確定當前請求的計數周期,計數周期的Key是一個時間刻度,
    Key = lastPeriod.Key + _statPeriodMilliseconds * i,
    CountValue = 0
};

// 循環隊列尾加1
// 如果超出了數組的索引範圍,則代表需要替換數組中第1個位置的計數周期,然後隊列尾來到數組中第1位
_tail++;
if (_tail == _length) _tail = 0;

// 如果隊列尾在數組中的索引小於等於隊列頭的索引,則隊列頭需要彈出數據,隊列頭的位置向後移動1位
if (_tail <= _head)
{
    _head++;
    // 如果隊列頭的索引會超出索引範圍,則隊列頭歸位到數組中的第1位
    if (_head == _length) _head = 0;
}
// 將新的計數周期寫入隊列尾所在的數組位置
_queue[_tail] = newPeriod;

這裡邊還會有一些特殊的處理,比如滑動窗口只包含一個小計數周期,再比如請求時間的前進是不均勻的(可能會跳過數個計數周期的時間跨度),都需要仔細考慮。


 

好了,以上就是本文的主要內容。

如果想了解完整的實現,查看全部代碼,請點擊進入GitHub