網關 rate limit 網絡速率限制方案

網關 rate limit 網絡速率限制方案

一、網絡限流算法

    在計算機領域中,限流技術(time limiting)被用來控制網絡接口收發通訊數據的速率。用這個方法來優化性能、較少延遲和提高帶寬等。
    在互聯網領域中也借鑑了這個概念,用來控制網絡請求的速率,在高併發,大流量的場景中,比如雙十一秒殺、搶購、搶票、搶單等場景。
    網絡限流主流的算法有兩種,分別是漏桶算法和令牌桶算法。接下來我們一一爲大家介紹:

1. 漏桶算法

leaky-bucket

描述:漏桶算法思路很簡單,水(數據或者請求)先進入到漏桶裏,漏桶以一定的速度出水,當水流入速度過大會直接溢出,可以看出漏桶算法能強行限制數據的傳輸速率。

實現邏輯: 控制數據注入到網絡的速率,平滑網絡上的突發流量。漏桶算法提供了一種機制,通過它,突發流量可以被整形以便爲網絡提供一個穩定的流量。 漏桶可以看作是一個帶有常量服務時間的單服務器隊列,如果漏桶(包緩存)溢出,那麼數據包會被丟棄。

優缺點:在某些情況下,漏桶算法不能夠有效地使用網絡資源。因爲漏桶的漏出速率是固定的參數,所以,即使網絡中不存在資源衝突(沒有發生擁塞),漏桶算法也不能使某一個單獨的流突發到端口速率。因此,漏桶算法對於存在突發特性的流量來說缺乏效率。而令牌桶算法則能夠滿足這些具有突發特性的流量。通常,漏桶算法與令牌桶算法可以結合起來爲網絡流量提供更大的控制。

2. 令牌桶算法

token-bucket

實現邏輯:令牌桶算法的原理是系統會以一個恆定的速度往桶裏放入令牌,而如果請求需要被處理,則需要先從桶裏獲取一個令牌,當桶裏沒有令牌可取時,則拒絕服務。 令牌桶的另外一個好處是可以方便的改變速度。 一旦需要提高速率,則按需提高放入桶中的令牌的速率。 一般會定時(比如100毫秒)往桶中增加一定數量的令牌, 有些變種算法則實時的計算應該增加的令牌的數量, 比如華爲的專利"採用令牌漏桶進行報文限流的方法"(CN 1536815 A),提供了一種動態計算可用令牌數的方法, 相比其它定時增加令牌的方法, 它只在收到一個報文後,計算該報文與前一報文到來的時間間隔內向令牌漏桶內注入的令牌數, 並計算判斷桶內的令牌數是否滿足傳送該報文的要求。

二、常見的 Rate limiting 實現方式

通常意義上的限速,其實可以分爲以下三種:

  • limit_rate 限制響應速度
  • limit_conn 限制連接數
  • limit_req 限制請求數

1. Nginx 模塊 (漏桶)

ngx_http_limit_req_module模塊(0.7.21)用於限制每個定義鍵的請求處理速度,特別是來自單個IP地址的請求的處理速度。

1.1 Example Configuration

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

    ...

    server {

        ...

        location /search/ {
            limit_req zone=one burst=5;
        }

1.2 使用規則

語法:    limit_req zone=name [burst=number] [nodelay | delay=number];
默認:    —
作用範圍:    http, server, location

參數說明

  • zone 設置內存名稱和內存大小。
  • burst 漏桶的突發大小。當大於突發值是請求被延遲。
  • nodelay|delay delay參數(1.15.7)指定了過度請求延遲的限制。默認值爲零,即所有過量的請求都被延遲。
設置共享內存區域和請求的最大突發大小。如果請求速率超過爲區域配置的速率,則延遲處理請求,以便以定義的速率處理請求。過多的請求會被延遲,直到它們的數量超過最大突發大小,在這種情況下,請求會因錯誤而終止。默認情況下,最大突發大小等於零。
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

server {
    location /search/ {
        limit_req zone=one burst=5;
    }

描述:平均每秒不允許超過一個請求,突發請求不超過5個。

-----參數使用說明-----

  1. 如果不希望在請求受到限制時延遲過多的請求,則應使用參數nodelay:
limit_req zone=one burst=5 nodelay;
  1. 可以有幾個limit_req指令。例如,下面的配置將限制來自單個IP地址的請求的處理速度,同時限制虛擬服務器的請求處理速度:
limit_req_zone $binary_remote_addr zone=perip:10m rate=1r/s;
limit_req_zone $server_name zone=perserver:10m rate=10r/s;

server {
    ...
    limit_req zone=perip burst=5 nodelay;
    limit_req zone=perserver burst=10;
}
當且僅當當前級別上沒有limit_req指令時,這些指令從上一級繼承。

1.3 圍繞limit_req_zone的相關配置


語法:    limit_req_log_level info | notice | warn | error;
默認:    limit_req_log_level error;
作用範圍:    http, server, location

This directive appeared in version 0.8.18.

設置所需的日誌記錄級別,用於服務器因速率超過或延遲請求處理而拒絕處理請求的情況。延遲日誌記錄級別比拒絕日誌記錄級別低1點;例如,如果指定了“limit_req_log_level通知”,則使用info級別記錄延遲。

錯誤狀態

語法:    limit_req_status code;
默認:    limit_req_status 503;
作用範圍:    http, server, location

This directive appeared in version 1.3.15.

設置狀態代碼以響應被拒絕的請求。

語法:    limit_req_zone key zone=name:size rate=rate [sync];
默認:    —
作用範圍:    http
設置共享內存區域的參數,該區域將保存各種鍵的狀態。特別是,狀態存儲當前過多請求的數量。鍵可以包含文本、變量及其組合。鍵值爲空的請求不被計算。

Prior to version 1.7.6, a key could contain exactly one variable.
例如:

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

說明:在這裏,狀態保存在一個10mb的區域“1”中,該區域的平均請求處理速度不能超過每秒1個請求。


總結:

  1. 客戶端IP地址作爲密鑰。注意,這裏使用的是$binary_remote_addr變量,而不是$remote_addr。$binary_remote_addr變量的大小對於IPv4地址總是4個字節,對於IPv6地址總是16個字節。存儲狀態在32位平臺上總是佔用64字節,在64位平臺上佔用128字節。一個兆字節區域可以保存大約16000個64字節的狀態,或者大約8000個128字節的狀態。
  2. 如果區域存儲耗盡,則刪除最近最少使用的狀態。即使在此之後無法創建新狀態,請求也會因錯誤而終止。
  3. 速率以每秒請求數(r/s)指定。如果需要每秒少於一個請求的速率,則在每分鐘請求(r/m)中指定。例如,每秒半請求是30r/m。

2. Openresty 模塊

2.1 限制接口總併發數

按照 ip 限制其併發連接數
lua_shared_dict my_limit_conn_store 100m;
...
location /hello {
   access_by_lua_block {
       local limit_conn = require "resty.limit.conn"
       -- 限制一個 ip 客戶端最大 1 個併發請求
       -- burst 設置爲 0,如果超過最大的併發請求數,則直接返回503,
       -- 如果此處要允許突增的併發數,可以修改 burst 的值(漏桶的桶容量)
       -- 最後一個參數其實是你要預估這些併發(或者說單個請求)要處理多久,以便於對桶裏面的請求應用漏桶算法
       
       local lim, err = limit_conn.new("my_limit_conn_store", 1, 0, 0.5)              
       if not lim then
           ngx.log(ngx.ERR, "failed to instantiate a resty.limit.conn object: ", err)
           return ngx.exit(500)
       end

       local key = ngx.var.binary_remote_addr
       -- commit 爲true 代表要更新shared dict中key的值,
       -- false 代表只是查看當前請求要處理的延時情況和前面還未被處理的請求數
       local delay, err = lim:incoming(key, true)
       if not delay then
           if err == "rejected" then
               return ngx.exit(503)
           end
           ngx.log(ngx.ERR, "failed to limit req: ", err)
           return ngx.exit(500)
       end

       -- 如果請求連接計數等信息被加到shared dict中,則在ctx中記錄下,
       -- 因爲後面要告知連接斷開,以處理其他連接
       if lim:is_committed() then
           local ctx = ngx.ctx
           ctx.limit_conn = lim
           ctx.limit_conn_key = key
           ctx.limit_conn_delay = delay
       end

       local conn = err
       -- 其實這裏的 delay 肯定是上面說的併發處理時間的整數倍,
       -- 舉個例子,每秒處理100併發,桶容量200個,當時同時來500個併發,則200個拒掉
       -- 100個在被處理,然後200個進入桶中暫存,被暫存的這200個連接中,0-100個連接其實應該延後0.5秒處理,
       -- 101-200個則應該延後0.5*2=1秒處理(0.5是上面預估的併發處理時間)
       if delay >= 0.001 then
           ngx.sleep(delay)
       end
   }

   log_by_lua_block {
       local ctx = ngx.ctx
       local lim = ctx.limit_conn
       if lim then
           local key = ctx.limit_conn_key
           -- 這個連接處理完後應該告知一下,更新shared dict中的值,讓後續連接可以接入進來處理
           -- 此處可以動態更新你之前的預估時間,但是別忘了把limit_conn.new這個方法抽出去寫,
           -- 要不每次請求進來又會重置
           local conn, err = lim:leaving(key, 0.5)
           if not conn then
               ngx.log(ngx.ERR,
                       "failed to record the connection leaving ",
                       "request: ", err)
               return
           end
       end
   }
   proxy_pass http://10.100.157.198:6112;
   proxy_set_header Host $host;
   proxy_redirect off;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_connect_timeout 60;
   proxy_read_timeout 600;
   proxy_send_timeout 600;
}

說明:其實此處沒有設置 burst 的值,就是單純的限制最大併發數,如果設置了 burst 的值,並且做了延時處理,其實就是對併發數使用了漏桶算法,但是如果不做延時處理,其實就是使用的令牌桶算法。參考下面對請求數使用漏桶令牌桶的部分,併發數的漏桶令牌桶實現與之相似

2.2 限制接口時間窗請求數

限制 ip 每分鐘只能調用 120 次 /hello 接口(允許在時間段開始的時候一次性放過120個請求)
lua_shared_dict my_limit_count_store 100m;
...

init_by_lua_block {
   require "resty.core"
}
....

location /hello {
   access_by_lua_block {
       local limit_count = require "resty.limit.count"

       -- rate: 10/min 
       local lim, err = limit_count.new("my_limit_count_store", 120, 60)
       if not lim then
           ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err)
           return ngx.exit(500)
       end

       local key = ngx.var.binary_remote_addr
       local delay, err = lim:incoming(key, true)
       -- 如果請求數在限制範圍內,則當前請求被處理的延遲(這種場景下始終爲0,因爲要麼被處理要麼被拒絕)和將被處理的請求的剩餘數
       if not delay then
           if err == "rejected" then
               return ngx.exit(503)
           end

           ngx.log(ngx.ERR, "failed to limit count: ", err)
           return ngx.exit(500)
       end
   }

   proxy_pass http://10.100.157.198:6112;
   proxy_set_header Host $host;
   proxy_redirect off;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_connect_timeout 60;
   proxy_read_timeout 600;
   proxy_send_timeout 600;
}

2.3 平滑限制接口請求數

限制 ip 每分鐘只能調用 120 次 /hello 接口(平滑處理請求,即每秒放過2個請求)
lua_shared_dict my_limit_req_store 100m;
....

location /hello {
   access_by_lua_block {
       local limit_req = require "resty.limit.req"
       -- 這裏設置rate=2/s,漏桶桶容量設置爲0,(也就是來多少水就留多少水) 
       -- 因爲resty.limit.req代碼中控制粒度爲毫秒級別,所以可以做到毫秒級別的平滑處理
       local lim, err = limit_req.new("my_limit_req_store", 2, 0)
       if not lim then
           ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
           return ngx.exit(500)
       end

       local key = ngx.var.binary_remote_addr
       local delay, err = lim:incoming(key, true)
       if not delay then
           if err == "rejected" then
               return ngx.exit(503)
           end
           ngx.log(ngx.ERR, "failed to limit req: ", err)
           return ngx.exit(500)
       end
   }

   proxy_pass http://10.100.157.198:6112;
   proxy_set_header Host $host;
   proxy_redirect off;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_connect_timeout 60;
   proxy_read_timeout 600;
   proxy_send_timeout 600;
}

2.4 漏桶算法限流

限制 ip 每分鐘只能調用 120 次 /hello 接口(平滑處理請求,即每秒放過2個請求),超過部分進入桶中等待,(桶容量爲60),如果桶也滿了,則進行限流
lua_shared_dict my_limit_req_store 100m;
....

location /hello {
   access_by_lua_block {
       local limit_req = require "resty.limit.req"
       -- 這裏設置rate=2/s,漏桶桶容量設置爲0,(也就是來多少水就留多少水) 
       -- 因爲resty.limit.req代碼中控制粒度爲毫秒級別,所以可以做到毫秒級別的平滑處理
       local lim, err = limit_req.new("my_limit_req_store", 2, 60)
       if not lim then
           ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
           return ngx.exit(500)
       end

       local key = ngx.var.binary_remote_addr
       local delay, err = lim:incoming(key, true)
       if not delay then
           if err == "rejected" then
               return ngx.exit(503)
           end
           ngx.log(ngx.ERR, "failed to limit req: ", err)
           return ngx.exit(500)
       end
       
       -- 此方法返回,當前請求需要delay秒後纔會被處理,和他前面對請求數
       -- 所以此處對桶中請求進行延時處理,讓其排隊等待,就是應用了漏桶算法
       -- 此處也是與令牌桶的主要區別既
       if delay >= 0.001 then
           ngx.sleep(delay)
       end
   }

   proxy_pass http://10.100.157.198:6112;
   proxy_set_header Host $host;
   proxy_redirect off;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_connect_timeout 60;
   proxy_read_timeout 600;
   proxy_send_timeout 600;
}

3.5 令牌桶算法限流

限制 ip 每分鐘只能調用 120 次 /hello 接口(平滑處理請求,即每秒放過2個請求),但是允許一定的突發流量(突發的流量,就是桶的容量(桶容量爲60),超過桶容量直接拒絕
lua_shared_dict my_limit_req_store 100m;
....

location /hello {
   access_by_lua_block {
       local limit_req = require "resty.limit.req"

       local lim, err = limit_req.new("my_limit_req_store", 2, 0)
       if not lim then
           ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
           return ngx.exit(500)
       end

       local key = ngx.var.binary_remote_addr
       local delay, err = lim:incoming(key, true)
       if not delay then
           if err == "rejected" then
               return ngx.exit(503)
           end
           ngx.log(ngx.ERR, "failed to limit req: ", err)
           return ngx.exit(500)
       end
       
       -- 此方法返回,當前請求需要delay秒後纔會被處理,和他前面對請求數
       -- 此處忽略桶中請求所需要的延時處理,讓其直接返送到後端服務器,
       -- 其實這就是允許桶中請求作爲突發流量 也就是令牌桶桶的原理所在
       if delay >= 0.001 then
       --    ngx.sleep(delay)
       end
   }

   proxy_pass http://10.100.157.198:6112;
   proxy_set_header Host $host;
   proxy_redirect off;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_connect_timeout 60;
   proxy_read_timeout 600;
   proxy_send_timeout 600;
}

說明:其實nginx的ngx_http_limit_req_module 這個模塊中的delay和nodelay也就是類似此處對桶中請求是否做延遲處理的兩種方案,也就是分別對應的漏桶和令牌桶兩種算法


注意:
resty.limit.traffic 模塊說明 This library is already usable though still highly experimental.
意思是說目前這個模塊雖然可以使用了,但是還處在高度實驗性階段,所以目前(2019-03-11)放棄使用resty.limit.traffic模塊。

3. kong 插件

3.1 rate-limiting

速率限制開發人員在給定的幾秒、幾分鐘、幾小時、幾天、幾個月或幾年時間內可以發出多少HTTP請求。如果底層服務/路由(或廢棄的API實體)沒有身份驗證層,那麼將使用客戶機IP地址,否則,如果配置了身份驗證插件,將使用使用者。
  1. 在一個Service上啓用該插件
$ curl -X POST http://kong:8001/services/{service}/plugins \
    --data "name=rate-limiting"  \
    --data "config.second=5" \
    --data "config.hour=10000"
  1. 在一個router上啓用該插件
$ curl -X POST http://kong:8001/routes/{route_id}/plugins \
    --data "name=rate-limiting"  \
    --data "config.second=5" \
    --data "config.hour=10000"
  1. 在一個consumer上啓動該插件
$ curl -X POST http://kong:8001/plugins \
    --data "name=rate-limiting" \
    --data "consumer_id={consumer_id}"  \
    --data "config.second=5" \
    --data "config.hour=10000"

rate-limiting支持三個策略,它們分別擁有自己的優缺點
策略 優點 缺點
cluster 準確,沒有額外的組件來支持 相對而言,性能影響最大的是,每個請求都強制對底層數據存儲執行讀和寫操作。
redis 準確,比集羣策略對性能的影響更小 額外的redis安裝要求,比本地策略更大的性能影響
local 最小的性能影響 不太準確,除非在Kong前面使用一致哈希負載均衡器,否則在擴展節點數量時它會發散

3.2 response-ratelimiting

此插件允許您根據上游服務返回的自定義響應頭限制開發人員可以發出的請求數量。您可以任意設置任意數量的限速對象(或配額),並指示Kong按任意數量增加或減少它們。每個自定義速率限制對象都可以限制每秒、分鐘、小時、天、月或年的入站請求。
  1. 在一個Service上啓用該插件
$ curl -X POST http://kong:8001/services/{service}/plugins \
    --data "name=response-ratelimiting"  \
    --data "config.limits.{limit_name}=" \
    --data "config.limits.{limit_name}.minute=10"
  1. 在一個router上啓用該插件
$ curl -X POST http://kong:8001/routes/{route_id}/plugins \
    --data "name=response-ratelimiting"  \
    --data "config.limits.{limit_name}=" \
    --data "config.limits.{limit_name}.minute=10"
  1. 在一個consumer上啓動該插件
$ curl -X POST http://kong:8001/plugins \
    --data "name=response-ratelimiting" \
    --data "consumer_id={consumer_id}"  \
    --data "config.limits.{limit_name}=" \
    --data "config.limits.{limit_name}.minute=10"
  1. 在api上啓用該插件
$ curl -X POST http://kong:8001/apis/{api}/plugins \
    --data "name=response-ratelimiting"  \
    --data "config.limits.{limit_name}=" \
    --data "config.limits.{limit_name}.minute=10"

3.3 request-size-limiting

阻塞體大於特定大小(以兆爲單位)的傳入請求。
  1. 在一個Service上啓用該插件
$ curl -X POST http://kong:8001/services/{service}/plugins \
    --data "name=request-size-limiting"  \
    --data "config.allowed_payload_size=128"
  1. 在一個router上啓用該插件
$ curl -X POST http://kong:8001/routes/{route_id}/plugins \
    --data "name=request-size-limiting"  \
    --data "config.allowed_payload_size=128"
  1. 在一個consumer上啓動該插件
$ curl -X POST http://kong:8001/plugins \
    --data "name=request-size-limiting" \
    --data "consumer_id={consumer_id}"  \
    --data "config.allowed_payload_size=128"
3.4 request-termination
此插件使用指定的狀態代碼和消息終止傳入的請求。這允許(暫時)停止服務或路由上的通信,甚至阻塞消費者。
  1. 在一個Service上啓用該插件
$ curl -X POST http://kong:8001/services/{service}/plugins \
    --data "name=request-termination"  \
    --data "config.status_code=403" \
    --data "config.message=So long and thanks for all the fish!"
  1. 在一個router上啓用該插件
$ curl -X POST http://kong:8001/routes/{route_id}/plugins \
    --data "name=request-termination"  \
    --data "config.status_code=403" \
    --data "config.message=So long and thanks for all the fish!"
  1. 在一個consumer上啓動該插件
$ curl -X POST http://kong:8001/plugins \
    --data "name=request-termination" \
    --data "consumer_id={consumer_id}"  \
    --data "config.status_code=403" \
    --data "config.message=So long and thanks for all the fish!"

4. 基於redis - INCR key

使用redis的INCR key,它的意思是將存儲在key上的值加1。如果key不存在,在操作之前將值設置爲0。如果鍵包含錯誤類型的值或包含不能表示爲整數的字符串,則返回錯誤。此操作僅限於64位帶符號整數。
return value
Integer reply: the value of key after the increment

examples

redis> SET mykey "10"
"OK"
redis> INCR mykey
(integer) 11
redis> GET mykey
"11"
redis> 

INCR key 有兩種用法:

  • 計數器(counter),比如文章瀏覽總量、分佈式數據分頁、遊戲得分等;
  • 限速器(rate limiter),速率限制器模式是一種特殊的計數器,用於限制操作的執行速率,比如:限制可以針對公共API執行的請求數量;

本方案的重點是使用redis實現一個限速器,我們使用INCR提供了該模式的兩種實現,其中我們假設要解決的問題是將API調用的數量限制在每IP地址每秒最多10個請求:

第一種方式,基本上每個IP都有一個計數器,每個不同的秒都有一個計數器
FUNCTION LIMIT_API_CALL(ip)
ts = CURRENT_UNIX_TIME()
keyname = ip+":"+ts
current = GET(keyname)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    MULTI
        INCR(keyname,1)
        EXPIRE(keyname,10)
    EXEC
    PERFORM_API_CALL()
END

優點:

  1. 使用ip+ts的方式,確保了每秒的緩存都是不同的key,將每一秒產生的redisobject隔離開。沒有使用過期時間強制限制redis過期時效。

缺點:

  1. 會產生大量的redis-key,雖然都寫入了過期時間,但是對於redis-key的清理也是一種負擔。有可能會影響redis的讀性能。
第二種方式,創建計數器的方式是,從當前秒中執行的第一個請求開始,它只能存活一秒鐘。如果在同一秒內有超過10個請求,計數器將達到一個大於10的值,否則它將過期並重新從0開始。
FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    value = INCR(ip)
    IF value == 1 THEN
        EXPIRE(ip,1)
    END
    PERFORM_API_CALL()
END

優點:

  1. 相對於方案一種佔用空間更小,執行效率更高。

缺點:

  1. INCR命令和EXPIRE命令不是原子操作,存在一個競態條件。如果由於某種原因客戶端執行INCR命令,但沒有執行過期,密鑰將被泄露,直到我們再次看到相同的IP地址。

修復方案:將帶有可選過期的INCR轉換爲使用EVAL命令發送的Lua腳本(只有在Redis 2.6版本中才可用)。
使用lua局部變量來解決,保證每次都能設置過期時間。

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],1)
end

三、最終實現方案

根據幾種常見的實現方案和場景以及優缺點最終採用的是

  • 使用kong的插件 rate-limiting ,如果不符合要求進行二次開發。
  • 直接開發kong插件使用令牌桶+redis實現限流
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章