当页面渲染遇上边缘计算

3,734

背景

通过优化页面性能,提升用户的体验,一直是我们追求的目标。我们可以通过浏览器缓存、预加载、预渲染等各种方案,来提升页面的访问性能和体验。但在实际业务场景中,有一类页面一直是性能优化的老大难,那就是首跳页面。即用户是第一次访问网站的场景。
对于 web 页面来说,首跳场景(例如 SEO、付费引流)的性能普遍比二跳场景下要差。原因有多种,主要是首跳用户在连接复用,和本地资源缓存利用方面,有很大的劣势。首跳场景下,很多在端上的优化手段(预加载,预执行,预渲染等)无法实施。
在客户端缓存能力无法利用的情况下,利用 cdn 距离用户近的特性,可能是一个性能优化的方向。接下来将介绍几种常见的性能优化方案,并引出我们提出的边缘渲染方案。

思路

思路1 - SSR

为了性能优化考虑,我们一般都会通过服务端渲染(SSR) ,将首屏动态内容直接服务端输出。


这种方式的优点时一次 html 返回即可包含页面主体内容,不需要浏览器二次请求接口后再用 js 渲染。但这种方式的缺点也比较明显,对于距离服务端远,或者服务端处理时间较长的场景,用户会看到较长时间的白屏。而且即使 html 返回完成了,用户并不会立即看到内容,页面还需要加载前置的 js,css 等资源后,才能看到内容。SSR 模式下的渲染时空图如下:

思路2 - CSR + CDN

为了减少白屏时间,考虑利用 CDN 的边缘缓存能力,可以把页面 html 直接缓存在 cdn 节点上。但对于大部分场景来说,页面的主体内容都是动态,或者个性化的,把全部 html 内容缓存在 cdn 上对于业务影响较大,很有少场景能接受。那么换个思路,只把 html 静态部分缓存在 cdn 上呢?其实这个思路也是一个很常见的操作,即把 html 的静态框架部分缓存在 cdn 上,让用户能快速看到部分内容,然后再在客户端发起异步请求,获取动态内容并且渲染(CSR)。CSR + CDN 模式下的渲染时空图如下:


这种方式的优点是页面静态框架缓存在 cdn 上,用户可以快速看到页面框架内容,减少白屏等待焦虑。缺点是完整的页面内容需要再执行 js ,拉取异步接口回来后再进行渲染。最终有意义的动态内容展示出来的时间,比 SSR 更晚。

思路3 - ESI

CSR + CDN 的方式,很好地解决了白屏时间问题,但带来了动态内容展示的延时。之所以有这个问题,是因为我们把页面的动态内容和静态内容分割到了两个阶段中,并且是串行的,而且串行过程中还穿插了 js 的下载和执行。有什么办法把动态内容和静态内容在 CDN 上整合起来呢?

ESI(Edge Side Include) 给了我们一个很好的思路启发,ESI 最初也是 CDN 服务商们提出的规范,可通过 html 标签里加特定的动态标签,可让页面的静态内容缓存在 cdn 上,动态内容可以自由组装。ESI 的渲染时空图如下:


这个方案看起来很美好,可以把静态的部分缓存在 CDN 上了,动态部分在用户请求时会动态请求和拼接。但最关键的问题在于,ESI 模式下,最终返回给用户的首字节,还是要等到所有动态内容在 CDN 上都获取和拼接完成。也就是并没有减少白屏时间,只是减少了 CDN 和服务器之间内容传输的体积,带来的性能优化收益很小。最终效果上与 SSR 区别不大。

虽然 ESI 的效果不符合我们预期,但给了我们很好的思考方向。如果能把 ESI 改造成可先返回静态内容,动态内容在 CDN 节点获取到之后,再返回给页面,就可以保证白屏时间短并且动态内容返回不推迟。如果要实现类似于流式 ESI 的效果,要求在 CDN 上能对请求进行细粒度的操作,以及流式的返回。CDN 节点上支持这么复杂的操作吗?答案是肯定的:边缘计算。目前一些 CDN 服务商已提供完善的边缘计算能力(cloudfare已经支持,alicdn 也已有内测版本支持,并即将对外开放),我们可以在 CDN 上做类似于浏览器的 service worker 的操作,可对请求和响应做灵活的编程。

基于边缘计算的能力,我们有了一种新的选择:边缘流式渲染方案。方案详情如下

方案 - 边缘流式渲染(ESR)

渲染流程

方案的核心思想是,借助边缘计算的能力,将静态内容与动态内容以流式的方式,先后返回给用户。cdn 节点相比于 server,距离用户更近,有着更短的网络延时。在 cdn 节点上,将可缓存的页面静态部分,先快速返回给用户,同时在 cdn 节点上发起动态部分内容请求,并将动态内容在静态部分的响应流后,继续返回给用户。最终页面渲染的时空图如下:

从上图可以看出,cdn 边缘节点可以很快地返回首字节和页面静态部分内容,然后动态内容由 cdn 发起向 server 起并流式返回给用户。方案有以下特点:

  1. 首屏 ttfb 会很短,静态内容(例如页面 Header 、基本结构、骨骼图)可以很快看到
  2. 动态内容是由 cdn 发起,相比于传统浏览器渲染,发起时间更早,且不依赖浏览器上下载和执行 js。理论上,最终 reponse 完结时间,与直接访问服务器获取完整动态页面时间一致。
  3. 在静态内容返回后,已经可以开始部分 html 的解析,以及 js, css 的下载和执行。把一些阻塞页面的操作提前进行,等完整动态内容流式返回后,可以更快地展示动态内容。
  4. 边缘节点与服务端之间的网络,相比于客户端与服务端之间的网络,更有优化空间。例如通过动态加速,以及 edge 与 server 之间的连接复用,能为动态请求减少 tcp 建连和网络传输开销。以做到最终动态内容的返回时间,比 client 直接访问 server 更快

demo 对比

目前在 alicdn 上对主搜页面做了一个 demo (edge-routine.m.alibaba.com/)(因为 demo 页面可能会频繁), 下面是在不同网络(通过 charles 的 network throttle 配置限速)情况下,与原始页面的加载对比:

  1. 不限速(wifi):

  1. 限速 4G

  1. 限速 3g

从上面结果可以看出,在网速越慢的情况下,通过 cdn 流式渲染的最终主要元素出来的时间比原始 ssr 的方式出来得越早。这与实际推论也符合,因为网络越慢,静态资源加载时间越慢,对应的浏览器提前加载静态资源带来的效果也越明显。另外,不管在什么网络情况下,cdn 流式渲染方式的白屏时间要短很多。

整体架构

架构图

边缘流式渲染

1. 模板

模板就是一个类似于包含 ESI 区块的语法,基于模板,会将需要动态请求的内容提取出来,把可以静态返回的内容分离出来并缓存起来。所以模板本质上定义了页面动态内容和静态内容。

在流式渲染过程中,会从上到下解析页面模板,如果是静态内容,直接返回给用户,如果遇到动态内容,会执行动态内容的 fetch 逻辑。整个过程中可能有静态和动态内容交替出现。

设计有以下几种类型的模板。

  • 第一种:原始 HTML

这种模板对现有业务的侵入性最小,只需要在现有的 SSR 页面内容里加上一定的标签,即可把页面中动态部分申明出来:

<html>
  <head>
  	<link rel="stylesheet" type="text/css" href="index.css">
  	<script src="index.js"></script>
    <meta name="esr-version" content="0.0.1"/>
  </head>
  <body>
  	<div>staic content....</div>
    
    <script 
    	type="esr/snippet/start" 
      esr-id="111"
      content="SLICE"></script>
    <div>
    	dynamic content1....  
    </div>
    <script type="esr/snippet/end"></script>
    
  	<div>staic content....</div>
    
    <script 
    	type="esr/snippet/start" 
      esr-id="222" 
      content="https://test.alibaba.com/snippet/222"></script>
    <div id="222">
    	dynamic content2....  
    </div>
    <script type="esr/snippet/end"></script>
  </body>
</html>
  • 第二种:静态模板(暂时没有关联的实际场景)

这咱模板需要单独把模板发到 cdn 上(未来如果渲染层接入了 FASS 网关和 SSR ,在这块可以和他们共用模板内容,并且在工作流中发布模板时自动同步到 cdn 上一份,同时清空 cdn 上缓存)。动态的内容有两种渲染方式。一种是利用后端 SSR 出来的动态 html 片断,另一种是后端提供动态数据,由边缘节进行动态html片断渲染。

使用 SSR 动态 html 片断的好处是,不需要在边缘上做 html 模板渲染,并且不需要开发者写两套模板逻辑。缺点是需要后端有 SSR 能力,并且动态内容传输体积较大。

使用边缘节点渲染动态 html 内容的好处是,后端只需要提供动态数据,不需要 SSR 能力(但前端要有 CSR 的能力做降级兜底),并且传输的动态内容体积小。切点是边缘节点上无法流式透传动态内容,需要等完整下载到边缘节点上,处理后再返回给用户。

<html>
  <head>
  	<link rel="stylesheet" type="text/css" href="index.css">
  	<script src="index.js"></script>
  </head>
  <body>
    <div>staic content....</div>
    
    <script 
    	type="esr/block"
      esr-id="111"
      content="https://test.alibaba.com/snippet/111"></script>
    
    <div>staic content....</div>
    
    <script 
      type="esr/template" 
      esr-id="222"
      content="https://test.alibaba.com/api/data">
    	<div>
      	{$data.name}
      </div>
    </script>
  </body>
</html>

2. 静态内容展现

静态内容来自于模板。对于不同模板类型,获取静态内容的方式不一样。对于 “原始 HTML” 类型的模板,静态内容会从首次动态请求返回的完整 HTML 中,根据 html 注释标记提取出来,并存储到 edge 缓存上。对于 “静态模板”,会通过拉取 CDN 的的模板文件 ,并存储到 edge 缓存上。静态内容有缓存过期时间和版本号。

模板一开始的静态内容会在响应时直接返回给用户。后续的静态内容(例如 html 和 body 的闭合标签)有两种方式:
a. 一种是等待动态内容返回后,再写到响应流中。这种方式对 SEO 比较友好,但缺点是动态内容会阻塞住后续静态内容,并且如果有多个动态内容区块的话,无法实现先返回的动态模板先展示,只能依次展示.
b. 另一种方式是先把静态内容完全返回,然后动态内容以类 bigpipe 的方式,通过脚本把内容插入到对应的坑位。这种方式的优点是静态内容可以一开始就完整展示,且多个动态内容可以先到先展示。缺点是对 SEO 不友好(因为动态内容是能进 js 插进去的)

3. 动态内容

动态内容是在渲染过程中,解析到需要动态获取的区域,会在 edge 上发起动态内容请求。动态内容支持以动态加速的形式到达服务端(源站)。连续节点与后端的动态的内容交互,分为三种方式:
a. 第一种是后端动态内容返回的是全量的页面,需要通过注释标记来从内容中提取。这种方式的优点是对现有业务侵入较小,缺点是动态内容传输体积大,并且需要下载完整 html 后再截取动态内容;
b. 第二种是后端动态内容只返回动态区块的内容,这种方式的优点是可以将动态响应流式返回给用户,缺点时需要页面单独对外提供一个只返回动态区块内容的 url。
c. 第三种是后端动态内容只返回数据,配合静态模板中的动态渲染模板,在边缘节点上渲染出动态 html 后返回给用户。优点是与后端传输数据量小,且不需要后端有 SSR 能力。缺点是需要开发者多维护一套模板逻辑,并且在边缘节点上做复杂的模板渲染可能会有 cpu 开销和限制。

用户和边缘节点的动态内容交互,分为两种形式:
a. 瀑布流式(对应路由配置里的 WATER_FALL ): 动态内容以瀑布流的形式依次返回。虽然在边缘节点上多个动态内容加载的操作是并行的,但对于用户来说,会从上到下依次展示页面内容。这种方式优点是对 SEO 友好,并且不影响页面模块的加载顺序。缺点是多个动态模块时,无法看到整体页面的框架,首个动态块的内容会阻塞后续动态块内容的展示,且页面底部的 js css 资源无法提前加载和执行。
b. 嵌入式(对应路由配置里的 ASYNC_INSERT ):静态内容一次性全部返回,其中动态部分内容会先占一些坑位。后续动态内容会以 innerHTML 的形式,插入到先前占的坑中。这种方式优点是页面底部的 js css 资源无法提前加载和执行,并且页面可以先看到一个全貌。缺点是对 SEO 不友好,且页面模块的执行顺序会根据动态块返回速度有所变化,需要在浏览器端页面逻辑里做一些判断和兼容。

边缘路由

路由配置:
g.alicdn.com/edgerender/… (只是一个设想的 url,是一个发布到静态 cdn 上的 json 资源)

{
  version: '0.0.1' // 配置版本号
  origin: 'us-proxy.alibaba.com',
  host: 'edge.alibaba.com'
	pages: [
    {
    	pageName: 'seo', // 页面名称标识
      match: '/abc/efg/.*', // 页面 path 匹配正则字符串
      renderConf: { // 渲染配置
        renderType: 'ESR', // 边缘渲染
        templateType: 'FULL_HTML', // 模板类型:将 SSR 出的完整 html 作为模板
        dynamicMode: 'WATER_FALL|ASYNC_INSERT', // 动态内容 append 返回方式:瀑布流返回|异步填坑(innerHTML)
        templateUrl: '' // 模板 url
      }
    },
    {
    	pageName: 'seo',
      match: '/abc/efg/.*',
      renderConf: { 
        renderType: 'ESR', 
        templateType: 'STATIC', // 静态模板,可通过 cdn url 获取
        dynamicMode: 'WATER_FALL|ASYNC_INSERT', // 动态内容 append 返回方式:瀑布流返回|异步填坑(innerHTML)
        templateUrl: 'https://g.alicdn.com/@g/xxx.html'
      }
    },
    {
    	pageName: 'jump',
      match: '/jump/.*',
      renderConf: {
        renderType: 'REDIRECT_302', // 302 跳转
        rewriteUrl: 'https://jump'
      }
    },
    {
    	pageName: 'proxy',
      match: '/proxy/.*',
      renderConf: {
        renderType: 'PROXY_PASS', // 301 跳转
        rewriteUrl: 'https://proxypassurl'
      }
    }
  ]
}

路由可以认为是边缘计算的一个入口,只有在路由配置中的页面,才会走对应的渲染流程。否则页面会直接走回源,获取页面完整内容。上面的 json 是目前设计的路由配置文件。配置文件最终会在一个静态资源的方式,走覆盖式发布发到 assets cdn 上。同时,为了支持配置发布灰度,线上会存在灰度版本和全量版本的两个配置,在路由代码里配置固定比例,加载灰度或者全量版本的配置。

目前在路由里设计了三种渲染模式,分别是流式渲染、重定向和反向代理。重定向和反向代理的配置比较简单,与 nginx 配置类似,只需要提目标 url 即可。

稳定性

影响范围控制

  1. CDN 开关:域名按区域、按比例切流,同时可随时从 cdn 上把流量切回统一接入
  2. 边缘计算 SCOPE 开关:cdn 上配置边缘计算覆盖路径,控制边缘计算只运行在部分路径下
  3. 边缘计算路由开关:边缘计算中通过读取路由配置,控制只有部分页面走流式渲染,否则请求直接走动态加速获取完整页面内容

异常处理

  1. dns 开关,如出现 cdn 严重问题,直接 dns 回切到统一接入
  2. 如果边缘计算基础功能出现异常,在 cdn 配置平台上关闭所有路径的边缘计算,走默认的动态加速
  3. 如果在进了边缘渲染,在没有返回任何响应内容给客户端前,就出现了错误,捕获错误并降级到获取完整页面内容
  4. 如果进了边缘渲染,已经返回了静态部分的响应给客户端,然后在边缘节点了加载动态内容出了问题(超时、http 错误码、与静态内容版本号不匹配),返回一个 location.reload()  的 script 标签,并结束响应,让页面强制刷新。刷新时可带上 bypass 边缘计算的 query 参数以保证刷新时不走边缘渲染

灰度

  1. 边缘计算代码灰度
    a. 本身平台支持灰度发布边缘计算代码
  2. 路由配置灰度
    a. 在边缘计算代码里,根据固定比例,加载灰度版本和正式版本的两个配置 url。灰度发布时只发布灰度配置,全量发布时发布全量配置。发布的同时清空 cdn 缓存
  3. 页面内容灰度
    a. 给灰度页面一个特殊的模板版本号,遇到这个版本号的话,就不走边缘渲染。

平滑发布

前后端分离的发模式下,有一个普遍存在的问题:平滑发布。当页面的静态资源(js, css )的发布,不是与后端一起发布时,可能引起后端返回的 HTML 内容与前端的 js ,css 内容不匹配的问题。如果两者之间的不匹配没做兼容处理,可能会出现样式错乱或者 document 选择器找不到元素的问题。

解决平滑发布的一种方式是,在做前后端同时变更的需求时,在代码上做兼容。这样先后发布就不影响页面可用性。

另一种方式是通过版本号。在后端页面上手动配置版本号。当有不兼容发布时,先发前端资源,然后后端手动修改版号,保证只有发布成功的后端机器, HTML 里引用的才是新版本的静态资源。

平滑发布的问题其实在分批发布和 Beta 发布的场景一直存在。只是在 ESR 的场景,我们把静态部分缓存在 cdn 上,会使前后端不一致的可能性更大。为了解决这个问题,需要对应业务的开发者进行发布时的风险识别。如果已经做了兼容,可以不用做特殊处理。但如果没有兼容,需要在修改页面模板的版本号,新版本的动态内容,在遇到版本号不匹配的静态内容时,会放弃本次流式渲染,保证页面不出动态内容和静态内容的兼容问题。

边缘 cdn 服务商

目前各大 cdn 服务商对边缘计算的支持情况如下:

  1. alicdn
    a. 支持类 service worker  环境的边缘计算,功能满足需求
    b. 海外节点目前还有限,部分区域性能可与akamai 对标甚至超过,但有些域名性能因节点少的原因还是比 akamai 稍差。
  2. akamai
    a. 只支持简单的请求改写计算,不满足边缘渲染的需求
    b. ESI 可以组装动态和静态内容,但不支持流式,动态内容会阻塞首屏
    c. 海外节点多,在一些地区下相比于 alicdn 有性能优势
  3. cloudfare
    a. 支持类 service worker  环境的边缘计算,功能满足需求
    b. 没有使用经验,如果要用的话可能流程比较复杂

需要考虑的一些细节问题

  1. 如果走了动静分离的流式渲染方式,http header 会随着静态部分快速返回给用户,一方面 aplus 脚本后面的动态参数可能会被固化下来,另一方面如果动态内容有返回 set-cookie 的 header 的话,无法直接传达给浏览器。所以如果场景里有强依赖 aplus 动态参数,或者有重要的 set-cookie 操作的话,需要注意。目前一个方案是页面上再通过一个同域名的异步接口去触发想要的 set-cookie 逻辑。(或者允许的话,根据动态内容返回的 set-cookie header ,让 js 来写 cookie)
  2. 动态页面 titile 和 meta 标签属性是变化的的问题 - 在动态内容获取后,可以通过往页面中写入 js 来重新设置这些属性,来解决
  3. 对于 seo 页面,如果采用动态内容插入的形式(包括 title 和 meta 标签后续 js 写入),可能对爬虫不友好。可以通过专门识别爬虫 ua ,在边缘节点上直接返回 ssr 完成页面内容来解决

方案进度

目前通过 demo,已经验证了方案的可行性。正在阿里巴巴国际站上的实际业务场景做实验。未来将会分享更完善和丰富的方案(比如直接在边缘节点上进行 react 组件渲染)和实际线上的运行效果。

参考

  1. cloudfare edge worker
  2. 2016 - the year of web streams
  3. ESI
  4. Async Fragments: Rediscovering Progressive HTML Rendering with Marko
  5. The Lost Art of Progressive HTML Rendering