Yelp如何重新架構其大規模大型的伺服器端渲染?

banq發表於2022-03-04

在 Yelp,我們使用伺服器端渲染 (SSR) 來提高基於 React 的前端頁面的效能。在 2021 年初發生一系列生產事件後,我們意識到我們現有的 SSR 系統無法擴充套件,因為我們將更多頁面從基於 Python 的模板遷移到 React。在這一年的剩餘時間裡,我們致力於重新構建我們的 SSR 系統,以提高穩定性、降低成本並提高功能團隊的可觀察性。

 

什麼是SSR?

伺服器端渲染是一種技術,用於提高JavaScript模板系統(如React)的效能。我們不是等待客戶端下載一個JavaScript包並根據其內容來渲染頁面,而是在伺服器端渲染頁面的HTML,並在下載後在客戶端附加動態鉤子。這種方法用增加的傳輸量換取更高的渲染速度,因為我們的伺服器通常比客戶端機器快。在實踐中,我們發現,這大大改善了我們的LCP時間。

Yelp SSR現狀

我們為SSR準備的元件是將它們與一個入口函式和任何其他依賴關係捆綁在一個獨立的.js檔案中。然後入口使用ReactDOMServer,它接受元件的道具併產生渲染的HTML。這些SSR包被上傳到S3,作為我們持續整合過程的一部分。

我們以前的SSR系統會在啟動時下載並初始化每個SSR包的最新版本,這樣它就可以準備好渲染任何頁面,而不用在關鍵路徑上等待S3。然後,根據傳入的請求,將選擇並呼叫一個適當的入口函式。這種方法給我們帶來了一些問題。

下載和初始化每個捆綁包大大增加了服務的啟動時間,這使得我們很難對擴充套件事件做出快速反應。

讓服務管理所有的捆綁包會產生大量的記憶體需求。每次我們橫向擴充套件並啟動一個新的服務例項時,我們都必須分配相當於每個捆綁包的原始碼和執行時用量總和的記憶體。從同一個例項中提供所有的捆綁服務也使得我們很難衡量一個捆綁的效能特徵。

如果在服務重啟之間上傳了一個新版本的捆綁包,服務就不會有它的副本了。我們通過在需要時動態下載缺失的捆綁包來解決這個問題,並使用LRU快取來確保我們不會在同一時間在記憶體中保留太多的動態捆綁包。

舊的系統是基於Airbnb的Hypernova。Airbnb就Hypernova的問題寫了自己的博文,但核心問題是,渲染元件會阻塞事件迴圈,並可能導致一些Node API以意想不到的方式中斷。我們遇到的一個關鍵問題是,阻塞事件迴圈會破壞Node的HTTP請求超時功能,這在系統已經過載的情況下大大加劇了請求延遲。任何SSR系統的設計都必須儘量減少因渲染而阻塞事件迴圈的影響。

2021年初,隨著Yelp公司的SSR捆綁系統的數量不斷增加,這些問題成為所有問題的根源。

啟動時間變得如此緩慢,以至於Kubernetes開始將例項標記為不健康,並自動重新啟動它們,防止它們變得健康。

該服務的大量堆積導致了嚴重的垃圾收集問題。在舊系統的生命週期結束時,我們為它分配了近12GB的舊堆空間。在一次實驗中,我們確定由於垃圾收集時間的損失,我們無法提供每秒50個以上的請求。

 

由於頻繁的捆綁驅逐和重新初始化而導致的動態捆綁快取耗費了大量的CPU負擔,開始影響在同一主機上執行的其他服務。

所有這些問題都降低了Yelp的前端效能,並導致了一些事件。

  

重新架構的目標

在處理了這些事件之後,我們開始重新架構我們的SSR系統。我們選擇穩定性、可觀察性和簡單性作為我們的設計目標。新的系統應該在沒有大量人工干預的情況下執行和擴充套件。它應該不僅對內部團隊,而且對擁有捆綁功能的團隊都易於觀察。新系統的設計應該讓未來的開發者容易理解。

我們還選擇了幾個具體的、功能性的目標。

  • 儘量減少阻塞事件迴圈的影響,以便請求超時等功能能夠正常工作。
  • 按捆綁服務例項進行分片,這樣每個捆綁服務都有自己獨特的資源分配。這減少了我們的整體資源佔用,並使特定的捆綁效能更容易觀察。
  • 能夠快速放棄我們預計無法快速服務的請求。如果我們知道渲染一個請求需要很長的時間,系統應該立即退回到客戶端渲染,而不是先等待SSR超時。這為我們的終端使用者提供了儘可能快的使用者體驗。

 

語言選擇

在實現SSR服務(SSRS)的時候,我們評估了幾種語言,包括Python和Rust。從內部生態系統的角度來看,使用Python是非常理想的,但是,我們發現Python的V8繫結狀態還沒有準備好投入生產,並且需要大量的投資才能用於SSR。

接下來,我們評估了Rust,它有高質量的V8繫結,已經在Deno等流行的生產就緒的專案中使用。然而,我們所有的SSR包都依賴於Node執行時API,而這並不是裸露的V8的一部分;因此,我們必須重新實現它的重要部分來支援SSR。這一點,加上Yelp的開發者生態系統中普遍缺乏對Rust的支援,使我們無法使用它。

最後,我們決定在Node中重寫SSRS,因為Node提供了一個V8 VM API,允許開發者在沙盒V8上下文中執行JS,在Yelp開發者生態系統中有高質量的支援,並允許我們重用其他內部Node服務的程式碼,以減少實施工作。

 

演算法

SSRS由一個主執行緒和許多工作執行緒組成。NodeJs工人執行緒與作業系統執行緒不同,每個執行緒都有自己的事件迴圈,執行緒之間不能瑣碎地共享記憶體。

當主執行緒收到一個HTTP請求時,它執行以下步驟:

根據一個 "超時因素 "檢查該請求是否應該被快速放棄。

目前,這個因素包括平均渲染執行時間和當前佇列大小,但也可以擴充套件到更多的指標,如CPU負載和吞吐量。

將請求推送到渲染工作者池佇列中。

當一個渲染工作者執行緒收到一個請求時,它會執行以下步驟:

  1. 執行伺服器端的渲染。這就阻塞了事件迴圈,但仍然是允許的,因為工作者一次只處理一個請求。在這個由CPU控制的工作發生時,不應該有其他東西使用事件迴圈。
  2. 將渲染後的 HTML 返回給主執行緒。

當主執行緒收到來自工作執行緒的響應時,它會將渲染的 HTML 返回給客戶端。

 

這種方法為我們提供了兩個重要的保證,幫助我們滿足我們的要求:

  • 事件迴圈在主Web伺服器執行緒中從不被阻塞。
  • 當事件迴圈在工作執行緒中被阻塞時,則永遠不要用它。

我們使用了Piscina,一個提供上述功能的第三方庫。它管理執行緒池,支援任務佇列、任務取消和許多其他有用的功能。

我們選擇Fastify作為主執行緒網路伺服器的動力,因為它既具有很高的效能又對開發者友好。

程式碼案例:

const workerPool = new Piscina({...});

app.post('/batch', opts, async (request, reply) => {
       if (
           Math.min(avgRunTime.movingAverage(), RENDER_TIMEOUT_MSECS) * (workerPool.queueSize + 1) >
           RENDER_TIMEOUT_MSECS
       ) {
           // Request is not expected to complete in time.
           throw app.httpErrors.tooManyRequests();
       }
       try {
           const start = performance.now();
           currentPendingTasks += 1;
           const resp = await workerPool.run(...);
           const stop = performance.now();
           const runTime = resp.duration;
           const waitTime = stop - start - runTime;
           avgRunTime.push(Date.now(), runTime);
           reply.send({
               results: resp,
           });
       } catch (e) {
           // Error handling code
       } finally {
           currentPendingTasks -= 1;
       }
   });

 

橫向擴充套件的自動縮放

SSRS是建立在PaaSTA上的,它提供了開箱即用的自動縮放機制。我們決定建立一個自定義的自動縮放訊號,以獲取工人池的利用率。

Math.min(currentPendingTasks, WORKER_COUNT) / WORKER_COUNT。

這個值與我們的目標利用率(設定點)在一個移動的時間視窗中進行比較,以進行水平縮放調整。我們發現,與基本的容器CPU使用率擴充套件相比,這個訊號有助於我們將每個工作者的負載保持在更健康、更準確的配置狀態,確保在合理的時間內為所有請求提供服務,而不會讓工作者超載或過度擴充套件服務。

垂直擴充套件的自動調整

Yelp由許多具有不同流量負載的頁面組成;因此,支援這些頁面的SSRS分片具有巨大的不同資源需求。我們沒有為每個SSRS分片靜態地定義資源,而是利用動態資源自動調整的優勢,隨著時間的推移自動調整容器資源,如CPU和記憶體分片。

這兩種擴充套件機制確保每個分片都有它需要的例項和資源,無論它收到的流量有多小或多大。最大的好處是在不同的頁面上高效執行SSRS,同時保持成本效益。

 

贏家

用Piscina和Fastify重寫SSRS,使我們能夠避免之前實施的阻塞事件迴圈問題。結合分片的方法和更好的擴充套件訊號,使我們能夠壓榨出更多的效能,同時減少雲端計算成本。其中的一些亮點包括。

  • 當伺服器端渲染一個包的時候,平均減少125ms p99。
  • 通過減少啟動時初始化的資料包數量,將服務啟動時間從舊系統的幾分鐘提高到幾秒鐘。
  • 通過使用自定義擴充套件因子和更有效地調整每個分片的資源,將雲端計算成本降低到以前系統的三分之一。
  • 提高了可觀察性,因為每個分片現在只負責渲染一個捆綁包,允許團隊更快了解哪裡出了問題。
  • 建立了一個更具擴充套件性的系統,允許未來的改進,如CPU剖析和捆綁源圖支援。

相關文章