來談談限流-從概念到實現
後端服務的介面都是有訪問上限的,如果外部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 booleangrant(){ 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 queue
是 as 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
就是基於令牌桶演算法實現的。當然不同的業務場景會有不同的需要,具體的選擇還是要結合場景。