Cris 的 Spark Streaming 笔记

一、Spark Streaming 概述

1.1 Spark Streaming是什么

Spark Streaming用于流式数据的处理。Spark Streaming支持的数据输入源很多,例如:KafkaFlumeTwitterZeroMQ和简单的TCP套接字等等。

数据输入后可以用Spark的高度抽象原语如:mapreducejoinwindow等进行运算。而结果也能保存在很多地方,如HDFS,数据库等。

Spark基于RDD的概念很相似,Spark Streaming使用离散化流(discretized stream)作为抽象表示,叫作DStream

DStream 是随时间推移而收到的数据的序列。在内部,每个时间区间收到的数据都作为 RDD 存在,而DStream是由这些RDD所组成的序列(因此得名“离散化”),可以理解为 DStream 是对多个 RDD 的再封装。

1.2 Spark Streaming特点

  1. 易用

  1. 容错

  1. 易整合到Spark体系

1.3 Spark Streaming架构

1. 架构图

整体架构图

架构实现图

2. 背压机制

Spark 1.5以前版本,用户如果要限制Receiver的数据接收速率,可以通过设置静态配制参数“spark.streaming.receiver.maxRate”的值来实现,此举虽然可以通过限制接收速率,来适配当前的处理能力,防止内存溢出,但也会引入其它问题。比如:producer数据生产高于maxRate,当前集群处理能力也高于maxRate,这就会造成资源利用率下降等问题。

为了更好的协调数据接收速率与资源处理能力,1.5版本开始Spark Streaming可以动态控制数据接收速率来适配集群数据处理能力。背压机制(即Spark Streaming Backpressure): 根据JobScheduler反馈作业的执行信息来动态调整Receiver数据接收率。

通过属性“spark.streaming.backpressure.enabled”来控制是否启用backpressure机制,默认值false,即不启用。

二、Dstream 入门

2.1 WordCount 案例实操

需求:使用netcat工具向9999端口不断的发送数据,通过Spark Streaming读取端口数据并统计不同单词出现的次数

1. 安装 netcat 工具

netcat(nc)是一个简单而有用的工具,不仅可以通过使用TCPUDP协议的网络连接读写数据,同时还是一个功能强大的网络调试和探测工具,能够建立你需要的几乎所有类型的网络连接。

Linux终端窗口可以直接使用yum工具进行安装:

然后测试,打开一个端口输入数据

另起一个 Terminal 接受数据

测试 ok~

2. 编写 WC 案例

新建一个模块 Spark Streaming,添加依赖

    <dependencies>
        <dependency>
            <groupId>org.apache.sparkgroupId>
            <artifactId>spark-streaming_2.11artifactId>
            <version>2.1.1version>
        dependency>
    dependencies>
复制代码

编写代码如下

/**
  * 一个简单的使用 Spark Streaming 统计端口发送数据的 WC 程序
  *
  * @author cris
  * @version 1.0
  **/
object Main {
  def main(args: Array[String]): Unit = {
    // 1. 初始化 SparkConf 对象
    val conf: SparkConf = new SparkConf().setAppName("Spark Streaming").setMaster("local[*]")

    // 2. 创建 StreamingContext 对象,Spark Streaming 流程的上下文对象
    val context = new StreamingContext(conf, Seconds(3))

    // 3. 通过监控端口创建DStream,读进来的数据为一行行
    val receiver: ReceiverInputDStream[String] = context.socketTextStream("hadoop101", 9999)
    // 将单词分割,并统计结果
    val dStream: DStream[String] = receiver.flatMap(_.split(" "))
    val dStream2: DStream[(String, Int)] = dStream.map((_, 1))
    val dStream3: DStream[(String, Int)] = dStream2.reduceByKey(_ + _)

    // 将结果打印
    dStream3.print()

    // 4. 启动 Spark Streaming 程序
    context.start()
    context.awaitTermination()
  }
}
复制代码

注意:如果程序运行时,log日志太多,可以将日志级别改成 ERROR

log4j.rootLogger=ERROR, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n
log4j.appender.logfile=org.apache.log4j.FileAppender
log4j.appender.logfile.File=target/spring.log
log4j.appender.logfile.layout=org.apache.log4j.PatternLayout
log4j.appender.logfile.layout.ConversionPattern=%d %p [%c] - %m%n
复制代码

然后启动 9999 端口

[cris@hadoop101 ~]$ nc -lk 9999 
复制代码

在启动 IDEA 中的 Main 程序,此时控制台如下

如果往 9999 端口输入数据

2.2 WordCount 案例解析

Discretized StreamSpark Streaming的基础抽象,代表持续性的数据流和经过各种Spark 算子操作后的结果数据流。在内部实现上,DStream是一系列连续的RDD来表示。每个RDD含有一段时间间隔内的数据,如下图

三、Dstream 创建

3.1 RDD 队列

1. 用法及说明

测试过程中,可以通过使用 ssc.queueStream(queueOfRDDs) 来创建DStream,每一个推送到这个队列中的RDD,都会作为一个DStream处理。

2. 案例实操

需求:循环创建几个RDD,将RDD放入队列。通过SparkStream创建Dstream,计算WordCount

代码如下:

object Main2 {
  def main(args: Array[String]): Unit = {
    //1.初始化Spark配置信息
    val conf = new SparkConf().setMaster("local[*]").setAppName("RDDStream")

    //2.初始化SparkStreamingContext
    val ssc = new StreamingContext(conf, Seconds(4))

    //3.创建RDD队列
    val rddQueue = new mutable.Queue[RDD[Int]]()

    //4.创建QueueInputDStream
    val inputStream: InputDStream[Int] = ssc.queueStream(rddQueue, oneAtATime = false)

    //5.处理队列中的RDD数据
    val mappedStream: DStream[(Int, Int)] = inputStream.map((_, 1))
    val reducedStream: DStream[(Int, Int)] = mappedStream.reduceByKey(_ + _)

    //6.打印结果
    reducedStream.print()

    ssc.start()

    //7.循环创建并向RDD队列中放入RDD
    for (i <- 1 to 5) {
      rddQueue += ssc.sparkContext.makeRDD(1 to 5, 10)
      Thread.sleep(2000)
    }

    ssc.awaitTermination()
  }
}
复制代码

结果展示

3.2 自定义数据源

需要继承Receiver,并实现onStartonStop方法来自定义数据源采集。

实质上就是自定义 Spark Streaming 的数据接受器

代码如下:

class CustomerReceiver(hostName: String, port: Int) extends Receiver[String](StorageLevel.MEMORY_ONLY) {

  // 开启数据接收器
  override def onStart(): Unit = {
    new Thread(new Runnable {
      // 开启一个线程执行数据接受的方法
      override def run(): Unit = receive()
    }).start()
  }

  def receive(): Unit = {

    var socket: Socket = null
    var reader: BufferedReader = null

    try {
      socket = new Socket(hostName, port)
      reader = new BufferedReader(new InputStreamReader(socket.getInputStream, StandardCharsets.UTF_8))
      var str: String = reader.readLine()
      while (str != null) {
        // 保存数据到 Spark Streaming
        store(str)
        str = reader.readLine()
      }
    } catch {
      case e: Exception => {
        reader.close()
        socket.close()
        println("获取数据失败,请调试!")
      }
    }
  }
  override def onStop(): Unit = {}
}

object Main3 {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setAppName("customer receiver").setMaster("local[*]")
    val streamingContext = new StreamingContext(conf, Seconds(2))

    // 从自定义的接收器去接收数据
    val dStream: ReceiverInputDStream[String] = streamingContext.receiverStream(new CustomerReceiver("hadoop101", 9999))
    dStream.print()

    streamingContext.start()
    streamingContext.awaitTermination()
  }
}
复制代码

核心就是 Receiver 这个类,以及 onStartstore 核心方法

3.3 Kafka数据源(开发重点)

1. 用法及说明

在工程中需要引入Maven工件spark-streaming-kafka-0-8_2.11来使用它。包内提供的 KafkaUtils对象可以在 StreamingContextJavaStreamingContext中以你的Kafka消息创建出 DStream

两个核心类:KafkaUtilsKafkaCluster

需求:通过SparkStreamingKafka读取数据,并将读取过来的数据做简单计算(WordCount),最终打印到控制台。

2. 代码完成如下

首先导入依赖

        <dependency>
            <groupId>org.apache.sparkgroupId>
            <artifactId>spark-streaming-kafka-0-8_2.11artifactId>
            <version>2.1.1version>
        dependency>
复制代码

然后使用 Kafka 的低阶 API 手动完成offset 的获取和保存

  • 首先完成 Spark StreamingKafka 的对接程序
  def main(args: Array[String]): Unit = {
    //1.初始化Spark配置信息
    val conf = new SparkConf().setMaster("local[*]").setAppName("KafkaStream")

    //2.初始化SparkStreamingContext
    val ssc = new StreamingContext(conf, Seconds(4))

    //3. kafka参数声明
    val brokers = "hadoop101:9092,hadoop102:9092,hadoop103:9092"
    val topic = "first"
    val group = "cris"
    val deserialization = "org.apache.kafka.common.serialization.StringDeserializer"
    val kafkaPropsMap: Map[String, String] = Map(
      ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> brokers,
      ConsumerConfig.GROUP_ID_CONFIG -> group,
      ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG -> deserialization,
      ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG -> deserialization)

    //4. 获取 KafkaCluster 对象
    val kafkaCluster = new KafkaCluster(kafkaPropsMap)

    //5. 获取上一次读取结束后的 offset
    val fromOffset: Map[TopicAndPartition, Long] = getOffset(topic, group, kafkaCluster).toMap

    //6. 读取 Kafka 数据为 DStream 对象
    val kafkaStream: InputDStream[String] = KafkaUtils.createDirectStream[String, String, StringDecoder, StringDecoder,
      String](ssc,
      kafkaPropsMap,
      fromOffset,
      (message: MessageAndMetadata[String, String]) => message.message())

    //7. DStream 数据处理
    kafkaStream.map((_, 1)).reduceByKey(_ + _).print()

    //8. 保存 offset
    saveOffsets(kafkaCluster, kafkaStream, group)

    //9. 开启 Spark Streaming 程序
    ssc.start()
    ssc.awaitTermination()
  }
复制代码
  • 然后是手动维护 offset 的两个方法(读取和更新)
  /**
    * 获取消费者组上一次数据消费的 offset 位置
    *
    * @param topic        主题
    * @param group        消费者组
    * @param kafkaCluster Kafka 集群抽象
    */
  def getOffset(topic: String, group: String, kafkaCluster: KafkaCluster): mutable.HashMap[TopicAndPartition, Long] = {

    // 定义一个存放主题分区 offset 信息的 map
    val topicAndPartitionToLong = new mutable.HashMap[TopicAndPartition, Long]()

    // 获取主题的分区信息
    val partionsInfo: Either[Err, Set[TopicAndPartition]] = kafkaCluster.getPartitions(Set(topic))

    // 如果主题分区信息有数据
    if (partionsInfo.isRight) {
      // 取出主题分区信息对象
      val infos: Set[TopicAndPartition] = partionsInfo.right.get

      // 获取该消费者组消费 topic 分区数据的 offset 信息
      val offsetInfo: Either[Err, Map[TopicAndPartition, Long]] = kafkaCluster.getConsumerOffsets(group, infos)

      // 如果 offset 信息有该消费者组消费 topic 分区数据的 offset 数据
      if (offsetInfo.isRight) {
        val offsets: Map[TopicAndPartition, Long] = offsetInfo.right.get
        for (offset <- offsets) {
          topicAndPartitionToLong += offset
        }
      } else {
        // 手动初始化该消费者组消费该主题分区数据 offset 信息
        for (topicAndPartition <- infos) {
          topicAndPartitionToLong += (topicAndPartition -> 0)
        }
      }
    }
    topicAndPartitionToLong
  }

  /**
    * 每批次 Spark Streaming 消费信息完毕都要进行 offset 的更新
    *
    * @param kafkaCluster Kafka 集群抽象
    * @param kafkaStream  Spark Streaming 消费 Kafka 数据的抽象
    * @param group
    */
  def saveOffsets(kafkaCluster: KafkaCluster, kafkaStream: InputDStream[String], group: String): Unit = {

    // 将 KafkaStream 对象中的每个 rdd 对象中的 offset 取出来
    kafkaStream.foreachRDD(rdd => {
      // 从 rdd 中取出 offsets
      val ranges: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges].offsetRanges

      // 遍历每个 rdd 得到的所有分区数据的 offsets
      for (offset <- ranges) {
        val untilOffset: Long = offset.untilOffset
        /// 保存 offset
        val result: Either[Err, Map[TopicAndPartition, Short]] = kafkaCluster.setConsumerOffsets(group, Map(offset.topicAndPartition() -> untilOffset))
        if (result.isLeft) {
          println(s"${result.left.get}")
        } else {
          println(s"${result.right.get} + $untilOffset")
        }
      }
    })
      
  }
复制代码

最后测试如下

启动 Kafka 的生产者

kafka_producer_topic first
# 实质上是 kafka-console-producer.sh --broker-list hadoop101:9092 --topic first
# Cris 这里使用了别名代替繁琐的编写
复制代码

然后启动 IDEA 程序

生产数据

IDEA 控制台打印信息

证明 Spark StreamingKafka 对接成功~

四、DStream 转换(重点)

DStream上的操作与RDD的类似,分为Transformations(转换)和Output Operations(输出)两种,此外转换操作中还有一些比较特殊的原语,如:updateStateByKeytransform以及各种Window相关的原语。

4.1 无状态转化操作

无状态转化操作就是把简单的RDD转化操作应用到每个批次上,也就是转化DStream中的每一个RDD。部分无状态转化操作列在了下表中。注意,针对键值对的DStream转化操作(比如 reduceByKey要添加import StreamingContext._才能在Scala中使用。

需要记住的是,尽管这些函数看起来像作用在整个流上一样,但事实上每个DStream在内部是由许多RDD(批次)组成,且无状态转化操作是分别应用到每个RDD上的。例如,reduceByKey会归约每个时间区间中的数据,但不会归约不同时间区间之间的数据。

Transform

Transform允许DStream上执行任意的RDD-to-RDD函数。即使这些函数并没有在DStreamAPI中暴露出来,通过该函数可以方便的扩展Spark API。该函数每一批次调度一次。其实也就是对DStream中的RDD应用转换。

比如之前使用 DStream 完成 WC 案例,我们可以对 DStream 中的每个 RDD 执行 WC 操作,通过 transform 算子

/**
  * 将 DStream 通过 transform 算子转换为一系列的 RDD 进行操作
  *
  * @author cris
  * @version 1.0
  **/
object Main4 {
  def main(args: Array[String]): Unit = {
    // 1. 初始化 SparkConf 对象
    val conf: SparkConf = new SparkConf().setAppName("Spark Streaming").setMaster("local[*]")

    // 2. 创建 StreamingContext 对象,Spark Streaming 流程的上下文对象
    val context = new StreamingContext(conf, Seconds(3))

    // 3. 通过监控端口创建DStream,读进来的数据为一行行
    val Dstream: ReceiverInputDStream[String] = context.socketTextStream("hadoop101", 9999)
    val result: DStream[(String, Int)] = Dstream.transform(rdd => {
      val words: RDD[String] = rdd.flatMap(_.split(" "))
      val wordsToTuple: RDD[(String, Int)] = words.map((_, 1))
      val wordsCount: RDD[(String, Int)] = wordsToTuple.reduceByKey(_ + _)
      wordsCount
    })

    result.print()

    context.start()
    context.awaitTermination()
  }
}
复制代码

4.2 有状态转换操作

UpdateStateByKey

UpdateStateByKey算子用于记录历史记录,有时,我们需要在DStream中跨批次维护状态(例如流计算中累加wordcount)。针对这种情况,updateStateByKey为我们提供了对一个状态变量的访问,用于键值对形式的DStream。给定一个由(键,事件)对构成的 DStream,并传递一个指定如何根据新的事件更新每个键对应状态的函数,它可以构建出一个新的 DStream,其内部数据为(键,状态) 对。

updateStateByKey的结果会是一个新的DStream,其内部的RDD 序列是由每个时间区间对应的(键,状态)对组成的。

updateStateByKey操作使得我们可以在用新信息进行更新时保持任意的状态。为使用这个功能,需要做下面两步:

  1. 定义状态,状态可以是一个任意的数据类型。

  2. 定义状态更新函数,用此函数阐明如何使用之前的状态和来自输入流的新值对状态进行更新。

使用updateStateByKey需要对检查点目录进行配置,会使用检查点来保存状态。

更新版的wordcount

/**
  * 通过保存上一批次的计算结果和当前批次计算结果整合完成数据状态的更新
  *
  * @author cris
  * @version 1.0
  **/
object StatusWC {
  def main(args: Array[String]): Unit = {
    // 1. 初始化 SparkConf 对象
    val conf: SparkConf = new SparkConf().setAppName("Spark Streaming").setMaster("local[*]")

    // 2. 创建 StreamingContext 对象,Spark Streaming 流程的上下文对象
    val context = new StreamingContext(conf, Seconds(3))
    // 2.1 需要设置 CheckPoint 来保存每批次计算的状态,以便于和下一批次计算的结果做整合
    context.sparkContext.setCheckpointDir("./checkpoint")

    // 3. 使用 DStream 完成 WC
    val words: ReceiverInputDStream[String] = context.socketTextStream("hadoop101", 9999)
    val wordsSeparated: DStream[String] = words.flatMap(_.split(" "))
    val wordsTuple: DStream[(String, Int)] = wordsSeparated.map((_, 1))

    // 3.1 定义每批次计算结果和上批次计算结果的整合函数
    val updateStateFunc: (Seq[Int], Option[Int]) => Option[Int] = (values: Seq[Int], state: Option[Int]) => {
      val sum: Int = values.sum
      val result: Int = state.getOrElse(0) + sum
      Some(result)
    }

    // 4. 批次计算并打印
    val result: DStream[(String, Int)] = wordsTuple.updateStateByKey(updateStateFunc)
    result.print()

    context.start()
    context.awaitTermination()
  }
}
复制代码

测试结果

总结:所谓的有状态转换就是通过保存上一批次计算结果,然后和下一批次计算结果整合得到新的计算结果,依次类推~

Window Operations

Window Operations可以设置窗口的大小和滑动窗口的间隔来动态的获取当前Steaming的允许状态。所有基于窗口的操作都需要两个参数,分别为窗口时长以及滑动步长。

(1)窗口时长:计算内容的时间范围;

(2)隔多久触发一次计算。

注意:这两者都必须为批次大小的整数倍。

如下图所示WordCount案例:窗口大小为计算批次的2倍,滑动步等于批次大小。

代码如下:

/**
  * 使用窗口函数,根据步长(计算间隔)来统计窗口长度(计算批次个数)的数据
  *
  * @author cris
  * @version 1.0
  **/
object WindowWC {
  def main(args: Array[String]): Unit = {
    // 1. 初始化 SparkConf 对象
    val conf: SparkConf = new SparkConf().setAppName("Spark Streaming").setMaster("local[*]")

    // 2. 创建 StreamingContext 对象,Spark Streaming 流程的上下文对象
    val context = new StreamingContext(conf, Seconds(3))

    // 3. 使用 DStream 完成 WC
    val words: ReceiverInputDStream[String] = context.socketTextStream("hadoop101", 9999)

    val wordsTuple: DStream[(String, Int)] = words.flatMap(_.split(" ")).map((_, 1))

    // 使用窗口函数,每经过 3 秒就计算当前时刻前 6 秒的所有数据
    val result: DStream[(String, Int)] = wordsTuple.reduceByKeyAndWindow((x: Int, y: Int) => x + y, Seconds(6), Seconds(3))
    result.print()

    context.start()
    context.awaitTermination()
  }
}
复制代码

测试结果如下

关于Window的操作还有如下方法:

(1)window(windowLength, slideInterval): 基于对源DStream窗化的批次进行计算返回一个新的Dstream

(2)countByWindow(windowLength, slideInterval): 返回一个滑动窗口计数流中的元素个数;

(3)reduceByWindow(func, windowLength, slideInterval): 通过使用自定义函数整合滑动区间流元素来创建一个新的单元素流;

(4)reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]): 当在一个(K,V)对的DStream上调用此函数,会返回一个新(K,V)对的DStream,此处通过对滑动窗口中批次数据使用reduce函数来整合每个keyvalue值。

(5)reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]): 这个函数是上述函数的更高效版本,每个窗口的reduce值都是通过用前一个窗的reduce值来递增计算。通过reduce进入到滑动窗口数据并”反向reduce”离开窗口的旧数据来实现这个操作。

一个例子是随着窗口滑动对keys的“加”“减”计数。通过前边介绍可以想到,这个函数只适用于”可逆的reduce函数”,也就是这些reduce函数有相应的”反reduce”函数(以参数invFunc形式传入)。如前述函数,reduce任务的数量通过可选参数来配置。(这个方法可以用于窗口函数的优化)

五、DStream 输出

输出操作指定了对流数据经转化操作得到的数据所要执行的操作(例如把结果推入外部数据库或输出到屏幕上)

RDD中的惰性求值类似,如果一个DStream及其派生出的DStream都没有被执行输出操作,那么这些DStream就都不会被求值。如果StreamingContext中没有设定输出操作,整个context就都不会启动。

输出操作如下:

(1)print():在运行流程序的驱动结点上打印DStream每一批次数据的最开始 10 个元素。这用于开发和调试。在Python API 中,同样的操作叫print

(2)saveAsTextFiles(prefix, [suffix]):以text文件形式存储这个DStream的内容。每一批次的存储文件名基于参数中的prefixsuffix。”prefix-Time_IN_MS[.suffix]”。

(3)saveAsObjectFiles(prefix, [suffix]):以Java对象序列化的方式将Stream中的数据保存为 SequenceFiles . 每一批次的存储文件名基于参数中的为"prefix-TIME_IN_MS[.suffix]". Python中目前不可用。

(4)saveAsHadoopFiles(prefix, [suffix]):将Stream中的数据保存为 Hadoop files. 每一批次的存储文件名基于参数中的为"prefix-TIME_IN_MS[.suffix]"。Python API 中目前不可用。

(5)foreachRDD(func):这是最通用的输出操作,即将函数 func 用于产生于 stream的每一个RDD。其中参数传入的函数func应该实现将每一个RDD中数据推送到外部系统,如将RDD存入文件或者通过网络将其写入数据库。

通用的输出操作foreachRDD,它用来对DStream中的RDD运行任意计算。这和transform 有些类似,都可以让我们访问任意RDD。在foreachRDD()中,可以重用我们在Spark中实现的所有行动操作。比如,常见的用例之一是把数据写到诸如MySQL的外部数据库中。

注意:

(1)连接不能写在driver层面(序列化);

(2)如果写在 RDDforeach方法则每个RDD都创建,得不偿失;

(3)增加foreachPartition,在分区创建。

你可能感兴趣的:(Cris 的 Spark Streaming 笔记)