基于Hive构建数据仓库时,通常在ETL过程中为了加快速度而提高任务的并行度,无论任务的类型是MapReduce还是Spark还是Flink,都会在数据写入Hive表时产生很多小文件。这里的小文件是指文件size小于HDFS配置的block块大小(目前默认配置是128MB)。在HDFS中,数据和元数据是分离的。数据文件被分割成block块文件,这些块文件存储在集群中的DataNode节点上并按副本因子进行复制,所有存储在HDFS上的文件或目录以及block的元信息(包含文件目录信息、位置信息、副本数和权限信息等)都会在NameNode的内存中以文件系统对象的形式存储,每个文件系统对象约占150B。文件系统对象是通过文件和它的块的数量来度量的。同一个192MB的文件由三个文件系统对象(1个文件inode+2个块)表示,并占用大约450字节的内存。与生成多个块的小文件相比,分割成较少块的大文件通常消耗更少的内存。一个128MB的数据文件由NameNode上的两个文件系统对象(1个文件inode+1个块)表示,并占用大约300字节的内存。相比之下,128个1 MB的文件由256个文件系统对象(128个文件索引节点+128个块)表示,大约占用38400字节。如果有1千万个小文件,每个文件占用一个block,则NameNode大约需要3GB内存空间,如果存储1亿个小文件,则NameNode需要30GB内存空间。其次,读写大量小文件的速度要远远小于读写几个大文件的速度,因为要频繁与NameNode交互导致NameNode处理队列过长和GC时间过长而产生延迟。故随着小文件的增多会严重影响到NameNode性能和制约集群的扩展。

目前我们构建数据仓库主要用到了Sqoop和SparkSQL。Sqoop用来从MySQL抽取业务系统数据到Hive构建原始数据层,然后根据具体的业务逻辑和数据分析需求用SparkSQL对Hive表原始数据进行处理加工并写回Hive构建更通用的数据层,这些ETL操作都是为了更方便和更高效地使用数据,从而尽可能地使隐藏在数据中的价值最大化。然而在追求ETL过程高效的同时,我们却忽视了接下来给NameNode造成的系统风险。下面对Sqoop和SparkSQL使用过程中为了提高处理效率而产生过多小文件的原因进行复盘,最后对Hive表小文件合并方法进行总结。

一、Sqoop生成Hive表产生小文件原因

Sqoop在Hadoop生态圈的存储系统(如Hive)与其他存储系统(如MySQL)间抽取数据的原理是将用户通过参数配置的Sqoop任务在提交运行时转换为MapReduce任务,下面是一个从MySQL每天增量抽取某个表T-1的数据到Hive中的样例:

sqoop import
--connect
jdbc:mysql://${nb_order_ip}/nb_order
--username
${username}
--password
${password}
--table
nb_biz_order
--columns
biz_order_no,biz_type,order_no,city_id,city_name,store_id
--where
"update_time BETWEEN '${last_date_begin}' AND '${last_date_end}'"
--hive-import
--num-mappers
9
--hive-database
default
--hive-table
nb_biz_order
--hive-delims-replacement
\00
--fields-terminated-by
\01
--lines-terminated-by
\n
--map-column-hive
create_time=TIMESTAMP,update_time=TIMESTAMP,play_time=TIMESTAMP
--hive-partition-key
record_date
--hive-partition-value
${last_date}
--direct

其中${}里的变量需要在任务提交时指定具体的值。

在抽取MySQL表数据到Hive中时,为了提高任务并行度往往将--num-mappers参数的值设置得比较大,这样在转换为MapReduce任务时,会启动--num-mappers指定个数的map任务去并行的抽取数据到Hive表中,这里由于计算逻辑是抽取数据没有其他复杂计算如聚合,所以没有reduce任务,在map任务完成后会生成--num-mappers指定个数的小文件。样例中生成的Hive分区表每天的分区中会产生9个小文件,Sqoop生成Hive非分区表也是会生成--num-mappers指定个数的小文件。

二、SparkSQL写Hive表产生小文件原因

使用SparkSQL进行数据处理产生过多小文件分两种情况:

1.当sql中含有join、group by相关的shuffle操作时,为了提高任务并行度可将spark.sql.shuffle.partitions参数设置得较大,该参数默认是200,但当数据落地时会产生200个小文件甚至更多;

2.当sql中没有shuffle操作时,输出文件的数量依赖于数据源的文件数量以及文件是否可切分等特性决定任务的并发度,如果数据源小文件过多也会导致输出小文件过多。

三、Hive表小文件合并方法总结

对于Sqoop和SparkSQL生成的Hive分区表,可以使用Hive为分区表提供的archive(归档或存档)功能,对历史分区中不再更新的数据进行归档。这里的归档仅仅是对分区下的小文件合并,合并后的文件总大小无变化,但会显著减少小文件个数。具体的归档和解归档操作是在可以访问Hive的客户端中(hive,spark-sql,spark-shell或Hue的Hive Query界面)执行如下语句:

# 启用archive功能
set hive.archive.enabled=true;

# 对分区表的指定分区进行归档
ALTER TABLE nb_biz_order ARCHIVE PARTITION(record_date='2020-10-13');

# 对分区表已归档的分区进行解归档
ALTER TABLE nb_biz_order UNARCHIVE PARTITION(record_date='2020-10-13');

对分区进行归档后,会在分区目录下生成一个名为data.har的目录,该目录下是合并后的文件及其文件索引信息。需要注意的是,使用archive归档后的分区不能再向其中追加和覆写数据,但解归档后可以追加和覆写。故对于不再更新变化的历史分区可以使用archive功能。

而使用SparkSQL生成的Hive分区表或非分区表,均可以在编写SparkSQL应用程序代码时,通过指定输出Hive表的partition数目来控制输出的文件数,具体代码如下:

// 保存为Hive非分区表
df.coalesce(repartitionNum).write.mode(toMode).saveAsTable(tableName)
// 保存为Hive分区表
df.coalesce(repartitionNum).write.mode(toMode).partitionBy(partitionKey).saveAsTable(tableName)

其中变量df是SparkSQL执行sql语句生成的DataFrame对象,repartitionNum是控制输出的partition数,最终表现为输出文件数,故可以通过减小repartitionNum来减少输出小文件的个数,toMode是写Hive的模式(比如"overwrite"或"append"),tableName是要写入的Hive表名,partitionKey是分区表的分区字段。

通过SparkSQL的coalesce接口减少输出文件数,本质是对数据做了重分区,并没有对文件进行合并,故通过SparkSQL的coalesce操作后的Hive表是可以对历史数据进行更新的。