到目前为止,您已经探索了使用 Delta Lake 的各种方法。您已经看到了许多使 Delta Lake 成为更好、更可靠的数据存储格式选择的特性。然而,要对 Delta Lake 表进行性能调优,您需要对表的维护基本机制有深入理解(这在第 5 章中已有介绍),并且需要一些关于操作或实现第 8 章中介绍的内部和高级功能的知识和实践。现在,我们将重点关注性能方面,详细探讨调节这些功能的一些影响。如果您最近没有使用或复习过第 5 章中的内容,我们建议您重新审视一下这些主题。
通常,您会希望最大限度地提高数据创建、消费和维护任务的可靠性和效率,同时避免为数据处理管道添加不必要的成本。通过花时间正确优化工作负载,您可以在这些任务的开销成本和各种性能考虑之间找到平衡,以实现您的目标。在这里,您应该能够理解如何调优一些您已经看到的功能,帮助您实现目标。
首先,有一些背景工作可以为您提供关于目标性质的清晰认识。之后,我们将探讨 Delta Lake 的一些功能及其如何影响这些目标。虽然 Delta Lake 通常可以在有限的修改下适用,但当您考虑现代数据栈的需求时,您会意识到总是可以做得更好。最终,进行性能调优涉及权衡各种因素和考虑取舍,以便在您需要的地方获得优势。因此,最好在考虑修改某些参数时,确保考虑到其他设置的影响。
性能目标
您需要考虑的一个重要因素是,您是希望优化数据生产者的性能,还是优化数据消费者的性能。如第 9 章所讨论的,金字塔架构(Medallion Architecture)是一个数据架构示例,它允许您通过数据策划层在需要的地方优化读写。这种过程分离帮助您在数据创建点和消费点通过关注每个阶段的目标来简化流程。接下来,让我们首先考虑一些您可能希望将调优工作定向到的不同目标。
最大化读取性能
将流程优化为适应数据消费者,简单来说,就是提高数据集的读取性能。数据科学家可能依赖于对数据集子集的重复读取来构建准确的机器学习模型,或者商业分析师希望从数据中提取特定信息,以便传达给业务利益相关者。在设计和布局流程时,应该考虑数据消费者的需求。虽然本节不会深入讨论需求收集或实体关系(ER)图,但适当的数据建模是构建成功数据平台的高价值前提,无论数据策划和治理是集中进行,还是通过数据网格架构等更为分散的方式进行。您在这里主要关心的数据消费者需求是这些数据消费者在大多数情况下如何访问数据。广义上讲,查询可以分为三种模式:点查询、范围查询或聚合查询。
点查询
点查询是数据消费者或用户提交的查询,目的是从数据集中返回单条记录。例如,用户可能会访问数据库,按案例查找单个记录。这些用户不太可能使用涉及 SQL 基础的连接逻辑或高级过滤条件的复杂查询模式。另一个例子是一个强大的 Web 服务器进程,根据需要动态地按案例获取结果。此类查询通常会根据感知的性能指标进行更高层次的评估。在这两种场景中,查询的性能都会对用户产生影响,因此您希望避免记录查找的任何延迟,同时避免产生高成本。这意味着在某些场景下——例如后者——可能需要高性能的专用事务系统来满足延迟要求;然而,通常情况下,通过这里介绍的调优方法,您可能能够在不需要二级系统的情况下充足地满足目标。
您需要考虑的一个方面是,文件大小、键或索引、分区策略等因素如何影响点查询性能。通常的经验法则是,尽量使用较小的文件大小,并尝试使用索引等特性来降低查找记录时的延迟,即使这个“稻草堆”是一个完整的领域。您还将看到统计信息和文件分布如何影响查找性能。
范围查询
范围查询是检索一组记录,而不是像点查询那样检索单个记录结果(点查询可以看作是具有狭窄边界的特殊情况)。范围查询不是查找精确匹配的条件,而是查找在边界内的数据。以下是一些常见的表示此类情况的词组:
- 介于
- 至少
- 在…之前
- 使得
当然,还有许多其他可能的词组,但总的来说,许多记录可能满足这种条件(尽管也有可能只得到一条记录)。即使使用精确匹配条件描述广泛类别时,您仍然会遇到范围查询,例如从宠物种类和品种的列表中选择猫作为动物种类——您只有一个物种,但可能有许多不同的品种。换句话说,范围查询的结果通常大于一个。通常情况下,您无法知道具体有多少条记录,除非添加某些排序元素并进一步限制范围。
聚合查询
表面上看,聚合查询与范围查询相似,不同之处在于,聚合查询不是选择一组特定的记录,而是使用额外的逻辑操作对每组记录执行某些操作。借用宠物的例子,您可能想获取每个物种的品种数量,或者其他类型的汇总信息。在这种情况下,通常会看到根据类别或通过将精细的时间戳分解为更大的时间段(例如按年)来进行数据分区。由于聚合查询将执行与范围查询类似的扫描和过滤操作,它们也将从相同类型的优化中受益。
您会发现,创建文件时的偏好(例如文件大小和组织)取决于如何选择此类使用的边界或定义组。同样,索引和分区通常应该与查询模式对齐,以产生更高效的读取。
点查询、范围查询和聚合查询之间的相似性可以总结如下:为了提供最佳性能,您需要将整体数据策略与数据的消费方式对齐。这意味着,在优化表时,您不仅要考虑数据布局策略,还要考虑消费模式。为此,您还需要考虑如何维护数据,并考虑运行 OPTIMIZE 等维护流程或使用 ANALYZE TABLE 收集统计信息如何影响性能,之后再根据需要安排停机时间。
最大化写入性能
优化数据生产者的性能不仅仅是减少延迟,即从接收(摄取)记录到将其写入(提交)存储的时间间隔,之后数据便可供消费。虽然通常您会希望尽可能减少这一时间,但在平衡服务水平协议(SLA)、性能目标和成本时,您还需要考虑更多因素。您已经看到了一些如何考虑您的数据架构策略应由数据消费者驱动的方式,主要通过将优化目标与使用的查询模式对齐。然而,您还必须记住,通常您并不会有足够的控制力来精确指定如何接收数据,因此您也会受到上游数据生产者的约束——也就是说,产生数据的系统。
您可能需要将多个不同的数据源进行联合,以提供业务所需的数据资产。这些数据源可能包括共享云存储位置中不定期上传的文件、传统的关系型数据库实例、内存存储和高流量消息总线管道等。涉及的系统类型将影响您的决策,因为数据的量和接收频率将影响您的数据应用程序的性能需求。您还可能会发现,这些数据源将进一步影响您选择的整体数据策略。
权衡
正如之前所提到的,许多写入过程的约束将由生产者系统决定。如果您在考虑大文件摄取或事件级别或微批次级别的流处理,那么事务的大小和数量会有很大差异。同样,如果您在使用单节点 Python 应用程序,或使用更大的分布式框架,您也会遇到类似的差异。您还需要考虑处理所需的时间,以及处理的节奏。许多这些因素必须加以平衡,因此,金字塔架构(Medallion Architecture)再次提供了帮助,因为您可以通过在青铜层优化核心数据生产过程,在黄金层优化数据消费者,同时用银层将两者连接起来。若要回顾金字塔架构,请参见第 9 章。
避免冲突
执行写操作的频率可能会限制您何时可以运行表维护操作——例如,当您使用 Z-Ordering 时。如果您使用 Apache Spark 的结构化流处理(Structured Streaming)将微批次级事务写入按小时分区的 Delta Lake 表,则必须考虑在分区仍然活跃时,运行其他进程的影响。选择像自动压缩和优化写入等选项也会影响您是否需要运行额外的维护操作。建立索引需要计算时间,也可能与其他进程发生冲突。尽管确保避免冲突的责任在您,但现在避免冲突比过去处理每次文件访问时涉及的读写锁要容易得多。
性能考虑
到目前为止,您已经了解了一些基于如何与 Delta Lake 交互的决策标准。Delta Lake 内置了许多不同的工具,您如何使用它们通常取决于特定表的交互方式。我们的目标是探讨可以调整的不同参数,并思考如何通过设置不同的参数来优化上述情况中的任何一种。部分内容将回顾第 6 章中讨论的数据生产者/消费者权衡问题。
分区
Delta Lake 的一大优点是数据仍然可以像 Parquet 文件一样使用 Hive 风格的分区进行分区。3 然而,能够以这种方式分区表也是一个缺点(请确保查看关于液态聚类的部分,“Cluster By”)。您可以按列甚至多个列对 Delta 表进行分区。最常用的分区列是日期,但在高流量处理过程中,使用小时和分钟列进行多层次分区的表也并不罕见。对于大多数过程来说,这种做法有些过度,但从技术上讲,您在定义分区结构时并不受限。然而,过度分区的表可能会导致性能问题。
结构
最简单的理解分区的方式是,它将一组文件分割成与您的分区列相关的排序目录。假设您有一个客户会员类别列,其中每个客户记录将归类为“付费会员”或“免费会员”,如下所示。如果您按 membership_type
列进行分区,那么所有包含“付费”会员记录的文件将位于一个子目录中,而所有包含“免费”会员记录的文件将位于另一个子目录中:
from deltalake.writer import write_deltalake
import pandas as pd
df = pd.DataFrame(data=[
(1, "Customer 1", "free"),
(2, "Customer 2", "paid"),
(3, "Customer 3", "free"),
(4, "Customer 4", "paid")],
columns=["id", "name", "membership_type"])
write_deltalake(
"/tmp/delta/partitioning.example.delta",
data=df,
mode="overwrite",
partition_by=["membership_type"])
注意
本章的所有示例和其他支持代码可以在本书的 GitHub 仓库中找到。
通过强制进行分区,并同时按 membership_type
列进行分区,您可以在检查写入路径目录时看到,每个 membership_type
列中的不同值都会有一个子目录:
tree /tmp/delta/partitioning.example.delta
/tmp/delta/partitioning.example.delta
├── _delta_log
│ └── 00000000000000000000.json
├── membership_type=free
│ └── 0-9bfd1aed-43ce-4201-9ef0-1d6b1a42db8a-0.parquet
└── membership_type=paid
└── 0-9bfd1aed-43ce-4201-9ef0-1d6b1a42db8a-0.parquet
接下来的部分将帮助您判断何时(或何时不)进行表分区,以及这些决策对其他性能特性的影响。理解更大范围的分区概念很重要,因为即使您没有选择自己进行分区,您也可能会接管那些已经分区的表。
陷阱
关于 Delta Lake 中的分区结构,您需要注意一些事项(记得回顾第 5 章中的表分区规则!)。关于使用的实际文件大小的决策将受到使用该表的数据消费者类型的影响,但文件的分区方式也会产生下游影响。通常,您需要确保每个分区中的数据总量至少为 1 GB,对于总表大小小于 1 TB 的情况,则不应进行分区。如果小于此限制,尤其是在使用 Delta Lake 的云环境中,您可能会在文件和目录列出操作中产生大量不必要的开销。这意味着,如果您有一个高基数列,通常不应将其用作分区列,除非文件大小仍然合适。在需要修改分区结构的情况下,您应使用第 5 章中概述的方法来替换表,以优化布局。过度分区问题已被证明会导致许多人遇到性能问题。花时间解决这个问题比将较差的性能传递到下游要好得多。
文件大小
过度分区的一个直接后果是文件大小通常过小。推荐的文件大小大约为 1 GB,以便更轻松地处理大规模数据处理。然而,在许多情况下,使用较小的文件大小(通常在 32 MB 到 128 MB 之间)对于读取操作可能有性能优势。关于文件大小的最佳决策最终取决于数据消费者的性质。青铜层中的高容量追加表通常使用较大的文件大小表现更好,因为较大的文件大小最大化了每次操作的吞吐量,而不考虑其他因素。较小的文件大小则更有助于细粒度的读取操作,如点查询,或在执行大量合并操作时,因为会生成更多的文件重写。
最终,文件大小通常由您应用维护操作的方式决定。当您运行 OPTIMIZE
时,特别是当您与包括 Z-Ordering 选项一起运行时,您会发现它会影响结果文件的大小。然而,您有几个基本选项来尝试控制文件大小。
表格工具
你可能对小文件问题已经相当熟悉。虽然它最初主要影响的是庞大的 MapReduce 处理,但问题的本质也延伸到了近年来的大规模分布式处理系统中。你在第五章中已经看到了维护 Delta Lake 表格的必要性以及可用的一些工具。一个常见的场景是在流处理用例中,由于事务往往较小,你需要确保将这些小文件重写成更大的文件,以避免类似的小文件问题。在这里,你将看到如何利用这些工具来影响与 Delta Lake 交互时的读写性能。
OPTIMIZE 操作
OPTIMIZE 操作本身旨在减少 Delta Lake 表格中包含的文件数量(回顾第五章的探讨)。这对于流处理工作负载尤其重要,其中微批处理创建的文件和提交可能只有几 MB 或更小,这样就可能会出现许多相对较小的文件。压缩是一个用来描述将较小文件合并为更大文件的过程,通常在谈论此操作时使用。压缩的最常见性能影响就是未进行压缩。尽管这些小文件可能有一些微小的好处(例如更精细的列统计),但这些好处通常会被列出和打开大量文件的成本所压倒。
操作的工作原理是,当你运行 OPTIMIZE 时,它会启动一个列出所有活动文件及其大小的操作。然后,任何可以合并的文件将被合并为目标大小约为 1GB 的文件。这有助于减少例如多个并发进程将较小事务提交到同一个 Delta Lake 目标表中可能引发的问题。换句话说,OPTIMIZE 是一种帮助避免小文件问题的机制。
请记住,该操作有一定的开销;它必须读取多个文件,并将它们合并成最终写入的文件,因此它是一个重 I/O 操作。移除文件开销是帮助提高下游数据消费者读取时间的部分原因。如果你在下游使用一个优化过的表作为流源,如第九章所述,最终的文件并不是数据变化文件,因此会被忽略。
值得注意的是,OPTIMIZE 操作有一些文件大小设置,你可以调节这些设置以更好地优化性能。这些设置及其行为将在第五章中详细讨论。接下来,我们将深入探讨 Z-Ordering,即使你打算使用液态聚类(liquid clustering),它的基本概念也有很强的关联性。
Z-Ordering
有时候,插入文件或建模数据的方式会自然地使记录聚集。例如,假设你从客户交易记录中插入一个文件,或者每 10 分钟从视频设备聚合播放事件。然后,假设你希望一个小时后回去计算一些关键绩效指标(KPIs)。你需要读取多少个文件?你已经知道是六个,因为你正在处理的是自然的时间元素(假设你使用了事件时间或交易时间)。你可以将数据描述为具有自然的线性聚类行为。你也可以将这个描述应用于任何具有天然排序顺序的数据。在这种情况下,你也可以通过字母排序、使用唯一的通用标识符(UUID)或使用文件插入时间,并根据需要重新排序,从而人为创建数据的排序或分区。
然而,在其他情况下,你的数据可能没有适合的天然聚类,这种聚类也可能不会对数据的消费方式有帮助。通过增加一个额外的时间范围排序,可能会有所改善,但通常基于第一个排序范围的过滤将始终产生最佳结果。随着附加列的增加,这一趋势将逐渐减弱,因为它仍然是过于线性的。
有一种方法在多个应用中使用,这种方法不仅仅局限于数据应用,它依赖于使用空间填充曲线来重新映射数据。为了不深入讲解过于严格的细节(暂时),这种构造可以让我们将多维信息(如多个列的值)映射到更加线性的一些东西上,例如排序范围中的聚类 ID。具体来说,你需要的是局部性保留的空间填充曲线,如 Z-Order 曲线或 Hilbert 曲线,它们是最常用的选项之一。它们允许我们以更少线性的方式创建数据聚类,这对于数据消费者来说可以带来很大的性能提升,尤其是在精细粒度的点查询或更复杂的范围查询中。
换句话说,这种多维方法意味着你可以更容易地过滤不相关的条件。考虑一个例子,假设你有一个客户或设备 ID 列和一个额外的位置信息列,这些列之间没有任何特别的相关性,因此没有天然的线性聚类顺序。空间填充曲线将允许你对这些数据强制施加一个聚类顺序。从实际角度来看,这意味着你可以过滤掉联合聚类,而不必读取整个数据集。
对于数据生产者而言,这代表了数据生产的额外步骤,可能会减慢处理过程,因此下游是否需要这种功能应该提前确定。如果没有人受益,那么应用它的成本就不值得。话虽如此,这个过程基本上是增量的,并且可以在指定时仅对单个分区运行。
通过 OPTIMIZE 配合 ZORDER BY 进行的压缩操作不是幂等的(这是一个数据变更标志为 False 的案例),但它在运行时设计为增量的。也就是说,当没有新数据添加到分区(或者对于未分区表而言,没有新数据添加到表)时,它不会再次对该分区或表进行聚类。这种行为预期你在 Z-Ordering 中使用相同的列规范,这样做是有道理的,因为新的列规范需要重新聚类整个分区(或表)。
注意
Z-Ordering 尝试在内存中创建相似大小的聚类,这通常与磁盘上的大小直接相关,但在某些情况下,这种关联可能不成立。在这些情况下,任务倾斜可能会在压缩过程中发生。例如,如果你有一个包含 JSON 值的字符串列,且该列的大小随着时间的推移显著增加,那么在按日期进行 Z-Ordering 时,任务时长和最终的文件大小可能会在后续处理过程中出现倾斜。
除了最极端的情况外,这通常不会对下游消费者或过程产生显著影响。
你可能会注意到,如果你在表中对文件进行 Z-Ordering 和不进行 Z-Ordering 进行实验时,文件大小的分布会发生变化。虽然在默认情况下,OPTIMIZE 会创建相对均匀大小的文件,但你所实施的聚类行为意味着文件的大小可能会变得比内建的文件大小限制(或者在可用时指定的限制)更小(或更大)。这种优先考虑聚类行为而非严格的文件大小的做法,旨在通过确保数据按需共同存储,提供最佳性能。
Spark中的优化自动化
Databricks提供了两个设置——autocompaction
和 optimized writes
,它们有助于简化一些表工具的使用,并减少干扰(例如,流处理工作负载)。过去,这两个设置的联合使用通常被称为“自动优化”(auto-optimize)。现在,它们可以单独使用,因为不仅可以将它们一起使用,还可以灵活地根据需要在不同场景中单独使用,从而带来巨大优势。
自动压缩(Autocompaction)
第一个设置是 delta.autoCompact
,它已经在Databricks Runtime中使用了几年,预计将扩展到Delta Lake的其他版本。autoCompact
的理念是,它可以在已有进程运行时对表执行OPTIMIZE操作,无需额外的命令。它的最大优势之一是,不需要额外的进程,这样就避免了与流处理应用程序发生冲突。例如,当流处理工作负载正在进行时,可以避免启动第二个进程。缺点是可能对处理延迟有较小的影响,因为在文件提交后,Spark会在同一进程中执行OPTIMIZE操作,分析表中的文件并在必要时进行压缩。这对于基于消息总线的流写入尤其有用,因为这些事务通常比其他工作负载类型的小,但也会带来权衡,因为它会插入额外的任务来进行压缩,从而延长处理时间。这意味着对于要求严格的SLA场景,可能希望避免使用该功能。
启用该功能只需设置Spark配置:
delta.autoCompact.enabled true
有一些附加设置提供了更多灵活性,允许你根据需要调整压缩操作的行为。
注意:
虽然这个功能可以改进使用OPTIMIZE与Delta Lake的方式,但它不会支持在文件上应用ZORDER。即便启用了autoCompact,可能仍然需要额外的处理步骤来为下游数据消费者提供最佳性能。
你可以通过 spark.databricks.delta.autoCompact.maxFileSize
来控制autoCompact的目标输出文件大小。默认值128 MB通常在实际操作中足够,但你可能希望根据重写多个文件时的影响、是否计划定期执行表维护操作以及所需的最终文件大小来调整这个数值。
触发压缩的文件数量通过 spark.databricks.delta.autoCompact.minNumFiles
设置。默认值为50。这确保你有一个最低阈值,以避免在文件数量较少的小表上进行不必要的额外操作。对于那些文件很少但有很多追加和删除操作的小表,设置较低的数值可能会有所帮助,因为这会创建更少的文件,并且由于文件较小而减少性能影响。对于写入Delta Lake事务通常较多的大规模过程,设置较高的数值可能更有利,因为这可以避免每个写入阶段都运行OPTIMIZE步骤,这可能会在每次事务中增加额外的运营成本。
优化写入(Optimized Writes)
这个设置也是Databricks在Delta Lake上的特定实现,未来可能会在其他版本中可用。在过去,你可能经常遇到这样的场景:你使用的DataFrame分区数量远大于你想要写入的文件数量,因为每个文件的大小太小,导致额外的不必要开销。为了解决这个问题,你通常会在实际写入操作前使用 coalesce(n)
或 repartition(n)
来将结果压缩到只写入n个文件。优化写入(Optimized Writes)就是为了解决不需要手动进行这些操作的问题。
如果你在表上设置 delta.optimizeWrites
为 true
,或者在Databricks SparkSession中设置 spark.databricks.delta.optimizeWrites.enabled
为 true
,你就能获得这种不同的行为。后者设置会将前者的选项应用到所有由SparkSession创建的新表中。你可能会好奇,背后是如何实现这种自动化的。其实它的工作原理是,在写操作发生之前,会根据需要进行额外的shuffle操作,以合并内存分区,这样就可以在提交时写入更少的文件。这对于分区表非常有益,因为分区往往会使文件更加细粒化。额外的shuffle步骤可能会给写入操作带来一些延迟,因此在数据生产者优化场景下,你可能希望跳过它,但它会自动提供一些额外的压缩,类似于autoCompact,只是它发生在写操作之前,而不是之后。下面的图10-1展示了数据分布在多个执行器上会导致每个分区写入多个文件(左侧),以及如何通过添加shuffle来改进文件安排(右侧)。
清理(Vacuum)
由于像写入失败这样的操作不会提交到事务日志中,你需要确保即使是没有运行 OPTIMIZE 的追加-only 表,也要进行清理。写入失败时不时会发生,无论是由于云服务提供商故障,还是其他原因,这些未提交的记录仍然存在于你的 Delta Lake 目录中,可能需要清理。如果不及早清理,会带来其他问题。我们曾在生产环境中看到过一些非常大的 Delta 表,由于在规划时忽略了清理,导致后来变成了一个更大、更费时的工作,因为到那个时候,成千上万的文件需要删除(有一个案例花费了三整天才解决)。除了不必要的存储费用外,任何外部事务访问包含额外文件的分区时,都需要处理更多的文件。设置一个每日或每周的清理任务,甚至在处理管道中加入维护操作,会更加高效。有关清理操作的细节,第五章中已做过分享,但值得在这里强调不清理带来的影响。
Databricks 自动调优
Databricks 包括了几个场景,在这些场景中,当启用相应选项时,系统会自动调整 delta.targetFileSize
设置。一个场景是基于工作负载类型,另一个是基于表的大小。
在 Databricks Runtime (DBR) 8.2 及更高版本中,当 delta.tuneFileSizesForRewrites
设置为 true
时,运行时会检查表的最近十次操作中是否有九次是合并操作。如果是这种情况,目标文件大小将减少,以提高写入效率(部分原因涉及统计信息和文件跳过,这将在下一节中讨论)。
从 DBR 8.4 开始,表的大小也会考虑在内,用于确定该设置。对于小于大约 2.5 TB 的表,delta.targetFileSize
设置将被设置为较小的 256 MB。如果表的大小大于 10 TB,目标大小将设置为 1 GB。如果表的大小介于 2.5 TB 和 10 TB 之间,目标大小将在 256 MB 到 1 GB 之间按比例增加。请参考文档以获取更多细节,包括该范围的参考表。
表统计信息
到目前为止,讨论的重点主要集中在表中文件的布局和分布上。这与数据在这些文件中的底层排列方式有很大关系。查看数据的主要方式是基于元数据中的文件统计信息。接下来,你将了解如何获取统计信息以及它为什么对你很重要。你将看到这一过程是什么样子的,统计信息长什么样子,以及它们如何影响性能。
统计信息的帮助
关于数据的统计信息非常有用。稍后你会看到这意味着什么,以及它长什么样子,但首先,我们来思考一下为什么你可能需要 Delta Lake 中文件的统计信息。假设你有一个包含“颜色”字段的表,该字段取100个可能值中的一个,每个颜色值恰好出现在100行中。这意味着你总共有10,000行数据。如果这些颜色值在行中是随机分布的,那么找到所有“绿色”记录就需要扫描整个数据集。现在假设你通过将数据分成十个文件来为数据集增加一些结构。在这种情况下,你可能猜测每个文件中都有绿色记录。如何在不扫描所有十个文件的情况下知道这是否成立?这就是为什么需要文件统计信息的部分原因——也就是说,如果你在写入文件时或作为维护操作的一部分进行计数操作,那么你就能从表的元数据中知道特定的值是否出现在某些文件中。如果你的记录已排序,这种影响会更大,因为那样你可以大大减少需要读取的文件数量,以便找到所有的绿色记录,或者找到行号在50到150之间的记录,正如图10-2所示。虽然这个例子只是概念性的,但它有助于让你明白为什么表统计信息很重要——不过,在你看到更详细的实际示例之前,先了解一下统计信息在 Delta Lake 中是如何运作的。
文件统计信息
如果你回到之前创建的客户数据表,你可以通过深入查看 Delta 日志,简要了解在文件创建过程中统计信息是如何生成的。建议检查 Delta Lake 协议的相关部分,以查看随着时间推移添加的其他统计信息。在这里,你可以使用表的路径定义,并将表创建时的初始 JSON 记录添加到 _delta_log 目录下:
import json
basepath = "/tmp/delta/partitioning.example.delta/"
fname = basepath + "_delta_log/00000000000000000000.json"
with open(fname) as f:
for i in f.readlines():
parsed = json.loads(i)
if 'add' in parsed.keys():
stats = json.loads(parsed['add']['stats'])
print(json.dumps(stats))
运行此代码后,你将获得为每个已添加到 Delta Lake 表中的文件生成的统计信息集合:
{
"numRecords": 2,
"minValues": {"id": 2, "name": "Customer 2"},
"maxValues": {"id": 4, "name": "Customer 4"},
"nullCount": {"id": 0, "name": 0}
}
{
"numRecords": 2,
"minValues": {"id": 1, "name": "Customer 1"},
"maxValues": {"id": 3, "name": "Customer 3"},
"nullCount": {"id": 0, "name": 0}
}
在这个例子中,你会看到所有的数据值,因为表中只有四条记录,而且没有插入空值,因此这些指标返回的值为零。
注意
从分区演示表中提取的示例统计信息中可以看到,每个文件都有记录数。Apache Spark 利用这个计数来避免在运行跨分区或整个表的简单计数操作时读取实际的数据文件,而是通过汇总统计信息而非扫描数据文件,从而提供了显著的性能优势。同样,Spark 可以利用这些统计信息高效地回答类似的查询。例如:
SELECT max(id) FROM example_table
在 Databricks (DBR 8.3 及以上版本) 中,你还可以运行 ANALYZE TABLE
命令来收集额外的统计信息,例如不同值的数量、平均长度和最大长度。这些附加的统计信息可以带来进一步的性能提升,因此,如果你使用的是兼容的计算引擎,务必利用它们。
如果你还记得第5章提到的,delta.dataSkippingNumIndexedCols
是你可以使用的一个设置,默认值为 32,它决定了统计信息将收集多少列。如果你有一个不太可能对表运行 SELECT
查询的情况(例如,在从铜层到银层的流处理过程中),你可以减少这个值,以避免写操作带来的额外开销。在查询行为对宽表的影响显著大于需要 ZORDER 的情况下,你也可以增加索引的列数(通常超过几列的索引就没有太大意义)。还有一点需要注意的是,你可以使用 ALTER TABLE CHANGE COLUMN (FIRST | AFTER)
来直接将较大值的列放在索引列之后。
如果你希望确保收集在表创建后添加的列的统计信息,你可以使用 FIRST
参数。你可以减少列的数量,并将一个长文本列放到时间戳列之后,避免尝试收集大文本列的统计信息,同时确保仍然包括时间戳信息,以便更好地利用过滤。设置这些选项相对简单,但请注意,after
参数需要指定列名:
ALTER TABLE
delta.`example`
set tblproperties("delta.dataSkippingNumIndexedCols"=5);
ALTER TABLE
delta.`example`
CHANGE articleDate first;
ALTER TABLE
delta.`example`
CHANGE textCol after revisionTimestamp;
分区修剪与数据跳过
那么,优化分区和收集文件级统计信息的实际目标是什么?目的是减少需要读取的数据量。从逻辑上讲,你越能跳过读取数据的部分,查询结果的获取速度就越快。在表面上,你已经看到了如何利用统计信息来查找某一列的最大值或计数记录数,而不需要读取实际的文件。这是因为该操作的读取部分是在文件创建时完成的,通过将该结果存储在元数据中,你就获得了类似缓存结果的效果,因为无需重新读取所有数据来计算结果。因此,这样做很有效,但如果你要做的操作不止是获取记录的计数呢?
接下来最好的做法是尽可能跳过读取更多的文件以获取结果。由于这些统计信息是按文件收集的,因此你得到的是一组可以用于检查成员资格的边界。我们再次查看之前的小示例表中的统计信息:
{
"numRecords": 2,
"minValues": {"id": 2, "name": "Customer 2"},
"maxValues": {"id": 4, "name": "Customer 4"},
"nullCount": {"id": 0, "name": 0}
}
{
"numRecords": 2,
"minValues": {"id": 1, "name": "Customer 1"},
"maxValues": {"id": 3, "name": "Customer 3"},
"nullCount": {"id": 0, "name": 0}
}
如果你想获取所有属于“Customer 1”的记录,那么你可以轻松地看到,你只需要读取两个可用文件中的一个。仅在这个简单的例子中,工作量就减少了一半。这开始突显出你已经看到的一些点的影响,比如关于文件大小或分区的决策,实际上将这些更大的观点结合在一起。
知道这一行为的存在后,你应该尝试针对一个分区布局和列组织进行优化,利用这些统计信息以最大化性能,依据你的目标。如果你正在优化写入性能,但频繁需要使用合并函数将值回填到某个先前的时间点,那么你可能希望组织数据,使得能够跳过尽可能多的其他天的数据,从而避免浪费处理时间。
同样地,如果你想最大化读取性能,并且你理解终端用户在消费数据时的访问模式,那么你可以寻求一个目标布局,在读取时提供更多跳过文件的机会。值得注意的是,不要过度分区,因为这样会带来额外的处理开销。接下来,你将看到如何使用 ZORDER 来结合这些统计信息影响下游性能。
Z排序再探
文件跳过通过减少需要读取的文件数量,极大地提升了许多查询的性能。但你可能会问:添加 ZORDER BY 的聚类行为如何影响这个过程?其实这个问题相对简单。记住,Z排序通过空间填充曲线创建记录的聚类。这样做的含义是,表中的文件是根据数据的聚类进行排列的。这意味着,当文件上收集统计信息时,你将获得与数据聚类方式对齐的边界信息。因此,现在在查找与 Z 排序聚类对齐的记录时,你可以进一步减少需要读取的文件数量。
你可能还会想知道,数据中的聚类是如何最初创建的。考虑优化读取任务的目标,在一个更简单的例子中。假设你有一个包含时间戳列的数据集。如果你想创建一些大小一致、具有明确边界的文件,那么一个简单的答案是:你可以按时间戳列线性排序数据,然后将数据划分成相同大小的块。但如果你想使用多列并根据键创建真实的聚类,而不仅仅是做一个线性排序,那该怎么办呢?
使用多列的空间填充曲线来创建聚类是一个更复杂的任务,一旦你理解了这个概念,它其实并不难,但比起线性排序的情况,它也不那么简单。至少目前是这样。这实际上是其中的一个点。你需要做一些额外的工作来构建一种方法,使得可以像在线性排序中那样对数据进行范围分区。为此,你需要一个映射函数,可以将多个维度转换为单一维度,这样就可以像线性排序的情况一样进行划分。Delta Lake 中实际的实现可能有点难以理解,但可以参考以下来自 Delta Lake 仓库的代码片段:
object ZOrderClustering extends SpaceFillingCurveClustering {
override protected[skipping] def getClusteringExpression(
cols: Seq[Column], numRanges: Int): Column = {
assert(cols.size >= 1, "Cannot do Z-Order clustering by zero columns!")
val rangeIdCols = cols.map(range_partition_id(_, numRanges))
interleave_bits(rangeIdCols: _*).cast(StringType)
}
}
这个方法接收传递给 ZORDER 修改器的多个列,并通过交替列的位来创建一个新的临时列,从而提供一个线性维度,你现在可以基于这个维度进行排序,然后按范围分区。现在你知道它是如何工作的,可以考虑一个更具体的示例来展示这种方法。
用例子来引导说明
这个例子将展示布局的差异如何影响在涉及 Z 排序聚类时需要读取的文件数量。在图 10-3 中,你有一个二维数组,想要匹配数据文件。x 范围和 y 范围的编号都是 1 到 9。数据点按 x 值进行分区,你希望找到所有 x 和 y 都是 5 或 6 的点。
首先,找到满足条件 x = 5 或 x = 6 的行。然后找到满足条件 y = 5 或 y = 6 的列。它们交汇的点就是你想要的目标值。但如果某个文件满足条件,你必须读取整个文件。因此,对于你读取的文件(包含匹配条件的文件),你可以将数据分为两类:一类是完全符合条件的数据,另一类是文件中依然需要读取的额外数据。
如你所见,你必须读取所有 x = 5 或 x = 6 的文件(行)来捕捉与 y 匹配的值,这意味着我们几乎 80% 的读取操作都是不必要的。
现在,将你的数据集更新为使用空间填充的 Z 排序曲线排列。两种情况下,你都有九个数据文件,但现在数据的布局(如图 10-4 所示)使得通过分析元数据(检查每个文件的最小值/最大值),你可以跳过额外的文件,避免读取大量不必要的记录。
应用聚类技术后,你只需要读取一个文件。这也是为什么 Z 排序常常与 OPTIMIZE 操作一起使用的原因之一。数据需要根据聚类进行排序和排列。你可能会想,如果数据已经高效地组织了,是否还需要对数据进行分区。简短的答案是需要,因为你可能仍然希望在某些情况下对数据进行分区,比如没有使用液态聚类时,可能会遇到并发问题。当数据已分区时,OPTIMIZE 和 ZORDER 仅会对已经在同一分区内放置的数据进行聚类和压缩。换句话说,聚类只会在单个分区内的数据范围内创建,因此 ZORDER 的好处仍然依赖于合理的分区方案选择。
确定数据紧密度或聚类成员关系的方法依赖于交替列的位,并随后对数据集进行范围分区。
你可以按照以下步骤来完成这一过程:
- 创建包含坐标位置的整数列。
- 将这些整数映射到二进制值。
- 对二进制值进行按位交错。
- 将生成的二进制值映射回整数。
- 对新的单维列进行范围分区。
- 根据坐标和桶标识符绘制点。
结果如图 10-5 所示。尽管它们不像图 10-4 那样整齐有序,但仍然清晰地展示了即使采用自生成和直接计算的方法,你也可以在数据集上创建自己的 Z 排序。
从数学的角度来看,还有更多细节甚至一些可能的增强可以考虑,但这个算法已经内置于 Delta Lake 中,因此为了保持我们的理智,这就是我们目前的严格限制。
最近,有人提出了是否应该对每个表进行分区的问题,以便为像 Z 排序这样的进一步发展提供更少的约束。这部分是因为从一开始就确定正确的分区列非常困难,尤其是在高度静态的过程之外。需求也可能随着时间的推移发生变化,这会导致在更新表结构时增加额外的维护工作(如果需要这样做,请参见第六章的示例)。这个领域的一个发展可能会减少这些维护负担并且彻底解决这些决策问题。
Cluster By
分区的终结?这是这个理念的核心。最新且性能最好的数据跳过方法出现在 Delta Lake 3.0 中。液态聚类取代了传统的 Hive 风格分区,并在表创建时引入了 CLUSTER BY 参数。像 ZORDER 一样,CLUSTER BY 使用空间填充曲线来确定最佳数据布局,但它采用了其他曲线类型,以提高效率。图 10-6 展示了不同的分区可能会在同一表结构中合并在一起或以不同的组合方式被拆分。
它开始有所不同的是在如何使用它上。液态聚类必须在表创建时声明以启用,并且与分区不兼容,因此不能同时定义这两者。一旦设置,它会创建一个表属性 clusteringColumns
,可以用来验证该表是否启用了液态聚类。从功能上讲,它类似于 ZORDER BY
,因为它仍然有助于知道哪些列可能会在查询中提供最佳的过滤行为,所以你仍然应该确保将优化目标放在眼前。
你也无法独立地对表进行 ZORDER
,因为该操作主要发生在压缩操作期间。值得提及的一个小额外好处是,液态聚类减少了运行 OPTIMIZE
操作时所需的特定信息,因为不需要额外设置参数,这使得你甚至可以循环遍历表列表来运行 OPTIMIZE
,而无需担心每个表的聚类键是否匹配。你还可以获得行级并发性——这是无分区表的必备特性——这意味着大多数情况下,你可以停止试图安排进程之间的顺序,减少停机时间,因为即使是 OPTIMIZE
也可以在写入操作期间运行。唯一的冲突发生在两个操作尝试同时修改同一行时。
文件聚类,如图 10-6 所示,在压缩过程中以两种不同的方式应用。对于普通的 OPTIMIZE
操作,它会检查布局分布的变化,并根据需要进行调整。这种新的聚类方法使得在写入过程中更可靠地应用数据聚类,从而使其增量应用更加稳定。这意味着在压缩过程中重写文件所需的工作更少,也使得该过程更加高效。这个特性叫做“急切聚类”(eager clustering)。这意味着,对于低于阈值(默认 512 GB)的数据,追加到表中的新数据会在写入时部分聚类(即“尽力而为”部分)。在某些情况下,这些文件的大小会从较大的表中有所不同,直到更多的数据积累,OPTIMIZE
再次运行。这是因为文件大小仍然由 OPTIMIZE
命令驱动。
警告
要使用 CLUSTER BY
参数,您需要至少在启用液态聚类表功能的 Delta Lake 版本 7 中作为写入者版本进行操作。如果仅用于读取表,您需要 版本 3 的读取者。这意味着,如果环境中有其他/较旧的消费者,您在迁移到较新版本和协议时可能会破坏工作流。
解释
CLUSTER BY
使用与 ZORDER
不同的空间填充曲线,但在没有分区的情况下,它会在整个表中创建聚类。使用它非常简单,只需在创建表的语句中包含 CLUSTER BY
参数即可。必须在创建时设置,否则表将无法作为液态分区表使用——之后无法添加。你可以通过使用 ALTER TABLE
语句后期更新用于操作的列,甚至可以通过 ALTER TABLE
删除聚类中的所有列(在后一种情况下,使用 NONE
而不是提供列名——稍后会有一个示例)。这意味着你可以获得很大的灵活性,因可以根据需求变化或消费模式的演变来更改聚类键。
在创建优化后的表时,无论是面向下游消费者还是写入过程,这为你提供了一个在两者之间做出决策的领域。与其他情况类似,如果目标是获得最快的写入性能,则可以选择不包含任何聚类或仅包含你希望的少量聚类。然而,对于下游消费者,你将获得显著的优势。如第 5 章所示,虽然可以对给定表进行重分区,但这并不是最直接的操作。现在,你可以通过重新定义聚类列,更优化地适应下游消费者的需求,并且在下次压缩过程中应用这些布局到底层文件中。这意味着,随着使用模式的变化,或者即使你在原始布局中做出了可疑的假设或错误,它们也可以更容易地得到修正。以下示例展示了如何在 Databricks 环境中利用液态聚类。
注意事项
如果初次写入表的大小超过 10 TB(例如,如果你使用 CTAS
(CREATE TABLE AS SELECT)语句进行一次性转换),第一次压缩操作可能会遇到性能问题,并且需要一些时间才能完成。聚类质量也可能会受到一定影响。因此,建议对大表进行批处理操作,但即使是 100 TB 的表,也可以应用液态聚类。
希望已经显而易见,当液态聚类适配时,它提供了比 Hive 风格分区和 ZORDER
表更多的优势。你可以获得更快的写入操作,且与其他优化良好的表的读取性能相似。你可以避免分区带来的问题。你可以获得更一致的文件大小,这使得下游进程更能抵抗任务倾斜。任何列都可以成为聚类列,并且你将获得更多灵活性,按需调整这些键。最后,由于行级并发性,进程之间的冲突最小化,使得工作流更加动态和适应性强。
示例
在这个示例中,你将看到 Wikipedia 文章数据集,它位于任何 Databricks 工作区的 /databricks-datasets/
目录中。这个 Parquet 目录大约有 11 GB 数据(磁盘大小),包含大约 1,100 个 gzipped 文件。
首先,创建一个 DataFrame 以供操作,添加一个常规日期列到数据集,然后创建一个临时视图,以便后续在 SQL 中使用:
# Python
articles_path = (
"/databricks-datasets/wikipedia-datasets/" +
"data-001/en_wikipedia/articles-only-parquet")
parquetDf = (
spark
.read
.parquet(articles_path)
)
parquetDf.createOrReplaceTempView("source_view")
有了临时视图后,你可以通过添加 CLUSTER BY
参数到常规的 CTAS
语句中来创建一个表:
-- SQL
CREATE TABLE
example.wikipages
CLUSTER BY
(id)
AS (SELECT *,
date(revisionTimestamp) AS articleDate
FROM source_view
)
你仍然需要考虑正常的统计信息收集操作,因此你可能想要从此过程中过滤掉实际的文章文本,但你也创建了 articleDate
列,这可能是你希望用于聚类的列。为此,你可以采取以下步骤:减少收集统计信息的列数,仅保留前五列,将 articleDate
和 text
列移动到合适的位置,然后定义新的 CLUSTER BY
列。你可以使用 ALTER TABLE
语句完成所有这些操作:
-- SQL
ALTER TABLE example.wikipages
SET tblproperties ("delta.dataSkippingNumIndexedCols"=5);
ALTER TABLE example.wikipages CHANGE articleDate first;
ALTER TABLE example.wikipages CHANGE `text` after revisionTimestamp;
ALTER TABLE example.wikipages CLUSTER BY (articleDate);
在这一步之后,你可以运行 OPTIMIZE
命令,其余的操作会为你处理。然后,你可以使用一个简单的查询来进行测试:
-- SQL
SELECT
year(articleDate) AS PublishingYear,
count(distinct title) AS Articles
FROM
example.wikipages
WHERE
month(articleDate)=3
AND
day(articleDate)=4
GROUP BY
year(articleDate)
ORDER BY
publishingYear
总的来说,这个过程很简单,性能也相当不错——比 Z-Ordered
Delta Lake 表略快。液态分区的初次写入也花费了大致相同的时间。这些结果是可以预期的,因为该布局仍然基本上是线性的。然而,最大的价值之一是增加的灵活性。如果你在某个时刻决定恢复按 id
列聚类(如原始定义中那样),你只需要再运行一次 ALTER TABLE
语句,然后计划稍后执行一次比平常更大的 OPTIMIZE
操作。无论你最终使用液态聚类,还是依赖于熟悉的 Z-Ordering
,你仍然可以部署一个额外的索引工具,进一步提高所选表的查询性能。
布隆过滤器索引
布隆过滤器索引是一种哈希映射索引,用于判断某个值是否可能存在于文件中,或是否肯定不存在。哈希映射索引被认为是空间高效的,因为包含哈希值的索引文件(在单个行中)会与关联的数据文件一起存储,你可以指定要索引的列。关键是你需要对要索引的不同值的数量有合理的预估,因为这将决定哈希值的长度。如果这个数字设置得太小,会导致哈希冲突;如果设置得太大,会浪费空间。
布隆过滤器索引可以被 Apache Spark 中的 Parquet 或 Delta Lake 表使用,即使它们使用了液态聚类。在运行时,Spark 会检查目录的存在性,如果存在,则使用该索引。查询时不需要显式指定。
深入了解
布隆过滤器索引是在写文件时创建的,因此如果你想使用该选项,需要考虑一些影响。特别地,如果你想索引所有数据,则应在定义表之后、写入数据之前立即定义索引。此部分的技巧是,正确地定义索引要求你提前知道要索引的列的不同值数量。这可能需要一些额外的处理开销,但在示例中,你可以添加 COUNT DISTINCT
语句并将值作为该过程的一部分来完成,仅使用元数据(这也是 Delta Lake 的一个好处)。使用 CLUSTER BY
示例中的相同表,但现在在表定义语句之后(在运行 OPTIMIZE
过程之前)插入一个布隆过滤器创建过程:
# Python
from pyspark.sql.functions import countDistinct
cdf = spark.table("example.wikipages")
raw_items = cdf.agg(countDistinct(cdf.id)).collect()[0][0]
num_items = int(raw_items * 1.25)
spark.sql(f"""
create bloomfilter index
on table
example.wikipages
for columns
(id options (fpp=0.05, numItems={num_items}))
""")
在这里,之前创建的表被加载,并且你可以使用 Spark SQL 函数 countDistinct
来获取你要为其添加索引的列的项数。由于该数字决定了哈希值的整体长度,通常来说最好给它加一些填充——例如,将 raw_items
乘以 1.25,在 num_items
上增加 25% 来预留表的增长空间(根据你的预期需求调整)。然后使用 SQL 定义布隆过滤器索引。注意,创建语句的语法清晰地说明了你希望对表执行的操作,非常直观。接下来,指定要索引的列,并为 fpp
设置值(有关配置的更多细节将在下面的部分中讨论),以及你希望能够索引的不同项的数量(已计算过)。
配置
fpp
参数中的值是 false positive probability(假阳性概率)的缩写。这个数值设置了在读取过程中可接受的假阳性率。较低的值提高了索引的准确性,但会带来一些性能损失。这是因为 fpp
值决定了每个元素需要存储多少位,因此提高准确性会增加索引本身的大小。
不太常用的配置选项 maxExpectedFpp
默认值为 1.0,表示禁用该选项。设置为 [0, 1) 区间内的其他值,则会设置最大期望的假阳性概率。如果计算出的 fpp
值超过了阈值,则认为使用该过滤器的成本高于其带来的好处,因此不会将其写入磁盘。与之关联的数据文件的读取将回退到正常的 Spark 操作,因为该文件没有索引。
布隆过滤器索引可以定义在数值类型、日期时间类型、字符串和字节类型上,但不能在嵌套列上使用布隆过滤器索引。与这些列配合使用的过滤操作包括 and
、or
、in
、equals
和 equalsnullsafe
。另一个限制是,null 值不会在索引过程中被索引,因此与 null 值相关的过滤操作仍然需要进行元数据扫描或文件扫描。
总结
当你开始改进使用 Delta Lake 构建数据表和数据管道的方式时,可能会有明确的优化目标,也可能会有一些相互冲突的目标。在本章中,你了解了分区和文件大小如何影响 Delta Lake 表的统计信息。此外,你还了解了压缩和空间填充曲线如何影响这些统计信息。无论如何,你应该已经掌握了关于使用 Delta Lake 时可以利用的不同优化工具的知识。最具体来说,需要注意的是,文件统计信息和数据跳过(data skipping)可能是提升下游查询性能最有价值的工具,你可以利用许多手段来影响这些统计信息并为不同的情况进行优化。无论你的目标是什么,这些内容都应成为你在评估和设计 Delta Lake 数据处理过程时的重要参考。