大数据之Flink简介第二部分
六、Flink中的时间和窗口
1、时间的概念
Flink分布式系统由两个时间非常重要,事件时间和处理时间,事件时间指的就是数据发生的时间,也就是数据本身的时间戳;处理时间,就是该数据进入Flink系统时,系统算子处理该数据的时间。
** 处理时间:**
处理时间是最简单的时间语义,就是Flink在处理该数据时,系统是什么时间就是什么时间。当我们需要统计8点-9点这个时间范围的数据量时,如果A数据是7点59分发生的,但是进入到系统处理时为8点,该数据就会被统计进8点-9点的区间。同样因为分布式系统不同节点的数据处理之间存在一定的时间差,可能后面到的数据C先处理完,而前面到的数据B因为某个节点资源运行稍慢,到了Flink系统中时,就会是C在B的前面。如果只按处理时间处理的话,上面的这些因素都不考虑,得出的结果不一定都是准确的结果,只能说是大概的结果,但处理时间的逻辑简单,处理方便。
事件时间:
事件时间就是该数据发生时的时间,也可以叫该数据的时间戳,它是作为数据的其中一个属性传入到Flink系统中的。如果以事件时间作为Flink处理数据的时间判定,就可以完全不用Flink系统本身的时间了。但在分布式中有一个问题,就是有可能后面产生的数据C(8点)先到,前面的数据B(7点59分59秒)后到,如果先将C的时间戳作为系统时间,就会发生时间倒退的情况。为了避免这种情况产生,我们人为规定了一个“水位线”的概念。
水位线:
在Flink中用来衡量事件时间Event Time进展的标记,就被称为水位线,水位线也可以看做水位线这个时间点之前的所有数据都到了。
有序流水位线:
假设数据的处理就是经过了A,B,C三个算子,且每个算子都是只有一个节点,就是并行度为1,并且数据都是有序的进入的(不考虑网络延迟等)。
则流程为 数据读入 => A处理 => B处理 => C处理 => 数据输出。
读入数据的时间戳为7点59分:1800ms => 1600ms => 1000ms => 500ms => 300ms => 100ms
这时候的水位线策略就是什么时间戳来了,就将谁设为当前的水位线即可。因为数据都是有序递增的,之前的数据不可能在水位线设置了之后才来,比如不会出现设置了水位线300ms后,还来200ms的数据。
乱序流水位线:
假设数据的处理就是经过了A,B,C三个算子,且每个算子都是只有一个节点,就是并行度为1,但是数据不能保证都是有序进入的(因为网络延迟、传输等)。
则流程为 数据读入 => A处理 => B处理 => C处理 => 数据输出。
读入数据的时间戳为7点59分:1800ms => 1500ms => 1600ms => 300ms => 400ms => 100ms
因为数据是乱序的,我们要考虑的就是整个网络或流程有可能的最大延迟大概是多少,例如上面的数据,1500ms的数据比1600ms的数据大概延迟了100ms,这时我们可以在水位线上延迟120ms,用这120ms来等待可能延迟的数据。当400ms的时间戳进入系统时,水位线为400ms-120ms=280ms,意思就是将水位线人为延迟一定的时间,然后等到280ms-400ms这个时间段的时间进来。而且因为水位线是不能往前倒退的,当300ms的数据进来时,水位线是280ms所以可以进来,但300ms的数据不会更新水位线,因为如果更新的话,水位线将变成300ms-120ms=180ms,这跟水位线不能倒退相悖。
因为实际情况都是乱序的数据为主,这里也看到了,凡事总有例外,如果数据延迟了130ms才来的话,那其实这个数据就被丢弃了。其实是对的,因为我们不能将延时设置很大,设置很大时,数据的处理就不是实时性的了,将会有很大的延迟。所以其实这里是需要权衡数据的准确性和数据延迟的,如果需要数据延迟很低,就需要舍弃数据的准确性了。但Flink也注意到了这个,所以后续会有专门针对延迟数据的方案。
乱序流分布式水位线:
假设数据的处理就是经过了A,B,C三个算子,且每个算子都有多个节点,就是并行度为3,且数据不能保证都是有序进入的(因为网络延迟、传输、算子处理等)。
、、、、、、、、 => A1处理 => B1处理 => C1处理 =>
则流程为 数据读入 => A2处理 => B2处理 => C2处理 => 数据输出
、、、、、、、、 => A3处理 => B3处理 => C3处理 =>
此时A算子处理的水位线分别为A1为100ms,A2为150ms,A3为200ms,每个算子都是按照乱序流水位线的策略设置水位线延迟的,但当A算子处理完,需要将数据交由B算子处理的时候,水位线应该是多少呢。其实就是取A算子的所有算子中最小的水位线,作为B的水位线并通过广播给下一个算子。而当A1的水位线更新为180ms时,给B的水位线会更新为150ms,因为给B算子的水位线是由所有A算子的最小的水位线决定的。
水位线生成策略:
Flink中水位线的生成方法为.assignTimestampsAndWatermarks(),该方法能为流中的数据指定时间戳,并设置水位线的生成策略。具体使用时,使用DataStream调用该方法即可。
(1)当数据中带有时间戳字段时,系统是无法知道的,需要显示指定将那个字段作为时间戳。
(2)水位线的生成策略有有序和乱序两种策略,乱序中有延迟时间的参数,当乱序中延迟时间设置为0时,其实就跟有序策略是一样的。
有序流策略为forMonotonousTimestamps(),T为输入数据的泛型;
乱序流策略为forBoundedOutOfOrderness(TimeDelay),T为输入数据的泛型;TimeDelay为延迟时间的参数。
DataStream<Event> stream = env.addSource(new ClickSource());
// 插入乱序流水位线的逻辑
stream.assignTimestampsAndWatermarks(
// 设置乱序流策略插入水位线,延迟时间设置为2s
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
// 指定抽取时间戳的字段
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;//返回Event中的timestamp字段作为时间戳
}
})
)
// 插入有序流水位线的逻辑
stream.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>(){
@Override
public long extractTimestamp(Event element, long recordTimestamp){
return element.timestamp;
}
})
);
自定义水位线策略:
一般Flink内置的水位线生成器就可以满足应用需求了。不过有时我们的业务逻辑可能非常复杂,这时对水位线生成的逻辑也有更高的要求,我们就必须自定义实现水位线策略WatermarkStrategy 了。自定义水位线策略时,也需要指定作为时间戳的字段,二是指明水位线的生成方式。这里有两种,一种是周期性的(Periodic),另一种是断点式的(Punctuated),分别对应WatermarkGenerator 接口中的onPeriodicEmit() 和 onEvent() 方法。前者是周期性调用,后者是每个事件,也就是数据到来时调用。
// 自定义水位线的产生
public class CustomWatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
.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 = 5000L; // 延迟时间
private Long maxTs = Long.MIN_VALUE + delayTime + 1L; // 观察到的最大时间戳
@Override
public void onEvent(Event event, long eventTimestamp, WatermarkOutput output) {
// 每来一条数据就调用一次
maxTs = Math.max(event.timestamp, maxTs); // 更新最大时间戳
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 发射水位线,默认200ms调用一次
output.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
}
水位线注意:
(1)水位线的具体设置是时间戳 - 设置的延迟 - 1ms。例如乱序流中设置的水位线是400ms-120ms-1ms=279ms,有序流设置的水位线是时间戳 - 1ms,200ms-1ms=199ms。
(2)水位线是触发窗口和定时器的时间。例如窗口是8点到9点,需求是统计8点到9点的点击次数,水位线设置为9点延迟500ms,则需要9点多500ms的数据过来,才能关闭8点-9点的窗口,然后将里面的数据统计输出。定时器也是一样的,需要水位线的时间超过了定时器的时间,定时器才会执行。
(3)数据开始之前Flink会为数据流自动插入一个负无穷大的水位线,在Java中是-Long.MAX_VALUE;而在数据结束时,会插入一个正无穷大的水位线,在Java中是Long.MAX_VALUE,保证所有的定时器和窗口都能正常关闭并统计数据输出。
(4)乱序流分布式水位线时,我们可以看出A算子的水位线是由A中耗时最多,也是延迟最多的算子决定的,耗时最多时,携带的水位线最小。所以为了保证后面的数据能正常通过水位线来触发处理,应该保持前面的算子不能延迟太久,尽可能的减少大量IO操作,或者shuffle操作等。
2、窗口的概念
Flink处理的是流式数据,是无界的,数据源源不断的,所以是不可能等所有的数据都来了才统计,也不是每个数据来了,都统计上。事实上,我们需要的都是一段时间内的数据,比如,日活,月活,日点击量,月点击量,甚至每天的小时点击数据。这时候,就需要将无界数据,归到某个窗口来进行统计,窗口也可以理解为时间段。
窗口底层的数据存储可以看成是桶bucket,例如当8点-9点的数据来临时,便新建窗口并将数据存储到8点-9点的桶中,当9点-10点的数据来临时,便新建窗口并将数据存储到9点-10点的桶中,因为数据是乱序的,所以可能同时存在多个窗口,也同时存在多个桶,但每个数据的时间戳都是固定的,属于哪个窗口就会划归到那个窗口进行处理。
流式数据相对于批量处理数据的优势就是,窗口没有关闭之前,每次数据来时,都可以将数据累加并输出,当窗口关闭,输出最后结果时,再存储到文件。期间累加器的数值可以作为状态保持,这样就算中间发生故障也能正常恢复。
3、窗口的分类
按照驱动类型分类
窗口本身是截取有界数据的一种方式,所以窗口一个非常重要的信息其实就是“怎样截取数据”。
时间窗口:
最常用的窗口类型,就是时间窗口,规定了开始和结束时间,窗口是左闭右开的状态,就是前面包含,后面不包含,例如8点-9点的数据,是包含8点的数据,但是不包含9点的数据。
计数窗口:
计数窗口,就是根据数据量,达到某个数据量的阈值,就关闭窗口,计数窗口相对于时间窗口更简单,但需要看使用的场景来定。
按照窗口分配数据的规则分类
具体的窗口分类大致可以分为4 类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)。
(1)滚动窗口(Tumbling Windows)
滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠, 也不会有间隔,是“首尾相接”的状态。每个数据都会被分配到一个窗口,且只属于一个窗口。滚动窗口可以基于时间定义,也可以基于数据个数定义,需要的参数只有一个,那就是窗口的大小。像8点-9点,9点-10点这样类似的就是属于滚动窗口。
(2)滑动窗口(Sliding Windows)
与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的, 而是可以“错开”一定的位置。如果看作一个窗口的运动,那么就像是向前小步“滑动”一样。滚动窗口中,数据有可能属于多个窗口。例如8点-9点,8点半-9点半,9点-10点这样的,就属于窗口大小为1小时,滑动步长为30分钟的滑动窗口,在8点45分的数据同时属于8点-9点的窗口和8点半-9点半的窗口。同样,此时滚动窗口可以看成是窗口大小和滑动步长一样大小的滑动窗口。所以滑动窗口的参数有两个,分别是窗口大小和滑动步长。
(3)会话窗口(Session Windows)
会话窗口,就是基于会话对数据进行分组的,类似于当开启一个session时,新建一个窗口,当session关闭时,关闭窗口。会话窗口只能基于时间来定义,无法基于数据个数来定义。且会话窗口有一个参数,表示多久时间没有数据,便关闭窗口。如果两个数据间隔的时间大于给定的参数,则认为属于两个窗口。会话窗口后面使用再具体介绍。
(4)全局窗口(Global Windows)
全局窗口,就相当于没有窗口,因为是流式数据,流式数据是没有边界的,所以全局接口没有结束的时候,但可以手动设置触发器,也就是定时器来触发。
窗口关闭和触发计算,Flink默认是一起执行的,但这是两个动作,也就是可以分开来执行。
4、窗口API
在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)的数据流 KeyedStream来开窗,还是直接在没有按键分区的 DataStream 上开窗。也就是说,在调用窗口算子之前, 是否有 keyBy 操作。
(1)按键分区(Keyed)和非按键分区(Non-Keyed)
按键分区KeyBy:
经过KeyBy分区之后,数据流会按照key 被分为多条逻辑流,基于KeyedStream进行窗口操作时,窗口任务会在多个并行子任务中开启一组窗口,各自独立计算。
调用方式为:Stream.keyBy().window()
非按键分区:
就是不按键分区,直接开窗,这种窗口逻辑只会在一个Task上面执行,并行度为1,不建议使用。
调用方式为:Stream.window()
(2)代码中窗口 API 的调用
窗口操作主要有两个部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)。
stream.keyBy(<key selector>)
.window(<window assigner>)
.aggregate(<window function>)
其中.window()方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的.aggregate() 方法传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑。
5、窗口分配器(Window Assigners)
处理时间:
(1)滚动处理时间窗口
滚动时间窗口由TumblingProcessingTimeWindows类提供,调用它的.of()方法。该方法默认是一个 Time 类型的参数 size,表示滚动窗口的大小。另外还有一个重载方法.of(),可以传入两个 Time 类型的参数:size 和 offset,offset表示偏移量。时间窗口默认是整点计算的,当我们需要特殊的例如8点20-9点20这样一个时间的时候就会用到偏移量了;并且按天计算的话,标准北京时间其实是UTC+8的,想要北京时间从0点开始计算,就需要减去8小时。
// 设置1小时1个的滚动窗口
stream.keyBy(...)
.window(TumblingProcessingTimeWindows.of(Time.hours(1)))
.aggregate(...)
// 设置1天1个的滚动窗口,并按照北京时间从0点开始计算
stream.keyBy(...)
.window(TumblingProcessingTimeWindows.of(Time.days(1), Time.hours(-8)))
.aggregate(...)
(2)滑动处理时间窗口
滑动时间窗口由SlidingProcessingTimeWindows类提供,调用它的.of()方法。这里.of()方法需要传入两个 Time 类型的参数:size 和 slide,前者表示滑动窗口的大小, 后者表示滑动窗口的滑动步长。滑动窗口同样还有一个重载方法需要添加偏移量的参数,具体用法跟上面一样。
// 新建一个10秒的滑动窗口,滑动步长为5秒
stream.keyBy(...)
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate(...)
(3)会话处理时间窗口
会话窗口由类 ProcessingTimeSessionWindows 提供,需要调用它的静态方法.withGap() 或者.withDynamicGap()。这里.withGap()方法需要传入一个 Time 类型的参数 size,表示会话的超时时间,也就是最小间隔。
stream.keyBy(...)
// 新建一个会话窗口,超过10秒没有数据,即关闭窗口
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate(...)
事件时间:
(4)滚动事件时间窗口
窗口分配器由类TumblingEventTimeWindows 提供,用法与滚动处理事件窗口完全一致。
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.aggregate(...)
这里.of()方法也可以传入第二个参数 offset,用于设置窗口起始点的偏移量。
(5)滑动事件时间窗口
窗口分配器由类 SlidingEventTimeWindows 提供,用法与滑动处理事件窗口完全一致。
stream.keyBy(...)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate(...)
(6)事件时间会话窗口
窗口分配器由类EventTimeSessionWindows 提供,用法与处理事件会话窗口完全一致。
stream.keyBy(...)
.window(EventTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate(...)
计数窗口:
(7)滚动计数窗口
滚动计数窗口只需要传入一个长整型的参数 size,表示窗口的大小。
stream.keyBy(...)
.countWindow(10)
我们定义了一个长度为 10 的滚动计数窗口,当窗口中元素数量达到 10 的时候,就会触发计算执行并关闭窗口。
(8)滑动计数窗口
与滚动计数窗口类似,不过需要在.countWindow()调用时传入两个参数:size 和 slide,前者表示窗口大小,后者表示滑动步长。
stream.keyBy(...)
.countWindow(10,3)
我们定义了一个长度为 10、滑动步长为 3 的滑动计数窗口。每个窗口统计 10 个数据,每
隔 3 个数据就统计输出一次结果。
(9)全局窗口
全局窗口是计数窗口的底层实现,一般在需要自定义窗口时使用。它的定义同样是直接调用.window(),分配器由GlobalWindows 类提供。
stream.keyBy(...)
.window(GlobalWindows.create());
需要注意使用全局窗口,必须自行定义触发器才能实现窗口计算,否则起不到任何作用。
6、窗口函数(Window Functions)
上面的窗口分配器,只是将数据划分到了哪个窗口,但是窗口里面的数据应该怎么处理,就是窗口函数的事情了。经过窗口分类之后的数据流格式为WindowedStream,必须经过窗口函数处理之后才能转化为DataStream。窗口函数可以根据处理的方式分成两类,增量聚合函数和全窗口函数
增量聚合函数
增量聚合就是采用流式数据的处理思维,将每个数据到来之后,直接跟原来的数据聚合累加,形成一个新的状态,每次数据来都会更新状态,窗口结束时,直接将状态值输出,提供了聚合的效率和实时性。
典型的增量聚合函数有两个:ReduceFunction 和AggregateFunction。
(1)归约函数(ReduceFunction)
最基本的聚合方式就是归约reduce,归约的思维就是数据两两进行归约,并形成一个状态,之后新数据再跟状态归约得到最终的结果。
使用方法跟之前的reduce是一样的,调用reduce方法,并实现里面的ReduceFunction 函数。
stream.keyBy(r -> r.f0)
// 设置滚动事件时间窗口
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.reduce(new ReduceFunction<Tuple2<String, Long>>() {
@Override
public Tuple2<String, Long> reduce(Tuple2<String, Long> value1, Tuple2<String, Long> value2) throws Exception {
// 定义累加规则,窗口闭合时,向下游发送累加结果
return Tuple2.of(value1.f0, value1.f1 + value2.f1);
}
})
.print();
(2)聚合函数(AggregateFunction)
ReduceFunction 可以解决大多数归约聚合的问题,但是这个接口有一个限制,就是聚合状态的类型、输出结果的类型都必须和输入数据类型一样。而在有些情况下,还需要对状态进行进一步处理才能得到输出结果,这时它们的类型可能不同,使用 AggregateFunction 就可以没有上述的限制,聚合的类型,输出结果的类型可以不用一致。
AggregateFunction<IN, ACC, OUT>有三个参数,输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。
该接口中有四个方法:
- createAccumulator():创建一个累加器,这就是为聚合创建了一个初始状态,每个聚合任务只会调用一次。
- add():将输入的元素添加到累加器中。这就是基于聚合状态,对新来的数据进行进 一步聚合的过程。方法传入两个参数:当前新到的数据 value,和当前的累加器accumulator;返回一个新的累加器值,也就是对聚合状态进行更新。每条数据到来之后都会调用这个方法。
- getResult():从累加器中提取聚合的输出结果。也就是说,我们可以定义多个状态, 然后再基于这些聚合的状态计算出一个结果进行输出。比如之前我们提到的计算平均值,就可以把 sum 和 count 作为状态放入累加器,而在调用这个方法时相除得到最终结果。这个方法只在窗口要输出结果时调用。
- merge():合并两个累加器,并将合并后的状态作为一个累加器返回。这个方法只在 需要合并窗口的场景下才会被调用;最常见的合并窗口(Merging Window)的场景就是会话窗口(Session Windows)。
AggregateFunction的工作原理就是先调用createAccumulator()创建一个初始的累加器状态,再每个数据都调用一次add(),更新累加器的状态,最后再调用getResult()计算结果。
public class WindowAggregateTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
// 所有数据设置相同的key,发送到同一个分区统计PV和UV,再相除
stream.keyBy(data -> true)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(2)))
.aggregate(new AvgPv())
.print();
env.execute();
}
public static class AvgPv implements AggregateFunction<Event, Tuple2<HashSet<String>, Long>, Double> {
@Override
public Tuple2<HashSet<String>, Long> createAccumulator() {
// 创建累加器
return Tuple2.of(new HashSet<String>(), 0L);
}
@Override
public Tuple2<HashSet<String>, Long> add(Event value, Tuple2<HashSet<String>, Long> accumulator) {
// 属于本窗口的数据来一条累加一次,并返回累加器
accumulator.f0.add(value.user);
return Tuple2.of(accumulator.f0, accumulator.f1 + 1L);
}
@Override
public Double getResult(Tuple2<HashSet<String>, Long> accumulator) {
// 窗口闭合时,增量聚合结束,将计算结果发送到下游
return (double) accumulator.f1 / accumulator.f0.size();
}
@Override
public Tuple2<HashSet<String>, Long> merge(Tuple2<HashSet<String>, Long> a, Tuple2<HashSet<String>, Long> b) {
return null;
}
}
}
全窗口函数
全窗口函数跟增量聚合函数不同的是,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。这其实跟批处理函数的思维一样了。全窗口函数也有两种:分别为窗口函数WindowFunction 和 处理窗口函数ProcessWindowFunction。
(1)窗口函数WindowFunction
这个类中可以获取到包含窗口所有数据的可迭代集合( Iterable),通过apply()方法调用,不过这个类的功能被处理窗口函数ProcessWindowFunction全覆盖了,以后也可能会弃用,所以了解下即可。
stream.keyBy(<key selector>)
.window(<window assigner>)
.apply(new MyWindowFunction());
(2)处理窗口函数ProcessWindowFunction
ProcessWindowFunction 是Window API 中最底层的通用窗口函数接口,因为它除了获取窗口所有数据之外,还能获取上下文对象,其包括了处理时间和水位线等,功能更丰富也更灵活。同样也是需要将所有数据缓存下来、等到窗口触发计算时才使用。
增量聚合和全窗口函数的结合使用
增量聚合能将数据先聚合处理,全窗口函数含有更多更能,更丰富的用法,那考虑将两者结合起来使用呢,用增量聚合先将数据聚合成状态,再将最后的状态发给全窗口函数输出,这样既能取到全窗口函数中的上下文和丰富用法,还能提前聚合数据,提高效率。实际情况中,往往都是两个合并使用的。
// ReduceFunction 与 ProcessWindowFunction 结合
public <R> SingleOutputStreamOperator<R> reduce(
ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> function)
// AggregateFunction 与 ProcessWindowFunction 结合
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate(
AggregateFunction<T, ACC, V> aggFunction, ProcessWindowFunction<V, R, K, W> windowFunction)
案例示例:
public class UrlViewCountExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
// 需要按照url分组,开滑动窗口统计
stream.keyBy(data -> data.url)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
// 同时传入增量聚合函数和全窗口函数
.aggregate(new UrlViewCountAgg(), new UrlViewCountResult())
.print();
env.execute();
}
// 自定义增量聚合函数,来一条数据就加一
public static class UrlViewCountAgg implements AggregateFunction<Event, Long, Long> {
@Override
public Long createAccumulator() {
return 0L;
}
@Override
public Long add(Event value, Long accumulator) {
return accumulator + 1;
}
@Override
public Long getResult(Long accumulator) {
return accumulator;
}
@Override
public Long merge(Long a, Long b) {
return null;
}
}
// 自定义窗口处理函数,只需要包装窗口信息
public static class UrlViewCountResult extends ProcessWindowFunction<Long, UrlViewCount, String, TimeWindow> {
@Override
public void process(String url, Context context, Iterable<Long> elements, Collector<UrlViewCount> out) throws Exception {
// 结合窗口信息,包装输出内容
Long start = context.window().getStart();
Long end = context.window().getEnd();
// 迭代器中只有一个元素,就是增量聚合函数的计算结果
out.collect(new UrlViewCount(url, elements.iterator().next(), start, end));
}
}
}
触发器(Trigger)
触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。触发器是窗口算子的内部属性,每个窗口分配器都会默认对应一个触发器。对于Flink内置的窗口类型,它们的触发器都已经做了实现。
Trigger 是一个抽象类,自定义时必须实现下面四个抽象方法:
- onElement():窗口中每到来一个元素,都会调用这个方法。
- onEventTime():当注册的事件时间定时器触发时,将调用这个方法。
- onProcessingTime ():当注册的处理时间定时器触发时,将调用这个方法。
- clear():当窗口关闭销毁时,调用这个方法。一般用来清除自定义的状态。
其中,前三个方法都是定时触发的含义,这三个方法的返回值都是TriggerResult,这是一个枚举类型(enum),其中定义了对窗口进行操作的四种类型。 - CONTINUE(继续):什么都不做
- FIRE(触发):触发计算,输出结果
- PURGE(清除):清空窗口中的所有数据,销毁窗口
- FIRE_AND_PURGE(触发并清除):触发计算输出结果,并清除窗口
还记得上面说的,窗口的关闭跟窗口的触发计算,他们是可以分开执行的。由上面的枚举类型可以看成,定时器可以只输出数据,也就是FIRE,也可以只关闭窗口,也就是PURGE,还能触发并关闭窗口FIRE_AND_PURGE。
7、迟到数据处理
针对迟到的数据,一般有三种处理方式:设置水位线延迟时间,允许窗口处理延迟数据和将迟到的数据放入窗口侧输出流。
设置水位线延迟时间
水位线延迟时间的设置上面有说了,水位线的设置是针对所有数据都有效的,设置水位线的延迟就是相当于将整个数据的时间戳都往后调了。因为水位线的延迟主要是用来对付分布式网络传输导致的数据乱序,而网络传输的乱序程度一般并不会很大,大多集中在几毫秒至几百毫秒之间,所以我们一般都只会设置毫秒级别的值,能够处理大多数延迟数据的小延迟,以此来降低数据的延迟。
允许窗口处理迟到数据
上面说了窗口的关闭和触发数据的计算结果是可以分开来的,而Flink中的窗口是可以设置延迟时间的,原理就是窗口时间一到,会先触发计算,并输出结果。后面的数据在窗口的延迟时间内到了之后,会再次触发计算,并将结果更新到原先的结果上面。等窗口时间超过了设置的延迟时间之后,窗口才会关闭。这样的话,相当于在水位线延迟的基础上,再加上了窗口的延迟,就能把数据的准确性再往上提高了。
将迟到数据放入窗口侧输出流
上面不管是水位线延迟,还是窗口延迟,最终都是会关闭的。那如果窗口关闭了之后,窗口数据才过来的话,怎么处理呢,Flink给我们提供了侧输出流。原理就是新建一条侧流,并将收到的数据保存下来,判断该数据属于哪个窗口后,再手动的更新到该窗口对应的状态上去。原则上,只要数据不丢失,虽然处理的过程麻烦了点,但能确保数据的正确性。
代码示例:
public class ProcessLateDataExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
// 读取socket文本流
SingleOutputStreamOperator<Event> stream =
env.socketTextStream("localhost", 7777)
.map(new MapFunction<String, Event>() {
@Override
public Event map(String value) throws Exception {
String[] fields = value.split(" ");
return new Event(fields[0].trim(), fields[1].trim(), Long.valueOf(fields[2].trim()));
}
})
// 方式一:设置watermark延迟时间,2秒钟
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
// 定义侧输出流标签
OutputTag<Event> outputTag = new OutputTag<Event>("late"){};
SingleOutputStreamOperator<UrlViewCount> result = stream.keyBy(data -> data.url)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
// 方式二:允许窗口处理迟到数据,设置1分钟的等待时间
.allowedLateness(Time.minutes(1))
// 方式三:将最后的迟到数据输出到侧输出流
.sideOutputLateData(outputTag)
.aggregate(new UrlViewCountAgg(), new UrlViewCountResult());
result.print("result");
result.getSideOutput(outputTag).print("late");
// 为方便观察,可以将原始数据也输出
stream.print("input");
env.execute();
}
public static class UrlViewCountAgg implements AggregateFunction<Event, Long, Long> {
@Override
public Long createAccumulator() {
return 0L;
}
@Override
public Long add(Event value, Long accumulator) {
return accumulator + 1;
}
@Override
public Long getResult(Long accumulator) {
return accumulator;
}
@Override
public Long merge(Long a, Long b) {
return null;
}
}
public static class UrlViewCountResult extends ProcessWindowFunction<Long, UrlViewCount, String, TimeWindow> {
@Override
public void process(String url, Context context, Iterable<Long> elements,
Collector<UrlViewCount> out) throws Exception {
// 结合窗口信息,包装输出内容
Long start = context.window().getStart();
Long end = context.window().getEnd();
out.collect(new UrlViewCount(url, elements.iterator().next(), start, end));
}
}
}
七、Flink的处理函数
1、基本处理函数(ProcessFunction )
处理函数主要是定义数据流的转换操作,所以也可以把它归到转换算子中。处理函数在Flink的API分层中属于最底层,也就是编程比较复杂,但是功能也是最为强大的API。基本处理函数的使用就是直接调用process并实现ProcessFunction接口。处理函数能访问流中的事件(event)、时间戳(timestamp)、水位线(watermark),甚至可以注册“定时事件”。还继承了富函数类,拥有富函数的特性,可以访问状态和其他的运行信息。此外,处理函数还可以直接将数据输出到侧输出流(side output)中。
处理函数 ProcessFunction<I, O> 的参数为输入和输出数据类型的泛型,内部定义了两个方法:一个是必须要实现的抽象方法.processElement();另一个是不一定要实现的非抽象方法.onTimer()。
(1)processElement()有三个参数
- value:当前流中的输入元素,也就是正在处理的数据,类型与流中数据类型一致。
- ctx:类型是 ProcessFunction 中定义的内部抽象类Context,表示当前运行的上下文,可以获取到当前的时间戳,并提供了用于查询时间和注册定时器的“定时服务”(TimerService),以及可以将数据发送到“侧输出流”(side output)的方法.output()。
- out:“收集器”(类型为 Collector),用于返回输出数据。使用方式与 flatMap算子中的收集器完全一样,直接调用 out.collect()方法就可以向下游发出一个数据。这个方法可以多次调用,也可以不调用。
ProcessFunction 可以轻松实现 flatMap 这样的基本转换功能,map、filter等都可以。
(2)非抽象方法.onTimer()
用于定义定时触发的操作,这是一个非常强大、也非常有趣的功能。onTimer()也有三个参数分别是时间戳(timestamp),上下文(ctx),以及收集器(out)。这里的时间戳在处理时间上的话就是处理时间,但在事件时间的语义下,就是水位线的时间。而且onTimer()是执行的操作,而定时器的设置只有在KeyedStream中才能设定,所以这里的OnTimer()方法其实是没有办法执行的。
简单使用:
stream.process(new ProcessFunction<Event, String>() {
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
if (value.user.equals("Mary")) {
out.collect(value.user);
} else if (value.user.equals("Bob")) {
out.collect(value.user);
out.collect(value.user);
}
System.out.println(ctx.timerService().currentWatermark());
}
}).print();
2、按键分区处理函数(KeyedProcessFunction)
keyBy都是将数据分区之后再做数据处理,在实际应该的场景中更加的常用和常用,因为实际的场景中,数据量总是很大的,而当分区之后更能发挥分布式的优点。按键分区处理函数还有一个优点就是可以灵活的使用定时器Timer和定时服务TimerService。
(1)定时器(Timer)和定时服务(TimerService)
注册定时器的功能,是通过上下文中提供的“定时服务”(TimerService)来实现的。 KeyedProcessFunction的上下文(Context)中提供了.timerService()方法,可以直接返回一个 TimerService 对象。TimerService 是 Flink 关于时间和定时器的基础服务接口,包含以下六个方法:
/*处理时间*/
// 获取当前的处理时间
long currentProcessingTime();
// 注册处理时间定时器,当处理时间超过 time 时触发
void registerProcessingTimeTimer(long time);
// 删除触发时间为 time 的处理时间定时器
void deleteProcessingTimeTimer(long time);
/*事件时间*/
// 获取当前的水位线(事件时间)
long currentWatermark();
// 注册事件时间定时器,当水位线超过 time 时触发
void registerEventTimeTimer(long time);
// 删除触发时间为 time 的处理时间定时器
void deleteEventTimeTimer(long time);
虽然处理函数都可以访问TimerService,不过只有基于 KeyedStream 的处理函数,才能去调用注册和删除定时器的方法; 未作按键分区的DataStream 不支持定时器操作,只能获取当前时间。
定时器的设置为:
// 注意这里的 coalescedTime 时间参数是毫秒
ctx.timerService().registerProcessingTimeTimer(coalescedTime);
调用方法就是在keyBy之后的KeyedStream,直接调用.process()方法,再传入KeyedProcessFunction 的实现类即可。
简单使用:
// 基于KeyedStream定义事件时间定时器
stream.keyBy(data -> true)
.process(new KeyedProcessFunction<Boolean, Event, String>() {
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
out.collect("数据到达,时间戳为:" + ctx.timestamp());
out.collect("数据到达,水位线为:" + ctx.timerService().currentWatermark() + "\n -------分割线-------");
// 注册一个10秒后的定时器
ctx.timerService().registerEventTimeTimer(ctx.timestamp() + 10 * 1000L);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
out.collect("定时器触发,触发时间:" + timestamp);
}
}).print();
其中keyBy(data -> true)是将所有数据的Key都指定为True,就是将所有的数据都分到同一个区,实际情况下切勿如此使用。
3、窗口处理函数
窗口处理函数中有ProcessWindowFunction 和 ProcessAllWindowFunction,窗口处理函数ProcessWindowFunction的使用与其他窗口函数类似, 也是基于WindowedStream直接调用方法就可以,只不过这时调用的是.process()。
stream.keyBy( t -> t.f0 )
.window( TumblingEventTimeWindows.of(Time.seconds(10)) )
.process(new MyProcessWindowFunction())
ProcessWindowFunction 依然是一个继承了 AbstractRichFunction 的抽象类,它有四个类型参数:
- IN:input,数据流中窗口任务的输入数据类型。
- OUT:output,窗口任务进行计算之后的输出数据类型。
- KEY:数据中键 key 的类型。
- W:窗口的类型,是Window 的子类型。一般情况下我们定义时间窗口,W 就是TimeWindow。
内部的方法,因为不是逐个处理数据的,所以不是之前的.processElement(),而是改成了.process()。同样的,方法也包含四个参数。 - key:窗口做统计计算基于的键值,也就是之前 keyBy 用来分区的字段。
- context:当前窗口进行计算的上下文,它的类型就是 ProcessWindowFunction内部定义的抽象类Context。
- elements:窗口收集到用来计算的所有数据,这是一个可迭代的集合类型。
- out:用来发送数据输出计算结果的收集器,类型为Collector。
窗口函数不再可以访问TimerService 对象,只能通过 currentProcessingTime()和 currentWatermark()来获取当前时间。同时,也没有.onTimer()方法, 而是多出了一个.clear()方法。clear()方法是当我们自定义了窗口函数时,能够显示的清除窗口的状态,防止内存数据溢出。
同样的,另一个窗口函数ProcessAllWindowFunction跟窗口处理函数的操作是一样的,不同的点在于ProcessAllWindowFunction是针对没有KeyBy之前的数据流调用的,所以参数里面也都只是三个,就是除去Key类型的参数,其他一致。
stream.windowAll( TumblingEventTimeWindows.of(Time.seconds(10)) )
.process(new MyProcessAllWindowFunction())
4、侧输出流(Side Output)
侧输出流的概念可以先将主的流看成主流,而这个看成支流,只要在处理函数的.processElement()或者.onTimer()方法中,调用上下文的.output()方法就可以了。
stream.process(new ProcessFunction<Integer,Long>() {
@Override
public void processElement( Integer value, Context ctx, Collector<Integer> out) throws Exception {
// 转换成 Long,输出到主流中
out.collect(Long.valueOf(value));
// 转换成 String,输出到侧输出流中
ctx.output(outputTag, "side-output: " + String.valueOf(value));
}
});
这里 output()方法需要传入两个参数,第一个是输出标签OutputTag,用来标识侧输出流,一般会在外部统一声明;第二个就是要输出的数据。
外部声明输出标签:
OutputTag<String> outputTag = new OutputTag<String>("side-output") {};
如果想要获取这个侧输出流,可以基于处理之后的 DataStream 直接调用.getSideOutput() 方法,传入对应的OutputTag,这个方式与窗口API 中获取侧输出流是完全一样的。
DataStream<String> stringStream = longStream.getSideOutput(outputTag);
八、Flink多流转换
上面所有的数据,我们都是只针对一条流来操作的,实际的情况中,可能需要分流或者合流的操作。分流一般采用侧输出流来操作,相对比较简单;合流的话就有比较多的情况和算子可以运用,一般我们用到union、connect、join和coGroup等接口来进行合并。
1、分流
分流简单来说就是将流分成几份,并单独以DataStream的数据格式输出,但我们一般都不会将流直接输出,而是经过筛选之后再输出,使用侧输出流的output直接输出。
public class SplitStreamByOutputTag {
// 定义输出标签,侧输出流的数据类型为三元组(user, url, timestamp)
private static OutputTag<Tuple3<String, String, Long>> MaryTag =
new OutputTag<Tuple3<String, String, Long>>("Mary-pv"){};
private static OutputTag<Tuple3<String, String, Long>> BobTag =
new OutputTag<Tuple3<String, String, Long>>("Bob-pv"){};
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env
.addSource(new ClickSource());
SingleOutputStreamOperator<Event> processedStream = stream.process(new ProcessFunction<Event, Event>() {
@Override
public void processElement(Event value, Context ctx, Collector<Event> out) throws Exception {
if (value.user.equals("Mary")){
ctx.output(MaryTag, new Tuple3<>(value.user, value.url, value.timestamp));
} else if (value.user.equals("Bob")){
ctx.output(BobTag, new Tuple3<>(value.user, value.url, value.timestamp));
} else {
out.collect(value);
}
}
});
processedStream.getSideOutput(MaryTag).print("Mary pv");
processedStream.getSideOutput(BobTag).print("Bob pv");
processedStream.print("else");
env.execute();
}
}
输出的字段、个数、类型也都是可以自定义的,都可以在processElement里面自定义。
2、基本合流操作
联合流(Union)
就是直接将多条流合并在一起,叫作流的联合,支持同时合并多条路,但要求就是流中的数据类型必须相同,合并之后的新流会包括所有流中的元素, 数据类型不变。
用法如下:
stream1.union(stream2, stream3, ...)
需要考虑一个问题,在事件时间语义下时,水位线应该以流中最小的水位线为主,跟前面分布式时,多个算子的水位线规则一致。因为水位线之后不能再出现水位线之前的数据,所有都是以水位线最小的为准。
SingleOutputStreamOperator<Event> stream1 = env.addSource(new ClickSource());
SingleOutputStreamOperator<Event> stream2 = env.addSource(new ClickSource());
SingleOutputStreamOperator<Event> stream3 = env.addSource(new ClickSource());
stream1.union(stream2,stream3)
连接流(Connect)
(1)连接流(ConnectedStreams)
联合流中,要求的是两条流的数据类型一致,但流的数据类型不总是一致,所以有了连接(Connect),可以将不同类型的两条流合并在一起。这里只是合并在一起而已,得到一个连接流ConnectedStreams,连接流还需要进一步的操作,通过.map()、.flatMap()或者.process()等方法再将流的数据类型分别统一成一致的类型,才能重新得到DataStream类型的数据流。
DataStream<Integer> stream1 = env.fromElements(1,2,3);
DataStream<Long> stream2 = env.fromElements(1L,2L,3L);
ConnectedStreams<Integer, Long> connectedStreams = stream1.connect(stream2);
SingleOutputStreamOperator<String> result = connectedStreams.map(new CoMapFunction<Integer, Long, String>() {
@Override
public String map1(Integer value) {
return "Integer: " + value;
}
@Override
public String map2(Long value) {
return "Long: " + value;
}
});
连接流ConnectedStreams也能先进行KeyBy之后,再进行流格式的统一,这样在数据量大的时候效率更好。
connectedStreams.keyBy(keySelector1, keySelector2);
但连接流只能每次2条流的合并,因为连接流的合并方法里面只定义了两个方法来处理两条流。
连接流处理函数(CoProcessFunction)
前面有说过一般处理函数(ProcessFunction),按键分区处理函数(KeyProcessFunction)、窗口处理函数(ProcessWindowFunction)。同样的,连接流也是有连接流处理函数。它需要实现的就是processElement1()、processElement2()两个方法,在每个数据到来时, 会根据来源的流调用其中的一个方法进行处理。CoProcessFunction 同样可以通过上下文 ctx 来访问 timestamp、水位线,并通过 TimerService 注册定时器;另外也提供了.onTimer()方法,用于定义定时触发的处理操作。
3、基于时间的合流
上面的流连接,只是单纯的将数据合并在一起,两条流之间的关系并不明确,但有的情况,两条流是有关系的,就是我们希望通过流中的字段去匹配对应来连接,类似于数据库的多表连接一样,需要两表有一个关联字段。
窗口联结(Window Join)
简单理解就是数据库的多表连接:
select * from table1 t1 join table2 t2 on t1.id = t2.id
窗口联结是需要按照key分组的,当两条流的数据到来时,会按照key先分组,并进入对应的窗口存储,当窗口触发计算时,每个窗口存储的数据(已经按照Key分好组的)会做一个笛卡尔积,然安进行遍历,把每一对匹配的数据,作为参数传入JoinFunction的Join中计算处理。每匹配一次成功的数据,Join就调用一次。
代码中调用的流程如下:
stream1.join(stream2)
.where(<KeySelector>)
.equalTo(<KeySelector>)
.window(<WindowAssigner>)
.apply(<JoinFunction>)
join是将两条流连接,where是选择stream1中的key,equalTo是选择stream2中的key,window是开窗函数(之前介绍的滑动窗口、滚动窗口和会话窗口都可以),而后面调用.apply()可以看作实现了一个特殊的窗口函数。注意这里只能调用.apply(),没有其他替代的方法。apply的作用就是为了实现后面的JoinFunction接口函数。
注意这里的数据最后出来是key的笛卡尔积,如stream1中有(a,1)(a,2),stream2中有(a,3)(a,4),那么最后join出来的数据就是(a,1)(a,3),(a,1)(a,4),(a,2)(a,3),(a,2)(a,4)。
间接联结(Interval Join)
两条流联结的数据并不一定都在同一个窗口中,或者说针对Stream1开的窗口,让Stream2的数据来匹配,但我们不能保证Stream2跟Stream1匹配的数据就一定比Stream1晚到来。那我们就想了,能不能以Stream1的数据时间为标准,然后再该时间上开辟一个往前和往后的时间窗口,比如Stream1的数据为500ms,那么其实Stream2的数据为490ms时的数据是匹配,我们就期望将Stream2的窗口开在450ms-550ms之间,这样的话,不管哪条流的数据前,只要两条数据流的数据相差不超过50ms,就能匹配上了。这个其实就是间接联结的用法。
间隔联结是基于 KeyedStream 的联结(join)操作。DataStream 在keyBy 得到KeyedStream 之后,可以调用.intervalJoin()来合并两条流,传入的参数同样是一个 KeyedStream, 两者的 key 类型应该一致;得到的是一个 IntervalJoin 类型。后续的操作是先通过.between()方法指定间隔的上下界,再调用.process()方法,定义对匹配数据对的处理操作。调用.process()需要传入一个处理函数,这也是处理函数家族的最后一员,处理联结函数ProcessJoinFunction。
代码调用流程如下:
stream1.keyBy(<KeySelector>)
.intervalJoin(stream2.keyBy(<KeySelector>))
.between(Time.milliseconds(-10), Time.milliseconds(10))
.process (new ProcessJoinFunction<Integer, Integer, String(){
@Override
public void processElement(Integer left, Integer right, Context ctx, Collector<String> out) {
out.collect(left + "," + right);
}
});
ProcessJoinFunction 内部同样有一个抽象方法.processElement()。与其他处理函数不同的是,它多了一个参数,这是因为有来自两条流的数据。参数中 left 指的就是第一条流中的数据,right 则是第二条流中与它匹配的数据。每当检测到一组匹配,就会调用这里的.processElement()方法,经处理转换之后输出结果。
窗口同组联结(Window CoGroup)
窗口同组联结CoGroup的用法跟Join非常类似,在调用时,只需将join()换成coGroup即可,如下:
stream1.coGroup(stream2)
.where(<KeySelector>)
.equalTo(<KeySelector>)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.apply(<CoGroupFunction>)
跟Join不同的是,coGroup的用法更加的通用,CoGroupFunction的三个参数分别是CoGroupFunction<IN1, IN2, O>,IN1和IN2都是可遍历的数据集合。因为coGroup的内部不是对数据做笛卡尔积传入,而是将所有的数据直接输出,由用户在函数中自定义数据的匹配规则。所以可以在CoGroupFunction实现类似于SQL语句中的内连接,左外连接或者右外连接等。事实上,Join的底层就是使用coGroup实现的。
九、Flink状态编程
Flink当中的状态可以看成是Flink计算过程的中间结果,我们将这些由一个任务维护,并且用来计算输出结果的所有数据,就叫作这个任务的状态。
1、Flink中的状态
算子的状态
无状态算子
运行过程不依赖其他数据,或者每个任务都是单独的独立事件,这样的任务算子,我们称之为无状态算子,像之前的map、filter、flatMap等。
有状态算子
除了当前数据之外,还需要其他数据来计算结果的,这里的其他数据就叫做算子的状态。像之前的求和,窗口等,需要保留之前到的数据计算的结果,这些就叫有状态的算子。
有状态算子的一般处理流程,具体步骤如下。
(1)算子任务接收到上游发来的数据;
(2)获取当前状态;
(3)根据业务逻辑进行计算,更新状态;
(4)得到计算结果,输出发送到下游任务。
状态的管理
状态的管理也就是分布式框架中需要考虑到的状态的功能,一般包含如下:
(1)状态的访问权限
(2)状态的容错性
(3)状态的重组调整
如何管理状态的访问权限才能让分布式框架正常运行;当程序出错时,该怎么恢复状态的值;当新增或缩减节点的时候,存在该节点上面的状态该怎么分配重组给其他节点。这些问题都是做状态管理时,应该考虑的问题。
状态的分类
状态一般分为托管状态(Managed State)和原始状态(Raw State),简单来说就是托管状态是由 Flink 的运行时(Runtime)来托管的;在配置容错机制后,状态会自动持久化保存,并在发生故障时自动恢复。当应用发生横向扩展时,状态也会自动地重组分配到所有的子任务实例上。
而原始状态就全部需要自定义了,因此一般情况下,不推荐使用原始状态。
托管状态下,我们按照是否进行按键分区划分为算子状态(Operator State)和按键分区状态(Keyed State)。因为算子状态是针对整个数据的,而按键分区状态是相对key之间相对独立的,互不干扰的,所以这两者之间有访问权限的差别。
2、按键分区状态(Keyed State)
按键分区状态就是针对每个健值维护一个状态的实例,为了就是将不同健值的状态分开来。在底层,Keyed State 类似于一个分布式的映射(map)数据结构,所有的状态会根据 key 保存成键值对(key-value)的形式。这样,就能将健值和状态一一对应起来了。
按键分区状态结构类型
(1)值状态(ValueState)
值状态就类似于一个变量,需要指定状态的名字和类型,ValueState本身是一个接口。如果想要保存一个长整型值作为状态,那么类型就是 ValueState。值类型提供了两个方法,用于访问状态的值好更新状态的值:
- T value():获取当前状态的值;
- update(T value):对状态进行更新,传入的参数 value 就是要覆写的状态值。
(2)列表状态(ListState)
当需要保存的状态是列表的时候,可以使用列表状态,ListState接口中同样有一个类型参数T,表示列表中数据的类型,ListState 提供的操作状态的方法如下:
- Iterable get():获取当前的列表状态,返回的是一个可迭代类型 Iterable;
- update(List values):传入一个列表values,直接对状态进行覆盖;
- add(T value):在状态列表中添加一个元素 value;
- addAll(List values):向列表中添加多个元素,以列表 values 形式传入。
(3)映射状态(MapState)
当需要保存的状态是健值对的时候,可以使用映射状态,对应的 MapState<UK, UV>接口中,就会有 UK、UV 两个泛型,分别表示保存的 key 和 value 的类型。MapState提供的操作状态的方法如下:
- UV get(UK key):传入一个 key 作为参数,查询对应的 value 值;
- put(UK key, UV value):传入一个键值对,更新 key 对应的 value 值;
- putAll(Map<UK, UV> map):将传入的映射 map 中所有的键值对,全部添加到映射状态中;
- remove(UK key):将指定 key 对应的键值对删除;
- boolean contains(UK key):判断是否存在指定的 key,返回一个 boolean 值。另外,MapState 也提供了获取整个映射相关信息的方法:
- Iterable<Map.Entry<UK, UV>> entries():获取映射状态中所有的键值对;
- Iterable keys():获取映射状态中所有的键(key),返回一个可迭代 Iterable 类型;
- Iterable values():获取映射状态中所有的值(value),返回一个可迭代 Iterable类型;
- boolean isEmpty():判断映射是否为空,返回一个 boolean 值。
(4)归约状态(ReducingState)
归约状态类似于值状态,不过它保留的值是将新添加尽量的值跟之前的值进行归约,再作为状态保存下来。ReducintState接口调用.add()方法时,是将值跟原来的值归约之后再对状态进行更新。
(5)聚合状态(AggregatingState)
与归约状态类似,但聚合状态的逻辑是比归约状态传入一个更加一般的函数,调用add()方法时,也是将值跟原来的值归约之后再对状态进行更新。
状态在使用前,是需要注册的,状态的注册,主要是通过“状态描述器”(StateDescriptor)来实现的。状态描述器中最重要的内容,就是状态的名称(name)和类型(type)。就跟变量使用一样,这样也可以在算子中注册多个状态了,只要状态的名字不同即可。
以 ValueState 为例,我们可以定义值状态描述器如下:
ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>(
"my state", // 状态名称
Types.LONG // 状态类型
);
因为状态的访问需要获取运行时上下文,这只能在富函数类(Rich Function)中获取到, 所以自定义的Keyed State 只能在富函数中使用。处理函数因为本身继承了富函数类,所以也是可以访问。在富函数中,调用.getRuntimeContext()方法获取到运行时上下文之后,RuntimeContext 有以下几个获取状态的方法:
ValueState<T> getState(ValueStateDescriptor<T>)
MapState<UK, UV> getMapState(MapStateDescriptor<UK, UV>)
ListState<T> getListState(ListStateDescriptor<T>)
ReducingState<T> getReducingState(ReducingStateDescriptor<T>)
AggregatingState<IN, OUT> getAggregatingState(AggregatingStateDescriptor<IN, ACC, OUT>)
结合上面的注册和状态获取的方法,我们可以在富函数的Open()方法里面获取状态:
// 声明值状态
private transient ValueState<Long> state;
@Override
public void open(Configuration config) {
// 注册状态描述器
ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>(
"my state", // 状态名称
Types.LONG // 状态类型
);
// 在 open 生命周期方法中获取状态
state = getRuntimeContext().getState(descriptor);
}
状态生存时间(TTL)
在实际应用中,很多状态会随着时间的推移逐渐增长,如果不加以限制,最终就会导致存储空间的耗尽。一个优化的思路是直接在代码中调用.clear()方法去清除状态,但是有时候我们的逻辑要求不能直接清除。这时就需要配置一个状态的“生存时间”(time-to-live,TTL),当状态在内存中存在的时间超出这个值时,就将它清除。我们也可以给状态设置一个失效时间,失效时间=当前时间+TTL,之后如果有对状态的访问和修改,我们可以再对失效时间进行更新。就是就当前时间修改为对数据访问和修改的时间,这样当对数据访问和修改的时间超过了设置的TTL时,就认为该状态失效,系统就会对其进行清除。
配置状态的 TTL 时,需要创建一个 StateTtlConfig 配置对象,然后调用状态描述的.enableTimeToLive()方法启动TTL 功能。
// 配置状态生存时间的属性
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(10))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
// 启用状态生存时间TTL
stateDescriptor.enableTimeToLive(ttlConfig);
这里用到了几个配置项:
- .newBuilder()
状态TTL 配置的构造器方法,必须调用,返回一个 Builder 之后再调用.build()方法就可以得到 StateTtlConfig 了。方法需要传入一个 Time 作为参数,这就是设定的状态生存时间。 - .setUpdateType()
设置更新类型。更新类型指定了什么时候更新状态失效时间,这里的 OnCreateAndWrite 表示只有创建状态和更改状态(写操作)时更新失效时间。另一种类型OnReadAndWrite 则表示无论读写操作都会更新失效时间,也就是只要对状态进行了访问,就表明它是活跃的,从而延长生存时间。这个配置默认为 OnCreateAndWrite。 - .setStateVisibility()
设置状态的可见性。所谓的“状态可见性”,是指因为清除操作并不是实时的,所以当状态过期之后还有可能存在,这时如果对它进行访问,能否正常读取到就是一个问题了。这里设置的NeverReturnExpired 是默认行为,表示从不返回过期值,也就是只要过期就认为它已经被清除了,应用不能继续读取;这在处理会话或者隐私数据时比较重要。对应的另一种配置是 ReturnExpireDefNotCleanedUp,就是如果过期状态还存在,就返回它的值。
注意,所有集合类型的状态,(例如ListState、MapState)在设置 TTL 时,都是针对每一项(per-entry)元素的。也就是说,一个列表状态中的每一个元素,都会以自己的失效时间来进行清理,而不是整个列表一起清理。
3、算子状态(Operator state)
算子状态是只针对当前算子并行任务有效,不需要考虑不同 key 的隔离。算子状态功能不如按键分区状态丰富,它的调用方法也会有一些区别。算子状态的作用范围被定义为当前算子,即使不同key但只要分到同一个并行子任务,就会访问到同一个算子状态。算子状态的应用场景比较少,一般用在source或者sink中,或者在一些没有Key的场景中。
算子状态结构类型
(1)列表状态(ListState)
算子状态的列表状态跟按键分区的列表状态一样,就是将状态表示为一组数据的列表。列表中的状态项就是可以重新分配的最小粒度,彼此之间完全独立。当算子并行度需要进行缩放时,同一个算子的不同分区会先进行收集起来,再通过轮询的方式分配给所有并行任务。这种方式也叫平均分割重组even-split redistribution。
(2)联合列表状态(UnionListState)
联合列表状态也是一样列表的状态,同上面不一样的地方在于当算子并行度进行缩放时,对于状态的分配不同。上面是全部收集起来后,再轮询分配,这里是全部收集起来之后,就给每个并行任务发送完整的列表,让子任务自行选择该使用和丢弃哪些状态,整体来说,效率不如列表状态。
(3)广播状态(BroadcastState)
联合列表状态是在并行度缩放的时候对状态的联合重组,广播状态是直接将大列表状态发送给每一个分区,这样每个分区都会收到同样的状态信息。
算子状态的状态保持
按键分区状态是按照key来对状态进行区分的,就是哪个key就对应哪个状态,但是算子状态是针对整个并行任务的,里面的列表项具体对应哪个分区的话是不确定的,这里就需要将信息保存下来。
(1)CheckpointFunction接口
在 Flink中,对状态进行持久化保存的快照机制叫作“检查点”(Checkpoint)。于是使用算子状态时,就需要对检查点的相关操作进行定义,实现一个 CheckpointedFunction 接口。
public interface CheckpointedFunction {
// 保存状态快照到检查点时,调用这个方法
void snapshotState(FunctionSnapshotContext context) throws Exception
// 初始化状态时调用这个方法,也会在恢复状态时调用
void initializeState(FunctionInitializationContext context) throws Exception;
}
对于算子状态的保存,都是调用快照保存的方法snapshotState(),对于算子状态的恢复调用的是initializeState()。其实这里的初始化需要考虑两个情况,一是整个程序第一次运行时,状态只需初始化即可;二是程序是在错误中恢复的,这时需要先读取算子状态的数据,再加载进来。
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
// 用状态描述器注册状态
ListStateDescriptor<Event> descriptor = new ListStateDescriptor<>(
"buffered-elements",
Types.POJO(Event.class));
// 获取状态信息,就是初始化状态
checkpointedState = context.getOperatorStateStore().getListState(descriptor);
// 如果是从故障中恢复,就将ListState中的所有元素添加到局部变量中,恢复状态
if (context.isRestored()) {
for (Event element : checkpointedState.get()) {
bufferedElements.add(element);
}
}
}
其中.getListState()是获取平均分割重组,.getUnionListState()是获取联合列表状态,.isRestored()是判断是否从故障中恢复。
4、广播状态
算子状态中有一类特殊的就是广播状态,广播状态好理解就是向下游的每一个子任务都发送一份同样的状态数据。一般普遍的应用就是用作动态配置,因为服务一旦开启是想连续不断运行的,如果这时想要修改配置,那是不是就要关掉重启服务呢,这样的话对业务的影响就比较大了。其实通过广播的方式将配置分发给每一个子任务,再收到配置的时候,先保存配置的状态,再对配置进行更新往往是一个非常不错的方法。广播状态底层都是以健值(key-value)的方式来存储的。
在代码上,可以直接调用DataStream的.broadcast()方法,传入一个映射状态描述器MapStateDescriptor说明状态的名称和类型,就可以得到一个广播流BroadcastStream;进而将要处理的数据流与这条广播流进行连接connect , 就会得到广播连接流BroadcastConnectedStream。注意广播状态只能用在广播连接流中。
// 定义广播状态的描述器,创建广播流
MapStateDescriptor<Void, Pattern> bcStateDescriptor = new MapStateDescriptor<>(
"patterns", Types.VOID, Types.POJO(Pattern.class));
BroadcastStream<Pattern> bcPatterns = patternStream.broadcast(bcStateDescriptor);
// 将事件流和广播流连接起来,进行处理
DataStream<Tuple2<String, Pattern>> matches = actionStream
.keyBy(data -> data.userId)
.connect(bcPatterns)
.process(new PatternEvaluator());
对于广播连接流调用 .process() 方法 ,可以传入广播处理函数,KeyedBroadcastProcessFunction 或者BroadcastProcessFunction 来进行处理计算。广播处理函数里面有两个方法.processElement()和.processBroadcastElement(),.processElement()方法用来处理普通数据,.processBroadcastElement()方法用来处理广播数据,广播数据只能读取不能修改。
5、状态持久化和状态后端
Flink中的状态都是保存在内存中的,虽然方便使用,但是在发生故障时,是无法恢复的。所以需要将状态持久化,就是保存到磁盘中,而关于状态的存储、访问以及维护的组件就叫做状态后端。
检查点(Checkpoint)
检查点其实就是所有任务在某一时刻的状态快照,就是将某一时刻的状态保存到磁盘中,发生故障后可以从该磁盘中时间最近的检查点恢复状态的信息数据。默认情况下,检查点是禁用的,直接调用执行环境的.enableCheckpointing()方法就可以开启检查点。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getEnvironment();
env.enableCheckpointing(10000);
这里.enableCheckpointing()传入的参数是检查点的间隔时间,单位为毫秒,意为每隔多长时间保存一次检查点。
此外,Flink海提供了保存点Savepoint,形式和原理上跟检查点都是一样的,区别在于,保存点是自定义的镜像保存,通常用于有计划的停止或者重启服务时的状态数据保存。
状态后端(State Backends)
检查点的流程大致如下:首先会由 JobManager 向所有 TaskManager 发出触发检查点的命令;TaskManger 收到之后,将当前任务的所有状态进行快照保存,持久化到远程的存储介质中;完成之后向 JobManager 返回确认信息。
Flink提供了两类不同的状态后端,一种是哈希表状态后端(HashMapStateBackend),另一种是内嵌 RocksDB状态后端(EmbeddedRocksDBStateBackend),系统默认的状态后端是HashMapStateBackend。
(1)哈希表状态后端(HashMapStateBackend)
哈希表状态后端在内部直接把状态当成对象处理,所有状态都会以键值对(key-value)的形式存储起来,所以在底层就是一个哈希表。对于检查点的保存,一般是放在持久化的分布式文件系统(file system)中,也可以通过配置“检查点存储”(CheckpointStorage)来另外指定。
哈希表状态后端是将本地状态全部放入内存的,这样可以获得最快的读写速度,使计算性能达到最佳;代价则是内存的占用。它适用于具有大状态、长窗口、大键值状态的作业, 对所有高可用性设置也是有效的。
(2)内嵌RocksDB状态后端(EmbeddedRocksDBStateBackend)
RocksDB 是一种内嵌的 key-value 存储介质,可以把数据持久化到本地硬盘。配置EmbeddedRocksDBStateBackend 后,会将处理中的数据全部放入 RocksDB 数据库中,RocksDB 默认存储在TaskManager 的本地数据目录里。数据被存储为序列化的字节数组(Byte Arrays),读写操作需要序列化/反序列化,因此访问要稍微慢一点。对于检查点,同样会写入到远程的持久化文件系统中。EmbeddedRocksDBStateBackend 始终执行的是异步快照,也就是不会因为保存检查点而阻塞数据的处理。
(3)状态后端的选择
哈希表后端和RocksDB后端的区别就是本地状态存放在哪里,前者是内存,后者是RocksDB。哈希表后端的内存读取很快,但当状态量非常大时,且状态在不断增长时,会很快将内存耗尽;RocksDB后端是将本地状态存储在RocksDB中的,读取会比哈希表慢一点,但可以根据硬盘空间扩展RocksDB的容量,且硬盘的资源经济效益上面比内存要便宜很多。具体使用哪种状态后端看使用的情况而定。
(4)状态后端的配置
在配置文件flink-conf.yaml中,配置的键名称为:state.backend,可以配置状态后端的类型和检查点的保存路径:
# 默认状态后端
state.backend: hashmap
# 存放检查点的文件路径
state.checkpoints.dir: hdfs://namenode:40010/flink/checkpoints
也能在代码中单独为每个作业单独配置状态后端:
// 配置哈希表状态后端
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(new HashMapStateBackend());
// 配置RocksDB状态后端
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(new EmbeddedRocksDBStateBackend());
需要配置RocksDB状态后端时,需要在Flink项目中添加依赖:
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-statebackend-rocksdb_${scala.binary.version}</artifactId>
<version>1.13.0</version>
</dependency>
Flink版本默认都有对RocksDB状态后端的支持,如果实在IDEA中使用的话,需要配置依赖;如果只是在配置文件中配置的话,不用配置依赖,Flink程序也能正常运行。