3、合并策略基础:核心思想、粒度选择与时机把握

小文件合并这事儿,说白了就是「把零钱换成整钞」。你想想看,HDFS 存一个小文件就要记一条元数据,NameNode 内存就那么点,文件一多直接撑爆。我早年接手过一个离线数仓,光用户行为日志就攒了上千万个小文件,NameNode 的 GC 停顿能把人逼疯。嗯,合并就是来解决这个问题的。

3.1 合并的核心思想

合并的本质很简单:把多个小文件拼成一个大文件。但这里有个关键点——合并不是简单的文件拼接,你得考虑后续怎么读。

核心原则:合并后的文件大小要匹配底层存储的块大小(比如 HDFS 默认 128MB),同时要保证数据在逻辑上仍然可分割、可并行读取。

我个人习惯把合并策略拆成三个维度来看:

  • 空间维度:文件要多大才合适?太小浪费元数据,太大又影响并行度。
  • 时间维度:什么时候该合并?是攒够一批就合,还是等系统空闲再合?
  • 逻辑维度:哪些文件可以合在一起?同分区、同表、还是同数据源?

这三个维度缺一不可。我在项目中见过有人只盯着文件大小,结果把不同分区的数据合到一个文件里,查询时全表扫描,性能反而更差。说白了,合并不是目的,让查询更快才是。

3.2 合并的粒度选择

粒度选择是合并策略里最容易踩坑的地方。选大了,合并效果不明显;选小了,合并本身的开销比收益还大。

3.2.1 文件大小粒度

我一般按以下标准来定目标文件大小:

存储类型 块大小 建议合并目标 说明
HDFS 128MB 64MB ~ 256MB 略小于块大小或整数倍,避免跨块读取
HDFS 256MB 128MB ~ 512MB 大块场景下适当放大
对象存储 无固定块 16MB ~ 64MB 对象存储的元数据开销较小,但请求次数有限
本地文件系统 4KB 1MB ~ 16MB 本地场景下主要减少 inode 消耗

这里有个经验值:合并后的文件数量控制在 1000 个以内,对大多数查询引擎来说是比较舒服的范围。我曾经遇到一个 Spark 任务,读取 10 万个小文件,光是打开文件句柄就花了 3 分钟。合并到 500 个大文件后,读取时间降到 10 秒。

3.2.2 分区粒度

按分区合并是最常见的做法。每个分区的文件独立合并,互不干扰。这样做的好处是:

  • 合并操作可以并行执行,不同分区互不影响
  • 查询时按分区裁剪,只读需要的分区
  • 单个分区出问题不影响全局

但要注意,分区粒度过细也有问题。比如按小时分区,一天 24 个分区,每个分区只有几百 KB 数据,合并后还是小文件。我建议:分区粒度要和数据量匹配。如果每小时数据量不到 1GB,就别按小时分区了,按天分区更合理。

3.2.3 表粒度

有些场景下,整张表的数据量都不大,比如维度表、配置表。这时候按表粒度合并就够了。我一般这样判断:

  • 如果整张表数据量 < 1GB,直接合并成 1 个文件
  • 如果 1GB ~ 10GB,合并成 8~16 个文件
  • 如果 > 10GB,按分区粒度处理

小技巧:对于经常全表扫描的小表,合并成单个文件反而能利用顺序读的优势,比多个小文件快得多。

3.3 合并的时机选择

时机选不对,合并反而变成负担。我总结了三类常见的合并时机策略:

3.3.1 定时合并

最直接的方式——每天凌晨跑一个合并任务。适合数据写入有明确周期、对实时性要求不高的场景。

  • 优点:简单可控,不影响白天业务
  • 缺点:如果白天写入量暴增,小文件会堆积到晚上才处理
  • 适用场景:离线数仓、T+1 报表

我曾经维护过一个定时合并任务,每天凌晨 2 点跑。结果有一天业务方搞了个大促,数据量翻了 10 倍,凌晨合并跑了 4 个小时还没跑完,直接影响了第二天的早报。从那以后,我学会了加一个「文件数阈值」的兜底逻辑。

3.3.2 阈值触发合并

设定一个阈值,比如「分区内文件数超过 1000 个」或「小文件总大小超过 5GB」,一旦触发就立即合并。

阈值类型 推荐值 触发动作
文件数量 500 ~ 2000 启动合并任务
文件总大小 块大小的 2~4 倍 启动合并任务
文件平均大小 < 块大小的 1/10 启动合并任务

阈值触发的好处是响应及时,但要注意避免「合并风暴」——多个分区同时触发合并,把集群资源打满。我一般会加一个全局并发限制,比如同时最多跑 5 个合并任务。

3.3.3 写入时合并(在线合并)

在数据写入的同时做合并。比如 Flink 的 StreamingFileSink 就支持按文件大小或时间滚动,写入过程中自动合并小文件。

  • 优点:实时性好,小文件不会堆积
  • 缺点:增加写入延迟,对写入性能有影响
  • 适用场景:实时流处理、近实时数仓

注意:在线合并要小心「小文件抖动」——刚合并完的大文件,如果后续又有少量数据写入,可能会被再次拆分。建议设置一个「冷却期」,合并后的一段时间内不再对该文件做操作。

3.4 合并策略的决策流程

说了这么多,到底怎么选?我画了一张决策流程图,你可以对照着来:

合并策略决策流程 开始评估合并策略 数据量 < 1GB? (整表合并) 是 整表合并 1 个大文件 否 有分区字段? (按分区合并) 是 按分区合并 每个分区独立合并 否 需要实时合并? (在线 vs 离线) 是 在线合并 写入时合并 否 离线合并 定时/阈值触发 执行合并并监控效果 提示:合并后要持续监控查询性能,如果效果不明显,调整粒度或时机再试

3.5 合并策略的避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路:

坑1:合并后文件过大

我曾经把一个小表合并成 2GB 的单个文件,结果下游的 Spark 任务只有一个分区在读,跑得比合并前还慢。合并不是越大越好,要兼顾并行度。

坑2:忽略合并本身的开销

合并任务要读数据、写数据,本身就会消耗 IO 和 CPU。如果小文件本身只有几 KB,合并的读写开销可能比省下来的元数据开销还大。我一般建议:文件平均大小小于块大小的 1/20 时,才值得合并。

坑3:合并后数据倾斜

按某个字段合并时,如果该字段分布不均匀,会导致合并后的大文件大小差异巨大。比如按用户 ID 合并,活跃用户的数据可能是一个文件的 10 倍。这时候要考虑加一个「二次散列」或者「范围分区」来均衡。

我的习惯:每次上线合并策略前,先用一个小分区做灰度测试。跑一遍合并前后的查询,对比耗时和资源消耗。数据不会骗人,效果好不好,一跑便知。