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方法进行处理:

PreprocessTableInsertion的apply方法

因为我们插入的是hive表,所以我们的relation会匹配HiveTableRelation。下文源码分析中,我们都会基于hive表作为目标表的前提来讨论,但读者需要清楚hive表不是InsertIntoTable的唯一目标数据源。

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

2.1.1 partition column的规范化检查

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的重命名和转换

rename and cast of 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表完全一致:

output columns的强制转化

可以看到,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方法进行实际的数据写入工作:

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排序

在运行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方法的签名是:

FileFormatWriter.write调用的runJob方法的签名

我们看到,传入的rdd就是query对应的rdd,而传入的function是调用FileFormatWriter.executeTask方法。 FileFormatWriter.executeTask方法会根据写入的数据中是否存在动态分区的列来决定生成什么样的ExecuteWriteTask来执行数据写入任务:

生成ExecuteWriteTask对象

在我们的case中存在动态分区,所以我们讨论DynamicPartitionWriteTask,SingleDirectoryWriteTask比较简单,有兴趣的读者可自行阅读源码。

2.2.3 DynamicPartitionWriteTask

DynamicPartitionWriteTask的核心在其execute方法,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表的列映射机制谈了下自己的看法,如有不对之处,望读者指出,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容