來源:Apache Flink
摘要:本文整理自阿里巴巴高階技術專家、Apache Flink PMC 賀小令,在 Flink Forward Asia 2022 生產實踐專場的分享。本篇內容主要分為三個部分:
Flink 作為流批一體計算引擎,給大家提供了統一的 API,統一的運算元描述,以及統一的排程。但 Flink 運算元的底層仍有一些細微的差別。對於一個批運算元而言,它的輸入是一個有限資料集。批運算元會基於完整資料集進行計算,計算過程中如果記憶體裝不下,資料會 Spill 到磁碟。對於流運算元而言,它的輸入是一個無限資料集。與批運算元不同,流運算元不能在收集到所有輸入資料之後才開始處理,也不可能將這些資料存到磁碟上。所以流運算元的處理是一條一條的(也可能會按一小批進行計算)。當流運算元接收到上游的一條資料後,對於 Stateful 運算元會從 State 裡讀取之前的計算結果,然後結合當前資料進行計算,並將計算結果儲存到 State 裡。因此 State 最佳化是流計算裡非常重要的一部分。批處理裡僅有 Append 訊息,而流處理中,不僅有 Append 訊息,還有 UpdateBefore、UpdateAfter 和 Delete 訊息,這些訊息都是歷史資料的訂正。在運算元的計算邏輯裡,如果不能很好的處理訂正訊息,會導致下游資料膨脹,引發新的問題。如何使一個作業執行更快?可以總結為 6 點:減少重複計算、減少無效資料計算、解決資料請求問題、提高計算吞吐、減少 State 訪問、減少 State 的大小。
除了最後兩個是針對流處理的,其餘都是各個通用計算引擎都需要面對的。接下來,透過剖析一個 Flink SQL 如何變成一個的 Flink 作業的過程,來介紹作業變得更優的手段。Flink 引擎接收到一個 SQL 文字後,透過 SqlParser 將其解析成 SqlNode。透過查詢 Catalog 中的後設資料資訊,對 SqlNode 中的表、欄位、型別、udf 等進行校驗,校驗透過後會轉換為 LogicalPlan,透過 Optimizer 最佳化後轉換為 ExecPlan。ExecPlan 是 SQL 層的最終執行計劃,透過 CodeGen 技術將執行計劃翻譯成可執行運算元,用 Transformation 來描述,最後轉換為 JobGraph 並提交到 Flink 叢集上執行。
如何讓 SQL 作業執行更快?Optimizer 在此發揮了重要作用。它將 SQL 語義等價的 LogicalPlan 轉換為可高效執行的 ExecPlan,而 ExecPlan 到 JobGraph 轉換過程都是確定的。下圖展示了最佳化器內部的細節。一個 Flink 作業通常包含多個 Insert 語句,當多個 Insert 語句翻譯成 Logical Plan,會形成一個 DAG,我們稱該最佳化器為 DAG 最佳化器。Optimizer 的輸入包括 5 部分:LogicalPlan、Flink 配置、約束資訊(例如 PK 資訊),統計資訊、使用者在 SQL 中提供的 Hints。
具體的最佳化過程包括:首先需要將 DAG 基於 View 進行分解,該做法是為了儘可能保證執行計劃可複用。分解完後會得到一個個獨立的 Tree,從葉子節點向根節點依次將這個 Tree 交給 Calcite 的 Optimizer 進行最佳化。然後將最佳化完的結果重新組裝為 DAG,形成 PhysicalPlan 的 DAG,並使用子圖複用技術繼續改寫 PhysicalPlan,進一步消除重複計算。最後將 PhysicalPlan 轉換為 ExecPlan。基於 Exec DAG,可以做更多的最佳化:如 MultipleInput Rewrite,減少網路 Shuffle 帶來的開銷;DynamicFiltering Rewrite,減少無效資料的讀取和計算等。Calcite Optimizer 是經典的資料庫中的關係代數最佳化,有基於規則的最佳化——RBO,基於代價模型的最佳化——CBO,同時還定義了大量的最佳化規則,大量的 Meta 資訊的推導(如 PrimaryKey推導、RowCount 的推導),來幫助最佳化器獲得最優的執行計劃。在 Calcite Optimizer 裡,結合了經典的最佳化思路:首先是對 LogicalPlan 進行一些確定性的改寫,例如 SubQuery 改寫、解關聯、常量摺疊等。然後做一些通用的改寫,例如各種 push down,形成 FlinkLogicalPlan。這部分最佳化是流批通用的。然後流和批根據各自底層的實現,進行特定的最佳化,例如批上會根據 Cost 選擇不同的 Join 演算法,流上會根據配置決定是否將 Retract 處理轉換為 Upsert 處理,是否開啟 Local/Global 解決資料熱點問題等。DAG 最佳化器解決了大量前面提到的各種問題,Flink SQL 是否足夠 Fast 起到了非常關鍵作用。
接下來將結合生產中一些高頻使用的場景和 SQL,介紹一些最佳化的最佳實踐。
2.1 Sub-Plan Reuse
首先是 Sub-Plan Reuse(即子圖複用)最佳化。下圖中的兩句 SQL 會被最佳化成兩個獨立的 Pipeline,見下圖中左下角的執行計劃,Scan 運算元和 Filter 運算元中 a>10 會計算兩次。
透過開啟 Sub-Plan Reuse 最佳化,最佳化器會自動發現執行計劃中計算邏輯完全一樣的子圖並將它們進行合併,從而避免重複計算。這個例子中,由於兩個 Scan 運算元完全一樣,所以經過最佳化之後變成一個 Scan 運算元,從而減小資料的讀取的次數,最佳化後的 DAG 如上圖右下角所示。邏輯上,最佳化器也可以複用 Filter 中的 a>10 的部分的計算。但是當遇到非常複雜的 Query 時,同時結合各種 push down 的最佳化,要找到理想的可複用的執行計劃是一件非常麻煩的事情。可以使用基於檢視(View)的子圖複用的方式來達到最大化子圖複用的效果。這裡的 View,可以理解為一個子圖複用單元。基於使用者定義的 View,最佳化器能夠自動識別哪些 View 能夠被複用。下圖中的 SQL 會翻譯成左下角的執行計劃,經過子圖複用最佳化後 Scan 和 Filter a>10 都將被複用(如下圖右下角執行計劃所示)。
2.2 Fast Aggregation
Aggregation Query 在生產中被高頻使用,這裡以“select a,sum(b) from my_table group by a;”為例進行介紹 Aggregation 相關最佳化。該 SQL 將被翻譯成下圖中左邊的邏輯執行計劃,而圖中右邊是執行拓撲:Scan 運算元的併發為 3,Aggregation 運算元的併發為 2。
圖中的一個小方框表示一條資料,用顏色來表示欄位 a 的值,用數字表示欄位 b 的值,所有的 Scan 運算元的資料會按欄位 a 進行 Shuffle 輸出給 Aggregate 運算元。流計算中,當 Aggregate 運算元接收到一條資料後,首先會從資料中抽取它對應的 Group Key,然後從 State 裡獲得對應 Key 之前的聚合結果,並基於之前的聚合結果和當前的資料進行聚合計算。最後將結果更新到 State 裡並輸出到下游。(邏輯可參考上圖中的虛擬碼)上圖中 Aggregate 運算元的輸入有 15 條資料,Aggregate 計算會觸發 15 次 State 操作(15 次 get 和 put 操作),這麼頻繁的操作可能會造成效能影響。與此同時,第一個 Aggregate 例項接收了 8 條紅色資料,會形成熱點 Key。如何解決 State 訪問頻繁和熱點 Key 的問題在生產中經常遇到。透過開啟 MiniBatch 能減少 State 的訪問和減少熱點 Key 的訪問。對應的配置為:
allow-latency 表明了 MiniBatch 的大小是 5 秒,即 5 秒內的資料會成為為一個 Mini Batch。開啟 Mini Batch 之後,Aggregation 的計算邏輯就會發生變化:當 Aggregate 運算元接收到一條資料時,不會直接觸發計算,而是把它放到一個 Buffer 裡。當 Buffer 攢滿之後,才開始計算:將 Buffer 的資料按 Key 進行分組,以每個組為單位進行計算。對於每個 Key,先從 State 中獲取該 Key 對應的之前的聚合結果,然後逐條計算該 Key 對應的所有資料,計算完成後將結果更新 State 並輸出給下游。
這裡 State 的訪問次數等於 Key 的個數。如果將上圖中所示的資料都放到一個 Buffer 的話,這裡 Key 的個數只有 4 個,因此只有 4 次 State 訪問。相比於原來的 15 次訪問,開啟 MiniBatch 最佳化後大大減少了 State 訪問的開銷。
上圖中,我們觀察到第一個 Aggregate 例項中雖然訪問 State 頻率很少,但是要處理的資料量還是沒變,其整體資料量和單 Key 資料量相比其他 Aggregate 例項都多很多,即存在資料傾斜。我們可以透過兩階段 Aggregate(Local/Global)來解決資料傾斜避免熱點。開啟兩階段 Aggregate 需要開啟 MiniBatch 和將 agg-phase-strategy 設定為 TWO PHASE/AUTO,開啟後的 Plan 如下圖所示。
LocalAgg 和上游運算元 chain 在一起,即 Scan 和 LocalAgg 採用相同併發,執行中間不存在資料 Shuffle。在 LocalAgg 裡,會按照 MiniBatch 的大小進行聚合,並把聚合的結果輸出到 GlobalAgg。上圖示例中,經過 LocalAgg 聚合後的紅色資料在第一個 GlobalAgg 例項中從 8 條減到 3 條,各個 GlobalAgg 例項之間不存在資料傾斜。
當 Query 中有 Distinct Aggreation Function 時,Local/Global 解決不了熱點問題。透過下圖的例子可以看出來,LocalAgg 按 a、b 欄位進行聚合,聚合效果並不明顯。因此,第一個 GlobalAgg 例項還是接收到大量的熱點資料,共 7 條紅色資料。LocalAgg 的聚合 Key(a、b)和下游的 Shuffle Key(a)不一致導致 Local/Global 無法解決 DistinctAgg 的資料熱點問題。
我們可以透過開啟 Partial/Final Aggreation 來解決 DistinctAgg 的資料熱點問題。PartialAgg 透過 Group Key + Distinct Key 進行 Shuffle,這樣確保相同的 Group Key + Distinct Key 能被 Shuffle 到同一個 PartialAgg 例項中,完成第一層的聚合,從而減少 FinalAgg 的輸入資料量。可以透過下面配置開啟 Partial/Final 最佳化:
開啟配置之後,最佳化器會將原來的 plan 翻譯成下圖的 plan:
上圖例子中,經過 PartialAgg 的聚合之後,第一個 FinalAgg 例項的熱點 Key 從 7 條減少到 3 條。開啟 Partial/Final 適用的場景為:
Query 中存在 Distinct Function,且存在資料傾斜;
非 Distinct Function 只能是如下函式:count,sum,avg,max,min;
資料集比較大的時候效果更好,因為 Partial/Final 兩層 Agg 之間會引入額外的網路 Shuffle 開銷;
- Partial Agg 還會引入額外的計算和 State 的開銷。
和 LocalAgg 不一樣,PartialAgg 會將結果存放在 State 中,這會造成 State 翻倍。為了解決 PartialAgg 引入額外的 State 開銷的問題,在 Partital/Final 基礎上引入 Increment 最佳化。它同時結合了 Local/Global 和 Partial/Final 最佳化,並將 Partial 的 GlobalAgg 和 Final 的 LocalAgg 合併成一個 IncrementalAgg 運算元,它只存放 Distinct Value 相關的值,因此減少了 State 的大小。開啟 IncrementalAgg 之後的執行計劃如下圖所示:
一些 Query 改寫也能減少 Aggregate 運算元的 State。很多使用者使用 COUNT DISTICNT + CASE WHEN 統計同一個維度不同條件的結果。在 Aggregate 運算元裡,每個 COUNT DISTICNT 函式都採用獨立的 State 儲存 Distinct 相關的資料,而這部分資料是冗餘的。可以透過將 CASE WHEN 改寫為 FILTER,最佳化器會將相同維度的 State 資料共享,從而減少 State 大小。
2.3 Fast Join
Join 是我們生產中非常常見的 Query,Join 的最佳化帶來的效能提升效果非常明顯。以下圖簡單的 Join Query 為例介紹 Join 相關的最佳化:該 Query 將被翻譯成 Regular Join(也常被稱為雙流 Join),其 State 會保留 left 端和 right 端所有的資料。當資料集較大時,Join State 也會非常大,在生產中可能會造成嚴重的效能問題。最佳化 Join State 是流計算非常重要的內容。
針對 Regular Join,有兩種常見的最佳化方式:
除了 Regular Join 外,Flink 還提供了其他型別的 Join:Lookup Join、Interval Join、Temporal Join、Window Join。在滿足業務需求的條件下,使用者可以將 Regular Join 進行改寫成其他型別的 Join 以節省 State。Regular Join 和其他 Join 的轉換關係如下圖所示:
將 Regular Join 改寫成 Lookup Join。主流來一條資料會觸發 Join 計算,Join 會根據主流的資料查詢維表中相關最新資料,因此 Lookup Join 不需要 State 儲存輸入資料。目前很多維表 Connector 提供了點查機制和快取機制,執行效能非常好,在生產中被大量使用。後面章節會單獨介紹 Lookup Join 相關最佳化。Lookup Join 的缺點是當維表資料有更新時,無法觸發 Join 計算。
將 Regular Join 改寫為 Interval Join。Interval Join 是在 Regluar Join 基礎上對 Join Condition 加了時間段的限定,從而在 State 中只需要儲存該時間段的資料即可,過期資料會被及時清理。因此 Interval Join 的 State 相比 Regular Join 要小很多。
把 Regular Join 改寫成 Window Join。Window Join 是在 Regluar Join 基礎上定義相關 Window 的資料才能被 Join。因此,State 只存放的最新 Window,過期資料會被及時清理。
- 把 Regular Join 改寫成 Temporal Join。Temporal Join 是在 Regular Join 上定義了相關版本才能被 Join。Temporal Join 保留最新版本資料,過期資料會被及時清理。
2.4 Fast Lookup Join
Lookup Join 在生產中被廣泛使用,因此我們這裡對其做單獨的最佳化介紹。當前 Flink 對 Lookup Join 提供了多種最佳化方式:第一種,提供同步和非同步查詢機制。如下圖所示,在同步查詢中,當一條資料傳送給維表 Collector 後,需要等結果返回,才能處理下一條,中間會有大量的時間等待。在非同步查詢過程中,會將一批資料同時傳送給維表 Collector,在這批資料完成查詢會統一返回給下游。透過非同步查詢模式模式,查詢效能得到極大提升。Flink 提供了 Query Hint 的方式開啟同步和非同步模式,請參考下圖所示。
第二種,在非同步模式下,提供了 Ordered 和 Unordered 機制。在 Order 模式之下,需要等所有資料返回後並對資料排序,以確保輸出的順序和輸入的順序一致,排序完成才能傳送給下游。在 Unordered 模式下,對輸出順序沒有要求,只要查詢到結果,就可以直接傳送給下游。因此,相比於 Order 模式,Unordered 模式能夠有極大提升效能。Flink 提供了 Query Hint 的方式開啟 Ordered 和 Unordered 模式,請參考下圖所示。(Flink 也提供 Job 級別的配置,開啟所有維表查詢的都採用相同輸出模式。)
第三種,Cache 機制。這是一種很常見的最佳化,用 本地 Memory Lookup 提高查詢效率。目前,Flink 提供了三種 Cache 機制:
Full Caching,將所有資料全部 Cache 到記憶體中。該方式適合小資料集,因為資料量過大會導致 OOM。Flink 提供了 Table Hints 開啟。同時,還可以透過 Hints 定義 reload 策略。
Partial Caching,適合大資料集使用,框架底層使用 LRU Cache 儲存最近被使用的資料。當然,也可以透過 Hint 定義 LRU Cache 的大小和 Cache 的失效時間。
2.5 Fast Deduplication
流計算中經常遇到資料重複,因此“去重”使用頻率非常高。在早期版本,Flink 透過 group by + first_value 的方式查詢第一行資料;透過 group by + last_value 查詢最後一行資料。示例如下圖所示:
上述 Query 會被轉換為 Aggregate 運算元,它的 State 非常大且無法保證語義完整。因為下游的輸出資料可能來源於不同行,導致與按行去重的語義語義相悖。在最近版本中,Flink 提供了更高效的去重方式。由於 Flink 沒有專門提供去重語法,目前透過 Over 語句表達去重語義,參考下圖所示。對於查詢第一行場景,可以按照時間升序,同時輸出 row_number,再基於 row_number 進行過濾。此時,運算元 State 只需要儲存去重的 Key 即可。對於查詢最後一行場景,把時間順序變為降序,運算元 State 只需要儲存最後一行資料即可。
2.6 Fast TopN
TopN(也稱為 Rank) 在生產中也經常遇到,Flink 中沒有提供專門的 TopN 的語法,也是透過 Over 語句實現。目前,TopN 提供了三種實現方式(它們效能依次遞減)。
第一種,AppendRank,要求輸入是 Append 訊息,此時 TopN 運算元中 State 僅需要為每個 Key 存放滿足要求的 N 條資料。第二種,UpdateFastRank, 要求輸入是 Update 訊息,同時要求上游的 Upsert Key 必須包含 Partition Key,Order-by 欄位必須是單調的且其單調方向需要與 Order-by 的方向相反。以上圖中右邊的 Query 為例,Group By a,b 會產生以 a,b 為 Key 的更新流。在 TopN 運算元中,上游產生的 Upsert Key (a,b) 包含 Over 中 Partition Key(a,b);Sum 函式的入參都是正數(where c >= 0), 所以 Sum 的結果將是單調遞增的,從而字段 c 是單調遞增,與排序方向相反。因此這個 Query 就可以被翻譯成 UpdateFastRank。第三種,RetractRank,對輸入沒有任何要求。State 中需要存放所有輸入資料,其效能也是最差的。除了在滿足業務需求的情況下修改 Query 以便讓最佳化器選擇更優的運算元外,TopN 還有一些其他的最佳化方法:
不要輸出 row_number 欄位,這樣可以大大減少下游處理的資料量。如果下游需要排序,可以在前端拿到資料後重排。
增加 TopN 運算元中 Cache 大小,減少對 State 的訪問。Cache 命中率的計算公式為:cache_hit = cache_size * parallelism / top_n_num / partition_key_num。由此可見,增加 Cache 大小可以增加 Cache 命中率(可以透過 table.exec.rank.topn-cache-size 修改 Cache 大小,預設值是 1 萬)。需要注意的是,增加 Cache 大小時,TaskManager 的記憶體也需要相應增加。
- 分割槽欄位最好與時間相關。如果 Partition 欄位不與時間屬性關聯,無法透過 TTL 進行清理,會導致 State 無限膨脹。(配置了 TTL,資料被過期清理可能導致結果錯誤,需要慎重)
2.7 Efficient User Defined Connector
Flink 提供了多種 Connector 相關介面,幫助最佳化器生成更優的執行計劃。使用者可以根據 Connector 的能力,實現對應的介面從而提高 Collector 的執行效能。
SupportsFilterPushDown,將 Filter 條件下推到 Scan 裡,從而減少 Scan 的讀 IO,以提高效能。
SupportsProjectionPushDown,告訴 Scan 只讀取必要的欄位,減少無效的欄位讀取。
SupportsPartitionPushDown,在靜態最佳化時,告訴 Scan 只需要讀取有效分割槽,避免無效分割槽讀取。
SupportsDynamicFiltering,在作業執行時,動態識別出哪些分割槽是有效的,避免無效分割槽讀取。
SupportsLimitPushDown,將 limit 值下推到 Scan 裡,只需要讀取 limit 條資料即可,大大減少了 Scan I/O。
SupportsAggregatePushDown,直接從 Scan 中讀取聚合結果,減少 Scan 的讀 I/O,同時輸出給下游的資料更少。
- SupportsStatisticReport,Connector 彙報統計資訊給最佳化器,以便最佳化器產生更優的執行計劃。
2.8 Use Hints Well
任何最佳化器都不是完美的,Flink 提供了 Hints 機制來影響最佳化器以獲得更優的執行計劃。目前有兩種 Hints:
在未來,我們將提供更有深度、更多場景、更智慧的最佳化,以進一步提高 Flink 引擎的執行效率。首先,我們將會在最佳化的深度上會持續挖掘,例如多個連續的 Join 會有 State 重複的問題,可以將它最佳化成一個 Join,避免 State 重複。其次,我們希望擴大最佳化的廣度,結合不同的業務場景進行有針對性的最佳化。
最後,我們希望最佳化更加智慧,會探索做一些動態最佳化相關工作,例如根據流量變化,線上自動最佳化 plan。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027827/viewspace-2948630/,如需轉載,請註明出處,否則將追究法律責任。