《Delta Lake Up & Running》第五章:性能调优

无论是使用传统的关系型数据库管理系统(RDBMS)还是使用Delta表,当存储和检索数据时,如何组织数据的底层存储格式会显著影响执行表操作和查询所需的时间。一般而言,性能调优指的是优化系统性能的过程,在Delta表的背景下,这涉及到优化数据的存储和检索方式。在历史上,提高数据检索的方式通常是通过增加RAM或CPU以实现更快的处理速度,或通过跳过不相关的数据来减少需要读取的数据量。Delta Lake提供了多种不同的技术,可以结合使用,通过高效地减少在操作过程中需要读取的文件和数据量来加速数据检索。

在Apache Spark和Delta Lake中,导致读取速度较慢和处理效率低下的另一个问题是小文件问题,简要提到过在第1章。小文件问题是一个问题,当底层数据文件被分成许多小文件而不是更大、更高效的文件时可能会出现。它可能会由于频繁写入等多种原因而出现,但可以通过Delta Lake中的多种技术来解决,包括将小文件合并成较大文件。

通过利用良好的性能调优策略来减轻小文件问题的影响,并更好地启用Delta表上的数据跳过功能,您可以显著改善执行时间的性能,特别是在处理大表或资源密集型数据湖操作和查询时。

数据跳过

跳过不相关数据最终是大多数性能调优功能的基础,因为它旨在减少需要读取的数据量。这一功能称为数据跳过,可以通过Delta Lake中的多种不同技术来增强。

Delta Lake自动维护每个文件的最小值和最大值,最多可维护32个字段的值,并将这些值存储为元数据的一部分。Delta Lake使用这些最小值和最大值范围来跳过那些超出查询字段值范围的文件。这是通过所谓的数据跳过统计来实现数据跳过的关键方面。

您不需要配置或指定数据跳过和数据统计,因为这一功能在Delta Lake中在适用时会自动激活,但其有效性在很大程度上取决于数据的布局。为了最大化数据跳过的有效性,可以使用诸如OPTIMIZE和ZORDER BY等命令来合并、聚类和共存数据,这将在后续章节中详细讨论,以使最小值和最大值范围变窄,理想情况下不重叠。

Delta Lake为每个数据文件收集以下数据跳过统计信息:

  • 记录数量
  • 每个前32列的最小值
  • 每个前32列的最大值
  • 每个前32列的空值数量

Delta Lake在您的表模式中定义的前32列上收集这些统计信息。请注意,嵌套列中的每个字段(例如,StructType1)都算作一列。您可以通过重新排列模式中的列来配置某些列的统计信息收集,或者可以通过使用delta.dataSkippingNumIndexedCols增加要收集统计信息的列数,但添加额外的列也会增加额外的开销,可能会对写入性能产生不利影响。通常,您希望在经常用于过滤、WHERE子句、连接以及进行聚合的列上收集数据跳过统计信息。相反,避免在长字符串上收集数据跳过统计信息,因为它们对于数据跳过目的来说效率远不如其他列。

图5-1显示,默认情况下,仅在表上收集前32列的统计信息。并且在收集统计信息的目的上,嵌套列内的每个字段都被视为单独的列。

以下示例可以在"数据跳过"笔记本中找到,该笔记本应在执行第5章的"章节初始化"笔记本后运行,该笔记本在指定位置创建了一个Delta表。在"数据跳过"笔记本中的脚本使用一个shell命令来查看Delta表的事务日志中的最后一个添加文件操作。这将显示最后一个事务条目中收集的数据跳过统计信息的示例。

bash 复制代码
%sh
# define path to Delta table
delta_table_path='mnt/datalake/book/chapter05/YellowTaxisDelta/'

# find the last transaction entry and search for "add"
# the output will show you the file stats stored in the json 
# transaction entry for the last file added
grep "\"add"\" "$(ls -1rt /dbfs/$delta_table_path/_delta_log/*.json |
  tail -n1)" | sed -n 1p > /tmp/commit.json
python -m json.tool < /tmp/commit.json

这会生成以下输出(仅显示相关部分):

swift 复制代码
stats:"{\"numRecords\":12177114,\"minValues\":{\"VendorID\":1,
  \"tpep_pickup_datetime\":\"2022-01-01\"...."maxValues\":{\"VendorID\":6,
  \"tpep_pickup_datetime\":\"2022-11-01\"...."nullCount\":{\"VendorID\":0,
  \"tpep_pickup_datetime\":0

从这个输出中,我们可以看到在最后一个文件中捕获了最小值和最大值,以及空值的数量或空值计数。由于表中的列少于32个,因此对所有列都收集了统计信息。这些元数据是在操作期间添加的每个文件中收集的。

如果表中包含超过32个列,我们还可以使用表属性delta.dataSkippingNumIndexedCols来更改收集统计信息的列数:

sql 复制代码
%sql
ALTER TABLE
  table_name
SET
  TBLPROPERTIES ('delta.dataSkippingNumIndexedCols' = '<value>');

在某些列上收集最小值和最大值可能不是有效的操作,因为在长字符串或二进制等长值上收集统计信息可能会导致开销较大。我们可以通过配置表属性delta.dataSkippingNumIndexedCols来避免包含长值的列,或者通过使用ALTER TABLE ALTER COLUMN将包含长值的列移动到大于delta.dataSkippingNumIndexedCols的列中。第7章将更详细地讨论如何更新表的模式和更改排序。

分区化

为了进一步减少操作中需要读取的数据量(即数据跳过)并提高大表的性能,Delta Lake的分区化允许您将Delta表组织成更小的块,称为分区。

您可以基于表中一个或多个列的值(最常见的是日期)来创建分区,这可以加速针对表的查询以及数据操作命令,如INSERT、UPDATE、MERGE和DELETE。

当对表进行分区时,底层数据集将根据每个分区组织成不同的目录和子目录(图5-2)。

图5-2中编号的步骤显示:

  1. 没有分区的Delta表被组织成一个单独的目录。
  2. 基于单个列进行分区的Delta表为每个分区值创建一个目录。
  3. 基于多个列进行分区的Delta表为每个分区值创建一个目录,然后为分区的附加列定义创建子目录。

当您可以有选择性地查询一个分区,而不是扫描数据集中的所有文件时,Delta Lake会快速扫描适当的目录(或目录)或分区,以执行您的操作,从而实现更快的操作。Delta Lake会自动跟踪表中存在的分区集,并在添加或删除数据时更新列表,因此无需运行ALTER TABLE来考虑新的分区。

要创建一个分区表,我们可以在SQL中使用表定义中的PARTITIONED BY子句:

sql 复制代码
%sql
--create partitioned table using SQL
CREATE TABLE tripData(PickupMonth INTEGER,
                       VendorID   INTEGER,
                       TotalAmount DOUBLE)
         PARTITIONED BY(PickupMonth)


----use the PARTITION specification to INSERT into a table
INSERT INTO tripData
         PARTITION(PickupMonth= '12') (VendorId, TotalAmount)
         SELECT VendorId, TotalAmount FROM decemberTripData;


-- drop partitions
ALTER TABLE student DROP PARTITION(PickupMonth = '12');

下面的脚本,可以在笔记本"02 - 分区"中找到,演示了如何从Parquet文件中创建一个分区Delta表,同时添加一个用于分区的列:

python 复制代码
# import modules
from pyspark.sql.functions import (month, to_date)

## UPDATE destination_path WITH YOUR PATH ##
# define Delta table destination path
destination_path = '/mnt/datalake/book/chapter05/YellowTaxisPartionedDelta/'

# read the Delta table, add columns to partition on, 
# and write it using a partition
# make sure to overwrite the existing schema if the table already exists 
# since we are adding partitions
spark.table('taxidb.tripData')                         \
.withColumn('PickupMonth', month('PickupDate'))        \
.withColumn('PickupDate', to_date('PickupDate'))       \
.write                                                 \
.partitionBy('PickupMonth')                            \
.format("delta")                                       \
.option("overwriteSchema", "true")                     \
.mode("overwrite")                                     \
.save(destination_path)

# register table in Hive
spark.sql(f"""CREATE TABLE IF NOT EXISTS taxidb.tripDataPartitioned
          USING DELTA LOCATION '{destination_path}' """ )

要查看Delta表的分区,我们可以使用SHOW PARTITIONS命令:

sql 复制代码
%sql
--list all partitions for the table
SHOW PARTITIONS taxidb.tripDataPartitioned

这将生成以下输出:

diff 复制代码
+-------------+
| PickUpMonth |
+-------------+
| 1           |
| 2           |
| ...         | 
| 12          |                         
+-------------+

在这个输出中,我们可以看到Delta表是按PickUpMonth进行分区的,每个月都有一个分区。

要查看分区在底层文件系统中的组织方式,我们还可以查看Delta表所在位置创建的目录。请记住,因为我们正在查看实际的文件系统,这可能还会显示旧的或不存在的分区。由于该表是使用本章的脚本创建的,它应该只包含相关的分区:

python 复制代码
# import OS module
import os

# create an environment variable so we can use this variable in the 
# following bash script
os.environ['destination_path'] = '/dbfs' + destination_path  

# list files and directories in directory
print(os.listdir(os.getenv('destination_path')))

这将生成以下输出:

css 复制代码
['PickupMonth=1','PickupMonth=10','PickupMonth=11','PickupMonth=12','PickupMonth=2', 'PickupMonth=3','PickupMonth=4','PickupMonth=5','PickupMonth=6','PickupMonth=7', 'PickupMonth=8','PickupMonth=9','_delta_log']

在这个输出中,Delta表不仅包含了事务日志目录_delta_log,还包括每个分区值的目录,这里是1到12月。

每个分区的目录值也包含在事务日志中,作为每个添加文件操作的一部分的元数据条目。当查看事务日志中的添加文件操作并查看partitionValues时,可以在当前表中看到这个元数据条目:

perl 复制代码
%sh
# find the last transaction entry and search for "add" to find an added file
# the output will show you partitionValues
grep ""add"" "$(ls -1rt $destination_path/_delta_log/*.json | tail -n1)" |
  sed -n 1p > /tmp/commit.json | sed -n 1p > /tmp/commit.json
python -m json.tool < /tmp/commit.json

这将生成以下输出(仅显示相关部分):

json 复制代码
{
    "add": {
        "path": "PickupMonth=12/part-00000-....c000.snappy.parquet",
        "partitionValues": {
            "PickupMonth": "12"
        }

由于这些元数据,分区实质上与数据跳过是相同的。但与基于数据统计的数据跳过不同,这是本章后面将更多了解的主题,数据跳过是基于字符串的确切匹配,即分区值,这有助于筛选文件。

Delta Lake还可以通过replaceWhere轻松更新指定的分区,这是您在第3章中了解到的。假设我们有一个业务要求,即在12月份支付类型为4时,需要将其更新为5。我们可以使用以下PySpark表达式和replaceWhere来实现这一结果:

python 复制代码
# import month from SQL functions
from pyspark.sql.functions import lit
from pyspark.sql.types import LongType

# use replaceWhere to update a specified partition
spark.read                                                              \
    .format("delta")                                                    \
    .load(destination_path)                                             \
    .where("PickupMonth == '12' and PaymentType == '3' ")               \
    .withColumn("PaymentType", lit(4).cast(LongType()))                 \
    .write                                                              \
    .format("delta")                                                    \
    .option("replaceWhere", "PickupMonth = '12'")                       \
    .mode("overwrite")                                                  \
    .save(destination_path)

在上述命令中,请注意我们使用了WHERE子句来仅加载一个分区。直接读取分区不是必要的,但使用WHERE子句(Spark SQL)或.where()函数(DataFrame API)可以启用数据跳过,例如:

bash 复制代码
# read a partition from the Delta table into a DataFrame
df = spark.read.table("<delta_table_path>").where("PickupMonth = '12'"

尽管使用.where()来读取数据可以非常有效,但您还可以将.where()与性能调优命令(如压实、OPTIMIZE和ZORDER BY)结合使用,仅在指定的分区上执行这些操作。当您要将新数据写入特定分区(例如,插入当前月的数据)时,这尤其有助于。如果不使用WHERE子句或.where()函数,默认情况下将扫描整个表。

例如,我们可以对单个分区执行压实操作:

lua 复制代码
# read a partition from the Delta table and repartition it
spark.read.format("delta")          \
.load(destination_path)             \
.where("PickupMonth = '12' ")       \
.repartition(5)                     \
.write                              \
.option("dataChange", "false")      \
.format("delta")                    \
.mode("overwrite")                  \
.save(destination_path)

我们还可以使用SQL轻松地对指定的分区执行OPTIMIZE和ZORDER BY操作:

ini 复制代码
%sql
OPTIMIZE taxidb.tripData WHERE PickupMonth = 12 ZORDER BY tpep_pickup_datetime

分区警告和注意事项

分区可以非常有益,特别是对于非常大的表,但在对表进行分区时有一些需要考虑的事项:

  • 谨慎选择分区列。如果某一列的基数非常高,不要将该列用于分区。例如,按照可能具有一百万个不同时间戳的时间戳列进行分区是一个不好的分区策略。高基数列非常适合Z-ordering,但不适合分区,因为它可能导致本章开头讨论的小文件问题。这就是为什么我们在早期的示例中添加了日期列,它们可以作为适当的分区列。
  • 最常用的分区列通常是日期。
  • 如果预计分区中的数据至少为1 GB,则可以按列进行分区。具有更少但更大的分区的表通常比具有许多较小分区的表性能更好,否则会遇到小文件问题。
  • 用于分区的列始终会移至表的末尾,除非在创建表时明确在列规范(每列的名称和数据类型)中定义分区列。
  • 一旦创建了带有分区的表,即使查询模式或分区要求发生了变化,也不能更改这些分区。分区被视为固定的数据布局,不支持分区演进。
  • 没有关于分区策略的魔法食谱,只是需要考虑的准则。这取决于数据、粒度、数据摄入和更新模式等因素。

压实文件

在对Delta表执行DML操作时,通常会在各个分区中以许多小文件的形式写入新数据。由于额外的文件元数据量和需要读取的数据文件总数,查询和操作速度可能会降低。这是之前提到的小文件问题。

为了避免这个问题,您应该将大量小文件重写为大于16 MB的较少数量的大文件。Delta Lake支持通过不同的方式将小文件合并成较大文件,从而优化数据在存储中的布局。

压实(Compaction)

文件的合并称为压实(compaction)或装箱(bin-packing)。要根据自己的规格执行压实,例如指定要将Delta表压实为的文件数量,可以使用数据更改为false的DataFrame写入器。这表示操作不会更改数据,只是重新排列数据布局。

以下示例可以在"03 - 压实、优化和ZOrder"笔记本中的步骤1中找到。该笔记本中的脚本演示了如何使用DataFrame写入器与repartition,这是用于增加或减少Spark DataFrame中分区数的方法,以及选项dataChange = False来使用自定义算法将数据压实为五个文件:

lua 复制代码
# define the path and number of files to repartition
path = "/mnt/datalake/book/chapter05/YellowTaxisDelta"
numberOfFiles = 5


# read the Delta table and repartition it
spark.read                      \
 .format("delta")               \
 .load(path)                    \
 .repartition(numberOfFiles)    \
 .write                         \
 .option("dataChange", "false") \
 .format("delta")               \
 .mode("overwrite")             \
 .save(path)

优化(OPTIMIZE)

压实允许您指定如何将小文件合并为较大文件。在Delta Lake中,触发此压实并让Delta Lake确定您想要的大文件的最佳数量的更优方式是使用OPTIMIZE命令。

OPTIMIZE命令旨在从事务日志中删除不必要的文件,同时在文件大小方面生成平衡的数据文件。较小的文件会被压实为新的大文件,最大可达1 GB。

图5-3显示了OPTIMIZE如何将较小的文件合并为较大的文件。请记住,OPTIMIZE不考虑文件内数据的组织方式;它只是重新排列和合并文件。在下一节中,您将了解如何在文件内组织数据。

正如图5-3中所示:

  • Delta表由包含没有特定顺序的数据的小文件组成。在这种情况下,有四个文件,每个文件包含两行数据。
  • 您运行OPTIMIZE来减少在操作期间需要读取的文件数量。
  • Delta表中的小文件被压实为新的大文件,最大可达1 GB。在这种情况下,我们有两个文件,每个文件包含四行数据。

让我们通过一个OPTIMIZE的示例来演示。使用笔记本"03 - 压实、优化和ZOrder",我们将执行第2步(第1步在压实部分执行了),将现有表重新分区为1,000个文件,以模拟将数据持续插入表的情景。在笔记本的第3步中,运行OPTIMIZE命令。输出将提供操作的指标。

perl 复制代码
%sql
OPTIMIZE taxidb.YellowTaxis

输出(仅显示相关部分):

lua 复制代码
+----------------------------------------------------------------+
| metrics                                                        |
+----------------------------------------------------------------+
| {"numFilesAdded": 9, "numFilesRemoved": 1000                   |
| "filesAdded":{..."totalFiles": 9,                              |
| "totalSize": 2096274374...                                     |
| "filesRemoved":{..."totalFiles": 1000, "totalSize": 2317072851 |
+----------------------------------------------------------------+

在对表运行OPTIMIZE命令后,我们可以看到删除了1,000个文件,添加了9个文件。

重要的是要注意,被删除的1,000个文件并没有从底层存储中物理删除;它们只是从事务日志中逻辑删除。这些文件将在下次运行VACUUM时从底层存储中物理删除,VACUUM将在第6章中详细讨论。

使用OPTIMIZE进行优化也是幂等的,这意味着如果在相同的表或数据子集上运行两次,第二次运行不会产生任何效果。如果您再次在taxidb.YellowTaxis表上运行相同的命令,稍后在本章中将会更多了解的数据跳过统计信息将指示添加了0个文件,删除了0个文件:

perl 复制代码
%sql
OPTIMIZE taxidb.YellowTaxis

输出(仅显示相关部分):

lua 复制代码
+---------------------------------------------------------+
| metrics                                                 |
+---------------------------------------------------------+
| {"numFilesAdded": 0, "numFilesRemoved": 0 "filesAdded": |
| {..."totalFiles": 0, "totalSize": 0...                  |
+---------------------------------------------------------+

我们也可以在特定的数据子集上进行优化,而不是优化整个表。这在我们只对特定分区执行DML操作(稍后在本章中将了解更多有关分区的信息)并且只需要优化该分区时非常有用。我们可以使用WHERE子句指定可选的分区谓词。假设我们只是定期在当前月的分区中添加和更新数据;在这种情况下,当前月是第12个月。在添加12到分区谓词后,您将注意到在运行以下命令后,只删除了17个文件,并在指定的分区中添加了4个文件:

ini 复制代码
%sql
OPTIMIZE taxidb.YellowTaxis WHERE PickupMonth = 12

输出(仅显示相关部分):

lua 复制代码
+----------------------------------------------------------+
| metrics                                                  |
+----------------------------------------------------------+
| {"numFilesAdded": 4, "numFilesRemoved": 17 "filesAdded": |
| {..."totalFiles": 4, "totalSize":1020557526              |
+----------------------------------------------------------+

OPTIMIZE注意事项

在运行OPTIMIZE命令以提高查询速度之前,有一些需要考虑的事项,以确保其有效性:

  • OPTIMIZE命令对于您不断写入数据并因此包含大量小文件的表或表分区是有效的。
  • 对于包含静态数据或数据很少更新的表,OPTIMIZE命令不会产生很大效果,因为几乎没有小文件需要合并为较大文件。
  • OPTIMIZE命令可能是一项资源密集型操作,需要执行一定时间。在执行操作时,您的云提供商可能会向您的计算引擎收取费用。在资源密集型操作与表的理想查询性能之间取得平衡是很重要的。

ZORDER BY

虽然OPTIMIZE旨在合并文件,但Z-ordering允许我们通过优化数据布局更有效地读取这些文件中的数据。ZORDER BY是该命令的一个参数,指的是数据根据其值在文件中排列的方式。具体来说,这种技术将相关信息集中在同一组文件中,以实现更快的数据检索。这种集中性会自动被Delta Lake在数据跳过算法中使用,您将在本章的下一节中更多了解。

Z-order索引可以提高对指定的Z-order列进行过滤的查询性能。性能得到提升,因为它允许查询更有效地定位相关行,还允许连接更有效地定位具有匹配值的行。这种效率最终归因于在查询期间需要读取的数据量的减少。

为了演示OPTIMIZE与Z-ordering的结合,我们将通过再次运行步骤6,重新设置并清除了本章早些时候对taxidb.YellowTaxis进行的优化,将现有表重新分区为1,000个较小的文件:

lua 复制代码
# define the path and number of files to repartition
path = "/mnt/datalake/book/chapter05/YellowTaxisDelta"
numberOfFiles = 1000

# read the Delta table and repartition it
spark.read.format("delta").load(path).repartition(numberOfFiles)    \
 .write                                                             \
 .option("dataChange", "false")                                     \
 .format("delta")                                                   \
 .mode("overwrite")                                                 \
 .save(path)

为了获得初始查询的基准,请执行脚本中的基准查询:

sql 复制代码
%sql
-- baseline query
-- take note how long it takes to return results
SELECT
  COUNT(*) as count,
  SUM(total_amount) as totalAmount,
  PickupDate
FROM
  taxidb.tripData
WHERE
  PickupDate BETWEEN '2022-01-01' AND '2022-03-31'
GROUP BY
  PickupDate

这个查询将为我们提供一个基准,用来衡量当底层Delta表具有许多小文件并且数据没有按特定顺序组织时,执行所需的时间。我们可以使用OPTIMIZE命令和ZORDER BY来合并文件并有效地对这些文件中的数据进行排序。这将显著减少获取查询结果所需的时间,因为数据更容易定位。通常,当在高基数列上使用,以及在查询谓词中频繁使用的列上使用时,效果最好,这意味着应用Z-ordering的列会影响数据检索的效果:

perl 复制代码
%sql
OPTIMIZE taxidb.tripData ZORDER BY PickupDate

现在我们已经添加了Z-ordering,我们可以在输出中看到详细的zOrderStats,其中包括策略名称、输入立方体文件以及有关ZORDER BY操作的其他统计信息。

当我们运行与在执行OPTIMIZE和ZORDER BY命令之前执行的相同的基准查询时,我们应该注意到检索查询结果所需的时间显著增加。检索结果所需的时间将根据群集配置而变化,但由于优化,我们一致注意到查询结果返回所需时间减少了约70%。

在这种情况下,添加Z-ordering提高了查询引擎在读取数据时的效率,而OPTIMIZE将小文件合并为较大文件。使用大型数据集来展示这一点可能会很困难,但图5-4说明了如何使用taxidb.YellowTaxis表进行合并和排序。

图5-4中的编号步骤显示:

  1. 查询名为taxiDb.YellowTaxis的Delta表,计算 PickupDate = '2022-06-30' 条件下的记录数。
  2. Delta表由包含没有特定顺序的数据的小文件组成。
  3. 我们运行OPTIMIZE命令,并使用ZORDER BY以提高查询执行性能。
  4. 小文件被合并为较大文件,并根据Z-order列(PickupDate)对数据进行排序。
  5. 利用数据跳过,因为我们正在查找查询谓词 PickupDate = '2022-06-30'。第一个文件被跳过,因为Delta Lake知道查询谓词不包含在该文件中,因为它超出了数据跳过统计信息中最小值和最大值的范围。
  6. 从第二个文件中迅速读取数据,因为Delta Lake知道要扫描该文件,因为搜索谓词在最小值和最大值的范围内。

您可以看到,在我们对表运行任何优化之前,数据是以没有特定顺序的小文件组织的。运行基准查询时,查询引擎不得不扫描所有Delta Lake文件以查找我们的查询谓词WHERE PickupDate BETWEEN '2022-01-01' AND '2022-03-31'。一旦我们应用了OPTIMIZE与ZORDER BY,数据被合并为较大的文件,并按升序排列PickupDate列。这使得查询引擎可以根据查询谓词从第一个文件中读取数据,并忽略或跳过第二个文件以获取结果。

ZORDER BY 注意事项

您可以在命令中将多个列作为逗号分隔的列表指定为ZORDER BY。然而,随着每个额外列的添加,局部性的效果会降低:

perl 复制代码
%sql
OPTIMIZE taxidb.tripData ZORDER BY PickupDate, VendorId

类似于OPTIMIZE,您可以将Z-ordering应用于特定的数据子集,比如分区,而不是应用于整个表:

ini 复制代码
%sql
OPTIMIZE taxidb.tripData ZORDER BY PickupDate, VendorId
WHERE PickupMonth = 2022

如果您预计某个列在查询谓词中经常被使用,并且该列具有高基数(即具有大量不同的值),那么请使用ZORDER BY。

与OPTIMIZE不同,Z-ordering不是幂等的,但旨在成为增量操作。Z-ordering的时间不保证在多次运行中减少。然而,如果没有向刚刚进行Z-ordering的分区添加新数据,那么对该分区进行另一次Z-ordering将不会产生任何效果。

Liquid Clustering

在撰写本文时,液体聚类是Delta Lake中的一项新功能,目前处于预览阶段。该功能将在不久的将来正式提供。您可以通过查看Delta Lake文档网站和此功能请求来了解有关液体聚类的详细信息并保持最新状态。

尽管本章中提到的一些性能调整技术旨在优化数据布局,从而提高读写性能,但存在一些不足之处: 分区 分区存在引入小文件问题的风险,其中数据存储在许多不同的小文件中,这不可避免地导致性能下降。而一旦对表进行分区,该分区就无法更改,可能会对新的用例或新的查询模式带来挑战。虽然Delta Lake支持分区,但在分区演进方面存在挑战,因为分区被视为一种固定的数据布局。

ZORDER BY 每当在表上插入、更新或删除数据时,必须再次运行OPTIMIZE ZORDER BY以进行优化。而当再次应用ZORDER BY时,用户必须记住表达式中使用的列。这是因为ZORDER BY中使用的列不会持久保存,可能会在尝试再次应用时导致错误或挑战。由于OPTIMIZE ZORDER BY不是幂等的,因此在运行时会重新集群数据。

分区和Z-ordering中的许多不足之处可以通过Delta Lake的液体聚类功能来解决。

  • Delta表的以下情况会从液体聚类中受益
  • 经常通过高基数列进行过滤的表
  • 数据分布存在相当偏斜的表
  • 需要大量调整和维护的表
  • 具有并发写入要求的表
  • 分区模式随时间变化的表

Delta Lake的液体聚类功能旨在解决分区和ZORDER BY存在的限制,并通过更动态的数据布局重塑读写性能。最终,液体聚类有助于减少性能调整的开销,同时支持高效的查询访问。

启用液体聚类功能

要在表上启用液体聚类,可以在创建表时使用CLUSTER BY命令来指定。必须在创建表时使用CLUSTER BY命令来指定液体聚类;不能在现有表上添加聚类(例如使用ALTER TABLE),而不启用液体聚类。

为了演示如何创建一个启用了液体聚类的表,我们可以使用笔记本"04 - 液体聚类"和以下命令:

sql 复制代码
%sql
CREATE EXTERNAL TABLE taxidb.tripDataClustered CLUSTER BY (VendorId)
LOCATION '/mnt/datalake/book/chapter05/YellowTaxisLiquidClusteringDelta'
AS SELECT * FROM taxiDb.tripData LIMIT 1000;

上述命令创建了一个启用了液体聚类的外部表,按VendorId进行聚类,并使用先前创建的taxiDb.tripData表中的数据进行填充。 要触发聚类,请在新创建的表上运行OPTIMIZE命令:

ini 复制代码
%sql
OPTIMIZE taxidb.tripDataClustered;

输出(仅显示相关部分):

lua 复制代码
+---------------------------------------------------------------------------+
| metrics                                                                   |
+---------------------------------------------------------------------------+
| {"sizeOfTableInBytesBeforeLazyClustering": 43427, "isNewMetadataCreated": |
| true..."numFilesClassifiedToLeafNodes": 1,                                  |
| "sizeOfFilesClassifiedToLeafNodesInBytes": 43427,                         |
| "logicalSizeOfFilesClassifiedToLeafNodesInBytes": 43427,                  |
| "numClusteringTasksPlanned": 0,  "numCompactionTasksPlanned": 0,          |  
| "numOptimizeBatchesPlanned": 0, "numLeafNodesExpanded": 0,                |   
| "numLeafNodesClustered": 0, "numLeafNodesCompacted": 0,                   |
| "numIntermediateNodesCompacted": 0, "totalSizeOfDataToCompactInBytes": 0, |   
| "totalLogicalSizeOfDataToCompactInBytes": 0,                              |   
| "numIntermediateNodesClustered": 0, "numFilesSkippedAfterExpansion": 0,   |
| "totalSizeOfFilesSkippedAfterExpansionInBytes": 0,                        |
| "totalLogicalSizeOfFilesSkippedAfterExpansionInBytes": 0,                 |
| "totalSizeOfDataToRewriteInBytes": 0,                                     | 
| "totalLogicalSizeOfDataToRewriteInBytes": 0...                              |
+---------------------------------------------------------------------------+

在本章的早些时候,您看到了在表上运行OPTIMIZE命令后显示的指标。在启用了液体聚类的表的OPTIMIZE命令输出中,您将看到clusterMetrics现在包含在输出的指标中。这些clusterMetrics显示了有关底层数据文件(例如大小和数量)、压缩详细信息和集群节点信息的详细信息,以便您可以查看聚类的结果。

需要注意的是,只有少数操作会在写入数据到启用了液体聚类的表时自动进行数据聚类。以下操作支持在写入数据时自动进行数据聚类,前提是插入的数据大小不超过512 GB:

  • INSERT INTO
  • CREATE TABLE AS SELECT(CTAS)语句
  • COPY INTO
  • 写入附加操作,例如spark.write.format("delta").mode("append")

由于只有这些特定操作支持在写入数据时进行数据聚类,因此您应该定期运行OPTIMIZE来触发聚类。频繁运行此命令将确保数据被正确聚类。

还值得注意的是,液体聚类在通过OPTIMIZE触发时是增量的,这意味着只有必要的数据会被重新写入以适应需要进行聚类的数据。由于并非所有写操作都会自动聚类数据,而且OPTIMIZE是一个增量操作,因此建议定期安排OPTIMIZE作业来进行数据聚类,特别是因为这种增量过程有助于这些作业快速运行。

对聚类列的操作

通过启用液体聚类,您学会了如何使用CLUSTER BY命令指定表在哪些列上进行了聚类。一旦表按特定列进行了聚类,您可以通过利用聚类列更高效地读取数据,同时还可以查看、更改和删除这些列。

更改聚类列

虽然在创建表时必须指定表的聚类方式,但仍然可以使用ALTER TABLE和CLUSTER BY更改表上用于聚类的列。要更改我们之前创建的表的聚类列,将其聚类在VendorId和RateCodeId上,请运行以下命令:

sql 复制代码
%sql
ALTER TABLE taxidb.tripDataClustered CLUSTER BY (VendorId, RateCodeId);

在更改聚类列时,液体聚类不需要重新写入整个表。这种聚类演变是由于液体聚类的动态数据布局功能,相对于本章前面提到的分区功能,它具有显著的优势。传统分区是一个固定的数据布局,不支持在不必须重新写入整个表的情况下更改表的分区方式。这种聚类演变对于表的查询模式经常会随时间而变化是至关重要的,这使您能够在没有重大开销或挑战的情况下动态适应新的查询模式。

查看聚类列

既然我们已经改变了表的聚类方式,我们可以使用DESCRIBE TABLE来查看表的元数据,以确认这些更改并查看聚类的列:

sql 复制代码
%sql
DESCRIBE TABLE taxidb.tripDataClustered;

输出(仅显示相关部分):

sql 复制代码
+---------------------------+-----------+---------+
| col_name                  | data_type | comment |     
+---------------------------+-----------+---------+
|  # Clustering Information |           |         | 
+---------------------------+-----------+---------+
|  # col_name               | data_type | comment |
+---------------------------+-----------+---------+
|  VendorId                 | bigint    | null    |
+---------------------------+-----------+---------+
|  RateCodeId               | double    | null    |
+---------------------------+-----------+---------+

DESCRIBE TABLE命令返回有关表的基本元数据信息,显示了聚类信息,并且表现在已经在VendorId和RateCodeId上进行了聚类。

从聚类表中读取数据

既然我们已经确认了聚类列,我们可以在查询筛选器中指定聚类列(例如,在WHERE子句中),以获得最佳(即最快)的查询结果。例如,在taxidb.tripDataClustered表上的WHERE子句中添加VendorId和RateCodeId以获得最佳查询结果:

sql 复制代码
%sql
SELECT * FROM taxidb.tripDataClustered WHERE VendorId = 1 and RateCodeId = 1

移除聚类列

如果我们选择删除表的聚类列,我们只需要指定 CLUSTER BY NONE

sql 复制代码
%sql
ALTER TABLE taxidb.tripDataClustered CLUSTER BY NONE;

Liquid Clustering 警告和注意事项

鉴于液体聚类在撰写时仍处于预览阶段,因此在启用和使用液体聚类之前,有几个因素需要考虑:

  1. 检查您的环境运行时,确保它支持在启用液体聚类的 Delta 表上运行 OPTIMIZE 操作。
  2. 如果您正在使用 Databricks 遵循本书并从 GitHub 存储库运行笔记本,则需要 Databricks Runtime 13.2 及更高版本。
  3. 启用液体聚类的表在创建时启用了许多 Delta 表功能,并使用 Delta 版本 7 和 reader 版本 3。表协议版本不能降级,并且具有启用聚类的表不可被不支持所有启用的 Delta 读取器协议表功能的 Delta Lake 客户端读取。
  4. 必须在首次创建表时启用 Delta Lake 液体聚类。不能在首次创建表时未启用聚类的情况下更改现有表以添加聚类。
  5. 只能为已统计列指定液体聚类的列。请记住,默认情况下,Delta 表中仅对前 32 列进行了统计。
  6. 结构化流式工作负载不支持写入时的聚类。
  7. 频繁运行 OPTIMIZE 以确保新数据已聚类。

这些是在启用液体聚类之前需要考虑的一些因素。请确保您的环境和应用程序符合这些要求,以充分利用液体聚类的性能优势。

总结

在本章中,您了解了存储和组织数据的不同技术,无论是在物理上还是在动态上,以及它们对数据在操作期间的读取和检索方式所产生的重大影响。随着捕获的数据点类型继续增长,以及数据的绝对数量不断增加,表将继续变得越来越大。对大型数据集进行性能调整一直以来都被认为是一种良好的策略和最佳实践。了解启用此功能的Delta Lake特性将有助于显著减少开销。

我们讨论了小数据文件问题,它对性能的影响,以及如何使用整理策略来解决它,包括使用OPTIMIZE进行最佳文件合并。在对表的文件进行OPTIMIZE之后,您可以使用ZORDER BY对这些文件内的值进行排序,从而通过数据跳过统计信息更有效地利用数据跳过。您还可以通过对Delta表进行分区并将数据分成不同的部分来进一步减少需要读取的数据量。

接下来,我们查看了一些新的Delta Lake特性,这些特性可以解决分区和Z-ordering仍然存在的一些挑战。液体聚类通过动态数据布局实现聚类演进,随着时间推移,它不需要您重写整个表格。这个自动化程度较高的特性与分区和Z-ordering不兼容,但与其他性能优化特性相比,需要更少的调整工作,极大地增强了表格的读取和写入性能。

使用本章中提到的Delta Lake特性可以减少需要读取的不相关数据量,并提高性能,特别是在Delta表中的数据文件数量不断增加的情况下。在下一章中,您将了解Delta Lake如何利用旧数据文件,使您可以对数据进行版本控制并回到某个特定的时间点。

相关推荐
2401_883041081 小时前
新锐品牌电商代运营公司都有哪些?
大数据·人工智能
青云交1 小时前
大数据新视界 -- 大数据大厂之 Impala 性能优化:融合机器学习的未来之路(上 (2-1))(11/30)
大数据·计算资源·应用案例·数据交互·impala 性能优化·机器学习融合·行业拓展
Json_181790144804 小时前
An In-depth Look into the 1688 Product Details Data API Interface
大数据·json
陈燚_重生之又为程序员4 小时前
基于梧桐数据库的实时数据分析解决方案
数据库·数据挖掘·数据分析
Qspace丨轻空间6 小时前
气膜场馆:推动体育文化旅游创新发展的关键力量—轻空间
大数据·人工智能·安全·生活·娱乐
Elastic 中国社区官方博客7 小时前
如何将数据从 AWS S3 导入到 Elastic Cloud - 第 3 部分:Elastic S3 连接器
大数据·elasticsearch·搜索引擎·云计算·全文检索·可用性测试·aws
Aloudata8 小时前
从Apache Atlas到Aloudata BIG,数据血缘解析有何改变?
大数据·apache·数据血缘·主动元数据·数据链路
水豚AI课代表8 小时前
分析报告、调研报告、工作方案等的提示词
大数据·人工智能·学习·chatgpt·aigc
58沈剑9 小时前
80后聊架构:架构设计中两个重要指标,延时与吞吐量(Latency vs Throughput) | 架构师之路...
架构
拓端研究室TRL11 小时前
【梯度提升专题】XGBoost、Adaboost、CatBoost预测合集:抗乳腺癌药物优化、信贷风控、比特币应用|附数据代码...
大数据