【Hudi】 Timeline — Hudi 的事务日志

28 阅读 2709 字 · 约 10 分钟

Hudi 表目录下有个 .hoodie/ 文件夹,里面放的不是数据,是元数据。这套元数据叫 Timeline,是 Hudi 读写一致性的核心。

为什么需要 Timeline

Parquet 文件本身是死的——它不会告诉你"我现在是最新版本"还是"已经被更新替代了",也不会告诉你"写我的那个作业成功提交了"还是"写到一半挂了"。

如果没有一个全局的事件记录,读端没法判断:

  • 哪些文件是当前最新的
  • 哪些文件是旧版本该清掉的
  • 哪个写入操作已经完成、哪个还在进行中

Timeline 解决的就是这个问题:把每次写操作变成一个明确的事件,按时间顺序记录下来。所有读者和写者都看这份日志来协调。

为什么要用文件系统而不是数据库或 Redis?两个原因:

  1. 零额外依赖。Hudi 跑在 HDFS/Spark/Flink 上,这些环境不一定有数据库或 Redis。用文件系统做元数据,部署不引入新组件,不需要额外运维。
  2. 一致性靠存储本身的原子操作。Timeline 文件和数据文件在同一个存储上,用文件系统的原子 rename 就能保证"文件出现 = 提交完成",不需要分布式锁或两阶段提交。

引入外部存储来做元数据不是不行,但那样 Hudi 就从一个"库表"变成了一个有外部依赖的服务——和它站在现有基础设施之上的定位不符。

Timeline 的结构

Timeline 是一个事件流,每个事件叫一个 instant。可以理解为:Hudi 用文件系统模拟了一个 WAL(Write-Ahead Log)——传统数据库把操作日志顺序写在磁盘上,Hudi 把同样的东西写在 HDFS/对象存储上,用文件名编码状态,用 ls 替代索引查找。

每个 instant 有固定的结构:

instant = (commitTime, action, state)
  • commitTime: 时间戳,也是全局排序的依据。所有 instant 按 commitTime 严格有序
  • action: 这个操作是干嘛的。常见有 commit(提交数据)、deltacommit(MOR 的增量提交)、compaction(合并 log→parquet)、clean(清理旧文件)、rollback(回滚)
  • state: 这个操作的当前状态。requested(已申请)→ inflight(进行中)→ completed(已完成)

.hoodie/ 目录里存的不是数据库,就是按这个结构组织的文件:

.hoodie/
├── 20240814093000.commit          # commit 的完成标记
├── 20240814093000.inflight         # commit 进行中
├── 20240814093000.requested        # commit 已申请
├── 20240814094500.clean            # clean 完成
├── 20240814094500.clean.inflight  # clean 进行中
└── ...

用文件名编码 instant 信息,不需要查任何数据库。HDFS/对象存储上的 ls 就是全部。

高频写入下小文件会堆积,Hudi 通过归档(把旧 instant 合并进 .hoodie/archived/)来控制数量。但这个细节对理解 Timeline 机制不关键,知道有这回事就行。

三个状态的意义

requested → inflight → completed 这个状态机不是为了复杂而复杂,它解决的是:文件系统上没有事务,但你可以用文件名模拟事务

一个 commit 的生命周期:

  1. requested: 写端说"我要开始写了"。Timeline 里出现一个 .requested 文件。其他写端看到就知道有人在干活,别冲突
  2. inflight: 写端正在写数据。数据文件写到分区目录,还没最终确认
  3. completed: 数据文件写完了,写端把 .commit 文件写到 Timeline。读端看到 .commit 就知道"这些文件是正式的了,可以读"

如果写到一半挂了,.inflight 文件留在那里但没有对应的 .commit。下一个写端或 clean 操作发现后,把写到一半的中间文件清掉——这就是 rollback。

用 Timeline 实现查询

读最新快照

读端扫一遍 Timeline,找出所有 completed 的 commit,拿到每个 commit 对应的文件列表。合并起来就是当前最新数据。不需要查任何外部元数据库,Timeline 文件本身就是答案。

Time Travel(读历史)

Hudi 支持按 commitTime 读历史快照:

SELECT * FROM orders
TIMESTAMP AS OF '2024-08-14 09:00:00'

原理很简单:从 Timeline 里找 <= 09:00:00 的最后一个 commit,读那个时间点的文件列表,略过更晚的 commit。

因为每个 commit 都对应一个明确的文件集合,时间旅行就是"往前翻几个 commit"。

Incremental Query(读增量)

不读全量,只读某个时间点之后的变化:

SELECT * FROM orders
WHERE _hoodie_commit_time > '2024-08-14 08:00:00'

Hudi 给每条数据加了元字段 _hoodie_commit_time,记录它属于哪个 commit。增量查询就是找出 commitTime 更大的那些数据文件,只读它们。

常用 action 类型

除了 commit,Timeline 上还有几个重要的 action:

  • deltacommit: MOR 表的增量提交。和 commit 逻辑一样,但数据写在 Avro log 文件里而不是 Parquet
  • compaction: 把 MOR 的 log 文件合并进 data file。减少读的时候的合并开销。compaction 本身也是个 commit——合并完的数据文件像普通数据一样被 commit 标记
  • clean: 删除过期的旧文件版本。COW 表每次重写都会留下旧 Parquet 文件,clean 按保留策略(比如保留最近 3 个 commit)清掉更老的
  • rollback: 回滚一个未完成的 commit,清理中间文件。不是人工操作,是自动恢复

这几个 action 串起来就是 Hudi 的日常运维:写数据(commit)→ 合并优化(compaction)→ 清理旧文件(clean)。Timeline 把它们都管在一条事件流里,状态机驱动。

小结

Timeline 不复杂,就是一个事件日志。但它让 Hudi 在无事务的文件系统上实现了:

  • 原子性: 文件出现 = 提交完成,没 .commit 文件 = 没提交
  • 一致性: 所有读者看同一个 Timeline,没有"读到一半"的中间态
  • 时间旅行: 往 Timeline 前面翻就是历史快照
  • 增量读: 只看新 commit 对应的文件,不扫全表

下回看到 .hoodie/ 目录,不用怕——它就是 Hudi 的记录本。