在騰訊新聞搶金達人活動 node 同構直出渲染方案的總結文章中我們整體了解了下同構直出渲染方案在我們項目中的使用。正如我在上篇文章結尾所說的:
應用型技術的難點不是在克服技術問題,而是在於能夠不斷的結合自身的產品體驗,發現其中存在的體驗問題,不斷使用更好的技術方案去優化用戶的體驗,為整個產品發展添磚加瓦。
我們在根據產品的體驗效果選擇了 react 同構直出渲染方案,必然也要保證當前方案的可用性和可靠性。例如我們的服務能同時支撐多少人訪問,當用戶量增大時是否可以依然保證用戶的正常訪問,如何保證 CPU、內存等正常運作,而不被一直占用無法釋放等。因此,這里我們應當下我們項目的幾項數據:
- 項目一天的訪問量是多少,高峰期的訪問量是多少,即並發的用戶量有多少;
- 我們的單機服務最大能支持多少 QPS;
- 高並發時的服務響應時間如何,頁面,接口的失敗率有多少;
- CPU 和內存的使用情況,是否存在 CPU 使用不充分或者內存泄露等問題;
這些數據,都是我們上線前要知道的。壓力測試的重要性就提現出來了,我們在上線前進行充分的測試,能夠讓我們掌握程序和服務器的運行性能,大致申請多少台機器等等。
1. 初次壓力測試
我們這里使用autocannon來對項目進行壓測。注意,我們現在還沒有進行任何的優化措施,就是要先暴露出問題來,然后針對性的進行優化。
每秒鍾 60 的並發,並持續 100 秒:
autocannon -c 60 -d 100
壓測后的數據:
從圖片中可以看到,每秒 60 的並發請求量時,QPS 平均有 266 左右,不過還有 23 個請求超時了,響應時間還可以,99%的請求在 1817ms 毫秒內完成。就目前這幾項數據來看,數據處理能力並不理想,我們還有很大的提升空間。
2. 解決方案
針對上面壓測出來的數據不理想,我們這里需要采取一些措施了。
2.1 內存管理
我們現在寫純前端時,幾乎已經很少關注內存的使用了,畢竟在前端發展的過程中,內存的垃圾回收機制相對來說比較完善,而且前端頁面的生存周期比較短。如果真是要特別注意的話,也是早期在 IE 瀏覽器中,js 與 dom 的交互過程中可能會產生內存的泄露。而且如果真會真要是泄露的話,也只會影響當前終端的用戶,其他的用戶暫時不會受到影響。
而服務端則不同,所有用戶都會訪問當前運行的代碼,只要程序有一丁點的內存泄露,在成千上萬的訪問量下,都會造成內存的堆積,垃圾無法回收,最終造成嚴重的內存泄露,並導致程序崩潰。為了預防內存泄露,我們在內存管理方面,主要三方面的內容:
- V8 引擎的垃圾回收機制;
- 造成內存泄露的原因;
- 如何檢測內存泄露;
Node 將 JavaScript 的主要應用場景擴展到了服務器端,相應要考慮的細節也與瀏覽器端不同, 需要更嚴謹地為每一份資源作出安排。總的來說,內存在 Node 中不能隨心所欲地使用,但也不是完全不擅長。
2.1.1 V8 引擎的垃圾回收機制
在 V8 中,主要將內存分為新生代和老生代兩代。新生代的對象為存活時間比較短的對象,老生代中的對象為存活時間較長的或常駐內存的對象。
默認情況下,新生代的內存最大值在 64 位系統和 32 位系統上分別為 32 MB 和 16 MB。V8 對內存的最大值在 64 位系統和 32 位系統上分別為 1464 MB 和 732 MB。
為什么這樣分兩代呢?是為了最優的 GC 算法。新生代的 GC 算法 Scavenge 速度快,但是不合適大數據量;老生代針使用 Mark-Sweep(標記清除) & Mark-Compact(標記整理) 算法,合適大數據量,但是速度較慢。分別對新舊兩代使用更適合他們的算法來優化 GC 速度。
2.1.2 內存泄露的原因
內存泄露的情況有很多,例如內存當緩存、隊列、重復的事件監聽等。
內存當緩存這種情況中,通常有用一個變量來緩存數據,然后沒有過期時間,一直填充數據,例如下面一個簡單的例子:
let cached = new Map();
server.get('*', (req, res) => {
if (cached.has(req.url)) {
return cached.get(req.url);
}
const html = app.render(req, res);
cached.set(req.url, html);
res.send(html);
});
除此之外,還有閉包
也是其中的一種情況。這種使用內存的不好的地方是,它沒有可用的過期策略,只會讓數據越來越多,最終造成內存泄露。更好的方式使用第三方的緩存機制,例如 redis、memcached 等,這些都有良好的過期和淘汰策略。
同時,也有一些隊列方面的處理,例如有些日志的寫入操作,當海量的數據需要寫入時,就會造成隊列的堆積。這時,我們設置隊列的超時策略和拒絕策略,讓一些操作盡快地釋放掉。
再一個就是事件的重復監聽。例如對同一個事件重復監聽,忘記移除(removeListener),將造成內存泄漏。這種情況很容易在復用對象上添加事件時出現,所以事件重復監聽可能收到如下警告:
Warning: Possible EventEmitter memory leak detected. 11 /question listeners added。Use emitter。setMaxListeners() to increase limit
2.1.3 排查的手段
我們從內存的監控圖中可以看到,在用戶量基本保持不變的情況下,內存是一直在緩慢上漲,說明我們產生了內存泄露,使用的內存並沒有被釋放掉。
這里我們可以通過node-heapdump
等工具來進行判斷,或者稍微簡單點,使用--inspect
命令實現:
node --inspect server.js
然后打開 chrome 鏈接chrome://inspect
來查看內存的使用情況。
通過兩次的內存抓取對比發現,handleRequestTimeout()
方法一直在產生,且每個 handle 方法中有無數個回調,資源無法被釋放。
通過定位查看使用的 axios 代碼是:
if (config.timeout) {
timer = setTimeout(function handleRequestTimeout() {
req.abort();
reject(createError('timeout of ' + config.timeout + 'ms exceeded', config, 'ECONNABORTED', req));
}
}
這里代碼看起來是沒任何問題的,這是在前端處理中一個很典型的超時處理解決方式。
由於 Nodejs 中,io 的鏈接會阻塞 timer 處理,因此這個 setTimeout 並不會按時觸發,也就有了 10s 以上才返回的情況。
貌似問題解決了,巨大的流量和阻塞的 connection 導致請求堆積,服務器處理不過來,CPU 也就下不來了。
通過定位並查看axios 的源碼:
if (config.timeout) {
// Sometime, the response will be very slow, and does not respond, the connect event will be block by event loop system.
// And timer callback will be fired, and abort() will be invoked before connection, then get "socket hang up" and code ECONNRESET.
// At this time, if we have a large number of request, nodejs will hang up some socket on background. and the number will up and up.
// And then these socket which be hang up will devoring CPU little by little.
// ClientRequest.setTimeout will be fired on the specify milliseconds, and can make sure that abort() will be fired after connect.
req.setTimeout(config.timeout, function handleRequestTimeout() {
req.abort();
reject(
createError(
'timeout of ' + config.timeout + 'ms exceeded',
config,
'ECONNABORTED',
req
)
);
});
}
額,我之前使用的版本比較早,跟我本地使用的代碼不一樣,說明是更新過了,再查看這個文件的9 月 16 日的改動歷史:
這里我們就需要把 axios 更新到最新的版本了。而且經過本地大量測試,發現在高負載下 CPU 和內存都在正常范圍內了。
2.2 緩存
緩存真是性能優化的一把好手,服務不夠,緩存來湊
。不過緩存的類型有很多種,我們應當根據項目的實際情況,合理地選擇使用緩存的策略。這里我們使用了 3 層的緩存策略。
在 nginx 中,可以使用 proxy_cache 設置要緩存的路徑和緩存的時間,同時可以啟用proxy_cache_lock
。
當 proxy_cache_lock 被啟用時,當多個客戶端請求一個緩存中不存在的文件(或稱之為一個 MISS),只有這些請求中的第一個被允許發送至服務器。其他請求在第一個請求得到滿意結果之后在緩存中得到文件。如果不啟用 proxy_cache_lock,則所有在緩存中找不到文件的請求都會直接與服務器通信。
不過這個字段的啟用也要非常慎重,當訪問量過大時,會造成請求的堆積,必須等待第一個請求返回完成后,才能處理后面的請求。
proxy_cache_path /data/cached keys_zone=answer:16m levels=1:2 inactive=60m;
server {
location / {
proxy_cache answer;
proxy_cache_valid 1m;
}
}
在業務層面,我們可以啟用 redis 緩存,來緩存整個頁面、頁面的某個部分或者接口等等,當穿透 nginx 緩存時,可以啟用 redis 緩存。使用第三方緩存的特點我們在之前的文章也說了:多個進程之間可以共享,同時減少項目本身對緩存淘汰算法的處理。
當前面的兩層緩存失效時,進入到我們的 node 服務層。二層的緩存機制,能實現不同的緩存策略和緩存粒度,業務需要根據自身場景, 選用適合自己業務的緩存即可。
3. 效果
這時我們項目的性能怎樣了呢?
autocanon -c 1000 -d 100
從圖片里可以看到,99%的請求在182ms內完成,每秒平均處理的請求有15707左右,相比我們最開始只能處理200多個請求,性能足足提升了60倍多。
相關閱讀:
我的博客原文地址:https://www.xiabingbao.com/post/node/node-high-performance.html
來自騰訊的前端開發工程師,與你分享前端快樂:wenzichel