原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-06-ratelimit.html
計(jì)算機(jī)程序可依據(jù)其瓶頸分為磁盤(pán) IO 瓶頸型,CPU 計(jì)算瓶頸型,網(wǎng)絡(luò)帶寬瓶頸型,分布式場(chǎng)景下有時(shí)候也會(huì)外部系統(tǒng)而導(dǎo)致自身瓶頸。
Web 系統(tǒng)打交道最多的是網(wǎng)絡(luò),無(wú)論是接收,解析用戶請(qǐng)求,訪問(wèn)存儲(chǔ),還是把響應(yīng)數(shù)據(jù)返回給用戶,都是要走網(wǎng)絡(luò)的。在沒(méi)有 epoll/kqueue
之類的系統(tǒng)提供的 IO 多路復(fù)用接口之前,多個(gè)核心的現(xiàn)代計(jì)算機(jī)最頭痛的是 C10k 問(wèn)題,C10k 問(wèn)題會(huì)導(dǎo)致計(jì)算機(jī)沒(méi)有辦法充分利用 CPU 來(lái)處理更多的用戶連接,進(jìn)而沒(méi)有辦法通過(guò)優(yōu)化程序提升 CPU 利用率來(lái)處理更多的請(qǐng)求。
自從 Linux 實(shí)現(xiàn)了 epoll
,F(xiàn)reeBSD 實(shí)現(xiàn)了 kqueue
,這個(gè)問(wèn)題基本解決了,我們可以借助內(nèi)核提供的 API 輕松解決當(dāng)年的 C10k 問(wèn)題,也就是說(shuō)如今如果你的程序主要是和網(wǎng)絡(luò)打交道,那么瓶頸一定在用戶程序而不在操作系統(tǒng)內(nèi)核。
隨著時(shí)代的發(fā)展,編程語(yǔ)言對(duì)這些系統(tǒng)調(diào)用又進(jìn)一步進(jìn)行了封裝,如今做應(yīng)用層開(kāi)發(fā),幾乎不會(huì)在程序中看到 epoll
之類的字眼,大多數(shù)時(shí)候我們就只要聚焦在業(yè)務(wù)邏輯上就好。Go 的 net 庫(kù)針對(duì)不同平臺(tái)封裝了不同的 syscall API,http
庫(kù)又是構(gòu)建在 net
庫(kù)之上,所以在 Go 語(yǔ)言中我們可以借助標(biāo)準(zhǔn)庫(kù),很輕松地寫(xiě)出高性能的 http
服務(wù),下面是一個(gè)簡(jiǎn)單的 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)
}
}
我們需要衡量一下這個(gè) Web 服務(wù)的吞吐量,再具體一些,就是接口的 QPS。借助 wrk,在家用電腦 Macbook Pro 上對(duì)這個(gè) hello world
服務(wù)進(jìn)行基準(zhǔn)測(cè)試,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
測(cè)試結(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
多次測(cè)試的結(jié)果在 4 萬(wàn)左右的 QPS 浮動(dòng),響應(yīng)時(shí)間最多也就是 40ms 左右,對(duì)于一個(gè) Web 程序來(lái)說(shuō),這已經(jīng)是很不錯(cuò)的成績(jī)了,我們只是照抄了別人的示例代碼,就完成了一個(gè)高性能的 hello world
服務(wù)器,是不是很有成就感?
這還只是家用 PC,線上服務(wù)器大多都是 24 核心起,32G 內(nèi)存 +,CPU 基本都是 Intel i7。所以同樣的程序在服務(wù)器上運(yùn)行會(huì)得到更好的結(jié)果。
這里的 hello world
服務(wù)沒(méi)有任何業(yè)務(wù)邏輯。真實(shí)環(huán)境的程序要復(fù)雜得多,有些程序偏網(wǎng)絡(luò) IO 瓶頸,例如一些 CDN 服務(wù)、Proxy 服務(wù);有些程序偏 CPU/GPU 瓶頸,例如登陸校驗(yàn)服務(wù)、圖像處理服務(wù);有些程序瓶頸偏磁盤(pán),例如專門的存儲(chǔ)系統(tǒng),數(shù)據(jù)庫(kù)。不同的程序瓶頸會(huì)體現(xiàn)在不同的地方,這里提到的這些功能單一的服務(wù)相對(duì)來(lái)說(shuō)還算容易分析。如果碰到業(yè)務(wù)邏輯復(fù)雜代碼量巨大的模塊,其瓶頸并不是三下五除二可以推測(cè)出來(lái)的,還是需要從壓力測(cè)試中得到更為精確的結(jié)論。
對(duì)于 IO/Network 瓶頸類的程序,其表現(xiàn)是網(wǎng)卡 / 磁盤(pán) IO 會(huì)先于 CPU 打滿,這種情況即使優(yōu)化 CPU 的使用也不能提高整個(gè)系統(tǒng)的吞吐量,只能提高磁盤(pán)的讀寫(xiě)速度,增加內(nèi)存大小,提升網(wǎng)卡的帶寬來(lái)提升整體性能。而 CPU 瓶頸類的程序,則是在存儲(chǔ)和網(wǎng)卡未打滿之前 CPU 占用率先到達(dá) 100%,CPU 忙于各種計(jì)算任務(wù),IO 設(shè)備相對(duì)則較閑。
無(wú)論哪種類型的服務(wù),在資源使用到極限的時(shí)候都會(huì)導(dǎo)致請(qǐng)求堆積,超時(shí),系統(tǒng) hang 死,最終傷害到終端用戶。對(duì)于分布式的 Web 服務(wù)來(lái)說(shuō),瓶頸還不一定總在系統(tǒng)內(nèi)部,也有可能在外部。非計(jì)算密集型的系統(tǒng)往往會(huì)在關(guān)系型數(shù)據(jù)庫(kù)環(huán)節(jié)失守,而這時(shí)候 Web 模塊本身還遠(yuǎn)遠(yuǎn)未達(dá)到瓶頸。
不管我們的服務(wù)瓶頸在哪里,最終要做的事情都是一樣的,那就是流量限制。
流量限制的手段有很多,最常見(jiàn)的:漏桶、令牌桶兩種:
這兩種方法看起來(lái)很像,不過(guò)還是有區(qū)別的。漏桶流出的速率固定,而令牌桶只要在桶中有令牌,那就可以拿。也就是說(shuō)令牌桶是允許一定程度的并發(fā)的,比如同一個(gè)時(shí)刻,有 100 個(gè)用戶請(qǐng)求,只要令牌桶中有 100 個(gè)令牌,那么這 100 個(gè)請(qǐng)求全都會(huì)放過(guò)去。令牌桶在桶中沒(méi)有令牌的情況下也會(huì)退化為漏桶模型。
圖 5-12 令牌桶
實(shí)際應(yīng)用中令牌桶應(yīng)用較為廣泛,開(kāi)源界流行的限流器大多數(shù)都是基于令牌桶思想的。并且在此基礎(chǔ)上進(jìn)行了一定程度的擴(kuò)充,比如 github.com/juju/ratelimit
提供了幾種不同特色的令牌桶填充方式:
func NewBucket(fillInterval time.Duration, capacity int64) *Bucket
默認(rèn)的令牌桶,fillInterval
指每過(guò)多長(zhǎng)時(shí)間向桶里放一個(gè)令牌,capacity
是桶的容量,超過(guò)桶容量的部分會(huì)被直接丟棄。桶初始是滿的。
func NewBucketWithQuantum(fillInterval time.Duration, capacity, quantum int64) *Bucket
和普通的 NewBucket()
的區(qū)別是,每次向桶中放令牌時(shí),是放 quantum
個(gè)令牌,而不是一個(gè)令牌。
func NewBucketWithRate(rate float64, capacity int64) *Bucket
這個(gè)就有點(diǎn)特殊了,會(huì)按照提供的比例,每秒鐘填充令牌數(shù)。例如 capacity
是 100,而 rate
是 0.1,那么每秒會(huì)填充 10 個(gè)令牌。
從桶中獲取令牌也提供了幾個(gè) 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 {}
名稱和功能都比較直觀,這里就不再贅述了。相比于開(kāi)源界更為有名的 Google 的 Java 工具庫(kù) Guava 中提供的 ratelimiter,這個(gè)庫(kù)不支持令牌桶預(yù)熱,且無(wú)法修改初始的令牌容量,所以可能個(gè)別極端情況下的需求無(wú)法滿足。但在明白令牌桶的基本原理之后,如果沒(méi)辦法滿足需求,相信你也可以很快對(duì)其進(jìn)行修改并支持自己的業(yè)務(wù)場(chǎng)景。
從功能上來(lái)看,令牌桶模型就是對(duì)全局計(jì)數(shù)的加減法操作過(guò)程,但使用計(jì)數(shù)需要我們自己加讀寫(xiě)鎖,有小小的思想負(fù)擔(dān)。如果我們對(duì) Go 語(yǔ)言已經(jīng)比較熟悉的話,很容易想到可以用 buffered channel 來(lái)完成簡(jiǎn)單的加令牌取令牌操作:
var tokenBucket = make(chan struct{}, capacity)
每過(guò)一段時(shí)間向 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())
}
}
}
把代碼組合起來(lái):
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 鐘的時(shí)候剛好填滿 100 個(gè),沒(méi)有太大的偏差。不過(guò)這里可以看到,Go 的定時(shí)器存在大約 0.001s 的誤差,所以如果令牌桶大小在 1000 以上的填充可能會(huì)有一定的誤差。對(duì)于一般的服務(wù)來(lái)說(shuō),這一點(diǎn)誤差無(wú)關(guān)緊要。
上面的令牌桶的取令牌操作實(shí)現(xiàn)起來(lái)也比較簡(jiǎn)單,簡(jiǎn)化問(wèn)題,我們這里只取一個(gè)令牌:
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
}
一些公司自己造的限流的輪子就是用上面這種方式來(lái)實(shí)現(xiàn)的,不過(guò)如果開(kāi)源版 ratelimit 也如此的話,那我們也沒(méi)什么可說(shuō)的了?,F(xiàn)實(shí)并不是這樣的。
我們來(lái)思考一下,令牌桶每隔一段固定的時(shí)間向桶中放令牌,如果我們記下上一次放令牌的時(shí)間為 t1,和當(dāng)時(shí)的令牌數(shù) k1,放令牌的時(shí)間間隔為 ti,每次向令牌桶中放 x 個(gè)令牌,令牌桶容量為 cap?,F(xiàn)在如果有人來(lái)調(diào)用 TakeAvailable
來(lái)取 n 個(gè)令牌,我們將這個(gè)時(shí)刻記為 t2。在 t2 時(shí)刻,令牌桶中理論上應(yīng)該有多少令牌呢?偽代碼如下:
cur = k1 + ((t2 - t1)/ti) * x
cur = cur > cap ? cap : cur
我們用兩個(gè)時(shí)間點(diǎn)的時(shí)間差,再結(jié)合其它的參數(shù),理論上在取令牌之前就完全可以知道桶里有多少令牌了。那勞心費(fèi)力地像本小節(jié)前面向 channel 里填充 token 的操作,理論上是沒(méi)有必要的。只要在每次 Take
的時(shí)候,再對(duì)令牌桶中的 token 數(shù)進(jìn)行簡(jiǎn)單計(jì)算,就可以得到正確的令牌數(shù)。是不是很像 惰性求值
的感覺(jué)?
在得到正確的令牌數(shù)之后,再進(jìn)行實(shí)際的 Take
操作就好,這個(gè) Take
操作只需要對(duì)令牌數(shù)進(jìn)行簡(jiǎn)單的減法即可,記得加鎖以保證并發(fā)安全。github.com/juju/ratelimit
這個(gè)庫(kù)就是這樣做的。
前面我們說(shuō)了很多 CPU 瓶頸、IO 瓶頸之類的概念,這種性能瓶頸從大多數(shù)公司都有的監(jiān)控系統(tǒng)中可以比較快速地定位出來(lái),如果一個(gè)系統(tǒng)遇到了性能問(wèn)題,那監(jiān)控圖的反應(yīng)一般都是最快的。
雖然性能指標(biāo)很重要,但對(duì)用戶提供服務(wù)時(shí)還應(yīng)考慮服務(wù)整體的 QoS。QoS 全稱是 Quality of Service,顧名思義是服務(wù)質(zhì)量。QoS 包含有可用性、吞吐量、時(shí)延、時(shí)延變化和丟失等指標(biāo)。一般來(lái)講我們可以通過(guò)優(yōu)化系統(tǒng),來(lái)提高 Web 服務(wù)的 CPU 利用率,從而提高整個(gè)系統(tǒng)的吞吐量。但吞吐量提高的同時(shí),用戶體驗(yàn)是有可能變差的。用戶角度比較敏感的除了可用性之外,還有時(shí)延。雖然你的系統(tǒng)吞吐量高,但半天刷不開(kāi)頁(yè)面,想必會(huì)造成大量的用戶流失。所以在大公司的 Web 服務(wù)性能指標(biāo)中,除了平均響應(yīng)時(shí)延之外,還會(huì)把響應(yīng)時(shí)間的 95 分位,99 分位也拿出來(lái)作為性能標(biāo)準(zhǔn)。平均響應(yīng)在提高 CPU 利用率沒(méi)受到太大影響時(shí),可能 95 分位、99 分位的響應(yīng)時(shí)間大幅度攀升了,那么這時(shí)候就要考慮提高這些 CPU 利用率所付出的代價(jià)是否值得了。
在線系統(tǒng)的機(jī)器一般都會(huì)保持 CPU 有一定的余裕。
![]() | ![]() |
更多建議: