在 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 解析的承諾,您可以像這樣實作并行性:

在這種情況下,兩個請求最終都會花費兩倍的時間。随着并發性的增加,這個問題變得更糟。
此外,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 錯誤也變得越來越明顯,不能用一般的慢啟動性能來解釋。 該錯誤來自正文解析器,特别是在用戶端在伺服器能夠完全讀取請求正文之前中止請求的情況下發生。 用戶端放棄并關閉連接配接,帶走我們繼續處理請求所需的寶貴資料。 發生這種情況的可能性要大得多,因為我們開始處理一個請求,然後我們的事件循環被另一個請求的渲染阻塞,然後從我們被中斷的地方傳回完成,卻發現用戶端已經離開了。