3. Compaction 触发策略:手动触发、定时触发、事件驱动触发、文件数阈值触发

Compaction 什么时候做?这个问题,我在不同项目里被问过不下二十次。说实话,没有银弹。每种触发方式都有自己的脾气,选对了事半功倍,选错了……嗯,我见过有人因为触发策略配错,集群 IO 被打满,整整一个下午都在救火。

这一章,我把四种主流触发策略掰开揉碎讲清楚。你想想看,搞懂了它们各自的适用场景,你就能像调音师一样,让湖仓的 compaction 节奏恰到好处。

核心观点:触发策略没有的好坏,关键看你的业务容忍度和资源水位。我个人的习惯是「混合触发」——用阈值兜底,用事件驱动保实时,用定时任务做补偿。

Compaction 触发策略全景图 触发决策器 手动触发 运维/脚本调用 定时触发 Cron / 调度器 事件驱动触发 Commit / 消息队列 文件数阈值触发 小文件数量监控 混合触发推荐架构 • 阈值触发兜底:防止小文件无限堆积 • 事件驱动保实时:写入高峰后立即触发 • 定时任务做补偿:低峰期统一清理残留 • 手动触发应急:排查问题或特殊维护时使用

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 报表
事件驱动触发 高(实时) 波动大 高 实时数仓、流式写入
文件数阈值触发 中高(按需) 平稳 中 通用场景、混合负载

最后说一句,别纠结于「哪种最好」。我见过的大厂生产环境,基本都是混合策略:阈值触发兜底 + 事件驱动加速 + 定时任务补偿 + 手动触发应急。你先把这四种方式都跑通,然后根据你的业务场景做组合,这才是调优的正道。