Spark Structured Streaming 编程权威指南点击这里可看全文
文章目录
Structured Streaming是建立在Spark SQL引擎上的可扩展和容错的流处理引擎。您可以使用Scala、Java、Python或R中的Dataset/DataFrame API来表达流聚合、事件时间窗口、流到批处理的连接等操作,而无需考虑流处理的细节。Structured Streaming提供了快速、可扩展、容错、端到端精确一次性的流处理功能。
默认情况下,Structured Streaming使用微批处理引擎进行查询处理,将数据流作为一系列小批量作业处理,实现了低至100毫秒的端到端延迟和精确一次性容错保证。然而,自Spark 2.3以来,引入了连续处理模式,可以实现低至1毫秒的端到端延迟,并提供至少一次性保证。在不更改查询中的Dataset/DataFrame操作的情况下,可以根据应用程序需求选择处理模式。
本指南将介绍Structured Streaming的编程模型和API。首先,我们将使用默认的微批处理模型来解释概念,然后讨论连续处理模型。让我们从一个简单的例子开始,演示如何使用Structured Streaming进行流式单词计数。
快速示例
假设您想要从一个监听在 TCP 套接字上的数据服务器中维护一个单词计数器。让我们看看如何使用 Structured Streaming 来表达这个需求。
首先,我们需要导入必要的类并创建一个本地的 SparkSession,作为与 Spark 相关的所有功能的起点。
from pyspark.sql import SparkSession
from pyspark.sql.functions import explode, split
spark = SparkSession \
.builder \
.appName("StructuredNetworkWordCount") \
.getOrCreate()
接下来,让我们创建一个流式 DataFrame,表示从 localhost:9999
上接收到的文本数据,并对 DataFrame 进行转换以计算单词计数。
# 创建表示从连接到 localhost:9999 的输入行流的 DataFrame
lines = spark \
.readStream \
.format("socket") \
.option("host", "localhost") \
.option("port", 9999) \
.load()
# 将行拆分为单词
words = lines.select(
explode(split(lines.value, " ")).alias("word")
)
# 生成单词计数
wordCounts = words.groupBy("word").count()
这个 lines
DataFrame 表示一个包含流式文本数据的无限表。该表包含一个名为 value
的字符串列,其中每一行都代表流式文本数据中的一行。
接下来,我们使用两个内置的 SQL 函数 split
和 explode
,将每一行拆分为多行,每行一个单词。通过 alias
函数给新列命名为 word
。最后,我们通过对 Dataset 中的唯一值进行分组并计数,定义了 wordCounts
DataFrame,表示流式数据中各个单词的计数。
现在,我们已经设置好了对流数据的查询。剩下的就是实际开始接收数据并计算计数了。我们将输出结果以完整模式(outputMode("complete")
)打印到控制台,并使用 start()
开始流式计算。
# 开始运行查询,将运行计数结果打印到控制台
query = wordCounts \
.writeStream \
.outputMode("complete") \
.format("console") \
.start()
query.awaitTermination()
执行此代码后,流式计算将在后台开始。query
对象是对活动流式查询的处理句柄,我们使用 awaitTermination()
等待查询结束,以防止进程在查询仍在运行时退出。
要实际执行这个示例代码,您可以编译自己的 Spark 应用程序中的代码,或者在下载了 Spark 后直接运行示例。我们展示的是后者。
首先,您需要使用以下命令将 Netcat(大多数类 Unix 系统中都有的小型实用程序)作为数据服务器运行:
$ nc -lk 9999
然后,在另一个终端中,您可以使用以下命令启动示例:
$ ./bin/spark-submit examples/src/main/python/sql/streaming/structured_network_wordcount.py localhost 9999
然后,在运行 Netcat 服务器的终端中,您在终端中输入的每一行都将被计数并每秒打印到屏幕上。输出结果类似于以下内容:
# TERMINAL 1:
# 运行 Netcat
$ nc -lk 9999
apache spark
apache hadoop
...
# TERMINAL 2: 运行 structured_network_wordcount.py
$ ./bin/spark-submit examples/src/main/python/sql/streaming/structured_network_wordcount.py localhost 9999
-------------------------------------------
Batch: 0
-------------------------------------------
+------+-----+
| value|count|
+------+-----+
|apache| 1|
| spark| 1|
+------+-----+
-------------------------------------------
Batch: 1
-------------------------------------------
+------+-----+
| value|count|
+------+-----+
|apache| 2|
| spark| 1|
|hadoop| 1|
+------+-----+
...
编程模型
在Structured Streaming中,将实时数据流视为不断追加的表。这导致了一种类似于批处理模型的新的流处理模型。您可以将流式计算表达为标准的类似批处理的查询,就像对静态表进行查询一样,而Spark会将其作为对无界输入表的增量查询来运行。让我们更详细地了解这个模型。
基本概念
将输入数据流视为"输入表"。每个到达的数据项都类似于追加到输入表中的新行。
通过对输入进行查询,将生成"结果表"。每个触发间隔(例如,每1秒),新的行被追加到输入表中,从而最终更新结果表。当结果表被更新时,我们希望将已更改的结果行写入外部存储器。
"输出"定义了要写入外部存储器的内容。输出可以以不同的模式定义:
- Complete模式:将整个更新后的结果表写入外部存储器。具体如何处理写入整个表由存储连接器决定。
- Append模式:只有自上次触发以来附加在结果表中的新行将被写入外部存储器。这仅适用于结果表中的现有行不会发生更改的查询。
- Update模式:只有自上次触发以来在结果表中更新的行将被写入外部存储器(自Spark 2.1.1以来可用)。请注意,这与Complete模式不同,该模式仅输出自上次触发以来发生变化的行。如果查询不包含聚合操作,则与Append模式等效。
请注意,每种模式适用于特定类型的查询。后面将详细讨论这一点。
为了说明这个模型的使用,让我们结合上面的快速示例来理解该模型。第一个lines DataFrame是输入表,最终的wordCounts DataFrame是结果表。请注意,对流式的lines DataFrame进行查询以生成wordCounts的方式与对静态DataFrame进行查询完全相同。然而,当启动此查询时,Spark将持续检查套接字连接中的新数据。如果有新数据,Spark将运行"增量"查询,将之前的计数与新数据组合起来计算更新的计数,如下所示。
请注意,Structured Streaming不会将整个表材料化。它从流式数据源中读取最新可用的数据,以增量方式处理并更新结果,然后丢弃源数据。它只保留所需的最小中间状态数据来更新结果(例如前面示例中的中间计数)。
这个模型与许多其他流处理引擎有很大的不同。许多流处理系统要求用户自己维护运行聚合,从而必须考虑容错性和数据一致性(至少一次、最多一次或精确一次)。在这个模型中,Spark负责在有新数据时更新结果表,从而使用户无需考虑这些问题。作为一个例子,让我们看看这个模型如何处理基于事件时间的处理和迟到的数据。
处理事件时间和迟到数据
事件时间是数据本身所嵌入的时间。对于许多应用程序,您可能希望基于这个事件时间进行操作。例如,如果您想要获取每分钟由物联网设备生成的事件数量,则可能希望使用数据生成的时间(即数据中的事件时间),而不是Spark接收到它们的时间。这个事件时间在这个模型中非常自然地表达 - 设备的每个事件是表中的一行,事件时间是行中的一个列值。这使得基于窗口的聚合(例如每分钟的事件数量)只是对事件时间列进行特殊类型的分组和聚合 - 每个时间窗口是一个分组,每一行可以属于多个窗口/分组。因此,这种基于事件时间窗口的聚合查询可以一致地定义在静态数据集(例如从收集的设备事件日志)上以及数据流上,这使用户的工作更加简单。
此外,这个模型自然地处理了相对于事件时间来说到达较晚的数据。由于Spark正在更新结果表,因此它完全控制着在有延迟数据时如何更新旧的聚合,并清理旧的聚合以限制中间状态数据的大小。自Spark 2.1版本以来,我们引入了水印机制,允许用户指定延迟数据的阈值,并使引擎能够相应地清理旧的状态。这些将在稍后的窗口操作部分中更详细地解释。
容错语义
提供端到端的准确一次性语义是Structured Streaming设计的关键目标之一。为了实现这一点,我们已经设计了Structured Streaming的源、接收器和执行引擎,以可靠地跟踪处理进度,从而能够通过重新启动和/或重新处理来处理任何类型的故障。假设每个流式源都有偏移量(类似于Kafka的偏移量或Kinesis的序列号)来跟踪流中的读取位置。引擎使用检查点和预写日志来记录每个触发器中正在处理的数据的偏移范围。流式接收器被设计为对重新处理具有幂等性。通过使用可重放的源和幂等的接收器,Structured Streaming可以在任何故障情况下保证端到端的准确一次性语义。
使用Datasets和DataFrames的API
自Spark 2.0版本以来,DataFrames和Datasets可以表示静态有界数据,也可以表示流式无界数据。与静态的DataFrames类似,您可以使用通用入口点SparkSession(Scala/Java/Python/R文档)从流式源创建流式DataFrames/Datasets,并对它们应用与静态DataFrames/Datasets相同的操作。如果您对Datasets/DataFrames不熟悉,强烈建议您通过DataFrame/Dataset编程指南来熟悉它们。
创建流式DataFrames和流式Datasets
通过SparkSession.readStream()方法(Scala/Java/Python文档)返回的DataStreamReader接口可以创建流式DataFrames。在R中,使用read.stream()方法。与用于创建静态DataFrames的读取接口类似,您可以指定源的详细信息 - 数据格式、模式、选项等。
输入源
有一些内置的数据源。
- 文件源:作为数据流读取目录中的文件。文件将按照文件修改时间的顺序进行处理。如果设置了latestFirst,则顺序将被颠倒。支持的文件格式包括文本、CSV、JSON、ORC和Parquet。有关更详细的列表和每个文件格式支持的选项,请参阅DataStreamReader接口的文档。请注意,文件必须以原子方式放置在给定的目录中,在大多数文件系统中,可以通过文件移动操作来实现。
- Kafka源:从Kafka读取数据。它与Kafka代理版本0.10.0或更高版本兼容。有关更多详细信息,请参阅Kafka集成指南。
- Socket源(用于测试):从套接字连接中读取UTF8文本数据。监听服务器套接字位于驱动程序上。请注意,这仅适用于测试,因为它不能提供端到端的容错保证。
- Rate源(用于测试):以指定的每秒行数生成数据,每个输出行包含时间戳和值。其中时间戳是一个包含消息分发时间的Timestamp类型,值是一个包含消息计数的Long类型,从0开始作为第一行。此源用于测试和基准测试。
- Rate Per Micro-Batch源(用于测试):以指定的每个微批次行数生成数据,每个输出行包含时间戳和值。其中时间戳是一个包含消息分发时间的Timestamp类型,值是一个包含消息计数的Long类型,从0开始作为第一行。与Rate源不同,该数据源提供了每个微批次的一致输入行集,无论查询执行情况如何(触发器配置、查询滞后等),例如,批次0将生成0999,批次1将生成10001999,依此类推。生成的时间也是如此。此源用于测试和基准测试。
由于某些源无法保证在故障后使用检查点偏移量进行数据重播,因此它们不具备容错性。有关Spark中所有数据源的详细信息,请参阅文档。
Source | Options | Fault-tolerant | Notes |
---|---|---|---|
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” - maxFileAge : 在忽略之前可以在此目录中找到的文件之前的最大时间间隔。对于第一个批次,所有文件都被认为是有效的。如果设置了latestFirst 和maxFilesPerTrigger ,则此参数将被忽略,因为可能会忽略旧的但仍然有效的文件。最大年龄是相对于最新文件的时间戳而不是当前系统的时间戳。(默认值:1周)- cleanSource : 选项以在处理后清理完成的文件。可用选项为"archive"、“delete”、“off”。如果未提供选项,则默认值为"off"。当提供"archive"时,必须同时提供附加选项 sourceArchiveDir 。"sourceArchiveDir"的值不能与源模式在深度(从根目录开始的目录数)上匹配,其中深度是两个路径中深度最小的那个。这将确保归档文件永远不会被视为新的源文件。Spark将根据其自己的路径移动源文件。例如,如果源文件的路径是 /a/b/dataset.txt,存档目录的路径是 /archived/here,文件将被移动到 /archived/here/a/b/dataset.txt。 注意: - 删除或移动已完成的文件都会引入额外的开销(即使在单独的线程中进行),每个微批次都会减慢速度。因此,在启用此选项之前,您需要了解文件系统中每个操作的成本。 - 启用此选项将减少列出源文件的成本,列出操作可能是一个昂贵的操作。 - 已完成文件清理器中使用的线程数可以通过spark.sql.streaming.fileSource.cleaner.numThreads进行配置(默认值:1)。 - 删除和移动操作都是尽力而为的。无法删除或移动文件不会导致流式查询失败。在某些情况下,Spark可能无法清理一些源文件,例如应用程序没有正常关闭,太多文件排队等待清理。 |
是 | 支持通配符路径,但不支持逗号分隔的多个路径/通配符。 |
Socket Source | - host : 要连接的主机,必须指定。- port : 要连接的端口,必须指定。 |
否 | |
Rate Source | - rowsPerSecond (例如100,默认值:1):每秒生成的行数。- rampUpTime (例如5s,默认值:0s):在生成速度达到rowsPerSecond之前,经过多长时间才开始逐渐加速生成。精度比秒更小的值将被截断为整数秒。- numPartitions (例如10,默认值:Spark的默认并行度):生成行的分区数。源将尽力达到rowsPerSecond,但查询可能受资源限制,可以调整numPartitions来帮助达到所需的速度。 |
是 | |
Rate Per Micro-Batch Source (格式:rate-micro-batch) | - rowsPerBatch (例如100):每个微批次要生成的行数。- numPartitions (例如10,默认值:Spark的默认并行度):生成行的分区数。- startTimestamp (例如1000, 默认值:0):生成时间的起始值。- advanceMillisPerBatch (例如1000, 默认值:1000):在每个微批次中生成时间时,时间提前的量。 |
是 | |
Kafka Source | 详见Kafka集成指南。 | 是 |
以下是一些示例。
spark = SparkSession. ...
# Read text from socket
socketDF = spark \
.readStream \
.format("socket") \
.option("host", "localhost") \
.option("port", 9999) \
.load()
socketDF.isStreaming() # 返回具有流式源的DataFrame为True
socketDF.printSchema()
# Read all the csv files written atomically in a directory
userSchema = StructType().add("name", "string").add("age", "integer")
csvDF = spark \
.readStream \
.option("sep", ";") \
.schema(userSchema) \
.csv("/path/to/directory") # 等同于 format("csv").load("/path/to/directory")
这些示例生成了无类型的流式DataFrame,这意味着在编译时不会检查DataFrame的模式,只有在提交查询时运行时才会检查。一些操作(如map、flatMap等)需要在编译时知道类型。为了做到这一点,可以使用与静态DataFrame相同的方法将这些无类型的流式DataFrame转换为有类型的流式Dataset。更多细节请参阅SQL编程指南。此外,支持的流式源的更多详细信息将在本文档后面讨论。
自Spark 3.1版本以来,您还可以使用DataStreamReader.table()从表创建流式DataFrame。更多详细信息请参阅Streaming Table APIs。
流式DataFrame/Dataset的模式推断和分区
默认情况下,从基于文件的源读取的结构化流处理需要您指定模式,而不是依赖Spark自动推断。这个限制确保即使在发生故障时,流查询仍然使用一致的模式。对于临时使用情况,您可以通过将spark.sql.streaming.schemaInference
设置为true来重新启用模式推断。
当存在以/key=value/
命名的子目录时,会自动发现分区。如果这些列出现在用户提供的模式中,Spark将根据正在读取的文件的路径来填充它们。组成分区方案的目录必须在查询开始时存在并保持静态。例如,添加/data/year=2016/
而存在/data/year=2015/
是可以的,但更改分区列(例如创建目录/data/date=2016-04-17/
)是无效的。
对流式DataFrame/Dataset的操作
您可以对流式DataFrame/Dataset应用各种操作,从无类型的类SQL操作(如select、where、groupBy),到有类型的RDD-like操作(如map、filter、flatMap)。详细信息请参阅SQL编程指南。以下是一些可用的示例操作。
基本操作 - 选择、投影、聚合
大多数常见的DataFrame/Dataset操作都支持流式处理。以下是一些示例操作:
df = ... # 具有模式 { device: string, deviceType: string, signal: double, time: DateType } 的流式DataFrame
# 选择信号大于10的设备
df.select("device").where("signal > 10")
# 每个设备类型更新次数的累计计数
df.groupBy("deviceType").count()
您还可以将流式DataFrame/Dataset注册为临时视图,然后在其上应用SQL命令。
df.createOrReplaceTempView("updates")
spark.sql("select count(*) from updates") # 返回另一个流式DataFrame
通过使用df.isStreaming()
方法,您可以确定DataFrame/Dataset是否具有流式数据。
df.isStreaming()
在查询中注入了有状态操作后,您可能希望检查查询计划,以了解有状态操作的影响。有状态操作包括输出模式、水印、状态存储大小维护等。
Window Operations on Event Time
在使用结构化流进行滑动事件时间窗口的聚合时,与分组聚合非常相似。在分组聚合中,根据用户指定的分组列维护每个唯一值的聚合值(例如计数)。而在基于窗口的聚合中,根据事件时间将行所属的窗口维护聚合值。下面通过一个例子来理解这个过程。
假设我们修改了示例,并且流现在包含了行以及生成行的时间。不再进行词频统计,而是想要在10分钟的窗口内统计单词数量,每5分钟更新一次。也就是说,在12:00 - 12:10、12:05 - 12:15、12:10 - 12:20等10分钟的窗口内接收到的单词数量。注意,12:00 - 12:10表示在12:00之后但在12:10之前到达的数据。现在,考虑一个在12:07接收到的单词。这个单词应该增加两个窗口的计数:12:00 - 12:10和12:05 - 12:15。因此,计数将根据分组键(即单词)和窗口索引(可以从事件时间计算得出)。
结果表如下所示:
窗口操作
由于窗口操作类似于分组操作,在代码中可以使用groupBy()和window()操作来表示窗口聚合。以下是Scala/Java/Python示例的完整代码。
words = ... # 包含{ timestamp: Timestamp, word: String}模式的流DataFrame
# 按窗口和单词分组数据并计算每个组的数量
windowedCounts = words.groupBy(
window(words.timestamp, "10 minutes", "5 minutes"),
words.word
).count()
处理延迟数据和水印
现在考虑一个事件到达应用程序时出现延迟的情况。例如,假设一个在12:04(事件时间)生成的单词可能在12:11被应用程序接收。应用程序应该使用12:04而不是12:11来更新12:00 - 12:10窗口的旧计数。在基于窗口的分组中,这种情况可以自然处理–结构化流可以维护部分聚合的中间状态长时间以便能够正确地更新旧窗口的聚合。下面是一个示例来说明这个过程。
为了连续运行这个查询几天,系统需要限制中间内存状态的累积量。这意味着系统需要知道何时可以从内存状态中删除旧的聚合,因为应用程序不再接收与该聚合相关的延迟数据。为了实现这一点,在Spark 2.1中引入了水印功能,它可以自动跟踪数据中的当前事件时间,并尝试相应地清理旧状态。您可以通过指定事件时间列和预期的数据延迟阈值来定义查询的水印。对于特定于T时刻结束的窗口,引擎将维护状态并允许延迟数据更新状态,直到(引擎看到的最大事件时间 - 延迟阈值 > T)。换句话说,阈值内的延迟数据将被聚合,但是超过阈值的数据将开始被丢弃。
以下是一个示例,通过withWatermark()方法在之前的例子中定义水印:
words = ... # 包含{ timestamp: Timestamp, word: String}模式的流DataFrame
# 按窗口和单词分组数据并计算每个组的数量
windowedCounts = words \
.withWatermark<