來談談限流-從概念到實現

做個好人君發表於2019-01-21

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

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

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

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

更多文章見個人部落格:github.com/farmerjohng…

併發數限流

併發數限流限制的是同一時刻的併發數,所以不考慮執行緒安全的話,我們只要用一個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。如下圖:

image

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

滑動視窗

我們用一個長度為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);
}

複製程式碼

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

漏桶演算法

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

image

維基百科上可以看到,漏桶演算法有兩種實現,一種是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

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

image

虛擬碼如下:

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個令牌,會以一定速率往桶中加令牌,每個請求都需要從令牌桶中取出相應的令牌才能放行,如果桶中沒有令牌則被限流。

image

令牌桶演算法與上文的漏桶演算法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的原始碼實現,讓讀者對於限流有個更清晰的認識。

參考文章

blog.zhuxingsheng.com/blog/counte…

blog.51cto.com/leyew/86030…

en.wikipedia.org/wiki/Leaky_…

相關文章