Stream Processing with Apache Flink中文版--第5章 DataStream API
本章介绍了Flink的DataStream API的基础知识。我们展示了一个典型的Flink流应用程序的结构和组件,讨论了Flink的类型系统和支持的数据类型,并给出了数据和分区转换。下一章将讨论窗口操作符、基于时间的转换、有状态操作符和连接器。阅读本章之后,您将了解如何实现具有基本功能的流处理应用程序。我们在代码示例中使用Scala,但是Java API基本上是类似的(将指出例外或特殊情况)。我们还在GitHub知识库中提供了用Java和Scala实现的完整示例应用程序(https://github.com/streaming-with-flink/)。
Hello,Flink!
让我们从一个简单的示例开始,了解使用DataStream API编写流应用程序是什么样子。我们将使用这个示例来展示Flink程序的基本结构,并介绍DataStream API的一些重要特性。我们的示例应用程序接收来自多个传感器的温度测量数据流。
首先,让我们看看表示传感器读数的数据类型:
传感器数据的Scala case类
case class SensorReading(id: String,timestamp: Long,temperature: Double)
下面的程序将温度从华氏度转换为摄氏度,并计算每个传感器每五秒的平均温度。
例5-1示例 每5秒计算一次传感器数据流的平均温度
// 在Scala object的main()方法中定了DataStream程序
object AverageSensorReadings {
// main()方法定义并执行DataStream程序
def main(args: Array[String]) {
// 设置流执行环境信息
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 在DataStream程序中使用事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
//通过一个stream source创建 DataStream[SensorReading]
val sensorData: DataStream[SensorReading] = env
// 通过SensorSource SourceFunction获取传感器读数
.addSource(new SensorSource).setParallelism(4)
// 设置时间戳和水印(事件时间 必须)
.assignTimestampsAndWatermarks(new SensorTimeAssigner)
val avgTemp: DataStream[SensorReading] = sensorData
// 使用内联lambda函数把华氏温度转换成摄氏温度
.map( r => {
val celsius = (r.temperature - 32) * (5.0 / 9.0)
SensorReading(r.id, r.timestamp, celsius)
} )
// 根据传感器id组织读数
.keyBy(_.id)
// 每5秒使用tumbling windows分组读数
.timeWindow(Time.seconds(5))
// 使用用户定义的函数计算平均温度
.apply(new TemperatureAverager)
// 打印结果流到标准输出
avgTemp.print()
// 执行应用程序
env.execute("Compute average sensor temperature")
}
}
您可能已经注意到,Flink程序是用常规的Scala或Java方法定义并提交执行的。通常,这是在静态mian方法中完成的。在我们的示例中,我们定义了averagesensorreading对象,并将大部分应用程序逻辑包含在main()中。
一个典型的Flink流处理应用程序的结构包括以下几个部分:
-
设置执行环境
-
从数据源读取一个或多个流
-
应用流转换来实现应用程序逻辑
-
可选地将结果输出到一个或多个数据接收器
-
执行这个项目
现在,我们使用上面的示例详细研究这些部分。
设置执行环境
Flink应用程序需要做的第一件事是设置它的执行环境。执行环境确定程序是在本地机器上运行还是在集群上运行。在DataStream API中,应用程序的执行环境由StreamExecutionEnvironment表示。在我们的示例中,我们通过调用getExecutionEnvironment()来检索执行环境。此方法返回本地或远程环境,具体取决于调用该方法的上下文。如果从具有到远程集群连接的提交客户机上,调用该方法,则返回远程执行环境。否则,它返回一个本地环境。
也可以显式地创建本地或远程执行环境,如下所示:
创建本地或远程执行环境
// 创建一个本地流执行环境
val localEnv: StreamExecutionEnvironment.createLocalEnvironment()
// 创建有一个远程流执行环境
val remoteEnv = StreamExecutionEnvironment.createRemoteEnvironment(
"host", // JobManager的主机名
1234, // JobManager进程的端口
"path/to/jarFile.jar) // 要发送到JobManager的JAR文件
传递给JobManager的JAR文件必须包含执行流应用程序所需的所有资源。
接着,我们使用env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)来设置我们的程序使用事件时间来解释时间语义。执行环境允许更多的配置选项,比如设置程序并行性和启用容错。
读取输入流
一旦配置了执行环境,就该执行一些实际工作并开始处理流了。StreamExecutionEnvironment提供了一些方法来创建将数据流注入应用程序的流源。数据流可以从消息队列、文件等源获取,也可以动态生成。
在我们的例子中,我们使用
val sensorData: DataStream[SensorReading] = env.addSource(new SensorSource)
连接到传感器测量的源,并创建一个SensorReading类型的初始DataStream。Flink支持许多数据类型,我们将在下一节中对此进行描述。这里,我们使用Scala case类作为我们之前定义的数据类型。传感器读数包含传感器id、表示测量时间的时间戳和测量的温度。以下两个方法通过调用setParallelism(4)将输入数据源配置为并行度为4并分配时间戳和水印,这些时间戳和水印是使用assignTimestampsAndWatermarks(new SensorTimeAssigner)处理事件所需的。SensorTimeAssigner的实现细节暂时不需要我们关注。
应用转换
一旦有了DataStream,就可以对其应用转换。有不同类型的转换。一些转换可以生成新的DataStream(可能是不同类型),转换不修改DataStream的记录,而是通过分区或分组对其进行重新组织。应用程序的逻辑由链接转换定义。
在我们的示例中,我们首先应用map()转换,它将每个传感器读数的温度转换为摄氏温度。然后,我们使用keyBy()转换根据传感器id对传感器读数进行分区。随后,我们定义一个timeWindow()转换,它将每个传感器id分区的传感器读数分组为5秒的滚动窗口。
val avgTemp: DataStream[SensorReading] = sensorData
.map( r => {
val celsius = (r.temperature - 32) * (5.0 / 9.0)
SensorReading(r.id, r.timestamp, celsius)
} )
.keyBy(_.id)
.timeWindow(Time.seconds(5))
.apply(new TemperatureAverager)
下一章将详细描述窗口转换。最后,我们应用一个用户定义函数(UDF)来计算每个窗口的平均温度。在本章的后面,我们将详细讨论如何在DataStream API中定义udf。
输出结果
流应用程序通常将其结果发送到某些外部系统,如Apache Kafka、文件系统或数据库。Flink提供了一个维护良好的流接收器集合,可用于将数据写入不同的系统。也可以实现自己的流接收器。还有一些应用程序不输出结果,但将结果保留在内部,通过Flink的可查询状态特性提供服务。
在我们的示例中,结果是一个DataStream[SensorReading],包含每个传感器的每5秒平均测量温度。通过调用print()将结果流写入标准输出。
avgTemp.print()
注意
请注意,流接收器的选择将影响应用程序的端到端一致性,无论应用程序的结果是否提供至少一次或完全一次语义。应用程序的端到端一致性取决于所选流接收器与Flink的检查点算法的集成。我们将在“应用程序一致性保证”中更详细地讨论这个主题。
执行
当应用程序被完全定义后,可以通过调用StreamExecutionEnvironment.execute()来执行它。这是我们例子中的最后一个调用:
env.execute("Compute average sensor temperature")
Flink程序是惰性执行的。也就是说,到目前为止,所有创建流源和转换的方法都没有导致任何数据处理。相反,执行环境构建了一个执行计划,该计划从环境中创建的所有流源开始,并包括应用于这些源的所有转换。
只有在调用execute()时,系统才会触发程序的执行。
构造的计划被转换成JobGraph并提交给JobManager执行。根据执行环境的类型,JobManager作为本地线程(本地执行环境)启动,或者JobGraph被发送到远程JobManager。如果JobManager是远程运行的,则JobGraph必须与一个JAR文件一起提供,该JAR文件包含应用程序的所有类和所需的依赖项。
转换
在本节中,我们将概述DataStream API的基本转换。与时间相关的操作符,如窗口操作符和其他特殊的转换将在后面的章节中描述。流转换应用于一个或多个流,并将它们转换为一个或多个输出流。编写DataStream API程序本质上可以归结为组合这些转换,创建数据流图,实现应用程序逻辑。
大多数流转换基于用户定义的函数。这些函数封装了用户应用程序逻辑,并定义了如何将输入流的元素转换为输出流的元素。函数,如下面的MapFunction,被定义为实现特定于转换的函数接口的类:
class MyMapFunction extends MapFunction[Int, Int] {
override def map(value: Int): Int = value + 1
}
函数接口定义了需要由用户实现的转换方法,如上面示例中的map()方法。
大多数函数接口被设计成SAM(单个抽象方法)接口,它们可以被实现为Java 8 lambda函数。Scala DataStream API还内置了对lambda函数的支持。在展示DataStream API的转换时,我们展示了所有函数类的接口,但是为了简单起见,在代码示例中主要使用lambda函数而不是函数类。
DataStream API为最常见的数据转换操作提供转换方法。如果您熟悉批处理API、函数式编程语言或SQL,您会发现API概念非常容易掌握。我们将DataStream API的转换分为四类:
-
基本转换:对单个事件的转换。
-
KeyedStream转换:应用于key上下文中事件的转换。
-
多流转换:将多个流合并到一个流中,或将一个流拆分为多个流的转换。
-
分区转换:重新组织流事件数据的转换。
基本转换
基本转换处理单个事件,这意味着每个输出记录都是从单个输入记录生成的。简单的值转换、记录的分割或记录的过滤都是常见的基本函数。我们解释它们的语义并给出代码示例。
map[DataStream -> DataStream]
map转换是通过调用DataStream.map()方法来指定的,并生成一个新的DataStream。它将每个传入事件传递给一个用户定义的mapper,该mapper仅返回一个输出事件(可能是不同类型的输出事件)。图5-1显示了将每个正方形转换成圆形的map转换。
MapFunction的类型是输入和输出事件的类型,可以使用MapFunction接口指定。它定义了map()方法,将一个输入事件转换成一个输出事件:
// T: the type of input elements
// O: the type of output elements
MapFunction[T, O]
> map(T): O
下面是一个简单的映射器,它提取了输入流中每个“SensorReading”的第一个字段(id):
val readings: DataStream[SensorReading] = ...
val sensorIds: DataStream[String] = readings.map(new MyMapFunction())
class MyMapFunction extends MapFunction[SensorReading,String] {
override def map(r: SensorReading): String = r.id
}
当使用Scala API或Java 8时,mapper也可以表示为lambda函数:
val readings: DataStream[SensorReading] = ...
val sensorIds: DataStream[String] = readings.map(r => r.id)
filter [DataStream -> DataStream]
filter转换通过对每个输入事件,计算布尔条件来删除或转发流中的事件。如果返回值为true,则保留输入事件并将其转发到输出,如果返回值为false,则删除事件。通过调用DataStream.filter()方法来指定filter转换,并生成与输入DataStream类型相同的新DataStream。图5-2显示了只保留白色方块的筛选操作。
布尔条件可以使用FilterFunction接口或lambda函数作为函数实现。FilterFunction接口的类型是输入流的类型,它定义了filter()方法,该方法通过输入事件调用,并返回一个布尔值:
// T: the type of elements
FilterFunction[T]
> filter(T): Boolean
下面的示例显示了一个过滤器,它将温度低于25华氏度的所有传感器测量值都过滤掉了:
val readings: DataStream[SensorReadings] = ...
val filteredSensors = readings.filter( r => r.temperature >= 25 )
flatMap [DataStream -> DataStream]
flatMap转换类似于map,但它可以为每个传入事件生成零、一个或多个输出事件。实际上,flatMap转换是filter和map的泛化,可以用来实现这两个操作。图5-3显示了一个基于传入事件的颜色来区分其输出的flatMap操作。如果输入是白色方块,则输出事件未修改。黑色方块被复制,灰色方块被过滤掉。
flatMap转换对每个传入事件应用一个函数。对应的FlatMapFunction定义了flatMap()方法,它可以将0、1或多个事件作为结果传递给Collector对象:
// T: the type of input elements
// O: the type of output elements
FlatMapFunction[T, O]
> flatMap(T, Collector[O]): Unit
这个例子展示了数据处理教程中常见的flatMap转换。该函数应用于一个"句子"流,按空格字符分隔每个句子,并将每个产生的单词作为单独的记录发出:
val sentences: DataStream[String] = ...
val words: DataStream[String] = sentences.flatMap(id => id.split(" "))
KeyedStream转换
许多应用程序的一个常见需求是处理一组事件,这组事件有公共的某个属性。DataStream API提供了KeyedStream的抽象,KeyedStream是一个DataStream,它在逻辑上被划分为具有相同key的事件的不相交子流。
在当前处理过的事件的key的上下文中,对KeyedStream进行读写状态的有状态转换。这意味着具有相同key的所有事件访问相同的状态,因此可以一起处理。
注意
请注意,必须小心使用有状态转换和key聚合。如果key域持续增长(例如,因为键是惟一的事务id),则必须清除不再活动的key的状态,以避免内存问题。参考“实现有状态函数”,其中详细讨论了有状态函数。
可以使用前面看到的map、flatMap和filter转换来处理KeyedStream。接着,我们将使用keyBy转换将DataStream转换为KeyedStream,并使用诸如滚动聚合和reduce之类的key转换。
keyBy
keyBy转换通过指定key将DataStream转换为KeyedStream。基于该key,流的事件被分配到分区,因此,具有相同key的所有事件都由后续操作符的相同任务处理。具有不同key值的事件可以由同一任务处理,但任务函数的key值状态始终在当前事件的key值范围内访问。
将输入事件的颜色作为key,图5-4将黑色事件分配给一个分区,将所有其他事件分配给另一个分区。
keyBy()方法接收一个参数,该参数指定分组的key(或多个key)并返回一个KeyedStream。有不同的方法来指定key。我们在“定义key和引用字段”中介绍了它们。下面的代码声明id字段作为传感器读取记录流的key:
val readings: DataStream[SensorReading] = ...
val keyed: KeyedStream[SensorReading, String] = readings.keyBy(r => r.id)
lambda函数r => r.id,提取传感器读数记录r的id字段。
滚动聚合
滚动聚合转换应用于KeyedStream,并生成聚合的数据流,如sum、minimum和maximum。滚动聚合操作符为每个key保留一个聚合值。对于每个传入事件,操作符更新相应的聚合值,并发出带有更新值的事件。滚动聚合不需要用户定义的函数,但是接收一个参数,该参数指定计算聚合的字段。DataStream API提供了以下滚动聚合方法:
-
sum()指定字段上输入流的滚动和。
-
min()指定字段上输入流的滚动最小值。
-
max()指定字段上输入流的滚动最大值。
-
minBy()输入流的滚动最小值,它返回迄今为止观察到的值最小的事件。
-
maxBy()输入流的滚动最大值,它返回迄今为止观察到的值最大的事件。
不可能组合多个滚动聚合方法—一次只能计算一个滚动聚合。
考虑下面的示例,在第一个字段作为key的Tuple3[Int,Int, Int]流,在第二个字段上计算滚动和:
val inputStream: DataStream[(Int, Int, Int)] =
env.fromElements((1, 2, 2), (2, 3, 1), (2, 2, 4), (1, 5, 3))
val resultStream: DataStream[(Int, Int, Int)] = inputStream
.keyBy(0) // tuple的第一个字段作为key
.sum(1) // 将tuple的第二个字段相加
在本例中,元组输入流由第一个字段key,滚动和由第二个字段计算。示例的输出是(1、2、2),然后是(1、7、2),键值为“1”,然后是(2、3、1),然后是(2、5、1),键值为“2”。“第一个字段是key,第二个字段是和,第三个字段没有定义。
只在有界的key域上使用滚动聚合
滚动聚合操作符为处理的每个key保持一个状态。由于这种状态永远不会被清除,所以您应该只对具有有界key域的流,应用滚动聚合操作符。
reduce
reduce变换是滚动聚合的泛化。它在KeyedStream上应用ReduceFunction,它将每个传入事件与当前的reduce值组合起来,并生成一个DataStream。reduce转换不会改变流的类型。输出流的类型与输入流的类型相同。
可以使用实现ReduceFunction接口的类来指定该函数。ReduceFunction定义了reduce()方法,它接受两个输入事件并返回一个相同类型的事件:
// T: the element type
ReduceFunction[T]
> reduce(T, T): T
在下面的例子中,流的key为语言,结果是每个语言不断更新的单词列表:
val inputStream: DataStream[(String, List[String])] =
env.fromElements(("en", List("tea")), ("fr", List("vin")), ("en",List("cake")))
val resultStream: DataStream[(String, List[String])] =
inputStream
.keyBy(0)
.reduce((x, y) => (x._1, x._2 ::: y._2))
lambda reduce函数转传递第一个元组字段(key字段)并连接第二个元组字段的List[String]值。
只在有界的key域上使用ROLLING REDUCE
ROLLING REDUCE操作符为每个被处理的key保持一个状态。由于这种状态永远不会被清除,所以您应该只对具有有界key域的流,应用一个滚动reduce操作符。
多流转换
许多应用程序读取多个流,这些流需要联合处理,或者分割一个流,以便将不同的逻辑应用于不同的子流。我们将讨论处理多个输入流或发出多个输出流的DataStream API转换。
union
union()方法合并两个或多个相同类型的DataStream,并产生一个相同类型的新DataStream。随后的转换处理所有输入流的元素。图5-5显示了将黑色和灰色事件合并到单个输出流中的union操作。
事件以FIFO方式合并—操作符不生成事件的特定顺序。而且,union操作符不执行重复消除。每个输入事件都被发送给下一个操作符。
下面展示了如何将“SensorReading”类型的三个流合并成一个流:
val parisStream: DataStream[SensorReading] = ...
val tokyoStream: DataStream[SensorReading] = ...
val rioStream: DataStream[SensorReading] = ...
val allCities: DataStream[SensorReading] = parisStream.union(tokyoStream, rioStream)
CONNECT, COMAP和COFLATMAP
合并两个流的事件是流处理中非常常见的需求。考虑这样一个应用程序,它监视森林区域,并在有火灾高风险时输出警报。应用程序接收您之前看到的温度传感器读数流和额外的烟雾水平测量流。当温度超过给定的阈值且烟雾水平很高时,应用程序发出火灾警报。
DataStream API提供了连接转换来支持这些用例。DataStream.connect()方法接收一个DataStream并返回一个ConnectedStreams对象,该对象表示两个连接的流:
// first stream
val first: DataStream[Int] = ...
// second stream
val second: DataStream[String] = ...
// connect streams
val connected: ConnectedStreams[Int, String] =first.connect(second)
ConnectedStreams对象提供了map()和flatMap()方法,它们分别将CoMapFunction和CoFlatMapFunction作为参数。您还可以将CoProcessFunction应用于ConnectedStreams。我们将在第6章讨论CoProcessFunction。
这两个函数的类型由第一个和第二个输入流类型,以及输出流类型确定,并定义了两个方法—每个方法对应一个输入。调用map1()和flatMap1()处理第一个输入的事件,调用map2()和flatMap2()处理第二个输入的事件:
// IN1: 第一个输入流类型
// IN2: 第二个输入流类型
// OUT: 输出元素的类型
CoMapFunction[IN1, IN2, OUT]
> map1(IN1): OUT
> map2(IN2): OUT
// IN1: 第一个输入流类型
// IN2: 第二个输入流类型
// OUT: 输出元素的类型
CoFlatMapFunction[IN1, IN2, OUT]
> flatMap1(IN1, Collector[OUT]): Unit
> flatMap2(IN2, Collector[OUT]): Unit
函数不能选择要读取哪个CONNECTEDSTREAMS
不可能控制调用CoMapFunction或CoFlatMapFunction方法的顺序。相反,只要事件通过相应的输入到达,就会立即调用方法。
两个流的联合处理通常需要两个流的事件根据某个条件来确定路由,这些条件由操作符的相同并行实例处理。默认情况下,connect()不会在两个流的事件之间建立关系,因此这两个流的事件被随机分配给操作符实例。这种行为产生不确定的结果,通常是不受欢迎的。为了在ConnectedStreams上实现确定性转换,可以将connect()与keyBy()或broadcast()组合使用。我们首先演示keyBy()案例:
val one: DataStream[(Int, Long)] = ...
val two: DataStream[(Int, String)] = ...
// keyBy two connected streams
val keyedConnect1: ConnectedStreams[(Int, Long), (Int,String)]
= one.connect(two).keyBy(0, 0) // key both input streams on first attribute
// alternative: connect two keyed streams
val keyedConnect2: ConnectedStreams[(Int, Long), (Int,String)]
= one.keyBy(0).connect(two.keyBy(0))
无论您是keyBy() ConnectedStreams还是connect()两个KeyedStreams, connect()转换都将使用相同的key将来自这两个流的所有事件路由到相同的操作符实例。注意,这两个流的key应该引用相同的实体类,就像SQL查询中的连接谓词一样。应用于已连接的key类型流的操作符可以访问key状态。
下一个例子展示了如何连接(非key)数据流与广播流:
val first: DataStream[(Int, Long)] = ...
val second: DataStream[(Int, String)] = ...
// connect streams with broadcast
val keyedConnect: ConnectedStreams[(Int, Long), (Int,String)] = first
// broadcast second input stream
.connect(second.broadcast())
将广播流的所有事件复制并发送给后续处理函数的所有并行操作实例。非广播流的事件只是简单地转发。因此,可以联合处理两个输入流的元素。
注意
可以使用广播状态连接key类型流和广播流。Broadcast state是Broadcast ()-connect()转换的改进版本。它还支持连接key类型流和广播流,并将广播事件存储在托管状态。这允许您实现通过数据流动态配置的操作符(例如,添加或删除过滤规则或更新机器学习模型)。在“使用连接广播状态”中详细讨论了广播状态。
分割(split)和选择(select)
分割是联合变换的逆变换。它将输入流划分为与输入流相同类型的两个或多个输出流。可以将每个传入事件路由到零、一个或多个输出流。因此,split还可以用于过滤或复制事件。图5-6显示了一个split操作符,它将所有白色事件路由到一个单独的流中。
split()方法接收一个OutputSelector,该选择器定义如何将流元素分配给指定的输出。OutputSelector定义了为每个输入事件调用的select()方法,并返回java.lang.Iterable[String]。为记录返回的字符串值指定将记录路由到的输出流。
// IN: the type of the split elements
OutputSelector[IN]
> select(IN): Iterable[String]
DataStream.split()方法返回一个SplitStream,它提供一个select()方法,通过指定输出名称从SplitStream中选择一个或多个流。
5 - 2示例 将一个Tuple数据流拆分为具有较大数字的流和具有较小数字的流。
val inputStream: DataStream[(Int, String)] = ...
val splitted: SplitStream[(Int, String)] = inputStream
.split(t => if (t._1 > 1000) Seq("large") else Seq("small"))
val large: DataStream[(Int, String)] = splitted.select("large")
val small: DataStream[(Int, String)] = splitted.select("small")
val all: DataStream[(Int, String)] = splitted.select("small","large")
注意
分割转换的一个限制是所有输出流的类型都必须与输入类型相同。在“发送到边输出”中,我们给出了处理函数的边输出特性,它可以从一个函数发出多个不同类型的流。
分区转换
分区转换对应于我们在“数据交换策略”中介绍的数据交换策略。这些操作定义如何将事件分配给任务。在使用DataStream API构建应用程序时,系统会根据操作语义和配置的并行性自动选择数据分区策略并将数据路由到正确的目的地。有时,在应用程序级别控制分区策略或定义自定义分区器是必要的或可取的。例如,如果我们知道DataStream的并行分区的负载是倾斜的,那么我们可能希望重新平衡数据,以便均匀地分配后续操作符的计算负载。或者,应用程序逻辑可能要求操作的所有任务接收相同的数据,或者要求按照自定义策略分发事件。在本节中,我们将介绍DataStream方法,这些方法使用户能够控制分区策略或定义自己的分区策略。
注意
注意,keyBy()不同于本节中讨论的分区转换。本节中的所有转换都产生一个DataStream,而keyBy()则产生一个KeyedStream,可以在这个KeyedStream上应用具有key状态访问权的转换。
Random(随机)
随机数据交换策略由DataStream.shuffle()方法实现。该方法按照均匀分布将记录随机分配给后续操作符的并行任务。
Round-Robin(循环)
rebalance()方法对输入流进行分区,以便以循环方式将事件均匀地分配给后续任务。图5-7说明了循环分布转换。
Rescale
rescale()方法以循环方式分发事件,但只分发给后续任务的一个子集。实际上,rescale分区策略提供了一种方法,可以在发送方和接收方任务数量不同时执行轻量级负载再平衡。如果接收方任务的数量是发送方任务数量的倍数,或者相反,则rescale转换更有效。
rebalance()和rescale()的根本区别在于任务连接的形成方式。虽然rebalance()将在所有发送任务与所有接收任务之间创建通信通道,但是rescale()将只创建从每个任务到下游操作符的某些任务的通道。rescale分布变换的连接模式如图5-7所示。
Broadcast
broadcast()方法复制输入数据流,以便将所有事件发送给下游操作符的所有并行任务。
Global
global()方法将输入数据流的所有事件发送到下游操作符的第一个并行任务。必须谨慎使用这种分区策略,因为将所有事件路由到同一任务可能会影响应用程序性能。
Custom
当预定义的分区策略都不合适时,可以使用partitionCustom()方法定义自己的分区策略。此方法接收实现分区逻辑的Partitioner对象和要对流进行分区的字段或key位置。下面的例子分割了一个整数流,这样所有的负数都被发送到第一个任务,所有其他的数都被发送到一个随机任务:
val numbers: DataStream[(Int)] = ...
numbers.partitionCustom(myPartitioner, 0)
object myPartitioner extends Partitioner[Int] {
val r = scala.util.Random
override def partition(key: Int, numPartitions: Int):Int = {
if (key < 0) 0 else r.nextInt(numPartitions)
}
}
设置并行度
Flink应用程序在分布式环境(如计算机集群)中并行执行。当将DataStream程序提交给JobManager执行时,系统将创建一个数据流图,并为执行操作符做好准备。每个操作符被并行化为一个或多个任务。每个任务将处理操作符输入流的一个子集。操作符的并行任务数称为操作符的并行度。它决定了操作符的处理工作(task)可以分布多少,以及可以处理多少数据。
操作符的并行性可以在执行环境级别或每个操作符级别控制。默认情况下,应用程序的所有操作符的并行度设置为应用程序执行环境的并行度。环境的并行性(以及所有操作符的默认并行性)是基于应用程序启动的上下文自动初始化的。如果应用程序在本地执行环境中运行,则将并行度设置为与CPU内核的数量匹配。在将应用程序提交到正在运行的Flink集群时,除非通过提交客户端显式地指定了环境并行度,否则将环境并行度设置为集群的默认并行度(有关详细信息,请参阅“运行和管理流应用程序”)。
通常,定义操作符的并行度相对于环境的默认并行度是一个好主意。这使您可以通过提交客户端调整应用程序的并行性,从而轻松地扩展应用程序。你可以访问环境的默认并行度,如下例所示:
val env: StreamExecutionEnvironment.getExecutionEnvironment
// get default parallelism as configured in the clusterconfig or
// explicitly specified via the submission client.
val defaultP = env.env.getParallelism
你也可以覆盖环境的默认并行度,此时无法通过提交客户端控制你的应用程序的并行度(代码设置的并行度优先级最高):
val env: StreamExecutionEnvironment.getExecutionEnvironment
// set parallelism of the environment
env.setParallelism(32)
操作符的默认并行性可以通过显式指定来覆盖。在下面的例子中,源操作符将以环境的默认并行度执行,map转换的任务数是源操作符的两倍,而sink操作总是由两个并行任务执行:
val env = StreamExecutionEnvironment.getExecutionEnvironment
// get default parallelism
val defaultP = env.getParallelism
// the source runs with the default parallelism
val result: = env.addSource(new CustomSource)
// the map parallelism is set to double the default parallelism
.map(new MyMapper).setParallelism(defaultP * 2)
// the print sink parallelism is fixed to 2
.print().setParallelism(2)
当您通过提交客户端提交应用程序并指定并行度为16时,source程序将以并行度为16运行,mapper程序将以32个任务运行,sink将以2个任务运行。如果您在本地环境中运行应用程序—或者示例,从您的ide上运行一台有8个核心的机器,source程序将运行8个任务,mapper将运行16个任务,sink将运行2个任务。
类型(Types)
Flink DataStream应用程序处理表示为数据对象的事件流。调用的DataSteam函数接收数据对象进行处理,并输出数据对象。在内部,Flink需要能够处理这些对象。需要对它们进行序列化和反序列化,以便通过网络传送它们,或者将它们写入状态存储地、检查点和保存点,或从状态存储地、检查点和保存点读取它们。为了有效地做到这一点,Flink需要应用程序处理的数据类型的详细信息。Flink使用类型信息的概念来表示数据类型,并为每种数据类型生成特定的序列化器、反序列化器和比较器。
Flink还提供了一个类型提取系统,该系统分析函数的输入和返回类型,以自动获取类型信息(类型推断),从而获得序列化器和反序列化器。然而,在某些情况下,例如lambda函数或泛型类型,有必要显式地提供类型信息以使应用程序能够运行或提高其性能。
在本节中,我们将讨论Flink支持的类型,如何为数据类型创建类型信息,以及如果Flink的类型系统不能自动推断函数的返回类型,如何使用提示来帮助它。
支持的数据类型
Flink支持Java和Scala中可用的所有常见数据类型。最广泛使用的类型可分为以下几类:
-
基本类型
-
Java和Scala元组
-
Scala case类
-
pojo,包括Apache Avro生成的类
-
一些特殊的类型
没有经过特殊处理的类型被视为泛型类型,并使用Kryo序列化框架进行序列化。
只使用KRYO作为备用解决方案
注意,如果可能,应该避免使用Kryo。因为Kryo是一种通用的序列化器,所以它通常不是很有效。Flink提供配置选项,通过预先将类注册到Kryo来提高效率。而且,Kryo没有提供一个好的迁移路径来演化数据类型。
让我们看看每种数据类型。
基本类型
支持所有Java和Scala基本类型,如Int(或Java的整数)、String和Double。下面是一个处理Long值数据流并递增每个元素的例子:
val numbers: DataStream[Long] = env.fromElements(1L, 2L,3L, 4L)
numbers.map( n => n + 1)
Java和Scala元组
元组是由固定数量的类型化字段组成的复合数据类型。
Scala DataStream API使用常规的Scala元组。下面的示例过滤一个包含两个字段的元组数据流:
// DataStream of Tuple2[String, Integer] for Person(name,age)
val persons: DataStream[(String, Integer)] =
env.fromElements(
("Adam", 17),
("Sarah", 23))
// filter for persons of age > 18
persons.filter(p => p._2 > 18)
Flink提供了Java元组的有效实现。Flink的Java元组最多可以有25个字段,每个字段的长度作为一个单独的类实现----tuple1、Tuple2、Tuple25。元组类是强类型的。
我们可以在Java DataStream API中重写过滤示例,如下:
// DataStream of Tuple2<String, Integer> for Person(name,age)
DataStream<Tuple2<String, Integer>> persons =
env.fromElements(
Tuple2.of("Adam", 17),
Tuple2.of("Sarah", 23));
// filter for persons of age > 18
persons.filter(p -> p.f1 > 18);
Tuple字段可以通过其public字段的名称(如前面所示,f0、f1、f2等)或使用getField(int pos)方法的位置(其中索引从0开始)进行访问:
Tuple2<String, Integer> personTuple = Tuple2.of("Alex","42");
Integer age = personTuple.getField(1); // age = 42
与Scala相比,Flink的Java元组是可变的,因此可以重新分配字段的值。函数可以重用Java元组,以减少垃圾收集器的压力。下面的示例展示了如何更新Java元组的字段:
personTuple.f1 = 42; // set the 2nd field to 42
personTuple.setField(43, 1); // set the 2nd field to 43
Scala case classes
Flink支持Scala case classes。Case class字段是按名称访问的。在下面,我们定义了一个case类Person:,包括name和age两个字段。至于元组,我们根据age过滤数据流:
case class Person(name: String, age: Int)
val persons: DataStream[Person] = env.fromElements(
Person("Adam", 17),
Person("Sarah", 23))
// filter for persons with age > 18
persons.filter(p => p.age > 18)
POJOs
Flink分析不属于上述分类的类型是,会检查是否可以将其标识为POJO类型并进行处理。Flink接受一个类作为POJO,如果它满足以下条件:
-
这是一个public类。
-
它有一个没有任何参数的public构造函数——默认构造函数。
-
所有字段都是public的,或者可以通过getter和setter访问。getter和setter函数必须遵循默认的命名方案,即Y类型的字段x,对应Y getX()和setX(Y x)。
-
所有字段类型都具有Flink支持的类型。
例如,下列Java类将被Flink标识为POJO
public class Person {
// both fields are public
public String name;
public int age;
// default constructor is present
public Person() {}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
DataStream<Person> persons = env.fromElements(
new Person("Alex", 42),
new Person("Wendy", 23));
avro生成的类由Flink自动识别并处理为pojo。
Arrays, Lists, Maps, Enums,以及其他特殊类型
Flink支持多种特殊用途的类型,如基本数组类型和对象数组类型;Java的ArrayList、HashMap和Enum类型;以及Hadoop Writable类型。此外,它还提供了有关Scala的Either、Option和Try类型的信息,以及Either类型的Flink Java版本。
为数据类型创建类型信息
Flink类型系统的中心类是TypeInformation。它为系统提供了生成序列化器和比较器所需的必要信息。例如,当您通过某个key join或group时,TypeInformation允许Flink执行语义检查,检查作为key使用的字段是否有效。
当应用程序被提交执行时,Flink的类型系统尝试为Flink框架处理的每个数据类型,自动派生TypeInformation。类型提取器分析所有函数的泛型类型和返回类型,以获得相应的TypeInformation对象。因此,您可以暂时使用Flink,而不必担心数据类型的TypeInformation。然而,有时类型提取器会失败,或者您可能希望定义自己的类型并告诉Flink如何有效地处理它们。在这种情况下,需要为特定的数据类型生成TypeInformation。
Flink使用静态方法为Java和Scala提供了两个实用程序类来生成TypeInformation。对于Java, helper类是org.apache.flink.api.common.typeinfo.Types,如下例所示:
// TypeInformation for primitive types
TypeInformation<Integer> intType = Types.INT;
// TypeInformation for Java Tuples
TypeInformation<Tuple2<Long, String>> tupleType =
Types.TUPLE(Types.LONG, Types.STRING);
// TypeInformation for POJOs
TypeInformation<Person> personType =
Types.POJO(Person.class);
Scala API的TypeInformation的helper类是org.apache.flink.api.scala.typeutils.Types,它的使用如下所示:
// TypeInformation for primitive types
val stringType: TypeInformation[String] = Types.STRING
// TypeInformation for Scala Tuples
val tupleType: TypeInformation[(Int, Long)] =
Types.TUPLE[(Int, Long)]
// TypeInformation for case classes
val caseClassType: TypeInformation[Person] =
Types.CASE_CLASS[Person]
SCALA API中的类型信息
在Scala API中,Flink使用Scala编译器宏在编译时为所有数据类型生成TypeInformation对象。要访问createTypeInformation宏函数,请确保始终将以下import语句添加到您的Scala应用程序中:
import org.apache.flink.streaming.api.scala._
显式提供类型信息
在大多数情况下,Flink可以自动推断类型并生成正确的TypeInformation。Flink的类型提取器利用反射并分析函数签名和子类信息,以获得用户定义函数的正确输出类型。但是,有时无法提取必要的信息(例如,因为Java会擦除泛型类型信息)。而且,在某些情况下,Flink可能不会选择生成最有效的序列化器和反序列化器的TypeInformation。因此,您可能需要显式地为Flink应用程序中使用的某些数据类型提供TypeInformation对象。
提供TypeInformation有两种方法。首先,可以通过实现ResultTypeQueryable接口来扩展函数类,显式地提供其返回类型的TypeInformation。下面的示例显示了一个提供返回类型的MapFunction:
class Tuple2ToPersonMapper extends MapFunction[(String, Int),Person]
with ResultTypeQueryable[Person] {
override def map(v: (String, Int)): Person = Person(v._1,v._2)
// provide the TypeInformation for the output data type
override def getProducedType: TypeInformation[Person] =Types.CASE_CLASS[Person]
}
在Java DataStream API中,您还可以使用returns()方法在定义数据流时显式地指定操作符的返回类型,如下图所示:
DataStream<Tuple2<String, Integer>> tuples = ...
DataStream<Person> persons = tuples
.map(t -> new Person(t.f0, t.f1))
// provide TypeInformation for the map lambda function's
return type.returns(Types.POJO(Person.class));
定义key和引用字段
您在前一节中看到的一些转换需要输入流类型上的关键规范或字段引用。在Flink中,key不像在使用键值对的系统中那样在输入类型中预定义。相反,key被定义为输入数据上的函数。因此,没有必要定义数据类型来保存键和值,这避免了大量的样板代码。
在下面,我们将讨论引用字段和定义数据类型上的key的不同方法。
字段位置
如果数据类型是tuple,则可以通过简单地使用相应tuple元素的字段位置来定义key。下面的示例按输入元组的第二个字段定义输入流的key:
val input: DataStream[(Int, String, Long)] = ...
val keyed = input.keyBy(1)
还可以定义由多个元组字段组成的组合键。在本例中,位置以列表的形式提供,一个接一个。我们可以使用第二个和第三个字段定义输入流的key,如下:
val keyed2 = input.keyBy(1, 2)
字段表达式
定义key和选择字段的另一种方法是使用基于字符串的字段表达式。字段表达式适用于元组、pojo和case类。它们还支持选择嵌套字段。在本章的介绍示例中,我们定义了以下case类:
case class SensorReading(
id: String,
timestamp: Long,
temperature: Double)
要使用传感器ID作为数据流的key,我们可以将字段名ID传递给keyBy()函数:
val sensorStream: DataStream[SensorReading] = ...
val keyedSensors = sensorStream.keyBy("id")
POJO或case类字段是通过它们的字段名来选择的,就像上面的例子一样。元组字段通过它们的字段名(Scala元组使用1-offset, Java元组使用0-offset)或它们的0-offset字段索引来引用:
val input: DataStream[(Int, String, Long)] = ...
val keyed1 = input.keyBy("2") // key by 3rd field
val keyed2 = input.keyBy("_1") // key by 1st field
DataStream<Tuple3<Integer, String, Long>> javaInput = ...
javaInput.keyBy("f2") // key Java tuple by 3rd field
pojo和元组中的嵌套字段选择,使用点号(. )标识嵌套级别。有以下case类:
case class Address(
address: String,
zip: String
country: String)
case class Person(
name: String,
birthday: (Int, Int, Int), // year, month, day
address: Address)
如果我们想引用一个人的邮政编码(zip),我们可以使用一个字段表达式:
val persons: DataStream[Person] = ...
persons.keyBy("address.zip") // key by nested POJO field
也可以在混合类型上嵌套表达式。下面的表达式访问嵌套在POJO中的元组字段:
persons.keyBy("birthday._1") // key by field of nested tuple
可以使用通配符字段表达式“_”(下划线字符),选择完整的数据类型:
persons.keyBy("birthday._") // key by all fields of nested tuple
key 选择器
指定key的第三个选项是KeySelector函数。KeySelector函数从输入事件中提取一个key:
// T: the type of input elements
// KEY: the type of the key
KeySelector[IN, KEY]
> getKey(IN): KEY
这个介绍性的例子实际上在keyBy()方法中使用了一个简单的KeySelector函数:
val sensorData: DataStream[SensorReading] = ...
val byId: KeyedStream[SensorReading, String] = sensorData.keyBy(r => r.id)
KeySelector函数接收输入项并返回key。key不一定是输入事件的字段,但可以通过任意计算得到。在下面的代码中,KeySelector函数返回元组字段的最大值作为key:
val input : DataStream[(Int, Int)] = ...
val keyedStream = input.keyBy(value => math.max(value._1,value._2))
与字段位置和字段表达式相比,KeySelector函数的一个优点是由于KeySelector类的泛型类型,结果key是强类型的。
实现函数
到目前为止,您已经在本章的代码示例中看到了用户定义函数的作用。在本节中,我们将更详细地解释在DataStream API中定义和参数化函数的不同方法。
函数类
Flink将用户定义函数(如MapFunction、FilterFunction和ProcessFunction)的所有接口公开为接口或抽象类。
函数是通过实现接口或扩展抽象类来实现的。在下面的例子中,我们实现了一个FilterFunction,它对包含单词“flink”的字符串进行过滤:
class FlinkFilter extends FilterFunction[String] {
override def filter(value: String): Boolean = {
value.contains("flink")
}
}
然后,函数类的一个实例可以作为参数传递给filter转换:
val flinkTweets = tweets.filter(new FlinkFilter)
函数也可以实现为匿名类:
val flinkTweets = tweets.filter(
new RichFilterFunction[String] {
override def filter(value: String): Boolean = {
value.contains("flink")
}
})
函数可以通过它们的构造函数接收参数。我们可以参数化上面的例子,并将字符串“flink”作为参数传递给KeywordFilter构造函数,如下所示:
val tweets: DataStream[String] = ???
val flinkTweets = tweets.filter(new KeywordFilter("flink"))
class KeywordFilter(keyWord: String) extends FilterFunction[String] {
override def filter(value: String): Boolean = {
value.contains(keyWord)
}
}
当一个程序被提交执行时,所有的函数对象都使用Java序列化进行序列化,并传送到其相应操作符的所有并行任务中。因此,在反序列化对象之后,所有配置值都将保留。
函数必须是可JAVA序列化的
Flink使用Java序列化序列化所有函数对象,将它们发送到worker进程。用户函数中包含的所有内容都必须是可序列化的。如果您的函数需要一个非序列化对象实例,您可以将其实现为一个富函数,并在open()方法中初始化非序列化字段,或者覆盖Java序列化和反序列化方法。
Lambda函数
大多数DataStream API方法接受lambda函数。Lambda函数可用于Scala和Java,当不需要访问状态和配置等高级操作时,它提供了一种简单而简洁的方式来实现应用程序逻辑。下面的例子展示了一个lambda函数,它过滤包含单词“flink”的tweet:
val tweets: DataStream[String] = ...
// a filter lambda function that checks if tweets contains the word "flink"
val flinkTweets = tweets.filter(_.contains("flink"))
富函数(Rich Functions)
通常需要在处理第一条数据记录之前初始化一个函数,或者检索关于执行它的上下文的信息。DataStream API提供了Rich Function,这类函数公开的功能比目前讨论的常规函数更多。
所有DataStream API转换函数都有Rich函数版本,您可以在使用常规函数或lambda函数的地方使用它们。Rich函数可以参数化,就像普通的函数类一样。Rich函数的名称以rich开头,然后是转换名称----RichMapFunction、RichFlatMapFunction等等。
当使用一个Rich函数时,你可以实现两个附加的方法到函数的生命周期:
-
open()方法是rich函数的初始化方法。在调用filter或map之类的转换方法之前,对每个任务调用一次。open()通常用于只需要完成一次的设置工作。请注意,Configuration参数仅用于DataSet API,而不用于DataStream API。因此,它应该被忽略。
-
close()方法是函数的终结方法,它在转换方法的最后一次调用之后,针对每个任务调用一次。因此,它通常用于清理和释放资源。
另外,getRuntimeContext()方法提供对函数的RuntimeContext的访问。RuntimeContext可用于检索诸如函数的并行性、子任务索引和执行该函数的任务名称等信息。此外,它还包括访问分区状态的方法。在“实现有状态函数”中详细讨论了Flink中的有状态流处理。下面的示例代码展示了如何使用RichFlatMapFunction的方法。例5-3展示了RichFLatMapFunction的方法
class MyFlatMap extends RichFlatMapFunction[Int, (Int, Int)] {
var subTaskIndex = 0
override def open(configuration: Configuration): Unit = {
subTaskIndex = getRuntimeContext.getIndexOfThisSubtask
// do some initialization
// e.g., establish a connection to an external system
}
override def flatMap(in: Int, out: Collector[(Int, Int)]): Unit = {
// subtasks are 0-indexed
if(in % 2 == subTaskIndex) {
out.collect((subTaskIndex, in))
}
// do some more processing
}
override def close(): Unit = {
// do some cleanup, e.g., close connections to external systems
}
}
包括外部和Flink依赖项
在实现Flink应用程序时,添加外部依赖项是一个常见的需求。有许多流行的库,例如Apache Commons或谷歌Guava,用于不同的场景。此外,大多数Flink应用程序依赖于一个或多个从外部系统(如Apache Kafka、文件系统或Apache Cassandra)获取数据或向外部系统发送数据的Flink连接器。有些应用程序还利用了Flink的特定于域的库,如表API、SQL或CEP库。因此,大多数Flink应用程序不仅依赖于Flink的DataStream API依赖项和Java SDK,而且还依赖于额外的第三方和Flink内部依赖项。
当应用程序执行时,它的所有依赖项必须对应用程序可用。默认情况下,只有核心API依赖项(DataStream和DataSet API)由Flink集群加载。应用程序需要的所有其他依赖项必须显式提供。
这样做的原因是为了保持默认依赖项的数量较低。大多数连接器和库依赖于一个或多个库,这些库通常有几个附加的传递依赖项。通常,这包括经常使用的库,如Apache Commons或谷歌的Guava。许多问题源于同一库的不同版本之间的不兼容性,这些不兼容性来自不同的连接器或直接来自用户应用程序。
有两种方法可以确保应用程序在执行时可以使用所有依赖项:
-
将所有依赖项打包到应用程序JAR文件中。这将产生一个自包含的、但通常相当大的应用程序JAR文件。
-
可以将依赖项的JAR文件添加到Flink设置的./lib文件夹中。在这种情况下,当Flink进程启动时,依赖项被加载到类路径中。像这样添加到类路径的依赖项对在Flink设置上运行的所有应用程序都是可用的。
构建一个所谓的胖JAR文件是处理应用程序依赖项的首选方法。我们在“引导一个Flink Maven项目”中介绍的Flink Maven原型生成Maven项目,这些项目被配置为生成包含所有所需依赖项的应用程序胖jar。默认情况下,包含在Flink进程的类路径中的依赖项将自动排除在JAR文件之外。生成的Maven项目的pom.xml文件包含解释如何添加附加依赖项的注释。
结束语
在本章中,我们介绍了Flink的DataStream API的基础知识。我们研究了Flink程序的结构,并学习了如何结合数据和分区转换来构建流应用程序。我们还研究了受支持的数据类型和指定key和用户定义函数的不同方法。如果你回头再读一遍介绍性的例子,你就会对正在发生的事情有一个更好的理解。在第6章中,事情将变得更加有趣——我们将学习如何用窗口操作符和时间语义来丰富我们的程序。