详解Flink滑动窗口的本质,以及解决数据断流导致水位线不增长的问题。

问题概述

在工作中遇到这个问题,处理了几天,尝试了几种解决方案,现分享一版可行的方法。

需求:从kafka读取用户行为日志,经过flink计算后向redis写入数据,使得业务侧可以查询到每个uid在过去1小时内的访问次数,做到秒级延迟。

 

需求分析:

这个场景很自然地想到使用flink的滑动窗口去解决问题:

由于需要统计过去1小时的数据,因此窗口大小设定为1小时。秒级延迟,这里以5秒的滑动步长为例。

SELECT 
    uid, 
    COUNT(url) AS cnt 
FROM 
    TABLE(
        HOP(
            TABLE EventTable, 
            DESCRIPTOR(ts), 
            INTERVAL '5' SECOND, 
            INTERVAL '60' MINUTE
        )
    ) 
GROUP BY 
    uid, 
    window_start, 
    window_end

 

这种做法的问题:

让我们假设一个大小为10mins,步长为1min的滑动窗口。

假设当前实际时间为18:00。在17:00:01时,最后一条数据到达并将水位线推进至17:00:01,此后不再有新的数据到来。因此,Flink在17:00:01时关闭了最后一个窗口,即16:50-17:00的窗口,并将统计数据存储在Redis中。这些数据在Redis中一直保留到18:00都可以被查询到。

然而,业务侧在18:00时实际期望看到的是17:50-18:00窗口的数据。例如,用户A在16:55访问了一次网站,用户B在17:00:01访问了一次网站。由于水位线被推进至17:00:01,[16:50,17:00]这个窗口被关闭,Redis中存储了A:1这样的数据。此后,再也没有新的数据进入,因此Redis中的数据保持不变,直到18:00仍然可以查询到A:1。

有的小伙伴可能会建议给Redis中的数据设置10分钟的过期时间。然而,这种方法并不可行,因为17:00:01写入redis的数据会在17:10:01过期,而16:55的用户A的数据实际上应该在17:05就过期。

 

Flink滑动窗口的本质

上述问题的本质是什么呢?让我们回到滑动窗口的本质:

455e43a54cdd4d509866ce3acbc24d0b.png

看图太抽象,让我来以一个例子加以解释:

还是假设一个大小为10mins,步长为1min的滑动窗口。

假若此时来了一条16:55的数据,将会发生什么呢?

此时会产生10个窗口:

[16:45:16:55]

[16:46:16:56]

[16:47:16:57]

...

[16:55:17:05]

然后这条16:55的数据将会被放入这10个窗口中,并等待窗口关闭。

那么窗口关闭的条件是什么呢?是当水位线超过当前窗口的上限。

因此,在刚才的例子中,当17:00:01到来的数据把水位线推进至17:00:01,实际上关闭的[16:50,17:00]的窗口只是这10个窗口中的一个。也就是说,如果17:01:01再来一条数据,就会关闭[16:01,17:01]的窗口,而这个窗口中就、依然含有16:55的数据!

理解了这一点,解决需求就有如下思路了:把redis的过期时间设置为窗口步长,也就是1分钟。

 

解决方案

17:00:01向redis写入的那条16:55的数据将会在17:01:01(现实时间)过期,而当水位线推进至17:01:01时,又会由于关闭了[16:01,17:01]这个窗口把这条数据重新写入redis(A:1)。这个过程会一直持续到17:05:01关闭最后一个时间窗口[16:55:17:05]的时候,此时在17:06(现实时间)之前,都可以查询到16:55这条数据。因此我们似乎就在1分钟延迟的基础之上满足了需求。

 

但是这样还不够,因为还没有解决数据断流的问题:

假设17:00:01来了一条数据,把水位线推至17:00:01并因此关闭了[16:01,17:01],计算所得结果(A:1)写入redis,之后便再无数据到来,此时就会出现:

17:01:01时,redis中的A:1过期了。由于再无新数据到来,水位线便停留在17:00:01,因此不会再有任何窗口被关闭。

这就尴尬了呀,我16:55的数据明明要到17:05才过期,但这17:01:01就过期了,就完全不满足业务的需求了。

解决方法:

这个问题的核心在于数据断流,导致事件时间与现实时间不一致。细品一下,在flink中,我们以事件时间来定义“时间”,但是在业务上,我们是以现实时间来定义“时间”的呀。

也就是说,对Flink而言,他对时间的感知来自于流经它的数据,比如我处理过的最大的时间戳是17:01:01,那么我就认为目前是17:01:01,就算你现实世界的时间流逝到100年之后,也跟我Flink没有任何关系;而业务上时间的感知来自于现实时间,简单来说就是业务人员并不关心flink中处理到什么时间的数据了,他们只知道当前是北京时间xx:xx:xx,那么我要查看的时间区间就是[当前时间-10分钟, 当前时间]。

所以如果能够把flink的时间和现实时间统一定义,问题也就迎刃而解了。

首先声明,Flink作为一种流式处理的计算框架,它的设计初衷就是假设数据是源源不断地流入的,如果你能保证你的数据流不会断,那么只需要正常地使用滑动窗口即可。我这里所做的处理只是为了满足业务侧的严谨需求。

 

Flink对于时间的流逝来自于水位线的升高,因此我们可以通过自定义生成水位线的方式,来控制水位线的升高,以达到统一事件时间与现实时间的目的。在Flink提供的Streaming API中做如下处理:

1. 首先在.assignTimestampsAndWatermarks()方法处,使用自定义的水位线生成策略。

2. 具体做法是实现WatermarkStrategy这个接口。然后重写接口中createWatermarkGenerator这个方法,它返回一个WatermarkGenerator类。

3. 重写这个类中的onPeriodicEmit方法,它可以周期性地发射水位线,比如说每5秒发射一次水位线。我们只需要让它发射当前系统时间,就实现了统一事件时间和现实时间的目的。

public class CustomWatermark {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.getConfig().setAutoWatermarkInterval(5000L);
;
// 读取数据源,并分配时间戳、生成水位线
        SingleOutputStreamOperator<Event> eventStream = env
                .addSource(new ClickSource())
                .assignTimestampsAndWatermarks(new CustomWatermarkStrategy());

        eventStream.print("test");

// 创建表环境
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

// 将数据流转换成表,并指定时间属性
        Table eventTable = tableEnv.fromDataStream(
                eventStream,
                $("user"),
                $("url"),
                $("timestamp").rowtime().as("ts")
        );
// 为方便在 SQL 中引用,在环境中注册表 EventTable
        tableEnv.createTemporaryView("EventTable", eventTable);

// 设置累积窗口,执行 SQL 统计查询
        Table result = tableEnv
                .sqlQuery(
                        "SELECT " +
                                "user, " +
                                "window_end AS endT, " +
                                "COUNT(url) AS cnt " +
                                "FROM TABLE( " +
                                "HOP( TABLE EventTable, " + // 定义累积窗口
                                "DESCRIPTOR(ts), " +
                                "INTERVAL '5' SECOND, " +
                                "INTERVAL '60' MINUTE)) " +
                                "GROUP BY user, window_start, window_end "
                );
        
        tableEnv.toDataStream(result).print();
        env.execute();
    }

    public static class CustomWatermarkStrategy implements WatermarkStrategy<Event> {

        @Override
        public TimestampAssigner<Event> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
            return new SerializableTimestampAssigner<Event>() {
                @Override
                public long extractTimestamp(Event element, long
                        recordTimestamp) {
                    // 实现你从消息中提取时间戳的逻辑
                    return element.timestamp;
                }
            };
        }

        @Override
        public WatermarkGenerator<Event>
        createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
            return new CustomPeriodicGenerator();
        }

    }

    public static class CustomPeriodicGenerator implements
            WatermarkGenerator<Event> {
        private Long delayTime = 0L; // 延迟时间
        private Long maxTs = Long.MIN_VALUE + delayTime + 1L; // 观察到的最大时间戳

        @Override
        public void onEvent(Event event, long eventTimestamp, WatermarkOutput
                output) {
            // 每来一条数据就调用一次
            maxTs = Math.max(event.timestamp, maxTs); // 更新最大时间戳
            System.out.println("事件性发射水位线");
        }

        @Override
        public void onPeriodicEmit(WatermarkOutput output) {
            // 发射水位线,每5s 调用一次
            //maxTs = maxTs + 4999L;
            output.emitWatermark(new Watermark(System.currentTimeMillis() - delayTime));
            System.out.println(System.currentTimeMillis() - delayTime);
            System.out.println("周期性发射水位线");
        }
    }
}

 

经过这样的处理,Flink就会根据系统时间去关闭窗口,因此就解决了数据断流的问题。同时我们以滑动步作为为redis过期时间,就满足了业务侧对于延迟的需求。 滑动步长越小,则延迟越低,但相应地计算开销也系统压力也会增大。

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值