Spark Structured Streaming首次在Apache Spark 2.0中引入。Structured Streaming的主要目标是在Spark上构建准实时流应用程序。Structured Streaming替代了一个名为DStreams(离散流)的较老、较低级别的API,该API基于旧的Spark RDD模型。自那时以来,Structured Streaming已经添加了许多优化和连接器,包括与Delta Lake的集成。
Delta Lake通过其两个主要运算符readStream和writeStream与Spark Structured Streaming集成。Delta表可用作流式数据的源和接收端。Delta Lake克服了通常与流系统相关的许多限制,包括:
- 合并由低延迟摄取产生的小文件
- 在多个流(或并发批处理作业)上保持"仅一次"处理
- 利用Delta事务日志,在使用文件作为源流时高效地发现哪些文件是新文件
我们将从快速回顾Spark Structured Streaming开始本章,然后初步概述Delta Lake流处理及其独特的功能。接下来,我们将步入一个小的"Hello Streaming World!"Delta Lake流处理示例。虽然范围有限,但这个示例将提供一个在非常简单的环境中了解Delta Lake流处理编程模型细节的机会。
数据的增量处理已经成为一种流行的ETL模型。AvailableNow流触发模式使开发人员能够构建增量管道,而无需维护自己的状态变量,从而实现了更简单、更健壮的管道。
您可以在Delta表上启用Change Data Feed(CDF)。客户端可以使用SQL查询消耗这个CDF feed,或者他们可以将这些变更流式传输到他们的应用程序中,从而实现创建审计试验、流式分析、合规性分析等用例。
流处理概览
尽管本章专注于Delta Lake流处理模型,但在深入研究Delta Lake Structured Streaming的独特功能之前,让我们简要回顾一下Spark Structured Streaming的基础知识。
Spark结构化流处理
Spark结构化流处理是建立在Apache Spark之上的近实时流处理引擎。它实现了可伸缩、容错和低延迟处理连续数据流的功能。Spark Structured Streaming提供了一个高级API,使您能够构建端到端的流应用程序,可以从各种来源读取和写入数据,例如Kafka、Azure Event Hubs、Amazon S3、Google Cloud Platform的Pub/Sub、Hadoop分布式文件系统等等。
Structured Streaming的核心思想是,它允许您将数据流视为一个无边界的类似表的结构,可以使用类似SQL的操作进行查询和操作,从而轻松分析和操作数据。Spark结构化流处理的众多优点之一是其易用性和简单性。API建立在熟悉的Spark SQL语法之上,因此您可以利用对SQL和DataFrame操作的现有知识,构建流应用程序,而无需学习一套新的复杂API。
此外,结构化流处理通过利用Spark的处理引擎提供了容错性和可靠性,该引擎可以从故障中恢复,并确保每个数据点仅处理一次。这种容错性使其非常适合构建需要低延迟和高吞吐量数据处理的关键任务应用程序。
Delta Lake与结构化流处理
当您在结构化流处理中使用Delta Lake时,您既获得了Delta Lake的事务保证,又拥有了Apache Spark结构化流处理的强大编程模型。通过Delta Lake,您现在可以将Delta表用作流式数据的源和接收端,实现了一种连续处理模型,通过流式方式处理您的数据,涵盖了数据湖中的原始数据、青铜层、银层和金属层,消除了批处理作业的需要,从而简化了解决方案架构。在本章的后面部分,我们将介绍这种连续处理架构的一个示例。
在第7章中,我们讨论了模式强制执行和模式演变。流入Delta Lake的流数据提供了模式强制执行,确保传入的数据流符合预定义的模式,防止数据异常进入数据湖。然而,当业务需求变化引入对捕获附加信息的需要时,您可以利用Delta Lake的模式演变能力,允许模式随时间变化。
流处理示例
我们将通过回顾一个非常简单的"Hello Streaming World!"示例来开始本节,以说明从Delta表进行流处理的基础知识。
Hello Streaming World
在本节中,我们将创建一个简单的Delta表流处理场景,并设置一个流查询,该查询:
- 从源Delta表中读取所有更改到一个流DataFrame。在Delta Lake表的情况下,"读取更改"等同于"读取事务日志条目",因为它们包含表的所有更改的详细信息。
- 对流DataFrame执行一些简单的处理。
- 将流DataFrame写入目标Delta表。
从源读取流并将流写入目标的组合通常被称为流查询,如图8-1所示。
一旦我们启动了流查询,我们将对源表执行一些小的批量更新,允许数据通过流查询流向目标。在执行查询期间,我们将查询查询过程日志,并研究检查点文件的内容,这些文件维护了我们流查询的状态。
这个简单的示例将使您在转向更复杂的示例之前充分了解Delta Lake流处理模型的基础知识。首先,执行第81章的"章节初始化"笔记本以创建所需的Delta表。然后,打开"01 - 简单流处理"笔记本。
在这里,我们有一个包含10条黄色出租车数据记录的Delta表,所有数据都包含在一个单独的Parquet文件中:
bash
%sh
ls -al /dbfs/mnt/datalake/book/chapter08/LimitedRecords.delta
drwxrwxrwx 2 root root 4096 Apr 11 19:40 _delta_log
-rwxrwxrwx 1 root root 6198 Apr 12 00:04 part-00000-....snappy.parquet
%sql
SELECT * from delta.`/mnt/datalake/book/chapter08/LimitedRecords.delta`
输出(仅显示相关部分):
diff
+------+--------+----------------------------+----------------------------+
|RideId|VendorId| PickupTime | DropTime |
+------+--------+----------------------------+----------------------------+
|1 | 1 |2022-03-01T00:00:00.000+0000|2022-03-01T00:15:34.000+0000|
|2 | 1 |2022-03-01T00:00:00.000+0000|2022-03-01T00:10:56.000+0000|
|3 | 1 |2022-03-01T00:00:00.000+0000|2022-03-01T00:11:20.000+0000|
|4 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:20:01.000+0000|
|5 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:00:00.000+0000|
|6 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:00:00.000+0000|
|7 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:00:00.000+0000|
|8 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:00:00.000+0000|
|9 | 2 |2022-03-01T00:00:00.000+0000|2022-03-01T00:00:00.000+0000|
|10 | 2 |2022-03-01T00:00:01.000+0000|2022-03-01T00:11:15.000+0000|
+------+--------+----------------------------+----------------------------+
创建流查询
首先,我们将创建我们的第一个简单流查询。我们从源表中读取一个流,如下所示:
makefile
# Start streaming from our source "LimitedRecords" table
# Notice that instead of a "read", we now use a "readStream",
# for the rest our statement is just like any other spark Delta read
stream_df = \
spark \
.readStream \
.format("delta") \
.load("/mnt/datalake/book/chapter08/LimitedRecords.delta")
readStream 与任何其他标准的 Delta 表读取操作一样,只是多了一个 Stream 后缀。我们得到了一个流 DataFrame,命名为 stream_df
。
接下来,我们对DataFrame执行一些操作。我们添加一个时间戳,以便知道我们何时从源表中读取每条记录。我们也不需要源 DataFrame 中的所有列,因此我们选择需要的列:
ini
# Add a "RecordStreamTime" column with the timestamp at which we read the
# record from stream
stream_df = stream_df.withColumn("RecordStreamTime", current_timestamp())
# This is the list of columns that we want from our streaming
# DataFrame
select_columns = [
'RideId', 'VendorId', 'PickupTime', 'DropTime',
'PickupLocationId', 'DropLocationId', 'PassengerCount',
'TripDistance', 'TotalAmount', 'RecordStreamTime'
]
# Select the columns we need. Note that we can manipulate our stream
# just like any other DataStream, although some operations like
# count() are NOT supported, since this is an unbounded DataFrame
stream_df = stream_df.select(select_columns)
最后,我们将 DataFrame 写入一个输出表:
ini
# Define the output location and the checkpoint location
target_location = "/mnt/datalake/book/chapter08/StreamingTarget"
target_checkpoint_location = f"{target_location}/_checkpoint"
# Write the stream to the output location, maintain
# state in the checkpoint location
streamQuery = \
stream_df \
.writeStream \
.format("delta") \
.option("checkpointLocation", target_checkpoint_location) \
.start(target_location)
首先,我们定义一个目标或输出位置,我们希望将流写入该位置。在选项中,我们定义了一个检查点文件的位置。这个检查点文件将维护流查询的元数据和状态。检查点文件是为了确保容错性并在发生故障时启用查询的恢复。它将维护许多其他信息,包括已处理的流源的事务日志条目,以便识别尚未处理的新条目。
最后,我们使用目标位置调用 start 方法。请注意,我们对输出和检查点文件使用相同的基目录。我们只是在检查点子目录后附加了下划线(_checkpoint)。
由于我们没有指定触发器,流查询将继续运行,因此它将执行、检查新记录、处理它们,然后立即检查下一组记录。在接下来的章节中,您将看到可以使用触发器更改此行为。
查询过程日志
当我们启动流查询时,我们会看到流正在初始化,并显示查询进度日志(QPL)。 QPL是由每个微批次生成的JSON日志,提供了有关微批次执行的详细信息。它用于在笔记本单元格中显示一个小型流仪表板。该仪表板提供有关流应用程序性能、吞吐量和延迟的各种指标、统计数据和见解。当您展开流显示时,会看到一个带有两个选项卡的仪表板(图8-2)。
第一个选项卡包含仪表板,其中以图形方式显示了QPL的一些关键指标。原始指标显示在原始数据选项卡中。 这里显示了查询过程日志的一部分原始数据:
json
{
"id" : "c5eaca75-cf4d-410f-b34c-9a2128ee1944",
"runId" : "508283a5-9758-4cf9-8ab5-3ee71a465b67",
"name" : null,
"timestamp" : "2023-05-30T16:31:48.500Z",
"batchId" : 1,
"numInputRows" : 0,
"inputRowsPerSecond" : 0.0,
"processedRowsPerSecond" : 0.0,
"durationMs" : {
"latestOffset" : 14,
"triggerExecution" : 15
},
QPL中的一个关键指标是流唯一标识,即日志中的第一个条目。这个ID唯一标识了流,并映射回检查点目录,稍后将看到。流唯一标识也显示在流仪表板标题上方。 查询日志还包含 batchId,这是微批次的ID。对于每个流,此ID将从零开始,并为每个处理的微批次递增一次。numInputRows 字段表示当前微批次中摄取的行数。 QPL中下一组重要的指标是 Delta 源和接收器指标: sources 中的 startOffset 和 endOffset 指示每个批次的起始和结束位置。这些包括以下子字段: reservoirVersion 是当前微批次操作的 Delta 表的版本。 index 用于跟踪从哪个部分文件开始处理。 如果 reservoirVersion 设置为当前流开始时 Delta 表的版本,则 isStartingVersion 布尔字段设置为 true。 sink 字段包含流接收器的位置。 当我们查看第1个微批次的源和接收器指标时,我们看到以下内容:
json
"sources" : [ {
"description" : "DeltaSource[dbfs:/mnt/.../LimitedRecords.delta]",
"startOffset" : {
"sourceVersion" : 1,
"reservoirId" : "6c25c8cd-88c1-4b74-9c96-a61c1727c3a2",
"reservoirVersion" : 0,
"index" : 0,
"isStartingVersion" : true
},
"endOffset" : {
"sourceVersion" : 1,
"reservoirId" : "6c25c8cd-88c1-4b74-9c96-a61c1727c3a2",
"reservoirVersion" : 0,
"index" : 0,
"isStartingVersion" : true
},
"latestOffset" : null,
"numInputRows" : 0,
"inputRowsPerSecond" : 0.0,
"processedRowsPerSecond" : 0.0,
"metrics" : {
"numBytesOutstanding" : "0",
"numFilesOutstanding" : "0"
}
} ],
"sink" : {
"description" : "DeltaSink[/mnt/datalake/book/chapter08/StreamingTarget]",
"numOutputRows" : -1
}
注意 numInputRows
为 0。这可能看起来有点令人惊讶,因为我们知道我们的源表中有10行。然而,当我们启动 .writeStream
时,流查询开始运行,并立即作为批次0的一部分处理了前10行。我们还可以看到我们的 batchId
目前为1,因为 batchId
从0开始,第一个批次已经被处理。
我们还可以看到 reservoirVersion
仍然是0,因为此批次尚未运行,没有处理新记录。所以,我们仍然处于源表的版本0。我们还看到 index
是0,这意味着我们正在处理第一个数据文件,并且确实在起始版本。我们可以通过显示源表的版本来验证这一点:
perl
%sql
DESCRIBE HISTORY delta.`/mnt/datalake/book/chapter08/LimitedRecords.delta`
输出(仅显示相关部分):
sql
+-------+----------------------------+
|version| timestamp|
+-------+----------------------------+
|0 |2023-05-30T16:25:23.000+0000|
+-------+----------------------------+
在这里,您可以看到我们当前确实是在版本0。我们还可以通过查询我们的输出流表来验证这一点:
sql
%sql
SELECT count(*) FROM delta.`/mnt/datalake/book/chapter08/StreamingTarget`
我们可以看到我们确实有10行:
sql
+--------+
|count(1)|
+--------+
| 10 |
+--------+
因为我们用 .start
启动了 writeStream
,并且没有指示查询应该运行多久,它一直在运行。当 writeStream
完成时,它执行下一个 readStream
,依此类推。然而,由于源表中没有生成新行,实际上什么也没发生,我们的输出记录计数仍然为10。batchId
不会改变,直到它从流中提取行,因此保持为1。 接下来,我们执行以下SQL语句,在源表中插入10条新记录:
sql
%sql
-- Use this query to insert 10 random records from the
-- allYellowTaxis table into the limitedYellowTaxis table
INSERT INTO
taxidb.limitedYellowTaxis
SELECT
*
FROM
taxidb.allYellowTaxis
ORDER BY rand()
LIMIT 10
如果我们取消注释并运行此查询,batchId
现在设置为1,并且我们看到了10行新数据:
json
{
"id" : "c5eaca75-cf4d-410f-b34c-9a2128ee1944",
"runId" : "508283a5-9758-4cf9-8ab5-3ee71a465b67",
"name" : null,
"timestamp" : "2023-05-30T16:46:08.500Z",
"batchId" : 1,
"numInputRows" : 10,
"inputRowsPerSecond" : 20.04008016032064,
一旦处理了这个 batchId
,batchId
2 的记录计数将恢复为0,因为没有来自流的新行。
请记住,流查询将一直运行下去,寻找源表中的新事务条目,并将相应的行写入流目标。通常,Spark结构化流处理作为基于微批次的流服务在运行。它将从源中读取一批记录,处理这些记录并将它们写入目标,然后立即开始下一批,寻找新的记录(或在Delta Lake的情况下,寻找新的事务条目)。
在一个实际应用中,这种对源表进行批量更新的模型并不经济。源表只会定期更新,但由于我们的流查询一直在运行,我们必须保持其集群一直运行,这会增加成本。在本章后面,我们将修改流查询以更好地适应我们的用例,但首先,让我们简要了解一下检查点文件。
检查点文件
前面,我们看到检查点文件将维护我们流查询的元数据和状态。检查点文件在 _checkpoint
子目录中:
bash
%sh
ls -al /dbfs/mnt/datalake/book/chapter08/StreamingTarget/_checkpoint/
drwxrwxrwx 2 root root 4096 May 1 23:37 __tmp_path_dir
drwxrwxrwx 2 root root 4096 May 1 23:37 commits
-rwxrwxrwx 1 root root 45 May 2 15:48 metadata
drwxrwxrwx 2 root root 4096 May 1 23:37 offsets
我们有一个文件(metadata)和两个目录(offsets和commits)。让我们看看每一个。metadata文件只是以JSON格式包含流标识符:
bash
%sh
head /dbfs/mnt/datalake/book/chapter08/StreamingTarget/_checkpoint/metadata
{"id":"c5eaca75-cf4d-410f-b34c-9a2128ee1944"}
当我们查看offsets目录时,您会看到两个文件,每个文件对应一个batchId:
bash
%sh
ls -al /dbfs/mnt/datalake/book/chapter08/StreamingTarget/_checkpoint/offsets
-rwxrwxrwx 1 root root 769 May 30 16:28 0
-rwxrwxrwx 1 root root 771 May 30 16:46 1
当我们查看文件0的内容时,我们看到以下内容:
json
v1
{
"batchWatermarkMs": 0,
"batchTimestampMs": 1685464087937,
"conf": {
"spark.sql.streaming.stateStore.providerClass":
"org.apache.spark.sql.execution.streaming
.state.HDFSBackedStateStoreProvider",
.....
}
}
{
"sourceVersion": 1,
"reservoirId": "6c25c8cd-88c1-4b74-9c96-a61c1727c3a2",
"reservoirVersion": 0,
"index": 0,
"isStartingVersion": true
}
第一部分包含Spark流处理的配置变量。第二部分包含我们之前在QPL中看到的相同的reservoirVersion、index和isStartingVersion。在这里记录的是批次执行前的状态,所以我们的版本是零,文件索引是零,isStartingVersion变量指示我们处于起始版本。
当我们查看文件1时,我们看到以下内容:
json
v1
{
"batchWatermarkMs": 0,
"batchTimestampMs": 1685465168696,
"conf": {
"spark.sql.streaming.stateStore.providerClass":
"org.apache.spark.sql.execution.streaming.state.
HDFSBackedStateStoreProvider",
...
}
}
{
"sourceVersion": 1,
"reservoirId": "6c25c8cd-88c1-4b74-9c96-a61c1727c3a2",
"reservoirVersion": 2,
"index": -1,
"isStartingVersion": false
}
在这个批次中,额外的10条记录被处理,将要被处理的下一个可能的版本是2,这在reservoirVersion中反映出来。另外,请注意,索引设置为-1,这表示对于当前版本没有其他文件要处理。
commits文件夹包含每个微批次一个文件。在我们的情况下,我们将有两个commits,每个批次一个:
diff
drwxrwxrw
-rwxrwxrwx 1 root root 29 Jun 9 15:55 0
-rwxrwxrwx 1 root root 29 Jun 9 16:15 1
每个文件代表了微批次的成功完成。它只包含一个水印:
bash
%sh
head /dbfs/mnt/datalake/book/chapter08/StreamingTarget/_checkpoint/commits/0
这产生了:
json
v1 {"nextBatchWatermarkMs":0}
在这一部分,我们初次了解了Delta流处理。我们看了一个简单的例子,其中Delta表既是流查询的源表,也是流查询的目标表。在接下来的章节中,我们将看看如何在增量处理模型中利用Delta流处理。
AvailableNow 流处理
Spark Structured Streaming 提供了多种触发器模式。AvailableNow
触发器选项以增量批次的形式消耗所有可用的记录,可以使用选项(例如 maxBytesPerTrigger
)配置批次大小。 首先,我们需要在"02 - 简单流处理"笔记本中取消当前正在运行的流查询,方法是导航到流仪表板并单击取消链接。然后,我们可以确认取消并停止流查询。 由于源表只会定期更新,我们不希望流查询持续运行。相反,我们希望启动查询,提取新的事务条目,处理相应的记录,写入接收器,然后停止。以下触发器将允许我们执行此操作。如果我们在流查询中添加代码 .trigger(availableNow=True)
,则查询将运行一次,然后停止,如笔记本"02 - AvailableNow Streaming"中所示:
ini
# Write the stream to the output location, maintain
# state in the checkpoint location
streamQuery = \
stream_df \
.writeStream \
.format("delta") \
.option("checkpointLocation", target_checkpoint_location) \
.trigger(availableNow=True) \
.start(target_location)
当我们运行此笔记本时,流查询将运行直到找不到新记录,但由于源表中没有添加新记录,因此找不到记录,也不会将记录写入目标表。我们可以通过查看 writeStream
的原始数据来验证这一点:
json
{
"id" : "c5eaca75-cf4d-410f-b34c-9a2128ee1944",
...
"numInputRows" : 0,
如果现在在笔记本中在 writeStream
下方运行下面的SQL查询,我们将向源表添加10条记录。然后,如果我们重新运行流查询,我们将再次看到这10行新数据:
json
{
"id" : "c5eaca75-cf4d-410f-b34c-9a2128ee1944",
"runId" : "36a31550-c2c1-48b0-9a6f-ce112572f59d",
"name" : null,
"timestamp" : "2023-05-30T17:48:12.079Z",
"batchId" : 2,
"numInputRows" : 10,
"sources" : [ {
"description" : "DeltaSource[dbfs:/mnt/.../LimitedRecords.delta]",
"startOffset" : {
"sourceVersion" : 1,
"reservoirId" : "6c25c8cd-88c1-4b74-9c96-a61c1727c3a2",
"reservoirVersion" : 3,
"index" : -1,
"isStartingVersion" : false
在输出中,我们还看到了 sources 部分,其中包含 reservoirVersion 变量,目前设置为 3。请记住,reservoirVersion 表示在这种情况下的下一个可能的版本 ID。如果我们对表进行 DESCRIBE HISTORY
,我们可以看到我们的版本是 2,所以下一个版本将是 3:
bash
%sql
describe history delta.`/mnt/datalake/book/chapter08/LimitedRecords.delta`
输出(仅显示 version 列):
diff
+-------+
|version|
+-------+
| 2 |
| 1 |
| 0 |
+-------+
在下一个查询中,我们向源表中添加了20条记录。然后,如果我们重新运行我们的流查询并查看原始数据,我们会看到这20条新记录,同时也会看到 startOffset 的 reservoirVersion 现在设置为 3:
json
{
"id" : "d89a5c02-052b-436c-a372-2445fb8d88d6",
..
"numInputRows" : 20,
...
"sources" : [ {
"description" : "DeltaSource[dbfs:/mnt/.../LimitedRecords.delta]",
"startOffset" : {
"sourceVersion" : 1,
"reservoirId" : "31611029-07d1-4bcc-8ee3-cad0d4fa8bc4",
"reservoirVersion" : 3,
...
},
这种 AvailableNow
模型意味着我们现在可以按照 "02 - AvailableNow Streaming" 笔记本中所示的方式运行流查询,只需一天一次,或每小时一次,或者任何用例需要的时间间隔。由于检查点文件中保存的状态,Delta Lake 将始终捕获自上次运行以来发生在源表中的所有更改。
除了 AvailableNow
触发器之外,还有一个 RunOnce
触发器,其行为非常相似。两者都将处理所有可用的数据。但是,RunOnce
触发器将在单个批次中处理所有记录,而 AvailableNow
触发器将在适当时处理数据的多个批次,通常可以更好地实现可伸缩性。
更新源记录
接下来,让我们看看当我们运行如下更新语句时会发生什么:
sql
%sql
-- Update query to demonstrate streaming update
-- behavior
UPDATE
taxidb.limitedyellowtaxis
SET
PickupLocationId = 100
WHERE
VendorId = 2
当我们查看相应事务日志条目的 commitInfo
操作时,我们看到以下内容:
json
"commitInfo": {
...
"operation": "UPDATE",
..
},
"notebook": {
"notebookId": "3478336043398159"
},
...
"operationMetrics": {
...
"numCopiedRows": "23",
...
"numUpdatedRows": "27",
...
},
...
}
我们可以看到有23行被复制到新的数据文件,而27行被更新,总共是50行。 因此,如果我们再次运行我们的查询,我们应该在我们的批次中看到确切的50行。当我们再次运行"02 - AvailableNow Streaming"笔记本时,我们会看到50行:
json
{
...
"batchId" : 4,
"numInputRows" : 50,
如果我们返回并再次运行流查询,我们会注意到以下错误:
python
Stream stopped...
com.databricks.sql.transaction.tahoe.DeltaUnsupportedOperationException:
Detected a data update (for example part-00000-....snappy.parquet) in the source
table at version 3. This is currently not supported. If you'd like to ignore
updates, set the option 'ignoreChanges' to 'true'. If you would like the data
update to be reflected, please restart this query with a fresh checkpoint
directory. The source table can be found at path
dbfs:/mnt/.../LimitedRecords.delta.
在这里,Delta Lake 告诉我们流中的数据更新当前不受支持。如果我们知道我们只想要新的记录,而不是更改,我们可以在 readStream
中添加 .option("ignoreChanges", "True")
选项:
python
# Start streaming from our source "LimitedRecords" table
# Notice that instead of a "read", we now use a "readStream",
# for the rest our statement is just like any other spark Delta read
# Uncomment the ignoreChanges option when you want to receive only
# new records, and no updated records
stream_df = \
spark \
.readStream \
.option("ignoreChanges", True) \
.format("delta") \
.load("/mnt/datalake/book/chapter08/LimitedRecords.delta")
如果我们现在重新运行流查询,它将成功。但是,当我们查看原始数据时,仍然会看到所有50个输入行,这看起来是错误的:
json
{
"id" : "d89a5c02-052b-436c-a372-2445fb8d88d6",
"runId" : "b1304246-4083-4275-8637-1f99768b8e03",
"name" : null,
"timestamp" : "2023-04-13T17:28:31.380Z",
"batchId" : 3,
"numInputRows" : 50,
"inputRowsPerSecond" : 0.0
这种行为是正常的。ignoreChanges
选项仍然会将 Delta 表中的所有重写文件发送到流中。这通常是所有更改记录的超集。然而,实际上只有插入的记录会被处理。
StreamingQuery
类
让我们看一下 streamQuery
变量的类型:
bash
# Let's take a look at the type
# of the streamQuery variable
print(type(streamQuery))
输出:
arduino
<class 'pyspark.sql.streaming.query.StreamingQuery'>
我们可以看到类型是 StreamingQuery
。如果我们调用 streamQuery
的 status
属性,我们会得到以下结果:
bash
# Print out the status of the last StreamingQuery
print(streamQuery.status)
输出:
python
{'message': 'Stopped', 'isDataAvailable': False, 'isTriggerActive': False}
查询当前已停止,没有可用的数据。没有触发器处于活动状态。另一个有趣的属性是 recentProgress
,它将打印出笔记本中流输出的原始数据部分相同的输出。例如,如果我们想要查看输入行数,我们可以打印以下内容:
css
print(streamQuery.recentProgress[0]["numInputRows"])
输出: 50
这个对象还有一些有趣的方法。例如,如果我们想要等待流终止,我们可以使用 awaitTermination()
方法。
重新处理源记录的全部或部分
由于我们一直在处理源表的许多批次,检查点文件系统地积累了所有这些更改。如果我们删除检查点文件并重新运行流查询,它将从源表的开头开始,并引入所有记录:
bash
%sh
# Uncomment this line if you want to reset the checkpoint
rm -r /dbfs/mnt/datalake/book/chapter08/StreamingTarget/_checkpoint
流查询的输出:
json
{
...
"numInputRows" : 50,
,..
"stateOperators" : [ ],
"sources" : [ {
"description" : "DeltaSource[dbfs:/mnt/.../LimitedRecords.delta]",
"startOffset" : null,
"endOffset" : {
...
"reservoirVersion" : 5,
...
},
"latestOffset" : null,
"numInputRows" : 50,
我们读取了源表中的所有行。我们从偏移量 null 开始,结束于 reservoirVersion 5。 我们还可以只流式传输部分更改。为此,我们可以在再次清除检查点后在 readStream
中指定 startingVersion
:
vbnet
stream_df = \
spark \
.readStream \
.option("ignoreChanges", True) \
.option("startingVersion", 3) \
.format("delta") \
.load("/mnt/datalake/book/chapter08/LimitedRecords.delta")
当我们查看原始数据时,我们得到以下结果:
json
{
...
"batchId" : 0,
"numInputRows" : 70,
"inputRowAnd" : 0.0,
...
"stateOperators" : [ ],
"sources" : [ {
...
"startOffset" : null,
"endOffset" : {
"sourceVersion" : 1,
"reservoirId" : "32c71d93-ca81-4d6e-9928-c1a095183016",
"reservoirVersion" : 6,
"index" : -1,
"isStartingVersion" : false
},
我们得到了70行。这是不正确的,因为我们从版本3开始。让我们看一下表8-1,总结了我们迄今为止使用过的版本和操作。
这验证了流查询的输入行总数。通过将其与 DESCRIBE HISTORY
命令结合使用,设置 startingVersion
会为我们提供许多选项。我们可以查看历史记录,然后决定从哪个时间点加载数据。
从变更数据源读取流
在第6章,您了解到Delta Lake如何记录通过CDF写入表中的所有数据的"更改事件"。这些更改可以传递给下游消费者。这些下游消费者可以使用具有 .readStream()
的流查询读取在CDF中捕获和传输的更改事件。
要在启用了CDF的表中读取CDF的更改,请将 readChangeFeed
选项设置为 true
。将 readChangeFeed
设置为 true
与 .readStream()
结合使用将允许我们有效地从源表中流式传输更改到下游目标表。我们还可以使用 startingVersion
或 startingTimestamp
来指定Delta表流源的起始点,而无需处理整个表:
python
# Read CDF stream with readChangeFeed since version 5
spark.readStream \
.format("delta") \
.option("readChangeFeed", "true") \
.option("startingVersion", 5) \
.table(" <delta_table_name> ")
# Read CDF stream since starting timestamp 2023-01-01 00:00:00
spark.readStream \
.format("delta") \
.option("readChangeFeed", "true") \
.option("startingTimestamp", "2023-01-01 00:00:00") \
.table(" <delta_table_name> ")
使用 .option("readChangeFeed", "true")
将返回带有CDF架构的表更改,该架构提供了 _change_type
、 _commit_timestamp
和 _commit_version
,这是 readStream
将要使用的。以下是CDF数据的示例(这是来自第6章的内容):
sql
+----------+----------------+------------+------------------+-----------------+
| VendorId | PassengerCount | FareAmount | _change_type | _commit_version |
+----------+----------------+------------+------------------+-----------------+
| 1 | 1000 | 2000 | update_preimage | 2 |
+----------+----------------+------------+------------------+-----------------+
| 1 | 1000 | 2500 | update_postimage | 2 |
+----------+----------------+------------+------------------+-----------------+
| 3 | 7000 | 10000 | delete | 3 |
+----------+----------------+------------+------------------+-----------------+
| 4 | 500 | 1000 | insert | 4 |
+----------+----------------+------------+------------------+-----------------+
先前读取变更 feed 的代码片段指定了 startingVersion
或 startingTimestamp
。重要的是要注意这些方法是可选的,如果没有提供,流将在流式处理时获取表的最新快照,将其作为插入,未来的更改作为变更数据。
读取变更数据时,我们可以指定其他选项,特别是围绕数据更改和速率限制的选项(每个微批次处理多少数据)。表 8-2 突出显示了在使用 Delta 表作为流源时在流查询中使用的其他重要选项。
速率限制选项对于更好地控制整体资源管理和利用率非常有用。例如,当有大量新的数据文件或大量要处理的数据时,我们可能希望避免潜在的过载处理资源(例如,我们的集群)。控制速率限制可以通过控制微批次大小来实现更平衡的处理体验。如果我们想要有效地控制速率限制,同时又要忽略删除以避免干扰现有的流查询,我们可以在流查询中指定这些选项:
vbnet
# Read CDF stream with readChangeFeed and don't specify the
# starting timestamp or version. Specify rate limits and ignore deletes.
spark.readStream \
.format("delta") \
.option("maxFilesPerTrigger", 50) \
.option("maxBytesPerTrigger", "10MB") \
.option("ignoreDeletes", "true") \
.option("readChangeFeed", "true") \
.table("delta_table_name")
在此示例中,我们设置了速率限制选项,忽略了删除,并省略了起始时间戳和版本选项。这将读取表的最新版本(因为未指定版本或时间戳),并更好地控制微批次的大小和处理资源,以减少对流查询的潜在中断。
总结
Delta Lake的一个关键特性是将批处理和流处理数据统一到单个表中。本章深入探讨了Delta Lake如何与Structured Streaming完全集成,以及Delta表如何支持连续数据流的可伸缩、容错和低延迟处理。
通过readStream和writeStream与Structured Streaming集成,Delta表可以用作流处理的源和目标,并利用流处理的DataFrame。本章的示例演示了如何将更改读取到这些流处理的DataFrame中,以及如何执行简单的处理将流写入目标。然后,我们探讨了检查点文件和元数据、查询过程日志以及流处理类,以更好地了解流处理是如何在幕后跟踪信息的。最后,您学习了如何使用readStream利用CDF传输和读取流查询中的逐行更改。
在将批处理和流处理数据统一到单个Delta表后,第9章将深入探讨如何安全地与其他组织共享这些数据。