小文件合并策略:从 OPTIMIZE 到自动合并
Delta Lake 用久了,你一定会遇到一个头疼的问题——小文件太多。我最早接手一个实时数仓项目时,每天增量写入几百万条数据,结果表里攒了上万个几 KB 的小文件。查询慢不说,元数据操作都卡得要命。后来我花了整整一周专门搞文件合并,才把性能拉回来。
小文件问题说白了就是:写入越频繁,文件越碎。而 Delta Lake 的 OPTIMIZE 命令,就是专门用来收拾这个烂摊子的。
核心观点:小文件合并不是一次性工作,而是需要策略性执行的持续任务。合并太勤浪费资源,合并太少又影响查询性能。
OPTIMIZE 命令详解
OPTIMIZE 是 Delta Lake 提供的文件合并命令。它的工作原理很简单:读取小文件,合并成大文件,再替换掉原来的小文件。整个过程对用户透明,查询时完全感知不到。
基本语法长这样:
-- 合并整个表
OPTIMIZE delta.`/path/to/table`;
-- 只合并指定分区
OPTIMIZE delta.`/path/to/table`
WHERE date = '2024-01-15';
-- 指定目标文件大小(单位:字节)
OPTIMIZE delta.`/path/to/table`
WHERE date = '2024-01-15'
WITH (
targetFileSize = 134217728 -- 128MB
);
我个人习惯把目标文件大小设在 64MB 到 256MB 之间。太小了合并效果不明显,太大了又影响并发读取。你想想看,一个 1GB 的文件和 8 个 128MB 的文件,在 Spark 并行处理时,后者反而更快。
我的经验:对于频繁查询的热表,目标文件大小建议 128MB;对于冷数据归档表,可以放宽到 256MB 甚至 512MB。这样既能保证查询性能,又能减少元数据压力。
ZORDER 排序优化
OPTIMIZE 只是合并文件,而 ZORDER 是在合并的同时对数据进行排序。为什么要排序?因为 Delta Lake 的 Data Skipping 机制依赖文件内的数据分布。
举个例子:你经常按 user_id 和 event_time 过滤数据。如果不排序,同一个 user_id 的数据可能散落在几十个文件里,查询时得扫描所有文件。排完序后,相关数据集中到少数文件,Data Skipping 就能精准跳过无关文件。
ZORDER 的用法:
OPTIMIZE delta.`/path/to/table`
ZORDER BY (user_id, event_time);
这里有个坑——ZORDER 列不是越多越好。我曾经在一个表上加了 5 个 ZORDER 列,结果合并时间暴涨 3 倍,查询性能反而没提升。后来我总结出规律:
- 1-2 个列:效果最好,适合高频过滤字段
- 3 个列:勉强可以,但收益递减
- 4 个以上:别这么干,合并成本远大于收益
避坑指南:我曾经在一个宽表上对 6 个列做了 ZORDER,结果 OPTIMIZE 跑了 2 小时没跑完。后来我改成只对 dt 和 user_id 做 ZORDER,合并时间降到 15 分钟,查询性能反而更好。记住,ZORDER 是锦上添花,不是越多越好。
自动合并配置
手动跑 OPTIMIZE 太麻烦,尤其是实时写入的场景。Delta Lake 提供了自动合并机制,通过 Spark 配置就能开启。
核心配置项:
| 配置项 | 默认值 | 说明 |
|---|---|---|
spark.databricks.delta.autoCompact.enabled |
false | 是否开启自动合并 |
spark.databricks.delta.autoCompact.minFileSize |
134217728 (128MB) | 小于此大小的文件会被合并 |
spark.databricks.delta.autoCompact.maxFileSize |
536870912 (512MB) | 合并后的目标文件大小上限 |
spark.databricks.delta.autoCompact.numShufflePartitions |
200 | 合并时的 shuffle 分区数 |
开启方式很简单:
spark.sql("SET spark.databricks.delta.autoCompact.enabled = true")
spark.sql("SET spark.databricks.delta.autoCompact.minFileSize = 67108864") -- 64MB
spark.sql("SET spark.databricks.delta.autoCompact.maxFileSize = 268435456") -- 256MB
自动合并会在每次写入操作后异步执行。它不会阻塞你的写入流程,而是等写入完成后,在后台默默把小文件合并掉。
我的建议:自动合并适合写入频率中等(每分钟几次到每小时几次)的场景。如果每秒都在写入,自动合并反而会成为负担。这时候我建议用定时任务,比如每 30 分钟手动跑一次 OPTIMIZE。
三种策略的对比与选择
我把这三种策略放在一起对比,方便你根据场景选择:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 手动 OPTIMIZE | 批量写入、定时任务 | 完全可控,可指定分区和文件大小 | 需要人工调度,容易遗忘 |
| ZORDER 排序 | 高频过滤查询的表 | 大幅提升 Data Skipping 效果 | 合并时间长,列数不宜过多 |
| 自动合并 | 实时写入、流式写入 | 无需人工干预,异步执行 | 配置不当可能影响写入性能 |
我个人习惯的组合是:
- 实时写入表:开启自动合并,同时每天凌晨跑一次带 ZORDER 的 OPTIMIZE
- 批量写入表:写入完成后手动跑 OPTIMIZE + ZORDER
- 冷数据表:每月跑一次 OPTIMIZE,不做 ZORDER
核心流程一览
下面这张图展示了小文件合并的完整决策流程,我把它画出来方便你理解:
避坑总结
最后,我把这些年踩过的坑整理成几条经验:
- 不要频繁跑 OPTIMIZE:每次合并都会产生新版本文件,元数据版本太多反而影响性能。我一般控制每天最多跑 2-3 次。
- ZORDER 列要选对:选那些经常出现在 WHERE 条件里的列,而且基数不要太高。比如
gender(男女两种值)就不适合,因为排了跟没排一样。 - 自动合并不是万能药:写入太频繁(每秒几十次)的场景,自动合并会跟不上。这时候我建议用流式写入的
foreachBatch里手动合并。 - 监控合并效果:我习惯在合并前后跑一下
DESCRIBE DETAIL,对比numFiles和sizeInBytes的变化。如果合并后文件数没明显减少,说明配置有问题。
重要提醒:OPTIMIZE 是写操作,会占用集群资源。我见过有人在生产高峰期跑 OPTIMIZE,结果把查询任务都挤垮了。建议把 OPTIMIZE 安排在业务低峰期,或者单独开一个低优先级集群来跑。