Apache Iceberg 表格式提供了在读取和写入过程中高性能的查询,使您能够直接在数据湖上运行在线分析处理(OLAP)工作负载。促进这种性能的是 Iceberg 表格式的各种组件设计方式。因此,了解这些组件的结构至关重要,以便查询引擎能够有效地使用它们进行更快的查询规划和执行。我们在第2章中详细讨论了这些架构组件。从高层次上看,所有这些组件可以按照图3-1所示的方式分为三个不同的层次。
让我们快速回顾一下查询引擎如何与这些组件进行读取和写入交互:
目录层
正如您在第2章中学到的,目录保存对当前元数据指针的引用,即每个表的最新元数据文件。无论您进行读取操作还是写入操作,目录都是查询引擎首先与之交互的组件。在读取操作中,引擎会联系目录以了解表的当前状态,而在写入操作中,目录用于遵循所定义的模式并了解表的分区方案。
元数据层
Apache Iceberg 中的元数据层由三个组件组成:元数据文件、清单列表和清单文件。每当查询引擎向 Iceberg 表写入内容时,都会原子性地创建一个新的元数据文件,并将其定义为元数据文件的最新版本。这确保了表提交的线性历史,并且在诸如并发写入(即多个引擎同时写入数据)的情况下有所帮助。此外,在读取操作期间,引擎将始终看到表的最新版本。查询引擎与清单列表交互以获取有关分区规范的信息,以帮助它们跳过不需要的清单文件,以提高性能。最后,引擎使用清单文件中的信息,如特定列的上限和下限、空值计数和特定于分区的数据,进行文件修剪。
数据层
查询引擎通过元数据文件筛选读取特定查询所需的数据文件,从而实现高效。在写入方面,数据文件被写入文件存储,并相应地创建和更新相关的元数据文件。
在接下来的章节中,您将学习有关 Apache Iceberg 中各种写入和读取操作的生命周期,以及每个操作如何与刚刚描述的组件进行交互,从而实现最佳的查询性能。请注意,在本章节中,我们将使用 Spark SQL 和 Dremio 的 SQL 查询引擎作为计算引擎来呈现查询。
Apache Iceberg 中的写查询
Apache Iceberg 中的写入过程涉及一系列步骤,使查询引擎能够高效地插入和更新数据。当启动写入查询时,它被发送到引擎进行解析。然后查询目录进行查阅,以确保数据的一致性和完整性,并根据定义的分区策略写入数据。然后根据查询编写数据文件和元数据文件。最后,更新目录文件以反映最新的元数据,使得后续的读取操作能够访问数据的最新版本。图3-2展示了该过程的高层概述。
创建表格
我们将从创建一个 Iceberg 表格开始,以便您可以了解底层的过程。示例查询将创建一个名为 orders 的表,其中包含四列。虽然在 Spark 和 Dremio 的 SQL 查询引擎中执行此操作的语法非常相似,但在本章的其余部分中分别显示,以便可以直接在每个系统中运行代码并跟随操作。这些代码示例也提供在书的 GitHub 仓库中。该表以 order_ts 字段的小时粒度进行分区。请注意,您不必为 Iceberg 表格添加显式的分区列。这个特性称为隐藏分区,正如我们在第1章中讨论的那样。
sql
# Spark SQL
CREATE TABLE orders (
order_id BIGINT,
customer_id BIGINT,
order_amount DECIMAL(10, 2),
order_ts TIMESTAMP
)
USING iceberg
PARTITIONED BY (HOUR(order_ts))
# Dremio
CREATE TABLE orders (
order_id BIGINT,
customer_id BIGINT,
order_amount DECIMAL(10, 2),
order_ts TIMESTAMP
)
PARTITION BY (HOUR(order_ts))
将查询发送到引擎
首先,查询被发送到查询引擎进行解析。然后,由于这是一个 CREATE 语句,引擎将开始创建和定义表格。
写入元数据文件
在这一点上,引擎开始创建一个名为 v1.metadata.json 的元数据文件,以在数据湖文件系统中存储关于表的信息。路径的通用形式类似于:s3://path/to/warehouse/db1/table1/metadata/v1.metadata.json。根据表路径 /path/to/warehouse/db1/table1 上的信息,引擎编写元数据文件。然后它通过指定列和数据类型定义了表 orders 的模式,并将其存储在元数据文件中。最后,它为表分配了一个唯一标识符:table-uuid。一旦查询成功执行,元数据文件 v1.metadata.json 就会被写入数据湖文件存储位置:s3://datalake/db1/orders/metadata/v1.metadata.json。如果您检查元数据文件,您将看到定义表的模式以及分区规范:
json
{
"table-uuid" : "072db680-d810-49ac-935c-56e901cad686",
"schema" : {
"type" : "struct",
"schema-id" : 0,
"fields" : [ {
"id" : 1,
"name" : "order_id",
"required" : false,
"type" : "long"
}, {
"id" : 2,
"name" : "customer_id",
"required" : false,
"type" : "long"
}, {
"id" : 3,
"name" : "order_amount",
"required" : false,
"type" : "decimal(10, 2)"
}, {
"id" : 4,
"name" : "order_ts",
"required" : false,
"type" : "timestamptz"
}
},
"partition-spec" : [ {
"name" : "order_ts_hour",
"transform" : "hour",
"source-id" : 4,
"field-id" : 1000
} ]
}
这是表的当前状态;您已经创建了一个表,但这是一个空表,没有任何记录。在 Iceberg 的术语中,这被称为快照(有关详细信息,请参阅第2章)。这里需要注意的一件重要事情是,由于您尚未插入任何记录,因此表中没有实际数据,因此您的数据湖中没有数据文件。因此,快照不指向任何清单列表;因此,没有清单文件。
更新目录文件以提交更改
最后,引擎将当前的元数据指针更新为指向目录文件版本提示文本中的 v1.metadata.json 文件,因为这是表的当前状态。请注意,目录文件的名称 version-hint.text 是特定于目录选择的。对于此演示,我们已经利用了基于文件系统的 Hadoop 目录。在第5章中,我们将比较和对比您可以选择的不同 Iceberg 目录选项。图3-3展示了在创建表之后 Iceberg 组件的层次结构。
插入查询
现在让我们向表中插入一些记录并了解事情是如何运作的。我们已经创建了一个名为 orders 的表,其中包含四列。对于此演示,我们将以下值输入到表中:order_id 为 123,customer_id 为 456,order_amount 为 36.17,order_ts 为 2023-03-07 08:10:23。以下是查询:
sql
sql
复制代码
# Spark SQL/Dremio 的 SQL 查询引擎
INSERT INTO orders VALUES (
123,
456,
36.17,
'2023-03-07 08:10:23'
)
将查询发送到引擎
查询被发送到查询引擎进行解析。由于这是一个 INSERT 语句,引擎需要关于表的信息,例如其模式,以开始查询规划。
检查目录
首先,查询引擎请求目录以确定当前元数据文件的位置,然后读取它。因为我们使用的是 Hadoop 目录,所以引擎将读取 /orders/metadata/version-hint.txt 文件并查看文件内容为单个整数:1。因此,借助目录实现的逻辑,引擎知道当前元数据文件位置是 /orders/metadata/v1.metadata.json,这是我们之前 CREATE TABLE 操作创建的文件。因此引擎将读取此文件。虽然引擎在这种情况下的动机是插入新的数据文件,但它仍然与目录交互,主要原因有两个:
- 引擎需要了解表的当前模式以便遵守它。
- 引擎需要了解分区方案以便在写入时相应地组织数据。
写入数据文件和元数据文件
在引擎了解表模式和分区方案之后,它开始写入新的数据文件和相关的元数据文件。以下是此过程中的发生情况。
引擎首先根据表的小时定义的分区方案,将记录写入 Parquet 数据文件(Parquet 是默认格式,但可以更改)。此外,如果为表定义了排序顺序,则在将记录写入数据文件之前会对其进行排序。这在文件系统中可能是这样的:
bash
s3://datalake/db1/orders/data/order_ts_hour=2023-03-07-08/0_0_0.parquet
写入数据文件后,引擎创建清单文件。此清单文件包含引擎创建的实际数据文件的路径信息。此外,引擎在清单文件中写入统计信息,例如列的上下界和空值计数,这对于查询引擎来说非常有益,可以剪枝文件并提供最佳性能。引擎在处理即将写入的数据时计算此信息,因此相对于从头开始计算统计信息的过程,这是一个相对轻量级的操作。清单文件被写入存储系统中的 .avro 文件:
bash
s3://datalake/db1/orders/metadata/62acb3d7-e992-4cbc-8e41-58809fcacb3e.avro
以下是清单文件内容的 JSON 表示。请注意,这不是清单文件的完整内容,而是关于我们的主题的一些关键信息的摘录:
json
{
"data_file" : {
"file_path" :
"s3://datalake/db1/orders/data/order_ts_hour=2023-03-07-08/0_0_0.parquet",
"file_format" : "PARQUET",
"block_size_in_bytes" : 67108864,
"null_value_counts" : [],
"lower_bounds" : {
"array": [{
"key": 1,
"value": 123
}],
}
"upper_bounds" : {
"array": [{
"key": 1,
"value": 123
}],
},
}
}
接下来,引擎创建清单列表以跟踪清单文件。如果与此快照关联的现有清单文件,则也将添加到此新清单列表中。引擎将此文件写入数据湖,并包含清单文件的路径信息、添加或删除的数据文件/行数以及分区统计信息等信息。再次说明,引擎已经拥有所有这些信息,因此具有这些统计信息是一项轻量级操作。此信息有助于读取查询排除任何不必要的清单文件,从而促进更快的查询:
bash
s3://datalake/db1/orders/metadata/
snap-8333017788700497002-1-4010cc03-5585-458c-9fdc-188de318c3e6.avro
以下是清单列表内容的片段:
json
{
"manifest_path":
"s3://datalake/db1/orders/metadata/62acb3d7-e992-4cbc-8e41-58809fcacb3e.avro",
"manifest_length": 6152,
"added_snapshot_id": 8333017788700497002,
"added_data_files_count": 1,
"added_rows_count": 1,
"deleted_rows_count": 0,
"partitions": {
"array": [ {
"contains_null": false,
"lower_bound": {
"bytes": "¹Ô\u0006\u0000"
},
"upper_bound": {
"bytes": "¹Ô\u0006\u0000"
}
} ]
}
}
最后,引擎通过考虑现有的元数据文件 v1.metadata.json(之前的当前文件)并跟踪以前的快照 s0,创建一个新的元数据文件 v2.metadata.json,其中包含一个新的快照 s1。这个新的元数据文件包括引擎创建的清单列表的信息,包括清单列表文件路径、快照 ID 和操作摘要。此外,引擎引用此清单列表(或快照)现在是当前的:
bash
s3://datalake/db1/orders/metadata/v2.metadata.json
以下是此新元数据文件内容的示例(这是元数据文件的摘录):
css
"current-snapshot-id" : 8333017788700497002,
"refs" : {
"main" : {
"snapshot-id" : 8333017788700497002,
"type" : "branch"
}
},
"snapshots" : [ {
"snapshot-id" : 8333017788700497002,
"summary" : {
"operation" : "append",
"added-data-files" : "1",
"added-records" : "1",
},
"manifest-list" : "s3://datalake/db1/orders/metadata/
snap-8333017788700497002-1-4010cc03-5585-458c-9fdc-188de318c3e6.avro",
} ],
更新目录文件以提交更改
现在引擎再次访问目录,以确保在执行此插入操作时没有其他快照被提交。通过进行这种验证,Iceberg 确保在多个写入者同时写入数据的情况下操作不会产生干扰。对于任何写入操作,Iceberg 都会乐观地创建元数据文件,假设当前版本在写入者提交之前将保持不变。在完成写入后,引擎通过将表的元数据文件指针从现有的基础版本切换到新版本 v2.metadata.json 来进行原子提交,该版本现在成为当前的元数据文件。
Iceberg 组件在此阶段的层次结构的可视化表示如图 3-4 所示。
合并查询
对于下一个写入操作,我们将执行一个 UPSERT/MERGE INTO 操作。这样的查询通常在您想要更新表中现有行的特定值时运行,如果该值不存在,则只需插入新行。
因此,对于我们的示例,假设有一个阶段表 orders_staging,其中包含两条记录:一条是对现有订单号(order_id=123)进行更新,另一条是全新的订单。我们希望保持 orders 表中每个订单的最新详细信息,因此,如果订单号已经存在于目标表(orders)中,我们将更新 order_amount。如果不存在,则只需插入新记录。以下是查询:
sql
# Spark SQL
MERGE INTO orders o
USING (SELECT * FROM orders_staging) s
ON o.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET order_amount = s.order_amount
WHEN NOT MATCHED THEN INSERT *;
# Dremio
MERGE INTO orders o
USING (SELECT * FROM orders_staging) s
ON o.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET order_amount = s.order_amount
WHEN NOT MATCHED THEN INSERT (order_id, customer_id, order_amount, order_ts)
VALUES (s.order_id, s.customer_id, s.order_amount, s.order_ts)
此查询将合并以下数据集:
orders:
order_id | customer_id | order_amount | order_ts |
---|---|---|---|
123 | 456 | 36.17 | 2023-03-07 08:10:23 |
orders_staging:
order_id | customer_id | order_amount | order_ts |
---|---|---|---|
123 | 456 | 50.5 | 2023-03-07 08:10:23 |
124 | 326 | 60 | 2023-01-27 10:05:03 |
将查询发送到引擎
首先,查询被查询引擎解析。在这种情况下,由于涉及两个表(阶段表和目标表),引擎需要两个表的数据来开始查询规划。
检查目录
与前一节讨论的 INSERT 操作类似,查询引擎首先请求目录以确定当前元数据文件的位置,然后读取它。由于此示例使用的目录是 Hadoop,因此引擎将读取 /orders/metadata/version-hint.txt
文件并检索其内容,即整数 2。获取此信息并使用目录逻辑后,引擎了解到当前元数据文件位置为 /orders/metadata/v2.metadata.json
。这是我们之前的 INSERT 操作生成的文件,因此引擎将读取此文件。然后,它将查看表的当前模式,以便写入操作可以遵循它。最后,引擎将根据分区策略了解数据文件是如何组织的,并开始写入新的数据文件。
写入数据文件和元数据文件
首先,查询引擎将从 orders_staging 和 orders 两个表中读取数据并将其加载到内存中,以确定匹配的记录。请注意,我们将在下一节详细介绍读取过程。引擎将根据 order_id 字段遍历两个表中的每条记录,并找出匹配的记录。
这里需要注意的一点是,由于引擎正在确定匹配项,因此在内存中跟踪的内容将基于 Iceberg 表属性定义的两种策略:复制写入(COW)和读取合并(MOR)。
虽然我们将在第 4 章对这两种策略进行更深入的讨论,但简而言之,使用 COW 策略时,每当更新 Iceberg 表时,与相关记录关联的任何关联数据文件都将重写为新的数据文件。但是,使用 MOR,数据文件不会被重写;相反,将生成新的删除文件以跟踪更改。
在我们的案例中,我们将使用 COW 策略。因此,包含来自 orders 表的 order_id = 123 记录的数据文件 0_0_0.parquet 将被读入内存。然后,将使用 order_staging 表中的新 order_amount 更新此 order_id 的 in-memory 数据。最后,这些修改的详细信息将写入新的 Parquet 数据文件:
s3://datalake/db1/orders/data/order_ts_hour=2023-03-07-08/0_0_1.parquet
请注意,在这个特定的示例中,我们的 orders 表中只有一条记录。但是,即使在这个表中有其他不符合查询条件的记录,引擎仍会复制所有这些记录,只有匹配的行才会被更新,而不匹配的行会被写入一个独立的文件。这是由于写入策略 COW。您将在第 4 章了解有关这些写入策略的更多信息。
现在,不符合条件的 order_staging 表中的记录将被视为常规的 INSERT,并将作为新的数据文件写入到一个不同的分区中,因为该记录的 hour(order_ts) 值与其他记录不同:
s3://datalake/db1/orders/data/order_ts_hour=2023-01-27-10/0_0_0.parquet
写入数据文件后,引擎创建一个新的清单文件,其中包含对这两个数据文件路径的引用。此外,清单文件还包括有关这些数据文件的各种统计信息,例如列的下限和上限以及值计数:
s3://datalake/db1/orders/metadata/faf71ac0-3aee-4910-9080-c2e688148066.avro
您可以在书的 GitHub 存储库中查看生成的清单文件的示例。
然后,引擎生成一个新的清单列表,指向前一步骤中创建的清单文件。它还跟踪任何现有的清单文件,并将清单列表写入数据湖:
s3://datalake/db1/orders/metadata/snap-5139476312242609518-1-e22ff753-2738-4d7d- a810-d65dcc1abe63.avro
检查清单列表后(请参阅书的 GitHub 存储库),您还可以看到分区统计信息以及添加和删除文件的数量。
然后,引擎继续创建一个新的元数据文件,v3.metadata.json,其中包含一个新的快照,s2,基于先前的当前元数据文件 v2.metadata.json 和作为其中一部分的快照 s0 和 s1。在书的 GitHub 存储库中,您可以看到此操作的示例。
更新目录文件以提交更改
最后,引擎在此时运行检查以确保没有写入冲突,然后使用最新的元数据文件值(v3.metadata.json)更新目录。在视觉上,Iceberg 组件在 UPSERT 操作的这个阶段将呈现如图 3-5 的样子。
Apache Iceberg 中的读查询
从 Apache Iceberg 表中读取数据遵循一系列明确定义的操作,无缝地将查询转换为可操作的见解。当启动读取查询时,首先将其发送给查询引擎。引擎利用目录检索最新的元数据文件位置,该文件包含关于表模式和其他元数据文件的关键信息,例如最终导致实际数据文件的清单列表。在此过程中使用关于列的统计信息来限制要读取的文件数量,从而有助于提高查询性能。
选择查询
在这一部分,我们将介绍当执行读取查询时 Apache Iceberg 的各个组件如何协同工作。以下是我们将要运行的查询:
sql
# Spark SQL/Dremio Sonar
SELECT *
FROM orders
WHERE order_ts BETWEEN '2023-01-01' AND '2023-01-31'
将查询发送给引擎
在此阶段,引擎将根据元数据文件开始规划查询。
检查目录
查询引擎请求目录以获取 orders 表的当前元数据文件路径,然后读取它。如前两节所述,由于我们在此使用的是 Hadoop 目录,因此引擎将读取 /orders/metadata/version-hint.txt
文件。该文件的内容是一个整数:3。根据此信息和目录实现的逻辑,引擎知道当前元数据文件位置是 /orders/metadata/v3.metadata.json
。这是我们之前的 MERGE INTO 操作生成的文件。
从元数据文件获取信息
然后引擎打开并读取元数据文件 v3.metadata.json,以获取关于一些事项的信息。首先,它确定表的模式,以准备其内部内存结构以读取数据。有关 metadata.json 文件中数据示例,请参阅书中的 GitHub 存储库。然后它了解表的分区方案,以了解数据的组织方式。查询引擎以后可以利用这一点来跳过不相关的数据文件。
引擎从元数据文件中检索到的最重要的信息之一是当前快照 ID。这是表的当前状态的标志。根据当前快照 ID,引擎将从快照数组中定位到清单列表文件路径,以进一步遍历和扫描相关文件。
从清单列表获取信息
在从元数据文件获取清单列表文件路径的位置后,查询引擎读取文件 snap-5139476312242609518-1-e22ff753-2738-4d7d-a810-d65dcc1abe63.avro 以获取更多详细信息。引擎从此文件中获取的最关键的信息是每个快照的清单文件路径位置。引擎需要此信息来获取特定查询的相关数据文件。
清单列表还包含有关分区的关键信息,例如分区规范 ID。这告诉引擎用于写入特定快照的分区方案。目前,该字段的值为 0,这意味着这是表的唯一分区。
还有其他分区特定的统计信息,例如清单的分区列的下限和上限。当引擎确定要跳过哪些清单文件以进行更好的文件修剪时,此信息非常有用。该文件还包含有关每个快照添加/删除的数据文件总数和行数的其他详细信息。
从清单文件获取信息
然后引擎打开未被修剪的清单文件 faf71ac0-3aee-4910-9080-c2e688148066.avro。它读取文件以获取详细信息。首先,查询引擎扫描每个条目,每个条目代表此清单文件跟踪的数据文件。它将每个这些数据文件所属的分区值与我们查询过滤器中使用的值进行比较。
在查询中,我们请求获取所有在 '2023-01-01' 和 '2023-01-31' 之间的订单详情。因此,引擎会忽略分区值 2023-03-07-08,因为它与筛选值范围不匹配。当筛选值与分区值匹配时,引擎将检查该分区中的所有记录。
根据分区值,引擎查找相应的数据文件 0_0_0.parquet。引擎还收集其他统计信息,例如每列的下限和上限以及空值计数,以跳过任何不相关的文件。
Apache Iceberg 默认提供的分区和基于指标的过滤等数据和文件优化技术允许引擎避免全表扫描,正如在本示例中所见,从而提供了显著的性能保证。最后,记录返回给用户:
order_id | customer_id | order_amount | order_ts | |
---|---|---|---|---|
1 | 125 | 321 | 20.50 | 2023-01-27 10:30:05 +00:00 |
从视觉上看,整个读取过程如图3-6所示。
参考图3-6,注意以下事项:
- 查询引擎与目录进行交互,获取当前的元数据文件(v3.metadata.json)。
- 然后获取当前快照ID(在本例中为S2)以及该快照的清单列表位置。
- 然后从清单列表中检索清单文件路径。
- 根据清单文件中的分区过滤器(2023-03-07-08)确定数据文件路径。
- 然后将所需数据文件中匹配的数据返回给用户。
时间旅行查询
在数据库和数据仓库领域,一个重要的功能是能够回溯到表的特定状态,查询历史数据(即已更改或已删除的数据)。Apache Iceberg为数据湖架构带来了类似的时间旅行能力。这对于诸如分析组织的以往季度数据、恢复意外删除的行或重现分析结果等场景特别有用。Apache Iceberg提供两种运行时间旅行查询的方式:使用时间戳和使用快照ID。
在本节中,您将学习如何为Apache Iceberg表运行时间旅行查询。为了演示的目的,假设我们需要回溯到执行MERGE INTO查询之前的状态(即当我们刚刚运行INSERT语句时的状态)。因此,根据这些假设,我们首先需要了解Iceberg表的历史。Apache Iceberg的一个最好的功能之一是它允许您通过称为元数据表的系统表分析各种表特定的元数据信息。您将在第10章中详细了解元数据表。为了分析我们的订单表的历史,我们将查询历史元数据表(本节中的示例元数据可在书的GitHub存储库中找到):
sql
#Spark SQL
SELECT * FROM catalog.db.orders.history;
#Dremio
SELECT * FROM TABLE (table_history('orders'))
这给我们提供了该表中发生的所有交易的列表:
made_current_at | snapshot_id | parent_id | is_current_ancestor |
---|---|---|---|
2023-03-06 21:28:35.360 | 7327164675870333694 | null | true |
2023-03-07 20:45:08.914 | 8333017788700497002 | 7327164675870333694 | true |
2023-03-09 19:58:40.448 | 5139476312242609518 | 8333017788700497002 | true |
总结历史元数据表:
第一次快照,ID 7327164675870333694,在我们运行CREATE语句后生成。
第二次快照,8333017788700497002,在我们使用INSERT语句插入新记录后创建。
最后,我们的MERGE INTO查询创建了第三个快照,ID 5139476312242609518。
由于我们的要求是在最终事务(即MERGE)之前进行时间旅行,我们将针对的时间戳或快照ID是第二个。这是我们将运行的查询:
sql
# Spark SQL
SELECT * FROM orders
TIMESTAMP AS OF '2023-03-07 20:45:08.914'
# Dremio Sonar
SELECT * FROM orders
AT TIMESTAMP '2023-03-07 20:45:08.914'
如果我们想要使用快照 ID 进行时间旅行,查询将如下所示:
sql
# Spark SQL
SELECT *
FROM orders
VERSION AS OF 8333017788700497002
# Dremio
SELECT *
FROM orders
AT SNAPSHOT 833301778870049700
现在让我们快速了解当运行时间旅行查询时 Iceberg 组件背后发生了什么,以及如何将相关数据文件返回给用户。
将查询发送到引擎
与任何 SELECT 语句一样,查询首先被发送到引擎,引擎解析它。引擎将利用表元数据来开始规划查询。
检查目录
在这一步中,查询引擎请求目录以了解当前元数据文件的位置并读取它。由于我们在此练习中使用了 Hadoop 目录,引擎将读取 /orders/metadata/version-hint.txt
文件的内容,其中包含整数 3。有了这些信息,并且遵循目录的实现逻辑,引擎确定当前元数据文件的位置为 /orders/metadata/v3.metadata.json
。最终,引擎将读取此文件以了解表模式和分区策略等内容。
从元数据文件获取信息
接下来,引擎读取元数据文件以获取表信息。当前元数据文件跟踪我们的 Iceberg 表生成的所有快照,除非它们是作为元数据维护策略的一部分有意过期的。从可用的快照列表中,引擎将根据时间戳值或快照 ID 确定时间旅行查询中指定的特定快照。引擎还了解表的模式和分区方案,以便后续用于文件修剪。最后,它获取该特定快照的相应清单列表路径:s3://datalake/db1/orders/metadata/snap-8333017788700497002-1-4010cc03-5585-458c-9fdc-188de318c3e6.avro
。
从清单列表获取信息
基于清单列表路径,引擎打开并读取包含我们快照数据的指定 .avro
文件。引擎从清单列表中推导出几个重要的信息:清单文件路径位置,其中保存了对实际数据文件的引用:s3://datalake/db1/orders/metadata/62acb3d7-e992-4cbc-8e41-58809fcacb3e.avro
。数据文件数量增加/删除的信息以及关于分区的统计信息。
从清单文件获取信息
最后,引擎读取与我们查询匹配的任何清单文件并获取详细信息。清单文件中最重要的信息是数据文件路径,其中包含查询记录的文件路径。引擎将遍历清单中的每个数据文件,以确定是否应该读取它。除了数据文件路径位置外,引擎还收集了前面章节讨论的列的统计信息。最终,引擎读取数据文件 0_0_0.parquet
,然后将以下输出返回给用户:
order_id | customer_id | order_amount | order_ts |
---|---|---|---|
123 | 456 | 36.17 | 2023-03-07 08:10:23 +00:00 |
这是我们在运行 MERGE INTO 查询之前插入到表中的记录。图 3-7 提供了对该过程的可视化总结。
参考图 3-7,请注意以下内容:
- 查询引擎与目录交互以获取当前的元数据文件(v3.metadata.json)。
- 然后,根据时间戳或时间旅行查询中提供的版本 ID,选择快照(在本例中为 S1),并获取该快照的清单列表位置。
- 然后从清单列表中检索清单文件路径。
- 引擎根据清单文件中的分区过滤器(2023-03-07-08)确定数据文件路径。
- 然后将所需数据文件中的匹配数据返回给用户。
总结
在本章中,我们讨论了各种读取和写入查询的内部工作原理,例如创建表、插入和更新记录,以了解 Apache Iceberg 的不同架构组件如何被计算引擎所利用。
在第四章中,我们将介绍 Apache Iceberg 中提供的开箱即用的优化技术,以确保在读写数据到表时实现高性能。