按键分区处理函数(KeyedProcessFunction)
在Flink程序中,为了实现数据的聚合统计,或者开窗计算之类的功能,一般都要先用keyBy算子对数据流进行“按键分区”,得到一个KeyedStream。也就是指定一个键(key),按照它的哈希值(hash code)将数据分成不同的“组”,然后分配到不同的并行子任务上执行计算;这相当于做了一个逻辑分流的操作,从而可以充分利用并行计算的优势实时处理海量数据。另外只有在KeyedStream中才支持使用TimerService设置定时器的操作。所以一般情况下,我们都是先做了keyBy分区之后,再去定义处理操作.
1、定时器(Timer)和定时服务(TimerService)
KeyedProcessFunction的一个特色,就是可以灵活地使用定时器。定时器(timers)是处理函数中进行时间相关操作的主要机制。在.onTimer()方法中可以实现定时处理的逻辑,而它能触发的前提,就是之前曾经注册过定时器、并且现在已经到了触发时间。注册定时器的功能,是通过上下文中提供的“定时服务”(TimerService)来实现的。定时服务与当前运行的环境有关。前面已经介绍过,ProcessFunction的上下文(Context)中提供了.timerService()方法,可以直接返回一个TimerService对象:
public abstract TimerService timerService();
TimerService是Flink关于时间和定时器的基础服务接口,包含以下六个方法:
/** Interface for working with time and timers. */ @PublicEvolving public interface TimerService { /** Error string for {@link UnsupportedOperationException} on registering timers. */ String UNSUPPORTED_REGISTER_TIMER_MSG = "Setting timers is only supported on a keyed streams."; /** Error string for {@link UnsupportedOperationException} on deleting timers. */ String UNSUPPORTED_DELETE_TIMER_MSG = "Deleting timers is only supported on a keyed streams."; /** Returns the current processing time. */ long currentProcessingTime(); /** Returns the current event-time watermark. */ long currentWatermark(); /** * Registers a timer to be fired when processing time passes the given time. * * <p>Timers can internally be scoped to keys and/or windows. When you set a timer in a keyed * context, such as in an operation on {@link * org.apache.flink.streaming.api.datastream.KeyedStream} then that context will also be active * when you receive the timer notification. */ void registerProcessingTimeTimer(long time); /** * Registers a timer to be fired when the event time watermark passes the given time. * * <p>Timers can internally be scoped to keys and/or windows. When you set a timer in a keyed * context, such as in an operation on {@link * org.apache.flink.streaming.api.datastream.KeyedStream} then that context will also be active * when you receive the timer notification. */ void registerEventTimeTimer(long time); /** * Deletes the processing-time timer with the given trigger time. This method has only an effect * if such a timer was previously registered and did not already expire. * * <p>Timers can internally be scoped to keys and/or windows. When you delete a timer, it is * removed from the current keyed context. */ void deleteProcessingTimeTimer(long time); /** * Deletes the event-time timer with the given trigger time. This method has only an effect if * such a timer was previously registered and did not already expire. * * <p>Timers can internally be scoped to keys and/or windows. When you delete a timer, it is * removed from the current keyed context. */ void deleteEventTimeTimer(long time); }
六个方法可以分成两大类:基于处理时间和基于事件时间。而对应的操作主要有三个:获取当前时间,注册定时器,以及删除定时器。需要注意,尽管处理函数中都可以直接访问TimerService,不过只有基于KeyedStream的处理函数,才能去调用注册和删除定时器的方法;未作按键分区的DataStream不支持定时器操作,只能获取当前时间。对于处理时间和事件时间这两种类型的定时器,TimerService内部会用一个优先队列将它们的时间戳(timestamp)保存起来,排队等待执行。可以认为,定时器其实是KeyedStream上处理算子的一个状态,它以时间戳作为区分。所以TimerService会以键(key)和时间戳为标准,对定时器进行去重;也就是说对于每个key和时间戳,最多只有一个定时器,如果注册了多次,onTimer()方法也将只被调用一次。这样一来,在代码中就方便了很多,可以肆无忌惮地对一个key注册定时器,而不用担心重复定义——因为一个时间戳上的定时器只会触发一次。基于KeyedStream注册定时器时,会传入一个定时器触发的时间戳,这个时间戳的定时器对于每个key都是有效的。这样,代码并不需要做额外的处理,底层就可以直接对不同key进行独立的处理操作了。利用这个特性,有时可以故意降低时间戳的精度,来减少定时器的数量,从而提高处理性能。比如可以在设置定时器时只保留整秒数,那么定时器的触发频率就是最多1秒一次。
ong coalescedTime = time /1000 * 1000;
tx.timerService().registerProcessingTimeTimer(coalescedTime);
这里注意定时器的时间戳必须是毫秒数,所以得到整秒之后还要乘以1000。定时器默认的区分精度是毫秒。另外Flink对.onTimer()和.processElement()方法是同步调用的(synchronous),所以也不会出现状态的并发修改。Flink的定时器同样具有容错性,它和状态一起都会被保存到一致性检查点(checkpoint)中。当发生故障时,Flink会重启并读取检查点中的状态,恢复定时器。如果是处理时间的定时器,有可能会出现已经“过期”的情况,这时它们会在重启时被立刻触发。
2、KeyedProcessFunction的使用
KeyedProcessFunction可以说是处理函数中的“嫡系部队”,可以认为是ProcessFunction的一个扩展。只要基于keyBy之后的KeyedStream,直接调用.process()方法,这时需要传入的参数就是KeyedProcessFunction的实现类。
stream.keyBy( t -> t.f0 ).process(new MyKeyedProcessFunction())
类似地,KeyedProcessFunction也是继承自AbstractRichFunction的一个抽象类,源码中定义如下:
/** * Process one element from the input stream. * * <p>This function can output zero or more elements using the {@link Collector} parameter and * also update internal state or set timers using the {@link Context} parameter. * * @param value The input value. * @param ctx A {@link Context} that allows querying the timestamp of the element and getting a * {@link TimerService} for registering timers and querying the time. The context is only * valid during the invocation of this method, do not store it. * @param out The collector for returning result values. * @throws Exception This method may throw exceptions. Throwing an exception will cause the * operation to fail and may trigger recovery. */ public abstract void processElement(I value, Context ctx, Collector<O> out) throws Exception; /** * Called when a timer set using {@link TimerService} fires. * * @param timestamp The timestamp of the firing timer. * @param ctx An {@link OnTimerContext} that allows querying the timestamp, the {@link * TimeDomain}, and the key of the firing timer and getting a {@link TimerService} for * registering timers and querying the time. The context is only valid during the invocation * of this method, do not store it. * @param out The collector for returning result values. * @throws Exception This method may throw exceptions. Throwing an exception will cause the * operation to fail and may trigger recovery. */ public void onTimer(long timestamp, OnTimerContext ctx, Collector<O> out) throws Exception {}
可以看到与ProcessFunction的定义几乎完全一样,区别只是在于类型参数多了一个K,这是当前按键分区的key的类型。同样地,必须实现一个.processElement()抽象方法,用来处理流中的每一个数据;另外还有一个非抽象方法.onTimer(),用来定义定时器触发时的回调操作。由于定时器只能在KeyedStream上使用,所以到了KeyedProcessFunction这里,才真正对时间有了精细的控制,定时方法.onTimer()才真正派上了用场。下面是一个使用处理时间定时器的具体示例:
public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); DataStreamSource<Event> eventDS = env.addSource(new ClickSource()); eventDS.keyBy(data -> data.user) //KeyedProcessFunction<String, Event, String>( K I O ) .process(new KeyedProcessFunction<String, Event, String>() { @Override public void processElement(Event value, Context ctx, Collector<String> out) throws Exception { //获取处理时间 long currTs = ctx.timerService().currentProcessingTime(); // 处理时间 out.collect(ctx.getCurrentKey() + " 数据到达时间 -> " + new Timestamp(currTs)); //注册 10s 的定时器:处理时间定时器 ctx.timerService().registerProcessingTimeTimer(currTs + 10 * 1000); } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception { out.collect(ctx.getCurrentKey() + " 定时器触发时间 -> " + new Timestamp(timestamp)); } }).print(); env.execute(); }
在上面的代码中,由于定时器只能在KeyedStream上使用,所以先要进行keyBy;这里的.keyBy(data-> true)是将所有数据的key都指定为了true,其实就是所有数据拥有相同的key,会分配到同一个分区。之后自定义了一个KeyedProcessFunction,其中.processElement()方法是每来一个数据都会调用一次,主要是定义了一个10秒之后的定时器;而.onTimer()方法则会在定时器触发时调用。所以会看到,程序运行后先在控制台输出“数据到达”的信息,等待10秒之后,又会输出“定时器触发”的信息,打印出的时间间隔正是10秒。当然,上面的例子是处理时间的定时器,所以是真的需要等待10秒才会看到结果。事件时间语义下,又会有什么不同呢?可以对上面的代码略作修改,做一个测试:
public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); SingleOutputStreamOperator<Event> eventDS = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks( WatermarkStrategy .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2)) .withTimestampAssigner(new SerializableTimestampAssigner<Event>() { @Override public long extractTimestamp(Event element, long recordTimestamp) { return element.timestamp; } })); eventDS.keyBy(data -> data.user) .process(new KeyedProcessFunction<String, Event, String>() { @Override public void processElement(Event value, Context ctx, Collector<String> out) throws Exception { //获取处理时间 long currTs = ctx.timerService().currentWatermark(); out.collect(ctx.getCurrentKey() + " 数据到时间戳 -> " + new Timestamp(currTs) + " watermaker " + ctx.timerService().currentWatermark()); //注册 10s 的定时器 ctx.timerService().registerEventTimeTimer(currTs + 10 * 1000); } //触发 @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception { out.collect(ctx.getCurrentKey() + " 定时器触发时间 -> " + new Timestamp(timestamp)); } }).print(); env.execute(); }
令狐冲 数据到时间戳 -> 292278994-08-17 15:12:55.192 watermaker -9223372036854775808 令狐冲 定时器触发时间 -> 292269055-12-03 00:47:14.192 任盈盈 数据到时间戳 -> 2022-07-06 12:31:55.509 watermaker 1657081915509 莫大 数据到时间戳 -> 2022-07-06 12:31:56.521 watermaker 1657081916521 依琳 数据到时间戳 -> 2022-07-06 12:31:57.529 watermaker 1657081917529 任盈盈 数据到时间戳 -> 2022-07-06 12:31:58.53 watermaker 1657081918530 令狐冲 数据到时间戳 -> 2022-07-06 12:31:59.545 watermaker 1657081919545 风清扬 数据到时间戳 -> 2022-07-06 12:32:00.55 watermaker 1657081920550 风清扬 数据到时间戳 -> 2022-07-06 12:32:01.551 watermaker 1657081921551 依琳 数据到时间戳 -> 2022-07-06 12:32:02.554 watermaker 1657081922554 风清扬 数据到时间戳 -> 2022-07-06 12:32:03.567 watermaker 1657081923567 任盈盈 数据到时间戳 -> 2022-07-06 12:32:04.577 watermaker 1657081924577 任盈盈 定时器触发时间 -> 2022-07-06 12:32:05.509 任盈盈 数据到时间戳 -> 2022-07-06 12:32:05.587 watermaker 1657081925587 莫大 定时器触发时间 -> 2022-07-06 12:32:06.521 风清扬 数据到时间戳 -> 2022-07-06 12:32:06.599 watermaker 1657081926599 依琳 定时器触发时间 -> 2022-07-06 12:32:07.529 莫大 数据到时间戳 -> 2022-07-06 12:32:07.61 watermaker 1657081927610 任盈盈 定时器触发时间 -> 2022-07-06 12:32:08.53 莫大 数据到时间戳 -> 2022-07-06 12:32:08.623 watermaker 1657081928623 令狐冲 定时器触发时间 -> 2022-07-06 12:32:09.545 依琳 数据到时间戳 -> 2022-07-06 12:32:09.633 watermaker 1657081929633 风清扬 定时器触发时间 -> 2022-07-06 12:32:10.55 令狐冲 数据到时间戳 -> 2022-07-06 12:32:10.643 watermaker 1657081930643 风清扬 定时器触发时间 -> 2022-07-06 12:32:11.551 风清扬 数据到时间戳 -> 2022-07-06 12:32:11.653 watermaker 1657081931653 依琳 定时器触发时间 -> 2022-07-06 12:32:12.554
每来一条数据,都会输出两行“数据到达”的信息,并以分割线隔开;两条数据到达的时间间隔为5秒。当第三条数据到达后,随后立即输出一条定时器触发的信息;再过5秒之后,剩余两条定时器信息输出,程序运行结束。可以发现,数据到来之后,当前的水位线与时间戳并不是一致的。当第一条数据到来,时间戳为1000,可水位线的生成是周期性的(默认200ms一次),不会立即发生改变,所以依然是最小值Long.MIN_VALUE;随后只要到了水位线生成的时间点(200ms到了),就会依据当前的最大时间戳1000来生成水位线了。这里没有设置水位线延迟,默认需要减去1毫秒,所以水位线推进到了999。而当时间戳为11000的第二条数据到来之后,水位线同样没有立即改变,仍然是999,就好像总是“滞后”数据一样。这样程序的行为就可以得到合理解释了。事件时间语义下,定时器触发的条件就是水位线推进到设定的时间。第一条数据到来后,设定的定时器时间为1000 + 10 * 1000 = 11000;而当时间戳为11000的第二条数据到来,水位线还处在999的位置,当然不会立即触发定时器;而之后水位线会推进到10999,同样是无法触发定时器的。必须等到第三条数据到来,将水位线真正推进到11000,就可以触发第一个定时器了。第三条数据发出后再过5秒,没有更多的数据生成了,整个程序运行结束将要退出,此时Flink会自动将水位线推进到长整型的最大值(Long.MAX_VALUE)。于是所有尚未触发的定时器这时就统一触发了,就在控制台看到了后两个定时器的触发信息。