部署 Node.js 應用以完成服務器端渲染 Server Side Rendering 的性能調優 一些經驗分享 Reverse Proxying and Load Balancing 總結

原文:Operationalizing Node.js for Server Side Rendering

在 Airbnb,我們花了數年時間將所有前端代碼穩定地遷移到一致的架構中,在該架構中,整個網頁都被編寫爲 React 組件的層次結構,其中包含來自我們 API 的數據。 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,但會遇到計算限制。隨着請求的計算部分相對於 I/O 的增加,併發請求將對延遲產生越來越大的影響,因爲 CPU 爭用。

考慮 Promise.all([fn1, fn2])。如果 fn1 或 fn2 是由 I/O 解析的承諾,您可以像這樣實現並行性:

如果 fn1 和 fn2 是計算的,它們將改爲這樣執行:

一個操作必須等待另一個完成才能運行,因爲只有一個執行線程。

對於服務器端渲染,當服務器進程處理多個併發請求時會出現這種情況。 併發請求將被正在處理的其他請求延遲:

在實踐中,請求通常由許多不同的異步階段組成,即使仍然主要是計算。 這可能導致更糟糕的交織。 如果我們的請求由一個像 renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) 這樣的鏈組成,我們可以有像這樣的請求交錯:

在這種情況下,兩個請求最終都會花費兩倍的時間。隨着併發性的增加,這個問題變得更糟。

此外,SSR 的共同目標之一是能夠在客戶端和服務器上使用相同或相似的代碼。這些環境之間的一個很大區別是客戶端上下文本質上是單租戶,而服務器上下文是多租戶的。在客戶端輕鬆工作的技術(如單例或其他全局狀態)將導致服務器上併發請求負載下的錯誤、數據泄漏和一般混亂。
這兩個問題只會成爲併發問題。在較低的負載水平下或在您的開發環境的舒適單一租戶中,一切通常都能正常工作。
這導致了與 Node 應用程序的規範示例完全不同的情況。我們使用 JavaScript 運行時是因爲它的庫支持和瀏覽器特性,而不是它的併發模型。在這個應用程序中,異步併發模型強加了它的所有成本,沒有或只有很少的好處。

一些經驗分享

用戶發送請求到我們的主要 Rails 應用程序 Monorail,它將希望在任何給定頁面上呈現的 React 組件的 props 拼湊在一起,並使用這些 props 和組件名稱向 Hypernova 發出請求。 Hypernova 使用 props 渲染組件以生成 HTML 以返回到 Monorail,然後將其嵌入到頁面模板中並將整個內容發送回客戶端。

在 SSR 渲染失敗(由於錯誤或超時)的情況下,回退是將組件及其道具嵌入頁面而不渲染 HTML,允許它們(希望)被客戶端成功渲染。 這導致我們將 SSR 視爲一種可選的依賴項,並且我們能夠容忍一定數量的超時和失敗。 我們將調用超時設置爲大約在我們調整值時觀察到的值。不出所料,我們以略低於 5% 的超時基線運行。

在日常流量負載高峯期進行部署時,我們會看到高達 40% 的 SSR 請求發生超時。類似 BadRequestError: Request aborted on deploys 的這些錯誤,掩蓋了所有其他應用程序/編碼錯誤。

我們曾將延遲歸咎於啓動延遲,而延遲實際上是由併發請求相互等待以使用 CPU 造成的。 從我們的性能指標來看,由於其他正在運行的請求而等待執行所花費的時間與執行請求所花費的時間無法區分。 這也意味着併發導致的延遲增加看起來與新代碼路徑或功能導致的延遲增加相同——實際上增加了任何單個請求的成本。

BadRequestError: Request aborted 錯誤也變得越來越明顯,不能用一般的慢啓動性能來解釋。 該錯誤來自正文解析器,特別是在客戶端在服務器能夠完全讀取請求正文之前中止請求的情況下發生。 客戶端放棄並關閉連接,帶走我們繼續處理請求所需的寶貴數據。 發生這種情況的可能性要大得多,因爲我們開始處理一個請求,然後我們的事件循環被另一個請求的渲染阻塞,然後從我們被中斷的地方返回完成,卻發現客戶端已經離開了。

我們決定通過使用我們擁有大量現有操作經驗的兩個現成組件來解決這個問題:反向代理 (nginx) 和負載均衡器 (haproxy)。

Reverse Proxying and Load Balancing

爲了利用我們的 SSR 服務器上存在的多個 CPU 內核,我們通過內置的 Node.js 集羣模塊運行多個 SSR 進程。 由於這些是獨立的進程,我們能夠並行處理併發請求。

這裏的問題是每個節點進程在請求的整個持續時間內都被有效佔用,包括從客戶端讀取請求正文。

雖然我們可以在單個進程中並行讀取多個請求,但這會導致在進行渲染時計算操作的交錯。

節點進程的使用與客戶端和網絡的速度耦合。

解決方案是使用緩衝反向代理來處理與客戶端的通信。 爲此,我們使用 nginx。 Nginx 將來自客戶端的請求讀入緩衝區,並在完全讀取後將完整請求傳遞給節點服務器。

這種傳輸通過環回或 unix 域套接字在機器上本地發生,這比機器之間的通信更快、更可靠。

通過 nginx 處理讀取請求,我們能夠實現節點進程的更高利用率。

總結

服務器端渲染代表與 Node.js 擅長的規範的、主要是 I/O 工作負載不同的工作負載。瞭解異常行爲的原因使我們能夠使用我們擁有現有操作經驗的現成組件來解決它。

異步渲染仍然存在資源爭用。異步渲染解決進程或瀏覽器的響應問題,但不解決並行性或延遲問題。 這篇翻譯的博文重點介紹的是純計算工作負載的簡單模型。對於 IO 和計算的混合工作負載,請求併發會增加延遲,但具有允許更高吞吐量的好處。

更多Jerry的原創文章,盡在:"汪子熙":


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章