正如您在第三章中所看到的,Apache Iceberg 表提供了一层元数据,允许查询引擎创建更智能的查询计划,以提升性能。然而,这些元数据只是优化数据性能的开始。
您可以使用各种优化杠杆来提升性能,包括减少数据文件数量、数据排序、表分区、行级更新处理、指标收集和外部因素。这些杠杆在提升数据性能方面起着至关重要的作用,本章将探讨每个杠杆,解决潜在的减速问题并提供加速见解。使用首选工具实施健壮的监控对于识别优化需求至关重要,其中包括使用 Apache Iceberg 元数据表,我们将在第十章中介绍。
压缩
每个流程或过程都需要时间成本,这意味着更长的查询时间和更高的计算成本。换句话说,您需要执行的步骤越多,完成任务所需的时间就越长。当您查询 Apache Iceberg 表时,您需要打开并扫描每个文件,然后在完成后关闭文件。对于一个查询,您需要扫描的文件越多,文件操作对查询的成本就越大。在流式或"实时"数据的世界中,这个问题被放大,因为数据在被创建时就被摄取,生成了许多只有几条记录的文件。
相比之下,批量摄取允许您更有效地规划如何写入更有组织的文件,例如一天或一周的记录。即使使用批量摄取,也可能会遇到"小文件问题",因为有太多小文件会影响扫描的速度和性能,原因是您执行了更多的文件操作,需要读取更多的元数据(每个文件都有元数据),以及在清理和维护操作时需要删除更多的文件。图 4-1 描绘了这两种情况。
基本上,在读取数据时,有些固定成本是无法避免的,而有些可变成本则可以通过采用不同策略来避免。固定成本包括读取与您的查询相关的特定数据;您无法避免必须读取数据以进行处理。虽然可变成本包括执行文件操作以访问数据,但使用本章中我们将讨论的许多策略,您可以尽可能减少这些可变成本。在使用这些策略之后,您将仅使用必要的计算资源以更便宜、更快地完成工作(更快地完成工作的好处在于可以更早地终止计算集群,从而降低成本)。
解决此问题的方法是定期将所有这些小文件中的数据重写为更少的较大文件(如果相对于您拥有的数据文件数量,清单过多,您可能还需要重写清单)。这个过程被称为压缩,因为您正在将许多文件压缩成少数文件。图 4-2 展示了压缩的过程。
实际操作压缩
您可能认为,虽然解决方案听起来很简单,但它将涉及您必须编写一些复杂的 Java 或 Python 代码。幸运的是,Apache Iceberg 的 Actions 包括了几个维护过程(Actions 包专门用于 Apache Spark,但其他引擎可以创建自己的维护操作实现)。这个包可以通过编写 SparkSQL(正如本章大部分内容所示)或编写命令式代码来使用,如下所示(请注意,这些操作仍然保持与普通 Iceberg 事务相同的 ACID 保证):
sql
Table table = catalog.loadTable("myTable");
SparkActions
.get()
.rewriteDataFiles(table)
.option("rewrite-job-order", "files-desc")
.execute();
在这个片段中,我们初始化了表的一个新实例,然后触发了 rewriteDataFiles
,这是用于压缩的 Spark 操作。SparkActions 使用的构建器模式允许我们链式调用方法,以微调压缩作业,不仅表达我们想要进行压缩的意图,还表达了我们想要如何进行压缩。
在调用 rewriteDataFiles
和开始作业的 execute
方法之间,您可以链接几种方法:
- binPack:将压缩策略设置为 binpack(稍后会讨论),这是默认设置,不需要显式提供
- Sort:将压缩策略更改为按优先级顺序重写的数据进行排序,更多细节见"压缩策略"
- zOrder:更改压缩策略以按多个字段进行 z-order 排序,进一步讨论见"排序"
- filter:使您能够传递用于限制重新编写哪些文件的表达式
- option:更改单个选项
- options:接受几个选项配置的映射
您可以传递几种可能的选项来配置作业;以下是一些重要的选项:
- target-file-size-bytes:这将设置输出文件的预期大小。默认情况下,这将使用表的 write.target.file-size-bytes 属性,其默认值为 512 MB。
- max-concurrent-file-group-rewrites:这是同时写入的文件组数量的上限。
- max-file-group-size-bytes:文件组的最大大小不是一个单独的文件。当处理大于工作程序可用内存的分区时,应使用此设置,以便将该分区拆分为多个并行写入的文件组。
- partial-progress-enabled:这允许在文件组压缩时进行提交。因此,对于长时间运行的压缩,这可以使并行查询受益于已经压缩的文件。
- partial-progress-max-commits:如果启用了部分进度,此设置将设置允许完成作业的最大提交数。
- rewrite-job-order:写入文件组的顺序,在使用部分进度时很重要,以确保较高优先级的文件组尽快提交,可以基于字节大小或文件组中的文件数量进行排序(bytes-asc、bytes-desc、files-asc、files-desc、none)。
文件大小和行组大小
对于 Apache Parquet 文件,有行组大小和文件大小两个概念。行组大小是 Parquet 文件中一组行的大小,每个文件可以有多个行组。因此,Iceberg 表的默认配置允许每个行组大小为 128 MB,文件大小为 512 MB(每个文件有四个行组)。您始终希望确保这两个设置是对齐的(即行大小可以被文件大小均匀地除尽)。行组数量较少会导致文件大小较小,因为需要为较少的组写入组元数据,而行组数量较多则会改善谓词下推,因为行组元数据可以具有更细粒度的范围,使查询引擎能够消除更多不包含与当前查询相关数据的行组的读取。
另一个例子是,您可能希望将文件大小增加到每个文件 1 GB,但保持行组大小为 128 MB(每个文件有八个行组);这样,需要打开和关闭的文件数量就会减少。虽然您经常运行的查询类型通常需要读取大部分数据,但您更倾向于较少的行组,因为谓词下推不会加速获取所有数据的过程。
行组大小和文件大小都可以设置为表属性(分别是 write.parquet.row-group-size-bytes 和 write.target-file-size-bytes),但文件大小可以在个别压缩作业中使用 options 设置。
部分进度部分进度允许在文件组完成时创建新的快照。这使得查询可以在其他文件完成时从已经紧凑的文件中受益。它还有助于防止大型压缩作业出现内存不足(OOM)的情况,因为随着作业完成,进度被保存,并且需要在内存中保留的数据量较少。
请注意,更多的快照意味着更多的元数据文件占用存储空间在您的表位置。但如果您希望您的读取器尽快受益于压缩作业,而不是稍后,这可能是一个有用的功能。如果您希望平衡额外快照的成本与部分进度的好处,您可以调整最大提交以限制单个压缩作业将进行的总提交数量。
以下代码片段实际运用了几种可能的表选项:
sql
Table table = catalog.loadTable("myTable");
SparkActions
.get()
.rewriteDataFiles(table)
.sort()
.filter(Expressions.and(
Expressions.greaterThanOrEqual("date", "2023-01-01"),
Expressions.lessThanOrEqual("date", "2023-01-31")))
.option("rewrite-job-order", "files-desc")
.execute();
在上面的示例中,我们实现了一个排序策略,默认情况下遵循表属性中指定的排序顺序。此外,我们还添加了一个过滤器,以专门重写一月份的数据。需要注意的是,此过滤器需要使用 Apache Iceberg 的内部表达式构建接口创建表达式。此外,我们配置了 rewrite-job-order,以优先处理较大文件组的重写。这意味着正在重写包含五个文件的文件组的文件将在只包含两个文件的文件组之前处理。
虽然以上方法都行之有效,但使用 Spark SQL 扩展可以更轻松地实现,这些扩展包括可通过以下语法从 Spark SQL 调用的调用过程:
sql
-- 使用位置参数
CALL catalog.system.procedure(arg1, arg2, arg3)
-- 使用命名参数
CALL catalog.system.procedure(argkey1 => argval1, argkey2 => argval2)
使用这种语法中的 rewriteDataFiles
过程将如示例 4-1 所示。
示例 4-1. 使用 rewrite_data_files
过程运行压缩作业
sql
-- 在 SparkSQL 中调用 Rewrite Data Files CALL 过程
CALL catalog.system.rewrite_data_files(
table => 'musicians',
strategy => 'binpack',
where => 'genre = "rock"',
options => map(
'rewrite-job-order','bytes-asc',
'target-file-size-bytes','1073741824', -- 1GB
'max-file-group-size-bytes','10737418240' -- 10GB
)
)
在这种情况下,我们可能会将一些数据流式传输到我们的音乐家表中,并注意到为摇滚乐队生成了许多小文件,因此我们不是对整个表运行压缩,这可能很耗时,而是只针对有问题的数据进行了处理。我们还告诉 Spark 优先处理较大字节的文件组,并保留每个文件组约 1GB 大小的文件,每个文件组约为 10GB。您可以在图 4-3 中看到这些设置的结果。
其他引擎可以实现它们自己的自定义压缩工具。例如,Dremio 通过其 OPTIMIZE
命令具有自己的 Iceberg 表管理功能,这是一个独特的实现,但遵循了许多 RewriteDataFiles
操作的 API:
css
OPTIMIZE TABLE catalog.MyTable
上述命令将通过将所有文件压缩为更少、更优化的文件来实现基本的 binpack 压缩。但就像 Spark 中的 rewriteDataFiles
过程一样,我们可以进行更细粒度的压缩。
例如,这里我们只压缩特定的分区:
java
OPTIMIZE TABLE catalog.MyTable
FOR PARTITIONS sales_year IN (2022, 2023) AND sales_month IN ('JAN', 'FEB', 'MAR')
这里我们使用特定的文件大小参数进行压缩:
ini
OPTIMIZE TABLE catalog.MyTable
REWRITE DATA (MIN_FILE_SIZE_MB=100, MAX_FILE_SIZE_MB=1000, TARGET_FILE_SIZE_MB=512)
在这个代码片段中,我们只重写清单:
css
OPTIMIZE TABLE catalog.MyTable
REWRITE MANIFESTS
如您所见,您可以使用 Spark 或 Dremio 来实现 Apache Iceberg 表的压缩。
压缩策略
如前所述,当使用 rewriteDataFiles
过程时,有几种压缩策略可供选择。表 4-1 总结了这些策略,包括它们的优缺点。在本节中,我们将讨论 binpack 压缩;标准排序和 z-order 排序将在本书后面章节中介绍。
表 4-1. 压缩策略的优缺点
策略 | 作用 | 优点 | 缺点 |
---|---|---|---|
Binpack | 仅组合文件;没有全局排序(会在任务内进行局部排序) | 提供最快的压缩作业。 | 数据未聚类。 |
Sort | 在分配任务之前按一个或多个字段顺序排序(例如,按字段a排序,然后在其内按字段b排序) | 按常查询字段聚类的数据可以显著提高读取速度。 | 与Binpack相比,压缩作业时间更长。 |
z-order | 在分配任务之前按同等权重的多个字段排序(X和Y值在此范围内的一组;在另一范围内的另一组) | 如果查询经常依赖于多个字段的过滤器,可以进一步提高读取速度。 | 与Binpack相比,压缩作业时间更长。 |
Binpack策略本质上是纯粹的压缩,不考虑数据组织方式,除了文件的大小。 在三种策略中,binpack是最快的,因为它只需将较小文件的内容写入到目标大小的较大文件中,而sort和z-order必须先对数据进行排序,然后才能分配文件组进行写入。 当你有流数据时,这种策略特别有用,因为它可以以符合服务水平协议(SLA)的速度运行压缩任务。
如果你正在摄取流数据,可能需要在每小时后对摄取的数据进行快速压缩。你可以这样做:
javascript
CALL catalog.system.rewrite_data_files(
table => 'streamingtable',
strategy => 'binpack',
where => 'created_at between "2023-01-26 09:00:00" and "2023-01-26 09:59:59" ',
options => map(
'rewrite-job-order', 'bytes-asc',
'target-file-size-bytes', '1073741824',
'max-file-group-size-bytes', '10737418240',
'partial-progress-enabled', 'true'
)
)
在这个压缩任务中,binpack策略被采用,以便更快地满足流数据的SLA要求。它专门针对一小时内摄取的数据,可以动态调整到最近一小时。部分进度提交的使用确保在文件组写入时,它们会立即提交,从而为读者带来即时的性能提升。重要的是,这个压缩过程只关注先前写入的数据,与流操作中引入的新数据文件的并发写入隔离开来。
在有限的数据范围内使用更快的策略可以使你的压缩任务更快。当然,如果允许超过一小时的压缩,可以进一步压缩数据,但需要平衡快速运行压缩任务的需求与优化的需求。你可能会在夜间对一天的数据进行额外的压缩任务,并在周末对一周的数据进行压缩任务,以持续优化,同时尽量少干扰其他操作。请记住,压缩总是遵守当前的分区规范,因此如果重写旧的分区规范的数据,将应用新的分区规则。
自动化压缩
如果你需要手动运行这些压缩任务来满足所有的SLA要求,会有点棘手,因此研究如何自动化这些过程可能会带来很大的好处。以下是一些可以用来自动化这些任务的方法:
- 使用编排工具:你可以使用如Airflow、Dagster、Prefect、Argo或Luigi等编排工具,在摄取作业完成后或在特定时间或周期间隔内,将适当的SQL发送到如Spark或Dremio等引擎。
- 使用无服务器函数:在数据进入云对象存储后,你可以使用无服务器函数来触发任务。
- 设置定时任务:可以设置cron任务在特定时间运行相应的作业。
这些方法要求你手动编写脚本并部署这些服务。然而,也有一些托管的Apache Iceberg目录服务,具备自动化表维护功能并包括压缩。例如,Dremio Arctic和Tabular等服务。
排序
在深入讨论排序压缩策略的细节之前,让我们先了解一下与优化表相关的排序。
对数据进行排序或"聚类"在查询时有一个非常特殊的好处:它有助于减少为获取查询所需数据而需要扫描的文件数量。排序允许具有相似值的数据集中在较少的文件中,从而使查询规划更加高效。
例如,假设你有一个数据集,代表每个NFL球队的每个球员,共有100个未按照任何特定方式排序的Parquet文件。如果你仅查询底特律雄狮队的球员,即使一个包含100条记录的文件中只有一条记录是底特律雄狮队的球员,该文件仍必须添加到查询计划中并被扫描。这意味着你可能需要扫描多达53个文件(NFL球队的最大球员人数)。如果你按球队名称字母顺序对数据进行排序,那么所有底特律雄狮队的球员应该会集中在大约四个文件中(100个文件除以32个NFL球队约等于3.125),其中可能还会包括一些绿湾包装工队和丹佛野马队的球员。因此,通过对数据进行排序,你将需要扫描的文件数量从可能的53个减少到4个,这大大提高了查询的性能。图4-4展示了扫描排序数据集的好处。
如果数据的排序方式符合典型的查询模式,例如你可能会经常基于特定的球队查询NFL数据,那么排序数据会非常有用。Apache Iceberg在许多不同的点都可以进行数据排序,因此你需要确保利用好所有这些点。
有两种主要方式来创建表。一种是使用标准的CREATE TABLE语句:
sql
-- Spark 语法
CREATE TABLE catalog.nfl_players (
id bigint,
player_name varchar,
team varchar,
num_of_touchdowns int,
num_of_yards int,
player_position varchar,
player_number int
);
-- Dremio 语法
CREATE TABLE catalog.nfl_players (
id bigint,
player_name varchar,
team varchar,
num_of_touchdowns int,
num_of_yards int,
player_position varchar,
player_number int
);
另一种方式是使用CREATE TABLE...AS SELECT (CTAS)语句:
sql
-- Spark SQL & Dremio 语法
CREATE TABLE catalog.nfl_players
AS (SELECT * FROM non_iceberg_teams_table);
创建表之后,你可以设置表的排序顺序,支持该属性的引擎会在写入数据之前进行排序,这也会成为使用排序压缩策略时的默认排序字段:
sql
ALTER TABLE catalog.nfl_teams WRITE ORDERED BY team;
如果使用CTAS,可以在你的AS查询中对数据进行排序:
sql
CREATE TABLE catalog.nfl_teams
AS (SELECT * FROM non_iceberg_teams_table ORDER BY team);
ALTER TABLE catalog.nfl_teams WRITE ORDERED BY team;
ALTER TABLE语句设置了一个全局排序顺序,支持该排序顺序的引擎将用于所有未来的写操作。你也可以在INSERT INTO时指定排序,如下所示:
sql
INSERT INTO catalog.nfl_teams
SELECT *
FROM staging_table
ORDER BY team;
这将确保数据在写入时已排序,但并不完美。回到之前的例子,如果每年NFL数据集都更新球队名单,你可能会有很多文件分割了狮子队和包装工队的球员。这是因为你现在需要为当前年份的新狮子队球员写入一个新文件。这时,排序压缩策略就派上用场了。
排序压缩策略将对作业目标中的所有文件进行排序。例如,如果你想对整个数据集的所有球员按球队进行全局排序,可以运行以下语句:
ini
CALL catalog.system.rewrite_data_files(
table => 'nfl_teams',
strategy => 'sort',
sort_order => 'team ASC NULLS LAST'
);
以下是传递的排序顺序字符串的解释:
- team
按team字段排序 - ASC
按升序排序(DESC则按降序排序) - NULLS LAST
将所有null值的球员放在排序的末尾,在Washington Commanders之后(NULLS FIRST则将所有球员放在Arizona Cardinals之前)
你也可以按其他字段进行排序。例如,你可能希望数据按团队排序,但在每个团队内再按名字的字母顺序排序。你可以通过运行具有以下参数的作业来实现这一点:
ini
CALL catalog.system.rewrite_data_files(
table => 'nfl_teams',
strategy => 'sort',
sort_order => 'team ASC NULLS LAST, name ASC NULLS FIRST'
);
按团队排序的权重最高,其次是按名字排序。你可能会在文件中看到球员的顺序,如狮子队名单结束和包装工队名单开始的位置,如图4-6所示。
如果终端用户经常询问诸如"所有名字以 A 开头的狮子队球员是谁"这样的问题,这种双重排序会进一步加速查询。然而,如果终端用户询问"所有名字以 A 开头的 NFL 球员是谁",这种排序方式就不那么有帮助了,因为所有名字以 A 开头的球员会分布在更多的文件中,而不是仅按名字排序。这时,z-order 排序可能会更有用。
关键是,要充分利用排序的优势,你需要了解终端用户提出的问题类型,以便有效地根据他们的问题对数据进行排序。
Z-order
在查询表时,有时候多个字段都是优先考虑的,这时 z-order 排序可能会非常有帮助。使用 z-order 排序,你将数据按多个数据点进行排序,这使得引擎能够更有效地减少最终查询计划中扫描的文件数量。让我们想象一下,我们正在尝试在一个 4 × 4 的网格中定位物品 Z(见图 4-7)。
在图 4-7 中指的是"A",我们有一个值(z),我们可以说等于 3.5,并且我们想缩小我们在数据中搜索的范围。我们可以通过根据 X 和 Y 值的范围将字段分为四个象限来缩小我们的搜索范围,如图中的"B"所示。
因此,如果我们根据 z-排序的字段知道我们正在查找的数据,我们可能可以避免搜索大部分数据,因为数据按照这两个字段进行了排序。然后,我们可以进一步将该象限细分,并对象限中的数据应用另一种 z-排序,如图中的"C"所示。由于我们的搜索基于多个因素(X 和 Y),通过采用这种方法,我们可以消除 75% 的可搜索区域。
您可以以类似的方式对数据文件中的数据进行排序和聚类。例如,假设您有一个涉及医学队列研究中所有参与者的数据集,并且您正在尝试按年龄和身高对队列中的结果进行组织;对数据进行 z-排序可能非常值得。您可以在图 4-8 中看到这一过程。
数据落入特定象限的数据将位于同一数据文件中,这样在尝试对不同年龄/身高组运行分析时,可以大大减少要扫描的文件数量。如果您正在搜索身高为 6 英尺,年龄为 60 岁的人,您可以立即排除包含属于其他三个象限数据的数据文件。
这种工作原理是因为数据文件将分为四类:
-
A:包含年龄 1--50 和身高 1--5 记录的文件
-
B:包含年龄 51--100 和身高 1--5 记录的文件
-
C:包含年龄 1--50 和身高 5--10 记录的文件
-
D:包含年龄 51--100 和身高 5--10 记录的文件
如果引擎知道您正在搜索年龄为 60 岁,身高为 6 英尺的人,它将使用 Apache Iceberg 元数据规划查询,那么类别 A、B 和 C 中的所有数据文件都将被排除,永远不会被扫描。请注意,即使只按年龄搜索,通过聚类也可以消除至少四个象限中的两个的好处。 要实现这一点,需要运行一个压实作业:
ini
CALL catalog.system.rewrite_data_files(
table => 'people',
strategy => 'sort',
sort_order => 'zorder(age,height)'
)
使用排序和 z-排序压实策略不仅可以减少数据存在的文件数量,还可以确保这些文件中数据的顺序使得查询规划更加高效。
虽然排序是有效的,但也存在一些挑战。首先,随着新数据的摄入,数据变得无序,在下一个压实作业之前,数据仍然有些分散在多个文件中。这是因为新数据添加到新文件中,并且可能在该文件中排序,但不是在所有先前记录的上下文中排序。其次,文件仍可能包含多个排序字段值的数据,对于仅需要特定值数据的查询来说,这可能是低效的。例如,在前面的示例中,文件包含了狮队和包队球员的数据,当您只对狮队球员感兴趣时,扫描包队记录是低效的。 为了解决这个问题,我们有分区。
分区
如果您知道特定字段对数据访问方式至关重要,您可能希望超出排序并进入分区。当表进行分区时,它将根据目标字段的不同值将记录写入其自己的数据文件,而不仅仅是基于字段排序顺序。
例如,在政治领域,您可能经常根据选民的党派关系查询选民数据,这使得这个字段成为一个很好的分区字段。这意味着所有"蓝"党的选民将列在与"红","黄"和"绿"党的选民不同的文件中。如果您查询"黄"党的选民,您扫描的数据文件中将不会包含任何其他党派的人。您可以在图 4-9 中看到这一点的示例。
排序后的数据如果其排序方式与典型的查询模式相符合,会非常有用。例如,在这个示例中,如果你经常基于某个特定的球队查询NFL数据,那么排序的数据将会更加便利。在Apache Iceberg中,数据排序可以发生在多个不同的点,因此你需要确保充分利用这些点。
创建表格有两种主要方法。一种是使用标准的CREATE TABLE语句:
sql
-- Spark语法
CREATE TABLE catalog.nfl_players (
id bigint,
player_name varchar,
team varchar,
num_of_touchdowns int,
num_of_yards int,
player_position varchar,
player_number int
);
-- Dremio语法
CREATE TABLE catalog.nfl_players (
id bigint,
player_name varchar,
team varchar,
num_of_touchdowns int,
num_of_yards int,
player_position varchar,
player_number int
);
另一种是使用CREATE TABLE...AS SELECT (CTAS)语句:
sql
-- Spark SQL & Dremio语法
CREATE TABLE catalog.nfl_players
AS (SELECT * FROM non_iceberg_teams_table);
创建表后,你需要设置表的排序顺序,任何支持该属性的引擎将在写入数据之前使用该顺序进行排序,并且在使用排序压缩策略时也将是默认的排序字段:
sql
ALTER TABLE catalog.nfl_teams WRITE ORDERED BY team;
如果使用CTAS,在你的AS查询中对数据进行排序:
sql
CREATE TABLE catalog.nfl_teams
AS (SELECT * FROM non_iceberg_teams_table ORDER BY team);
ALTER TABLE catalog.nfl_teams WRITE ORDERED BY team;
ALTER TABLE语句设置了一个全局的排序顺序,这个顺序将被所有遵循排序顺序的引擎在未来的写入中使用。你也可以在INSERT INTO时指定它,如下所示:
sql
INSERT INTO catalog.nfl_teams
SELECT *
FROM staging_table
ORDER BY team;
这将确保数据在写入时被排序,但并不完美。回到之前的例子,如果NFL数据集每年都因球队名单的变化而更新,你可能会有许多文件将狮子队和包装工队的球员数据分开写入。这是因为你现在需要为当年的新狮子队球员写一个新文件。这时排序压缩策略就派上用场了。
排序压缩策略会在所有目标文件中对数据进行排序。例如,如果你想全局地按球队对整个数据集进行排序,可以运行以下语句:
ini
CALL catalog.system.rewrite_data_files(
table => 'nfl_teams',
strategy => 'sort',
sort_order => 'team ASC NULLS LAST'
);
以下是传递的排序字符串的解析:
team
: 按team字段对数据进行排序ASC
: 按升序对数据进行排序(DESC则表示降序)NULLS LAST
: 将所有NULL值的球员放在排序结果的末尾,即在Washington Commanders之后(NULLS FIRST则表示将所有NULL值的球员放在排序结果的最前,即在Arizona Cardinals之前)
图4-5展示了排序后的结果。
你还可以按其他字段进行排序。例如,你可能希望数据按球队排序,但在每个球队内部,你可能希望按姓名的字母顺序排序。你可以通过运行带有以下参数的作业来实现这一点:
ini
CALL catalog.system.rewrite_data_files(
table => 'nfl_teams',
strategy => 'sort',
sort_order => 'team ASC NULLS LAST, name ASC NULLS FIRST'
);
按球队排序的权重最高,其次是按姓名排序。你可能会在文件中看到这样的顺序,其中狮子队的名单结束的地方紧接着是包装工队的名单,如图4-6所示。
如果终端用户经常提出类似"所有名字以A开头的狮子队球员是谁"的问题,这种双重排序会进一步加快查询速度。然而,如果终端用户询问"所有名字以A开头的NFL球员是谁",这种排序方式就不太有帮助了,因为所有名字以A开头的球员分散在多个文件中,而不是仅按名字排序时的集中情况。这时z-order排序会非常有用。
关键是,要充分利用排序的优势,你需要了解终端用户提出的问题类型,以便有效地对数据进行排序,使其与用户的查询需求相契合。
Z-order排序
有时候在查询一个表时会有多个优先字段,这时z-order排序会非常有帮助。通过z-order排序,你可以按多个数据点对数据进行排序,这使引擎在最终的查询计划中能更好地减少扫描的文件数量。让我们假设我们在一个4×4的网格中定位项目Z(如图4-7所示)。
参考图4-7中的"A",我们有一个值(z),可以假设其等于3.5,并且我们希望缩小在数据中搜索的区域。我们可以通过根据X和Y值的范围将字段分成四个象限来缩小搜索范围,如图中"B"所示。
因此,如果我们根据z-order排序的字段知道我们要查找的数据,可以避免搜索大量数据,因为这些数据按两个字段排序。然后,我们可以进一步细分该象限,并对该象限中的数据应用另一个z-order排序,如图中的"C"所示。由于我们的搜索基于多个因素(X和Y),采取这种方法可以消除75%的可搜索区域。
你可以用类似的方法对数据文件中的数据进行排序和聚类。例如,假设你有一个参与医学队列研究的所有人员的数据集,并且你试图按年龄和身高组织队列中的结果;在这种情况下,z-order排序可能非常有价值。你可以在图4-8中看到这种方法的实际应用。
落入特定象限的数据将位于相同的数据文件中,这样在对不同年龄/身高组进行分析时,可以大大减少需要扫描的文件。如果你在搜索身高为6英尺且年龄为60岁的人,可以立即排除其他三个象限中的数据文件。
这之所以有效,是因为数据文件将分为四类:
- A:包含年龄1-50岁和身高1-5英尺的记录的文件
- B:包含年龄51-100岁和身高1-5英尺的记录的文件
- C:包含年龄1-50岁和身高5-10英尺的记录的文件
- D:包含年龄51-100岁和身高5-10英尺的记录的文件
如果引擎知道你在搜索一个60岁且身高6英尺的人,它在使用Apache Iceberg元数据来规划查询时,将排除所有属于A、B和C类别的数据文件,这些文件将不会被扫描。请记住,即使你只按年龄进行搜索,通过聚类至少可以排除四个象限中的两个,从而获益。
实现这一目标需要运行一个压缩作业:
ini
CALL catalog.system.rewrite_data_files(
table => 'people',
strategy => 'sort',
sort_order => 'zorder(age, height)'
);
使用排序和z-order压缩策略不仅可以减少数据文件的数量,还可以确保这些文件中的数据顺序更加有利于高效的查询规划。
尽管排序非常有效,但它也带来了一些挑战。首先,当新数据被摄入时,它变得未排序,直到下一个压缩作业完成之前,这些数据在多个文件中仍然是分散的。这是因为新数据被添加到一个新文件中,可能在该文件内是排序的,但在所有先前记录的上下文中并未排序。其次,文件中可能仍包含排序字段的多个值的数据,这对于只需要特定值数据的查询来说是低效的。例如,在前面的例子中,文件包含了狮子队和包装工队球员的数据,当你只对狮子队球员感兴趣时,扫描包装工队记录是低效的。
为了解决这个问题,我们引入了分区。
分区
如果你知道某个特定字段在数据访问中至关重要,你可能需要超越排序,进入分区。当一个表被分区时,不只是根据某个字段对顺序进行排序,而是将具有目标字段不同值的记录写入各自独立的数据文件中。
例如,在政治领域,你可能经常根据选民的党派隶属关系查询选民数据,这使得这是一个很好的分区字段。这意味着所有"蓝色"党派的选民将与"红色"、"黄色"和"绿色"党派的选民分别列在不同的文件中。如果你查询"黄色"党派的选民,你扫描的数据文件中不会包含其他任何党派的选民。图4-9展示了这一点。
传统上,基于特定字段的派生值对表进行分区需要创建一个额外的字段,这个字段需要单独维护,并且用户在查询时需要了解该字段。例如:
- 按天、月或年对时间戳列进行分区需要创建一个额外的列,该列基于时间戳独立表示年、月或日。
- 按文本值的首字母进行分区需要创建一个仅包含该字母的额外列。
- 按桶(基于哈希函数将记录平均分配到一定数量的分区中)进行分区需要创建一个额外的列,该列表明记录属于哪个桶。
然后,你需要在创建表时基于这些派生字段设置分区,文件将根据其分区组织到子目录中:
sql
-- Spark SQL
CREATE TABLE MyHiveTable (...) PARTITIONED BY month;
每次插入记录时,你必须手动转换值:
sql
INSERT INTO MyTable (SELECT MONTH(time) AS month, ... FROM data_source);
在查询表时,引擎不会意识到原始字段和派生字段之间的关系。这意味着以下查询会从分区中受益:
sql
SELECT * FROM MYTABLE WHERE time BETWEEN '2022-07-01 00:00:00' AND '2022-07-31 00:00:00' AND month = 7;
然而,用户通常并不了解这个替代列(他们也不应该需要了解)。这意味着大多数时候,用户会发出类似于以下的查询,这会导致全表扫描,使查询完成时间更长,并消耗更多资源:
sql
SELECT * FROM MYTABLE WHERE time BETWEEN '2022-07-01 00:00:00' AND '2022-07-31 00:00:00';
对于业务用户或数据分析师来说,上述查询更直观,因为他们可能不太了解表的内部工程,结果是很多时候会意外地进行全表扫描。这就是Iceberg的隐藏分区功能发挥作用的地方。
隐藏分区
Apache Iceberg在处理分区时采取了截然不同的方式,解决了许多在优化表分区时遇到的痛点。其结果之一是称为隐藏分区的特性。
这种方法的起点是Apache Iceberg如何跟踪分区。Iceberg并不是依赖文件的物理布局来跟踪分区,而是通过快照和清单级别来跟踪分区值的范围,这带来了许多新的灵活性:
- 不需要生成额外的列来基于转换值进行分区,你可以使用内置的转换,查询引擎和工具在根据元数据规划查询时可以应用这些转换。
- 使用这些转换时不需要额外的列,可以减少数据文件的存储量。
- 元数据使引擎能够识别原始列上的转换,你可以仅基于原始列进行过滤,并享受分区带来的好处。
这意味着,如果你创建了一个按月分区的表:
sql
CREATE TABLE catalog.MyTable (...) PARTITIONED BY months(time) USING iceberg;
以下查询将从分区中受益:
sql
SELECT * FROM MYTABLE WHERE time BETWEEN '2022-07-01 00:00:00' AND '2022-07-31 00:00:00';
如你在之前的CREATE TABLE语句中所见,你可以像在目标列上应用函数一样应用转换。在规划分区时,有几种可用的转换:
- year(仅年)
- month(月和年)
- day(日、月和年)
- hour(小时、日、月和年)
- truncate(截断)
- bucket(桶)
year、month、day和hour转换适用于时间戳列。请记住,如果你指定month,元数据中跟踪的分区值将反映时间戳的月份和年份;如果使用day,它们将反映时间戳的年份、月份和日期,因此无需使用多个转换来实现更细粒度的分区。
truncate转换基于列的截断值对表进行分区。例如,如果你想按人名的首字母对表进行分区,可以这样创建表:
sql
CREATE TABLE catalog.MyTable (...) PARTITIONED BY truncate(name, 1) USING iceberg;
bucket转换非常适合基于高基数(大量唯一值)的字段进行分区。bucket转换将使用哈希函数将记录分布在指定数量的桶中。例如,如果你想基于邮政编码对选民数据进行分区,但可能有太多的邮政编码导致分区过多且数据文件太小,你可以运行如下命令:
scss
CREATE TABLE catalog.voters (...) PARTITIONED BY bucket(24, zip) USING iceberg;
任何一个桶中会包含多个邮政编码,但至少当你查找特定邮政编码时,不会进行全表扫描,只需扫描包含该邮政编码的桶即可。因此,使用Apache Iceberg的隐藏分区,你可以更灵活地表达常见的分区模式。利用这些分区不需要终端用户做额外的思考,只需按他们自然会过滤的字段进行过滤即可。
分区演变
传统分区的另一个挑战是,由于其依赖于文件物理结构布局到子目录中,改变表的分区方式需要重写整个表。随着数据和查询模式的发展,这成为一个不可避免的问题,需要重新考虑如何分区和排序数据。
Apache Iceberg通过其元数据跟踪分区解决了这个问题,因为元数据不仅跟踪分区值,还跟踪历史分区方案,允许分区方案演变。因此,如果两个不同文件中的数据基于两种不同的分区方案编写,Iceberg元数据会使引擎知道,从而能够分别基于分区方案A和分区方案B创建计划,最后形成一个整体扫描计划。
例如,假设你有一个按会员注册年份分区的会员记录表:
scss
CREATE TABLE catalog.members (...) PARTITIONED BY years(registration_ts) USING iceberg;
然后,几年后,会员增长速度加快,值得按月分解记录。你可以调整表以修改分区方式,如下所示:
sql
ALTER TABLE catalog.members ADD PARTITION FIELD months(registration_ts);
Apache Iceberg日期相关的分区转换的一个妙处在于,如果你演变到更细粒度的分区规则,无需移除较粗粒度的分区规则。然而,如果你使用的是bucket或truncate分区,并且决定不再按特定字段对表进行分区,你可以像这样更新你的分区方案:
sql
ALTER TABLE catalog.members DROP PARTITION FIELD bucket(24, id);
当分区方案更新时,它仅适用于以后写入表的新数据,因此无需重写现有数据。同时,请记住,任何由rewriteDataFiles过程重写的数据将使用新分区方案重写,因此如果你想保持旧数据在旧方案中,确保在压缩作业中使用适当的过滤器以避免重写它。
其他分区考虑因素
假设你使用迁移过程(在第13章中讨论)迁移了一个Hive表。目前,它可能基于派生列(例如,同一表中的时间戳列的月份列)进行分区,但你希望向Apache Iceberg表达应使用Iceberg转换。为此,可以使用REPLACE PARTITION命令:
sql
ALTER TABLE catalog.members REPLACE PARTITION FIELD registration_day WITH days(registration_ts) AS day_of_registration;
这不会更改任何数据文件,但会允许元数据使用Iceberg转换跟踪分区值。
你可以通过多种方式优化表。例如,使用分区将具有唯一值的数据写入唯一文件,对这些文件中的数据进行排序,然后确保将这些文件压缩成更少的大文件,这样可以保持表的性能优异和流畅。虽然优化并不总是针对一般用途,但也有特定用例,例如行级更新和删除,你可以使用写时复制(copy-on-write)和读时合并(merge-on-read)进行优化。
写时复制与读时合并
在处理工作负载速度时的另一个考虑因素是如何处理行级更新。当你添加新数据时,它只是被添加到一个新的数据文件中,但当你想要更新预先存在的行以更新或删除它们时,你需要注意一些事项:
在数据湖中,因此也在Apache Iceberg中,数据文件是不可变的,意味着它们无法更改。这提供了许多好处,例如能够实现快照隔离(因为旧快照引用的文件将具有一致的数据)。
如果你要更新10行,不能保证它们在同一个文件中,因此你可能需要重写10个文件和其中的每一行数据,以便为新快照更新10行。
处理行级更新有三种方法,本节中有详细介绍,并在表4-2中进行了总结。
表4-2. Apache Iceberg中的行级更新模式
更新方式 | 读取速度 | 写入速度 | 最佳实践 |
---|---|---|---|
写时复制 | 最快的读取速度 | 最慢的更新/删除速度 | |
读时合并(位置删除) | 快速读取 | 快速更新/删除 | 使用常规压缩以最小化读取成本。 |
读时合并(相等性删除) | 较慢的读取 | 最快的更新/删除速度 | 使用更频繁的压缩以最小化读取成本。 |
写时复制
默认的方法被称为写时复制(Copy-on-Write,COW)。在这种方法中,即使是一个数据文件中的单个行被更新或删除,该数据文件也会被重写,新文件会代替它出现在新的快照中。你可以在图4-10中看到这一示例。
这种方法在你优化读取时是理想的,因为读取查询可以直接读取数据,而无需协调任何已删除或已更新的文件。然而,如果你的工作负载包括非常频繁的行级更新,为了这些更新重写整个数据文件可能会使你的更新速度超出了SLA允许的范围。这种方法的优点包括更快的读取,而缺点则涉及较慢的行级更新和删除。
读时合并
与写时复制相反的选择是读时合并(Merge-on-Read,MOR),在这种方法中,不是重写整个数据文件,而是在现有文件中捕获需要更新的记录,并在删除文件中跟踪应该被忽略的记录。 如果你正在删除一条记录:
- 该记录将列在一个删除文件中。
- 当读取器读取表时,它将会将数据文件与删除文件进行对比。 如果你正在更新一条记录:
- 要更新的记录被跟踪在一个删除文件中。
- 创建一个只包含更新记录的新数据文件。
- 当读取器读取表时,由于删除文件,它将忽略旧版本的记录,并使用新版本的记录在新数据文件中。
这在图4-11中有所描述。
这样可以避免重写未更改的记录到新文件,只是因为它们存在于包含要更新记录的数据文件中,从而加快了写入事务。但是,这也以较慢的读取为代价,因为查询将不得不扫描删除文件以了解在正确的数据文件中应该忽略哪些记录。
为了最小化读取成本,你会希望定期运行压缩作业,并确保这些压缩作业有效运行时,你会希望利用之前学到的一些属性:
- 使用过滤器/where子句,仅在最后一个时间段(小时,天)摄入的文件上运行压缩。
- 使用部分进度模式,在文件组被重写时进行提交,以便读取器可以尽快看到边际改进,而不是等到以后。
通过使用这些技术,你可以加速重度更新工作负载的写入端,同时将读取性能的成本降至最低。这种方法的优点包括更快的行级更新,但由于需要协调删除文件,这会导致读取速度变慢。
在进行MOR写入时,删除文件使你能够跟踪哪些记录需要在现有数据文件中忽略以进行未来的读取。我们将使用类比来帮助你理解不同类型删除文件之间的高级概念。(记住,通常由引擎为特定用例决定写入哪种类型的删除文件,而不是通常由表设置决定。)
当你有大量数据并且想要排除特定行时,你有几个选择:
- 你可以根据数据集中的位置来查找行数据,有点像根据座位号在电影院中找到你的朋友。
- 你可以根据行数据的内容来查找,就像在人群中挑选出穿着鲜红帽子的朋友一样。
如果你使用第一种选择,你会使用所谓的位置删除文件。但是如果你使用第二种选择,你将需要相等性删除文件。每种方法都有其优缺点。这意味着根据情况,你可能会选择其中一种。这完全取决于什么对你最有效!
让我们来探讨这两种删除文件类型。位置删除跟踪应在哪些文件中被忽略的行。以下表格是位置删除文件中数据的布局示例:
文件路径 | 位置 |
---|---|
001.parquet | 0 |
001.parquet | 5 |
006.parquet | 5 |
读取指定文件时,位置删除文件将跳过指定位置的行。这在读取时需要的成本要小得多,因为它有一个相当具体的点,在这个点上必须跳过一行。然而,这会在写入时产生成本,因为删除文件的编写者需要知道已删除记录的位置,这需要它读取包含已删除记录的文件以识别这些位置。
相等性删除则是指定值,如果记录匹配,则应该忽略。以下表格显示了相等性删除文件中数据的布局方式: 要删除的行(相等性删除)
Team | State |
---|---|
Yellow | NY |
Green | MA |
配置COW和MOR
表格是否配置为通过COW或MOR处理行级更新取决于以下因素:
- 表属性
- 用于写入Apache Iceberg的引擎是否支持MOR写入
以下表属性确定特定事务是通过COW还是MOR处理的:
- write.delete.mode:用于删除事务的方法
- write.update.mode:用于更新事务的方法
- write.merge.mode:用于合并事务的方法
请记住,对于所有Apache Iceberg表属性,虽然许多属性是规范的一部分,但仍然取决于特定的计算引擎是否遵守规范。你可能会遇到不同的行为,因此要了解哪些表属性受到你用于特定作业的引擎的支持。查询引擎开发人员会有意遵守所有Apache Iceberg表属性,但这需要根据特定引擎的架构进行实现。随着时间的推移,引擎应该会遵守所有这些属性,以便在所有引擎上获得相同的行为。
由于Apache Spark对Apache Iceberg的支持是从Apache Iceberg项目内部处理的,因此可以在Spark中创建表时设置所有这些属性,如下所示:
ini
CREATE TABLE catalog.people (
id int,
first_name string,
last_name string
) TBLPROPERTIES (
'write.delete.mode'='copy-on-write',
'write.update.mode'='merge-on-read',
'write.merge.mode'='merge-on-read'
) USING iceberg;
此属性也可以在创建表后使用ALTER TABLE语句进行设置:
ini
ALTER TABLE catalog.people SET TBLPROPERTIES (
'write.delete.mode'='merge-on-read',
'write.update.mode'='copy-on-write',
'write.merge.mode'='copy-on-write'
);
就是这么简单。但是请记住,在使用非Apache Spark引擎时:
- 表属性可能会受到支持,也可能不会。这取决于引擎是否实现了支持。
- 当使用MOR时,请确保用于查询数据的引擎能够读取删除文件。
其他考虑因素
除了你的数据文件及其组织方式,还有许多提升性能的手段。我们将在接下来的章节中讨论其中的许多内容。
指标收集
正如第2章所讨论的,每组数据文件的清单都在跟踪表中每个字段的指标,以帮助进行最小/最大过滤和其他优化。跟踪的列级指标类型包括:
- 值的计数、空值计数和唯一值计数
- 上下限值
如果你有非常宽的表(例如,包含很多字段的表;例如,100+个字段),跟踪的指标数量可能会开始成为读取元数据的负担。幸运的是,使用Apache Iceberg的表属性,你可以微调哪些列跟踪其指标,哪些列不跟踪。这样,你可以跟踪经常在查询过滤器中使用的列的指标,而不捕获不常用列的指标数据,从而避免它们的指标数据膨胀元数据。
你可以使用表属性为需要的列定制指标收集级别(你不需要为所有列指定),例如:
ini
ALTER TABLE catalog.db.students SET TBLPROPERTIES (
'write.metadata.metrics.column.col1'='none',
'write.metadata.metrics.column.col2'='full',
'write.metadata.metrics.column.col3'='counts',
'write.metadata.metrics.column.col4'='truncate(16)',
);
正如你所见,你可以为每个单独的列设置指标收集方式,有几种可能的值:
- none:不收集任何指标。
- counts:仅收集计数(值、唯一值、空值)。
- truncate(XX) :计数并将值截断为一定字符数,并基于此确定上/下限。例如,字符串列可能会被截断为16个字符,并基于截断后的字符串值确定其元数据值范围。
- full:基于完整值确定计数和上/下限。
你不需要为每列显式设置,因为默认情况下,Iceberg将此设置为truncate(16)。
重写清单
有时问题不在于数据文件,因为它们大小适中且排序良好,而是在多个快照中写入,因此单个清单可能列出更多的数据文件。虽然清单较轻量,但更多的清单仍意味着更多的文件操作。有一个单独的rewriteManifests
过程,仅用于重写清单文件,以便你拥有更少的清单文件,而这些清单文件列出大量的数据文件:
objectivec
CALL catalog.system.rewrite_manifests('MyTable')
如果在运行此操作时遇到任何内存问题,可以通过传递第二个参数false
来关闭Spark缓存。如果你正在重写大量清单并且它们被Spark缓存,可能会导致单个执行器节点出现问题:
objectivec
CALL catalog.system.rewrite_manifests('MyTable', false)
何时运行此操作取决于数据文件大小是否最佳,但清单文件数量是否过多。例如,如果你在一个分区中有5GB的数据分成10个数据文件,但这些文件被列在五个清单文件中,则无需重写数据文件,但可以将列出的10个文件合并到一个清单中。
优化存储
当你对表进行更新或运行压缩作业时,会创建新文件,但旧文件不会被删除,因为这些文件与表的历史快照相关联。为了防止存储大量不需要的数据,你应该定期过期快照。请记住,无法时间旅行到已过期的快照。在过期期间,任何与仍然有效的快照无关的数据文件将被删除。
你可以过期在特定时间戳之前创建的快照:
sql
CALL catalog.system.expire_snapshots('MyTable', TIMESTAMP '2023-02-01 00:00:00.000', 100)
第二个参数是要保留的最小快照数量(默认情况下,它将保留最近五天的快照),因此它只会过期在时间戳之前的快照。但如果快照在最近的100个快照之内,它将不会过期。
你也可以过期特定的快照ID:
sql
CALL catalog.system.expire_snapshots(table => 'MyTable', snapshot_ids => ARRAY(53))
在此示例中,ID为53的快照将过期。我们可以通过打开metadata.json文件并检查其内容或使用第10章中详细介绍的元数据表查找快照ID。你可能有一个快照意外暴露了敏感数据,并希望过期该单个快照以清理该事务中创建的数据文件。这将为你提供灵活性。过期是一个事务,因此将创建一个带有更新的有效快照列表的新metadata.json文件。
过期快照过程可以传递六个参数:
- table:要运行操作的表
- older_than:过期在此时间戳之前的所有快照
- retain_last:要保留的最小快照数量
- snapshot_ids:要过期的特定快照ID
- max_concurrent_deletes:用于删除文件的线程数
- stream_results:如果为true,通过弹性分布式数据集(RDD)分区将删除的文件发送到Spark驱动程序,这对于在删除大文件时避免内存不足(OOM)问题非常有用
优化存储时的另一个考虑因素是孤立文件。这些文件和工件会累积在表的数据目录中,但由于它们是由失败的作业写入的,因此未在元数据树中跟踪。过期快照不会清理这些文件,因此应偶尔运行一个特殊过程来处理此问题。此过程将查看表的默认位置中的每个文件,并评估其是否与活动快照相关。这可能是一个密集的过程(这就是为什么你应该偶尔进行)。要删除孤立文件,请运行以下命令:
ini
CALL catalog.system.remove_orphan_files(table => 'MyTable')
你可以为removeOrphanFiles
过程传递以下参数:
- table:要操作的表
- older_than:仅删除在此时间戳之前创建的文件
- location:查找孤立文件的位置;默认为表的默认位置
- dry_run:布尔值,如果为true,不会删除文件,但会返回将被删除的文件列表
- max_concurrent_deletes:用于删除文件的最大线程数
对于大多数表来说,数据将位于其默认位置,但有时你可能通过addFiles
过程(在第13章中介绍)添加外部文件,并可能希望清理这些目录中的工件。这就是location
参数的作用。
写入分布模式
写入分布模式要求理解大规模并行处理 (MPP) 系统如何处理文件写入。这些系统将工作分配到多个节点上,每个节点执行一个工作或任务。写入分布指的是将要写入的记录如何分配到这些任务中。如果没有设置特定的写入分布模式,数据将被任意分配。前 X 条记录将被分配到第一个任务,接下来的 X 条记录分配到下一个任务,依此类推。
每个任务都是独立处理的,因此每个任务会为其包含至少一条记录的每个分区创建至少一个文件。因此,如果您有 10 条属于分区 A 的记录分布在 10 个任务中,您将在该分区中得到 10 个每个包含一条记录的文件,这并不理想。
更理想的情况是,将属于同一分区的所有记录分配到同一任务,这样它们可以写入同一个文件。这就是写入分布的作用,即如何将数据分配到各个任务中。这里有三种选项:
none 没有特殊分布。这是写入时最快的方式,适合预排序数据。
hash 数据按分区键进行哈希分布。
range 数据按分区键或排序顺序进行范围分布。
在哈希分布中,每条记录的值会通过哈希函数进行处理,并根据结果进行分组。多个值可能会根据哈希函数落入同一分组。例如,如果数据中有 1, 2, 3, 4, 5 和 6 这些值,哈希分布可能会将 1 和 4 分配到任务 A,2 和 5 分配到任务 B,3 和 6 分配到任务 C。您仍然会为所有分区写入最少数量的文件,但涉及的顺序写入会更少。
在范围分布中,数据会进行排序和分配,因此可能会将值 1 和 2 分配到任务 A,3 和 4 分配到任务 B,5 和 6 分配到任务 C。这种排序会根据分区值或排序顺序 (SortOrder) 进行。如果指定了排序顺序,数据不仅会根据分区值,还会根据排序顺序字段的值进行分组。这对于可以从特定字段聚类中受益的数据是理想的。然而,按顺序分布数据的排序开销比将数据通过哈希函数进行分布更大。
还有一个写入分布属性可以指定删除、更新和合并的行为:
ini
ALTER TABLE catalog.MyTable SET TBLPROPERTIES (
'write.distribution-mode'='hash',
'write.delete.distribution-mode'='none',
'write.update.distribution-mode'='range',
'write.merge.distribution-mode'='hash',
);
如果您经常更新很多行,但很少删除行,您可能需要使用不同的分布模式,因为根据查询模式的不同,不同的分布模式可能更有优势。
对象存储考虑因素
对象存储是一种独特的数据存储方式。它不是像传统文件系统那样将文件保持在整齐的文件夹结构中,而是将所有东西都放入所谓的桶 (bucket) 中。每个文件都成为一个对象,并附带大量元数据。这些元数据告诉我们关于文件的各种信息;在使用对象存储时,元数据能够提高并发性和弹性,因为底层文件可以复制以供区域访问或并发使用,而所有用户只是将其视为一个简单的"对象"。
当您想从对象存储中获取文件时,不是通过文件夹点击,而是使用 API。就像使用 GET 或 PUT 请求与网站交互一样,在这里也是通过这种方式来访问数据。例如,您可以使用 GET 请求来请求一个文件,系统会检查元数据以找到文件,然后您就可以获取数据了。
这种 API 优先的方法有助于系统管理数据,例如在不同地方制作副本或处理大量请求时。对象存储(大多数云供应商都提供)对于数据湖和数据湖仓是理想的,但它有一个潜在的瓶颈。
由于架构和对象存储处理并行性的方式,通常对同一"前缀"下的文件请求数量有限制。因此,如果您想访问 /prefix1/fileA.txt 和 /prefix1/fileB.txt,即使它们是不同的文件,访问两者也算作前缀1的限制。这在拥有大量文件的分区中会成为问题,因为查询可能导致对这些分区的许多请求,从而遇到限流问题,减慢查询速度。
运行压缩以限制分区中的文件数量可以有所帮助,但 Apache Iceberg 特别适合这种情况,因为它不依赖于文件的物理布局方式,这意味着它可以在多个前缀下写入同一分区的文件。
您可以在表属性中启用此功能,如下所示:
sql
ALTER TABLE catalog.MyTable SET TBLPROPERTIES (
'write.object-storage.enabled'= true
);
这将把同一分区的文件分布在多个前缀中,包括一个哈希以避免潜在的限流问题。
因此,不再是这样:
bash
s3://bucket/database/table/field=value1/datafile1.parquet
s3://bucket/database/table/field=value1/datafile2.parquet
s3://bucket/database/table/field=value1/datafile3.parquet
而是这样:
bash
s3://bucket/4809098/database/table/field=value1/datafile1.parquet
s3://bucket/5840329/database/table/field=value1/datafile2.parquet
s3://bucket/2342344/database/table/field=value1/datafile3.parquet
通过文件路径中的哈希,同一分区的每个文件现在被视为在不同的前缀下,从而避免限流问题。
数据文件布隆过滤器
布隆过滤器是一种确定值是否可能存在于数据集中的方法。想象一排比特(即二进制代码中的0和1),长度由您决定。现在,当您向数据集中添加数据时,会将每个值通过一个称为哈希函数的过程。这个函数会输出比特排列中的一个位置,您将该位置的比特从0翻转为1。这个翻转的比特就像一面旗帜,表示"嘿,一个哈希到这个位置的值可能在数据集中。"
例如,假设我们通过一个具有10个比特的布隆过滤器处理了1000条记录。当完成时,我们的布隆过滤器可能如下所示:
csharp
[0,1,1,0,0,1,1,1,1,0]
现在假设我们想找到一个特定的值,我们称之为 X。我们将 X 通过相同的哈希函数,它指向比特排列中的第3个位置。根据我们的布隆过滤器,第三个位置有一个1。这意味着我们的数据集中可能存在一个哈希到这个位置的值。因此,我们去检查数据集,看看 X 是否真的存在。
现在我们找一个不同的值,我们称之为 Y。当我们将 Y 通过哈希函数,它指向比特排列中的第四个位置。但是我们的布隆过滤器在那个位置有一个0,这意味着没有值哈希到这个位置。所以我们可以自信地说,Y 肯定不在我们的数据集中,这样我们可以节省时间,不用深入检查数据。
布隆过滤器很方便,因为它们可以帮助我们避免不必要的数据扫描。如果我们想让它们更精确,可以添加更多的哈希函数和比特。但请记住,添加的越多,布隆过滤器就越大,所需空间也就越多。和生活中的大多数事情一样,这是一种平衡。一切都是权衡。
您可以通过表属性启用特定列的布隆过滤器写入(这也可以用于 ORC 文件):
ini
ALTER TABLE catalog.MyTable SET TBLPROPERTIES (
'write.parquet.bloom-filter-enabled.column.col1'= true,
'write.parquet.bloom-filter-max-bytes'= 1048576
);
然后,查询引擎可以利用这些布隆过滤器,通过跳过布隆过滤器明确指示不包含所需数据的数据文件,从而使读取数据文件更快。