Spark从入门到放弃—Spark Streaming实战

简介

上一篇文章对Spark Streaming做了基本的介绍,包括Spark Streaming的概念、特点、应用场景、基础组件等等,具体可见Spark从入门到放弃—Spark Streaming介绍。本文的目的是具体的代码实践,通过若干个小例子来演示Spark Streaming API的使用。

WordCount

还是以经典的WordCount来举例,但是这里不再是统计本地文件的word数量,而是通过网络接收实时数据流,并且实现word的实时统计更新。具体代码如下:

import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.ReceiverInputDStream
import org.apache.spark.streaming.{Seconds, StreamingContext}

object SparkStreaming_WordCount {
    def main(args: Array[String]) : Unit = {
        val sparkConf = new SparkConf().setAppName("WordCount").setMaster("local[*]")
        val ssc = new StreamingContext(sparkConf, Seconds(2))

        // 通过监听端口创建一个DStream对象,包含一行行数据
        val linesStream: ReceiverInputDStream[String] = ssc.socketTextStream("localhost", 9999)
        // 对数据进行切分,形成一个个单词
        val wordStream = linesStream.flatMap(_.split(" "))
        // 将单词映射成元祖
        val wordToOne = wordStream.map(word => (word, 1))
        //将相同的单词进行聚合操作
        val wordcount = wordToOne.reduceByKey(_+_)
        //print,action操作
        wordcount.print()

        //启动ssc
        ssc.start()
        ssc.awaitTermination()
    }
}

代码里面设置的间隔时间为2秒,即每两秒Spark会从Socket数据量中取一次数据,然后执行随后的Transformation等操作。

另外我们打开一个终端窗口,启动一个NetCat服务器,设置为9999号端口,然后启动程序连接服务器,然后在服务端发送一些字符串,Spark接受到数据之后,会统计每个时间单位出现的word的数量,发送的数据如下:
发送的数据
程序的运行结果如下:
运行结果

可以看到间隔时间确实是2秒,并且每次输出的结果刚好是统计了这个时间段内的单词的数量。

自定义数据源

Spark Streaming可以从包括内置数据源在内的任意数据源获取数据(其他数据源包括flume,kafka,kinesis,文件,套接字等等)。如果开发者需要自定义数据源,那么这需要开发者去实现一个定制receiver从具体的数据源接收数据。
要想实现自定义的receiver,必须继承Receiver这个抽象类,并且实现它的两个方法,分别是onStartonStop,这两个方法分别代表开始接受数据以及停止接收数据。
需要注意到是,onStart()启动线程负责数据的接收,onStop()确保这个接收过程停止。一旦接收到了数据,这些数据可以通过调用store()方法存到Spark中,store()是[Receiver]类中的方法。
下面实现一个简单的例子,它继承了Receiver类,并且实现了onStartonStop方法。为了简单起见,在onStart方法中,我们开启了一个线程,每隔500ms就产生一个随机数,并且通过调用父类的store方法,将数据保存到Spark的内存中,然后被StreamingContxt对象获取。在主线程中,我们只是简单地将接收到的数据流打印出来。
代码如下:

def main(args: Array[String]): Unit = {

    val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
    val ssc = new StreamingContext(sparkConf, Seconds(3))

    val messageDS: ReceiverInputDStream[String] = ssc.receiverStream(new MyReceiver())
    messageDS.print()

    ssc.start()
    ssc.awaitTermination()
}
/*
自定义数据采集器
1. 继承Receiver,定义泛型, 传递参数
2. 重写方法
 */
class MyReceiver extends Receiver[String](StorageLevel.MEMORY_ONLY) {
    private var flag = true
    override def onStart(): Unit = {
        new Thread(new Runnable {
            override def run(): Unit = {
                while ( flag ) {
                    val message = "采集的数据为:" + new Random().nextInt(10).toString
                    store(message)
                    Thread.sleep(500)
                }
            }
        }).start()
    }

    override def onStop(): Unit = {
        flag = false;
    }
}
}

结果如下:

Transform操作

Transform操作允许在DStream上执行任意的RDD-to-RDD函数,并且返回一个新的DStream。 使用transform操作,我们就可以轻松地使用RDD操作,即便这些函数没有在DStream的API中暴露出来。举个例子,对一个数据流中的每批次数据与另外一个dataset进行join操作的功能,DStream中并没有直接包含可以执行这些操作的API,但是我们可以通过transform函数轻松实现。值得注意的时候,传递给transform函数的参数,每经过一个批次就会被调用一次。
仍然以上述的WordCount例子来举例说明transform函数的使用,代码如下:

object WordCount {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val lines = ssc.socketTextStream("localhost", 9999)

        val wordAndCountDStream: DStream[(String, Int)] = lines.transform(
            rdd => {
                val words: RDD[String] = rdd.flatMap(_.split(" "))
                val wordAndOne: RDD[(String, Int)] = words.map((_, 1))
                val value: RDD[(String, Int)] = wordAndOne.reduceByKey(_+_)
                value
            }
        )
        //打印 wordAndCountDStream.print
        wordAndCountDStream.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

结果如下:
代码结果

发送的数据

Join操作

使用join函数,我们可以很轻松地对两条流数据进行聚合操作。前提是,两条流的批次大小一致,这样才可以在每个批间隔内对每条流各自的RDD进行聚合操作。同样,我们也可以使用leftOuterJoin, rightOuterJoin, fullOuterJoin等函数。
代码如下:

object WordCount {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(5))

        val data9999 = ssc.socketTextStream("localhost", 9999)
        val data8888 = ssc.socketTextStream("localhost", 8888)

        val map9999: DStream[(String, Int)] = data9999.map((_,9))
        val map8888: DStream[(String, Int)] = data8888.map((_,8))

        // 所谓的DStream的Join操作,其实就是两个RDD的join
        val joinDS: DStream[(String, (Int, Int))] = map9999.join(map8888)

        joinDS.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

有状态转化操作

updateStateByKey操作允许用户保持任意的状态信息,并且可以根据新信息持续更新状态。UpdateStateByKey 原语用于记录历史记录,有时,我们需要在 DStream 中跨批次维护状态(例如流计算中累加 wordcount)。针对这种情况,updateStateByKey()为我们提供了对一个状态变量 的访问,用于键值对形式的 DStream。给定一个由(键,事件)对构成的 DStream,并传递一个指 定如何根据新的事件更新每个键对应状态的函数,它可以构建出一个新的 DStream,其内部数 据为(键,状态) 对。
updateStateByKey() 的结果会是一个新的 DStream,其内部的 RDD 序列是由每个时间区间对 应的(键,状态)对组成的。updateStateByKey 操作使得我们可以在用新信息进行更新时保持任意的状态。为使用这个功能,需要做下面两步:

  1. 定义状态,状态可以是一个任意的数据类型。
  2. 定义状态更新函数,用此函数阐明如何使用之前的状态和来自输入流的新值对状态进行更新。

使用 updateStateByKey 需要对检查点目录进行配置,会使用检查点来保存状态。
在每个批次中,Spark会对所有存在的key调用状态更新函数,而不管这些key在当前批次中是否包含新的数据。
之前实现的WordCount例子,只是对每个批次中出现的word进行计数,而并没有保存状态。使用updateStateByKey函数可以对以往所有出现过的word进行计数,代码如下:


object WordCount {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))
        ssc.checkpoint("cp")

        // 无状态数据操作,只对当前的采集周期内的数据进行处理
        // 在某些场合下,需要保留数据统计结果(状态),实现数据的汇总
        // 使用有状态操作时,需要设定检查点路径
        val datas = ssc.socketTextStream("localhost", 9999)

        val wordToOne = datas.map((_,1))

        // updateStateByKey:根据key对数据的状态进行更新
        // 传递的参数中含有两个值
        // 第一个值表示相同的key的value数据
        // 第二个值表示缓存区相同key的value数据
        val state = wordToOne.updateStateByKey(
            ( seq:Seq[Int], buff:Option[Int] ) => {
                val newCount = buff.getOrElse(0) + seq.sum
                Option(newCount)
            }
        )

        state.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

结果如下:

结果
发送到数据为:
可以看到,使用updateStateByKey操作,我们可以保存状态信息,这在某些场景下非常有用,比如人流量统计。

Window操作

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

  • 窗口时长: 计算内容的时间范围
  • 滑动步长: 隔多久触发一次计算


    window操作示意图

这两者都必须为采集周期大小的整数倍!

以WordCount为例,设置间隔时间为3秒,窗口大小为12秒,滑动步长为6秒,代码如下:


object WordCount {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val lines = ssc.socketTextStream("localhost", 9999)
        val wordToOne = lines.map((_,1))

        // 窗口的范围应该是采集周期的整数倍
        // 窗口可以滑动的,但是默认情况下,一个采集周期进行滑动
        // 这样的话,可能会出现重复数据的计算,为了避免这种情况,可以改变滑动的滑动(步长)
        val windowDS: DStream[(String, Int)] = wordToOne.window(Seconds(12), Seconds(6))

        val wordToCount = windowDS.reduceByKey(_+_)

        wordToCount.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

输入的数据流为:

输入数据流
结果:
由于我们设置的窗口大小为12秒,步长为6秒,即要滑动两次才可以把一个窗口内包含的数据处理完,因此会导致数据被重复处理。
为了提高效率,Spark提供了另外一个函数reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]),当窗口进行滑动的时候,第一个参数func用来增加新加入到窗口中的数据,第二个参数invFunc用来删除掉不再窗口中的数据,即每个窗口的 reduce 值都是通过用前一个窗的 reduce 值来递增计算。仍然以上述的WordCount为例,代码如下:

object WordCount {
    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))
        ssc.checkpoint("cp")

        val lines = ssc.socketTextStream("localhost", 9999)
        val wordToOne = lines.map((_,1))

        // reduceByKeyAndWindow : 当窗口范围比较大,但是滑动幅度比较小,那么可以采用增加数据和删除数据的方式
        // 无需重复计算,提升性能。
        val windowDS: DStream[(String, Int)] =
        wordToOne.reduceByKeyAndWindow(
            (x:Int, y:Int) => { x + y },
            (x:Int, y:Int) => { x - y },
            Seconds(12),
            Seconds(6))

        windowDS.print()

        ssc.start()
        ssc.awaitTermination()
    }
}
结果

从上图中可以明显看到数据的变化过程,这个过程反应的就是窗口的滑动过程,当数据进入到窗口区间内的时候,对应key的value累加,当数据滑出窗口的时候,对应key的value减少。

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容