Spark DataFrame使用問題記錄:insertInto引起大量文件問題

1 問題描述

最近工作中有使用到spark sql的DataFrameWriter.insertInto函數往Hive表插入數據。在一次測試中,執行到該函數時,HDFS上產生了大量的小文件和目錄,最終導致測試環境的namenode發生failover。

經過一些investigation後,發現是因爲dataframe中的column list和hive表的column list排列順序不一致,導致一個基數(cardinality)非常大的column被誤認爲partition column,進而產生了大量的臨時文件和目錄。

這個問題的解決方案本身很簡單,只要確保dataframe的columns和hive表的columns保持名稱和順序都一致就可以了。但是,這個問題引發了我對spark sql insertInto函數內部實現原理的好奇心。在我們的case中,data frame的column names和hive表的column names已經是一樣的,只不過順序不完全一致,爲什麼spark沒有按列名匹配呢?另外,還想搞清楚每個dataframe partition的數據是怎樣寫入到各個hive partition中的。

ok,所以我們有了兩個問題:

    1. DataFrameWriter.insertInto函數寫入hive表時,是怎樣確定dataframe columns和hive表columns的對應關係的?

    2. 在將DataFrame的每個partition寫入hive表時,是怎樣把單個RDD partition的數據寫入到單個或多個hive partition中的?

2 源碼分析

有了問題,我們就要帶着問題去查閱源碼,找尋答案(注意,本文的源碼版本爲2.2.3) 。DataFrameWriter.insertInto函數的處理和執行過程涉及了spark sql的analyzer,optimizer,spark planner, catalog等模塊,本文不打算go through每個環節,只會對與上述兩個問題密切相關的模塊進行源碼分析,包括:

    1. 對insertInto語句進行預處理的analyzer中的規則:PreprocessTableInsertion

    2. 將數據寫入hive表的邏輯計劃(logical plan):InsertIntoHiveTable

2.1 PreprocessTableInsertion

DataFrameWriter.insertInto方法會生成邏輯計劃InsertIntoTable, 該邏輯計劃會被analyzer中的規則PreprocessTableInsertion預處理,PreprocessTableInsertion會調用其preprocess方法進行處理:

因爲我們插入的是hive表,所以我們的relation會匹配HiveTableRelation。下文源碼分析中,我們都會基於hive表作爲目標表的前提來討論,但讀者需要清楚hive表不是InsertIntoTable的唯一目標數據源。

來看看PreprocessTableInsertion的preprocess方法裏做了什麼:

2.1.1 partition column的規範化檢查

preprocess方法會對傳入的partition columns進行normalize處理,這裏的insert.partition是在insert into語句中指定的partition columns信息,partColNames是hive表的partition columns信息。 PartitioningUtils.normalizePartitionSpec方法做了以下事情:

1. 做大小寫轉換處理,將所有列名都轉換成小寫;

2. 檢查指定的partition columns是否都是hive表的partition column;

3. 檢查指定的partition columns是否有重複,如果有則直接拋出異常。

在我們的case中,通過DataFrameWriter.insertInto方法插入數據,並沒有指定partition columns,所以在這裏我們的insert.partition是一個空map。

然後,preprocess方法會抽取出所有的static partition columns (就是在insert into 語句中指定的常量分區列,例如,insert into tableA partition (dt='2019-06-18') ...),除了static partition columns以外的partition columns就是dynamic partition columns。hive表中除了static partition columns以外的所有columns(包括dynamic partition columns和非分區columns)都需要由insert.query提供,所以這裏會驗證expectedColumns和insert.query.schema的長度是否匹配,如果不匹配則直接拋出異常。

2.1.2 output columns的重命名和轉換

做完partition columns的規範化後,preprocess方法會判斷normalizedPartSpec是否爲空,

如果不爲空,則說明用戶指定了分區信息,則直接將normalizedPartSpec作爲insertIntoTable邏輯計劃的分區信息。

如果爲空,則說明用戶沒有指定分區信息(比如直接調用DataFrameWriter.insertInto方法就不會指定分區信息),那麼spark會將目標hive表的分區列partColNames作爲insertIntoTable邏輯計劃的分區信息。注意,這裏partColNames.map(_ -> None).toMap生成的是一個partition column name到partition column value的map,這裏所有partition column name都映射爲None,表示所有分區列都是動態分區列。

最後,不管normalizedPartSpec是否爲空,spark都會調用castAndRenameChildOutput方法將insertIntoTable邏輯計劃的query的output columns強制重命名和轉換成和目標hive表完全一致:

可以看到,spark並沒有根據列名來映射query和hive表的column list,而是直接根據column排列的順序一一比對,只要不一致就直接將query的column重名爲hive表的對應column,如果類型不匹配則會進行強制類型轉換。是不是有點暴力?

2.2 InsertIntoHiveTable

經過PreprocessTableInsertion規則處理後的InsertIntoTable邏輯計劃會進一步被規則HiveAnalysis處理。HiveAnalysis規則會將InsertIntoTable邏輯計劃轉換成InsertIntoHiveTable邏輯計劃。

InsertIntoHiveTable繼承自RunnableCommand, 而RunnableCommand最終都會被轉換成物理計劃ExecutedCommandExec, 本文不討論spark的物理執行計劃,關於spark邏輯計劃到物理計劃的轉換讀者可閱讀SparkStrategies類的源碼,上面提到的RunnableCommand邏輯計劃就是在SparkStrategies的BasicOperators策略中被轉換成ExecutedCommandExec物理計劃的。

ExecutedCommandExec執行時最終會調用對應RunnableCommand對象的run方法,在我們這裏就是InsertIntoHiveTable的run方法。下面我們就來看看InsertIntoHiveTable的run方法主要做了什麼。

2.2.1 InsertIntoHiveTable.run方法

在正式寫入數據之前,InsertIntoHiveTable.run方法會先獲取和設置一系列的元數據信息,比如hive表的location,文件格式,壓縮算法等。這裏不討論這些細節,有興趣的讀者可查閱InsertIntoHiveTable類的源碼。這裏主要講一下寫數據的過程,InsertIntoHiveTable.run方法調用了FileFormatWriter.write方法進行實際的數據寫入工作:

2.2.2 FileFormatWriter.write方法

FileFormatWriter.write方法最核心的代碼如下:

1. 按partition columns排序

在運行spark job進行數據寫入之前,FileFormatWriter.write方法會先判斷InsertIntoHiveTable中的query的ordering是否滿足hive partition的要求,即是否已經按照hive的partition columns排過序了(這裏同樣會檢查bucket和非partition column的ordering要求)。

如果滿足要求,則直接使用InsertIntoHiveTable中的query,否則就要加一個SortExec的物理計劃對query的數據按照partition columns進行一次排序(如果有bucket或非partition column的ordering要求,也會將其加入進行排序),注意這裏的global=false, 所以是每個partition內部的局部排序,不是全局排序。

2. run spark job寫入數據

最後FileFormatWriter.write方法會調用SparkContext.runJob方法起一個spark job來執行數據寫入的任務。這個runJob方法的簽名是:

我們看到,傳入的rdd就是query對應的rdd,而傳入的function是調用FileFormatWriter.executeTask方法。 FileFormatWriter.executeTask方法會根據寫入的數據中是否存在動態分區的列來決定生成什麼樣的ExecuteWriteTask來執行數據寫入任務:

在我們的case中存在動態分區,所以我們討論DynamicPartitionWriteTask,SingleDirectoryWriteTask比較簡單,有興趣的讀者可自行閱讀源碼。

2.2.3 DynamicPartitionWriteTask

DynamicPartitionWriteTask的核心在其execute方法,DynamicPartitionWriteTask.execute方法的核心代碼:

DynamicPartitionWriteTask.execute方法會遍歷單個rdd partition的每行數據,獲取每行數據的partition columns。這裏的getPartitionColsAndBucketId是一個UnsafeProjection對象,用於從row中抽取出partition和bucket columns。注意,這裏的抽取方法是根據column name找到每個hive表partition column在row中的column index,也就是說這裏我們是按列名而不是順序匹配Hive表和query的columns的。

看到這裏,有沒有覺得spark做得有點不合理?既然前面在PreprocessTableInsertion已經按列的順序做了columns的強制重命名和類型轉換,那這裏的按列名查找豈不是很多餘?個人覺得PreprocessTableInsertion對Hive表和query的columns的映射機制可以做的更細化一些。比如,在我們的case中,query(data frame)和Hive表的column名字是一樣的,只是順序不一致而已,在這種情況下就不應該按列順序做強制重命名和類型轉換。我們後來修改了spark的代碼,在PreprocessTableInsertion中去掉了按列順序重命名的步驟,然後我們用重新編譯的spark測試了我們的case,結果一切正常,沒有出現大量文件的問題。當然,這只是針對我們的case,我們的修改也只是for test purpose. 至於該如何改進spark的這個行爲,留給讀者思考。

我們接着說,找到每行數據的partition columns後,DynamicPartitionWriteTask.execute方法會判斷當前行和上一行是否同屬一個partition,如果不是,則認爲在當前partition數據中發現了一個新的hive partition,相應地就會在HDFS上新建一個目錄來存放該partition的數據文件。因爲前面我們已經按hive partition columns排過序了,所以這裏的邏輯是合理的。新建目錄和文件在方法newOutputWriter中完成。

最終,每條數據都會被寫入到HDFS文件中:currentWriter.write(getOutputRow(row)). 注意,這裏的getOutputRow也是根據列名而不是列順序從row中獲取需要寫入到HDFS文件的數據的。

3 回答問題

ok,分析完了,現在來回答文章開頭提出的兩個問題:

    1. DataFrameWriter.insertInto函數寫入hive表時,是怎樣確定dataframe columns和hive表columns的對應關係的?

答:在進行邏輯計劃的analysis時,PreprocessTableInsertion規則是按照列順序將dataframe columns映射到hive表columns的(強制重命名和類型轉換);在執行數據寫入hive表任務的DynamicPartitionWriteTask中,又是根據列名進行映射的。

    2. 在將DataFrame的每個partition寫入hive表時,是怎樣把單個RDD partition的數據寫入到單個或多個hive partition中的?

答:DynamicPartitionWriteTask處理的單個RDD partition數據是已經按partition columns拍過序的,所以DynamicPartitionWriteTask可以在遍歷每行數據時判斷當前行數據的partition是否和上一行數據不一致,如果不一致則生成一個新的partition的output writer將數據寫到新的hive partition對應的文件中去。

4 總結

本文從工作中遇到的大量文件夾和文件問題出發,剖析了DataFrameWriter.insertInto函數涉及的兩個重要模塊:PreprocessTableInsertion規則和InsertIntoHiveTable邏輯計劃的實現細節,解釋了爲什麼會出現大量文件夾和文件的問題,並對spark中query和hive表的列映射機制談了下自己的看法,如有不對之處,望讀者指出,謝謝。

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