1. 程式人生 > >基本效能分析,省了幾百萬美元

基本效能分析,省了幾百萬美元

本文是關於我如何應用基本效能分析技術,藉助火焰圖做了一處小改進,使得我們 Postgres 計算機叢集的 CPU 狀況獲得了 10 倍的改善,並在第二年幫助 Heap 節省了幾百萬刀。

針對使用者分析的索引資料

Heap 是一個使用者分析工具,它自動捕捉每個使用者與網站或應用進行的互動行為。成功安裝於網站後,Heap 會自動追蹤每個頁面的瀏覽量、點選量、表單提交等資訊。這樣每個網站擁有者可以針對不同子集的原始資料,使用 Heap 來執行不同種類的聚合。

為了能夠對無意義的原始資料有所洞見,Heap 能讓使用者根據原始資料自定義事件。“登陸”就是一個例子,可以定義為“在 /login 頁面進行表單提交”。

為了加快分析速度,我們用了一個非常規的索引策略,它基於 Postgres 的部分索引特性。部分索引就像一個普通的 Postgres 索引,不同點在於它只包含了滿足特定謂詞 (predicate) 的行,你可以把它想象成帶有 WHERE 條件的常規索引。針對每個使用者建立的事件定義,我們根據使用者的原始事件資料,創造了一個部分索引,並將其繫結在滿足定義的行上。每當我們的 events 表格中插入一條新行,Postgres 會自動將它與表內現存的每條部分索引的謂詞進行測試,並將其加入匹配的索引中。

針對每個事件定義,對應的部分索引能讓它快速獲得所有的匹配事件,因為索引恰恰包含了滿足定義的事件。你應該閱讀我們

這篇關於如何索引資料的部落格,它更加深入地介紹了部分索引的相關內容。

問題初顯:異常的高 CPU 佔用率

當我們第一次運用這條索引策略時,對比之前的策略,我們的 CPU 佔用率有了大幅上升。我們認為這是正常的現象,因為我們最大的客戶有著成千上萬條索引,而為了支援基於 CSS 選擇器的過濾器,大部分的部分索引都包含了一個正則表示式過濾器。我們認為由於正則表示式的求值極其耗時耗力,而每個插入的事件都要經過上千條正則表示式的測試,這就成了高 CPU 佔用率的唯一合理解釋。儘管沒有明顯的證據表明這就是原因,但每個使用 Heap 的人都慢慢將它當作了 CPU 佔用率過高的合理解釋。它被看作是新索引策略帶來的根本妥協。

十月左右,隨著資料量的持續增長,問題開始出現:高峰時間無法消化所有資訊。有時候新事件需要花費數小時才能顯示在 Heap 儀表板上,而這對一個實時分析工具而言完全不可理喻。拋開通過花錢解決問題的常規路線,我想動手嘗試優化 Heap 的資訊吞吐量問題。

用火焰圖對 CPU 佔用情況視覺化

此前我鮮有除錯此類效能問題的經驗。在搜尋了一陣後,我看到了大牛 Brendan Gregg 寫的一篇關於火焰圖的文章。火焰圖是 Brendan Gregg 發明的一種視覺化方法,用於快速檢視哪些部分正在佔用 CPU。建立火焰圖的第一步是使用 Linux perf 工具從程序堆疊中取樣:

1 perf record-p$(pid of process)-F99-g--sleep60

它會對指定的程序堆疊以每秒 99 次的速度,進行持續一分鐘的取樣,並將資料寫入 perf.data 資料檔案中。這樣,你就可以從 Brendan Gregg 的火焰圖資料庫中執行以下命令,對檔案進行分析並生成火焰圖:

1 perf script|./stackcollapse-perf.pl>out.perf-folded./flamegraph.pl out.perf-folded>flame-graph.svg

我最初建立的火焰圖之一是 Postgres 的後端程序。因為我們使用了連線池,一個簡單的後端程序要服務多項請求。由於我們執行的最多的請求是 INSERTs,Postgres 後端程序的火焰圖能夠讓我們清楚地看到,事件插入資料庫時的 CPU 佔用情況。在 pid 中對來自 pg_stat_activity 的 Postgres 程序執行以上命令後,我獲得了下面這張火焰圖:


(單擊在新標籤中開啟上圖,然後點選方形框來放大。滑鼠停留在方形框上將顯示對應資訊)

對於新手而言,火焰圖可能非常難以理解。Brendan Gregg 給出以下解釋幫助我們理解一張火焰圖:

X 軸顯示堆疊剖面群體,以字母順序排序(而不是時間的流逝),Y 軸顯示堆疊深度。每個方塊框代表了一個堆疊幀。幀越寬,代表了它在堆疊中出現的頻率越高。頂端顯示了 CPU 正在執行的程序,下面是歷史程序。顏色通常而言並不重要,它們是隨機分配的,用來區分不同的框架。

從火焰圖中可以清楚地看到,大約 55% 的 CPU 時間花在了 ExecOpenIndices 上(圖中行右側區域的大黃色條)。視線上移一點,可以看到大部分的時間被兩個不同的功能所消耗,它們是 BuildIndexInfoindex_openBuildIndexInfo 呼叫了 RelationGetIndexPredicate,而後者花費了 ~20% 的總 CPU 時間。這樣來看,大部分時間都花在了 RelationGetIndexPredicate 上。

仔細檢視 RelationGetIndexPredicate 的原始碼,它的作用是解析和優化部分索引謂詞。這就解釋了為什麼 RelationGetIndexPredicate 耗費了如此大量的時間,因為相比對已解析表示式進行求值,解析二進位制表示式要更加困難。

現在我們再看看剩下花在 ExecOpenIndices 上的時間。其中大部分剩餘時間花在了 index_open 上。看上去 index_open 先呼叫了 relation_open,後者又呼叫了 RelationIdGetRelation。從 RelationIdGetRelation 的原始碼檔案中,可以看到它的作用是查詢不同關係的元資料(本次問題中它主要用於查詢部分索引)。根據 RelationGetIndexPredicateRelationIdGetRelation 消耗的時間,看起來 Postgres 花費了更多的時間用於獲取和解析部分索引謂詞,而不是對其求值。

實施修復

看了不同函式的原始碼,可以發現存在著大量的快取。在 RelationGetIndexPredicate 中,Postfres 先檢測是否已抽取謂詞並立即返回它。

RelationIdGetRelation 先使用 RelationIdCacheLookup 來檢查關係源資料是否已經過計算並快取。通常情況下,索引元資料只需要經歷一次獲取和解析,剩下的時間都是從快取中讀取。

不幸的是,因為我們每次將一個事件寫入數以萬計的不同表格,快取出了問題。Postgres 有一個服務請求的程序池,並且每個程序都有單獨的快取。這些程序對每次插入都分配了輪詢 (round-robin)。由於共享的模式中現存上萬張基礎表格,每次插入事件時,很有可能將兩次事件插入同一程序的同一張表中,也就是說索引元資料幾乎無法在執行插入時進行快取。因此,Postgres 幾乎每次都需要在插入事件時,獲取並解析目的表格的索引元資料。

根據這一點,我們可以做一個簡單的改進:與其將每個事件單獨插入表格,我們可以對需要插入相同表格的事件進行一次批量插入。通過執行一個簡單的命令來批量插入事件,Postgres 就只需要在每次批處理時獲取和解析索引元資料。我們之前本想進行批量插入以減少執行計數,但不是出於節省 CPU 資源的目的,因為我們假設所有的 CPU 都要用於對索引謂詞求值。

批量插入的初始基準顯示 CPU 佔用率得到了 10x 的縮減。得知了這一結果,我們開始在生產中測試批量插入。最終,通過對平均大小在 ~50 的事件進行批量插入,我們的吞吐量獲得了 10x 的提高。這是對不同 Kafka 部分的吞吐量傳輸延遲時間,進行批處理前後的對比:左邊的單位是延遲時間 (latency time)。我們能夠在幾分鐘內清理完一個小時的積壓 (backlog)。

在實行批處理後,我又生成了一張插入事件的火焰圖:

這一次圖上顯示大部分的時間都歸於 ExecQual(中間的紅條),而根據原始碼,而它是作用是對部分索引謂詞進行求值,也就是說這一次 Postgres 將大量的 CPU 用在了正途上。

我在半年前發現了這個問題。自此,我們不需要給叢集增加額外的 CPU,而且看起來以後的幾個月也不用這樣做。我只是運用了基本的效能分析就有如此成效,沒花什麼力氣就獲得了 10 倍的收益。