服務治理:常用限流演算法總結
一、為什麼會有限流
限流,看字面意思,限制流動。
為什麼要限制流動?
比如高速公路出現了事故,交警會對高速路車輛的進入進行指揮和限制。
發生了一些意外情況,才可能要限制流動。等恢復正常情況後,就解除限制。不可能無緣無故的限制流動,畢竟限制會影響正常系統執行。
在舉一個例子:
足球館看足球比賽,足球館的場地大小是固定的,座位數是固定的,能容納看球人數總量是有限的。如果超過足球館容量最大承載,會導致場內擁擠,這樣會發生2個問題:一個是導致大家行動不便,一個可能會發生意想不到的事故。
那怎麼辦?球票。
一個足球場出售的球票是有限制的,一共賣多少張票是有一定數量額度。
系統容量有限,如果超過了系統的負荷,那麼就需要做一些限制措施,避免系統執行時出現異常情況。
那在計算機系統中,為什麼要限流?
同理,訪問計算機系統時或者是計算機系統本身出現了一些異常情況。比如流量過大,系統處理不過來。比如系統升級等等情況。
計算機系統容量是有限的,記憶體大小,CPU 處理資料的速度,都是有限的,不可能無限大。如果超過了一定的闕值,系統就會出現異常,甚至當機。
現在微服務架構比較流行,各種服務比較多,服務之間呼叫頻繁。
如果訪問一個 API 服務時,超過了這個服務能提供的最大訪問能力,服務會崩潰,那就要對這個服務進行保護,避免服務因訪問過大導致服務不可用,不僅影響自己服務,也可能影響其它相關服務。
採用什麼方法保護服務呢?限流就是保護方法之一。
在 IT 高併發系統中,處於對系統的保護,需要對系統進行限流。
二、IT 系統中的限流
上面已經介紹了 IT 系統中的一些限流問題。
下面來看看對使用限流的一些具體情況描述。
在網際網路世界裡,一根一根的網線把整個世界連線起來,那麼網路裡面傳輸的資料
流動起來就形成了網路流。TCP 裡就有限制流量的演算法-滑動視窗演算法。
在微服務系統裡的 API 介面中,對介面做限制,保護介面安全,保證系統穩定。
對介面訪問請求,怎麼描述介面請求情況?
一般用每秒請求數(request per second),併發請求數等,來描述對介面的請求情況。所以限制也是對每秒請求數進行限制。
還有平常使用的連線池技術,也可以理解為限流思想的一種,把連線數限制在一個數量上。把固定數量的連線放入“池子”中,很形象的說法。當然也是複用減少損耗。
三、常用限流演算法
常用的限流演算法,一般有 4 種:
- 計數器
- 滑動視窗
- 漏桶
- 令牌桶
計數器演算法
計數器演算法:
在固定視窗內對請求進行計數,然後與設定的最大請求數進行比較,如果超過了最大值,就進行限流。到達了一個固定時間視窗終點,將計數器清零,重新開始計數。
計數器演算法又叫 固定視窗演算法-Fixed Window。
舉個例子,比如在微服務中有一個介面,限制呼叫次數: 1 分鐘內最大呼叫次數為 30。
根據描述,這個演算法為:
設定最大請求數 MaxRequest = 30,視窗時間 WindowTime = 60 秒,
還有一個計時開始時間 BeginTime , 請求計數 Counter。
用 Go 寫一個 demo,不過為了測試方便,把 MaxRequest 設定為 10,WindowTime 設定為 3 秒,程式如下:
package main
import (
"fmt"
"sync"
"time"
)
const (
MAXREQUEST = 10 // 限制最大請求數
WINDOWTIME = 3 * time.Second // 最大視窗時間
)
type limit struct {
beginTime time.Time
counter int
mu sync.Mutex
}
func (limit *limit) apiLimit() bool {
limit.mu.Lock()
defer limit.mu.Unlock()
nowTime := time.Now()
if nowTime.Sub(limit.beginTime) >= WINDOWTIME {
limit.beginTime = nowTime
limit.counter = 0
}
if limit.counter > MAXREQUEST {
return false
}
limit.counter++
fmt.Println("counter: ", limit.counter)
return true
}
func main() {
var wg sync.WaitGroup
var limit limit
for i := 0; i < 15; i++ {
wg.Add(1)
fmt.Println("req start:", i, time.Now())
go func(i int) {
if limit.apiLimit() {
fmt.Println("req counter: ", i, time.Now())
}
wg.Done()
}(i)
time.Sleep(150 * time.Millisecond)
}
wg.Wait()
}
-
演算法優點:實現簡單
-
演算法缺點:
1.計數器演算法有一個”臨界時間點“問題。
比如限制 1 分鐘內最大請求為 30 個。在 21:30:59 秒到達 30 個,然後 21:31:01 秒(臨界時間)又瞬間到達 30 個,雖然兩個時間窗內請求都符合限流要求,但在兩個視窗臨界時間 2 秒內集中了 60 次請求,超過了規定值 30。全域性從速率來看 30/60s=0.5,而現在 60/2s =30,遠超 0.5,這對系統來說可能就無法承受了。在這個 2 秒內不做限流,就可能會把我們的應用搞崩潰。
它無法應對兩個時間視窗臨界時間內的突發流量。
2.如果請求速度太快,會丟掉一些請求。
怎麼解決“臨界時間點”問題,看下面滑動視窗演算法。
滑動視窗演算法
滑動視窗演算法(Sliding Window)部分解決了計數器演算法(固定時間視窗演算法)“時間臨界點” 的問題。
有的人還會把滑動視窗演算法細分:滑動視窗日誌(sliding window log) 和 滑動視窗計數(sliding window counter)。
滑動視窗計數
滑動視窗演算法:
在計數器演算法中,把大時間視窗在進一步劃分為更細小的時間視窗格子,隨著時間向前移動,大時間窗每次向前移動一個小格子,而不是大時間窗向前移動。每個小格子都有自己獨立計數器,小格子會記錄每個請求到達的時間點。
最終統計比較:
- 比較小格子內請求數:(大時間視窗內規定最大請求數 / N個小格子) > 小格子時間窗內總請求數
舉個例子:
把 1 分鐘時間窗在劃分為 6 個小格子時間窗,每個小格子 10 秒。每過 10 秒鐘,時間視窗向右滑動一小格。每一個小格
都有自己獨立的計數器 counter。下面圖1到圖2:
Go 例子 slidingwindow.go:
這個例子把最大請求數設定為300,最大時間窗時間設定為 30秒,小格子時間窗設定為 1秒,便於程式演示。
package main
import (
"fmt"
"sync"
"time"
)
const (
MAXREQUEST = 300 // 限制最大請求數
WINDOWTIME = 30 * time.Second // 最大視窗時間
)
type SlidingWindow struct {
smallWindowTime int64 // 小視窗時間大小
smallWindowNum int64 // 小視窗總數
smallWindowCap int // 小視窗請求容量
counters map[int64]int // 小視窗計數器
mu sync.Mutex // 鎖
}
func NewSlidingWindow(smallWindowTime time.Duration) (*SlidingWindow, error) {
num := int64(WINDOWTIME / smallWindowTime) // 小視窗總數
return &SlidingWindow{
smallWindowTime: int64(smallWindowTime),
smallWindowNum: num,
smallWindowCap: MAXREQUEST / int(num),
counters: make(map[int64]int),
}, nil
}
func (sw *SlidingWindow) ReqLimit() bool {
sw.mu.Lock()
sw.mu.Unlock()
// 獲取當前小格子視窗所在的時間值
curSmallWindowTime := time.Now().Unix()
// 計算當前小格子視窗起始時間
beginTime := curSmallWindowTime - sw.smallWindowTime*(sw.smallWindowNum-1)
// 計算當前小格子視窗請求總數
var count int
for sWindowTime, counter := range sw.counters { // 遍歷計數器
if sWindowTime < beginTime { // 判斷不是當前小格子
delete(sw.counters, sWindowTime)
} else {
count += counter // 當前小格子視窗計數器累加
}
}
// 當前小格子請求到達請求限制,請求失敗,返回 false
if count >= sw.smallWindowCap {
return false
}
// 沒有到達請求上限,當前小格子視窗計數器+1,請求成功
sw.counters[curSmallWindowTime]++
return true
}
func main() {
var wg sync.WaitGroup
sw, _ := NewSlidingWindow(1 * time.Second)
fmt.Println("num:", sw.smallWindowNum, "cap:", sw.smallWindowCap)
for i := 0; i < 15; i++ {
wg.Add(1)
fmt.Println("req start:", i, time.Now())
go func(i int) {
if sw.ReqLimit() {
fmt.Println("req counter: ", time.Now())
}
wg.Done()
}(i)
time.Sleep(200 * time.Millisecond)
}
wg.Wait()
}
滑動視窗演算法是怎麼解決“臨界時間點”問題?
還是用上面計數器的例子:比如限制 1 分鐘內最大請求為 30 個。
在 21:30:59 秒到達 30 個請求,它落在上圖2中灰色小格子中,然後 21:31:01 秒又瞬間到達 30 個,它會落在圖2橘色小格子中。而當時間到達 21:31:01 時,時間窗要向右移動一小格(如上圖2箭頭所示),此時大時間窗內的總請求數為 60,超過了規定的最大請求數 30 個,這時就能檢測出超過了請求闕值從而觸發限流。
為什麼說是部分解決“臨界時間點”,或者說它的缺點?
這個看劃分小格子的時間大小了。比如說上面例子小格子時間是 10 秒,如果瞬間流量是微秒呢?可能又會超過限制。那劃分更細時間單位。理論上流量到達時間也可以更細。
這個又咋辦?
多層次限流,同一個介面設定多條限流規則。比如 1 分鐘 30 個,100ms 2 個。
與計數器演算法(固定時間視窗演算法)區別:
計數器其實是一個固定時間視窗,它只有一格,比較大的一格時間,計數器演算法是按照一大格時間窗向前移動。滑動視窗演算法是按照一小格時間向前移動。固定視窗可以說是滑動視窗的一種特殊情況。
滑動時間視窗小格子劃分的時間越細,向前移動就越平滑。
漏桶演算法
先看下面一張圖:
這個圖很形象的把漏桶演算法表示出來了:
(a)圖:有一個控制開關的水龍頭,下面有一個桶用來裝水,桶下面有一個放水的洞。把請求比作水,水來了就先放到桶裡,然後按照一定
的速率放出水。水龍頭放水過快,桶裡的水滿了就會溢位。表現為請求就是多出的請求丟掉。(b)圖:把 (a) 圖在進一步演算法化,把漏桶演算法形象表示出來。
流入的請求速率是不確定,請求可以是任意速率流入桶中,流出的請求則是按照固定速率流出。把流入桶中的請求計數(桶的當前水位),當請求超過桶的容量(最高水位)時,桶溢位丟棄這部分請求。
有的人形象把它叫作流量“整形”,因為不管你流入有多快,流出都是固定速率。
一個 Go demo:
package main
import (
"fmt"
"sync"
"time"
)
const (
MAXREQUEST = 5
)
type LeakyBucket struct {
capacity int // 桶的容量 - 最高水位
currentReqNum int // 桶中當前請求數量 - 當前水位
lastTime time.Time // 桶中上次請求時間 - 上次放水時間
rate int // 桶中流出請求的速率,每秒流出多少請求,水流速度/秒
mu sync.Mutex // 鎖
}
func NewLeakyBucket(rate int) *LeakyBucket {
return &LeakyBucket{
capacity: MAXREQUEST, //容量
lastTime: time.Now(),
rate: rate,
}
}
func (lb *LeakyBucket) ReqLimit() bool {
lb.mu.Lock()
lb.mu.Unlock()
now := time.Now()
// 計算距離上次放水時間間隔
gap := now.Sub(lb.lastTime)
fmt.Println("gap:", gap)
if gap >= time.Second {
// gap 這段時間流出的請求數=gap時間 * 每秒流出速率
out := int(gap/time.Second) * lb.rate
// 計算當前桶中請求數
lb.currentReqNum = maxInt(0, lb.currentReqNum-out)
lb.lastTime = now
}
// 桶中的當前請求數大於桶容量,請求失敗
if lb.currentReqNum >= lb.capacity {
return false
}
// 若沒超過桶容量,桶中請求量+1,返回true
lb.currentReqNum++
fmt.Println("curReqNum:", lb.currentReqNum)
return true
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// 測試
func main() {
var wg sync.WaitGroup
lb := NewLeakyBucket(1)
fmt.Println("cap:", lb.capacity)
for i := 0; i < 15; i++ {
wg.Add(1)
fmt.Println("req start:", i, time.Now())
go func(i int) {
if lb.ReqLimit() {
fmt.Println("req counter: ", time.Now())
}
wg.Done()
}(i)
time.Sleep(10 * time.Millisecond)
}
wg.Wait()
}
缺點:
這個漏桶演算法能保護系統,但是有大量請求時還是會丟棄很多請求,導致請求失敗數高。
令牌桶演算法
上面漏桶演算法流入速度不穩定,流出速度是穩定的。
漏桶演算法是直接把請求放入到桶裡,令牌桶演算法,一看名字,放入桶中的是令牌,然後請求獲取令牌成功才能往下執行,否則丟棄請求。
令牌總數超過桶容量,就丟棄。令牌我們可以勻速生產,所以流入桶中令牌是穩定的。
因為令牌是自己生產的,所以生產令牌的快慢可以控制,那是不是接受對應的請求可以快也可以慢,這樣就能夠應對突發流量。流量大,生產令牌就快點。能不能應對無限大突發流量?當然不行,資源是有限,對桶的最大流量也要進行限制。
令牌桶演算法如下圖所示:
總結下令牌桶演算法幾個關鍵引數:
1.令牌桶的容量
2.令牌生產的速率,比如每秒生產多少個令牌
3.最大限流量,最大請求的容量,這個關係到令牌桶裡的令牌總數
四、參考
- https://www.cnblogs.com/liqiangchn/p/14253924.html 作者:我又不亂來
- https://juejin.cn/post/6870396751178629127 作者:超悅人生
- https://en.wikipedia.org/wiki/Rate_limiting
- https://medium.com/figma-design/an-alternative-approach-to-rate-limiting-f8a06cf7c94c author:Nikrad Mahdi
- http://dockone.io/article/10569
- https://www.cnblogs.com/Tony100/p/14416305.html 作者:tony