限速器演算法

charlieroro發表於2023-12-27

限速器

限速器型別
  • Leaky Bucket:漏桶演算法(和令牌桶(token bucket)非常相似)是一種非常簡單,使用佇列來進行限流的演算法。當接收到一個請求時,會將其追加到佇列的末尾,系統會按照先進先出的順序處理請求,一旦佇列滿,則會丟棄額外的請求。佇列中的請求數目受限於佇列的大小。

    image

    這種方式可以緩解突發流量對系統的影響,缺點是在流量突發時,由於佇列中快取了舊的請求,導致無法處理新的請求。而且也無法保證請求能夠在一定時間內處理完畢。

    令牌桶不會快取請求,它透過頒發令牌的方式來允許請求,因此它存在和漏桶演算法一樣的問題。

  • Fixed Window:該系統使用n秒的視窗大小(通常使用人類友好的值,例如60或3600秒)來跟蹤固定視窗下的請求速率。每接收到一個請求都會增加計算器,當計數器超過閾值後,則會丟棄請求。通常當前時間戳的下限來定義定義視窗,如12:00:03(視窗長度為60秒)將位於12:00:00的視窗中。

    image

    該演算法可以保證最新的請求不受舊請求的影響。但如果在視窗邊界出現突發流量,由於短時間內產生的流量可能會同時被計入當前和下一個視窗,因此可能會導致請求速率翻倍。如果有多個消費者等待視窗重置,則在視窗重置後的一開始會出現踩踏效應。跟漏桶演算法一樣,固定視窗演算法是針對所有消費者而非單個消費者進行限制的。

  • Sliding Log:滑動日誌會跟蹤每個消費者的請求對應的時間戳日誌。系統會將這些日誌儲存在按時間排序的雜湊集或表中,並丟棄時間戳超過閾值的日誌。當接收到一個請求後,會透過計算日誌的總數來決定請求速率。如果請求超過速率閾值,則暫停處理該請求。
    image

    這種演算法的優點在於它不存在固定視窗中的邊界限制,因此在限速上更加精確。由於系統會跟蹤每個消費者的滑動日誌,因此也不存在固定視窗演算法中的踩踏效應。

    但儲存無限量的請求會帶來儲存成本,且該演算法在接收到請求時都需要計算消費者先前的請求總和(有可能需要跨伺服器叢集進行運算),因此計算成本也很高。基於上述原因,該演算法在處理突發流量或DDos攻擊等問題上存在擴充套件性問題。

  • Sliding Window:滑動視窗演算法結合了固定視窗演算法中的低成本處理以及滑動日誌中對邊界條件的改進。像固定視窗演算法一樣,該演算法會為每個固定視窗設定一個計數器,並根據當前時間戳來考慮前一視窗中的請求速率的加權值,用來平滑突發流量。

    例如,假設有一個每分鐘允許100個事件的限速器,此時當前時間到了75s點,那麼內部視窗如下:

    image

此時限速器在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(即獲取當前的限速器計數,然後增加計數,最後將計數儲存到資料庫)模式時可能會產生競爭,導致資料庫計數不一致。

image

解決該問題的一種方式是使用鎖,但鎖會帶來嚴重的效能問題。更好的方式是使用set-then-get模式,並依賴原子操作來提升效能。

效能最佳化

即使是Redis這種快速儲存也會給每個請求帶來毫秒級的延遲。可以採用本地記憶體檢查的方式來最小化延遲。

為了使用本地檢查,需要放寬速率檢查條件,並使用最終一致性模型。例如,每個節點都可以建立一個資料同步週期,用來與中央資料儲存同步。每個節點週期性地將每個消費者和視窗的計數器增量推送到資料庫,並原子方式更新資料庫值。然後,節點可以檢索更新後的值並更新其記憶體版本。在集中→發散→再集中的週期中達到最終一致。

同步週期應該是可配置的,當在叢集中的多個節點間分發流量時,較短的同步間隔會降低資料點的差異。而較長的同步間隔會減少資料儲存的讀/寫壓力,並減少每個節點獲取新同步值所帶來的開銷。

Golang中的滑動視窗

Golang的滑動視窗實現比較好的實現有mennanov/limitersRussellLuo/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:用於指定視窗型別。本實現中分為LocalWindowSyncWindow兩種。前者用於設定單個節點的限速,後者用於和中央儲存聯動,可以實現全侷限速。

下面看下核心函式AllowNadvance的實現:

實現中涉及到了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函式用於調整視窗大小,有如下幾種情況:

需要注意的是,newCurrStartlim.curr.Start()相差0或多個lim.size,如果相差0,則newCurrStart等於lim.curr.Start() ,此時滑動視窗和當前視窗有重疊部分。

  • 如果diffSize == 1說明記錄的當前視窗和預期的當前視窗是相鄰的(如下圖)。

    image

    因此需要將記錄的當前視窗作為前一個視窗(lim.prev),並將預期的當前視窗作為當前視窗,設定計數為0。轉化後的視窗如下:

    image
  • 如果如果diffSize > 1說明記錄的當前視窗和預期的當前視窗不相鄰,相差1個或多個視窗(如下圖),說明此時預期的當前視窗的前一個視窗內沒有接收到請求,因而沒有對視窗進行調整。

    image

    此時將前一個視窗的計數設定為0。並將預期的當前視窗作為當前視窗,設定計數為0。

image

此時AllowN中的運算如下:

  1. 計算出當前時間距離當前視窗開始邊界的差值(elapsed)
  2. 計算出滑動視窗在前一個視窗中重疊部分所佔的比重(百分比)
  3. 使用滑動視窗在前一個視窗中重疊部分所佔的比重乘以前一個視窗內的計數,再加上當前視窗的計數,算出滑動視窗的當前計數
  4. 如果要判斷滑動視窗是否能夠允許n個事件,則使用滑動視窗的當前計數+n與計數閾值進行比較。如果小於計數閾值,則允許事件,並讓滑動視窗計數+n,否則返回false。
  • 如果diffSize<1,說明滑動視窗和當前視窗有重疊部分,此時不需要調整視窗。AllowN中的運算與上述邏輯相同:

    image

相關文章