2. Hudi核心概念
这一章,我们来啃Hudi最核心的几个概念。说实话,刚接触Hudi的时候,我也被这些术语搞得有点晕——Copy-on-Write、Merge-on-Read、Timeline、FileGroup、FileSlice……但搞懂了它们,你才算真正入了Hudi的门。
我习惯把这三块内容比作Hudi的“骨架”:存储类型决定了数据怎么写、怎么读;时间线记录了数据发生了什么变化;文件组织则定义了数据在磁盘上怎么摆放。三者缺一不可。
2.1 存储类型:Copy-on-Write vs Merge-on-Read
Hudi提供了两种存储类型,说白了就是两种不同的数据写入和读取策略。选哪个,取决于你的业务场景。
2.1.1 Copy-on-Write(COW)
COW的意思是“写时复制”。每次写入数据时,Hudi会把涉及的文件整个重写一遍。嗯,听起来有点笨重,但换来的是读取时极致的性能。
核心特点:
- 写入时:读取原文件 → 合并新数据 → 写入新文件 → 原子替换
- 读取时:直接读取最新版本的列式文件(Parquet),无需任何合并
- 文件格式:仅Parquet
我在项目中遇到过这样一个场景:某金融客户需要做实时风控查询,要求单条记录查询延迟低于100ms。COW就是最佳选择——因为读取时不需要合并任何增量日志,直接扫Parquet文件就行。
适用场景:
- 读多写少的分析型查询
- 对查询延迟敏感的场景(如实时报表、仪表盘)
- 数据量中等,写入频率不高
2.1.2 Merge-on-Read(MOR)
MOR的思路就完全不一样了。它把写入和读取分开优化:写入时只写增量日志(Avro格式),读取时再把基础文件和日志合并。
核心特点:
- 写入时:追加写入Avro格式的增量日志文件(.log),速度极快
- 读取时:需要合并基础Parquet文件 + 增量日志文件
- 文件格式:基础文件Parquet + 日志文件Avro
你想想看,如果业务场景是IoT传感器数据——每秒成千上万条写入,但查询频率很低,MOR就特别合适。写入延迟极低,读取时虽然慢一点,但可以通过压缩(Compaction)来优化。
注意:MOR读取时如果不做压缩,随着日志文件增多,读取性能会逐渐下降。我建议根据业务情况设置合理的压缩策略,比如每写入10000条记录触发一次小压缩。
2.1.3 对比总结
| 对比维度 | Copy-on-Write | Merge-on-Read |
|---|---|---|
| 写入放大 | 高(每次重写整个文件) | 低(仅追加日志) |
| 读取性能 | 优(直接读Parquet) | 中(需合并日志) |
| 写入延迟 | 较高 | 低 |
| 文件格式 | 仅Parquet | Parquet + Avro |
| 适合场景 | 读多写少、低延迟查询 | 写多读少、流式写入 |
2.2 Timeline时间线机制
Timeline是Hudi的“操作日志”。它记录了表上发生的每一次操作——提交、完成、回滚、压缩……说白了,就是Hudi的元数据核心。
我个人习惯把Timeline想象成一本账本:每一笔操作都是一个“Instant”(瞬间记录),按时间顺序排列。这样,任何时候你都能知道表处于什么状态。
2.2.1 Instant的组成
每个Instant包含三个要素:
- 时间戳:精确到毫秒的唯一标识,比如
20250214103000000 - 操作类型:COMMIT、COMPLETED、ROLLBACK、COMPACTION等
- 状态:REQUESTED(请求中)、INFLIGHT(执行中)、COMPLETED(已完成)
常见的Instant类型:
COMMIT:一次写入操作的提交COMPLETED:提交完成ROLLBACK:回滚操作COMPACTION:MOR表的压缩操作CLEAN:清理旧版本文件RESTORE:恢复到某个时间点
举个例子,一次写入操作的Timeline看起来是这样的:
20250214103000000.commit.requested
20250214103000000.commit.inflight
20250214103000000.commit.completed
嗯,这里要注意:如果写入过程中失败了,你会看到 inflight 状态一直挂着,没有对应的 completed。这时候就需要手动回滚或者触发自动回滚机制。
避坑指南:我曾经遇到过一个问题——写入任务挂了,但Timeline上还留着 inflight 的Instant,导致后续写入一直报错。解决方案是调用Hudi的 rollback API清理掉这个挂起的Instant。所以,监控Timeline的健康状态很重要。
2.2.2 Timeline的查询能力
Timeline最强大的地方在于——你可以基于它做时间旅行(Time Travel)查询。比如查询“今天上午10点的数据快照”,Hudi会通过Timeline找到那个时间点对应的文件版本。
-- 查询指定时间点的数据快照
SELECT * FROM hudi_table
TIMESTAMP AS OF '2025-02-14 10:00:00';
2.3 文件组织:FileGroup与FileSlice
理解了存储类型和时间线,我们再来看数据在磁盘上到底是怎么组织的。这里有两个核心概念:FileGroup 和 FileSlice。
2.3.1 FileGroup
FileGroup是一组文件的逻辑集合。你可以把它理解成Hudi表的一个“分区内的数据分片”。每个FileGroup都有一个唯一的FileGroup ID。
举个例子,假设一张Hudi表按日期分区,分区 dt=2025-02-14 下有3个FileGroup,每个FileGroup负责一部分数据。写入新数据时,Hudi会决定数据落到哪个FileGroup里。
FileGroup的特点:
- 一个FileGroup包含一个基础文件(Base File)和多个日志文件(Log Files)
- FileGroup内的数据是互斥的,不会重复
- FileGroup的数量决定了写入的并行度
2.3.2 FileSlice
FileSlice是FileGroup内的一个“版本快照”。每个FileSlice包含:
- 一个基础文件(Parquet格式)
- 零个或多个日志文件(Avro格式,仅MOR模式有)
你想想看,每次提交(Commit)都会在FileGroup内生成一个新的FileSlice。这样,同一个FileGroup下就有多个FileSlice,对应不同的数据版本。
我画个图帮你理解:
FileGroup-001
├── FileSlice-1 (commit_time=100)
│ ├── base_file_100.parquet
│ └── (无日志文件,COW模式)
├── FileSlice-2 (commit_time=200)
│ ├── base_file_200.parquet
│ └── (无日志文件,COW模式)
└── FileSlice-3 (commit_time=300)
├── base_file_300.parquet
└── (无日志文件,COW模式)
对于MOR模式,FileSlice会包含日志文件:
FileGroup-002
├── FileSlice-1 (commit_time=100)
│ ├── base_file_100.parquet
│ └── log_file_101.avro, log_file_102.avro
└── FileSlice-2 (commit_time=200)
├── base_file_200.parquet
└── (无日志文件,刚压缩完)
注意:FileSlice的数量直接影响存储空间和查询性能。如果FileSlice太多(比如频繁写入导致大量小版本),建议开启Hudi的清理(Clean)策略,保留最近N个版本即可。
2.3.3 文件组织与查询的关系
当Hudi执行查询时,它会:
- 根据查询条件定位到具体分区
- 在分区内找到所有FileGroup
- 对每个FileGroup,找到最新的FileSlice
- 读取FileSlice中的基础文件和日志文件(如果需要合并)
这个过程听起来复杂,但Hudi内部做了大量优化。比如,它会跳过那些不包含查询数据的FileGroup,减少IO开销。
个人经验:我在调优Hudi查询性能时,最常做的一件事就是检查FileGroup的数量。如果FileGroup太多(比如超过1000个),查询计划阶段就会变慢。这时候我会调整 hoodie.parquet.small.file.limit 参数,让小文件自动合并,减少FileGroup数量。
2.4 小结
这一章我们聊了Hudi的三个核心概念:
- 存储类型:COW适合读多写少,MOR适合写多读少
- Timeline:记录了所有操作,支持时间旅行查询
- 文件组织:FileGroup是数据分片,FileSlice是版本快照