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

編程學習之如何在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 是計算密集型的,它們將像這樣執行:html

一個操做必須等待另外一個操做完成後才能運行,由於只有一個執行線程。
在進行服務器端渲染時,當服務器進程須要處理多個併發請求,就會出現這種狀況。正在處理中的請求將致使其餘請求延遲:
在實際當中,請求一般由許多不一樣的異步階段組成,儘管仍然以計算爲主。這可能致使更糟糕的交叉。若是咱們的請求包含一個像 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 的回滾機制。做者:無明前端

相關文章
相關標籤/搜索