原文: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 和计算的混合工作负载,请求并发会增加延迟,但具有允许更高吞吐量的好处。