蘇寧Nodejs性能優(yōu)化實(shí)戰(zhàn)
Nodejs 項(xiàng)目背景介紹
自 2016 年以來(lái),蘇寧大規(guī)模的使用了基于 Nodejs 渲染的項(xiàng)目,架構(gòu)使用 Nginx+Nodejs+PM2 組合,其中 Nodejs 版本從最初的 6.0+ 升級(jí)到如今的 8.0+,Nodejs 框架從 Express 過(guò)度到 Koa2,而 Nodejs 的性能優(yōu)化作為其中的核心,蘇寧在其性能提升上,也從 0 到 1,開(kāi)始摸索。
初步優(yōu)化—css、js 注冊(cè)與合并
ejs 模板相關(guān)優(yōu)化
在蘇寧的 nodejs 項(xiàng)目中,剛開(kāi)始使用 express 框架,后來(lái)隨著 node.js 8.0 LTS 版本的發(fā)布,又開(kāi)始使用 kos 框架。無(wú)論是 express 還是 koa 框架,蘇寧在項(xiàng)目開(kāi)發(fā)中都使用 ejs 模板語(yǔ)言(關(guān)于 ejs 模板語(yǔ)言這里就不多做介紹,有興趣的同學(xué)可以自行搜索)。
合并 css 和 js 帶來(lái)的性能損失
在使用 ejs 模板過(guò)程中,蘇寧把公共部分抽出來(lái)為 layout.ejs 文件,頁(yè)面模板通過(guò) 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>
這樣做解決了公共部分與頁(yè)面業(yè)務(wù)邏輯的分離,但是也帶來(lái)另一個(gè)問(wèn)題 --layout 模板和 page1 模板中靜態(tài)資源標(biāo)簽位置的問(wèn)題,以下是渲染過(guò)后返回給客戶(hù)端的 html 頁(yè)面:
- ...
- <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 的靜態(tài)資源引用標(biāo)簽都在 body 內(nèi),復(fù)雜的頁(yè)面可能還會(huì)有 page2、page3、pageN... 這樣會(huì)有大量的靜態(tài)資源引用標(biāo)簽出現(xiàn)在 body 內(nèi),這顯然不符合我們的預(yù)期,我們需要控制靜態(tài)資源標(biāo)簽在頁(yè)面中的調(diào)用位置,為了解決上面的問(wèn)題,蘇寧引入了 ejs 模板靜態(tài)資源 register 機(jī)制,其注冊(cè)步驟如下:
a. 使用 getResource() 方法輸出占位符。
b. 使用 register() 注冊(cè)方法注冊(cè)資源,例如:register('a.css', 'b.js')。
c. 將注冊(cè)的靜態(tài)資源處理合并后進(jìn)行字符串 replace 操作。
使用 register 方法后 ejs 模板渲染過(guò)后的 html 頁(yè)面如下:
- ...
- {{{CSS_PLACEHOLDER}}}
- </header>
- <body>
- <div class="header"></div>
- <h1>hello</h1>
- </body>
- {{{JS_PLACEHOLDER}}}
- ...
“{{{CSS_PLACEHOLDER}}}”和“{{{JS_PLACEHOLDER}}} ”就是getResource()輸出的占位符,在服務(wù)器response之前進(jìn)行字符串replace操作,將占位符替換成register()方法中注冊(cè)的路徑:
- ...
- <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>
- ...
這樣就符合了正常的頁(yè)面靜態(tài)資源引入位置,同時(shí)蘇寧在 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>
- ...
這樣瀏覽器中發(fā)起的請(qǐng)求就會(huì)少很多,減少頁(yè)面請(qǐng)求也是性能優(yōu)化的一個(gè)點(diǎn)。
緩存機(jī)制
使用 register 機(jī)制后我們又發(fā)現(xiàn)了一個(gè)問(wèn)題,當(dāng)客戶(hù)端每一個(gè) request 請(qǐng)求發(fā)起,nodejs 服務(wù)在響應(yīng)之前都會(huì)進(jìn)行字符串查找替換, 如果頁(yè)面夠復(fù)雜,最終渲染生成的字符串足夠大,每一次進(jìn)行字符串查找替換的過(guò)程中也造成了一定的性能損耗。正常在實(shí)際的使用中我們多次訪(fǎng)問(wèn)一個(gè)路由地址,其頁(yè)面引用的靜態(tài)資源并不會(huì)發(fā)生變化。利用這個(gè)特性蘇寧引入了靜態(tài)資源緩存機(jī)制。
當(dāng)一個(gè)新的頁(yè)面請(qǐng)求進(jìn)來(lái)之后,在執(zhí)行 register 方法之前,會(huì)根據(jù)頁(yè)面請(qǐng)求地址的 pathname 進(jìn)行緩存查找,如果命中緩存,則 getResource() 直接返回緩存內(nèi)容,相應(yīng)的 regsiter 方法也不會(huì)去執(zhí)行。否則執(zhí)行 register() 流程。引入緩存機(jī)制后,非第一次訪(fǎng)問(wèn)代碼邏輯中少了注冊(cè)、替換流程,相應(yīng)的頁(yè)面響應(yīng)時(shí)間也縮短了,經(jīng)過(guò)多次測(cè)試,頁(yè)面響應(yīng)時(shí)間大概縮短 4-8ms。
進(jìn)階優(yōu)化—大量路由的優(yōu)化匹配
在開(kāi)發(fā)蘇寧易購(gòu)香港站過(guò)程當(dāng)中,由于整站頁(yè)面較多、參數(shù)開(kāi)發(fā)人員眾多及基于項(xiàng)目安全性的考慮,項(xiàng)目開(kāi)發(fā)中配置了多達(dá) 173 條靜態(tài)路由以及 11 條動(dòng)態(tài)路由,所以路由匹配效率明顯下降。究其原因,得從 express 源碼入手,express 框架在處理路由配置的方法是,將每一條配置信息轉(zhuǎn)換成一條正則表達(dá)式,在請(qǐng)求進(jìn)入的時(shí)候,逐條進(jìn)行匹配,直到匹配成功為止。
對(duì)于動(dòng)態(tài)路由——路由中含有模糊匹配,則必須使用正則表達(dá)式來(lái)進(jìn)行匹配,無(wú)法優(yōu)化。而對(duì)于靜態(tài)路由,就是固定的字符串的路由表達(dá)式,則可以通過(guò)鍵值對(duì)映射進(jìn)行匹配,復(fù)雜度從 O(n) 變成了 O(1) 大大縮短匹配時(shí)間,且不會(huì)隨著路由增加而耗時(shí)加長(zhǎng)。在實(shí)際代碼中,由于架構(gòu)采用了集中路由配置,所以很方便的從配置文件里面就篩選出了靜態(tài)路由,然后存放在一個(gè) Object 中(HashMap)。然后形成一個(gè)中間件形式,相當(dāng)于把多條路由中間件變成了一條路由中間件。
缺陷:和原來(lái)的邏輯相比,優(yōu)化后的方案缺少了路由匹配的順序,所以在開(kāi)發(fā)的時(shí)候需要額外注意,不過(guò)總體來(lái)說(shuō)影響甚微,因?yàn)殪o態(tài)路由優(yōu)先匹配,也是應(yīng)該優(yōu)先響應(yīng)的。
高階優(yōu)化—TPS 的提升
在蘇寧易購(gòu)大聚惠系統(tǒng)的前后端分離中,初次提交壓力測(cè)試結(jié)果非常差。懷疑有什么配置沒(méi)有配好,當(dāng)時(shí)的數(shù)據(jù)是這樣的(16 臺(tái) 4C4G):
TPS 低的不能忍,而且當(dāng)時(shí)已經(jīng)配備了 Node.js 8.9.1 這個(gè)版本,理論上絕不可能那么差,在觀(guān)察代碼,也沒(méi)有發(fā)現(xiàn)特別消耗性能的地方。最后我們找到了原因,在 ejs 模版配置的時(shí)候沒(méi)有開(kāi)啟模版緩存導(dǎo)致。如果不開(kāi)啟模版緩存那么每次請(qǐng)求渲染的時(shí)候,都會(huì)從磁盤(pán)中讀取本地模版文件進(jìn)行操作,這個(gè)磁盤(pán)讀取的動(dòng)作消耗了很多 CPU。平時(shí)使用不會(huì)察覺(jué),只有當(dāng)壓力測(cè)試的時(shí)候才會(huì)體現(xiàn)出來(lái)。設(shè)置好了參數(shù)后,我們得到了 10 倍的性能提升。
但我們的優(yōu)化并沒(méi)有止步于此,我們定的目標(biāo)是 3000TPS,也就是還需要再提高 50% 的渲染性能。這時(shí)候我們就必須找到影響 nodejs 性能的點(diǎn)。Nodejs 的特點(diǎn)是單線(xiàn)程異步編程,意味著異步操作對(duì)性能的影響不大,而同步操作則會(huì)嚴(yán)重影響性能。
所以第一步,是先檢查代碼中同步操作的邏輯,是否有消耗 CPU 的代碼。經(jīng)過(guò)檢查,排除了代碼部分的嫌疑。只好借助 chrome 提供的 devtools 來(lái)進(jìn)行分析,啟動(dòng) node 參數(shù)—inspect,打開(kāi) chrome 的 devtools 插件就可以通過(guò) CPU profile 進(jìn)行分析了。排除掉不可避免的 CPU 消耗,問(wèn)題浮出水面,原來(lái)還有一部分的 CPU 消耗來(lái)自于 ejs 模版引擎的內(nèi)部。
從圖中可以看出來(lái)有兩部分消耗,一部分是來(lái)自 ejs 模版引擎內(nèi)部的淺拷貝,一部分是來(lái)自查找文件是否存在的系統(tǒng)命令。由于大聚會(huì)系統(tǒng)的 ejs 里面大量使用 include,導(dǎo)致了這部分消耗凸顯了出來(lái)。打開(kāi) ejs 引擎源碼查看,發(fā)現(xiàn)雖然緩存了模版,但每次 include 函數(shù)依然會(huì)去執(zhí)行 fs.exsitSync 函數(shù)。找到罪魁禍?zhǔn)滓院?,修改起?lái)其實(shí)很簡(jiǎn)單,在執(zhí)行改函數(shù)的判斷條件里面加上先判斷緩存中是否存在。修改后這部分消耗減少了不少。
淺拷貝的問(wèn)題,通過(guò) js 的原型鏈解決,將傳入的數(shù)據(jù)對(duì)象作為原型對(duì)象,通過(guò) Object.create 函數(shù)構(gòu)造一個(gè)派生對(duì)象,實(shí)現(xiàn)原來(lái)淺拷貝達(dá)到的目的(模版內(nèi)部修改對(duì)象屬性不會(huì)影響原始對(duì)象,防止污染原始對(duì)象傳入到其他模版中去)。派生對(duì)象修改屬性,并不會(huì)修改原型中對(duì)象的屬性,只會(huì)在派生對(duì)象中新建一個(gè)同名的屬性,所以不會(huì)污染原始對(duì)象。新增屬性也只會(huì)在派生對(duì)象中。這一步優(yōu)化減少了很多賦值操作。
經(jīng)過(guò)以上的優(yōu)化,再進(jìn)行 CPU profile 分析,發(fā)現(xiàn)在 ejs 引擎內(nèi)部依然有一個(gè)函數(shù)在消耗 CPU,那就是 getIncludePath。這個(gè)函數(shù)的目的是在執(zhí)行 include 的時(shí)候講傳入的相對(duì)路徑轉(zhuǎn)成絕對(duì)路徑,目的是防止嵌套的 include 中傳入相同的相對(duì)路徑字符串,卻是代表不同的模版文件。但是在轉(zhuǎn)換成絕對(duì)路徑這一步里面會(huì)調(diào)用文件系統(tǒng)函數(shù)造成 CPU 消耗。
解決的思路很快就出來(lái)了,就是需要講相對(duì)路徑映射成絕對(duì)路徑,然后緩存起來(lái),這樣就不必每次去計(jì)算絕對(duì)路徑了。當(dāng)然這個(gè)緩存不能是全局的,必須每一個(gè) include 創(chuàng)建一個(gè)緩存,這樣才能避免相同的相對(duì)路徑有歧義的問(wèn)題。
原始邏輯:
優(yōu)化的邏輯:
說(shuō)明:路徑映射 Map 是一個(gè)定義在模版函數(shù)所在作用域上的,只有該模版函數(shù)內(nèi)部能訪(fǎng)問(wèn)到,每次執(zhí)行模版函數(shù)的時(shí)候都會(huì)擁有一個(gè)獨(dú)立的 Map。
經(jīng)過(guò)上述優(yōu)化后,本地進(jìn)行壓測(cè)有 50% 的性能提升,故提交測(cè)試組對(duì)大聚會(huì)進(jìn)行線(xiàn)上壓測(cè)。
壓測(cè)結(jié)果非常好,從 2000tps 到了 3500 多,提升了 75% 之多。單臺(tái)機(jī)器大約 220tps 左右,而原 java 系統(tǒng)單臺(tái)大概 150tps 左右。
總 結(jié)
Nodejs 系統(tǒng)的性能優(yōu)勢(shì)主要體現(xiàn)在異步 IO 上面,所以性能瓶頸基本都是出在同步操作上面,那么優(yōu)化也是主要盡量減少同步操作,適當(dāng)使用一些 js 的技巧,另外 npm 包的開(kāi)源特點(diǎn)也給優(yōu)化工作帶來(lái)了便利。