2. Hudi核心概念:表格式、Copy-on-Write与Merge-on-Read、时间线、文件组与文件片

2.1 表格式:Hudi如何组织数据与元数据

Hudi本质上是一种表格式(Table Format),它构建在分布式文件系统(如HDFS、S3)之上,通过一组约定好的元数据文件来管理海量数据文件。与传统的Hive表(仅将分区目录映射为表)不同,Hudi在表目录中额外维护了一个.hoodie元数据目录,用于记录表的完整“操作历史”和“数据文件版本”。

一个Hudi表的基本目录结构如下:

user_events/
├── .hoodie/
│   ├── hoodie.properties          # 表配置(表类型、主键、payload类等)
│   ├── timeline/                  # 时间线目录(核心元数据)
│   │   ├── 20241101120000.commit
│   │   ├── 20241101120000.commit.requested
│   │   ├── 20241101120000.inflight
│   │   ├── 20241101130000.deltacommit
│   │   └── ...
│   └── filegroup/                 # 文件组元数据(部分版本)
├── partition_path=2024/11/01/
│   ├── 8f2a1c5e-...-0_0-0-0_20241101120000.parquet
│   └── .8f2a1c5e-...-0_0-0-0_20241101120000.log.1_0-0-1
└── partition_path=2024/11/02/
    └── ...

从存储角度看,Hudi表由数据文件(Parquet/ORC等列式文件)和日志文件(Avro格式的增量记录)组成;从逻辑角度看,Hudi通过时间线(Timeline)记录每一次写入、压缩、清理等操作,并通过文件组(File Group)与文件片(File Slice)将物理文件组织成可追溯、可版本化的数据集合。

核心要点:Hudi表格式的本质是“元数据驱动数据文件演进”。每一次写入都会在时间线上留下不可变的提交记录,而数据文件则通过文件组/文件片结构实现版本化管理,从而支撑ACID语义、增量查询和时间旅行。

2.2 Copy-on-Write 与 Merge-on-Read:两种表类型

Hudi根据数据写入时对文件的处理策略,将表分为两种类型:Copy-on-Write(COW)和Merge-on-Read(MOR)。这是Hudi最核心的存储模型选择,直接影响写入放大、读取延迟和实时性。

2.2.1 Copy-on-Write(写时复制)

COW表在每次写入时,直接读取目标文件组的最新文件片,将新数据与旧数据合并后重写为一个新的Parquet文件,并更新时间线。旧文件在提交完成后即被标记为无效(但物理删除由清理服务执行)。

  • 写入路径:新数据 → 读取旧文件 → 合并 → 写新Parquet → 提交commit
  • 优点:查询时只需读取列式文件,无日志合并开销,查询性能最佳;文件数量少,适合分析型工作负载。
  • 缺点:每次写入都会重写整个文件组的最新文件片,写入放大明显;写入延迟较高,不适合频繁的小批量更新。

2.2.2 Merge-on-Read(读时合并)

MOR表采用基础文件(Base File)+ 日志文件(Log File)的组合。写入时新记录以追加方式写入Avro格式的日志文件,不直接重写Parquet;查询时需将基础文件与日志文件动态合并,或由压缩(Compaction)服务异步将日志合并生成新的基础文件。

  • 写入路径:新数据 → 追加到.log文件 → 提交deltacommit(轻量级)
  • 读取路径:读取Base Parquet + 读取Log → 合并记录 → 返回结果
  • 优点:写入延迟低,适合高频增量写入;支持近实时查询(配合压缩策略可平衡读性能)。
  • 缺点:查询时若日志文件过多,合并开销大;需要额外管理压缩策略。
对比维度 Copy-on-Write (COW) Merge-on-Read (MOR)
数据文件格式 仅Parquet(列式) Parquet(Base)+ Avro(Log)
写入方式 合并重写整个文件片 追加写日志文件
提交类型 commit(较重) deltacommit(轻量)
写入放大 高 低
查询性能 最优(无合并) 受日志大小影响,需压缩
典型场景 批量ETL、分析查询为主 高频写入、近实时消费
选型建议:如果业务以T+1批量分析为主,更新频率低,选择COW可获得最佳查询性能;如果业务需要秒级到分钟级的数据可见性,且写入流量大(如日志、订单流水),选择MOR并配置合理的压缩策略。

2.3 时间线(Timeline):Hudi的事务日志

Hudi的时间线是表的中心元数据,记录了表从创建以来发生的所有操作。每个操作对应一个Instant(瞬间),包含三个状态:

  1. Requested(已请求):操作已提交请求,但尚未执行。
  2. Inflight(进行中):操作正在执行中。
  3. Completed(已完成):操作成功完成,数据已可见。

时间线中的操作类型主要包括:

  • commit:COW表的写入提交。
  • deltacommit:MOR表的写入提交(写日志文件)。
  • compaction:MOR表的压缩操作,将日志合并到基础文件。
  • clean:清理旧版本文件片,回收存储空间。
  • rollback:回滚失败的写入。
  • savepoint:设置恢复点,防止clean删除特定版本。
  • replacecommit:用于分区替换或批量删除等操作。

时间线是Hudi实现多版本并发控制(MVCC)和时间旅行(Time Travel)的基础。通过时间线,Hudi可以快速定位某个时间点表的最新状态,也可以回溯历史版本的数据。

2.4 文件组与文件片:数据文件的版本化组织

Hudi将每个分区下的数据文件组织为文件组(File Group),每个文件组包含一系列文件片(File Slice)。文件组是Hudi数据更新的最小逻辑单元,同一文件组内的数据具有相同的主键范围。

文件片是文件组内的一次版本快照,由以下部分组成:

  • 一个基础文件(Base File,Parquet格式),包含截至某个提交时刻的全部数据。
  • 零个或多个日志文件(Log File,Avro格式),记录该基础文件之后的增量更新(仅MOR表)。

每次写入操作会为涉及的文件组生成一个新的文件片。例如,一个COW表文件组经历了三次提交,则会产生三个文件片,但只有最新文件片是活跃的,旧文件片会被清理服务异步删除。

文件组ID: fg-8f2a1c5e
├── 文件片 v1 (commit 20241101120000)
│   └── 8f2a1c5e_0-0-0_20241101120000.parquet
├── 文件片 v2 (commit 20241101130000)
│   └── 8f2a1c5e_0-0-0_20241101130000.parquet
└── 文件片 v3 (commit 20241101140000) ← 当前活跃
    └── 8f2a1c5e_0-0-0_20241101140000.parquet

对于MOR表,一个文件片可能同时包含基础文件和多个日志文件:

文件组ID: fg-9b3d7e21
└── 文件片 v5 (deltacommit 20241101150000)
    ├── 9b3d7e21_0-0-0_20241101100000.parquet   (基础文件)
    ├── .9b3d7e21_0-0-0_20241101120000.log.1_0-0-1
    └── .9b3d7e21_0-0-0_20241101150000.log.2_0-0-1

文件组与文件片的设计带来了三个关键能力:

  1. 增量查询:通过时间线定位某个提交之后的文件片变化,即可读取增量数据。
  2. 版本回滚:指定时间点,读取该时刻对应的文件片版本。
  3. 高效更新:只需更新受影响的文件组,而非全表重写。
常见误区:初学者容易混淆“文件组”和“分区”。分区是物理目录的划分(如按日期),而文件组是分区内部数据文件的逻辑分组。一个分区可以包含多个文件组,每个文件组负责一部分主键范围的数据。文件组数量由写入时的分区策略和文件大小决定,过多的小文件组会导致元数据膨胀和查询性能下降。

2.5 综合案例:一次更新操作的全链路

假设有一个MOR表user_events,分区dt=2024-11-01下已有1个文件组,包含1个基础文件(提交于10:00)和1个日志文件(提交于11:00)。现在12:00有一批新的更新事件到达,Hudi的处理流程如下:

  1. 步骤1:写入端根据主键哈希将新记录路由到对应的文件组(本例为fg-9b3d7e21)。
  2. 步骤2:在时间线上创建deltacommit.requested(12:00)。
  3. 步骤3:将新记录追加写入一个新的日志文件.log.3_0-0-1,状态变为inflight。
  4. 步骤4:写入完成后,提交deltacommit,状态变为completed。此时文件组的最新文件片包含:基础文件(10:00)+ 日志1(11:00)+ 日志2(12:00)。
  5. 步骤5:查询时,Hudi读取基础文件和所有日志文件,按主键合并后返回最新结果。
  6. 步骤6:后台压缩服务(若配置)在达到阈值后,将基础文件与日志合并生成新的基础文件,并更新时间线为compaction完成。

下图展示了MOR表在一次更新和一次压缩后的文件片与时间线状态:

MOR表文件组与时间线状态流转 时间线 (Timeline) 10:00 commit 11:00 deltacommit 12:00 deltacommit 13:00 compaction 文件组 fg-9b3d7e21 的文件片演进 文件片 v1 (10:00) Base: .parquet (10:00) 无日志文件 文件片 v2 (11:00) Base: .parquet (10:00) Log: .log.1 (11:00) 文件片 v3 (12:00) 当前 Base: .parquet (10:00) Log: .log.1 + .log.2 文件片 v4 (13:00 压缩后) 新Base: .parquet (13:00) 日志已合并,无Log文件 Compaction 合并 图例 基础文件 (Parquet) 日志文件 (Avro)

2.6 常见误区与复盘

误区1:认为COW表不会产生小文件。实际上,如果写入频率高且每次写入的数据量小,COW表会不断生成新的Parquet文件(每个文件组一个),同样会产生大量小文件。解决方式是合理设置文件大小参数(如target.file.size.bytes)和写入批大小。

误区2:MOR表的日志文件可以无限增长。如果不配置压缩策略,日志文件会持续膨胀,导致查询时需要合并大量日志,性能急剧下降。必须设置合理的压缩触发策略(如compaction.delta_commits或时间间隔)。

复盘总结:理解Hudi表格式的关键在于把握三条主线:

  • 时间线是表的“操作日志”,记录所有变更的先后顺序和状态;
  • 文件组/文件片是数据的“版本化存储结构”,决定了数据如何被更新和查询;
  • COW与MOR是两种不同的“写入策略”,在写入延迟、查询性能和存储成本之间做出权衡。

在实际调优中,需要根据业务读写比例、数据新鲜度要求和集群资源,综合选择表类型并配置合理的文件大小、压缩策略和清理策略。