Fabian Hueske是Apache Flink项目的提交者和PMC成员,也是Data Artisans的共同创始人。
Apache Flink是一个框架,用于实现有状态流处理应用程序并在计算集群上大规模运行它们。 在上一篇文章中,我们检查了什么是有状态流处理,它解决了哪些用例以及为什么要使用Apache Flink实现和运行流应用程序。
在本文中,我将介绍两个有状态流处理的常见用例,并讨论如何使用Flink来实现它们。 第一个用例是事件驱动的应用程序,即,提取连续的事件流并将一些业务逻辑应用于这些事件的应用程序。 第二个是流分析用例,在这里我将展示两个使用Flink的SQL API实现的分析查询,这些查询实时聚合流数据。 Data Artisans的我们在公共GitHub 存储库中提供了所有示例的源代码。
在深入研究示例详细信息之前,我将介绍示例应用程序提取的事件流,并说明如何运行我们提供的代码。
一系列出租车事件
我们的示例应用程序基于2013年在纽约市发生的有关出租车的公共数据集 。 2015年DEBS(ACM国际基于分布式事件系统的国际会议)盛大挑战赛的组织者重新整理了原始数据集并将其转换为一个CSV文件,我们从中读取以下九个字段。
- 大奖章-出租车的MD5总和
- Hack_license-出租车许可证的MD5总和ID
- Pickup_datetime-旅客上车的时间
- Dropoff_datetime-乘客下车的时间
- Pickup_longitude-取件地点的经度
- Pickup_latitude-取件地点的纬度
- Dropoff_longitude-下车地点的经度
- Dropoff_latitude-下车地点的纬度
- Total_amount-总支付金额(美元)
CSV文件以其放置时间属性的升序存储记录。 因此,该文件可被视为旅行结束时发布的事件的有序日志。 为了运行我们在GitHub上提供的示例,您需要从Google云端硬盘下载DEBS挑战的数据集 。
所有示例应用程序依次读取CSV文件并将其作为出租车事件流接收。 从那里开始,应用程序像处理任何其他流一样处理事件,例如,从基于日志的发布订阅系统(例如Apache Kafka或Kinesis)中获取的流一样。 实际上,读取文件(或任何其他类型的持久数据)并将其视为流是Flink统一批处理和流处理方法的基石。
运行Flink示例
如前所述,我们在GitHub存储库中发布了示例应用程序的源代码。 我们鼓励您分叉并克隆存储库。 这些示例可以在您选择的IDE中轻松执行; 您无需设置和配置Flink集群即可运行它们。 首先,将示例的源代码导入为Maven项目。 然后,执行应用程序的主类,并提供数据文件的存储位置(有关下载数据的链接,请参见上文)作为程序参数。
启动应用程序后,它将在应用程序的JVM进程内启动本地的嵌入式Flink实例,并提交应用程序以执行它。 在Flink启动和计划任务的同时,您将看到一堆日志语句。 应用程序运行后,其输出将被写入标准输出。
在Flink中构建事件驱动的应用程序
现在,让我们讨论第一个用例,它是一个事件驱动的应用程序。 事件驱动的应用程序吸收事件流,在接收到事件时执行计算,并且可能发出新事件或触发外部动作。 可以通过事件日志系统将多个事件驱动的应用程序连接在一起,从而构成多个事件驱动的应用程序,这与可以通过微服务组成大型系统类似。 事件驱动的应用程序,事件日志和应用程序状态快照(在Flink中称为保存点)构成了一种非常强大的设计模式,因为您可以重置其状态并重放其输入以从故障中恢复,修复错误或迁移应用程序。应用程序到其他群集。
在本文中,我们将研究一个事件驱动的应用程序,该应用程序支持服务,该服务监视出租车司机的工作时间。 2016年,纽约市出租车和豪华轿车委员会决定将出租车司机的工作时间限制为12小时轮班,并且要求至少有八个小时的休息时间才能开始下一次轮班。 换档从第一次骑行开始。 从那时起,驾驶员可以在12个小时的时间范围内开始新的旅程。 我们的应用程序会跟踪驾驶员的游乐设施,标记其12小时窗口的结束时间(即,他们可能开始上一次游乐设施的时间),并标记违反规定的游乐设施。 您可以在我们的GitHub存储库中找到此示例的完整源代码 。
我们的应用程序是通过Flink的DataStream API和KeyedProcessFunction
。 DataStream API是一种功能性API,基于类型化数据流的概念。 DataStream<T>
是类型T
的事件流的逻辑表示。 通过对流应用一个函数来处理该流,该函数会产生另一个可能不同类型的数据流。 Flink通过将事件分配到流分区并将函数的不同实例应用于每个分区来并行处理流。
以下代码片段显示了我们的监视应用程序的高层流程。
// ingest stream of taxi rides.
DataStream<TaxiRide> rides = TaxiRides.getRides(env, inputPath);
DataStream<Tuple2<String, String>> notifications = rides
// partition stream by the driver’s license id
.keyBy(r -> r.licenseId)
// monitor ride events and generate notifications
.process(new MonitorWorkTime());
// print notifications
notifications.print();
该应用程序开始提取出租车乘坐事件流。 在我们的示例中,事件是从文本文件中读取,解析并存储在TaxiRide
POJO对象中的。 实际应用程序通常会从消息队列或事件日志中提取事件,例如Apache Kafka或Pravega 。 下一步是通过驱动程序的licenseId
键入TaxiRide
事件。 keyBy
操作在已声明的字段上keyBy
进行分区,以使具有相同键的所有事件都由以下函数的相同并行实例处理。 在我们的例子中,我们在licenseId
字段上进行分区,因为我们想监视每个驱动程序的工作时间。
接下来,我们对分区的TaxiRide
事件应用MonitorWorkTime
函数。 该功能跟踪每个驾驶员的游乐设施并监视其换挡和休息时间。 它发出类型为Tuple2<String, String>
,其中每个元组表示一个包含驱动程序的许可证ID和消息的通知。 最后,我们的应用程序通过将消息打印到标准输出来发出消息。 实际应用程序会将通知写入外部消息或存储系统(例如Apache Kafka,HDFS或数据库系统),或者触发外部调用以立即将其推出。
现在,我们已经讨论了应用程序的总体流程,让我们看一下MonitorWorkTime
函数,该函数包含了大多数应用程序的实际业务逻辑。 MonitorWorkTime
函数是有状态的KeyedProcessFunction
,它吸收TaxiRide
事件并发出Tuple2<String, String>
记录。 KeyedProcessFunction
接口具有两种处理数据的方法: processElement()
和onTimer()
。 每个到达事件都会调用processElement()
方法。 当先前注册的计时器触发时,将调用onTimer()
方法。 以下代码段显示MonitorWorkTime
函数的框架以及在处理方法之外声明的所有内容。
public static class MonitorWorkTime
extends KeyedProcessFunction<String, TaxiRide, Tuple2<String, String>> {
// time constants in milliseconds
private static final long ALLOWED_WORK_TIME = 12 * 60 * 60 * 1000; // 12 hours
private static final long REQ_BREAK_TIME = 8 * 60 * 60 * 1000; // 8 hours
private static final long CLEAN_UP_INTERVAL = 28 * 60 * 60 * 1000; // 24 hours
private transient DateTimeFormatter formatter;
// state handle to store the starting time of a shift
ValueState<Long> shiftStart;
@Override
public void open(Configuration conf) {
// register state handle
shiftStart = getRuntimeContext().getState(
new ValueStateDescriptor<>(“shiftStart”, Types.LONG));
// initialize time formatter
this.formatter = DateTimeFormat.forPattern(“yyyy-MM-dd HH:mm:ss”);
}
// processElement() and onTimer() are discussed in detail below.
}
该函数为以毫秒为单位的时间间隔声明一些常量,一个时间格式化程序以及一个由Flink管理的键控状态的状态句柄。 定期检查点的受管状态,并在发生故障时自动恢复。 键状态是按每个键组织的,这意味着一个函数将为每个句柄和键维护一个值。 在我们的例子中, MonitorWorkTime
函数为每个密钥(即每个licenseId
保持Long
值。 shiftStart
状态存储驾驶员换挡的开始时间。 状态句柄在open()
方法中初始化,该方法在处理第一个事件之前被调用一次。
现在,让我们看一下processElement()
方法。
@Override
public void processElement(
TaxiRide ride,
Context ctx,
Collector<Tuple2<String, String>> out) throws Exception {
// look up start time of the last shift
Long startTs = shiftStart.value();
if (startTs == null ||
startTs < ride.pickUpTime - (ALLOWED_WORK_TIME + REQ_BREAK_TIME)) {
// this is the first ride of a new shift.
startTs = ride.pickUpTime;
shiftStart.update(startTs);
long endTs = startTs + ALLOWED_WORK_TIME;
out.collect(Tuple2.of(ride.licenseId,
“You are allowed to accept new passengers until “ + formatter.print(endTs)));
// register timer to clean up the state in 24h
ctx.timerService().registerEventTimeTimer(startTs + CLEAN_UP_INTERVAL);
} else if (startTs < ride.pickUpTime - ALLOWED_WORK_TIME) {
// this ride started after the allowed work time ended.
// it is a violation of the regulations!
out.collect(Tuple2.of(ride.licenseId,
“This ride violated the working time regulations.”));
}
}
每个TaxiRide
事件都会调用processElement()
方法。 首先,该方法从状态句柄获取驾驶员换挡的开始时间。 如果状态不包含开始时间( startTs == null
)或上次轮班开始的时间比当前乘车时间早20小时以上( ALLOWED_WORK_TIME + REQ_BREAK_TIME
),则当前乘车时间是新轮班的第一乘车时间。 无论哪种情况,该功能都会通过将换档的开始时间更新为当前乘车的开始时间来开始新的换档,并向驾驶员发出新换档的结束时间的消息,并注册一个计时器以清除状态在24小时内。
如果当前行驶不是新班次的第一次行驶,则该功能检查是否违反工作时间规定,即,它是否比驾驶员当前班次的开始晚了12个小时以上。 在这种情况下,该函数将发出一条消息,通知驾驶员有关违规的情况。
MonitorWorkTime
函数的processElement()
方法注册一个计时器,以在轮班开始后24小时清除状态。 删除不再需要的状态对于防止由于泄漏状态而导致状态大小增长很重要。 当应用程序的时间超过计时器的时间戳时,计时器将触发。 此时,将onTimer()
方法。 类似于状态,每个键都维护计时器,并且在调用onTimer()
方法之前,将函数放入关联键的上下文中。 因此,所有状态访问都将定向到注册计时器时处于活动状态的键。
让我们来看看onTimer()
的方法MonitorWorkTime
。
@Override
public void onTimer(
long timerTs,
OnTimerContext ctx,
Collector<Tuple2<String, String>> out) throws Exception {
// remove the shift state if no new shift was started already.
Long startTs = shiftStart.value();
if (startTs == timerTs - CLEAN_UP_INTERVAL) {
shiftStart.clear();
}
}
在开始轮班以清理不再需要的状态后, processElement()
方法注册计时器24小时。 清理状态是onTimer()
方法实现的唯一逻辑。 当计时器启动时,我们检查驾驶员是否同时开始新的换档,即换档开始时间是否改变。 如果不是这种情况,我们清除驾驶员的换档状态。