面試官說:來談談限流-從概念到實現,一問你就懵逼了?

後端服務的接口都是有訪問上限的,如果外部QPS或併發量超過了訪問上限會導致應用癱瘓。所以一般都會對接口調用加上限流保護,防止超出預期的請求導致系統故障。

從限流類型來說一般來說分爲兩種:併發數限流和qps限流,併發數限流就是限制同一時刻的最大併發請求數量,qps限流指的是限制一段時間內發生的請求個數。

從作用範圍的層次上來看分單機限流和分佈式限流,前者是針對單機的,後者是針對集羣的,他們的思想都是一樣的,只不過是範圍不一樣,本文分析的都是單機限流

接下來我們看看併發數限流和QPS限流。

併發數限流

併發數限流限制的是同一時刻的併發數,所以不考慮線程安全的話,我們只要用一個int變量就能實現,僞代碼如下:

int maxRequest=100;
int nowRequest=0;

public void request(){
    if(nowRequest>=maxRequest){
        return ;
    }
    nowRequest++;
    //調用接口
    try{
         invokeXXX();    
    }finally{
         nowRequest--;
    }
}

顯然,上述實現會有線程安全的問題,最直接的做法是加鎖:

int maxRequest=100;
int nowRequest=0;

public void request(){
    if(nowRequest>=maxRequest){
        return ;
    }
    synchronized(this){
         if(nowRequest>=maxRequest){
            return ;
        }
        nowRequest++;
    }

    //調用接口
    try{
         invokeXXX();    
    }finally{
        synchronized(this){
            nowRequest--;
        }
    }
}

當然也可以用AtomicInteger實現:

int maxRequest=100;
AtomicInteger nowRequest=new AtomicInteger(0);

public void request(){
    for(;;){
        int currentReq=nowRequest.get();
        if(currentReq>=maxRequest){
            return;
        }
        if(nowRequest.compareAndSet(currentReq,currentReq+1)){
            break;
        }
    }

    //調用接口
    try{
         invokeXXX();    
    }finally{
        nowRequest.decrementAndGet();
    }
}

熟悉JDK併發包的同學會說幹嘛這麼麻煩,這不就是信號量(Semaphore)做的事情嗎? 對的,其實最簡單的方法就是用信號量來實現:

int maxRequest=100;
Semaphore reqSemaphore = new Semaphore(maxRequest);

public void request(){
    if(!reqSemaphore.tryAcquire()){
        return ;
    }

    //調用接口
    try{
         invokeXXX();    
    }finally{
       reqSemaphore.release();
    }
}

條條大路通羅馬,併發數限流比較簡單,一般來說用信號量就好。

QPS限流

QPS限流限制的是一段時間內(一般指1秒)的請求個數。

計數器法

最簡單的做法用一個int型的count變量做計數器:請求前計數器+1,如超過閾值並且與第一個請求的間隔還在1s內,則限流。

僞代碼如下:

int maxQps=100;
int count;
long timeStamp=System.currentTimeMillis();
long interval=1000;

public synchronized boolean grant(){
    long now=System.currentTimeMillis();
    if(now<timeStamp+interval){
        count++;
        return count<maxQps;
    }else{
        timeStamp=now;
        count=1;
        return true;
    }
}

該種方法實現起來很簡單,但其實是有臨界問題的,假如在第一秒的後500ms來了100個請求,第2秒的前500ms來了100個請求,那在這1秒內其實最大QPS爲200。如下圖:

計數器法會有臨界問題,主要還是統計的精度太低,這點可以通過滑動窗口算法解決

滑動窗口

我們用一個長度爲10的數組表示1秒內的QPS請求,數組每個元素對應了相應100ms內的請求數。用一個sum變量代碼當前1s的請求數。同時每隔100ms將淘汰過期的值。

僞代碼如下:

int maxQps=100;
AtomicInteger[] count=new AtomicInteger[10];
long timeStamp=System.currentTimeMillis();
long interval=1000;
AtomicInteger sum;
volatile int index;

public void init(){
    for(int i=0;i<count.length;i++){
        count[i]=new AtomicInteger(0);
    }
    sum=new AtomicInteger(0);
}

public synchronized boolean  grant(){
    count[index].incrementAndGet();
    return sum.incrementAndGet()<maxQps;
}

//每100ms執行一次
public void run(){
    index=(index+1)%count.length;
    int val=count[index].getAndSet(0);
    sum.addAndGet(-val);
}

滑動窗口的窗口越小,則精度越高,相應的資源消耗也更高。

漏桶算法

漏桶算法思路是,有一個固定大小的桶,水(請求)忽快忽慢的進入到漏桶裏,漏桶以一定的速度出水。當桶滿了之後會發生溢出。

維基百科上可以看到,漏桶算法有兩種實現,一種是as a meter,另一種是as a queue網上大多數文章都沒有提到其有兩種實現,且對這兩種概念混亂。

As a meter

第一種實現是和令牌桶等價的,只是表述角度不同。

僞代碼如下:

long timeStamp=System.currentTimeMillis();//上一次調用grant的時間
int bucketSize=100;//桶大小
int rate=10;//每ms流出多少請求
int count;//目前的水量

public synchronized boolean grant(){
    long now = System.currentTimeMillis();
    if(now>timeStamp){
         count = Math.max(0,count-(now-timeStamp)*rate); 
         timeStamp = now;
    }

    if(count+1<=bucketSize){
        count++;
        return true;
    }else{
        return false;
    }
}

該種實現允許一段時間內的突發流量,比如初始時桶中沒有水,這時1ms內來了100個請求,這100個請求是不會被限流的,但之後每ms最多隻能接受10個請求(比如下1ms又來了100個請求,那其中90個請求是會被限流的)。

其達到的效果和令牌桶一樣。

As a queue

第二種實現是用一個隊列實現,當請求到來時如果隊列沒滿則加入到隊列中,否則拒絕掉新的請求。同時會以恆定的速率從隊列中取出請求執行。

僞代碼如下:

Queue<Request> queue=new LinkedBlockingQueue(100);
int gap;
int rate;

public synchronized boolean grant(Request req){
    if(!queue.offer(req)){return false;}
}

// 單獨線程執行
void consume(){
    while(true){
        for(int i=0;i<rate;i++){
            //執行請求
            Request req=queue.poll();
            if(req==null){break;}
            req.doRequest();
        }
        Thread.sleep(gap);
    }
}

對於該種算法,固定的限定了請求的速度,不允許流量突發的情況。

比如初始時桶是空的,這時1ms內來了100個請求,那只有前10個會被接受,其他的會被拒絕掉。注意與上文中as a meter實現的區別。

不過,當桶的大小等於每個ticket流出的水大小時,第二種漏桶算法和第一種漏桶算法是等價的。也就是說,as a queueas a meter的一種特殊實現。如果你沒有理解這句話,你可以再看看上面as a meter的僞代碼,當bucketSize==rate時,請求速度就是恆定的,不允許突發流量。

令牌桶算法

令牌桶算法的思想就是,桶中最多有N個令牌,會以一定速率往桶中加令牌,每個請求都需要從令牌桶中取出相應的令牌才能放行,如果桶中沒有令牌則被限流。

令牌桶算法與上文的漏桶算法as a meter實現是等價的,能夠在限制數據的平均傳輸速率的同時還允許某種程度的突發傳輸。僞代碼:

int token;
int bucketSize;
int rate;
long timeStamp=System.currentTimeMillis();

public synchronized boolean grant(){
    long now=System.currentTimeMillis();
    if(now>timeStamp){
         token=Math.max(bucketSize,token+(timeStamp-now)*rate);
         timeStamp=now;
    }
    if(token>0){
        token--;
        return true;
    }else{
        return false;
    }

}

漏桶算法兩種實現和令牌桶算法的對比

as a meter的漏桶算法和令牌桶算法是一樣的,只是思想角度有所不同。

as a queue的漏桶算法能強行限制數據的傳輸速率,而令牌桶和as a meter漏桶則能夠在限制數據的平均傳輸速率的同時還允許某種程度的突發傳輸。

一般業界用的比較多的是令牌桶算法,像guava中的RateLimiter就是基於令牌桶算法實現的。當然不同的業務場景會有不同的需要,具體的選擇還是要結合場景。

End

本文介紹了後端系統中常用的限流算法,對於每種算法都有對應的僞代碼,結合僞代碼理解起來應該不難。但僞代碼中只是描述了大致思想,對於一些細節和效率問題並沒有關注,所以下篇文章將會分析常用限流API:guava的RateLimiter的源碼實現,讓讀者對於限流有個更清晰的認識。

本人免費整理了Java高級資料,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高併發分佈式等教程,一共30G,需要自己領取。
傳送門:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL8Q

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章