彈力設計之限流設計

保護系統不會在過載的情況下出現問題,我們就需要限流。

我們在一些系統中都可以看到這樣的設計,比如,我們的數據庫訪問的連接池,還有我們的線程池,還有 Nginx 下的用於限制瞬時併發連接數的 limit_conn 模塊,限制每秒平均速率的 limit_req 模塊,還有限制 MQ 的生產速,等等。

限流的策略

限流的目的是通過對併發訪問進行限速,相關的策略一般是,一旦達到限制的速率,那麼就會觸發相應的限流行爲。一般來說,觸發的限流行爲如下。

  • 拒絕服務。把多出來的請求拒絕掉。一般來說,好的限流系統在受到流量暴增時,會統計當前哪個客戶端來的請求最多,直接拒掉這個客戶端,這種行爲可以把一些不正常的或者是帶有惡意的高併發訪問擋在門外。

  • 服務降級。關閉或是把後端服務做降級處理。這樣可以讓服務有足夠的資源來處理更多的請求。降級有很多方式,一種是把一些不重要的服務給停掉,把 CPU、內存或是數據的資源讓給更重要的功能;一種是不再返回全量數據,只返回部分數據。因爲全量數據需要做 SQL Join 操作,部分的數據則不需要,所以可以讓 SQL 執行更快,還有最快的一種是直接返回預設的緩存,以犧牲一致性的方式來獲得更大的性能吞吐。

  • 特權請求。所謂特權請求的意思是,資源不夠了,我只能把有限的資源分給重要的用戶,比如:分給權利更高的 VIP 用戶。在多租戶系統下,限流的時候應該保大客戶的,所以大客戶有特權可以優先處理,而其它的非特權用戶就得讓路了。

  • 延時處理。在這種情況下,一般會有一個隊列來緩衝大量的請求,這個隊列如果滿了,那麼就只能拒絕用戶了,如果這個隊列中的任務超時了,也要返回系統繁忙的錯誤了。使用緩衝隊列只是爲了減緩壓力,一般用於應對短暫的峯刺請求。

  • 彈性伸縮。動用自動化運維的方式對相應的服務做自動化的伸縮。這個需要一個應用性能的監控系統,能夠感知到目前最繁忙的 TOP 5 的服務是哪幾個。然後去伸縮它們,還需要一個自動化的發佈、部署和服務註冊的運維繫統,而且還要快,越快越好。否則,系統會被壓死掉了。當然,如果是數據庫的壓力過大,彈性伸縮應用是沒什麼用的,這個時候還是應該限流。

限流的實現方式

計數器方式

最簡單的限流算法就是維護一個計數器 Counter,當一個請求來時,就做加一操作,當一個請求處理完後就做減一操作。如果這個 Counter 大於某個數了(我們設定的限流閾值),那麼就開始拒絕請求以保護系統的負載了。

這個算法足夠的簡單粗暴。

隊列算法

在這個算法下,請求的速度可以是波動的,而處理的速度則是非常均速的。這個算法其實有點像一個 FIFO 的算法。



在上面這個 FIFO 的隊列上,我們可以擴展出一些別的玩法。

一個是有優先級的隊列,處理時先處理高優先級的隊列,然後再處理低優先級的隊列。 如下圖所示,只有高優先級的隊列被處理完成後,纔會處理低優先級的隊列。



有優先級的隊列可能會導致低優先級隊列長時間得不到處理。爲了避免低優先級的隊列被餓死,一般來說是分配不同比例的處理時間到不同的隊列上,於是我們有了帶權重的隊列。

如下圖所示。有三個隊列的權重分佈是 3:2:1,這意味着我們需要在權重爲 3 的這個隊列上處理 3 個請求後,再去權重爲 2 的隊列上處理 2 個請求,最後再去權重爲 1 的隊列上處理 1 個請求,如此反覆。



隊列流控是以隊列的的方式來處理請求。如果處理過慢,那麼就會導致隊列滿,而開始觸發限流。

但是,這樣的算法需要用隊列長度來控制流量,在配置上比較難操作。如果隊列過長,導致後端服務在隊列沒有滿時就掛掉了。一般來說,這樣的模型不能做 push,而是 pull 方式會好一些。

漏斗算法 Leaky Bucket

下圖是一個漏斗算法的示意圖 。



我們可以看到,就像一個漏斗一樣,進來的水量就好像訪問流量一樣,而出去的水量就像是我們的系統處理請求一樣。當訪問流量過大時這個漏斗中就會積水,如果水太多了就會溢出。

一般來說,這個“漏斗”是用一個隊列來實現的,當請求過多時,隊列就會開始積壓請求,如果隊列滿了,就會開拒絕請求。很多系統都有這樣的設計,比如 TCP。當請求的數量過多時,就會有一個 sync backlog 的隊列來緩衝請求,或是 TCP 的滑動窗口也是用於流控的隊列。



我們可以看到,漏斗算法其實就是在隊列請求中加上一個限流器,來讓 Processor 以一個均勻的速度處理請求。

令牌桶算法 Token Bucket

關於令牌桶算法,主要是有一箇中間人。在一個桶內按照一定的速率放入一些 token,然後,處理程序要處理請求時,需要拿到 token,才能處理;如果拿不到,則不處理。

下面這個圖很清楚地說明了這個算法。



從理論上來說,令牌桶的算法和漏斗算法不一樣的是,漏斗算法中,處理請求是以一個常量和恆定的速度處理的,而令牌桶算法則是在流量小的時候“攢錢”,流量大的時候,可以快速處理。

然而,我們可能會問,Processor 的處理速度因爲有隊列的存在,所以其總是能以最大處理能力來處理請求,這也是我們所希望的方式。因此,令牌桶和漏斗都是受制於 Processor 的最大處理能力。無論令牌桶裏有多少令牌,也無論隊列中還有多少請求。總之,Processor 在大流量來臨時總是按照自己最大的處理能力來處理的。

但是,試想一下,如果我們的 Processor 只是一個非常簡單的任務分配器,比如像 Nginx 這樣的基本沒有什麼業務邏輯的網關,那麼它的處理速度一定很快,不會有什麼瓶頸,而其用來把請求轉發給後端服務,那麼在這種情況下,這兩個算法就有不一樣的情況了。

漏斗算法會以一個穩定的速度轉發,而令牌桶算法平時流量不大時在“攢錢”,流量大時,可以一次發出隊列裏有的請求,而後就受到令牌桶的流控限制。

基於響應時間的動態限流

上面的算法有個不好的地方,就是需要設置一個確定的限流值。這就要求我們每次發佈服務時都做相應的性能測試,找到系統最大的性能值。

然而,在很多時候,我們卻並不知道這個限流值,或是很難給出一個合適的值。其基本會有如下的一些因素:

  • 實際情況下,很多服務會依賴於數據庫。所以,不同的用戶請求,會對不同的數據集進行操作。就算是相同的請求,可能數據集也不一樣,比如,現在很多應用都會有一個時間線 Feed 流,不同的用戶關心的主題人人不一樣,數據也不一樣。而且數據庫的數據是在不斷變化的,可能前兩天性能還行,因爲數據量增加導致性能變差。在這種情況下,我們很難給出一個確定的一成不變的值,因爲關係型數據庫對於同一條 SQL 語句的執行時間其實是不可預測的(NoSQL 的就比 RDBMS 的可預測性要好)。
  • 不同的 API 有不同的性能。我們要在線上爲每一個 API 配置不同的限流值,這點太難配置,也很難管理。
  • 而且,現在的服務都是能自動化伸縮的,不同大小的集羣的性能也不一樣,所以,在自動化伸縮的情況下,我們要動態地調整限流的閾值,這點太難做到了。

基於上述這些原因,我們限流的值是很難被靜態地設置成恆定的一個值。

我們想使用一種動態限流的方式。這種方式,不再設定一個特定的流控值,而是能夠動態地感知系統的壓力來自動化地限流。

這方面設計的典範是 TCP 協議的擁塞控制的算法。TCP 使用 RTT - Round Trip Time 來探測網絡的延時和性能,從而設定相應的“滑動窗口”的大小,以讓發送的速率和網絡的性能相匹配。這個算法是非常精妙的,我們完全可以借鑑在我們的流控技術中。

我們記錄下每次調用後端請求的響應時間,然後在一個時間區間內(比如,過去 10 秒)的請求計算一個響應時間的 P90 或 P99 值,也就是把過去 10 秒內的請求的響應時間排個序,然後看 90% 或 99% 的位置是多少。

這樣,我們就知道有多少請求大於某個響應時間。如果這個 P90 或 P99 超過我們設定的閾值,那麼我們就自動限流。

這個設計中有幾個要點。

  • 需要計算的一定時間內的 P90 或 P99。在有大量請求的情況下,這個非常地耗內存也非常地耗 CPU,因爲需要對大量的數據進行排序。解決方案有兩種,一種是不記錄所有的請求,採樣就好了,另一種是使用一個叫蓄水池的近似算法。
  • 這種動態流控需要像 TCP 那樣,你需要記錄一個當前的 QPS. 如果發現後端的 P90/P99 響應太慢,那麼就可以把這個 QPS 減半,然後像 TCP 一樣走慢啓動的方式,直接到又開始變慢,然後減去 1/4 的 QPS,再慢啓動,然後再減去 1/8 的 QPS……這個過程有點像個阻尼運行的過程,然後整個限流的流量會在一個值上下做小幅振動。這麼做的目的是,如果後端擴容伸縮後性能變好,系統會自動適應後端的最大性能。
  • 這種動態限流的方式實現起來並不容易。大家可以看一下 TCP 的算法。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章