服務治理:常用限流演算法總結

九卷 發表於 2022-05-12
演算法

服務治理:常用限流演算法總結

一、為什麼會有限流


限流,看字面意思,限制流動。

為什麼要限制流動?

比如高速公路出現了事故,交警會對高速路車輛的進入進行指揮和限制。

發生了一些意外情況,才可能要限制流動。等恢復正常情況後,就解除限制。不可能無緣無故的限制流動,畢竟限制會影響正常系統執行。

在舉一個例子:

足球館看足球比賽,足球館的場地大小是固定的,座位數是固定的,能容納看球人數總量是有限的。如果超過足球館容量最大承載,會導致場內擁擠,這樣會發生2個問題:一個是導致大家行動不便,一個可能會發生意想不到的事故。
那怎麼辦?球票。
一個足球場出售的球票是有限制的,一共賣多少張票是有一定數量額度。

系統容量有限,如果超過了系統的負荷,那麼就需要做一些限制措施,避免系統執行時出現異常情況。

那在計算機系統中,為什麼要限流?

同理,訪問計算機系統時或者是計算機系統本身出現了一些異常情況。比如流量過大,系統處理不過來。比如系統升級等等情況。

計算機系統容量是有限的,記憶體大小,CPU 處理資料的速度,都是有限的,不可能無限大。如果超過了一定的闕值,系統就會出現異常,甚至當機。

現在微服務架構比較流行,各種服務比較多,服務之間呼叫頻繁。
如果訪問一個 API 服務時,超過了這個服務能提供的最大訪問能力,服務會崩潰,那就要對這個服務進行保護,避免服務因訪問過大導致服務不可用,不僅影響自己服務,也可能影響其它相關服務。

採用什麼方法保護服務呢?限流就是保護方法之一。

在 IT 高併發系統中,處於對系統的保護,需要對系統進行限流。

二、IT 系統中的限流


上面已經介紹了 IT 系統中的一些限流問題。

下面來看看對使用限流的一些具體情況描述。

在網際網路世界裡,一根一根的網線把整個世界連線起來,那麼網路裡面傳輸的資料
流動起來就形成了網路流。TCP 裡就有限制流量的演算法-滑動視窗演算法。

在微服務系統裡的 API 介面中,對介面做限制,保護介面安全,保證系統穩定。
對介面訪問請求,怎麼描述介面請求情況?
一般用每秒請求數(request per second),併發請求數等,來描述對介面的請求情況。所以限制也是對每秒請求數進行限制。

還有平常使用的連線池技術,也可以理解為限流思想的一種,把連線數限制在一個數量上。把固定數量的連線放入“池子”中,很形象的說法。當然也是複用減少損耗。

三、常用限流演算法


常用的限流演算法,一般有 4 種:

  1. 計數器
  2. 滑動視窗
  3. 漏桶
  4. 令牌桶

計數器演算法

計數器演算法:

在固定視窗內對請求進行計數,然後與設定的最大請求數進行比較,如果超過了最大值,就進行限流。到達了一個固定時間視窗終點,將計數器清零,重新開始計數。
計數器演算法又叫 固定視窗演算法-Fixed Window

舉個例子,比如在微服務中有一個介面,限制呼叫次數: 1 分鐘內最大呼叫次數為 30。
根據描述,這個演算法為:

設定最大請求數 MaxRequest = 30,視窗時間 WindowTime = 60 秒,
還有一個計時開始時間 BeginTime , 請求計數 Counter。

image-20220509213244455

用 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.如果請求速度太快,會丟掉一些請求。

image-20220511014852669

怎麼解決“臨界時間點”問題,看下面滑動視窗演算法。

滑動視窗演算法

滑動視窗演算法(Sliding Window)部分解決了計數器演算法(固定時間視窗演算法)“時間臨界點” 的問題。

有的人還會把滑動視窗演算法細分:滑動視窗日誌(sliding window log)滑動視窗計數(sliding window counter)

滑動視窗計數

滑動視窗演算法:

在計數器演算法中,把大時間視窗在進一步劃分為更細小的時間視窗格子,隨著時間向前移動,大時間窗每次向前移動一個小格子,而不是大時間窗向前移動。每個小格子都有自己獨立計數器,小格子會記錄每個請求到達的時間點。

最終統計比較:

  • 比較小格子內請求數:(大時間視窗內規定最大請求數 / N個小格子) > 小格子時間窗內總請求數

舉個例子:

把 1 分鐘時間窗在劃分為 6 個小格子時間窗,每個小格子 10 秒。每過 10 秒鐘,時間視窗向右滑動一小格。每一個小格

都有自己獨立的計數器 counter。下面圖1到圖2:

image-20220511013025069

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 個。

與計數器演算法(固定時間視窗演算法)區別:

計數器其實是一個固定時間視窗,它只有一格,比較大的一格時間,計數器演算法是按照一大格時間窗向前移動。滑動視窗演算法是按照一小格時間向前移動。固定視窗可以說是滑動視窗的一種特殊情況。

滑動時間視窗小格子劃分的時間越細,向前移動就越平滑。

漏桶演算法

先看下面一張圖:

image-20220510191200907

這個圖很形象的把漏桶演算法表示出來了:

(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()
}

缺點:

這個漏桶演算法能保護系統,但是有大量請求時還是會丟棄很多請求,導致請求失敗數高。

令牌桶演算法

上面漏桶演算法流入速度不穩定,流出速度是穩定的。

漏桶演算法是直接把請求放入到桶裡,令牌桶演算法,一看名字,放入桶中的是令牌,然後請求獲取令牌成功才能往下執行,否則丟棄請求。

令牌總數超過桶容量,就丟棄。令牌我們可以勻速生產,所以流入桶中令牌是穩定的。

因為令牌是自己生產的,所以生產令牌的快慢可以控制,那是不是接受對應的請求可以快也可以慢,這樣就能夠應對突發流量。流量大,生產令牌就快點。能不能應對無限大突發流量?當然不行,資源是有限,對桶的最大流量也要進行限制。

令牌桶演算法如下圖所示:

image-20220512024641528

總結下令牌桶演算法幾個關鍵引數:

1.令牌桶的容量

2.令牌生產的速率,比如每秒生產多少個令牌

3.最大限流量,最大請求的容量,這個關係到令牌桶裡的令牌總數

四、參考