當頁面渲染遇上邊緣計算

方凳雅集發表於2019-11-15

背景

通過優化頁面效能,提升使用者的體驗,一直是我們追求的目標。我們可以通過瀏覽器快取、預載入、預渲染等各種方案,來提升頁面的訪問效能和體驗。但在實際業務場景中,有一類頁面一直是效能優化的老大難,那就是首跳頁面。即使用者是第一次訪問網站的場景。
對於 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

相關文章