我们前边讲过spark streaming实际上一系列微批处理程序组成,与批处理不同的一个地方在于,流处理程序有时间的概念,这里指的时间是数据正式接入spark streaming程序的时间(系统时钟 System Clock)。
对spark RDD 而言,按触发spark job的算子执行顺序,spark 最终会形成两层调度器,第一层是高层DagScheduler(高级调度器)。DAGScheduler负责将Task拆分成不同Stage的具有依赖关系(包含RDD的依赖关系)的多批任务,然后提交给TaskScheduler进行具体处理,由于划分的stage具有依赖关系,DagScheduler在提交stage时会严格保证每个子stage对应的父stage已经执行完毕。第二层是TaskScheduler(低级的调度器接口),TaskScheduler负责实际每个具体Task的物理调度,DagScheduler会把一个stage转换成一组并行的taskset,而TaskScheduler会将taskset里边的task调度到每个executor节点上。
对于spark streaming程序需要增加一个按照批处理时间调度的功能,这个调度器完成的功能就是将这段批处理时间来的数据生成一个job,然后交给DagScheduler,这样就可以无缝的将spark-streaming程序迁移到spark程序中去,就是这么简单。
Streaming Job的产生
在spark-streaming定义了一个上下文类StreamingContext(类似于spark 的sparkContext),其中包含了JobScheduler这样一个组件,在调用start函数后JobScheduler也会正式启动。
case INITIALIZED =>
startSite.set(DStream.getCreationSite())
StreamingContext.ACTIVATION_LOCK.synchronized {
StreamingContext.assertNoOtherContextIsActive()
try {
validate()
// Start the streaming scheduler in a new thread, so that thread local properties
// like call sites and job groups can be reset without affecting those of the
// current thread.
ThreadUtils.runInNewThread("streaming-start") {
sparkContext.setCallSite(startSite.get)
sparkContext.clearJobGroup()
sparkContext.setLocalProperty(SparkContext.SPARK_JOB_INTERRUPT_ON_CANCEL, "false")
savedProperties.set(SerializationUtils.clone(sparkContext.localProperties.get()))
scheduler.start() //启动spark-streaming调度器
}
state = StreamingContextState.ACTIVE
scheduler.listenerBus.post(
StreamingListenerStreamingStarted(System.currentTimeMillis()))
} catch {
case NonFatal(e) =>
logError("Error starting the context, marking it as stopped", e)
scheduler.stop(false)
state = StreamingContextState.STOPPED
throw e
}
StreamingContext.setActiveContext(this)
}
JobScheduler启动后会创建JobGenerator组件,JobGenerator会按照设置的批处理间隔batchDuration进行采样,采样时间到时,会发出GenerateJobs事件。同时JobGenerator会创建一个异步事件池进行事件触发,用来接受GenerateJobs事件,然后DStreamGraph按照采样时间生成一个spark job。
private val timer = new RecurringTimer(clock, ssc.graph.batchDuration.milliseconds,
longTime => eventLoop.post(GenerateJobs(new Time(longTime))), "JobGenerator") //采样定时器,批处理时间到后,发出采样信号
/** Processes all events */
private def processEvent(event: JobGeneratorEvent) {
logDebug("Got event " + event)
event match {
case GenerateJobs(time) => generateJobs(time)
case ClearMetadata(time) => clearMetadata(time)
case DoCheckpoint(time, clearCheckpointDataLater) =>
doCheckpoint(time, clearCheckpointDataLater)
case ClearCheckpointData(time) => clearCheckpointData(time)
}
}
spark streaming 将stream 分为输入流inputStream和输出流outputStream,输入流在生成时创建比如DirectKakfaInputDstream,SocketInputDStream,输出流触发job时生成例如ForEachDStream。其中输出流ForEachDStream包含了从输入流和到输出流的依赖关系,而最终输入流和输出流都会保存在DStreamGraph这个类中,与此同时DStreamGraph会将产生job中这个任务最终会落在输出流头上。
def generateJobs(time: Time): Seq[Job] = {
logDebug("Generating jobs for time " + time)
val jobs = this.synchronized {
outputStreams.flatMap { outputStream =>
val jobOption = outputStream.generateJob(time)
jobOption.foreach(_.setCallSite(outputStream.creationSite))
jobOption
}
}
logDebug("Generated " + jobs.length + " jobs for time " + time)
jobs
}
job生成的过程是一个dfs深度优先遍历的过程,只是这里的根节点是输出流ForEachDStream,最终的叶子节点是每个输入流InputStreamg。输出流ForEachDStream会首先调用其父Dstream的getOrCompute函数生成Dstream的RDD,然后重复这个递归过程,直至到源头InputStream,最后会将Stream依赖链转换为完整的RDD依赖链。这个类似于Spark RDD经过一系列算子transform后,最终触发job时是从最后一个RDD从后向前遍历,直至找到这个stage的原始RDD。
override def generateJob(time: Time): Option[Job] = {
parent.getOrCompute(time) match {
case Some(rdd) =>
val jobFunc = () => createRDDWithLocalProperties(time, displayInnerRDDOps) {
foreachFunc(rdd, time)
}
Some(new Job(time, jobFunc))
case None => None
}
}
这里引用别人一个时序图,来说明上述事情,从最终的根节点dfs遍历过程中,就能将所有的Stream串在一起并形成一组job,交给调度器jobScheduler统一提交调度。
Dstream 生成RDD
从一个Dstream转换为RDD,是spark streaming的重点,这里我们仍以kafka为例。从kafka接受数据,这里有两种方式,一种是接收器(Receiver)来接收kafka中的数据,其最基本是使用Kafka高阶用户API接口。对于所有的接收器,从kafka接收来的数据会存储在spark的executor中或者WAL,之后spark streaming提交的job会处理这些数据,这个也是spark-streaming默认Stream的实现方式,例如SocketInputDStream和KafkaInputDStream,但这种方式有个比较大的问题是,Kafka和spark中保存了两份数据,有些浪费。于是便有了第二种处理方式从kafka直接拉取数据,称为直接拉取方式,当batch任务触发时,由Executor读取数据,并参与到其他Executor的数据计算过程中去。driver来决定读取多少offsets,并将offsets交由checkpoints来维护。将触发下次batch任务,再由Executor读取Kafka数据并计算。我们重点分析第二种方式。
在Dstream中的getOrCompute函数中,generatedRDDs保存了time->RDD的映射关系,如果取不到对应采样时间的RDD,就会重新计算一个RDD,在正式计算RDD之前会调用isTimeValid函数决定当前采样时间戳是否有效。这里有个误区就是,不是每个采样时间到时都会开始RDD计算,最明显的就是窗口函数(可见下一个章节窗口函数),窗口函数有个滑动间隔的概念,这时需要等到滑动时长到时才需要计算RDD,否则返回None。
private[streaming] final def getOrCompute(time: Time): Option[RDD[T]] = {
// If RDD was already generated, then retrieve it from HashMap,
// or else compute the RDD
generatedRDDs.get(time).orElse {
// Compute the RDD if time is valid (e.g. correct time in a sliding window)
// of RDD generation, else generate nothing.
if (isTimeValid(time)) { //这里会判断当前采样时间是否有效
val rddOption = createRDDWithLocalProperties(time, displayInnerRDDOps = false) {
// Disable checks for existing output directories in jobs launched by the streaming
// scheduler, since we may need to write output to an existing directory during checkpoint
// recovery; see SPARK-4835 for more details. We need to have this call here because
// compute() might cause Spark jobs to be launched.
SparkHadoopWriterUtils.disableOutputSpecValidation.withValue(true) {
compute(time) //会按照当前采样时间计算RDD,不同的Dstream有不同的实现方式
}
}
rddOption.foreach { case newRDD =>
// Register the generated RDD for caching and checkpointing
if (storageLevel != StorageLevel.NONE) {
newRDD.persist(storageLevel)
logDebug(s"Persisting RDD ${newRDD.id} for time $time to $storageLevel")
}
if (checkpointDuration != null && (time - zeroTime).isMultipleOf(checkpointDuration)) {
newRDD.checkpoint()
logInfo(s"Marking RDD ${newRDD.id} for time $time for checkpointing")
}
generatedRDDs.put(time, newRDD) //将产生的RDD加入generatedRDDs
}
rddOption
} else {
None //如果当前时间戳无效,则返回None
}
}
}
DirectKafkaInputDStream会在compute函数里边首先计算当前consumer所在的偏移量,同时与上一个采样保存下来的currentOffsets进行比较。如果上一次采样currentOffsets在本次发现有一些分区不存在则会抛出IllegalStateException,代表kafka本身可能进行了一次rebanlance。如果当前consumer加入了新的分区,这里便会感知新分区的存在,并重新定位到对应的分区偏移量。
protected def latestOffsets(): Map[TopicPartition, Long] = {
val c = consumer
paranoidPoll(c)
val parts = c.assignment().asScala
// make sure new partitions are reflected in currentOffsets
val newPartitions = parts.diff(currentOffsets.keySet)
// Check if there's any partition been revoked because of consumer rebalance.
val revokedPartitions = currentOffsets.keySet.diff(parts)
if (revokedPartitions.nonEmpty) {
throw new IllegalStateException(s"Previously tracked partitions " +
s"${revokedPartitions.mkString("[", ",", "]")} been revoked by Kafka because of consumer " +
s"rebalance. This is mostly due to another stream with same group id joined, " +
s"please check if there're different streaming application misconfigure to use same " +
s"group id. Fundamentally different stream should use different group id")
}
// position for new partitions determined by auto.offset.reset if no commit
currentOffsets = currentOffsets ++ newPartitions.map(tp => tp -> c.position(tp)).toMap //感知新分区的存在
// find latest available offsets
c.seekToEnd(currentOffsets.keySet.asJava) //定位到新的分区偏移量
parts.map(tp => tp -> c.position(tp)).toMap
}
定位到最新的偏移量带来的问题,很可能上一次采样的偏移量和当前的偏移量相差过大,就会导致系统反压。流处理反压问题是个比较常见的问题,他是短时负载高峰导致系统接收数据的速率远高于它处理数据的速率,对应到spark-streaming 来说就是计算过程中会出现batch processing time > batch interval的情况,其中batch processing time 为实际计算一个批次花费时间,batch interval为Streaming应用设置的批处理间隔。这意味着Spark Streaming的数据接收速率高于Spark从队列中移除数据的速率,也就是数据处理能力低,在设置间隔内不能完全处理当前接收速率接收的数据。如果这种情况持续过长的时间,会造成数据在内存中堆积,导致Receiver所在Executor内存溢出等问题。Spark 1.5以前版本,用户如果要限制Receiver的数据接收速率,可以通过设置静态配制参数“spark.streaming.receiver.maxRate”的值来实现,此举虽然可以通过限制接收速率,来适配当前的处理能力,防止内存溢出,但也会引入其它问题。比如:producer数据生产高于maxRate,当前集群处理能力也高于maxRate,这就会造成资源利用率下降等问题。为了更好的协调数据接收速率与资源处理能力,Spark Streaming 从v1.5开始引入反压机制(back-pressure),通过PID算法动态控制数据接收速率来适配集群数据处理能力。用户涉及到的重要参数如下:
- spark.streaming.backpressure.enabled:是否启用backpressure机制,默认为false,开启设置为true
- spark.streaming.backpressure.pid.proportional:用于响应错误的权重
- spark.streaming.backpressure.pid.integral:错误积累的响应权重
- spark.streaming.backpressure.pid.derived:对错误趋势的响应权重
- spark.streaming.backpressure.pid.minRate:可以估算的最低费率是多少,默认是100
如果用户开启反压后,在clamp函数会将当前最新的偏移量和反压取数据的最大偏移量进行比较取最小值最为本次批处理的截止偏移量,代表每次只能从kafka拉取这么多的数据,然后将当前偏移量和截止偏移量作为参数构造KafkaRDD。最后将构造KafkaRDD的截止偏移量更新到当前偏移量,如果用户设置了手动提交偏移量的话,会向kafka broker提交。
override def compute(validTime: Time): Option[KafkaRDD[K, V]] = {
val untilOffsets = clamp(latestOffsets())
val offsetRanges = untilOffsets.map { case (tp, uo) =>
val fo = currentOffsets(tp)
OffsetRange(tp.topic, tp.partition, fo, uo)
}
val useConsumerCache = context.conf.getBoolean("spark.streaming.kafka.consumer.cache.enabled",
true)
val rdd = new KafkaRDD[K, V](context.sparkContext, executorKafkaParams, offsetRanges.toArray,
getPreferredHosts, useConsumerCache)
// Report the record number and metadata of this batch interval to InputInfoTracker.
val description = offsetRanges.filter { offsetRange =>
// Don't display empty ranges.
offsetRange.fromOffset != offsetRange.untilOffset
}.map { offsetRange =>
s"topic: ${offsetRange.topic}\tpartition: ${offsetRange.partition}\t" +
s"offsets: ${offsetRange.fromOffset} to ${offsetRange.untilOffset}"
}.mkString("\n")
// Copy offsetRanges to immutable.List to prevent from being modified by the user
val metadata = Map(
"offsets" -> offsetRanges.toList,
StreamInputInfo.METADATA_KEY_DESCRIPTION -> description)
val inputInfo = StreamInputInfo(id, rdd.count, metadata)
ssc.scheduler.inputInfoTracker.reportInfo(validTime, inputInfo)
currentOffsets = untilOffsets
commitAll()
Some(rdd)
}
这样就把最按照采样时间将Dstream转化为Rdd,并保存到generatedRDDs(一个hashMap,保存了时间到RDD的映射),这样从输入流DirectKafkaInputDStream到最终的输出流ForEachDstream,形成的最终形成的调用图如下所示:
窗口函数
窗口是流处理里边一个非常有用的场景,Spark Streaming 提供了窗口计算,它允许你使用一个滑动窗口应用在数据变换中。下图说明了该滑动窗口
任何窗口操作都需要指定2个参数,window length(窗口长度):窗口的持续时间(上图为3个时间单位),sliding interval (滑动间隔)- 窗口操作的时间间隔(上图为2个时间单位)。滑动窗口指明后会按照上述指定的参数顺序移动。
窗口函数的生成RDD过程与普通的Dstream不同的是,普通的Dstream只需要按照普通批处理采样直接生成RDD,然后接着处理一个下一个采样到来的数据即可。但有了窗口函数后,我们需要处理的数据应该是一个窗口的数据而不仅仅是一个采样批处理的数据,另外移动的时间应该是指定的滑动间隔时间,我们看一下spark streaming是如何这个机制的。
在DStream定义了slideDuration滑动时长这个函数,默认情况下滑动时长和批处理间隔batchDuration保持一致。
//InputDStream
override def slideDuration: Duration = {
if (ssc == null) throw new Exception("ssc is null")
if (ssc.graph.batchDuration == null) throw new Exception("batchDuration is null")
ssc.graph.batchDuration
}
//ForEachDStream
override def slideDuration: Duration = parent.slideDuration
但是在WindowedDStream滑动时长是有用户传入的,不在使用默认的批处理时长。
override def slideDuration: Duration = _slideDuration
Dstream->RDD过程中,会进行时间戳校验isTimeValid,这里需要保证的是当前采样时间减去streaming启动时间一定要是滑动时长的整数倍,这就意味着一旦生成WindowedDStream,将会丢掉在滑动时长中间的采样时间。
/** Checks whether the 'time' is valid wrt slideDuration for generating RDD */
private[streaming] def isTimeValid(time: Time): Boolean = {
if (!isInitialized) {
throw new SparkException (this + " has not been initialized")
} else if (time <= zeroTime || ! (time - zeroTime).isMultipleOf(slideDuration)) {
/*
* zeroTime 是程序启动时间
* time - zeroTime 可以整除slideDuration,滑动窗口正式前移,否则保持不动
*/
logInfo(s"Time $time is invalid as zeroTime is $zeroTime" +
s" , slideDuration is $slideDuration and difference is ${time - zeroTime}")
false
} else {
logDebug(s"Time $time is valid")
true
}
}
我们可以看一下启动日志,验证一下我们的看法 ,这里设置批处理时长为4秒,滑动间隔时长是16s,这就意味着三个采样时间会被遗弃掉,下边的启动日志也证明了这一点。
滑动时长明白后,窗口函数的计算就迎刃而解了,当滑动到一个新的窗口后,会计算当前窗口的起始位置和终止位置,然后按照批处理时间进行切分,每段切分的结果就是一个RDD,这样就把一个时间窗口的数据转换为一组RDD,最后将这些RDD进行一次union成unionRDD或者PartitionerAwareUnionRDD返回。
override def compute(validTime: Time): Option[RDD[T]] = {
val currentWindow = new Interval(validTime - windowDuration + parent.slideDuration, validTime) //一个窗口的间隔
val rddsInWindow = parent.slice(currentWindow) //将窗口进行切分
Some(ssc.sc.union(rddsInWindow)) //union后返回
}
窗口函数里边RDD的计算的一些问题
将WindowedDStream转为为unionRDD或者PartitionerAwareUnionRDD取决于分区器的定义和分区器的数量,对于KafkaRDD并没有定义分区器,因此直接转换为UnionRDD,这里会引发一个问题意味着同一个分区会有多个消费者按照不同的偏移量去访问,可能会引发线程不安全问题。
举个例子如上图所示,kafkaConsumer1,kafkaConsumer2可以理解为union前的访问的spark excutor task线程,union后的分区数量是前边所有分区数量的累加,这就意味着在windowStream -> RDD过程中,RDD分区会突然增加很多,特别当窗口时长和批处理时长相差比较大时,而RDD分区分配到task是可能是随机的,这就有可能导致RDD不同分区(如果对应kafka topic相同分区的不同偏移量)分配到不同的spark task线程上,而kafkaConsumer只能被一个线程所拥有,就会报kafka consumer 访问不安全的异常,详情参见SPARK-19185。
//unionRDD 分区会增多,是原来所有RDD分区的累加
override def getPartitions: Array[Partition] = {
val parRDDs = if (isPartitionListingParallel) {
val parArray = rdds.par
parArray.tasksupport = UnionRDD.partitionEvalTaskSupport
parArray
} else {
rdds
}
val array = new Array[Partition](parRDDs.map(_.partitions.length).seq.sum)
var pos = 0
for ((rdd, rddIndex) <- rdds.zipWithIndex; split <- rdd.partitions) {
array(pos) = new UnionPartition(pos, rdd, rddIndex, split.index)
pos += 1
}
array
}
spark给出的解决方案是重新设计了kafkaConsumer,详情参见https://github.com/apache/spark/pull/20997。