保持 Delta Lake 表格高效运行的过程,类似于任何形式的预防性维护——无论是汽车、摩托车,还是其他交通工具(如自行车、电动滑板车、滑轮鞋)。我们不会等到轮胎漏气才开始处理问题,而是会立即采取行动。我们会从简单的观察入手,检查是否有漏气现象,并问自己:“轮胎需要修补吗?问题是否仅仅是气压不足,还是更严重,需要换一个新的轮胎?” 这种监控情况、发现问题并采取解决方案的过程,完全可以应用到我们的 Delta Lake 表格上,它也是维护表格的一个重要部分。从本质上来说,我们只需要关注清理、监控、调优、修复和替换。
接下来,我们将学习如何利用 Delta Lake 的实用方法,并了解相关的配置(即表属性)。我们将展示一些常见的清理、调优、修复和替换表格的方法,以帮助优化表格性能和健康,最终构建对我们所采取操作的因果关系的深刻理解。
使用 Delta Lake 表属性
Delta Lake 提供了许多实用功能,帮助我们进行表格的常规维护(清理和调优)、修复、恢复,甚至替换等操作,这些功能对于任何数据工程师来说都非常宝贵。我们将从介绍一些常见的与维护相关的 Delta Lake 表属性开始,并通过一个简单的练习展示如何应用、修改和移除表属性。
Delta Lake 表属性参考
存储在表定义旁边的元数据包括 TBLPROPERTIES
。常见的属性在表 5-1 中列出,这些属性用于控制 Delta 表的行为。这些属性使得自动化的预防性维护成为可能。当与 Delta Lake 表的实用功能结合使用时,它们还提供了对复杂任务的简单控制。我们只需要添加或移除属性,就可以控制表格的行为。
提示:
收藏表 5-1,以便在需要时作为快速参考。每一行都提供了属性名称、内部数据类型和与清理、调优、修复或替换 Delta Lake 表相关的使用场景。
属性 | 数据类型 | 用途 | 默认值 |
---|---|---|---|
delta.logRetentionDuration | CalendarInterval | 清理 | 间隔 30 天 |
delta.deletedFileRetentionDuration | CalendarInterval | 清理 | 间隔 1 周 |
delta.setTransactionRetentionDuration | CalendarInterval | 清理、修复 | (无) |
delta.targetFileSize | String | 调优 | (无) |
delta.tuneFileSizesForRewrites | Boolean | 调优 | (无) |
delta.autoOptimize.optimizeWrite | Boolean | 调优 | (无) |
delta.autoOptimize.autoCompact | Boolean | 调优 | (无) |
delta.dataSkippingNumIndexedCols | Int | 调优 | 32 |
delta.checkpoint.writeStatsAsStruct | Boolean | 调优 | (无) |
delta.checkpoint.writeStatsAsJson | Boolean | 调优 | true |
delta.randomizeFilePrefixes | Boolean | 调优 | false |
注: 以上属性中,带有 (a) 的属性是 Databricks 独有的。
使用表属性的好处在于它们只会影响表格的元数据,大多数情况下不需要更改物理表格结构。此外,能够选择启用或禁用这些属性,允许我们修改 Delta Lake 的行为,而无需回去更改现有的流水线代码,大多数情况下也不需要重启或重新部署我们的流应用程序(批量应用程序将在下次运行时自动读取修改后的属性)。
注意:
添加或删除表属性的行为与使用常见的数据操作语言(DML)运算符并无不同,DML 运算符包括插入、删除、更新,甚至更高级的 upsert(根据匹配项插入或更新行)。第 10 章将详细介绍 Delta 的高级 DML 模式。
任何表格更改将在下一个事务中生效——对于批量处理应用程序是自动的,对于流应用程序则是即时生效。
对于流式 Delta Lake 应用程序,表格的更改,包括表元数据的更改,都被视为 ALTER TABLE 命令。其他不修改物理表数据的操作,如 vacuum
和 optimize
,可以在不影响流应用程序的情况下更新,而不会中断应用程序的流处理。
物理表格或表格元数据的更改被平等对待,并会在 Delta 日志中生成版本化记录。添加新事务会导致本地同步 deltaSnapshot,以确保任何不同步(过时)的进程得到更新。这一切都归功于 Delta Lake 支持多个并发写入者,允许以分散(分布式)方式进行更改,并通过表的 Delta 日志进行中心化同步。
还有一些属于维护范畴的使用场景,需要人工的有意操作,并提前通知下游消费者。在本章结束时,我们将介绍如何使用 REPLACE TABLE
来添加分区。这个过程可能会破坏活动的表读取者,因为它会重写 Delta Lake 表的物理布局。
为了演示,接下来的章节将使用 GitHub 仓库中的 covid_nyt
数据集,并配合 Docker 环境。按照以下命令启动:
$ export DLDG_DATA_DIR=~/path/to/delta-lake-definitive-guide/datasets/
$ export DLDG_CHAPTER_DIR=~/path/to/delta-lake-definitive-guide/ch05
$ docker run --rm -it \
--name delta_quickstart \
-v $DLDG_DATA_DIR/:/opt/spark/data/datasets \
-v $DLDG_CHAPTER_DIR/:/opt/spark/work-dir/ch05 \
-p 8888-8889:8888-8889 \
delta_quickstart
这条命令会在本地启动 JupyterLab 环境。使用输出中的 URL 打开 JupyterLab 环境,点击 /ch05/ch05_notebook.ipynb
以继续学习。
创建带属性的空表
在本书中,我们通过多种方式创建了表格,现在我们用 SQL CREATE TABLE
语法简单地生成一个空表。在示例 5-1 中,我们创建了一个包含单个日期列和一个默认表属性 delta.logRetentionDuration
的新表。我们将在本章后续部分讲解这个属性的用途。
示例 5-1:使用默认表属性创建 Delta Lake 表格
$ spark.sql("""
CREATE TABLE IF NOT EXISTS default.covid_nyt (
date DATE
) USING DELTA
TBLPROPERTIES('delta.logRetentionDuration'='interval 7 days');
""")
注意
值得指出的是,covid_nyt
数据集有六个列。在示例 5-1 中,我们故意简化了定义,因为在下一步导入时,我们可以直接借用完整的 covid_nyt
表的架构。这将教会我们如何通过填补表格定义中缺失的列来演化当前表的架构。
填充表格
此时,我们已经有了一个空的 Delta Lake 表格。它本质上是一个表格的承诺,里面只包含一个 /tablename/_delta_log
目录和一个包含空表架构及元数据的初始日志条目。如果你想执行一个简单的测试来确认,可以运行以下命令显示表格的支持文件:
$ spark.table("default.covid_nyt").inputFiles()
inputFiles
命令将返回一个空列表。这是预期的结果,但也显得有点孤单。接下来,我们通过向这个表中添加数据,为它带来一些欢乐。我们将直接将 covid_nyt
Parquet 数据读取到我们之前创建的空 Delta Lake 表格中。
在活动会话中,执行以下代码块,将 covid_nyt
数据集合并到空的 default.covid_nyt
表中:
$ from pyspark.sql.functions import to_date
(spark.read
.format("parquet")
.load("/opt/spark/work-dir/rs/data/COVID-19_NYT/*.parquet")
.withColumn("date", to_date("date", "yyyy-MM-dd"))
.write
.format("delta")
.saveAsTable("default.covid_nyt")
)
注意
COVID-19 数据集的 date
列表示为 STRING
类型。在本练习中,我们将 date
列设置为 DATE
类型,并使用 withColumn("date", to_date("date", "yyyy-MM-dd"))
来尊重表格中现有的数据类型。
你会注意到,操作未能执行:
$ pyspark.sql.utils.AnalysisException: Table default.covid_nyt already exists
我们遇到了 AnalysisException
。幸运的是,这个异常阻止了我们执行错误操作。在之前的代码块中,这个异常的抛出是因为 Spark 的 DataFrameWriter 默认使用 errorIfExists
,这对我们是有益的,能够保护我们的数据。所以,如果表格已存在,我们会抛出异常,而不是尝试做任何可能损坏现有表格的操作。
为了解决这个问题,我们需要将操作的写入模式改为 append
。这会改变操作的行为,表明我们有意向现有表格添加记录。
让我们修改写入模式为 append
:
(spark.read
...
.write
.format("delta")
.mode("append")
...
)
好,我们突破了一个障碍,不再被“表格已存在”异常阻拦了。然而,我们遇到了另一个 AnalysisException
:
$ pyspark.sql.utils.AnalysisException: A schema mismatch detected when writing to the Delta table (Table ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
这次的 AnalysisException
是因为架构不匹配。Delta 协议通过这种方式保护我们(操作员),避免在现有表的架构与我们本地读取的 covid_nyt
Parquet 数据架构之间存在不匹配时,盲目进行更改。这个异常是另一个防护措施,防止我们不小心污染表格架构,这一过程被称为架构强制。
架构强制与演化
Delta Lake 使用了一种来自传统数据仓库的技术,称为 写时架构(schema-on-write)。这意味着在执行写操作之前,会检查写入者的架构与现有表的架构是否匹配。这个双重过程为表格架构提供了一个基于之前事务的单一事实来源:
- 架构强制
这是控制过程,检查现有架构是否符合要求,允许写事务发生。如果架构不匹配,会抛出异常。- 架构演化
这是故意修改现有架构的过程,使其保持向后兼容。这通常通过ALTER TABLE {t} ADD COLUMN(S)
完成,Delta Lake 也支持这一操作,并且能够在写操作时启用mergeSchema
选项。
演化表格架构
将 covid_nyt
数据添加到现有表格中的最后一步是明确表示我们同意将要带入表格的架构更改,并且我们打算同时提交实际的表格数据和表格架构的修改:
$ (spark.read
.format("parquet")
.load("/opt/spark/work-dir/rs/data/COVID-19_NYT/*.parquet")
.withColumn("date", to_date("date", "yyyy-MM-dd"))
.write
.format("delta")
.mode("append")
.option("mergeSchema", "true")
.saveAsTable("default.covid_nyt")
)
成功了!我们现在有了一个可用的表格,这是执行上述代码的结果。简要总结一下,我们在写操作中添加了两个修饰符,原因如下:
- 我们将写入模式更新为追加(append)操作。这是必要的,因为我们在一个独立的事务中创建了表格,而默认的写入模式(
errorIfExists
)在 Delta Lake 表已存在时会中止操作。 - 我们将写入操作更新为包含
mergeSchema
选项,这使我们能够在同一个事务中修改covid_nyt
表的架构,添加数据集所需的五个额外列,同时物理上将covid_nyt
数据添加到表格中。
完成这些操作后,我们的表格中现在有了实际数据,同时在过程中演化了表格架构,从基于 Parquet 的 covid_nyt
数据集演化而来。
你可以通过执行以下 DESCRIBE
命令查看完整的表格元数据:
$ spark.sql("describe extended default.covid_nyt").show(truncate=False)
执行 DESCRIBE
命令后,你将看到完整的表格元数据,包括列(及注释)、分区(在我们这个例子中没有分区)以及所有可用的 tblproperties
。使用 DESCRIBE
是了解我们表格的简单方法,或者说,任何你未来需要处理的表格也可以通过这种方式了解。
自动架构演化的替代方法
在前面的示例中,我们使用
.option("mergeSchema", "true")
来修改 Delta Lake 写入器的行为。虽然这个选项简化了我们如何演化 Delta Lake 表架构的过程,但它也有一个代价:我们可能对表格架构的变化不完全知情。如果上游数据源引入了未知的列,我们希望知道哪些列是打算带入表格的,哪些列可以安全忽略。如果我们知道
default.covid_nyt
表中缺少五列,我们可以运行ALTER TABLE
来添加缺失的列:
$ spark.sql(""" ALTER TABLE default.covid_nyt ADD COLUMNS ( county STRING, state STRING, fips INT, cases INT, deaths INT ); """)
尽管这个过程可能看起来繁琐,因为我们已经学会了如何自动合并表格架构的修改,但回溯并撤销突如其来的变化最终会更加昂贵。通过一些前期的工作,显式地选择不进行自动架构修改并不困难:
(spark.read .format("parquet") .load("/opt/spark/work-dir/rs/data/COVID-19_NYT/*.parquet") .withColumn("date", to_date("date", "yyyy-MM-dd")) .write .format("delta") .option("mergeSchema", "false") .mode("append") .saveAsTable("default.covid_nyt") )
就这样!我们有了所有预期的表格修改,且完全是经过意图和控制的,没有任何意外,这帮助保持表格的干净整洁。
添加或修改表格属性
添加或修改现有表格属性的过程很简单。如果某个属性已经存在,那么任何更改都会直接覆盖现有的属性。新添加的属性将被附加到表格属性集合中。
为了展示这一行为,请在活动会话中执行以下 ALTER TABLE
语句:
$ spark.sql("""
ALTER TABLE default.covid_nyc
SET TBLPROPERTIES (
'engineering.team_name'='dldg_authors',
'engineering.slack'='delta-users.slack.com'
)
""")
此操作会将两个属性添加到我们的表格元数据中:一个指向团队名称(dldg_authors
),另一个是该书的作者团队的 Slack 组织(delta-users.slack.com
)。每当我们修改表格的元数据时,所有更改都会记录在表格历史记录中。要查看对表格所做的更改(包括我们刚刚做的表格属性修改),可以在 DeltaTable Python 接口上调用 history
方法:
$ from delta.tables import DeltaTable
dt = DeltaTable.forName(spark, 'default.covid_nyt')
dt.history(10).select("version", "timestamp", "operation").show()
上述命令将输出对表格所做的更改:
+-------+--------------------+-----------------+
|version| timestamp| operation|
+-------+--------------------+-----------------+
| 2|2023-06-07 04:38:...|SET TBLPROPERTIES|
| 1|2023-06-07 04:14:...| WRITE|
| 0|2023-06-07 04:13:...| CREATE TABLE|
+-------+--------------------+-----------------+
要查看(或确认)上一个事务的更改,可以对 covid_nyt
表调用 SHOW TBLPROPERTIES
:
$ spark.sql("show tblproperties default.covid_nyt").show(truncate=False)
或者你也可以对之前的 DeltaTable 实例执行 detail()
函数:
$ dt.detail().select("properties").show(truncate=False)
删除表格属性
如果只能添加表格属性而无法删除,那就毫无意义。所以,接下来我们将学习如何使用 ALTER TABLE table_name UNSET TBLPROPERTIES
删除表格属性。
假设我们不小心拼写了一个属性名称,比如将 delta.loRgetentionDuratio
拼写为错误的 delta.logRetentionDuration
,虽然这个错误不会导致严重问题,但没必要让它留在表格中。
要删除这个不需要的(或拼写错误的)属性,我们可以在 ALTER TABLE
命令中执行 UNSET TBLPROPERTIES
:
$ spark.sql("""
ALTER TABLE default.covid_nyt
UNSET TBLPROPERTIES('delta.logRetentionDuration')
""")
就这样,那个不需要的属性就不再占用表格属性空间了。
Spark 仅适用:默认表格属性
一旦你对各种 Delta Lake 表格属性的细节更加熟悉,你就可以使用以下 Spark 配置前缀向 SparkSession 提供一组默认的表格属性:
spark.databricks.delta.properties.defaults.`<conf>`
虽然此方法仅适用于 Spark 工作负载,但你可以想象在许多场景中,能够自动将属性注入到管道中会非常有用:
spark...delta.defaults.logRetentionDuration=interval 2 weeks spark...delta.defaults.deletedFileRetentionDuration=interval 28 days
说到有用,表格属性可以用于存储有关表格所有者、工程团队、通信渠道(如 Slack 和电子邮件)以及其他任何有助于扩展表格元数据实用性的内容。利用表格元数据有助于简化数据发现,并捕获关于数据集所有者和负责人的信息。如前所示,表格元数据可以存储大量信息,远远超出了简单配置的范围。
表格 5-2 列出了可以用于增强任何 Delta Lake 表格的数据目录样式信息的示例表格属性。这些属性按前缀分类,并提供额外的数据目录信息。
表格 5-2:用于数据目录管理的表格属性
属性 描述 catalog.team_name
提供团队名称并回答问题:“谁负责这个表格?” catalog.engineering.comms.slack
提供工程团队的 Slack 渠道—使用类似 delta-users.slack.com/archives/CG… 的永久链接,因为频道名称可能会发生变化 catalog.engineering.comms.email
提供工程团队的电子邮件地址—例如: dldg_authors@gmail.com
(注意,这不是一个真实的邮箱地址,但理解意思即可)catalog.table.classification
可用于声明表格类型—例如: pii
、sensitive-pii
、general
、all-access
等;这些值也可用于基于角色的访问控制(本书不涉及集成部分)
Delta Lake 表格优化
你是否熟悉“每个动作都有一个相等且相反的反应”这一概念?这种理念与物理学定律相呼应,随着新数据的插入(追加)、修改(更新)、合并(插入/更新)或删除(删除),我们在 Delta Lake 表格中的操作(动作)会引发系统中的反应——记录每次操作作为原子事务(版本、时间戳、操作等),不仅确保表格继续满足当前的使用需求,还能保留足够的历史记录,使我们可以回溯(时间旅行)到早期状态(表格的某个时刻),并在表格遇到更大问题时进行修复(覆盖)或恢复(替换)。
然而,在深入更复杂的维护操作之前,让我们先来看看随着时间推移可能悄然出现的一些常见问题。最著名的之一便是“小文件问题”。我们现在就来讨论这个问题及其解决方案。
大表格与小文件的问题
当我们谈论小文件问题时,实际上我们指的是一个不止 Delta Lake 独有的问题,而是一个与网络 IO 相关的常见问题,特别是在由过多小文件构成的未优化表格中,网络 IO 的开销过高。小文件通常被定义为小于 64KB 的文件。
那么,过多的小文件到底如何伤害我们呢?答案是:“有许多不同的方式”,但所有问题的共同点是,它们会随着时间的推移悄然出现,并要求我们对表格中封装的物理文件布局进行修改。如果我们没有意识到当表格开始变慢并承受它自身的重量时,可能会导致分布式计算需求的增加,从而需要更高效地打开和执行查询。
小贴士
确保表格保持最佳状态的一种策略是采用表级监控。我们将在第13章中讨论一些仅基于元数据的监控策略。这些策略可以扩展到跟踪当前表快照的文件数量,或者跟踪磁盘上表格的版本数量。最终,监控是一个工具,能帮助我们意识到经常出现的问题,并能作为维护策略中的救命稻草。
每次操作所需的步骤数量会随着时间的推移而增加,最终直到表格无法高效加载为止。这通常是由于云对象存储的原因,每次操作都有其各自的延迟、并发限制,最终导致更高的运营成本。
注意
这种问题在传统的 Hadoop 风格的生态系统中尤为明显,比如 MapReduce 和 Spark,在这些系统中,分发的单位与任务绑定,文件由“块”组成,每个块由一个任务处理。如果我们在表格中有一百万个文件,每个文件大小为 1GB,且块大小为 64MB,那么为了读取整个表格,我们需要分发 1565 万个任务。理想的做法是优化表格中文件的目标大小,从而减少文件系统和网络的 IO。当我们遇到未优化的文件(小文件问题)时,表格的性能将大大下降。举个具体例子,假设我们有一个 1TB 的大表,但组成该表的文件大小为大约 5KB,这意味着每 GB 会有 20 万个文件,总共有大约 2 亿个文件需要打开才能加载整个表格。在大多数情况下,这样的表格根本打不开。
为了更直观地了解小文件问题,我们将重新创建一个真实的小文件问题,然后探讨如何优化该表格。请继续关注以下示例,我们将使用 covid_nyt
数据集。
创建小文件问题
covid_nyt
数据集有超过一百万条记录。表格的总大小小于 7MB,分为 8 个分区,因此这是一个很小的数据集:
$ ls -lh /opt/spark/work-dir/ch05/spark-warehouse/covid_nyt/*.parquet | wc -l
8
如果我们反过来思考,假设我们有 9000 个甚至 100万个文件来表示 covid_nyt
数据集,结果会怎样呢?虽然这个用例是极端的,但我们会在书中的第7章学习到,流式应用程序通常是导致大量小文件的罪魁祸首。
现在,让我们创建另一个名为 default.nonoptimal_covid_nyt
的空表,并通过简单的命令将其非优化化。首先,执行以下命令:
$ from delta.tables import DeltaTable
(DeltaTable.createIfNotExists(spark)
.tableName("default.nonoptimal_covid_nyt")
.property("description", "table to be optimized")
.property("catalog.team_name", "dldg_authors")
.property("catalog.engineering.comms.slack", "https://delta-users.slack.com/archives/CG9LR6LN4")
.property("catalog.engineering.comms.email","dldg_authors@gmail.com")
.property("catalog.table.classification","all-access")
.addColumn("date", "DATE")
.addColumn("county", "STRING")
.addColumn("state", "STRING")
.addColumn("fips", "INT")
.addColumn("cases", "INT")
.addColumn("deaths", "INT")
.execute())
现在我们有了表格,可以通过常规的 default.covid_nyt
表格作为源,轻松地创建出过多的小文件。该表格的总行数为 1,111,930。如果我们将表格的分区数从原来的 8 个分区重新划分为 9000 个分区,这将把表格拆分成大约 9000 个文件,每个文件大约 5KB:
$ (spark
.table("default.covid_nyt")
.repartition(9000)
.write
.format("delta")
.mode("overwrite")
.saveAsTable("default.nonoptimal_covid_nyt")
)
注意
如果你想查看物理表格文件,可以执行以下命令:
WAREHOUSE_DIR=/opt/spark/work-dir/ch05/spark-warehouse
FILE_PATH=$WAREHOUSE_DIR/nonoptimal_covid_nyt/*parquet
docker exec -it delta_quickstart bash \
-c "ls -l ${FILE_PATH} | wc -l"
你将看到正好有 9000 个文件。
现在我们有了一个需要优化的表格,接下来我们将介绍 OPTIMIZE
命令。可以把它当作一个非常友好的工具,它将帮助你轻松地将这些小文件合并成少量的较大文件,而且速度飞快。
使用 OPTIMIZE 解决小文件问题
OPTIMIZE
是 Delta 提供的一个实用工具函数,分为两种变体:Z-Order 和 bin-packing。默认情况下是 bin-packing。在我们探讨如何修复小文件问题时,值得指出的是,反过来也可能发生——你可能会发现自己有很多大文件,必须将其拆分成更小的文件以便进行高效处理。
OPTIMIZE
什么是 bin-packing?从高层次来看,这是一个将许多小文件合并成更少的大文件的技术,合并过程中通过一个或多个容器来完成。容器(bin)被定义为最大文件大小(默认情况下,Spark Delta Lake 的最大文件大小为 1 GB;对于 Delta Rust,最大为 250MB)。
OPTIMIZE
命令可以通过一系列配置来调整优化过程:
- (仅适用于 Spark)
spark.databricks.delta.optimize.minFileSize
(long 类型):用于将小于该阈值的文件(以字节为单位)进行合并,然后通过OPTIMIZE
命令重新写入成一个更大的文件。 - (仅适用于 Spark)
spark.databricks.delta.optimize.maxFileSize
(long 类型):用于指定通过OPTIMIZE
命令生成的目标文件大小。 - (仅适用于 Spark)
spark.databricks.delta.optimize.repartition.enabled
(布尔值):用于更改OPTIMIZE
的行为,并在进行合并时使用repartition(1)
代替coalesce(1)
。 - (适用于 delta-rs)表格属性
delta.targetFileSize
(字符串类型),例如 250MB,可以在 delta-rs 客户端中使用,但在 OSS 版本的 Delta 中尚不支持。
OPTIMIZE
命令是确定性的,旨在实现 Delta Lake 表格(或某个特定子集)的均匀分布。
为了展示 OPTIMIZE
的实际效果,我们可以对 nonoptimal_covid_nyt
表执行优化操作。你可以多次运行该命令,OPTIMIZE
只有在添加了新记录时才会再次生效:
$ results_df = (DeltaTable
.forName(spark, "default.nonoptimal_covid_nyt")
.optimize()
.executeCompaction())
运行优化操作的结果会返回为一个 DataFrame(results_df
),并且可以通过表格历史记录查看。要查看 OPTIMIZE
的统计信息,可以在 DeltaTable 实例上使用 history
方法:
from pyspark.sql.functions import col
(
DeltaTable.forName(spark, "default.nonoptimal_covid_nyt")
.history(10)
.where(col("operation") == "OPTIMIZE")
.select(
"version", "timestamp", "operation",
"operationMetrics.numRemovedFiles",
"operationMetrics.numAddedFiles"
)
.show(truncate=False)
)
执行后的输出将显示如下表格:
version | timestamp | operation | numRemovedFiles | numAddedFiles |
---|---|---|---|---|
2 | 2023-06-07 06:47:28.488 | OPTIMIZE | 9000 | 1 |
在我们的操作中,重要的列显示了我们移除了 9000 个文件(numRemovedFiles
)并生成了一个压缩文件(numAddedFiles
)。
Z-Order 优化
Z-Order 是一种将相关信息放在同一组文件中的技术。相关信息是指存储在表格列中的数据。以 covid_nyt
数据集为例,如果我们知道需要快速计算按州分的死亡率,那么使用 ZORDER BY
可以让我们跳过打开不包含相关信息的表格文件。Delta Lake 的数据跳过算法会自动利用这种数据的局部性,这种行为大大减少了需要读取的数据量。
对于 ZORDER BY
的调优,涉及以下配置:
delta.dataSkippingNumIndexedCols
(整数类型):该表格属性用于减少存储在表格元数据中的统计列数,默认值为 32 列。delta.checkpoint.writeStatsAsStruct
(布尔类型):该表格属性用于启用将每个事务的列统计信息作为 Parquet 数据写入的功能。默认值为false
,因为并非所有基于供应商的 Delta Lake 解决方案都支持读取基于结构的统计信息。
注意
第 10 章将更详细地介绍性能调优,因此目前我们只讨论一般的维护注意事项。
表格调优和管理
我们刚刚介绍了如何使用 OPTIMIZE
命令优化表格。在许多情况下,如果表格小于 1 GB,使用 OPTIMIZE
是完全可行的;然而,表格通常会随着时间增长,最终我们必须考虑对表格进行分区,这是表格维护的下一步。
分区表格
表格分区可能会为你带来好处,也可能反而适得其反,这与我们观察到的小文件问题类似;太多的分区会造成类似的问题,但问题出现在目录级别的隔离上。幸运的是,有一些通用的指导原则和规则可以帮助你有效地管理分区,或者至少在必要时为你提供遵循的模式。
表格分区规则
以下规则将帮助你理解何时应引入分区:
- 如果表格小于 1 TB,则不要添加分区;只需使用
OPTIMIZE
来减少文件数量。 - 如果
bin-packing
优化没有提供所需的性能提升,建议与下游数据用户沟通,了解他们通常如何查询你的表格;你也许可以使用 Z-Order 优化,通过数据共置来加速他们的查询。
如果需要优化,如何删除数据?
GDPR 和其他数据治理规则意味着表格数据是动态变化的。遵守数据治理规则通常意味着你需要优化从表格中删除记录的方式,甚至在法律保留的情况下需要保留表格数据。一个简单的用例是 N 天删除——例如,30 天保留。虽然根据 Delta Lake 表格的大小,按天分区并非最优选择,但它可以简化常见的删除模式,比如删除某个时间点之前的数据。以 30 天删除为例,如果表格按 datetime
列进行分区,你可以运行一个简单的作业,调用删除操作:
delete from {table} where datetime < current_timestamp() - interval 30 days
选择正确的分区列
以下建议将帮助你选择正确的列(或列组合)进行分区。最常用的分区列是日期。决定按哪个列进行分区时,可以遵循以下两条经验法则:
- 该列的基数是否非常高?
如果基数很高,不要使用该列进行分区。例如,如果你按userId
列进行分区,而该列中可能有超过一百万个不同的用户 ID,那么这种分区策略就不合适。 - 每个分区中的数据量有多少?
如果你预期某个分区中的数据至少为 1 GB,则可以选择该列进行分区。
正确的分区策略可能不会立即显现出来,这也是正常的;在你拥有正确的用例(和数据)之前,不必急于进行优化。
示例用例:表格创建时定义分区、向现有表格添加分区和删除分区
根据我们刚刚设定的规则,让我们通过以下几个用例来实践:在表格创建时定义分区、向现有表格添加分区以及删除(删除)分区。这个过程将帮助我们更好地理解如何使用分区——毕竟,这对于 Delta Lake 表格的长期预防性维护是必须的。
在表格创建时定义分区
让我们创建一个新的表 default.covid_nyt_by_day
,它将使用 date
列自动为表添加新分区,且无需人工干预:
from pyspark.sql.types import DateType
from delta.tables import DeltaTable
(DeltaTable.createIfNotExists(spark)
.tableName("default.covid_nyt_by_date")
...
.addColumn("date", DateType(), nullable=False)
.partitionedBy("date")
.addColumn("county", "STRING")
.addColumn("state", "STRING")
.addColumn("fips", "INT")
.addColumn("cases", "INT")
.addColumn("deaths", "INT")
.execute())
在表格创建逻辑中,几乎与之前的例子相同,唯一的区别是我们在 DeltaTable
构建器中引入了 partitionBy("date")
。为了确保 date
列始终存在,数据定义语言(DDL)中包括了 nullable=False
标记,因为该列对于分区是必需的。
分区要求表示我们表格的物理文件按每个分区使用唯一的目录进行布局。这意味着,所有物理表格数据都必须移动,以遵循分区规则。从非分区表迁移到分区表并不复杂,但对于支持活跃下游客户来说,可能会有些棘手。
作为一般准则,最好制定一个计划,将现有的数据客户迁移到新的表格——在本例中是新的分区表——而不是为任何活动读取者引入潜在的破坏性更改。
在掌握了最佳实践后,接下来我们将学习如何实现这一点。
从非分区表迁移到分区表
在我们拥有分区表的表格定义后,迁移工作变得非常简单。我们只需要将非分区表中的所有数据读取出来,然后将行写入新创建的分区表。更方便的是,我们不需要指定分区方式,因为分区策略已经在表的元数据中定义好了:
(
spark
.table("default.covid_nyt")
.write
.format("delta")
.mode("append")
.option("mergeSchema", "false")
.saveAsTable("default.covid_nyt_by_date")
)
这个过程会产生一个“分叉”。我们现在有了表的先前版本(非分区表)和新版本(分区表),这意味着我们有了一个副本。在正常的切换过程中,通常需要继续进行双写,直到客户告知他们已经准备好完全迁移。第7章将为你提供一些智能增量合并的技巧,并且为了保持两个版本的表同步,使用 merge
和增量处理是最佳选择。
分区元数据管理
由于 Delta Lake 会自动创建和管理表的分区,在插入新数据和删除旧数据时,因此不需要手动调用 ALTER TABLE table_name [ADD | DROP PARTITION] (column=value)
。这意味着你可以将精力集中在其他地方,而不必手动维护表元数据与表本身状态的同步。
查看分区元数据
要查看分区信息以及其他表格元数据,我们可以为表创建一个新的 DeltaTable
实例,并调用 detail
方法;这将返回一个 DataFrame
,可以查看全部内容,或者根据需要过滤出特定列:
(
DeltaTable.forName(spark, "default.covid_nyt_by_date")
.detail()
.toJSON()
.collect()[0]
)
上述命令将返回的 DataFrame
转换为 JSON 对象,然后使用 collect()
转换为列表,这样我们可以直接访问 JSON 数据:
{
"format": "delta",
"id": "8c57bc67-369f-4c84-a63e-38b8ac19bdf2",
"name": "default.covid_nyt_by_date",
"location": "file:/opt/spark/work-dir/ch05/spark-warehouse/covid_nyt_by_date",
"createdAt": "2023-06-08T05:35:00.072Z",
"lastModified": "2023-06-08T05:50:45.241Z",
"partitionColumns": ["date"],
"numFiles": 423,
"sizeInBytes": 17660304,
"properties": {
"description": "table with default partitions",
"catalog.table.classification": "all-access",
"catalog.engineering.comms.email": "dldg_authors@gmail.com",
"catalog.team_name": "dldg_authors",
"catalog.engineering.comms.slack": "https://delta-users.slack.com/..."
},
"minReaderVersion": 1,
"minWriterVersion": 2,
"tableFeatures": ["appendOnly", "invariants"]
}
通过完成对分区的介绍,接下来我们将专注于 Delta Lake 表生命周期和维护下的两个关键技术:修复和替换表。
修复、恢复和替换表数据
让我们面对现实:即使有最好的意图,我们仍然是人类,也会犯错。在你作为数据工程师的职业生涯中,你将需要学习的是数据恢复的艺术。数据恢复的过程通常被称为“重放”,因为我们采取的行动是将时间倒回,或者回到一个早期的时间点。这使我们能够移除表中的问题更改,并用“修复”后的数据替换错误的数据。
恢复和替换表
虽然可以恢复表,但有一个条件,就是需要有一个比当前表更好的数据源。在第9章中,我们将学习“奖牌架构”,用于定义原始数据(铜牌)、清洗数据(银牌)和精选数据(黄金)的清晰质量边界。为了本章的目的,我们假设我们的铜牌数据库表中有原始数据,可以用来替换在银牌数据库表中被破坏的数据。
替换损坏或其他不良表分区的一个技巧是使用 replaceWhere
选项与 overwrite
模式结合。例如,假设我们的 2021-02-17 的数据意外被删除了。恢复被意外删除的数据有其他方式(我们将在下一部分学习),但如果数据被永久删除了,也无需惊慌——我们可以获取恢复数据并使用条件覆盖:
recovery_table = spark.table("bronze.covid_nyt_by_date")
partition_col = "date"
partition_to_fix = "2021-02-17"
table_to_fix = "silver.covid_nyt_by_date"
(recovery_table
.where(col(partition_col) == partition_to_fix)
.write
.format("delta")
.mode("overwrite")
.option("replaceWhere", f"{partition_col} == {partition_to_fix}")
.saveAsTable("silver.covid_nyt_by_date")
)
这段代码展示了 replace
覆盖模式,它可以用来替换缺失的数据或条件性地覆盖表中的现有数据。这个选项允许你修复可能已经损坏的表,或者解决数据缺失但现已可用的情况。replaceWhere
与 insert overwrite
不仅限于分区列,也可以用于条件性地替换表中的数据。
提示: 确保 replaceWhere
条件与恢复表的 WHERE
子句匹配非常重要;否则,你可能会造成更大的问题,并进一步损坏正在修复的表。尽可能避免人为错误。如果你发现自己经常修复(替换或恢复)表中的数据,建议创建一些防护措施来保护表的完整性。例如,可以写一个简单的命令行工具,接收一个表并设置条件(replaceWhere
、overwrite
或 restore
),并允许任何人触发一次“干运行”——也就是一个模拟运行——查看会发生什么,确保操作正确进行,不会造成额外的问题。为了避免团队成员在本地运行命令,可以通过 API(使用凭证)或 GitHub actions(通过 PR 和审查执行)触发该命令。这样可以记录操作意图,如果出现问题,可以在影响最小的情况下回滚操作,避免意外发生。
接下来,我们来看一下如何条件性地删除整个分区。
删除数据和移除分区
通常,我们会删除 Delta Lake 表中的特定分区,以满足特定的请求——例如,删除某个时间点之前的数据、删除异常数据或清理表数据。
无论是哪种情况,如果我们的目标仅仅是清空某个特定的分区,我们可以通过条件删除分区列来实现。以下语句条件性地删除 2023 年 1 月 1 日之前的分区(date
):
(
DeltaTable
.forName(spark, 'default.covid_nyt_by_date')
.delete(col("date") < "2023-01-01")
)
删除数据或丢弃整个分区可以通过条件删除来管理。当你根据分区列进行删除时,这是一种高效的删除方式,因为它不需要将物理表数据加载到内存中进行处理;而是通过表元数据中包含的信息,根据谓词修剪分区。在根据非分区列进行删除时,成本较高,因为可能会进行部分或全表扫描。然而,有一个额外的好处:无论你是删除整个分区,还是条件性地删除表中的子集数据,如果你改变主意,可以使用时间旅行功能“撤销”操作。接下来我们将学习如何将表恢复到早期的时间点。
警告: 请记住,永远不要在 Delta Lake 操作的上下文之外删除 Delta Lake 表数据(文件),因为这样做可能会损坏表并引发麻烦。这也意味着任何非 Delta 兼容的过程应遵循相同的规则。例如,云存储生命周期策略:如果你的文件每隔 N 天自动删除一次,这也可能会损坏你的 Delta Lake 表。
Delta Lake 表的生命周期
随着时间的推移,每次修改 Delta Lake 表时,表的旧版本会保留在磁盘上,以支持表的恢复或查看表的早期版本(时间旅行),并为可能从表的不同时间点(即表的不同历史时间点)读取数据的流式作业提供清洁的体验。因此,确保设置足够长的 delta.logRetentionDuration
窗口非常重要,这样当你在表上运行 vacuum
时,数据不会立即丢失,也不会因为数据流失而导致客户不满。
恢复表
在发生事务的情况下——例如,表中的数据被错误地删除(因为生活中总有意外发生)——我们可以通过回溯恢复表,而不是重新加载数据(前提是我们有数据的副本)。这是一个重要的功能,特别是在数据的唯一副本实际上就是刚刚被删除的数据时。当没有其他地方可以恢复数据时,你可以通过时间旅行回到表的早期版本。
恢复表所需的额外信息可以从表的历史记录中获取:
dt = DeltaTable.forName(spark, "silver.covid_nyt_by_date")
(dt.history(10)
.select("version", "timestamp", "operation")
.show())
上面的代码将显示 Delta Lake 表的最近 10 次操作。如果你想回溯到一个早期版本,只需查找 DELETE
操作:
+-------+--------------------+--------------------+
|version| timestamp| operation|
+-------+--------------------+--------------------+
| 1|2023-06-09 19:11:...| DELETE|
| 0|2023-06-09 19:04:...|CREATE TABLE AS S...|
+-------+--------------------+--------------------+
你会看到 DELETE
操作发生在版本 1,所以我们可以将表恢复到版本 0:
dt.restoreToVersion(0)
恢复表所需的只是了解你想要移除的操作。在我们的例子中,我们删除了 DELETE
操作。由于 Delta Lake 的删除操作是在表元数据中进行的,除非你运行一个叫做 VACUUM
(或 REORG
)的过程,否则你可以安全地返回到表的先前版本。
清理数据
当我们从 Delta Lake 表中删除数据时,这个操作并不是立即生效的。事实上,操作本身只是将删除的数据从 Delta Lake 表快照中移除,所以数据仍然存在,只是变得不可见。这意味着,如果数据被意外删除,我们实际上有能力“撤销”操作。为了真正清除被删除的文件并将其从 Delta Lake 表中完全清除,我们可以使用一个叫做“垃圾清理”(vacuum)的方法。
Vacuum(垃圾清理)
vacuum
命令会清理不再被当前表引用的已删除文件或表的旧版本,这种情况通常发生在你对表使用覆盖(overwrite)方法时。如果你覆盖了表,实际上你只是为新文件创建了新的指针,这些文件被表元数据引用。所以,如果你频繁覆盖表,表在磁盘上的大小会指数级增长。因此,最好的做法是利用 vacuum
来启用短时间的时间旅行(通常最多 30 天),并采用不同的策略来存储战略性表备份。我们现在来看看一个常见的场景。
幸运的是,有一些表属性帮助我们在表发生变化时控制其行为。这些规则将决定 vacuum
过程的执行方式:
delta.logRetentionDuration
默认是 30 天,用于跟踪表的历史记录。随着操作的增加,历史记录会不断增加。如果你不打算使用时间旅行操作,可以将历史记录天数减少到一周。delta.deletedFileRetentionDuration
默认是 1 周,可以在不打算撤销删除操作的情况下进行更改。为了确保安全,最好保留至少一天的已删除文件。
设置了这些表属性后,vacuum
命令将自动为我们完成大部分工作。以下是执行 vacuum
操作的代码示例:
DeltaTable.forName(spark, "default.nonoptimal_covid_nyt")
.vacuum()
运行 vacuum
后,表中所有不再被表快照引用的文件将被删除,包括表的早期版本中的已删除文件。虽然 vacuum
是减少维护旧版本表的成本的必要过程,但它也有副作用——即,如果下游的数据消费者需要读取表的早期版本,他们可能会遇到问题。
小贴士
如果需要存储长期保存的表备份(例如用于审计、灾难恢复,或团队需要读取表的早期版本),最简单的方法是将备份存储在另一个表中。我们只需要为备份提供表的版本号,然后创建一个新的 Delta Lake 表来永久存储表的备份。这类备份可以以 _version_x
作为后缀,并与原始表模式并排存放,从而减少需要查看的地方,方便查找表的早期版本。
关于流数据进出 Delta Lake 表的其他问题将在第七章中详细讨论。
小贴士
vacuum
命令不会自动运行。当你准备将表投入生产并希望自动化清理过程时,你可以设置一个 cron 任务定期调用 vacuum
(例如每天或每周)。还需要指出的是,vacuum
依赖于文件写入磁盘时的时间戳,因此如果整个表被导入,直到你达到保留阈值,vacuum
命令才会生效。这是因为文件系统标记文件创建时间与实际文件创建时间的方式不同。
删除表
删除表是一个无法撤销的操作。如果你执行 DELETE FROM {table}
,你实际上是在截断表,并且仍然可以利用时间旅行来撤销操作。然而,如果你真的想删除一个表的所有痕迹,请先阅读以下警告,并记住,如果你想有恢复策略,请提前创建表的副本(或克隆)。
警告
删除表是一个无法撤销的操作。如果你确实想删除一个表的所有痕迹,请继续阅读。
删除 Delta Lake 表的所有痕迹
如果你想做一个永久删除,彻底移除一个受管 Delta Lake 表的所有痕迹,并且你已经了解与此操作相关的风险,并且真的打算放弃任何恢复表的可能性,那么你可以使用 SQL DROP TABLE
语法来删除该表:
spark.sql(f"DROP TABLE silver.covid_nyt_by_date")
你可以通过尝试列出 Delta Lake 表的文件来确认表是否已被删除:
docker exec \
-it delta_quickstart bash \
-c "ls -l /opt/spark/.../silver.db/covid_nyt_by_date/"
前面的代码将输出以下内容,显示该表确实不再存在于磁盘上:
ls: cannot access './spark-warehouse/silver.db/covid_nyt_by_date/': No such file or directory
总结
本章介绍了 Delta Lake 项目中提供的常用工具函数。我们学习了如何处理表属性,探讨了最常遇到的表属性,并了解了如何优化表以解决小文件问题。这引导我们学习了分区管理,以及如何恢复和替换表中的数据。我们探讨了使用时间旅行来恢复表,并以清理工作和最终删除不再需要的表作为本章的结尾。虽然并不是每个用例都能在一本书中完美适配,但现在我们拥有了一个很好的参考,能够帮助我们解决常见问题,并为保持 Delta Lake 表的正常运行提供所需的解决方案。