你所不知道的限流
在系統架構設計當中,限流是一個不得不說的話題,因為他太不起眼,但是也太重要了。這點有些像古代鎮守邊陲的將士,據守隘口,抵擋住外族的千軍萬馬,一旦隘口失守,各種饕餮湧入城內,勢必將我們苦心經營的朝堂廟店洗劫一空,之前的所有努力都付之一炬。所以今天我們點了這個話題,一方面是要對限流做下總結,另一方面,拋磚引玉,看看大家各自的系統中,限流是怎麼做的。
提到限流,映入腦海的肯定是限制流量四個字,其重點在於如何限。而且這個限,還分為單機限和分散式限,單機限流,顧名思義,就是對部署了應用的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. 基於漏桶演算法的單機限流
所謂漏桶( ofollow,noindex" target="_blank">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]) ifval>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種限流方式的講解,主要是想起到拋磚引玉的作用,期待大家更好更優的解決方法。
以上程式碼都是虛擬碼,使用的時候請進行線上驗證,否則帶來了副作用的話,就得不償失了。