提速 10 倍!深度解讀位元組跳動新型雲原生 Spark History Server

語言: CN / TW / HK

更多技術交流、求職機會,歡迎關注位元組跳動資料平臺微信公眾號,回覆【1】進入官方交流群

 

前不久,在 6月29日 Databricks 舉辦的 Data + AI Summit 上,火山引擎向大家首次介紹了 UIMeta,一款致力於監控、分析和優化的新型雲原生 Spark History Server,相比於傳統的事件日誌檔案,它在縮小了近乎 10倍體積的基礎上,居然還實現了提速 10倍!!!

目前,UIMeta Service 已經取代了原有的 History Server,為位元組跳動每天數百萬的作業提供服務,並且成為火山引擎 湖倉一體分析服務 LAS(LakeHouse Analytics Service)的預設服務。

實際上,對於 History Server來說,事件日誌包含太多冗餘資訊,長時間執行的應用程式可能會帶來巨大的事件日誌,這可能需要大量維護並且需要很長時間才能重構 UI 資料從而提供服務。在大規模生產中,作業的數量可能很大,會給歷史伺服器帶來沉重的負擔。接下來,火山引擎 LAS 團隊將向大家詳細介紹位元組跳動內部是怎麼基於 UIMeta 實現海量資料業務的平穩和高效運轉,讓技術驅動業務不斷髮展。

業務背景

開源 Spark History Server 架構

為了能夠更好理解本次重構的背景和意義,首先對原生 Spark History Server 原理做個簡單的介紹。

開源 Spark History Server 流程圖

Spark History 建立在 Spark 事件(Spark Event)體系之上。在 Spark 任務執行期間會產生大量包含執行資訊的SparkListenerEvent,例如 ApplicationStart / StageCompleted / MetricsUpdate 等等,都有對應的 SparkListenerEvent 實現。所有的 event 會發送到ListenerBus中,被註冊在ListenerBus中的所有listener監聽。其中EventLoggingListener是專門用於生成 event log 的監聽器。它會將 event 序列化為 Json 格式的 event log 檔案,寫到檔案系統中(如 HDFS)。通常一個機房的任務的檔案都儲存在一個路徑下。

在 History Server 側,核心邏輯在 FsHistoryProvider中。FsHistoryProvider 會維持一個執行緒間歇掃描配置好的 event log 儲存路徑,遍歷其中的 event log 檔案,提取其中概要資訊(主要是 appliaction_id, user, status, start_time, end_time, event_log_path),維護一個列表。當用戶訪問 UI,會從列表中查詢請求所需的任務,如果存在,就完整讀取對應的 event log 檔案,進行解析。解析的過程就是一個回放過程(replay)。Event log 檔案中的每一行是一個序列化的 event,將它們逐行反序列化,並使用 ReplayListener將其中資訊反饋到 KVStore 中,還原任務的狀態。

無論執行時還是 History Server,任務狀態都儲存在有限幾個類的例項中,而它們則儲存在 KVStore中,KVStore是 Spark 中基於記憶體的KV儲存,可以儲存任意的類例項。前端會從KVStore查詢所需的物件,實現頁面的渲染。

痛點

儲存空間開銷大

Spark 的事件體系非常詳細,導致 event log 記錄的事件數量非常大,對於 UI 顯示來說,大部分 event 是無用的。並且 event log 一般使用 json 明文儲存,空間佔用較大。對於比較複雜或時間長的任務,event log 可以達到幾十 GB。位元組內部 7 天的 event log 佔用約 3.2 PB 的 HDFS 儲存空間。

回放效率差,延遲高

History Server 採用回放解析 event log 的方式還原 Spark UI,有大量的計算開銷,當任務較大就會有明顯的響應延遲,響應延遲是指從使用者發起前端訪問到頁面 UI 完全渲染出來的等待時長。作業結束之後,使用者可能要等十幾分鍾甚至半小時才能通過 History Server 看到作業歷史。而大型作業結束後,使用者往往希望儘快看到作業歷史從而根據作業歷史進行問題診斷和作業優化,使用者等待 UI 完成渲染時間過長,非常影響使用者體驗。

擴充套件性差

如上所述,History Server 的FsHistoryProvider在回放解析檔案之前,需要先掃描配置的 event log 路徑,遍歷其中的 event log,將所有檔案的元資訊載入到記憶體中,這使得原生服務成為了有狀態的服務。因此每次服務重啟,都需要重新載入整個路徑,才能對外服務。每個任務在完成後,也需要等待下一輪掃描才能被訪問到。

當叢集任務數量增多,每一輪掃描檔案的耗時以及元資訊記憶體佔用都會增加,這也要求服務有越來越高的資源配置。如果通過拆分 event log 路徑來縮小單例項的壓力,需要對路由規則進行改造,運維難度增大。目前,位元組跳動內部通過增加 UIService 例項就可以方便的進行水平擴充套件。

非雲原生

Spark History Server 並非是雲原生的服務,在公有云場景下改造和維護成本高。首先公有云場景需要進行租戶資源隔離,其次公有云場景下不同使用者的 workload 差異很大,不同使用者任務量有數量級的差別,會出現大量長尾作業。為每個使用者單獨部署 History Server 計算和儲存成本過大且不均衡,而部署統一的 History Server 無法做到資源隔離,一旦出現問題影響較多使用者,兩種方式運維成本都會很高。火山引擎湖倉一體分析引擎 LAS(Lakehouse Analytics Service),提供了雲原生的 UIService,可以有效解決上述問題。

 

UIService

方案

為了解決前面的三個問題,我們嘗試對 History Server 進行改造。如上所述,無論執行中的 Spark Driver 還是 History Server,都是通過監聽 event,將其中包含的任務變化資訊反映到幾種 UI 相關的類的例項中,然後存入KVStore供 UI 渲染。也就是說,KVStore中儲存著 UI 顯示所需的完備資訊。對於 History Server 的使用者來說,絕大多數情況下我們只關心任務的最終狀態,而無需關心引起狀態變化的具體 event。因此,我們可以只將 KVStore 持久化下來,而不需要儲存大量冗餘的 event 資訊。此外,KVStore原生支援了 Kryo 序列化,效能明顯於 Json 序列化。我們基於此思想重寫了一套新的 History Server 系統,命名為 UIService。

 

UIService框架圖

實現

UIMetaStore

KVStore

中和 UI 相關的所有類例項,我們將這些類統稱為 UIMeta 類。具體包括 AppStatusStoreSQLAppStatusStore中的資訊(如下所列)。我們定義一個類 UIMetaStore來抽象,一個UIMetaStore即一個任務所有 UI 資訊的集合。

UIMetaStore所包含資訊:

# AppStatusStore
org.apache.spark.status.JobDataWrapper
org.apache.spark.status.ExecutorStageSummaryWrapper
org.apache.spark.status.ApplicationInfoWrapper
org.apache.spark.status.PoolData
org.apache.spark.status.ExecutorSummaryWrapper
org.apache.spark.status.StageDataWrapper
org.apache.spark.status.AppSummary
org.apache.spark.status.RDDOperationGraphWrapper
org.apache.spark.status.TaskDataWrapper
org.apache.spark.status.ApplicationEnvironmentInfoWrapper

# SQLAppStatusStore
org.apache.spark.sql.execution.ui.SQLExecutionUIData
org.apache.spark.sql.execution.ui.SparkPlanGraphWrapper

UIMetaStore

還定義了持久化檔案的資料結構,結構如下:

4-Byte Magic Number: "UI_S"
----------- Body ---------------
4_byte_length_of_class_name | class_name_str1 | 4_byte_length | serialized_of_class1_instance1
4_byte_length_of_class_name | class_name_str1 | 4_byte_length | serialized_of_class1_instance2
4_byte_length_of_class_name | class_name_str2 | 4_byte_length | serialized_of_class2_instance1
4_byte_length_of_class_name | class_name_str2 | 4_byte_length | serialized_of_class2_instance2
  • Magic Number用於檔案型別標識校驗。

  • Body 是 UIMetaStore 的主體資料,使用連續儲存。每一個 UI 相關的類例項,會序列化成四個片段:類名長度(4 byte long 型別)+ 類名(string 型別)+ 資料長度(4 byte long 型別)+ 序列化的資料(二進位制型別)。在讀取時順序讀取,每個元素先讀取長度資訊,再根據長度讀取後續相應資料進行反序列化。

  • 使用 Spark 原生的KVStoreSerializer序列化,可以保證前後相容性。

 

UIMetaLoggingListener

類似於EventLoggingListener,為 UIMeta 開發了專用的 Listener —— UIMetaLoggingListener,用於監聽事件,寫 UIMeta 檔案。和EventLoggingListener進行對比:EventLoggingListener每接受一個 event 都會觸發寫,寫的是序列化的 event;而UIMetaLoggingListener只會被特定的 event 觸發,目前是隻會被stageEnd,JobEnd 事件觸發,但每次寫操作是批量的寫,將上一階段的UIMetaStore的資訊完整地持久化。做一個類比,EventLoggingListener好比流式,不斷地追加寫,而 UIMetaLoggingListener類似於批式,定期將任務狀態快照下來。

UIMetaProvider

替換原先的FsHistoryProvider,主要區別在於:

  • 將讀取 event log 檔案和回放生成KVStore的流程改為讀取UIMetaFile,反序列化出UIMetaStore

  • 去掉了FsHistoryProvider的路徑掃描邏輯;每次 UI 訪問,根據 appid 和路徑規則,直接去讀取 UIMetaFile 解析。這使得 UIService 無需預載入所有檔案元資訊,不需要隨著任務數量增加提高伺服器配置,方便了水平擴充套件。

優化

避免重複寫

由於每個 stage 完成都會觸發寫 UIMeta 檔案,這樣對於 UIMeta 的很多元素,可能會出現重複持久化的情況,增加寫入耗時和檔案的大小。因此我們在UIMetaLoggingListener內部維護了一個 map,記錄已經被序列化的例項。在寫 UIMeta 檔案時進行過濾,只寫沒有寫過或者資料發生改變的元素。這樣可以杜絕大部分的寫冗餘。此外,開發期間發現,佔用空間最大的是task級別資訊TaskDataWrapper。在一個 stage 完成觸發寫時。可能會將仍處於 RUNNING 狀態的 stage 的 task 序列化下來,這樣當 RUNNING 的 stage 完成時,task 資訊會再被寫一次,也會造成資料冗餘,因此我們對序列化TaskDataWrapper資訊進行過濾,在 stage 結束時只持久化狀態是 Completed 的 task 資訊。

支援回退到 event log

鑑於 UIService 在初期有存在問題的風險,我們還支援了回退機制,即訪問一個任務的 UI,優先嚐試走 UIService 的路徑:解析 UIMeta 檔案,如果 UIMeta 檔案不存在或者解析報錯,會回退到讀 event log 檔案的路徑,避免 UI 訪問失敗。同時還支援將 event log 檔案轉換成 UIMeta 檔案,這樣下一次呼叫時就可以使用 UIService。這個功能保證我們遷移過程的平滑。

收益

儲存收益

線上測試顯示儲存平均減少85%,總量減少92.4%。

下圖顯示了某機房 event log 和 UIMeta 儲存佔用監控,可以看到 UIMeta 較 event log 在儲存量上有數量級的減少。目前位元組內部7天的 event log 佔用儲存空間 3.2 PB,改用 UIMeta 後,空間佔用只有350TB。

憑藉 UIService 的儲存優勢,我們可以保留更長時間的日誌資訊,有助於歷史分析,問題覆盤。目前我們已從保留7天日誌提高到了保留30天,並可以根據需求增大保留時間。

某機房 event log/UIMeta HDFS儲存監控對比

訪問延遲收益

  • 訪問延遲:平均縮短 35%,PCT90/95/99 分別減少 84.6%/90.8%/93.7%。

訪問延遲百分位分佈

如下圖所示,UIService 的 UI 訪問延遲整體較比 event log 向左移,長尾任務明顯減少。

訪問延遲分佈圖

架構收益

去掉了原生 History Server 遍歷路徑,預載入的耗時環節,消除從任務完成到 History Server 可訪問的時間間隔,從原本的平均 10min 左右降低到秒級,任務完成即可立即對外提供服務。同時使 History Server 可以水平擴充套件,能更好應對未來任務量增長帶來的挑戰。

目前,位元組跳動內部我們通過增加 UIService 例項就可以方便的進行橫向擴充套件。在火山引擎湖倉一體分析服務 LAS 中,我們也基於 UIService 實現了支援租戶訪問隔離,雲原生的,可按需伸縮的 Spark History Server。