Go 語言 服務(wù)流量限制

2023-03-22 15:04 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-06-ratelimit.html


5.6 Ratelimit 服務(wù)流量限制

計算機(jī)程序可依據(jù)其瓶頸分為磁盤 IO 瓶頸型,CPU 計算瓶頸型,網(wǎng)絡(luò)帶寬瓶頸型,分布式場景下有時候也會外部系統(tǒng)而導(dǎo)致自身瓶頸。

Web 系統(tǒng)打交道最多的是網(wǎng)絡(luò),無論是接收,解析用戶請求,訪問存儲,還是把響應(yīng)數(shù)據(jù)返回給用戶,都是要走網(wǎng)絡(luò)的。在沒有 epoll/kqueue 之類的系統(tǒng)提供的 IO 多路復(fù)用接口之前,多個核心的現(xiàn)代計算機(jī)最頭痛的是 C10k 問題,C10k 問題會導(dǎo)致計算機(jī)沒有辦法充分利用 CPU 來處理更多的用戶連接,進(jìn)而沒有辦法通過優(yōu)化程序提升 CPU 利用率來處理更多的請求。

自從 Linux 實現(xiàn)了 epoll,F(xiàn)reeBSD 實現(xiàn)了 kqueue,這個問題基本解決了,我們可以借助內(nèi)核提供的 API 輕松解決當(dāng)年的 C10k 問題,也就是說如今如果你的程序主要是和網(wǎng)絡(luò)打交道,那么瓶頸一定在用戶程序而不在操作系統(tǒng)內(nèi)核。

隨著時代的發(fā)展,編程語言對這些系統(tǒng)調(diào)用又進(jìn)一步進(jìn)行了封裝,如今做應(yīng)用層開發(fā),幾乎不會在程序中看到 epoll 之類的字眼,大多數(shù)時候我們就只要聚焦在業(yè)務(wù)邏輯上就好。Go 的 net 庫針對不同平臺封裝了不同的 syscall API,http 庫又是構(gòu)建在 net 庫之上,所以在 Go 語言中我們可以借助標(biāo)準(zhǔn)庫,很輕松地寫出高性能的 http 服務(wù),下面是一個簡單的 hello world 服務(wù)的代碼:

package main

import (
    "io"
    "log"
    "net/http"
)

func sayhello(wr http.ResponseWriter, r *http.Request) {
    wr.WriteHeader(200)
    io.WriteString(wr, "hello world")
}

func main() {
    http.HandleFunc("/", sayhello)
    err := http.ListenAndServe(":9090", nil)
    if err != nil {
        log.Fatal("ListenAndServe:", err)
    }
}

我們需要衡量一下這個 Web 服務(wù)的吞吐量,再具體一些,就是接口的 QPS。借助 wrk,在家用電腦 Macbook Pro 上對這個 hello world 服務(wù)進(jìn)行基準(zhǔn)測試,Mac 的硬件情況如下:

CPU: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
Core: 2
Threads: 4

Graphics/Displays:
      Chipset Model: Intel Iris Graphics 6100
          Resolution: 2560 x 1600 Retina
    Memory Slots:
          Size: 4 GB
          Speed: 1867 MHz
          Size: 4 GB
          Speed: 1867 MHz
Storage:
          Size: 250.14 GB (250,140,319,744 bytes)
          Media Name: APPLE SSD SM0256G Media
          Size: 250.14 GB (250,140,319,744 bytes)
          Medium Type: SSD

測試結(jié)果:

~ ??? wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
  10 threads and 10 connections
  Thread Stats   Avg	  Stdev	 Max   +/- Stdev
    Latency   339.99us	1.28ms  44.43ms   98.29%
    Req/Sec	 4.49k   656.81	 7.47k	73.36%
  449588 requests in 10.10s, 54.88MB read
Requests/sec:  44513.22
Transfer/sec:	  5.43MB

~ ??? wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
  10 threads and 10 connections
  Thread Stats   Avg	  Stdev	 Max   +/- Stdev
    Latency   334.76us	1.21ms  45.47ms   98.27%
    Req/Sec	 4.42k   633.62	 6.90k	71.16%
  443582 requests in 10.10s, 54.15MB read
Requests/sec:  43911.68
Transfer/sec:	  5.36MB

~ ??? wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
  10 threads and 10 connections
  Thread Stats   Avg	  Stdev	 Max   +/- Stdev
    Latency   379.26us	1.34ms  44.28ms   97.62%
    Req/Sec	 4.55k   591.64	 8.20k	76.37%
  455710 requests in 10.10s, 55.63MB read
Requests/sec:  45118.57
Transfer/sec:	  5.51MB

多次測試的結(jié)果在 4 萬左右的 QPS 浮動,響應(yīng)時間最多也就是 40ms 左右,對于一個 Web 程序來說,這已經(jīng)是很不錯的成績了,我們只是照抄了別人的示例代碼,就完成了一個高性能的 hello world 服務(wù)器,是不是很有成就感?

這還只是家用 PC,線上服務(wù)器大多都是 24 核心起,32G 內(nèi)存 +,CPU 基本都是 Intel i7。所以同樣的程序在服務(wù)器上運(yùn)行會得到更好的結(jié)果。

這里的 hello world 服務(wù)沒有任何業(yè)務(wù)邏輯。真實環(huán)境的程序要復(fù)雜得多,有些程序偏網(wǎng)絡(luò) IO 瓶頸,例如一些 CDN 服務(wù)、Proxy 服務(wù);有些程序偏 CPU/GPU 瓶頸,例如登陸校驗服務(wù)、圖像處理服務(wù);有些程序瓶頸偏磁盤,例如專門的存儲系統(tǒng),數(shù)據(jù)庫。不同的程序瓶頸會體現(xiàn)在不同的地方,這里提到的這些功能單一的服務(wù)相對來說還算容易分析。如果碰到業(yè)務(wù)邏輯復(fù)雜代碼量巨大的模塊,其瓶頸并不是三下五除二可以推測出來的,還是需要從壓力測試中得到更為精確的結(jié)論。

對于 IO/Network 瓶頸類的程序,其表現(xiàn)是網(wǎng)卡 / 磁盤 IO 會先于 CPU 打滿,這種情況即使優(yōu)化 CPU 的使用也不能提高整個系統(tǒng)的吞吐量,只能提高磁盤的讀寫速度,增加內(nèi)存大小,提升網(wǎng)卡的帶寬來提升整體性能。而 CPU 瓶頸類的程序,則是在存儲和網(wǎng)卡未打滿之前 CPU 占用率先到達(dá) 100%,CPU 忙于各種計算任務(wù),IO 設(shè)備相對則較閑。

無論哪種類型的服務(wù),在資源使用到極限的時候都會導(dǎo)致請求堆積,超時,系統(tǒng) hang 死,最終傷害到終端用戶。對于分布式的 Web 服務(wù)來說,瓶頸還不一定總在系統(tǒng)內(nèi)部,也有可能在外部。非計算密集型的系統(tǒng)往往會在關(guān)系型數(shù)據(jù)庫環(huán)節(jié)失守,而這時候 Web 模塊本身還遠(yuǎn)遠(yuǎn)未達(dá)到瓶頸。

不管我們的服務(wù)瓶頸在哪里,最終要做的事情都是一樣的,那就是流量限制。

5.6.1 常見的流量限制手段

流量限制的手段有很多,最常見的:漏桶、令牌桶兩種:

  1. 漏桶是指我們有一個一直裝滿了水的桶,每過固定的一段時間即向外漏一滴水。如果你接到了這滴水,那么你就可以繼續(xù)服務(wù)請求,如果沒有接到,那么就需要等待下一滴水。
  2. 令牌桶則是指勻速向桶中添加令牌,服務(wù)請求時需要從桶中獲取令牌,令牌的數(shù)目可以按照需要消耗的資源進(jìn)行相應(yīng)的調(diào)整。如果沒有令牌,可以選擇等待,或者放棄。

這兩種方法看起來很像,不過還是有區(qū)別的。漏桶流出的速率固定,而令牌桶只要在桶中有令牌,那就可以拿。也就是說令牌桶是允許一定程度的并發(fā)的,比如同一個時刻,有 100 個用戶請求,只要令牌桶中有 100 個令牌,那么這 100 個請求全都會放過去。令牌桶在桶中沒有令牌的情況下也會退化為漏桶模型。


圖 5-12 令牌桶

實際應(yīng)用中令牌桶應(yīng)用較為廣泛,開源界流行的限流器大多數(shù)都是基于令牌桶思想的。并且在此基礎(chǔ)上進(jìn)行了一定程度的擴(kuò)充,比如 github.com/juju/ratelimit 提供了幾種不同特色的令牌桶填充方式:

func NewBucket(fillInterval time.Duration, capacity int64) *Bucket

默認(rèn)的令牌桶,fillInterval 指每過多長時間向桶里放一個令牌,capacity 是桶的容量,超過桶容量的部分會被直接丟棄。桶初始是滿的。

func NewBucketWithQuantum(fillInterval time.Duration, capacity, quantum int64) *Bucket

和普通的 NewBucket() 的區(qū)別是,每次向桶中放令牌時,是放 quantum 個令牌,而不是一個令牌。

func NewBucketWithRate(rate float64, capacity int64) *Bucket

這個就有點(diǎn)特殊了,會按照提供的比例,每秒鐘填充令牌數(shù)。例如 capacity 是 100,而 rate 是 0.1,那么每秒會填充 10 個令牌。

從桶中獲取令牌也提供了幾個 API:

func (tb *Bucket) Take(count int64) time.Duration {}
func (tb *Bucket) TakeAvailable(count int64) int64 {}
func (tb *Bucket) TakeMaxDuration(count int64, maxWait time.Duration) (
    time.Duration, bool,
) {}
func (tb *Bucket) Wait(count int64) {}
func (tb *Bucket) WaitMaxDuration(count int64, maxWait time.Duration) bool {}

名稱和功能都比較直觀,這里就不再贅述了。相比于開源界更為有名的 Google 的 Java 工具庫 Guava 中提供的 ratelimiter,這個庫不支持令牌桶預(yù)熱,且無法修改初始的令牌容量,所以可能個別極端情況下的需求無法滿足。但在明白令牌桶的基本原理之后,如果沒辦法滿足需求,相信你也可以很快對其進(jìn)行修改并支持自己的業(yè)務(wù)場景。

5.6.2 原理

從功能上來看,令牌桶模型就是對全局計數(shù)的加減法操作過程,但使用計數(shù)需要我們自己加讀寫鎖,有小小的思想負(fù)擔(dān)。如果我們對 Go 語言已經(jīng)比較熟悉的話,很容易想到可以用 buffered channel 來完成簡單的加令牌取令牌操作:

var tokenBucket = make(chan struct{}, capacity)

每過一段時間向 tokenBucket 中添加 token,如果 bucket 已經(jīng)滿了,那么直接放棄:

fillToken := func() {
    ticker := time.NewTicker(fillInterval)
    for {
        select {
        case <-ticker.C:
            select {
            case tokenBucket <- struct{}{}:
            default:
            }
            fmt.Println("current token cnt:", len(tokenBucket), time.Now())
        }
    }
}

把代碼組合起來:

package main

import (
    "fmt"
    "time"
)

func main() {
    var fillInterval = time.Millisecond * 10
    var capacity = 100
    var tokenBucket = make(chan struct{}, capacity)

    fillToken := func() {
        ticker := time.NewTicker(fillInterval)
        for {
            select {
            case <-ticker.C:
                select {
                case tokenBucket <- struct{}{}:
                default:
                }
                fmt.Println("current token cnt:", len(tokenBucket), time.Now())
            }
        }
    }

    go fillToken()
    time.Sleep(time.Hour)
}

看看運(yùn)行結(jié)果:

current token cnt: 98 2018-06-16 18:17:50.234556981 +0800 CST m=+0.981524018
current token cnt: 99 2018-06-16 18:17:50.243575354 +0800 CST m=+0.990542391
current token cnt: 100 2018-06-16 18:17:50.254628067 +0800 CST m=+1.001595104
current token cnt: 100 2018-06-16 18:17:50.264537143 +0800 CST m=+1.011504180
current token cnt: 100 2018-06-16 18:17:50.273613018 +0800 CST m=+1.020580055
current token cnt: 100 2018-06-16 18:17:50.2844406 +0800 CST m=+1.031407637
current token cnt: 100 2018-06-16 18:17:50.294528695 +0800 CST m=+1.041495732
current token cnt: 100 2018-06-16 18:17:50.304550145 +0800 CST m=+1.051517182
current token cnt: 100 2018-06-16 18:17:50.313970334 +0800 CST m=+1.060937371

在 1s 鐘的時候剛好填滿 100 個,沒有太大的偏差。不過這里可以看到,Go 的定時器存在大約 0.001s 的誤差,所以如果令牌桶大小在 1000 以上的填充可能會有一定的誤差。對于一般的服務(wù)來說,這一點(diǎn)誤差無關(guān)緊要。

上面的令牌桶的取令牌操作實現(xiàn)起來也比較簡單,簡化問題,我們這里只取一個令牌:

func TakeAvailable(block bool) bool{
    var takenResult bool
    if block {
        select {
        case <-tokenBucket:
            takenResult = true
        }
    } else {
        select {
        case <-tokenBucket:
            takenResult = true
        default:
            takenResult = false
        }
    }

    return takenResult
}

一些公司自己造的限流的輪子就是用上面這種方式來實現(xiàn)的,不過如果開源版 ratelimit 也如此的話,那我們也沒什么可說的了?,F(xiàn)實并不是這樣的。

我們來思考一下,令牌桶每隔一段固定的時間向桶中放令牌,如果我們記下上一次放令牌的時間為 t1,和當(dāng)時的令牌數(shù) k1,放令牌的時間間隔為 ti,每次向令牌桶中放 x 個令牌,令牌桶容量為 cap?,F(xiàn)在如果有人來調(diào)用 TakeAvailable 來取 n 個令牌,我們將這個時刻記為 t2。在 t2 時刻,令牌桶中理論上應(yīng)該有多少令牌呢?偽代碼如下:

cur = k1 + ((t2 - t1)/ti) * x
cur = cur > cap ? cap : cur

我們用兩個時間點(diǎn)的時間差,再結(jié)合其它的參數(shù),理論上在取令牌之前就完全可以知道桶里有多少令牌了。那勞心費(fèi)力地像本小節(jié)前面向 channel 里填充 token 的操作,理論上是沒有必要的。只要在每次 Take 的時候,再對令牌桶中的 token 數(shù)進(jìn)行簡單計算,就可以得到正確的令牌數(shù)。是不是很像 惰性求值 的感覺?

在得到正確的令牌數(shù)之后,再進(jìn)行實際的 Take 操作就好,這個 Take 操作只需要對令牌數(shù)進(jìn)行簡單的減法即可,記得加鎖以保證并發(fā)安全。github.com/juju/ratelimit 這個庫就是這樣做的。

5.6.3 服務(wù)瓶頸和 QoS

前面我們說了很多 CPU 瓶頸、IO 瓶頸之類的概念,這種性能瓶頸從大多數(shù)公司都有的監(jiān)控系統(tǒng)中可以比較快速地定位出來,如果一個系統(tǒng)遇到了性能問題,那監(jiān)控圖的反應(yīng)一般都是最快的。

雖然性能指標(biāo)很重要,但對用戶提供服務(wù)時還應(yīng)考慮服務(wù)整體的 QoS。QoS 全稱是 Quality of Service,顧名思義是服務(wù)質(zhì)量。QoS 包含有可用性、吞吐量、時延、時延變化和丟失等指標(biāo)。一般來講我們可以通過優(yōu)化系統(tǒng),來提高 Web 服務(wù)的 CPU 利用率,從而提高整個系統(tǒng)的吞吐量。但吞吐量提高的同時,用戶體驗是有可能變差的。用戶角度比較敏感的除了可用性之外,還有時延。雖然你的系統(tǒng)吞吐量高,但半天刷不開頁面,想必會造成大量的用戶流失。所以在大公司的 Web 服務(wù)性能指標(biāo)中,除了平均響應(yīng)時延之外,還會把響應(yīng)時間的 95 分位,99 分位也拿出來作為性能標(biāo)準(zhǔn)。平均響應(yīng)在提高 CPU 利用率沒受到太大影響時,可能 95 分位、99 分位的響應(yīng)時間大幅度攀升了,那么這時候就要考慮提高這些 CPU 利用率所付出的代價是否值得了。

在線系統(tǒng)的機(jī)器一般都會保持 CPU 有一定的余裕。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號