第一章 Flink 简介
第二章 Flink 环境部署
第三章 Flink DataStream API
第四章 Flink 窗口和水位线
第五章 Flink Table API&SQL
第六章 新闻热搜实时分析系统
在流式处理的过程中,数据是在不同的节点间不停流动的;这样一来,就会有网络传输的延迟,当上下游任务需要跨节点传输数据时,它们对于“时间”的理解也会有所不同。
当基于特定时间段(通常称为Windows,窗口),或者当执行事件处理时,事件的时间发生很重要。
通常来说,处理时间是我们计算效率的衡量标准,而事件时间会更符合我们的业务计算逻辑。所以更多时候我们使用事件时间;不过处理时间也不是一无是处。对于处理时间而言,由于没有任何附加考虑,数据一来就直接处理,因此这种方式可以让我们的流处理延迟降到最低,效率达到最高。
支持事件时间的流处理器需要一种方法来衡量事件时间的进度。例如,每小时构建一次窗口运算符,当事件时间超过结束时间时,需要通知窗口以便操作员可以关闭正在进行的窗口。
Flink 中衡量事件时间进度的机制是水位线,水位线作为数据流的一部分,随着数据一起流动,在不同的任务之间传输,并带有时间戳t。专门用于处理数据中的乱序问题,通常会结合窗口使用。
如上图所示,每个事件产生的数据,都包含了一个时间戳,用了一个整数表示。当产生于7秒的数据到来之后,当前的事件时间就是7秒;在后面插入一个时间戳也为7秒的水位线W(7)
,随着数据一起向下游流动。这样如果出现下游有多个并行子任务的情形,我们只要将水位线广播出去,就可以通知到所有下游任务当前的时间进度了。
水位线,其实就是流中的一个周期性出现的时间标记,水位线插入的“周期”,本身也是一个时间概念,它指的是处理时间(系统时间),而不是事件时间。
水位线对于无序流至关重要,如上图所示,其中事件不按其时间戳排序。一般来说,水位线是一种声明,即在流中的该点之前,直到某个时间戳的所有事件都应该已经到达。一旦水位线到达操作员,操作员可以将其内部事件时钟提前到水位线的值
当使用事件时间窗口时,可能会发生元素延迟到达的情况,即Flink用来跟踪事件时间进展的水位线已经超过了元素所属窗口的结束时间戳。默认情况下,当水位线超过窗口的结束时间戳时,将删除晚到的元素。但是,Flink允许为窗口操作符指定最大允许延迟,允许延迟指定元素在被删除之前可以延迟多少时间,其默认值为0。如果元素在窗口的结束时间戳 + 最大允许延迟之前到达,元素仍旧被添加到窗口。
假设设置的允许最大延迟时间为3分钟,当事件时间戳为9:11的事件到达时,由于该事件时间是进入Flink的当前最大事件时间,因此Watermark=9:11‒3分钟=9:08。此时水位线在窗口内部不会触发窗口计算,窗口继续等待延迟数据,接下来当事件时间戳为9:15的事件到达时,由于该事件时间是进入Flink的当前最大事件时间,因此Watermark=9:15‒3分钟=9:12。此时水位线在窗口外部,满足窗口触发计算的规则:Watermark>=窗口结束时间,因此窗口会立即触发计算,计算完毕后发射出计算结果并销毁窗口。
在不设置水位线的情况下,当数据C到达时,由于C的事件时间大于窗口结束时间,窗口已经关闭,因此后面的数据B和数据A虽然属于该窗口,但是不会被计算,将被丢弃。
设置水位线后,假设允许最大延迟时间为5分钟,当数据C到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:11‒5分钟=9:06<窗口结束时间,因此窗口不会被触发计算。而数据C的事件时间大于窗口结束时间,数据C不属于该窗口,将属于下一个窗口。
当数据B到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:11‒5分钟=9:06 <窗口结束时间,因此窗口不会被触发计算。窗口开始时间<=数据B的事件时间<窗口结束时间,数据B属于该窗口。
当数据D到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:15‒5分钟=9:10=窗口结束时间,此时窗口触发计算。而数据D的事件时间大于窗口结束时间,数据D不属于该窗口,将属于下一个窗口。
整个过程中,由于设置了水位线,数据B没有丢失,数据A虽然属于该窗口,但当数据A到达时窗口已经触发了计算,因此数据A将丢失。这说明水位线机制可以在一定程度上解决数据延迟到达问题,但不能完全解决。如果希望数据A不丢失,可以延长允许的最大延迟时间,但是这样可能会增加不必要的处理延迟。
对于一些使用水印解决不了的、更为严重的延迟数据,Flink默认是丢弃的,为了保证数据不丢失,Flink提供了允许延迟(Allowed Lateness)和侧道输出机制(Side Output)。
- 允许延迟使用
allowedLateness(lateness: Time)
方法设置延迟时间,该方法只对事件时间窗口有效。允许延迟机制不会延迟窗口触发计算,而是窗口触发计算之后不会销毁,保留计算状态,继续等待一段时间。- 侧道输出使用
sideOutputLateData(outputTag: OutputTag[T])
方法将延迟到达的数据保存到outputTag对象中,后面可通过getSideOutput(outputTag)
方法得到被丢弃数据组成的数据流。
Flink 中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把控制的权力交给了开发人员,我们可以在代码中定义水位线的生成策略。
原始的时间戳只是写入日志数据的一个字段,如果不提取出来并明确把它分配给数据, Flink 是无法知道数据真正产生的时间的。用于生成水位线的方法是.assignTimestampsAndWatermarks()
,它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间。该方法需要传入一个 WatermarkStrategy
作为参数,这就是所谓的“水位线生成策略”。WatermarkStrategy
中包含了一个“时间戳分配器”TimestampAssigner
和一个“水位线生成器”WatermarkGenerator
。
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
// 设置水印的生命周期,默认是200毫秒,即每个200毫秒生成一次水印
env.getConfig.setAutoWatermarkInterval(200)
// 设置水位线生成策略
dataStream.assignTimestampsAndWatermarks(new WatermarkStrategy[Student]{
// 水位线生成器
override def createWatermarkGenerator(context: WatermarkGeneratorSupplier.Context): WatermarkGenerator[Student] = ???
// 时间戳分配器
override def withTimestampAssigner(timestampAssigner: SerializableTimestampAssigner[Student]): WatermarkStrategy[Student] = super.withTimestampAssigner(timestampAssigner)
})
forMonotonousTimestamps(单调时间戳)
:主要针对有序流,有序流的主要特点就是时间戳单调增长,所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接拿当前最大的时间戳作为水位线就可以了。forBoundedOutOfOrderness(有界乱序数据)
:主要针对乱序流,由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。这个方法需要传入一个 maxOutOfOrderness
参数,表示“ 最大乱序程度 ” ,它表示数据流中乱序数据时间戳的最大差值。noWatermarks(无水位线)
不需要水位线生成策略 ///
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.noWatermarks[Student]())
有序流水位线生成策略 ///
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy
.forMonotonousTimestamps()
// .withTimestampAssigner((element: Student, recordTimestamp: Long) => element.createTime))
.withTimestampAssigner(new SerializableTimestampAssigner[Student] {
override def extractTimestamp(element: Student, recordTimestamp: Long): Long = element.createTime
})
)
// 升序的时间戳水位线(并行度是1的情况可以使用)
dataStream.assignAscendingTimestamps(_.createTime)
/ 无序流水位线生成策略 //
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy
.forBoundedOutOfOrderness(Duration.ofMillis(100L))
// .withTimestampAssigner((element: Student, recordTimestamp: Long) => element.createTime))
.withTimestampAssigner(new SerializableTimestampAssigner[Student] {
override def extractTimestamp(element: Student, recordTimestamp: Long): Long = element.createTime
})
)
Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”。
我们可以把窗口想象成一个固定位置的“框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输出结果,最后清空窗口再次收集计算。
窗口可以是时间驱动的(每10秒)或者是数据驱动的(每3个元素),如下图所示:
时间窗口和计数窗口,只是对窗口的一个大致划分,在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同分配数据的方式,就可以有不同的功能应用。
根据分配数据的规则,窗口的具体实现可以分为4类:滚动窗口(Tumbling Window
)、滑动窗口(Sliding Window
)、会话窗口(Session Window
),以及全局窗口(Global Window
)。
在定义窗口操作之前,首先需要确定是基于按键分区(Keyed
)的数据流KeyedStream
来开窗,还是直接在没有按键分区的DataStream
上开窗。简单理解就是在调用窗口算子之前,是否有keyBy()
操作。
(1) 按键分区窗口(Keyed Windows)
经过按键分区keyBy()
操作后,数据流会按照key
被分为多条逻辑流,窗口计算会在多个并行子任务上同时执行,每个key
上都定义了一组窗口,各自独立地进行统计计算。在代码实现上,我们需要先对DataStream
调用keyBy()
进行按键分区,然后再调用window()
定义窗口。
stream
.keyBy(...) <- 按照一个Key进行分组
.window(...) <- 必填项:"将数据流中的元素分配到相应的窗口中"
[.trigger(...)] <- 可选项:"指定触发器Trigger" (省略则使用默认 trigger) 定义window什么时候关闭,触发计算并输出结果
[.evictor(...)] <- 可选项:"指定清除器Evictor" (省略则不使用 evictor) 定义移除某些数据的逻辑
[.allowedLateness(...)] <- 可选项:"lateness" (省略则为 0) 允许处理迟到的数据
[.sideOutputLateData(...)] <- 可选项:"output tag" (省略则不对迟到数据使用 side output) 将迟到的数据数据放入侧输出流
.reduce/aggregate/apply() <- 必填项:"窗口处理函数Window Function" 处理算子
[.getSideOutput(...)] <- 可选项:"output tag" 获取侧输出流
(2) 非按键分区(Non-Keyed Windows)
如果没有进行keyBy()
,原始的DataStream
不会被分割为多个逻辑上的DataStream
, 所以所有的窗口计算会被同一个Task
完成,也就是Parallelis
为1,所以在实际应用中一般不推荐使用这种方式。在代码中,直接基于DataStream
调用windowAll()
定义窗口。
stream
.windowAll(...) <- 必填项:"assigner"
[.trigger(...)] <- 可选项:"trigger" (else default trigger)
[.evictor(...)] <- 可选项:"evictor" (else no evictor)
[.allowedLateness(...)] <- 可选项:"lateness" (else zero)
[.sideOutputLateData(...)] <- 可选项:"output tag" (else no side output for late data)
.reduce/aggregate/apply() <- 必填项:"function"
[.getSideOutput(...)] <- 可选项:"output tag"
这里需要注意的是,对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll
本身就是一个非并行的操作。
Window Assigner 定义了Stream中的元素如何被分发到各个窗口。 通过算子window(...)
或 windowAll(...)
中指定一个WindowAssigner
。 Flink 为最常用的情况提供了一些定义好的窗口分配器,也就是tumbling windows
、 sliding windows
、 session windows
和global windows
。 也可以继承 WindowAssigner
类来实现自定义的窗口分配器。
.countWindow()
的方式调用。滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。定义滚动窗口的参数只有一个,就是窗口大小。
滚动窗口-窗口分配器使用语法
val source: DataStream[T] = ...
// 基于计数的滚动窗口
source
.keyBy()
.countWindow(4L) // 窗口大小为4个元素
.()
// 基于事件时间的滚动窗口
source
.keyBy()
.window(TumblingEventTimeWindows.of(Time.minutes(1))) // 窗口大小为1分钟
.()
// 基于处理时间的滚动窗口
source
.keyBy()
.window(TumblingProcessingTimeWindows.of(Time.minutes(1))) // 窗口大小1分钟
.()
// 滚动窗口分配器还可以使用可选的偏移量(Offset)参数,用于更改窗口的对齐方式。比如以1小时为单位的窗口流,但是窗口需要从每小时的第15分钟开始,则可以设置偏移量。
TumblingEventTimeWindows.of(Time.hours(1), Time.minutes(15))
案例:每隔5秒钟统计一次用户的访问量
// Event类
case class Event(name: String, url: String, timestamp: Long)
class EventSource extends SourceFunction[Event] {
private var running = true
private val names = Array("张三丰", "张无忌", "赵敏", "貂蝉", "赵云")
private val url = Array("/index", "/product/detail", "/product?id=1", "/product?id=2", "/product?id=3", "/about")
// 产生数据的方法
override def run(sourceContext: SourceFunction.SourceContext[Event]): Unit = {
while (running) {
val randomEvent = Event(names(Random.nextInt(names.length)), url(Random.nextInt(url.length)), Calendar.getInstance().getTimeInMillis)
// 把产生的数据进行收集发送
sourceContext.collect(randomEvent)
// 每隔1秒钟产生一条event数据
Thread.sleep(1000L)
}
}
// 停止执行自动调用方法
override def cancel(): Unit = running = false
}
基于处理事件的计算
object TumblingProcessingTimeWindowWindowTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
env.addSource(new EventSource)
.map(item => (item.name, 1))
.keyBy(_._1)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5))) // 使用处理时间
.sum(1)
.print()
env.execute()
}
}
基于事件事件的处理
object TumblingEventTimeWindowTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
env.addSource(new EventSource)
.assignTimestampsAndWatermarks(WatermarkStrategy
.forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner[Event] { // 指定水位线生成策略
override def extractTimestamp(element: Event, recordTimestamp: Long): Long = element.timestamp
}))
// .withTimestampAssigner((element: MyRandomEventSource.UserClick,timestamp: Long)=>element.clickTime)
// 对于有序数据的简化版
// .assignAscendingTimestamps(uc => uc.clickTime)
.map(item => (item.name, 1))
.keyBy(_._1)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.sum(1)
.print()
env.execute()
}
}
滑动窗口与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以“错开”一定的位置,向前滑动。具体每一步滑多远,是可以控制的。所以定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个滑动步长(window slide),代表了窗口计算的频率。
滑动窗口-窗口分配器使用语法
val source: DataStream[T] = ...
// 基于计数的滑动窗口,窗口大小为4个元素,滑动步长为2个元素
source
.keyBy()
..countWindow(4L, 2L)
.()
// 基于事件时间的滑动窗口,窗口大小为10秒,滑动步长为5秒
source
.keyBy()
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.()
// 基于处理时间的滑动窗口,窗口大小为10秒,滑动步长为5秒
source
.keyBy()
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.()
// 与滚动窗口一样,滑动窗口分配器也有一个可选的偏移量参数,可以用来改变窗口的对齐方式。如果系统时间基于UTC-0(世界标准时间),中国当地时间是UTF+08:00 ,就需要设置偏移量为Time.hours(-8)
SlidingEventTimeWindows.of(Time.hours(12), Time.hours(1), Time.hours(-8))
每隔2秒钟统计用户在10秒内的访问次数
object SlidingEventTimeWindowTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
env.addSource(new EventSource)
.assignAscendingTimestamps(event => event.timestamp)
.map(item => (item.name, 1))
.keyBy(_._1)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(2)))
.sum(1)
.print()
env.execute()
}
}
会话窗口顾名思义,是基于会话(session)来对数据进行分组的,根据会话间隙(Session Gap)切分不同的窗口,当一个窗口在大于会话间隙的时间内没有接收到新数据时,窗口将关闭。会话窗口分配器可以配置静态会话间隔,也可以根据业务逻辑自定义动态会话间隔,该功能定义不活动的时间长度。当该时间的到期时,当前会话窗口将关闭,随后的事件将分配给新的会话窗口。在这种模式下,窗口的长度是可变的,每个窗口的开始和结束时间并不是确定的。需要注意的是如果数据一直不间断地进入窗口,也会导致窗口始终不触发的情况。
创建动态间隔会话窗口,需要实现SessionWindowTimeGapExtractor
接口,并实现其中的extract()
方法,可以在该方法中加入相应的业务逻辑来动态控制会话间隔。
全局窗口将整个输入看作一个简单的窗口,因为Flink是基于事件的流式无界处理,所以我们需要指定一个“触发器Trigger”才能触发窗口执行聚合运算,否则他不会进行任何运算,而全局窗口的默认触发器是永不触发的NeverTrigger
。
事件被窗口分配器分配到窗口后,接下来需要指定想要在每个窗口上执行的计算函数,以便对窗口内的数据进行处理。Flink提供的窗口函数有:ReduceFunction
、AggregateFunction
、ProcessWindowFunction
。
ReduceFunction
和AggregateFunction
是增量计算函数,都可以基于中间状态对窗口中的元素进行递增聚合。例如,窗口每流入一个新元素,新元素就会与中间数据进行合并,生成新的中间数据,再保存到窗口中。ProcessWindowFunction
是全量计算函数,与增量聚合函数不同,全量计算函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。使用它还可以获取窗口中的状态数据和窗口元数据(窗口开始时间、结束时间等)。在效率上肯定是不如增量计算的,不过在需要依赖窗口所有做计算的时候它就非常灵活,例如对整个窗口数据排序取TopN。ReduceFunction指定如何聚合输入中的两个元素以产生相同类型的输出元素。Flink使用ReduceFunction递增聚合窗口的元素。
每隔5秒钟统计一次用户的访问量
object WindowFunctionTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
val source = env.addSource(new MyRandomEventSource)
source
.map(item => (item.name, 1))
.keyBy(_._1)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5L)))
//.sum(1)
.reduce((state, data) => (state._1, state._2 + data._2))
.print("每个5秒统计一次用户访问的页面数")
env.execute()
}
}
该聚合函数有一个限制就是输入和输出的类型需要保持一致。
AggregateFunction是聚合函数的基本接口,也是ReduceFunction的通用版本。与ReduceFunction相同,Flink将在窗口输入元素到达时对其进行增量聚合。
AggregateFunction的泛型具有3种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。
AggregateFunction是一种灵活的聚合函数,具体有以下特点:
AggregateFunction接口源码解析
/**
* @param 聚集的值的类型(输入值)
* @param 累加器的类型(中间聚合状态)
* @param 聚合结果的类型
*/
@PublicEvolving
public interface AggregateFunction extends Function, Serializable {
// 创建新的累加器,开始新的聚合
ACC createAccumulator();
// 将给定输入值与给定累加器相加,返回新的累加器值
ACC add(IN var1, ACC var2);
// 从累加器获取聚合结果
OUT getResult(ACC var1);
// 合并两个累加器,返回具有合并状态的累加器(窗口合并的使用使用)
ACC merge(ACC var1, ACC var2);
}
每隔2秒钟统计用户在10秒内的平均访问次数
object AggregateFunctionTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
val source = env.addSource(new MyRandomEventSource)
source
.keyBy(item => "default") //全部分到default组中
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(2)))
.aggregate(new AverageAggregateFunction)
.print("每隔2秒钟统计用户在10秒内的平均访问次数")
env.execute()
}
class AverageAggregateFunction extends AggregateFunction[RandomEvent, (Long, Set[String]), Double] {
// 初始化累加器
override def createAccumulator(): (Long, Set[String]) = (0L, Set[String]())
// (点击次数,用户数)
override def add(value: RandomEvent, accumulator: (Long, Set[String])): (Long, Set[String]) = (accumulator._1 + 1L, accumulator._2 + value.name)
// 点击次数/用户数
override def getResult(accumulator: (Long, Set[String])): Double = accumulator._1.toDouble / accumulator._2.size
// 合并累加器(只会对会话窗口生效,其他窗口不会调用,这里就是用默认实现)
override def merge(a: (Long, Set[String]), b: (Long, Set[String])): (Long, Set[String]) = ???
}
}
ProcessWindowFunction
使用ProcessWindowFunction可以获得一个包含窗口所有元素的可迭代对象(Iterable),以及一个可以访问时间和状态信息的上下文对象(Context),这使得它比其他窗口函数提供了更多的灵活性。这种灵活性是以性能和资源消耗为代价的,因为元素不能递增聚合,而是需要在调用处理函数之前在内部缓冲窗口中的所有元素,直到认为窗口已经准备好进行处理。
每隔10秒钟统计URL的访问量
object ProcessWindowFunctionTest {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
val source = env.addSource(new MyRandomEventSource)
source.keyBy(item => item.url)
.window(TumblingProcessingTimeWindows.of(Time.seconds(10)))
.process(new UrlCountProcessWindowFunction)
.print()
env.execute()
}
class UrlCountProcessWindowFunction extends ProcessWindowFunction[RandomEvent, String, String, TimeWindow] {
private val sdf = new SimpleDateFormat("HH:mm:ss")
override def process(key: String, context: Context, elements: Iterable[RandomEvent], out: Collector[String]): Unit = {
// 在上下文中获取窗口元数据
val startTime = sdf.format(new Date(context.window.getStart))
val endTime = sdf.format(new Date(context.window.getEnd))
// 收集计算结果并发射出去
out.collect(s"在【$startTime ~ $endTime】期间\t$key\t的访问量为:${elements.size}")
}
}
}