本文主要從設計與原理方面分享最佳化過程中的思考,不涉及具體的程式碼實現。在分析過程中我會寫一些當時思考的問題,在看後續答案時可以自己也先思考一下
老的限流方案
首先講解一下原本閘道器限流功能的實現方案,省略其中的白名單,黑名單,令牌桶演算法實現等一些細節- 限流策略中包含多種策略,比如根據使用者維度限流,ip維度限流,介面維度限流等,每種限流策略各自維護自己的計數。任意一個策略觸發限流拒絕都會拒絕請求
- 每種限流策略都會設定好總允許的權重值,每次訪問透過redis的lua指令碼進行權重的扣除和增加,直到為0,扣除不了的則觸發拒絕
- 簡單講一下限流使用的令牌桶,閘道器會在redis中記錄剩餘令牌數和上次補充令牌的時間點,redis的key是限流維度生成的唯一id,比如使用者維度則是特殊字首+使用者id。假設我們有一個令牌桶,初始令牌數為10,每5秒鐘補充5個令牌,桶的最大容量也是10個令牌。那麼令牌使用情況如下圖:
* 老限流的流程圖:
限流存在的問題
總體來看,這個限流的方案還是比較簡單的,當流量不大的時候也可以正常執行。但是流量大的時候存在一些問題,大家可以先想一下有哪些問題?
- 一次請求由於需要從多個維度進行限流,所以會序列的多次訪問redis,每次io都需要消耗時間
- lua指令碼在redis中執行,redis的效能成為瓶頸,一方面是cpu高,另一方面redis的命令也是序列的,後面的命令需要等待前面的命令
- 即使權重已經扣除到0了,當請求過來時,仍然需要訪問redis去判斷是否可以透過
分析和最佳化思路
針對這3個問題,我們分析一下,都有哪些處理方案,以及這些方案的優缺點
第一個問題:多次序列IO
-
多執行緒並行訪問
- 並行可以同時進行網路請求,雖然總次數沒有少,但是總時間可以減少到最慢的那次請求的耗時。比如,每次1秒,序列訪問3次,總耗時3秒,改為並行則只需要1秒。
- 序列改並行一般來說改動起來會小一些,比較好實現。可以節省時間。但是本質上沒有減少資源消耗
- 同時由於本次請求的是redis,redis仍然需要進行序列執行
- 所以這個方案並不好
-
合併請求批次訪問
- 在這個場景中,時間消耗分為,io耗時+redis執行耗時。
- 批次訪問可以減少io耗時的部分,但是不會減少redis執行耗時。
- 總體來看這個方案可以減少總耗時,但是由於需要聚合請求,分發響應,因此程式碼改動會稍微大一些。
- 目前看是一個可行的方案
-
減少redis的訪問
- 首先我們要分析redis的作用是什麼,為什麼我們需要訪問redis?
- 由於閘道器是無狀態的,限流是全域性維度的,所以針對這個場景我們利用redis作為一箇中心化的資料儲存,也就是每個策略的權重資料。其次由於請求是併發的,所以我們利用了redis來保證權重加減的原子性。在這2個原因中的3個關鍵點是:資料儲存,中心化和原子性。
- 資料儲存通常來講我們遵循(記憶體>redis>資料庫)。那我們是否可以用記憶體代替redis實現資料儲存?
- 如果閘道器只有一臺機器,那麼是可行的,但是現在由於多臺機器,所以中心化這個關鍵點限制了我們把資料從redis改為記憶體。
- 那我們改進一下這個思路,能不能把部分資料改為記憶體?
- 資料是權重資料,本質上就是一個計數器。那我們將計數器的一部分值放到記憶體中,扣除完了之後再來redis扣除一次(這個思路其實就是java中的TLAB機制的啟發)。
- 資料儲存和中心化都沒問題了,那原子性是否可以保證?
- 不同機器從redis中扣除這部分邏輯與之前是一致的,所以原子性沒問題,儲存在記憶體中的值,只會被單機操作一定可以保證原子性(最差就是加鎖)。
- 那麼最終分析之後這個方案也是可行的
- 首先我們要分析redis的作用是什麼,為什麼我們需要訪問redis?
第二個問題: redis執行lua指令碼成為瓶頸
- 最佳化lua指令碼
- lua指令碼實現的是令牌桶演算法,在當前場景下沒有發現最佳化空間,因此不考慮
- redis分片處理
- 不同的key路由到不同的redis中,可以解決瓶頸問題,方案可行,但是本質上沒有減少資源消耗
- 減少redis訪問
- 同上一個問題的解決方案,方案可行
第三個問題:權重扣除為0,仍然訪問redis
- 還是先分析原因,為什麼權重為0時我們需要訪問redis?
- 因為記憶體中並不知道權重是否已經為0,這個資料只在redis中存在。
- 那我們有什麼辦法可以提前知道redis中權重是否為0?
- 好像沒有辦法
- 但是如果上一次請求返回了權重為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%
最佳化後的總結
- 最佳化的首要關鍵因素是能發現問題,也就是最佳化點
- 一般最直接的方式就是透過監控發現,所以監控很重要
- 業務的理解程度也很大程度上會影響你是否能發現問題
- 其次是問題的解決方案
- 解決方案跟個人經驗有關係,但是大部分的問題的解決方案都是類似的,透過其他人的問題解決方案來積累經驗我認為是最值的
- 一般來講很難一下子就想到最優方案。想出一種方案後最好再思考一下,有哪些缺點,還有沒有其他方案。比較多個方案選一個相對最優的方案
- 最佳化時常用的一些思路
- 通用方案不一定是最好的,可以利用一些功能的特點去針對性的最佳化
- 最佳化到一定程度後,一般很難做到十全十美,透過放棄一部分東西可以換取一些你更需要的東西,比如空間換時間
如果覺得本文的內容有不足之處,歡迎指出
總覺得自己寫的文章語言都好生硬,不知道怎麼改善