限流,永遠都不是一件簡單的事!
本文轉(zhuǎn)載自微信公眾號「 咖啡拿鐵」,作者 咖啡拿鐵。轉(zhuǎn)載本文請聯(lián)系 咖啡拿鐵公眾號。
背景
隨著微服務(wù)的流行,服務(wù)之間的穩(wěn)定性變得越發(fā)重要,往往我們會花很多經(jīng)歷在維護服務(wù)的穩(wěn)定性上,限流和熔斷降級是我們最常用的兩個手段。前段時間在群里有些小伙伴對限流的使用些疑問,再加上最近公司大促也做了限流相關(guān)的事,所以在這里總結(jié)一下寫寫自己對限流的一些看法。
剛才說了限流是我們保證服務(wù)穩(wěn)定性的手段之一,但是他并不是所有場景的穩(wěn)定性都能保證,和他名字一樣他只能在大流量或者突發(fā)流量的場景下才能發(fā)揮出自己的作用。比如我們的系統(tǒng)最高支持100QPS,但是突然有1000QPS請求打了進來,可能這個時候系統(tǒng)就會直接掛掉,導(dǎo)致后面一個請求都處理不了,但是如果我們有限流的手段,無論他有多大的QPS,我們都只處理100QPS的請求,其他請求都直接拒絕掉,雖然有900的QPS的請求我們拒絕掉了,但是我們的系統(tǒng)沒有掛掉,我們系統(tǒng)仍然可以不斷的處理后續(xù)的請求,這個是我們所期望的。有同學(xué)可能會說,現(xiàn)在都上的云了,服務(wù)的動態(tài)伸縮應(yīng)該是特別簡單的吧,如果我們發(fā)現(xiàn)流量特別大的時候,自動擴容機器到可以支撐目標(biāo)QPS那不就不需要限流了嗎?其實有這個想法的同學(xué)應(yīng)該還挺多的,有些同學(xué)可能被一些吹牛的文章給唬到了,所以才會這么想,這個想法在特別理想化的時候是可以實現(xiàn)的,但是在現(xiàn)實中其實有下面幾個問題:
- 擴容是需要時間。擴容簡單來說就是搞一個新的機器,然后重新發(fā)布代碼,做java的同學(xué)應(yīng)該是知道發(fā)布成功一個代碼的時間一般不是以秒級計算,而是以分鐘級別計算,有時候你擴容完成,說不定流量尖峰都過去了。
- 擴容到多少是個特別復(fù)雜的問題。擴容幾臺機器這個是比較復(fù)雜的,需要大量的壓測計算,以及整條鏈路上的一個擴容,如果擴容了你這邊的機器之后,其他團隊的機器沒有擴容可能最后還是有瓶頸這個也是一個問題。
所以單純的擴容是解決不了這個問題的,限流仍然是我們必須掌握的技能!
基本原理
想要掌握好限流,就需要先掌握他的一些基本算法,限流的算法基本上分為三種,計數(shù)器,漏斗,令牌桶,其他的一些都是在這些基礎(chǔ)上進行演變而來。
計數(shù)器算法
首先我們來說一下計數(shù)器算法,這個算法比較簡單粗暴,我們只需要一個累加變量,然后每隔一秒鐘去刷新這個累加變量,然后再判斷這個累加變量是否大于我們的最大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;
- }
這個代碼比較簡單,我們定義了當(dāng)前的qps,以及上一次刷新累加變量的時間,還有我們的最大qps和我們的lock鎖,我們每次檢查的時候,都需要判斷是否需要刷新,如果需要刷新那么需要把時間和qps都進行重置,然后再進行qps的累加判斷。
這個算法因為太簡單了所以帶來的問題也是特別明顯,如果我們最大的qps是100,在0.99秒的時候來了100個請求,然后在1.01秒的時候又來了100個請求,這個是可以通過我們的程序的,但是我們其實在0.03秒之內(nèi)通過了200個請求,這個肯定不符合我們的預(yù)期,因為很有可能這200個請求直接就會將我們機器給打掛。
滑動窗口計數(shù)器
為了解決上面的臨界的問題,我們這里可以使用滑動窗口來解決這個問題:
如上圖所示,我們將1s的普通計數(shù)器,分成了5個200ms,我們統(tǒng)計的當(dāng)前qps都需要統(tǒng)計最近的5個窗口的所有qps,再回到剛才的問題,0.99秒和1.01秒其實都在我們的最近5個窗口之內(nèi),所以這里不會出現(xiàn)剛才的臨界的突刺問題。
其實換個角度想,我們普通的計數(shù)器其實就是窗口數(shù)量為1的滑動窗口計數(shù)器,只要我們分的窗口越多,我們使用計數(shù)器方案的時候統(tǒng)計就會越精確,但是相對來說維護的窗口的成本就會增加,等會我們介紹sentinel的時候會詳細介紹他是怎么實現(xiàn)滑動窗口計數(shù)的。
漏斗算法
解決計數(shù)器中臨界的突刺問題也可以通過漏斗算法來實現(xiàn),如下圖所示:
在漏斗算法中我們需要關(guān)注漏桶和勻速流出,不論流量有多大都會先到漏桶中,然后以均勻的速度流出。如何在代碼中實現(xiàn)這個勻速呢?比如我們想讓勻速為100q/s,那么我們可以得到每流出一個流量需要消耗10ms,類似一個隊列,每隔10ms從隊列頭部取出流量進行放行,而我們的隊列也就是漏桶,當(dāng)流量大于隊列的長度的時候,我們就可以拒絕超出的部分。
漏斗算法同樣的也有一定的缺點:無法應(yīng)對突發(fā)流量(和上面的臨界突刺不一樣,不要混淆)。比如一瞬間來了100個請求,在漏桶算法中只能一個一個的過去,當(dāng)最后一個請求流出的時候時間已經(jīng)過了一秒了,所以漏斗算法比較適合請求到達比較均勻,需要嚴(yán)格控制請求速率的場景。
令牌桶算法
為了解決突發(fā)流量情況,我們可以使用令牌桶算法,如下圖所示:
這個圖上需要關(guān)注三個階段:
- 生產(chǎn)令牌:我們在這里同樣的還是假設(shè)最大qps是100,那么我們從漏斗的每10ms過一個流量轉(zhuǎn)化成每10ms生產(chǎn)一個令牌,直到達到最大令牌。
- 消耗令牌:我們每一個流量都會消耗令牌桶,這里的消耗的規(guī)則可以多變,既可以是簡單的每個流量消耗一個令牌,又可以是根據(jù)不同的流量數(shù)據(jù)包大小或者流量類型來進行不同的消耗規(guī)則,比如查詢的流量消耗1個令牌,寫入的流量消耗2個令牌。
- 判斷是否通過:如果令牌桶足夠那么我們就允許流量通過,如果不足夠可以等待或者直接拒絕,這個就可以采用漏斗那種用隊列來控制。
單機限流
上面我們已經(jīng)介紹了限流的一些基本算法,我們把這些算法應(yīng)用到我們的分布式服務(wù)中又可以分為兩種,一個是單機限流,一個是集群限流。單機限流指的是每臺機器各自做自己的限流,互不影響。我們接下來看看單機限流怎么去實現(xiàn)呢?
guava
guava是谷歌開源的java核心工具庫,里面包括集合,緩存,并發(fā)等好用的工具,當(dāng)然也提供了我們這里所需要的的限流的工具,核心類就是RateLimiter。
- // RateLimiter rateLimiter = RateLimiter.create(100, 500, TimeUnit.MILLISECONDS); 預(yù)熱的rateLimit
- RateLimiter rateLimiter = RateLimiter.create(100); // 簡單的rateLimit
- boolean limitResult = rateLimiter.tryAcquire();
使用方式比較簡單,如上面代碼所示,我們只需要構(gòu)建一個RateLimiter,然后再調(diào)用tryAcquire方法,如果返回為true代表我們此時流量通過,相反則被限流。在guava中RateLimiter也分為兩種,一個是普通的令牌桶算法的實現(xiàn),還有一個是帶有預(yù)熱的RateLimiter,可以讓我們令牌桶的釋放速度逐步增加直到最大,這個帶有預(yù)熱的在sentinel也有,這個可以在一些冷系統(tǒng)中比如數(shù)據(jù)庫連接池沒有完全填滿,還在不斷初始化的場景下使用。
在這里只簡單的介紹一下guava的令牌桶怎么去實現(xiàn)的:
普通的令牌桶創(chuàng)建了一個SmoothBursty的類,這個類也就是我們實現(xiàn)限流的關(guān)鍵,具體怎么做限流的在我們的tryAcquire中:
這里分為四步:
- Step1: 加上一個同步鎖,需要注意一下這里在sentinel中并沒有加鎖這個環(huán)節(jié),在guava中是有這個的,后續(xù)也會將sentinel的一些問題。
- Step2: 判斷是否能申請令牌桶,如果桶內(nèi)沒有足夠的令牌并且等待時間超過我們的timeout,這里我們就不進行申請了。
- Step3: 申請令牌并獲取等待時間,在我們tryAcquire中的timeout參數(shù)就是就是我們的最大等待時間,如果我們只是調(diào)用tryAcquire(),不會出現(xiàn)等待,第二步的時候已經(jīng)快速失敗了。
- Step4: sleep等待的時間。
扣除令牌的方法具體在reserverEarliestAvailable方法中:
這里雖然看起來過程比較多,但是如果我們只是調(diào)用tryAcquire(),就只需要關(guān)注兩個紅框:
- Step1: 根據(jù)當(dāng)前最新時間發(fā)放token,在guava中沒有采用使用其他線程異步發(fā)放token的方式,把token的更新放在了我們每次調(diào)用限流方法中,這個設(shè)計可以值得學(xué)習(xí)一下,很多時候不一定需要異步線程去執(zhí)行也可以達到我們想要的目的,并且也沒有異步線程的復(fù)雜。
- Step2: 扣除令牌,這里我們已經(jīng)在canAcquire中校驗過了,令牌一定能扣除成功。
guava的限流目前就提供了這兩種方式的限流,很多中間件或者業(yè)務(wù)服務(wù)都把guava的限流作為自己的工具,但是guava的方式比較局限,動態(tài)改變限流,以及更多策略的限流都不支持,所以我們接下來介紹一下sentinel。
sentinel
sentinel是阿里巴巴開源的分布式服務(wù)框架的輕量級流量控制框架,承接了阿里巴巴近 10 年的雙十一大促流量的核心場景,他的核心是流量控制但是不局限于流量控制,還支持熔斷降級,監(jiān)控等等。
使用sentinel的限流稍微比guava復(fù)雜很多,下面寫了一個最簡單的代碼:
- String KEY = "test";
- // ============== 初始化規(guī)則 =========
- 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中比較強調(diào)Resource這個概念,我們所保護的或者說所作用于都是基于Resource來說,所以我們首先需要確定我們的Resource的key,這里我們簡單的設(shè)置為test了。
- Step2:然后我們初始化我們這個Resource的一個限流規(guī)則,我們這里選擇的是針對QPS限流并且策略選擇的是默認,這里默認的話就是使用的滑動窗口版的計數(shù)器,然后加載到全局的規(guī)則管理器里面,整個規(guī)則的設(shè)置和guava的差別比較大。
- Step3: 在sentinel第二個比較重要的概念就是Entry,Entry表示一次資源操作,內(nèi)部會保存當(dāng)前invocation信息,在finally的時候需要對entry進行退出。我們執(zhí)行限流判定的時候?qū)嶋H上也就是獲取Entry,SphU.entry也就是我們執(zhí)行我們上面限流規(guī)則的關(guān)鍵,這里和guava不一樣如果被限流了,就會拋出BlockException,我們在進行限流的處理。
雖然sentinel的使用整體比guava復(fù)雜很多,但是算法的可選比guava的限流也多一點。
基于并發(fā)數(shù)(線程數(shù))
我們之前介紹的都是基于QPS的,在sentinel中提供了基于并發(fā)數(shù)的策略,效果類似于信號量隔離,當(dāng)我們需要讓業(yè)務(wù)線程池不被慢調(diào)用耗盡,我們就可以使用這種模式。
通常來說我們同一個服務(wù)提供的http接口都是使用的一個線程池,比如我們使用的tomcat-web服務(wù)器那么我們就會有個tomcat的業(yè)務(wù)線程池,如果在http中有兩個方法A和B,B的速度相對來說比較快,A的速度相對來說比較慢,如果大量的調(diào)用A這個方法,由于A的速度太慢,線程得不到釋放,有可能導(dǎo)致線程池被耗盡,另一個方法B就得不到線程。這個場景我們之前有遇到過直接導(dǎo)致整個服務(wù)所接收的請求全部被拒絕。有的同學(xué)說限制A的QPS不是就可以了嗎,要注意的是QPS是每秒的,如果我們這個A接口的耗時大于1s,那么下一波A來了之后QPS是要重新計算的。
基于這個就提供了基于并發(fā)數(shù)的限流,我們設(shè)置Grade為FLOW_GRADE_THREAD,就可以實現(xiàn)這個限流模式。
基于QPS
基于QPS的限流sentinel也提供了4種策略:
- 默認策略:設(shè)置Behavior為CONTROL_BEHAVIOR_DEFAULT,這個模式是滑動窗口計數(shù)器模式。這種方式適用于對系統(tǒng)處理能力確切已知的情況下,比如通過壓測確定了系統(tǒng)的準(zhǔn)確水位時。
- Warm Up:設(shè)置為Behavior為CONTROL_BEHAVIOR_WARM_UP,類似之前guava中介紹的warmup。預(yù)熱啟動方式。當(dāng)系統(tǒng)長期處于低水位的情況下,當(dāng)流量突然增加時,直接把系統(tǒng)拉升到高水位可能瞬間把系統(tǒng)壓垮。這個模式下QPS的曲線圖如下:
- 勻速排隊:設(shè)置Behavior為CONTROL_BEHAVIOR_RATE_LIMITER,這個模式其實就是漏斗算法,優(yōu)缺點之前也講解過了
- Warm Up + 勻速排隊:設(shè)置Behavior為CONTROL_BEHAVIOR_WARM_UP_RATE_LIMITER,之前warm up到高水位之后使用的是滑動窗口的算法限流,這個模式下繼續(xù)使用勻速排隊的算法。
基于調(diào)用關(guān)系
sentinel提供了更為復(fù)雜的一種限流,可以基于調(diào)用關(guān)系去做更為靈活的限流:
- 根據(jù)調(diào)用方限流:調(diào)用方的限流使用比較復(fù)雜,需要調(diào)用ContextUtil.enter(resourceName, origin),origin就是我們的調(diào)用方標(biāo)識,然后在我們的rule設(shè)置參數(shù)的時候,對limitApp進行設(shè)置就可以進行對調(diào)用方的限流:
- 設(shè)置為default,默認對所有調(diào)用方都限流。
- 設(shè)置為{some_origin_name},代表對特定的調(diào)用者才限流。
- 設(shè)置為other,會對配置的一個referResource參數(shù)代表的調(diào)用者除外的進行限流。
關(guān)聯(lián)流量控制:在sentinel中也支持,兩個有關(guān)聯(lián)的資源可以互相影響流量控制,比如有兩個接口都使用的是同一個資源,一個接口比較重要,另外一個接口不是那么重要,我們可以設(shè)置一個規(guī)則當(dāng)重要的接口大量訪問的時候,就可以對另外一個不重要接口進行限流,防止這個接口突然出現(xiàn)流量影響重要的接口。
sentinel的一些問題
sentinel雖然提供了這么多算法,但是也有一些問題:
- 首先來說sentinel上手比較難,對比guava的兩行代碼來說,使用sentinel需要了解一些名詞,然后針對這些名詞再來使用,雖然sentinel提供了一些注解來幫助我們簡化使用,但是整體來說還是比guava要復(fù)雜。
- sentinel有一定的運維成本,sentinel的使用往往需要搭建sentinel的server后臺,對比guava的開箱即用來說,有一定的運維成本。
- sentinel的限流統(tǒng)計有一定的并發(fā)問題,在sentinel的源碼中是沒有加鎖的地方的,極端情況下如果qps限制的是10,如果有100個同時過限流的邏輯,這個時候都會通過,而guava不會發(fā)生這樣的情況。
這些問題基本上都是和guava的限流來比較的,畢竟sentinel的功能更多,付出的成本相對來說也會更多。
集群限流
之前說的所有限流都是單機限流,但是我們現(xiàn)在都是微服務(wù)集群的架構(gòu)模式,通常一個服務(wù)會有多臺機器,比如有一個訂單服務(wù),這個服務(wù)有10臺機器,那么我們想做整個集群限流到500QPS,我們應(yīng)該怎么去做呢?這個很簡單,直接每臺機器都限流50就好了,50*10就是500,但是在現(xiàn)實環(huán)境中會出現(xiàn)負載不均衡的情況,在微服務(wù)調(diào)用的時候負載均衡的算法多種多樣,比如同機房優(yōu)先,輪訓(xùn),隨機等算法,這些算法都有可能導(dǎo)致我們的負載不是特別的均衡,就會導(dǎo)致我們整個集群的QPS可能有沒有500,甚至在400的時候就被限流了,這個是我們真實場景中所遇到過的。既然單機限流有問題,那么我們應(yīng)該設(shè)計一個更加完善的集群限流的方案
Redis
這個方案不依賴限流的框架,我們整個集群使用同一個redis即可,需要自己封裝一下限流的邏輯,這里我們使用最簡單的計數(shù)器去設(shè)計,我們將我們的系統(tǒng)時間以秒為單位作為key,設(shè)置到redis里面(可以設(shè)置一定的過期時間用于空間清理),利用redis的int原子加法,每來一個請求都進行+1,然后再判斷當(dāng)前值是否超過我們限流的最大值。
redis的方案實現(xiàn)起來整體來說比較簡單,但是強依賴我們的系統(tǒng)時間,如果不同機器之間的系統(tǒng)時間有偏差限流就有可能不準(zhǔn)確。
sentinel
在sentinel中提供了集群的解決方案,這個對比其他的一些限流框架是比較有特色的。在sentinel中提供了兩種模式:
- 獨立模式:限流服務(wù)作為單獨的server進行部署,如下圖所示,所有的應(yīng)用都向單獨部署的token-server進行獲取token,這種模式適用于跨服務(wù)之間的全局限流,比如下面圖中,A和B都會去token-server去拿,這個場景一般來說比較少,更多的還是服務(wù)內(nèi)集群的限流比較多。
- 內(nèi)嵌模式:在內(nèi)嵌模式下,我們會把server部署到我們應(yīng)用實例中,我們也可以通過接口轉(zhuǎn)換我們的server-client身份,當(dāng)然我們可以自己引入一些zk的一些邏輯設(shè)置讓我們的leader去當(dāng)server,機器掛了也可以自動切換。這種比較適合同一個服務(wù)集群之間的限流,靈活性比較好,但是要注意的是大量的token-server的訪問也有可能影響我們自己的機器。
當(dāng)然sentinel也有一些兜底的策略,如果token-server掛了我們可以退化成我們單機限流的模式,不會影響我們正常的服務(wù)。
實戰(zhàn)
我們上面已經(jīng)介紹了很多限流的工具,但是很多同學(xué)對怎么去限流仍然比較迷惑。我們?nèi)绻麑σ粋€場景或者一個資源做限流的話有下面幾個點需要確認一下:
- 什么地方去做限流
- 限多少流
- 怎么去選擇工具
什么地方去做限流
這個問題比較復(fù)雜,很多公司以及很多團隊的做法都不相同,在美團的時候搞了一波SOA,那個時候我記得所有的服務(wù)所有的接口都需要做限流,叫每個團隊去給接口評估一個合理的QPS上限,這樣做理論上來說是對的,我們每個接口都應(yīng)該給與一個上限,防止把整體系統(tǒng)拖垮,但是這樣做的成本是非常之高的,所以大部分公司還是選擇性的去做限流。
首先我們需要確定一些核心的接口,比如電商系統(tǒng)中的下單,支付,如果流量過大那么電商系統(tǒng)中的路徑就有問題,比如像對賬這種邊緣的接口(不影響核心路徑),我們可以不設(shè)置限流。
其次我們不一定只在接口層才做限流,很多時候我們直接在網(wǎng)關(guān)層把限流做了,防止流量進一步滲透到核心系統(tǒng)中。當(dāng)然前端也能做限流,當(dāng)前端捕獲到限流的錯誤碼之后,前端可以提示等待信息,這個其實也算是限流的一部分。其實當(dāng)限流越在下游觸發(fā)我們的資源的浪費就越大,因為在下游限流之前上游已經(jīng)做了很多工作了,如果這時候觸發(fā)限流那么之前的工作就會白費,如果涉及到一些回滾的工作還會加大我們的負擔(dān),所以對于限流來說應(yīng)該是越上層觸發(fā)越好。
限多少流
限多少流這個問題大部分的時候可能就是一個歷史經(jīng)驗值,我們可以通過日常的qps監(jiān)控圖,然后再在這個接觸上加一點冗余的QPS可能這個就是我們的限流了。但是有一個場景需要注意,那就是大促(這里指的是電商系統(tǒng)里面的場景,其他系統(tǒng)類比流量較高的場景)的時候,我們系統(tǒng)的流量就會突增,再也不是我們?nèi)粘5腝PS了,這種情況下,往往需要我們在大促之前給我們系統(tǒng)進行全鏈路壓測,壓測出一個合理的上限,然后限流就基于這個上限去設(shè)置。
怎么去選擇工具
一般來說大一點的互聯(lián)網(wǎng)公司都有自己的統(tǒng)一限流的工具這里直接采用就好。對于其他情況的話,如果沒有集群限流或者熔斷這些需求,我個人覺得選擇RateLimter是一個比較不錯的選擇,應(yīng)該其使用比較簡單,基本沒有學(xué)習(xí)成本,如果有其他的一些需求我個人覺得選擇sentinel,至于hytrix的話我個人不推薦使用,因為這個已經(jīng)不再維護了。
總結(jié)
限流雖然只有兩個字,但是真正要理解限流,做好限流,是一件非常不容易的事,對于我個人而已,這篇文章也只是一些淺薄的見識,如果大家有什么更好的意見可以關(guān)注我的公眾號留言進行討論。