一次讓效能提升 1500% 的經歷
一般來說,我這裡的基礎設施得益於 CDN、OSS 和閘道器層,到了這裡都不用扛太多的流量,只要寫的不怎麼車禍,後端都不存在什麼效能瓶頸。
但是這次要上一個灰度功能,需要和業務相結合,在此之前我們的灰度都是以 DNS 為標準進行地區式的灰度,但是現在流量必須要直接打到後端才能判斷,如果前面快取了就沒辦法灰度了——因此沒辦法,我們只能船到橋頭自然直——扛吧。
先來簡單描述一下我們的程式碼邏輯:查詢 1 -> 處理邏輯 -> 查詢 2 -> 查詢 3 -> 處理邏輯 -> 灰度判斷 -> 返回。
最初保持了最原始的程式碼,沒有設定任何快取,這在過去是滿足需求的,因為快取是由閘道器層的反向代理幫我們在 Header 里加上的,包括了 s-max-age 和 max-age。
然後開壓:wrk -c20 -t20 -d1m
:
20 threads and 20 connections Thread StatsAvgStdevMax+/- Stdev Latency195.58ms73.94ms 592.81ms68.39% Req/Sec5.592.8320.0063.45% 6155 requests in 1.00m, 3.94MB read Requests/sec:102.40 Transfer/sec:67.19KB
最初我們以為是壓得連線數太少,增大了之後 QPS 不增反降,跟做閘道器的大佬交流了一下之後知道了以下兩點:
而我們的語句中 IO 的部分只有網路 IO 的資料庫操作,於是我們為資料庫操作加上了一層 LRU 快取,大致是這樣的:
let result if (cache(query) is not empty) { result = cache(query) } else { result = await db(query) } cache.set(result)
當然,這只是一段虛擬碼。
然後我們再壓了一次:
2 threads and 400 connections Thread StatsAvgStdevMax+/- Stdev Latency337.68ms485.34ms2.00s84.04% Req/Sec79.4431.82323.0081.98% 9237 requests in 1.00m, 5.92MB read Socket errors: connect 0, read 0, write 0, timeout 4952 Requests/sec:153.71 Transfer/sec:100.92KB
效能提升很有限(實際上上面這段是有問題的,讀者朋友先思考一下,之後再說),這個時候壓測的同學忍不住了,開始和我一起 Review 程式碼,我們又開始懷疑了連線池的大小。
我們將連線池的最大連線數擴大了十倍,接著壓,提升依舊不明顯。
然後我們進行了本地除錯,開始打日誌之後發現 SQL 語句在有快取的情況下依舊進行了重複請求,這個時候才回想到因為我們沒有合併請求(也不好合並請求,因為 querystring 不同),所以併發的資料進來如果沒有處理完,其實此時還沒有存到 cache,別的請求會接著查詢,直到第一波查詢完畢,嚴重的影響了 QPS。
所以我們把查詢的 Promise 快取,而不是請求的結果。(實際上之前做過這種操作的專案,但是一時沒想起來)
let resultPromise if (cache(query) is not empty) { resultPromise = cache(query) } else { resultPromise = db(query) } cache.set(resultPromise) result = await resultPromise
壓測:
2 threads and 400 connections Thread StatsAvgStdevMax+/- Stdev Latency206.88ms305.24ms2.39s89.53% Req/Sec1.46k580.862.65k71.35% 28138 requests in 10.10s, 18.00MB read Requests/sec:2785.71 Transfer/sec:1.78MB
並且監控中 CPU 和記憶體佔用也比以前更低了(不過當然沒有 QPS 明顯拉),系統能在同時處理 1500% 的請求了。
當然,為了避免快取永遠被使用,我們這裡設定了五分鐘的超時快取,並且僅當沒有快取時才執行cache.set
(因為 set 會重新整理快取時間計數器),也就是說,每五分鐘才請求一次,這樣的頻率相信也是我們的使用者(業務方)能接受的範圍內。