限流,永遠都不是一件簡單的事!

背景

隨着微服務的流行,服務之間的穩定性變得越發重要,往往我們會花很多經歷再維護服務的穩定性上,限流和熔斷降級是我們最常用的兩個手段。前段時間在羣裏有些小夥伴對限流的使用些疑問,再加上最近公司大促也做了限流相關的事,所以在這裏總結一下寫寫自己對限流的一些看法。

剛纔說了限流是我們保證服務穩定性的手段之一,但是他並不是所有場景的穩定性都能保證,和他名字一樣他只能在大流量或者突發流量的場景下才能發揮出自己的作用。比如我們的系統最高支持100QPS,但是突然有1000QPS請求打了進來,可能這個時候系統就會直接掛掉,導致後面一個請求都處理不了,但是如果我們有限流的手段,無論他有多大的QPS,我們都只處理100QPS的請求,其他請求都直接拒絕掉,雖然有900的QPS的請求我們拒絕掉了,但是我們的系統沒有掛掉,我們系統仍然可以不斷的處理後續的請求,這個是我們所期望的。有同學可能會說,現在都上的雲了,服務的動態伸縮應該是特別簡單的吧,如果我們發現流量特別大的時候,自動擴容機器到可以支撐目標QPS那不就不需要限流了嗎?其實有這個想法的同學應該還挺多的,有些同學可能被一些吹牛的文章給唬到了,所以纔會這麼想,這個想法在特別理想化的時候是可以實現的,但是在現實中其實有下面幾個問題:

  • 擴容是需要時間。擴容簡單來說就是搞一個新的機器,然後重新發布代碼,做java的同學應該是知道發佈成功一個代碼的時間一般不是以秒級計算,而是以分鐘級別計算,有時候你擴容完成,說不定流量尖峯都過去了。
  • 擴容到多少是個特別複雜的問題。擴容幾臺機器這個是比較複雜的,需要大量的壓測計算,以及整條鏈路上的一個擴容,如果擴容了你這邊的機器之後,其他團隊的機器沒有擴容可能最後還是有瓶頸這個也是一個問題。

所以單純的擴容是解決不了這個問題的,限流仍然是我們必須掌握的技能!

基本原理

想要掌握好限流,就需要先掌握他的一些基本算法,限流的算法基本上分爲三種,計數器,漏斗,令牌桶,其他的一些都是在這些基礎上進行演變而來。

計數器算法

首先我們來說一下計數器算法,這個算法比較簡單粗暴,我們只需要一個累加變量,然後每隔一秒鐘去刷新這個累加變量,然後再判斷這個累加變量是否大於我們的最大QPS。

    int curQps = 0;
    long lastTime = System.currentTimeMillis();
    int maxQps = 100;
    Object lock = new Object();
    boolean check(){
        synchronized (lock){
            long now = System.currentTimeMillis();
            if (now - lastTime > 1000){
                lastTime = now;
                curQps = 0;
            }
            curQps++;
            if (curQps > maxQps){
                return false;
            }
        }
        return true;
    }

這個代碼比較簡單,我們定義了當前的qps,以及上一次刷新累加變量的時間,還有我們的最大qps和我們的lock鎖,我們每次檢查的時候,都需要判斷是否需要刷新,如果需要刷新那麼需要把時間和qps都進行重置,然後再進行qps的累加判斷。

這個算法因爲太簡單了所以帶來的問題也是特別明顯,如果我們最大的qps是100,在0.99秒的時候來了100個請求,然後在1.01秒的時候又來了100個請求,這個是可以通過我們的程序的,但是我們其實在0.03秒之內通過了200個請求,這個肯定不符合我們的預期,因爲很有可能這200個請求直接就會將我們機器給打掛。

滑動窗口計數器

爲了解決上面的臨界的問題,我們這裏可以使用滑動窗口來解決這個問題: 如上圖所示,我們將1s的普通計數器,分成了5個200ms,我們統計的當前qps都需要統計最近的5個窗口的所有qps,再回到剛纔的問題,0.99秒和1.01秒其實都在我們的最近5個窗口之內,所以這裏不會出現剛纔的臨界的突刺問題。

其實換個角度想,我們普通的計數器其實就是窗口數量爲1的滑動窗口計數器,只要我們分的窗口越多,我們使用計數器方案的時候統計就會越精確,但是相對來說維護的窗口的成本就會增加,等會我們介紹sentinel的時候會詳細介紹他是怎麼實現滑動窗口計數的。

漏斗算法

解決計數器中臨界的突刺問題也可以通過漏斗算法來實現,如下圖所示:

在漏斗算法中我們需要關注漏桶和勻速流出,不論流量有多大都會先到漏桶中,然後以均勻的速度流出。如何在代碼中實現這個勻速呢?比如我們想讓勻速爲100q/s,那麼我們可以得到每流出一個流量需要消耗10ms,類似一個隊列,每隔10ms從隊列頭部取出流量進行放行,而我們的隊列也就是漏桶,當流量大於隊列的長度的時候,我們就可以拒絕超出的部分。

漏斗算法同樣的也有一定的缺點:無法應對突發流量(和上面的臨界突刺不一樣,不要混淆)。比如一瞬間來了100個請求,在漏桶算法中只能一個一個的過去,當最後一個請求流出的時候時間已經過了一秒了,所以漏斗算法比較適合請求到達比較均勻,需要嚴格控制請求速率的場景。

令牌桶算法

爲了解決突發流量情況,我們可以使用令牌桶算法,如下圖所示: 這個圖上需要關注三個階段:

  • 生產令牌:我們在這裏同樣的還是假設最大qps是100,那麼我們從漏斗的每10ms過一個流量轉化成每10ms生產一個令牌,直到達到最大令牌。
  • 消耗令牌:我們每一個流量都會消耗令牌桶,這裏的消耗的規則可以多變,既可以是簡單的每個流量消耗一個令牌,又可以是根據不同的流量數據包大小或者流量類型來進行不同的消耗規則,比如查詢的流量消耗1個令牌,寫入的流量消耗2個令牌。
  • 判斷是否通過:如果令牌桶足夠那麼我們就允許流量通過,如果不足夠可以等待或者直接拒絕,這個就可以採用漏斗那種用隊列來控制。

單機限流

上面我們已經介紹了限流的一些基本算法,我們把這些算法應用到我們的分佈式服務中又可以分爲兩種,一個是單機限流,一個是集羣限流。單機限流指的是每臺機器各自做自己的限流,互不影響。我們接下來看看單機限流怎麼去實現呢?

guava

guava是谷歌開源的java核心工具庫,裏面包括集合,緩存,併發等好用的工具,當然也提供了我們這裏所需要的的限流的工具,核心類就是RateLimiter。

//  RateLimiter rateLimiter = RateLimiter.create(100, 500, TimeUnit.MILLISECONDS); 預熱的rateLimit
    RateLimiter rateLimiter = RateLimiter.create(100); // 簡單的rateLimit
    boolean limitResult = rateLimiter.tryAcquire();

使用方式比較簡單,如上面代碼所示,我們只需要構建一個RateLimiter,然後再調用tryAcquire方法,如果返回爲true代表我們此時流量通過,相反則被限流。在guava中RateLimiter也分爲兩種,一個是普通的令牌桶算法的實現,還有一個是帶有預熱的RateLimiter,可以讓我們令牌桶的釋放速度逐步增加直到最大,這個帶有預熱的在sentinel也有,這個可以在一些冷系統中比如數據庫連接池沒有完全填滿,還在不斷初始化的場景下使用。

在這裏只簡單的介紹一下guava的令牌桶怎麼去實現的:

普通的令牌桶創建了一個SmoothBursty的類,這個類也就是我們實現限流的關鍵,具體怎麼做限流的在我們的tryAcquire中: 這裏分爲四步:

  • Step1: 加上一個同步鎖,需要注意一下這裏在sentinel中並沒有加鎖這個環節,在guava中是有這個的,後續也會將sentinel的一些問題。
  • Step2: 判斷是否能申請令牌桶,如果桶內沒有足夠的令牌並且等待時間超過我們的timeout,這裏我們就不進行申請了。
  • Step3: 申請令牌並獲取等待時間,在我們tryAcquire中的timeout參數就是就是我們的最大等待時間,如果我們只是調用tryAcquire(),不會出現等待,第二步的時候已經快速失敗了。
  • Step4: sleep等待的時間。

扣除令牌的方法具體在reserverEarliestAvailable方法中:

這裏雖然看起來過程比較多,但是如果我們只是調用tryAcquire(),就只需要關注兩個紅框:

  • Step1: 根據當前最新時間發放token,在guava中沒有采用使用其他線程異步發放token的方式,把token的更新放在了我們每次調用限流方法中,這個設計可以值得學習一下,很多時候不一定需要異步線程去執行也可以達到我們想要的目的,並且也沒有異步線程的複雜。
  • Step2: 扣除令牌,這裏我們已經在canAcquire中校驗過了,令牌一定能扣除成功。

guava的限流目前就提供了這兩種方式的限流,很多中間件或者業務服務都把guava的限流作爲自己的工具,但是guava的方式比較侷限,動態改變限流,以及更多策略的限流都不支持,所以我們接下來介紹一下sentinel。

sentinel

sentinel是阿里巴巴開源的分佈式服務框架的輕量級流量控制框架,承接了阿里巴巴近 10 年的雙十一大促流量的核心場景,他的核心是流量控制但是不侷限於流量控制,還支持熔斷降級,監控等等。

使用sentinel的限流稍微比guava複雜很多,下面寫了一個最簡單的代碼:

        String KEY = "test";
        // ============== 初始化規則 =========
        List<FlowRule> rules = new ArrayList<FlowRule>();
        FlowRule rule1 = new FlowRule();
        rule1.setResource(KEY);
        // set limit qps to 20
        rule1.setCount(20);
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule1.setLimitApp("default");
        rules.add(rule1);
        rule1.setControlBehavior(CONTROL_BEHAVIOR_DEFAULT);
        FlowRuleManager.loadRules(rules);
        // ================ 限流判定 ===========
        Entry entry = null;

        try {
            entry = SphU.entry(KEY);
            // do something

        } catch (BlockException e1) {
            // 限流會拋出BlockException 異常
        }finally {
            if (entry != null) {
                entry.exit();
            }
        }
  • Step1:在sentinel中比較強調Resource這個概念,我們所保護的或者說所作用於都是基於Resource來說,所以我們首先需要確定我們的Resource的key,這裏我們簡單的設置爲test了。
  • Step2:然後我們初始化我們這個Resource的一個限流規則,我們這裏選擇的是針對QPS限流並且策略選擇的是默認,這裏默認的話就是使用的滑動窗口版的計數器,然後加載到全局的規則管理器裏面,整個規則的設置和guava的差別比較大。
  • Step3: 在sentinel第二個比較重要的概念就是Entry,Entry表示一次資源操作,內部會保存當前invocation信息,在finally的時候需要對entry進行退出。我們執行限流判定的時候實際上也就是獲取Entry,SphU.entry也就是我們執行我們上面限流規則的關鍵,這裏和guava不一樣如果被限流了,就會拋出BlockException,我們在進行限流的處理。

雖然sentinel的使用整體比guava複雜很多,但是算法的可選比guava的限流也多一點。

基於併發數(線程數)

我們之前介紹的都是基於QPS的,在sentinel中提供了基於併發數的策略,效果類似於信號量隔離,當我們需要讓業務線程池不被慢調用耗盡,我們就可以使用這種模式。

通常來說我們同一個服務提供的http接口都是使用的一個線程池,比如我們使用的tomcat-web服務器那麼我們就會有個tomcat的業務線程池,如果在http中有兩個方法A和B,B的速度相對來說比較快,A的速度相對來說比較慢,如果大量的調用A這個方法,由於A的速度太慢,線程得不到釋放,有可能導致線程池被耗盡,另一個方法B就得不到線程。這個場景我們之前有遇到過直接導致整個服務所接收的請求全部被拒絕。有的同學說限制A的QPS不是就可以了嗎,要注意的是QPS是每秒的,如果我們這個A接口的耗時大於1s,那麼下一波A來了之後QPS是要重新計算的。

基於這個就提供了基於併發數的限流,我們設置Grade爲FLOW_GRADE_THREAD,就可以實現這個限流模式。

基於QPS

基於QPS的限流sentinel也提供了4種策略:

  • 默認策略:設置Behavior爲CONTROL_BEHAVIOR_DEFAULT,這個模式是滑動窗口計數器模式。這種方式適用於對系統處理能力確切已知的情況下,比如通過壓測確定了系統的準確水位時。

  • Warm Up:設置爲Behavior爲CONTROL_BEHAVIOR_WARM_UP,類似之前guava中介紹的warmup。預熱啓動方式。當系統長期處於低水位的情況下,當流量突然增加時,直接把系統拉昇到高水位可能瞬間把系統壓垮。這個模式下QPS的曲線圖如下:

  • 勻速排隊:設置Behavior爲CONTROL_BEHAVIOR_RATE_LIMITER,這個模式其實就是漏斗算法,優缺點之前也講解過了
  • Warm Up + 勻速排隊:設置Behavior爲CONTROL_BEHAVIOR_WARM_UP_RATE_LIMITER,之前warm up到高水位之後使用的是滑動窗口的算法限流,這個模式下繼續使用勻速排隊的算法。

基於調用關係

sentinel提供了更爲複雜的一種限流,可以基於調用關係去做更爲靈活的限流:

  • 根據調用方限流:調用方的限流使用比較複雜,需要調用ContextUtil.enter(resourceName, origin),origin就是我們的調用方標識,然後在我們的rule設置參數的時候,對limitApp進行設置就可以進行對調用方的限流:

    • 設置爲default,默認對所有調用方都限流。
    • 設置爲{some_origin_name},代表對特定的調用者才限流。
    • 設置爲other,會對配置的一個referResource參數代表的調用者除外的進行限流。
  • 關聯流量控制: 在sentinel中也支持,兩個有關聯的資源可以互相影響流量控制,比如有兩個接口都使用的是同一個資源,一個接口比較重要,另外一個接口不是那麼重要,我們可以設置一個規則當重要的接口大量訪問的時候,就可以對另外一個不重要接口進行限流,防止這個接口突然出現流量影響重要的接口。

sentinel的一些問題

sentinel雖然提供了這麼多算法,但是也有一些問題:

  • 首先來說sentinel上手比較難,對比guava的兩行代碼來說,使用sentinel需要了解一些名詞,然後針對這些名詞再來使用,雖然sentinel提供了一些註解來幫助我們簡化使用,但是整體來說還是比guava要複雜。
  • sentinel有一定的運維成本,sentinel的使用往往需要搭建sentinel的server後臺,對比guava的開箱即用來說,有一定的運維成本。
  • sentinel的限流統計有一定的併發問題,在sentinel的源碼中是沒有加鎖的地方的,極端情況下如果qps限制的是10,如果有100個同時過限流的邏輯,這個時候都會通過,而guava不會發生這樣的情況。

這些問題基本上都是和guava的限流來比較的,畢竟sentinel的功能更多,付出的成本相對來說也會更多。

集羣限流

之前說的所有限流都是單機限流,但是我們現在都是微服務集羣的架構模式,通常一個服務會有多臺機器,比如有一個訂單服務,這個服務有10臺機器,那麼我們想做整個集羣限流到500QPS,我們應該怎麼去做呢?這個很簡單,直接每臺機器都限流50就好了,50*10就是500,但是在現實環境中會出現負載不均衡的情況,在微服務調用的時候負載均衡的算法多種多樣,比如同機房優先,輪訓,隨機等算法,這些算法都有可能導致我們的負載不是特別的均衡,就會導致我們整個集羣的QPS可能有沒有500,甚至在400的時候就被限流了,這個是我們真實場景中所遇到過的。既然單機限流有問題,那麼我們應該設計一個更加完善的集羣限流的方案

redis

這個方案不依賴限流的框架,我們整個集羣使用同一個redis即可,需要自己封裝一下限流的邏輯,這裏我們使用最簡單的計數器去設計,我們將我們的系統時間以秒爲單位作爲key,設置到redis裏面(可以設置一定的過期時間用於空間清理),利用redis的int原子加法,每來一個請求都進行+1,然後再判斷當前值是否超過我們限流的最大值。

redis的方案實現起來整體來說比較簡單,但是強依賴我們的系統時間,如果不同機器之間的系統時間有偏差限流就有可能不準確。

sentinel

在sentinel中提供了集羣的解決方案,這個對比其他的一些限流框架是比較有特色的。在sentinel中提供了兩種模式:

  • 獨立模式:限流服務作爲單獨的server進行部署,如下圖所示,所有的應用都向單獨部署的token-server進行獲取token,這種模式適用於跨服務之間的全侷限流,比如下面圖中,A和B都會去token-server去拿,這個場景一般來說比較少,更多的還是服務內集羣的限流比較多。

  • 內嵌模式:在內嵌模式下,我們會把server部署到我們應用實例中,我們也可以通過接口轉換我們的server-client身份,當然我們可以自己引入一些zk的一些邏輯設置讓我們的leader去當server,機器掛了也可以自動切換。這種比較適合同一個服務集羣之間的限流,靈活性比較好,但是要注意的是大量的token-server的訪問也有可能影響我們自己的機器。

當然sentinel也有一些兜底的策略,如果token-server掛了我們可以退化成我們單機限流的模式,不會影響我們正常的服務。

實戰

我們上面已經介紹了很多限流的工具,但是很多同學對怎麼去限流仍然比較迷惑。我們如果對一個場景或者一個資源做限流的話有下面幾個點需要確認一下:

  • 什麼地方去做限流
  • 限多少流
  • 怎麼去選擇工具

什麼地方去做限流

這個問題比較複雜,很多公司以及很多團隊的做法都不相同,在美團的時候搞了一波SOA,那個時候我記得所有的服務所有的接口都需要做限流,叫每個團隊去給接口評估一個合理的QPS上限,這樣做理論上來說是對的,我們每個接口都應該給與一個上限,防止把整體系統拖垮,但是這樣做的成本是非常之高的,所以大部分公司還是選擇性的去做限流。

首先我們需要確定一些核心的接口,比如電商系統中的下單,支付,如果流量過大那麼電商系統中的路徑就有問題,比如像對賬這種邊緣的接口(不影響核心路徑),我們可以不設置限流。

其次我們不一定只在接口層才做限流,很多時候我們直接在網關層把限流做了,防止流量進一步滲透到核心系統中。當然前端也能做限流,當前端捕獲到限流的錯誤碼之後,前端可以提示等待信息,這個其實也算是限流的一部分。其實當限流越在下游觸發我們的資源的浪費就越大,因爲在下游限流之前上游已經做了很多工作了,如果這時候觸發限流那麼之前的工作就會白費,如果涉及到一些回滾的工作還會加大我們的負擔,所以對於限流來說應該是越上層觸發越好。

限多少流

限多少流這個問題大部分的時候可能就是一個歷史經驗值,我們可以通過日常的qps監控圖,然後再在這個接觸上加一點冗餘的QPS可能這個就是我們的限流了。但是有一個場景需要注意,那就是大促(這裏指的是電商系統裏面的場景,其他系統類比流量較高的場景)的時候,我們系統的流量就會突增,再也不是我們日常的QPS了,這種情況下,往往需要我們在大促之前給我們系統進行全鏈路壓測,壓測出一個合理的上限,然後限流就基於這個上限去設置。

怎麼去選擇工具

一般來說大一點的互聯網公司都有自己的統一限流的工具這裏直接採用就好。對於其他情況的話,如果沒有集羣限流或者熔斷這些需求,我個人覺得選擇RateLimter是一個比較不錯的選擇,應該其使用比較簡單,基本沒有學習成本,如果有其他的一些需求我個人覺得選擇sentinel,至於hytrix的話我個人不推薦使用,因爲這個已經不再維護了。

總結

限流雖然只有兩個字,但是真正要理解限流,做好限流,是一件非常不容易的事,對於我個人而已,這篇文章也只是一些淺薄的見識,如果大家有什麼更好的意見可以關注我的公衆號留言進行討論。

如果大家覺得這篇文章對你有幫助,你的關注和轉發是對我最大的支持,O(∩_∩)O:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章