蘇寧Nodejs性能優化實戰
作者|李浩
編輯|覃雲
Nodejs 項目背景介紹
自 2016 年以來,蘇寧大規模的使用了基於 Nodejs 渲染的項目,架構使用 Nginx+Nodejs+PM2 組合,其中 Nodejs 版本從最初的 6.0+ 升級到如今的 8.0+,Nodejs 框架從 Express 過度到 Koa2,而 Nodejs 的性能優化作為其中的核心,蘇寧在其性能提升上,也從 0 到 1,開始摸索。
初步優化—css、js 註冊與合併
ejs 模板相關優化
在蘇寧的 nodejs 項目中,剛開始使用 express 框架,後來隨著 node.js 8.0 LTS 版本的發布,又開始使用 kos 框架。無論是 express 還是 koa 框架,蘇寧在項目開發中都使用 ejs 模板語言(關於 ejs 模板語言這裡就不多做介紹,有興趣的同學可以自行搜索)。
合併 css 和 js 帶來的性能損失
在使用 ejs 模板過程中,蘇寧把公共部分抽出來為 layout.ejs 文件,頁面模板通過 ejs include 方法在 layout.ejs 引入,例如:
這樣做解決了公共部分與頁面業務邏輯的分離,但是也帶來另一個問題 --layout 模板和 page1 模板中靜態資源標籤位置的問題,以下是渲染過後返回給客戶端的 html 頁面:
我們可以看到 page1 的靜態資源引用標籤都在 body 內,複雜的頁面可能還會有 page2、page3、pageN... 這樣會有大量的靜態資源引用標籤出現在 body 內,這顯然不符合我們的預期,我們需要控制靜態資源標籤在頁面中的調用位置,為了解決上面的問題,蘇寧引入了 ejs 模板靜態資源 register 機制,其註冊步驟如下:
a. 使用 getResource() 方法輸出佔位符。
b. 使用 register() 註冊方法註冊資源,例如:register("a.css", "b.js")。
c. 將註冊的靜態資源處理合併後進行字元串 replace 操作。
使用 register 方法後 ejs 模板渲染過後的 html 頁面如下:
「{{}}」和「{{}} 」就是getResource()輸出的佔位符,在伺服器response之前進行字元串replace操作,將佔位符替換成register()方法中註冊的路徑:
這樣就符合了正常的頁面靜態資源引入位置,同時蘇寧在 register() 方法做路徑合併的功能,合併後的地址路徑如下:
這樣瀏覽器中發起的請求就會少很多,減少頁面請求也是性能優化的一個點。
緩存機制
使用 register 機制後我們又發現了一個問題,當客戶端每一個 request 請求發起,nodejs 服務在響應之前都會進行字元串查找替換, 如果頁面夠複雜,最終渲染生成的字元串足夠大,每一次進行字元串查找替換的過程中也造成了一定的性能損耗。正常在實際的使用中我們多次訪問一個路由地址,其頁面引用的靜態資源並不會發生變化。利用這個特性蘇寧引入了靜態資源緩存機制。
當一個新的頁面請求進來之後,在執行 register 方法之前,會根據頁面請求地址的 pathname 進行緩存查找,如果命中緩存,則 getResource() 直接返回緩存內容,相應的 regsiter 方法也不會去執行。否則執行 register() 流程。引入緩存機制後,非第一次訪問代碼邏輯中少了註冊、替換流程,相應的頁面響應時間也縮短了,經過多次測試,頁面響應時間大概縮短 4-8ms。
進階優化—大量路由的優化匹配
在開發蘇寧易購香港站過程當中,由於整站頁面較多、參數開發人員眾多及基於項目安全性的考慮,項目開發中配置了多達 173 條靜態路由以及 11 條動態路由,所以路由匹配效率明顯下降。究其原因,得從 express 源碼入手,express 框架在處理路由配置的方法是,將每一條配置信息轉換成一條正則表達式,在請求進入的時候,逐條進行匹配,直到匹配成功為止。
對於動態路由——路由中含有模糊匹配,則必須使用正則表達式來進行匹配,無法優化。而對於靜態路由,就是固定的字元串的路由表達式,則可以通過鍵值對映射進行匹配,複雜度從 O(n) 變成了 O(1) 大大縮短匹配時間,且不會隨著路由增加而耗時加長。在實際代碼中,由於架構採用了集中路由配置,所以很方便的從配置文件裡面就篩選出了靜態路由,然後存放在一個 Object 中(HashMap)。然後形成一個中間件形式,相當於把多條路由中間件變成了一條路由中間件。
缺陷:和原來的邏輯相比,優化後的方案缺少了路由匹配的順序,所以在開發的時候需要額外注意,不過總體來說影響甚微,因為靜態路由優先匹配,也是應該優先響應的。
高階優化—TPS 的提升
在蘇寧易購大聚惠系統的前後端分離中,初次提交壓力測試結果非常差。懷疑有什麼配置沒有配好,當時的數據是這樣的(16 台 4C4G):
TPS 低的不能忍,而且當時已經配備了 Node.js 8.9.1 這個版本,理論上絕不可能那麼差,在觀察代碼,也沒有發現特別消耗性能的地方。最後我們找到了原因,在 ejs 模版配置的時候沒有開啟模版緩存導致。如果不開啟模版緩存那麼每次請求渲染的時候,都會從磁碟中讀取本地模版文件進行操作,這個磁碟讀取的動作消耗了很多 CPU。平時使用不會察覺,只有當壓力測試的時候才會體現出來。設置好了參數後,我們得到了 10 倍的性能提升。
但我們的優化並沒有止步於此,我們定的目標是 3000TPS,也就是還需要再提高 50% 的渲染性能。這時候我們就必須找到影響 nodejs 性能的點。Nodejs 的特點是單線程非同步編程,意味著非同步操作對性能的影響不大,而同步操作則會嚴重影響性能。
所以第一步,是先檢查代碼中同步操作的邏輯,是否有消耗 CPU 的代碼。經過檢查,排除了代碼部分的嫌疑。只好藉助 chrome 提供的 devtools 來進行分析,啟動 node 參數—inspect,打開 chrome 的 devtools 插件就可以通過 CPU profile 進行分析了。排除掉不可避免的 CPU 消耗,問題浮出水面,原來還有一部分的 CPU 消耗來自於 ejs 模版引擎的內部。
從圖中可以看出來有兩部分消耗,一部分是來自 ejs 模版引擎內部的淺拷貝,一部分是來自查找文件是否存在的系統命令。由於大聚會系統的 ejs 裡面大量使用 include,導致了這部分消耗凸顯了出來。打開 ejs 引擎源碼查看,發現雖然緩存了模版,但每次 include 函數依然會去執行 fs.exsitSync 函數。找到罪魁禍首以後,修改起來其實很簡單,在執行改函數的判斷條件裡面加上先判斷緩存中是否存在。修改後這部分消耗減少了不少。
淺拷貝的問題,通過 js 的原型鏈解決,將傳入的數據對象作為原型對象,通過 Object.create 函數構造一個派生對象,實現原來淺拷貝達到的目的(模版內部修改對象屬性不會影響原始對象,防止污染原始對象傳入到其他模版中去)。派生對象修改屬性,並不會修改原型中對象的屬性,只會在派生對象中新建一個同名的屬性,所以不會污染原始對象。新增屬性也只會在派生對象中。這一步優化減少了很多賦值操作。
經過以上的優化,再進行 CPU profile 分析,發現在 ejs 引擎內部依然有一個函數在消耗 CPU,那就是 getIncludePath。這個函數的目的是在執行 include 的時候講傳入的相對路徑轉成絕對路徑,目的是防止嵌套的 include 中傳入相同的相對路徑字元串,卻是代表不同的模版文件。但是在轉換成絕對路徑這一步裡面會調用文件系統函數造成 CPU 消耗。
解決的思路很快就出來了,就是需要講相對路徑映射成絕對路徑,然後緩存起來,這樣就不必每次去計算絕對路徑了。當然這個緩存不能是全局的,必須每一個 include 創建一個緩存,這樣才能避免相同的相對路徑有歧義的問題。
原始邏輯:
優化的邏輯:
說明:路徑映射 Map 是一個定義在模版函數所在作用域上的,只有該模版函數內部能訪問到,每次執行模版函數的時候都會擁有一個獨立的 Map。
經過上述優化後,本地進行壓測有 50% 的性能提升,故提交測試組對大聚會進行線上壓測。
壓測結果非常好,從 2000tps 到了 3500 多,提升了 75% 之多。單台機器大約 220tps 左右,而原 java 系統單台大概 150tps 左右。
總 結
Nodejs 系統的性能優勢主要體現在非同步 IO 上面,所以性能瓶頸基本都是出在同步操作上面,那麼優化也是主要盡量減少同步操作,適當使用一些 js 的技巧,另外 npm 包的開源特點也給優化工作帶來了便利。
作者介紹
李浩,蘇寧易購高級前端技術經理,主要負責蘇寧前後端分離 nodejs 項目開發。具有多年的 web 前端從業經歷,曾任途牛金服前端負責人。熱愛前端,對新技術有學習熱情,在 nodejs 前後端分離、koa 等框架方面有獨特的見解和豐富的項目實踐。
前端之巔
「前端之巔」是 InfoQ 旗下關注大前端技術的垂直社群。緊跟時代潮流,共享一線技術,歡迎關注。
活動推薦
※七行JSON代碼將你的網站變成移動應用
※你所不知道的移動開發
TAG:前端之巔 |