Flink 在 B 站的多元化探索與實踐

danny_2018發表於2022-12-09

在過去的一年裡,B站圍繞 Flink 主要做了三個方面的工作:平臺建設、增量化和 AI on Flink。實時平臺是實時業務的技術底座,也是 Flink 面向使用者的視窗,需要堅持持續迭代最佳化,不斷增強功能,提升使用者效率。增量化是我們在增量化數倉和流批一體上的嘗試,在實時和離線之間找到一個更好的平衡,加速數倉效率,解決計算口徑問題。AI 方向,我們也正在結合業務做進一步的探索,與 AIFlow 社群進行合作,完善最佳化機器學習工作流。

一、 平臺建設

1.1 基礎功能完善

在平臺的基礎功能方面,我們做了很多新的功能和最佳化。其中兩個重點的是支援 Kafka 的動態 sink 和任務提交引擎的最佳化。

我們遇到了大量這樣的 ETL 場景,業務的原始實時資料流是一條較大的混合資料流,包含了數個子業務資料。資料透過 Kafka 傳輸,末端的每個子業務都對應單獨的處理邏輯,每個子業務都去消費全量資料,再進行過濾,這樣的資源消耗對業務來說是難以接受的,Kafka 的 IO 壓力也很大。因此我們會開發一個 Flink 任務,對混合資料流按照子業務進行拆分,寫到子業務對應的 topic 裡,讓業務使用。

技術實現上,早期 Flink SQL 的寫法就是寫一個 source 再寫多個 sink,每個 sink 對應一個業務的 topic,這確實可以滿足短期的業務訴求,但存在的問題也較多:

第一是資料的傾斜,不同的子業務資料量不同,資料拆分後,不同 sink 之處理的資料量也存在較大差別,而且 sink 都是獨立的 Kafka producer,高峰期間會造成 sink 之間資源的爭搶,對效能會有明顯的影響;

第二是無法動態增減 sink,需要改變 Flink SQL 程式碼,然後重啟任務才能完成增減 sink。過程中,不僅所有下游任務都會抖動,還有一個嚴重的問題就是無法從 savepoint 恢復,也就意味著資料的一致性無法保證;

第三是維護成本高,部分業務存在上百個子分流需求,會導致 SQL 太長,維護成本極高。

基於以上原因,我們開發了一套 Kafka 動態 sink 的功能,支援在一個 Kafka sink 裡面動態地寫多個 topic 資料,架構如上圖。我們對 Kafka 表的 DDL 定義進行了擴充套件,在 topic 屬性裡支援了 UDF 功能,它會根據入倉的資料計算出這條資料應該寫入哪個 Kafka 叢集和 topic。sink 收到資料後會先呼叫 UDF 進行計算,拿到結果後再進行目標叢集和 topic 資料的寫入,這樣業務就不需要在 SQL 裡編寫多個 sink,程式碼很乾淨,也易於維護,並且這個 sink 被所有 topic 共用,不會產生傾斜問題。UDF 直接面向業務系統,分流規則也會平臺化,業務方配置好規則後,分流實施自動生效,任務不需要做重啟。而且為了避免 UDF 的效能問題,避免使用者自己去開發 UDF,我們提供了一套標準的分流,做了大量的快取最佳化,只要按照規範定義好分流,規則的業務表就可以直接使用 UDF。

目前內部幾個千億級別的分流場景,都在這套方案下高效執行中。

基礎功能上做的第二個最佳化就是任務的提交引擎最佳化。做提交器的最佳化主要是因為存在以下幾個問題:

第一,本地編譯問題。Flink SQL 任務在 Yarn 上的部署有三種模式:per-job、application 和 yarn-session。早前我們一直沿用 per-job 模式,但是隨著任務規模變大,這個模式出現了很多的問題。per-job 模式下,任務的編譯是在本地進行再提交到遠端 app master,編譯消耗提交引擎的服務效能,在短時批次操作時很容易導致效能不足;

第二,多版本的支援問題。我們支援多個 Flink 版本,因此在版本與提交引擎耦合的情況下,需要維護多個不同程式碼版本的提交引擎,維護成本高;

第三,UDF 的載入。我們一直使用 Flink 命令裡的 -c 命令進行 UDF 傳遞,UDF 程式碼包存在 UDFS 上,透過 Hadoop 的 web HDFS 協議進行 cluster 載入,一些大的任務啟動時,web HDFS 的 HTTP 埠壓力會瞬間增大,存在很大的穩定隱患;

第四,程式碼包的傳輸效率。使用者程式碼包或者 Flink 引擎程式碼包都要做多次的上傳下載操作,遇到 HDFS 反應較慢的場景,耗時較長,而實時任務希望做到極致的快速上下線。

因此我們做了提交器的最佳化:

首先引入了 1.11 版本以上支援的 application 模式,這個模式與 per-job 最大的區別就是 Flink 任務的編譯全部移到了 APP master 裡做,這樣就解決了提交引擎的瓶頸問題;

在多版本的支援上面,我們對提交引擎也做了改造,把提交器與 Flink 的程式碼徹底解耦,所有依賴 Flink 程式碼的操作全部抽象了標準的介面放到了 Flink 原始碼側,並在 Flink 原始碼側增加了一個模組,這個模組會隨著 Flink 的版本一起升級提交引擎,對通用介面的呼叫全部進行反射和快取,在效能上也是可接受的;

而且 Flink 的多版本原始碼全部按照 maled 模式進行管理,存放在 HDFS。按照業務指定的任務版本,提交引擎會從遠端下載 Flink 相關的版本包快取到本地,所以只需要維護一套提交器的引擎。Flink 任何變更完全和引擎無關,升級版本提交引擎也不需要參與;

完成 application 模式升級後,我們對 UDF 和其他資源包的上傳下載機制也進行了修改,透過 HDFS 遠端直接分發到 GM/TM 上,減少了上傳下載次數,同時也避免了 cluster 的遠端載入。

1.2 新任務構建模式

平臺之前支援 Flink 的構建模式主要有兩種, SQL 和 JAR 包。兩者的優劣勢都很明顯,SQL 簡單易用門檻低,但是不夠靈活,比如一些定時操作在 SQL 裡面無法進行。JAR 包功能完善也靈活,但是門檻高,需要學習 Flink datastream 一整套 API 的概念,非開發人員難以掌握,而我們大量的使用者是數倉,這種JAR包的任務難以標準化管理。業務方大多希望使用 SQL,避免使用 JAR 包。

我們調研了平臺已有的 Datastream JAR 包任務,發現大部分的 JAR 包任務還是以 Table API 為主,只有少量過程用 Datastream 做了一些資料的轉換,完成之後還是註冊成了 Table 進行 Table 操作。如果平臺可以支援在 SQL 裡面做一些複雜的自定義轉換,業務其實完全不需要編寫程式碼。


因此我們支援了一種新的任務構建模式——運算元化,模組化地構建一個 Flink 任務,混合 JAR 包與 SQL,在進行任務構建時,先定義一段 SQL,再定義一個 JAR 包,再接一段 SQL,每段都稱為運算元,運算元之間相互串聯,構成一個完整的任務。

採用 Flink 標準的 SQL 語法,對 JAR 包進行了介面的限制,必須繼承平臺的介面定義進行開發。輸入輸出都是定義好的 Datastream。它比 UDF 的擴充套件性更強,靈活性也更好。而且整個任務的輸入輸出基本可以做到和 SQL 同級別的管控力,運算元的開發也比純 JAR 包簡單得多,不需要學習太多 Flink API 的操作,只需要對 Datastream 進行變換。而且對於一些常用的公共運算元,平臺可以統一開發提供,擁有更專業的效能最佳化,業務方只要引用即可。

目前在實時數倉等一些偏固定業務的場景,我們都在嘗試進行標準化運算元的推廣和使用。

1.3 智慧診斷

平臺建設的第三點是流任務的智慧診斷。目前實時支援的業務場景包括 ETL、AI、資料整合等,且任務規模增長速度很快。越來越大的規模對平臺的服務能力也提出了更高的要求。


此前,平臺人員需要花費很多的時間在協助業務解決資源或各種業務問題上,主要存在以下幾個方面的問題:

資源配置:初始資源確認困難,碎片化嚴重,使用資源週期性變化;

效能調優:資料傾斜,網路資源最佳化,state 效能調優,gc 效能調優;

錯誤診斷:任務失敗原因分析,修復建議。

這些問題日常都靠平臺人員兜底,規模小的時候大家勉強可以負擔,但是規模快速變大後已經完全無力消化,需要一套自動化的系統來解決這些問題。

因此我們做了一套流任務的智慧診斷系統,架構如上圖。

系統會持續抓取任務執行時的 metrics 進行效能分析,分析完成後推給使用者,讓使用者自己執行具體的最佳化改進操作;也會實時抓取任務失敗的日誌,並與詞庫進行匹配,將錯誤進行翻譯,使使用者更容易理解,同時也會給出更好理解的解決方案,讓使用者自行進行故障處理;同時還會根據任務的歷史執行資源進行自動化縮容處理,解決資源浪費和資源不足的問題。

目前此功能已經節省了整個佇列 10% 的資源左右,分擔了相當一部分平臺的運維壓力,在未來我們會持續進行最佳化迭代,更進一步提高這套系統在自動化運維上面的能力以及覆蓋度。

未來,在提交引擎方面,我們希望融合 Yarn session 模式與 application 模式做 session 的複用,解決任務上線的資源申請效率問題。同時希望大 state 任務也能夠在 session 的基礎上覆用本地的 state,啟動時無需重新下載 state。

智慧診斷方面,我們希望實現更多自動化的操作,實現自動進行最佳化改進,而不需要使用者手動操作,做到使用者低感知;擴容縮容也會持續提速,目前縮容的頻率只在天級,擴容還未實現自動化。未來我們希望整個操作的週期和頻率做到分鐘級的自動化。

運算元方面,我們希望能統一目前的 SQL 和 JAR 包兩種模式,統一任務構建方式,讓使用者以更低的成本更多複雜的操作,平臺也更方便管理。

二、增量化

上圖是我們早期的資料架構,是典型的 Lambda 架構。實時和離線從源頭上就完全分離、互不干涉,實時佔較低,離線數倉是核心的數倉模型,佔主要的比例,但它存在幾個明顯的問題。

第一,時效性。數倉模型是分層架構,層與層之間的轉換靠排程系統驅動,而排程系統是有周期的,常見的基本都是天或小時。源頭生產的資料,數倉各層基本需要隔一天或幾個小時才可見,無法滿足實時性要求稍高的場景;

第二,資料的使用效率低。ETL 和 adhoc 的資料使用完全一樣,沒有針對性的讀寫最佳化,也沒有按照使用者的查詢習慣進行重新組織,缺乏資料佈局最佳化的能力。

針對第一個問題,是否全部實時化即可?但是實時數倉的成本高,而且不太好做大規模的資料回溯。大部分業務也不需要做到 Kafka 的秒級時效。第二個問題也不好解決,流式寫入為了追求效率,對資料的佈局能力較弱,不具備資料的重新組織能力。因此我們在實時和離線之間找到了一個平衡——做分鐘級的增量化。

我們採用 Flink 作為計算引擎,它的 checkpoint 是一個天然的增量化機制,實時任務進行一次 checkpoint,產出一批增量資料進行增量化處理。數倉來源主要有日誌資料和 binlog 資料,日誌資料使用 Append 傳統的 HDFS 儲存即可做到增量化的生產;binlog 資料是 update 模式,但 HDFS 對 update 的支援並不好,因此我們引入了 Hudi 儲存,它能夠支援 update 操作,並且具備一定的資料佈局能力,同時它也可以做 Append 儲存,並且能夠解決 HDFS 的一些小檔案問題。因此日誌資料也選擇了 Hudi 儲存,採用 Append 模式。

最終我們的增量化方案由 Flink 計算引擎 + Hudi 儲存引擎構成。

增量化場景的落地上,考慮到落地的複雜性,我們先選取了業務邏輯相對簡單、沒有複雜聚合邏輯的 ODS 和 DWD 層進行落地。目前的資料是由 Flink 直接寫到 Hive 的 ODS 層,我們對此進行了針對性的適配,支援了 Hive 表的增量化讀取,開發了 HDFSStreamingSource,同時為了避免對 HDFS 路徑頻繁掃描的壓力,ODS 層寫入時會進行索引建立,記錄寫入的檔案路徑和時間,只需要追蹤索引檔案即可。

source 也是分層架構,有檔案分發層和讀取層,檔案分發層進行協調,分配讀取檔案數,防止讀取層某個檔案讀取過慢堆積過多檔案,中間的轉換能夠支援 FlinkSQL 操作,具備完整的實時數倉的能力。

sink 側我們引入了 Hudi connector,支援資料 Append 寫入 Hudi,我們還對 Hudi 的 compaction 機制進行了一些擴充套件,主要有三個:DQC 檢測、資料佈局的最佳化以及對映到 Hive 表的分割槽目錄。目前資料的佈局依舊還很弱,主要依賴 Hudi 本身的 min、max 和 bloom 的最佳化。

完成所有上述操作後,ODS 到 DWD 的資料時效性有了明顯提升。

從資料生產到 DWD 可見,提高到了分鐘級別;DWD 層的生產完成時間也從傳統的 2:00~5:00 提前到了凌晨 1 點之前。此外,採用 Hudi 儲存也為日後的湖倉一體打下了以一個好的基礎。

除了日誌資料,我們對 CDC 也採用這套方案進行加速。基於 Flink 的 CDC 能力,針對 MySQL 的資料同步實現了全增量一體化操作。依賴 Hudi 的 update 能力,單任務完成了 MySQL 的資料同步工作,並且資料只延遲了一個 checkpoint 週期。CDC 暫時不支援全量拉取,需要額外進行一次全量的初始化操作,其他的流程則完全一致。

Hudi 本身的模型和離線的分割槽全量有較大的區別,為了相容離線排程需要的分割槽全量資料,我們也修改了 Hudi 的 compaction 機制。在做劃分割槽的 compaction 時會做一次資料的全量複製,生成全量的歷史資料分割槽,對映到 Hive 表的對應分割槽。同時對於 CDC 場景下的資料質量,我們也做了很多的保障工作。

為了保證 CDC 資料的一致性,我們從以下 4 個方面進行了完善和最佳化:

第一,binlog 條數的一致性。按照時間視窗進行 binlog 生產側和消費側的條數校驗,避免中介軟體丟資料;

第二,資料內容抽樣檢測。考慮到成本,我們在 DB 端和源端、Hudi 儲存端抽樣增量資料進行內容的精確比較,避免 update 出錯;

第三,全鏈路的黑盒測試。測試庫表模擬了線上情況,進行 7×24 小時不間斷的 Kafka 生產 MySQL 資料,然後串通整套流程防止鏈路故障;

第四,定期的全量對比。業務的庫表一般比較大,歷史資料會低頻地定期進行全量比對,防止抽樣觀測漏掉的錯誤。

剛開始使用 Hudi 的時候,Hudi on Flink 還是處於初級的階段,因此存在大量問題,我們也一起和 Hudi 社群做了大量最佳化工作,主要有 4 個方面:Hudi 表的冷啟動最佳化、checkpoint 一致性問題解決、Append 效率低的最佳化以及 get list 的效能問題。

首先是冷啟動的問題。Hudi 的索引儲存在 Flink state 裡,一張存在的 Hudi 表如果要透過 Flink 進行增量化更新寫入,就必然面臨一個問題:如何把 Hudi 表已有的資訊寫入到 Flink state 裡。

MySQL 可以藉助 Flink CDC 完成全量 + 增量的過程構建,可以繞開從已有 Hudi 表冷啟動的過程,但是 TiDB 不行,它的存量表在藉助別的手段構建完之後,想要增量化就會面臨如何從 FlinkSQL 冷啟動的問題。

社群有個原始方案,在記錄所有的運算元 BucketAssigner 裡面讀取全部的 Hudi 表資料,然後進行 state 構建,從功能上是可行的,但是在效能上根本無法接受,尤其是大表,由於 Flink 的 key state 機制原理,BucketAssigner 每個併發度都要讀取全表資料,然後挑選出屬於當前這個併發的資料儲存到自己的 state 裡面,每個並方案都要去讀全量的表,這在效能上難以滿足。

業務能啟動的時間太長了,很多百億級別的表能啟動的時間可能是在幾個小時,而且讀取的資料太多,很容易失敗。

和社群進行了溝通交流後,他們提供了一套全新的方案,新增了獨立的 Bootstrap 機制,專門負責冷啟動過程。Bootstrap 由 coordinator 和 IndexBootstrap 兩個運算元組成,IndexBootstrap 負責讀取工作,coordinator 負責協調分配檔案讀取,防止單個 IndexBootstrap 讀取速度慢而降低整個初始化流程的效率。

IndexBootstrap 運算元讀取到資料後,會按照與業務資料一樣的 Keyby 規則,Keyby 到對應的 BucketAssigner 運算元上,並在資料上面打標,告知 BucketAssigner 這條資料是有 Bootstrap 的,不需要往下游 writer 傳送。整個流程裡,原始資料只需讀取一遍,而且是多併發一起讀,效率獲得了極大的提升。而且 BucketAssigner 只需要處理自己應該處理的資料,不再需要處理全表的資料。

其次是 Hudi 的 checkpoint 一致性問題。Hudi on checkpoint 在每次 checkpoint 完成的時候會進行一次 commit 操作,具體流程是 writer 運算元在 checkpoint 的時候 flush 記憶體資料,然後給 writer coordinator 運算元彙報彙總資訊,writer coordinor 運算元收到彙報資訊時會將其快取起來,checkpoin 完成後,收到 notification 資訊時會進行一次 commit 操作。

但是在 Flink 的 checkpoint 機制裡,notification 無法保證一定成功,因為它並不在 checkpoint 的生命週期裡,而是一個回撥操作,是在 checkpoin 成功後執行。checkpoin 成功後,如果這個介面還沒有執行完成,commit 操作就會丟失,也就意味著 checkpoint 週期內的資料會丟失。

針對上述問題,我們進行了重構。Writer 運算元在 cehckpoint 時,會對彙報的 writer coordinator 的資訊進行 state 持久化,任務重啟後重新彙報給 writer coordinator 運算元。writer coordinator 運算元再收集所有 writer 運算元資訊並做一次 commit 判斷,確保對應的 commit 已經完成。此時,Writer 運算元也會保持阻塞,確保上次持久化的 commit 完成之後才會處理最新的資料,這樣就對齊了 Hudi 與 Flink 的 checkpoint 機制,保證了邊界場景資料的一致性。

第三是針對 Hudi 在 Append 寫入場景下的最佳化。

由於 Append 模式是複用 update 模式的程式碼,所以在沒有重複 key 的 Append 場景下,很多操作是可以簡化的,因為 update 為了處理重複,需要做很多額外的操作。如果能夠簡化這些操作,吞吐能力可以有較大的提升。

第一個操作是小檔案的查詢,每次 checkpoint 後,update 都會重新 list 檔案,然後從檔案中找到大小不達標的檔案繼續 open 並寫入。update 場景存在傾斜,會造成很多檔案大小不均勻,但是 Append 場景不存在這種問題,它所有的檔案大小都很均勻;

第二個是 keyby。在 update 的模式下面,單個 key 只能被一個節點處理,因此上游需要按照 Hudi key 進行 keyby 操作。但是 Append 場景沒有重複 key,可以直接用 chain 代替 keyby,大大減少了節點之間序列化傳輸的開銷。同時 Append 場景下不存在記憶體合併,整體效率也會更高。

最後一個是 GetListing 的最佳化。Hudi 表與底層 HDFS 檔案的對映是透過 ViewManager 來做的,Hudi table 物件和 TimelineService 都會自己去初始化一個 ViewManager,每個 ViewManager 在初始化的時候都會進行 HDFS 目錄的 list 操作,由於每個併發都持有多個 Hudi table 或 TimelineService,會造成大併發任務啟動時 HDFS 的壓力很大。我們對 TimelineService 進行了單例化的最佳化,保證每個程式只有一 TimelineService,能夠數倍地降低 HDFS list 的壓力。後續我們還會基於 Flink 的 coordinator 機制做任務級別的單例化。

未來,我們會繼續挖掘增量的能力,給業務帶來更多的價值。

三、AI on Flink

傳統的機器學習鏈路裡資料的傳輸、特徵的計算以及模型的訓練,都是離線處理的,存在兩個大的問題。

第一個是時效性低,模型和特徵的更新週期基本是 t+1 天或者 t+1 小時,在追求時效性的場景下體驗並不好。第二個是計算訓練的效率很低,必須等天或小時的分割槽資料全部準備好之後才能開始特徵計算和訓練。全量分割槽資料導致計算和訓練的壓力大。

在實時技術成熟後,大部分模型訓練流程都切換到實時架構上,資料傳輸、特徵計算和訓練都可以做到幾乎實時,從全量變成了短時的小批次增量進行,訓練的壓力也大大減輕。同時由於實時對離線的相容性,在很多場景比如特徵回補上,也可以嘗試使用 Flink 的流批一體進行落地。

上圖是我們典型的機器學習鏈路圖。從圖上可以看出,樣本資料生產特徵的計算、模型的訓練和效果的評估都大量實時化,中間也夾雜著少量離線過程,比如一些超長週期的特徵計算。

同時也可以看出,完整的業務的模型訓練鏈路長,需要管理和維護大量的實時任務和離線任務。出現故障的時候,具體問題的定位也異常艱難。如何在整個機器學習的鏈路中同時管理號這麼多實時和離線任務,並且讓任務之間的協同和排程有序進行、高效運維,是我們一直在思考的問題。

因此我們引入了 Flink 生態下 AIFlow 系統。AIFlow 本身的定位就是做機器學習鏈路的管理,核心的機器計算引擎是 Flink,這和我們的訴求不謀而合。這套系統有三個主要的特性符合我們的業務需求。

第一,流批的混合排程。在我們實際的業務生產上,一套完整的實時鏈路都會夾雜著實時和離線兩種型別的任務。AIFlow 支援流批的混合排程,支援資料依賴與控制依賴,能夠很好地支援我們現有的業務形態。並且未來在 Flink 流批一體方面也會有更多的發揮空間;

第二,後設資料的管理,AIFlow 對所有資料和模型都支援版本管理。有了版本管理,各種實驗效果和實驗引數就都可追溯;

第三,開放的 notification 機制。整個鏈路中存在很多的外部系統節點,難以歸納到平臺內部,但是透過 notification 機制,可以打通 AIFlow 內部節點與外部節點的依賴。整套系統的部署分為三部分,notification service、 meta service 以及 scheduler,擴充套件性也很好,我們在內部化的過程中實現了很多自己的擴充套件。

實時平臺在今年引入 AIFlow 的之後已經經歷了兩個版本的迭代,V2 版本是社群 release 之前的一個內部版本,我們進行了分裝提供試用。V3 版本是今年 7 月社群正式 release 之後,我們進行了版本的對接。

AIFlow 的構建使用 Python 進行描述,執行時會有視覺化的節點展示,可以很方便地追蹤各個節點的狀態,運維也可以做到節點級的管理,不需要做整個鏈路級別的運維。

未來我們會對這套系統在流批一體、特徵管理以及模型訓練三個方向進行重點的迭代與開發,更好地發揮它的價值。

來自 “ Apache Flink ”, 原文作者:張楊@bilibili;原文連結:https://mp.weixin.qq.com/s/_wt4gDRbr9seEjZacKvVLg,如有侵權,請聯絡管理員刪除。

相關文章