4. 离线合并策略:基于文件大小的合并、基于时间窗口的合并、基于分区的合并
离线合并,说白了就是“事后补救”。数据已经写进来了,但小文件太多,我们得想办法把它们重新揉成大块。我做了这么多年存储优化,发现离线合并是性价比最高的手段——它不影响写入链路,可以在业务低峰期慢慢跑。
但离线合并也不是随便写个脚本就能搞定的。你想想看,如果合并策略选错了,可能越合越碎,甚至把热数据也锁住。今天我就把三种最主流的离线合并策略掰开揉碎讲清楚。
核心观点:没有万能的合并策略,只有最适合你业务场景的策略。文件大小、时间窗口、分区边界——这三者往往需要组合使用。
4.1 基于文件大小的合并
这是最直观的策略。设定一个目标文件大小(比如 128MB 或 256MB),然后把小于这个阈值的小文件合并成大文件。
我在项目中遇到过这样一个场景:某个日志系统每小时生成上千个几 KB 的小文件,NameNode 内存被打满。我们当时用的就是纯文件大小合并策略,效果立竿见影。
4.1.1 合并算法流程
- 扫描:遍历目标目录,获取所有文件的大小。
- 过滤:筛选出小于阈值(如 64MB)的文件。
- 分组:按“总大小不超过目标块大小”的原则打包。
- 合并:将同组文件顺序读入,写入一个新的大文件。
- 替换:原子性地删除小文件,替换为大文件。
// 伪代码示例:基于文件大小的合并
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,更新表的统计信息。不然查询优化器可能还用旧的元数据做计划,导致性能不升反降。
离线合并策略就讲到这里。三种策略各有千秋,关键是根据你的业务场景灵活组合。记住,没有最好的策略,只有最合适的策略。