KeyedProcessFunction

一、定时器

       KeyedProcessFunction 是可以灵活地使用定时器。
       定时器(timers)是处理函数中进行时间相关操作的主要机制。在.onTimer()方法中可以实现定时处理的逻辑,而能触发的前提,就是之前曾经注册过定时器、并且现在已经到触发时间。注册定时器的功能,是通过上下文中提供的“定时服务”(TimerService)来实现的。 定时服务与当前运行的环境有关。ProcessFunction 的上下文(Context)中提供.timerService()方法,可以直接返回一个 TimerService 对象:

public abstract TimerService timerService();

        TimerService 是 Flink 关于时间和定时器的基础服务接口,包含以下六个方法:

// 获取当前的处理时间
long currentProcessingTime();

// 获取当前的水位线(事件时间)
long currentWatermark();

// 注册处理时间定时器,当处理时间超过 time 时触发
void registerProcessingTimeTimer(long time);

// 注册事件时间定时器,当水位线超过 time 时触发
void registerEventTimeTimer(long time);

// 删除触发时间为 time 的处理时间定时器
void deleteProcessingTimeTimer(long time);

// 删除触发时间为 time 的处理时间定时器
void deleteEventTimeTimer(long time);

  六个方法分成两大类:基于处理时间和基于事件时间。对应的操作主要有三个:获取当前时间,注册定时器以及删除定时器。需要注意,尽管处理函数中都可以直接访问TimerService,不过只有基于 KeyedStream 的处理函数,才能去调用注册和删除定时器的方法; 未作按键分区的DataStream 不支持定时器操作,只能获取当前时间。

  对于处理时间和事件时间这两种类型的定时器,TimerService 内部会用一个优先队列将它们的时间戳(timestamp)保存起来,排队等待执行。可以认为,定时器其实是 KeyedStream 上处理算子的一个状态,它以时间戳作为区分。所以 TimerService 会以键(key)和时间戳为标准,对定时器进行去重;也就是说对于每个 key 和时间戳,最多只有一个定时器,如果注册了多次,onTimer()方法也将只被调用一次。在代码中就方便了很多,可以肆无忌惮地对一个key 注册定时器,而不用担心重复定义——因为一个时间戳上的定时器只会触发一次。
  基于KeyedStream 注册定时器时,会传入一个定时器触发的时间戳,时间戳的定时器对于每个 key 都是有效的。我们的代码并不需要做额外的处理,底层就可以直接对不同key 进行独立的处理操作了。
       利用这个特性,有时可以故意降低时间戳的精度,来减少定时器的数量,从而提高处理性能。比如可以在设置定时器时只保留整秒数,那么定时器的触发频率就是最多 1 秒一次。

long coalescedTime = time / 1000 * 1000;
ctx.timerService().registerProcessingTimeTimer(coalescedTime);

  注意定时器的时间戳必须是毫秒数,所以我们得到整秒之后还要乘以 1000。定时器默认的区分精度是毫秒。另外 Flink 对.onTimer()和.processElement()方法是同步调用的(synchronous),所以不会出现状态的并发修改。
       Flink 定时器同样具有容错性,它和状态一起都会被保存到一致性检查点(checkpoint) 中。当发生故障时,Flink 会重启并读取检查点中的状态,恢复定时器。如果是处理时间的定时器,有可能会出现已经“过期”的情况,这时它们会在重启时被立刻触发。

  keyBy 之后的KeyedStream,直接调用.process()方法,这时需要传入的参数就是 KeyedProcessFunction 的实现类

stream.keyBy( t -> t.f0 ).process(new MyKeyedProcessFunction())

  KeyedProcessFunction 也是继承自 AbstractRichFunction 的一个抽象类,源码中定义如下:

public abstract class KeyedProcessFunction<K, I, O> extends AbstractRichFunction
{

...

public abstract void processElement(I value, Context ctx, Collector<O> out) throws Exception;
public void onTimer(long timestamp, OnTimerContext ctx, Collector<O> out) throws Exception {}
public abstract class Context {...}

...

}

  必须实现一个.processElement()抽象方法,用来处理流中的每一个数据;另外还有一个非抽象方法.onTimer(),用来定义定时器触发时的回调操作。由于定时器只能在 KeyedStream 上使用,所以到了KeyedProcessFunction 这里,我们才真正对时间有了精细的控制,定时方法.onTimer()才真正派上了用场。

    @Test
    public void keyByProcessTest(){
        env.addSource(new MySource())
                // 分组
                .keyBy(data -> true)
                // 处理函数测试
                .process(new KeyedProcessFunction<Boolean, Event, String>() {
                    @Override
                    public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
                        out.collect("定时器触发,触发时间:" + new Timestamp(timestamp));
                    }

                    @Override
                    public void processElement(Event event, Context context, Collector<String> collector) throws Exception {
                        long currTs = context.timerService().currentProcessingTime();
                        collector.collect("数据到达,到达时间:" + new Timestamp(currTs));
                        // 注册一个 10 秒后的定时器
                        context.timerService().registerProcessingTimeTimer(currTs + 10 * 1000L);
                    }
                })
                .print();
    }

  .keyBy(data -> true)是将所有数据的key 都指定为了true,其实就是所有数据拥有相同的 key,会分配到同一个分区。自定义了一个KeyedProcessFunction,其中.processElement()方法是每来一个数据都会调用一次,主要是定义了一个 10 秒之后的定时器;而.onTimer()方法则会在定时器触发时调用。所以我们会看到,程序运行后先在控制台输出“数据到达”的信息,等待 10 秒之后,又会输出“定时器触发”的信息,打印出的时间间隔正是 10 秒。

    @Test
    public void keyBy2ProcessTest() {
        env.addSource(new SourceFunction<Event>() {
            @Override
            public void run(SourceContext<Event> sourceContext) throws Exception {
                // 直接发出测试数据
                sourceContext.collect(new Event("Mary", "./home", 1000L));
                // 为了更加明显,中间停顿 5 秒钟
                Thread.sleep(5000L);


                // 发出 10 秒后的数据
                sourceContext.collect(new Event("Mary", "./home", 11000L));
                Thread.sleep(5000L);

                // 发出 10 秒+1ms 后的数据
                sourceContext.collect(new Event("Alice", "./cart", 11001L));
                Thread.sleep(5000L);
            }

            @Override
            public void cancel() {

            }
        })
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps().withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                    @Override
                    public long extractTimestamp(Event element, long recordTimestamp) {
                        return element.timestamp;
                    }
                }))
                // 分组
                .keyBy(data -> true)
                // 处理函数测试
                .process(new KeyedProcessFunction<Boolean, Event, String>() {
                    @Override
                    public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
                        out.collect("定时器触发,触发时间:" + new Timestamp(timestamp));
                    }

                    @Override
                    public void processElement(Event event, Context context, Collector<String> collector) throws Exception {
                        collector.collect("数据到达,时间戳为:" + context.timestamp());
                        collector.collect("数据到达,水位线为:" + context.timerService().currentWatermark() + "\n -------分割线    ");
                        // 注册一个 10 秒后的定时器
                        context.timerService().registerEventTimeTimer(context.timestamp() + 10 * 1000L);
                    }
                })
                .print();
    }

  一个数据源,发出三条测试数据,时间戳分别为 1000、11000 和 11001,并且发出数据后都会停顿 5 秒,在自定义的KeyedProcessFunction 中使用定时器。同样地,每来一条数据,我们就将当前的数据时间戳和水位线信息输出,并注册一个 10 秒后(以当前数据时间戳为基准)的事件时间定时器。执行程序结果如下:

5> 数据到达,时间戳为:1000
5> 数据到达,水位线为:-9223372036854775808
 -------分割线    
5> 数据到达,时间戳为:11000
5> 数据到达,水位线为:999
 -------分割线    
5> 数据到达,时间戳为:11001
5> 数据到达,水位线为:10999
 -------分割线    
5> 定时器触发:1970-01-01 08:00:11.0
5> 定时器触发:1970-01-01 08:00:21.0
5> 定时器触发:1970-01-01 08:00:21.001

  事件时间语义下,定时器触发的条件就是水位线推进到设定的时间。第一条数据到来后,设定的定时器时间为 1000 + 10 * 1000 = 11000;而当时间戳为 11000 的第二条数据到来,水位线还处在 999 的位置,当然不会立即触发定时器;而之后水位线会推进到 10999,同样是无法触发定时器的。必须等到第三条数据到来,将水位线真正推进到 11000,就可以触发第一个定时器了。第三条数据发出后再过 5 秒,没有更多的数据生成了,整个程序运行结束将要退出,此时 Flink 会自动将水位线推进到长整型的最大值(Long.MAX_VALUE)。于是所有尚未触发的定时器这时就统一触发了,我们就在控制台看到了后两个定时器的触发信息。

  

posted on 2022-11-19 13:40  溪水静幽  阅读(1023)  评论(0)    收藏  举报