Nginx限速模塊初探

Nginx限速模塊分為哪幾種?按請求速率限速的burst和nodelay參數是什麼意思?漏桶演算法和令牌桶演算法究竟有什麼不同?本文將帶你一探究竟。我們會通過一些簡單的示例展示Nginx限速模塊是如何工作的,然後結合代碼講解其背後的演算法和原理。

核心演算法

在探究Nginx限速模塊之前,我們先來看看網路傳輸中常用兩個的流量控制演算法:漏桶演算法令牌桶演算法。這兩隻「桶」到底有什麼異同呢?

漏桶演算法(leaky bucket)

漏桶演算法(leaky bucket)演算法思想如圖所示:

一個形象的解釋是:

  • 水(請求)從上方倒入水桶,從水桶下方流出(被處理);
  • 來不及流出的水存在水桶中(緩衝),以固定速率流出;
  • 水桶滿後水溢出(丟棄)。

這個演算法的核心是:緩存請求、勻速處理、多餘的請求直接丟棄。

令牌桶演算法(token bucket)

令牌桶(token bucket)演算法思想如圖所示:

演算法思想是:

  • 令牌以固定速率產生,並緩存到令牌桶中;
  • 令牌桶放滿時,多餘的令牌被丟棄;
  • 請求要消耗等比例的令牌才能被處理;
  • 令牌不夠時,請求被緩存。

相比漏桶演算法,令牌桶演算法不同之處在於它不但有一隻「桶」,還有個隊列,這個桶是用來存放令牌的,隊列才是用來存放請求的。

從作用上來說,漏桶和令牌桶演算法最明顯的區別就是是否允許突發流量(burst)的處理,漏桶演算法能夠強行限制數據的實時傳輸(處理)速率,對突發流量不做額外處理;而令牌桶演算法能夠在限制數據的平均傳輸速率的同時允許某種程度的突發傳輸

Nginx按請求速率限速模塊使用的是漏桶演算法,即能夠強行保證請求的實時處理速度不會超過設置的閾值。

Nginx限速模塊

Nginx主要有兩種限速方式:按連接數限速(ngx_http_limit_conn_module)、按請求速率限速(ngx_http_limit_req_module)。我們著重講解按請求速率限速。

按連接數限速

按連接數限速是指限制單個IP(或者其他的key)同時發起的連接數,超出這個限制後,Nginx將直接拒絕更多的連接。這個模塊的配置比較好理解,詳見ngx_http_limit_conn_module官方文檔。

按請求速率限速

按請求速率限速是指限制單個IP(或者其他的key)發送請求的速率,超出指定速率後,Nginx將直接拒絕更多的請求。採用leaky bucket演算法實現。為深入了解這個模塊,我們先從實驗現象說起。開始之前我們先簡單介紹一下該模塊的配置方式,以下面的配置為例:

使用limit_req_zone關鍵字,我們定義了一個名為mylimit大小為10MB的共享內存區域(zone),用來存放限速相關的統計信息,限速的key值為二進位的IP地址($binary_remote_addr),限速上限(rate)為2r/s;接著我們使用limit_req關鍵字將上述規則作用到/search/上。burstnodelay的作用稍後解釋。

使用上述規則,對於/search/目錄的訪問,單個IP的訪問速度被限制在了2請求/秒,超過這個限制的訪問將直接被Nginx拒絕。

實驗1——毫秒級統計

我們有如下配置:

上述規則限制了每個IP訪問的速度為2r/s,並將該規則作用於跟目錄。如果單個IP在非常短的時間內並發發送多個請求,結果會怎樣呢?

我們使用單個IP在10ms內發並發送了6個請求,只有1個成功,剩下的5個都被拒絕。我們設置的速度是2r/s,為什麼只有1個成功呢,是不是Nginx限制錯了?當然不是,是因為Nginx的限流統計是基於毫秒的,我們設置的速度是2r/s,轉換一下就是500ms內單個IP只允許通過1個請求,從501ms開始才允許通過第二個請求。

實驗2——burst允許緩存處理突發請求

實驗1我們看到,我們短時間內發送了大量請求,Nginx按照毫秒級精度統計,超出限制的請求直接拒絕。這在實際場景中未免過於苛刻,真實網路環境中請求到來不是勻速的,很可能有請求「突發」的情況,也就是「一股子一股子」的。Nginx考慮到了這種情況,可以通過burst關鍵字開啟對突發請求的緩存處理,而不是直接拒絕。

來看我們的配置:

我們加入了burst=4,意思是每個key(此處是每個IP)最多允許4個突發請求的到來。如果單個IP在10ms內發送6個請求,結果會怎樣呢?

相比實驗1成功數增加了4個,這個我們設置的burst數目是一致的。具體處理流程是:1個請求被立即處理,4個請求被放到burst隊列里,另外一個請求被拒絕。通過burst參數,我們使得Nginx限流具備了緩存處理突發流量的能力

但是請注意:burst的作用是讓多餘的請求可以先放到隊列里,慢慢處理。如果不加nodelay參數,隊列里的請求不會立即處理,而是按照rate設置的速度,以毫秒級精確的速度慢慢處理。

實驗3——nodelay降低排隊時間

實驗2中我們看到,通過設置burst參數,我們可以允許Nginx緩存處理一定程度的突發,多餘的請求可以先放到隊列里,慢慢處理,這起到了平滑流量的作用。但是如果隊列設置的比較大,請求排隊的時間就會比較長,用戶角度看來就是RT變長了,這對用戶很不友好。有什麼解決辦法呢?nodelay參數允許請求在排隊的時候就立即被處理,也就是說只要請求能夠進入burst隊列,就會立即被後台worker處理,請注意,這意味著burst設置了nodelay時,系統瞬間的QPS可能會超過rate設置的閾值。nodelay參數要跟burst一起使用才有作用。

延續實驗2的配置,我們加入nodelay選項:

單個IP 10ms內並發發送6個請求,結果如下:

跟實驗2相比,請求成功率沒變化,但是總體耗時變短了。這怎麼解釋呢?實驗2中,有4個請求被放到burst隊列當中,工作進程每隔500ms(rate=2r/s)取一個請求進行處理,最後一個請求要排隊2s才會被處理;實驗3中,請求放入隊列跟實驗2是一樣的,但不同的是,隊列中的請求同時具有了被處理的資格,所以實驗3中的5個請求可以說是同時開始被處理的,花費時間自然變短了。

但是請注意,雖然設置burst和nodelay能夠降低突發請求的處理時間,但是長期來看並不會提高吞吐量的上限,長期吞吐量的上限是由rate決定的,因為nodelay只能保證burst的請求被立即處理,但Nginx會限制隊列元素釋放的速度,就像是限制了令牌桶中令牌產生的速度。

看到這裡你可能會問,加入了nodelay參數之後的限速演算法,到底算是哪一個「桶」,是漏桶演算法還是令牌桶演算法?當然還算是漏桶演算法。考慮一種情況,令牌桶演算法的token為耗盡時會怎麼做呢?由於它有一個請求隊列,所以會把接下來的請求緩存下來,緩存多少受限於隊列大小。但此時緩存這些請求還有意義嗎?如果server已經過載,緩存隊列越來越長,RT越來越高,即使過了很久請求被處理了,對用戶來說也沒什麼價值了。所以當token不夠用時,最明智的做法就是直接拒絕用戶的請求,這就成了漏桶演算法,哈哈~

源碼剖析

經過上面的示例,我們隊請求限速模塊有了一定的認識,現在我們深入剖析代碼實現。按請求速率限流模塊ngx_http_limit_req_module代碼位於[src/http/modules/ngx_http_limit_req_module.c

](github.com/nginx/nginx/,900多好代碼可謂短小精悍。相關代碼有兩個核心數據結構:

  1. 紅黑樹:通過紅黑樹記錄每個節點(按照聲明時指定的key)的統計信息,方便查找;
  2. LRU隊列:將紅黑樹上的節點按照最近訪問時間排序,時間近的放在隊列頭部,以便使用LRU隊列淘汰舊的節點,避免內存溢出。

這兩個關鍵對象存儲在ngx_http_limit_req_shctx_t中:

其中除了rbtree和queue之外,還有一個叫做sentinel的變數,這個變數用作紅黑樹的NIL節點。

該模塊的核心邏輯在函數ngx_http_limit_req_lookup()中,這個函數主要流程是怎樣呢?對於每一個請求:

  1. 從根節點開始查找紅黑樹,找到key對應的節點;
  2. 找到後修改該點在LRU隊列中的位置,表示該點最近被訪問過;
  3. 執行漏桶演算法;
  4. 沒找到時根據LRU淘汰,騰出空間;
  5. 生成並插入新的紅黑樹節點;
  6. 執行下一條限流規則。

流程很清晰,但是代碼中牽涉到紅黑樹、LRU隊列等高級數據結構,是不是會寫得很複雜?好在Nginx作者功力深厚,代碼寫得簡潔易懂,哈哈~

代碼有三種返回值,它們的意思是:

  • NGX_BUSY 超過了突發門限,拒絕
  • NGX_OK 未超過限制,通過
  • NGX_AGAIN 未超過限制,但是還有規則未執行,需執行下一條限流規則

上述代碼不難理解,但我們還有幾個問題:

  1. LRU是如何實現的?
  2. 漏桶演算法是如何實現的?
  3. 每個key相關的burst隊列在哪裡?

LRU是如何實現的

LRU演算法的實現很簡單,如果一個節點被訪問了,那麼就把它移到隊列的頭部,當空間不足需要淘汰節點時,就選出隊列尾部的節點淘汰掉,主要體現在如下代碼中:

漏桶演算法是如何實現的

漏桶演算法的實現也比我們想像的簡單,其核心是這一行公式excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000,這樣代碼的意思是:excess表示當前key上遺留的請求數,本次遺留的請求數 = 上次遺留的請求數 - 預設速率 X 過去的時間 + 1。這個1表示當前這個請求,由於Nginx內部表示將單位縮小了1000倍,所以1個請求要轉換成1000。

上述代碼受限算出當前key上遺留的請求數,如果超過了burst,就直接拒絕;由於Nginx允許多條限速規則同時起作用,如果已是最後一條規則,則允許通過,否則執行下一條規則。

單個key相關的burst隊列在哪裡

沒有單個key相關的burst隊列。上面代碼中我們看到當到達最後一條規則時,只要excess<limit->burst限速模塊就會返回NGX_OK,並沒有把多餘請求放入隊列的操作,這是因為Nginx是基於timer來管理請求的,當限速模塊返回NGX_OK時,調度函數會計算一個延遲處理的時間,同時把這個請求放入到共享的timer隊列中(一棵按等待時間從小到大排序的紅黑樹)。

ngx_http_limit_req_handler(ngx_http_request_t *r)n{n ...n for (n = 0; n < lrcf->limits.nelts; n++) {n ...n ngx_shmtx_lock(&ctx->shpool->mutex);// 獲取鎖n rc = ngx_http_limit_req_lookup(limit, hash, &key, &excess, // 執行漏桶演算法n (n == lrcf->limits.nelts - 1));n ngx_shmtx_unlock(&ctx->shpool->mutex);// 釋放鎖n ...n if (rc != NGX_AGAIN)n break;n }n ...n delay = ngx_http_limit_req_account(limits, n, &excess, &limit);// 計算當前請求需要的延遲時間n if (!delay) {n return NGX_DECLINED;// 不需要延遲,交給後續的handler進行處理n }n ...n ngx_add_timer(r->connection->write, delay);// 否則將請求放到定時器隊列里n return NGX_AGAIN; // the request has been successfully processed, the request must be suspended until some event. http://www.nginxguts.com/2011/01/phases/n}n

我們看到ngx_http_limit_req_handler()調用了函數ngx_http_limit_req_lookup(),並根據其返回值決定如何操作:或是拒絕,或是交給下一個handler處理,或是將請求放入定期器隊列。當限速規則都通過後,該hanlder通過調用函數ngx_http_limit_req_account()得出當前請求需要的延遲時間,如果不需要延遲,就將請求交給後續的handler進行處理,否則將請求放到定時器隊列里。注意這個定時器隊列是共享的,並沒有為單獨的key(比如,每個IP地址)設置隊列。關於handler模塊背景知識的介紹,可參考Tengine團隊撰寫的Nginx開發從入門到精通

關於按請求速率限速的原理講解,可參考Rate Limiting with NGINX and NGINX Plus,關於源碼更詳細的解析可參考ngx_http_limit_req_module 源碼分析以及y123456yz的Nginx源碼分析的git項目

結尾

本文主要講解了Nginx按請求速率限速模塊的用法和原理,其中burst和nodelay參數是容易引起誤解的,雖然可通過burst允許緩存處理突發請求,結合nodelay能夠降低突發請求的處理時間,但是長期來看他們並不會提高吞吐量的上限,長期吞吐量的上限是由rate決定的。需要特別注意的是,burst設置了nodelay時,系統瞬間的QPS可能會超過rate設置的閾值。

本文只是對Nginx管中窺豹,更多關於Nginx介紹的文章,可參考Tengine團隊撰寫的Nginx開發從入門到精通。

作者:a小滑鼠

原文

更多技術乾貨敬請關注云棲社區知乎機構號:阿里云云棲社區 - 知乎

推薦閱讀:

Python黑帽編程2.6 模塊

TAG:Nginx | 算法 | 模块 |