Flink窗口在IoT场景实时分析中的应用

背景

在IoT场景中,设备上报的感知数据,会先存储在消息队列中,比如:Kafka、Pulsar中。然后基于消息中间件做数据转换处理落盘,或者进行实时分析。今天我们说的流式分析场景,就是基于此技术架构。业务上想统计每个设备最近五分钟上报温度的平均值。

需求分析

如果是离线分析,所有数据落盘,入到具体的OLAP数据库,这样的需求是比较简单的,一个SQL就搞定了。若对实时性要求高,只能借助于流式计算的技术。现以Flink为例,先来简单说下涉及到的一些概念:

事件时间和处理时间(Event Time and Processing Time)

所谓的事件时间Event Time,就是指的本身的数据时间。以上面的架构为准,其时间一般取Kafka消息队列接收数据时的时间,就是receive_time。这里可能大家会有个疑问,为何不取终端设备上报时间呢。如果说你能保证此时间是准确的,那是最好的。但是一般来说对于物联网设备,尤其是廉价的设备,其时钟同步机制是不健全的,无法有效保证数据时间的准确性,这个时间我们称之为:data_time。Processing Time指的是系统的处理时间,就是Flink集群内的系统时间,接收到消息后,Flink会按当前机器的时间来进行开窗。

一般来说,使用系统处理时间的场景是非常少的。绝大多所数使用的是事件时间,也就是Event Time。这样比较贴切事实。试想有这样的场景,kafka机器一直正常,Flink中的任务由于异常执行失败,或者说客户认为的把聚合任务关停。那么如果再次恢复,使用Processing Time进行计算,就会存在将历史数据当成最新数据来计算,这样是不合业务逻辑的。所以我们应该使用Event Time来进行聚合操作。此时其实就是离线分析的典型场景了,把kafka当成离线存储。无论是离线分析还是实时分析,其实都会面临同样的业务问题,只是解决的方式方法不同而已。

水位线(Watermark)

水位线作用是重新明确数据的计算标识,以用于窗口计算,一般是时间类型的字段。举个简单的例子,我们将接受时间定义为数据时间,也就是data_time等同于receive_time,在我们使用Flink进行窗口计算的时候,熟悉离线分析的同学肯定会知道,以小时报为例,12点10分统计的小时报,按T+1的规则其统计的数据范围即上个小时的数据,即:11:00:00到12:00:00,这其实就是一小时的窗口,水位线就相当于你过滤数据的那个字段,一般就是data_time。

关于窗口

在Flink中支持多种窗口类型,分别为:滚动窗口(Tumbling Windows)、滑动窗口(Sliding Windows) 、滑动窗口(Sliding Windows)、滑动窗口(Sliding Windows)。具体的概念可以看下Flink的官网介绍,这里主要介绍下滚动窗口、滑动窗口。针对事件时间和处理时间相应的会有:TumblingEventTimeWindows、SlidingEventTimeWindows、TumblingProcessingTimeWindows、SlidingProcessingTimeWindows之分。我们在使用过程中遇到的一些问题。来看一下官网的关于窗口生命周期的一些说明:

简单来说,一个窗口在第一个属于它的元素到达时就会被创建,然后在时间(event 或 processing time) 超过窗口的"结束时间戳 + 用户定义的 allowed lateness (详见 [Allowed Lateness](https://nightlies.apache.org/flink/flink-docs-master/zh/docs/dev/datastream/operators/windows/#allowed-lateness))"时 被完全删除。Flink 仅保证删除基于时间的窗口,其他类型的窗口不做保证, 比如全局窗口(详见 [Window Assigners](https://nightlies.apache.org/flink/flink-docs-master/zh/docs/dev/datastream/operators/windows/#window-assigners))。 例如,对于一个基于 event time 且范围互不重合(滚动)的窗口策略, 如果窗口设置的时长为五分钟、可容忍的迟到时间(allowed lateness)为 1 分钟, 那么第一个元素落入 12:00 至 12:05 这个区间时,Flink 就会为这个区间创建一个新的窗口。 当 watermark 越过 12:06 时,这个窗口将被摧毁。

官方的介绍比较清晰的说明的窗口的生命周期,今天结合实际的应用场景,来深入分析下窗口。TumblingEventTimeWindows、SlidingEventTimeWindows他们默认的Trigger都是EventTimeTrigger,都是基于Event Time来进行窗口计算的。两者都有个共同的问题:假如某一时刻数据的源头不再发送数据了,那么最后一个窗口存在关闭不了的问题。可以通过自定义周期性水位生成器来解决。

周期性水位生成器

我是根据code-cookbook.readthedocs.io/zh-cn/main/...

的描述,来创建自己的周期水位生成器。其间有几处需要特别注意,先看代码:

JAVA 复制代码
**
 * A watermark generator for generating watermarks. This class is modified from {@link BoundedOutOfOrdernessWatermarks}.
 */
public class BoundedOutOfOrdernessWatermarksOnEventTime<T> implements WatermarkGenerator<T> {
    /**
     * The maximum timestamp encountered so far.
     */
    private long maxTimestamp;

    /**
     * The maximum out-of-orderness that this watermark generator assumes.
     */
    //允许延时的时间,针对乱序、迟到数据场景
    private final long outOfOrdernessMillis;

    /**
     * Processing time of last event
     */
    private long lastEventTimestamp;

    /**
     * Time to emit watermark if no event comes for a long time.
     * The goal is to trigger the computation of the window even when no record is coming.
     */
    //窗口超时时间
    private final Duration waitTimeInMillsToEmitWatermark;

    /**
     * watermark generator interval
     */
    private final long autoWatermarkInterval;

    private long currentLogicalEventTimeMills;

    /**
     * Creates a new watermark generator with the given out-of-orderness bound.
     *
     * @param maxOutOfOrderness              The bound for the out-of-orderness of the event timestamps.
     * @param waitTimeInMillsToEmitWatermark A time allow flink to wait for in case of that no next element arrives for a long time.
     *                                       When the waiting time is up and no next element arrives, the watermark will still be generated and emitted
     * @param autoWatermarkInterval          watermark generator interval
     */
    public BoundedOutOfOrdernessWatermarksOnEventTime(Duration maxOutOfOrderness, Duration waitTimeInMillsToEmitWatermark, long autoWatermarkInterval) {
        this.waitTimeInMillsToEmitWatermark = waitTimeInMillsToEmitWatermark;
        this.autoWatermarkInterval = autoWatermarkInterval;
        Preconditions.checkNotNull(maxOutOfOrderness, "maxOutOfOrderness");
        Preconditions.checkArgument(!maxOutOfOrderness.isNegative(), "maxOutOfOrderness cannot be negative");

        this.outOfOrdernessMillis = maxOutOfOrderness.toMillis();

        // start so that our lowest watermark would be Long.MIN_VALUE.
        this.maxTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
        this.lastEventTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
        this.currentLogicalEventTimeMills = Long.MIN_VALUE + outOfOrdernessMillis + 1;
    }

    public BoundedOutOfOrdernessWatermarksOnEventTime(Duration maxOutOfOrderness, Duration waitTimeInMillsToEmitWatermark) {
        this(maxOutOfOrderness, waitTimeInMillsToEmitWatermark, 200L);
    }

    /**
     * Flink will call this method when events arrive for each record.
     *
     * @param event          element in stream
     * @param eventTimestamp the time an event happened
     * @param output         An output for watermarks. The output accepts watermarks and idleness (inactivity) status
     */
    @Override
    public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
        maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
        lastEventTimestamp = maxTimestamp;
        currentLogicalEventTimeMills = maxTimestamp;
    }

    /**
     * Flink will call this method once in a while, the interval is defined by {@code ExecutionConfig.setAutoWatermarkInterval(...)},
     * for example:
     * <pre>{@code env.getConfig().setAutoWatermarkInterval(400L);}
     * </pre>
     * <p>
     * If flink wait enough time, and still not get next record, so we need to emit a new watermark triggering a computation of last window.
     * If we don't do that, the computation of last window will never be triggered.
     * It is important when we want to get some status value from the last event.
     *
     * @param output An output for watermarks. The output accepts watermarks and idleness (inactivity) status
     */
    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        currentLogicalEventTimeMills = currentLogicalEventTimeMills + autoWatermarkInterval;
        if ((currentLogicalEventTimeMills - lastEventTimestamp) > waitTimeInMillsToEmitWatermark.toMillis()) {
            output.emitWatermark(new Watermark(currentLogicalEventTimeMills - outOfOrdernessMillis - 1));
            maxTimestamp = currentLogicalEventTimeMills;
        } else {
            output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
        }
    }
}

Main方法

JAVA 复制代码
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        KafkaSource<String> source = KafkaSource.<String>builder()
                .setBootstrapServers(ConfigUtils.getString("bootstrap.servers"))
                .setTopics(ConfigUtils.getString("temperature.sensor.topic"))
                .setGroupId("temperatureSensor.g1")
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();
        DataStream<String> kafkaDataStream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source");

        SingleOutputStreamOperator<TemperatureSensor> sourceStream = kafkaDataStream
                .map(new MapFunction<String, TemperatureSensor>() {
                    private static final long serialVersionUID = 1L;
                    @Override
                    public TemperatureSensor map(String value) {
                        TemperatureSensor temperatureSensor = null;
                        try {
                            //{"productKey":"cu1tfzwc4whywunZ","deviceKey":"bw0nbqwXNsHCGtM","temperature":35.1,"dataTime":"2023-12-11 08:39:12","receiveTime":1702255153000}
                            final JSONObject jsonObject = JSONObject.parseObject(value);
                            temperatureSensor = new TemperatureSensor();
                            if (jsonObject != null) {
                                temperatureSensor.setProductKey(jsonObject.getString("productKey"));
                                temperatureSensor.setDeviceKey(jsonObject.getString("deviceKey"));
                                temperatureSensor.setTemperature(jsonObject.getDouble("temperature"));
                                temperatureSensor.setDataTime(jsonObject.getString("dataTime"));
                                temperatureSensor.setReceiveTime(jsonObject.getLong("receiveTime"));
                            }
                            System.out.println(">>>>>"+ temperatureSensor.getDeviceKey());
                        } catch (Exception e) {
                            System.out.println("FlinkWindowTest map error");
                        }
                        return temperatureSensor;
                    }
                });
        WatermarkStrategy<TemperatureSensor> temperatureSensorWatermarkStrategy = WatermarkStrategy
                //使用自定义水位线策略
                .<TemperatureSensor>forGenerator((WatermarkGeneratorSupplier<TemperatureSensor>) context -> new BoundedOutOfOrdernessWatermarksOnEventTime(Duration.ofSeconds(5), Duration.ofSeconds(15), 200L))
                .withIdleness(Duration.ofSeconds(60))
                //提取数据中的时间作为EventTime
                .withTimestampAssigner(new SerializableTimestampAssigner<TemperatureSensor>() {
                    @Override
                    public long extractTimestamp(TemperatureSensor element, long recordTimestamp) {
                        return element.getReceiveTime();
                    }
                });

        SingleOutputStreamOperator<String> process = sourceStream
                .assignTimestampsAndWatermarks(temperatureSensorWatermarkStrategy)
                //按产品设备,进行分组
                .keyBy(new KeySelector<TemperatureSensor, Tuple2<String,String>>() {
                    @Override
                    public Tuple2<String,String> getKey(TemperatureSensor userBehavior) throws Exception {
                        return userBehavior.getGroup();
                    }
                })
                //基于Event的滑动窗口设置
                .window(TumblingEventTimeWindows.of(Time.seconds(15)))
                .process(new ProcessWindowFunction<TemperatureSensor, String, Tuple2<String,String>, TimeWindow>() {
                    @Override
                    public void process(Tuple2<String, String> key, Context context, Iterable<TemperatureSensor> elements, Collector<String> out) throws Exception {
                        long startTs = context.window().getStart();
                        long endTs = context.window().getEnd();
                        System.out.println("process:maxTimestamp:"+context.window().maxTimestamp());
                        String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
                        String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");
                        long count = elements.spliterator().estimateSize();
                        out.collect("分组 " + key + " 的窗口在时间区间: " + windowStart + "-" + windowEnd + " 产生" + count + "条数据,具体数据:" + elements.toString());
                    }
                });
        process.print();
        env.execute();

注意点

1、自定义水位onPeriodicEmit方法

官方的解释如下:

Called periodically, and might emit a new watermark, or not.

The interval in which this method is called and Watermarks are generated depends on ExecutionConfig.getAutoWatermarkInterval()。

此方法是其由系统周期调用,默认是200ms一次,可以通过ExecutionConfig来配置此值。这块大家一定要注意在创建BoundedOutOfOrdernessWatermarksOnEventTime时候,其值需要与系统配置的值一致。如果过大,可能引起水位线升得过高,导致窗口不再触发。另外,如果使用了withIdleness来处理空闲source。如果确实存在了空闲源,那么onPeriodicEmit的实际调用次数就是idleTimeout与autoWatermarkInterval的比值,不会一直调用下去。

2、水位线的理解

窗口计算中,所有的计算还是基于窗口生命周期来的。结合业务场景水位线理解如下:

用户需求:统计10分钟内,每个设备的平均温度。

一个设备上报某一条消息:{"deviceId":"cuwksjakpglcnz3G:gRhEGpz7EvVH4HJ","data_time":"2023-12-14 15:28:00","temperature":30.5}。

此条消息,正常应该落在2023-12-14 15:20:00,2023-12-14 15:30:00时间区间内。

a)如果是此区间窗口未创建过,那么Flink会创建此窗口。如果此时水位已经超过了所设置的时间(包括延时),则直接触发窗口计算。如果没有,等待水位上升。持续不断的数据过来,或者通过周期水位生成器会引起水位的上升,直到上升到一定程度,触发窗口计算。

b)如果此区间窗口已经创建,未关闭。判断逻辑同a)。

c)如果此区间窗口已经关闭,那么这个区间数据再发送过来的时候,就会当成迟到数据,可以通过SideOutput来对迟到数据进行处理。其实,可离线分析的处理逻辑是类似的,在离线场景,一般会用第二次的统计来覆盖第一次。因为数据已经落盘,且通过SQL很容易处理迟到的数据,把其当成历史数据一部分即可。相反,实时分析的处理逻辑会复杂很多。

相关推荐
小珑也要变强3 小时前
队列基础概念
c语言·开发语言·数据结构·物联网
码爸9 小时前
flink doris批量sink
java·前端·flink
唯创知音10 小时前
电子烟智能化创新体验:WTK6900P语音交互芯片方案,融合频谱计算、精准语音识别与流畅音频播报
人工智能·单片机·物联网·音视频·智能家居·语音识别
武子康10 小时前
大数据-133 - ClickHouse 基础概述 全面了解
java·大数据·分布式·clickhouse·flink·spark
honey ball15 小时前
仪表放大器AD620
运维·单片机·嵌入式硬件·物联网·学习
luckyluckypolar16 小时前
STM32 -中断
stm32·单片机·嵌入式硬件·物联网
码爸16 小时前
flink 批量写clickhouse
java·clickhouse·flink
四格17 小时前
如何使用 Bittly 根据业务流程自动发送串口指令
物联网·嵌入式
码爸18 小时前
flink kafka sink (scala)
flink·kafka·scala
小的~~19 小时前
k8s使用本地docker私服启动自制的flink集群
docker·flink·kubernetes