小文件问题详解

聊完文件大小对 Spark 的整体影响,咱们得先搞清楚一个最基础的问题——到底多大的文件算「小文件」?

说实话,这个问题没有的数字标准。我个人的习惯是:如果一个文件在 HDFS 上的块大小是 128MB,但实际数据只有几 MB 甚至几百 KB,那它就是小文件。更准确地说,当文件数量远大于并行度(分区数)时,小文件问题就来了。

小文件的定义:单个文件大小远小于 HDFS 块大小(通常 128MB),且文件数量远大于 Spark 任务并行度,导致元数据膨胀、调度开销剧增、I/O 效率低下的文件。

小文件产生的根源

小文件不是凭空冒出来的。我在项目中排查过不下 20 次小文件问题,总结下来,根源就三类。

1. 流式写入

这是最常见的元凶。你想想看,Kafka 过来的数据每秒几百条,如果用 saveAsTextFile 或 foreachBatch 每批次直接写 HDFS,每批次生成一个文件。一天下来,几千个文件就出来了。

我记得有一次帮一个实时数仓团队做优化,他们的 Structured Streaming 作业每 10 秒触发一次微批次,每次写一个 parquet 文件。一个月后,HDFS 上某个分区目录下堆了 26 万个文件。NameNode 直接报警,RPC 延迟飙到 5 秒以上。

避坑指南:流式写入时,我建议用 coalesce(1) 或 repartition(1) 合并输出,或者使用 FileSink 的 rolling 策略,按时间或数据量滚动合并。

2. 过度分区

这个坑我自己也踩过。刚用 Spark 时,总觉得分区越多并行度越高,于是写代码时随手就 repartition(10000)。结果呢?每个分区就几百条数据,写出去就是一万个小文件。

过度分区通常发生在:

  • 盲目调大 spark.sql.shuffle.partitions:默认 200 其实够用,有人非要设成 2000
  • 使用 repartition 但没考虑数据量:100MB 数据分 1000 个区,每个区才 100KB
  • Join 或 Aggregation 后没做合并:Shuffle 完分区数没变,但数据量已经很小了

注意:分区数不是越大越好。分区数 = 数据量 / 目标文件大小(建议 128MB~256MB)。我一般用公式:分区数 = max(总数据量 / 256MB, 集群核心数)。

3. 动态分区插入

这个场景在数仓 ETL 里特别常见。用 INSERT OVERWRITE TABLE PARTITION 时,如果分区键的基数很高(比如按 user_id 分区),每个分区可能只有几条数据,但每个分区都会生成一个文件。

我见过最夸张的例子:一个表按小时分区,但数据源延迟到达,导致每个小时分区下又有几十个文件。最终一个月的分区下堆了 10 万+ 文件。

4. 数据倾斜的副产品

数据倾斜不仅影响性能,还会产生小文件。倾斜的 key 所在分区数据量大,正常分区数据量小。正常分区写出去的文件可能只有几 KB,这就是小文件。

小文件对 Driver 的负面影响

很多人以为小文件只影响 Executor,其实 Driver 才是第一个受害者。

影响维度 具体表现 我见过的案例
元数据膨胀 Driver 需要维护所有输出文件的元数据(路径、大小、块位置)。10 万个文件,光元数据就要占几百 MB 内存 有一次 Driver 堆内存 8GB,GC 频繁,最后 OOM。查下来是 30 万个小文件导致的
Task 调度开销 每个小文件对应一个 Task,Driver 要创建、调度、跟踪这些 Task。10 万个 Task 的调度时间可能超过 5 分钟 我见过一个作业,实际计算只用了 30 秒,但调度花了 3 分钟
RDD Lineage 膨胀 小文件导致 RDD 分区数暴增,DAG 图变得巨大,Driver 序列化和反序列化开销剧增 用 toDebugString 看 lineage,打印出来有几千行

小文件对 Executor 的负面影响

Executor 层面更直接——全是 I/O 和序列化的开销。

  • Task 启动开销占比过高:每个 Task 启动需要序列化、广播变量、建立连接。如果处理时间只有 100ms,启动时间占 200ms,那效率只有 33%
  • HDFS I/O 效率低下:小文件意味着随机读取,而不是顺序读取。HDFS 擅长处理大文件的顺序读,小文件的寻道开销占比太大
  • 网络传输放大:Shuffle 时,小文件会导致 Map 输出碎片化,Reduce 端需要拉取大量小数据块,网络连接数暴增
  • GC 压力增大:每个 Task 都要创建反序列化器、迭代器对象。Task 数量一多,对象创建和销毁频繁,GC 频繁触发

一句话总结:小文件让 Spark 从「计算密集型」变成了「调度密集型 + I/O 密集型」,性能断崖式下跌。

小文件问题的知识体系

下面这张图把整个小文件问题的脉络梳理清楚了。从定义到根源,再到对 Driver 和 Executor 的影响,一目了然。

小文件问题知识体系 小文件问题知识体系 小文件问题 定义:文件大小 ≪ HDFS 块大小 产生根源 流式写入 过度分区 动态分区插入 数据倾斜 负面影响 Driver 影响 元数据膨胀 Task 调度开销 Lineage 膨胀 Executor 影响 Task 启动开销 HDFS I/O 低效 网络传输放大 GC 压力增大 核心结论 小文件让 Spark 从「计算密集型」变为「调度密集型 + I/O 密集型」

嗯,这张图把整个小文件问题的脉络都串起来了。从定义到四个根源,再到对 Driver 和 Executor 的具体影响,每个环节都环环相扣。你想想看,如果流式写入不加控制,再叠加上过度分区,那后果就是 Driver 内存爆掉、Executor 忙着启动 Task 而不是算数据。

我曾经帮一个团队排查过这样的问题:他们的流式作业每天产生 5 万个小文件,Driver 的 GC 时间占到了 40%,作业经常失败。最后我们做了三件事——合并小文件、调整分区策略、改用 coalesce 控制输出文件数。效果立竿见影,作业稳定了,性能提升了 3 倍。

我的建议:在日常开发中,养成检查输出文件数的习惯。用 hdfs dfs -count 或 Spark UI 的 Storage 页签看看文件数量和大小分布。如果发现大量小于 10MB 的文件,就要警惕了。