有時候會發現即使是讀取少量的數據,啓動延時可能也非常大,針對該現象進行分析,並提供一些解決思路。
背景
Spark 一次查詢過程可以簡單抽象爲 planning 階段和 execution 階段,在一個新的 Spark Session 中第一次查詢某數據的過程稱爲冷啓動,在這種情況下 planning 的耗時可能會比 execution 更長。
Spark 讀取數據冷啓動時,會從文件系統中獲取文件的一些元數據信息(location,size,etc.)用於優化,如果一個目錄下的文件過多,就會比較耗時(可能達到數十分鐘),該邏輯在 InMemoryFieIndex 中實現。
後續再次多次查詢則會在 FileStatusCache 中進行查詢,planning 階段性能也就大幅提升了,下文將探討 planning 階段如何加載元數據以及可能的一些優化點。
InMemoryFileIndex
before spark 2.1
spark 2.1 版本前,spark 直接從文件系統中查詢數據的元數據並將其緩存到內存中,元數據包括一個 partition 的列表和文件的一些統計信息(路徑,文件大小,是否爲目錄,備份數,塊大小,定義時間,訪問時間,數據塊位置信息)。一旦數據緩存後,在後續的查詢中,表的 partition 就可以在內存中進行下推,得以快速的查詢。
將元數據緩存在內存中雖然提供了很好的性能,但也存在2個缺點:在 spark 加載所有表分區的元數據之前,會阻塞查詢。對於大型分區表,遞歸的掃描文件系統以發現初始查詢文件的元數據可能會花費數分鐘,特別是當數據存儲在雲端。其次,表的所有元數據都需要放入內存中,增加了內存壓力。
after spark 2.1
spark 2.1 針對上述缺點進行了優化,可參考 SPARK-17861
- 將表分區元數據信息緩存到 catalog 中,例如 (hive metastore),因此可以在 PruneFileSourcePartitions 規則中提前進行分區發現,catalyse optimeizer 會在邏輯計劃中對分區進行修剪,避免讀取到不需要的分區文件信息。
- 文件統計現在可以在計劃期間內增量的,部分的緩存,而不是全部預先加載。Spark需要知道文件的大小以便在執行物理計劃時將它們劃分爲讀取任務。通過共享一個固定大小的250MB緩存(可配置),而不是將所有表文件統計信息緩存到內存中,在減少內存錯誤風險的情況下顯著加快重複查詢的速度。
舊錶可以使用 MSCK REPAIR TABLE
命令進行轉化,查看是否生效,如果 Partition Provider
爲 Catalog
則表示會從 catalog 中獲取分區信息
sql("describe formatted test_table")
.filter("col_name like '%Partition Provider%'").show
+-------------------+---------+-------+
| col_name|data_type|comment|
+-------------------+---------+-------+
|Partition Provider:| Catalog| |
+-------------------+---------+-------+
性能對比
出自官方blog,通過讀取一張表不同的分區數,觀察任務 execution time 和 planning time,在spark2.1之前 planning 階段的耗時是相同的,意味着讀取一個分區也需要掃描全表的 file status。
優化 HDFS 獲取 File 元數據性能
雖然優化了避免加載過多元數據的問題,但是單個分區下文件過多導致讀取文件元數據緩慢的問題並沒有解決。
在 SPARK-27801 中(將在 spark3.0 release),對一個目錄下多文件的場景進行了優化,性能有大幅度的提升。
使用 DistributedFileSystem.listLocatedStatus
代替了 fs.listStatus
+ getFileBlockLocations
的方式
-
listLocatedStatus
向 namenode 發起一次請求獲得file status
和file block location
信息 -
listStatus
獲取一系列的 file status 後,還要根據 file status 循環向 namenode 發起請求獲得file block location
信息
listLocatedStatus
// 對 namenode 只發起一次 listLocatedStatus 請求,在方法內部獲得每個文件 block location 信息
val statuses = fs.listLocatedStatus(path)
new Iterator[LocatedFileStatus]() {
def next(): LocatedFileStatus = remoteIter.next
def hasNext(): Boolean = remoteIter.hasNext
}.toArray
statuses.flatMap{
Some(f)
}
fs.listStatus + getFileBlockLocations (只展示核心代碼)
val statuses = fs.listStatus(path)
statuses.flatMap{
val locations = fs.getFileBlockLocations(f, 0, f.getLen).map { loc =>
if (loc.getClass == classOf[BlockLocation]) {
loc
} else {
new BlockLocation(loc.getNames, loc.getHosts, loc.getOffset, loc.getLength)
}
}
val lfs = new LocatedFileStatus(f.getLen, f.isDirectory, f.getReplication, f.getBlockSize,
f.getModificationTime, 0, null, null, null, null, f.getPath, locations)
if (f.isSymlink) {
lfs.setSymlink(f.getSymlink)
}
Some(lfs)
}
性能對比
實測一個57個分區,每個分區1445個文件的任務,性能提升6倍左右
打入 SPARK-27801 前
打入 SPARK-27801 後:
文件元數據讀取方式及元數據緩存管理
- 讀取數據時會先判斷分區的數量,如果分區數量小於等於
spark.sql.sources.parallelPartitionDiscovery.threshold (默認32)
,則使用 driver 循環讀取文件元數據,如果分區數量大於該值,則會啓動一個 spark job,分佈式的處理元數據信息(每個分區下的文件使用一個task進行處理) - 分區數量很多意味着 Listing leaf files task 的任務會很多,分區裏的文件數量多意味着每個 task 的負載高,使用 FileStatusCache 緩存文件狀態,默認的緩存
spark.sql.hive.filesourcePartitionFileCacheSize
爲 250MB
Tip
Listing leaf files task 的數量計算公式爲
val numParallelism = Math.min(paths.size, parallelPartitionDiscoveryParallelism)
其中,paths.size
爲需要讀取的分區數量,parallelPartitionDiscoveryParallelism
由參數 spark.sql.sources.parallelPartitionDiscovery.parallelism
控制,默認爲10000,目的是防止 task 過多,但從生產任務上觀察發現大多數 get status task 完成的時間都是毫秒級,可以考慮把這個值調低,減少任務啓動關閉的開銷,或者直接修改源碼將 paths.size
按一定比例調低,例如 paths.size/2
控制 task 數量之前
控制 task 數量之後
結語
spark 查詢冷啓動(獲取文件元數據性能)對比前幾個版本已經有非常大提升,降低了查詢的延時
-
SPARK-17861 在物理計劃中進行了優化,通過將分區信息存入 catalog ,避免了讀取時加載全量表的文件信息
-
SPARK-27801 優化讀取 hdfs 文件元數據的方式,之前
getFileBlockLocations
的方式是串行的,在文件數量很多的情況下速度會很慢,同時用listLocatedStatus
的方式減少了客戶端對 namenode 的直接調用,例如需要讀取的數據爲3個分區,每個分區 10k 個文件,之前客戶端需要訪問 namenode 的次數爲30k,現在爲3次 -
打入最新的 patch 和 優化 task 數量後,隨機找的一個生產任務 Listing Leaf files job 時間從數十秒減少到1S以內,不過有時候依舊存在毛刺,這與 namenode 和 機器的負載程度有關
一些思考,是否可以考慮用 Redis
替換 FileStatusCache
,在數據寫入的時候更新 Redis 中的 file status 信息,這樣就相當於所有的 spark 應用共享了 FileStatusCache
,減少了內存使用的同時也不再有讀數據冷啓動的問題了。
參考
scalable-partition-handling-for-cloud-native-architecture-in-apache-spark-2-1