原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-06-ratelimit.html
計算機(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ù)瓶頸在哪里,最終要做的事情都是一樣的,那就是流量限制。
流量限制的手段有很多,最常見的:漏桶、令牌桶兩種:
這兩種方法看起來很像,不過還是有區(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ù)場景。
從功能上來看,令牌桶模型就是對全局計數(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
這個庫就是這樣做的。
前面我們說了很多 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 有一定的余裕。
更多建議: