Spark CommitCoordinator 保證資料一致性

郭俊JasonGuo發表於2018-09-26

原創文章,轉載請務必將下面這段話置於文章開頭處。
本文轉發自技術世界原文連結 http://www.jasongj.com/spark/committer/
本文所述內容均基於 2018年9月17日 Spark 最新 Release 2.3.1 版本,以及 hadoop-2.6.0-cdh-5.4.4

概述

Spark 輸出資料到 HDFS 時,需要解決如下問題:

  • 由於多個 Task 同時寫資料到 HDFS,如何保證要麼所有 Task 寫的所有檔案要麼同時對外可見,要麼同時對外不可見,即保證資料一致性
  • 同一 Task 可能因為 Speculation 而存在兩個完全相同的 Task 例項寫相同的資料到 HDFS中,如何保證只有一個 commit 成功
  • 對於大 Job(如具有幾萬甚至幾十萬 Task),如何高效管理所有檔案

commit 原理

本文通過 Local mode 執行如下 Spark 程式詳解 commit 原理

sparkContext.textFile("/json/input.zstd")
  .map(_.split(","))
  .saveAsTextFile("/jason/test/tmp")

在詳述 commit 原理前,需要說明幾個述語

  • Task,即某個 Application 的某個 Job 內的某個 Stage 的一個 Task
  • TaskAttempt,Task 每次執行都視為一個 TaskAttempt。對於同一個 Task,可能同時存在多個 TaskAttemp
  • Application Attempt,即 Application 的一次執行

在本文中,會使用如下縮寫

  • ${output.dir.root} 即輸出目錄根路徑
  • ${appAttempt} 即 Application Attempt ID,為整型,從 0 開始
  • ${taskAttemp} 即 Task Attetmp ID,為整型,從 0 開始

檢查 Job 輸出目錄

在啟動 Job 之前,Driver 首先通過 FileOutputFormat 的 checkOutputSpecs 方法檢查輸出目錄是否已經存在。若已存在,則直接丟擲 FileAlreadyExistsException
Check output path

Driver執行setupJob

Job 開始前,由 Driver(本例使用 local mode,因此由 main 執行緒執行)呼叫 FileOuputCommitter.setupJob 建立 Application Attempt 目錄,即 output.dir.root/temporary/{output.dir.root}/_temporary/{appAttempt}
Setup job

Task執行setupTask

由各 Task 執行 FileOutputCommitter.setupTask 方法(本例使用 local mode,因此由 task 執行緒執行)。該方法不做任何事情,因為 Task 臨時目錄由 Task 按需建立。
Setup task

按需建立 Task 目錄

本例中,Task 寫資料需要通過 TextOutputFormatgetRecordWriter 方法建立 LineRecordWriter。而建立前需要通過 FileOutputFormat.getTaskOutputPath設定 Task 輸出路徑,即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}/${fileName}。該 Task Attempt 所有資料均寫在該目錄下的檔案內
Create task output file

檢查是否需要 commit

Task 執行資料寫完後,通過 FileOutputCommitter.needsTaskCommit 方法檢查是否需要 commit 它寫在 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 下的資料。

檢查依據是 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 目錄是否存在
Need commmit task

如果需要 commit,並且開啟了 Output commit coordination,還需要通過 RPC 由 Driver 側的 OutputCommitCoordinator 判斷該 Task Attempt 是否可以 commit
Need commmit task detail

之所以需要由 Driver 側的 CommitCoordinator 判斷是否可以 commit,是因為可能由於 speculation 或者其它原因(如之前的 TaskAttemp 未被 Kill 成功)存在同一 Task 的多個 Attemp 同時寫資料且都申請 commit 的情況。

CommitCoordinator

當申請 commitTask 的 TaskAttempt 為失敗的 Attempt,則直接拒絕

若該 TaskAttempt 成功,並且 CommitCoordinator 未允許過該 Task 的其它 Attempt 的 commit 請求,則允許該 TaskAttempt 的 commit 請求

若 CommitCoordinator 之前已允許過該 TaskAttempt 的 commit 請求,則繼續同意該 TaskAttempt 的 commit 請求,即 CommitCoordinator 對該申請的處理是冪等的。

若該 TaskAttempt 成功,且 CommitCoordinator 之前已允許該 Task 的其它 Attempt 的 commit 請求,則直接拒絕當前 TaskAttempt 的 commit 請求
Coordinator handle request

OutputCommitCoordinator 為了實現上述功能,為每個 ActiveStage 維護一個如下 StageState

private case class StageState(numPartitions: Int) {
  val authorizedCommitters = Array.fill[TaskAttemptNumber](numPartitions)(NO_AUTHORIZED_COMMITTER)
  val failures = mutable.Map[PartitionId, mutable.Set[TaskAttemptNumber]]()
  }

該資料結構中,儲存了每個 Task 被允許 commit 的 TaskAttempt。預設值均為 NO_AUTHORIZED_COMMITTER

同時,儲存了每個 Task 的所有失敗的 Attempt

commitTask

當 TaskAttempt 被允許 commit 後,Task (本例由於使用 local model,因此由 task 執行緒執行)會通過如下方式 commitTask。

mapreduce.fileoutputcommitter.algorithm.version 的值為 1 (預設值)時,Task 將 taskAttemptPath 即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 重命令為 committedTaskPath 即 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
Commit task v1

mapreduce.fileoutputcommitter.algorithm.version 的值為 2,直接將taskAttemptPath 即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 內的所有檔案移動到 outputPath 即 ${output.dir.root}/
Commit task v2

commitJob

當所有 Task 都執行成功後,由 Driver (本例由於使用 local model,故由 main 執行緒執行)執行 FileOutputCommitter.commitJob

mapreduce.fileoutputcommitter.algorithm.version 的值為 1,則由 Driver 單執行緒遍歷所有 committedTaskPath 即 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt},並將其下所有檔案移動到 finalOutput 即 ${output.dir.root}
Commit job v1

mapreduce.fileoutputcommitter.algorithm.version 的值為 2,則無須移動任何檔案。因為所有 Task 的輸出檔案已在 commitTask 內被移動到 finalOutput 即 ${output.dir.root}
Commit job v2

所有 commit 過的 Task 輸出檔案移動到 finalOutput 即 ${output.dir.root} 後,Driver 通過 cleanupJob 刪除 ${output.dir.root}/_temporary/ 下所有內容
Cleanup job

recoverTask

上文所述的 commitTask 與 commitJob 機制,保證了一次 Application Attemp 中不同 Task 的不同 Attemp 在 commit 時的資料一致性

而當整個 Application retry 時,在之前的 Application Attemp 中已經成功 commit 的 Task 無須重新執行,其資料可直接恢復

恢復 Task 時,先獲取上一次的 Application Attempt,以及對應的 committedTaskPath,即 ${output.dir.root}/_temporary/${preAppAttempt}/${taskAttempt}

mapreduce.fileoutputcommitter.algorithm.version 的值為 1,並且 preCommittedTaskPath 存在(說明在之前的 Application Attempt 中該 Task 已被 commit 過),則直接將 preCommittedTaskPath 重新命名為 committedTaskPath

mapreduce.fileoutputcommitter.algorithm.version 的值為 2,無須恢復任何資料,因為在之前 Application Attempt 中 commit 過的 Task 的資料已經在 commitTask 中被移動到 ${output.dir.root}
Recover task

abortTask

中止 Task 時,由 Task 呼叫 FileOutputCommitter.abortTask 方法刪除 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
Abort task

abortJob

中止 Job 由 Driver 呼叫 FileOutputCommitter.abortJob 方法完成。該方法通過 FileOutputCommitter.cleanupJob 方法刪除 ${output.dir.root}/_temporary

總結

V1 vs. V2 committer 過程

V1 committer(即 mapreduce.fileoutputcommitter.algorithm.version 的值為 1),commit 過程如下

  • Task 執行緒將 TaskAttempt 資料寫入 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
  • commitTask 由 Task 執行緒將 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 移動到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
  • commitJob 由 Driver 單執行緒依次將所有 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt} 移動到 ${output.dir.root},然後建立 _SUCCESS 標記檔案
  • recoverTask 由 Task 執行緒將 ${output.dir.root}/_temporary/${preAppAttempt}/${preTaskAttempt} 移動到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}

V2 committer(即 mapreduce.fileoutputcommitter.algorithm.version 的值為 2),commit 過程如下

  • Task 執行緒將 TaskAttempt 資料寫入 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
  • commitTask 由 Task 執行緒將 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 移動到 ${output.dir.root}
  • commitJob 建立 _SUCCESS 標記檔案
  • recoverTask 無需任何操作

V1 vs. V2 committer 效能對比

V1 在 Job 執行結束後,在 Driver 端通過 commitJob 方法,單執行緒序列將所有 Task 的輸出檔案移動到輸出根目錄。移動以檔案為單位,當 Task 個數較多(大 Job,或者小檔案引起的大量小 Task),Name Node RPC 較慢時,該過程耗時較久。在實踐中,可能因此發生所有 Task 均執行結束,但 Job 不結束的問題。甚至 commitJob 耗時比 所有 Task 執行時間還要長

而 V2 在 Task 結束後,由 Task 在 commitTask 方法內,將自己的資料檔案移動到輸出根目錄。一方面,Task 結束時即移動檔案,不需等待 Job 結束才移動檔案,即檔案移動更早發起,也更早結束。另一方面,不同 Task 間並行移動檔案,極大縮短了整個 Job 內所有 Task 的檔案移動耗時

V1 vs. V2 committer 一致性對比

V1 只有 Job 結束,才會將資料檔案移動到輸出根目錄,才會對外可見。在此之前,所有檔案均在 ${output.dir.root}/_temporary/${appAttempt} 及其子檔案內,對外不可見。

當 commitJob 過程耗時較短時,其失敗的可能性較小,可認為 V1 的 commit 過程是兩階段提交,要麼所有 Task 都 commit 成功,要麼都失敗。

而由於上文提到的問題, commitJob 過程可能耗時較久,如果在此過程中,Driver 失敗,則可能發生部分 Task 資料被移動到 ${output.dir.root} 對外可見,部分 Task 的資料未及時移動,對外不可見的問題。此時發生了資料不一致性的問題

V2 當 Task 結束時,立即將資料移動到 ${output.dir.root},立即對外可見。如果 Application 執行過程中失敗了,已 commit 的 Task 資料仍然對外可見,而失敗的 Task 資料或未被 commit 的 Task 資料對外不可見。也即 V2 更易發生資料一致性問題

相關文章