多個提高Node.js應用吞吐量的小優化技巧介紹

多個提高Node.js應用吞吐量的小優化技巧介紹翻譯自 InfoQ 英文站的 node-micro-optimizations-javascript 一文,從屬於筆者的Web 前端入門與工程實踐。

多個提高Node.js應用吞吐量的小優化技巧介紹

內容提點

  • 儘可能地使用聚合IO操作,以批量寫的方式來最小化系統調用的次數。

  • 需要將發布的開銷考慮進內,清除應用中不同的定時器。

  • CPU分析器能夠給你提高一些有用信息,但是並不能完整地反饋整個流程。

  • 謹慎使用ECMAScript高級語法,特別是你還未使用最新的JavaScript引擎或者類似於Babel這樣的轉換器的時候。

  • 要洞察你的依賴樹的組成並且對你使用的依賴進行適當的性能評測

當我們希望去優化某個包含了IO功能的應用性能時,我們需要對於應用耗費的CPU周期以及那些妨礙到應用並行化執行的因素了如指掌。本文則是分享我在提升Apache Cassandra項目中的DataStax Node.js 驅動時的一些思考與總結出的導致應用吞吐量降級的關鍵因素。

背景

Node.js使用的標準JavaScript引擎V8會將JavaScript代碼編譯為機器碼然後以本地代碼的方式運行。V8引擎使用了如下三個組件來同時保證較低的啟動時間與最佳性能表現:

  • 能夠快速將JavaScript代碼編譯為機器碼的通用編譯器。

  • 能夠自動追蹤應用中代碼執行時間並且決定應該優化哪些代碼模塊的運行時分析器。

  • 能夠自動優化被分析器標註的待優化代碼的優化編譯器;並且如果操作被認為是過優化,該編譯器還能自動地進行逆優化操作。

儘管優化編譯器能夠保證最佳的性能表現,但是它並不會對所有的代碼進行優化,特別是那些不合適的代碼編寫模式。你可以參考來自Google Chrome DevTools團隊的建議來了解哪些代碼模式是V8拒絕優化的,典型的包括:

  • 包含try-catch語句的函數

  • 使用arguments對象對函數參數進行重新賦值

雖然優化編譯器能夠顯著提升代碼允許速度,但是對於典型的IO密集型的應用,大部分的性能優化還是依賴於指令重排以及避免高佔用的調用來提高每秒的操作執行數目;這也會是我們在接下來的章節中需要討論的部分。

測試基準

為了能夠更好地發現那些可以惠及最多用戶的優化技巧,我們需要模擬真實用戶場景,根據常用任務執行的工作量來定義測試基準。首先我們需要測試API入口點的吞吐量與時延;除此之外如果希望獲取更多的信息,你也可以選擇對於內部調用方法進行性能評測。推薦使用process.hrtime()來獲取實時解析與執行時長。雖然可能會對項目開發造成些許不便,但我還是建議儘可能早地在開發周期中引入性能評測。可以選擇先從一些方法調用進行吞吐量測試,然後再慢慢地增加譬如時延分布這些相對複雜的測試。

CPU 分析

目前有多種CPU分析器可供我們使用,其中Node.js本身提供的開箱即用的CPU分析器已經能應付大部分的使用場景。內建的Node.js分析器源於V8內置的分析器,它能夠以固定地頻率對棧信息進行採樣;你可以在運行node命令時使用--prof參數來創建V8標記文件。然後你可以對分析結果進行聚合轉化處理,通過使用--prof-process參數將其轉化為可讀性更好的文本:

$ node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txtn

在編輯器中打開經過處理的記錄文件,你可以看到整個記錄被劃分為了部分,首先我們來看下Summary部分,其格式如下所示:

[Summary]:nn ticks total nonlib namenn 20109 41.2% 45.7% JavaScriptnn 23548 48.3% 53.5% C++nn 805 1.7% 1.8% GCnn 4774 9.8% Shared librariesnn 356 0.7% Unaccountedn

上面的值分別代表了在JavaScript/C++代碼以及垃圾收集器中的採樣頻次,其會隨著分析代碼的不同而變化。然後你可以根據需要分別查看具體的子部分(譬如[JavaScript], [C++], ...)來了解具體的採樣信息。除此之外,分析文件中還包含一個叫做[Bottom up (heavy) profile]的非常有用的部分,它以樹形結構展示了買個函數的調用者,其基本格式如下:

223 32% LazyCompile: *function1 lib/file1.js:223:20nn221 99% LazyCompile: ~function2 lib/file2.js:70:57nn221 100% LazyCompile: *function3 /lib/file3.js:58:74n

上面的百分比代表該層調用者占目標函數所有調用者數目的比重,而函數之前的星號意味著該函數是經過優化處理的,而波浪號代表該函數是未經過優化的。在上面的例子中,function199%的調用是由function2發起的,而function3佔據了function2100%的調用佔比。CPU 分析結果與火焰圖是非常有用的分析棧佔用與CPU耗時的工具。不過需要注意的是,這些分析結果並不意味著全部,大量的非同步IO操作會讓分析變得不那麼容易。

系統調用

Node.js利用Libuv提供的平台無關的介面來實現非阻塞型IO,應用程序中所有的IO操作(sockets, 文件系統, ...)都會被轉化為系統調用。而調度這些系統調用會耗費大量的時間,因此我們需要儘可能地聚合IO操作,以批量寫的方式來最小化系統調用的次數。具體而言,我們應該將Socket或者文件流放入到緩衝中然後一次性處理而不是對每個操作進行單獨處理。你可以使用寫隊列來管理你的所有寫操作,常用的寫隊列的實現邏輯如下:

  • 當我們需要進行寫操作並且在某個處理窗口期內:

    • 將該緩衝區添加到待寫列表中

  • 連接所有的緩衝區並且一次性的寫入到目標管道中。

你可以基於總的緩衝區長度或者第一個元素進入隊列的時間來定義窗口尺寸,不過在定義窗口尺寸時我們需要權衡考慮單個寫操作的時延與整體寫操作的時延,不能厚此薄彼。你也需要同時考慮能夠聚合的寫操作的最大數目以及單個寫請求的開銷。你可能會以千位元組為單位決定一個寫隊列的上限,我們的經驗發現8千位元組左右是個不錯的臨界點;當然根據你應用的具體場景這個值肯定會有變化,你可以參考我們的這個寫隊列的完整實現。總結而言,當我們採用了批量寫之後系統調用的數目大大降低了,最終提升了應用的整體吞吐量。

Node.js 定時器

Node.js中的定時器與window中的定時器具有相同的API,可以很方便地實現簡單的調度操作;在整個生態系統中有很廣泛的應用,因此我們的應用中可能充斥著大量的延時調用。類似於其他基於散列的輪轉調度器,Node.js使用散列表與鏈表來維護定時器實例。不過有別於其他的輪轉調度器,Node.js並沒有維持固定長度的散列表,而是根據觸發時間對定時器建立索引。添加新的定時器實例時,如果Node.js發現已經存在了相同的鍵值(有相同觸發事件的定時器),那麼會以O(1)複雜度完成添加操作。如果還不存在該鍵值,則會創建新的桶然後將定時器添加到該桶中。需要銘記於心的是,我們應該儘可能地重用已存在的定時器存放桶,避免移除整個桶然後再創建一個新的這種耗時的操作。舉例而言,如果你使用滑動延時,那麼應該在使用clearTimeout()移除定時器之前使用setTimeout()創建新的定時器。我們對於心跳包的處理中在移除上一個定時器之前會先確定下以O(1)複雜度調度空閑的定時器。

Ecmascript 語言特性

當我們著眼於整體的性能保障時,我們需要避免使用部分Ecmascript中的高級語言特性,典型的譬如:Function.prototype.bind(), Object.defineProperty() 以及 Object.defineProperties()。我們可以在JavaScript引擎的實現描述或者問題中發現這些特性的性能缺陷所在,譬如Improvement in Promise performance in V8 5.3 以及 Function.prototype.bind performance in V8 5.4。另外你也需要謹慎使用ES2015或者ESNext中的新的語言特性,它們相較於ECMAScript 5中的語法會慢很多。six-speed 項目網站就追蹤了這些語言特性在不同的JavaScript引擎上的性能表現,如果你尚未發現某些特性的性能評測你也可以自己進行一些測試。V8 團隊也一直致力於提高新的語言特性的性能表現,最終使其與底層實現保持一致。我們可以在性能規劃中隨時了解他們對於ES2015性能優化的工作進展,這裡他們會收集使用者對於提升點的建議並且發布新的設計文檔來闡述他們的解決方案。你也可以在這個博客隨時了解V8的實現進展,不過考慮到V8的提升可能需要較長的時間才能合併入LTS版本的Node.js: 根據LTS規劃只有在Node.js大版本迭代時才會合併進最新的V8版本。你可能要等待6-12月才能發現新的V8引擎被合併進入Node.js的運行環境中,而目前Node.js的新的發布版本只會包含V8引擎中的部分修復。

依賴

Node.js 運行時為我們提供了完整的IO操作庫,但是ECMAScript語法標準則僅提供了寥寥無幾的內建數據類型,很多時候我們不得不依賴第三方的庫來進行某些基本任務。沒有人能保證這些第三方的庫可以準確高效地工作,即使那些流行的明星模塊也可能存在問題。Node.js的生態系統是如此的繁榮茂盛,可能很多依賴模塊中只包含幾個你自己很方便就能實現的方法。我們需要在重複造輪子的代價與依賴帶來的性能不可控之間做一個權衡。我們團隊會儘可能地避免引入新的依賴,並且對所有的依賴持保守態度。不過對於bluebird這樣本身發布了可信賴的性能評測的庫我們是很歡迎的。我們的項目中使用async來處理非同步操作,在代碼庫中廣泛地使用了async.series(), async.waterfall() 以及 async.whilst()。確實我們很難說這樣連接了多個層次的非同步處理庫就是性能受損的罪魁禍首,幸好有很多其他開發者定位了其中存在的問題。我們也可以選擇類似於neo-async這樣的替代庫,它的運行效率明顯提高並且也有公開的性能評測結果。

總結

本文中提及的優化技巧有的屬於常識,有的則是涉及到Node.js生態系統以及JavaScript核心引擎的實現細節與工作原理。在我們開發的客戶端驅動中,通過引入這些優化手段我們達成了兩倍的吞吐量的提升。考慮到我們的Node.js應用以單線程方式運行,我們應用佔據CPU的時間片與指令的排布順序會大大影響整體的吞吐量與高平行的實現程度。

關於作者

Jorge Bay是Apache Cassandra項目中Node.js以及C#客戶端驅動的核心工程師,同時還是DataStax的DSE。他樂於解決問題與提供服務端解決方案,Jorge擁有超過15年的專業軟體開發經驗,他為Apache Cassandra實現的Node.js客戶端驅動同樣也是DataStax官方驅動的基礎


推薦閱讀:

【譯】針對 Airbnb 清單頁的 React 性能優化
記一次冷雨寒風中的UWA優化日(內附技術PPT)
Unity優化技巧(上)
Android應用內存泄露分析、改善經驗總結
關於Unity渲染優化,你可能遇到這些問題

TAG:Nodejs | V8 | 性能优化 |