更多精彩文章。java
《微服務不是所有,只是特定領域的子集》nginx
《使用Netty,咱們到底在開發些什麼?》github
《程序員畫像,十年沉浮》數據庫
最有用系列:vim
《Linux生產環境上,最經常使用的一套「vim「技巧》api
《Linux生產環境上,最經常使用的一套「Sed「技巧》緩存
你可能知道高併發系統須要限流這個東西,但具體是限制的什麼,該如何去作,仍是臨摹兩可。咱們接下來系統性的給它歸個小類,但願對你有所幫助。
google guava中提供了一個限流實現: RateLimiter,這個類設計的很是精巧,能夠適用於咱們平常業務中大多數流控
的場景,但鑑於使用場景的多樣性,使用時也須要至關當心。
前面已經使用兩篇簡單的文章進行了預熱。
信號量限流,高併發場景不得不說的祕密
沒有預熱,不叫高併發,叫併發高
此次不一樣。本篇文章將詳細的,深刻的介紹限流的各類場景和屬性,而後分析guava這個限流器的核心源碼,並對其特性進行總結。屬於稍高級的進階篇。
弄清楚你要限制的資源,是這個過程當中最重要的一環。我大致將它分爲三類。
好比SLB、nginx或者業務層gateway等,都支持限流,一般是基於鏈接數
(或者併發數)、請求數
進行限流。限流的維度一般是基於好比IP地址、資源位置、用戶標誌等。更進一步,還能夠根據自身負載狀況動態調整限流的策略(基準)。
服務調用方,也能夠叫作本地限流,客戶端能夠限制某個遠端服務的調用速度,超過閾值,能夠直接進行阻塞或者拒絕,是限流的協做方
。
基本同上,流量超過系統承載能力時,會直接拒絕服務。一般基於應用自己的可靠性考慮,屬於限流的主體方
。咱們常說的限流,通常發生在此處。本文主要結合RateLimiter討論基於限流主體方的使用方式,其餘的都相似。
限流策略有時候很簡單,有時候又很複雜,但常見的就三種。其餘的都是在這之上進行的改進和擴展。
這是一種簡單的、易於實施的限流方式,可使用咱們前面提到的java信號量實現。它的使用場景也有着比較鮮明的特色:
1)每次請求,所須要的資源開支都比較均衡,好比,每一個請求對CPU的消耗、IO消耗等,都差很少,請求的RT時間都基本接近。
2) 請求密度或稀疏或高頻,這個咱們不去關注。
3)資源自己不須要繁瑣的初始化工做(預熱
),或者初始化工做的開支能夠忽略。(會增長複雜度)
4)對待流量溢出的策略比較簡單,一般是直接拒絕
而不是等待,由於等待每每意味着故障。
這種策略一般在適用在流量的頂層組件上,好比代理層、中間件等對併發鏈接數的限制。而嘗試獲取憑證的超時時間,就叫作溢出等待
。很上檔次很裝b的詞,對不對?
請求流量以不肯定速率申請資源,程序處理以恆定的速率進行,就是漏桶算法的基本原理。有點像製做冰激凌的過程。-.- 有關漏桶模型,你們能夠去研究一下相關資料。
大致有如下幾個概念。
請求首先嚐試進入隊列,若是隊列溢滿,則拒絕此請求。進入隊列之後,請求則等待執行。
因而可知,請求究竟什麼時候被執行,還存在一些變數,這直接取決於隊列中pending的請求數。有時候,挑剔的設計者會考慮增長有關限制請求等待的時間閾值,這個時間就是請求入隊、出隊的最大時差。buffer的大小設計,一般與速率有直接關係。
這個出隊,有些講究,不一樣的設計理念實現就有所不一樣。有搶佔式
、有調度式
。其中「搶佔式」就是處理線程(或者進程,好比nginx worker進程)在上一個請求處理完畢以後即從buffer隊列中poll新的請求,不管當前線程(或者進程)的處理速率是否超過設定的速率,這種策略下buffer大小就限定了速率的上限。
調度式,就比較易於理解,須要額外的調度線程(進程),並嚴格按照設定的速率,從buffer中獲取請求,並輪訓的方式將請求交給其餘worker線程,若是已有的worker線程繁忙,則直接建立新線程,目的就是確保速率是有保障的,這種方式下,buffer大小主要取決於等待時間。
就是由於漏桶的速率限制比較穩定,因此其面臨流量突發(bursty)幾乎沒有應對能力,簡單來講,超出buffer,就直接拒絕。
多麼可憐的請求們。
儘管buffer的設計在必定層面上兼顧流量突發,可是仍是太脆弱了,好比某個瞬間,請求密度很高(最尷尬的就是,只大了一點),將buffer溢滿,或許buffer再「大一點點」就可以在合理時間內被處理;對於請求方,就會有些迷惑,「我只不過是稍微超了一點,你就給了我一連串沒法工做的信息,so nave!!!」。
這種策略,也很經常使用,可是一般適用在限流的協做方,也是就客戶端層面。請求發出以前,作流控,若是有溢出,就要用其餘可靠的策略來保障結果,好比重試等;反正 「對面的服務壓垮了,別怪我,我很自律」。
設計模型,我就再也不介紹,你們能夠去wiki深刻了解一下。
令牌桶的基本思想,跟老一輩的集體公社時代同樣,每月的供銷是限額的,有資源才分配給我的,不足部分下個月再說,你能夠排隊賒帳。
令牌的個數,就是能夠容許獲取資源的請求個數(咱們假設每一個請求只須要一個令牌)。事實上,咱們並不會真的去建立令牌實體,由於這是沒有必要的,咱們使用帶有時間特徵的計數器來表示令牌的可用個數便可。跟漏桶算法相比,令牌桶的「桶」不是用來buffer請求的、而是用來計量可用資源數量(令牌)的。雖然咱們並不會建立令牌實體,可是仍然能夠假想,這個桶內每隔X時間就會新增必定數量的令牌,若是沒有請求申請令牌,那麼這個令牌桶是會溢出的...你會發現,這個設計跟漏桶算法從IO方向上是相反的。
那麼漏桶算法的缺點,也正好成爲了令牌桶的專長:流量突發;令牌桶成了buffer,若是請求密度低,或者處於冷卻狀態,那麼令牌桶就會溢滿,此後若是流量突發,則過去積累的結餘資源則能夠直接被「借用」。
令牌桶算法,使用場景不少,適應程度很高,現實中流量突發是常見的,並且從設計角度考慮,令牌桶更易於實現。回到正題,RateLimiter,就是一個基於令牌桶思想的實現。
咱們的口子越縮越小,終於到正題了。
guava的api已經把它的設計思想闡述的比較清楚了,可是這個註釋閱讀起來仍是稍微有點「哲學派」,咱們先看兩個栗子,而後從源碼層面看下它的設計原理。
//RateLimiter limiter = RateLimiter.create(10,2, TimeUnit.SECONDS);//QPS 100
RateLimiter limiter = RateLimiter.create(10);
long start = System.currentTimeMillis();
for (int i= 0; i < 30; i++) {
double time = limiter.acquire();
long after = System.currentTimeMillis() - start;
if (time > 0D) {
System.out.println(i + ",limited,等待:" + time + ",已開始" + after + "毫秒");
} else {
System.out.println(i + ",enough" + ",已開始" + after + "毫秒");
}
//模擬冷卻時間,下一次loop能夠認爲是bursty開始
if (i == 9) {
Thread.sleep(2000);
}
}
System.out.println("total time:" + (System.currentTimeMillis() - start));
複製代碼
此例爲簡單的流控,只有一種資源,QPS爲10
;在實際業務場景中,可能不一樣的資源速率是不一樣的,咱們能夠建立N多個limeter各自服務於資源。
acquire()方法就是獲取一個令牌(源碼中使用permit,許可證),若是permit足夠,則直接返回而無需等待,若是不足,則等待1/QPS秒。
此外,你會發現, limiter並無相似於鎖機制中的release()方法 ,這意味着「只要申請,總會成功」、且退出時也無需歸還。
RateLimiter內部有兩種實現:(下文中,「資源」、「令牌」、「permits」爲同一含義)
能夠支持「突發流量」的限流器,即當限流器不被使用時間,能夠額外存儲一些permits以備突發流量,當突發流量發生時能夠更快更充分的使用資源,流量平穩後(或者冷卻期,積累的permits被使用完以後)速率處於限制狀態。
其重點就是,冷卻期間,permits會積累,且在突發流量時,能夠消耗此前積累的permits並且無需任何等待。就像一我的,奔跑以後休息一段時間,再次起步能夠有更高的速度。
因而可知,若是你的資源,冷卻(不被使用)一段時間以後,再次被使用時能夠提供比正常更高的效率,這個時候,你可使用SmoothBursty。
建立方式
RateLimiter.create(double permitsPerSecond)
複製代碼
結果相似
0,enough,已開始1毫秒
1,limited,等待:0.098623,已開始105毫秒
2,limited,等待:0.093421,已開始202毫秒
3,limited,等待:0.098287,已開始304毫秒
4,limited,等待:0.096025,已開始401毫秒
5,limited,等待:0.098969,已開始505毫秒
6,limited,等待:0.094892,已開始605毫秒
7,limited,等待:0.094945,已開始701毫秒
8,limited,等待:0.099145,已開始801毫秒
9,limited,等待:0.09886,已開始905毫秒
10,enough,已開始2908毫秒
11,enough,已開始2908毫秒
12,enough,已開始2908毫秒
13,enough,已開始2908毫秒
14,enough,已開始2908毫秒
15,enough,已開始2908毫秒
16,enough,已開始2908毫秒
17,enough,已開始2908毫秒
18,enough,已開始2908毫秒
19,enough,已開始2908毫秒
20,enough,已開始2909毫秒
21,limited,等待:0.099283,已開始3011毫秒
22,limited,等待:0.096308,已開始3108毫秒
23,limited,等待:0.099389,已開始3211毫秒
24,limited,等待:0.096674,已開始3313毫秒
25,limited,等待:0.094783,已開始3411毫秒
26,limited,等待:0.097161,已開始3508毫秒
27,limited,等待:0.099877,已開始3610毫秒
28,limited,等待:0.097551,已開始3713毫秒
29,limited,等待:0.094606,已開始3809毫秒
total time:3809
複製代碼
具備warming up(預熱)特性,即突發流量發生時,不能當即達到最大速率,而是須要指定的「預熱時間」內逐步上升最終達到閾值;它的設計哲學,與SmoothBursty相反,當突發流量發生時,以可控的慢速、逐步使用資源(直到最高速率),流量平穩後速率處於限制狀態。
其重點是,資源一直被使用,那麼它能夠持續限制穩定的速率;不然,冷卻時間越長(有效時長爲warmup間隔)獲取permits時等待的時間越長,須要注意,冷卻時間會積累permits,可是獲取這些permits仍然須要等待。
因而可知,若是你的資源,冷卻(不被使用)一段時間以後,再次被使用時它須要必定的準備工做,此時它所能提供的效率比正常要低;好比連接池、數據庫緩存等。
建立方式
RateLimiter.create(double permitsPerSecond,long warnupPeriod,TimeUnit unit)
複製代碼
執行結果以下,能夠看到有一個明顯的增加過程。
0,enough,已開始1毫秒
1,limited,等待:0.288847,已開始295毫秒
2,limited,等待:0.263403,已開始562毫秒
3,limited,等待:0.247548,已開始813毫秒
4,limited,等待:0.226932,已開始1041毫秒
5,limited,等待:0.208087,已開始1250毫秒
6,limited,等待:0.189501,已開始1444毫秒
7,limited,等待:0.165301,已開始1614毫秒
8,limited,等待:0.145779,已開始1761毫秒
9,limited,等待:0.128851,已開始1891毫秒
10,enough,已開始3895毫秒
11,limited,等待:0.289809,已開始4190毫秒
12,limited,等待:0.264528,已開始4458毫秒
13,limited,等待:0.247363,已開始4710毫秒
14,limited,等待:0.225157,已開始4939毫秒
15,limited,等待:0.206337,已開始5146毫秒
16,limited,等待:0.189213,已開始5337毫秒
17,limited,等待:0.167642,已開始5510毫秒
18,limited,等待:0.145383,已開始5660毫秒
19,limited,等待:0.125097,已開始5786毫秒
20,limited,等待:0.109232,已開始5898毫秒
21,limited,等待:0.096613,已開始5999毫秒
22,limited,等待:0.096321,已開始6098毫秒
23,limited,等待:0.097558,已開始6200毫秒
24,limited,等待:0.095132,已開始6299毫秒
25,limited,等待:0.095495,已開始6399毫秒
26,limited,等待:0.096352,已開始6496毫秒
27,limited,等待:0.098641,已開始6597毫秒
28,limited,等待:0.097883,已開始6697毫秒
29,limited,等待:0.09839,已開始6798毫秒
total time:6798
複製代碼
上面兩個類都繼承自SmoothRateLimiter,最終繼承自RateLimiter;RateLimiter內部核心的方法:
1)double acquire():獲取一個permit,若是permits充足則直接返回,不然等待1/QPS秒。此方法返回線程等待的時間(秒),若是返回0.0表示未限流、未等待。
2)double acquire(int n):獲取n個permits,若是permits充足則直接返回,不然限流並等待,等待時間爲「不足的permits個數 / QPS」。(暫且這麼解釋)
下面就是這個方法的僞代碼啦。
//僞代碼
public double acquire(int requiredPermits) {
long waitTime = 0L;
synchronized(mutex) {
boolean cold = nextTicketTime > now;
if (cold) {
storedPermits = 根據冷卻時長計算累積的permits;
nextTicketTime = now;
}
//根據storedPermits、requiredPermits計算須要等待的時間
//bursty:若是storePermits足夠,則waitTime = 0
//warmup:平滑預熱,storePermits越多(即冷卻時間越長),等待時間越長
if(storedPermits不足) {
waitTime += 欠缺的permits個數 / QPS;
}
if(bursty限流) {
waitTime += 0;//即無需額外等待
}
if(warmup限流) {
waitTime += requiredPermits / QPS;
if(storedPermits > 0.5 * maxPermits) {
waitTime += 阻尼時間;
}
}
nextTicketTime += waitTime
}
if (waitTime > 0L) {
Thread.sleep(waitTime);
}
return waitTime;
}
複製代碼
如下內容會比較枯燥~~~很是枯燥~~~
一、Object mutex:同步鎖,如上述僞代碼所示,在計算存量permits、實際申請permits(包括計算)的過程當中所有是同步的;咱們須要知道,RateLimiter內部確實使用了鎖同步機制。
二、maxPermits:最大可存儲的許可數量(tickets數量),SmoothBursty和SmoothWarimingUp默認實現中,有所不一樣:
1)SmoothBusty,其值爲maxBurstSecond * QPS,就是容許「突發流量持續時間」 * QPS,這種設計能夠理解,不過RateLimiter將maxBustSecond硬編碼爲1.0,最終此值等於QPS。
2)SmoothWarmingUp:默認算法值爲warmupPeriod * QPS,簡單說就是「預熱時長」 * QPS。
此參數主要限制,不管冷卻多長時間,其storedPermits不能超過此值;此值在設定QPS以後,則不會再改變。
三、storedPermits:已存儲的permits數量,此值取決於冷卻時間,簡單來講冷卻的時間越久,此值越大,但不會超過maxPermits,起始值爲0。
1)當一個請求,申請permit以前,將會計算上一次令牌申請(nexFreeTicketTime)的時間與now之間的時差,並根據令牌產生速率(1/QPS)計算此冷卻期間能夠存儲的令牌數,也就是storedPermits。
2)permits申請完畢以後,將當前時間(若是須要等待,額外加上等待時間)做爲下一次令牌申請的起始時間,此時冷卻時間結束。
3)申請完畢以後,storedPermits將會減去申請的permits個數,直到爲0。
冷卻時長和申請頻次,都決定了storedPermits大小,其中冷卻時間會致使storePermits增長,acquire操做將致使storePermits減小。
四、nextFreeTicketMicros(時間戳,微妙單位):下一個能夠自由獲取的令牌的時間,此值能夠爲將來的某個時間,此時表示限流已經開始,部分請求已經在等待,此值主要用來標記「冷卻狀態」。(賒帳)
1)若是處於冷卻期,那麼此值一般是過去式,即此值小於now。
2)若是此時有請求申請permits,則會經過此值與now的時差,計算storedPermits,同時將此值設置爲now。
3)若是此值是將來時刻,即大於now,則無需計算storedPermits,也無需重置此值。
4)申請tickets後,從storedPermits減去須要的tickets個數,若是觸發限速等待(好比預熱期、permits不足),則會將2)操做以後額外增長等待時間做爲nextFreeTicketsTime值。
5)基於2),對於warmingUp限流,冷卻期以後的首個請求是不須要等待的,只是將此值設置爲now + 阻尼性質的等待時間waitTime(),這意味着在此後waitTime期間再有請求,則會觸發等待,並繼續延續nextFreeTicketMicros值。此值的延續,在warming up期間,阻尼waitTime計算比較複雜,由1/QPS + 額外值,這個額外值,隨着預熱時間增加而減少。
6)基於2),對於bursty限流,若是storedPermits大於0,則老是不須要等待,只是簡單將此值設爲爲now;不然,則按照正常的1/QPS間隔計算其應該被推延的時間點。
五、對於warming up限流,將maxPermits * 0.5做爲一個閾值分割線,當storedPermits小於此分割線時,在限流時使用正常等待時間(申請permits個數 / QPS);在此分割線之上時,則4)增長額外阻尼,即預熱阻尼。
六、咱們發現,RateLimiter內部並不會真的生成tickets實體,而是根據冷卻時長、在申請資源時才計算存量tickets(對應爲storedPermits)。不管何種限流,storedPermits都是優先使用。
是時候總結一下了。
RateLimiter是線程安全的,因此在併發環境中能夠直接使用,而無需額外的lock或者同步。
考慮到RateLimiter內部的同步鎖,咱們一般在實際業務開發中,每一個資源(好比URL)使用各自的RateLimiter而不是公用一個,佔用的內存也不大。
這個限流器內部無額外的線程,也沒有其餘的數據結構用來存儲tickets實體,因此它很是的輕量級,這也是優點所在。
RateLimiter最大的問題,就是acquire方法總會成功,內部的tickets時間點會向後推移; 若是併發很高,嚴重超過rate閾值時,後續被限流的請求,其等待時間將會基於時間線累加,致使等待時間不可控,這和信號量同病相憐。
爲了不上面的問題,咱們一般先使用tryAcquired檢測,若是可行再去acquire;若是令牌不足,適當拒絕。因此 基於RateLimiter,並無內置的拒絕策略,這一點須要咱們額外開發。
咱們不能簡單依賴於acquire方法,來實現限流等待,不然這可能帶來嚴重問題。咱們一般須要封裝RateLimiter,並使用額外的屬性記錄其是否「處於限流狀態」、「已經推延的tickets時間點」,若是「已經推延的時間點很是遙遠」且超過可接受範圍,則直接拒絕請求。簡單來講,封裝acquire方法,增長對請求可能等待時間的判斷,若是超長,則直接拒絕。
RateLimiter存在一個很大的問題,就是幾乎無法擴展:子類均爲protected。反射除外哦。
仍是上一段代碼吧,能更加清晰的看到咱們所作的工做: FollowCotroller.java:流控器,若是限流開始,則只能有max個請求所以而等待,超過此值則直接拒絕
public class FollowController {
private final RateLimiter rateLimiter;
private int maxPermits;
private Object mutex = new Object();
//等待獲取permits的請求個數,原則上能夠經過maxPermits推算
private int maxWaitingRequests;
private AtomicInteger waitingRequests = new AtomicInteger(0);
public FollowController(int maxPermits,int maxWaitingRequests) {
this.maxPermits = maxPermits;
this.maxWaitingRequests = maxWaitingRequests;
rateLimiter = RateLimiter.create(maxPermits);
}
public FollowController(int permits,long warmUpPeriodAsSecond,int maxWaitingRequests) {
this.maxPermits = maxPermits;
this.maxWaitingRequests = maxWaitingRequests;
rateLimiter = RateLimiter.create(permits,warmUpPeriodAsSecond, TimeUnit.SECONDS);
}
public boolean acquire() {
return acquire(1);
}
public boolean acquire(int permits) {
boolean success = rateLimiter.tryAcquire(permits);
if (success) {
rateLimiter.acquire(permits);//可能有出入
return true;
}
if (waitingRequests.get() > maxWaitingRequests) {
return false;
}
waitingRequests.getAndAdd(permits);
rateLimiter.acquire(permits);
waitingRequests.getAndAdd(0 - permits);
return true;
}
}
複製代碼
以上代碼,均可以在github找到。
https://github.com/sayhiai/example-ratelimit
複製代碼
能夠看到,guava提供了一個很是輕量而全面的限流器。它自己沒有使用多線程去實現,但它是線程安全的。相比較信號量,它的使用簡單的多。但鑑於限流場景的多樣性,使用時一樣要很是當心。
更多精彩文章。