《spark streaming源碼九》Executor 端長時容錯詳解

轉載自https://github.com/lw-lin/CoolplaySpark

本系列內容適用範圍:

* 2017.07.11 update, Spark 2.2 全系列 √ (已發佈:2.2.0)
* 2017.10.02 update, Spark 2.1 全系列 √ (已發佈:2.1.0, 2.1.1, 2.1.2)
* 2016.11.14 update, Spark 2.0 全系列 √ (已發佈:2.0.0, 2.0.1, 2.0.2)

目錄

 

引言

(1) 熱備

(2) 冷備

WriteAheadLog 框架

WriteAheadLog, WriteAheadLogRecordHandle

FileBasedWriteAheadLogSegment

WriteAheadLogRecordHandle

FileBasedWriteAheadLogWriter

FileBasedWriteAheadLogRandomReader

FileBasedWriteAheadLogReader

WAL 總結

(3) 重放

(4) 忽略

粗粒度忽略

細粒度忽略

總結


引言

之前的詳解我們詳解了完成 Spark Streamimg 基於 Spark Core 所新增功能的 3 個模塊,接下來我們看一看第 4 個模塊將如何保障 Spark Streaming 的長時運行 —— 也就是,如何與前 3 個模塊結合,保障前 3 個模塊的長時運行。

通過前 3 個模塊的關鍵類的分析,我們可以知道,保障模塊 1 和 2 需要在 driver 端完成,保障模塊 3 需要在 executor 端和 driver 端完成。

本文我們詳解 executor 端的保障。

在 executor 端,ReceiverSupervisor 和 Receiver 失效後直接重啓就 OK 了,關鍵是保障收到的塊數據的安全。保障了源頭塊數據,就能夠保障 RDD DAG (Spark Core 的 lineage)重做。

Spark Streaming 對源頭塊數據的保障,分爲 4 個層次,全面、相互補充,又可根據不同場景靈活設置:

  • (1) 熱備
  • (2) 冷備
  • (3) 重放
  • (4) 忽略

(1) 熱備

熱備是指在存儲塊數據時,將其存儲到本 executor、並同時 replicate 到另外一個 executor 上去。這樣在一個 replica 失效後,可以立刻無感知切換到另一份 replica 進行計算。

實現方式是,在實現自己的 Receiver 時,即指定一下 StorageLevel 爲 MEMORY_ONLY_2 或 MEMORY_AND_DISK_2 就可以了。

比如這樣:

class MyReceiver extends Receiver(StorageLevel.MEMORY_ONLY_2) {
  override def onStart(): Unit = {}
  override def onStop(): Unit = {}
}

 

這樣,Receiver 在將數據 store() 給 ReceiverSupervisorImpl 的時候,將同時指明此 storageLevelReceiverSupervisorImpl 也將根據此 storageLevel 將塊數據具體的存儲給 BlockManager

然後就是依靠 BlockManager 進行熱備。具體的 —— 我們以 ReceiverSupervisorImpl 向 BlockManager 存儲一個 byteBuffer爲例 —— BlockManager 在收到 putBytes(byteBuffer) 時,實際是直接調用 doPut(byteBuffer) 的。 那麼我們看 doPut(...) 方法(友情提醒,主要看代碼裏的註釋):

private def doPut(blockId: BlockId, data: BlockValues, level: StorageLevel, ...)
  : Seq[(BlockId, BlockStatus)] = {
  ...
  //【如果  putLevel.replication > 1 的話,就定義這個 future,複製數據到另外的 executor 上】
  val replicationFuture = data match {
    case b: ByteBufferValues if putLevel.replication > 1 =>
      val bufferView = b.buffer.duplicate()
      Future {
        //【這裏非常重要,會在 future 啓動時去實際調用 replicate() 方法,複製數據到另外的 executor 上】
        replicate(blockId, bufferView, putLevel)
      }(futureExecutionContext)
    case _ => null
  }

  putBlockInfo.synchronized {
    ...
    // 【存儲到本機 blockManager 的 blockStore 裏】
    val result = data match {
      case IteratorValues(iterator) =>
        blockStore.putIterator(blockId, iterator, putLevel, returnValues)
      case ArrayValues(array) =>
        blockStore.putArray(blockId, array, putLevel, returnValues)
      case ByteBufferValues(bytes) =>
        bytes.rewind()
        blockStore.putBytes(blockId, bytes, putLevel)
    }
  }
      
  //【再次判斷  putLevel.replication > 1】
  if (putLevel.replication > 1) {
    data match {
      case ByteBufferValues(bytes) =>
        //【如果之前啓動了 replicate 的 future,那麼這裏就同步地等這個 future 結束】
        if (replicationFuture != null) {
          Await.ready(replicationFuture, Duration.Inf)
        }
      case _ =>
        val remoteStartTime = System.currentTimeMillis
        if (bytesAfterPut == null) {
          if (valuesAfterPut == null) {
            throw new SparkException(
              "Underlying put returned neither an Iterator nor bytes! This shouldn't happen.")
          }
          bytesAfterPut = dataSerialize(blockId, valuesAfterPut)
        }
        //【否則之前沒有啓動 replicate 的 future,那麼這裏就同步地調用 replicate() 方法,複製數據到另外的 executor 上】
        replicate(blockId, bytesAfterPut, putLevel)
        logDebug("Put block %s remotely took %s"
          .format(blockId, Utils.getUsedTimeMs(remoteStartTime)))
    }
  }

  ...
}

所以,可以看到, BlockManager 的 putBytes() 語義就是承諾了,如果指定需要 replicate,那麼當 putBytes() 方法返回時,就一定是存儲到本機、並且一定 replicate 到另外的 executor 上了。對於 BlockManager 的 putIterator() 也是同樣的語義,因爲 BlockManager 的 putIterator() 和 BlockManager 的 putBytes() 一樣,都是基於 BlockManager 的 doPut() 來實現的。

簡單總結本小節的解析,Receiver 收到的數據,通過 ReceiverSupervisorImpl,將數據交給 BlockManager 存儲;而 BlockManager 本身支持將數據 replicate() 到另外的 executor 上,這樣就完成了 Receiver 源頭數據的熱備過程。

而在計算時,計算任務首先將獲取需要的塊數據,這時如果一個 executor 失效導致一份數據丟失,那麼計算任務將轉而向另一個 executor 上的同一份數據獲取數據。因爲另一份塊數據是現成的、不需要像冷備那樣重新讀取的,所以這裏不會有 recovery time。

(2) 冷備

!!! 需要同時修改

冷備是每次存儲塊數據時,除了存儲到本 executor,還會把塊數據作爲 log 寫出到 WriteAheadLog 裏作爲冷備。這樣當 executor 失效時,就由另外的 executor 去讀 WAL,再重做 log 來恢復塊數據。WAL 通常寫到可靠存儲如 HDFS 上,所以恢復時可能需要一段 recover time。

冷備的寫出過程如下圖 4(c) 過程所示:

這裏我們需要插播一下詳解 WriteAheadLog 框架。

WriteAheadLog 框架

WriteAheadLog 的方式在單機 RDBMS、NoSQL/NewSQL 中都有廣泛應用,前者比如記錄 transaction log 時,後者比如 HBase 插入數據可以先寫到 HLog 裏。

WriteAheadLog 的特點是順序寫入,所以在做數據備份時效率較高,但在需要恢復數據時又需要順序讀取,所以需要一定 recovery time。

不過對於 Spark Streaming 的塊數據冷備來講,在恢復時也非常方便。這是因爲,對某個塊數據的操作只有一次(即新增塊數據),而沒有後續對塊數據的追加、修改、刪除操作,這就使得在 WAL 裏只會有一條此塊數據的 log entry。所以,我們在恢復時只要 seek 到這條 log entry 並讀取就可以了,而不需要順序讀取整個 WAL。

也就是,Spark Streaming 基於 WAL 冷備進行恢復,需要的 recovery time 只是 seek 到並讀一條 log entry 的時間,而不是讀取整個 WAL 的時間,這個是個非常大的節省。

Spark Streaming 裏的 WAL 框架,由一組抽象類,和一組基於文件的具體實現組成。其類結構關係如下:

WriteAheadLog, WriteAheadLogRecordHandle

WriteAheadLog 是多條 log 的集合,每條具體的 log 的引用就是一個 LogRecordHandle。這兩個 abstract 的接口定義如下:

//  來自 WriteAheadLog

@org.apache.spark.annotation.DeveloperApi
public abstract class WriteAheadLog {
  // 【寫方法:寫入一條 log,將返回一個指向這條 log 的句柄引用】
  abstract public WriteAheadLogRecordHandle write(ByteBuffer record, long time);

  // 【讀方法:給定一條 log 的句柄引用,讀出這條 log】
  abstract public ByteBuffer read(WriteAheadLogRecordHandle handle);

  // 【讀方法:讀取全部 log】
  abstract public Iterator<ByteBuffer> readAll();

  // 【清理過時的 log 條目】
  abstract public void clean(long threshTime, boolean waitForCompletion);

  // 【關閉方法】
  abstract public void close();
}

//  來自 WriteAheadLogRecordHandle

@org.apache.spark.annotation.DeveloperApi
public abstract class WriteAheadLogRecordHandle implements java.io.Serializable {
  // 【Handle 則是一個空接口,需要具體的子類定義真正的內容】
}

這裏 WriteAheadLog 基於文件的具體實現是 FileBasedWriteAheadLogWriteAheadLogRecordHandle 基於文件的具體實現是 FileBasedWriteAheadLogSegment,下面我們詳細看看這兩個具體的類。

FileBasedWriteAheadLogSegment

FileBasedWriteAheadLog 有 3 個重要的配置項或成員:

  • rolling 配置項

    • FileBasedWriteAheadLog 的實現把 log 寫到一個文件裏(一般是 HDFS 等可靠存儲上的文件),然後每隔一段時間就關閉已有文件,產生一些新文件繼續寫,也就是 rolling 寫的方式
    • rolling 寫的好處是單個文件不會太大,而且刪除不用的舊數據特別方便
    • 這裏 rolling 的間隔是由參數 spark.streaming.receiver.writeAheadLog.rollingIntervalSecs(默認 = 60 秒) 控制的
  • WAL 存放的目錄:{checkpointDir}/receivedData/{receiverId}

    • {checkpointDir} 在 ssc.checkpoint(checkpointDir) 指定的
    • {receiverId} 是 Receiver 的 id
    • 在這個 WAL 目錄裏,不同的 rolling log 文件的命名規則是 log-{startTime}-{stopTime}
  • 然後就是 FileBasedWriteAheadLog.currentLogWriter

    • 一個 LogWriter 對應一個 log file,而且 log 文件本身是 rolling 的,那麼前一個 log 文件寫完成後,對應的 writer 就可以 close() 了,而由新的 writer 負責寫新的文件
    • 這裏最新的 LogWriter 就由 currentLogWriter 來指向

接下來就是 FileBasedWriteAheadLog 的讀寫方法了:

  • write(byteBuffer: ByteBuffer, time: Long)
    • 最重要的是先調用 getCurrentWriter(),獲取當前的 currentWriter
    • 注意這裏,如果 log file 需要 rolling 成新的了,那麼 currentWriter 也需要隨之更新;上面 getCurrentWriter() 會完成這個按需更新 currentWriter 的過程
    • 然後就可以調用 writer.write(byteBuffer) 就可以了
  • read(segment: WriteAheadLogRecordHandle): ByteBuffer
    • 直接調用 reader.read(fileSegment)
    • 在 reader 的實現裏,因爲給定了 segment —— 也就是 WriteAheadLogRecordHandle,而 segment 裏包含了具體的 log file 和 offset,就可以直接 seek 到這條 log,讀出數據並返回

所以總結下可以看到,FileBasedWriteAheadLog 主要是進行 rolling file 的管理,然後將具體的寫方法、讀方法是由具體的 LogWriter 和 LogReader 來做的。

WriteAheadLogRecordHandle

前面我們剛說,WriteAheadLogRecordHandle 是一個 log 句柄的空實現,需要子類指定具體的 log 句柄內容。

然後在基於的 file 的子類實現 WriteAheadLogRecordHandle 裏,就記錄了 3 方面內容:

// 來自 FileBasedWriteAheadLogSegment

private[streaming] case class FileBasedWriteAheadLogSegment(path: String, offset: Long, length: Int)
  extends WriteAheadLogRecordHandle
  • path: String
  • offset: Long
  • length: Int

這 3 方面內容就非常直觀了,給定文件、偏移和長度,就可以唯一確定一條 log。

FileBasedWriteAheadLogWriter

FileBasedWriteAheadLogWriter 的實現,就是給定一個文件、給定一個塊數據,將數據寫到文件裏面去。

然後在完成的時候,記錄一下文件 path、offset 和 length,封裝爲一個 FileBasedWriteAheadLogSegment 返回。

這裏需要注意下的是,在具體的寫 HDFS 數據塊的時候,需要判斷一下具體用的方法,優先使用 hflush(),沒有的話就使用 sync()

// 來自 FileBasedWriteAheadLogWriter

private lazy val hadoopFlushMethod = {
  // Use reflection to get the right flush operation
  val cls = classOf[FSDataOutputStream]
  Try(cls.getMethod("hflush")).orElse(Try(cls.getMethod("sync"))).toOption
}

FileBasedWriteAheadLogRandomReader

FileBasedWriteAheadLogRandomReader 的主要方法是 read(segment: FileBasedWriteAheadLogSegment): ByteBuffer,即給定一個 log 句柄,返回一條具體的 log。

這裏主要代碼如下,注意到其中最關鍵的是 seek(segment.offset) !

// 來自 FileBasedWriteAheadLogRandomReader

def read(segment: FileBasedWriteAheadLogSegment): ByteBuffer = synchronized {
  assertOpen()
  // 【seek 到這條 log 所在的 offset】
  instream.seek(segment.offset)
  // 【讀一下 length】
  val nextLength = instream.readInt()
  HdfsUtils.checkState(nextLength == segment.length,
    s"Expected message length to be ${segment.length}, but was $nextLength")
  val buffer = new Array[Byte](nextLength)
  // 【讀一下具體的內容】
  instream.readFully(buffer)
  // 【以 ByteBuffer 的形式,返回具體的內容】
  ByteBuffer.wrap(buffer)
}

FileBasedWriteAheadLogReader

FileBasedWriteAheadLogReader 實現跟 FileBasedWriteAheadLogRandomReader 差不多,不過是不需要給定 log 的句柄,而是迭代遍歷所有 log:

// 來自 FileBasedWriteAheadLogReader

// 【迭代方法:hasNext()】
override def hasNext: Boolean = synchronized {
  if (closed) {
    // 【如果已關閉,就肯定不 hasNext 了】
    return false
  }

  if (nextItem.isDefined) {
    true
  } else {
    try {
      // 【讀出來下一條,如果有,就說明還確實 hasNext】
      val length = instream.readInt()
      val buffer = new Array[Byte](length)
      instream.readFully(buffer)
      nextItem = Some(ByteBuffer.wrap(buffer))
      logTrace("Read next item " + nextItem.get)
      true
    } catch {
     ...
    }
  }
}

// 【迭代方法:next()】
override def next(): ByteBuffer = synchronized {
  // 【直接返回在 hasNext() 方法裏實際讀出來的數據】
  val data = nextItem.getOrElse {
    close()
    throw new IllegalStateException(
      "next called without calling hasNext or after hasNext returned false")
  }
  nextItem = None // Ensure the next hasNext call loads new data.
  data
}

WAL 總結

通過上面幾個小節,我們看到,Spark Streaming 有一套基於 rolling file 的 WAL 實現,提供一個寫方法,兩個讀方法:

  • WriteAheadLogRecordHandle write(ByteBuffer record, long time)
    • 由 FileBasedWriteAheadLogWriter 具體實現
  • ByteBuffer read(WriteAheadLogRecordHandle handle)`
    • 由 FileBasedWriteAheadLogRandomReader 具體實現
  • Iterator<ByteBuffer> readAll()
    • 由 FileBasedWriteAheadLogReader 具體實現

(3) 重放

如果上游支持重放,比如 Apache Kafka,那麼就可以選擇不用熱備或者冷備來另外存儲數據了,而是在失效時換一個 executor 進行數據重放即可。

具體的,Spark Streaming 從 Kafka 讀取方式有兩種

  • 基於 Receiver 的
    • 這種是將 Kafka Consumer 的偏移管理交給 Kafka —— 將存在 ZooKeeper 裏,失效後由 Kafka 去基於 offset 進行重放
    • 這樣可能的問題是,Kafka 將同一個 offset 的數據,重放給兩個 batch 實例 —— 從而只能保證 at least once 的語義
  • Direct 方式,不基於 Receiver
    • 由 Spark Streaming 直接管理 offset —— 可以給定 offset 範圍,直接去 Kafka 的硬盤上讀數據,使用 Spark Streaming 自身的均衡來代替 Kafka 做的均衡
    • 這樣可以保證,每個 offset 範圍屬於且只屬於一個 batch,從而保證 exactly-once

這裏我們以 Direct 方式爲例,詳解一下 Spark Streaming 在源頭數據實效後,是如果從上游重放數據的。

這裏的實現分爲兩個層面:

  • DirectKafkaInputDStream:負責偵測最新 offset,並將 offset 分配至唯一個 batch
    • 會在每次 batch 生成時,依靠 latestLeaderOffsets() 方法去偵測最新的 offset
    • 然後與上一個 batch 偵測到的 offset 相減,就能得到一個 offset 的範圍 offsetRange
    • 把這個 offset 範圍內的數據,唯一分配到本 batch 來處理
  • KafkaRDD:負責去讀指定 offset 範圍內的數據,並基於此數據進行計算
    • 會生成一個 Kafka 的 SimpleConsumer —— SimpleConsumer 是 Kafka 最底層、直接對着 Kafka 硬盤上的文件讀數據的類
    • 如果 Task 失敗,導致任務重新下發,那麼 offset 範圍仍然維持不變,將直接重新生成一個 Kafka 的 SimpleConsumer 去讀數據

所以看 Direct 的方式,歸根結底是由 Spark Streaming 框架來負責整個 offset 的偵測、batch 分配、實際讀取數據;並且這些分 batch 的信息都是 checkpoint 到可靠存儲(一般是 HDFS)了。這就沒有用到 Kafka 使用 ZooKeeper 來均衡 consumer 和記錄 offset 的功能,而是把 Kafka 直接當成一個底層的文件系統來使用了。

當然,我們講上游重放並不只侷限於 Kafka,而是說凡是支持消息重放的上游都可以 —— 比如,HDFS 也可以看做一個支持重放的可靠上游 —— FileInputDStream 就是利用重放的方式,保證了 executor 失效後的源頭數據的可讀性。

(4) 忽略

最後,如果應用的實時性需求大於準確性,那麼一塊數據丟失後我們也可以選擇忽略、不恢復失效的源頭數據。

假設我們有 r1, r2, r3 這三個 Receiver,而且每 5 秒產生一個 Block,每 15 秒產生一個 batch。那麼,每個 batch 有 15 s ÷ 5 block/s/receiver × 3 receiver = 9 block。現在假設 r1 失效,隨之也丟失了 3 個 block。

那麼上層應用如何進行忽略?有兩種粒度的做法。

粗粒度忽略

粗粒度的做法是,如果計算任務試圖讀取丟失的源頭數據時出錯,會導致部分 task 計算失敗,會進一步導致整個 batch 的 job 失敗,最終在 driver 端以 SparkException 的形式報出來 —— 此時我們 catch 住這個 SparkException,就能夠屏蔽這個 batch 的 job 失敗了。

粗粒度的這個做法實現起來非常簡單,問題是會忽略掉整個 batch 的計算結果。雖然我們還有 6 個 block 是好的,但所有 9 個的數據都會被忽略。

細粒度忽略

細粒度的做法是,只將忽略部分侷限在丟失的 3 個 block 上,其它部分 6 部分繼續保留。目前原生的 Spark Streaming 還不能完全做到,但我們對 Spark Streaming 稍作修改,就可以做到了。

細粒度基本思路是,在一個計算的 task 發現作爲源數據的 block 失效後,不是直接報錯,而是另外生成一個空集合作爲“修正”了的源頭數據,然後繼續 task 的計算,並將成功。

如此一來,僅侷限在發生數據丟失的 3 個塊數據纔會進行“忽略”的過程,6 個好的塊數據將正常進行計算。最後整個 job 是成功的。

當然這裏對 Spark Streaming 本身的改動,還需要考慮一些細節,比如只在 Spark Streaming 裏生效、不要影響到 Spark Core、SparkSQL,再比如 task 通常都是會失效重試的,我們希望前幾次現場重試,只在最後一次重試仍不成功的時候再進行忽略。

我們把修改的代碼,以及使用方法放在這裏了,請隨用隨取。

總結

我們上面分四個小節介紹了 Spark Streaming 對源頭數據的高可用的保障方式,我們用一個表格來總結一下:

圖示 優點 缺點
(1) 熱備 無 recover time 需要佔用雙倍資源
(2) 冷備 十分可靠 存在 recover time
(3) 重放 不佔用額外資源 存在 recover time
(4) 忽略 無 recover time 準確性有損失
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章