处理函数应用案例——TopN
窗口的计算处理,在实际应用中非常常见。对于一些比较复杂的需求,如果增量聚合函数无法满足,就需要考虑使用窗口处理函数这样的“大招”了。网站中一个非常经典的例子,就是实时统计一段时间内的热门url。例如,需要统计最近10秒钟内最热门的两个url链接,并且每5秒钟更新一次。这可以用一个滑动窗口来实现,而“热门度”一般可以直接用访问量来表示。于是就需要开滑动窗口收集url的访问数据,按照不同的url进行统计,而后汇总排序并最终输出前两名。这其实就是著名的“Top N”问题。很显然,简单的增量聚合可以得到url链接的访问量,但是后续的排序输出Top N就很难实现了。所以接下来用窗口处理函数进行实现。
1、思路
- 读取数据源;
- 筛选浏览行为(pv);
- 提取时间戳并生成水位线;
- 按照url进行keyBy分区操作;
- 开长度为10 min、步长为5 min的事件时间滑动窗口;
- 使用增量聚合函数AggregateFunction,并结合全窗口函数WindowFunction进行窗口聚合,得到每个url、在每个统计窗口内的浏览量,包装成UrlViewCount;
- 按照窗口进行keyBy分区操作;
- 对同一窗口的统计结果数据,使用KeyedProcessFunction进行收集并排序输出;
这里又会带来另一个问题。最后用KeyedProcessFunction来收集数据做排序,这时面对的就是窗口聚合之后的数据流,而窗口已经不存在了;那到底什么时候会收集齐所有数据呢?这问题听起来似乎有些没道理。统计浏览量的窗口已经关闭,就说明了当前已经到了要输出结果的时候,直接输出不就行了吗?没有这么简单。因为数据流中的元素是逐个到来的,所以即使理论上应该“同时”收到很多url的浏览量统计结果,实际也是有先后的、只能一条一条处理。下游任务(就是定义的KeyedProcessFunction)看到一个url的统计结果,并不能保证这个时间段的统计数据不会再来了,所以也不能贸然进行排序输出。解决的办法,自然就是要等所有数据到齐了——这很容易让联想起水位线设置延迟时间的方法。这里也可以“多等一会儿”,等到水位线真正超过了窗口结束时间,要统计的数据就肯定到齐了。具体实现上,可以采用一个延迟触发的事件时间定时器。基于窗口的结束时间来设定延迟,其实并不需要等太久——因为是靠水位线的推进来触发定时器,而水位线的含义就是“之前的数据都到齐了”。所以只需要设置1毫秒的延迟,就一定可以保证这一点。而在等待过程中,之前已经到达的数据应该缓存起来,这里用一个自定义的“列表状态”(ListState)来进行存储,如下图所示。这个状态需要使用富函数类的getRuntimeContext()方法获取运行时上下文来定义,一般把它放在open()生命周期方法中。之后每来一个UrlViewCount,就把它添加到当前的列表状态中,并注册一个触发时间为窗口结束时间加1毫秒(windowEnd + 1)的定时器。待到水位线到达这个时间,定时器触发,可以保证当前窗口所有url的统计结果UrlViewCount都到齐了;于是从状态中取出进行排序输出。
2、代码
具体代码如下:
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner; import org.apache.flink.api.common.eventtime.WatermarkStrategy; import org.apache.flink.api.common.functions.AggregateFunction; import org.apache.flink.api.common.state.ListState; import org.apache.flink.api.common.state.ListStateDescriptor; import org.apache.flink.api.common.typeinfo.Types; import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.streaming.api.functions.KeyedProcessFunction; import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction; import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows; import org.apache.flink.streaming.api.windowing.time.Time; import org.apache.flink.streaming.api.windowing.windows.TimeWindow; import org.apache.flink.util.Collector; import org.wdh01.bean.Event; import org.wdh01.bean.UrlViewCount; import org.wdh01.chapter05.ClickSource; import java.sql.Timestamp; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; public class KeyedProcessTopN0704 { public static void main(String[] args) throws Exception { //获取执行环境&设置并行度 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); /** * 读取数据&提取水位线 */ SingleOutputStreamOperator<Event> eventStream = env.addSource(new ClickSource()) .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(1)) .withTimestampAssigner(new SerializableTimestampAssigner<Event>() { @Override public long extractTimestamp(Event element, long recordTimestamp) { return element.timestamp; } })); // 需要按照url分组,求出每个url的访问量 SingleOutputStreamOperator<UrlViewCount> aggregateStream = eventStream.keyBy(data -> data.url) .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5))) .aggregate(new AggregateFunction<Event, Long, Long>() { @Override public Long createAccumulator() { return 0L; } @Override public Long add(Event value, Long accumulator) { return accumulator + 1L; } @Override public Long getResult(Long accumulator) { return accumulator; } @Override public Long merge(Long a, Long b) { return a + b; } }, new 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)); } } ); aggregateStream.keyBy(data -> data.end) .process(new TopN(2)).print("res"); env.execute(); } public static class TopN extends KeyedProcessFunction<Long, UrlViewCount, String> { //入参 private Integer n; //定义列表状态 private ListState<UrlViewCount> listState; public TopN(Integer n) { this.n = n; } @Override public void open(Configuration parameters) throws Exception { //获取列表状态 listState = getRuntimeContext().getListState( new ListStateDescriptor<UrlViewCount>(" url-view-cnt-list", Types.POJO(UrlViewCount.class)) ); } @Override public void processElement(UrlViewCount value, Context ctx, Collector<String> out) throws Exception { //将 cnt 数据保存在 列表状态中 listState.add(value); //注册一个window end+1s 定时器,等待所有数据到齐开始排序 ctx.timerService().registerEventTimeTimer(ctx.timestamp() + 1); } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception { //从列表状态中获取数据 ArrayList<UrlViewCount> arrayList = new ArrayList<>(); for (UrlViewCount urlViewCount : listState.get()) { arrayList.add(urlViewCount); } //清空状态释放资源 listState.clear(); //排序 arrayList.sort(new Comparator<UrlViewCount>() { @Override public int compare(UrlViewCount o1, UrlViewCount o2) { return o2.cnt.intValue() - o1.cnt.intValue(); } }); //取前3名,包装输出信息 StringBuilder res = new StringBuilder(); res.append("------------------------"); res.append("窗口结束时间 " + new Timestamp(timestamp - 1) + "\n"); for (Integer i = 0; i < this.n; i++) { UrlViewCount urlViewCount = arrayList.get(i); String info = "No. " + (i + 1) + " " + "url: " + urlViewCount.url + " " + "浏览量:" + urlViewCount.cnt + "\n"; res.append(info); } res.append("------------------------"); out.collect(res.toString()); } } }