一、背景與痛點
在2017年上半年以前,TalkingData的App Analytics和Game Analytics兩個產品,流式框架使用的是自研的td-etl-framework。該框架降低了開發流式任務的複雜度,對於不同的任務只需要實現一個changer鏈即可,並且支援水平擴充套件,效能尚可,曾經可以滿足業務需求。
但是到了2016年底和2017年上半年,發現這個框架存在以下重要侷限:
- 效能隱患:App Analytics-etl-adaptor和Game Analytics-etl-adaptor這兩個模組相繼在節假日出現了嚴重的效能問題(Full-GC),導致指標計算延遲;
- 框架的容錯機制不足:依賴於儲存在Kafka或ZK上的offset,最多隻能達到at-least-once,而需要依賴其他服務與儲存才能實現exactly-once,並且會產生異常導致重啟丟數;
- 框架的表達能力不足: 不能完整的表達DAG圖,對於複雜的流式處理問題需要若干依賴該框架的若干個服務組合在一起才能解決問題;
TalkingData這兩款產品主要為各類移動端App和遊戲提供資料分析服務,隨著近幾年業務量不斷擴大,需要選擇一個效能更強、功能更完善的流式引擎來逐步升級我們的流式服務。調研從2016年底開始,主要是從Flink、Heron、Spark streaming中作選擇。
最終,我們選擇了Flink,主要基於以下幾點考慮:
- Flink的容錯機制完善,支援Exactly-once;
- Flink已經整合了較豐富的streaming operator,自定義operator也較為方便,並且可以直接呼叫API完成stream的split和join,可以完整的表達DAG圖;
- Flink自主實現記憶體管理而不完全依賴於JVM,可以在一定程度上避免當前的etl-framework的部分服務的Full-GC問題;
- Flink的window機制可以解決GA中類似於單日遊戲時長遊戲次數分佈等時間段內某個指標的分佈類問題;
- Flink的理念在當時的流式框架中最為超前: 將批當作流的特例,最終實現批流統一;
二、演進路線
2.1 standalone-cluster (1.1.3->1.1.5->1.3.2)
我們最開始是以standalone cluster的模式部署。從2017年上半年開始,我們逐步把Game Analytics中一些小流量的etl-job遷移到Flink,到4月份時,已經將產品接收各版本SDK資料的etl-job完全遷移至Flink,並整合成了一個job。形成了如下的資料流和stream graph:
圖1. Game Analytics-etl-adaptor遷移至Flink後的資料流圖
圖2. Game Analytics-etl的stream graph
在上面的資料流圖中,flink-job通過Dubbo來呼叫etl-service,從而將訪問外部儲存的邏輯都抽象到了etl-service中,flink-job則不需考慮複雜的訪存邏輯以及在job中自建Cache,這樣既完成了服務的共用,又減輕了job自身的GC壓力。
此外我們自構建了一個monitor服務,因為當時的1.1.3版本的Flink可提供的監控metric少,而且由於其Kafka-connector使用的是Kafka08的低階API,Kafka的消費offset並沒有提交的ZK上,因此我們需要構建一個monitor來監控Flink的job的活性、瞬時速度、消費淤積等metric,並接入公司owl完成監控告警。
這時候,Flink的standalone cluster已經承接了來自Game Analytics的所有流量,日均處理訊息約10億條,總吞吐量達到12TB每日。到了暑假的時候,日均日誌量上升到了18億條每天,吞吐量達到了約20TB每日,TPS峰值為3萬。
在這個過程中,我們又遇到了Flink的job消費不均衡、在standalone cluster上job的deploy不均衡等問題,而造成線上消費淤積,以及叢集無故自動重啟而自動重啟後job無法成功重啟。(我們將在第三章中詳細介紹這些問題中的典型表現及當時的解決方案。)
經過一個暑假後,我們認為Flink經受了考驗,因此開始將App Analytics的etl-job也遷移到Flink上。形成了如下的資料流圖:
圖3. App Analytics-etl-adaptor的標準SDK處理工作遷移到Flink後的資料流圖
圖4. App Analytics-etl-flink job的stream graph
2017年3月開始有大量使用者開始遷移至統一的JSON SDK,新版SDK的Kafka topic的峰值流量從年中的8K/s 上漲至了年底的 3W/s。此時,整個Flink standalone cluster上一共部署了兩款產品的4個job,日均吞吐量達到了35TB。
這時遇到了兩個非常嚴重的問題:
1) 同一個standalone cluster中的job相互搶佔資源,而standalone cluster的模式僅僅只能通過task slot在task manager的堆內記憶體上做到資源隔離。同時由於前文提到過的Flink在standalone cluster中deploy job的方式本來就會造成資源分配不均衡,從而會導致App Analytics線流量大時而引起Game Analytics線淤積的問題;
2) 我們的source operator的並行度等同於所消費Kafka topic的partition數量,而中間做etl的operator的並行度往往會遠大於Kafka的partition數量。因此最後的job graph不可能完全被鏈成一條operator chain,operator之間的資料傳輸必須通過Flink的network buffer的申請和釋放,而1.1.x 版本的network buffer在資料量大的時候很容易在其申請和釋放時造成死鎖,而導致Flink明明有許多訊息要處理,但是大部分執行緒處於waiting的狀態導致業務的大量延遲。
這些問題逼迫著我們不得不將兩款產品的job拆分到兩個standalone cluster中,並對Flink做一次較大的版本升級,從1.1.3(中間過度到1.1.5)升級成1.3.2。最終升級至1.3.2在18年的Q1完成,1.3.2版本引入了增量式的checkpoint提交併且在效能和穩定性上比1.1.x版本做了巨大的改進。升級之後,Flink叢集基本穩定,儘管還有消費不均勻等問題,但是基本可以在業務量增加時通過擴容機器來解決。
2.2 Flink on yarn (1.7.1)
因為standalone cluster的資源隔離做的並不優秀,而且還有deploy job不均衡等問題,加上社群上使用Flink on yarn已經非常成熟,因此我們在18年的Q4就開始計劃將Flink的standalone cluster遷移至Flink on yarn上,並且Flink在最近的版本中對於batch的提升較多,我們還規劃逐步使用Flink來逐步替換現在的批處理引擎。
圖5. Flink on yarn cluster規劃
如圖5,未來的Flink on yarn cluster將可以完成流式計算和批處理計算,叢集的使用者可以通過一個構建service來完成stream/batch job的構建、優化和提交,job提交後,根據使用者所在的業務團隊及服務客戶的業務量分發到不同的yarn佇列中,此外,叢集需要一個完善的監控系統,採集使用者的提交記錄、各個佇列的流量及負載、各個job的執行時指標等等,並接入公司的OWL。
從19年的Q1開始,我們將App Analytics的部分stream job遷移到了Flink on yarn 1.7中,又在19年Q2前完成了App Analytics所有處理統一JSON SDK的流任務遷移。當前的Flink on yarn叢集的峰值處理的訊息量達到30W/s,日均日誌吞吐量達約到50億條,約60TB。在Flink遷移到on yarn之後,因為版本的升級效能有所提升,且job之間的資源隔離確實優於standalone cluster。遷移後我們使用Prometheus+Grafana的監控方案,監控更方便和直觀。
我們將在後續將Game Analytics的Flink job和日誌匯出的job也遷移至該on yarn叢集,預計可以節約1/4的機器資源。
三、重點問題的描述與解決
在Flink實踐的過程中,我們一路上遇到了不少坑,我們挑出其中幾個重點坑做簡要講解。
1.少用靜態變數及job cancel時合理釋放資源
在我們實現Flink的operator的function時,一般都可以繼承AbstractRichFunction,其已提供生命週期方法open()/close(),所以operator依賴的資源的初始化和釋放應該通過重寫這些方法執行。當我們初始化一些資源,如spring context、dubbo config時,應該儘可能使用單例物件持有這些資源且(在一個TaskManager中)只初始化1次,同樣的,我們在close方法中應當(在一個TaskManager中)只釋放一次。
static的變數應該慎重使用,否則很容易引起job cancel而相應的資源沒有釋放進而導致job重啟遇到問題。規避static變數來初始化可以使用org.apache.flink.configuration.Configuration(1.3)或者org.apache.flink.api.java.utils.ParameterTool(1.7)來儲存我們的資源配置,然後通過ExecutionEnvironment來存放(Job提交時)和獲取這些配置(Job執行時)。
示例程式碼:
Flink 1.3
設定及註冊配置
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
Configuration parameters = new Configuration();
parameters.setString("zkConnects", zkConnects);
parameters.setBoolean("debug", debug);
env.getConfig().setGlobalJobParameters(parameters);
獲取配置(在operator的open方法中)
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
ExecutionConfig.GlobalJobParameters globalParams = getRuntimeContext().getExecutionConfig().getGlobalJobParameters();
Configuration globConf = (Configuration) globalParams;
debug = globConf.getBoolean("debug", false);
String zks = globConf.getString("zkConnects", "");
//.. do more ..
}
Flink 1.7
設定及註冊配置
ParameterTool parameters = ParameterTool.fromArgs(args);
// set up the execution environment
final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.getConfig().setGlobalJobParameters(parameters);
獲取配置
public static final class Tokenizer extends RichFlatMapFunction<String, Tuple2<String, Integer>> {
@Override
public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
ParameterTool parameters = (ParameterTool)
getRuntimeContext().getExecutionConfig().getGlobalJobParameters();
parameters.getRequired("input");
// .. do more ..
2.NetworkBuffer及operator chain
如前文所述,當Flink的job 的上下游Task(的subTask)分佈在不同的TaskManager節點上時(也就是上下游operator沒有chained在一起,且相對應的subTask分佈在了不同的TaskManager節點上),就需要在operator的資料傳遞時申請和釋放network buffer並通過網路I/O傳遞資料。
其過程簡述如下:上游的operator產生的結果會通過RecordWriter序列化,然後申請BufferPool中的Buffer並將序列化後的結果寫入Buffer,此後Buffer會被加入ResultPartition的ResultSubPartition中。ResultSubPartition中的Buffer會通過Netty傳輸至下一級的operator的InputGate的InputChannel中,同樣的,Buffer進入InputChannel前同樣需要到下一級operator所在的TaskManager的BufferPool申請,RecordReader讀取Buffer並將其中的資料反序列化。BufferPool是有限的,在BufferPool為空時RecordWriter/RecordReader所在的執行緒會在申請Buffer的過程中wait一段時間,具體原理可以參考:[1], [2]。
簡要截圖如下:
圖6. Flink的網路棧, 其中RP為ResultPartition、RS為ResultSubPartition、IG為InputGate、IC為inputChannel。
在使用Flink 1.1.x和1.3.x版本時,如果我們的network buffer的數量配置的不充足且資料的吞吐量變大的時候,就會遇到如下現象:
圖7. 上游operator阻塞在獲取network buffer的requestBuffer()方法中
圖8. 下游的operator阻塞在等待新資料輸入
圖9. 下游的operator阻塞在等待新資料輸入
我們的工作執行緒(RecordWriter和RecordReader所在的執行緒)的大部分時間都花在了向BufferPool申請Buffer上,這時候CPU的使用率會劇烈的抖動,使得Job的消費速度下降,在1.1.x版本中甚至會阻塞很長的一段時間,觸發整個job的背壓,從而造成較嚴重的業務延遲。
這時候,我們就需要通過上下游operator的並行度來計算ResultPartition和InputGate中所需要的buffer的個數,以配置充足的taskmanager.network.numberOfBuffers。
圖10. 不同的network buffer對CPU使用率的影響
當配置了充足的network buffer數時,CPU抖動可以減少,Job消費速度有所提高。
在Flink 1.5之後,在其network stack中引入了基於信用度的流量傳輸控制(credit-based flow control)機制[2],該機制大限度的避免了在向BufferPool申請Buffer的阻塞現象,我們初步測試1.7的network stack的效能確實比1.3要高。
但這畢竟還不是最優的情況,因為如果藉助network buffer來完成上下游的operator的資料傳遞不可以避免的要經過序列化/反序列化的過程,而且信用度的資訊傳遞有一定的延遲性和開銷,而這個過程可以通過將上下游的operator鏈成一條operator chain而避免。
因此我們在構建我們流任務的執行圖時,應該儘可能多的讓operator都chain在一起,在Kafka資源允許的情況下可以擴大Kafka的partition而使得source operator和後繼的operator 鏈在一起,但也不能一味擴大Kafka topic的partition,應根據業務量和機器資源做好取捨。更詳細的關於operator的training和task slot的調優可以參考: [4]。
3.Flink中所選用序列化器的建議
在上一節中我們知道,Flink的分佈在不同節點上的Task的資料傳輸必須經過序列化/反序列化,因此序列化/反序列化也是影響Flink效能的一個重要因素。Flink自有一套型別體系,即Flink有自己的型別描述類(TypeInformation)。Flink希望能夠掌握儘可能多的進出operator的資料型別資訊,並使用TypeInformation來描述,這樣做主要有以下2個原因:
- 型別資訊知道的越多,Flink可以選取更好的序列化方式,並使得Flink對記憶體的使用更加高效;
- TypeInformation內部封裝了自己的序列化器,可通過createSerializer()獲取,這樣可以讓使用者不再操心序列化框架的使用(例如如何將他們自定義的型別註冊到序列化框架中,儘管使用者的定製化和註冊可以提高效能)。
總體上來說,Flink推薦我們在operator間傳遞的資料是POJOs型別,對於POJOs型別,Flink預設會使用Flink自身的PojoSerializer進行序列化,而對於Flink無法自己描述或推斷的資料型別,Flink會將其識別為GenericType,並使用Kryo進行序列化。Flink在處理POJOs時更高效,此外POJOs型別會使得stream的grouping/joining/aggregating等操作變得簡單,因為可以使用如:dataSet.keyBy("username") 這樣的方式直接運算元據流中的資料欄位。
除此之外,我們還可以做進一步的優化:
1) 顯示呼叫returns方法,從而觸發Flink的Type Hint:
dataStream.flatMap(new MyOperator()).returns(MyClass.class)
returns方法最終會呼叫TypeExtractor.createTypeInfo(typeClass) ,用以構建我們自定義的型別的TypeInformation。createTypeInfo方法在構建TypeInformation時,如果我們的型別滿足POJOs的規則或Flink中其他的基本型別的規則,會盡可能的將我們的型別“翻譯”成Flink熟知的型別如POJOs型別或其他基本型別,便於Flink自行使用更高效的序列化方式。
//org.apache.flink.api.java.typeutils.PojoTypeInfo
@Override
@PublicEvolving
@SuppressWarnings("unchecked")
public TypeSerializer<T> createSerializer(ExecutionConfig config) {
if (config.isForceKryoEnabled()) {
return new KryoSerializer<>(getTypeClass(), config);
}
if (config.isForceAvroEnabled()) {
return AvroUtils.getAvroUtils().createAvroSerializer(getTypeClass());
}
return createPojoSerializer(config);
}
對於Flink無法“翻譯”的型別,則返回GenericTypeInfo,並使用Kryo序列化:
//org.apache.flink.api.java.typeutils.TypeExtractor
@SuppressWarnings({ "unchecked", "rawtypes" })
private <OUT,IN1,IN2> TypeInformation<OUT> privateGetForClass(Class<OUT> clazz, ArrayList<Type> typeHierarchy,
ParameterizedType parameterizedType, TypeInformation<IN1> in1Type, TypeInformation<IN2> in2Type) {
checkNotNull(clazz);
// 嘗試將 clazz轉換為 PrimitiveArrayTypeInfo, BasicArrayTypeInfo, ObjectArrayTypeInfo
// BasicTypeInfo, PojoTypeInfo 等,具體原始碼已省略
//...
//如果上述嘗試不成功 , 則return a generic type
return new GenericTypeInfo<OUT>(clazz);
}
2) 註冊subtypes: 通過StreamExecutionEnvironment或ExecutionEnvironment的例項的registerType(clazz)方法註冊我們的資料類及其子類、其欄位的型別。如果Flink對型別知道的越多,效能會更好;
3) 如果還想做進一步的優化,Flink還允許使用者註冊自己定製的序列化器,手動建立自己型別的TypeInformation,具體可以參考Flink官網:[3];
在我們的實踐中,最初為了擴充套件性,在operator之間傳遞的資料為JsonNode,但是我們發現效能達不到預期,因此將JsonNode改成了符合POJOs規範的型別,在1.1.x的Flink版本上直接獲得了超過30%的效能提升。在我們呼叫了Flink的Type Hint和env.getConfig().enableForceAvro()後,效能得到進一步提升。這些方法一直沿用到了1.3.x版本。
在升級至1.7.x時,如果使用env.getConfig().enableForceAvro()這個配置,我們的程式碼會引起校驗空欄位的異常。因此我們取消了這個配置,並嘗試使用Kyro進行序列化,並且註冊我們的型別的所有子類到Flink的ExecutionEnvironment中,目前看效能尚可,並優於舊版本使用Avro的效能。但是最佳實踐還需要經過比較和壓測KryoSerializerAvroUtils.getAvroUtils().createAvroSerializerPojoSerializer才能總結出來,大家還是應該根據自己的業務場景和資料型別來合理挑選適合自己的serializer。
4.Standalone模式下job的deploy與資源隔離共享
結合我們之前的使用經驗,Flink的standalone cluster在釋出具體的job時,會有一定的隨機性。舉個例子,如果當前叢集總共有2臺8核的機器用以部署TaskManager,每臺機器上一個TaskManager例項,每個TaskManager的TaskSlot為8,而我們的job的並行度為12,那麼就有可能會出現下圖的現象:
第一個TaskManager的slot全被佔滿,而第二個TaskManager只使用了一半的資源!資源嚴重不平衡,隨著job處理的流量加大,一定會造成TM1上的task消費速度慢,而TM2上的task消費速度遠高於TM1的task的情況。假設業務量的增長迫使我們不得不擴大job的並行度為24,並且擴容2臺效能更高的機器(12核),在新的機器上,我們分別部署slot數為12的TaskManager。經過擴容後,叢集的TaskSlot的佔用可能會形成下圖:
新擴容的配置高的機器並沒有去承擔更多的Task,老機器的負擔仍然比較嚴重,資源本質上還是不均勻!
除了standalone cluster模式下job的釋出策略造成不均衡的情況外,還有資源隔離差的問題。因為我們在一個cluster中往往會部署不止一個job,而這些job在每臺機器上都共用JVM,自然會造成資源的競爭。起初,我們為了解決這些問題,採用瞭如下的解決方法:
- 將TaskManager的粒度變小,即一臺機器部署多個例項,每個例項持有的slot數較少;
- 將大的業務job隔離到不同的叢集上。
這些解決方法增加了例項數和叢集數,進而增加了維護成本。因此我們決定要遷移到on yarn上,目前看Flink on yarn的資源分配和資源隔離確實比standalone模式要優秀一些。
四、總結與展望
Flink在2016年時僅為星星之火,而只用短短兩年的時間就成長為了當前最為炙手可熱的流處理平臺,而且大有統一批與流之勢。經過兩年的實踐,Flink已經證明了它能夠承接TalkingData的App Analytics和Game Analytics兩個產品的流處理需求。接下來我們會將更復雜的業務和批處理遷移到Flink上,完成叢集部署和技術棧的統一,最終實現圖5 中Flink on yarn cluster 的規劃,以更少的成本來支撐更大的業務量。
參考資料:
[1] https://cwiki.apache.org/conf...
[2] https://flink.apache.org/2019...
[3] https://ci.apache.org/project...
[4] https://mp.weixin.qq.com/s/XR...
作者簡介:
肖強:TalkingData資深工程師,TalkingData統計分析產品App Analytics和Game Analytics技術負責人。碩士畢業於北京航空航天大學,主要從事大資料平臺開發,對流式計算和分散式儲存有一定研究。