1. 程式人生 > >你所不知道的限流

你所不知道的限流

mit 快的 方法 容災 優點 font 基本 很多 效應

在系統架構設計當中,限流是一個不得不說的話題,因為他太不起眼,但是也太重要了。這點有些像古代鎮守邊陲的將士,據守隘口,抵擋住外族的千軍萬馬,一旦隘口失守,各種饕餮湧入城內,勢必將我們苦心經營的朝堂廟店洗劫一空,之前的所有努力都付之一炬。所以今天我們點了這個話題,一方面是要對限流做下總結,另一方面,拋磚引玉,看看大家各自的系統中,限流是怎麽做的。

提到限流,映入腦海的肯定是限制流量四個字,其重點在於如何限。而且這個限,還分為單機限和分布式限,單機限流,顧名思義,就是對部署了應用的docker機或者物理機,進行流量控制,以使得流量的湧入呈現可控的態勢,防止過大過快的流量湧入造成應用的性能問題,甚至於失去響應。分布式限流,則是對集群的流量限制,一般這類應用的流量限制集中在一個地方來進行,比如redis,zk或者其他的能夠支持分布式限流的組件中。這樣當流量過大過快的時候,不至於因為集群中的一臺機器被壓垮而帶來雪崩效應,造成集群應用整體坍塌。

下面我們來細數一下各種限流操作。

1. 基於計數器的單機限流

此類限流,一般是通過應用中的計數器來進行流量限制操作。計數器可以用Integer類型的變量,也可以用Java自帶的AtomicLong來實現。原理就是設置一個計數器的閾值,每當有流量進入的時候,將計數器遞增,當達到閾值的時候,後續的請求將會直接被拋棄。代碼實現如下:

    //限流計數器
    private static AtomicLong counter = new AtomicLong();
    //限流閾值
    private static final long counterMax = 500;
    //業務處理方法
public void invoke(Request request) { try { //請求過濾 if (counter.incrementAndGet() > counterMax) { return; } //業務邏輯 doBusiness(request); } catch (Exception e) { //錯誤處理 doException(request,e); } finally
{ counter.decrementAndGet(); } }

上面的代碼就是一個簡單的基於計數器實現的單機限流。代碼簡單易行,操作方便,而且可以帶來不錯的效果。但是缺點也很明顯,那就是先來的流量一般都能打進來,後來的流量基本上都會被拒絕。由於每個請求被執行的概率其實不一樣,所以就沒有公平性可言。

所以總結一下此種限流優缺點:

優點:代碼簡潔,操作方便

缺點:先到先得,先到的請求可執行概率為100%,後到的請求可執行概率小一些,每個請求獲得執行的機會是不平等的。

那麽,如果想讓每個請求獲得執行的機會是平等的話,該怎麽做呢?

2. 基於隨機數的單機限流

此種限流算法,使得請求可被執行的概率是一致的,所以相對於基於計數器實現的限流說來,對用戶更加的友好一些。代碼如下:

    //獲取隨機數
    private static ThreadLocalRandom ptgGenerator = ThreadLocalRandom.current();
    //限流百分比,允許多少流量通過此業務,這裏限定為10%
    private static final long ptgGuarder = 10;
    //業務處理方法
    public void invoke(Request request) {
        try {
            //請求進入,獲取百分比
            int currentPercentage = ptgGenerator.nextInt(1, 100);
            if (currentPercentage <= ptgGuarder) {
                //業務處理
                doBusiness(request);
            } else {
                return;
            }
        } catch (Exception e) {
            //錯誤處理
            doException(request, e);
        }
    }

從上面代碼可以看出來,針對每個請求,都會先獲取一個隨機的1~100的執行率,然後和當前限流閾值(比如當前接口只允許10%的流量通過)相比,如果小於此限流閾值,則放行;如果大於此限流閾值,則直接返回,不做任何處理。和之前的計數器限流比起來,每個請求獲得執行的概率是一致的。當然,在真正的業務場景中,用戶可以通過動態配置化閾值參數,來控制每分鐘通過的流量百分比,或者是每小時通過的流量百分比。但是如果對於突增的高流量,此種方法則有點問題,因為高並發下,每個請求之間進入的時間很短暫,導致nextInt生成的值,大概率是重復的,所以這裏需要做的一個優化點,就是為其尋找合適的seed,用於優化nextInt生成的值。

優點:代碼簡潔,操作簡便,每個請求可執行的機會是平等的。

缺點:不適合應用突增的流量。

3. 基於時間段的單機限流

有時候,我們的應用只想在單位時間內放固定的流量進來,比如一秒鐘內只允許放進來100個請求,其他的請求拋棄。那麽這裏的做法有很多,可以基於計數器限流實現,然後判斷時間,但是此種做法稍顯復雜,可控性不是特別好。

那麽這裏我們就要用到緩存組件來實現了。原理是這樣的,首先請求進來,在guava中設置一個key,此key就是當前的秒數,秒數的值就是放進來的請求累加數,如果此累加數到100了,則拒絕後續請求即可。代碼如下:

   //獲取guava實例
    private static LoadingCache<Long, AtomicLong> guava = CacheBuilder.newBuilder()
            .expireAfterWrite(5, TimeUnit.SECONDS)
            .build(new CacheLoader<Long, AtomicLong>() {
                        @Override
                        public AtomicLong load(Long seconds) throws Exception {
                            return null;
                        }
                    });
    //每秒允許通過的請求數
    private static final long requestsPerSecond = 100;
    //業務處理方法
    public void invoke(Request request) {
        try {
            //guava key
            long guavaKey = System.currentTimeMillis() / 1000;
            //請求累加數
            long guavaVal = guava.get(guavaKey).incrementAndGet();
            if (guavaVal <= requestsPerSecond) {
                //業務處理
                doBusiness(request);
            } else {
                return;
            }
        } catch (Exception e) {
            //錯誤處理
            doException(request, e);
        }
    }

從上面的代碼中可以看到,我們巧妙的利用了緩存組件的特性來實現。每當有請求進來,緩存組件中的key值累加,到達閾值則拒絕後續請求,這樣很方便的實現了時間段限流的效果。雖然例子中給的是按照秒來限流的實現,我們可以在此基礎上更改為按照分鐘或者按照小時來實現的方案。

優點:操作簡單,可靠性強

缺點:突增的流量,會導致每個請求都會訪問guava,由於guava是堆內內存實現,勢必會對性能有一點點影響。其實如果怕限流影響到其他內存計算,我們可以將此限流操作用堆外內存組件來實現,比如利用OHC或者mapdb等。也是比較好的備選方案。

4. 基於漏桶算法的單機限流

所謂漏桶(Leaky bucket),則是指,有一個盛水的池子,然後有一個進水口,有一個出水口,進水口的水流可大可小,但是出水口的水流是恒定的。下圖圖示可以顯示的更加清晰:

技術分享圖片

從圖中我們可以看到,水龍頭相當於各端的流量,進入到漏桶中,當流量很小的時候,漏桶可以承載這種流量,出水口按照恒定的速度出水,水不會溢出來。當流量開始增大的時候,漏桶中的出水速度趕不上進水速度,那麽漏桶中的水位一直在上漲。當流量再大,則漏桶中的水過滿則溢。

由於目前很多MQ,比如rabbitmq等,都屬於漏桶算法原理的具體實現,請求過來先入queue隊列,隊列滿了拋棄多余請求,之後consumer端勻速消費隊列裏面的數據。所以這裏不再貼多余的代碼。

優點:流量控制效果不錯

缺點:不能夠很好的應付突增的流量。適合保護性能較弱的系統,但是不適合性能較強的系統。如果性能較強的系統能夠應對這種突增的流量的話,那麽漏桶算法是不合適的。

5. 基於令牌桶算法的單機限流

所謂令牌桶(Token Bucket),則是指,請求過來的時候,先去令牌桶裏面申請令牌,申請到令牌之後,才能去進行業務處理。如果沒有申請到令牌,則操作終止。具體說明如下圖:

技術分享圖片

由於生成令牌的流量是恒定的,面對突增流量的時候,桶裏有足夠令牌的情況下,突增流量可以快速的獲取到令牌,然後進行處理。從這裏可以看出令牌桶對於突增流量的處理是容許的。

由於目前guava組件中已經有了對令牌桶的具體實現類:RateLimiter, 所以我們可以借助此類來實現我們的令牌桶限流。代碼如下:

    //指定每秒放1個令牌
    private static RateLimiter limiter = RateLimiter.create(1);
    //令牌獲取超時時間
    private static final long acquireTimeout = 1000;
    //業務處理方法
    public void invoke(Request request) {
        try {
            //拿到令牌則進行業務處理
            if (limiter.tryAcquire(acquireTimeout, TimeUnit.MILLISECONDS)) {
                //業務處理
                doBusiness(request);
            }
            //拿不到令牌則退出
            else {
                return;
            }
        } catch (Exception e) {
            //錯誤處理
            doException(request, e);
        }
    }

從上面代碼我們可以看到,一秒生成一個令牌,那麽我們的接口限定為一秒處理一個請求,如果感覺接口性能可以達到1000tps單機,那麽我們可以適當的放大令牌桶中的令牌數量,比如800,那麽當突增流量過來,會直接拿到令牌然後進行業務處理。但是當令牌桶中的令牌消費完畢之後,那麽請求就會被阻塞,直到下一秒另一批800個令牌生成出來,請求才開始繼續進行處理。

所以利用令牌桶的優缺點就很明顯了:

有點:使用簡單,有成熟組件

缺點:適合單機限流,不適合分布式限流。

6. 基於redis lua的分布式限流

由於上面5中限流方式都是單機限流,但是在實際應用中,很多時候我們不僅要做單機限流,還要做分布式限流操作。由於目前做分布式限流的方法非常多,我就不再一一贅述了。我們今天用到的分布式限流方法,是redis+lua來實現的。

為什麽用redis+lua來實現呢?原因有兩個:

其一:redis的性能很好,處理能力強,且容災能力也不錯。

其二:一個lua腳本在redis中就是一個原子性操作,可以保證數據的正確性。

由於要做限流,那麽肯定有key來記錄限流的累加數,此key可以隨著時間進行任意變動。而且key需要設置過期參數,防止無效數據過多而導致redis性能問題。

來看看lua代碼:

            --限流的key
            local key = ‘limitkey‘..KEYS[1]

            --累加請求數
            local val = tonumber(redis.call(‘get‘, key) or 0)

            --限流閾值
            local threshold = tonumber(ARGV[1])

            if  val>threshold then
                --請求被限
                return 0
            else
                --遞增請求數
                redis.call(‘INCRBY‘, key, "1")
                --5秒後過期
                redis.call(‘expire‘, key, 5)
                --請求通過
                return 1
            end

之後就是直接調用使用,然後根據返回內容為0還是1來判定業務邏輯能不能走下去就行了。這樣可以通過此代碼段來控制整個集群的流量,從而避免出現雪崩效應。當然此方案的解決方式也可以利用zk來進行,由於zk的強一致性保證,不失為另一種好的解決方案,但是由於zk的性能沒有redis好,所以如果在意性能的話,還是用redis吧。

優點:集群整體流量控制,防止雪崩效應

缺點:需要引入額外的redis組件,且要求redis支持lua腳本。

總結

通過以上6種限流方式的講解,主要是想起到拋磚引玉的作用,期待大家更好更優的解決方法。

以上代碼都是偽代碼,使用的時候請進行線上驗證,否則帶來了副作用的話,就得不償失了。

你所不知道的限流