1. 程式人生 > >分散式系統限流策略(二)

分散式系統限流策略(二)

前文中介紹了系統限流的原理和基礎的使用場景,本篇將介紹應用接入層(Nginx)、分散式應用如何限流。

應用接入層限流(Nginx/OpenResty)

接入層通常是指流量的入口,主要的目的有:負載均衡、非法請求過濾、請求聚合、快取、降級、限流、A/B測試、服務質量監控等。對於流量接入層所使用的中介軟體一般都是:Nginx(OpenResty)。下面將分別介紹一下如何進行限流操作。

Nginx

Nginx限流可以使用其自帶的2個模組:連線數限流模組(ngx_http_limit_conn_module)和漏桶演算法實現的請求限流模組(ngx_http_limit_req_module)。

ngx_http_limit_conn_module

limit_conn是用來對某個key對應的總的網路連線數進行限流,可以按照IP、host維度進行限流。不是每個請求都會被計數器統計,只有被Nginx處理並且已經讀取了整個請求頭的連線才會被計數。下面給出一個Demo(按照IP限流):

http {
    limit_conn_zone $binary_remote_addr zone=addr:10m; # 用來配置限流key及存放key對應資訊的記憶體區域大小。此處的key是“$binary_remote_addr”,表示IP地址。也可以使用$server_name作為key
limit_conn_log_level error; # 被限流後的日誌級別 limit_conn_status 503; # 被限流後返回的狀態碼 ... server { ... location /limit { limit_conn addr 1; # 要配置存放key和計數器的共享記憶體區域和指定key的最大連線數。此處表示Nginx最多同時併發處理1個連線 } ... }

也可以按照host進行限流,Demo如下:

http {
    limit_conn_zone $server_name zone zone=hostname:10m;

    limit_conn_log_level error; # 被限流後的日誌級別
limit_conn_status 503; # 被限流後返回的狀態碼 ... server { ... location /limit { limit_conn hostname 1; } ... }

流程如下所示:

這裡寫圖片描述

ngx_http_limit_req_module

limit_req是漏桶演算法,對於指定key對應的請求進行限流。配置Demo如下:

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; # 配置限流key、存放key對應資訊的共享記憶體區域大小、固定請求速率。此處的key是“$binary_remote_addr”(IP地址)。固定請求速率使用rate配置,支援10r/s和60r/m。

    limit_conn_log_level error;
    limit_conn_status 503;
    ...
    server {
    ...
        location /limit {
            limit_req zone=one burst=5 nodelay; # 配置限流區域、桶容量(突發容量,預設為0)、是否延遲模式(預設延遲)
        }
    ...
    }
}

limit_req的主要執行過程如下:

  1. 請求進入後首先判斷上一次請求時間相對於當前時間是否需要限流,如果需要則執行步驟2,否則執行步驟3.
  2. 如果沒有配置桶容量(burst=0),按照固定速率處理請求。如果請求被限流了,直接返回503;
    如果配置了桶容量(burst>0),及延遲模式(沒有配置nodelay)。如果桶滿了,則新進入的請求被限流。如果沒有滿,則會以固定速率被處理;
    如果配置了桶容量(burst>0),及非延遲模式(配置了nodelay)。則不會按照固定速率處理請求,而是允許突發處理請求。如果桶滿了,直接返回503.
  3. 如果沒有被限流,則正常處理請求。
  4. Nginx會在響應時間選擇一些(3個節點)限流key進行過期處理,進行記憶體回收。

OpenResty

Openresty提供了Lua限流模組lua-resty-limit-traffic,通過它可以按照更為複雜的業務邏輯進行動態限流處理。它也提供了limit.conn和limit.req的實現,演算法與Nginx的limit_conn和limit_req是一樣的。其下載地址為:lua-resty-limit-traffic,下載後,將其limit資料夾中的內容覆蓋掉OpenResty安裝目錄中的resty中的limit資料夾即可。

lua-resty-limit-traffic

OpenResty中的限速,可以分為以下三種:limit_rate(限制響應速度)、limit_conn(限制連線數)、limit_req(限制請求數)。下面將分別介紹一下它們的用法。

limit_rate(限制響應速度)

Nginx有個$limit_rate,這個變數反映的是當前請求每秒能響應的位元組數。該位元組數預設為配置檔案中 limit_rate指令的設值。 通過 OpenResty,我們可以直接在 Lua 程式碼中動態設定它。

access_by_lua_block {
    -- 設定當前請求的響應上限是 每秒 300K 位元組
    ngx.var.limit_rate = "300K"
}

limit_conn(限制連線數)

對於限制連線數,連線數限制並不是1S內的連線數限制,而是同一時刻的連線數限制。下面給出一個Demo:

nginx.conf

# nginx.conf
lua_code_cache on;
# 注意 limit_conn_store 的大小需要足夠放置限流所需的鍵值。
# 每個 $binary_remote_addr 大小不會超過 16K,算上 lua_shared_dict 的節點大小,總共不到 64 位元組。
# 100M 可以放 1.6M 個鍵值對
lua_shared_dict limit_conn_store 100M;
server {
    listen 8080;
    location /limit {
        access_by_lua_file src/access.lua;
        content_by_lua_file src/content.lua;
        log_by_lua_file src/log.lua;
    }
}

然後封裝一個隊req.conn的工具:limit_conn.lua

-- utils/limit_conn.lua
local limit_conn = require "resty.limit.conn"

-- new 的第四個引數用於估算每個請求會維持多長時間,以便於應用漏桶演算法
local limit, limit_err = limit_conn.new("limit_conn_store", 2, 2, 0.01)
if not limit then
    error("failed to instantiate a resty.limit.conn object: ", limit_err)
end

local _M = {}

function _M.incoming()
    local key = ngx.var.binary_remote_addr
    local delay, err = limit: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

    if limit:is_committed() then
        local ctx = ngx.ctx
        ctx.limit_conn_key = key
        ctx.limit_conn_delay = delay
    end

    if delay >= 0.001 then
        ngx.log(ngx.WARN, "delaying conn, excess ", delay,
                "s per binary_remote_addr by limit_conn_store")
        ngx.sleep(delay)
    end
end

function _M.leaving()
    local ctx = ngx.ctx
    local key = ctx.limit_conn_key
    if key then
        local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
        local conn, err = limit:leaving(key, latency)
        if not conn then
            ngx.log(ngx.ERR,
            "failed to record the connection leaving ",
            "request: ", err)
        end
    end
end

return _M

然後是接收到請求時的處理程式碼:access.lua

-- src/access.lua
local limit_conn = require "utils.limit_conn"

-- 對於內部重定向或子請求,不進行限制。因為這些並不是真正對外的請求。
if ngx.req.is_internal() then
    return
end
limit_conn.incoming()

對於內容生成:content.lua,這裡我們就簡單的處理一下:

-- src/content.lua

ngx.say('content has generated!')

ngx.sleep(0.01) # 這裡模擬一個0.01S的耗時,否則看不出效果

然後是內容生成後的後置程式碼:log.lua

-- src/log.lua
local limit_conn = require "utils.limit_conn"

limit_conn.leaving()

筆者在MAC系統下使用webbench對介面進行測試,過程如下:

webbench -c 10 -t 10 http://localhost/limit

這裡面-c表示10個併發,執行10S的壓力測試。筆者從實驗結果看來:

  1. 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.05)這個條件時,從第1S開始,200的響應結果為34個;後面的每一秒200的響應結果都維持在60個左右。
  2. 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.01)這個條件時,從第1S開始,200的響應結果為44個;後面的每一秒200的響應結果都維持在160個左右。
  3. 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.05)這個條件時,從第1S開始,200的響應結果為82個;後面的每一秒200的響應結果都維持在224個左右。
  4. 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.001)這個條件時,從第1S開始,200的響應結果為131個;後面的每一秒200的響應結果都維持在223個左右。
  5. 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.0001)這個條件時,從第1S開始,200的響應結果為171個;後面的每一秒200的響應結果都維持在300個左右。

從上面的結果看來,對於每個請求的執行時間預估越接近實際值或者時間略小於實際的平均值,最後榨取機器的剩餘價值會越多。

limit_req(限制請求數)

對於限制請求數,下面給出一個Demo:

lua_shared_dict my_limit_req_store 100m;

location /limit {
    access_by_lua_file src/utils/limit_req.lua;
    content_by_lua_file src/content.lua;
}

limit_req.lua的內容如下:

local limit_req = require "resty.limit.req"

-- 將請求限制在20請求/秒,突發10次/秒,
-- 也就是說,我們推遲了每秒30以下和20以上的請求,並拒絕超過30請求/秒的任何請求。
local lim, err = limit_req.new("my_limit_req_store", 20, 10)
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

if delay >= 0.001 then
    local excess = err

    ngx.sleep(delay)
end

筆者使用如下命令進行測試:

webbench -c 50 -t 5 http://localhost/limit

結果是每秒的200的結果為20個。

limit_traffic

limit_traffic可以聚合上面多種請求限流策略,這裡不再說明。後續會在OpenResty的專題單獨說明。

分散式應用限流

分散式應用限流指的是,在應用伺服器上面進行限流操作,如Tomcat等。分散式限流最關鍵的是要將限流服務做成原子化,而解決方案可以使使用redis+lua進行實現,在Java開發語言中,Jedis可以支援原子性的Lua指令碼。下面介紹一下Redis+Lua的實現。

Redis+Lua的實現

Lua指令碼

local key = KEYS[1] --限流KEY(一秒一個)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
    return 0
else --請求數+1,並設定2秒過期
    redis.call("INCRBY", key,"1")
    redis.call("expire", key,"2")
    return 1
end

Java呼叫程式碼如下:

public static boolean acquire() throws Exception {
    String luaScript = Files.toString(new File("limit.lua"), Charset.defaultCharset());
    Jedis jedis = new Jedis("192.168.147.52", 6379);
    String key = "ip:" + System.currentTimeMillis()/ 1000; //此處將當前時間戳取秒數
    Stringlimit = "3"; //限流大小
    return (Long)jedis.eval(luaScript,Lists.newArrayList(key), Lists.newArrayList(limit)) == 1;
}

因為Redis的限制(Lua中有寫操作不能使用帶隨機性質的讀操作,如TIME)不能在Redis Lua中使用TIME獲取時間戳,因此只好從應用獲取然後傳入,在某些極端情況下(機器時鐘不準的情況下),限流會存在一些小問題。