在系統架構設計當中,限流是一個不得不說的話題,由於他太不起眼,可是也過重要了。這點有些像古代鎮守邊陲的將士,據守隘口,抵擋住外族的千軍萬馬,一旦隘口失守,各類饕餮涌入城內,勢必將咱們苦心經營的朝堂廟店洗劫一空,以前的全部努力都付之一炬。因此今天咱們點了這個話題,一方面是要對限流作下總結,另外一方面,拋磚引玉,看看你們各自的系統中,限流是怎麼作的。redis
提到限流,映入腦海的確定是限制流量四個字,其重點在於如何限。並且這個限,還分爲單機限和分佈式限,單機限流,顧名思義,就是對部署了應用的docker機或者物理機,進行流量控制,以使得流量的涌入呈現可控的態勢,防止過大過快的流量涌入形成應用的性能問題,甚至於失去響應。分佈式限流,則是對集羣的流量限制,通常這類應用的流量限制集中在一個地方來進行,好比redis,zk或者其餘的可以支持分佈式限流的組件中。這樣當流量過大過快的時候,不至於由於集羣中的一臺機器被壓垮而帶來雪崩效應,形成集羣應用總體坍塌。算法
下面咱們來細數一下各類限流操做。sql
1. 基於計數器的單機限流docker
此類限流,通常是經過應用中的計數器來進行流量限制操做。計數器能夠用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(); } }架構
上面的代碼就是一個簡單的基於計數器實現的單機限流。代碼簡單易行,操做方便,並且能夠帶來不錯的效果。可是缺點也很明顯,那就是先來的流量通常都能打進來,後來的流量基本上都會被拒絕。每一個請求被執行的機率實際上是不同的,這樣就使得早來的用戶反而獲取不到執行機會,晚來的用戶反而有被執行的可能。併發
因此總結一下此種限流優缺點:dom
優勢:代碼簡潔,操做方便分佈式
缺點:先到先得,先到的請求可執行機率爲100%,後到的請求可執行機率小一些,每一個請求得到執行的機會是不平等的。ide
那麼,若是想讓每一個請求得到執行的機會是平等的話,該怎麼作呢?
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種限流方式的講解,主要是想起到拋磚引玉的做用,期待你們更好更優的解決方法。
以上代碼都是僞代碼,使用的時候請進行線上驗證,不然帶來了反作用的話,就得不償失了
歡迎工做一到五年的Java工程師朋友們加入Java架構開發: 855835163
羣內提供免費的Java架構學習資料(裏面有高可用、高併發、高性能及分佈式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用本身每一分每一秒的時間來學習提高本身,不要再用"沒有時間「來掩飾本身思想上的懶惰!趁年輕,使勁拼,給將來的本身一個交代!