问题概述
在工作中遇到这个问题,处理了几天,尝试了几种解决方案,现分享一版可行的方法。
需求:从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滑动窗口的本质
上述问题的本质是什么呢?让我们回到滑动窗口的本质:
看图太抽象,让我来以一个例子加以解释:
还是假设一个大小为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过期时间,就满足了业务侧对于延迟的需求。 滑动步长越小,则延迟越低,但相应地计算开销也系统压力也会增大。