聊聊flink水位線

滴普科技DEEPEXI發表於2022-06-22

1、概述

flink中比較重要的是時間和狀態,學習flink的過程中對水位線的理解一直模糊,經過一段時間的消化,在此總結總結。本文主要把水位線是什麼,怎麼來的,有什麼用描述清楚。

2、不太好理解的水位線

有些人喜歡把水位線叫成水印,不管是水印還是水位線,中文翻譯過來一點都不貼切我們的生活,比較抽象,讓人難得理解。在我們生活中水位線類似家中掛在牆上的一個掛鐘,類似我們的手錶。下面來聊聊如下的話題:
1,到底是如何產生。
2,既然是一個掛鐘,鐘錶有哪些特點呢,鐘錶每隔1s秒針往前走一小步,時間是不是越來越大,這些特點水位線是不是也有呢。
3,掛鐘有什麼用處啊?晚上看看手錶發現12點,我們肯定自我暗示:"應該睡覺了",通過時間讓我們知道什麼時間該幹什麼事情。

3、什麼叫水位線

3.1、水位線的定義
水位線就是一個邏輯時鐘,為什麼叫邏輯時鐘?正常時間是有cpu產生的,週期而固定的往前走,但是我們這個時鐘的時間是程式設計師計算出來,根據"事件時間"動態計算出來(至於什麼是時間事件,有什麼使用場景這裡就不講了),如某一時刻計算的結果為x,x值為2022-10-10 10:10:10對應的時間戳為1665367810000,x的值隨著事件時間的變大而變大,可能的結果為x,x+1,x+2,x+3,x+4 ... 連續的越來越大的時間戳是不是類似鐘錶每隔1s往前走一步呢。
3.2、水位線(邏輯時鐘)的組成
水位線由一串連續的時間戳組成,越來越大,每個時間戳都是根據事件時間動態計算出來的。時鐘也是由一連續的時間組成,也是越來越大,如2022-10-10 10:10:10,2022-10-10 10:10:11,2022-10-10 10:10:12,2022-10-10 10:10:13 。。。等,水位線就是類似生活中的時鐘,所以我把這個水位線稱為邏輯時鐘,邏輯時鐘就是水位線,水印機制。
3.3、邏輯時鐘當前時間
類似時鐘的當前時間,此處此刻為幾點幾分幾秒,這個當前時間比較重要,視窗的閉合,定時任務的觸發都是根據當前時間來判斷的。
當前值特點:越來越大,流剛剛產生的時候插入負無窮大值,結束是插入正無窮大的值。

個人覺得這個當前值類似一個指標型別的變數,他的指向是不停的變化的(個人理解)。

3.4、當前時間的計算公式
時鐘的"當前時間"對應一個具體的時間戳。時鐘的當前值xxx = 事件時間 - 最大延遲時間 - 1毫秒。
3.5、來一個案例
案例描述:從socket讀取資料,並列印當前水位的具體值。


package com.deepexi.sql;

​

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;

import org.apache.flink.api.common.eventtime.WatermarkStrategy;

import org.apache.flink.api.common.typeinfo.Types;

import org.apache.flink.api.java.tuple.Tuple2;

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

import org.apache.flink.streaming.api.functions.KeyedProcessFunction;

import org.apache.flink.util.Collector;

​

import java.time.Duration;

​

public class ExampleTest {

​

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

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(1);

        env

                //從socket讀取資料

                .socketTextStream("192.168.117.211", 9999)

                .map(r -> Tuple2.of(r.split(" ")[0], Long.parseLong(r.split(" ")[1])))

                .returns(Types.TUPLE(Types.STRING, Types.LONG))

                .assignTimestampsAndWatermarks(

                        //5s延遲時間

                        WatermarkStrategy.<Tuple2<String, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(5))

                                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {

                                    @Override

                                    public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {

                                        //提取事件時間

                                        return element.f1;

                                    }

                                })

                )

                //分流

                .keyBy(r -> r.f0)

                .process(new KeyedProcessFunction<String, Tuple2<String, Long>, String>() {

                    @Override

                    public void processElement(Tuple2<String, Long> value, Context ctx, Collector<String> out) throws Exception {

                        out.collect("當前的水位線是:" + ctx.timerService().currentWatermark());

                    }

                })

                .print();

​

        env.execute();

    }

​

}

nc -lk 9999 開啟socket服務,監聽9999埠
命令列輸入:a 1000
[root@localhost ~]# nc -lk 9999 a 1000
idea控制檯列印
當前的水位線是:-9223372036854775808 //-9223372036854775808是一個無窮大的數字

命令列輸入:a 2000
idea控制檯列印:
當前的水位線是:-4001 //當前水位線的值 = 事件時間 - 最大延遲時間 -1 = 1000 - 5000 -1 = -4000
為什麼用1000- 5000 -1而用2000 - 5000 -1? flink會週期往流中插入水位線,水位線也是流中的一個元素,還是看下圖理解吧。

命令列輸入:a 3000
idea控制檯列印:當前的水位線是:-3001 //2000 - 5000 -1 = -2000

命令列輸入:a 10000
idea控制檯列印:當前的水位線是:-2001 //3000 - 5000 -1 = -2000

命令列輸入:a 1000
idea控制檯列印:當前的水位線是:4999 //10000 - 5000 -1 = 4999

命令列輸入:a 1000
idea控制檯列印:當前的水位線是:4999 //10000 - 5000 -1 = 4999

命令列輸入:a 2000
idea控制檯列印:當前的水位線是:4999 //10000 - 5000 -1 = 4999

通過控制檯的列印結果發現水位線的和鐘錶一樣,值總是越來越大的,隨著事件時間的變化而變化,但是不會變小,也可能會停止某一刻,如輸入a 1000後在輸入a 1000,a 2000水位線的值始終是4999。

整個列印過程
命令列視窗:


[root@master ~]# nc -lk 9999

a 1000

a 2000

a 3000

a 10000

a 1000

a 1000

a 2000

idea列印:


當前的水位線是:-9223372036854775808

當前的水位線是:-4001

當前的水位線是:-3001

當前的水位線是:-2001

當前的水位線是:4999

當前的水位線是:4999

當前的水位線是:4999

在這裡插入圖片描述

4、如何產生的

水位線本質就是一個時間戳,這個時間戳是程式設計師根據事件時間動態計算出來,直接來一個案例吧。
案例1
自定義水位線的產生邏輯,實現WatermarkStrategy介面,flink會每隔200毫秒的呼叫onPeriodicEmit方法。

public class ExampleTest2 {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        //設定每隔1分鐘插入一次水位線
        //env.getConfig().setAutoWatermarkInterval(6 * 1000L);

        env
                .socketTextStream("192.168.117.211", 9999)
                .map(new MapFunction<String, Tuple2<String, Long>>() {
                    @Override
                    public Tuple2<String, Long> map(String value) throws Exception {
                        String[] arr = value.split(" ");
                        return Tuple2.of(arr[0], Long.parseLong(arr[1]));
                    }
                })
                .assignTimestampsAndWatermarks(new CustomWatermarkGenerator())
                .print();

        env.execute();
    }

    public static class CustomWatermarkGenerator implements WatermarkStrategy<Tuple2<String, Long>> {
        @Override
        public TimestampAssigner<Tuple2<String, Long>> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
            return new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                @Override
                public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {
                    return element.f1;
                }
            };
        }

        @Override
        public WatermarkGenerator<Tuple2<String, Long>> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
            return new WatermarkGenerator<Tuple2<String, Long>>() {
                // 最大延遲時間
                private Long bound = 5000L;
                private Long maxTs = -Long.MAX_VALUE + bound + 1L;

                @Override
                public void onEvent(Tuple2<String, Long> event, long eventTimestamp, WatermarkOutput output) {
                    //更新觀察到的最大事件時間
                    maxTs = Math.max(maxTs, event.f1);
                }

                @Override
                public void onPeriodicEmit(WatermarkOutput output) {
                    System.out.println("水位線的值:" + (maxTs - bound - 1L));
                    // 傳送水位線,計算公式:事件時間-延遲時間-1L
                    output.emitWatermark(new Watermark(maxTs - bound - 1L));
                }
            };
        }
    }
}

nc -lk 9999 開啟socket服務,監聽9999埠
啟動idea,控制檯每隔200毫秒列印結果:水位線的值:xxxxx。如下:
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807

命令列輸入:a 1000
控制檯每隔200毫秒列印結果:水位線的值:xxxxx。如下:
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001

命令列輸入:a 2000
控制檯每隔200毫秒列印介面:水位線的值:xxxxx。如下:
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001

//預設200毫秒插入水位線到流,可以設定水位線的插入流的時間間隔
env.getConfig().setAutoWatermarkInterval(6 * 1000L);

整個列印過程
命令列視窗:

[root@master ~]# nc -lk 9999
a 1000
a 2000

idea列印:

水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
(a,1000)
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
(a,2000)
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
Disconnected from the target VM, address: '127.0.0.1:58591', transport: 'socket'
水位線的值:-3001

Process finished with exit code 130

通過結果我們可以知道,水位線的值隨著事件時間1000,2000的變化而變化。如果輸入a 2000後在輸入a 1000,控制檯列印結果是怎樣的?那肯定列印的是:水位線的值:-3001,因為水位線的值和時間一樣永遠只會越來越大。

案例2

改造一下程式,新增如下程式碼,keyby後,把命令列輸入的元素列印出來。
在這裡插入圖片描述
nc -lk 9999啟動socket監聽9999埠
啟動idea

命令列輸入

[root@localhost ~]# nc -lk 9999
a 1000
a 2000
a 5000
a 6000

idea控制檯列印:

水位線的值:-9223372036854775807
水位線的值:-9223372036854775807
輸入業務資料是:(a,1000)
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
水位線的值:-4001
輸入業務資料是:(a,2000)
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
水位線的值:-3001
輸入業務資料是:(a,5000)
水位線的值:-1
水位線的值:-1
水位線的值:-1
水位線的值:-1
水位線的值:-1
水位線的值:-1
水位線的值:-1
水位線的值:-1
輸入業務資料是:(a,6000)
水位線的值:999
水位線的值:999
水位線的值:999
水位線的值:999
水位線的值:999

分析計算結果結果:
-9223372036854775807,-9223372036854775807,(a,1000),-4001,-4001,-4001,-4001,-4001,-4001,-4001,-4001,(a,2000),-3001,-3001,-3001,-3001,-3001,(a,5000),-1,-1,-1,(a,6000),999,999,999,999

不知道大家有沒有一種感覺,水位線和業務資料什麼關係?是不是類似生活中落花和流水的關係呢?業務資料就是河流中的水,水位線就像落在水中的花,他們兩一起流向大海,水位線和業務資料一樣都屬於流中的一個元素。

5、有什麼用

在流的世界邏輯時鐘就是一個參照物。還是掛鐘來舉例吧,看看掛鐘已經12點了,我們肯定在會暗示自己該放下手機了要睡覺了。針對源源不斷的資料流,把資料流拆分為多段進行處理,針對每段資料進行統計,那什麼時候觸發統計呢?這個時候就會用這個邏輯時鐘,視窗看看邏輯時間當前處於幾點鐘,發現視窗結束時間小於時鐘的時間,視窗閉合進行統計。

案例1,水位線觸發定時任務的執行
功能描述:水位線的當前時間戳大於定時任務的的觸發時間後 觸發定時任務的執行。

public class ExampleTest3 {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        env
                .socketTextStream("192.168.117.211", 9999)
                .map(r -> Tuple2.of(r.split(" ")[0], Long.parseLong(r.split(" ")[1])))
                .returns(Types.TUPLE(Types.STRING, Types.LONG))
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy.<Tuple2<String, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                            @Override
                            public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {
                                return element.f1;
                            }
                        })
                )
                .keyBy(r -> r.f0)
                .process(new KeyedProcessFunction<String, Tuple2<String, Long>, String>() {
                    @Override
                    public void processElement(Tuple2<String, Long> value, Context ctx, Collector<String> out) throws Exception {
//                        out.collect("當前的水位線是:" + ctx.timerService().currentWatermark());
                        ctx.timerService().registerEventTimeTimer(value.f1 + 5000L);
                        out.collect("註冊了一個時間戳是:" + new Timestamp(value.f1 + 5000L) + " 的定時器");
                    }

                    @Override
                    public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
                        super.onTimer(timestamp, ctx, out);
                        out.collect("定時器觸發了!");
                    }
                })
                .print();

        env.execute();
    }
}

nc -lk 9999 開啟socket服務,監聽9999埠
命令列輸入:a 1665367810000 //1665367810000對應的時間為2022-10-10 10:10:10
控制檯輸出:註冊了一個時間戳是:2022-10-10 10:10:15.0 的定時器 //2022-10-10 10:10:15轉換為時間戳為1665367815000

解釋一下控制檯輸出結果
當前水位線的值:2022-10-10 10:10:10 - 5s -1毫秒 = 1665367810000 - 5000 -1 = 1665367804999。當水位線的值大於1665367815000定時任務觸發。

命令列輸入:1665367821000 //命令列輸入2022-10-10 10:10:21對應的時間戳1665367821000將會觸發定時任務
控制檯輸出:定時器觸發了!

命名行列印輸入

[root@master ~]# nc -lk 9999
a 1665367810000
a 1665367821000

idea列印輸入

註冊了一個時間戳是:2022-10-10 10:10:15.0 的定時器
註冊了一個時間戳是:2022-10-10 10:10:26.0 的定時器
定時器觸發了!

案例2,水位線當前時間戳大於視窗結束時間觸發視窗閉

案例day3.Example4

public class ExampleTest4 {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        env
                .socketTextStream("192.168.117.211", 9999)
                .map(new MapFunction<String, Tuple2<String, Long>>() {
                    @Override
                    public Tuple2<String, Long> map(String value) throws Exception {
                        String[] arr = value.split(" ");
                        return Tuple2.of(arr[0], Long.parseLong(arr[1]));
                    }
                })
                .assignTimestampsAndWatermarks(
                        // 最大延遲時間設定為5秒
                        WatermarkStrategy.<Tuple2<String, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                                .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                                    @Override
                                    public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {
                                        return element.f1; // 告訴flink事件時間是哪一個欄位
                                    }
                                })
                )
                .keyBy(r -> r.f0)
                // 5秒的事件時間滾動視窗
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .process(new ProcessWindowFunction<Tuple2<String, Long>, String, String, TimeWindow>() {
                    @Override
                    public void process(String key, Context context, Iterable<Tuple2<String, Long>> elements, Collector<String> out) throws Exception {
                        long windowStart = context.window().getStart();
                        long windowEnd = context.window().getEnd();
//                        System.out.println("當前視窗的結束值:" + context.currentWatermark());
//                        System.out.println("當前水位線的值:" + context.currentWatermark());
                        long count = elements.spliterator().getExactSizeIfKnown();
                        out.collect("使用者" + key + " 在視窗" +
                                "" + new Timestamp(windowStart) + "~" + new Timestamp(windowEnd) + "" +
                                "中的pv次數是:" + count);
                    }
                })
                .print();

        env.execute();
    }
}

命令列輸入:a 1665367810000 //flink將開啟一個2022-10-10 10:10:10.0~2022-10-10 10:10:15的視窗,當水位線當前值(當前值指上面的當前時間)大於視窗結束時間對應的時間戳會觸發視窗閉合。

命令列輸入:a 1665367821000 //此時水位線當前值為:1665367821000 - 5000 -1 = 1665367815999,1665367815999轉換為時間:2022-10-10 10:10:15,2022-10-10 10:10:15等於視窗結束時間,所以觸發視窗閉合。
控制輸出:使用者a 在視窗2022-10-10 10:10:10.0~2022-10-10 10:10:15.0中的pv次數是:1

命令列

[root@master ~]# nc -lk 9999
a 1665367810000
a 1665367821000

idea

當前視窗的結束值:1665367815999
當前水位線的值:1665367815999
使用者a 在視窗2022-10-10 10:10:10.0~2022-10-10 10:10:15.0中的pv次數是:1

如果根據"處理時間"來進行統計分析,視窗要閉合進行統計,肯定有一個參考的時間,只是這個時間是cpu幫忙產生的,視窗的閉合根據cpu產生的時間進行閉合,但邏輯時鐘的某瞬間的值是程式計算出來的,這也是為什麼把水位線稱為邏輯時鐘。

6、遲到資料的處理

6.1、什麼叫遲到資料
事件時間小於水位線當前時間戳,比如當前資料流的資料xxx攜帶的事件時間是2022:20:50,邏輯時鐘的此時的時間為2022:20:51,那麼flink認為xxx就是一條遲到資料。

案例描述:手動傳送水位線,手動傳送攜帶事件時間的元素。

public class ExampleTest5 {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<String> result = env
                .addSource(new SourceFunction<String>() {
                    @Override
                    public void run(SourceContext<String> ctx) throws Exception {
                        // 傳送資料攜帶事件時間的資料hello world
                        ctx.collectWithTimestamp("hello world", 1000L);
                        // 傳送水位線
                        ctx.emitWatermark(new Watermark(999L));
                        // 傳送資料攜帶事件時間的資料 hello flink
                        ctx.collectWithTimestamp("hello flink", 2000L);
                        // 傳送水位線
                        ctx.emitWatermark(new Watermark(1999L));
                        // 傳送資料攜帶事件時間的資料hello late
                        ctx.collectWithTimestamp("hello late", 1000L);
                    }

                    @Override
                    public void cancel() {

                    }
                })
                .process(new ProcessFunction<String, String>() {
                    @Override
                    public void processElement(String value, Context ctx, Collector<String> out) throws Exception {
                        //System.out.println("當前水位線:" + ctx.timerService().currentWatermark());

                        //判斷事件時間是否小於水位線
                        if (ctx.timestamp() < ctx.timerService().currentWatermark()) {
                            System.out.println("遲到元素:" + value);
                        } else {
                            System.out.println("正常元素:" + value);
                        }
                    }
                });

        env.execute();
    }
}

控制檯輸出:
正常元素:hello world
正常元素:hello flink
遲到元素:hello late

6.2、遲到元素的處理
理解了什麼叫遲到元素,至於怎麼處理,flink提供了幾種方案,如

案例:遲到資料傳送到"側輸出流"中

public class ExampleTest {

    // 定義側輸出流
    private static OutputTag<String> lateElement = new OutputTag<String>("late-element") {
    };

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        
        SingleOutputStreamOperator<String> result = env
                .addSource(new SourceFunction<String>() {
                    @Override
                    public void run(SourceContext<String> ctx) throws Exception {
                        // 傳送資料攜帶事件時間的資料hello world
                        ctx.collectWithTimestamp("hello world", 1000L);
                        // 傳送水位線
                        ctx.emitWatermark(new Watermark(999L));
                        // 傳送資料攜帶事件時間的資料 hello flink
                        ctx.collectWithTimestamp("hello flink", 2000L);
                        // 傳送水位線
                        ctx.emitWatermark(new Watermark(1999L));
                        // 傳送資料攜帶事件時間的資料hello late
                        ctx.collectWithTimestamp("hello late", 1000L);
                    }

                    @Override
                    public void cancel() {

                    }
                })
                .process(new ProcessFunction<String, String>() {
                    @Override
                    public void processElement(String value, Context ctx, Collector<String> out) throws Exception {
                        //判斷事件時間是否小於水位線
                        if (ctx.timestamp() < ctx.timerService().currentWatermark()) {
                            ctx.output(lateElement, "遲到元素髮送到側輸出流:" + value);
                        } else {
                            out.collect("正常到達的元素:" + value);
                        }
                    }
                });
        result.print("主流:");
        result.getSideOutput(lateElement).print("側輸出流:");

        env.execute();
    }
}

idea控制檯輸出:
主流:> 正常到達的元素:hello world
主流:> 正常到達的元素:hello flink
側輸出流:> 遲到元素髮送到側輸出流:hello late

思考:視窗,遲到元素,水位線之間有什麼關聯?

7、總結

水位線類似生活中的時鐘,通過時鐘我們知道當前時間處於幾點幾分秒,這個"當前時間"在flink裡面對應一個時間戳,通過時間戳來觸發視窗的閉合,觸發定時任務的執行。也類似一個參照物的角色。

相關文章