1. 程式人生 > 其它 >高併發限流

高併發限流

高併發限流

問題描述

突然發現自己的介面請求量突然漲到之前的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中就可以在分散式環境中用了。

參考資料