限流一詞經常使用於計算機網絡之中,定義以下:程序員
In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks.
經過控制數據的網絡數據的發送或接收速率來防止可能出現的DOS攻擊。而實際的軟件服務過程當中,限流也可用於API服務的保護。因爲提供服務的計算機資源(包括CPU、內存、磁盤及網絡帶寬等)是有限的,則其提供的API服務的QPS也是有限的,限流工具就是經過限流算法對API訪問進行限制,保證服務不會超過其能承受的負載壓力。
本文主要涉及內容包括:算法
<!-- more -->編程
援引wiki中關於限流的Algorithms一小節的說明,經常使用的限流算法主要包括:數組
以上幾種方式其實能夠簡單的分爲計數算法、漏桶算法和令牌桶算法。緩存
不管固定窗口仍是滑動窗口核心均是對請求進行計數,區別僅僅在於對於計數時間區間的處理。網絡
固定窗口計數法思想比較簡單,只須要肯定兩個參數:計數週期T及週期內最大訪問(調用)數N。請求到達時使用如下流程進行操做:
多線程
固定窗口計數實現簡單,而且只須要記錄上一個週期起始時間與週期內訪問總數,幾乎不消耗額外的存儲空間。ide
固定窗口計數缺點也很是明顯,在進行週期切換時,上一個週期的訪問總數會當即置爲0,這可能致使在進行週期切換時可能出現流量突發,以下圖所示
函數
簡化模型,假設在兩個週期T0中a時刻有n1個訪問同時到達,週期T1中b時刻有n2個訪問同時到達,且n1和n2均小於設定的最高訪問次數N(不然會觸發限流)。
根據以上假設能夠推斷,限流器不會限流,n1+n2次訪問都可以經過。現假設a,b兩時刻之間時間差爲t,則能夠得出如下關係:工具
$$ \left\{ \begin{aligned} n1 \le N \\ n2 \le N \\ (n1+n2) \le 2N \\ \end{aligned} \right. $$
根據觀察可發現,在$t$的時間內,出現了$n1+n2$次請求,且$n1+n2$是可能大於$N$的,因此在實際使用過程當中,固定窗口計數器存在突破限額N的可能。
舉例,限制QPS爲10,某用戶在週期切換的先後的0.1秒內,分兩次發送10次請求,根據算法規則此20次請求可經過限流器,則0.1面秒請求數20,超過每秒最多10次請求的限制。
爲解決固定窗口計數帶來的週期切換處流量突發問題,可使用滑動窗口計數。滑動窗口計算本質上也是固定窗口計數,區別在於將計數週期進行細化。
滑動窗口計數法與股固定窗口計數法相比較,除了計數週期T及週期內最大訪問(調用)數N兩個參數,增長一個參數M,用於設置週期T內的滑動窗口數。限流流程以下:
滑動窗口計數在固定窗口計數記錄數據基礎上,須要增長一個長度爲M的計數數組,用於記錄在窗口滑動過程當中各窗口訪問數據。其流程示例以下:
滑動窗口針對週期進行了細分,不存在週期到後計數直接重置爲0的狀況,故不會出現跨週期的流量限制問題。
漏桶限流算法的實現原理在wiki有詳細說明,引用其原理圖:
簡單說明爲:人爲設定漏桶流出速度及漏桶的總容量,在請求到達時判斷當前漏桶容量是否已滿,不滿則可將請求存入桶中,不然拋棄請求。程序以設定的速率取出請求進行處理。
根據描述,須要肯定參數爲漏桶流出速度r及漏桶容量N,流程以下:
漏桶算法主要特色在於能夠保證不管收到請求的速率如何,真正抵達服務方接口的請求速率最大爲r,可以對輸入的請求進行平滑處理。
漏桶算法的缺點也很是明顯,因爲其只能以特定速率處理請求,則如何肯定該速率就是核心問題,若是速率設置過小則會浪費性能資源,設置太大則會形成資源不足。而且因爲速率的設置,不管輸入速率如何波動,均不會體如今服務端,即便資源有空餘,對於突發請求也沒法及時處理,故對有突發請求處理需求時,不宜選擇該方法。
令牌桶限流的實現原理在wiki有詳細說明。簡單總結爲:設定令牌桶中添加令牌的速率,而且設置桶中最大可存儲的令牌,當請求到達時,向桶中請求令牌(根據應用需求,可能爲1個或多個),若令牌數量知足要求,則刪除對應數量的令牌並經過當前請求,若桶中令牌數不足則觸發限流規則。
根據描述須要設置的參數爲,令牌添加速率r,令牌桶中最大容量N,流程以下:
令牌桶算法經過設置令牌放入速率能夠控制請求經過的平均速度,且因爲設置的容量爲N的桶對令牌進行緩存,能夠容忍必定流量的突發。
以上提到四種算法,本小節主要對四種算法作簡單比較算法進行對比。
<style>
table th {
width: 100px;
}
</style>
算法名稱 | 須要肯定參數 | 實現簡介 | 空間複雜度 | 說明 |
---|---|---|---|---|
固定窗口計數 | 計數週期T 週期內最大訪問數N |
使用計數器在週期內累加訪問次數,達到最大次數後出發限流策略 | O(1),僅須要記錄週期內訪問次數及週期開始時間 | 週期切換時可能出現訪問次數超過限定值 |
滑動窗口計數 | 計數週期T 週期內最大訪問數N 滑動窗口數M |
將時間週期分爲M個小週期,分別記錄每一個小週期內訪問次數,而且根據時間滑動刪除過時的小週期 | O(M),須要記錄每一個小週期中的訪問數量 | 解決固定窗口算法週期切換時的訪問突發問題 |
漏桶算法 | 漏桶流出速度r 漏桶容量N |
服務到達時直接放入漏桶,如當前容量達到N,則觸發限流側率,程序以r的速度在漏桶中獲取訪問請求,知道漏桶爲空 | O(1),僅須要記錄當前漏桶中容量 | 平滑流量,保證服務請求到達服務方的速度恆定 |
令牌桶算法 | 令牌產生速度r 令牌桶容量N |
程序以r的速度向令牌桶中增長令牌,直到令牌桶滿,請求到達時向令牌桶請求令牌,若有知足需求的令牌則經過請求,不然觸發限流策略 | O(1),僅須要記錄當前令牌桶中令牌數 | 可以在限流的基礎上,處理必定量的突發請求 |
上文簡單介紹了經常使用的限流算法,在JAVA軟件開發過程當中可以使用Guava包中的限流工具進行服務限流。Guava包中限流工具類圖以下所示:
其中RateLimiter
類爲限流的核心類,其爲public
的抽象類,RateLimiter
有一個實現類SmoothRateLimiter
,根據不一樣消耗令牌的策略SmoothRateLimiter
又有兩個具體實現類SmoothBursty
和SmoothWarmingUp
。
在實際使用過程當中通常直接使用RateLimiter
類,其餘類對用戶是透明的。RateLimiter
類的設計使用了相似BUILDER模式的小技巧,並作了必定的調整。
經過RateLimiter
類圖可見,RateLimiter
類不只承擔了具體實現類的建立職責,同時也肯定了被建立出的實際類可提供的方法。標準建立者模式UML圖以下所示(引用自百度百科)RateLimiter
類即承擔了builder的職責,也承擔了Product的職責。
在實際的代碼編寫過程當中,對GUAVA包限流工具的使用參考如下代碼:
final RateLimiter rateLimiter = RateLimiter.create(2.0); // rate is "2 permits per second" void submitTasks(List<Runnable> tasks, Executor executor) { for (Runnable task : tasks) { rateLimiter.acquire(); // may wait executor.execute(task); } }
以上代碼摘自GUAVA包RateLimiter
類的說明文檔,首先使用create
函數建立限流器,指定每秒生成2個令牌,在須要調用服務時使用acquire
函數或取令牌。
根據代碼示例,抽象類RateLimiter
因爲承擔了Product的職責,其已經肯定了暴露給編程人員使用的API函數,其中主要實現的核心函數爲create
及acquire
。所以由此爲入口進行分析。
create
函數具備兩個個重載,根據不一樣的重載可能建立不一樣的RateLimiter
具體實現子類。目前可返回的實現子類包括SmoothBursty
及SmoothWarmingUp
兩種,具體不一樣下文詳細分析。
acquire
函數也具備兩個重載類,但分析過程僅僅須要關係具備整形參數的函數重載便可,無參數的函數僅僅是acquire(1)
的簡便寫法。
在acquire(int permits)
函數中主要完成三件事:
完成以上工做的過程當中,RateLimiter
類肯定了獲取受權的過程骨架而且實現了一些通用的方法,這些通用方法中會調用爲實現的抽象方法,開發人員根據不一樣的算法需求可實現特定子類對抽象方法進行覆蓋。其調用流程以下圖:
其中橙色塊中reserveEarliestAvailable
方法即爲須要子類進行實現的,下文以該函數爲核心,分析RateLimiter
類的子類是如何實現該方法的。
根據上文的類圖可見,RateLimiter
類在GUAVA包中的直接子類僅有SmoothRateLimiter
,故以reserveEarliestAvailable
函數爲入口研究其具體實現,而在代碼實現的過程當中須要使用SmoothRateLimiter
類中的屬性,現將類中各屬性羅列出來:
序號 | 屬性名稱 | 屬性說明 | 是否爲靜態屬性 |
---|---|---|---|
1 | storedPermits | 當前令牌桶中令牌數 | 否 |
2 | maxPermits | 令牌桶中最大令牌數 | 否 |
3 | stableIntervalMicros | 兩個令牌產生的時間間隔 | 否 |
4 | nextFreeTicketMicros | 下一次有空閒令牌產生的時刻 | 否 |
reserveEarliestAvailable
源碼以下:
@Override final long reserveEarliestAvailable(int requiredPermits, long nowMicros) { resync(nowMicros); long returnValue = nextFreeTicketMicros; double storedPermitsToSpend = min(requiredPermits, this.storedPermits); double freshPermits = requiredPermits - storedPermitsToSpend; long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros); this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); this.storedPermits -= storedPermitsToSpend; return returnValue; }
經過reserveEarliestAvailable
的函數名稱能夠知道,該函數可以返回令牌可用的最先時間。函數須要的輸入參數有需求的令牌數requiredPermits
,當前時刻nowMicros
。進入函數後,首先調用名爲resync
的函數:
void resync(long nowMicros) { // if nextFreeTicket is in the past, resync to now if (nowMicros > nextFreeTicketMicros) { double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros(); storedPermits = min(maxPermits, storedPermits + newPermits); nextFreeTicketMicros = nowMicros; } }
函數邏輯比較簡單,首先獲取nextFreeTicketMicros
,該值在上表中已經說明,表示下一次有空閒令牌產生的時刻,若是當前時刻小於等於nextFreeTicketMicros
,說明在當前時刻不可能有新的令牌產生,則直接返回。若當前時刻大於nextFreeTicketMicros
,則完成如下工做:
coolDownIntervalMicros
的函數,該函數返回建立一個新令牌須要的冷卻時間(注:該函數在當前類中並未實現,具體實現下文說明);storedPermits
屬性,取產生的令牌和最大可存儲令牌之間的最小值;nextFreeTicketMicros
屬性置爲當前時刻。可見,resync
函數主要功能在於計算新產生的令牌數,並更新nextFreeTicketMicros
屬性,nextFreeTicketMicros
屬性取值是當前時刻和nextFreeTicketMicros
屬性的原始值中最大的一個。
完成resync
函數的調用後,使用returnValue
變量記錄更新令牌後的最近可用時間(即上文更新後的nextFreeTicketMicros
屬性)。
使用storedPermitsToSpend
變量記錄須要消耗以存儲的令牌數,其取值爲請求令牌數和當前存儲令牌數之間的最小值。
使用freshPermits
變量記錄須要刷新的令牌數,其實現是用需求的令牌數減去以前計算的storedPermitsToSpend
變量,可見freshPermits
變量取值爲需求令牌數與已存儲令牌數之間的差值,當需求令牌數小於已存儲令牌數是則爲0。
後續爲該函數核心,計算須要等待的時間,計算等待時間主要分爲兩個部分:消耗已經存儲的令牌須要的時間及生成新令牌的時間,其中storedPermitsToWaitTime
函數用於計算消耗已經存儲的令牌須要的時間,該函數也是抽象函數,後文具體分析子類實現。
完成等待時間的計算後,程序更新nextFreeTicketMicros
屬性,將最近可用時間與須要等待的時間相加。
最後在更新存儲的令牌數,將須要消耗額令牌數減去。
根據以上的代碼分析能夠發現,GUAVA對於令牌桶的實現跟理論有一點點小的區別。其當前一次的請求消耗的令牌數並不會影響本次請求的等待時間,而是會影響下一次請求的等待時間。
根據以上分析,當一次請求到達,最近可用時間返回當前時間和上一次請求計算的最近可用時間的最大值,而本次請求須要的令牌數會更新下一次的最近可用時間。在這樣的設計下,若是每秒產生一個令牌,第一請求需求10個令牌,則當第一次請求調用acquire
方法時可以當即返回,而下一次請求(不管須要多少令牌)均須要等待到第一個請求以後的10秒之後,第三次請求等待時間則取決於第二次需求了多少令牌。這也是函數名稱中「reserve」的含義。
在以上文代碼分析中出現了兩個抽象函數coolDownIntervalMicros
及storedPermitsToWaitTime
,現分析這兩個抽象函數。
coolDownIntervalMicros
函數coolDownIntervalMicros
函數在代碼中已經有說明:
Returns the number of microseconds during cool down that we have to wait to get a new permit.
主要含義爲生成一個令牌須要消耗的時間,該函數主要應用於計算當前時間可產生的令牌數。根據上文的UML圖SmoothRateLimiter
類有兩個子類SmoothBursty
及SmoothWarmingUp
。 SmoothBursty
類中對於coolDownIntervalMicros
函數的實現以下:
@Override double coolDownIntervalMicros() { return stableIntervalMicros; }
可見實現很是簡單,僅僅只是返回stableIntervalMicros
屬性,即產生兩個令牌須要的時間間隔。SmoothWarmingUp
類中對於coolDownIntervalMicros
函數的實現以下:
@Override double coolDownIntervalMicros() { return warmupPeriodMicros / maxPermits; }
其中maxPermits
屬性上文已經出現過,表示當前令牌桶的最大容量。warmupPeriodMicros
屬性屬於SmoothWarmingUp
類的特有屬性,表示令牌桶中令牌從0到maxPermits
須要通過的時間,故warmupPeriodMicros / maxPermits
表示在令牌數量達到maxPermits
以前的令牌產生時間間隔。
storedPermitsToWaitTime
函數storedPermitsToWaitTime
函數在代碼中已經有說明:
Translates a specified portion of our currently stored permits which we want to spend/acquire, into a throttling time. Conceptually, this evaluates the integral of the underlying function we use, for the range of [(storedPermits - permitsToTake), storedPermits]
主要表示消耗存儲在令牌桶中的令牌須要的時間。SmoothBursty
類中對於storedPermitsToWaitTime
函數的實現以下:
@Override long storedPermitsToWaitTime(double storedPermits, double permitsToTake) { return 0L; }
直接返回0,表示消耗令牌不須要時間。 SmoothBursty
類中對於storedPermitsToWaitTime
函數的實現以下:
@Override long storedPermitsToWaitTime(double storedPermits, double permitsToTake) { double availablePermitsAboveThreshold = storedPermits - thresholdPermits; long micros = 0; // measuring the integral on the right part of the function (the climbing line) if (availablePermitsAboveThreshold > 0.0) { double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake); // TODO(cpovirk): Figure out a good name for this variable. double length = permitsToTime(availablePermitsAboveThreshold) + permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake); micros = (long) (permitsAboveThresholdToTake * length / 2.0); permitsToTake -= permitsAboveThresholdToTake; } // measuring the integral on the left part of the function (the horizontal line) micros += (long) (stableIntervalMicros * permitsToTake); return micros; }
實現較爲複雜,其核心思想在於計算消耗當前存儲令牌時須要根據預熱設置區別對待。其中涉及到新變量thresholdPermits
,該變量爲令牌閾值,噹噹前存儲的令牌數大於該值時,消耗(storedPermits-thresholdPermits)
範圍的令牌須要有預熱的過程(即消耗每一個令牌的間隔時間慢慢減少),而消耗0~thresholdPermits
個數的以存儲令牌,每一個令牌消耗時間爲固定值,即stableIntervalMicros
。
而thresholdPermits
取值須要考慮預熱時間及令牌產生速度兩個屬性,即thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros;
。可見閾值爲預熱時間中可以產生的令牌數的一半,而且根據註釋計算消耗閾值以上的令牌的時間能夠轉換爲計算預熱圖的梯形面積(實際爲積分),本處不詳細展開。
使用此種設計能夠保證在上次請求間隔時間較長時,令牌桶中存儲了較多的令牌,當消耗這些令牌時,最開始的令牌消耗時間較長,後續時間慢慢縮短直到達到stableIntervalMicros
的狀態,產生預熱的效果。
以上分析GUAVA限流器實現,其使用了兩個抽象類及兩個具體子類完成了限流器實現,其中使用頂層抽象類承擔了建立者角色,將全部子類進行了透明化,減小了程序員在使用工具過程當中須要瞭解的類的數量。
在實現限流器的過程當中,基於令牌桶的思想,而且增長了帶有預熱器的令牌桶限流器實現。被限流的線程使用其自帶的SleepingStopwatch
工具類,最終使用的是Thread.sleep(ms, ns);
方法,而線程使用sleep
休眠時其持有的鎖並不會釋放,在多線程編程時此處須要注意。
最後,GUAVA的限流器觸發算法採用的是預約令牌的方式,即當前請求須要的令牌數不會對當前請求的等待時間形成影響,而是會影響下一次請求的等待時間。
本文主要總結了當前經常使用的服務限流算法,對比個各算法特色,最後分析GUAVA包中對於限流器的實現的核心方法。