前端 SSR 系统设计思路谈
在过去的几个月里,除了写后端接口以外,大部分时间都在搞一些前端基础建设、技术方案的确立和整个链路的监控告警体系的搭配,而在整条链路中,SSR 起到了比重很大的一环。
有许许多多的文章都致力于教大家:怎么样去做一个 SSR 的 demo(包括一些库的文档),而作为系统的一环,除了怎么开发外,有许多更现实的问题等着我们去解决,而本文就以我们遇到的一些问题来抛砖引玉。
为什么我们需要 SSR
说回历史,Web 最初从一个 MVC 发展到 SPA,再发展成 SSR,仿佛印证了时尚是一个环,兜兜转转结果还是你。从某种角度上来说,确实是一种螺旋式上升——
- MVC:后端 Hello World 级别的小伙伴们多半看到过,比如 PHP、JSP 这类 MVC 的开发体系,前端如果也懂对应语言,那么就能开工了。
- SPA:我们成功的将前端和后端分离开来,大家不用在一起开发了,从系统解耦的角度考虑,这是一次很大的突破。
那么,既然前后端都解耦了,为什么我们仍然开始需要一个服务端来渲染我的 html 呢?
这里就谈到了两个问题,第一个叫 SEO,第二个叫首屏渲染。
SEO
SEO 的历史可以追溯到 1997 年,彼时,读本文的各位小伙伴们甚至可能都没有出生。它的全称是 Search Engine Optimization,伴随着搜索引擎的诞生,网站针对搜索引擎的优化也成了随之而来的问题。
对于大多数没有真正建过网站,而只在公司做开发的同学来说,这个词可能只存在于文档中,知道好像有这么个东西,而 SSR 是为了解决这个问题;但是对于更多建过网站的「站长」来说,SEO 本身就是一个和搜索引擎斗智斗勇的史诗。
如果真的要说起 SEO 来,就我所熟知的历史,同样也可以写一篇文章来仔细说说,但这里暂且不用太多的篇幅再介绍整个互联网网站的发展史,简单介绍一句 2000 年后流传的圣经:「内容为王、外链为皇」以表对一个时代的敬意吧。
扯远了,这篇文章的关键并不是阐述我怎么做内容,而是我怎么通过技术的手段来让我的内容得到更好的曝光,这也是一个技术对 SEO 能够产生的能量。
那么我们究竟为什么需要 SEO 呢?很明显的一点是,搜索引擎去掉竞价排名外,就是一个白嫖的流量入口,SEO 做的本质就是怎么让你占据更多关键词,让你的排名更靠前,就能获得免费的流量。在降本提效元年,这是一件多么诱人的事情,即使是大公司,资金投入也是能省则省。
现在,大家可能对 SEO 有了个初步概念,但是问题是——这和我的 SSR 有什么关系呢。
我们现在来想另一个问题:如果你需要写一个高性能的通用爬虫,你可能会怎么处理:
- 一个 IP 池 + 多进程、多线程
- 从首页开始,探索 DOM 中的每一个 a 标签,进行递归,然后保存数据
这里需要强调通用,是因为平时我们所做的获取某网站数据的爬虫,可能会基于它是走接口还是渲染进首屏 DOM 进行定制化的数据获取开发,但如果我是一个搜索引擎爬虫,很明显我只是快准狠的找特征值,不会浪费时间在网站定制上。
现在你知道了一个关键点:我需要解析 DOM,探索标签。因此我们需要用 SSR 来让我的 html 有数据可以抓——而不是 CSR 模式下的无 body 内容模式。
<html>
<body><div id="app"></div></body>
</html>
尽管 Google 的爬虫目前变得高端了起来,也学会了跑一些 JavaScript,但是据说也不会等待数据获取完成,只会跑同步脚本——无论如何,系统可控性也是我们要考虑的一点,当我们说搜索引擎的时候,同样说的也不只是 Google,从这一点考虑,SSR 也是我们必须的一部分。
写到这里,我已经发现光是 SEO,已经能拉出来单独说一篇文章了,所以这里对于「我实际要塞什么数据进去来做 SEO」暂且不表,只点到为止。
首屏渲染
前面花了很多笔墨来科普 SEO,但是对于前端小伙伴来说,更熟悉的可能是一些性能指标(当然,实际上 SEO 也会根据性能指标来为你的网站打分,也有一定优化作用,这个如果有后续文章可以后续介绍),首屏时间自然也是其中一环。
同样的,本文也不是来介绍性能优化的,所以这里不对具体性能指标的定义、测量做过多的介绍,这一部分前端小伙伴们也已经很熟悉了,所以整体篇幅会比前面 SEO 要短许多(应该)。
如果你是不了解性能指标的同学,这里简单解读下:对于首屏来说,我们撇开定义和数值,从用户的角度出发,思考这样一个场景,对于用户来说,走 CSR 和 SSR,分别在什么情况下能够看到有效的数据:
- CSR 的场合,先把无内容的 html 返回,然后跑 JS,执行 fetch,等 fetch 结束,再进行 render,最终 loading 结束,用户看到了这个页面的内容。
- SSR 的场合,必要的首屏数据已经优先写进了 html 中,当 html 下载完成后,整个页面就基本处于可用的状态(如果你的页面去掉 JS 同样也能用的话),可能图片没有加载完,但是整体的网页结构、网站内容都已经出现了,用户可以一下子看到网站的内容。
理想情况下,SSR 的用户体验当然要更好一些(这里也可以看出,性能指标并不是空穴来风,也不是急功近利的,一切都是站在用户体验的角度出发思考的),但是这里为什么说的是理想情况,之后就会向大家说明了。
SSR VS SSG
做 SSR 的时候很容易遇到的一个问题是——一些搞不清楚 SSR 和 SSG 的区别的小伙伴可能会跑过来问你「为什么不使用 SSG」、「你说的性能瓶颈我怎么从来没有遇到过」、「你为什么要用掉这么多 CPU 资源,我的就没有」。
很明显,说这样话的人就是没有分清楚 SSR 和 SSG 真正发挥作用的场景,本着知其然也要知其所以然的态度,「礼貌探讨」一下(取决于对方礼貌与否):你知道 SSR 和 SSG 的区别吗?
如果能够回答我:SSG 是静态的去生成一个 html,而 SSR 是动态的随着请求去渲染资源,那我们的征程就可以开始了。
如果了解 SSG 的话,你会发现这完全就是两个东西嘛。如果我使用 SSG 去直出页面,那么实际上生成完页面,我可以往 CDN 上一丢,妥妥的静态页面。而这也不是说 SSG 的页面就一定得是亘古不变的,事实上,如果是定时的、固定的数据集,那么 SSG 同样可以节约资源,并且做到上文我们想要解决的两个核心基本点——很简单,跑个 job 就行了嘛,甚至假设有一些千人千面的资源,它不需要在首屏出现,那么我们其实也可以用 SSG 了。
而 SSR 的职责却完全不同,它把首屏中所需要的接口请求都放在了自己的服务中,因此我们可以很轻松的应对千人千面和实时性要求极高的场景,做好上述两个基本点(实际上当你打开 bilibili 的时候,整个首页可能都是由算法拼接而成的,这种时候就只能采用 SSR 的策略)。
可以说,针对 SSR 和 SSG 的讨论,脱离了业务场景的对撕是没有意义的,谁都不是一颗银弹,只有结合了业务去分析,你才能知道在什么场景下去用什么东西。
当然,相较之下,站在整条链路上去考虑,SSG 要简单轻松不少,因此同样的,我也不会多花时间来介绍 SSG 的相关内容。
SSR 该怎么开发
文章开头也说了,市面上有大量的文章去介绍:我怎么去开发一个 SSR 应用,但为了本文的完整性,这里也会简单介绍一下 SSR 的开发流程(以 Vue3 + Vite)为例。
从 demo 说起
关于 demo,在 Vue 和 Vite 的文档里写的还是比较明白的,至少写个 demo 不成问题:
- Vue SSR 文档:https://cn.vuejs.org/guide/scaling-up/ssr.html
- Vite SSR 文档:https://cn.vitejs.dev/guide/ssr.html
对于(一个 demo 的)开发阶段来说,我们只要注意生命周期的执行,善用 useSSRContext
就可以了,所有的注意事项都写在了 Vue 和 Vite 的文档里。
实际上,在这个 part,更想讨论的问题是——我究竟该怎么选择一个 SSR 框架,或者我应不应该用一个 SSR 框架。
对于 Vue 来说,它本身就给了几个选项:Nuxt
、Vite Plugin SSR
,对于一个大型的,需要持续迭代的 C 端项目来说,使用这种开源的高度封装框架可能并不是一个好的选择(尤其是做的时候 Nuxt 还处于 beta 版),对于有一定研发能力、且项目规模足够的团队,基于底层去进行上层封装可能是个更好的选择,还是那句话,「可控」在一个大型系统中很重要。
工程化
即使是浅尝辄止的 demo,如果要真的部署上线,不可避免的依旧会遇到前端工程化的问题,即使本章讲的是 demo,我们依旧会介绍下工程化视角下我们的整个 pipeline 需要怎么处理。
撇去原始社会的「把文件直接推进虚拟机,然后 run」这种简单粗暴的模式以外,在 docker 社会下,我们还有哪些选择呢?
第一种最直观的方案是:构建完整的文件列表后启动,直接把全量文件压进容器启动,这种方案非常的服务端思想,SSR 容器本身是一个完全独立的闭环系统,可以以此来进行灰度、回滚,容器本身是个非常好的资源维度。但是这样我可能无法保证 CSR 和 SSR 跑的一定是同一份代码。
第二种方案是:将我的 CSR 与 SSR 结合起来,SSR 只构建 server,而剩下的资源都是 CSR 的构建中产生的,这样的优点是我可以保证运行代码的版本始终是可预期的,肯定是同一份。
可能有人会问的一个问题是:上面提到的我的运行版本可预期,是同一份,为什么我需要进行这样的保障机制?
降级
这里引入了我们实际开发和 demo 差异的第一个问题,降级。
在实际开发中,我们不能保证一个服务是 100% 可用的,为了保证呈现给用户的最大可用性,我们会对系统中的每一个服务进行一些常用操作:「容错」、「熔断」、「降级」。对于一个 SSR 服务来说,多引入了一个 SSR,肉眼可见,链路多加了一层,那么如果 SSR 服务出了问题且没有降级的情况,会直接影响到用户(前端服务作为流量入口,其实对于可用性要求极高,只是前端工程师大部分场景下不需要关心),这可不是「一个接口不可用」,而是整个网站变成了 500 的状态,本身在 CSR 的情况下,我直接走静态资源,可以承载的 QPS 有着质的差别(甚至你有可能是直接静态资源推文件存储 + CDN 的),理想的降级情况就是:如果 SSR 服务出现问题,那么降级到直接访问静态资源(CSR 渲染)。毕竟我们引入 SSR 只是为了优化流量和用户体验,如果因为 SSR 服务出现问题,导致这一部分流量「被优化掉」了,属于得不偿失。
如果要降级的话,我们就必须要保证:我的 CSR 和 SSR 跑的得是同一份代码,如果版本不一致,就可能会造成更多的意外情况——Boom!
这一保障制度可以是由发布系统提供统一编译能力:一次编译,两个环境发布;也可以是业务把控(方案 2)。
有人又要问了,再怎么摆烂,我人肉保证大概可能也就差一两个 bug fix 的水平,是不是问题不大——如果你的静态资源是上 CDN 的话,问题很大,可能会导致 CSR 的版本推了,但是 SSR 因为跑的版本不一样,所以找不到对应静态资源的情况,发版再次原地爆炸。
当然,我们也可以引入资源检查来确保版本的一致性,总的来说,在这一阶段,选择什么技术方案更像是基于基建能力去进行取舍。
那么,方案 2 是怎么样将 CSR 和 SSR 串联起来的呢——我们需要一个类似于配置中心一类的服务将静态资源进行关联和获取(同时可以管理版本),在容器启动阶段下载(注入)到本地,以此作为启动依据。既然拉的本身是 CSR 构建时编译出来的资源,那么自然可以保证是同一版本了。
除此以外,方案 2 还有哪些优点呢,我们来假设我们的服务规模足够大,现在我有了 100 台容器,我发一次 Web 版本需要更新 CSR 和 SSR 中的对应资源。配置中心的网络 I/O 要比容器发布快太多了。仔细想想,这似乎是个不错的方案?
这当然也是有缺点的,比如上文我们提到了用容器来进行灰度实验,那么如果我们用方案 2,就必须要求配置中心去支持灰度。
同时 SSR 服务也需要在依赖更新时重新打包以安装上最新的依赖,如果依赖变化了,那么方案 2 就显得没有那么美好了,甚至如果检查机制没做好的情况,可能会导致 SSR 服务原地爆炸整体不可用。
服务解耦
如果你仔细想想的话,方案 1 似乎没有办法做到 SSR 和 CSR 服务的完全分离,而方案 2 从系统设计中保证了 SSR 和 CSR 是可以解耦的(毕竟只要使用网络 I/O 下拉资源)。
这里引入了新的问题:解耦是否是必须的。
在现代化设计中,我们习惯于让一个系统或者应用尽可能的小,来减少改动带来的风险,尽管这会带来一些些链路的治理成本,但大部分情况下可能是利大于弊的。而在 SSR 这个场景下,我们是否真的有必要让 SSR 独立于 CSR 代码存在(比如可以是两个 repo 分别维护和管理)。
从整个开发流程中,我们可以看出这一点对于 SSR 应用来说或许是弊大于利的——上文我们讲到了其中一点:「需要同步更新依赖」,在两个应用中肉眼人工同步毫无疑问是痛苦的,在上线前缺乏感知的,尽管我们可以独立开发,但是独立是必要的吗?
我的 SSR 服务本身就应该依附于我的 CSR 而存在,本身就是紧密合作的关系,而在「解耦」的经历中,我们其实会遇到一个很严重的问题:我在 CSR 服务下开发的代码,到了上线时,SSR 环境下是有问题的,SSR 跑不起来,此时我已经堆积了一个版本的代码了,同时也到了提测甚至是上线阶段,一般的同学到了这里已经开始慌了:咋整呀这个。
如果在开发阶段就能使用同款 SSR mode 开发,这样的问题就能相对的少很多(尽管开发中不可避免的会遇到一些 dev mode 和 production mode 不一致的问题,但相对少了很多,也更可控)。同时,我们也只要维护一份依赖清单。表面上来看,这是一个更大型的耦合的系统,但其实整体的维护和治理成本却降低了。
总结
小小总结一下开发阶段的问题:
方案 1:全量资源 | 方案 2:CSR+SSR 整散结合 | |
---|---|---|
优点 | 1. 完整独立可单独运行的系统 2. 可以利用 Docker 特性直接回滚 3. 可以基于容器做灰度 4. 构建包时刻都是最新的(依赖最新) | 1. 可以保证最终一致性 2. 可以利用 CSR 模版检测资源是否有效 3. 发布和回滚更快 4. 支持服务解耦 |
缺点 | 1. 独立运行,因此无法保证最终一致性 2. 无法检测对应的降级是否真正可用(无法保证 CDN 资源一致性) | 1. CDN 无法保证资源访问安全性 2. 网络 IO 可能会导致启动失败 3. 无法针对 SSR 进行资源灰度 4. 可能会忘了更新依赖导致翻车 |
同时,我们也必须注意开发阶段尽可能的都在 SSR 模式下开发(同时也得保证 CSR 模式的可用性)。
当然,在方案 1 到方案 2 之间有大量的留白的灰度空间,大家可以根据自己的实际开发和基建情况进行定制。
如何提高系统吞吐
我们在这里假设,你的服务一定是有人用的,而且还有不少人——接下来,我们就会遇到比 demo 更难的问题,怎么样去负载日益增长的 QPS。QPS(Query Per Second) 这个词对于前端可能有些陌生,简单的来说就是每秒的请求数,而在面试阶段如果前端简历里写了 SSR,我基本上都会问类似的问题,而大部分人对于应对日益增长的体量,最直观的印象是:扩容。
当然,这样就把天聊爆了,站在系统的角度,我们还是得全面的看待问题来解决系统吞吐上的瓶颈,来帮助系统又快又好的运行。
SSR 的本质
在做优化之前,我们先来思考一下 SSR 的本质是什么,前面我们介绍的 SSR,实际上更多的是从业务的角度去解读它是什么。
- CPU 计算:运行 JS,渲染出对应的 DOM 结构
- 网络 IO:获取首屏所需接口数据
当然截至目前来看,我们的 SSR 服务大多数时候瓶颈都在 CPU 上,这也是它有别于我们其他后端服务的地方——对于一个业务系统中的大多数服务都是 IO 密集型的应用,很少有 CPU 密集型的应用,这也会给我们带来一些额外的考虑。
网络 IO
先从简单的说起,对于网络 IO 来说,我们自然是希望节省整个请求过程中的耗时——上文中我们介绍了「理想情况下,SSR 的用户体验更好,因为首屏会先出来」,但是如果在内部网络 IO 花费了太多时间,那么对于用户来说,白屏的时间变得更好藏了,反而成了一种负优化。
针对这一点,其实很好解决,在 IO 密集型的应用中,我们有过太多的经验:
- 使用内网请求接口:这一点就是字面意思
- 尽可能的减少网络包体积:这一点需要提供接口的后端与前端沟通共同努力,协商一个最好的方式(包括但不限于只下发必要的字段、使用 grpc 等)
- 有效利用缓存:利用缓存本质上是一种常用方式,但在这种场景下,可能需要思考「我要不要上缓存」和「我怎么上缓存」。
为了避免了频繁建立和销毁连接的开销,我们可以使用 keepalive 来避免极端情况的发生,比如我们可以使用 httpAgent
来保证最大 sockets 数,避免保证异常流量的情况整个系统的安全性。
请注意,开启 keepalive
需要保证请求的另一端支持 keepalive
,你可以通过下文测试是否支持 keepalive
:https://stackoverflow.com/questions/4242145/how-to-test-http-keep-alive-is-actually-working
在前端应用中,我们可能也会设置 timeout
,但必须区分清楚的是:timeout
的时间到底是从哪里到哪里的呢——在大多数设置中,实际上,他是从建连开始算的超时时间,这里就与 SSR 的场景有所冲突了。
我们在上面实际上准备了一个连接池,但如果请求过大,没排上号,这个请求也应该到点取消,这是由上下游整体的时间决定的,因为在我们的 SLB 层(也就是 nginx),也必然会配置一个超时时间,他可能是 1s,也可能是 800ms,在时间到了之后,没排上号的就应该直接原地取消了。(实际上即使没有池,在链路中我们同样有可能遇到这个问题)
口头上总结一下,我们就知道,我们其实迫切需要一个基于每一个请求生成上下文的 fetch
方法,它的实现可以是任意的,但至少他需要支持取消请求。这极其类似于 Golang 中的 context.WithTimeout
。
这里就会遇到另一个老生常谈的问题,对于 Node 服务来说,全局变量是会共享的,和只会针对端用户生效的 CSR 不一样,所以对于这个场景,我们需要针对每个请求去新建实例并使用。
在 Vue 中,我们可以利用 Vue 在 SSR 中的单独实例加上 inject / provide 去做一个基于请求 fetch
。这一点会随着业务规模的增长(和受流量攻击频率的增长)越发重要,也有助于设计出一个符合服务端思想的系统。
CPU
再来考虑一下 CPU 计算的问题,CPU 是我们平时不太容易遇到的一个课题,站在原理的角度,就是「优化和改进算法」,让整个 DOM 结构尽可能快的生成;生成的核心算法本质上被封装在了你所用的方法内部(比如 Vite),因此我们所能做的一个点是,尽可能的去精简我们首屏的内容结构,让 CPU 的每个点都用在刀刃上,如果无效的 DOM、数据过多,毫无疑问就提高了渲染的开销。
因此我们这里总结一下,CPU 的优化上,能做的可能有:
- 使用尽可能快的生成方案
- 优化 DOM 结构和包大小
当然,也有可能是你的业务代码本身里面有一些时间复杂度比较高的代码,这也是可以优化的点。
要想知道我们的系统主要资源开销在哪里,可以「不服跑个分」,使用 Nodejs 的相关工具生成火焰图:
- https://nodejs.org/zh-cn/docs/guides/simple-profiling/
- https://nodejs.org/zh-cn/docs/guides/diagnostics-flamegraph/
简单总结一下如何使用:
- 启动:
node --prof index
以采样的模式启动程序,会生成 log 文件。 - 生成火焰图:
node --prof-process --preprocess -j isolate*.log | npx flamebearer
生成完火焰图之后,我们可能还需要读懂火焰图,然后才能知道我们的系统瓶颈究竟在哪里,对于火焰图怎么读,也有很多篇文章介绍(甚至阮老师也有介绍过),在这里只贴链接,如有需要,在未来的文章中可能在详细介绍吧:
- https://www.ruanyifeng.com/blog/2017/09/flame-graph.html
- https://zhuanlan.zhihu.com/p/73385693
- https://www.infoq.cn/article/a8kmnxdhbwmzxzsytlga
另外的,也会有人提出:我们是不是可以对 SSR 进行缓存,无论是页面级的缓存还是组件级的缓存——对于一个通用的方案来说,这是一个比较张口就来的解决方案。确实,他能有效的减少 CPU 的开销,但是无论如何,「缓存」这个设计一定得针对具体的业务模型去决定的:我的业务是否对实时性有一定要求;我的缓存粒度是什么,超时时间是多少;甚至我做了缓存之后,我的缓存命中率究竟是多少,一味的只是说上缓存是没有任何意义的。——更何况缓存同时会影响你的整个开发模型,可能会引入额外的开发成本,也同样不是银弹。
针对这种场景,我们也可以结合上文描述的 SSG,以及考虑你的首屏究竟需要什么来进行合理的取舍,最终得到一个适合自己的方案。
更进一步:系统的诞生
很显然,对于一个系统来说,SSR 必然不是孤立存在的一个服务,需要结合整条链路去进行服务的保障,上面其实我们已经提到了一些服务的防御、降级能力,但是还有一些通用能力需要安全上,这一点,可以结合自身的基建去配置来保障自身及时发现异常、以及在出现异常时不影响下游服务:
- 监控告警
- 限流限频
- 逐层降级
这一趴要详细说的话太多结合公司的基建了,因此在这里不做展开了。但是推荐大家在做一个服务的同时了解自己的上下游,方便排查问题。
总结
本文大致总结了一下我们在实际开发过程中遇到的一些挑战,当然,随着体量的增长毫无疑问会有一些新的挑战出现,而上文中也不断提到一点,那就是「结合实际业务场景和基建情况」,否则就变得话不投机半句多了,因此本文只是抛砖引玉,顺便科普。
植入部分
如果您觉得文章不错,可以通过赞助支持我。
如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。
若只想优化首屏渲染,SSR 带来的运维压力和成本已经抵消首屏优化减少的时长。
保证SSR和CSR是同一份代码->
我们用的是同构方案+脚本推送静态资源,做SSR->CSR->静态文件CSR的两层降级。
这只是一个降级方案,并不能保证真的是同一个版本吧