在实际环境中,经常会出现,因为网络原因,数据有可能会延迟一会才到达Flink实时处理系统。
Flink 中的时间概念类型
在 Flink 的流式处理中,会涉及到时间的不同概念, 如下图所示:
对于流式数据处理,最大的特点是数据上具有时间的属性特征,Flink 根据时间产生的位置不同,将时间区分为三种时间概念,分别为事件生成时间(Event Time)、事件接入时间(Ingestion Time)和事件处理时间(Processing Time)。如图 4-7 所示,数据从终端产生,或者从系统中产生的过程中生成的时间为事件生成时间,当数据经过消息中间件传入到 Flink 系统中,在 DataSource 中接入的时候会生成事件接入时间,当数据在Flink 系统中通过各个算子实例执行转换操作的过程中,算子实例所在系统的时间为数据处理时间。Flink 已经支持这三种类型时间概念,用户能够根据需要选择时间类型作为对流式数据的依据,这种情况极大地增强了对事件数据处理的灵活性和准确性。
- Event Time: 是事件创建的时间。它通常由事件中的时间戳描述,例如采集的日志数据中,每一条日志都会记录自己的生成时间,Flink 通过时间戳分配器访问事件时间戳。
- Ingestion Time: 是数据进入 Flink 的时间。
- Processing Time: 是每一个执行基于时间操作的算子的本地系统时间,采用的是处理机器的时间,默认的时间属性就是 Processing Time。在分布式系统中,数据本身不乱序,但每台机器的时间如果不同步,也可能导致数据处理过程中数据乱序的问题,从而影响计算结果。总之,Processing Time 概念适用于时间计算精度要求不是特别高的计算场景,例如统计某些延时非常高的日志数据等。
时间概念指定
在 Flink 中默认情况下使用是 Process Time 时间概念,如果用户选择使用 Event Time或者Ingestion Time,需要在创建的StreamExecutionEnvironment 中调用setStreamTimeCharacteristic()方法设定系统的时间概念,如下代码使用TimeCharacteristic.EventTime 作为系统的时间概念,这样对当前的 StreamExecutionEnvironment 会全局生效。对应的,如果使用 Ingestion Time 概念,则通过传入 TimeCharacteristic.IngestionTime数指定。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
基于Processing Time 处理数据
基于我们处理数据机器的时间,而不关注数据产生的时间,进行数据切分。这样处理所产生的结果是不可预测的,因为在所处理的集合中存在不同event time 的message。
基于Event Time 处理数据
与基于Processing Time相比,基于Event Time 处理数据关心的是事件先后发生的一个关系,在处理过程中需要将数据进行一个相应的排序,然后才会做相应的统计工作。
对于业务来说,想要统计某个时间段的数据,真正的语义应该是统计发生在某个时间段发生的message,而非Flink服务处理的时间段。所以在日常开发中基于Event time 处理数据是一个硬性的要求。与基于Processing Time相比,基于Event Time 处理数据需要增加一些额外的配置,比如对于我们事件时间的一些抽取以及如何去保证数据在乱序的情况下能够达到我们计算结果的一致性保障。
原本应该被该窗口计算的数据,因为网络延迟等原因晚到,就有可能丢失了
示例一
假设,你正在去往地下停车场的路上,并且打算用手机点一份外卖。选好了外卖后,你就用在线支付功能付款了,这个时候是11点59分。恰好这时,你走进了地下停车库,而这里并没有手机信号。因此外卖的在线支付并没有立刻成功,而支付系统一直在Retry重试“支付”这个操作。
当你找到自己的车并且开出地下停车场的时候,已经是12点01分了。这个时候手机重新有了信号,手机上的支付数据成功发到了外卖在线支付系统,支付完成。
在上面这个场景中你可以看到:
支付数据的事件时间是11点59分,而支付数据的处理时间是12点01分
问题:
如果要统计12点之前的订单金额,那么这笔交易是否应被统计?
答案:
应该被统计,因为该数据的真真正正的产生时间为11点59分,即该数据的事件时间为11点59分,事件时间能够真正反映/代表事件的本质! 所以一般在实际开发中会以事件时间作为计算标准。
示例二
一条错误日志的内容为:
- 2020-11:11 22:59:00 error NullPointExcep --事件时间
- 进入Flink的时间为2020-11:11 23:00:00 --摄入时间
- 到达Window的时间为2020-11:11 23:00:10 --处理时间
问题:
对于业务来说,要统计1h内的故障日志个数,哪个时间是最有意义的?
答案:
EventTime事件时间,因为bug真真正正产生的时间就是事件时间,只有事件时间才能真正反映/代表事件的本质!
示例三
某App会记录用户的所有点击行为,并回传日志(在网络不好的情况下,先保存在本地,延 后回传)。A用户在 11:01:00 对App进行操作,B用户在11:02:00操作App,但是A用户的网络不太稳定,回传日志延迟,导致在服务端先接受到B用户的消息,再接受到A用户的消息,消息乱序。
问题:
如果这个是一个根据用户操作先后顺,进行抢购的业务,那么是A用户成功还是B用户成功?
答案:
应该算A成功,因为A确实比B操作的早,但是实际中考虑到实现难度,能直接按B成功算。也就是说,实际开发中希望基于事件时间来处理数据,但因为数据可能因为网络延迟等原因,出现了乱序,按照事件时间处理起来有难度!
实际开发中,希望基于事件时间来处理数据,但因为数据可能网络延迟等原因,出现了乱序或延迟到达,那么可能处理的结果不是想要的甚至出现数据丢失的情况,所以需要一种机制来解决一定程度上的数据乱序或延迟到的问题。
EventTime And Watermark
通常情况下由于网络或者系统等外部因素影响下,事件数据往往不能及时传输至Flink 系统中,系统的不稳定而造成数据乱序到达或者延迟到达等问题,因此,需要有一种机制能够控制数据处理的进度。
具体来讲,在创建一个基于事件时间的 Window后,需要确定属于该 Window 的数据元素是否已经全部到达,确定后才可以对 Window中的所有数据做计算处理(如汇总、分组等),如果数据并没有全部到达,则继续等待该窗口中的数据全部到达后再开始处理。在这种情况下就需要用到水位线(Watermarks)机制,它能够衡量数据处理进度(表达数据到达的完整性),保证事件数据全部到达 Flink系统,即使数据乱序或者延迟到达,也能够像预期一样计算出正确和连续的结果。
Flink会使用最新的事件时间减去固定时间间隔作为 Watermark,该时间间隔为用户外部配置的支持最大延迟到达的时间长度,也就是说不会有事件超过该间隔到达,否则就认为是迟到事件或者异常事件。例如设定时间间隔为 5s,算子会根据接入算子中最新事件的时间减去 5s 来更新其水位线时间戳,当窗口结束时间大于 Operator 水位线时间戳,且窗口中含有事件数据,则会立即触发窗口进行计算。总体来说,水位线的作用就是告知Operator 在后面不会再有小于等于水位线时间戳的事件接入,满足条件即可以触发相应的窗口计算。
Watermarks的定义
在时刻 T,任意创建时间(Event Time)为 ,且
的所有事件都已经到达或被观测到,那么 T 就被定义为水位。
“Streaming System”一书则是这样表述水位的:水位是一个单调增加且表征最早未完成工作(oldest work not yet completed)的时间戳。
在 Flink 中,用来衡量事件时间(Event Time)进展的标记,就被称作“水位线”(Watermark)。
EventTime 的使用
在 Flink 的流式处理中, 绝大部分的业务都会使用 eventTime, 一般只在eventTime 无法使用时,才会被迫使用 ProcessingTime 或者 IngestionTime。
如果要使用 EventTime,那么需要引入 EventTime 的时间属性,引入方式如下所示:
// 基于 Event Time 处理数据
// 设定 Stream 中 TimeCharacteristic
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// alternatively:
// env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
// env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
使用 Event-Time 处理过程中, Flink 系统需要知道:
- 每个 StreamElement 的 EventTime 时间戳
- 通过Extract(时间提取器),将数据元素中的Event-Time进行提取
- 接入的数据何时可以触发统计计算(Watermark)
- 怎么样去判断某个时间段内的数据已经全部到达Window里面,并且可以去触发统计计算
我们知道,流处理从事件产生,到流经 source,再到 operator,中间是有一个过程和时间的,虽然大部分情况下,流到 operator 的数据都是按照事件产生的时间顺序来的,但是也不排除由于网络、分布式等原因,导致乱序的产生,所谓乱序,就是指 Flink 接收到的事件的先后顺序不是严格按照事件的 Event Time 顺序排列的。
那么此时出现一个问题,一旦出现乱序,如果只根据 eventTime 决定 window 的运行,我们不能明确数据是否全部到位,但又不能无限期的等下去,此时必须要有个机制来保证一个特定的时间后,必须触发window 去进行计算了, 这个特别的机制,就是 Watermark。
Watermark的实现
具体实现上,水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点, 主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了
如图所示,每个事件产生的数据,都包含了一个时间戳,我们直接用一个整数表示。 这里没有指定单位,可以理解为秒。当产生于 2 秒的数据到来之后,当前的事件时间就是 2 秒;在后面插入一个时间戳也为 2 秒的水位线, 随着数据一起向下游流动。而当 5 秒产生的数据到来之后,同样在后面插入一个水位线,时间戳也为 5,当前的时钟就推进到了 5 秒。这样,如果出现下游有多个并行子任务的情形,我们只要将水位线广播出去(预示着所有的subtask都可以收到该水位线),就可以通知到所有下游任务当前的时间进度了。水位线就像它的名字所表达的,是数据流中的一部分,随着数据一起流动,在不同任务之 间传输。
顺序事件中的Watermarks
在理想状态下,数据应该按照它们生成的先后顺序、排好队进入流中;也就是说,它们处理的过程会保持原先的顺序不变,遵守先来后到的原则。这样的话我们从每个数据中提取时间戳,就可以保证总是从小到大增长的,从而插入的水位线也会不断增长、事件时钟不断向前推进。
实际应用中,如果当前数据量非常大,可能会有很多数据的时间戳是相同的,这时每来一条数据就提取时间戳、插入水位线就做了大量的无用功。而且即使时间戳不同,同时涌来的数据时间差会非常小(比如几毫秒),往往对处理计算也没什么影响。所以为了提高效率,一般 会每隔一段时间生成一个水位线,这个水位线的时间戳,就是当前最新数据的时间戳,如图所示。所以这时的水位线,其实就是有序流中的一个周期性出现的时间标记。
-
上图中方块代表事件(event)
-
方块中的数字表示Event Time
-
w(5) w(10)表示waterMark
乱序事件中的Watermarks
现实情况下数据元素往往并不是按照其产生顺序接入到Flink 系统中进行处理,而频繁出现乱序或迟到的情况,这种情况就需要使用 Watermarks 来应对。
- 上图中方块代表事件(event)
- 方块中的数字表示Event Time
- w(5) w(10)表示waterMark
- maxOutOfOrderness = 2
在乱序情况下,watermarker产生规则为当前事件的EventTime - maxOutOfOrderness
比如上图中
-
当事件时间为7的事件进来后为产生一个watermark(5)的水位线插入到数据流中
-
当事件时间为9的时间到达后会产生一个(9-2 = 7)的waterMarker,w(7) 插入到数据流中
-
当事件时间为11的时间到达后会产生一个(11-2 = 9)的waterMarker,w(9)插入到数据流中
-
当事件时间为10 的时间到达后,此时Event Time并没有超过前面的事件的最大值(EventTime=11),此时不会触发waterMarker 的更新。
这个时候我们会发现watermarker的更新是根据我们的event-Time的变动而更新,公式为Max(EventTime-maxOutOfOrderness),可以发现water marker是单调递增的。
- Watermark用于标记 Event-Time 的前进过程;
- Watermark跟随 DataStream Event-Time 变动,并自身携带 TimeStamp;
- Watermark用于表明所有较早的事件已经(可能)到达;
- Watermark本身也属于特殊的事件
如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线。这时只需要保存一下之前所有数据中的最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线
并行数据流中的 Watermarks
Watermark 在 Source Operator中生成,并且在每个Source Operator的子Task中都会独立生成 Watermark。在 Source Operator 的子任务中生成后就会更新该 Task 的Watermark,且会逐步更新下游算子中的 Watermark 水位线,随后一致保持在该并发之中,直到下一次 Watermarks 的生成,并对前面的 Watermarks 进行覆盖。如图 4-10 所示,W(19) 水位线已经将 Source 算子和 Map 算子的子任务时钟的时间全部更新为值 19,并且一直会随着事件向后移动更新下游算子中的事件时间。如果多个 Watermark 同时更新一个算子 Task 的当前事件时间,Flink 会选择最小的水位线来更新,当一个 Window 算子 Task 中水位线大于了 Window 结束时间,就会立即触发窗口计算。
水位线的特性
水位线就代表了当前的事件时间时钟,而且可以在数据的时间戳基础上加一些延迟来保证不丢数据,这一点对于乱序流的正确处理非常重要。
可以总结一下水位线的特性:
- 水位线是插入到数据流中的一个标记,可以认为是一个特殊的数据
- 水位线主要的内容是一个时间戳,用来表示当前事件时间的进展
- 水位线是基于数据的时间戳生成的
- 水位线的时间戳必须单调递增,以确保任务的事件时间时钟一直向前推进
- 水位线可以通过设置延迟,来保证正确处理乱序数据
- 一个水位线 Watermark(t),表示在当前流中事件时间已经达到了时间戳 t, 这代表 t 之 前的所有数据都到齐了,之后流中不会出现时间戳
的数据
水位线是 Flink 流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成对乱序数据的正确处理。
Watermarker 有什么用
对于watermarker 本身来讲,它在flink系统中,本身没有太大的意义,只有跟window结合才会产生相应的价值。对于window来讲,主要还是通过watermarker去控制它的窗口触发时机。
比如在窗口中,如:[10:00:00~10:00:10]的窗口,一旦Flink服务接收到Event Time为10:00:10就会触发计算,那么可能会导致延迟到达的数据丢失,造成统计结果不够精确,那么现在有了Watermaker,窗口就可以按照Watermaker来触发计算。
也就是说Watermaker 存在的意义
- 用来触发窗口计算的
- 解决一定范围内的乱序事件
- Watermark 的主要目的是告诉窗口不再会有比当前 Watermark 更晚的数据到达
Watermarker 是如何触发窗口计算的
窗口计算的触发条件为:
- 窗口中有数据
- Watermaker >= 窗口的结束时间
Watermaker = MAX(当前窗口的最大的事件事件 - 最大允许的延迟事件或乱序时间)也就是说只要不断有数据来,就可以保证Watermaker水位线是会一直上升/变大的,不会下降/减小的,所以最终一定是会触发窗口计算的
注意
上面的触发公式进行如下变形:
- Watermaker >= 窗口的结束时间
- Watermaker = 当前窗口的最大的事件事件 - 最大允许的延迟事件或乱序时间
- 当前窗口的最大的事件事件 - 最大允许的延迟事件或乱序时间 >= 窗口的结束时间
- 当前窗口的最大的事件事件 >= 窗口的结束时间 + 最大允许的延迟事件或乱序时间
一个场景
如图所示:
窗口时间为[10:00:00 ~ 10:10:00),CBDA数据依次到达窗口
1. 没有Watermaker机制
- C数据进入[10:10:00 ~ 10:20:00)的窗口
- B数据到达了(最少延迟2 min)此时窗口[10:00:00 ~ 10:10:00) 已经被触发,因为当时的时间> 10:11:00+1。所以B数据丢失
2. 有了Watermaker机制,并设置最大允许的延迟时间或乱序时间为5分钟
-
C数据到达时,Watermaker = max{10:11:00} - 5 = 10:06:00 < 窗口结束时间 不满足触发条件
-
B数据到达时,Watermaker = max{10:11:00,10:09:00} - 5 = 10:06:00 < 窗口结束时间 不满足触发条件
-
D数据到达时,Watermaker = max{10:11:00,10:09:00,10:15:00} - 5 = 10:10:00 = 窗口结束时间 满足触发条件
-
这个时候窗口才会触发计算,B数据不会丢失
3. 注意
Watermaker机制可以在一定程度上解决数据乱序或延迟到达的问题,但是更加严重的还是无法解决。比如A (event-time 10:08:00)数据到达时窗口已经计算完毕了,所以A数据还是会丢失,如果要让A数据也不丢失,可以将最大允许的延迟时间再设置大一点,或使用后续学习的Allowed Lateness 侧道输出机制
Watermark 的使用
如何生成水位线
生成水位线的总体原则
我们知道,完美的水位线是“绝对正确”的,也就是一个水位线一旦出现,就表示这个时间之前的数据已经全部到齐、之后再也不会出现了。而完美的东西总是可望不可即,我们只能尽量去保证水位线的正确。如果对结果正确性要求很高、想要让窗口收集到所有数据,我们该怎么做呢?
一个字,等。由于网络传输的延迟不确定,为了获取所有迟到数据,我们只能等待更长的时间。作为筹划全局的程序员,我们当然不会傻傻地一直等下去。那到底等多久呢?这就需要对相关领域有一定的了解了。比如,如果我们知道当前业务中事件的迟到时间不会超过 5 秒, 那就可以将水位线的时间戳设为当前已有数据的最大时间戳减去 5 秒,相当于设置了 5 秒的延迟等待。
更多的情况下,我们或许没那么大把握。毕竟未来是没有人能说得准的,我们怎么能确信未来不会出现一个超级迟到数据呢?所以另一种做法是,可以单独创建一个 Flink 作业来监控事件流,建立概率分布或者机器学习模型,学习事件的迟到规律。得到分布规律之后,就可以选择置信区间来确定延迟,作为水位线的生成策略了。例如,如果得到数据的迟到时间服从 的正态分布,那么设置水位线延迟为 3 秒,就可以保证至少 97.7%的数据可以正确处理。
如果我们希望计算结果能更加准确,那可以将水位线的延迟设置得更高一些,等待的时间越长,自然也就越不容易漏掉数据。不过这样做的代价是处理的实时性降低了,我们可能为极少数的迟到数据增加了很多不必要的延迟。
如果我们希望处理得更快、实时性更强,那么可以将水位线延迟设得低一些。这种情况下, 可能很多迟到数据会在水位线之后才到达,就会导致窗口遗漏数据,计算结果不准确。
所以 Flink 中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把控制的权力交给了程序员,我们可以在代码中定义水位线的生成策略。
水位线生成策略(Watermark Strategies)
在 Flink 的 DataStream API 中,有一个单独用于生成水位线的方法:.assignTimestampsAndWatermarks(),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间
public SingleOutputStreamOperator<T> assignTimestampsAndWatermarks(
WatermarkStrategy<T> watermarkStrategy)
具体使用时,直接用 DataStream 调用该方法即可,与普通的 transform 方法完全一样。
DataStream<Event> stream = env.addSource(new ClickSource());
DataStream<Event> withTimestampsAndWatermarks = stream.assignTimestampsAndWatermarks(<watermark strategy>);
这里可能有疑惑:不是说数据里已经有时间戳了吗,为什么这里还要“分配”呢?这是因为原始的时间戳只是写入日志数据的一个字段,如果不提取出来并明确把它分配给数据, Flink 是无法知道数据真正产生的时间的。当然,有些时候数据源本身就提供了时间戳信息, 比如读取 Kafka 时,我们就可以从 Kafka 数据中直接获取时间戳,而不需要单独提取字段分配了。
assignTimestampsAndWatermarks()方法需要传入一个 WatermarkStrategy 作为参数,这就 是所谓的“水位线生成策略”。WatermarkStrategy 中包含了一个"时间戳分配器"TimestampAssigner 和一个"水位线生成器”WatermarkGenerator"
@Public
public interface WatermarkStrategy<T> extends
TimestampAssignerSupplier<T>, WatermarkGeneratorSupplier<T> {
WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context);
WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context);
}
- TimestampAssigner:主要负责从流中数据元素的某个字段中提取时间戳,并分配给元素。时间戳的分配是生成水位线的基础。
- WatermarkGenerator:主要负责按照既定的方式,基于时间戳生成水位线。在 WatermarkGenerator 接口中,主要又有两个方法:onEvent()和 onPeriodicEmit()。
-
onEvent:每个事件(数据)到来都会调用的方法,它的参数有当前事件、时间戳, 以及允许发出水位线的一个 WatermarkOutput,可以基于事件做各种操作
-
onPeriodicEmit:周期性调用的方法,可以由WatermarkOutput发出水位线。周期时间为处理时间,可以调用环境配置的.setAutoWatermarkInterval()方法来设置,默认为200ms。
-
指定 Timestamps 与生成 Watermarks
如果使用 Event Time 时间概念处理流式数据,除了在 StreamExecationEviromment中指定 TimeCharacteristic 外,还需要在 Flink 程序中指定 Event Time 时间戳在数据中的字段信息,在 Flink 程序运行过程中会通过指定字段抽取出对应的事件时间,该过程叫作 Timestamps Assigning。
简单来讲,就是告诉系统需要用哪个字段作为事件时间的数据来源。另外 Timestamps 指定完毕后,下面就需要制定创建相应的 Watermarks, 需要用户定义根据 Timestamps 计算出 Watermarks 的生成策略。目前 Flink 支持两种方式指定 Timestamps 和生成 Watermarks。
- 一种方式在 DataStream Source算子接口的Source Function 中定义,
- 另外一种方式是通过自定义 Timestamp Assigner 和 Watermark Generator 生成。
在Source Function 中直接定义Timestamps 和Watermarks
在DataStream Source 算子中指定Event Timestamps,也就是说在数据进入到Flink系统中就直接指定分配EventTime和WaterMark.用户需要重写SourceFunction接口中run()方法实现数据生成逻辑,同时需要调用 SourceContext 的collectWithTimestamp()方法生成eventTime时间戳,掉用emitWatermark()方法生成Watermarks。如下面代码所示,在addSource中通过匿名类实现SourceFunction接口,将本地集合中数据读取到系统中,并且分别调用collectWithTimestamp和
emitWatermark()方法指定EventTime和生成Watermarks
// 创建数组数据集
val input = List (("a", 1L, 1), ("b", 1L, 1), ("b", 3L, 1))
// 添加 DataSource 数据源,实例化 SourceFunction 接口
val source: DataStream [ (String, Long, Int)] = env.addSource(
new SourceFunction [ (String, Long, Int)]() {
// 复写 run 方法,调用 SourceContext 接口
override def run (ctx: SourceContext [ (String, Long, Int)]): Unit {
input. foreach (value => {
// 调用 collectWithTimestamp 增加 Event Time抽取
ctx.collectWithTimestamp (value, value._2)
// 调用 emitWatermark,创建 Watermark,最大延时设定为1
ctx.emitWatermark (new Watermark (value._2 - 1))
})
// 设定默认 Watermark
ctx.emitWatermark (new Watermark (Long. MaxValue))
}
override def cancel(): Unit = {}
})
Flink 内置水位线生成器
WatermarkStrategy 这个接口是一个生成水位线策略的抽象,让我们可以灵活地实现自己的需求;但看起来有些复杂,如果想要自己实现应该还是比较麻烦的。好在 Flink 充分考虑到了我们的痛苦,提供了内置的水位线生成器(WatermarkGenerator),不仅开箱即用简化了编程, 而且也为我们自定义水位线策略提供了模板。
这两个生成器可以通过调用 WatermarkStrategy 的静态辅助方法来创建。它们都是周期性生成水位线的,分别对应着处理有序流和乱序流的场景。
有序流
对于有序流,主要特点就是时间戳单调增长(Monotonously Increasing Timestamps),所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接调用WatermarkStrategy.forMonotonousTimestamps()方法就可以实现。简单来说,就是直接拿当前最大的时间戳作为水位线就可以了。
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.getTimestamp();
}
}));
上面代码中我们调用withTimestampAssigner()方法,将数据中的 timestamp 字段提取出来,作为时间戳分配给数据元素;然后用内置的有序流水位线生成器构造出了生成策略。这样,提取出的数据时间戳,就是我们处理计算的事件时间。
这里需要注意的是,时间戳和水位线的单位,必须都是毫秒。
乱序流
由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间(Fixed Amount of Lateness)。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。调用 WatermarkStrategy. forBoundedOutOfOrderness()方法就可以实现。这个方法需要传入一个 maxOutOfOrderness 参数,表示“最大乱序程度”,它表示数据流中乱序数据时间戳的最大差值;如果我们能确定乱序 程度,那么设置对应时间长度的延迟,就可以等到所有的乱序数据了。
代码示例如下
// 插入水位线的逻辑
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.
// 针对乱序流插入水位线,延迟时间设置为 5s
<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
// 抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.getTimestamp();
}
}));
自定义水位线策略
一般来说,Flink 内置的水位线生成器就可以满足应用需求了。不过有时我们的业务逻辑可能非常复杂,这时对水位线生成的逻辑也有更高的要求,我们就必须自定义实现水位线策略 WatermarkStrategy 了。
在 WatermarkStrategy 中,时间戳分配器 TimestampAssigner 都是大同小异的,指定字段提取时间戳就可以了;而不同策略的关键就在于 WatermarkGenerator 的实现。
整体说来,Flink 有两种不同的生成水位线的方式:
- 一种是周期性的(Periodic),
- 一种是断点式的(Punctuated)。
对应着WatermarkGenerator 接口中的两个方法onEvent()和 onPeriodicEmit()
- onEvent():在每个事件到来时调用
- onPeriodicEmit():由框架周期性调用
前者是在每个事件到来时调用,而后者由框架周期性调用。周期性调用的方法中发出水位线,自然就是周期性生成水位线;而在事件触发的方法中发出水位线,自然就是断点式生成了。两种方式的不同就集中体现在这两个方法的实现上。
周期性水位线生成器(Periodic Generator)
周期性生成器一般是通过 onEvent()观察判断输入的事件,而在 onPeriodicEmit()里发出水位线。
下面是一段自定义周期性生成水位线的代码:
@Test
public void test_watermark_customer_watermark() throws Exception {
DataStreamSource<Event> dataStream = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Mary", "./cart", 1000L),
new Event("Mary", "./home", 1000L),
new Event("Mary", "./cart", 1000L),
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 2000L),
new Event("Bob", "./home", 2000L),
new Event("Bob", "./cart", 2000L),
new Event("Bob", "./home", 2000L),
new Event("Pop", "./home", 500L),
new Event("Pop", "./cart", 500L),
new Event("Pop", "./cart", 500L),
new Event("Pop", "./cart", 500L),
new Event("Pop", "./home", 500L),
new Event("Pop", "./cart", 500L),
new Event("Pop", "./home", 500L));
// 插入水位线的逻辑
dataStream.assignTimestampsAndWatermarks(new MyCustomerWaterMark());
StreamingFileSink<String> fileSink = StreamingFileSink.<String>forRowFormat(new Path("./output.txt"), new SimpleStringEncoder<>("UTF-8"))
.withRollingPolicy(DefaultRollingPolicy.builder()
.withRolloverInterval(TimeUnit.SECONDS.toMillis(15))
.withInactivityInterval(TimeUnit.SECONDS.toMillis(5))
.withMaxPartSize(1024 * 1024 * 1024)
.build()
).build();
// 将Event转换成String写入到文件
dataStream.map(Event::toString).addSink(fileSink);
}
@AfterEach
public void afterEach() throws Exception {
env.execute();
}
// 自定义水位线的产生
class MyCustomerWaterMark 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.getTimestamp();
}
};
}
@Override
public WatermarkGenerator<Event> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new CustomPeriodicGenerator();
}
}
class CustomPeriodicGenerator implements WatermarkGenerator<Event> {
private Long delayTime = 5000L; // 延迟时间
private Long maxTs = Long.MIN_VALUE + delayTime + 1L; // 观察到的最大时间戳
@Override
public void onEvent(Event event, long eventTimestamp, WatermarkOutput output) {
// 每来一条数据就调用一次
maxTs = Math.max(event.getTimestamp(), maxTs); // 更新最大时间戳
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 发射水位线,默认 200ms 调用一次
output.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
我们在 onPeriodicEmit()里调用 output.emitWatermark(),就可以发出水位线了;这个方法由系统框架周期性地调用,默认 200ms 一次。所以水位线的时间戳是依赖当前已有数据的最大时间戳的(这里的实现与内置生成器类似,也是减去延迟时间再减 1),但具体什么时候生成与数据无关。
断点式水位线生成器(Punctuated Generator)
断点式生成器会不停地检测 onEvent()中的事件,当发现带有水位线信息的特殊事件时,就立即发出水位线。一般来说,断点式生成器不会通过 onPeriodicEmit()发出水位线。 自定义的断点式水位线生成器代码如下:
/**
* 自定义的断点式水位线生成器
*/
class CustomPunctuatedGenerator implements WatermarkGenerator<Event> {
@Override
public void onEvent(Event event, long eventTimestamp, WatermarkOutput output) {
output.emitWatermark(new Watermark(event.getTimestamp() - 1));
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 不需要做任何事情,因为我们在 onEvent 方法中发射了水位线
}
}
水位线的传递
我们知道水位线是数据流中插入的一个标记,用来表示事件时间的进展,它会随着数据一起在任务间传递。如果只是直通式(forward)的传输,那很简单,数据和水位线都是按照本身的顺序依次传递、依次处理的;一旦水位线到达了算子任务, 那么这个任务就会将它内部的时钟设为这个水位线的时间戳。
在这里,“任务的时钟”其实仍然是各自为政的,并没有统一的时钟。实际应用中往往上下游都有多个并行子任务,为了统一推进事件时间的进展,我们要求上游任务处理完水位线、时钟改变之后,要把当前的水位线再次发出,广播给所有的下游子任务。这样,后续任务就不需要依赖原始数据中的时间戳(经过转化处理后,数据可能已经改变了),也可以知道当前事件时间了。
可是还有另外一个问题,那就是在“重分区”(redistributing)的传输模式下,一个任务有可能会收到来自不同分区上游子任务的数据。而不同分区的子任务时钟并不同步,所以同一时刻发给下游任务的水位线可能并不相同。这时下游任务又该听谁的呢?
这就要回到水位线定义的本质了:它表示的是“当前时间之前的数据,都已经到齐了”。 这是一种保证,告诉下游任务“只要你接到这个水位线,就代表之后我不会再给你发更早的数据了,你可以放心做统计计算而不会遗漏数据”。所以如果一个任务收到了来自上游并行任务的不同的水位线,说明上游各个分区处理得有快有慢,进度各不相同比如上游有两个并行子任务都发来了水位线,一个是 5 秒,一个是 7 秒;这代表第一个并行任务已经处理完 5 秒之前的所有数据,而第二个并行任务处理到了 7 秒。那这时自己的时钟怎么确定呢?当然也要以“这之前的数据全部到齐”为标准。如果我们以较大的水位线 7 秒作为当前时间,那就表示“7 秒 前的数据都已经处理完”,这显然不是事实——第一个上游分区才处理到 5 秒,5~7 秒的数据还会不停地发来;而如果以最小的水位线 5 秒作为当前时钟就不会有这个问题了,因为确实所 有上游分区都已经处理完,不会再发 5 秒前的数据了。这让我们想到“木桶原理”:所有的上游并行任务就像围成木桶的一块块木板,它们中最短的那一块,决定了我们桶中的水位。
我们可以用一个具体的例子,将水位线在任务间传递的过程完整梳理一遍。如图所示,当前任务的上游,有四个并行子任务,所以会接收到来自四个分区的水位线;而下游有三个并行子任务,所以会向三个分区发出水位线(广播)。
具体过程如下:
-
上游并行子任务发来不同的水位线,当前任务会为每一个分区设置一个“分区水位线” (Partition Watermark),这是一个分区时钟;而当前任务自己的时钟,就是所有分区时钟里最小的那个。
-
当有一个新的水位线(第一分区的 4)从上游传来时,当前任务会首先更新对应的分区时钟;然后再次判断所有分区时钟中的最小值,如果比之前大,说明事件时间有了进展,当前任务的时钟也就可以更新了。这里要注意,更新后的任务时钟,并不一定是新来的那个分区水位线,比如这里改变的是第一分区的时钟,但最小的分区时钟是第三分区的 3,于是当前任 务时钟就推进到了 3。当时钟有进展时,当前任务就会将自己的时钟以水位线的形式,广播给下游所有子任务。
-
再次收到新的水位线(第二分区的 7)后,执行同样的处理流程。首先将第二个分区时钟更新为 7,然后比较所有分区时钟;发现最小值没有变化,那么当前任务的时钟也不变也不会向下游任务发出水位线。
-
同样道理,当又一次收到新的水位线(第三分区的 6)之后,第三个分区时钟更新为6,同时所有分区时钟最小值变成了第一分区的 4,所以当前任务的时钟推进到 4,并发出时间 戳为 4 的水位线,广播到下游各个分区任务。
水位线在上下游任务之间的传递,非常巧妙地避免了分布式系统中没有统一时钟的问题, 每个任务都以“处理完之前所有数据”为标准来确定自己的时钟,就可以保证窗口处理的结果总是正确的。对于有多条流合并之后进行处理的场景,水位线传递的规则是类似的。
水位线的总结
水位线在事件时间的世界里面,承担了时钟的角色。也就是说在事件时间的流中,水位线是唯一的时间尺度。如果想要知道现在的时刻,就要看水位线的大小。后面讲到的窗口的闭合, 以及定时器的触发都要通过判断水位线的大小来决定是否触发。
水位线是一种特殊的事件,由程序员通过编程插入的数据流里面,然后跟随数据流向下游 流动。
水位线的默认计算公式:水位线 = 观察到的最大事件时间 - 最大延迟时间 - 1 毫秒。
所以这里涉及到一个问题,就是不同的算子看到的水位线的大小可能是不一样的。因为下游的算子可能并未接收到来自上游算子的水位线,导致下游算子的时钟要落后于上游算子的时钟。比如 map->reduce 这样的操作,如果在 map 中编写了非常耗时间的代码,将会阻塞水位线的向下传播,因为水位线也是数据流中的一个事件,位于水位线前面的数据如果没有处理完毕,那么水位线不可能弯道超车绕过前面的数据向下游传播,也就是说会被前面的数据阻塞。 这样就会影响到下游算子的聚合计算,因为下游算子中无论由窗口聚合还是定时器的操作,都需要水位线才能触发执行。这也就告诉了我们,在编写 Flink 程序时,一定要谨慎的编写每一个算子的计算逻辑,尽量避免大量计算或者是大量的 IO 操作,这样才不会阻塞水位线的向下传递。
在数据流开始之前,Flink 会插入一个大小是负无穷大(在 Java 中是-Long.MAX_VALUE) 的水位线,而在数据流结束时,Flink 会插入一个正无穷大(Long.MAX_VALUE)的水位线,保证所有的窗口闭合以及所有的定时器都被触发。
对于离线数据集,Flink 也会将其作为流读入,也就是一条数据一条数据的读取。在这种情况下,Flink 对于离线数据集,只会插入两次水位线,也就是在最开始处插入负无穷大的水位线,在结束位置插入一个正无穷大的水位线。因为只需要插入两次水位线,就可以保证计算的正确,无需在数据流的中间插入水位线了。
水位线的重要性在于它的逻辑时钟特性,而逻辑时钟这个概念可以说是分布式系统里面最为重要的概念之一了,理解透彻了对理解各种分布式系统非常有帮助。