限速器
限速器類型
-
Leaky Bucket:漏桶算法(和令牌桶(token bucket)非常相似)是一種非常簡單,使用隊列來進行限流的算法。當接收到一個請求時,會將其追加到隊列的末尾,系統會按照先進先出的順序處理請求,一旦隊列滿,則會丟棄額外的請求。隊列中的請求數目受限於隊列的大小。
這種方式可以緩解突發流量對系統的影響,缺點是在流量突發時,由於隊列中緩存了舊的請求,導致無法處理新的請求。而且也無法保證請求能夠在一定時間內處理完畢。
令牌桶不會緩存請求,它通過頒發令牌的方式來允許請求,因此它存在和漏桶算法一樣的問題。
-
Fixed Window:該系統使用n秒的窗口大小(通常使用人類友好的值,例如60或3600秒)來跟蹤固定窗口下的請求速率。每接收到一個請求都會增加計算器,當計數器超過閾值後,則會丟棄請求。通常當前時間戳的下限來定義定義窗口,如12:00:03(窗口長度爲60秒)將位於12:00:00的窗口中。
該算法可以保證最新的請求不受舊請求的影響。但如果在窗口邊界出現突發流量,由於短時間內產生的流量可能會同時被計入當前和下一個窗口,因此可能會導致請求速率翻倍。如果有多個消費者等待窗口重置,則在窗口重置後的一開始會出現踩踏效應。跟漏桶算法一樣,固定窗口算法是針對所有消費者而非單個消費者進行限制的。
-
Sliding Log:滑動日誌會跟蹤每個消費者的請求對應的時間戳日誌。系統會將這些日誌保存在按時間排序的哈希集或表中,並丟棄時間戳超過閾值的日誌。當接收到一個請求後,會通過計算日誌的總數來決定請求速率。如果請求超過速率閾值,則暫停處理該請求。
這種算法的優點在於它不存在固定窗口中的邊界限制,因此在限速上更加精確。由於系統會跟蹤每個消費者的滑動日誌,因此也不存在固定窗口算法中的踩踏效應。
但保存無限量的請求會帶來存儲成本,且該算法在接收到請求時都需要計算消費者先前的請求總和(有可能需要跨服務器集羣進行運算),因此計算成本也很高。基於上述原因,該算法在處理突發流量或DDos攻擊等問題上存在擴展性問題。
-
Sliding Window:滑動窗口算法結合了固定窗口算法中的低成本處理以及滑動日誌中對邊界條件的改進。像固定窗口算法一樣,該算法會爲每個固定窗口設置一個計數器,並根據當前時間戳來考慮前一窗口中的請求速率的加權值,用來平滑突發流量。
例如,假設有一個每分鐘允許100個事件的限速器,此時當前時間到了
75s
點,那麼內部窗口如下:
此時限速器在15秒前開始的當前窗口期間(15s~75s)內已經允許了12個事件,而在前一個完整窗口期間允許了86個事件。滑動窗口內的計數近似值可以這樣計算:
count = 86 * ((60-15)/60) + 12
= 86 * 0.75 + 12
= 76.5 events
86 * ((60-15)/60)
爲與上一個窗口重疊的計數,12
爲當前窗口的計數
由於每個關鍵點需要跟蹤的數據量相對較少,因此能夠在大型集羣中進行擴展和分佈。
推薦使用滑動窗口算法,它在提供靈活擴展性的同時,保證了算法的性能。此外它還避免了漏桶算法中的飢餓問題以及固定窗口算法中的踩踏效應。
分佈式系統中的限速
可以採用中央數據存儲(如redis或Cassandra)的方式來實現多節點集羣的全侷限速。中央存儲會爲每個窗口和消費者收集請求次數。但這種方式會給請求帶來延遲,且存儲可能會存在競爭。
在採用get-then-set(即獲取當前的限速器計數,然後增加計數,最後將計數保存到數據庫)模式時可能會產生競爭,導致數據庫計數不一致。
解決該問題的一種方式是使用鎖,但鎖會帶來嚴重的性能問題。更好的方式是使用set-then-get模式,並依賴原子操作來提升性能。
性能優化
即使是Redis這種快速存儲也會給每個請求帶來毫秒級的延遲。可以採用本地內存檢查的方式來最小化延遲。
爲了使用本地檢查,需要放寬速率檢查條件,並使用最終一致性模型。例如,每個節點都可以創建一個數據同步週期,用來與中央數據存儲同步。每個節點週期性地將每個消費者和窗口的計數器增量推送到數據庫,並原子方式更新數據庫值。然後,節點可以檢索更新後的值並更新其內存版本。在集中→發散→再集中的週期中達到最終一致。
同步週期應該是可配置的,當在集羣中的多個節點間分發流量時,較短的同步間隔會降低數據點的差異。而較長的同步間隔會減少數據存儲的讀/寫壓力,並減少每個節點獲取新同步值所帶來的開銷。
Golang中的滑動窗口
Golang的滑動窗口實現比較好的實現有mennanov/limiters和RussellLuo/slidingwindow,個人更推薦後者。下面看下RussellLuo/slidingwindow
的用法和實現。
簡單用法
下面例子中,創建了一個每秒限制10個事件的限速器。lim.Allow()
會增加當前窗口的計數,當計數達到閾值(10),則會返回false
。
package main
import (
"fmt"
sw "github.com/RussellLuo/slidingwindow"
"time"
)
func main() {
lim, _ := sw.NewLimiter(time.Second, 10, func() (sw.Window, sw.StopFunc) {
return sw.NewLocalWindow()
})
for i := 1; i < 12; i++ {
ok := lim.Allow()
fmt.Printf("ok: %v\n", ok)
}
}
對外接口如下:
lim.SetLimit(newLimit int64)
:設置窗口大小lim.Allow()
:就是AllowN(time.Now(), 1)
lim.AllowN(now time.Time, n int64)
:判斷當前窗口是否允許n
個事件,如果允許,則當前窗口計數器+n,並返回true
,反之則返回false
lim.Limit()
:獲取限速值lim.Size()
:獲取窗口大小
實現
首先初始化一個限速器,NewLimiter
的函數簽名如下:
func NewLimiter(size time.Duration, limit int64, newWindow NewWindow) (*Limiter, StopFunc)
- size:窗口大小
- limit:窗口限速
- newWindow:用於指定窗口類型。本實現中分爲LocalWindow和SyncWindow兩種。前者用於設置單個節點的限速,後者用於和中央存儲聯動,可以實現全侷限速。
下面看下核心函數AllowN
和advance
的實現:
實現中涉及到了3個窗口:當前窗口、當前窗口的前一個窗口以及滑動窗口。每個窗口都有計數,且計數不能超過限速器設置的閾值。當前窗口和當前窗口的前一個窗口中保存了計數變量,而滑動窗口的計數是通過計算獲得的。
// AllowN reports whether n events may happen at time now.
func (lim *Limiter) AllowN(now time.Time, n int64) bool {
lim.mu.Lock()
defer lim.mu.Unlock()
lim.advance(now)//調整窗口
elapsed := now.Sub(lim.curr.Start())
weight := float64(lim.size-elapsed) / float64(lim.size)
count := int64(weight*float64(lim.prev.Count())) + lim.curr.Count() //計算出滑動窗口的計數值
// Trigger the possible sync behaviour.
defer lim.curr.Sync(now)
if count+n > lim.limit { //如果滑動窗口計數值+n大於閾值,則說明如果運行n個事件,會超過限速器的閾值,此時拒絕即可。
return false
}
lim.curr.AddCount(n) //如果沒有超過閾值,則更新當前窗口的計數即可。
return true
}
// advance updates the current/previous windows resulting from the passage of time.
func (lim *Limiter) advance(now time.Time) {
// Calculate the start boundary of the expected current-window.
newCurrStart := now.Truncate(lim.size) //返回將當前時間向下舍入爲lim.size的倍數的結果,此爲預期當前窗口的開始邊界
diffSize := newCurrStart.Sub(lim.curr.Start()) / lim.size
if diffSize >= 1 {
// The current-window is at least one-window-size behind the expected one.
newPrevCount := int64(0)
if diffSize == 1 {
// The new previous-window will overlap with the old current-window,
// so it inherits the count.
//
// Note that the count here may be not accurate, since it is only a
// SNAPSHOT of the current-window's count, which in itself tends to
// be inaccurate due to the asynchronous nature of the sync behaviour.
newPrevCount = lim.curr.Count()
}
lim.prev.Reset(newCurrStart.Add(-lim.size), newPrevCount)
// The new current-window always has zero count.
lim.curr.Reset(newCurrStart, 0)
}
}
advance
函數用於調整窗口大小,有如下幾種情況:
需要注意的是,
newCurrStart
和lim.curr.Start()
相差0或多個lim.size
,如果相差0,則newCurrStart
等於lim.curr.Start()
,此時滑動窗口和當前窗口有重疊部分。
-
如果
diffSize == 1
說明記錄的當前窗口和預期的當前窗口是相鄰的(如下圖)。因此需要將記錄的當前窗口作爲前一個窗口(
lim.prev
),並將預期的當前窗口作爲當前窗口,設置計數爲0。轉化後的窗口如下: -
如果如果
diffSize > 1
說明記錄的當前窗口和預期的當前窗口不相鄰,相差1個或多個窗口(如下圖),說明此時預期的當前窗口的前一個窗口內沒有接收到請求,因而沒有對窗口進行調整。此時將前一個窗口的計數設置爲0。並將預期的當前窗口作爲當前窗口,設置計數爲0。
此時AllowN
中的運算如下:
- 計算出當前時間距離當前窗口開始邊界的差值(
elapsed
) - 計算出滑動窗口在前一個窗口中重疊部分所佔的比重(百分比)
- 使用滑動窗口在前一個窗口中重疊部分所佔的比重乘以前一個窗口內的計數,再加上當前窗口的計數,算出滑動窗口的當前計數
- 如果要判斷滑動窗口是否能夠允許n個事件,則使用滑動窗口的當前計數+n與計數閾值進行比較。如果小於計數閾值,則允許事件,並讓滑動窗口計數+n,否則返回false。
-
如果
diffSize<1
,說明滑動窗口和當前窗口有重疊部分,此時不需要調整窗口。AllowN
中的運算與上述邏輯相同: