第04講:Flink 常用的 DataSet 和 DataStream API

大資料技術派發表於2022-01-25

Flink系列文章

第01講:Flink 的應用場景和架構模型
第02講:Flink 入門程式 WordCount 和 SQL 實現
第03講:Flink 的程式設計模型與其他框架比較
第04講:Flink 常用的 DataSet 和 DataStream API

本課時我們主要介紹 Flink 的 DataSet 和 DataStream 的 API,並模擬了實時計算的場景,詳細講解了 DataStream 常用的 API 的使用。

說好的流批一體呢

現狀

在前面的課程中,曾經提到過,Flink 很重要的一個特點是“流批一體”,然而事實上 Flink 並沒有完全做到所謂的“流批一體”,即編寫一套程式碼,可以同時支援流式計算場景和批量計算的場景。目前截止 1.10 版本依然採用了 DataSet 和 DataStream 兩套 API 來適配不同的應用場景。

DateSet 和 DataStream 的區別和聯絡

在官網或者其他網站上,都可以找到目前 Flink 支援兩套 API 和一些應用場景,但大都缺少了“為什麼”這樣的思考。

Apache Flink 在誕生之初的設計哲學是:用同一個引擎支援多種形式的計算,包括批處理、流處理和機器學習等。尤其是在流式計算方面,Flink 實現了計算引擎級別的流批一體。那麼對於普通開發者而言,如果使用原生的 Flink ,直接的感受還是要編寫兩套程式碼。

整體架構如下圖所示:

image.png
在 Flink 的原始碼中,我們可以在 flink-java 這個模組中找到所有關於 DataSet 的核心類,DataStream 的核心實現類則在 flink-streaming-java 這個模組。

image (1).png

image (2).png

在上述兩張圖中,我們分別開啟 DataSet 和 DataStream 這兩個類,可以發現,二者支援的 API 都非常豐富且十分類似,比如常用的 map、filter、join 等常見的 transformation 函式。

我們在前面的課時中講過 Flink 的程式設計模型,對於 DataSet 而言,Source 部分來源於檔案、表或者 Java 集合;而 DataStream 的 Source 部分則一般是訊息中介軟體比如 Kafka 等。

由於 Flink DataSet 和 DataStream API 的高度相似,並且 Flink 在實時計算領域中應用的更為廣泛。所以下面我們詳細講解 DataStream API 的使用。

DataStream

我們先來回顧一下 Flink 的程式設計模型,在之前的課時中提到過,Flink 程式的基礎構建模組是(Streams)和轉換(Transformations),每一個資料流起始於一個或多個 Source,並終止於一個或多個 Sink。資料流類似於有向無環圖(DAG)。

image (3).png

在第 02 課時中模仿了一個流式計算環境,我們選擇監聽一個本地的 Socket 埠,並且使用 Flink 中的滾動視窗,每 5 秒列印一次計算結果。

自定義實時資料來源

在本課時中,我們利用 Flink 提供的自定義 Source 功能來實現一個自定義的實時資料來源,具體實現如下:

public class MyStreamingSource implements SourceFunction<MyStreamingSource.Item> {
    private boolean isRunning = true;
    /**
     * 重寫run方法產生一個源源不斷的資料傳送源
     * @param ctx
     * @throws Exception
     */

    @Override
    public void run(SourceContext<Item> ctx) throws Exception {
        while(isRunning){
            Item item = generateItem();
            ctx.collect(item);
            //每秒產生一條資料
            Thread.sleep(1000);
        }
    }

    @Override
    public void cancel() {
        isRunning = false;
    }

    //隨機產生一條商品資料

    private Item generateItem(){
        int i = new Random().nextInt(100);
        Item item = new Item();
        item.setName("name" + i);
        item.setId(i);
        return item;
    }
    class Item{
        private String name;
        private Integer id;
        Item() {
        }

        public String getName() {

            return name;

        }



        void setName(String name) {

            this.name = name;

        }



        private Integer getId() {

            return id;

        }



        void setId(Integer id) {

            this.id = id;

        }



        @Override

        public String toString() {

            return "Item{" +

                    "name='" + name + '\'' +

                    ", id=" + id +

                    '}';

        }

    }

}





class StreamingDemo {

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



        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //獲取資料來源

        DataStreamSource<MyStreamingSource.Item> text = 

        //注意:並行度設定為1,我們會在後面的課程中詳細講解並行度

        env.addSource(new MyStreamingSource()).setParallelism(1); 

        DataStream<MyStreamingSource.Item> item = text.map(

                (MapFunction<MyStreamingSource.Item, MyStreamingSource.Item>) value -> value);



        //列印結果

        item.print().setParallelism(1);

        String jobName = "user defined streaming source";

        env.execute(jobName);

    }



}

在自定義的資料來源中,實現了 Flink 中的 SourceFunction 介面,同時實現了其中的 run 方法,在 run 方法中每隔一秒鐘隨機傳送一個自定義的 Item。

可以直接執行 main 方法來進行測試:

image (4).png

可以在控制檯中看到,已經有源源不斷地資料開始輸出。下面我們就用自定義的實時資料來源來演示 DataStream API 的使用。

Map

Map 接受一個元素作為輸入,並且根據開發者自定義的邏輯處理後輸出。

image (5).png

複製程式碼

class StreamingDemo {

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



        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //獲取資料來源

        DataStreamSource<MyStreamingSource.Item> items = env.addSource(new MyStreamingSource()).setParallelism(1); 

        //Map

        SingleOutputStreamOperator<Object> mapItems = items.map(new MapFunction<MyStreamingSource.Item, Object>() {

            @Override

            public Object map(MyStreamingSource.Item item) throws Exception {

                return item.getName();

            }

        });

        //列印結果

        mapItems.print().setParallelism(1);

        String jobName = "user defined streaming source";

        env.execute(jobName);

    }

}

我們只取出每個 Item 的 name 欄位進行列印。

image (6).png

注意,Map 運算元是最常用的運算元之一,官網中的表述是對一個 DataStream 進行對映,每次進行轉換都會呼叫 MapFunction 函式。從源 DataStream 到目標 DataStream 的轉換過程中,返回的是 SingleOutputStreamOperator。當然了,我們也可以在重寫的 map 函式中使用 lambda 表示式。

複製程式碼

SingleOutputStreamOperator<Object> mapItems = items.map(

      item -> item.getName()

);

甚至,還可以自定義自己的 Map 函式。通過重寫 MapFunction 或 RichMapFunction 來自定義自己的 map 函式。

複製程式碼

class StreamingDemo {

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



        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //獲取資料來源

        DataStreamSource<MyStreamingSource.Item> items = env.addSource(new MyStreamingSource()).setParallelism(1);

        SingleOutputStreamOperator<String> mapItems = items.map(new MyMapFunction());

        //列印結果

        mapItems.print().setParallelism(1);

        String jobName = "user defined streaming source";

        env.execute(jobName);

    }



    static class MyMapFunction extends RichMapFunction<MyStreamingSource.Item,String> {



        @Override

        public String map(MyStreamingSource.Item item) throws Exception {

            return item.getName();

        }

    }

}

此外,在 RichMapFunction 中還提供了 open、close 等函式方法,重寫這些方法還能實現更為複雜的功能,比如獲取累加器、計數器等。

FlatMap

FlatMap 接受一個元素,返回零到多個元素。FlatMap 和 Map 有些類似,但是當返回值是列表的時候,FlatMap 會將列表“平鋪”,也就是以單個元素的形式進行輸出。

複製程式碼

SingleOutputStreamOperator<Object> flatMapItems = items.flatMap(new FlatMapFunction<MyStreamingSource.Item, Object>() {

    @Override

    public void flatMap(MyStreamingSource.Item item, Collector<Object> collector) throws Exception {

        String name = item.getName();

        collector.collect(name);

    }

});

上面的程式會把名字逐個輸出。我們也可以在 FlatMap 中實現更為複雜的邏輯,比如過濾掉一些我們不需要的資料等。

Filter

顧名思義,Fliter 的意思就是過濾掉不需要的資料,每個元素都會被 filter 函式處理,如果 filter 函式返回 true 則保留,否則丟棄。

image (7).png

例如,我們只保留 id 為偶數的那些 item。

複製程式碼

SingleOutputStreamOperator<MyStreamingSource.Item> filterItems = items.filter(new FilterFunction<MyStreamingSource.Item>() {

    @Override

    public boolean filter(MyStreamingSource.Item item) throws Exception {



        return item.getId() % 2 == 0;

    }

});

image (8).png

當然,我們也可以在 filter 中使用 lambda 表示式:

複製程式碼

SingleOutputStreamOperator<MyStreamingSource.Item> filterItems = items.filter( 

    item -> item.getId() % 2 == 0

);

KeyBy

在介紹 KeyBy 函式之前,需要你理解一個概念:KeyedStream。 在實際業務中,我們經常會需要根據資料的某種屬性或者單純某個欄位進行分組,然後對不同的組進行不同的處理。舉個例子,當我們需要描述一個使用者畫像時,則需要根據使用者的不同行為事件進行加權;再比如,我們在監控雙十一的交易大盤時,則需要按照商品的品類進行分組,分別計算銷售額。

image (9).png

我們在使用 KeyBy 函式時會把 DataStream 轉換成為 KeyedStream,事實上 KeyedStream 繼承了 DataStream,KeyedStream 中的元素會根據使用者傳入的引數進行分組。

我們在第 02 課時中講解的 WordCount 程式,曾經使用過 KeyBy:

複製程式碼

    // 將接收的資料進行拆分,分組,視窗計算並且進行聚合輸出

        DataStream<WordWithCount> windowCounts = text

                .flatMap(new FlatMapFunction<String, WordWithCount>() {

                    @Override

                    public void flatMap(String value, Collector<WordWithCount> out) {

                        for (String word : value.split("\\s")) {

                            out.collect(new WordWithCount(word, 1L));

                        }

                    }

                })

                .keyBy("word")

                .timeWindow(Time.seconds(5), Time.seconds

                ....

在生產環境中使用 KeyBy 函式時要十分注意!該函式會把資料按照使用者指定的 key 進行分組,那麼相同分組的資料會被分發到一個 subtask 上進行處理,在大資料量和 key 分佈不均勻的時非常容易出現資料傾斜和反壓,導致任務失敗。

image (10).png

常見的解決方式是把所有資料加上隨機前字尾,這些我們會在後面的課時中進行深入講解。

Aggregations

Aggregations 為聚合函式的總稱,常見的聚合函式包括但不限於 sum、max、min 等。Aggregations 也需要指定一個 key 進行聚合,官網給出了幾個常見的例子:

複製程式碼

keyedStream.sum(0);
keyedStream.sum("key");
keyedStream.min(0);
keyedStream.min("key");
keyedStream.max(0);
keyedStream.max("key");
keyedStream.minBy(0);
keyedStream.minBy("key");
keyedStream.maxBy(0);
keyedStream.maxBy("key");

在上面的這幾個函式中,max、min、sum 會分別返回最大值、最小值和彙總值;而 minBy 和 maxBy 則會把最小或者最大的元素全部返回。我們拿 max 和 maxBy 舉例說明:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//獲取資料來源

List data = new ArrayList<Tuple3<Integer,Integer,Integer>>();

data.add(new Tuple3<>(0,1,0));

data.add(new Tuple3<>(0,1,1));

data.add(new Tuple3<>(0,2,2));

data.add(new Tuple3<>(0,1,3));

data.add(new Tuple3<>(1,2,5));

data.add(new Tuple3<>(1,2,9));

data.add(new Tuple3<>(1,2,11));

data.add(new Tuple3<>(1,2,13));



DataStreamSource<MyStreamingSource.Item> items = env.fromCollection(data);

items.keyBy(0).max(2).printToErr();



//列印結果

String jobName = "user defined streaming source";

env.execute(jobName);

我們直接執行程式,會發現奇怪的一幕:

image (11).png

從上圖中可以看到,我們希望按照 Tuple3 的第一個元素進行聚合,並且按照第三個元素取最大值。結果如我們所料,的確是按照第三個元素大小依次進行的列印,但是結果卻出現了一個這樣的元素 (0,1,2),這在我們的源資料中並不存在。

我們在 Flink 官網中的文件可以發現:

The difference between min and minBy is that min returns the minimum value, whereas minBy returns the element that has the minimum value in this field (same for max and maxBy).

文件中說:min 和 minBy 的區別在於,min 會返回我們制定欄位的最大值,minBy 會返回對應的元素(max 和 maxBy 同理)

網上很多資料也這麼寫:min 和 minBy 的區別在於 min 返回最小的值,而 minBy 返回最小值的key,嚴格來說這是不正確的。

min 和 minBy 都會返回整個元素,只是 min 會根據使用者指定的欄位取最小值,並且把這個值儲存在對應的位置,而對於其他的欄位,並不能保證其數值正確。max 和 maxBy 同理。

事實上,對於 Aggregations 函式,Flink 幫助我們封裝了狀態資料,這些狀態資料不會被清理,所以在實際生產環境中應該儘量避免在一個無限流上使用 Aggregations。而且,對於同一個 keyedStream ,只能呼叫一次 Aggregation 函式。

不建議的是那些狀態無限增長的聚合,實際應用中一般會配合視窗使用。使得狀態不會無限制擴張。

Reduce

Reduce 函式的原理是,會在每一個分組的 keyedStream 上生效,它會按照使用者自定義的聚合邏輯進行分組聚合。
image (12).png

例如:

複製程式碼

List data = new ArrayList<Tuple3<Integer,Integer,Integer>>();

data.add(new Tuple3<>(0,1,0));

data.add(new Tuple3<>(0,1,1));

data.add(new Tuple3<>(0,2,2));

data.add(new Tuple3<>(0,1,3));

data.add(new Tuple3<>(1,2,5));

data.add(new Tuple3<>(1,2,9));

data.add(new Tuple3<>(1,2,11));

data.add(new Tuple3<>(1,2,13));



DataStreamSource<Tuple3<Integer,Integer,Integer>> items = env.fromCollection(data);

//items.keyBy(0).max(2).printToErr();



SingleOutputStreamOperator<Tuple3<Integer, Integer, Integer>> reduce = items.keyBy(0).reduce(new ReduceFunction<Tuple3<Integer, Integer, Integer>>() {

    @Override

    public Tuple3<Integer,Integer,Integer> reduce(Tuple3<Integer, Integer, Integer> t1, Tuple3<Integer, Integer, Integer> t2) throws Exception {

        Tuple3<Integer,Integer,Integer> newTuple = new Tuple3<>();



        newTuple.setFields(0,0,(Integer)t1.getField(2) + (Integer) t2.getField(2));

        return newTuple;

    }

});



reduce.printToErr().setParallelism(1);

我們對下面的元素按照第一個元素進行分組,第三個元素分別求和,並且把第一個和第二個元素都置為 0:

複製程式碼

data.add(new Tuple3<>(0,1,0));

data.add(new Tuple3<>(0,1,1));

data.add(new Tuple3<>(0,2,2));

data.add(new Tuple3<>(0,1,3));

data.add(new Tuple3<>(1,2,5));

data.add(new Tuple3<>(1,2,9));

data.add(new Tuple3<>(1,2,11));

data.add(new Tuple3<>(1,2,13));

那麼最終會得到:(0,0,6) 和 (0,0,38)。

總結

這一課時介紹了常用的 API 操作,事實上 DataStream 的 API 遠遠不止這些,我們在看官方文件的時候要動手去操作驗證一下,更為高階的 API 將會在實戰課中用到的時候著重進行講解。

相關文章