在上一章中,我们介绍了Delta Lake,并了解了它如何为传统数据湖添加了事务保证、DML支持、审计、统一的流式和批处理模型、模式强制执行以及可扩展的元数据模型。
在本章中,我们将亲自尝试Delta Lake。首先,我们将在安装了Spark的本地机器上设置Delta Lake。我们将在两个交互式shell中运行Delta Lake示例:
首先,我们将运行带有Delta Lake软件包的PySpark交互式shell。这将允许我们输入并运行一个创建Delta表的简单两行Python程序。
接下来,我们将使用Spark Scala shell运行类似的程序。尽管本书没有详细介绍Scala语言,但我们希望演示Delta Lake可与Spark shell和Scala一起使用。
接下来,我们将在您喜爱的编辑器中创建一个名为helloDeltaLake的Python起始程序,并在PySpark shell中以交互方式运行程序。本章中设置的环境和helloDeltaLake程序将成为我们在本书中创建大多数其他程序的基础。
一旦环境准备就绪,我们就准备深入研究Delta表格格式。由于Delta Lake使用Parquet作为底层存储介质,我们首先简要了解Parquet格式。由于当我们稍后研究事务日志时,分区和分区文件扮演着重要角色,因此我们将研究自动分区和手动分区的机制。接下来,我们将转向Delta表格,研究Delta表格如何在_delta_log目录中添加事务日志。
本章的其余部分将专门用于事务日志。我们将创建并运行多个Python程序,以研究事务日志条目的详细信息,记录了哪些类型的操作,以及何时以及如何编写Parquet数据文件以及它们如何与事务日志条目相关。我们将查看更复杂的更新示例及其对事务日志的影响。最后,我们将介绍检查点文件的概念,以及它们如何帮助Delta Lake实施可扩展的元数据系统。
获取标准的Spark镜像
在本地机器上设置Spark可能会让人望而却步。您需要调整许多不同的设置,更新包等。因此,我们选择使用Docker容器。如果您尚未安装Docker,可以从其官方网站免费下载。我们使用的具体容器是标准的Apache Spark镜像。要下载该镜像,您可以使用以下命令:
bash
docker pull apache/spark
在您拉取了镜像之后,可以使用以下命令启动容器:
bash
docker run -it apache/spark /bin/sh
Spark的安装位于/opt/spark目录中。PySpark、spark-sql和所有其他工具位于/opt/spark/bin目录中。有关如何使用容器的更多说明,您可以在本书的GitHub存储库的自述文件中找到。
使用PySpark与Delta Lake
如前所述,Delta Lake运行在现有存储之上,与现有的Apache Spark API完全兼容。这意味着如果您已经安装了Spark或按照前一部分中的说明使用了容器,那么开始使用Delta Lake将会很容易。
有了Spark,您可以安装delta-spark 2.4.0包。您可以在其PySpark目录中找到delta-spark包。在命令行中输入以下命令:
pip install delta-spark
安装完delta-spark后,可以像这样交互式运行Python shell:
css
pyspark --packages io.delta:<delta_version>
--conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension"
--conf "spark.sql.catalog.spark_catalog=
org.apache.spark.sql.delta.catalog.DeltaCatalog"
这将为您提供一个PySpark shell,您可以通过其交互式运行命令:
bash
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 3.2.2
/_/
Using Python version 3.9.13 (tags/v3.9.13:6de2ca5, May 17 2022 16:36:42)
Spark context Web UI available at http://host.docker.internal:4040
Spark context available as 'sc' (master = local[*],
app id = local-1665944381326).
SparkSession available as 'spark'.
在Shell中,您现在可以运行交互式的PySpark命令。我们总是通过使用Spark创建一个range()来进行快速测试,从而生成一个DataFrame,然后可以将其保存为Delta Lake格式(有关更多详细信息,请参见"创建和运行一个Spark程序:helloDeltaLake")。 以下是完整的代码:
kotlin
data = spark.range(0, 10)
data.write.format("delta").mode("overwrite").save("/book/testShell")
以下是完整的运行:
python
>>> data = spark.range(0, 10)
>>> data.write.format("delta").mode("overwrite").save("/book/testShell")
>>>
这里我们看到了创建range()
的语句,然后是写入语句。我们可以看到Spark的执行器正在运行。当您打开输出目录时,您将找到生成的Delta表(关于Delta表格式的更多细节将在下一节中介绍)。
在Spark Scala Shell中运行Delta Lake
您还可以在交互式Spark Scala shell中运行Delta Lake。根据Delta Lake Quickstart中的说明,您可以使用以下方式启动Scala shell,并添加Delta Lake包:
css
spark-shell --packages io.delta:<delta_version>
--conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension"
--conf "spark.sql.catalog.spark_catalog=
org.apache.spark.sql.delta.catalog.DeltaCatalog"
这将启动交互式Scala shell:
vbnet
Spark context Web UI available at http://host.docker.internal:4040
Spark context available as 'sc' (master = local[*],
app id = local-1665950762666).
Spark session available as 'spark'.
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 3.2.2
/_/
Using Scala version 2.12.15 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_311)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
在Shell中,您现在可以运行交互式的Scala命令。让我们在Scala上进行类似的测试,就像您在PySpark Shell中所做的一样:
kotlin
val data = spark.range(0, 10)
data.write.format("delta").mode("overwrite").save("/book/testShell")
以下是完整的运行示例:
kotlin
cala> val data = spark.range(0, 10)
data: org.apache.spark.sql.Dataset[Long] = [id: bigint]
scala> data.write.format("delta").mode("overwrite").save("/book/testShell")
同样,当您检查输出时,您会发现生成的Delta表。
在Databricks上运行Delta Lake
对于本书后面的示例,选择了Databricks社区版来运行Delta Lake。选择这个平台来开发和运行代码示例,因为它是免费的,简化了Spark和Delta Lake的设置,不需要您自己的云账户,也不需要提供云计算或存储资源。使用Databricks社区版,用户可以访问一个具有完整笔记本环境和已安装Delta Lake的最新运行时的集群。
如果您不想在本地计算机上运行Spark和Delta Lake,还可以在云平台上的Databricks上运行Delta Lake,例如Azure、AWS或Google Cloud。这些环境使得开始使用Delta Lake变得更加容易,因为它们已经安装了Delta Lake的版本。
云的额外好处是您可以创建具有任意大小的真正的Spark集群,潜在地可以跨越数百个节点,拥有成千上万个核心,用于处理几十TB或PB级别的数据。
在云中使用Databricks有两种选项。您可以使用其受欢迎的笔记本,也可以使用Databricks实验室的开源工具dbx,将您喜欢的开发环境连接到云上的Databricks集群。 dbx是由Databricks实验室提供的工具,允许您从编辑环境连接到Databricks集群。
创建和运行一个Spark程序:helloDeltaLake
安装delta-spark包后,创建你的第一个PySpark程序非常简单。按照以下步骤创建PySpark程序:
- 创建一个新文件(我们命名为helloDeltaLake.py)。
- 添加必要的导入语句。至少需要导入PySpark和Delta Lake:
javascript
import pyspark
from delta import *
接下来,创建一个SparkSession builder,加载Delta Lake的扩展,如下所示:
makefile
# Create a builder with the Delta extensions
builder = pyspark.sql.SparkSession.builder.appName("MyApp") \
.config("spark.sql.extensions", \
"io.delta.sql.DeltaSparkSessionExtension") \
.config("spark.sql.catalog.spark_catalog", \
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
接下来,我们可以创建SparkSession对象本身。我们将创建SparkSession对象并打印出其版本,以确保对象有效:
python
# Create a Spark instance with the builder
# As a result, you now can read and write Delta tables
spark = configure_spark_with_delta_pip(builder).getOrCreate()
print(f"Hello, Spark version: {spark.version}")
为了验证我们的Delta Lake扩展是否正常工作,我们创建了一个范围并以Delta Lake格式写入它:
python
# Create a range, and save it in Delta Lake format to ensure
# that your Delta Lake extensions are indeed working
df = spark.range(0, 10)
df.write \
.format("delta") \
.mode("overwrite") \
.save("/book/chapter02/helloDeltaLake")
这样就完成了您的入门程序的代码。您可以在书的代码存储库的/chapter02/helloDeltaLake.py位置找到完整的代码文件。如果您想编写自己的代码,这个代码是一个很好的起点。
要运行程序,我们只需在Windows上启动命令提示符,或在MacOS上启动终端,并导航到包含我们代码的文件夹。然后,我们可以使用以下命令启动PySpark,将程序作为输入:
pyspark < helloDeltaLake.py
当我们运行程序时,我们会得到我们的Spark版本输出(显示的版本将取决于读者安装的Spark版本):
yaml
Hello, Spark version: 3.4.1
当我们查看输出时,我们可以看到我们已经写了一个有效的Delta表。 Delta Lake格式的详细信息将在下一节中介绍。
在这一点上,我们已经成功安装了PySpark和Delta Lake,并且能够编写和运行一个具有Delta Lake扩展的完整的PySpark程序。既然您可以运行自己的程序,我们准备好在下一节中详细探讨Delta Lake格式。
Delta Lake格式
在本节中,我们将深入探讨Delta Lake的开放式表格格式。当我们使用这种格式保存文件时,实际上是在写入具有额外元数据的标准Parquet文件。这个额外的元数据是启用Delta Lake核心功能的基础,甚至可以执行传统关系数据库管理系统(RDBMS)中通常看到的DML操作,如INSERT、UPDATE和DELETE,以及众多其他操作。
由于Delta Lake将数据写出为Parquet文件,我们将更深入地研究Parquet文件格式。我们首先会写出一个简单的Parquet文件,并详细查看Spark写入的相关内容。这将让我们对本书中要使用的文件有一个很好的理解。
接下来,我们将以Delta Lake格式写出一个文件,注意到它触发了创建包含事务日志的"_delta_log"目录。我们将详细研究这个事务日志以及它是如何用于生成单一数据源的。我们将看到事务日志是如何实现第1章中提到的ACID原子性属性。
我们将看到Delta Lake是如何将一个事务分解为单独的原子提交操作,并将这些操作记录在事务日志中,以有序、原子单位的方式。最后,我们将研究几种用例,并调查写入了哪些Parquet数据文件和事务日志条目,以及这些条目中存储了什么内容。
由于每个事务都会写入一个事务日志条目,可能会导致出现多个小文件。为确保这种方法仍然可扩展,Delta Lake将每隔10个事务(在撰写本文时)创建一个检查点文件,其中包含完整的事务状态。这样,Delta Lake读取器只需处理检查点文件和随后写入的少量事务条目。这实现了一个快速、可扩展的元数据系统。
Parquet文件
Apache Parquet文件格式是过去20年来最流行的大数据格式之一。Parquet是开源的,因此它可以在Apache Hadoop许可下免费使用,并与大多数Hadoop数据处理框架兼容。
与基于行的格式(如CSV或Avro)不同,Parquet是一种面向列的格式,这意味着每个列/字段的值都存储在一起,而不是在每个记录中。图2-1显示了基于行的布局和面向列的布局之间的差异,以及如何在逻辑表中表示这些差异。
图2-1展示了与行布局不同的是,列布局按列值的顺序依次存储,这种列格式有助于逐列进行压缩。此格式还支持灵活的压缩选项和可扩展的数据类型编码模式,这意味着可以使用不同的编码来压缩整数和字符串数据类型。
Parquet文件由行组和元数据组成。行组包含来自同一列的数据,因此每列都存储在同一个行组中。Parquet文件中的元数据不仅包含有关这些行组的信息,还包含有关列(例如最小/最大值、值的数量)和数据模式的信息,这使Parquet成为一个具有附加元数据以支持更好数据跳跃的自描述文件。
图2-2显示了Parquet文件由行组和元数据组成。每个行组包括数据集中的每一列的列块,而每个列块由一个或多个包含列数据的页组成。要深入了解Parquet文件格式的更多文档,请访问Apache Parquet的网站和文档。
Parquet文件的优势
由于其面向列的格式、存储布局、元数据和长期受欢迎,Parquet文件在分析工作负载和处理大数据时具有以下几个强大的优势:
- 高性能
Parquet文件是一种面向列的格式,它们能够更好地进行压缩和编码,因为这些算法可以利用每一列中存储的相似值和数据类型。对于I/O密集型操作,这种压缩数据可以显著提高性能。
在Parquet文件的情况下,当列值一起存储时,查询只需读取查询所需的列,而不需要在基于行的格式中读取所有列。这意味着列格式可以减少需要读取的操作数据量,从而提高性能。
Parquet文件中包含的元数据描述了数据的一些特征,其中包括有关行组、数据模式以及最重要的列的信息。列元数据包括最小/最大值和值的数量等信息。这些元数据一起减少了每个操作所需读取的数据量(即数据跳跃),从而实现更好的查询性能。
- 经济高效
由于Parquet文件能够更好地利用压缩和编码,这使得数据本身更加经济高效。压缩后的数据在存储文件时占用更少的磁盘空间,从而降低了存储空间和存储成本。
- 互操作性
由于Parquet文件在过去20年中非常流行,特别是对于传统大数据处理框架和工具(例如Hadoop),它们得到了广泛支持,并提供了出色的互操作性。
创建一个Parquet文件
在图书存储库中,位于/chapter02/writeParquetFile的Python程序在内存中创建一个Spark DataFrame,并使用标准的PySpark API以Parquet格式将其写入/parquetData文件夹。
kotlin
data = spark.range(0, 100)
data.write.format("parquet") \
.mode("overwrite") \
.save('/book/chapter02/parquetData')
在我们的情况下,当我们查看写入磁盘的内容时,我们看到以下内容(根据您的本地机器不同,您可能会看到不同的结果):
dart
Directory of C:\book\chapter02\parquetData
10/17/2022
10/17/2022 511 part-00000-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00001-a3885270-...-c000.snappy.parquet
10/17/2022 517 part-00002-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00003-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00004-a3885270-...-c000.snappy.parquet
10/17/2022 517 part-00005-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00006-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00007-a3885270-...-c000.snappy.parquet
10/17/2022 517 part-00008-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00009-a3885270-...-c000.snappy.parquet
10/17/2022 513 part-00010-a3885270-...-c000.snappy.parquet
10/17/2022 517 part-00011-a3885270-...-c000.snappy.parquet
一个刚接触大数据领域的开发者此时可能会感到有些震惊。我们只写入了100个数字,为什么最终会得到12个Parquet文件呢?这需要进行一些详细说明。
首先,我们在写入操作中指定的文件名实际上是一个目录名,而不是文件名。正如您所见,目录/parquetData 包含了12个Parquet文件。
当我们查看 .parquet 文件时,可能会看到我们有12个文件。Spark是一个高度并行的计算环境,系统试图让Spark集群中的每个CPU核心保持繁忙。在我们的情况下,我们在本地机器上运行,这意味着我们的集群中只有一台机器。当我们查看系统的信息时,我们可以看到我们有12个核心。
当我们查看写入的.parquet文件的数量时,我们会发现我们有12个文件,这与我们集群中的核心数相等。这是Spark在这种情况下的默认行为。文件的数量将等于可用核心的数量。假设我们向代码中添加以下语句:
python
data = spark.range(0, 100)
data.write.format("parquet") \
.mode("overwrite") \
.save('/book/chapter02/parquetData')
print(f"The number of partitions is: {data.rdd.getNumPartitions()}")
从输出中我们可以看到,的确有12个文件:
csharp
The number of partitions is: 12
尽管在仅写入100个数字的情况下可能看起来有些过于复杂,但可以想象在读取或写入非常大的文件时,将文件拆分并并行处理可以显著提高性能。
在输出中看到的 .crc 文件是循环冗余校验文件。Spark使用它们来确保数据没有被损坏。这些文件通常非常小,因此与它们提供的实用性相比,它们的开销非常小。虽然有一种方法可以关闭生成这些文件,但我们不建议这样做,因为它们的好处远远超过开销。
输出中的最后两个文件是 _SUCCESS 和 _SUCCESS.crc 文件。Spark使用这些文件来提供一种确认所有分区都已正确写入的方法。
创建Delta表
到目前为止,我们一直在使用Parquet文件。现在,让我们将前一节中的第一个示例保存为Delta Lake格式,而不是Parquet(代码:/chapter02/writeDeltaFile.py)。我们只需要将代码中的Parquet格式替换为Delta格式,如下所示:
python
data = spark.range(0, 100)
data.write \
.format("delta") \
.mode("overwrite") \
.save('/book/chapter02/deltaData')
print(f"The number of filesis: {data.rdd.getNumPartitions()}")
我们得到相同数量的分区:
csharp
The number of files is: 12
当我们查看输出时,我们会看到添加了 _delta_log 文件:
dart
Directory of C:\book\chapter02\deltaData
10/17/2022 16 .part-00000-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00001-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00002-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00003-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00004-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00005-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00006-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00007-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00008-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00009-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00010-...-c000.snappy.parquet.crc
10/17/2022 16 .part-00011-...-c000.snappy.parquet.crc
10/17/2022 524 part-00000-...-c000.snappy.parquet
10/17/2022 519 part-00001-...-c000.snappy.parquet
10/17/2022 523 part-00002-...-c000.snappy.parquet
10/17/2022 519 part-00003-...-c000.snappy.parquet
10/17/2022 519 part-00004-...-c000.snappy.parquet
10/17/2022 522 part-00005-...-c000.snappy.parquet
10/17/2022 519 part-00006-...-c000.snappy.parquet
10/17/2022 519 part-00007-...-c000.snappy.parquet
10/17/2022 523 part-00008-...-c000.snappy.parquet
10/17/2022 519 part-00009-...-c000.snappy.parquet
10/17/2022 519 part-00010-...-c000.snappy.parquet
10/17/2022 523 part-00011-...-c000.snappy.parquet
10/17/2022 <DIR> _delta_log
24 File(s) 6,440 bytes
_delta_log 文件包含了对数据执行的每个操作的事务日志。
Delta Lake事务日志
Delta Lake事务日志(也称为DeltaLog)是自Delta Lake表创建以来记录在表上执行的每个事务的顺序记录。它对Delta Lake的功能非常关键,因为它是其重要特性的核心,包括ACID事务、可扩展的元数据处理和时间旅行功能。
事务日志的主要目标是允许多个读取器和写入器同时在给定版本的数据文件上操作,并为执行引擎提供额外的信息,如数据跳过索引,以进行更高性能的操作。Delta Lake事务日志始终向用户显示数据的一致视图,并充当单一的真相来源。它是跟踪用户对Delta表所做的所有更改的中央存储库。
当Delta表读取器首次读取Delta表或在上次读取后已被修改的开放文件上运行新查询时,Delta Lake会查看事务日志以获取表的最新版本。这确保了用户文件的版本始终与最近查询时的主记录同步,并且用户不能对文件进行分歧和冲突的更改。
事务日志如何实现原子性
在第一章中,我们了解到原子性保证了对文件执行的所有操作(例如,INSERT、UPDATE、DELETE或MERGE)要么完全成功,要么完全不成功。如果没有原子性,任何硬件故障或软件错误都可能导致数据文件部分写入,从而导致数据损坏或至少是无效的数据。
事务日志是Delta Lake提供原子性保证的机制。事务日志还负责处理元数据、时间旅行以及大型表格数据集的大幅提高元数据操作速度。
事务日志是自创建以来对Delta表格执行的每个事务的有序记录。它充当单一的真相来源,并跟踪对表格所做的所有更改。事务日志使用户能够推断他们的数据,并信任其完整性和质量。简单的规则是,如果一个操作没有记录在事务日志中,那么它就从未发生过。在接下来的章节中,我们将通过几个示例来阐明这些原则。
将事务拆分成原子提交
每当您执行一组操作来修改表格或存储文件(例如插入、更新、删除或合并操作),Delta Lake 将将该操作分解为一系列原子的、离散的步骤,由表2-1中显示的一个或多个操作组成。
这些操作以有序的原子单位(称为提交)的形式记录在事务日志条目(*.json)中。这类似于Git源代码控制系统跟踪更改的方式,以原子提交的形式。这也意味着您可以重放事务日志中的每个提交以获得文件的当前状态。 例如,如果用户创建一个事务来向表中添加新列,然后添加数据到该列,Delta Lake会将此事务分解为其组成操作部分,并一旦事务完成,将它们作为以下提交添加到事务日志中:
- 更新元数据:更改模式以包括新列。
- 添加文件:为每个新添加的文件。
文件级别的事务日志
当您写入一个Delta表时,该文件的事务日志会自动创建在 _delta_log 子目录中。随着您继续对Delta表进行更改,这些更改将自动记录为有序的原子提交在事务日志中。每个提交都以JSON文件的形式写入,从0000000000000000000.json开始。如果您对文件进行额外的更改,Delta Lake将按升序生成附加的JSON文件,因此下一个提交将被写入为0000000000000000001.json,下一个为0000000000000000002.json,依此类推。 在本章的其余部分,出于可读性的目的,我们将使用事务日志条目的缩写形式。而不是显示长达19位的数字,我们将使用长达5位的缩写形式(因此您将使用00001.json而不是更长的记法)。 此外,我们将缩短Parquet文件的名称。这些名称通常如下所示: part-00007-71c70d7f-c7a8-4a8c-8c29-57300cfd929b-c000.snappy.parquet 为了演示和解释,我们将将这样的名称缩写为part-00007.parquet,省略GUID和snappy.parquet部分。 在我们的示例可视化中,我们将以动作名称和受影响的数据文件名称来可视化每个事务条目;例如,在图2-3中,我们在一个单一的事务文件中有一个删除(文件)操作和另一个添加(文件)操作。
对同一文件进行多次写入
在本节中,我们将使用一组图表,详细描述每个代码步骤。对于每个步骤,我们显示以下信息:
- 实际的代码片段显示在第二列。
- 在代码片段旁边,我们显示作为代码片段执行结果的Parquet数据文件。
- 在最后一列中,我们显示事务日志条目的JSON文件。我们为每个事务日志条目显示操作和受影响的Parquet数据文件名称。
对于这个第一个示例,您将使用图书存储库中的chapter02/MultipleWriteOperations.py来展示对同一文件进行多次写入。
以下是图2-4中不同步骤的逐步说明: 首先,将新的Delta表写入路径。一个Parquet文件已写入到输出路径(part-00000.parquet)。第一个事务日志条目(00000.json)已在_delta_log目录中创建。由于这是文件的第一个事务日志条目,记录了一个元数据操作和一个添加文件操作,指示添加了一个分区文件。 接下来,我们向表中追加数据。我们可以看到已写入一个新的Parquet文件(part-00001.parquet),并在事务日志中创建了一个附加的条目(00001.json)。与第一步类似,该条目包含一个添加文件操作,因为我们添加了一个新文件。 我们再次追加更多数据。再次写入一个新的数据文件(part-00002.parquet),并向事务日志中添加了一个新的事务日志文件(00002.json),包含一个添加文件操作。
请注意,每个事务日志条目还将包含一个提交信息操作,其中包含了事务的审计信息。出于可读性的目的,我们在图表中省略了提交信息日志条目。
写操作的操作顺序非常重要。对于每个写操作,数据文件始终首先被写入,只有在该操作成功完成时,才会向_delta_log文件夹添加一个事务日志文件。只有当事务日志条目成功写入时,事务才被视为已完成。
读取Delta表的最新版本
当系统读取Delta表时,它将遍历事务日志以"编译"表格的当前状态。读取文件时的事件顺序如下: 首先读取事务日志文件。 基于日志文件的信息,读取数据文件。 接下来,我们将描述先前示例(multipleWriteOperations.py)中写入的Delta表的顺序。Delta将读取所有日志文件(00000.json、00001.json和00002.json)。它将根据日志信息读取三个数据文件,如图2-5所示。
请注意,操作的顺序还意味着在事务日志中不再引用的数据文件可能存在。实际上,在更新或删除的情况下,这是一个常见的情况。Delta Lake不会删除这些数据文件,因为如果用户使用Delta Lake的时间旅行功能(在第6章中介绍),这些文件可能会再次被需要。您可以使用VACUUM命令来删除旧的、过时的数据文件(也在第6章中介绍)。
写操作中的故障场景
接下来,让我们看看如果写操作失败会发生什么。在前面的写入场景中,假设图2-4中的第3步写入操作在中途失败。可能已经写入了部分Parquet文件,但事务日志条目00002.json尚未写入。这将导致图2-6中所示的情景。
正如您在图2-6中所看到的,最后一个事务文件丢失。根据之前指定的读取顺序,Delta Lake将读取第一个和第二个JSON事务文件,以及相应的part-00000和part-00001 Parquet文件。Delta Lake的读取器将不会读取不一致的数据;它将通过前两个事务日志文件读取一个一致的视图。
更新场景
最后一个场景包含在chapter02/UpdateOperation.py代码库中。为了保持简单,我们有一个包含患者信息的小型Delta表。我们只跟踪每位患者的患者ID和患者姓名。在这个用例中,我们创建一个包含四名患者的Delta表,每个文件中有两名患者。接下来,我们从另外两名患者添加数据。最后,我们更新了第一名患者的姓名。正如您将看到的,这次更新产生了比预期更大的影响。完整的更新场景如图2-7所示。
在这个示例中,我们执行以下步骤:
- 第一个代码片段创建了一个Spark DataFrame,其中包含四名患者的患者ID和姓名。我们使用
.coalesce(2)
将DataFrame写入一个Delta表,强制数据写入两个文件。结果,我们写入了两个文件。一旦part-00000.parquet和part-00001.parquet文件被写入,就创建了一个事务日志条目(00000.json)。请注意,事务日志条目包含两个添加文件操作,指示添加了part-00000.parquet和part-00001.parquet文件。 - 接下来的代码片段追加了另外两名患者(P5和P6)的数据。这导致了part-00002.parquet文件的创建。同样,一旦文件被写入,就会写入事务日志条目(00001.json),事务就完成了。再次请注意,事务日志文件有一个添加文件操作,指示添加了一个文件(part-00002.parquet)。
- 代码执行一个更新操作。在这种情况下,我们想要将患者ID为1的患者的姓名从P1更新为P11。目前,患者ID为1的记录存在于part-0中。为执行更新,读取part-0并使用映射操作符来更新任何匹配患者ID为1的P1记录为P11。一个新文件被写入为part-3。最后,Delta Lake写入了事务日志条目(00002.json)。请注意,它写入了一个移除文件操作,表示移除了part-0文件,以及一个添加操作,表示添加了part-3文件。这是因为来自part-0的数据被重写到part-3中,而所有已修改的行(以及未修改的行)都被添加到了part-3中,使part-0文件变得过时。 请注意,Delta Lake不会删除part-0文件,因为用户可能希望通过时间旅行回到过去,而在这种情况下,文件是必需的。VACUUM命令可以清理未使用的文件,如此操作详细介绍在第6章中。
现在我们已经看到了在更新期间数据是如何写入的,让我们看看在读取中如何确定要读取的内容,如图2-8所示。
读取将按以下方式进行:
- 首先读取第一个事务日志条目(00000.json)。该条目告诉Delta Lake包括part-0和part-1文件。
- 接下来读取下一个条目(00001.json),告诉Delta Lake包括part-2文件。
- 最后读取最后一个条目(00002.json),通知读取器移除part-0文件并包括part-3。
结果,读取器最终会读取part-1、part-2和part-3,得到了图2-8中所示的正确数据。
扩展大规模元数据
现在我们已经看到事务日志如何记录每个操作,我们可以有许多非常大的文件,其中包含数千个事务日志条目,针对单个Parquet文件。Delta Lake如何扩展其元数据处理,而不需要读取数千个小文件,这将对Spark的读取性能产生负面影响?Spark在读取大文件时效果最佳,那么我们该如何解决这个问题?
一旦Delta Lake写入程序将提交记录到事务日志,它将在"_delta_log"文件夹中以Parquet格式保存一个检查点文件。Delta Lake写入程序将在每10次提交后继续生成一个新的检查点。检查点文件保存了表在特定时间点的整个状态。注意,"状态"指的是不同的操作,而不是文件的实际内容。因此,它将包含添加文件、删除文件、更新元数据、提交信息等操作,以及所有上下文信息。它将以原生Parquet格式保存这个列表。这将允许Spark快速读取检查点。这为Spark读取器提供了一个"快捷方式",可以完全重现表的状态,避免重新处理数千个小的JSON文件,这可能效率低下。
检查点文件示例
以下是一个示例(如图2-9所示),我们执行多次提交,结果生成了一个检查点文件。该示例使用了书籍存储库中的代码文件chap02/TransactionLogCheckPointExample.py。
这个示例包括以下步骤:
第一段代码创建一个标准的Spark DataFrame,其中包含多个病人的数据。请注意,我们对DataFrame应用了coalesce(1)事务,将数据强制放入一个分区。
接下来,我们将DataFrame以Delta Lake格式写入存储文件。我们验证只有一个part-0001.parquet文件被写入。我们还看到在_delta_log目录中创建了一个单个事务日志条目(00000.json)。该目录条目包含了part-00001.parquet文件的添加文件操作。
在接下来的步骤中,我们设置一个循环,循环次数为range(0, 9),将创建一个新的病人行,然后从该元组创建一个DataFrame,并将DataFrame写入存储文件。由于循环了九次,我们创建了九个额外的Parquet文件,从part-00001.parquet到part-00009.parquet。我们还看到了九个额外的事务日志条目,从00001.json到00009.json。
在第3步中,我们创建了一个额外的病人元组,将其转换为DataFrame,并将其写入Delta表。这创建了一个额外的数据文件(part-00010.parquet)。事务日志中有一个标准的日志条目(00010.json),包含了part-00010.parquet文件的添加文件操作。但有趣的事实是它还创建了一个000010.checkpoint.parquet文件。这就是前面提到的检查点。每10次提交会生成一个检查点。这个Parquet文件以原生Parquet格式包含了在提交时表的整个状态。
在最后一步,代码生成了两次提交,创建了part-00011.parquet和part-00012.parquet,以及两个新的日志条目,这些条目指向这些文件。
如果Delta Lake需要重新创建表的状态,它将简单地读取检查点文件(000010.checkpoint.parquet),然后重新应用两个额外的日志条目(00011.json和00012.json)。
显示检查点文件
既然我们已经生成了checkpoint.parquet文件,让我们使用/chapter02/readCheckPointFile.py Python文件来查看它的内容:
ini
# Set your output path for your Delta table
DATALAKE_PATH = "/book/chapter02/transactionLogCheckPointExample"
CHECKPOINT_PATH = "/_delta_log/00000000000000000010.checkpoint.parquet"
# Read the checkpoint.parquet file
checkpoint_df = \
spark \
.read \
.format("parquet") \
.load(f"{DATALAKE_PATH}{CHECKPOINT_PATH}")
# Display the checkpoint dataframe
checkpoint_df.show()
请注意,我们在这里进行的是Parquet格式的读取,因为检查点文件确实是以Parquet格式存储的,而不是Delta格式。 checkpoint_df DataFrame的内容如下所示:
dart
+----+--------------------+------+--------------------+--------+
| txn| add|remove| metaData|protocol|
+----+--------------------+------+--------------------+--------+
|null|{part-00000-f7d9f...| null| null| null|
|null|{part-00000-a65e0...| null| null| null|
|null|{part-00000-4c3ea...| null| null| null|
|null|{part-00000-8eb1f...| null| null| null|
|null|{part-00000-2e143...| null| null| null|
|null|{part-00000-d1d13...| null| null| null|
|null|{part-00000-650bf...| null| null| null|
|null|{part-00000-ea06e...| null| null| null|
|null|{part-00000-79258...| null| null| null|
|null|{part-00000-23558...| null| null| null|
|null| null| null| null| {1, 2}|
|null| null| null|{376ce2d6-11b1-46...| null|
|null|{part-00000-eb29a...| null| null| null|
+----+--------------------+------+--------------------+--------+
正如您所见,检查点文件包含了不同操作(添加、移除、元数据和协议)的列。我们看到了不同Parquet数据文件的添加文件操作,以及当我们创建Delta表时的更新元数据操作,以及初始Delta表写入导致的协议更改操作。 请注意,DataFrame.show()不会按顺序显示DataFrame记录。协议更改和更新元数据记录总是检查点文件中的第一条记录,然后是不同的添加文件操作。
总结
在我们开始探索Delta Lake的旅程时,一切都始于初始设置。本章介绍了如何在本地计算机上使用PySpark和Spark Scala shell设置Delta Lake,同时涵盖了必要的库和软件包,以使您能够运行带有Delta Lake扩展的PySpark程序。您还可以使用像Databricks这样的基于云的工具来简化此设置过程,以开发、运行和共享基于Spark的应用程序,例如Delta Lake。
在了解如何启动Delta Lake之后,我们开始学习Delta Lake的基本组件,这些组件无疑支持了本书中我们将讨论的大多数核心功能。通过添加检查点文件以实现可伸缩的元数据和将标准Parquet文件添加到事务日志以支持ACID事务,Delta Lake具备了支持可靠性和可扩展性的关键元素。既然我们已经建立了这些基本组件,您将在下一章中学习有关Delta表上的基本操作。