[分佈式設計] 限流設計

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

我們在一些系統中都可以看到這樣的設計,比如,我們的數據庫訪問的連接池,還有我們的線程池,還有 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 方式會好一些。

[分佈式設計] 限流設計

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

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

[分佈式設計] 限流設計

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

令牌桶算法 Token Bucket

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

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

[分佈式設計] 限流設計

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

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

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

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

另外,令牌桶還可能做成第三方的一個服務,這樣可以在分佈式的系統中對全局進行流控,這也是一個很好的方式。

基於響應時間的動態限流

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

當然,性能測試並不是很容易做的。有關性能測試的方法請參看我在 CoolShell 上的這篇文章《性能測試應該怎麼做》。雖然性能測試比較不容易,但是還是應該要做的。

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

實際情況下,很多服務會依賴於數據庫。所以,不同的用戶請求,會對不同的數據集進行操作。就算是相同的請求,可能數據集也不一樣,比如,現在很多應用都會有一個時間線 Feed 流,不同的用戶關心的主題人人不一樣,數據也不一樣。而且數據庫的數據是在不斷變化的,可能前兩天性能還行,因為數據量增加導致性能變差。在這種情況下,我們很難給出一個確定的一成不變的值,因為關係型數據庫對於同一條 SQL 語句的執行時間其實是不可預測的(NoSQL 的就比 RDBMS 的可預測性要好)。

不同的 API 有不同的性能。我們要在線上為每一個 API 配置不同的限流值,這點太難配置,也很難管理。

而且,現在的服務都是能自動化伸縮的,不同大小的集群的性能也不一樣,所以,在自動化伸縮的情況下,我們要動態地調整限流的閾值,這點太難做到了。

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

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

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

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

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

這個設計中有幾個要點。

  • 你需要計算的一定時間內的 P90 或 P99。在有大量請求的情況下,這個非常地耗內存也非常地耗 CPU,因為需要對大量的數據進行排序。解決方案有兩種,一種是不記錄所有的請求,採樣就好了,另一種是使用一個叫蓄水池的近似算法。關於這個算法這裡我不就多說了,《編程珠璣》裡講過這個算法,你也可以自行 Google,英文叫 Reservoir Sampling。
  • 這種動態流控需要像 TCP 那樣,你需要記錄一個當前的 QPS. 如果發現後端的 P90/P99 響應太慢,那麼就可以把這個 QPS 減半,然後像 TCP 一樣走慢啟動的方式,直接到又開始變慢,然後減去 1/4 的 QPS,再慢啟動,然後再減去 1/8 的 QPS……這個過程有點像個阻尼運行的過程,然後整個限流的流量會在一個值上下做小幅振動。這麼做的目的是,如果後端擴容伸縮後性能變好,系統會自動適應後端的最大性能。
  • 這種動態限流的方式實現起來並不容易。大家可以看一下 TCP 的算法。TCP 相關的一些算法,我寫在了 CoolShell 上的《TCP 的那些事(下)》這篇文章中。你可以用來做參考來實現。

流的設計要點

為了向用戶承諾 SLA。我們保證我們的系統在某個速度下的響應時間以及可用性。同時,也可以用來阻止在多租戶的情況下,某一用戶把資源耗盡而讓所有的用戶都無法訪問的問題。為了應對突發的流量。節約成本。我們不會為了一個不常見的尖峰來把我們的系統擴容到最大的尺寸。而是在有限的資源下能夠承受比較高的流量。

在設計上,我們還要有以下的考量。

限流應該是在架構的早期考慮。當架構形成後,限流不是很容易加入。限流模塊性能必須好,而且對流量的變化也是非常靈敏的,否則太過遲鈍的限流,系統早因為過載而掛掉了。限流應該有個手動的開關,這樣在應急的時候,可以手動操作。當限流發生時,應該有個監控事件通知。讓我們知道有限流事件發生,這樣,運維人員可以及時跟進。而且還可以自動化觸發擴容或降級,以緩解系統壓力。當限流發生時,對於拒掉的請求,我們應該返回一個特定的限流錯誤碼。這樣,可以和其它錯誤區分開來。而客戶端看到限流,可以調整發送速度,或是走重試機制。限流應該讓後端的服務感知到。限流發生時,我們應該在協議頭中塞進一個標識,比如 HTTP Header 中,放入一個限流的級別,告訴後端服務目前正在限流中。這樣,後端服務可以根據這個標識決定是否做降級。


分享到:


相關文章: