限速器算法
限速器
限速器類型
-
Leaky Bucket:漏桶算法(和令牌桶(token bucket)非常相似)是一種非常簡單,使用隊列來進行限流的算法。當(dāng)接收到一個請求時,會將其追加到隊列的末尾,系統(tǒng)會按照先進先出的順序處理請求,一旦隊列滿,則會丟棄額外的請求。隊列中的請求數(shù)目受限于隊列的大小。
這種方式可以緩解突發(fā)流量對系統(tǒng)的影響,缺點是在流量突發(fā)時,由于隊列中緩存了舊的請求,導(dǎo)致無法處理新的請求。而且也無法保證請求能夠在一定時間內(nèi)處理完畢。
令牌桶不會緩存請求,它通過頒發(fā)令牌的方式來允許請求,因此它存在和漏桶算法一樣的問題。
-
Fixed Window:該系統(tǒng)使用n秒的窗口大小(通常使用人類友好的值,例如60或3600秒)來跟蹤固定窗口下的請求速率。每接收到一個請求都會增加計算器,當(dāng)計數(shù)器超過閾值后,則會丟棄請求。通常當(dāng)前時間戳的下限來定義定義窗口,如12:00:03(窗口長度為60秒)將位于12:00:00的窗口中。
該算法可以保證最新的請求不受舊請求的影響。但如果在窗口邊界出現(xiàn)突發(fā)流量,由于短時間內(nèi)產(chǎn)生的流量可能會同時被計入當(dāng)前和下一個窗口,因此可能會導(dǎo)致請求速率翻倍。如果有多個消費者等待窗口重置,則在窗口重置后的一開始會出現(xiàn)踩踏效應(yīng)。跟漏桶算法一樣,固定窗口算法是針對所有消費者而非單個消費者進行限制的。
-
Sliding Log:滑動日志會跟蹤每個消費者的請求對應(yīng)的時間戳日志。系統(tǒng)會將這些日志保存在按時間排序的哈希集或表中,并丟棄時間戳超過閾值的日志。當(dāng)接收到一個請求后,會通過計算日志的總數(shù)來決定請求速率。如果請求超過速率閾值,則暫停處理該請求。
這種算法的優(yōu)點在于它不存在固定窗口中的邊界限制,因此在限速上更加精確。由于系統(tǒng)會跟蹤每個消費者的滑動日志,因此也不存在固定窗口算法中的踩踏效應(yīng)。
但保存無限量的請求會帶來存儲成本,且該算法在接收到請求時都需要計算消費者先前的請求總和(有可能需要跨服務(wù)器集群進行運算),因此計算成本也很高。基于上述原因,該算法在處理突發(fā)流量或DDos攻擊等問題上存在擴展性問題。
-
Sliding Window:滑動窗口算法結(jié)合了固定窗口算法中的低成本處理以及滑動日志中對邊界條件的改進。像固定窗口算法一樣,該算法會為每個固定窗口設(shè)置一個計數(shù)器,并根據(jù)當(dāng)前時間戳來考慮前一窗口中的請求速率的加權(quán)值,用來平滑突發(fā)流量。
例如,假設(shè)有一個每分鐘允許100個事件的限速器,此時當(dāng)前時間到了
75s點,那么內(nèi)部窗口如下:
此時限速器在15秒前開始的當(dāng)前窗口期間(15s~75s)內(nèi)已經(jīng)允許了12個事件,而在前一個完整窗口期間允許了86個事件?;瑒哟翱趦?nèi)的計數(shù)近似值可以這樣計算:
count = 86 * ((60-15)/60) + 12
= 86 * 0.75 + 12
= 76.5 events
86 * ((60-15)/60)為與上一個窗口重疊的計數(shù),12為當(dāng)前窗口的計數(shù)
由于每個關(guān)鍵點需要跟蹤的數(shù)據(jù)量相對較少,因此能夠在大型集群中進行擴展和分布。
推薦使用滑動窗口算法,它在提供靈活擴展性的同時,保證了算法的性能。此外它還避免了漏桶算法中的饑餓問題以及固定窗口算法中的踩踏效應(yīng)。
分布式系統(tǒng)中的限速
可以采用*數(shù)據(jù)存儲(如redis或Cassandra)的方式來實現(xiàn)多節(jié)點集群的全局限速。*存儲會為每個窗口和消費者收集請求次數(shù)。但這種方式會給請求帶來延遲,且存儲可能會存在競爭。
在采用get-then-set(即獲取當(dāng)前的限速器計數(shù),然后增加計數(shù),最后將計數(shù)保存到數(shù)據(jù)庫)模式時可能會產(chǎn)生競爭,導(dǎo)致數(shù)據(jù)庫計數(shù)不一致。
解決該問題的一種方式是使用鎖,但鎖會帶來嚴重的性能問題。更好的方式是使用set-then-get模式,并依賴原子操作來提升性能。
性能優(yōu)化
即使是Redis這種快速存儲也會給每個請求帶來毫秒級的延遲??梢圆捎帽镜貎?nèi)存檢查的方式來最小化延遲。
為了使用本地檢查,需要放寬速率檢查條件,并使用最終一致性模型。例如,每個節(jié)點都可以創(chuàng)建一個數(shù)據(jù)同步周期,用來與*數(shù)據(jù)存儲同步。每個節(jié)點周期性地將每個消費者和窗口的計數(shù)器增量推送到數(shù)據(jù)庫,并原子方式更新數(shù)據(jù)庫值。然后,節(jié)點可以檢索更新后的值并更新其內(nèi)存版本。在集中→發(fā)散→再集中的周期中達到最終一致。
同步周期應(yīng)該是可配置的,當(dāng)在集群中的多個節(jié)點間分發(fā)流量時,較短的同步間隔會降低數(shù)據(jù)點的差異。而較長的同步間隔會減少數(shù)據(jù)存儲的讀/寫壓力,并減少每個節(jié)點獲取新同步值所帶來的開銷。
Golang中的滑動窗口
Golang的滑動窗口實現(xiàn)比較好的實現(xiàn)有mennanov/limiters和RussellLuo/slidingwindow,個人更推薦后者。下面看下RussellLuo/slidingwindow的用法和實現(xiàn)。
簡單用法
下面例子中,創(chuàng)建了一個每秒限制10個事件的限速器。lim.Allow()會增加當(dāng)前窗口的計數(shù),當(dāng)計數(shù)達到閾值(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):設(shè)置窗口大小 -
lim.Allow():就是AllowN(time.Now(), 1) -
lim.AllowN(now time.Time, n int64):判斷當(dāng)前窗口是否允許n個事件,如果允許,則當(dāng)前窗口計數(shù)器+n,并返回true,反之則返回false -
lim.Limit():獲取限速值 -
lim.Size():獲取窗口大小
實現(xiàn)
首先初始化一個限速器,NewLimiter的函數(shù)簽名如下:
func NewLimiter(size time.Duration, limit int64, newWindow NewWindow) (*Limiter, StopFunc)
- size:窗口大小
- limit:窗口限速
- newWindow:用于指定窗口類型。本實現(xiàn)中分為LocalWindow和SyncWindow兩種。前者用于設(shè)置單個節(jié)點的限速,后者用于和*存儲聯(lián)動,可以實現(xiàn)全局限速。
下面看下核心函數(shù)AllowN和advance的實現(xiàn):
實現(xiàn)中涉及到了3個窗口:當(dāng)前窗口、當(dāng)前窗口的前一個窗口以及滑動窗口。每個窗口都有計數(shù),且計數(shù)不能超過限速器設(shè)置的閾值。當(dāng)前窗口和當(dāng)前窗口的前一個窗口中保存了計數(shù)變量,而滑動窗口的計數(shù)是通過計算獲得的。
// 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)//調(diào)整窗口
elapsed := now.Sub(lim.curr.Start())
weight := float64(lim.size-elapsed) / float64(lim.size)
count := int64(weight*float64(lim.prev.Count())) + lim.curr.Count() //計算出滑動窗口的計數(shù)值
// Trigger the possible sync behaviour.
defer lim.curr.Sync(now)
if count+n > lim.limit { //如果滑動窗口計數(shù)值+n大于閾值,則說明如果運行n個事件,會超過限速器的閾值,此時拒絕即可。
return false
}
lim.curr.AddCount(n) //如果沒有超過閾值,則更新當(dāng)前窗口的計數(shù)即可。
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) //返回將當(dāng)前時間向下舍入為lim.size的倍數(shù)的結(jié)果,此為預(yù)期當(dāng)前窗口的開始邊界
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函數(shù)用于調(diào)整窗口大小,有如下幾種情況:
需要注意的是,
newCurrStart和lim.curr.Start()相差0或多個lim.size,如果相差0,則newCurrStart等于lim.curr.Start(),此時滑動窗口和當(dāng)前窗口有重疊部分。
-
如果
diffSize == 1說明記錄的當(dāng)前窗口和預(yù)期的當(dāng)前窗口是相鄰的(如下圖)。因此需要將記錄的當(dāng)前窗口作為前一個窗口(
lim.prev),并將預(yù)期的當(dāng)前窗口作為當(dāng)前窗口,設(shè)置計數(shù)為0。轉(zhuǎn)化后的窗口如下: -
如果如果
diffSize > 1說明記錄的當(dāng)前窗口和預(yù)期的當(dāng)前窗口不相鄰,相差1個或多個窗口(如下圖),說明此時預(yù)期的當(dāng)前窗口的前一個窗口內(nèi)沒有接收到請求,因而沒有對窗口進行調(diào)整。此時將前一個窗口的計數(shù)設(shè)置為0。并將預(yù)期的當(dāng)前窗口作為當(dāng)前窗口,設(shè)置計數(shù)為0。
此時AllowN中的運算如下:
- 計算出當(dāng)前時間距離當(dāng)前窗口開始邊界的差值(
elapsed) - 計算出滑動窗口在前一個窗口中重疊部分所占的比重(百分比)
- 使用滑動窗口在前一個窗口中重疊部分所占的比重乘以前一個窗口內(nèi)的計數(shù),再加上當(dāng)前窗口的計數(shù),算出滑動窗口的當(dāng)前計數(shù)
- 如果要判斷滑動窗口是否能夠允許n個事件,則使用滑動窗口的當(dāng)前計數(shù)+n與計數(shù)閾值進行比較。如果小于計數(shù)閾值,則允許事件,并讓滑動窗口計數(shù)+n,否則返回false。
-
如果
diffSize<1,說明滑動窗口和當(dāng)前窗口有重疊部分,此時不需要調(diào)整窗口。AllowN中的運算與上述邏輯相同:
總結(jié)
- 上一篇: GPT Zero 是什么?
- 下一篇: 【UniApp】-uni-app-打包成