4. 离线合并策略:基于文件大小的合并、基于时间窗口的合并、基于分区的合并

离线合并,说白了就是“事后补救”。数据已经写进来了,但小文件太多,我们得想办法把它们重新揉成大块。我做了这么多年存储优化,发现离线合并是性价比最高的手段——它不影响写入链路,可以在业务低峰期慢慢跑。

但离线合并也不是随便写个脚本就能搞定的。你想想看,如果合并策略选错了,可能越合越碎,甚至把热数据也锁住。今天我就把三种最主流的离线合并策略掰开揉碎讲清楚。

核心观点:没有万能的合并策略,只有最适合你业务场景的策略。文件大小、时间窗口、分区边界——这三者往往需要组合使用。

4.1 基于文件大小的合并

这是最直观的策略。设定一个目标文件大小(比如 128MB 或 256MB),然后把小于这个阈值的小文件合并成大文件。

我在项目中遇到过这样一个场景:某个日志系统每小时生成上千个几 KB 的小文件,NameNode 内存被打满。我们当时用的就是纯文件大小合并策略,效果立竿见影。

4.1.1 合并算法流程

  1. 扫描:遍历目标目录,获取所有文件的大小。
  2. 过滤:筛选出小于阈值(如 64MB)的文件。
  3. 分组:按“总大小不超过目标块大小”的原则打包。
  4. 合并:将同组文件顺序读入,写入一个新的大文件。
  5. 替换:原子性地删除小文件,替换为大文件。
// 伪代码示例:基于文件大小的合并
def merge_by_size(input_dir, target_size_mb=128):
    small_files = scan_files_under(input_dir, max_size_mb=64)
    groups = pack_files(small_files, target_size_mb)
    for group in groups:
        merged_path = generate_merged_path(group)
        with open(merged_path, 'wb') as out:
            for f in group:
                out.write(read_file(f))
        atomic_replace(group, merged_path)

我的习惯:阈值不要设得太死。我会留一个 10% 的余量,比如目标 128MB,实际合并到 115MB 就收手。这样可以避免跨块边界导致的额外开销。

4.1.2 优缺点对比

维度 优点 缺点
实现复杂度 低,逻辑简单 —
存储利用率 高,文件大小均匀 —
时间关联性 — 可能混合不同时间窗口的数据
查询性能 — 时间跨度大的文件不利于分区裁剪

嗯,这里要注意:纯大小合并虽然简单,但容易把不同时间段的数据混在一起。如果你的查询经常按时间过滤,那就要小心了。

4.2 基于时间窗口的合并

这个策略是我个人最常用的。它按时间窗口(比如 1 小时、1 天)把数据分组,然后在每个窗口内做合并。

为什么要这么做?因为大多数大数据系统的查询都带有时间范围。如果合并后的文件时间跨度太大,查询时就得扫描很多不必要的数据。

4.2.1 时间窗口的选择

窗口大小取决于你的查询模式。我一般遵循这个原则:

  • 窗口 > 查询范围:查询只需扫描一个文件,效率最高。
  • 窗口 = 查询范围:每个查询扫描 1-2 个文件,可以接受。
  • 窗口 < 查询范围:查询需要扫描多个文件,性能下降。

我曾经踩过的坑:把时间窗口设得太细(比如 5 分钟),结果合并出来的文件还是很小,等于没合。后来我改成按查询频率的 2 倍窗口大小,才解决问题。

4.2.2 实现要点

// 伪代码:基于时间窗口的合并
def merge_by_time_window(input_dir, window_minutes=60):
    files = scan_all_files(input_dir)
    windows = group_by_time_bucket(files, window_minutes)
    for bucket in windows:
        if total_size(bucket) < target_size_mb:
            merge_files(bucket)

这里有个细节:时间窗口的边界怎么切?是按文件创建时间,还是按数据本身的时间戳?我建议用数据时间戳,因为文件创建时间可能被延迟写入干扰。

4.3 基于分区的合并

分区是 Hive、Spark、Iceberg 等系统的核心概念。基于分区的合并,就是在每个分区内部做文件合并。

你想想看,如果分区键是日期,那么每天的数据天然就在一个独立目录里。我们只需要在每个分区目录下跑合并任务就行,互不干扰。

4.3.1 分区合并的优势

  • 隔离性好:不同分区的合并互不影响,可以并行执行。
  • 查询友好:分区裁剪后,查询只需扫描相关分区的文件。
  • 易于管理:可以按分区粒度控制合并频率,热分区多合,冷分区少合。

4.3.2 分区合并的挑战

但分区合并也不是银弹。我遇到过这么个情况:某个表按天分区,但每天的数据量差异巨大。周一 100GB,周日只有 1GB。如果每个分区都用同样的合并策略,周日那 1GB 数据合完还是小文件。

解决办法是:动态调整合并阈值。根据分区大小自动计算目标文件数,而不是用固定值。

// 动态阈值示例
def adaptive_merge(partition_path):
    partition_size = get_directory_size(partition_path)
    target_files = max(1, partition_size // 256MB)
    merge_to_target(partition_path, target_files)

4.4 三种策略的对比与选择

我把这三种策略放在一起对比,方便你选型:

策略 适用场景 不适用场景 我推荐的组合
基于文件大小 文件大小极不均匀,NameNode 压力大 时间敏感查询 作为兜底策略
基于时间窗口 查询频繁按时间过滤 数据写入延迟大 与大小策略叠加
基于分区 分区表,分区数量稳定 分区过多或过少 作为主策略

我的建议:在实际项目中,我通常把分区合并作为主策略,然后在每个分区内部叠加时间窗口和文件大小限制。这样既保证了查询性能,又控制了文件数量。

4.5 知识体系总览

下面这张图把三种策略的关系和适用场景串起来了:

离线合并策略知识体系 离线合并策略 基于文件大小 基于时间窗口 基于分区 适用:文件大小不均 不适用:时间敏感查询 推荐:作为兜底策略 适用:频繁时间过滤 不适用:写入延迟大 推荐:与大小策略叠加 适用:分区表稳定 不适用:分区过多/过少 推荐:作为主策略 最佳实践:分区主策略 + 时间窗口 + 大小限制 三者组合,兼顾查询性能与存储效率

4.6 实战避坑指南

最后,分享几个我亲身踩过的坑:

  • 合并期间不要忽略写入:如果合并任务和写入任务同时操作同一个文件,会出现数据丢失。一定要用原子替换或软链接切换。
  • 不要一次合并太多文件:我试过一次性合并 10 万个小文件,结果 OOM 了。建议分批处理,每批不超过 1000 个文件。
  • 监控合并进度:合并任务跑几个小时是常事。如果没有进度监控,出了问题你都不知道。
  • 预留回滚能力:合并前备份元数据,万一合错了还能恢复。

一个小技巧:合并完成后,记得执行一次 ANALYZE TABLE 或 REFRESH,更新表的统计信息。不然查询优化器可能还用旧的元数据做计划,导致性能不升反降。

离线合并策略就讲到这里。三种策略各有千秋,关键是根据你的业务场景灵活组合。记住,没有最好的策略,只有最合适的策略。