高併發系統限流算法詳解

在大數據量高併發訪問時,經常會出現服務或接口面對暴漲的請求而不可用的情況,甚至引發連鎖反映導致整個系統崩潰。此時你需要使用的技術手段之一就是限流,當請求達到一定的併發數或速率,就進行等待、排隊、降級、拒絕服務等。在限流時,常見的兩種算法是漏桶和令牌桶算法算法,本文即對相關內容進行重點介紹。

四種常見的限流算法
         1、計數器算法
         2、滑動窗口算法
         3、漏桶算法
         4、令牌桶算法
      兩種限流控制的實現方式:RateLimiter、Semphore

一、計數器算法

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

public class CounterTest {
    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;
        }
    }

    public long getNowTime() {
        return System.currentTimeMillis();
    }
}

這個算法雖然簡單,但是有一個十分致命的問題,那就是臨界問題,我們看下圖:
在這裏插入圖片描述
從上圖中我們可以看到,假設有一個惡意用戶,他在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格。

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

三、漏桶算法

主要目的是控制數據注入到網絡的速率,平滑網絡上的突發流量。漏桶算法提供了一種機制,通過它,突發流量可以被整形以便爲網絡提供一個穩定的流量。漏桶算法的示意圖如下:

在這裏插入圖片描述
請求先進入到漏桶裏,漏桶以一定的速度出水,當水請求過大會直接溢出,可以看出漏桶算法能強行限制數據的傳輸速率。

在算法實現方面,可以準備一個隊列,用來保存請求,另外通過一個線程池定期從隊列中獲取請求並執行,可以一次性獲取多個併發執行。

這種算法,在使用過後也存在弊端:無法應對短時間的突發流量。

四、令牌桶算法

是網絡流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一種算法。典型情況下,令牌桶算法用來控制發送到網絡上的數據的數目,並允許突發數據的發送。令牌桶算法示意圖如下所示:

在這裏插入圖片描述
大小固定的令牌桶可自行以恆定的速率源源不斷地產生令牌。如果令牌不被消耗,或者被消耗的速度小於產生的速度,令牌就會不斷地增多,直到把桶填滿。後面再產生的令牌就會從桶中溢出。最後桶中可以保存的最大令牌數永遠不會超過桶的大小。
放令牌這個動作是持續不斷的進行,如果桶中令牌數達到上限,就丟棄令牌,所以就存在這種情況,桶中一直有大量的可用令牌,這時進來的請求就可以直接拿到令牌執行,比如設置qps爲100,那麼限流器初始化完成一秒後,桶中就已經有100個令牌了,這時服務還沒完全啓動好,等啓動完成對外提供服務時,該限流器可以抵擋瞬時的100個請求。所以,只有桶中沒有令牌時,請求才會進行等待,最後相當於以一定的速率執行。

漏桶算法和令牌桶算法比較:
        兩者主要區別在於“漏桶算法”能夠強行限制數據的傳輸速率,而“令牌桶算法”在能夠限制數據的平均傳輸速率外,還允許某種程度的突發傳輸。在“令牌桶算法”中,只要令牌桶中存在令牌,那麼就允許突發地傳輸數據直到達到用戶配置的門限,所以它適合於具有突發特性的流量。

五、使用Guava的RateLimiter進行限流控制

Guava是google提供的java擴展類庫,其中的限流工具類RateLimiter採用的就是令牌桶算法。RateLimiter 從概念上來講,速率限制器會在可配置的速率下分配許可證,如果必要的話,每個acquire() 會阻塞當前線程直到許可證可用後獲取該許可證,一旦獲取到許可證,不需要再釋放許可證。通俗的講RateLimiter會按照一定的頻率往桶裏扔令牌,線程拿到令牌才能執行,比如你希望自己的應用程序QPS不要超過1000,那麼RateLimiter設置1000的速率後,就會每秒往桶裏扔1000個令牌。例如我們需要處理一個任務列表,但我們不希望每秒的任務提交超過兩個,此時可以採用如下方式:
public class RateLimiterMain {
   public static void main(String[] args) {
       RateLimiter rateLimiter = RateLimiter.create(10);
       for (int i = 0; i < 10; i++) {
           new Thread(new Runnable() {
               @Override
               public void run() {
                   rateLimiter.acquire()
 
                   System.out.println("pass");
               }
           }).start();
       }
   }
}

在上述例子中,創建了一個每秒生成10個令牌的限流器,即100ms生成一個,並最多保存10個令牌,多餘的會被丟棄。

rateLimiter提供了acquire()和tryAcquire()接口
1、使用acquire()方法,如果沒有可用令牌,會一直阻塞直到有足夠的令牌。
2、使用tryAcquire()方法,如果沒有可用令牌,就直接返回false。
3、使用tryAcquire()帶超時時間的方法,如果沒有可用令牌,就會判斷在超時時間內是否可以等到令牌,如果不能,就返回false,如果可以,就阻塞等待。

六、使用Semphore進行併發流控

Java 併發庫的Semaphore 可以很輕鬆完成信號量控制,Semaphore可以控制某個資源可被同時訪問的個數,通過 acquire() 獲取一個許可,如果沒有就等待,而 release() 釋放一個許可。單個信號量的Semaphore對象可以實現互斥鎖的功能,並且可以是由一個線程獲得了“鎖”,再由另一個線程釋放“鎖”,這可應用於死鎖恢復的一些場合。下面的Demo中申明瞭一個只有200個許可的Semaphore,而有5000個線程要訪問這個資源,通過acquire()和release()獲取和釋放訪問許可:

 //請求總數
 private static int clientTotal = 5000;
 //同時併發執行的線程數
 private static int threadTotal = 200;
 public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        
        for(int i=0; i<clientTotal; i++){
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }

進行限流控制還可以有很多種方法,針對不同的場景各有優劣,例如通過AtomicLong計數器控制、使用MQ消息隊列進行流量消峯等等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章