Hudi 的 MOR 与 COW 写入模式深度对比:延迟、吞吐与查询的三角权衡

深度对比 Hudi 的 MOR 与 COW 两种写入模式在写入延迟、吞吐量和查询性能之间的三角权衡,结合写入放大、读放大与 Compaction 机制,分析常见选型误区,并给出不同数据场景下的实践建议与配置参考。

很多团队接触 Hudi,第一关就卡在表类型上。建表时会遇见 COPY_ON_WRITEMERGE_ON_READ 两个选项,官方文档讲清楚了它们的基本差异,但真正到了生产环境,问题通常会变成一句很实际的话:我手头的写入压力、查询压力和更新频率摆在面前,选哪个才不会在半年后头疼。Hudi 的 MOR 与 COW 两种写入模式,本质是在写入延迟、写入吞吐和查询性能三者之间做置换,理解不了这个三角权衡,后面调参数也容易被动。

AI technology illustration

先搞清楚 COW 和 MOR 到底在更新数据时发生了什么

COW 的名字很直白,copy on write。更新一条记录时,Hudi 会把这条记录所在的基础数据文件读出来,应用变更后再把整个文件重新写回。写入过程中会产生新的文件版本,旧版本由后续的清理机制删除。也就是说,一次更新操作哪怕只改了文件里的几行,也要等完整的读改写流程结束,提交才会完成。

MOR 则换了一个思路。更新和删除不直接碰基础 Parquet 文件,而是先把变更追加到对应文件组的日志块里,也就是 Avro 格式的 delta log。读取的时候再把基础文件和这些日志合并,得到最新数据。因为写入侧变成了纯追加,延迟和吞吐都变得很舒服,但代价并没有消失,只是被搬到了读取侧和后台压缩任务里。

还有一个容易被忽略的机制是 file group。Hudi 会把同一主键范围的数据组织进一个文件组,无论是 COW 还是 MOR。COW 更新时,文件组里受影响的基础文件会被替换成新版本;MOR 更新时,变更会追加到文件组对应的日志文件中。这个差异决定了两种模式对提交频率的容忍度:COW 一次提交要做若干文件的原子替换,MOR 一次提交只是落盘少量日志。提交频率越高、单次变更越碎,两者的吞吐差距就越明显。

理解到这一层,就能看出来这其实是数据系统里常见的写换读、时间换空间的搬移。Hudi 把它做成了一个显式的表结构选择,代价也因此变得可见、可控。

三角权衡:为什么快写和快读很难同时成立

最核心的原因很简单:列式文件没法做真正意义上的原地更新。Parquet 是批量编码的存储格式,单行改动无法直接落盘。所以每一次更新,本质上都逃不开“读旧文件、生成新文件”这个过程。COW 选择在写入时完成这个过程,MOR 选择把它延后到读取和压缩时完成。于是就有了下面这组对应关系:

COW 的延迟高,但换来的是干净稳定的文件布局和读取路径;MOR 的吞吐高,但它的查询性能有一个前提——delta log 不能无限制增长。

写入放大是 COW 最直接的代价

假设一张订单表里单个 Parquet 文件有 200MB,某次 upsert 只命中这个文件里的 10 条记录,COW 依然会把整个文件重新编码写一遍。当更新频繁且散落在大量文件里时,一次提交要对几十个文件做读改写,写延迟自然下不来。不少团队把更新占比高的表从 COW 迁到 MOR 后,写入链路立刻稳定下来,原因就是写入端不再关心文件内部的记录分布。

但这里有一个常见误区:如果写入以 INSERT 为主,COW 并不存在那么严重的放大问题。新增数据直接生成新文件,没有读改写环节,COW 和 MOR 的写入性能差距并不大。只有更新占比高的负载才会放大 COW 的劣势,没必要在任何场景下都换 MOR。

读放大是 MOR 的隐性成本

MOR 的读放大体现在每次查询都要做基础文件与日志的合并。合并过程对查询引擎透明,但成本并不隐形。日志文件越多、跨的文件组越广,合并开销越大。极端情况下,一个文件组积累了几十个日志块,点查可能从毫秒级变成秒级。更麻烦的是,这种慢查询很难靠堆并发解决,因为瓶颈在单个文件组内部的拼接计算。

所以说,MOR 本质上是用查询稳定性换写入稳定性,这个交换划不划算,完全取决于你的业务更在意哪一侧。

MOR 的一个关键细节:查询与日志深度强绑定

如果你翻过 Hudi 早期版本的文档,会发现 MOR 表其实支持两种查询视角:读优化查询只读基础文件,不包含最新日志变更;实时查询要合并日志,返回最新数据。后来这种双轨设计在版本演进中逐渐收敛,但历史设计说明了一件事——MOR 在诞生之初就预设了“读取侧可以牺牲新鲜度来换性能”这个选项。

这个背景在实际选型里很有用。比如上游经常对近几天的数据进行修正,而对下游报表来说这些修正不需要秒级可见,那么 MOR 配合合理的压缩调度,就可以同时承受高频改动和相对可控的查询成本。反过来,如果一张表要被实时分析平台高频查询,日志深度控制不住的话,MOR 的查询侧会先扛不住。

生产场景里的典型样本

先看一个 CDC 入湖的场景。某个团队做数据库变更同步,每秒几百到上千条变更,更新占比很高。他们一开始用 COW,提交延迟越拖越长,因为每批变更都在反复重写基础文件。切成 MOR 之后写入立刻顺畅,但紧接着巡检用的 SQL 开始变慢,点查和聚合都出现偶发长尾。最后他们不得不引入定时压缩,把日志深度控制在若干次提交以内,查询端才恢复可接受的水平。

再看一个离线数仓回刷的场景。另一个团队每天写入几千万条新增数据,偶尔对历史分区做一次全量修正。这种负载对 COW 非常友好,历史修正虽然慢一点,但一天只跑一两次完全能接受。如果他们换成 MOR,不但要额外维护压缩调度,查询侧还要白白承担日志合并的成本。COW 在这里反而是更优解。

还有一个在 Flink 入湖场景里很典型的现象。流式任务通常保持较小的批间隔,追求秒级到分钟级的时效。如果表是 COW,每次 checkpoint 都要评估哪些基础文件被命中,更新密集时 checkpoint 时长会明显上涨,进而拖累整个作业的吞吐。MOR 在这种负载下的优势几乎是决定性的,这也是很多实时入湖流水线默认选 MOR 的原因之一。

综合这几个样本,常见的错误判断大致有三类:

  • 以写入吞吐作为唯一标准,认为 MOR 全面优于 COW。实际上插入占比高的负载下两者差距很小,COW 的查询稳定性反而更有价值。
  • 忽略读取侧的合并成本,上线后才发现慢查询。MOR 的写入优势是显性的,读取劣势是隐性的,通常要等日志堆起来才暴露。
  • 把 Compaction 当作可以无限延后的后台任务。压缩确实可以异步,但不调度、不监控,就有日志无界增长和任务堆积双重风险。

COW 与 MOR 的多维度对比

对比维度 COW MOR
更新写入方式 读改写整个基础文件 追加 Avro delta log
写入放大
读放大 高,取决于日志深度
写入延迟 偏高
查询稳定性 稳定 受日志长度影响明显
文件数管理 较好,关注小文件问题 依赖压缩收敛日志
典型负载 INSERT 为主、低频更新 高频 UPSERT、DELETE
运维复杂度 中高,重点是压缩调度

落地时的配置与避坑建议

如果决定在某张表上用 MOR,Hudi 的配置本身并不复杂。以 Spark 批式写入为例,核心就是表类型和压缩策略两个位置:

df.write.format("hudi")
  .option("hoodie.table.name", "dwd_trade_match")
  .option("hoodie.table.type", "MERGE_ON_READ")
  .option("hoodie.datasource.write.operation", "upsert")
  .option("hoodie.compact.inline", "true")
  .option("hoodie.compact.inline.max.delta.commits", "5")
  .mode("append")
  .save("s3://warehouse/dwd_trade_match")

这里把压缩设置为每 5 次提交触发一次,目的是把日志深度限制在可控范围。max.delta.commits 设得越小,查询侧越稳定,但压缩频率越高,对资源的占用也越明显。这个值没有通用最优解,必须结合提交频率和单次提交的数据规模来调。

COW 表的配置重点则完全不同。它更关心小文件治理,比如 hoodie.parquet.small.file.limit、hoodie.copyonwrite.record.size.estimate 这类参数。COW 的每次更新都会产生新版本文件,如果分区更新频率高而数据量小,文件数会快速膨胀,查询性能同样会垮。听上去 COW 很省心,小文件问题才是它真正的运维暗面。

经验提醒:MOR 表如果查询压力大,优先保证压缩调度的稳定性,而不是在查询引擎侧硬调参数。压缩不及时造成的日志堆积,是 MOR 慢查询最常见的根因。

落到具体行动上,建议按下面几步走:

  1. 先量化现状:观察提交时长、单次提交重写的文件数量、目标查询的 P95 延迟,再决定是否动表类型。
  2. 不要全表迁移,挑一张更新频率最高的真实表灰度,切换前后各记录一段时间写入吞吐和查询延迟。
  3. 更新频繁但很少被查询的中间过程表,选 MOR 收益最大;被实时报表高频查询的宽表,则要谨慎评估日志深度和压缩窗口。
  4. 把压缩调度写进监控体系,观察压缩延迟和积压数量,而不是上线后就不再过问。

Hudi 的 COW 和 MOR 没有哪一个是默认答案。它们只是把“更新数据”这件事的成本,放在了时间轴的不同位置。COW 把代价提前到写入那一刻,用写入延迟换查询干净;MOR 把代价延后到读取和压缩那一刻,用运维复杂度换写入吞吐。选型的关键,是想清楚自己的更新频率、查询压力,以及团队愿意承担多少压缩调度的工作。把这三件事量化了再建表,大概率不会走弯路。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/942/

(0)
上一篇 1小时前
下一篇 1小时前

相关推荐