限流常見方案

濤姐濤哥發表於2022-01-01

限流常見方案

 

            我歌月徘徊,我舞影零亂。
    醒時相交歡,醉後各分散。

 

一、限流思路

常見的系統服務限流模式有:熔斷、服務降級、延遲處理和特殊處理四種。

1、熔斷

將熔斷措施嵌入到系統設計中,當系統出現問題時,若短時間內無法修復,系統會自動開啟熔斷開關,拒絕流量訪問,避免大流量對後端的過載請求。

除此之外,系統還能夠動態監測後端程式的修復情況,當程式已恢復穩定時,就關閉熔斷開關,恢復正常服務。

常見的熔斷元件有 Hystrix 以及阿里的 Sentinel。

在Spring Cloud框架裡,熔斷機制通過Hystrix實現。Hystrix會監控微服務間呼叫的狀況,當失敗的呼叫到一定閾值,預設是5秒內20次呼叫失敗,就會啟動熔斷機制。
熔斷機制的註解是@HystrixCommand,Hystrix會找有這個註解的方法,並將這類方法關聯到和熔斷器連在一起的代理上。

2、服務降級

將系統的所有功能服務進行一個分級,當系統出現問題需要緊急限流時,可將不是那麼重要的功能進行降級處理,停止服務,保障核心功能正常運作。

例如在電商平臺中,如果突發流量激增,可臨時將商品評論、積分等非核心功能進行降級,停止這些服務,釋放出機器和 CPU 等資源來保障使用者正常下單。

這些降級的功能服務可以等整個系統恢復正常後,再來啟動,進行補單/補償處理。

除了功能降級以外,還可以採用不直接運算元據庫,而全部讀快取、寫快取的方式作為臨時降級方案。

熔斷&降級

  • 相同點:

    目標一致 都是從可用性和可靠性出發,為了防止系統崩潰;

    使用者體驗類似,最終都讓使用者體驗到的是某些功能暫時不可用。

  • 不同點:

    觸發原因不同,服務熔斷一般是某個服務(下游服務,即被呼叫的服務)故障引起;

  • 而服務降級一般是從整體負荷考慮。

3、延遲處理

延遲處理需要在系統的前端設定一個流量緩衝池,將所有的請求全部緩衝進這個池子,不立即處理。後端真正的業務處理程式從這個池子中取出請求依次處理,常見的可以用佇列模式來實現。

這就相當於用非同步的方式去減少了後端的處理壓力,但是當流量較大時,後端的處理能力有限,緩衝池裡的請求可能處理不及時,會有一定程度延遲。

4、特權處理

這個模式需要將使用者進行分類,通過預設的分類,讓系統優先處理需要高保障的使用者群體,其它使用者群的請求就會延遲處理或者直接不處理。

二、限流演算法

常見的限流演算法有三類:計數器演算法、漏桶演算法和令牌桶演算法。

1、計數器演算法

計數器演算法是限流演算法中最簡單最容易的一種,如上圖每分鐘只允許100個請求,第一個請求進去的時間為startTime,在startTime + 60s內只允許100個請求 。

當60s內超過十個請求後,則拒絕請求;不超過的允許請求,到第60s 則重新設定時間。

限流常見方案
 1 package com.todaytalents.rcn.parser.util;
 2 
 3 import java.util.concurrent.atomic.AtomicInteger;
 4 
 5 /**
 6  * 計數器實現限流:
 7  * 每分鐘只允許100個請求,第一個請求進去的時間為startTime,在startTime + 60s內只允許100個請求
 8  * 60s內超過100個請求後,則拒絕請求,
 9  * 不超過,允許請求,到第60s 重新設定時間。
10  *
11  * @author: Arafat
12  * @date: 2021/12/29
13  * @company: 澳B99999
14  **/
15 public class CalculatorCurrentLimiting {
16 
17     /**
18      * 限流個數
19      */
20     private int maxCount = 100;
21     /**
22      * 指定的時間內:秒
23      */
24     private long specifiedTime = 60;
25     /**
26      * 原子類計數器
27      */
28     private AtomicInteger atomicInteger = new AtomicInteger(0);
29     /**
30      * 起始時間
31      */
32     private long startTime = System.currentTimeMillis();
33 
34     /**
35      * @param maxCount      限流個數
36      * @param specifiedTime 指定的時間內
37      * @return 返回true 不限流,返回false 則限流
38      */
39     public boolean limit(int maxCount, int specifiedTime) {
40         atomicInteger.addAndGet(1);
41         if (1 == atomicInteger.get()) {
42             startTime = System.currentTimeMillis();
43             atomicInteger.addAndGet(1);
44             return true;
45         }
46         // 超過時間間隔,重新開始計數
47         if (System.currentTimeMillis() - startTime > specifiedTime * 1000) {
48             startTime = System.currentTimeMillis();
49             atomicInteger.set(1);
50             return true;
51         }
52         // 還在時間間隔內,檢查是否超過限流數量
53         if (maxCount < atomicInteger.get()) {
54             return false;
55         }
56         return true;
57     }
58 
59 }
View Code

利用計數器演算法比如要求某一個介面,1分鐘內的請求不能超過100次。

可以在開始時設定一個計數器,每次請求,該計數器+1;如果該計數器的值大於10並且與第一次請求的時間間隔在1分鐘內,那麼說明請求過多則限制請求直接返回或不處理,反之。

如果該請求與第一次請求的時間間隔大於1分鐘,並且該計數器的值還在限流範圍內,那麼重置該計數器。

計算器演算法雖然簡單,但它有一個狠致命的臨界問題。

上圖可以看出假若有一個惡意使用者,他在0:59時,瞬間傳送了100個請求,並且在1:00時,又瞬間傳送了100個請求,那麼其實這個使用者在 1秒裡面,瞬間傳送了200個請求。

而上述計數器演算法規定的是1分鐘最多100個請求,也就是每秒鐘最多1.7個請求,而使用者通過在時間視窗的重置節點處突發請求,可以瞬間超過限流的速率限制,這個漏洞可能會瞬間壓垮服務應用。

上述漏洞問題其實是因為計數器限流演算法統計的精度太低,可以藉助滑動視窗演算法將臨界問題的影響降低。

2、滑動視窗

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

那麼滑動視窗怎麼解決剛才的臨界問題的呢?

上圖,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘黃色的格子中。當時間到達1:00時,視窗會往右移動一格,那麼此時時間視窗內的總請求數量一共是200個,超過了限定的100個,所以此時能夠檢測出來觸發了限流。

經比較發現發現,計數器演算法其實就是滑動視窗演算法。只是它沒有對時間視窗做進一步地劃分,所以只有1格。所以,當滑動視窗的格子劃分的越多,則滑動視窗的滾動就越平滑,限流的統計就會越精確。

3、漏桶演算法

漏桶演算法思路很簡單,水(請求)先進入到漏桶裡,漏桶以一定的速度出水,當水流入速度過大會超過桶可接納的容量時直接溢位,可以看出漏桶演算法能強行限制資料的傳輸速率。

使用漏桶演算法,可以保證介面會以一個常速速率來處理請求,所以漏桶演算法必定不會出現臨界問題。

漏桶演算法實現類:

限流常見方案
 1 import java.util.concurrent.atomic.AtomicInteger;
 2 
 3 /**
 4  * 漏桶演算法:把水滴看成請求
 5  *
 6  * @author: Arafat
 7  * @date: 2021/12/29
 8  **/
 9 public class LeakyBucket {
10     /**
11      * 桶的容量
12      */
13     private int capacity = 100;
14     /**
15      * 桶剩餘的水滴的量(初始化的時候桶為空)
16      */
17     private AtomicInteger water = new AtomicInteger(0);
18     /**
19      * 水滴的流出的速率 每1000毫秒流出1滴
20      */
21     private int leakRate;
22     /**
23      * 第一次請求之後,木桶在這個時間點開始漏水
24      */
25     private long leakTimeStamp;
26 
27     public LeakyBucket(int leakRate) {
28         this.leakRate = leakRate;
29     }
30 
31     public boolean acquire() {
32         // 如果是空桶,就用當前時間作為桶開始漏出的時間
33         if (water.get() == 0) {
34             leakTimeStamp = System.currentTimeMillis();
35             water.addAndGet(1);
36             return capacity == 0 ? false : true;
37         }
38         // 先執行漏水,計算剩餘水量
39         int waterLeft = water.get() - ((int) ((System.currentTimeMillis() - leakTimeStamp) / 1000)) * leakRate;
40         water.set(Math.max(0, waterLeft));
41         // 重新更新leakTimeStamp
42         leakTimeStamp = System.currentTimeMillis();
43         // 嘗試加水,並且水還未滿
44         if ((water.get()) < capacity) {
45             water.addAndGet(1);
46             return true;
47         } else {
48             // 水滿,拒絕加水,直接溢位
49             return false;
50         }
51     }
52     
53 }
View Code

使用漏桶限流:

限流常見方案
 1 /**
 2  * @author Arafat
 3  */
 4 @Slf4j
 5 @RestController
 6 @AllArgsConstructor
 7 @RequestMapping("/test")
 8 public class TestController {
 9 
10     /**
11      * 漏桶:水滴的漏出速率是每秒 1 滴
12      */
13     private LeakyBucket leakyBucket = new LeakyBucket(1);
14 
15     private UserService userService;
16 
17     /**
18      * 漏桶限流
19      *
20      * @return
21      */
22     @RequestMapping("/searchUserInfoByLeakyBucket")
23     public Object searchUserInfoByLeakyBucket() {
24         // 限流判斷
25         boolean acquire = leakyBucket.acquire();
26         if (!acquire) {
27             log.info("請您稍後再試!");
28             return Reply.success("請您稍後再試!");
29         }
30         // 若沒有達到限流的要求,直接呼叫介面查詢
31         return Reply.success(userService.search());
32     }
33 
34 }
View Code

漏桶演算法的兩個優點:

  • 削峰:有大量流量進入時,會發生溢位,從而限流保護服務可用。
  • 緩衝:不至於直接請求到伺服器,緩衝壓力,消費速度固定,因為計算效能固定。

4、令牌桶演算法

令牌桶演算法思想:以固定速率產生令牌,放入令牌桶,每次使用者請求都得申請令牌,令牌不足則拒絕請求或等待。

上圖,令牌桶演算法會以一個恆定的速度往桶裡放入令牌,而如果請求需要被處理,則需要先從桶裡獲取一個令牌,當桶裡沒有令牌可取時,則拒絕服務。 

限流常見方案
 1 import java.util.concurrent.Executors;
 2 import java.util.concurrent.ScheduledExecutorService;
 3 import java.util.concurrent.TimeUnit;
 4 
 5 /**
 6  * 令牌桶演算法限流
 7  *
 8  * @author: Arafat
 9  * @date: 2021/12/30
10  **/
11 public class TokensLimiter {
12 
13     /**
14      * 最後一次令牌發放時間
15      */
16     public long timeStamp = System.currentTimeMillis();
17     /**
18      * 桶的容量
19      */
20     public int capacity = 10;
21     /**
22      * 令牌生成速度10/s
23      */
24     public int rate = 10;
25     /**
26      * 當前令牌數量
27      */
28     public int tokens ;
29     /**
30      * 週期性執行緒池
31      */
32     private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);
33 
34     /**
35      * 執行緒池每0.5s傳送隨機數量的請求,
36      * 每次請求計算當前的令牌數量,
37      * 請求令牌數量超出當前令牌數量,則限流。
38      */
39     public void acquire() {
40         scheduledExecutorService.scheduleWithFixedDelay(() -> {
41             long now = System.currentTimeMillis();
42             // 當前令牌數
43             tokens = Math.min(capacity, (int) (tokens + (now - timeStamp) * rate) / 1000);
44             //每隔0.5秒傳送隨機數量的請求
45             int permits = (int) (Math.random() * 9) + 1;
46             System.out.println("請求令牌數:" + permits + ",當前令牌數:" + tokens);
47             timeStamp = now;
48             if (tokens < permits) {
49                 // 若不到令牌,則拒絕
50                 System.out.println("限流了");
51             } else {
52                 // 還有令牌,領取令牌
53                 tokens -= permits;
54                 System.out.println("剩餘令牌=" + tokens);;
55             }
56         }, 1000, 500, TimeUnit.MILLISECONDS);
57     }
58 
59     public static void main(String[] args) {
60         TokensLimiter tokensLimiter = new TokensLimiter();
61         tokensLimiter.acquire();
62     }
63 
64 }
View Code

令牌桶演算法預設從桶裡移除令牌是不需要耗費時間的,如果給移除令牌設定一個延時時間,那麼實際上又採用了漏桶演算法的思路。

至於臨界問題的場景,在0:59秒的時候,由於桶內積滿了100個token,所以這100個請求可以瞬間通過。但是由於token是以較低的速率填充的,所以在1:00的時候,桶內的token數量不可能達到100個,那麼此時不可能再有100個請求通過。所以令牌桶演算法可以很好地解決臨界問題。
漏桶與令牌桶演算法的區別

  • 主要區別在於“漏桶演算法”能夠強行限制資料的傳輸速率,而“令牌桶演算法”在能夠限制資料的平均傳輸速率外,還允許某種程度的突發傳輸。
  • 在“令牌桶演算法”中,只要令牌桶中存在令牌,那麼就允許突發地傳輸資料直到達到使用者配置的門限,因此它適合於具有突發特性的流量。
  • 令牌桶演算法由於實現簡單,且允許某些流量的突發,對使用者友好,所以被業界採用地較多。
  • 具體情況具體分析,只有最合適的演算法,沒有最優的演算法。

基於谷歌RateLimiter實現限流

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

限流常見方案
 1 import com.google.common.util.concurrent.RateLimiter;
 2 import lombok.AllArgsConstructor;
 3 import lombok.extern.slf4j.Slf4j;
 4 import org.springframework.web.bind.annotation.RequestMapping;
 5 import org.springframework.web.bind.annotation.RestController;
 6 
 7 import java.util.concurrent.TimeUnit;
 8 
 9 /**
10  * @author Arafat
11  */
12 @Slf4j
13 @RestController
14 @AllArgsConstructor
15 @RequestMapping("/test")
16 public class TestController {
17 
18     /**
19      * 每秒鐘放入n個令牌,相當於每秒只允許執行n個請求
20      * n = 1
21      * n == 5
22      */
23     //private static final RateLimiter RATE_LIMITER = RateLimiter.create(1);
24     private static final RateLimiter RATE_LIMITER = RateLimiter.create(5);
25 
26     public static void main(String[] args) {
27         // 每秒中限制1個請求  0:表示等待超時時間,設定0表示不等待,直接拒絕請求
28         boolean tryAcquire = RATE_LIMITER.tryAcquire(0, TimeUnit.SECONDS);
29         // false表示沒有獲取到token
30         if (!tryAcquire) {
31             System.out.println("現在搶購的人數過多,請稍等一下下哦!");
32         }
33 
34         // tryAcquire 模擬有20個請求
35         for (int i = 0; i < 20; i++) {
36             /**
37              * 嘗試從令牌桶中獲取令牌,
38              * 若獲取不到則等待300毫秒看能不能獲取到
39              */
40             boolean request = RATE_LIMITER.tryAcquire(300, TimeUnit.MILLISECONDS);
41             if (request) {
42                 // 獲取成功,執行相應邏輯
43                 handle(i);
44             }
45         }
46 
47         // acquire 模擬有20個請求
48         for (int i = 0; i < 20; i++) {
49             // 從令牌桶中獲取一個令牌,若沒有獲取到會阻塞直到獲取到為止,所以所有的請求都會被執行
50             RATE_LIMITER.acquire();
51             // 獲取成功,執行相應邏輯
52             handle(i);
53         }
54     }
55 
56     private static void handle(int i) {
57         System.out.println("第 " + i + " 次請求OK~~~");
58     }
59 
60 }
View Code

三、叢集限流

前面幾種演算法都屬於單機限流的範疇,但簡單的單機限流仍無法滿足複雜的場景。比如為了限制某個資源被每個使用者或者商戶的訪問次數,5s只能訪問2次,或者一天只能呼叫1000次,這種場景單機限流是無法實現的,這時就需要通過叢集限流進行實現。

可以使用Redis實現叢集限流,大概思路是每次有相關操作的時候,就向redis伺服器傳送一個incr命令。

redisOperations.opsForValue().increment()

比如需要限制某個使用者訪問某個詳情/details介面的次數,只需要拼接使用者id和介面名,加上當前服務名的字首作為redis的key,每次該使用者訪問此介面時,只需要對這個key執行incr命令,再這個key帶上過期時間,就可以實現指定時間的訪問頻率。

 

 

 

 

 

我歌月徘徊,我舞影零亂。
醒時相交歡,醉後各分散。

 

 

 

 

相關文章