一文搞懂高頻面試題之限流演算法,從演算法原理到實現,再到對比分析
阿新 • • 發佈:2020-09-09
限流是指在系統面臨高併發、大流量請求的情況下,限制新的流量對系統的訪問,從而保證系統服務的安全性。常用的限流演算法有計數器固定視窗演算法、滑動視窗演算法、漏斗演算法和令牌桶演算法,下面將對這幾種演算法進行分別介紹,並給出具體的實現。本文目錄如下,略長,讀者可以全文閱讀,同樣也可以只看感興趣的部分。
[TOC]
### 計數器固定視窗演算法
#### 原理
計數器固定視窗演算法是最基礎也是最簡單的一種限流演算法。原理就是對一段固定時間視窗內的請求進行計數,如果請求數超過了閾值,則捨棄該請求;如果沒有達到設定的閾值,則接受該請求,且計數加1。當時間視窗結束時,重置計數器為0。
![計數器固定視窗演算法原理圖](https://gitee.com/exzlc/pictures/raw/master/img/image-202009071543004412.png)
#### 程式碼實現及測試
實現起來也比較簡單,如下:
```java
package project.limiter;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Project: AllForJava
* Title:
* Description:
* Date: 2020-09-07 15:56
* Copyright: Copyright (c) 2020
*
* @公眾號: 超悅程式設計
* @微訊號:exzlco
* @author: 超悅人生
* @email: [email protected]
* @version 1.0
**/
public class CounterLimiter {
private int windowSize; //視窗大小,毫秒為單位
private int limit;//視窗內限流大小
private AtomicInteger count;//當前視窗的計數器
private CounterLimiter(){}
public CounterLimiter(int windowSize,int limit){
this.limit = limit;
this.windowSize = windowSize;
count = new AtomicInteger(0);
//開啟一個執行緒,達到視窗結束時清空count
new Thread(new Runnable() {
@Override
public void run() {
while(true){
count.set(0);
try {
Thread.sleep(windowSize);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
//請求到達後先呼叫本方法,若返回true,則請求通過,否則限流
public boolean tryAcquire(){
int newCount = count.addAndGet(1);
if(newCount > limit){
return false;
}else{
return true;
}
}
//測試
public static void main(String[] args) throws InterruptedException {
//每秒20個請求
CounterLimiter counterLimiter = new CounterLimiter(1000,20);
int count = 0;
//模擬50次請求,看多少能通過
for(int i = 0;i < 50;i ++){
if(counterLimiter.tryAcquire()){
count ++;
}
}
System.out.println("第一撥50次請求中通過:" + count + ",限流:" + (50 - count));
//過一秒再請求
Thread.sleep(1000);
//模擬50次請求,看多少能通過
count = 0;
for(int i = 0;i < 50;i ++){
if(counterLimiter.tryAcquire()){
count ++;
}
}
System.out.println("第二撥50次請求中通過:" + count + ",限流:" + (50 - count));
}
}
```
測試結果如下:
![計數器固定視窗演算法測試結果](https://gitee.com/exzlc/pictures/raw/master/img/image-202009071625554202.png)
可以看到50個請求只有20個通過了,30個被限流,達到了預期的限流效果。
#### 特點分析
**優點**:實現簡單,容易理解。
**缺點**:流量曲線可能不夠平滑,有“突刺現象”,如下圖所示。這樣會有兩個問題:
![計數器固定視窗演算法限流曲線](https://gitee.com/exzlc/pictures/raw/master/img/image-202009071752070592.png)
1. **一段時間內(不超過時間視窗)系統服務不可用**。比如視窗大小為1s,限流大小為100,然後恰好在某個視窗的第1ms來了100個請求,然後第2ms-999ms的請求就都會被拒絕,這段時間使用者會感覺系統服務不可用。
2. **視窗切換時可能會產生兩倍於閾值流量的請求**。比如視窗大小為1s,限流大小為100,然後恰好在某個視窗的第999ms來了100個請求,視窗前期沒有請求,所以這100個請求都會通過。再恰好,下一個視窗的第1ms有來了100個請求,也全部通過了,那也就是在2ms之內通過了200個請求,而我們設定的閾值是100,通過的請求達到了閾值的兩倍。
![計數器固定視窗限流演算法產生兩倍於閾值流量的請求](https://gitee.com/exzlc/pictures/raw/master/img/image-202009071808449262.png)
### 計數器滑動視窗演算法
#### 原理
計數器滑動視窗演算法是計數器固定視窗演算法的改進,解決了固定視窗切換時可能會產生兩倍於閾值流量請求的缺點。
滑動視窗演算法在固定視窗的基礎上,將一個計時視窗分成了若干個小視窗,然後每個小視窗維護一個獨立的計數器。當請求的時間大於當前視窗的最大時間時,則將計時視窗向前平移一個小視窗。平移時,將第一個小視窗的資料丟棄,然後將第二個小視窗設定為第一個小視窗,同時在最後面新增一個小視窗,將新的請求放在新增的小視窗中。同時要保證整個視窗中所有小視窗的請求數目之後不能超過設定的閾值。
![計數器滑動視窗演算法原理圖](https://gitee.com/exzlc/pictures/raw/master/img/image-202009071833171852.png)
從圖中不難看出,滑動視窗演算法就是固定視窗的升級版。將計時視窗劃分成一個小視窗,滑動視窗演算法就退化成了固定視窗演算法。而滑動視窗演算法其實就是對請求數進行了更細粒度的限流,視窗劃分的越多,則限流越精準。
#### 程式碼實現及測試
```java
package project.limiter;
/**
* Project: AllForJava
* Title:
* Description:
* Date: 2020-09-07 18:38
* Copyright: Copyright (c) 2020
*
* @公眾號: 超悅程式設計
* @微訊號:exzlco
* @author: 超悅人生
* @email: [email protected]
* @version 1.0
**/
public class CounterSildeWindowLimiter {
private int windowSize; //視窗大小,毫秒為單位
private int limit;//視窗內限流大小
private int splitNum;//切分小視窗的數目大小
private int[] counters;//每個小視窗的計數陣列
private int index;//當前小視窗計數器的索引
private long startTime;//視窗開始時間
private CounterSildeWindowLimiter(){}
public CounterSildeWindowLimiter(int windowSize, int limit, int splitNum){
this.limit = limit;
this.windowSize = windowSize;
this.splitNum = splitNum;
counters = new int[splitNum];
index = 0;
startTime = System.currentTimeMillis();
}
//請求到達後先呼叫本方法,若返回true,則請求通過,否則限流
public synchronized boolean tryAcquire(){
long curTime = System.currentTimeMillis();
long windowsNum = Math.max(curTime - windowSize - startTime,0) / (windowSize / splitNum);//計算滑動小視窗的數量
slideWindow(windowsNum);//滑動視窗
int count = 0;
for(int i = 0;i < splitNum;i ++){
count += counters[i];
}
if(count >= limit){
return false;
}else{
counters[index] ++;
return true;
}
}
private synchronized void slideWindow(long windowsNum){
if(windowsNum == 0)
return;
long slideNum = Math.min(windowsNum,splitNum);
for(int i = 0;i < slideNum;i ++){
index = (index + 1) % splitNum;
counters[index] = 0;
}
startTime = startTime + windowsNum * (windowSize / splitNum);//更新滑動視窗時間
}
//測試
public static void main(String[] args) throws InterruptedException {
//每秒20個請求
int limit = 20;
CounterSildeWindowLimiter counterSildeWindowLimiter = new CounterSildeWindowLimiter(1000,limit,10);
int count = 0;
Thread.sleep(3000);
//計數器滑動視窗演算法模擬100組間隔30ms的50次請求
System.out.println("計數器滑動視窗演算法測試開始");
System.out.println("開始模擬100組間隔150ms的50次請求");
int faliCount = 0;
for(int j = 0;j < 100;j ++){
count = 0;
for(int i = 0;i < 50;i ++){
if(counterSildeWindowLimiter.tryAcquire()){
count ++;
}
}
Thread.sleep(150);
//模擬50次請求,看多少能通過
for(int i = 0;i < 50;i ++){
if(counterSildeWindowLimiter.tryAcquire()){
count ++;
}
}
if(count > limit){
System.out.println("時間視窗內放過的請求超過閾值,放過的請求數" + count + ",限流:" + limit);
faliCount ++;
}
Thread.sleep((int)(Math.random() * 100));
}
System.out.println("計數器滑動視窗演算法測試結束,100組間隔150ms的50次請求模擬完成,限流失敗組數:" + faliCount);
System.out.println("===========================================================================================");
//計數器固定視窗演算法模擬100組間隔30ms的50次請求
System.out.println("計數器固定視窗演算法測試開始");
//模擬100組間隔30ms的50次請求
CounterLimiter counterLimiter = new CounterLimiter(1000,limit);
System.out.println("開始模擬100組間隔150ms的50次請求");
faliCount = 0;
for(int j = 0;j < 100;j ++){
count = 0;
for(int i = 0;i < 50;i ++){
if(counterLimiter.tryAcquire()){
count ++;
}
}
Thread.sleep(150);
//模擬50次請求,看多少能通過
for(int i = 0;i < 50;i ++){
if(counterLimiter.tryAcquire()){
count ++;
}
}
if(count > limit){
System.out.println("時間視窗內放過的請求超過閾值,放過的請求數" + count + ",限流:" + limit);
faliCount ++;
}
Thread.sleep((int)(Math.random() * 100));
}
System.out.println("計數器滑動視窗演算法測試結束,100組間隔150ms的50次請求模擬完成,限流失敗組數:" + faliCount);
}
}
```
測試時,取滑動視窗大小為1000/10=100ms,然後模擬100組間隔150ms的50次請求,計數器滑動視窗演算法與計數器固定視窗演算法進行對別,可以看到如下結果:
![計數器滑動視窗演算法測試結果](https://gitee.com/exzlc/pictures/raw/master/img/image-20200909102303406.png)
固定視窗演算法在視窗切換時產生了兩倍於閾值流量請求的問題,而滑動視窗演算法避免了這個問題。
#### 特點分析
1. 避免了計數器固定視窗演算法固定視窗切換時可能會產生兩倍於閾值流量請求的問題;
2. 和漏斗演算法相比,新來的請求也能夠被處理到,避免了漏斗演算法的飢餓問題。
### 漏斗演算法
#### 原理
漏斗演算法的原理也很容易理解。請求來了之後會首先進到漏斗裡,然後漏斗以恆定的速率將請求流出進行處理,從而起到平滑流量的作用。當請求的流量過大時,漏斗達到最大容量時會溢位,此時請求被丟棄。從系統的角度來看,我們不知道什麼時候會有請求來,也不知道請求會以多大的速率來,這就給系統的安全性埋下了隱患。但是如果加了一層漏斗演算法限流之後,就能夠保證請求以恆定的速率流出。在系統看來,請求永遠是以平滑的傳輸速率過來,從而起到了保護系統的作用。
![漏斗演算法原理圖](https://gitee.com/exzlc/pictures/raw/master/img/081225378155003.png)
#### 程式碼實現及測試
```java
package project.limiter;
import java.util.Date;
import java.util.LinkedList;
/**
* Project: AllForJava
* Title:
* Description:
* Date: 2020-09-08 16:45
* Copyright: Copyright (c) 2020
*
* @公眾號: 超悅程式設計
* @微訊號:exzlco
* @author: 超悅人生
* @email: [email protected]
* @version 1.0
**/
public class LeakyBucketLimiter {
private int capaticy;//漏斗容量
private int rate;//漏斗速率
private int left;//剩餘容量
private Li