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

1 問題描述

最近工作中有使用到spark sql的DataFrameWriter.insertInto函數(shù)往Hive表插入數(shù)據(jù)。在一次測(cè)試中,執(zhí)行到該函數(shù)時(shí),HDFS上產(chǎn)生了大量的小文件和目錄,最終導(dǎo)致測(cè)試環(huán)境的namenode發(fā)生failover。

經(jīng)過一些investigation后,發(fā)現(xiàn)是因?yàn)閐ataframe中的column list和hive表的column list排列順序不一致,導(dǎo)致一個(gè)基數(shù)(cardinality)非常大的column被誤認(rèn)為partition column,進(jìn)而產(chǎn)生了大量的臨時(shí)文件和目錄。

這個(gè)問題的解決方案本身很簡(jiǎn)單,只要確保dataframe的columns和hive表的columns保持名稱和順序都一致就可以了。但是,這個(gè)問題引發(fā)了我對(duì)spark sql insertInto函數(shù)內(nèi)部實(shí)現(xiàn)的好奇心。在我們的case中,data frame的column names和hive表的column names已經(jīng)是一樣的,只不過順序不完全一致,為什么spark沒有按列名匹配呢?另外,還想搞清楚每個(gè)dataframe partition的數(shù)據(jù)是怎樣寫入到各個(gè)hive partition中的。

ok,所以我們有了兩個(gè)問題:

? ? 1. DataFrameWriter.insertInto函數(shù)寫入hive表時(shí),是怎樣確定dataframe columns和hive表columns的對(duì)應(yīng)關(guān)系的?

? ? 2. 在將DataFrame的每個(gè)partition寫入hive表時(shí),是怎樣把單個(gè)RDD partition的數(shù)據(jù)寫入到單個(gè)或多個(gè)hive partition中的?

2 源碼分析

有了問題,我們就要帶著問題去查閱源碼,找尋答案(注意,本文的源碼版本為2.2.3) 。DataFrameWriter.insertInto函數(shù)的處理和執(zhí)行過程涉及了spark sql的analyzer,optimizer,spark planner, catalog等模塊,本文不打算go through每個(gè)環(huán)節(jié),只會(huì)對(duì)與上述兩個(gè)問題密切相關(guān)的模塊進(jìn)行源碼分析,包括:

? ? 1. 對(duì)insertInto語句進(jìn)行預(yù)處理的analyzer中的規(guī)則:PreprocessTableInsertion

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

2.1 PreprocessTableInsertion

DataFrameWriter.insertInto方法會(huì)生成邏輯計(jì)劃InsertIntoTable, 該邏輯計(jì)劃會(huì)被analyzer中的規(guī)則PreprocessTableInsertion預(yù)處理,PreprocessTableInsertion會(huì)調(diào)用其preprocess方法進(jìn)行處理:

PreprocessTableInsertion的apply方法

因?yàn)槲覀儾迦氲氖莌ive表,所以我們的relation會(huì)匹配HiveTableRelation。下文源碼分析中,我們都會(huì)基于hive表作為目標(biāo)表的前提來討論,但讀者需要清楚hive表不是InsertIntoTable的唯一目標(biāo)數(shù)據(jù)源。

來看看PreprocessTableInsertion的preprocess方法里做了什么:

2.1.1 partition column的規(guī)范化檢查

partition column的規(guī)范化檢查

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

1. 做大小寫轉(zhuǎn)換處理,將所有列名都轉(zhuǎn)換成小寫;

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

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

在我們的case中,通過DataFrameWriter.insertInto方法插入數(shù)據(jù),并沒有指定partition columns,所以在這里我們的insert.partition是一個(gè)空map。

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

2.1.2 output columns的重命名和轉(zhuǎn)換

rename and cast of output columns

做完partition columns的規(guī)范化后,preprocess方法會(huì)判斷normalizedPartSpec是否為空,

如果不為空,則說明用戶指定了分區(qū)信息,則直接將normalizedPartSpec作為insertIntoTable邏輯計(jì)劃的分區(qū)信息。

如果為空,則說明用戶沒有指定分區(qū)信息(比如直接調(diào)用DataFrameWriter.insertInto方法就不會(huì)指定分區(qū)信息),那么spark會(huì)將目標(biāo)hive表的分區(qū)列partColNames作為insertIntoTable邏輯計(jì)劃的分區(qū)信息。注意,這里partColNames.map(_ -> None).toMap生成的是一個(gè)partition column name到partition column value的map,這里所有partition column name都映射為None,表示所有分區(qū)列都是動(dòng)態(tài)分區(qū)列。

最后,不管normalizedPartSpec是否為空,spark都會(huì)調(diào)用castAndRenameChildOutput方法將insertIntoTable邏輯計(jì)劃的query的output columns強(qiáng)制重命名和轉(zhuǎn)換成和目標(biāo)hive表完全一致:

output columns的強(qiáng)制轉(zhuǎn)化

可以看到,spark并沒有根據(jù)列名來映射query和hive表的column list,而是直接根據(jù)column排列的順序一一比對(duì),只要不一致就直接將query的column重名為hive表的對(duì)應(yīng)column,如果類型不匹配則會(huì)進(jìn)行強(qiáng)制類型轉(zhuǎn)換。是不是有點(diǎn)暴力?

2.2 InsertIntoHiveTable

經(jīng)過PreprocessTableInsertion規(guī)則處理后的InsertIntoTable邏輯計(jì)劃會(huì)進(jìn)一步被規(guī)則HiveAnalysis處理。HiveAnalysis規(guī)則會(huì)將InsertIntoTable邏輯計(jì)劃轉(zhuǎn)換成InsertIntoHiveTable邏輯計(jì)劃。

InsertIntoHiveTable繼承自RunnableCommand, 而RunnableCommand最終都會(huì)被轉(zhuǎn)換成物理計(jì)劃ExecutedCommandExec, 本文不討論spark的物理執(zhí)行計(jì)劃,關(guān)于spark邏輯計(jì)劃到物理計(jì)劃的轉(zhuǎn)換讀者可閱讀SparkStrategies類的源碼,上面提到的RunnableCommand邏輯計(jì)劃就是在SparkStrategies的BasicOperators策略中被轉(zhuǎn)換成ExecutedCommandExec物理計(jì)劃的。

ExecutedCommandExec執(zhí)行時(shí)最終會(huì)調(diào)用對(duì)應(yīng)RunnableCommand對(duì)象的run方法,在我們這里就是InsertIntoHiveTable的run方法。下面我們就來看看InsertIntoHiveTable的run方法主要做了什么。

2.2.1 InsertIntoHiveTable.run方法

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

FileFormatWriter.write called in InsertIntoHiveTable.run

2.2.2 FileFormatWriter.write方法

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

Sort the query by partition columns and run spark job to write data

1. 按partition columns排序

在運(yùn)行spark job進(jìn)行數(shù)據(jù)寫入之前,F(xiàn)ileFormatWriter.write方法會(huì)先判斷InsertIntoHiveTable中的query的ordering是否滿足hive partition的要求,即是否已經(jīng)按照hive的partition columns排過序了(這里同樣會(huì)檢查bucket和非partition column的ordering要求)。

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

2. run spark job寫入數(shù)據(jù)

最后FileFormatWriter.write方法會(huì)調(diào)用SparkContext.runJob方法起一個(gè)spark job來執(zhí)行數(shù)據(jù)寫入的任務(wù)。這個(gè)runJob方法的簽名是:

FileFormatWriter.write調(diào)用的runJob方法的簽名

我們看到,傳入的rdd就是query對(duì)應(yīng)的rdd,而傳入的function是調(diào)用FileFormatWriter.executeTask方法。 FileFormatWriter.executeTask方法會(huì)根據(jù)寫入的數(shù)據(jù)中是否存在動(dòng)態(tài)分區(qū)的列來決定生成什么樣的ExecuteWriteTask來執(zhí)行數(shù)據(jù)寫入任務(wù):

生成ExecuteWriteTask對(duì)象

在我們的case中存在動(dòng)態(tài)分區(qū),所以我們討論DynamicPartitionWriteTask,SingleDirectoryWriteTask比較簡(jiǎn)單,有興趣的讀者可自行閱讀源碼。

2.2.3 DynamicPartitionWriteTask

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

DynamicPartitionWriteTask.execute方法

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

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

我們接著說,找到每行數(shù)據(jù)的partition columns后,DynamicPartitionWriteTask.execute方法會(huì)判斷當(dāng)前行和上一行是否同屬一個(gè)partition,如果不是,則認(rèn)為在當(dāng)前partition數(shù)據(jù)中發(fā)現(xiàn)了一個(gè)新的hive partition,相應(yīng)地就會(huì)在HDFS上新建一個(gè)目錄來存放該partition的數(shù)據(jù)文件。因?yàn)榍懊嫖覀円呀?jīng)按hive partition columns排過序了,所以這里的邏輯是合理的。新建目錄和文件在方法newOutputWriter中完成。

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

3 回答問題

ok,分析完了,現(xiàn)在來回答文章開頭提出的兩個(gè)問題:

? ? 1. DataFrameWriter.insertInto函數(shù)寫入hive表時(shí),是怎樣確定dataframe columns和hive表columns的對(duì)應(yīng)關(guān)系的?

答:在進(jìn)行邏輯計(jì)劃的analysis時(shí),PreprocessTableInsertion規(guī)則是按照列順序?qū)ataframe columns映射到hive表columns的(強(qiáng)制重命名和類型轉(zhuǎn)換);在執(zhí)行數(shù)據(jù)寫入hive表任務(wù)的DynamicPartitionWriteTask中,又是根據(jù)列名進(jìn)行映射的。

? ? 2. 在將DataFrame的每個(gè)partition寫入hive表時(shí),是怎樣把單個(gè)RDD partition的數(shù)據(jù)寫入到單個(gè)或多個(gè)hive partition中的?

答:DynamicPartitionWriteTask處理的單個(gè)RDD partition數(shù)據(jù)是已經(jīng)按partition columns拍過序的,所以DynamicPartitionWriteTask可以在遍歷每行數(shù)據(jù)時(shí)判斷當(dāng)前行數(shù)據(jù)的partition是否和上一行數(shù)據(jù)不一致,如果不一致則生成一個(gè)新的partition的output writer將數(shù)據(jù)寫到新的hive partition對(duì)應(yīng)的文件中去。

4 總結(jié)

本文從工作中遇到的大量文件夾和文件問題出發(fā),剖析了DataFrameWriter.insertInto函數(shù)涉及的兩個(gè)重要模塊:PreprocessTableInsertion規(guī)則和InsertIntoHiveTable邏輯計(jì)劃的實(shí)現(xiàn)細(xì)節(jié),解釋了為什么會(huì)出現(xiàn)大量文件夾和文件的問題,并對(duì)spark中query和hive表的列映射機(jī)制談了下自己的看法,如有不對(duì)之處,望讀者指出,謝謝。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容