Apache Hudi权威指南——写入Hudi

132 阅读39分钟

写入操作是任何数据湖仓中的关键职能,直接影响其可靠性与性能。因此,深入理解 Hudi 写入器(writer)的内部行为——以及在你的特定用例中应当利用哪些功能——至关重要。基于第 2 章关于表布局、时间线结构与表类型取舍的基础概念,本章将结合内部机制深挖与使用示例,成为你理解 Apache Hudi 写入操作的实用指南。

本章分为三个部分,全面探索 Hudi 的写入能力。 “拆解写入流程(Breaking Down the Write Flow)” 将对 Hudi 的端到端写入过程进行剖析:从数据准备到最终的事务提交,我们会逐步追踪每一个环节,揭示保障数据正确性与高效性的内部机制。

为便于将讨论落到实处, “探索写入操作(Exploring Write Operations)” 引入一个真实的业务场景:数据提供商 DataCentral, Inc. 专注于对数百万物联网(IoT)设备的传感器数据进行分析。我们将示范 Hudi 的全部写入操作——包括 upsert、delete、insert、bulk_insert——展示如何在真实环境下解决常见的数据操作挑战。

Hudi 的核心写入操作之所以强大高效,源于若干专为复杂湖仓数据模式设计的重要特性。为避免干扰对主线流程的理解,我们将在 “重点功能亮点(Highlighting Noteworthy Features)” 中集中讲解这些更进阶的能力。

完成本章学习后,你将能够高效地向 Hudi 写入数据:不仅清晰掌握写入流程,还能针对不同场景运用多种写入操作,并学会利用高级特性来构建高效且可靠的数据湖仓流水线。

拆解写入流程(Breaking Down the Write Flow)

要用 Hudi 高效构建数据湖仓,必须清楚其内部写入流程。本节将按步骤说明 Hudi 的写入流程:从提交(commit)发起、记录准备与数据写入,到提交收尾(finalization),逐一讲解每个阶段。

图 3-1 概览了 Hudi 的写入流程,其中包含主流程步骤与可选步骤。

image.png

主流程涵盖大多数写入操作类型所共有的步骤,而可选步骤仅适用于特定类型。接下来的小节中,我们将以 upsert(默认写入操作)为例,贯穿讲解这些步骤。

注意
此处所说的“commit(提交)”指的是数据库领域常见的事务性提交概念。该概念同样适用于 Hudi 表:每一次事务性动作有时也被称为一次 commit。请不要把这种更广义的事务性“commit”与 Hudi 时间线上记录的特定 commit 动作(第 2 章已介绍)混淆。

Start Commit(开始提交)

“开始提交”标志着任意 Hudi 写入操作的起点,其主要入口是 Hudi 写入客户端(write client) 。该客户端与执行引擎兼容(例如 Spark 的 SparkRDDWriteClient、Flink 的 HoodieFlinkWriteClient、以及用于 Kafka Connect 的 HoodieJavaWriteClient)。它首先会为即将到来的写入“清场”:检查表的时间线上是否存在先前失败的动作,并在需要时执行回滚。完成这些预检后,客户端会在时间线上创建一个 requested 状态的动作以启动写入(COW 为 commit,若是 Merge-on-Read〔MOR〕则为 deltacommit)。此阶段通常还会将用户提供的配置与已有的 Hudi 表属性进行“对账”,形成最终配置集并传递给客户端用于后续步骤。

Prepare Records(准备记录)

此步骤会对输入数据执行必要的转换。在介绍这些转换前,先了解 Hudi 的内部数据结构 HoodieRecord。如图 3-2 所示,HoodieRecord 用于将输入记录封装为带有额外元数据的对象,这些元数据包括:记录键(record key)分区路径(partition path)排序值(ordering value) ,以及该记录的当前位置与新的目标位置等。

image.png

HoodieKey 字段包含记录键(record key)分区路径(partition path) ,二者共同在一张 Hudi 表内唯一标识一条记录。排序值(ordering value)字段用于决定具有相同 HoodieKey 的记录的先后顺序。将这些信息与记录数据一并存储对于许多依赖顺序的用例至关重要。比如在用 CDC(变更数据捕获)复制数据库表时,记录可能共享同一个主键,因此需要来自源端的排序值以识别最新记录,从而保证复制正确。

合并重复记录

具有相同 HoodieKey 的记录被视作重复。在处理通常包含重复项的 CDC 记录时,需要基于排序值正确地合并,以确保捕获到记录的最新版本。在写入存储之前的这个步骤完成合并,可减少后续写入阶段的工作量。

对于 upsertdelete 操作,“合并重复记录”在准备阶段默认执行。而在 insertbulk_insert 这类仅追加语义的操作中,默认执行合并。

为应对需要合并的各种场景,Hudi 支持通过**合并模式(merge modes)**来定义合并行为。详见“合并模式”一节。

索引(Indexing)

记录准备阶段的下一个可选步骤是索引:即在表中为传入记录定位任何匹配的既有记录,从而识别哪些是更新记录、哪些是记录。

HoodieRecordLocation 信息对于精准定位记录至关重要。在一张 Hudi 表中,可以先用分区路径file ID 确定其文件组,再用动作时间戳定位该文件组中的文件切片,最后借助位置信息在所含基准文件内快速找到记录。在索引过程中,会填充(如图 3-2 所示的)当前 HoodieRecordLocation 字段,指示该条记录在本次写入时的存在位置。

对于 upsertdelete 操作,Hudi 写入器必须执行索引,因为需要将新的变更写入到这些记录原先所在的文件组,以遵循 Hudi 的文件组/文件切片设计。此设计确保同一记录的不同版本存放于不同文件切片中,并通过动作时间戳将版本与写入时间关联。对 insertbulk_insert 这类仅追加语义的写入,索引不是必需,会被跳过。无论在数据库还是在数据湖仓中,索引都是提升读写性能的关键技术。第 5 章将对此做深入讨论。

记录分桶(Partition Records)

记录准备就绪后,下一步是分桶:将传入记录切分为适当大小内存分区以便分布式处理。对于 upsertinsert 操作,分桶过程允许采用装箱(bin-packing)算法来分配记录,从而缓解小文件问题并维持高性能的表布局。“插入与 upsert 的小文件处理”一节会作详细介绍。

写入存储(Write to Storage)

此时记录已准备好写入存储中的文件切片。Hudi 使用不同的“写句柄(write handle) ”来区分处理更新插入。例如在 COW 表中,插入的写句柄可能会创建新的文件组来承载新记录;而更新的写句柄需要读取已定位的文件切片,将现有记录与传入更新合并,然后把合并结果写回到相应文件组中的新文件切片。写句柄还会回传与写入相关的元数据统计,供事务收尾使用。

提交变更(Commit Changes)

在最终步骤中,Hudi 写入器会执行多项任务,以正确结束这次事务性动作

  • 作为数据表索引子系统元数据表(第 5 章详述)会在同一个写事务中更新。写入操作的元数据会被保存,以便用最新记录同步索引数据,确保一致性与正确性
  • 若该表配置了并发写入(第 7 章详述),Hudi 写入器会检查数据冲突。来自写句柄的元数据与统计会被聚合,生成总体的写入报告(write report) ,并持久化到 Hudi 时间线上已完成的动作时刻中。
  • 在写入数据到存储前,Hudi 会创建标记文件(marker files) 。标记文件是空文件,用于追踪本次操作正在写入的基准文件日志文件。如果写入半途失败,这些标记文件会留在 .hoodie/.temp/ 目录下,便于清理残留文件;当写入成功(时间线显示动作完成)后,标记文件会在提交后任务中被删除。标记文件还可用于多写入者场景中的早期冲突检测(第 7 章讨论)。
  • 正如第 2 章简述的,Hudi 时间线以 LSM-Tree 结构存储动作时刻。当时刻数量超过可配置阈值时,较旧的时刻会被归档timeline/history/ 目录。阈值触发时会启动归档服务作为提交后任务,从而限制活动时间线上的时刻数量,保持读写的高效。
  • 若使用 Spark 作为执行引擎,可在此步骤运行提交前校验(pre-commit validation) 。你可以在写入配置中设置 hoodie.precommit.validators(如 org.apache.hudi.client.validator.SqlQueryEqualityPreCommitValidator)。配合相关配置,Hudi 写入器会执行 SQL 并按配置校验结果。详见 Hudi 文档。

Upsert 流程小结

在用 upsert 示例讲完写入流程的各步骤后,下面对整体过程做一份总结(完整示意见图 3-3):

  1. 开始提交(Start the commit)
    Hudi 写入客户端先在表的时间线上“清场”,回滚任何失败的动作;随后在时间线上创建一个 requested 时刻(COW 为 commit,MOR 为 deltacommit)。
  2. 准备记录(Prepare the records)
    将输入记录封装为 HoodieRecord,依据 HoodieKey 与排序值进行记录合并,并执行索引定位。
  3. 记录分区(Partition the records)
    在内存中对已准备好的记录进行分区以便分布式处理;对 upsert/insert 启用小文件处理机制,以保持表的存储布局优化。
  4. 写入存储(Write to storage)
    Hudi 使用不同类型的“写句柄(write handle)”处理分区后的记录,并执行对底层存储的 I/O。
  5. 提交变更(Commit the changes)
    最终步骤中,写入器完成事务收尾与表级簿记工作(更新元数据表、生成写入报告、清理标记文件、触发归档等)。

image.png

探索写入操作(Exploring Write Operations)

在上一节中,我们以默认的 upsert 操作为代表,深入解析了 Hudi 写入操作的内部机制。尽管 upsert 是基础,但 Hudi 还提供了多种其他写入类型。本节将转向更实用的角度,通过 Spark SQL 代码示例演示这些操作,并解释其行为与相关概念。

本节示例基于一家虚构公司 DataCentral, Inc. 。DataCentral 专注于收集海量物联网(IoT)设备的传感器数据,并在其平台上提供分析服务。其数据采集模块的主表为 sensor_data,其模式见表 3-1。

表 3-1. DataCentral, Inc. 使用的 sensor_data 模式

字段名数据类型说明
idSTRING传感器设备的唯一标识
typeSTRING传感器数据类型(如温度 TEMP、湿度 HUM、气压 PRES)
tsBIGINT采样时的纪元时间戳(毫秒)
emit_tsBIGINT数据被发出时的纪元时间戳(毫秒)
valueFLOAT采样到的传感器数值
org_idSTRING拥有该传感器设备的机构的唯一标识

后续小节将围绕此表,展示传感器数据处理过程中可能出现的各种场景。

定义表属性(Define Table Properties)

根据表 3-1 的模式创建 sensor_data 表:

CREATE TABLE sensor_data (
    id STRING,
    type STRING,
    ts BIGINT,
    emit_ts BIGINT,
    value FLOAT,
    org_id STRING
) USING HUDI
TBLPROPERTIES (
    type = 'mor',                 -- 1
    primaryKey = 'id,type,ts',    -- 2
    preCombineField = 'emit_ts'   -- 3
)
PARTITIONED BY (org_id);          -- 4

1 选择 MOR 作为表类型,以应对来自大量 IoT 设备的高吞吐写入。
2 以传感器 ID + 数据类型 + 采样时间戳 构成复合记录键,唯一标识每条传感器数据。
3 使用发出时间 emit_ts 作为 preCombineField(排序字段) ,以确定同键记录的先后顺序——这对处理延迟到达乱序数据至关重要。
4org_id 分区,以优化按机构过滤的常见查询。

当 Hudi 表允许 更新/删除 时,必须为同一记录键的记录建立明确的排序逻辑。原因在于:

  • 发生更新时,Hudi 需按该顺序选择正确版本
  • 删除时,Hudi 需判断传入删除是否更新更晚(若更晚则执行删除,否则跳过)。
    preCombineField(排序字段) 用于指定用于比较的字段,值更大即表示更“新”的版本

Tip
Hudi 1.1 起,可指定多个排序字段。Hudi 将按给定顺序依次比较,当前者相同才继续用后者,最终共同决定记录的先后。

但仅仅“取最新版本”并不总是足够。根据业务需要,你可能希望实现自定义合并逻辑:如仅选择性更新某些字段,或对新旧记录做求和/求均值等计算。传入记录与已有记录的合并行为merge mode(合并模式) 决定(本章稍后讨论)。在本节示例中,只需记住:emit_ts 决定记录顺序;因此,在后续“纠偏”过程中若同键记录以更晚的 emit_ts 到达,Hudi 会选择 emit_ts 更大的那条作为当前版本。

使用 INSERT INTO

通常,我们将新的传感器数据追加写入表中;当传感器周期性发出“纠正数据”时,再执行 upsert,以便更新已有记录插入新增记录。Hudi 通过三种不同的写入操作同时支持 insertupsert 语义:insertbulk_insertupsert。它们都可以通过标准的 INSERT INTO 语法执行。

首先用 insert 操作向 sensor_data 写入一批初始数据:

SET hoodie.spark.sql.insert.into.operation=insert;  -- 1

INSERT INTO sensor_data VALUES
('SENSOR_001', 'TEMP', 1797649200010, 1797649200050, 296.65,  'ORG_A'),
('SENSOR_001', 'HUM',  1797649200020, 1797649200050, 65.2,    'ORG_A'),
('SENSOR_001', 'PRES', 1797649200030, 1797649200050, 1013.25, 'ORG_A'),
('SENSOR_002', 'TEMP', 1797649200040, 1797649200100, 297.25,  'ORG_B'),
('SENSOR_002', 'HUM',  1797649200050, 1797649200100, 62.8,    'ORG_B');

1 指定 INSERT INTO 语句所采用的写入操作类型。

上述示例以 insert 操作向两个分区(机构 A 与机构 B)写入 5 条记录。各传感器上报三类数据:温度、湿度、气压。可以注意到:虽然不同类型的采样时间 ts 不同,但同一传感器各条记录的发出时间 emit_ts 相同,且晚于采样时间。

若希望用 bulk_insert 得到相同效果,可在执行相同的 INSERT INTO 前设置:

SET hoodie.spark.sql.insert.into.operation=bulk_insert;

Insert 与 Bulk Insert 对比

两者都遵循仅追加语义,但写入流程实现存在差异,见表 3-2。

表 3-2. insertbulk_insert 对比

项目insertbulk_insert
合并重复默认不合并;可设 hoodie.combine.before.insert=true 开启同左
是否索引
文件尺寸机制自动小文件处理通过排序模式/分区可调
适用场景渐进式、小批量写入首次引导/冷启动、大批量写入

需要特别注意:由于两者都不执行索引,因此不会修改表内已有记录。并且,即便可配置对传入批次内部做去重合并,该合并不涉及“传入数据 vs 表内既有数据”的跨集合合并。理解“仅追加语义”的这些含义非常关键。

接下来的两节将深入讲解这两种操作的文件尺寸控制机制

Insert 与 Upsert 的小文件处理(Small-file handling for insert and upsert operations)

在批/流系统中,数据以大小不一的批次写入文件系统。随着小批次或低流量的长期摄入,小文件会越来越多。这会损害查询性能(计划与执行需频繁打开/读取/关闭大量文件),并带来更高的元数据开销、因索引变慢导致的写入延迟增加,以及由于每个可压缩数据文件承载记录更少而导致的压缩率下降、存储利用低下。因此,维持合理的数据文件大小对查询性能与存储效率都至关重要。

Hudi 在执行 insertupsert 时,会自动维持配置的目标文件大小。在写入新记录前,Hudi 会在目标分区中识别符合条件的小尺寸文件,并将传入数据路由到这些文件中,填充到接近配置的最大尺寸阈值;剩余记录再写入新文件。此类智能装箱(bin-packing)策略可使小文件逐步长大,最终趋近目标文件大小,从而无需人工干预即可有效缓解小文件问题。

对于 COW 表,如图 3-4 所示,Hudi 采用直接策略:凡是文件切片(仅含一个基准文件)的大小小于配置阈值(默认 100 MBhoodie.parquet.small.file.limit)的,均可作为候选;新插入会分配到这些候选中,使其朝目标文件大小(默认 120 MBhoodie.parquet.max.file.size)增长。超过小文件阈值的文件切片不再作为候选接收新记录;它们只有在收到对其既有记录的更新/删除时才会被重写。

image.png

为优化写入性能,MOR 表对小文件的处理略有不同。当小文件处理算法将新记录分配到某个文件组时,会触发重写该文件组的最新文件切片。这在 COW 表里是可以接受的:重写基准文件本就是更新/删除操作的常态,因此对新插入触发一次重写并不会恶化整体时间复杂度。
MOR 表的设计目标是更低写入时延,频繁重写会背离初衷。为此,默认每次写入操作每个分区只选择一个小文件组进行扩容(可通过 hoodie.merge.small.file.group.candidates.limit 配置),以尽量降低对写延迟的影响。
另外,最新文件切片中已存在日志文件(来自先前更新)的文件组会被排除在候选之外。原因是:若将新插入并入此类文件组,就需要合并基准文件与日志文件,带来额外计算开销并增加写延迟。

这种与 COW 不同的策略背后的思路是:将小文件治理分散到多次写入中进行,每次在每个分区只“长大”一个(或按配置多个)文件组,表的整体文件大小分布终将达到理想状态。由于 MOR 场景通常写入更频繁,这些文件尺寸的改善也能更快显现。

注意
对于某些 MOR 表的写入索引类型(允许将新记录直接追加到日志文件),上述“只扩一个小文件组”的策略不适用。这类情况下,插入可以像 COW 一样,追加到任何满足小文件阈值的文件组,从而加速文件尺寸优化Bucket Index(桶索引) 就是其中常见的一种类型,第 5 章会详述。

小文件处理需要筛选候选文件组并对输入记录运行装箱(bin-packing)算法,这会为写入操作带来一定时延。若更看重写性能,可将 hoodie.parquet.small.file.limit 设为 0 完全关闭小文件处理。为缓解由此带来的文件尺寸问题,可以配置异步 Clustering 服务,专门以更优尺寸重写文件组。第 6 章将进一步讨论该主题。

bulk_insert 的排序模式(Sort modes in bulk_insert)

分布式引擎会把大作业拆成更小的任务并分配到多台机器的工作线程。在数据写入场景中,每个工作线程处理输入数据的一部分。对于不可追加的文件格式(如 Apache Parquet),在不分区的表里,单个任务通常输出一个文件。但在分区表中,由于记录会按分区字段值被路由到不同的分区路径(如图 3-5 所示),同一个任务可能会写出多个输出文件

image.png

除非对输入数据进行了专门的控制或预处理,否则各个任务可能会随机处理属于不同分区路径的记录。这在任务数很多时,容易导致产生大量小文件。为解决这一问题,bulk_insert 的写入流程不像 insert 那样使用“小文件处理”。原因在于:bulk_insert 主要面向初始引导(bootstrap)场景,此时并不存在可被填充的小文件;而且“小文件处理”对 bulk_insert 常见的海量数据并不高效。取而代之,bulk_insert 使用排序模式(sort modes)来控制数据的排序、分区与任务分配。只要选择了合适的排序模式,就能生成尺寸良好的输出文件。下面介绍两种常见模式:GLOBAL_SORTPARTITION_PATH_REPARTITION

GLOBAL_SORT 模式 会对整批输入记录进行排序:先按分区路径,再按记录键排序。这样可以将同一物理分区的记录就地集中,使每个任务写出更少且更大的文件。全局排序后的记录还能被执行引擎均匀切分并分发,从而缓解数据倾斜,避免计算资源浪费。此外,按记录键排序(尤其当键具备时间性或业务含义时)还能提升文件组内的数据局部性,有利于后续更新的效率。该策略的优势在于:文件尺寸良好、任务负载更均衡、数据局部性更好。其主要缺点是需要进行全量排序与 shuffle,带来显著的写入时延与内存开销。另外,当记录键完全随机(如 UUID)时,数据局部性的收益不复存在,使得排序成本的价值大打折扣。

PARTITION_PATH_REPARTITION 模式(见图 3-6)则在不排序的情况下,将同一分区路径的所有记录分配给同一个任务。这种做法同样能减少文件数、增大文件尺寸,达到与 GLOBAL_SORT 类似的效果。由于避免了对整批输入的排序,它更快且更省内存。但其主要缺点是:当某些分区路径上的数据高度倾斜时,对应任务会成为瓶颈;部分工作线程空闲,而另一些则处理远多于平均值的负载,造成资源浪费。

image.png

注意
默认情况下,bulk_insert 使用空操作NONE_SORT 模式。该模式完全依赖执行引擎将工作负载分配给各任务,不对任务的拆分与分配进行干预。此方式没有额外开销、写入速度最快,但也可能导致严重的小文件问题(如图 3-5 所示)。排序模式的选择会显著影响 bulk_insert性能、内存消耗以及最终表的存储形态。因此,务必充分分析目标表的数据分布模式,为 bulk_insert 选择最合适的策略。

若需完全自定义排序与分区行为,你也可以实现 Hudi 的 BulkInsertPartitioner 接口,以针对你的用例优化 bulk_insert

以 upsert 方式执行(Execute as upsert)

回到 sensor_data 示例,我们也可以用 INSERT INTO 语法执行 upsert:表中已存在的记录会被更新不存在的记录会被插入。由于我们为 sensor_data 定义了记录键排序字段,Hudi 会自动推断INSERT INTO 作为 upsert 执行,以减少配置工作量。
当然,你也可以在特定的 Spark SQL 会话中,通过设置 hoodie.spark.sql.insert.into.operation=insertbulk_insert覆盖这一行为。

insert 一样,upsert 也会启用小文件处理机制以维持合适的文件大小——两者都面向小批量、渐进式的数据写入,而非 bulk_insert 常见的海量数据处理场景。

使用 MERGE INTO 执行部分合并(Partial Merge)

在某些场景下,你可能需要手动为某个组织批量更新已纠正的传感器数据,而不是依赖常规的 INSERT INTO + upsert 流程。此时可以利用(第 2 章介绍过的)MERGE INTO 语法,将这一批更新数据加载并应用到目标表,如下所示:

MERGE INTO sensor_data t
USING (
  SELECT 
    'SENSOR_001'  AS id, 
    'TEMP'        AS type, 
    1797649200010 AS ts, 
    1797649300000 AS emit_ts,
    300.2         AS value,
    'ORG_A'       AS org_id
) s
ON t.id = s.id AND t.type = s.type AND t.ts = s.ts AND t.org_id = s.org_id
WHEN MATCHED THEN UPDATE SET
  emit_ts = s.emit_ts,
  value = s.value;

上述 SQL 表示:当来源表 s 与目标表 t复合记录键分区值上匹配时,把 t 中对应记录的 emit_tsvalue 更新为 s 中的新值。示例为演示而硬编码了样例记录;实际生产中通常会从外部来源表 SELECT 出带更新的数据。

在 Hudi(尤其是 MOR 表)中使用 MERGE INTO 的一大优势是支持部分合并(partial merge) 。第 2 章中我们了解到:对 Hudi 表的更新会以日志文件(log files)的形式保存在各自文件组中。部分合并指的是仅在日志文件中记录发生变化的列及其新值。其好处包括:

  • 减少写入数据量 → 写入更快、存储更省;
  • 查询时合并更高效 → 基准文件与日志文件的合并计算更少、查询性能更好。

部分合并对“超宽表”尤为关键——这在多种湖仓场景中很常见。比如流式处理中,多个流式写入者可能同时向一张汇聚大量来源的“超宽”事实表写入小批次更新,该表可能有数百甚至上千列;在 AI 领域,一张宽表可能存放来自多模态来源的特征。在这些模式下,大量更新只涉及少数列,启用部分合并至关重要。若总是按全量 schema 写更新,将严重拖累写入、存储与读取效率。

注意
目前,部分合并只在使用 MERGE INTO 作为写入器时可用。随着项目演进,这一强大能力会逐步覆盖更多写入方式,例如 UPDATE SQL 命令或第 8 章将介绍的 Hudi Streamer

执行删除(Perform Deletion)

简单回顾第 2 章:可使用 DELETE FROM 并基于记录键字段或其他字段进行过滤来删除记录:

DELETE FROM sensor_data
WHERE id = 'SENSOR_001' AND type = 'HUM' AND ts = 1797649200020;  -- 1

DELETE FROM sensor_data
WHERE org_id = 'ORG_A';                                          -- 2

1 删除某一条具体的传感器数据记录。
2 删除组织 A 的全部记录。

高效删除分区(Delete partitions efficiently)

Hudi 还支持用 ALTER TABLE DROP PARTITION 一次性删除整个分区:

ALTER TABLE sensor_data 
DROP PARTITION (org_id = 'ORG_A');

相较使用 DELETE FROM 批量删分区,该方法效率更高。借助 Hudi 的时间线与文件组设计,该操作会生成一次 replacecommit 动作:它在时间线上逻辑删除目标分区内的所有文件组与文件切片,而立即物理移除底层数据。

这种“仅元数据”的方式极快,且不干扰后续读写。数据处理引擎会先读取时间线,发现被 drop 的分区已不再暴露任何数据文件(得益于文件组与唯一 file ID 带来的逻辑隔离),随后按“分区为空”的状态继续执行任务。

这同时也为时间旅行查询(第 2 章已介绍)提供了“宽限期”:由于时间线保留了与历史数据文件关联的动作时间戳,你仍可读取分区删除前存在的记录。但需注意,这些历史数据不会无限期保留;在配置的保留期后,会由清理(cleaning)表服务永久移除(第 6 章详述)。

覆盖分区或整表(Overwrite Partition or Table)

Hudi 支持以一批新数据覆盖一个或多个分区,或覆盖整张表。对应的写操作类型为 insert_overwriteinsert_overwrite_table。可通过 INSERT OVERWRITE TABLE 语法执行。以下示例覆盖特定分区

INSERT OVERWRITE TABLE sensor_data PARTITION(org_id = 'ORG_A')   -- 1
SELECT 'SENSOR_003', 'TEMP', 1797649200010, 179764920050, 290.8; -- 2

SET hoodie.datasource.write.operation = insert_overwrite;         -- 3
INSERT OVERWRITE TABLE sensor_data
SELECT 
  'SENSOR_003', 'TEMP', 1797649200010, 179764920050, 290.8, 'ORG_A'; -- 4

1 显式指定要被新数据覆盖的目标分区。
2 由于已在 PARTITION(...) 子句中指明目标分区,不得在源数据中再带分区字段值。
3 额外的 Hudi 配置,使 INSERT OVERWRITE TABLE动态作用于受影响的分区
4 若表是分区表,且未使用 PARTITION(...) 子句,则应当在源数据中包含分区字段值。

为演示方便,这里只向目标分区插入了一条记录;实际中通常会针对外部来源表执行 SELECT 以插入整批数据。若省略 PARTITION 子句,可依据源数据中的分区字段值覆盖多个分区

要覆盖整张表,可将 Hudi 的写操作设为 insert_overwrite_table

SET hoodie.datasource.write.operation = insert_overwrite_table;  -- 1
INSERT OVERWRITE TABLE sensor_data
SELECT 'SENSOR_003', 'TEMP', 1797649200010, 179764920050, 290.8, 'ORG_A';

1 指示 INSERT OVERWRITE TABLE 以“截断整表 + 插入新数据”的方式执行。

警告
hoodie.datasource.write.operation 设为 insert_overwrite_table 时,INSERT OVERWRITE TABLE 总是覆盖整表无论是否指定了 PARTITION(...) 子句。若只想覆盖某个分区,务必取消该配置。

无论是覆盖分区还是整表,只要使用了 INSERT OVERWRITE TABLE,Hudi 都会在时间线上生成一次 replacecommit,这与使用 ALTER TABLE DROP PARTITION 删除分区时类似。可以将该动作视为:一个或多个分区删除 + 一次 bulk_insert 的组合。在同一 replacecommit 动作中,Hudi 会记录哪些分区被标记为删除以及哪些新数据文件被写入表中。

注意
INSERT OVERWRITE 在功能上等价于 INSERT OVERWRITE TABLE,只是文档(如 Spark、Hive)中更常见后者表述。请勿将这些 SQL 语法与 Hudi 的写操作类型 insert_overwriteinsert_overwrite_table 混淆。执行 INSERT OVERWRITE TABLE不会隐式把 Hudi 的写操作类型切换为 insert_overwrite_table;同理,执行 INSERT OVERWRITE 亦然。

亮点功能(Highlighting Noteworthy Features)

在前两节通过内部写入流程与 SQL 示例讲解了常见写入操作之后,本节将关注更多进阶特性。这里重点介绍与写入 Hudi 相关的三项尤为重要的能力。对每个特性,我们都会说明其用法、适用时机以及能带来的具体收益。

Key Generators(键生成器)

Hudi 将“主键”这一长期存在于传统数据库中的能力率先引入湖仓。能唯一标识记录是 Hudi 多项核心能力的基础,包括高效的更新与删除、高性能的点查(第 4 章)以及快速索引(第 5 章)。如本章前面所示,HoodieKey 结构体表示一条记录的唯一标识,包含记录键(record key)分区路径(partition path) 。Hudi 的 KeyGenerator API 的职责,就是基于原始输入数据中的记录键字段分区字段(第 2 章介绍),生成 HoodieKey(见图 3-7)。

image.png

图示说明了 Hudi 的键生成过程:输入数据列经由配置生成 _hoodie_record_key_hoodie_partition_path,共同构成 HoodieRecord 中的 HoodieKey
图 3-7. 键生成流程

为便于高效处理,Hudi 会为表中每条记录预置多个元字段(均为字符串类型),其中包括 _hoodie_record_key_hoodie_partition_path,用来存放构成 HoodieKey 的值。虽然记录键与分区字段的配置能让 Hudi 在原始输入数据中定位到对应值,但这些原始值未必足以构成全局唯一标识。KeyGenerator API 的意义正在于确保 HoodieKey 作为“主键”正确工作,这是 Hudi 数据管理能力的关键前提。需要注意:KeyGenerator API 始终以字符串返回记录键与分区路径的值,会将其从原始数据类型转换为与元字段类型匹配的字符串。

Hudi 内置了若干键生成器以覆盖大多数场景:

  • SIMPLE
    适用于仅有单一记录键字段单一分区字段的表。
  • COMPLEX
    适用于分区表,可包含一个或多个记录键字段以及一个或多个分区字段。它通过拼接字段名与对应值构造记录键与分区路径:使用 冒号 (:) 分隔字段名与值,使用 逗号 (,) 分隔各对字段-值。
  • NON_PARTITION
    适用于非分区表(一个或多个记录键字段)。记录键逻辑与 COMPLEX 类似,但分区路径始终返回空字符串,即所有记录都直接落在表的基路径下。
  • TIMESTAMP
    COMPLEX 的扩展版,适用于分区路径必须为格式化时间戳字符串(如按日期/小时)的分区表。记录键沿用 COMPLEX 逻辑,分区路径按该生成器的时间戳配置进行格式化。
  • CUSTOM
    最灵活的内置生成器,适合需要高级变换的分区表。它会自动推断记录键生成逻辑:若仅有一个记录键字段则用 SIMPLE,多字段则用 COMPLEX。其最大优势在于可组合多种分区路径生成策略。例如可配置:
    hoodie.datasource.write.partitionpath.fields=country:SIMPLE,date:TIMESTAMP
    这表示 country 分区字段采用 SIMPLE,date 分区字段采用 TIMESTAMP,最终得到多级分区路径

提示(Tip)
无需在写入配置中显式设置键生成器类型。Hudi 会根据你配置的记录键字段分区字段自动推断合适的键生成器。

关于 KeyGenerator API 的更多用法示例,可参考官方文档页面。

此前出现的所有 CREATE TABLE 示例都需要你通过 primaryKey 指定记录键字段。但在有些情况下,源数据集并没有天然主键,而你只想追加新数据。对此,Hudi 允许你省略记录键配置,此时 Hudi 会为每条记录自动生成全局唯一键。该键由以下组合构成:
1)写入动作的时间戳;2)分布式任务以及该任务内记录的序列号

回到“探索写入操作”中的 sensor_data 示例:如果没有纠正数据、只需将所有到达的数据作为新增插入,你可以定义一个COW 表且不指定记录键或排序字段。此时 CREATE TABLE 可简化为:

CREATE TABLE sensor_data ( 
  id STRING, 
  type STRING, 
  ts BIGINT, 
  emit_ts BIGINT, 
  value FLOAT, 
  org_id STRING 
) USING HUDI 
PARTITIONED BY (org_id);

你依然可以按 org_id 对表进行分区,并利用 KeyGenerator API 定义分区路径。分区路径将按照 KeyGenerator 设置生成,而记录键自动生成

除了简化建表,自动键生成的一个重要优势是:即使你的 sensor_data 最初只做追加写,日后也可能出于法规(如 GDPR)要求而删除特定记录。只要记录键被正确填充,就能利用 Hudi 的高效写入流程可靠地完成删除

Merge Modes(合并模式)

许多真实的数据管道需要记录合并来确保业务逻辑被正确处理。Hudi 针对合并机制进行了一等公民设计:通过 merge mode 定义通用语义,并提供 record merger API 以实现完全自定义。在 Hudi 中,合并会发生在多个阶段——记录准备写入存储查询(第 4 章)以及压缩合并(第 6 章)——标准化的 API 确保这些阶段的行为一致

Hudi 开箱即用地支持两种合并模式:COMMIT_TIME_ORDERINGEVENT_TIME_ORDERING

  • COMMIT_TIME_ORDERING
    到达顺序合并,选择最新到达的版本作为合并结果。
  • EVENT_TIME_ORDERING
    用户指定的排序字段合并,选择排序值最大的版本。

举例来说,COMMIT_TIME_ORDERING 适用于处理数据库变更日志的场景,日志记录按上游数据库的逻辑序列号(LSN)严格有序;而 EVENT_TIME_ORDERING 适合处理延迟到达事件的场景,例如用户行为因信号中断而延迟发送。你可以通过 hoodie.write.record.merge.mode 选择适合的合并模式。若未设置该配置:

  • 当表未设置排序字段时,默认采用 COMMIT_TIME_ORDERING
  • 当表设置了一个或多个排序字段时,默认采用 EVENT_TIME_ORDERING

若需要完全自定义合并逻辑,可实现 record merger API 并设置:

hoodie.write.record.merge.mode=CUSTOM
hoodie.write.record.merge.custom.implementation.classes=<你的实现类>
hoodie.write.record.merge.strategy.id=<需使用的实现 ID>

出于合并行为一致性考虑,某张表一旦创建,其合并模式配置会作为表属性持久化不可在建表后修改。更多细节请参阅文档页面。

写时模式的模式演进(Schema Evolution on Write)

此前我们通过 SQL 示例展示了 Hudi 的写入能力,但写入 Hudi 表并不只有这一种方式。像 Spark 或 Flink 这样的执行引擎也提供编程式 API,允许你用(例如)Python 或 Java 编写与 SQL 等价的操作。Hudi 与这些编程式 API 无缝集成

下面是使用 PySparksensor_data 表执行 upsert 的示例:

columns = ["id", "type", "ts", "emit_ts", "value", "org_id"]
data = [("SENSOR_004", "TEMP",  1797649200050, 1797649200100, 70.1, 'ORG_C')]
df = spark.createDataFrame(data).toDF(*columns)

hudi_options = {
    "hoodie.table.name": "sensor_data",
    "hoodie.datasource.write.recordkey.field", "id,type,ts",
    "hoodie.datasource.write.partitionpath.field": "org_id",
}

df.write.format("hudi"). \
    options(**hudi_options). \
    mode("append"). \
    save(basePath)

在本节语境下,无需逐行理解这段代码;关键在于:编程式 API 比 SQL 更灵活。例如,只要安装了所需库,你就可以用任意函数改造输入数据。但这种灵活性也带来了写入时如何处理模式(schema)演进的额外考量。

使用 INSERT INTO 时,SQL 引擎会校验你的输入数据是否与建表时定义的模式一致;缺失列会导致校验失败。相比之下,像上面的 PySpark 例子那样,程序式 API 会先创建一个 Spark DataFrame,输入记录遵循你定义的列集合;在 API 层面并不会天然校验输入数据模式与表模式的匹配。这正是 Hudi 能力发挥作用的地方:表格式定义了处理此类模式演进的行为。

Hudi 支持向后兼容的模式演进——即传入模式可以新增列,或将现有列的数据类型提升(promote) (例如从 INT 升级为 LONG)。虽然通过开启 hoodie.datasource.write.schema.allow.auto.evolution.column.drop(默认 false)也可支持不兼容的演进(如删除既有列),但不推荐这样做,因为它通常会引发众多下游兼容性问题与迁移成本。

默认情况下,如果传入模式最新表模式不向后兼容,Hudi 写入器会失败。为在处理传入数据时更灵活,你可以将 hoodie.write.set.null.for.missing.columns 设为 true,这会触发一次**对账(reconciliation)**流程,其规则如下:

  • 传入模式中的新列:会被加入到表的模式中。
  • 表模式中存在但传入缺失的列:对本次传入记录,该列将被填充为 null
  • 匹配列且传入类型是表类型的提升版本(例如传入 LONG、表为 INT):表的模式会被提升到新类型

该对账流程使 Hudi 表能够优雅地处理模式不匹配,并为下游流水线保留向后兼容性

注意
Hudi 项目在持续演进。尽管总体建议始终倾向于向后兼容的变更,但具体到模式演进的细节很多。建议查阅最新的官方文档以获取完整情形列表。

引导导入(Bootstrapping)

设想你在云存储(如 Amazon S3)中已有一大批按特定字段分区纯 Parquet 数据文件。你希望把这些数据导入到一个新的 Hudi 表(新目录)中,并保持记录完全一致,以便后续利用 Hudi 的优势(如高效 upsert、增量查询)。最直观的方法是:用 Spark 读取这些 Parquet,然后对新 Hudi 表执行 bulk_insert(第 2 章的 CREATE TABLE AS SELECT 示例演示了这一点)。但对于超大规模数据,该方式既耗时又昂贵:你需要扩容并运维一个合适的集群,还会产生大量读写,带来高昂的云费用

Hudi 的 bootstrap 操作正是为时间与成本效率而设计。其高阶流程如 图 3-8 所示。

image.png

图示展示了 Hudi 表中 METADATA_ONLY 引导流程:从源目录数据文件到目标目录“骨架(skeleton)”文件的流转,并附带相关的元数据操作。
图 3-8. METADATA_ONLY 引导流程

METADATA_ONLY 引导包含三个主要步骤:

  1. Runner 扫描源目录的 Parquet 文件,在目标目录引导出一个新 Hudi 表。对每个源文件,创建一个仅含 Hudi 元字段(metafields) 的“骨架文件”,如 _hoodie_record_key_hoodie_partition_path 等,这些值依据写入配置从原始记录推导而来。
  2. Runner 创建 bootstrap 索引文件(存放在 .hoodie/.aux/.bootstrap/),用于将这些骨架文件映射到各自的源数据文件。这些索引是关键,使后续 Hudi 写入器/读取器能把骨架文件中的元字段与原始记录“拼合(stitch) ”,从而把骨架文件当作文件组中的常规基准文件来对待。
  3. bootstrap 是一次事务性写入动作,会记录在表的时间线上。由于它是该表的第一次写入,会保留一个特殊的动作时间戳:对于 METADATA_ONLY,时间戳固定为 00000000000001

bootstrap 的另一种模式是 FULL_RECORD,本质上就是一次 bulk insert:读取原始数据并写入目标表,其时间线写入动作的时间戳固定为 00000000000002

完成引导后,该表就像普通 Hudi 表一样工作。后续写入都会落在目标表,而源目录保持不变。

注意
本章前面介绍的“部分合并(partial merge) ”目前尚不支持已引导(bootstrapped) 的表。

使用 bootstrap 的关键优势在于其 METADATA_ONLY 模式:如上所述,创建骨架文件只需读取原始数据中的少量列(用于计算 Hudi 元字段),相较 FULL_RECORD 或常规 bulk_insert,读写工作量大幅降低。bootstrap 也很灵活:你可以用正则表达式指定源目录中哪些分区采用 METADATA_ONLY哪些采用 FULL_RECORD。关于具体命令与完整配置列表,请查阅官方文档。

小结(Summary)

本章系统、全面地讲解了 Hudi 的写入能力,既覆盖写入数据到 Hudi 表的内部机制,也涵盖实践用法。这些知识为构建高效、可靠的湖仓数据管道打下了基础。

Hudi 的写入流程遵循一套五步法:发起提交、准备记录(可选的重复合并与索引步骤)、为分布式处理进行数据分区、通过专用写句柄写入存储、以及在时间线上完成提交。这一结构化流程确保了数据一致性,并支撑了 Hudi 的核心能力(如高效更新与删除)。

Hudi 提供多种写入操作:

  • insert:以小文件治理为特征的追加写,适合增量批处理
  • bulk_insert:通过重分区/排序策略高效处理大体量装载,非常适合初始数据引导
  • upsert:在一次操作中同时完成插入与更新,自动处理新旧记录,并同样利用小文件治理优化文件大小。
  • 借助 MERGE INTO 的部分合并(partial merge) ,可显著提升表在写入、读取与存储方面的效率。
  • DELETE FROM 适合记录级删除,而 ALTER TABLE DROP PARTITION 则用于高效的分区级删除
  • insert_overwrite / insert_overwrite_table分区删除批量插入的效率相结合,实现高效的数据覆盖

理解何时使用哪类操作,对于获得最佳性能与存储效率至关重要。

Hudi 还提供多项增强写入的强大特性:

  • Key Generators:从数据字段生成唯一记录标识,内置多种生成器以应对单/多记录键与分区字段;对仅追加的表还可自动生成键
  • Merge Modes:支持常见的排序语义可完全自定义的合并逻辑,确保合并过程一致且高效
  • Schema Evolution:支持随时间演进的数据结构变更,允许向后兼容的变更(如新增列、类型提升),不破坏既有流水线。
  • Bootstrapping:通过为原始文件创建仅元数据引用,高效地将现有数据集迁移到 Hudi 表中,相比全量重写大幅降低时间与成本

掌握本章的要点——从了解写入流程、选择合适的操作,到利用 Hudi 的进阶特性——你已具备为自身业务需求构建高效、可靠的湖仓数据管道的能力。