分布式限流方案的探索與實踐
隨著微服務的流行,服務之間的依賴性和調用關系變得越來越復雜,服務的穩(wěn)定性變得尤為重要。業(yè)務場景中經常會涉及到瞬時流量沖擊,可能會導致請求響應超時,甚至服務器被壓垮、宕機不可用。出于對系統(tǒng)本身和上下游服務的保護,我們通常會對請求進行限流處理,快速拒絕超出配置上限的請求,保證系統(tǒng)或上下游服務系統(tǒng)的穩(wěn)定。合理策略能有效應對流量沖擊,確保系統(tǒng)可用性和性能。本文詳細介紹了幾種限流算法,比較各個算法的優(yōu)缺點,給出了限流算法選型的一些建議,同時對業(yè)務上常用的分布式限流也提出一些解決方案。
一、背景
保護高并發(fā)服務穩(wěn)定主要有三把利器:緩存、降級和限流。
- 緩存:緩存是一種提高數(shù)據(jù)讀取性能的技術,通過在內存中存儲經常訪問的數(shù)據(jù),可以減少對數(shù)據(jù)庫或者其他存儲系統(tǒng)的訪問,從而提高系統(tǒng)的響應速度。緩存可以應用在多個層次,例如瀏覽器緩存、CDN 緩存、反向代理緩存、應用緩存等。
- 降級:降級是在系統(tǒng)壓力過大或者部分服務不可用的情況下,暫時關閉一些非核心的服務,以保證核心服務的正常運行。降級可以在多個層次進行,例如頁面降級、功能降級、服務降級等。
- 限流:限流是一種控制系統(tǒng)處理請求的速率的技術,以防止系統(tǒng)過載。限流可以通過多種算法實現(xiàn),例如令牌桶算法、漏桶算法等。
這三把"利器"各有其特點,通常會結合使用,以達到最佳的效果。例如,可以通過緩存來減少數(shù)據(jù)庫的訪問,通過降級來應對系統(tǒng)故障,通過限流來防止系統(tǒng)過載。在設計高并發(fā)系統(tǒng)時,需要根據(jù)系統(tǒng)的具體需求和特點,合理地使用這些技術。接下來本文會主要介紹一些業(yè)界常用的限流方法。
二、限流知識概述
限流是一種對請求或并發(fā)數(shù)進行限制的關鍵技術手段,旨在保障系統(tǒng)的正常運行。當服務資源有限、處理能力有限時,限流可以對調用服務的上游請求進行限制,以防止自身服務因資源耗盡而停止服務。
在限流中,有兩個重要的概念需要了解:
- 閾值:指在單位時間內允許的請求量。例如,將 QPS(每秒請求數(shù))限制為500,表示在1秒內最多接受500次請求。通過設置合適的閾值,可以控制系統(tǒng)的負載,避免過多的請求導致系統(tǒng)崩潰或性能下降。
- 拒絕策略:用于處理超過閾值的請求的策略。常見的拒絕策略包括直接拒絕、排隊等待等。直接拒絕會立即拒絕超過閾值的請求,而排隊等待則將請求放入隊列中,按照一定的規(guī)則進行處理。選擇合適的拒絕策略可以平衡系統(tǒng)的穩(wěn)定性和用戶體驗。
通過合理設置閾值和選擇適當?shù)木芙^策略,限流技術可以幫助系統(tǒng)應對突發(fā)的請求量激增、惡意用戶訪問或請求頻率過高的情況,保障系統(tǒng)的穩(wěn)定性和可用性。限流方案根據(jù)限流范圍,可分為 單機限流和分布式限流 ;其中單機限流依據(jù)算法,又可分為 固定窗口、滑動窗口、漏桶和令牌桶限流等常見四種 。本文將對上述限流方案進行詳細介紹。
三、限流基本算法
1.固定窗口限流
(1) 算法介紹
固定窗口算法是一種簡單直觀的限流算法,其原理是將時間劃分為固定大小的窗口,在每個窗口內限制請求的數(shù)量或速率。具體實現(xiàn)時,可以使用一個計數(shù)器來記錄當前窗口內的請求數(shù),并與預設的閾值進行比較。固定窗口算法的原理如下:
- 將時間劃分固定大小窗口,例如每秒一個窗口。
- 在每個窗口內,記錄請求的數(shù)量。
- 當有請求到達時,將請求計數(shù)加一。
- 如果請求計數(shù)超過了預設的閾值(比如3個請求),拒絕該請求。
- 窗口結束后,重置請求計數(shù)。
(2) 代碼實現(xiàn)
type FixedWindowLimiter struct {
windowSize time.Duration // 窗口大小
maxRequests int // 最大請求數(shù)
requests int // 當前窗口內的請求數(shù)
lastReset int64 // 上次窗口重置時間(秒級時間戳)
resetMutex sync.Mutex // 重置鎖
}
func NewFixedWindowLimiter(windowSize time.Duration, maxRequests int) *FixedWindowLimiter {
return &FixedWindowLimiter{
windowSize: windowSize,
maxRequests: maxRequests,
lastReset: time.Now().Unix(),
}
}
func (limiter *FixedWindowLimiter) AllowRequest() bool {
limiter.resetMutex.Lock()
defer limiter.resetMutex.Unlock()
// 檢查是否需要重置窗口
if time.Now().Unix()-limiter.lastReset >= int64(limiter.windowSize.Seconds()) {
limiter.requests = 0
limiter.lastReset = time.Now().Unix()
}
// 檢查請求數(shù)是否超過閾值
if limiter.requests >= limiter.maxRequests {
return false
}
limiter.requests++
return true
}
func main() {
limiter := NewFixedWindowLimiter(1*time.Second, 3) // 每秒最多允許3個請求
for i := 0; i < 15; i++ {
now := time.Now().Format("15:04:05")
if limiter.AllowRequest() {
fmt.Println(now + " 請求通過")
} else {
fmt.Println(now + " 請求被限流")
}
time.Sleep(100 * time.Millisecond)
}
}
執(zhí)行結果:
(3) 優(yōu)缺點
優(yōu)點:
- 實現(xiàn)簡單:固定窗口算法的實現(xiàn)相對簡單,易于理解和部署。
- 穩(wěn)定性較高:對于突發(fā)請求能夠較好地限制和控制,穩(wěn)定性較高。
- 易于實現(xiàn)速率控制:固定窗口算法可以很容易地限制請求的速率,例如每秒最多允許多少個請求。
缺點:
- 請求分布不均勻:固定窗口算法中,窗口內的請求分布可能不均勻,導致某些窗口內的請求數(shù)量超過閾值,而其他窗口內的請求較少。
- 無法應對突發(fā)流量:固定窗口算法的窗口大小是固定的,無法靈活地應對突發(fā)流量。
- 請求的不公平性:窗口結束時的請求計數(shù)重置可能導致請求的不公平性。例如,在窗口結束前的最后一秒內,請求計數(shù)已滿,而在窗口開始時的第一秒內,請求計數(shù)為零。
固定窗口算法適用于對請求速率有明確要求且流量相對穩(wěn)定的場景,但對于突發(fā)流量和請求分布不均勻的情況,可能需要考慮其他更靈活的限流算法。
2. 滑動窗口限流
(1) 算法介紹
上文已經說明當遇到時間窗口的臨界突變時,固定窗口算法可能無法靈活地應對流量的變化。例如,在一個時間窗口結束時,如果突然出現(xiàn)大量請求,固定窗口算法可能會導致請求被拒絕,即使在下一個時間窗口內的請求并不多。這種情況下,我們需要一種更靈活的算法來應對突發(fā)流量和請求分布不均勻的情況—滑動窗口算法。該算法是對固定窗口算法的一種改進,它通過動態(tài)調整窗口的大小來更好地適應流量的變化。與固定窗口算法不同,滑動窗口算法可以在遇到下一個時間窗口之前調整窗口的大小,以便更好地控制請求的速率。算法的原理如下:
- 窗口大小:確定一個固定的窗口大小,例如1秒。
- 請求計數(shù):在窗口內,每次有請求到達時,將請求計數(shù)加1。
- 限制條件:如果窗口內的請求計數(shù)超過了設定的閾值,即超過了允許的最大請求數(shù),就拒絕該請求。
- 窗口滑動:隨著時間的推移,窗口會不斷滑動,移除過期的請求計數(shù),以保持窗口內的請求數(shù)在限制范圍內。
動態(tài)調整:在滑動窗口算法中,我們可以根據(jù)實際情況調整窗口的大小。當遇到下一個時間窗口之前,我們可以根據(jù)當前的流量情況來調整窗口的大小,以適應流量的變化。
(2) 代碼實現(xiàn)
package main
import (
"fmt"
"sync"
"time"
)
type SlidingWindowLimiter struct {
windowSize time.Duration // 窗口大小
maxRequests int // 最大請求數(shù)
requests []time.Time // 窗口內的請求時間
requestsLock sync.Mutex // 請求鎖
}
func NewSlidingWindowLimiter(windowSize time.Duration, maxRequests int) *SlidingWindowLimiter {
return &SlidingWindowLimiter{
windowSize: windowSize,
maxRequests: maxRequests,
requests: make([]time.Time, 0),
}
}
func (limiter *SlidingWindowLimiter) AllowRequest() bool {
limiter.requestsLock.Lock()
defer limiter.requestsLock.Unlock()
// 移除過期的請求
currentTime := time.Now()
for len(limiter.requests) > 0 && currentTime.Sub(limiter.requests[0]) > limiter.windowSize {
limiter.requests = limiter.requests[1:]
}
// 檢查請求數(shù)是否超過閾值
if len(limiter.requests) >= limiter.maxRequests {
return false
}
limiter.requests = append(limiter.requests, currentTime)
return true
}
func main() {
limiter := NewSlidingWindowLimiter(500*time.Millisecond, 2) // 每秒最多允許4個請求
for i := 0; i < 15; i++ {
now := time.Now().Format("15:04:05")
if limiter.AllowRequest() {
fmt.Println(now + " 請求通過")
} else {
fmt.Println(now + " 請求被限流")
}
time.Sleep(100 * time.Millisecond)
}
}
執(zhí)行結果:
(3) 優(yōu)缺點
優(yōu)點:
- 靈活性:滑動窗口算法可以根據(jù)實際情況動態(tài)調整窗口的大小,以適應流量的變化。這種靈活性使得算法能夠更好地應對突發(fā)流量和請求分布不均勻的情況。
- 實時性:由于滑動窗口算法在每個時間窗口結束時都會進行窗口滑動,它能夠更及時地響應流量的變化,提供更實時的限流效果。
- 精度:相比于固定窗口算法,滑動窗口算法的顆粒度更小,可以提供更精確的限流控制。
缺點:
- 內存消耗:滑動窗口算法需要維護一個窗口內的請求時間列表,隨著時間的推移,列表的長度會增長。這可能會導致較大的內存消耗,特別是在窗口大小較大或請求頻率較高的情況下。
- 算法復雜性:相比于簡單的固定窗口算法,滑動窗口算法的實現(xiàn)較為復雜。它需要處理窗口滑動、請求計數(shù)和過期請求的移除等邏輯,可能需要更多的代碼和計算開銷。
從代碼的角度可以發(fā)現(xiàn),滑動窗口算法實際上是顆粒度更小的固定窗口算法,它可以在一定程度上提高限流的精度和實時性,并不能從根本上解決請求分布不均勻的問題。算法受限于窗口的大小和時間間隔,特別是在極端情況下,如突發(fā)流量過大或請求分布極不均勻的情況下,仍然可能導致限流不準確。因此,在實際應用中,要采用更復雜的算法或策略來進一步優(yōu)化限流效果。
3. 漏桶限流
(1) 算法介紹
盡管滑動窗口算法可以提供一定的限流效果,但它仍然受限于窗口的大小和時間間隔。在某些情況下,突發(fā)流量可能會導致窗口內的請求數(shù)超過限制。為了更好地平滑請求的流量,漏桶限流算法可以作為滑動窗口算法的改進。算法的原理很簡單:它維護一個固定容量的漏桶,請求以不定的速率流入漏桶,而漏桶以固定的速率流出。如果請求到達時,漏桶已滿,則會觸發(fā)拒絕策略??梢詮纳a者-消費者模式去理解這個算法,請求充當生產者,每個請求就像一滴水,當請求到達時,它被放入一個隊列(漏桶)中。而漏桶則充當消費者,以固定的速率從隊列中消費請求,就像從桶底的孔洞中不斷漏出水滴。消費的速率等于限流閾值,例如每秒處理2個請求,即500毫秒消費一個請求。漏桶的容量就像隊列的容量,當請求堆積超過指定容量時,會觸發(fā)拒絕策略,即新到達的請求將被丟棄或延遲處理。算法的實現(xiàn)如下:
- 漏桶容量:確定一個固定的漏桶容量,表示漏桶可以存儲的最大請求數(shù)。
- 漏桶速率:確定一個固定的漏桶速率,表示漏桶每秒可以處理的請求數(shù)。
- 請求處理:當請求到達時,生產者將請求放入漏桶中。
- 漏桶流出:漏桶以固定的速率從漏桶中消費請求,并處理這些請求。如果漏桶中有請求,則處理一個請求;如果漏桶為空,則不處理請求。
- 請求丟棄或延遲:如果漏桶已滿,即漏桶中的請求數(shù)達到了容量上限,新到達的請求將被丟棄或延遲處理。
(2) 代碼實現(xiàn)
package main
import (
"fmt"
"time"
)
type LeakyBucket struct {
rate float64 // 漏桶速率,單位請求數(shù)/秒
capacity int // 漏桶容量,最多可存儲請求數(shù)
water int // 當前水量,表示當前漏桶中的請求數(shù)
lastLeakMs int64 // 上次漏水的時間戳,單位秒
}
func NewLeakyBucket(rate float64, capacity int) *LeakyBucket {
return &LeakyBucket{
rate: rate,
capacity: capacity,
water: 0,
lastLeakMs: time.Now().Unix(),
}
}
func (lb *LeakyBucket) Allow() bool {
now := time.Now().Unix()
elapsed := now - lb.lastLeakMs
// 漏水,根據(jù)時間間隔計算漏掉的水量
leakAmount := int(float64(elapsed) / 1000 * lb.rate)
if leakAmount > 0 {
if leakAmount > lb.water {
lb.water = 0
} else {
lb.water -= leakAmount
}
}
// 判斷當前水量是否超過容量
if lb.water > lb.capacity {
lb.water-- // 如果超過容量,減去剛剛增加的水量
return false
}
// 增加水量
lb.water++
lb.lastLeakMs = now
return true
}
func main() {
// 創(chuàng)建一個漏桶,速率為每秒處理3個請求,容量為4個請求
leakyBucket := NewLeakyBucket(3, 4)
// 模擬請求
for i := 1; i <= 15; i++ {
now := time.Now().Format("15:04:05")
if leakyBucket.Allow() {
fmt.Printf(now+" 第 %d 個請求通過\n", i)
} else {
fmt.Printf(now+" 第 %d 個請求被限流\n", i)
}
time.Sleep(200 * time.Millisecond) // 模擬請求間隔
}
}
執(zhí)行結果:
(3) 優(yōu)缺點
優(yōu)點:
- 平滑流量:漏桶算法可以平滑突發(fā)流量,使得輸出流量變得更加平穩(wěn),避免了流量的突然增大對系統(tǒng)的沖擊。
- 簡單易實現(xiàn):漏桶算法的原理簡單,實現(xiàn)起來也相對容易。
- 有效防止過載:通過控制流出的流量,漏桶算法可以有效地防止系統(tǒng)過載。
缺點:
- 對突發(fā)流量的處理不夠靈活:雖然漏桶算法可以平滑突發(fā)流量,但是在某些情況下,我們可能希望能夠快速處理突發(fā)流量。在這種情況下,漏桶算法可能就不夠靈活了。
- 無法動態(tài)調整流量:漏桶算法的流出速率是固定的,無法根據(jù)系統(tǒng)的實際情況動態(tài)調整。
- 可能會導致流量浪費:如果輸入流量小于漏桶的流出速率,那么漏桶的流出速率就會被浪費。
- 如果輸入流量持續(xù)大于漏桶的流出速率,那么漏桶會一直滿,新的請求會被丟棄,可能會導致服務質量下降。
4. 令牌桶限流
(1) 算法介紹
令牌桶算法是實現(xiàn)限流是一種常見的思路,用于限制請求的速率。它可以確保系統(tǒng)在高負載情況下仍能提供穩(wěn)定的服務,并防止突發(fā)流量對系統(tǒng)造成過載。最為常用的 Google 的 Java 開發(fā)工具包 Guava 中的限流工具類 RateLimiter 就是令牌桶的一個實現(xiàn)。令牌桶算法基于一個令牌桶的概念,其中令牌以固定的速率產生,并放入桶中。每個令牌代表一個請求的許可。當請求到達時,需要從令牌桶中獲取一個令牌才能通過。如果令牌桶中沒有足夠的令牌,則請求被限制或丟棄。 令牌桶算法的實現(xiàn)步驟如下:
- 初始化一個令牌桶,包括桶的容量和令牌產生的速率。
- 持續(xù)以固定速率產生令牌,并放入令牌桶中,直到桶滿為止。
- 當請求到達時,嘗試從令牌桶中獲取一個令牌。
- 如果令牌桶中有足夠的令牌,則請求通過,并從令牌桶中移除一個令牌。
- 如果令牌桶中沒有足夠的令牌,則請求被限制或丟棄。
(2) 代碼實現(xiàn)
package main
import (
"fmt"
"sync"
"time"
)
// TokenBucket 表示一個令牌桶。
type TokenBucket struct {
rate float64 // 令牌添加到桶中的速率。
capacity float64 // 桶的最大容量。
tokens float64 // 當前桶中的令牌數(shù)量。
lastUpdate time.Time // 上次更新令牌數(shù)量的時間。
mu sync.Mutex // 互斥鎖,確保線程安全。
}
// NewTokenBucket 創(chuàng)建一個新的令牌桶,給定令牌添加速率和桶的容量。
func NewTokenBucket(rate float64, capacity float64) *TokenBucket {
return &TokenBucket{
rate: rate,
capacity: capacity,
tokens: capacity, // 初始時,桶是滿的。
lastUpdate: time.Now(),
}
}
// Allow 檢查是否可以從桶中移除一個令牌。如果可以,它移除一個令牌并返回 true。
// 如果不可以,它返回 false。
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
// 根據(jù)經過的時間計算要添加的令牌數(shù)量。
now := time.Now()
elapsed := now.Sub(tb.lastUpdate).Seconds()
tokensToAdd := elapsed * tb.rate
tb.tokens += tokensToAdd
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity // 確保令牌數(shù)量不超過桶的容量。
}
if tb.tokens >= 1.0 {
tb.tokens--
tb.lastUpdate = now
return true
}
return false
}
func main() {
tokenBucket := NewTokenBucket(2.0, 3.0)
for i := 1; i <= 10; i++ {
now := time.Now().Format("15:04:05")
if tokenBucket.Allow() {
fmt.Printf(now+" 第 %d 個請求通過\n", i)
} else { // 如果不能移除一個令牌,請求被拒絕。
fmt.Printf(now+" 第 %d 個請求被限流\n", i)
}
time.Sleep(200 * time.Millisecond)
}
}
執(zhí)行結果:
(3) 優(yōu)缺點
優(yōu)點:
- 平滑流量:令牌桶算法可以平滑突發(fā)流量,使得突發(fā)流量在一段時間內均勻地分布,避免了流量的突然高峰對系統(tǒng)的沖擊。
- 靈活性:令牌桶算法可以通過調整令牌生成速率和桶的大小來靈活地控制流量。
- 允許突發(fā)流量:由于令牌桶可以積累一定數(shù)量的令牌,因此在流量突然增大時,如果桶中有足夠的令牌,可以應對這種突發(fā)流量。
缺點:
- 實現(xiàn)復雜:相比于其他一些限流算法(如漏桶算法),令牌桶算法的實現(xiàn)稍微復雜一些,需要維護令牌的生成和消耗。
- 需要精確的時間控制:令牌桶算法需要根據(jù)時間來生成令牌,因此需要有精確的時間控制。如果系統(tǒng)的時間控制不精確,可能會影響限流的效果。
- 可能會有資源浪費:如果系統(tǒng)的流量持續(xù)低于令牌生成的速率,那么桶中的令牌可能會一直積累,造成資源的浪費。
5. 四種基本算法的對比
四、分布式限流
單機限流指針對單一服務器的情況,通過限制單臺服務器在單位時間內處理的請求數(shù)量,防止服務器過載。常見的限流算法上文已介紹,其優(yōu)點在于實現(xiàn)簡單,效率高,效果明顯。隨著微服務架構的普及,系統(tǒng)的服務通常會部署在多臺服務器上,此時就需要分布式限流來保證整個系統(tǒng)的穩(wěn)定性。接下本文會介紹幾種常見的分布式限流技術方案:
1. 基于中心化的限流方案
(1) 方案原理
通過一個中心化的限流器來控制所有服務器的請求。實現(xiàn)方式:
- 選擇一個中心化的組件,例如—Redis。
- 定義限流規(guī)則,例如:可以設置每秒鐘允許的最大請求數(shù)(QPS),并將這個值存儲在Redis中。
- 對于每個請求,服務器需要先向Redis請求令牌。
- 如果獲取到令牌,說明請求可以被處理;如果沒有獲取到令牌,說明請求被限流,可以返回一個錯誤信息或者稍后重試。
(2) 代碼實現(xiàn)
package main
import (
"context"
"fmt"
"go.uber.org/atomic"
"sync"
"git.code.oa.com/pcg-csd/trpc-ext/redis"
)
type RedisClient interface {
Do(ctx context.Context, cmd string, args ...interface{}) (interface{}, error)
}
// Client 數(shù)據(jù)庫
type Client struct {
client RedisClient // redis 操作
script string // lua腳本
}
// NewBucketClient 創(chuàng)建redis令牌桶
func NewBucketClient(redis RedisClient) *Client {
helper := redis
return &Client{
client: helper,
script: `
-- 令牌桶限流腳本
-- KEYS[1]: 桶的名稱
-- ARGV[1]: 桶的容量
-- ARGV[2]: 令牌產生速率
local bucket = KEYS[1]
local capacity = tonumber(ARGV[1])
local tokenRate = tonumber(ARGV[2])
local redisTime = redis.call('TIME')
local now = tonumber(redisTime[1])
local tokens, lastRefill = unpack(redis.call('hmget', bucket, 'tokens', 'lastRefill'))
tokens = tonumber(tokens)
lastRefill = tonumber(lastRefill)
if not tokens or not lastRefill then
tokens = capacity
lastRefill = now
else
local intervalsSinceLast = (now - lastRefill) * tokenRate
tokens = math.min(capacity, tokens + intervalsSinceLast)
end
if tokens < 1 then
return 0
else
redis.call('hmset', bucket, 'tokens', tokens - 1, 'lastRefill', now)
return 1
end
`,
}
}
// 獲取令牌,獲取成功則立即返回true,否則返回false
func (c *Client) isAllowed(ctx context.Context, key string, capacity int64, tokenRate int64) (bool, error) {
result, err := redis.Int(c.client.Do(ctx, "eval", c.script, 1, key, capacity, tokenRate))
if err != nil {
fmt.Println("Redis 執(zhí)行錯誤:", err)
return false, err
}
return result == 1, nil
}
// 調用檢測
func main() {
c := NewBucketClient(redis.GetPoolByName("redis://127.0.0.1:6379"))
gw := sync.WaitGroup{}
gw.Add(120)
count := atomic.Int64{}
for i := 0; i < 120; i++ {
go func(i int) {
defer gw.Done()
status, err := c.isAllowed(context.Background(), "test", 100, 10)
if status {
count.Add(1)
}
fmt.Printf("go %d status:%v error: %v\n", i, status, err)
}(i)
}
gw.Wait()
fmt.Printf("allow %d\n\n", count.Load())
}
執(zhí)行結果:
(3) 存在的問題
- 性能瓶頸:由于所有的請求都需要經過Redis,因此Redis可能成為整個系統(tǒng)的性能瓶頸。為了解決這個問題,可以考慮使用Redis集群來提高性能,或者使用更高性能的硬件。
- 單點故障:如果Redis出現(xiàn)故障,整個系統(tǒng)的限流功能將受到影響。為了解決這個問題,可以考慮使用Redis的主從復制或者哨兵模式來實現(xiàn)高可用。
- 網絡帶寬:Redis是一個基于網絡通信的內存數(shù)據(jù)庫,因此網絡帶寬是其性能的一個關鍵因素。如果網絡帶寬有限,可能會導致請求的傳輸速度變慢,從而影響Redis的性能。
redis官方性能測試圖
2. 基于負載均衡的分布式限流方案
(1) 方案原理
可以看到中心化限流方案的存在較高的單點故障風險,且?guī)捚款i比較嚴重。在這個基礎上本文結合本地緩存單機限流和負載均衡設計了一個新的分布式限流方案。具體方案如下:
- 使用負載均衡器或分布式服務發(fā)現(xiàn)(北極星即可做到),將請求均勻地分發(fā)到每個機器上。這確保了每個機器都能處理一部分請求。
- 在每個機器上維護本機的限流狀態(tài),實現(xiàn)本地緩存單機限流的邏輯。使用令牌桶限流算法,在每個機器上獨立地進行限流控制。每秒鐘處理的請求數(shù)、令牌桶的令牌數(shù)量等。根據(jù)本地限流狀態(tài),對到達的請求進行限流判斷。
- 準備相應的動態(tài)調整方案,可以根據(jù)每個機器的實際負載情況,動態(tài)地調整限流參數(shù)。例如,如果一個機器的CPU或內存使用率過高,你可以降低這個機器的限流閾值,減少這個機器的請求處理量。反之,如果一個機器的資源使用率較低,提高這個機器的限流閾值,增加這個機器的請求處理量。
(2) 存在的問題
- 本地緩存:這個方案對本地緩存的要求較高,需要自己根據(jù)業(yè)務邏輯抉擇本地緩存的淘汰策略和緩存容量限制等風險點。
- 限流精度:本地緩存單機限流的精度受限于每個服務實例的資源和配置。這可能導致限流策略無法精確地適應整個系統(tǒng)的流量變化,無法靈活地調整限流規(guī)則。
- 請求負載均衡器的單點故障。
- 動態(tài)擴縮容的適應性:當系統(tǒng)需要動態(tài)擴展或縮容時,該方案可能需要額外的配置和和調整,以確保新加入或移除的服務實例能夠正確地參與限流和請求均衡。
3. 基于分布式協(xié)調服務的限流
(1) 方案原理
使用ZooKeeper或者etcd等分布式協(xié)調服務來實現(xiàn)限流。每臺服務器都會向分布式協(xié)調服務申請令牌,只有獲取到令牌的請求才能被處理。基本方案:
- 初始化令牌桶:在ZooKeeper中創(chuàng)建一個節(jié)點,節(jié)點的數(shù)據(jù)代表令牌的數(shù)量。初始時,將數(shù)據(jù)設置為令牌桶的容量。
- 申請令牌:當一個請求到達時,服務器首先向ZooKeeper申請一個令牌。這可以通過獲取節(jié)點的分布式鎖,然后將節(jié)點的數(shù)據(jù)減1實現(xiàn)。如果操作成功,說明申請到了令牌,請求可以被處理;如果操作失敗,說明令牌已經用完,請求需要被拒絕或者等待。
- 釋放令牌:當一個請求處理完畢時,服務器需要向ZooKeeper釋放一個令牌。這可以通過獲取節(jié)點的分布式鎖,然后將節(jié)點的數(shù)據(jù)加1實現(xiàn)。
- 補充令牌:可以設置一個定時任務,定期向ZooKeeper中的令牌桶補充令牌。補充的頻率和數(shù)量可以根據(jù)系統(tǒng)的負載情況動態(tài)調整。
(2) 存在的問題
這個方案的優(yōu)點是可以實現(xiàn)精確的全局限流,并且可以避免單點故障。但是,這個方案的缺點是實現(xiàn)復雜,且對ZooKeeper的性能有較高的要求。如果ZooKeeper無法處理大量的令牌申請和釋放操作,可能會成為系統(tǒng)的瓶頸。
五、總結
總之,沒有最好的方案,只有合適的方案。在選擇合適的限流方案時,我們需要考慮多種因素,包括系統(tǒng)的需求、現(xiàn)有的技術棧、系統(tǒng)的負載情況以及底層系統(tǒng)的性能等。理解每種方案的工作原理和特性,以便在實際應用中做出最佳的選擇。
一個好的限流設計必須要考慮到業(yè)務的特性和需求,同時具備以下六點:
- 多級限流:除了主備復制的限流服務,可以考慮實現(xiàn)多級限流策略。例如,可以在應用層、服務層和數(shù)據(jù)層都設置限流,這樣可以更好地防止系統(tǒng)過載。
- 動態(tài)閾值調整:我們可以根據(jù)系統(tǒng)的實時負載情況動態(tài)調整限流策略。例如,當系統(tǒng)負載較低時,我們可以放寬限流策略;當系統(tǒng)負載較高時,我們可以收緊限流策略。
- 靈活維度:限流策略應該能夠根據(jù)不同的業(yè)務場景進行調整。除了接口,設備,ip,賬戶id等維度外,我們還可以考慮更細粒度的限流。例如,我們可以根據(jù)用戶的行為模式進行限流,這樣可以更好地防止惡意用戶的攻擊。
- 解耦性:限流應該作為一個基礎服務,與具體的業(yè)務邏輯分離。這樣,當業(yè)務邏輯發(fā)生變化時,不需要修改限流服務的代碼,只需要調整限流策略即可。
- 容錯性:限流服務應該具有高可用性,但是如果出現(xiàn)問題,業(yè)務應該有備選方案(熔斷、降級)。這可能包括使用備用的限流服務,或者根據(jù)業(yè)務的敏感性決定是否放行請求。
- 監(jiān)控和報警:對限流策略應進行實時監(jiān)控,并設置報警機制。當限流策略觸發(fā)時,可立即收到報警,以便我們可以及時地處理問題。
限流是保證系統(tǒng)穩(wěn)定和高效運行的重要手段,但它并不是唯一的解決方案。我們還需要考慮其他的系統(tǒng)設計和優(yōu)化手段,例如負載均衡、緩存、異步處理等(面對爆量,擴容永遠是最好的方式,除了貴?。?。這些手段相互配合,才能構建出一個既能應對高并發(fā)請求,又能保證服務質量的系統(tǒng)。