30

深入浅出动态化 SSR 服务(二):SSR 服务篇

 4 years ago
source link: https://www.infoq.cn/article/qLewQSiT7OshkUgw18e5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

现代 SSR 之殇

在第一篇的技术选型中我们有说到,对于 SSR 的技术选型实际上有两个思路,其分别是:

  1. HandleBars 等以纯字符串渲染引擎为主的思路
  2. Vue / React 等现代 UI 框架以 Virtual DOM 为主的思路

虽然我们最后贯彻 开发友好 为准则选择了 Vue / React 等现代 UI 框架,但实际上其弊端(性能和内存占用)相对于字符串渲染引擎来说仍然有非常大的差距。从 Vue 的初始化过程我们很容易分析出,由于整个过程需要首先生成大量的 Virtual DOM 对象,然后再从 Virtual DOM 对象生成模板字符串,因此相比字符串渲染引擎来说避免不了会有更多的内存占用和 CPU 消耗。

这个问题在动态化的页面可视化 SSR 服务来说会更严重,考虑到直接使用 Vue CLI 同构并不进行优化的场景,对于普通页面可视化 SSR 服务来说,其开发编译和执行过程如下:

  1. 我们存在一个服务的入口页面组件,其包含所有编写的组件,并通过 Vue<component> 进行动态初始化,同时暴露此页面组件对象给外部调用
  2. 使用 Vue CLI 进行打包,获得 entry-server.js
  3. 启动 SSR 服务,监听端口,准备接收请求进行渲染操作
  4. 当请求到达时,我们请求需要渲染的页面数据信息,拿到当前页面所依赖的组件信息
  5. 传递对应组件信息给入口页面组件,并使用 VuerenderToString 方法获取得到对应的 HTML 字符串信息

从上面的过程我们可以看到,由于页面对应的组件是完全动态的,因此入口页面组件实际上需要注册所有已知的开发组件。 Vue 的初始化注册操作实际上是很耗时的,特别是在自身组件越来越多的情况下,而且对于某些简单的页面来说,这样的做法实际上会造成很多的性能浪费。考虑《开发工具篇》我们提到的一个场景:

D1 发布结果包含了 100 个组件,但是对于某一生成页面而言,只需要对 2 个组件进行重复渲染。

因此我们需要进行按需加载以及按需初始化注册,避免整体组件的加载及初始化注册,以此提高性能。

组件的按需加载

在《开发工具篇》中我们已经通过 sis 拿到了我们依赖关系表,同时也对每个组件的编译进行了拆分,因此要达到组件的按需加载需求实际上是比较容易的,如图所示:

ZRzayiZ.png!web

其执行过程为:

  1. 客户端请求对应的页面数据
  2. 根据页面 ID 调用相关接口或数据库 / 缓存获取得到对应的页面与组件的数据信息(步骤 1-2)
  3. 根据组件的信息查找依赖关系表,获取得到组件代码的加载路径(步骤 3-4)
  4. 加载对应的加载路径,获取对应的组件代码并进行返回(步骤 5-6)
  5. 动态执行对应的组件代码,并对 Vue 实例进行注册,并调用 renderToString 方法获取对应的 HTML 信息
  6. 返回 HTML 信息给客服端

其用代码描述大致如下:

ruAFzam.png!web

值得说明的是,由于 sissis-ssr 是以一个公共服务来进行地设计,各个团队对应的编译产物是存放在云端的对象存储之中。这样,其他团队使用此平台就不需要(也不应该)涉及到 sis-ssr 的部署及重启。因此,对于页面的组件代码获取而言是需要依靠 downloader 来进行本地 / 远程加载的。

组件代码的缓存

由于整个系统涉及到了组件代码的动态化加载,因此 downloader 的性能也会一定情况下影响单请求单页面的渲染信息返回速度。通过《开发工具篇》我们可以知道,使用合并优化后仍然会有一些公用依赖代码,但实际生产的运行过程中,公用代码的缓存使用率会比较高,因此这个时候我们可以在 downloader 内部增加缓存。另外,除了 Node 自身的内存缓存外,我们还可以增加一层文件缓存,尽可能的保证加载的性能(例如服务重启后的加载性能)。对于内存缓存而言,我们一般使用 LRU,以此来帮助我们主动清理 HIT 数量比较低的缓存内容,其代码如下:

eMVRfuf.png!web

当然,整个过程中你还可以使用 Promise.all 进行并行加载来进一步提高对应的组件代码的加载效率。

页面缓存

除了组件代码的缓存外,在正常的实际运行中,我们一般还需要增加页面的缓存,在这里我们同样使用 LRU 和文件缓存来达到这一目的,代码如下:

67RfI32.png!web

简单页面的压力测试

现在我们来对一个 Hello 页面 进行简单的压力测试,其渲染的组件代码为一个简单的子组件的嵌套,如代码所示:

UbiAFjU.png!web

sis-ssr 对比的实现为 vue2-ssr-example , 两者依赖库 / 框架均为:

[email protected]
[email protected]
[email protected]

测试的服务器配置为:

  • 阿里云 - 计算型 - 4 核 8G

注意,在此压力测试中 sis-ssr 去除了页面缓存,仅保留了组件代码的缓存,页面的组件信息获取写死在代码之中,两者的渲染执行逻辑基本一致。以 pm2 start index.js -i 4 的方式启动并进行压测,其最终结果为:

2EJNjay.png!web

结果分析

我们可以看到在总共 2000 个请求 300 个并发的情况下 sis-ssr 相比 vue2-ssr-example 的整体渲染性能会好很多,甚至高于 100%!可能有读者会有疑问:“按照分析来看,难道不是应该 vue2-ssr-example 性能会更高或者相差不大么?”,实际上确实应该如此,其拖慢 vue2-ssr-example 性能的最重要原因其实是 Webpack 编译时的逻辑。

我们知道对于 Webpack 等前端编译工具打包而言,其会按照对应的配置进行对应的代码 ES5.1 之类的语法编译及 polyfill 引入的优化,而对于 sis 来说,我们在打包 SSR 时并没有进行相关的优化,尽可能让对应的编译的代码结果足够干净,由于 sis-ssr 跑的代码大部分都是 Native 的语法和原生方法,相比 vue2-ssr-example 产出的代码来说会有不小的提升。

假设我们将这些部分进行类似 sis 的优化,那么实际上压力测试结果会比较相近。在 Hello 页面 的压测之中彼此的性能差距在 3% 左右。

从这个例子我们可以看出,对于性能优化而言是需要从细小处做起,多个细小处做到极致合起来就能得到意想不到的提升。

一般页面的压力测试

但在实际项目中,我们往往没有那么简单的页面,反而会有更复杂的组件嵌套以及调用关系。在这个压力测试中我们引入 ElementUI 中几个组件来进行比较,组件如代码所示:

QJNf6jF.png!web

注意,此次压力测试中我们仍然不修改 vue2-ssr-example 的相关编译配置,其最终测试结果为:

AJbay22.png!web

结果分析

我们可以看到 sis-ssr 相比较于 vue2-ssr-example 来说仍然有 20% 的性能提升,这是符合我们的预期的,因为从 ElementUI 编译得到的代码后我们可以看到,实际上 sis 引入的代码中有相当多的代码已经被预编译成 ES5.1 并加入了对应的 polyfill 了,所有的执行热点基本上是由于 ElementUI 自身逻辑造成的,因此 sis-ssr 相对 vue2-ssr-example 的提升就不会像 Hello 页面 那么明显了。

总之,基于 sissis-ssr 的 SSR 服务在实际生产的页面渲染场景相对于目前大部分其他实践方案来说是有比较可观的性能收益的。

这里的 vue2-ssr-example 的压测结果相比 Hello 页面 测试结果来说降低得并不是特别多,其原因在于:实际上我们引入的 ElementUI 组件还是比较简单的组件,相比 Hello 页面 而言增加的执行逻辑并没多太多,但 sis-ssr 因为以上讨论原因下降会比较明显。以上内容均可以通过 NodeProfile 工具分析得出。

超时、限流与降级

在实际生产中,服务高性能当然是值得高兴,但如果是没有稳定性的高性能,那么实际上就没有那么让人愉悦了。在 sis-ssr 中为了保证对应的单机服务的稳定性,我们分别采取了三个策略,其分别是:

  • 超时策略
  • 限流策略
  • 渲染降级策略

sis-ssr 中实际上会出现不少的异步请求的情况,例如 downloader 的远程加载组件代码。对于这些服务端的异步请求,我们一般都会强制考虑请求的超时处理,防止请求长时间被挂起造成的问题,例如:

Abi6BjJ.png!web

其次,为了保证我们的单机服务不被外部流量冲击,我们也需要加入限流的策略。其中比较常用的限流策略包括:固定窗口算法、滑动窗口算法、漏桶算法以及令牌桶算法等。在 Node 中有存在基于 redisexpress-rate-limitNPM 包帮助我们完成相关的逻辑。

最后 sis-ssr 为了应对各种请求 / 流量异常的情况还做了多级的降级策略:

Node
Node

至此由上至下进行兜底,以此保证服务的可用性。除此之外,由于 Node 的单进程单主线程(在这里我们排除 I/O 异步等事件循环中的子线程)且页面渲染是纯 CPU 操作的特性,其在渲染大页面时经常会出现阻塞运行时主线程的情况。因此我们可以创建包含一定数量工作线程的线程池(使用 Nodeworker_thread ),然后将对应的页面渲染放置在 Worker 工作线程之中,当线程池中无空闲工作线程时,Master 线程进行主动的页面降级渲染以此来提高对应的性能。此功能已在 sis-ssr 中得到了实现并取得了极好的实际效果,其压力测试结果如图所示:

uyiuuaz.png!web

此次压测的各环境不变,同时测试对象主要是 Hello 页面 。从结果中我们可以看到,搭配了主动降级 + Worker 的渲染方式相比后两者有非常大的提升,其原因也很简单,因为大部分请求由于 Worker 不空闲均被降级为牺牲首屏并将数据写入页面,由客户端渲染的方式了(在上面的压力测试下,大约有 10% 左右的请求是真正的 Node 渲染,其余的都走降级页面了)。

在这种情况下,由于 Google 已经支持同步的前端渲染页面的收录,所以降级渲染请求并不会影响到 SEO。那么对于内容到达时间(time-to-content)呢?我们可以做一个粗糙的计算(实际请以数据日志为主),在我们 只关注于渲染时间且单机单进程 情况下,假设并发 10 个请求,Node 端每次渲染耗时 100ms,那么完全以 Node 端渲染来说,最后一个用户的内容到达时间为 1s。若考虑降级,降级页面的渲染时间为 50ms,则最后一个用户获得页面 HTML 的时间为 350ms,若静态资源加载加上同步的前端渲染页面耗时小于 650ms,则相对于完全 Node 端渲染的方案有提升。同时我们需要注意,随着并发数的增加,此提升会越来越高。若并发数提升为 20 时,同样的计算后我们可以得到完全 Node 渲染的方式最后一个用户的内容到达时间为 2s,而降级页面最后一个用户获得页面 HTML 的时间为 750ms,若静态资源加载加上同步的前端渲染页面耗时小于 1250ms 则具有对应的提升。

总结与期待

在本章我们较为详细的探讨了 sis-ssr 的一些内部逻辑,同时与 vue2-ssr-example 进行了单机的压力测试的比较并分析了对应的原因。同时我们也讲述了 sis-ssr 是如何保证生产环境的高可用性的。在下一篇中我们会从这些细节脱身,从更全局和整体的架构角度来看待整个动态化 SSR 服务。敬请期待《深入浅出动态化 SSR 服务(之三) - 架构篇》。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK