1. 程式人生 > >Java並發編程(8)- 應用限流及其常見算法

Java並發編程(8)- 應用限流及其常見算法

grant 服務器 timeunit 基於 cond str 通過 拼接 inter

應用限流

在開發高並發系統時,有三把利器用來保護系統:緩存、降級和限流:

  • 緩存:緩存的目的是提升系統訪問速度和增大系統處理容量
  • 降級:降級是當服務出現問題或者影響到核心流程時,需要暫時屏蔽掉,待高峰或者問題解決後再打開
  • 限流:限流的目的是通過對並發訪問/請求進行限速,或者對一個時間窗口內的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務、排隊或等待、降級等處理

本文僅針對限流做一些簡單的說明,那麽何為限流呢?顧名思義,限流就是限制流量,就像你寬帶包了1個G的流量,用完了就沒了。通過限流,我們可以很好地控制系統的qps,從而達到保護系統的目的。本篇文章將會介紹一下常用的限流算法以及他們各自的特點。

限流本質上是控制某段代碼在一定時間內執行的次數,例如我們系統每天五點過後都有130w~140w的數據需要插入數據庫,若是直接一次性插入這些數據,必將導致數據庫連接被占滿無法接收其他處理的請求,數據庫的負載壓力會瞬間飆升,甚至是壓垮數據庫造成雪崩現象。所以我們需要對此操作進行限流,以一個恒定的速率去插入數據,假設每秒插入400條數據,當然這個數值需要根據實際情況去設定,如此一來就可以有效控制同一時間往數據庫插入的數據流不會很大,這樣就不會出現上述問題了。如下圖:
技術分享圖片

應用限流的常用算法:

  • 計數器法
  • 滑動窗口
  • 漏桶算法
  • 令牌桶算法

計數器法

計數器法是限流算法裏最簡單也是最容易實現的一種算法。比如我們規定,對於A接口來說,我們1分鐘的訪問次數不能超過100個。那麽我們可以這麽做:在一開始的時候,我們可以設置一個計數器counter,每當一個請求過來的時候,counter就加1,如果counter的值大於100並且該請求與第一個 請求的間隔時間還在1分鐘之內,那麽說明請求數過多;如果該請求與第一個請求的間隔時間大於1分鐘,且counter的值還在限流範圍內,那麽就重置 counter,具體算法的示意圖如下:

技術分享圖片

具體的偽代碼如下:

public class CounterDemo {
    public long timeStamp = getNowTime();  // 當前時間
    public int reqCount = 0;  // 初始化計數器
    public final int limit = 100; // 時間窗口內最大請求數
    public final long interval = 1000; // 時間窗口ms

    public boolean grant() {
        long now = getNowTime();
        if (now < timeStamp + interval) {
            // 在時間窗口內
            reqCount++;
            // 判斷當前時間窗口內是否超過最大請求控制數
            return reqCount <= limit;
        } else {
            timeStamp = now;
            // 超時後重置
            reqCount = 1;
            return true;
        }
    }
}

這個算法雖然簡單,但是有一個十分致命的問題,那就是臨界問題,如下圖:
技術分享圖片

從上圖中我們可以看到,假設有一個惡意用戶,他在0:59時,瞬間發送了100個請求,並且1:00又瞬間發送了100個請求,那麽其實這個用戶在 1秒裏面,瞬間發送了200個請求。我們剛才規定的是1分鐘最多100個請求,也就是每秒鐘最多1.7個請求,用戶通過在時間窗口的重置節點處突發請求, 可以瞬間超過我們的速率限制。用戶有可能通過算法的這個漏洞,瞬間壓垮我們的應用。

聰明的朋友可能已經看出來了,剛才的問題其實是因為我們統計的精度太低。那麽如何很好地處理這個問題呢?或者說,如何將臨界問題的影響降低呢?我們可以看下面的滑動窗口算法。

滑動窗口

滑動窗口,又稱rolling window。為了解決計數器法統計精度太低的問題,引入了滑動窗口算法。如果學過TCP網絡協議的話,那麽一定對滑動窗口這個名詞不會陌生。下面這張圖,很好地解釋了滑動窗口算法:
技術分享圖片

在上圖中,整個紅色的矩形框表示一個時間窗口,在我們的例子中,一個時間窗口就是一分鐘。然後我們將時間窗口進行劃分,比如圖中,我們就將滑動窗口劃成了6格,所以每格代表的是10秒鐘。每過10秒鐘,我們的時間窗口就會往右滑動一格。每一個格子都有自己獨立的計數器counter,比如當一個請求 在0:35秒的時候到達,那麽0:30~0:39對應的counter就會加1。

那麽滑動窗口怎麽解決剛才的臨界問題的呢?在上圖中,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘×××的格子中。當時間到達1:00時,我們的窗口會往右移動一格,那麽此時時間窗口內的總請求數量一共是200個,超過了限定的100個,所以此時能夠檢測出來觸發了限流。

我再來回顧一下剛才的計數器算法,我們可以發現,計數器算法其實就是滑動窗口算法。只是它沒有對時間窗口做進一步地劃分,所以只有1格。

由此可見,當滑動窗口的格子劃分的越多,那麽滑動窗口的滾動就越平滑,限流的統計就會越精確。

漏桶算法

漏桶算法,又稱leaky bucket。為了理解漏桶算法,我們看一下對於該算法的示意圖:
技術分享圖片

從圖中我們可以看到,整個算法其實十分簡單。首先,我們有一個固定容量的桶,有水流進來,也有水流出去。對於流進來的水來說,我們無法預計一共有多少水會流進來,也無法預計水流的速度。但是對於流出去的水來說,這個桶可以固定水流出的速率。而且,當桶滿了之後,多余的水將會溢出。

我們將算法中的水換成實際應用中的請求,我們可以看到漏桶算法天生就限制了請求的速度。當使用了漏桶算法,我們可以保證接口會以一個常速速率來處理請求。所以漏桶算法天生不會出現臨界問題。

具體的偽代碼如下:

public class LeakyDemo {
        public long timeStamp = getNowTime();  // 當前時間
        public int capacity; // 桶的容量
        public int rate; // 水漏出的速度
        public int water; // 當前水量(當前累積請求數)

        public boolean grant() {
            long now = getNowTime();
            water = max(0, water - (now - timeStamp) * rate); // 先執行漏水,計算剩余水量
            timeStamp = now;
            if ((water + 1) < capacity) {
                // 嘗試加水,並且水還未滿
                water += 1;
                return true;
            } else {
                // 水滿,拒絕加水
                return false;
        }
    }
}

令牌桶算法

令牌桶算法,又稱token bucket。同樣為了理解該算法,我們來看一下該算法的示意圖:
技術分享圖片

從圖中我們可以看到,令牌桶算法比漏桶算法稍顯復雜。首先,我們有一個固定容量的桶,桶裏存放著令牌(token)。桶一開始是空的,token以 一個固定的速率r往桶裏填充,直到達到桶的容量,多余的令牌將會被丟棄。每當一個請求過來時,就會嘗試從桶裏移除一個令牌,如果沒有令牌的話,請求無法通過。

具體的偽代碼如下:

public class TokenBucketDemo {
    public long timeStamp = getNowTime();  // 當前時間
    public int capacity; // 桶的容量
    public int rate; // 令牌放入速度
    public int tokens; // 當前令牌數量

    public boolean grant() {
        long now = getNowTime();
        // 先添加令牌
        tokens = min(capacity, tokens + (now - timeStamp) * rate);
        timeStamp = now;
        if (tokens < 1) {
            // 若不到1個令牌,則拒絕
            return false;
        } else {
            // 還有令牌,領取令牌
            tokens -= 1;
            return true;
        }
    }
}

若仔細研究算法,我們會發現我們默認從桶裏移除令牌是不需要耗費時間的。如果給移除令牌設置一個延時時間,那麽實際上又采用了漏桶算法的思路。Google的Guava庫下的SmoothWarmingUp類就采用了這個思路。

我們再來考慮一下臨界問題的場景。在0:59秒的時候,由於桶內積滿了100個token,所以這100個請求可以瞬間通過。但是由於token是以較低的速率填充的,所以在1:00的時候,桶內的token數量不可能達到100個,那麽此時不可能再有100個請求通過。所以令牌桶算法可以很好地解決臨界問題。下圖比較了計數器(左)和令牌桶算法(右)在臨界點的速率變化。我們可以看到雖然令牌桶算法允許突發速率,但是下一個突發速率必須要等桶內有足夠的 token後才能發生:
技術分享圖片

限流算法小結

計數器 VS 滑動窗口:

計數器算法是最簡單的算法,可以看成是滑動窗口的低精度實現。滑動窗口由於需要存儲多份的計數器(每一個格子存一份),所以滑動窗口在實現上需要更多的存儲空間。也就是說,如果滑動窗口的精度越高,需要的存儲空間就越大。

漏桶算法 VS 令牌桶算法:

漏桶算法和令牌桶算法最明顯的區別是令牌桶算法允許流量一定程度的突發。因為默認的令牌桶算法,取走token是不需要耗費時間的,也就是說,假設桶內有100個token時,那麽可以瞬間允許100個請求通過。

令牌桶算法由於實現簡單,且允許某些流量的突發,對用戶友好,所以被業界采用地較多。當然我們需要具體情況具體分析,只有最合適的算法,沒有最優的算法。


RateLimiter使用示例

Google開源工具包Guava提供了限流工具類RateLimiter,該類基於令牌桶算法(Token Bucket)來完成限流,非常易於使用。RateLimiter經常用於限制對一些物理資源或者邏輯資源的訪問速率,它支持兩種獲取permits接口,一種是如果拿不到立刻返回false(tryAcquire()),一種會阻塞等待一段時間看能不能拿到(tryAcquire(long timeout, TimeUnit unit))。

使用tryAcquire方法獲取令牌的示例代碼:

@Slf4j
public class RateLimiterExample1 {
    /**
     * 每秒鐘放入5個令牌,相當於每秒只允許執行5個請求
     */
    private static final RateLimiter RATE_LIMITER = RateLimiter.create(5);

    public static void main(String[] args) {
        // 模擬有100個請求
        for (int i = 0; i < 100; i++) {
            // 嘗試從令牌桶中獲取令牌,若獲取不到則等待300毫秒看能不能獲取到
            if (RATE_LIMITER.tryAcquire(300, TimeUnit.MILLISECONDS)) {
                // 獲取成功,執行相應邏輯
                handle(i);
            }
        }
    }

    private static void handle(int i) {
        log.info("{}", i);
    }
}

若想保證所有的請求都被執行,而不會被拋棄的話,可以選擇使用acquire方法:

@Slf4j
public class RateLimiterExample2 {
    /**
     * 每秒鐘放入5個令牌,相當於每秒只允許執行5個請求
     */
    private static final RateLimiter RATE_LIMITER = RateLimiter.create(5);

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            // 從令牌桶中獲取一個令牌,若沒有獲取到會阻塞直到獲取到為止,所以所有的請求都會被執行
            RATE_LIMITER.acquire();
            // 獲取成功,執行相應邏輯
            handle(i);
        }
    }

    private static void handle(int i) {
        log.info("{}", i);
    }
}

集群限流

前面討論的幾種算法都屬於單機限流的範疇,但是業務需求五花八門,簡單的單機限流,根本無法滿足他們。

比如為了限制某個資源被每個用戶或者商戶的訪問次數,5s只能訪問2次,或者一天只能調用1000次,這種需求,單機限流是無法實現的,這時就需要通過集群限流進行實現。

如何實現?為了控制訪問次數,肯定需要一個計數器,而且這個計數器只能保存在第三方服務,比如redis。

大概思路:每次有相關操作的時候,就向redis服務器發送一個incr命令,比如需要限制某個用戶訪問/index接口的次數,只需要拼接用戶id和接口名生成redis的key,每次該用戶訪問此接口時,只需要對這個key執行incr命令,在這個key帶上過期時間,就可以實現指定時間的訪問頻率。

Java並發編程(8)- 應用限流及其常見算法