1. 程式人生 > >微服務介面限流的設計、思考

微服務介面限流的設計、思考

微服務拆分之後,系統之間的呼叫關係錯綜複雜,平臺的整體複雜熵升高,出錯的概率、debug 問題的難度都高了好幾個數量級。所以,服務治理便成了微服務的一個技術重點。服務治理本身的概念比較大,包括鑑權、限流、降級、熔斷、監控告警等等,本文聚焦於限流,根據筆者的實戰經驗,分享一些對微服務介面限流的思考。

本文試圖講清楚以下問題,如果您對限流也有類似的疑問或對某一話題感興趣,歡迎閱讀本文。

  1. 微服務架構中沒有介面限流,可能會遇到哪些問題?

  2. 針對微服務介面限流,如何選擇合適的限流演算法?

  3. 如何根據場景和效能要求權衡選擇單機還是分散式限流?

  4. 如何根據業務需求靈活的選擇不同的限流熔斷機制?

  5. 如何對介面選擇合適的限流時間粒度和最大限流值?

  6. 如何驗證微服務介面限流功能的有效性和正確性?

  7. 如何打造高度容錯、高 TPS、低延遲的限流框架?

文章的最後,還順帶介紹了筆者開源的限流框架: ratelimiter4j,歡迎大家交流使用。

1微服務介面限流的背景

在應對秒殺、大促、雙 11、618 等高效能壓力的場景時,限流已經成為了標配技術解決方案,為保證系統的平穩執行起到了關鍵性的作用。不管應用場景是哪種,限流無非就是針對超過預期的流量,通過預先設定的限流規則選擇性的對某些請求進行限流“熔斷”。限於篇幅和作者的經驗能力,本文主要講微服務架構下,服務介面的限流。

對於微服務來說,特別是一些中臺微服務,其介面請求可能來自很多系統,例如使用者服務的介面會被很多內部系統呼叫,比如 CRM, 促銷系統等。對於服務於眾多呼叫系統和應對海量介面請求的微服務來說,介面限流除了應對上面提到的一些大促秒殺場景之外,在下面一些場景中也發揮著很大的作用。

作為提供介面服務的微服務系統,我們是無法限制呼叫方如何來使用我們的介面的,我們曾經就遇到過有一些呼叫方多執行緒併發跑 job 來請求我們的介面,也遇到到一些因為呼叫方的程式碼 bug 或者業務上面的突發流量,導致來自這個呼叫方的介面請求數量突增,過度爭用服務執行緒資源,而來自其他呼叫方的介面請求因此來不及響應而排隊等待,微服務整體的請求響應時間變長甚至超時。所以為了防止介面被過度呼叫,需要對每個呼叫方進行細粒度的訪問限流。

除了對呼叫者的訪問頻率進行限制外,我們有的時候還需要對某些介面的訪問頻率做限制。比如一些慢介面,可能因為邏輯複雜,處理時間會比較長,如果對慢介面的訪問頻率不加限制,過多的慢介面請求會一直佔用服務的執行緒資源不釋放,導致無法響應其他介面請求,影響微服務系統整體的吞吐量和介面響應時間,甚至引起大量的介面超時。除了慢介面,有些核心介面,因為一旦異常訪問對業務的影響比較大,除了做呼叫鑑權之外,還需要做非預期異常流量的限流。

綜上所述,我們不僅僅需要針對大促秒殺場景的粗粒度的微服務介面限流功能:比如限制微服務叢集單臺機器每秒請求次數,我們還需要針對不同調用方甚至不同介面進行更加細粒度限流:比如限制 A 呼叫方對某個服務的某個的介面的每秒最大請求次數。

2微服務介面限流的背景

限流中的“流”字該如何解讀呢?要限制的指標到底是什麼?不同的場景對“流”的定義也是不同的,可以是網路流量,頻寬,每秒處理的事務數 (TPS),每秒請求數 (hits per second),併發請求數,甚至還可能是業務上的某個指標,比如使用者在某段時間內允許的最多請求簡訊驗證碼次數。

從保證系統穩定可用的角度考量,對於微服務系統來說,最好的一個限流指標是:併發請求數。通過限制併發處理的請求數目,可以限制任何時刻都不會有過多的請求在消耗資源,比如:我們通過配置 web 容器中 servlet worker 執行緒數目為 200,則任何時刻最多都只有 200 個請求在處理,超過的請求都會被阻塞排隊。

上一節講到,我們為了解決呼叫方對服務資源的過度爭用問題,還需要針對不同調用方甚至不同介面做細粒度限流,所以,我們除了需要對系統整體的併發請求數做限制之外,還需要對每個呼叫方甚至不同介面的併發請求數做限制。但是,要想合理的設定某個呼叫方的最大允許併發數是比較困難的,這個值很難通過監控統計來獲取,太小容易誤殺,太大又起不了作用。所以我們還需要其他限流指標。

對比 TPS 和 hits per second 的兩個指標,我們選擇使用 hits per second 作為限流指標。因為,對 TPS 的限流實際上是無法做的,TPS 表示每秒處理事務數,事務的開始是接收到介面請求,事務的結束是處理完成返回,所以有一定的時間跨度,如果事務開始限流計數器加一,事務結束限流計數器減一,則就等同於併發限流。而如果把事務請求接收作為計數時間點,則就退化為按照 hits per second 來做限流,而如果把事務結束作為計數時間點,則計數器的數值並不能代表系統當下以及接下來的系統訪問壓力。

對 hits per second 的限流是否是一個有效的限流指標呢?答案是肯定的,這個值是可觀察可統計的,所以方便配置限流規則,而且這個值在一定程度上反應系統當前和接下來的效能壓力,對於這一指標的限流確實也可以達到限制對系統資源的使用。

有了流的定義之後,我們接下來看幾種常用的限流演算法:固定時間視窗,滑動時間視窗,令牌桶演算法,漏桶演算法以及他們的改進版本。

3固定、滑動時間視窗限流演算法

基於固定時間視窗的限流演算法是非常簡單的。首先需要選定一個時間起點,之後每次介面請求到來都累加計數器,如果在當前時間視窗內,根據限流規則(比如每秒鐘最大允許 100 次介面請求),累加訪問次數超過限流值,則限流熔斷拒絕介面請求。當進入下一個時間視窗之後,計數器清零重新計數。

這種基於固定時間視窗的限流演算法的缺點在於:限流策略過於粗略,無法應對兩個時間視窗臨界時間內的突發流量。我們舉一個例子:假設我們限流規則為每秒鐘不超過 100 次介面請求,第一個 1s 時間視窗內,100 次介面請求都集中在最後的 10ms 內,在第二個 1s 的時間視窗內,100 次介面請求都集中在最開始的 10ms 內,雖然兩個時間視窗內流量都符合限流要求 (<=100 個請求),但在兩個時間視窗臨界的 20ms 內會集中有 200 次介面請求,如果不做限流,集中在這 20ms 內的 200 次請求就有可能壓垮系統,如圖 -1:

滑動時間視窗演算法是對固定時間視窗演算法的一種改進,流量經過滑動時間視窗演算法整形之後,可以保證任意時間視窗內,都不會超過最大允許的限流值,從流量曲線上來看會更加平滑,可以部分解決上面提到的臨界突發流量問題。對比固定時間視窗限流演算法,滑動時間視窗限流演算法的時間視窗是持續滑動的,並且除了需要一個計數器來記錄時間視窗內介面請求次數之外,還需要記錄在時間視窗內每個介面請求到達的時間點,對記憶體的佔用會比較多。滑動時間視窗的演算法模型如下:

滑動視窗記錄的時間點 list = (t_1, t_2, …t_k),時間視窗大小為 1 秒,起點是 list 中最小的時間點。當 t_m 時刻新的請求到來時,我們通過以下步驟來更新滑動時間視窗並判斷是否限流熔斷:

STEP 1: 檢查介面請求的時間 t_m 是否在當前的時間視窗 [t_start, t_start+1 秒) 內。如果是,則跳轉到 STEP 3,否則跳轉到 STEP 2.

STEP 2: 向後滑動時間視窗,將時間視窗的起點 t_start 更新為 list 中的第二小時間點,並將最小的時間點從 list 中刪除。然後,跳轉到 STEP 1。

STEP 3: 判斷當前時間視窗內的介面請求數是否小於最大允許的介面請求限流值,即判斷: list.size < max_hits_limit,如果小於,則說明沒有超過限流值,允許介面請求,並將此介面請求的訪問時間放入到時間視窗內,否則直接執行限流熔斷。

滑動時間視窗限流演算法可以部分解決固定時間視窗的臨界問題,上面的例子通過滑動時間視窗演算法整形之後,第一個 1 秒的時間視窗的 100 次請求都會通過,第二個時間視窗最開始 10ms 內的 100 個請求會被限流熔斷。

即便滑動時間視窗限流演算法可以保證任意時間視窗內介面請求次數都不會超過最大限流值,但是仍然不能防止在細時間粒度上面訪問過於集中的問題,比如上面舉的例子,第一個 1s 的時間視窗內 100 次請求都集中在最後 10ms 中。也就是說,基於時間視窗的限流演算法,不管是固定時間視窗還是滑動時間視窗,只能在選定的時間粒度上限流,對選定時間粒度內的更加細粒度的訪問頻率不做限制。

為了應對上面的問題,對於時間視窗限流演算法,還有很多改進版本,比如:

多層次限流,我們可以對同一個介面設定多條限流規則,除了 1 秒不超過 100 次之外,我們還可以設定 100ms 不超過 20 次 (這裡需要設定的比 10 次大一些),兩條規則同時限制,流量會更加平滑。除此之外,還有針對滑動時間視窗限流演算法空間複雜度大的改進演算法,限於篇幅,這裡就不展開詳說了。

4令牌桶、漏桶限流演算法

上面我們講了兩種基於時間視窗的限流演算法:固定時間視窗和滑動時間視窗演算法,兩種限流演算法都無法應對細時間粒度的突發流量,對流量的整形效果在細時間粒度上不夠平滑。本節介紹兩種更加平滑的限流演算法:令牌桶演算法和漏桶演算法,在某些場景下,這兩種演算法會優於時間視窗演算法成為首選。實際上令牌桶和漏桶演算法的演算法思想大體類似,可以把漏桶演算法作為令牌桶限流演算法的改進版本,所以我們以介紹令牌桶演算法為主。

我們先來看下最基礎未經過改進的令牌桶演算法:

  1. 介面限制 t 秒內最大訪問次數為 n,則每隔 t/n 秒會放一個 token 到桶中;

  2. 桶中最多可以存放 b 個 token,如果 token 到達時令牌桶已經滿了,那麼這個 token 會被丟棄;

  3. 介面請求會先從令牌桶中取 token,拿到 token 則處理介面請求,拿不到 token 則執行限流。

令牌桶演算法看似比較複雜,每間隔固定時間都要放 token 到桶中,但並不需要專門起一個執行緒來做這件事情。每次在取 token 之前,根據上次放入 token 的時間戳和現在的時間戳,計算出這段時間需要放多少 token 進去,一次性放進去,所以在實現上面也並沒有太大難度。

漏桶演算法稍微不同與令牌桶演算法的一點是:對於取令牌的頻率也有限制,要按照 t/n 固定的速度來取令牌,所以可以看出漏桶演算法對流量的整形效果更加好,流量更加平滑,任何突發流量都會被限流。因為令牌桶大小為 b,所以是可以應對突發流量的。當然,對於令牌桶演算法,還有很多其他改進演算法,比如:

  1. 預熱桶

  2. 一次性放入多個令牌

  3. 支援一次性取多個令牌

對比基於時間視窗的限流演算法,令牌桶和漏桶演算法對流量整形效果比時間視窗演算法要好很多,但是並不是整形效果越好就越合適,對於沒有提前預熱的令牌桶,如果做否決式限流,會導致誤殺很多請求。上述演算法中當 n 比較小時,比如 50,間隔 20ms 才會向桶中放入一個令牌,而介面的訪問在 1s 內可能隨機性很強,這就會出現:儘管從曲線上看對最大訪問頻率的限制很有效,流量在細時間粒度上面都很平滑,但是誤殺了很多本不應該拒絕的介面請求。

所以令牌桶和漏桶演算法比較適合阻塞式限流,比如一些後臺 job 類的限流,超過了最大訪問頻率之後,請求並不會被拒絕,而是會被阻塞到有令牌後再繼續執行。對於像微服務介面這種對響應時間比較敏感的限流場景,會比較適合選擇基於時間視窗的否決式限流演算法,其中滑動時間視窗限流演算法空間複雜度較高,記憶體佔用會比較多,所以對比來看,儘管固定時間視窗演算法處理臨界突發流量的能力較差,但實現簡單,而簡單帶來了好的效能和不容易出錯,所以固定時間視窗演算法也不失是一個好的微服務介面限流演算法。

5限流演算法分散式改造: 分散式限流演算法

相對於單機限流演算法,分散式限流演算法的是指: 演算法可以分散式部署在多臺機器上面,多臺機器協同提供限流功能,可以對同一介面或者服務做限流。分散式限流演算法相較於單機的限流演算法,最大的區別就是介面請求計數器需要中心化儲存,比如我們開源限流專案 ratelimiter4j 就是基於 Redis 中心計數器來實現分散式限流演算法。

分散式限流演算法在引入 Redis 中心計數器這個獨立的系統之後,系統的複雜度一下子高了很多,因為要解決一些分散式系統的共性技術問題:

1. 資料一致性問題  

介面限流過程包含三步操作:

Step 1:“讀”當前的介面訪問計數 n;

Step 2:”判斷”是否限流;

Step 3:“寫”介面計數 n+1, if 介面限流驗證通過

在併發情況下,這 3 步 CAS 操作 (compare and swap) 存在 race condition。在多執行緒環境下,可以通過執行緒的加鎖或者 concurrent 開發包中的 Atomic 原子物件來實現。在分散式情況下,思路也是類似的,可以通過分散式鎖,來保證同一時間段只有一個程序在訪問,但是引入分散式鎖需要引入新的系統和維護鎖的程式碼,代價較大,為了簡單,我們選擇另一種思路:藉助 Redis 單執行緒工作模式 +Lua 指令碼完美的支援了上述操作的原子性。限於篇幅,不展開程式碼討論,詳細可以參看開源專案 ratelimiter4j.

2. 超時問題  

對於 Redis 的各種異常情況,我們處理起來並不是很難,catch 住,封裝為統一的 exception,向上拋,或者吞掉。但是如果 Redis 訪問超時,會嚴重影響介面的響應時間甚至導致介面響應超時,這個副作用是不能接受的。所以在我們訪問 Redis 時需要設定合理的超時時間,一旦超時,判定為限流失效,繼續執行介面邏輯。Redis 訪問超時時間的設定既不能太大也不能太小,太大可能會影響到介面的響應時間,太小可能會導致太多的限流失效。我們可以通過壓測或者線上監控,獲取到 Redis 訪問時間分佈情況,再結合服務介面可以容忍的限流延遲時間,權衡設定一個較合理的超時時間。

3. 效能問題  

分散式限流演算法的效能瓶頸主要在中心計數器 Redis,從我們開源的 ratelimiter4j 壓測資料來看,在沒有做 Redis sharding 的情況下,基於單例項 Redis 的分散式限流演算法的效能要遠遠低於基於記憶體的單機限流演算法,基於我們的壓測環境,單機限流演算法可以達到 200 萬 TPS,而分散式限流演算法只能做到 5 萬 TPS。所以,在應用分散式限流演算法時,一定要考量限流演算法的效能是否滿足應用場景,如果微服務介面的 TPS 已經超過了限流框架本身的 TPS,則限流功能會成為效能瓶頸影響介面本身的效能。

除了 TPS 之外,網路延遲也是一個需要特別考慮的問題,特別是如果中心計數器與限流服務跨機房跨城市部署,之間的網路延遲將會非常大,嚴重影響微服務介面的響應時間。

6如何選擇單機限流還是分散式限流

首先需要說明一下:這裡所說的單機限流和分散式限流與之前提到的單機限流演算法和分散式限流演算法並不是一個概念!為了提高服務的效能和可用性,微服務都會多例項叢集部署,所謂單機限流是指:獨立的對叢集中的每臺例項進行介面限流,比如限制每臺例項介面訪問的頻率為最大 1000 次 / 秒,單機限流一般使用單機限流演算法;所謂的分散式限流是指:提供服務級的限流,限制對微服務叢集的訪問頻率,比如限制 A 呼叫方每分鐘最多請求 1 萬次“使用者服務”,分散式限流既可以使用單機限流演算法也可以使用分散式限流演算法。

單機限流的初衷是防止突發流量壓垮伺服器,所以比較適合針對併發做限制。分散式限流適合做細粒度限流或者訪問配額,不同的呼叫方對不同的介面執行不同的限流規則,所以比較適合針對 hits per second 限流。從保證系統可用性的角度來說,單機限流更具優勢,從防止某呼叫方過度競爭服務資源來說,分散式限流更加適合。

分散式限流與微服務之間常見的部署架構有以下幾種:

1. 在接入層(api-gateway)整合限流功能  

這種整合方式是在微服務架構下,有 api-gateway 的前提下,最合理的架構模式。如果 api-gateway 是單例項部署,使用單機限流演算法即可。如果 api-gateway 是多例項部署,為了做到服務級別的限流就必須使用分散式限流演算法。

2. 限流功能封裝為 RPC 服務  

當微服務接收到介面請求之後,會先通過限流服務暴露的 RPC 介面來查詢介面請求是否超過限流閾值。這種架構模式,需要部署一個限流服務,增加了運維成本。這種部署架構,效能瓶頸會出現在微服務與限流服務之間的 RPC 通訊上,即便單機限流演算法可以做到 200 萬 TPS,但經過 RPC 框架之後,做到 10 萬 TPS 的請求限流就已經不錯了。

3. 限流功能整合在微服務系統內  

這種架構模式不需要再獨立部署服務,減少了運維成本,但限流程式碼會跟業務程式碼有一些耦合,不過,可以將限流功能整合在切面層,儘量跟業務程式碼解耦。如果做服務級的分散式限流,必須使用分散式限流演算法,如果是針對每臺微服務例項進行單機限流,使用單機限流演算法就可以。

7針對不同業務使用不同限流熔斷策略

這裡所講的熔斷策略,就是當介面達到限流上限之後,如何來處理介面請求的問題。前面也有提到過一些限流熔斷策略了,所謂否決式限流就是超過最大允許訪問頻率之後就拒絕請求,比如返回 HTTP status code 429 等,所謂阻塞式限流就是超過最大允許訪問頻率之後就排隊請求。除此之外,還有其他一些限流熔斷策略,比如:記錄日誌,傳送告警,服務降級等等。

同一個系統對於不同的呼叫方也有可能有不同的限流熔斷策略,比如對響應時間敏感的呼叫方,我們可能採用直接拒絕的熔斷策略,對於像後臺 job 這樣對響應時間不敏感的呼叫方,我們可能採用阻塞排隊處理的熔斷策略。

我們再來看下其他熔斷策略的一些應用場景:比如限流功能剛剛上線,為了驗證限流演算法的有效性及其限流規則的合理性,確保不誤殺請求,可以先採用日誌記錄 + 告警的限流熔斷策略,通過分析日誌判定限流功能正常工作後,再進一步升級為其他限流熔斷策略。

不同的熔斷策略對於選擇限流演算法也是有影響的,比如令牌桶和漏桶演算法就比較適合阻塞式限流熔斷場景,如果是否決式的限流熔斷場景就比較適合選擇基於時間視窗的限流演算法。

8如何配置合理的限流規則

限流規則包含三個部分:時間粒度,介面粒度,最大限流值。限流規則設定是否合理直接影響到限流是否合理有效。

對於限流時間粒度的選擇,我們既可以選擇 1 秒鐘不超過 1000 次,也可以選擇 10 毫秒不超過 10 次,還可以選擇 1 分鐘不超過 6 萬次,雖然看起這幾種限流規則都是等價的,但過大的時間粒度會達不到限流的效果,比如限制 1 分鐘不超過 6 萬次,就有可能 6 萬次請求都集中在某一秒內;相反,過小的時間粒度會削足適履導致誤殺很多本不應該限流的請求,因為介面訪問在細時間粒度上隨機性很大。所以,儘管越細的時間粒度限流整形效果越好,流量曲線越平滑,但也並不是越細越合適。

對於訪問量巨大的介面限流,比如秒殺,雙十一,這些場景下流量可能都集中在幾秒內,TPS 會非常大,幾萬甚至幾十萬,需要選擇相對小的限流時間粒度。相反,如果介面 TPS 很小,建議使用大一點的時間粒度,比如限制 1 分鐘內介面的呼叫次數不超過 1000 次,如果換算成:一秒鐘不超過 16 次,這樣的限制就有點不合理,即便一秒內超過 16 次,也並沒有理由就拒絕介面請求,因為對於我們系統的處理能力來說,16 次 / 秒的請求頻率太微不足道了。即便 1000 次請求都集中在 1 分鐘內的某一秒內,也並不會影響到系統的穩定性,所以 1 秒鐘 16 次的限制意義不大。

除了時間粒度之外,還需要根據不同的限流需求選擇不同介面粒度,比如:

1)限制微服務每個例項介面呼叫頻率

2)限制微服務叢集整體的訪問頻率

2)限制某個呼叫方對某個服務的呼叫頻率

3)限制某個呼叫方對某個服務的某個介面的訪問頻率

4)限制某服務的某個介面的訪問頻率

5)限制某服務的某類介面的訪問頻率

對於最大允許訪問頻率的設定,需要結合效能壓測資料、業務預期流量、線上監控資料來綜合設定,最大允許訪問頻率不大於壓測 TPS,不小於業務預期流量,並且參考線上監控資料。

9如何配置合理的限流規則

這裡所說的有效性包含兩個方面:限流演算法的有效性和限流規則的有效性。在大促,秒殺,或者其他異常流量到來之前,我們需要事先通過實驗來驗證限流功能的有效性,用資料來證明限流功能確實能夠攔截非預期的異常流量。否則,就有可能會因為限流演算法的選擇不夠合適或者限流規則設定不合理,導致真正超預期流量到來的時候,限流不能起到保護服務的作用,超出預期的異常流量壓垮系統。

如何測試限流功能正確有效呢?儘管可以通過模擬流量或者線上流量回放等手段來測試,但是最有效的測試方法還是:通過導流的方式將流量集中到一小組機器上做真實場景的測試。對於測試結果,我們至少需要記錄每個請求的如下資訊:對應介面,請求時間點,限流結果 (通過還是熔斷),然後根據記錄的資料繪製成如下圖表:

從圖表中,我們可以一目瞭然的瞭解限流前與限流後的流量情況,可以清晰的看到限流規則和演算法對流量的整形是否合理有效。

除了事先驗證之外,我們還需要時刻監控限流的工作情況,實時瞭解限流功能是否執行正常。一旦發生限流異常,能夠在不重啟服務的情況下,做到熱更新限流配置:包括開啟關閉限流功能,調整限流規則,更換限流演算法等等。

10高容錯高效能開源限流框架:ratelimiter4j

ratelimiter4j 是一個高效能高容錯易整合的限流框架, 從功能的角度來看限流功能的實現並不複雜,而非功能性的需求是系統開發的技術難點:

1)低延遲:不能或者較小的影響介面本身的響應時間

每個微服務介面請求都需要檢查是否超過了限定的訪問頻率,無疑會增加介面的響應時間,而響應時間對於微服務介面來說,是一個非常關注的效能指標,所以讓限流延遲儘可能小,是我們在開發 ratelimiter4j 限流框架時特別考慮的。

2)高度容錯:限流框架的異常不影響微服務的可用性

接入限流本身是為了提供系統的可用性穩定性,不能因為限流本身的異常反過來影響到微服務的可用性,這個副作用是不能接受的。比如分散式限流演算法依賴的 Redis 掛掉了,限流操作無法進行,這個時候業務介面也要能繼續正常服務。

3)高 TPS:限流框架的 TPS 至少要大於微服務本身的介面 TPS

對於大規模服務來說,介面訪問頻率比較高,幾萬甚至幾十萬的 TPS,限流框架支援的 TPS 至少要高於服務本身的 TPS,否則就會因為限流本身的效能問題反過來拖垮服務。

目前 ratelimiter4j 框架將限流規則組織成 trie  tree 資料結構,可以實現快速查詢請求對應的介面限流規則,實驗證明 trie tree 這種資料結構非常適合像 url 這種具有分級目錄且目錄重複度高的介面格式。

針對分散式限流,目前 ratelimiter4j 壓測得到的結果在響應時間可以接受的範圍內最大支援 5 萬 TPS,高併發對 TPS 的影響並不敏感,瓶頸主要在 Redis 中心計數器,接下來會通過改進演算法及其中心計數器支援 sharding 的方式來優化效能。

ratelimiter4j GitHub 地址:https://github.com/wangzheng0822/ratelimiter4j