Spark.NET 入门指南(三)

原文:Introducing .NET for Apache Spark

协议:CC BY-NC-SA 4.0

九、结构化流

在这一章中,我们将看一个如何创建流应用程序的例子。Apache Spark 的结构化流 API 允许您使用 DataFrame API 来表达您的 Apache Spark 作业。您不是使用静态数据集,而是使用基于 Apache Spark 的可伸缩、容错的流处理引擎来处理微批量数据。

我们将创建的应用程序将做两件事。首先,它将检查特定条件下的每条消息,并允许我们的应用程序发出警报;其次,它将收集 5 分钟内收到的所有数据,汇总数据,并保存数据,以便可以在仪表板中显示。

我们的流示例

在本章的示例中,我们将使用 Apache Kafka 主题,通过 Debezium 连接器使用变更数据捕获(CDC)从 Microsoft SQL Server 读取变更。除了微软 SQL Server,这些都是开源产品。配置 Microsoft SQL Server、Apache Kafka 和 Debezium 超出了本章的范围,但是我们将解释如何解析 Apache Kafka 消息。

对整个过程的概述是

  1. 数据被写入 SQL Server 数据库。

  2. SQL Server 的变更数据捕获功能可生成变更数据。

  3. Debezium 阅读这些更改,并发布到 Apache Kafka 主题。

  4. 我们的应用程序读取 Apache Kafka 主题并处理数据。

应该注意的是,尽管我们将使用 Apache Kafka,但是 Apache Spark 可以从许多不同的源进行流式传输,尽管连接细节和解析不同,但是 Apache Spark 中的处理是相同的。

树立榜样

要自己运行该示例,您将需要一个支持变更数据捕获的 SQL Server 实例、一个与 Kafka Connect 一起运行的 Apache Kafka 实例以及用于 SQL Server 的 Debezium 连接器。在清单 9-1 中,我们展示了 SQL Server 中我们将用作源表的表。

有关配置 SQL Server 变更数据捕获的更多信息,请参见 https://docs.microsoft.com/en-us/sql/relational-databases/track-changes/about-change-data-capture-sql-server *。*关于卡夫卡和德贝兹姆,见 https://debezium.io/documentation/reference/connectors/sqlserver.html

CREATE TABLE dbo.SalesOrderItems
(
     Order_Item_ID      INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
     Order_ID           INT NOT NULL,
     Product_ID         INT NOT NULL,
     Amount INT         NOT NULL,
     Price_Sold         FLOAT NOT NULL,
     Margin FLOAT       NOT NULL
     Order_Date         DATETIME NOT NULL
)

Listing 9-1The source SQL Server table

一旦创建了表并在数据库和表上启用了 CDC,我们就可以从 Debezium connector for SQL Server 创建一个连接。Debezium 将为我们创建主题。在本例中,主题将被称为“sql.dbo.SalesOrderItems ”,因为我们在 Debezium 连接器配置中将数据库的名称配置为“sql ”,然后添加表的模式和名称来构建完整的主题名称。如果您创建了一个到单独表的连接,那么该表的名称将用于创建主题。

当数据被写入 SQL Server 表时,Debezium 会读取任何更改并创建一个 JSON 消息,该消息会被发送到 Apache Kafka。在清单 9-2 中,我们展示了一个示例消息,稍后我们将需要使用 DataFrame API 对其进行解析。

{
    "schema": {
        "type": "struct",

        ],
        "optional": false,
        "name": "sql.dbo.SalesOrderItems.Envelope"
    },
    "payload": {
        "before": null,
        "after": {
            "Order_ID": 1,
            "Order_Item_ID": 737,
            "Product_ID": 123,
            "Amount": 10,
            "Price_Sold": 1000.23,
            "Margin": 0.99
        },
        "source": {
            "version": "1.3.0.Final",
            "connector": "sqlserver",
            "name": "sql",
            "ts_ms": 1602915585290,
            "snapshot": "false",
            "db": "Transactions",
            "schema": "dbo",
            "table": "SalesOrderItems",
            "change_lsn": "0000002c:00000c60:0003",
            "commit_lsn": "0000002c:00000c60:0004",
            "event_serial_no": 1
        },
        "op": "c",
        "ts_ms": 1602915587594,
        "transaction": null
    }
}

Listing 9-2A sample Apache Kafka message from the Debezium SQL Server connector. The schema section has been removed to keep the size of the listing reasonable

JSON 消息由一个模式、包含前后数据的有效负载和源信息(如事务时间)组成。在本例中,payload 部分有一个空的“before”对象,因为这是一个插入。如果是更新,那么 before 部分就会有数据。

流媒体应用

在清单 9-3 和 9-4 中,我们将展示如何使用 SparkSession 从 Apache Kafka 主题创建 DataFrame。我们对连接和主题信息进行了硬编码,但是您可能会从命令行参数或配置文件中读取这些信息。

let spark = SparkSession.Builder().GetOrCreate();

let rawDataFrame = spark.ReadStream()
                   |> fun stream -> stream.Format("kafka")
                   |> fun stream -> stream.Option("kafka.bootstrap.servers", "localhost:9092")
                   |> fun stream -> stream.Option("subscribe", "sql.dbo.SalesOrderItems")
                   |> fun stream -> stream.Option("startingOffset", "earliest")
                   |> fun stream -> stream.Load()

Listing 9-4Creating a DataFrame from an Apache Kafka topic in F#

var spark = SparkSession.Builder().GetOrCreate();

var rawDataFrame = spark.ReadStream().Format("kafka")
    .Option("kafka.bootstrap.servers", "localhost:9092")
    .Option("subscribe", "sql.dbo.SalesOrderItems")
    .Option("startingOffset", "earliest").Load();

Listing 9-3Creating a DataFrame from an Apache Kafka topic in C#

我们传入的“startingOffset”选项决定了查询开始时的起始点。有关可用选项及其描述的完整列表,请参见 https://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html

我们在这里创建的“rawDataFrame”将读取 Apache Kafka 主题,以便可以在 DataFrame 上构建正确的列,但此时不会包含任何数据,事实上,如果我们尝试执行rawDataFrame.Show(),我们将会得到一条错误消息,显示为"Queries with streaming sources must be executed with writeStream.start()"。然而,我们可以做一个 PrintSchema (),DataFrame 模式应该是这样的:

root
 |-- key: binary (nullable = true)
 |-- value: binary (nullable = true)
 |-- topic: string (nullable = true)
 |-- partition: integer (nullable = true)
 |-- offset: long (nullable = true)
 |-- timestamp: timestamp (nullable = true)
 |-- timestampType: integer (nullable = true)

数据在“值”列中,是我们在清单 9-3 中看到的 JSON 消息的二进制表示。

粉碎 JSON 文档

因为 JSON 在 DataFrame 列中,并且我们已经有了 DataFrame 中的数据,所以我们想使用 Apache Spark 分解 JSON 文档并检索我们想要的实际列。我们需要提供一个允许 Apache Spark 读取 JSON 的模式。在清单 9-5 和 9-6 中,我们展示了如何创建StructType模式定义。

let messageSchema() =
    StructType(
        [|
            StructField("schema", StringType())
            StructField("payload", StructType(
                [|
                    StructField("after", StructType(
                        [|
                            StructField("Order_ID", IntegerType())
                            StructField("Product_ID", IntegerType())
                            StructField("Amount", IntegerType())
                            StructField("Price_Sold", FloatType())
                            StructField("Margin", FloatType())
                        |]
                    ))
                    StructField("source", StructType(
                        [|
                            StructField("version", StringType())
                            StructField("ts_ms", LongType())
                         |]
                    ))
                |]
            ))
        |]
    )

Listing 9-6Creating a StructType schema definition in F#

var jsonSchema = new StructType(
    new List<StructField>
    {
        new StructField("schema", new StringType()),
        new StructField("payload", new StructType(
            new List<StructField>
            {
                new StructField("after", new StructType(
                    new List<StructField>
                    {
                        new StructField("Order_ID", new IntegerType()),
                        new StructField("Product_ID", new IntegerType()),
                        new StructField("Amount", new IntegerType()),
                        new StructField("Price_Sold", new FloatType()),
                        new StructField("Margin", new FloatType())
                    })),
                new StructField("source", new StructType(new List<StructField>
                {
                    new StructField("version", new StringType()),
                    new StructField("ts_ms", new LongType())
                }))
            }))
    }

);

Listing 9-5Creating a StructType schema definition in C#

需要注意的重要一点是,您只需要提供您感兴趣的文档部分的细节。例如,文档的 schema 部分对我们理解文档很有用,但是我们不能在 Apache Spark 中使用它,因为我们需要 schema 来读取 schema,所以在我们的StructType schema 中,我们将整个部分标记为StringType,所以它被存储为一个字符串,我们可以选择读取或不读取它。对于我们感兴趣的文档部分,即交易时间“ts_ms”和订单细节,我们确实需要提供一个特定的模式。应该注意的是,如果您遗漏了一列,那么 Apache Spark 将会忽略它,但是如果您提供了不正确的数据类型,那么整行都将为空,即使文档中的其他值具有正确的数据类型。

创建数据帧

在清单 9-7 和 9-8 中,我们将获取我们创建的指向 Apache Kafka 主题的数据帧以及我们的StructType模式,并创建一个数据帧,Apache Spark 将 JSON 文档分解成我们可以使用的实际数据帧列。

let parsedDataFrame = rawDataFrame
                        |> fun dataFrame -> dataFrame.SelectExpr("CAST(value as string) as value")
                        |> fun dataFrame -> dataFrame.WithColumn("new", Functions.FromJson(Functions.Col("value"), messageSchema().Json))
                        |> fun dataFrame -> dataFrame.Select("new.payload.after.*", "new.payload.source.*")
                        |> fun dataFrame -> dataFrame.WithColumn("timestamp", Functions.Col("ts_ms").Divide(1000).Cast("timestamp"))

Listing 9-8Shredding the JSON document into DataFrame Columns in F#

var parsedDataFrame = rawDataFrame
    .SelectExpr("CAST(value as string) as value")
    .WithColumn("new", FromJson(Col("value"), messageSchema.Json))
    .Select("value", "new.payload.after.*", "new.payload.source.*")
    .WithColumn("timestamp", Col("ts_ms").Divide(1000).Cast("timestamp"));

Listing 9-7Shredding the JSON document into DataFrame Columns in C#

这里要注意的是,我们采用二进制“值”列并将其转换为字符串,然后我们使用FromJson函数,并结合我们的模式。FromJson Apache Spark 函数将为我们模式中的每个属性创建一列,我们使用 JSON 路径“new.payload.after.*”选择数据,这将为模式中“after”对象下指定的每个类型提供一列,名称将是属性名,如“Order_ID”和“Margin”。

Microsoft Change Data Capture 提供的时间戳需要除以 1000,以便我们可以将其转换为具有中正确日期和时间的时间戳。

此时,我们仍然在使用从 Apache Kafka 主题创建的原始 DataFrame。在没有任何数据的情况下,我们还没有开始从任何地方传输任何数据。

开始流

在清单 9-9 和 9-10 中,我们要做的下一件事是启动一个流并使用ForeachBatch方法,它将为 Apache Spark 结构化流提供给我们的应用程序的每个微批处理运行一次。我们将使用这个微批处理来检查每一行,如果销售的商品符合特定条件,就会触发警报。

let handleStream (dataFrame:DataFrame, _) : unit  =
    dataFrame.Filter(Functions.Col("Margin").Lt(0.10))
    |> fun failedRows -> match failedRows.Count() with
        | 0L -> printfn "We had no failing rows"
            failedRows.Show()
        | _ -> printfn "Trigger Ops Alert Here"

let operationalAlerts = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
    |> fun dataFrame -> dataFrame.WriteStream()
    |> fun stream -> stream.ForeachBatch(fun dataFrame batchId -> handleStream(dataFrame,batchId))
    |> fun stream -> stream.Start()

Listing 9-10Using ForeachBatch to process each micro-batch looking for specific conditions in F#

var operationalAlerts = parsedDataFrame
    .WriteStream()
    .Format("console")
    .ForeachBatch((df, id) => HandleStream(df, id))
    .Start();

private static void HandleStream(DataFrame df, in long batchId)
{
    var tooLowMargin = df.Filter(Col("Margin").Lt(0.10));

    if (tooLowMargin.Count() > 0)
    {
        tooLowMargin.Show();
        Console.WriteLine("Trigger Ops Alert Here");
    }

}

private static void HandleStream(DataFrame df, in long batchId)
{
    var tooLowMargin = df.Filter(Col("Margin").Lt(0.10));

    if (tooLowMargin.Count() > 0)
    {
        tooLowMargin.Show();
        Console.WriteLine("Trigger Ops Alert Here");
    }
}

Listing 9-9Using ForeachBatch to process each micro-batch looking for specific conditions in C#

HandleStream中,我们展示了我们可以开始使用 DataFrame API 和我们期望的熟悉方法,例如 Filter 和 show,来构建我们的应用程序,就像我们编写批处理模式应用程序一样。

这里需要注意的两件事是WithWatermark函数和WriteStream函数。WithWatermark函数允许 Apache Spark 确保迟交的消息不会被丢弃。在这个系统中,我们只关心最近的数据,所以如果任何消息在 30 秒后到达,那么它们可能会被丢弃。如果这是一个关键的业务流程,那么您可能会增加保证消息传递的时间。您选择的时间长度是在使用更多内存、更长的窗口(您更有可能收到所有消息)和更少的内存(如果存在基础结构问题或其他问题,消息可能会丢失)之间进行权衡。

第二个函数是WriteStream,它启动实际的流处理,并导致任何写入 Apache Kafka 主题的消息被引入 Apache Spark 实例并进行处理。在我们调用WriteStream之前,我们不会收到任何实际消息。

这是我们的流应用程序的第一部分,在这里我们实时处理消息并采取一些行动。我们展示的操作非常简单,但是您可以运行更复杂的命令,包括加入静态数据集甚至其他流,因此支持加入流到流作业。

汇总数据

在清单 9-11 和 9-12 中,我们展示了应用程序的第二部分将获取一段时间内收到的所有消息,并聚合数据以便显示在仪表板中。这是流式应用程序的另一个常见用例,因为它允许业务用户实时查看趋势,而不必等待每小时甚至每天的批处理过程来运行和更新他们的仪表板和报告。

let totalValueSoldByProducts = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
                                |> fun dataFrame -> dataFrame.GroupBy(Functions.Window(Functions.Col("timestamp"), "5 minute"), Functions.Col("Product_ID")).Sum("Price_Sold")
                                |> fun dataFrame -> dataFrame.WithColumnRenamed("sum(Price_Sold)", "Total_Price_Sold_Per_5_Minutes")
                                |> fun dataFrame -> dataFrame.WriteStream()
                                |> fun stream -> stream.Format("parquet")
                                |> fun stream -> stream.Option("checkpointLocation", "/tmp/checkpointLocation")
                                |> fun stream -> stream.OutputMode("append")
                                |> fun stream -> stream.Option("path", "/tmp/ValueOfProductsSoldPer5Minutes")
                                |> fun stream -> stream.Start()

Listing 9-12Aggregating time slices of data in real time using F#

var totalByProductSoldLast5Minutes = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
    .GroupBy(Window(Col("timestamp"), "5 minute"), Col("Product_ID")).Sum("Price_Sold")
    .WithColumnRenamed("sum(Price_Sold)", "Total_Price_Sold_Per_5_Minutes")
    .WriteStream()
    .Format("parquet")
    .Option("checkpointLocation", "/tmp/checkpointLocation")
    .OutputMode("append")
    .Option("path", "/tmp/ValueOfProductsSoldPer5Minutes")
    .Start();

Listing 9-11Aggregating time slices of data in real time using C#

在这个例子中,同样,我们有了WithWatermark,但是在我们调用WriteStream之前,我们还在数据帧上有一个聚合。一般的方法是,在调用WriteStream之前,我们定义我们想要对数据做什么,然后 Apache Spark 将负责运行聚合,然后为我们写出数据。

等待流数据出现是使用 Apache Spark 的一种不同方式,并且可能更难解决它为什么不能按预期工作的问题,因此通常更容易的是让 DataFrame 操作使用静态数据集,然后将代码复制到您的流应用程序。

当我们聚合数据时,我们还使用 Apache Spark Window函数,该函数接受包含可以使用的时间戳的列的名称,以及应该聚合数据的时间长度。在本例中,我使用了“5 分钟”,这意味着对于我们接收数据的每 5 分钟时间段,我们将运行聚合并将数据保存到文件系统。

查看输出

在清单 9-11 和 9-12 中,在使用WriteStream启动流之后,我们指定我们想要将数据作为 parquet 写入并附加到任何已经存在的数据。如果我们运行该程序并将一些数据写入 SQL Server 表,我们应该会看到以下格式的数据被写入磁盘:

+------------------------------------+----------+-------------------------+
|                         window|Product_ID|Total_Price_Sold_Per_5_Minutes|
+------------------------------------+----------+-------------------------+
|[2020-10-16 06:05:00, 2020-10-16 06:10:00]|    123|       12002.759765625|
|[2020-10-16 06:55:00, 2020-10-16 06:00:00]|    123|       6001.3798828125|
|[2020-10-16 06:00:00, 2020-10-16 06:05:00]|    123|      9002.06982421875|
|[2020-10-16 06:10:00, 2020-10-16 06:15:00]|    123|      9002.06982421875|
+------------------------------------+----------+-------------------------+

当我们调用 StartStream 时,会在后台创建一个线程,处理会移动到该线程上。这意味着现有的 main 函数将会结束,我们的应用程序将会停止,所以我们需要确保我们的应用程序和流一样长。在清单 9-13 和 9-14 中,我们展示了如何使用 stream AwaitTermination方法保持流程活动,直到流停止。

[|
 async {

     operationalAlerts.AwaitTermination()
 }
 async{
    totalValueSoldByProducts.AwaitTermination()
 }
 |]
    |> Async.Parallel
    |> Async.RunSynchronously
    |> ignore

Listing 9-14Keeping the process alive until the streams terminate in F#

Task.WaitAll(
    Task.Run(() => operationalAlerts.AwaitTermination()),
    Task.Run(() => totalByProductSoldLast5Minutes.AwaitTermination())
);

Listing 9-13Keeping the process alive until the streams terminate in C#

如果我们运行我们的应用程序,那么我们将会看到大约每 5 分钟写入一次聚合,并且在屏幕上显示任何没有通过测试的行。要运行该应用程序,您需要为您的 Apache Spark 版本传递“Spark-SQL-Kafka”JAR 文件的名称。

摘要

在本章中,我们已经了解了如何使用 DataFrame API 来创建流应用程序,这种应用程序使用两种常见模式,一种是单独处理每个批处理并采取一些措施,另一种是使用 Apache Spark 来聚合流数据,以便在报告和仪表板中使用。

与 DataFrame API 类似,结构化流 API 提供了一个易于使用的接口,这在技术上很难做好,而 Apache Spark 使它几乎无缝。

十、故障排除

在本章中,我们将了解如何监控您的应用程序并对其进行故障排除。我们将查看您可以控制的日志文件和 SparkUI,Spark ui 是一个用于检查 Apache Spark 作业的 web 界面,这些作业在性能方面如何运行,以及 Apache Spark 作业生成了什么执行计划。在这一章中,我们不会有任何代码示例,但我们会看看配置和 SparkUI web 界面。

记录

Apache Spark 使用 log4j 进行日志记录,为了控制日志记录的数量,您应该在 Spark 目录中找到“conf”文件夹,在该目录中应该可以看到 log4j.properties 文件。如果您没有看到 log4j.properties 文件,那么您应该看到 log4j.properties.template,您可以将它复制到 log4j.properties。模板文件放在。属性文件。

如果我们查看 log4j.properties 文件内部,第一部分控制我们看到多少日志记录以及日志到哪里。在清单 10-1 中,我们将查看 log4j.properties 文件的第一部分。有关 log4j 的更多信息,请参见 https://logging.apache.org/log4j/2.x/

# Set everything to be logged to the console
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

Listing 10-1The first section of the log4j.properties file

这意味着控制台将会看到大量的信息,因为任何来自信息层及以上的信息都会显示出来。我们在表 10-1 中显示了 log4j 消息级别。

表 10-1

log4j 消息级别

| 日志级别 | | 离开 | | 致命的 | | 错误 | | 警告 | | 信息 | | 调试 | | 微量 | | 全部 |

通常,在我的开发机器上,我将错误级别设置为 error 或 WARN。除此之外的任何事情都会导致消息太少或者太多。

要更改属性文件只是为了显示错误而不是警告,将“rootCategory”行更改为 ERROR,如清单 10-2 所示。

log4j.rootCategory=ERROR, console

Listing 10-2Changing the logging level to ERROR

也可以通过使用SparkContext上的LogLevel方法来使用代码控制日志记录级别,您可以从SparkSession获得对它的引用。

Spark UI

Apache Spark 附带了一个 UI,用于检查执行的作业。UI 非常有用,因为它允许我们深入研究作业的执行情况,并解决性能问题。了解如何访问 Spark UI 以及如何诊断性能问题对于提高 Apache Spark 的效率至关重要。

当您运行 Apache Spark 作业时,如果您将日志设置为 INFO 或以上,您可能会注意到日志中的这一行:

INFO SparkUI: Bound SparkUI to 0.0.0.0, and started at http://machine.dns.name:4040

这意味着 Apache Spark 已经启动了一个 web 服务器,并且正在监听端口 4040。当作业正在运行时,您可以连接并查看作业的详细信息。但是,一旦 Apache Spark 作业完成,web 服务器就会关闭,您将无法查看任何内容。在本例中,端口是 4040,这是默认端口,但是如果 Apache Spark 的另一个实例已经在运行,它将使用端口 4041 或下一个空闲端口,正确的 URL 将被打印出来。

除了为每个作业启动 Spark UI 并在作业完成时关闭之外,我们还可以请求 Apache Spark 实例将 Spark UI 所需的事件数据写入一个目录,而不是在作业运行时启动 Spark UI 并在作业完成时关闭,而是运行一个称为历史服务器的副本,它将从单个作业写入的文件夹中读取事件数据。

要配置 Apache Spark 以便将事件写入文件夹,您应该编辑 Spark 配置文件 conf/spark-defaults.conf 并添加以下两行:

spark.eventLog.enabled true
spark.eventLog.dir /tmp/spark-history-logs

然后,作业写入其中的任何数据都将被历史服务器获取,您可以从 Apache Spark 安装目录中运行“sbin/start-history-server.sh”来启动历史服务器。如果我们启动历史服务器,那么它有一个不同的默认端口,因此历史服务器通常在http://localhost:18080可用。

历史服务器

Spark UI 有两个版本,一个版本为 Apache Spark 的每个实例启动,另一个版本可以存储以前实例的数据,然后我们可以在名为历史服务器的 Spark UI 版本中显示详细信息。使用历史服务器,您可以看到不再活动的先前实例的跟踪输出。

我们看到的区别是,当我们连接到一个历史服务器而不是一个特定实例的 SparkUI 时,我们首先到达一个概览窗口,让我们深入到我们感兴趣的 Apache Spark 的特定实例。在图 10-1 中,我们第一次连接时看到了历史服务器。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-1

历史服务器

从历史服务器上的默认页面,我们可以做一些事情。首先,我们可以选择下载“事件日志”,它包含 Apache Spark 实例编写的每个日志行,但是为了简化解析,它被包装成一个 JSON 文件。其次,我们可以点击一个“应用程序 ID ”,进入应用程序的主屏幕,如图 10-2 所示。

作业选项卡

主屏幕在顶部分为几个选项卡,“作业”是默认选项卡。如果我们展开“活动作业”,我们可以看到是否有任何当前正在执行的作业以及它们的状态。在这种情况下,我们可以看到有一个活动工单和一个已完成工单。在图 10-3 中,我们可以看到活动和已完成的作业展开后的样子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-3

可在 SparkUI 中查看的活动和已完成的作业

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-2

SparkUI 主屏幕

The main screen is broken into several tabs across the top, and “Jobs” is the default tab. If we expand “Active Jobs,” we can see if there are any currently executing jobs and what their status is. In this case, we can see that there is one active Job and one completed Job. In Figure

当我们查看描述时,我们需要记住 Apache Spark 中的处理是基于动作和转换的。在动作发生之前,转换会被添加到计划中,并且不会导致任何实际的处理。当一个动作发生时,在这种情况下,完成的动作是从一个 parquet 文件中读取,而正在运行的动作是在一个DataFrame上调用Show

此外,虽然看起来提交了两个不同的作业,但我们看到的是 Apache Spark 如何将请求转换成作业和阶段,因此这是通过运行

spark.Read().Parquet("/tmp/partitions").GroupBy("id").Count().Cache().Show()

“作业”选项卡显示的最后一件事是添加或删除执行者的时间表,以及作业开始、完成和失败的时间。我们在图 10-4 中看到一个这样的例子,两个小任务在 14:25 左右运行并成功完成。如果作业失败了,那么方框会显示为红色。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-4

Spark 工作时间表

为了深入到一个具体的工作,我们可以在时间线视图上点击它,如图 10-4 所示,或者点击图 10-3 所示的描述列中的链接。作业详细信息页签如图 10-5 所示。首先要注意的是,我们再次看到了构成工作的各个阶段。持续时间和无序播放列用于性能故障排除。如果一个阶段花费了大量时间,那么这是我们开始理解性能特征的第一步。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-5

作业的不同阶段分为几个阶段,有助于了解作业的哪些部分速度较慢

当 Apache Spark 使用 DataFrame API 处理作业时,Apache Spark 将创建一个 SQL 执行计划并执行该计划。当我们使用 DataFrame API 时,它是 RDD API 上的一个抽象层,因此在对 Apache Spark 性能进行故障排除时,理解该计划是什么以及它是如何工作的是一项核心技能。在图 10-5 中,有一个到为作业生成的 SQL 计划的链接。在顶部,“关联的 SQL 查询:0”,0 指的是查询编号,如果您单击该链接,它将带您到 SQL 计划。我们将在本章后面讲述 SQL 计划选项卡以及如何阅读计划。

在图 10-5 中,我们可以看到另一个截面,我们可以将其展开,称为“DAG 可视化”;DAG 是将针对 rdd 运行的操作列表。DAG 可视化是 rdd 如何被处理的细节。图 10-6 显示了作业的 DAG 可视化,包括组成作业的所有阶段的所有操作。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-6

作业的 DAG 可视化

图 10-6 中的 DAG 向我们展示了这项工作有两个阶段。第一个阶段读取一个 parquet 文件,“扫描 parquet”,然后使用“交换”操作符将数据传递给第二个阶段。然后,Apache Spark 使用“InMemoryTableScan”操作符对数据进行表扫描。如果我们看图 10-7 ,我们可以看到当我们悬停在黑色和绿色的点上时,我们可以获得关于所发生事情的更多信息。在图 10-8 中,我们还可以看到“扫描拼花”和“InMemoryTableScan”的细节。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-8

“InMemoryTableScan”的详细信息

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-7

“扫描拼花地板”的细节

“作业详细信息”选项卡用于显示作业是如何被分解成各个阶段的。在 DAG 可视化之后,我们可以看到每个阶段花费时间的细节;在图 10-9 中,我们看到了每个阶段的细节,包括每个阶段实际花费的时间、组成该阶段的任务数量以及涉及的数据量。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-9

每个阶段的细节

在图 10-9 中,我们可以看到有两个阶段,表格显示第一个阶段由 12 个任务组成,该阶段耗时 8 秒。第二阶段由一项任务组成,耗时 1 秒。如果这项工作太慢,我们将使用这些信息来开始精确地缩小哪个或哪些任务花费的时间最多。

在这种情况下,我们开始看到为什么作业需要 8 秒钟的线索,因为第一阶段读取 44.1 MiB,并且必须执行 53.9 MiB 的随机写入。这可能意味着数据存储的方式对于处理来说效率不高,在处理完成之前必须在执行器之间重新排序和复制。

在图 10-10 中,我们单击运行了 8 秒钟的第一个阶段,这将我们带到“阶段”屏幕和第一个阶段的详细信息。

“阶段”选项卡

我们在舞台细节中看到的细节向我们展示了

  1. 该阶段花费的时间,包括所有任务

  2. 就大小和行数而言,读取了多少数据

  3. 有多少数据被“打乱”,这是我们应该尽量避免的

  4. 有多少数据溢出到内存中

  5. 有多少数据溢出到磁盘,这是我们应该尽可能避免的另一件事

  6. 与该阶段关联的作业,通过该作业,我们可以导航回作业详细信息屏幕

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-10

“阶段”选项卡和阶段详细信息

The detail we start to see in the stage details shows

在图 10-11 中,我们进一步向下移动阶段详细信息屏幕,可以看到已完成任务的摘要,特别是每项任务所用时间的分布。这里重要的一点是,我们通常希望一个阶段中的任务花费大约相同的时间,所以如果最小和最大时间之间的差异非常大,或者如果第 75 个百分点和最大百分点之间的差异非常大,那么我们很可能遇到了数据分区方式的问题。原因是所有任务都读取一部分数据,如果一个任务最终获得的数据比其他任务多得多,那么这个任务就会成为性能的瓶颈。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-11

分解为任务的阶段的概要度量

在这里,我们可以看到大多数任务花费了 0.2 秒,但至少有一个任务花费了 8 秒,这是一个相当大的差异。在图 10-12 中,我们将进一步向下移动阶段详细信息屏幕,并查看按执行者细分的汇总指标。在这个例子中,我在我的笔记本电脑上运行作业,所以我只有一个执行者。但是,如果它是在 Apache Spark 集群上,那么可能会有许多执行程序,这将允许您查看是特定的执行程序导致了问题,还是所有的执行程序都有问题。通常,当我们运行 Apache Spark 集群时,我们使用由相同类型的机器组成的集群。尽管如此,没有什么可以阻止您运行各种机器规模的集群,所以可能是一个节点上的一个执行器没有足够的内存,这是您可以在 stage details 屏幕上监视的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-12

执行者指标

stage details(阶段详细信息)屏幕还为我们提供了每个任务所用时间的直观概览,正如我们在图 10-13 中看到的,如果我们将鼠标悬停在该栏上,它将显示特定任务的详细信息。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-13

每项任务执行情况的可视化

当您将鼠标悬停在该条上时,您会看到额外的信息,包括任务索引,在本例中为“任务 5”,它允许我们通过向下滚动并检查图 10-14 中所示的任务列表来查看更多细节。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-14

组成一个阶段的任务列表

在图 10-14 中,我们可以看到索引为 5 的任务实际上是“任务 ID”6,这是需要注意的。

当我们查看这个任务列表时,我们可以看到快速任务和慢速任务之间的差异,也就是说,“混洗”的数据量要高得多。

SQL 选项卡

如果我们现在转到“SQL”选项卡,我们可以看到为每个查询生成的执行计划列表。在图 10-15 中,我们可以看到“SQL”选项卡和生成的计划列表以及与计划相关的作业。在这种情况下,只有一个计划。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-15

组成一个阶段的任务列表

如果我们单击描述,我们会深入到 SQL 的详细信息。在图 10-16 中,我们可以看到 SQL 详细信息屏幕的顶部,其中包括作业运行时间和相关作业,因此您可以在 SQL 计划和作业之间来回切换。重要的是要记住,当我们使用 DataFrame API 或使用 SQL 查询时,我们实际上是在做同样的事情。Apache Spark 解析任何 SQL 查询并构建一个执行查询的计划,其方式与 DataFrame API 调用导致计划生成并执行的方式相同。编写 SQL 查询和使用 DataFrame API 生成的计划可以是相同的。知道无论您想用哪种方式为 Apache Spark 编写代码都会导致相同的计划和处理是使用 Apache Spark 的另一个令人信服的原因。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-16

SQL 详细信息

在查询细节之后,我们可以看到查询的可视化表示,包括每个阶段输出了多少行。这在查看复杂的查询时非常有用,尤其是使用连接时,有助于跟踪行来自何处或从何处丢失。

在屏幕的更下方,在图 10-17 中,我们看到了逻辑计划被解析时的文本表示以及被分析的逻辑计划。如果您曾经使用过 SQL Server 文本计划,那么您应该对它们相当熟悉。每个计划都是一个操作树,第一个操作在树的底部,沿着树向上传递数据,直到我们到达树的顶部。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-17

经过解析和分析的逻辑计划

在逻辑计划之后,在图 10-18 中,我们可以看到“优化的逻辑计划”,它包含计划的性能特征,如在每个阶段使用哪种类型的连接,最后是“物理计划”,它详细描述了路径和分区信息,说明 Apache Spark 必须如何执行计划。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-18

优化的逻辑规划和物理规划

图 10-18 中的细节可能有点难以阅读;需要注意的重要一点是,我们可以看到逻辑和物理计划的细节,包括分区信息和源文件细节。该计划还包含下推到源的任何过滤器的细节。

剩余选项卡

Spark UI 上的其余选项卡包括“存储”选项卡,如图 10-19 所示。“存储”选项卡显示已缓存的任何 rdd 的详细信息。在这个例子中,我编写的代码使用了 DataFrame API,调用了DataFrame.Cache(),这使得数据被写入磁盘,以便可以在另一个查询中使用,而不必再次进行处理。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-19

“存储”选项卡概述屏幕

我们可以看到 RDD 占用了多少内存空间,缓存了多少分区。如果我们点击 RDD 的名字,它会把我们带到 RDD 的细节,如图 10-20 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-20

RDD 缓存详细信息屏幕

Spark UI 中的下一个选项卡是“Environment”选项卡,它包括环境的细节,比如 Apache Spark 实例使用的 Java 和 Scala 的版本。这些信息虽然有用,但希望不是经常需要。图 10-21 显示了我的本地 Apache Spark 实例上的环境选项卡。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-21

Spark UI 中的“环境”选项卡

在图 10-22 中,我们看到 Spark UI 中的最后一个选项卡是“执行者”选项卡,它显示了每个执行者执行情况的细节。例如,如果我们看到“GC 时间”很长,那么我们应该考虑增加可用内存或优化代码,这样就需要更少的内存来处理作业。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-22

Spark UI 中的“执行者”标签

关于 SparkUI 要指出的最后一点是,当您在共享集群上时,很难区分不同的作业,因此为了帮助跟踪特定的作业,当您创建SparkSession时,您可以选择为作业设置一个标识符,这允许您在 Spark UI 中快速查看作业。在清单 10-3 中,我们可以看到如何设置应用程序名称,在图 10-23 中,我们可以看到名称显示在 Spark UI 中,以帮助跟踪特定的作业。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-23

在共享的 Apache Spark 实例上运行时,应用程序名称有助于区分作业

var spark = SparkSession
    .Builder()
    .AppName("TF-IDF Application")
    .GetOrCreate();

Listing 10-3Setting the AppName, which is then displayed in the Spark UI

摘要

在本章中,我们已经了解了 Apache Spark 的日志记录,以及如何配置您看到的消息数量。通常情况下,您希望在需要所有消息的地方有尽可能少的消息,直到您需要返回并排除故障,因此知道在哪里配置日志记录是至关重要的。

然后,我们对 Spark UI 和不同的屏幕进行了概述,我们可以使用这些屏幕来获得诊断 Apache Spark 作业的性能问题所需的信息。希望您能够查看 Spark UI 并深入问题,而不会迷失在其他不相关工作的信息海洋中。

十一、DeltaLake

Delta Lake 是 Apache Spark 的扩展,由 Apache Spark 背后的公司 Databricks 创建,并作为一个独立的开源项目发布。Delta Lake 的目标是在企业环境中高效地写入数据湖,无论您拥有哪种类型的数据湖,无论是 Azure 数据湖存储、AWS S3 还是 Hadoop。Delta Lake 将关系数据库(如 Microsoft SQL Server 或 Oracle)的 ACID 属性带到了远程文件系统(如数据湖)中。

当我们使用 RDBMS(如 Microsoft SQL Server 或 Oracle)时,我们会讨论 ACID 属性,以及我们如何能够向数据库发送读写请求,RDBMS 会为我们处理 ACID 属性。酸的性质可以描述为

  • 原子性–读取和写入发生在它们的事务中,要么完全完成,要么完全失败。

  • 一致性–数据绝不会处于损坏状态,并且必须有效。数据还必须通过任何数据约束,以确保数据不仅从格式的角度来看是有效的,而且符合预期,例如是否允许空值。

  • 隔离–一个事务不能影响另一个正在进行的事务。

  • 持久性–一旦事务被提交,那么即使系统停止运行,它也会保持提交状态。

为了理解这对 Apache Spark 意味着什么,让我们看看之前发生了什么,当你将一些数据写到文件系统时会发生什么,在这个例子中,是 Azure 数据湖存储。

假设您有一个 Apache Spark 应用程序,它将使用 parquet 格式写入一些数据。在写入数据的选项中,您指定“覆盖”,这将导致任何数据被覆盖。在这种情况下,您需要确保您是唯一写入数据的进程,因为任何其他进程都会覆盖您的数据。除非您有一些外部进程导致一个且只有一个作业写入特定文件夹,否则您会发现需要解决复杂的计时问题。

另一个潜在的问题是,如果 Apache Spark 正在写入一个目录,而写入中途失败了,那么会发生什么。读者应该怎么做?他们会知道数据不完整吗?即使他们意识到数据不完整,也不可能回到以前被覆盖的数据。

Apache Spark 的另一个问题是从包含大量文件的目录中读取。评估要读入的文件列表是非常昂贵的,并且会降低从数据湖中读取的作业的速度。

对于传统 RDBMS 中的表,我们可以读取、插入、更新、删除或合并,这是插入、更新和删除的组合。有了数据湖,我们有时可以通过添加新文件来添加文件,或者我们可以覆盖文件,但我们不能打开一个 parquet 文件,找到一些行,然后更新或删除它们。我们在 RDBMS 中处理表的传统方式不能转化为基于文件的数据湖。

引入 DeltaLake 是为了解决这些问题。Delta Lake 将 ACID 属性从 RDBMS 带到了基于文件的数据湖,并能够对数据湖中的文件运行插入、更新、删除甚至合并语句,同时还修复了同一目录中大量文件的缓慢读取性能,并修复了多个写入程序的问题以及当写入程序失败并留下不完整数据时会发生什么情况。Delta Lake 改进了所有这一切,并使您能够回滚到以前的数据版本。

这种类型的并发控制称为“多版本并发控制”或 MVCC,在这种控制中,原始数据保持不变,但提供一些其他方法来存储版本信息。

三角洲日志

Delta Lake 之所以有效,是因为它创建了所谓的“Delta Log”,这是一组 JSON 文件,允许 Apache Spark 不仅读取数据湖中的数据,还读取关于哪些文件与数据的哪个版本相关的版本信息。增量日志位于名为 _delta_log 的文件夹中,由具有已知命名系统的 JSON 文件组成,并使用特定于每种类型的数据湖的锁来确保 JSON 文件被写入一次,从而允许多个写入者写入他们的数据,然后按顺序写入 JSON 文件,因此一个事务不能在实际失败或被部分覆盖时看起来像另一个事务成功了。

JSON 增量日志文件描述了数据的状态。在清单 11-1 中,我们可以看到当文件夹被转换为 delta 格式时,第一个 JSON 文件被写入数据湖。清单显示了最初的操作,即“CONVERT ”,然后 12 个文件被添加到表中。增量日志还包括写入数据的模式和组成第一个版本的文件的路径。

{
    "commitInfo": {
        "timestamp": 1603400609668,
        "operation": "CONVERT",
        "operationParameters": {
            "numFiles": 12,
            "partitionedBy": "[]",
            "collectStats": false
        },
        "operationMetrics": {
            "numConvertedFiles": "12"
        }
    }
}
{
    "protocol": {
        "minReaderVersion": 1,
        "minWriterVersion": 2
    }
}
{
    "metaData": {
        "id": "8942a94c-506b-488c-8247-0da4e861a37a",
        "format": {
            "provider": "parquet",
            "options": {}
        },
        "schemaString": "{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"long\",\"nullable\":true,\"metadata\":{}}]}",
        "partitionColumns": [],
        "configuration": {},
        "createdTime": 1603400609648
    }
}
{
    "add": {

        "path": "part-00011-707f035c-4ddb-461f-9d52-bc1f41f1f08c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 804,
        "modificationTime": 1603400607000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00000-707f035c-4ddb-461f-9d52-bc1f41f1f08c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 796,
        "modificationTime": 1603400607000,
        "dataChange": true
    }
}

Listing 11-1The JSON delta log file

阅读日期

为了从这种 delta 格式中读取数据,Apache Spark 转到“_delta_log”文件夹,按名称顺序读取每个 JSON 文件,然后评估哪些数据文件应该包含在返回的 DataFrame 中。让一组 JSON 文件告诉 Apache Spark 要读取哪些文件,意味着 Apache Spark 不必枚举所有文件,这样会增加性能开销。

Apache Spark 写入数据的顺序至关重要。首先,编写实际的 parquet 文件,然后更新 JSON 文件。这意味着如果 Apache Spark 写数据文件,然后崩溃,delta 格式不会处于不一致的状态;增量日志中没有引用的拼花文件将被忽略。

更改数据

在本节中,我们将了解 Delta Lake 如何修改可供读取的数据。我们将首先概述 Delta Lake 的所有不同特性,然后以一个 Delta Lake 应用程序示例结束,该示例展示了所有的附加特性。我们将先在 C#中演示使用 Delta Lake,然后在 F#中演示。

追加数据

最直接的操作是向现有的数据集添加更多的数据,Apache Spark 通过编写新的数据文件,然后向 JSON 添加更多的“add”指令来实现,我们在清单 11-1 中看到了这些指令。在清单 11-2 中,我们可以看到在运行一个“附加”之后,我们在哪里获得下一个 JSON 文件,以及要添加到数据帧中的下一组文件的详细信息。

{
    "commitInfo": {
        "timestamp": 1603740783553,
        "operation": "WRITE",
        "operationParameters": {
            "mode": "Append",
            "partitionBy": "[]"
        },
        "readVersion": 0,
        "isBlindAppend": true,
        "operationMetrics": {
            "numFiles": "12",
            "numOutputBytes": "5780",
            "numOutputRows": "50"
        }
    }
}
{
    "add": {
        "path": "part-00000-29d1078d-45f1-40f2-8058-1dc16eee7bf2-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603740783000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00001-1b7ad20f-a037-4ca4-a786-be6400e5b3b1-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603740783000,
        "dataChange": true
    }
}

Listing 11-2Appending more data causes additional “add” directives to be added via a JSON delta log file

如果我们现在让 Apache Spark 读取,那么它会做的是评估第一个 JSON 文件,找到所有“add”指令,然后评估第二个 JSON 文件,评估第二个文件中的“add”指令,并从所有底层的 parquet 文件创建一个数据帧。

覆盖数据

当我们想要覆盖数据以便我们正在写入的数据成为完整的数据集时,Apache Spark 将该写入标记为覆盖,并且忽略所有以前的 parquet 文件。

在清单 11-3 中,我们可以看到“Overwrite”操作使用“remove”指令从 JSON delta 日志中删除了 parquet 文件,并使用“add”指令添加了新文件。这意味着可以在任何时间点从底层的 parquet 文件中读取数据,这是不会改变的。

{
    "commitInfo": {
        "timestamp": 1603741047813,
        "operation": "WRITE",
        "operationParameters": {
            "mode": "Overwrite",
            "partitionBy": "[]"
        },
        "readVersion": 1,
        "isBlindAppend": false,
        "operationMetrics": {
            "numFiles": "12",
            "numOutputBytes": "5780",
            "numOutputRows": "50"
        }
    }
}
{
    "remove": {
        "path": "part-00005-6bd2f7c1-a364-4028-9846-3da01fd36f7f-c000.snappy.parquet",
        "deletionTimestamp": 1603741047812,
        "dataChange": true
    }
}
{
    "remove": {

        "path": "part-00001-437c2c31-ff49-489b-aff8-274f3b3de4b2-c000.snappy.parquet",
        "deletionTimestamp": 1603741047813,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00000-061e39ea-a20c-4671-8abb-adae938d2115-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603741047000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00001-8428416f-4c93-4d5d-bbcb-830905e1221f-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603741047000,
        "dataChange": true
    }
}

Listing 11-3The “remove” directives are causing Apache Spark to ignore the underlying files

更改数据

到目前为止,我们已经研究了如何追加额外的文件或覆盖整个增量表,这相对来说比较简单。如果我们想编辑增量表中的数据怎么办?德尔塔湖是怎么做到的?

如果我们要删除一行或更新一行,那么就复杂多了。Delta Lake 将读取表的当前状态,然后读入数据以识别要更改或删除的行。一旦 Delta Lake 知道哪些特定的行需要删除或更改,它将创建一个新的文件,其中包含应该保留的行以及任何更新的行。任何应该删除的行都不会写入新文件。当写入新文件时,新文件的“添加”指令和前一文件的“移除”指令被写入增量日志文件。

这意味着,如果您有一个由一个包含一百万行的 parquet 文件组成的 Delta 表,并且您更改了其中的一行,那么其他 999,999 行将与新修改的文件一起被重写。这是一种浪费,但这是唯一可用的选择,因为拼花地板不是可更新的格式。实际上,如果您不使用 Delta Lake 格式,您仍然会产生很高的成本,因为您必须覆盖这些文件,所以即使它不是最佳的,它也不比其他可能的解决方案差。

在清单 11-4 中,我们看到了增量日志上的更新语句的结果。

{
    "commitInfo": {
        "timestamp": 1603742054064,
        "operation": "UPDATE",
        "operationParameters": {
            "predicate": "(id#529L > 500)"
        },
        "readVersion": 1,
        "isBlindAppend": false,
        "operationMetrics": {
            "numRemovedFiles": "6",
            "numAddedFiles": "6",
            "numUpdatedRows": "499",
            "numCopiedRows": "1"
        }
    }
}
{
    "remove": {
        "path": "part-00006-d4e299fc-0ae4-48d8-9252-b00b3b78584d-c000.snappy.parquet",
        "deletionTimestamp": 1603742053905,
        "dataChange": true
    }
}
{

    "remove": {
        "path": "part-00010-d4e299fc-0ae4-48d8-9252-b00b3b78584d-c000.snappy.parquet",
        "deletionTimestamp": 1603742053905,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00004-426a4814-9962-4aa5-81da-e38480d86c5c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 493,
        "modificationTime": 1603742054000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00005-b6e1b947-3109-4951-af4a-f01a894642ff-c000.snappy.parquet",
        "partitionValues": {},
        "size": 493,
        "modificationTime": 1603742054000,
        "dataChange": true
    }
}

Listing 11-4Result of the delta log after running an update statement

update 语句运行并删除任何包含匹配行的文件,并使用任何新数据写入一个新文件。有趣的是,delta 日志对人类来说可读性有多强。在本例中,它甚至记录了更新操作和用于查找要修改的行的过滤器。

检查站

如果对以 delta 格式存储的数据集做了很多修改,您可能会发现枚举所有这些 JSON 文件会变得很慢。Delta Lake 的另一个特性是,它使用“检查点”文件以一种读取速度更快的格式存储一个特定版本的状态。当 Delta Lake 认为合适时,它将创建一个具有当前状态的 parquet 文件,并在“_last_checkpoint”文件中记录检查点的版本。如果您将检查点文件作为一个 parquet 文件读取并显示数据,那么您将看到如下所示的内容:

+----+--------------------+------+--------------------+--------+----------+
| txn|                 add|remove|            metaData|protocol|commitInfo|
+----+--------------------+------+--------------------+--------+----------+
|null|[part-00007-d4e29...|  null|                null|    null|      null|
|null|[part-00010-d4e29...|  null|                null|    null|      null|
|null|[part-00009-d4e29...|  null|                null|    null|      null|
|null|[part-00002-d4e29...|  null|                null|    null|      null|
|null|[part-00004-d4e29...|  null|                null|    null|      null|
|null|[part-00006-d4e29...|  null|                null|    null|      null|
|null|[part-00008-d4e29...|  null|                null|    null|      null|
|null|[part-00003-d4e29...|  null|                null|    null|      null|
|null|[part-00000-d4e29...|  null|                null|    null|      null|
|null|[part-00011-d4e29...|  null|                null|    null|      null|
|null|[part-00001-d4e29...|  null|                null|    null|      null|
|null|                null|  null|                null|  [1, 2]|      null|
|null|                null|  null|[3434f91f-fb53-4e...|    null|      null|
|null|[part-00005-d4e29...|  null|                null|    null|      null|
+----+--------------------+------+--------------------+--------+----------+

历史

由于数据被写入各个 parquet 文件,然后增量日志被用来记录数据在任何时间点的状态,我们还可以查看增量表的历史记录,并选择数据,就像它是特定版本或特定时间一样。

要查看可用的历史记录,我们可以手动检查 JSON 增量日志,该日志由位于 https://databricks.com/blog/2019/08/21/diving-into-delta-lake-unpacking-the-transaction-log.html#:~:text=The%20Delta%20Lake%20Transaction%20Log%20at%20the%20File%20Level&text=Each%20commit%20is%20written%20out 的数据块、JSON % 20% 2C % 20 以下%20as%20000002 或位于https://github.com/delta-io/delta/blob/master/PROTOCOL.md的协议本身记录。

Delta Lake 还提供了一个 API,我们可以使用对象或 SQL 请求来调用它。当我们使用 API 时,我们得到了一个数据帧,其中包含了增量表的历史细节。

如果我们查看数据帧的内容,我们会看到

  • 版本

  • 时间戳

  • 创建版本的用户

  • 写、删除、更新等操作

  • 工作或笔记本详细信息

  • 集群 ID

  • 操作的详细信息,例如使用的过滤器以及添加和删除的文件数量

真空

因为每次进行更改时,都会添加更多的文件,所以对于经常更新的表来说,数据的大小可能会变得过于昂贵,无法永久存储。为了迎合这一点,Delta Lake 提供了一种方法来设置保留历史长度,然后运行 Vacuum,这将删除任何不再需要提供历史的文件。

如果您指定一段时间,例如七天,这是默认值,那么创建过去七天的历史所需的任何文件都会保留。这可能意味着您有一个七天前的文件,它仍然用于提供增量表的当前版本。如果您认为只有删除或更新文件中的数据时文件才过时,如果文件中的数据没有更改,则文件仍然有效,即使您指定要清空超过七天的数据。

我们可以随时发出 Vacuum 命令,但是我们应该注意,如果我们不运行 Vacuum 命令,文件将无限期地停留在那里。

真空清理数据文件。任何日志文件都会保留到检查点之后,这是在每十次提交之后自动发生的。

合并

有了 Delta Lake 格式和修改现有文件的能力,Apache Spark 团队还为 Delta Lake 引入了 merge,这意味着我们可以有一个可以

  • 更新行

  • 删除行

  • 插入新行

Merge 是一个令人兴奋的特性,因为我们可以让 Apache Spark 一次运行多个操作,而不是手动运行更新、插入和删除。

图式进化

Delta Lake 格式包括底层数据的模式,这意味着如果我们试图用额外的列追加一个新的数据文件,追加将会失败。要解决这个问题,您可以包含“mergeSchema”选项,该选项会用新列自动更新模式。对于任何旧行,新列将为空。但是,对于任何新追加的数据,该列都将有数据。

时间旅行

表的历史记录允许我们查看存在哪个版本,然后,通过 DataFrame API,我们可以指定选项来控制版本,要么是“timestampAsOf ”,就像是特定时间一样提取数据,要么是“versionAsOf ”,就像是特定版本一样提取数据。

这种快速回到过去的能力对于故障排除非常方便。通过能够回滚到特定的日期和时间,然后重新运行以前中断的数据管道,它已经为我个人节省了几次重新加载大量数据的时间。

DeltaLake 应用示例

希望到现在为止,你已经对 DeltaLake 有了很好的理解。因此,在这一节中,我们将研究如何将 Delta Lake 代码添加到 Apache Spark 实例中,并创建我们的。NET 用于使用 Delta Lake 格式的 Apache Spark 应用程序。

配置

DeltaLake 不是核心阿帕奇星火项目的一部分。核心团队已经创建了它,但是它被视为第三方组件。因为它没有附带 Apache Spark,所以我们需要做的事情很少。

首先,我们需要确保 Apache Spark 实例加载了 Delta Lake 的 JAR 文件。为此,我修改了$SPARK _ HOME/conf/SPARK-defaults . conf 文件,并添加了以下代码行:

spark.jars.packages io.delta:delta-core_2.12:0.7.0

这将导致 JAR 文件被下载并包含在随后启动的每个 Apache Spark 实例中。

现在 Apache Spark 有了 Delta Lake 格式,我们需要包含。NET 对象,因为它们也不是微软。Spark NuGet 包。DeltaLake。NET 对象在微软。所以您需要将它添加到您的项目中。

最后,为了使用 Delta Lake SQL 扩展,我们需要告诉 Apache Spark 使用配置选项“spark.sql.extensions”启用扩展的 SQL 命令,该选项设置为“io . Delta . SQL . deltasparksessionextension”。

c sharp . c sharp . c sharp . c sharp

这一节将通过一个例子来说明如何使用 Delta Lake 扩展。NET for Apache Spark。首先,我们将通过 C#的例子,然后是 F#的例子。

在清单 11-5 中,我们创建了SparkSession,但是使用配置选项指定我们想要使用DeltaSparkSessionExtensions

var spark = SparkSession.Builder()
    .Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
    .GetOrCreate();

Listing 11-5Create the SparkSession passing in the details of the Delta extension we wish to load

然后,作为一次性练习,我们创建 DeltaLake 表。对于 Delta Lake,有一个名为 DeltaTable 的静态类,它为我们提供了一些有用的方法来获取对 Delta 表的引用,并将 parquet 文件转换为 Delta Lake 格式。在清单 11-6 中,我们使用 DeltaTable。IsdeltaTable 来查看 Delta 表是否存在,或者我们是否必须编写一个 parquet 文件,然后将其转换为 Delta Lake 格式。

if (!DeltaTable.IsDeltaTable("parquet.`/tmp/delta-demo`"))
{
    spark.Range(1000).WithColumn("name", Lit("Sammy")).Write().Mode("overwrite").Parquet("/tmp/delta-demo");
    DeltaTable.ConvertToDelta(spark, "parquet.`/tmp/delta-demo`");
}

Listing 11-6Converting a parquet file to Delta Lake

注意,在清单 11-6 中,我们显式地将一个 parquet 文件转换成 Delta Lake 格式,但是我们也可以使用“Delta”格式编写一个 DataFrame。

在清单 11-7 中,我们使用Delta.ForPath获取对增量表的引用,然后通过使用ToDF()将对增量表的引用转换成DataFrame

var delta = DeltaTable.ForPath("/tmp/delta-demo");
delta.ToDF().OrderBy(Desc("Id")).Show();

Listing 11-7Using DeltaTable to get a reference to the DataFrame

向增量表追加数据就像使用其他格式一样简单。我们指定模式为“追加”;我们在清单 11-8 中展示了这一点。

spark.Range(5, 500 ).WithColumn("name", Lit("Lucy")).Write().Mode("append").Format("delta").Save("/tmp/delta-demo");

Listing 11-8Appending data to a Delta table

如果我们想要更新增量表中的数据,我们需要使用对DeltaTable的引用,而不是从DeltaTable.ToDF返回的DataFrame。在清单 11-9 中,我展示了如何使用DeltaTable引用来更新增量表。在本例中,我们找到 id 大于 500 的任何行,然后将 id 列设置为值 999。

delta.Update(Expr("id > 500"), new Dictionary<string, Column>()
{
    {"id", Lit(999)}
});

Listing 11-9Updating a Delta table

从增量表中删除是通过调用DeltaTable.Delete然后传递一个过滤器来完成的。如果您没有提供过滤器,那么每一行都会被删除。清单 11-10 显示了Delete的操作。

delta.Delete(Column("id").EqualTo(999));

Listing 11-10Deleting from a Delta table

为了从表中查看历史,我们使用 DeltaTable。History()方法,该方法返回一个数据帧,因此我们可以调用 Show 或做任何您需要做的过滤。这很有用,因为您可以过滤数据帧以找到一个特定的更新,然后使用版本和/或时间详细信息在特定更新时从表中读取。清单 11-11 展示了如何请求一个增量表的历史。

delta.History().Show(1000, 10000);

Listing 11-11Requesting the history from a Delta table

现在你有了可用的历史;您可以使用“timestampAsOf”和“version of”选项来指定您想要的增量表的确切版本,而不是最新版本。在清单 11-12 中,我们展示了如何读取增量表,就好像它是一个特定的版本或时间。

spark.Read().Format("delta").Option("versionAsOf", 0).Load("/tmp/delta-demo").OrderBy(Desc("Id")).Show();

spark.Read().Format("delta").Option("timestampAsOf", "2021-10-22 22:03:36").Load("/tmp/delta-demo").OrderBy(Desc("Id")).Show();

Listing 11-12Reading from the Delta table using time travel

我们要看的下一个操作是合并操作。当我们在中合并数据时,我们使用增量表作为目标,使用数据帧作为源。为了方便起见,通常最好将两个表都用别名。在清单 11-13 中,我展示了一个示例合并操作,我将 Delta 表别名为“target ”,将 DataFrame 别名为“source ”,因此当提供过滤器来显示匹配哪些行时,我们可以以简单的字符串格式“target.id = source.id”提供过滤器。当我们提供了过滤器后,我们可以选择在行匹配时提供两个动作,在行不匹配时提供一个动作。当一行匹配时,我们还可以选择提供第二个过滤器,更新匹配的行或删除它们。

当我们不匹配任何行时,我们可以插入所有列或者提供一个列列表。在这种情况下,因为我在增量表和数据帧中有相同的模式,所以 InsertAll 工作正常。

var newData = spark.Range(10).WithColumn("name", Lit("Ed"));

delta.Alias("target")
        .Merge(newData.Alias("source"), "target.id = source.id")
        .WhenMatched(newData["id"].Mod(2).EqualTo(0)).Update(new Dictionary<string, Column>()
                                                            {
                                                                {"name", newData["name"]}
                                                            })
        .WhenMatched(newData["id"].Mod(2).EqualTo(1)).Delete()
        .WhenNotMatched().InsertAll()
    .Execute();

Listing 11-13The Delta Lake merge operation

清单 11-13 中的示例背后的逻辑是

  • 在增量表中找到与数据帧中的 id 相匹配的任何一行。

  • 如果有任何行匹配并且它们是偶数,Mod(2).EqualTo(0),更新该行并将名称列设置为源数据帧中相关 id 的名称列的值。

  • 如果任何行匹配并且是奇数,Mod(2).EqualTo(1),删除该行。

  • 如果源数据帧中的任何 id 在目标增量表中尚不存在,则将源数据帧中的所有列插入到目标增量表中。

最后,在清单 11-14 中,我们展示了要演示的最后一个操作是Vacuum方法,如果可以的话,它会整理任何不需要支持当前版本和保留期内任何版本的旧数据文件。

delta.Vacuum(1F)

Listing 11-14Delta table Vaccum

FSharp

这一节将通过一个例子来说明如何使用 Delta Lake 扩展。NET for Apache Spark 使用 F#。

在清单 11-15 中,我们创建了SparkSession,但是使用配置选项指定我们想要使用DeltaSparkSessionExtensions

let spark = SparkSession.Builder()
            |> fun builder -> builder.Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
            |> fun builder -> builder.GetOrCreate()

Listing 11-15Create the SparkSession passing in the details of the Delta extension we wish to load

然后,作为一次性练习,我们创建 DeltaLake 表。对于 Delta Lake,有一个名为 DeltaTable 的静态类,它为我们提供了一些有用的方法来获取对 Delta 表的引用,并将 parquet 文件转换为 Delta Lake 格式。在清单 11-16 中,我们使用 DeltaTable。IsdeltaTable 来查看 Delta 表是否存在,或者我们是否必须编写一个 parquet 文件,然后将其转换为 Delta Lake 格式。

let delta = match DeltaTable.IsDeltaTable("parquet.`/tmp/delta-demo`") with
                | false -> spark.Range(1000L)
                                |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Sammy"))
                                |> fun dataframe -> dataframe.Write()
                                |> fun writer -> writer.Mode("overwrite").Parquet("/tmp/delta-demo")
                           DeltaTable.ConvertToDelta(spark, "parquet.`/tmp/delta-demo`")
                | _ -> DeltaTable.ForPath("/tmp/delta-demo")

Listing 11-16Converting a parquet file to Delta Lake

注意,在清单 11-16 中,我们显式地将一个拼花文件转换成 Delta Lake 格式,但是我们也可以使用“Delta”格式编写一个数据帧。如果我们不需要创建增量表,我们使用Delta.ForPath来获取对增量表的引用,然后通过使用ToDF()将对增量表的引用转换成DataFrame

向增量表追加数据就像使用其他格式一样简单。我们指定模式为“追加”;我们在清单 11-17 中展示了这一点。

spark.Range(5L, 500L)
    |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Lucy"))
    |> fun dataframe -> dataframe.Write()
    |> fun writer -> writer.Mode("append").Format("delta").Save("/tmp/delta-demo")

Listing 11-17Appending data to a Delta table

如果我们想要更新增量表中的数据,我们需要使用对DeltaTable的引用,而不是从DeltaTable.ToDF返回的DataFrame。在清单 11-18 中,我展示了如何使用DeltaTable引用来更新增量表。在本例中,我们找到 id 大于 500 的任何行,然后将 id 列设置为值 999。

delta.Update(Functions.Expr("id > 500"), Dictionary<string, Column>(dict [("id", Functions.Lit(999))]))

Listing 11-18Updating a Delta table

从增量表中删除是通过调用DeltaTable.Delete然后传递一个过滤器来完成的。如果您没有提供过滤器,那么每一行都会被删除。清单 11-19 显示了Delete的操作。

delta.Delete(Functions.Col("id").EqualTo(999))

Listing 11-19Deleting from a Delta table

为了从表中查看历史,我们使用 DeltaTable。History()方法,该方法返回一个数据帧,因此我们可以调用 Show 或做任何您需要做的过滤。这很有用,因为您可以过滤数据帧以找到一个特定的更新,然后使用版本和/或时间详细信息在特定更新时从表中读取。清单 11-20 展示了如何请求一个增量表的历史。

delta.History()
        |> fun dataframe -> dataframe.Show()

Listing 11-20Requesting the history from a Delta table

现在你有了可用的历史;您可以使用“timestampAsOf”和“version of”选项来指定您想要的增量表的确切版本,而不是最新版本。在清单 11-21 中,我们展示了如何读取增量表,就好像它是一个特定的版本或时间。

spark.Read()
    |> fun reader -> reader.Format("delta")
    |> fun reader -> reader.Option("versionAsOf", 0L)
    |> fun reader -> reader.Load("/tmp/delta-demo")
    |> fun dataframe -> dataframe.OrderBy(Functions.Desc("id"))
    |> fun ordered -> ordered.Show()

spark.Read()
    |> fun reader -> reader.Format("delta")
    |> fun reader -> reader.Option("timestampAsOf", "2022-01-01")
    |> fun reader -> reader.Load("/tmp/delta-demo")
    |> fun dataframe -> dataframe.OrderBy(Functions.Desc("id"))
    |> fun ordered -> ordered.Show()

Listing 11-21Reading from the Delta table using time travel

我们要看的下一个操作是合并操作。当我们在中合并数据时,我们使用增量表作为目标,使用数据帧作为源。为了方便起见,通常最好将两个表都用别名。在清单 11-22 中,我展示了一个示例合并操作,我将 Delta 表别名为“target ”,将 DataFrame 别名为“source ”,因此当提供过滤器来显示匹配哪些行时,我们可以以简单的字符串格式“target.id = source.id”提供过滤器。当我们提供了过滤器后,我们可以选择在行匹配时提供两个动作,在行不匹配时提供一个动作。当一行匹配时,我们还可以选择提供第二个过滤器,更新匹配的行或删除它们。

当我们不匹配任何行时,我们可以插入所有列或者提供一个列列表。在这种情况下,因为我在增量表和数据帧中有相同的模式,所以 InsertAll 工作正常。

let newData = spark.Range(10L)
                 |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Ed"))
                 |> fun newData -> newData.Alias("source")

delta.Alias("target")
  |> fun target -> target.Merge(newData, "source.id = target.id")
  |> fun merge -> merge.WhenMatched(newData.["id"].Mod(2).EqualTo(0))
  |> fun evens -> evens.Update(Dictionary<string, Column>(dict [("name", newData.["name"])]))
  |> fun merge -> merge.WhenMatched(newData.["id"].Mod(2).EqualTo(0))
  |> fun odds -> odds.Delete()
  |> fun merge -> merge.WhenNotMatched()
  |> fun inserts -> inserts.InsertAll()
  |> fun merge -> merge.Execute()

Listing 11-22The Delta Lake merge operation

清单 11-22 中的示例背后的逻辑是

  • 在增量表中找到与数据帧中的 id 相匹配的任何一行。

  • 如果有任何行匹配并且它们是偶数,Mod(2).EqualTo(0),更新该行并将名称列设置为源数据帧中相关 id 的名称列的值。

  • 如果任何行匹配并且是奇数,Mod(2).EqualTo(1),删除该行。

  • 如果源数据帧中的任何 id 在目标增量表中尚不存在,则将源数据帧中的所有列插入到目标增量表中。

最后,在清单 11-23 中,我们展示了要演示的最后一个操作是Vacuum方法,如果可以的话,它会整理任何不需要支持当前版本和保留期内任何版本的旧数据文件。

delta.Vacuum(1F)

Listing 11-23Delta table Vaccum

摘要

在本章中,我们已经了解了 Delta Lake,以及它如何帮助我们在数据湖中创建数据应用程序。我在生产中使用过 Delta Lake,它的好处非常明显,比如能够将表回滚到特定的时间点,以及更新、删除和合并现有 Delta 表中的数据。

第一部分:开始

第二部分:API

第三部分:测验

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值