网关速率限制实践
速率限制 (Rate Limit) 通過限制調用 API 的頻率防止 API 過度使用,保護 API 免受意外或惡意的使用,在諸多業務場景中得到廣泛應用。日前,又拍云系統開發工程師陳卓受邀在 Open Talk 公開課上作了題為《又拍云網關速率限制實踐》的分享,詳細解讀當前常用的算法以及基于網關 nginx/openresty 的實現和配置細節。以下是直播分享內容整理,查看視頻請點擊閱讀原文。
網關速率限制是一種防御服務性措施,公共服務需要借其保護自己免受過度使用,使用速率限制主要有三個好處:
-
提升用戶體驗:用戶在使用公共服務時總會面臨一些資源增強和共享的問題,例如 CPU,當一個用戶不管是有意或無意地過度使用 API 時,勢必會對其他的用戶造成一些影響。
-
更加安全:我們的服務、CPU、內存其實都是有一定的限制,過度訪問勢必會影響到服務的穩定性。假如有四個服務,每個服務能承載 100 個請求,當其中一個服務超過 100 個請求時就可能會宕機,其它三個服務在接收到超過 100 個服務請求時,也會接著連續宕機,這會造成服務不可用。
-
減少開銷:現在很多服務都是放到公有云上,內存、CPU 和流量都是有成本的,有一些按量計費,使用多少花多少錢,這種情況會產生一些不必要的開銷。
RateLimit 的幾種算法
首先介紹四種速率限制的算法,分別是漏桶(Leaky Bucket)、令牌桶(Token Bucket)、固定窗口(Fixed Windows)、滑動窗口(Sliding Windows),很多限制措施都是基于這些算法進行的。漏桶和令牌桶雖然直觀理解看似不太一樣,但是在底層實現中這兩種算法非常相似,達到的效果差不多。固定窗口和滑動窗口屬于另外一類,滑動窗口是基于固定窗口做的。
漏桶(Leaky Bucket)
如上圖所示,用戶請求都被放進桶里,當桶滿了以后,請求會被拒絕掉。桶的底部有一個孔,請求會以一定的速率被放過,比如說現在是限制每分鐘 10 個請求,意味著每隔 6 秒鐘就會有一個請求通過。漏桶算法的特點在于其通過請求的速率是恒定的,可以將流量整形的非常均勻,即便同時有 100 個請求也不會一次性通過,而是按一定間隔慢慢放行,這對后端服務迎接突發流量非常友好。
令牌桶(Token Bucket)
令牌桶,顧名思義桶里放的是一些令牌,這些令牌會按一定的速率往桶里放,假如每分鐘限制 10 個請求,那么每分鐘就往桶里放 10 個令牌,請求進來的時候需要先在令牌桶里拿令牌,拿到令牌則請求被放行,桶為空拿不到則意味著該請求被拒絕掉。
需要說明的是,令牌的個數是按一定的速率投放的,每分鐘放 10 個令牌,那么能通過的請求肯定也是每分鐘 10 個。假如勻速放令牌, 6 秒鐘放一個令牌,最終結果和每分鐘放 10 個令牌是一樣的。
漏桶(Leaky Bucket)算法實現
由于令牌桶跟漏桶的實現效果差不多,這里主要細講漏桶的算法和實現。先假設速率限制是每分鐘 3 個請求,即每 20 秒鐘放行一個請求。如圖所示,假設第 10 秒進來第一個請求,因為之前一直都沒有請求進入,所以該請求被允許通過。記錄下最后一次的訪問時間,即為本次請求通過時間點。
現在第 20 秒又過來一個請求,20 秒相對于 10 秒鐘經過了 10 秒鐘,按照計算只允許被通過 0.5 個請求,那請求就被拒絕掉了。這個 last 值還是保持最后一次一個請求通過的時間。第 30 秒又來了一個請求:如果將 30 秒看作是最后一次更新時間,相當于是 30 秒減 10 秒,也就是經過了 20 秒,而我們的限制是每 20 秒允許 1 個請求,那么這個請求會被放過去,last 值現在已經變成了 30 秒。
通過上述分析可以發現,漏桶限制非常嚴格,即便請求是第 29 秒進來也不能被通過,因為必須要經過 20 秒才允許通過一個請求,這可能會給業務帶來一個問題:例如現在每分鐘允許通過 3 個請求,用戶可能需要在前 10 秒鐘把三個請求發完,這種需求在這種算法下不會被允許。因為從發掉第一個請求到發第二個請求必須要間隔 20 秒才可以,為了彌補這種缺陷,需要引用另外一個參數 burst(爆發),允許突然爆發的請求。如下圖中所示,40 秒距離 30 秒實際上只經過了 10 秒鐘,按照之前的算法計算只被允許訪問 0.5 個請求,實際上應該被拒絕掉,但是我們允許它提前多訪問一個請求(burst 為1),算下來就是 0.5+1=1.5 個請求。
需要注意的是,雖然我們當前時間是 40 秒,但我們最后需要更新請求時間到 50 秒,這是因為現在已經超量使用進入到下一個時間段了,相當于是提前放行一個請求,最后一個 last 時間是 30 秒,應該加 20 秒到 50 秒。這也是該算法實現的一個特點,很多算法也都有 burst 的功能,即允許提前訪問。
45 秒又來了一個請求,盡管這個請求來時,我們也允許它提前訪問。但由于上一次最后訪問時間已經是 50 秒了,而且在通過計算得出不到一個請求時,這一個請求也就被拒絕掉了,時間戳 last 還是 50 秒。
**漏桶算法核心的地方在于我們在實現的時候保存最后一次的通過時間,新請求來的時候,用當前的時候減去之前的時間,然后拿到可以允許通過的請求個數。如果能通過,就把最后一次請求時間改成當前的時間;不能通過,當前最后一次請求時間還是不變。**如果我們要添加 burst 的功能,即提前允許它訪問多少個請求的時候,last 時間可能不再是最后一次放過去的時間,而是相對于之前最后一次請求的時間,它增長了多少個請求的時間,而這個 last 時間可能會超過請求的時候,總的來看主要核心的變量就是 last 的時間戳和 burst。
漏桶/令牌桶算法開源庫
漏桶跟令牌桶的開源庫也是特別多,下列幾個庫非常經典,各個語言和各個包都有實現,再加上因為我從事的工作主要是對 lua 和 golang 比較熟悉,這里主要講他們:
- nginx
https://www.nginx.com/blog/rate-limiting-nginx/
- openresty/lua-resty-limit-traffic (兩個變量)
https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/req.lua
- uber-go/ratelimit
https://github.com/uber-go/ratelimit
Nginx 使用漏桶實現的,這個大家有興趣去看一看,我們稍后會講 Nginx 如何配置限制。Openresty 是基于 Nginx 之上使用 lua 編寫模塊的一個框架,它的實現里主要有兩個參數,第一個參數是剛剛說的 rate,即每秒允許多少個請求;另一個參數是 burst ,指的是允許提前范圍多少個,比如說每秒鐘允許請求 5 個,這里還可以允許它提前放過去 5 個請求。
Uber 是 Uber 公司內部用 go 語言實現的一個 rate 限制。與前面 lua 代碼不加鎖不同的是,這個算法加了一個自選鎖。我認為在高并發場景中,自選鎖是一個挺好的選擇,因為這會有一個 get 和 set 的操作,為了保證準確肯定要加鎖,大家也可以去看看。
Nginx 配置
Nginx 配置中先考慮限制維度。例如每個用戶每分鐘只被允許訪問兩次就是按照用戶緯度來限制,或者按照 ip 和 host 來限制,還有就是按照一個 Server,比如一個 Sever 最大能承載每秒鐘 10000 個,超過 10000 個可能要被彈掉了。
以上提到的這些限制維度在 Nginx 里都能實現,實現方式主要依賴于 Nginx 的兩個模塊:ngx_http_limit_req_module 和 ngx_http_limit_conn_module ,即限制請求數和限制連接數。
固定窗口(Fixed Windows)
固定窗口是最好理解的一個算法,應用在分布式限制場景中非常容易實現,因為它不需要加鎖。
如圖是一個時間戳的窗口,我們現在規定每分鐘 50 個請求。30 秒來了第 1 個請求,40 秒的時候來了 49 個請求,現在一分鐘的時候來了 50 個請求,由于已經達到每秒 50 個限制,當 50 秒再來一個請求時會被直接彈掉。等到下一分鐘時,即便一下子來了 50 個請求也會被放過,因為它已經到了下一分鐘了。
通過分析,大家能看到固定窗口算法是真的非常簡單,你的程序只需要存儲著當前時間窗口內已經有了多少個請求。至于不加鎖,則是因為我們直接原子操作增加變量,增加完了以后需要注意有沒有超過 50,超過 50,請求被拒絕;沒有超過 50,請求會被接收,所以這里不會出現 get 跟 set 的情況。
當然這個也有一個弊端,如上圖所示的 00:30 到 01:30 ,也算是一個 60 秒的時間范圍,但它有 100 個請求了,和我們限制的要求是不一樣的,會出現流量高峰的問題。因而這種算法只能保證在一個固定窗口請求不會超過 50 個,如果是隨機一個非固定窗口之內,它的請求就很有可能超過 50 個,針對這種情況又提出了滑動窗口的概念。
滑動窗口(Sliding Windows)
如圖所示,每分鐘有 50 個請求,滑動窗口的一分鐘指的的是當前時間往前的一分鐘有多少個請求,例如 01:15 之前相當于從 0:15 到 01:15 。
已知 01:00 到 01:15 有 18 個請求,但 00:15 到 01:00 這個時間段是多少個請求呢?我們現在知道的是 00:00 到 01:00 是有 42 個請求,而滑動窗口算法的特點在于按比例??梢詫⑦@一分鐘分成兩段時間,前 15 秒和 后 45 秒,它按這個比例計算 00:15 到 01:00 大約有多少個請求。按比例算不是很精準,因為它只記錄了總數。通過計算
rate=42*((60-15)/60)+18=42 * 0.75 + 18=49.5 requests
算下來是 49.5 個請求,當前這個請求應該是被拒絕掉的。
通過上述操作可以發現滑動窗口通過比例來保證每個分鐘內通過值和限制值相近。當然這種不準的情況可以通過減小窗口時間改進,例如現在窗口是 1 分鐘,你可以減小到 10 秒鐘,這樣發生錯誤的概率就會降低,不過減小到 10 秒窗口帶來的額外存儲成本就會很高。雖然這個算法有一些缺點,但是也有不少的公司在用。
滑動窗口(Sliding Windows)是否準確的問題
Cloudflare 對來自 270000 個不同來源的 4 億個請求的分析顯示:
-
0.003% 的請求被錯誤地允許或限制了速率
-
實際利率與近似利率之間的平均差異為 6%
-
盡管產生了略高于閾值的流量(誤報),實際發生率比閾值率高出不到 15%
很多大公司也在使用滑動窗口算法,如果你的限制每分鐘 50 個,你能容忍它每分鐘 40 個或者 60個的話,這種算法方案也是可行的。
固定窗口/滑動窗口應用
剛剛我們已經講到了滑動窗口限制算法不需要加鎖,使用原子操作即可,所以實現也非常簡單。
- openresty/lua-resty-limit-traffic (atomic原子操作)
https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/count.lua
只有 100 的代碼,這里用了一個 increment 的原子操作,不需要加鎖,對多線程、多進程的實現比較友好,開銷非常少。
- kong
https://github.com/Kong/kong/tree/master/kong/plugins/rate-limiting
這是 Kong 實現的滑動窗口應用,不過代碼比較多,大家有興趣的看看,同樣是 lua 的代碼,這個滑動窗口的實現比較全。
分布式 Rate Limit
很多時候網關可能不止一臺,有兩臺機器的時候就要執行同步操作,例如在漏桶算法中要同步 last 值。同步的策略可以使用 DB 庫。不過 DB 庫同步適合請求量比較小的場景。面對請求量特別大的時候,可以使用 redis 這種高速的內存庫,同步比較快。當然以上兩種都是限制比較精準的時候可以使用,如果不是特別精準,只需要防止服務不被沖垮,我覺得可以使用 local 限制。
local 的限制是什么呢?我們剛剛提到每分鐘限制 50 個請求,如果你有兩個 Node,可以平均分配每個 Node 25 個,這個方案是可行的。如果有權重是 10% 的流量往一邊走,90% 的流量往另一邊走,可以相對調大其中一個 Node 的權重,改成一個 Node 每分鐘限制 45 個,另一個每分鐘限制 5 個,這樣可以避免再接入一些 DB 的中間件。
面對這種分布式的業務場景,APISIX 實現的還不錯 (https://github.com/apache/incubator-apisix/blob/master/apisix/plugins/limit-count/limit-count-redis.lua),它是基于 openresty 做的一個庫,直接使用了 redis 作為同步,通過固定窗口方法實現。另一個不錯的是goredis(https://github.com/rwz/redis-gcra/blob/master/vendor/perform_gcra_ratelimit.lua),基于 golang 的庫實現了漏桶算法。goredis 的成本稍微高一些,如果是分布式的話,用固定窗口和滑動窗口的成本會低很多。
分布式 Rate Limit 性能優化
前面提到每個請求過來時都要讀取 redis 或者是 DB 類的數據,例如固定窗口讀取 count 值,去redis 把 count 減 1 會增加延遲,這種情況下就帶來一些多余的開銷。為解決此問題,一些開源的企業級方案推崇不實時同步數值的做法。假如現在每分鐘有兩個請求,Node1 接到一個請求時,并不是馬上執行去 redis 執行 last 減 1 的操作,而是等待一段時間,例如 1 秒鐘同步一次,然后同步到 redis ,這樣就減少了同步的次數。
但是這種操作也會帶來一個新的問題。假如現在的限制是每秒允許 2 兩個請求,Node1 和 Node2 在一秒內同時來了兩個請求,因為還沒有到一秒,只是在本地計數,所以這 4 個請求被放過。當到了 1 秒的時候,去 redis 減值的時候,才會發現已經有 4 個請求被放過。不過這種可以通過限制它下一秒一個請求都不能被通過來補償。
當然這種情況要看你的容忍程度,這也算是一種解決方案,不過這種解決方案實現的還是比較少。Kong 算其中一個,它是基于 openresty 開發的一個網關產品,實現了我們講的定時同步,不需要實時同步 count 值的功能。還有一個是 Cloudflare,也是用滑動窗口去解決性能的問題,不過它沒有開源。
總結
-
漏桶跟令牌桶非常經典,大家可以找到自己熟悉的語言算法實現,限制比較準確。
-
固定窗口實現更簡單,無鎖,適于分布式,但是會有流量高峰的問題。在一些限制不需要那么平滑的的場景中可以使用,限制相對準確。
-
滑動窗口實現簡單,也適用于分布式,且不會有高峰流量的問題,但是限制會有偏差,如果要使用需要容忍限制偏差的問題。
以上是陳卓在又拍云 Open Talk 公開課上的主要分享內容,演講視頻和 PPT 詳見下方鏈接:
Open Talk 公開課
總結
- 上一篇: TCP 和 UDP,哪个更胜一筹
- 下一篇: 如何替公司省下数千万勒索费用