Yelp如何重新架构其大规模大型的服务器端渲染?


在 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剖析和捆绑源图支持。