前言
近期將Flink Job從Standalone遷移至了OnYarn,隨後發現Job效能較之前有所降低:遷移前有8.3W+/S的資料消費速度,遷移到Yarn後分配同樣的資源但消費速度降為7.8W+/S,且較之前的消費速度有輕微的抖動。經過原因分析和測試驗證,最終採用了在保持分配給Job的資源不變的情況下將總Container數量減半、每個Container持有的資源從1C2G 1Slot
變更為2C4G 2Slot
的方式,使該問題得以解決。
經歷該問題後,發現深入理解Slot和Flink Runtime Graph是十分必要的,於是撰寫了這篇文章。本文內容分為兩大部分,第一部分詳細的分析Flink Slot與Job執行的關係,第二部詳細的介紹遇到的問題和解決方案。
Flink Slot
Flink叢集是由JobManager(JM)、TaskManager(TM)兩大元件組成的,每個JM/TM都是執行在一個獨立的JVM程式中。JM相當於Master,是叢集的管理節點,TM相當於Worker,是叢集的工作節點,每個TM最少持有1個Slot,Slot是Flink執行Job時的最小資源分配單位,在Slot中執行著具體的Task任務。
對TM而言:它佔用著一定數量的CPU和Memory資源,具體可通過taskmanager.numberOfTaskSlots
, taskmanager.heap.size
來配置,實際上taskmanager.numberOfTaskSlots
只是指定TM的Slot數量,並不能隔離指定數量的CPU給TM使用。在不考慮Slot Sharing(下文詳述)的情況下,一個Slot內執行著一個SubTask(Task實現Runable,SubTask是一個執行Task的具體例項),所以官方建議taskmanager.numberOfTaskSlots
配置的Slot數量和CPU相等或成比例。
當然,我們可以藉助Yarn等排程系統,用Flink On Yarn的模式來為Yarn Container分配指定數量的CPU資源,以達到較嚴格的CPU隔離(Yarn採用Cgroup做基於時間片的資源排程,每個Container內執行著一個JM/TM例項)。而taskmanager.heap.size
用來配置TM的Memory,如果一個TM有N個Slot,則每個Slot分配到的Memory大小為整個TM Memory的1/N,同一個TM內的Slots只有Memory隔離,CPU是共享的。
對Job而言:一個Job所需的Slot數量大於等於Operator配置的最大Parallelism數,在保持所有Operator的slotSharingGroup
一致的前提下Job所需的Slot數量與Job中Operator配置的最大Parallelism相等。
關於TM/Slot之間的關係可以參考如下從官方文件擷取到的三張圖:
圖一:Flink On Yarn的Job提交過程,從圖中我們可以瞭解到每個JM/TM例項都分屬於不同的Yarn Container,且每個Container內只會有一個JM或TM例項;通過對Yarn的學習我們可以瞭解到,每個Container都是一個獨立的程式,一臺物理機可以有多個Container存在(多個程式),每個Container都持有一定數量的CPU和Memory資源,而且是資源隔離的,程式間不共享,這就可以保證同一臺機器上的多個TM之間是資源隔離的(Standalone模式下,同一臺機器下若有多個TM,是做不到TM之間的CPU資源隔離的)。
圖一
圖二:Flink Job執行圖,圖中有兩個TM,各自有3個Slot,2個Slot內有Task在執行,1個Slot空閒。若這兩個TM在不同Container或容器上,則其佔用的資源是互相隔離的。在TM內多個Slot間是各自擁有 1/3 TM的Memory,共享TM的CPU、網路(Tcp:ZK、 Akka、Netty服務等)、心跳資訊、Flink結構化的資料集等。
圖二
圖三:Task Slot的內部結構圖,Slot內執行著具體的Task,它是線上程中執行的Runable物件(每個虛線框代表一個執行緒),這些Task例項在原始碼中對應的類是org.apache.flink.runtime.taskmanager.Task
。每個Task都是由一組Operators Chaining在一起的工作集合,Flink Job的執行過程可看作一張DAG圖,Task是DAG圖上的頂點(Vertex),頂點之間通過資料傳遞方式相互連結構成整個Job的Execution Graph。
圖三
Operator Chain
Operator Chain是指將Job中的Operators按照一定策略(例如:single output operator可以chain在一起)連結起來並放置在一個Task執行緒中執行。Operator Chain預設開啟,可通過StreamExecutionEnvironment.disableOperatorChaining()
關閉,Flink Operator類似Storm中的Bolt,在Strom中上游Bolt到下游會經過網路上的資料傳遞,而Flink的Operator Chain將多個Operator連結到一起執行,減少了資料傳遞/執行緒切換等環節,降低系統開銷的同時增加了資源利用率和Job效能。實際開發過程中需要開發者瞭解這些原理,並能合理分配Memory和CPU給到每個Task執行緒。
注: 【一個需要注意的地方】Chained的Operators之間的資料傳遞預設需要經過資料的拷貝(例如:kryo.copy(...)),將上游Operator的輸出序列化出一個新物件並傳遞給下游Operator,可以通過ExecutionConfig.enableObjectReuse()
開啟物件重用,這樣就關閉了這層copy操作,可以減少物件序列化開銷和GC壓力等,具體原始碼可閱讀org.apache.flink.streaming.runtime.tasks.OperatorChain
與org.apache.flink.streaming.runtime.tasks.OperatorChain.CopyingChainingOutput
。官方建議開發人員在完全瞭解reuse內部機制後才使用該功能,冒然使用可能會給程式帶來bug。
Operator Chain效果可參考如下官方文件截圖:
圖四:圖的上半部分是StreamGraph視角,有Task類別無並行度,如圖:Job Runtime時有三種型別的Task,分別是Source->Map
、keyBy/window/apply
、Sink
,其中Source->Map
是Source()
和Map()chaining
在一起的Task;圖的下半部分是一個Job Runtime期的實際狀態,Job最大的並行度為2,有5個SubTask(即5個執行執行緒)。若沒有Operator Chain,則Source()
和Map()
分屬不同的Thread,Task執行緒數會增加到7,執行緒切換和資料傳遞開銷等較之前有所增加,處理延遲和效能會較之前差。補充:在slotSharingGroup
用預設或相同組名時,當前Job執行需2個Slot(與Job最大Parallelism相等)。
圖四
Slot Sharing
Slot Sharing是指,來自同一個Job且擁有相同slotSharingGroup
(預設:default)名稱的不同Task的SubTask之間可以共享一個Slot,這使得一個Slot有機會持有Job的一整條Pipeline,這也是上文提到的在預設slotSharing的條件下Job啟動所需的Slot數和Job中Operator的最大parallelism相等的原因。通過Slot Sharing機制可以更進一步提高Job執行效能,在Slot數不變的情況下增加了Operator可設定的最大的並行度,讓類似window這種消耗資源的Task以最大的並行度分佈在不同TM上,同時像map、filter這種較簡單的操作也不會獨佔Slot資源,降低資源浪費的可能性。
具體Slot Sharing效果可參考如下官方文件截圖:
圖五:圖的左下角是一個soure-map-reduce
模型的Job,source和map是4 parallelism
,reduce是3 parallelism
,總計11個SubTask;這個Job最大Parallelism是4,所以將這個Job釋出到左側上面的兩個TM上時得到圖右側的執行圖,一共佔用四個Slot,有三個Slot擁有完整的source-map-reduce
模型的Pipeline,如右側圖所示;注:map
的結果會shuffle
到reduce
端,右側圖的箭頭只是說Slot內資料Pipline,沒畫出Job的資料shuffle
過程。
圖五
圖六:圖中包含source-map[6 parallelism]
、keyBy/window/apply[6 parallelism]
、sink[1 parallelism]
三種Task,總計佔用了6個Slot;由左向右開始第一個slot內部執行著3個SubTask[3 Thread],持有Job的一條完整pipeline;剩下5個Slot內分別執行著2個SubTask[2 Thread],資料最終通過網路傳遞給Sink
完成資料處理。
圖六
Operator Chain & Slot Sharing API
Flink在預設情況下有策略對Job進行Operator Chain 和 Slot Sharing的控制,比如:將並行度相同且連續的SingleOutputStreamOperator操作chain在一起(chain的條件較苛刻,不止單一輸出這一條,具體可閱讀org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator.isChainable(...))
,Job的所有Task都採用名為default的slotSharingGroup
做Slot Sharing。但在實際的需求場景中,我們可能會遇到需人為干預Job的Operator Chain 或 Slot Sharing策略的情況,本段就重點關注下用於改變預設Chain 和 Sharing策略的API。
- StreamExecutionEnvironment.disableOperatorChaining():關閉整個Job的Operator
Chain,每個Operator獨自佔有一個Task,如上圖四所描述的Job,如果disableOperatorChaining
則source->map
會拆開為source()
,map()
兩種Task,Job實際的Task數會增加到7。這個設定會降低Job效能,在非生產環境的測試或profiling時可以藉助以更好分析問題,實際生產過程中不建議使用。 - someStream.filter(...).map(...).startNewChain().map():
startNewChain()
是指從當前Operator[map]
開始一個新的chain,即:兩個map會chaining在一起而filter不會(因為startNewChain的存在使得第一次map與filter斷開了chain)。 - someStream.map(...).disableChaining():
disableChaining()
是指當前Operator[map]
禁用Operator
Chain,即:Operator[map]會獨自佔用一個Task。 - someStream.map(...).slotSharingGroup("name"):預設情況下所有Operator的slotGroup都為
default
,可以通過slotSharingGroup()
進行自定義,Flink會將擁有相同slotGroup名稱的Operators執行在相同Slot內,不同slotGroup名稱的Operators執行在其他Slot內。
Operator Chain有三種策略ALWAYS
、NEVER
、HEAD
,詳細可檢視org.apache.flink.streaming.api.operators.ChainingStrategy
。startNewChain()
對應的策略是ChainingStrategy.HEAD
(StreamOperator的預設策略),disableChaining()
對應的策略是ChainingStrategy.NEVER,ALWAYS
是儘可能的將Operators chaining在一起;在通常情況下ALWAYS是效率最高,很多Operator會將預設策略覆蓋為ALWAYS
,如filter、map、flatMap等函式。
遷移OnYarn後Job效能下降的問題
JOB說明:
類似StreamETL,100 parallelism,即:一個流式的ETL Job,不包含window等操作,Job的並行度為100;
環境說明:
- Standalone下的Job Execution Graph:
10TMs * 10Slots-per-TM
,即:Job的Task執行在10個TM節點上,每個TM上佔用10個Slot,每個Slot可用1C2G資源,GCConf:-XX:+UseG1GC -XX:MaxGCPauseMillis=100
。 - OnYarn下初始狀態的Job Execution Graph:
100TMs*1Slot-per-TM
,即:Job的Task執行在100個Container上,每個Container上的TM持有1個Slot,每個Container分配1C2G
資源,GCConf:-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
。 - OnYarn下調整後的Job Execution Graph:
50TMs*2Slot-per-TM
,即:Job的Task執行在50個Container上,每個Container上的TM持有2個Slot,每個Container分配2C4G資源,GCConfig:-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
。
注:OnYarn下使用了與Standalone一致的GC配置,當前Job在Standalone或OnYarn環境中執行時,YGC、FGC頻率基本相同,OnYarn下單個Container的堆記憶體較小使得單次GC耗時減少。生產環境中大家最好對比下CMS和G1,選擇更好的GC策略,當前上下文中暫時認為GC對Job效能影響可忽略不計。
問題分析:
引起Job效能降低的原因不難定位,從這張Container的執行緒圖(VisualVM中的截圖)可見:
圖七:在一個1C2G的Container內有126個活躍執行緒,守護執行緒78個。首先,在一個1C2G的Container中執行著126個活躍執行緒,頻繁的執行緒切換是會經常出現的,這讓本來就不充裕的CPU顯得更加的匱乏。其次,真正與資料處理相關的執行緒是紅色畫筆圈出的14條執行緒(2條Kafka Partition Consumer
、Consumers和Operators包含在這個兩個執行緒內;12條Kafka Producer
執行緒,將處理好的資料sink到Kafka Topic),這14條執行緒之外的大多數執行緒在相同TM、不同Slot間可以共用,比如:ZK-Curator、Dubbo-Client、GC-Thread、Flink-Akka、Flink-Netty、Flink-Metrics等執行緒,完全可以通過增加TM下Slot數量達到多個SubTask共享的目的。
此時我們會很自然的得出一個解決辦法:在Job使用資源不變的情況下,在減少Container數量的同時增加單個Container持有的CPU、Memory、Slot數量,比如上文環境說明中從方案2調整到方案3,實際調整後的Job執行穩定了許多且消費速度與Standalone基本持平。
圖七
注:當前問題是內部遷移類似StreamETL的Job時遇到的,解決方案簡單但不具有普適性,對於帶有window運算元的Job需要更仔細縝密的問題分析。目前Deploy到Yarn叢集的Job都配置了JMX/Prometheus兩種監控,單個Container下Slot數量越多、每次scrape的資料越多,實際生成環境中需觀測是否會影響Job正常執行,在測試時將Container配置為3C6G 3Slot
時發現一次java.lang.OutOfMemoryError: Direct buffer memory
的異常,初步判斷與Prometheus Client相關,可適當調整JVM的MaxDirectMemorySize
來解決。
所出現異常如圖八:
圖八
總結
Operator Chain是將多個Operator連結在一起放置在一個Task中,只針對Operator;Slot Sharing是在一個Slot中執行多個Task,針對的是Operator Chain之後的Task。這兩種優化都充分利用了計算資源,減少了不必要的開銷,提升了Job的執行效能。此外,Operator Chain的原始碼在streaming包下,只在流處理任務中有這個機制;Slot Sharing在flink-runtime包下,似乎應用更廣泛一些(具體還有待考究)。
最後,只有充分的瞭解Slot、Operator Chain、Slot Sharing是什麼,以及各自的作用和相互間的關係,才能編寫出優秀的程式碼並高效的執行在叢集上。
參考資料:
https://ci.apache.org/project...
https://ci.apache.org/project...
https://ci.apache.org/project...
https://ci.apache.org/project...
https://ci.apache.org/project...
https://flink.apache.org/visu...
作者:TalkingData資料工程師 王成龍