Spark Structured Streaming
一、概述
http://spark.apache.org/docs/latest/structured-streaming-programming-guide.html
Structured Streaming构建在Spark SQL基础之上的一个可靠且容错的流数据处理引擎。
简短来说,Structured Streaming提供快速、可靠、容错、端对端的精确一次流数据处理语义
流数据处理方法。
流数据进行处理,处理容错语义:
- at exactly once: 精确一次; 流数据不论处理成功还是失败 一定能够精确处理1次
- at least once:最少一次; 流数据处理成功(1次),处理失败(n次)
- at most once: 最多一次;流数据处理成功(1次),处理失败(0次)
注意:在内部,Structured Streaming依然会将流数据,划分为micro batch
, 并且达到端对端低于100ms的处理延迟和精确一次的容错处理语义
优点:
- 支持多种数据端(流数据的输入和输出可以有多种方式)
- 应用Spark SQL操作,可以通过SQL语法计算流数据;
select word,num(word) from t_word group by word
- 支持容错语义:
at exactly once
, Spark 2.3版本之后,提供端对端低于1ms的处理延迟和at least once
- 借助于Spark SQL底层优化,保证对流数据处理以高效方式处理
二、Quick Example
实现实时单词计数
Maven依赖
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>2.4.4</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_2.11</artifactId>
<version>2.4.4</version>
<!--集群中运行打开,本地运行注释-->
<!--<scope>provided</scope>-->
</dependency>
开发应用
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
object StructuredStreamingWordCount {
def main(args: Array[String]): Unit = {
//1. 构建spark session
val spark = SparkSession.builder().master("local[*]").appName("word count").getOrCreate()
spark.sparkContext.setLogLevel("OFF")
import spark.implicits._
//2. 构建流数据的DF 接受tcp请求端口的访问数据 作为df流数据
val df = spark.readStream.format("socket").option("host", "SparkOnStandalone").option("port", "5555").load()
// value count
/*
1 Spark Spark Row("Spark Spark",) // 0
2 Hello Spark Row("Hello Spark")
3 Hello Scala Row("...")
4 AA AA AA
1 Spark Row("Spark")
2 Spark Row("Spark")
3 Hello
4
*/
//3. 应用SQL操作,对流数据进行处理 // Hello Spark => Hello Spark
df.flatMap(row => row.getString(0).split(" ")).createOrReplaceTempView("t_word") // 列名 value
val wordcounts = spark.sql("select value,count(value) from t_word group by value")
//4. 结果DF写出
wordcounts
.writeStream
.format("console")
.outputMode(OutputMode.Complete()) // 输出模式: 支持 追加、完整、更新
.start() // streaming应用 持续运行
.awaitTermination()
}
}
启动TCP数据服务器
[root@SparkOnStandalone ~]# nc -lk 5555
Spark Spark
Hello Spark
Hello Scala
AA AA AA
查看结果
-------------------------------------------
Batch: 1
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Spark| 2|
+-----+------------+
-------------------------------------------
Batch: 2
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Hello| 1|
|Spark| 3|
+-----+------------+
-------------------------------------------
Batch: 3
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
|Hello| 2|
|Scala| 1|
|Spark| 3|
+-----+------------+
-------------------------------------------
Batch: 4
-------------------------------------------
+-----+------------+
|value|count(value)|
+-----+------------+
| AA| 3|
|Hello| 2|
|Scala| 1|
|Spark| 3|
+-----+------------+
三、编程模型
Spark结构化流核心思想将流数据视为一个持续追加的数据库表; Spark结构流处理类似于Spark SQL的批处理操作
基本概念
-
InputTable
: 输入表,当流数据中产生新记录等价于InputTable中追加一个新行;换句来说:InputTable代表反应Data Stream -
ResultTable
: 结果表,当在InputTable中使用查询,则产生ResultTable -
OutputMode
: 输出模式,表示使用何种方式将ResultTable中的结果写出到外部存储系统
注意:
-
Spark结构化流并不会长时间持有InputTable中内容,实际上流数据产生后会应用增量更新,完成后流数据丢弃;这样设计目的是为了保证对于内存的使用保证在一个合理的范围;只使用内存存放ResultTable(状态表)
-
Spark结构化流模型不同于其它流数据处理引擎,状态管理是自动处理
Fault Tolerance Semantics(容错语义)
Structure Streaming通过checkpoint和write ahead log去记录每一次批处理的数据源的偏移量(区间),可以保证在失败的时候可以重复的读取数据源
其次Structure Streaming也提供了Sink的幂等写的特性(在编程中一个操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同), 因此
Structure Streaming实现end-to-end exactly-once 语义的故障恢复。
Spark结构化流实现端对端精确一次处理语义;
输入端: 通常是Kafka,处理引擎正常处理流数据则提交消费位置offset;如果未正常处理流数据则不提交消费位置offset,下一次消费数据时,还会重新拉取这条记录;
输出端: 实现幂等写操作(写出一次或者多次影响结果是一致的)
四、操作API
Input Sources
内置sources
- File source
- Kafka source
- Socket source (for testing)
不同类型的Sources,容错支持
Source | Options | Fault-tolerant | Notes |
---|---|---|---|
File source | path : path to the input directory, and common to all file formats. maxFilesPerTrigger : maximum number of new files to be considered in every trigger (default: no max) latestFirst : whether to process the latest new files first, useful when there is a large backlog of files (default: false) fileNameOnly : whether to check new files based on only the filename instead of on the full path (default: false). With this set to true , the following files would be considered as the same file, because their filenames, “dataset.txt”, are the same: “file:///dataset.txt” “s3://a/dataset.txt” “s3n://a/b/dataset.txt” “s3a://a/b/c/dataset.txt” For file-format-specific options, see the related methods in DataStreamReader (Scala/Java/Python/R). E.g. for “parquet” format options see DataStreamReader.parquet() . In addition, there are session configurations that affect certain file-formats. See the SQL Programming Guide for more details. E.g., for “parquet”, see Parquet configuration section. | Yes | Supports glob paths, but does not support multiple comma-separated paths/globs. |
Socket Source | host : host to connect to, must be specified port : port to connect to, must be specified | No | |
Rate Source | rowsPerSecond (e.g. 100, default: 1): How many rows should be generated per second. rampUpTime (e.g. 5s, default: 0s): How long to ramp up before the generating speed becomes rowsPerSecond . Using finer granularities than seconds will be truncated to integer seconds. numPartitions (e.g. 10, default: Spark’s default parallelism): The partition number for the generated rows. The source will try its best to reach rowsPerSecond , but the query may be resource constrained, and numPartitions can be tweaked to help reach the desired speed. | Yes | |
Kafka Source | See the Kafka Integration Guide. | Yes |
# 1. kafka和结构化流的集成依赖
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql-kafka-0-10_2.11</artifactId>
<version>2.4.4</version>
</dependency>
# 2. 确保kafka服务正常 (zk & kafka进程 )
# 3. 创建测试topic
[root@HadoopNode00 kafka_2.11-0.11.0.0]# bin/kafka-topics.sh --create --topic test --zookeeper HadoopNode00:2181 --partitions 1 --replication-factor 1
Created topic "test".
# 4. 启动kafka生产者,用以生产流数据
[root@HadoopNode00 kafka_2.11-0.11.0.0]# bin/kafka-console-producer.sh --topic test --broker-list HadoopNode00:9092
>
package sources
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types.{BooleanType, IntegerType, StringType, StructType}
object InputSources {
def main(args: Array[String]): Unit = {
//1. 构建spark session
val spark = SparkSession.builder().master("local[*]").appName("csv sources").getOrCreate()
spark.sparkContext.setLogLevel("OFF")
import spark.implicits._
//2. 构建流数据的DF 接受tcp请求端口的访问数据 作为df流数据
/*
val inputTable = spark
.readStream
.format("csv") // csv
.schema(
new StructType()
.add("id", IntegerType)
.add("name", StringType)
.add("sex", BooleanType)
.add("salary", IntegerType))
.load("file:///d://csv")
*/
/*
val inputTable = spark
.readStream
.format("json") // csv
.schema(
new StructType()
.add("id", IntegerType)
.add("name", StringType)
.add("sex", BooleanType)
.add("salary", IntegerType))
.load("file:///d://json")
*/
/*
val inputTable = spark
.readStream
.format("orc") // csv
.schema(
new StructType()
.add("id", IntegerType)
.add("name", StringType)
.add("sex", BooleanType)
.add("salary", IntegerType))
.load("file:///d://orc")
inputTable.createOrReplaceTempView("t_user")
val resultTable = spark.sql("select * from t_user")
*/
/*
val inputTable = spark.readStream.textFile("hdfs://SparkOnStandalone:9000/data")
inputTable.createOrReplaceTempView("t_data")
val resultTable = spark.sql("select * from t_data")
*/
//***************************************************************
val df = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "HadoopNode00:9092")
.option("subscribe", "test") // 默认 订阅test topic 所有分区
//.option("startingOffsets", """{"test":{"0":2}}""") // 从test topic 0号分区offset为2的位置拉取流数据(kafka指定消费分区和消费位置)
//.option("endingOffsets", """{"test":{"0":4}}""") // 无法使用
//.option("startingOffsets", """{"test":{"0":-1}}""") // -2 == earliest -1 == latest 大于0的整数代表消费位置offset
.option("startingOffsets", """{"test":{"0":-2}}""") // -2 == earliest -1 == latest 大于0的整数代表消费位置offset
// latest 如果有已提交的offset则从提交的offset开始消费数据,如果没有则消费最新的数据
// earliest 如果有已提交的offset则从提交的offset开始消费数据,如果没有则从头消费数据
.load()
// kafka record:k v topic partition offset timestamp
df
.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "topic", "partition", "offset", "timestamp").as[(String, String, String, Int, Long, Long)]
.createOrReplaceTempView("t_kafka")
val resultTable = spark.sql("select * from t_kafka")
//***************************************************************
resultTable
.writeStream
.format("console")
.outputMode(OutputMode.Append())
.start()
.awaitTermination()
}
}
Output Sinks
内置sink:
- File sink
- Kafka sink
- Foreach sink
- Console sink (for debugging)
不同类型的Sink,容错语义和输出模式:
Sink | Supported Output Modes | Options | Fault-tolerant | Notes |
---|---|---|---|---|
File Sink | Append | path : path to the output directory, must be specified. For file-format-specific options, see the related methods in DataFrameWriter (Scala/Java/Python/R). E.g. for “parquet” format options see DataFrameWriter.parquet() | Yes (exactly-once) | Supports writes to partitioned tables. Partitioning by time may be useful. |
Kafka Sink | Append, Update, Complete | See the Kafka Integration Guide | Yes (at-least-once) | More details in the Kafka Integration Guide |
Foreach Sink | Append, Update, Complete | None | Yes (at-least-once) | More details in the next section |
ForeachBatch Sink | Append, Update, Complete | None | Depends on the implementation | More details in the next section |
Console Sink | Append, Update, Complete | numRows : Number of rows to print every trigger (default: 20) truncate : Whether to truncate the output if too long (default: true) | No |
package sinkes
import org.apache.spark.sql.{ForeachWriter, Row, SparkSession}
import org.apache.spark.sql.streaming.OutputMode
import redis.clients.jedis.Jedis
object OutputSinkes {
def main(args: Array[String]): Unit = {
//1. 构建spark session
val spark = SparkSession.builder().master("local[*]").appName("csv sources").getOrCreate()
spark.sparkContext.setLogLevel("OFF")
import spark.implicits._
//***************************************************************
//2. 构建流数据的DF 接受tcp请求端口的访问数据 作为df流数据
/*
val df = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "HadoopNode00:9092")
.option("subscribe", "test") // 默认 订阅test topic 所有分区
.load()
// kafka record:k v topic partition offset timestamp
df
.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "topic", "partition", "offset", "timestamp").as[(String, String, String, Int, Long, Long)]
.createOrReplaceTempView("t_kafka")
val resultTable = spark.sql("select * from t_kafka")
*/
/*
resultTable
.writeStream
//.format("json") // filesink 文件格式,可以json csv orc parquet text
.format("csv") // filesink 文件格式,可以json csv orc parquet text
.outputMode(OutputMode.Append()) // filesink 只支持append输出模式
.option("path", "file:///d://csv2")
.option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint5") // 写权限
.start()
.awaitTermination()
*/
//***************************************************************
// kafka source
val df = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "HadoopNode00:9092")
.option("subscribe", "test") // 默认 订阅test topic 所有分区
.option("group.id", "g1") // 默认 订阅test topic 所有分区
.load()
df
.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)", "CAST(offset AS LONG)").as[(String, String, Long)]
.flatMap(t3 => t3._2.split(" "))
.map(word => (word, 1))
.createOrReplaceTempView(q"t_word")
val resultTable = spark.sql("select _1 as key, count(_2) as value from t_word group by _1")
// (Hello,10) record k = Hello v = 10
// kafka sink 输出模式:append update complete
//****************************************************************
/*
resultTable
.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
.writeStream
.format("kafka")
.option("kafka.bootstrap.servers", "HadoopNode00:9092")
.option("topic", "result")
.option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint6")
.outputMode(OutputMode.Update())
.start()
.awaitTermination()
*/
// 写入redis中
//****************************************************************
resultTable
.writeStream
.foreach(new ForeachWriter[Row] {
// 允许对当前分区数据进行处理
override def open(partitionId: Long, epochId: Long): Boolean = true
// 处理 row 对象代表是resultTable中一行数据 Row("Hello",10)
override def process(value: Row): Unit = {
val word = value.getString(0)
val num = value.getLong(1)
// redis 插值API
val jedis = new Jedis("SparkOnStandalone", 6379)
jedis.set(word, num.toString)
jedis.close()
}
// 当null或者错误回调close
override def close(errorOrNull: Throwable): Unit = {
if (errorOrNull != null) {
errorOrNull.printStackTrace()
}
}
})
.outputMode(OutputMode.Update())
.option("checkpointLocation", "hdfs://SparkOnStandalone:9000/checkpoint7")
.start()
.awaitTermination()
}
}
SQL操作(略)
五、基于EventTime的窗口操作
基于事件时间的窗口操作
滑动event-time时间窗口的聚合在StructuredStreaming上很简单,并且和分组聚合非常相似。在分组聚合中,为用户指定的分组列中的每个唯一值维护聚合值(例如计数)。在基于窗口的聚合的情况下,为每一个event-time窗口维护聚合值。
想象一下,quickexample中的示例被修改,现在stream中的每行包含了生成的时间。我们不想运行word count,而是要在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收到的一个word。这个词应该增加对应于两个窗口的计数,分别为12:00 - 12:10和12:05 - 12:15。所以计数counts将会被group key(ie:the word)和window(根据event-time计算)索引。将会如下所示
由于此窗口类似于分组,因此在代码中,可以使用groupBy()和window()操作来表示窗口聚合。如:
package window
import java.sql.Timestamp
import java.text.SimpleDateFormat
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.OutputMode
object WindowOnEventTime {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder().appName("window on event time").master("local[*]").getOrCreate()
spark.sparkContext.setLogLevel("OFF")
import spark.implicits._
// 构建流数据源
// 巧妙设计:发送每一条记录包含 数据+时间
// 记录 = 单词,eventTime
// 如: Hello,1575862650000 11:37:30
// Spark,1575862648000 11:37:28
// 11:37:30
val df = spark.readStream.format("socket").option("host", "SparkOnStandalone").option("port", "8888").load()
import org.apache.spark.sql.functions._
df
.map(row => { // Hello,1575862650000 =>(Hello,Timestamp(1575862650000))
val line = row.getString(0)
val arr = line.split(",")
val word = arr(0)
val timestamp = arr(1).toLong
(word, new Timestamp(timestamp))
})
.toDF("word", "timestamp")
.groupBy(window($"timestamp", "10 seconds", "5 seconds"), $"word") // 指定分组规则:窗口(10s,5s) + 单词
.count()
// .printSchema() // Struct(Window) | Word | Count
.map(row => {
val start = row.getStruct(0).getTimestamp(0)
val end = row.getStruct(0).getTimestamp(1)
val word = row.getString(1)
val count = row.getLong(2)
(new SimpleDateFormat("HH:mm:ss").format(start), new SimpleDateFormat("HH:mm:ss").format(end), word, count)
})
.toDF("start", "end", "word", "count")
.writeStream
.format("console")
.outputMode(OutputMode.Complete())
.start()
.awaitTermination()
}
}
六、处理延迟数据和水位线
处理延迟数据和水位线
默认: 延迟数据累加到窗口计算中,并且Spark在内存保存所有窗口计算的中间结果
现在考虑如果一个事件迟到应用程序会发生什么。例如,假设12:04(即event-time)生成的一个word可以在12:11被应用程序接收。应用程序应该使用时间12:04而不是12:11更新窗口的较旧计数,即12:00 - 12:10。这在我们基于窗口的分组中很自然有可能发生- Structured Streaming可以长时间维持部分聚合的中间状态,以便延迟的数据可以正确地更新旧窗口的聚合,如下所示:
但是,为了长久的运行这个查询,必须限制内存中间状态的数量。这就意味着,系统需要知道什么时候能够从内存中删除旧的聚合,此时默认应用接受延迟的数据之后不再进行聚合。Spark2.1中引入了watermarking(水位线),它能够让engine自动跟踪当前的数据中的event time并据此删除旧的状态表。你可以通过指定event-time列和时间阀值来指定一个查询的watermark,阀值以内的数据才会被处理。对于一个特定的开始于时间T的window窗口,引擎engine将会保持状态并且允许延迟的数据更新状态直到(max event time seen by the engine - late threshold > T)。换句话说,阀值内的数据将被聚合,阀值外的数据将会被丢弃。
wm(水位线) = 最大的事件时间-数据的延迟时间
作用: 界定过期数据和有效数据的一种规则
- 水位线以内的延迟数据为有效数据,参与窗口的计算
- 水位线以外的数据为无效数据,直接丢弃,水位线以外的窗口会自动drop
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()
本例中,watermark的指定列为“timestamp”,并且指定了“10minute”作为阀值。如果这个查询运行在update的输出模式,引擎engine会持续更新window的counts到结果集中,直到窗口超过watermark的阀值,本例中,则是如果timestamp列的时间晚于当前时间10minute。
如上所示,蓝色虚线表示最大event-time,每次数据间隔触发开始时,watermark被设置为max eventtime - ‘10 mins’,如图红色实线所示。例如,当引擎engine观测到到数据(12:14,dog),对于下个触发器,watermark被设置为12:04。这个watermark允许引擎保持十分钟内的中间状态并且允许延迟数据更新聚合集。例如(12:09,cat)的数据未按照顺序延迟到达,它将落在12:05 – 12:15 和 12:10 – 12:20 。因为它依然大于12:04 ,所以引擎依然保持着中间结果集,能够正确的更新对应窗口的结果集。但是当watermark更新到12:11,中间结果集12:00-12:10的数据将会被清理掉,此时所有的数据(如(12:04,donkey))都会被认为“too late”从而被忽略。注意,每次触发器之后,更新的counts(如 purple rows)都会被写入sink作为输出的触发器,由更新模式控制。
某些接收器(例如文件)可能不支持更新模式所需的细粒度更新。要与他们一起工作,我们还支持append模式,只有最后的计数被写入sink。这如下所示。
请注意,在非流数据集上使用watermark是无效的。由于watermark不应以任何方式影响任何批次查询,我们将直接忽略它。
七、Join Operations
流和批连接操作
Streaming DataFrames可以与静态 DataFrames连接,以创建新的Streaming DataFrames。 例如下面的例子:
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.types.{BooleanType, StructType}
object SparkStructuredStreamingForJoinOpt {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder().appName("join opt").master("local[*]").getOrCreate()
val df1 = spark // 基于静态文件创建DF
.read
.format("json")
.load("file:///G:\\IDEA_WorkSpace\\scala-workspace\\spark-day11\\src\\main\\resources") // id name sex
val df2 = spark // 基于流数据创建DF
.readStream
.format("csv")
.schema(
new StructType()
.add("id", "integer")
.add("name", "string")
.add("sex", BooleanType)
.add("salary", "double")
)
.csv("file:///d://csv")
// 批和流不允许Join【流数据不能join给批数据】
// 正常: 【批数据join给流数据】
df2.join(df1,Seq("id","id"),"leftOuter") // 流DF join 批DF
.writeStream
.format("console")
.outputMode("append")
.start()
.awaitTermination()
}
}
//----------------------------------------------------------------------
Batch: 0
-------------------------------------------
+---+---+----+------+----+------+
| id| id|name|salary|name|salary|
+---+---+----+------+----+------+
| 1| 1| zs|1000.0| zs|3000.0|
| 2| 2| ls|2000.0| ls|3000.0|
| 3| 3| ww|3000.0| ww|3000.0|
| 4| 4| zs|1000.0| zs2|3000.0|
| 5| 5| ls|2000.0| ls2|3000.0|
| 6| 6| ww|3000.0| ww2|3000.0|
| 4| 4| zs|3000.0| zs2|3000.0|
| 7| 7| ls|2000.0|null| null|
| 8| 8| ww|3000.0|null| null|
+---+---+----+------+----+------+
.start()
.awaitTermination()
}
}
//----------------------------------------------------------------------
Batch: 0
±–±--±—±-----±—±-----+
| id| id|name|salary|name|salary|
±–±--±—±-----±—±-----+
| 1| 1| zs|1000.0| zs|3000.0|
| 2| 2| ls|2000.0| ls|3000.0|
| 3| 3| ww|3000.0| ww|3000.0|
| 4| 4| zs|1000.0| zs2|3000.0|
| 5| 5| ls|2000.0| ls2|3000.0|
| 6| 6| ww|3000.0| ww2|3000.0|
| 4| 4| zs|3000.0| zs2|3000.0|
| 7| 7| ls|2000.0|null| null|
| 8| 8| ww|3000.0|null| null|
±–±--±—±-----±—±-----+