【Vuejs】971- Vue SSR 性能優(yōu)化實踐
齊云雷,微醫(yī)云服務團隊前端工程師,本文是作者在《第二屆繽紛前端技術沙龍》分享主題的文字版。
估計大部分讀者對標題中的性能優(yōu)化更感興趣,可惜我分享的重點其實更多在于實踐。實踐有深有淺,下面介紹的時候會存在比較大的側重。當然,篇幅不代表難易程度,考慮到不少信息已經有非常棒的公開資料,對這一部分我只會簡單提起關鍵詞,希望能起到拋磚引玉的作用。
本次分享圍繞著 Vue SSR 和相關業(yè)務增長的背景,向大家展示我們做過了哪些嘗試,以及一些踩坑經歷,希望能給中小規(guī)模的團隊帶來一定的參考價值。
對于大型團隊來說,這里基礎的優(yōu)化可能已經習以為常。并且許多人為了榨干機器性能,追求極致,已經有了各式各樣的成功探索。我們從中學習到了很多思路,但不管是多么優(yōu)秀的想法,多多少少也有著各自的局限性,適合他們的不一定適合我們。
受限于分享人的經驗和水平,本文大多是從 Server 的角度思考如何解決問題,不免存在疏漏,望讀者大大們批評指正。
一、實踐背景
實踐和背景息息相關,在展開篇幅之前,先交代一下我們進行性能優(yōu)化的背景。
首先,不得不提的就是行業(yè)背景,順便給公司貼一則介紹:微醫(yī)是一家互聯(lián)網(wǎng)醫(yī)療企業(yè),在數(shù)字健康領域的前線奮戰(zhàn)十年有余,為廣大用戶提供線上線下融合的一站式醫(yī)療和健保服務。在今年以前,醫(yī)療行業(yè)的峰值流量是遠遠小于其他服務業(yè)的,特別是真正核心的醫(yī)療、醫(yī)藥、醫(yī)檢等業(yè)務,不太可能出現(xiàn)高并發(fā)的情況。
說到這兒,大家可能都明白了,2020 年出現(xiàn)了一個重要的轉折點——新冠疫情。

業(yè)務背景

第一個問題,流量上漲,更重要的是不知道會有多少流量涌進來。我們當然不能給機器無限擴容。
第二個問題,真正開始面向全國區(qū)域的用戶。使用云服務器的團隊還可以添加不同地區(qū)的節(jié)點,但微醫(yī)大部分業(yè)務使用的是自己的機房,甚至都在杭州附近。其他地區(qū)的用戶距離太遠了,網(wǎng)絡體驗差,訪問速度慢。
技術背景

鑒于知曉 SSR 技術的小伙伴對此圖已經非常了解,所以我只給不太了解的朋友提一下服務端渲染的優(yōu)缺點。
SSR 優(yōu)勢

由于服務端直出頁面,從而縮短內容到達時間、減少首頁白屏。
直出的頁面包含了頁面關鍵數(shù)據(jù)信息,對搜索引擎的爬蟲更友好,利于提高網(wǎng)站搜索排名。恰恰因為主流的爬蟲不會解析 js 腳本,所以一些注重 SEO 的應用不得不上 SSR。
另外提一句,現(xiàn)在的 SSR 渲染一般指的都是同構渲染,可以兼顧客戶端渲染的大部分優(yōu)勢。
SSR 缺陷

SSR 的缺點也很突出,首要的問題自然是服務端壓力比客戶端大,這符合拆東補西的規(guī)律。SSR 通過壓榨服務端的性能提升客戶端首屏體驗,而渲染頁面屬于計算密集型的任務,對于 Node.js 編寫的服務而言,效率實在捉襟見肘。頁面組件復雜的情況,少量的并發(fā)就能拖垮進程。
另一個是潛在的問題在于影響開發(fā)體驗。毫無后端經驗的前端團隊可能對服務層代碼的把控力不足,貿然使用 SSR 風險非常大。不過由于我們將 SSR 服務端和客戶端進行較好的解耦,對于開發(fā)體驗而言,與 CSR 并沒有太大的區(qū)別。
二、方案討論
拿到問題之后,先來分解問題。在這兒借用一張圖,把一個 SSR 請求的生命周期分為三個階段,主要是把執(zhí)行渲染的部分從整體中抽出來

FCP:首次內容繪制時間,TTI:可交互時間
不過需要解釋一下,通常的拆解方式是用戶從瀏覽器發(fā)起的請求階段、服務器渲染階段和響應階段,但這樣的話,戰(zhàn)線被拉長,可優(yōu)化的范圍太大。而我們的核心訴求是緩解服務器的壓力,并不是一味地追求極限數(shù)值。
所以,我們特地縮窄了視野,僅僅從服務器的立場,將這三個階段分別理解為
請求已經到到達服務還未執(zhí)行渲染 開始渲染計算,直到渲染完成 服務器處理響應
SSR 最根本的性能問題,其實還是在中間這一步,密集的 CPU 運算。
所以 Vue3 帶來了一個變革,保守能讓渲染性能提高 2 到 3 倍。但在 Vue3 到來之前,我們有辦法提高這一步的性能嗎?

Vue3 優(yōu)化的一大原因是盡可能將部分 VDOM 的渲染改為字符串拼接,我們可以按照同樣的思路,改造 Vue2。不過話說回來,Vue 整個渲染過程能讓我們干預的地方很少,更不用說涉及底層算法的替換,具體該如何實施呢?
在 Vue3 推出之前,已經有許多前輩這樣做了。例如去年的 Tweb 分享上,有講師分享了一套將 SSR 性能優(yōu)化到極致的方案,使用自研的編譯器替換 vue-loader,在編譯時根據(jù) Vue 語法樹生成線性字符串的拼接,后續(xù)不再需要構造和遍歷 VDOM。
但是,這樣做的普遍后果是難以兼容 Vue 全部的語法,乃至 Vuex 也無法繼續(xù)使用。可惜我們頁面的邏輯非常復雜,也重度依賴 Vuex 管理狀態(tài),如果為了嘗試這樣的方案而對項目進行大幅改造,性價比顯得太低。更何況已經有著未來可期的 Vue3,不如先把這個棘手的問題放一放,讓我們把精力優(yōu)先投入到另外兩個可優(yōu)化的階段。
三、常規(guī)優(yōu)化
性能優(yōu)化必然是始終在進行的,有一些常規(guī)方法早就投入了使用,我們按渲染階段來盤點一二。
渲染前

對應前面所說的,從服務器的視角出發(fā),有以下操作可以讓渲染任務執(zhí)行前就減輕一些負擔。
第一,多級緩存。接口數(shù)據(jù)、組件和最終吐出的頁面均可緩存。這一步的核心是繼續(xù)把 CPU 壓力轉移到內存,前者可以縮短請求鏈路,后兩個可以減少渲染計算量。緩存的方式非常靈活,簡陋一點就直接用內存緩存,配合 LRU 算法基本夠用。復雜的場景就需要上 Redis 等內存數(shù)據(jù)庫。
第二,請求復用。我們通常使用封裝好的 Request、Axios 等庫完成請求,最值得留意的選項就是使用開啟了 keep-alive 的 http-agent,它能讓后續(xù)的請求復用之前建立的連接,減少重復的握手次數(shù)。
第三點,降級熔斷。如果沒有降級,雖然 Node.js 節(jié)點比較穩(wěn)定,不至于因為壓力而宕機,但卻會出現(xiàn)請求堆積,導致 Node.js 請求后端接口超時,服務將呈現(xiàn)不可用狀態(tài)。
回看上面這些做法,實現(xiàn)起來會遇到什么問題呢?
對于我們團隊來說,多數(shù)組件依賴全局狀態(tài),組件緩存的適用場景不多,因此我們主要使用頁面緩存。如果業(yè)務存在高度定制的頁面,不同用戶之間存在無法復用的緩存,可能會消耗巨大的內存。內存也是服務器寶貴的資源,但比其成本和性能來說,使用不當還會面臨更大的風險。緩存是一個非常復雜的課題,它的副作用在后面的小節(jié)還會再做介紹。簡而言之,我們必須做好充分的準備才有可能規(guī)避緩存帶來的隱患。
再談降級。一方面,降級會將 SSR 服務的壓力釋放到客戶端,而瀏覽器渲染頁面時無法讀取 SSR 服務層緩存的接口數(shù)據(jù),改為直接請求后端服務。這是對 SSR 進程是一種保護,但對后端應用卻不是件好事。另一方面,如果僅僅在發(fā)生異常時降級,那么遇到請求堆積而超時,降級沒能起到緩解壓力的作用,頁面整體響應時間也被拖長。因此,降級策略也需要靈活而完善地落實。
渲染后

在頁面渲染之后,我們會做一系列體驗上的優(yōu)化,而其中稱得上性能優(yōu)化的主要是這兩點。
可以把 CDN 簡單理解為一組代理服務器,所謂的 CDN 加速靜態(tài)資源,得益于資源被緩存到了代理服務器。通常靜態(tài)資源的內容不會頻繁變更,因此比動態(tài)的頁面數(shù)據(jù)更加適合緩存。
需要注意的是,gzip 壓縮有多種方式。近期就發(fā)生過出現(xiàn) CDN 將 gzip 響應頭去掉的問題,導致壓縮沒有生效,內容大小差了十幾 KB,頁面響應時間卻差了 400ms。
四、深度實踐
前面介紹的是業(yè)務增長之前所做過的優(yōu)化,但真正頂住壓力的辦法還在后面。
基礎網(wǎng)絡調優(yōu)
內網(wǎng)調用

這是一個早期被疏忽的基礎問題。
最初,我們 SSR 服務器通過公網(wǎng)的網(wǎng)關域名來訪問后端接口,但是從公網(wǎng)解析域名的效率極低。雖然可以 keep-alive 在一定程度復用連接,但仍然存在周期性建立連接的過程,此時的網(wǎng)絡體驗就很差。
為了穩(wěn)定縮短接口調用時間,我們將公網(wǎng)的域名解析改為配置 host 直接訪問網(wǎng)關 IP,但限于網(wǎng)關配置,用得仍然是 https 協(xié)議。后來和運維協(xié)商,才變更為使用 http 形式的內網(wǎng)域名調用。

這里稍微引申一個話題。在運維介入之前,使用 IP 訪問網(wǎng)關存在著一定的風險。如果只有單個 IP,容易發(fā)生單點故障;而如果有多個 IP,就需要面臨負載均衡和容災的處理。
負載均衡主要是避免出現(xiàn)擁堵,這要求我們應該記錄多個網(wǎng)關 IP,通過輪詢訪問來確保流量均勻分發(fā)到多個網(wǎng)關服務器。
容災則要求我們在某個節(jié)點故障時,能夠自動剔除故障節(jié)點,并在其恢復之后重新加入備選項。
除了上述的基本情況,實際上還存在著流量分配權重的問題。試想,不同服務器的性能、網(wǎng)絡帶寬等等都可能存在差異。我們想讓能者多勞,怎么辦?

如果沒有處理這種情況的經驗,推薦使用 Nginx 的加權平滑輪詢,這也是它默認的負載均衡算法。
加權和輪詢很容易理解,什么是平滑呢?對于一個高權重的節(jié)點,經過它的流量不會忽高忽低,被使用的頻率越穩(wěn)定,其負載均衡的算法越是平滑。
由于算法實現(xiàn)非常簡單,不知情的同學可以自行查找資料。上面描述的依然是一個非常基礎的模型,適用于網(wǎng)絡環(huán)境的過度,最終還是讓網(wǎng)關和運維提供支持為好。
擴展多級緩存
對于高并發(fā)的場景,我們都知道緩存頁面的重要性,具體又該如何處理呢?
隨著渲染方案的不同,主要也是分成兩個方向,一個是以 CSR 為主體的,可以將全部頁面部署到 CDN,并開啟 CDN 緩存。另一個是 SSR 為主體的,大多靠自身的緩存中間件硬抗,靠龐大的 Redis 和 MQ 集群,以設計傳統(tǒng)后端服務器的思路來處理。
在此之前,微醫(yī)的渲染服務比較簡單,幾乎只有內存緩存,導致 Node.js 進程內存占用比較夸張。如今面臨 CDN 緩存和引入 Redis 集群兩個方向的選擇,其實也不是選擇,兩個優(yōu)化都值得做,我們優(yōu)先采取了對于當前架構最為溫和的 CDN 緩存。
CDN 緩存介紹
剛才講靜態(tài)資源緩存的時候,對 CDN 已經有過初步介紹了,但它的功能不止用于緩存靜態(tài)資源。本小節(jié)則是講我們如何將 SSR 渲染出來的動態(tài)頁面放在 CDN 緩存上,這和靜態(tài)資源有許多不同的關注點。
接下來通過一系列問答帶諸位走近這個話題。
為什么接入 CDN

抽象一個簡單的請求鏈路,方便理解 CDN 的定位。看似增加了一層傳輸成本,其實沒有那么簡單。
CDN 利用自身廣大的服務器資源,能動態(tài)優(yōu)化訪問路由、就近提供訪問節(jié)點,以更低延遲、更高帶寬從源站獲取數(shù)據(jù),優(yōu)化了網(wǎng)絡層面的用戶體驗。
出于成本問題,大部分公司不會自己搭建 CDN 集群,而是使用了大廠提供的 CDN 服務。
我們把 CDN 節(jié)點放大,進一步體會它的作用
在沒有緩存的前提下,鏈路上存在一定損耗,總體效果仍要具體分析,不一定帶來正面優(yōu)化。但一旦引入了緩存,就產生了質的變化
為什么開啟 CDN 緩存

CDN 能夠緩存用戶請求到的資源,并且可以包含 HTTP 響應頭。在下一次任意用戶請求同樣的資源時,用緩存的資源直接響應用戶,節(jié)省了本該由源站處理的所有后續(xù)步驟。
簡單來說,就是截短了請求鏈路。
如何開啟 CDN 緩存

在不考慮自研 CDN 的情況下,開啟 CDN 緩存的步驟非常簡單:
域名接入 CDN 服務,同時針對路徑啟用緩存 在源站設置 Cache-Control 響應頭,為了更靈活地控制緩存規(guī)則,但并不是必須
一般兩者并非缺一不可,緩存時間的規(guī)則視 CDN 服務商而定。
哪些服務可以開啟 CDN 緩存

大部分網(wǎng)站都適合接入 CDN,但 SSR 頁面只有滿足一定條件才可以開啟 CDN 緩存。因為開啟緩存后,同一個 url 下所有用戶訪問的都是同一份資源。并且頁面數(shù)據(jù)應當對時效性要求不高,至少能接受分鐘級的延遲。
CDN 緩存優(yōu)化
用來衡量緩存效果的重要指標是緩存命中率,在正式設置 CDN 緩存之前,我們再來了解幾個提高緩存命中率的要點。這些要點也適合作為評估系統(tǒng)是否應該接入 CDN 緩存的標準。

(1)緩存時間
提高 Cache-Control 的時間是最有效的措施,緩存持續(xù)時間越久,緩存失效的機會越少。即使頁面訪問量不大的時候也能顯著提高緩存命中率。
需要注意,Cache-Control 只能告知 CDN 該緩存的時間上限,并不影響它被 CDN 提早淘汰。流量過低的資源,很快會被清理掉,CDN 用逐級沉淀的緩存機制保護自己的資源不被浪費。
(2)忽略 URL 參數(shù)
用戶訪問的完整 URL 可能包含了各種參數(shù),CDN 默認會把它們當作不同的資源,每個資源又是獨立的緩存。
而有些參數(shù)是明顯不合預期的,例如,頁面鏈接在微信等渠道分享后,末尾被掛上各種渠道自身設置的統(tǒng)計參數(shù)。平均到單個資源的訪問量就會大大降低,進而降低了緩存效果。
部分 CDN 后臺支持開啟 過濾參數(shù) 選項,來忽略 URL ? 后面的參數(shù)。此時同一個 URL 一律當作同一個資源文件。
(3)主動緩存
化被動為主動,才有可能實現(xiàn) 100% 的緩存命中率。常用的主動緩存是資源預熱,更適合 URL 路徑明確的靜態(tài)文件,動態(tài)路由無法交給 CDN 智能預熱,除非依次推送具體的地址。
應用代碼演進
談過 CDN 緩存優(yōu)化的幾個要點,便可得知 CDN 后臺的配置是需要謹慎對待的。我在實際操作中,也經過了幾個階段的調整,可畢竟具體配置方式取決于 CDN 服務商,因此本文不再深入討論。
現(xiàn)在,我們要把目光轉到代碼層的演進了。
1. 掌控緩存
代碼配置有一個前提,即 CDN 后臺需要開啟讀取源站 Cache-Control 的支持。
而后,只要簡單地添加響應頭,就能從運維手中接管設置 CDN 緩存規(guī)則的主動權。
以 Node.js Koa 中間件為例,全局的初始化版本如下
app.use((ctx, next) => {
ctx.set('Cache-Control', `max-age=300`)
})
當然,上述代碼的疏漏是非常多的。在 SSR 應用中,不太需要緩存所有的頁面,這就要補充路徑的判斷條件。
2. 控制路徑
雖然 CDN 后臺也可以配置路徑,但配置方式乃至路徑數(shù)量都有局限性,不如代碼形式靈活。
假如我們只需要緩存 /foo 頁面,就加入 if 判斷
app.use((ctx, next) => {
if (ctx.path === '/foo') {
ctx.set('Cache-Control', `max-age=300`)
}
})
這就陷入了第一個陷阱,一定要注意路由對 path 的處理。一般地,'/foo' 和 '/foo/' 是兩個獨立的 path。可能因為 ctx.path === '/foo' 而漏掉了請求 path 為 /foo/ 的處理。
3. 補充路徑
偽代碼如下
app.use((ctx, next) => {
if ([ '/foo', '/foo/' ].includes(ctx.path)) {
ctx.set('Cache-Control', `max-age=300`)
}
})
此外,CDN 后臺的配置也需要規(guī)避這個問題。在騰訊 CDN 中,目錄和文件適用于不同的頁面路徑。
4. 忽略降級頁面
在服務端渲染失敗時,為了提高容錯,我們會返回降級之后的頁面,轉為客戶端渲染。如果因為偶然的網(wǎng)絡波動,導致 CDN 緩存了降級頁面,將在一段時間內持續(xù)影響用戶體驗。
所以我們又引入了 ctx._degrade 自定義變量,標識頁面是否觸發(fā)了降級
app.use(async (ctx, next) => {
if ([ '/foo', '/foo/' ].includes(ctx.path)) {
ctx.set('Cache-Control', `max-age=300`)
}
await next()
// 頁面降級時,取消緩存
if (ctx._degrade) {
ctx.set('Cache-Control', 'no-cache')
}
})
沒錯,這并不是最后一個陷阱。
5. Cookie 和狀態(tài)治理
上面已經提到了 CDN 可以選擇性地緩存 HTTP 響應頭,可是此選項是對整個域名生效,又普遍需要開啟。
新的問題正是來自一個不希望被緩存的響應頭。
應用 Cookie 的設置依賴于響應頭 Set-Cookie 字段,Set-Cookie 的緩存直接會導致所有用戶的 Cookie 被刷新為同一個。
有多個解決方案,一是該頁面不要設置任何 Cookie,二是代理層過濾掉 Set-Cookie 字段。可惜騰訊 CDN 目前還不支持對響應頭的過濾,這步容錯必須自己操作。
app.use(async (ctx, next) => {
const enableCache = [ '/foo', '/foo/' ].includes(ctx.path)
if (enableCache) {
ctx.set('Cache-Control', `max-age=300`)
}
await next()
// 頁面降級時,取消緩存
if (ctx._degrade) {
ctx.set('Cache-Control', 'no-cache')
}
// 緩存頁面不設 Set-Cookie
else if (enableCache) {
ctx.res.removeHeader('Set-Cookie')
}
})
上面增加的代碼旨在頁面響應前移除 Set-Cookie,但是中間件的加載順序是難以控制的。特別是一些(中間件)插件,會隱式地創(chuàng)建 Cookie,這讓 Cookie 的清理工作異常麻煩。如果后續(xù)維護人員不知情,很可能將 Set-Cookie 重新加入到響應頭中。所以,這種擦屁股的工作,盡量在代理層處理,而不是放在代碼邏輯中。
除了 Cookie,還可能面臨其他狀態(tài)信息管理問題。比如在 Vuex 的 renderState 中存放請求用戶的登錄狀態(tài),此時 HTML 頁面嵌入了用戶信息,如果被 CDN 緩存,在客戶端將發(fā)生和未清除 Set-Cookie 相似的問題。類似的例子還有很多,它們的解決思路非常相像,接入 CDN 緩存前務必對狀態(tài)信息做好全面的排查。
6. 定制緩存路徑
現(xiàn)在功能總算趨于正常,然而緩存規(guī)則復雜多變,如果想設置更多頁面,還要單獨定制緩存時間呢?這段代碼仍需要不斷地變動。
例如,我們只想緩存 /foo/:id,而不緩存 /foo/foo、/foo/bar 等路徑。
注意 CDN 后臺可能只支持配置一個 /foo/ 開頭的緩存路徑,這就要求我們需要將 ctx.set('Cache-Control', 'no-cache') 做為默認處理,加在中間件的第一行。
又比如,我們想緩存 /foo 頁面 5 分鐘,/bar 頁面 1 天,又需要引入一個時間配置表。
這個中間件和相應的配置就會變得越來越難以維護。
因此,我們換一種思路,緩存規(guī)則不再交給中間件,而是轉到 Vue SSR 的 entry-server,通過 metadata 可以做到頁面級別的配置。由于 SSR 方案的差異性,不再贅述具體實現(xiàn)。
7. 緩存失效
緩存失效是個中性詞,如何處理 CDN 緩存失效,此中利弊不得不慎重權衡。
一方面,它會間歇增加服務壓力,在 Serverless 應用中還會提高計算成本。而另一方面,許多場景我們不得不主動觸發(fā)它,才能真正更新資源。
CDN 緩存的黑暗面無法讓人忽視。對用戶而言,緩存是透明的,對產品、技術卻很可能成為阻礙。
如果處理不當,它將影響新功能能否及時發(fā)布、阻斷后置所有服務的埋點、提高風險感知的成本,以及無法保障一致性,增加了線上問題的排查難度。
因此,十分有必要設立一個負責緩存刷新、預熱的觸發(fā)式服務,用以改進開發(fā)人員的體驗。可是 CDN 緩存可控性很低,刷新也不能做到全然實時生效。
處于頻繁變化的頁面,最好考慮進入穩(wěn)定期再開啟 CDN 緩存。即使是穩(wěn)定的、大流量的頁面,也還需要考慮 CDN 緩存穿透的防范措施。
一旦 CDN 緩存在 SSR 架構中得到重用,就要做好長期調整決策的準備。
頁面靜態(tài)化
在 CDN 緩存無法涉足的地方,我們也可以對自身進行多級緩存的加固。
動態(tài)路由下的頁面路徑比較分散,而分攤到頁面具體 URL 的流量可能就不高。顯然這樣的頁面不適合 CDN 緩存,緩存命中率很低,所以才引入了將頁面靜態(tài)化的預渲染方案。
在頁面正常渲染完成后,我們既然可以將整個頁面緩存下來,也能夠將緩存從內存持久化到硬盤或云存儲服務。這樣一來,便可以低成本地完整“緩存”數(shù)量巨大的頁面庫。
這既是對 CDN 緩存的良好補充,也可以廣泛用于頁面容災。
五、總結
以上這些優(yōu)化,我們在力所能及的范圍內相時而動,還存在著非常多的問題和缺陷,但愿可以給從未進行此類嘗試的朋友提供一個詳細的案例。

本文的大段篇幅留給了相對少見的優(yōu)化,尤其是多級緩存的處理。上圖是一個粗糙的性能對比,其中最大的影響因素就是 CDN 緩存。在本文的最后,我也將對此項改造著重進行總結。
CDN 緩存是一把利刃,在大流量的場景下,可以替源站攔截幾乎所有的請求,能提供極強伸縮性的負載。
但是,你的 SSR 應用適合接入 CDN 緩存嗎?

再一次細數(shù)上面提到的諸多問題:
路徑控制 頁面降級 狀態(tài)治理 緩存失效
答案得你自己說了算……
實際上,極少數(shù) SSR 頁面場景才需要 CDN 緩存,如門戶首頁。流量不高、路徑分散的一般業(yè)務,只需要使用動態(tài)的 CDN 加速和靜態(tài)文件緩存,就能基本滿足 CDN 代理層的優(yōu)化需要。
我的今天分享就到此為止,謝謝大家!

回復“加群”與大佬們一起交流學習~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
