Apache Hudi权威指南——Hudi 快速入门

135 阅读30分钟

在第 1 章中,我们探讨了促使 Apache Hudi 成为现代数据架构有力之选的基础概念:数据湖如何演进为湖仓、Hudi 在生态中的定位、其高层架构、Hudi 技术栈以及关键特性概览。尽管理论为理解提供了必要上下文,但真正理解 Hudi 的最佳方式是——上手实操。

本章将从“概念”转向“实践”。我们不会仅仅罗列功能,而是用不同配置与操作来演示 Hudi 表的行为,让你在执行常见湖仓操作的同时,直观看到底层表布局如何随之演化。

我们先从一个简单的购买(purchase) 跟踪表开始,用 Apache Spark 执行典型的增删改查(CRUD)操作。在执行命令的同时,我们会检查表“物理结构”的变化,帮助你形成对 Hudi 如何在幕后组织与管理数据的直观理解。

本章分为三节,循序渐进、层层递进:

  • “基础操作(Basic Operations)” :使用默认的写时复制(COW, Copy-on-Write) 表类型创建 Hudi 表,并探索基本 CRUD。通过 SQL 示例,观察每种操作如何影响表布局,并学习记录键、分区、时间线内部机制等核心概念。
  • “选择表类型(Choose a Table Type)” :引入 读时合并(MOR, Merge-on-Read) 表类型,用该配置重建 purchase 表。将两种布局并列对比,理解性能取舍与各自适用场景,帮助你为具体用例选对表类型。
  • “高级用法(Advanced Usage)” :展示更多湖仓 SQL 模式,如 CTAS、MERGE INTO、使用非记录键字段进行更新、时间旅行查询、增量查询等,以满足超出简单 CRUD 的复杂需求。

读完本章,你将清楚了解 Hudi 表在实践中的工作方式,并为后续更高级主题打下坚实基础。

基础操作(Basic Operations)

本节我们构建一个简单的 purchase 表,作为湖仓中跟踪客户交易的“单一事实源”。该表存储单笔交易的数据:客户标识、购买日期、金额与状态等。每条记录代表一笔交易,且以 purchase_id 唯一标识。完整模式见表 2-1。

表 2-1. purchase 表模式

列名数据类型说明
purchase_idSTRING购买的唯一标识。
customer_idBIGINT下单客户的标识(通常对应客户表的唯一标识)。
amountFLOAT支付金额。
statusSTRING购买状态(如:COMPLETED、PENDING)。
purchase_dateSTRING购买日期字符串(例如:2026-12-01)。

我们将 Hudi 作为该表的存储格式。将表实现为 Hudi 表后,执行与查询引擎能够原生理解其格式,并利用 Hudi 的高级能力(如时间旅行、增量处理)。你可以用 Spark、Apache Flink、Apache Hive、Presto、Trino 等不同引擎操作 Hudi 表。以下示例统一使用 Spark SQL 展示常见操作。

创建表(Create the Table)

在创建 purchase 表之前,需要初始化支持 Hudi 的 Spark SQL 会话,然后执行 CREATE TABLE。这会建立表结构,并生成用于检查 Hudi 表组织方式的初始目录布局。

假设你的环境中已正确安装 Spark 3.5,可用如下方式启动会话:

export HUDI_VERSION=1.1.0
spark-sql \
  --packages <Hudi Spark bundle 依赖坐标> \   # 1
  --conf spark.sql.extensions=org.apache.spark.sql.hudi.HoodieSparkSessionExtension \
  --conf spark.sql.catalog.spark_catalog=org.apache.spark.sql.hudi.catalog.HoodieCatalog \
  --conf spark.serializer=org.apache.spark.serializer.KryoSerializer \
  --conf spark.kryo.registrator=org.apache.spark.HoodieSparkKryoRegistrar

1 指示 Spark 按指定版本下载 Hudi-Spark bundle 作为依赖库。
例如:org.apache.hudi:hudi-spark3.5-bundle_2.13:$HUDI_VERSION。Hudi 也为其他引擎(如 Flink、Trino)提供 bundle。对你的应用栈而言,目标引擎的 bundle 是使用 Hudi 表所需的主要制品。

Tip
上面命令包含若干 Spark 配置,使用 Hudi 表时必需。为方便开发,可写入 spark-defaults.conf,避免每次重复。

类似地,Hudi 支持通过集中配置文件 hudi-defaults.conf(默认位于 /etc/hudi/conf)下发常用配置,减少重复并简化多表环境的初始化。

根据表 2-1 的模式创建 purchase 表:

CREATE TABLE purchase (
    purchase_id STRING,
    customer_id BIGINT,
    amount FLOAT,
    status STRING,
    purchase_date STRING
) USING HUDI                                  -- 1
TBLPROPERTIES (
    primaryKey = 'purchase_id'                -- 2
)
PARTITIONED BY (purchase_date)                -- 3
LOCATION '<base path to the Hudi table>';

1 表示该表遵循 Hudi 表格式规范
2 表示用于保证记录唯一性的字段(记录键,record key)。
3 表示用于将数据组织到目录中的字段(分区字段)。

这些设置及相关概念会在“插入/更新/删除/查询记录”中详细说明。先执行 SQL,看下 Hudi 初始表布局

初始表布局(Initial table layout)

执行 SQL 后,在表基路径下会创建一个 .hoodie/ 目录及其文件/子目录:

<base path of the purchase table>
└── .hoodie/                      # 1
    ├── .aux/
    ├── .schema/
    ├── .temp/
    ├── hoodie.properties         # 2
    └── timeline/                 # 3
        └── history/              # 4

1 .hoodie/ 存放表的各类元数据。因为指定了 USING HUDI,该目录即实现了 Hudi 格式规范,引导各执行引擎按规范而非“纯数据文件”来对待此表。
2 hoodie.properties 保存读写两端使用的表级属性,包括记录键与分区字段等配置。
3 timeline/ 包含跟踪所有表变更的事务日志。这些日志用于为 Hudi 表提供 ACID 保障,同时也是读写器与表交互的入口。这里的时间线称为活动时间线(active timeline)
4 history/ 存放已压缩归档的时间线条目。Hudi 用 LSM-Tree 结构存储时间线;当 timeline/ 中的条目超过阈值,会将较旧条目压缩归档到 timeline/history/。该设计在保证时间线操作性能的同时提升存储效率。这里的时间线称为归档时间线(archived timeline)

注意
请勿手动修改 hoodie.properties 来更新表配置——Hudi 会对其做校验和,手改会破坏文件。只有 Hudi 写入器迁移工具能在 Hudi 制品(如 Spark-Hudi bundle)实现的正确逻辑下更新它。

记录键字段(Record key fields)

Hudi 表中的记录可由一个或多个字段唯一标识。若用户未配置键,Hudi 会自动生成高可压缩的记录键(第 3 章详述)。在上面的 CREATE TABLE 中,primaryKey 是设置记录键字段的速记;其在 hoodie.properties 中对应 hoodie.table.recordkey.fields,多字段时用逗号分隔。

更新与删除操作必须依赖记录键来匹配目标记录。你也可以在建表时省略记录键配置——这会使表仅追加(append-only) ,只允许插入。第 3 章将详细讨论写入能力。

分区字段(Partition fields)

对湖仓表进行分区,可提升读写效率。SQL 子句 PARTITIONED BY 指定用于分区的字段;其在 hoodie.properties 中对应 hoodie.table.partition.fields

当存在多个分区字段时,分区路径会按字段顺序嵌套。例如 PARTITIONED BY (a, b) 会产生 a=foo/b=bar/ 的路径。分区不是 Hudi 表的必需项——若不分区,所有数据文件都会直接位于表基路径下。

注意
通过 Spark SQL 创建的分区表默认采用 Hive 风格分区路径,格式为 <partition_field_name>=<value>。这有助于与 Spark 表保持兼容(Spark 通常不在数据文件中重复写入分区字段,而是通过路径体现)。由于 Hudi 会在数据文件中保留分区字段,你可以在 TBLPROPERTIES 中关闭该路径格式:

'hoodie.datasource.write.hive_style_partitioning' = 'false'

将数据按分区字段值组织到目录后,查询引擎在带有相应谓词时可以跳过不匹配的分区(分区裁剪)。例如:

SELECT * FROM purchase WHERE purchase_date = '2026-12-01';

purchase_date 为分区字段时,引擎只扫描 purchase_date=2026-12-01/ 目录,节省时间与计算成本。

但分区并非总是有益:过度分区 会产生大量小文件,反而伤害查询性能;且对与分区字段无关的谓词灵活性较差。Hudi 更倾向用更强的索引技术来补强分区能力——第 5 章会展开说明。

插入、更新、删除与查询记录(Insert, Update, Delete, and Fetch Records)

先用 Spark SQL 向 purchase 表插入 5 条记录,完成一次写入操作:

INSERT INTO purchase
VALUES
('purchase-1', 101, 21.9, 'COMPLETED', '2026-11-30'),
('purchase-2', 101, 123.09, 'PENDING', '2026-11-30'),
('purchase-3', 102, 390.15, 'PENDING', '2026-12-01'),
('purchase-4', 103, 41.5, 'COMPLETED', '2026-12-01'),
('purchase-5', 101, 98.3, 'COMPLETED', '2026-12-01');

接着,基于记录键更新一条购买记录:

UPDATE purchase
SET status = 'COMPLETED'
WHERE purchase_id = 'purchase-2';

然后,基于记录键删除另一条购买记录:

DELETE FROM purchase
WHERE purchase_id = 'purchase-3';

最后查询表,验证记录:

SELECT purchase_id, customer_id, amount, status, purchase_date
FROM purchase ORDER BY purchase_id;

运行后可见输出:

purchase_id     customer_id     amount  status          purchase_date
purchase-1      101             21.9    COMPLETED       2026-11-30
purchase-2      101             123.09  COMPLETED       2026-11-30
purchase-4      103             41.5    COMPLETED       2026-12-01
purchase-5      101             98.3    COMPLETED       2026-12-01

结果如预期:purchase-2status 从初始的 PENDING 被更新为 COMPLETED,而 purchase-3 被删除。

写时复制(COW)表在写入后的布局

对表完成写操作后,可以在 .hoodie/ 与按 purchase_date 分区的路径下,观察到更多文件:

<base path of the purchase table>
├── .hoodie/
│   ├── hoodie.properties
│   ├── metadata/                                  # 1
│   └── timeline/
│       ├── 20261201022554235_20261201022556713.commit     # 2
│       ├── 20261201022554235.commit.requested
│       ├── 20261201022554235.inflight
│       ├── 20261201022558299_20261201022558980.commit     # 3
│       ├── 20261201022558299.commit.requested
│       ├── 20261201022558299.inflight
│       ├── 20261201022600486_20261201022600958.commit     # 4
│       ├── 20261201022600486.commit.requested
│       ├── 20261201022600486.inflight
│       └── history/
├── purchase_date=2026-11-30/                      # 5
│   ├── .hoodie_partition_metadata                 # 6
│   ├── ffa5854b-9104-402b-8099-0482d0844554-0_0-2-4_20261201022554235.parquet
│   └── ffa5854b-9104-402b-8099-0482d0844554-0_0-4-8_20261201022558299.parquet
└── purchase_date=2026-12-01/
     ├── .hoodie_partition_metadata
     ├── ba5a740b-0db6-4a21-902a-0eb397e4ab4f-0_0-7-1_20261201022600486.parquet
     └── ba5a740b-0db6-4a21-902a-0eb397e4ab4f-0_1-2-5_20261201022554235.parquet

1 元数据表所在位置,是 Hudi 表内的索引子系统;第 5 章会详细讨论。
2 时间线中的第一个 commit 动作,对应插入操作;含起止时间戳,表示该事务动作已完成。
3 时间线中的第二个 commit 动作,对应更新操作。
4 时间线中的第三个 commit 动作,对应删除操作。
5 存放 purchase_date = 2026-11-30 的分区路径。
6 该分区的元数据文件,用于分区发现。

你还会注意到各分区目录下的 Apache Parquet 文件,文件名前缀是 UUID,且带有与时间线条目匹配的时间戳。我们将在“基准文件与日志文件(Base files and log files) ”中进一步说明。下面先理解构成 Hudi 时间线的文件。

时间线、动作与时刻(Timeline, actions, and instants)

在 Hudi 中,对表执行的所有变更都会记录在按时间排序的事务日志列表 timeline 中,位于 .hoodie/timeline/ 目录。动作(action) 表示对表实际发生的变更——即一次事务本身。.hoodie/timeline/ 目录中的每个文件代表时间线上的一个时刻(instant) 。一个或多个具有相同时间戳前缀的时刻,构成了在该时间点开始的一个动作。

时刻文件遵循以下命名约定:

<动作开始时间戳>[_<动作结束时间戳>].<动作类型>[.<动作状态>]
动作时间戳(Action timestamps)

开始时间戳是单调递增的值,表示动作开始时间;结束时间戳表示动作完成时间,仅对已完成的动作存在。时间戳格式为 yyyyMMddHHmmssSSS。时间线中的所有动作时间戳遵循 TrueTime 语义,在参与的各进程间全局单调递增。

关于 TrueTime 语义
Google Spanner 的 TrueTime API(论文中提出)通过提供全局同步且不确定性有界的时钟,克服了分布式系统时间管理的难题。不同于易受时钟漂移与时间线不一致影响的传统系统,TrueTime 为每个节点提供在已知误差范围内一致的时间视图。这对实现分布式事务的外部一致性至关重要,使系统能够自信地分配时间戳,避免前后操作时间戳冲突,从而解决时钟同步与因果关系的长期难题。

动作类型(Action types)

动作类型表示对表做了何种变更。上面的示例中,动作是 commit(写入操作)。此外还有 deltacommitreplacecommitcleanrollback 等。我们会在“选择表类型”中讨论 deltacommit(与设置 Hudi 表类型相关),其他动作将在后续章节陆续介绍。

动作状态(Action states)

动作状态表示动作所处阶段:requestedinflight已完成。例如以下是一个完成的 commit,其开始时间为 20261201022554235,结束时间为 20261201022556713

├── 20261201022554235_20261201022556713.commit
├── 20261201022554235.commit.requested
├── 20261201022554235.inflight

注意(关于时刻文件的两条命名约定):

  • 已完成的动作不带诸如 requestedinflight 的状态后缀——文件名以动作类型结尾。
  • commit 动作有个特殊之处:其 inflight 时刻在时间戳后省略动作类型。

一个动作总是从 requested 开始,然后进入 inflight,最终到达完成。当三个状态下的时刻文件齐备时,动作被视为完成;若缺少完成态(仅有 requestedrequested + inflight),该动作即为待完成(pending)

时间线记录了表变更的完整历史,并支撑时间旅行查询增量查询(在“高级用法”中介绍、并在第 4 章进一步讨论)。理解如何读取时间线,有助于你调试数据问题、评估变更影响并执行数据审计。

选择表类型(Choose a Table Type)

在上一节中,我们使用 Hudi 的默认表类型 写时复制(COW, Copy-on-Write) 创建了 purchase 表,并在执行基础 CRUD 操作的同时观察了表的物理结构。不过,Hudi 实际上提供两种彼此不同的表类型,它们针对不同用例与性能特征做了优化。为了全面了解可选项并做出明智的表配置决策,我们需要看看另一种表类型 读时合并(MOR, Merge-on-Read) 在处理相同操作时的表现。

创建 Merge-on-Read 表(Create a Merge-on-Read Table)

我们用 MOR 表类型重新创建 purchase 表。建表 SQL 与之前几乎一致,只需更改一项配置:

CREATE TABLE purchase (
    purchase_id STRING,
    customer_id BIGINT,
    amount FLOAT,
    status STRING,
    purchase_date STRING
) USING HUDI
TBLPROPERTIES (
    type = 'mor',                -- 1
    primaryKey = 'purchase_id'
)
PARTITIONED BY (purchase_date)
LOCATION '<base path to the Hudi table>';

1 创建 MOR 表时的唯一区别是指定 type 属性。对于 COW 表,你也可以显式设置 type = 'cow'(可选,因为 COW 是默认值)。

创建 MOR 表后,运行与上一节完全相同的插入、更新、删除与查询 SQL。你会得到与之前相同的结果,说明尽管底层存储策略不同,两种表类型都能提供一致的数据访问。

MOR 表写入后的布局(MOR Table’s Layout After Writes)

来看使用 MOR 类型后,表物理结构的差异:

<base path of the purchase table>
├── .hoodie/
│   ├── hoodie.properties
│   ├── metadata/
│   └── timeline/
│       ├── 20261201040547825_20261201040549713.deltacommit      # 1
│       ├── 20261201040547825.deltacommit.inflight
│       ├── 20261201040547825.deltacommit.requested
│       ├── 20261201040551271_20261201040551832.deltacommit      # 2
│       ├── 20261201040551271.deltacommit.inflight
│       ├── 20261201040551271.deltacommit.requested
│       ├── 20261201040553967_20261201040554439.deltacommit      # 3
│       ├── 20261201040553967.deltacommit.inflight
│       ├── 20261201040553967.deltacommit.requested
│       └── history/
├── purchase_date=2026-11-30/                                     # 4
│   ├── .d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_20261201040551271.log.1_0-55-97
│   ├── .hoodie_partition_metadata
│   └── d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_0-31-46_20261201040547825.parquet
└── purchase_date=2026-12-01/
     ├── .06be74aa-6d0e-4406-bc38-981ed3e0d7e4-0_20261201040553967.log.1_0-79-139
     ├── .hoodie_partition_metadata
     └── 06be74aa-6d0e-4406-bc38-981ed3e0d7e4-0_1-31-47_20261201040547825.parquet

1 时间线中的第一个 deltacommit 动作,对应插入操作。
2 第二个 deltacommit,对应更新操作。
3 第三个 deltacommit,对应删除操作。
4 在该分区路径下可同时看到一个 Parquet 基准文件与一个以 .log 结尾的 Hudi 日志文件;二者在前导点之后共享相同的 UUID 前缀。

MOR 表的时间线时刻命名约定与上一节相同,但写操作用 deltacommit 而非 commit。这表明写入的数据可以落在 Parquet 基准文件(按配置)或 Hudi 日志文件 中——这是二者的重要差异。下面通过文件格式来理解这种设计取舍。

基准文件与日志文件(Base files and log files)

基准文件(base file) 存放表的主数据,并对分析型查询做了优化。基准文件为列式格式(如 Parquet、Apache ORC)或带索引的格式(如 HFile),可在建表时配置(默认 Parquet)。其命名规范为:

<file ID>_<write token>_<action start timestamp>.<base file extension>

各部分含义:

  • File ID:标识并归类同一 Hudi 表中共享该 ID 的文件
  • Write token:每次写入尝试的唯一字符串,用于妥善处理失败与重试
  • Action start timestamp:通过开始时间戳把文件与时间线中的动作关联
  • Base file extension:文件格式后缀(.parquet、.orc、.hfile 等)

日志文件(log file) 是 Hudi 原生格式,以一系列数据块编码。具体数据字节会根据配置序列化为 Apache Avro、Parquet 或 HFile(默认 Avro)。日志文件命名规范:

.<file ID>_<action start timestamp>.log.<sequence number>_<write token>

各部分含义:

  • File ID:标识并归类共享该 ID 的文件
  • Action start timestamp:把文件与时间线动作(开始时间)关联
  • Sequence number:标示同一动作中写出的多个日志文件的顺序
  • Write token:每次写入尝试的唯一字符串,用于处理失败与重试

动作开始时间戳 是将时间线中的事务动作与其对应物理文件关联起来的关键机制。借助它,Hudi 的写入端与读取端能够判断当前哪些动作在进行、以及正确地选择应写入或读取的文件。

File ID 则在不同基准文件与日志文件之间提供必要的关联,使读写双方能定位并处理与相关记录对应的文件。接下来我们讨论围绕 File ID 的设计与概念。

文件组与文件切片(File groups and file slices)

了解了 Hudi 中的单个文件类型后,还需要看看这些文件如何被组织成支持高效数据管理与查询的逻辑结构。Hudi 采用分层组织方式,将相关文件聚合在一起,并在时间维度上跟踪它们的演进(见图 2-1)。

image.png

无论是基准文件(base files)还是日志文件(log files),都会被组织成称为**文件组(file groups)**的逻辑概念,每个文件组由唯一的 file ID 标识。这个 file ID 作为共同标识,将相关的基准文件与日志文件关联起来,形成一个用于数据存储与检索的整体单元。

记录与文件组之间的关系是 Hudi 设计的基础:表中的每条记录由唯一键标识,并在任一时刻映射到且仅映射到一个文件组。这种一对一映射使 Hudi 的读写端可以通过确定“哪个文件组包含该记录”来高效定位记录,从而减少文件扫描范围。

在每个文件组内部,数据文件进一步组织为文件切片(file slices) 。一个文件切片表示该文件组在某个时间点上所有记录的状态,最多包含一个基准文件,以及可选的一组日志文件。下面我们回到前面创建的 purchase 表来更好地理解这些概念。

COW 表中的文件切片

在 COW(Copy-on-Write)类型的 purchase 表中,我们只会在每个分区下看到基准文件。本例中,一个文件组包含多个文件切片,每个切片都由一个基准文件构成:

.hoodie/timeline/
├── 20261201022554235_20261201022556713.commit          # 1
├── 20261201022558299_20261201022558980.commit          # 2
purchase_date=2026-11-30/
├── ffa5854b-9104-402b-8099-0482d0844554-0_0-2-3_20261201022554235.parquet  # 3
└── ffa5854b-9104-402b-8099-0482d0844554-0_0-4-8_20261201022558299.parquet  # 4

1 执行插入操作的 commit 动作
2 执行更新操作的 commit 动作
3 由时间 20261201022554235 的 commit 动作生成、仅包含一个基准文件的文件切片
4 由时间 20261201022558299 的 commit 动作生成、仅包含一个基准文件的文件切片

这两个文件切片同属 file ID = ffa5854b-9104-402b-8099-0482d0844554-0 的同一文件组,各自代表该文件组在对应时间点的一个“版本”;较新的版本包含更新后的记录以及其他所有记录。

注意
将数据文件归入“文件切片”与“文件组”并无物理层面的目录结构约束——它们都是逻辑分组。物理文件只是存放在同一路径下(分区路径或表基路径)。

MOR 表中的文件切片

在 MOR(Merge-on-Read)类型的 purchase 表中,我们会在每个分区下同时看到基准文件与日志文件。本例中,一个文件组包含一个文件切片,该切片由一个基准文件与一个日志文件构成:

.hoodie/timeline/
├── 20261201040547825_20261201040549713.deltacommit     # 1
├── 20261201040551271_20261201040551832.deltacommit     # 2
purchase_date=2026-11-30/
├── .d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_20261201040551271.log.1_0-5-9   # 3
└── d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_0-3-4_20261201040547825.parquet  # 4

1 执行插入操作的 deltacommit 动作
2 执行更新操作的 deltacommit 动作
3 属于在 20261201040547825 创建的文件切片的日志文件,但该日志文件本身是在以 20261201040551271 开始的 deltacommit 中创建
4 属于在 20261201040547825 创建的文件切片的基准文件

该文件切片隶属于 file ID = d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0 的文件组。日志文件以 Hudi 日志格式仅保存被更新的记录,而基准文件保存插入操作产生的全部记录

文件切片归属(File slicing)

文件切片归属指确定“哪些文件属于某个文件组中的哪个文件切片”的过程。对 COW 表而言,这比 MOR 表简单:COW 不含日志文件,故每个基准文件就存放了映射到该文件组的全部记录,并按其 commit 动作开始时间 形成一个文件切片。

而在 MOR 表中,日志文件可能只包含映射到该文件组记录的子集,因此需要找到相应的基准文件来形成完整的文件切片。其逻辑是:定位在日志文件对应的 deltacommit 结束时间之前时间上最近的基准文件(根据其动作开始时间)。

在我们的 purchase 表示例中,日志文件
.d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_20261201040551271.log.1_0-55-97
关联的是以 20261201040551271 开始、以 20261201040551832 结束的 deltacommit 动作。应关联的目标基准文件是
d4bd5df5-f5dd-411c-bb77-a1dbf02ef0fd-0_0-31-46_20261201040547825.parquet
因为它的动作开始时间早于日志文件的 deltacommit 结束时间 20261201040551832,且它是该文件组内最近的(且唯一的)基准文件。

你可能会疑惑:既然 COW 看起来更简单,为什么还要引入这些复杂性?下一节我们将讨论两种表类型的不同特征,理解这些概念与分组逻辑背后的设计动机。

写时复制(COW)与读时合并(MOR)

在湖仓环境中,COW 与 MOR 两类表型都要解决“写入性能与读取性能的权衡”这一根本难题。二者在逻辑组织上同样采用文件组(file groups)与文件切片(file slices)的概念,但在处理更新的方式上截然不同,由此产生了不同的性能特征与最佳适用场景。

理解每种表型如何处理更新,有助于明确在何时选择哪一种。关键在于:COW 通过在写入阶段吸收更新成本来优化一致的读取性能;而 MOR 则通过将合并成本推迟到读取或压缩合并(compaction)阶段来优化快速写入

COW 表的更新流程

COW 表中,记录的更新或删除会在对应文件组内生成新的基准文件(base files) ,不会写入日志文件。当发生更新操作时,Hudi 会定位到包含需修改记录的所有基准文件,并以更新后的数据完整重写这些文件。

COW 更新流程如下(见图 2-2):

  1. 定位目标文件组:对每条待更新记录,Hudi 利用**元数据表(metadata table)**查找包含该记录的具体文件组。
  2. 合并记录:定位到文件组后,从最新的基准文件中提取现有记录,与传入的更新记录执行合并;未变化的记录将被保留。
  3. 写入数据:将结果写为一个全新的基准文件,作为该文件组中的新文件切片,其中同时包含更新后的记录与未变化的记录。
  4. 写入元数据:把所有新写文件的元数据写入元数据表
  5. 发布到元数据表时间线:在元数据表的时间线上记录一次新的 deltacommit 动作。
  6. 发布到数据表时间线:在数据表的时间线上记录一次新的 commit 动作,表示该事务已完成。

image.png

这种做法确保每次查询都只读取基准文件(base files) ,从而提供优异且可预测的读取性能。但与此同时,写放大可能非常明显——在一个 1 GB 的 Parquet 文件中仅更新一条记录,也需要重写整个文件

MOR 表的更新流程

MOR 表通过在基准文件之外配合轻量级日志文件(log files)定期执行压缩合并(compaction) ,在写入与读取性能之间取得平衡。更新与删除最初会写入日志文件,从而避免立刻重写基准文件的开销。

MOR 的更新流程如下(见图 2-3):

  1. 定位目标文件组:与 COW 相同,针对每条待更新记录,Hudi 使用元数据表定位包含该记录的具体文件组。
  2. 写入数据:不重写基准文件,而是将更新追加写入该文件组内的日志文件。
  3. 写入元数据:把新写入文件的元数据写入元数据表
  4. 发布到元数据表时间线:在元数据表时间线上记录一次新的 deltacommit 动作。
  5. 发布到数据表时间线:在数据表时间线上记录一次新的 deltacommit 动作,表示该事务完成。
  6. 累积变更:后续更新操作继续写入日志文件,逐步累积一系列变更。
  7. 压缩合并(Compaction) :定期将累积的日志文件与基准文件合并,生成新的基准文件,形成新的文件切片。

注意
压缩合并(compaction) 是 Hudi 的表服务之一,用于在 MOR 表中将日志文件与基准文件合并,以维持最佳查询性能。关于压缩合并的策略、调度与配置选项,将在第 6 章详细介绍。

image.png

数据的更新与删除会以行式的 Hudi 日志格式写入到日志文件(log files)中,并在查询执行压缩合并(compaction)期间与基准文件(base files)进行动态合并。由于只需写入已变更的记录,这种方式可显著降低写放大;但在读取时会引入额外开销,因为查询引擎必须合并基准文件与日志文件以生成最新结果。

权衡取舍

在 COW 与 MOR 之间进行选择,涉及影响运维特性与用例适配性的根本权衡。表 2-2 总结了两种表类型的高层对比。

表 2-2. COW 与 MOR 的权衡

维度COWMOR
写入延迟较高较低
查询延迟较低较高
更新成本较高(重写整个基准文件)较低(追加写入日志文件)
读放大无(仅读基准文件)目标文件组为 O(records_changed)
写放大对给定更新模式为 O(records_of_target_file_groups)目标文件组为 O(records_changed)
基准文件大小需更小以避免高更新 I/O 成本可更大(更新成本低且可摊销)
运维复杂度较低(文件结构与行为更简单)较高(需管理压缩合并)

据此可进一步推断,COW 更擅长于

  • 读取占主导、对查询性能要求极高的分析型/OLAP 扫描
  • 静态或变化缓慢、很少需要更新的参考维表
  • 以大批量、低频处理为特征的批 ETL 管道
  • 更看重运维简洁性而非极致写入优化的场景

MOR 更适合

  • 只处理新增/变更记录、无需全表重写的增量流水线(高效推进 bronze–silver–gold 分层)
  • 需要跟上上游高频更新的 CDC 管道
  • 追求分钟级可用性的流数据摄入
  • 频繁更新与删除的表,如用户行为追踪或库存管理
  • 同时服务于低延迟操作型查询与批量分析的混合批/流工作负载

随着数据处理需求从纯批处理分析向实时流式演进,MOR 表日益流行。伴随 Hudi 项目演进与 MOR 合并成本的持续优化,MOR 正逐步成为多数工作负载场景中的首选表类型

高级用法(Advanced Usage)

前面各节演示了基础的 CRUD 操作,但真实的湖仓环境往往需要处理更复杂的逻辑。本节展示超越简单插入、更新、删除场景的高级 SQL 用法,以应对更复杂的需求。

CTAS(Create Table As Select)

CREATE TABLE AS SELECT (CTAS) 允许你用一次操作,从查询结果创建一张新的 Hudi 表。它对从其他格式迁移数据或高效创建派生表尤其有用:

-- 基于现有的 purchase 表创建一个汇总 Hudi 表
CREATE TABLE purchase_summary 
USING HUDI                                  -- 1
TBLPROPERTIES (
    primaryKey = 'customer_id,purchase_date' -- 2
)
AS SELECT 
    customer_id,
    purchase_date,
    ROUND(SUM(amount), 2) as total_amount,   -- 3
    COUNT(*) as purchase_count               -- 4
FROM purchase
GROUP BY customer_id, purchase_date;

1purchase_summary 的表格式指定为 Hudi。
2 使用复合记录键字段。
3 汇总金额,得到每个客户在每个购买日期的总金额。
4 统计每个客户在每个购买日期的购买次数。

查询验证结果:

SELECT customer_id, purchase_date, total_amount, purchase_count
FROM purchase_summary
ORDER BY customer_id, purchase_date;

输出:

customer_id         purchase_date   total_amount     purchase_count
101                 2026-11-30      144.99           2
101                 2026-12-01      98.3             1
103                 2026-12-01      41.5             1

将源数据合并进目标表(Merge Source Data into the Table)

MERGE INTO 通过把源数据与目标表连接,并依据匹配条件执行不同动作,实现灵活的 upsert:

-- 创建包含更新记录的暂存表
CREATE TABLE purchase_updates (               -- 1
    purchase_id STRING,
    customer_id BIGINT,
    amount FLOAT,
    status STRING,
    purchase_date STRING
) USING PARQUET;

-- 将更新合并进 purchase 表
MERGE INTO purchase t
USING (
    SELECT purchase_id, customer_id, amount, status, purchase_date 
    FROM purchase_updates
) s
ON t.purchase_id = s.purchase_id              -- 2
WHEN MATCHED THEN 
    UPDATE SET t.amount = s.amount, t.status = s.status   -- 3
WHEN NOT MATCHED THEN 
    INSERT (purchase_id, customer_id, amount, status, purchase_date)  -- 4
    VALUES (s.purchase_id, s.customer_id, s.amount, s.status, s.purchase_date);

1 创建包含将要合并进主表的新/更新记录的暂存表。
2 基于 purchase_id 作为连接条件,匹配源与目标中的记录。
3 当匹配到时,仅更新 amountstatus 字段,其它数据保持不变。
4 当未匹配到时,插入新记录到目标表。

使用“非记录键字段”进行更新与删除(Update and Delete Using Nonrecord Key Fields)

Hudi 支持基于非主键字段执行更新与删除,使你可按业务逻辑灵活管理数据。

在前述的 purchase 表上,运行以下 SQL,基于 customer_idamount 的谓词执行更新:

-- 按 customer_id 与 amount 的条件更新
UPDATE purchase 
SET status = 'PENDING' 
WHERE customer_id = 101 AND amount > 100.0;   -- 1

1 谓词不在任何记录键字段上。

查询验证结果:

SELECT purchase_id, customer_id, amount, status, purchase_date
FROM purchase
ORDER BY purchase_id;

输出:

purchase_id     customer_id     amount  status       purchase_date
purchase-1      101             21.9    COMPLETED    2026-11-30
purchase-2      101             123.09  PENDING      2026-11-30    -- 1
purchase-4      103             41.5    COMPLETED    2026-12-01
purchase-5      101             98.3    COMPLETED    2026-12-01

1 purchase-2status 已更新为 PENDING

基于 statuspurchase_date 的谓词执行删除:

-- 按状态与日期条件删除
DELETE FROM purchase 
WHERE status = 'PENDING' 
AND purchase_date BETWEEN '2026-11-01' AND '2026-11-30';  -- 1

1 谓词不在任何记录键字段上。

查询验证结果:

SELECT purchase_id, customer_id, amount, status, purchase_date
FROM purchase
ORDER BY purchase_id;

输出:

purchase_id     customer_id     amount  status          purchase_date
purchase-1      101             21.9    COMPLETED       2026-11-30
purchase-4      103             41.5    COMPLETED       2026-12-01
purchase-5      101             98.3    COMPLETED       2026-12-01

可见满足条件的 purchase-2 已被删除。

时间旅行查询(Time Travel Query)

时间旅行查询允许你访问历史版本数据,用于审计、调试与分析数据演化。该能力利用 Hudi 的时间线设计与文件切片版本化,按给定时间戳保留并检索匹配的记录版本。第 4 章将更详细讨论时间旅行与其他查询类型。

示例:

-- 按指定提交时间点查询表的历史状态
SELECT purchase_id, customer_id, amount, status 
FROM purchase TIMESTAMP AS OF '20261201040547825'   -- 1
WHERE customer_id = 101;

-- 使用可读时间戳格式进行查询
SELECT customer_id, COUNT(*) as purchase_count
FROM purchase TIMESTAMP AS OF '2026-12-01 10:30:00' -- 2
GROUP BY customer_id;

1 TIMESTAMP AS OF 用于指定过去的查询时间;格式可与时间线的时间戳一致。
2 TIMESTAMP AS OF 也接受更常用的时间戳格式。

增量查询(Incremental Query)

增量查询仅检索自某一时间点之后发生变更的数据,从而支撑高效的增量处理流水线并显著降低计算成本。该能力利用 Hudi 的时间线与文件组组织,快速定位变更记录。第 4 章将详细介绍增量查询及其收益。

示例:

-- 获取自指定时间戳以来发生变更的记录(返回最新状态)
SELECT *
FROM hudi_table_changes('purchase', 'latest_state', '20261201040547825');  -- 1

-- 以 CDC 模式获取自指定起点以来的变更记录(返回前/后镜像)
SELECT *
FROM hudi_table_changes('purchase', 'cdc', 'earliest', '20260516000000');   -- 2

1 表值函数 hudi_table_changes 接受参数以运行增量查询。
2cdc 模式运行的增量查询,会返回变更记录的 before/after 镜像。

这些高级 SQL 用法展示了 Hudi 在应对复杂湖仓场景时的强大之处:从用 CTAS 高效迁移/派生数据,到用增量查询构建 CDC 流水线,这些操作共同构成了复杂数据处理工作流的基础,将传统数据库的优势与数据湖的规模与灵活性有效结合。

小结

本章通过对 Hudi 表的动手实践,将理论知识转化为可操作的理解。借助一个购买追踪表,我们观察了在常见湖仓操作中表布局如何演化。关键概念包括:

表布局与属性
我们剖析了 .hoodie/ 目录的结构,并介绍了记录键字段(record key fields)与分区字段(partition fields)等关键表级属性。

时间线、动作与时刻
我们查看了存放在 .hoodie/timeline/ 下的条目,并说明了带时间戳的动作(如 commitdeltacommit)如何通过时刻状态的流转,支撑 ACID 保证与高级查询能力。

基准文件、日志文件、文件组与文件切片
我们解释了基准文件如何为读取进行优化、日志文件如何承载增量变更,以及这些文件如何被逻辑地组织为文件组与文件切片,以实现高效的数据管理。

表类型对比
我们比较了 COW 与 MOR 两种表类型:COW 通过重写文件来优化读取性能;MOR 则通过日志文件吸收频繁更新,从而优先保证写入性能。

此外,我们展示了 CTAS、MERGE INTO 与基于非记录键字段的更新等高级 SQL 用法;时间旅行查询与增量查询则突出体现了 Hudi 在审计与高效数据处理方面的独特价值。

本章引入的概念为后续章节奠定了基础:时间线管理与文件组织是第 3 章写入操作的底层支撑;本章的 SQL 示例为第 4 章的全面查询能力做了预热;在表布局中一闪而过的 metadata/ 目录将于第 5 章深入讲解;而本章简要提及的 MOR 压缩合并,将在第 6 章中详细展开。