Apache Hudi 最初由Uber于 2016 年开发,旨在实现一个交易型数据湖,该数据湖可以快速可靠地支持更新,以支持公司拼车平台的大规模增长。Apache Hudi 现在被业内许多人广泛用于构建一些非常大规模的数据湖。Apache Hudi 为快速变化的环境中管理数据提供了一个有前途的解决方案。
Hudi 使用户能够使用 Hudi 存储的记录级元数据跟踪单个记录随时间的变化,这是 Hudi 的基本设计选择。然而,由于这种选择在同行中的独特性,因此也是引起争议的常见原因,并且清楚地了解记录级元数据提供的价值以及额外成本至关重要。本博客将讨论 Hudi 中五个记录级元字段的重要性以及相关的存储开销,以充分理解其对 Apache Hudi 工作负载的好处。
记录键元字段用于唯一标识 Hudi 表或分区中的记录。借助记录键,Hudi 可以确保没有重复记录,并在写入时强制执行唯一性完整性约束。与数据库类似,记录键也用于记录的索引,以实现更快、有针对性的更新和删除,以及从 Hudi 表生成 CDC 更改日志。大多数源数据已经包含一个自然记录键,尽管 Hudi 也可以自动生成记录键(即将发布),以支持日志事件等可能不包含此类字段的用例。
在可变工作负载中,数据在被摄取或存储后会发生变化。通常这些是 a) 删除请求以符合数据保护相关法规和 b) 从上游系统向下传递的更新请求。如果没有记录键将更改记录链接在一起,可能会导致系统中出现重复记录。例如,假设我们正在从上游 OLTP 数据库接收变更日志。这些日志可以在一个时间窗口内多次更新同一个主键。为了防止重复,我们必须合并同一提交中的记录,并根据相同的键定义始终如一地针对存储中的记录进行合并。
如果想知道记录键对不可变数据不是很有帮助,让我们举个例子。考虑这样一个场景,新数据不断添加到表中,同时需要回填来修复过去的数据质量问题或推出新的业务逻辑。回填可以在任何时间段发生,并且不能保证被回填的数据不会与活动写入重叠。如果没有记录键,回填必须严格逐个分区执行,同时与写入端协调以远离回填分区以避免不准确的数据或重复。但是使用记录键,用户可以识别和回填单个记录,而不是在较粗略的分区级别处理它。当结合 Hudi 的并发控制机制和对排序字段的支持时,正常和回填写入端可以无缝写入表,而不必担心回填写入端覆盖正常写入,这可以使表恢复到旧状态。请注意即使使用严格序列化的事务,这些事情也可能发生在数据上。
现在已经确定我们需要记录键,让我们了解为什么它们还需要以持久形式与实际记录一起存储,即使 Hudi 支持虚拟键。这样做有明显的好处,在复合键的情况下,每次重新计算或重新处理记录键可能很耗时,因为它需要从存储中读取多个列。故障时有发生,在数据工程中,配置的无意变更很常见,通常会导致多个团队花费数小时来确定和解决根本原因。这方面的一个例子可能是记录键配置被意外更改,导致两条记录看似重复,但在系统中被视为单独的记录。当关键字段发生变化时(比如从 A 到 B),无法保证表中的所有历史数据相对于新的关键字段 B 都是唯一的,因为到目前为止我们已经对 A 执行了所有唯一性实施。因此实现记录键是一种简单而有效的技术,可以避免陷入这些棘手的数据质量问题。如果使用物化记录键,则两个记录之间的差异(记录键的更改)与数据一起记录,并且不会违反唯一性约束。
数据库通常由多个内部组件组成,它们协同工作以向用户提供效率、性能和出色的可操作性。同样 Hudi 也设计了内置的表服务和索引机制,以确保高性能的表存储布局和更快的查询。
这些服务依靠记录键来正确有效地实现其预期目标。让我们以压缩服务为例。压缩是一种将增量日志与基本文件合并以生成具有最新数据快照的最新版本文件的方法。压缩过程每次都检查数据以提取旧文件的记录键是低效的。反序列化成本很容易增加,因为这需要对每条记录以及每次运行压缩时进行。正如开创性的数据库工作所指出的那样,记录键是将加快写入/查询速度的索引等技术与导致记录在表内跨文件移动的聚簇等其他机制联系在一起的基本结构。
这些字段捕获 Hudi 表中记录的物理/空间分布。_hoodie_partition_path 字段表示记录存在的相对分区路径。_hoodie_file_name 字段表示存在记录的实际数据文件名。回到Hudi增量数据处理的根源,分区路径字段通常用于从增量查询进一步过滤记录,例如下游ETL作业只对表中最后N天分区的变化感兴趣,可以通过简单地编写一个 _hoodie_partition_path 过滤器实现。
这些字段也是在生产环境中快速调试数据质量问题的手段。想象一下调试重复记录问题,这是由重复作业或锁提供程序配置错误等引起的。注意到表中有重复条目但不确定它们是如何出现的。还需要找到受影响的记录并确定问题发生的时间。如果没有必要的元字段,确定问题的根本原因就像大海捞针。在 Hudi 中,简单的 "select _hoodie_partition_path, _hoodie_file_name, columns fromwhere ;" 将选取分区路径和文件名,从中提供重复记录以进一步调查。由于这两个字段对于单个文件中的所有记录都是相同的,因此它们压缩得很好并且不承担任何开销。
这两个字段代表一条记录在Hudi表中的时间分布,从而可以跟踪记录的变化历史。_hoodie_commit_time 字段表示创建记录时的提交时间,类似于数据库提交。_hoodie_commit_seqno 字段是提交中每条记录的唯一序列号,类似于 Apache Kafka 主题中的偏移量。在 Kafka 中偏移量帮助流式客户端跟踪消息并在发生故障或关闭后从同一位置恢复处理。同样,_hoodie_commit_seqno 可用于从 Hudi 表生成流。
为了更好地理解此功能,让我们考虑一个写入时复制 (CoW) 表,其中新的写入通过与现有的最新基础文件合并来生成版本化的基础文件。仅在此处跟踪文件级别的版本可能是不够的,因为并非文件中的所有记录在提交期间都已更新。要在其他LakeHouse系统中获得这种类型的记录级更改,必须连接表的每两个相邻快照,这在丢失有关表快照的元数据等情况下可能非常昂贵且不精确。
相比之下 Hudi 将记录级别的变更流视为首要设计目标,并在所有级别对这些信息进行编码——将时间提交到文件、日志块和记录中。此外通过将这种更改跟踪信息与数据一起有效地存储,即使是增量查询也可以从在表上执行的所有存储组织/排序/布局优化中受益。
Hudi 使用此元字段解锁的另一个强大功能是能够为记录保留近乎无限的历史记录。Hudi 社区的一位用户——一家大型银行,能够成功利用此功能支持对历史数据的时间旅行查询——甚至可以追溯到 5 或 6 年前。这可以在实践中通过仅管理文件大小配置、启用可扩展元数据和禁用清理器来实现。如果不将提交时间与记录一起保存,就不可能从记录创建时就看到记录的历史记录。当想在拥有这么多年数据的历史表中挖掘时间旅行能力时这个功能就派上用场了。
结合 Hudi 的可扩展表元数据,这可以解锁近乎无限的历史保留,这使得一些 Hudi 用户甚至可以回到几年前。
到目前为止我们讨论了 Hudi 中元字段解锁的基本功能。如果仍然担心元字段的存储成本,我们想以一个小的基准估计此开销。这个基准测试是基于 Hudi master 运行的。为此我们为不同宽度的表格生成了样本数据,并比较了在 Hudi 表格中存储额外元字段与通过 spark 编写的普通Parquet表的成本。如果对细节感兴趣,这里是基准设置。
该基准测试在三种不同宽度(10 列、30 列和 100 列)的表格上比较了 Vanilla Parquet、具有默认 gzip 压缩的 Hudi CoW Bulk Insert 和具有 snappy 压缩的 Hudi CoW Bulk Insert。Hudi 默认使用 gzip 压缩,这比 Vanilla Spark Parquet 编写的压缩效果更好。可以看到包括元数据在内的实际数据被很好地压缩(记录键元字段压缩 11 倍,而其他压缩甚至更多,有时甚至完全压缩)并且与没有元字段的Vanilla Parquet数据相比存储更少。Hudi 的默认设置是在未来的版本中转向 zstd,这将抵消 gzip 相对于 snappy 的计算开销。即使我们在 Hudi 中使用 snappy 编解码器也可以看到随着表变得越来越宽,为 100 TB 表估计的元字段占用的额外空间会减少。即使对于标准 TPCDS 上的 100 TB 表大小(例如具有 30 列的表),也只需支付约 8 美元即可添加记录级元字段。如果表格更宽比如 100 列甚至 1000 列,添加元字段的成本不会超过 1 美元。
总之 Hudi 在记录级别跟踪的元字段具有更大的用途。它们通过保持表中的唯一性约束、支持更快的目标更新/删除、实现增量处理和时间旅行、支持表服务准确高效地运行、安全地处理重复项、时间旅行,在维护数据完整性方面发挥着关键作用。它们有助于调试并防止由于潜在的数据质量问题而导致的管道清理噩梦。如果使用像 Delta 或 Iceberg 这样没有这些元字段的表格格式,那么其中许多好处并不容易实现。例如像重复检测这样基本的事情需要与源数据和数据模型的假设进行多次连接,或者由用户负责在将其引入数据湖之前进行处理。在我们结束之前,我们希望读者考虑这个问题 - 为静态大小为 100TB 的 30 列表添加元字段的成本约为 8 美元就可以享受记录级元字段提供的好处。
如果仍然不确定,请查看 Uber 的这篇博客。Uber 利用 Hudi 纪录的元字段和增量处理能力的组合,将其管道中的计算成本降低了 80%,这可以轻松覆盖额外的元字段开销,数倍于此。