• flink 1.11.2 学习笔记(5)-处理消息延时/乱序的三种机制


    在实时数据处理的场景中,数据的到达延时或乱序是经常遇到的问题,比如:

    * 按时间顺序发生的数据1 -> 2,本来应该是1先发送,1先到达,但是在1发送过程中,因为网络延时之类的原因,导致1反而到达晚了,变成2先到达,也就造成所谓的接收乱序;

    * 发送方本身就延时了,比如:事实上按1 -> 2产生的数据 ,发送方如果是多线程发送数据,可能造成2先发,1后发,中间网络传输就算没有延时,也会导致接收到时已经乱序;

    * 有一些比如本来是19:59:59发生的业务数据,由于一些中间环节耗时(比如:最长可能需要5秒),到了发送的时候,已经是20:00:04了,但是在处理时,又希望这条数据能算到上1个小时的统计窗口里(即:数据虽然晚到了,已经错过了上1个时间窗口的计算时机,但是不希望被扔掉)

    flink做为一个流批一体的框架,自然也考虑到这个问题,它提供了3种机制来应对,还是以最经典的wordcount为例,先定义WordCount类:

    package com.cnblogs.yjmyzz.flink.demo;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    import java.util.Date;
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class WordCount {
    
        private String word;
    
        private Date eventDateTime;
    
    }
    

    为了后面json序列化方便,定义一个Gson工具类(可参考)

    package com.cnblogs.yjmyzz.flink.demo;
    
    import com.google.gson.Gson;
    import com.google.gson.GsonBuilder;
    import com.google.gson.JsonDeserializer;
    import com.google.gson.JsonPrimitive;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    /**
     * @author jimmy
     */
    public enum GsonUtils {
    
        INSTANCE;
    
        private static Gson gson;
    
        public Gson gson() {
            if (gson != null) {
                return gson;
            }
            String dateFormatWithMS = "yyyy-MM-dd HH:mm:ss.SSS";
            String dateFormatNoMS = "yyyy-MM-dd HH:mm:ss";
            GsonBuilder builder = new GsonBuilder();
            builder.registerTypeAdapter(Date.class, (JsonDeserializer<Date>) (json, typeOfT, context) -> {
                if (json == null || json.toString().equalsIgnoreCase("""")) {
                    //空字符判断
                    return null;
                }
                JsonPrimitive jsonPrimitive = json.getAsJsonPrimitive();
                SimpleDateFormat sdfMS = new SimpleDateFormat(dateFormatWithMS);
                SimpleDateFormat sdfNoMS = new SimpleDateFormat(dateFormatNoMS);
                Date dt = null;
                try {
                    if (jsonPrimitive.isString()) {
                        if (jsonPrimitive.getAsString().length() == 19) {
                            //这里只是示例,简单用长度来判断是哪种格式
                            //yyyy-MM-dd HH:mm:ss格式
                            dt = sdfNoMS.parse(json.getAsString());
                        } else {
                            //yyyy-MM-dd HH:mm:ss.SSS格式
                            dt = sdfMS.parse(json.getAsString());
                        }
                    } else if (jsonPrimitive.isNumber()) { //兼容timestamp类型
                        dt = new Date(jsonPrimitive.getAsLong());
                    }
                } catch (Exception e) {
                    //错误日志记录,略
                    e.printStackTrace();
                }
                return dt;
            });
            gson = builder
                    .setDateFormat(dateFormatWithMS)
                    .setPrettyPrinting()
                    .create();
            return gson;
        }
    }
    

    开始flink处理,我们的场景是先启动一个nc模拟网络服务端发送数据,然后flink实时接收,然后按1分钟做为时间窗口,统计窗口内收到的word个数。

    发送的数据格式类似:

    {"word":"hello","eventDateTime":"2021-05-09 22:01:10.000"}
    

    代码如下:

    package com.cnblogs.yjmyzz.flink.demo;
    
    import org.apache.flink.api.common.functions.FlatMapFunction;
    import org.apache.flink.api.common.functions.MapFunction;
    import org.apache.flink.api.common.typeinfo.TypeInformation;
    import org.apache.flink.api.java.tuple.Tuple3;
    import org.apache.flink.api.java.typeutils.TupleTypeInfo;
    import org.apache.flink.streaming.api.TimeCharacteristic;
    import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
    import org.apache.flink.streaming.api.windowing.time.Time;
    import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    public class WorkCountSample {
    
        public static void main(String[] args) throws Exception {
    
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    
            // 1 设置环境
            final StreamExecutionEnvironment env = StreamExecutionEnvironment
                    .getExecutionEnvironment()
                    .setParallelism(1);
    
            //指定使用eventTime作为时间标准
            env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    
            SingleOutputStreamOperator<Tuple3<String, Integer, String>> count = env
                    .socketTextStream("127.0.0.1", 9999)
                    .map((MapFunction<String, WordCount>) value -> {
                        //将接收到的json转换成WordCount对象
                        WordCount wordCount = GsonUtils.INSTANCE.gson().fromJson(value, WordCount.class);
                        return wordCount;
                    })
                    //这里先不指定任何水印延时
                    .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<WordCount>(Time.milliseconds(0)) {
                        @Override
                        public long extractTimestamp(WordCount element) {
                            //指定事件时间的字段
                            return element.getEventDateTime().getTime();
                        }
                    })
                    .flatMap((FlatMapFunction<WordCount, Tuple3<String, Integer, String>>) (value, out) -> {
                        String word = value.getWord();
                        //辅助输出窗口信息,方便调试
                        String windowTime = sdf.format(new Date(TimeWindow.getWindowStartWithOffset(value.getEventDateTime().getTime(), 0, 60 * 1000)));
                        if (word != null && word.trim().length() > 0) {
                            out.collect(new Tuple3<>(word.trim(), 1, windowTime));
                        }
                    })
                    .returns(((TypeInformation) TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class, String.class)))
                    .keyBy(0)
                    //按每分钟开窗
                    .timeWindow(Time.minutes(1))
                    .sum(1);
    
    
            count.print();
    
            env.execute("wordCount");
    
        }
    }
    

    来测试一下,先启用1个网络服务端,mac或linux上,终端输入 nc -l 9999,再启用上面的flink程序,在终端依次输入下面3条json(即:模拟发送了3条数据)

    {"word":"hello","eventDateTime":"2021-05-09 22:01:10.000"}
    {"word":"hello","eventDateTime":"2021-05-09 22:01:00.999"}
    {"word":"hello","eventDateTime":"2021-05-09 22:02:00.000"}

    可以看到,在输入到第3条时,因为事件时间已经到了第2分钟,所以上1分钟的窗口被关闭,触发了计算,输出了hello:2,符合预期。

    注意一下:第1条与第2条的事件时间,正好的是反的,第1条是22:01:10,而第2条是更早的22:01:00,也就是乱序,但是仍然都正确的统计在了22:01:00这个1分钟的窗口里。所以按时间开窗的场景,flink天然就能兼容一些乱序情况。

    如果是延时问题,比如希望延时1秒才开始触发上1个时间窗口的计算,即: 22:02.00.999 的事件时间数据到达时,才开始计算22:01:00 开始的这个1分钟窗口(相当于多等1秒),可以调整第40行代码,即所谓的水印WaterMark机制

    一、Watermark延时设置

    .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<WordCount>(Time.milliseconds(1000))

    很简单对吧?只要把40行这里的0,调整成1000,也就是延时1000ms触发计算,注:这是一个左闭右开的区间,即[0,1000)的延时范围都是允许的。

    再测试一下:

     可以看到,当输入第3条数据时,虽然已经是22:02:00.000,到了第2分钟,但是并没有触发前1个时间窗口的计算输出,而是在第4条数据输入,也就是22:02.00.999时才触发22:01 窗口的计算,以此之后,哪怕再有01分窗口的数据上报,将被扔掉。

    二、时间窗口延时设置

    在刚才示例中,如果某个窗口计算过了(也就是窗口关闭了),后面哪怕还有该窗口内的数据上报,默认也会被丢失。这好比:公司组织团建,约好第2天早上8点发车(即:时间窗口的截止时间为8点),然后考虑到可能有人会迟到(即: 数据延时上报),会让司机多等5分钟(即:watermark的延时),但是过了08:05,如果还有人没来,就不管了,这个好象有点不厚道。怎么办?通常公司会说,现在我们先点下人数(即:窗口先计算1次),如果还有人没到,我们最后再多等10分钟(即:这10分钟内,如果还有人再来,每来1个人,再清点1次,看看人有没有到齐,如果到了08:15还没到齐,就只能发车了,不让让全公司的人等个别懒虫)。

    这就是flink的第2种处理延时机制,窗口延时计算,只要加一行allowLateness就好。

    .timeWindow(Time.minutes(1))
    .allowedLateness(Time.seconds(10))

    观察上面的运行结果 ,第3次输入时,触发了窗口的第1次计算,紧接着第4条输入,仍然是01分窗口的数据(相当于迟到赶来的人),又触发了1次计算,但是到了第5条,也就是第1个黄色箭头的数据到达时,已经到了最后截止时间,窗口彻底关闭(即:发车了),后面再有数据过来,也不管了。

    三、迟到数据的侧输出流

    还是以上面的公司团建发车为例,如果有些人真的有事情,来不及,但是又想去团建怎么办?(即:肯定是迟到了,但是数据不能丢)一般的做法,我们是让他自行打车,单独前往。这在Flink里,叫做所谓“侧输出流”,把迟到的数据单独放在一个Stream里收集起来,然后单独处理。

    package com.cnblogs.yjmyzz.flink.demo;
    
    import org.apache.flink.api.common.functions.FlatMapFunction;
    import org.apache.flink.api.common.functions.MapFunction;
    import org.apache.flink.api.common.typeinfo.TypeInformation;
    import org.apache.flink.api.java.tuple.Tuple3;
    import org.apache.flink.api.java.typeutils.TupleTypeInfo;
    import org.apache.flink.streaming.api.TimeCharacteristic;
    import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
    import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
    import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
    import org.apache.flink.streaming.api.windowing.time.Time;
    import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
    import org.apache.flink.util.OutputTag;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    public class WorkCountSample {
    
        public static void main(String[] args) throws Exception {
    
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    
            // 1 设置环境
            final StreamExecutionEnvironment env = StreamExecutionEnvironment
                    .getExecutionEnvironment()
                    .setParallelism(1);
    
            //指定使用eventTime作为时间标准
            env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    
            OutputTag<Tuple3<String, Integer, String>> lateTag = new OutputTag<>("late", ((TypeInformation) TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class, String.class)));
    
            SingleOutputStreamOperator<Tuple3<String, Integer, String>> count = env
                    .socketTextStream("127.0.0.1", 9999)
                    .map((MapFunction<String, WordCount>) value -> {
                        //将接收到的json转换成WordCount对象
                        WordCount wordCount = GsonUtils.INSTANCE.gson().fromJson(value, WordCount.class);
                        return wordCount;
                    })
                    //这里先不指定任何水印延时
                    .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<WordCount>(Time.milliseconds(1000)) {
                        @Override
                        public long extractTimestamp(WordCount element) {
                            //指定事件时间的字段
                            return element.getEventDateTime().getTime();
                        }
                    })
                    .flatMap((FlatMapFunction<WordCount, Tuple3<String, Integer, String>>) (value, out) -> {
                        String word = value.getWord();
                        //辅助输出窗口信息,方便调试
                        String windowTime = sdf.format(new Date(TimeWindow.getWindowStartWithOffset(value.getEventDateTime().getTime(), 0, 60 * 1000)));
                        if (word != null && word.trim().length() > 0) {
                            out.collect(new Tuple3<>(word.trim(), 1, windowTime));
                        }
                    })
                    .returns(((TypeInformation) TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class, String.class)))
                    .keyBy(0)
                    //按每分钟开窗
                    .timeWindow(Time.minutes(1))
                    .allowedLateness(Time.seconds(10))
                    //定义迟到数据的侧输出流
                    .sideOutputLateData(lateTag)
                    .sum(1);
    
    
            count.print();
    
            //迟到的数据,这时只是简单的打印出来
            count.getSideOutput(lateTag).print();
    
            env.execute("wordCount");
    
        }
    }
    

    33行,先定义一个OutputTag

    64行,通过sideOutputLateData(lateTag)指定侧输出流,将迟到的数据收集于此

    71行,将收集到的测输出流,打印出来(实际业务中,可以存到mysql等一些存储体系中)

    运行效果:

    注:

    右侧倒数第2条{"word":"hello","eventDateTime":"2021-05-09 22:02:10.999"}发送完毕后,01分的窗口已关闭。

    再发送最后1条{"word":"world","eventDateTime":"2021-05-09 22:01:10.000"}时,这条就是迟到数据了,从左侧输出来看,已正确输出,被侧输出流处理了。

    小结一下:

    1、Watermark水印在窗口计算触发前延时;

    2、allowedLateness则是只要窗口计算时机被触发了,把现有数据先算一把,后面如果还有该窗口的数据过来,可以继续再算(前提是在允许的延时阈值范围内)

    3、如果上述2种延时都满足不了,在窗口彻底关闭了后,还有迟到数据进来,可以放到侧输出流,单独处理。

    作者:菩提树下的杨过
    出处:http://yjmyzz.cnblogs.com
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。
  • 相关阅读:
    oralce 11g data guard
    oracle的锁与并发机制
    10 个MySQL数据库备份教程推荐
    Linux环境下用exp备份Oracle数据表并导入的脚本
    使用Oracle 10g的Logminer挖掘日志
    ORACLE查询表最近更改的数据
    如何监控oracle的索引是否使用
    看 淡 一切 生 命 只 是 个 过 程
    Java数组声明、创建、初始化
    Linux建立FTP的方法
  • 原文地址:https://www.cnblogs.com/yjmyzz/p/flink-watermark-allowedLateness-sideOutputLateData.html
Copyright © 2020-2023  润新知