现在比以往任何时候,世界都充满了实时数据源。从电子商务、社交网络动态、航班数据到网络安全和物联网设备,数据源的数量和可以访问它们的速度都在增加。一个问题是,尽管某些事件级别的操作是合理的,但我们依赖的大部分信息都存在于这些信息的聚合中。因此,我们面临着两个对立的优先事项:(a) 尽可能缩短洞察时间,以及 (b) 从聚合信息中捕获足够有意义和可操作的信息。多年来,我们一直看到处理技术朝这个方向转变,正是在这种环境下,Delta Lake 应运而生。Delta Lake 为我们提供了一个开放的湖仓格式,支持多个批处理和流式过程的无缝集成,同时提供像 ACID 事务和可扩展的元数据处理等必要功能,而这些在大多数分布式数据存储中通常是缺失的。考虑到这一点,本章我们将深入探讨 Delta Lake 的流处理,重点介绍流处理的核心功能、配置选项、特定使用方法,以及 Delta Lake 与 Databricks Delta Live Tables 的关系。
流处理与 Delta Lake
在接下来的部分,我们将先覆盖一些基础概念,然后深入探讨如何使用 Delta Lake 进行流处理。我们将从概念和术语的概述开始,接着介绍几种我们可以与 Delta Lake 一起使用的流处理框架(关于流处理的更深入介绍,参见 Jules S. Damji、Brooke Wenig、Tathagata Das 和 Denny Lee 的《Learning Spark》[O'Reilly])。然后,我们将看一下 Delta Lake 的核心功能、可用选项以及一些 Apache Spark 中常见的更高级案例。最后,我们将介绍 Databricks 中的一些相关功能,如 Delta Live Tables 以及它与 Delta Lake 的关系,然后回顾如何使用 Delta Lake 中的 Change Data Feed 功能。
流处理与批处理
数据处理作为一个概念对我们来说很容易理解:在数据处理生命周期中,我们接收数据,对其进行各种操作,然后存储或传输数据。那么,批处理数据流程与流处理数据流程的主要区别是什么呢?延迟。虽然可以在不同的点上测量延迟,但延迟本质上是记录输入和记录输出之间的时间间隔。所有的处理流程,最重要的驱动因素就是延迟,因为这些过程的业务逻辑通常是相似的,区别在于消息/文件大小和处理速度。使用哪种方法通常取决于时间要求或服务水平/交付协议,这些应该在项目开始时的需求收集阶段就被明确。需求还应考虑从原始数据中获取可操作洞察的延迟,这将决定您的处理方法选择。我们通常更倾向于使用一个统一的批处理和流处理 API 的框架,因为它们的处理逻辑几乎没有区别,这使得当需求随着时间变化时,我们可以保持灵活性。这比使用像 Lambda 架构那样分别运行批处理和流处理的不同系统要简单得多。
每个批处理都有明确的开始和结束点,也就是说,在时间和格式上都有界限。例如,我们可能会将每个独立的日历日期处理为一个批次,或者将“一组文件”作为一个批次来处理。而在流处理中,我们的视角会有所不同,我们将数据视为无限制的、连续的数据流。即使是在文件到达存储的情况下,我们也可以将文件视为一个连续到达的流(比如日志数据)。最终,这种“无限制性”是将源视为数据流的唯一条件。在图7-1中,批处理流程等同于每次计划运行时处理六个文件的组,而流处理则是一直运行,并且在文件到达时处理每个文件。
正如我们稍后将在比较几种与 Delta Lake 配合使用的流处理框架时看到的,像 Apache Flink 或 Apache Spark 这样的流处理引擎可以与 Delta Lake 一起工作,作为数据流的起点或终点。这些多重角色意味着 Delta Lake 可以在不同类型的流式工作负载的多个阶段使用。通常,在更复杂的数据管道中,我们会看到存储层和处理引擎共同存在,涉及两种操作方式。大多数流处理引擎的一个共同特性是,它们仅仅是处理引擎。一旦我们解耦了存储和计算,必须分别考虑并选择每一个,但它们不能独立运行。
从实际角度来看,我们对其他相关概念(如处理时间和表维护)的思考方式会受到选择批处理或流处理的影响。如果一个批处理流程被安排在特定时间运行,那么我们可以轻松地衡量该流程运行的时间、处理的数据量,并将其与其他处理流程链接起来,处理表维护操作。然而,对于流处理,我们需要以不同的方式来衡量和维护这些流程,但我们之前看到的许多功能——例如自动压缩和优化写入——都可以在这两个领域中使用。在图7-2中,我们可以看到,在现代系统中,批处理和流处理可以融合在一起,一旦我们脱离了传统框架,就可以更专注于延迟的权衡。通过选择一个具有合理统一 API 的框架,最小化批处理和流处理用例编程上的差异,并将其运行在像 Delta Lake 这样的存储格式之上,这样可以简化维护操作,并支持这两种处理方式,我们最终会得到一个更强大且灵活的系统,它可以处理所有的数据处理任务,减少了平衡多个工具的需求,避免了运行多个系统所带来的复杂性。这使得 Delta Lake 成为流处理工作负载的理想存储解决方案。接下来,我们将考虑一些流处理应用程序的特定术语,并回顾一些与 Delta Lake 可用的不同框架集成。
流处理术语
在许多方面,流处理和批处理过程非常相似,主要区别在于延迟和节奏。然而,这并不意味着流处理没有其独特的术语。一些术语,如源(source)和接收器(sink),与批处理中的使用差别不大,而像检查点(checkpoint)和水印(watermark)这样的术语在批处理中并不适用。了解这些术语是有帮助的,但如果你需要更深入的了解,可以参考《Stream Processing with Apache Flink》(Fabian Hueske 和 Vasiliki Kalavri 著,O’Reilly)或《Learning Spark》。
源(Source)
流处理源是指任何可以被视为无界数据集的数据来源。数据流处理的源种类繁多,最终取决于所处理任务的性质。Spark 和 Flink 生态系统中有许多不同的消息队列和发布/订阅连接器作为数据源。这些包括一些常见的选择,如 Apache Kafka、Amazon Kinesis、ActiveMQ、RabbitMQ、Azure Event Hubs 和 Google Pub/Sub。例如,这些系统也可以通过监控云存储位置中的新文件来生成流数据。接下来我们会看到 Delta Lake 如何作为流数据源。
接收器(Sink)
流处理的接收器(sink)同样有不同的形式。我们常常看到许多相同的消息队列和发布/订阅系统在使用,但特别是在接收器方面,我们经常会看到一些物化层,例如键值存储、关系型数据库(RDBMS)或像 AWS S3 或 Azure ADLS 这样的云存储。一般来说,最终的目标通常属于后者类别,而在源和目标之间,我们会看到多种方法的组合。Delta Lake 作为接收器表现极为出色,尤其在管理大规模、高吞吐量的流数据摄取过程时。
检查点(Checkpoint)
在流处理过程中,通常需要确保实现了检查点(checkpoint)。检查点用于跟踪处理任务的进度,使得失败恢复成为可能,而无需每次都从头开始重新处理。这是通过记录流的偏移量及其相关的状态信息来实现的。在一些流处理引擎(如 Flink 和 Spark)中,有内建机制可以简化检查点操作的使用。有关具体使用方法,可以参考相关文档。
注意
本章的所有示例和一些其他辅助代码可以在本书的 GitHub 仓库中找到。
以 Spark 为例,当我们开始一个流写入过程并定义适当的检查点位置时,它会在后台创建目标位置的一些目录。在以下示例中,我们找到一个从名为 "gold" 的流程写入的检查点(并且目录也类似命名):
tree -L 1 /…/ckpt/gold/
.../ckpt/gold/
├── __tmp_path_dir
├── commits
├── metadata
├── offsets
└── state
- metadata 目录包含有关流查询的一些信息。
- state 目录包含查询相关的状态快照(如果有的话)。
- offsets 和 commits 目录按微批次级别跟踪流从源到接收器的进度。对于 Delta Lake 来说,这意味着分别跟踪输入和输出文件,如稍后所述。
水印(Watermark)
水印是相对于正在处理的记录的时间概念。对于我们的讨论来说,这个话题和使用方式稍微复杂一些,我们建议查阅相关文档。对于我们的简化定义来说,水印基本上是对在处理过程中可以接受的数据延迟的限制。水印特别用于与窗口聚合操作结合使用。
Apache Flink
Apache Flink 是一个主要的分布式内存处理引擎,支持有界和无界数据处理。Flink 支持许多预定义和内建的数据流源和接收器连接器。在数据源方面,Flink 支持许多消息队列和发布/订阅连接器,如 RabbitMQ、Apache Pulsar 和 Apache Kafka(有关更详细的流连接器信息,请参见 Flink 文档)。虽然像 Kafka 这样的连接器可以作为输出目标,但更常见的是将数据写入文件存储、Elasticsearch,甚至通过 JDBC 连接器写入数据库。你可以在 Flink 的文档中找到更多有关连接器的信息。
使用 Delta Lake,我们为 Flink 提供了另一个数据源和目标,这在多工具混合生态系统中至关重要,或者可以简化逻辑处理的转换。例如,在 Flink 中,我们可以专注于事件流处理,然后将数据直接写入云存储中的 Delta 表,之后可以在 Spark 中进行进一步处理。或者,我们也可以完全反向操作,从 Delta Lake 中的记录向消息队列写入数据。关于这个连接器的更详细介绍,包括实现和架构细节,可以在 delta.io 网站的博客中找到。
Apache Spark
Apache Spark 同样支持许多输入源和输出接收器。由于 Apache Spark 更倾向于用于大规模数据摄取和 ETL,我们看到的输入源种类相对偏向与事件处理中心的 Flink 系统有所不同。除了基于文件的源,Spark 还原生集成了 Kafka,并且支持一些独立维护的连接器库,如 Azure Event Hubs、Google Pub/Sub Lite 和 Apache Pulsar。
Spark 也有多个输出接收器可用,但 Delta Lake 是 Spark 数据处理中的一种大规模目标。正如我们之前提到的,Delta Lake 设计的初衷就是解决大规模流式数据摄取过程中,Parquet 文件格式的局限性。由于 Delta Lake 的起源和与 Apache Spark 的长期历史关系,本文大部分内容将围绕 Spark 展开,但我们应当注意,许多概念在其他框架中也有类似的对应。
Delta-rs
Rust 生态系统同样有额外的处理引擎和库,并且由于名为 delta-rs 的实现,我们获得了可以在 Delta Lake 上运行的更多处理选项。这个领域是近年来快速发展的新兴方向,已经进行了大量的构建和完善。Polars 和 DataFusion 只是流数据处理的其他几个选择,它们与 delta-rs 配合得也相当好。这个领域正在快速发展,预计未来会有更多增长。
delta-rs 实现的另一个好处是它提供了直接的 Python 集成,这为数据流处理任务开辟了更多可能性。这意味着,对于小规模作业,可以使用 Python API(例如 AWS Boto3)来与那些通常需要更大规模框架进行交互的服务进行交互,从而减少不必要的开销。尽管你可能无法利用一些天然支持流式操作的框架中的某些功能,但仍然可以显著减少基础设施的需求,并保持超快的性能。
delta-rs 实现的最终结果是,Delta Lake 为我们提供了一种格式,使我们能够同时使用多个处理框架和引擎,而不依赖额外的关系型数据库管理系统(RDBMS),并且可以不依赖以 Java 为中心的技术栈。这意味着,即使在不同的系统中工作,我们也可以自信地构建数据应用,而不牺牲通过 Delta Lake 获得的内建优势。
Delta 作为数据源
Delta Lake 设计的初衷大部分是作为一个流处理接收器(sink),它增加了以前在实践中缺乏的功能性和可靠性。特别是,Delta Lake 简化了那些通常包含大量小型事务和文件的过程的维护,并提供了 ACID 事务保证。然而,在深入探讨这一方面之前,我们先来思考一下 Delta Lake 作为数据流源(streaming source)的情况。通过我们在事务日志中看到的增量性质,Delta Lake 提供了一个直接的数据源——包含良好排序的 ID 值的 JSON 文件。这意味着任何引擎都可以使用文件 ID 作为流式消息的偏移量,并且能够通过追加操作查看新添加的文件记录。事务日志中包含一个标志 dataChange,帮助区分合并或其他生成新文件的表维护事件,这些新文件不需要被传送到下游消费者。由于 ID 是单调递增的,这使得偏移量追踪变得更简单,因此下游消费者仍然能够实现精确一次(exactly-once)语义。
这一切的实际好处是,借助 Spark Structured Streaming,你可以将 readStream 格式定义为 "delta",它会首先处理目标表或文件中之前可用的所有数据,然后在数据追加时实时增量更新。这大大简化了许多处理架构(如我们之前看到的勋章架构(medallion architecture),稍后会更详细讨论),但目前我们可以假设,创建额外的数据精炼层变得更加自然,并且显著减少了开销成本。
在 Spark 中,readStream 本身定义了操作模式,"delta" 表示格式,操作按常规进行,大部分动作发生在后台。与此不同,Flink 的方法稍有不同,首先你会从 Delta 源对象(Delta source object)开始,构建一个 DataStream 类,然后使用 forContinuousRowData API 开始增量处理:
# Python 示例
streamingDeltaDf = (
spark
.readStream
.format("delta")
.option("ignoreDeletes", "true")
.load("/files/delta/user_events")
)
Delta 作为接收器
许多用于流处理接收器的功能(如异步压缩操作)在之前并不可用,或者不可扩展,无法支持现代高流量的流式数据摄取。用户活动和设备的可用性以及物联网(IoT)的快速发展,迅速推动了大规模流式数据源的增长。此时一个最关键的问题便是:如何高效且可靠地捕获所有数据?
Delta Lake 的许多特性正是为了应对这个问题。例如,操作提交到事务日志的方式,天然地适合流处理引擎的上下文,在这里你跟踪流的进度并确保只有已完成的事务才会提交到日志中,而损坏的文件不会被提交;这使得你可以确保可靠地捕获所有源数据,并提供一定的可靠性保证。通过事务中每个操作添加的行和文件数,Delta 日志产生的度量帮助你分析流处理过程的稳定性(或变动性)。
大规模的流处理通常以微批处理(microbatches)的形式进行,本质上是较大批量处理的较小规模事务。结果是,我们可能会看到许多写入操作来自流处理引擎,它们在数据传输过程中捕获数据。当这种处理发生在“始终在线”的流处理过程中时,可能会很难管理数据生态系统中的其他方面,比如运行维护操作、回填数据或修改历史数据。像 optimize 这样的表优化命令,以及与多个进程交互 Delta 日志的能力,意味着这些操作在执行之前已经考虑过,并且由于其增量性质,我们可以更容易、以可预测的方式中断这些过程。然而,另一方面,我们可能仍然需要更频繁地思考,这些操作的组合可能会产生偶尔需要避免的冲突。
在 Delta Lake 和 Apache Spark 中,特别是在使用勋章架构(medallion architecture)时(我们将在第9章中深入讨论),它们作为流处理接收器和源协同工作,成为一种中间解决方案(参见图7-3)。这实际上在许多情况下消除了对额外基础设施的需求,简化了整体架构,同时仍然提供了低延迟、高吞吐量的流处理机制,并保持了干净的数据工程实践。
将一个流式 DataFrame 对象写入 Delta Lake 非常简单,只需要通过 writeStream 方法指定格式和目录位置:
# Python 示例
(streamingDeltaDf
.writeStream
.format("delta")
.outputMode("append")
.start("/<delta_path>/")
)
类似地,你可以将一个 readStream 定义(格式相同)和一个 writeStream 定义链式组合起来,设置完整的输入-转换-输出流程(此处省略了转换代码以简化示例):
# Python 示例
(spark
.readStream
.format("delta")
.load("/files/delta/user_events")
…
# 其他转换逻辑
…
.writeStream
.format("delta")
.outputMode("append")
.start("/<delta_path>/")
)
Delta Streaming 选项
现在我们已经讨论了 Delta Lake 中的流式数据进出是如何在概念上工作的,让我们深入了解实际操作中的技术选项,并探讨一些你可能希望修改它们的背景。我们将从如何限制输入速率开始,特别是如何结合 Apache Spark 中的一些功能来利用这一点。接下来,我们将探讨一些可能希望跳过某些事务的情况。最后,我们将考虑时间与处理任务之间的一些关系方面。
限制输入速率
在流式处理时,我们通常需要在三个因素之间找到平衡:准确性、延迟和成本。通常,我们不想放弃准确性(除非是想丢弃陈旧记录或限制数据范围的情况),因此这通常是在延迟和成本之间的权衡——也就是说,我们可以选择接受更高的成本,并增加资源来尽可能快地处理数据,或者我们可以限制数据的大小,并接受更长的数据处理时间。通常,这一切大多由流式处理引擎控制,但 Delta Lake 提供了两个额外的选项,让我们能更好地控制微批的大小:
- maxFilesPerTrigger
设置每个微批中考虑的新文件数量的上限。默认值是 1000。 - maxBytesPerTrigger
设置每个微批中处理数据的近似限制。这个选项设定了一个软最大值,意味着微批大约处理这个量的数据,但当最小输入单元大于这个限制时,也可以处理更多的数据。换句话说,这个大小设置更像是一个阈值,数据量达到这个阈值时就会触发处理,具体处理多少文件,取决于超过阈值的文件数量——就像是每个微批文件数量的动态设置。
这两个设置可以通过 Structured Streaming 中的触发器(Triggers)来平衡,以增加或减少每个微批处理的数据量。例如,你可以利用这些设置来降低计算所需的资源,或根据你将要处理的文件大小来定制作业。如果你使用 Trigger.Once 进行流式处理,那么这两个选项将被忽略。默认情况下不会设置这两个选项。实际上,你可以在同一个流式查询中同时使用 maxBytesPerTrigger 和 maxFilesPerTrigger,此时微批会运行直到达到其中一个限制。
警告
我们需要注意的是,如果设置了较长的触发器或作业调度间隔,同时设置了较短的日志保留期限(logRetentionDuration),可能会导致旧的事务被跳过,特别是如果进行了清理。由于流式处理不知道之前发生了什么,它会从日志中的最早可用事务开始处理,这意味着数据可能会在处理时被跳过。一个简单的例子是,当 logRetentionDuration 被设置为一两天,但处理作业每周运行一次时,任何在此期间进行的清理操作会删除一些较旧版本的文件,这样会导致这些变化在下一次运行时未被传播。
忽略更新或删除
到目前为止,我们在讨论 Delta Lake 中的流式数据时,还没有提及一个我们应该讨论的重要问题。在前面的章节中,我们已经看到了一些 Delta Lake 的功能,特别是更新和删除操作是如何改善 CRUD 操作的便利性的。然而,我们需要指出的是,当我们在 Delta Lake 中进行流式处理时,默认假设我们是从一个仅追加类型的数据源进行流式处理——也就是说,它假定发生的增量变化只是新文件的添加。那么问题来了:如果我的流式数据源中包含更新或删除操作,会发生什么呢?
简单来说,Spark 的 readStream 操作会失败,至少在默认设置下是如此。这是因为作为流式数据源,我们期望只接收新文件,因此我们必须指定如何处理来自更新或删除的文件。对于大规模数据接入表或接收变更数据捕获(CDC)记录的情况,这通常没有问题,因为这些表通常不会受到其他类型操作的影响。处理这些情况有两种方法。较为复杂的方法是删除输出和检查点,并从头开始重启流。更简单的方法是利用 ignoreDeletes 或 ignoreChanges 选项,尽管它们的名称相似,但它们的行为却有很大的不同。最大的注意事项是,当使用这两个选项中的任何一个时,你需要手动追踪并在下游做出相应的修改,接下来我们将详细解释。
ignoreDeletes 设置
ignoreDeletes 设置的作用与其名称一样:当遇到删除操作时,如果没有创建新文件,它将忽略这些删除操作。这个设置之所以重要,是因为如果你删除了上游文件,这些变化将不会传递到下游目标,但是我们可以使用此设置来避免流式处理作业失败,同时仍然支持重要的删除操作,例如当我们需要根据 GDPR 的“被遗忘权”清除个别用户数据时。关键是,数据需要按照我们在删除操作中过滤的相同值进行分区,以便没有残留的文件会被重新生成。这意味着,相同的删除操作可能需要在多个表中运行,但我们可以在流处理过程中忽略这些小的删除操作,并继续处理,留下下游删除操作由另一个独立的过程处理。
ignoreChanges 设置
ignoreChanges 设置的行为与 ignoreDeletes 有些不同。与跳过仅删除文件的操作不同,ignoreChanges 允许由于更改而生成的新文件像新文件一样传递下来。这意味着,如果我们在某个特定文件内更新了一些记录,或者删除了文件中的一些记录,以至于生成了文件的新版本,那么这个新版本的文件会在下游处理时被视为新文件。这有助于确保我们拥有最新版本的数据。然而,理解这一点的影响很重要,以避免数据重复。我们在这些情况下需要确保通过合并逻辑处理重复记录,或者通过加入额外的时间戳信息(例如添加 version_as_of 时间戳)来区分数据。在许多变更操作的情况下,大部分记录将会被重新处理,但没有发生变化,因此合并或去重通常是首选的处理方式。
示例
假设你有一个 Delta Lake 表 user_events,包含 date、user_email 和 action 列,并且该表按 date 列进行分区。假设我们正在将 user_events 表作为我们更大管道过程中的流式数据源,并且我们需要由于 GDPR 请求而删除一些数据。
- 当你在分区边界上删除数据时(即在查询的 WHERE 子句中根据分区列过滤数据),文件已经根据这些值分配到目录,因此删除操作只是从表的元数据中删除了这些文件。
如果你只想删除与特定日期对齐的整个分区数据,可以在 readStream 中添加 ignoreDeletes 选项:
streamingDeltaDf = (
spark
.readStream
.format("delta")
.option("ignoreDeletes", "true")
.load("/files/delta/user_events")
)
- 如果你基于非分区列(如
user_email)删除数据,则需要使用ignoreChanges选项:
streamingDeltaDf = (
spark
.readStream
.format("delta")
.option("ignoreChanges", "true")
.load("/files/delta/user_events")
)
同样地,如果你基于非分区列(如 user_email)更新记录,那么将会创建一个新文件,其中包含已更改的记录以及原始文件中未更改的记录。设置 ignoreChanges 后,readStream 查询会看到这个文件,因此你需要在流式处理中添加额外的逻辑,以避免重复数据进入输出。
初始处理位置
当你从 Delta Lake 数据源开始一个流处理任务时,默认行为是从表的最早版本开始,然后逐步处理到最新版本。当然,有时候我们并不希望从最早版本开始,比如当我们需要删除流处理的检查点并从某个中间点重新启动,甚至是从最新的可用点重新启动。幸运的是,由于有事务日志,我们实际上可以指定这个起始点,这样就无需从日志的开始重新处理所有内容,类似于检查点允许流从某个特定点恢复。
我们可以通过两种方式来定义流处理的初始位置。第一种方法是指定我们想要开始处理的具体版本,第二种方法是指定我们想要开始处理的时间。这些选项通过 startingVersion 和 startingTimestamp 提供。
指定 startingVersion 就是你可能预期的操作。给定事务日志中的某个特定版本,提交的文件将是我们开始处理的第一批数据,之后的处理将继续进行。这样,从该版本(包括该版本)开始的所有表的更改都将被流式数据源读取。你可以查看事务日志中的版本参数,确定你需要的具体版本,或者也可以指定“latest”来仅获取最新的更改。
提示:
在使用 Apache Spark 时,最简单的方式是通过 SQL 上下文中的 DESCRIBE HISTORY 命令输出的 version 列来查看提交版本。
同样,我们可以通过指定 startingTimestamp 来采用更具时间性的方式。使用时间戳选项时,我们会看到两种略有不同的行为。如果给定的时间戳正好与某个提交时间匹配,它将包括该提交的文件进行处理;否则,行为将是处理该时间点之后版本的文件。一个特别有用的功能是,它并不严格要求提供完整格式化的时间戳字符串;我们也可以使用类似的日期字符串,它会被自动解析。这意味着,我们的 startingTimestamp 参数可以是以下格式之一:
- 时间戳字符串,例如
2023-03-23T00:00:00.000Z - 日期字符串,例如
2023-03-23
与其他设置不同,我们在这里不能同时使用这两个选项;我们必须选择其中一个。如果这个设置被添加到一个已经定义了检查点的现有流式查询中,那么这两个选项将会被忽略,因为它们只在启动新查询时才会生效。
注意事项
虽然使用这些选项可以让你从指定位置开始处理源数据,但架构将反映最新的可用版本。这意味着,如果在指定的起始点和当前版本之间发生了不兼容的架构更改,可能会导致错误的值或处理失败。
示例
考虑我们之前提到的 user_events 数据集,假设你想从版本 5 开始读取发生的更改。那么你可以像这样写:
(spark
.readStream
.format("delta")
.option("startingVersion", "5")
.load("/files/delta/user_events")
)
或者,如果你想根据日期读取更改——假设从 2023-04-18 以来的所有更改,你可以使用如下代码:
(spark
.readStream
.format("delta")
.option("startingTimestamp", "2023-04-18")
.load("/files/delta/user_events")
)
初始快照与 withEventTimeOrder
使用 Delta Lake 作为流式数据源时,默认的排序是基于文件的修改日期。我们也看到,在初次运行查询时,查询会自然地执行,直到表的当前状态被处理完为止。我们将此版本的表——从起始点到当前状态——称为流式查询开始时的初始快照。在 Databricks 中,我们有一个额外的选项,可以为此初始快照解释时间。我们可能需要考虑,针对我们的数据集,基于修改时间的默认排序是否正确,或者是否存在我们可以利用的事件时间字段,这样可能会简化数据排序的过程。
与记录最后修改(即最后看到)时间相关的时间戳并不一定与事件发生的时间对齐。考虑物联网设备数据,它可能以不同间隔的批次形式交付。这意味着,如果你依赖于 last_modified 时间戳列或类似字段,记录可能会被无序处理,这可能导致水印将晚到的事件丢弃。你可以通过启用 withEventTimeOrder 选项来避免这种数据丢失问题,这样会优先使用事件时间而不是修改时间。以下是设置该选项的示例,假设 event_time 列有水印选项:
(spark
.readStream
.format("delta")
.option("withEventTimeOrder", "true")
.load("/files/delta/user_events")
.withWatermark("event_time", "10 seconds")
)
启用此选项后,初始快照会被分析以获取总的时间范围,然后将其分成多个桶,每个桶依次作为微批处理,这可能会导致一些额外的 shuffle 操作。你仍然可以使用 maxFilesPerTrigger 或 maxBytesPerTrigger 选项来限制处理速率。
在这种情况下,有几个要注意的事项:
-
数据丢失问题 只会在默认排序下处理初始 Delta 快照时发生。
-
withEventTimeOrder是一个仅在流式查询开始时生效的设置,因此在查询开始后,并且初始快照仍在处理时,无法修改此设置。如果你想修改withEventTimeOrder设置,必须删除检查点并利用初始处理位置选项重新启动查询。 -
如果你启用了
withEventTimeOrder,则在初始快照处理完成之前,无法将其降级到不支持该功能的版本。如果需要降级版本,你可以等待初始快照处理完成,或者删除检查点并重新启动查询。 -
存在一些较为罕见的场景,无法使用
withEventTimeOrder:- 如果事件时间列是生成的列,并且在 Delta 源和水印之间有非投影的转换。
- 如果流式查询中有多个 Delta 源的水印。
-
由于可能增加 shuffle 操作,初始快照的处理性能可能会受到影响。
使用事件时间排序会触发对初始快照的扫描,以找到每个微批次的对应事件时间范围。这意味着,为了更好的性能,我们需要确保事件时间列是我们收集统计信息的列之一。这样,我们的查询可以利用数据跳跃,从而加速过滤操作。如果合理的话,你可以通过基于事件时间列对数据进行分区来提高处理性能。性能指标应显示每个微批次中引用了多少文件。
注意:
设置 spark.databricks.delta.withEventTimeOrder.enabled 为 true 可以作为集群级别的 Spark 配置,但要注意,这样做会使其应用于集群上运行的所有流式查询。
使用 Apache Spark 的高级用法
到目前为止,我们讨论的大部分功能可以通过前面提到的多个框架应用。但在这里,我们将重点关注一些在使用 Apache Spark 时常遇到的特定案例。这些是使用框架的特性,可以避免我们直接使用 Delta Lake 中某些内建功能的情况。
幂等流式写入(Idempotent Stream Writes)
前面的讨论大多集中在从单一数据源到单一目标的处理任务的执行。在现实世界中,我们可能并不总是拥有这么简洁明了的管道;相反,我们可能会发现自己正在构建使用多个源并写入多个目标的管道,而且这些目标可能还会重叠。借助事务日志和原子提交的行为,我们可以从功能角度支持多个写入者写入单一 Delta Lake 目标,这一点我们已经讨论过了。那么,在流处理管道中,我们如何应用这种支持呢?
在 Apache Spark 中,我们可以使用 foreachBatch 方法,它在 Structured Streaming 的 DataFrame 上可用,允许我们为每个流的微批次定义更加自定义的逻辑。我们通常会使用这个方法来支持将单个流源写入多个目标。我们遇到的问题是,如果存在两个不同的目标,且写入第二个目标时事务失败,那么每个目标的处理状态就会不同步。更具体地说,由于第一次写入已经完成,而第二次写入失败,当流处理作业重新启动时,它会基于上次运行的相同偏移量重新开始,因为它未成功完成。
考虑以下示例,其中我们有一个 sourceDf 的 DataFrame,并且希望将其分批处理并写入两个不同的目标。我们定义一个函数,接收输入的 DataFrame,并通过正常的 Spark 操作将每个微批次写出。然后,我们可以使用 writeStream 方法中的 foreachBatch 方法应用该函数:
sourceDf = ... # 流式源 DataFrame
# 定义一个将数据写入两个目标的函数
def writeToDeltaLakeTables(batch_df):
# 目标位置 1
(batch_df
.write
.format("delta")
.save("/<delta_path_1>/")
)
# 目标位置 2
(batch_df
.write
.format("delta")
.save("/<delta_path_2>/")
)
# 使用 ‘foreachBatch’ 方法应用函数
(sourceDf
.writeStream
.format("delta")
.queryName("Unclear status stream")
.foreachBatch(writeToDeltaLakeTables)
.start()
)
假设在写入第一个位置后但在第二次写入完成之前发生了错误。由于事务失败,我们知道第二个表不会有任何内容提交到日志中,但第一个表的事务是成功的。当我们重新启动作业时,它会从相同的点开始并重新执行该微批次的整个函数,这可能导致重复数据被写入第一个表。幸运的是,Delta Lake 提供了一些帮助我们解决这个问题的功能,允许我们指定更细粒度的事务追踪。
幂等写入(Idempotent Writes)
假设我们正在使用流式源的 foreachBatch 并写入两个目标。我们希望将 foreachBatch 事务的结构与一些巧妙的 Delta Lake 功能结合,确保我们在所有表中提交微批次事务,而不会在某些表中出现重复事务(即我们希望实现对表的幂等写入)。我们有两个可以帮助我们实现这一目标的选项:
txnAppId
这是一个唯一的字符串标识符,充当每次 DataFrame 写操作的应用 ID,标识每次写入的源。你可以使用流式查询的 ID 或其他有意义的名称作为 txnAppId。
txnVersion
这是一个单调递增的数字,充当事务版本,并在功能上成为 writeStream 查询的偏移标识符。
小贴士
应用 ID(txnAppId)可以是任何用户生成的唯一字符串,并且不需要与流 ID 相关,因此可以用来更功能化地描述执行操作的应用或标识数据源。实际上,使用相同的 DataFrameWriter 选项,也可以在批处理处理时实现类似的幂等写入。
通过包括这两个选项,我们可以在写入级别创建唯一的源和偏移量追踪,即使在 foreachBatch 操作中写入多个目标时也是如此。这使得在表级别能够检测到重复的写入尝试,并将其忽略。也就是说,如果在处理多个目标表中的一个时写入被中断,我们可以继续处理,而不会对已经成功提交事务的表执行重复写操作。当流从检查点重新启动时,它会从相同的微批次开始,但在 foreachBatch 中,由于写操作现在以表级别粒度进行检查,我们只会写入那些未能成功完成的表,因为我们会使用相同的 txnAppId 和 txnVersion 标识符。
警告
如果你想从源重新启动处理并删除/重新创建流式检查点,必须在重新启动查询之前提供一个新的 appId。如果没有提供,所有从重新启动查询开始的写入将被忽略,因为它将包含相同的 txnAppId,而批次 ID 值将重新开始,因此目标表会将它们视为重复事务。
如果我们想使用这些选项更新之前的示例函数,实现幂等写入多个位置,我们可以为每个目标指定选项,如下所示:
app_id = ... # 用作应用程序 ID 的唯一字符串。
def writeToDeltaLakeTableIdempotent(batch_df, batch_id):
# 目标位置 1
(batch_df
.write
.format("delta")
.option("txnVersion", batch_id)
.option("txnAppId", app_id)
.save("/<delta_path>/")
)
# 目标位置 2
(batch_df
.write
.format("delta")
.option("txnVersion", batch_id)
.option("txnAppId", app_id)
.save("/<delta_path>/")
)
合并(Merge)
另一个常见的情况是,我们在流处理时经常使用 foreachBatch。考虑到我们之前看到的一些限制,我们可能允许大量未更改的记录重新通过管道进行处理,或者我们可能需要更高级的匹配和转换逻辑,例如处理 CDC(Change Data Capture)记录。为了更新值,我们需要将更改合并到现有表中,而不是仅仅附加信息。不幸的是,流式处理的默认行为要求我们使用附加类型的操作(除非我们利用 foreachBatch)。
我们在第三章中查看了 merge 操作,它允许我们使用匹配条件更新或删除现有记录,并附加不匹配的记录——也就是说,我们可以执行 upsert 操作。由于 foreachBatch 让我们像对待普通 DataFrame 一样对待每个微批次,因此在微批次级别我们实际上可以使用 Delta Lake 执行这些 upsert 操作。你可以使用 MERGE SQL 操作或其对应的 Scala、Java 和 Python Delta Lake API 来将数据从源表、视图或 DataFrame 合并到目标 Delta 表。它甚至支持超出 SQL 标准的扩展语法,以促进更复杂的用例。
在 Delta Lake 上执行合并操作通常需要对源数据进行两次扫描。如果你在源 DataFrame 中使用非确定性函数,如 current_timestamp 或 random,那么对源数据的多次扫描可能会产生不同的行值,导致不正确的结果。你可以通过使用更具体的函数或列值来避免这种情况,或者将结果写入中间表。缓存源数据也可能有帮助,因为缓存失效可能导致源数据被部分或完全重新处理,从而产生类似的值变化(例如,当集群缩减时,丢失一些执行器)。我们已经看到,试图做一些如使用盐列来根据随机数生成重新划分 DataFrame 分区的事情时,这种情况可能会以令人惊讶的方式失败(例如,Spark 无法找到磁盘上的 shuffle 分区,因为随机前缀与重新运行时的预期不同)。合并操作的多次扫描增加了这种情况发生的可能性。
让我们考虑一个使用合并操作的例子,通过 foreachBatch 更新一组客户的最新每日零售交易汇总。在这个例子中,我们将根据客户 ID 进行匹配,并包含交易日期、商品数量和金额。实际上,我们使用 mergeBuilder API 来处理流式 DataFrame 的逻辑。我们在函数内提供客户 ID 作为匹配条件,并定义变更源,然后允许删除机制,或者更新现有客户或在新客户出现时插入。函数内的操作流程是指定要合并的内容,提供匹配条件的参数,并在记录匹配或不匹配时采取的操作(我们可以为此添加一些额外的条件):
from delta.tables import *
def upsertToDelta(microBatchDf, batchId):
target_table = "retail_db.transactions_silver"
deltaTable = DeltaTable.forName(spark, target_table)
(deltaTable.alias("dt")
.merge(source=microBatchDf.alias("sdf"),
condition="sdf.t_id = dt.t_id")
.whenMatchedDelete(condition="sdf.operation='DELETE'")
.whenMatchedUpdate(set={
"t_id": "sdf.t_id",
"transaction_date": "sdf.transaction_date",
"item_count": "sdf.item_count",
"amount": "sdf.amount"
})
.whenNotMatchedInsert(values={
"t_id": "sdf.t_id",
"transaction_date": "sdf.transaction_date",
"item_count": "sdf.item_count",
"amount": "sdf.amount"
})
.execute())
该函数体与我们在常规批处理过程中指定合并逻辑的方式类似。唯一的区别是,在这种情况下,我们会为每个接收到的批次运行合并操作,而不是一次性对整个源进行处理。现在,在函数已经定义的情况下,我们可以读取一流变更流,并使用 foreachBatch 在 Spark 中应用我们自定义的合并逻辑,并将结果写回到另一个表:
changesStream = ... # 包含 CDC 记录的流式 DataFrame
# 将流式聚合查询的输出写入 Delta 表
(changesStream
.writeStream
.format("delta")
.queryName("Summaries Silver Pipeline")
.foreachBatch(upsertToDelta)
.outputMode("update")
.start()
)
因此,每个微批次的更改流将应用合并逻辑,并写入目标表,甚至多个表,就像我们在幂等写入示例中做的那样。
Delta Lake 性能指标
在任何数据处理管道中,一个经常被忽视但非常有用的功能是对正在发生的操作进行深入了解。拥有帮助我们理解处理速度和规模的指标,对于成本估算、容量规划以及在问题发生时进行故障排除都是非常有价值的信息。我们已经在流式处理 Delta Lake 时看到过一些接收到的指标信息,但在这里我们将更仔细地查看实际接收到的内容。
指标
正如我们所看到的,有些情况下我们希望手动设置 Delta Lake 处理的起始和结束边界点,这些通常与版本或时间戳对齐。在这些边界内,我们可能会有不同数量的文件等内容,而我们看到的一个特别重要的概念是,跟踪流处理过程中的偏移量或进度。这是特别对于流式处理非常重要的概念,它帮助我们追踪处理进度。在 Spark Structured Streaming 输出的指标中,我们可以看到几个用于跟踪这些偏移量的细节。
在 Databricks 上运行处理时,还有一些额外的指标有助于跟踪背压(backpressure),即当前时间点仍待完成的工作量。我们看到的性能指标包括 numInputRows、inputRowsPerSecond 和 processedRowsPerSecond。背压指标包括 numBytesOutstanding 和 numFilesOutstanding。这些指标的命名非常直观,因此我们不需要单独详细解释它们。
小贴士
将 inputRowsPerSecond 与 processedRowsPerSecond 指标进行比较,得到的比率可以用来衡量相对性能,这可能表明是否需要为作业分配更多资源,或者是否需要对触发器进行一些降速。
自定义指标
对于 Apache Flink 和 Apache Spark,也有自定义指标选项,可以用来扩展应用程序中跟踪的指标信息。我们已经看到的一种方法是从 Spark 的 foreachBatch 操作内部发送额外的自定义指标信息。根据需要查看每个处理框架的文档,以进一步了解如何使用此选项。尽管这种方法提供了最高程度的定制,但也需要最多的手动工作。
Auto Loader 和 Delta Live Tables
我们大部分的重点是放在 Delta Lake 开源项目中所有可以自由使用的内容。然而,Databricks 提供了一些主要功能,它们依赖于或与 Delta Lake 密切配合,值得一提。
Auto Loader
Databricks 提供了一个独特的 Spark Structured Streaming 数据源,称为 Auto Loader,实际上它更适合作为 cloudFiles 数据源来使用。总体上,cloudFiles 数据源是 Databricks 上 Structured Streaming 的一种流式数据源定义,但它已经迅速成为许多组织进行流式处理的一个简便入口,而 Delta Lake 通常是其目标接收端。这部分原因是它提供了一种自然的方式来增量化批处理过程,从而集成流处理的一些优势,比如偏移量跟踪。
cloudFiles 数据源实际上有两种不同的操作方式:一种是直接对存储位置进行文件列出操作,另一种是监听与存储位置相关的通知队列。无论使用哪种方法,都可以很快看出,这是一种可扩展且高效的机制,用于从云存储定期摄取文件,因为它用于跟踪进度的偏移量实际上就是指定源目录中的文件名。有关最常见用法的示例,请参见“Delta Live Tables”部分。
Auto Loader 的一个常见应用是将其作为金银铜架构设计的一部分,处理文件并将数据导入 Delta Lake 表,通过进一步的转换、丰富和聚合处理,直到生成金层聚合数据表。这通常与额外的数据层处理结合使用,Delta Lake 既是源数据,也是流处理的接收端,从而提供低延迟、高吞吐量、端到端的数据转换管道。这个过程已成为文件基础摄取的标准,消除了对基于 Lambda 架构的复杂处理流程的需求,Databricks 也基于这种方法构建了一个框架。
Delta Live Tables
Databricks 提供了一个运行在 Delta Lake 上的数据工程管道框架,称为 Delta Live Tables(DLT),它结合了增量摄取、简化的 ETL 流程以及自动化的数据质量处理(如期望值)。DLT 的目标是简化构建我们刚刚描述的管道,这实际上解释了为什么在讨论 Delta Lake 流处理时将它包含在这里:它是一个围绕 Delta Lake 构建的产品,将本指南中提到的一些关键原则,封装成一个易于管理的框架。
与逐步构建处理管道不同,声明式框架允许你定义一些表和视图,减少了许多我们讨论过的功能所需的语法,通过自动化领域中常用的最佳实践来简化工作。它可以帮助你管理的内容包括计算资源、数据质量监控、处理管道健康状况和优化的任务编排。
DLT 提供静态表、流式表、视图和物化视图,用于串联许多本来更为复杂的任务。在流处理方面,我们看到 Auto Loader 是一个突出且常见的初始数据源,它为 Delta Lake 支持的表中的下游增量处理提供数据。以下是基于 Delta Live Tables 文档中的示例代码:
import dlt
@dlt.table
def autoloader_dlt_bronze():
return (
spark
.readStream
.format("cloudFiles")
.option("cloudFiles.format", "json")
.load("<data path>")
)
@dlt.table
def delta_dlt_silver():
return (
dlt
.read_stream("autoloader_dlt_bronze")
...
<transformation logic>
...
)
@dlt.table
def live_delta_gold():
return (
dlt
.read("delta_dlt_silver")
...
<aggregation logic>
...
)
由于初始源是流式处理过程,因此 silver 和 gold 表也会进行增量处理。对于流式源,特别的一个优势是简化。通过不必定义检查点位置或编程创建元数据表中的表项,我们能够用更少的工作量构建管道。简而言之,DLT 为我们提供了在 Delta Lake 上构建数据管道的许多相同优势,但抽象了许多细节,使其更简单、更易于使用。
变更数据提要(Change Data Feed)
之前我们讨论了将变更数据捕获(CDC)数据集成到流式 Delta Lake 管道中的方法。那么,Delta Lake 是否有支持此类提要的选项?简短的答案是:有。为了深入探讨这个问题,我们首先确保对相关概念有清晰的理解。
到目前为止,我们已经通过多个示例使用了 Delta Lake,并且我们看到,对于任何特定的数据行,基本上只有三种主要操作:插入记录、更新记录或删除记录。这与其他数据系统大体相似。那么,CDC 到底是如何介入的呢?
正如 Joe Reis 和 Matt Housley 在《数据工程基础》一书中定义的那样:“变更数据捕获(CDC)是一种提取数据库中发生的每个变更事件(插入、更新、删除)的方法。CDC 常用于在数据库之间进行近实时复制,或为下游处理创建事件流。”更简洁地说,CDC “是从源数据库系统中摄取变更的过程”。
将这一点与我们最初的提问联系起来,Delta Lake 通过名为 变更数据提要(CDF) 的功能来支持跟踪变更。CDF 的作用是让你跟踪 Delta Lake 表中的变更。一旦启用,你将能够实时获取所有发生的变更。更新、合并和删除操作会被放入新的 _change_data 文件夹,而追加操作已经有自己的表历史记录条目,因此不需要额外的文件。通过这种跟踪,我们可以将这些操作作为变更提要从表中读取,并用于下游处理。这些变更将包含所需的行数据,并附加一些元数据来显示变更类型。
注意
CDF 在 Delta Lake 2.0.0 及以上版本中可用。对于启用了列映射的表,使用 CDF 的支持程度根据你使用的版本有所不同:
- 版本 ≤ 2.0 不支持启用列映射的表的流式或批量读取。
- 版本 2.1 仅支持对启用列映射的表进行批量读取。这也要求没有非增量模式的架构变更(即没有重命名或重新排序)。
- 版本 2.2 对启用列映射的表同时支持批量和流式读取 CDF,只要没有非增量模式的架构变更。
- 版本 ≥ 2.3 对启用列映射的表支持批量读取 CDF,并且可以处理非增量模式的架构变更。CDF 使用查询中使用的结束版本的架构,而不是可用的最新版本。如果指定的版本范围跨越非增量模式的架构变更,仍可能会遇到失败。
使用变更数据提要
虽然最终是否使用 CDF 特性取决于你在构建数据管道时的需求,但在某些常见的用例中,利用 CDF 可以简化或重新思考你处理某些任务的方式。以下是一些你可以考虑使用 CDF 的示例:
1. 优化下游表
通过仅处理源表操作后的行级变更,可以提高下游 Delta Lake 表的性能,从而简化 ETL(提取、转换、加载)和 ELT(提取、加载、转换)操作,因为 CDF 能减少逻辑复杂性。因为你已经知道记录如何变化,而不是检查其当前状态,这样可以提高效率。
2. 传播变更
你可以将变更数据提要发送到下游系统,例如其他流式接收端(如 Kafka),或者其他关系型数据库管理系统(RDBMS),利用这些变更数据在数据管道的后续阶段进行增量处理。
3. 创建审计追踪
你也可以将变更数据提要捕获为 Delta 表。这可以提供永久存储,并具备高效的查询能力,以查看所有的变更历史,包括何时发生了删除操作以及哪些更新被执行。这对于跟踪引用表的历史变更或对敏感数据进行安全审计非常有用。
我们还需要注意,使用 CDF 并不一定会增加额外的存储开销。一旦启用,我们实际发现,它对处理开销没有显著影响。变更记录的大小通常较小,在大多数情况下,其大小远小于变更操作中实际写入的文件。这意味着启用此特性对性能几乎没有影响。
操作的变更数据存储在 Delta 表目录下的 _change_data 文件夹中,类似于事务日志。像追加文件或删除整个分区这样的操作要比其他类型的变更简单得多。当变更类型较简单时,Delta Lake 会检测到它可以直接从事务日志高效地计算变更数据提要,因此这些记录可以完全跳过。这些通常是最常见的操作,因此这一能力大大减少了开销。
注意
由于 _change_data 文件夹不是当前版本的表数据的一部分,因此该文件夹中的文件遵循表的保留策略。这意味着,它在清理(vacuum)操作中会被移除,就像其他超出保留策略的事务日志文件一样。
启用变更数据提要(Change Data Feed)
总体来说,配置 Delta Lake 的 CDF 并不需要做太多的操作。其实,核心操作就是启用它,但如何启用会根据是创建新表还是为现有表实施此功能而有所不同。
对于新表
只需在 CREATE TABLE 命令中将表属性 delta.enableChangeDataFeed 设置为 true:
-- SQL
CREATE TABLE student (id INT, name STRING, age INT)
TBLPROPERTIES (delta.enableChangeDataFeed = true)
对于现有表
你可以使用 ALTER TABLE 命令来修改表属性,将 delta.enableChangeDataFeed 设置为 true:
-- SQL
ALTER TABLE myDeltaTable SET TBLPROPERTIES (delta.enableChangeDataFeed = true)
在 Apache Spark 中
如果你使用 Apache Spark,可以通过将 spark.databricks.delta.properties.defaults.enableChangeDataFeed 设置为 true,将其设为 SparkSession 对象的默认行为。
读取变更数据提要
读取变更数据提要类似于 Delta Lake 中的大多数读取操作。关键区别在于,我们需要在读取时指定要读取变更数据提要本身,而不仅仅是数据本身,通过设置 readChangeFeed 为 true。否则,语法与设置时间旅行或典型的流式读取选项非常相似。读取变更数据提要作为批处理操作和流式处理操作之间的行为有所不同,我们将分别考虑这两种情况。虽然在我们的示例中不会实际使用,但通过 maxFilesPerTrigger 或 maxBytesPerTrigger 进行速率限制可以应用于除初始快照版本外的其他版本。当使用这些选项时,整个提交版本会按预期被速率限制,或者当低于阈值时,整个提交版本会被返回。
为批处理操作指定边界
由于批处理操作是有界的过程,我们需要告诉 Delta Lake 用哪些边界来读取变更数据提要。你可以提供版本号或时间戳字符串来设置起始和结束边界。你设置的边界会在查询中包含——也就是说,如果最终的时间戳或版本号恰好与提交匹配,那么该提交的变更会包含在变更数据提要中。如果你想读取从某一点到最新可用变更的数据,则只需指定起始版本或时间戳。
设置边界点时,你需要使用整数指定版本,或者使用类似于设置时间旅行选项的方式提供格式为 yyyy-MM-dd[ HH:mm:ss[.SSS]] 的时间戳。如果你提供的时间戳或版本低于变更数据提要启用时的任何版本或时间戳,则会抛出错误,提示你变更数据提要未启用:
# 版本作为整数或长整型
(spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", 0)
.option("endingVersion", 10)
.table("myDeltaTable")
)
# 时间戳作为格式化时间戳
(spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingTimestamp", '2023-04-01 05:45:46')
.option("endingTimestamp", '2023-04-21 12:00:00')
.table("myDeltaTable")
)
# 只提供起始版本/时间戳
(spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingTimestamp", '2023-04-21 12:00:00.001')
.table("myDeltaTable")
)
# 类似地,为文件位置提供参数
(spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingTimestamp", '2021-04-21 05:45:46')
.load("/pathToMyDeltaTable")
)
为流式处理操作指定边界
如果我们希望使用 readStream 读取表的变更数据提要,仍然可以设置 startingVersion 或 startingTimestamp,但它们在流式处理中比在批处理操作中更为可选——如果未提供这些选项,流将返回流式处理时的表的最新快照作为 INSERT,然后返回所有后续的变更数据。
流式处理的另一个区别是我们不会配置结束位置,因为流是无界的,因此没有结束边界。在读取变更数据时,也支持速率限制选项(如 maxFilesPerTrigger,maxBytesPerTrigger)和 excludeRegex,所以我们可以像正常读取流数据一样进行操作:
# 提供起始版本
(spark.readStream.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", 0)
.load("/pathToMyDeltaTable")
)
# 提供起始时间戳
(spark.readStream.format("delta")
.option("readChangeFeed", "true")
.option("startingTimestamp", "2021-04-21 05:35:43")
.load("/pathToMyDeltaTable")
)
# 不提供任何参数
(spark.readStream.format("delta")
.option("readChangeFeed", "true")
.load("/pathToMyDeltaTable")
)
注意事项
如果指定的起始版本或时间戳超出了表中找到的最新版本或时间戳,将会遇到错误:timestampGreaterThanLatestCommit。你可以通过设置以下选项避免这个错误,这意味着选择接收一个空结果集:
-- SQL
set
delta.changeDataFeed.timestampOutOfRange.enabled
=true;
如果起始版本或时间戳值在表中是有效的,但结束版本或时间戳超出范围,启用此功能后,将会返回所有在指定范围内的可用版本。
数据模式
此时,你可能会想知道我们在读取变更数据提要时收到的数据是什么样的。我们接收到的数据与之前的数据一样,包含所有相同的列。这是有道理的,因为否则它就无法与表的模式匹配。然而,我们确实会获得一些额外的列,以便我们可以理解发生的变更类型。我们在读取变更数据提要时,会获得以下三个新列:
变更类型
_change_type 列是一个字符串类型的列,用于标识每一行的变更类型,具体包括插入(insert)、更新前镜像(update_preimage)、更新后镜像(update_postimage)或删除(delete)操作。在此,前镜像是更新前的匹配值,而后镜像是更新后的匹配值。
提交版本
_commit_version 列是一个长整型的列,记录变更所对应的 Delta Lake 文件/表版本,这个版本来自事务日志。在批处理操作中,它会在查询定义的边界内或处于边界之间。而在流式读取时,它将大于或等于起始版本,并随着时间的推移不断增加。
提交时间戳
_commit_timestamp 列是一个时间戳类型的列(格式为 yyyy-MM-dd[ HH:mm:ss[.SSS]]),记录 _commit_version 中版本创建并提交到日志的时间。
示例
假设在“People 10 M”数据集中有一个(虚构的)差异,原因是该数据实际上属于一个亲戚。我们可以更新错误的记录,查看变更数据提要时,我们会看到原始记录值作为前镜像,而更新后的值作为后镜像。我们将更新错误输入的名字,并更正个人的性别和名字。之后,我们查看表的子集,突出显示变更前后的记录,以查看它的样子。我们还可以注意到,它同时捕获了提交版本和时间戳:
-- SQL
UPDATE
people10m
SET
gender = 'F',
firstName='Leah'
WHERE
firstName='Leo'
and lastName='Conkay';
# Python
(
spark
.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", 5)
.option("endingVersion", 5)
.table("tristen.people10m")
.select(
col("firstName"),
col("lastName"),
col("gender"),
col("_change_type"),
col("_commit_version"))
).show()
)
输出结果如下:
+---------+--------+------+----------------+---------------+-------------------+
|firstName|lastName|gender| _change_type|_commit_version| _commit_timestamp|
+---------+--------+------+----------------+---------------+-------------------+
| Leo| Conkay| M| update_preimage| 5|2023-04-05 13:14:40|
| Leah| Conkay| F|update_postimage| 5|2023-04-05 13:14:40|
+---------+--------+------+----------------+---------------+-------------------+
在这个例子中,我们看到在更新之前,名字是 "Leo",性别是 "M"(男性),这就是前镜像;更新后,名字是 "Leah",性别是 "F"(女性),这就是后镜像。我们还可以看到,提交版本是 5,提交时间戳是 2023-04-05 13:14:40。
总结
在本章中,我们基于之前章节中介绍的许多概念,探讨了这些概念如何应用于多种不同的使用场景。我们深入了解了流数据处理中使用的一些基本概念,并探讨了它们如何与 Delta Lake 相结合。我们间接地看到了,使用统一的 API 使得核心流式功能(特别是在 Spark 中)的使用变得更加简化,因为它们在使用方式上非常相似。接着,我们探索了几种不同的选项,提供了更直接的控制流式读取和写入行为的方法,以便在 Delta Lake 中进行操作。之后,我们讨论了一些与 Apache Spark 或 Databricks 的流处理紧密相关的领域,这些领域是建立在 Delta Lake 之上的。最后,我们回顾了 Delta Lake 中的变更数据提要功能,并展示了如何在流式或非流式应用中使用它。我们希望这些内容能够解答你可能对 Delta Lake 使用中存在的许多疑问或好奇心。
接下来,我们将继续探索 Delta Lake 中其他一些更高级的特性。