编程学习之如何在Node.js中优化服务器端渲染?[图]
在 Airbnb,咱们花了数年时间将全部前端代码迁移到 React 架构,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,但在计算方面却受到了限制。随着计算部分所占比例的增长,开始出现 CPU 争用,并发请求将对延迟产生愈来愈大的影响。
以 Promise.all([fn1,fn2]) 为例,若是 fn1 或 fn2 是属于 I/O 密集型的 promise,就能够实现这样的并行执行:
若是 fn1 和 fn2 是计算密集型的,它们将像这样执行:html
一个操做必须等待另外一个操做完成后才能运行,由于只有一个执行线程。
在进行服务器端渲染时,当服务器进程须要处理多个并发请求,就会出现这种状况。正在处理中的请求将致使其余请求延迟:
在实际当中,请求一般由许多不一样的异步阶段组成,尽管仍然以计算为主。这可能致使更糟糕的交叉。若是咱们的请求包含一个像 renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) 这样的链,那么请求的交叉多是这样的:
在这种状况下,两个请求都须要两倍的时间才能处理完成。随着并发的增长,这个问题将变得更加严重。
SSR 的一个目标是可以在客户端和服务器上使用相同或相似的代码。这两种环境之间存在一个巨大的差别,客户端上下文本质上是单租户的,而服务器上下文倒是多租户的。在客户端能够正常运行的东西,好比单例或全局状态,到了服务器端就会致使 bug、数据泄漏和各类混乱。
这两个问题都与并发有关。在负载水平较低时,或在开发环境当中,一切都正常。
这与 Node 应用程序的状况彻底不一样。咱们之因此使用 JavaScript 运行时,是由于它提供的库支持和对浏览器的支持,而不是由于它的并发模型。上述的示例代表,异步并发模型所带来的成本已经超出了它所能带来的好处。
从 Hypernova 中学到的教训:
咱们的新渲染服务 Hyperloop 将成为 Airbnb 用户的主要交互服务。所以,它的可靠性和性能对用户体验来讲相当重要。随着逐渐在生产环境中使用新服务,咱们将参考从早期 SSR 服务 Hypernova 中吸收到的教训。
Hypernova 的工做方式与新服务不一样。它是一个纯粹的渲染器,Rails 单体应用 Monorail 会调用它,它返回渲染组件的 HTML 片断。在大多数状况下,“片断”是整个页面的一部分,Rails 只提供外部布局。页面上的各个部分可使用 ERB 拼接在一块儿。可是,不论是哪种状况,Hypernova 都不获取数据,数据由 Rails 提供。
也就是说,在计算方面,Hyperloop 和 Hypernova 具备相似的操做特性,而 Hypernova 提供了良好的测试基础,能够帮助咱们理解生产环境中的页面内容是如何进行替换的。
用户请求进入咱们的 Rails 主应用程序 Monorail,它为须要进行渲染的 React 组件组装 props,并向 Hypernova 发送带有这些 props 和组件名称的请求。Hypernova 使用收到的 props 来渲染组件,生成 HTML 并返回给 Monorail,Monorail 将 HTML 片断嵌入到页面模板中,并将全部内容发送给客户端。
若是 Hypernova 渲染失败(因为错误或超时),就将组件及 props 嵌入页面,或许它们能够成功地在客户端渲染。所以,咱们认为 Hypernova 是一个可选的依赖项,咱们可以容忍一些超时和失败。我根据 SLA p95 来设置超时时间,不出所料,咱们的超时基线略低于 5%。
在高峰流量负载期间进行部署时,咱们能够看到从 Monorail 到 Hypernova 最多有 40%的请求超时。咱们能够从 Hypernova 中看到 BadRequestError:aborted 的错误率峰值。
部署超时峰值示例:
咱们把这些超时和错误归因于缓慢的启动时间,如 GC 启动初始化、缺乏 JIT、填充缓存等等。新发布的 React 或 Node 有望提供足够的性能改进,以缓解启动缓慢的问题。
我怀疑这多是因为不良的负载均衡或部署期间的容量问题形成的。当咱们在同一个进程上同时运行多个计算请求时,咱们看到了延迟的增长。我添加了一个中间件来记录进程同时处理的请求数。
咱们将启动延迟归咎于并发请求等待 CPU。从咱们的性能指标来看,咱们没法区分用于等待执行的时间与用于实际处理请求的时间。这也意味着并发性带来的延迟与新代码或新特性带来的延迟是相同的——这些实际上都会增长单个请求的处理成本。
很明显,咱们不能将 BadRequestError:Request aborted 错误归咎于启动延迟。这个错误来自消息解析器,特别在服务器彻底读取请求消息体以前,客户端停止了请求。客户端关闭了链接,咱们没法拿处处理请求所需的宝贵数据。发生这种状况的可能性更大,好比:咱们开始处理请求,而后事件循环被另外一个请求渲染阻塞,当回到以前被中断的地方继续处理时,发现客户端已经消失了。Hypernova 的请求消息体也很大,平均有几百千字节,这样只会让事情变得更糟。
咱们决定使用两个现有的组件来解决这个问题:反向代理(Nginx)和负载均衡器(HAProxy)。
反向代理和负载均衡:
为了充分利用 Hypernova 实例上的多核 CPU,咱们在单个实例上运行多个 Hypernova 进程。由于这些是独立的进程,因此可以并行处理并发请求。
问题是每一个 Node 进程将在整个请求时间内被占用,包括从客户端读取请求消息体。虽然咱们能够在单个进程中并行读取多个请求,但在渲染时,这会致使计算操做交叉。所以,Node 进程的使用状况取决于客户端和网络的速度。
解决办法是使用缓冲反向代理来处理与客户端的通讯。为此,咱们使用了 Nginx。Nginx 将客户端的请求读入缓冲区,并在彻底读取后将完整请求传给 Node 服务器。高老头读书笔记摘抄好词好句及感悟赏析,这个传输过程是在本地机器上进行的,使用了回送或 unix 域套接字,这比机器之间的通讯更快、更可靠。
经过使用 Nginx 来处理读取请求,咱们可以实现更高的 Node 进程利用率。
咱们还使用 Nginx 来处理一部分请求,不须要将它们发送给 Node.js 进程。咱们的服务发现和路由层经过 /ping 低成本请求来检查主机之间的链接性。在 Nginx 中处理这些能够下降 Node.js 进程的吞吐量。
接下来是负载均衡。咱们须要明智地决定哪些 Node.js 进程应该接收哪些请求。cluster 模块经过 round-robin 算法来分配请求,当请求延迟的变化很小时,这种方式是很好的,例如:
可是当有不一样类型的请求须要花费不一样的处理时间时,它就不那么好用了。后面的请求必须等待前面的请求所有完成,即便有另外一个进程能够处理它们。
更好的分发模型应该像这样:
由于这能够最大限度地减小等待时间,并能够更快地返回响应。
这能够经过将请求放进队列中并只将请求分配给空闲的进程来实现。为此,咱们使用了 HAProxy。
当咱们在 Hypernova 中实现了这些,就彻底消除了部署时的超时峰值以及 BadRequestError 错误。并发请求也是形成延迟的主要因素,随着新方案的实施,延迟也下降了。在使用相同的超时配置的状况下,超时率基线从 5%变为 2%。部署期间的 40%失败也下降到了 2%,这是一个重大的胜利。如今,用户看到空白页的概率已经很低了。将来,部署稳定性对于咱们的新渲染器来讲相当重要,由于新渲染器没有 Hypernova 的回滚机制。做者:无明前端