首先要实现的是实时热门商品统计,我们将会基于 UserBehavior 数据集来进行分析。 项目主体用 Java 编写,采
用 IDEA 作为开发环境进行项目编写,采用 maven 作为项目构建和管理工具。首先我们需要搭建项目框架。
1、创建 Maven 项目
1.1 项目框架搭建
打开 IDEA,创建一个 maven 项目,命名为 UserBehaviorAnalysis。由于包含了多个模块,我们可以以
UserBehaviorAnalysis 作为父项目,并在其下建一个名为HotItemsAnalysis 的子项目,用于实时统计热门 top N
商品。
在 UserBehaviorAnalysis 下 新 建 一 个 maven module 作 为 子 项 目 , 命 名 为 HotItemsAnalysis。
父项目只是为了规范化项目结构,方便依赖管理,本身是不需要代码实现的, 所以 UserBehaviorAnalysis 下的
src 文件夹可以删掉。
1.2 声明项目中工具的版本信息
我们整个项目需要的工具的不同版本可能会对程序运行造成影响,所以应该在最外层的 UserBehaviorAnalysis 中
声明所有子模块共用的版本信息。
在 pom.xml 中加入以下配置:
UserBehaviorAnalysis/pom.xml
<properties> <flink.version>1.10.1</flink.version> <scala.binary.version>2.12</scala.binary.version> <kafka.version>2.2.0</kafka.version> </properties>
1.3 添加项目依赖
对 于 整 个 项 目 而 言 , 所 有 模 块 都 会 用 到 flink 相 关 的 组 件 , 所 以 我们在 UserBehaviorAnalysis 中引
入公有依赖:
UserBehaviorAnalysis/pom.xml
<dependencies> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-java</artifactId> <version>${flink.version}</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-streaming-java_${scala.binary.version}</artifactId> <version>${flink.version}</version> </dependency> <dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka_${scala.binary.version}</artifactId> <version>${kafka.version}</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-connector-kafka_${scala.binary.version}</artifactId> <version>${flink.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </build>
在 HotItemsAnalysis 子模块中,我们并没有引入更多的依赖,所以不需要改动pom 文件。
1.4 数据准备
将数据文件 UserBehavior.csv 复制到资源文件目录 src/main/resources 下,我们 将从这里读取数据。 至此,我们的准备工作都已完成,接下来可以写代码了。
2、模块代码实现
我们将实现一个“实时热门商品”的需求,可以将“实时热门商品”翻译成程序员更好理解的需求:每隔 5 分钟输
出最近一小时内点击量最多的前 N 个商品。将这个需求进行分解我们大概要做这么几件事情:
• 抽取出业务时间戳,告诉 Flink 框架基于业务时间做窗口
• 过滤出点击行为数据
• 按一小时的窗口大小,每 5 分钟统计一次,做滑动窗口聚合(Sliding Window)
• 按每个窗口聚合,输出每个窗口中点击量前 N 名的商品
2.1 程序主体
在 src/main/java/beans 下定义 POJOs:UserBehavior 和 ItemViewCount。创建类HotItems,在 main 方
法中创建 StreamExecutionEnvironment 并做配 置,然后从 UserBehavior.csv 文件中读取数据,并包装成
UserBehavior 类型。代码如下:
HotItemsAnalysis/src/main/java/HotItems.java
public class HotItems { public static void main(String[] args) throws Exception { // 1. 创建环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); // 2. 读取数据,创建 DataStream DataStream<String> inputStream = env.readTextFile("..\\UserBehavior.csv"); // 3. 转换为 POJO,并分配时间戳和 watermark DataStream<UserBehavior> dataStream = inputStream.map(line -> { String[] fields = line.split(","); return new UserBehavior(new Long(fields[0]), new Long(fields[1]), new Integer(fields[2]), fields[3], new Long(fields[4])); }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() { @Override public long extractAscendingTimestamp(UserBehavior element) { return element.getTimestamp() * 1000L; } }); env.execute("hot items"); } }
这里注意,我们需要统计业务时间上的每小时的点击量,所以要基于 EventTime 来处理。那么如果让 Flink
按照我们想要的业务时间来处理呢?这里主要有两件事情要做。
第一件是告诉 Flink 我们现在按照 EventTime 模式进行处理,Flink 默认使用ProcessingTime 处理,所以我们要
显式设置如下:
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
第二件事情是指定如何获得业务时间,以及生成 Watermark。Watermark 是用来追踪业务事件的概念,可以理解
成 EventTime 世界中的时钟,用来指示当前处理到什么时刻的数据了。由于我们的数据源的数据已经经过整理,
没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做 Watermark。这里我们用
AscendingTimestampExtractor 来实现时间戳的抽取和 Watermark 的生成。
.assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() { @Override public long extractAscendingTimestamp(UserBehavior element) { return element.getTimestamp() * 1000L; } });
这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。
2.2 过滤出点击事件
在开始窗口操作之前,先回顾下需求“每隔 5 分钟输出过去一小时内点击量最多的前 N 个商品”。由于原始数
据中存在点击、购买、收藏、喜欢各种行为的数据,但是我们只需要统计点击量,所以先使用 filter 将点击行为数
据过滤出来。
.filter( data -> "pv".equals(data.getBehavior()) )
2.3 设置滑动窗口,统计点击量
由于要每隔 5 分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔 5 分钟滑动一次。
即分别要统计[09:00, 10:00), [09:05, 10:05), [09:10, 10:10)…等窗口的商品点击量。是一个常见的滑动窗口需求
(Sliding Window)。
.keyBy("itemId") .timeWindow(Time.minutes(60), Time.minutes(5)) .aggregate(new CountAgg(), new WindowResultFunction());
我们使用.keyBy("itemId")对商品进行分组,使用.timeWindow(Time size, Time slide) 对每个商品做滑动窗
口( 1 小时窗口, 5 分钟滑动一次)。然后我们使用 .aggregate(AggregateFunction af, WindowFunction wf)
做增量的聚合操作,它能使用 AggregateFunction 提 前 聚 合 掉 数 据 , 减 少 state 的 存 储 压 力 。 较之
.apply(WindowFunction wf) 会将窗口中的数据都存储下来,最后一起计算要高效地多。这里的
CountAgg 实现了 AggregateFunction 接口,功能是统计窗口中的条数, 即遇到一条数据就加一。
// 自定义预聚合函数类,每来一个数据就 count 加 1 public static class ItemCountAgg implements AggregateFunction<UserBehavior, Long, Long>{ @Override public Long createAccumulator() { return 0L; } @Override public Long add(UserBehavior value, Long accumulator) { return accumulator + 1; } @Override public Long getResult(Long accumulator) { return accumulator; } @Override public Long merge(Long a, Long b) { return a + b; } }
聚合操作 .aggregate(AggregateFunction af, WindowFunction wf) 的第二个参数WindowFunction 将每个
key 每个窗口聚合后的结果带上其他信息进行输出。我们这里实现的 WindowResultFunction 将 < 主键商品 ID ,
窗 口 , 点 击 量 > 封装成了ItemViewCount 进行输出。
代码如下:
// 自定义窗口函数,结合窗口信息,输出当前 count 结果 public static class WindowCountResult implements WindowFunction<Long, ItemViewCount, Tuple, TimeWindow>{ @Override public void apply(Tuple tuple, TimeWindow window, Iterable<Long> input, Collector<ItemViewCount> out) throws Exception { Long itemId = tuple.getField(0); Long windowEnd = window.getEnd(); Long count = input.iterator().next(); out.collect( new ItemViewCount(itemId, windowEnd, count) ); } }
现在我们就得到了每个商品在每个窗口的点击量的数据流。
2.4 计算最热门 Top N 商品
为了统计每个窗口下最热门的商品,我们需要再次按窗口进行分组,这里根据 ItemViewCount 中的
windowEnd 进行 keyBy()操作。然后使用 ProcessFunction 实现一个自定义的 TopN 函数 TopNHotItems
来计算点击量排名前 3 名的商品,并将排名结果格式化成字符串,便于后续输出。
.keyBy("windowEnd") .process(new TopNHotItems(3)); // 求点击量前 3 名的商品
ProcessFunction 是 Flink 提供的一个 low-level API,用于实现更高级的功能。它主要提供了定时器 timer 的
功能(支持 EventTime 或 ProcessingTime)。
本案例中我们将利用 timer 来判断何时收齐了某个 window 下所有商品的点击量数据。由于Watermark 的 进
度 是 全 局 的 , 在 processElement 方 法 中 , 每 当 收 到 一 条 数 据 ItemViewCount,我们就注册一个
windowEnd+1 的定时器(Flink 框架会自动忽略同一时间的重复注册)。windowEnd+1 的定时器被触发时,意
味着收到了 windowEnd+1 的 Watermark,即收齐了该 windowEnd 下的所有商品窗口统计值。我们在onTimer()
中处理将收集的所有商品及点击量进行排序,选出 TopN,并将排名信息格式化成字符串后进行输出。
这里我们还使用了 ListState<ItemViewCount>来存储收到的每条 ItemViewCount 消息,保证在发生故障
时,状态数据的不丢失和一致性。
ListState 是 Flink 提供的类似 Java List 接口的 State API,它集成了框架的 checkpoint 机制,自动做到了
exactly-once 的语义保证。
// 求某个窗口中前 N 名的热门点击商品,key 为窗口时间戳,输出为 TopN 的结果字符串 public static class TopNHotItems extends KeyedProcessFunction<Tuple, ItemViewCount, String>{ private Integer topSize; public TopNHotItems(Integer topSize) { this.topSize = topSize; } // 定义状态,所有 ItemViewCount 的 List ListState<ItemViewCount> itemViewCountListState; @Override public void open(Configuration parameters) throws Exception { itemViewCountListState = getRuntimeContext().getListState(new ListStateDescriptor<ItemViewCount>("item-count-list", ItemViewCount.class)); } @Override public void processElement(ItemViewCount value, Context ctx, Collector<String> out) throws Exception { itemViewCountListState.add(value); ctx.timerService().registerEventTimeTimer(value.getWindowEnd() + 1L); } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception { ArrayList<ItemViewCount> itemViewCounts = Lists.newArrayList(itemViewCountListState.get().iterator()); // 排序 itemViewCounts.sort(new Comparator<ItemViewCount>() { @Override public int compare(ItemViewCount o1, ItemViewCount o2) { return o2.getCount().intValue() - o1.getCount().intValue(); } }); // 将排名信息格式化成 String StringBuilder result = new StringBuilder(); result.append("====================================\n"); result.append("窗口结束时间: ").append(new Timestamp(timestamp - 1)).append("\n"); for( int i = 0; i < topSize; i++ ){ ItemViewCount currentItemViewCount = itemViewCounts.get(i); result.append("No").append(i+1).append(":") .append(" 商品 ID=") .append(currentItemViewCount.getItemId()) .append(" 浏览量=") .append(currentItemViewCount.getCount()) .append("\n"); } result.append("====================================\n\n"); // 控制输出频率,模拟实时滚动结果 Thread.sleep(1000); out.collect(result.toString()); }
最后我们可以在 main 函数中将结果打印输出到控制台,方便实时观测: .print();
至此整个程序代码全部完成,我们直接运行 main 函数,就可以在控制台看到不断输出的各个时间点统计出的热门
商品。
2.5 完整代码
最终完整代码如下:
public class HotItems { public static void main(String[] args) throws Exception { // 1. 创建环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); // 2. 读取数据,创建 DataStream DataStream<String> inputStream = env.readTextFile("..\\UserBehavior.csv"); // 3. 转换为 POJO,并分配时间戳和 watermark DataStream<UserBehavior> dataStream = inputStream.map(line -> { String[] fields = line.split(","); return new UserBehavior(new Long(fields[0]), new Long(fields[1]), new Integer(fields[2]), fields[3], new Long(fields[4])); }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() { @Override public long extractAscendingTimestamp(UserBehavior element) { return element.getTimestamp() * 1000L; } }); // 4. 分组开窗聚合,得到每个窗口内各个商品的 count 值 DataStream<ItemViewCount> windowAggStream = dataStream.filter(data -> "pv".equals(data.getBehavior())) .keyBy("itemId").timeWindow(Time.hours(1), Time.minutes(5)) .aggregate(new ItemCountAgg(), new WindowCountResult()); // 5. 收集同一窗口的所有商品 count 值,排序输出 Top N DataStream<String> resultStream = windowAggStream.keyBy("windowEnd").process(new TopNHotItems(5)); resultStream.print(); env.execute("hot items"); } // 自定义预聚合函数类,每来一个数据就 count 加 1 public static class ItemCountAgg implements AggregateFunction<UserBehavior, Long, Long> { @Override public Long createAccumulator() { return 0L; } @Override public Long add(UserBehavior value, Long accumulator) { return accumulator + 1; } @Override public Long getResult(Long accumulator) { return accumulator; } @Override public Long merge(Long a, Long b) { return a + b; } } // 自定义窗口函数,结合窗口信息,输出当前 count 结果 public static class WindowCountResult implements WindowFunction<Long, ItemViewCount, Tuple, TimeWindow> { @Override public void apply(Tuple tuple, TimeWindow window, Iterable<Long> input, Collector<ItemViewCount> out) throws Exception { Long itemId = tuple.getField(0); Long windowEnd = window.getEnd(); Long count = input.iterator().next(); out.collect(new ItemViewCount(itemId, windowEnd, count)); } } public static class TopNHotItems extends KeyedProcessFunction<Tuple, ItemViewCount, String> { private Integer topSize; public TopNHotItems(Integer topSize) { this.topSize = topSize; } // 定义状态,所有 ItemViewCount 的 List ListState<ItemViewCount> itemViewCountListState; @Override public void open(Configuration parameters) throws Exception { itemViewCountListState = getRuntimeContext() .getListState(new ListStateDescriptor<ItemViewCount>("item-count-list", ItemViewCount.class)); } @Override public void processElement(ItemViewCount value, Context ctx, Collector<String> out) throws Exception { itemViewCountListState.add(value); ctx.timerService().registerEventTimeTimer(value.getWindowEnd() + 1L); } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception { ArrayList<ItemViewCount> itemViewCounts = Lists.newArrayList(itemViewCountListState.get().iterator()); // 排序 itemViewCounts.sort(new Comparator<ItemViewCount>() { @Override public int compare(ItemViewCount o1, ItemViewCount o2) { return o2.getCount().intValue() - o1.getCount().intValue(); } }); // 将排名信息格式化成 String StringBuilder result = new StringBuilder(); result.append("====================================\n"); result.append("窗口结束时间: ").append(new Timestamp(timestamp - 1)).append("\n"); for (int i = 0; i < Math.min(itemViewCounts.size(), topSize); i++) { ItemViewCount currentItemViewCount = itemViewCounts.get(i); result.append("No").append(i + 1).append(":").append(" 商品 ID=").append(currentItemViewCount.getItemId()) .append(" 浏览量=").append(currentItemViewCount.getCount()).append("\n"); } result.append("====================================\n\n"); // 控制输出频率,模拟实时滚动结果 Thread.sleep(1000); out.collect(result.toString()); } } }
2.6 更换 Kafka 作为数据源
实际生产环境中,我们的数据流往往是从 Kafka 获取到的。如果要让代码更贴近生产实际,我们只需将
source 更换为 Kafka 即可:
Properties properties = new Properties(); properties.setProperty("bootstrap.servers", "localhost:9092"); properties.setProperty("group.id", "consumer-group"); properties.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); properties.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); properties.setProperty("auto.offset.reset", "latest"); DataStream<String> inputStream = env.addSource(new FlinkKafkaConsumer<String>("hotitems", new SimpleStringSchema(), properties));
当然,根据实际的需要,我们还可以将 Sink 指定为 Kafka、ES、Redis 或其它存储,这里就不一一展开实现了。