2019 年 1 月 12 日,由又拍雲、OpenResty 中國社區主辦的 OpenResty × Open Talk 全國巡回沙龍·深圳站圓滿結束,又拍雲首席架構師張聰在活動上做了《 OpenResty 動態流控的幾種姿勢 》的分享。
OpenResty x Open Talk 全國巡回沙龍是由 OpenResty 社區、又拍雲發起的,為促進 OpenResty 在技術圈的發展,增進 OpenResty 使用者的交流與學習的系列活動,活動將會陸續在深圳、北京、上海、廣州、杭州、成都、武漢等地舉辦,歡迎大家關注。
張聰,又拍雲首席架構師,多年 CDN 行業產品設計、技術開發和團隊管理相關經驗,個人技術方向集中在 Nginx、OpenResty 等高性能 Web 服務器方面,國內 OpenResty 技術早期推廣者之一;目前擔任又拍雲內容加速部技術負責人,主導又拍雲 CDN 技術平台的建設和發展。
以下是分享全文:
大家下午好,今天我主要和大家分享“在 OpenResty 上如何做動態的流量控制”,將會從以下幾個方面來介紹:
- Nginx 如何做流控,介紹幾種經典的速率和流量控制的指令和方法;
- OpenResty 如何動態化做流控;
- OpenResty 動態流控在又拍雲的業務應用。
又拍雲與 OpenResty 結緣
我目前在又拍雲負責 CDN 的架構設計和開發工作,又拍雲早在 2012 年就開始接觸 OpenResty ,當時我們做調研選型,部分項目考慮用 Lua 來實現,在此之前是基於 Nginx C 模塊來做業務開發,一個防盜鏈模塊就好幾千行代碼,改成 Lua 之后大量減少了代碼,並且整個開發的效率、維護的復雜度都降低了。此外我們通過測試和性能對比,幾乎沒有多的損耗,因為在這一層主要是字符串的處理,甚至在 LuaJIT 加速的情況下有很多的調用,比我們原先用 C 寫的函數還高效得多。
目前又拍雲整個 CDN 代理層系統、對外開放的 API 系統、數據中心的網關系統、分布式雲存儲代理層、邏輯層全部用 ngx_lua 進行了深度的改造,又拍雲內部幾個不同業務的團隊都在 OpenResty 技術棧上有多年的實踐和經驗積累。
又拍雲開放了一個 upyun-resty 的倉庫(https://github.com/upyun/upyun-resty),我們內部孵化的開源項目以及對社區的補丁修復等都會發布在這個倉庫。大家如果對又拍雲這塊的工作感興趣可以關注這個倉庫,我們今年還會陸續把內部使用非常成熟的一些庫放出來,包括今天講的兩個與限速有關的 Lua 庫也已經開源出來了。
什么是流控以及為什么要做流控
1、什么是流控
今天的主題,首先是針對應用層,尤其是 7 層的 HTTP 層,在業務流量進來的時候如何做流量的疏導和控制。我個人對“流控”的理解(針對應用層):
(1) 流控通常意義下是通過一些合理的技術手段對入口請求或流量進行有效地疏導和控制,從而使得有限資源的上游服務和整個系統能始終在健康的設計負荷下工作,同時在不影響絕大多數用戶體驗的情況下,整個系統的“利益”最大化。
因為后端資源有限,無論考慮成本、機器或者系統本身的瓶頸,不可能要求上游系統能夠承受突發的流量,而需要在前面做好流量的控制和管理。
有時候我們不得不犧牲少數的用戶體驗,拒絕部分請求來保證絕大多數的請求正常地服務,其實沒有完美能夠解決所有問題的方案,所以這個在流量控制中要結合我們對業務的理解需要學會做取舍。
(2) 流控有時候也是在考慮安全和成本時的一個手段。
除了上面的通用場景,流控也在安全和成本上做控制。比如敏感賬號的登錄頁面,密碼失敗次數多了就禁掉它,不允許反復暴力嘗試;比如我們的上游帶寬有限,需要確保傳輸的帶寬在較低的水平中進行,不要把線路跑滿,因為跑滿有可能涉及到一些成本超支的問題等。
2、為什么要流控
針對上面的描述,下面介紹一些流控跟速率限制的方法。
(1)為了業務數據安全,針對關鍵密碼認證請求進行有限次數限制,避免他人通過字典攻擊暴力破解。
為了數據安全,我們會對一些敏感的請求嘗試訪問做累計次數的限制,比如一定時間內你輸錯了三次密碼,接下來的幾個小時內就不讓你來嘗試了,這是一種很常見的手段。如果沒有這樣的保護,攻擊者會不斷試你的密碼,調用這個敏感的接口,最終可能會讓他試出來,所以這是需要保護的。
(2)在保障正常用戶請求頻率的同時,限制非正常速率的惡意 DDoS 攻擊請求,拒絕非人類訪問。
我們需要保障一個 API 服務正常的請求流量,但是拒絕完全惡意的 DDoS 的攻擊、大量非人類訪問。這也是在最前面這層需要做的事情,否則這些請求串進上游,很多后端的服務器肯定是抗不住的。
(3)控制上游應用在同一時刻處理的用戶請求數量,以免出現並發資源競爭導致體驗下降。
我們需要控制上游只能同時並發處理幾個任務或幾個請求,此時關心的是“同時”,因為它可能有內部的資源競爭,或者有一些沖突,必須保證這個服務“同時”只能滿足幾個用戶的處理。
(4)上游業務處理能力有限,如果某一時刻累計未完成任務超過設計最大容量,會導致整體系統出現不穩定甚至持續惡化,需要時刻保持在安全負荷下工作。
當我們整個上游系統的彈性伸縮能力還不錯,它會有一個設計好的最大容量空間,即最多累計能夠承受多大量的請求流入。如果超過它最大可處理范圍性能就會下降。例如一個任務系統每小時能夠完成 10 萬個任務,如果一個小時內任務沒有堆積超過 10 萬,它都能夠正常處理;但某一個小時出現了 20 萬請求,那它處理能力就會下降,它原本一小時能處理 10 萬,此時可能只能處理 5 萬或 2 萬甚至更少,性能變得很差,持續惡化,甚至最終導致崩潰。
因此,我們需要對這樣的流量進行疏導,確保后端系統能夠健康地運行,如果它每小時最多只能跑 10 萬的任務,那么無論多大的任務量,每小時最多都應只讓它跑 10 萬的量,而不是因為量超過了,反而最后連 10 萬都跑不到。
(5)集群模式下,負載均衡也是流控最基礎的一個環節,當然也有些業務無法精確進行前置負載均衡,例如圖片處理等場景就容易出現單點資源瓶頸,此時需要根據上游節點實時負載情況進行主動調度。
在做流量管理時,負載均衡是很基礎的。如果一個集群基本負載均衡都沒做好,流量還是偏的,上游某個節點很容易在集群中出現單點,這時去做流量控制就有點不合適。流量控制,首先在集群模式下要先做好負載均衡,在流量均衡的情況下再去做流量控制,識別惡意的流量。而不要前面的負載均衡都沒做好,流量都集中在某一台機器上,那你在這一台上去做控制,吃力不討好。
(6)在實際的業務運營中,往往出於成本考慮,還需要進行流量整形和帶寬控制,包括下載限速和上傳限速,以及在特定領域例如終端設備音視頻播放場景下,根據實際碼率進行針對性速率限制等。
出於成本的考慮,我們會對一些流量進行控制。比如下載限速是一個很常見的場景,終端用戶尤其是移動端,在進行視頻的播放,按正常的碼率播放已經足夠流暢。如果是家庭帶寬,下載速度很快,打開沒一會兒就把電影下載完成了,但實際上沒有必要,因為電影播放已經足夠流暢,一下子把它下載完浪費了很多流量。特別地對於音視頻的內容提供商,他覺得浪費了流量而且用戶體驗差不多,所以此時一般會對這些文件進行下載限速。
經典的 Nginx 方式實現流量控制

Nginx 大家都非常熟悉,特別是數據中心或后端的服務,不管是什么語言寫的,可能你也不明白為什么要這么做,但前面套一個 Nginx 總是讓人放心一點,因為在這么多年的發展中,Nginx 已經默默變成一個非常基礎可靠的反頂流量最外層入口的在向代理服務器,基本上很多開發者甚至感知不到它的存在,只知道運維幫忙前面架了一個轉發的服務。
所以,如果我們要做流量管理,應該盡量往前做,不要等流量轉發到后面,讓應用服務去做可能已經來不及了,應用服務只需要關心業務,這些通用的事情就讓上層的代理服務器完成。
1、Nginx 請求速率限制
(1)limit_req 模塊

△ ngx_http_limit_req_module
limit_req 是 Nginx 最常用的限速的模塊,上圖是一個簡單的配置,它基於來源 IP 作為唯一的 Key,針對某個唯一的來源 IP 做速率控制,這里的速率控制配置是 5r/s( 1 秒內允許 5 個請求進來),基於這個模塊的實現,再解釋一下 5r/s,即每隔 200ms 能夠允許進來一個請求,每個請求的間隔必須大於 200ms,如果小於 200ms 它就會幫你拒絕。
使用起來很簡單,配置一個共享內存,為了多個 worker 能共享狀態。具體可以在 location 做這樣的配置,配完之后就會產生限速的效果。

△ limit_req 請求示意圖
上圖可以更加直觀地了解這個機制,圖中整個灰色的時間條跨度是 1s,我按 200ms 切割成了五等份,時間條上面的箭頭代表一個請求。0s 的時候第一次請求過來直接轉發到后面去了;第二次間隔 200ms 過來的請求也是直接轉發到上游;第三個請求也一樣;第四個請求(第一個紅色箭頭)在 500ms 左右過來,它跟前一個請求的時間間隔只有 100ms,此時模塊就發揮作用,幫你拒絕掉,后面也是類似的。
總結一下 limit_req 模塊的特點:
- 針對來源IP,限制其請求速率為 5r/s。
- 意味着,相鄰請求間隔至少 200ms,否則拒絕。
但實際業務中,偶爾有些突增也是正常的。
這樣簡單地使用,很多時候在實際的業務中是用得不舒服的,因為實際業務中很多場景是需要有一些偶爾的突增的,這樣操作會過於敏感,一超過 200ms 就彈,絕大多數系統都需要允許偶爾的突發,而不能那么嚴格地去做速率限制。
(2)brust 功能參數
這樣就引出了 limit_req 模塊中的一個功能參數 brust(突發),為了方便演示,這里設置 brust=4,表示在超過限制速率 5r/s 的時候,同時最多允許額外有 4 個請求排隊等候,待平均速率回歸正常后,隊列最前面的請求會優先被處理。

△ brust=4,limit_req 請求示意圖
在 brust 參數的配合下,請求頻率限制允許一定程度的突發請求。設置為 4 次后,表示在超過 5r/s 的瞬間,本來要直接彈掉的請求,現在系統允許額外有 4 個位置的排隊等候,等到整體的平均速率回歸到正常后,排隊中的 4 個請求會挨個放進去。對於上游的業務服務,感知到的始終是 200ms 一個間隔進來一個請求,部分提前到達的請求在 Nginx 這側進行排隊,等到請求可以進來了就放進來,這樣就允許了一定程度的突發。
如上圖,時間條上面第四個請求跟第三個,間隔明顯是小於 200ms ,按原來的設置應該就直接拒絕了,但現在我們允許一定程度的突發,所以第四個請求被排隊了,等時間慢慢流轉到 600ms 的時候,就會讓它轉發給后端,實際它等待了 100ms。下面也是挨個進來排隊,第五個請求進來它排隊了 200ms,因為前面的時間片已經被第四個請求占用了,它必須等到下一個時間片才能轉發。可以看到上游服務接收到的請求間隔永遠是恆定的 200ms。
在已經存在 4 個請求同時等候的情況下,此時“立刻”過來的請求就會被拒絕。上圖中可以看到從第五個請求到第九個請求,一共排隊了 5 個請求,第十個請求才被拒絕。因為時間一直是在流動的,它整體是一個動態排隊的過程,解決了一定程度的突發,當然太多突發了還是會處理的。
雖然允許了一定程度的突發,但有些業務場景中,排隊導致的請求延遲增加是不可接受的,例如上圖中突發隊列隊尾的那個請求被滯后了 800ms 才進行處理。對於一些敏感的業務,我們不允許排隊太久,因為這些延時根本就不是在進行有效處理,它只是等候在 Nginx 這側,這時很多業務場景可能就接受不了,這樣的機制我們也需要結合新的要求再優化。但是如果你對延時沒有要求,允許一定的突發,用起來已經比較舒服了。
(3)nodealy 功能參數
limit_req 模塊引入了 nodelay 的功能參數,配合 brust 參數使用。nodelay 參數配合 brust=4 就可以使得突發時需要等待的請求立即得到處理,與此同時,模擬一個插槽個數為 4 的“令牌”隊列(桶)。

△ brust=4 配合 nodelay 的 limit_req 請求示意圖
本來突發的請求是需要等待的,有了 nodelay 參數后,原本需要等待的 4 個請求一旦過來就直接轉發給后端,落到后端的請求不會像剛剛那樣存在嚴格的 200ms 間隔,在比較短的時間內就會落下去,它實際上沒有在排隊,請求進來直接往上游就轉發,不過后續超出隊列突發的請求仍然是會被限制的。
為了能夠比較好理解這個場景,引入一個虛擬“桶”。從抽象的角度描述下這個過程,該“令牌”桶會每隔 200ms 釋放一個“令牌”,空出的槽位等待新的“令牌”進來,若桶槽位被填滿,隨后突發的請求就會被拒絕。
本來第六到第九這 4 個請求是排隊等候在 Nginx 一側,現在它們沒有等待直接下去了,可以理解為我們拿出了 4 個虛擬的令牌放入一個“桶”,4 個令牌模擬這 4 個請求在排隊。“桶”每隔 200ms 就會釋放出一個令牌,而一旦它釋放出一個,新的虛擬令牌就可以過來,如果它還沒釋放出,“桶”是滿的,這時請求過來還是會被拒絕。總而言之就是真實的請求沒有在排隊,而是引入了 4 個虛擬的令牌在排隊,在它滿的情況下是不允許其它請求進來。
如此,可以保證這些排隊的請求不需要消耗在無謂的等待上,可以直接進去先處理,而對於后面超過突發值的請求還是拒絕的。這樣就達到了折中,對於上游,它需要更短的時間間隔來處理請求,當然這需要結合業務來考慮,這里只是提供了一種方式和特定的案例。
總結,在這個模式下,在控制請求速率的同時,允許了一定程度的突發,並且這些突發的請求由於不需要排隊,它能夠立即得到處理,改善了延遲體驗。
(4)delay 功能參數
Nginx 最新的版本 1.15.7 增加了 delay 參數,**支持 delay=number 和 brust=number 參數配合使用。 **delay 也是一個獨立的參數,它支持 number(數量)的配置,和突發的數量配置是一樣的,這兩個參數解決的問題更加細致,通用場景中遇到的可能會少一點。
這個功能參數是這樣描述的:在有些特定場景下,我們既需要保障正常的少量關聯資源能夠快速地加載,同時也需要對於突發請求及時地進行限制,而 delay 參數能更精細地來控制這類限制效果。
比如網站的頁面,它下面有 4-6 個 JS 、CSS 文件,加載頁面時需要同時快速地加載完這幾個文件,才能確保整個頁面的渲染沒有問題。但如果同時超過十個並發請求在這個頁面上出現,那可能就會是非預期的突發,因為一個頁面總共才 4-6 個資源,如果刷一下同時過來的是 12 個請求,說明用戶很快地刷了多次。在這種情況下,業務上是要控制的,就可以引入了 delay 參數,它能夠更精細地來控制限制效果。
在上面的例子中,一個頁面並發加載資源加載這個頁面的時候會跑過來 4-6 個請求,某個用戶點一下頁面,服務端收到的是關於這個頁面的 4-6 個並發請求,返回給它;如果他很快地點了兩下,我們覺得需要禁止他很快地刷這個頁面刷兩次,就需要把超過並發數的這部分請求限制掉,但 burst 設置太小又擔心有誤傷,設置太大可能就起不到任何效果。
此時,我們可以配置一個策略,整體突發配置成 12,超過 12 個肯定是需要拒絕的。而在 12 范圍內,我們希望前面過來的 4-6 個並發請求能夠更快地加載,不要進行無效地等待,這里設置 delay=8 ,隊列中前 8 個等候的請求會直接傳給上游,而不會排隊,而第 8 個之后的請求仍然會排隊,但不會被直接拒絕,只是會慢一些,避免在這個尺度內出現一些誤傷,同時也起到了一定限制效果(增大時延)。
上面 4 點都是講 Nginx 怎么進行請求速率限制,簡單總結一下,速率就是針對連續兩個請求間的請求頻率的控制,包括允許一定程度的突發,以及突發排隊是否需要延后處理的優化,還有后面提到的 delay 和 brust 的配合使用。
2、Nginx 並發連接數限制

△ ngx_http_limit_conn_module
Nginx 有一個模塊叫 limit_conn,在下載的場景中,會出現幾個用戶同時在下載同一個資源,對於處理中的請求,該模塊是在讀完請求頭全部內容后才開始計數,比如同時允許在線 5 人下載,那就限制 5 個,超過的 503 拒絕。特別地,在 HTTP/2 和 SPDY 協議下,每一個並發請求都會當作一個獨立的計數項。
3、Nginx 下載帶寬限制

△ ngx_http_core_module
在 ngx_http_core_module 模塊里面有 limit_rate_after 和 limit_rate 參數,這個是下載帶寬限制。如上圖,意思是在下載完前面 500KB 數據后,對接下來的數據以每秒 20KB 速度進行限制,這個在文件下載、視頻播放等業務場景中應用比較多,可以避免不必要的浪費。例如視頻播放,第一個畫面能夠盡快看到,對用戶體驗來說很重要,如果用戶第一個頁面看不到,那他的等待忍耐程度是很差的,所以這個場景下前面的幾個字節不應該去限速,在看到第一個畫面之后,后面畫面是按照一定視頻碼率播放,所以沒必要下載很快,而且快了也沒用,它照樣是流暢的,但卻多浪費了流量資源,如果用戶看到一半就關掉,整個視頻下載完成,對於用戶和內容提供商都是資源浪費。
OpenResty 動態流控
相比 Nginx ,OpenResty 具有很多的優勢。
- 我們需要更加豐富的流控策略!Nginx 只有經典的幾種。
- 我們需要更加靈活的配置管理!限速的策略配置規則是多樣化的,我們需要更加靈活。
- 我們需要在 Nginx 請求生命周期的更多階段進行控制!前面提到的的 limit_req 模塊,它只能在 PREACCESS 階段進行控制,我們可能需要在 SSL 的卸載過程中對握手的連接頻率進行控制,我們也可能需要在其它任意階段進行請求頻率控制,那 Nginx 這個模塊就做不到了。
- 我們需要跨機器進行狀態同步!
請求速率限制 / 並發連接數限制
OpenResty 官方有一個叫做 lua-resty-limit-traffic 的模塊,里面有三種限速的策略。
(1) resty.limit.req 模塊

△ lua-resty-limit-traffic (resty.limit.req)
resty.limit.req 模塊的設計與 NGINX limit_req 實現的效果和功能一樣,當然它用 Lua 來表達限速邏輯,可以在任何的代碼里面去引入,幾乎可以在任意上下⽂中使⽤。
(2)resty.limit.conn 模塊
功能和 NGINX limit_conn 一致,但 Lua 版本允許突發連接進行短暫延遲等候。
(3)resty.limit.count 模塊

△ lua-resty-limit-traffic (resty.limit.count)
第三個是 resty.limit.count 模塊,請求數量限制,這個目前 Nginx 沒有,用一句話概括這個模塊,就是在單位時間內確保累計的請求數量不超過一個最大的值。比如在 1 分鍾之內允許累計有 100 個請求,累計超過 100 就拒絕。這個模塊和 Github API Rate Limiting(https://developer.github.com/v3/#rate-limiting)的接口設計類似,也是一個比較經典的限制請求的方式。
跨機器速率限制

△ lua-resty-redis-ratelimit (resty.redis.ratelimit)
有了 OpenResty,可以做一些更加有意思的事情。比如我們有多台機器,想把限制的狀態共享,又拍雲之前開放了一個簡單的模塊叫 lua-resty-redis-ratelimit(resty.redis.ratelimit),顧名思義就是把這個狀態扔到 Redis 保存。它和 Nginx limit req 以及 resty.limit.req 一樣,都是基於漏桶算法對平均請求速率進行限制。不同的是,該模塊將信息保存在 Redis 從而實現多 Nginx 實例狀態共享。
借助於 Redis Lua Script 機制 ,Redis 有一個支持寫 Lua 腳本的功能,這個腳本能夠讓一些操作在 Redis 執行的時候保證原子性,依賴這個機制,我們把一次狀態的變更用 Lua Script 就能夠完全原子性地在 Redis 里面做完。
同時,該模塊支持在整個集群層⾯禁⽌某個非法⽤用戶一段時間,可實現全局自動拉⿊功能。因為是全局共享,一旦全網有一個客戶觸發了設置的請求頻率限制,我們可以在整個集群內瞬間把他拉黑幾個小時。
當然這個模塊是有代價的,而且代價也比較大,因為 Nginx 和 Redis 交互需要網絡 IO,會帶來一定延遲開銷,僅適合請求量不大,但需要非常精確限制全局請求速率或單位統計時間跨度非常大的場景。
當然,這個模塊也可以做一些自己的優化,不一定所有的狀態都需要跟 Redis 同步,可以根據自己的業務情況做一些局部計算,然后定時做全局同步,犧牲一些精確性和及時性,這些都可以去抉擇,這邊只是多提供了一個手段。
知識點-漏桶算法

△ 漏桶算法示意圖
前面提到的多個模塊都是基於漏桶算法的思想達到頻率限速的效果,如上圖,一個水桶,水滴一滴一滴往下滴,我們希望水往下滴的速度盡可能是恆定的,這樣下游能夠承載的處理能力是比較健康的,不要一下子桶就漏了一個大洞沖下去,希望它均衡地按序地往下滴,同時前面會有源源不斷的水進來。

這個漏桶算法思想的核心就是上圖中這個簡單的公式,我們怎么把請求的 5r/s,即每 200ms 一個請求的頻次限制代到這個公式呢?
首先,在具體實現中,一般定義最小速率為 0.001r/s,即最小的請求刻度是 0.001 個請求,為了直觀計算,我們用 1 個水滴(假設單位t)來表達 0.001 個請求,那么 rate=5r/s 相當於 5000t/s。
前面提到該算法是計算兩個相鄰請求的頻率,所以要計算當前請求和上一個請求的時間間隔,假設是 100 ms,單位是毫秒,下面公式中除以 1000 轉換成秒等於 0.1s,即 0.1s 能夠往下滴 500 個水滴,因為速率是 5000t/s,時間過去了 0.1 秒,當然只滴下去 500 滴水。
500 水滴下去的同時,速率一直是恆定的,但是同時又有請求進來,因為新的請求進來才會去計算這個公式,所以后面加了 1000,1000 個水滴代表當前這一個請求。就可以計算出當前桶的剩余水滴數。
excess 表示上一次超出的水滴數(延遲通過),一開始是 0 。特別地,如果 excess<0,說明這個桶空了,就會把 excess 重置為 0 ;如果 excess>0,說明這個桶有水滴堆積,這時水滴的流入速度比它的流出速度快了,返回 BUSY,表示繁忙。通過這樣動態的標記就可以把這個速率給控制起來。
前面提到的突發,只要把這里的 0 換成 4 ,就是允許一定程度的突發了。
令牌桶限速
令牌桶和漏桶從一些特殊的角度(特別是從效果)上是有一些相似的,但是它們在設計思想上有比較明顯的差異。

△ 令牌桶
令牌桶是指令牌以一定的速率往桶里進令牌,進來的請求是恆定的速率來補充這個桶,只要桶沒有滿就可以一直往里面放,如果是補充滿了就不會再補充了。每處理一個請求就從令牌桶拿出一塊,如果沒有令牌可以拿那么請求就無法往下走。

△ lua-resty-limit-rate (resty.limit.rate)
lua-resty-limit-rate(resty.limit.rate)是又拍雲最近開源的一個庫,基於令牌桶實現。
上圖是個簡化的演示,首先申請兩個令牌桶,一個是全局的令牌桶,一個是針對某個用戶的令牌桶,因為系統內肯定有很多用戶調用,全局是一個桶,每個用戶是一個桶,可以做一個組合的設置。如果全局的桶沒有滿,單個用戶超過了用戶單獨的頻次限制,我們一般會允許其突發,后端對於處理 A 用戶、B 用戶的消耗一般是相同的,只是業務邏輯上分了 A 用戶和 B 用戶。
因此,整體容量沒有超過限制,單個用戶即便超過了他的限制配置,也允許他突發。只有全局桶拿不出令牌,此時再來判斷每個用戶的桶,看是否可以拿出令牌,如果它拿不出來了就拒絕掉。此時整體系統達到瓶頸,為了用戶體驗,我們不可能無差別地去彈掉任意用戶的請求,而是挑出當前突發較大的用戶將其請求拒絕而保障其他正常的用戶請求不受任何影響,這是基於用戶體驗的角度來考慮限速的方案配置。
相比 limit.req 基於漏桶的設計,令牌桶的思想更關注容量的變化,而非相鄰請求間的速率的限制,它適合有一定彈性容量設計的系統,只有在全局資源不夠的時候才去做限制,而非兩個請求之間頻率超了就限制掉,速率允許有較⼤大的波動。
相比 limit.count 對單位窗口時間內累計請求數量進行限制,該模塊在特定配置下,也能達到類似效果,並且能避免在單位時間窗口切換瞬間導致可能雙倍的限制請求情況出現。 limit.count 模塊在單位時間內,比如在 1 分鍾內限制 100 次,在下一個 1 分鍾統計時,上一個 1 分鍾統計的計數是清零的,固定的時間窗口在切換的時候,在這個切換的瞬間,可能前 1 分鍾的最后 1 秒上來了 99 個請求,下一個 1 分鍾的第 1 秒上來 99 個請求,在這 2 秒內,它超過了設計的單位時間最多 100 個請求的限制,它的切換瞬間會有一些邊界的重疊。而基於令牌桶后,因為它的流入流出有一個桶的容量在保護,所以它切換是比較平滑的,流入速度和流出速度中間有一個緩沖。
除了請求速率限制(一個令牌一個請求),還能夠對字節傳輸進行流量整形,此時,一個令牌相當於一個字節。因為流量都是由一個個字節組成的。如果把字節變成令牌,那流量的流出流入也可以通過令牌桶來給流量做一些整形。整形就是流量按你期望設計的形狀帶寬(單位時間內的流量)進行傳輸。
OpenResty 動態流控在又拍雲的業務應用
- 海外代理進行上傳流量整形,避免跑滿傳輸線路帶寬(流量整形);
- 某 API 請求基於令牌桶針對不同賬戶進行請求速率控制(令牌桶應用);
- CDN 特性:IP 訪問限制,支持階梯策略升級(IP訪問限制);
- CDN 特性:碼率適配限速
又拍雲和 KONG

KONG 是一個非常著名的 OpenResty 的應用,又拍雲在 2018 年在網關層引入了 KONG ,內部也維護了一個 KONG 的 Fork 版本,做了一些插件的改造和適配。
流量整形
我們在 KONG 上怎么去做流量呢?因為香港到國內數據中心的傳輸線路價格非常昂貴,我們購買線路帶寬是有一定限制的。但是我們在這條線路傳輸有很多 API ,如果有一個 API 突發流量,就會影響到其他,所以我們在 KONG 上做了改造。

KONG 的設計不允許管控請求的 socket 字節流,也是用 Nginx 的核心模塊來轉發字節流,我們需要去管控所有從 req socket 進來的字節流,因為要做字節流限制,所以我們這里用純 Lua 接管了。
Lua 接管之后,可以看到每 8192 個字節,都會拿 8192 個令牌,如果能拿出來,就讓這 8192 個字節往后端傳;如果拿不出來,說明當時已經往后傳太多字節了,就讓它等一等,起到一些限制效果。
令牌桶應用

我們在某一個 API 系統中用令牌桶怎么做策略的限制呢?上圖是一個簡單的配置示例,我們針對全局有一個桶,一個令牌的添加速度是 40r/s,令牌的容量是 12000,每次是 4 個令牌一起添加,這是全局桶的策略;每個用戶空間的策略是:桶的容量是 6000,每次 2 個令牌一起添加,它的限制大概是 10r/s ;對於一些特殊的操作,比如 delete,我們會限制得更加嚴格一點,引入了第三個,專門針對 delete 操作的桶。
所以這里可以有好多桶來配合,全局的,局部的以及特殊的操作,大家的限制等級都不太一樣,策略都可以靈活去配置。

△ 限制效果圖
上圖是我們實際的限制效果,藍色部分是通過令牌桶屏蔽掉的,綠色的是健康的,這部分被彈的,看業務數據的話,不是任意空間被彈掉,它被彈的時候都是那么幾個空間被彈掉,會比較集中那幾個空間,特別出頭的被彈掉。而不是說一大堆的空間,甚至請求流量很小的,你隨機去彈幾個。肯定要挑出那些搗亂的把它彈掉,從而保護整個后端的請求能維持在一個健康的水位下。
IP 訪問限制

△ IP 訪問限制
又拍雲的產品中有一個 IP 訪問的限制的功能,針對單位時間內的 IP 進行頻率的保護。當你的網站或者靜態資源被一些惡意的 IP 瘋狂下載,浪費你很多流量的時候是有幫助的。而且我們支持階梯的配置,達到第一個階梯禁止多少時間,如果繼續達到第二個階梯,階梯升級禁用的力度就會更大。
碼率適配限速

△ 碼率適配限速
針對視頻播放,我們需要對碼率進行適配。這個功能可以動態讀取 MP4 的元數據,讀到它的碼率情況,從而做出相應的下載帶寬控制的策略,使得這個文件在播放的時候看到的是很流暢的,用戶體驗沒有受到任何影響,但是不會因為客戶端網速較快而多浪費流量資源。這是下載帶寬限速,結合實際應用的一個例子。
分享視頻及PPT可前往: