1. 程式人生 > >編程學習之如何在Node.js中優化服務器端渲染?[圖]

編程學習之如何在Node.js中優化服務器端渲染?[圖]

模型 如果 高峰 字節 may 異步化 當我 node.js res

編程學習之如何在Node.js中優化服務器端渲染?[圖]
在 Airbnb,我們花了數年時間將所有前端代碼遷移到 React 架構,Ruby on Rails 在 Web 應用中所占的比例每天都在減少。實際上,我們很快會轉向另一個新的服務,即通過 Node.js 提供完整的服務器端渲染頁面。這個服務將為 Airbnb 的所有產品渲染大部分 HTML。這個渲染引擎不同於其他後端服務,因為它不是用 Ruby 或 Java 開發的,但它也不同於常見的 I/O 密集型 Node.js 服務。
一說起 Node.js,你可能就開始暢想著高度異步化的應用程序,可以同時處理成千上萬個連接。你的服務從各處拉取數據,以迅雷不及掩耳之勢處理好它們,然後返回給客戶端。你可能正在處理一大堆 WebSocket 連接,你對自己的輕量級並發模型充滿自信,認為它非常適合完成這些任務。
但服務器端渲染(SSR)卻打破了你對這種美好願景的假設,因為它是計算密集型的。Node.js 中的用戶代碼運行在單個線程上,因此可以並發執行計算操作(與 I/O 操作相反),但不能並行執行它們。Node.js 可以並行處理大量的異步 I/O,但在計算方面卻受到了限制。隨著計算部分所占比例的增加,開始出現 CPU 爭用,並發請求將對延遲產生越來越大的影響。
以 Promise.all([fn1,fn2]) 為例,如果 fn1 或 fn2 是屬於 I/O 密集型的 promise,就可以實現這樣的並行執行:
如果 fn1 和 fn2 是計算密集型的,它們將像這樣執行:

技術分享圖片

一個操作必須等待另一個操作完成後才能運行,因為只有一個執行線程。
在進行服務器端渲染時,當服務器進程需要處理多個並發請求,就會出現這種情況。正在處理中的請求將導致其他請求延遲:
在實際當中,請求通常由許多不同的異步階段組成,盡管仍然以計算為主。這可能導致更糟糕的交叉。如果我們的請求包含一個像 renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) 這樣的鏈,那麽請求的交叉可能是這樣的:
在這種情況下,兩個請求都需要兩倍的時間才能處理完成。隨著並發的增加,這個問題將變得更加嚴重。
SSR 的一個目標是能夠在客戶端和服務器上使用相同或類似的代碼。這兩種環境之間存在一個巨大的差異,客戶端上下文本質上是單租戶的,而服務器上下文卻是多租戶的。在客戶端可以正常運行的東西,比如單例或全局狀態,到了服務器端就會導致 bug、數據泄漏和各種混亂。
這兩個問題都與並發有關。在負載水平較低時,或在開發環境當中,一切都正常。
這與 Node 應用程序的情況完全不同。我們之所以使用 JavaScript 運行時,是因為它提供的庫支持和對瀏覽器的支持,而不是因為它的並發模型。上述的示例表明,異步並發模型所帶來的成本已經超出了它所能帶來的好處。
從 Hypernova 中學到的教訓:
我們的新渲染服務 Hyperloop 將成為 Airbnb 用戶的主要交互服務。因此,它的可靠性和性能對用戶體驗來說至關重要。隨著逐漸在生產環境中使用新服務,我們將參考從早期 SSR 服務 Hypernova 中吸取到的教訓。
Hypernova 的工作方式與新服務不同。它是一個純粹的渲染器,Rails 單體應用 Monorail 會調用它,它返回渲染組件的 HTML 片段。在大多數情況下,“片段”是整個頁面的一部分,Rails 只提供外部布局。頁面上的各個部分可以使用 ERB 拼接在一起。但是,不管是哪一種情況,Hypernova 都不獲取數據,數據由 Rails 提供。
也就是說,在計算方面,Hyperloop 和 Hypernova 具有類似的操作特性,而 Hypernova 提供了良好的測試基礎,可以幫助我們理解生產環境中的頁面內容是如何進行替換的。
用戶請求進入我們的 Rails 主應用程序 Monorail,它為需要進行渲染的 React 組件組裝 props,並向 Hypernova 發送帶有這些 props 和組件名稱的請求。Hypernova 使用收到的 props 來渲染組件,生成 HTML 並返回給 Monorail,Monorail 將 HTML 片段嵌入到頁面模板中,並將所有內容發送給客戶端。
如果 Hypernova 渲染失敗(由於錯誤或超時),就將組件及 props 嵌入頁面,或許它們可以成功地在客戶端渲染。因此,我們認為 Hypernova 是一個可選的依賴項,我們能夠容忍一些超時和失敗。我根據 SLA p95 來設置超時時間,不出所料,我們的超時基線略低於 5%。
在高峰流量負載期間進行部署時,我們可以看到從 Monorail 到 Hypernova 最多有 40%的請求超時。我們可以從 Hypernova 中看到 BadRequestError:aborted 的錯誤率峰值。
部署超時峰值示例:
我們把這些超時和錯誤歸因於緩慢的啟動時間,如 GC 啟動初始化、缺少 JIT、填充緩存等等。新發布的 React 或 Node 有望提供足夠的性能改進,以緩解啟動緩慢的問題。
我懷疑這可能是由於不良的負載均衡或部署期間的容量問題造成的。當我們在同一個進程上同時運行多個計算請求時,我們看到了延遲的增加。我添加了一個中間件來記錄進程同時處理的請求數。
我們將啟動延遲歸咎於並發請求等待 CPU。從我們的性能指標來看,我們無法區分用於等待執行的時間與用於實際處理請求的時間。這也意味著並發性帶來的延遲與新代碼或新特性帶來的延遲是相同的——這些實際上都會增加單個請求的處理成本。
很明顯,我們不能將 BadRequestError:Request aborted 錯誤歸咎於啟動延遲。這個錯誤來自消息解析器,特別在服務器完全讀取請求消息體之前,客戶端中止了請求。客戶端關閉了連接,我們無法拿到處理請求所需的寶貴數據。發生這種情況的可能性更大,比如:我們開始處理請求,然後事件循環被另一個請求渲染阻塞,當回到之前被中斷的地方繼續處理時,發現客戶端已經消失了。Hypernova 的請求消息體也很大,平均有幾百千字節,這樣只會讓事情變得更糟。
我們決定使用兩個現有的組件來解決這個問題:反向代理(Nginx)和負載均衡器(HAProxy)。
反向代理和負載均衡:
為了充分利用 Hypernova 實例上的多核 CPU,我們在單個實例上運行多個 Hypernova 進程。因為這些是獨立的進程,所以能夠並行處理並發請求。
問題是每個 Node 進程將在整個請求時間內被占用,包括從客戶端讀取請求消息體。雖然我們可以在單個進程中並行讀取多個請求,但在渲染時,這會導致計算操作交叉。因此,Node 進程的使用情況取決於客戶端和網絡的速度。
解決辦法是使用緩沖反向代理來處理與客戶端的通信。為此,我們使用了 Nginx。Nginx 將客戶端的請求讀入緩沖區,並在完全讀取後將完整請求傳給 Node 服務器。高老頭讀書筆記

摘抄好詞好句及感悟賞析,這個傳輸過程是在本地機器上進行的,使用了回送或 unix 域套接字,這比機器之間的通信更快、更可靠。
通過使用 Nginx 來處理讀取請求,我們能夠實現更高的 Node 進程利用率。
我們還使用 Nginx 來處理一部分請求,不需要將它們發送給 Node.js 進程。我們的服務發現和路由層通過 /ping 低成本請求來檢查主機之間的連接性。在 Nginx 中處理這些可以降低 Node.js 進程的吞吐量。
接下來是負載均衡。我們需要明智地決定哪些 Node.js 進程應該接收哪些請求。cluster 模塊通過 round-robin 算法來分配請求,當請求延遲的變化很小時,這種方式是很好的,例如:
但是當有不同類型的請求需要花費不同的處理時間時,它就不那麽好用了。後面的請求必須等待前面的請求全部完成,即使有另一個進程可以處理它們。
更好的分發模型應該像這樣:
因為這可以最大限度地減少等待時間,並可以更快地返回響應。
這可以通過將請求放進隊列中並只將請求分配給空閑的進程來實現。為此,我們使用了 HAProxy。
當我們在 Hypernova 中實現了這些,就完全消除了部署時的超時峰值以及 BadRequestError 錯誤。並發請求也是造成延遲的主要因素,隨著新方案的實施,延遲也降低了。在使用相同的超時配置的情況下,超時率基線從 5%變為 2%。部署期間的 40%失敗也降低到了 2%,這是一個重大的勝利。現在,用戶看到空白頁的幾率已經很低了。未來,部署穩定性對於我們的新渲染器來說至關重要,因為新渲染器沒有 Hypernova 的回滾機制。作者:無明

編程學習之如何在Node.js中優化服務器端渲染?[圖]