微服務接口限流的設計與思考

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

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

  1. 微服務架構中沒有接口限流,可能會遇到哪些問題?
  2. 針對微服務接口限流,如何選擇合適的限流算法?
  3. 如何根據場景和性能要求權衡選擇單機還是分佈式限流?
  4. 如何根據業務需求靈活的選擇不同的限流熔斷機制?
  5. 如何對接口選擇合適的限流時間粒度和最大限流值?
  6. 如何驗證微服務接口限流功能的有效性和正確性?
  7. 如何打造高度容錯、高 TPS、低延遲的限流框架?

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

微服務接口限流的背景

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

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

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

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

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

關於接口限流中“流”的定義

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

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

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

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

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

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

固定、滑動時間窗口限流算法

基於固定時間窗口的限流算法是非常簡單的。首先需要選定一個時間起點,之後每次接口請求到來都累加計數器,如果在當前時間窗口內,根據限流規則(比如每秒鐘最大允許 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 次大一些),兩條規則同時限制,流量會更加平滑。除此之外,還有針對滑動時間窗口限流算法空間複雜度大的改進算法,限於篇幅,這裡就不展開詳說了。

令牌桶、漏桶限流算法

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

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

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

限流算法分佈式改造: 分佈式限流算法

相對於單機限流算法,分佈式限流算法的是指: 算法可以分佈式部署在多臺機器上面,多臺機器協同提供限流功能,可以對同一接口或者服務做限流。分佈式限流算法相較於單機的限流算法,最大的區別就是接口請求計數器需要中心化存儲,比如我們開源限流項目 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 之外,網絡延遲也是一個需要特別考慮的問題,特別是如果中心計數器與限流服務跨機房跨城市部署,之間的網絡延遲將會非常大,嚴重影響微服務接口的響應時間。

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

首先需要說明一下:這裡所說的單機限流和分佈式限流與之前提到的單機限流算法和分佈式限流算法並不是一個概念!為了提高服務的性能和可用性,微服務都會多實例集群部署,所謂單機限流是指:獨立的對集群中的每臺實例進行接口限流,比如限制每臺實例接口訪問的頻率為最大 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. 限流功能集成在微服務系統內

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

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

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

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

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

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

如何配置合理的限流規則

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

對於限流時間粒度的選擇,我們既可以選擇 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,不小於業務預期流量,並且參考線上監控數據。

如何評判限流功能是否正確有效

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

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

微服務接口限流的設計與思考


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

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

高容錯高性能開源限流框架: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 的方式來優化性能。



分享到:


相關文章: