Nginx限速模塊初探

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

核心算法

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

漏桶算法(leaky bucket)

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

一個形象的解釋是:git

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

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














令牌桶算法(token bucket)

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

令牌桶算法

算法思想是:shell

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

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

從做用上來講,漏桶和令牌桶算法最明顯的區別就是是否容許突發流量(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算法實現。爲深刻了解這個模塊,咱們先從實驗現象提及。開始以前咱們先簡單介紹一下該模塊的配置方式,如下面的配置爲例:

http {
    limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
    ...
    server {
        ...
        location /search/ {
            limit_req zone=mylimit burst=4 nodelay;
        }

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

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

實驗1——毫秒級統計

咱們有以下配置:

...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server { 
    location / { 
        limit_req zone=mylimit;
    }
}
...

上述規則限制了每一個IP訪問的速度爲2r/s,並將該規則做用於跟目錄。若是單個IP在很是短的時間內併發發送多個請求,結果會怎樣呢?

# 單個IP 10ms內併發發送6個請求
send 6 requests in parallel, time cost: 2 ms
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 200 OK
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 503 Service Temporarily Unavailable
end, total time cost: 461 ms

咱們使用單個IP在10ms內發併發送了6個請求,只有1個成功,剩下的5個都被拒絕。咱們設置的速度是2r/s,爲何只有1個成功呢,是否是Nginx限制錯了?固然不是,是由於Nginx的限流統計是基於毫秒的,咱們設置的速度是2r/s,轉換一下就是500ms內單個IP只容許經過1個請求,從501ms開始才容許經過第二個請求。

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

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

來看咱們的配置:

...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server { 
    location / { 
        limit_req zone=mylimit burst=4;
    }
}
...

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

# 單個IP 10ms內發送6個請求,設置burst
send 6 requests in parallel, time cost: 2 ms
HTTP/1.1 200 OK
HTTP/1.1 503 Service Temporarily Unavailable
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
end, total time cost: 2437 ms

相比實驗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選項:

...
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server { 
    location / { 
        limit_req zone=mylimit burst=4 nodelay;
    }
}
...

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

# 單個IP 10ms內發送6個請求
   實驗3, 設置burst和nodelay       |  實驗2, 只設置burst
send 6 requests, time cost: 4 ms |  time cost: 2 ms
HTTP/1.1 200 OK                  |  HTTP/1.1 200 OK
HTTP/1.1 200 OK                  |  HTTP/1.1 503 ...
HTTP/1.1 200 OK                  |  HTTP/1.1 200 OK
HTTP/1.1 200 OK                  |  HTTP/1.1 200 OK
HTTP/1.1 503 ...                 |  HTTP/1.1 200 OK
HTTP/1.1 200 OK                  |  HTTP/1.1 200 OK
total time cost: 465 ms          |  total time cost: 2437 ms

跟實驗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,900多好代碼可謂短小精悍。相關代碼有兩個核心數據結構:

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

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

typedef struct {
    ngx_rbtree_t                  rbtree; /* red-black tree */
    ngx_rbtree_node_t             sentinel; /* the sentinel node of red-black tree */
    ngx_queue_t                   queue; /* used to expire info(LRU algorithm) */
} 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_http_limit_req_lookup(...){
  while (node != sentinel) {
    // search rbtree
    if (hash < node->key) { node = node->left; continue;} // 1. 從根節點開始查找紅黑樹
    if (hash > node->key) { node = node->right; continue;}
    rc = ngx_memn2cmp(key->data, lr->data, key->len, (size_t) lr->len);
    if (rc == 0) {// found
      ngx_queue_remove(&lr->queue); // 2. 修改該點在LRU隊列中的位置,表示該點最近被訪問過
      ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);// 2
      ms = (ngx_msec_int_t) (now - lr->last);
      excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000; // 3. 執行漏桶算法
      if (excess < 0) 
        excess = 0;
      if ((ngx_uint_t) excess > limit->burst)
        return NGX_BUSY; // 超過了突發門限,拒絕
      if (account) {// 是不是最後一條規則
        lr->excess = excess;    
        lr->last = now;    
        return NGX_OK; // 未超過限制,經過
      }
      ...
      return NGX_AGAIN; // 6. 執行下一條限流規則
    }
    node = (rc < 0) ? node->left : node->right; // 1
  } // while
  ...
  // not found
  ngx_http_limit_req_expire(ctx, 1); // 4. 根據LRU淘汰,騰出空間
  node = ngx_slab_alloc_locked(ctx->shpool, size); // 5. 生成新的紅黑樹節點
  ngx_rbtree_insert(&ctx->sh->rbtree, node);// 5. 插入該節點,從新平衡紅黑樹
  ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);
  if (account) {    
    lr->last = now; 
    lr->count = 0;
    return NGX_OK;
  }
  ...
  return NGX_AGAIN; // 6. 執行下一條限流規則
}

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

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

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

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

LRU是如何實現的

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

ngx_queue_remove(&lr->queue); // 2. 修改該點在LRU隊列中的位置,表示該點最近被訪問過
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);// 2
...
ngx_http_limit_req_expire(ctx, 1); // 4. 根據LRU淘汰,騰出空間

漏桶算法是如何實現的

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

excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000; // 3. 執行漏桶算法
if (excess < 0) 
    excess = 0;
if ((ngx_uint_t) excess > limit->burst)
    return NGX_BUSY; // 超過了突發門限,拒絕
if (account) { // 是不是最後一條規則
    lr->excess = excess;    
    lr->last = now;    
    return NGX_OK; // 未超過限制,經過
}
...
return NGX_AGAIN; // 6. 執行下一條限流規則

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

咱們看到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開發從入門到精通

相關文章
相關標籤/搜索