4. Compaction原理:MOR表数据写入与读取路径、Compaction的必要性、Compaction策略

4.1 MOR表的核心机制:写入与读取路径

MOR(Merge-On-Read)表的核心设计思想是将写入成本与读取成本分离。写入时只追加增量数据,读取时再合并基线数据与增量数据。理解这一机制,是掌握Compaction原理的前提。

MOR表在文件系统层面维护两类数据文件:Base File(列式文件,如Parquet)与Log File(行式文件,如Avro)。每次写入操作(Insert/Update/Delete)都会产生新的Log File片段,而Base File保持不变。下图展示了MOR表的数据写入与读取路径:

写入路径(Write Path) 写入应用 Insert / Update / Delete 产生增量数据 Log File 行式存储(Avro) 追加写入增量数据 读取路径(Read Path) 查询引擎 实时合并(Merge) Base + Log 按Key合并 Base File 列式存储(Parquet) 历史基线数据 Log File 增量变更数据 多个Log片段 Compaction(压缩合并) Inline(内联) 写入事务内同步执行 Async(异步) 独立进程/线程后台执行 Schedule(调度) 先标记再异步执行 合并结果:生成新的Base File 将Log中的增量合并到Base File,清理旧Log 读取时只需扫描单个Base File,无需合并 Log积累

上图揭示了MOR表的核心矛盾:写入越快,Log文件积累越多,读取时需要合并的数据量就越大。写入路径追求低延迟,将变更追加到Log;读取路径则必须遍历所有Log片段与Base File进行合并,才能返回最新数据。这种设计在写入密集型场景下优势明显,但若Log无限增长,读取性能将急剧恶化。

4.2 Compaction的必要性:性能衰减的临界点

Compaction的本质是将Log File中的增量数据合并到新的Base File中,并清理已合并的Log片段。其必要性可以从三个维度理解:

维度 不执行Compaction的后果 Compaction后的效果
读取性能 每次查询需合并数十甚至上百个Log片段,CPU与I/O开销线性增长 只需读取单个Base File,查询延迟降低数倍至数十倍
存储效率 Log文件冗余存储同一Key的多次变更,占用额外空间 合并后只保留最新版本,存储空间显著下降
文件数量 小文件数量持续膨胀,NameNode/元数据压力增大 文件数量收敛,元数据开销降低,适合小文件合并场景
误区警示:很多初学者认为Compaction只是“优化手段”,而非“必要操作”。实际上,在持续写入的MOR表上,若不配置任何Compaction策略,Log文件会无限增长。当Log片段数量超过阈值(如默认的hoodie.compaction.inline.max.delta.commits=5)时,读取延迟可能从毫秒级恶化到秒级甚至分钟级,且Hudi的FileGroup清理机制(Clean)也无法回收被Log占用的空间,最终导致存储与查询双双失控。

4.3 Compaction策略:Inline、Async与Schedule

Hudi提供了三种Compaction触发与执行策略,适用于不同场景。理解它们的差异是调优的前提。

4.3.1 Inline Compaction(内联压缩)

Inline Compaction在写入事务提交之前同步执行。当满足触发条件时(如提交次数达到阈值),当前写入任务会暂停,先执行Compaction,再完成本次写入提交。

-- 开启Inline Compaction,每5次提交触发一次
hoodie.compaction.inline=true
hoodie.compaction.inline.max.delta.commits=5

优点:实现简单,无需额外部署组件;Compaction与写入在同一事务中,一致性保障容易。
缺点:Compaction耗时较长时,会阻塞写入任务,导致写入延迟尖峰。适合写入频率低、数据量小的场景。

4.3.2 Async Compaction(异步压缩)

Async Compaction将Compaction任务从写入路径中剥离,由独立的Spark作业或线程在后台执行。写入任务只负责生成Compaction计划(Plan),不等待执行结果。

-- 开启异步Compaction
hoodie.compaction.async.enable=true
hoodie.compaction.async.max.delta.commits=5

-- 使用独立Spark作业执行(生产推荐)
spark-submit --class org.apache.hudi.utilities.HoodieCompactor \
  --master yarn \
  --driver-memory 4g \
  --executor-memory 8g \
  --executor-cores 4 \
  hudi-utilities-bundle.jar \
  --base-path /user/hudi/table \
  --table-name my_table \
  --schedule-compaction \
  --instant-time 20250101000000

优点:Compaction不阻塞写入,写入延迟稳定;可独立调整Compaction的计算资源。
缺点:需要额外运维Spark作业;若后台任务执行不及时,Log仍会积累。适合写入压力大、对延迟敏感的生产环境。

4.3.3 Schedule + 手动执行(调度模式)

该策略将Compaction拆分为两个阶段:先调度(Schedule)生成计划,再在合适时机执行(Execute)。调度阶段只做标记,不执行实际合并,因此开销极小。

-- 步骤1:仅生成Compaction计划(不执行)
hoodie.compaction.schedule.enable=true
hoodie.compaction.schedule.max.delta.commits=5

-- 步骤2:手动触发执行(可在业务低峰期)
spark-submit --class org.apache.hudi.utilities.HoodieCompactor \
  --master yarn \
  hudi-utilities-bundle.jar \
  --base-path /user/hudi/table \
  --table-name my_table \
  --execute-compaction \
  --instant-time 20250101000000

优点:执行时机完全可控,可避开业务高峰;调度与执行解耦,便于自动化编排。
缺点:需要额外的调度系统配合(如Airflow、DolphinScheduler)。适合有明确维护窗口、需要精细控制资源使用的场景。

4.4 策略选型与案例复盘

三种策略并非互斥,实际生产中可以组合使用。以下是一个典型的选型决策流程:

  1. 评估写入频率与延迟要求:若写入QPS高且对P99延迟敏感,优先排除Inline。
  2. 评估运维能力:是否有独立Spark集群资源?能否接受额外运维成本?
  3. 评估数据新鲜度要求:若下游对数据可见性要求高(分钟级),Async需保证执行频率不低于写入频率的1/5。
  4. 组合策略:例如“Async为主 + Schedule兜底”,平时异步执行,若Log积累超过紧急阈值则手动调度一次。
案例复盘:某电商订单表采用MOR模式,初始配置为hoodie.compaction.inline=true。业务增长后,每次Compaction耗时约3分钟,导致写入P99延迟从200ms飙升至4秒,频繁触发超时。调整为Async策略后,写入延迟恢复稳定;但发现后台Compactor执行频率不足,Log文件在高峰期积累至40+个片段,查询延迟上升。最终采用“Async + 基于Log数量的动态调度”方案:当未合并的Log片段超过20个时,自动触发一次Compaction,同时将Compactor的Spark资源从4核提升至8核。优化后,写入P99稳定在300ms以内,查询P95延迟控制在1.5秒内,存储空间节省约35%。

Compaction策略的选择本质上是在写入延迟、查询性能、运维复杂度之间寻找平衡。没有放之四海而皆准的配置,必须结合业务的实际写入模式、查询SLA和集群资源进行动态调整。建议在生产环境通过压测对比不同策略下的延迟分布,并持续监控Log文件数量与Compaction耗时,才能找到最优解。