【限流01】限流算法理论篇
微服務就是將復雜的大應用拆分成小的應用,這樣做的好處是各個應用之間獨立開發、測試、上線,互不影響。但是服務拆分之后,帶來的問題也很多,我們需要保障微服務的正常運行,就需要進行服務治理。常用手段有:鑒權、限流、降級、熔斷等。
其中,限流是指對某個接口的調用頻率進行限制,防止接口調用頻率過快導致線程資源被耗盡從而導致整個系統響應變慢。限流在很多業務中都有應用,比如:秒殺、雙11。當用戶請求量過大時,就會拒絕后續接口請求,保障系統的穩定性。
接口限流的實現思路是:統計某個時間段內的接口調用次數,當調用次數超過設置的閾值時,就進行限流限制接口訪問。
常見的限流算法有:固定時間窗口算法、滑動時間窗口算法、令牌桶算法、漏桶算法等,下面我們將一一介紹每種算法的實現思路和代碼實現。
一、固定時間窗口限流算法
1、算法概述
固定時間窗口限流算法的思路就是:確定一段時間段,在該時間段內統計接口的調用次數,來判斷是否限流。
實現步驟如下:
 選定一個時間起點,當接口請求到來時,
- 接口訪問次數小于閾值,可以訪問,接口訪問次數 + 1;
- 接口訪問次數大于閾值,拒絕該時間段內后續訪問進行限流,接口訪問次數不變;
- 進入下一個時間窗口之后,計數器清零,時間起點設置為當前時間,這樣就進入下一個時間窗口。
示意圖如下:
 
 ? (圖片來源:https://time.geekbang.org/column/article/80388?utm_term=zeusNGLWQ&utm_source=xiangqingye&utm_medium=geektime&utm_campaign=end&utm_content=xiangqingyelink1104,下圖同上)
這種限流算法的缺點是:無法應對兩個時間窗口臨界時間內的突發流量。
如下圖:假設要求每秒鐘接口請求次數不超過100,在第1s時間窗口內接口請求次數為100,但是都集中在最后10ms;第2s時間窗口內接口請求次數也為100,都集中在前10ms內;兩個時間窗口請求次數都小于100,滿足要求。但是在兩個10ms內接口請求次數=200 > 100。如果這個次數不是200,是2000萬,可能就會導致系統崩潰。
 
2、代碼實現
public class FixedWindowRateLimitAlg implements RateLimitAlg {// msprivate static final long LOCK_EXPIRE_TIME = 200L;private Stopwatch stopWatch;// 限流計數器private AtomicInteger counter = new AtomicInteger(0);private final int limit;private Lock lock = new ReentrantLock();public FixedWindowRateLimitAlg(int limit) {this(limit, Stopwatch.createStarted());}public FixedWindowRateLimitAlg(int limit, Stopwatch stopWatch) {this.limit = limit;this.stopWatch = stopWatch;}@Overridepublic boolean tryAcquire() throws InterruptedException {int currentCount = counter.incrementAndGet();// 未達到限流if (currentCount < limit) {return true;}// 使用固定時間窗口統計當前窗口請求數// 請求到來時,加鎖進行計數器統計工作try {if (lock.tryLock(LOCK_EXPIRE_TIME, TimeUnit.MILLISECONDS)) {// 如果超過這個時間窗口, 則計數器counter歸零, stopWatch, 窗口進入下一個窗口if (stopWatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {counter.set(0);stopWatch.reset();}// 不超過, 則當前時間窗口內的計數器counter+1currentCount = counter.incrementAndGet();return currentCount < limit;}} catch (InterruptedException e) {System.out.println("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");throw new InterruptedException("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");} finally {lock.unlock();}// 出現異常 不能影響接口正常請求return true;}}二、滑動時間窗口限流算法
1、算法概述
固定時間窗口限流算法無法處理兩個時間窗口臨界值流量突增的情況。為了解決這個問題,我們可以稍微優化下固定時間窗口限流算法,通過限制任意時間窗口內(比如:1S)接口請求數都不超過某個閾值,這個優化后的算法就叫做滑動時間窗口限流算法。
滑動時間窗口限流算法將一個大的時間窗口分成粒度更小的時間窗口,每個子窗口獨立統計次數。每經過一個子窗口的時間,整體窗口就向右滑動一格。
 如上圖所示,假設要求每分鐘通過次數不超過100次,將1分鐘分成6個10s的單元格。
 第一個圖中假設最后1個10s內(序號:6)通過請求次數為100次,第二個圖中假設第1個10s (序號:7)內請求次數也通過100次。由于是滑動窗口,第一個窗口向右移動一格后,在第二個滑動窗口內,序號6、7兩者加起來的請求次數為200>100,所以限流,從而解決了固定時間窗口無法處理兩個窗口臨界值的問題。
雖然滑動時間窗口算法可以保證任意時間窗口內接口請求次數不超過閾值,但是仍然無法避免更細粒度流量突增的場景,比如在某個10s內的單元格內流量突增無法立即被限流。同時,使用滑動窗口算法時,流量曲線如下,無法達到平滑過渡的效果,無法控制流量速度
 
2、算法實現
可以使用循環隊列來實現滑動時間窗口限流算法:
假設限流規則是任意1s內,接口請求數不超過N次。
創建一個N+1 (循環隊列本身會浪費一個存儲單元,所以是N+1)的循環隊列,用來記錄1S內的請求。
當有新的請求到來時,
- 將與該請求的時間間隔超過1s的請求從隊列中移除(移動head指針);
- 再看循環隊列中是否有空閑位置,如果有,則把新請求存儲在隊列尾部(tail指針所在位置,同時移動tail指針);
- 如果循環隊列尾部沒有空閑位置,說明這個1s內的請求次數已經超過限流次數N,拒絕后續服務。
算法實現的示意圖如下:
 
假設1S內請求次數不能超過6次,整個隊列分成(6+1)個單元格。
- 18:060代表 18s 60ms的時候,第一個請求到來,此時隊列為空,于是存儲在第一個單元格內(即head指針指向的位置);
- 同樣,18:123、18:336、18:569、18:702、18:906分別為第2、3、4、5、6個請求,均在1s間隔內且隊列都有空閑位置,于是依次存儲到對應單元格內;
- 當19:003請求到來時,沒有與其超過1s間隔的請求,所以不需要移除其他請求;由于隊列尾部沒有空閑位置,說明1S內的請求次數已經超過6次,拒絕該請求訪問;
- 當19:406到來時,與其超過1S間隔的請求有18:060、18:123、18:336,該3個請求需要移除隊列(逆時針移動head指針3個單元格),同時tail指針逆時針移動一格后,存放當前請求19:406;
3、偽代碼實現
/*** @author: wanggenshen* @date: 2020/6/29 21:56.* @description: 循環隊列實現滑動窗口限流算法*/ public class SlidingWindowLimiter {private int windowSize;private CircularQueue queue;public SlidingWindowLimiter(int windowSize) {this.windowSize = windowSize;queue = new CircularQueue(windowSize);}public boolean tryAcquire(long now) {// 判斷是否有間隔1s的請求, 有則移除隊列while (queue.prevNode() != -1 && now - queue.prevNode() > 1000) {System.out.println("超過1S間隔, 移除超過間隔的節點: " + queue.prevNode() + "當前時間: " + now + ", 間隔: " + (now - queue.prevNode()));queue.dequeue();}// 隊列已滿, 拒絕訪問if (queue.isFull()) {System.out.println("隊列已滿, now: " + now);return false;}queue.enqueue(now);return true;}static class CircularQueue {/*** 每次請求的時間戳*/private long[] timeQueue;/*** 隊列大小*/private int size;/*** 頭指針*/private int headIndex;/*** 尾指針*/private int tailIndex;public CircularQueue(int size) {// 循環隊列尾部指針多占用一個單元格timeQueue = new long[size + 1];this.size = size + 1;}/*** 入隊*/public void enqueue (long timestamp) {// 隊列已滿if (isFull()) {throw new RuntimeException("Exceed queue size.");}timeQueue[tailIndex] = timestamp;tailIndex = (tailIndex + 1) % size;}/*** 出隊** @return*/public long dequeue () {// 隊列為空if (isEmpty()) {return -1;}long timestamp = timeQueue[headIndex];headIndex = (headIndex + 1) % size;return timestamp;}public long prevNode() {// 隊列為空if (isEmpty()) {return -1;}return timeQueue[headIndex];}public boolean isFull() {return (tailIndex + 1) % size == headIndex;}public boolean isEmpty() {return tailIndex == headIndex;}}}三、漏桶算法
1、算法概述
實際上,當請求數超過閾值時,我們不希望后續流量被全部限流,而是希望將流量控制在一定速度內。
 漏桶算法就是基于流控來控制流量。
 
如下圖所示,調用方請求比作是水龍頭出的水,水桶出的水是比作是接口提供方處理的請求。當水龍頭出水速度大于桶里的水流出速度(類似接口調用請求頻率過快),水直接溢出(類似請求直接被限流)。通過這種方法不僅能保證流量不會超過閾值,同時保證接口的請求數以穩定的速度去處理。
 
 漏桶算法的優點在于能夠控制接口提供方的接口被勻速處理;缺點在于設置的速率不當會影響接口處理的效率。
2、代碼實現
偽代碼如下:
/*** @author: wanggenshen* @date: 2020/6/29 21:00.* @description: 漏桶限流算法*/ public class LeakyBucketLimiter {/*** 桶內剩余的水*/private long left;/*** 桶的容量*/private long capacity;/*** 一桶水漏完的時間*/private long duration;/*** 桶漏水的速率, capacity = duration*velocity*/private double velocity;/*** 上一次成功放入水桶的時間*/private long lastUpdateTime;public boolean acquire() {long now = System.currentTimeMillis();// 剩余的水量 - 桶勻速漏出去的水left = Math.max(0, left - (long)((now - lastUpdateTime) * velocity));// 當前水桶再加一單位水沒有溢出, 則可以繼續訪問if (left++ <= capacity) {lastUpdateTime = now;return true;} else {return false;}} }四、令牌桶算法
1、算法概述
令牌桶算法的實現原理是:
以恒定速率生成令牌放進令牌桶,令牌桶滿了的時候就丟棄不再放入令牌桶;
如果想要處理請求,就需要從令牌桶中取一個令牌。能取出令牌則去處理請求;沒有令牌則拒絕請求。
 
 令牌桶算法與漏桶算法很類似,最主要的區別在于:
-  漏桶算法輸入速率不定,但是輸出速率恒定;令牌桶算法輸出速率可以根據流量大小進行調整; 
-  從接口處理者的角度看,漏桶算法只能以固定頻率去處理請求(比如每秒只能處理1個請求,如果此時來了10個請求,漏桶需要花10s處理完);而令牌桶算法可以處理突發流量,比如來了20個請求,如果令牌桶中有>=20個令牌,那么處理者就可以一下子全部處理這20個請求; 
2、偽代碼實現
/*** @author: wanggenshen* @date: 2020/6/29 21:00.* @description: 令牌桶限流算法*/ public class TokenBucketLimiter {/*** 令牌桶桶內剩余的令牌*/private long left;/*** 令牌桶的容量*/private long capacity;/*** 一桶水漏完的時間*/private long duration;/*** 令牌桶生產令牌的速率, capacity = duration*velocity*/private double velocity;/*** 上一次拿走令牌的時間*/private long lastUpdateTime;public boolean acquire() {long now = System.currentTimeMillis();// 令牌桶余量 = 【上一次令牌桶剩余的令牌】+ 【(上一次拿走令牌到現在的時間段) * 每個單位時間生產令牌的速率 】// 生產出的令牌 超過令牌桶的容量時, 則舍棄left = Math.min(capacity, left + (long)((now - lastUpdateTime) * velocity));// 若當前能夠成功領取令牌, 則可以訪問if (left-- >= 0) {lastUpdateTime = now;return true;} else {return false;}} }生產環境下可以考慮使用Guava提供的令牌桶算法實現類: RateLimiter來進行限流,RateLimiter的實現是線程安全的。
五、分布式限流
生產環境下服務基本上分布式部署,那么在對服務進行限流時需要考慮到分布式限流。
最簡單的做法是給每臺應用服務器平均分配流控閾值,將分布式限流轉換為單機限流。如總流量不超過1000次,那么5個服務實例,每個實例請求數不能超過200次。但是如果遇到流量不均勻(比如一臺機器流量一直是10、另外幾臺> 200)、或者有一臺宕機,那么另外幾臺平均下來就是250>200,這種做法不是很好。
常見的實現思路有兩種:
- 中心化:使用一個第三方服務統一存儲所有服務實例的調用次數,由其去判斷是否進行限流。這種方式需要注意第三方服務宕機導致不可用問題。這個時候可以退化成單機流控。
- 去中心化:每個服務單獨保存同一份流控數據,但是很難做到保持狀態一致,即CAP中的C。
一般使用中心化這種思路。
1、TokenServer 流控
Sentinel提供了TokenServer,作為一個獨立服務來統計總調用量、判斷單個請求是否允許訪問。應用服務器每次接收到請求后,都要與TokenServer進行一次通信,判斷該次請求能否訪問。
 
 這種實現方式的好處是:由TokenServer集中管理每個服務實例的總調用量,服務實例不用關心請求的統計工作;
缺點是:非常依賴于TokenServer的性能,因為需要與其進行網絡通信。同時需要關系TokenServer服務的單節點故障問題。
2、存儲式流控
存儲式流控是每個服務請求到來時,從第三方存儲(如Redis、MySQL)讀取接口請求數、然后再將請求數更新回緩存;
拿到請求數后由每個服務實例自己去判斷是否需要限流。
 
 總結
要設計一個高性能、高可靠性的分布式流控性能需要考慮網絡通信、加鎖同步等對性能帶來的影響,同時也需要考慮分布式環境的可靠性。
參考:https://mp.weixin.qq.com/s/joP22Z8zblcDBAV1keSdJw
總結
以上是生活随笔為你收集整理的【限流01】限流算法理论篇的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: CentOS7环境下MySQL踩坑记
- 下一篇: mysql查看当前环境变量
