目錄
流量控制簡介
在流式處理系統(tǒng)中,流量控制(rate control/rate limit)是一個非常重要的話題。對系統(tǒng)進(jìn)行流控,主要目的是為了保證運行的穩(wěn)定性,防止突發(fā)大流量造成整個系統(tǒng)的擾動(throttle),長時間或劇烈的擾動甚至?xí)瓜到y(tǒng)宕機(jī)。另外,為了保證系統(tǒng)的吞吐量最大化,也需要設(shè)計合理的流控門檻,避免系統(tǒng)空轉(zhuǎn)使資源利用率降低。
Spark Streaming作為基于微批次(micro-batch)的流處理框架,其流量的理想狀態(tài)就是官方文檔中所說的“batches of data should be processed as fast as they are being generated”,即每一批次的處理時長batch_process_time需要小于(但是又比較接近)我們設(shè)定的批次間隔batch_interval。如果batch_process_time > batch_interval,說明程序的處理能力不足,積累的數(shù)據(jù)越來越多,最終會造成Executor內(nèi)存溢出。如果batch_process_time << batch_interval,說明系統(tǒng)有很長時間是空閑的,應(yīng)該適當(dāng)提升流量。
Spark Streaming流控基本設(shè)置
Spark Streaming通過Executor里的Receiver組件源源不斷地接收外部數(shù)據(jù),并通過BlockManager將外部數(shù)據(jù)轉(zhuǎn)化為Spark中的塊進(jìn)行存儲。Spark Streaming機(jī)制的簡單框圖如下所示。
要限制Receiver接收數(shù)據(jù)的速率,可以在SparkConf中設(shè)置配置項spark.streaming.receiver.maxRate
,單位為數(shù)據(jù)條數(shù)/秒。如果采用的是基于Direct Stream方式的Kafka連接,不經(jīng)過Receiver,就得設(shè)置配置項spark.streaming.kafka.maxRatePerPartition
來限流,單位是每分區(qū)的數(shù)據(jù)條數(shù)/秒。
這兩種方式的優(yōu)點是設(shè)置非常簡單,只需要通過實際業(yè)務(wù)的吞吐量估算一下使批次間隔和處理耗時基本達(dá)到平衡的速率就可以了。缺點是一旦業(yè)務(wù)量發(fā)生變化,就只能手動修改參數(shù)并重啟Streaming程序。另外,人為估計的參數(shù)畢竟有可能不準(zhǔn),設(shè)置得太激進(jìn)或太保守都不好。
所以,Spark后來提出了動態(tài)流量控制的方案,能夠根據(jù)當(dāng)前系統(tǒng)的處理速度智能地調(diào)節(jié)流量閾值,名為反壓(back pressure)機(jī)制。其在1.5版本開始加入,ASF JIRA中對應(yīng)的issue是SPARK-7398。要啟用它,只需要將配置項spark.streaming.backpressure.enabled
設(shè)為true就可以(默認(rèn)值為false)。
反壓機(jī)制看似簡單,但它背后有一套非常精巧的控制邏輯,下面就來深入看一看。
Spark Streaming反壓機(jī)制的具體實現(xiàn)
動態(tài)流量控制器
o.a.s.streaming.scheduler.RateController抽象類是動態(tài)流量控制的核心。其源碼不甚長,抄錄如下。
private[streaming] abstract class RateController(val streamUID: Int, rateEstimator: RateEstimator)
extends StreamingListener with Serializable {
init()
protected def publish(rate: Long): Unit
@transient
implicit private var executionContext: ExecutionContext = _
@transient
private var rateLimit: AtomicLong = _
private def init() {
executionContext = ExecutionContext.fromExecutorService(
ThreadUtils.newDaemonSingleThreadExecutor("stream-rate-update"))
rateLimit = new AtomicLong(-1L)
}
private def readObject(ois: ObjectInputStream): Unit = Utils.tryOrIOException {
ois.defaultReadObject()
init()
}
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()
override def onBatchCompleted(batchCompleted: StreamingListenerBatchCompleted) {
val elements = batchCompleted.batchInfo.streamIdToInputInfo
for {
processingEnd <- batchCompleted.batchInfo.processingEndTime
workDelay <- batchCompleted.batchInfo.processingDelay
waitDelay <- batchCompleted.batchInfo.schedulingDelay
elems <- elements.get(streamUID).map(_.numRecords)
} computeAndPublish(processingEnd, elems, workDelay, waitDelay)
}
}
可見,RateController抽象類繼承自StreamingListener特征,表示它是一個Streaming監(jiān)聽器。在之前的Spark Core源碼精讀系列文章中已經(jīng)講過了監(jiān)聽器和事件總線機(jī)制,因此不再多說了。
RateController的主要工作如下:
- 監(jiān)聽StreamingListenerBatchCompleted事件,該事件表示一個批次已經(jīng)處理完成。
- 從該事件的BatchInfo實例中取得:處理完成的時間戳processingEndTime、實際處理時長processingDelay(從批次的第一個job開始處理到最后一個job處理完成經(jīng)過的時間)、調(diào)度時延schedulingDelay(從批次被提交給Streaming JobScheduler到第一個job開始處理經(jīng)過的時間)。
- 另外從事件的StreamInputInfo實例中取得批次輸入數(shù)據(jù)的條數(shù)numRecords。
- 將取得的以上4個參數(shù)傳遞給速率估算器RateEstimator,計算出新的流量閾值,并將其發(fā)布出去。
通過RateController的子類ReceiverRateController實現(xiàn)的publish()抽象方法可知,新的流量閾值是發(fā)布給了ReceiverTracker。
private[streaming] class ReceiverRateController(id: Int, estimator: RateEstimator)
extends RateController(id, estimator) {
override def publish(rate: Long): Unit =
ssc.scheduler.receiverTracker.sendRateUpdate(id, rate)
}
不過下面先看速率估算器RateEstimator的實現(xiàn),稍后再回來看ReceiverTracker之后的事情。
基于PID機(jī)制的速率估算器
o.a.s.streaming.scheduler.rate.RateEstimator是一個很短的特征,其中只給出了計算流量閾值的方法compute()的定義。它還有一個伴生對象用于創(chuàng)建速率估算器的實例,其中寫出了更多關(guān)于反壓機(jī)制的配置參數(shù)。
object RateEstimator {
def create(conf: SparkConf, batchInterval: Duration): RateEstimator =
conf.get("spark.streaming.backpressure.rateEstimator", "pid") match {
case "pid" =>
val proportional = conf.getDouble("spark.streaming.backpressure.pid.proportional", 1.0)
val integral = conf.getDouble("spark.streaming.backpressure.pid.integral", 0.2)
val derived = conf.getDouble("spark.streaming.backpressure.pid.derived", 0.0)
val minRate = conf.getDouble("spark.streaming.backpressure.pid.minRate", 100)
new PIDRateEstimator(batchInterval.milliseconds, proportional, integral, derived, minRate)
case estimator =>
throw new IllegalArgumentException(s"Unknown rate estimator: $estimator")
}
}
目前RateEstimator的唯一實現(xiàn)類是PIDRateEstimator,亦即spark.streaming.backpressure.rateEstimator
配置項的值只能為pid。其具體代碼如下。
private[streaming] class PIDRateEstimator(
batchIntervalMillis: Long,
proportional: Double,
integral: Double,
derivative: Double,
minRate: Double
) extends RateEstimator with Logging {
private var firstRun: Boolean = true
private var latestTime: Long = -1L
private var latestRate: Double = -1D
private var latestError: Double = -1L
def compute(
time: Long,
numElements: Long,
processingDelay: Long,
schedulingDelay: Long
): Option[Double] = {
this.synchronized {
if (time > latestTime && numElements > 0 && processingDelay > 0) {
val delaySinceUpdate = (time - latestTime).toDouble / 1000
val processingRate = numElements.toDouble / processingDelay * 1000
val error = latestRate - processingRate
val historicalError = schedulingDelay.toDouble * processingRate / batchIntervalMillis
val dError = (error - latestError) / delaySinceUpdate
val newRate = (latestRate - proportional * error -
integral * historicalError -
derivative * dError).max(minRate)
latestTime = time
if (firstRun) {
latestRate = processingRate
latestError = 0D
firstRun = false
None
} else {
latestRate = newRate
latestError = error
Some(newRate)
}
} else {
None
}
}
}
}
PIDRateEstimator充分運用了工控領(lǐng)域中常見的PID控制器的思想。所謂PID控制器,即比例(Proportional)-積分(Integral)-微分(Derivative)控制器,本質(zhì)上是一種反饋回路(loop feedback)。它把收集到的數(shù)據(jù)和一個設(shè)定值(setpoint)進(jìn)行比較,然后用它們之間的差計算新的輸入值,該輸入值可以讓系統(tǒng)數(shù)據(jù)盡量接近或者達(dá)到設(shè)定值。
下圖示出PID控制器的基本原理。
亦即:
其中e(t)代表誤差,即設(shè)定值與回授值之間的差。也就是說,比例單元對應(yīng)當(dāng)前誤差,積分單元對應(yīng)過去累積誤差,而微分單元對應(yīng)將來誤差??刂迫齻€單元的增益因子分別為Kp、Ki、Kd。
回到PIDRateEstimator的源碼來,對應(yīng)以上的式子,我們可以得知:
- 處理速率的設(shè)定值其實就是上一批次的處理速率latestRate,回授值就是這一批次的速率processingRate,誤差error自然就是兩者之差。
- 過去累積誤差在這里體現(xiàn)為調(diào)度時延的過程中數(shù)據(jù)積壓的速度,也就是schedulingDelay * processingRate / batchInterval。
- 將來誤差就是上面算出的error對時間微分的結(jié)果。
將上面三者綜合起來,就可以根據(jù)Spark Streaming在上一批次以及這一批次的處理速率,估算出一個合適的用于下一批次的流量閾值。比例增益Kp由spark.streaming.backpressure.pid.proportional
控制,默認(rèn)值1.0;積分增益Ki由spark.streaming.backpressure.pid.integral
控制,默認(rèn)值0.2;微分增益Kd由spark.streaming.backpressure.pid.derived
控制,默認(rèn)值0.0。
除了上述參數(shù)之外,還有兩個參數(shù)與反壓機(jī)制相關(guān)。一是spark.streaming.backpressure.initialRate
,用于控制初始化時的處理速率。二是spark.streaming.backpressure.pid.minRate
,用于控制最小處理速率,默認(rèn)值100條/秒。
通過RPC發(fā)布流量閾值
回來看ReceiverTracker,顧名思義,它負(fù)責(zé)追蹤Receiver的狀態(tài)。其sendRateUpdate()方法如下。
def sendRateUpdate(streamUID: Int, newRate: Long): Unit = synchronized {
if (isTrackerStarted) {
endpoint.send(UpdateReceiverRateLimit(streamUID, newRate))
}
}
其中endpoint是RPC端點的引用,具體來說,是ReceiverTrackerEndpoint的引用。這個方法會將流ID與新的流量閾值包裝在UpdateReceiverRateLimit消息中發(fā)送過去。
ReceiverTrackerEndpoint收到這條消息后,會再將其包裝為UpdateRateLimit消息并發(fā)送給Receiver注冊時的RPC端點(位于ReceiverSupervisorImpl類中)。
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)
}
}
})
可見,收到該消息之后調(diào)用了BlockGenerator.updateRate()方法。BlockGenerator是RateLimiter的子類,它負(fù)責(zé)將收到的流數(shù)據(jù)轉(zhuǎn)化成塊存儲。updateRate()方法是在RateLimiter抽象類中實現(xiàn)的。
private[receiver] def updateRate(newRate: Long): Unit =
if (newRate > 0) {
if (maxRateLimit > 0) {
rateLimiter.setRate(newRate.min(maxRateLimit))
} else {
rateLimiter.setRate(newRate)
}
}
這里最終借助了Guava中的限流器RateLimiter實現(xiàn)限流(Spark是不會重復(fù)造輪子的),其中maxRateLimit就是前面提到過的spark.streaming.receiver.maxRate
參數(shù)。至此,新的流量閾值就設(shè)置好了。
以上就是與反壓機(jī)制有關(guān)的全部細(xì)節(jié),整個流程可以用下面的框圖表示。
還有最后一個小問題,流量閾值設(shè)定好之后是如何生效的?這其實已經(jīng)超出了本文的范疇,簡單看一下。
借助Guava令牌桶完成流量控制
Receiver在收到一條數(shù)據(jù)之后,會調(diào)用BlockGenerator.addData()方法,將數(shù)據(jù)存入緩存。然后再從緩存取數(shù)據(jù),并包裝成一個個block。
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")
}
}
注意到在真正存入緩存之前,先調(diào)用了waitToPush()方法,它本質(zhì)上就是Guava的RateLimiter.acquire()方法。
@CanIgnoreReturnValue
public double acquire() {
return acquire(1);
}
@CanIgnoreReturnValue
public double acquire(int permits) {
long microsToWait = reserve(permits);
stopwatch.sleepMicrosUninterruptibly(microsToWait);
return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
resync(nowMicros);
long returnValue = nextFreeTicketMicros;
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
double freshPermits = requiredPermits - storedPermitsToSpend;
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
this.storedPermits -= storedPermitsToSpend;
return returnValue;
}
Guava的限流器是計算機(jī)網(wǎng)絡(luò)中經(jīng)典限流方法——令牌桶(token bucket)算法的典型實現(xiàn)。acquire()方法的作用是從RateLimiter獲取一個令牌(這里叫permit),如果能夠取到令牌才將數(shù)據(jù)緩存,如果不能取到令牌就會被阻塞。RateLimiter.setRate()方法就是通過改變向令牌桶中放入令牌的速率(參數(shù)名稱permitsPerSecond)來實現(xiàn)流量控制的。
關(guān)于令牌桶算法的細(xì)節(jié),可以參見英文維基,也可以參考Guava源碼,內(nèi)容十分豐富。下圖只是一個簡單的示意。