背压/反压/BackPressure

Flink系列文章

转载声明

本文大量内容系转载自以下文章,有删改,并参考其他文档资料加入了一些内容:

转载仅为方便学习查看,一切权利属于原作者,本人只是做了整理和排版,如果带来不便请联系我删除。

摘要

Back Pressure是流处理系统中,非常经典而常见的问题,它是让流系统能对压力变化能够呈现良好抗压性的关键点所在。各个开源实时处理系统,在中后期,都开始有对背压机制有完善的考虑和设计,基本原理一致,但是实现方式,有依赖于具体系统而各有千秋。

今天我们以Spark Streaming和Flink这两个目前最流行的流处理平台为例,剖析一下Back Pressure的原理和实现技巧。

1 背压的基本概念

1.1 排队理论

在这里插入图片描述
在系统架构设计中,有一个经典的排队理论,其核心的理念是:一个服务中心的服务能力是有限的,完成服务是需要一定时间的。所以为了保障服务中心的服务能正常进行,需要在外面维护一个队列,让到达的消息事件进行排队,直到服务中心完成服务,才能让下一个事件进入服务中心。

1.2 生产者-消费者模式

在这里插入图片描述
体现这种设计理念的经典设计模式之一,就是生产者-消费者模式。生产者产生事件,消费者消费事件,而随着双方数量和处理速度的变化和不均衡,生产者生产速度超过消费者的现象,经常会存在,Queue只能够缓解这种问题,不能根治,而且Queue的长度需要被妥善的设计。

1.3 Reactive Stream

在这里插入图片描述
为了解决这个问题,业界提出了Reactive Stream的设计模式。这是一种生产者-消费者+迭代器的模式。它的改进点在于,消费者(Subscriber)向生产者(Publisher),指明请求的个数,然后生产者根据该数量,向订阅者推送指定数量的消息。
在这里插入图片描述
根据该设计模式,在Java 9中,引入标准的4个接口(Processor[Publisher, Subscriber-Subscription],并应用于RxJava。但是在大数据流式系统中,大部分只是参考其理念,而因为分布式的原因,所以需要做更加精密而复杂的设计。

1.4 真实环境下的流失压力

在这里插入图片描述
在真实的生产环境中,流式系统面对的系统压力,在波峰和波谷是完全不一样的,这个时候如果用固定的资源数,会造成很大的浪费,所以在Spark和Flink中,都会有一个动态Executor个数的模型,可以动态调节。但是就算是如此,在调整的过程中,还是会出现突然的压力过大的情况,难以避免。这个时候,如何让系统能够稳健的应对压力,就需要用到背压的概念和设计了。

1.5 流式系统的背压

在这里插入图片描述
从上图可以看到,一个流式系统的背压能力,其实需要从输入源开始,到最后的输出,每一个位于上游的子模块/系统,都具备根据下游信号量往下游发送指定数量的消息的能力,只有这样,整个流式系统才能完美背压,不会被系统突增的压力挤垮。

1.6 为什么需要网络流控

在这里插入图片描述
首先我们可以看下这张最精简的网络流控的图,Producer 的吞吐率是 2MB/s,Consumer 是 1MB/s,这个时候我们就会发现在网络通信的时候我们的 Producer 的速度是比 Consumer 要快的,有 1MB/s 的这样的速度差,假定我们两端都有一个 Buffer,Producer 端有一个发送用的 Send Buffer,Consumer 端有一个接收用的 Receive Buffer,在网络端的吞吐率是 2MB/s,过了 5s 后我们的 Receive Buffer 可能就撑不住了,这时候会面临两种情况:

  • 如果 Receive Buffer 是有界的,这时候新到达的数据就只能被丢弃掉了。
  • 如果 Receive Buffer 是无界的,Receive Buffer 会持续的扩张,最终会导致 Consumer 的内存耗尽。

1.7 网络流控的实现:静态限速

在这里插入图片描述
为了解决这个问题,我们就需要网络流控来解决上下游速度差的问题,传统的做法可以在 Producer 端实现一个类似 Rate Limiter 这样的静态限流,Producer 的发送速率是 2MB/s,但是经过限流这一层后,往 Send Buffer 去传数据的时候就会降到 1MB/s 了,这样的话 Producer 端的发送速率跟 Consumer 端的处理速率就可以匹配起来了,就不会导致上述问题。但是这个解决方案有两点限制:

  • 事先无法预估 Consumer 到底能承受多大的速率
  • Consumer 的承受能力通常会动态地波动

所以该值调大可能导致Consumer消费不过来而崩溃,而调整太小会导致资源利用率太低,不够灵活。

1.8 网络流控的实现:动态反馈/自动反压

在这里插入图片描述
针对静态限速的问题我们就演进到了动态反馈(自动反压)的机制,我们需要 Consumer 能够及时的给 Producer 做一个 feedback,即告知 Producer 能够承受的速率是多少。动态反馈分为两种:

  • 负反馈:接受速率小于发送速率时发生,告知 Producer 降低发送速率

  • 正反馈:发送速率小于接收速率时发生,告知 Producer 可以把发送速率提上来

让我们来看几个经典案例:

  • Storm 反压实现
    可以看到 Storm 在每一个 Bolt 都会有一个监测反压的线程(Backpressure Thread),这个线程一但检测到 Bolt 里的接收队列(recv queue)出现了严重阻塞就会把这个情况写到 ZooKeeper 里,ZooKeeper 会一直被 Spout 监听,监听到有反压的情况就会停止发送,通过这样的方式匹配上下游的发送接收速率。
    在这里插入图片描述
  • Spark Streaming 反压实现
    Spark Streaming 里也有做类似这样的 feedback 机制,下图 Fetcher 会实时的从 Buffer、Processing 这样的节点收集一些指标然后通过 Controller 把速度接收的情况再反馈到 Receiver,实现速率的匹配。
    在这里插入图片描述

2 Spark Streaming背压

2.1 Spark Streaming基本架构

在这里插入图片描述
Spark Streaming是Spark的流式模块,它基于Spark Core提供了一套基于micro-batch处理的实时流式处理框架。它的基本理念是将数据流转换为DStream,再通过Spark Engine的RDD机制,进行统一处理。

2.2 Spark Streaming系统核心模块

在这里插入图片描述
在这里插入图片描述
上图是SparkStreaming的系统核心模块,和背压特性相关的,主要是模块3:数据的产生和导入。

Spark在1.6.0之后使用Netty替代了Akka实现网络通信,原因主要有:

  • 很多Spark用户也使用Akka,但是由于Akka不同版本之间无法互相通信,这就要求用户必须使用跟Spark完全一样的Akka版本,导致用户无法升级Akka。
  • Spark的Akka配置是针对Spark自身来调优的,可能跟用户自己代码中的Akka配置冲突。
  • Spark用的Akka特性很少,这部分特性很容易自己实现。同时,这部分代码量相比-Akka来说少很多,debug比较容易。如果遇到什么bug,也可以自己马上fix,不需要等Akka上游发布新版本。而且,Spark升级Akka本身又因为第一点会强制要求用户升级他们使用的Akka,对于某些用户来说是不现实的。

2.3 Spark Streaming 背压整体设计

在这里插入图片描述
基于前面的排队理论,Spark Streaming每一批次的处理时长(batch_process_time)需要小于Streaming应用设置的批次间隔batch_interval,否则batch_process_time > batch_interval 意味着处理数据的速度低于接收数据的速度,程序的处理能力不足,积累的数据越来越多,最终会造成Executor的OOM(如果设置StorageLevel包含disk, 则内存存放不下的数据会溢写至disk, 加大延迟)。

可以通过设置参数spark.streaming.receiver.maxRate为true(默认false)来限制Receiver的数据接收速率,此举虽然可以通过限制接收速率,来适配当前的处理能力,防止内存溢出,但也会引入其它问题。比如设置的参数值过低,即producer数据生产速率高于maxRate,当前集群处理能力其实也高于maxRate,这就会造成资源利用率下降等问题。

为了更好的协调数据接收速率与资源处理能力,Spark Steaming从1.5版本开始,开始引入背压机制(back-pressure),第一个相关Issue是经典的SPARK-7398。其大体的思路是:

  • 通过在Driver端进行速率估算,并将速率更新到Executor端的各个Receiver,从而实现动态控制数据接收速率来适配集群数据处理能力,即背压。

Spark Streaming反压过程主要是根据JobSchedule反馈作业的执行信息来估算当前的最大处理速度(rate),然后动态地调整Receiver数据接收率。

  1. 在原架构的基础上加上一个新的组件RateController,这个组件负责监听OnBatchCompleted事件,从中抽取processingDelayschedulingDelay信息.
  2. Estimator依据这些信息估算出最大处理速度rate
  3. 最后由基于Receiver的Input Stream将rate通过ReceiverTracker与ReceiverSupervisorImpl转发给BlockGenerator(继承自RateLimiter).
    在这里插入图片描述
    反压执行过程主要分为两部分:BatchCompleted事件触发 以及 BatchCompleted事件处理
  • BatchCompleted事件触发:

    • 每当一个Job执行完成时会向eventLoop发送一个JobCompleted事件
    • EventLoop事件处理器接收到JobCompleted事件之后将调用handleJobCompletion来处理Job完成事件
    • handleJobCompletion使用Job执行的信息(delay)创建StreamingListenerBatchCompleted事件并通过StreamingListenerBus向监听器发送
  • BatchCompleted事件处理:

    • SteamingListenerBus将时间转交给具体的SteamingListener,即RateController
    • Driver RateController接收到BatchCompleted事件之后调用RateController#onBatchCompleted进行处理
    • Driver RateController#onBatchCompleted从Job的完成信息中抽取任务的执行延迟和调度延迟,然后不断地调用RateEstimator计算新的rate
    • Driver 通过ReceiverTracker将新生成的rate包装成UpdateReceiverRateLimit事件转交给Driver ReceiverTrackerEndpoint
    • Driver ReceiverTrackerEndpoint接收到消息后,查询注册时的ReceiverSupvisorImpl,再将rate包装成UpdateLimit发送到Executor endpoint
    • Executor endpoint接收到消息后,使用updateRate更新BlockGenerators,同时计算出一个固定的令牌间隔

以上两个过程便将反压机制中最重要的rate调整完成。

当Executor Receiver开始接收数据的时候,需要获取令牌才能够将数据存放入currentBuffer,否则的话将被阻塞,进而阻塞Receiver从数据源拉取数据。

其中令牌投放采用令牌桶机制(参考下图),固定大小的令牌桶根据rate源源不断地产生令牌,如果令牌不消耗,或消耗的速度小于产生的速度,令牌就会不断的增多,直到把桶撑满。后面再产生的令牌就会被丢弃,最后桶中可以保存的最大令牌数永远不会超过桶的大小。
在这里插入图片描述
当进行某操作时需要令牌时会从令牌桶中取出相应的令牌数,如果获取到则继续操作,否则阻塞。用完之后不用放回。

对应到Streaming,数据流被Receiver接收后,按行解析后存入iterator中,通过supervisor.pushSingle()方法将接收的数据存入currentBuffer等待BlockGenerator定时将数据取走,包装成block. 在将数据存放入currentBuffer之时,要获取许可(令牌)。如果获取到许可就可以将数据存入buffer, 否则将被阻塞,进而阻塞Receiver从数据源拉取数据。

整个特性,主要由三大模块实现:

  • 速率控制 Driver-RateController;
  • 速率估算 Driver-RateEstimator
  • 速率更新 Executor-RateLimiter

2.4 RateController 速率控制

在这里插入图片描述
整个背压机制的核心就是Drvier端的RateContoller,它作为控制核心,继承自StreamingListener,用于处理BatchCompleted事件,监听Batch的完成情况,记录下它们的关键延迟,然后传递给computeAndPublish方法,遍历Executor并进行速率估算和更新。

2.5 RateEstimator 速率估算

在这里插入图片描述
PIDRateEstimator是目前RateEstimator的唯一官方实现,基本上也没谁去重新实现一个,因为确实好用。PID(Proportional Integral Derivative,比例积分差分控制算法)是工控领域中,经过多次的验证是一种非常有效的工业控制器算法。Spark Streaming将它引入,作为根据最新的Rate,以及比例(Proportional) 积分(Integral)微分(Derivative)这3个变量,来确定最新的Rate,实现简洁明了,也非常好理解。

2.6 RateLimiter 速率更新

在这里插入图片描述
计算完新Rate,就该把它发布出去了。

RateController通过ReceiverTracker,利用RPC消息,发布Rate到Receiver所在的Executor节点上,该节点上的ReceiverSupervisorImpl会接收消息,并把速率更新到BlockGenerator上,从而以控制每个批次的数据生成。

仔细阅读这两个类的代码,可以发现它们充分利用了Scala的特性和高性能网络通信库,非常的简洁,一点都不拖泥带水。无论是发送端的UpdateRateLimit的case class消息类构建,还是接收端的receive的偏函数特性,都充分的体现了Scala的代码之美。

2.7 源码分析

2.7.1 RateController的注册

JobScheduler启动时会抽取在DStreamGraph中注册的所有InputDstream中的RateController,并向ListenerBus注册监听,此部分代码如下:

def start(): Unit = synchronized {
  if (eventLoop != null) return // scheduler has already been started

  logDebug("Starting JobScheduler")
  eventLoop = new EventLoop[JobSchedulerEvent]("JobScheduler") {
    override protected def onReceive(event: JobSchedulerEvent): Unit = processEvent(event)

    override protected def onError(e: Throwable): Unit = reportError("Error in job scheduler", e)
  }
  eventLoop.start()

  // attach rate controllers of input streams to receive batch completion updates
  for {
    inputDStream <- ssc.graph.getInputStreams
    rateController <- inputDStream.rateController
  } ssc.addStreamingListener(rateController)

  listenerBus.start()
  receiverTracker = new ReceiverTracker(ssc)
  inputInfoTracker = new InputInfoTracker(ssc)
  receiverTracker.start()
  jobGenerator.start()
  logInfo("Started JobScheduler")
}

2.7.2 BatchCompleted触发过程

对BatchedCompleted的分析,应该从JobGenerator入手,因为BatchedCompleted是批次处理结束的标志,也就是JobGenerator产生的作业执行完成时触发的,因此进行作业执行分析。

  • generateJobs
    Streaming 应用中JobGenerator每个Batch Interval都会为应用中的每个Output Stream建立一个Job, 该批次中的所有Job组成一个Job Set.使用JobScheduler的submitJobSet进行批量Job提交。此部分代码结构如下所示
/** Generate jobs and perform checkpoint for the given `time`.  */
private def generateJobs(time: Time) {
  // Set the SparkEnv in this thread, so that job generation code can access the environment
  // Example: BlockRDDs are created in this thread, and it needs to access BlockManager
  // Update: This is probably redundant after threadlocal stuff in SparkEnv has been removed.
  SparkEnv.set(ssc.env)
 
  // Checkpoint all RDDs marked for checkpointing to ensure their lineages are
  // truncated periodically. Otherwise, we may run into stack overflows (SPARK-6847).
  ssc.sparkContext.setLocalProperty(RDD.CHECKPOINT_ALL_MARKED_ANCESTORS, "true")
  Try {
    jobScheduler.receiverTracker.allocateBlocksToBatch(time) // allocate received blocks to batch
    graph.generateJobs(time) // generate jobs using allocated block
  } match {
    case Success(jobs) =>
      val streamIdToInputInfos = jobScheduler.inputInfoTracker.getInfo(time)
      jobScheduler.submitJobSet(JobSet(time, jobs, streamIdToInputInfos))
    case Failure(e) =>
      jobScheduler.reportError("Error generating jobs for time " + time, e)
  }
  eventLoop.post(DoCheckpoint(time, clearCheckpointDataLater = false))
}
  • sumitJobSet
    sumitJobSet会创建固定数量的后台线程(具体由spark.streaming.concurrentJobs指定),去处理Job Set中的Job. 具体实现逻辑为:
    def submitJobSet(jobSet: JobSet) {
      if (jobSet.jobs.isEmpty) {
        logInfo("No jobs added for time " + jobSet.time)
      } else {
        listenerBus.post(StreamingListenerBatchSubmitted(jobSet.toBatchInfo))
        jobSets.put(jobSet.time, jobSet)
        jobSet.jobs.foreach(job => jobExecutor.execute(new JobHandler(job)))
        logInfo("Added jobs for time " + jobSet.time)
      }
    }
    
  • JobHandler
    JobHandler用于执行Job及处理Job执行结果信息。当Job执行完成时会产生JobCompleted事件. JobHandler的具体逻辑如下面代码所示:
private class JobHandler(job: Job) extends Runnable with Logging {
    import JobScheduler._
 
    def run() {
      try {
        val formattedTime = UIUtils.formatBatchTime(
          job.time.milliseconds, ssc.graph.batchDuration.milliseconds, showYYYYMMSS = false)
        val batchUrl = s"/streaming/batch/?id=${job.time.milliseconds}"
        val batchLinkText = s"[output operation ${job.outputOpId}, batch time ${formattedTime}]"
 
        ssc.sc.setJobDescription(
          s"""Streaming job from <a href="$batchUrl">$batchLinkText</a>""")
        ssc.sc.setLocalProperty(BATCH_TIME_PROPERTY_KEY, job.time.milliseconds.toString)
        ssc.sc.setLocalProperty(OUTPUT_OP_ID_PROPERTY_KEY, job.outputOpId.toString)
        // Checkpoint all RDDs marked for checkpointing to ensure their lineages are
        // truncated periodically. Otherwise, we may run into stack overflows (SPARK-6847).
        ssc.sparkContext.setLocalProperty(RDD.CHECKPOINT_ALL_MARKED_ANCESTORS, "true")
 
        // We need to assign `eventLoop` to a temp variable. Otherwise, because
        // `JobScheduler.stop(false)` may set `eventLoop` to null when this method is running, then
        // it's possible that when `post` is called, `eventLoop` happens to null.
        var _eventLoop = eventLoop
        if (_eventLoop != null) {
          _eventLoop.post(JobStarted(job, clock.getTimeMillis()))
          // 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.
          PairRDDFunctions.disableOutputSpecValidation.withValue(true) {
            job.run()
          }
          _eventLoop = eventLoop
          if (_eventLoop != null) {
            _eventLoop.post(JobCompleted(job, clock.getTimeMillis()))
          }
        } else {
          // JobScheduler has been stopped.
        }
      } finally {
        ssc.sc.setLocalProperty(JobScheduler.BATCH_TIME_PROPERTY_KEY, null)
        ssc.sc.setLocalProperty(JobScheduler.OUTPUT_OP_ID_PROPERTY_KEY, null)
      }
    }
  }
}
  • handleJobCompletion
    当Job执行完成时,向eventLoop发送JobCompleted事件。

    EventLoop事件处理器接到JobCompleted事件后将调用handleJobCompletion来处理Job完成事件。handleJobCompletion使用Job执行信息创建StreamingListenerBatchCompleted事件并通过StreamingListenerBus向监听器发送。实现如下:

private def handleJobCompletion(job: Job, completedTime: Long) {
  val jobSet = jobSets.get(job.time)
  jobSet.handleJobCompletion(job)
  job.setEndTime(completedTime)
  listenerBus.post(StreamingListenerOutputOperationCompleted(job.toOutputOperationInfo))
  logInfo("Finished job " + job.id + " from job set of time " + jobSet.time)
  if (jobSet.hasCompleted) {
    jobSets.remove(jobSet.time)
    jobGenerator.onBatchCompletion(jobSet.time)
    logInfo("Total delay: %.3f s for time %s (execution: %.3f s)".format(
      jobSet.totalDelay / 1000.0, jobSet.time.toString,
      jobSet.processingDelay / 1000.0
    ))
    // 关键代码
    listenerBus.post(StreamingListenerBatchCompleted(jobSet.toBatchInfo))
  }
  job.result match {
    case Failure(e) =>
      reportError("Error running job " + job, e)
    case _ =>
  }
}

2.7.3 BatchCompleted事件处理过程

  • RateController.onBatchCompleted
    该方法实现自StreamingListener,当处理完一个batch的若干job时调用。

    StreamingListenerBus将事件转交给具体的StreamingListener,因此BatchCompleted将交由RateController进行处理 , RateController接到BatchCompleted事件后将调用onBatchCompleted对事件进行处理。

    **
     * A StreamingListener that receives batch completion updates, and maintains
     * an estimate of the speed at which this stream should ingest messages,
     * given an estimate computation from a `RateEstimator`
     */
    private[streaming] abstract class RateController(val streamUID: Int, rateEstimator: RateEstimator)
    extends StreamingListener with Serializable {
      
      // 本方法从完成的任务中抽取任务的执行延迟和调度延迟
      // 然后用这两个参数用RateEstimator(目前存在唯一实现PIDRateEstimator,
      // proportional-integral-derivative (PID) controller, PID控制器)估算出新的rate并发布。
      override def onBatchCompleted(batchCompleted: StreamingListenerBatchCompleted) {
        // A map of input stream id to its input info
        val elements = batchCompleted.batchInfo.streamIdToInputInfo
        for {
          processingEnd <- batchCompleted.batchInfo.processingEndTime
          // 任务的执行延迟
          workDelay <- batchCompleted.batchInfo.processingDelay
          // 任务和调度延迟
          waitDelay <- batchCompleted.batchInfo.schedulingDelay
          elems <- elements.get(streamUID).map(_.numRecords)
          // 用这两个参数用RateEstimator(目前存在唯一实现PIDRateEstimator,
      	  // proportional-integral-derivative (PID) controller, PID控制器)估算出新的rate并发布。
        } computeAndPublish(processingEnd, elems, workDelay, waitDelay)
      }
      
      /**
       * Compute the new rate limit and publish it asynchronously.
       */
      private def computeAndPublish(time: Long, elems: Long, workDelay: Long, waitDelay: Long): Unit =
        Future[Unit] {
          val newRate = rateEstimator.compute(time, elems, workDelay, waitDelay)
          newRate.foreach { s =>
            rateLimit.set(s.toLong)
            publish(getLatestRate())
          }
        }
        
      def getLatestRate(): Long = rateLimit.get()
    }  
    
  • publish
    由RateController的子类ReceiverRateController来定义。具体逻辑如下(ReceiverInputDStream中定义):

    /**
     * A RateController that sends the new rate to receivers, via the receiver tracker.
     */
    private[streaming] class ReceiverRateController(id: Int, estimator: RateEstimator)
        extends RateController(id, estimator) {
      // publish的功能为新生成的rate 借助ReceiverTracker进行转发。
      // ReceiverTracker将rate包装成UpdateReceiverRateLimit事交ReceiverTrackerEndpoint  
      override def publish(rate: Long): Unit =
        ssc.scheduler.receiverTracker.sendRateUpdate(id, rate)
    }
    
  • Rate发送到Executor
    ReceiverTrackerEndpoint接到消息后,其将会从receiverTrackingInfos列表中获取Receiver注册时使用的endpoint(实为ReceiverSupervisorImpl),再将rate包装成UpdateLimit发送至endpoint.

/** RpcEndpointRef for receiving messages from the ReceiverTracker in the driver */
private val endpoint = env.rpcEnv.setupEndpoint(
  "Receiver-" + streamId + "-" + System.currentTimeMillis(), new ThreadSafeRpcEndpoint {
    override val rpcEnv: RpcEnv = env.rpcEnv
 
    override def receive: PartialFunction[Any, Unit] = {
      case StopReceiver =>
        logInfo("Received stop signal")
        ReceiverSupervisorImpl.this.stop("Stopped by driver", None)
      case CleanupOldBlocks(threshTime) =>
        logDebug("Received delete old batch signal")
        cleanupOldBlocks(threshTime)
      case UpdateRateLimit(eps) =>
        logInfo(s"Received a new rate limit: $eps.")
        registeredBlockGenerators.asScala.foreach { bg =>
          bg.updateRate(eps)
        }
    }
  })
  • Executor更新Rate
    Executor接到信息后,使用updateRate更新BlockGenerators(RateLimiter子类),来计算出一个固定的令牌间隔。
/**
  * Set the rate limit to `newRate`. The new rate will not exceed the maximum rate configured by
  * {{{spark.streaming.receiver.maxRate}}}, even if `newRate` is higher than that.
  *
  * @param newRate A new rate in events per second. It has no effect if it's 0 or negative.
  */
 private[receiver] def updateRate(newRate: Long): Unit =
   if (newRate > 0) {
     if (maxRateLimit > 0) {
       rateLimiter.setRate(newRate.min(maxRateLimit))
     } else {
       rateLimiter.setRate(newRate)
     }
   }
  • setRate的实现 如下:
public final void setRate(double permitsPerSecond) {
  Preconditions.checkArgument(permitsPerSecond > 0.0
      && !Double.isNaN(permitsPerSecond), "rate must be positive");
  synchronized (mutex) {
    resync(readSafeMicros());
    double stableIntervalMicros = TimeUnit.SECONDS.toMicros(1L) / permitsPerSecond;  //固定间隔
    this.stableIntervalMicros = stableIntervalMicros;
    doSetRate(permitsPerSecond, stableIntervalMicros);
  }
}

2.7.4 流量控制

当Receiver开始接收数据时,会通过supervisor.pushSingle()方法将接收的数据存入currentBuffer等待BlockGenerator定时将数据取走,包装成block.

在将数据存放入currentBuffer之时,要获取许可(令牌)。

如果获取到许可就可以将数据存入buffer, 否则将被阻塞,进而阻塞Receiver从数据源拉取数据。

/**
 * Push a single data item into the buffer.
 */
def addData(data: Any): Unit = {
  if (state == Active) {
    waitToPush()  //获取令牌
    synchronized {
      if (state == Active) {
        currentBuffer += data
      } else {
        throw new SparkException(
          "Cannot add data as BlockGenerator has not been started or has been stopped")
      }
    }
  } else {
    throw new SparkException(
      "Cannot add data as BlockGenerator has not been started or has been stopped")
  }
}

3 Flink背压

3.1 Flink的设计思想

在这里插入图片描述
同Spark Steaming类似,Flink的基本元素是Stream和Transformations,每个Streaming Dataflow由很多Stream和Operator组成,在这些Stream里,Source是数据源,Sink是数据池。最终会组成一个DAG。

3.2 跨节点传输

Flink 在运行时主要由 operators 和 streams 两大组件构成。每个 operator 会消费中间态的流,并在流上进行转换,然后生成新的流。

在 Flink 中,这些逻辑流就好比是分布式阻塞队列,而队列容量是通过缓冲池(LocalBufferPool)来实现的。每个被生产和被消费的流都会被分配一个缓冲池。缓冲池管理着一组Buffer,Buffer在被消费后可以被回收循环利用。
在这里插入图片描述
上图展示的是两个task之间的数据传输:

  1. 记录"A"进入了Flink并且被Task 1处理(省略中间的一些反序列化、Netty接收过程)
  2. 记录被序列化后放入buffer中(Task 1 在输出端有一个相关联的 LocalBufferPool(称缓冲池1),如果缓冲池1中有空闲可用的 buffer 来序列化记录 “A”,我们就序列化并发送该 buffer)
  3. buffer被送到Task 2中, Task 2从这个buffer中读出记录(Task 2 在输入端也有一个相关联的 LocalBufferPool(称缓冲池2))

数据传输有两个场景:

  • 本地传输
    如果Task 1和Task 2在同一个worker,buffer可以直接传递给下一个Task。

    一旦Task 2消费了该buffer,那么就会被LocalBufferPool1回收。

    如果Task 2消费的速度比Task 1取buffer的速度小,导致LocalBufferPool1无可用的buffer,则Task1等待在可用的buffer上。最终导致Task1的降速。

  • 网络传输
    如果 Task 1 和 Task 2 运行在不同的 worker 节点上,那么 buffer 会在发送到网络(TCP Channel)后被回收。

    在接收端,会从 LocalBufferPool 中申请 buffer,然后拷贝网络中的数据到 buffer 中。如果没有可用的 buffer,会停止从 TCP 连接中读取数据。

    在输出端,通过 Netty 的水位机制来保证不往网络中写入太多数据。如果网络中的数据(Netty输出缓冲中的字节数)超过了高水位值,我们会等到其降到低水位值以下才继续写入数据。这保证了网络中不会有太多的数据。

    如果接收端停止消费网络中的数据(由于接收端缓冲池没有可用 buffer),网络中的缓冲数据就会堆积,那么发送端也会暂停发送。另外,这会使得发送端的缓冲池得不到回收,writer 阻塞在向 LocalBufferPool 请求 buffer,阻塞了 writer 往 ResultSubPartition 写数据。

    通过固定大小的缓冲池,保证了Flink有一套健壮的反压机制,使得Task生产数据的速度不会快于消费的速度。我们上面描述的这个方案可以从两个 Task 之间的数据传输自然地扩展到更复杂的 pipeline 中,保证反压机制可以扩散到整个 pipeline。

在这里插入图片描述
实际DAG被部署到执行集群时,还要考虑并行度的影响,假设并行度是4,同时,该集群有两个TaskManager(执行工作的节点,每个节点可以执行多个任务),假设TaskManager 1执行A.1,A.2,B.1和B.2,TaskManager 2执行A.3,A.4,B.3和B.4。于是只有不在同一台机器的子任务面临节点之间的传输,以A.1,A.2到B.3,B.4为例,如上图。

Flink 在运行时主要由 operators 和 streams 两大组件构成。每个 operator 会消费中间态的流,并在流上进行转换,然后生成新的流。在 Flink 中,这些逻辑流就好比是分布式阻塞队列,而队列容量是通过缓冲池(LocalBufferPool)来实现的。每个被生产和被消费的流都会被分配一个缓冲池。缓冲池管理着一组缓冲(Buffer),缓冲在被消费后可以被回收循环利用。
每个子任务都有自己的本地缓存池,收到的数据以及发出的数据,都会序列化之后,放入到缓冲池里

然后,两个TaskManager之间,只会建立一条物理链路(底层使用Netty通讯),所有子任务之间的通讯,都由这条链路承担。

  • Flink内部节点之间(比如JobManager和TaskManager)的通信是用Akka
    Akka:它是基于协程的,性能不容置疑;基于scala的偏函数,易用性也没有话说,但是它毕竟只是RPC通信。
  • 而Operator之间的数据传输是利用Netty
    Netty:相比更加基础一点,可以为不同的应用层通信协议(RPC,FTP,HTTP等)提供支持

3.3 Flink初代TaskManager级别背压

3.3.1 概述

flink1.5以前flink依靠tcp feedback实现网络流控,可参考Flink 原理与实现:如何处理反压问题
在这里插入图片描述
当任何一个子任务的发送缓存(不管是子任务自己的本地缓存,还是底层传输时Netty的发送缓存)耗尽时,发送方就会被阻塞,产生背压;

同样,任何任务接收数据时,如果本地缓存用完了,都会停止从底层Netty那里读取数据,这样很快上游的数据很快就会占满下游的底层接收缓存,从而背压到发送端,形成对上游所有的任务的背压。

3.3.2 自然背压

在这里插入图片描述

  1. 上游的task产生数据后,会先写在local buffer中,然后通知JM自己的数据已经好了;
    在何时通知JM?这里有一个设置,比如pipeline还是blocking,pipeline意味着上游哪怕产生一个数据,也会去通知,blocking则需要缓存的插槽存满了才会去通知,默认是pipeline。
  2. JM通知下游的Task去拉取数据
  3. 下游的Task去上游的Task拉取数据,形成链条。

虽然生产数据的是Task,但是一个TaskManager中的所有Task共享一个NetworkEnvironment。

下游的Task利用ResultPartitionManager主动去上游Task拉数据,底层利用的是Netty和TCP实现网络链路的传输。

那么,一直都在说Flink的背压是一种自然的方式,为什么是自然的了?

  • 从上面的图中下面的链路中可以看到,当下游的process逻辑比较慢,无法及时处理数据时,他自己的local buffer中的消息就不能及时被消费,进而导致netty无法把数据放入local buffer,进而netty也不会去socket上读取新到达的数据,进而在tcp机制中,tcp也不会从上游的socket去读取新的数据;
  • 上游的netty也是一样的逻辑,它无法发送数据,也就不能从上游的localbuffer中消费数据,所以上游的localbuffer可能就是满的,上游的operator或者process在处理数据之后进行collect.out的时候申请不到本地缓存,导致上游的process被阻塞。
  • 这样,在这个链路上,就实现了背压。
  • 如果还有相应的上游,则会一直反压上去,一直影响到source,导致source也放慢从外部消息源读取消息的速度。一旦瓶颈解除,网络链路畅通,则背压也会自然而然的解除。

以上就是Flink自然背压原因,不需要复杂逻辑。

3.3.3 Netty 水位机制

下方的代码是初始化 NettyServer 时配置的水位值参数:

// 默认高水位值为2个buffer大小, 当接收端消费速度跟不上,发送端会立即感知到
bootstrap.childOption(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, config.getMemorySegmentSize() + 1);
bootstrap.childOption(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 2 * config.getMemorySegmentSize());
  • 当输出缓冲中的字节数超过了高水位值时, 则 Channel.isWritable()会返回false。
  • 当输出缓存中的字节数又掉到了低水位值以下, 则 Channel.isWritable() 会重新返回true。

Flink 中发送数据的核心代码在 PartitionRequestQueue 中,该类是 server channel pipeline 的最后一层。发送数据关键代码如下所示:

private void writeAndFlushNextMessageIfPossible(final Channel channel) throws IOException {
  if (fatalError) {
    return;
  }

  Buffer buffer = null;

  try {
    // channel.isWritable() 配合 WRITE_BUFFER_LOW_WATER_MARK 
    // 和 WRITE_BUFFER_HIGH_WATER_MARK 实现发送端的流量控制
    if (channel.isWritable()) {
      // 注意: 一个while循环也就最多只发送一个BufferResponse, 连续发送BufferResponse是通过writeListener回调实现的
      while (true) {
        if (currentPartitionQueue == null && (currentPartitionQueue = queue.poll()) == null) {
          return;
        }

        buffer = currentPartitionQueue.getNextBuffer();

        if (buffer == null) {
          // 跳过这部分代码
          ...
        }
        else {
          // 构造一个response返回给客户端
          BufferResponse resp = new BufferResponse(buffer, currentPartitionQueue.getSequenceNumber(), currentPartitionQueue.getReceiverId());

          if (!buffer.isBuffer() &&
              EventSerializer.fromBuffer(buffer, getClass().getClassLoader()).getClass() == EndOfPartitionEvent.class) {
            // 跳过这部分代码。batch 模式中 subpartition 的数据准备就绪,通知下游消费者。
            ...
          }

          // 将该response发到netty channel, 当写成功后, 
          // 通过注册的writeListener又会回调进来, 从而不断地消费 queue 中的请求
          channel.writeAndFlush(resp).addListener(writeListener);

          return;
        }
      }
    }
  }
  catch (Throwable t) {
    if (buffer != null) {
      buffer.recycle();
    }

    throw new IOException(t.getMessage(), t);
  }
}

// 当水位值降下来后(channel 再次可写),会重新触发发送函数
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
  writeAndFlushNextMessageIfPossible(ctx.channel());
}

核心发送方法中如果channel不可写,则会跳过发送。当channel再次可写后,Netty 会调用该Handle的 channelWritabilityChanged 方法,从而重新触发发送函数。

3.3.4 图解

3.3.4.1 概述

在这里插入图片描述
这张图就体现了 Flink 在做网络传输的时候基本的数据的流向,发送端在发送网络数据前要经历自己内部的一个流程,会有一个自己的 Network Buffer,在底层用 Netty 去做通信,Netty 这一层又有属于自己的 ChannelOutbound Buffer,因为最终是要通过 Socket 做网络请求的发送,所以在 Socket 也有自己的 Send Buffer,同样在接收端也有对应的三级 Buffer。学过计算机网络的时候我们应该了解到,TCP 是自带流量控制的。

实际上 Flink (before V1.5)就是通过 TCP 的流控机制来实现 feedback 的。

3.3.4.2 TCP 流控机制

根据下图我们来简单的回顾一下 TCP 包的格式结构。首先,他有 Sequence number 这样一个机制给每个数据包做一个编号,还有 ACK number 这样一个机制来确保 TCP 的数据传输是可靠的,除此之外还有一个很重要的部分就是 Window Size,接收端在回复消息的时候会通过 Window Size 告诉发送端还可以发送多少数据。
在这里插入图片描述
TCP的反压,是通过callback实现的:

  1. 当socket发送数据去ReceiveBuffer后,Receiver会发送ACK,内容为目前Receiver端的Buffer还有多少剩余空间。
  2. Send端会根据Receiver端反馈的Buffer剩余空间量来控制发送速率。

下图中黄色表示可用的
在这里插入图片描述
TCP 的流控就是基于滑动窗口的机制,现在我们有一个 Socket 的发送端和一个 Socket 的接收端,目前我们的发送端的速率是我们接收端的 3 倍,这样会发生什么样的一个情况呢?

假定初始的时候我们发送的 window 大小是 3,然后我们接收端的 window 大小是固定的 5。
在这里插入图片描述
首先,发送端会一次性发 3 个 packets,将 1,2,3 发送给接收端,接收端接收到后会将这 3 个 packets 放到 Buffer。
在这里插入图片描述
接收端一次消费 1 个 packet,这时候 1 就已经被消费了,然后我们看到接收端的滑动窗口会往前滑动一格,这时候 2,3 还在 Buffer 当中 而 4,5,6 是空出来的,所以接收端会给发送端发送 ACK = 4 ,代表发送端可以从 4 开始发送,同时会将 window 设置为 3 (表示ReceiverBuffer 的大小 5 减去已经存下的 2 和 3还剩3个位置),发送端接收到回应后也会将他的滑动窗口向前移动到 4,5,6。
在这里插入图片描述
这时候发送端将 4,5,6 发送,接收端也能成功的接收到 Buffer 中去。
在这里插入图片描述
到这一阶段后,接收端就消费到 2 了,同样他的窗口也会向前滑动一个,这时候他的 Buffer 就只剩一个了,于是向发送端发送 ACK = 7、window = 1。发送端收到之后滑动窗口也向前移,但是这个时候就不能移动 3 格了,虽然发送端的速度允许发 3 个 packets 但是 window 传值已经告知只能接收一个,所以他的滑动窗口就只能往前移一格到 7 ,这样就达到了限流的效果,发送端的发送速度从 3 降到 1。
在这里插入图片描述
在这里插入图片描述
我们再看一下这种情况,这时候发送端将 7 发送后,接收端接收到,但是由于接收端的消费出现问题,一直没有从 Buffer 中去取,这时候接收端向发送端发送 ACK = 8、window = 0 ,由于这个时候 window = 0,发送端是不能发送任何数据,也就会使发送端的发送速度降为 0。这个时候发送端不发送任何数据了,接收端也不进行任何的反馈了,那么如何知道消费端又开始消费了呢?
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
TCP 当中有一个 ZeroWindowProbe 的机制,发送端会定期的发送 1 个字节的探测消息,这时候接收端就会把 window 的大小进行反馈。当接收端的消费恢复了之后,接收到探测消息就可以将 window 反馈给发送端端了从而恢复整个流程。TCP 就是通过这样一个滑动窗口的机制实现 feedback。

3.3.4.3 WindowWordCount例子
  • 代码
    在这里插入图片描述
    大体的逻辑就是从 Socket 里去接收数据,每 5s 去进行一次 WordCount,将这个代码提交后就进入到了编译阶段。

  • 编译阶段:由Client 做 StreamGraph 生成 JobGraph
    在这里插入图片描述
    这时候还没有向集群去提交任务,在 Client 端会将 StreamGraph 生成 JobGraph,JobGraph 就是作为向集群提交的最基本的单元。

    在生成 JobGrap 的时候会做一些优化,将一些没有 Shuffle 机制的节点进行合并(比如上图的Keyed Aggregation和Sink)。有了 JobGraph 后就会向集群进行提交,进入运行阶段。

  • 运行阶段:JobManager生成和调度 ExecutionGraph
    在这里插入图片描述
    JobGraph 提交到集群后会生成 ExecutionGraph ,这时候就已经具备基本的执行任务的雏形了,把每个任务拆解成了不同的 SubTask,上图 ExecutionGraph 中的Intermediate Result Partition就是用于发送数据的模块,最终会将 ExecutionGraph 交给 JobManager 的调度器,将整个 ExecutionGraph 调度起来。

    然后我们概念化(虚拟图)这样一张物理执行图(上图下方),可以看到每个 Task 在接收数据时都会通过这样一个InputGate可以认为是负责接收数据的,再往前有这样一个ResultPartition负责发送数据。在 ResultPartition 又会去做分区跟下游的 Task 保持一致,就形成了 ResultSubPartition 和 InputChannel 的对应关系。这就是从逻辑层上来看的网络传输的通道,基于这么一个概念我们可以将反压的问题进行拆解。

3.3.4.4 问题拆解:反压传播两个阶段

我们可以看到,上游task向下游task传输数据的时候,有ResultPartition和InputGate两个组件。

其中RP用来发送数据,IG用来接收数据。
在这里插入图片描述
对应着上面的执行图,我们一共涉及 3 个 TaskManager,在每个 TaskManager 里面都有相应的 Task 在执行,还有负责接收数据的 InputGate,发送数据的 ResultPartition,这就是一个最基本的数据传输的通道。

在这时候假设最下游的 Task (Sink)出现了问题,处理速度降了下来这时候是如何将这个压力反向传播回去呢?这时候就分为两种情况:

  • 跨 TaskManager ,反压如何从 InputGate 传播到 ResultPartition
  • TaskManager 内,反压如何从 ResultPartition 传播到 InputGate
3.3.4.5 BufferPool

Flink内部使用了BufferPool来复用内存:

  • NetworkBufferPool
    TM级别,所有subtask共享。
  • LocalBufferPool
    subtask级别,所有Channel共享
    • 每个ResultPartition的所有ResultSubPartition共用一个LocalBufferPool
      ResultPartition类如下:
      /**
       * Registers a buffer pool with this result partition.
       *
       * <p>There is one pool for each result partition, which is shared by all its sub partitions.
       *
       * <p>The pool is registered with the partition *after* it as been constructed in order to conform
       * to the life-cycle of task registrations in the {@link TaskExecutor}.
       */
      @Override
      public void setup() throws IOException {
      	checkState(this.bufferPool == null, "Bug in result partition setup logic: Already registered buffer pool.");
      
      	BufferPool bufferPool = checkNotNull(bufferPoolFactory.apply(this));
      	checkArgument(bufferPool.getNumberOfRequiredMemorySegments() >= getNumberOfSubpartitions(),
      		"Bug in result partition setup logic: Buffer pool has not enough guaranteed buffers for this result partition.");
      
      	this.bufferPool = bufferPool;
      	partitionManager.registerResultPartition(this);
      }
      
    • 每个InputGate的所有InputChannel共用一个LocalBufferPool
3.3.4.6 跨TM的反压
  • 跨TM的数据传输
    在这里插入图片描述
    上图是跨TM的网络传输的流程。理解过Flink内存管理的同学都会知道,Flink会向off-heap内存申请一段固定的内存来作为NetWork BufferPool。然后RP和IG都会向LocalBufferPool申请内存资源,LocalBufferPool会向NetWorkBufferPool申请资源。

    当sink端数据处理不过来的时候,IG会不断向LocalBufferPool申请内存,导致LocalBufferPool会不断向NetWorkBufferPool申请内存。这样最终导致NetworkBufferPool的可用内存被申请完。

    需要注意的是,发送数据需要 ResultPartition,在每个 ResultPartition 里面会有分区个 ResultSubPartition。

    对于一个 TM 来说会有一个统一的 NetworkBufferPool被所有的 Task 共享,在初始化时会从 Off-heap Memory 中申请内存,申请到内存的后续内存管理就是同步 NetworkBufferPool 来进行的,不需要依赖 JVM GC 的机制去释放。有了 NetworkBufferPool 之后可以为每一个 ResultSubPartition 创建 Local BufferPool 。

    如上图左边的 TM 的 Record Writer 写了 <1,2>这个两个数据进来,因为 ResultSubPartition 初始化的时候为空,没有 Buffer 用来接收,就会向 Local BufferPool 申请内存,这时 Local BufferPool 也没有足够的内存于是将请求转到 Network BufferPool,最终将申请到的 Buffer 按原链路返还给 ResultSubPartition,<1,2> 这个两个数据就可以被写入了。

    之后会将 ResultSubPartition 的 Buffer内容拷贝到 Netty 的 Buffer 当中,并最终拷贝到 Socket 的 Buffer 将消息发送出去。

    最后接收端按照类似的机制去处理将消息消费掉。具体是TCP放到SocketBuffer,然后Netty从Off-heap的直接内存申请内存后拷贝到Netty Buffer,再由IG向LocalBufferPool->NetworfkBufferPool方向申请内存后拷贝到InputChannel Buffer,最后交由RecordReader进行读取、算子消费。

  • 跨TM的数据反压1:生产速度大于消费速度
    接下来模拟上下游处理速度不匹配的场景,发送端的速率为 2,接收端的速率为 1,看一下反压的过程
    在这里插入图片描述
    因为速度不匹配就会导致一段时间后 InputChannel 的 Buffer 被用尽,如上图。此时会向 Local BufferPool 申请新的 Buffer ,这时候可以看到 Local BufferPool 中的一块 Buffer 会被标记为 Used。

  • 跨TM的数据反压2:
    在这里插入图片描述
    发送端还在持续以不匹配的速度发送数据,然后就会导致 InputChannel 向 Local BufferPool 申请 Buffer 的时候发现没有可用的 Buffer 了(如上图两块都是Used),这时候就只能向 Network BufferPool 申请。注意,每个 Local BufferPool 都有最大的可用的 Buffer,防止一个 Local BufferPool 把 Network BufferPool 耗尽。

    此时 Network BufferPool 还有可用的 Buffer 可以申请。

  • 跨TM的数据反压3:IG能申请的Buffer已达到阈值
    在这里插入图片描述
    一段时间后,发现 Network BufferPool 也没有可用的 Buffer了,或是 Local BufferPool 的最大可用 Buffer 到了上限无法向 Network BufferPool 申请,此时就没有办法去读取新的数据了, Netty AutoRead 就会被禁掉,Netty 就不会再从 Socket 的 Buffer 中读取数据了,会造成Socket Buffer数据积压。

  • 跨TM的数据反压4:Receiver端SocketBuffer被占满
    在这里插入图片描述
    显然,再过不久 Socket 的 Buffer 也被用尽,这时Receiver端就会将 Window = 0 通过ACK发送给Sender端(这就是前文提到的 TCP 滑动窗口的机制),这会导致Sender端的 Socket 停止发送。

  • 跨TM的数据反压5:Sender端的 Socket 的 Buffer 也耗尽
    -
    很快发送端的 Socket 的 Buffer 也被用尽,Sender端的Netty 检测到 Socket 无法写了之后就会停止向 Socket 写数据。

  • 跨TM的数据反压6:Netty水位机制,阻塞ResultSubPartition写入NettyBuffer
    在这里插入图片描述
    Netty 停止写了之后,所有的数据就会阻塞在 Netty 的 Buffer 当中了,但是 Netty 的 Buffer 是无界的,可以通过 Netty 的水位机制中的 high watermark 控制他的上界。当超过了 high watermark,Netty 就会将其 channel 置为不可写状态。

    上游的ResultSubPartition每次往Netty写入数据的时候,都会通过 Netty的channel.isWritable()方法来判断Netty是否能被写入,当返回false的时候就会就会停止向 Netty 写数据并发生阻塞。

    比如我们设定Netty高水位值为2,此时Netty Buffer内有 3个元素,超过高水位,此时Netty的channel.isWritable()就会返回false,ResultSubPartition就不会再往Netty写入数据。

  • 跨TM的数据反压7:Sender端因ResultSubPartition写阻塞而不断向LocalBufferPool申请内存
    在这里插入图片描述
    因为Netty水位而导致ResultSubPartition写阻塞后,所有的压力都来到了 ResultSubPartition,和Receiver端一样他会不断的向LocalBufferPool请求内存,LocalBufferPool会向NetworkBufferPool请求内存。

  • 跨TM的数据反压8:Sender端因ResultSubPartition持续的写阻塞而最终导致Local BufferPool 和 Network BufferPool 可申请内存都用尽
    在这里插入图片描述
    如果下游处理数据的速度一直跟不上产生数据的速度,那么会最终导致Local BufferPool 和 Network BufferPool 可申请内存都用尽,最后整个 Operator 就会停止写数据,达到跨 TaskManager 的反压的目的。

  • 随后会继续往上级Task反压。

3.3.5.7 TM内的反压

了解了跨 TaskManager 反压过程后再来看 TaskManager 内反压过程就更好理解了,下面是TaskManager内部的反压过程,也就是RS到IG。其实和上面的类似。

下游的 TaskManager 反压导致本 TaskManager 的 ResultSubPartition 无法继续写入数据,于是 Record Writer 的写也被阻塞住了,因为 Operator 需要有输入才能有计算后的输出,输入跟输出都是在同一线程执行, Record Writer 阻塞了,Record Reader 也停止从 InputChannel 读数据,这时上游的 TaskManager 还在不断地发送数据,最终将这个 TaskManager 的 Buffer 耗尽。具体流程可以参考下图,这就是 TaskManager 内的反压过程。

  • TM内的数据反压1:RecordWriter阻塞,同一个线程的RecordReader也阻塞,导致IG数据也不会被读取
    在这里插入图片描述
    当RecordWriter写入被堵塞的时候,由于RecordWriter和RecordReader是在同一个线程内,此时会导致RecordReader也被阻塞。这个是就会重复上面的堵塞过程,导致IG也无法使用了。

  • TM内的数据反压2:IG数据一直未被读取,导致Netty无法再向IG放入数据
    在这里插入图片描述
    IG持续未被消费,由于Netty还在持续写入,所以会不断向LocalBufferPool和NetworkBufferPool申请内存,直到无法再申请。

  • TM内的数据反压3:Netty发现InputChannel满了,停止从Socket消费
    在这里插入图片描述

  • TM内的数据反压4:因Netty不消费,导致Socket被压满,从而继续往上级算子反压
    在这里插入图片描述

3.3.6 1.5以前自然背压的问题

在这里插入图片描述

  • 任务一个下游子任务的产生背压,都会影响整条TaskManager之间的链路,导致全链路所有子任务背压
    在一个 TaskManager 中可能要执行多个 Task,如果多个 Task 的数据最终都要传输到下游的同一个 TaskManager 就会复用同一个 Socket 进行传输,这个时候如果单个 Task 产生反压,就会导致复用的 Socket 阻塞,其余的 Task 也无法使用传输,checkpoint barrier 也无法发出导致下游执行 checkpoint 的延迟增大。
  • 流控实现链路太长
    依赖最底层的 TCP 去做流控,而且还有一层Netty,反压传播路径长,导致反压生效延迟比较大。
  • 由于依赖tcp本身流控,所以task任务触发反压机制,会直接阻塞tcp网络通信,造成无法正常通信,比如checkpoint barrier无法通过tcp发出

3.3.7 自然背压源码分析

  • task启动和注册
    task run方法是任务执行的相关逻辑代码,其中有一行
// 给这个Task注册网络相关的栈
network.registerTask(this);

public void registerTask(Task task) throws IOException {
	final ResultPartition[] producedPartitions = task.getProducedPartitions();

	synchronized (lock) {
		if (isShutdown) {
			throw new IllegalStateException("NetworkEnvironment is shut down");
		}

		for (final ResultPartition partition : producedPartitions) {
			setupPartition(partition);//注册输出
		}

		// Setup the buffer pool for each buffer reader
		final SingleInputGate[] inputGates = task.getAllInputGates();
		for (SingleInputGate gate : inputGates) {
			setupInputGate(gate);//注册输入
		}
	}
}
  • task输出类型为ResultPartition,代表单个任务生成数据的结果分区
  • 输入类型为SingleInputGate(InputGate的子类),看下InputGate的类说明,画了一个很简单明了的图, operation到operation,数据处理是就由InputGate读入。
    *                            Intermediate result
    *               +-----------------------------------------+
    *               |                      +----------------+ |              +-----------------------+
    * +-------+     | +-------------+  +=> | Subpartition 1 | | <=======+=== | Input Gate | Reduce 1 |
    * | Map 1 | ==> | | Partition 1 | =|   +----------------+ |         |    +-----------------------+
    * +-------+     | +-------------+  +=> | Subpartition 2 | | <==+    |
    *               |                      +----------------+ |    |    | Subpartition request
    *               |                                         |    |    |
    *               |                      +----------------+ |    |    |
    * +-------+     | +-------------+  +=> | Subpartition 1 | | <==+====+
    * | Map 2 | ==> | | Partition 2 | =|   +----------------+ |    |         +-----------------------+
    * +-------+     | +-------------+  +=> | Subpartition 2 | | <==+======== | Input Gate | Reduce 2 |
    *               |                      +----------------+ |              +-----------------------+
    *               +-----------------------------------------+
    
  • 输入和输出都会注册一个LocalBufferPool,LocalBufferPool是由NetworkBufferPool来管理的,所有的Task共享一个NetworkBufferPool
  • 每个LocalBufferPool中包含一定数量的MemorySegment(内存块),
    • ResultPartition注册了子partitions个数个MemorySegment
      //ResultPartition注册bufferpool
      bufferPool = networkBufferPool.createBufferPool(partition.getNumberOfSubpartitions(),
      				maxNumberOfMemorySegments,
      				partition.getPartitionType().hasBackPressure() ? Optional.empty() : Optional.of(partition));
      
      partition.registerBufferPool(bufferPool);
      
      
    • InputGate注册了NumberOfInputChannels * networkBuffersPerChannel个MemorySegment
      //InputGate注册bufferpool
      maxNumberOfMemorySegments = gate.getConsumedPartitionType().isBounded() ?
      					gate.getNumberOfInputChannels() * networkBuffersPerChannel +
      						extraNetworkBuffersPerGate : Integer.MAX_VALUE;
      
      bufferPool = networkBufferPool.createBufferPool(gate.getNumberOfInputChannels(),
      maxNumberOfMemorySegments);
      gate.setBufferPool(bufferPool);
      	```
      

如下图所示展示了 Flink 在网络传输场景下的内存管理。网络上传输的数据会写到 Task 的 InputGate(IG) 中,经过 Task 的处理后,再由 Task 写到 ResultPartition(RS) 中。每个 Task 都包括了输入和输出,输入和输出的数据存在 Buffer 中(都是字节数据)。BufferMemorySegment 的包装类。

  • 在Task执行的过程中,InputChannel会从Bufferpoll申请MemorySegment用于数据的存放处理。如果无法从NetworkBufferPool申请到MemorySegment,代表没有可用的MemorySegment了,这时候Task的InputChannel会阻塞从而停止读取数据,上游也会相应的停止发送数据,任务进入反压状态。
  • 同理,ResultPartition写入是也要向NetworkBufferPool申请MemorySegment,申请不到会阻塞达到暂停写入的效果。
    在这里插入图片描述
  1. TaskManager(TM)在启动时,会先初始化NetworkEnvironment对象,TM 中所有与网络相关的东西都由该类来管理(如 Netty 连接),其中就包括NetworkBufferPool。

    根据配置,Flink 会在 NetworkBufferPool 中生成一定数量(默认2048)的内存块 MemorySegment,内存块的总数量就代表了网络传输中所有可用的内存。

    NetworkEnvironment 和 NetworkBufferPool 是 Task 之间共享的,每个 TM 只会实例化一个。

  2. Task 线程启动时,会向 NetworkEnvironment 注册,NetworkEnvironment 会为 Task 的 InputGate(IG)和 ResultPartition(RP) 分别创建一个 LocalBufferPool(缓冲池)并设置可申请的 MemorySegment(内存块)数量。

    IG 对应的缓冲池初始的内存块数量与 IG 中 InputChannel 数量一致,RP 对应的缓冲池初始的内存块数量与 RP 中的 ResultSubpartition 数量一致。

    不过,每当创建或销毁缓冲池时,NetworkBufferPool 会计算剩余空闲的内存块数量,并平均分配给已创建的缓冲池。注意,这个过程只是指定了缓冲池所能使用的内存块数量,并没有真正分配内存块,只有当需要时才分配。

    为什么要动态地为缓冲池扩容呢?因为内存越多,意味着系统可以更轻松地应对瞬时压力(如GC),不会频繁地进入反压状态,所以我们要利用起那部分闲置的内存块。

  3. 在 Task 线程执行过程中,当 Netty 接收端收到数据时,为了将 Netty 中的数据拷贝到 Task 中,InputChannel(实际是 RemoteInputChannel)会向其对应的LocalBufferPool申请内存块(上图中的①)。

    如果LocalBufferPool中也没有可用的内存块且已申请的数量还没到池子上限,则会向 NetworkBufferPool 申请内存块(上图中的②)并交给 InputChannel 填上数据(上图中的③和④)。

    如果缓冲池已申请的数量达到上限了呢?或者 NetworkBufferPool 也没有可用内存块了呢?这时候,Task 的 Netty Channel 会暂停读取,上游的发送端会立即响应停止发送,拓扑会进入反压状态。

    同样,当 Task 线程写数据到 ResultPartition 时也会向缓冲池请求内存块。如果没有可用内存块时,也会阻塞在请求内存块的地方,达到暂停写入的目的。

  4. 当一个内存块被消费完成之后(在输入端是指内存块中的字节被反序列化成对象了,在输出端是指内存块中的字节写入到 Netty Channel 了),会调用 Buffer.recycle() 方法,会将内存块还给 LocalBufferPool (上图中的⑤)。

    如果LocalBufferPool中当前申请的数量超过了池子容量(由于上文提到的动态容量,由于新注册的 Task 导致该池子容量变小),则LocalBufferPool会将该内存块回收给 NetworkBufferPool(上图中的⑥)。如果没超过池子容量,则会继续留在池子中,减少反复申请的开销。

当单个Task执行完成时,在finally方法会进行回收资源。此时会将MemorySegment还给 LocalBufferPool,如果LocalBufferPool的个数超过了NetworkBufferPool最大值,则回收LocalBufferPool,否则保留以减少重复申请开销。

// free the network resources
network.unregisterTask(this);

// free memory resources
if (invokable != null) {
	memoryManager.releaseAll(invokable);
}

综上:Flink不需要特殊机制处理反压问题,数据的传输机制相当于反压的实现,如果碰到类似的问题,去排查整个任务pipline中最慢的组件就能解决相应的问题了。

3.4 基于Credit - based Flow Control的任务级别背压

3.4.1 概述

flink1.5及以后flink在inputchannel层实现了tcp的反压机制,避免在tcp层阻塞
在这里插入图片描述
为了解决上节的单任务背压影响全链路的问题,在Flink 1.5之后,引入了Credit-based Flow Control,基于信用点的流量控制,这种方法,首先把每个子任务的本地缓存分为两个部分:

  • 独占缓存(Exclusive Buffers)
    input channel独占的缓存。接收方将独占缓存的大小作为信用点发给数据发送方,发送方会按照不同的子任务分别记录信用点,并发送尽可能多数据给接收方,发送后则降低对应信用点的大小;

    当信用点为0时,则不再发送,起到背压的作用。

  • 浮动缓存(Floating Buffers)
    在发送数据的同时,发送方还会把队列中暂存排队的数据量,发给接收方。

    接收方收到后,根据本地缓存的大小,决定是否去浮动缓存里请求更多的缓存来加速队列的处理,起到动态控制流量的作用。整个过程参考上图。

通过这样的设计,就实现了任务级别的背压:任意一个任务产生背压,只会影响这个任务,并不会对TaskManger上的其它任务造成影响。

3.4.2 图解

credit的反压机制简单的理解起来就是在 Flink 层面实现类似 TCP 流控的反压机制,以解决传统自然反压的弊端,Credit 可以类比为 TCP 的 Window 机制。

  • Credit-based 反压过程1:Sender发送backlog size告知发送大小,Receiver返回 Credit告知接收Buffer大小
    在这里插入图片描述
    如上图所示在 Flink 层面实现反压机制,就是每一次 ResultSubPartition 向 InputChannel 发送消息的时候都会发送一个 backlog size告诉下游发送端准备要发送多少消息,下游就会去计算有多少的 Buffer 去接收消息。

    Receiver端算完之后,如果有充足的 Buffer 就会返还给上游一个 Credit告知他可以发送消息(图上两个 ResultSubPartition 和 InputChannel 之间是虚线是因为最终还是要通过 Netty 和 Socket 去通信),下面我们看一个具体示例。

  • Credit-based 反压过程2
    在这里插入图片描述
    假设我们上下游的速度不匹配:上游发送速率为 2,下游接收速率为 1。

    可以看到图上在 ResultSubPartition 中累积了两条消息,10 和 11,此时发送端要发送的 backlog 就为 2,于是将要发送的数据 <8,9>backlog = 2 一同发送给下游。

    下游收到了之后就会去计算是否有 2 个 Buffer 去接收,这个时候发现backlog > credit,即InputChannel 中已经不足了,这时就会从 Local BufferPool 和 Network BufferPool 申请,好在这个时候 Buffer 还是可以申请到的。

  • Credit-based 反压过程3
    在这里插入图片描述
    长此以往,由于上游的发送速率始终大于下游的接受速率,下游的 TM 的 Buffer 最终会到达申请上限,此时就会向上游返回 Credit = 0表示没有内存缓存了。

    那么RS接收到Credit = 0的时候,就不会继续往netty写数据了。这会导致上游 TaskManager ResultSubPartition 的 Buffer 也很快耗尽,达到反压的效果。

    这样socket就不会堵塞了,同时生效延迟也降低了。同时RP也会不断去探测IG是否有空余的空间。

  • Credit-based 探测机制
    当消费端又有可用Buffer后,会有探测机制告知Sender恢复发送数据。

3.4.3 源码

3.4.3.1 发送端

在这里插入图片描述
可以看出,StreamTask算子处理完数据后,会调用RecordWrite将数据写到相应的ResultPartition中,每个ResultPartition会被拆分成一到多个ResultSubpartition,数据实际上是写到每个ResultSubpartition的buffers里。

下图为RecordWriter代码:
在这里插入图片描述
关键的方法requestNewBufferBuilder会到Task的LocalBufferPool请求内存, 没有就会阻塞。

Flink就是通过检测每个任务线程的栈深度来实现背压检测的,如果背压了,就会出现很深的栈深度,因为要在这个方法上等待内存释放。

下图为RecordSubpartition代码:
在这里插入图片描述
CreditBasedSequenceNumberingViewReader是TaskManager对应的Netty的通讯服务中的一个部分,当调用ResultSubpartition#flush时,实际上调用的是CreditBasedSequenceNumberingViewReader#notifyDataAvailable方法,从而通知Netty服务端的Pipeline醒来进行数据读取。

下图为发送端NettyServer的PartitionRequestQueue源码:
在这里插入图片描述
将Reader加入到准备就绪状态,在这之前会先判断reader是否可用,其实是判断Credit够不够,不够就暂时无法发出;

下图为发送端NettyServer的PartitionRequestQueue.CreditBasedSequenceNumberingViewReader源码:
在这里插入图片描述
上图为判断子任务Credit够不够;

3.4.3.2 接收端

在这里插入图片描述
接收端只要及时将Credit信息发送给发送端,发送端就能根据Credit数量尽快开始数据发送。

从代码结构上,和发送端相反,接收端是InputGate间接的监听Netty的读取事件:当CreditBasedPartitionRequestClientHandler正常读取出数据并写入InputChannel后,通知CheckpointBarrierHandle来读取。
在这里插入图片描述
只要对端可写的时候,马上尽快把Credit发送过去。

事实上,作为数据的接收方,更多是接收来自发送方的大量数据,发往发送方的Credit相对少很多,所以一般一有Credit可用,就能马上发送给发送方,保证实时性。

3.4.4 Credit反压总结

  • 优点
    • 反压链路长度减短,延迟降低
      在 ResultSubPartition 层就能感知到反压,不用通过 Socket 和 Netty 一层层地向上反馈,降低了反压生效的延迟。
    • 同时也不会将 Socket 阻塞
      解决了由于仅一个 Task 反压就导致 TM之间的 Socket 阻塞带来的一些列问题(如CheckpointBarrier无法传递等)。

3.5 Flink反压总结

  • 网络流控是为了在上下游速度不匹配的情况下,防止下游出现过载

  • 网络流控有静态限速和动态反压两种手段

  • Flink 1.5 之前是基于 TCP 流控 + bounded buffer 实现反压

  • Flink 1.5 之后实现了自己托管的 credit – based 流控机制,在应用层模拟 TCP 的流控机制

  • 思考:有了基于Credit的动态反压,那么传统静态自然反压是不是就没有用了?
    在这里插入图片描述
    答案当然不是,实际上动态反压不是万能的,我们流计算的结果最终是要输出到一个外部的存储(Storage),外部数据存储到 Sink 端时是不一定会触发反压,这个很大程度上取决于sink端数据存储在哪里。

  • 像 Kafka 这样是实现了限流限速的消息中间件可以通过协议将反压反馈给 Sink 端

  • 而如果数据最终落地在es中,那么由于前面数据量过大,会导致写es task端直接报错,整个任务就停止了,这个时候动态反压就没有了,那么静态的限制Source Consumer的量就很关键了,但是这个设置是在1.8版本里才有,比如ES使用的是BulkProcessor,可以设置flush时机。

    所以说动态反压并不能完全替代静态限速的,需要根据合适的场景去选择处理方案。

3.6 Flink反压分析及处理

3.6.1 概述

反压(backpressure)是实时计算应用开发中,特别是流式计算中,十分常见的问题。反压意味着数据管道中某个节点成为瓶颈,处理速率跟不上上游发送数据的速率,而需要对上游进行限速。

由于实时计算应用通常使用消息队列来进行生产端和消费端的解耦,消费端数据源是 pull-based 的,所以反压通常是从某个节点传导至数据源并降低数据源(比如 Kafka consumer)的摄入速率。

关于 Flink 的反压机制,简单来说,Flink 拓扑中每个节点(Task)间的数据都以阻塞队列的方式传输,下游来不及消费导致队列被占满后,上游的生产也会被阻塞,最终导致数据源的摄入被阻塞。而本文将着重结合官方博客分享笔者在实践中分析和处理 Flink 反压的经验。

3.6.2 反压的影响

反压并不会直接影响作业的可用性,它表明作业处于亚健康的状态,但有潜在的性能瓶颈并可能导致更大的数据处理延迟。

  • 对于一些对延迟要求不太高或者数据量比较小的应用来说,反压的影响可能并不明显;

  • 然而对于规模比较大的 Flink 作业来说反压可能会导致严重的问题。

    这是因为 Flink 的 checkpoint 机制,反压还会影响到两项指标:

    • checkpoint 时长
      因为 checkpoint barrier 是不会越过普通数据的,数据处理被阻塞也会导致 checkpoint barrier 流经整个数据管道的时长变长,因而 checkpoint 总体时间(End to End Duration)变长。
    • state 大小
      因为为保证 EOS(Exactly-Once-Semantics,准确一次),对于有两个以上输入管道的 Operator,checkpoint barrier 需要对齐(Alignment),接受到较快的输入管道的 barrier 后,它后面数据会被缓存起来但不处理,直到较慢的输入管道的 barrier 也到达,这些被缓存的数据会被放到state 里面,导致 checkpoint 变大。

这两个影响对于生产环境的作业来说是十分危险的,因为 checkpoint 是保证数据一致性的关键,checkpoint 时间变长有可能导致 checkpoint 超时失败,而 state变大同样可能拖慢 checkpoint 甚至导致 OOM (使用 Heap-based StateBackend)或者物理内存使用超出容器资源(使用 RocksDBStateBackend)的稳定性问题。

因此,我们在生产中要尽量避免出现反压的情况(顺带一提,为了缓解反压给 checkpoint 造成的压力,社区提出了 FLIP-76: Unaligned Checkpoints[4] 来解耦反压和 checkpoint)。

3.6.3 定位反压节点

3.6.3.1 概述

要解决反压首先要做的是定位到造成反压的节点,这主要有两种办法:

  • 通过 Flink Web UI 自带的反压监控面板;
    比较容易上手,适合简单分析
  • 通过 Flink Task Metrics。
    提供了更加丰富的信息,适合用于监控系统。

因为反压会向上游传导,这两种方式都要求我们从 Source 节点到 Sink 的逐一排查,直到找到造成反压的根源原因。下面分别介绍这两种办法。

3.6.3.2 Flink Web UI 反压监控面板

Flink Web UI 的反压监控提供了 SubTask 级别的反压监控,原理是通过周期性对 Task 线程的栈信息采样,得到线程被阻塞在请求 Buffer(意味着被下游队列阻塞)的频率来判断该节点是否处于反压状态。

Flink JM会周期性地调用·Task.isBackPressured()·方法,以从运行中的task中采样,监控反压指标。

默认每次采样会为每个task每50ms采样100次,可在WebUI观察该指标(60秒刷新一次,避免TM过载),比如0.01表示百分之1的样本发生反压。

该指标有几种情况:

OK: 0 <= Ratio <= 0.10
LOW: 0.10 < Ratio <= 0.5
HIGH: 0.5 < Ratio <= 1

目前判断是否产生背压是通过output buffer可用性,如果没有足够的buffer可用于输出说明该Taskb受到了来自下游的反压。

@Override
public boolean isBackPressured() {
	if (invokable == null || consumableNotifyingPartitionWriters.length == 0 || !isRunning()) {
		return false;
	}
	final CompletableFuture<?>[] outputFutures = new CompletableFuture[consumableNotifyingPartitionWriters.length];
	for (int i = 0; i < outputFutures.length; ++i) {
		outputFutures[i] = consumableNotifyingPartitionWriters[i].getAvailableFuture();
	}
	return !CompletableFuture.allOf(outputFutures).isDone();
}

如果处于反压状态,那么有两种可能性:

  • 节点内部反压
    该节点的发送速率跟不上它的产生数据速率。

    这一般会发生在一条输入多条输出的 Operator(比如 flatmap)。

    此时该节点则为反压的根源节点,它是从 Source Task 到 Sink Task 的第一个出现反压的节点。

  • 跨节点反压
    下游的节点接受速率较慢,通过反压机制限制了该节点的发送速率。

    此时需要继续排查下游节点。

值得注意的是,反压的根源节点并不一定会在反压面板体现出高反压,因为反压面板监控的是发送端,如果某个节点是性能瓶颈并不会导致它本身出现高反压,而是导致它的上游出现高反压。总体来看,如果我们找到第一个出现反压的节点,那么反压根源要么是就这个节点,要么是它紧接着的下游节点。

那么如果区分这两种情况呢?很遗憾只通过反压面板是无法直接判断的,我们还需要结合 Metrics 或者其他监控手段来定位。此外如果作业的节点数很多或者并行度很大,由于要采集所有 Task 的栈信息,反压面板的压力也会很大甚至不可用。

3.6.3.3 Task Metrics
3.6.3.3.1 概述

Flink 提供的 Task Metrics 是更好的反压监控手段,但也要求更加丰富的背景知识。

首先我们简单回顾下 Flink 1.5 以后的网路栈,熟悉的读者可以直接跳过。

  • TaskManager 传输数据时,不同的 TaskManager 上的两个 Subtask 间通常根据 key 的数量有多个 Channel,这些 Channel 会复用同一个 TaskManager 级别的 TCP 链接(Socket),并且共享接收端 Subtask 级别的 Buffer Pool。

  • 在接收端,每个 Channel 在初始阶段会被分配固定数量的 Exclusive Buffer,这些 Buffer 会被用于存储接受到的数据,交给 Operator 使用后再次被释放。

  • Channel 接收端空闲的 Buffer 数量称为 Credit,Credit 会被定时同步给发送端被后者用于决定发送多少个 Buffer 的数据。

  • 在流量较大时,Channel 的 Exclusive Buffer 可能会被写满,此时 Flink 会向 Buffer Pool 申请剩余的 Floating Buffer。这些 Floating Buffer 属于备用 Buffer,哪个 Channel 需要就去哪里。

    而在 Channel 发送端,一个 Subtask 所有的 Channel 会共享同一个 Buffer Pool,这边就没有区分 Exclusive Buffer 和 Floating Buffer。
    在这里插入图片描述

3.6.3.3.2 指标

我们在监控反压时会用到的 Metrics 主要和 Channel 接受端的 Buffer 使用率有关,最为有用的是以下几个 Metrics:

Metris描述
outPoolUsage发送端 Buffer 的使用率
inPoolUsage接收端 Buffer 的使用率
floatingBuffersUsage(1.9 以上)接收端 Floating Buffer 的使用率。
等于 floatingBuffersUsage 与 exclusiveBuffersUsage 的总和
exclusiveBuffersUsage (1.9 以上)接收端 Exclusive Buffer 的使用率

在这里插入图片描述
在这里插入图片描述

3.6.3.3.3 通过指标分析反压思路
  • 如果一个 Subtask 的发送端 Buffer 占用率很高,则表明它被下游反压限速了;
  • 如果一个 Subtask 的接受端 Buffer 占用很高,则表明本subtask是反压产生源头,它将反压传导至上游。

反压情况可以根据以下表格进行对号入座(图片来自官网):
在这里插入图片描述
outPoolUsage 和 inPoolUsage 同为低或同为高分别表明当前 Subtask 正常或处于被下游反压,这应该没有太多疑问。

而比较有趣的是当 outPoolUsage 和 inPoolUsage 表现不同时,这可能是出于反压传导的中间状态或者表明该 Subtask 就是反压的根源。

如果一个 Subtask 的 outPoolUsage 是高,通常是被下游 Task 所影响,所以可以排查它本身是反压根源的可能性。

如果一个 Subtask 的 outPoolUsage 是低,但其 inPoolUsage 是高,则表明它有可能是反压的根源。因为通常反压会传导至其上游,导致上游某些 Subtask 的 outPoolUsage 为高,我们可以根据这点来进一步判断。

值得注意的是,反压有时是短暂的且影响不大,比如来自某个 Channel 的短暂网络延迟或者 TaskManager 的正常 GC,这种情况下我们可以不用处理。

对于 Flink 1.9 及以上版本,除了上述的表格,我们还可以根据 floatingBuffersUsage/exclusiveBuffersUsage 以及其上游 Task 的 outPoolUsage 来进行进一步的分析一个 Subtask 和其上游 Subtask 的数据传输。
在这里插入图片描述
通常来说,floatingBuffersUsage 为高则表明反压正在传导至上游,而 exclusiveBuffersUsage 则表明了反压是否存在倾斜(floatingBuffersUsage 高、exclusiveBuffersUsage 低为有倾斜,说明此时少数 channel 占用了大部分的 Floating Buffer)。

至此,我们已经有比较丰富的手段定位反压的根源是出现在哪个节点,但是具体的原因还没有办法找到。

另外基于网络的反压 metrics 并不能定位到具体的 Operator,只能定位到 Task(可能由若干无repatition的算子组成了算子链,在一个subtask线程内运行)。

特别是 embarrassingly parallel(易并行)的作业(所有的 Operator 会被放入一个 Task,因此只有一个节点),反压 metrics 则派不上用场。

3.6.4 分析具体原因及处理

定位到反压节点后,分析造成原因的办法和我们分析一个普通程序的性能瓶颈的办法是十分类似的,可能还要更简单一点,因为我们要观察的主要是 Task Thread。

在实践中,很多情况下的反压是由于数据倾斜造成的,这点我们可以通过 Web UI 各个 SubTask 的 Records SentRecord Received 来确认,另外 Checkpoint detail 里不同 SubTask 的 State size 也是一个分析数据倾斜的有用指标。

此外,最常见的问题可能是用户代码的执行效率问题(频繁被阻塞或者性能问题)。最有用的办法就是对 TaskManager 进行 CPU profile,从中我们可以分析到 Task Thread 是否跑满一个 CPU 核:如果是的话要分析 CPU 主要花费在哪些函数里面,比如我们生产环境中就偶尔遇到卡在 Regex 的用户函数(ReDoS);如果不是的话要看 Task Thread 阻塞在哪里,可能是用户函数本身有些同步的调用,可能是 checkpoint 或者 GC 等系统活动导致的暂时系统暂停。

当然,性能分析的结果也可能是正常的,只是作业申请的资源不足而导致了反压,这就通常要求拓展并行度。值得一提的,在未来的版本 Flink 将会直接在 WebUI 提供 JVM 的 CPU 火焰图,这将大大简化性能瓶颈的分析。

另外 TaskManager 的内存以及 GC 问题也可能会导致反压,包括 TaskManager JVM 各区内存不合理导致的频繁 Full GC 甚至失联。推荐可以通过给 TaskManager 启用 G1 垃圾回收器来优化 GC,并加上 -XX:+PrintGCDetails 来打印 GC 日志的方式来观察 GC 的问题。

4 Spark和Flink背压方式对比

在这里插入图片描述
Spark Streaming可以对PID算法的几个变量进行调整,以适应具体的数据流量波形;也可以调整最大处理的消息数,已防止出现OOM;Flink可以调整LocalBuffer和FloatBuffer大小,来适配不同的流量波形。

3.6.3.4 Task Metrics

5 Storm 背压

5.1 实现原理

5.1.1 Storm 1.0以前

对于开启了acker机制的Storm程序,可以通过设置conf.setMaxSpoutPending参数来实现反压效果,如果下游bolt处理速度跟不上导致spout发送的tuple没有及时确认的数量超过了参数设定的值,spout就会停止发送数据。

这种简单粗暴的方式主要有以下几个缺点:

  • 很难调优conf.setMaxSpoutPending参数的设置来达到最好的反压效果,如果参数小则会导致吞吐上不去;如果参数设置大了则会导致work进程OOM
  • 下游bolt出现阻塞,上游停止发送,下游消除阻塞后,上游又进行开闸放水,过一会儿,下游又阻塞,上游又限流,如此反复,会导致整个系统数据流一直处在一个颠簸的状态
  • 对于关闭acker机制的Storm程序无效

5.1.2 Storm 1.0以后

Storm 是通过监控 Bolt 中的接收队列负载情况,如果超过高水位值就会将反压信息写到 Zookeeper ,Zookeeper 上的 watch 会通知该拓扑的所有 Worker 都进入反压状态,最后 Spout 停止发送 tuple
在这里插入图片描述
反压过程:

  1. worker executor的接收队列大于高水位,通知反压线程
  2. worker反压线程通知zookeeper,将反压信息写入到zookeeper节点
  3. zookeeper通知该topo上所有的worker进入反压状态
  4. spout降低发送tuple的速率

问题:

  • 停止发送,等待系统回复,再次高速生产,然后再次停止发送,造成往数据流颠簸

5.2 JStorm

5.2.1 概述

JStorm的限流机制,当下游bolt发生阻塞的时候,并且阻塞task比例超过某个比例的时候,会触发启动反压限流。

其中判断bolt是否发生阻塞是通过连续n次采样周其中,队列超过某个阈值,就认为该task处于阻塞状态。

根据阻塞的component,进行反向DAG推算,直到推算到源头spout,将topology的一个状态位设置为 “限流状态”。

task出现阻塞时,将自己的执行线程时间传递给TM(topology master),当启动反向限流后,TM把这个执行时间传递给spout。这样spout每次发送一个tuple,就会等待这个执行时间。而当spout降速之后,发送过阻塞命令的task检查队列水位是否连续n次低于某个阈值,如果是,就会发送解除限流命令给TM,TM然后发送提速命令给所有的spout,这样spout每次发送一个tuple就会减少等待时间,当spout的等待时间降为0,spout就会不断地向TM发送解除限速给TM,当所有降速的spout都发了解除限速命令,那么就会将topology的状态设置为正常,标志真正解除限速。

5.2.2 配置示例

## 反压总开关
topology.backpressure.enable: true
## 高水位 -- 当队列使用量超过这个值时,认为阻塞
topology.backpressure.water.mark.high: 0.8
## 低水位 -- 当队列使用量低于这个量时, 认为可以解除阻塞
topology.backpressure.water.mark.low: 0.05
## 阻塞比例 -- 当阻塞task数/这个component并发 的比例高于这值时,触发反压
topology.backpressure.coordinator.trigger.ratio: 0.1

## 反压采样周期, 单位ms
topology.backpressure.check.interval: 1000
## 采样次数和采样比例, 即在连续4次采样中, 超过(不包含)(4 * 0.75)次阻塞才能认为真正阻塞, 超过(不包含)(4 * 0.75)次解除阻塞才能认为是真正解除阻塞
topology.backpressure.trigger.sample.rate: 0.75
topology.backpressure.trigger.sample.number: 4

参考文档

更多好文

  • 6
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值