如果你经常和 Spark 打交道,大概率遇过这种怪现象:数据量明明不大,几百个任务却磨了半个小时;打开 Spark UI 一看,全是几十 KB 到几 MB 的小文件,一个任务可能只读了几十 KB,调度、序列化、任务分配的开销反而比计算本身还大。等你把文件合并、压缩,任务数降下来,运行时间立刻缩短。这种场景,在日志类、埋点类,以及上游任务频繁落数的数仓里特别常见。
这篇文章就围绕 Spark 读取小文件的调优参数展开。我会先拆解小文件到底卡在哪,再把spark.sql.files.maxPartitionBytes、spark.sql.files.openCostInBytes这些关键参数逐个讲透,最后给出一套可以直接“抄作业”的参数组合和排障清单。无论你是在做离线报表、实时链路落盘,还是数据湖上的批处理,这篇文章都能帮你少踩几个坑。
1. 小文件问题到底卡在哪
1.1 为什么“小”文件这么伤
小文件本身不是问题,问题是数量。文件小到一定程度后,Spark 的任务数量往往不取决于数据量,而取决于文件数量。每个文件都要走一次文件系统查找、元数据读取、任务分发和序列化流程,这些固定开销不会因为文件小就省掉。
举个例子。同样 120GB 数据,如果压缩成 120 个 1GB 文件,任务量大概就是几分钟的事;如果拆成 12 万个 1MB 文件,任务量可能膨胀到上万。数据总量没变,但每个任务都在干几乎相同的事:启动、读一小块、结束、等待调度。执行时间被这种空转稀释,Spark UI 上看起来全是毫秒级或者秒级的任务,整体跑下来却非常慢。
用生活里的场景类比一下,就像快递员送 1 吨货物。整包送货一趟车就走完了,拆成几千个小包裹,光是扫码、装车、敲门就要耗费几十倍时间。小文件给 Spark 带来的就是这种“拆包裹”的损耗,而调优参数就是在告诉你:哪些箱子可以打成一个包再发。
1.2 先分清瓶颈是读还是写
不是所有小文件问题都靠读侧参数解决。动手调参之前,先看一眼 Spark UI 和事件日志,判断瓶颈发生在哪个阶段。
如果你看到 Stage 列表里大量读任务的时间集中在几秒以内,输入数据量一栏显示的是几十 KB 到几 MB,而总任务数却成百上千,那问题基本出在文件扫描阶段。这种情况,读侧参数能产生立竿见影的效果。
如果你看到的是 Shuffle Read 很大、Shuffle Write 很大,或者某个 Stage 的任务时间分布极其不均,那瓶颈可能在 join、groupBy,或者上游分区数设计不合理。这时候去调spark.sql.shuffle.partitions、开 Adaptive Query Execution 反而更有效。读侧参数再调,也解决不了 shuffle 阶段被切碎的问题。
2. 核心调优参数逐个拆解
Spark 读取文件时,有一个专门负责把文件切成分区的过程。它不会永远一个文件一个分区,而是按一定的字节阈值把多个小文件归到同一个分区。理解这一点,调优就有方向了。
2.1 spark.sql.files.maxPartitionBytes
这个参数是控制分区大小的天花板,默认值是 128MB,单位可以是128m、1g这样的字符串,也可以直接写字节数。
它真正的作用是限制“一个分区最多大概包含多少字节”。Spark 在读文件时,会先按文件大小和打开成本做估算,再把多个小文件塞进一个分区,直到估出来的大小接近这个上限。文件若超过这个值,就会被切成多个 block 分散到不同分区。
所以处理小文件第一步,就是看这个上限是否太低了。假设上游产生了大量 5MB 大小的小文件,默认 128MB 上限可以把二十多个小文件归到同一个分区;如果你把它调成 512MB,合并力度就明显变大,任务数也会相应降下来。
配置方式很简单,提交时加参数:
spark-submit \ --conf spark.sql.files.maxPartitionBytes=512m \ --class com.example.SomeJob \ application.jar或者直接在代码里设置:
spark.conf.set("spark.sql.files.maxPartitionBytes", "512m")注意一点:这个参数不是越大越好。分区太大,每个任务处理的数据量就偏大,一旦某个任务执行到一半失败要重试,代价会很高。如果分区里的字段多、行又宽,堆内存也容易吃紧。一般我会控制在 128MB 到 1GB 之间,具体看集群单核能处理的数据规模。
2.2 spark.sql.files.openCostInBytes
如果说maxPartitionBytes是“一个包最多装多重”,那openCostInBytes就是“打开每个小包裹时额外算的手续费”,默认 4MB。
Spark 在估算文件分区时,会给每个文件加一个虚拟字节数。这个虚拟值是为了模拟打开文件的固定开销:文件元数据读取、文件句柄、网络连接,这些成本不能因为文件只有几 KB 就忽略不计。
它会影响分区方式,而且方向有点反直觉。很多人看到“打开成本”就觉得参数调越大越好,其实不是。调大openCostInBytes会让每个文件的虚拟体积变大,反而更难把多个文件合并进同一个分区。比如一个 1MB 的文件,maxPartitionBytes是 128MB,openCostInBytes是 8MB,那它的虚拟体积是 9MB,理论上一个分区能装十来个文件;如果你把openCostInBytes调到 64MB,虚拟体积就变成 65MB,一个分区几乎只能装一两个文件,任务数反而变多了。
所以正确的用法,是把这个值设在“你心中合理的最小文件处理成本”附近,让它参与分区估算,但不要大到喧宾夺主。如果文件平均 5MB,设置 2MB 到 8MB 通常比较合理;如果文件只有几十 KB,可以把openCostInBytes适当调小,方便更多文件合并。
2.3 容易被忽略的 bytesPerCore 和默认并行度
光调上面两个参数还不够,因为 Spark 有一个公式在背后一起作用。文件扫描时,分区大小不是简单地等于maxPartitionBytes,而是类似这样算出来的:
maxSplitBytes = min( spark.sql.files.maxPartitionBytes, max(spark.sql.files.openCostInBytes, totalBytes / spark.default.parallelism) )totalBytes是所有待读文件的真实大小加上openCostInBytes后的总和,defaultParallelism是 Spark 默认并行度。这个公式会带来一个很真实的现象:如果总数据量不大、并行度又高,算出来的分割阈值很小,maxPartitionBytes根本起不到作用,Spark 还是会把每个文件单独当成一个分区。
这时候,你可以考虑调整spark.default.parallelism来影响totalBytes / defaultParallelism这一项,或者接受“文件太少时任务数本来就下不来”的现实。更常见的做法是检查集群资源够不够,让总数据量分摊到每个核上之后,阈值自然上升。注意不要为了调任务数就盲目把defaultParallelism调低,这会影响其他阶段的并行度。
另外,spark.sql.files.minPartitionNum这个参数一般用于磁贴数据源或部分数据源场景,对普通 Parquet、ORC、CSV 文件源不一定会生效。很多帖子里把它当成万能并行度调整项,实际使用时效果往往有限。
2.4 与读取联动的一组配套参数
小文件读取只是整条链路的第一步,后续的 shuffle 阶段同样会制造小分区。所以读侧调参的同时,建议把自适应执行打开。Spark 3.2 之后spark.sql.adaptive.enabled默认是开的,但旧版本需要显式开启:
spark.sql.adaptive.enabled=true spark.sql.adaptive.coalescePartitions.enabled=true spark.sql.adaptive.advisoryPartitionSizeInBytes=128mspark.sql.adaptive.coalescePartitions.enabled会让 Spark 在 shuffle 完成后动态合并过小的分区,避免下游算子再次被碎片化;advisoryPartitionSizeInBytes则给合并器一个参考目标,一般建议与maxPartitionBytes保持一致,比如 128MB 或 256MB。
还有两个辅助参数,在文件损坏或者文件缺失时很有用:spark.sql.files.ignoreCorruptFiles=true和spark.sql.files.ignoreMissingFiles=true。它们不是小文件调优的主角,但在文件不干净的环境中能避免整个任务被一个坏文件拖死。
3. 实操:一组可以直接抄的参数组合
3.1 一个典型场景
我之前在某个离线报表任务里遇到的就是典型小文件问题。上游每十分钟同步一次增量数据,日表目录下散落着大量几 MB 级小文件。任务统计一天的数据,每次都是把全量小文件扫一遍再聚合,跑一次要半个多小时。
用 Spark UI 看,读取文件那个 Stage 的任务数大概有 1600 个,大量任务执行时间不到一秒,整个阶段大半时间浪费在任务调度和文件打开上。原始表总数据量其实只有 120GB 左右。这个场景就是标准的小文件读取代价案例。
3.2 参数配置与实验记录
我先后做了三组对比实验,每组跑的是同一个查询,只是改了读侧参数,其他资源条件保持一致。这张表是简化后的数据:
| 配置方案 | maxPartitionBytes | openCostInBytes | 读任务数 | 读取阶段耗时 |
|---|---|---|---|---|
| 方案A(默认) | 128m | 4m | 约1600 | 约25分钟 |
| 方案B | 512m | 4m | 约450 | 约9分钟 |
| 方案C | 512m | 64m | 约2500 | 约35分钟 |
方案A就是默认参数,任务碎,耗时长。方案B只把maxPartitionBytes调大,任务数立刻降下来,读取阶段性能提升明显。方案C看起来像是“预算充足”,但openCostInBytes设得过大,导致每个文件的虚拟体积变大,一个分区装不下几个文件,任务数反而比默认还多。
这个结果很能说明问题:小文件调优,优先调maxPartitionBytes。在文件平均几 MB 的场景下,openCostInBytes保持默认或者稍微调整即可,不要盲目加大。
3.3 落地方案怎么选
如果你要直接拿去用,下面这套参数可以作为起点。
spark-submit \ --master yarn \ --executor-memory 8g \ --executor-cores 4 \ --conf spark.sql.files.maxPartitionBytes=512m \ --conf spark.sql.files.openCostInBytes=4m \ --conf spark.sql.adaptive.enabled=true \ --conf spark.sql.adaptive.coalescePartitions.enabled=true \ --conf spark.sql.adaptive.advisoryPartitionSizeInBytes=128m \ --conf spark.sql.shuffle.partitions=400 \ --class com.example.SomeJob \ application.jar这套配置适合几百 GB 量级、单文件几 MB 到几十 MB 的数值仓库场景。如果你的集群只有几十核,建议把maxPartitionBytes降到 256MB,避免每个任务分到太多数据;如果集群有上千核,可以调到 1GB 甚至更高,但最好用两三次实验验证 GC 和重试率。
调参不是一次到位的,关键是看两个指标:读取阶段的任务数和平均任务耗时。理想情况下,任务数应该接近集群总核心数的 2 到 3 倍。比如 200 个核,读取任务冲到 2000,肯定有问题;降到 400 到 600 之间,整体吞吐会比较健康。
4. 常见问题与排障技巧实录
4.1 最容易被带偏的:openCostInBytes 越大越好
我在前面实验里已经踩过这个坑。很多文章喜欢建议“把小文件的 openCostInBytes 调大,让 Spark 认为它们打开成本很高,从而合并分区”,听起来很有道理,实际上却要看具体文件大小。
openCostInBytes是把每个文件的打开开销折算成虚拟字节数来参与分区。它变大以后,单个文件的分区权重变大,一个分区能放下的文件数量反而变少。如果你的目的是让任务数降下去,那这个参数就不能无脑调大,而是要保持在你理想中的“单文件最小处理成本”范围内。
我现在的习惯是,先看文件平均大小,再决定这个参数。文件平均 1MB 左右,设在 2MB 以内;文件平均 10MB,设在 4MB 到 8MB。总之,它应该占每个分区虚拟体积的一小部分,而不是主导因素。
4.2 调完没效果或者更慢怎么办
有一类常见情况,是参数压根没生效。spark.sql.files.maxPartitionBytes只对 Spark SQL 的 DataFrame API 和 SQL 查询生效,如果你用的是 RDD 的textFile,或者某些第三方 connector,这个参数可能不会被用到。那时候要看底层文件源实现,改用 Hadoop 侧的mapreduce.input.fileinputformat.split.maxsize等配置。
另一类情况是调大maxPartitionBytes之后反而更慢。原因通常是并行度被压得太低。比如 100 核的集群,你把分区上限调到 1GB,数据总量才 150GB,分区可能只有 150 个,每个任务处理 1GB,单任务耗时直线上升,一旦某一行数据倾斜,任务运行时间会更离谱。解决办法是回到 256MB 或者 512MB,让任务数在核数的 2 倍左右。
还有一种“假没效果”:读取阶段确实优化了,但后面 join 或者 groupBy 又产生了新的 shuffle,小分区再次冒出来。这时候需要看 Shuffle Read 大小和后续 Stage 的任务数,用 AQE 的coalescePartitions和advisoryPartitionSizeInBytes去控制,而不是一直盯着读取参数。
4.3 压缩格式和文件类型也会搅局
小文件调优还有一个隐藏的坑,就是压缩格式。Spark 在读可切分压缩格式,比如 Snappy、Zstd 或者 LZO 索引文件时,能把一个大文件拆成几个部分并行读;但像 Gzip 这种不可切分压缩格式,整个文件只能作为一个整体被处理,无法被拆开。
如果文件本身很大又是 Gzip 压缩,任务数会很大,而且一个任务读一个文件的特性会让maxPartitionBytes很难完全发挥。这种场景下,更合理的做法是在数据写入阶段把 Gzip 换成 Parquet + Snappy/Zstd,或者先做一次格式转换。
5. 比调参更重要的事:从写入侧减少小文件
5.1 源头治理的基本动作
读侧调参只是缓解,不是根治。真正高效的方法,是从写入侧控制文件数量和大小。上游在写表的时候,如果每个任务都落一个小文件,那么无论你读的时候怎么合并,底层的文件数量都不会变少,扫描路径还是要遍历所有文件。
源头控制有几个简单做法。写入前,用coalesce或者repartition控制输出分区数:
df.coalesce(50) .write .mode("overwrite") .partitionBy("dt") .parquet("hdfs://path/to/table")如果业务上一定按天分区写,可以先算一下每天的数据量,再把任务数控制在一个合理范围。比如每天 200GB,每文件 256MB,那目标就是 800 个文件;如果配置了 2000 个写入任务,就会产生 2000 个小文件。
如果是 Spark SQL 的INSERT写法,可以直接在插入前设置:
SET spark.sql.shuffle.partitions = 400; INSERT OVERWRITE TABLE target SELECT /*+ REPARTITION(400) */ ... FROM source;这里仍要注意:spark.sql.shuffle.partitions控制的是 shuffle 后的分区数,不一定直接等于输出文件数,但它是影响输出文件数量的主因。
5.2 定期压缩任务怎么做
已经有很多小文件的表,短期内不可能立刻重建。这时候需要做一个压缩任务,也就是把历史分区里的小文件读出来合并后再覆盖写回去。核心逻辑很简单:
spark .read .parquet("/path/to/table/dt=2025-01-01") .coalesce(10) .write .mode("overwrite") .parquet("/path/to/table/dt=2025-01-01")压缩任务的频率取决于写入节奏。如果上游每天产生几千个小文件,一天一次压缩就够了;如果实时链路每小时都落文件,最好每小时做一次轻量合并。
如果你用的是数据湖格式,比如 Iceberg 或某些支持自动文件优化的表格式,还有更省力的选择。比如 Iceberg 的OPTIMIZE语句或者 Spark 侧的 rewrite 相关操作,能自动把小文件合并到目标大小。这类功能的好处是你不用手动写压缩任务,缺点是要依赖表格式本身的维护能力。
5.3 我个人的几条经验
小文件问题的解法是多层的。读侧参数能解决运行时的任务切分问题,但不能解决底层文件数量问题;写入侧压缩能减文件数,但不能解决已经存在的存量小文件。两条腿一起走,效果才会好。
我在实际项目里的调整顺序大致是:先开 AQE,再调大maxPartitionBytes,确认读取任务数降到合理范围;然后观察 shuffle 阶段是否有碎片,不符合预期再动spark.sql.shuffle.partitions和advisoryPartitionSizeInBytes;最后回到生产链路里,给写入任务设置合适的分区数,并给历史表安排定期压缩。
如果只让我记住一句话,那就是:Spark 读取小文件时,spark.sql.files.maxPartitionBytes是主力,openCostInBytes是辅助,两个参数都不该盲目设成极值。把注意力多放在文件写入侧的治理上,你会收到比调参更稳定的长期回报。