我在幾期薅羊毛活動中學到了什么~
前言
為什么突然想寫一篇總結了呢,其實也是被虐的。今年 3 月份初期,我們商城接了一個 XX 銀行的一分購活動(說白點就是薅羊毛),那時候是活動第一期,未曾想到活動入口開放時,流量能直接將 cpu 沖至 100%,導致服務短暫的 502 了。期間采取了緊急方案到活動結束,但未曾想到還有活動二期,以及上周剛上線的活動三期。想著最近這段時間也做了一些事情,還有遇到的一些坑點,趁此機會,就不偷懶記錄一下吧。
活動一期到三期具體做了些什么
技術背景&瓶頸
項目是基于 Vue+SSR 架構的,且沒有做緩存處理,沒做緩存的主要原因第一個是原本應用 tps 比較低,改造動力不強,并且頁面渲染結果中包含了用戶數據以及服務端時間,沒法在不經過改造的情況下直接上緩存。所以當一期活動大流量沖擊時,高并發情況下很容易將 cpu 打至 100%。
一期在未知情況下,服務直接扛不住了,當時為了活動能正常進行,首要方案就是先加機器扛住部分壓力,緊接著就是加緩存,目前有兩種緩存方案,緩存頁面或緩存組件,但由于我們的需要緩存的商品詳情頁組件涉及到動態信息,可維護性太差,心智成本高,最終選擇了前者。我們整理了一下商詳頁有關動態變化的信息數據(與時間/用戶相關等類型的數據),在活動期間,緊急屏蔽了部分不影響功能的動態內容,然后頁面上 CDN。
活動結束后,我們做了下復盤,要像應用要能保障大流量情況下穩定運行,性能優化處理是避免不了的了。為此我們做了以下四大方案:
1.對數據做動靜分離: 我們可以將數據分類成動靜兩類,靜態數據即是一段時間內,不隨時間/用戶改變,動態數據則是相反的,經常變動,與時間有關,有用戶相關等類型的數據都可以歸類為動態數據。原頁面無法上緩存的最大阻礙就是,就是在 node 渲染模板時,會默認獲取用戶數據,或是在 asyncData 中調用用戶相關的接口;此外,還會設置服務端時間等動態數據。所以思路就是將靜態數據放在 node 獲取,將動態數據放到客戶端(瀏覽器讀取 asyncData、mounted 等瀏覽器生命周期里)獲取保證服務端的清潔。
2.頁面接入 CDN: 經過動靜態分離的改造后,已經可以將其路徑加入 cdn。但需要注意路徑上 query 等參數是否會影響渲染,如果影響,需要把邏輯搬到客戶端,同時需要注意一下過期時間(如 10 分鐘)是否會對業務產生影響
3.應用緩存: 如果在比較糟糕的情況下,cdn 失效了導致回源率上升,應用本身還是需要做好準備。這就要根據項目需要去選擇內存緩存/redis 緩存。
4.自動降級: 在極端的情況下,前面的緩存都沒擋住流量,就需要終極方案:降級渲染。所謂降級渲染,就是不進入路由,直接將空模板返回,完全交給瀏覽器去做渲染。這樣做的最大好處就是完全避免了 node 壓力,將一個 SSR 應用變成了靜態應用。缺點也是顯而易見的,用戶看到的是空模板,需要等待初始化。那如何自動降級呢,可以通過定時器時時檢測 cpu、負載等壓力,來確定當前機器的負載,從而決定是否降級;也可以通過 url 的 query 上是否攜帶特定標識,顯式地決定是否降級。
對項目方案做了以上性能優化接下來就是壓測,也算是順利上線了。🤪
二期活動沒過多久又來了,不過如我們預期,項目很穩定地扛住了壓力,期間也增加了流量接口,并加友好提示等優化。但其中一個痛點是需要針對幾個特殊商品去做個文案處理,這幾個文案非接口返回,也是臨時性的一些醒目提示,沒必要放在詳情頁接口中返回。由于時間也很緊急,我們不確定后面還有沒有這種特定的文案需求(和具體的頁面以及特定的區域關聯),決定還是暫時寫一些 low code:針對特定的活動商品 id,臨時添加文案,活動下線之后,把文案去除。這樣做的風險性是有的,畢竟代碼是臨時性的,需要上下線,并且有時間延遲。但好在活動結束時是周末,最后一天流量訪問并不大,給了相對應的引導文案以及售后處理,評估下來影響不大,尚可接受。
以下圖片商品詳情頁和商品購買頁需要加的特定文案:
薅羊毛活動是真香現場嗎~~6 月底產品就和我打了個招呼,說 XX 活動又要有三期了,但整體方案依舊和二期一樣不變。我內心:還來???(小聲說句打工人太苦了),由于最終時間沒定下來,也有了二期的教訓之后,和后端同學也一起商量了一下,把活動商品往配置化方向考慮,放在我們配置后臺中文案模塊且是可行的。針對商品詳情頁,考慮到不破壞動靜分離,先確定下配置化接口返回的數據是靜態的,可以放在服務端獲取。以下具體三期做的事情:
- 將參與活動商品的文案做成配置化,從配置接口獲取,去除 low code
- 整理大流量活動頁(例如商詳頁)的接口,放在客戶端的接口需要做限流,接口達到一定的 tps 后,返回 429 狀態,前端要做容錯處理,頁面功能正常訪問,屏蔽限流接口錯誤。
- 針對購買限流接口,需要給 busy 提示(活動太火爆了,請稍后再試)
- // 統一在 getResponseErrorInterceptor 處針對 429 狀態做處理
- export const getResponseErrorInterceptor = ({ errorCallback }) => (error) => {
- if (!isClient) {
- ...
- } else {
- // 429 Code 服務報錯需要支持不彈出錯誤提示
- if (+error.response.status === 429) {
- // 針對限流接口,且需要 busy 提示時增加 needBusyMsg 屬性
- errorCallback(error.config.needBusyMsg ? '活動太火爆了,請稍后再試' : null);
- } else {
- ...
- );
- }
- }
- return throwError(error);
- };
結束了上周一周忙碌的壓測和測試,三期終于上線了。👏👏👏👏
想了解更多 Vue SSR 性能優化方案可以移步到這里: Vue SSR 性能優化實踐
實際過程中遇到的一些 Coding Question
1.本地項目(vue-SSR 架構)里,一個動態接口放在服務端獲取時,有一段代碼很 easy,一個是否是會員的標識去開通會員按鈕的顯隱,代碼如下(代碼有簡化):
- <redirect
- v-if="!isVip"
- :link="link"
- type="h5"
- >
- 開通會員<ui-icon name="arrow-right" />
- </redirect>
本地中雖然運行正常,但是會有如下警告:vue.esm.js:6428 Mismatching childNodes vs. VNodes: NodeList(2) [div, a.DetailVip-right.Redirect] (2) [VNode, VNode]
[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside
, or missing . Bailing hydration and performing full client-side render.
但更新到測試環境中,頁面會失效,點擊失效等。會報有如下錯誤:Failed to execute 'appendChild' on 'Node': This node type does not support this method
分析
Vue SSR 指南在客戶端激活里剛好說到了一些需要注意的坑,使用「SSR + 客戶端混合」時,需要了解的一件事是,瀏覽器可能會更改的一些特殊的 HTML 結構。例如 table 中漏寫<tbody>,像以下幾種情況也會導致導致服務端返回的靜態 HTML 和瀏覽器端渲染的內容不一致:
- 無效的HTML(例如:<p><p>Text</p></p>)
- 服務器與客戶端的不同狀態
- 例如日期、時間戳和隨機化等不確定變量的影響
- 第三方腳本影響到了組件的渲染
- 需要身份驗證相關時
當然確定原因之后對癥下藥,總結有幾種辦法可以解決此問題:
- 檢查相關代碼,確保 HTML 有效
- 最簡單粗暴的一個方法就是:用v-show去代替v-if,要知道構建時生成的HTML是無狀態的,應用程序中與身份驗證相關的所有部分應該只在客戶端呈現,具體可以 diff 下獲取數據以及在服務器/客戶端呈現的內容,解決服務器和客戶端之間的狀態不一致
- 面對第三方腳本這類的,可以通過將組件包裝在標簽中來避免在服務器端渲染組件
- .....(歡迎補充)
針對此類問題,還可以看看這篇文章:https://blog.lichter.io/posts/vue-hydration-error/
2: 同樣的 h5 頁面,在瀏覽器中打開配置生效,而在公眾號&小程序中打開卻失效了?
三期的時候,我們把活動商品 id 和對應文案做成了配置化處理。配置方式如下:
獲取商品配置內容經過 JSON.stringify()之后,毋庸置疑會得到如下字符串:
- 455164527672033280|龍支付 立減 10 元|滿 40 立減 10(僅限 XX 卡)#\\n623841656577658880|龍支付 立減 10 元(僅限 XX 卡)|滿 40 立減 10(僅限 XX 卡)#\\n350947143063699456|龍支付測試 立減 10 元(僅限 XX 卡)|滿 40 立減 10 測試(僅限 XX 卡)#
在詳情頁獲取所有的商品 id 列表信息,我們用的#做區分,寫了一個簡單的正則如下:
- activityItems() {
- return this.getFieldValue('activity_item')?.split('#\\n');
- },
但在公眾號里面打開我們的 h5 鏈接,會將#自動轉義成\,內容會變成:
- 455164527672033280|龍支付 立減 10 元(僅限 XX 卡)|滿 40 立減 10(僅限 XX 卡)\\\n623841656577658880|龍支付 立減 10 元(僅限 XX 卡)|滿 40 立減 10(僅限 XX 卡)\\\n350947143063699456|龍支付測試 立減 10 元(僅限 XX 卡)|滿 40 立減 10 測試(僅限 XX 卡)\
啊,這,,,不是吧??(發現時內心幾乎是崩潰的)😩 解決方式立馬把#字符換成不被轉移的字符;。
另外在小程序中打開失效是因為延用二期的方案,當時做了限制判斷,只需要在主站和主 app 中打開有效,小程序設有自己單獨的 appid,三期活動有多方入口,把該限制放開即可。
總結
薅羊毛參與了三期,也是積累了一些經驗,踩了一些坑吧,想著太久沒寫了該記錄一下了,先總結到這里,還有忘記的再補充~
本文轉載自微信公眾號「微醫大前端技術」,可以通過以下二維碼關注。轉載本文請聯系微醫大前端技術公眾號。