分散式系統限流策略(二)
前文中介紹了系統限流的原理和基礎的使用場景,本篇將介紹應用接入層
(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的主要執行過程如下:
- 請求進入後首先判斷上一次請求時間相對於當前時間是否需要限流,如果需要則執行步驟2,否則執行步驟3.
- 如果沒有配置桶容量(burst=0),按照固定速率處理請求。如果請求被限流了,直接返回503;
如果配置了桶容量(burst>0),及延遲模式(沒有配置nodelay)。如果桶滿了,則新進入的請求被限流。如果沒有滿,則會以固定速率被處理;
如果配置了桶容量(burst>0),及非延遲模式(配置了nodelay)。則不會按照固定速率處理請求,而是允許突發處理請求。如果桶滿了,直接返回503. - 如果沒有被限流,則正常處理請求。
- 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的壓力測試。筆者從實驗結果看來:
- 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.05)這個條件時,從第1S開始,200的響應結果為34個;後面的每一秒200的響應結果都維持在60個左右。
- 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.01)這個條件時,從第1S開始,200的響應結果為44個;後面的每一秒200的響應結果都維持在160個左右。
- 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.05)這個條件時,從第1S開始,200的響應結果為82個;後面的每一秒200的響應結果都維持在224個左右。
- 當設定limit_conn.new(“limit_conn_store”, 2, 2, 0.001)這個條件時,從第1S開始,200的響應結果為131個;後面的每一秒200的響應結果都維持在223個左右。
- 當設定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獲取時間戳,因此只好從應用獲取然後傳入,在某些極端情況下(機器時鐘不準的情況下),限流會存在一些小問題。