QPS 的計算是怎么實現的?
面試的時候,面試官看著你做的項目,大概率會問一句,這個項目(API)能支持多大 QPS?
如果你是個已經工作有幾年的程序員,那想必這個問題難不倒你。但如果,我是說如果,面試官問你,你知道 QPS 的計算是怎么實現的不,能詳細說下思路嗎? 閣下又該如何應對呢?這是個很有意思的問題,我們今天就來聊聊這個話題。考慮到有不少讀者還是學生,我們先來看下 QPS 的含義。
QPS 是什么
QPS(Queries Per Second),也就是“每秒查詢數”,它表示服務器每秒能夠處理的請求數量,是一個衡量服務器性能的重要指標。
QPS是什么
比如說服務的用戶查詢 API 支持 100 QPS,就是指這個接口可以做到每秒查 100 次。你務必牢牢記住這個概念,因為工作之后經常會聽別人提起它。很多面試官就特別愛問:"你的這個項目(API)的讀寫性能怎么樣,單個實例能支持多少 QPS?"。這個問題就是個照妖鏡。面試官可以通過這個問題了解你對項目的了解程度。 如果你答不出來,那你在這個項目中很可能就不是核心開發,或者說你這個項目既不核心也不重要,甚至可能你就沒做過這個項目。。。 并且這個 QPS 數值還會有一個合理范圍,有經驗的開發能通過這個值判斷這個服務 API 底層大概是咋樣的。如果你回答的數值過小或過大,那又可以繼續細聊過小和過大的原因。
我說下我目前接觸下來比較合理的 QPS 范圍:帶了數據庫的服務一般寫性能在 5k 以下,讀性能一般在 10k 以下,能到 10k 以上的話,那很可能是在數據庫前面加了層緩存。如果你的服務還帶了個文本算法模型,那使用了 gpu 的情況下 API 一般支持 100~400QPS 左右,如果是個同時支持文本和圖片的模型,也就是所謂的多模態模型,那一般在 100QPS 以內。
qps經驗值
比如候選人上來就說服務單實例 API 讀寫性能都有上萬 QPS, 那我可以大概猜到這應該是個純 cpu+內存的 API 鏈路。但如果候選人還說這里面沒做緩存且有數據庫調用,那我可能會追問這里頭用的是哪款數據庫,底層是什么存儲引擎?如果候選人還說這里面帶了個文本檢測的算法模型,那有點違反經驗,那我會多聊聊細節,說不定這對我來說是個開眼界的機會。
如何計算 QPS ?
現在了解完 QPS 了,假設我們想要獲得某個函數 的 QPS,該怎么做呢?這一般分兩個情況:
- ? 1.實時性要求較低的監控場景。
- ? 2.實時性要求較高的服務治理場景。
計算qps的兩個場景
監控場景
監控服務 QPS 是最常見的場景,它對實時性要求不高。如果我們想要查看服務的 QPS,可以在服務代碼內部接入 Prometheus 的代碼庫,然后在每個需要計算 QPS 的地方,加入類似Counter.Inc()這樣的代碼,意思是函數執行次數加 1。這個過程也就是所謂的打點。
當函數執行到打點函數時,Prometheus 代碼庫內部會計算這個函數的調用次數,將數據寫入到 counter_xx.db 的文件中,再同步到公司的時序數據庫中,然后我們可以通過一些監控面板,比如 grafana調取時序數據庫里的打點數據,在監控面板上通過特殊的表達式,也就是PromQL,對某段時間里的打點進行求導計算速率,這樣就能看到這個函數的調用 QPS 啦。
監控場景中獲取qps
服務治理場景
跟監控面板查看服務 QPS 不同的是,我們有時候需要以更高的實時性獲取 QPS。比如在服務治理這一塊,我們需要在服務內部加入一些中間層,實時計算服務 api 當前的 QPS,當它大于某個閾值時,可以做一些自定義邏輯,比如是直接拒絕掉一些請求,還是將請求排隊等一段時間后再處理等等,也就是所謂的限流。這樣的場景都要求我們實時計算出準確的 QPS,那么接下來就來看下這是怎么實現的?
基本思路
計算某個函數的執行 QPS 說白了就是計算每秒內這個函數被執行了多少次。我們可以參考監控場景的思路,用一個臨時變量 cnt 記錄某個函數的執行次數,每執行一次就給變量+1,然后計算單位時間內的變化速率。公式就像這樣:
QPS = (cnt(t) - cnt(t - Δt)) / Δt
其中 cnt(t) 表示在時間 t 的請求數,Δt表示時間間隔。比如在第 9 秒的時候, cnt 是 80, 到第 10 秒的時候,cnt 是 100,那這一秒內就執行了 (100-80)/(10-9) = 20 次, 也就是 20QPS。
QPS怎么計算
引入 bucket
但這樣會有個問題,到了第 10 秒的時候,有時候我還想回去知道第 5 和第 6 秒的 QPS,光一個變量的話,數據老早被覆蓋了,根本不夠用。于是我們可以將臨時變量 cnt,改成了一個數組,數組里每個元素都用來存放(cnt(t) - cnt(t - Δt)) 的值。數組里的每個元素,都叫 bucket.
bucket數組
調整 bucket 范圍粒度
我們默認每個 bucket 都用來存放 1s 內的數據增量,但這粒度比較粗,我們可以調整為 200ms,這樣我們可以獲得更細粒度的數據。粒度越細,意味著我們計算 QPS 的組件越靈敏,那基于這個 QPS 做的服務治理能力響應就越快。于是,原來用 1 個 bucket 存放 1s 內的增量數量,現在就變成要用 5 個 bucket 了。
bucket粒度細化
引入環形數組
但這樣又引入一個新的問題,隨著時間變長,數組的長度就越長,需要的內存就越多,最終導致進程申請的內存過多,被 oom(Out of Memory) kill 了。為了解決這個問題,我們可以為數組加入最大長度的限制,超過最大長度的部分,就從頭開始寫,覆蓋掉老的數據。這樣的數組,就是所謂的環狀數組。
雖然環狀數組聽起來挺高級了,但說白了就是一個用%取模來確定寫入位置的定長數組,沒有想象的那么高端。比如數組長度是 5,數組 index 從 0 開始,要寫 index=6 的 bucket, 計算 6%5 = 1,那就是寫入 index=1 的位置上。
bucket環形數組
加入滑動窗口
有了環形數組之后,現在我們想要計算 qps,就需要引入滑動窗口的概念。這玩意聽著玄乎,其實就是 start 和 end 兩個變量。通過它來圈定我們要計算 qps 的 bucket 數組范圍。將當前時間跟 bucket 的粒度做取模操作,可以大概知道 end 落在哪個 bucket 上,確定了 end 之后,將 end 的時間戳減個 1s就能大概得到 start 在哪個 bucket 上,有了這兩個值,再將 start 到 end 范圍內的 bucket 取出。對范圍內的 bucket 里的 cnt 求和,得到這段時間內的總和,再除以 Δt,也就是 1s。就可以得到 qps。
引入滑動窗口
到這里 qps 的計算過程就介紹完了。
如何計算平均耗時
既然 qps 可以這么算,那同理,我們也可以計算某個函數的平均耗時,實現也很簡單,上面提到 bucket 有個用來統計調用次數的變量 cnt,現在再加個用來統計延時的變量 Latency 。每次執行完函數,就給 bucket 里的 Latency 變量 加上耗時。再通過滑動窗口獲得對應的 bucket 數組范圍,計算 Latency 的總和,再除以這些 bucket 里的調用次數 cnt 總和。就像下面這樣:
函數的平均耗時 = Latency總和/cnt總和
于是就得到了這個函數的平均耗時。
sentinel-golang
看到這里,你應該對「怎么基于滑動窗口和 bucket 實現一個計算 QPS 和平均 Latency 的組件」有一定思路了。但沒代碼,說再多好像也不夠解渴,對吧?其實,上面的思路,就是阿里開源的sentinel-golang中 QPS 計算組件的實現方式。sentinel-golang 是個著名的服務治理庫,它會基于 QPS 和 Latency 等信息提供一系列限流熔斷策略。如果你想了解具體的代碼實現,可以去看下。鏈接是:
https://github.com/alibaba/sentinel-golang
但茫茫碼海,從何看起呢?下面給出一些關鍵詞,大家可以作為入口去搜索看下。首先可以基于 sliding_window_metric.go 里的 GetQPS 開始看起,它是實時計算 QPS 的入口函數。這里面會看到很多上面提到的內容細節,其中前面提到的滑動窗口,它在 sentinel-golang 中叫 LeapArray。 bucket環形數組,在 sentinel-golang 中叫 AtomicBucketWrapArray。環形數組里存放的 bucket 在代碼里就是 MetricBucket,但需要注意的是 MetricBucket 里的 count 并不是一個數字類型,而是一個 map 類型,它將上面提到的 cnt 和 Latency 等都作為一種 key-value 來存放。以后想要新增字段就不需要改代碼了,提高了代碼擴展性。
最后
- ? QPS 指“每秒查詢數”,是程序員必知必會的內容。建議多了解你負責的項目的 qps,以防面試官一問你三不知。
- ? 這篇文章介紹了代碼實時計算 QPS 的實現細節,同時這也是著名的服務治理庫 sentinel-golang 的實現原理,除了 golang 版本,它還有 java,cpp,js 版本的庫,原理都大同小異,看完這篇文章等于一次性學了 4 個庫,這波不虧。