Delta Lake权威指南——使用Delta Lake构建本地应用程序

Delta Lake 在 Java 平台上创建,但自从该协议开源后,它已经用多种不同的语言实现,这为在本地应用程序中使用 Delta Lake 提供了新的机会,而无需依赖 Apache Spark。在最初基于 Spark 的库之后,最成熟的 Delta Lake 协议实现是 delta-rs,它为 Python 和 Rust 用户提供了 deltalake 库。

在本章中,您将学习如何使用这些库构建基于 Python 或 Rust 的应用程序来加载、查询和写入 Delta Lake 表。同时,我们将回顾一些支持 Delta Lake 的 Python 和 Rust 生态系统中的工具,这为构建数据应用程序提供了极大的灵活性和性能。与基于 Spark 的库不同,deltalake 库没有特定的基础设施要求,可以轻松地在命令行、Jupyter Notebook、AWS Lambda 或其他可以执行 Python 或编译过的 Rust 程序的环境中运行。这种极高的可移植性带来了一个权衡:没有"集群",因此本地的 Delta Lake 应用程序通常不能超越单台机器的计算或内存资源进行扩展。

为了演示这种"低开销"方法在利用 Delta Lake 时的实用性,本章将指导您创建一个 AWS Lambda,它将通过触发器接收新数据,查询现有的 Delta Lake 表以丰富其数据,并将新的结果存储在一个新的银层 Delta Lake 表中。AWS Lambda 的定价模型鼓励短时间执行和低内存使用,这使得 deltalake 成为构建快速且低成本数据应用程序的强大工具。尽管本章中的示例运行在 AWS 上,Python 和 Rust 的 deltalake 库支持多个不同的存储后端,包括 Azure 和 Google Cloud Platform 等云提供商,或 MinIO、HDFS 等本地工具。

注意:

本章将不包括开发和部署 Lambda 函数的通用要求。如需了解更多,请查阅 AWS 文档,了解如何构建 Python Lambda 或 Rust Lambda。

开始使用

要开发本地 Delta Lake 应用程序,您需要在构建 Python 应用程序时安装 Python 3。您的工作站很可能已经预装了 Python 3,或者它作为"开发工具包"包的一部分可以轻松获取。而 Rust 工具链仅在构建基于 Rust 的 Delta Lake 应用程序时才是必要的。Rust 应该按照官方文档来安装,包括安装编译器和相关工具,如 cargo。

Python 示例

这个示例将在您的工作站的终端中开发,使用 virtualenv 来管理 Lambda 函数的项目特定依赖:

shell 复制代码
% cd ~/dldg        # 选择您想要的目录
% virtualenv venv  # 配置一个 Python 虚拟环境来管理依赖
                   # 存储在 ./venv/ 目录中
% source ./venv/bin/activate  # 激活此 shell 中的虚拟环境

一旦虚拟环境被激活,可以使用 pip 安装 deltalake 包。同时,安装 pandas 包以进行数据查询也是很有帮助的。以下示例演示了一些基本的 deltalakepandas 调用,用于加载和显示一个在两个单独列(c1c2)之间分区的测试数据集,该数据集包含一系列数字:

python 复制代码
% pip install 'deltalake>=0.18.2' pandas
% python
>>> from deltalake import DeltaTable
>>> dt = DeltaTable('./deltatbl-partitioned')
>>> dt.files()
['c2=foo0/part-00000-2bcc9ff6-0551-4401-bd22-d361a60627e3.c000.snappy.parquet', 'c2=foo1/part-00000-786c7455-9587-454f-9a4c-de0b22b62bbd.c000.snappy.parquet', 'c2=foo0/part-00001-ca647ee7-f1ad-4d70-bf02-5d1872324d6f.c000.snappy.parquet', 'c2=foo1/part-00001-1c702e73-89b5-465a-9c6a-25f7559cd150.c000.snappy.parquet']
>>> df = dt.to_pandas()
>>> df
   c1    c2
0   0  foo0
1   2  foo0
2   4  foo0
3   1  foo1
4   3  foo1
5   6  foo0
6   8  foo0
7   5  foo1
8   7  foo1
9   9  foo1

借助 to_pandas() 函数,从 Delta Lake 表中读取数据非常简单,它会从 DeltaTable 中加载数据并生成一个 DataFrame,您可以使用这个 DataFrame 进一步查询或检查存储在 Delta Lake 表中的数据。使用 Pandas DataFrame 后,您可以在终端或笔记本中进行广泛的数据分析;如果您想了解更多关于 Pandas 的内容,可以参考《Python for Data Analysis》(O'Reilly)。

尽管使用 Pandas 开始非常简单,但在读取大数据集时,to_pandas() 函数也有一些局限性;这些将在下一节中讨论。

读取大数据集

使用 Pandas 和 Delta Lake 是从终端中开始探索数据的好方法。在前述 to_pandas() 函数调用的背后,Python 进程必须执行以下操作:

  1. 收集必要数据文件的引用------本质上是 dt.files() 返回的 .parquet 文件。
  2. 从存储中检索这些数据文件(在这个示例中是本地文件系统)。
  3. 反序列化并将这些数据文件加载到内存中。
  4. 使用内存中加载的数据构建 pandas.DataFrame 对象。

步骤 2 和 3 在数据量增长时会遇到扩展性问题。现代工作站通常有大量内存,加载几 GB 的数据到内存中通常不成问题,但数据的检索却可能成为瓶颈。例如,如果 Delta Lake 表存储在 AWS S3 上,而您的 Python 终端运行在笔记本电脑上,加载几 GB 的数据通过咖啡店的 WiFi 网络可能非常耗时,尤其是在您不打算查询整个表时,这样做也是不必要的。

Delta Lake 的设计提供了几种机制来减少必须加载的数据大小,从而帮助加速查询并提高效率:

  • 分区(Partitions)
    将数据按公共前缀进行分区,允许通过类似 mytable/year=2024/*.parquet 这样的结构进行文件分组。
  • 文件统计(File statistics)
    在事务日志中由写入者附加的额外元数据,指示 .parquet 文件中列的最小值或最大值等信息,不论是使用 Apache Spark 还是本地的 Python/Rust 代码。

通过减少执行查询时需要加载的文件数量,分区和文件统计帮助减少执行时间,使结果生成速度更快,这也缩短了开发人员的迭代时间,并使数据处理工作负载更便宜。以下示例将使用这些特性来减少从存储加载的文件数量,同时减少在 Pandas 中处理 Delta Lake 表时所需的内存。

注意

Delta 协议还提供了许多其他设计优化,以提高操作效率,如检查点、压缩和 Z-Ordering。 这些特性在本地的 Python 和 Rust 库中得到了支持,在本书的其他部分有讨论,本章不会专门介绍这些内容。

分区(Partitions)

将数据按公共前缀进行分区是多个存储系统共享的模式,包括 Delta Lake。这种分区通常被称为 "hive-style"(Hive 风格分区)。to_pandas() 函数允许您通过可选参数 partitions 来指定分区。考虑以下示例表的布局:

ini 复制代码
deltatbl-partitioned
├── c2=foo0
│   ├── part-00000-2bcc9ff6-0551-4401-bd22-d361a60627e3.c000.snappy.parquet
│   └── part-00001-ca647ee7-f1ad-4d70-bf02-5d1872324d6f.c000.snappy.parquet
├── c2=foo1
│   ├── part-00000-786c7455-9587-454f-9a4c-de0b22b62bbd.c000.snappy.parquet
│   └── part-00001-1c702e73-89b5-465a-9c6a-25f7559cd150.c000.snappy.parquet
└── _delta_log
    └── 00000000000000000000.json

该表具有一个分区列 c2,并定义了两个分区。如果只想处理第一个分区(foo0)中的数据,可以通过在 to_pandas() 调用中使用分区过滤器来限制仅加载指定的分区,如下所示:

css 复制代码
>>> dt.to_pandas(partitions=[('c2', '=', 'foo0')])
   c1    c2
0   0  foo0
1   2  foo0
2   4  foo0
3   6  foo0
4   8  foo0

如果数据集非常大,可以将相同的分区过滤器传递给 DeltaTablefiles() 函数,以低开销预览 to_pandas() 调用将加载的文件:

css 复制代码
>>> dt.files([('c2', '=', 'foo0')])
['c2=foo0/part-00000-2bcc9ff6-0551-4401-bd22-d361a60627e3.c000.snappy.parquet', 'c2=foo0/part-00001-ca647ee7-f1ad-4d70-bf02-5d1872324d6f.c000.snappy.parquet']

这个分区过滤器显示,仅需要加载两个 .parquet 文件,而不是整个表中的四个文件。这有助于减少从存储中检索数据文件的时间,但这些文件的内容仍然需要加载到内存中以构建 pandas.DataFrame

to_pandas() 函数还有两个其他可选参数,在尝试减少内存占用时需要考虑。最容易使用的是 columns,它简单地限制了从 .parquet 文件投影到 DataFrame 中的列。在这个示例中,c2 分区有助于减少从表中加载的数据量,但在 DataFrame 中并不需要它,可以通过 columns 参数将其排除:

css 复制代码
>>> dt.to_pandas(partitions=[('c2', '=', 'foo0')], columns=['c1'])
   c1
0   0
1   2
2   4
3   6
4   8

对于特别宽的表,这可以是一个有用的技巧,减少加载到内存中的数据量,并减少在终端中显示的数据量,使得结果更容易进行视觉检查。

另一个可选参数是 filters,它接受 DNF(析取范式)风格的过滤谓词,支持多种操作,如 <><=>==!=。以下代码段展示了如何将这些可选参数结合起来,生成一个紧凑的 DataFrame,只包含所需的数据:

css 复制代码
>>> dt.to_pandas(partitions=[('c2', '=', 'foo0')], columns=['c1'], 
filters=[('c1', '<=', 4), ('c1', '>', 0)])
   c1
0   2
1   4

to_pandas() 函数提供的语义并不能替代 Pandas DataFrame API 所提供的表达能力,但它为限制从 Delta Lake 表中检索并加载到内存中的数据量提供了一个非常有用的机制。在后面 "构建 Lambda" 一节中讨论的示例中,AWS Lambda 环境的资源限制非常适合快速且轻量的运行时,因此这些考虑都是非常重要的。

文件统计(File Statistics)

Delta 协议允许使用可选的文件统计信息,这些信息可以通过查询引擎进一步优化查询。当写入 .parquet 文件时,大多数写入程序会将这些附加的元数据放入 Delta 事务日志中,记录每一列的最小值和最大值。deltalake Python 库可以利用这些信息跳过不包含指定列值的文件。这对于具有可预测且顺序排列数据的附加-only 表(如每年一分区的表)尤为有用。

考虑一个按年份分区的数据集(例如,dataset3),其中每个分区内包含多个 Parquet 文件,事务日志包含如下条目:

sql 复制代码
{
  "add": {
    "path": "year=2022/0-ec9935aa-a154-4ba4-ab7e-92a53369c433-2.parquet",
    "partitionValues": {
      "year": "2022"
    },
    "size": 3025,
    "modificationTime": 1705178628881,
    "dataChange": true,
    "stats": "{"numRecords": 4, "minValues": {"month": 9, "decimal date": 2022.7083, "average": 415.74, "deseasonalized": 419.02, "ndays": 24, "sdev": 0.27, "unc": 0.1}, "maxValues": {"month": 12, "decimal date": 2022.9583, "average": 418.99, "deseasonalized": 419.72, "ndays": 30, "sdev": 0.57, "unc": 0.22}, "nullCount": {"month": 0, "decimal date": 0, "average": 0, "deseasonalized": 0, "ndays": 0, "sdev": 0, "unc": 0}}",
    "tags": null,
    "deletionVector": null,
    "baseRowId": null,
    "defaultRowCommitVersion": null,
    "clusteringProvider": null
  }
}

其中,stats 部分包含用于基于文件统计的优化相关信息。通过检查 minValuesmaxValues,可以得知 0-ec9935aa-a154-4ba4-ab7e-92a53369c433-2.parquet 文件只包含 2022 年 9 月到 12 月的数据。以下 Pandas 调用将创建一个只加载了这个特定文件的数据的 DataFrame,利用了分区列和月份列。文件统计帮助底层引擎避免加载 year=2022/ 分区中的每个文件,而只选择包含月份大于或等于 9 的文件,从而实现更快速、更高效的数据检索执行:

python 复制代码
>>> from deltalake import DeltaTable
>>> dt = DeltaTable('./data/gen/filestats')
>>> len(dt.files())
198
>>> df = dt.to_pandas(filters=[('year', '=', 2022), ('month', '>=', 9)])
>>> df
   year  month  decimal date  average  deseasonalized  ndays  sdev   unc
0  2022      9    2022.7083    415.91          419.36     28  0.41  0.15
1  2022     10    2022.7917    415.74          419.02     30  0.27  0.10
2  2022     11    2022.8750    417.47          419.44     25  0.52  0.20
3  2022     12    2022.9583    418.99          419.72     24  0.57  0.22

与直接加载 Delta Lake 表中的每个文件来生成 DataFrame 进行实验相比,使用过滤器利用 Delta 的分区和文件统计功能,只加载了存储中的一个文件。

Delta Lake 事务日志提供了大量的信息,deltalake 原生 Python 库利用这些信息来提供快速高效的表读取;更多示例可以在在线文档中找到。读取现有的 Delta Lake 表非常令人兴奋,但对于许多 Python 用户来说,编写 Delta Lake 表有助于在基于 Python 的数据分析或机器学习环境中解锁新的超能力。

写入数据(Writing Data)

许多在 Python 中进行数据分析或机器学习的示例,都以将数据从 CSV 或 TSV 格式的数据集加载到某种形式的 DataFrame(通常是 Pandas)中为起点。逗号分隔值(CSV)文件比较容易生成并且便于理解,可以在不同应用之间进行流式传输。CSV 数据集的主要缺点是,进行数据分析时,通常需要将数据完全加载到内存中,这在数据集非常大或加载速度较慢时会成为一个问题。

本节将使用与前面示例相同的小型(几十 KB)公开可用的 CSV 数据集,展示 Delta Lake 表格在 Python 中如何通过 deltalake 包轻松创建。该数据集包含由 NOAA 提供的年平均 CO2 浓度数据,并展示了使用 Delta Lake 进行数据操作的简便性。

有几种不同的方法可以写入 Delta Lake 表格,但本示例将集中展示通过 pandas.DataFrame 写入一个未分区的 Delta Lake 表格的简单用法:

yaml 复制代码
>>> import pandas as pd
>>> from deltalake import write_deltalake, DeltaTable
>>> df = pd.read_csv('./data/co2_mm_mlo.csv', comment='#')
>>> len(df)
790
>>> write_deltalake('./data/co2_monthly', df)
>>> dt = DeltaTable('./data/co2_monthly')
>>> dt.files()
['0-6db689af-10fe-4350-82e8-bef6d962330a-0.parquet']
>>> df = dt.to_pandas()
>>> df
     year  month  decimal date  average  deseasonalized  ndays  sdev   unc
0    1958      3     1958.2027   315.70          314.43     -1 -9.99 -0.99
1    1958      4     1958.2877   317.45          315.16     -1 -9.99 -0.99
2    1958      5     1958.3699   317.51          314.71     -1 -9.99 -0.99
3    1958      6     1958.4548   317.24          315.14     -1 -9.99 -0.99
4    1958      7     1958.5370   315.86          315.18     -1 -9.99 -0.99
..    ...    ...           ...      ...             ...    ...   ...   ...
785  2023      8     2023.6250   419.68          421.57     21  0.45  0.19
786  2023      9     2023.7083   418.51          421.96     18  0.30  0.14
787  2023     10     2023.7917   418.82          422.11     27  0.47  0.17
788  2023     11     2023.8750   420.46          422.43     21  0.91  0.38
789  2023     12     2023.9583   421.86          422.58     20  0.69  0.29

[790 rows x 8 columns]

生成的 Delta Lake 表格非常简单,只包含一个 .parquet 数据文件,因为源数据集的体积较小;对于数十兆字节或更大的数据集,deltalake 写入器在创建新事务时可能会生成多个数据文件。write_deltalake() 函数具有多个可选参数,可以实现更高级的功能,例如分区:

shell 复制代码
>>> df = pd.read_csv('./data/co2_mm_mlo.csv', comment='#')
>>> write_deltalake('./data/gen/co2_monthly_partitioned', data=df, partition_by=['year'])

该代码段将根据提供的 DataFrame 中的 year 列,使用 Hive 风格的分区写入新的 Delta Lake 表格。存储中的表格会按如下方式进行清晰的分区:

ini 复制代码
co2_monthly_partitioned
├── _delta_log
│   └── 00000000000000000000.json
├── year=1958
│   └── 0-50ffe4cc-864d-4753-8f47-b0b55618a31a-0.parquet
├── year=1959
│   └── 0-50ffe4cc-864d-4753-8f47-b0b55618a31a-0.parquet
├── year=1960
│   └── 0-50ffe4cc-864d-4753-8f47-b0b55618a31a-0.parquet

在此示例中,未压缩的数据集很小,完全可以加载到内存中,但对于更大的数据集,write_deltalake() 可以接受更大、加载方式更懒的数据集和迭代器,从而支持增量写入数据。

注意

要追加或覆盖数据,可以使用 mode 可选参数,目前支持以下几种模式:

  • error(默认值):如果表格已存在则返回错误
  • append:将提供的数据添加到表格中
  • overwrite:用提供的数据替换表格内容
  • ignore:如果表格已存在,则不写入表格,或者返回错误

能够轻松地写入 Delta Lake 表格,可以加速本地开发或模型训练;此外,还可以在 AWS Lambda 等环境中构建简单且快速的数据摄取应用,这将在本章后面讨论。

合并/更新(Merging/Updating)

DeltaTable 对象包含多个用于执行常见合并或更新任务的简单函数,如删除(delete)、合并(merge)和更新(update)。这些函数的使用方式与关系数据库中的删除、合并和更新操作类似,但在底层,Delta 事务日志会执行大量重要的工作,以追踪正在修改的数据。

例如,假设有一个 Delta Lake 表格,包含 100 行数据,存储在单个 first.parquet 文件中,并通过一次添加事务进行添加。随后执行一个删除操作,删除每隔一行的数据,这将生成一个新的 second.parquet 文件,包含 50 条记录。提交删除操作时,会在表格上创建一个新的事务,其中包含两个操作------一个是删除 first.parquet,另一个是添加 second.parquet

python 复制代码
>>> import pyarrow as pa
>>> from deltalake import DeltaTable, write_deltalake
>>> data = pa.table({'id': list(range(100))})  # 创建一个示例数据集
>>> write_deltalake('delete-test', data)
>>> dt = DeltaTable('delete-test')
>>> dt.version()
0
>>> dt.to_pandas().count()
Id     100
dtype: int64
>>> dt.delete('id % 2 == 0')  # 删除 id 为偶数的行
{'num_added_files': 1, 'num_removed_files': 1, 'num_deleted_rows': 50, 
 'num_copied_rows': 50, 'execution_time_ms': 35187, 'scan_time_ms': 33442, 
 'rewrite_time_ms': 1}
>>> dt.version()  # 新版本已创建
1

在执行 delete() 操作后,检查 ./delete-test/_delta_log/ 目录,可以看到两个事务条目;00000000000000000001.json 包含代表 delete() 操作的动作,详细展示了表格修改的具体工作原理:

json 复制代码
{
  "add": {
    "path": "part-00001-4bc82516-2371-4004-9ff8...c000.snappy.parquet",
    "partitionValues": {},
    "size": 799,
    "modificationTime": 1708794006394,
    "dataChange": true,
    "stats": "{"numRecords":50,"minValues":{"id":1},"maxValues":{"id":99},"nullCount":{"id":0}}",
    "tags": null,
    "deletionVector": null,
    "baseRowId": null,
    "defaultRowCommitVersion": null,
    "clusteringProvider": null
  }
}
{
  "remove": {
    "path": "0-2684b307-3947-49ce-bc07-02688b10a204-0.parquet",
    "dataChange": true,
    "deletionTimestamp": 1708794006394,
    "extendedFileMetadata": true,
    "partitionValues": {},
    "size": 1074
  }
}
{
  "commitInfo": {
    "timestamp": 1708794006394,
    "operation": "DELETE",
    "operationParameters": {
      "predicate": "id % 2 = 0"
    },
    "clientVersion": "delta-rs.0.17.0"
  }
}

上述代码片段包含了两个关键操作:removeadd,以及它们各自的文件。此外,还有一个名为 commitInfo 的操作,这在 Delta Lake 表格协议中是可选的,但它可能包含有关触发此事务的附加信息。在本例中,它描述了 DELETE 操作及其谓词,帮助我们理解为什么需要进行删除和添加操作。

无论操作是删除、更新还是合并,当 Delta Lake 表格中的数据发生变化时,通常会删除过时的 Parquet 文件,并创建包含修改数据的新 Parquet 文件。除了使用较新的删除向量(deletion vectors)功能外,这种情况通常适用。需要注意的是,Python 或 Rust 库目前不支持删除向量功能。

超越 Pandas

deltalake Python 包为多个不同的查询引擎和实现提供了对 Delta Lake 表格格式的支持。虽然本章使用 Pandas 库作为示例来展示读取、写入等操作,但也有其他库的集成支持 deltalake 包,例如 Polars 或 Datafusion。这些库为 Python 数据应用程序提供了强大的功能集。

基础库 pyarrow 将所有这些集成绑定在一起,并实现了共享抽象,例如 RecordBatchDataSetTable。在 deltalake 的文档中,有多个函数接受或返回这些对象。本章没有提供关于这些类型的详尽文档,这些类型的高级文档可以在 PyArrow 项目的在线 API 文档中找到。

RecordBatch

在 Python 中读取和写入 Delta Lake 表格的大多数内部操作都会创建或处理 RecordBatch 对象,这些对象表示长度相等的列的集合。Delta Lake 数据文件使用 Apache Parquet 格式,该格式是一种列式数据格式,而 RecordBatch 也是列式的。与其表达类似于 [1, 'Will', True], [2, 'Robert', True], [3, 'Ion', True] 这样的行,RecordBatch 类型通常会使用列来实例化,例如:[1, 2, 3], ['Will', 'Robert', 'Ion'], [True, True, True]

大多数应用程序可以使用建立在 RecordBatch 类型之上的 DataFrame,但通过直接理解和操作 RecordBatch 对象,有多种方式可以从 Python 数据应用中获得更高的性能或效率。

Table

pyarrow.Table 是一组命名的、等长的 Arrow 数组,它实际上就是大多数数据系统中常见的"表"的概念。作为模式加数据的容器,DeltaTable 对象可以通过 to_pyarrow_table() 函数直接暴露一个 PyArrow Table,该函数接受类似于 to_pandas() 的过滤选项,例如 partitionsfilters 等关键字参数。调用 to_pyarrow_table() 还会将所有可用数据加载到内存中。

当可能时,依赖 DataSet 而不是 Table 更加高效,下面会详细介绍。

DataSet

pyarrow.DataSetTable 类似,具有完整数据集的关联模式,但与 Table 不同,DataSet 是懒加载的,并且在处理更大数据集时提供了极大的灵活性。创建 DataSet 对象的开销非常低,因为它通常会导致从存储中读取的数据非常少,可以通过 to_pyarrow_dataset()DeltaTable 对象创建。

一旦创建了 DataSet,就可以使用类似 filter 这样的函数懒加载数据,这将提供一个新的过滤过的数据集。你可以通过调用 to_batches() 来获取一个懒加载的 RecordBatch 数据迭代器,或调用 to_table() 来生成包含过滤后数据的 pyarrow.Table

在许多情况下,deltalake 内部使用 DataSet 来处理数据,它是一个非常灵活、高效且文档齐全的数据类型。

从简单的数据摄取到转换、复杂查询和机器学习任务,几乎在任何 Python 环境中与 Delta Lake 表格的交互能力都为存储在 Delta Lake 中的数据开启了无数的可能性和应用。

Rust

本章开头描述的 Python 库背后,实际上是一个用 Rust 实现的完整 Delta Lake 协议,通常称为 delta-rs,它也可以直接用于构建高性能数据应用程序。将 deltalake 包添加到 Rust 项目的 Cargo.toml 中,通常是开始使用它的全部步骤。deltalake crate 包含了功能标志和依赖项,这些依赖项可以选择性地启用对 AWS、Azure 或 Google Cloud 的支持。此外,datafusion 功能标志也可以用来添加与 Apache Arrow DataFusion 项目的集成,从而在 Rust 中执行复杂的查询、写入和合并操作。

Rust 有许多特性使得它越来越受到数据工程任务的青睐,比如其低内存开销、易于并发和稳定性。在许多情况下,一旦用 Rust 开发了 Delta Lake 应用程序,它可以在没有问题的情况下持续运行多年。

本节中的示例假设在 Rust 项目中配置了最新版本的 deltalake 包,并启用了 datafusion 特性。

提示

Rust 是一种编译型语言,对于像 delta-rsDataFusion 这样的大型项目,使用默认的 Clang 或 GNU ld 链接器时,可能会遇到链接时间过长的问题。建议安装和配置 mold 链接器,以改善开发周期时间。

遵循与 Python 示例相同的模式,首先打开一个构造的 Delta Lake 表:

rust 复制代码
#[tokio::main]
async fn main() {
    println!(">> Loading `deltatbl-partitioned`");
    let table = deltalake::open_table("../data/deltatbl-partitioned").await
      .expect("Failed to open table");
    println!("..loaded version {}", table.version());
    for file in table.get_files_iter() {
      println!(" - {}", file.as_ref());
    }
}

完整的源代码位于与本书关联的 GitHub 仓库中。这个简单的示例从提供的路径创建一个 DeltaTable,并检查与最新版本相关的文件,输出如下内容:

ini 复制代码
>> Loading `deltatbl-partitioned`
..loaded version 0
 - c2=foo0/part-00000-2bcc9ff6-0551-4401-bd22-d361a60627e3.c000.snappy.parquet
 - c2=foo1/part-00000-786c7455-9587-454f-9a4c-de0b22b62bbd.c000.snappy.parquet
 - c2=foo0/part-00001-ca647ee7-f1ad-4d70-bf02-5d1872324d6f.c000.snappy.parquet
 - c2=foo1/part-00001-1c702e73-89b5-465a-9c6a-25f7559cd150.c000.snappy.parquet

检查表的文件列表并不是特别有趣,因此接下来的示例利用了 DataFusion 工具,提供一个类似 SQL 查询的接口来查询同一个 Delta Lake 表:

rust 复制代码
use std::sync::Arc;
use deltalake::datafusion::execution::context::SessionContext;
use deltalake::arrow::util::pretty::print_batches;

#[tokio::main]
async fn main() {
  let ctx = SessionContext::new();
  let table = deltalake::open_table("../data/deltatbl-partitioned")
      .await
      .unwrap();
  ctx.register_table("demo", Arc::new(table)).unwrap();

  let batches = ctx
      .sql("SELECT * FROM demo LIMIT 5").await.expect("Failed to execute SQL")
      .collect()
      .await.unwrap();
  print_batches(&batches).expect("Failed to print batches");
}

运行这个示例将打印 Delta Lake 表中找到的前五条记录,提供一个简单的接口,并且是一个简单却不太 Rust 的 API 来查询表中的数据:

diff 复制代码
+----+------+
| c1 | c2   |
+----+------+
| 0  | foo0 |
| 2  | foo0 |
| 4  | foo0 |
| 1  | foo1 |
| 3  | foo1 |
+----+------+

DataFusion 提供了自己的 SQL 方言,同时也提供了一个 DataFrame API,应该对来自 Pandas 或 Apache Spark 的用户来说非常熟悉。在本书的 GitHub 仓库中,还有一些示例展示了 DataFrame 与 DataFusion SQL 示例等效的操作。事实上,SessionContext::sql 函数返回一个 DataFrame,允许将简单的 SQL 查询与更复杂的 DataFrame 链式逻辑结合,从而适应更复杂的用例。

读取数据

对于那些代表大于单台机器内存所能承载的数据的 Delta Lake 表,Rust 的 deltalake 库提供了类似于 Python 库的分区和文件统计语义。

在 Python 中读取大数据集时,必须在创建 Pandas DataFrame 之前指定分区和过滤器。而在 DataFusion 中,得益于 deltalake Rust crate 与 DataFusion API 的紧密集成,过滤器可以与 DataFrame 的创建同时指定。deltatbl-partitioned 表在 c2 列上有一个分区,可以在 DataFusion SQL 查询中使用该分区,以避免读取与谓词不匹配的 .parquet 文件。例如:

ini 复制代码
let df = ctx
          .sql("SELECT * FROM demo WHERE c2 = 'foo0'")
          .await
          .expect("Failed to create data frame");

DataFusion SQL 还将利用 Delta 事务日志中的文件统计信息,在创建 DataFrame 时生成适当且最优的查询计划。通常来说,当使用 DataFusion SQL 或 DataFrame API 与 Delta Lake 配合时,默认行为几乎总是正确且最优的。

写入数据

从基础层面来看,Delta Lake 表由数据文件(通常为 Apache Parquet 格式)和事务日志文件(JSON 格式)组成。deltalake Rust crate 支持同时写入数据和事务日志文件,或者只写入事务。例如,kafka-delta-ingest 会将 JSON 数据流转换为 Apache Parquet 格式,然后创建事务将数据添加到配置的 Delta Lake 表中。其他 Rust 应用程序可能使用外部系统创建的 Parquet 数据文件,例如 oxbow,它只需要管理 Delta Lake 表的事务日志。

无论具体应用需求如何,deltalake crate 提供了几种选项。虽然详细介绍每个写入 API 超出了本书的范围,但目前 crate 支持以下几种类型:

  • 支持与 Delta 日志直接交互的事务操作
  • 基于 DataFusion 的写入器,用于插入和/或合并记录
  • 一个简单的高级 JSON 写入器,接受 serde_json::Value 类型
  • 一个 RecordBatch 写入器,允许开发者将 Arrow RecordBatch 转换为 Apache Parquet 文件,并写入 Delta Lake 表

对于大多数用例,关于需要哪种类型的写入器的决策通常取决于写入操作是追加还是合并。

对于仅追加操作的写入器,DataFusion 是不必要的,可以使用 deltalake 包的 RecordBatchWriter 来执行追加操作到 DeltaTable。

警告

DataFusion 是一个功能强大的数据处理引擎,构建在 Rust 上,但它也会显著增加二进制文件大小、链接时间开销以及 API 表面。当使用 deltalake crate 执行查询、合并等操作时,必须通过指定 datafusion 特性来启用对 DataFusion 的集成,例如使用 cargo add --features datafusion deltalake。例如,在构建一个使用 Delta Lake 的 AWS Lambda 时,未启用 DataFusion 的二进制文件大小为 4.8 MB;启用 datafusion 特性标志后,生成的 bootstrap.zip 文件大小为 8.2 MB。

通常,使用 RecordBatchWriter 最具挑战性的部分是构建所需的 Arrow RecordBatch 对象,这些对象包含模式,并且本质上是列式的。arrow crate 中有一些帮助构建 RecordBatch 对象的工具,例如 JSON 读取器 arrow::json::reader::ReaderBuilder;但在以下示例中,将手动从内存数据创建对象,并假设已经创建了 Delta Lake 表。

合并/更新

从 Rust 中修改 Delta Lake 表通常需要使用 datafusion 特性,因为 DataFusion 引擎提供了处理谓词的能力,这对于删除、合并和更新记录是必要的。与 Python 库不同,Python 库通过 DeltaTable 对象进行这些操作,而 Delta Lake 表的操作通过 DeltaOps 结构体提供,后者帮助生成各种操作的构建器,例如以下的删除示例:

css 复制代码
let table = deltalake::open_table("./test")?;
let (table, metrics) = DeltaOps::from(table).delete()
                         .with_predicate(col("id").rem(lit(2)).eq(lit(0)))
                         .await?

与 Python 示例类似,这将产生一个新事务,包含一个删除操作和一个添加操作。DeltaOps 的文档提供了关于如何使用 deleteupdatemerge 的更多信息。DataFusion 是这些操作的核心,因此在构建谓词、数据框(用于合并)和表达式时,参考 DataFusion 的文档非常有用。DataFusion SQL 也可以通过 deltalake crate 的表提供者来代替 Rust 的 DataFrame 语义。

构建 Lambda

无服务器函数是构建 Delta Lake 原生应用的理想用例,例如使用 AWS Lambda。Lambda 的计费模型鼓励低内存使用和快速执行时间,这使其成为构建紧凑高效数据处理应用的理想平台。本节将对本章之前的一些示例进行调整,以便在 AWS Lambda 中运行,处理数据摄取或使用 deltalake 进行处理。其他云服务提供商也有类似的无服务器产品,如 Azure Functions 和 Google Cloud Run。本节中的概念可以移植到这些环境中,但一些接口可能会有所不同。

对于大多数应用程序,AWS Lambda 是通过外部事件触发的,例如传入的 HTTP 请求、SQS 消息或 CloudWatch 事件。Lambda 会将这些外部事件转换为 JSON 有效载荷,Lambda 函数将接收到这些有效载荷并据此执行操作。例如,设想一个应用程序,它接收一个包含数千条记录的 HTTP POST 请求,记录应该被写入到 S3,如图 6-1 中的请求流程图所示。调用时,Lambda 接收到 JSON 数组,然后可以将其附加到预配置的 Delta Lake 表中。Lambda 的概念上应该简单,任务完成得尽可能快且高效。

Python

Lambda 函数可以直接在 AWS Lambda 的 Web UI 中用 Python 编写。不幸的是,默认的 Python 运行时仅内置了最基本的包,开发者若想使用 deltalake,需要通过层(layers)或容器(containers)打包 Lambda。AWS 提供了一个 "AWS SDK with Pandas" 层,可以用来入门,但需要注意,由于 Lambda 层有 250 MB 的大小限制,必须小心地包含 deltalake 依赖。Lambda 的打包方式对其执行影响不大,因此本节不会过多关注打包和上传 Lambda 的过程。请参考本书的 GitHub 仓库,其中包含了使用层和基于容器的方案的示例,并附有部署示例所需的基础设施代码。

hello-delta-rust 示例演示了最简单的 Delta Lake 应用程序在 Lambda 中的实现。该示例仅查看表的元数据,而不是查询任何数据。

以下是 lambda_function.py,它简单地打开 Delta Lake 表并返回元数据给 HTTP 客户端:

python 复制代码
import os
from deltalake import DeltaTable

def lambda_handler(event, context):
    url = os.environ['TABLE_URL']
    dt = DeltaTable(url)
    return { 
        'version' : dt.version(),
        'table' : url,
        'files' : dt.files(),
        'metadata' : {}
    }

这个简单的 Python 示例创建了一个 DeltaTable 对象,然后对表 (dt) 执行操作,演示了如何通过 Lambda 与 Delta Lake 进行交互。只要 lambda_handler 函数返回一个列表或字典,AWS Lambda 会自动将信息通过 HTTP 以 JSON 格式返回给调用者。

本节中"读取大型数据集"部分中的示例,使用 Pandas 或 PyArrow 在 Python 中查询数据,也可以在 Lambda 环境中重用。

同样,Python 中处理数据写入的示例也可以在 Lambda 中重用。然而,Lambda 执行环境本质上是并行化的,这在使用 AWS S3 时会带来并发写入挑战,这些挑战和解决方案将在本章后面讨论。在这之前,我们需要一个应用程序,该程序将上述 JSON 数组追加到 Delta Lake 表中。执行从 lambda_handler 函数开始,这是 AWS Lambda 执行上传代码的入口点:

css 复制代码
def lambda_handler(event, context):
    table_url = os.environ['TABLE_URL']
    try:
        input = pa.RecordBatch.from_pylist(json.loads(event['body']))
        dt = DeltaTable(table_url)
        write_deltalake(dt, data=input, schema=schema(), mode='append')
        status = 201
        body = json.dumps({'message' : 'Thanks for the data!'})
    except Exception as err:
        status = 400
        body = json.dumps({'message' : str(err), 
                            'type' : type(err).__name__})

    return {
        'statusCode' : status,
        'headers' : {'Content-Type' : 'application/json'},
        'isBase64Encoded' : False,
        'body' : body,
    }

以上代码是 GitHub 仓库中的 ingest-with-python 示例的简化版,可以直接放入任意 Python Lambda 配置中。然而,在上传数据时,默认会返回一个错误:

json 复制代码
{
    "message": "Atomic rename requires a LockClient for S3 backends. Either configure the LockClient, or set AWS_S3_ALLOW_UNSAFE_RENAME=true to opt out of support for concurrent writers.",
    "type": "DeltaProtocolError"
}

默认情况下,deltalake 库禁用了不安全的重命名。当遇到这个错误时,可能会有设置 AWS_S3_ALLOW_UNSAFE_RENAMEtrue 的诱惑,但这样做可能会导致数据丢失或表损坏,因为在没有协调的情况下,不能安全地在 AWS S3 上执行并发写入。跳到"在 AWS S3 上的并发写入"部分,了解如何配置 Lambda 安全地执行并发写入。

append-only 示例可以进一步扩展,从其他 Delta Lake 表加载和合并数据,通过创建更多的 DeltaTable 对象,并使用 pandas.DataFrame 函数进行合并、连接或拼接。假设有一个名为 s3://bucket/dietary_prefs 的次级表,需要与上传到 Lambda 的员工记录表进行连接,结果生成 s3://bucket/offsite_attendees 表:

ini 复制代码
def lambda_handler(event, context):
    table_url = os.environ['TABLE_URL']
    try:
        input = pd.DataFrame(json.loads(event['body']))
        # 假设 `input` 和 `prefs` 都有 `id` 列用于执行内连接
        prefs = DeltaTable('s3://bucket/dietary_prefs').to_pandas()
        dt = DeltaTable(table_url)
        write_deltalake(dt, 
                        data=pd.merge(input, prefs),
                        schema=schema(), mode='append')
        # ...

在 Lambda 中执行数据集的连接时,重要的是要记住,Lambda 函数在内存和 CPU 受限的环境中运行!如果使用谓词或直接采用 PyArrow,而不是 Pandas,可以改善性能,尤其是在简单方法变得内存或 CPU 密集时。如果工作数据集无法适应 Lambda 内存,则应考虑将工作负载移至其他环境,如独立服务(ECS/EKS/EC2),或者迁移到 Spark 以利用多台机器。

Rust

在 AWS Lambda 中构建 Rust 函数与构建 Python 函数类似,都是直接的。然而,与 Python 不同,Rust 可以编译为原生代码,因此在 AWS Lambda 中无需使用"运行时";相反,需要上传一个包含编译后的可执行文件的自定义格式的 bootstrap.zip 文件。为了构建 Lambda 所需的 bootstrap.zip 文件,需要在工作站上安装额外的工具,如 cargo-lambda,它提供生成器以及构建/交叉编译功能。以下示例以及本书 GitHub 仓库中的示例都依赖于 cargo-lambda,要开始编写 Rust Lambda,应该创建必要的框架:

erlang 复制代码
% cargo lambda new deltadog --event-type s3::S3Event  
% cd deltadog
% cargo add --features s3 deltalake
% cargo lambda build --release --output-format zip

上面的最后一个命令将生成 ./target/lambda/deltadog/bootstrap.zip,可以直接上传到 AWS Lambda。与 Python 示例类似,Rust 代码也有一个单一的入口点,可以在其中添加 Rust 代码。通过上面的框架,可以将本章中的任何读取或写入 Rust 示例复制并粘贴到 Lambda 函数中。与 Python Lambda 不同,Rust Lambda 通常可以处理更多的数据,因为 Rust 可执行文件的体积紧凑且高效。现有的 deltalake Rust 代码可以原封不动地添加到函数处理器中:

rust 复制代码
async fn function_handler(event: LambdaEvent<S3Event>) -> Result<(), Error> {
    // 从请求中提取一些有用的信息
    let _table = deltalake::open_table("s3://example/table").await?;
    Ok(())
}

GitHub 仓库中的 ingest-with-rust 示例可以作为起点,类似于 ingest-with-python 示例。

在 AWS S3 上的并发写入

Delta Lake 从一开始就支持多个集群的并发读取,但安全的并发写入在 AWS S3 上需要特别小心,因为 S3 缺乏 putIfAbsent 一致性保证。在没有单独协调的情况下,无法保证来自不同写入进程(无论是 Python、Rust 还是 Spark)的写入操作不会发生冲突。大多数使用 AWS S3 构建的 Delta Lake 应用程序都使用某种变种的 AWS DynamoDB 表来协调写入操作。在 deltalake Python 版本 0.15 和 Rust 版本 0.17 之前,这些库使用 dynamodb_lock,而更近期的版本使用与 S3DynamoDBLogStore 兼容的实现,这使得 Python 和 Rust 应用程序能够使用 Delta Lake Spark 写入者所采用的相同协议进行互操作,并支持多集群。

S3DynamoDBLogStore

在 AWS S3 上执行并发写入的事实标准依赖于 DynamoDB 表,但以不同的方式利用它。从 deltalake Rust crate 版本 0.17 和 deltalake Python 版本 0.15 开始,本地应用程序可以通过 S3DynamoDBLogStore 协议无缝地与 Spark 应用程序互操作。该协议依赖于通过 DynamoDB 表来协调 Delta 日志的提交,这不仅提供了日志提交的序列化,还增强了在写入者发生意外崩溃时的恢复能力(见图 6-2)。

S3DynamoDBLogStore 的设计考虑在 Delta Lake 博客中有更深入的解释。请参考 Delta Lake 文档,以获取关于如何配置所需 DynamoDB 表的最新详细信息,或从本书 GitHub 仓库中的一些示例开始。

DynamoDB 锁

使用旧依赖项的应用程序可能仍然依赖于 dynamo​db_lock,但由于这种方法已被弃用,本节不会深入探讨其功能和设计。从高层次来看,DynamoDB 表被配置为与 Python 或 Rust 应用程序一起使用的简单键值存储。在执行写入操作之前,deltalake 库会检查 DynamoDB 是否存在锁项------即代表它希望写入的表的键。如果该键不存在,库将执行以下操作:

  1. 写入一个具有表标识符的生存时间(TTL)锁项;
  2. 提交其 Delta 事务;
  3. 从 DynamoDB 删除该项。

然而,如果键已存在,应用程序必须进入重试/回退循环,并等待直到锁项从 DynamoDB 表中清除。除了仅支持 Python/Rust 写入者外,这种方法已被弃用,因为它在写入者失败的情况下提供了较差的恢复能力。如果写入者在锁项创建后崩溃或错误退出,所有其他写入者必须等待,直到原始写入者能够恢复其锁或 TTL 过期。使用这种方法几乎没有恢复保障,而"单一巨型表锁"会导致并发限制,这可能严重影响 Lambda 调用。

与 S3 兼容存储的并发性

许多其他存储系统实现了 AWS S3 API,例如开源的 MinIO 和 Cloudflare R2。然而,并非所有类似 S3 的实现都受到 AWS S3 最终一致性行为的影响,这意味着在并发写入者之间可能不需要协调。

请查阅服务的文档,以确定它是否支持原子"如果不存在则复制"操作(有时称为 putIfAbsent)。例如,Cloudflare R2 仅在其 REST API 提供自定义头部时支持原子行为,这可以通过 AWS_COPY_IF_NOT_EXISTS 环境变量在 deltalake 包中进行切换。

对于其他服务,可以将环境变量 AWS_S3_ALLOW_UNSAFE_RENAME 设置为 true,以禁用 deltalake 包的协调/锁定要求。

下一步

本地数据处理生态系统正在蓬勃发展,Python 和 Rust 中的许多优秀工具正在开发并逐步成熟。大多数创新由热情和富有灵感的开发者在更广泛的开源生态系统中推动。

Delta Lake 通过 deltalake Python 包或 Rust crate 扮演着关键角色,使数据应用能够受益于 Delta 的优化存储和事务特性。集成和优秀工具的列表持续增长;以下是一些值得进一步了解的有趣项目:

Python:

  • Pandas
  • Polars
  • Dask
  • Daft
  • LakeFS
  • PyArrow

Rust:

  • ROAPI
  • kafka-delta-ingest
  • Ballista
  • DataFusion
  • arrow-rs
  • Arroyo
  • ParadeDB

delta-rs 项目以及这里列出的其他项目的生产力取决于参与其中的人,因此邀请你加入!提交 bug 报告,编写用户文档,或者创建使用 Delta Lake 解决新问题的开源项目!

相关推荐
Ekine19 分钟前
【Flink-scala】DataStream编程模型之水位线
大数据·flink·scala
琅中之嶹32 分钟前
确定 POST 请求中的数据字段
开发语言·python·数据分析
RestCloud1 小时前
ETL工具观察:ETLCloud与MDM是什么关系?
数据仓库·数据分析·etl·数据集成·mdm
动态一时爽,重构火葬场1 小时前
elasticsearch是如何进行搜索的?
大数据·elasticsearch·搜索引擎
叫我DPT1 小时前
24年某马最新Hadoop课程总结文档
大数据·hadoop·分布式
P.H. Infinity1 小时前
【Elasticsearch】06-JavaRestClient查询
大数据·elasticsearch·搜索引擎
F20226974862 小时前
Python爬虫——城市数据分析与市场潜能计算(Pandas库)
爬虫·python·数据分析
希艾席蒂恩2 小时前
高效数据分析:五款报表工具助力企业智能决策
大数据·数据库·信息可视化·统计·报表·可视化
运维&陈同学2 小时前
【Nacos01】消息队列与微服务之Nacos 介绍和架构
linux·运维·后端·微服务·云原生·nacos·架构·注册中心