Categories
程式開發

深入淺出動態化SSR服務(二):SSR服務篇


現代 SSR 之殤

在第一篇的技術選型中我們有說到,對於 SSR 的技術選型實際上有兩個思路,其分別是:

  1. HandleBars等以純字符串渲染引擎為主的思路
  2. Vue/React等現代 UI 框架以 Virtual DOM 為主的思路

雖然我們最後貫徹 開發友好 為準則選擇了Vue/React等現代 UI 框架,但實際上其弊端(性能和內存佔用)相對於字符串渲染引擎來說仍然有非常大的差距。從Vue的初始化過程我們很容易分析出,由於整個過程需要首先生成大量的Virtual DOM 對象,然後再從Virtual DOM 對像生成模板字符串,因此相比字符串渲染引擎來說避免不了會有更多的內存佔用和CPU 消耗。

這個問題在動態化的頁面可視化 SSR 服務來說會更嚴重,考慮到直接使用Vue CLI同構並不進行優化的場景,對於普通頁面可視化 SSR 服務來說,其開發編譯和執行過程如下:

  1. 我們存在一個服務的入口頁面組件,其包含所有編寫的組件,並通過Vue進行動態初始化,同時暴露此頁面組件對像給外部調用
  2. 使用Vue CLI進行打包,獲得entry-server.js
  3. 啟動 SSR 服務,監聽端口,準備接收請求進行渲染操作
  4. 當請求到達時,我們請求需要渲染的頁面數據信息,拿到當前頁面所依賴的組件信息
  5. 傳遞對應組件信息給入口頁面組件,並使用VuerenderToString方法獲取得到對應的 HTML 字符串信息

從上面的過程我們可以看到,由於頁面對應的組件是完全動態的,因此入口頁面組件實際上需要註冊所有已知的開發組件。Vue的初始化註冊操作實際上是很耗時的,特別是在自身組件越來越多的情況下,而且對於某些簡單的頁面來說,這樣的做法實際上會造成很多的性能浪費。考慮《開發工具篇》我們提到的一個場景:

D1 發布結果包含了 100 個組件,但是對於某一生成頁面而言,只需要對 2 個組件進行重複渲染。

因此我們需要進行按需加載以及按需初始化註冊,避免整體組件的加載及初始化註冊,以此提高性能。

組件的按需加載

在《開發工具篇》中我們已經通過sis拿到了我們依賴關係表,同時也對每個組件的編譯進行了拆分,因此要達到組件的按需加載需求實際上是比較容易的,如圖所示:

深入淺出動態化SSR服務(二):SSR服務篇 1

其執行過程為:

  1. 客戶端請求對應的頁面數據
  2. 根據頁面 ID 調用相關接口或數據庫/緩存獲取得到對應的頁面與組件的數據信息(步驟 1-2)
  3. 根據組件的信息查找依賴關係表,獲取得到組件代碼的加載路徑(步驟 3-4)
  4. 加載對應的加載路徑,獲取對應的組件代碼並進行返回(步驟 5-6)
  5. 動態執行對應的組件代碼,並對Vue實例進行註冊,並調用renderToString方法獲取對應的 HTML 信息
  6. 返回 HTML 信息給客服端

其用代碼描述大致如下:

深入淺出動態化SSR服務(二):SSR服務篇 2

值得說明的是,由於sissis-ssr是以一個公共服務來進行地設計,各個團隊對應的編譯產物是存放在雲端的對象存儲之中。這樣,其他團隊使用此平台就不需要(也不應該)涉及到sis-ssr的部署及重啟。因此,對於頁面的組件代碼獲取而言是需要依靠downloader來進行本地/遠程加載的。

組件代碼的緩存

由於整個系統涉及到了組件代碼的動態化加載,因此downloader的性能也會一定情況下影響單請求單頁面的渲染信息返回速度。通過《開發工具篇》我們可以知道,使用合併優化後仍然會有一些公用依賴代碼,但實際生產的運行過程中,公用代碼的緩存使用率會比較高,因此這個時候我們可以在downloader內部增加緩存。另外,除了Node自身的內存緩存外,我們還可以增加一層文件緩存,盡可能的保證加載的性能(例如服務重啟後的加載性能)。對於內存緩存而言,我們一般使用 LRU,以此來幫助我們主動清理 HIT 數量比較低的緩存內容,其代碼如下:

深入淺出動態化SSR服務(二):SSR服務篇 3

當然,整個過程中你還可以使用Promise.all進行並行加載來進一步提高對應的組件代碼的加載效率。

頁面緩存

除了組件代碼的緩存外,在正常的實際運行中,我們一般還需要增加頁面的緩存,在這裡我們同樣使用LRU和文件緩存來達到這一目的,代碼如下:

深入淺出動態化SSR服務(二):SSR服務篇 4

簡單頁面的壓力測試

現在我們來對一個Hello 頁面進行簡單的壓力測試,其渲染的組件代碼為一個簡單的子組件的嵌套,如代碼所示:

深入淺出動態化SSR服務(二):SSR服務篇 5

sis-ssr對比的實現為vue2-ssr-example, 兩者依賴庫/框架均為:

測試的服務器配置為:

  • 阿里雲 – 計算型 – 4 核 8G

注意,在此壓力測試中sis-ssr去除了頁面緩存,僅保留了組件代碼的緩存,頁面的組件信息獲取寫死在代碼之中,兩者的渲染執行邏輯基本一致。以pm2 start index.js -i 4的方式啟動並進行壓測,其最終結果為:

深入淺出動態化SSR服務(二):SSR服務篇 6

結果分析

我們可以看到在總共 2000 個請求 300 個並發的情況下sis-ssr相比vue2-ssr-example的整體渲染性能會好很多,甚至高於 100%!可能有讀者會有疑問:“按照分析來看,難道不是應該vue2-ssr-example性能會更高或者相差不大麼? ”,實際上確實應該如此,其拖慢vue2-ssr-example性能的最重要原因其實是Webpack編譯時的邏輯。

我們知道對於Webpack等前端編譯工具打包而言,其會按照對應的配置進行對應的代碼ES5.1之類的語法編譯及polyfill引入的優化,而對於sis來說,我們在打包 SSR 時並沒有進行相關的優化,盡可能讓對應的編譯的代碼結果足夠乾淨,由於sis-ssr跑的代碼大部分都是 Native 的語法和原生方法,相比vue2-ssr-example產出的代碼來說會有不小的提升。

假設我們將這些部分進行類似sis的優化,那麼實際上壓力測試結果會比較相近。在Hello 頁面的壓測之中彼此的性能差距在 3%左右。

從這個例子我們可以看出,對於性能優化而言是需要從細小處做起,多個細小處做到極致合起來就能得到意想不到的提升。

一般頁面的壓力測試

但在實際項目中,我們往往沒有那麼簡單的頁面,反而會有更複雜的組件嵌套以及調用關係。在這個壓力測試中我們引入ElementUI中幾個組件來進行比較,組件如代碼所示:

深入淺出動態化SSR服務(二):SSR服務篇 7

注意,此次壓力測試中我們仍然不修改vue2-ssr-example的相關編譯配置,其最終測試結果為:

深入淺出動態化SSR服務(二):SSR服務篇 8

結果分析

我們可以看到sis-ssr相比較於vue2-ssr-example來說仍然有 20%的性能提升,這是符合我們的預期的,因為從ElementUI編譯得到的代碼後我們可以看到,實際上sis引入的代碼中有相當多的代碼已經被預編譯成ES5.1並加入了對應的polyfill了,所有的執行熱點基本上是由於ElementUI自身邏輯造成的,因此sis-ssr相對vue2-ssr-example的提升就不會像Hello 頁面那麼明顯了。

總之,基於sissis-ssr的 SSR 服務在實際生產的頁面渲染場景相對於目前大部分其他實踐方案來說是有比較可觀的性能收益的。

這裡的vue2-ssr-example的壓測結果相比 Hello 頁面 測試結果來說降低得併不是特別多,其原因在於:實際上我們引入的ElementUI組件還是比較簡單的組件,相比 Hello 頁面 而言增加的執行邏輯並沒多太多,但sis-ssr因為以上討論原因下降會比較明顯。以上內容均可以通過NodeProfile工具分析得出。

超時、限流與降級

在實際生產中,服務高性能當然是值得高興,但如果是沒有穩定性的高性能,那麼實際上就沒有那麼讓人愉悅了。在sis-ssr中為了保證對應的單機服務的穩定性,我們分別採取了三個策略,其分別是:

  • 超時策略
  • 限流策略
  • 渲染降級策略

sis-ssr中實際上會出現不少的異步請求的情況,例如downloader的遠程加載組件代碼。對於這些服務端的異步請求,我們一般都會強制考慮請求的超時處理,防止請求長時間被掛起造成的問題,例如:

深入淺出動態化SSR服務(二):SSR服務篇 9

其次,為了保證我們的單機服務不被外部流量衝擊,我們也需要加入限流的策略。其中比較常用的限流策略包括:固定窗口算法、滑動窗口算法、漏桶算法以及令牌桶算法等。在Node中有存在基於redisexpress-rate-limitNPM包幫助我們完成相關的邏輯。

最後sis-ssr為了應對各種請求/流量異常的情況還做了多級的降級策略:

  1. 服務端數據請求成功且Node端渲染成功,正常返回
  2. 服務端數據請求成功但Node端渲染失敗,則拋棄首屏並將相關數據寫入頁面,由客戶端進行渲染
  3. 流量異常/系統資源佔用過高,完全由客戶端請求數據並進行渲染

至此由上至下進行兜底,以此保證服務的可用性。除此之外,由於Node的單進程單主線程(在這裡我們排除I/O異步等事件循環中的子線程)且頁面渲染是純CPU操作的特性,其在渲染大頁面時經常會出現阻塞運行時主線程的情況。因此我們可以創建包含一定數量工作線程的線程池(使用Nodeworker_thread),然後將對應的頁面渲染放置在Worker工作線程之中,當線程池中無空閒工作線程時,Master線程進行主動的頁面降級渲染以此來提高對應的性能。此功能已在sis-ssr中得到了實現並取得了極好的實際效果,其壓力測試結果如圖所示:

深入淺出動態化SSR服務(二):SSR服務篇 10

此次壓測的各環境不變,同時測試對象主要是 Hello 頁面。從結果中我們可以看到,搭配了主動降級+Worker的渲染方式相比後兩者有非常大的提升,其原因也很簡單,因為大部分請求由於Worker不空閒均被降級為犧牲首屏並將數據寫入頁面,由客戶端渲染的方式了(在上面的壓力測試下,大約有10%左右的請求是真正的Node渲染,其餘的都走降級頁面了)。

在這種情況下,由於Google已經支持同步的前端渲染頁面的收錄,所以降級渲染請求並不會影響到SEO。那麼對於內容到達時間(time-to-content)呢?我們可以做一個粗糙的計算(實際請以數據日誌為主),在我們 只關注於渲染時間且單機單進程 情況下,假設並發10個請求,Node端每次渲染耗時100ms,那麼完全以Node端渲染來說,最後一個用戶的內容到達時間為1s。若考慮降級,降級頁面的渲染時間為50ms,則最後一個用戶獲得頁面HTML的時間為350ms,若靜態資源加載加上同步的前端渲染頁面耗時小於650ms,則相對於完全Node端渲染的方案有提升。同時我們需要注意,隨著並發數的增加,此提升會越來越高。若並發數提升為20時,同樣的計算後我們可以得到完全Node渲染的方式最後一個用戶的內容到達時間為2s,而降級頁面最後一個用戶獲得頁面HTML的時間為750ms,若靜態資源加載加上同步的前端渲染頁面耗時小於1250ms則具有對應的提升。

總結與期待

在本章我們較為詳細的探討了sis-ssr的一些內部邏輯,同時與vue2-ssr-example進行了單機的壓力測試的比較並分析了對應的原因。同時我們也講述了sis-ssr是如何保證生產環境的高可用性的。在下一篇中我們會從這些細節脫身,從更全局和整體的架構角度來看待整個動態化SSR服務。敬請期待《深入淺出動態化SSR服務(之三) – 架構篇》。

相關閱讀

https://www.infoq.cn/article/SlgQEvW8VGt8EEiTeXEd