表类型
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 后压一次),也可以按时间或日志大小。
怎么选?
排除特殊情况,按这个决策树:
- 业务读多写少 + 能接受小时级延迟 → COW。架构最简单,查询最快,没有 compaction 心智负担。
- 流式 CDC / 分钟级 freshness → MOR。写入吞吐和延迟才是主矛盾。
- 不确定 → 先 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 表越来越大、写入耗时不断攀升,怎么诊断和优化?
排查路径:
- 看 upsert 分布:是不是写放大严重——少量 key 改动牵涉到大量 file group。
hoodie.parquet.small.file.limit/hoodie.parquet.max.file.size调整 base file 大小,让单文件不要太大 - 看更新热度:如果大量 file group 都在被频繁改写 → COW 已经不适合,迁 MOR
- 看索引类型:
hoodie.index.type默认BLOOM,海量小键场景可能扫得多,可考虑SIMPLE/BUCKET/RECORD_LEVEL_INDEX(取决于版本) - 看分区策略:分区粒度太细(每分区少量数据)→ 合并分区;太粗 → 拆细
- 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 的保留策略决定了能回溯多远。