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