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