限流常見方案
我歌月徘徊,我舞影零亂。
醒時相交歡,醉后各分散。
一、限流思路
常見的系統服務限流模式有:熔斷、服務降級、延遲處理和特殊處理四種。
1、熔斷
將熔斷措施嵌入到系統設計中,當系統出現問題時,若短時間內無法修復,系統會自動開啟熔斷開關,拒絕流量訪問,避免大流量對后端的過載請求。
除此之外,系統還能夠動態監測后端程序的修復情況,當程序已恢復穩定時,就關閉熔斷開關,恢復正常服務。
常見的熔斷組件有 Hystrix 以及阿里的 Sentinel。
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 }
利用計數器算法比如要求某一個接口,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 }
使用漏桶限流:

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 }
漏桶算法的兩個優點:
- 削峰:有大量流量進入時,會發生溢出,從而限流保護服務可用。
- 緩沖:不至於直接請求到服務器,緩沖壓力,消費速度固定,因為計算性能固定。
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 }
令牌桶算法默認從桶里移除令牌是不需要耗費時間的,如果給移除令牌設置一個延時時間,那么實際上又采用了漏桶算法的思路。
至於臨界問題的場景,在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 }
三、集群限流
前面幾種算法都屬於單機限流的范疇,但簡單的單機限流仍無法滿足復雜的場景。比如為了限制某個資源被每個用戶或者商戶的訪問次數,5s只能訪問2次,或者一天只能調用1000次,這種場景單機限流是無法實現的,這時就需要通過集群限流進行實現。
可以使用Redis實現集群限流,大概思路是每次有相關操作的時候,就向redis服務器發送一個incr命令。
redisOperations.opsForValue().increment()
比如需要限制某個用戶訪問某個詳情/details接口的次數,只需要拼接用戶id和接口名,加上當前服務名的前綴作為redis的key,每次該用戶訪問此接口時,只需要對這個key執行incr命令,再這個key帶上過期時間,就可以實現指定時間的訪問頻率。
我歌月徘徊,我舞影零亂。
醒時相交歡,醉后各分散。