window+trigger+watermark处理全局乱序数据,指定窗口上的allowedLateness可以处理特定窗口操作的局部事件时间乱序数据
1、流处理系统中的微批
Flink内部也使用了某种形式的微批处理技术,在shuffle阶段将含有多个事件的缓冲容器通过网络发送,而不是发送单个事件
流处理系统中的批处理必须满足以下两点要求:
- 批处理只作为提高系统性能的机制。批量越大,系统的吞吐量就越大。
- 为了提高性能而使用的批处理必须完全独立于定义窗口时所用的缓冲,或者为了保证容错性而提交的代码,也不能作为 API 的一部分。否则,系统将受到限制,并且变得脆弱且难以使用。
2、时间概念
- 事件时间,即事件实际发生的时间(由水印触发器实现),基于事件时间处理可实现时间回溯并正确地重新处理数据
- 处理时间,即事件被处理的时间,是处理事件的机器所测量的时间
- 摄取时间,即事件进入流处理框架的时间,缺乏事件时间的数据会被处理器附上摄取时间(由source函数完成)
3、窗口
所有内置窗口都由同一种机制实现,开窗机制与检查点机制完全分离;可直接用基本的开窗机制定义更复杂的窗口(如某种时间窗口,可基于元素计数生成中间结果)
窗口时间区间是按自然时间分配的,比如3秒的时间间隔,[0,3) [3,6)
(1)时间窗口(每隔B时长对A时长内数据聚合)
- 设置事件时间 env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
- 设置处理时间 env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)
- 设置摄取时间 env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime)
- 滚动窗口A stream.timeWindow(Time.minutes(1)) stream.window(TumblingEventTimeWindows.of(Time.seconds(1)))
- 滑动窗口B stream.timeWindow(Time.minutes(1), Time.seconds(30)) stream.window(TumblingEventTimeWindows.of(Time.seconds(1)), SlidingEventTimeWindows.of(Time.seconds(30)))
(2)计数窗口(每隔B个元素对A个元素进行聚合)
为避免永远达不到计数窗口而浪费内存,可用时间窗口触发超时
- 滚动窗口A stream.countWindow(4)
- 滑动窗口B stream.countWindow(4, 2)
(3)会话窗口(会话即活动阶段,其前后都是非活跃阶段,常用于无固定持续时间或无固定交互次数的场景)
由超时时间设定,即希望非活跃状态持续多久才结束窗口。window区间:当b比上一条记录a延迟超过超时时间t时,出现会话窗口[上一个window_end, b-t)
- 事件时间会话窗口 stream.window(EventTimeSessionWindows.withGap(Time.minutes(5))
- 处理时间会话窗口 stream.window(ProcessingTimeSessionWindows.withGap(Time.minutes(5))
处理延迟数据
- allowedLateness(Time.minutes(60))
缩短反馈时间(若用户会话迟迟不结束,反馈时间过长)
- trigger(ContinuousEventTrigger.of(Time.minutes(10)) #每10分钟输出一个结果并覆盖之前的
(4)全局窗口(对全部数据进行统计,使用流方法实现批处理)
内置触发器是NeverTrigger,永远不会触发,需要自定义触发器才有意义
stream.window(GlobalWindows.create()).trigger(...)
4、触发器
继承Trigger类
Trigger抽象类的结构:
boolean canMerge()
void clear(W window, TriggerContext ctx)
TriggerResult onElement(T element, long timestamp, W window, TriggerContext ctx) 每个元素到来时执行
TriggerResult onEventTime(long time, W window, TriggerContext ctx) Timer到期后执行
void onMerge(W window, OnMergeContext ctx)
TriggerResult onProcessingTime(long time, W window, TriggerContext ctx)
5、水印
水印的语义是认为比水印早的消息都已消费
窗口 + 水印,用于解决乱序问题(并不是解决,而是假定所有正常的事件都只是一定程度内乱序,可以解决此程度内的乱序)
(1)基于事件时间处理时的水印处理逻辑
水印是判断所有事件到达的标志,开始计算和输出结果,晚于处理时间但早于此水印时间的事件也可被正确处理。
水印定义最长迟到数据(比当前watermark还早的数据会被丢弃,水印阈值越大,允许的迟到数据越久)
watermark的值不是全局的,但与key无关,有几个并行,就有几个watermark,window的触发条件与最小的watermark有关
水印时间 = 收到的最大事件时间 - 水印阈值
一个操作算子收到多个并行流的输入时,取最小的watermark作为当前算子的watermark
(2)异常情况
如果水印迟到得太久(可能是maxOrderness设置太大,也可能是后序事件过晚到达),收到结果的速度会变慢,解决方法是在水印到达之前输出近似结果,其实就是后面设置Lateness的方案;如果水印到达得太早(可能是maxOrderness设置太小,也可能是后序事件过早到达),则可能丢失一些前序事件,收到错误结果,解决方法是采用Flink作业监控事件流,学习事件的迟到规律,以此构建水印模型
(3)分配Timestamp和Watermark
timestamp和watermark都是通过从1970年1月1日0时0分0秒到现在的毫秒数来指定的
先后顺序:分配timstamp是按设置的时间间隔定时执行的,即使无数据进来也会执行,这就造成了getCurrentWatermark调用后看上去第一个watermark永远是以0为基准计算显示的 ,但实际并不是按那个算的。第2条的watermark如果是23的话,是不大于window_end 24的,也就不应该触发,而如果是下一条的24则可以触发。AssignerWithPeriodicWatermarks子类是每隔一段时间执行的,这个具体由ExecutionConfig.setAutoWatermarkInterval设置,如果没有设置会几乎没有间隔地调用getCurrentWatermark方法。之所以会出现-10000时因为你没有数据进入窗口,当然一直都是-10000,但是getCurrentWatermark方法不是在执行extractTimestamp后才执行的
直接在数据源生成(推荐,数据生成时即分配timestamp和watermark)
实现SourceFuntion接口的run方法,并调用如下方法:
- 分配timestamp:SourceContext.collectWithTimestamp(...)
- 分配watermark:SourceContext.emitWatermark(new WaterMark(...))
获取流后使用生成器生成新流(使用此种方式,会覆盖源提供的timestamp和watermark,注意一定要在时间窗口之前生成)
stram.assignTimestampsAndWatermarks( AssignerWithPeriodicWatermarks/AssignerWithPunctuatedWatermarks 实现类对象)
定义分配器
AssignerWithPeriodicWatermarks(周期性水印,分配时间戳并定期生成水印)
watermark产生的事件间隔(每n毫秒)是通过ExecutionConfig.setAutoWatermarkInterval(...)来定义的,当getCurrentWatermark()被调用时,若返回的watermark非空且大于上一个watermark,则发射一个新的watermark
- 预定义实现类(使用时重写extractTimestamp):
- AscendingTimestampExtractor 适用于时间戳递增的情况
- BoundedOutOfOrdernessTimestampExtractor 适用于乱序但最大延迟已知的情况
- 自定义实现类(使用时重写getCurrentWatermark、extractTimestamp)
AssignerWithPunctuatedWatermarks(带断点水印)
事件驱动生成水印,每个单独的event都可以产生一个watermark,会有额外计算,过多可能导致性能降低。任何一个event都触发extractTimestamp(...)来为元素分配一个timestamp,然后立即调用该元素上的checkAndGetNextWatermark(...)方法,一旦checkAndGetNextWatermark(...)返回一个非空的watermark并且watermark比前一个watermark大的话,这个新的watermark将会被发送
(4)设定水印后触发window的条件:
- watermark >= window_end(开启多并发后,每个算子接收到的watermark都会进行对齐,取最小的watermark作为最终的watermark并往下一个算子发送)
- 在[window_begin, window_end)中有数据存在
(5)不足之处
无法应对迟到数据,如果一个窗口已经被触发了,即使满足上述条件也不会第二次触发窗口。水印被发射到下一个算子前已默认比水印更早的数据已经全部处理了
6、allowedLateness
主要用于解决迟到问题,给迟到数据第二次或多次触发window的机会,可对无法触发window的迟到数据单独处理
默认情况下,watermark超过end-of-window后,将忽略之后到达的符合window的数据
在Watermark < 窗口结束时间 + Lateness时,仍会继续等待窗口内的元素参与窗口计算,计算时要注意状态值的重复,直到Watermark >= 窗口结束时间 + Lateness 时清空缓存
要注意再次触发窗口时,UDF中的状态值的处理,要考虑state在计算时的去重问题
(1)
- 对于trigger是默认的EventTimeTrigger的情况,allowedLateness会再次触发窗口的计算,而之前触发的数据,会buffer起来,直到watermark超过end-of-window + allowedLateness的时间,窗口的数据及元数据信息才会被删除。再次计算就是DataFlow模型中的Accumulating的情况。
- 对于sessionWindow情况,当late element在allowedLateness范围之内到达时,可能会引起窗口的merge,这样,之前窗口的数据会在新窗口中累加计算,这就是DataFlow模型中的AccumulatingAndRetracting的情况。
(2)触发条件
- watermark < window_end + allowedLateness
- 在[window_begin, window_end)中有late数据存在
7、定时器Timer
Flink Streaming API提供的用于感知并利用处理时间/事件时间变化的机制
Timer会由Flink按key+timestamp自动去重的,也就是说如果你的key有N个,并且注册的timestamp相同的话,那么实际只会注册N个Timer
(1)在KeyedProcessFunction实现类里定义定时器:
重写processElement(),对每个输入元素注册定时器,但会自动去重
重写onTimer(),定时器触发时执行的逻辑
根据时间特征的不同,具体如下:
处理时间——调用Context.timerService().registerProcessingTimeTimer()注册;onTimer()在系统时间戳达到Timer设定的时间戳时触发。
事件时间——调用Context.timerService().registerEventTimeTimer()注册;onTimer()在Flink内部水印达到或超过Timer设定的时间戳时触发。
(2)EventTimeTrigger使用Timer实现触发时间窗口
@Override public TriggerResult onElement(Object element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception { if (window.maxTimestamp() <= ctx.getCurrentWatermark()) { return TriggerResult.FIRE; } else { ctx.registerEventTimeTimer(window.maxTimestamp()); return TriggerResult.CONTINUE; } }
(3)接口
TimerService接口是用来处理时间和定时器的,根据time确定一个唯一的定时器
InternalTimerService接口是TimerService接口的内部版本,根据time和namespace来确定一个唯一的定时器。有实现类InternalTimerServiceImpl
currentProcessingTime 获取当前处理时间 currentWatermark 获取当前水印 deleteEventTimeTimer 删除某time和namespace对应的定时器deleteProcessingTimeTimer 删除某time和namespace对应的定时器 void registerEventTimeTimer(N namespace, long time) 水印达到给定time时注册特定的定时器 registerProcessingTimeTimer 处理时间达到给定time时注册特定的定时器
InternalTimerServiceImpl有两个关键字段processingTimeTimersQueue和eventTimeTimersQueue,分别存储in-flight中的处理定时器、事件定时器,注册Timer实际上就是为它们赋予对应的时间戳、key和命名空间,并将它们加入对应的优先队列,这两个优先队列是按Timer时间戳为关键字排序的最小堆,。继承HeapPriorityQueueSet实现优先级队列功能,在Java自带的PriorityQueue基础上加入按key去重逻辑。
注册:KeyGroup是Flink内部KeyedState的原子单位,亦即一些key的组合。一个Flink App的KeyGroup数量与最大并行度相同,将key分配到KeyGroup的操作则是经典的取hashCode+取模。而KeyGroupRange则是一些连续KeyGroup的范围,每个Flink sub-task都只包含一个KeyGroupRange。以插入一个Timer的流程为例:
- 从Timer中取出key,计算该key属于哪一个KeyGroup;
- 计算出该KeyGroup在整个KeyGroupRange中的偏移量,按该偏移量定位到HashMap<T, T>[]数组的下标;
- 根据putIfAbsent()方法的语义,只有当对应HashMap不存在该Timer的key时,才将Timer插入最小堆中,做到了KeyGroup级别的key去重。
触发:如果是处理定时器,按顺序从队列中获取到比时间戳time小的所有Timer,并挨个执行Triggerable.onProcessingTime()方法,也就是在上文KeyedProcessOperator的同名方法,用户自定义的onTimer()逻辑也就被执行了。如果是事件定时器,当水印到来时会触发所有早于水印时间戳的Timer
TimerHeapInternalTimer最终实现了InternalTimerServiceImpl里队列域变量的元素,根据time、namespace、key确定一个唯一的定时器,在java堆存定时器,每个timer都是优先级队列里的一个元素,都用timerHeapIndex维护其在优先级队列里的下标,方便快速删除