案例:TopN访问url

需求分析

需求:网站中一个非常经典的例子,就是实时统计一段时间内的热门 url。例如,需要统计最近 10 秒钟内最热门的两个 url 链接,并且每 5 秒钟更新一次。

我们知道,这可以用一个滑动窗口 来实现,而“热门度”一般可以直接用访问量来表示。于是就需要开滑动窗口收集 url 的访问 数据,按照不同的 url 进行统计,而后汇总排序并最终输出前两名

这其实就是著名的“Top N” 问题

很显然,简单的增量聚合可以得到 url 链接的访问量,但是后续的排序输出 Top N 就很难 实现了。所以接下来我们用窗口处理函数进行实现

代码逻辑

1.相关包

import bean.Event;
import bean.UrlViewCount;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
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.api.java.tuple.Tuple2;
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.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import source.ClickSource;
import window.UrlCountViewExample;

import java.sql.Timestamp;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;

2.主体逻辑

public class ProcessTest04_TopNExample {
	public static void main(String[] args) throws Exception {
        // 1.得到运行环境
		StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
		env.setParallelism(1);  //并行度设为1方便测试
        
        // 2.添加数据,为自定义用于测试的数据源
        SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
            	//建立乱序流水位线,这里使用Duration.ZERO其实和有序流效果一样
				.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)
						.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
							@Override
							public long extractTimestamp(Event event, long l) {
								return event.timestamp;
							}
						})
		 		);
        
        // 3.统计每个url的访问量
        ...
        // 4.对url统计量进行收集和排序
        ...
        
        // 5.执行环境
		env.execute();
	}

3.自定义数据源

// 实现SourceFunction接口
public class ClickSource implements SourceFunction<Event> {
	// 声明一个标志位,控制数据的生成
	private Boolean running = true;

	@Override
	public void run(SourceContext<Event> ctx) throws Exception {
		// 随机生成数据
		Random random = new Random();
		// 定义字段选取的数据集
		String[] users = {"Mary", "Alice", "Bob", "Cary"};
		String[] urls = {"./home", "./cart", "./fav", "./prod?id=100", "./prod?id=10"};

		//	循环生成数据
		while (running) {
			String user = users[random.nextInt(users.length)];
			String url = urls[random.nextInt(urls.length)];
			long timestamp = Calendar.getInstance().getTimeInMillis();

			ctx.collect(new Event(user, url, timestamp));
			Thread.sleep(1000L);
		}

	}

	@Override
	public void cancel() {
		running = false;
	}
}

4.Java bean定义

//流中的事件
public class Event {
	public String user;
	public String url;
	public Long timestamp;

	public Event() {
	}

	public Event(String user, String url, Long timestamp) {
		this.user = user;
		this.url = url;
		this.timestamp = timestamp;
	}

	@Override
	public String toString() {
		return "Event{" +
				"user='" + user + '\'' +
				", url='" + url + '\'' +
				", timestamp=" + new Timestamp(timestamp) +
				'}';
	}
}
//定义Java bean便于封装需要用到的相关数据
public class UrlViewCount {
	public String url;
	public Long count;
	public Long windowStart;
	public Long windowEnd;

	public UrlViewCount() {
	}

	public UrlViewCount(String url, Long count, Long windowStart, Long windowEnd) {
		this.url = url;
		this.count = count;
		this.windowStart = windowStart;
		this.windowEnd = windowEnd;
	}

	@Override
	public String toString() {
		return "UrlViewCount{" +
				"url='" + url + '\'' +
				", count=" + count +
				", windowStart=" + new Timestamp(windowStart) +
				", windowEnd=" + new Timestamp(windowEnd) +
				'}';
	}
}

5.统计排序

对于统计每个url的访问量,这里其实可以有两个思路

一、对所有url直接开窗,然后收集窗口内的所有数据进行排序,最后得到TopN的访问url;

// 直接开窗,收集所有数据进行排序,统计访问量top2的url
stream.map(data -> data.url)  //转换得到url信息
		.windowAll(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))  //定义滑动窗口,窗口大小为10s,滑动步长为5s
		.aggregate(new UrlHashMapCountAgg(), new UrlAllWindowResult())  //进行聚合并封装结果信息
		.print();
//自定义实现 UrlHashMapCountAgg 聚合函数
public static class UrlHashMapCountAgg implements AggregateFunction<String, HashMap<String, Long>, ArrayList<Tuple2<String, Long>>> {
	@Override
	public HashMap<String, Long> createAccumulator() {
		return new HashMap<>();
	}

	@Override
	public HashMap<String, Long> add(String s, HashMap<String, Long> accumulator) {
		if (accumulator.containsKey(s)) {
			Long count = accumulator.get(s);
			accumulator.put(s, count + 1);
		} else {
			accumulator.put(s, 1L);
		}
		return accumulator;
	}

	@Override
	public ArrayList<Tuple2<String, Long>> getResult(HashMap<String, Long> accumulator) {
		ArrayList<Tuple2<String, Long>> result = new ArrayList<>();
		for (String key : accumulator.keySet()) {
			result.add(Tuple2.of(key, accumulator.get(key)));
		}
		result.sort(new Comparator<Tuple2<String, Long>>() {
			@Override
			public int compare(Tuple2<String, Long> o1, Tuple2<String, Long> o2) {
				return o2.f1.intValue() - o1.f1.intValue();
			}
		});
		return result;
	}

	@Override
	public HashMap<String, Long> merge(HashMap<String, Long> stringLongHashMap, HashMap<String, Long> acc1) {
		return null;
	}
}

// 实现自定义全窗口函数,包装信息输出结果
public static class UrlAllWindowResult extends ProcessAllWindowFunction<ArrayList<Tuple2<String, Long>>, String, TimeWindow> {
	@Override
	public void process(ProcessAllWindowFunction<ArrayList<Tuple2<String, Long>>, String, TimeWindow>.Context context, Iterable<ArrayList<Tuple2<String, Long>>> iterable, Collector<String> collector) throws Exception {
		ArrayList<Tuple2<String, Long>> list = iterable.iterator().next();

		StringBuilder result = new StringBuilder();
		result.append("-------------------------------------\n");
		result.append("窗口结束时间:" + new Timestamp(context.window().getEnd()) + "\n");

		// 取List前两个,包装信息输出
		for (int i = 0; i < 2; i++) {
			Tuple2<String, Long> currTuple = list.get(i);
			String info = "No. " + (i + 1) + " " +
					"url: " + currTuple.f0 + " " +
					"访问量: " + currTuple.f1 + "\n";
			result.append(info);
		}
		result.append("-------------------------------------\n");

		collector.collect(result.toString());
	}
}

但是这样做的缺点是不能进行并行计算,效率不高


二、先按key对url进行分组,然后再开窗,得到每个窗口内每个url的统计量,但是这里的问题在于怎么得到最后的TopN?

我们可不可以在窗口关闭时就直接收集结果并输出呢?这样做是不行的,因为数据流中的元素是逐个到来的,所以即使理论上我们应该 “同时” 收到很多 url 的浏览量统计结果,实际也是有先后的、只能一条一条处理

处理函数内 看到一个 url 的统计结果,并不能保证这个时间段的统计数据 不会再来了,所以也不能贸然进行排序输出

解决的办法,自然就是要等所有数据到齐了,这很容易让我们联想起水位线设置延迟时间的方法。这里我们也可以“多等一会儿”,等到水位线真正超过了窗口结束时间,要统计的数据就肯定到齐了


具体实现上,可以采用一个延迟触发的事件时间定时器。基于窗口的结束时间来设定延迟, 其实并不需要等太久——因为我们是靠水位线的推进来触发定时器,而水位线的含义就是 “之前的数据都到齐了”。所以我们只需要设置 1 毫秒的延迟,就一定可以保证这一点

而在等待过程中,之前已经到达的数据应该缓存起来,我们这里用一个自定义的 “列表状态”(ListState)来进行存储,之后每来一个 UrlViewCount,就把它添加到当前的列表状态中

并注册一个触发时间为窗口结束时间加 1 毫秒(windowEnd + 1)的定时器,待到水位线到达这个时间,定时器触发,我们可以保证当前窗口所有 url 的统计结果 UrlViewCount 都到齐了;于是从状态中取出进行排序输出

使用“列表状态”进行排序

// 1. 按照url进行分组,统计窗口内每个url的访问量
SingleOutputStreamOperator<UrlViewCount> urlCountStream = stream.keyBy(data -> data.url)  //按url进行分组
		.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))  //定义滑动窗口,窗口大小为10秒,每5秒滑动一次
		.aggregate(new UrlCountViewExample.UrlViewCountAgg(), new UrlCountViewExample.UrlViewCountResult());  //聚合并封装结果

urlCountStream.print("url count");

// 统计top2
int n = 2;

// 2. 并行计算,对于同一窗口统计出的访问量,进行收集和排序
urlCountStream.keyBy(data -> data.windowEnd)  //按windowEnd进行分组
		.process(new TopNProcessResult(n))
		.print();

定义在UrlCountViewExample包下边的两个类

//增量聚合,来一条数据就加1
public static class UrlViewCountAgg implements AggregateFunction<Event, Long, Long> {

	@Override
	public Long createAccumulator() {
		return 0L;
	}

	@Override
	public Long add(Event event, Long aLong) {
		return aLong + 1;
	}

	@Override
	public Long getResult(Long aLong) {
		return aLong;
	}

	@Override
	public Long merge(Long aLong, Long acc1) {
		return null;
	}
}

// 包装窗口信息
public static class UrlViewCountResult extends ProcessWindowFunction<Long, UrlViewCount, String, TimeWindow> {

	@Override
	public void process(String url, ProcessWindowFunction<Long, UrlViewCount, String, TimeWindow>.Context context, Iterable<Long> events, Collector<UrlViewCount> out) throws Exception {
		// 结合窗口信息
		long start = context.window().getStart();
		long end = context.window().getEnd();
		Long count = events.iterator().next();
		out.collect(new UrlViewCount(url, count, start, end));
	}
}
// 实现自定义的keyedProcessFunction
public static class TopNProcessResult extends KeyedProcessFunction<Long, UrlViewCount, String> {
	// 定义一个属性,n
	private Integer n;

	// 定义列表状态
	// 不能在这里进行初始化状态,因为状态只能在open生命周期方法,即上下文环境建立后得到,否则只是一个本地变量
	private ListState<UrlViewCount> urlViewCountListState;

	public TopNProcessResult(Integer n) {
		this.n = n;
	}

	// 在环境中获取状态
	@Override
	public void open(Configuration parameters) throws Exception {
		urlViewCountListState = getRuntimeContext().getListState(
				new ListStateDescriptor<UrlViewCount>("url-count-list", Types.POJO(UrlViewCount.class))
		);
	}

	@Override
	public void processElement(UrlViewCount urlViewCount, KeyedProcessFunction<Long, UrlViewCount, String>.Context context, Collector<String> collector) throws Exception {
		// 将数据保存到状态中
		urlViewCountListState.add(urlViewCount);
		//注册windowEnd + 1ms的定时器
		context.timerService().registerEventTimeTimer(context.getCurrentKey() + 1);
	}

	@Override
	public void onTimer(long timestamp, KeyedProcessFunction<Long, UrlViewCount, String>.OnTimerContext ctx, Collector<String> out) throws Exception {
		ArrayList<UrlViewCount> urlViewCountArrayList = new ArrayList<>();
		for (UrlViewCount urlViewCount : urlViewCountListState.get()) {
			urlViewCountArrayList.add(urlViewCount);
		}

		// 排序
		urlViewCountArrayList.sort(
				new Comparator<UrlViewCount>() {
					@Override
					public int compare(UrlViewCount o1, UrlViewCount o2) {
						return o2.count.intValue() - o1.count.intValue();
					}
				}
		);

		// 包装信息打印输出
		StringBuilder result = new StringBuilder();
		result.append("-------------------------------------\n");
		result.append("窗口结束时间:" + new Timestamp(ctx.getCurrentKey()) + "\n");

		// 取List前两个,包装信息输出
		for (int i = 0; i < n; i++) {
			UrlViewCount curr = urlViewCountArrayList.get(i);
			String info = "No. " + (i + 1) + " " +
					"url: " + curr.url + " " +
					"访问量: " + curr.count + "\n";
			result.append(info);
		}
		result.append("-------------------------------------\n");

		out.collect(result.toString());
	}
}
posted @ 2022-11-05 19:20  黄一洋  阅读(47)  评论(0)    收藏  举报