案例: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());
}
}

浙公网安备 33010602011771号