前两篇分别讲了 Hudi 的定位和测试环境安装。到这里已经可以用 Spark DataSource 写出一张 Hudi 表,也能在 HDFS 上看到 .hoodie 目录和 Parquet 文件。
但如果只是看到这些文件,还不能真正理解 Hudi。普通 Hive 表也有目录,也有 Parquet 文件。Hudi 真正不同的地方在于:它会把每一次写入、压缩、清理、回滚都记录到 Timeline 中,再用 FileGroup 和 FileSlice 管理同一批记录的多个文件版本。
1. Timeline 基础
1.1 背景问题
先从一个普通 Hive / Parquet 表的问题说起。
假设有一张订单表,第一次写入两条数据:
id=1, name=book, price=39.9
id=2, name=pen, price=3.5
过了一段时间,订单 id=1 的价格需要修正,同时新增一条 id=3:
id=1, name=book, price=45.8
id=3, name=notebook, price=12.0
如果只是普通 Parquet 文件,通常有几种做法:
- 直接追加新文件,下游查询时自己按主键去重。
- 重写整个分区,覆盖旧文件。
- 额外写一张修正表,查询时再关联。
这些做法都能用,但问题也明显:谁来判断最新版本?写入失败时哪些文件可见?下游如何只读增量?旧文件什么时候清理?同一条记录到底属于哪个文件?
Hudi 解决这类问题时,引入了几层关键抽象:
| 概念 | 解决的问题 |
|---|---|
| Timeline | 表上发生过哪些动作,哪些已经完成,哪些还在进行 |
| Instant | Timeline 上一次具体动作的记录 |
| FileGroup | 同一批记录在文件层面的逻辑归属 |
| FileSlice | 某个 FileGroup 在一个时间点上的文件视图 |
| Base File | 列式基础文件,COW 表里通常是 Parquet |
| Log File | MOR 表中追加增量变更的日志文件 |
理解这些概念以后,再看 Hudi 的 Upsert、Incremental Query、Compaction 和 Clean,逻辑会清楚很多。
1.2 先准备一张有多次提交的 Hudi 表
上一篇已经创建过一张 COW 表:
hdfs://localhost:9000/hudi/demo_hudi_orders
本文在这张表上再执行一次 Upsert,让 Timeline 中出现新的 commit,也让 HDFS 目录下出现同一个 FileGroup 的多个 Base File 版本。
后续 hdfs dfs 命令中的 /hudi/demo_hudi_orders 与 Spark 中的 hdfs://localhost:9000/hudi/demo_hudi_orders 指向同一张表,只是省略了默认文件系统前缀。这里沿用上一篇环境,hudi-spark3.5-bundle_2.12-1.0.2.jar 已经放入 Spark jars 目录;如果没有提前配置,需要在 spark-submit 中通过 --jars 指定 Hudi bundle。
示例脚本如下:
cat > /tmp/hudi_second_commit.py <<'PY'
from pyspark.sql import SparkSession
spark = (SparkSession.builder
.appName("hudi-second-commit")
.master("local[2]")
.config("spark.sql.catalogImplementation", "in-memory")
.getOrCreate())
spark.sparkContext.setLogLevel("WARN")
base_path = "hdfs://localhost:9000/hudi/demo_hudi_orders"
rows = [
(1, "book", 45.8, 20250627144700),
(3, "notebook", 12.0, 20250627144710)
]
df = spark.createDataFrame(rows, ["id", "name", "price", "ts"])
(df.write.format("hudi")
.option("hoodie.table.name", "demo_hudi_orders")
.option("hoodie.datasource.write.table.type", "COPY_ON_WRITE")
.option("hoodie.datasource.write.recordkey.field", "id")
.option("hoodie.datasource.write.precombine.field", "ts")
.option("hoodie.datasource.write.operation", "upsert")
.mode("append")
.save(base_path))
(spark.read.format("hudi")
.load(base_path)
.select("_hoodie_commit_time", "id", "name", "price", "ts")
.orderBy("id")
.show(truncate=False))
spark.stop()
PY
执行命令:
spark-submit \
--master local[2] \
--conf spark.sql.catalogImplementation=in-memory \
/tmp/hudi_second_commit.py
查询结果如下:
+-------------------+---+--------+-----+--------------+
|_hoodie_commit_time|id |name |price|ts |
+-------------------+---+--------+-----+--------------+
|20250627144723047 |1 |book |45.8 |20250627144700|
|20250615210847801 |2 |pen |3.5 |20250615210810|
|20250627144723047 |3 |notebook|12.0 |20250627144710|
+-------------------+---+--------+-----+--------------+
可以看到,id=1 已经变成新的价格,id=2 仍然保留旧版本,id=3 是新增数据。_hoodie_commit_time 说明每条记录来自哪个 commit。
这就是 Hudi Snapshot Query 的直观效果:用户看到的是表的最新快照,而不是自己去拼多个 Parquet 文件。
在执行本次 upsert 前,我还执行过一次用于生成文件版本的测试写入,因此后面的 Timeline 中可以看到 20250627144635580 和 20250627144723047 两个 2025-06-27 的 commit。Snapshot Query 展示的是最新有效结果,不等于把目录里所有 Parquet 文件内容直接合并出来。
1.3 Timeline 是什么
Timeline 是 Hudi 表的时间线,也可以理解成这张表的事务日志。
每次写入、压缩、清理、回滚、保存点,都会在 Timeline 上留下记录。读取端不是简单地扫描目录里所有文件,而是先根据 Timeline 判断哪些 Instant 已经完成,再构造对应的文件视图。
官方文档里对 Timeline 的描述很直接:Hudi 会维护表上所有动作的时间线,用来提供某个时间点的表视图,也支持按数据到达顺序做增量读取。落到实际使用里,我更愿意这样理解:
Timeline 决定“哪些文件属于这张表的哪个版本”,而不是让查询引擎直接相信目录里出现的所有文件。
这点非常重要。文件系统只知道文件存在,不知道文件是否属于一个已经完成的事务。Timeline 则告诉 Hudi:哪些写入已经完成,哪些写入还在进行,哪些写入失败后应该回滚。
1.4 Instant 是什么
Instant 是 Timeline 上的一次动作记录。一个 Instant 通常包含三个要素:
| 要素 | 含义 |
|---|---|
| action | 这次动作的类型,比如 commit、deltacommit、clean |
| instant time | 这次动作在 Timeline 上的时间标识 |
| state | 当前状态,比如 REQUESTED、INFLIGHT、COMPLETED |
常见 action 如下:
下面列的是常见 action 类型,本文测试表中主要能看到 commit;compaction、clean、rollback、savepoint、clustering 会在后续场景中逐步出现。
| action | 说明 |
|---|---|
| commit | COW 表一次原子写入 |
| deltacommit | MOR 表一次增量写入,可能只写 Log File |
| compaction | 把 Log File 合并进 Base File |
| clean | 清理不再需要的旧文件版本 |
| rollback | 回滚失败或不完整写入 |
| savepoint | 标记一批文件版本,防止被 cleaner 清理 |
| clustering | 重排文件布局,改善查询或文件大小 |
状态流转可以画成下面这样:

这张图主要说明一个 Instant 从 REQUESTED 到 INFLIGHT,再到 COMPLETED 的生命周期。
1.4.1 REQUESTED
REQUESTED 表示动作已经被调度,计划已经生成,但还没有真正执行。
比如 compaction 可以先被 schedule,生成一个 compaction plan,然后等异步任务真正执行。对于普通写入或表服务来说,REQUESTED 都表示这个 Instant 已经被 Hudi 记录为一个待执行动作;只是在 compaction、clustering 这类表服务里,它更明显地对应“先 schedule,再执行”的流程。
1.4.2 INFLIGHT
INFLIGHT 表示动作正在执行。
写入任务可能已经在生成文件、写 marker、更新 metadata,但还没有提交完成。这个阶段如果任务失败,Hudi 需要知道哪些中间文件应该被清理或回滚。
1.4.3 COMPLETED
COMPLETED 表示动作完成,对读取端可见。
对查询来说,关键不是“有没有写出 Parquet 文件”,而是“对应 Instant 是否已经完成”。这也是 Hudi 实现快照隔离和原子提交的核心。
1.5 Hudi 1.0.x 的 Timeline 文件命名
在本文环境中,Timeline 位于:
/hudi/demo_hudi_orders/.hoodie/timeline
查看命令:
hdfs dfs -ls /hudi/demo_hudi_orders/.hoodie/timeline
服务器上的真实输出如下:
Found 10 items
-rw-r--r-- 1 root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/timeline/20250615210847801.commit.requested
-rw-r--r-- 1 root supergroup 3420 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/timeline/20250615210847801.inflight
-rw-r--r-- 1 root supergroup 3870 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/timeline/20250615210847801_20250615210859455.commit
-rw-r--r-- 1 root supergroup 0 2025-06-27 14:46 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144635580.commit.requested
-rw-r--r-- 1 root supergroup 3524 2025-06-27 14:46 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144635580.inflight
-rw-r--r-- 1 root supergroup 3957 2025-06-27 14:46 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144635580_20250627144644147.commit
-rw-r--r-- 1 root supergroup 0 2025-06-27 14:47 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144723047.commit.requested
-rw-r--r-- 1 root supergroup 3524 2025-06-27 14:47 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144723047.inflight
-rw-r--r-- 1 root supergroup 3957 2025-06-27 14:47 /hudi/demo_hudi_orders/.hoodie/timeline/20250627144723047_20250627144731846.commit
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/timeline/history
这里能看到几类文件:
20250615210847801.commit.requested
20250615210847801.inflight
20250615210847801_20250615210859455.commit
20250627144635580.commit.requested
20250627144635580.inflight
20250627144635580_20250627144644147.commit
这里的 inflight 文件名是本文 Hudi 1.0.2 环境中的实际输出。不同 Hudi 版本和 Timeline layout 下,inflight / requested / completed 文件名可能略有差异,排查时以 hoodie.properties 中的 Timeline layout 和实际目录为准。
需要注意 Hudi 1.0.x 的一个变化:Hudi 1.0.x Technical Spec 中完成态 Instant 的文件名格式可以理解为:
beginInstantTime_completionInstantTime.action
在本文的 commit 场景中就是:
requestedTime_completionTime.commit
例如:
20250627144723047_20250627144731846.commit
前半段可以理解为 requested time,后半段是 completion time。Hudi 1.0 之后对 Timeline 做了较大调整,Timeline 默认放在 .hoodie/timeline 目录下,并且完成态 Instant 会携带 completion time。这和很多基于旧版本写的文章不完全一样。
同一个目录下的 history 也值得注意。Hudi 1.0.x 的 Timeline 采用新的 LSM Timeline 布局,活跃 Timeline 不会无限堆积所有历史 Instant。随着提交次数增加,较早的 Timeline 元数据会进入历史 / 归档相关结构,用于降低活跃 Timeline 的扫描成本。日常排查最近写入时,优先看活跃 Timeline;排查更早的提交、清理、回滚记录时,再结合 history 目录继续分析。
到这里可以先记住三点:第一,Hudi 1.0.x 的活跃 Timeline 在 .hoodie/timeline;第二,完成态 Instant 文件名会带 completion time;第三,history 不是普通备份目录,而是 Timeline 历史 / 归档结构的一部分。
1.6 Arrival Time 与 Event Time
理解 Timeline 时,容易把两个时间混在一起:
| 时间 | 含义 | 示例 |
|---|---|---|
| Arrival Time | 数据到达 Hudi 并提交的时间 | _hoodie_commit_time=20250627144723047 |
| Event Time | 业务事件自身发生的时间 | 订单更新时间、日志时间、binlog 时间 |
Hudi 的 commit time 更接近 Arrival Time。它记录的是这批数据什么时候进入 Hudi 表,而不是业务事件什么时候发生。
在本文测试表里,我们写入时使用 ts 作为 precombine 字段:
.option("hoodie.datasource.write.precombine.field", "ts")
ts 更接近业务上的 Event Time,在写入同一个 record key 出现多条候选记录时,Hudi 会用 precombine 字段决定保留哪条记录。它不是全表的事件时间排序字段,也不等同于 Timeline 的 commit time。_hoodie_commit_time 则是 Hudi 提交时生成的 Timeline 时间。
这个区别在 CDC 入湖中特别重要。比如一条旧 binlog 补数在当天才进入 Hudi,它的业务时间可能很早,但 commit time 是当天。下游做 Incremental Query 时,通常按 commit time 消费“新到达 Hudi 的变化”,而不是按业务事件时间简单过滤。
2. 文件布局与版本模型
2.1 文件布局
再来看 HDFS 上的真实目录。
因为本文示例是非分区 COW 表,所以数据文件会直接出现在 basePath 下;分区表则会先进入分区目录。
hdfs dfs -ls /hudi/demo_hudi_orders
服务器上的真实输出如下:
Found 5 items
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie
-rw-r--r-- 1 root supergroup 96 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie_partition_metadata
-rw-r--r-- 1 root supergroup 435085 2025-06-27 14:46 /hudi/demo_hudi_orders/50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-14-17_20250627144635580.parquet
-rw-r--r-- 1 root supergroup 435086 2025-06-27 14:47 /hudi/demo_hudi_orders/50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-14-17_20250627144723047.parquet
-rw-r--r-- 1 root supergroup 435066 2025-06-15 21:08 /hudi/demo_hudi_orders/50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-26-29_20250615210847801.parquet
目录中最关键的是两类内容:
.hoodie:表元数据目录,包含 Timeline、表配置、Metadata Table 相关目录等。- Parquet 文件:真正存放业务数据的 Base File。
注意,HDFS 目录中保留了多个历史 Base File,但 Snapshot Query 会根据 Timeline 选择当前有效的 FileSlice,因此查询结果不会简单等于所有 Parquet 文件内容的直接合并。
再看 .hoodie 目录:
hdfs dfs -ls /hudi/demo_hudi_orders/.hoodie
服务器上的真实输出如下:
Found 7 items
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/.aux
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/.index_defs
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/.schema
drwxr-xr-x - root supergroup 0 2025-06-27 14:47 /hudi/demo_hudi_orders/.hoodie/.temp
-rw-r--r-- 1 root supergroup 1257 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/hoodie.properties
drwxr-xr-x - root supergroup 0 2025-06-15 21:08 /hudi/demo_hudi_orders/.hoodie/metadata
drwxr-xr-x - root supergroup 0 2025-06-27 14:47 /hudi/demo_hudi_orders/.hoodie/timeline
用图表示就是这样:

这张图主要说明 Hudi 表不是单个 Parquet 文件,而是 basePath、.hoodie 元数据目录和数据文件共同组成。
这里有几个点需要特别注意。
2.1.1 basePath
basePath 是一张 Hudi 表的根目录。
本文示例是:
hdfs://localhost:9000/hudi/demo_hudi_orders
无论 Spark、Flink 还是 Hive Sync,最终都要围绕这个路径识别同一张表。Hudi 表不是某一个单独 Parquet 文件,而是 basePath 下的数据文件、.hoodie 元数据和 Timeline 共同组成的。
2.1.2 partition
本文用的是非分区表,所以 Parquet 文件直接在 basePath 下。
如果是分区表,目录可能是:
/hudi/order_hudi/dt=2025-06-27/region=cn/
Hudi 的 partition 更多是文件组织方式。根据写入参数不同,分区字段可能保留在数据文件中,也可能只体现在路径中;但 Hudi 一定会写入自己的元字段,用来表达 commit time、record key、partition path、file name 等表语义。
2.1.3 .hoodie
.hoodie 是 Hudi 表的元数据目录。
本文环境中有这些内容:
.aux
.index_defs
.schema
.temp
hoodie.properties
metadata
timeline
hoodie.properties 中能看到表级配置:
hdfs dfs -cat /hudi/demo_hudi_orders/.hoodie/hoodie.properties | \
egrep "hoodie.table.name|hoodie.table.type|hoodie.table.version|hoodie.timeline.path|hoodie.timeline.layout.version|hoodie.table.recordkey.fields|hoodie.table.precombine.field|hoodie.table.base.file.format"
服务器上的真实输出如下:
hoodie.table.type=COPY_ON_WRITE
hoodie.table.precombine.field=ts
hoodie.timeline.layout.version=2
hoodie.table.version=8
hoodie.table.name=demo_hudi_orders
hoodie.table.recordkey.fields=id
hoodie.timeline.path=timeline
hoodie.table.base.file.format=PARQUET
不同版本或不同建表方式下,hoodie.properties 中的字段可能略有差异,如果某一项没有输出,以实际文件内容为准。
其中几个字段很关键:
| 配置 | 含义 |
|---|---|
| hoodie.table.name | 表名 |
| hoodie.table.type | 表类型,本文是 COPY_ON_WRITE |
| hoodie.table.version | 表格式版本 |
| hoodie.timeline.path | Timeline 目录 |
| hoodie.timeline.layout.version | Timeline 布局版本 |
| hoodie.table.recordkey.fields | 主键字段 |
| hoodie.table.precombine.field | 预合并字段 |
| hoodie.table.base.file.format | Base File 格式 |
这些配置决定了 Hudi 如何解释这张表。只拷贝 Parquet 文件而丢掉 .hoodie 目录,基本就丢掉了 Hudi 表语义。
2.2 FileGroup
FileGroup 是 Hudi 管理记录历史版本的核心单位。
一个 FileGroup 由 fileId 标识。在正常写入路径下,同一个 record key 会通过索引和文件布局规则定位到某个 FileGroup。后续更新时,Hudi 会尽量围绕这个 FileGroup 生成新的文件版本,而不是让同一条记录散落到完全无关的文件里。
本文表目录中可以看到多个 Parquet 文件,它们的 fileId 是相同的:
50540e89-d60a-4fa4-9186-3cf93e348b0c
文件名类似:
50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-14-17_20250627144635580.parquet
50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-14-17_20250627144723047.parquet
50540e89-d60a-4fa4-9186-3cf93e348b0c-0_0-26-29_20250615210847801.parquet
这说明它们属于同一个 FileGroup,只是不同时间点生成的不同 Base File 版本。
这里的 fileId 可以从文件名前半部分看出,后面的 0_0-14-17、0_0-26-29 更接近写入任务相关的 write token / task 信息,不要把整个文件名都当成 fileId。判断 FileGroup 时主要看 fileId,write token 不同不影响它们属于同一个 FileGroup 的判断。
FileGroup 的价值在于:更新不再是全表级别的混乱扫描,而是有一个比较稳定的文件组归属。索引机制后面会围绕 HoodieKey 到 FileGroup 的映射展开。
2.3 FileSlice
FileSlice 是 FileGroup 中某一个时间点的文件视图。
一个 FileSlice 通常由下面两部分组成:
| 组成 | COW 表 | MOR 表 |
|---|---|---|
| Base File | 通常是 Parquet Base File | 可能有 Base File,也可能在某些阶段主要由 Log File 承载变更 |
| Log File | 一般没有 | 可能有一个或多个 Log File |
在 COW 表中,一次 update 往往会生成新的 Base File。所以本文看到的是同一个 FileGroup 下多个 Parquet 文件版本。
在 MOR 表中,写入可以先追加到 Log File,之后通过 Compaction 合并成新的 Base File。也就是说,一个 FileSlice 可能是:
Base File + Log File 1 + Log File 2 + ...
下面这张图把 FileGroup 和 FileSlice 的关系画出来:

这张图主要说明同一个 FileGroup 下可以有多个 FileSlice,COW 与 MOR 的文件组成不同。
这里可以理解为:
- FileGroup 更像“这批记录的长期归属”。
- FileSlice 更像“这个 FileGroup 在某个时间点的可读版本”。
- COW 表更容易看到多个 Base File 版本。
- MOR 表更容易看到 Base File 加多个 Log File 的组合。
2.3.1 Base File 和 Log File 再补一层
从文件名看,Base File 通常就是 Parquet 文件,Log File 通常就是 MOR 表里的增量日志。但 Hudi 对这两类文件的使用,比“一个列式文件”和“一个日志文件”要更具体。
Base File 除了业务字段外,还会包含 Hudi 元字段;在使用 Bloom Index 等基于文件的索引场景中,Base File 的文件元数据中还可能携带 BloomFilter 等用于快速判断 record key 是否可能存在的信息。在 BloomFilter 正常构建且文件元数据未损坏的前提下,它可能有假阳性,但不应该有假阴性:如果它判断某个 key 一定不在文件中,Hudi 就可以跳过这个文件;如果判断可能存在,才需要继续读取确认。下一篇讲 Bloom Index 时会展开这个点。
Log File 也不是普通文本日志。Hudi 的 Log File 是自己编码的日志格式,内部以 LogBlock 组织数据。一个 LogBlock 会带有块类型、大小、内容和校验相关信息,读取 MOR 表时,Hudi 才能把 Base File 和后续 LogBlock 中的插入、更新、删除语义合并起来。
不建议用 cat、head 这类普通文本方式直接理解 Log File 内容,应通过 Hudi Reader 或相关工具读取。
所以 FileSlice 不是简单的“列式文件加几个附属文件”。它更像一个可读文件视图:Base File 提供基础列式数据,Log File 提供这之后的变更,Timeline 决定哪些 FileSlice 当前有效。
2.4 MVCC 与多版本文件
Hudi 能让读写并行,一个重要基础就是 MVCC。
写入任务不会原地修改旧 Parquet 文件,而是写出新的文件版本,然后通过 Timeline 提交。读取任务根据某个已经完成的 Instant 构造文件视图。这样一来:
- 正在进行的写入不会直接污染当前查询。
- 在 cleaner 保留策略允许的范围内,旧查询可以继续读旧版本文件。
- 新查询可以在 commit 完成后读到新版本。
- Clean 可以在确认旧版本不再需要后清理历史文件。
这和数据库里的 MVCC 思路有相似之处,但实现载体不同。数据库通常在存储引擎里维护行版本;Hudi 则是在文件系统上维护文件版本和 Timeline。
2.5 Clean 和 Compaction 对文件布局的影响
Timeline 上除了写入,还有表服务动作。
2.5.1 Clean
Clean 用来清理旧的 FileSlice 或旧文件版本。
如果没有 clean,COW 表每次 update 都生成新的 Base File,历史文件会越来越多。Hudi 需要在查询安全和存储成本之间做平衡:既不能太早删掉还可能被旧查询使用的文件,也不能无限保留所有版本。
后续可以结合 hoodie.cleaner.policy、hoodie.cleaner.commits.retained 等配置理解 cleaner 如何决定保留多少历史版本。
2.5.2 Compaction
Compaction 主要针对 MOR 表。
MOR 表写入时可以把更新追加到 Log File,降低写入延迟。但 Log File 积累多了以后,Snapshot Query 每次都要合并 Base File 和 Log File,查询成本会上升。
Compaction 会把 Log File 中的变更合并进新的 Base File,形成新的 FileSlice。后面讲 MOR 表时会专门分析这个流程。
2.6 增量消费为什么依赖 Timeline
Hudi 的 Incremental Query 并不是简单扫描某个分区目录,而是按 Timeline 上的 commit 范围读取变化。
比如下游任务上一次消费到:
20250615210847801
下一次可以从这个 commit 之后继续读:
20250627144635580
20250627144723047
这样下游关心的是“哪些提交之后发生了变化”,而不是自己去解析 HDFS 目录下新增了哪些文件。
这就是 Timeline 对增量 Pipeline 的价值。它把文件系统中的一堆文件,组织成有顺序、有状态、可回放的表变更历史。
生产环境中还要配合 Timeline archive 和 cleaner 保留策略。如果下游任务停太久,所需的早期 commit 元数据或文件版本可能已经进入归档 / 被清理,需要从更近的 checkpoint 恢复,或者重新全量同步。
3. 常见问题
3.1 为什么 .hoodie/timeline 下还有 requested 和 inflight 文件
在本文环境中可以看到:
20250627144635580.commit.requested
20250627144635580.inflight
20250627144635580_20250627144644147.commit
这说明 Hudi 会保留 Instant 生命周期相关文件。普通 Snapshot Query 构造可见数据时主要依赖已经完成的写入类 Instant,同时也会受到已经完成的 rollback、clean、compaction、clustering 等表服务动作影响。requested 和 inflight 文件能帮助 Hudi 进行状态管理、故障恢复和回滚判断。
3.2 为什么同一个 FileGroup 下有多个 Parquet 文件
因为这是 COW 表,更新时会生成新的 Base File。旧文件不会立即消失,Hudi 会根据清理策略决定什么时候删除旧版本。
3.3 为什么 Snapshot Query 只看到最新结果
Snapshot Query 会根据完成态 Timeline 构造最新文件视图。虽然 HDFS 目录里有多个 Base File,但并不是所有文件都同时代表最新状态。Hudi 会选择当前有效的 FileSlice。COW 表通常选择最新有效 Base File;MOR 表则可能需要把 Base File 和后续 Log File 合并成最新快照。
3.4 为什么 commit time 不等于业务时间
commit time 表示数据进入 Hudi 表并提交的时间,更接近 Arrival Time。业务时间通常来自字段,比如订单更新时间、日志事件时间、binlog 时间。两者用途不同,不要混用。
3.5 Hudi 1.0.x 的 Timeline 路径为什么和旧资料不一样
Hudi 1.0 对 Timeline 布局做了调整,本文环境的 hoodie.properties 中可以看到:
hoodie.timeline.path=timeline
hoodie.timeline.layout.version=2
所以实际路径是:
.hoodie/timeline
如果照着旧版本文章去 .hoodie 根目录下找所有 commit 文件,可能会误判。
3.6 timeline/history 是不是普通备份目录
不是简单备份目录。Hudi 1.0.x 的 Timeline 采用新的 LSM Timeline 布局,history 更接近 Timeline 历史 / 归档结构的一部分,用来保存不再需要频繁参与当前文件视图构造的旧 Instant 相关信息。表刚创建时内容可能很少,运行时间长、提交次数多以后,归档历史会更有意义。
排查最近一次写入、查询为什么看不到数据时,优先看活跃 Timeline;排查很久以前的 commit、clean、rollback 记录时,再去看 history。
4. 核心判断与总结
4.1 Timeline 与文件布局的核心判断
Hudi 的核心不是“写出几个 Parquet 文件”,而是把文件变成有版本、有事务、有增量历史的表。
Timeline 解决的是表级动作顺序和可见性问题。没有 Timeline,文件系统只是一堆文件;有了 Timeline,Hudi 才知道哪些文件属于哪次提交,哪些提交可以被查询,哪些失败写入需要回滚。
FileGroup 解决的是记录归属问题。更新一条记录时,Hudi 不应该每次都全表扫描,而是要尽量通过索引把这条记录定位到候选 FileGroup,再完成更新写入。后面索引机制本质上就是服务于这个定位过程。
FileSlice 解决的是某个时间点的文件视图问题。COW 表里它通常表现为一个 Base File;MOR 表里它可能是 Base File 加多个 Log File。Snapshot Query、Read Optimized Query、Incremental Query 的差异,最终也会落到如何选择和合并 FileSlice 上。
所以我会把这几个概念串成一句话:
Timeline 管事务,FileGroup 管记录归属,FileSlice 管某个时间点的文件视图。
4.2 总结
这篇主要讲清楚了 Hudi 的几个底层概念:
- Timeline 是 Hudi 表的事务日志,记录写入、清理、压缩、回滚等动作。
- Instant 是 Timeline 上一次动作的记录,包含 action、instant time 和 state。
- Hudi 1.0.x 中 Timeline 默认位于
.hoodie/timeline,完成态 commit 文件会携带 completion time。 - FileGroup 用 fileId 标识,是记录历史版本管理的核心单位。
- FileSlice 是 FileGroup 在某个时间点的文件视图,COW 和 MOR 的表现不同。
- MVCC 让 Hudi 可以通过多版本文件实现快照读取和写入隔离。
- Incremental Query 能成立,是因为 Timeline 提供了有序的提交历史。
- Base File 除了业务数据外还包含 Hudi 元字段,并可能携带索引相关文件元数据;Log File 则通过 LogBlock 表达 MOR 表中的增量变更。