摘要:本文整理自京東高階技術專家韓飛在 Flink Forward Asia 2021 流批一體專場的分享。主要內容包括:
- 整體思考
- 技術方案及優化
- 落地案例
- 未來展望
一、整體思考
提到流批一體,不得不提傳統的大資料平臺 —— Lambda 架構。它能夠有效地支撐離線和實時的資料開發需求,但它流和批兩條資料鏈路割裂所導致的高開發維護成本以及資料口徑不一致是無法忽視的缺陷。
通過一套資料鏈路來同時滿足流和批的資料處理需求是最理想的情況,即流批一體。此外我們認為流批一體還存在一些中間階段,比如只實現計算的統一或者只實現儲存的統一也是有重大意義的。
以只實現計算統一為例,有一些資料應用的實時性要求比較高,比如希望端到端的資料處理延時不超過一秒鐘,這對目前開源的、適合作為流批統一的儲存來說是一個很大的挑戰。以資料湖為例,它的資料可見性與 commit 的間隔相關,進而與 Flink 做 checkpoint 的時間間隔相關,此特性結合資料處理鏈路的長度,可見做到端到端一秒鐘的處理並不容易。因此對於這類需求,只實現計算統一也是可行的。通過計算統一去降低使用者的開發及維護成本,解決資料口徑不一致的問題。
在流批一體技術落地的過程中,面臨的挑戰可以總結為以下 4 個方面:
- 首先是資料實時性。如何把端到端的資料時延降低到秒級別是一個很大的挑戰,因為它同時涉及到計算引擎及儲存技術。它本質上屬於效能問題,也是一個長期目標。
- 第二個挑戰是如何相容好在資料處理領域已經廣泛應用的離線批處理能力。此處涉及開發和排程兩個層面的問題,開發層面主要是複用的問題,比如如何複用已經存在的離線表的資料模型,如何複用使用者已經在使用的自定義開發的 Hive UDF 等。排程層面的問題主要是如何合理地與排程系統進行整合。
- 第三個挑戰是資源及部署問題。比如通過不同型別的流、批應用的混合部署來提高資源利用率,以及如何基於 metrics 來構建彈性伸縮能力,進一步提高資源利用率。
- 最後一個挑戰也是最困難的一個:使用者觀念。大多數使用者對於比較新的技術理念通常僅限於技術交流或者驗證,即使驗證之後覺得可以解決實際問題,也需要等待合適的業務來試水。這個問題也催生了一些思考,平臺側一定要多站在使用者的視角看待問題,合理地評估對使用者的現有技術架構的改動成本以及使用者收益、業務遷移的潛在風險等。
上圖是京東實時計算平臺的全景圖,也是我們實現流批一體能力的載體。中間的 Flink 基於開源社群版本深度定製。基於該版本構建的叢集,外部依賴包含三個部分,JDOS、HDFS/CFS 和 Zookeeper。
- JDOS 是京東的 Kubernetes 平臺,目前我們所有 Flink 計算任務容器化的,都執行在這套平臺之上;
- Flink 的狀態後端有 HDFS 和 CFS 兩種選擇,其中 CFS 是京東自研的物件儲存;
- Flink 叢集的高可用是基於 Zookeeper 構建的。
在應用開發方式方面,平臺提供 SQL 和 Jar 包兩種方式,其中 Jar 的方式支援使用者直接上傳 Flink 應用 Jar 包或者提供 Git 地址由平臺來負責打包。除此之外我們平臺化的功能也相對比較完善,比如基礎的後設資料服務、SQL 除錯功能,產品端支援所有的引數配置,以及基於 metrics 的監控、任務日誌查詢等。
連線資料來源方面,平臺通過 connector 支援了豐富的資料來源型別,其中 JDQ 基於開源 Kafka 定製,主要應用於大資料場景的訊息佇列;JMQ 是京東自研,主要應用於線上系統的訊息佇列;JimDB 是京東自研的分散式 KV 儲存。
在當前 Lambda 架構中,假設實時鏈路的資料儲存在 JDQ,離線鏈路的資料存在 Hive 表中,即便計算的是同一業務模型,後設資料的定義也常常是存在差異的,因此我們引入統一的邏輯模型來相容實時離線兩邊的後設資料。
在計算環節,通過 FlinkSQL 結合 UDF 的方式來實現業務邏輯的流批統一計算,此外平臺會提供大量的公用 UDF,同時也支援使用者上傳自定義 UDF。針對計算結果的輸出,我們同樣引入統一的邏輯模型來遮蔽流批兩端的差異。對於只實現計算統一的場景,可以將計算結果分別寫入流批各自對應的儲存,以保證資料的實時性與先前保持一致。
對於同時實現計算統一和儲存統一的場景,我們可以將計算的結果直接寫入到流批統一的儲存。我們選擇了 Iceberg 作為流批統一的儲存,因為它擁有良好的架構設計,比如不會繫結到某一個特定的引擎等。
在相容批處理能力方面,我們主要進行了以下三個方面的工作:
第一,複用離線數倉中的 Hive 表。
以資料來源端為例,為了遮蔽上圖左側圖中流、批兩端後設資料的差異,我們定義了邏輯模型 gdm_order_m 表,並且需要使用者顯示地指定 Hive 表和 Topic 中的欄位與這張邏輯表中欄位的對映關係。這裡對映關係的定義非常重要,因為基於 FlinkSQL 的計算只需面向這張邏輯表,而無需關心實際的 Hive 表與 Topic 中的欄位資訊。在執行時通過 connector 建立流表和批表的時候,邏輯表中的欄位會通過對映關係被替換成實際的欄位。
在產品端,我們可以給邏輯表分別繫結流表和批表,通過拖拽的方式來指定欄位之間的對映關係。這種模式使得我們的開發方式與之前有所差異,之前的方式是先新建一個任務並指定是流任務還是批任務,然後進行 SQL 開發,再去指定任務相關的配置,最後釋出任務。而在流批一體模式下,開發模式變為了首先完成 SQL 的開發,其中包括邏輯的、物理的 DDL 的定義,以及它們之間的欄位對映關係的指定,DML 的編寫等,然後分別指定流批任務相關的配置,最後釋出成流批兩個任務。
第二,與排程系統打通。
離線數倉的資料加工基本是以 Hive/Spark 結合排程的模式,以上圖中居中的圖為例,資料的加工被分為 4 個階段,分別對應數倉的 BDM、FDM、GDM 和 ADM 層。隨著 Flink 能力的增強,使用者希望把 GDM 層的資料加工任務替換為 FlinkSQL 的批任務,這就需要把 FlinkSQL 批任務嵌入到當前的資料加工過程中,作為中間的一個環節。
為了解決這個問題,除了任務本身支援配置排程規則,我們還打通了排程系統,從中繼承了父任務的依賴關係,並將任務自身的資訊同步到排程系統中,支援作為下游任務的父任務,從而實現了將 FlinkSQL 的批任務作為原資料加工的其中一個環節。
第三,對使用者自定義的 Hive UDF、UDAF 及 UDTF 的複用。
對於現存的基於 Hive 的離線加工任務,如果使用者已經開發了 UDF 函式,那麼最理想的方式是在遷移 Flink 時對這些 UDF 進行直接複用,而不是按照 Flink UDF 定義重新實現。
在 UDF 的相容問題上,針對使用 Hive 內建函式的場景,社群提供了 load hive modules 方案。如果使用者希望使用自己開發的 Hive UDF,可以通過使用 create catalog、use catalog、create function,最後在 DML 中呼叫的方式來實現, 這個過程會將 Function 的資訊註冊到 Hive 的 Metastore 中。從平臺管理的角度,我們希望使用者的 UDF 具備一定的隔離性,限制使用者 Job 的粒度,減少與 Hive Metastore 互動以及產生髒函式後設資料的風險。
此外,當元資訊已經被註冊過,希望下次能在 Flink 平臺端正常使用,如果不使用 if not exist 語法,通常需要先 drop function,再進行 create 操作。但是這種方式不夠優雅,同時也對使用者的使用方式有限制。另一種解決方法是使用者可以註冊臨時的 Hive UDF,在 Flink1.12 中註冊臨時 UDF 的方式是 create temporary function,但是該 Function 需要實現 UserDefinedFunction 介面後才能通過後面的校驗,否則會註冊失敗。
所以我們並沒有使用 create temporary function,而是對 create function 做了一些調整,擴充套件了 ExtFunctionModule,將解析出來的 FunctionDefinition 註冊到 ExtFunctionModule 中,做了一次 Job 級別的臨時註冊。這樣的好處就是不會汙染 Hive Metastore,提供了良好的隔離性,同時也沒有對使用者的使用習慣產生限制,提供了良好的體驗。
不過這個問題在社群 1.13 的版本已經得到了綜合的解決。通過引入 Hive 解析器等擴充套件,已經可以把實現 UDF、GenericUDF 介面的自定義 Hive 函式通過 create temporary function 語法進行註冊和使用。
資源佔用方面,流處理和批處理是天然錯峰的。對於批處理,離線數倉每天 0 點開始計算過去一整天的資料,所有的離線報表的資料加工會在第二天上班前全部完成,所以通常 00:00 到 8:00 是批計算任務大量佔用資源的時間段,而這個時間段通常線上的流量都比較低。流處理的負載與線上的流量是正相關的,所以這個時間段流處理的資源需求是比較低的。上午 8 點到晚上 0 點,線上的流量比較高,而這個時間段批處理的任務大部分都不會被觸發執行。
基於這種天然的錯峰,我們可以通過在專屬的 JDOS Zone 中進行不同型別的流批應用的混部來提升資源的使用率,並且如果統一使用 Flink 引擎來處理流批應用,資源的使用率會更高。
同時為了使應用可以基於流量進行動態調整,我們還開發了自動彈性伸縮的服務 (Auto-Scaling Service)。它的工作原理如下:執行在平臺上的 Flink 任務上報 metrics 資訊到 metrics 系統,Auto-Scaling Service 會基於 metrics 系統中的一些關鍵指標,比如 TaskManager 的 CPU 使用率、任務的背壓情況等來判定任務是否需要增減計算資源,並把調整的結果反饋給 JRC 平臺,JRC 平臺通過內嵌的 fabric 客戶端將調整的結果同步到 JDOS 平臺,從而完成對 TaskManager Pod 個數的調整。此外,使用者可以在 JRC 平臺上通過配置來決定是否為任務開啟此功能。
上圖右側圖表是我們在 JDOS Zone 中進行流批混部並結合彈性伸縮服務試點測試時的 CPU 使用情況。可以看到 0 點流任務進行了縮容,將資源釋放給批任務。我們設定的新任務在 2 點開始執行,所以從 2 點開始直到早上批任務結束這段時間,CPU 的使用率都比較高,最高到 80% 以上。批任務執行結束後,線上流量開始增長時,流任務進行了擴容,CPU 的使用率也隨之上升。
二、技術方案及優化
流批一體是以 FlinkSQL 為核心載體,所以我們對於 FlinkSQL 的底層能力也做了一些優化,主要分為維表優化、join 優化、window 優化和 Iceberg connector 優化幾個方面。
首先是維表相關的幾個優化。目前社群版本的 FlinkSQL 只支援部分資料來源 sink 運算元並行度的修改,並不支援 source 以及中間處理運算元的並行度修改。
假設一個 FlinkSQL 任務消費的 topic 有 5 個分割槽,那麼下游運算元的實際並行度是 5,運算元之間是 forward 的關係。對於資料量比較大的維表 join 場景,為了提高效率,我們希望並行度高一些,希望可以靈活設定它的並行度而不與上游的分割槽數繫結。
基於此,我們開發了預覽拓撲的功能,不論是 Jar 包、SQL 任務都可以解析並生成 StreamGraph 進行預覽,進一步還能支援修改分組、運算元 chain 的策略、並行度、設定 uid 等。
藉助這個功能,我們還可以調整維表 join 運算元的並行度,並且將分割槽策略由 forward 調整為 rebalance,然後把這些調整後的資訊更新到 StreamGraph。此外我們還實現了動態 rebalance 策略,可以基於 backLog 去判斷下游分割槽中的負載情況,從而選擇最優的分割槽進行資料分發。
為了提升維表 join 的效能,我們對所有平臺支援的維表資料來源型別都實現了非同步 IO 並支援在記憶體中做快取。不論是原生的 forward 方式還是 rebalance 方式,都存在快取失效和替換的問題。那麼,如何提高維表快取的命中率以及如何降低維表快取淘汰的操作?
以原生的 forward 方式為例,forward 意味著每個 subtask 快取著隨機的維表資料,與 joinkey 的值有關。對維表的 joinkey 做雜湊,就能保證下游每一個運算元快取著與 joinkey 相關的、不同的維表資料,從而有效地提升快取的命中率。
在實現層面我們新增了一條叫 StreamExecLookupHashJoinRule 的優化規則,並且把它新增到物理 rewrite 的階段。在最底層的掃描資料 StreamExecTableSourceScan 和維表 join StreamExecLookupJoin 之間增加了一個 StreamExecChange 節點,由它來完成對維表資料的雜湊操作。可以通過在定義維表 DDL 時指定 lookup.hash.enable=true 來開啟這個功能。
我們對於 forward、rebalance、雜湊三種方式開啟快取,進行了相同場景的效能測試。主表一億條資料去 join 維表的 1 萬條資料,在不同的計算資源下,rebalance 相較於原生的 forward 方式有數倍的效能提升,而雜湊相較於 rebalance 的方式又有數倍的效能提升,整體效果是比較可觀的。
針對維表 join 單條查詢效率比較低的問題,解決思路也很簡單,就是攢批,按照微批的方式去訪問 (mini-batch)。可以在 DDL 的定義中通過設定 lookup.async.batch.size 的值來指定批次的大小。除此之外,我們還在時間維度上引入了 Linger 機制來做限制,防止極端場景出現遲遲無法攢夠一批資料而導致時延比較高的情況,可以通過在 DDL 的定義中設定 lookup.async.batch.linger 的值來指定等待時間。
經過測試,mini-batch 的方式能夠帶來 15% ~ 50% 的效能提升。
Interval join 也是生產上一個使用比較頻繁的場景,這類業務的特點是流量非常大,比如 10 分鐘百 GB 級別。Interval join 兩條流的資料都會快取在內部 state 中,任意一邊的資料到達都會獲取對面流相應時間範圍的資料去執行 join function,所以這種大流量的任務會有非常大的狀態。
對此我們選用了 RocksDB 來做狀態後端,但是進行了調參優化後效果仍不理想,任務執行一段時間之後會出現背壓,導致 RocksDB 的效能下降,CPU 的使用率也比較高。
通過分析我們發現,根本原因與 Flink 底層掃描 RocksDB 是基於字首的掃描方式有關。因此解決思路也很簡單,根據查詢條件,精確地構建查詢的上下界,把字首查詢變為範圍查詢。查詢條件依賴的具體上下界的 key 變為了 keyGroup+joinKey+namespace+timestamp[lower,upper],可以精確地只查詢某些 timestamp 之間的資料,任務的背壓問題也得到了解決。而且資料量越大,這種優化帶來的效能提升越明顯。
Regular join 使用狀態來儲存所有歷史資料,所以如果流量大也會導致狀態資料比較大。而它儲存狀態是依賴 table.exec.state.ttl 引數,這個引數值比較大也會導致狀態大。
針對這種場景,我們改為使用外部儲存JimDB儲存狀態資料。目前只做了 inner join 的實現,實現機制如下:兩邊的流對 join 到的資料進行下發的同時,將所有資料以 mini-batch 的方式寫入到 JimDB,join 時會同時掃描記憶體中以及 JimDB 中對應的資料。此外,可以通過 JimDB ttl 的機制來實現 table.exec.state.ttl 功能,從而完成對過期資料的清理。
上述實現方式優缺點都比較明顯,優點是可以支援非常大的狀態,缺點是目前無法被 Flink checkpoint 覆蓋到。
對於 window 的優化,首先是視窗偏移量。需求最早來源於一個線上場景,比如我們想統計某個指標 2021 年 12 月 4 日 0 點 ~ 2021 年 12 月 5 日 0 點的結果, 但由於線上叢集是東 8 區時間,所以實際統計的結果是 2021 年 12 月 4 日早上 8 點 ~ 2021 年 12 月 5 日早上 8 點的結果,這顯然不符合預期。因此這個功能最早是為了修復非本地時區跨天級別的視窗統計錯誤的問題。
在我們增加了視窗偏移量引數後,可以非常靈活地設定視窗的起始時間,能夠支援的需求也更廣泛。
其次,還存在另外一個場景:雖然使用者設定了視窗大小,但是他希望更早看到視窗當前的計算結果,便於更早地去做決策。因此我們新增了增量視窗的功能,它可以根據設定的增量間隔,觸發執行輸出視窗的當前計算結果。
對於端到端實時性要求不高的應用,可以選擇 Iceberg 作為下游的統一儲存。但是鑑於計算本身的特性、使用者 checkpoint 間隔的配置等原因,可能導致產生大量的小檔案。Iceberg 的底層我們選用 HDFS 作為儲存,大量的小檔案會對 Namenode 產生較大的壓力,所以就有了合併小檔案的需求。
Flink 社群本身提供了基於 Flink batch job 的合併小檔案的工具可以解決這個問題,但這種方式有點重,所以我們開發了運算元級別的小檔案合併的實現。思路是這樣的,在原生的 global commit 之後,我們新增了三個運算元 compactCoordinator、 compactOperator 和 compactCommitter,其中 compactCoordinator 負責獲取待合併的 snapshot 並下發,compactOperator 負責 snapshot 的合併操作的執行,並且可以多個 compactOperator 併發執行,compactCommitter 負責合併後 datafiles 的提交。
我們在 DDL 的定義中新增了兩個引數,auto-compact 指定是否開啟合併檔案的功能,compact.delta.commits 指定每提交多少次 commit 來觸發一次 compaction。
在實際的業務需求中,使用者可能會從 Iceberg 中讀取巢狀資料,雖然可以在 SQL 中指定讀取巢狀欄位內部的資料,但是在實際讀取資料時是會將包含當前巢狀欄位的所有欄位都讀取到,再去獲取使用者需要的欄位,而這會直接導致 CPU 和網路頻寬負載的增高,所以就產生了如下需求:如何只讀取到使用者真正需要的欄位?
解決這個問題,要滿足兩個條件,第一個條件是讀取 Iceberg 的資料結構 schema 只包含使用者需要的欄位,第二個條件是 Iceberg 支援按列名去讀取資料,而這個本身已經滿足了,所以我們只需要實現第一個條件即可。
如上圖右側所示,結合之前的 tableSchema 和 projectFields 資訊重構,生成了一個只包含使用者需要欄位的新的資料結構 PruningTableSchema,並且作為 Iceberg schema 的輸入,通過這樣的操作實現了根據使用者的實際使用情況對巢狀結構進行列裁剪。圖中左下部的示例展示了使用者優化前後讀取巢狀欄位的對比,可以看到基於 PruningTablesSchema 能夠對無用的欄位進行有效的裁剪。
經過上述優化,CPU 使用率降低了 20%~30%。而且,在相同的資料量下,批任務的執行時間縮短了 20%~30%。
此外,我們還實現了一些其他優化,比如修復了 interval outer join 資料晚於 watermark 下發、且下游有時間運算元時會導致的資料丟失問題,UDF 的複用問題,FlinkSQL 擴充套件 KeyBy 語法,維表資料預載入以及 Iceberg connector 從指定的 snapshot 去讀取等功能。
三、落地案例
京東目前 FlinkSQL 線上任務 700+,佔Flink總任務數的 15% 左右,FlinkSQL 任務累計峰值處理能力超過 1.1 億條/秒。目前主要基於社群的 1.12 版本進行了一些定製優化。
3.1 案例一
實時通用資料層 RDDM 流批一體化的建設。RDDM 全稱是 real-time detail data model - 實時明細資料模型,它涉及訂單、流量、商品、使用者等,是京東實時數倉的重要一環,服務了非常多的核心業務,例如黃金眼/商智、JDV、廣告演算法、搜推演算法等。
RDDM 層的實時業務模型與離線資料中 ADM 和 GDM 層的業務加工邏輯一致。基於此,我們希望通過 FlinkSQL 來實現業務模型的流批計算統一。同時這些業務也具備非常鮮明的特點,比如訂單相關的業務模型都涉及大狀態的處理,流量相關的業務模型對於端到端的實時性要求比較高。此外,某些特殊場景也需要一些定製化的開發來支援。
RDDM 的實現主要有兩個核心訴求:首先它的計算需要關聯的資料比較多的,大量的維度資料都儲存在 HBase 中;此外部分維度資料的查詢存在二級索引,需要先查詢索引表,從中取出符合條件的 key 再去維度表中獲取真正的資料。
針對上述需求,我們通過結合維表資料預載入的功能與維表 keyby 的功能來提升 join 的效率。針對二級索引的查詢需求,我們定製了 connector 來實現。
維表資料預載入的功能指在初始化的階段就將維表資料載入到記憶體中,這個功能結合 keyby 使用可以非常有效地減少快取的數量,提高命中率。
部分業務模型關聯的歷史資料比較多,導致狀態資料比較大,目前我們是根據場景進行定製的優化。我們認為根本的解決方案是實現一套高效的基於 KV 的 statebackend,對於此功能的實現正在規劃中。
3.2 案例二
流量買賣黑產的輿情分析。它的主要流程如下:源端通過爬蟲獲取相關資訊並寫入到 JMQ,資料同步到 JDQ 以後,通過 Flink 處理然後繼續寫下游的 JDQ。與此同時,通過 DTS 資料傳輸服務,將上游 JDQ 的資料同步到 HDFS,然後通過 Hive 表進行離線的資料加工。
此業務有兩個特點:首先,端到端的實時性要求不高,可以接受分鐘級別的延時;第二,離線和實時的加工邏輯一致。因此,可以直接把中間環節的儲存從 JDQ 換成 Iceberg,然後通過 Flink 去增量讀取,並通過 FlinkSQL 實現業務邏輯加工,即完成了流批兩套鏈路的完全統一。其中 Iceberg 表中的資料也可以供 OLAP 查詢或離線做進一步的加工。
上述鏈路端到端的時延在一分鐘左右,基於運算元的小檔案合併功能有效地提升了效能,儲存計算成本有了顯著的降低,綜合評估開發維護成本降低了 30% 以上。
四、未來規劃
未來規劃主要分為以下兩個方面:
首先,業務擴充方面。我們會加大 FlinkSQL 任務的推廣,探索更多流批一體的業務場景,同時對產品形態進行打磨,加速使用者向 SQL 的轉型。同時,將平臺後設資料與離線後設資料做更深度的融合,提供更好的後設資料服務。
其次,平臺能力方面。我們會繼續深挖 join 場景和大狀態場景,同時探索高效 KV 型別的狀態後端實現,並在統一計算和統一儲存的框架下不斷優化設計,以降低端到端時延。
更多 Flink 相關技術問題,可掃碼加入社群釘釘交流群
第一時間獲取最新技術文章和社群動態,請關注公眾號~
活動推薦
阿里雲基於 Apache Flink 構建的企業級產品-實時計算Flink版現開啟活動:
99 元試用 實時計算Flink版(包年包月、10CU)即有機會獲得 Flink 獨家定製衛衣;另包 3 個月及以上還有 85 折優惠!
瞭解活動詳情:https://www.aliyun.com/produc...