Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remix实战系列 - Streaming SSR如何提升13%的业务指标 #26

Closed
lili21 opened this issue Mar 23, 2023 · 0 comments
Closed

Remix实战系列 - Streaming SSR如何提升13%的业务指标 #26

lili21 opened this issue Mar 23, 2023 · 0 comments

Comments

@lili21
Copy link
Owner

lili21 commented Mar 23, 2023

背景

MLBB战报是一个为MLBB的玩家提供每周游戏数据总结的H5页面。战报只有一个页面,每个模块为一屏,用户通过上划来查看不同模块的数据信息,比如段位、英雄等。

image

image

image

虽然经过上一次的SSR改造, 战报页面性能有了极大的提升 - LCP减少70%,7s -> 2s。但也带来了新的问题

问题

服务可用性偏低

讲人话就是页面不可访问的概率偏高,平均每天能收到30个告警

性能劣化

随着需求迭代,周报内容越来越多,由原来的3屏变成了5屏。依赖的接口和下游增多,导致性能劣化。

从第一版SSR优化后的2.3s左右,到现在劣化至2.7s左右

原因分析

战报只有一个页面,每个模块为一屏,用户通过上划来查看不同模块的数据信息,比如段位、英雄等。

改造为SSR后,会在服务端获取所有数据,然后生成HTML返回

// server-side
const 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-side
const datas = await Promise.all([
  getUserInfo(),
  getReport(1)
])

const html = renderHTML(datas);

res.send(html);


// client-side - xxx

这样可以解决我们的问题,服务可用性提高,首屏性能只依赖首屏的接口。但我们关注首屏性能,不代表我们只关注首屏性能

这样做的缺点是其他屏的渲染又回到了CSR模式,也就有CSR模式的问题 - 网络请求瀑布流,导致其他屏的渲染时机被推迟,影响用户的整体体验

image

是否能做到既要又要呢?

解决方案 Streaming SSR

Streaming SSR这个说法可能比较新,但是这个理念和实践最早可以追溯到十几年前。比如Facebook早在2009年就公开了一种叫「BigPipe」的方案,其实就是Streaming SSR。感兴趣的可以参考 BigPipe: Pipelining web pages for high performance

原理解析

传统的SSR就是在服务端生成整个HTML,一起返回。而Streaming SSR就是在服务端分开生成HTML,分开传输HTML

// server-side - 传统SSR
const 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 SSR
Promise.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%

image

TTFB周环比下降约19%

image

LCP周环比下降约10%

image

页面打开率提升13%

image

遇到的问题

缓冲区导致Streaming失效

遇到的主要问题就是网络代理层可能会导致HTML分开传输失效,导致性能层面的提升失效。以我们的服务为例,服务是通过Goofy Stack部署的,底层是FaaS,网络请求会经过FaaS的网关层

image

预期的效果:服务返回的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重新渲染一遍。

image

相关issues

参考链接

@lili21 lili21 changed the title WIP: Streaming SSR实战 Remix实战系列 - Streaming SSR如何提升13%的业务指标 Apr 1, 2023
@lili21 lili21 closed this as completed May 8, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant