3. Compaction原理:Compaction的作用、触发机制(内联/异步)与调度执行流程

3.1 Compaction在Hudi中的核心作用

在Hudi的存储模型中,数据写入时优先进入基于行存的Avro日志文件(Log File),以获得低延迟的写入能力;而读取时则依赖基于列存的Parquet基础文件(Base File),以获得高效的扫描性能。这种写优化(Copy-on-Write与Merge-on-Read的混合形态)设计带来了一个必然问题:随着写入持续进行,日志文件不断膨胀,读取时需要合并的增量数据越来越多,查询延迟随之恶化。

Compaction(压缩/合并)正是解决这一问题的核心机制。它将一组日志文件与其对应的基础文件进行合并,重写为新的Parquet文件,从而达成以下目标:

  • 控制文件数量:将多个Log File合并进Base File,减少小文件数量,降低NameNode/元数据压力。
  • 提升查询性能:消除读取时的实时合并开销,让查询直接扫描列存文件。
  • 清理历史版本:合并过程中可同步清理过期数据版本,配合时间旅行保留策略。
  • 稳定写入延迟:将耗时的合并操作从写入关键路径中剥离(异步模式),避免写入抖动。
关键认知:Compaction不是“可选优化”,而是MOR表(Merge-on-Read)生命周期中的必选维护操作。若不执行Compaction,日志文件将无限增长,最终导致读取路径的合并成本超过写入收益,表性能急剧退化。

3.2 Compaction的触发机制:内联与异步

Hudi根据Compaction执行时机与写入操作的关系,将其分为两种模式:内联Compaction(Inline)和异步Compaction(Async)。两者的核心区别在于:Compaction任务是否在发起写入的Spark/Flink作业线程内同步执行。

3.2.1 内联Compaction(Inline)

内联模式下,Compaction作为写入提交(Commit)流程的一部分,在同一批写入任务完成后、提交元数据之前同步执行。写入作业需要等待Compaction完成才能结束。

  • 配置方式:hoodie.compact.inline=true(Spark)或Flink中设置compaction.async.enabled=false且配合schedule策略。
  • 适用场景:小数据量、低写入频率的批处理场景;或对数据新鲜度要求极高、希望写入后立即可读且无需合并的场景。
  • 优点:实现简单,无需额外调度组件;写入完成后数据立即可查,无“读时合并”延迟。
  • 缺点:Compaction耗时被计入写入延迟;若Compaction压力大,会导致写入作业长时间阻塞,不适合大规模持续写入。

3.2.2 异步Compaction(Async)

异步模式下,Compaction的调度(Schedule)与执行(Execute)被解耦。写入作业仅负责生成Compaction计划(Plan),实际合并由独立的Compaction任务在后台执行。这是生产环境推荐的模式。

  • 配置方式:Spark Structured Streaming中设置hoodie.compact.async=true;Flink中设置compaction.async.enabled=true并配置独立的Compaction作业。
  • 适用场景:持续写入的实时入湖场景;写入峰值明显、Compaction耗时波动大的场景。
  • 优点:写入延迟稳定,不受Compaction影响;Compaction可错峰执行,充分利用集群空闲资源。
  • 缺点:需要额外的调度与监控;若Compaction积压,读取端需合并更多日志文件,查询性能可能下降。
对比维度 内联Compaction 异步Compaction
执行时机 写入提交前同步执行 写入提交后后台独立执行
写入延迟影响 直接增加写入耗时 基本不影响写入耗时
调度复杂度 低(随写入自动触发) 高(需独立调度与监控)
适用写入模式 低频批量写入 高频持续写入
积压风险 低(写入阻塞天然限流) 高(需关注积压指标)
典型配置参数 hoodie.compact.inline=true hoodie.compact.async=true

3.3 Compaction的调度与执行流程

无论内联还是异步,Compaction在Hudi内部都遵循“先调度生成计划,再执行计划”的两阶段流程。理解这一流程是诊断Compaction积压的前提。

阶段一:Compaction调度(Schedule)

调度阶段的核心任务是扫描当前未Compaction的文件组(File Group),筛选出需要合并的日志文件集合,生成Compaction计划。具体步骤如下:

  1. 扫描文件组:遍历表分区下所有File Group,检查每个File Group的日志文件大小与数量。
  2. 判断是否满足触发条件:根据hoodie.compact.inline.max.delta.commits(默认5,即间隔5次提交触发一次调度)或hoodie.compact.inline.max.delta.seconds等参数判断当前File Group是否达到合并阈值。
  3. 生成Compaction Plan:将满足条件的File Group及其日志文件列表写入Hudi元数据的CompactionPlan中,该Plan包含待合并的文件组ID、日志文件路径、目标Base File版本等信息。
  4. 持久化计划:Compaction Plan被写入时间线(Timeline)上的requested状态,等待执行器拾取。

阶段二:Compaction执行(Execute)

执行阶段由Compaction任务(Spark或Flink作业)读取计划并实际完成合并。流程如下:

  1. 读取Compaction Plan:执行器从Timeline中获取处于requested状态的计划,将其标记为inflight(进行中)。
  2. 执行合并:对计划中的每个File Group,读取Base File与所有Log File,按Key进行合并(去重、应用更新),生成新的Parquet文件。
  3. 写入新文件:将合并后的数据写入临时路径,完成后替换原Base File,并更新文件组的最新文件切片(File Slice)。
  4. 提交Compaction:将Compaction结果写入Timeline,状态从inflight变为completed,同时清理已合并的日志文件。
调度与执行分离的意义:调度是轻量级操作(仅扫描元数据),执行是重量级操作(实际读写数据)。异步模式下,写入作业只做轻量调度,将重量执行交给后台任务,从而保证写入路径的稳定。这也是为什么即使Compaction积压,写入通常不受影响,但查询性能会逐步劣化。

3.4 案例:一次典型Compaction的完整生命周期

假设一个MOR表当前有3个File Group,每个File Group已有1个Base File(Parquet)和若干Log File。以下是一次异步Compaction的完整过程:

时间线(Timeline)状态变化:
[commit 001] --> 写入10条数据到FG1/FG2的Log File
[commit 002] --> 写入15条数据到FG1/FG3的Log File
[commit 003] --> 写入8条数据到FG2的Log File
                │
                ▼
[compaction request] --> 调度器扫描发现:
                         FG1: 2个LogFile, 累计25条
                         FG2: 2个LogFile, 累计18条
                         FG3: 1个LogFile, 累计15条
                         满足 max.delta.commits=3 条件
                │
                ▼
[compaction inflight] --> 后台Compaction任务启动:
                          FG1: BaseFile(v1) + Log1 + Log2 = 新BaseFile(v2)
                          FG2: BaseFile(v1) + Log1 + Log2 = 新BaseFile(v2)
                          FG3: 日志量未达阈值,本次不合并
                │
                ▼
[compaction completed] --> 新BaseFile(v2)生效,Log文件清理
                           FG1/FG2各保留1个最新BaseFile
                           FG3仍为BaseFile(v1) + 1个LogFile

该案例展示了三个关键点:并非所有File Group都会在每次Compaction中被合并(FG3因日志量不足被跳过);Compaction是增量合并,只处理新增的Log File;Compaction完成后旧Log File被清理,释放存储空间。

3.5 常见误区与复盘

误区一:认为Compaction是“一次性”操作。
Compaction是持续性的增量维护操作,每次只合并自上次Compaction以来新增的日志文件。不要期望一次Compaction解决所有历史积压——积压严重时需要多次连续Compaction才能追平。
误区二:混淆“调度频率”与“执行能力”。
调度频率由max.delta.commits控制,决定“多久生成一次计划”;执行能力由Compaction任务的资源(并行度、内存)决定。即使调度频繁,如果执行资源不足,计划仍会堆积在requested状态,形成积压。
误区三:内联模式下忽视Compaction耗时。
内联Compaction虽然实现简单,但在数据量增长后,Compaction耗时可能从秒级增长到分钟级,直接拖垮写入作业。生产环境建议优先使用异步模式,并单独监控Compaction耗时指标。
复盘要点:诊断Compaction积压时,首先查看Timeline上处于requested和inflight状态的Compaction计划数量。若requested持续增加而inflight长期不完成,说明调度正常但执行能力不足;若requested本身不增长,则需检查调度触发条件是否被满足(如提交间隔是否达到阈值)。
Compaction 调度与执行流程 写入作业 (Spark/Flink) Log File 写入 (Avro行存) ① 调度 (Schedule) 扫描文件组,生成Plan Timeline 时间线 requested inflight completed ② 执行 (Execute) 读取Plan,合并文件 新 Base File (Parquet列存) 旧 Log File 清理 合并完成后删除已处理日志 查询引擎 (Spark/Flink/Presto) 关键判断点: • requested 持续堆积 → 调度正常,执行能力不足(资源不够/任务未启动) • inflight 长期不完成 → 单个Compaction任务执行过慢(数据倾斜/资源争抢) • 两者均无增长 → 触发条件未满足(max.delta.commits未达到)