1.滑動窗⼝算法介紹
限流算法的應⽤場景⾮常⼴泛,⽐如通過限流來確定下遊配置較差的應⽤不會被上遊應⽤的⼤量請求擊穿,⽆論是HTTP請求還是RPC請求,從⽽使得服務保持穩定。⽬前常⻅的限流算法有如下⼏種:計數器算法、令牌桶算法、漏桶算法。
其中計數器算法是⽐較簡單容易實作的⼀種,⽐如想限制某個接⼝每分鐘通路不能超過1000次,我們可以定義兩個變量:⼀個是統計的時間區間,例如⼀分鐘,包含開始時間和結束時間;另⼀個是記錄該區間内的通路次數。如果記錄時間超出舊的區間就新增⼀個區間并重新計數;如果在舊區間内就把通路次數加⼀。統計某⼀分鐘的通路數時隻需要直接取對應時間區間内的次數即可。
這其實就是固定窗⼝算法的基本原理。但是這樣的統計⽅式存在臨界點的問題,假如我們想限制每分鐘内接⼝調⽤次數不能超過1000次,如下圖所示,如果⽤戶在01:59秒發送了1000次調⽤,⼜在02:01秒調⽤了1000次,2秒内就調⽤了2000次,已經⼤于限流的門檻值,但是分屬于兩個統計區間,并不會觸發限流,失去了保護作⽤。
要解決這個問題可以采取滑動窗⼝算法、令牌桶算法或漏桶算法,我們本次主要讨論滑動窗⼝算法的實作。類似于TCP協定中的滑動窗⼝,限流中滑動窗⼝可以這麼簡單了解:
1、設定的統計時間是⼀個窗⼝,他有⼀個時間⻓度屬性,⽐如⼀分鐘。
2、窗⼝時間區間可以繼續分隔成多個更⼩的時間單元,⽐如分成10個單元,每個單元是6秒鐘。記錄時間在該單元内的通路次數會累加在該單元内。
3、随着時間的推移,窗⼝會向右滑動,它覆寫的時間單元會發⽣變化,窗⼝内的統計數也會發⽣變化。如此細分會讓計數變得更加平滑,是以細分越多,統計越準确。
綜上所述,我們總結為:
1、滑動窗⼝算法是将時間周期分為N個⼩周期,分别記錄每個⼩周期内通路次數,并且根據時間滑動删除過期的⼩周期。
2、滑動窗⼝算法是以目前時間點為基準,往前推移⼀個統計時間區間(如1分鐘)來統計區間内的計數值。并不是說窗⼝會⾃動移動,⽽是統計的時候才産⽣移動的效果。
3、滑動窗⼝計數的精準度由窗⼝區間劃分的格⼦多少決定的,格⼦越多越精準。
2.Sentinel滑動窗⼝算法實作
⾸先介紹⼀下滑動窗⼝算法采⽤的資料結構。
1、定義⼀個窗⼝區間(span),表示我們要統計的資料時間範圍:intervalInMs,區間統計的總時間⻓度(毫秒) 。
2、定義窗⼝區間要劃分的時間單元格⼦(window)的個數:sampleCount,(統計區間劃分統計⼦窗⼝的個數 )。
3、定義每個格⼦包含的時間⻓度:windowLengthInMs(每個格⼦的時間⻓度)。很明顯 windowLengthInMs=intervalInMs/sampleCount 。這⾥隐含的⼀個條件是 intervalInMs要是sampleCount的整數倍。
4、定義⼀個存儲結構(bucket),⽤于存儲計數的值(count),它與時間單元格⼦⼀⼀對應。資料結構可以采⽤數組或連結清單,Sentinel中采⽤的是定⻓的數組,⽐較有意思。
為了⽅便算法示範我們先假定各個參數如下:
示範的資料結構如下圖所示:
虛線表示⼀個跨度為1000ms的統計區間,共分為2個⼩窗⼝,每個⼩窗⼝⾥⾯有個bucket⽤來存儲計數,每個⼩窗⼝的時間跨度是500ms,各有⼀個⻩⾊箭頭表示該窗⼝的開始時間。Sentinel中是以AtomicReferenceArray 數組來存儲window資料的,依照上圖應該是⼀個length=2的數組。
基于滑動窗⼝計數器的原理,算法必須要實作如下核⼼操作:
1、每次請求過來時,根據目前時間求得對應的計數窗⼝,并将窗⼝内的值加1。
2、窗⼝的滑動(更新)。
3、取出統計區間内的有效窗⼝,并sum得出總和。
接下來逐漸講解Sentinel中是如何實作上述3個步驟的。
2.1 基于目前時間求解⽬标窗⼝
基于目前時間求解⽬标窗⼝的流程如下圖所示:
1、基于目前時間戳求解應屬窗⼝的數組下标idx。這⼀步其實很簡單,因為每個⼀個窗⼝都是定⻓的隻需要計算目前時間包含了⼏個機關窗⼝⻓度,然後按數組⻓度取模即可。
2、基于目前時間戳求解應屬窗⼝的startTime。這⼀步的的實作如下,也很容易了解,⽐如 timeMillis=750,750-750%500=500,就是第⼆個窗⼝的startTime。
3、然後根據idx去數組⾥⾯取值,會有兩種情況:⼀種是取得到;⼀種是取不到。取得到的原因是因為之前已經有請求建立了該窗⼝,取不到的時候就要目前線程⾃⽴更⽣建立⼀個窗⼝。因為是⾼并發的場景,可能同時存在多個線程同時建立窗⼝,為了保證資料⼀緻性,需要通過CAS的⽅式來添加到窗⼝數組中。如果添加失敗,那麼目前線程還需要等待⼀下後再嘗試重新擷取窗⼝,Sentinel中是通過 Thread.yield(); 讓出CPU時間⽚來實作放⼿的。
4、如果根據idx取到了窗⼝,那還需要再根據求得的startTime與窗⼝的startTime做⼀個對⽐。如果相等說明該窗⼝是未過期可⽤的。如果不等那就可能有兩種原因:⼀種是窗⼝已經過期了,需要更新;⼀種是伺服器的時間回撥了。這兩種情況都涉及到窗⼝的更新(請記住,數組⾥⾯始終隻有2個元素,都是要重複使⽤的)。
2.2 窗⼝的更新
⾸先解釋⼀下怎麼會有窗⼝過期的情況,如下圖所示,假如目前timeMillis=1200,此時idx=0,并且startTime=1000。根據idx=0取得第⼀個窗⼝,但是此時該窗⼝還是舊的狀态,存的是0-500時間内的髒資料,是以需要更新該窗⼝,修改為:
startTime=1000,count=0。
還有⼀種情況就是startTime < window1.startTime,這種情況是伺服器時間回撥了,Sentinel的處理是直接建立⼀個窗⼝傳回,但沒有添加到數組中,等于是丢棄了這個窗⼝資料。
2.3 計算統計區間内計數總和
經過前⾯的介紹,我們窗⼝數組中的窗⼝有可能是過期的,那麼怎麼取得正确的計數值呢?⾸先獲得窗⼝數組中所有的窗⼝清單;然後判斷該窗⼝是否過期,如果過期就抛棄;最後将有效窗⼝中Bucket中的值進⾏累加即可。
判斷是否過期隻需要按如下邏輯處理即可:目前時間戳timeMillis 減去窗⼝的 startTime 是否超出了統計區間⻓度 intervalInMs,超出的都是過期的。
3.總結
本⽂主要介紹了什麼是滑動窗⼝算法,同時以Sentinel中的實作為例,示範了它如何通過⼀個定⻓的數組來實作滑動窗⼝的核⼼操作。雖然沒有⻓篇累牍的代碼⾛讀,但是相信看了這篇⽂章的你再去看Sentinel中的源碼就會⼼有成⽵、豁然開朗了,繼⽽⾃⼰實作⼀個滑動窗⼝算法也不是什麼難事(最近發現騰訊雲虛拟機熱遷移會導緻redis連接配接逾時異常,便基于滑動窗⼝寫了⼀個定時統計告警的分享,也是這篇⽂章的由來)。
最後,這⾥再抛出⼀個問題:如果基于連結清單來實作滑動窗⼝算法該如何設計,和Sentinel的實作相⽐各有什麼優劣?