在軟體架構領域,“限流”與“熔斷”是兩個經常會被同時提及的概念,它們都是系統高可用不可缺少的重要武器。
熔斷是指在一個系統中,如果服務出現了過載現象,為了防止造成整個系統故障而切斷服務的機制。它是一種十分有用的過載保護機制,一般會有下邊這幾種狀態:
我們來考慮一個稍微極端一點的場景:如果系統流量不是很穩定,並且流量高峰時都會觸發熔斷,那麼頻繁的流量變化就意味著系統將一直在熔斷的三種狀態中不斷切換。
這導致的結果是每次從開啟熔斷到關閉熔斷的期間,大量使用者將無法正常使用系統服務。這種情況下系統層面的可用性大致是這樣的:
另外,資源利用率也很低,上圖波谷的時間段資源都是未充分利用的。
由此可見,光有熔斷是遠遠不夠的。所以還需要限流機制。
限流
限流是對系統按照預設的規則進行流量限制的一種機制,它確保接收的流量不會超過系統所能承載的上限,以保證系統的可用性。與熔斷不同,限流並不切斷服務,因此服務會一直可用。
怎麼做限流?
限制流量要限在哪個值好呢?
系統如果能將接收的流量持續保持在高位,但又不超過系統所能承載的上限,會是更有效率的運作模式,因為這會將前邊提到的波谷填滿。
也就是說限流最好能限在一個系統處理能力的上限附近,所以關於怎麼做限流,第一步就是:通過壓力測試等方式獲得系統的能力上限在哪個水平。
除了獲得這個限流的值,更主要的一步是具體怎麼去限制這些流量,也就是制定限流策略,比如標準該怎麼定、是隻注重結果還是也要注重過程的平滑性等。
最後還需要考慮如何處理那些被限制了的流量,這些流量能不能直接丟棄?不能的話該如何處理?
獲得系統能力上限、處理被限制流量
獲得系統能力上限,簡單地講就是對系統做一輪壓測。可以在一個獨立的環境進行,也可以直接在生產環境的多個節點中選擇一個節點作為樣本來壓測,當然需要做好與其它節點的隔離。
一般我們做壓測是為了獲得 2 個結果,速率和併發數。前者表示在單位時間內能夠處理的請求數量,比如 xxx 次請求/秒,後者表示系統在同一時刻能處理的最大請求數量,比如 xxx 次的併發。從指標上需要獲得最大值、平均值或者中位數,後續限流策略需要設定的具體標準數值就是從這些指標中來的。
此外,從精益求精的角度來說,諸如 CPU、網路頻寬以及記憶體等資源的耗用也可以作為參照因素。
前邊還講到了做限流還要考慮觸發限流後的措施,除了直接把請求流量丟棄之外,還有一種方式:“降級”。本文重點主要是在怎麼具體去做限流,所以關於獲得系統能力上限和這裡的降級就不再繼續展開了。
具體如何限流?
常用的策略就 4 種:固定視窗、滑動視窗、漏桶與令牌桶。
固定視窗
固定視窗就是定義一個固定的統計週期,比如 1 分鐘或者 30 秒、10 秒這樣,然後在每個週期統計當前週期中接收到的請求數量,經過計數器累加後如果達到設定的閾值就觸發流量干預。直到進入下一個週期後,計數器清零,流量接收恢復正常狀態。
這個策略最簡單,寫起程式碼來也沒幾行。
全域性變數 int totalCount = 0; //有一個「固定週期」會觸發的定時器將數值清零。
if(totalCount > 限流閾值) {
return; //不繼續處理請求。
}
totalCount++;
// do something...複製程式碼
固定視窗有一點需要注意,假如請求的進入非常集中,那麼設定的限流閾值等同於你需要承受的最大併發數。所以,如果需要考慮到併發問題,那麼這裡的固定週期設定得要儘可能短,因為,這樣才能使限流閾值的數值相應地減小。甚至,限流閾值就可以直接用併發數來指定。比如,假設固定週期是 3 秒,那麼這裡的閾值就可以設定為平均併發數*3。
不過不管怎麼設定,由於流量的進入往往都不是一個恆定的值,所以固定視窗永遠存在一個缺點:流量進入速度有所波動,那麼就會出現兩種情況,要麼計數器會被提前計滿,導致這個週期內剩下時間段的請求被限制;要麼就是計數器計不滿,也就是限流閾值設定得過大,導致資源無法充分利用。
滑動視窗可以改善這個問題。
滑動視窗
滑動視窗其實就是對固定視窗做了進一步的細分,將原先的粒度切得更細,比如 1 分鐘的固定視窗切分為 60 個 1 秒的滑動視窗。然後統計的時間範圍隨著時間的推移同步後移。
我們可以得出一個結論:如果固定視窗的固定週期已經很小了,那麼使用滑動視窗的意義也就沒有了。舉個例子,現在的固定視窗週期已經是 1 秒了,再切分到毫秒級別反而得不償失,會帶來巨大的效能和資源損耗。
滑動視窗大致的程式碼邏輯是這樣:
全域性陣列 連結串列[] counterList = new 連結串列[切分的滑動視窗數量];
//有一個定時器,在每一次統計時間段起點需要變化的時候就將索引0位置的元素移除,並在末端追加一個新元素。
int sum = counterList.Sum();
if(sum > 限流閾值) {
return; //不繼續處理請求。
}
int 當前索引 = 當前時間的秒數 % 切分的滑動視窗數量;
counterList[當前索引]++;
// do something...複製程式碼
雖然滑動視窗可以改善固定視窗關於週期設定的缺陷,但是本質上它還是預先劃定時間片的方式,屬於一種“預測”,也意味著它無法做到 100% 物盡其用。
桶模式可以做得更好,因為它多了一個緩衝區(桶本身)。
漏桶
漏桶模式的核心是固定“出口”的速率,不管進來多少量,出去的速率一直是這麼多。如果湧入的量多到桶都裝不下了,那麼就進行流量干預。
整個實現過程我們來分解一下:
- 控制流出的速率。這個其實可以使用前面提到的兩個視窗思路來實現,如果當前速率小於閾值則直接處理請求,否則不直接處理請求,進入緩衝區,並增加當前水位。
- 緩衝的實現可以做一個短暫的休眠或者記錄到一個容器中再做非同步的重試。
- 最後控制桶中的水位不超過最大水位。這個很簡單,就是一個全域性計數器,進行加加減減。
可以發現這其中的本質就是:通過一個緩衝區將高於均值的流量暫存下來補足到低於均值的時期,將不平滑的流量“整形”成平滑的,以此最大化計算處理資源的利用率。
實現程式碼的簡化表示如下:
全域性變數 int unitSpeed; //出口當前的流出速率。每隔一個速率計算週期(比如1秒)會觸發定時器將數值清零。
全域性變數 int waterLevel; //當前緩衝區的水位線。
if(unitSpeed < 速率閾值) {
unitSpeed++;
//do something...
}
else{
if(waterLevel > 水位閾值){
return; //不繼續處理請求。
}
waterLevel++;
while(unitSpeed >= 速率閾值){
sleep(一小段時間)。
}
unitSpeed++;
waterLevel--;
//do something...
}複製程式碼
這種更優秀的漏桶策略已經可以在流量總量充足的情況下發揮你預期的 100% 處理能力,但這還不是極致。
因為一個程式所在的執行環境中,往往不單單隻有這個程式本身,還會存在一些系統程式甚至是其它的使用者程式。也就是說,程式本身的處理能力是會被干擾的,是會變化的。所以,你可以預估某一個階段內的平均值、中位數,但無法預估具體某一個時刻的程式處理能力。因此,你必然會使用相對悲觀的標準去作為閾值,防止程式超負荷,這就使得資源的利用率不會達到極致。
那麼從資源利用率的角度來說,有沒有更優秀的方案呢?有,這就是令牌桶。
令牌桶
令牌桶模式的核心是固定“進口”速率。先拿到令牌,再處理請求,拿不到令牌就被流量干預。因此,當大量的流量進入時,只要令牌的生成速度大於等於請求被處理的速度,那麼此刻的程式處理能力就是極限。
也來分解一下它的實現過程:
- 控制令牌生成的速率,並放入桶中。這個其實就是單獨一個執行緒在不斷地生成令牌。
- 控制桶中待領取的令牌水位不超過最大水位。這個和漏桶一樣,就是一個全域性計數器,進行加加減減。
大致的程式碼簡化表示如下(看上去像固定視窗的反向邏輯):
全域性變數 int tokenCount = 令牌數閾值; //可用令牌數。有一個獨立的執行緒用固定的頻率增加這個數值,但不大於「令牌數閾值」。
if(tokenCount == 0){
return; //不繼續處理請求。
}
tokenCount--;
//do something...複製程式碼
但是這樣一來令牌桶的容量大小理論上就是程式需要支撐的最大併發數。的確如此,假設同一時刻進入的流量將令牌取完,但是程式來不及處理,將會導致事故發生。
所以,沒有真正完美的策略,只有合適的策略。因此,根據不同的場景選擇最適合的策略才是更重要的。下面分享一些我選擇這四種策略的經驗。
做限流的最佳實踐
固定視窗
一般來說,如非時間緊迫,不建議選擇這個方案,它太過生硬。但是,為了能快速解決眼前的問題,那麼它可以作為臨時應急的方案。
滑動視窗
這個方案適用於對異常結果高容忍的場景,畢竟相比“兩窗”少了一個緩衝區。但是,它勝在實現簡單。
漏桶
我覺得這個方案最適合作為一個通用方案。雖說資源的利用率並不極致,但是寬進嚴出的思路在保護系統的同時還留有一些餘地,使得它的適用場景更廣。
令牌桶
當你需要儘可能地壓榨程式的效能(此時桶的最大容量必然會大於等於程式的最大併發能力),並且所處的場景流量進入波動不是很大時(不至於一瞬間取完令牌,壓垮後端系統),可以使用這個策略。
分散式系統中帶來的新挑戰
一個成熟的分散式系統大致是這樣的:
每一個上游系統都可以理解為是其下游系統的客戶端。然後我們回想一下前面的內容,可能你發現了,前面聊的限流都沒有提到到底是在客戶端做限流還是服務端做,甚至看起來更傾向是建立在服務端的基礎上做。但是在一個分散式系統中,一個服務端本身就可能存在多個副本,並且還會提供給多個客戶端呼叫,甚至其自身也會作為客戶端角色。那麼,在如此複雜的環境中,該如何下手做限流呢?我的思路是通過“一縱一橫”來考量。
縱
都知道限流是一個保護措施,那麼可以將它想象成一個盾牌。另外,一個請求在系統中的處理過程是鏈式的。那麼,正如古時候軍隊打仗一樣,盾牌兵除了有小部分在老大周圍保護,剩下的全在最前線。因為盾的位置越前,能受益的範圍越大。
分散式系統中最前面的是什麼?接入層。如果你的系統有接入層,比如用 nginx 做的反向代理,那麼可以通過它的 ngx_http_limit_conn_module 以及 ngx_http_limit_req_module 來做限流,這是很成熟的一個解決方案。
如果沒有接入層,那麼只能在應用層以 AOP 的思路去做了。但是,由於應用是分散的,出於成本考慮你需要針對性地去做限流。比如 To C 的應用必然比 To B 的應用更需要做,高頻的快取系統必然比低頻的報表系統更需要做,Web 應用由於存在 Filter 的機制做起來必然比 Service 應用更方便。
那麼應用間的限流到底是做到客戶端還是服務端呢?
我的觀點是,從效果上看客戶端模式肯定是優於服務端模式的,因為當處於被限流狀態的時候,客戶端模式連建立連線的動作都省了。另一個潛在的好處是,與集中式的服務端模式相比,可以把少數的服務端程式的壓力分散掉。但是在客戶端做成本也更高,因為它是去中心化的,假如需要多個節點之間的資料共通的話,會是一個很麻煩的事情。
所以,我建議:如果考慮成本就選擇服務端模式,考慮效果就選擇客戶端模式。當然也不是絕對,比如一個服務端的流量大部分都來源於某一個客戶端,那麼就可以直接在這個客戶端做限流,這也不失為一個好方案。
資料庫層面的話,一般連線字串中本身就會包含最大連線數的概念,就已經可以起到限流作用了。如果想做更精細的控制就只能做到統一封裝的資料庫訪問層框架中了。
聊完了縱,那麼橫是什麼呢?
橫
不管是多個客戶端,還是同一個服務端的多個副本,每個節點的效能必然會存在差異,如何設立合適的閾值?以及如何讓策略的變更儘可能快的在叢集中的多個節點生效?說起來很簡單,引入一個效能監控平臺和配置中心。但這些真真要做好並不容易
歡迎工作一到五年的Java工程師朋友們加入Java架構開發:855801563
本群提供免費的學習指導 架構資料 以及免費的解答
不懂得問題都可以在本群提出來 之後還會有職業生涯規劃以及面試指導