限流淺析

前言

我們每個系統在做壓測的時候,都有一個處理峯值,當接近峯值繼續接受請求的時候,會導致整個系統響應緩慢;爲了保護系統,需要拒絕處理過載的請求,這就是我們下面介紹的限流,通過設定一個峯值閾值,限制請求達到這個峯值,以此來保護系統;我們常見的一些中間件比如tomcat,mysql,redis等等都有類似的限制。

限流算法

做限流的時候我們有一些常用的限流算法包括:計數器限流,令牌桶限流,漏桶限流;

  • 1.令牌桶限流

令牌桶算法的原理是系統以一定速率向桶中放入令牌,填滿了就丟棄令牌;請求來時會先從桶中取出令牌,如果能取到令牌,則可以繼續完成請求,否則等待或者拒絕服務;令牌桶允許一定程度突發流量,只要有令牌就可以處理,支持一次拿多個令牌;

  • 2.漏桶限流

漏桶算法的原理是按照固定常量速率流出請求,流入請求速率任意,當請求數超過桶的容量時,新的請求等待或者拒絕服務;可以看出漏桶算法可以強制限制數據的傳輸速度;

  • 3.計數器限流

計數器是一種比較簡單粗暴的算法,主要用來限制總併發數,比如數據庫連接池、線程池、秒殺的併發數;計數器限流只要一定時間內的總請求數超過設定的閥值則進行限流;

如何限流

瞭解了限流算法之後,我們需要知道在什麼地方限流,以及如何限流;對於一個系統來說我們常常可以在接入層進行限流,這個大部分情況下可以直接使用nginx,OpenResty等中間件直接處理;也可以在業務層進行限流,這個需要根據我們不同的業務需求使用相關的限流算法來處理。

業務層限流

對於業務層我們可能是單節點的,也可能是多節點用戶綁定的,也可能是多節點無綁定的;這時候我們就要區分是進程內的限流還是需要分佈式限流。

進程內限流

對於進程內限流相對來說還是比較簡單的,guava是我們經常使用的利器,下面分別看看如何限制接口的總併發量,某個時間窗口的請求數,以及使用令牌桶和漏桶算法更加平滑的限流;

  • 限制接口的總併發量

只需要配置一個總併發量,然後使用一個計算器記錄每次請求,然後和總併發量比較即可:

private static int max = 10;
private static AtomicInteger limiter = new AtomicInteger();

if (limiter.incrementAndGet() > max){
    System.err.println("超過最大限制數");
    return;
}
  • 限制時間窗口請求數

限制某個接口在指定時間之內的請求量,可以使用guava的cache來緩存計數器,然後再設置過期時間;比如下面設置每分鐘最大請求爲100:

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

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+lua

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;

OpenResty安裝使用

  • 下載安裝配置

直接去官方下載即可:http://openresty.org/en/download.html,啓動,重載,停止命令如下:

nginx.exe
nginx.exe -s reload
nginx.exe -s stop

打開ip+端口,可以看到:Welcome to OpenResty! 即表示啓動成功;

  • lua腳本實例

首先需要在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模塊

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源碼、架構、算法和麪試。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章