Remix实战系列 - Streaming SSR如何提升13%的业务指标
2023-03-23
背景
MLBB战报是一个为MLBB的玩家提供每周游戏数据总结的H5页面。战报只有一个页面,每个模块为一屏,用户通过上划来查看不同模块的数据信息,比如段位、英雄等。
虽然经过上一次的SSR改造, 战报页面性能有了极大的提升 - LCP减少70%,7s -> 2s。但也带来了新的问题
问题
服务可用性偏低
讲人话就是页面不可访问的概率偏高,平均每天能收到30个告警
性能劣化
随着需求迭代,周报内容越来越多,由原来的3屏变成了5屏。依赖的接口和下游增多,导致性能劣化。
从第一版SSR优化后的2.3s左右,到现在劣化至2.7s左右
原因分析
战报只有一个页面,每个模块为一屏,用户通过上划来查看不同模块的数据信息,比如段位、英雄等。
改造为SSR后,会在服务端获取所有数据,然后生成HTML返回
// server-sideconst datas = await Promise.all([ getUserInfo(), getReport(1), // 首屏 - 总结模块 getReport(2), // 第二屏 - 段位 getReport(3), // 第三屏 - 常用英雄 getReport(4), // 第四屏 - 名人堂 getRport(5) // 第五屏 - 尾页])const html = renderHTML(datas);res.send(html);
这么做两个问题
任意接口报错,都会导致页面无法访问
假设下游接口SLA为99.9%,我们服务的SLA就降到了 99.9^6 = 99.4%
首屏性能受最慢接口影响
假设首屏接口200ms就返回了,但第四屏接口花了600ms,想想看我们的页面要等多久才能渲染?首屏性能为什么要受非首屏内容影响?
解决方案 SSR + CSR
我们是不是可以首屏用SSR,非首屏用CSR?
// server-sideconst datas = await Promise.all([ getUserInfo(), getReport(1)])const html = renderHTML(datas);res.send(html);// client-side - xxx
这样可以解决我们的问题,服务可用性提高,首屏性能只依赖首屏的接口。但我们关注首屏性能,不代表我们只关注首屏性能
这样做的缺点是其他屏的渲染又回到了CSR模式,也就有CSR模式的问题 - 网络请求瀑布流,导致其他屏的渲染时机被推迟,影响用户的整体体验
是否能做到既要又要呢?
解决方案 Streaming SSR
Streaming SSR这个说法可能比较新,但是这个理念和实践最早可以追溯到十几年前。比如Facebook早在2009年就公开了一种叫「BigPipe」的方案,其实就是Streaming SSR。感兴趣的可以参考 BigPipe: Pipelining web pages for high performance
原理解析
传统的SSR就是在服务端生成整个HTML,一起返回。而Streaming SSR就是在服务端分开生成HTML,分开传输HTML
// server-side - 传统SSRconst datas = await Promise.all([ getUserInfo(), getReport(1), // 首屏 - 总结模块 getReport(2), // 第二屏 - 段位 getReport(3), // 第三屏 - 常用英雄 getReport(4), // 第四屏 - 名人堂 getRport(5) // 第五屏 - 尾页])const html = renderHTML(datas);res.send(html);
// server-side - Streaming SSRPromise.all([ getUserInfo(), getReport(1),]).then(datas => { const html = renderHtml(datas); res.send(html);}) // 首屏getReport(2).then(data => { const html = renderHtml(data); res.send(html);}) // 第二屏 - 段位...
从代码逻辑可以看出,Streaming SSR实现了我们既要又要的目标 - 既保证了首屏性能和服务可用性,又没有推迟非首屏数据的加载时机,保证了非首屏的用户体验
实战
React 18已经支持了Streaming SSR,推荐大家直接使用框架来体验,如NextJS或Remix。这里给大家展示下Remix框架中的用法
Remix中每个路由组件都可以定义一个loader函数,用来做数据请求(函数执行在服务端)
import { json } from '@remix-run/node';import { useLoaderData } from '@remix-run/react';// loader 获取数据export const loader = async () => { const datas = await Promise.all([ getUserInfo(), getReport(1), // 首屏 - 总结模块 getReport(2), // 第二屏 - 段位 getReport(3), // 第三屏 - 常用英雄 getReport(4), // 第四屏 - 名人堂 getRport(5) // 第五屏 - 尾页 ]) return json(datas);}export default function Page() { // 组件中通过userLoaderData获取loader返回的数据 const [userInfo, firstReport, secondReport] = useLoaderData(); return ( <div> <div>{userInfo.name}</div> <div>{firstReport.win_rate}</div> <div>{secondReprot.level}</div> </div> )}
代码如上所示。那怎么改造既要又要的效果呢?Remix在1.11.0版本中正式加上了Defer API。使用方法如下
import { defer } from '@remix-run/node';// loader 获取数据export const loader = async () => { const otherDatas = Promise.all([ getReport(2), // 第二屏 - 段位 getReport(3), // 第三屏 - 常用英雄 getReport(4), // 第四屏 - 名人堂 getRport(5) // 第五屏 - 尾页 ]) const datas = await Promise.all([ getUserInfo(), getReport(1), // 首屏 - 总结模块 ]) return defer({ datas, otherDatas })}
我们把loader中的请求分成了两部分,一部分是首屏渲染需要的数据,这部分数据我们还是要等他请求完成。一部分是首屏外的数据,注意这里我们没有await,所以loader函数不用等这些接口完成就可以返回数据。
那在组件中要怎么使用这些数据呢?
import { Suspense } from 'react';import { useLoaderData } from '@remix-run/react'; export default function Page() { // 组件中通过userLoaderData获取loader返回的数据 const { datas, otherDatas } = useLoaderData(); const [userInfo, firstReport] = data; return ( <div> <div>{userInfo.name}</div> <div>{firstReport.win_rate}</div> <div>{otherDatas???}</div> // otherDatas怎么用? </div> )}
我们依然使用useLoaderData来获取loader中返回的数据,datas就是普通数据类型,和原来的用法一样。但是otherDatas要怎么使用呢?otherDatas是一个Promise吗?Bingo,otherDatas在这里确实是一个Promise,所以我们需要处理pending,fulfil,reject三种状态,如下所示
import { Suspense } from 'react';import { Await, useAsyncValue, useAsyncError } from '@remix-run/react';function NonFirstPage() { return ( <Suspense fallback={<div>loading...</div>}> <Await resolve={otherDatas} errorElement={<ErrorElement />}> <OtherPages /> </Await> </Suspense> )}function OtherPages() { const [secondReport, thirdReport] = useAsyncValue(); return ( <div>{secondReprot.level}</div> ... )}function ErrorElement() { const error = useAsyncError(); // report it and render fallback content return <div>errror fallback content</div>}
我们是用Suspense来处理pending态。使用remix提供的Await组件,以及useAsyncValue, useAsyncError来处理fulfil和reject态。
业务层面的改造就已经完成了,可以看到Defer API的使用还是非常简单的,改造成本还是很低的
效果
报警数环比下降90%
TTFB周环比下降约19%
LCP周环比下降约10%
页面打开率提升13%
遇到的问题
缓冲区导致Streaming失效
遇到的主要问题就是网络代理层可能会导致HTML分开传输失效,导致性能层面的提升失效。以我们的服务为例,服务是通过Goofy Stack部署的,底层是FaaS,网络请求会经过FaaS的网关层
预期的效果:服务返回的HTML片段,立即返回给浏览器,浏览器收到后就开始渲染
实际的效果:FaaS网关会有一个4KB大小的缓存区,如果返回的HTML片段小于4KB,网关会等待下一个HTML片段,直到超出4KB,才会开始返回给浏览器
虽然这个会影响Streaming带来的性能提升效果,但是服务可用性的提升是实打实的,也不受缓冲区的影响。
React 18
useEffect调用两次
https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-strict-mode
页面中的可视化图表消失,经排查是useEffect调用两次导致的。升级相关依赖后修复
hydration报错
React 18的hydration处理逻辑和17不同,升级后线上有相关报错,报错的组件会降级到CSR重新渲染一遍。
相关issues