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/元数据压力。
- 提升查询性能:消除读取时的实时合并开销,让查询直接扫描列存文件。
- 清理历史版本:合并过程中可同步清理过期数据版本,配合时间旅行保留策略。
- 稳定写入延迟:将耗时的合并操作从写入关键路径中剥离(异步模式),避免写入抖动。
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计划。具体步骤如下:
- 扫描文件组:遍历表分区下所有File Group,检查每个File Group的日志文件大小与数量。
- 判断是否满足触发条件:根据
hoodie.compact.inline.max.delta.commits(默认5,即间隔5次提交触发一次调度)或hoodie.compact.inline.max.delta.seconds等参数判断当前File Group是否达到合并阈值。 - 生成Compaction Plan:将满足条件的File Group及其日志文件列表写入Hudi元数据的
CompactionPlan中,该Plan包含待合并的文件组ID、日志文件路径、目标Base File版本等信息。 - 持久化计划:Compaction Plan被写入时间线(Timeline)上的
requested状态,等待执行器拾取。
阶段二:Compaction执行(Execute)
执行阶段由Compaction任务(Spark或Flink作业)读取计划并实际完成合并。流程如下:
- 读取Compaction Plan:执行器从Timeline中获取处于
requested状态的计划,将其标记为inflight(进行中)。 - 执行合并:对计划中的每个File Group,读取Base File与所有Log File,按Key进行合并(去重、应用更新),生成新的Parquet文件。
- 写入新文件:将合并后的数据写入临时路径,完成后替换原Base File,并更新文件组的最新文件切片(File Slice)。
- 提交Compaction:将Compaction结果写入Timeline,状态从
inflight变为completed,同时清理已合并的日志文件。
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才能追平。
调度频率由
max.delta.commits控制,决定“多久生成一次计划”;执行能力由Compaction任务的资源(并行度、内存)决定。即使调度频繁,如果执行资源不足,计划仍会堆积在requested状态,形成积压。
内联Compaction虽然实现简单,但在数据量增长后,Compaction耗时可能从秒级增长到分钟级,直接拖垮写入作业。生产环境建议优先使用异步模式,并单独监控Compaction耗时指标。
requested和inflight状态的Compaction计划数量。若requested持续增加而inflight长期不完成,说明调度正常但执行能力不足;若requested本身不增长,则需检查调度触发条件是否被满足(如提交间隔是否达到阈值)。