高併發限流
高併發限流
問題描述
突然發現自己的介面請求量突然漲到之前的10倍,頻寬被佔滿,沒多久該介面幾乎不可使用,並引發連鎖反應導致整個系統崩潰。
計數器(固定視窗)演算法
計數器演算法是使用計數器在週期內累加訪問次數,當達到設定的限流值時,觸發限流策略。下一個週期開始時,進行清零,重新計數。
此演算法在單機還是分散式環境下實現都非常簡單,使用redis的incr原子自增性和執行緒安全即可輕鬆實現。
這個演算法通常用於QPS限流和統計總訪問量,對於秒級以上的時間週期來說,會存在一個非常嚴重的問題,那就是臨界問題,如下圖:
假設1min內伺服器的負載能力為100,因此一個週期的訪問量限制在100,然而在第一個週期的最後5秒和下一個週期的開始5秒時間段內,分別湧入100的訪問量,雖然沒有超過每個週期的限制量,但是整體上10秒內已達到200的訪問量,已遠遠超過伺服器的負載能力,由此可見,計數器演算法方式限流對於週期比較長的限流,存在很大的弊端。
滑動視窗演算法
滑動視窗演算法是將時間週期分為N個小週期,分別記錄每個小週期內訪問次數,並且根據時間滑動刪除過期的小週期。
如下圖,假設時間週期為1min,將1min再分為2個小週期,統計每個小週期的訪問數量,則可以看到,第一個時間週期內,訪問數量為75,第二個時間週期內,訪問數量為100,超過100的訪問則被限流掉了
由此可見,當滑動視窗的格子劃分的越多,那麼滑動視窗的滾動就越平滑,限流的統計就會越精確。
此演算法可以很好的解決固定視窗演算法的臨界問題。
使用Redis中Zset方法可以實現滑動視窗,在一個列表中,value可以是隨機值,但是score是時間戳,zset中range方法可以拿到兩個時間戳間隔內的個數,如果超過則直接返回。
漏桶演算法
漏桶演算法是訪問請求到達時直接放入漏桶,如當前容量已達到上限(限流值),則進行丟棄(觸發限流策略)。漏桶以固定的速率進行釋放訪問請求(即請求通過),直到漏桶為空。
令牌桶演算法
一個存放固定容量令牌的桶,按照固定速率(每秒/或者可以自定義時間)往桶裡新增令牌,然後每次獲取一個令牌,當桶裡沒有令牌可取時,則拒絕服務
令牌桶分為2個動作,動作1(固定速率往桶中存入令牌)、動作2(客戶端如果想訪問請求,先從桶中獲取token)
RateLimiter
create(double permitsPerSecond)
根據指定的穩定吞吐率建立RateLimiter,這裡的吞吐率是指每秒多少許可數(通常是指QPS,每秒多少查詢)
create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
根據指定的穩定吞吐率和預熱期來建立RateLimiter,這裡的吞吐率是指每秒多少許可數(通常是指QPS,每秒多少個請求量),在這段預熱時間內,RateLimiter每秒分配的許可數會平穩地增長直到預熱期結束時達到其最大速率。(只要存在足夠請求數來使其飽和)
兩個方法區別:第一個方法固定速率生成令牌數,第二個方法有預熱時間段,在預熱階段內不超過設定的令牌數,超過預熱期後以固定時間速率生成令牌,當出現突然出現大量資料。
明顯區別:使用第一種方法可能會使消費方(後臺服務)沒有消費完成,一直往系統塞資料導致伺服器不可用,使用第二種方法將流量比較平滑的過渡,從而降低後臺服務down掉的風險(就是預熱期內設定的令牌數少,不容易一下子把系統攻破)
RateLimiter是一個抽象類,限流器有兩個實現類:1、SmoothBursty;2、SmoothWarmingUp
SmoothBursty是以穩定的速度生成permit。SmoothWarmingUp是漸進的生成,最終達到最大值趨於穩定。
償還機制:當前請求的債務(請求的令牌大於限流器儲存的令牌數)由下一個請求來償還(上個請求虧欠的令牌,下個請求需要等待虧欠令牌生產出來以後才能被授權)acquire多個token時生效。
stableIntervalMircos //穩定生成令牌的時間間隔。
maxBurstSeconds //1秒生產的令牌。
maxPermits //最大儲存令牌數。
nextFreeTicketMicros //下個請求可被授權令牌的時間(不管請求多少令牌),實現當前債務由下一個請求來償還機制關鍵。
storedPermits //已儲存的令牌,生產過剩的令牌儲存小於等於maxPermits,是應對突發流量的請求的關鍵。
//從RateLimiter中獲取一個permit,阻塞直到請求可以獲得為止。
public double acquire(){
Return acquire(1);
}
//從RateLimiter中獲取指定數量的permits,阻塞直到請求可以獲得為止
public double acquire(int permits) {
//計算獲得這些數量需等待時間
long microsToWait = reserve(permits);
//不可被打斷的等待
stopwatch.sleepMicrosUninterruptibly(microsToWait);
//單位轉換為秒
return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
//預訂給定數量的permits來使用,計算需要這些數量permits等待時間。
final long reserve(int permits) {
//校驗負數
checkPermits(permits);
//搶佔鎖,這裡的鎖使用單例模式獲得
synchronized (mutex()) {
//計算等待時間
return reserveAndGetWaitLength(permits, stopwatch.readMicros());
}
}
//具體計算等待時間的邏輯(繼承上一次債務,並且透支本次所需要的所有permits)
//注意這裡返回的是時間點
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 同步時間軸
resync(nowMicros);
// 繼承上次債務
long returnValue = nextFreeTicketMicros;
// 跟桶記憶體儲量比,本次可以獲取到的permit數量,如果儲存的permit大於本次需要的permit數量則此處是0,否則是一個正數
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
// 還缺少的permits數量
double freshPermits = requiredPermits - storedPermitsToSpend;
// 計算需要等待的時間(微秒)
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros);
// 繼承上一次債務時間點+這次需要等待的時間,讓下一次任務去等待
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
// 減去本次消費的permit數
this.storedPermits -= storedPermitsToSpend;
// 本次只需要等待到上次欠債時間點即可
return returnValue;
}
原始碼示例
/**
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
**/
@RestController
public class HelloController {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
private static final RateLimiter rateLimiter = RateLimiter.create(2);
/**
* tryAcquire嘗試獲取permit,預設超時時間是0,意思是拿不到就立即返回false
*/
@RequestMapping("/sayHello")
public String sayHello() {
if (rateLimiter.tryAcquire()) { // 一次拿1個
System.out.println(sdf.format(new Date()));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
System.out.println("limit");
}
return "hello";
}
/**
* acquire拿不到就等待,拿到為止
*/
@RequestMapping("/sayHi")
public String sayHi() {
rateLimiter.acquire(5); // 一次拿5個
System.out.println(sdf.format(new Date()));
return "hi";
}
}
各演算法比較
漏桶
漏桶的出水速度是恆定的,那麼意味著如果瞬時大流量的話,將有大部分請求被丟棄掉(也就是所謂的溢位)。
令牌桶
生成令牌的速度是恆定的,而請求去拿令牌是沒有速度限制的。這意味,面對瞬時大流量,該演算法可以在短時間內請求拿到大量令牌,而且拿令牌的過程並不是消耗很大的事情。
最後,不論是對於令牌桶拿不到令牌被拒絕,還是漏桶的水滿了溢位,都是為了保證大部分流量的正常使用,而犧牲掉了少部分流量,這是合理的,如果因為極少部分流量需要保證的話,那麼就可能導致系統達到極限而掛掉,得不償失。
並不能說明令牌桶一定比漏洞好,她們使用場景不一樣。令牌桶可以用來保護自己,主要用來對呼叫者頻率進行限流,為的是讓自己不被打垮。所以如果自己本身有處理能力的時候,如果流量突發(實際消費能力強於配置的流量限制),那麼實際處理速率可以超過配置的限制。
而漏桶演算法,這是用來保護他人,也就是保護他所呼叫的系統。主要場景是,當呼叫的第三方系統本身沒有保護機制,或者有流量限制的時候,我們的呼叫速度不能超過他的限制,由於我們不能更改第三方系統,所以只有在主調方控制。這個時候,即使流量突發,也必須捨棄。因為消費能力是第三方決定的。
如果要讓自己的系統不被打垮,用令牌桶。如果保證別人的系統不被打垮,用漏桶演算法。
這是單機(單程序)的限流,是JVM級別的的限流,所有的令牌生成都是在記憶體中,在分散式環境下不能直接這麼用。
如果我們能把permit放到Redis中就可以在分散式環境中用了。