3. Compaction 触发策略:手动触发、定时触发、事件驱动触发、文件数阈值触发
Compaction 什么时候做?这个问题,我在不同项目里被问过不下二十次。说实话,没有银弹。每种触发方式都有自己的脾气,选对了事半功倍,选错了……嗯,我见过有人因为触发策略配错,集群 IO 被打满,整整一个下午都在救火。
这一章,我把四种主流触发策略掰开揉碎讲清楚。你想想看,搞懂了它们各自的适用场景,你就能像调音师一样,让湖仓的 compaction 节奏恰到好处。
核心观点:触发策略没有的好坏,关键看你的业务容忍度和资源水位。我个人的习惯是「混合触发」——用阈值兜底,用事件驱动保实时,用定时任务做补偿。
3.1 手动触发——最原始但最可控
手动触发,说白了就是人肉运维。你写个脚本,或者调个 API,让 compaction 立刻跑一次。我在早期做数据迁移项目时,经常这么干——因为那时候业务还没稳定,自动触发反而容易出乱子。
什么时候该用手动触发?
- 紧急合并:发现小文件太多,查询慢得离谱,立刻手动跑一次
- 版本升级前后:升级湖仓引擎前,先手动 compaction 一把,避免兼容性问题
- 数据修复:某个分区数据异常,需要单独做 compaction 修复
- 测试验证:新配置上线前,手动触发验证效果
我的习惯:手动触发时,我会先跑一次 dry-run 模式,看看要合并多少文件、预估耗时。确认没问题再真正执行。这个习惯救过我一次——有一次预估要合并 2 万个小文件,实际跑起来差点把磁盘打满。
手动触发的代码示例(以 Apache Iceberg 为例):
-- Spark SQL 手动触发
CALL catalog.system.rewrite_data_files(
table = 'db.orders',
options = map(
'rewrite-all', 'true',
'max-file-group-size-bytes', '536870912'
)
);
-- 或者用 Java API
Table table = catalog.loadTable(TableIdentifier.of("db", "orders"));
RewriteDataFilesActionResult result = Actions.forTable(table)
.rewriteDataFiles()
.maxParallelism(4)
.targetFileSize(512 * 1024 * 1024) // 512MB
.execute();
3.2 定时触发——最省心但最迟钝
定时触发,就是设个 Cron 表达式,让 compaction 在固定时间窗口跑。比如每天凌晨 2 点跑一次。这招在离线数仓时代很流行,但到了实时湖仓场景,问题就来了。
我记得有一次,业务方抱怨说「数据写入后查询要等半小时才能看到结果」。我排查了半天,发现 compaction 是每小时跑一次,而数据写入是持续不断的。半小时内小文件堆积了上千个,查询自然慢。
定时触发的典型配置:
| 调度周期 | 适用场景 | 风险点 |
|---|---|---|
| 每小时 | 中等写入频率,延迟容忍度 30-60 分钟 | 高峰期可能堆积过多小文件 |
| 每天凌晨 | 离线批处理,T+1 报表 | 实时查询体验差 |
| 每 15 分钟 | 准实时场景,延迟要求 5-10 分钟 | 资源消耗较高,可能和写入高峰重叠 |
避坑指南:我曾经在一个项目里把定时触发设成了每 5 分钟一次,结果 compaction 和写入任务抢资源,导致写入延迟飙升。后来我改成「低峰期密集触发,高峰期只做阈值兜底」,才稳住局面。
3.3 事件驱动触发——最智能但最复杂
事件驱动触发,就是让 compaction 监听某个事件信号。比如每次数据写入提交(commit)后,自动触发一次 compaction。这听起来很美好,但实现起来坑不少。
事件驱动的常见实现方式:
- Commit Hook:在写入事务提交后,回调 compaction 逻辑
- 消息队列:写入完成后发一条 Kafka/RocketMQ 消息,消费端触发 compaction
- WAL 监听:监听 Write-Ahead Log 的变化,增量触发
我比较推荐消息队列的方式。为什么?因为解耦。写入端只管写,compaction 端只管合并,中间通过消息传递状态。这样即使 compaction 挂了,也不会影响写入链路。
// 伪代码:事件驱动的 compaction 消费者
public class CompactionConsumer {
@KafkaListener(topics = "table-commit-events")
public void onCommit(CommitEvent event) {
// 检查是否需要 compaction
if (shouldCompact(event.getPartition())) {
// 异步触发 compaction
compactionService.submit(
event.getTableName(),
event.getPartition(),
CompactionMode.INCREMENTAL
);
}
}
private boolean shouldCompact(String partition) {
// 检查小文件数量是否超过阈值
int fileCount = fileMonitor.getFileCount(partition);
return fileCount > 100; // 超过 100 个小文件就触发
}
}
关键设计:事件驱动触发一定要加「防抖」逻辑。如果每次 commit 都触发 compaction,写入频繁时 compaction 会无限叠加。我一般会设一个冷却时间——比如 30 秒内只触发一次,或者等小文件数量累积到一定阈值再触发。
3.4 文件数阈值触发——最实用但最容易被忽视
文件数阈值触发,是我个人最喜欢的方式。它不依赖时间,不依赖外部事件,就盯着一个指标——小文件数量。超过阈值就触发,没超过就等着。
为什么说它实用?因为小文件数量是 compaction 最直接的「病因」。你想想看,查询慢、元数据膨胀、资源浪费,根源往往就是小文件太多。盯着这个指标,就是盯着问题的本质。
阈值触发的核心参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| min-files-to-merge | 10 - 50 | 最少多少个文件才触发合并,避免频繁合并 |
| max-files-to-merge | 100 - 500 | 单次合并最多处理多少个文件,防止 OOM |
| target-file-size | 256MB - 1GB | 合并后的目标文件大小,根据查询模式调整 |
| max-concurrent-merges | 2 - 8 | 同时运行的合并任务数,受集群资源限制 |
阈值触发的配置示例(Delta Lake):
-- Delta Lake 自动 compaction 配置
ALTER TABLE db.orders SET TBLPROPERTIES (
'delta.autoOptimize.optimizeWrite' = 'true',
'delta.autoOptimize.autoCompact' = 'true',
'delta.targetFileSize' = '268435456', -- 256MB
'delta.numSmallFilesToTriggerCompaction' = '50'
);
我的经验:阈值触发不要设得太激进。比如 min-files-to-merge 设成 5,那 compaction 会频繁触发,反而增加负担。我一般从 30 开始调,观察一周,如果小文件还是堆积,再降到 20。记住,compaction 本身也是资源消耗。
3.5 四种策略的对比与选择
四种策略各有千秋,我整理了一张对比表,方便你快速决策:
| 策略 | 实时性 | 资源消耗 | 运维复杂度 | 推荐场景 |
|---|---|---|---|---|
| 手动触发 | 低(靠人) | 可控 | 低 | 运维操作、测试验证 |
| 定时触发 | 中(固定窗口) | 可预测 | 低 | 离线批处理、T+1 报表 |
| 事件驱动触发 | 高(实时) | 波动大 | 高 | 实时数仓、流式写入 |
| 文件数阈值触发 | 中高(按需) | 平稳 | 中 | 通用场景、混合负载 |
最后说一句,别纠结于「哪种最好」。我见过的大厂生产环境,基本都是混合策略:阈值触发兜底 + 事件驱动加速 + 定时任务补偿 + 手动触发应急。你先把这四种方式都跑通,然后根据你的业务场景做组合,这才是调优的正道。