小文件合并策略:从 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 (定时任务) 需要 ZORDER 排序? (高频过滤字段 ≤ 3 个) 是 否 OPTIMIZE + ZORDER (排序后合并) 普通 OPTIMIZE (仅合并文件) 文件合并完成

避坑总结

最后,我把这些年踩过的坑整理成几条经验:

  • 不要频繁跑 OPTIMIZE:每次合并都会产生新版本文件,元数据版本太多反而影响性能。我一般控制每天最多跑 2-3 次。
  • ZORDER 列要选对:选那些经常出现在 WHERE 条件里的列,而且基数不要太高。比如 gender(男女两种值)就不适合,因为排了跟没排一样。
  • 自动合并不是万能药:写入太频繁(每秒几十次)的场景,自动合并会跟不上。这时候我建议用流式写入的 foreachBatch 里手动合并。
  • 监控合并效果:我习惯在合并前后跑一下 DESCRIBE DETAIL,对比 numFiles 和 sizeInBytes 的变化。如果合并后文件数没明显减少,说明配置有问题。

重要提醒:OPTIMIZE 是写操作,会占用集群资源。我见过有人在生产高峰期跑 OPTIMIZE,结果把查询任务都挤垮了。建议把 OPTIMIZE 安排在业务低峰期,或者单独开一个低优先级集群来跑。