3. 小文件合并:小文件产生原因、合并策略、合并工具与参数调优

小文件问题,可以说是 Iceberg 生产环境里最让人头疼的「慢性病」。我见过不少团队,表里几万个文件,查询慢得像蜗牛,元数据操作动不动就超时。说白了,小文件不治理,存储和计算成本都会失控。

这一节,我们就把小文件的来龙去脉、合并手段、以及参数怎么调,一次性讲透。

3.1 小文件是怎么产生的?

先看看源头。小文件不是凭空冒出来的,常见的场景有这么几个:

  • 流式写入太频繁:Kafka 实时入湖,每几分钟就提交一次,每次只写几十 MB。我遇到过一天产生 2 万多个小文件的案例。
  • 分区粒度过细:按小时甚至按分钟分区,每个分区数据量很少,文件自然就小了。
  • 并发写入冲突:多个 Spark 任务同时写同一张表,每个任务各自写自己的文件,合并前谁也不管谁。
  • 更新/删除操作:Iceberg 的 MOR(Merge-on-Read)模式会产生 delete files,积累多了也是小文件。
  • 历史遗留:从 Hive 迁移过来的表,原本就有大量小文件,Iceberg 不会自动帮你合并。

核心结论:小文件的本质是「写入频率」和「数据量」不匹配。频率越高、单次数据量越小,小文件就越多。

3.2 合并策略:什么时候合并?合并成多大?

合并不是无脑跑一个命令就完事。你需要根据业务场景选策略。我个人习惯把合并策略分成三种:

策略 适用场景 优点 缺点
定时批量合并 离线数仓、T+1 报表 简单可控,对查询影响小 实时性差,数据延迟高
增量合并 近实时流处理 延迟低,文件数可控 实现复杂,需配合 compaction
按分区合并 分区数据量差异大 只合并热分区,节省资源 冷分区可能被忽略

至于合并成多大,我建议你参考 HDFS 的块大小。128MB 到 256MB 是一个比较稳妥的范围。文件太小,NameNode 压力大;文件太大,并行度又不够。

我的经验:目标文件大小设为 256MB 时,查询性能和元数据开销能达到比较好的平衡。如果你的表经常做全表扫描,可以适当放大到 512MB。

3.3 合并工具:Iceberg 的 RewriteDataFiles

Iceberg 官方提供了 RewriteDataFilesAction,这是最核心的合并工具。它底层用的是 Spark,但封装得比较友好。

基本用法是这样的:

import org.apache.iceberg.spark.actions.SparkActions

SparkActions.get()
  .rewriteDataFiles(table)
  .option("target-file-size-bytes", "268435456")  // 256MB
  .option("max-concurrent-file-group-rewrites", "10")
  .execute()

这段代码会扫描表的所有数据文件,把小于目标大小的文件合并成大文件。嗯,这里要注意:合并过程会重写数据,所以会占用额外的存储空间和计算资源。

3.4 参数调优:让合并跑得更稳

参数调优是门手艺活。我踩过不少坑,下面这几个参数是我觉得最关键的:

参数名 默认值 推荐值 说明
target-file-size-bytes 512MB 256MB 合并后的目标文件大小
min-file-size-bytes 75MB 100MB 小于此值的文件才会被合并
max-concurrent-file-group-rewrites 50 10-20 并发合并的组数,太大容易撑爆资源
rewrite-job-order none bytes-asc 按文件大小升序处理,优先合并小文件
partial-progress-enabled false true 启用部分进度,避免单次合并时间过长

避坑指南:我曾经把 max-concurrent-file-group-rewrites 设成 50,结果集群资源被打满,其他查询全挂了。建议从 10 开始,观察集群负载再逐步调高。

3.5 合并流程全景图

下面这张图展示了小文件合并的完整流程,从扫描到提交,每一步都有讲究。

小文件合并流程 扫描小文件 文件分组 重写数据文件 提交新元数据 清理旧文件 关键参数 target-file-size-bytes min-file-size-bytes max-concurrent-file-group-rewrites 注意事项 合并期间数据可读 旧文件在过期后删除

3.6 实战建议:合并频率怎么定?

合并频率没有标准答案,但你可以参考这个原则:文件数不要超过分区数据量的 10 倍。比如一个分区有 10GB 数据,目标文件 256MB,那文件数应该在 40 个左右。如果超过 400 个,就该合并了。

我一般这样安排:

  • 离线表:每天凌晨跑一次合并,配合 ETL 调度。
  • 实时表:每 1-2 小时跑一次增量合并,用 partial-progress-enabled=true 避免单次耗时过长。
  • 大表(TB 级):按分区合并,只合并最近 7 天的热分区,历史分区一个月合并一次。

小技巧:合并前先用 table.snapshot().summary() 查看当前文件数,再决定要不要跑合并。如果文件数不多,就别浪费资源了。

3.7 避坑总结

最后,把我踩过的坑列出来,你遇到了可以直接对照:

  • 合并期间 OOM:max-concurrent-file-group-rewrites 设太大,Spark Executor 内存不够。建议从 5 开始试。
  • 合并后查询变慢:目标文件设得太大,导致扫描并行度下降。256MB 是个安全起点。
  • 合并任务卡住:表有并发写入,导致提交冲突。可以开启 partial-progress-enabled,或者错开写入窗口。
  • 存储空间暴涨:合并期间新旧文件同时存在,磁盘占用翻倍。确保集群有足够的临时空间。

小文件合并不是一次性工作,而是一个持续治理的过程。把参数调好、频率定好,你的 Iceberg 表才能跑得又快又省。