系統不做限流,我看你是對中國人口數量有什麼誤解

在軟件架構領域,“限流”與“熔斷”是兩個經常會被同時提及的概念,它們都是系統高可用不可缺少的重要武器。

熔斷是指在一個系統中,如果服務出現了過載現象,為了防止造成整個系統故障而切斷服務的機制。它是一種十分有用的過載保護機制,一般會有下邊這幾種狀態:

系統不做限流,我看你是對中國人口數量有什麼誤解

我們來考慮一個稍微極端一點的場景:如果系統流量不是很穩定,並且流量高峰時都會觸發熔斷,那麼頻繁的流量變化就意味著系統將一直在熔斷的三種狀態中不斷切換。

這導致的結果是每次從開啟熔斷到關閉熔斷的期間,大量用戶將無法正常使用系統服務。這種情況下系統層面的可用性大致是這樣的:

系統不做限流,我看你是對中國人口數量有什麼誤解

另外,資源利用率也很低,上圖波谷的時間段資源都是未充分利用的。

由此可見,光有熔斷是遠遠不夠的。所以還需要限流機制。

限流

限流是對系統按照預設的規則進行流量限制的一種機制,它確保接收的流量不會超過系統所能承載的上限,以保證系統的可用性。與熔斷不同,限流並不切斷服務,因此服務會一直可用。

怎麼做限流?

限制流量要限在哪個值好呢?

系統如果能將接收的流量持續保持在高位,但又不超過系統所能承載的上限,會是更有效率的運作模式,因為這會將前邊提到的波谷填滿。

系統不做限流,我看你是對中國人口數量有什麼誤解

也就是說限流最好能限在一個系統處理能力的上限附近,所以關於怎麼做限流,第一步就是:通過壓力測試等方式獲得系統的能力上限在哪個水平。

除了獲得這個限流的值,更主要的一步是具體怎麼去限制這些流量,也就是制定限流策略,比如標準該怎麼定、是隻注重結果還是也要注重過程的平滑性等。

最後還需要考慮如何處理那些被限制了的流量,這些流量能不能直接丟棄?不能的話該如何處理?

獲得系統能力上限、處理被限制流量

獲得系統能力上限,簡單地講就是對系統做一輪壓測。可以在一個獨立的環境進行,也可以直接在生產環境的多個節點中選擇一個節點作為樣本來壓測,當然需要做好與其它節點的隔離。

一般我們做壓測是為了獲得 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% 物盡其用。

系統不做限流,我看你是對中國人口數量有什麼誤解

桶模式可以做得更好,因為它多了一個緩衝區(桶本身)。

漏桶

漏桶模式的核心是固定“出口”的速率,不管進來多少量,出去的速率一直是這麼多。如果湧入的量多到桶都裝不下了,那麼就進行流量干預。

系統不做限流,我看你是對中國人口數量有什麼誤解

整個實現過程我們來分解一下:

  1. 控制流出的速率。這個其實可以使用前面提到的兩個窗口思路來實現,如果當前速率小於閾值則直接處理請求,否則不直接處理請求,進入緩衝區,並增加當前水位。
  2. 緩衝的實現可以做一個短暫的休眠或者記錄到一個容器中再做異步的重試。
  3. 最後控制桶中的水位不超過最大水位。這個很簡單,就是一個全局計數器,進行加加減減。

可以發現這其中的本質就是:通過一個緩衝區將高於均值的流量暫存下來補足到低於均值的時期,將不平滑的流量“整形”成平滑的,以此最大化計算處理資源的利用率

系統不做限流,我看你是對中國人口數量有什麼誤解

實現代碼的簡化表示如下:

全局變量 int unitSpeed; //出口當前的流出速率。每隔一個速率計算週期(比如1秒)會觸發定時器將數值清零。
全局變量 int waterLevel; //當前緩衝區的水位線。

if(unitSpeed < 速率閾值) {
unitSpeed++;

//do something...
}
else{
if(waterLevel > 水位閾值){
return; //不繼續處理請求。
}

waterLevel++;

while(unitSpeed >= 速率閾值){
sleep(一小段時間)。
}
unitSpeed++;
waterLevel--;

//do something...
}

這種更優秀的漏桶策略已經可以在流量總量充足的情況下發揮你預期的 100% 處理能力,但這還不是極致。

因為一個程序所在的運行環境中,往往不單單隻有這個程序本身,還會存在一些系統進程甚至是其它的用戶進程。也就是說,程序本身的處理能力是會被幹擾的,是會變化的。所以,你可以預估某一個階段內的平均值、中位數,但無法預估具體某一個時刻的程序處理能力。因此,你必然會使用相對悲觀的標準去作為閾值,防止程序超負荷,這就使得資源的利用率不會達到極致。

系統不做限流,我看你是對中國人口數量有什麼誤解

那麼從資源利用率的角度來說,有沒有更優秀的方案呢?有,這就是令牌桶。

令牌桶

令牌桶模式的核心是固定“進口”速率。先拿到令牌,再處理請求,拿不到令牌就被流量干預。因此,當大量的流量進入時,只要令牌的生成速度大於等於請求被處理的速度,那麼此刻的程序處理能力就是極限。

系統不做限流,我看你是對中國人口數量有什麼誤解

也來分解一下它的實現過程:

  1. 控制令牌生成的速率,並放入桶中。這個其實就是單獨一個線程在不斷地生成令牌。
  2. 控制桶中待領取的令牌水位不超過最大水位。這個和漏桶一樣,就是一個全局計數器,進行加加減減。

大致的代碼簡化表示如下(看上去像固定窗口的反向邏輯):

全局變量 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 年互聯網架構經驗。專注大型系統架構、分佈式系統。


分享到:


相關文章: