蘇寧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.ejs
- <link type="text/css" rel="stylesheet" href="public.css" />
- <script src="public.js"></script>
- ...
- include(page1);
- ...
- //page1.ejs
- <link type="text/css" rel="stylesheet" href="page1.css" />
- <script src="page1.js"></script>
- <h1>hello</h1>
這樣做解決了公共部分與頁面業務邏輯的分離,但是也帶來另一個問題 --layout 模板和 page1 模板中靜態資源標簽位置的問題,以下是渲染過后返回給客戶端的 html 頁面:
- ...
- <link type="text/css" rel="stylesheet" href="public.css" />
- <script src="public.js"></script>
- </header>
- <body>
- <div class="header"></div>
- <link type="text/css" rel="stylesheet" href="page1.css" />
- <script src="page1.js"></script>
- <h1>hello</h1>
- </body>
- ...
我們可以看到 page1 的靜態資源引用標簽都在 body 內,復雜的頁面可能還會有 page2、page3、pageN... 這樣會有大量的靜態資源引用標簽出現在 body 內,這顯然不符合我們的預期,我們需要控制靜態資源標簽在頁面中的調用位置,為了解決上面的問題,蘇寧引入了 ejs 模板靜態資源 register 機制,其注冊步驟如下:
a. 使用 getResource() 方法輸出占位符。
b. 使用 register() 注冊方法注冊資源,例如:register('a.css', 'b.js')。
c. 將注冊的靜態資源處理合并后進行字符串 replace 操作。
使用 register 方法后 ejs 模板渲染過后的 html 頁面如下:
- ...
- {{{CSS_PLACEHOLDER}}}
- </header>
- <body>
- <div class="header"></div>
- <h1>hello</h1>
- </body>
- {{{JS_PLACEHOLDER}}}
- ...
“{{{CSS_PLACEHOLDER}}}”和“{{{JS_PLACEHOLDER}}} ”就是getResource()輸出的占位符,在服務器response之前進行字符串replace操作,將占位符替換成register()方法中注冊的路徑:
- ...
- <link type="text/css" rel="stylesheet" href="public.css" />
- <link type="text/css" rel="stylesheet" href="page1.css" />
- </header>
- <body>
- <div class="header"></div>
- <h1>hello</h1>
- </body>
- <script src="public.js"></script>
- <script src="page1.js"></script>
- ...
這樣就符合了正常的頁面靜態資源引入位置,同時蘇寧在 register() 方法做路徑合并的功能,合并后的地址路徑如下:
- …
- <link type="text/css" rel="stylesheet" href="public.css,page1.css " /></header>
- <body>
- <div class="header"></div>
- <h1>hello</h1>
- </body>
- <script src="public.js, page1.js "></script>
- ...
這樣瀏覽器中發起的請求就會少很多,減少頁面請求也是性能優化的一個點。
緩存機制
使用 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 包的開源特點也給優化工作帶來了便利。

























