Spark如何協調來完成整個Job的運行詳解

點擊上方 藍色字體 ,選擇“ 設爲星標
回覆”資源“獲取更多資源

恭喜你有毅力看到這裏,先複習:

Spark的Cache和Checkpoint區別和聯繫拾遺

Spark Job 邏輯執行圖和數據依賴解析

Spark Job 物理執行圖詳解

Spark Shuffle過程詳解

前幾章從 job 的角度介紹了用戶寫的 program 如何一步步地被分解和執行。這一章主要從架構的角度來討論 master,worker,driver 和 executor 之間怎麼協調來完成整個 job 的運行。

直接看圖和描述即可。

部署圖

重新貼一下 Overview 中給出的部署圖:

接下來分階段討論並細化這個圖。

Job 提交

下圖展示了driver program(假設在 master node 上運行)如何生成 job,並提交到 worker node 上執行。

Driver 端的邏輯如果用代碼表示:

finalRDD.action()
=> sc.runJob()

// generate job, stages and tasks
=> dagScheduler.runJob()
=> dagScheduler.submitJob()
=> dagSchedulerEventProcessActor ! JobSubmitted
=> dagSchedulerEventProcessActor.JobSubmitted()
=> dagScheduler.handleJobSubmitted()
=> finalStage = newStage()
=> mapOutputTracker.registerShuffle(shuffleId, rdd.partitions.size)
=> dagScheduler.submitStage()
=> missingStages = dagScheduler.getMissingParentStages()
=> dagScheduler.subMissingTasks(readyStage)

// add tasks to the taskScheduler
=> taskScheduler.submitTasks(new TaskSet(tasks))
=> fifoSchedulableBuilder.addTaskSetManager(taskSet)

// send tasks
=> sparkDeploySchedulerBackend.reviveOffers()
=> driverActor ! ReviveOffers
=> sparkDeploySchedulerBackend.makeOffers()
=> sparkDeploySchedulerBackend.launchTasks()
=> foreach task
CoarseGrainedExecutorBackend(executorId) ! LaunchTask(serializedTask)

代碼的文字描述:

當用戶的 program 調用 val sc = new SparkContext(sparkConf) 時,這個語句會幫助 program 啓動諸多有關 driver 通信、job 執行的對象、線程、actor等,該語句確立了 program 的 driver 地位。

生成 Job 邏輯執行圖

Driver program 中的 transformation() 建立 computing chain(一系列的 RDD),每個 RDD 的 compute() 定義數據來了怎麼計算得到該 RDD 中 partition 的結果,getDependencies() 定義 RDD 之間 partition 的數據依賴。

生成 Job 物理執行圖

每個 action() 觸發生成一個 job,在 dagScheduler.runJob() 的時候進行 stage 劃分,在 submitStage() 的時候生成該 stage 包含的具體的 ShuffleMapTasks 或者 ResultTasks,然後將 tasks 打包成 TaskSet 交給 taskScheduler,如果 taskSet 可以運行就將 tasks 交給 sparkDeploySchedulerBackend 去分配執行。

分配 Task

sparkDeploySchedulerBackend 接收到 taskSet 後,會通過自帶的 DriverActor 將 serialized tasks 發送到調度器指定的 worker node 上的 CoarseGrainedExecutorBackend Actor上。

Job 接收

Worker 端接收到 tasks 後,執行如下操作

coarseGrainedExecutorBackend ! LaunchTask(serializedTask)
=> executor.launchTask()
=> executor.threadPool.execute(new TaskRunner(taskId, serializedTask))

executor 將 task 包裝成 taskRunner,並從線程池中抽取出一個空閒線程運行 task。一個 CoarseGrainedExecutorBackend 進程有且僅有一個 executor 對象。

Task 運行

下圖展示了 task 被分配到 worker node 上後的執行流程及 driver 如何處理 task 的 result。

Executor 收到 serialized 的 task 後,先 deserialize 出正常的 task,然後運行 task 得到其執行結果 directResult,這個結果要送回到 driver 那裏。但是通過 Actor 發送的數據包不易過大,如果 result 比較大(比如 groupByKey 的 result)先把 result 存放到本地的“內存+磁盤”上,由 blockManager 來管理,只把存儲位置信息(indirectResult)發送給 driver,driver 需要實際的 result 的時候,會通過 HTTP 去 fetch。如果 result 不大(小於spark.akka.frameSize = 10MB),那麼直接發送給 driver。

上面的描述還有一些細節:如果 task 運行結束生成的 directResult > akka.frameSize,directResult 會被存放到由 blockManager 管理的本地“內存+磁盤”上。BlockManager 中的 memoryStore 開闢了一個 LinkedHashMap 來存儲要存放到本地內存的數據。LinkedHashMap 存儲的數據總大小不超過 Runtime.getRuntime.maxMemory * spark.storage.memoryFraction(default 0.6) 。如果 LinkedHashMap 剩餘空間不足以存放新來的數據,就將數據交給 diskStore 存放到磁盤上,但前提是該數據的 storageLevel 中包含“磁盤”。

In TaskRunner.run()
// deserialize task, run it and then send the result to
=> coarseGrainedExecutorBackend.statusUpdate()
=> task = ser.deserialize(serializedTask)
=> value = task.run(taskId)
=> directResult = new DirectTaskResult(ser.serialize(value))
=> if( directResult.size() > akkaFrameSize() )
indirectResult = blockManager.putBytes(taskId, directResult, MEMORY+DISK+SER)
else
return directResult
=> coarseGrainedExecutorBackend.statusUpdate(result)
=> driver ! StatusUpdate(executorId, taskId, result)

ShuffleMapTask 和 ResultTask 生成的 result 不一樣。ShuffleMapTask 生成的是 MapStatus,MapStatus 包含兩項內容:一是該 task 所在的 BlockManager 的 BlockManagerId(實際是 executorId + host, port, nettyPort),二是 task 輸出的每個 FileSegment 大小。ResultTask 生成的 result 的是 func 在 partition 上的執行結果。比如 count() 的 func 就是統計 partition 中 records 的個數。由於 ShuffleMapTask 需要將 FileSegment 寫入磁盤,因此需要輸出流 writers,這些 writers 是由 blockManger 裏面的 shuffleBlockManager 產生和控制的。

In task.run(taskId)
// if the task is ShuffleMapTask
=> shuffleMapTask.runTask(context)
=> shuffleWriterGroup = shuffleBlockManager.forMapTask(shuffleId, partitionId, numOutputSplits)
=> shuffleWriterGroup.writers(bucketId).write(rdd.iterator(split, context))
=> return MapStatus(blockManager.blockManagerId, Array[compressedSize(fileSegment)])

//If the task is ResultTask
=> return func(context, rdd.iterator(split, context))

Driver 收到 task 的執行結果 result 後會進行一系列的操作:首先告訴 taskScheduler 這個 task 已經執行完,然後去分析 result。由於 result 可能是 indirectResult,需要先調用 blockManager.getRemoteBytes() 去 fech 實際的 result,這個過程下節會詳解。得到實際的 result 後,需要分情況分析,如果是 ResultTask 的 result,那麼可以使用 ResultHandler 對 result 進行 driver 端的計算(比如 count() 會對所有 ResultTask 的 result 作 sum),如果 result 是 ShuffleMapTask 的 MapStatus,那麼需要將 MapStatus(ShuffleMapTask 輸出的 FileSegment 的位置和大小信息)存放到 mapOutputTrackerMaster 中的 mapStatuses 數據結構中以便以後 reducer shuffle 的時候查詢。如果 driver 收到的 task 是該 stage 中的最後一個 task,那麼可以 submit 下一個 stage,如果該 stage 已經是最後一個 stage,那麼告訴 dagScheduler job 已經完成。

After driver receives StatusUpdate(result)
=> taskScheduler.statusUpdate(taskId, state, result.value)
=> taskResultGetter.enqueueSuccessfulTask(taskSet, tid, result)
=> if result is IndirectResult
serializedTaskResult = blockManager.getRemoteBytes(IndirectResult.blockId)
=> scheduler.handleSuccessfulTask(taskSetManager, tid, result)
=> taskSetManager.handleSuccessfulTask(tid, taskResult)
=> dagScheduler.taskEnded(result.value, result.accumUpdates)
=> dagSchedulerEventProcessActor ! CompletionEvent(result, accumUpdates)
=> dagScheduler.handleTaskCompletion(completion)
=> Accumulators.add(event.accumUpdates)

// If the finished task is ResultTask
=> if (job.numFinished == job.numPartitions)
listenerBus.post(SparkListenerJobEnd(job.jobId, JobSucceeded))
=> job.listener.taskSucceeded(outputId, result)
=> jobWaiter.taskSucceeded(index, result)
=> resultHandler(index, result)

// if the finished task is ShuffleMapTask
=> stage.addOutputLoc(smt.partitionId, status)
=> if (all tasks in current stage have finished)
mapOutputTrackerMaster.registerMapOutputs(shuffleId, Array[MapStatus])
mapStatuses.put(shuffleId, Array[MapStatus]() ++ statuses)
=> submitStage(stage)

Shuffle read

上一節描述了 task 運行過程及 result 的處理過程,這一節描述 reducer(需要 shuffle 的 task )是如何獲取到輸入數據的。關於 reducer 如何處理輸入數據已經在上一章的 shuffle read 中解釋了。

問題:reducer 怎麼知道要去哪裏 fetch 數據?

 reducer 首先要知道 parent stage 中 ShuffleMapTask 輸出的 FileSegments 在哪個節點。這個信息在 ShuffleMapTask 完成時已經送到了 driver 的 mapOutputTrackerMaster,並存放到了 mapStatuses: HashMap  裏面 ,給定 stageId,可以獲取該 stage 中 ShuffleMapTasks 生成的 FileSegments 信息 Array[MapStatus],通過 Array(taskId) 就可以得到某個 task 輸出的 FileSegments 位置(blockManagerId)及每個 FileSegment 大小。

當 reducer 需要 fetch 輸入數據的時候,會首先調用 blockStoreShuffleFetcher 去獲取輸入數據(FileSegments)的位置。blockStoreShuffleFetcher 通過調用本地的 MapOutputTrackerWorker 去完成這個任務,MapOutputTrackerWorker 使用 mapOutputTrackerMasterActorRef 來與 mapOutputTrackerMasterActor 通信獲取 MapStatus 信息。blockStoreShuffleFetcher 對獲取到的 MapStatus 信息進行加工,提取出該 reducer 應該去哪些節點上獲取哪些 FileSegment 的信息,這個信息存放在 blocksByAddress 裏面。之後,blockStoreShuffleFetcher 將獲取 FileSegment 數據的任務交給 basicBlockFetcherIterator。

rdd.iterator()
=> rdd(e.g., ShuffledRDD/CoGroupedRDD).compute()
=> SparkEnv.get.shuffleFetcher.fetch(shuffledId, split.index, context, ser)
=> blockStoreShuffleFetcher.fetch(shuffleId, reduceId, context, serializer)
=> statuses = MapOutputTrackerWorker.getServerStatuses(shuffleId, reduceId)

=> blocksByAddress: Seq[(BlockManagerId, Seq[(BlockId, Long)])] = compute(statuses)
=> basicBlockFetcherIterator = blockManager.getMultiple(blocksByAddress, serializer)
=> itr = basicBlockFetcherIterator.flatMap(unpackBlock)

basicBlockFetcherIterator 收到獲取數據的任務後,會生成一個個 fetchRequest,每個 fetchRequest 包含去某個節點獲取若干個 FileSegments 的任務。圖中展示了 reducer-2 需要從三個 worker node 上獲取所需的白色 FileSegment (FS)。總的數據獲取任務由 blocksByAddress 表示,要從第一個 node 獲取 4 個,從第二個 node 獲取 3 個,從第三個 node 獲取 4 個。

爲了加快任務獲取過程,顯然要將總任務劃分爲子任務(fetchRequest),然後爲每個任務分配一個線程去 fetch。Spark 爲每個 reducer 啓動 5 個並行 fetch 的線程(Hadoop 也是默認啓動 5 個)。由於 fetch 來的數據會先被放到內存作緩衝,因此一次 fetch 的數據不能太多,Spark 設定不能超過 spark.reducer.maxMbInFlight=48MB注意這 48MB 的空間是由這 5 個 fetch 線程共享的,因此在劃分子任務時,儘量使得 fetchRequest 不超過48MB / 5 = 9.6MB。如圖在 node 1 中,Size(FS0-2) + Size(FS1-2) < 9.6MB 但是 Size(FS0-2) + Size(FS1-2) + Size(FS2-2) > 9.6MB,因此要在 t1-r2 和 t2-r2 處斷開,所以圖中有兩個 fetchRequest 都是要去 node 1 fetch。那麼會不會有 fetchRequest 超過 9.6MB?當然會有,如果某個 FileSegment 特別大,仍然需要一次性將這個 FileSegment fetch 過來。另外,如果 reducer 需要的某些 FileSegment 就在本節點上,那麼直接進行 local read。最後,將 fetch 來的 FileSegment 進行 deserialize,將裏面的 records 以 iterator 的形式提供給 rdd.compute(),整個 shuffle read 結束。

In basicBlockFetcherIterator:

// generate the fetch requests
=> basicBlockFetcherIterator.initialize()
=> remoteRequests = splitLocalRemoteBlocks()
=> fetchRequests ++= Utils.randomize(remoteRequests)

// fetch remote blocks
=> sendRequest(fetchRequests.dequeue()) until Size(fetchRequests) > maxBytesInFlight
=> blockManager.connectionManager.sendMessageReliably(cmId,
blockMessageArray.toBufferMessage)
=> fetchResults.put(new FetchResult(blockId, sizeMap(blockId)))
=> dataDeserialize(blockId, blockMessage.getData, serializer)

// fetch local blocks
=> getLocalBlocks()
=> fetchResults.put(new FetchResult(id, 0, () => iter))

下面再討論一些細節問題:

reducer 如何將 fetchRequest 信息發送到目標節點?目標節點如何處理 fetchRequest 信息,如何讀取 FileSegment 並回送給 reducer?

rdd.iterator() 碰到 ShuffleDependency 時會調用 BasicBlockFetcherIterator 去獲取 FileSegments。BasicBlockFetcherIterator 使用 blockManager 中的 connectionManager 將 fetchRequest 發送給其他節點的 connectionManager。connectionManager 之間使用 NIO 模式通信。其他節點,比如 worker node 2 上的 connectionManager 收到消息後,會交給 blockManagerWorker 處理,blockManagerWorker 使用 blockManager 中的 diskStore 去本地磁盤上讀取 fetchRequest 要求的 FileSegments,然後仍然通過 connectionManager 將 FileSegments 發送回去。如果使用了 FileConsolidation,diskStore 還需要 shuffleBlockManager 來提供 blockId 所在的具體位置。如果 FileSegment 不超過 spark.storage.memoryMapThreshold=8KB ,那麼 diskStore 在讀取 FileSegment 的時候會直接將 FileSegment 放到內存中,否則,會使用 RandomAccessFile 中 FileChannel 的內存映射方法來讀取 FileSegment(這樣可以將大的 FileSegment 加載到內存)。

當 BasicBlockFetcherIterator 收到其他節點返回的 serialized FileSegments 後會將其放到 fetchResults: Queue 裏面,並進行 deserialization,所以 fetchResults: Queue 就相當於在 Shuffle details 那一章提到的 softBuffer。如果 BasicBlockFetcherIterator 所需的某些 FileSegments 就在本地,會通過 diskStore 直接從本地文件讀取,並放到 fetchResults 裏面。最後 reducer 一邊從 FileSegment 中邊讀取 records 一邊處理。

After the blockManager receives the fetch request

=> connectionManager.receiveMessage(bufferMessage)
=> handleMessage(connectionManagerId, message, connection)

// invoke blockManagerWorker to read the block (FileSegment)
=> blockManagerWorker.onBlockMessageReceive()
=> blockManagerWorker.processBlockMessage(blockMessage)
=> buffer = blockManager.getLocalBytes(blockId)
=> buffer = diskStore.getBytes(blockId)
=> fileSegment = diskManager.getBlockLocation(blockId)
=> shuffleManager.getBlockLocation()
=> if(fileSegment < minMemoryMapBytes)
buffer = ByteBuffer.allocate(fileSegment)
else
channel.map(MapMode.READ_ONLY, segment.offset, segment.length)

每個 reducer 都持有一個 BasicBlockFetcherIterator,一個 BasicBlockFetcherIterator 理論上可以持有 48MB 的 fetchResults。每當 fetchResults 中有一個 FileSegment 被讀取完,就會一下子去 fetch 很多個 FileSegment,直到 48MB 被填滿。

BasicBlockFetcherIterator.next()
=> result = results.task()
=> while (!fetchRequests.isEmpty &&
(bytesInFlight == 0 || bytesInFlight + fetchRequests.front.size <= maxBytesInFlight)) {
sendRequest(fetchRequests.dequeue())
}
=> result.deserialize()

Discussion

架構部分其實沒有什麼好說的,就是設計時儘量功能獨立,模塊獨立,松耦合。BlockManager 設計的不錯,就是管的東西太多(數據塊、內存、磁盤、通信)。

這一章主要探討了系統中各個模塊是怎麼協同來完成 job 的生成、提交、運行、結果收集、結果計算以及 shuffle 的。貼了很多代碼,也畫了很多圖,雖然細節很多,但遠沒有達到源碼的細緻程度。如果有地方不明白的,請根據描述閱讀一下源碼吧。


Spark的Cache和Checkpoint區別和聯繫拾遺

Spark Job 邏輯執行圖和數據依賴解析

Spark Job 物理執行圖詳解

Spark Shuffle過程詳解

最新Hive/Hadoop高頻面試點小集合

歡迎點贊+收藏+轉發朋友圈素質三連

文章不錯?點個【在看】吧! 

本文分享自微信公衆號 - 大數據技術與架構(import_bigdata)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章