数十年来,数据库一直依赖索引这种专用数据结构,通过快速定位特定记录来大幅提升读写性能。Apache Hudi将这一核心理念扩展到了数据湖领域,并采用了一种独特而强大的实现方式。每一个Hudi表都内置了一个自管理的元数据表,它充当了索引子系统,能够在广泛的读写场景下实现高效的数据跳过和快速记录查找。
本文将分两部分深入探讨Hudi的索引子系统。第一部分主要解析其内部布局和数据跳过能力。第二部分将涵盖高级特性——记录索引、二级索引、表达式索引以及异步索引维护。
元数据表
在Hudi数据表内部,元数据表本身也是一个Hudi Merge-on-Read(MOR)表。与典型的数据表不同,它具有专门的布局。该表按索引类型进行物理分区,每个分区包含相应的索引条目。在物理存储层面,元数据表采用HFile作为基础文件格式。这是一个深思熟虑的选择:HFile在处理键值查找(索引最主要的查询模式)方面效率极高。
多模式索引
该元数据表常被称为多模式索引,因为它容纳了多种索引类型,为加速不同的查询模式提供了多样化能力。
元数据表位于数据表基础路径下的 .hoodie/metadata/ 目录中。它包含不同索引的分区,例如用于跟踪数据表分区和文件的“文件索引”(位于files/分区下),以及用于跟踪特定列文件级统计信息(如最小/最大值)的“列统计索引”(位于column_stats/分区下)。每个索引分区都存储着为其特定目的定制的映射条目。
这种分区设计提供了极大的灵活性,允许您仅启用适合您工作负载的索引。它还确保了可扩展性,便于未来支持新的索引类型。
当向数据表提交写入时,元数据表会在同一个事务性写入中更新。这一关键步骤确保了索引条目始终与数据表记录同步,维护了整个表的数据一致性。因此,选择Merge-on-Read(MOR)作为元数据表的表类型是一个显而易见的选择。MOR能够吸收高频写入操作,防止元数据表的更新过程成为整个表写入的瓶颈。为确保高效读取,Hudi会根据其压缩配置自动对元数据表执行压缩。默认情况下,每向元数据表写入10次,就会执行一次内联压缩,将累积的日志文件与基础文件合并,生成一组新的、经过读优化的HFile格式基础文件。
HFile 格式
HFile格式以排序、不可变且带块索引的方式存储键值对,其设计灵感源自谷歌BigTable论文中介绍的SSTable。正如你所见,通过实现SSTable模型,HFile在执行随机访问时特别高效,这正是索引的主要查询模式——给定一个特定信息(如记录键或分区值),返回匹配的结果(例如包含该记录键的文件ID,或属于该分区的文件列表)。
由于HFile中的键按字典序存储,对具有公共键前缀的批量查找也非常高效,只需要顺序读取附近键的数据即可。
默认行为
创建Hudi表时,元数据表默认会启用三个分区:文件、列统计和分区统计。
- 文件:存储所有分区的列表以及每个分区所有基础文件和日志文件的列表,位于元数据表的
files/ 分区。
- 列统计:存储指定列的文件级统计信息(如最小值、最大值、值计数和空值计数),位于元数据表的
column_stats/ 分区。
- 分区统计:存储指定列的分区级统计信息(如最小值、最大值、值计数和空值计数),位于元数据表的
partition_stats/ 分区。
默认情况下,当未为column_stats和partition_stats指定列时,Hudi将对表模式中可用的前32列(由hoodie.metadata.index.column.stats.max.columns.to.index控制)进行索引。
每当在数据表上执行新写入时,元数据表都会相应更新。对于任何可用的索引,新的索引条目都会被更新插入到其对应的分区中。例如,如果新写入在数据表中创建了一个包含新基础文件的新分区,则文件分区将被更新,并包含最新的分区和文件列表。类似地,列统计和分区统计分区将接收指示新文件和分区更新统计信息的新条目。
请注意,根据设计,您无法禁用文件分区,因为它是服务于读写过程的基础索引。尽管如此(尽管不推荐),您仍然可以在写入时通过设置hoodie.metadata.enable=false来禁用整个元数据表。
通过文件、列统计和分区统计实现数据跳过
数据跳过是一种避免不必要数据扫描的核心优化技术。其最基本的形式是物理分区,即根据客户订单表中的order_date等列将数据组织到目录中。当查询对分区列进行过滤时,引擎会使用分区剪枝仅读取相关目录。更高级的技术则存储每个文件内数据的轻量级统计信息(如最小/最大值)。查询引擎首先查阅此元数据;如果统计信息表明某个文件不可能包含所需数据,引擎则会完全跳过读取该文件。这种减少I/O的策略是加速查询和降低计算成本的关键。对于处理海量数据的大数据平台来说,数据跳过尤为重要。
数据跳过过程
Hudi的索引子系统使用多种索引的组合实现了多层跳过策略。Spark或Trino等查询引擎可以利用Hudi的文件、分区统计和列统计索引来显著提升性能。该过程分为几个阶段。
首先,查询引擎解析输入的SQL并提取相关的过滤谓词,例如price >= 300。这些谓词被下推到Hudi的集成组件,该组件管理索引查找过程。
接着,该组件查询文件索引以获取初始的分区列表。它使用存储分区级统计信息(如最小/最大值)的分区统计索引来剪裁此列表。例如,任何最大价格低于300的分区都会被完全跳过。
经过这轮初始剪裁后,组件再次查询文件索引以获取剩余分区内的数据文件列表。然后使用列统计索引(它提供文件级的相同最小/最大统计信息)进一步剪裁这个文件列表。
这种多步骤过程确保了查询引擎只读取满足查询所需的最小文件集,从而显著减少了处理的总数据量。
SQL 示例
以下示例演示了数据跳过的实际效果。我们将创建一个Hudi表并对其执行Spark SQL查询,首先同时禁用分区统计和列统计以建立基准。
CREATE TABLE orders (
order_id STRING,
price DECIMAL(12,2),
order_status STRING,
update_ts BIGINT,
shipping_date DATE,
shipping_country STRING
) USING HUDI
PARTITIONED BY (shipping_country)
OPTIONS (
primaryKey = 'order_id',
preCombineField = 'update_ts',
hoodie.metadata.index.column.stats.enable = 'false',
hoodie.metadata.index.partition.stats.enable = 'false'
);
并插入一些示例数据:
INSERT INTO orders VALUES
('ORD001', 389.99, 'PENDING', 17495166353, DATE '2023-01-01', 'A'),
('ORD002', 199.99, 'CONFIRMED', 17495167353, DATE '2023-01-01', 'A'),
('ORD003', 59.50, 'SHIPPED', 17495168353, DATE '2023-01-11', 'B'),
('ORD004', 99.00, 'PENDING', 17495169353, DATE '2023-02-09', 'B'),
('ORD005', 19.99, 'PENDING', 17495170353, DATE '2023-06-12', 'C'),
('ORD006', 5.99, 'SHIPPED', 17495171353, DATE '2023-07-31', 'C');
仅启用文件索引
在同时禁用列统计和分区统计的情况下,插入操作期间仅构建文件索引。我们使用以下SQL进行测试:
SELECT order_id, price, shipping_country
FROM orders
WHERE price > 300;
此查询寻找价格大于300的订单,这些订单仅存在于分区'A'(shipping_country = 'A')中。运行SQL后,在Spark UI中我们看到:Spark读取了全部3个分区和3个文件以寻找潜在匹配,但实际上只有分区A中的1条记录满足查询条件。
启用列统计
现在让我们启用列统计,同时保持分区统计禁用。注意,不能反其道而行之——分区统计需要先启用列统计。
CREATE TABLE orders (
order_id STRING,
price DECIMAL(12,2),
order_status STRING,
update_ts BIGINT,
shipping_date DATE,
shipping_country STRING
) USING HUDI
PARTITIONED BY (shipping_country)
OPTIONS (
primaryKey = 'order_id',
preCombineField = 'update_ts',
hoodie.metadata.index.column.stats.enable = 'true',
hoodie.metadata.index.partition.stats.enable = 'false'
);
运行相同的SQL,在Spark UI中显示:仍然扫描了全部3个分区,但只扫描了1个文件。由于没有分区统计,查询引擎无法剪裁分区,但列统计成功过滤掉了不匹配的文件。如果启用了分区统计,检查那两个无关分区及其文件的计算成本本可以避免。
同时启用列统计和分区统计
现在让我们也启用分区统计。由于在Hudi 1.x中这两个索引默认都是启用的,我们可以简单地从CREATE语句中省略那些额外的配置:
CREATE TABLE orders (
order_id STRING,
price DECIMAL(12,2),
order_status STRING,
update_ts BIGINT,
shipping_date DATE,
shipping_country STRING
) USING HUDI
PARTITIONED BY (shipping_country)
OPTIONS (
primaryKey = 'order_id',
preCombineField = 'update_ts'
);
运行相同的SQL,在Spark UI中显示:现在我们看到了完整的剪裁效果——由于两个索引协同工作,只扫描了1个相关分区和1个相关文件。在1 TB数据集上的测试表明,查询时间减少了93%。
配置相关列进行索引
默认情况下,Hudi为分区统计和列统计索引前32列。此限制是为了防止过度的元数据开销——每个被索引的列都需要为每个分区和数据文件计算最小值、最大值、空值计数和值计数统计信息。在大多数情况下,您只需要索引一小部分经常在查询谓词中使用的列。您可以通过在云原生数据湖架构中合理配置这些索引列,来指定需要对哪些列进行索引,从而降低维护成本:
CREATE TABLE orders (
order_id STRING,
price DECIMAL(12,2),
order_status STRING,
update_ts BIGINT,
shipping_date DATE,
shipping_country STRING
) USING HUDI
PARTITIONED BY (shipping_country)
OPTIONS (
primaryKey = 'order_id',
preCombineField = 'update_ts',
'hoodie.metadata.index.column.stats.column.list' = 'price,shipping_date'
);
配置项 hoodie.metadata.index.column.stats.column.list 同时适用于分区统计和列统计。通过仅索引price和shipping_date列,那些基于价格比较或发货日期范围进行过滤的查询已经可以看到显著的性能提升。
关键要点与后续
Hudi的元数据表本身是一个充当多模式索引子系统的Hudi Merge‑on‑Read(MOR)表。它按索引类型进行物理分区,并以HFile格式存储基础文件。这种布局为索引在数据湖规模下所需的访问模式提供了快速的点查找和高效的键前缀批量扫描。
索引维护与数据写入在事务上同步进行,保持索引条目与数据表一致。定期的压缩将日志文件合并为读优化的HFile基础文件,以保持点查找的快速和可预测性。在读取路径上,Hudi组合多个索引以最小化I/O:文件索引列举候选文件,分区统计剪裁无关分区,列统计剪除不匹配文件。实际上,引擎只扫描满足查询所需的最小文件集。
在实践中,默认设置是一个很好的起点。保持元数据表启用,并通过hoodie.metadata.index.column.stats.column.list显式列出您经常过滤的列,以控制元数据开销。在下一部分,我们将深入探讨如何使用记录索引、二级索引和表达式索引来加速基于等值匹配和表达式的谓词查询,并讨论异步索引维护如何在后台构建索引的同时避免阻塞写入器。