最近學習了限流與RateLimiter

前言算法

分佈式環境下應對高併發保證服務穩定幾招,按照我的理解,優先級從高到低分別爲緩存、限流、降級、熔斷,每招都有它的做用,本文重點就講講限流這部分。api

坦白講,其實上面的說法也不許確,由於服務降級、熔斷自己也是限流的一種,由於它們本質上也是阻斷了流量進來,可是本文但願你們能夠把限流當作一個單純的名詞來理解,看一下對請求作流控的幾種算法及具體實現方式。緩存

 

爲何要限流服務器

其實很好理解的一個問題,爲何要限流,天然就流量過大了唄,一個對外服務有不少場景都會流量增大:網絡

  • 業務用戶量不斷攀升
  • 各類促銷
  • 網絡爬蟲
  • 惡意刷單

注意這個"大",1000QPS大嗎?5000QPS大嗎?10000QPS大麼?沒有答案,由於沒有標準,所以,"大"必定是和正常流量相比的大。流量一大,服務器扛不住,扛不住就掛了,掛了無法提供對外服務致使業務直接熔斷。怎麼辦,最直接的辦法就是從源頭把流量限制下來,例如服務器只有支撐1000QPS的處理能力,那就每秒放1000個請求,天然保證了服務器的穩定,這就是限流。併發

下面看一下常見的兩種限流算法。分佈式

 

漏桶算法ide

漏桶算法的原理比較簡單,水(請求)先進入到漏桶裏,人爲設置一個最大出水速率,漏桶以<=出水速率的速度出水,當水流入速度過大會直接溢出(拒絕服務):高併發

所以,這個算法的核心爲:ui

  • 存下請求
  • 勻速處理
  • 多於丟棄

所以這是一種強行限制請求速率的方式,可是缺點很是明顯,主要有兩點:

  • 沒法面對突發的大流量----好比請求處理速率爲1000,容量爲5000,來了一波2000/s的請求持續10s,那麼後5s的請求將所有直接被丟棄,服務器拒絕服務,可是實際上網絡中突發一波大流量尤爲是短期的大流量是很是正常的,超過容量就拒絕,很是簡單粗暴
  • 沒法有效利用網絡資源----好比雖然服務器的處理能力是1000/s,但這不是絕對的,這個1000只是一個宏觀服務器處理能力的數字,實際上一共5秒,每秒請求量分別爲1200、1300、1200、500、800,平均下來qps也是1000/s,可是這個量對服務器來講徹底是能夠接受的,可是由於限制了速率是1000/s,所以前面的三秒,每秒只能處理掉1000個請求而一共打回了700個請求,白白浪費了服務器資源

因此,一般來講利用漏桶算法來限流,實際場景下用得很少。

 

令牌桶算法

令牌桶算法是網絡流量整形(Traffic Shaping)和限流(Rate Limiting)中最常使用的一種算法,它可用於控制發送到網絡上數據的數量並容許突發數據的發送。

從某種意義上來講,令牌桶算法是對漏桶算法的一種改進,主要在於令牌桶算法可以在限制調用的平均速率的同時還容許必定程度的突發調用,來看下令牌桶算法的實現原理:

整個的過程是這樣的:

  • 系統以恆定的速率產生令牌,而後將令牌放入令牌桶中
  • 令牌桶有一個容量,當令牌桶滿了的時候,再向其中放入的令牌就會被丟棄
  • 每次一個請求過來,須要從令牌桶中獲取一個令牌,假設有令牌,那麼提供服務;假設沒有令牌,那麼拒絕服務

那麼,咱們再看一下,爲何令牌桶算法能夠防止必定程度的突發流量呢?能夠這麼理解,假設咱們想要的速率是1000QPS,那麼往桶中放令牌的速度就是1000個/s,假設第1秒只有800個請求,那意味着第2秒能夠允許1200個請求,這就是必定程度突發流量的意思,反之咱們看漏桶算法,第一秒只有800個請求,那麼所有放過,第二秒這1200個請求將會被打回200個。

注意上面屢次提到必定程度這四個字,這也是我認爲令牌桶算法最須要注意的一個點。假設仍是1000QPS的速率,那麼5秒鐘放1000個令牌,第1秒鐘800個請求過來,第2~4秒沒有請求,那麼按照令牌桶算法,第5秒鐘能夠接受4200個請求,可是實際上這已經遠遠超出了系統的承載能力,所以使用令牌桶算法特別注意設置桶中令牌的上限便可。

總而言之,做爲對漏桶算法的改進,令牌桶算法在限流場景下被使用更加普遍。

 

RateLimiter使用

上面說了令牌桶算法在限流場景下被使用更加普遍,接下來咱們看一下代碼示例,模擬一下每秒最多過五個請求:

public class RateLimiterTest {

    private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
    private static final int THREAD_COUNT = 25;
    
    @Test
    public void testRateLimiter1() {
        RateLimiter rateLimiter = RateLimiter.create(5);
        
        Thread[] ts = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            ts[i] = new Thread(new RateLimiterThread(rateLimiter), "RateLimiterThread-" + i);
        }
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            ts[i].start();
        }
        
        for (;;);
    }
    
    public class RateLimiterThread implements Runnable {
        
        private RateLimiter rateLimiter;
        
        public RateLimiterThread(RateLimiter rateLimiter) {
            this.rateLimiter = rateLimiter;
        }
        
        @Override
        public void run() {
            rateLimiter.acquire(1);
            
            System.out.println(Thread.currentThread().getName() + "獲取到了令牌,時間 = " + FORMATTER.format(new Date()));
        }
        
    }
    
}

利用RateLimiter.create這個構造方法能夠指定每秒向桶中放幾個令牌,比方說上面的代碼create(5),那麼每秒放置5個令牌,即200ms會向令牌桶中放置一個令牌。這邊代碼寫了一條線程模擬實際場景,拿到令牌那麼就能執行下面邏輯,看一下代碼執行結果:

RateLimiterThread-0獲取到了令牌,時間 = 2019-08-25 20:58:53
RateLimiterThread-23獲取到了令牌,時間 = 2019-08-25 20:58:54
RateLimiterThread-21獲取到了令牌,時間 = 2019-08-25 20:58:54
RateLimiterThread-19獲取到了令牌,時間 = 2019-08-25 20:58:54
RateLimiterThread-17獲取到了令牌,時間 = 2019-08-25 20:58:54
RateLimiterThread-13獲取到了令牌,時間 = 2019-08-25 20:58:54
RateLimiterThread-9獲取到了令牌,時間 = 2019-08-25 20:58:55
RateLimiterThread-15獲取到了令牌,時間 = 2019-08-25 20:58:55
RateLimiterThread-5獲取到了令牌,時間 = 2019-08-25 20:58:55
RateLimiterThread-1獲取到了令牌,時間 = 2019-08-25 20:58:55
RateLimiterThread-11獲取到了令牌,時間 = 2019-08-25 20:58:55
RateLimiterThread-7獲取到了令牌,時間 = 2019-08-25 20:58:56
RateLimiterThread-3獲取到了令牌,時間 = 2019-08-25 20:58:56
RateLimiterThread-4獲取到了令牌,時間 = 2019-08-25 20:58:56
RateLimiterThread-8獲取到了令牌,時間 = 2019-08-25 20:58:56
RateLimiterThread-12獲取到了令牌,時間 = 2019-08-25 20:58:56
RateLimiterThread-16獲取到了令牌,時間 = 2019-08-25 20:58:57
RateLimiterThread-20獲取到了令牌,時間 = 2019-08-25 20:58:57
RateLimiterThread-24獲取到了令牌,時間 = 2019-08-25 20:58:57
RateLimiterThread-2獲取到了令牌,時間 = 2019-08-25 20:58:57
RateLimiterThread-6獲取到了令牌,時間 = 2019-08-25 20:58:57
RateLimiterThread-10獲取到了令牌,時間 = 2019-08-25 20:58:58
RateLimiterThread-14獲取到了令牌,時間 = 2019-08-25 20:58:58
RateLimiterThread-18獲取到了令牌,時間 = 2019-08-25 20:58:58
RateLimiterThread-22獲取到了令牌,時間 = 2019-08-25 20:58:58

看到,很是標準,在每次消耗一個令牌的狀況下,RateLimiter能夠保證每一秒內最多隻有5個線程獲取到令牌,使用這種方式能夠很好的作單機對請求的QPS數控制。

至於爲何2019-08-25 20:58:53這個時間點只有1條線程獲取到了令牌而不是有5條線程獲取到令牌,由於RateLimiter是按照秒計數的,可能第一個線程是2019-08-25 20:58:53.999秒來的,算在2019-08-25 20:58:53這一秒內;下一個線程2019-08-25 20:58:54.001秒來,天然就算到2019-08-25 20:58:54這一秒去了。

上面的寫法是RateLimiter最經常使用的寫法,注意:

  • acquire是阻塞的且會一直等待到獲取令牌爲止,它有一個返回值爲double型,意思是從阻塞開始到獲取到令牌的等待時間,單位爲秒
  • tryAcquire是另一個方法,它能夠指定超時時間,返回值爲boolean型,即假設線程等待了指定時間後仍然沒有獲取到令牌,那麼就會返回給客戶端false,客戶端根據自身狀況是打回給前臺錯誤仍是定時重試

 

RateLimiter預消費

處理請求,每次來一個請求就acquire一把是RateLimiter最多見的用法,可是咱們看acquire還有個acquire(int permits)的重載方法,即容許每次獲取多個令牌數。這也是有可能的,請求數是一個大維度每次扣減1,有可能服務器按照字節數來進行限流,例如每秒最多處理10000字節的數據,那每次扣減的就不止1了。

接着咱們再看一段代碼示例:

@Test
public void testRateLimiter2() {
    RateLimiter rateLimiter = RateLimiter.create(1);
        
    System.out.println("獲取1個令牌開始,時間爲" + FORMATTER.format(new Date()));
    double cost = rateLimiter.acquire(1);
    System.out.println("獲取1個令牌結束,時間爲" + FORMATTER.format(new Date()) + ", 耗時" + cost + "ms");
    System.out.println("獲取5個令牌開始,時間爲" + FORMATTER.format(new Date()));
    cost = rateLimiter.acquire(5);
    System.out.println("獲取5個令牌結束,時間爲" + FORMATTER.format(new Date()) + ", 耗時" + cost + "ms");
    System.out.println("獲取3個令牌開始,時間爲" + FORMATTER.format(new Date()));
    cost = rateLimiter.acquire(3);
    System.out.println("獲取3個令牌結束,時間爲" + FORMATTER.format(new Date()) + ", 耗時" + cost + "ms");
}

代碼運行結果爲:

獲取1個令牌開始,時間爲2019-08-25 21:21:09.973
獲取1個令牌結束,時間爲2019-08-25 21:21:09.976, 耗時0.0ms
獲取5個令牌開始,時間爲2019-08-25 21:21:09.976
獲取5個令牌結束,時間爲2019-08-25 21:21:10.974, 耗時0.997237ms
獲取3個令牌開始,時間爲2019-08-25 21:21:10.976
獲取3個令牌結束,時間爲2019-08-25 21:21:15.974, 耗時4.996529ms

看到這就是標題所說的預消費能力,也是RateLimiter中容許必定程度突發流量的實現方式。第二次須要獲取5個令牌,指定的是每秒放1個令牌到桶中,咱們發現實際上並無等5秒鐘等桶中積累了5個令牌才能讓第二次acquire成功,而是直接等了1秒鐘就成功了。咱們能夠捋一捋這個邏輯:

  • 第一次請求過來須要獲取1個令牌,直接拿到
  • RateLimiter在1秒鐘後放一個令牌,第一次請求預支的1個令牌還上了
  • 1秒鐘以後第二次請求過來須要得到5個令牌,直接拿到
  • RateLimiter在花了5秒鐘放了5個令牌,還上了第二次請求預支的5個令牌
  • 第三個請求在5秒鐘以後拿到3個令牌

也就是說,前面的請求若是流量大於每秒放置令牌的數量,那麼容許處理,可是帶來的結果就是後面的請求延後處理,從而在總體上達到一個平衡總體處理速率的效果。

突發流量的處理,在令牌桶算法中有兩種方式,一種是有足夠的令牌才能消費,一種是先消費後還令牌。後者就像咱們0首付買車似的,30萬的車不多有等攢到30萬才全款買的,先簽了相關合同把車子給你,而後貸款慢慢還,這樣就爽了。RateLimiter也是一樣的道理,先讓請求獲得處理,再慢慢還上預支的令牌,客戶端一樣也爽了,不然我假設預支60個令牌,1分鐘以後才能處理個人請求,不合理也不人性化。

 

RateLimiter的限制

特別注意RateLimiter是單機的,也就是說它沒法跨JVM使用,設置的1000QPS,那也在單機中保證平均1000QPS的流量。

假設集羣中部署了10臺服務器,想要保證集羣1000QPS的接口調用量,那麼RateLimiter就不適用了,集羣流控最多見的方法是使用強大的Redis:

  • 一種是固定窗口的計數,例如當前是2019/8/26 20:05:00,就往這個"2019/8/26 20:05:00"這個key進行incr,當前是2019/8/26 20:05:01,就往"2019/8/26 20:05:01"這個key進行incr,incr後的結果只要大於咱們設定的值,那麼就打回去,小於就至關於獲取到了執行權限
  • 一種是結合lua腳本,實現分佈式的令牌桶算法,網上實現仍是比較多的,能夠參考http://www.javashuo.com/article/p-cyozlxxy-ee.html這篇文章

總得來講,集羣限流的實現也比較簡單。

 

總結

本文主要寫了常見的兩種限流算法漏桶算法與令牌桶算法,而且演示了Guava中RateLimiter的實現,相信看到這裏的朋友必定都懂了,恭喜大家!

令牌桶算法是最經常使用的限流算法,它最大的特色就是允許必定程度的突發流量

漏桶算法一樣也有本身的應用之處,例如Nginx的限流模塊就是基於漏桶算法的,它最大的特色就是強行限制流量按照指定的比例下發,適合那種對流量有絕對要求的場景,就是流量能夠允許在我指定的值之下,能夠被屢次打回,可是不管如何決不能超過指定的。

雖然令牌桶算法相對更好,可是仍是我常常說的,使用哪一種徹底就看你們各自的場景,適合的纔是最好的。

相關文章
相關標籤/搜索