閘道器限流功能效能最佳化

无所事事O_o發表於2024-06-04

本文主要從設計與原理方面分享最佳化過程中的思考,不涉及具體的程式碼實現。在分析過程中我會寫一些當時思考的問題,在看後續答案時可以自己也先思考一下

老的限流方案

首先講解一下原本閘道器限流功能的實現方案,省略其中的白名單,黑名單,令牌桶演算法實現等一些細節
  • 限流策略中包含多種策略,比如根據使用者維度限流,ip維度限流,介面維度限流等,每種限流策略各自維護自己的計數。任意一個策略觸發限流拒絕都會拒絕請求
  • 每種限流策略都會設定好總允許的權重值,每次訪問透過redis的lua指令碼進行權重的扣除和增加,直到為0,扣除不了的則觸發拒絕
  • 簡單講一下限流使用的令牌桶,閘道器會在redis中記錄剩餘令牌數和上次補充令牌的時間點,redis的key是限流維度生成的唯一id,比如使用者維度則是特殊字首+使用者id。假設我們有一個令牌桶,初始令牌數為10,每5秒鐘補充5個令牌,桶的最大容量也是10個令牌。那麼令牌使用情況如下圖:
    * 老限流的流程圖:

限流存在的問題

總體來看,這個限流的方案還是比較簡單的,當流量不大的時候也可以正常執行。
但是流量大的時候存在一些問題,大家可以先想一下有哪些問題?

  1. 一次請求由於需要從多個維度進行限流,所以會序列的多次訪問redis,每次io都需要消耗時間
  2. lua指令碼在redis中執行,redis的效能成為瓶頸,一方面是cpu高,另一方面redis的命令也是序列的,後面的命令需要等待前面的命令
  3. 即使權重已經扣除到0了,當請求過來時,仍然需要訪問redis去判斷是否可以透過

分析和最佳化思路

針對這3個問題,我們分析一下,都有哪些處理方案,以及這些方案的優缺點

第一個問題:多次序列IO
  1. 多執行緒並行訪問

    • 並行可以同時進行網路請求,雖然總次數沒有少,但是總時間可以減少到最慢的那次請求的耗時。比如,每次1秒,序列訪問3次,總耗時3秒,改為並行則只需要1秒。
    • 序列改並行一般來說改動起來會小一些,比較好實現。可以節省時間。但是本質上沒有減少資源消耗
    • 同時由於本次請求的是redis,redis仍然需要進行序列執行
    • 所以這個方案並不好
  2. 合併請求批次訪問

    • 在這個場景中,時間消耗分為,io耗時+redis執行耗時。
    • 批次訪問可以減少io耗時的部分,但是不會減少redis執行耗時。
    • 總體來看這個方案可以減少總耗時,但是由於需要聚合請求,分發響應,因此程式碼改動會稍微大一些。
    • 目前看是一個可行的方案
  3. 減少redis的訪問

    • 首先我們要分析redis的作用是什麼,為什麼我們需要訪問redis?
      • 由於閘道器是無狀態的,限流是全域性維度的,所以針對這個場景我們利用redis作為一箇中心化的資料儲存,也就是每個策略的權重資料。其次由於請求是併發的,所以我們利用了redis來保證權重加減的原子性。在這2個原因中的3個關鍵點是:資料儲存,中心化和原子性。
    • 資料儲存通常來講我們遵循(記憶體>redis>資料庫)。那我們是否可以用記憶體代替redis實現資料儲存?
      • 如果閘道器只有一臺機器,那麼是可行的,但是現在由於多臺機器,所以中心化這個關鍵點限制了我們把資料從redis改為記憶體。
    • 那我們改進一下這個思路,能不能把部分資料改為記憶體?
      • 資料是權重資料,本質上就是一個計數器。那我們將計數器的一部分值放到記憶體中,扣除完了之後再來redis扣除一次(這個思路其實就是java中的TLAB機制的啟發)。
    • 資料儲存和中心化都沒問題了,那原子性是否可以保證?
      • 不同機器從redis中扣除這部分邏輯與之前是一致的,所以原子性沒問題,儲存在記憶體中的值,只會被單機操作一定可以保證原子性(最差就是加鎖)。
    • 那麼最終分析之後這個方案也是可行的
第二個問題: redis執行lua指令碼成為瓶頸
  1. 最佳化lua指令碼
    • lua指令碼實現的是令牌桶演算法,在當前場景下沒有發現最佳化空間,因此不考慮
  2. redis分片處理
    • 不同的key路由到不同的redis中,可以解決瓶頸問題,方案可行,但是本質上沒有減少資源消耗
  3. 減少redis訪問
    • 同上一個問題的解決方案,方案可行
第三個問題:權重扣除為0,仍然訪問redis
  1. 還是先分析原因,為什麼權重為0時我們需要訪問redis?
    • 因為記憶體中並不知道權重是否已經為0,這個資料只在redis中存在。
  2. 那我們有什麼辦法可以提前知道redis中權重是否為0?
    • 好像沒有辦法
  3. 但是如果上一次請求返回了權重為0,那麼這一次請求是否可以判斷出redis中權重是否為0?
    • 答案是部分可以。
    • 利用令牌桶演算法的特點,令牌桶每隔一定時間會增加令牌。如果當前權重已經是0,且在到需要增加令牌的時間之前,權重一定一直為0。
    • 所以當上一次請求返回為0時,同時記錄上一次補充令牌的時間點,那麼就可以推算出在哪個時間點前,權重一直為0,那麼此時就不需要再次訪問redis。
    • 透過這種方式可以極大的減少權重為0時的redis訪問。不管總請求數是多少,在一個令牌補充週期內,每臺機器只會在權重為0時訪問一次redis。
    • 這個問題的解決方案與上2個問題的解決方案同時相容,因此也是可行的

最佳化後的方案

現在我們總結上面的問題解決方案:

  • 可以看到減少redis訪問這個方案可以同時解決第一個和第二個問題,並且真實的減少了資源消耗
  • 第三個問題的解決方案也不衝突,因此最佳化後的限流方案改為如下流程:
新方案的優點
  • 從原本一次請求多次redis訪問變成了,多次請求一次redis操作
  • 大部分扣除權重不經過網路,純記憶體操作
  • redis訪問極大的減少,redis的消耗變小
  • 權重扣完後不會再請求redis,避免惡意流量打垮redis
新方案有什麼缺點?
  • 限流的精確度變差,限流的邊界值被模糊了
    • 比如原本限流1w次,現在變成不到1w次就已經觸發了限流
    • 已經觸發限流後,在補充令牌前,後面的請求可能又可以成功
  • 這個缺點我認為是可以接受的,畢竟我們的限流沒有必要那麼的精確,可以容忍
  • 提一個問題,假設一個令牌週期內限流1w次,新方案的限流最早從第多少次會觸發第一次限流?和哪些因素有關?

最佳化後的效果

redis的cpu使用率從85% -> 5%

機器的cpu使用率從70% -> 50%

最佳化後的總結

  • 最佳化的首要關鍵因素是能發現問題,也就是最佳化點
    • 一般最直接的方式就是透過監控發現,所以監控很重要
    • 業務的理解程度也很大程度上會影響你是否能發現問題
  • 其次是問題的解決方案
    • 解決方案跟個人經驗有關係,但是大部分的問題的解決方案都是類似的,透過其他人的問題解決方案來積累經驗我認為是最值的
    • 一般來講很難一下子就想到最優方案。想出一種方案後最好再思考一下,有哪些缺點,還有沒有其他方案。比較多個方案選一個相對最優的方案
  • 最佳化時常用的一些思路
    • 通用方案不一定是最好的,可以利用一些功能的特點去針對性的最佳化
    • 最佳化到一定程度後,一般很難做到十全十美,透過放棄一部分東西可以換取一些你更需要的東西,比如空間換時間

如果覺得本文的內容有不足之處,歡迎指出
總覺得自己寫的文章語言都好生硬,不知道怎麼改善

相關文章