高併發之限流,到底限的什麼鬼 (精品長文)

更多精彩文章。java

《微服務不是所有,只是特定領域的子集》nginx

《「分庫分表" ?選型和流程要慎重,不然會失控》git

這麼多監控組件,總有一款適合你程序員

《使用Netty,咱們到底在開發些什麼?》github

《這多是最中肯的Redis規範了》算法

《程序員畫像,十年沉浮》數據庫

最有用系列:vim

《Linux生產環境上,最經常使用的一套「vim「技巧》api

《Linux生產環境上,最經常使用的一套「Sed「技巧》緩存

《Linux生產環境上,最經常使用的一套「AWK「技巧》


你可能知道高併發系統須要限流這個東西,但具體是限制的什麼,該如何去作,仍是臨摹兩可。咱們接下來系統性的給它歸個小類,但願對你有所幫助。

google guava中提供了一個限流實現: RateLimiter,這個類設計的很是精巧,能夠適用於咱們平常業務中大多數流控的場景,但鑑於使用場景的多樣性,使用時也須要至關當心。

前面已經使用兩篇簡單的文章進行了預熱。
信號量限流,高併發場景不得不說的祕密
沒有預熱,不叫高併發,叫併發高

此次不一樣。本篇文章將詳細的,深刻的介紹限流的各類場景和屬性,而後分析guava這個限流器的核心源碼,並對其特性進行總結。屬於稍高級的進階篇。

限流場景

弄清楚你要限制的資源,是這個過程當中最重要的一環。我大致將它分爲三類。

代理層

好比SLB、nginx或者業務層gateway等,都支持限流,一般是基於鏈接數(或者併發數)、請求數進行限流。限流的維度一般是基於好比IP地址、資源位置、用戶標誌等。更進一步,還能夠根據自身負載狀況動態調整限流的策略(基準)。

服務調用者

服務調用方,也能夠叫作本地限流,客戶端能夠限制某個遠端服務的調用速度,超過閾值,能夠直接進行阻塞或者拒絕,是限流的協做方

服務接收方

基本同上,流量超過系統承載能力時,會直接拒絕服務。一般基於應用自己的可靠性考慮,屬於限流的主體方。咱們常說的限流,通常發生在此處。本文主要結合RateLimiter討論基於限流主體方的使用方式,其餘的都相似。

限流策略

限流策略有時候很簡單,有時候又很複雜,但常見的就三種。其餘的都是在這之上進行的改進和擴展。

根據併發級別限流

這是一種簡單的、易於實施的限流方式,可使用咱們前面提到的java信號量實現。它的使用場景也有着比較鮮明的特色:

1)每次請求,所須要的資源開支都比較均衡,好比,每一個請求對CPU的消耗、IO消耗等,都差很少,請求的RT時間都基本接近。
2) 請求密度或稀疏或高頻,這個咱們不去關注。
3)資源自己不須要繁瑣的初始化工做(預熱),或者初始化工做的開支能夠忽略。(會增長複雜度)
4)對待流量溢出的策略比較簡單,一般是直接拒絕而不是等待,由於等待每每意味着故障。

這種策略一般在適用在流量的頂層組件上,好比代理層、中間件等對併發鏈接數的限制。而嘗試獲取憑證的超時時間,就叫作溢出等待。很上檔次很裝b的詞,對不對?

漏桶算法

請求流量以不肯定速率申請資源,程序處理以恆定的速率進行,就是漏桶算法的基本原理。有點像製做冰激凌的過程。-.- 有關漏桶模型,你們能夠去研究一下相關資料。

大致有如下幾個概念。

桶 buffer

請求首先嚐試進入隊列,若是隊列溢滿,則拒絕此請求。進入隊列之後,請求則等待執行。

因而可知,請求究竟什麼時候被執行,還存在一些變數,這直接取決於隊列中pending的請求數。有時候,挑剔的設計者會考慮增長有關限制請求等待的時間閾值,這個時間就是請求入隊、出隊的最大時差。buffer的大小設計,一般與速率有直接關係。

漏:請求出隊

這個出隊,有些講究,不一樣的設計理念實現就有所不一樣。有搶佔式、有調度式。其中「搶佔式」就是處理線程(或者進程,好比nginx worker進程)在上一個請求處理完畢以後即從buffer隊列中poll新的請求,不管當前線程(或者進程)的處理速率是否超過設定的速率,這種策略下buffer大小就限定了速率的上限。

調度式,就比較易於理解,須要額外的調度線程(進程),並嚴格按照設定的速率,從buffer中獲取請求,並輪訓的方式將請求交給其餘worker線程,若是已有的worker線程繁忙,則直接建立新線程,目的就是確保速率是有保障的,這種方式下,buffer大小主要取決於等待時間。

溢出

就是由於漏桶的速率限制比較穩定,因此其面臨流量突發(bursty)幾乎沒有應對能力,簡單來講,超出buffer,就直接拒絕。

多麼可憐的請求們。

流量突發

儘管buffer的設計在必定層面上兼顧流量突發,可是仍是太脆弱了,好比某個瞬間,請求密度很高(最尷尬的就是,只大了一點),將buffer溢滿,或許buffer再「大一點點」就可以在合理時間內被處理;對於請求方,就會有些迷惑,「我只不過是稍微超了一點,你就給了我一連串沒法工做的信息,so nave!!!」。

這種策略,也很經常使用,可是一般適用在限流的協做方,也是就客戶端層面。請求發出以前,作流控,若是有溢出,就要用其餘可靠的策略來保障結果,好比重試等;反正 「對面的服務壓垮了,別怪我,我很自律」。

令牌桶

設計模型,我就再也不介紹,你們能夠去wiki深刻了解一下。

令牌桶的基本思想,跟老一輩的集體公社時代同樣,每月的供銷是限額的,有資源才分配給我的,不足部分下個月再說,你能夠排隊賒帳。

令牌的個數,就是能夠容許獲取資源的請求個數(咱們假設每一個請求只須要一個令牌)。事實上,咱們並不會真的去建立令牌實體,由於這是沒有必要的,咱們使用帶有時間特徵的計數器來表示令牌的可用個數便可。跟漏桶算法相比,令牌桶的「桶」不是用來buffer請求的、而是用來計量可用資源數量(令牌)的。雖然咱們並不會建立令牌實體,可是仍然能夠假想,這個桶內每隔X時間就會新增必定數量的令牌,若是沒有請求申請令牌,那麼這個令牌桶是會溢出的...你會發現,這個設計跟漏桶算法從IO方向上是相反的。

那麼漏桶算法的缺點,也正好成爲了令牌桶的專長:流量突發;令牌桶成了buffer,若是請求密度低,或者處於冷卻狀態,那麼令牌桶就會溢滿,此後若是流量突發,則過去積累的結餘資源則能夠直接被「借用」。

令牌桶算法,使用場景不少,適應程度很高,現實中流量突發是常見的,並且從設計角度考慮,令牌桶更易於實現。回到正題,RateLimiter,就是一個基於令牌桶思想的實現。

咱們的口子越縮越小,終於到正題了。

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」爲同一含義)

SmoothBursty

能夠支持「突發流量」的限流器,即當限流器不被使用時間,能夠額外存儲一些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  
複製代碼

SmoothWarmingUp

具備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  
複製代碼

acquire方法源碼分析

上面兩個類都繼承自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
複製代碼

End

能夠看到,guava提供了一個很是輕量而全面的限流器。它自己沒有使用多線程去實現,但它是線程安全的。相比較信號量,它的使用簡單的多。但鑑於限流場景的多樣性,使用時一樣要很是當心。


更多精彩文章。

微服務不是所有,只是特定領域的子集

這麼多監控組件,總有一款適合你

「分庫分表" ?選型和流程要慎重,不然會失控

使用Netty,咱們到底在開發些什麼?

Linux生產環境上,最經常使用的一套「vim「技巧

相關文章
相關標籤/搜索