从Spark 2.0开始,DataFrames和Dataset可以表示静态的,有界的数据,以及流式无界数据。与静态Dataset/DataFrame类似,我们可以使用公共入口点SparkSession从流源创建流式Dataset/DataFrame,并对它们应用与静态Dataset/DataFrame相同的操作。如果我们不熟悉Dataset/DataFrame,可以看之前Spark SQL内容熟悉它们。
创建流式DataFrame和流式Dataset
可以通过SparkSession.readStream()方法返回DataStreamReader接口来创建Streaming DataFrame。与用于创建静态DataFrame的读取接口类似,我们可以指定源的详细信息:数据格式,架构,选项等。
输入源
有一些内置源:
- 文件来源:将目录中写入的文件作为数据流读取。支持的文件格式为text,csv,json,orc,parquet。有关更新的列表,请参阅DataStreamReader接口文档,以及每种文件格式支持的选项。请注意,文件必须以原子形式放在指定目录中,在大多数文件系统中,可以通过文件移动操作来实现。
- Kafka来源:从kafka读取数据。它与kafka 0.10.0或更高版本兼容。
- 套接字源(用于测试):从套接字(socket)连接读取UTF-8文本数据。侦听服务器中位于驱动程序的套接字。注意,此种输入源应仅用于测试,因为这不提供端到端的容错保证。
- 速率源(用于测试):以每秒指定的行数生成数据,每个输出行包含一个timestamp和value。其中timestamp是一个Timestamp含有信息分配的时间类型,并且value是包含消息计数的Long型参数,从0开始作为第一行。此输入源用于测试和基准测试。
某些源不具有容错能力,因为它们无法保证在发生故障后可以使用检查点(checkpoint这可是个好东西,我们专门用其作为我们断线续读的功能实现。)偏移重放数据。
数据源 | 选项 | 容错 | 节点 |
File Source | path :输入目录的路径,并且对所有文件格式都是通用的。 maxFilesPerTrigger :每个触发器中要考虑的最大新文件数(默认值:无最大值) latestFirst :是否先处理最新的新文件,当存在大量积压文件时有用(默认值:false) fileNameOnly :是否基于以下方法检查新文件只有文件名而不是完整路径(默认值:false)。将此设置为“true”时,以下文件将被视为同一文件,因为它们的文件名“dataset.txt”是相同的: “file:///dataset.txt” “s3:// a / dataset.txt“ ”s3n://a/b/dataset.txt“ ”s3a://a/b/c/dataset.txt“ | Yes | 支持glob路径,但不支持多个以逗号分隔的路径/globs。 |
Socket Source | host :要连接的主机,必须指定port :要连接的端口,必须指定 | No | |
Rate Source |
源代码将尽力达到目标 | Yes | |
Kafka Source | 请参阅Kafka集成指南。 | Yes |
SparkSession spark = ...
// Read text from socket
Dataset<Row> socketDF = spark
.readStream()
.format("socket")
.option("host", "localhost")
.option("port", 9999)
.load();
socketDF.isStreaming(); // Returns True for DataFrames that have streaming sources
socketDF.printSchema();
// Read all the csv files written atomically in a directory
StructType userSchema = new StructType().add("name", "string").add("age", "integer");
Dataset<Row> csvDF = spark
.readStream()
.option("sep", ";")
.schema(userSchema) // Specify schema of the csv files
.csv("/path/to/directory"); // Equivalent to format("csv").load("/path/to/directory")
这些示例生成无类型的流式DataFrame,这意味着在编译时不检查DataFrame的架构,仅在提交查询时在运行时检查。某些操作(如map,flatMap等)需要在编译时知道类型。要执行这些操作,我们可以使用与静态DataFrame相同的方法将这些无类型流式DataFrame转换为类型化流式数据集。
流式DataFrames/Datasets的模式推理和分区
默认情况下,基于文件的源和结构化流需要我们指定架构,而不是依靠Spark自动推断它。此限制可确保即使再出现故障的情况下,也将使用一致的架构进行流式查询。对于临时用例,我们可以通过设置spark.sql.streaming.schemaInference为重新启用架构推断true。
当命名的子目录/key=value/存在且列表将自动递归到这些目录中时,确实回发生分区发现。如果这些列出现在用户提供的模式中,则Spark将根据正在读取的文件的路径填充它们。构成分区方案的目录必须在查询开始时存在,并且必须保持静态。例如,当/data/year=2015已经存在添加/data/year/2016也是可以的,但更改分区列(及通过创建目录/data/data=2016-04-17/)无效。
流式传输DataFrames/Datasets的操作
我们可以将DataFrames/Datasets从无类型转换为流操作,类似于Spark SQL中的操作(select,where,groupBy),为键入RDD般的操作(例如map,filter,flatMap)。
基本操作:选择,映射,聚合
DataFrame/Dataset上的大多数常见操作都支持流式传输。
import org.apache.spark.api.java.function.*;
import org.apache.spark.sql.*;
import org.apache.spark.sql.expressions.javalang.typed;
import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder;
public class DeviceData {
private String device;
private String deviceType;
private Double signal;
private java.sql.Date time;
...
// Getter and setter methods for each field
}
Dataset<Row> df = ...; // streaming DataFrame with IOT device data with schema { device: string, type: string, signal: double, time: DateType }
Dataset<DeviceData> ds = df.as(ExpressionEncoder.javaBean(DeviceData.class)); // streaming Dataset with IOT device data
// Select the devices which have signal more than 10
df.select("device").where("signal > 10"); // using untyped APIs
ds.filter((FilterFunction<DeviceData>) value -> value.getSignal() > 10)
.map((MapFunction<DeviceData, String>) value -> value.getDevice(), Encoders.STRING());
// Running count of the number of updates for each device type
df.groupBy("deviceType").count(); // using untyped API
// Running average signal for each device type
ds.groupByKey((MapFunction<DeviceData, String>) value -> value.getDeviceType(), Encoders.STRING())
.agg(typed.avg((MapFunction<DeviceData, Double>) value -> value.getSignal()));
我们还可以将流式DataFrame/Dataset注册为临时视图,然后在其上应用SQL命令。
df.createOrReplaceTempView("updates");
spark.sql("select count(*) from updates"); // returns another streaming DF
注意,我们可以使用df.isStreaming()方法确定DataFrame/Dataset是否具有流数据
df.isStreaming();
事件时间的窗口操作
使用结构化流式传输时,滑动事件时间窗口上的聚合非常简单,并且与分组聚合非常相似。在分组聚合中,为用户指定的分组列中的每个唯一值维护聚合值(例如,计数)。在基于窗口的聚合的情况下,为每个窗口维护一行的时间时间的聚合值。让我们通过一个例子来理解这一点。
想象一下,我们的示例已被修改,流现在包含行以及生成行的时间。我们不想获取字数,而是计算10分钟内的单词,每5分钟更新一次。也就是说,在10分钟窗口12:00-12:10,12:05-12:15,12:10-12:20等之间收到的单词数量。注意,12:00-12:10表示数据在12:00之后但在12:10之前到达。现在,考虑一下在12:07收到的一个字。这个字应该增加对应于两个窗口12:00-12:10和12:05-12:15的计数。因此,计数将由分组键(即单词)和窗口(以可从事件事件计算)索引。
结果表看起来如下所示。
由于此窗口类似于分组,因此在代码中,我们可以使用groupBy()和window()操作来表示窗口化聚合。如下代码。
Dataset<Row> words = ... // streaming DataFrame of schema { timestamp: Timestamp, word: String }
// Group the data by window and word and compute the count of each group
Dataset<Row> windowedCounts = words.groupBy(
functions.window(words.col("timestamp"), "10 minutes", "5 minutes"),
words.col("word")
).count();
处理延迟数据和水印
现在考虑如果其中一个事件到达应用程序的后期会发生什么。例如,应用程序在12:11可以接收在12:04(即事件时间)生成的单词。应用程序应用使用时间12:04而不是12:11来更新窗口的旧计数12:00-12:10.这在我们基于窗口的分组中自然发生:结构化可以长时间维持部分聚合的中间状态,以便于后期数据可以正确更新旧窗口的聚合,如下所示。
但是,要运行此查询数天,系统必须限制它累积的中间内存中状态的数量。这意味着系统需要知道何时可以从内存状态中删除旧聚合,因为应用程序不再接收该聚合的后期数据。为了实现这一点,在Spark2.1中引入了水印,使引擎能够自动跟踪数据中的当前事件事件并尝试相应地清理旧状态。我们可以通过指定事件时间列以及根据事件时间预计数据地延迟时间来定义查询的水印。对于从时间开始的特定窗口T,引擎将保持状态并允许延迟数据更新状态直到(max event time seen by the engine - late threshold > T)。换句话说,阈值内的后期数据将被聚合,但是晚于阈值的数据将开始被丢弃。让我们通过一个例子来理解这一点。我们可以使用withWatermark()方法如下所示的前一个示例轻松定义水印。
Dataset<Row> words = ... // streaming DataFrame of schema { timestamp: Timestamp, word: String }
// Group the data by window and word and compute the count of each group
Dataset<Row> windowedCounts = words
.withWatermark("timestamp", "10 minutes")
.groupBy(
functions.window(words.col("timestamp"), "10 minutes", "5 minutes"),
words.col("word"))
.count();
在这个例子中,我们在“timestamp”列的值上定义查询的水印,并且还将“10分钟”定义为允许数据延迟的阈值。如果此查询在更新输出模式下运行,则引擎将继续更新结果表中窗口的计数,直到窗口早于水印,该水印落后于列中的当前事件事件“时间戳”10分钟。
如图所示,蓝色虚线是引擎跟踪的最大事件时间,并且在(max event time - ‘10 mins’)每个触发器开始时设置的水印是红线。例如,当引擎观察数据时(12:14,dog),它设置水印为下一个触发器为12:04.该水印使发动机保持中间状态另外10分钟,以允许计算延迟数据。例如,数据(12:09,cat)是乱序的,而且很晚,它落在窗口12:00-12:10和12:05-12:15。由于12:04仍然位于触发器中的水印之前,因此引擎仍将中间计数保持为状态并正确更新相关窗口的计数。但是,当水印更新为12:11,窗口的中间状态(12:00-12:10)被清除,所有后续数据(例如12:04,donkey)被认为“太晚”,因此被忽略。请注意,在每次触发后,更新的计数(即紫色行)将作为触发输出写入sink,如更新模式所示。
某些接收器(例如文件)可能不支持更新模式所需的细粒度更新。为了使用它们,Spark还支持附加模式,其中只有最终计数被写入接收器。
注意,withWatermark在非流式数据集上使用是no-op。由于水印不应以任何方式影响任何批量查询,Spark将直接忽略它。
与之前的更新模式类似,引擎维护每个窗口的中间计数。但是,部分计数不会更新到结果表,也不会写入接收器。引擎等待“10分钟”以计算延迟日期,然后丢弃窗口水印的中间状态,并将最终计数附加到结果表/接收器。例如,12:00 - 12:10
只有在水印更新到之后,窗口的最终计数才会附加到结果表中12:11
。
用于清除聚合状态的水印的条件
值得注意的是,在聚合查询中,水印清除状态必须满足以下条件。
- 输出模式必须为Append或Update。完整模式要求保留所有聚合数据,因此不能使用水印来降低中间状态。
- 聚合必须具有事件时间或事件时间列上的一个window。
- withWatermark必须在与聚合中使用的时间戳列相同的列上调用。例如,
df.withWatermark("time", "1 min").groupBy("time2").count()
在追加输出模式下无效,因为水印是在与聚合列不同的列上定义的。 - withWatermark必须在聚合之前调用要使用的水印细节。例如,df.groupBy("time").count().withWatermark("time", "1 min")在追加输出模式下无效。
带水印聚合的语义保证
- 水印延迟(设置为
withWatermark
)为“2小时”可确保引擎永远不会丢弃延迟小于2小时的任何数据。换句话说,任何不到2小时(在事件时间方面)的数据都保证汇总到那时处理的最新数据。 - 但是,保证只在一个方向严格。延迟2小时以上的数据不能保证被丢弃;它可能会也可能不会聚合。更多延迟的数据,引擎进行处理的可能性较小。
接下来会讲解Join()的主要用法。