1. 为什么Hive小文件问题值得单独拎出来讲
干过离线数仓的人基本都遇到过这种场景:一个分区底下躺着几千甚至上万个几十KB的小文件,NameNode内存被撑得难受,下游任务拉取数据时开几千个Map任务,每个任务跑几秒钟就结束,光调度开销就吃掉了大半时间。更难受的是,有些任务本来跑十分钟能出结果,因为小文件太多,硬生生拖到一个小时。
Hive小文件合并这件事,说大不大,说小也不小。它不是一个“配个参数就完事”的活儿,而是需要根据数据流向、任务类型、存储格式、下游消费方式来综合判断的系统性调优动作。我见过太多团队在这上面踩坑:有人在Map输入端加了合并,结果任务反而变慢了;有人在输出端开了合并,小文件是少了,但单个文件大到离谱,下游又不好并行读。
这篇内容就把Hive小文件合并的两个核心切入点——Map输入端合并和Map/Reduce输出端合并——从头到尾拆一遍。不管你是刚接触Hive不久的新手,还是已经调过几次参数但总觉得效果不稳定的老手,都能从中找到可以直接抄作业的配置方案和判断逻辑。
核心关键词先摆出来:Hive小文件合并、Map输入端合并、Map/Reduce输出端合并、CombineHiveInputFormat、merge mapfiles、merge mapredfiles、NameNode内存压力、任务调度开销。这些词后面会反复出现,但不会硬堆,而是跟着实际场景走。
适合谁看?三类人最对口:一是负责离线数仓日常运维、经常被小文件告警骚扰的工程师;二是做Hive SQL性能优化、需要从执行计划层面理解合并逻辑的开发者;三是刚接手一个“历史遗留小文件重灾区”集群、不知道从哪下手的数据平台同学。下面直接进入正题。
2. 小文件到底是怎么产生的,以及它为什么这么烦人
2.1 小文件的四个主要来源
在动手合并之前,得先搞清楚小文件是从哪冒出来的。不同来源对应不同的合并策略,搞错了方向就是白费力气。
第一个来源是动态分区插入。这是最常见的小文件制造机。一条INSERT INTO ... PARTITION(dt)语句,如果动态分区字段基数很大,每个分区写入的数据量又很小,就会在每个分区下生成至少一个文件。假设有500个动态分区,每个分区只写了几百行,那就是500个小文件起步。如果同时开了多个Reducer,每个Reducer在每个分区都写一个文件,数量直接翻倍。
第二个来源是Reduce任务数量过多。默认情况下Hive会根据数据量估算Reducer个数,但估算逻辑有时候过于保守,导致Reducer数量远超实际需要。每个Reducer输出一个文件,Reducer越多,文件越碎。尤其是那些SELECT ... GROUP BY之后数据量急剧缩小的任务,Reducer数量没跟着降下来,小文件就出来了。
第三个来源是频繁的增量写入。有些团队采用“每小时跑一次增量”的模式,每次增量只写入少量数据,一天下来就是24个小文件,一个月就是720个。时间一长,分区内的文件数量就失控了。
第四个来源是数据源本身就很碎。比如从消息队列落地到HDFS的数据,或者从多个上游系统汇总过来的数据,原始文件粒度就很细。这种情况下,小文件问题在进入Hive之前就已经存在了。
2.2 小文件带来的三重伤害
小文件的危害不是单一的,它像多米诺骨牌一样,会引发一连串连锁反应。
第一重伤害是NameNode内存压力。HDFS中每个文件、每个目录、每个Block在NameNode内存里都占一份元数据,大约150字节。一万个小文件就是1.5MB,看起来不多,但如果有几十万个分区,每个分区几千个小文件,总量就非常可观了。NameNode内存一旦吃紧,整个集群的读写都会变慢,严重时直接触发Full GC,集群卡死。
第二重伤害是任务调度开销。Hive在读取数据时,每个小文件通常会对应一个Map任务(除非用了合并输入格式)。一个Map任务的启动和初始化开销大约1到3秒,如果文件只有几十KB,实际读取可能不到1秒,那大部分时间都花在调度上了。一万个小文件就是一万个Map任务,光调度就能跑十几分钟。
第三重伤害是存储和计算资源浪费。小文件在HDFS上会占用完整的Block,哪怕文件只有1KB,也会占一个Block的元数据槽位。在计算层面,每个Map任务都要分配Container,Container的申请和释放都是走YARN的,频繁申请释放对ResourceManager也是压力。
这里有个容易被忽略的点:小文件问题在数据量小的时候不明显,很多团队等到集群规模上来了才发现,但那时候历史数据已经积重难返,清理成本非常高。所以从建仓第一天起就应该把合并策略纳入规范。
3. Map输入端合并:让读取阶段就少开任务
3.1 Map输入端合并的核心逻辑
Map输入端合并,顾名思义,是在Hive读取数据、生成Map任务之前,先把多个小文件“逻辑上”合并成一个输入分片。注意这里说的是“逻辑上”,因为HDFS上的物理文件并没有被真正合并,只是Hive在切分输入的时候,把多个小文件打包给同一个Map任务处理。
这个动作的核心价值在于:减少Map任务数量。原本一万个小文件要开一万个Map,合并之后可能只需要开几百个Map,调度开销直接降下来。而且因为每个Map处理的数据量变大了,任务运行时间更“饱满”,整体资源利用率更高。
实现这个逻辑的关键类是CombineHiveInputFormat,它是Hive默认的输入格式(从Hive 0.10开始)。它继承自CombineFileInputFormat,在切分输入分片时,会考虑文件的物理位置、文件大小、是否可切分等因素,把多个小文件合并到一个分片里。
3.2 关键参数与配置方法
Map输入端合并主要靠三个参数控制,都在hive-site.xml或者会话级别设置。
-- 开启Map输入端合并,默认就是true SET hive.input.format=org.apache.hadoop.hive.ql.io.CombineHiveInputFormat; -- 每个Map任务处理的最大数据量,单位字节,默认256MB SET mapreduce.input.fileinputformat.split.maxsize=268435456; -- 每个Map任务处理的最小数据量,单位字节,默认1字节 SET mapreduce.input.fileinputformat.split.minsize=1; -- 节点级别的分片大小,影响同一节点上的合并粒度 SET mapreduce.input.fileinputformat.split.minsize.per.node=1; -- 机架级别的分片大小,影响同一机架上的合并粒度 SET mapreduce.input.fileinputformat.split.minsize.per.rack=1;这几个参数的关系需要理清楚。maxsize决定了一个Map任务最多处理多少数据,如果设得太大,单个Map跑太久,容易拖尾;设得太小,合并效果不明显。minsize和per.node、per.rack配合使用,控制合并的粒度:如果多个小文件在同一个节点上,且总大小不超过maxsize,就会被合并到一个分片;如果跨节点,则看机架级别的配置。
我一般建议的起始配置是:maxsize设为256MB或512MB,minsize保持默认1字节,per.node和per.rack也保持默认。这样Hive会尽可能把同一节点上的小文件合并,同时不会让单个Map任务过大。
3.3 什么情况下Map输入端合并会“帮倒忙”
Map输入端合并不是万能的,有些场景下开了反而更慢。
场景一:文件本身已经很大且可切分。如果分区下的文件都是几百MB的Parquet或ORC文件,它们本身就可以被切分成多个分片,CombineHiveInputFormat的合并逻辑反而会增加额外的判断开销。这种情况下,用HiveInputFormat可能更直接。
场景二:数据本地性要求极高。合并分片时,如果为了凑够maxsize而把不同节点上的文件合并到一个分片,就会破坏数据本地性,Map任务需要跨网络拉取数据。对于网络带宽紧张的环境,这可能得不偿失。
场景三:下游对Map数量有硬性要求。有些任务依赖固定的Map数量来做并行控制,合并之后Map数量变了,可能导致下游逻辑出错。这种情况虽然少见,但一旦遇到就很隐蔽。
实操心得:判断是否该开Map输入端合并,最直接的方法是看任务执行计划里的Map数量。如果Map数量远大于文件数量除以预期每Map处理量,说明合并没生效或者参数不合理。另外,可以对比开合并前后的任务总耗时,如果开了之后反而变慢,就要检查是不是数据本地性被破坏了。
4. Map/Reduce输出端合并:从源头控制文件数量
4.1 输出端合并的两种模式
Map/Reduce输出端合并,是在任务写完数据之后,额外启动一个合并任务,把同一分区下的小文件合并成更大的文件。Hive提供了两种模式,分别对应不同的参数。
第一种是merge mapfiles模式,对应参数hive.merge.mapfiles,默认true。它针对的是只有Map没有Reduce的任务(比如SELECT * FROM table或者INSERT INTO ... SELECT ...没有聚合操作)。这类任务每个Map输出一个文件,如果Map数量多,输出文件就多。开启这个参数后,Hive会在任务结束后启动一个合并任务,把这些输出文件合并。
第二种是merge mapredfiles模式,对应参数hive.merge.mapredfiles,默认false。它针对的是有Reduce的任务。Reduce任务每个Reducer输出一个文件,如果Reducer数量多,输出文件也多。开启这个参数后,Hive会在Reduce任务结束后启动合并任务。
还有一个辅助参数hive.merge.size.per.task,默认256MB,控制合并后每个文件的目标大小。以及hive.merge.smallfiles.avgsize,默认16MB,当输出文件的平均大小小于这个值时,才会触发合并。
-- 开启Map输出合并,默认true SET hive.merge.mapfiles=true; -- 开启Map/Reduce输出合并,默认false,建议开启 SET hive.merge.mapredfiles=true; -- 合并后每个文件的目标大小,默认256MB SET hive.merge.size.per.task=268435456; -- 输出文件平均大小小于此值才触发合并,默认16MB SET hive.merge.smallfiles.avgsize=16777216; -- 是否在合并时使用压缩,根据存储格式决定 SET hive.merge.orcfile.stripe.level=true;4.2 输出端合并的执行过程
输出端合并的执行过程可以拆成三步。
第一步是判断是否触发合并。任务结束后,Hive会统计输出目录下所有文件的数量和总大小,计算平均文件大小。如果平均大小小于hive.merge.smallfiles.avgsize,并且对应的merge参数是true,就触发合并。
第二步是启动合并任务。这个合并任务本身也是一个MapReduce任务(或者Tez任务),它读取输出目录下的所有小文件,按照hive.merge.size.per.task指定的大小重新切分,写入临时目录。
第三步是替换原文件。合并任务完成后,Hive会把临时目录下的合并文件移动到原输出目录,删除原来的小文件。这个过程对下游是透明的,下游看到的还是同一个分区路径,只是文件数量变少了。
这里有个细节值得注意:合并任务本身也会消耗资源。如果输出数据量很大,合并任务可能跑得比原任务还久。所以hive.merge.size.per.task不能设得太大,否则合并任务本身就成了瓶颈。
4.3 输出端合并的参数调优经验
hive.merge.size.per.task这个参数最需要根据实际情况调整。默认256MB对于大多数场景是合适的,但如果下游是Spark任务,Spark的默认分区大小是128MB,那合并成256MB的文件反而会导致Spark读取时再切分。这种情况下,把hive.merge.size.per.task设为128MB更合理。
hive.merge.smallfiles.avgsize默认16MB,意思是平均文件大小小于16MB才合并。如果分区下文件平均大小是20MB,但文件数量很多,这个参数就不会触发合并。有些团队会把它调大到64MB甚至128MB,让更多场景触发合并。但调太大也有风险:如果输出数据本身就不多,合并后可能只剩一个文件,下游并行度不够。
注意:输出端合并是在任务结束后额外启动一个任务,这个任务也会占用YARN资源。如果集群资源紧张,合并任务可能排队很久才执行,导致整个数据链路延迟。建议在资源充足的时段跑合并,或者把合并任务放到单独的队列里。
5. 两种合并方式的配合使用与场景选择
5.1 什么时候用Map输入端合并,什么时候用输出端合并
这两种合并方式不是互斥的,可以同时开启,但侧重点不同。
Map输入端合并解决的是“读”的问题。它适合读取大量小文件的场景,比如查询一个历史分区,分区下有几千个小文件。开启后,Map数量减少,查询速度提升。但它不改变HDFS上的物理文件,小文件依然存在,NameNode压力没有缓解。
输出端合并解决的是“写”的问题。它适合写入大量小文件的场景,比如动态分区插入、Reducer数量过多的任务。开启后,物理文件被真正合并,NameNode压力得到缓解,下游读取也更高效。但它会增加任务执行时间,因为多了一个合并阶段。
所以一个比较通用的策略是:读的时候用Map输入端合并减少任务数,写的时候用输出端合并控制文件数量。两者配合,既能提升当前任务性能,又能控制小文件增量。
5.2 不同存储格式下的合并策略差异
Hive支持多种存储格式,不同格式的合并策略有差异。
TextFile格式是最容易产生小文件的,也是合并效果最明显的。TextFile可切分,Map输入端合并和输出端合并都能正常工作。但TextFile压缩后如果用了不支持切分的压缩算法(比如Gzip),合并后的大文件也无法切分,会导致单个Map处理整个文件。这种情况下,要么换支持切分的压缩算法(如Snappy、LZO),要么控制合并后文件大小。
ORC格式本身有Stripe级别的结构,合并时可以利用hive.merge.orcfile.stripe.level参数,在Stripe级别合并,效率更高。ORC文件通常比TextFile大,小文件问题相对轻一些,但动态分区场景下依然会出现大量小文件。
Parquet格式的合并和ORC类似,但Parquet的Row Group结构对合并后的文件大小更敏感。如果合并后文件太大,单个Row Group可能跨越多个HDFS Block,读取效率反而下降。建议Parquet格式下hive.merge.size.per.task不要超过512MB。
5.3 一个完整的配置模板
下面这个配置模板可以直接抄作业,适用于大多数离线数仓场景。
-- 会话级别设置,建议放在初始化脚本里 SET hive.input.format=org.apache.hadoop.hive.ql.io.CombineHiveInputFormat; SET mapreduce.input.fileinputformat.split.maxsize=268435456; SET mapreduce.input.fileinputformat.split.minsize=1; SET mapreduce.input.fileinputformat.split.minsize.per.node=1; SET mapreduce.input.fileinputformat.split.minsize.per.rack=1; SET hive.merge.mapfiles=true; SET hive.merge.mapredfiles=true; SET hive.merge.size.per.task=268435456; SET hive.merge.smallfiles.avgsize=16777216; -- 如果是ORC格式,额外开启 SET hive.merge.orcfile.stripe.level=true; -- 控制Reducer数量,从源头减少小文件 SET hive.exec.reducers.bytes.per.reducer=268435456; SET hive.exec.reducers.max=1009;这个模板的核心思路是:输入端尽量合并小文件减少Map数量,输出端控制合并后文件大小在256MB左右,同时通过hive.exec.reducers.bytes.per.reducer控制Reducer数量,避免Reducer过多导致输出文件过碎。
实操心得:配置不是一成不变的。我一般会先在一个中等规模的分区上测试,对比开合并前后的任务耗时、Map数量、输出文件数量三个指标。如果任务耗时增加超过20%,就要重新评估参数。另外,合并任务本身也会在YARN上显示为一个独立的Application,可以通过YARN的Web UI观察它的执行情况。
6. 常见问题与排查技巧实录
6.1 合并参数开了但没生效
这是最常见的问题。参数明明设了,但输出文件数量没变化。排查思路如下:
先确认参数是否真的生效。在Hive会话里执行SET hive.merge.mapredfiles;,看返回值是不是true。有时候参数在hive-site.xml里被覆盖了,或者会话级别设置被后面的语句覆盖了。
再确认是否满足触发条件。hive.merge.smallfiles.avgsize默认16MB,如果输出文件平均大小大于16MB,就不会触发合并。可以手动计算一下输出目录的文件平均大小。
最后确认任务类型。hive.merge.mapfiles只对纯Map任务生效,hive.merge.mapredfiles只对有Reduce的任务生效。如果任务类型和参数不匹配,自然不会合并。
6.2 合并后任务变慢了
合并本身是有开销的,如果合并带来的收益小于开销,任务就会变慢。常见原因有三个:
原因一:合并任务排队。合并任务需要申请Container,如果集群资源紧张,合并任务可能排队很久。这种情况下,可以尝试把合并任务放到单独的队列,或者调整合并任务的优先级。
原因二:合并后文件太大。如果hive.merge.size.per.task设得太大,合并后单个文件可能达到1GB甚至更大,下游读取时无法并行,反而变慢。建议根据下游消费方式调整,Spark下游一般128MB到256MB比较合适。
原因三:数据本地性被破坏。Map输入端合并时,如果为了凑够分片大小而跨节点合并,Map任务需要远程拉取数据。可以通过调整per.node和per.rack参数,优先在同一节点内合并。
6.3 动态分区插入时小文件依然很多
动态分区插入是小文件的重灾区,即使开了输出端合并,有时候效果也不理想。原因通常是Reducer数量太多。每个Reducer在每个动态分区都会写一个文件,Reducer数量乘以分区数量就是文件数量。
解决办法是控制Reducer数量。可以通过hive.exec.reducers.bytes.per.reducer调大每个Reducer处理的数据量,从而减少Reducer数量。另外,可以考虑用DISTRIBUTE BY语句把相同分区键的数据分发到同一个Reducer,这样每个分区只会由一个Reducer写入,文件数量等于Reducer数量而不是Reducer数量乘以分区数量。
-- 动态分区插入时,用DISTRIBUTE BY控制文件数量 INSERT INTO TABLE target_table PARTITION(dt) SELECT col1, col2, dt FROM source_table DISTRIBUTE BY dt;6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 合并参数开了但文件数没变 | 参数未生效或未满足触发条件 | 检查SET值,计算平均文件大小 | 确认参数生效,调整avgsize阈值 |
| 合并后任务变慢 | 合并任务排队或文件过大 | 查看YARN队列,检查输出文件大小 | 调整合并队列,减小size.per.task |
| 动态分区小文件多 | Reducer数量过多 | 查看Reducer数量与分区数量 | 调大bytes.per.reducer,用DISTRIBUTE BY |
| Map输入端合并无效 | 文件不可切分或格式不支持 | 检查文件格式和压缩算法 | 换可切分格式,调整input.format |
| 合并后下游读取变慢 | 文件太大无法并行 | 检查下游任务并行度 | 减小merge.size.per.task |
6.5 几个容易踩的坑
坑一:在Tez引擎下合并行为不同。Tez引擎的合并逻辑和MapReduce不完全一样,有些参数在Tez下不生效。如果用的是Tez,建议查阅对应版本的文档,确认参数支持情况。
坑二:合并任务失败导致数据丢失。极少数情况下,合并任务失败后原文件被删除,临时文件也没保留,导致数据丢失。虽然Hive有保护机制,但在生产环境还是建议先备份再合并,或者在小规模数据上验证后再推广。
坑三:分区级别合并和表级别合并混淆。ALTER TABLE ... CONCATENATE是表级别的合并操作,和任务级别的合并是两回事。前者是手动触发的DDL操作,后者是任务执行时自动触发的。两者可以配合使用,但不要混淆。
最后分享一个小技巧:如果历史分区小文件已经积重难返,可以用
ALTER TABLE ... PARTITION ... CONCATENATE逐个分区合并,或者写一个脚本批量处理。但要注意,CONCATENATE操作本身也会消耗资源,建议在业务低峰期执行。另外,CONCATENATE对ORC格式支持最好,TextFile格式下效果有限,可能需要用INSERT OVERWRITE重写分区来达到合并效果。