一、回顧:計算器算法存在問題
對於秒級以上的時間周期來說,會存在一個非常嚴重的問題,那就是臨界問題。

從上圖中我們可以看到,假設有一個惡意用戶,他在0:59時,瞬間發送了100個請求,並且1:00又瞬間發送了100個請求,那么其實這個用戶在 1秒里面,瞬間發送了200個請求。我們剛才規定的是1分鍾最多100個請求,也就是每秒鍾最多1.7個請求,用戶通過在時間窗口的重置節點處突發請求, 可以瞬間超過我們的速率限制。用戶有可能通過算法的這個漏洞,瞬間壓垮我們的應用。
下面就說說如何解決這個由於問題。計算器的問題其實是因為我們統計的精度太低(統計的1秒內的流量情況,只有1個值進行記錄)。那么如何很好地處理這個問題呢?或者說,如何將臨界問題的影響降低呢?我們可以看下面的滑動窗口算法。
二、滑動窗口算法
滑動窗口,又稱rolling window。為了解決計數器法統計精度太低的問題,引入了滑動窗口算法。下面這張圖,很好 地解釋了滑動窗口算法:

是一分鍾。然后我們將時間窗口進行划分,比如圖中,我們就將滑動窗口划成了6格,所以每格代表的是10秒鍾。每過10秒鍾,我們的時間窗口就會往右滑動一格。每一個格子都有自己獨立的計數器counter,比如當一個請求 在0:35秒的時候到達,那么0:30~0:39對應的counter就會加1。
那么滑動窗口怎么解決剛才的臨界問題的呢?在上圖中,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘×××的格子中。當時間到達1:00時,我們的窗口會往右移動一格,那么此時時間窗口內的總請求數量一共是200個,超過了限定的100個,所以此時能夠檢測出來觸發了限流。
回顧一下上面的計數器算法,我們可以發現,計數器算法其實就是滑動窗口算法。只是它沒有對時間窗口做進一步地划分,所以只有1格。
由此可見,當滑動窗口的格子划分的越多,那么滑動窗口的滾動就越平滑,限流的統計就會越精確。
BTW:
(1)滑動窗口算法是以當前這個時間點為基准,往前推移1秒進行計算當前1秒內的請求量情況。
(2)滑動窗口限流統計的精准度是由划分的格子多少決定的,這個怎么理解吶,就是把1秒中進行划分成多個時間段,比如2個格子的話,那么就是2段,0-500ms和501-1000ms。那么就會兩個值進行存儲統計請求量,比如數組[0,1] 各存儲一個段的請求值。
(3)計算器算法是滑動窗口算法將時間段划分為1的特殊情況。
綜上我們對滑動窗口算法下個定義:滑動窗口算法是將時間周期分為N個小周期,分別記錄每個小周期內訪問次數,並且根據時間滑動刪除過期的小周期。
三、滑動窗口算法實現
我們看一段代碼:
/** * 滑動時間窗口限流實現 * 假設某個服務最多只能每秒鍾處理100個請求,我們可以設置一個1秒鍾的滑動時間窗口, * 窗口中有10個格子,每個格子100毫秒,每100毫秒移動一次,每次移動都需要記錄當前服務請求的次數 */ public class SlidingTimeWindow { // 時間窗口內最大請求數 public final int limit = 100; // 服務訪問次數 Long counter = 0L; // 使用LinkedList來記錄滑動窗口的10個格子。 LinkedList<Long> slots = new LinkedList<Long>(); // 時間划分多少段落 int split = 10; // 是否限流了,true:限流了,false:允許正常訪問。 boolean isLimit = false; private void doCheck() throws InterruptedException { while (true) { slots.addLast(counter); if (slots.size() > split) { slots.removeFirst();// 超出了,就把第一個移出。 } // 比較最后一個和第一個,兩者相差100以上就限流 if ((slots.peekLast() - slots.peekFirst()) > limit) { System.out.println("限流了。。"); // 修改限流標記為true isLimit = true; } else { // 修改限流標記為false isLimit = false; } Thread.sleep(1000/split); } } /** * 測試 * @param args * @throws InterruptedException */ public static void main(String[] args) throws InterruptedException { SlidingTimeWindow timeWindow = new SlidingTimeWindow(); //開啟一個線程判斷當前的限流情況. new Thread(new Runnable() { @Override public void run() { try { timeWindow.doCheck(); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); //模擬請求. while(true) { //判斷是否被限流了. if(!timeWindow.isLimit) { timeWindow.counter++; //未被限流執行相應的業務方法. // executeBusinessCode(); //模擬業務執行方法時間. Thread.sleep(new Random().nextInt(15)); System.out.println("業務方法執行完了..."); }else { System.out.println("被限流了,直接返回給用戶"); } } } }
說明:
(1)doCheck方法,就是改變是否限流標志位的方法:這里通過LinkedList(鏈表)來進行模擬,通過LinkedList的大小來控制1秒分隔多少段。peekLast()、slots.peekFirst()就是這個時間窗口的一個流量值。
(2)在main方法中,我們通過開啟一個線程來執行我們的doCheck()方法,由於doCheck()方法是while(true),所以會一直執行,sleep的時間剛好是每個時間間隔值,那么就會不斷刷新LinkedList中的值。
(3)最后while(true)代碼是模擬業務執行調用代碼,也就是controller發起調用的代碼了。
四、小結:
(1)滑動窗口算法是將時間周期分為N個小周期,分別記錄每個小周期內訪問次數,並且根據時間滑動刪除過期的小周期。
(2)計算器算法也屬於滑動窗口算法,是滑動窗口算法的一種情況(划分周期為1)。
(3)滑動窗口算法實現方式:數組(多少周期,數組大小就是多大,將每個時間通過一定的算法落到每個數組下標即可);鏈表(通過鏈表中的大小來判斷划分周期個數,如果超過了,可以使用鏈表的removeFirst()移除第一個,從而實現往后移動窗口)
(4)數組實現的實例:這個可以看Sentinel的源碼,就是定義了一個數組來實現的,時間划分是2。
五、小思考
當在系統剛剛啟動的時候,系統很多資源都未分配完成,瞬間來了n個請求,如果使用滑動窗口算法的話,那么只有達到了QPS控制閾值,才會觸發限流機制,那么這樣針對系統剛啟動資源未分配的情況,就無法防止系統癱瘓了。
針對上面的這種情況,那么你有什么對應的限流算法可以應對吶?
