spark streaming剖析
数据处理方式
- 对于数据,我们可以从时间维度相对的分为有界数据和无界数据
- 在数据计算方式上,分为以批次为单位进行处理的批次计算和以记录为单位类似于流水线的流式处理
- 对于有界数据,我们更希望进行批次处理,而对于无界数据,我们更希望采流式,然而这也不是绝对的,无界数据也可以累积为批次进行批量计算,而有界数据也可以一条一条的进行流水线处理,具体的计算方式根据业务进行合理选择
处理流程
- spark流式处理包括DataSource、receiver、DStream三个部分,receiver负责从数据源接收数据并存储,DStream负责处理数据,是rdd的集合
- 每个DStream会关联一个receiver,但是某些数据源可以不使用receiver(比如hdfs),即direct模式
- 如果需要多个receiver并行接收数据,则需创建多个DStream,并且在master为local的情况下,线程数量必须大于receiver数量,因为receiver和DStream分别在两个线程中运行,如果线程数小于等于receiver数量,则没有线程进行数据处理
- 批次的大小(batchDuration)通过指定时间间隔设定,是最小的处理粒度
- 可以通过window函数,把计算同时作用于多个批次上,但是窗口的大小,必须是批次大小的整数倍,窗口通过步长进行滑动。下图为窗口为2个批次大小,步长为1个批次大小的滑动窗口
- 有状态计算
- updateStateByKey实现计算函数作用于当前key-value和之前的key状态,比如数据累加,下面的代码利用word count的例子,实现数据累加
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.{Seconds, StreamingContext}
object StreamDemo {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName("streamdemo").setMaster("local[2]")
val ssc = new StreamingContext(conf, Seconds(1))
ssc.checkpoint(".")
val sc = ssc.sparkContext
sc.setLogLevel("ERROR")
val source = ssc.socketTextStream("localhost",8899)
val formatStream = source.map(
line=>{
line.split(" ")
}).map(arr=>(arr(0),1))
updateState(formatStream)
ssc.start()
ssc.awaitTermination()
}
def updateState(ds:DStream[(String,Int)]):Unit={
ds.updateStateByKey((newValues:Seq[Int],preValue:Option[Int])=>{
Some(preValue.getOrElse(0)+newValues.sum)
}).print()
}
}
上面的输出结果为:
Time: 1657091815000 ms
-------------------------------------------
(hello,1)
(spark,1)
-------------------------------------------
Time: 1657091816000 ms
-------------------------------------------
(hello,2)
(spark,2)
-------------------------------------------
Time: 1657091817000 ms
-------------------------------------------
(hello,3)
(spark,3)
-------------------------------------------
Time: 1657091818000 ms
-------------------------------------------
(hello,4)
(spark,4)
需要注意的是,有状态操作需要定时做checkpoint,需要配置checkpoint目录
- reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks])操作通过添加reduceFunc和invReduceFunc函数,实现对窗口计算的优化。本次窗口结果=上次窗口计算结果+新进入窗口批次数据-滑出窗口批次数据,还是以word count为例:
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.{Seconds, StreamingContext}
object StreamDemo {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName("streamdemo").setMaster("local[2]")
val ssc = new StreamingContext(conf, Seconds(1))
ssc.checkpoint(".")
val sc = ssc.sparkContext
sc.setLogLevel("ERROR")
val source = ssc.socketTextStream("localhost",8899)
val formatStream = source.map(
line=>{
line.split(" ")
}).map(arr=>(arr(0),1))
windowState(formatStream)
ssc.start()
ssc.awaitTermination()
}
def windowState(ds:DStream[(String,Int)]):Unit={
ds.reduceByKeyAndWindow((oldValue:Int,newValue:Int)=>{
println(s"reducefunc:====oldvalue=$oldValue newvalue=$newValue")
newValue+oldValue
},
(currValue:Int,subValue:Int)=>{
println(s"invReduceFunc:===currValue$currValue subvalue=$subValue")
currValue-subValue
},Seconds(5),Seconds(3)).print()
}
}
输出结果:
reducefunc:====oldvalue=1 newvalue=1
reducefunc:====oldvalue=2 newvalue=1
invReduceFunc:===currValue=5 subvalue=3
reducefunc:====oldvalue=1 newvalue=1
reducefunc:====oldvalue=2 newvalue=1
reducefunc:====oldvalue=2 newvalue=3
reducefunc:====oldvalue=1 newvalue=1
reducefunc:====oldvalue=2 newvalue=1
invReduceFunc:===currValue=5 subvalue=3
reducefunc:====oldvalue=1 newvalue=1
reducefunc:====oldvalue=2 newvalue=1
reducefunc:====oldvalue=2 newvalue=3
-------------------------------------------
Time: 1657117266000 ms
-------------------------------------------
(hello,5)
(spark,5)
窗口计算优化,是有状态操作,需要做checkpoint,需要在窗口计算量与io之间做平衡
DStream内部细节
我们下面还是以work count为例,看下DStream内部处理流程:
- 总的来说,spark streaming是建立在rdd的基础上,streaming最终会转化成rdd进行计算
- JobGenerator会按照每批定义的时间,把接收到的数据打包在一个rdd中,封装成job,最后由jobScheduler的线程池执行
- DStream根据DStream的依赖关系,找到最前端的InputStream,生成rdd,再在rdd上应用对应的转换操作
数据输出
spark stream计算后的数据可以输出到多种外部系统,比如hdfs、db、大屏展示等,对应的操作包括print、saveTextFile、saveAsObjectFiles、saveAsHadoopFiles、foreachRDD,而foreachRDD是最常用的实现输出结果到外部系统的操作,但是需要注意foreachRDD是在driver端执行,对于不能跨机器的对象(比如tcp连接、数据库连接等),需要放在executor端创建。下面是官网提供的正确有效的创建连接的例子:
dstream.foreachRDD { rdd =>
rdd.foreachPartition { partitionOfRecords =>
// ConnectionPool is a static, lazily initialized pool of connections
val connection = ConnectionPool.getConnection()
partitionOfRecords.foreach(record => connection.send(record))
ConnectionPool.returnConnection(connection) // return to the pool for future reuse
}
}
kafka数据源
数据接收模式
- kafka作为数据源,有receiver和direct两种模式,但是在新的kafka consumer api采用direct模式
分区匹配模式
- kafka topic分区与spark 的分区是1:1的对应关系,分区匹配上包括三种模式:
- PreferConsistent—— 所有executor采用一致的方式
- PreferBrokers—— 当executor与broker在同一机器时,采用该模式
- PreferFixed—— 当分区的负载不均衡时,需要手动指定分区的对应关系
偏移量(offset)存储策略
保证结果输出操作幂等
- checkpoint存储
由于checkpoint是周期进行,offset更新不及时,导致重复计算,需要要保证结果输出操作幂等,并且不能修改程序代码 - kafka存储
kafka的第一个输入流中,可以获取当前批次的offset范围和CanCommitOffsets偏移量提交接口
val sparkConf = new SparkConf().setMaster("local[2]").setAppName("kafka")
val ssc = new StreamingContext(sparkConf,Seconds(1))
ssc.sparkContext.setLogLevel("ERROR")
val kafkaParams = Map[String, Object](
"bootstrap.servers" -> "kafka1:9092,kafka2:9092",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"group.id" -> "spark-consumer2",
"auto.offset.reset" -> "earliest",
"enable.auto.commit" -> (false: java.lang.Boolean)
)
val topics = Array("spark")
val kafkaInputStream = KafkaUtils.createDirectStream[String, String] (ssc,LocationStrategies.PreferConsistent,ConsumerStrategies.Subscribe[String,String](topics,kafkaParams)
)
kafkaInputStream.foreachRDD { rdd =>
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
kafkaInputStream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
}
通过kafka存储offset,由于结果存储和offset存储分属不同系统,仍需保证计算结果输出操作的幂等
输出结果与offset存储放入原子事务中
下面为来自官网伪代码
// begin from the offsets committed to the database
val fromOffsets = selectOffsetsFromYourDatabase.map { resultSet =>
new TopicPartition(resultSet.string("topic"), resultSet.int("partition")) -> resultSet.long("offset")
}.toMap
val stream = KafkaUtils.createDirectStream[String, String](
streamingContext,
PreferConsistent,
Assign[String, String](fromOffsets.keys.toList, kafkaParams, fromOffsets)
)
stream.foreachRDD { rdd =>
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
val results = yourCalculation(rdd)
// begin your transaction
// update results
// update offsets where the end of existing offsets matches the beginning of this batch of offsets
// assert that offsets were updated correctly
// end your transaction
}
需要注意的是,可以每个分区自己维护offset,但是需要每个分区单独开启事务,需要考虑事务对资源的消耗情况,比较合理的方式是计算结果回收到driver端,在driver端开启事务,但同时也需要考虑数据回收的大小