Flink DataStream API 編程模型

Flink系列文章

  1. 第01講:Flink 的應用場景和架構模型
  2. 第02講:Flink 入門程序 WordCount 和 SQL 實現
  3. 第03講:Flink 的編程模型與其他框架比較
  4. 第04講:Flink 常用的 DataSet 和 DataStream API
  5. 第05講:Flink SQL & Table 編程和案例
  6. 第06講:Flink 集羣安裝部署和 HA 配置
  7. 第07講:Flink 常見核心概念分析
  8. 第08講:Flink 窗口、時間和水印
  9. 第09講:Flink 狀態與容錯
  10. 第10講:Flink Side OutPut 分流
  11. 第11講:Flink CEP 複雜事件處理
  12. 第12講:Flink 常用的 Source 和 Connector
  13. 第13講:如何實現生產環境中的 Flink 高可用配置
  14. 第14講:Flink Exactly-once 實現原理解析
  15. 第15講:如何排查生產環境中的反壓問題
  16. 第16講:如何處理Flink生產環境中的數據傾斜問題
  17. 第17講:生產環境中的並行度和資源設置

本章教程對 Apache Flink 的基本概念進行了介紹,雖然省略了許多重要細節,但是如果你掌握了本章內容,就足以對Flink實現可擴展並行度的 ETL、數據分析以及事件驅動的流式應用程序,有一個大致的瞭解。

Flink 是一個分佈式系統,需要有效分配和管理計算資源才能執行流應用程序。它集成了所有常見的集羣資源管理器,例如Hadoop YARN,但也可以設置作爲獨立集羣甚至庫運行。Flink 運行時由兩種類型的進程組成:一個 JobManager 和一個或者多個 TaskManager。

Client 不是運行時和程序執行的一部分,而是用於準備數據流並將其發送給 JobManager。之後,客戶端可以斷開連接(分離模式),或保持連接來接收進程報告(附加模式)。客戶端可以作爲觸發執行 Java/Scala 程序的一部分運行,也可以在命令行進程./bin/flink run ...中運行。

可以通過多種方式啓動 JobManager 和 TaskManager:直接在機器上作爲standalone 集羣啓動、在容器中啓動、或者通過YARN等資源框架管理並啓動。TaskManager 連接到 JobManagers,宣佈自己可用,並被分配工作。

流處理

在自然環境中,數據的產生原本就是流式的。無論是來自 Web 服務器的事件數據,證券交易所的交易數據,還是來自工廠車間機器上的傳感器數據,其數據都是流式的。但是當你分析數據時,可以圍繞 有界流(bounded)或 無界流(unbounded)兩種模型來組織處理數據,當然,選擇不同的模型,程序的執行和處理方式也都會不同。

Flink 程序看起來像一個轉換 DataStream 的常規程序。每個程序由相同的基本部分組成:

  1. 獲取一個執行環境(execution environment);
  2. 加載/創建初始數據;
  3. 指定數據相關的轉換;
  4. 指定計算結果的存儲位置;
  5. 觸發程序執行。

通常,你只需要使用 getExecutionEnvironment() 即可,因爲該方法會根據上下文做正確的處理:如果你在 IDE 中執行你的程序或將其作爲一般的 Java 程序執行,那麼它將創建一個本地環境,該環境將在你的本地機器上執行你的程序。如果你基於程序創建了一個 JAR 文件,並通過命令行運行它,Flink 集羣管理器將執行程序的 main 方法,同時 getExecutionEnvironment() 方法會返回一個執行環境以在集羣上執行你的程序。

StreamExecutionEnvironment senv = StreamExecutionEnvironment.getExecutionEnvironment();

示例

如下是一個完整的、可運行的程序示例,它是基於流窗口的單詞統計應用程序,計算 5 秒窗口內來自 Web 套接字的單詞數。你可以複製並粘貼代碼以在本地運行,需要的maven依賴地址

package wordcount;

import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;

public class WindowWordCount {
    public static void main(String[] args) throws Exception {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        DataStream<Tuple2<String, Integer>> dataStream = env
                .socketTextStream("192.168.20.130", 9999)
                .flatMap(new Splitter())
                .keyBy(value -> value.f0)
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .sum(1);

        dataStream.print();
        System.out.println("parallelism -> " + env.getParallelism());

        env.execute("Window WordCount");
    }

    public static class Splitter implements FlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String sentence, Collector<Tuple2<String, Integer>> out) throws Exception {
            for (String word: sentence.split(" ")) {
                out.collect(new Tuple2<String, Integer>(word, 1));
            }
        }
    }

}

Linux安裝nc工具:yum install nc,並且在命令行鍵入數據:

[root@hadoop-001 ~]# nc -lk 9999
flink flink spark
flink hadoop spark

程序執行結果:

# IDEA執行,默認flink並行度是8,可以env.setParallelism來設置
parallelism -> 8


1> (spark,1)
7> (flink,2)


1> (spark,1)
8> (hadoop,1)
7> (flink,1)

兩個窗口的結果,可以看到,把flink spark hadoop三個單詞的總次數一個不漏的算出來了。需要注意打印結果,1>表示編號爲1的task打印的,代碼的gitee地址

我們知道了一個Flink程序通常有source -> transform -> sink,即 讀取數據源,處理轉換數據,結果保存 ,接下來將逐步介紹這些基本用法。

Data Sources

Source 是你的程序從中讀取其輸入的地方。你可以用 StreamExecutionEnvironment.addSource(sourceFunction) 將一個 source 關聯到你的程序。Flink 自帶了許多預先實現的 source functions,不過你仍然可以通過實現 SourceFunction 接口編寫自定義的非並行 source,也可以通過實現 ParallelSourceFunction 接口或者繼承 RichParallelSourceFunction 類編寫自定義的並行 sources。通過 StreamExecutionEnvironment 可以訪問多種預定義的 stream source:

1 基於文件:

  • readTextFile(path) - 讀取文本文件,例如遵守 TextInputFormat 規範的文件,逐行讀取並將它們作爲字符串返回。

  • readFile(fileInputFormat, path) - 按照指定的文件輸入格式讀取(一次)文件。

  • readFile(fileInputFormat, path, watchType, interval, pathFilter, typeInfo) - 這是前兩個方法內部調用的方法。它基於給定的 fileInputFormat 讀取路徑 path 上的文件。根據提供的 watchType 的不同,source 可能定期(每 interval 毫秒)監控路徑上的新數據(watchType 爲 FileProcessingMode.PROCESS_CONTINUOUSLY),或者處理一次當前路徑中的數據然後退出(watchType 爲 FileProcessingMode.PROCESS_ONCE)。使用 pathFilter,用戶可以進一步排除正在處理的文件。

實現:

在底層,Flink 將文件讀取過程拆分爲兩個子任務,即 目錄監控 和 數據讀取。每個子任務都由一個單獨的實體實現。監控由單個非並行(並行度 = 1)任務實現,而讀取由多個並行運行的任務執行。後者的並行度和作業的並行度相等。單個監控任務的作用是掃描目錄(定期或僅掃描一次,取決於 watchType),找到要處理的文件,將它們劃分爲 分片,並將這些分片分配給下游 reader。Reader 是將實際獲取數據的角色。每個分片只能被一個 reader 讀取,而一個 reader 可以一個一個地讀取多個分片。

重要提示:

  • 如果 watchType 設置爲 FileProcessingMode.PROCESS_CONTINUOUSLY,當一個文件被修改時,它的內容會被完全重新處理。這可能會打破 “精確一次” 的語義,因爲在文件末尾追加數據將導致重新處理文件的所有內容。

  • 如果 watchType 設置爲 FileProcessingMode.PROCESS_ONCE,source 掃描一次路徑然後退出,無需等待 reader 讀完文件內容。當然,reader 會繼續讀取數據,直到所有文件內容都讀完。關閉 source 會導致在那之後不再有檢查點。這可能會導致節點故障後恢復速度變慢,因爲作業將從最後一個檢查點恢復讀取。

2 基於套接字:

  • socketTextStream - 從套接字讀取。元素可以由分隔符分隔。

3 基於集合:

  • fromCollection(Collection) - 從 Java Java.util.Collection 創建數據流。集合中的所有元素必須屬於同一類型。

  • fromCollection(Iterator, Class) - 從迭代器創建數據流。class 參數指定迭代器返回元素的數據類型。

  • fromElements(T ...) - 從給定的對象序列中創建數據流。所有的對象必須屬於同一類型。

  • fromParallelCollection(SplittableIterator, Class) - 從迭代器並行創建數據流。class 參數指定迭代器返回元素的數據類型。

  • generateSequence(from, to) - 基於給定間隔內的數字序列並行生成數據流。

4 自定義:

  • addSource - 關聯一個新的 source function。例如,你可以使用 addSource(new FlinkKafkaConsumer<>(...)) 來從 Apache Kafka 獲取數據。更多詳細信息見連接器。

基本的stream source

這樣將簡單的流放在一起是爲了方便用於原型或測試。StreamExecutionEnvironment 上還有一個 fromCollection(Collection) 方法。因此,你可以這樣做:

List<Person> people = new ArrayList<Person>();

people.add(new Person("Fred", 35));
people.add(new Person("Wilma", 35));
people.add(new Person("Pebbles", 2));

DataStream<Person> flintstones = env.fromCollection(people);

另一個獲取數據到流中的便捷方法是用 socket

DataStream<String> lines = env.socketTextStream("localhost", 9999)
    
    
    
public static void demo4() throws Exception {
    StreamExecutionEnvironment senv = StreamExecutionEnvironment.getExecutionEnvironment();

    /**
     * 1. linux安裝nc工具:yum install nc
     * 2. 發送數據: nc -lk 9999
      */
    DataStream<Person> persons = senv.socketTextStream("192.168.20.130", 9999)
            .map(line -> new Person(line.split(",")[0], Integer.valueOf(line.split(",")[1])));

    persons.print();
    senv.execute("DataSourceDemo");
}

或讀取文件

DataStream<String> lines = env.readTextFile("file:///path");

在真實的應用中,最常用的數據源是那些支持低延遲,高吞吐並行讀取以及重複(高性能和容錯能力爲先決條件)的數據源,例如 Apache Kafka,Kinesis 和各種文件系統,這將在後面的教程會經常使用Kafka Source。REST API 和數據庫也經常用於增強流處理的能力(stream enrichment)。

由於篇幅,這裏不會列出所有的代碼,demo的gitee地址

DataStream Transformations

轉換主要常用的算子有map、flatMap、Filter、KeyBy、Window等,它們作用是對數據進行清洗、轉換、分發等。這裏列出幾個常用算子,在以後的Flink程序編寫中,這將是非常常用的。通常都需要用戶自定義Function,可以通過1)實現接口;2)匿名類;3)Java8 Lambdas表達式;

1. Map算子 DataStream => DataStream

輸入一個元素同時輸出一個元素。下面是將輸入流中元素數值加倍的 map function:

DataStream<Integer> dataStream = //...
dataStream.map(new MapFunction<Integer, Integer>() {
    @Override
    public Integer map(Integer value) throws Exception {
        return 2 * value;
    }
});

2. FlatMap算子 DataStream => DataStream

輸入一個元素同時產生零個、一個或多個元素。下面是將句子拆分爲單詞的 flatmap function:

dataStream.flatMap(new FlatMapFunction<String, String>() {
    @Override
    public void flatMap(String value, Collector<String> out)
        throws Exception {
        for(String word: value.split(" ")){
            out.collect(word);
        }
    }
});

3. Filter算子 DataStream => DataStream

爲每個元素執行一個布爾 function,並保留那些 function 輸出值爲 true 的元素。下面是過濾掉零值的 filter:

dataStream.filter(new FilterFunction<Integer>() {
    @Override
    public boolean filter(Integer value) throws Exception {
        return value != 0;
    }
});

KeyBy算子 DataStream => KeyedStream

在邏輯上將流劃分爲不相交的分區。具有相同 key 的記錄都分配到同一個分區。在內部, keyBy() 是通過哈希分區實現的,有多種指定 key 的方式,以下是通過Java8 Lambdas表達式:

dataStream.keyBy(value -> value.getSomeKey());
dataStream.keyBy(value -> value.f0);

還可以通過實現KeySelector接口,來指定key。

Rich Functions

至此,你已經看到了 Flink 的幾種函數接口,包括 FilterFunction, MapFunction,和 FlatMapFunction。這些都是單一抽象方法模式。對其中的每一個接口,Flink 同樣提供了一個所謂 “rich” 的變體,如 RichFlatMapFunction,其中增加了以下方法,包括:

  • open(Configuration c)

  • close()

  • getRuntimeContext()

open() 僅在算子初始化時調用一次。可以用來加載一些靜態數據,或者建立外部服務的鏈接等,比如從數據庫讀取配置。

getRuntimeContext() 爲整套潛在有趣的東西提供了一個訪問途徑,最明顯的,它是你創建和訪問 Flink 狀態的途徑。

Data Sinks

Data sinks 使用 DataStream 並將它們轉發到文件、套接字、外部系統或打印它們。Flink 自帶了多種內置的輸出格式,這些格式相關的實現封裝在 DataStreams 的算子裏:

  • writeAsText() / TextOutputFormat - 將元素按行寫成字符串。通過調用每個元素的 toString() 方法獲得字符串。

  • writeAsCsv(...) / CsvOutputFormat - 將元組寫成逗號分隔值文件。行和字段的分隔符是可配置的。每個字段的值來自對象的 toString() 方法。

  • print() / printToErr() - 在標準輸出/標準錯誤流上打印每個元素的 toString() 值。 可選地,可以提供一個前綴(msg)附加到輸出。這有助於區分不同的 print 調用。如果並行度大於1,輸出結果將附帶輸出任務標識符的前綴。

  • writeUsingOutputFormat() / FileOutputFormat - 自定義文件輸出的方法和基類。支持自定義 object 到 byte 的轉換。

  • writeToSocket - 根據 SerializationSchema 將元素寫入套接字。

  • addSink - 調用自定義 sink function。Flink 捆綁了連接到其他系統(例如 Apache Kafka)的連接器,這些連接器被實現爲 sink functions。

print() / printToErr() 主要是程序開發調試的時候,將一些中間結果打印到控制檯,便於調試。

在實際業務開發中,通常會使用addSink ,裏面傳入一個SinkFunction對象,將結果保存到mysql等外部存儲。

rows.addSink(new RichSinkFunction<Row>() {
    private Connection conn = null;

    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        if(conn == null) {
            Class.forName("ru.yandex.clickhouse.ClickHouseDriver");
            conn = DriverManager.getConnection("jdbc:clickhouse://192.168.1.2:8123/test");
        }
    }

    @Override
    public void close() throws Exception {
        super.close();
        if(conn != null) {
            conn.close();
        }
    }

    @Override
    public void invoke(Row row, Context context) throws Exception {
        String sql = "";
        PreparedStatement ps = null;
        sql = "insert into table ...";
        ps = conn.prepareStatement(sql);
        ps.setInt(1, ...);
      
        ps.execute();

        if(ps != null) {
            ps.close();
        }
    }
});

在sink裏面拿到數據庫連接,通常在open()方法,並且組裝sql,invoke()將其寫入到數據庫。

Flink 爲流式/批式處理應用程序的開發提供了不同級別的抽象。

image-20231226210537809

  • Flink API 最底層的抽象爲有狀態實時流處理。其抽象實現是 Process Function,並且 Process Function 被 Flink 框架集成到了 DataStream API 中來爲我們使用。它允許用戶在應用程序中自由地處理來自單流或多流的事件(數據),並提供具有全局一致性和容錯保障的狀態。此外,用戶可以在此層抽象中註冊事件時間(event time)和處理時間(processing time)回調方法,從而允許程序可以實現複雜計算。

  • Flink API 第二層抽象是 Core APIs。實際上,許多應用程序不需要使用到上述最底層抽象的 API,而是可以使用 Core APIs 進行編程:其中包含 DataStream API(應用於有界/無界數據流場景)。Core APIs 提供的流式 API(Fluent API)爲數據處理提供了通用的模塊組件,例如各種形式的用戶自定義轉換(transformations)、聯接(joins)、聚合(aggregations)、窗口(windows)和狀態(state)操作等。此層 API 中處理的數據類型在每種編程語言中都有其對應的類。

  • Process Function 這類底層抽象和 DataStream API 的相互集成使得用戶可以選擇使用更底層的抽象 API 來實現自己的需求。DataSet API 還額外提供了一些原語,比如循環/迭代(loop/iteration)操作。

  • Flink API 第三層抽象是 Table API。Table API 是以表(Table)爲中心的聲明式編程(DSL)API,例如在流式數據場景下,它可以表示一張正在動態改變的表。Table API 遵循(擴展)關係模型:即表擁有 schema(類似於關係型數據庫中的 schema),並且 Table API 也提供了類似於關係模型中的操作,比如 select、project、join、group-by 和 aggregate 等。Table API 程序是以聲明的方式定義應執行的邏輯操作,而不是確切地指定程序應該執行的代碼。儘管 Table API 使用起來很簡潔並且可以由各種類型的用戶自定義函數擴展功能,但還是比 Core API 的表達能力差。此外,Table API 程序在執行之前還會使用優化器中的優化規則對用戶編寫的表達式進行優化。

表和 DataStream/DataSet 可以進行無縫切換,Flink 允許用戶在編寫應用程序時將 Table API 與 DataStream/DataSet API 混合使用。

  • Flink API 最頂層抽象是 SQL。這層抽象在語義和程序表達式上都類似於 Table API,但是其程序實現都是 SQL 查詢表達式。SQL 抽象與 Table API 抽象之間的關聯是非常緊密的,並且 SQL 查詢語句可以在 Table API 中定義的表上執行。

容錯處理

流式處理遇到程序中斷是很常見的異常,如何恢復,這將是很關鍵的,那麼Flink又是如何進行容錯的呢?

Checkpoint Storage

Flink 定期對每個算子的所有狀態進行持久化快照,並將這些快照複製到更持久的地方,例如分佈式文件系統hdfs。 如果發生故障,Flink 可以恢復應用程序的完整狀態並恢復處理,就好像沒有出現任何問題一樣。

這些快照的存儲位置是通過作業_checkpoint storage_定義的。 有兩種可用檢查點存儲實現:一種持久保存其狀態快照 到一個分佈式文件系統,另一種是使用 JobManager 的堆。

狀態快照如何工作?

Flink 使用 Chandy-Lamport algorithm 算法的一種變體,稱爲異步 barrier 快照(asynchronous barrier snapshotting)。

當 checkpoint coordinator(job manager 的一部分)指示 task manager 開始 checkpoint 時,它會讓所有 sources 記錄它們的偏移量,並將編號的 checkpoint barriers 插入到它們的流中。這些 barriers 流經 job graph,標註每個 checkpoint 前後的流部分。

image-20231226213132036

Checkpoint n 將包含每個 operator 的 state,這些 state 是對應的 operator 消費了嚴格在 checkpoint barrier n 之前的所有事件,並且不包含在此(checkpoint barrier n)後的任何事件後而生成的狀態。

當 job graph 中的每個 operator 接收到 barriers 時,它就會記錄下其狀態。擁有兩個輸入流的 Operators(例如 CoProcessFunction)會執行 barrier 對齊(barrier alignment) 以便當前快照能夠包含消費兩個輸入流 barrier 之前(但不超過)的所有 events 而產生的狀態。

確保精確一次(exactly once)

當流處理應用程序發生錯誤的時候,結果可能會產生丟失或者重複。Flink 根據你爲應用程序和集羣的配置,可以產生以下結果:

  • Flink 不會從快照中進行恢復(at most once)

  • 沒有任何丟失,但是你可能會得到重複冗餘的結果(at least once)

  • 沒有丟失或冗餘重複(exactly once)

Flink 通過回退和重新發送 source 數據流從故障中恢復,當理想情況被描述爲精確一次時,這並不意味着每個事件都將被精確一次處理。相反,這意味着 每一個事件都會影響 Flink 管理的狀態精確一次。

Barrier 只有在需要提供精確一次的語義保證時需要進行對齊(Barrier alignment)。如果不需要這種語義,可以通過配置 CheckpointingMode.AT_LEAST_ONCE 關閉 Barrier 對齊來提高性能。

端到端精確一次

爲了實現端到端的精確一次,以便 sources 中的每個事件都僅精確一次對 sinks 生效,必須滿足以下條件:

  1. 你的 sources 必須是可重放的,並且

  2. 你的 sinks 必須是事務性的(或冪等的)

在Flink裏面開啓checkpoint只需要:

Job 升級與擴容

升級 Flink 作業一般都需要兩步:第一,使用 Savepoint 優雅地停止 Flink Job。 Savepoint 是整個應用程序狀態的一次快照(類似於 checkpoint ),該快照是在一個明確定義的、全局一致的時間點生成的。第二,從 Savepoint 恢復啓動待升級的 Flink Job。 在此,“升級”包含如下幾種含義:

  • 配置升級(比如 Job 並行度修改)

  • Job 拓撲升級(比如添加或者刪除算子)

  • Job 的用戶自定義函數升級

Step 1: 停止 Job
要優雅停止 Job,需要使用 JobID 通過 CLI 或 REST API 調用 “stop” 命令。 JobID 可以通過獲取所有運行中的 Job 接口或 Flink WebUI 界面獲取,拿到 JobID 後就可以繼續停止作業了:

bin/flink stop <job-id>

client 預期輸出

Suspending job "<job-id>" with a savepoint.
Suspended job "<job-id>" with a savepoint.

Savepoint 已保存在 state.savepoints.dir 指定的路徑中,該配置在 flink-conf.yaml 中定義,flink-conf.yaml 掛載在本機的 /tmp/flink-savepoints-directory/ 目錄下。 在下一步操作中我們會用到這個 Savepoint 路徑,如果我們是通過 REST API 操作的, 那麼 Savepoint 路徑會隨着響應結果一起返回,我們可以直接查看文件系統來確認 Savepoint 保存情況。

**Step 2: 重啓 Job (不作任何變更) **

如果代碼邏輯需要改變,現在你可以從這個 Savepoint 重新啓動待升級的 Job。

flink run -s <savepoint-path> -p 3 -c MainClass -yid app_id /opt/ClickCountJob.jar

預期輸出

Starting execution of program
Job has been submitted with JobID <job-id>

遲到的數據

對於數據延遲,Flink又是怎麼處理的呢?這裏先介紹2個概念。

Event Time and Watermarks

Flink 明確支持以下三種時間語義:

  • 事件時間(event time): 事件產生的時間,記錄的是設備生產(或者存儲)事件的時間;

  • 攝取時間(ingestion time): Flink 讀取事件時記錄的時間;

  • 處理時間(processing time): Flink pipeline 中具體算子處理事件的時間;

爲了獲得可重現的結果,例如在計算過去的特定一天裏第一個小時股票的最高價格時,我們應該使用事件時間。這樣的話,無論什麼時間去計算都不會影響輸出結果。然而如果使用處理時間的話,實時應用程序的結果是由程序運行的時間所決定。多次運行基於處理時間的實時程序,可能得到的結果都不相同,也可能會導致再次分析歷史數據或者測試新代碼變得異常困難。

EventTime就是我們的數據時間,Flink把每條數據稱爲Event;Watermarks就是每條數據允許的最大延遲;

公司組織春遊,規定週六早晨8:00 ~ 8:30清查人數,人齊則發車出發,可是總有那麼個同學會睡懶覺遲到,這時候通常也會等待20分鐘,但是不能一直等下去,最多等到8:50,不會繼續等待了,直接出發。在這個例子中,最晚期限時間是8:50 - 20分鐘,watermark就是8:30對應的時間戳。

在基於窗口的允許延遲的Flink程序中,窗口最大時間,減去允許延遲的時間,也就是watermark,如果watermark大於window 結束時間,則觸發計算。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章