高併發場景下如何實現系統限流?

在分佈式高可用設計中,限流應該是應用最廣泛的技術手段之一,今天一起來討論一下,爲什麼需要限流,以及常見的限流算法都有哪些。

一、常見限流算法

限流是服務降級的一種手段,顧名思義,通過限制系統的流量,從而實現保護系統的目的。

合理的限流配置,需要了解系統的吞吐量,所以,限流一般需要結合容量規劃和壓測來進行。當外部請求接近或者達到系統的最大閾值時,觸發限流,採取其他的手段進行降級,保護系統不被壓垮。常見的降級策略包括延遲處理、拒絕服務、隨機拒絕等。

限流後的策略,其實和 Java 併發編程中的線程池非常類似,我們都知道,線程池在任務滿的情況下,可以配置不同的拒絕策略,比如:

Java 的線程池是開發中一個小的功能點,但是見微知著,也可以引申到系統的設計和架構上,將知識進行合理地遷移複用。

限流方案中有一點非常關鍵,那就是如何判斷當前的流量已經達到我們設置的最大值,具體有不同的實現策略,下面進行簡單分析。

二、計數器法

一般來說,我們進行限流時使用的是單位時間內的請求數,也就是平常說的 QPS,統計 QPS 最直接的想法就是實現一個計數器。

計數器法是限流算法裏最簡單的一種算法,我們假設一個接口限制 100 秒內的訪問次數不能超過 10000 次,維護一個計數器,每次有新的請求過來,計數器加 1。這時候判斷,如果計數器的值小於限流值,並且與上一次請求的時間間隔還在 100 秒內,允許請求通過,否則拒絕請求;如果超出了時間間隔,要將計數器清零。

下面的代碼裏使用 AtomicInteger 作爲計數器,可以作爲參考:

public class CounterLimiter {
    //初始時間
    private static long startTime = System.currentTimeMillis();
    //初始計數值
    private static final AtomicInteger ZERO = new AtomicInteger(0);
    //時間窗口限制
    private static final int interval = 10000;
    //限制通過請求
    private static int limit = 100;
    //請求計數
    private AtomicInteger requestCount = ZERO;
    //獲取限流
    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        //在時間窗口內
        if (now < startTime + interval) {
            //判斷是否超過最大請求
            if (requestCount.get() < limit) {
                requestCount.incrementAndGet();
                return true;
            }
            return false;
        } else {
            //超時重置
            requestCount = ZERO;
            startTime = now;
            return true;
        }
    }
}

計數器策略進行限流,可以從單點擴展到集羣,適合應用在分佈式環境中。單點限流使用內存即可,如果擴展到集羣限流,可以用一個單獨的存儲節點,比如 Redis 或者 Memcached 來進行存儲,在固定的時間間隔內設置過期時間,就可以統計集羣流量,進行整體限流。

計數器策略有一個很大的缺點,是對臨界流量不友好,限流不夠平滑。假設這樣一個場景,我們限制用戶一分鐘下單不超過 10 萬次,現在在兩個時間窗口的交匯點,前後一秒鐘內,分別發送 10 萬次請求。也就是說,窗口切換的這兩秒鐘內,系統接收了 20 萬下單請求,這個峯值可能會超過系統閾值,影響服務穩定性。

對計數器算法的優化,就是避免出現兩倍窗口限制的請求,可以使用滑動窗口算法實現,感興趣的小夥伴可以去了解一下。

三、漏桶和令牌桶算法

漏桶算法和令牌桶算法,在實際應用中更加廣泛,也經常被拿來對比,所以我們放在一起進行分析。

漏桶算法可以用漏桶來對比,假設現在有一個固定容量的桶,底部鑽一個小孔可以漏水,我們通過控制漏水的速度,來控制請求的處理,實現限流功能。

漏桶算法的拒絕策略很簡單,如果外部請求超出當前閾值,則會在水桶裏積蓄,一直到溢出,系統並不關心溢出的流量。漏桶算法是從出口處限制請求速率,並不存在上面計數器法的臨界問題,請求曲線始終是平滑的。

漏桶算法的一個核心問題是,對請求的過濾太精準了,我們常說,水至清則無魚,其實在限流裏也是一樣的,  我們限制每秒下單 10 萬次,那 10 萬零 1 次請求呢?是不是必須拒絕掉呢?

大部分業務場景下這個答案是否定的,雖然限流了,但還是希望系統允許一定的突發流量,這時候就需要令牌桶算法。

再來看一下令牌桶算法,在令牌桶算法中,假設我們有一個大小恆定的桶,這個桶的容量和設定的閾值有關,桶裏放着很多令牌,通過一個固定的速率,往裏邊放入令牌,如果桶滿了,就把令牌丟掉,最後桶中可以保存的最大令牌數永遠不會超過桶的大小。

當有請求進入時,就嘗試從桶裏取走一個令牌,如果桶裏是空的,那麼這個請求就會被拒絕。

不知道你有沒有應用過 Google 的 Guava 開源工具包,在 Guava 中,就有限流策略的工具類 RateLimiter,RateLimiter 基於令牌桶算法實現流量限制,使用非常方便。

RateLimiter 會按照一定的頻率往桶裏扔令牌,線程拿到令牌才能執行,RateLimter 的 API 可以直接應用,主要方法是 acquire 和 tryAcquire,acquire 會阻塞,tryAcquire 方法則是非阻塞的。下面是一個簡單的示例:

複製代碼

public class LimiterTest {
    public static void main(String[] args) throws InterruptedException {
        //允許10個,permitsPerSecond
        RateLimiter limiter = RateLimiter.create(100);
        for(int i=1;i<200;i++){
            if (limiter.tryAcquire(1)){
                System.out.println("第"+i+"次請求成功");
            }else{
                System.out.println("第"+i+"次請求拒絕");
            }
        }
    }
}

四、不同限流算法的比較

計數器算法實現比較簡單,特別適合集羣情況下使用,但是要考慮臨界情況,可以應用滑動窗口策略進行優化,當然也是要看具體的限流場景。

漏桶算法和令牌桶算法,漏桶算法提供了比較嚴格的限流,令牌桶算法在限流之外,允許一定程度的突發流量。在實際開發中,我們並不需要這麼精準地對流量進行控制,所以令牌桶算法的應用更多一些。

如果我們設置的流量峯值是 permitsPerSecond=N,也就是每秒鐘的請求量,計數器算法會出現 2N 的流量,漏桶算法會始終限制 N 的流量,而令牌桶算法允許大於 N,但不會達到 2N 這麼高的峯值。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7k2ih0A97eWtLfVOsiF4oA