一 概述
1.1 流處理技術的演變
在開源世界裡,Apache Storm專案是流處理的先鋒。Storm最早由Nathan Marz和創業公司BackType的一個團隊開發,後來才被Apache基金會接納。Storm提供了低延遲的流處理,但是它為實時性付出了一些代價:很難實現高吞吐,並且其正確性沒能達到通常所需的水平,換句話說,它並不能保證exactly-once,即便是它能夠保證的正確性級別,其開銷也相當大。
在低延遲和高吞吐的流處理系統中維持良好的容錯性是非常困難的,但是為了得到有保障的準確狀態,人們想到了一種替代方法:將連續時間中的流資料分割成一系列微小的批量作業。如果分割得足夠小(即所謂的微批處理作業),計算就幾乎可以實現真正的流處理。因為存在延遲,所以不可能做到完全實時,但是每個簡單的應用程式都可以實現僅有幾秒甚至幾亞秒的延遲。這就是在Spark批處理引擎上執行的Spark Streaming所使用的方法。
更重要的是,使用微批處理方法,可以實現exactly-once語義,從而保障狀態的一致性。如果一個微批處理失敗了,它可以重新執行,這比連續的流處理方法更容易。Storm Trident是對Storm的延伸,它的底層流處理引擎就是基於微批處理方法來進行計算的,從而實現了exactly-once語義,但是在延遲性方面付出了很大的代價。
對於Storm Trident以及Spark Streaming等微批處理策略,只能根據批量作業時間的倍數進行分割,無法根據實際情況分割事件資料,並且,對於一些對延遲比較敏感的作業,往往需要開發者在寫業務程式碼時花費大量精力來提升效能。這些靈活性和表現力方面的缺陷,使得這些微批處理策略開發速度變慢,運維成本變高。
於是,Flink出現了,這一技術框架可以避免上述弊端,並且擁有所需的諸多功能,還能按照連續事件高效地處理資料,Flink的部分特性如下圖所示:
1.2 初識Flink
Flink起源於Stratosphere專案,Stratosphere是在2010~2014年由3所地處柏林的大學和歐洲的一些其他的大學共同進行的研究專案,2014年4月Stratosphere的程式碼被複制並捐贈給了Apache軟體基金會,參加這個孵化專案的初始成員是Stratosphere系統的核心開發人員,2014年12月,Flink一躍成為Apache軟體基金會的頂級專案。
在德語中,Flink一詞表示快速和靈巧,專案採用一隻松鼠的彩色圖案作為logo,這不僅是因為松鼠具有快速和靈巧的特點,還因為柏林的松鼠有一種迷人的紅棕色,而Flink的松鼠logo擁有可愛的尾巴,尾巴的顏色與Apache軟體基金會的logo顏色相呼應,也就是說,這是一隻Apache風格的松鼠。
Flink主頁在其頂部展示了該專案的理念:“Apache Flink是為分散式、高效能、隨時可用以及準確的流處理應用程式打造的開源流處理框架”。
Apache Flink是一個框架和分散式處理引擎,用於對無界和有界資料流進行有狀態計算。Flink被設計在所有常見的叢集環境中執行,以記憶體執行速度和任意規模來執行計算。
1.3 Flink核心計算框架
Flink的核心計算架構是下圖中的Flink Runtime執行引擎,它是一個分散式系統,能夠接受資料流程式並在一臺或多臺機器上以容錯方式執行。
Flink Runtime執行引擎可以作為YARN(Yet Another Resource Negotiator)的應用程式在叢集上執行,也可以在Mesos叢集上執行,還可以在單機上執行(這對於除錯Flink應用程式來說非常有用)。
上圖為Flink技術棧的核心組成部分,值得一提的是,Flink分別提供了面向流式處理的介面(DataStream API)和麵向批處理的介面(DataSet API)。因此,Flink既可以完成流處理,也可以完成批處理。Flink支援的擴充庫涉及機器學習(FlinkML)、複雜事件處理(CEP)、以及圖計算(Gelly),還有分別針對流處理和批處理的Table API。
Flink 是一個真正的批流結合的大資料計算框架,將大資料背景下的計算統一整合在一起,不僅降低了學習和操作難度,也有效實現了離線計算和實時計算的大一統
能被Flink Runtime執行引擎接受的程式很強大,但是這樣的程式有著冗長的程式碼,編寫起來也很費力,基於這個原因,Flink提供了封裝在Runtime執行引擎之上的API,以幫助使用者方便地生成流式計算程式。Flink 提供了用於流處理的DataStream API和用於批處理的DataSet API。值得注意的是,儘管Flink Runtime執行引擎是基於流處理的,但是DataSet API先於DataStream API被開發出來,這是因為工業界對無限流處理的需求在Flink誕生之初並不大。
DataStream API可以流暢地分析無限資料流,並且可以用Java或者Scala來實現。開發人員需要基於一個叫DataStream的資料結構來開發,這個資料結構用於表示永不停止的分散式資料流。
Flink的分散式特點體現在它能夠在成百上千臺機器上執行,它將大型的計算任務分成許多小的部分,每個機器執行一部分。Flink能夠自動地確保發生機器故障或者其他錯誤時計算能夠持續進行,或者在修復bug或進行版本升級後有計劃地再執行一次。這種能力使得開發人員不需要擔心執行失敗。Flink本質上使用容錯性資料流,這使得開發人員可以分析持續生成且永遠不結束的資料(即流處理)。
二 Flink基本架構
2.1 JobManager與TaskManager
Flink執行時包含了兩種型別的處理器:
**JobManager處理器:**也稱之為Master,用於協調分散式執行,它們用來排程task,協調檢查點,協調失敗時恢復等。Flink執行時至少存在一個master處理器,如果配置高可用模式則會存在多個master處理器,它們其中有一個是leader,而其他的都是standby。
TaskManager處理器:也稱之為Worker,用於執行一個dataflow的task(或者特殊的subtask)、資料緩衝和data stream的交換,Flink執行時至少會存在一個worker處理器。
簡單圖示如下
Master和Worker處理器可以直接在物理機上啟動,或者通過像YARN這樣的資源排程框架啟動。
Worker連線到Master,告知自身的可用性進而獲得任務分配。
2.2 無界資料流與有界資料流
Flink用於處理有界和無界資料:
無界資料流:無界資料流有一個開始但是沒有結束,它們不會在生成時終止並提供資料,必須連續處理無界流,也就是說必須在獲取後立即處理event。對於無界資料流我們無法等待所有資料都到達,因為輸入是無界的,並且在任何時間點都不會完成。處理無界資料通常要求以特定順序(例如事件發生的順序)獲取event,以便能夠推斷結果完整性,無界流的處理稱為流處理。
有界資料流:有界資料流有明確定義的開始和結束,可以在執行任何計算之前通過獲取所有資料來處理有界流,處理有界流不需要有序獲取,因為可以始終對有界資料集進行排序
,有界流的處理也稱為批處理。
在無界資料流和有界資料流中我們提到了批處理和流處理,這是大資料處理系統中常見的兩種資料處理方式。
批處理的特點是有界、持久、大量,批處理非常適合需要訪問全套記錄才能完成的計算工作,一般用於離線統計。流處理的特點是無界、實時,流處理方式無需針對整個資料集執行操作,而是對通過系統傳輸的每個資料項執行操作,一般用於實時統計。
在Spark生態體系中,對於批處理和流處理採用了不同的技術框架,批處理由SparkSQL實現,流處理由Spark Streaming實現,這也是大部分框架採用的策略,使用獨立的處理器分別實現批處理和流處理,而Flink可以同時實現批處理和流處理。
Flink是如何同時實現批處理與流處理的呢?答案是,Flink將批處理(即處理有限的靜態資料)視作一種特殊的流處理。
Apache Flink是一個面向分散式資料流處理和批量資料處理的開源計算平臺,它能夠基於同一個Flink執行時(Flink Runtime),提供支援流處理和批處理兩種型別應用的功能。現有的開源計算方案,會把流處理和批處理作為兩種不同的應用型別,因為它們要實現的目標是完全不相同的:流處理一般需要支援低延遲、Exactly-once保證,而批處理需要支援高吞吐、高效處理,所以在實現的時候通常是分別給出兩套實現方法,或者通過一個獨立的開源框架來實現其中每一種處理方案。例如,實現批處理的開源方案有MapReduce、Tez、Crunch、Spark,實現流處理的開源方案有Samza、Storm。
Flink在實現流處理和批處理時,與傳統的一些方案完全不同,它從另一個視角看待流處理和批處理,將二者統一起來:Flink是完全支援流處理,也就是說作為流處理看待時輸入資料流是無界的;批處理被作為一種特殊的流處理,只是它的輸入資料流被定義為有界的。基於同一個Flink執行時(Flink Runtime),分別提供了流處理和批處理API,而這兩種API也是實現上層面向流處理、批處理型別應用框架的基礎。
2.3 資料流程式設計模型
Flink提供了不同級別的抽象,以開發流或批處理作業,如下圖所示:
最底層級的抽象僅僅提供了有狀態流,它將通過過程函式(Process Function)被嵌入到DataStream API中。底層過程函式(Process Function) 與 DataStream API 相整合,使其可以對某些特定的操作進行底層的抽象,它允許使用者可以自由地處理來自一個或多個資料流的事件,並使用一致的容錯的狀態。除此之外,使用者可以註冊事件時間並處理時間回撥,從而使程式可以處理複雜的計算。
實際上,大多數應用並不需要上述的底層抽象,而是針對核心API(Core APIs) 進行程式設計,比如DataStream API(有界或無界流資料)以及DataSet API(有界資料集)。這些API為資料處理提供了通用的構建模組,比如由使用者定義的多種形式的轉換(transformations),連線(joins),聚合(aggregations),視窗操作(windows)等等。DataSet API 為有界資料集提供了額外的支援,例如迴圈與迭代。這些API處理的資料型別以類(classes)的形式由各自的程式語言所表示。
Table API 以表為中心,其中表可能會動態變化(在表達流資料時)。Table API遵循(擴充套件的)關係模型:表有二維資料結構(schema)(類似於關聯式資料庫中的表),同時API提供可比較的操作,例如select、project、join、group-by、aggregate等。Table API程式宣告式地定義了什麼邏輯操作應該執行,而不是準確地確定這些操作程式碼的看上去如何 。 儘管Table API可以通過多種型別的使用者自定義函式(UDF)進行擴充套件,其仍不如核心API更具表達能力,但是使用起來卻更加簡潔(程式碼量更少)。除此之外,Table API程式在執行之前會經過內建優化器進行優化。
你可以在表與 DataStream/DataSet 之間無縫切換,以允許程式將 Table API 與 DataStream 以及 DataSet 混合使用。
Flink提供的最高層級的抽象是 SQL 。這一層抽象在語法與表達能力上與 Table API 類似,但是是以SQL查詢表示式的形式表現程式。SQL抽象與Table API互動密切,同時SQL查詢可以直接在Table API定義的表上執行。
三 Flink叢集搭建
Flink可以選擇的部署方式有:
Local、Standalone(資源利用率低)、Yarn、Mesos、Docker、Kubernetes、AWS。
我們主要對Standalone模式和Yarn模式下的Flink叢集部署進行分析。
3.1 Standalone模式安裝
我們對standalone模式的Flink叢集進行安裝,準備三臺虛擬機器,其中一臺作為JobManager(hadoop101),另外兩臺作為TaskManager(hadoop102、hadoop103)。
-
首先官網下載
-
然後將下載的壓縮包傳送到虛擬機器上,解壓到指定位置
-
然後修改配置檔案
[cris@hadoop101 conf]$ vim flink-conf.yaml 複製程式碼
然後修改Worker 節點配置
[cris@hadoop101 conf]$ vim slaves 複製程式碼
-
最後將 Flink 同步到其他兩臺 Worker 節點即可
[cris@hadoop101 module]$ xsync flink-1.6.1/ 複製程式碼
-
啟動命令如下
[cris@hadoop101 bin]$ ./start-cluster.sh 複製程式碼
非常簡單~
通過 jps 檢視程式情況
[cris@hadoop101 bin]$ jpsall ----------jps of hadoop101--------- 2491 StandaloneSessionClusterEntrypoint 2555 Jps ----------jps of hadoop102--------- 2338 Jps 2285 TaskManagerRunner ----------jps of hadoop103--------- 2212 Jps 2159 TaskManagerRunner 複製程式碼
-
訪問叢集web介面(8081埠)
出現如下介面表示 Flink 叢集啟動成功
-
簡單跑個 WC 任務
-
關閉叢集
[cris@hadoop101 bin]$ ./stop-cluster.sh Stopping taskexecutor daemon (pid: 2285) on host hadoop102. Stopping taskexecutor daemon (pid: 2159) on host hadoop103. Stopping standalonesession daemon (pid: 2491) on host hadoop101. [cris@hadoop101 bin]$ jpsall ----------jps of hadoop101--------- 3249 Jps ----------jps of hadoop102--------- 2842 Jps ----------jps of hadoop103--------- 2706 Jps 複製程式碼
3.2 Yarn模式安裝
前四步同 Standalone 模式
-
明確虛擬機器中已經設定好了環境變數HADOOP_HOME
-
啟動Hadoop叢集(HDFS和Yarn)
-
在hadoop101節點提交Yarn-Session,使用安裝目錄下bin目錄中的yarn-session.sh指令碼進行提交:
[cris@hadoop101 ~]$ /opt/module/flink-1.6.1/bin/yarn-session.sh -n 2 -s 6 -jm 1024 -tm 1024 -nm test -d 複製程式碼
其中:
-n(--container):TaskManager的數量。
-s(--slots): 每個TaskManager的slot數量,預設一個slot一個core,預設每個taskmanager的slot的個數為1。
-jm:JobManager的記憶體(單位MB)。
-tm:每個taskmanager的記憶體(單位MB)。
-nm:yarn 的appName(現在yarn的ui上的名字)。
-d:後臺執行。
-
啟動後檢視Yarn的Web頁面,可以看到剛才提交的會話:
檢視程式資訊
-
簡單的跑個任務
[cris@hadoop101 flink-1.6.1]$ ./bin/flink run -m yarn-cluster examples/batch/WordCount.jar 複製程式碼
終端直接列印結果
在看看web 介面
四 Flink執行架構
4.1 任務提交流程
Flink任務提交後,Client向HDFS上傳Flink的Jar包和配置,之後向Yarn ResourceManager提交任務,ResourceManager分配Container資源並通知對應的NodeManager啟動ApplicationMaster
ApplicationMaster啟動後載入Flink的Jar包和配置構建環境,然後啟動JobManager,之後ApplicationMaster向ResourceManager申請資源啟動TaskManager,ResourceManager分配Container資源後,由ApplicationMaster通知資源所在節點的NodeManager啟動TaskManager
NodeManager載入Flink的Jar包和配置構建環境並啟動TaskManager,TaskManager啟動後向JobManager傳送心跳包,並等待JobManager向其分配任務
4.2 TaskManager與Slots
每一個TaskManager是一個JVM程式,它可能會在獨立的執行緒上執行一個或多個subtask。為了控制一個worker能接收多少個task,worker通過task slot來進行控制(一個worker至少有一個task slot)。·
每個task slot表示TaskManager擁有資源的一個固定大小的子集。假如一個TaskManager有三個slot,那麼它會將其管理的記憶體分成三份給各個slot。資源slot化意味著一個subtask將不需要跟來自其他job的subtask競爭被管理的記憶體,取而代之的是它將擁有一定數量的記憶體儲備。需要注意的是,這裡不會涉及到CPU的隔離,slot目前僅僅用來隔離task的受管理的記憶體。
通過調整task slot的數量,允許使用者定義subtask之間如何互相隔離。如果一個TaskManager一個slot,那將意味著每個task group執行在獨立的JVM中(該JVM可能是通過一個特定的容器啟動的),而一個TaskManager多個slot意味著更多的subtask可以共享同一個JVM。而在同一個JVM程式中的task將共享TCP連線(基於多路複用)和心跳訊息。它們也可能共享資料集和資料結構,因此這減少了每個task的負載。
TaskSlot是靜態的概念,是指TaskManager具有的併發執行能力**,可以通過引數taskmanager.numberOfTaskSlots進行配置,而並行度parallelism是動態概念,即TaskManager執行程式時實際使用的併發能力,可以通過引數parallelism.default進行配置。
也就是說,假設一共有3個TaskManager,每一個TaskManager中的分配3個TaskSlot,也就是每個TaskManager可以接收3個task,一共9個TaskSlot,如果我們設定parallelism.default=1,即執行程式預設的並行度為1,9個TaskSlot只用了1個,有8個空閒,因此,設定合適的並行度才能提高效率。
4.3 Dataflow
Flink程式由Source、Transformation、Sink這三個核心元件組成,Source主要負責資料的讀取,Transformation主要負責對屬於的轉換操作,Sink負責最終資料的輸出,在各個元件之間流轉的資料稱為流(streams)。
Flink程式的基礎構建模組是 流(streams) 與 轉換(transformations)(需要注意的是,Flink的DataSet API所使用的DataSets其內部也是stream)。一個stream可以看成一箇中間結果,而一個transformations是以一個或多個stream作為輸入的某種operation,該operation利用這些stream進行計算從而產生一個或多個result stream。
在執行時,Flink上執行的程式會被對映成streaming dataflows,它包含了streams和transformations operators。每一個dataflow以一個或多個sources開始以一個或多個sinks結束,dataflow類似於任意的有向無環圖(DAG)。
4.4 並行資料流
Flink程式的執行具有並行、分散式的特性。在執行過程中,一個 stream 包含一個或多個 stream partition ,而每一個 operator 包含一個或多個 operator subtask,這些operator subtasks在不同的執行緒、不同的物理機或不同的容器中彼此互不依賴得執行。
一個特定operator的subtask的個數被稱之為其parallelism(並行度)。一個stream的並行度總是等同於其producing operator的並行度。一個程式中,不同的operator可能具有不同的並行度。
Stream在operator之間傳輸資料的形式可以是one-to-one(forwarding)的模式也可以是redistributing的模式,具體是哪一種形式,取決於operator的種類。
One-to-one:stream(比如在source和map operator之間)維護著分割槽以及元素的順序。那意味著map operator的subtask看到的元素的個數以及順序跟source operator的subtask生產的元素的個數、順序相同,map、fliter、flatMap等運算元都是one-to-one的對應關係。
Redistributing:這種操作會改變資料的分割槽個數。每一個operator subtask依據所選擇的transformation傳送資料到不同的目標subtask。例如,keyBy() 基於hashCode重分割槽、broadcast和rebalance會隨機重新分割槽,這些運算元都會引起redistribute過程,而redistribute過程就類似於Spark中的shuffle過程。
4.5 task與operatorchains
出於分散式執行的目的,Flink將operator的subtask連結在一起形成task,每個task在一個執行緒中執行。將operators連結成task是非常有效的優化:它能減少執行緒之間的切換和基於快取區的資料交換,在減少時延的同時提升吞吐量。連結的行為可以在程式設計API中進行指定。
下面這幅圖,展示了5個subtask以5個並行的執行緒來執行:
4.6 任務排程流程
客戶端不是執行時和程式執行的一部分,但它用於準備併傳送dataflow給Master,然後,客戶端斷開連線或者維持連線以等待接收計算結果,客戶端可以以兩種方式執行:要麼作為Java/Scala程式的一部分被程式觸發執行,要麼以命令列./bin/flink run的方式執行。
五 Flink DataStream API
5.1 Flink執行模型
以上為Flink的執行模型,Flink的程式主要由三部分構成,分別為Source、Transformation、Sink。DataSource主要負責資料的讀取,Transformation主要負責對屬於的轉換操作,Sink負責最終資料的輸出。
5.2 Flink程式架構
每個Flink程式都包含以下的若干流程:
-
獲得一個執行環境;(Execution Environment)
-
載入/建立初始資料;(Source)
-
指定轉換這些資料;(Transformation)
-
指定放置計算結果的位置;(Sink)
-
觸發程式執行
5.3 Environment
執行環境StreamExecutionEnvironment是所有Flink程式的基礎。
建立執行環境有三種方式,分別為:
StreamExecutionEnvironment.getExecutionEnvironment
StreamExecutionEnvironment.createLocalEnvironment
StreamExecutionEnvironment.createRemoteEnvironment
複製程式碼
StreamExecutionEnvironment.getExecutionEnvironment
建立一個執行環境,表示當前執行程式的上下文。 如果程式是獨立呼叫的,則此方法返回本地執行環境;如果從命令列客戶端呼叫程式以提交到叢集,則此方法返回此叢集的執行環境,也就是說,getExecutionEnvironment會根據查詢執行的方式決定返回什麼樣的執行環境,是最常用的一種建立執行環境的方式。
val env = StreamExecutionEnvironment.getExecutionEnvironment
複製程式碼
5.4 Source
I 基於File的資料來源
-
readTextFile(path)
一列一列的讀取遵循TextInputFormat規範的文字檔案,並將結果作為String返回。
object Test { def main(args: Array[String]): Unit = { // 1. 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment // 2. 讀取指定路徑的文字檔案 val stream: DataStream[String] = executionEnvironment.readTextFile("test00.txt") // 3. action 運算元對 DataStream 中的資料列印 stream.print() // 4. 啟動 Flink 應用 executionEnvironment.execute("test") } } 複製程式碼
Terminal 列印結果
1> apache spark hadoop flume 1> kafka hbase hive flink 4> apache spark hadoop flink 5> kafka hbase hive flink 6> sqoop hue oozie zookeeper 8> apache spark hadoop flume 3> kafka hbase oozie zookeeper 2> sqoop hue oozie zookeeper 7> flink oozie azakaban spark 複製程式碼
注意:stream.print():每一行前面的數字代表這一行是哪一個並行執行緒輸出的。
還可以根據指定的 fileInputFormat 來讀取檔案
readFile(fileInputFormat, path)
-
基於Socket的資料來源
從Socket中讀取資訊
object Test { def main(args: Array[String]): Unit = { // 1. 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment val stream: DataStream[String] = executionEnvironment.socketTextStream("localhost", 1234) // 3. action 運算元對 DataStream 中的資料列印 stream.print() // 4. 啟動 Flink 應用 executionEnvironment.execute("test") } } 複製程式碼
-
基於集合(Collection)的資料來源
-
fromCollection(seq):從集合中建立一個資料流,集合中所有元素的型別是一致的
val stream: DataStream[Int] = executionEnvironment.fromCollection(List(1,2,3,4)) 複製程式碼
-
fromCollection(Iterator):從迭代(Iterator)中建立一個資料流,指定元素資料型別的類由iterator返回
val stream: DataStream[Int] = executionEnvironment.fromCollection(Iterator(3,1,2)) 複製程式碼
-
fromElements(elements:_*):從一個給定的物件序列中建立一個資料流,所有的物件必須是相同型別
val list = List(1,2,3) val stream: DataStream[List[Int]] = executionEnvironment.fromElements(list) 複製程式碼
-
generateSequence(from, to):從給定的間隔中並行地產生一個數字序列
val stream: DataStream[Long] = executionEnvironment.generateSequence(1,10) 複製程式碼
-
5.5 Sink
Data Sink 消費DataStream中的資料,並將它們轉發到檔案、套接字、外部系統或者列印出。
Flink有許多封裝在DataStream操作裡的內建輸出格式。
1. writeAsText
將元素以字串形式逐行寫入(TextOutputFormat),這些字串通過呼叫每個元素的toString()方法來獲取。
2. WriteAsCsv
將元素以逗號分隔寫入檔案中(CsvOutputFormat),行及欄位之間的分隔是可配置的。每個欄位的值來自物件的toString()方法。
3. print/printToErr
列印每個元素的toString()方法的值到標準輸出或者標準錯誤輸出流中。或者也可以在輸出流中新增一個字首,這個可以幫助區分不同的列印呼叫,如果並行度大於1,那麼輸出也會有一個標識由哪個任務產生的標誌。
4. writeUsingOutputFormat
自定義檔案輸出的方法和基類(FileOutputFormat),支援自定義物件到位元組的轉換。
5. writeToSocket
將元素寫入到socket中.
5.6 Transformation
1. map
DataStream → DataStream:輸入一個引數產生一個引數。
// 初始化 Flink 執行環境
val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
// 針對每一行資料前面新增指定字串
val mapDataStream: DataStream[String] = dataStream.map("Apache:" + _)
mapDataStream.print()
// 啟動 Flink 應用
executionEnvironment.execute("test")
複製程式碼
2. flatMap
DataStream → DataStream:輸入一個引數,產生0個、1個或者多個輸出。
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
// 將每行資料按照空格分割成集合,最終 "壓平"
val mapDataStream: DataStream[String] = dataStream.flatMap(_.split(" "))
mapDataStream.print()
複製程式碼
3. filter
DataStream → DataStream:結算每個元素的布林值,並返回布林值為true的元素。
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
val mapDataStream: DataStream[String] = dataStream.filter(_.contains("kafka"))
複製程式碼
4. Connect
DataStream,DataStream → ConnectedStreams:連線兩個保持他們型別的資料流,兩個資料流被Connect之後,只是被放在了一個同一個流中,內部依然保持各自的資料和形式不發生任何變化,兩個流相互獨立。
// 初始化 Flink 執行環境
val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
val listDataStream: DataStream[Int] = executionEnvironment.fromCollection(List(1, 2, 3))
val connStreams: ConnectedStreams[String, Int] = dataStream.connect(listDataStream)
// map函式中的第一個函式作用於 ConnectedStreams 的第一個 DataStream;第二個函式作用於第二個 DataStream
connStreams.map(e => println(e + "-----"), println(_))
// 啟動 Flink 應用
executionEnvironment.execute("test")
複製程式碼
測試效果如下:
針對 ConnectedStreams 的map 和 flatMap 操作稱之為 CoMap,CoFlatMap
作用於ConnectedStreams上,功能與map和flatMap一樣,對ConnectedStreams中的每一個Stream分別進行map和flatMap處理。
5. split
DataStream → SplitStream:根據某些特徵把一個DataStream拆分成兩個或者多個DataStream。
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
val flatMapDStream: DataStream[String] = dataStream.flatMap(_.split(" "))
val splitDStream: SplitStream[String] = flatMapDStream.split(e => "hadoop".equals(e) match {
case true => List("hadoop")
case false => List("other")
})
splitDStream.select("hadoop").print()
複製程式碼
通常配合 select 運算元使用
6. Union
DataStream → DataStream:對兩個或者兩個以上的DataStream進行union操作,產生一個包含所有DataStream元素的新DataStream。注意:如果你將一個DataStream跟它自己做union操作,在新的DataStream中,你將看到每一個元素都出現兩次。
val listDStream: DataStream[Int] = executionEnvironment.fromCollection(List(1,2))
val unionDStream: DataStream[Int] = listDStream.union(listDStream)
unionDStream.print()
複製程式碼
7. KeyBy
DataStream → KeyedStream:輸入必須是Tuple型別,邏輯地將一個流拆分成不相交的分割槽,每個分割槽包含具有相同key的元素,在內部以hash的形式實現的
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
val kvDStream: DataStream[(String, Int)] = dataStream.flatMap(_.split(" ")).map((_, 1))
val result: KeyedStream[(String, Int), String] = kvDStream.keyBy(_._1)
result.print()
複製程式碼
通常結合 reduce 等聚合運算元使用
8. Reduce,Fold,Aggregations
KeyedStream → DataStream:一個分組資料流的聚合操作,合併當前的元素和上次聚合的結果,產生一個新的值,返回的流中包含每一次聚合的結果,而不是隻返回最後一次聚合的最終結果。
val dataStream: DataStream[String] = executionEnvironment.readTextFile("test00.txt")
val kvDStream: DataStream[(String, Int)] = dataStream.flatMap(_.split(" ")).map((_, 1))
val result: KeyedStream[(String, Int), String] = kvDStream.keyBy(_._1)
val reduceDStream: DataStream[(String, Int)] = result.reduce((iter1, iter2) => (iter1._1, iter1._2 + iter2._2))
reduceDStream.print()
複製程式碼
可以發現,Flink 並不是像 Spark 那樣將最後的總的統計結果返回,而是每次聚合統計都將結果返回,所以需要藉助 Flink 的Window 來進行資料的聚合統計(fold 和 aggregation同理)
其實,reduce、fold、aggregation這些聚合運算元都是和Window配合使用的,只有配合Window,才能得到想要的結果.
fold
KeyedStream → DataStream:一個有初始值的分組資料流的滾動摺疊操作,合併當前元素和前一次摺疊操作的結果,併產生一個新的值,返回的流中包含每一次摺疊的結果,而不是隻返回最後一次摺疊的最終結果。
Aggregations
KeyedStream → DataStream:分組資料流上的滾動聚合操作。min和minBy的區別是min返回的是一個最小值,而minBy返回的是其欄位中包含最小值的元素(同樣原理適用於max和maxBy),返回的流中包含每一次聚合的結果,而不是隻返回最後一次聚合的最終結果。
六 Time 和 Window(重點)
6.1 Time
在Flink的流式處理中,會涉及到時間的不同概念,如下圖所示:
Event Time:是事件建立的時間。它通常由事件中的時間戳描述,例如採集的日誌資料中,每一條日誌都會記錄自己的生成時間,Flink通過時間戳分配器訪問事件時間戳。
Ingestion Time:是資料進入Flink的時間。
Processing Time:是每一個執行基於時間操作的運算元的本地系統時間,與機器相關,預設的時間屬性就是Processing Time。
例如,一條日誌進入Flink的時間為2017-11-12 10:00:00.123,到達Window的系統時間為2017-11-12 10:00:01.234,日誌的內容如下:
2017-11-02 18:37:15.624 INFO Fail over to rm2
複製程式碼
對於業務來說,要統計1min內的故障日誌個數,哪個時間是最有意義的?—— eventTime,因為我們要根據日誌的生成時間進行統計。
通常我們需要指定日誌中的哪條資料是 eventTime
6.2 Window
Window可以分成兩類:
-
CountWindow:按照指定的資料條數生成一個Window,與時間無關。
-
TimeWindow:按照時間生成Window。
對於TimeWindow,可以根據視窗實現原理的不同分成三類:滾動視窗(Tumbling Window)、滑動視窗(Sliding Window)和會話視窗(Session Window)。
對於CountWindow 可以分為滾動視窗和滑動視窗
1. 滾動視窗(Tumbling Windows)
將資料依據固定的視窗長度對資料進行切片。
特點:時間對齊,視窗長度固定,沒有重疊。
滾動視窗分配器將每個元素分配到一個指定視窗大小的視窗中,滾動視窗有一個固定的大小,並且不會出現重疊。例如:如果你指定了一個5分鐘大小的滾動視窗,視窗的建立如下圖所示:
適用場景:適合做BI統計等(做每個時間段的聚合計算)。
2. 滑動視窗(Sliding Windows)
滑動視窗是固定視窗的更廣義的一種形式,滑動視窗由固定的視窗長度和滑動間隔組成。
特點:時間對齊,視窗長度固定,有重疊。
滑動視窗分配器將元素分配到固定長度的視窗中,與滾動視窗類似,視窗的大小由視窗大小引數來配置,另一個視窗滑動引數控制滑動視窗開始的頻率。因此,滑動視窗如果滑動引數小於視窗大小的話,視窗是可以重疊的,在這種情況下元素會被分配到多個視窗中。
例如,你有10分鐘的視窗和5分鐘的滑動,那麼每個視窗中5分鐘的視窗裡包含著上個10分鐘產生的部分資料,如下圖所示:
適用場景:對最近一個時間段內的統計(求某介面最近5min的失敗率來決定是否要報警)。
3. 會話視窗(Session Windows)
由一系列事件組合一個指定時間長度的timeout間隙組成,類似於web應用的session,也就是一段時間沒有接收到新資料就會生成新的視窗。
特點:時間無對齊。
session視窗分配器通過session活動來對元素進行分組,session視窗跟滾動視窗和滑動視窗相比,不會有重疊和固定的開始時間和結束時間的情況,相反,當它在一個固定的時間週期內不再收到元素,即非活動間隔產生,那個這個視窗就會關閉。一個session視窗通過一個session間隔來配置,這個session間隔定義了非活躍週期的長度,當這個非活躍週期產生,那麼當前的session將關閉並且後續的元素將被分配到新的session視窗中去。
4. Window API
CountWindow
CountWindow根據視窗中相同key元素的數量來觸發執行,執行時只計算元素數量達到視窗大小的key對應的結果。 注意:CountWindow的window_size指的是相同Key的元素的個數,不是輸入的所有元素的總數。
-
滾動視窗
預設的CountWindow是一個滾動視窗,只需要指定視窗大小即可,當元素數量達到視窗大小時,就會觸發視窗的執行。
def main(args: Array[String]): Unit = { // 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment val socketDStream: DataStream[String] = executionEnvironment.socketTextStream("localhost",1234) val mapDStream: DataStream[(String, Int)] = socketDStream.map(e => { val strings: Array[String] = e.split(" ") (strings(0), strings(1).toInt) }) val keyDStream: KeyedStream[(String, Int), Tuple] = mapDStream.keyBy(0) // 只有等相同key 的元素個數達到3的時候才會進行 reduce 和 print 操作 val windowDStream: WindowedStream[(String, Int), Tuple, GlobalWindow] = keyDStream.countWindow(3) val reduceDStream: DataStream[(String, Int)] = windowDStream.reduce((e1,e2)=>(e1._1,e1._2+e2._2)) reduceDStream.print() // 啟動 Flink 應用 executionEnvironment.execute("test") } 複製程式碼
測試效果如下:
-
滑動視窗
滑動視窗和滾動視窗的函式名是完全一致的,只是在傳引數時需要傳入兩個引數,一個是window_size,一個是sliding_size。
下面程式碼中的sliding_size設定為了2,也就是說,每收到兩個相同key的資料就計算一次,每一次計算的window範圍是該 key 的前4個元素。
def main(args: Array[String]): Unit = { // 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment val socketDStream: DataStream[String] = executionEnvironment.socketTextStream("localhost",1234) val mapDStream: DataStream[(String, Int)] = socketDStream.map(e => { val strings: Array[String] = e.split(" ") (strings(0), strings(1).toInt) }) val keyDStream: KeyedStream[(String, Int), Tuple] = mapDStream.keyBy(0) // 只有等相同key 的元素個數達到2的時候才會對該 key 的前4條資料進行 reduce 和 print 操作 val windowDStream: WindowedStream[(String, Int), Tuple, GlobalWindow] = keyDStream.countWindow(4,2) val reduceDStream: DataStream[(String, Int)] = windowDStream.reduce((e1,e2)=>(e1._1,e1._2+e2._2)) reduceDStream.print() // 啟動 Flink 應用 executionEnvironment.execute("test") } } 複製程式碼
TimeWindow
TimeWindow是將指定時間範圍內的所有資料組成一個window,一次對一個window裡面的所有資料進行計算。
-
滾動視窗
Flink預設的時間視窗根據Processing Time 進行視窗的劃分,將Flink獲取到的資料根據進入Flink的時間劃分到不同的視窗中。
// 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment val socketDStream: DataStream[String] = executionEnvironment.socketTextStream("localhost",1234) val mapDStream: DataStream[(String, Int)] = socketDStream.map(e => { val strings: Array[String] = e.split(" ") (strings(0), strings(1).toInt) }) val keyDStream: KeyedStream[(String, Int), Tuple] = mapDStream.keyBy(0) // 每3 秒對進入該視窗的所有相同key 的資料進行reduce 和 print 操作 val windowDStream: WindowedStream[(String, Int), Tuple, TimeWindow] = keyDStream.timeWindow(Time.seconds(3)) val reduceDStream: DataStream[(String, Int)] = windowDStream.reduce((e1,e2)=>(e1._1,e1._2+e2._2)) reduceDStream.print() // 啟動 Flink 應用 executionEnvironment.execute("test") 複製程式碼
-
滑動視窗
滑動視窗和滾動視窗的函式名是完全一致的,只是在傳引數時需要傳入兩個引數,一個是window_size,一個是sliding_size。
下面程式碼中的sliding_size設定為了2s,也就是說,視窗每2s就計算一次,每一次計算的window範圍是4s內的所有元素。
// 初始化 Flink 執行環境 val executionEnvironment: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment val socketDStream: DataStream[String] = executionEnvironment.socketTextStream("localhost",1234) val mapDStream: DataStream[(String, Int)] = socketDStream.map(e => { val strings: Array[String] = e.split(" ") (strings(0), strings(1).toInt) }) val keyDStream: KeyedStream[(String, Int), Tuple] = mapDStream.keyBy(0) // 每2 秒對進入該視窗的所有資料進行前 4 秒資料的 reduce 和 print 操作 val windowDStream: WindowedStream[(String, Int), Tuple, TimeWindow] = keyDStream.timeWindow(Time.seconds(4),Time .seconds(2)) val reduceDStream: DataStream[(String, Int)] = windowDStream.reduce((e1,e2)=>(e1._1,e1._2+e2._2)) reduceDStream.print() // 啟動 Flink 應用 executionEnvironment.execute("test") 複製程式碼
Window Fold
WindowedStream → DataStream:給視窗賦一個fold功能的函式,並返回一個fold後的結果。
// 獲取執行環境
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 建立SocketSource
val stream = env.socketTextStream("localhost", 11111,'\n',3)
// 對stream進行處理並按key聚合
val streamKeyBy = stream.map(item => (item, 1)).keyBy(0)
// 引入滾動視窗
val streamWindow = streamKeyBy.timeWindow(Time.seconds(5))
// 執行fold操作
val streamFold = streamWindow.fold(100){
(begin, item) =>
begin + item._2
}
// 將聚合資料寫入檔案
streamFold.print()
// 執行程式
env.execute("TumblingWindow")
複製程式碼
Aggregation on Window
WindowedStream → DataStream:對一個window內的所有元素做聚合操作。min和 minBy的區別是min返回的是最小值,而minBy返回的是包含最小值欄位的元素(同樣的原理適用於 max 和 maxBy)。
// 獲取執行環境
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 建立SocketSource
val stream = env.socketTextStream("localhost", 11111)
// 對stream進行處理並按key聚合
val streamKeyBy = stream.map(item => (item.split(" ")(0), item.split(" ")(1))).keyBy(0)
// 引入滾動視窗
val streamWindow = streamKeyBy.timeWindow(Time.seconds(5))
// 執行聚合操作
val streamMax = streamWindow.max(1)
// 將聚合資料寫入檔案
streamMax.print()
// 執行程式
env.execute("TumblingWindow")
複製程式碼
七 EventTime與waterMark
7.1 EventTime的引入
在Flink的流式處理中,絕大部分的業務都會使用eventTime,一般只在eventTime無法使用時,才會被迫使用ProcessingTime或者IngestionTime。
如果要使用EventTime,那麼需要引入EventTime的時間屬性,引入方式如下所示:
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 從呼叫時刻開始給env建立的每一個stream追加時間特徵
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
複製程式碼
這裡日誌的時間是 Flink 根據我們的規則去解析生成的eventTime,而不是預設的 processingTime
而window 的時間區間是左閉右開的,及 2019-01-25 00:00:06 時間的日誌會進入第二個window
7.2 Watermark的引入
我們知道,流處理從事件產生,到流經source,再到operator,中間是有一個過程和時間的,雖然大部分情況下,流到operator的資料都是按照事件產生的時間順序來的,但是也不排除由於網路等原因,導致亂序的產生,所謂亂序,就是指Flink接收到的事件的先後順序不是嚴格按照事件的Event Time順序排列的。
那麼此時出現一個問題,一旦出現亂序,如果只根據eventTime決定window的執行,我們不能明確資料是否全部到位,但又不能無限期的等下去,此時必須要有個機制來保證一個特定的時間後,必須觸發window去進行計算了,這個特別的機制,就是Watermark。
Watermark是一種衡量Event Time進展的機制,它是資料本身的一個隱藏屬性,資料本身攜帶著對應的Watermark。
Watermark是用於處理亂序事件的,而正確的處理亂序事件,通常用Watermark機制結合window來實現。
資料流中的Watermark用於表示timestamp小於Watermark的資料,都已經到達了,因此,window的執行也是由Watermark觸發的。
Watermark可以理解成一個延遲觸發機制,我們可以設定Watermark的延時時長t,每次系統會校驗已經到達的資料中最大的maxEventTime,然後認定eventTime小於maxEventTime - t的所有資料都已經到達,如果有視窗的停止時間等於maxEventTime – t,那麼這個視窗被觸發執行。
個人總結一下:針對進入視窗的每條資料,計算當前所有達到視窗的資料的最大eventTime,將這個eventTime和延遲時間(watermark)做減法,差值如果大於某一個視窗的的結束時間,那麼該視窗就進行運算元操作
有序流的Watermarker如下圖所示:(Watermark設定為0)
亂序流的Watermarker如下圖所示:(Watermark設定為2)
當Flink接收到每一條資料時,都會產生一條Watermark,這條Watermark就等於當前所有到達資料中的maxEventTime - 延遲時長,也就是說,Watermark是由資料攜帶的,一旦資料攜帶的Watermark比當前未觸發的視窗的停止時間要晚,那麼就會觸發相應視窗的執行。由於Watermark是由資料攜帶的,因此,如果執行過程中無法獲取新的資料,那麼沒有被觸發的視窗將永遠都不被觸發。
上圖中,我們設定的允許最大延遲到達時間為2s,所以時間戳為7s的事件對應的Watermark是5s,時間戳為12s的事件的Watermark是10s,如果我們的視窗1是1s~5s,視窗2是6s~10s,那麼時間戳為7s的事件到達時的Watermarker恰好觸發視窗1,時間戳為12s的事件到達時的Watermark恰好觸發視窗2。
7.3 測試程式碼
// 初始化 Flink 執行環境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
// 將 Flink 時間由預設的processingTime 設定為 eventTime
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
val source: DataStream[String] = env.socketTextStream("localhost", 1234)
// 設定watermark 以及如何解析每條日誌資料中的eventTime
val stream: DataStream[String] = source.assignTimestampsAndWatermarks(
new BoundedOutOfOrdernessTimestampExtractor[String](Time.seconds(0)) {
override def extractTimestamp(element: String): Long = {
val time: Long = element.split(" ")(0).toLong
println(time)
time
}
}
)
val keyStream: KeyedStream[(String, Int), Tuple] = stream.map(e => (e.split(" ")(1), 1)).keyBy(0)
// 設定滾動視窗的長度為5秒,及每5秒的eventTime 間隔計算一次
val windowStream: WindowedStream[(String, Int), Tuple, TimeWindow] = keyStream.window(TumblingEventTimeWindows.of(Time.seconds(5)))
val reduceStream: DataStream[(String, Int)] = windowStream.reduce(
(e1, e2) => (e1._1, e1._2 + e2._2)
)
reduceStream.print()
env.execute("test")
}
複製程式碼
測試如下
如果watermark 設定為2,那麼等到7000(毫秒)以及大於這個時間的日誌進入window 的時候,才會進行第一個視窗的計算
如果視窗型別設定為 SlidingEventTimeWindows ,那麼watermark 影響的就是滑動視窗的計算時間,感興趣的可以自己試試
如果視窗型別設定為 EventTimeSessionWindows.withGap(Time.seconds(10)),那麼影響的就是相鄰兩條資料的時間間隔必須大於指定時間才會觸發計算
八 總結
Flink是一個真正意義上的流計算引擎,在滿足低延遲和低容錯開銷的基礎之上,完美的解決了exactly-once的目標,真是由於Flink具有諸多優點,越來越多的企業開始使用Flink作為流處理框架,逐步替換掉了原本的Storm和Spark技術框架。