天天看點

你所不知道的限流

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

提到限流,映入腦海的肯定是限制流量四個字,其重點在于如何限。而且這個限,還分為單機限和分布式限,單機限流,顧名思義,就是對部署了應用的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%,後到的請求可執行機率小一些,每個請求獲得執行的機會是不平等的。

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

  1. 基于随機數的單機限流

此種限流算法,使得請求可被執行的機率是一緻的,是以相對于基于計數器實作的限流說來,對使用者更加的友好一些。代碼如下:

//擷取随機數 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生成的值。

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

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

  1. 基于時間段的單機限流

有時候,我們的應用隻想在機關時間内放固定的流量進來,比如一秒鐘内隻允許放進來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等。也是比較好的備選方案。

  1. 基于漏桶算法的單機限流

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

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

由于目前很多MQ,比如rabbitmq等,都屬于漏桶算法原理的具體實作,請求過來先入queue隊列,隊列滿了抛棄多餘請求,之後consumer端勻速消費隊列裡面的資料。是以這裡不再貼多餘的代碼。

優點:流量控制效果不錯

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

  1. 基于令牌桶算法的單機限流

所謂令牌桶(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個令牌生成出來,請求才開始繼續進行處理。

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

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

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

  1. 基于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種限流方式的講解,主要是想起到抛磚引玉的作用,期待大家更好更優的解決方法。

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

歡迎工作一到五年的Java工程師朋友們加入Java架構開發: 854393687 群内提供免費的Java架構學習資料(裡面有高可用、高并發、高性能及分布式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!

轉載于:https://juejin.im/post/5bd56e4d51882513ee258c89