转载请注明出处,谢谢合作~
该篇中的示例暂时只有 Scala 版本~
Structured Streaming 流式编程指南
- 概述(Overview)
- 示例程序(Quick Example)
- 编程模型(Basic Concepts)
- 基本概念(Basic Concepts)
- 处理时间时间和延迟数据(Handling Event-time and Late Data)
- 容错语义(Fault Tolerance Semantics)
- Datasets 和 DataFrames API(API using Datasets and DataFrames)
- 创建流式 DataFrame 和 Dataset(Creating streaming DataFrames and streaming Datasets)
- 输入数据源(Input Sources)
- 流式 DataFrame/Dataset 中的模式推断和分区(Schema inference and partition of streaming DataFrames/Datasets)
- 流式 DataFrame/Dataset 算子(Operations on streaming DataFrames/Datasets)
- 基础算子——选择,投影,聚合(Basic Operations - Selection, Projection, Aggregation)
- 基于事件事件的窗口算子(Window Operations on Event Time)
- 处理延迟数据和水印机制(Handling Late Data and Watermarking)
- 连接算子(Join Operations)
- 流式数据与静态数据连接(Stream-static Joins)
- 流式数据与流式数据连接(Stream-stream Joins)
- 借助水印(可选)进行内连接(Inner Joins with optional Watermarking)
- 借助水印进行外连接(Outer Joins with Watermarking)
- 流式查询中的连接操作(Support matrix for joins in streaming queries)
- 流式数据去重(Streaming Deduplication)
- 处理多重水印的策略(Policy for handling multiple watermarks)
- 自定义有状态算子(Arbitrary Stateful Operations)
- 不支持的算子(Unsupported Operations)
- 全局水印的限制(Limitation of global watermark)
- 启动流式查询(Starting Streaming Queries)
- 输出模式(Output Modes)
- 数据输出(Output Sinks)
- 使用Foreach 和 ForeachBatch(Using Foreach and ForeachBatch)
- ForeachBatch
- Foreach
- 使用Foreach 和 ForeachBatch(Using Foreach and ForeachBatch)
- 触发器(Triggers)
- 管理流式查询(Managing Streaming Queries)
- 监控流式查询(Monitoring Streaming Queries)
- 交互式访问统计信息(Reading Metrics Interactively)
- 通过异步 API 编程报告统计信息(Reporting Metrics programmatically using Asynchronous APIs)
- 使用 Dropwizard 报告统计信息(Reporting Metrics using Dropwizard)
- 通过 Checkpoint 从失败中恢复(Recovering from Failures with Checkpointing)
- 流式查询任务逻辑变更后的重启恢复语义(Recovery Semantics after Changes in a Streaming Query)
- 创建流式 DataFrame 和 Dataset(Creating streaming DataFrames and streaming Datasets)
- Continuous 计算模式(Continuous Processing)
- 更多信息(Additional Information)
概述
Structured Streaming 是一个基于 Spark SQL 的兼具扩展性和容错性的流式处理引擎。可以像在静态数据集上执行批处理计算一个表达流处理逻辑,Spark SQL 引擎会负责整个执行流程,包括增量,持续的计算新到来的数据,并更新最终的计算结果。可以使用不同语言(Scala, Java, Python 或者 R)的 Dataset/DataFrame API 来表达流式聚合计算,时间时间窗口计算,流批连接计算等等。执行层使用的是与 Spark SQL 相同的优化引擎。最后,系统可以保证在 checkpoint 和预写日志(WAL) 机制下的端到端 exactly-once 处理语义。简而言之,Structed Streaming 是一个提供了高速,可扩展,容错,端到端 exactly-once 处理语义的流处理执行引擎,而不需要用户对流处理有很深的理解。
默认情况下,Structured Streaming 在内部使用 micro-batch processing 执行引擎,该引擎会把数据流切分成一系列的小批次来处理,所以端到端的延迟大概在 100 ms 左右,而且保证 exactly-once 容错处理语义。然而,从 Spark 2.3 开始,引进了一种叫做 Continuous Processing 的低延迟处理模型,可以将端到端的延迟降低至 1 ms,保证 at-least-once 的容错语义。可以根据应用程序的需要选择处理模型,而不必改变计算中的 Dataset/DataFrame 算子逻辑。
本篇文档会带你了解编程模型以及 API,会介绍在 micro-batch 计算模型中最常用到的概念,之后讨论 Continuous Processing 计算模型。首先,让我们从一个简单的 Structured Streaming 示例开始——一个流式 word count 程序。
示例程序
假如你想维持一个一直在运行的 word count 程序,文本来源是一个 TCP socket,让我们看看如何用 Structed Streaming 来实现。完整的代码参见 Scala/Java/Python/R。如果下载了 Spark(download Spark),可以直接运行示例程序(run the example)。无论如何,让我们一步一步拆解程序,看看具体是怎么实现的。首先,必须引入需要的类然后创建一个本地 SparkSession 对象,Spark 应用程序的入口。
import org.apache.spark.sql.functions._
import org.apache.spark.sql.SparkSession
val spark = SparkSession
.builder
.appName("StructuredNetworkWordCount")
.getOrCreate()
import spark.implicits._
接下来,创建一个文本数据的 DataFrame,数据来源是监听地址 localhost:9999
收到的文本信息,之后对 DataFrame 进行操作计算单词计数。
// Create DataFrame representing the stream of input lines from connection to localhost:9999
val lines = spark.readStream
.format("socket")
.option("host", "localhost")
.option("port", 9999)
.load()
// Split the lines into words
val words = lines.as[String].flatMap(_.split(" "))
// Generate running word count
val wordCounts = words.groupBy("value").count()
DataFrame 对象 lines
代表了一个包含流式文本数据的无界表,该表有一个类型为字符串的列,列名为「value」,流式文本数据中的每一行都成为表中的一行数据。注意,目前只是在描述数据转换流程,并没有接收任何数据,程序并没有启动。下一步,通过 .as[String]
方法将 DataFrame 转换为一个字符串类型的 Dataset,以便于使用 flatMap
算子来分割一行文本成为多个单词,结果 Dataset 对象 words
包含了所有的单词。最后,通过对 words
中的唯一值进行分组操作之后计数来定义 DataFrame 对象 wordCounts
。 注意这是一个流式的 DataFrame,表示流数据上的 word count。
现在创建流数据的查询对象,剩下的就只有真正的启动程序接收数据执行计算。设置每次数据更新后就将所有结果(指定 outputMode("complete")
)打印到控制台,然后通过 start()
方法启动这个流式计算任务。
// Start running the query that prints the running counts to the console
val query = wordCounts.writeStream
.outputMode("complete")
.format("console")
.start()
query.awaitTermination()
当程序运行之后,流失计算会在后台运行,query
是流式查询任务的管理对象,通过调用 awaitTermination()
方法可以等待程序运行结束,避免在执行过程中自动退出。
为了真正执行该程序,可以在自己的应用程序(Spark application)中编译代码,也可以在下载 Spark 之后直接运行示例程序(run the example)。这里采用后者演示,运行程序需要首先启动一个 Netcat 程序(类 Unix 系统中的一个小工具)作为数据提供方:
$ nc -lk 9999
之后,在另一个终端,启动示例程序:
$ ./bin/run-example org.apache.spark.examples.sql.streaming.StructuredNetworkWordCount localhost 9999
然后,在运行 Netcat 的终端输入文本,你会看到在示例程序运行的终端中单词计数会被打印到控制台,如下所示:
# TERMINAL 1: Running Netcat | # TERMINAL 2: RUNNING StructuredNetworkWordCount |
---|---|
$ nc -lk 9999 apache spark apache hadoop ... |
$ ./bin/run-example org.apache.spark.examples.sql.streaming.StructuredNetworkWordCount localhost 9999 ------------------------------------------- Batch: 0 ------------------------------------------- +-------+-------+ | value | count | +-------+-------+ |apache| 1 | | spark | 1 | +-------+------+ ------------------------------------------- Batch: 1 ------------------------------------------- +-------+-------+ | value | count | +-------+-------+ |apache| 2 | | spark | 1 | |hadoop| 1 | +-------+-------+ ... |
编程模型
Structured Streaming 中最关键的改建就是将一个实时数据流当做一张一直在追加写表来看待,由此衍生出一种新的非常像批处理的流数据处理模型。可以采用和标准批处理一样的查询来表达流数据计算流程,就像在静态数据表上进行查询一样,Spark 会解决在无界数据表上增量查询的问题。接下来更深入的理解该模型。
基本概念
我们把输入数据流当做「输入表」,每一条从数据流中到达的数据都像是追加写到输入表中的新的一行。
一个在输入表上的查询会生成一张「结果表」,每次计算的触发(假如为 1 秒),都有新的数据追加到输入表中,并最终更新结果表。每当结果表被更新,都将结果表中更新的部分写入到外部系统中。
[图片上传失败...(image-8d4f62-1593958431013)]
「输出」被定义为写出到外部存储系统的数据,输出类型存在不同的模式:
- Complete Mode - 被更新的完整的结果集会被写出到外部存储系统中,具体写出完整结果集的行为和最终状态的结果取决于连接器如何定义。
- Append Mode - 只有自上一次触发计算后在结果集中追加的新数据才会被写出到外部存储系统。这种方式只有在结果集中已存在的数据不会被更新的场景下才适用。
- Update Mode - 只有自上一次触发计算后在结果集中被更新的数据才会被写出到外部存储系统(该模式发布于 Spark 2.1.1)。注意,这种模式跟 Complete 模式不同,Update 模式只输出自上一次触发计算以来被更新的数据,如果查询中不包含聚合计算,该模式和 Append 模式是一样的。
注意,每种模式都有一些特定的查询场景,后面(later)再讨论这个话题。
为了说明该模型如何使用,在此以上面的示例( Quick Example)来阐述。DataFrame 对象 lines
是那张输入表,最后面的 DataFrame 对象 wordCounts
是结果表。注意,从 lines
生成 wordCounts
的过程和在一个静态表上的操作如出一辙。然而,当这个查询启动之后,Spark 会持续的检查 socket 连接中是否有新数据到来。如果有新数据,Spark 会执行一个「增量的」计算,通过之前的计算结果和新来的数据一起计算更新后的结果集,如下所示。
[图片上传失败...(image-55b967-1593958431013)]
注意,Structured Streaming 并不会物化整张输入表(所有已到来的数据)。Spark 会从流式数据源中读取最新可用的数据,增量的处理这部分数据来更新结果集,之后便会丢弃这部分数据。Spark 只会保留更新结果集所需要的最小的中间状态(例如,前面示例中单词计数结果的中间状态)。
该流式计算模型和许多其他的流处理引擎很不一样。许多流处理系统需要用户自己维护聚合状态,所以必须面对数据容错和数据一致性(至少处理一次,最多处理一次,精确处理一次)。在该模型中,Spark 负责在新数据到来的时候更新结果集,让用户不至于头大。比如说,让我们来看看该模型如何面对基于事件时间的流计算以及延迟到来的数据。
处理事件时间和延迟数据
事件时间是数据中自带的时间,对许多应用来说,需要以事件时间为准来处理数据。例如,如果需要计算每分钟内获 IoT 设备产生的事件数量,你可能想要使用数据生成的时间(也就是数据中的事件时间),而不是 Spark 接收到该事件时的时间。事件时间在该模型中被表达的很自然——设备中的每一个事件都是表中的一行数据,而事件时间就是其中的一列。这种方式让基于窗口的聚合(比如说每分钟内的计数)操作变成了一个特定的针对事件时间的分组聚合操作——每个时间窗口就是一组,每一行数据都可能属于不同的窗口。所以,可以在静态数据(比如说收集起来的事件日志)和流式数据上定义思路一致的聚合操作(基于事件时间窗口的聚合查询),从而让用户感到轻松。
此外,该模型也可以很自然的处理基于事件时间的延迟数据。由于只更新结果集,当延迟数据到来时 Spark 对更新之前的聚合结果有着完全的控制,也能够及时清理过期的聚合结果以免中间状态变得太大。自 Spark 2.1 开始,Spark 支持了水印机制,可以让用户指定延迟数据的界限,并相应的清理过期状态。详情参见 Window Operations 章节。
容错语义
精确一次交付语义是 Structed Streaming 的设计中一个关键的目标。为了实现该目标,Spark 提供了 Source 接口,Sink 接口和执行引擎,来可靠的追踪整个计算过程,通过重启任务或者重计算来处理任何形式的失败情况。每一种 Source 都假设有一个偏移量(类似于 Kafka 当中的偏移量或者 Kinesis 当中的序列号)来跟踪数据流中的读取位置,执行引擎通过检查点(checkpoint)和预写日志(WAL,write-ahead logs)机制来记录在每次触发计算过程中流数据的偏移量的区间。而 Sink 接口被设计为拥有幂等性,来处理重计算。所有这些设计一起,包括可重放的 Source 和幂等性的 Sink,Structed Streaming 能够在任何失败情况想保证精确一次交付语义(end-to-end exactly-once semantics)。
Datasets 和 DataFrames API
从 Spark 2.0 开始,DataFrame 和 Dataset 可以表示静态的有界的数据集,也可以表示流式的无界的数据集。与静态 Dataset/DataFrame 相似,可以通过通用的编程入口 SparkSession
(Scala/Java/Python/R) 从流式数据源中创建流式 Dataset/DataFrame,使用和静态 Dataset/DataFrame 一样的算子。如果还不熟悉 Dataset/DataFrame,强烈建议你先了解 DataFrame/Dataset Programming Guide。
创建流式 DataFrame 和 Dataset
流式 DataFrame 可以通过 SparkSession.readStream()
方法返回的 DataStreamReader
(Scala/Java/Python) 接口对象来创建。在 R 语言中,是通过 read.stream()
方法。与创建静态 DataFrame 的 read 方法类似,可以指定流式数据源的详细信息——数据格式,表结构,选项等等。
输入数据源
这里有一些内置的数据源。
- File source - 将写入到一个目录的文件当做流式数据源。文件将会按照修改时间的顺序依次计算。如果设置了
latestFirst
选项,顺序将会反过来。支持的文件格式有 text, CSV, JSON, ORC, Parquet。关于最新的支持格式以及各种格式的选项参见 DataStreamReader 接口文档。注意,文件必须被原子的放入指定目录,所以在大多数文件系统中,需要通过移动或者更名操作来实现。 - Kafka source - 从 Kafka 中读取数据,兼容 Kafka 0.10.0 及以上版本。详情参见 Kafka Integration Guide。
- Socket source (for testing) - 从一个 socket 连接中读取 UTF8 编码的文本,监听的 socket 在 driver 端。注意,该数据源并不支持端到端的容错机制,只应该用来测试。
- Rate source (for testing) - 每秒钟生成指定数量的数据,每条生成的数据行包含一个
timestamp
字段和一个value
字段。timestamp
字段是时间戳类型,表示数据生成的时间;字段是长整形,表示数据值,从 0 开始。该数据源主要用来测试和基准测试。
某些数据源不不具有容错性,因为在任务失败后数据无法使用检查点存储的偏移量保证失败的数据被重放,详情参见前面的章节 fault-tolerance semantics。下面是 Spark 中内置数据源的各种选项。
Source | Options | 容错 | Notes |
---|---|---|---|
File source | path :输入目录的路径,适用于所有的数据格式。maxFilesPerTrigger :每次触发计算时最多包含的文件数量,默认为无限制。latestFirst :是否优先处理最新的文件,适用于文件积压的场景。默认值为 false。fileNameOnly :是否只通过文件名来检查是否为新文件,而不是通过完整路径。默认值为 false。设置为 true 时,下列文件将会被认为是相同的文件,因为他们的文件名一样。"file:///dataset.txt" "s3://a/dataset.txt" "s3n://a/b/dataset.txt" "s3a://a/b/c/dataset.txt" maxFileAge :文件的最大「年龄」,小于该时间的文件将会被忽略。在第一个批次中,所有的文件都会认为是有效的。如果 latestFirst 选项设置为 true,同时也设置了 maxFilesPerTrigger 选项,则该选项会被忽略,因为有效的应该被计算的老文件可能会被忽略。该选项的默认值为 1 week。文件最大年龄的指定是考虑到最新更新的文件的时间戳,而不是当前系统的时间。cleanSource :是否在计算完之后清理文件,可选的值有「archive」、「delete」、「off」,默认值为「off」。如果采用「archive」选项,也必须指定 sourceArchiveDir 选项,sourceArchiveDir 的值必须不能和数据源路径的模式相匹配,这样可以保证归档后的文件不会出现在新文件中。例如,假如将 '/hello?/spark/*' 当做路径模式,'/hello1/spark/archive/dir' 就不能用做 sourceArchiveDir 的值,因为 '/hello1/spark/archive' 是匹配模式 '/hello?/spark/*' 的,'/hello1/spark' 也不能用作 sourceArchiveDir 的值,因为 '/hello1/spark' 也是匹配 '/hello?/spark/*' 模式的,'/archived/here' 可以使用因为它和路径模式不匹配。Spark 会将文件移动到归档目录(连同自身的目录一起),例如,如果数据源路径是 /a/b/dataset.txt ,归档目录是 /archived/here ,文件会被移动到 /archived/here/a/b/dataset.txt 。注意 1:在每一个批次中,归档和删除完成计算的文件会带来额外的开销(即使实在不同的线程执行),所以在开启该选项之前需要了解每种操作在文件系统中的开销。另一方面,开启该选项可以减少遍历文件的开销。清理完成计算的文件的线程数量可以通过配置 spark.sql.streaming.fileSource.cleaner.numThreads 指定,默认值为 1。注意 2:在开启该选项之后,所配置的数据源路径不应该再由其他的流式任务所使用。类似的,也必须保证数据源路径不能和输出路径(file stream sink)相匹配。 注意 3:删除和移动文件的行为都会尽力完成,如果执行失败,整个应用程序也会失败。在某些情况下 Spark 可能不会清理数据源文件——例如,应用程序没有被优雅的停止,很多文件都在队列中等待被清理。 对于文件类型的流式数据源的特定选项,可以查看 DataStreamReader (Scala/Java/Python/R) 中的相关方法。例如,对于「Parquet」格式的选项,参考 DataStreamReader.parquet() 。此外,有一些会话级别的配置影响特定的文件格式,详情参见 SQL Programming Guide,例如,对于「Parquet」格式,参考 Parquet configuration 章节。 |
Yes | ,支持通配符路径,但是不支持以逗号分割的多个路径。 |
Socket Source | host :socket 的主机,必须指定。port :socket 的端口,必须指定。 |
No | |
Rate Source | rowsPerSecond :默认值为 1,一秒钟会生成的数据数量。rampUpTime :默认值为 0s,花多长时间达到指定速率 rowsPerSecond ,使用比秒更细粒度的值会被截断到秒。 numPartitions :默认值为 Spark 的默认并行度。分区数。该数据源会尽可能的达到 rowsPerSecond 指定的速率,但是可能会受资源的约束以及分区 numPartitions 的影响而达不到该数据生成速率。 |
Yes | |
Kafka Source | 详见 Kafka Integration Guide. | Yes |
这里有一些示例。
val spark: SparkSession = ...
// Read text from socket
val 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
val userSchema = new StructType().add("name", "string").add("age", "integer")
val 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 转换成类型确定的流式 Dataset,详情参见 SQL Programming Guide。另外,更多有关所支持的流式数据源的详情会在文档后面讨论。
流式 DataFrame/Dataset 中的模式推断和分区
默认情况下,Structured Streaming 中基于文件类型的数据源需要指定数据模式,而不是指望 Spark 自动推断。这个限制保证了在流式查询中都使用相同的模式,即使任务失败。对于特定的场景,可以通过配置参数 spark.sql.streaming.schemaInference
为 true
来重新开启模式推断。
如果子目录的名称符合模式,会自动进行分区发现,同时遍历递归扫描所有的子目录。如果这些列出现在了用户自定义的模式中,Spark 会自动填充这些字段。在应用程序启动之后,用于分区发现的目录字段必须保持不变,例如,在 /data/year=2015/
分区存在的情况下可以添加 /data/year=2016/
分区,但是改变分区的列名是无效的(比如创建一个 /data/date=2016-04-17/
目录)。
流式 DataFrame/Dataset 算子
可以在流式 DataFrame/Dataset 上使用所有的算子——包括无类型的类 SQL 的算子(例如 select
, where
, groupBy
),以及强类型的类 RDD 的算子(例如 map
, filter
, flatMap
)。详情参见 SQL programming guide。下面来看一些示例。
基础算子——选择,投影,聚合
DataFrame/Dataset 从最常用的算子都支持流式数据,少数不支持的算子之后再讨论(discussed later)。
case class DeviceData(device: String, deviceType: String, signal: Double, time: DateTime)
val df: DataFrame = ... // streaming DataFrame with IOT device data with schema { device: string, deviceType: string, signal: double, time: string }
val ds: Dataset[DeviceData] = df.as[DeviceData] // 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(_.signal > 10).map(_.device) // using typed APIs
// 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
import org.apache.spark.sql.expressions.scalalang.typed
ds.groupByKey(_.deviceType).agg(typed.avg(_.signal)) // using typed API
还可以将一个流式的 DataFrame/Dataset 注册成一个临时视图,然后执行 SQL 命令。
df.createOrReplaceTempView("updates")
spark.sql("select count(*) from updates") // returns another streaming DF
注意,可以通过方法来确定一个 DataFrame/Dataset 是不是流式的。
df.isStreaming
基于事件时间的窗口算子
Structured Streaming 中在一个滑动事件时间窗口上的聚合操作是很自然的,与分组聚合操作十分相似。在分组聚合中,分组列中的每个唯一的值都会维护一个聚合值(比如说计数)。在基于窗口的聚合操作中,每个窗口(由每个事件时间落入该窗口范围的行组成)都会维护一些聚合值。下面借助插图来理解一下。
假设前面的示例程序(quick example)作了一些改动,每条数据都会附带它的生成时间。现在需要每 5 分钟计算一次之前 10 分钟内的单词计数,也就是说,在以 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()
算子来表达窗口聚合操作。完整的代码示例参见 Scala/Java/Python。
import spark.implicits._
val words = ... // streaming DataFrame of schema { timestamp: Timestamp, word: String }
// Group the data by window and word and compute the count of each group
val windowedCounts = words.groupBy(
window($"timestamp", "10 minutes", "5 minutes"),
$"word"
).count()
处理延迟数据和水印机制
现在来考虑如果一条数据延迟到达的场景。假如一条数据的事件时间是 12:04
,但是直到 12:11
Structured Streaming 程序才收到这条数据。程序应该用时间 12:04 来更新窗口 12:00 - 12:10
的计数,而不是用 12:11
。这在基于窗口的分组聚合操作中是很自然的事情,Structured Streaming 在一个比较长的时间区间内维护了多个聚合值的中间状态,所以在延迟数据到来时候还是可以正确的更新老窗口的聚合值,如下图所示。
[图片上传失败...(image-4a56a6-1593958431013)]
然而,如果长时间运行该程序,就需要控制中间状态占用的内存。这就意味着系统需要知道何时一个老的窗口的中间状态可以被清理,因为程序将不再接收该窗口的延迟数据了。为此,从 Spark 2.1 开始,引入了水印(watermarking)机制,水印可以让执行引擎自动追踪最新数据的事件时间,并尝试清理旧的中间状态。可以通过指定事件时间列和基于事件时间控制延迟数据的阈值来定义水印,对于一个结束时间为 T
的特定的窗口,执行引擎会维护该窗口的状态,允许延迟数据更新状态直到 (执行引擎接收到的最大事件时间 - 数据延迟阈值 > T)
。换句话说,在阈值内的延迟数据会被聚合,之外的数据将会被丢弃(准确的语义参见后面的 later 章节)。现在来看一个示例,可以使用 withWatermark()
方法轻松的在之前的示例中定义水印。
import spark.implicits._
val words = ... // streaming DataFrame of schema { timestamp: Timestamp, word: String }
// Group the data by window and word and compute the count of each group
val windowedCounts = words
.withWatermark("timestamp", "10 minutes")
.groupBy(
window($"timestamp", "10 minutes", "5 minutes"),
$"word")
.count()
在该示例中,在「timestamp」列上定义了一个水印,以「10 分钟」作为延迟数据的阈值。如果该示例以 Update
输出模式运行(参见 Output Modes 章节),执行引擎会一直更新结果集中一个窗口中的计数值直到窗口落后于水印,也就是落后于「timestamp」列中的当前事件时间 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)
)会由于延迟太大而被忽略。注意,在每次触发计算之后,被更新的计数值(也就是紫色的行)会被输出到外部,正如 Update 输出模式的定义一样。
有一些输出系统(比如文件)可能不支持 Update 输出模式需要的细粒度更新,对于这种场景,可以采用 Append 模式,只有计数值最终被确定之后才会被输出到外部系统。如下图所示。
注意,在非流式的 Dataset 上使用 withWatermark
是无效的。水印机制在任何情况下都不会影响批处理的计算结果,会被直接忽略。
与前面的 Update 模式相似,执行引擎会维护每一个窗口的中间计数值。然而,被更新的计数值并不会更新到结果集中,也不会被写入到外部系统。执行引擎会等待「10 分钟」来将延迟数据纳入计算,之后将最终计数值写入到结果集中,并清理该窗口的中间状态。例如,窗口 12:00 - 12:10
的最终计数值只有在水印值更新为 12:11
后才被追加写到结果集中。
水印机制下清理聚合值中间状态的条件
只有在下列条件被满足时,才会处理聚合查询的中间状态(对于 Spark 2.1.1 是这样,在将来版本中可能会改变)。
- 输出模式必须为 Append 或者 Update。Complete 模式需要保存所有的聚合状态,所以无法痛殴水印机制来清理中间状态,不同输出模式的语义详见 Output Modes 章节。
- 聚合操作必须有一个事件时间列,或者作用于事件时间列的
window
函数。 -
withWatermark
方法必须使用局和操作中指定的相同的事件时间列。例如,df.withWatermark("time", "1 min").groupBy("time2").count()
在 Append 输出模式下是无效的,因为水印中指定的列和分组聚合操作中的列不一样。 -
withWatermark
方法必须在分组聚合操作之前被调用,来启用水印机制。例如,df.groupBy("time").count().withWatermark("time", "1 min")
在 Append 输出模式下是无效的。
水印机制下聚合结果值语义
- 一个「2 小时」的水印允许延迟(通过
withWatermark
方法设置)可以保证执行引擎在 2 小时内不会丢弃任何状态数据。也就是说,任何延迟少于 2 小时的数据(根据事件时间)都可以保证参与聚合计算。 - 然而,上述保证是单向的。落后 2 小时更久的数据并不能保证一定被丢弃,它可能参与聚合计算,也可能不参与。数据延迟越大,执行引擎将其纳入计算的机会就越小。
连接算子
Structured Streaming 支持一个流式的 Dataset/DataFrame 和另一个流式的或者静态的 Dataset/DataFrame 进行连接。和前面章节中流式聚合计算相似,流式连接操作的结果也是增量生成的。本章节会然所哪些类型的连接操作(即内连接、外连接等等)支持上述场景。注意在所有支持的连接类型中,流式 Dataset/DataFrame 连接操作的结果和包含相同数据的静态 Dataset/DataFrame 的结果别无二致。
流式数据与静态数据连接
自 Spark 2.0 版本开始,Structured Streaming 支持了流式 DataFrame/Dataset 和静态 DataFrame/Dataset 之间的连接操作(内连接和部分类型的外连接),接下来是一个示例。
val staticDf = spark.read. ...
val streamingDf = spark.readStream. ...
streamingDf.join(staticDf, "type") // inner equi-join with a static DF
streamingDf.join(staticDf, "type", "right_join") // right outer join with a static DF
注意,stream-static 连接不是有状态的,所以不需要状态管理。然而,一些类型的流式-静态连接目前还不支持,参见本章节末(end of this Join section)。
流式数据与流式数据连接
在 Spark 2.3 中,增加了对 stream-stream 连接的支持,也就是说,可以将两个流式 Dataset/DataFrame 进行连接。生成两个流式数据连接结果集的挑战在于,在每一个时刻,连接操作两端的数据集都是不完整的,以至于很难匹配两者的输入项。在一个流中收到的一条数据都有可能和另一个流中还未到达的(在未来会到达的)数据项匹配。所以,对于两个输入流,Spark 会缓存输入的数据当做该流的状态,使之能够将未来的数据和过去的数据进行匹配,生成连接结果。另外,和流式聚合操作类型,Spark 会自动处理延迟的乱序数据,并通过水印机制来清理状态。接下来看看支持哪些类型的 stream-stream 连接,以及如何使用。
借助水印(可选)进行内连接
支持任意列、任意连接条件的内连接。然而,在流式数据持续到来的过程中,由于新来的数据有可能跟所有的老数据中的任意一条匹配,所有的历史数据都会被保存下来,以至于流失状态会无线膨胀。为了避免无限制的状态,必须定义额外的连接条件,从而让无限制的老数据不能和未来的新数据进行匹配,以便将它们清理出状态集。也就是说,必须在连接操作中执行下面的步骤。
- 在两个输入流中都定义水印(延迟阈值),让执行引擎可以获知数据可以延迟多久到达(和流式聚合操作类似)。
- 在两个输入流之间指定基于事件时间的约束条件,以便执行引擎可以判断何时一个输入的老数据不再被另一个输入需要了(即不再满足约束条件)。约束条件可以通过两种方式定义。
- 时间范围连接条件(比如 ...JOIN ON leftTime BETWEEN rightTime AND rightTime + INTERVAL 1 HOUR`)
- 在事件时间窗口上连接(比如
...JOIN ON leftTimeWindow = rightTimeWindow
)
让我们来通过一个示例理解一下。
假如我们想将一个广告效果(当广告被展示时)的数据流和另一个用户点击广告的数据流进行连接操作,来分析广告效果和所引起的点击之间的关联关系。为了能够清理 stream-stream 连接的中间状态,必须定义水印延迟阈值,以及时间约束条件。
- 水印延迟阈值:假如广告呈现和相应的点击操作会分别乱序/延迟最多 2/3 个小时。
- 时间时间范围条件:假如一个点击事件可能发生在广告展示之后 1 个小时以内。
代码回事这样的:
import org.apache.spark.sql.functions.expr
val impressions = spark.readStream. ...
val clicks = spark.readStream. ...
// Apply watermarks on event-time columns
val impressionsWithWatermark = impressions.withWatermark("impressionTime", "2 hours")
val clicksWithWatermark = clicks.withWatermark("clickTime", "3 hours")
// Join with event-time constraints
impressionsWithWatermark.join(
clicksWithWatermark,
expr("""
clickAdId = impressionAdId AND
clickTime >= impressionTime AND
clickTime <= impressionTime + interval 1 hour
""")
)
水印机制下 stream-stream 内连接的语义保证
和水印机制下聚合操作语义(guarantees provided by watermarking on aggregations)类似,一个延迟阈值为「2 小时」的水印可以保证执行引擎不会丢弃延迟超过两个小时的数据,但是延迟超过两个小时的数据可能会也可能不会被计算。
借助水印进行外连接
尽管水印机制和事件时间约束条件的组合对内连接来说是可选的,但是对于外连接来说时必须被指定的。为了生成外连接中的空值,执行引擎必须知道何时一条输入数据不会再匹配另一条未来的数据,所以必须指定水印机制和事件时间约束条件来确保计算结果的正确性。外连接的示例和前面广告的示例及其相似,只是多了一个额外的参数来指定连接类型为外连接。
impressionsWithWatermark.join(
clicksWithWatermark,
expr("""
clickAdId = impressionAdId AND
clickTime >= impressionTime AND
clickTime <= impressionTime + interval 1 hour
"""),
joinType = "leftOuter" // can be "inner", "leftOuter", "rightOuter"
)
水印机制下 stream-stream 外连接的语义保证
有关延迟阈值和数据是否被清理的语义,外连接和内连接(inner joins)相同。
注意事项
有几个关于生成外连接结果集的重要的特点需要注意。
- 外连接中的空值结果会在等待一段时间之后生成,等待时间取决于水印延迟阈值和时间范围约束条件。这是因为执行引擎等待那么长时间来保证没有匹配项存在,并且在将来也不会有匹配项存在。
- 在目前的 micro-batch 执行引擎的实现中,水印在一个 micro-batch 结束时被更新,下一个 micro-batch 会使用更新之后的水印来清理状态和输出结果。由于只在有新数据到来的时候才会触发一次 micro-batch 计算,外连接的计算结果的生成可能会由于流中没有新数据到来而延迟输出。简而言之,如果两个连接操作的输入流中的任何一个在一段时间内没有接收到数据,外连接(左连接或者右链接)的输出结果将会被延迟。
流式查询中的连接操作
Left Input | 右输入 | Join Type | |
---|---|---|---|
Static | Static | All types | 支持,没有流式数据的参与。 |
Stream | Static | Inner | 支持,并且是无状态的。 |
Stream | Static | Left Outer | 支持,并且是无状态的。 |
Stream | Static | Right Outer | 不支持。 |
Stream | Static | Full Outer | 不支持。 |
Static | Stream | Inner | 支持,并且是无状态的。 |
Static | Stream | Left Outer | 不支持。 |
Static | Stream | Right Outer | 支持,并且是无状态的。 |
Static | Stream | Full Outer | 不支持。 |
Stream | Stream | Inner | 支持,可选的连接两端都定义水印,以及时间约束条件来清理状态。 |
Stream | Stream | Left Outer | 条件支持,必须定义右端的水印以及时间约束条件,可选的定义左端的水印来清理状态。 |
Stream | Stream | Right Outer | 条件支持,必须定义左端的水印以及时间约束条件,可选的定义右端的水印来清理状态。 |
Stream | Stream | Full Outer | 不支持。 |
关于支持的连接操作的一些说明:
- 连接操作可以级联,可以
df1.join(df2, ...).join(df3, ...).join(df4, ....)
。 - 自 Spark 2.4 开始,只能在输出模式是 Append 时使用连接操作,其他模式暂时不支持。
- 自 Spark 2.4 开始,在连接算子之前不可以使用非 map 类型的算子。下面是一些不能够使用的场景:
- 不能再连接算子之前使用流式聚合算子。
- 不能再连接算子之前使用
mapGroupsWithState
和flatMapGroupsWithState
算子(Update 输出模式)。
流式数据去重
可以通过事件中唯一的标识符来对流式数据进行去重,这和在静态数据集中根据某一列进行去重一模一样。Spark 会从已到达数据中缓存必要的数量来过滤重复数据。与聚合操作类似,可以也可以不在去重的过程中使用水印。
- 借助 watermark - 如果某条重复数据的到来有最晚时间限制,那么可以在事件时间列上定义水印,根据唯一标识符和事件时间一起去重。执行引擎会根据水印来清理不再期望会有重复数据到来的的老的数据状态。这种方式限制了需要维护的状态大小。
- 不借助 watermark - 由于对于重复数据可能到来的时间没有限制,执行引擎会缓存所有已到达的数据作为中间状态。
val streamingDf = spark.readStream. ... // columns: guid, eventTime, ...
// Without watermark using guid column
streamingDf.dropDuplicates("guid")
// With watermark using guid and eventTime columns
streamingDf
.withWatermark("eventTime", "10 seconds")
.dropDuplicates("guid", "eventTime")
处理多重水印的策略
一个流式查询可以有多个输入流被合并或者连接在一起,每个输入流都可以定义一个有状态算子需要的数据延迟阈值,可以通过 withWatermarks("eventTime", delay)
方法来为每个输入流设置。例如,假设需要对 inputStream1
和 inputStream2
进行连接操作:
inputStream1.withWatermark("eventTime1", "1 hour")
.join(
inputStream2.withWatermark("eventTime2", "2 hours"),
joinCondition)
在执行该查询的过程中,Structured Streaming会单独追踪每一个输入流中的最大事件时间,并基于相应的延迟阈值计算水印,从中选取一个全局水印为有状态算子提供支持。默认情况下,会选择最小的水印作为全局水印,因为这样可以保证在一个流落后于另一个流的情况下没有数据会被错误清理(例如,一个流因为上流的异常而没有接收到数据)。也就是说,全局水印会安全的以最慢的流的节奏向前移动,输出也会相应的延迟。
然而,在某些情况下,可能需要及时获取计算结果,即使那个比较慢的流中可能会有数据丢失。从 Spark 2.4 开始,可以通过设置 SQL 配置项 spark.sql.streaming.multipleWatermarkPolicy
定义多重水印策略来选择全局水印,默认值为 min
,设置为 max
表示选取最大的水印作为全局水印。这样会让全局水印跟随更快的那个流。然而,副作用就是较慢的那个流中的数据会被提前删除。所以,请谨慎使用该配置项。
自定义有状态算子
许多场景需要使用比聚合操作更高级的有状态算子,例如追踪流式数据中的会话事件。对于这样的需求,必须存储自定义的数据类型作为状态,并且在每一次触发计算时对这些状态执行自定义的操作。从 Spark 2.2 开始,可以通过 mapGroupsWithState
算子和更强大的 flatMapGroupsWithState
算子来满足这样的需求。这两个算子都支持在分组数据集上应用自定义的代码来更新自定义的状态。详情参见 API 文档 (Scala/Java) 和示例 (Scala/Java)。
尽管 Spark 无法检查并作出强制要求,状态函数的实现应该符合输出模式的要求。例如,在 Update 模式下 Spark 并不会期望有状态算子发送事件时间大于当前水印加允许延迟阈值之和的数据,而在 Append 模式下有状态算子可以发送这些数据。
不支持的算子
流式 DataFrame/Dataset 不支持某些算子,如下所示:
- 多个流式聚合操作(即一个流式 DataFrame 上链式调用聚合算子)目前还不支持。
- 在流式数据上的 Limit 操作和获取前 N 行数据目前还不支持。
- 在流式数据上的 Distinct 算子目前还不支持。
- 流式数据上的排序算子只能在聚合算子之后,并且需要是 Complete 输出模式。
- 在流式数据上的某些类型的外链接目前还不支持。详情参见 support matrix in the Join Operations section。
此外,有一些 Dataset 算子目前还不支持,它们是 action 类型的算子,会马上返回计算结果,这样的操作在流式数据上并不合理。这些操作可以通过显示的启动一个流式计算任务来实现(详见下一章节)。
-
count()
- 无法从流式数据中返回一个计数值。请使用ds.groupBy().count()
,可以返回一个包含运行时计数值的流式 Dataset。 -
foreach()
- 请使用ds.writeStream.foreach(...)
。 -
show()
- 请使用 console 类型的数据输出 Sink。
如果尝试上述几种算子,会抛出一个类似「operation XYZ is not supported with streaming DataFrames/Datasets」的 AnalysisException
。尽管其中的一些可能会在未来的版本中支持,还是有另外一些算子在流式数据集上很难有效的实现。例如,输入流上的排序操作是不支持的,因为需要持续追踪所有接收到的数据,这就是为什么难以有效实现的原因。
全局水印的限制
在 Append 输出模式中,如果一个有状态算子发送了事件时间大于当前水印加允许延迟阈值之和的数据,它们会成为下游有状态算子中的「延迟数据」(由于使用了全局水印)。注意,这些数据可能会被丢弃。这是全局水印的一个限制,可能会造成潜在性的计算正确性问题。
Spark 会检查逻辑执行计划,在发现这种情况时打印一条警告日志。
所有在下述有状态算子之后的有状态算子都可能会出现上述情形:
- Append 输出模式下的流式聚合操作。
- stream-stream 类型的外连接操作。
- Append 输出模式下的
mapGroupsWithState
和flatMapGroupsWithState
算子(取决于状态函数的实现)。
由于无法检查 mapGroupsWithState
/flatMapGroupsWithState
算子的状态函数,Spark 会假设在 Append 输出模式下状态函数会发送延迟数据。
有一个解决方案:将流式查询分割成多个单状态算子的流,并保证每一次计算的端到端的精确一次语义。保证最后一次计算的端到端的精确一次语义不是必须的。
启动流式查询
一旦定义了一个表示最终计算结果的 DataFrame/Dataset,剩下所需要做的就是启动流式计算任务。必须通过 Dataset.writeStream()
方法返回的 DataStreamWriter
(Scala/Java/Python) 对象来启动任务,并且指定一些下面的参数。
- 数据输出配置:数据格式、输出位置等等。
- 输出模式:决定那些数据会被输出。
- 查询名称:可选,指定唯一的查询名称。
- 触发计算的时间间隔:可选,指定触发计算的时间间隔。如果没有指定,系统会在上一次计算完成之后立即检查是否有新的数据到来。如果一次触发计算的时间点由于上次一计算还未完成而错过了,系统会在上一次计算完成之后立即调度该次计算。
- 检查点路径:对于一些需要保证端到端容错性的数据输出 Sink,指定系统写入检查点信息的路径。该值应该是一个容错的、兼容 HDFS 文件系统的路径。检查点的语义保证将在下一章节讨论。
输出模式
有以下几种输出模式。
- Append 模式(默认) - 默认输出模式,只有上一次计算之后添加到结果集中的新数据会被输出。这种模式只适用于被添加到结果集中的数据不会再被更新的查询,所以,Append 模式可以保证数据只被输出一次(假设 Sink 是容错的)。例如,只包含
select
,where
,map
,flatMap
,filter
,join
等算子的查询支持 Append 模式。 - Complete 模式 - 每次触发计算时都会输出完整的结果集,这种模式适用于聚合查询。
- Update 模式 - (自 Spark 2.1.1 版本之后可用)只有上一次计算之后结果集中被更新的数据会被输出。未来的版本中会添加更多内容。
不同类型的流式查询支持不同的输出模式。
Query Type | Supported Output Modes | Notes | |
---|---|---|---|
Queries with aggregation | 借助事件时间水印的聚合操作 | Append, Update, Complete | Append 模式使用水印来清理过期的聚合状态。但是根据输出模式的语义——数据只会被添加到结果集中一次并最终确定不再更新,窗口聚合操作的输出会延迟到 withWatermark() 指定的阈值之后(即越过了水印阈值)。 详情参见 Late Data 章节。Update 模式使用水印来清理过期的聚合状态。Complete 模式不会清理聚合状态,因为根据定义,该模式每次都将所有的计算结果输出。 |
聚合查询 | Complete, Update | Complete, Update | 由于没有定义水印,旧的聚合状态不会被清理。因为聚合操作会更新结果集,违背模式语义,所以不支持 Append 模式。 |
mapGroupsWithState |
Update | ||
flatMapGroupsWithState |
Append 操作模式 | Append | 允许在 flatMapGroupsWithState 算子之后进行的聚合操作。 |
flatMapGroupsWithState |
Update 操作模式 | Update | 不允许在 flatMapGroupsWithState 算子之后进行的聚合操作。 |
连接查询 | Append | Update 和 Complete 模式目前还不支持,详情参见 support matrix in the Join Operations section。 | |
其他查询 | Append, Update | 因为在结果集中保存所有未聚合的数据时不可行的,所以不支持 Complete 模式。 |
数据输出
有一些内置的数据输出 Sink。
- File sink - 输出数据到指定目录。
writeStream
.format("parquet") // can be "orc", "json", "csv", etc.
.option("path", "path/to/destination/dir")
.start()
- Kafka sink - 输出数据到一个或多个 Kafka 主题。
writeStream
.format("kafka")
.option("kafka.bootstrap.servers", "host1:port1,host2:port2")
.option("topic", "updates")
.start()
- Foreach sink - 自定义结果输出,详情参见后面的章节。
writeStream
.foreach(...)
.start()
- Console sink (测试用) - 打印计算结果到标准输出。支持 Append 和 Complete 模式。该输出类型只适用于测试环境下的小批量数据,因为每次计算后的所有需要输出的数据都会汇聚到 driver 端存储在内存中。
writeStream
.format("console")
.start()
- Memory sink (测试用) - 计算结果会存储成一张内存表。支持 Append 和 Complete 模式。该输出类型只适用于测试环境下的小批量数据,因为每次计算后的所有需要输出的数据都会汇聚到 driver 端存储在内存中。所以,请谨慎使用。
writeStream
.format("memory")
.queryName("tableName")
.start()
一些数据输出类型不是容错的,因为它们无法保证计算结果的持久化,只是为测试准备的,参见前面的章节 fault-tolerance semantics。下面列举了 Spark 中所有的数据输出类型。
输出类型 | Supported Output Modes | 选项 | Fault-tolerant | 说明 |
---|---|---|---|---|
File Sink | Append | path :输出路径,必须指定。对于文件格式选项,参见 DataFrameWriter (Scala/Java/Python/R) 对象中的相关方法。例如,「parquet」格式的选项参见 DataFrameWriter.parquet() 。 |
Yes (exactly-once) | 支持写入分区表。通常以时间进行分区。 |
Kafka Sink | Append, Update, Complete | 参见 Kafka Integration Guide。 | Yes (at-least-once) | 详见 Kafka Integration Guide。 |
Foreach Sink | Append, Update, Complete | None | Yes (at-least-once) | 详见下一章节(next section)。 |
ForeachBatch Sink | Append, Update, Complete | None | Depends on the implementation | 详见下一章节(next section)。 |
Console Sink | Append, Update, Complete | numRows :每次触发计算输出的数据行数(默认值为 20)。truncate :如果太长的话,是否截断输出数据(默认值为 true)。 |
No | |
Memory Sink | Append, Complete | None | No。但是在 Complete 输出模式下,重启程序将会重新创建整个表。 | Table name is the query name. |
注意,必须调用 start()
方法真正的启动程序,会返回一个 StreamingQuery 对象作为流式计算任务的句柄,可以通过该对象管理执行过程。现在,来看一些示例。
// ========== DF with no aggregations ==========
val noAggDF = deviceDataDf.select("device").where("signal > 10")
// Print new data to console
noAggDF
.writeStream
.format("console")
.start()
// Write new data to Parquet files
noAggDF
.writeStream
.format("parquet")
.option("checkpointLocation", "path/to/checkpoint/dir")
.option("path", "path/to/destination/dir")
.start()
// ========== DF with aggregation ==========
val aggDF = df.groupBy("device").count()
// Print updated aggregations to console
aggDF
.writeStream
.outputMode("complete")
.format("console")
.start()
// Have all the aggregates in an in-memory table
aggDF
.writeStream
.queryName("aggregates") // this query name will be the table name
.outputMode("complete")
.format("memory")
.start()
spark.sql("select * from aggregates").show() // interactively query in-memory table
使用 Foreach 和 ForeachBatch
foreach
和 foreachBatch
算子可以在一个流式计算任务中自定义计算和输出的逻辑。它们之间有一点区别,foreach
算子操作的是每一行数据,而 foreachBatch
算子操作的是整个 micro-batch 的数据。接下来进行详细说明。
ForeachBatch
foreachBatch(...)
算子可以指定一个函数来处理流式查询中的每一个 micro-batch 的数据。自 Spark 2.4 开始,该算子支持 Scala,Java 和 Python,接收两个参数,一个属于该 micro-batch 的 DataFrame 或者 Dataset,和该 micro-batch 的唯一 ID。
streamingDF.writeStream.foreachBatch { (batchDF: DataFrame, batchId: Long) =>
// Transform and write batchDF
}.start()
可以通过 foreachBatch
算子进行:
- 重用已存在的批次数据 - 对于许多外部存储系统,可能还不支持流式的输出,但是可能已经提供了对批处理的支持。使用
foreachBatch
算子能够以批处理的方式对每个批次进行数据输出操作。 - 写出到不同的位置 - 如果需要将流式处理的计算结果写出到不同的位置,可以简单的将 DataFrame/Dataset 输出多次。然而,每一次写出都可能导致数据的重复计算(包括可能的从数据源中读取数据)。为了避免重复计算,可以缓存输出的 DataFrame/Dataset,并把它们写出到不同的位置,之后再清理缓存。如下所示。
streamingDF.writeStream.foreachBatch { (batchDF: DataFrame, batchId: Long) =>
batchDF.persist()
batchDF.write.format(...).save(...) // location 1
batchDF.write.format(...).save(...) // location 2
batchDF.unpersist()
}
- 使用其他的 DataFrame 算子 - 许多 DataFrame/Dataset 算子都不适用于流式 DataFrame/Dataset,因为 Spark 不支持在那些场景下生成增量的执行计划。此时通过
foreachBatch
算子可以在每个 micro-batch 上使用这些算子。然而,必须自己保证端到端的容错语义。
注意:
- 默认情况下,
foreachBatch
算子只提供至少一次的容错语义。然而,可以使用函数提供的 batchId 来处理重复的输出,达到精确一次语义的保证。 -
foreachBatch
算子不能在 continuous 计算模式下使用,因为它依赖的是 micro-batch 流式执行引擎。如果使用的是 continuous 计算模式,请使用foreach
算子。
Foreach
如果无法使用 foreachBatch
算子(例如,相应的批处理输出模块不存在,或者使用了 continuous 计算模式),可以使用 foreach
算子来自定义输出逻辑。具体而言,可以将输出逻辑划分到三个方法中:open
,process
和 close
。自 Spark 2.4 开始,该算子支持 Scala,Java 和 Python。
在 Scala 中,需要继承 ForeachWriter
(docs) 类。
streamingDatasetOfString.writeStream.foreach(
new ForeachWriter[String] {
def open(partitionId: Long, version: Long): Boolean = {
// Open connection
}
def process(record: String): Unit = {
// Write string to connection
}
def close(errorOrNull: Throwable): Unit = {
// Close the connection
}
}
).start()
执行语义:当流式计算任务启动以后,Spark 通过下面的方式来调用上述方法:
- 该对象的一个副本被传递给一个子任务生成的数据来使用。也就是说,在分布式环境下,每个自定义的
ForeachWriter
子类实例负责处理一个分区中的所有数据。 - 该对象必须是课序列化的,因为每个子任务都会拿到该对象的序列化后的一个副本。所以,强烈建议所有的初始化工作(例如打开一个连接或者开启一个事务)在
open()
方法中进行,该方法表示子任务已经准备好接收上游数据。 - 上述方法的生命周期如下:
- 对于每个附带
partitionId
的分区:- 对于数据流中每个附带
epochId
的批次数据:- 首先调用
open(partitionId, epochId)
方法。 - 如果
open(…)
方法返回 true,对于该批次中的每一行数据,调用process(row)
方法。 - 调用
close(error)
方法,如果在处理数据的过程中有异常方法,异常会被当做参数。
- 首先调用
- 对于数据流中每个附带
- 对于每个附带
- 只有在
open()
方法存在并且返回成功(无论返回值是什么)的情况下,close()
方法才会被调用,除非进程异常退出。 - 注意:Spark 并不保证相同的
(partitionId, epochId)
会有相同的输出,所以无法通过(partitionId, epochId)
来进行数据去重。例如,出于某些原因,数据源提供了不同的分区数量,此时 Spark 会改变分区 ID。详情参见 SPARK-28650。如果需要对输出去重,请使用foreachBatch
算子。
触发器
一个流式查询任务的触发器定义了流式数据执行计算的时机,无论查询是以固定批次间隔的 micro-batch 模式进行还是以 continuous 模式进行。下面是目前支持的触发器类型。
Trigger Type | Description |
---|---|
未定义(默认) | 如果没有显示定义触发器,默认情况下,查询任务会以 micro-batch 计算模式执行,在上一次计算完成之后会尽快执行下一次的计算。 |
固定间隔的 micro-batch 模式 | 查询任务会以 micro-batch 计算模式执行,计算时机根据用户自定义的时间间隔触发。如果上一次计算在该时间间隔内执行完成,执行引擎会等待直到下一个触发时刻进行下一次的计算。如果上一次计算在该时间间隔内没有计算完成(即,错过了一个触发时刻),下一个批次的计算会在上一次计算完成之后立刻执行(即,不会等待再一次的触发时刻)。如果没有新的数据到来,则不会触发计算。 |
触发一次的 micro-batch 模式 | 查询任务会以 micro-batch 计算模式执行,并且只会触发一次,之后便停止任务。这种方式适用于需要周期性的启动任务,处理完上一个周期所有数据就停止任务的场景。在某些场景下,这种方式能够显著的减少计算开销。 |
固定检查点间隔的 continuous 模式 (试验阶段) | 查询任务会以新的低延迟的 continuous 计算模式执行,详情参见之后的 Continuous Processing section。 |
下面是一些代码示例。
import org.apache.spark.sql.streaming.Trigger
// Default trigger (runs micro-batch as soon as it can)
df.writeStream
.format("console")
.start()
// ProcessingTime trigger with two-seconds micro-batch interval
df.writeStream
.format("console")
.trigger(Trigger.ProcessingTime("2 seconds"))
.start()
// One-time trigger
df.writeStream
.format("console")
.trigger(Trigger.Once())
.start()
// Continuous trigger with one-second checkpointing interval
df.writeStream
.format("console")
.trigger(Trigger.Continuous("1 second"))
.start()
管理流式查询
在流式查询任务启动时获得的 StreamingQuery
对象可以用来管理和监控查询任务。
val query = df.writeStream.format("console").start() // get the query object
query.id // get the unique identifier of the running query that persists across restarts from checkpoint data
query.runId // get the unique id of this run of the query, which will be generated at every start/restart
query.name // get the name of the auto-generated or user-specified name
query.explain() // print detailed explanations of the query
query.stop() // stop the query
query.awaitTermination() // block until query is terminated, with stop() or with error
query.exception // the exception if the query has been terminated with error
query.recentProgress // an array of the most recent progress updates for this query
query.lastProgress // the most recent progress update of this streaming query
可以在一个 SparkSession 中启动任意数量的流式查询任务,它们会并发执行,共享集群资源。可以通过方法来获取这些 StreamingQueryManager
(Scala/Java/Python) 对象,来管理当前正在运行的查询。
val spark: SparkSession = ...
spark.streams.active // get the list of currently active streaming queries
spark.streams.get(id) // get a query object by its unique id
spark.streams.awaitAnyTermination() // block until any one of them terminates
监控流式查询
有多种方式可以监控正在运行的流式查询任务。可以通过 Spark 对 Dropwizard Metrics 的支持将统计信息推送到外部系统,或者通过程序访问这些统计数据。
交互式访问统计信息
可以通过 streamingQuery.lastProgress()
和 streamingQuery.status().lastProgress()
方法直接获取一个正在运行的流式查询任务当前的状态和统计信息。lastProgress()
方法在 Scala 和 Java 中返回一个 StreamingQueryProgress
对象,在 Python 中返回一个包含相同字段的字典。其中包含了数据流中上一次触发计算的批次中所有的相关信息——那些数据参与了计算,计算效率如何,延迟等等。还有一个 streamingQuery.recentProgress
方法返回最近执行批次的数组。
此外,方法在 Scala 和 Java 中返回一个 StreamingQueryStatus
对象,在 Python 中返回一个包含相同字段的字典。其中提供了目前正在执行的相关信息——是否有一次计算正在执行,数据是否参与了计算等等。
下面是一些示例。
val query: StreamingQuery = ...
println(query.lastProgress)
/* Will print something like the following.
{
"id" : "ce011fdc-8762-4dcb-84eb-a77333e28109",
"runId" : "88e2ff94-ede0-45a8-b687-6316fbef529a",
"name" : "MyQuery",
"timestamp" : "2016-12-14T18:45:24.873Z",
"numInputRows" : 10,
"inputRowsPerSecond" : 120.0,
"processedRowsPerSecond" : 200.0,
"durationMs" : {
"triggerExecution" : 3,
"getOffset" : 2
},
"eventTime" : {
"watermark" : "2016-12-14T18:45:24.873Z"
},
"stateOperators" : [ ],
"sources" : [ {
"description" : "KafkaSource[Subscribe[topic-0]]",
"startOffset" : {
"topic-0" : {
"2" : 0,
"4" : 1,
"1" : 1,
"3" : 1,
"0" : 1
}
},
"endOffset" : {
"topic-0" : {
"2" : 0,
"4" : 115,
"1" : 134,
"3" : 21,
"0" : 534
}
},
"numInputRows" : 10,
"inputRowsPerSecond" : 120.0,
"processedRowsPerSecond" : 200.0
} ],
"sink" : {
"description" : "MemorySink"
}
}
*/
println(query.status)
/* Will print something like the following.
{
"message" : "Waiting for data to arrive",
"isDataAvailable" : false,
"isTriggerActive" : false
}
*/
通过异步 API 编程报告统计信息
可以通过给 SparkSession
注册一个 StreamingQueryListener
(Scala/Java) 对象来异步的监控所有的查询任务。一旦通过 sparkSession.streams.attachListener()
方法注册了自定义的 StreamingQueryListener
,就会在查询开始、结束时以及某个批次计算后执行其中定义好的回调函数。下面是一个示例。
val spark: SparkSession = ...
spark.streams.addListener(new StreamingQueryListener() {
override def onQueryStarted(queryStarted: QueryStartedEvent): Unit = {
println("Query started: " + queryStarted.id)
}
override def onQueryTerminated(queryTerminated: QueryTerminatedEvent): Unit = {
println("Query terminated: " + queryTerminated.id)
}
override def onQueryProgress(queryProgress: QueryProgressEvent): Unit = {
println("Query made progress: " + queryProgress.progress)
}
})
使用 Dropwizard 报告统计信息
Spark 支持使用 Dropwizard Library 来报告统计信息。激活该功能在 Structured Streaming 查询任务中的统计信息,需要在 SparkSession 中显示配置参数 spark.sql.streaming.metricsEnabled
为 true。
spark.conf.set("spark.sql.streaming.metricsEnabled", "true")
// or
spark.sql("SET spark.sql.streaming.metricsEnabled=true")
所有在 Spark 开启该配置项后启动的流式查询任务都会通过 Dropwizard 向已配置好的任何 sinks 中报告统计信息(例如,Ganglia,Graphite,JMX 等等)。
通过 Checkpoint 从失败中恢复
如果任务执行出现了异常,或者手动停止任务,可以恢复前一个批次的执行状态,然后从中断的地方继续运行。各功能是通过检查点和预写日志机制实现的。可以给流式查询任务配置一个检查点路径,任务会保存所有的运行信息(即,每次触发计算时该批次的数据偏移量区间)和正在运行的中间聚合状态(例如,示例代码 quick example 中的单词计数值)到检查点中。检查点的路径位置应该是一个兼容 HDFS 文件系统的路径,可以在启动流式查询任务时(starting a query)通过 DataStreamWriter
对象设置。
aggDF
.writeStream
.outputMode("complete")
.option("checkpointLocation", "path/to/HDFS/dir")
.format("memory")
.start()
流式查询任务逻辑变更后的重启恢复语义
在流式查询任务从检查点重启之后,对于流式任务逻辑的变更(也就是更改了代码逻辑)哪些是允许的(allowed)有一些限制。存在一些不允许的(not allowed)变更类型,或者逻辑变更后的结果是未定义的。如下所述:
- 术语「允许」的意思是可以对代码逻辑做出相应的变更,但是变更后的语义是否是符合预期的取决于具体的变更方式。
- 术语「不允许」的意思是不应该做这样的变更,因为这样的变更会导致重启的任务发生错误而失败。
sdf
表示一个由sparkSession.readStream
方法生成的流式 DataFrame/Dataset。
变更类型
- 数据源的数量或者类型的变更(即,使用不同的数据源):这种变更是不允许的。
- 数据源的参数的变更:这种变更是否允许,以及这种变更的语义是否是确定的取决于数据源和查询任务的定义。这里有几个示例。
- 输入速率限制的添加 / 删除 / 修改是允许的:
spark.readStream.format("kafka").option("subscribe", "topic")
变更为spark.readStream.format("kafka").option("subscribe", "topic").option("maxOffsetsPerTrigger", ...)
。 - 变更订阅的主题/文件一般情况下是不允许的,因为结果不是我们所预期的:
spark.readStream.format("kafka").option("subscribe", "topic")
变更为spark.readStream.format("kafka").option("subscribe", "newTopic")
。
- 输入速率限制的添加 / 删除 / 修改是允许的:
- 数据输出 sink 类型的变更:变更一些数据输出的特定组合是允许的。是否允许需要根据具体案例具体分析。下面是一些示例。
- File sink 变更为 kafka sink 是允许的,Kafka 只会收到新的数据。
- Kafka sink 变更为 file sink 是不允许的。
- Kafka sink 变更为 foreach 是允许的,反之亦然。
- 数据输出 sink 参数的变更:这种变更是否允许,以及这种变更的语义是否是确定的取决于数据输出和查询任务的定义。这里有几个示例。
- 变更 file sink 的输出目录是不允许的:
sdf.writeStream.format("parquet").option("path", "/somePath")
变更为sdf.writeStream.format("parquet").option("path", "/anotherPath")
。 - 变更 kafka sink 的输出主题是允许的:
sdf.writeStream.format("kafka").option("topic", "someTopic")
tosdf.writeStream.format("kafka").option("topic", "anotherTopic")
。 - 对于用户自定义的 foreach sink 的逻辑变更(
ForeachWriter
中的代码逻辑)是允许的,但是变更的语义取决于具体的代码实现。
- 变更 file sink 的输出目录是不允许的:
- 变更投影 / 过滤 / 类 map 的算子某些时候是允许的,例如:
- 添加 / 删除过滤算子是允许的:
sdf.selectExpr("a")
变更为sdf.where(...).selectExpr("a").filter(...)
。 - 将投影算子变更为相同输出模式(表结构)的投影算子是允许的:
sdf.selectExpr("stringColumn AS json").writeStream
变更为sdf.selectExpr("anotherStringColumn AS json").writeStream
。 - 将投影算子变更为不同输出模式(表结构)的投影算子在某些前提下是允许的:
sdf.selectExpr("a").writeStream
变更为sdf.selectExpr("b").writeStream
只在输出 sink 中允许字段「a」的类型变更为「b」的类型时是允许的。
- 添加 / 删除过滤算子是允许的:
- 变更有状态算子:有一些流式任务中的算子刷要维护一些状态数据来持续的更新结果值。Structured Streaming 会自动将这些状态数据存储到检查点路径来进行容错(比如存储到 HDFS, AWS S3, Azure Blob storage),在任务重启之后恢复这些状态数据。然而,以上操作都假设状态数据的模式(表结构)在两次任务之间保持不变,这就意味着对重启的流式任务中有状态算子的任何变更(即,添加、删除以及修改字段)都是不允许的。下面是在重启之前不允许改变表结构的有状态算子的列表,以保证状态数据可以顺利恢复。
- 流式聚合算子:例如,
sdf.groupBy("a").agg(...)
,任何分组键以及聚合键的数量或者类型的变更都是不允许的。 - 流式去重算子:例如,
sdf.dropDuplicates("a")
,任何分组键以及聚合键的数量或者类型的变更都是不允许的。 - 流式连接算子:例如,
sdf1.join(sdf2, ...)
(即,两个输入都是由sparkSession.readStream
方法生成的),输入流的数据模式或者连接键都不允许变更,连接类型(内连接或者外连接)也不允许变更。 其他有关连接算子的变更结果是未定义的。 - 自定义有状态算子:例如,
sdf.groupByKey(...).mapGroupsWithState(...)
或者sdf.groupByKey(...).flatMapGroupsWithState(...)
。有关用户自定义的状态数据的模式的变更,以及超时策略的变更都是不允许的。任何有关用户自定义状态函数的逻辑的变更是允许的,但是变更行为的语义取决于用户自定义的代码逻辑。如果真的想变更状态数据的模式,可以使用支持模式演化的序列化框架显示编码 / 解码复杂的状态数据结构为字节数组。例如,如果将状态数据存储为 Avro 编码格式的字节数组,便可以在重启任务之前自由的更改 Avro 状态数据模式,因为二进制类型的状态数据总是能够顺利恢复。
- 流式聚合算子:例如,
Continuous 计算模式
[试验阶段]
Continuous processing 计算模式是一个新的,处在试验阶段的流式执行引擎,在 Spark 2.3 中被引入,可以在端到端低延迟(约 1 ms)的同时提供至少一次的容错语义保证。相比而言,默认的 micro-batch processing 执行引擎可以保证精确一次处理的容错语义,但是最多只能达到约 100 ms 左右的延迟水平。对于某些类型的查询任务(将会在下面讨论),可以选择不同的计算模式而不用改变应用程序的逻辑(即,不用改变 DataFrame/Dataset 算子)。
若采用 continuous processing 计算模式,只需要指定一个以检查点时间间隔为参数的 continuous trigger,例如:
import org.apache.spark.sql.streaming.Trigger
spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "host1:port1,host2:port2")
.option("subscribe", "topic1")
.load()
.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
.writeStream
.format("kafka")
.option("kafka.bootstrap.servers", "host1:port1,host2:port2")
.option("topic", "topic1")
.trigger(Trigger.Continuous("1 second")) // only change in query
.start()
1 秒钟的检查点时间间隔表示 continuous processing 执行引擎会每秒钟记录一次执行中的相关信息。生成的检查点文件和 micro-batch 计算模式的格式兼容,所以任何类型的流式查询任务都可以采用任何类型的触发器进行重启。例如,一个以 micro-batch 引擎运行的流失计算任务能够以 continuous processing 模式重启,反之亦然。注意在每次切换计算模式时,只能保证至少一次处理的容错语义。
支持的查询
在 Spark 2.4 中,只有下面的查询类型支持 continuous processing 计算模式。
- Operations:只有类 map 的 Dataset/DataFrame 算子支持 continuous processing 计算模式,也就是说,只有投影算子(
select
,map
,flatMap
,mapPartitons
等等)和选择算子(where
,filter
等等)。- 支持所有 SQL 函数,除了聚合函数(目前聚合函数还不支持),
current_timestamp()
和current_date()
(确定性计算中使用时间比较困难)。
- 支持所有 SQL 函数,除了聚合函数(目前聚合函数还不支持),
- Sources:
- Kafka source:所有的选项都支持。
- Rate source:适用于测试环境。在 continuous 计算模式下只能使用
numPartitions
和rowsPerSecond
选项。
- Sinks:
- Kafka sink:所有的选项都支持。
- Memory sink:适用于调试环境。
- Console sink:适用于调试环境,支持所有的选项。注意,控制台会每隔一段时间打印一次,由触发器中指定的时间间隔控制。
详情参见 Input Sources 和 Output Sinks 章节。尽管 console sink 适用于测试环境,不过端到端的低延迟现象可以在 Kafka 数据源中更好的体现,因为对于 Kafka 而言执行引擎可以在输入主题中的数据到来几毫秒之后就能够在输出主题中看到计算后的结果。
注意事项
- Continuous processing 计算引擎启动了多个长时间运行的子任务,持续的从数据源中读取数据,计算,之后写出到外部系统。子任务的数量取决于可能从数据源中并行读取多少分区。所以在启动任务之前,必须保证集群中有足够的核数供子任务并行运行。例如,如果从一个拥有 10 个分区的 Kafka 主题中读取数据,那么集群中必须至少有 10 个核来让任务能够正常运行。
- 停止一个 continuous processing 计算模式的流处理任务可能会产生虚假的任务终止警告,它们可以被忽略。
- 目前没有自动重试失败子任务的机制。任何形式的异常都会导致整个任务停止,需要手动从检查点恢复。
更多信息
注意
- 某些配置在任务启动之后是无法修改的。改变这些配置需要清理检查点文件之后重启一个新的任务。这些配置项包括:
-
spark.sql.shuffle.partitions
- 这是执行状态的物理分区机制导致的,中间执行状态的分区 ID 是通过对 key 进行哈希计算后得到的,所以执行状态的分区数不应该被修改。
- 如果需要对有状态算子分配更少的分区数,可以通过
coalesce
算子来避免不必要的重分区。- 在
coalesce
之后,reduce 端子任务的数量不会被改变除非发生了又一次的 shuffle 操作。
- 在
-
spark.sql.streaming.stateStore.providerClass
:为了正确的读取之前任务的中间状态,负责状态管理的类不应该被修改。 -
spark.sql.streaming.multipleWatermarkPolicy
:对该参数的修改会在任务包含多个水印时导致不一致的水印,所以处理多重水印的策略不应该被修改。
-
扩展阅读
- 查看和运行示例代码(Scala/Java/Python/R)
- 对于如何运行 Spark 示例程序的一些指导(Instructions)
- 关于与 Kafka 的集成参见 Structured Streaming Kafka Integration Guide
- 关于使用 DataFrames/Datasets 的更多详情参见 Spark SQL Programming Guide
- 第三方博客
- Real-time Streaming ETL with Structured Streaming in Apache Spark 2.1 (Databricks Blog)
- Real-Time End-to-End Integration with Apache Kafka in Apache Spark’s Structured Streaming (Databricks Blog)
- Event-time Aggregation and Watermarking in Apache Spark’s Structured Streaming (Databricks Blog)
一些讨论
- Spark 欧洲峰会 2017
- Easy, Scalable, Fault-tolerant Stream Processing with Structured Streaming in Apache Spark - Part 1 slides/video, Part 2 slides/video
- Deep Dive into Stateful Stream Processing in Structured Streaming - slides/video
- Spark 峰会 2016
- A Deep Dive into Structured Streaming - slides/video