咱們每一個系統在作壓測的時候,都有一個處理峯值,當接近峯值繼續接受請求的時候,會致使整個系統響應緩慢;爲了保護系統,須要拒絕處理過載的請求,這就是咱們下面介紹的限流,經過設定一個峯值閾值,限制請求達到這個峯值,以此來保護系統;咱們常見的一些中間件好比tomcat,mysql,redis等等都有相似的限制。html
作限流的時候咱們有一些經常使用的限流算法包括:計數器限流,令牌桶限流,漏桶限流;mysql
令牌桶算法的原理是系統以必定速率向桶中放入令牌,填滿了就丟棄令牌;請求來時會先從桶中取出令牌,若是能取到令牌,則能夠繼續完成請求,不然等待或者拒絕服務;令牌桶容許必定程度突發流量,只要有令牌就能夠處理,支持一次拿多個令牌;nginx
漏桶算法的原理是按照固定常量速率流出請求,流入請求速率任意,當請求數超過桶的容量時,新的請求等待或者拒絕服務;能夠看出漏桶算法能夠強制限制數據的傳輸速度;web
計數器是一種比較簡單粗暴的算法,主要用來限制總併發數,好比數據庫鏈接池、線程池、秒殺的併發數;計數器限流只要必定時間內的總請求數超過設定的閥值則進行限流;面試
瞭解了限流算法以後,咱們須要知道在什麼地方限流,以及如何限流;對於一個系統來講咱們經常能夠在接入層進行限流,這個大部分狀況下能夠直接使用nginx,OpenResty等中間件直接處理;也能夠在業務層進行限流,這個須要根據咱們不一樣的業務需求使用相關的限流算法來處理。redis
對於業務層咱們多是單節點的,也多是多節點用戶綁定的,也多是多節點無綁定的;這時候咱們就要區分是進程內的限流仍是須要分佈式限流。算法
對於進程內限流相對來講仍是比較簡單的,guava是咱們常用的利器,下面分別看看如何限制接口的總併發量,某個時間窗口的請求數,以及使用令牌桶和漏桶算法更加平滑的限流;sql
只須要配置一個總併發量,而後使用一個計算器記錄每次請求,而後和總併發量比較便可:數據庫
private static int max = 10; private static AtomicInteger limiter = new AtomicInteger(); if (limiter.incrementAndGet() > max){ System.err.println("超過最大限制數"); return; }
限制某個接口在指定時間以內的請求量,可使用guava的cache來緩存計數器,而後再設置過時時間;好比下面設置每分鐘最大請求爲100:api
LoadingCache<Long, AtomicLong> counter = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build(new CacheLoader<Long, AtomicLong>() { @Override public AtomicLong load(Long key) throws Exception { return new AtomicLong(0); } }); private static int max = 100; long curMinutes = System.currentTimeMillis() / 1000 * 60; if (counter.get(curMinutes).incrementAndGet() > max) { System.err.println("時間窗口請求數超過上限"); return; }
過時時間爲一分鐘,每分鐘自動清零;這種處理方式可能會出現超限的狀況,好比前59秒都沒有消息,到60的時候一會兒來了200條消息,這時候先接受了100條消息,恰好到期計數器清0,而後又接受了100條消息;這種狀況能夠參考TCP的滑動窗口思路來解決。
計數器的方式仍是比較粗暴的,令牌桶和漏桶限流這兩種算法相對來講仍是比較平滑的,能夠直接使用guava提供的RateLimiter類:
RateLimiter limiter = RateLimiter.create(2); System.out.println(limiter.acquire(4)); System.out.println(limiter.acquire()); System.out.println(limiter.acquire()); System.out.println(limiter.acquire(2)); System.out.println(limiter.acquire()); System.out.println(limiter.acquire());
create(2)表示桶容量爲2而且每秒新增2個令牌,也就是500毫秒新增一個令牌,acquire()表示從裏面獲取一個令牌,返回值爲等待的時間,輸出結果以下:
0.0 1.998633 0.49644 0.500224 0.999335 0.500186
能夠看到此算法是容許必定突發狀況的,第一次獲取4個令牌等待時間爲0,後面再獲取須要等待2秒才能夠,後面每次獲取須要500毫秒。
如今大部分系統都採用了多節點部署,因此一個業務可能在多個進程內被處理,因此這時候分佈式限流必不可少,好比常見的秒殺系統,可能同時有N臺業務邏輯節點;
常規的作法是使用Redis+lua和OpenResty+lua來實現,將限流服務作成原子化,同時也要保證高性能;Redis和OpenResty都已高性能著稱,同時也提供了原子化方案,具體以下所示;
Redis在服務端對消息的處理是單線程的,同時支持lua腳本的執行,能夠將限流的相關邏輯用lua腳本實現,來保證原子性,大致實現以下:
-- 限流 key local key = KEYS[1] -- 限流大小 local limit = tonumber(ARGV[1]) -- 過時時間 local expire = tonumber(ARGV[2]) local current = tonumber(redis.call('get',key) or "0") if current + 1 > limit then return 0; else redis.call("INCRBY", key, 1) redis.call("EXPIRE", key, expire) return current + 1 end
以上使用計數器算法來實現限流,在調用lua的地方能夠傳入限流key,限流大小以及key的有效期;返回結果若是爲0表示超出限流大小,不然返回當前累計的值。
OpenResty核心就是nginx,可是在這個基礎之上加了不少第三方模塊,ngx_lua模塊將lua嵌入到了nginx中,使得nginx能夠做爲一個web服務器來使用;還有其餘經常使用的開發模塊如:lua-resty-lock,lua-resty-limit-traffic,lua-resty-memcached,lua-resty-mysql,lua-resty-redis等等;
本小節咱們先使用lua-resty-lock模塊來實現一個簡單計數器限流,相關lua代碼以下:
local locks = require "resty.lock"; local function acquire() local lock = locks:new("locks"); local elapsed, err = lock:lock("limit_key"); local limit_counter = ngx.shared.limit_counter; --獲取客戶端ip local key = ngx.var.remote_addr; --限流大小 local limit = 5; local current = limit_counter:get(key); --打印key和當前值 ngx.say("key="..key..",value="..tostring(current)); if current ~= nil and current + 1 > limit then lock:unlock(); return 0; end if current == nil then limit_counter:set(key,1,5); --設置過時時間爲5秒 else limit_counter:incr(key,1); end lock:unlock(); return 1; end
以上是一個對ip進行限流的實例,由於須要保證原子性,因此使用了resty.lock模塊,同時也相似redis設置了過時時間重置,另一點須要注意對鎖的釋放;還須要設置兩個共享字典
http { ... #lua_shared_dict <name> <size> 定義一塊名爲name的共享內存空間,內存大小爲size; 經過該命令定義的共享內存對象對於Nginx中全部worker進程都是可見的 lua_shared_dict locks 10m; lua_shared_dict limit_counter 10m; }
接入層一般就是流量入口處,Nginx被不少系統用做流量入口,固然OpenResty也不例外,並且OpenResty提供了更強大的功能,好比這裏將要介紹的lua-resty-limit-traffic模塊,是一個功能強大的限流模塊;在使用lua-resty-limit-traffic以前咱們先大體看一下如何使用OpenResty;
直接去官方下載便可:http://openresty.org/en/download.html,啓動,重載,中止命令以下:
nginx.exe nginx.exe -s reload nginx.exe -s stop
打開ip+端口,能夠看到:Welcome to OpenResty! 即表示啓動成功;
首先須要在nginx.conf的http目錄下作以下配置:
http { ... lua_package_path "/lualib/?.lua;;"; #lua 模塊 lua_package_cpath "/lualib/?.so;;"; #c模塊 include lua.conf; #導入自定義lua配置文件 }
這裏自定義了一個lua.conf,有關lua的請求都在這裏面配置,放在和nginx.conf一個路徑下便可;已一個test.lua爲例,lua.conf配置以下:
#lua.conf server { charset utf-8; #設置編碼 listen 8081; server_name _; location /test { default_type 'text/html'; content_by_lua_file lua/api/test.lua; } }
這裏把全部的lua文件都放在lua/api目錄下,好比一個最簡單的hello world:
ngx.say("hello world");
lua-resty-limit-traffic提供了限制最大併發鏈接數,時間窗口請求數,以及平滑限制請求數三種方式,分別對應:resty.limit.conn,resty.limit.count,resty.limit.req;相關文檔能夠直接在pod/lua-resty-limit-traffic中找到,裏面有完整的實例;
如下會用到三個共享字典,事先在http下配置:
http { lua_shared_dict my_limit_conn_store 100m; lua_shared_dict my_limit_count_store 100m; lua_shared_dict my_limit_req_store 100m; }
提供的resty.limit.conn限制最大鏈接數,具體腳本以下:
local limit_conn = require "resty.limit.conn" --B<syntax:> C<obj, err = class.new(shdict_name, conn, burst, default_conn_delay)> 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 local delay, err = lim:incoming(key, true) if not delay then if err == "rejected" then return ngx.exit(502) end ngx.log(ngx.ERR, "failed to limit req: ", err) return ngx.exit(500) end 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 if delay >= 0.001 then ngx.sleep(delay) end
new()參數分別是:字典名稱,容許的最大併發請求數,容許的突發鏈接數,鏈接延遲;
incoming()中commit是一個布爾值,當爲true時表示記錄當前請求的數量,不然就直接運行;
返回值:若是請求不超過方法中指定的conn值,則此方法返回0做爲延遲以及當前時間的併發請求(或鏈接)數;
提供的resty.limit.count能夠限制必定請求數在一個時間窗口內,具體腳本以下:
local limit_count = require "resty.limit.count" --B<syntax:> C<obj, err = class.new(shdict_name, count, time_window)> --速率限制在20/10s local lim, err = limit_count.new("my_limit_count_store", 20, 10) if not lim then ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err) return ngx.exit(500) end local local key = ngx.var.binary_remote_addr --B<syntax:> C<delay, err = obj:incoming(key, commit)> 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 count: ", err) return ngx.exit(500) end
new()中指定的三個參數分別是:字典名稱,指定的請求閾值,請求個數復位前的窗口時間,以秒爲單位;
incoming()中commit是一個布爾值,當爲true時表示記錄當前請求的數量,不然就直接運行;
返回值:若是請求數在限制範圍內,則返回當前請求被處理的延遲和將被處理的請求的剩餘數;
提供的resty.limit.req能夠已更加平滑的方式限制請求,具體腳本以下:
local limit_req = require "resty.limit.req" --B<syntax:> C<obj, err = class.new(shdict_name, rate, burst)> --限制在200個請求/秒如下,給與100個請求/秒的突發請求;也就說每秒請求最大能夠200-300之間,超出300報錯 local lim, err = limit_req.new("my_limit_req_store", 200, 100) 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
new()三個參數分別是:字典名稱,請求速率(每秒數)閾值,每秒容許延遲的過多請求數;
incoming()中commit是一個布爾值,當爲true時表示記錄當前請求的數量,不然就直接運行,能夠理解爲一個開關;
返回值:若是請求數在限制範圍內,則此方法返回0做爲當前時間的延遲和每秒過多請求的(零)個數;
更多能夠直接查看官方文檔:pod/lua-resty-limit-traffic目錄下
本文首先介紹了常見的限流算法,而後介紹在業務層進程內和分佈式應用分別是如何進行限流的,最後接入層經過OpenResty的lua-resty-limit-traffic模塊進行限流。
能夠關注微信公衆號「 回滾吧代碼」,第一時間閱讀,文章持續更新;專一Java源碼、架構、算法和麪試。