(七)Spark——Structured Streaming

目录

一、Structured Streaming 概述


  这里有篇文章,可以看看:
专访朱诗雄:Apache Spark 中的全新流式引擎 Structured Streaming


  从 spark2.0 开始, spark 引入了一套新的流式计算模型: Structured Streaming.
  该组件进一步降低了处理数据的延迟时间, 它实现了“有且仅有一次(Exectly Once)” 语义, 可以保证数据被精准消费,这点在 Spark Streaming 是不能满足的。
  Structured Streaming 基于 Spark SQl 引擎, 是一个具有弹性和容错的流式处理引擎. 使用 Structure Streaming 处理流式计算的方式和使用批处理计算静态数据(表中的数据)的方式是一样的.
  随着流数据的持续到达, Spark SQL 引擎持续不断的运行并得到最终的结果. 我们可以使用 Dataset/DataFrame API 来表达流的聚合, 事件-时间窗口(event-time windows), 流-批处理连接(stream-to-batch joins)等等. 这些计算都是运行在被优化过的 Spark SQL 引擎上. 最终, 通过 chekcpoin 和 WALs(Write-Ahead Logs)(溢写日志,HBase也有这个), 系统保证end-to-end exactly-once.
  总之, Structured Streaming 提供了快速, 弹性, 容错, end-to-end exactly-once 的流处理, 而用户不需要对流进行推理(比如 spark streaming 中的流的各种转换).
  默认情况下, 在内部, Structured Streaming 查询使用微批处理引擎(micro-batch processing engine)处理, 微批处理引擎把流数据当做一系列的小批job(small batch jobs ) 来处理. 所以, 延迟低至 100 毫秒, 从 Spark2.3, 引入了一个新的低延迟处理模型:Continuous Processing, 延迟低至 1 毫秒.

二、Structured Streaming 快速入门

  为了使用最稳定最新的 Structure Streaming, 这里使用比较新的版本(2.4.3)。
  这里入门案例是从一个网络端口中读取数据, 并统计每个单词出现的数量。

1. 导入依赖

        <!--导入spark SQL 2.4.3版本的依赖-->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql_2.11</artifactId>
            <version>2.4.3</version>
        </dependency>

2. 具体实现

import org.apache.spark.sql.streaming.{StreamingQuery, Trigger}
import org.apache.spark.sql.{DataFrame, SparkSession}

object WordCount1 {
  def main(args: Array[String]): Unit = {
    // 初始化SparkSession 
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("name").getOrCreate()
    // 导入隐式转换
    import spark.implicits._

    //1. 从数据源加载数据(socket)(加载数据源的方式有4种,后面详细说)
    val lines: DataFrame = spark.readStream  //读一个流数据,lines其实就是一个输入表
      .format("socket") //指定这个流的格式
      .option("host","hadoop102")   //指定socket的地址和端口号
      .option("port",9999)
      .load     //加载,默认得到一个DF
    
    //WordCount
    val wordCount = lines.as[String]      //因为一开始拿到的是字节数组,所以转成string类型的DataSet
      .flatMap(_.split(" "))
      .groupBy("value") //没有命名只有一个字段系统会给其字段名为value
      .count()
    //WordCount也可以采用写SQL的方式:
/*    lines.as[String].flatMap(_.split(" ")).createOrReplaceTempView("w")
    val wordCount = spark.sql(
      """
        |select
        |*,
        |count(*) count
        |from w
        |group by value
      """.stripMargin)*/
    
    //2. 输出
    val result: StreamingQuery = wordCount.writeStream//result其实就是结果表,
      .format("console")  //指定输出到哪,这里输出到控制台
      .outputMode("update") //输出模式,输出模式有三种:complete append  update
      .trigger(Trigger.ProcessingTime("2 seconds")) //多长时间触发一次,如果不写的话就是尽快处理
      .start
    
    result.awaitTermination()//阻止当前线程退出
    spark.stop()
    
  }
}

日志太多不方便看可以找个 log4j.properties 到resources中。
log4j.properties

3. 测试结果

  1. 在hadoop102 启动 socket 服务:
[fseast@hadoop102 ~]$ nc -lk 9999
  1. 启动Structured Streaming 程序
    在nc窗口输入一些单词, 查看程序的输出结果:
    输入:
aa cc bb aa
bb cc cc
cc

结果:

-------------------------------------------
Batch: 1
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
|   cc|    1|
|   bb|    1|
|   aa|    2|
+-----+-----+

-------------------------------------------
Batch: 2
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
|   cc|    3|
|   bb|    2|
+-----+-----+

-------------------------------------------
Batch: 3
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
|   cc|    4|
+-----+-----+

4. 代码说明

其实我在代码已经注释的很详细了。

  1. DataFrame lines 表示一个“无界表(unbounded table)”, 存储着流中所有的文本数据. 这个无界表包含列名为value的一列数据, 数据的类型为String, 而且在流式文本数据中的每一行(line)就变成了无界表中的的一行(row). 注意, 这时我们仅仅设置了转换操作, 还没有启动它, 所以现在还没有收到任何数据
  2. 紧接着我们把 DateFrame 通过 .as[String] 变成了 DataSet, 所以我们可以切割每行为多个单词.得到的 words DataSet包含了所有的单词.
  3. 最后, 我们通过value(每个唯一的单词)进行分组得到wordCounts DataFrame, 并且统计每个单词的个数. 注意, wordCounts是一个流式DataFrame, 它表示流中正在运行的单词数(the running word counts of the stream).
  4. 我们必须在流式数据(streaming data)上启动查询. 剩下的实际就是开始接收数据和计算个数. 为此, 当数据更新的时候, 我们通过outputMode(“complete”)来打印完整的计数集到控制台, 然后通过.start来启动流式计算.
  5. 代码执行之后, 流式计算将会在后台启动. 查询对象(query: StreamingQuery)可以激活流式查询(streaming query), 然后通过awaitTermination()来等待查询的终止,从而阻止查询激活之后进程退出.

三、Structured Streaming 编程模型

  Structured Streaming 的核心思想是:把持续不断的流式数据当做一个不断追加的表.
  这使得新的流式处理模型同批处理模型非常相像. 我们可以表示我们的流式计算类似于作用在静态数表上的标准批处理查询, spark 在一个无界表上以增量查询的方式来运行。

1. 基本概念

1.1 输入表

  把输入数据流当做输入表(Input Table). 到达流中的每个数据项(data item)类似于被追加到输入表中的一行。

在这里插入图片描述

1.2 结果表

作用在输入表上的查询将会产生“结果表(Result Table)”. 每个触发间隔(trigger interval, 例如 1s), 新行被追加到输入表, 最终会更新结果表。无论何时更新结果表, 我们都希望将更改的结果行写入到外部接收器(external sink)。
在这里插入图片描述

1.3 输出

  输出(Output)定义为写到外部存储. 输出模式(outputMode)有 3 种:

  1. Complete Mode 整个更新的结果表会被写入到外部存储. 存储连接器负责决定如何处理整个表的写出(类似于 spark streaming 中的有转态的转换).
  2. Append Mode 从上次触发结束开始算起, 仅仅把那些新追加到结果表中的行写到外部存储(类似于无状态的转换). 该模式仅适用于不会更改结果表中行的那些查询. (如果有聚合操作, 则必须添加 wartemark, 否则不支持此种模式)
  3. Update Mode 从上次触发结束开始算起, 仅仅在结果表中更新的行会写入到外部存储。此模式从 2.1.1 可用. 注意, Update Mode 与 Complete Mode 的不同在于 Update Mode 仅仅输出改变的那些行. 如果查询不包括聚合操作, 则等同于Append Mode。

complete:完整模式,全部输出,必须有聚合才可以使用,
append:追加模式,只输出那些将来永远不可能再更新的数据。没有聚合的时候,append和update是一样的,有聚合的时候,一定要有水印才能使用append。
update:只输出更新的模式,只输出变化的部分,也就是哪一条数据发生了变化,就输出哪一条数据。

1.4 快速入门代码的再次说明

  lines DataFrame是“输入表”, wordCounts DataFrame 是“结果表”, 从输入表到结果表中间的查询同静态的 DataFrame 是一样的. 查询一旦启动, Spark 会持续不断的在 socket 连接中检测新的数据, 如果其中有了新的数据, Spark 会运行一个增量(incremental)查询, 这个查询会把前面的运行的 count 与新的数据组合在一起去计算更新后的 count。

在这里插入图片描述

注意:Structured Streaming 不会实现整个表,它从流式数据源读取最新的可用数据, 持续不断的处理这些数据, 然后更新结果, 并且会丢弃原始数据,它仅保持最小的中间状态的数据, 以用于更新结果(例如前面例子中的中间counts)。

2. 处理事件-时间和延迟数据(Handling Event-time and Late Data)

通俗的理解,Event-time 是数据发生的时间,Late Date 是到达 Spark 的时间。spark 怎么拿的到 Event-time 呢,把其放到数据格式里面去。


  Structured streaming 与其他的流式引擎有很大的不同. 许多系统要求用户自己维护运行的聚合, 所以用户自己必须推理数据的一致性(at-least-once, or at-most-once, or exactly-once). 在Structured streaming模型中, 当有新数据的时候, spark 负责更新结果表, 从而减轻了用户的推理工作。
  来看下个模型如何处理基于事件时间的处理和迟到的数据。
  Event-time 是指嵌入到数据本身的时间, 或者指数据产生的时间. 对大多数应用程序来说, 我们想基于这个时间去操作数据. 例如, 如果我们获取 IoT(Internet of Things) 设备每分钟产生的事件数, 我们更愿意使用数据产生时的时间(event-time in the data), 而不是 spark 接收到这些数据时的时间.
  在这个模型中, event-time 是非常自然的表达. 来自设备的每个时间都是表中的一行, event-time 是行中的一列. 允许基于窗口的聚合(例如, 每分钟的事件数)仅仅是 event-time 列上的特殊类型的分组(grouping)和聚合(aggregation): 每个时间窗口是一个组,并且每一行可以属于多个窗口/组。因此,可以在静态数据集和数据流上进行基于事件时间窗口( event-time-window-based)的聚合查询,从而使用户操作更加方便。
  此外, 该模型也可以自然的处理晚于 event-time 的数据, 因为spark 一直在更新结果表, 所以它可以完全控制更新旧的聚合数据,或清除旧的聚合以限制中间状态数据的大小。
  自 Spark 2.1 起,开始支持 watermark(水印) 来允许用于指定数据的超时时间(即接收时间比 event-time 晚多少),并允许引擎相应的清理旧状态。

3. 容错语义

  提供端到端的exactly-once语义是 Structured Streaming 设计的主要目标. 为了达成这一目的, spark 设计了结构化流数据源, 接收器和执行引擎(Structured Streaming sources, the sinks and the execution engine)以可靠的跟踪处理的进度, 以便能够对任何失败能够重新启动或者重新处理。
  每种流数据源假定都有 offsets(类似于 Kafka offsets) 用于追踪在流中的读取位置. 引擎使用 checkpoint 和 WALs 来记录在每个触发器中正在处理的数据的 offset 范围. 结合可重用的数据源(replayable source)和幂等接收器(idempotent sink), Structured Streaming 可以确保在任何失败的情况下端到端的 exactly-once 语义。

四、操作Streaming DataFrame 和 Streaming DataSet

  使用 Structured Streaming 最重要的就是对 Streaming DataFrame 和 Streaming DataSet 进行各种操作。
  从 Spark2.0 开始, DataFrame 和 DataSet 可以表示静态有界的表, 也可以表示流式无界表.
  与静态 Datasets/DataFrames 类似,我们可以使用公共入口点 SparkSession 从流数据源创建流式 Datasets/DataFrames,并对它们应用与静态 Datasets/DataFrames 相同的操作。
  通过spark.readStream()得到一个DataStreamReader对象, 然后通过这个对象加载流式数据源, 就得到一个流式的 DataFrame。

在这里插入图片描述

  spark 内置了几个流式数据源, 基本可以满足我们的所有需求:
(1)File source 读取文件夹中的文件作为流式数据. 支持的文件格式: text, csv, josn, orc, parquet. 注意, 文件必须放置的给定的目录中, 在大多数文件系统中, 可以通过移动操作来完成.
(2)kafka source 从 kafka 读取数据. 目前兼容 kafka 0.10.0+ 版本
(3)socket source 用于测试. 可以从 socket 连接中读取 UTF8 的文本数据. 侦听的 socket 位于驱动中. 注意, 这个数据源仅仅用于测试.
(4)rate source 用于测试. 以每秒指定的行数生成数据,每个输出行包含一个 timestamp 和 value。其中 timestamp 是一个 Timestamp类型(信息产生的时间),并且 value 是 Long 包含消息的数量. 用于测试和基准测试.

1. socket source

  主要用来做测试用的,而且无法保证严格一次。

2. file source

2.1 读取普通文件夹内的文件

代码:

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.types.{IntegerType, StringType, StructField, StructType}

object FileSource {
  def main(args: Array[String]): Unit = {
    // 初始化SparkSession 
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("FileSource1").getOrCreate()
    // 导入隐式转换
    import spark.implicits._
    
    val userSchema = StructType(StructField("name",StringType)::StructField("age",IntegerType) :: StructField("sex",StringType) :: Nil)
    val user = spark.readStream
      .format("csv")
      .schema(userSchema)
      .load("E:\\file\\test\\sparkdata")//必须是给个目录,不能是文件
      .groupBy("sex")
      .sum("age")

    user.writeStream
      .format("console")
      .outputMode("update")
      .trigger(Trigger.ProcessingTime(1000))//触发器 数字表示毫秒值,0表示立即处理
      .start()
      .awaitTermination()
  }
}

注意:前面获取 user 的代码也可以使用下面的替换:

val user: DataFrame = spark.readStream
            .schema(userSchema)
            .csv("E:\\file\\test\\sparkdata")

在目录E:\file\test\sparkdata下创建 user.csv 文件:
在这里插入图片描述

结果:
执行程序,结果为:

-------------------------------------------
Batch: 0
-------------------------------------------
+------+--------+
|   sex|sum(age)|
+------+--------+
|female|      22|
|  male|      60|
+------+--------+
2.2 读取自动分区的文件夹内的文件

  当文件夹被命名为 “key=value” 形式时, Structured Streaming 会自动递归遍历当前文件夹下的所有子文件夹, 并根据文件名实现自动分区。
  如果文件夹的命名规则不是“key=value”形式, 则不会触发自动分区. 另外, 同级目录下的文件夹的命名规则必须一致。

  1. 在E:\file\test\sparkdata目录下创建三个文件夹,并把上面所用到的CSV文件复制几份到文件夹中,同时这三个文件夹的同级目录不要有其他文件:
    在这里插入图片描述

  2. 代码:
    与上方的 2.1 节的代码一样,我只注释了聚合的操作而已:

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.types.{IntegerType, StringType, StructField, StructType}

object FileSource1 {
  def main(args: Array[String]): Unit = {
    // 初始化SparkSession 
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("FileSource1").getOrCreate()
    // 导入隐式转换
    import spark.implicits._


    val userSchema = StructType(StructField("name", StringType) :: StructField("age", IntegerType) :: StructField("sex", StringType) :: Nil)
    val df = spark.readStream
      .format("csv")
      .schema(userSchema)
      .load("E:\\file\\test\\sparkdata")
      /*.groupBy("sex")
      .sum("age")*/

    df.writeStream
      .format("console")
      .outputMode("update")
      .trigger(Trigger.ProcessingTime(1000))
      .start()
      .awaitTermination()
  }
}

部分结果为(结果太多,就没有拷贝完):

-------------------------------------------
Batch: 0
-------------------------------------------
+----+---+------+----+
|name|age|   sex|year|
+----+---+------+----+
|lisi| 20|  male|2017|
|  zs| 18|  male|2017|
|  zs| 18|  male|2019|
| fsd| 22|  male|2019|
|lili| 22|female|2019|
|lisi| 20|  male|2018|
|lili| 22|female|2018|
+----+---+------+----+

3. Kafka source

参考文档:
http://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html

导入依赖:

        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql-kafka-0-10_2.11</artifactId>
            <version>2.4.3</version>
        </dependency>
3.1 以 Streaming 模式创建 Kafka 工作流
import org.apache.spark.sql.{DataFrame, SparkSession}

object KafkaSource {
  def main(args: Array[String]): Unit = {
    // 初始化SparkSession 
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("KafkaSource").getOrCreate()
    // 导入隐式转换
    import spark.implicits._

    val df: DataFrame = spark.readStream	//按照流的方式读
      .format("kafka") //数据源的格式
      .option("kafka.bootstrap.servers", "hadoop102:9092,hadoop103:9092,hadoop104:9092")
      .option("subscribe", "topic1") // 也可以订阅多个主题: "topic1,topic2"
      .load()
      .selectExpr("cast(value as string)") //这个算子可以写SQL表达式,写SQL转换类型成string
      .as[String] //转成DataSet
      .flatMap(_.split(" "))
      .groupBy("value") //DataFrame默认列名为value
      .count()


    df.writeStream
      .format("console")
      .outputMode("update")
      .option("truncate", false) //那些很长的字段显示不完全的也不省略了,设置全部显示出来
      .start()
      .awaitTermination()
    
  }

}

启动程序,然后在Kafka生产数据:

[fseast@hadoop102 kafka_2.11-0.11.0.2]$ bin/kafka-console-producer.sh --broker-list hadoop102:9092 --topic topic1
>aa cc aa

结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
|cc   |1    |
|aa   |2    |
+-----+-----+
3.2 通过 Batch 模式创建 Kafka 工作流

  这种模式一般需要设置消费的其实偏移量和结束偏移量, 如果不设置 checkpoint 的情况下, 默认起始偏移量 earliest, 结束偏移量为 latest.
  该模式为一次性作业(批处理), 而非持续性的处理数据,就是只执行一次就结束了。

代码:

import org.apache.spark.sql.{DataFrame, SparkSession}

object KafkaSource1 {
  def main(args: Array[String]): Unit = {
    // 初始化SparkSession 
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("KafkaSource1").getOrCreate()
    // 导入隐式转换
    import spark.implicits._

    val df: DataFrame = spark.read  //按照批处理的方式读,使用 read 方法,而不是 readStream 方法
      .format("kafka") //数据源的格式
      .option("kafka.bootstrap.servers", "hadoop102:9092,hadoop103:9092,hadoop104:9092")
      .option("subscribe", "topic1") // 也可以订阅多个主题: "topic1,topic2"
      .option("startingOffsets","earliest")//开始的offset,也就是从哪里开始消费topic,earliest是最开始,传{"topic1":{"0":12}}这种格式也可以
      .option("endingOffsets","latest")//结束的offset,latest是最后的
      .load()
      .selectExpr("cast(value as string)") //这个算子可以写SQL表达式,写SQL转换类型成string
      .as[String] //转成DataSet
      .flatMap(_.split(" "))
      .groupBy("value") //DataFrame默认列名为value
      .count()


    df.write  //使用 write 而不是 writeStream
      .format("console")
      .option("truncate", false) //那些很长的字段显示不完全的也不省略了,设置全部显示出来
      .save()
    
  }

}

结果:
因为设置从一开始消费,所以能消费到之前生产过的数据。

+-----+-----+
|value|count|
+-----+-----+
|cc   |3    |
|aa   |6    |
+-----+-----+

4. Rate Source

  以固定的速率生成固定格式的数据, 用来测试 Structured Streaming 的性能。

import org.apache.spark.sql.{DataFrame, SparkSession}

object RateSource {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("RateSource").getOrCreate()
    
    val df: DataFrame = spark.readStream
      .format("rate") // 设置数据源为 rate
      .option("rowPerSecond",100)// 设置每秒产生的数据的条数, 默认是 1
      .option("rampUpTime",1) // 设置多少秒到达指定速率 默认为 0
      .option("numPartitions",3)  // 设置分区数  默认是 spark 的默认并行度
      .load
    
    df.writeStream
      .format("console")
      .outputMode("update")
      .option("truncate",false)//很长的字段显示不完全的也不省略了,设置全部显示出来
      .start()
      .awaitTermination()
  }
}

结果为:

-------------------------------------------
Batch: 7
-------------------------------------------
+-----------------------+-----+
|timestamp              |value|
+-----------------------+-----+
|2019-09-24 23:21:39.461|5    |
+-----------------------+-----+

-------------------------------------------
Batch: 8
-------------------------------------------
+-----------------------+-----+
|timestamp              |value|
+-----------------------+-----+
|2019-09-24 23:21:40.461|6    |
+-----------------------+-----+

五、操作 Streaming DataFrame/Streaming DataSet

  在streaming DataFrames/Datasets上应用各种操作。
  主要分两种:
    1. 直接执行 sql
    2. 特定类型的 api(DSL)
       DSL也分为强类型(操作DF)和弱类型(操作DS)。

1. 基本操作

Most of the common operations on DataFrame/Dataset are supported for streaming.
在 DF/DS 上大多数通用操作都支持作用在 Streaming DataFrame/Streaming DataSet 上。


  把下面的 json 内容放到E:\file\test\sparkdata目录下的people.json文件中(下面要使用):

{"name": "Michael","age": 29,"sex": "female"}
{"name": "Andy","age": 30,"sex": "male"}
{"name": "Justin","age": 19,"sex": "male"}
{"name": "Lisi","age": 18,"sex": "male"}
{"name": "zs","age": 10,"sex": "female"}
{"name": "zhiling","age": 40,"sex": "female"}
1.1 弱类型 api(了解)
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.sql.types.{LongType, StringType, StructType}

object UnTypeOpt {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession
      .builder()
      .master("local[*]")
      .appName("UnTypeOpt")
      .getOrCreate()
    val peopleSchema: StructType = new StructType()
      .add("name", StringType)
      .add("age", LongType)
      .add("sex", StringType)
    val peopleDF: DataFrame = spark.readStream
      .schema(peopleSchema)
      .json("E:\\file\\test\\sparkdata")  // 等价于: format("json").load(path)


    val df: DataFrame = peopleDF.select("name", "age", "sex").where("age > 20").groupBy("sex").sum("age") // 弱类型 api
    df.writeStream
      .outputMode("complete")
      .format("console")
      .start
      .awaitTermination()
  }
}

执行结果为:

-------------------------------------------
Batch: 0
-------------------------------------------
+------+--------+
|   sex|sum(age)|
+------+--------+
|female|      69|
|  male|      30|
+------+--------+
1.2 强类型 api(了解)
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.sql.types.{LongType, StringType, StructType}

object TypeOpt {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession
      .builder()
      .master("local[*]")
      .appName("BasicOperation")
      .getOrCreate()
    import spark.implicits._
    val peopleSchema: StructType = new StructType()
      .add("name", StringType)
      .add("age", LongType)
      .add("sex", StringType)
    val peopleDF: DataFrame = spark.readStream
      .schema(peopleSchema)
      .json("E:\\file\\test\\sparkdata")  // 等价于: format("json").load(path)

    val ds = peopleDF.as[People].filter(_.age > 20).map(_.name)

    ds.writeStream
      .outputMode("update")
      .format("console")
      .start
      .awaitTermination()
  }


}

case class People(name: String, age: Long, sex: String)

结果为:

-------------------------------------------
Batch: 0
-------------------------------------------
+-------+
|  value|
+-------+
|Michael|
|   Andy|
|zhiling|
+-------+
1.3 直接执行 sql(重要)
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.sql.types.{LongType, StringType, StructType}

object BasicOperation3 {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession
      .builder()
      .master("local[*]")
      .appName("BasicOperation3")
      .getOrCreate()
    import spark.implicits._

    val peopleSchema: StructType = new StructType()
      .add("name", StringType)
      .add("age", LongType)
      .add("sex", StringType)
    val peopleDF: DataFrame = spark.readStream
      .schema(peopleSchema)
      .json("E:\\file\\test\\sparkdata")

    peopleDF.createOrReplaceTempView("people") // 创建临时表
    val df: DataFrame = spark.sql("select * from people where age > 20")

    df.writeStream
      .outputMode("append")
      .format("console")
      .start
      .awaitTermination()
  }
}

结果为:

-------------------------------------------
Batch: 0
-------------------------------------------
+-------+---+------+
|   name|age|   sex|
+-------+---+------+
|Michael| 29|female|
|   Andy| 30|  male|
|zhiling| 40|female|
+-------+---+------+

2. 基于 event-time 的窗口操作

2.1 event-time 窗口理解

  在 Structured Streaming 中, 可以按照事件发生时的时间对数据进行聚合操作, 即基于 event-time 进行操作.
  在这种机制下, 即不必考虑 Spark 陆续接收事件的顺序是否与事件发生的顺序一致, 也不必考虑事件到达 Spark 的时间与事件发生时间的关系.
  因此, 它在提高数据处理精度的同时, 大大减少了开发者的工作量.
  我们现在想计算 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的计数。因此,计数将由分组键(即单词)和窗口(可以从事件时间计算)索引。
统计后的结果应该是这样的:
在这里插入图片描述

import java.sql.Timestamp

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.Trigger


object Window1 {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("Window1").getOrCreate()
    import spark.implicits._
    
    import org.apache.spark.sql.functions._
    val lines = spark.readStream
      .format("socket") //设置数据源
      .option("host","hadoop102")
      .option("port",9999)
      .option("includeTimestamp",true)  //给产生的数据自动添加时间戳
      .load
      .as[(String,Timestamp)]
      .flatMap{
        case (words,ts) => words.split("\\W+").map((_,ts))
      }
      .toDF("word","ts")
      .groupBy(
      // 调用 window 函数, 返回的是一个 Column 参数 1: 表示时间戳的列 参数 2: 窗口长度 参数 3: 滑动步长
        window($"ts","4 minutes","2 minutes"),
        $"word")
      .count()
    
    lines.writeStream
      .format("console")
      .outputMode("update")
      .trigger(Trigger.ProcessingTime(1000))
      .option("truncate",false)//显示完全
      .start()
      .awaitTermination()
  }
}

在nc窗口输入数据:

[fseast@hadoop102 ~]$ nc -lk 9999
aa cc
bb aa

显示结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-09-25 11:04:00, 2019-09-25 11:08:00]|cc  |1    |
|[2019-09-25 11:06:00, 2019-09-25 11:10:00]|cc  |1    |
|[2019-09-25 11:04:00, 2019-09-25 11:08:00]|aa  |1    |
|[2019-09-25 11:06:00, 2019-09-25 11:10:00]|aa  |1    |
+------------------------------------------+----+-----+
-------------------------------------------
Batch: 2
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-09-25 11:04:00, 2019-09-25 11:08:00]|aa  |2    |
|[2019-09-25 11:06:00, 2019-09-25 11:10:00]|bb  |1    |
|[2019-09-25 11:06:00, 2019-09-25 11:10:00]|aa  |2    |
|[2019-09-25 11:04:00, 2019-09-25 11:08:00]|bb  |1    |
+------------------------------------------+----+-----+

  由此可以看出, 在这种窗口机制下, 无论事件何时到达, 以怎样的顺序到达, Structured Streaming 总会根据事件时间生成对应的若干个时间窗口, 然后按照指定的规则聚合。下面可以看一下它这些窗口机制的规则。

2.2 event-time 窗口生成规则

org.apache.spark.sql.catalyst.analysis.TimeWindowing

// 窗口个数
maxNumOverlapping = ceil(windowDuration / slideDuration)
for (i <- 0 until maxNumOverlapping)
   windowId <- ceil((timestamp - startTime) / slideDuration)
   windowStart <- windowId * slideDuration + (i - maxNumOverlapping) * slideDuration + startTime
   windowEnd <- windowStart + windowDuration
   return windowStart, windowEnd

案例:
自己输入时间

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.Trigger


object Window1 {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("Window1").getOrCreate()
    import org.apache.spark.sql.functions._
    import spark.implicits._
    // 输入的数据中包含时间戳, 而不是自动添加的时间戳
    //2019-09-25 09:50:00,fseast
    val lines = spark.readStream
      .format("socket") //设置数据源
      .option("host","hadoop102")
      .option("port",9999)
      .load
      .as[String]
      .map(line => {
        val split = line.split(",")
        (split(0),split(1))
      })
      .toDF("ts","word")
      .groupBy(
        // 窗口长度为10,滑动步长为5
        window($"ts","10 minutes","5 minutes"),
        $"word")
      .count()
    
    lines.writeStream
      .format("console")
      .outputMode("complete")
      .trigger(Trigger.ProcessingTime(1000))
      .option("truncate",false)//显示完全
      .start()
      .awaitTermination()
  }
}

在nc窗口进行数据输入:

[fseast@hadoop102 ~]$ nc -lk 9999
2019-09-25 09:50:00,hello 

输出的结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+-----+-----+
|window                                    |word |count|
+------------------------------------------+-----+-----+
|[2019-09-25 09:50:00, 2019-09-25 10:00:00]|hello|1    |
|[2019-09-25 09:45:00, 2019-09-25 09:55:00]|hello|1    |
+------------------------------------------+-----+-----+

分析:
窗口长度和滑动步长:
window($“ts”,“10 minutes”,“5 minutes”)

2019-09-25 09:50:00,50 刚好是滑动步长5的整数,所以9点50减去2*5就是就9:40。所以第一个区间范围是[2019-09-25 09:40:00 - 2019-09-25 09:50:00)。
因此有区间:
[2019-09-25 09:40:00 - 2019-09-25 09:50:00)左闭右开,所以50分不在这个区间
[2019-09-25 09:50:00 - 2019-09-25 09:55:00)可知2019-09-25 09:50:00,hello在这区间
[2019-09-25 09:50:00 - 2019-09-25 10:00:00)可知2019-09-25 09:50:00,hello在这个区间


再次输入数据:

2019-09-25 09:49:00,test

输出结果为:

-------------------------------------------
Batch: 2
-------------------------------------------
+------------------------------------------+-----+-----+
|window                                    |word |count|
+------------------------------------------+-----+-----+
|[2019-09-25 09:40:00, 2019-09-25 09:50:00]|test |1    |
|[2019-09-25 09:50:00, 2019-09-25 10:00:00]|hello|1    |
|[2019-09-25 09:45:00, 2019-09-25 09:55:00]|test |1    |
|[2019-09-25 09:45:00, 2019-09-25 09:55:00]|hello|1    |
+------------------------------------------+-----+-----+

分析:
窗口长度和滑动步长:
window($“ts”,“10 minutes”,“5 minutes”)
2019-09-25 09:49:00,比49大的且是滑动步长5的倍数是50,所以50减2*5为9点40。所以第一个区间是[2019-09-25 09:40:00 - 2019-09-25 09:50:00)。往后每个区间加滑动步长5即可。
区间:
[2019-09-25 09:40:00 - 2019-09-25 09:50:00)可知2019-09-25 09:49:00,test在这个区间
[2019-09-25 09:45:00 - 2019-09-25 10:55:00)可知2019-09-25 09:49:00,test在这个区间



修改上面的窗口长度和滑动步长:把window($“ts”,“10 minutes”,“5 minutes”)改成window($“ts”,“10 minutes”,“3 minutes”),即窗口长度为10,窗口滑动步长为3.

然后启动程序并在nc窗口输入数据:

2019-09-25 09:50:00,fseast

输出结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+------+-----+
|window                                    |word  |count|
+------------------------------------------+------+-----+
|[2019-09-25 09:42:00, 2019-09-25 09:52:00]|fseast|1    |
|[2019-09-25 09:48:00, 2019-09-25 09:58:00]|fseast|1    |
|[2019-09-25 09:45:00, 2019-09-25 09:55:00]|fseast|1    |
+------------------------------------------+------+-----+

分析:
2019-09-25 09:50:00,比50大的且是步长3的倍数的第一个数是51,51减去3*4等于9点39。(9:51是某一个区间的起点,所以往前减去n多个步长就可以得到包含9点50的区间了)
所以区间有:
[2019-09-25 09:39:00-2019-09-25 09:49:00)可知2019-09-25 09:50:00不在这个区间
[2019-09-25 09:42:00-2019-09-25 09:52:00)可知2019-09-25 09:50:00在这个区间
[2019-09-25 09:45:00-2019-09-25 09:55:00)可知2019-09-25 09:50:00在这个区间
[2019-09-25 09:48:00-2019-09-25 09:58:00)可知2019-09-25 09:50:00在这个区间

3. 基于 Watermark 处理延迟数据

  在数据分析系统中, Structured Streaming 可以持续的按照 event-time 聚合数据, 然而在此过程中并不能保证数据按照时间的先后依次到达. 例如: 当前接收的某一条数据的 event-time 可能远远早于之前已经处理过的 event-time. 在发生这种情况时, 往往需要结合业务需求对延迟数据进行过滤。
  现在考虑如果事件延迟到达会有哪些影响. 假如, 一个单词在 12:04(event-time) 产生, 在 12:11 到达应用. 应用应该使用 12:04 来在窗口(12:00 - 12:10)中更新计数, 而不是使用 12:11. 这些情况在我们基于窗口的聚合中是自然发生的, 因为结构化流可以长时间维持部分聚合的中间状态。
  但是, 如果这个查询运行数天, 系统很有必要限制内存中累积的中间状态的数量. 这意味着系统需要知道何时从内存状态中删除旧聚合, 因为应用不再接受该聚合的后期数据。
  为了实现这个需求, 从 spark2.1, 引入了 watermark(水印), 使用引擎可以自动的跟踪当前的事件时间, 并据此尝试删除旧状态。
  通过指定 event-time 列和预估事件的延迟时间上限来定义一个查询的 watermark. 针对一个以时间 T 结束的窗口, 引擎会保留状态和允许延迟时间直到(max event time seen by the engine - late threshold > T). 换句话说, 延迟时间在上限内的被聚合, 延迟时间超出上限的开始被丢弃.
  可以通过withWatermark() 来定义watermark
  watermark 计算: watermark = MaxEventTime - Threshhod
  而且, watermark只能逐渐增加, 不能减少.

总结:
Structured Streaming 引入 Watermark 机制, 主要是为了解决以下两个问题:
(1)处理聚合中的延迟数据
(2)减少内存中维护的聚合状态.
在不同输出模式(complete, append, update)中, Watermark 会产生不同的影响.

水印是等下一次数据出来的时候,用来抛弃数据。
水印的目的就是为了腾出内存。

3.1 update 模式下使用 watermark

在 update 模式下, 仅输出与之前批次的结果相比, 涉及更新或新增的数据。

代码:

import java.sql.Timestamp

import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}
import org.apache.spark.sql.streaming.{StreamingQuery, Trigger}

object Watermark1 {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]")
      .appName("Watermark1")
      .getOrCreate()
    
    import spark.implicits._
    val lines: DataFrame = spark.readStream
      .format("socket")
      .option("host","hadoop102")
      .option("port",9999)
      .load()
    
    //输入的数据中包含时间戳, 而不是自动添加的时间戳
    // 2019-09-25 10:20:00,hello hello hello
    val words: DataFrame = lines.as[String]
      .flatMap(line =>{
        val split = line.split(",")
        split(1).split(" ").map((_,Timestamp.valueOf(split(0))))
      })
      .toDF("word","timestamp")
    
    import org.apache.spark.sql.functions._
    
    val wordCounts: Dataset[Row] = words
      // 添加watermark, 参数 1: event-time 所在列的列名 参数 2: 延迟时间的上限.
      .withWatermark("timestamp", "2 minutes")
      .groupBy(
        window($"timestamp", "10 minutes", "2 minutes"),
        $"word")
      .count()

    val query: StreamingQuery = wordCounts.writeStream
      .outputMode("update")
      .trigger(Trigger.ProcessingTime(2000))
      .format("console")
      .option("truncate", "false")
      .start
    query.awaitTermination()
  }
}

结果及分析:


初始化水印是:watermark = 0
输入第一条数据:

2019-08-14 10:55:00,dog

此时的水印为: 2019-08-14 10:55:00 - 2 min = 2019-08-14 10:53:00
显示的结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
|[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |1    |
|[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |1    |
|[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
|[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |1    |
+------------------------------------------+----+-----+

根据窗口的长度以及窗口的步长,window($“timestamp”, “10 minutes”, “2 minutes”),
输出的结果确实应该是有五条数据。本次输入数据所产生的水印用于下次输入的数据对比。


第二次输入数据:

2019-08-14 11:00:00,dog

此时的水印为: 2019-08-14 11:00:00 - 2 min = 2019-08-14 10:58:00
显示结果为:

-------------------------------------------
Batch: 3
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-08-14 11:00:00, 2019-08-14 11:10:00]|dog |1    |
|[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |2    |
|[2019-08-14 10:58:00, 2019-08-14 11:08:00]|dog |1    |
|[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |2    |
|[2019-08-14 10:56:00, 2019-08-14 11:06:00]|dog |1    |
+------------------------------------------+----+-----+

因为产生的窗口都是大于上一次的水印 2019-08-14 10:53:00,所以都不抛弃。然后与上一次的窗口一样的count加一。


第三次输入数据:

2019-08-14 10:55:00,dog

此时的水印为:
因为10:55:00 - 2 min = 10:53,因为10:53比上一次的水印2019-08-14 10:58:00小,所以现在的水印还是上一次的水印:2019-08-14 10:58:00(因为 watermark 只能增加不能减少)

输出结果为:

-------------------------------------------
Batch: 5
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-08-14 10:52:00, 2019-08-14 11:02:00]|dog |3    |
|[2019-08-14 10:50:00, 2019-08-14 11:00:00]|dog |2    |
|[2019-08-14 10:54:00, 2019-08-14 11:04:00]|dog |3    |
+------------------------------------------+----+-----+

如果在没有水印的情况下应该还要再输出2条的,但是上一次的水印为2019-08-14 10:58:00,所以10:46 - 10:56 和 10:48 - 10:58 这两个窗口的数据被抛弃掉了。

3.2 append 模式下使用 wartermark

在 append 模式中, 仅输出不可变化的数据。过期的数据可以输出,因为过期的数据不可能再变。

把前一个案例的update 改成 append即可。

    val query: StreamingQuery = wordCounts.writeStream
      .outputMode("append")
      .trigger(Trigger.ProcessingTime(2000))
      .format("console")
      .option("truncate", "false")
      .start

第一次输入数据:
初始水印为: watermark=0

2019-08-14 10:55:00,dog

此时水印为:10:55 - 2 = 10:53
输出结果:

-------------------------------------------
Batch: 1
-------------------------------------------
+------+----+-----+
|window|word|count|
+------+----+-----+
+------+----+-----+

第二次输入数据:

2019-08-14 11:00:00,dog

显示结果为:

-------------------------------------------
Batch: 3
-------------------------------------------
+------+----+-----+
|window|word|count|
+------+----+-----+
+------+----+-----+

因为5个窗口数据都大于上一次的水印,所以5个窗口数据都在,所以append模式下不显示。
此时,水印变成了:11:00 - 2 = 10:58,
所以有两个窗口数据小于了水印,
所以 稍微过了一会又显示:

-------------------------------------------
Batch: 4
-------------------------------------------
+------------------------------------------+----+-----+
|window                                    |word|count|
+------------------------------------------+----+-----+
|[2019-08-14 10:46:00, 2019-08-14 10:56:00]|dog |1    |
|[2019-08-14 10:48:00, 2019-08-14 10:58:00]|dog |1    |
+------------------------------------------+----+-----+

输出完之后,这两个数据会被清除掉。


第三次输入数据:

2019-08-14 10:55:00,dog

因为10:55 - 2 = 10:53小于上一次的水印10:58,所以水印还是10:58.

显示结果为:

-------------------------------------------
Batch: 5
-------------------------------------------
+------+----+-----+
|window|word|count|
+------+----+-----+
+------+----+-----+
3.3 watermark 机制总结
  1. watermark 在用于基于时间的状态聚合操作时, 该时间可以基于窗口, 也可以基于 event-timeb本身.
  2. 输出模式必须是append或update. 在输出模式是complete的时候(必须有聚合), 要求每次输出所有的聚合结果. 我们使用 watermark 的目的是丢弃一些过时聚合数据, 所以complete模式使用wartermark无效也无意义.
  3. 在输出模式是append时, 必须设置 watermark 才能使用聚合操作. 其实, watermark 定义了 append 模式中何时输出聚合聚合结果(状态), 并清理过期状态.
  4. 在输出模式是update时, watermark 主要用于过滤过期数据并及时清理过期状态.
  5. watermark 会在处理当前批次数据时更新, 并且会在处理下一个批次数据时生效使用. 但如果节点发送故障, 则可能延迟若干批次生效.
  6. withWatermark 必须使用与聚合操作中的时间戳列是同一列.df.withWatermark(“time”, “1 min”).groupBy(“time2”).count() 无效
  7. withWatermark 必须在聚合之前调用. f.groupBy(“time”).count().withWatermark(“time”, “1 min”) 无效

4. 流数据去重

根据唯一的 id 实现数据去重

代码:

import java.sql.Timestamp

import org.apache.spark.sql.{DataFrame, SparkSession}

object DropDuplicate {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]")
      .appName("DropDuplicate")
      .getOrCreate()
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")
      .option("host","hadoop001")
      .option("port",9999)
      .load()
    
    val words: DataFrame = lines.as[String].map(line =>{
      val arr: Array[String] = line.split(",")
      (arr(0),Timestamp.valueOf(arr(1)),arr(2))
    }).toDF("uid","ts","word")
    
    val wordCounts = words
      .withWatermark("ts","2 minutes")
      .dropDuplicates("uid")//去重,如果uid相同就是重复,可以传递多个列
    
    wordCounts.writeStream
      .outputMode("append")//没有聚合的append和update一样
      .format("console")
      .start
      .awaitTermination()
  }
}

第一次输入数据:

1,2019-09-14 11:50:00,dog

显示的结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+---+-------------------+----+
|uid|                 ts|word|
+---+-------------------+----+
|  1|2019-09-14 11:50:00| dog|
+---+-------------------+----+

第二次输入数据:

2,2019-09-14 11:51:00,dog

显示结果为:

-------------------------------------------
Batch: 3
-------------------------------------------
+---+-------------------+----+
|uid|                 ts|word|
+---+-------------------+----+
|  2|2019-09-14 11:51:00| dog|
+---+-------------------+----+

第三次输入数据为:

1,2019-09-14 11:50:00,dog

显示结果为:

-------------------------------------------
Batch: 5
-------------------------------------------
+---+---+----+
|uid| ts|word|
+---+---+----+
+---+---+----+

注意:

  1. dropDuplicates 不可用在聚合之后, 即通过聚合得到的 df/ds 不能调用dropDuplicates
  2. 使用watermark - 如果重复记录的到达时间有上限,则可以在事件时间列上定义水印,并使用guid和事件时间列进行重复数据删除。该查询将使用水印从过去的记录中删除旧的状态数据,这些记录不会再被重复。这限制了查询必须维护的状态量。
  3. 没有watermark - 由于重复记录可能到达时没有界限,查询将来自所有过去记录的数据存储为状态。

5. join 操作

Structured Streaming 支持 streaming DataSet/DataFrame 与静态的DataSet/DataFrame 进行 join, 也支持 streaming DataSet/DataFrame与另外一个streaming DataSet/DataFrame 进行 join.
join 的结果也是持续不断的生成, 类似于前面学习的 streaming 的聚合结果.

5.1 Stream-Static Joins

静态数据与流式数据的连接。
模拟的静态数据:

lisi,20
llf,33
zs,30

模拟的流式数据:

lisi,male
llf,female
ww,male

内连接代码:

import org.apache.spark.sql.{DataFrame, SparkSession}

object SteamingStatic {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]")
      .appName("SteamingStatic")
      .getOrCreate()
    import spark.implicits._
    
    //拿到静态的 df
    val arr = Array(("lisi",20),("llf",33),("zs",30))
    val staticDF: DataFrame = spark.sparkContext.parallelize(arr)
      .toDF("name","age")
    
    //动态df(流式df)
    val steamingDF: DataFrame = spark.readStream
      .format("socket")
      .option("host","hadoop001")
      .option("port",9999)
      .load
      .as[String]
      .map(line =>{
        val splits = line.split(",")
        (splits(0),splits(1))
      }).toDF("name","sex")
    
    //内连接   等值内连接  a.name=b.name
    val joinedDF = steamingDF.join(staticDF,Seq("name"))//因此两个字段名要一样
    
    joinedDF.writeStream  //流式数据与静态数据连得到的还是流式数据
      .format("console")
      .outputMode("update")
      .start()
      .awaitTermination()
  }
}

输入第一条数据:

lisi,male

显示结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+----+----+---+
|name| sex|age|
+----+----+---+
|lisi|male| 20|
+----+----+---+

输入第二条数据:

llf,female

显示结果为:

-------------------------------------------
Batch: 2
-------------------------------------------
+----+------+---+
|name|   sex|age|
+----+------+---+
| llf|female| 33|
+----+------+---+

输入第三条数据:

ww,male

显示结果为:
因为静态数据没有ww,所以为空。

-------------------------------------------
Batch: 3
-------------------------------------------
+----+---+---+
|name|sex|age|
+----+---+---+
+----+---+---+

左外连接:
把上方的内连接代码:

    //内连接   等值内连接  a.name=b.name
    val joinedDF = steamingDF.join(staticDF,Seq("name"))//因此两个字段名要一样

改为:

    //外连接   左外连接加个joinType参数
    val joinedDF = steamingDF.join(staticDF,Seq("name"),"left")//因此两个字段名要一样

即可。

输入第一条数据:

lisi,male

显示结果为:

-------------------------------------------
Batch: 1
-------------------------------------------
+----+----+---+
|name| sex|age|
+----+----+---+
|lisi|male| 20|
+----+----+---+

输入第二条数据:

abc,female

显示结果为:

-------------------------------------------
Batch: 2
-------------------------------------------
+----+------+----+
|name|   sex| age|
+----+------+----+
| abc|female|null|
+----+------+----+
5.2 Stream-stream Joins

在 Spark2.3, 开始支持 stream-stream join。
Spark 会自动维护两个流的状态, 以保障后续流入的数据能够和之前流入的数据发生 join 操作, 但这会导致状态无限增长. 因此, 在对两个流进行 join 操作时, 依然可以用 watermark 机制来消除过期的状态, 避免状态无限增长.

数据规划:
第一个数据格式:姓名,年龄,事件时间

lisi,female,2019-09-16 11:50:00
zs,male,2019-09-16 11:51:00
ww,female,2019-09-16 11:52:00
zhiling,female,2019-09-16 11:53:00
hxf,female,2019-09-16 11:54:00
fsd,male,2019-09-16 11:55:00
test,female,2019-09-16 11:56:00

第二个数据格式:姓名,性别,事件时间

lisi,18,2019-09-16 11:50:00
zs,19,2019-09-16 11:51:00
ww,20,2019-09-16 11:52:00
zhiling,22,2019-09-16 11:53:00
eee,30,2019-09-16 11:54:00
fsd,98,2019-09-16 11:55:00

5.2.1 不带 watermast 的 inner join
import java.sql.Timestamp

import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.{DataFrame, SparkSession}

object SteamSteamJoin {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession
      .builder()
      .master("local[*]")
      .appName("StreamStream1")
      .getOrCreate()
    import spark.implicits._

    // 第 1 个 stream
    val nameSexStream: DataFrame = spark.readStream
      .format("socket")
      .option("host", "hadoop102")
      .option("port", 10000)
      .load
      .as[String]
      .map(line => {
        val arr: Array[String] = line.split(",")
        (arr(0), arr(1), Timestamp.valueOf(arr(2)))
      }).toDF("name", "sex", "ts1")

    // 第 2 个 stream
    val nameAgeStream: DataFrame = spark.readStream
      .format("socket")
      .option("host", "hadoop102")
      .option("port", 20000)
      .load
      .as[String]
      .map(line => {
        val arr: Array[String] = line.split(",")
        (arr(0), arr(1).toInt, Timestamp.valueOf(arr(2)))
      }).toDF("name", "age", "ts2")


    // join 操作
    val joinResult: DataFrame = nameSexStream.join(nameAgeStream, "name")

    joinResult.writeStream
      .outputMode("append")
      .format("console")
      .trigger(Trigger.ProcessingTime(0))
      .start()
      .awaitTermination()
  }

}

输入前面准备的数据:

[fseast@hadoop102 ~]$ nc -lk 10000
lisi,female,2019-09-16 11:50:00
zs,male,2019-09-16 11:51:00
ww,female,2019-09-16 11:52:00
zhiling,female,2019-09-16 11:53:00
hxf,female,2019-09-16 11:54:00
fsd,male,2019-09-16 11:55:00
test,female,2019-09-16 11:56:00
[fseast@hadoop102 ~]$ nc -lk 20000
lisi,18,2019-09-16 11:50:00
zs,19,2019-09-16 11:51:00
ww,20,2019-09-16 11:52:00
zhiling,22,2019-09-16 11:53:00
eee,30,2019-09-16 11:54:00
fsd,98,2019-09-16 11:55:00

显示结果为:

-------------------------------------------
Batch: 2
-------------------------------------------
+-------+------+-------------------+---+-------------------+
|   name|   sex|                ts1|age|                ts2|
+-------+------+-------------------+---+-------------------+
|zhiling|female|2019-09-16 11:53:00| 22|2019-09-16 11:53:00|
|     ww|female|2019-09-16 11:52:00| 20|2019-09-16 11:52:00|
|     zs|  male|2019-09-16 11:51:00| 19|2019-09-16 11:51:00|
|    fsd|  male|2019-09-16 11:55:00| 98|2019-09-16 11:55:00|
|   lisi|female|2019-09-16 11:50:00| 18|2019-09-16 11:50:00|
+-------+------+-------------------+---+-------------------+
5.2.2 带 watermast 的 inner join

代码与上面不带Watermast的代码相比,只在两个stream加了Watermast函数。

import java.sql.Timestamp

import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.{DataFrame, SparkSession}

object StreamStreamWatermastJoin {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession
      .builder()
      .master("local[*]")
      .appName("StreamStream1")
      .getOrCreate()
    import spark.implicits._

    // 第 1 个 stream
    val nameSexStream: DataFrame = spark.readStream
      .format("socket")
      .option("host", "hadoop102")
      .option("port", 10000)
      .load
      .as[String]
      .map(line => {
        val arr: Array[String] = line.split(",")
        (arr(0), arr(1), Timestamp.valueOf(arr(2)))
      }).toDF("name", "sex", "ts1")
      .withWatermark("ts1", "2 minutes")

    // 第 2 个 stream
    val nameAgeStream: DataFrame = spark.readStream
      .format("socket")
      .option("host", "hadoop102")
      .option("port", 20000)
      .load
      .as[String]
      .map(line => {
        val arr: Array[String] = line.split(",")
        (arr(0), arr(1).toInt, Timestamp.valueOf(arr(2)))
      }).toDF("name", "age", "ts2")
      .withWatermark("ts1", "2 minutes")


    // join 操作
    val joinResult: DataFrame = nameSexStream.join(nameAgeStream, "name")

    joinResult.writeStream
      .outputMode("append")
      .format("console")
      .trigger(Trigger.ProcessingTime(0))
      .start()
      .awaitTermination()
  }

}
5.2.3 outer join

外连接必须使用 watermast。

和内连接相比, 代码几乎一致, 只需要在连接的时候指定下连接类型即可:joinType = “left_join”。

6. Streaming DF/DS 不支持的操作

到目前, DF/DS 的有些操作 Streaming DF/DS 还不支持。

  1. 多个Streaming 聚合(例如在 DF 上的聚合链)目前还不支持
  2. limit 和取前 N 行还不支持
  3. distinct 也不支持
  4. 仅仅支持对 complete 模式下的聚合操作进行排序操作
  5. 仅支持有限的外连接
  6. 有些方法不能直接用于查询和返回结果, 因为他们用在流式数据上没有意义.

• count() 不能返回单行数据, 必须是s.groupBy().count()
• foreach() 不能直接使用, 而是使用: ds.writeStream.foreach(…)
• show() 不能直接使用, 而是使用 console sink
如果执行上面操作会看到这样的异常: operation XYZ is not supported with streaming DataFrames/Datasets.

六、输出分析

  一旦定义了最终结果DataFrame / Dataset,剩下的就是开始流式计算。为此,必须使用返回的 DataStreamWriter Dataset.writeStream()。
需要指定一下选项:

  1. 输出接收器的详细信息:数据格式,位置等。
  2. 输出模式:指定写入输出接收器的内容。
  3. 查询名称:可选,指定查询的唯一名称以进行标识。
  4. 触发间隔:可选择指定触发间隔。如果未指定,则系统将在前一处理完成后立即检查新数据的可用性。如果由于先前的处理尚未完成而错过了触发时间,则系统将立即触发处理。
  5. 检查点位置:对于可以保证端到端容错的某些输出接收器,请指定系统写入所有检查点信息的位置。这应该是与HDFS兼容的容错文件系统中的目录。

1. 输出模式

1.1 Append 模式(默认)

  默认输出模式, 仅仅添加到结果表的新行才会输出。
  采用这种输出模式, 可以保证每行数据仅输出一次。
  在查询过程中, 如果没有使用 watermark 机制, 则不能使用聚合操作. 如果使用了 watermark 机制, 则只能使用基于 event-time 的聚合操作。
  watermark 用于高速 append 模式如何输出不会再发生变动的数据. 即只有过期的聚合结果才会在 Append 模式中被“有且仅有一次”的输出。

1.2 Complete 模式

每次触发, 整个结果表的数据都会被输出. 仅仅聚合操作才支持.
同时该模式使用 watermark 无效.

1.3 update 模式

该模式在 从 spark 2.1.1 可用. 在处理完数据之后, 该模式只输出相比上个批次变动的内容(新增或修改).
如果没有聚合操作, 则该模式与 append 模式一致. 如果有聚合操作, 则可以基于 watermast 清理过期的状态.

2. 输出接收器(output sink)

spark 提供了几个内置的 output-sink。
不同 output sink 所适用的 output mode 不尽相同。

2.1 file sink

存储输出到目录中 仅仅支持 append 模式

需求: 把单词和单词的反转组成 json 格式写入到目录中。
代码:

import org.apache.spark.sql.{DataFrame, SparkSession}

object FileSink {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]").appName("FileSink").getOrCreate()
    
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")//设置数据源
      .option("host","hadoop102")
      .option("port","9999")
      .load()
    
    val words = lines.as[String].flatMap(line => {
      line.split("\\W+")
        .map(word =>{
        (word,word.reverse)
      })
    }).toDF("原单词","反转单词")
    
    words.writeStream.outputMode("append")
      .format("json")//输出格式,支持 "orc", "json", "csv"
      .option("path","./filesink")//输出目录
      .option("checkpointLocation","./ck1")// 必须指定 checkpoint 目录,否则报错
      .start
      .awaitTermination()
  }

}

输入结果:

[fseast@hadoop102 ~]$ nc -lk 9999
hello,option

得到一个文件夹,里面的 json 文件的内容为:

{"原单词":"hello","反转单词":"olleh"}
{"原单词":"option","反转单词":"noitpo"}
2.2 Kafka Sink
2.2.1 以 Streaming 方式输出数据

代码:

import org.apache.spark.sql.{DataFrame, SparkSession}

object KafkaSink {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("KafkaSink").getOrCreate()
    
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")//设置数据源
      .option("host","hadoop102")
      .option("port",9999)
      .load()
    
    val words = lines.as[String].flatMap(line => {
      line.split("\\W+")
    }).toDF("value")
    
    words.writeStream
      .outputMode("append")
      .format("kafka") 
      .option("kafka.bootstrap.servers","hadoop102:9092,hadoop103:9092,hadoop104:9092")
      .option("topic","testsink")
      .option("checkpointLocation","./ck3")
      .start
      .awaitTermination()
    
  }

}

消费Kafka:

[fseast@hadoop102 kafka_2.11-0.11.0.2]$ bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --from-beginning --topic testsink

输入数据:

[fseast@hadoop102 ~]$ nc -lk 9999
test fsd

消费到的数据为:

[fseast@hadoop102 kafka_2.11-0.11.0.2]$ bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --from-beginning --topic testsink
test
fsd
2.2.2 以 batch 方式输出数据

  这种方式输出离线处理的结果, 将已存在的数据分为若干批次进行处理. 处理完毕后程序退出。

代码:

import org.apache.spark.sql.{DataFrame, SparkSession}

object KafkaSinkBatch {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]")
      .appName("KafkaSinkBatch")
      .getOrCreate()
    
    import spark.implicits._
    
    val wordCount: DataFrame = spark.sparkContext.parallelize(Array("hello hello not","test hello"))
      .flatMap(_.split(" "))
      .toDF("word")
      .groupBy("word")
      .count()
      .map(row => row.getString(0)+","+ row.getLong(1))
      .toDF("value")    //写入数据的时候,必须有一列“value”
    
    wordCount.write
      .format("kafka")
      .option("kafka.bootstrap.servers","hadoop102:9092,hadoop103:9092,hadoop104:9092")
      .option("topic","testsink")
      .save()
  }
}

在Kafka消费数据:

[fseast@hadoop102 kafka_2.11-0.11.0.2]$ bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --from-beginning --topic testsink

然后执行程序,消费到的数据为:

test,1
hello,3
not,1
2.3 console sink

console sink 主要用于测试。

2.4 memory sink

memory sink也是用于测试,将其统计结果全部输入内存中指定的表中, 然后可以通过 sql 与从表中查询数据。
如果数据量非常大,可能会导致内存溢出。

代码:

import java.util.{Timer, TimerTask}

import org.apache.spark.sql.streaming.StreamingQuery
import org.apache.spark.sql.{DataFrame, SparkSession}

object MemorySink {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder()
      .master("local[*]")
      .appName("MemorySink")
      .getOrCreate()
    
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")
      .option("host","hadoop102")
      .option("port","9999")
      .load()
    
    val words: DataFrame = lines.as[String]
      .flatMap(_.split("\\W+"))
      .groupBy("value")
      .count()
    
    val query: StreamingQuery = words.writeStream
      .outputMode("complete")
      .format("memory") // memeory sink
      .queryName("word_count")//内存临时表名
      .start
    
    // 测试使用定时器执行查询表
    val timer = new Timer
    val task = new TimerTask {
      override def run(): Unit = {
        //放要执行的代码
        spark.sql("select * from word_count").show()
      }
    }
    //固定的频率去运行,第二个参数:执行延迟,第三个参数:执行周期就是每隔多长时间执行一次
    timer.scheduleAtFixedRate(task,0,2000)
    
    query.awaitTermination()
    
  }

}

输入数据:

[fseast@hadoop102 ~]$ nc -lk 9999
test hello test

返回的结果:

+-----+-----+
|value|count|
+-----+-----+
|hello|    1|
| test|    2|
+-----+-----+
2.5 foreach sink

  foreach sink 会遍历表中的每一行, 允许将流查询结果按开发者指定的逻辑输出.

案例:
  把从一个网络端口读取数据,进行 WordCount, 将WordCount 结果写入到MySQL数据库中。

(1)添加依赖:

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.27</version>
        </dependency>

(2)在MySQL中创建数据库表:

create database ss;
use ss;
create table word_count(word varchar(255) primary key not null, count bigint not null);

(3)实现代码:

import java.sql.{Connection, DriverManager, PreparedStatement}

import org.apache.spark.sql.{DataFrame, ForeachWriter, Row, SparkSession}

object ForeachSink {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("ForeachSink").getOrCreate()
     
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")//设置数据源
      .option("host","hadoop102")
      .option("port","10000")
      .load()
    
    val wordCount: DataFrame = lines.as[String]
      .flatMap(_.split("\\W+"))
      .groupBy("value")
      .count() //value count
    
    val query = wordCount.writeStream
      .outputMode("update")
      .foreach(new ForeachWriter[Row] {
        //插入数据, 当有重复的 key 的时候更新,
        val sql = "insert into word_count values(?, ?) on duplicate key update word=?, count=?";
        var conn: Connection = null
        
        // open 一般用于打开链接,返回false时则表示跳过该区的数据
        override def open(partitionId: Long, epochId: Long): Boolean = {
          Class.forName("com.mysql.jdbc.Driver")
          conn = DriverManager.getConnection("jdbc:mysql://hadoop102:3306/ss", "root", "123456")
          
          //如果conn不为空,而且conn 没有关,则可以往外写
          conn != null && !conn.isClosed
        }

        //把数据写入到连接中
        override def process(value: Row): Unit = {
          val ps: PreparedStatement = conn.prepareStatement(sql)
          ps.setString(1,value.getString(0))
          ps.setLong(2,value.getLong(1))
          ps.setString(3,value.getString(0))
          ps.setLong(4,value.getLong(1))
          
          ps.execute()
          ps.close()
        }

        //关闭连接
        override def close(errorOrNull: Throwable): Unit = {
          if (conn != null && !conn.isClosed) conn.close()
        }
      })
      .start()
    query.awaitTermination()
  }
}

(4)执行:
启动 netcat 监控端口:

[fseast@hadoop102 ~]$ nc -lk 10000

然后启动刚才写的程序,然后在 10000 端口输入数据:

[fseast@hadoop102 ~]$ nc -lk 10000
aa cc aa
aa cc bb

在MySQL数据库中查看word_count表:
在这里插入图片描述

2.6 ForeachBatch Sink
  1. 案例1:
      把从一个网络端口读取数据,进行 WordCount, 将WordCount 结果写入到MySQL数据库和本地 json 文件中。

代码:

import java.util.Properties

import org.apache.spark.sql.streaming.StreamingQuery
import org.apache.spark.sql.{DataFrame, SparkSession}

object ForeachBatchSink {
  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("ForeachSink").getOrCreate()
     
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")//设置数据源
      .option("host","hadoop102")
      .option("port","10000")
      .load()
    
    val wordCount: DataFrame = lines.as[String]
      .flatMap(_.split("\\W+"))
      .groupBy("value")
      .count() //value count

    val props: Properties = new Properties()
    props.setProperty("user", "root")
    props.setProperty("password", "123456")
    
    val query: StreamingQuery = wordCount.writeStream
      .outputMode("complete")
      .foreachBatch((df,batchId) => {
        df.persist()//持久化
        df.write.mode("overwrite")//如果有则覆盖
          .jdbc("jdbc:mysql://hadoop102:3306/ss","word_count",props)//写到jdbc
        df.write.mode("overwrite").json("./foreachBatch")//写到json
        df.unpersist()
      })
      .start()
    query.awaitTermination()
  }
}

执行:
启动netcat 监控端口,然后启动程序,启动程序时MySQL数据库中的ss表将会被清空。
在 nc 输入数据:

[fseast@hadoop102 ~]$ nc -lk 10000
ttt uuu ttt    
ttt ccc

在MySQL数据库中查看WordCount表:
在这里插入图片描述

也产生了 json 文件:
在这里插入图片描述

  1. 另一种方式:
    把操作写在 foreach 里面:
import java.util.Properties

import org.apache.spark.sql.streaming.StreamingQuery
import org.apache.spark.sql.{DataFrame, SparkSession}

object ForeachBatchSink1 {
  def main(args: Array[String]): Unit = {1
    val spark: SparkSession = SparkSession.builder().master("local[*]")
      .appName("ForeachSink").getOrCreate()
     
    import spark.implicits._
    
    val lines: DataFrame = spark.readStream
      .format("socket")//设置数据源
      .option("host","hadoop102")
      .option("port","10000")
      .load()

    val props: Properties = new Properties()
    props.setProperty("user", "root")
    props.setProperty("password", "123456")
    
    val query: StreamingQuery = lines.writeStream
      .outputMode("complete")
      .foreachBatch((df,batchId) => {
        val result: DataFrame = df.as[String].flatMap(_.split("\\W+"))
          .groupBy("value").count()
        
        result.persist()
        result.write.mode("overwrite")
          .jdbc("jdbc:mysql://hadoop102:3306/ss","word_count",props)
        result.write.mode("overwrite").json("./foreach1")
        result.unpersist()
      })
      .start()
    query.awaitTermination()
  }
}

七、Trigger(触发器)

流式查询的触发器定义了流式数据处理的时间, 流式查询根据触发器的不同, 可以是根据固定的批处理间隔进行微批处理查询, 也可以是连续的查询。

Trigger TypeDescription
unspecified (default)不写即没有显示的设定触发器, 表示使用 micro-batch mode, 尽可能块的处理每个批次的数据. 如果无数据可用, 则处于阻塞状态, 等待数据流入
Fixed interval micro-batches 固定周期的微批处理查询会在微批处理模式下执行, 其中微批处理将以用户指定的间隔执行. 1. 如果以前的微批处理在间隔内完成, 则引擎会等待间隔结束, 然后开启下一个微批次 2. 如果前一个微批处理在一个间隔内没有完成(即错过了间隔边界), 则下个微批处理会在上一个完成之后立即启动(不会等待下一个间隔边界) 3. 如果没有新数据可用, 则不会启动微批次. 适用于流式数据的批处理作业
One-time micro-batch 一次性微批次查询将在所有可用数据上执行一次微批次处理, 然后自行停止. 如果你希望定期启动集群, 然后处理集群关闭期间产生的数据, 然后再关闭集群. 这种情况下很有用. 它可以显著的降低成本. 一般用于非实时的数据分析
Continuous with fixed checkpoint interval (experimental 2.3 引入) 连续处理以超低延迟处理数据(很多功能不支持)
// 1. 默认触发器
val query: StreamingQuery = df.writeStream
    .outputMode("append")
    .format("console")
    .start()
// 2. 微批处理模式
val query: StreamingQuery = df.writeStream
        .outputMode("append")
        .format("console")
        .trigger(Trigger.ProcessingTime("2 seconds"))
        .start

// 3. 只处理一次. 处理完毕之后会自动退出
val query: StreamingQuery = df.writeStream
        .outputMode("append")
        .format("console")
        .trigger(Trigger.Once())
        .start()
        
// 4. 持续处理
val query: StreamingQuery = df.writeStream
    .outputMode("append")
    .format("console")
    .trigger(Trigger.Continuous("1 seconds"))
    .start

1. 连续处理模式(Continuous processing)

  连续处理是2.3 引入, 它可以实现低至 1ms 的处理延迟. 并实现了至少一次(at-least-once)的语义.
  微批处理模式虽然实现了严格一次(exactly-once)的语义, 但是最低有 100ms 的延迟.
  对有些类型的查询, 可以切换到这个模式, 而不需要修改应用的逻辑.(不用更改 df/ds 操作)
  若要切换到连续处理模式, 只需要更改触发器即可。

1.1 连续处理模式支持的查询
  1. 操作: 支持 select, map, flatMap, mapPartitions, etc. 和 selections (where, filter, etc.). 不支持聚合操作
  2. 数据源:
    • kafka 所有选项都支持
    • rate source
  3. sink
    • 所有的 kafka 参数都支持
    • memory sink
    • console sink
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值