在軟件架構領域,「限流」與「熔斷」是兩個常常會被同時說起的概念,它們都是系統高可用不可缺乏的重要武器。前端
熔斷是指在一個系統中,若是服務出現了過載現象,爲了防止形成整個系統故障而切斷服務的機制。它是一種十分有用的過載保護機制,通常會有下邊這幾種狀態:nginx
咱們來考慮一個稍微極端一點的場景:若是系統流量不是很穩定,而且流量高峯時都會觸發熔斷,那麼頻繁的流量變化就意味着系統將一直在熔斷的三種狀態中不斷切換。數據庫
這致使的結果是每次從開啓熔斷到關閉熔斷的期間,大量用戶將沒法正常使用系統服務。這種狀況下系統層面的可用性大體是這樣的:編程
另外,資源利用率也很低,上圖波谷的時間段資源都是未充分利用的。後端
因而可知,光有熔斷是遠遠不夠的。因此還須要限流機制。數組
限流是對系統按照預設的規則進行流量限制的一種機制,它確保接收的流量不會超過系統所能承載的上限,以保證系統的可用性。與熔斷不一樣,限流並不切斷服務,所以服務會一直可用。緩存
限制流量要限在哪一個值好呢?安全
系統若是能將接收的流量持續保持在高位,但又不超過系統所能承載的上限,會是更有效率的運做模式,由於這會將前邊提到的波谷填滿。網絡
也就是說限流最好能限在一個系統處理能力的上限附近,因此關於怎麼作限流,第一步就是:經過壓力測試等方式得到系統的能力上限在哪一個水平。架構
除了得到這個限流的值,更主要的一步是具體怎麼去限制這些流量,也就是制定限流策略,好比標準該怎麼定、是隻注重結果仍是也要注重過程的平滑性等。
最後還須要考慮如何處理那些被限制了的流量,這些流量能不能直接丟棄?不能的話該如何處理?
得到系統能力上限,簡單地講就是對系統作一輪壓測。能夠在一個獨立的環境進行,也能夠直接在生產環境的多個節點中選擇一個節點做爲樣原本壓測,固然須要作好與其它節點的隔離。
通常咱們作壓測是爲了得到 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 應用更方便。
那麼應用間的限流究竟是作到客戶端仍是服務端呢?
個人觀點是,從效果上看客戶端模式確定是優於服務端模式的,由於當處於被限流狀態的時候,客戶端模式連創建鏈接的動做都省了。另外一個潛在的好處是,與集中式的服務端模式相比,能夠把少數的服務端程序的壓力分散掉。可是在客戶端作成本也更高,由於它是去中心化的,假如須要多個節點之間的數據共通的話,會是一個很麻煩的事情。
因此,我建議:若是考慮成本就選擇服務端模式,考慮效果就選擇客戶端模式。固然也不是絕對,好比一個服務端的流量大部分都來源於某一個客戶端,那麼就能夠直接在這個客戶端作限流,這也不失爲一個好方案。
數據庫層面的話,通常鏈接字符串中自己就會包含最大鏈接數的概念,就已經能夠起到限流做用了。若是想作更精細的控制就只能作到統一封裝的數據庫訪問層框架中了。
聊完了縱,那麼橫是什麼呢?
無論是多個客戶端,仍是同一個服務端的多個副本,每一個節點的性能必然會存在差別,如何設立合適的閾值?以及如何讓策略的變動儘量快的在集羣中的多個節點生效?提及來很簡單,引入一個性能監控平臺和配置中心。但這些真真要作好並不容易,本文暫時不展開。
張帆(Zachary),7 年電商行業經驗,5 年開發團隊管理經驗,4 年互聯網架構經驗。專一大型系統架構、分佈式系統。
本文系做者投稿文章。歡迎投稿。