Hudi 基础概念:表类型、索引机制、时间线与 Instant
在真正理解 Hudi 的写时复制(Copy on Write,COW)与读时合并(Merge on Read,MOR)之前,必须先建立对 Hudi 核心数据结构的整体认知。Hudi 表并非一个简单的“目录 + 文件”组合,而是一套带有事务语义、时间轴管理和索引加速的数据湖存储体系。本节将围绕四个关键概念展开:表类型(COW/MOR)、索引机制、时间线(Timeline)与 Instant,为后续深入剖析两种表类型的写入与读取路径打下基础。
2.1 表类型:COW 与 MOR 的本质差异
Hudi 提供了两种表类型,它们决定了数据在存储层如何组织、更新如何发生以及查询时看到的数据形态。理解二者的核心差异,是选择合适表结构的前提。
- Copy on Write(COW):数据以列式文件(Parquet)存储。每次更新(Update)或删除(Delete)操作,都会重写包含受影响记录的文件,生成新版本的文件,旧文件在清理(Clean)后删除。读取时无需合并,因为每个文件都是完整且自洽的。
- Merge on Read(MOR):数据以列式文件(Parquet)为基础,同时追加基于行的 Avro 文件(称为 Log 文件)记录增量更新。读取时需将 Base 文件与 Log 文件合并,以呈现最新数据。这种方式牺牲了部分读性能,换取了更低的写放大(Write Amplification)。
2.2 索引机制:定位记录所属文件
无论是 COW 还是 MOR,更新操作都需要先找到“记录当前位于哪个文件”。如果没有索引,Hudi 必须全表扫描所有文件才能定位目标记录,这在大型表上代价极高。Hudi 的索引机制正是为了解决这一“定位”问题而设计的。
Hudi 索引的核心作用是将一条记录的 记录键(Record Key) 映射到 文件组 ID(File Group ID)。文件组是 Hudi 内部管理的基本单元,一个文件组包含一个 Base 文件以及可能存在的多个 Log 文件。索引本质上是一张哈希表或查找表,键为记录键,值为文件组 ID。
| 索引类型 | 工作原理 | 适用场景 |
|---|---|---|
| Bloom Index(布隆索引) | 基于布隆过滤器,快速判断记录是否可能存在于某个文件中 | 默认索引,适用于大多数场景,内存开销可控 |
| Simple Index(简单索引) | 直接比较记录键与文件中的键集合 | 小表或测试环境,实现简单但效率较低 |
| HBase Index | 将映射关系存储在外部 HBase 表中 | 超大规模表,索引数据量超过本地内存容量 |
| Bucket Index(桶索引) | 通过哈希将记录固定分配到桶中,桶即文件组 | 写入量极大且键分布均匀的场景,避免索引查询开销 |
2.3 时间线(Timeline)与 Instant:Hudi 的事务日志
Hudi 的核心设计之一是其时间线机制。时间线记录了表上发生的所有操作(Instant),类似于数据库的事务日志或 WAL(Write-Ahead Log)。每次写入、提交、清理、压缩等操作都会在时间线上留下一个不可变的“事件记录”。
一个 Instant 由三部分组成:
- 操作类型(Action):如 COMMIT(提交写入)、CLEAN(清理旧文件)、COMPACT(合并 MOR 的 Log 文件)、ROLLBACK(回滚失败操作)等。
- 时间戳(Timestamp):格式为
yyyyMMddHHmmss,例如20250614103000,用于标识操作的先后顺序。 - 状态(State):包括 REQUESTED(已请求)、INFLIGHT(执行中)、COMPLETED(已完成)等。
时间线以文件形式存储在 Hudi 表的元数据目录中(通常是 .hoodie 目录)。每个 Instant 对应一个元数据文件,文件名包含时间戳和操作类型。通过读取时间线,Hudi 可以知道哪些数据文件是当前有效的、哪些是过期需要清理的、哪些操作尚未完成需要回滚。
# 示例:Hudi 表 .hoodie 目录下的时间线文件
.hoodie/
├── 20250614103000.commit.requested
├── 20250614103000.commit.inflight
├── 20250614103000.commit
├── 20250614113000.clean
├── 20250614123000.compaction.requested
└── 20250614123000.compaction.inflight
时间线在读写流程中扮演着“仲裁者”角色:
- 写入端:在写入数据前,先向时间线请求一个新的 Instant(状态为 REQUESTED),然后执行写入操作(状态变为 INFLIGHT),最后提交(状态变为 COMPLETED)。如果写入失败,则执行 ROLLBACK 操作。
- 读取端:查询引擎在读取时,会扫描时间线,确定哪些文件组是“活跃”的(即最近一次 COMMIT 之后仍然有效的文件),从而避免读取到过期数据。
- 清理与压缩:CLEAN 操作会删除早于某个时间点的旧版本文件;COMPACT 操作会将 MOR 表的 Log 文件合并到新的 Base 文件中,并更新时间线。
2.4 案例:一次更新操作的时间线流转
假设我们有一张 COW 表,需要更新一条记录。整个过程可以拆解为以下步骤,每一步都对应时间线上的状态变化:
// 伪代码示意
// 1. 写入端开始,生成时间戳 20250614150000
// 2. 向时间线写入 20250614150000.commit.requested
// 3. 通过索引(Bloom Index)定位到订单 1001 位于文件组 FG-001
// 4. 读取 FG-001 的 Base 文件,找到包含订单 1001 的 Parquet 文件
// 5. 将新数据与旧数据合并,重写整个 Parquet 文件(COW 特性)
// 6. 写入新文件,更新文件系统视图
// 7. 向时间线写入 20250614150000.commit(状态 COMPLETED)
// 8. 旧版本的 Parquet 文件标记为待清理
在这个过程中,时间线保证了操作的原子性:如果步骤 5 或 6 失败,Hudi 会执行 ROLLBACK,将时间线上的 Instant 标记为回滚状态,并删除未完成写入的数据文件,从而保证表的一致性。
2.5 误区与复盘:常见理解偏差
实际上,COW 表在写入过程中也可能产生临时的 Log 文件(用于缓冲写入),但最终会合并到 Parquet 文件中。COW 表的查询路径不会读取 Log 文件,而 MOR 表的查询路径则必须合并 Log 文件。
一个文件组(File Group)可能包含多个文件版本(File Slice)。例如,COW 表经过多次更新后,一个文件组下会有多个 Parquet 文件版本,但只有最新版本是活跃的。索引定位到的是文件组,而不是具体某个文件版本。
如果时间线文件过多(例如频繁提交小批次写入),读取端需要扫描的时间线元数据就会增加,可能导致读取延迟上升。因此,合理设置写入批次大小、定期执行 CLEAN 操作,是维护 Hudi 表性能的重要实践。