提升node.js中使用redis的效能
問題初現
某基於node.js開發的業務系統向外提供了一個dubbo服務,提供向第三方快取查詢、設定多項業務資料並聚合操作結果。在QPS達到800時(兩臺虛擬機器,每臺機器4Core8G4node程序),在監控平臺上出現了非常多的slow rt警告,平均介面響應達到60+ms,請求報警率達到80%+。
為找到造成該服務吞吐量過低的罪魁禍首,業務人員在請求日誌中打點了所有查詢快取的操作,結果顯示每個請求查詢快取耗時在50-100ms之間跳動。查詢了redis-server的監控資料發現,不存在server端的慢查詢,在整個監控區間內服務端處理時間在40us徘徊,因此排除了redis-server的處理能力不足原因;
通過登入內網機器進行不斷測試到對應redis server機器的端到端時延發現內部區域網的頻寬、時延與抖動足夠正常,都不是造成該問題的原因。
因此,錯誤原因定位到了呼叫redis client的業務程式碼以及redis client的I/O效能。
本文中提到的node redis client採用的基於node-redis封裝的二方包,因此問題排查也基於node-redis這個模組。
瓶頸在哪
為了在本地模擬線上環境的併發,可以做一個不是很嚴謹的測試:
async ()=>{ let dd = Date.now() let arr = [] for(let i=0;i<200;i++){ arr.push(new Promise((res,rej)=>{ let hrtime = process.hrtime(); client.send_command('get',['key'], function(e,r) { let diff = process.hrtime(hrtime); let cost = (diff[0] * NS_PER_SEC + diff[1])/1000000; console.log(`final: ${cost} ms`) res(); }); })); } await Promise.all(arr) console.log('ops/sec:',200*1000/(Date.now() - dd),Date.now() - dd); }
會發現每個請求的rt都會比前一個請求來的大
最後一個請求的rt竟然達到了257 ms!雖然在node單程序像示例程式碼那樣併發執行200次get請求是非常少見而且愚蠢的(關於示例程式碼的優化在在下節講述),但是針對這個示例必須找到請求delay增加的原因。
為此繼續分析,redis client採用的是單連線模式,底層採用的非阻塞網路I/O,socket.recv()在node層面是通過監聽socket的data事件完成的,因此先分析redis-client讀效能如何:
上圖每段日誌的含義分別表示:
- data events trigger times: socket data事件觸發的次數
- data event start from prevent event: data事件距離上次觸發的時間間隔
- data events exec time(ms): 本次事件處理函式執行時間
上圖只是截取了最初的請求日誌,發現當第6次觸發data事件時,竟然距離上次觸發事件隔了35ms,在隨後的請求中會復現這種現象,因此這也就導致了在併發200次查詢請求時,每個請求的rt都會隨之增大,並且有些響應之間間隔了30ms。
從表象看造成問題在於redis-server傳送的響應不是一個數據塊,而是多個數據塊導致觸發socket的data事件過多,而且data事件抖動過大導致響應之間存在30ms的突變(data事件是無法同時觸發兩次的,每次data事件處理函式執行完後才能繼續觸發下一個data事件);當然也有可能和socket寫入(即傳送req)有關,如快取請求等。為了繼續探查,監控與socket寫入相關的介面 **_write()**,記錄每次寫入socket的資料時距離上一次寫入的間隔:
可見,在使用redis-client傳送請求時,write方法也不是瓶頸。
採用同樣方法,對socket的push()(該方法觸發socket的data事件)進行監控,發現socket的資料到達間隔抖動非常大:
因此,造成redis-client併發請求下響應rt抖動較大的情況與單連線下響應資料到達本地的時刻有關,具體可能與底層libuv的快取策略有關(筆者並未再往下探查)。
在一個node例項中通過一個單連線與redis server通訊,在高併發下會出現排隊等待響應的情況,並且有可能會出現響應rt雪崩效應(如上文demo所示),因此需要儘可能減少或快取客戶端的請求數量,進行批量傳送。
調優
1. pipeline(涉及到寫模式及時序)
2. script
對於pipeline方式,redis server是預設支援的。通俗點說,pipeline可以合併一系列請求一次傳送,並將這些請求對應的結果一次性拿到。因此這種方式可以有效減少響應次數,從而減少socket觸發data事件的次數,儘可能快的拿到響應體。
需要強調的是,在node中,是通過底層socket的**_writev**實現一次傳送多條redis命令的,_writev又叫做聚合寫,它支援將不同緩衝區的多條資料通過一次系統呼叫寫入目標流,因此效能上比每次寫單個緩衝區的單個數據來的好得多。在node的Writeable物件中,有cork和uncork方法,通過這兩個方法可以在node write stream中快取多條資料,通過_writev一次性發送。
關於 _writev的資料結構
redis在拿到資料後,根據resp協議解析出命令集合快取在佇列中,直到收到exec命令,開始批量執行命令集,並將所有命令執行的結果轉換為陣列返回給redis client。這樣就可以通過一次寫、一次讀實現高效能I/O。
async ()=>{
let dd = Date.now()
let batch = await client.batch();
for(let i=0;i<200;i++){
batch.get('vdWeex_com.koudai.weidian.buyer_1');
}
let rt = await batch.exec();
process.exit();
}
而對於script方法,則是由redis client傳入script命令,在server端執行script邏輯,批量執行命令,並返回結果。同樣是一次寫、一次讀。
收穫
1. node socket預設採用writev 集合寫
2. 無依賴批量請求採用pipeline
3. eval script解決有依賴批量請求
4. redis高效能體現在服務端處理能力,但瓶頸往往出現在客戶端,因此增強客戶端I/O能力與併發並行多客戶端才是高併發解決方案