Hudi 核心要点

表类型

Hudi 提供两种表类型:Copy-on-Write (COW)Merge-on-Read (MOR)。两者在写入路径、读取路径、数据新鲜度上有完全不同的取舍。选哪种取决于业务的读写比例和延迟要求——这是接入 Hudi 时第一个要拍板的决定。

共同的存储模型

无论 COW 还是 MOR,Hudi 表都按下面的层级组织:

table
└── partition (按 partition path 分区,类似 Hive)
    └── file group (拥有稳定的 file ID)
        └── file slice (某次提交对应的版本)
            ├── base file   .parquet  (列式)
            └── log files   .log      (仅 MOR 才有,行式 Avro)
  • File group:在一个分区内由 file ID 唯一标识;同一个 key 的多次更新会落到同一个 file group,便于定位
  • File slice:file group 在某次提交(instant)时的物化版本,包含基础文件 + 增量日志(如有)

两种表类型的差异在于怎么处理更新,进而决定有没有 log 文件、读取时是否要 merge。

Copy-on-Write (COW)

存储格式:仅 Parquet 基础文件,没有 log 文件

写入流程:每次 upsert 会找到受影响的 file group,把旧 base file 加上更新后的数据整体重写成一个新的 Parquet 文件,作为该 file group 的新 file slice。提交完成后老 slice 被标记为可回收。

特点

  • 读路径简单——只读 Parquet,没有合并开销
  • 写放大严重——改一行可能要重写整个文件
  • 不需要 compaction
  • 数据 freshness 受限于写入频率(通常小时级)

适用场景

  • 读多写少、批处理 ETL
  • 对查询性能敏感、不在乎写入吞吐
  • 更新模式集中(大部分写打到少量 file group,重写代价可控)

Merge-on-Read (MOR)

存储格式:Parquet 基础文件 + Avro 增量日志文件(.log)。

写入流程:upsert 不重写 base file,而是把增量记录追加写入对应 file group 的 log 文件(delta commit)。日志累积到一定量(按 commit 数 / 大小 / 时间触发)后由 compaction 把 log 合回 base 文件,产出新的 file slice。

特点

  • 写入轻量——只追加日志、不重写 Parquet
  • 读取要 on-the-fly merge log + base,延迟略高
  • 需要管理 compaction(同步或异步)
  • 可以做到分钟级 freshness

适用场景

  • 流式入湖、CDC 同步
  • 写多读少 / 写读均衡
  • 对端到端延迟敏感(分钟级)

三种查询类型

Hudi 支持三种查询语义,配置项是 hoodie.datasource.query.type

查询类型 看到什么 COW MOR
Snapshot 表的最新快照 ✓ 直接读 base ✓ 合并 base + log
Incremental 自某个 commit 后的增量
Read-Optimized 只读已 compact 的部分 ✓ 等价于 Snapshot ✓ 只读 base(数据偏旧、查询最快)

关键点:

  • COW 的 Snapshot 和 Read-Optimized 等价——它本来就只有 base file
  • MOR 的 Read-Optimized 会跳过未 compact 的 log,牺牲新鲜度换查询性能,适合 BI 报表这种容忍轻微延迟的场景
  • MOR 的 Snapshot 是最新的但最慢,因为要 merge

MOR 表对外暴露两张 Hive 表(同步元数据时自动建):

  • <table><table>_rt:Snapshot 查询(实时)
  • <table>_ro:Read-Optimized 查询(仅 base)

Timeline:所有变更的事件源

Hudi 的核心机制之一是 timeline——表上每个动作都在 .hoodie/ 元数据目录里留一条 instant 记录。每个 instant 有:

  • action:动作类型
  • instant time:单调递增的时间戳
  • state:状态流转 REQUESTED → INFLIGHT → COMPLETED

常见 action:

Action 含义 出现于
commit 一次写入完成 COW 的写入;MOR 的 compaction
deltacommit 一次日志追加 MOR 的写入
compaction 合并 log 到 base 仅 MOR
replacecommit 替换写入(clustering / insert overwrite) 两者
clean 清理过期文件版本 两者
rollback 回滚失败的提交 两者
savepoint 标记一个不可清理的快照 两者
restore 恢复到某个 savepoint 两者

读取时 Hudi 通过 timeline 决定 “看到哪个版本”,因此 Snapshot Isolation 和 Time Travel 都是基于 timeline 实现的。

Compaction 策略(MOR 专属)

Compaction 是 MOR 表的关键运维点,可以同步也可以异步

  • Inline compaction:写入过程中按条件触发,简单但会阻塞写
  • Async compaction:单独的进程 / 任务跑,写入持续低延迟,但需要额外作业管理

触发条件常用 hoodie.compact.inline.max.delta.commits(每多少次 delta commit 后压一次),也可以按时间或日志大小。

怎么选?

排除特殊情况,按这个决策树:

  1. 业务读多写少 + 能接受小时级延迟COW。架构最简单,查询最快,没有 compaction 心智负担。
  2. 流式 CDC / 分钟级 freshnessMOR。写入吞吐和延迟才是主矛盾。
  3. 不确定 → 先 COW。等明显被写入瓶颈卡住、或者业务对 freshness 要求降到分钟级时再切 MOR。

COW 和 MOR 可以同表内通过重建迁移,但成本高,建表前想清楚比之后改更省事。

配置速查

# 建表时定表类型
hoodie.datasource.write.table.type = COPY_ON_WRITE      # 或 MERGE_ON_READ

# 写入操作类型
hoodie.datasource.write.operation = upsert              # insert / bulk_insert / delete

# MOR compaction(仅当 table.type = MERGE_ON_READ)
hoodie.compact.inline = false                           # true 则同步 compact
hoodie.compact.inline.max.delta.commits = 5

# 查询类型
hoodie.datasource.query.type = snapshot                 # incremental / read_optimized

一句话总结

COW = 写时合并,读快写贵MOR = 读时合并,写快读慢但能 compact 拉回来。前者是批,后者是流。

面试自测

1. COW 和 MOR 在写入和读取路径上的核心差异?

写入

  • COW:upsert 时找到受影响的 file group,把旧 Parquet base file 加更新后重写成新的 base file(写放大严重)
  • MOR:upsert 只追加写入对应 file group 的 Avro log 文件(delta commit),不动 base file

读取

  • COW:直接读 Parquet base file,无合并开销
  • MOR:snapshot 查询要把 log 和 base 做 on-the-fly merge;read-optimized 查询只读 base、跳过未 compact 的 log
2. MOR 的三种查询类型分别看到什么数据?
  • Snapshot:最新的合并结果(base + log 的 merge view),数据最新但查询最慢
  • Incremental:自某个 commit time 后的所有变更记录,CDC 场景常用
  • Read-Optimized:只读已 compact 的 base file,数据可能偏旧(落后于最后一次 compaction),但查询性能等同于纯 Parquet

MOR 表同步到 Hive 时一般会注册两张表:<table><table>_rt(snapshot)+ <table>_ro(read-optimized)。

3. 实时性要求分钟级、每分钟数十万条 upsert,选 COW 还是 MOR?为什么?

MOR

理由:

  • 高频 upsert 用 COW 会严重写放大,每次更新都重写整个 base file,写入吞吐撑不住
  • MOR 的 delta commit 只追加 log,写入开销小、延迟低,能满足分钟级 freshness
  • 后续靠 async compaction 把 log 合回 base,不阻塞写

报表端如果对延迟不敏感可以查 _ro 表换取查询性能;对延迟敏感就查 snapshot 表。

4. Hudi 的 timeline 是什么?解决了什么问题?

Timeline 是 Hudi 维护在 .hoodie/ 元数据目录里的事件源(event log),记录表上每一次变更操作(commit / deltacommit / compaction / clean / rollback …)。

每个 instant 包含 action、instant time(单调递增)、state(REQUESTED → INFLIGHT → COMPLETED)。

它解决了几个核心问题:

  • Snapshot Isolation:读时按 timeline 锚定 instant time,看到一致的快照
  • Time Travel:能查任意历史 commit 时刻的数据
  • 故障恢复:未 COMPLETED 的 instant 可被识别并 rollback
  • Incremental query:能高效拿到某个 commit 之后的变更
5. file group 和 file slice 的区别?为什么这么设计?
  • File group:分区内由 file ID 唯一标识的一组数据,同一个 key 的多次写入会落到同一个 file group(key 与 file group 的映射稳定)
  • File slice:file group 在某个 instant 时刻的物化版本,由一个 base file + 0 或多个 log file 组成

设计动机:

  • 稳定的 file group 让 upsert 能高效定位待更新文件(通过索引:bloom / simple / HBase 等)
  • 多个 file slice 表达版本演化,支持时间旅行 + 安全的 rollback / clean
  • 这套抽象既适配 COW(每次写入产生新 slice,旧 slice 可清理)也适配 MOR(slice 内的 log 会被 compact 进新 slice 的 base)
6. compaction 是干什么的?inline vs async 怎么选?

仅 MOR 表需要。Compaction = 把 log files 合并到 base file,产生新的 file slice。不做 compaction,log 会越积越多、读取越来越慢。

  • Inline compaction:写入进程在同一作业里触发 compaction
    • 优点:简单,不用单独运维
    • 缺点:阻塞写入路径,延迟尖刺
  • Async compaction:单独的作业 / 线程 / 服务(如 HoodieCompactor)定时跑
    • 优点:写入路径保持低延迟
    • 缺点:需要额外作业管理、资源隔离

低吞吐场景用 inline;高吞吐 / 流式入湖用 async。

7. 线上一个 COW 表越来越大、写入耗时不断攀升,怎么诊断和优化?

排查路径:

  1. 看 upsert 分布:是不是写放大严重——少量 key 改动牵涉到大量 file group。hoodie.parquet.small.file.limit / hoodie.parquet.max.file.size 调整 base file 大小,让单文件不要太大
  2. 看更新热度:如果大量 file group 都在被频繁改写 → COW 已经不适合,迁 MOR
  3. 看索引类型hoodie.index.type 默认 BLOOM,海量小键场景可能扫得多,可考虑 SIMPLE / BUCKET / RECORD_LEVEL_INDEX(取决于版本)
  4. 看分区策略:分区粒度太细(每分区少量数据)→ 合并分区;太粗 → 拆细
  5. clustering:把分散在多个 file group 的相关数据物理重新组织,减少未来更新的写放大

最后选项:迁移到 MOR,业务侧通过查 _ro 表保住查询性能。

8. Hudi 怎么实现 time travel?

依赖 timeline + 多版本 file slice。

  • 每次写入提交后,旧 file slice 不会立即删除——clean 操作根据策略(如 KEEP_LATEST_COMMITS 保留最近 N 次提交)懒删
  • 查询时通过 hoodie.datasource.read.begin.instanttime / hoodie.datasource.read.end.instanttime(或 SQL 的 TIMESTAMP AS OF / VERSION AS OF)指定目标 instant time
  • 读路径根据 instant time 从 timeline 找到对应的 file slice 版本,组装快照

只要目标 instant 还在 timeline 中、对应的 file slice 还没被 clean 掉,就能读到那个时刻的数据。注意 clean 的保留策略决定了能回溯多远。