做一個能對標阿里雲的前端APM工具(上)

hh54188發表於2022-03-20

APM 全稱是 Application Performance Monitor,即效能監控

這篇文章有三個前提:

  • 從產品形態上看這肯定不是一個能夠媲美阿里產品的競品,所以抱歉我碰瓷了。你可以把這裡的阿里換成任何一個你在 google 用 APM 搜尋到的工具。但是文章最後會我會用阿里的工具對同一網站進行效能測試,看看我們兩者的懸殊究竟子啊哪裡。很有意思的是,雖然我自己的寫的這個工具相比阿里雲的監測工具無比簡陋,但它依然達到了我的目的幫助我發現了問題在哪。從這個意義上說,這確實是一種勝利
  • 工具起點和終點是 site2share,這是一個我自己給自己寫的一個工具網站,我需要知道上線後使用者對它的效能感受究竟如何,所以工具因它而造,在完成對它進行效能測試的使命之後也即壽終正寢。
  • 這篇文章其實是對我去年寫的《效能指標的信仰危機》一文的回應。在那篇文章中基本都是在闡述這個工具背後的道理和設計,沒有一行真實程式碼的落地。

我還記的很多年前網路上盛傳的一道經典前端面試題,大意是請解釋從瀏覽器位址列敲入 url 之後到看到頁面的過程中發生了什麼。這類問題的迷人之處在於它給了你一記響亮的大嘴巴子卻又讓你心服口服——原來我們對眼皮下的諸多事物都熟視無睹,以及漫不經意問題背後存在著這麼大的學問題。

在這篇文章裡我要回答的問題也簡單明瞭:我怎麼知道我網站效能有多慢以及慢在哪?這個問題是網站上線之初我需要首先搞清楚的。

有待解決的問題

確定指標

在大問題下有兩個子問題是優先需要搞清楚,

  • 我要用什麼指標來衡量快慢?
  • 我怎麼排查慢的瓶頸在哪裡?

這兩個子問題在我去年的《效能指標的信仰危機》一文中已經做出了詳細說明,因為篇幅的關係,這裡只陳述結論,並且兩個問題的答案有千絲萬縷的聯絡,必須放在一起聊才行

簡單來說,諸如 onload 或者 DOMContentLoaded 這類技術指標是遠遠不夠的,甚至 First Contentful Paint 距離使用者的實際感知依然有距離(在後面我也會證明這件事)。 好的指標應該儘可能的向使用者靠攏,甚至是與業務深度定製的。所以我建議將頁面上用於承載核心內容DOM元素的出現時機作為效能的核心指標。這個時機之所以關鍵,因為它等同於網站此時此刻才能被稱之為可用。

以網站的詳情頁為例,關鍵元素便是 .single-folder-container

但著並不意味著一個指標就足夠了,因為如果我們發現這個指標數值不夠理想,我們無法準確定位問題在哪裡。所以好的資料帶來的效應應該是雙向的:即它既能準確反映當前產品執行狀態(從產品到資料),同時通過觀察資料我們也應該能得知產品存在何類問題(從資料到產品)

在這個前提下,我們需要從“有潛力”的效能瓶頸中挖掘指標。想當然影響網站載入效能的因素有:

  • 資源載入(指令碼,樣式等外部資源)的快慢
  • 介面響應時間

那繼續記錄這兩者的載入時間如何?

要回答這個問題,我們又要繼續反問自己了,這兩類資訊足夠我們推測出問題出在哪裡嗎?相比單一指標而言答案是肯定的,但依然還有細化的空間。以資源載入為例,參考 Resource Timing 如下圖所示資源載入也分為多個階段:

我們甚至可以診斷到究竟是在 DNS 解析還是 TCP 連線階段出現了問題。然而我們不應該事無鉅細的收集一切指標,有幾個因素需要考慮:

  • 問題暴露之後是不是真的有必要解決?我有沒有能力解決?比如上百毫秒的 DNS 解析時間可能是業界的好幾倍,但它是否真的是我整個站點的瓶頸?採用已有的CDN解決方案是不是會比我煞費苦心的提升幾百秒的時間價效比更高?
  • 我的個人經驗告訴我,採集指標用的程式碼是有維護成本的,通常這類程式碼的維護成本會比業務程式碼成本高,成本和程式碼的侵入性成正比。成本高昂之處在於它被破壞之後難以被察覺;單元測試和迴歸測試更加困難

回到確定指標的問題上,我們必須直面的一個現狀是我們無法一次性知道我們需要什麼樣的資料,這很正常,確定指標就是一個是假設、驗證、再假設、再驗證的收斂過程。嘗試總比停滯能夠讓我們接近正確答案。我們不妨開始收集上面提到的三類指標

  • 關鍵元素的出現時機
  • 資源載入時間
  • 介面時間

介面問題

前端工程師一定會落入的陷阱是隻用前端的視角看待問題,而忽略了最重要的介面效能。對大部分人來說頁面載入可能只是線性的:

但實際上在 API Request 環節上,我們應該用微服務的視角來看待問題。一個請求從發出請求到得到響應,會經由不同的微服務用以獲取資料,如果能對請求歷經的每一道鏈路予以追蹤。這有益於我們線上上環境中定位問題以及衡量單個微服務的效率。這就是 Distributed Tracing, 目前這項技術已經相當成熟了,jaegertracingZipkin 都是 distributed tracing 解決方案

然而如果你對後端拆分服務層有所瞭解的話,如果想診斷單個微服務的效能在哪,我們還可以繼續下鑽到單個微服務中,去對比呼叫不同服務層方法時的效能(服務層對前端同樣適用,詳細的介紹可以參考我前年翻譯的這篇文章《Angular 架構模式與最佳實踐》

我想表達的已經非常明顯了,想要完整挖掘應用的效能瓶頸,我們應該同時對上下游進行考察,割裂視角得到的結果是有失偏頗的

解決方案

收集日誌

如果你有采集日誌的經驗,你應該知道日誌的採集和輸出是兩碼事。尤其是對於後端程式而言。日誌既可以記錄在本地檔案中,也可以直接輸出在控制檯上,而到了線上環境則需要記錄在專業的日誌服務裡。

比如 NodeJS 的開源日誌類庫 winston,它支援整合多種 transport,一種 transport 即為一種用於儲存日誌的儲存方式。它還支援編寫自定義的 transport,目前開源社群的的 transport可選項幾乎支援市面上所有主流的日誌服務。在 .NET CORE 中的 logging providers 也是相同的概念

但這種“主動”收集日誌的方式並非是最佳實踐,關於構建網路應用的方法論The Twelve-Factor App提出,應用本身不應該考慮日誌的儲存,而只是保證日誌以 stdout 的形式輸出,由環境來負責對日誌的收集與加工。這項提議是合理的,因為應用程式本不應該知道也無法知道它將要部署的雲環境,而不同環境處理日誌的方式並不相同

出於 fail fast 的考慮我在開發 site2share 後端時並沒有遵循這一理念,在需要進行日誌採集時,我直接呼叫具體平臺的採集方法。目前我的日誌全部記錄在 Azure Application Insights 上,所以在記錄時我需要呼叫 Application Insights 客戶端方法:AppInsightsClient.trackTrace(message)

只不過在實現層面藉助 winston 程式碼可以變得更優雅,我們可以建立一個 logger 來達到同時相容多個日誌輸出渠道的效果

const logger = winston.createLogger({
  transports: [
     new AppInsightsTransport(),
     new winston.transports.Console()
  ]
});

因為我們測試的是前端效能,且效能資料產生在消費端瀏覽器的網頁上。所以我們依賴的是每個使用者在訪問之後由植入在頁面的指令碼主動上傳資料

Application Insights

我選擇 Azure Application Insights 用於儲存和查詢日誌, 選擇的其中一個原因是網站從前端(Azure Static Web App)到後端(Azure Service App)甚至是 DevOps 我使用的都是 Azure服務,自然官方的 Application Insights 能更好的與這些服務整合;而另一個更重要的原因是,它能為我們解決 distributed tracing 的問題。

你需要在你的應用中植入 Application Insigths 的 SDK 才能進行日誌收集,SDK支援前後端程式。它收集日誌的方式有兩種,主動收集和被動上報。以 JavaScript 語言的 Web 應用程式為例,在頁面上植入 SDK 之後它會自動收集程式執行時的報錯、發出的非同步請求、console.log(以 monkey patch 的方式)、效能資訊(通過 Performance API);你也可以呼叫 SDK 提供的 trackMetric、trackEvent 等主動上報自定義的指標和事件資訊。效能採集時我們同時利用了這兩種手段

我們通常將指標、日誌等資訊稱為 telemetry (data / item),通常這些資料會儲存在不同的表中並且和其他資料淹沒在一起。如何將兩者關聯起來呢? Application Insights 將 telemetry 相互關聯起來的解決方案很簡單:為每一則資料提供一個唯一的上下文標識 operation_Id。以使用者訪問一次頁面為例,那麼這次訪問產生的資料裡的 operation_Id 都叫做 xyz,那麼在 Application Insights 平臺上,我們便可以通過 xyz 將關聯的資料(以Kusto語法)查詢出來

(requests | union dependencies | union pageViews)
| where operation_Id == "xyz"

我們不僅可以將前端與前端的資料關聯起來,還可以將前端與後端的資料做關聯,這便是我們做 distributed tracing 的法寶。對於微服務應用而言,Application Insights 甚至可以為我們生成 Application Map,視覺化服務間的呼叫過程和耗時情況。

在過完這一小節的技術細節之後,我們可以視覺化的看看我們需要哪些資料以及它們又是如何關聯的

資源載入指標

多虧了 Performance API,在現代瀏覽器中收集指標變得異常簡單。無需主動觸發,瀏覽器在每次頁面載入時就已經按照時間線將效能指標資訊封裝在 PerformanceEntry 物件中,事後我們只需將需要的資料篩選出來即可,比如我們關心的指令碼:

window.performance.getEntries().filter(({initiatorType, entryType}) => initiatorType === 'script' && entryType === 'resource')

根據上一小節的結論,我們也不會事無鉅細的記錄資源載入每一個環節的資料,在這裡我重點採集資源載入的持續時間和資源載入的開始時間,這兩者我們從 PerformanceEntry 上都能獲取到,分別是 duration 和 fetchStart。因為目前在我看來前置載入以及縮短載入時間都是提升效能的有效手段。如果事後這兩項數值無法看出任何異常再考慮收集更多的指標

上報元素出現時間

確認元素出現時刻最簡單粗暴的方式就是通過 setInterval 頂起輪詢元素是否出現,但在現代瀏覽器中我們可以使用 MutationObserver API 來監控元素的所有變化,於是問題可以換一種問法:body 標籤下什麼時候出現 .single-folder-container 元素,關鍵程式碼大致如下

const observer = new MutationObserver(mutations => {
        if (document.querySelector('.single-folder-container')) {
          observer.disconnect();
          return;
        }
    });
    observer.observe(document.querySelector('body'), {
      subtree: true,
      childList: true
    });

這裡出現了一個問題:這段程式碼極為關鍵卻又難以被測試。

第一個問題是例如在 Jest 環境中並不存在原生的 MutationObserver 物件,如果你只是為了通過測試而單純 mock MutationObserver 物件測試的意義便蕩然無存了;

其次即使在 Headless Chrome 這類支援 MutationObserver 的環境中進行測試,你如果知道它上報給你的元素出現時間是正確的?因為你自己也不知道準確的時機是什麼(也就是你測試李的 expect),10秒一定是不正確的,但是 2.2秒呢?

額外效能指標

理論上來說上面兩者便是所有我們預期想收集的指標。但還是有兩個額外指標是我想收集的:First Paint 和 First Contentful Paint,簡單來說它們記錄的是瀏覽器在繪製頁面的一些關鍵時機。這兩項指標也可以從 Performance API 裡獲得

window.performance.getEntries().filter(entry => entry.entryType === 'paint')

Paint Timing 會比單純的技術指標更接近使用者體驗,但是與實際使用者看到元素出現實際相比如何,我們拭目以待

後端時間

我猜想可能存在的效能瓶頸有兩處:1)Redis 查詢 2) MySQL 查詢。

Redis 主要用於 session 的儲存,加之後端由 Node.js + ExpressJS 搭建,對於 session 讀取效能監控不易。所以我優先優先考察 MySQL 查詢效能,比如統計findByFolderId方法的讀取效能:

const findFolderIdStartTime = +new Date();
await FolderService.findByFolderId(parseInt(req.params!.id))
appInsightsClient.trackMetric({
	name: "APM:GET_SINGLE_FOLDER:FIND_BY_ID", 
	value: +new Date - findFolderIdStartTime
});

總結

最後,為了便於在日誌平臺上找到對應的指標,以及對指定型別的指標做統計,我們需要對上述指標進行命名,以下就是命名規則,

  • 後端資料庫查詢單條資料時間—— APM:GET_SINGLE_FOLDER:FIND_BY_ID
  • 瀏覽器中 first-paint 指標 ——browser:first-paint
  • 瀏覽器中 first-contentful-paint 指標——browser:first-contentful-paint
  • 前端中非同步請求資料——resource:xmlhttprequest:
  • 瀏覽器請求指令碼資源資料——resource:script:
  • 詳情頁關鍵元素可見時間——folder-detail:visible
  • 個人首頁關鍵元素可見時間——dashboard:visible

上一篇即將告一段落。我已經闡述了這個效能收集方案的思路,也基本實現了我們的效能採集指令碼。有了這些程式碼,我們基本上可以完整採集單次的效能資料了

在下一篇中我們要解決這樣幾個問題

  • 在網站幾乎沒有人訪問的情況下,如何得到足夠的樣本量來衡量效能(如何使用 Azure Serverless 和 Azure Logic App 來解決)
  • 從最後產生的二十萬條資料中如何發現問題
  • 對比阿里的監控服務 ARMS

本文也同時釋出在我的個人網站知乎

相關文章