Spark-学习指南-全-

Spark 学习指南(全)

原文:zh.annas-archive.org/md5/98291279430d6b18e8d3bee6f3b53603

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

自 2009 年我在加州大学伯克利分校启动该项目以来,Apache Spark 已经显著发展。在移至 Apache 软件基金会后,这个开源项目已经有超过 1400 名贡献者来自数百家公司,全球Spark meetup 小组的成员人数也已增长到超过五十万人。Spark 的用户群体也变得非常多样化,涵盖了 Python、R、SQL 和 JVM 开发者,使用案例从数据科学到商业智能再到数据工程各个领域。我一直与 Apache Spark 社区密切合作,以帮助推动其发展,看到迄今为止的进展,我感到非常激动。

Spark 3.0 的发布标志着项目的重要里程碑,并引发了对更新学习材料的需求。提出第二版《Learning Spark》的想法已经多次提及,并且早就应该出版了。尽管我共同撰写了 Learning SparkSpark: The Definitive Guide(都是 O’Reilly 的书),但是现在是让下一代 Spark 贡献者继续叙述的时候了。我很高兴看到四位经验丰富的从业者和开发者,他们从 Spark 早期开始就与 Apache Spark 密切合作,联手撰写了这本书的第二版,结合了最新的 API 和 Spark 开发者的最佳实践,以清晰而丰富的指南形式呈现。

本版的作者对学习方法非常有利于实践。Spark 和分布式大数据处理的关键概念已经被提炼为易于跟随的章节。通过书中的示例代码,开发者可以建立对 Spark 的信心,并更深入地理解其结构化 API 及其如何利用它们。我希望《Learning Spark》的第二版能够指导您在大规模数据处理的旅程中前行,无论您想用 Spark 解决什么问题。

Matei Zaharia,首席技术专家,

Databricks 的联合创始人,斯坦福大学助理教授,

以及 Apache Spark 的原始创建者

前言

欢迎您阅读第二版的Learning Spark。自 2015 年首版由 Holden Karau、Andy Konwinski、Patrick Wendell 和 Matei Zaharia 共同撰写以来已经过去五年。这个新版已经更新,以反映 Apache Spark 经过 Spark 2.x 和 Spark 3.0 的演变,包括其扩展的内置和外部数据源生态系统,机器学习和流处理技术,与 Spark 紧密集成。

自其首个 1.x 版本发布以来的多年间,Spark 已成为事实上的大数据统一处理引擎。在此过程中,它已扩展其范围以支持各种分析工作负载。我们的目的是为读者捕捉和整理这一演变过程,展示不仅可以如何使用 Spark,而且它如何适应大数据和机器学习的新时代。因此,我们设计每一章节都在前一章节奠定的基础上逐步建设,确保内容适合我们的目标读者群体。

本书适合对象

大多数处理大数据的开发人员是数据工程师、数据科学家或机器学习工程师。本书旨在为那些希望使用 Spark 来扩展其应用程序以处理大量数据的专业人士提供帮助。

特别是数据工程师将学习如何使用 Spark 的结构化 API 执行复杂的数据探索和分析,无论是批量还是流式数据;使用 Spark SQL 进行交互式查询;使用 Spark 的内置和外部数据源在不同文件格式中读取、精炼和写入数据,作为其提取、转换和加载(ETL)任务的一部分;以及使用 Spark 和开源 Delta Lake 表格格式构建可靠的数据湖。

对于数据科学家和机器学习工程师,Spark 的 MLlib 库提供了许多常见算法来构建分布式机器学习模型。我们将介绍如何使用 MLlib 构建管道,分布式机器学习的最佳实践,如何使用 Spark 扩展单节点模型,以及如何使用开源库 MLflow 管理和部署这些模型。

虽然本书着重于将 Spark 作为多样工作负载的分析引擎进行学习,但我们不会涵盖 Spark 支持的所有语言。大多数章节中的示例都是用 Scala、Python 和 SQL 编写的。在必要时,我们也会添加一些 Java。对于有意通过 R 学习 Spark 的人士,我们推荐 Javier Luraschi、Kevin Kuo 和 Edgar Ruiz 的Mastering Spark with R(O’Reilly)。

最后,由于 Spark 是一个分布式引擎,建立对 Spark 应用概念的理解至关重要。我们将指导您了解您的 Spark 应用程序如何与 Spark 的分布式组件进行交互,以及如何将执行分解为集群上的并行任务。我们还将涵盖支持的部署模式及其适用环境。

尽管有许多我们选择涵盖的主题,但我们选择不专注于的主题也有几个。这些包括旧版的低级别 Resilient Distributed Dataset (RDD) API 和 GraphX,即用于图形和图形并行计算的 Spark API。我们也没有涵盖高级主题,比如如何扩展 Spark 的 Catalyst 优化器以实现自己的操作,如何实现自己的目录,或者如何编写自己的 DataSource V2 数据接收器和数据源。虽然这些都是 Spark 的一部分,但它们超出了您第一本关于学习 Spark 的书籍的范围。

相反,我们已经将书籍围绕 Spark 的结构化 API 进行了重点和组织,跨其所有组件,并展示您如何使用 Spark 在规模上处理结构化数据以执行数据工程或数据科学任务。

书籍的组织方式

我们按照一种引导您从章节到章节的方式组织了这本书,通过介绍概念,通过示例代码片段演示这些概念,并在书的GitHub 存储库中提供完整的代码示例或笔记本。

第一章,Apache Spark 简介:统一分析引擎

介绍大数据的演变,并提供 Apache Spark 的高级概述及其在大数据中的应用。

第二章,下载 Apache Spark 并入门

引导您下载并在本地计算机上设置 Apache Spark。

第三章,Apache Spark 的结构化 APIs 到 第六章,Spark SQL 和数据集

这些章节专注于使用 DataFrame 和 Dataset 结构化 API 从内置和外部数据源中摄取数据,应用内置和自定义函数,并利用 Spark SQL。这些章节构成后续章节的基础,包括所有最新的 Spark 3.0 变更。

第七章,优化和调优 Spark 应用程序

通过 Spark UI 提供了调优、优化、调试和检查 Spark 应用程序的最佳实践,以及可以调整以提高性能的配置细节。

第八章,结构化流处理

指导您通过 Spark 流处理引擎的演变和结构化流处理编程模型。它检查典型流查询的解剖,并讨论转换流数据的不同方法——有状态聚合、流连接和任意有状态聚合——同时指导您如何设计高性能的流查询。

第九章,使用 Apache Spark 构建可靠的数据湖

调查了三种开源表格式存储解决方案,作为 Spark 生态系统的一部分,它们利用 Apache Spark 构建具有事务保证的可靠数据湖。由于 Delta Lake 与 Spark 在批处理和流处理工作负载中的紧密集成,我们重点关注该解决方案,并探讨其如何促进数据管理新范式,即“lakehouse”。

第十章,MLlib 机器学习

介绍了 MLlib,Spark 的分布式机器学习库,并通过端到端示例演示了如何构建机器学习流水线,包括特征工程、超参数调优、评估指标以及模型的保存和加载。

第十一章,管理、部署和扩展机器学习流水线与 Apache Spark

讲解如何使用 MLflow 跟踪和管理 MLlib 模型,比较和对比不同的模型部署选项,探讨如何利用 Spark 进行非 MLlib 模型的分布式模型推断、特征工程和/或超参数调优。

第十二章,尾声:Apache Spark 3.0

尾声突出了 Spark 3.0 的显著特性和变化。虽然增强和特性的全面范围太广泛而无法适应单一章节,但我们突出了您应该了解的主要变化,并建议您在 Spark 3.0 正式发布时查看发布说明。

在这些章节中,我们根据需要引入或记录了 Spark 3.0 的功能,并针对 Spark 3.0.0-preview2 测试了所有的代码示例和 notebooks。

如何使用代码示例

本书中的代码示例从简短的代码片段到完整的 Spark 应用程序和端到端 notebooks,涵盖 Scala、Python、SQL 以及必要时的 Java。

虽然某些章节中的短代码片段是自包含的,可以复制并粘贴到 Spark shell(pysparkspark-shell)中运行,其他则是来自独立的 Scala、Python 或 Java Spark 应用程序或端到端 notebooks 的片段。要在 Scala、Python 或 Java 中运行独立的 Spark 应用程序,请阅读本书 GitHub 存储库的各自章节的 README 文件中的说明。

至于 notebooks,要运行这些内容,您需要注册一个免费的Databricks 社区版帐户。我们详细介绍了如何导入这些 notebooks,并在 Spark 3.0 中创建一个集群,详见README

使用的软件和配置

本书中大部分代码及其相关 notebooks 均针对 Apache Spark 3.0.0-preview2 编写和测试,这是我们在编写最终章节时可用的版本。

当本书出版时,Apache Spark 3.0 将已发布并可供社区普遍使用。我们建议您根据您操作系统的以下配置 下载 并使用官方版本:

  • Apache Spark 3.0(为 Apache Hadoop 2.7 预构建)

  • Java 开发工具包(JDK)1.8.0

如果您只打算使用 Python,则可以简单地运行 pip install pyspark

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

**粗体固定宽度**

显示用户应直接输入的命令或其他文本。

*斜体固定宽度*

显示应由用户提供的值或由上下文确定的值。

注意

此元素表示一般注释。

使用代码示例

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,则您可以在您的程序和文档中使用它。除非您要复制本书的大部分代码,否则您无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。

我们赞赏,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“学习 Spark,第二版,作者 Jules S. Damji、Brooke Wenig、Tathagata Das 和 Denny Lee。版权所有 2020 Databricks, Inc.,978-1-492-05004-9。”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时与我们联系,邮箱为 permissions@oreilly.com

O'Reilly 在线学习

注意

40 多年来,O'Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入的学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104 (传真)

访问我们的书页,我们在那里列出勘误、示例和任何额外信息,网址为 https://oreil.ly/LearningSpark2

电子邮件 bookquestions@oreilly.com,您可以评论或询问有关本书的技术问题。

有关我们的书籍和课程的新闻和信息,请访问 http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上关注我们:http://www.youtube.com/oreillymedia

致谢

这个项目真正是众多人的团队努力,没有他们的支持和反馈,我们将无法完成这本书,尤其是在当今前所未有的 COVID-19 时代。

首先,我们要感谢我们的雇主,Databricks,支持我们并在工作中分配了专门的时间来完成这本书。特别感谢 Matei Zaharia、Reynold Xin、Ali Ghodsi、Ryan Boyd 和 Rick Schultz,他们鼓励我们写第二版。

第二,我们要感谢我们的技术审阅者:Adam Breindel、Amir Issaei、Jacek Laskowski、Sean Owen 和 Vishwanath Subramanian。他们通过他们在社区和行业的技术专业知识,提供了勤奋和建设性的反馈,使本书成为了宝贵的资源,用来学习 Spark。

除了正式的书评者外,我们还从其他具有特定主题和章节知识的人士那里获得了宝贵的反馈,我们要感谢他们的贡献。特别感谢:Conor Murphy、Hyukjin Kwon、Maryann Xue、Niall Turbitt、Wenchen Fan、Xiao Li 和 Yuanjian Li。

最后,我们要感谢我们在 Databricks 的同事(他们对我们错过或忽视项目截止日期的包容)、我们的家人和爱人(他们在我们白天或周末深夜写作时的耐心与同情)、以及整个开源 Spark 社区。没有他们的持续贡献,Spark 就不会取得今天的成就——我们这些作者也没有什么可写的。

谢谢大家!

第一章:Apache Spark 简介:统一分析引擎

本章概述了 Apache Spark 的起源及其基本理念。它还介绍了项目的主要组件及其分布式架构。如果您熟悉 Spark 的历史和高级概念,可以跳过本章。

Spark 的起源

在本节中,我们将梳理 Apache Spark 短暂发展的历程:它的起源、灵感以及在社区中作为事实上的大数据统一处理引擎的采用。

谷歌的大数据与分布式计算

当我们谈论规模时,我们不禁会想到谷歌搜索引擎在互联网上索引和搜索数据的闪电般速度。谷歌这个名字与规模是同义词。事实上,谷歌是对数学术语 googol 的故意拼写错误:那是 1 加上 100 个零!

传统的存储系统如关系数据库管理系统(RDBMS)和命令式编程方式都无法处理谷歌希望构建和搜索互联网索引文档的规模。由此产生的需求推动了 Google File System(GFS)MapReduce(MR)Bigtable 的创建。

虽然 GFS 在集群农场的许多廉价硬件服务器上提供了容错和分布式文件系统,但 Bigtable 在 GFS 上提供了结构化数据的可扩展存储。MR 引入了一种基于函数式编程的新并行编程范式,用于在分布在 GFS 和 Bigtable 上的数据上进行大规模处理。

本质上,您的 MR 应用与 MapReduce 系统 交互,将计算代码(映射和减少函数)发送到数据所在的位置,支持数据局部性和集群机架亲和性,而不是将数据带到应用程序。

工作节点聚合和减少中间计算,通过减少函数生成最终附加输出,然后将其写入分布式存储中,供应用程序访问。这种方法显著减少了网络流量,并使大部分输入/输出(I/O)保持在本地磁盘而非分布在网络上。

虽然谷歌的大部分工作是专有的,但上述三篇论文中表达的思想在开源社区中激发了创新思想,尤其是在雅虎等面对类似大规模数据挑战的地方。

雅虎的 Hadoop!

谷歌的 GFS 论文中表达的计算挑战和解决方案为Hadoop 文件系统(HDFS)提供了一个蓝图,包括作为分布式计算框架的 MapReduce 实现。2006 年 4 月捐赠给Apache 软件基金会(ASF),这成为了相关模块的 Apache Hadoop 框架的一部分:Hadoop Common、MapReduce、HDFS 和 Apache Hadoop YARN。

虽然 Apache Hadoop 在 Yahoo!之外获得了广泛应用,激发了一个庞大的开源社区,吸引了许多贡献者和两家基于开源的商业公司(Cloudera 和 Hortonworks,现已合并),但在 HDFS 上的 MapReduce 框架存在一些不足之处。

首先,它很难管理和管理,操作复杂性令人生畏。其次,其一般批处理 MapReduce API 冗长,需要大量样板设置代码,容错性脆弱。第三,对于具有许多 MR 任务对的大批量数据作业,每对任务的中间计算结果都写入本地磁盘以供后续阶段操作使用(参见图 1-1)。这种重复的磁盘 I/O 操作付出了代价:大规模 MR 作业可能运行数小时甚至数天。

在映射和减少计算之间间歇性迭代的读写

图 1-1. 在映射和减少计算之间间歇性迭代的读写

最后,尽管 Hadoop MR 有助于一般批处理的大规模作业,但它在结合其他工作负载(如机器学习、流处理或交互式 SQL 查询)方面表现不佳。

为了处理这些新的工作负载,工程师们开发了定制系统(如 Apache Hive、Apache Storm、Apache Impala、Apache Giraph、Apache Drill、Apache Mahout 等),每个系统都有自己的 API 和集群配置,进一步增加了 Hadoop 的操作复杂性和开发人员的陡峭学习曲线。

当时的问题是(牢记 Alan Kay 的格言:“简单的事情应该简单,复杂的事情应该可能”),是否有办法使 Hadoop 和 MR 更简单更快?

Spark 在 AMPLab 的早期发展阶段

加州大学伯克利分校的研究人员曾参与过 Hadoop MapReduce 项目,他们接受了这一挑战,并启动了一个名为Spark的项目。他们意识到 MR 在交互式或迭代计算作业中效率低下(或难以解决),且是一个复杂的学习框架,因此从一开始就采纳了使 Spark 更简单、更快、更易用的理念。这项努力始于 2009 年的 RAD 实验室,后来成为 AMPLab(现在被称为 RISELab)。

早期关于 Spark 的论文表明,对于某些工作,它比 Hadoop MapReduce 快 10 到 20 倍。今天,它比前者快几个数量级。Spark 项目的核心目标是借鉴 Hadoop MapReduce 的思想,但增强系统:使其高度容错和尴尬并行,支持在迭代和交互式映射和减少计算之间的中间结果内存存储,提供多语言作为编程模型的简单和可组合的 API,并统一支持其他工作负载。我们很快会回到这个统一的概念,因为这是 Spark 中的重要主题。

到了 2013 年,Spark 已被广泛使用,其原始创作者和研究人员之一 —— Matei Zaharia、Ali Ghodsi、Reynold Xin、Patrick Wendell、Ion Stoica 和 Andy Konwinski —— 将 Spark 项目捐赠给 ASF,并成立了名为 Databricks 的公司。

Databricks 和开源社区的开发者们在 Apache Spark 1.0 发布于 2014 年 5 月,由 ASF 管理。这个首个重要版本为未来频繁发布和 Databricks 以及超过 100 家商业供应商贡献显著特性奠定了基础。

什么是 Apache Spark?

Apache Spark 是一个统一引擎,专为大规模分布式数据处理设计,可以在数据中心或云中进行。

Spark 提供内存存储以进行中间计算,使其比 Hadoop MapReduce 快得多。它包含具有可组合 API 的库,用于机器学习(MLlib)、交互式查询的 SQL(Spark SQL)、与实时数据交互的流处理(Structured Streaming)以及图处理(GraphX)。

Spark 的设计理念围绕着四个关键特性展开:

  • 速度

  • 使用便捷性

  • 模块化

  • 可扩展性

让我们看看这对框架意味着什么。

速度

Spark 在多个方面追求速度目标。首先,其内部实现受益于硬件行业近年来在提高 CPU 和内存价格性能方面的巨大进展。如今的商品服务器价格便宜,拥有数百 GB 的内存、多个核心,并且基于 Unix 的操作系统利用高效的多线程和并行处理。框架被优化以充分利用所有这些因素。

其次,Spark 将其查询计算构建为有向无环图(DAG);其 DAG 调度器和查询优化器构建了一个高效的计算图,通常可以分解为在集群上的工作节点上并行执行的任务。第三,其物理执行引擎 Tungsten 使用整体阶段代码生成来生成紧凑的执行代码(我们将在第三章中讨论 SQL 优化和整体阶段代码生成)。

所有中间结果保留在内存中,并且其有限的磁盘 I/O 使其性能大幅提升。

使用简便性

Spark 通过提供一个名为 Resilient Distributed Dataset(RDD)的简单逻辑数据结构的基本抽象来实现简化。所有其他高级结构化数据抽象(如 DataFrames 和 Datasets)都是在此基础上构建的。通过提供一组转换操作作为操作,Spark 提供了一个简单的编程模型,您可以在熟悉的语言中使用它来构建大数据应用程序。

模块化

Spark 操作可应用于多种工作负载类型,并在支持的编程语言(Scala、Java、Python、SQL 和 R)中表达。Spark 提供了统一的库,具有良好文档化的 API,包括以下核心组件模块:Spark SQL、Spark Structured Streaming、Spark MLlib 和 GraphX,将所有工作负载组合在一个引擎下运行。我们将在下一节详细介绍所有这些内容。

您可以编写一个单一的 Spark 应用程序,可以处理所有内容——无需为不同的工作负载使用不同的引擎,也无需学习单独的 API。使用 Spark,您可以获得一个统一的处理引擎来处理您的工作负载。

可扩展性

Spark 专注于其快速的并行计算引擎,而不是存储。与同时包括存储和计算的 Apache Hadoop 不同,Spark 将这两者解耦。这意味着您可以使用 Spark 来读取存储在多种来源中的数据——如 Apache Hadoop、Apache Cassandra、Apache HBase、MongoDB、Apache Hive、RDBMS 等,并在内存中处理。Spark 的DataFrameReaderDataFrameWriter还可以扩展到从其他来源(如 Apache Kafka、Kinesis、Azure Storage 和 Amazon S3)读取数据,将其读入其逻辑数据抽象中,并对其进行操作。

Spark 开发者社区维护着一个第三方 Spark 包列表,作为不断增长的生态系统的一部分(见图 1-2)。这个丰富的软件包生态系统包括用于各种外部数据源的 Spark 连接器、性能监视器等。

Apache Spark 生态系统的连接器

图 1-2. Apache Spark 生态系统的连接器

统一分析

尽管统一化的概念并不是 Spark 独有的,但它是其设计哲学和演变的核心组成部分。2016 年 11 月,计算机协会(ACM)认可了 Apache Spark,并授予其原始创作者的杰出 ACM 奖,以表彰他们关于 Apache Spark 作为“大数据处理统一引擎”的论文。获奖论文指出,Spark 取代了所有独立的批处理、图形、流和查询引擎,如 Storm、Impala、Dremel、Pregel 等,使用一个统一的组件堆栈来处理各种工作负载。

Apache Spark 组件作为统一堆栈

如图 1-3 所示,Spark 提供了四个不同的组件作为用于各种工作负载的库:Spark SQL,Spark MLlib,Spark Structured Streaming 和 GraphX。每个组件都与 Spark 核心的容错引擎分开,您可以使用 API 编写您的 Spark 应用程序,Spark 将其转换为由核心引擎执行的 DAG。因此,无论您是使用提供的结构化 API(我们将在第三章中介绍)还是在 Java、R、Scala、SQL 或 Python 中编写 Spark 代码,底层代码都会被分解为高度紧凑的字节码,在集群中的工作节点的 JVM 中执行。

Apache Spark 组件和 API 堆栈

图 1-3. Apache Spark 组件和 API 堆栈

让我们更详细地看一下这些组件。

Spark SQL

该模块适用于结构化数据。您可以读取存储在关系数据库表中的数据或从带有结构化数据的文件格式(CSV、文本、JSON、Avro、ORC、Parquet 等)中读取数据,然后在 Spark 中构建永久或临时表。此外,当使用 Java、Python、Scala 或 R 中的 Spark 结构化 API 时,您可以组合类似 SQL 的查询来查询刚刚读入 Spark DataFrame 中的数据。迄今为止,Spark SQL 符合ANSI SQL:2003 标准,它也可以作为纯 SQL 引擎运行。

例如,在这个 Scala 代码片段中,您可以从存储在 Amazon S3 上的 JSON 文件中读取数据,创建一个临时表,并对读入内存的 Spark DataFrame 执行类似 SQL 的查询:

// In Scala
// Read data off Amazon S3 bucket into a Spark DataFrame
spark.read.json("s3://apache_spark/data/committers.json")
  .createOrReplaceTempView("committers")
// Issue a SQL query and return the result as a Spark DataFrame
val results = spark.sql("""SELECT name, org, module, release, num_commits
 FROM committers WHERE module = 'mllib' AND num_commits > 10
 ORDER BY num_commits DESC""")

您可以在 Python、R 或 Java 中编写类似的代码片段,生成的字节码将是相同的,从而获得相同的性能。

Spark MLlib

Spark 附带一个包含常见机器学习(ML)算法的库,称为 MLlib。自 Spark 首次发布以来,由于 Spark 2.x 底层引擎的增强,此库组件的性能显著提高。MLlib 提供许多流行的机器学习算法,构建在基于高级 DataFrame 的 API 之上,用于构建模型。

注意

自 Apache Spark 1.6 起,MLlib 项目 分为两个包:spark.mllibspark.ml。基于 DataFrame 的 API 属于后者,而前者包含基于 RDD 的 API,现已处于维护模式。所有新功能都集中在 spark.ml 中。本书将“MLlib”称为 Apache Spark 中机器学习的总体库。

这些 API 允许您提取或转换特征、构建流水线(用于训练和评估),以及持久化模型(用于保存和重新加载)。附加工具包括使用常见线性代数运算和统计学。MLlib 还包括其他低级别的 ML 原语,包括通用梯度下降优化。以下 Python 代码片段封装了数据科学家在构建模型时可能进行的基本操作(更详细的示例将在第 10 和第十一章中讨论):

# In Python
from pyspark.ml.classification import LogisticRegression
...
training = spark.read.csv("s3://...")
test = spark.read.csv("s3://...")

# Load training data
lr = LogisticRegression(maxIter=10, regParam=0.3, elasticNetParam=0.8)

# Fit the model
lrModel = lr.fit(training)

# Predict
lrModel.transform(test)
...

Spark 结构化流处理

Apache Spark 2.0 引入了一个实验性的持续流处理模型和基于 DataFrame 的 结构化流处理 API,构建在 Spark SQL 引擎之上。到 Spark 2.2,结构化流处理已经普遍可用,意味着开发者可以在生产环境中使用它。

对于大数据开发者而言,需要实时结合和响应来自 Apache Kafka 等引擎及其他流数据源的静态数据和流数据。新模型将流视为持续增长的表,新的数据行追加到末尾。开发者可以将其简单地视为结构化表,并像对待静态表一样进行查询。

在结构化流处理模型下,Spark SQL 核心引擎处理所有容错和延迟数据语义的方面,使开发者能够相对轻松地编写流处理应用程序。这种新模型取代了 Spark 1.x 系列中的旧 DStreams 模型,我们将在第 Chapter 8 中详细讨论。此外,Spark 2.x 和 Spark 3.0 扩展了流数据源的范围,包括 Apache Kafka、Kinesis 和基于 HDFS 或云存储的数据源。

以下代码片段展示了结构化流处理应用程序的典型结构。它从本地主机的套接字读取,并将词频统计结果写入 Apache Kafka 主题:

# In Python
# Read a stream from a local host
from pyspark.sql.functions import explode, split
lines = (spark 
  .readStream
  .format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load())

# Perform transformation
# Split the lines into words
words = lines.select(explode(split(lines.value, " ")).alias("word"))

# Generate running word count
word_counts = words.groupBy("word").count()

# Write out to the stream to Kafka
query = (word_counts
  .writeStream 
  .format("kafka") 
  .option("topic", "output"))

GraphX

如其名,GraphX 是一个用于操作图形的库(例如社交网络图、路由和连接点或网络拓扑图),并执行图形并行计算。它提供了标准的图形分析、连接和遍历算法,社区用户贡献的算法包括 PageRank、连通组件和三角形计数。¹

这段代码片段展示了如何使用 GraphX API 简单地连接两个图形:

// In Scala
val graph = Graph(vertices, edges)
messages = spark.textFile("hdfs://...")
val graph2 = graph.joinVertices(messages) {
  (id, vertex, msg) => ...
}

Apache Spark 的分布式执行

如果你已经阅读到这里,你已经知道 Spark 是一个分布式数据处理引擎,其组件在机群上协作工作。在我们探索本书后续章节中关于 Spark 编程之前,你需要理解 Spark 分布式架构的所有组件如何协同工作和通信,以及可用的部署模式。

让我们从查看图 1-4 中显示的每个个体组件及其如何适应架构开始。在 Spark 架构的高层次上,一个 Spark 应用程序由负责在 Spark 集群上协调并行操作的驱动程序组成。驱动程序通过SparkSession访问集群中的分布式组件。

Apache Spark 的组件和架构

图 1-4. Apache Spark 的组件和架构

Spark 驱动程序

作为负责实例化SparkSession的 Spark 应用程序的一部分,Spark 驱动程序具有多重角色:它与集群管理器通信;它请求来自集群管理器的资源(CPU、内存等)给 Spark 的执行器(JVMs);它将所有 Spark 操作转换为 DAG 计算,安排它们,并将它们作为任务分发到 Spark 执行器中。一旦资源分配完成,它直接与执行器通信。

SparkSession

在 Spark 2.0 中,SparkSession成为了所有 Spark 操作和数据的统一通道。它不仅取代了以前的 Spark 入口点,如SparkContextSQLContextHiveContextSparkConfStreamingContext,而且使得使用 Spark 变得更简单更容易。

注意

尽管在 Spark 2.x 中SparkSession取代了所有其他上下文,你仍然可以访问各个上下文及其各自的方法。这样做是为了保持向后兼容性。也就是说,你的旧 1.x 版本的代码中使用SparkContextSQLContext仍然可以工作。

通过这个统一通道,你可以创建 JVM 运行时参数,定义 DataFrames 和 Datasets,从数据源读取数据,访问目录元数据,并执行 Spark SQL 查询。SparkSession为所有 Spark 功能提供了单一统一的入口点。

在独立的 Spark 应用程序中,你可以使用所选编程语言中的一个高级 API 来创建SparkSession。在 Spark shell 中(更多内容请参阅下一章节),SparkSession会为你创建,并且可以通过全局变量sparksc访问它。

在 Spark 1.x 中,您必须创建各种上下文(用于流处理、SQL 等),引入额外的样板代码。而在 Spark 2.x 应用程序中,您可以为每个 JVM 创建一个 SparkSession,并使用它执行多个 Spark 操作。

我们来看一个例子:

// In Scala
import org.apache.spark.sql.SparkSession

// Build SparkSession
val spark = SparkSession
  .builder
  .appName("LearnSpark")
  .config("spark.sql.shuffle.partitions", 6)
  .getOrCreate()
...
// Use the session to read JSON 
val people = spark.read.json("...")
...
// Use the session to issue a SQL query
val resultsDF = spark.sql("SELECT city, pop, state, zip FROM table_name")

集群管理器

集群管理器负责管理和分配运行 Spark 应用程序的节点集群的资源。目前,Spark 支持四种集群管理器:内置独立集群管理器、Apache Hadoop YARN、Apache Mesos 和 Kubernetes。

Spark 执行器

Spark 执行器在集群中的每个工作节点上运行。执行器与驱动程序通信,并负责在工作节点上执行任务。在大多数部署模式中,每个节点上只运行一个执行器。

部署模式

Spark 的一个吸引人的特点是其支持多种部署模式,使得 Spark 能够在不同的配置和环境中运行。由于集群管理器对其运行位置不加区分(只要能管理 Spark 的执行器并满足资源请求),Spark 可以部署在一些最流行的环境中,比如 Apache Hadoop YARN 和 Kubernetes,并且可以以不同的模式运行。表 1-1 总结了可用的部署模式。

表 1-1. Spark 部署模式速查表

Mode Spark 驱动程序 Spark 执行器 集群管理器
Local 在单个 JVM 上运行,比如笔记本电脑或单节点 在与驱动程序相同的 JVM 上运行 在相同的主机上运行
Standalone 可以在集群中的任何节点上运行 集群中的每个节点将启动自己的执行器 JVM 可以随意分配到集群中的任何主机上
YARN (client) 运行在客户端,不是集群的一部分 YARN 的 NodeManager 容器 YARN 的资源管理器与 YARN 的应用程序主管一起为执行器在 NodeManagers 上分配容器
YARN (cluster) 与 YARN Application Master 一起运行 与 YARN 客户端模式相同 与 YARN 客户端模式相同
Kubernetes 运行在 Kubernetes 的 pod 中 每个 worker 在自己的 pod 中运行 Kubernetes Master

分布式数据和分区

实际的物理数据分布在存储中,作为驻留在 HDFS 或云存储中的分区(见图 1-5)。虽然数据作为分区分布在物理集群中,但 Spark 将每个分区视为高级逻辑数据抽象——即内存中的 DataFrame。尽管这并非总是可能,但每个 Spark 执行器最好被分配一个需要它读取最接近它的网络中分区的任务,以保证数据本地性。

数据分布在物理机器上

图 1-5. 数据分布在物理机器上

分区允许有效的并行处理。将数据分解成块或分区的分布方案允许 Spark 执行器仅处理靠近它们的数据,从而最小化网络带宽。也就是说,每个执行器的核心被分配了自己的数据分区来处理(见图 1-6)。

每个执行器的核心获得一个数据分区来处理

图 1-6. 每个执行器的核心获得一个数据分区来处理

例如,这段代码片段将把存储在集群中的物理数据分割成八个分区,每个执行器将获取一个或多个分区读入其内存:

# In Python
log_df = spark.read.text("path_to_large_text_file").repartition(8)
print(log_df.rdd.getNumPartitions())

这段代码将在内存中创建一个包含 10,000 个整数的 DataFrame,分布在八个分区中:

# In Python
df = spark.range(0, 10000, 1, 8)
print(df.rdd.getNumPartitions())

这两个代码片段都将打印出8

在第三章和第七章中,我们将讨论如何调整和更改分区配置,以实现基于执行器核心数量的最大并行性。

开发者的体验

在所有开发者的喜悦中,没有比一组可组合的 API 更吸引人的了,它们可以提高生产力,易于使用,直观且表达力强。Apache Spark 最主要吸引开发者的一个特点是其易于使用的 API,可用于操作小到大的数据集,跨语言:Scala、Java、Python、SQL 和 R。

Spark 2.x 背后的一个主要动机是通过限制开发者需要处理的概念数量来统一和简化框架。Spark 2.x 引入了更高级别的抽象 API,作为特定领域语言构造,这使得编写 Spark 程序变得高度表达性和开发体验愉快。您只需表达您希望任务或操作计算什么,而不是如何计算它,并让 Spark 来确定如何为您最好地执行。我们将在第三章介绍这些结构化 API,但首先让我们看看 Spark 开发者是谁。

谁在使用 Spark,以及用途是什么?

毫不奇怪,大多数处理大数据的开发者是数据工程师、数据科学家或机器学习工程师。他们被吸引到 Spark,因为它允许他们使用单一引擎构建各种应用程序,并使用熟悉的编程语言。

当然,开发者可能会穿很多帽子,有时会同时执行数据科学和数据工程任务,特别是在初创公司或较小的工程团队中。然而,在所有这些任务中,数据——大量的数据——是基础。

数据科学任务

作为在大数据时代崭露头角的学科,数据科学是利用数据讲述故事的过程。但在他们能够叙述故事之前,数据科学家必须清洗数据,探索数据以发现模式,并建立模型以预测或建议结果。其中一些任务需要统计学、数学、计算机科学和编程的知识。

大多数数据科学家擅长使用 SQL 等分析工具,熟悉 NumPy 和 pandas 等库,能够使用 R 和 Python 等编程语言。但他们还必须知道如何处理转换数据,以及如何使用成熟的分类、回归或聚类算法来构建模型。他们的任务通常是迭代的、交互式的或临时的,或者是为了验证他们的假设进行实验性的。

幸运的是,Spark 支持这些不同的工具。Spark 的 MLlib 提供了一套通用的机器学习算法来构建模型管道,使用高级估计器、转换器和数据特征化工具。Spark SQL 和 Spark Shell 支持对数据的交互式和临时探索。

此外,Spark 使数据科学家能够处理大数据集并扩展其模型训练和评估。Apache Spark 2.4 作为Project Hydrogen的一部分引入了新的集群调度程序,以适应分布式训练和调度深度学习模型的容错需求,而 Spark 3.0 引入了在独立、YARN 和 Kubernetes 部署模式下支持 GPU 资源收集的能力。这意味着那些需要深度学习技术的开发人员可以使用 Spark。

数据工程任务

在建立模型之后,数据科学家经常需要与其他团队成员合作,这些成员可能负责部署模型。或者他们可能需要与其他人密切合作,将原始的肮脏数据转换为干净的数据,以便其他数据科学家轻松消费或使用。例如,分类或聚类模型并不孤立存在;它们与诸如 Web 应用程序或 Apache Kafka 等流引擎之类的其他组件一起工作,或作为更大数据管道的一部分。这种管道通常由数据工程师构建。

数据工程师对软件工程原则和方法论有很强的理解,并具备构建可扩展数据管道以支持业务用例的技能。数据管道使得从多种来源获取的原始数据能够进行端到端的转换——数据被清洗,以便下游开发者使用,存储在云中或 NoSQL 或 RDBMS 中进行报告生成,或通过商业智能工具对数据分析师可访问。

Spark 2.x 引入了一种称为continuous applications的进化流处理模型,结构化流处理(在第八章中详细讨论)。通过结构化流处理 API,数据工程师可以构建复杂的数据管道,从实时和静态数据源进行 ETL 数据处理。

数据工程师使用 Spark,因为它提供了并行化计算的简便方式,并隐藏了分布和容错的所有复杂性。这使他们能够专注于使用基于高级 DataFrame 的 API 和领域特定语言(DSL)查询来进行 ETL,读取和合并来自多个源的数据。

由于Catalyst 优化器用于 SQL 和Tungsten用于紧凑代码生成,Spark 2.x 和 Spark 3.0 中的性能提升大大简化了数据工程师的生活。他们可以选择使用适合手头任务的三种Spark API之一——RDDs、DataFrames 或 Datasets,并享受 Spark 带来的益处。

热门的 Spark 使用案例

无论您是数据工程师、数据科学家还是机器学习工程师,您都会发现 Spark 在以下用例中非常有用:

  • 并行处理分布在集群中的大数据集

  • 执行自由查询或交互式查询以探索和可视化数据集

  • 使用 MLlib 构建、训练和评估机器学习模型

  • 从多种数据流实现端到端数据管道

  • 分析图数据集和社交网络

社区采纳与扩展

毫不奇怪,Apache Spark 在开源社区中引起了共鸣,尤其是在数据工程师和数据科学家中间。其设计理念及其作为 Apache 软件基金会项目的纳入,引发了开发者社区的极大兴趣。

全球有超过 600 个Apache Spark Meetup 小组,近 50 万名成员。每周,世界各地都有人在 Meetup 或会议上发表演讲或分享博客文章,介绍如何使用 Spark 构建数据流水线。Spark + AI Summit是专注于 Spark 在机器学习、数据工程和数据科学等多个垂直领域应用的最大会议。

自 2014 年 Spark 首次发布 1.0 版本以来,已发布了许多次次要和主要版本,其中最近的主要版本是 2020 年的 Spark 3.0。本书将涵盖 Spark 2.x 和 Spark 3.0 的各个方面。到出版时,社区将已发布 Spark 3.0,并且本书中的大部分代码已经在 Spark 3.0-preview2 上进行了测试。

在其发布过程中,Spark 一直吸引着来自全球和多个组织的贡献者。目前,Spark 拥有近 1,500 名贡献者,超过 100 次发布,21,000 个分支,以及约 27,000 次提交,正如图 1-7 所示。我们希望当你阅读完这本书后,也会产生贡献的冲动。

Apache Spark 在 GitHub 上的状态(来源:https://github.com/apache/spark)

图 1-7. Apache Spark 在 GitHub 上的状态(来源:github.com/apache/spark

现在我们可以开始关注学习的乐趣——在哪里以及如何开始使用 Spark。下一章,我们将向你展示如何通过三个简单的步骤快速启动 Spark。

¹ 作为开源项目由 Databricks 贡献给社区,GraphFrames 是一个通用的图处理库,类似于 Apache Spark 的 GraphX,但使用基于 DataFrame 的 API。

第二章:下载 Apache Spark 并开始

在本章中,我们将帮助你设置 Spark,并通过三个简单的步骤来开始编写你的第一个独立应用程序。

我们将使用本地模式,在 Spark shell 中所有处理都在单台机器上完成——这是学习框架的简单方法,提供了迭代执行 Spark 操作的快速反馈循环。使用 Spark shell,你可以在小数据集上原型化 Spark 操作,然后再编写复杂的 Spark 应用程序,但对于大数据集或实际工作中希望利用分布式执行优势的场景,本地模式不适用——你应该使用 YARN 或 Kubernetes 部署模式。

虽然 Spark shell 只支持 Scala、Python 和 R,但你可以使用任何支持的语言(包括 Java)编写 Spark 应用程序,并在 Spark SQL 中发出查询。我们期望你对所选择的语言有一定的熟悉度。

第 1 步:下载 Apache Spark

要开始,请访问 Spark 下载页面,在第 2 步的下拉菜单中选择 “Pre-built for Apache Hadoop 2.7”,然后在第 3 步点击 “Download Spark” 链接(见 图 2-1)。

Apache Spark 下载页面

图 2-1。Apache Spark 下载页面

这将下载 spark-3.0.0-preview2-bin-hadoop2.7.tgz 压缩包,其中包含在笔记本电脑上以本地模式运行 Spark 所需的所有与 Hadoop 相关的二进制文件。或者,如果你要安装到现有的 HDFS 或 Hadoop 安装中,可以从下拉菜单中选择匹配的 Hadoop 版本。如何从源代码构建超出了本书的范围,但你可以在 文档 中了解更多。

注意

在本书印刷时,Apache Spark 3.0 仍处于预览模式,但你可以使用相同的下载方法和说明下载最新的 Spark 3.0 版本。

自 Apache Spark 2.2 发布以来,仅关心在 Python 中学习 Spark 的开发人员可以选择从 PyPI 仓库 安装 PySpark。如果你只使用 Python 编程,就无需安装运行 Scala、Java 或 R 所需的所有其他库;这使得二进制文件更小。要从 PyPI 安装 PySpark,只需运行 pip install pyspark

对于 SQL、ML 和 MLlib,可以通过 pip install pyspark[sql,ml,mllib] 安装一些额外的依赖项(或者如果只需要 SQL 依赖项,可以使用 pip install pyspark[sql])。

注意

你需要在计算机上安装 Java 8 或更高版本,并设置 JAVA_HOME 环境变量。请参阅 文档 以获取有关如何下载和安装 Java 的说明。

如果您想在交互式 shell 模式下运行 R,您必须先安装 R,然后运行 sparkR。要使用 R 进行分布式计算,还可以使用由 R 社区创建的开源项目sparklyr

Spark 的目录和文件

我们假设您在您的笔记本电脑或集群上运行 Linux 或 macOS 操作系统的某个版本,并且本书中的所有命令和说明都将使用这种风格。一旦您完成下载 tarball,cd到下载的目录,使用 tar -xf spark-3.0.0-preview2-bin-hadoop2.7.tgz 提取 tarball 内容,然后cd到该目录并查看内容:

$ cd spark-3.0.0-preview2-bin-hadoop2.7
$ **ls**
LICENSE   R          RELEASE   conf    examples   kubernetes  python   yarn
NOTICE    README.md  bin       data    jars       licenses    sbin

让我们简要总结一下这些文件和目录的意图和目的。在 Spark 2.x 和 3.0 中添加了新项,并且某些现有文件和目录的内容也发生了变化:

README.md

这个文件包含了如何使用 Spark shell、从源代码构建 Spark、运行独立 Spark 示例、查阅 Spark 文档和配置指南以及为 Spark 做贡献的详细说明。

bin

正如其名称所示,这个目录包含大多数用于与 Spark 交互的脚本,包括Spark shellsspark-sqlpysparkspark-shellsparkR)。我们将在本章后面使用这些 shell 和可执行文件提交一个独立的 Spark 应用程序,使用spark-submit,并编写一个脚本,在支持 Kubernetes 的 Spark 上运行时构建和推送 Docker 镜像。

sbin

这个目录中的大多数脚本都是用于在集群中以各种部署模式启动和停止 Spark 组件的管理目的。有关部署模式的详细信息,请参阅表 1-1 中的速查表,位于第 1 章中。

kubernetes

自 Spark 2.4 发布以来,这个目录包含用于在 Kubernetes 集群上为您的 Spark 分发创建 Docker 镜像的 Dockerfile。它还包含一个文件,提供了在构建您的 Docker 镜像之前如何构建 Spark 分发的说明。

data

这个目录中填充了作为 Spark 组件输入的**.txt*文件:MLlib、Structured Streaming 和 GraphX。

examples

对于任何开发者,简化学习任何新平台之旅的两个关键点是大量的“如何”代码示例和全面的文档。Spark 提供了 Java、Python、R 和 Scala 的示例,当学习框架时,您会想要使用它们。我们将在本章和后续章节中提到一些这些示例。

第 2 步:使用 Scala 或 PySpark Shell

如前所述,Spark 带有四种广泛使用的解释器,它们像交互式的“shell”一样,支持临时数据分析:pysparkspark-shellspark-sqlsparkR。在很多方面,它们的交互性模拟了您可能已经熟悉的 Python、Scala、R、SQL 或 Unix 操作系统的 shell,如 bash 或 Bourne shell。

这些 shell 已经增强,支持连接到集群并允许您将分布式数据加载到 Spark 工作节点的内存中。无论您处理的是几千兆字节的数据还是小数据集,Spark shell 都有助于快速学习 Spark。

要启动 PySpark,请cdbin目录,并键入**pyspark**以启动 shell。如果您已从 PyPI 安装了 PySpark,则仅需键入**pyspark**即可:

$ **pyspark**
Python 3.7.3 (default, Mar 27 2019, 09:23:15)
[Clang 10.0.1 (clang-1001.0.46.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
20/02/16 19:28:48 WARN NativeCodeLoader: Unable to load native-hadoop library 
for your platform... using builtin-java classes where applicable
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /__ / .__/\_,_/_/ /_/\_\   version 3.0.0-preview2
      /_/

Using Python version 3.7.3 (default, Mar 27 2019 09:23:15)
SparkSession available as 'spark'.
>>> **spark.version**
'3.0.0-preview2'
>>>

要启动一个类似的 Scala Spark shell,cdbin目录,然后键入**spark-shell**

$ spark-shell
20/05/07 19:30:26 WARN NativeCodeLoader: Unable to load native-hadoop library 
for your platform... using builtin-java classes where applicable
Spark context Web UI available at http://10.0.1.7:4040
Spark context available as 'sc' (master = local[*], app id = local-1581910231902)
Spark session available as 'spark'.
Welcome to

      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 3.0.0-preview2
      /_/

Using Scala version 2.12.10 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_241)
Type in expressions to have them evaluated.
Type :help for more information.
scala> **spark.version**
res0: String = 3.0.0-preview2
scala>

使用本地机器

现在您已经在本地机器上下载并安装了 Spark,在本章的其余部分中,您将使用本地的 Spark 解释器 shell。也就是说,Spark 将在本地模式下运行。

注意

请参考 Table 1-1 在 Chapter 1 中,以提醒您本地模式下哪些组件运行在哪里。

如前一章所述,Spark 计算被表达为操作。这些操作然后被转换为基于低级 RDD 的字节码作为任务,并分布到 Spark 的执行器进行执行。

让我们看一个简短的示例,在此示例中,我们将文本文件读取为 DataFrame,展示读取的字符串样本,并计算文件中的总行数。这个简单的示例演示了高级结构化 API 的使用,我们将在下一章中介绍。在 Scala shell 中,show(10, false)操作在 DataFrame 上只显示前 10 行而不截断;默认情况下,truncate布尔标志为true。下面是在 Scala shell 中的示例:

scala> val strings = spark.read.text("../README.md")
strings: org.apache.spark.sql.DataFrame = [value: string]

scala> strings.show(10, false)
+------------------------------------------------------------------------------+
|value                                                                         |
+------------------------------------------------------------------------------+
|# Apache Spark                                                                |
|                                                                              |
|Spark is a unified analytics engine for large-scale data processing. It       |
|provides high-level APIs in Scala, Java, Python, and R, and an optimized      |
|engine that supports general computation graphs for data analysis. It also    |
|supports a rich set of higher-level tools including Spark SQL for SQL and     |
|DataFrames, MLlib for machine learning, GraphX for graph processing,          |
| and Structured Streaming for stream processing.                              |
|                                                                              |
|<https://spark.apache.org/>                                                   |
+------------------------------------------------------------------------------+
only showing top 10 rows

scala> strings.count()
res2: Long = 109
scala>

相当简单。让我们看一个使用 Python 解释器pyspark的类似示例:

$ pyspark
Python 3.7.3 (default, Mar 27 2019, 09:23:15)
[Clang 10.0.1 (clang-1001.0.46.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by org.apache.spark.unsafe.Platform 
WARNING: Use --illegal-access=warn to enable warnings of further illegal 
reflective access operations
WARNING: All illegal access operations will be denied in a future release
20/01/10 11:28:29 WARN NativeCodeLoader: Unable to load native-hadoop library 
for your platform... using builtin-java classes where applicable
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use 
setLogLevel(newLevel).
Welcome to

      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /__ / .__/\_,_/_/ /_/\_\   version 3.0.0-preview2
      /_/

Using Python version 3.7.3 (default, Mar 27 2019 09:23:15)
SparkSession available as 'spark'.
>>> **strings = spark.read.text("../README.md")**
>>> **strings.show(10, truncate=False)**
+------------------------------------------------------------------------------+
|value                                                                         |
+------------------------------------------------------------------------------+
|# Apache Spark                                                                |
|                                                                              |
|Spark is a unified analytics engine for large-scale data processing. It       |
|provides high-level APIs in Scala, Java, Python, and R, and an optimized      |
|engine that supports general computation graphs for data analysis. It also    |
|supports a rich set of higher-level tools including Spark SQL for SQL and     |
|DataFrames, MLlib for machine learning, GraphX for graph processing,          |
|and Structured Streaming for stream processing.                               |
|                                                                              |
|<https://spark.apache.org/>                                                   |
+------------------------------------------------------------------------------+
only showing top 10 rows

>>> **strings.count()**
109
>>>

要退出任何 Spark shell,请按 Ctrl-D。正如您所看到的,与 Spark shell 的快速交互不仅有助于快速学习,也有助于快速原型开发。

在上述示例中,请注意 Scala 和 Python 之间的 API 语法和签名的一致性。在 Spark 从 1.x 版本到现在的演变过程中,这是持久改进之一。

还请注意,我们使用了高级结构化 API 来将文本文件读取为 Spark DataFrame,而不是 RDD。在本书中,我们将更多地关注这些结构化 API;自 Spark 2.x 以来,RDD 已被归类为低级 API。

注意

在高级结构化 API 中表达的每个计算都被分解为低级优化和生成的 RDD 操作,然后转换为执行器的 JVM 的 Scala 字节码。生成的 RDD 操作代码对用户不可访问,也与用户面向的 RDD API 不同。

步骤 3: 理解 Spark 应用概念

现在您已经下载了 Spark,在您的笔记本电脑上以独立模式安装了它,在 Spark shell 中启动了它,并且交互地执行了一些简短的代码示例,您已经准备好迈出最后一步。

要了解我们示例代码底层发生的情况,您需要熟悉一些关键的 Spark 应用概念,以及代码如何作为跨 Spark 执行器的任务转换和执行。我们将从定义一些重要术语开始:

应用

使用其 API 构建的用户程序的一部分。它包括群集上的驱动程序和执行器。

SparkSession

一个提供与底层 Spark 功能交互的入口点,并允许使用其 API 编程 Spark 的对象。在交互式 Spark shell 中,Spark 驱动程序为您实例化一个SparkSession,而在 Spark 应用程序中,您自己创建一个SparkSession对象。

作业

作为响应于 Spark 动作(如save()collect())而生成的多个任务的并行计算。

阶段

每个作业被划分为称为阶段的较小任务集,这些阶段彼此依赖。

任务

一个将被发送到 Spark 执行器的工作或执行的单元。

让我们更详细地探讨这些概念。

Spark 应用程序和 SparkSession

每个 Spark 应用程序的核心是 Spark 驱动程序程序,它创建一个SparkSession对象。当您使用 Spark shell 时,驱动程序是 shell 的一部分,并且SparkSession对象(通过变量spark访问)在您启动 shell 时已经为您创建,就像在之前的示例中看到的那样。

在这些示例中,因为您在本地笔记本电脑上启动了 Spark shell,所有操作都在单个 JVM 中本地运行。但是,您可以轻松地启动一个 Spark shell,在集群上并行分析数据,就像在本地模式下一样。命令spark-shell --helppyspark --help将向您展示如何连接到 Spark 群集管理器。图 2-2 展示了在此操作后 Spark 在集群上执行的方式。

Spark 组件通过 Spark 驱动程序在 Spark 的分布式架构中进行通信

图 2-2. Spark 组件通过 Spark 驱动程序在 Spark 的分布式架构中进行通信。

一旦您拥有了SparkSession,您就可以使用API 编程 Spark来执行 Spark 操作。

Spark 作业

在与 Spark shell 的交互会话中,驱动程序将您的 Spark 应用程序转换为一个或多个 Spark 作业(图 2-3)。然后将每个作业转换为 DAG。这本质上就是 Spark 的执行计划,其中 DAG 中的每个节点可以是单个或多个 Spark 阶段。

Spark 驱动程序创建一个或多个 Spark 作业

图 2-3. Spark 驱动程序创建一个或多个 Spark 作业

Spark 阶段

作为 DAG 节点的一部分,阶段基于可以串行或并行执行的操作创建(图 2-4)。并非所有 Spark 操作都可以在单个阶段中进行,因此它们可能会分成多个阶段。通常,阶段在运算符的计算边界上界定,它们指示 Spark 执行器之间的数据传输。

Spark 作业创建一个或多个阶段

图 2-4. Spark 作业创建一个或多个阶段

Spark 任务

每个阶段由 Spark 任务(执行的单位)组成,然后在每个 Spark 执行器上进行联合;每个任务映射到单个核心并在单个数据分区上工作(图 2-5)。因此,具有 16 个核心的执行器可以并行处理 16 个或更多任务,从而使 Spark 任务的执行极为并行化!

Spark 阶段创建一个或多个任务以分配给执行器

图 2-5. Spark 阶段创建一个或多个任务以分配给执行器

转换、动作和惰性评估

Spark 对分布式数据的操作可以分为两种类型:转换动作。转换顾名思义,将 Spark DataFrame 转换为新的 DataFrame 而不改变原始数据,具有不可变性的属性。换句话说,诸如 select()filter() 的操作不会改变原始 DataFrame;相反,它将返回操作的转换结果作为新的 DataFrame。

所有转换都是惰性评估的。也就是说,它们的结果不会立即计算,而是被记录或记忆为 血统。记录的血统允许 Spark 在执行计划的稍后时间重新排列某些转换,将其合并,或者优化转换为更有效的阶段。惰性评估是 Spark 延迟执行的策略,直到调用动作或数据被“触及”(从磁盘读取或写入)。

动作触发了所有记录转换的惰性评估。在 图 2-6 中,所有转换 T 被记录,直到调用动作 A。每个转换 T 产生一个新的 DataFrame。

懒惰的转换和渴望的动作

图 2-6. 懒惰的转换和渴望的动作

惰性评估使得 Spark 能够通过查看链式转换来优化查询,而血统和数据不可变性则提供了容错能力。由于 Spark 记录了每个转换的血统,并且在转换之间的 DataFrame 是不可变的,它可以通过简单地重放记录的血统来恢复其原始状态,从而在发生故障时具有容错能力。

表 2-1 列出了一些转换和操作的示例。

表 2-1. Spark 操作的转换和操作

转换 操作
orderBy() show()
groupBy() take()
filter() count()
select() collect()
join() save()

操作和转换贡献到了一个 Spark 查询计划中,我们将在下一章中讨论它。在执行查询计划之前,不会执行任何操作。下面的示例,以 Python 和 Scala 两种语言显示,有两个转换——read()filter()——以及一个操作——count()。操作触发了作为查询执行计划的一部分记录的所有转换的执行。在这个示例中,直到在 shell 中执行 filtered.count() 之前,什么都不会发生:

# In Python 
>>> `strings` `=` `spark``.``read``.``text``(``"``../README.md``"``)`
>>> `filtered` `=` `strings``.``filter``(``strings``.``value``.``contains``(``"``Spark``"``)``)`
>>> `filtered``.``count``(``)`
20
// In Scala scala> `import` `org.apache.spark.sql.functions._`
scala> `val` `strings` `=` `spark``.``read``.``text``(``"../README.md"``)`
scala> `val` `filtered` `=` `strings``.``filter``(``col``(``"value"``)``.``contains``(``"Spark"``)``)`
scala> `filtered``.``count``(``)`
res5: Long = 20

窄转换和宽转换

如前所述,转换是 Spark 惰性评估的操作。惰性评估方案的一个巨大优势在于,Spark 能够检查您的计算查询并确定如何优化它。此优化可以通过连接或流水线一些操作并将它们分配到一个阶段,或者通过确定哪些操作需要在集群之间进行数据洗牌或交换来进行。这将这些操作分解成阶段。

转换可以被分类为具有 窄依赖宽依赖。任何一个可以从单个输入分区计算单个输出分区的转换都是 转换。例如,在前面的代码片段中,filter()contains() 表示窄转换,因为它们可以在单个分区上操作并产生结果输出分区而无需交换任何数据。

然而,groupBy()orderBy() 指示 Spark 执行 转换,从而读取来自其他分区的数据,将其组合并写入磁盘。由于每个分区将有其自己的包含 “Spark” 词的单词计数的数据行,一个计数 (groupBy()) 将会强制进行来自执行器分区的数据在集群中的洗牌。在这个转换中,orderBy() 需要来自其他分区的输出来计算最终的聚合。

图 2-7 描述了两种依赖关系的类型。

窄转换与宽转换

图 2-7. 窄转换与宽转换

Spark UI

Spark 包含一个图形用户界面,您可以使用它来检查或监视 Spark 应用程序在其不同分解阶段(即作业、阶段和任务)的情况。根据 Spark 的部署方式,驱动程序会启动一个 Web UI,默认运行在 4040 端口,您可以查看诸如以下详细信息和指标:

  • 调度器阶段和任务的列表

  • RDD 大小和内存使用情况的摘要

  • 关于环境的信息

  • 运行执行器的信息

  • 所有的 Spark SQL 查询

在本地模式下,您可以在 Web 浏览器中访问 http://:4040 来访问此界面。

注意

当您启动 spark-shell 时,输出的一部分显示了本地主机的 URL,以访问 4040 端口。

让我们检查前一节中 Python 示例如何转换为作业、阶段和任务。要查看 DAG 的外观,请在 Web UI 中点击“DAG 可视化”。正如图 2-8 所示,驱动程序创建了一个作业和一个阶段。

我们简单 Python 示例的 DAG

图 2-8. 我们简单 Python 示例的 DAG

请注意,由于只有一个阶段,不存在需要在执行器之间交换数据的“交换”操作。阶段的各个操作显示为蓝色框。

阶段 0 由一个任务组成。如果有多个任务,它们将并行执行。您可以在“阶段”选项卡中查看每个阶段的详细信息,如图 2-9 所示。

阶段 0 的详细信息

图 2-9. 阶段 0 的详细信息

我们将在第七章中更详细地介绍 Spark UI。现在,只需注意 UI 提供了一种深入了解 Spark 内部工作的微观镜头,作为调试和检查工具。

您的第一个独立应用程序

为了便于学习和探索,Spark 发行版附带了每个 Spark 组件的一组示例应用程序。您可以浏览安装位置中的 examples 目录,以了解可用内容。

从本地机器上的安装目录,您可以使用命令 bin/run-example *<class> [params]* 运行提供的几个 Java 或 Scala 示例程序之一。例如:

$ **./bin/run-example JavaWordCount README.md**

这将在控制台上输出 INFO 消息,以及 README.md 文件中每个单词及其计数的列表(计数单词是分布式计算的“Hello, World”)。

在前面的示例中,我们统计了文件中的单词。如果文件很大,它将分布在由数据的小块组成的集群中,我们的 Spark 程序将分发任务,计算每个分区中每个单词的计数,并返回最终聚合的计数。但是,这个示例已经变成了一个陈词滥调。

让我们解决一个类似的问题,但使用更大的数据集和更多 Spark 分布功能和 DataFrame APIs。我们将在后面的章节中介绍本程序中使用的 APIs,但现在请稍等片刻。

这本书的作者中有一位数据科学家,她喜欢在烤饼干时加入 M&M,并且在她经常教授机器学习和数据科学课程的美国州份给她的学生批量提供这些饼干作为奖励。但显然她是数据驱动的,她希望确保在不同州份的学生得到正确颜色的 M&M 饼干(见 Figure 2-11)。

M&M 按颜色分布(来源:https://oreil.ly/mhWIT)

图 2-11. M&M 按颜色分布(来源:https://oreil.ly/mhWIT

让我们编写一个 Spark 程序,读取一个包含超过 100,000 条记录的文件(每行或行有一个 <*state*, *mnm_color*, *count*>),并计算并聚合每种颜色和州的计数。这些聚合计数告诉我们每个州学生喜欢的 M&M 颜色。详细的 Python 代码清单见 Example 2-1。

Example 2-1. 计数和聚合 M&M(Python 版本)
# Import the necessary libraries.
# Since we are using Python, import the SparkSession and related functions
# from the PySpark module.
import sys

from pyspark.sql import SparkSession
from pyspark.sql.functions import count

if __name__ == "__main__":
   if len(sys.argv) != 2:
       print("Usage: mnmcount <file>", file=sys.stderr)
       sys.exit(-1)

   # Build a SparkSession using the SparkSession APIs.
   # If one does not exist, then create an instance. There
   # can only be one SparkSession per JVM.
   spark = (SparkSession
     .builder
     .appName("PythonMnMCount")
     .getOrCreate())
   # Get the M&M data set filename from the command-line arguments
   mnm_file = sys.argv[1]
   # Read the file into a Spark DataFrame using the CSV
   # format by inferring the schema and specifying that the
   # file contains a header, which provides column names for comma-
   # separated fields.
   mnm_df = (spark.read.format("csv") 
     .option("header", "true") 
     .option("inferSchema", "true") 
     .load(mnm_file))

   # We use the DataFrame high-level APIs. Note
   # that we don't use RDDs at all. Because some of Spark's 
   # functions return the same object, we can chain function calls.
   # 1\. Select from the DataFrame the fields "State", "Color", and "Count"
   # 2\. Since we want to group each state and its M&M color count,
   #    we use groupBy()
   # 3\. Aggregate counts of all colors and groupBy() State and Color
   # 4  orderBy() in descending order
   count_mnm_df = (mnm_df
     .select("State", "Color", "Count") 
     .groupBy("State", "Color") 
     .agg(count("Count").alias("Total")) 
     .orderBy("Total", ascending=False))
   # Show the resulting aggregations for all the states and colors;
   # a total count of each color per state.
   # Note show() is an action, which will trigger the above
   # query to be executed.
   count_mnm_df.show(n=60, truncate=False)
   print("Total Rows = %d" % (count_mnm_df.count()))
   # While the above code aggregated and counted for all 
   # the states, what if we just want to see the data for 
   # a single state, e.g., CA? 
   # 1\. Select from all rows in the DataFrame
   # 2\. Filter only CA state
   # 3\. groupBy() State and Color as we did above
   # 4\. Aggregate the counts for each color
   # 5\. orderBy() in descending order 
   # Find the aggregate count for California by filtering
   ca_count_mnm_df = (mnm_df
     .select("State", "Color", "Count") 
     .where(mnm_df.State == "CA") 
     .groupBy("State", "Color") 
     .agg(count("Count").alias("Total")) 
     .orderBy("Total", ascending=False))
   # Show the resulting aggregation for California.
   # As above, show() is an action that will trigger the execution of the
   # entire computation. 
   ca_count_mnm_df.show(n=10, truncate=False)
   # Stop the SparkSession
   spark.stop()

你可以使用你喜欢的编辑器将此代码输入到名为 mnmcount.py 的 Python 文件中,从本书的 GitHub repo 下载 mnn_dataset.csv 文件,并使用安装目录中的 bin 目录中的 submit-spark 脚本将其作为 Spark 作业提交。将你的 SPARK_HOME 环境变量设置为你在本地机器上安装 Spark 的根级目录。

注意

上述代码使用了 DataFrame API,读起来像高级 DSL 查询。我们将在下一章中介绍这个和其他 API;现在请注意,你可以清晰简洁地告诉 Spark 要做什么,而不是如何做,与 RDD API 不同。非常酷的东西!

为了避免在控制台上打印冗长的INFO消息,请将 log4j.properties.template 文件复制到 log4j.properties,并在 conf/log4j.properties 文件中设置 log4j.rootCategory=WARN

让我们使用 Python APIs 提交我们的第一个 Spark 作业(关于代码功能的解释,请阅读 Example 2-1 中的内联注释):

$SPARK_HOME/bin/spark-submit mnmcount.py data/mnm_dataset.csv

+-----+------+-----+
|State|Color |Total|
+-----+------+-----+
|CA   |Yellow|1807 |
|WA   |Green |1779 |
|OR   |Orange|1743 |
|TX   |Green |1737 |
|TX   |Red   |1725 |
|CA   |Green |1723 |
|CO   |Yellow|1721 |
|CA   |Brown |1718 |
|CO   |Green |1713 |
|NV   |Orange|1712 |
|TX   |Yellow|1703 |
|NV   |Green |1698 |
|AZ   |Brown |1698 |
|CO   |Blue  |1695 |
|WY   |Green |1695 |
|NM   |Red   |1690 |
|AZ   |Orange|1689 |
|NM   |Yellow|1688 |
|NM   |Brown |1687 |
|UT   |Orange|1684 |
|NM   |Green |1682 |
|UT   |Red   |1680 |
|AZ   |Green |1676 |
|NV   |Yellow|1675 |
|NV   |Blue  |1673 |
|WA   |Red   |1671 |
|WY   |Red   |1670 |
|WA   |Brown |1669 |
|NM   |Orange|1665 |
|WY   |Blue  |1664 |
|WA   |Yellow|1663 |
|WA   |Orange|1658 |
|NV   |Brown |1657 |
|CA   |Orange|1657 |
|CA   |Red   |1656 |
|CO   |Brown |1656 |
|UT   |Blue  |1655 |
|AZ   |Yellow|1654 |
|TX   |Orange|1652 |
|AZ   |Red   |1648 |
|OR   |Blue  |1646 |
|UT   |Yellow|1645 |
|OR   |Red   |1645 |
|CO   |Orange|1642 |
|TX   |Brown |1641 |
|NM   |Blue  |1638 |
|AZ   |Blue  |1636 |
|OR   |Green |1634 |
|UT   |Brown |1631 |
|WY   |Yellow|1626 |
|WA   |Blue  |1625 |
|CO   |Red   |1624 |
|OR   |Brown |1621 |
|TX   |Blue  |1614 |
|OR   |Yellow|1614 |
|NV   |Red   |1610 |
|CA   |Blue  |1603 |
|WY   |Orange|1595 |
|UT   |Green |1591 |
|WY   |Brown |1532 |
+-----+------+-----+

Total Rows = 60

+-----+------+-----+
|State|Color |Total|
+-----+------+-----+
|CA   |Yellow|1807 |
|CA   |Green |1723 |
|CA   |Brown |1718 |
|CA   |Orange|1657 |
|CA   |Red   |1656 |
|CA   |Blue  |1603 |
+-----+------+-----+

首先,我们看到每个州每种 M&M 颜色的所有聚合结果,然后是仅限于 CA 的那些(那里偏爱黄色)。

如果你想要使用 Scala 版本的同一个 Spark 程序怎么办?这些 APIs 是相似的;在 Spark 中,跨支持的语言保持了良好的一致性,只有少量语法差异。 Example 2-2 是该程序的 Scala 版本。看一看,在下一节中我们将展示如何构建和运行应用程序。

Example 2-2. 计数和聚合 M&M(Scala 版本)
package main.scala.chapter2

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._

*`/*``*`*
 *`*`* Usage: MnMcount <mnm_file_dataset> *`*/`*
object MnMcount {
 def main(args: Array[String]) {
   val spark = SparkSession
     .builder
     .appName("MnMCount")
     .getOrCreate()

   if (args.length < 1) {
     print("Usage: MnMcount <mnm_file_dataset>")
     sys.exit(1)
   }
   // Get the M&M data set filename
   val mnmFile = args(0)
   // Read the file into a Spark DataFrame
   val mnmDF = spark.read.format("csv")
     .option("header", "true")
     .option("inferSchema", "true")
     .load(mnmFile)
   // Aggregate counts of all colors and groupBy() State and Color
   // orderBy() in descending order
   val countMnMDF = mnmDF
     .select("State", "Color", "Count")
     .groupBy("State", "Color")
     .agg(count("Count").alias("Total"))
     .orderBy(desc("Total"))
   // Show the resulting aggregations for all the states and colors
   countMnMDF.show(60)
   println(s"Total Rows = ${countMnMDF.count()}")
   println()
   // Find the aggregate counts for California by filtering
   val caCountMnMDF = mnmDF
     .select("State", "Color", "Count")
     .where(col("State") === "CA")
     .groupBy("State", "Color")
     .agg(count("Count").alias("Total"))
     .orderBy(desc("Total"))
   // Show the resulting aggregations for California
   caCountMnMDF.show(10)
   // Stop the SparkSession
   spark.stop()
 }
}

在 Scala 中构建独立应用程序

现在我们将展示如何使用 Scala Build Tool (sbt) 构建你的第一个 Scala Spark 程序。

注意

因为 Python 是一种解释性语言,并且没有像编译成字节码那样的编译步骤(尽管可以将 Python 代码编译成 .pyc 字节码),我们不会在这里详细介绍这一步骤。有关如何使用 Maven 构建 Java Spark 程序的详细信息,请参阅 Apache Spark 网站上的 指南。为了书写简洁,在本书中,我们主要以 Python 和 Scala 示例为主。

build.sbt 是类似于 makefile 的规范文件,描述和指导 Scala 编译器如何构建你的 Scala 相关任务,例如 jars、packages、需要解决的依赖以及查找它们的位置。在我们的例子中,我们有一个简单的 sbt 文件用于我们的 M&M 代码 (示例 2-3)。

示例 2-3. sbt 构建文件
// Name of the package
name := "main/scala/chapter2"
// Version of our package
version := "1.0"
// Version of Scala
scalaVersion := "2.12.10"
// Spark library dependencies
libraryDependencies ++= Seq(
  "org.apache.spark" %% "spark-core" % "3.0.0-preview2",
  "org.apache.spark" %% "spark-sql"  % "3.0.0-preview2"
)

假设你已经安装了 Java 开发工具包 (JDK) 和 sbt,并设置了 JAVA_HOMESPARK_HOME,通过一条命令,你可以构建你的 Spark 应用程序:

$ **sbt clean package**
[info] Updated file /Users/julesdamji/gits/LearningSparkV2/chapter2/scala/
project/build.properties: set sbt.version to 1.2.8
[info] Loading project definition from /Users/julesdamji/gits/LearningSparkV2/
chapter2/scala/project
[info] Updating 
[info] Done updating.
...
[info] Compiling 1 Scala source to /Users/julesdamji/gits/LearningSparkV2/
chapter2/scala/target/scala-2.12/classes ...
[info] Done compiling.
[info] Packaging /Users/julesdamji/gits/LearningSparkV2/chapter2/scala/target/
scala-2.12/main-scala-chapter2_2.12-1.0.jar ...
[info] Done packaging.
[success] Total time: 6 s, completed Jan 11, 2020, 4:11:02 PM

成功构建后,你可以像下面这样运行 M&M 计数示例的 Scala 版本:

$SPARK_HOME/bin/spark-submit --class main.scala.chapter2.MnMcount \ 
jars/main-scala-chapter2_2.12-1.0.jar data/mnm_dataset.csv
...
...
20/01/11 16:00:48 INFO TaskSchedulerImpl: Killing all running tasks in stage 4: 
Stage finished
20/01/11 16:00:48 INFO DAGScheduler: Job 4 finished: show at MnMcount.scala:49, 
took 0.264579 s
+-----+------+-----+
|State| Color|Total|
+-----+------+-----+
|   CA|Yellow| 1807|
|   CA| Green| 1723|
|   CA| Brown| 1718|
|   CA|Orange| 1657|
|   CA|   Red| 1656|
|   CA|  Blue| 1603|
+-----+------+-----+

输出与 Python 运行相同。试一试吧!

就是这样——我们的数据科学家作者将非常乐意使用这些数据来决定在她所教的任何州的课程中要使用什么颜色的 M&M 饼干。

摘要

在本章中,我们介绍了开始使用 Apache Spark 的三个简单步骤:下载框架、熟悉 Scala 或 PySpark 交互式 shell,并了解高级 Spark 应用程序的概念和术语。我们快速概述了使用转换和动作编写 Spark 应用程序的过程,并简要介绍了使用 Spark UI 来检查创建的作业、阶段和任务。

最后,通过一个简短的示例,我们向你展示了如何使用高级结构化 API 告诉 Spark 做什么——这将引导我们进入下一章,更详细地探讨这些 API。

第三章:Apache Spark 的结构化 API

在本章中,我们将探索向 Apache Spark 添加结构的主要动机,这些动机如何导致创建高级 API(DataFrames 和 Datasets),以及它们在 Spark 2.x 中的组件之间的统一。我们还将看看支撑这些结构化高级 API 的 Spark SQL 引擎。

Spark SQL在早期 Spark 1.x 版本中首次引入时,紧随其后的是DataFrames作为SchemaRDDs的后继者。我们第一次在 Spark 中看到了结构。Spark SQL 引入了高级表达操作函数,模仿类 SQL 语法,以及 DataFrames,为后续版本中更多结构提供了基础,为 Spark 计算查询中的高性能操作铺平了道路。

但在我们讨论新的结构化 API 之前,让我们简要了解一下在 Spark 中没有结构的情况,通过简单的 RDD 编程 API 模型来看一下。

Spark:RDD 底层是什么?

RDD 是 Spark 中最基本的抽象。与 RDD 关联的有三个关键特征:

  • 依赖项

  • 分区(带有一些本地化信息)

  • 计算函数:Partition => Iterator[T]

这三个特性对于构建所有高级功能的简单 RDD 编程 API 模型至关重要。首先,需要一个依赖项列表,指导 Spark 如何使用其输入构建 RDD。在需要重现结果时,Spark 可以从这些依赖项重新创建 RDD,并在其上复制操作。这一特性赋予了 RDD 的弹性。

第二,分区使得 Spark 能够将工作分割并行化处理分区上的计算。在某些情况下,例如从 HDFS 读取数据,Spark 将使用本地化信息将工作发送到靠近数据的执行者。这样可以减少通过网络传输的数据量。

最后,RDD 具有一个计算函数,用于生成将存储在 RDD 中的数据的Iterator[T]

简单而优雅!然而,这个原始模型存在一些问题。首先,计算函数(或计算过程)对 Spark 来说是不透明的。也就是说,Spark 不知道你在计算函数中做了什么。无论是执行连接、过滤、选择还是聚合操作,Spark 只把它视为一个 lambda 表达式。另一个问题是,对于 Python RDDs 来说,Iterator[T]数据类型也是不透明的;Spark 只知道它是 Python 中的一个通用对象。

此外,由于无法检查函数中的计算或表达式,Spark 无法优化表达式 —— 它无法理解其意图。最后,Spark 对于 T 的具体数据类型一无所知。对于 Spark 来说,它是一个不透明的对象;它无法知道您是否在访问对象内的某个特定类型的列。因此,Spark 只能将不透明对象序列化为一系列字节,而无法使用任何数据压缩技术。

这种不透明性显然阻碍了 Spark 重新排列您的计算以生成高效的查询计划的能力。那么解决方案是什么呢?

结构化 Spark

Spark 2.x 引入了几种关键方案来结构化 Spark。其中一种方法是使用在数据分析中常见的通用模式来表达计算。这些模式被表达为诸如过滤、选择、计数、聚合、求平均和分组等高级操作。这样做提供了额外的清晰度和简单性。

通过 DSL 中一组常见操作符的使用,此特定性进一步通过 DSL 中一组常见操作符的使用进行缩小。通过这些操作符,作为 Spark 支持的语言(Java、Python、Spark、R 和 SQL)中的 API 提供,这些操作符让您告诉 Spark 您希望如何计算您的数据,因此,它可以构建一个高效的查询计划来执行。

最终的顺序和结构方案是允许您将数据以表格格式排列,如 SQL 表或电子表格,使用支持的结构化数据类型(我们将很快介绍)。

但所有这些结构有什么好处呢?

关键优势和益处

结构化带来了许多好处,包括在 Spark 组件之间提升性能和空间效率。我们稍后将进一步探讨使用 DataFrame 和 Dataset API 时的这些好处,但现在我们将集中讨论其他优势:表达性、简单性、可组合性和统一性。

首先,让我们通过一个简单的代码片段来展示表达性和可组合性。在下面的示例中,我们想要按名称分组,然后计算每个名称的所有年龄的平均值 —— 这是数据分析和探索中常见的模式。如果我们要使用低级别的 RDD API,代码将如下所示:

# In Python
# Create an RDD of tuples (name, age)
dataRDD = sc.parallelize([("Brooke", 20), ("Denny", 31), ("Jules", 30), 
  ("TD", 35), ("Brooke", 25)])
# Use map and reduceByKey transformations with their lambda 
# expressions to aggregate and then compute average

agesRDD = (dataRDD
  .map(lambda x: (x[0], (x[1], 1)))
  .reduceByKey(lambda x, y: (x[0] + y[0], x[1] + y[1]))
  .map(lambda x: (x[0], x[1][0]/x[1][1])))

没有人会反驳这段代码的晦涩和难以阅读,它告诉 Spark 如何 对键进行聚合并计算平均值,使用了一系列的 Lambda 函数。换句话说,该代码在指导 Spark 如何计算查询。对于 Spark 来说完全不透明,因为它无法传达意图。此外,在 Scala 中等效的 RDD 代码与此处展示的 Python 代码也有很大不同。

相比之下,如果我们使用高级 DSL 操作符和 DataFrame API 表达相同的查询,从而指导 Spark 做什么?看一下:

# In Python 
from pyspark.sql import SparkSession
from pyspark.sql.functions import avg
# Create a DataFrame using SparkSession
spark = (SparkSession
  .builder
  .appName("AuthorsAges")
  .getOrCreate())
# Create a DataFrame 
data_df = spark.createDataFrame([("Brooke", 20), ("Denny", 31), ("Jules", 30), 
  ("TD", 35), ("Brooke", 25)], ["name", "age"])
# Group the same names together, aggregate their ages, and compute an average
avg_df = data_df.groupBy("name").agg(avg("age"))
# Show the results of the final execution
avg_df.show()

+------+--------+
|  name|avg(age)|
+------+--------+
|Brooke|    22.5|
| Jules|    30.0|
|    TD|    35.0|
| Denny|    31.0|
+------+--------+

这个版本的代码不仅更富表现力,而且更简单,因为我们使用高级 DSL 操作符和 API 告诉 Spark 要做什么。实际上,我们已经使用这些操作符来组合我们的查询。由于 Spark 可以检查或解析这个查询并理解我们的意图,它可以优化或安排操作以实现高效执行。Spark 清楚地知道我们希望做什么:按姓名分组人们,聚合他们的年龄,然后计算具有相同姓名的所有人的平均年龄。我们使用高级操作符组合了整个计算作为一个简单的查询——这是多么富有表现力啊?

有人或许会认为,通过仅使用高级别的、富有表现力的 DSL 操作符来映射常见或重复的数据分析模式,以引入秩序和结构,我们限制了开发者指导编译器或控制查询计算方式的能力。请放心,您并不局限于这些结构化模式;您随时可以切换回不结构化的低级 RDD API,尽管我们几乎从不需要这样做。

除了更易于阅读之外,Spark 高级 API 的结构还在其组件和语言之间引入了统一性。例如,这里展示的 Scala 代码与之前的 Python 代码执行相同的操作——而且 API 看起来几乎相同:

// In Scala
import org.apache.spark.sql.functions.avg
import org.apache.spark.sql.SparkSession
// Create a DataFrame using SparkSession
val spark = SparkSession
  .builder
  .appName("AuthorsAges")
  .getOrCreate()
// Create a DataFrame of names and ages
val dataDF = spark.createDataFrame(Seq(("Brooke", 20), ("Brooke", 25), 
  ("Denny", 31), ("Jules", 30), ("TD", 35))).toDF("name", "age")
// Group the same names together, aggregate their ages, and compute an average
val avgDF = dataDF.groupBy("name").agg(avg("age"))
// Show the results of the final execution
avgDF.show()

+------+--------+
|  name|avg(age)|
+------+--------+
|Brooke|    22.5|
| Jules|    30.0|
|    TD|    35.0|
| Denny|    31.0|
+------+--------+
注意

这些 DSL 操作符中的一些执行类似关系型操作,如果您了解 SQL,您会很熟悉,比如选择、过滤、分组和聚合。

所有这些我们开发者所珍视的简洁性和表达性,都是因为 Spark SQL 引擎的存在,它支持构建高级结构化 API。正是由于这个引擎,作为所有 Spark 组件的基础,我们才能获得统一的 API。无论您是在结构化流处理还是 MLlib 中对 DataFrame 表达查询,您始终是在操作和转换结构化数据。本章稍后我们将更详细地了解 Spark SQL 引擎,但现在让我们来探索这些 API 和 DSL,用于常见操作以及如何用于数据分析。

DataFrame API

受到pandas DataFrames在结构、格式以及一些特定操作的启发,Spark DataFrames 就像是带有命名列和模式的分布式内存中的表格,其中每列具有特定的数据类型:整数、字符串、数组、映射、实数、日期、时间戳等。对于人眼来说,Spark DataFrame 就像是一张表。示例见 Table 3-1。

表 3-1. DataFrame 的表格化格式

Id (Int) First (String) Last (String) Url (String) Published (Date) Hits (Int) Campaigns (List[Strings])
1 Jules Damji https://tinyurl.1 1/4/2016 4535 [twitter, LinkedIn]
2 Brooke Wenig https://tinyurl.2 5/5/2018 8908 [twitter, LinkedIn]
3 Denny Lee https://tinyurl.3 6/7/2019 7659 [web, twitter, FB, LinkedIn]
4 Tathagata Das https://tinyurl.4 5/12/2018 10568 [twitter, FB]
5 Matei Zaharia https://tinyurl.5 5/14/2014 40578 [web, twitter, FB, LinkedIn]
6 Reynold Xin https://tinyurl.6 3/2/2015 25568 [twitter, LinkedIn]

当数据以结构化表格的形式展示时,不仅易于理解,而且在进行常见的行列操作时也易于处理。此外,请回忆一下,正如您在 第 2 章 中学到的那样,DataFrame 是不可变的,Spark 会保留所有转换的历史记录。您可以添加或更改列的名称和数据类型,创建新的 DataFrame,同时保留之前的版本。可以在模式中声明 DataFrame 中命名的列及其关联的 Spark 数据类型。

让我们在使用它们定义模式之前,先检查 Spark 中可用的通用和结构化数据类型。然后,我们将说明如何使用它们创建具有模式的 DataFrame,捕获 表 3-1 中的数据。

Spark 的基本数据类型

与其支持的编程语言相匹配,Spark 支持基本的内部数据类型。这些数据类型可以在您的 Spark 应用程序中定义或声明。例如,在 Scala 中,您可以定义或声明特定列名为 StringByteLongMap 等类型。在这里,我们定义了与 Spark 数据类型相关联的变量名:

`$SPARK_HOME``/``bin``/``spark``-``shell`
scala> `import` `org.apache.spark.sql.types._`
import org.apache.spark.sql.types._
scala> `val` `nameTypes` `=` `StringType`
nameTypes: org.apache.spark.sql.types.StringType.type = StringType
scala> `val` `firstName` `=` `nameTypes`
firstName: org.apache.spark.sql.types.StringType.type = StringType
scala> `val` `lastName` `=` `nameTypes`
lastName: org.apache.spark.sql.types.StringType.type = StringType

表 3-2 列出了 Spark 支持的基本 Scala 数据类型。它们都是 DataTypes 类的子类型,除了 DecimalType

表 3-2. Spark 中的基本 Scala 数据类型

数据类型 Scala 中分配的值 实例化的 API
ByteType Byte DataTypes.ByteType
ShortType Short DataTypes.ShortType
IntegerType Int DataTypes.IntegerType
LongType Long DataTypes.LongType
FloatType Float DataTypes.FloatType
DoubleType Double DataTypes.DoubleType
StringType String DataTypes.StringType
BooleanType Boolean DataTypes.BooleanType
DecimalType java.math.BigDecimal DecimalType

Spark 支持类似的基本 Python 数据类型,如 表 3-3 中所列。

表 3-3. Spark 中的基本 Python 数据类型

数据类型 Python 中分配的值 实例化的 API
ByteType int DataTypes.ByteType
ShortType int DataTypes.ShortType
IntegerType int DataTypes.IntegerType
LongType int DataTypes.LongType
FloatType float DataTypes.FloatType
DoubleType float DataTypes.DoubleType
StringType str DataTypes.StringType
BooleanType bool DataTypes.BooleanType
DecimalType decimal.Decimal DecimalType

Spark 的结构化和复杂数据类型

对于复杂数据分析,您不仅仅处理简单或基本数据类型。您的数据可能是复杂的,通常是结构化或嵌套的,您需要 Spark 来处理这些复杂的数据类型。它们有多种形式:映射(maps)、数组(arrays)、结构(structs)、日期(dates)、时间戳(timestamps)、字段等。表 3-4 列出了 Spark 支持的 Scala 结构化数据类型。

表 3-4. Scala 中的结构化数据类型在 Spark 中

数据类型 在 Scala 中分配的值 实例化的 API
BinaryType Array[Byte] DataTypes.BinaryType
TimestampType java.sql.Timestamp DataTypes.TimestampType
DateType java.sql.Date DataTypes.DateType
ArrayType scala.collection.Seq DataTypes.createArrayType(ElementType)
MapType scala.collection.Map DataTypes.createMapType(keyType, valueType)
StructType org.apache.spark.sql.Row StructType(ArrayType[fieldTypes])
StructField 与此字段类型对应的值类型 StructField(name, dataType, [nullable])

Spark 支持的 Python 中等效的结构化数据类型在 表 3-5 中列举。

表 3-5. Python 中的结构化数据类型在 Spark 中

数据类型 在 Python 中分配的值 实例化的 API
BinaryType bytearray BinaryType()
TimestampType datetime.datetime TimestampType()
DateType datetime.date DateType()
ArrayType 列表、元组或数组 ArrayType(dataType, [nullable])
MapType dict MapType(keyType, valueType, [nullable])
StructType 列表或元组 StructType([fields])
StructField 与此字段类型对应的值类型 StructField(name, dataType, [nullable])

尽管这些表展示了支持的多种类型,但更重要的是看到当您为数据定义 schema 时这些类型如何结合在一起。

Schema 和创建 DataFrame

在 Spark 中,schema 定义了 DataFrame 的列名和相关的数据类型。通常情况下,当您从外部数据源读取结构化数据时(更多内容将在下一章讨论),schema 就会发挥作用。与在读取时定义 schema 相比,提前定义 schema 具有三个优点:

  • 解除 Spark 推断数据类型的负担。

  • 防止 Spark 创建一个单独的作业仅仅是为了读取文件的大部分内容以确定 schema,对于大数据文件来说,这可能既昂贵又耗时。

  • 如果数据与 schema 不匹配,您可以及早发现错误。

因此,我们鼓励您无论何时希望从数据源中读取大型文件时都提前定义模式。为了简短示例,让我们为 表 3-1 中的数据定义一个模式,并使用该模式创建一个 DataFrame。

两种定义模式的方式

Spark 允许您以两种方式定义模式。一种是以编程方式定义,另一种是使用数据定义语言(DDL)字符串,后者更简单且更易读。

要为具有三个命名列 authortitlepages 的 DataFrame 编程定义模式,您可以使用 Spark DataFrame API。例如:

// In Scala
import org.apache.spark.sql.types._
val schema = StructType(Array(StructField("author", StringType, false),
  StructField("title", StringType, false),
  StructField("pages", IntegerType, false)))
# In Python
from pyspark.sql.types import *
schema = StructType([StructField("author", StringType(), False),
  StructField("title", StringType(), False),
  StructField("pages", IntegerType(), False)])

使用 DDL 定义相同的模式要简单得多:

// In Scala
val schema = "author STRING, title STRING, pages INT"
# In Python
schema = "author STRING, title STRING, pages INT"

您可以选择任何一种方式来定义模式。在许多示例中,我们将同时使用两种方法:

# In Python 
from pyspark.sql import SparkSession

# Define schema for our data using DDL 
schema = "`Id` INT, `First` STRING, `Last` STRING, `Url` STRING, 
  `Published` STRING, `Hits` INT, `Campaigns` ARRAY<STRING>"

# Create our static data
data = [[1, "Jules", "Damji", "https://tinyurl.1", "1/4/2016", 4535, ["twitter",
"LinkedIn"]],
       [2, "Brooke","Wenig", "https://tinyurl.2", "5/5/2018", 8908, ["twitter",
"LinkedIn"]],
       [3, "Denny", "Lee", "https://tinyurl.3", "6/7/2019", 7659, ["web",
"twitter", "FB", "LinkedIn"]],
       [4, "Tathagata", "Das", "https://tinyurl.4", "5/12/2018", 10568, 
["twitter", "FB"]],
       [5, "Matei","Zaharia", "https://tinyurl.5", "5/14/2014", 40578, ["web",
"twitter", "FB", "LinkedIn"]],
       [6, "Reynold", "Xin", "https://tinyurl.6", "3/2/2015", 25568, 
["twitter", "LinkedIn"]]
      ]

# Main program
if __name__ == "__main__":
   # Create a SparkSession
   spark = (SparkSession
     .builder
     .appName("Example-3_6")
     .getOrCreate())
   # Create a DataFrame using the schema defined above
   blogs_df = spark.createDataFrame(data, schema)
   # Show the DataFrame; it should reflect our table above
   blogs_df.show()
   # Print the schema used by Spark to process the DataFrame
   print(blogs_df.printSchema())

从控制台运行此程序将产生以下输出:

$ `spark``-``submit` `Example``-``3``_6``.``py`
...
+-------+---------+-------+-----------------+---------+-----+------------------+
|Id     |First    |Last   |Url              |Published|Hits |Campaigns         |
+-------+---------+-------+-----------------+---------+-----+------------------+
|1      |Jules    |Damji  |https://tinyurl.1|1/4/2016 |4535 |[twitter,...]     |
|2      |Brooke   |Wenig  |https://tinyurl.2|5/5/2018 |8908 |[twitter,...]     |
|3      |Denny    |Lee    |https://tinyurl.3|6/7/2019 |7659 |[web, twitter...] |
|4      |Tathagata|Das    |https://tinyurl.4|5/12/2018|10568|[twitter, FB]     |
|5      |Matei    |Zaharia|https://tinyurl.5|5/14/2014|40578|[web, twitter,...]|
|6      |Reynold  |Xin    |https://tinyurl.6|3/2/2015 |25568|[twitter,...]     |
+-------+---------+-------+-----------------+---------+-----+------------------+

root
 |-- Id: integer (nullable = false)
 |-- First: string (nullable = false)
 |-- Last: string (nullable = false)
 |-- Url: string (nullable = false)
 |-- Published: string (nullable = false)
 |-- Hits: integer (nullable = false)
 |-- Campaigns: array (nullable = false)
 |    |-- element: string (containsNull = false)

如果您想在代码的其他位置使用此模式,只需执行 blogs_df.schema,它将返回模式定义:

StructType(List(StructField("Id",IntegerType,false),
StructField("First",StringType,false),
StructField("Last",StringType,false),
StructField("Url",StringType,false),
StructField("Published",StringType,false),
StructField("Hits",IntegerType,false),
StructField("Campaigns",ArrayType(StringType,true),false)))

如您所见,DataFrame 的布局与 表 3-1 的布局以及相应的数据类型和模式输出匹配。

如果您要从 JSON 文件中读取数据而不是创建静态数据,则模式定义将相同。让我们用 Scala 示例说明相同的代码,这次是从 JSON 文件中读取:

// In Scala
package main.scala.chapter3

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.types._

object Example3_7 {
 def main(args: Array[String]) {

   val spark = SparkSession
     .builder
     .appName("Example-3_7")
     .getOrCreate()

   if (args.length <= 0) {
     println("usage Example3_7 <file path to blogs.json>")
     System.exit(1)
   }
   // Get the path to the JSON file
   val jsonFile = args(0)
   // Define our schema programmatically
   val schema = StructType(Array(StructField("Id", IntegerType, false),
     StructField("First", StringType, false),
     StructField("Last", StringType, false),
     StructField("Url", StringType, false),
     StructField("Published", StringType, false),
     StructField("Hits", IntegerType, false),
     StructField("Campaigns", ArrayType(StringType), false)))

   // Create a DataFrame by reading from the JSON file 
   // with a predefined schema
   val blogsDF = spark.read.schema(schema).json(jsonFile)
   // Show the DataFrame schema as output
   blogsDF.show(false)
   // Print the schema
   println(blogsDF.printSchema)
   println(blogsDF.schema)
 }
}

毫不奇怪,Scala 程序的输出与 Python 程序的输出相同:

+---+---------+-------+-----------------+---------+-----+----------------------+
|Id |First    |Last   |Url              |Published|Hits |Campaigns             |
+---+---------+-------+-----------------+---------+-----+----------------------+
|1  |Jules    |Damji  |https://tinyurl.1|1/4/2016 |4535 |[twitter, LinkedIn]   |
|2  |Brooke   |Wenig  |https://tinyurl.2|5/5/2018 |8908 |[twitter, LinkedIn]   |
|3  |Denny    |Lee    |https://tinyurl.3|6/7/2019 |7659 |[web, twitter,...]    |
|4  |Tathagata|Das    |https://tinyurl.4|5/12/2018|10568|[twitter, FB]         |
|5  |Matei    |Zaharia|https://tinyurl.5|5/14/2014|40578|[web, twitter, FB,...]|
|6  |Reynold  |Xin    |https://tinyurl.6|3/2/2015 |25568|[twitter, LinkedIn]   |
+---+---------+-------+-----------------+---------+-----+----------------------+

root
 |-- Id: integer (nullable = true)
 |-- First: string (nullable = true)
 |-- Last: string (nullable = true)
 |-- Url: string (nullable = true)
 |-- Published: string (nullable = true)
 |-- Hits: integer (nullable = true)
 |-- Campaigns: array (nullable = true)
 |    |-- element: string (containsNull = true)

StructType(StructField("Id",IntegerType,true), 
    StructField("First",StringType,true), 
    StructField("Last",StringType,true), 
    StructField("Url",StringType,true),
    StructField("Published",StringType,true), 
    StructField("Hits",IntegerType,true),
    StructField("Campaigns",ArrayType(StringType,true),true))

现在您已经了解如何在 DataFrame 中使用结构化数据和模式,让我们关注 DataFrame API 中的列和行及其操作的含义。

列和表达式

如前所述,DataFrame 中的命名列在概念上类似于 pandas 或 R 中的命名列,或者类似于 RDBMS 表中的命名列:它们描述了字段的类型。您可以按其名称列出所有列,并可以使用关系或计算表达式对其值进行操作。在 Spark 支持的语言中,列是具有公共方法的对象(由 Column 类型表示)。

您还可以在列上使用逻辑或数学表达式。例如,您可以使用 expr("columnName * 5")(expr("columnName - 5") > col(anothercolumnName)) 创建一个简单的表达式,其中 columnName 是 Spark 类型(整数、字符串等)。expr()pyspark.sql.functions(Python)和 org.apache.spark.sql.functions(Scala)包中的一部分。像这些包中的任何其他函数一样,expr() 接受 Spark 将解析为表达式并计算结果的参数。

注意

Scala、Java 和 Python 都具有与列关联的 公共方法。您会注意到 Spark 文档同时提到了 colColumnColumn 是对象的名称,而 col() 是一个标准内置函数,返回一个 Column

让我们看一些在 Spark 中可以对列执行的操作示例。每个示例后面都跟着它的输出:

// In Scala scala> `import` `org.apache.spark.sql.functions._`
scala> `blogsDF``.``columns`
res2: Array[String] = Array(Campaigns, First, Hits, Id, Last, Published, Url)

// Access a particular column with col and it returns a Column type scala> `blogsDF``.``col``(``"Id"``)`
res3: org.apache.spark.sql.Column = id

// Use an expression to compute a value scala> `blogsDF``.``select``(``expr``(``"Hits * 2"``)``)``.``show``(``2``)`
// or use col to compute value scala> `blogsDF``.``select``(``col``(``"Hits"``)` `*` `2``)``.``show``(``2``)`

+----------+
|(Hits * 2)|
+----------+
|      9070|
|     17816|
+----------+

// Use an expression to compute big hitters for blogs // This adds a new column, Big Hitters, based on the conditional expression blogsDF.withColumn("Big Hitters", (expr("Hits > 10000"))).show()

+---+---------+-------+---+---------+-----+--------------------+-----------+
| Id|    First|   Last|Url|Published| Hits|           Campaigns|Big Hitters|
+---+---------+-------+---+---------+-----+--------------------+-----------+
|  1|    Jules|  Damji|...| 1/4/2016| 4535| [twitter, LinkedIn]|      false|
|  2|   Brooke|  Wenig|...| 5/5/2018| 8908| [twitter, LinkedIn]|      false|
|  3|    Denny|    Lee|...| 6/7/2019| 7659|[web, twitter, FB...|      false|
|  4|Tathagata|    Das|...|5/12/2018|10568|       [twitter, FB]|       true|
|  5|    Matei|Zaharia|...|5/14/2014|40578|[web, twitter, FB...|       true|
|  6|  Reynold|    Xin|...| 3/2/2015|25568| [twitter, LinkedIn]|       true|
+---+---------+-------+---+---------+-----+--------------------+-----------+

// Concatenate three columns, create a new column, and show the 
// newly created concatenated column
blogsDF
  .withColumn("AuthorsId", (concat(expr("First"), expr("Last"), expr("Id"))))
  .select(col("AuthorsId"))
  .show(4)

+-------------+
|    AuthorsId|
+-------------+
|  JulesDamji1|
| BrookeWenig2|
|    DennyLee3|
|TathagataDas4|
+-------------+

// These statements return the same value, showing that 
// expr is the same as a col method call
blogsDF.select(expr("Hits")).show(2)
blogsDF.select(col("Hits")).show(2)
blogsDF.select("Hits").show(2)

+-----+
| Hits|
+-----+
| 4535|
| 8908|
+-----+

// Sort by column "Id" in descending order
blogsDF.sort(col("Id").desc).show()
blogsDF.sort($"Id".desc).show()

+--------------------+---------+-----+---+-------+---------+-----------------+
|           Campaigns|    First| Hits| Id|   Last|Published|              Url|
+--------------------+---------+-----+---+-------+---------+-----------------+
| [twitter, LinkedIn]|  Reynold|25568|  6|    Xin| 3/2/2015|https://tinyurl.6|
|[web, twitter, FB...|    Matei|40578|  5|Zaharia|5/14/2014|https://tinyurl.5|
|       [twitter, FB]|Tathagata|10568|  4|    Das|5/12/2018|https://tinyurl.4|
|[web, twitter, FB...|    Denny| 7659|  3|    Lee| 6/7/2019|https://tinyurl.3|
| [twitter, LinkedIn]|   Brooke| 8908|  2|  Wenig| 5/5/2018|https://tinyurl.2|
| [twitter, LinkedIn]|    Jules| 4535|  1|  Damji| 1/4/2016|https://tinyurl.1|
+--------------------+---------+-----+---+-------+---------+-----------------+

在这个最后的例子中,表达式 blogs_df.sort(col("Id").desc)blogsDF.sort($"Id".desc) 是相同的。它们都按照降序排序名为 Id 的 DataFrame 列:一个使用显式函数 col("Id") 返回一个 Column 对象,而另一个在列名前使用 $,这是 Spark 中将列名 Id 转换为 Column 的函数。

注意

我们在这里只是浅尝辄止,并且仅仅使用了 Column 对象上的几种方法。有关 Column 对象的所有公共方法的完整列表,请参阅 Spark 文档

在 DataFrame 中,Column 对象不能单独存在;每列都是记录中的一部分,所有行一起构成一个 DataFrame,在本章后面我们将看到它实际上是 Scala 中的 Dataset[Row]

在 Spark 中,一行是一个通用的 Row 对象,包含一个或多个列。每列可以是相同的数据类型(例如整数或字符串),也可以是不同的类型(整数、字符串、映射、数组等)。因为 Row 是 Spark 中的对象,是一个有序的字段集合,你可以在 Spark 支持的每种语言中实例化一个 Row,并通过从 0 开始的索引访问其字段:

// In Scala
import org.apache.spark.sql.Row
// Create a Row
val blogRow = Row(6, "Reynold", "Xin", "https://tinyurl.6", 255568, "3/2/2015", 
  Array("twitter", "LinkedIn"))
// Access using index for individual items
blogRow(1)
res62: Any = Reynold
# In Python
from pyspark.sql import Row
blog_row = Row(6, "Reynold", "Xin", "https://tinyurl.6", 255568, "3/2/2015", 
  ["twitter", "LinkedIn"])
# access using index for individual items
blog_row[1]
'Reynold'

如果需要快速互动和探索,可以使用 Row 对象来创建 DataFrame:

# In Python 
rows = [Row("Matei Zaharia", "CA"), Row("Reynold Xin", "CA")]
authors_df = spark.createDataFrame(rows, ["Authors", "State"])
authors_df.show()
// In Scala
val rows = Seq(("Matei Zaharia", "CA"), ("Reynold Xin", "CA"))
val authorsDF = rows.toDF("Author", "State") 
authorsDF.show()

+-------------+-----+
|       Author|State|
+-------------+-----+
|Matei Zaharia|   CA|
|  Reynold Xin|   CA|
+-------------+-----+

在实践中,通常会从文件中读取 DataFrame,就像前面所示。在大多数情况下,由于文件可能会很大,定义架构并使用它是创建 DataFrame 的更快更高效的方法。

在创建了一个大型分布式 DataFrame 后,您可能希望对其执行一些常见的数据操作。让我们来看一些您可以在结构化 API 中使用高级关系操作符执行的 Spark 操作。

常见的 DataFrame 操作

要在 DataFrame 上执行常见的数据操作,首先需要从包含结构化数据的数据源中加载 DataFrame。Spark 提供了一个接口 DataFrameReader,它允许你从多种数据源(如 JSON、CSV、Parquet、Text、Avro、ORC 等格式)中读取数据到 DataFrame 中。同样,要将 DataFrame 写回特定格式的数据源,Spark 使用 DataFrameWriter

使用 DataFrameReaderDataFrameWriter

由于这些高级抽象和社区的贡献,Spark 中的读写操作变得简单,可以连接到各种数据源,包括常见的 NoSQL 存储、RDBMS、流处理引擎(如 Apache Kafka 和 Kinesis)等。

要开始,让我们读取一个包含旧金山消防部门通话数据的大型 CSV 文件。¹如前所述,我们将为此文件定义一个模式,并使用DataFrameReader类及其方法告诉 Spark 该做什么。因为这个文件包含 28 列和超过 4,380,660 条记录,²定义模式比让 Spark 推断模式更有效率。

注意

如果您不想指定模式,Spark 可以从较低成本的样本中推断模式。例如,您可以使用samplingRatio选项:

// In Scala
val sampleDF = spark
  .read
  .option("samplingRatio", 0.001)
  .option("header", true)
  .csv("""/databricks-datasets/learning-spark-v2/
 sf-fire/sf-fire-calls.csv""")

让我们看看如何做到这一点:

# In Python, define a schema 
from pyspark.sql.types import *

# Programmatic way to define a schema 
fire_schema = StructType([StructField('CallNumber', IntegerType(), True),
                StructField('UnitID', StringType(), True),
                StructField('IncidentNumber', IntegerType(), True),
                StructField('CallType', StringType(), True),                  
                StructField('CallDate', StringType(), True),      
                StructField('WatchDate', StringType(), True),
                StructField('CallFinalDisposition', StringType(), True),
                StructField('AvailableDtTm', StringType(), True),
                StructField('Address', StringType(), True),       
                StructField('City', StringType(), True),       
                StructField('Zipcode', IntegerType(), True),       
                StructField('Battalion', StringType(), True),                 
                StructField('StationArea', StringType(), True),       
                StructField('Box', StringType(), True),       
                StructField('OriginalPriority', StringType(), True),       
                StructField('Priority', StringType(), True),       
                StructField('FinalPriority', IntegerType(), True),       
                StructField('ALSUnit', BooleanType(), True),       
                StructField('CallTypeGroup', StringType(), True),
                StructField('NumAlarms', IntegerType(), True),
                StructField('UnitType', StringType(), True),
                StructField('UnitSequenceInCallDispatch', IntegerType(), True),
                StructField('FirePreventionDistrict', StringType(), True),
                StructField('SupervisorDistrict', StringType(), True),
                StructField('Neighborhood', StringType(), True),
                StructField('Location', StringType(), True),
                StructField('RowID', StringType(), True),
                StructField('Delay', FloatType(), True)])

# Use the DataFrameReader interface to read a CSV file
sf_fire_file = "/databricks-datasets/learning-spark-v2/sf-fire/sf-fire-calls.csv"
fire_df = spark.read.csv(sf_fire_file, header=True, schema=fire_schema)
// In Scala it would be similar
val fireSchema = StructType(Array(StructField("CallNumber", IntegerType, true),
                   StructField("UnitID", StringType, true),
                   StructField("IncidentNumber", IntegerType, true),
                   StructField("CallType", StringType, true), 
                   StructField("Location", StringType, true),
                   ...
                   ...
                   StructField("Delay", FloatType, true)))

// Read the file using the CSV DataFrameReader
val sfFireFile="/databricks-datasets/learning-spark-v2/sf-fire/sf-fire-calls.csv"
val fireDF = spark.read.schema(fireSchema)
  .option("header", "true")
  .csv(sfFireFile)

spark.read.csv()函数读取 CSV 文件,并返回一个带有命名列和模式规定类型的 DataFrame 行。

要将 DataFrame 写入您选择的外部数据源中,您可以使用DataFrameWriter接口。像DataFrameReader一样,它支持多个数据源。Parquet 是一种流行的列格式,默认使用 snappy 压缩来压缩数据。如果 DataFrame 被写入 Parquet,则模式将作为 Parquet 元数据的一部分保留。在这种情况下,后续将 DataFrame 读回时不需要手动提供模式。

将 DataFrame 保存为 Parquet 文件或 SQL 表

一个常见的数据操作是探索和转换数据,然后将 DataFrame 持久化为 Parquet 格式或将其保存为 SQL 表。持久化转换后的 DataFrame 与读取它一样简单。例如,要将我们刚刚使用的 DataFrame 持久化为文件,您可以执行以下操作:

// In Scala to save as a Parquet file
val parquetPath = ...
fireDF.write.format("parquet").save(parquetPath)
# In Python to save as a Parquet file
parquet_path = ...
fire_df.write.format("parquet").save(parquet_path)

或者,您可以将其保存为表格,其中注册 Hive 元存储的元数据(我们将在下一章中介绍 SQL 管理和非管理表格、元存储和 DataFrame):

// In Scala to save as a table 
val parquetTable = ... // name of the table
fireDF.write.format("parquet").saveAsTable(parquetTable)
# In Python
parquet_table = ... # name of the table
fire_df.write.format("parquet").saveAsTable(parquet_table)

让我们逐步介绍一些在读取数据后对 DataFrame 执行的常见操作。

转换和操作

现在您已经将旧金山消防部门通话的分布式 DataFrame 存储在内存中,作为开发人员,您首先要做的是检查数据以查看列的外观。它们是否是正确的类型?是否有需要转换为不同类型的列?它们是否有null值?

在“转换、操作和惰性评估”中的第二章,,您可以看到如何使用转换和操作来操作 DataFrame,并看到每个的一些常见示例。我们能从旧金山消防部门的通话中了解到什么?

投影和过滤器

在关系术语中,投影是通过使用过滤器返回仅匹配某个关系条件的行的一种方式。在 Spark 中,使用select()方法进行投影,而过滤器可以使用filter()where()方法来表达。我们可以使用这种技术来检查我们的 SF 消防局数据集的特定方面。

# In Python
few_fire_df = (fire_df
  .select("IncidentNumber", "AvailableDtTm", "CallType") 
  .where(col("CallType") != "Medical Incident"))
few_fire_df.show(5, truncate=False)
// In Scala
val fewFireDF = fireDF
  .select("IncidentNumber", "AvailableDtTm", "CallType")
  .where($"CallType" =!= "Medical Incident")    
fewFireDF.show(5, false)

+--------------+----------------------+--------------+
|IncidentNumber|AvailableDtTm         |CallType      |
+--------------+----------------------+--------------+
|2003235       |01/11/2002 01:47:00 AM|Structure Fire|
|2003235       |01/11/2002 01:51:54 AM|Structure Fire|
|2003235       |01/11/2002 01:47:00 AM|Structure Fire|
|2003235       |01/11/2002 01:47:00 AM|Structure Fire|
|2003235       |01/11/2002 01:51:17 AM|Structure Fire|
+--------------+----------------------+--------------+
only showing top 5 rows

如果我们想知道作为火灾呼叫原因记录的不同CallType的数量,这些简单而表达力强的查询可以完成工作:

# In Python, return number of distinct types of calls using countDistinct()
from pyspark.sql.functions import *
(fire_df
  .select("CallType")
  .where(col("CallType").isNotNull())
  .agg(countDistinct("CallType").alias("DistinctCallTypes"))
  .show())
// In Scala
import org.apache.spark.sql.functions._
fireDF
  .select("CallType")
  .where(col("CallType").isNotNull)
  .agg(countDistinct('CallType) as 'DistinctCallTypes)
  .show()

+-----------------+
|DistinctCallTypes|
+-----------------+
|               32|
+-----------------+

我们可以使用以下查询列出数据集中的不同呼叫类型:

# In Python, filter for only distinct non-null CallTypes from all the rows
(fire_df
  .select("CallType")
  .where(col("CallType").isNotNull())
  .distinct()
  .show(10, False))
// In Scala
fireDF
  .select("CallType")
  .where($"CallType".isNotNull())
  .distinct()
  .show(10, false)

Out[20]: 32

+-----------------------------------+
|CallType                           |
+-----------------------------------+
|Elevator / Escalator Rescue        |
|Marine Fire                        |
|Aircraft Emergency                 |
|Confined Space / Structure Collapse|
|Administrative                     |
|Alarms                             |
|Odor (Strange / Unknown)           |
|Lightning Strike (Investigation)   |
|Citizen Assist / Service Call      |
|HazMat                             |
+-----------------------------------+
only showing top 10 rows

重命名、添加和删除列

有时您希望出于样式或约定原因重命名特定列,其他时候则出于可读性或简洁性。SF 消防局数据集中的原始列名带有空格。例如,列名IncidentNumberIncident Number。列名中的空格可能会有问题,特别是当您想要将 DataFrame 写入或保存为 Parquet 文件时(这是不允许的)。

通过在架构中使用StructField指定所需的列名,我们有效地更改了结果 DataFrame 中的所有名称。

或者,您可以使用withColumnRenamed()方法有选择地重命名列。例如,让我们将Delay列的名称更改为ResponseDelayed并查看响应时间超过五分钟的情况:

# In Python
new_fire_df = fire_df.withColumnRenamed("Delay", "ResponseDelayedinMins")
(new_fire_df
  .select("ResponseDelayedinMins")
  .where(col("ResponseDelayedinMins") > 5)
  .show(5, False))
// In Scala
val newFireDF = fireDF.withColumnRenamed("Delay", "ResponseDelayedinMins")
newFireDF
  .select("ResponseDelayedinMins")
  .where($"ResponseDelayedinMins" > 5)
  .show(5, false)

这给我们一个新的重命名列:

+---------------------+
|ResponseDelayedinMins|
+---------------------+
|5.233333             |
|6.9333334            |
|6.116667             |
|7.85                 |
|77.333336            |
+---------------------+
only showing top 5 rows
注意

因为 DataFrame 转换是不可变的,当我们使用withColumnRenamed()重命名列时,我们会得到一个新的 DataFrame,同时保留具有旧列名的原始 DataFrame。

在数据探索期间,修改列的内容或其类型是常见操作。在某些情况下,数据是原始或脏乱的,或者其类型不适合作为关系操作符的参数。例如,在我们的 SF 消防局数据集中,列CallDateWatchDateAlarmDtTm是字符串,而不是 Unix 时间戳或 SQL 日期,Spark 都支持并且可以在数据的转换或操作(例如,在数据的日期或时间分析期间)中轻松操作。

那么我们如何将它们转换为更可用的格式呢?由于一些高级 API 方法的帮助,这非常简单。spark.sql.functions有一组日期/时间戳函数,如to_timestamp()to_date(),我们可以专门为此目的使用它们:

# In Python
fire_ts_df = (new_fire_df
  .withColumn("IncidentDate", to_timestamp(col("CallDate"), "MM/dd/yyyy"))
  .drop("CallDate") 
  .withColumn("OnWatchDate", to_timestamp(col("WatchDate"), "MM/dd/yyyy"))
  .drop("WatchDate") 
  .withColumn("AvailableDtTS", to_timestamp(col("AvailableDtTm"), 
  "MM/dd/yyyy hh:mm:ss a"))
  .drop("AvailableDtTm"))

# Select the converted columns
(fire_ts_df
  .select("IncidentDate", "OnWatchDate", "AvailableDtTS")
  .show(5, False))
// In Scala
val fireTsDF = newFireDF
  .withColumn("IncidentDate", to_timestamp(col("CallDate"), "MM/dd/yyyy"))
  .drop("CallDate")
  .withColumn("OnWatchDate", to_timestamp(col("WatchDate"), "MM/dd/yyyy"))
  .drop("WatchDate") 
  .withColumn("AvailableDtTS", to_timestamp(col("AvailableDtTm"), 
  "MM/dd/yyyy hh:mm:ss a"))
  .drop("AvailableDtTm") 

// Select the converted columns
fireTsDF
  .select("IncidentDate", "OnWatchDate", "AvailableDtTS")
  .show(5, false)

这些查询非常有效——发生了很多事情。让我们分解它们的功能:

  1. 将现有列的数据类型从字符串转换为 Spark 支持的时间戳。

  2. 根据适当的格式字符串"MM/dd/yyyy""MM/dd/yyyy hh:mm:ss a"使用新格式。

  3. 在转换为新数据类型后,使用drop()旧列并将新列指定为withColumn()方法的第一个参数。

  4. 将新修改的 DataFrame 分配给fire_ts_df

这些查询结果包含三个新列:

+-------------------+-------------------+-------------------+
|IncidentDate       |OnWatchDate        |AvailableDtTS      |
+-------------------+-------------------+-------------------+
|2002-01-11 00:00:00|2002-01-10 00:00:00|2002-01-11 01:58:43|
|2002-01-11 00:00:00|2002-01-10 00:00:00|2002-01-11 02:10:17|
|2002-01-11 00:00:00|2002-01-10 00:00:00|2002-01-11 01:47:00|
|2002-01-11 00:00:00|2002-01-10 00:00:00|2002-01-11 01:51:54|
|2002-01-11 00:00:00|2002-01-10 00:00:00|2002-01-11 01:47:00|
+-------------------+-------------------+-------------------+
only showing top 5 rows

现在我们已经修改了日期,我们可以使用 spark.sql.functions 中的函数如 month()year()day() 进行查询,以进一步探索我们的数据。我们可以找出最近七天内记录了多少呼叫,或者我们可以查看数据集中包含了多少年的消防部门呼叫数据:

# In Python
(fire_ts_df
  .select(year('IncidentDate'))
  .distinct()
  .orderBy(year('IncidentDate'))
  .show())
// In Scala
fireTsDF
  .select(year($"IncidentDate"))
  .distinct()
  .orderBy(year($"IncidentDate"))
  .show()
+------------------+
|year(IncidentDate)|
+------------------+
|              2000|
|              2001|
|              2002|
|              2003|
|              2004|
|              2005|
|              2006|
|              2007|
|              2008|
|              2009|
|              2010|
|              2011|
|              2012|
|              2013|
|              2014|
|              2015|
|              2016|
|              2017|
|              2018|
+------------------+

到目前为止,在本节中,我们已经探讨了许多常见的数据操作:读写 DataFrames;定义模式并在读取 DataFrame 时使用它;将 DataFrame 保存为 Parquet 文件或表;从现有的 DataFrame 投影和过滤选定的列;以及修改、重命名和删除列。

最后一个常见操作是按列中的值对数据进行分组,并以某种方式聚合数据,比如简单地计数。这种按组和计数的模式和投影、过滤一样常见。让我们试一试。

聚合

如果我们想知道最常见的火警呼叫类型是什么,或者哪些邮政编码占了大多数呼叫?这类问题在数据分析和探索中很常见。

一些在 DataFrames 上的转换和操作,如 groupBy()orderBy()count(),提供了按列名聚合,然后在它们之间聚合计数的能力。

注意

对于计划对其进行频繁或重复查询的更大的 DataFrame,您可以从缓存中受益。我们将在后续章节中介绍 DataFrame 缓存策略及其好处。

让我们先来回答第一个问题:什么是最常见的火警呼叫类型?

# In Python
(fire_ts_df
  .select("CallType")
  .where(col("CallType").isNotNull())
  .groupBy("CallType")
  .count()
  .orderBy("count", ascending=False)
  .show(n=10, truncate=False))
// In Scala 
fireTsDF
  .select("CallType")
  .where(col("CallType").isNotNull)
  .groupBy("CallType")
  .count()
  .orderBy(desc("count"))
  .show(10, false)

+-------------------------------+-------+
|CallType                       |count  |
+-------------------------------+-------+
|Medical Incident               |2843475|
|Structure Fire                 |578998 |
|Alarms                         |483518 |
|Traffic Collision              |175507 |
|Citizen Assist / Service Call  |65360  |
|Other                          |56961  |
|Outside Fire                   |51603  |
|Vehicle Fire                   |20939  |
|Water Rescue                   |20037  |
|Gas Leak (Natural and LP Gases)|17284  |
+-------------------------------+-------+

根据这个输出,我们可以得出最常见的呼叫类型是医疗事件。

注意

DataFrame API 还提供了 collect() 方法,但对于非常大的 DataFrame 而言,这是资源密集型(昂贵的)且危险的,因为它可能引起内存溢出(OOM)异常。不像 count() 返回给驱动程序一个单一的数字,collect() 返回整个 DataFrame 或 Dataset 中所有 Row 对象的集合。如果你想看看一些 Row 记录,最好使用 take(*n*),它只会返回 DataFrame 的前 *n*Row 对象。

其他常见的 DataFrame 操作

除了我们已经看到的所有操作,DataFrame API 还提供了描述性统计方法,如 min()max()sum()avg()。让我们看一些示例,展示如何使用我们的 SF Fire Department 数据集计算它们。

在这里,我们计算了数据集中所有火警呼叫的警报总数、平均响应时间以及最小和最大响应时间,以 Pythonic 的方式导入 PySpark 函数,以避免与内置 Python 函数冲突:

# In Python
import pyspark.sql.functions as F
(fire_ts_df
  .select(F.sum("NumAlarms"), F.avg("ResponseDelayedinMins"),
    F.min("ResponseDelayedinMins"), F.max("ResponseDelayedinMins"))
  .show())
// In Scala
import org.apache.spark.sql.{functions => F}
fireTsDF
  .select(F.sum("NumAlarms"), F.avg("ResponseDelayedinMins"), 
  F.min("ResponseDelayedinMins"), F.max("ResponseDelayedinMins"))
  .show()

+--------------+--------------------------+--------------------------+---------+
|sum(NumAlarms)|avg(ResponseDelayedinMins)|min(ResponseDelayedinMins)|max(...) |
+--------------+--------------------------+--------------------------+---------+
|       4403441|         3.902170335891614|               0.016666668|1879.6167|
+--------------+--------------------------+--------------------------+---------+

对于数据科学工作负载中常见的更高级的统计需求,请阅读关于诸如 stat()describe()correlation()covariance()sampleBy()approxQuantile()frequentItems() 等方法的 API 文档。

正如您所看到的,使用 DataFrame 的高级 API 和 DSL 运算符可以轻松组合和链式表达查询。如果我们尝试使用 RDD 进行相同操作,代码的可读性和比较的不透明性将难以想象!

端到端 DataFrame 示例

旧金山消防局公共数据集上的探索性数据分析、ETL 和常见数据操作有许多可能性,超出我们在此展示的内容。

为了简洁起见,我们不会在这里包含所有示例代码,但书籍的 GitHub 仓库 提供了 Python 和 Scala 笔记本,供您尝试使用此数据集完成端到端的 DataFrame 示例。笔记本探索并回答以下您可能会问到的常见问题,使用 DataFrame API 和 DSL 关系运算符:

  • 2018 年所有不同类型的火警呼叫是什么?

  • 2018 年哪些月份的火警呼叫次数最高?

  • 2018 年旧金山哪个社区产生了最多的火警呼叫?

  • 2018 年哪些社区对火警响应时间最差?

  • 2018 年的哪个周有最多的火警呼叫?

  • 社区、邮政编码和火警呼叫次数之间是否存在相关性?

  • 我们如何使用 Parquet 文件或 SQL 表存储这些数据并读取它?

到目前为止,我们已广泛讨论了 DataFrame API,这是涵盖 Spark MLlib 和 Structured Streaming 组件的结构化 API 之一,我们将在本书后面进行讨论。

接下来,我们将把焦点转向 Dataset API,并探索这两个 API 如何为开发者提供统一的结构化接口来编程 Spark。然后,我们将检查 RDD、DataFrame 和 Dataset API 之间的关系,并帮助您确定何时以及为什么使用哪个 API。

Dataset API

正如本章前面所述,Spark 2.0 统一了 DataFrame 和 Dataset API 作为具有类似接口的结构化 API,以便开发者只需学习一组 API。数据集具有两种特性:类型无类型 的 API,如 图 3-1 所示。

Apache Spark 中的结构化 API

图 3-1. Apache Spark 中的结构化 API

从概念上讲,在 Scala 中,您可以将 DataFrame 看作是一组通用对象的别名,即 Dataset[Row],其中 Row 是一个通用的无类型 JVM 对象,可能包含不同类型的字段。相比之下,Dataset 是 Scala 中的强类型 JVM 对象集合或 Java 中的类。或者,正如 Dataset 文档 所述,Dataset 是:

一种强类型的领域特定对象集合,可以使用函数式或关系操作并行转换。每个数据集 [在 Scala 中] 还有一个称为 DataFrame 的无类型视图,它是一个Row的数据集。

类型化对象、无类型对象和通用行

在 Spark 支持的语言中,数据集仅在 Java 和 Scala 中有意义,而在 Python 和 R 中只有数据框架有意义。这是因为 Python 和 R 不是编译时类型安全的;类型在执行期间动态推断或分配,而不是在编译时。在 Scala 和 Java 中情况恰恰相反:类型在编译时绑定到变量和对象。然而,在 Scala 中,DataFrame 只是非类型化的 Dataset[Row] 的别名。表 3-6 简要总结了这一点。

表 3-6. Spark 中的类型化和非类型化对象

语言 类型化和非类型化的主要抽象 类型化或非类型化
Scala Dataset[T] 和 DataFrame(Dataset[Row] 的别名) 既有类型又有非类型
Java Dataset<T> Typed
Python DataFrame 通用的 Row 非类型化
R DataFrame 通用的 Row 非类型化

Row 是 Spark 中的一个通用对象类型,保存一个可以使用索引访问的混合类型集合。在内部,Spark 操纵 Row 对象,将它们转换为 表 3-2 和 表 3-3 中涵盖的等效类型。例如,在 Row 的字段中,一个 Int 将分别映射或转换为 Scala 或 Java 中的 IntegerTypeIntegerType(),以及 Python 中的相应类型:

// In Scala
import org.apache.spark.sql.Row 
val row = Row(350, true, "Learning Spark 2E", null)
# In Python
from pyspark.sql import Row
row = Row(350, True, "Learning Spark 2E", None)

使用 Row 对象的索引,您可以使用其公共 getter 方法访问各个字段:

// In Scala `row``.``getInt``(``0``)`
res23: Int = 350
`row``.``getBoolean``(``1``)`
res24: Boolean = true
`row``.``getString``(``2``)`
res25: String = Learning Spark 2E
# In Python
`row``[``0``]`
Out[13]: 350
`row``[``1``]`
Out[14]: True
`row``[``2``]`
Out[15]: 'Learning Spark 2E'

相比之下,类型化对象是 JVM 中实际的 Java 或 Scala 类对象。数据集中的每个元素映射到一个 JVM 对象。

创建数据集

与从数据源创建数据框架一样,创建数据集时必须了解模式。换句话说,您需要知道数据类型。虽然可以通过 JSON 和 CSV 数据推断模式,但对于大型数据集来说,这是资源密集型的(昂贵的)。在 Scala 中创建数据集的最简单方法是使用案例类来指定结果数据集的模式。在 Java 中,使用 JavaBean 类(我们在 第六章 进一步讨论 JavaBean 和 Scala 案例类)。

Scala:案例类

当您希望实例化自己的领域特定对象作为数据集时,您可以在 Scala 中定义一个案例类。例如,让我们看一个来自 JSON 文件的物联网设备(IoT)读数集合的示例(我们在本节的最后端对端示例中使用此文件)。

我们的文件包含一系列 JSON 字符串行,如下所示:

{"device_id": 198164, "device_name": "sensor-pad-198164owomcJZ", "ip": 
"80.55.20.25", "cca2": "PL", "cca3": "POL", "cn": "Poland", "latitude":
53.080000, "longitude": 18.620000, "scale": "Celsius", "temp": 21, 
"humidity": 65, "battery_level": 8, "c02_level": 1408,"lcd": "red", 
"timestamp" :1458081226051}

为了将每个 JSON 条目表示为 DeviceIoTData,一个特定于域的对象,我们可以定义一个 Scala 案例类:

case class DeviceIoTData (battery_level: Long, c02_level: Long, 
cca2: String, cca3: String, cn: String, device_id: Long, 
device_name: String, humidity: Long, ip: String, latitude: Double,
lcd: String, longitude: Double, scale:String, temp: Long, 
timestamp: Long)

一旦定义,我们可以用它来读取文件并将返回的 Dataset[Row] 转换为 Dataset[DeviceIoTData](输出被截断以适应页面):

// In Scala
val ds = spark.read
 .json("/databricks-datasets/learning-spark-v2/iot-devices/iot_devices.json")
 .as[DeviceIoTData]

ds: org.apache.spark.sql.Dataset[DeviceIoTData] = [battery_level...]

ds.show(5, false)

+-------------|---------|----|----|-------------|---------|---+
|battery_level|c02_level|cca2|cca3|cn           |device_id|...|
+-------------|---------|----|----|-------------|---------|---+
|8            |868      |US  |USA |United States|1        |...|
|7            |1473     |NO  |NOR |Norway       |2        |...|
|2            |1556     |IT  |ITA |Italy        |3        |...|
|6            |1080     |US  |USA |United States|4        |...|
|4            |931      |PH  |PHL |Philippines  |5        |...|
+-------------|---------|----|----|-------------|---------|---+
only showing top 5 rows

数据集操作

就像您可以对数据框架执行转换和操作一样,您也可以对数据集执行操作。根据操作的类型,结果会有所不同:

// In Scala
val filterTempDS = ds.filter({d => {d.temp > 30 && d.humidity > 70})

filterTempDS: org.apache.spark.sql.Dataset[DeviceIoTData] = [battery_level...]

filterTempDS.show(5, false)

+-------------|---------|----|----|-------------|---------|---+
|battery_level|c02_level|cca2|cca3|cn           |device_id|...|
+-------------|---------|----|----|-------------|---------|---+
|0            |1466     |US  |USA |United States|17       |...|
|9            |986      |FR  |FRA |France       |48       |...|
|8            |1436     |US  |USA |United States|54       |...|
|4            |1090     |US  |USA |United States|63       |...|
|4            |1072     |PH  |PHL |Philippines  |81       |...|
+-------------|---------|----|----|-------------|---------|---+
only showing top 5 rows

在这个查询中,我们将函数用作数据集方法 filter() 的参数。这是一个具有多个签名的重载方法。我们使用的版本 filter(func: (T) > Boolean): Dataset[T] 接受一个 lambda 函数 func: (T) > Boolean 作为其参数。

Lambda 函数的参数是 DeviceIoTData 类型的 JVM 对象。因此,我们可以使用点(.)符号访问其各个数据字段,就像在 Scala 类或 JavaBean 中一样。

另一个需要注意的是,使用数据帧,您可以将 filter() 条件表达为类 SQL 的 DSL 操作,这些操作是与语言无关的(正如我们在消防调用示例中看到的)。而在使用数据集时,我们使用语言本地的表达式,如 Scala 或 Java 代码。

这里是另一个示例,生成了另一个较小的数据集:

// In Scala
case class DeviceTempByCountry(temp: Long, device_name: String, device_id: Long, 
  cca3: String)
val dsTemp = ds
  .filter(d => {d.temp > 25})
  .map(d => (d.temp, d.device_name, d.device_id, d.cca3))
  .toDF("temp", "device_name", "device_id", "cca3")
  .as[DeviceTempByCountry]
dsTemp.show(5, false)

+----+---------------------+---------+----+
|temp|device_name          |device_id|cca3|
+----+---------------------+---------+----+
|34  |meter-gauge-1xbYRYcj |1        |USA |
|28  |sensor-pad-4mzWkz    |4        |USA |
|27  |sensor-pad-6al7RTAobR|6        |USA |
|27  |sensor-pad-8xUD6pzsQI|8        |JPN |
|26  |sensor-pad-10BsywSYUF|10       |USA |
+----+---------------------+---------+----+
only showing top 5 rows

或者,您可以仅检查数据集的第一行:

val device = dsTemp.first()
println(device)

device: DeviceTempByCountry =
DeviceTempByCountry(34,meter-gauge-1xbYRYcj,1,USA)

或者,您可以使用列名表达相同的查询,然后转换为 Dataset[DeviceTempByCountry]

// In Scala
val dsTemp2 = ds
  .select($"temp", $"device_name", $"device_id", $"device_id", $"cca3")
  .where("temp > 25")
  .as[DeviceTempByCountry]
注意

从语义上讲,select() 类似于前一个查询中的 map(),因为这两个查询都选择字段并生成等效的结果。

总结一下,我们可以在数据集上执行的操作——filter()map()groupBy()select()take() 等——与数据帧上的操作类似。从某种意义上讲,数据集类似于 RDD,因为它们提供了与上述方法相似的接口和编译时安全性,但具有更易于阅读和面向对象的编程接口。

当我们使用数据集时,底层的 Spark SQL 引擎负责处理 JVM 对象的创建、转换、序列化和反序列化。它还通过数据集编码器处理非 Java 堆内存管理。(我们将在第六章详细讨论数据集和内存管理。)

端到端数据集示例

在这个端到端的数据集示例中,您将进行与数据帧示例相似的探索性数据分析、ETL(提取、转换和加载)和数据操作,使用 IoT 数据集。该数据集虽然小而虚构,但我们的主要目标是展示您如何使用数据集表达查询的清晰度以及这些查询的可读性,就像我们使用数据帧时一样。

出于简洁起见,我们不会在此处列出所有示例代码;但是,我们已经在GitHub 仓库中提供了笔记本。笔记本探讨了您可能对此数据集进行的常见操作。使用数据集 API,我们尝试执行以下操作:

  1. 检测电池电量低于阈值的故障设备。

  2. 辨别二氧化碳排放高的有问题的国家。

  3. 计算温度、电池电量、CO2 和湿度的最小值和最大值。

  4. 按平均温度、CO2、湿度和国家进行排序和分组。

数据帧与数据集

现在您可能想知道何时应该使用 DataFrames 或 Datasets。在许多情况下,两者都可以使用,取决于您使用的编程语言,但在某些情况下,一个可能比另一个更可取。以下是一些示例:

  • 如果您希望告诉 Spark 要做什么,而不是 如何做,请使用 DataFrames 或 Datasets。

  • 如果您希望使用丰富的语义、高级抽象和 DSL 操作符,请使用 DataFrames 或 Datasets。

  • 如果您希望严格的编译时类型安全,并且不介意为特定的 Dataset[T] 创建多个 case 类,请使用 Datasets。

  • 如果您的处理需求高级表达式、过滤器、映射、聚合、计算平均值或总和、SQL 查询、列式访问或在半结构化数据上使用关系运算符,请使用 DataFrames 或 Datasets。

  • 如果您的处理要求类似于 SQL 查询的关系转换,请使用 DataFrames。

  • 如果您希望利用和受益于 Tungsten 的高效序列化与编码器,请使用 Datasets

  • 如果您希望统一、优化代码并简化 Spark 组件间的 API 使用,请使用 DataFrames。

  • 如果您是 R 用户,请使用 DataFrames。

  • 如果您是 Python 用户,请使用 DataFrames,并在需要更多控制时切换到 RDDs。

  • 如果您希望获得空间和速度效率,请使用 DataFrames。

  • 如果您希望在编译时捕获错误而不是在运行时,请选择适当的 API,如 图 3-2

使用结构化 API 检测到错误时

图 3-2. 使用结构化 API 检测到错误时。

何时使用 RDDs

您可能会问:RDDs 是否被降为二等公民?它们是否已经被弃用?答案是坚定的 !RDD API 将继续得到支持,尽管在 Spark 2.x 和 Spark 3.0 中所有未来的开发工作将继续采用 DataFrame 接口和语义,而不是使用 RDDs。

有一些情况下,您需要考虑使用 RDDs,例如:

  • 使用第三方包编写的 RDDs

  • 可以放弃使用 DataFrames 和 Datasets 提供的代码优化、有效的空间利用和性能优势。

  • 想要精确指导 Spark 如何进行 查询。

此外,您可以通过简单的 API 方法调用 df.rdd 在 DataFrames 或 Datasets 之间无缝移动(但请注意,这样做会有成本,并且应避免除非必要)。毕竟,DataFrames 和 Datasets 是建立在 RDDs 之上的,并且它们在整个阶段代码生成期间被分解为紧凑的 RDD 代码,我们将在下一节讨论此问题。

最后,前面的章节介绍了 Spark 中的结构化 API 如何使开发人员能够使用简单友好的 API 在结构化数据上编写表达性查询。换句话说,您告诉 Spark 要做什么,而不是 如何做,使用高级操作,它会确保构建查询的最有效方式并为您生成紧凑的代码。

构建高效查询并生成紧凑代码的过程是 Spark SQL 引擎的工作。这是我们所关注的结构化 API 构建的基础。现在让我们来看看引擎的内部工作原理。

Spark SQL 和底层引擎

在编程层面上,Spark SQL 允许开发人员对具有模式的结构化数据发出符合 ANSI SQL:2003 兼容的查询。自 Spark 1.3 引入以来,Spark SQL 已经发展成为一个重要的引擎,许多高级结构化功能都建立在其之上。除了允许您在数据上发出类似 SQL 的查询之外,Spark SQL 引擎还:

  • 在 Java、Scala、Python 和 R 中统一 Spark 组件,并允许对 DataFrame/Dataset 进行抽象化,从而简化了处理结构化数据集。

  • 连接到 Apache Hive 元存储和表。

  • 从结构化文件格式(JSON、CSV、文本、Avro、Parquet、ORC 等)中读取和写入具有特定模式的结构化数据,并将数据转换为临时表。

  • 提供交互式的 Spark SQL Shell 用于快速数据探索。

  • 通过标准数据库 JDBC/ODBC 连接器提供与外部工具的桥接。

  • 为 JVM 生成优化的查询计划和紧凑代码,用于最终执行。

图 3-3 显示了 Spark SQL 与之交互以实现所有这些功能的组件。

Spark SQL 及其堆栈

图 3-3. Spark SQL 及其堆栈

Spark SQL 引擎的核心是 Catalyst 优化器和 Project Tungsten。这两者共同支持高级的 DataFrame 和 Dataset API 以及 SQL 查询。我们将在第六章详细讨论 Tungsten;现在让我们更仔细地看看优化器。

Catalyst 优化器

Catalyst 优化器接受计算查询并将其转换为执行计划。它经历四个转换阶段,如图 3-4所示。

  1. 分析

  2. 逻辑优化

  3. 物理计划

  4. 代码生成

Spark 计算的四阶段旅程

图 3-4. Spark 计算的四阶段旅程

例如,考虑我们在第二章的 M&Ms 示例中的一个查询。以下两个示例代码块将经历相同的过程,最终得到类似的查询计划和相同的执行字节码。换句话说,无论您使用哪种语言,您的计算都经历相同的过程,并且生成的字节码可能是相同的:

# In Python
count_mnm_df = (mnm_df
  .select("State", "Color", "Count") 
  .groupBy("State", "Color") 
  .agg(count("Count") 
  .alias("Total")) 
  .orderBy("Total", ascending=False))
-- In SQL
SELECT State, Color, Count, sum(Count) AS Total
FROM MNM_TABLE_NAME
GROUP BY State, Color, Count
ORDER BY Total DESC

要查看 Python 代码经历的不同阶段 您可以在 DataFrame 上使用count_mnm_df.explain(True)方法。或者,要查看不同的逻辑和物理计划,在 Scala 中可以调用df.queryExecution.logicaldf.queryExecution.optimizedPlan。(在第七章中,我们将讨论更多关于调整和调试 Spark 以及如何阅读查询计划的内容。)这给出了以下输出:

`count_mnm_df``.``explain``(``True``)`

== Parsed Logical Plan ==
'Sort ['Total DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
   +- Project [State#10, Color#11, Count#12]
      +- Relation[State#10,Color#11,Count#12] csv

== Analyzed Logical Plan ==
State: string, Color: string, Total: bigint
Sort [Total#24L DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
   +- Project [State#10, Color#11, Count#12]
      +- Relation[State#10,Color#11,Count#12] csv

== Optimized Logical Plan ==
Sort [Total#24L DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
   +- Relation[State#10,Color#11,Count#12] csv

== Physical Plan ==
*(3) Sort [Total#24L DESC NULLS LAST], true, 0
+- Exchange rangepartitioning(Total#24L DESC NULLS LAST, 200)
   +- *(2) HashAggregate(keys=[State#10, Color#11], functions=[count(Count#12)],
output=[State#10, Color#11, Total#24L])
      +- Exchange hashpartitioning(State#10, Color#11, 200)
         +- *(1) HashAggregate(keys=[State#10, Color#11],
functions=[partial_count(Count#12)], output=[State#10, Color#11, count#29L])
            +- *(1) FileScan csv [State#10,Color#11,Count#12] Batched: false,
Format: CSV, Location:
InMemoryFileIndex[file:/Users/jules/gits/LearningSpark2.0/chapter2/py/src/...
dataset.csv], PartitionFilters: [], PushedFilters: [], ReadSchema:
struct<State:string,Color:string,Count:int>

让我们考虑另一个 DataFrame 计算的例子。以下 Scala 代码经历了类似的旅程,底层引擎优化其逻辑和物理计划:

// In Scala
// Users DataFrame read from a Parquet table
val usersDF  = ...
// Events DataFrame read from a Parquet table
val eventsDF = ...
// Join two DataFrames
val joinedDF = users
  .join(events, users("id") === events("uid"))
  .filter(events("date") > "2015-01-01")

经过初步分析阶段后,查询计划由 Catalyst 优化器转换和重排,如图 3-5 所示。

一个特定查询转换的示例

图 3-5. 一个特定查询转换的示例

让我们逐个讨论四个查询优化阶段..

第 1 阶段:分析

Spark SQL 引擎首先为 SQL 或 DataFrame 查询生成一个抽象语法树(AST)。在此初始阶段,通过查询内部的Catalog解析任何列或表名,这是 Spark SQL 的程序接口,保存列名、数据类型、函数、表、数据库等名称的列表。一旦它们都被成功解析,查询将进入下一个阶段。

第 2 阶段:逻辑优化

正如图 3-4 所示,此阶段包括两个内部阶段。应用标准基于规则的优化方法,Catalyst 优化器首先构建一组多个计划,然后使用其基于成本的优化器 (CBO)为每个计划分配成本。这些计划被布置为操作树(如图 3-5 中所示);它们可能包括常量折叠、谓词下推、投影修剪、布尔表达式简化等过程。这个逻辑计划是物理计划的输入。

第 3 阶段:物理规划

在这个阶段,Spark SQL 为选定的逻辑计划生成了一个最优的物理计划,使用与 Spark 执行引擎中可用的物理操作符相匹配的操作符。

第 4 阶段:代码生成

查询优化的最后阶段涉及生成高效的 Java 字节码,以在每台机器上运行。由于 Spark SQL 可以操作内存中加载的数据集,Spark 可以利用最先进的编译器技术来进行代码生成以加快执行速度。换句话说,它充当编译器。项目 Tungsten 在此处发挥了作用,促进了整个阶段的代码生成。

什么是整阶段代码生成?它是一个物理查询优化阶段,将整个查询折叠成一个单一函数,消除了虚拟函数调用,并利用 CPU 寄存器处理中间数据。第二代 Tungsten 引擎,即 Spark 2.0 引入的,使用这种方法为最终执行生成紧凑的 RDD 代码。这种精简的策略显著提高了 CPU 效率和性能

注意

我们在概念层面上讨论了 Spark SQL 引擎的工作原理,其主要组成部分为:Catalyst 优化器和 Project Tungsten。本书不涵盖内部技术工作细节;但是,对于好奇的读者,我们鼓励你查阅文中的参考资料,进行深入的技术讨论。

概要

在本章中,我们深入探讨了 Spark 结构化 API,首先回顾了 Spark 中结构的历史和优点。

通过说明常见的数据操作和代码示例,我们展示了高级 DataFrame 和 Dataset API 比低级 RDD API 更具表达力和直观性。结构化 API 专为简化大数据集的处理而设计,提供了领域特定的操作符,增强了代码的清晰度和表现力。

我们探讨了根据使用情景选择 RDD、DataFrame 和 Dataset 的时机。

最后,我们深入了解了 Spark SQL 引擎的主要组件——Catalyst 优化器和 Project Tungsten——是如何支持结构化的高级 API 和 DSL 操作符的。正如你所见,无论你使用 Spark 支持的哪种语言,Spark 查询都经历相同的优化过程,从逻辑和物理计划的构建到最终紧凑的代码生成。

本章的概念和代码示例为接下来的两章奠定了基础,我们将进一步阐述 DataFrame、Dataset 和 Spark SQL 之间无缝互操作的能力。

¹ 这些公共数据可以在oreil.ly/iDzQK获取。

² 原始数据集有超过 60 列。我们删除了一些不必要的列,移除了包含空值或无效值的记录,并增加了额外的延迟列。

第四章:Spark SQL 和 DataFrame:内置数据源简介

在上一章中,我们解释了 Spark 结构化的演变和其结构的合理性。特别是,我们讨论了 Spark SQL 引擎如何为高级 DataFrame 和 Dataset API 提供统一的基础。现在,我们将继续讨论 DataFrame,并探索其与 Spark SQL 的互操作性。

本章和下一章还将探讨 Spark SQL 如何与图中显示的一些外部组件接口交互(参见图 4-1)。

特别是,Spark SQL:

  • 提供了高级结构化 API 的引擎,我们在第三章中探索过。

  • 可以读取和写入多种结构化格式的数据(例如 JSON、Hive 表、Parquet、Avro、ORC、CSV)。

  • 允许您使用来自外部商业智能(BI)数据源的 JDBC/ODBC 连接器(如 Tableau、Power BI、Talend)或来自 RDBMS(如 MySQL 和 PostgreSQL)查询数据。

  • 为 Spark 应用程序中的表或视图存储的结构化数据提供了编程接口。

  • 提供了一个交互式 shell,用于在您的结构化数据上发出 SQL 查询。

  • 支持ANSI SQL:2003兼容命令和HiveQL

Spark SQL 连接器和数据源

图 4-1. Spark SQL 连接器和数据源

让我们从如何在 Spark 应用程序中使用 Spark SQL 开始。

在 Spark 应用程序中使用 Spark SQL

SparkSession,在 Spark 2.0 中引入,为使用结构化 API 编程 Spark 提供了一个统一入口点。您可以使用 SparkSession 访问 Spark 功能:只需导入该类并在代码中创建一个实例。

要发出任何 SQL 查询,请在 SparkSession 实例 spark 上使用 sql() 方法,例如 spark.sql("SELECT * FROM myTableName")。以这种方式执行的所有 spark.sql 查询都会返回一个 DataFrame,您可以在其上执行进一步的 Spark 操作,如果您愿意——就像我们在第三章中探索的那些操作,以及您将在本章和下一章中学到的操作。

基本查询示例

在本节中,我们将通过对航空准时表现和航班延误原因数据集的几个查询示例来进行讲解,该数据集包含有关美国航班的日期、延误、距离、起点和终点的数据。它以 CSV 文件形式提供,包含超过一百万条记录。使用一个模式,我们将数据读入一个 DataFrame 并将该 DataFrame 注册为临时视图(稍后将详细讨论临时视图),以便我们可以使用 SQL 进行查询。

查询示例以代码片段提供,并且包含所有这里展示的代码的 Python 和 Scala 笔记本可以在本书的 GitHub 仓库 中找到。这些示例将为您展示如何通过 spark.sql 编程接口 在 Spark 应用程序中使用 SQL。与 DataFrame API 一样,这个接口以其声明性的风格允许您查询结构化数据在您的 Spark 应用程序中。

通常,在独立的 Spark 应用程序中,您会手动创建一个 SparkSession 实例,就像下面的例子所示。然而,在 Spark shell(或 Databricks 笔记本)中,SparkSession 会自动为您创建,并且可以通过适当命名的变量 spark 访问。

让我们首先将数据集读取到临时视图中:

// In Scala
import org.apache.spark.sql.SparkSession            
val spark = SparkSession
  .builder
  .appName("SparkSQLExampleApp")
  .getOrCreate()

// Path to data set 
val csvFile="/databricks-datasets/learning-spark-v2/flights/departuredelays.csv"

// Read and create a temporary view
// Infer schema (note that for larger files you may want to specify the schema)
val df = spark.read.format("csv")
  .option("inferSchema", "true")
  .option("header", "true")
  .load(csvFile)
// Create a temporary view
df.createOrReplaceTempView("us_delay_flights_tbl")
# In Python
from pyspark.sql import SparkSession        
# Create a SparkSession
spark = (SparkSession
  .builder
  .appName("SparkSQLExampleApp")
  .getOrCreate())

# Path to data set
csv_file = "/databricks-datasets/learning-spark-v2/flights/departuredelays.csv"

# Read and create a temporary view
# Infer schema (note that for larger files you 
# may want to specify the schema)
df = (spark.read.format("csv")
  .option("inferSchema", "true")
  .option("header", "true")
  .load(csv_file))
df.createOrReplaceTempView("us_delay_flights_tbl")
注意

如果你想指定一个模式(schema),你可以使用一个 DDL 格式的字符串。例如:

// In Scala
val schema = "date STRING, delay INT, distance INT, 
 origin STRING, destination STRING"
# In Python
schema = "`date` STRING, `delay` INT, `distance` INT, 
 `origin` STRING, `destination` STRING"

现在我们有了一个临时视图,我们可以使用 Spark SQL 发出 SQL 查询。这些查询与您可能在 MySQL 或 PostgreSQL 数据库中针对 SQL 表发出的查询没有什么不同。这里的重点是显示 Spark SQL 提供了符合 ANSI:2003 标准的 SQL 接口,并演示 SQL 和 DataFrames 之间的互操作性。

美国航班延误数据集有五列:

  • date 列包含类似 02190925 的字符串。当转换时,这映射为 02-19 09:25 am

  • delay 列给出计划和实际起飞时间之间的延迟时间(分钟)。提前起飞显示为负数。

  • distance 列给出起飞机场到目的地机场的距离(英里)。

  • origin 列包含起飞机场的 IATA 机场代码。

  • destination 列包含目的地的 IATA 机场代码。

考虑到这一点,让我们尝试一些针对这个数据集的示例查询。

首先,我们将找到所有距离超过 1,000 英里的航班:

spark.sql("""SELECT distance, origin, destination 
FROM us_delay_flights_tbl WHERE distance > 1000 
ORDER BY distance DESC""").show(10)

+--------+------+-----------+
|distance|origin|destination|
+--------+------+-----------+
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
+--------+------+-----------+
only showing top 10 rows

正如结果所示,所有最长的航班都是在檀香山(HNL)和纽约(JFK)之间。接下来,我们将找到所有从旧金山(SFO)到芝加哥(ORD)的航班,至少延误两小时:

spark.sql("""SELECT date, delay, origin, destination 
FROM us_delay_flights_tbl 
WHERE delay > 120 AND ORIGIN = 'SFO' AND DESTINATION = 'ORD' 
ORDER by delay DESC""").show(10)

+--------+-----+------+-----------+
|date    |delay|origin|destination|
+--------+-----+------+-----------+
|02190925|1638 |SFO   |ORD        |
|01031755|396  |SFO   |ORD        |
|01022330|326  |SFO   |ORD        |
|01051205|320  |SFO   |ORD        |
|01190925|297  |SFO   |ORD        |
|02171115|296  |SFO   |ORD        |
|01071040|279  |SFO   |ORD        |
|01051550|274  |SFO   |ORD        |
|03120730|266  |SFO   |ORD        |
|01261104|258  |SFO   |ORD        |
+--------+-----+------+-----------+
only showing top 10 rows

看起来在这两个城市之间有许多显著延误的航班,不同的日期。(作为练习,将 date 列转换为可读格式,并找出这些延误最常见的日期或月份。这些延误是否与冬季月份或节假日有关?)

让我们尝试一个更复杂的查询,其中我们在 SQL 中使用 CASE 子句。在下面的示例中,我们希望标记所有美国航班,无论起飞地和目的地如何,都显示它们经历的延误情况:非常长的延误(> 6 小时),长延误(2–6 小时),等等。我们将在一个名为 Flight_Delays 的新列中添加这些人类可读的标签:

spark.sql("""SELECT delay, origin, destination,
              CASE
                  WHEN delay > 360 THEN 'Very Long Delays'
                  WHEN delay > 120 AND delay < 360 THEN 'Long Delays'
                  WHEN delay > 60 AND delay < 120 THEN 'Short Delays'
                  WHEN delay > 0 and delay < 60  THEN  'Tolerable Delays'
                  WHEN delay = 0 THEN 'No Delays'
                  ELSE 'Early'
               END AS Flight_Delays
               FROM us_delay_flights_tbl
               ORDER BY origin, delay DESC""").show(10)

+-----+------+-----------+-------------+
|delay|origin|destination|Flight_Delays|
+-----+------+-----------+-------------+
|333  |ABE   |ATL        |Long Delays  |
|305  |ABE   |ATL        |Long Delays  |
|275  |ABE   |ATL        |Long Delays  |
|257  |ABE   |ATL        |Long Delays  |
|247  |ABE   |DTW        |Long Delays  |
|247  |ABE   |ATL        |Long Delays  |
|219  |ABE   |ORD        |Long Delays  |
|211  |ABE   |ATL        |Long Delays  |
|197  |ABE   |DTW        |Long Delays  |
|192  |ABE   |ORD        |Long Delays  |
+-----+------+-----------+-------------+
only showing top 10 rows

与 DataFrame 和 Dataset API 一样,通过 spark.sql 接口,您可以进行像前一章中探索的常见数据分析操作。这些计算经历了相同的 Spark SQL 引擎过程(详见 “Catalyst 优化器” 在 第三章 中的详细说明),从而给您带来相同的结果。

前面三个 SQL 查询可以用等效的 DataFrame API 查询来表示。例如,第一个查询可以在 Python 的 DataFrame API 中表示为:

# In Python
from pyspark.sql.functions import col, desc
(df.select("distance", "origin", "destination")
  .where(col("distance") > 1000)
  .orderBy(desc("distance"))).show(10)

# Or
(df.select("distance", "origin", "destination")
  .where("distance > 1000")
  .orderBy("distance", ascending=False).show(10))

这将产生与 SQL 查询相同的结果:

+--------+------+-----------+
|distance|origin|destination|
+--------+------+-----------+
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
|4330    |HNL   |JFK        |
+--------+------+-----------+
only showing top 10 rows

作为练习,请尝试将另外两个 SQL 查询转换为使用 DataFrame API 的形式。

正如这些示例所示,使用 Spark SQL 接口查询数据类似于向关系数据库表写入常规 SQL 查询。尽管查询使用 SQL 编写,但您可以感受到与 DataFrame API 操作的可读性和语义的相似性,您在 第三章 中已经遇到并将在下一章进一步探索。

为了使您能够像前面的示例中展示的那样查询结构化数据,Spark 管理创建和管理视图和表的所有复杂性,无论是在内存中还是磁盘上。这将引导我们进入下一个主题:如何创建和管理表和视图。

SQL 表和视图

表存储数据。在 Spark 中,每个表都有相关的元数据,包括表和数据的信息:模式、描述、表名、数据库名、列名、分区、实际数据存储的物理位置等等。所有这些信息都存储在中央元数据存储中。

与为 Spark 表单独设置元数据存储不同,默认情况下 Spark 使用 Apache Hive 元数据存储,位置在 /user/hive/warehouse,用于持久化表的所有元数据。然而,您可以通过设置 Spark 配置变量 spark.sql.warehouse.dir 来更改默认位置,可以设置为本地或外部分布式存储。

托管与非托管表

Spark 允许您创建两种类型的表:托管表和非托管表。对于 托管 表,Spark 管理文件存储中的元数据和数据。这可以是本地文件系统、HDFS 或对象存储(如 Amazon S3 或 Azure Blob)。对于 非托管 表,Spark 只管理元数据,而您需要自己在外部 数据源(例如 Cassandra)中管理数据。

对于托管表,因为 Spark 管理一切,例如 SQL 命令 DROP TABLE table_name 会同时删除元数据和数据。对于非托管表,同样的命令只会删除元数据,而不会删除实际数据。我们将在下一节看一些创建托管和非托管表的示例。

创建 SQL 数据库和表

表位于数据库中。默认情况下,Spark 在 default 数据库下创建表。要创建自己的数据库名称,您可以从您的 Spark 应用程序或笔记本中发出 SQL 命令。使用美国航班延误数据集,让我们创建一个托管表和一个非托管表。首先,我们将创建一个名为 learn_spark_db 的数据库,并告诉 Spark 我们要使用该数据库:

// In Scala/Python
spark.sql("CREATE DATABASE learn_spark_db")
spark.sql("USE learn_spark_db")

从此处开始,我们在应用程序中发出的任何命令来创建表都将导致这些表被创建在此数据库中,并位于数据库名称 learn_spark_db 下。

创建一个托管表

要在 learn_spark_db 数据库中创建一个托管表,可以执行如下的 SQL 查询:

// In Scala/Python
spark.sql("CREATE TABLE managed_us_delay_flights_tbl (date STRING, delay INT, 
 distance INT, origin STRING, destination STRING)")

您可以使用 DataFrame API 来执行相同的操作,如下所示:

# In Python
# Path to our US flight delays CSV file 
csv_file = "/databricks-datasets/learning-spark-v2/flights/departuredelays.csv"
# Schema as defined in the preceding example
schema="date STRING, delay INT, distance INT, origin STRING, destination STRING"
flights_df = spark.read.csv(csv_file, schema=schema)
flights_df.write.saveAsTable("managed_us_delay_flights_tbl")

这两个语句将在 learn_spark_db 数据库中创建托管表 us_delay_flights_tbl

创建一个非托管表

相反,您可以从您自己的数据源(例如存储在文件存储中的 Parquet、CSV 或 JSON 文件)创建非托管表,这些表对您的 Spark 应用程序可访问。

要从诸如 CSV 文件之类的数据源创建一个非托管表,在 SQL 中使用:

spark.sql("""CREATE TABLE us_delay_flights_tbl(date STRING, delay INT, 
  distance INT, origin STRING, destination STRING) 
  USING csv OPTIONS (PATH 
  '/databricks-datasets/learning-spark-v2/flights/departuredelays.csv')""")

在 DataFrame API 内使用:

(flights_df
  .write
  .option("path", "/tmp/data/us_flights_delay")
  .saveAsTable("us_delay_flights_tbl"))
注意

为了让您能够探索这些示例,我们已经创建了 Python 和 Scala 示例笔记本,您可以在本书的 GitHub 仓库 中找到。

创建视图

除了创建表外,Spark 还可以在现有表的基础上创建视图。视图可以是全局的(在给定集群上的所有 SparkSession 中可见)或会话作用域的(仅对单个 SparkSession 可见),它们是临时的:在 Spark 应用程序终止后会消失。

创建视图 与在数据库内创建表具有类似的语法。创建视图后,您可以像查询表一样查询它。视图和表的区别在于视图实际上不保存数据;表在 Spark 应用程序终止后保持存在,但视图会消失。

您可以使用 SQL 从现有表创建视图。例如,如果您希望仅处理具有纽约(JFK)和旧金山(SFO)起飞机场的美国航班延误数据集的子集,则以下查询将创建仅由该表切片组成的全局临时视图和临时视图:

-- In SQL
CREATE OR REPLACE GLOBAL TEMP VIEW us_origin_airport_SFO_global_tmp_view AS
  SELECT date, delay, origin, destination from us_delay_flights_tbl WHERE 
  origin = 'SFO';

CREATE OR REPLACE TEMP VIEW us_origin_airport_JFK_tmp_view AS
  SELECT date, delay, origin, destination from us_delay_flights_tbl WHERE 
  origin = 'JFK'

你可以使用 DataFrame API 来完成相同的事情:

# In Python
df_sfo = spark.sql("SELECT date, delay, origin, destination FROM 
  us_delay_flights_tbl WHERE origin = 'SFO'")
df_jfk = spark.sql("SELECT date, delay, origin, destination FROM 
  us_delay_flights_tbl WHERE origin = 'JFK'")

# Create a temporary and global temporary view
df_sfo.createOrReplaceGlobalTempView("us_origin_airport_SFO_global_tmp_view")
df_jfk.createOrReplaceTempView("us_origin_airport_JFK_tmp_view")

一旦创建了这些视图,您可以像对待表一样发出查询。请注意,当访问全局临时视图时,您必须使用前缀 global_temp*.<view_name>*,因为 Spark 在全局临时数据库 global_temp 中创建全局临时视图。例如:

-- In SQL 
SELECT * FROM global_temp.us_origin_airport_SFO_global_tmp_view

相反,您可以访问普通的临时视图,而无需使用 global_temp 前缀:

-- In SQL 
SELECT * FROM us_origin_airport_JFK_tmp_view
// In Scala/Python
spark.read.table("us_origin_airport_JFK_tmp_view")
// Or
spark.sql("SELECT * FROM us_origin_airport_JFK_tmp_view")

您还可以像删除表一样删除视图:

-- In SQL
DROP VIEW IF EXISTS us_origin_airport_SFO_global_tmp_view;
DROP VIEW IF EXISTS us_origin_airport_JFK_tmp_view
// In Scala/Python
spark.catalog.dropGlobalTempView("us_origin_airport_SFO_global_tmp_view")
spark.catalog.dropTempView("us_origin_airport_JFK_tmp_view")

临时视图与全局临时视图

临时视图和全局临时视图之间的差异微妙,对于刚接触 Spark 的开发人员来说可能会造成轻微混淆。临时视图绑定在 Spark 应用程序中的单个SparkSession中。相比之下,全局临时视图在 Spark 应用程序中的多个SparkSession中可见。是的,您可以在单个 Spark 应用程序中创建多个SparkSessions——例如,在您想要访问(和合并)不共享相同 Hive 元存储配置的两个不同SparkSessions 的数据时,这可能会很方便。

查看元数据

正如之前提到的,Spark 管理每个托管或非托管表相关的元数据。这些数据被捕获在Catalog中,这是 Spark SQL 中用于存储元数据的高级抽象。在 Spark 2.x 中,Catalog的功能通过新的公共方法进行了扩展,使您能够查看与您的数据库、表和视图相关的元数据。Spark 3.0 扩展了其使用外部catalog的功能(我们在第十二章中简要讨论)。

例如,在 Spark 应用程序中创建SparkSession变量spark后,您可以通过类似以下方法访问所有存储的元数据:

// In Scala/Python
spark.catalog.listDatabases()
spark.catalog.listTables()
spark.catalog.listColumns("us_delay_flights_tbl")

从书的GitHub repo导入笔记本并尝试一下。

缓存 SQL 表

虽然我们将在下一章讨论表缓存策略,但在这里值得一提的是,与 DataFrame 一样,您可以缓存和取消缓存 SQL 表和视图。在Spark 3.0中,除了其他选项外,您还可以指定表为LAZY,这意味着它只有在首次使用时才会被缓存,而不是立即缓存:

-- In SQL CACHE [LAZY] TABLE *`<``table``-``name``>`*
UNCACHE TABLE *`<``table``-``name``>`*

将表读取到 DataFrames 中

数据工程师经常会在他们的常规数据摄取和 ETL 过程中构建数据管道。他们会用经过清洗的数据填充 Spark SQL 数据库和表,以便应用程序在下游消费。

假设你有一个现有的数据库,learn_spark_db,以及一个准备就绪的表,us_delay_flights_tbl。你可以直接使用 SQL 查询该表,并将返回的结果赋给一个 DataFrame:

// In Scala
val usFlightsDF = spark.sql("SELECT * FROM us_delay_flights_tbl")
val usFlightsDF2 = spark.table("us_delay_flights_tbl")
# In Python
us_flights_df = spark.sql("SELECT * FROM us_delay_flights_tbl")
us_flights_df2 = spark.table("us_delay_flights_tbl")

现在,您已经从现有的 Spark SQL 表中读取了一个经过清洗的 DataFrame。您还可以使用 Spark 的内置数据源读取其他格式的数据,从而灵活地与各种常见文件格式交互。

DataFrames 和 SQL 表的数据源

如图 4-1 所示,Spark SQL 提供了与各种数据源交互的接口。它还提供了一套通用方法,用于通过数据源 API从这些数据源读取和写入数据。

在本节中,我们将介绍一些内置数据源、可用文件格式以及加载和写入数据的方式,以及与这些数据源相关的特定选项。但首先,让我们更详细地了解两个高级别的数据源 API 构造,它们决定了您与不同数据源交互的方式:DataFrameReaderDataFrameWriter

DataFrameReader

DataFrameReader是从数据源读取数据到 DataFrame 的核心构造。它具有定义的格式和推荐的使用模式:

DataFrameReader.format(args).option("key", "value").schema(args).load()

在 Spark 中,将方法串联在一起的模式很常见且易于阅读。在我们探索常见数据分析模式时(见第三章),我们已经看到过这种模式。

请注意,只能通过SparkSession实例访问DataFrameReader。也就是说,不能创建DataFrameReader的实例。要获取对它的实例句柄,请使用:

SparkSession.read 
// or 
SparkSession.readStream

当使用read方法时,返回一个DataFrameReader的句柄,用于从静态数据源读取 DataFrame;而使用readStream方法时,则返回一个实例,用于从流式数据源读取。(我们将在本书后面讨论结构化流处理。)

DataFrameReader的每个公共方法的参数取不同的值。表 4-1 列举了这些参数及其支持的子集。

表 4-1. DataFrameReader 方法、参数和选项

方法 参数 描述
format() "parquet", "csv", "txt", "json", "jdbc", "orc", "avro" 如果不指定此方法,则默认为 Parquet 格式或者根据spark.sql.sources.default设置的格式。

| option() | ("mode", {PERMISSIVE &#124; FAILFAST &#124; DROPMALFORMED}) ("inferSchema", {true &#124; false})

("path", "path_file_data_source") | 一系列键/值对和选项。Spark 文档展示了一些示例,并解释了不同模式及其作用。默认模式是PERMISSIVE"inferSchema""mode"选项特定于 JSON 和 CSV 文件格式。 |

schema() DDL StringStructType,例如,'A INT, B STRING'StructType(...) 对于 JSON 或 CSV 格式,可以在option()方法中指定推断模式。通常,为任何格式提供模式可以加快加载速度,并确保数据符合预期的模式。
load() "/path/to/data/source" 数据源的路径。如果在option("path", "...")中指定,则可以为空。

虽然我们不会详尽列举所有不同参数和选项的组合,但Python、Scala、R 和 Java 的文档提供了建议和指导。尽管如此,展示一些示例仍然是值得的:

// In Scala
// Use Parquet 
val file = """/databricks-datasets/learning-spark-v2/flights/summary-
 data/parquet/2010-summary.parquet"""
val df = spark.read.format("parquet").load(file) 
// Use Parquet; you can omit format("parquet") if you wish as it's the default
val df2 = spark.read.load(file)
// Use CSV
val df3 = spark.read.format("csv")
  .option("inferSchema", "true")
  .option("header", "true")
  .option("mode", "PERMISSIVE")
  .load("/databricks-datasets/learning-spark-v2/flights/summary-data/csv/*")
// Use JSON
val df4 = spark.read.format("json")
  .load("/databricks-datasets/learning-spark-v2/flights/summary-data/json/*")
注意

一般来说,从静态 Parquet 数据源读取时不需要模式——Parquet 元数据通常包含模式,因此可以推断出 通常,当从静态的 Parquet 数据源读取时,不需要 schema —— Parquet 的元数据通常包含 schema,因此可以推断出来。然而,对于流式数据源,你需要提供 schema。(我们将在第八章介绍如何从流式数据源读取。)

Parquet 是 Spark 的默认和首选数据源,因为它高效,使用列式存储,并采用快速压缩算法。你将在后面看到额外的好处(例如列式下推),当我们更深入介绍 Catalyst 优化器时。

DataFrameWriter

DataFrameWriter 的功能与其对应方法相反:它将数据保存或写入到指定的内置数据源。与 DataFrameReader 不同,你的实例不是从 SparkSession 获取,而是从你希望保存的 DataFrame 获取。它有一些推荐的使用模式:

DataFrameWriter.format(args)
  .option(args)
  .bucketBy(args)
  .partitionBy(args)
  .save(path)

DataFrameWriter.format(args).option(args).sortBy(args).saveAsTable(table)

要获取实例句柄,使用:

DataFrame.write
// or 
DataFrame.writeStream

DataFrameWriter 的每个方法的参数也会取不同的值。我们在表 4-2 中列出这些参数,并列出了一部分支持的参数。

表 4-2. DataFrameWriter 方法、参数和选项

方法 参数 描述
format() "parquet""csv""txt""json""jdbc""orc""avro",等等。 如果不指定此方法,则默认是 Parquet 或 spark.sql.sources.default 中设置的格式。

| option() | ("mode", {append &#124; overwrite &#124; ignore &#124; error or errorifexists} ) ("mode", {SaveMode.Overwrite &#124; SaveMode.Append, SaveMode.Ignore, SaveMode.ErrorIfExists})

("path", "path_to_write_to") | 一系列键/值对和选项。Spark 文档展示了一些示例。这是一个重载的方法。默认模式选项是 error or errorifexistsSaveMode.ErrorIfExists;如果数据已存在,在运行时会抛出异常。 |

bucketBy() (numBuckets, col, col..., coln) 桶的数量和要按其分桶的列名。使用 Hive 的 bucketing 方案在文件系统上。
save() "/path/to/data/source" 要保存的路径。如果在 option("path", "...") 中指定,这里可以为空。
saveAsTable() "table_name" 要保存到的表名。

下面是一个简短的示例片段,说明方法和参数的使用:

// In Scala
// Use JSON
val location = ... 
df.write.format("json").mode("overwrite").save(location)

Parquet

我们将从 Parquet 开始探索数据源,因为它是 Spark 的默认数据源。Parquet 被许多大数据处理框架和平台支持和广泛使用,是一种开源的列式文件格式,提供许多 I/O 优化(例如压缩,节省存储空间并快速访问数据列)。

由于其高效性和这些优化,建议在转换和清洗数据后,将 DataFrame 保存为 Parquet 格式以供下游消费。(Parquet 也是 Delta Lake 的默认表开放格式,我们将在第九章中介绍。)

将 Parquet 文件读入 DataFrame

Parquet 文件存储在包含数据文件、元数据、若干压缩文件和一些状态文件的目录结构中。页脚中的元数据包含文件格式的版本、模式、列数据(如路径等)等信息。

例如,Parquet 文件目录可能包含一组文件如下所示:

_SUCCESS
_committed_1799640464332036264
_started_1799640464332036264
part-00000-tid-1799640464332036264-91273258-d7ef-4dc7-<...>-c000.snappy.parquet

在目录中可能有一些压缩文件,文件名为part-XXXX(此处显示的名称已缩短以适应页面)。

要将 Parquet 文件读入 DataFrame,只需指定格式和路径:

// In Scala
val file = """/databricks-datasets/learning-spark-v2/flights/summary-data/
 parquet/2010-summary.parquet/"""
val df = spark.read.format("parquet").load(file)
# In Python
file = """/databricks-datasets/learning-spark-v2/flights/summary-data/parquet/
 2010-summary.parquet/"""
df = spark.read.format("parquet").load(file)

除非您从流数据源读取,否则无需提供模式,因为 Parquet 将其保存为其元数据的一部分。

将 Parquet 文件读入 Spark SQL 表

除了将 Parquet 文件读入 Spark DataFrame 之外,您还可以直接使用 SQL 创建 Spark SQL 非托管表或视图:

-- In SQL
CREATE OR REPLACE TEMPORARY VIEW us_delay_flights_tbl
    USING parquet
    OPTIONS (
      path "/databricks-datasets/learning-spark-v2/flights/summary-data/parquet/
 2010-summary.parquet/" )

创建表或视图后,您可以使用 SQL 将数据读入 DataFrame,就像我们在一些早期示例中看到的那样:

// In Scala
spark.sql("SELECT * FROM us_delay_flights_tbl").show()
# In Python
spark.sql("SELECT * FROM us_delay_flights_tbl").show()

这两个操作返回相同的结果:

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 DataFrame 写入 Parquet 文件

在 Spark 中,将 DataFrame 写入表或文件是常见操作。要写入 DataFrame,只需使用本章早些时候介绍的DataFrameWriter方法和参数,并提供保存 Parquet 文件的位置。例如:

// In Scala
df.write.format("parquet")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/parquet/df_parquet")
# In Python
(df.write.format("parquet")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/parquet/df_parquet"))
注意

请记住,Parquet 是默认文件格式。如果不包含format()方法,DataFrame 仍将保存为 Parquet 文件。

这将在指定路径创建一组紧凑且压缩的 Parquet 文件。由于我们在这里选择了 snappy 作为压缩方式,因此我们将获得 snappy 压缩的文件。为简洁起见,本示例仅生成了一个文件;通常会创建十几个文件:

-rw-r--r--  1 jules  wheel    0 May 19 10:58 _SUCCESS
-rw-r--r--  1 jules  wheel  966 May 19 10:58 part-00000-<...>-c000.snappy.parquet

将 DataFrame 写入 Spark SQL 表

将 DataFrame 写入 SQL 表与写入文件一样简单,只需使用saveAsTable()而不是save()。这将创建一个名为us_delay_flights_tbl的托管表:

// In Scala
df.write
  .mode("overwrite")
  .saveAsTable("us_delay_flights_tbl")
# In Python
(df.write
  .mode("overwrite")
  .saveAsTable("us_delay_flights_tbl"))

总之,Parquet 是 Spark 中首选和默认的内置数据源文件格式,并已被许多其他框架采纳。我们建议您在 ETL 和数据摄入过程中使用这种格式。

JSON

JavaScript 对象表示法(JSON)也是一种流行的数据格式。与 XML 相比,它因易于阅读和解析而广受欢迎。它有两种表示格式:单行模式和多行模式。在 Spark 中,两种模式都受支持。

在单行模式下 每行表示一个单独的 JSON 对象,而在多行模式下整个多行对象构成一个单独的 JSON 对象。要在此模式下读取,请在 option() 方法中将 multiLine 设置为 true。

将 JSON 文件读取到 DataFrame 中

您可以像使用 Parquet 文件一样将 JSON 文件读入 DataFrame 中——只需在 format() 方法中指定 "json" 即可:

// In Scala
val file = "/databricks-datasets/learning-spark-v2/flights/summary-data/json/*"
val df = spark.read.format("json").load(file)
# In Python
file = "/databricks-datasets/learning-spark-v2/flights/summary-data/json/*"
df = spark.read.format("json").load(file)

将 JSON 文件读取到 Spark SQL 表中

您还可以像使用 Parquet 一样从 JSON 文件创建 SQL 表:

-- In SQL CREATE OR REPLACE TEMPORARY VIEW `us_delay_flights_tbl`
    USING json
    OPTIONS (
      path  "/databricks-datasets/learning-spark-v2/flights/summary-data/json/*"
    )

创建表格后,您可以像以前一样使用 SQL 将数据读入 DataFrame 中:

// In Scala/Python
spark.sql("SELECT * FROM us_delay_flights_tbl").show()

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |15   |
|United States    |Croatia            |1    |
|United States    |Ireland            |344  |
|Egypt            |United States      |15   |
|United States    |India              |62   |
|United States    |Singapore          |1    |
|United States    |Grenada            |62   |
|Costa Rica       |United States      |588  |
|Senegal          |United States      |40   |
|Moldova          |United States      |1    |
+-----------------+-------------------+-----+
only showing top 10 rows

将 DataFrame 写入 JSON 文件

将 DataFrame 保存为 JSON 文件很简单。指定适当的 DataFrameWriter 方法和参数,并提供保存 JSON 文件的位置:

// In Scala
df.write.format("json")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/json/df_json")
# In Python
(df.write.format("json")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/json/df_json"))

此操作会在指定路径创建一个目录,并填充一组紧凑的 JSON 文件:

-rw-r--r--  1 jules  wheel   0 May 16 14:44 _SUCCESS
-rw-r--r--  1 jules  wheel  71 May 16 14:44 part-00000-<...>-c000.json

JSON 数据源选项

表格 4-3 描述了 DataFrameReaderDataFrameWriter 的常见 JSON 选项。详细清单请参阅文档。

表格 4-3. DataFrameReader 和 DataFrameWriter 的 JSON 选项

属性名称 含义 范围
compression none, uncompressed, bzip2, deflate, gzip, lz4, 或 snappy 使用此压缩编解码器进行写入。请注意,读取将仅从文件扩展名检测压缩或编解码器。 写入
dateFormat yyyy-MM-ddDateTimeFormatter 使用此格式或 Java 的任何 DateTimeFormatter 格式。 读/写
multiLine true, false 使用多行模式。默认为 false(单行模式)。 读取
allowUnquotedFieldNames true, false 允许未引用的 JSON 字段名称。默认为 false 读取

CSV

与普通文本文件一样广泛使用的是这种通用文本文件格式,每个数据或字段由逗号分隔;每行逗号分隔的字段表示一个记录。即使逗号是默认分隔符,您也可以在数据中使用其他分隔符来分隔字段,以避免逗号作为数据的一部分。流行的电子表格可以生成 CSV 文件,因此它在数据和业务分析师中很受欢迎的格式。

将 CSV 文件读取到 DataFrame 中

与其他内置数据源一样,您可以使用 DataFrameReader 方法和参数将 CSV 文件读取到 DataFrame 中:

// In Scala
val file = "/databricks-datasets/learning-spark-v2/flights/summary-data/csv/*"
val schema = "DEST_COUNTRY_NAME STRING, ORIGIN_COUNTRY_NAME STRING, count INT"

val df = spark.read.format("csv")
  .schema(schema)
  .option("header", "true")
  .option("mode", "FAILFAST")     // Exit if any errors
  .option("nullValue", "")        // Replace any null data with quotes
  .load(file)
# In Python
file = "/databricks-datasets/learning-spark-v2/flights/summary-data/csv/*"
schema = "DEST_COUNTRY_NAME STRING, ORIGIN_COUNTRY_NAME STRING, count INT"
df = (spark.read.format("csv")
  .option("header", "true")
  .schema(schema)
  .option("mode", "FAILFAST")  # Exit if any errors
  .option("nullValue", "")     # Replace any null data field with quotes
  .load(file))

将 CSV 文件读取到 Spark SQL 表中

从 CSV 数据源创建 SQL 表与使用 Parquet 或 JSON 没有区别:

-- In SQL
CREATE OR REPLACE TEMPORARY VIEW us_delay_flights_tbl
    USING csv
    OPTIONS (
      path "/databricks-datasets/learning-spark-v2/flights/summary-data/csv/*",
      header "true",
      inferSchema "true",
      mode "FAILFAST"
    )

创建表格后,您可以像以前一样使用 SQL 将数据读入 DataFrame 中:

// In Scala/Python
spark.sql("SELECT * FROM us_delay_flights_tbl").show(10)

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 DataFrame 写入 CSV 文件

将 DataFrame 保存为 CSV 文件很简单。指定适当的 DataFrameWriter 方法和参数,并提供保存 CSV 文件的位置:

// In Scala
df.write.format("csv").mode("overwrite").save("/tmp/data/csv/df_csv")
# In Python
df.write.format("csv").mode("overwrite").save("/tmp/data/csv/df_csv")

这将在指定位置生成一个文件夹,并填充一堆压缩和紧凑的文件:

-rw-r--r--  1 jules  wheel   0 May 16 12:17 _SUCCESS
-rw-r--r--  1 jules  wheel  36 May 16 12:17 part-00000-251690eb-<...>-c000.csv

CSV 数据源选项

表 4-4 描述了 DataFrameReaderDataFrameWriter 的一些常见 CSV 选项。因为 CSV 文件可能很复杂,提供了许多选项;详细列表请参阅文档。

表 4-4. DataFrameReader 和 DataFrameWriter 的 CSV 选项

属性名称 含义 范围
compression none, bzip2, deflate, gzip, lz4snappy 用于写入的压缩编解码器。 写入
dateFormat yyyy-MM-ddDateTimeFormatter 使用此格式或 Java 的 DateTimeFormatter 中的任何格式。 读取/写入
multiLine true, false 使用多行模式。默认为false(单行模式)。 读取
inferSchema true, false 如果为 true,Spark 将确定列数据类型。默认为 false 读取
sep 任意字符 用于分隔行中列值的字符。默认分隔符为逗号(,)。 读取/写入
escape 任意字符 用于转义引号的字符。默认为 \ 读取/写入
header true, false 指示第一行是否为标头,表示每个列名。默认为 false 读取/写入

Avro

Spark 2.4 中作为内置数据源引入,Avro 格式 被使用,例如由 Apache Kafka 用于消息序列化和反序列化。它提供许多好处,包括直接映射到 JSON、速度和效率以及许多编程语言的绑定。

将 Avro 文件读取到 DataFrame 中

使用 DataFrameReader 将 Avro 文件读取到 DataFrame 中,在本节中,它的使用方式与我们讨论过的其他数据源一致:

// In Scala
val df = spark.read.format("avro")
 .load("/databricks-datasets/learning-spark-v2/flights/summary-data/avro/*")
df.show(false)
# In Python
df = (spark.read.format("avro")
  .load("/databricks-datasets/learning-spark-v2/flights/summary-data/avro/*"))
df.show(truncate=False)

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 Avro 文件读取到 Spark SQL 表中

再次,使用 Avro 数据源创建 SQL 表与使用 Parquet、JSON 或 CSV 没有什么不同:

-- In SQL 
CREATE OR REPLACE TEMPORARY VIEW episode_tbl
    USING avro
    OPTIONS (
      path "/databricks-datasets/learning-spark-v2/flights/summary-data/avro/*"
    )

创建表后,可以使用 SQL 将数据读取到 DataFrame 中:

// In Scala
spark.sql("SELECT * FROM episode_tbl").show(false)
# In Python
spark.sql("SELECT * FROM episode_tbl").show(truncate=False)

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 DataFrame 写入 Avro 文件

将 DataFrame 写入 Avro 文件很简单。像往常一样,指定适当的 DataFrameWriter 方法和参数,并提供保存 Avro 文件的位置:

// In Scala
df.write
  .format("avro")
  .mode("overwrite")
  .save("/tmp/data/avro/df_avro")
# In Python
(df.write
  .format("avro")
  .mode("overwrite")
  .save("/tmp/data/avro/df_avro"))

这将在指定位置生成一个文件夹,其中包含一堆压缩和紧凑的文件:

-rw-r--r--  1 jules  wheel    0 May 17 11:54 _SUCCESS
-rw-r--r--  1 jules  wheel  526 May 17 11:54 part-00000-ffdf70f4-<...>-c000.avro

Avro 数据源选项

表 4-5 描述了 DataFrameReaderDataFrameWriter 的常见选项。详细列表请参阅文档

表 4-5. DataFrameReader 和 DataFrameWriter 的 Avro 选项

属性名称 默认值 含义 范围
avroSchema None 用户以 JSON 格式提供的可选 Avro 模式。记录字段的数据类型和命名应与输入的 Avro 数据或 Catalyst 数据(Spark 内部数据类型)匹配,否则读写操作将失败。 读/写
recordName topLevelRecord 写结果中的顶级记录名称,Avro 规范要求的。
recordNamespace "" 写结果中的记录命名空间。
ignoreExtension true 如果启用此选项,则加载所有文件(带有和不带有 .avro 扩展名的文件)。否则,将忽略没有 .avro 扩展名的文件。
compression snappy 允许您指定写入时要使用的压缩编解码器。当前支持的编解码器包括 uncompressedsnappydeflatebzip2xz。如果未设置此选项,则将考虑 spark.sql.avro.compression.codec 中的值。

ORC

作为另一种优化的列式文件格式,Spark 2.x 支持矢量化 ORC 读取器。两个 Spark 配置决定了使用哪种 ORC 实现。当 spark.sql.orc.impl 设置为 nativespark.sql.orc.enableVectorizedReader 设置为 true 时,Spark 使用矢量化 ORC 读取器。矢量化读取器一次读取数据块(通常每块 1,024 行),而不是逐行读取,从而优化了扫描、过滤、聚合和连接等密集操作,减少 CPU 使用。

对于使用 SQL 命令 USING HIVE OPTIONS (fileFormat 'ORC') 创建的 Hive ORC SerDe(序列化和反序列化)表,当 Spark 配置参数 spark.sql.hive.convertMetastoreOrc 设置为 true 时,使用矢量化读取器。

将 ORC 文件读取为 DataFrame

要使用 ORC 矢量化读取器读取 DataFrame,您只需使用常规的 DataFrameReader 方法和选项:

// In Scala 
val file = "/databricks-datasets/learning-spark-v2/flights/summary-data/orc/*"
val df = spark.read.format("orc").load(file)
df.show(10, false)
# In Python
file = "/databricks-datasets/learning-spark-v2/flights/summary-data/orc/*"
df = spark.read.format("orc").option("path", file).load()
df.show(10, False)

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 ORC 文件读入 Spark SQL 表中

使用 ORC 数据源创建 SQL 视图时与 Parquet、JSON、CSV 或 Avro 没有区别:

-- In SQL
CREATE OR REPLACE TEMPORARY VIEW us_delay_flights_tbl
    USING orc
    OPTIONS (
      path "/databricks-datasets/learning-spark-v2/flights/summary-data/orc/*"
    )

创建表后,您可以像往常一样使用 SQL 将数据读入 DataFrame:

// In Scala/Python
spark.sql("SELECT * FROM us_delay_flights_tbl").show()

+-----------------+-------------------+-----+
|DEST_COUNTRY_NAME|ORIGIN_COUNTRY_NAME|count|
+-----------------+-------------------+-----+
|United States    |Romania            |1    |
|United States    |Ireland            |264  |
|United States    |India              |69   |
|Egypt            |United States      |24   |
|Equatorial Guinea|United States      |1    |
|United States    |Singapore          |25   |
|United States    |Grenada            |54   |
|Costa Rica       |United States      |477  |
|Senegal          |United States      |29   |
|United States    |Marshall Islands   |44   |
+-----------------+-------------------+-----+
only showing top 10 rows

将 DataFrame 写入 ORC 文件

在读取后写回转换后的 DataFrame 同样简单,使用 DataFrameWriter 方法:

// In Scala
df.write.format("orc")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/orc/df_orc")
# In Python
(df.write.format("orc")
  .mode("overwrite")
  .option("compression", "snappy")
  .save("/tmp/data/orc/flights_orc"))

结果将是一个指定位置包含一些压缩 ORC 文件的文件夹:

-rw-r--r--  1 jules  wheel    0 May 16 17:23 _SUCCESS
-rw-r--r--  1 jules  wheel  547 May 16 17:23 part-00000-<...>-c000.snappy.orc

图像

在 Spark 2.4 中,社区引入了一个新的数据源,图像文件,以支持深度学习和机器学习框架,如 TensorFlow 和 PyTorch。对于基于计算机视觉的机器学习应用,加载和处理图像数据集至关重要。

将图像文件读入 DataFrame

与之前所有文件格式一样,您可以使用 DataFrameReader 方法和选项来读取图像文件,如下所示:

// In Scala
import org.apache.spark.ml.source.image

val imageDir = "/databricks-datasets/learning-spark-v2/cctvVideos/train_images/"
val imagesDF = spark.read.format("image").load(imageDir)

imagesDF.printSchema

imagesDF.select("image.height", "image.width", "image.nChannels", "image.mode", 
  "label").show(5, false)
# In Python
from pyspark.ml import image

image_dir = "/databricks-datasets/learning-spark-v2/cctvVideos/train_images/"
images_df = spark.read.format("image").load(image_dir)
images_df.printSchema()

root
 |-- image: struct (nullable = true)
 |    |-- origin: string (nullable = true)
 |    |-- height: integer (nullable = true)
 |    |-- width: integer (nullable = true)
 |    |-- nChannels: integer (nullable = true)
 |    |-- mode: integer (nullable = true)
 |    |-- data: binary (nullable = true)
 |-- label: integer (nullable = true)

images_df.select("image.height", "image.width", "image.nChannels", "image.mode", 
  "label").show(5, truncate=False)

+------+-----+---------+----+-----+
|height|width|nChannels|mode|label|
+------+-----+---------+----+-----+
|288   |384  |3        |16  |0    |
|288   |384  |3        |16  |1    |
|288   |384  |3        |16  |0    |
|288   |384  |3        |16  |0    |
|288   |384  |3        |16  |0    |
+------+-----+---------+----+-----+
only showing top 5 rows

二进制文件

Spark 3.0 增加了对二进制文件作为数据源的支持DataFrameReader将每个二进制文件转换为包含文件的原始内容和元数据的单个 DataFrame 行(记录)。二进制文件数据源生成一个包含以下列的 DataFrame:

  • path: StringType

  • modificationTime: TimestampType

  • length: LongType

  • content: BinaryType

将二进制文件读取为 DataFrame

要读取二进制文件,请将数据源格式指定为binaryFile。您可以使用数据源选项pathGlobFilter加载符合给定全局模式的路径的文件,同时保留分区发现的行为。例如,以下代码从输入目录中读取所有 JPG 文件,包括任何分区目录:

// In Scala
val path = "/databricks-datasets/learning-spark-v2/cctvVideos/train_images/"
val binaryFilesDF = spark.read.format("binaryFile")
  .option("pathGlobFilter", "*.jpg")
  .load(path)
binaryFilesDF.show(5)
# In Python
path = "/databricks-datasets/learning-spark-v2/cctvVideos/train_images/"
binary_files_df = (spark.read.format("binaryFile")
  .option("pathGlobFilter", "*.jpg")
  .load(path))
binary_files_df.show(5)

+--------------------+-------------------+------+--------------------+-----+
|                path|   modificationTime|length|             content|label|
+--------------------+-------------------+------+--------------------+-----+
|file:/Users/jules...|2020-02-12 12:04:24| 55037|[FF D8 FF E0 00 1...|    0|
|file:/Users/jules...|2020-02-12 12:04:24| 54634|[FF D8 FF E0 00 1...|    1|
|file:/Users/jules...|2020-02-12 12:04:24| 54624|[FF D8 FF E0 00 1...|    0|
|file:/Users/jules...|2020-02-12 12:04:24| 54505|[FF D8 FF E0 00 1...|    0|
|file:/Users/jules...|2020-02-12 12:04:24| 54475|[FF D8 FF E0 00 1...|    0|
+--------------------+-------------------+------+--------------------+-----+
only showing top 5 rows

要忽略目录中的分区数据发现,您可以将recursiveFileLookup设置为"true"

// In Scala
val binaryFilesDF = spark.read.format("binaryFile")
  .option("pathGlobFilter", "*.jpg")
  .option("recursiveFileLookup", "true")
  .load(path)
binaryFilesDF.show(5)
# In Python
binary_files_df = (spark.read.format("binaryFile")
  .option("pathGlobFilter", "*.jpg")
  .option("recursiveFileLookup", "true")
  .load(path))
binary_files_df.show(5)

+--------------------+-------------------+------+--------------------+
|                path|   modificationTime|length|             content|
+--------------------+-------------------+------+--------------------+
|file:/Users/jules...|2020-02-12 12:04:24| 55037|[FF D8 FF E0 00 1...|
|file:/Users/jules...|2020-02-12 12:04:24| 54634|[FF D8 FF E0 00 1...|
|file:/Users/jules...|2020-02-12 12:04:24| 54624|[FF D8 FF E0 00 1...|
|file:/Users/jules...|2020-02-12 12:04:24| 54505|[FF D8 FF E0 00 1...|
|file:/Users/jules...|2020-02-12 12:04:24| 54475|[FF D8 FF E0 00 1...|
+--------------------+-------------------+------+--------------------+
only showing top 5 rows

当将recursiveFileLookup选项设置为"true"时,注意label列将不存在。

目前,二进制文件数据源不支持将 DataFrame 写回原始文件格式。

在本节中,您将了解如何从一系列支持的文件格式中将数据读取到 DataFrame 中。我们还展示了如何从现有的内置数据源创建临时视图和表。无论您使用 DataFrame API 还是 SQL,查询都会产生相同的结果。您可以在本书的GitHub 存储库中的笔记本中查看其中一些查询。

摘要

总结一下,本章探讨了 DataFrame API 与 Spark SQL 之间的互操作性。特别是,您了解了如何使用 Spark SQL 来:

  • 使用 Spark SQL 和 DataFrame API 创建托管和非托管表。

  • 从各种内置数据源和文件格式读取和写入。

  • 使用spark.sql编程接口来对存储为 Spark SQL 表或视图的结构化数据发出 SQL 查询。

  • 浏览 Spark Catalog以检查与表和视图相关联的元数据。

  • 使用DataFrameWriterDataFrameReader API。

通过本章的代码片段和本书的GitHub 存储库中的笔记本,您可以了解如何使用 DataFrame 和 Spark SQL。在这个过程中,下一章进一步探讨了 Spark 如何与图 4-1 中显示的外部数据源进行交互。您将看到更多关于转换和 DataFrame API 与 Spark SQL 互操作性的深入示例。

第五章:Spark SQL 和 DataFrames:与外部数据源交互

在上一章中,我们探讨了与 Spark 内置数据源的交互。我们还深入研究了 DataFrame API 及其与 Spark SQL 的互操作性。在本章中,我们将重点介绍 Spark SQL 如何与外部组件接口。具体来说,我们将讨论 Spark SQL 如何让您实现以下功能:

  • 使用用户定义函数同时适用于 Apache Hive 和 Apache Spark。

  • 连接外部数据源,如 JDBC 和 SQL 数据库,PostgreSQL、MySQL、Tableau、Azure Cosmos DB 和 MS SQL Server。

  • 处理简单和复杂类型,高阶函数以及常见的关系操作符。

我们还将探讨使用 Spark SQL 查询 Spark 的一些不同选项,例如 Spark SQL shell、Beeline 和 Tableau。

Spark SQL 和 Apache Hive

Spark SQL 是 Apache Spark 的基础组件,将关系处理与 Spark 的函数式编程 API 结合。它的起源可以追溯到之前 Shark 的工作。Shark 最初是建立在 Apache Spark 之上的 Hive 代码库,并成为 Hadoop 系统上第一个交互式 SQL 查询引擎之一。它证明了可以拥有两者兼得的最佳选择:像企业数据仓库一样快速,并像 Hive/MapReduce 一样可扩展。

Spark SQL 让 Spark 程序员利用更快的性能和关系式编程的优势(例如声明式查询和优化存储),以及调用复杂的分析库(例如机器学习)。正如前一章讨论的那样,从 Apache Spark 2.x 开始,SparkSession 提供了一个统一的入口点来操作 Spark 中的数据。

用户定义函数

虽然 Apache Spark 拥有大量内置函数,但 Spark 的灵活性允许数据工程师和数据科学家定义自己的函数。这些称为 用户定义函数(UDFs)。

Spark SQL UDFs

创建自己的 PySpark 或 Scala UDF 的好处在于,您(以及其他人)将能够在 Spark SQL 中使用它们。例如,数据科学家可以将 ML 模型封装在 UDF 中,以便数据分析师可以在 Spark SQL 中查询其预测结果,而不必深入了解模型的内部。

下面是创建 Spark SQL UDF 的简化示例。请注意,UDF 操作是会话级别的,并不会持久化在底层的元数据存储中:

// In Scala
// Create cubed function
val cubed = (s: Long) => {
  s * s * s
}

// Register UDF
spark.udf.register("cubed", cubed)

// Create temporary view
spark.range(1, 9).createOrReplaceTempView("udf_test")
# In Python
from pyspark.sql.types import LongType

# Create cubed function
def cubed(s):
  return s * s * s

# Register UDF
spark.udf.register("cubed", cubed, LongType())

# Generate temporary view
spark.range(1, 9).createOrReplaceTempView("udf_test")

您现在可以使用 Spark SQL 执行以下任意一个 cubed() 函数:

// In Scala/Python
// Query the cubed UDF
spark.sql("SELECT id, cubed(id) AS id_cubed FROM udf_test").show()

+---+--------+
| id|id_cubed|
+---+--------+
|  1|       1|
|  2|       8|
|  3|      27|
|  4|      64|
|  5|     125|
|  6|     216|
|  7|     343|
|  8|     512|
+---+--------+

在 Spark SQL 中进行评估顺序和空值检查

Spark SQL(包括 SQL、DataFrame API 和 Dataset API)不保证子表达式的评估顺序。例如,以下查询不保证 s is NOT NULL 子句在 strlen(s) > 1 子句之前执行:

spark.sql("SELECT s FROM test1 WHERE s IS NOT NULL AND strlen(s) > 1")

因此,为了执行适当的 null 检查,建议您执行以下操作:

  1. 使 UDF 本身具有null感知性,并在 UDF 内进行null检查。

  2. 使用IFCASE WHEN表达式对null进行检查,并在条件分支中调用 UDF。

使用 Pandas UDF 加速和分发 PySpark UDF

以前使用 PySpark UDF 的一个主要问题是,它们的性能比 Scala UDF 慢。这是因为 PySpark UDF 需要在 JVM 和 Python 之间传输数据,这是非常昂贵的。为了解决这个问题,作为 Apache Spark 2.3 的一部分引入了Pandas UDFs(也称为向量化 UDFs)。Pandas UDF 使用 Apache Arrow 传输数据并使用 Pandas 处理数据。您可以使用关键字pandas_udf作为装饰器来定义 Pandas UDF,或者直接包装函数本身。一旦数据处于Apache Arrow 格式中,就不再需要序列化/pickle 数据,因为数据已经以 Python 进程可消耗的格式存在。与逐行操作单个输入不同,您正在操作 Pandas Series 或 DataFrame(即向量化执行)。

从 Apache Spark 3.0 开始,使用 Python 3.6 及以上版本,将 Pandas UDFs 分为两个 API 类别:Pandas UDFs 和 Pandas 函数 API。

Pandas UDFs

在 Apache Spark 3.0 中,Pandas UDF 根据 Pandas UDF 中的 Python 类型提示推断 Pandas UDF 类型,例如pandas.Seriespandas.DataFrameTupleIterator。以前需要手动定义和指定每个 Pandas UDF 类型。当前在 Pandas UDF 中支持的 Python 类型提示的情况包括 Series 到 Series、Series 迭代器到 Series 迭代器、多个 Series 迭代器到 Series 迭代器以及 Series 到标量(单个值)。

Pandas 函数 API

Pandas 函数 API 允许您直接将本地 Python 函数应用于 PySpark DataFrame,其中输入和输出均为 Pandas 实例。对于 Spark 3.0,支持的 Pandas 函数 API 包括 grouped map、map 和 co-grouped map。

更多信息,请参阅“带有 Python 类型提示的重新设计的 Pandas UDFs”在第十二章。

下面是 Spark 3.0 标量 Pandas UDF 的示例:²

# In Python
# Import pandas
import pandas as pd

# Import various pyspark SQL functions including pandas_udf
from pyspark.sql.functions import col, pandas_udf
from pyspark.sql.types import LongType

# Declare the cubed function 
def cubed(a: pd.Series) -> pd.Series:
    return a * a * a

# Create the pandas UDF for the cubed function 
cubed_udf = pandas_udf(cubed, returnType=LongType())

上述代码片段声明了一个名为cubed()的函数,执行一个cubed操作。这是一个常规的 Pandas 函数,额外增加了cubed_udf = pandas_udf()调用来创建我们的 Pandas UDF。

让我们从一个简单的 Pandas Series(如定义为x)开始,然后应用本地函数cubed()进行立方计算:

# Create a Pandas Series
x = pd.Series([1, 2, 3])

# The function for a pandas_udf executed with local Pandas data
print(cubed(x))

输出如下:

0     1
1     8
2    27
dtype: int64

现在让我们切换到 Spark DataFrame。我们可以执行此函数作为 Spark 向量化 UDF,如下所示:

# Create a Spark DataFrame, 'spark' is an existing SparkSession
df = spark.range(1, 4)

# Execute function as a Spark vectorized UDF
df.select("id", cubed_udf(col("id"))).show()

这里是输出:

+---+---------+
| id|cubed(id)|
+---+---------+
|  1|        1|
|  2|        8|
|  3|       27|
+---+---------+

与本地函数相反,使用矢量化 UDF 将导致执行 Spark 作业;前面的本地函数仅在 Spark 驱动程序上执行 Pandas 函数。当查看此pandas_udf函数的一个阶段的 Spark UI 时,这一点变得更加明显(图 5-1)。

注意

要深入了解 Pandas UDF,请参阅pandas 用户定义函数文档

Spark UI 执行 Pandas UDF 的阶段

图 5-1. Spark UI 执行 Pandas UDF 的阶段

与许多 Spark 作业一样,该作业从parallelize()开始,将本地数据(Arrow 二进制批次)发送到执行器,并调用mapPartitions()将 Arrow 二进制批次转换为 Spark 的内部数据格式,然后可以分发给 Spark 工作节点。有许多WholeStageCodegen步骤,这些步骤代表了性能的重大提升(感谢项目 Tungsten 的整阶段代码生成,显著提高了 CPU 效率和性能)。但是,ArrowEvalPython步骤指出(在本例中)正在执行 Pandas UDF。

使用 Spark SQL Shell、Beeline 和 Tableau 进行查询

有多种机制可以查询 Apache Spark,包括 Spark SQL shell、Beeline CLI 实用程序以及 Tableau 和 Power BI 等报表工具。

在本节中,我们提供了有关 Tableau 的说明;关于 Power BI,请参阅文档

使用 Spark SQL Shell

执行 Spark SQL 查询的方便工具是spark-sql命令行界面。虽然此实用程序在本地模式下与 Hive 元数据存储服务通信,但它不会与Thrift JDBC/ODBC 服务器(也称为Spark Thrift ServerSTS)通信。STS 允许 JDBC/ODBC 客户端通过 JDBC 和 ODBC 协议在 Apache Spark 上执行 SQL 查询。

要启动 Spark SQL CLI,请在$SPARK_HOME文件夹中执行以下命令:

./bin/spark-sql

一旦启动了 shell,您可以使用它进行交互式执行 Spark SQL 查询。让我们看几个例子。

创建表

要创建一个新的永久性 Spark SQL 表,请执行以下语句:

spark-sql> CREATE TABLE people (name STRING, age int);

您的输出应该类似于这样,注意创建 Spark SQL 表people以及其文件位置(/user/hive/warehouse/people):

20/01/11 22:42:16 WARN HiveMetaStore: Location: file:/user/hive/warehouse/people
specified for non-external table:people
Time taken: 0.63 seconds

向表中插入数据

您可以通过执行类似于以下语句将数据插入到 Spark SQL 表中:

INSERT INTO people SELECT name, age FROM ...

由于您不依赖于从预先存在的表或文件中加载数据,因此可以使用INSERT...VALUES语句向表中插入数据。以下三个语句将三个个体(如果已知,包括其姓名和年龄)插入到people表中:

spark-sql> **INSERT INTO people VALUES ("Michael", NULL);**
Time taken: 1.696 seconds
spark-sql> **INSERT INTO people VALUES ("Andy", 30);**
Time taken: 0.744 seconds
spark-sql> **INSERT INTO people VALUES ("Samantha", 19);**
Time taken: 0.637 seconds
spark-sql>

运行 Spark SQL 查询

现在您的表中有了数据,可以对其运行 Spark SQL 查询。让我们从查看我们的元数据存储中存在哪些表开始:

spark-sql> **SHOW TABLES;**
default   people     false
Time taken: 0.016 seconds, Fetched 1 row(s)

接下来,让我们找出我们的表中有多少人年龄小于 20 岁:

spark-sql> **SELECT * FROM people WHERE age < 20;**
Samantha  19
Time taken: 0.593 seconds, Fetched 1 row(s)

同样地,让我们看看那些未指定年龄的个体是谁:

spark-sql> **SELECT name FROM people WHERE age IS NULL;**
Michael
Time taken: 0.272 seconds, Fetched 1 row(s)

使用 Beeline

如果您使用过 Apache Hive,可能对命令行工具 Beeline 比较熟悉,这是一个常用工具,用于针对 HiveServer2 运行 HiveQL 查询。Beeline 是基于 SQLLine CLI 的 JDBC 客户端。您可以使用同样的工具执行 Spark SQL 查询来连接 Spark Thrift 服务器。请注意,当前实现的 Thrift JDBC/ODBC 服务器对应于 Hive 1.2.1 中的 HiveServer2。您可以使用随 Spark 或 Hive 1.2.1 提供的以下 Beeline 脚本来测试 JDBC 服务器。

启动 Thrift 服务器

要启动 Spark Thrift JDBC/ODBC 服务器,请在 $SPARK_HOME 文件夹中执行以下命令:

./sbin/start-thriftserver.sh
注意

如果您尚未启动 Spark 驱动程序和工作进程,请在执行 start-thriftserver.sh 命令之前执行以下命令:

./sbin/start-all.sh

通过 Beeline 连接到 Thrift 服务器

要使用 Beeline 测试 Thrift JDBC/ODBC 服务器,请执行以下命令:

./bin/beeline

然后配置 Beeline 连接到本地 Thrift 服务器:

!connect jdbc:hive2://localhost:10000
注意

默认情况下,Beeline 处于 非安全模式。因此,用户名为您的登录名(例如,user@learningspark.org),密码为空。

使用 Beeline 执行 Spark SQL 查询

从这里,您可以运行类似于通过 Beeline 运行 Hive 查询的 Spark SQL 查询。以下是几个示例查询及其输出:

0: jdbc:hive2://localhost:10000> **SHOW tables;**

+-----------+------------+--------------+
| database  | tableName  | isTemporary  |
+-----------+------------+--------------+
| default   | people     | false        |
+-----------+------------+--------------+
1 row selected (0.417 seconds)

0: jdbc:hive2://localhost:10000> **SELECT * FROM people;**

+-----------+-------+
|   name    |  age  |
+-----------+-------+
| Samantha  | 19    |
| Andy      | 30    |
| Michael   | NULL  |
+-----------+-------+
3 rows selected (1.512 seconds)

0: jdbc:hive2://localhost:10000>

停止 Thrift 服务器

完成后,您可以使用以下命令停止 Thrift 服务器:

./sbin/stop-thriftserver.sh

使用 Tableau

与通过 Beeline 或 Spark SQL CLI 运行查询类似,您可以通过 Thrift JDBC/ODBC 服务器将喜爱的 BI 工具连接到 Spark SQL。在本节中,我们将向您展示如何将 Tableau Desktop(版本 2019.2)连接到您的本地 Apache Spark 实例。

注意

您需要安装 Tableau 的 Spark ODBC 驱动程序版本 1.2.0 或更高版本。如果您已安装(或升级至)Tableau 2018.1 或更高版本,则应预先安装此驱动程序。

启动 Thrift 服务器

要从 $SPARK_HOME 文件夹启动 Spark Thrift JDBC/ODBC 服务器,请执行以下命令:

./sbin/start-thriftserver.sh
注意

如果您尚未启动 Spark 驱动程序和工作进程,请在执行 start-thriftserver.sh 命令之前执行以下命令:

./sbin/start-all.sh

启动 Tableau

如果您第一次启动 Tableau,将会看到一个连接对话框,允许您连接到多种数据源。默认情况下,左侧的“到服务器”菜单中不包括 Spark SQL 选项(参见图 5-2)。

Tableau 连接对话框

图 5-2. Tableau 连接对话框

要访问 Spark SQL 选项,请在列表底部单击“更多…”,然后从主面板出现的列表中选择 Spark SQL,如图 5-3 所示。

选择更多…Spark SQL 连接到 Spark SQL

图 5-3. 选择更多… > Spark SQL 连接到 Spark SQL

这将弹出 Spark SQL 对话框(参见图 5-4)。由于您正在连接到本地 Apache Spark 实例,您可以使用以下参数进行非安全用户名身份验证模式:

  • 服务器:localhost

  • 端口:10000(默认)

  • 类型:SparkThriftServer(默认)

  • 身份验证:用户名

  • 用户名:您的登录,例如,user@learningspark.org

  • 需要 SSL:未选中

Spark SQL 对话框

图 5-4. Spark SQL 对话框

连接到 Spark SQL 数据源成功后,您将看到类似图 5-5 的数据源连接视图。

Tableau 数据源连接视图,连接到本地 Spark 实例

图 5-5. Tableau 数据源连接视图,连接到本地 Spark 实例

在左侧的选择模式下拉菜单中选择“default”。然后输入要查询的表的名称(见图 5-6)。请注意,您可以单击放大镜图标以获取可用表的完整列表。

选择模式和要查询的表

图 5-6. 选择模式和要查询的表
注意

欲了解如何使用 Tableau 连接到 Spark SQL 数据库的更多信息,请参阅 Tableau 的Spark SQL 文档和 Databricks 的Tableau 文档

输入people作为表名,然后从左侧拖放表格到主对话框(在“将表拖到此处”标记的空间内)。您应该看到类似图 5-7 的内容。

连接到本地 Spark 实例中的 people 表

图 5-7. 连接到本地 Spark 实例中的 people 表

单击“立即更新”,在幕后 Tableau 将查询您的 Spark SQL 数据源(参见图 5-8)。

现在您可以执行针对 Spark 数据源的查询、表连接等操作,就像处理任何其他 Tableau 数据源一样。

Tableau 工作表表视图查询本地 Spark 数据源

图 5-8. Tableau 工作表表视图查询本地 Spark 数据源

停止 Thrift 服务器

完成后,您可以使用以下命令停止 Thrift 服务器:

./sbin/stop-thriftserver.sh

外部数据源

在本节中,我们将重点介绍如何使用 Spark SQL 连接外部数据源,从 JDBC 和 SQL 数据库开始。

JDBC 和 SQL 数据库

Spark SQL 包括一个数据源 API,可以使用 JDBC 从其他数据库读取数据。它简化了对这些数据源的查询,因为它将结果作为 DataFrame 返回,从而提供了 Spark SQL 的所有优势(包括性能和与其他数据源的连接能力)。

要开始使用,您需要为 JDBC 数据源指定 JDBC 驱动程序,并将其放置在 Spark 类路径中。从 $SPARK_HOME 文件夹中,您可以执行如下命令:

./bin/spark-shell --driver-class-path $database.jar --jars $database.jar

使用数据源 API,可以将远程数据库的表加载为 DataFrame 或 Spark SQL 临时视图。用户可以在数据源选项中指定 JDBC 连接属性。Table 5-1 包含 Spark 支持的一些常见连接属性(不区分大小写)。

Table 5-1. 常见连接属性

属性名称 描述
user, password 这些通常作为连接属性提供,用于登录数据源。
url JDBC 连接 URL,例如,jdbc:postgresql://localhost/test?user=fred&password=secret
dbtable 要读取或写入的 JDBC 表。不能同时指定 dbtablequery 选项。
query 用于从 Apache Spark 读取数据的查询,例如,`SELECT column1, column2, ..., columnN FROM [table
driver 用于连接到指定 URL 的 JDBC 驱动程序的类名。

查看完整的连接属性列表,请参阅Spark SQL 文档

分区的重要性

在 Spark SQL 和 JDBC 外部数据源之间传输大量数据时,重要的是对数据源进行分区。所有数据都通过一个驱动程序连接,这可能会使数据提取的性能显著下降,并可能使源系统的资源饱和。虽然这些 JDBC 属性是可选的,但对于任何大规模操作,强烈建议使用 Table 5-2 中显示的属性。

Table 5-2. 分区连接属性

属性名称 描述
numPartitions 用于表读写并行性的最大分区数。这也决定了最大并发 JDBC 连接数。
partitionColumn 在读取外部数据源时,partitionColumn 是用于确定分区的列;请注意,partitionColumn 必须是数值、日期或时间戳列。
lowerBound 设置分区步长中partitionColumn的最小值。
upperBound 设置分区步长中partitionColumn的最大值。

让我们看一个 示例,帮助您理解这些属性如何工作。假设我们使用以下设置:

  • numPartitions10

  • lowerBound1000

  • upperBound10000

然后步长为 1,000,将创建 10 个分区。这相当于执行这 10 个查询(每个分区一个):

  • SELECT * FROM table WHERE partitionColumn BETWEEN 1000 and 2000

  • SELECT * FROM table WHERE partitionColumn BETWEEN 2000 and 3000

  • ...

  • SELECT * FROM table WHERE partitionColumn BETWEEN 9000 and 10000

虽然不是全面的,但以下是在使用这些属性时要记住的一些提示:

  • numPartitions 的一个良好起点是使用 Spark 工作节点数量的倍数。例如,如果您有四个 Spark 工作节点,可以从 4 或 8 个分区开始。但重要的是注意源系统如何处理读取请求。对于有处理窗口的系统,可以最大化对源系统的并发请求;对于没有处理窗口的系统(例如连续处理数据的 OLTP 系统),应减少并发请求以避免源系统饱和。

  • 最初,基于 partitionColumn 的最小和最大 实际 值来计算 lowerBoundupperBound。例如,如果您选择 {numPartitions:10, lowerBound: 1000, upperBound: 10000},但所有值都在 20004000 之间,那么这 10 个查询(每个分区一个)中只有 2 个将承担所有工作。在这种情况下,更好的配置是 {numPartitions:10, lowerBound: 2000, upperBound: 4000}

  • 选择一个可以均匀分布的 partitionColumn,以避免数据倾斜。例如,如果大多数 partitionColumn 的值为 2500,那么在 {numPartitions:10, lowerBound: 1000, upperBound: 10000} 的情况下,大部分工作将由请求值在 20003000 之间的任务执行。相反,选择一个不同的 partitionColumn,或者如果可能的话生成一个新的(可能是多列的哈希)以更均匀地分布分区。

PostgreSQL

要连接到 PostgreSQL 数据库,请构建或从 Maven 下载 JDBC jar 并将其添加到类路径中。然后启动 Spark shell (spark-shellpyspark),指定该 jar 文件:

bin/spark-shell --jars postgresql-42.2.6.jar

以下示例展示了如何使用 Spark SQL 数据源 API 和 Scala 中的 JDBC 从 PostgreSQL 数据库加载数据和保存数据:

// In Scala
// Read Option 1: Loading data from a JDBC source using load method
val jdbcDF1 = spark
  .read
  .format("jdbc")
  .option("url", "jdbc:postgresql:[DBSERVER]")
  .option("dbtable", "[SCHEMA].[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .load()

// Read Option 2: Loading data from a JDBC source using jdbc method
// Create connection properties
import java.util.Properties
val cxnProp = new Properties()
cxnProp.put("user", "[USERNAME]") 
cxnProp.put("password", "[PASSWORD]")

// Load data using the connection properties
val jdbcDF2 = spark
  .read
  .jdbc("jdbc:postgresql:[DBSERVER]", "[SCHEMA].[TABLENAME]", cxnProp)

// Write Option 1: Saving data to a JDBC source using save method
jdbcDF1
  .write
  .format("jdbc")
  .option("url", "jdbc:postgresql:[DBSERVER]")
  .option("dbtable", "[SCHEMA].[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .save()

// Write Option 2: Saving data to a JDBC source using jdbc method
jdbcDF2.write
  .jdbc(s"jdbc:postgresql:[DBSERVER]", "[SCHEMA].[TABLENAME]", cxnProp)

以下是如何在 PySpark 中执行此操作的示例:

# In Python
# Read Option 1: Loading data from a JDBC source using load method
jdbcDF1 = (spark
  .read
  .format("jdbc") 
  .option("url", "jdbc:postgresql://[DBSERVER]")
  .option("dbtable", "[SCHEMA].[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .load())

# Read Option 2: Loading data from a JDBC source using jdbc method
jdbcDF2 = (spark
  .read 
  .jdbc("jdbc:postgresql://[DBSERVER]", "[SCHEMA].[TABLENAME]",
          properties={"user": "[USERNAME]", "password": "[PASSWORD]"}))

# Write Option 1: Saving data to a JDBC source using save method
(jdbcDF1
  .write
  .format("jdbc")
  .option("url", "jdbc:postgresql://[DBSERVER]")
  .option("dbtable", "[SCHEMA].[TABLENAME]") 
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .save())

# Write Option 2: Saving data to a JDBC source using jdbc method
(jdbcDF2
  .write 
  .jdbc("jdbc:postgresql:[DBSERVER]", "[SCHEMA].[TABLENAME]",
          properties={"user": "[USERNAME]", "password": "[PASSWORD]"}))

MySQL

要连接到 MySQL 数据库,请从 MavenMySQL (后者更简单!)构建或下载 JDBC jar 并将其添加到类路径中。然后启动 Spark shell (spark-shellpyspark),指定该 jar 文件:

bin/spark-shell --jars mysql-connector-java_8.0.16-bin.jar

以下示例展示了如何使用 Spark SQL 数据源 API 和 JDBC 在 Scala 中从 MySQL 数据库加载数据并保存数据:

// In Scala
// Loading data from a JDBC source using load 
val jdbcDF = spark
  .read
  .format("jdbc")
  .option("url", "jdbc:mysql://[DBSERVER]:3306/[DATABASE]")
  .option("driver", "com.mysql.jdbc.Driver")
  .option("dbtable", "[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .load()

// Saving data to a JDBC source using save 
jdbcDF
  .write
  .format("jdbc")
  .option("url", "jdbc:mysql://[DBSERVER]:3306/[DATABASE]")
  .option("driver", "com.mysql.jdbc.Driver")
  .option("dbtable", "[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .save()

而这是如何在 Python 中执行该操作的:

# In Python
# Loading data from a JDBC source using load 
jdbcDF = (spark
  .read
  .format("jdbc")
  .option("url", "jdbc:mysql://[DBSERVER]:3306/[DATABASE]")
  .option("driver", "com.mysql.jdbc.Driver") 
  .option("dbtable", "[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .load())

# Saving data to a JDBC source using save 
(jdbcDF
  .write 
  .format("jdbc") 
  .option("url", "jdbc:mysql://[DBSERVER]:3306/[DATABASE]")
  .option("driver", "com.mysql.jdbc.Driver") 
  .option("dbtable", "[TABLENAME]") 
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .save())

Azure Cosmos DB

要连接到 Azure Cosmos DB 数据库,构建或下载来自 MavenGitHub 的 JDBC jar,并将其添加到您的类路径中。然后启动 Scala 或 PySpark shell,指定这个 jar(请注意,此示例使用的是 Spark 2.4):

bin/spark-shell --jars azure-cosmosdb-spark_2.4.0_2.11-1.3.5-uber.jar

您还可以选择使用 --packagesSpark Packages 使用其 Maven 坐标来获取连接器:

export PKG="com.microsoft.azure:azure-cosmosdb-spark_2.4.0_2.11:1.3.5"
bin/spark-shell --packages $PKG

以下示例展示了如何使用 Spark SQL 数据源 API 和 JDBC 在 Scala 和 PySpark 中从 Azure Cosmos DB 数据库加载数据并保存数据。请注意,通常使用 query_custom 配置来利用 Cosmos DB 中的各种索引:

// In Scala
// Import necessary libraries
import com.microsoft.azure.cosmosdb.spark.schema._
import com.microsoft.azure.cosmosdb.spark._
import com.microsoft.azure.cosmosdb.spark.config.Config

// Loading data from Azure Cosmos DB
// Configure connection to your collection
val query = "SELECT c.colA, c.coln FROM c WHERE c.origin = 'SEA'"
val readConfig = Config(Map(
  "Endpoint" -> "https://[ACCOUNT].documents.azure.com:443/", 
  "Masterkey" -> "[MASTER KEY]",
  "Database" -> "[DATABASE]",
  "PreferredRegions" -> "Central US;East US2;",
  "Collection" -> "[COLLECTION]",
  "SamplingRatio" -> "1.0",
  "query_custom" -> query
))

// Connect via azure-cosmosdb-spark to create Spark DataFrame
val df = spark.read.cosmosDB(readConfig)
df.count

// Saving data to Azure Cosmos DB
// Configure connection to the sink collection
val writeConfig = Config(Map(
  "Endpoint" -> "https://[ACCOUNT].documents.azure.com:443/",
  "Masterkey" -> "[MASTER KEY]",
  "Database" -> "[DATABASE]",
  "PreferredRegions" -> "Central US;East US2;",
  "Collection" -> "[COLLECTION]",
  "WritingBatchSize" -> "100"
))

// Upsert the DataFrame to Azure Cosmos DB
import org.apache.spark.sql.SaveMode
df.write.mode(SaveMode.Overwrite).cosmosDB(writeConfig)
# In Python
# Loading data from Azure Cosmos DB
# Read configuration
query = "SELECT c.colA, c.coln FROM c WHERE c.origin = 'SEA'"
readConfig = {
  "Endpoint" : "https://[ACCOUNT].documents.azure.com:443/", 
  "Masterkey" : "[MASTER KEY]",
  "Database" : "[DATABASE]",
  "preferredRegions" : "Central US;East US2",
  "Collection" : "[COLLECTION]",
  "SamplingRatio" : "1.0",
  "schema_samplesize" : "1000",
  "query_pagesize" : "2147483647",
  "query_custom" : query
}

# Connect via azure-cosmosdb-spark to create Spark DataFrame
df = (spark
  .read
  .format("com.microsoft.azure.cosmosdb.spark")
  .options(**readConfig)
  .load())

# Count the number of flights
df.count()

# Saving data to Azure Cosmos DB
# Write configuration
writeConfig = {
 "Endpoint" : "https://[ACCOUNT].documents.azure.com:443/",
 "Masterkey" : "[MASTER KEY]",
 "Database" : "[DATABASE]",
 "Collection" : "[COLLECTION]",
 "Upsert" : "true"
}

# Upsert the DataFrame to Azure Cosmos DB
(df.write
  .format("com.microsoft.azure.cosmosdb.spark")
  .options(**writeConfig)
  .save())

欲了解更多信息,请参阅 Azure Cosmos DB 文档

MS SQL Server

要连接到 MS SQL Server 数据库,下载 JDBC jar 并将其添加到您的类路径中。然后启动 Scala 或 PySpark shell,指定这个 jar:

bin/spark-shell --jars mssql-jdbc-7.2.2.jre8.jar

以下示例展示了如何使用 Spark SQL 数据源 API 和 JDBC 在 Scala 和 PySpark 中从 MS SQL Server 数据库加载数据并保存数据:

// In Scala
// Loading data from a JDBC source
// Configure jdbcUrl
val jdbcUrl = "jdbc:sqlserver://[DBSERVER]:1433;database=[DATABASE]"

// Create a Properties() object to hold the parameters. 
// Note, you can create the JDBC URL without passing in the
// user/password parameters directly.
val cxnProp = new Properties()
cxnProp.put("user", "[USERNAME]") 
cxnProp.put("password", "[PASSWORD]") 
cxnProp.put("driver", "com.microsoft.sqlserver.jdbc.SQLServerDriver")

// Load data using the connection properties
val jdbcDF = spark.read.jdbc(jdbcUrl, "[TABLENAME]", cxnProp)

// Saving data to a JDBC source
jdbcDF.write.jdbc(jdbcUrl, "[TABLENAME]", cxnProp)
# In Python
# Configure jdbcUrl
jdbcUrl = "jdbc:sqlserver://[DBSERVER]:1433;database=[DATABASE]"

# Loading data from a JDBC source
jdbcDF = (spark
  .read
  .format("jdbc") 
  .option("url", jdbcUrl)
  .option("dbtable", "[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .load())

# Saving data to a JDBC source
(jdbcDF
  .write
  .format("jdbc") 
  .option("url", jdbcUrl)
  .option("dbtable", "[TABLENAME]")
  .option("user", "[USERNAME]")
  .option("password", "[PASSWORD]")
  .save())

其他外部来源

这些仅是 Apache Spark 可以连接的许多外部数据源之一;其他流行的数据源包括:

数据框架和 Spark SQL 中的高阶函数

由于复杂数据类型是简单数据类型的综合,直接操作它们是很诱人的。有两种 典型解决方案 用于操作复杂数据类型:

  • 将嵌套结构扩展为单独的行,应用某些函数,然后重新创建嵌套结构

  • 构建用户定义函数

这些方法的好处是可以以表格格式思考问题。它们通常涉及(但不限于)使用 utility functionsget_json_object()from_json()to_json()explode()selectExpr()

让我们更仔细地看看这两个选项。

选项 1:展开和收集

在这个嵌套的 SQL 语句中,我们首先执行 explode(values),这会为 values 中的每个元素 value 创建一个带有 id 的新行:

-- In SQL
SELECT id, collect_list(value + 1) AS values
FROM  (SELECT id, EXPLODE(values) AS value
        FROM table) x
GROUP BY id

当使用 collect_list() 返回带有重复对象的列表时,GROUP BY 语句需要执行洗牌操作,这意味着重新收集的数组的顺序不一定与原始数组相同。由于 values 可能具有任意数量的维度(一个非常宽或非常长的数组),并且我们正在进行 GROUP BY,这种方法可能非常昂贵。

选项 2:用户定义函数

要执行相同的任务(将 values 中的每个元素加 1),我们还可以创建一个使用 map() 迭代每个元素 value 并执行加法操作的 UDF:

-- In SQL
SELECT id, collect_list(value + 1) AS values
FROM  (SELECT id, EXPLODE(values) AS value
        FROM table) x
GROUP BY id

我们可以在 Spark SQL 中如下使用此 UDF:

spark.sql("SELECT id, plusOneInt(values) AS values FROM table").show()

虽然这比使用 explode()collect_list() 更好,因为不会有任何排序问题,但序列化和反序列化过程本身可能很昂贵。然而,值得注意的是,对于大数据集,collect_list() 可能会导致执行器遇到内存不足的问题,而使用 UDF 则可以缓解这些问题。

复杂数据类型的内置函数

您可以尝试使用 Apache Spark 2.4 及更高版本提供的一些复杂数据类型的内置函数,其中一些常见函数列在表 5-3(数组类型)和表 5-4(映射类型)中;完整列表请参阅Databricks 文档中的笔记本

表 5-3. 数组类型函数

函数/描述 查询 输出
array_distinct(array<T>): array<T> 从数组中移除重复项 SELECT array_distinct(array(1, 2, 3, null, 3)); [1,2,3,null]
array_intersect(array<T>, array<T>): array<T> 返回两个数组的交集(去重) SELECT array_intersect(array(1, 2, 3), array(1, 3, 5)); [1,3]
array_union(array<T>, array<T>): array<T> 返回两个数组的并集,无重复 SELECT array_union(array(1, 2, 3), array(1, 3, 5)); [1,2,3,5]
array_except(array<T>, array<T>): array<T> 返回存在于 array1 中但不存在于 array2 中的元素,无重复 SELECT array_except(array(1, 2, 3), array(1, 3, 5)); [2]
array_join(array<String>, String[, String]): String 使用分隔符连接数组的元素 SELECT array_join(array('hello', 'world'), ' '); hello world
array_max(array<T>): T 返回数组中的最大值;跳过 null 元素 SELECT array_max(array(1, 20, null, 3)); 20
array_min(array<T>): T 返回数组中的最小值;跳过 null 元素 SELECT array_min(array(1, 20, null, 3)); 1
array_position(array<T>, T): Long 返回给定数组的第一个元素的(从 1 开始的)索引作为 Long SELECT array_position(array(3, 2, 1), 1); 3
array_remove(array<T>, T): array<T> 从给定的数组中移除所有等于给定元素的元素 SELECT array_remove(array(1, 2, 3, null, 3), 3); [1,2,null]
arrays_overlap(array<T>, array<T>): array<T> 如果 array1 包含至少一个非 null 元素,同时也存在于 array2 中,则返回 true SELECT arrays_overlap(array(1, 2, 3), array(3, 4, 5)); true
array_sort(array<T>): array<T> 将输入数组按升序排序,将 null 元素放置在数组末尾 SELECT array_sort(array('b', 'd', null, 'c', 'a')); ["a","b","c","d",null]
concat(array<T>, ...): array<T> 连接字符串、二进制、数组等 SELECT concat(array(1, 2, 3), array(4, 5), array(6)); [1,2,3,4,5,6]
flatten(array<array<T>>): array<T> 将数组的数组展平为单个数组 SELECT flatten(array(array(1, 2), array(3, 4))); [1,2,3,4]
array_repeat(T, Int): array<T> 返回包含指定元素的数组,元素重复指定次数 SELECT array_repeat('123', 3); ["123","123","123"]
reverse(array<T>): array<T> 返回反转顺序的字符串或数组 SELECT reverse(array(2, 1, 4, 3)); [3,4,1,2]

| sequence(T, T[, T]): array<T> 通过递增步长生成从起始到结束(包括)的元素数组 | SELECT sequence(1, 5); SELECT sequence(5, 1);

SELECT sequence(to_date('2018-01-01'), to_date('2018-03-01'), interval 1 month); | [1,2,3,4,5] [5,4,3,2,1] |

["2018-01-01", "2018-02-01", "2018-03-01"] |

shuffle(array<T>): array<T> 返回给定数组的随机排列 SELECT shuffle(array(1, 20, null, 3)); [null,3,20,1]
slice(array<T>, Int, Int): array<T> 返回从给定索引开始的给定长度的子数组(如果索引为负数则从末尾计数) SELECT slice(array(1, 2, 3, 4), -2, 2); [3,4]
array_zip(array<T>, array<U>, ...): array<struct<T, U, ...>> 返回合并的结构数组 SELECT arrays_zip(array(1, 2), array(2, 3), array(3, 4)); [{"0":1,"1":2,"2":3},{"0":2,"1":3,"2":4}]
element_at(array<T>, Int): T / 返回给定数组在指定(基于 1 的)索引处的元素 SELECT element_at(array(1, 2, 3), 2); 2
cardinality(array<T>): Int size 的别名;返回给定数组或映射的大小 SELECT cardinality(array('b', 'd', 'c', 'a')); 4

表 5-4. 映射函数

函数/描述 查询 输出
map_form_arrays(array<K>, array<V>): map<K, V> 从给定的键/值数组对创建映射;键中的元素不应为 null SELECT map_from_arrays(array(1.0, 3.0), array('2', '4')); {"1.0":"2", "3.0":"4"}
map_from_entries(array<struct<K, V>>): map<K, V> 返回从给定数组创建的映射 SELECT map_from_entries(array(struct(1, 'a'), struct(2, 'b'))); {"1":"a", "2":"b"}
map_concat(map<K, V>, ...): map<K, V> 返回输入映射的并集 SELECT map_concat(map(1, 'a', 2, 'b'), map(2, 'c', 3, 'd')); {"1":"a", "2":"c","3":"d"}
element_at(map<K, V>, K): V 返回给定键的值,如果映射中不包含该键则返回 null SELECT element_at(map(1, 'a', 2, 'b'), 2); b
cardinality(array<T>): Int size 的别名;返回给定数组或映射的大小 SELECT cardinality(map(1, 'a', 2, 'b')); 2

Higher-Order Functions

除了前述的内置函数外,还有接受匿名 lambda 函数作为参数的高阶函数。一个高阶函数的例子如下:

-- In SQL
transform(values, value -> lambda expression)

transform()函数接受一个数组(values)和匿名函数(lambda表达式)作为输入。该函数通过将匿名函数应用于每个元素来透明地创建一个新数组,并将结果分配给输出数组(类似于 UDF 方法,但更高效)。

让我们创建一个样本数据集,以便我们可以运行一些示例:

# In Python
from pyspark.sql.types import *
schema = StructType([StructField("celsius", ArrayType(IntegerType()))])

t_list = [[35, 36, 32, 30, 40, 42, 38]], [[31, 32, 34, 55, 56]]
t_c = spark.createDataFrame(t_list, schema)
t_c.createOrReplaceTempView("tC")

# Show the DataFrame
t_c.show()
// In Scala
// Create DataFrame with two rows of two arrays (tempc1, tempc2)
val t1 = Array(35, 36, 32, 30, 40, 42, 38)
val t2 = Array(31, 32, 34, 55, 56)
val tC = Seq(t1, t2).toDF("celsius")
tC.createOrReplaceTempView("tC")

// Show the DataFrame
tC.show()

这是输出:

+--------------------+
|             celsius|
+--------------------+
|[35, 36, 32, 30, ...|
|[31, 32, 34, 55, 56]|
+--------------------+

使用上述 DataFrame,您可以运行以下高阶函数查询。

transform()

transform(array<T>, function<T, U>): array<U>

transform()函数通过将函数应用于输入数组的每个元素来生成一个数组(类似于map()函数):

// In Scala/Python
// Calculate Fahrenheit from Celsius for an array of temperatures
spark.sql("""
SELECT celsius, 
 transform(celsius, t -> ((t * 9) div 5) + 32) as fahrenheit 
 FROM tC
""").show()

+--------------------+--------------------+
|             celsius|          fahrenheit|
+--------------------+--------------------+
|[35, 36, 32, 30, ...|[95, 96, 89, 86, ...|
|[31, 32, 34, 55, 56]|[87, 89, 93, 131,...|
+--------------------+--------------------+

filter()

filter(array<T>, function<T, Boolean>): array<T>

filter()函数生成一个数组,其中只包含布尔函数为true的输入数组的元素:

// In Scala/Python
// Filter temperatures > 38C for array of temperatures
spark.sql("""
SELECT celsius, 
 filter(celsius, t -> t > 38) as high 
 FROM tC
""").show()

+--------------------+--------+
|             celsius|    high|
+--------------------+--------+
|[35, 36, 32, 30, ...|[40, 42]|
|[31, 32, 34, 55, 56]|[55, 56]|
+--------------------+--------+

exists()

exists(array<T>, function<T, V, Boolean>): Boolean

exists()函数如果布尔函数对输入数组中的任何元素成立,则返回true

// In Scala/Python
// Is there a temperature of 38C in the array of temperatures
spark.sql("""
SELECT celsius, 
 exists(celsius, t -> t = 38) as threshold
 FROM tC
""").show()

+--------------------+---------+
|             celsius|threshold|
+--------------------+---------+
|[35, 36, 32, 30, ...|     true|
|[31, 32, 34, 55, 56]|    false|
+--------------------+---------+

reduce()

reduce(array<T>, B, function<B, T, B>, function<B, R>)

reduce()函数通过将元素合并到缓冲区B中,使用function<B, T, B>,并在最终缓冲区上应用结束function<B, R>,将输入数组的元素减少为单个值:

// In Scala/Python
// Calculate average temperature and convert to F
spark.sql("""
SELECT celsius, 
 reduce(
 celsius, 
 0, 
 (t, acc) -> t + acc, 
 acc -> (acc div size(celsius) * 9 div 5) + 32
 ) as avgFahrenheit 
 FROM tC
""").show()

+--------------------+-------------+
|             celsius|avgFahrenheit|
+--------------------+-------------+
|[35, 36, 32, 30, ...|           96|
|[31, 32, 34, 55, 56]|          105|
+--------------------+-------------+

常见的 DataFrame 和 Spark SQL 操作

Spark SQL 的强大之处在于支持的广泛 DataFrame 操作(也称为无类型 Dataset 操作)。支持的操作列表非常广泛,包括:

  • 聚合函数

  • 集合函数

  • 日期时间函数

  • 数学函数

  • 杂项函数

  • 非聚合函数

  • 排序功能

  • 字符串函数

  • UDF 函数

  • 窗口函数

完整列表请参阅Spark SQL 文档

在本章中,我们将专注于以下常见的关系操作:

  • 联合和连接

  • 窗口函数

  • 修改

要执行这些 DataFrame 操作,我们首先要准备一些数据。在以下代码片段中,我们:

  1. 导入两个文件并创建两个 DataFrame,一个用于机场(airportsna)信息,另一个用于美国航班延误(departureDelays)。

  2. 使用expr(),将delaydistance列从STRING转换为INT

  3. 创建一个较小的表foo,我们可以专注于我们的演示示例;它只包含从西雅图(SEA)出发到旧金山(SFO)目的地的三个航班的信息,时间范围较小。

让我们开始吧:

// In Scala
import org.apache.spark.sql.functions._

// Set file paths
val delaysPath = 
  "/databricks-datasets/learning-spark-v2/flights/departuredelays.csv"
val airportsPath = 
  "/databricks-datasets/learning-spark-v2/flights/airport-codes-na.txt"

// Obtain airports data set
val airports = spark.read
  .option("header", "true")
  .option("inferschema", "true")
  .option("delimiter", "\t")
  .csv(airportsPath)
airports.createOrReplaceTempView("airports_na")

// Obtain departure Delays data set
val delays = spark.read
  .option("header","true")
  .csv(delaysPath)
  .withColumn("delay", expr("CAST(delay as INT) as delay"))
  .withColumn("distance", expr("CAST(distance as INT) as distance"))
delays.createOrReplaceTempView("departureDelays")

// Create temporary small table
val foo = delays.filter(
  expr("""origin == 'SEA' AND destination == 'SFO' AND 
 date like '01010%' AND delay > 0"""))
foo.createOrReplaceTempView("foo")

# In Python
# Set file paths
from pyspark.sql.functions import expr
tripdelaysFilePath = 
  "/databricks-datasets/learning-spark-v2/flights/departuredelays.csv"
airportsnaFilePath = 
  "/databricks-datasets/learning-spark-v2/flights/airport-codes-na.txt"

# Obtain airports data set
airportsna = (spark.read
  .format("csv")
  .options(header="true", inferSchema="true", sep="\t")
  .load(airportsnaFilePath))

airportsna.createOrReplaceTempView("airports_na")

# Obtain departure delays data set
departureDelays = (spark.read
  .format("csv")
  .options(header="true")
  .load(tripdelaysFilePath))

departureDelays = (departureDelays
  .withColumn("delay", expr("CAST(delay as INT) as delay"))
  .withColumn("distance", expr("CAST(distance as INT) as distance")))

departureDelays.createOrReplaceTempView("departureDelays")

# Create temporary small table
foo = (departureDelays
  .filter(expr("""origin == 'SEA' and destination == 'SFO' and 
 date like '01010%' and delay > 0""")))
foo.createOrReplaceTempView("foo")

departureDelays DataFrame 包含超过 1.3M 个航班的数据,而foo DataFrame 只包含三行关于从 SEA 到 SFO 的航班信息,具体时间范围如下输出所示:

// Scala/Python spark.sql("SELECT * FROM airports_na LIMIT 10").show()

+-----------+-----+-------+----+
|       City|State|Country|IATA|
+-----------+-----+-------+----+
| Abbotsford|   BC| Canada| YXX|
|   Aberdeen|   SD|    USA| ABR|
|    Abilene|   TX|    USA| ABI|
|      Akron|   OH|    USA| CAK|
|    Alamosa|   CO|    USA| ALS|
|     Albany|   GA|    USA| ABY|
|     Albany|   NY|    USA| ALB|
|Albuquerque|   NM|    USA| ABQ|
| Alexandria|   LA|    USA| AEX|
|  Allentown|   PA|    USA| ABE|
+-----------+-----+-------+----+

spark.sql("SELECT * FROM departureDelays LIMIT 10").show()

+--------+-----+--------+------+-----------+
|    date|delay|distance|origin|destination|
+--------+-----+--------+------+-----------+
|01011245|    6|     602|   ABE|        ATL|
|01020600|   -8|     369|   ABE|        DTW|
|01021245|   -2|     602|   ABE|        ATL|
|01020605|   -4|     602|   ABE|        ATL|
|01031245|   -4|     602|   ABE|        ATL|
|01030605|    0|     602|   ABE|        ATL|
|01041243|   10|     602|   ABE|        ATL|
|01040605|   28|     602|   ABE|        ATL|
|01051245|   88|     602|   ABE|        ATL|
|01050605|    9|     602|   ABE|        ATL|
+--------+-----+--------+------+-----------+

spark.sql("SELECT * FROM foo").show()

`+--------+-----+--------+------+-----------+`
`|`    `date``|``delay``|``distance``|``origin``|``destination``|`
`+--------+-----+--------+------+-----------+`
`|``01010710``|`   `31``|`     `590``|`   `SEA``|`        `SFO``|`
`|``01010955``|`  `104``|`     `590``|`   `SEA``|`        `SFO``|`
`|``01010730``|`    `5``|`     `590``|`   `SEA``|`        `SFO``|`
`+--------+-----+--------+------+-----------+`

在接下来的章节中,我们将使用这些数据执行 union、join 和 windowing 示例。

联合

Apache Spark 中的常见模式之一是将两个具有相同模式的不同 DataFrame 联合在一起。这可以通过union()方法实现:

// Scala
// Union two tables
val bar = delays.union(foo)
bar.createOrReplaceTempView("bar")
bar.filter(expr("""origin == 'SEA' AND destination == 'SFO'
AND date LIKE '01010%' AND delay > 0""")).show()
# In Python
# Union two tables
bar = departureDelays.union(foo)
bar.createOrReplaceTempView("bar")

# Show the union (filtering for SEA and SFO in a specific time range)
bar.filter(expr("""origin == 'SEA' AND destination == 'SFO'
AND date LIKE '01010%' AND delay > 0""")).show()

bar DataFrame 是foodelays的并集。使用相同的过滤条件得到bar DataFrame,我们看到foo数据的重复,这是预期的:

-- In SQL
spark.sql("""
SELECT * 
 FROM bar 
 WHERE origin = 'SEA' 
 AND destination = 'SFO' 
 AND date LIKE '01010%' 
 AND delay > 0
""").show()

+--------+-----+--------+------+-----------+
|    date|delay|distance|origin|destination|
+--------+-----+--------+------+-----------+
|01010710|   31|     590|   SEA|        SFO|
|01010955|  104|     590|   SEA|        SFO|
|01010730|    5|     590|   SEA|        SFO|
|01010710|   31|     590|   SEA|        SFO|
|01010955|  104|     590|   SEA|        SFO|
|01010730|    5|     590|   SEA|        SFO|
+--------+-----+--------+------+-----------+

连接

一个常见的 DataFrame 操作是将两个 DataFrame(或表)连接在一起。默认情况下,Spark SQL 连接是一个inner join,选项包括innercrossouterfullfull_outerleftleft_outerrightright_outerleft_semileft_anti。更多信息请参阅文档(适用于 Scala 和 Python)。

以下代码示例执行了airportsnafoo DataFrames 之间的默认inner连接:

// In Scala
foo.join(
  airports.as('air), 
  $"air.IATA" === $"origin"
).select("City", "State", "date", "delay", "distance", "destination").show()
# In Python
# Join departure delays data (foo) with airport info
foo.join(
  airports, 
  airports.IATA == foo.origin
).select("City", "State", "date", "delay", "distance", "destination").show()
-- In SQL
spark.sql("""
SELECT a.City, a.State, f.date, f.delay, f.distance, f.destination 
 FROM foo f
 JOIN airports_na a
 ON a.IATA = f.origin
""").show()

上述代码允许您查看从foo DataFrame 连接到airports DataFrame 的城市和州信息的日期、延误、距离和目的地信息:

+-------+-----+--------+-----+--------+-----------+
|   City|State|    date|delay|distance|destination|
+-------+-----+--------+-----+--------+-----------+
|Seattle|   WA|01010710|   31|     590|        SFO|
|Seattle|   WA|01010955|  104|     590|        SFO|
|Seattle|   WA|01010730|    5|     590|        SFO|
+-------+-----+--------+-----+--------+-----------+

窗口化

窗口函数使用窗口中行的值(一系列输入行)来返回一组值,通常以另一行的形式。使用窗口函数,可以在一组行上操作,同时为每个输入行返回单个值。在本节中,我们将展示如何使用dense_rank()窗口函数;如表 5-5 所示,还有许多其他函数。

表 5-5. 窗口函数

SQL DataFrame API
排名函数 rank() rank()
dense_rank() denseRank()
percent_rank() percentRank()
ntile() ntile()
row_number() rowNumber()
分析函数 cume_dist() cumeDist()
first_value() firstValue()
last_value() lastValue()
lag() lag()
lead() lead()

让我们从查看源自西雅图(SEA)、旧金山(SFO)和纽约市(JFK)并且前往特定目的地的航班所经历的TotalDelays(通过sum(Delay)计算)开始。

-- In SQL
DROP TABLE IF EXISTS departureDelaysWindow;

CREATE TABLE departureDelaysWindow AS
SELECT origin, destination, SUM(delay) AS TotalDelays 
  FROM departureDelays 
 WHERE origin IN ('SEA', 'SFO', 'JFK') 
   AND destination IN ('SEA', 'SFO', 'JFK', 'DEN', 'ORD', 'LAX', 'ATL') 
 GROUP BY origin, destination;

SELECT * FROM departureDelaysWindow

+------+-----------+-----------+
|origin|destination|TotalDelays|
+------+-----------+-----------+
|   JFK|        ORD|       5608|
|   SEA|        LAX|       9359|
|   JFK|        SFO|      35619|
|   SFO|        ORD|      27412|
|   JFK|        DEN|       4315|
|   SFO|        DEN|      18688|
|   SFO|        SEA|      17080|
|   SEA|        SFO|      22293|
|   JFK|        ATL|      12141|
|   SFO|        ATL|       5091|
|   SEA|        DEN|      13645|
|   SEA|        ATL|       4535|
|   SEA|        ORD|      10041|
|   JFK|        SEA|       7856|
|   JFK|        LAX|      35755|
|   SFO|        JFK|      24100|
|   SFO|        LAX|      40798|
|   SEA|        JFK|       4667|
+------+-----------+-----------+

如果对于每个起点机场,您想要找到经历最多延误的三个目的地怎么办?您可以为每个起点运行三个不同的查询,然后像这样合并结果:

-- In SQL
SELECT origin, destination, SUM(TotalDelays) AS TotalDelays
 FROM departureDelaysWindow
WHERE origin = '[ORIGIN]'
GROUP BY origin, destination
ORDER BY SUM(TotalDelays) DESC
LIMIT 3

其中[ORIGIN]JFKSEASFO的三个不同起点值。

但更好的方法是使用类似dense_rank()的窗口函数来执行以下计算:

-- In SQL
spark.sql("""
SELECT origin, destination, TotalDelays, rank 
 FROM ( 
 SELECT origin, destination, TotalDelays, dense_rank() 
 OVER (PARTITION BY origin ORDER BY TotalDelays DESC) as rank 
 FROM departureDelaysWindow
 ) t 
 WHERE rank <= 3
""").show()

+------+-----------+-----------+----+
|origin|destination|TotalDelays|rank|
+------+-----------+-----------+----+
|   SEA|        SFO|      22293|   1|
|   SEA|        DEN|      13645|   2|
|   SEA|        ORD|      10041|   3|
|   SFO|        LAX|      40798|   1|
|   SFO|        ORD|      27412|   2|
|   SFO|        JFK|      24100|   3|
|   JFK|        LAX|      35755|   1|
|   JFK|        SFO|      35619|   2|
|   JFK|        ATL|      12141|   3|
+------+-----------+-----------+----+

通过使用dense_rank()窗口函数,我们可以快速确定对于这三个起点城市而言,延误最严重的目的地是:

  • 西雅图(SEA):旧金山(SFO)、丹佛(DEN)和芝加哥(ORD)

  • 旧金山(SFO):洛杉矶(LAX)、芝加哥(ORD)和纽约(JFK)

  • 纽约(JFK):洛杉矶(LAX)、旧金山(SFO)和亚特兰大(ATL)

需要注意的是,每个窗口分组需要适合单个执行器,并在执行期间组成单个分区。因此,需要确保查询不是无界的(即限制窗口的大小)。

修改

另一个常见操作是对 DataFrame 执行修改。虽然 DataFrame 本身是不可变的,但可以通过创建新的、不同的 DataFrame 进行修改,例如添加不同的列。 (回想一下前几章中提到的底层 RDD 是不可变的——即不能更改的——以确保 Spark 操作有数据血统。)让我们从之前的小 DataFrame 示例开始:

// In Scala/Python
foo.show()

--------+-----+--------+------+-----------+
|    date|delay|distance|origin|destination|
+--------+-----+--------+------+-----------+
|01010710|   31|     590|   SEA|        SFO|
|01010955|  104|     590|   SEA|        SFO|
|01010730|    5|     590|   SEA|        SFO|
+--------+-----+--------+------+-----------+

添加新列

要向foo DataFrame 添加新列,请使用withColumn()方法:

// In Scala
import org.apache.spark.sql.functions.expr
val foo2 = foo.withColumn(
              "status", 
              expr("CASE WHEN delay <= 10 THEN 'On-time' ELSE 'Delayed' END")
           )
# In Python
from pyspark.sql.functions import expr
foo2 = (foo.withColumn(
          "status", 
          expr("CASE WHEN delay <= 10 THEN 'On-time' ELSE 'Delayed' END")
        ))

新创建的foo2 DataFrame 包含了原始foo DataFrame 的内容,还增加了由CASE语句定义的status列:

// In Scala/Python
foo2.show()

+--------+-----+--------+------+-----------+-------+
|    date|delay|distance|origin|destination| status|
+--------+-----+--------+------+-----------+-------+
|01010710|   31|     590|   SEA|        SFO|Delayed|
|01010955|  104|     590|   SEA|        SFO|Delayed|
|01010730|    5|     590|   SEA|        SFO|On-time|
+--------+-----+--------+------+-----------+-------+

处理数据时,有时候需要交换列和行,即[*些使用高阶函数的示例。最后,我们讨论了一些常见的关系运算符,并展示了如何执行一些 DataFrame 操作。

要删除列,请使用drop()方法。例如,让我们移除delay列,因为在上一节中已经添加了status列:

// In Scala
val foo3 = foo2.drop("delay")
foo3.show()
# In Python
foo3 = foo2.drop("delay")
foo3.show()

+--------+--------+------+-----------+-------+
|    date|distance|origin|destination| status|
+--------+--------+------+-----------+-------+
|01010710|     590|   SEA|        SFO|Delayed|
|01010955|     590|   SEA|        SFO|Delayed|
|01010730|     590|   SEA|        SFO|On-time|
+--------+--------+------+-----------+-------+

重命名列

可以使用rename()方法重命名列:

// In Scala
val foo4 = foo3.withColumnRenamed("status", "flight_status")
foo4.show()
# In Python
foo4 = foo3.withColumnRenamed("status", "flight_status")
foo4.show()

+--------+--------+------+-----------+-------------+
|    date|distance|origin|destination|flight_status|
+--------+--------+------+-----------+-------------+
|01010710|     590|   SEA|        SFO|      Delayed|
|01010955|     590|   SEA|        SFO|      Delayed|
|01010730|     590|   SEA|        SFO|      On-time|
+--------+--------+------+-----------+-------------+

透视

在 当处理数据时,有时需要交换列和行,即将数据透视。让我们获取一些数据来演示这个概念:

-- In SQL
SELECT destination, CAST(SUBSTRING(date, 0, 2) AS int) AS month, delay 
  FROM departureDelays 
 WHERE origin = 'SEA'

+-----------+-----+-----+
|destination|month|delay|
+-----------+-----+-----+
|        ORD|    1|   92|
|        JFK|    1|   -7|
|        DFW|    1|   -5|
|        MIA|    1|   -3|
|        DFW|    1|   -3|
|        DFW|    1|    1|
|        ORD|    1|  -10|
|        DFW|    1|   -6|
|        DFW|    1|   -2|
|        ORD|    1|   -3|
+-----------+-----+-----+
only showing top 10 rows

透视允许你将名称放置在month列中(而不是12,你可以显示JanFeb),并对目的地和月份的延迟进行聚合计算(本例中为平均值和最大值):

-- In SQL
SELECT * FROM (
SELECT destination, CAST(SUBSTRING(date, 0, 2) AS int) AS month, delay 
  FROM departureDelays WHERE origin = 'SEA' 
) 
PIVOT (
  CAST(AVG(delay) AS DECIMAL(4, 2)) AS AvgDelay, MAX(delay) AS MaxDelay
  FOR month IN (1 JAN, 2 FEB)
)
ORDER BY destination

+-----------+------------+------------+------------+------------+
|destination|JAN_AvgDelay|JAN_MaxDelay|FEB_AvgDelay|FEB_MaxDelay|
+-----------+------------+------------+------------+------------+
|        ABQ|       19.86|         316|       11.42|          69|
|        ANC|        4.44|         149|        7.90|         141|
|        ATL|       11.98|         397|        7.73|         145|
|        AUS|        3.48|          50|       -0.21|          18|
|        BOS|        7.84|         110|       14.58|         152|
|        BUR|       -2.03|          56|       -1.89|          78|
|        CLE|       16.00|          27|        null|        null|
|        CLT|        2.53|          41|       12.96|         228|
|        COS|        5.32|          82|       12.18|         203|
|        CVG|       -0.50|           4|        null|        null|
|        DCA|       -1.15|          50|        0.07|          34|
|        DEN|       13.13|         425|       12.95|         625|
|        DFW|        7.95|         247|       12.57|         356|
|        DTW|        9.18|         107|        3.47|          77|
|        EWR|        9.63|         236|        5.20|         212|
|        FAI|        1.84|         160|        4.21|          60|
|        FAT|        1.36|         119|        5.22|         232|
|        FLL|        2.94|          54|        3.50|          40|
|        GEG|        2.28|          63|        2.87|          60|
|        HDN|       -0.44|          27|       -6.50|           0|
+-----------+------------+------------+------------+------------+
only showing top 20 rows

总结

本章探讨了 Spark SQL 如何与外部组件进行交互。我们讨论了创建用户定义函数(包括 Pandas UDFs),并介绍了执行 Spark SQL 查询的一些选项(包括 Spark SQL shell、Beeline 和 Tableau)。然后,我们提供了如何使用 Spark SQL 与各种外部数据源连接的示例,如 SQL 数据库、PostgreSQL、MySQL、Tableau、Azure Cosmos DB、MS SQL Server 等。

我们探讨了 Spark 用于复杂数据类型的内置函数,并给出了使用高阶函数的一些示例。最后,我们讨论了一些常见的关系操作符,并展示了如何执行一系列 DataFrame 操作。

在下一章中,我们将探讨如何处理 Datasets,强类型操作的好处,以及何时以及为何使用它们。

¹ 当前的 Spark SQL 引擎在实现中不再使用 Hive 代码。

² 注意,在处理 Pandas UDF 时,Spark 2.32.43.0之间有些许差异。

第六章:Spark SQL 和 Datasets

在第四章和第五章中,我们介绍了 Spark SQL 和 DataFrame API。我们探讨了如何连接内置和外部数据源,瞥见了 Spark SQL 引擎,并探索了 SQL 和 DataFrame 之间的互操作性,创建和管理视图和表,以及高级 DataFrame 和 SQL 转换。

虽然我们在第三章中简要介绍了 Dataset API,但我们只是简略地涉及了如何创建、存储、序列化和反序列化 Datasets —— 强类型分布式集合的显著方面。

在本章中,我们深入了解 Datasets:我们将探讨在 Java 和 Scala 中使用 Datasets,Spark 如何管理内存以容纳 Dataset 构造作为高级 API 的一部分,以及使用 Datasets 所涉及的成本。

Java 和 Scala 的单一 API

正如你可能记得的那样,在第三章中(图 3-1 和表 3-6),Datasets 提供了一个统一且独特的 API 用于强类型对象。在 Spark 支持的语言中,只有 Scala 和 Java 是强类型的;因此,Python 和 R 只支持无类型的 DataFrame API。

Datasets 是特定于领域的强类型对象,可以使用函数式编程或来自 DataFrame API 的 DSL 操作符并行操作它们。

由于这个统一的 API,Java 开发者不再面临落后的风险。例如,对于 Scala 的 groupBy()flatMap()map()filter() API 的任何未来接口或行为更改,对于 Java 也是相同的,因为它是一个公共的统一接口。

Scala 的 Case Classes 和 JavaBeans 用于 Datasets

如果你还记得从第三章中(表 3-2),Spark 拥有内部数据类型,如 StringTypeBinaryTypeIntegerTypeBooleanTypeMapType,它在 Spark 操作期间使用这些类型无缝映射到 Scala 和 Java 的语言特定数据类型。这种映射是通过编码器完成的,我们将在本章后面讨论它们。

要创建 Dataset[T],其中 T 是你在 Scala 中定义的类型化对象,你需要一个案例类来定义这个对象。使用我们在第三章中的示例数据(表 3-1),假设我们有一个 JSON 文件,包含数百万条关于博客作者写作 Apache Spark 的条目,格式如下:

{id: 1, first: "Jules", last: "Damji", url: "https://tinyurl.1", date: 
"1/4/2016", hits: 4535, campaigns: {"twitter", "LinkedIn"}},
...
{id: 87, first: "Brooke", last: "Wenig", url: "https://tinyurl.2", date:
"5/5/2018", hits: 8908, campaigns: {"twitter", "LinkedIn"}}

要创建分布式的 Dataset[Bloggers],我们必须先定义一个 Scala 案例类,该类定义了构成 Scala 对象的每个单独字段。这个案例类作为 Bloggers 类型对象的蓝图或模式:

// In Scala
case class Bloggers(id:Int, first:String, last:String, url:String, date:String, 
hits: Int, campaigns:Array[String])

现在我们可以从数据源读取文件:

val bloggers = "../data/bloggers.json"
val bloggersDS = spark
  .read
  .format("json")
  .option("path", bloggers)
  .load()
  .as[Bloggers]

结果分布式数据集中的每行都是类型为 Bloggers 的对象。

同样地,您可以在 Java 中创建一个名为 Bloggers 的 JavaBean 类,然后使用编码器创建一个 Dataset<Bloggers>

// In Java
import org.apache.spark.sql.Encoders;
import java.io.Serializable;

public class Bloggers implements Serializable {
    private int id;
    private String first;
    private String last;
    private String url;
    private String date;
    private int hits;
    private Array[String] campaigns;

// JavaBean getters and setters
int getID() { return id; }
void setID(int i) { id = i; }
String getFirst() { return first; }
void setFirst(String f) { first = f; }
String getLast() { return last; }
void setLast(String l) { last = l; }
String getURL() { return url; }
void setURL (String u) { url = u; }
String getDate() { return date; }
Void setDate(String d) { date = d; }
int getHits() { return hits; }
void setHits(int h) { hits = h; }

Array[String] getCampaigns() { return campaigns; }
void setCampaigns(Array[String] c) { campaigns = c; }
}

// Create Encoder
Encoder<Bloggers> BloggerEncoder = Encoders.bean(Bloggers.class);
String bloggers = "../bloggers.json"
Dataset<Bloggers>bloggersDS = spark
  .read
  .format("json")
  .option("path", bloggers)
  .load()
  .as(BloggerEncoder);

正如您所看到的,在 Scala 和 Java 中创建数据集需要一些预先考虑,因为您必须了解读取的每行的所有单独列名和类型。与 DataFrame 不同,您可以选择让 Spark 推断模式,但是 Dataset API 要求您提前定义数据类型,并且您的案例类或 JavaBean 类必须与您的模式匹配。

注意

在 Scala 案例类或 Java 类定义中,字段的名称必须与数据源中的顺序匹配。数据中每行的列名会自动映射到类中相应的名称,并且类型会自动保留。

如果字段名称与输入数据匹配,则可以使用现有的 Scala 案例类或 JavaBean 类。使用 Dataset API 与使用 DataFrame 一样简单、简洁和声明性。对于大多数数据集的转换,您可以使用与之前章节中学习的相同的关系运算符。

让我们来看一些使用示例数据集的方面。

使用数据集

创建示例数据集的一种简单而动态的方式是使用 SparkSession 实例。在此场景中,为了说明目的,我们动态创建一个 Scala 对象,其中包含三个字段:uid(用户的唯一 ID)、uname(随机生成的用户名字符串)和usage(服务器或服务使用的分钟数)。

创建示例数据

首先,让我们生成一些示例数据:

// In Scala
import scala.util.Random._
// Our case class for the Dataset
case class Usage(uid:Int, uname:String, usage: Int)
val r = new scala.util.Random(42)
// Create 1000 instances of scala Usage class 
// This generates data on the fly
val data = for (i <- 0 to 1000) 
  yield (Usage(i, "user-" + r.alphanumeric.take(5).mkString(""),
  r.nextInt(1000)))
// Create a Dataset of Usage typed data
val dsUsage = spark.createDataset(data)
dsUsage.show(10)

+---+----------+-----+
|uid|     uname|usage|
+---+----------+-----+
|  0|user-Gpi2C|  525|
|  1|user-DgXDi|  502|
|  2|user-M66yO|  170|
|  3|user-xTOn6|  913|
|  4|user-3xGSz|  246|
|  5|user-2aWRN|  727|
|  6|user-EzZY1|   65|
|  7|user-ZlZMZ|  935|
|  8|user-VjxeG|  756|
|  9|user-iqf1P|    3|
+---+----------+-----+
only showing top 10 rows

在 Java 中,思路类似,但我们必须使用显式的 Encoder(在 Scala 中,Spark 会隐式处理这一点):

// In Java
import org.apache.spark.sql.Encoders;
import org.apache.commons.lang3.RandomStringUtils;
import java.io.Serializable;
import java.util.Random;
import java.util.ArrayList;
import java.util.List;

// Create a Java class as a Bean
public class Usage implements Serializable {
   int uid;                // user id
   String uname;           // username
   int usage;              // usage

   public Usage(int uid, String uname, int usage) {
       this.uid = uid;
       this.uname = uname;
       this.usage = usage;
   }
   // JavaBean getters and setters 
   public int getUid() { return this.uid; }
   public void setUid(int uid) { this.uid = uid; }
   public String getUname() { return this.uname; }
   public void setUname(String uname) { this.uname = uname; }
   public int getUsage() { return this.usage; }
   public void setUsage(int usage) { this.usage = usage; }

   public Usage() {
   }

   public String toString() {
       return "uid: '" + this.uid + "', uame: '" + this.uname + "', 
 usage: '" + this.usage + "'";
   }
}

// Create an explicit Encoder 
Encoder<Usage> usageEncoder = Encoders.bean(Usage.class);
Random rand = new Random();
rand.setSeed(42);
List<Usage> data = new ArrayList<Usage>()

// Create 1000 instances of Java Usage class 
for (int i = 0; i < 1000; i++) {
  data.add(new Usage(i, "user" + 
  RandomStringUtils.randomAlphanumeric(5),
  rand.nextInt(1000));

// Create a Dataset of Usage typed data
Dataset<Usage> dsUsage = spark.createDataset(data, usageEncoder);
注意

Scala 和 Java 生成的数据集会有所不同,因为随机种子算法可能不同。因此,Scala 和 Java 的查询结果会有所不同。

现在我们有了生成的数据集 dsUsage,让我们执行一些在之前章节中已经做过的常见转换。

转换示例数据

请记住,数据集是强类型的特定领域对象的集合。这些对象可以并行转换,使用功能或关系操作。这些转换的示例包括 map()reduce()filter()select()aggregate()。作为高阶函数的示例,这些方法可以接受 lambda、闭包或函数作为参数并返回结果。因此,它们非常适合函数式编程

Scala 是一种函数式编程语言,最近 Java 也添加了 lambda、函数参数和闭包。让我们在 Spark 中尝试一些高阶函数,并使用之前创建的样本数据进行函数式编程构造。

高阶函数和函数式编程

举个简单的例子,让我们使用 filter() 返回 dsUsage Dataset 中所有使用超过 900 分钟的用户。一种方法是使用函数表达式作为 filter() 方法的参数:

// In Scala
import org.apache.spark.sql.functions._
dsUsage
  .filter(d => d.usage > 900)
  .orderBy(desc("usage"))
  .show(5, false)

另一种方式是定义一个函数,并将该函数作为 filter() 的参数提供:

def filterWithUsage(u: Usage) = u.usage > 900
dsUsage.filter(filterWithUsage(_)).orderBy(desc("usage")).show(5)

+---+----------+-----+
|uid|     uname|usage|
+---+----------+-----+
|561|user-5n2xY|  999|
|113|user-nnAXr|  999|
|605|user-NL6c4|  999|
|634|user-L0wci|  999|
|805|user-LX27o|  996|
+---+----------+-----+
only showing top 5 rows

在第一个案例中,我们使用了一个 lambda 表达式 {d.usage > 900} 作为 filter() 方法的参数,而在第二个案例中,我们定义了一个 Scala 函数 def filterWithUsage(u: Usage) = u.usage > 900。在两种情况下,filter() 方法迭代分布式 Dataset 中的每一行 Usage 对象,并应用表达式或执行函数,返回一个新的 Usage 类型的 Dataset,其中表达式或函数的值为 true。(详见 Scala 文档 获取方法签名的详细信息。)

在 Java 中,filter() 的参数类型为 FilterFunction<T>。这可以匿名内联定义,也可以使用命名函数。在本例中,我们将通过命名方式定义我们的函数,并将其赋值给变量 f。将此函数应用于 filter() 将返回一个新的 Dataset,其中包含所有满足我们过滤条件的行(条件为 true):

// In Java
// Define a Java filter function
FilterFunction<Usage> f = new FilterFunction<Usage>() {
   public boolean call(Usage u) {
       return (u.usage > 900);
   }
};

// Use filter with our function and order the results in descending order
dsUsage.filter(f).orderBy(col("usage").desc()).show(5);

+---+----------+-----+
|uid|uname     |usage|
+---+----------+-----+
|67 |user-qCGvZ|997  |
|878|user-J2HUU|994  |
|668|user-pz2Lk|992  |
|750|user-0zWqR|991  |
|242|user-g0kF6|989  |
+---+----------+-----+
only showing top 5 rows

并非所有的 lambda 或函数参数都必须评估为 Boolean 值;它们也可以返回计算出的值。考虑使用高阶函数 map() 的这个例子,我们的目的是找出每个 usage 值超过某个阈值的用户的使用费用,以便我们可以为这些用户提供每分钟的特价。

// In Scala
// Use an if-then-else lambda expression and compute a value
dsUsage.map(u => {if (u.usage > 750) u.usage * .15 else u.usage * .50 })
  .show(5, false)
// Define a function to compute the usage
def computeCostUsage(usage: Int): Double = {
  if (usage > 750) usage * 0.15 else usage * 0.50
}
// Use the function as an argument to map()
dsUsage.map(u => {computeCostUsage(u.usage)}).show(5, false)
+------+
|value |
+------+
|262.5 |
|251.0 |
|85.0  |
|136.95|
|123.0 |
+------+
only showing top 5 rows

要在 Java 中使用 map(),必须定义一个 MapFunction<T>。这可以是匿名类,也可以是扩展了 MapFunction<T> 的定义类。在本例中,我们在方法调用本身内联使用它:

// In Java
// Define an inline MapFunction
dsUsage.map((MapFunction<Usage, Double>) u -> {
   if (u.usage > 750)
       return u.usage * 0.15;
   else
       return u.usage * 0.50;
}, Encoders.DOUBLE()).show(5); // We need to explicitly specify the Encoder
+------+
|value |
+------+
|65.0  |
|114.45|
|124.0 |
|132.6 |
|145.5 |
+------+
only showing top 5 rows

尽管我们已经计算出了使用费用的值,但我们不知道这些计算值与哪些用户相关联。我们如何获取这些信息呢?

步骤很简单:

  1. 创建一个 Scala case 类或 JavaBean 类 UsageCost,带有一个名为 cost 的附加字段或列。

  2. 定义一个函数来计算 cost,并在 map() 方法中使用它。

这在 Scala 中的实现如下:

// In Scala
// Create a new case class with an additional field, cost
case class UsageCost(uid: Int, uname:String, usage: Int, cost: Double)

// Compute the usage cost with Usage as a parameter
// Return a new object, UsageCost
def computeUserCostUsage(u: Usage): UsageCost = {
  val v = if (u.usage > 750) u.usage * 0.15 else u.usage * 0.50
    UsageCost(u.uid, u.uname, u.usage, v)
}

// Use map() on our original Dataset
dsUsage.map(u => {computeUserCostUsage(u)}).show(5)

+---+----------+-----+------+
|uid|     uname|usage|  cost|
+---+----------+-----+------+
|  0|user-Gpi2C|  525| 262.5|
|  1|user-DgXDi|  502| 251.0|
|  2|user-M66yO|  170|  85.0|
|  3|user-xTOn6|  913|136.95|
|  4|user-3xGSz|  246| 123.0|
+---+----------+-----+------+
only showing top 5 rows

现在,我们有一个经过转换的 Dataset,其中包含通过我们 map() 转换中的函数计算的新列 cost,以及所有其他列。

同样,在 Java 中,如果我们想要每个用户关联的成本,我们需要定义一个 JavaBean 类 UsageCostMapFunction<T>。有关完整的 JavaBean 示例,请参见本书的GitHub repo;为简洁起见,我们仅在此展示内联的 MapFunction<T>

// In Java
// Get the Encoder for the JavaBean class
Encoder<UsageCost> usageCostEncoder = Encoders.bean(UsageCost.class);

// Apply map() function to our data
dsUsage.map( (MapFunction<Usage, UsageCost>) u -> {
       double v = 0.0;
       if (u.usage > 750) v = u.usage * 0.15; else v = u.usage * 0.50;
       return new UsageCost(u.uid, u.uname,u.usage, v); },
		          usageCostEncoder).show(5);

+------+---+----------+-----+
|  cost|uid|     uname|usage|
+------+---+----------+-----+
|  65.0|  0|user-xSyzf|  130|
|114.45|  1|user-iOI72|  763|
| 124.0|  2|user-QHRUk|  248|
| 132.6|  3|user-8GTjo|  884|
| 145.5|  4|user-U4cU1|  970|
+------+---+----------+-----+
only showing top 5 rows

使用高阶函数和 Datasets 需要注意的几点:

  • 我们正在使用作为函数参数的类型化 JVM 对象。

  • 我们使用点符号(来自面向对象编程)来访问类型化的 JVM 对象内的各个字段,使其更易于阅读。

  • 我们的一些函数和 lambda 签名可以是类型安全的,确保在编译时检测错误,并指导 Spark 在哪些数据类型上工作,执行什么操作等。

  • 我们的代码可读性强,表达力强,使用 Java 或 Scala 语言特性中的 lambda 表达式。

  • Spark 提供了在 Java 和 Scala 中无需高阶函数构造的 map()filter() 的等效方法,因此您并不需要强制使用 Datasets 或 DataFrames 中的函数式编程。相反,您可以简单地使用条件 DSL 操作符或 SQL 表达式:例如,dsUsage.filter("usage > 900")dsUsage($"usage" > 900)。(有关更多信息,请参见 “Costs of Using Datasets”。)

  • 对于 Datasets,我们使用编码器来高效地在 JVM 和 Spark 的内部二进制格式之间转换数据类型(关于此的更多信息请见 “Dataset Encoders”)。

注意

高阶函数和函数式编程并不是 Spark Datasets 独有的;您也可以在 DataFrames 中使用它们。回想一下,一个 DataFrame 是一个 Dataset[Row],其中 Row 是一个通用的未类型化 JVM 对象,可以包含不同类型的字段。该方法签名接受在 Row 上操作的表达式或函数,这意味着每个 Row 的数据类型可以作为表达式或函数的输入值。

将 DataFrames 转换为 Datasets

为了对查询和结构进行强类型检查,您可以将 DataFrame 转换为 Datasets。要将现有的 DataFrame df 转换为类型为 SomeCaseClass 的 Dataset,只需使用 df.as[SomeCaseClass] 表示法。我们之前已经看到了一个例子:

// In Scala
val bloggersDS = spark
  .read
  .format("json")
  .option("path", "/data/bloggers/bloggers.json")
  .load()
  .as[Bloggers]

spark.read.format("json") 返回一个 DataFrame<Row>,在 Scala 中是 Dataset[Row] 的类型别名。使用 .as[Bloggers] 指示 Spark 使用编码器(本章后面讨论)来将对象从 Spark 的内部内存表示序列化/反序列化为 JVM Bloggers 对象。

Datasets 和 DataFrames 的内存管理

Spark 是一种高效的内存分布式大数据引擎,因此其对内存的高效利用对其执行速度至关重要。¹ 在其发布历史中,Spark 对内存的使用已经显著发展

  • Spark 1.0 使用基于 RDD 的 Java 对象进行内存存储、序列化和反序列化,这在资源和速度方面都很昂贵。此外,存储是在 Java 堆上分配的,因此在处理大数据集时受到 JVM 的垃圾回收(GC)的影响。

  • Spark 1.x 引入了Project Tungsten。其突出特点之一是引入了新的基于行的内部格式,用于在非堆内存中布局 Datasets 和 DataFrames,使用偏移量和指针。Spark 使用高效的encoders机制在 JVM 和其内部 Tungsten 格式之间进行序列化和反序列化。在非堆内存分配内存意味着 Spark 不会受到 GC 的过多限制。

  • Spark 2.x 引入了第二代 Tungsten 引擎,具备整体阶段代码生成和矢量化基于列的内存布局。建立在现代编译器的思想和技术之上,这个新版本还利用了现代 CPU 和缓存架构,采用“单指令多数据”(SIMD)方法进行快速并行数据访问。

Dataset Encoders

Encoders 将数据从 Spark 的内部 Tungsten 格式转换为 JVM Java 对象的非堆内存。换句话说,它们将 Spark 的 Dataset 对象从内部格式序列化和反序列化为 JVM 对象,包括基本数据类型。例如,一个Encoder[T]将会把数据从 Spark 的内部 Tungsten 格式转换为Dataset[T]

Spark 具有内置支持,可自动生成原始类型(例如字符串、整数、长整数)、Scala case 类和 JavaBeans 的 encoders。与 Java 和 Kryo 序列化和反序列化相比,Spark 的 encoders 速度显著更快

在我们之前的 Java 示例中,我们明确地创建了一个编码器:

Encoder<UsageCost> usageCostEncoder = Encoders.bean(UsageCost.class);

然而,对于 Scala,Spark 会自动为这些高效的转换器生成字节码。让我们来看看 Spark 的内部 Tungsten 基于行的格式。

Spark 的内部格式与 Java 对象格式对比

Java 对象具有较大的开销——头部信息、哈希码、Unicode 信息等。即使是一个简单的 Java 字符串如“abcd”,也占用 48 字节的存储空间,而不是你可能期望的 4 字节。想象一下创建一个MyClass(Int, String, String)对象的开销。

不创建基于 JVM 的 Datasets 或 DataFrames 对象,而是 Spark 分配非堆Java 内存来布局它们的数据,并使用 encoders 将数据从内存表示转换为 JVM 对象。例如,Figure 6-1 展示了 JVM 对象MyClass(Int, String, String)在内部的存储方式。

JVM 对象存储在由 Spark 管理的连续非堆 Java 内存中

图 6-1. JVM 对象存储在由 Spark 管理的连续非堆 Java 内存中

当数据以这种连续的方式存储并通过指针算术和偏移量访问时,编码器可以快速地序列化或反序列化这些数据。这意味着什么?

序列化和反序列化(SerDe)

在分布式计算中并不是一个新概念,数据经常在集群中的计算机节点之间传输,序列化和反序列化是发送方将一个类型化对象进行编码(序列化)成二进制表示或格式的过程,并由接收方将其从二进制格式进行解码(反序列化)成其相应的数据类型对象。

例如,如果在 Spark 集群中的节点之间共享 JVM 对象MyClass(参见图 6-1),发送方将其序列化为字节数组,接收方将其反序列化回类型为MyClass的 JVM 对象。

JVM 具有其内置的 Java 序列化器和反序列化器,但效率低下,因为(正如我们在前一节中看到的那样)JVM 在堆内存中创建的 Java 对象臃肿。因此,该过程较慢。

这就是数据集编码器发挥作用的地方,由于几个原因:

  • Spark 的内部 Tungsten 二进制格式(参见图 6-1 和 6-2)将对象存储在 Java 堆内存之外,并且紧凑,因此这些对象占用的空间较少。

  • 编码器可以通过使用简单的指针算术与内存地址和偏移量遍历内存,快速进行序列化(参见图 6-2)。

  • 在接收端,编码器可以快速将二进制表示反序列化为 Spark 内部的表示形式。编码器不受 JVM 垃圾收集暂停的影响。

Spark 内部 Tungsten 基于行的格式

图 6-2. Spark 内部 Tungsten 基于行的格式

然而,生活中的大多数好事都是要付出代价的,接下来我们将讨论这一点。

使用数据集的成本

在“数据框架与数据集”中的第三章,我们概述了使用数据集的一些好处,但这些好处也伴随着代价。如前一节所述,当数据集传递给高阶函数(如filter()map()flatMap())时,这些函数接受 Lambda 和函数参数,从 Spark 内部 Tungsten 格式反序列化为 JVM 对象存在成本。

与在引入 Spark 编码器之前使用的其他序列化器相比,这种成本是较小且可容忍的。然而,对于更大的数据集和许多查询,这种成本会累积,并可能影响性能。

缓解成本的策略

缓解过度序列化和反序列化的一种策略是在查询中使用 DSL 表达式,避免将 lambda 作为匿名函数传递给高阶函数。因为 lambda 是匿名的且在运行时对 Catalyst 优化器不透明,所以当您使用它们时,它不能有效地辨别您在做什么(您没有告诉 Spark 要做什么),因此不能优化您的查询(参见“The Catalyst Optimizer”在第三章)。

第二种策略是将您的查询链在一起,以最小化序列化和反序列化。在 Spark 中,将查询链在一起是一种常见的做法。

让我们通过一个简单的例子来说明。假设我们有一个类型为Person的数据集,其中Person定义为 Scala 案例类:

// In Scala
Person(id: Integer, firstName: String, middleName: String, lastName: String,
gender: String, birthDate: String, ssn: String, salary: String)

我们希望对这个数据集发出一系列查询,使用函数式编程。

让我们看一个案例,我们以一种低效的方式组合查询,从而无意中产生了重复序列化和反序列化的成本:

import java.util.Calendar
val earliestYear = Calendar.getInstance.get(Calendar.YEAR) - 40

personDS

  // Everyone above 40: lambda-1
  .filter(x => x.birthDate.split("-")(0).toInt > earliestYear)

  // Everyone earning more than 80K
  .filter($"salary" > 80000)

  // Last name starts with J: lambda-2
  .filter(x => x.lastName.startsWith("J"))

  // First name starts with D
  .filter($"firstName".startsWith("D"))
  .count()

正如您在图 6-3 中所观察到的那样,每当我们从 lambda 转到 DSL(filter($"salary" > 8000))时,我们都会产生序列化和反序列化Person JVM 对象的成本。

使用 lambda 和 DSL 串联查询的低效方法

图 6-3. 使用 lambda 和 DSL 串联查询的低效方法

相比之下,下面的查询仅使用 DSL 而不使用 lambda。因此,它效率更高——在整个组合和链式查询过程中不需要序列化/反序列化:

personDS
  .filter(year($"birthDate") > earliestYear) // Everyone above 40
  .filter($"salary" > 80000) // Everyone earning more than 80K
  .filter($"lastName".startsWith("J")) // Last name starts with J
  .filter($"firstName".startsWith("D")) // First name starts with D
  .count()

对于好奇的人,您可以在本书 GitHub 存储库的本章笔记本中查看两次运行之间的时间差异。

总结

本章详细讨论了如何在 Java 和 Scala 中处理数据集。我们探讨了 Spark 如何管理内存以适应数据集构造作为其统一且高级 API 的一部分,并考虑了使用数据集的一些成本及其如何减少这些成本。我们还展示了如何在 Spark 中使用 Java 和 Scala 的函数式编程构造。

最后,我们深入探讨了编码器如何将数据从 Spark 的内部 Tungsten 二进制格式序列化和反序列化为 JVM 对象。

在下一章中,我们将通过检查高效的 I/O 策略、优化和调整 Spark 配置,以及在调试 Spark 应用程序时要查找的属性和信号,来优化 Spark。

¹ 想要了解更多关于 Spark 如何管理内存的详细信息,请参阅文本和演示中提供的参考资料以及“Apache Spark Memory Management”“Deep Dive into Project Tungsten Bringing Spark Closer to Bare Metal”

第七章:优化和调整 Spark 应用程序

在前一章中,我们详细说明了如何在 Java 和 Scala 中处理数据集。我们探讨了 Spark 如何管理内存以适应数据集构造作为其统一和高级 API 的一部分,以及考虑了使用数据集的成本及其如何减轻这些成本。

除了降低成本外,我们还希望考虑如何优化和调整 Spark。在本章中,我们将讨论一组启用优化的 Spark 配置,查看 Spark 的连接策略系列,并检查 Spark UI,寻找不良行为的线索。

优化和调整 Spark 的效率

虽然 Spark 有许多用于调优的配置,但本书只涵盖了一些最重要和常调整的配置。要获得按功能主题分组的全面列表,您可以查阅文档

查看和设置 Apache Spark 配置

你可以通过三种方式获取和设置 Spark 的属性。首先是通过一组配置文件。在你部署的 $SPARK_HOME 目录(即你安装 Spark 的地方),有一些配置文件:conf/spark-defaults.conf.templateconf/log4j.properties.templateconf/spark-env.sh.template。修改这些文件中的默认值,并去掉 .template 后缀后保存,Spark 将使用这些新值。

注意

conf/spark-defaults.conf 文件中的配置更改适用于 Spark 集群和提交到集群的所有 Spark 应用程序。

第二种方法是直接在 Spark 应用程序中或在使用 spark-submit 提交应用程序时的命令行中指定 Spark 配置,使用 --conf 标志:

spark-submit --conf spark.sql.shuffle.partitions=5 --conf
"spark.executor.memory=2g" --class main.scala.chapter7.SparkConfig_7_1 jars/main-
scala-chapter7_2.12-1.0.jar

下面是在 Spark 应用程序中如何操作:

// In Scala import org.apache.spark.sql.SparkSession

def printConfigs(session: SparkSession) = {
   // Get conf
   val mconf = session.conf.getAll
   // Print them
   for (k <- mconf.keySet) { println(s"`$`{k} -> `$`{mconf(k)}\n") }
}

def main(args: Array[String]) {
 // Create a session
 val spark = SparkSession.builder
   .config("spark.sql.shuffle.partitions", 5)
   .config("spark.executor.memory", "2g")
   .master("local[*]")
   .appName("SparkConfig")
   .getOrCreate()

 printConfigs(spark)
 spark.conf.set("spark.sql.shuffle.partitions",
   spark.sparkContext.defaultParallelism)
 println(" ****** Setting Shuffle Partitions to Default Parallelism")
 printConfigs(spark)
}

spark.driver.host -> 10.8.154.34
spark.driver.port -> 55243
spark.app.name -> SparkConfig
spark.executor.id -> driver
spark.master -> local[*]
spark.executor.memory -> 2g
spark.app.id -> local-1580162894307
spark.sql.shuffle.partitions -> 5

第三种选项是通过 Spark shell 的程序化接口。与 Spark 中的其他一切一样,API 是主要的交互方法。通过 SparkSession 对象,您可以访问大多数 Spark 配置设置。

例如,在 Spark REPL 中,以下 Scala 代码显示了在本地主机上以本地模式启动 Spark(有关可用的不同模式的详细信息,请参见 “部署模式” 在 第一章)时的 Spark 配置:

// In Scala
// mconf is a Map[String, String] 
scala> val mconf = spark.conf.getAll
...
scala> for (k <- mconf.keySet) { println(s"${k} -> ${mconf(k)}\n") }

spark.driver.host -> 10.13.200.101
spark.driver.port -> 65204
spark.repl.class.uri -> spark://10.13.200.101:65204/classes
spark.jars ->
spark.repl.class.outputDir -> /private/var/folders/jz/qg062ynx5v39wwmfxmph5nn...
spark.app.name -> Spark shell
spark.submit.pyFiles ->
spark.ui.showConsoleProgress -> true
spark.executor.id -> driver
spark.submit.deployMode -> client
spark.master -> local[*]
spark.home -> /Users/julesdamji/spark/spark-3.0.0-preview2-bin-hadoop2.7
spark.sql.catalogImplementation -> hive
spark.app.id -> local-1580144503745

你也可以查看仅限于 Spark SQL 的特定 Spark 配置:

// In Scala
spark.sql("SET -v").select("key", "value").show(5, false)
# In Python
spark.sql("SET -v").select("key", "value").show(n=5, truncate=False)

+------------------------------------------------------------+-----------+
|key                                                         |value      |
+------------------------------------------------------------+-----------+
|spark.sql.adaptive.enabled                                  |false      |
|spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin   |0.2        |
|spark.sql.adaptive.shuffle.fetchShuffleBlocksInBatch.enabled|true       |
|spark.sql.adaptive.shuffle.localShuffleReader.enabled       |true       |
|spark.sql.adaptive.shuffle.maxNumPostShufflePartitions      |<undefined>|
+------------------------------------------------------------+-----------+
only showing top 5 rows

或者,你可以通过 Spark UI 的环境标签页访问当前 Spark 的配置,我们将在本章后面讨论,这些值是只读的,如 图 7-1 所示。

Spark 3.0 UI 的环境标签页

图 7-1. Spark 3.0 UI 的环境标签页

要以编程方式设置或修改现有配置,首先检查属性是否可修改。spark.conf.isModifiable("*<config_name>*")将返回truefalse。可以使用 API 将所有可修改的配置设置为新值:

// In Scala scala> `spark``.``conf``.``get``(``"spark.sql.shuffle.partitions"``)`
res26: String = 200
scala> `spark``.``conf``.``set``(``"spark.sql.shuffle.partitions"``,` `5``)`
scala> `spark``.``conf``.``get``(``"spark.sql.shuffle.partitions"``)`
res28: String = 5
# In Python
>>> `spark``.``conf``.``get``(``"``spark.sql.shuffle.partitions``"``)`
'200'
>>> `spark``.``conf``.``set``(``"``spark.sql.shuffle.partitions``"``,` `5``)`
>>> `spark``.``conf``.``get``(``"``spark.sql.shuffle.partitions``"``)`
'5'

在所有可以设置 Spark 属性的方式中,存在一个优先顺序确定哪些值将被采用。首先读取spark-defaults.conf中定义的所有值或标志,然后是使用spark-submit命令行提供的值,最后是通过 SparkSession 在 Spark 应用程序中设置的值。所有这些属性将被合并,重置在 Spark 应用程序中的重复属性将优先。同样地,通过命令行提供的值将覆盖配置文件中的设置,前提是它们未在应用程序本身中被覆盖。

调整或提供正确的配置有助于提高性能,这一点将在下一节中详细讨论。这里的建议来自社区从业者的观察,专注于如何最大化 Spark 集群资源利用率,以适应大规模工作负载。

为大规模工作负载扩展 Spark

大规模的 Spark 工作负载通常是批处理作业——有些在每晚运行,有些则在白天定期调度。无论哪种情况,这些作业可能处理数十 TB 甚至更多的数据。为了避免由于资源匮乏或性能逐渐下降而导致作业失败,有几个 Spark 配置可以启用或修改。这些配置影响三个 Spark 组件:Spark 驱动程序、执行器以及执行器上运行的洗牌服务。

Spark 驱动程序的责任是与集群管理器协调,在集群中启动执行器并调度 Spark 任务。在大型工作负载下,您可能会有数百个任务。本节解释了您可以调整或启用的一些配置,以优化资源利用率,并行化任务,避免大量任务的瓶颈。一些优化思路和见解来自像 Facebook 这样的大数据公司,在使用 Spark 处理 TB 级数据时分享给了 Spark 社区,并在 Spark + AI Summit 上进行了交流。¹

静态与动态资源分配

当您将计算资源作为命令行参数传递给spark-submit时,就像我们之前所做的那样,您限制了资源上限。这意味着,如果由于比预期更大的工作负载导致任务在驱动程序中排队,那么后续可能需要更多资源,Spark 将无法提供或分配额外的资源。

如果您使用 Spark 的动态资源分配配置,Spark 驱动程序可以根据大型工作负载的需求请求更多或更少的计算资源。在您的工作负载动态变化的场景中,即它们在计算容量需求上有所变化时,使用动态分配有助于适应突发的高峰需求。

这种技术可以帮助的一个用例是流式处理,在这种情况下,数据流量可能是不均匀的。另一个用例是按需数据分析,在高峰时段可能会有大量的 SQL 查询。启用动态资源分配允许 Spark 更好地利用资源,当执行器空闲时释放它们,并在需要时获取新的执行器。

注意

在处理大型或变化工作负载时,动态分配同样在多租户环境中非常有用,此时 Spark 可能与 YARN、Mesos 或 Kubernetes 中的其他应用或服务一同部署。不过需要注意的是,Spark 的资源需求变化可能会影响同时需求资源的其他应用程序。

要启用和配置动态分配,您可以使用以下设置。请注意,这里的数字是任意的;适当的设置取决于您的工作负载的性质,并且应相应调整。某些配置不能在 Spark REPL 内设置,因此您必须通过编程方式设置:

spark.dynamicAllocation.enabled true
spark.dynamicAllocation.minExecutors 2
spark.dynamicAllocation.schedulerBacklogTimeout 1m
spark.dynamicAllocation.maxExecutors 20
spark.dynamicAllocation.executorIdleTimeout 2min

默认情况下,spark.dynamicAllocation.enabled被设置为false。启用后,Spark 驱动程序将请求集群管理器创建至少两个执行器作为起始值(spark.dynamicAllocation.minExecutors)。随着任务队列积压增加,每当积压超过超时时间(spark.dynamicAllocation.schedulerBacklogTimeout)时,将请求新的执行器。在本例中,每当有未安排超过 1 分钟的挂起任务时,驱动程序将请求启动新的执行器以安排积压任务,最多不超过 20 个(spark.dynamicAllocation.maxExecutors)。相反地,如果执行器完成任务并且在空闲 2 分钟后(spark.dynamicAllocation.executorIdleTimeout),Spark 驱动程序将终止它。

配置 Spark 执行器的内存和洗牌服务

仅仅启用动态资源分配是不够的。您还必须了解 Spark 如何配置和使用执行器内存,以确保执行器不会因为内存不足或 JVM 垃圾收集而出现问题。

每个执行器可用的内存量由spark.executor.memory控制。这被分为三部分,如图 7-2 所示:执行内存、存储内存和保留内存。默认分配为 60%用于执行内存和 40%用于存储内存,并且预留 300MB 用于保留内存,以防止 OOM 错误。Spark 的文档建议这适用于大多数情况,但您可以调整spark.executor.memory的哪一部分用作基线。当存储内存未被使用时,Spark 可以获取它用于执行内存的执行目的,反之亦然。

执行器内存布局

图 7-2. 执行器内存布局

Spark 洗牌、连接、排序和聚合使用执行内存。由于不同的查询可能需要不同数量的内存,因此将可用内存的一部分(spark.memory.fraction默认为0.6)用于此目的可能有些棘手,但很容易进行调整。与之相反,存储内存主要用于缓存用户数据结构和从 DataFrame 派生的分区。

在映射和洗牌操作期间,Spark 会读写本地磁盘上的洗牌文件,因此会有大量的 I/O 活动。这可能导致瓶颈,因为默认配置对于大规模 Spark 作业来说并不是最优的。了解需要调整哪些配置可以在 Spark 作业的这个阶段缓解风险。

在表 7-1 中,我们列出了一些建议的配置,以便在这些操作期间进行的映射、溢出和合并过程不受低效的 I/O 影响,并在将最终的洗牌分区写入磁盘之前使用缓冲内存。调整每个执行器上运行的洗牌服务(调整洗牌服务)也可以增强大规模 Spark 工作负载的整体性能。

表 7-1. 调整 Spark 在映射和洗牌操作期间的 I/O 的配置

配置 默认值、推荐值和描述
spark.driver.memory 默认为1g(1 GB)。这是分配给 Spark 驱动程序的内存量,用于从执行器接收数据。在spark-submit时使用--driver-memory可以更改此值。只有在预期驱动程序将从collect()等操作中接收大量数据,或者当驱动程序内存不足时才需要更改此值。
spark.shuffle.file.buffer 默认为 32 KB。推荐为 1 MB。这使得 Spark 在最终写入磁盘之前能够进行更多的缓冲。
spark.file.transferTo 默认为true。将其设置为false会强制 Spark 在最终写入磁盘之前使用文件缓冲区传输文件,从而减少 I/O 活动。
spark.shuffle.unsafe.file.output.buffer 默认为 32 KB。这控制了在洗牌操作期间合并文件时可能的缓冲量。一般来说,对于较大的工作负载,较大的值(例如 1 MB)更合适,而默认值适用于较小的工作负载。
spark.io.compression.lz4.blockSize 默认为 32 KB。增加到 512 KB。通过增加块的压缩大小可以减小洗牌文件的大小。
spark.shuffle.service.index.cache.size 默认为 100m。缓存条目受限于指定的内存占用(以字节为单位)。
spark.shuffle.registration.timeout 默认为 5000 ms。增加到 120000 ms。
spark.shuffle.registration.maxAttempts 默认为 3。如有需要增加到 5。
注意

此表中的建议并不适用于所有情况,但它们应该让您了解如何根据工作负载调整这些配置。与性能调整中的其他所有事物一样,您必须进行实验,直到找到适合的平衡点。

最大化 Spark 的并行性

Spark 的高效性很大程度上归因于其在规模化处理中能够并行运行多个任务的能力。要理解如何最大化并行性——即尽可能并行读取和处理数据——您必须深入了解 Spark 如何从存储中将数据读入内存,以及分区对 Spark 的意义。

在数据管理术语中,分区是一种将数据排列成可配置和可读块或连续数据的子集的方法。这些数据子集可以独立读取或并行处理,如果需要,可以由一个进程中的多个线程处理。这种独立性很重要,因为它允许数据处理的大规模并行性。

Spark 在并行处理任务方面表现出色。正如您在 第二章 中了解到的那样,对于大规模工作负载,一个 Spark 作业将包含许多阶段,在每个阶段中将有许多任务。Spark 最多会为每个核心的每个任务安排一个线程,并且每个任务将处理一个独立的分区。为了优化资源利用和最大化并行性,理想情况是每个执行器的核心数至少与分区数相同,如 图 7-3 所示。如果分区数超过每个执行器的核心数,所有核心都将保持忙碌状态。您可以将分区视为并行性的原子单位:在单个核心上运行的单个线程可以处理单个分区。

Spark 任务、核心、分区和并行性之间的关系

图 7-3. Spark 任务、核心、分区和并行性之间的关系

如何创建分区

正如前面提到的,Spark 的任务将从磁盘读取的数据作为分区处理到内存中。磁盘上的数据根据存储的不同而以块或连续文件块的形式排列。默认情况下,数据存储上的文件块大小范围从 64 MB 到 128 MB 不等。例如,在 HDFS 和 S3 上,默认大小为 128 MB(这是可配置的)。这些块的连续集合构成一个分区。

在 Spark 中,分区的大小由 spark.sql.files.maxPartitionBytes 决定,默认为 128 MB。您可以减小这个大小,但这可能会导致所谓的“小文件问题”——许多小分区文件,由于文件系统操作(如打开、关闭和列出目录)而引入大量磁盘 I/O 和性能降低,特别是在分布式文件系统上可能会很慢。

当你明确使用 DataFrame API 的一些方法时,也会创建分区。例如,在创建大型 DataFrame 或从磁盘读取大型文件时,可以明确地指示 Spark 创建某个数量的分区:

// In Scala `val` `ds` `=` `spark``.``read``.``textFile``(``"../README.md"``)``.``repartition``(``16``)`
ds: org.apache.spark.sql.Dataset[String] = [value: string]

`ds``.``rdd``.``getNumPartitions`
res5: Int = 16

`val` `numDF` `=` `spark``.``range``(``1000L` `*` `1000` `*` `1000``)``.``repartition``(``16``)`
`numDF``.``rdd``.``getNumPartitions`

numDF: org.apache.spark.sql.Dataset[Long] = [id: bigint]
res12: Int = 16

最后,shuffle partitions是在 shuffle 阶段创建的。默认情况下,spark.sql.shuffle.partitions中 shuffle partitions 的数量设置为 200。可以根据数据集的大小调整这个数字,以减少通过网络发送到执行器任务的小分区的数量。

注意

spark.sql.shuffle.partitions的默认值对于较小或流式工作负载来说太高;可能需要将其减少到更低的值,比如执行器核心数或更少。

groupBy()join()等操作期间创建的shuffle partitions,也被称为宽转换,消耗网络和磁盘 I/O 资源。在这些操作期间,shuffle 将结果溢出到执行器的本地磁盘,位置由spark.local.directory指定。对于这些操作,性能良好的 SSD 硬盘将提高性能。

为 shuffle 阶段设置 shuffle partitions 的数量没有一个神奇的公式;这个数字可能根据你的用例、数据集、核心数量以及可用的执行器内存量而变化——这是一个试错的过程。²

除了为了大型工作负载扩展 Spark 功能外,为了提高性能,你需要考虑缓存或持久化频繁访问的 DataFrame 或表。我们将在下一节中探讨各种缓存和持久化选项。

数据的缓存和持久化

缓存和持久化有什么区别?在 Spark 中,它们是同义词。两个 API 调用,cache()persist(),提供了这些功能。后者在数据存储方面提供了更多的控制能力——可以在内存和磁盘上,序列化和非序列化存储数据。这两者都有助于提高频繁访问的 DataFrame 或表的性能。

DataFrame.cache()

cache()将尽可能多地将读取的分区存储在 Spark 执行器的内存中(请参见 Figure 7-2)。虽然 DataFrame 可能只有部分缓存,但分区不能部分缓存(例如,如果有 8 个分区,但只有 4.5 个分区可以适合内存,那么只有 4 个分区会被缓存)。然而,如果没有缓存所有分区,当您再次访问数据时,没有被缓存的分区将需要重新计算,从而减慢 Spark 作业的速度。

让我们看一个例子,看看当缓存一个大型 DataFrame 时,如何提高访问一个 DataFrame 的性能:

// In Scala
// Create a DataFrame with 10M records
val df = spark.range(1 * 10000000).toDF("id").withColumn("square", $"id" * $"id")
df.cache() // Cache the data
df.count() // Materialize the cache

res3: Long = 10000000
Command took 5.11 seconds

df.count() // Now get it from the cache
res4: Long = 10000000
Command took 0.44 seconds

第一个count()方法实例化缓存,而第二个访问缓存,导致这个数据集接近 12 倍的快速访问时间。

注意

当您使用cache()persist()时,DataFrame 不会完全缓存,直到调用一个通过每条记录的动作(例如count())为止。如果使用像take(1)这样的动作,只会缓存一个分区,因为 Catalyst 意识到您不需要计算所有分区就可以检索一条记录。

观察一个 DataFrame 如何在本地主机的一个执行器上存储,如图 7-4 所示,我们可以看到它们都适合于内存中(记住,DataFrames 在底层由 RDD 支持)。

缓存分布在执行器内存的 12 个分区

图 7-4. 缓存分布在执行器内存的 12 个分区

DataFrame.persist()

persist(StorageLevel.*LEVEL*) 微妙地提供了对数据如何通过StorageLevel进行缓存的控制。表 7-2 总结了不同的存储级别。数据在磁盘上始终使用 Java 或 Kryo 序列化。

表 7-2. 存储级别

存储级别 描述
MEMORY_ONLY 数据直接存储为对象并仅存储在内存中。
MEMORY_ONLY_SER 数据以紧凑的字节数组表示并仅存储在内存中。要使用它,必须进行反序列化,这会带来一定的成本。
MEMORY_AND_DISK 数据直接存储为对象在内存中,但如果内存不足,则其余部分将序列化并存储在磁盘上。
DISK_ONLY 数据进行序列化并存储在磁盘上。
OFF_HEAP 数据存储在堆外。在 Spark 中,堆外内存用于存储和查询执行,详见“配置 Spark 执行器内存和洗牌服务”。
MEMORY_AND_DISK_SER 类似于MEMORY_AND_DISK,但在存储在内存中时将数据序列化。(数据始终在存储在磁盘上时进行序列化。)
注意

每个StorageLevel(除了OFF_HEAP)都有一个相应的LEVEL_NAME_2,这意味着在两个不同的 Spark 执行器上复制两次:MEMORY_ONLY_2MEMORY_AND_DISK_SER_2等。虽然这种选项很昂贵,但它允许在两个位置提供数据局部性,提供容错性,并给 Spark 提供在数据副本处调度任务的选项。

让我们看看与前一节相同的例子,但使用persist()方法:

// In Scala import org.apache.spark.storage.StorageLevel

// Create a DataFrame with 10M records val df = spark.range(1 * 10000000).toDF("id").withColumn("square", $"id" * $"id")
df.persist(StorageLevel.DISK_ONLY) // Serialize the data and cache it on disk df.count() // Materialize the cache

res2: Long = 10000000
Command took 2.08 seconds

df.count() // Now get it from the cache res3: Long = 10000000
Command took 0.38 seconds

如您从图 7-5 中看到的那样,数据存储在磁盘上,而不是内存中。要取消持久化缓存的数据,只需调用DataFrame.unpersist()

缓存分布在执行器磁盘上的 12 个分区

图 7-5. 缓存分布在执行器磁盘上的 12 个分区

最后,您不仅可以缓存 DataFrames,还可以缓存由 DataFrames 派生的表或视图。这使它们在 Spark UI 中具有更可读的名称。例如:

// In Scala
df.createOrReplaceTempView("dfTable")
spark.sql("CACHE TABLE dfTable")
spark.sql("SELECT count(*) FROM dfTable").show()

+--------+
|count(1)|
+--------+
|10000000|
+--------+

Command took 0.56 seconds

何时缓存和持久化

缓存的常见用例是需要重复访问大型数据集以进行查询或转换的情景。一些例子包括:

  • 在迭代式机器学习训练期间常用的数据框

  • 在 ETL 过程中或构建数据管道期间经常访问的数据框进行频繁转换

何时不要缓存和持久化

并非所有用例都需要缓存。一些可能不需要缓存您的数据框的情况包括:

  • 无法完全放入内存的数据框

  • 不需要频繁使用的数据框进行廉价转换,无论其大小如何

一般来说,您应该谨慎使用内存缓存,因为它可能会导致资源成本的增加,具体取决于使用的StorageLevel

接下来,我们将转向讨论几种常见的 Spark 连接操作,这些操作会触发昂贵的数据移动,从集群中要求计算和网络资源,并且我们如何通过组织数据来减少这种移动。

Spark 连接家族

在大数据分析中,连接操作是一种常见的转换类型,其中两个数据集(以表格或数据框的形式)通过共同匹配键合并。类似于关系数据库,Spark 数据框和数据集 API 以及 Spark SQL 提供一系列的连接转换:内连接、外连接、左连接、右连接等。所有这些操作都会触发大量数据在 Spark 执行器之间的移动。

这些转换的核心在于 Spark 如何计算要生成的数据、写入磁盘的键和相关数据以及如何将这些键和数据作为 groupBy()join()agg()sortBy()reduceByKey() 等操作的一部分传输到节点。这种移动通常称为洗牌

Spark 有 五种不同的连接策略,通过这些策略,在执行器之间交换、移动、排序、分组和合并数据:广播哈希连接(BHJ)、洗牌哈希连接(SHJ)、洗牌排序合并连接(SMJ)、广播嵌套循环连接(BNLJ)和洗牌与复制嵌套循环连接(又称笛卡尔积连接)。我们将仅关注其中的两种(BHJ 和 SMJ),因为它们是您最常遇到的。

广播哈希连接

也称为仅地图侧连接,广播哈希连接用于当两个数据集需要根据某些条件或列进行连接时,一个较小的数据集(适合驱动程序和执行器内存)和另一个足够大的数据集需要避免移动。使用 Spark 广播变量,驱动程序将较小的数据集广播到所有 Spark 执行器,如 图 7-6 所示,然后在每个执行器上与较大的数据集进行连接。该策略避免了大量的数据交换。

BHJ:较小的数据集广播到所有执行器

图 7-6. BHJ:较小的数据集广播到所有执行器

默认情况下,如果较小的数据集大小小于 10 MB,Spark 将使用广播连接。此配置在 spark.sql.autoBroadcastJoinThreshold 中设置;根据每个执行器和驱动程序中的内存量,您可以减少或增加大小。如果您确信您有足够的内存,即使对大于 10 MB 的 DataFrame,您也可以使用广播连接(甚至可达到 100 MB)。

一个常见的用例是当您有两个 DataFrame 之间的公共键集,一个持有比另一个少的信息,并且您需要一个合并视图。例如,考虑一个简单的情况,您有一个大数据集 playersDF 包含全球足球运动员的信息,以及一个较小的数据集 clubsDF 包含他们所属的足球俱乐部的信息,您希望根据一个共同的键将它们合并:

// In Scala 
import org.apache.spark.sql.functions.broadcast
val joinedDF = playersDF.join(broadcast(clubsDF), "key1 === key2")
注意

在此代码中,我们强制 Spark 进行广播连接,但如果较小的数据集大小低于 spark.sql.autoBroadcastJoinThreshold,它将默认采用此类连接。

BHJ 是 Spark 提供的最简单和最快的连接,因为它不涉及数据集的任何洗牌;所有数据在广播后都可在执行器上本地使用。您只需确保 Spark 驱动程序和执行器的内存足够大,以将较小的数据集保存在内存中。

在操作之后的任何时间,您可以通过执行以下操作查看物理计划执行的连接操作:

joinedDF.explain(mode)

在 Spark 3.0 中,您可以使用 joinedDF.explain('*mode*') 来显示可读且易于理解的输出。模式包括 'simple''extended''codegen''cost''formatted'

何时使用广播哈希连接

在以下条件下使用此类连接以获取最大效益:

  • 当 Spark 将较小和较大的数据集内的每个键都哈希到同一个分区时

  • 当一个数据集比另一个数据集小得多(并且在默认配置下小于 10 MB,如果有足够的内存则更多)

  • 当您仅希望执行等值连接时,基于匹配的未排序键来组合两个数据集

  • 当您不担心过多的网络带宽使用或内存溢出错误,因为较小的数据集将广播到所有 Spark 执行器

指定在 spark.sql.autoBroadcastJoinThreshold 中值为 -1 将导致 Spark 总是采用洗牌排序合并连接,这将在下一节中讨论。

洗牌排序合并连接

排序合并算法是合并两个大数据集的有效方法,这两个数据集具有可排序、唯一且可分配或存储在同一分区的公共键。从 Spark 的角度来看,这意味着具有相同键的每个数据集内的所有行都在相同执行器上的相同分区上进行哈希。显然,这意味着数据必须在执行器之间共享或交换。

正如名称所示,此连接方案有两个阶段:排序阶段和合并阶段。排序阶段根据每个数据集的所需连接键对数据集进行排序;合并阶段迭代每个数据集中的每个键,并在两个键匹配时合并行。

默认情况下,通过spark.sql.join.preferSortMergeJoin启用SortMergeJoin。以下是书籍的此章节中可用的独立应用程序笔记本的代码片段,位于其GitHub 存储库。主要思想是使用两个拥有一百万条记录的大型 DataFrame,按两个共同的键uid == users_id进行连接。

此数据为合成数据,但说明了这一点:

// In Scala
import scala.util.Random
// Show preference over other joins for large data sets
// Disable broadcast join
// Generate data
...
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")

// Generate some sample data for two data sets
var states = scala.collection.mutable.Map[Int, String]()
var items = scala.collection.mutable.Map[Int, String]()
val rnd = new scala.util.Random(42)

// Initialize states and items purchased
states += (0 -> "AZ", 1 -> "CO", 2-> "CA", 3-> "TX", 4 -> "NY", 5-> "MI")
items += (0 -> "SKU-0", 1 -> "SKU-1", 2-> "SKU-2", 3-> "SKU-3", 4 -> "SKU-4", 
    5-> "SKU-5")

// Create DataFrames
val usersDF = (0 to 1000000).map(id => (id, s"user_${id}",
    s"user_${id}@databricks.com", states(rnd.nextInt(5))))
    .toDF("uid", "login", "email", "user_state")
val ordersDF = (0 to 1000000)
    .map(r => (r, r, rnd.nextInt(10000), 10 * r* 0.2d,
    states(rnd.nextInt(5)), items(rnd.nextInt(5))))
    .toDF("transaction_id", "quantity", "users_id", "amount", "state", "items")

// Do the join 
val usersOrdersDF = ordersDF.join(usersDF, $"users_id" === $"uid")

// Show the joined results
usersOrdersDF.show(false)

+--------------+--------+--------+--------+-----+-----+---+---+----------+
|transaction_id|quantity|users_id|amount  |state|items|uid|...|user_state|
+--------------+--------+--------+--------+-----+-----+---+---+----------+
|3916          |3916    |148     |7832.0  |CA   |SKU-1|148|...|CO        |
|36384         |36384   |148     |72768.0 |NY   |SKU-2|148|...|CO        |
|41839         |41839   |148     |83678.0 |CA   |SKU-3|148|...|CO        |
|48212         |48212   |148     |96424.0 |CA   |SKU-4|148|...|CO        |
|48484         |48484   |148     |96968.0 |TX   |SKU-3|148|...|CO        |
|50514         |50514   |148     |101028.0|CO   |SKU-0|148|...|CO        |
|65694         |65694   |148     |131388.0|TX   |SKU-4|148|...|CO        |
|65723         |65723   |148     |131446.0|CA   |SKU-1|148|...|CO        |
|93125         |93125   |148     |186250.0|NY   |SKU-3|148|...|CO        |
|107097        |107097  |148     |214194.0|TX   |SKU-2|148|...|CO        |
|111297        |111297  |148     |222594.0|AZ   |SKU-3|148|...|CO        |
|117195        |117195  |148     |234390.0|TX   |SKU-4|148|...|CO        |
|253407        |253407  |148     |506814.0|NY   |SKU-4|148|...|CO        |
|267180        |267180  |148     |534360.0|AZ   |SKU-0|148|...|CO        |
|283187        |283187  |148     |566374.0|AZ   |SKU-3|148|...|CO        |
|289245        |289245  |148     |578490.0|AZ   |SKU-0|148|...|CO        |
|314077        |314077  |148     |628154.0|CO   |SKU-3|148|...|CO        |
|322170        |322170  |148     |644340.0|TX   |SKU-3|148|...|CO        |
|344627        |344627  |148     |689254.0|NY   |SKU-3|148|...|CO        |
|345611        |345611  |148     |691222.0|TX   |SKU-3|148|...|CO        |
+--------------+--------+--------+--------+-----+-----+---+---+----------+
only showing top 20 rows

检查我们的最终执行计划时,我们注意到 Spark 使用了预期的SortMergeJoin来连接这两个 DataFrame。如预期那样,Exchange操作是在每个执行器上的映射操作结果之间的洗牌:

usersOrdersDF.explain() 

== Physical Plan ==
InMemoryTableScan [transaction_id#40, quantity#41, users_id#42, amount#43,
state#44, items#45, uid#13, login#14, email#15, user_state#16]
   +- InMemoryRelation [transaction_id#40, quantity#41, users_id#42, amount#43,
state#44, items#45, uid#13, login#14, email#15, user_state#16], 
StorageLevel(disk, memory, deserialized, 1 replicas)
         +- *(3) **SortMergeJoin** [users_id#42], [uid#13], Inner
            :- *(1) Sort [users_id#42 ASC NULLS FIRST], false, 0
            :  +- Exchange hashpartitioning(users_id#42, 16), true, [id=#56]
            :     +- LocalTableScan [transaction_id#40, quantity#41, users_id#42,
amount#43, state#44, items#45]
            +- *(2) Sort [uid#13 ASC NULLS FIRST], false, 0
               +- **Exchange hashpartitioning**(uid#13, 16), true, [id=#57]
                  +- LocalTableScan [uid#13, login#14, email#15, user_state#16]

此外,Spark UI(我们将在下一节中讨论)显示整个作业的三个阶段:ExchangeSort操作发生在最终阶段,随后合并结果,如图 7-7 和 7-8 所示。Exchange操作代价高昂,并要求分区在执行器之间通过网络进行洗牌。

桶前:Spark 的阶段

图 7-7. 桶前:Spark 的阶段

桶前:需要交换

图 7-8. 桶前:需要交换

优化洗牌排序合并连接

如果我们为常见的排序键或要执行频繁等连接的列创建分区桶,我们可以从此方案中消除Exchange步骤。也就是说,我们可以创建显式数量的桶来存储特定排序列(每个桶一个键)。通过这种方式预排序和重新组织数据可提升性能,因为它允许我们跳过昂贵的Exchange操作并直接进入WholeStageCodegen

在此章节的笔记本中的以下代码片段(在书籍的GitHub 存储库中提供)中,我们按照将要连接的users_iduid列进行排序和分桶,并将桶保存为 Parquet 格式的 Spark 托管表:

// In Scala
import org.apache.spark.sql.functions._
import org.apache.spark.sql.SaveMode

// Save as managed tables by bucketing them in Parquet format
usersDF.orderBy(asc("uid"))
  .write.format("parquet")
  .bucketBy(8, "uid")
  .mode(SaveMode.OverWrite)
  .saveAsTable("UsersTbl")

ordersDF.orderBy(asc("users_id"))
  .write.format("parquet")
  .bucketBy(8, "users_id")
  .mode(SaveMode.OverWrite)
  .saveAsTable("OrdersTbl")

// Cache the tables
spark.sql("CACHE TABLE UsersTbl")
spark.sql("CACHE TABLE OrdersTbl")

// Read them back in
val usersBucketDF = spark.table("UsersTbl")
val ordersBucketDF = spark.table("OrdersTbl")

// Do the join and show the results
val joinUsersOrdersBucketDF = ordersBucketDF
    .join(usersBucketDF, $"users_id" === $"uid")

joinUsersOrdersBucketDF.show(false)

+--------------+--------+--------+---------+-----+-----+---+---+----------+
|transaction_id|quantity|users_id|amount   |state|items|uid|...|user_state|
+--------------+--------+--------+---------+-----+-----+---+---+----------+
|144179        |144179  |22      |288358.0 |TX   |SKU-4|22 |...|CO        |
|145352        |145352  |22      |290704.0 |NY   |SKU-0|22 |...|CO        |
|168648        |168648  |22      |337296.0 |TX   |SKU-2|22 |...|CO        |
|173682        |173682  |22      |347364.0 |NY   |SKU-2|22 |...|CO        |
|397577        |397577  |22      |795154.0 |CA   |SKU-3|22 |...|CO        |
|403974        |403974  |22      |807948.0 |CO   |SKU-2|22 |...|CO        |
|405438        |405438  |22      |810876.0 |NY   |SKU-1|22 |...|CO        |
|417886        |417886  |22      |835772.0 |CA   |SKU-3|22 |...|CO        |
|420809        |420809  |22      |841618.0 |NY   |SKU-4|22 |...|CO        |
|659905        |659905  |22      |1319810.0|AZ   |SKU-1|22 |...|CO        |
|899422        |899422  |22      |1798844.0|TX   |SKU-4|22 |...|CO        |
|906616        |906616  |22      |1813232.0|CO   |SKU-2|22 |...|CO        |
|916292        |916292  |22      |1832584.0|TX   |SKU-0|22 |...|CO        |
|916827        |916827  |22      |1833654.0|TX   |SKU-1|22 |...|CO        |
|919106        |919106  |22      |1838212.0|TX   |SKU-1|22 |...|CO        |
|921921        |921921  |22      |1843842.0|AZ   |SKU-4|22 |...|CO        |
|926777        |926777  |22      |1853554.0|CO   |SKU-2|22 |...|CO        |
|124630        |124630  |22      |249260.0 |CO   |SKU-0|22 |...|CO        |
|129823        |129823  |22      |259646.0 |NY   |SKU-4|22 |...|CO        |
|132756        |132756  |22      |265512.0 |AZ   |SKU-2|22 |...|CO        |
+--------------+--------+--------+---------+-----+-----+---+---+----------+
only showing top 20 rows

连接输出按uidusers_id排序,因为我们保存的表按升序排序。因此,在SortMergeJoin期间不需要排序。查看 Spark UI(图 7-9),我们可以看到我们跳过了Exchange并直接进入了WholeStageCodegen

物理计划还显示没有执行Exchange操作,与桶前的物理计划相比:

joinUsersOrdersBucketDF.explain()

== Physical Plan ==
*(3) SortMergeJoin [users_id#165], [uid#62], Inner
:- *(1) Sort [users_id#165 ASC NULLS FIRST], false, 0
:  +- *(1) Filter isnotnull(users_id#165)
:     +- Scan In-memory table `OrdersTbl` [transaction_id#163, quantity#164,
users_id#165, amount#166, state#167, items#168], [isnotnull(users_id#165)]
:           +- InMemoryRelation [transaction_id#163, quantity#164, users_id#165,
amount#166, state#167, items#168], StorageLevel(disk, memory, deserialized, 1
replicas)
:                 +- *(1) ColumnarToRow
:                    +- FileScan parquet 
...

桶后:不需要交换

图 7-9. 桶后:不需要交换

何时使用洗牌排序合并连接

在以下条件下使用这种类型的连接以获得最大效益:

  • 当 Spark 能够将两个大数据集中的每个键按排序和哈希方式分到同一分区时

  • 当您希望仅执行基于匹配排序键的两个数据集的等连接以合并时

  • 当您希望防止 ExchangeSort 操作以节省跨网络的大型洗牌时

到目前为止,我们已经涵盖了与调整和优化 Spark 相关的操作方面,以及 Spark 在两种常见连接操作期间如何交换数据。我们还演示了如何通过使用分桶来避免大数据交换来提高 shuffle sort merge join 操作的性能。

正如您在前面的图表中所看到的,Spark UI 是可视化这些操作的有用方式。它显示收集的指标和程序的状态,揭示了大量关于可能性能瓶颈的信息和线索。在本章的最后一节中,我们将讨论在 Spark UI 中寻找什么。

检查 Spark UI

Spark 提供了一个精心设计的 Web UI,允许我们检查应用程序的各个组件。它提供有关内存使用情况、作业、阶段和任务的详细信息,以及事件时间线、日志和各种指标和统计信息,这些可以帮助您深入了解 Spark 应用程序在 Spark 驱动程序级别和单个执行器中的运行情况。

一个 spark-submit 作业将启动 Spark UI,您可以在本地主机上(在本地模式下)或通过 Spark 驱动程序(在其他模式下)连接到默认端口 4040。

通过 Spark UI 选项卡的旅程

Spark UI 有六个选项卡,如 图 7-10 所示,每个选项卡都提供了探索的机会。让我们看看每个选项卡向我们揭示了什么。

Spark UI 选项卡

图 7-10. Spark UI 选项卡

这个讨论适用于 Spark 2.x 和 Spark 3.0。虽然 Spark 3.0 中的 UI 大部分相同,但它还增加了第七个选项卡,结构化流处理。这在 第十二章 中进行了预览。

作业与阶段

正如您在 第二章 中学到的,Spark 将应用程序分解为作业、阶段和任务。作业和阶段选项卡允许您浏览这些内容,并深入到细粒度级别以检查个别任务的详细信息。您可以查看它们的完成状态,并查看与 I/O、内存消耗、执行持续时间等相关的指标。

图 7-11 显示了作业选项卡及其扩展的事件时间轴,显示了执行者何时添加到或从集群中移除。它还提供了集群中所有已完成作业的表格列表。持续时间列显示了每个作业完成所需的时间(由第一列中的作业 ID 标识)。如果这段时间很长,则可能需要调查导致延迟的任务阶段。从此摘要页面,您还可以访问每个作业的详细页面,包括 DAG 可视化和已完成阶段列表。

作业选项卡提供了事件时间轴视图和所有已完成作业的列表

图 7-11. 作业选项卡提供了事件时间轴视图和所有已完成作业的列表

阶段选项卡提供了应用程序中所有作业所有阶段当前状态的摘要。您还可以访问每个阶段的详细页面,其中包含 DAG 和任务的指标(图 7-12)。除了一些可选的统计信息外,您还可以查看每个任务的平均持续时间、GC 花费的时间以及洗牌字节/记录读取的数量。如果从远程执行者读取洗牌数据,则高 Shuffle Read Blocked Time 可能会提示 I/O 问题。高 GC 时间表明堆上有太多对象(可能是内存不足的执行者)。如果一个阶段的最大任务时间远大于中位数,则可能存在数据分区不均匀导致的数据倾斜问题。请留意这些显著迹象。

阶段选项卡提供了有关阶段及其任务的详细信息

图 7-12. 阶段选项卡提供了有关阶段及其任务的详细信息

您还可以查看每个执行者的聚合指标以及此页面上各个任务的详细信息。

执行者

执行者选项卡提供了有关为应用程序创建的执行者的信息。正如您在 图 7-13 中所看到的,您可以深入了解有关资源使用情况(磁盘、内存、核心)、GC 时间、洗牌期间写入和读取的数据量等细节。

执行者选项卡显示了 Spark 应用程序使用的执行者的详细统计数据和指标

图 7-13. 执行者选项卡显示了 Spark 应用程序使用的执行者的详细统计数据和指标

除了摘要统计信息外,您还可以查看每个单独执行者的内存使用情况及其用途。当您在 DataFrame 或管理表上使用 cache()persist() 方法时,这也有助于检查资源使用情况,接下来我们将讨论这些。

存储

在“Shuffle Sort Merge Join”中的 Spark 代码中,我们对桶分区后管理了两个表的缓存。在图 7-14 中显示的存储选项卡提供了有关该应用程序缓存的任何表或 DataFrame 的信息,这是由cache()persist()方法的结果产生的。

存储选项卡显示内存使用情况的详细信息

图 7-14. 存储选项卡显示内存使用情况的详细信息

点击链接“内存表UsersTbl”在图 7-14 中,可以进一步了解表在内存和磁盘上的缓存情况,以及在 1 个执行器和 8 个分区上的分布情况,这个数字对应我们为这个表创建的桶的数量(参见图 7-15)。

Spark UI 显示表在执行器内存中的缓存分布

图 7-15. Spark UI 显示表在执行器内存中的缓存分布

SQL

作为您的 Spark 应用程序执行的一部分执行的 Spark SQL 查询的影响可以通过 SQL 选项卡进行跟踪和查看。您可以看到查询何时执行以及由哪个作业执行,以及它们的持续时间。例如,在我们的SortMergeJoin示例中,我们执行了一些查询;所有这些都显示在图 7-16 中,并附带了进一步深入了解的链接。

SQL 选项卡显示已完成的 SQL 查询的详细信息

图 7-16. SQL 选项卡显示已完成的 SQL 查询的详细信息

点击查询的描述会显示执行计划的细节,包括所有物理运算符,如图 7-17 所示。在计划的每个物理运算符下面—例如,在此处扫描内存表哈希聚合Exchange—都有 SQL 指标。

当我们想要检查物理运算符的细节并了解发生了什么时,这些指标就非常有用:有多少行被扫描了,写了多少洗牌字节等等。

Spark UI 显示 SQL 查询的详细统计信息

图 7-17. Spark UI 显示 SQL 查询的详细统计信息

环境

与其他选项卡一样,图 7-18 中显示的环境选项卡同样重要。了解您的 Spark 应用程序所在的环境可以揭示许多有用于故障排除的线索。事实上,了解已设置了哪些环境变量,包括了哪些 jar 包,设置了哪些 Spark 属性(及其相应的值,特别是如果您调整了“优化和调整 Spark 的效率”中提到的一些配置),设置了哪些系统属性,使用了哪个运行时环境(如 JVM 或 Java 版本)等等对于您在 Spark 应用程序中注意到任何异常行为时的调查工作非常有帮助。

环境标签显示了你的 Spark 集群的运行时属性

图 7-18. 环境标签显示了你的 Spark 集群的运行时属性。

调试 Spark 应用程序

在本节中,我们已经浏览了 Spark UI 中的各种标签。正如你所见,UI 提供了大量信息,可用于调试和解决 Spark 应用程序的问题。除了我们在这里涵盖的内容之外,它还提供了访问驱动程序和执行器的 stdout/stderr 日志的方式,你可以在这里记录调试信息。

通过 UI 进行调试是与在你喜欢的 IDE 中逐步执行应用程序不同的过程 —— 更像是侦探,追随面包屑的线索 —— 虽然如果你喜欢那种方法,你也可以在本地主机上的 IDE(如 IntelliJ IDEA)中调试 Spark 应用程序。

Spark 3.0 UI 标签展示了关于发生情况的见解性线索,以及访问驱动程序和执行器的 stdout/stderr 日志,你可能在这里记录了调试信息。

起初,这些大量的信息对新手来说可能是压倒性的。但随着时间的推移,你会逐渐理解每个标签中要寻找的内容,并且能够更快地检测和诊断异常。模式会变得清晰,通过频繁访问这些标签并在运行一些 Spark 示例后熟悉它们,你会习惯通过 UI 调优和检查你的 Spark 应用程序。

总结

在本章中,我们讨论了多种优化技术,用于调优你的 Spark 应用程序。正如你所见,通过调整一些默认的 Spark 配置,你可以改善大型工作负载的扩展性,增强并行性,并减少 Spark 执行器之间的内存饥饿。你还一瞥了如何使用缓存和持久化策略以适当的级别加速访问你经常使用的数据集,并且我们检查了 Spark 在复杂聚合过程中使用的两种常见连接方式,并演示了通过按排序键分桶 DataFrame 如何跳过昂贵的洗牌操作。

最后,通过 Spark UI,你可以从视觉角度看到性能的情况完整呈现出来。尽管 UI 提供了详细和丰富的信息,但它并不等同于在 IDE 中逐步调试;然而我们展示了如何通过检查和从半打 Spark UI 标签中获得的度量和统计信息、计算和内存使用数据以及 SQL 查询执行跟踪来成为 Spark 的侦探。

在下一章中,我们将深入讲解结构化流式处理,并向你展示在前几章学习过的结构化 API 如何让你连续编写流式和批处理应用程序,使你能够构建可靠的数据湖和管道。

¹ 参见 “调整 Apache Spark 以应对大规模工作负载”“Apache Spark 中的 Hive 分桶”

² 想要了解一些有关配置洗牌分区的技巧,请参见 “调整 Apache Spark 以应对大规模工作负载”“Apache Spark 中的 Hive 分桶”,以及 “为什么你应该关注文件系统中的数据布局”

第八章:结构化流处理

在前几章中,您学习了如何使用结构化 API 处理非常大但有限的数据量。然而,数据通常是连续到达并需要实时处理。在本章中,我们将讨论如何使用相同的结构化 API 处理数据流。

Apache Spark 流处理引擎的演变

流处理被定义为对无休止数据流的连续处理。随着大数据的到来,流处理系统从单节点处理引擎过渡到多节点、分布式处理引擎。传统上,分布式流处理是使用逐记录处理模型实现的,如图 8-1 所示。

传统逐记录处理模型

图 8-1. 传统逐记录处理模型

处理管道由节点的有向图组成,如图 8-1 所示;每个节点连续接收一个记录,处理它,然后将生成的记录转发给图中的下一个节点。这种处理模型可以实现非常低的延迟——即,输入记录可以在毫秒内通过管道进行处理并生成结果输出。然而,这种模型在从节点故障和落后节点(即比其他节点慢的节点)中恢复时效率不高;它可以通过大量额外的故障转移资源快速恢复故障,或者使用最少的额外资源但恢复速度较慢。¹

微批次流处理的出现

当 Apache Spark 引入 Spark Streaming(也称为 DStreams)时,挑战了这种传统方法。它引入了微批次流处理的概念,其中流处理被建模为连续的小型 map/reduce 风格的批处理作业(因此称为“微批次”),针对流数据的小块。如图 8-2 所示。

结构化流使用微批处理模型

图 8-2. 结构化流使用微批处理模型

正如所示,Spark Streaming 将输入流的数据划分为,比如,1 秒的微批次。每个批次在 Spark 集群中以分布式方式处理,使用小的确定性任务生成微批次的输出。将流计算分解为这些小任务,相比传统的连续操作模型,有两个优势:

  • Spark 的灵活任务调度可以通过在其他执行者上重新调度任务的一个或多个副本,非常快速且高效地从故障和落后执行者中恢复。

  • 任务的确定性质保证了无论任务重新执行多少次,输出数据都是相同的。这一关键特性使得 Spark Streaming 能够提供端到端的精确一次处理保证,即生成的输出结果将确保每个输入记录仅被处理一次。

这种高效的容错性是以延迟为代价的——微批处理模型无法达到毫秒级的延迟;通常情况下,它可以达到几秒钟的延迟(在某些情况下甚至可以低至半秒)。然而,我们观察到,对于绝大多数流处理应用场景来说,微批处理的优势远远超过秒级延迟的缺点。这是因为大多数流水线至少具备以下一种特性:

  • 管道不需要低于几秒钟的延迟。例如,如果流式输出仅由每小时作业读取,生成具有亚秒级延迟的输出就没有用处。

  • 管道的其他部分存在较大的延迟。例如,如果传感器向 Apache Kafka(用于摄取数据流的系统)写入的操作被批处理以获得更高的吞吐量,那么在下游处理系统中进行任何优化也无法使端到端延迟低于批处理延迟。

此外,DStream API 是建立在 Spark 的批处理 RDD API 之上的。因此,DStreams 具有与 RDDs 相同的功能语义和容错模型。因此,Spark Streaming 证明了单一统一的处理引擎可以为批处理、交互式和流处理工作负载提供一致的 API 和语义。这种流处理中的基本范式转变推动了 Spark Streaming 成为最广泛使用的开源流处理引擎之一。

从 Spark Streaming(DStreams)中学到的教训

尽管如此,DStream API 也存在一些缺陷。以下是一些需要改进的关键领域:

缺乏批处理和流处理的单一 API

即使 DStreams 和 RDDs 具有一致的 API(即相同的操作和语义),开发人员在将批处理作业转换为流处理作业时仍然需要显式重写其代码以使用不同的类。

缺乏逻辑计划和物理计划之间的分离

Spark Streaming 以开发人员指定的顺序执行 DStream 操作。由于开发人员有效地指定了确切的物理执行计划,因此没有自动优化的余地,开发人员必须手动优化其代码以获得最佳性能。

缺乏对事件时间窗口的本机支持

DStreams 仅基于 Spark Streaming 接收每条记录的时间(称为处理时间)定义窗口操作。然而,许多使用案例需要根据记录生成的时间(称为事件时间)计算窗口聚合,而不是它们接收或处理的时间。缺乏对事件时间窗口的本机支持使得开发者难以使用 Spark Streaming 构建这样的管道。

这些缺点塑造了我们将在接下来讨论的结构化流处理的设计理念。

结构化流处理的哲学

基于这些来自 DStreams 的经验教训,结构化流处理从头开始设计,核心理念是对开发者而言,编写流处理管道应该像编写批处理管道一样简单。总结来说,结构化流处理的指导原则包括:

批处理和流处理的统一编程模型和接口

这种统一模型为批处理和流处理工作负载提供了简单的 API 接口。您可以像在批处理上一样在流上使用熟悉的 SQL 或类似批处理的 DataFrame 查询(就像您在之前章节中学到的那样),将处理故障容忍、优化和延迟数据等底层复杂性留给引擎处理。在接下来的部分中,我们将研究您可能编写的一些查询。

流处理的更广泛定义

大数据处理应用程序变得足够复杂,以至于实时处理和批处理之间的界限显著模糊化。结构化流处理的目标是从传统的流处理扩展其适用性到更大类别的应用程序;任何定期(例如每几小时)到连续(如传统流处理应用程序)处理数据的应用程序都应该能够使用结构化流处理表达。

接下来,我们将讨论结构化流处理使用的编程模型。

结构化流处理的编程模型

“表”是在构建批处理应用程序时开发者熟悉的概念。结构化流处理通过将流视为无界、持续追加的表来将此概念扩展到流应用程序中,如图 8-3 所示。

结构化流处理的编程模型:数据流作为无界表

图 8-3. 结构化流处理的编程模型:数据流作为无界表

数据流中接收的每条新记录就像附加到无界输入表的新行。结构化流处理不会实际保留所有输入,但结构化流处理产生的输出直到时间 T 将等同于在静态有界表中具有直到 T 的所有输入并在表上运行批处理作业的效果。

如图 8-4 所示,开发人员随后定义在这个概念输入表上的查询,就像它是一个静态表,以计算将写入输出汇聚的结果表。结构化流将自动将这种类似批处理的查询转换为流执行计划。这称为增量化:结构化流会找出每次记录到达时需要维护的状态。最后,开发人员指定触发策略来控制何时更新结果。每次触发器触发时,结构化流都会检查新数据(即输入表中的新行),并增量更新结果。

结构化流处理模型

图 8-4. 结构化流处理模型

模型的最后部分是输出模式。每当更新结果表时,开发人员都希望将更新写入外部系统,例如文件系统(例如 HDFS,Amazon S3)或数据库(例如 MySQL,Cassandra)。通常我们希望增量写入输出。为此,结构化流提供了三种输出模式:

追加模式

自上次触发以来附加到结果表的新行将被写入外部存储。这仅适用于查询中现有结果表中的行不可更改的情况(例如,输入流的映射)。

更新模式

自上次触发以来在结果表中更新的唯一行将在外部存储中更改。这种模式适用于可以原地更新的输出汇聚,例如 MySQL 表。

完整模式

更新后的整个结果表将被写入外部存储。

注意

除非指定了完整模式,否则结构化流不会完全实现结果表。只会维护足够的信息(称为“状态”),以确保能够计算结果表中的更改并输出更新。

将数据流视为表不仅使得在数据上进行逻辑计算更容易概念化,而且使得在代码中表达这些计算更加容易。由于 Spark 的 DataFrame 是表的编程表示,您可以使用 DataFrame API 来表达对流数据的计算。您只需从流数据源定义一个输入 DataFrame(即输入表),然后以与在批处理源上定义 DataFrame 相同的方式对 DataFrame 应用操作。

在接下来的部分中,您将看到使用 DataFrame 编写结构化流查询是多么简单。

结构化流查询的基础知识

在本节中,我们将介绍一些您需要理解以开发结构化流查询的高级概念。我们将首先介绍定义和启动流查询的关键步骤,然后讨论如何监视活动查询并管理其生命周期。

定义流查询的五个步骤

如前一节所讨论的,结构化流处理使用与批处理查询相同的 DataFrame API 来表达数据处理逻辑。但是,在定义结构化流查询时,您需要了解一些关键的不同之处。在本节中,我们将通过构建一个简单的查询来探索定义流查询的步骤,该查询从套接字上的文本数据流中读取并计算单词数。

步骤 1:定义输入源

与批处理查询一样,第一步是从流源定义一个 DataFrame。但是,当读取批处理数据源时,我们需要使用 spark.read 创建一个 DataFrameReader,而在流处理源中,我们需要使用 spark.readStream 创建一个 DataStreamReaderDataStreamReader 具有与 DataFrameReader 大部分相同的方法,因此可以类似地使用它。以下是从通过套接字连接接收的文本数据流创建 DataFrame 的示例:

# In Python
spark = SparkSession...
lines = (spark
  .readStream.format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load())
// In Scala 
val spark = SparkSession...
val lines = spark
  .readStream.format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load()

此代码将 lines DataFrame 生成为从 localhost:9999 读取的换行分隔文本数据的无界表。需要注意的是,类似于使用 spark.read 读取批处理源一样,这并不会立即开始读取流数据;它只是设置了读取数据的配置,一旦显式启动流查询,数据才会被读取。

除了套接字外,Apache Spark 还原生支持从 Apache Kafka 和所有 DataFrameReader 支持的各种基于文件的格式(Parquet、ORC、JSON 等)读取数据流。这些源的详细信息及其支持的选项将在本章后面讨论。此外,流查询可以定义多个输入源,包括流式和批处理,可以使用像 union 和 join 这样的 DataFrame 操作进行组合(同样在本章后面讨论)。

步骤 2:转换数据

现在我们可以应用常规的 DataFrame 操作,比如将行拆分为单词并计数它们,如下所示的代码:

# In Python
from pyspark.sql.functions import *
words = lines.select(split(col("value"), "\\s").alias("word"))
counts = words.groupBy("word").count()
// In Scala
import org.apache.spark.sql.functions._
val words = lines.select(split(col("value"), "\\s").as("word"))
val counts = words.groupBy("word").count()

counts 是一个流处理 DataFrame(即在无界流数据上的 DataFrame),表示在流查询启动并持续处理流输入数据时将计算的运行单词计数。

需要注意的是,用于转换 lines 流处理 DataFrame 的这些操作如果 lines 是批处理 DataFrame 也会以完全相同的方式工作。通常情况下,大多数可以应用于批处理 DataFrame 的 DataFrame 操作也可以应用于流处理 DataFrame。要了解结构化流处理支持哪些操作,您必须了解两类广泛的数据转换:

无状态转换

select()filter()map() 等操作不需要来自前一行的任何信息来处理下一行;每行可以单独处理。这些操作在处理中不涉及先前的“状态”,因此被称为无状态操作。无状态操作可以应用于批处理和流处理的 DataFrame。

有状态转换

相比之下,像 count() 这样的聚合操作需要维护状态以跨多行合并数据。具体来说,涉及分组、连接或聚合的任何数据框架操作都是有状态的转换。虽然结构化流支持其中许多操作,但由于计算难度大或无法以增量方式计算,有些组合操作不受支持。

结构化流支持的有状态操作及如何在运行时管理它们的状态将在本章后面讨论。

第三步:定义输出目的地和输出模式

在转换数据后,我们可以使用 DataFrame.writeStream(而不是用于批处理数据的 DataFrame.write)定义如何写入处理后的输出数据。这将创建一个 DataStreamWriter,类似于 DataFrameWriter,它具有额外的方法来指定以下内容:

  • 输出写入详细信息(输出位置及方式)

  • 处理细节(如何处理数据及如何从故障中恢复)

让我们从输出写入详细信息开始(我们将在下一步关注处理细节)。例如,以下片段展示了如何将最终的counts写入控制台:

# In Python
writer = counts.writeStream.format("console").outputMode("complete")
// In Scala
val writer = counts.writeStream.format("console").outputMode("complete")

在这里,我们指定了 "console" 作为输出流目的地,"complete" 作为输出模式。流查询的输出模式指定在处理新输入数据后写出更新后输出的部分。在此示例中,当处理一块新的输入数据并更新单词计数时,我们可以选择打印到控制台所有迄今为止看到的单词计数(即完整模式),或者只打印最后一块输入数据中更新的单词。这由指定的输出模式决定,可以是以下之一(正如我们在“结构化流的编程模型”中已经看到的):

追加模式

这是默认模式,只有自上次触发以来新增加到结果表/数据框架(例如,counts 表)的新行会输出到目标位置。从语义上讲,此模式保证输出的任何行将不会被将来的查询修改或更新。因此,追加模式仅支持那些永远不会修改先前输出数据的查询(例如无状态查询)。相比之下,我们的词频统计查询可以更新先前生成的计数,因此不支持追加模式。

完整模式

在这种模式下,结果表/数据框架的所有行将在每次触发结束时输出。这种模式适用于结果表比输入数据要小得多,因此可以在内存中保留。例如,我们的词频统计查询支持完整模式,因为计数数据很可能比输入数据小得多。

更新模式

在此模式下,只有自上次触发以来更新的结果表/DataFrame 行将在每次触发结束时输出。这与追加模式相反,因为输出行可能会被查询修改,并在未来再次输出。大多数查询支持更新模式。

注意

有关不同查询支持的输出模式的详细信息可以在最新的结构化流编程指南中找到。

除了将输出写入控制台外,结构化流还原生支持将流式数据写入文件和 Apache Kafka。此外,您还可以使用 foreachBatch()foreach() API 方法将数据写入任意位置。事实上,您可以使用 foreachBatch() 来使用现有的批数据源写入流式输出(但您将失去精确一次性保证)。这些输出位置的详细信息及其支持的选项将在本章后面讨论。

第四步:指定处理细节

在启动查询之前的最后一步是指定如何处理数据的详细信息。继续使用我们的词频统计示例,我们将如下指定处理细节:

# In Python
checkpointDir = "..."
writer2 = (writer
  .trigger(processingTime="1 second")
  .option("checkpointLocation", checkpointDir))
// In Scala
import org.apache.spark.sql.streaming._
val checkpointDir = "..."
val writer2 = writer
  .trigger(Trigger.ProcessingTime("1 second"))
  .option("checkpointLocation", checkpointDir)

在此处,我们使用 DataFrame.writeStream 创建的 DataStreamWriter 指定了两种类型的详细信息:

触发详情

这指示何时触发新可用流式数据的发现和处理。有四个选项:

默认

当未显式指定触发器时,默认情况下,流式查询以微批次处理数据,其中下一个微批次在前一个微批次完成后立即触发。

使用触发间隔的处理时间

您可以显式指定 ProcessingTime 触发器及其间隔,并且查询将在固定间隔触发微批处理。

一次

在这种模式下,流式查询将精确执行一个微批处理,处理所有新的可用数据并随后停止。这在你希望通过外部调度程序控制触发和处理,并使用任意自定义时间表重新启动查询(例如,仅执行一次查询每天一次)时非常有用。

连续

这是一个实验性模式(截至 Spark 3.0),流式查询将连续处理数据,而不是以微批次处理。虽然只有 DataFrame 操作的一个小子集允许使用此模式,但它可以提供比微批处理触发模式更低的延迟(低至毫秒)。有关最新信息,请参阅最新的结构化流编程指南

检查点位置

这是任何与 HDFS 兼容的文件系统中的目录,流查询将其进度信息保存在其中——即已成功处理的数据。在失败时,此元数据用于在失败时恢复查询,确切一次性保证因此设置此选项对于故障恢复是必要的。

第 5 步:启动查询

一旦所有内容都已指定,最后一步是启动查询,可以通过以下方式完成:

# In Python
streamingQuery = writer2.start()
// In Scala
val streamingQuery = writer2.start()

类型为streamingQuery的返回对象表示活动查询,并可用于管理查询,这将在本章后面介绍。

注意,start()是一个非阻塞方法,因此一旦查询在后台启动,它就会立即返回。如果你希望主线程阻塞,直到流查询终止,可以使用streamingQuery.awaitTermination()。如果查询在后台因错误而失败,awaitTermination()也将因同样的异常而失败。

您可以使用awaitTermination(timeoutMillis)等待超时时长,也可以使用streamingQuery.stop()显式停止查询。

将所有内容整合在一起

总结一下,这里是通过套接字读取文本流数据、计算单词数并将计数打印到控制台的完整代码:

# In Python
from pyspark.sql.functions import *
spark = SparkSession...
lines = (spark
  .readStream.format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load())

words = lines.select(split(col("value"), "\\s").alias("word"))
counts = words.groupBy("word").count()
checkpointDir = "..."
streamingQuery = (counts
  .writeStream
  .format("console")
  .outputMode("complete")
  .trigger(processingTime="1 second")
  .option("checkpointLocation", checkpointDir)
  .start())
streamingQuery.awaitTermination()
// In Scala
import org.apache.spark.sql.functions._
import org.apache.spark.sql.streaming._
val spark = SparkSession...
val lines = spark
  .readStream.format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load()

val words = lines.select(split(col("value"), "\\s").as("word"))
val counts = words.groupBy("word").count()

val checkpointDir = "..."
val streamingQuery = counts.writeStream
  .format("console")
  .outputMode("complete")
  .trigger(Trigger.ProcessingTime("1 second"))
  .option("checkpointLocation", checkpointDir)
  .start()
streamingQuery.awaitTermination()

查询启动后,后台线程不断从流源读取新数据,处理它,并将其写入流接收器。接下来,让我们快速看一下这是如何执行的。

主动流查询的内部运行机制

查询启动后,引擎中会出现以下步骤序列,如图 8-5 所示。DataFrame 操作转换为逻辑计划,这是 Spark SQL 用来计划查询的抽象表示:

  1. Spark SQL 分析和优化此逻辑计划,以确保可以在流数据上以增量和高效的方式执行。

  2. Spark SQL 启动了一个后台线程,不断执行以下循环:²

    1. 基于配置的触发间隔,线程检查流源以查看是否有新数据可用。

    2. 如果有新数据可用,则通过运行微批处理来执行它。从优化的逻辑计划中生成优化的 Spark 执行计划,该计划从源读取新数据,逐步计算更新结果,并根据配置的输出模式将输出写入接收器。

    3. 对于每个微批处理,处理的确切数据范围(例如文件集或 Apache Kafka 偏移量的范围)和任何相关状态都保存在配置的检查点位置,以便在需要时确定性地重新处理确切的范围。

  3. 此循环持续到查询终止,可能出现以下原因之一:

    1. 查询发生了故障(无论是处理错误还是集群中的故障)。

    2. 使用streamingQuery.stop()明确停止查询。

    3. 如果触发器设置为Once,则查询将在执行包含所有可用数据的单个微批次后自行停止。

流式查询的增量执行

图 8-5. 流式查询的增量执行
注意

关于结构化流处理的一个关键点是,在其背后实际上使用的是 Spark SQL 来执行数据。因此,充分利用了 Spark SQL 的超优化执行引擎的全部能力,以最大化流处理吞吐量,提供关键的性能优势。

接下来,我们将讨论如何在终止后重新启动流查询以及流查询的生命周期。

使用精确一次性保证从故障中恢复

要在完全新的进程中重新启动终止的查询,您必须创建一个新的SparkSession,重新定义所有的 DataFrame,并在最终结果上使用与第一次启动查询时相同的检查点位置开始流式查询。对于我们的词频统计示例,您可以简单地重新执行从第一行的spark定义到最后一行的start()的整个代码片段。

检查点位置在重启时必须保持一致,因为该目录包含流查询的唯一标识,并确定查询的生命周期。如果删除检查点目录或者使用不同的检查点目录启动相同查询,则相当于从头开始一个新的查询。具体来说,检查点具有记录级信息(例如,Apache Kafka 偏移量),以跟踪上一个查询正在处理的最后一个不完整微批次的数据范围。重新启动的查询将使用此信息在成功完成的最后一个微批次之后精确地开始处理记录。如果先前的查询已计划一个微批次但在完成之前终止,则重新启动的查询将重新处理相同范围的数据,然后处理新数据。结合 Spark 的确定性任务执行,生成的输出将与重启前预期的输出相同。

当满足以下条件时,结构化流处理可以确保端到端的精确一次性保证(即,输出就像每个输入记录确实只处理了一次):

可重放的流源

最后一个不完整的微批次的数据范围可以从源头重新读取。

确定性计算

所有数据转换在给定相同输入数据时都能确定性地产生相同的结果。

幂等的流接收器

接收器可以识别重新执行的微批次,并忽略由重启可能引起的重复写入。

请注意,我们的词频统计示例不提供精确一次性保证,因为套接字源不可重放,控制台接收器不是幂等的。

关于重新启动查询的最后一点,可以在重新启动之间对查询进行微小修改。以下是几种可以修改查询的方式:

DataFrame 转换

您可以在重新启动之间对转换进行微小修改。例如,在我们的流式字数示例中,如果要忽略具有可能导致查询崩溃的损坏字节序列的行,则可以在转换中添加一个过滤器:

# In Python
# isCorruptedUdf = udf to detect corruption in string

filteredLines = lines.filter("isCorruptedUdf(value) = false")
words = filteredLines.select(split(col("value"), "\\s").alias("word"))
// In Scala
// val isCorruptedUdf = udf to detect corruption in string

val filteredLines = lines.filter("isCorruptedUdf(value) = false")
val words = filteredLines.select(split(col("value"), "\\s").as("word"))

使用此修改后的words DataFrame 重新启动后,重新启动的查询将对自重新启动以来处理的所有数据应用过滤器(包括最后一个不完整的微批次),以防止再次失败。

源和接收端选项

在重新启动之间是否可以更改readStreamwriteStream选项取决于特定源或接收端的语义。例如,如果数据将发送到该主机和端口,则不应更改hostport选项的套接字源。但是,您可以向控制台接收端添加一个选项,以在每次触发后打印最多一百个变更计数:

writeStream.format("console").option("numRows", "100")...

处理细节

正如前面讨论的,检查点位置在重新启动之间不能更改。但是,可以在不破坏容错保证的情况下更改其他细节,如触发间隔。

有关在重新启动之间允许的狭窄变化集的更多信息,请参阅最新的结构化流编程指南

监视活动查询

在生产中运行流水线的重要部分是跟踪其健康状况。结构化流提供了几种方法来跟踪活动查询的状态和处理指标。

使用 StreamingQuery 查询当前状态

您可以使用StreamingQuery实例查询活动查询的当前健康状况。以下是两种方法:

使用 StreamingQuery 获取当前指标

当查询在微批次中处理一些数据时,我们认为它已经取得了一些进展。lastProgress()返回上一个完成的微批次的信息。例如,打印返回的对象(在 Scala/Java 中为StreamingQueryProgress,在 Python 中为字典)将产生类似于以下的内容:

// In Scala/Python
{
  "id" : "ce011fdc-8762-4dcb-84eb-a77333e28109",
  "runId" : "88e2ff94-ede0-45a8-b687-6316fbef529a",
  "name" : "MyQuery",
  "timestamp" : "2016-12-14T18:45:24.873Z",
  "numInputRows" : 10,
  "inputRowsPerSecond" : 120.0,
  "processedRowsPerSecond" : 200.0,
  "durationMs" : {
    "triggerExecution" : 3,
    "getOffset" : 2
  },
  "stateOperators" : [ ],
  "sources" : [ {
    "description" : "KafkaSource[Subscribe[topic-0]]",
    "startOffset" : {
      "topic-0" : {
        "2" : 0,
        "1" : 1,
        "0" : 1
      }
    },
    "endOffset" : {
      "topic-0" : {
        "2" : 0,
        "1" : 134,
        "0" : 534
      }
    },
    "numInputRows" : 10,
    "inputRowsPerSecond" : 120.0,
    "processedRowsPerSecond" : 200.0
  } ],
  "sink" : {
    "description" : "MemorySink"
  }
}

一些值得注意的列包括:

id

与检查点位置绑定的唯一标识符。这将在查询的整个生命周期内保持不变(即在重新启动之间)。

runId

与当前(重新)启动查询实例相关联的唯一标识符。每次重新启动时都会更改。

numInputRows

上一微批次中处理的输入行数。

inputRowsPerSecond

源处生成输入行的当前速率(在上一个微批次持续时间内的平均值)。

processedRowsPerSecond

通过接收器处理的行的当前速率(在最后一次微批处理的平均时间内)。如果此速率一直低于输入速率,则查询无法像源生成数据那样快速处理数据。这是查询健康状况的关键指标。

sourcessink

提供了最后一个批次中处理的数据的源/接收器特定细节。

使用StreamingQuery.status()获取当前状态

这提供了有关后台查询线程此刻正在执行的操作的信息。例如,打印返回的对象将产生类似以下的输出:

// In Scala/Python
{
  "message" : "Waiting for data to arrive",
  "isDataAvailable" : false,
  "isTriggerActive" : false
}

使用 Dropwizard Metrics 发布度量

Spark 支持通过一个称为Dropwizard Metrics的流行库报告度量。此库允许度量数据发布到许多流行的监控框架(Ganglia、Graphite 等)。由于报告的数据量很大,这些度量默认情况下不会为结构化流查询启用。要启用它们,除了为 Spark 配置 Dropwizard Metrics之外,您还必须在启动查询之前显式设置SparkSession配置spark.sql.streaming.metricsEnabledtrue

注意,仅通过StreamingQuery.lastProgress()公布了 Dropwizard Metrics 中可用的部分信息。如果您想持续将更多进度信息发布到任意位置,您必须编写自定义监听器,如下所述。

使用自定义 StreamingQueryListeners 发布度量

StreamingQueryListener是一个事件监听器接口,您可以使用它注入任意逻辑以持续发布度量。此开发者 API 仅在 Scala/Java 中可用。使用自定义监听器有两个步骤:

  1. 定义您的自定义监听器。StreamingQueryListener接口提供了三种方法,您可以通过自己的实现定义这三种类型的与流查询相关的事件:启动、进度(即执行了触发器)和终止。这里是一个例子:

    // In Scala
    import org.apache.spark.sql.streaming._
    val myListener = new StreamingQueryListener() {
      override def onQueryStarted(event: QueryStartedEvent): Unit = {
        println("Query started: " + event.id)
      }
      override def onQueryTerminated(event: QueryTerminatedEvent): Unit = {
        println("Query terminated: " + event.id)
      }
      override def onQueryProgress(event: QueryProgressEvent): Unit = {
        println("Query made progress: " + event.progress)
      }
    }
    
  2. 在启动查询之前将监听器添加到SparkSession

    // In Scala
    spark.streams.addListener(myListener)
    

    添加监听器后,运行在此SparkSession上的所有流查询事件将开始调用监听器的方法。

流数据源和接收器

现在我们已经介绍了表达端到端结构化流查询所需的基本步骤,让我们看看如何使用内置的流数据源和接收器。作为提醒,您可以使用SparkSession.readStream()从流源创建 DataFrame,并使用DataFrame.writeStream()将结果 DataFrame 的输出写入。在每种情况下,您可以使用format()方法指定源类型。稍后我们将看到几个具体的例子。

文件

结构化流支持从文件中读取和写入数据流,格式与批处理中支持的格式相同:纯文本、CSV、JSON、Parquet、ORC 等。在这里,我们将讨论如何在文件上操作结构化流。

从文件读取

结构化流可以将写入目录的文件视为数据流。以下是一个示例:

# In Python
from pyspark.sql.types import *
inputDirectoryOfJsonFiles =  ... 

fileSchema = (StructType()
  .add(StructField("key", IntegerType()))
  .add(StructField("value", IntegerType())))

inputDF = (spark
  .readStream
  .format("json")
  .schema(fileSchema)
  .load(inputDirectoryOfJsonFiles))
// In Scala
import org.apache.spark.sql.types._
val inputDirectoryOfJsonFiles =  ... 

val fileSchema = new StructType()
  .add("key", IntegerType)
  .add("value", IntegerType)

val inputDF = spark.readStream
  .format("json")
  .schema(fileSchema)
  .load(inputDirectoryOfJsonFiles)

返回的流 DataFrame 将具有指定的模式。在使用文件时,有几个需要记住的关键点:

  • 所有文件必须具有相同的格式,并且预计具有相同的模式。例如,如果格式是"json",则所有文件必须以每行一个 JSON 记录的 JSON 格式。每个 JSON 记录的模式必须与readStream()指定的模式匹配。违反这些假设可能导致不正确的解析(例如,意外的null值)或查询失败。

  • 每个文件必须在目录列表中以原子方式显示——也就是说,整个文件必须一次性可用于读取,一旦可用,文件就不能被更新或修改。这是因为结构化流处理会在引擎找到文件后(使用目录列表)处理文件,并在内部标记为已处理。对该文件的任何更改都不会被处理。

  • 当有多个新文件要处理但在下一个微批处理中只能选择其中一些(例如,因为速率限制),它将选择具有最早时间戳的文件。然而,在微批处理内部,选择的文件没有预定义的读取顺序;所有这些文件将并行读取。

注意

此流文件源支持许多常见选项,包括由spark.read()支持的特定于文件格式的选项(参见“DataFrame 和 SQL 表的数据源”第第四章)和几个流特定选项(例如,maxFilesPerTrigger限制文件处理速率)。请查阅编程指南获取完整详细信息。

写入文件

结构化流支持将流查询输出写入与读取相同格式的文件。但是,它只支持追加模式,因为在输出目录写入新文件很容易(即向目录追加数据),但修改现有数据文件则很难(如更新和完成模式所期望的)。它还支持分区。以下是一个示例:

# In Python
outputDir = ...
checkpointDir = ...
resultDF = ...

streamingQuery = (resultDF.writeStream
  .format("parquet")
  .option("path", outputDir)
  .option("checkpointLocation", checkpointDir)
  .start())
// In Scala
val outputDir = ...
val checkpointDir = ...
val resultDF = ...

val streamingQuery = resultDF
  .writeStream
  .format("parquet")
  .option("path", outputDir)
  .option("checkpointLocation", checkpointDir)
  .start()

您可以直接指定输出目录作为start(outputDir),而不是使用"path"选项。

一些需要记住的关键点:

  • Structured Streaming 在写入文件时实现端到端的精确一次性保证,通过维护已写入目录的数据文件日志。此日志保存在子目录 _spark_metadata 中。对目录(而非其子目录)的任何 Spark 查询都将自动使用日志来读取正确的数据文件集,以保持精确一次性保证(即不会读取重复数据或部分文件)。请注意,其他处理引擎可能不了解此日志,因此可能无法提供相同的保证。

  • 如果在重启之间更改结果 DataFrame 的模式,则输出目录将包含多个模式的数据。在查询目录时,这些模式必须进行协调。

Apache Kafka

Apache Kafka 是一种流行的发布/订阅系统,广泛用于数据流的存储。Structured Streaming 内置支持从 Apache Kafka 读取和写入数据。

从 Kafka 读取

要从 Kafka 执行分布式读取,您必须使用选项来指定如何连接到源。假设您想订阅来自主题 "events" 的数据。以下是如何创建流式 DataFrame 的方法:

# In Python
inputDF = (spark
  .readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "events")
  .load())
// In Scala
val inputDF = spark
  .readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "events")
  .load()

返回的 DataFrame 将具有 表 8-1 中描述的模式。

表 8-1. 由 Kafka 源生成的 DataFrame 的模式

列名 列类型 描述
key binary 记录键的字节数据。
value binary 记录值的字节数据。
topic string 订阅多个主题时,记录所在的 Kafka 主题。
partition int 记录所在 Kafka 主题的分区。
offset long 记录的偏移值。
timestamp long 记录相关联的时间戳。
timestampType int 与记录相关联的时间戳类型的枚举。

您还可以选择订阅多个主题、主题模式,甚至特定主题的分区。此外,您可以选择是否只读取订阅主题中的新数据或处理这些主题中的所有可用数据。您甚至可以从批处理查询中读取 Kafka 数据,即将 Kafka 主题视为表格。有关更多详情,请参阅 Kafka 集成指南

写入 Kafka

对于写入 Kafka,Structured Streaming 期望结果 DataFrame 具有特定名称和类型的几列,如 表 8-2 所述。

表 8-2. 可写入 Kafka sink 的 DataFrame 模式

列名 列类型 描述
key(可选) stringbinary 如果存在,则将以 Kafka 记录键的形式写入的字节;否则,键将为空。
value(必需) stringbinary 将以 Kafka 记录值的形式写入的字节。
topic(仅在未将 "topic" 指定为选项时需要) string 如果未将 "topic" 指定为选项,则确定要将键/值写入的主题。这对于将写入扇出到多个主题非常有用。如果已指定 "topic" 选项,则忽略此值。

您可以在所有三种输出模式下将数据写入 Kafka,尽管不推荐使用完整模式,因为它会重复输出相同的记录。以下是将前面的单词计数查询输出写入 Kafka 的具体示例,使用更新模式:

# In Python
counts = ... # DataFrame[word: string, count: long]
streamingQuery = (counts
  .selectExpr(
    "cast(word as string) as key", 
    "cast(count as string) as value")
  .writeStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("topic", "wordCounts")
  .outputMode("update")
  .option("checkpointLocation", checkpointDir)
  .start())
// In Scala
val counts = ... // DataFrame[word: string, count: long]
val streamingQuery = counts
  .selectExpr(
    "cast(word as string) as key", 
    "cast(count as string) as value")
  .writeStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("topic", "wordCounts")
  .outputMode("update")
  .option("checkpointLocation", checkpointDir)
  .start()

有关详细信息,请参阅 Kafka 集成指南

自定义流源和接收器

在本节中,我们将讨论如何读取和写入不具备结构化流内置支持的存储系统。特别是,您将看到如何使用 foreachBatch()foreach() 方法来实现自定义逻辑以写入您的存储。

写入任何存储系统

有两种操作允许您将流查询的输出写入任意存储系统:foreachBatch()foreach()。它们有略微不同的用例:foreach() 允许在每一行上进行自定义写入逻辑,而 foreachBatch() 则允许在每个微批次的输出上执行任意操作和自定义逻辑。让我们更详细地探讨它们的用法。

使用 foreachBatch()

foreachBatch() 允许您指定一个函数,在流查询的每个微批次的输出上执行该函数。它接受两个参数:一个 DataFrame 或 Dataset,它包含微批次的输出,以及微批次的唯一标识符。例如,假设我们想要将前面的单词计数查询的输出写入 Apache Cassandra。截至 Spark Cassandra Connector 2.4.2,尚不支持写入流数据集。但是,您可以使用连接器的批处理 DataFrame 支持,将每个批次的输出(即更新的单词计数)写入 Cassandra,如下所示:

# In Python
hostAddr = "<ip address>"
keyspaceName = "<keyspace>"
tableName = "<tableName>"

spark.conf.set("spark.cassandra.connection.host", hostAddr)

def writeCountsToCassandra(updatedCountsDF, batchId):
    # Use Cassandra batch data source to write the updated counts
    (updatedCountsDF
      .write
      .format("org.apache.spark.sql.cassandra")
      .mode("append")
      .options(table=tableName, keyspace=keyspaceName)
      .save())

streamingQuery = (counts
  .writeStream
  .foreachBatch(writeCountsToCassandra)
  .outputMode("update")
  .option("checkpointLocation", checkpointDir)
  .start())
// In Scala
import org.apache.spark.sql.DataFrame

val hostAddr = "<ip address>"
val keyspaceName = "<keyspace>"
val tableName = "<tableName>"

spark.conf.set("spark.cassandra.connection.host", hostAddr)

def writeCountsToCassandra(updatedCountsDF: DataFrame, batchId: Long) {
    // Use Cassandra batch data source to write the updated counts
    updatedCountsDF
      .write
      .format("org.apache.spark.sql.cassandra")
      .options(Map("table" -> tableName, "keyspace" -> keyspaceName))
      .mode("append")
      .save()
    }

val streamingQuery = counts
  .writeStream
  .foreachBatch(writeCountsToCassandra _)
  .outputMode("update")
  .option("checkpointLocation", checkpointDir)
  .start()

使用 foreachBatch(),您可以执行以下操作:

重用现有的批处理数据源

如前面的示例所示,通过 foreachBatch() 您可以使用现有的批处理数据源(即支持写入批处理 DataFrame 的数据源)来写入流查询的输出。

写入多个位置

如果要将流查询的输出写入多个位置(例如 OLAP 数据仓库和 OLTP 数据库),则可以简单地多次写入输出 DataFrame/Dataset。然而,每次写入尝试可能导致输出数据重新计算(包括可能重新读取输入数据)。为了避免重新计算,您应该将 batchOutputDataFrame 缓存起来,将其写入多个位置,然后取消缓存:

# In Python
def writeCountsToMultipleLocations(updatedCountsDF, batchId):
  updatedCountsDF.persist()
  updatedCountsDF.write.format(...).save()  # Location 1
  updatedCountsDF.write.format(...).save()  # Location 2
  updatedCountsDF.unpersist()
// In Scala
def writeCountsToMultipleLocations(
  updatedCountsDF: DataFrame, 
  batchId: Long) {
    updatedCountsDF.persist()
    updatedCountsDF.write.format(...).save()  // Location 1
    updatedCountsDF.write.format(...).save()  // Location 2
    updatedCountsDF.unpersist()
 }

应用额外的 DataFrame 操作

许多 DataFrame API 操作在流式 DataFrame 上不受支持³,因为结构化流在这些情况下不支持生成增量计划。使用 foreachBatch(),您可以在每个微批次输出上应用其中一些操作。但是,您必须自行推理执行操作的端到端语义。

注意

foreachBatch() 仅提供至少一次写入保证。通过使用 batchId 来消除重新执行的微批次中的多次写入,可以获得精确一次的保证。

使用 foreach()

如果 foreachBatch() 不是一个选项(例如,不存在相应的批数据写入器),那么可以使用 foreach() 表达您自定义的写入逻辑。具体而言,您可以通过将其分为 open()process()close() 三种方法来表达数据写入逻辑。结构化流将使用这些方法来写入输出记录的每个分区。以下是一个抽象示例:

# In Python
# Variation 1: Using function
def process_row(row):
    # Write row to storage
    pass

query = streamingDF.writeStream.foreach(process_row).start()  

# Variation 2: Using the ForeachWriter class
class ForeachWriter:
  def open(self, partitionId, epochId):
    # Open connection to data store
    # Return True if write should continue
    # This method is optional in Python 
    # If not specified, the write will continue automatically
    return True

  def process(self, row):
    # Write string to data store using opened connection
    # This method is NOT optional in Python
    pass

  def close(self, error):
    # Close the connection. This method is optional in Python
    pass

resultDF.writeStream.foreach(ForeachWriter()).start()
// In Scala
import org.apache.spark.sql.ForeachWriter
val foreachWriter = new ForeachWriter[String] {  // typed with Strings

    def open(partitionId: Long, epochId: Long): Boolean = {
      // Open connection to data store
      // Return true if write should continue
    }

    def process(record: String): Unit = {
      // Write string to data store using opened connection
    }

    def close(errorOrNull: Throwable): Unit = {
      // Close the connection
    }
 }

resultDSofStrings.writeStream.foreach(foreachWriter).start()

关于这些方法的详细语义如何执行,请参阅结构化流编程指南

从任何存储系统读取

不幸的是,截至 Spark 3.0,构建自定义流源和接收器的 API 仍处于实验阶段。Spark 3.0 中的 DataSourceV2 倡议引入了流式 API,但尚未宣布稳定。因此,目前没有官方方法可以从任意存储系统读取。

数据转换

在本节中,我们将深入探讨结构化流支持的数据转换。正如之前简要讨论的那样,在结构化流中,只支持可以增量执行的 DataFrame 操作。这些操作广泛分为无状态有状态操作。我们将定义每种操作类型,并解释如何识别哪些操作是有状态的。

增量执行和流式状态

正如我们在“一个活跃流式查询的内部工作原理”中讨论的那样,在 Spark SQL 的 Catalyst 优化器中,将所有 DataFrame 操作转换为优化的逻辑计划。Spark SQL 计划器决定如何执行逻辑计划时,会识别这是一个需要在连续数据流上操作的流式逻辑计划。因此,计划器不会将逻辑计划转换为一次性物理执行计划,而是生成连续的执行计划序列。每个执行计划都会增量更新最终的结果 DataFrame —— 也就是说,该计划仅处理来自输入流的新数据块,可能还会处理上一个执行计划计算的一些中间部分结果。

每次执行被视为一个微批处理,并且在执行之间传递的部分中间结果被称为流“状态”。DataFrame 操作可以根据执行操作增量需要维护状态来广泛分类为无状态和有状态操作。在本节的其余部分,我们将探讨无状态和有状态操作之间的区别,以及它们在流查询中的存在如何需要不同的运行时配置和资源管理。

注意

一些逻辑操作基本上不可能或者计算增量非常昂贵,因此不支持结构化流。例如,任何尝试以cube()rollup()等操作启动流查询的尝试将引发UnsupportedOperationException

无状态转换

所有投影操作(例如select()explode()map()flatMap())和选择操作(例如filter()where())都会逐个处理每个输入记录,无需任何先前行的信息。这种不依赖于先前输入数据的特性使它们成为无状态操作。

仅具有无状态操作的流查询支持追加和更新输出模式,但不支持完整模式。这是有道理的:由于此类查询的任何处理输出行都不能被未来的数据修改,因此可以将其写入所有流式接收器的追加模式中(包括仅追加的接收器,如任何格式的文件)。另一方面,这样的查询自然不会跨输入记录组合信息,因此可能不会减少结果中数据的量。通常不支持完整模式,因为存储不断增长的结果数据通常代价高昂。这与有状态的转换形成鲜明对比,接下来我们将讨论这一点。

有状态的转换

有状态转换的最简单示例是DataFrame.groupBy().count(),它生成自查询开始以来接收到的记录数量的累积计数。在每个微批处理中,增量计划将新记录的计数添加到前一个微批处理生成的先前计数中。计划之间传递的部分计数即为状态。这种状态保存在 Spark 执行器的内存中,并且以检查点的方式保存到配置的位置,以容忍故障。尽管 Spark SQL 会自动管理此状态的生命周期以确保正确的结果,但通常需要调整一些参数来控制维护状态的资源使用情况。在本节中,我们将探讨不同有状态操作符在底层管理其状态的方式。

分布式和容错的状态管理

从第一章和第二章回忆起,运行在集群中的 Spark 应用程序具有一个 driver 和一个或多个 executor。驱动器中运行的 Spark 调度程序将您的高级操作分解为更小的任务,并将它们放入任务队列中,当资源可用时,执行者从队列中拉取任务以执行。流查询中的每个微批次基本上执行一组从流源读取新数据并将更新后的输出写入流接收器的任务集。对于有状态的流处理查询,除了写入接收器,每个微批次的任务还会生成中间状态数据,这些数据将由下一个微批次消耗。这些状态数据生成完全分区和分布(与 Spark 中的所有读取、写入和处理一样),并且被缓存在执行者内存中以便高效消费。这在 图 8-6 中有所体现,展示了我们原始的流式单词计数查询中的状态管理方式。

结构化流式处理中的分布式状态管理

图 8-6. 结构化流式处理中的分布式状态管理

每个微批次读取一组新的单词,将它们在执行者内部进行分组以计算每个微批次中的计数,最后将它们添加到运行计数中以生成新的计数。这些新计数既是输出也是下一个微批次的状态,并因此缓存在执行者的内存中。下一个数据微批次在执行者之间的分组方式与之前完全相同,因此每个单词始终由相同的执行者处理,可以在本地读取和更新其运行计数。

然而,仅仅将这些状态保存在内存中是不够的,因为任何失败(无论是执行者的失败还是整个应用程序的失败)都会导致内存中的状态丢失。为了避免丢失,我们将键/值状态更新同步保存为用户提供的检查点位置中的变更日志。这些更改与每个批次处理的偏移范围共同版本化,通过读取检查点日志可以自动重建所需版本的状态。在任何故障情况下,结构化流处理能够通过重新处理相同的输入数据以及之前微批次中的相同状态来重新执行失败的微批次,从而生成与没有失败时相同的输出数据。这对于确保端到端的精确一次性保证至关重要。

总结一下,对于所有有状态操作,结构化流处理通过在分布式方式下自动保存和恢复状态来确保操作的正确性。根据有状态操作的不同,您可能只需调整状态清理策略,以便可以自动从缓存状态中丢弃旧键和值。接下来我们将讨论这一点。

有状态操作类型

流式状态的核心是保留过去数据的摘要。有时需要清理状态中的旧摘要,以为新摘要腾出空间。根据操作方式的不同,我们可以区分两种类型的有状态操作:

托管的有状态操作

这些操作根据特定于操作的“旧”定义自动识别和清理旧状态。您可以调整“旧”定义以控制资源使用情况(例如,用于存储状态的执行器内存)。属于此类别的操作包括:

  • 流式聚合

  • 流-流连接

  • 流式去重

非托管的有状态操作

这些操作允许您定义自己的自定义状态清理逻辑。本类别中的操作包括:

  • MapGroupsWithState

  • FlatMapGroupsWithState

这些操作允许您定义任意的有状态操作(如会话化等)。

以下各操作在以下各节中详细讨论。

有状态流聚合

结构化流处理可以逐步执行大多数 DataFrame 聚合操作。您可以按键(例如,流式词频统计)和/或时间(例如,每小时接收到的记录计数)聚合数据。在本节中,我们将讨论调整这些不同类型流式聚合的语义和操作细节。我们还将简要讨论一些不支持流式操作的聚合类型。让我们从不涉及时间的聚合开始。

不基于时间的聚合

不涉及时间的聚合可以大致分为两类:

全局聚合

跨流中所有数据的聚合。例如,假设您有一个名为 sensorReadings 的流式 DataFrame,表示传感器读数流。您可以使用以下查询计算接收到的读数总数的运行计数:

# In Python
runningCount = sensorReadings.groupBy().count()
// In Scala
val runningCount = sensorReadings.groupBy().count()
注意

您不能在流式 DataFrame 上直接使用聚合操作,例如 DataFrame.count()Dataset.reduce()。这是因为对于静态 DataFrame,这些操作会立即返回最终计算的聚合结果,而对于流式 DataFrame,聚合结果必须持续更新。因此,您必须始终使用 DataFrame.groupBy()Dataset.groupByKey() 进行流式 DataFrame 的聚合。

分组聚合

在数据流中的每个组或键中进行聚合。例如,如果 sensorReadings 包含来自多个传感器的数据,则可以计算每个传感器的运行平均读数(例如,为每个传感器设置基准值),如下所示:

# In Python 
baselineValues = sensorReadings.groupBy("sensorId").mean("value")
// In Scala
val baselineValues = sensorReadings.groupBy("sensorId").mean("value")

除计数和平均值之外,流式 DataFrame 还支持以下类型的聚合(类似于批处理 DataFrame):

所有内置的聚合函数

sum(), mean(), stddev(), countDistinct(), collect_set(), approx_count_distinct() 等。更多详细信息请参阅 API 文档(PythonScala)。

一起计算多个聚合

您可以将多个聚合函数应用于以下方式一起计算:

# In Python
from pyspark.sql.functions import *
multipleAggs = (sensorReadings
  .groupBy("sensorId")
  .agg(count("*"), mean("value").alias("baselineValue"), 
    collect_set("errorCode").alias("allErrorCodes")))
// In Scala
import org.apache.spark.sql.functions.*
val multipleAggs = sensorReadings
  .groupBy("sensorId")
  .agg(count("*"), mean("value").alias("baselineValue"),
    collect_set("errorCode").alias("allErrorCodes"))

用户定义的聚合函数

支持所有用户定义的聚合函数。有关无类型和有类型用户定义聚合函数的更多详细信息,请参阅 Spark SQL 编程指南

关于执行此类流式聚合,我们已经在前面的章节中说明了如何将运行聚合保持为分布式状态。除此之外,还有两个非常重要的注意事项适用于非时间依赖的聚合:用于此类查询的输出模式和通过状态规划资源使用。这些将在本节末尾讨论。接下来,我们将讨论在时间窗口内组合数据的聚合。

事件时间窗口内的聚合

在许多情况下,不是对整个流执行聚合,而是希望对按时间窗口分桶的数据执行聚合。继续使用传感器示例,假设每个传感器每分钟最多发送一次读数,并且我们希望检测任何传感器报告异常高次数。为了找到这样的异常,我们可以计算每个传感器在五分钟间隔内接收到的读数数量。此外,为了保证稳健性,应基于数据在传感器生成时生成的时间间隔计算时间间隔,而不是基于接收数据时的时间,因为任何传输延迟都会使结果偏斜。换句话说,我们要使用事件时间——即记录中的时间戳,表示生成读数的时间。假设 sensorReadings DataFrame 具有命名为 eventTime 的生成时间戳列。我们可以将这个五分钟计数表示如下:

# In Python
from pyspark.sql.functions import *
(sensorReadings
  .groupBy("sensorId", window("eventTime", "5 minute"))
  .count())
// In Scala
import org.apache.spark.sql.functions.*
sensorReadings
  .groupBy("sensorId", window("eventTime", "5 minute"))
  .count()

这里需要注意的关键是 window() 函数,它允许我们将五分钟窗口表达为动态计算的分组列。启动时,此查询将为每个传感器读数执行以下操作:

  • 使用 eventTime 值计算传感器读数所在的五分钟时间窗口。

  • 基于组合组 (*<computed window>*, SensorId) 对读数进行分组。

  • 更新组合组的计数。

让我们通过一个示例来理解这个过程。图 8-7 展示了如何将一些传感器读数映射到基于事件时间的五分钟滚动(即非重叠)窗口组。两个时间线展示了每个接收事件将被结构化流处理的时间,以及事件数据中的时间戳(通常是传感器生成事件时的时间)。

事件时间映射到滚动窗口

图 8-7. 事件时间映射到滚动窗口

基于事件时间,每个五分钟窗口都用于基于组的计数。请注意,事件可能会以晚到或乱序的方式到达。如图所示,事件时间为 12:07 的事件在事件时间为 12:11 的事件之后接收和处理。然而,无论它们何时到达,每个事件都会根据其事件时间分配到适当的组中。实际上,根据窗口规范,每个事件可能会分配到多个组。例如,如果要计算每隔 5 分钟滑动的 10 分钟窗口对应的计数,则可以执行以下操作:

# In Python
(sensorReadings
  .groupBy("sensorId", window("eventTime", "10 minute", "5 minute"))
  .count())
// In Scala
sensorReadings
  .groupBy("sensorId", window("eventTime", "10 minute", "5 minute"))
  .count()

在此查询中,每个事件都将被分配到两个重叠的窗口,如图 8-8 所示。

事件时间映射到多个重叠窗口

图 8-8. 事件时间映射到多个重叠窗口

每个唯一的元组(*<分配的时间窗口>*, sensorId)被视为动态生成的组,将对其进行计数。例如,事件[eventTime = 12:07, sensorId = id1]映射到两个时间窗口,因此生成两个组,(12:00-12:10, id1)(12:05-12:15, id1)。这两个窗口的计数分别增加了 1. 图 8-9 对之前展示的事件进行了说明。

假设输入记录以五分钟的触发间隔进行处理,底部的表在图 8-9 显示了结果表(即计数)在每个微批处理时的状态。随着事件时间的推移,新的组会自动创建并且它们的聚合会自动更新。晚到和乱序的事件会被自动处理,因为它们只是更新旧的组。

每隔 5 分钟触发后结果表中的更新计数

图 8-9. 每隔五分钟触发后结果表中的更新计数

然而,从资源使用的角度来看,这带来了另一个问题——状态大小无限增长。随着创建新组对应于最新时间窗口,旧组继续占用状态内存,等待任何延迟数据更新它们。即使在实践中,输入数据的延迟可能有一个界限(例如,数据不能迟于七天),但查询不知道这些信息。因此,它不知道何时将窗口视为“太旧以接收更新”并将其从状态中删除。为了向查询提供延迟边界(并防止无界状态),您可以指定水印,接下来我们将讨论它。

处理延迟数据与水印

水印定义为事件时间中的移动阈值,落后于查询在处理数据中观察到的最大事件时间。这种落后的间隙被称为水印延迟,它定义了引擎等待延迟数据到达的时间。通过了解对于给定组不会再有更多数据到达的时间点,引擎可以自动完成某些组的聚合并将其从状态中删除。这限制了引擎必须维护的状态总量,以计算查询结果。

例如,假设您知道您的传感器数据不会迟到超过 10 分钟。那么,您可以设置水印如下:

# In Python
(sensorReadings
  .withWatermark("eventTime", "10 minutes")
  .groupBy("sensorId", window("eventTime", "10 minutes", "5 minutes"))
  .mean("value"))
// In Scala
sensorReadings
  .withWatermark("eventTime", "10 minutes")
  .groupBy("sensorId", window("eventTime", "10 minutes", "5 minute"))
  .mean("value")

请注意,在groupBy()之前必须调用withWatermark(),并且在与用于定义窗口的时间戳列相同的时间戳列上。当执行此查询时,结构化流将持续跟踪eventTime列的观察到的最大值,并相应更新水印,过滤“太迟”的数据,并清除旧状态。也就是说,任何迟到超过 10 分钟的数据都将被忽略,并且所有比最新(按事件时间)输入数据早 10 分钟以上的时间窗口将从状态中清除。为了澄清此查询的执行方式,请考虑图 8-10 中显示的时间线,显示了如何处理输入记录的选择。

展示引擎如何跟踪事件的最大时间戳,更新水印,并相应处理延迟数据的插图

图 8-10. 展示引擎如何跟踪事件的最大时间戳,更新水印,并相应处理延迟数据

此图显示了记录按其处理时间(x 轴)和事件时间(y 轴)处理的二维图。记录以五分钟的微批次处理,并标记为圆圈。底部的表显示了每个微批次完成后结果表的状态。

每条记录在所有左侧记录接收并处理后收到。考虑两条记录 [12:15, id1](大约在 12:17 处理)和 [12:13, id3](大约在 12:18 处理)。记录 id3 因为在传感器生成 id1 记录之前生成,但在后者之后处理,因此被认为是迟到的(并因此标记为红色实线)。然而,在处理时间范围为 12:15–12:20 的微批次中,水印使用的是 12:04,该时间基于前一微批次中看到的最大事件时间(即 12:14 减去 10 分钟水印延迟)。因此,迟到的记录 [12:13, id3] 不被认为太迟并成功计数。相反,在下一个微批次中,记录 [12:04, id1] 因与新水印 12:11 比较被认为太迟,并被丢弃。

您可以根据应用程序的要求设置水印延迟——此参数的较大值允许数据稍晚到达,但代价是增加状态大小(即内存使用),反之亦然。

水印的语义保证

在我们结束关于水印的本节之前,让我们考虑水印提供的精确语义保证。10 分钟的水印保证引擎 永远不会丢弃 与输入数据中的最新事件时间相比延迟不到 10 分钟的任何数据。然而,该保证仅在一个方向上严格。延迟超过 10 分钟的数据不保证会被丢弃——也就是说,它可能会被聚合。输入记录是否会聚合取决于记录接收和触发处理它的微批次的确切时间。

支持的输出模式

与不涉及时间的流聚合不同,使用时间窗口的聚合可以使用所有三种输出模式。然而,根据模式,您需要注意与状态清理相关的其他影响:

更新模式

在此模式下,每个微批次仅输出聚合得到更新的行。此模式可用于所有类型的聚合。特别是对于时间窗口聚合,水印将确保状态定期清理。这是运行带有流聚合查询的最有用和高效的模式。然而,您不能使用此模式将聚合写入仅附加流目标,如任何基于文件的格式,如 Parquet 和 ORC(除非您使用 Delta Lake,我们将在下一章中讨论)。

完全模式

在此模式下,每个微批次都会输出所有更新的聚合结果,无论它们的年龄或是否包含更改。虽然这种模式可用于所有类型的聚合,但对于时间窗口聚合来说,使用完整模式意味着即使指定了水印,状态也不会被清除。输出所有聚合需要所有过去的状态,因此即使已定义水印,聚合数据也必须保留。在使用时间窗口聚合时要谨慎使用此模式,因为这可能导致状态大小和内存使用无限增加。

追加模式

此模式仅适用于事件时间窗口上的聚合,并且启用了水印。请注意,追加模式不允许先前输出的结果更改。对于没有水印的聚合,每个聚合都可能随着未来数据的变化而更新,因此不能在追加模式下输出。只有在事件时间窗口上启用了水印时,查询才知道聚合不会再次更新。因此,追加模式仅在水印确保聚合不会再次更新时输出每个键及其最终聚合值。此模式的优势在于允许你将聚合结果写入追加模式的流数据接收器(例如文件)。缺点是输出会延迟水印持续时间——查询必须等待尾随水印超过键的时间窗口,然后才能完成聚合输出。

流连接

结构化流支持将流数据集与另一个静态或流数据集进行连接。在本节中,我们将探讨支持的不同类型的连接(内连接、外连接等),以及如何使用水印来限制用于有状态连接的存储。我们将从连接数据流和静态数据集的简单情况开始。

流-静态连接

许多使用情况需要将数据流与静态数据集连接。例如,让我们考虑广告变现的情况。假设你是一家在网站上展示广告并且当用户点击时赚钱的广告公司。假设你有一个静态数据集,其中包含所有要展示的广告(称为展示次数),以及另一个流事件流,记录用户每次点击展示的广告。为了计算点击收入,你需要将事件流中的每次点击与表中对应的广告展示次数进行匹配。首先,我们将数据表示为两个 DataFrame,一个是静态的,一个是流式的,如下所示:

# In Python
# Static DataFrame [adId: String, impressionTime: Timestamp, ...]
# reading from your static data source 
impressionsStatic = spark.read. ... 

# Streaming DataFrame [adId: String, clickTime: Timestamp, ...] 
# reading from your streaming source
clicksStream = spark.readStream. ...
// In Scala
// Static DataFrame [adId: String, impressionTime: Timestamp, ...]
// reading from your static data source 
val impressionsStatic = spark.read. ...

// Streaming DataFrame [adId: String, clickTime: Timestamp, ...]
// reading from your streaming source 
val clicksStream = spark.readStream. ...

要将点击与广告展示次数进行匹配,可以简单地使用它们之间共同的adId列进行内连接等值连接:

# In Python
matched = clicksStream.join(impressionsStatic, "adId")
// In Scala
val matched = clicksStream.join(impressionsStatic, "adId")

这与如果印象和点击都是静态数据框架所写的代码相同 - 唯一的区别是您用于批处理的spark.read()和用于流的spark.readStream()。 执行此代码时,每个点击的微批次都会与静态印象表进行内连接,以生成匹配事件的输出流。

除了内连接外,结构化流还支持两种流 - 静态外连接:

  • 在左侧是流数据框架时的左外连接

  • 在右侧是流数据框架时的右外连接

不支持其他类型的外连接(例如,在右侧是流数据框架的完全外连接和左外连接),因为它们不容易进行增量运行。 在这两种支持的情况下,代码与在两个静态数据框架之间进行左/右外连接时完全相同:

# In Python
matched = clicksStream.join(impressionsStatic, "adId", "leftOuter")
// In Scala
val matched = clicksStream.join(impressionsStatic, Seq("adId"), "leftOuter")

关于流-静态连接,有几个关键点需要注意:

  • 流-静态连接是无状态操作,因此不需要任何水印。

  • 静态数据框架在与每个微批次的流数据进行连接时被重复读取,因此可以缓存静态数据框架以加快读取速度。

  • 如果在定义静态数据框架的数据源中的底层数据发生更改,则流查询是否看到这些更改取决于数据源的具体行为。 例如,如果静态数据框架是在文件上定义的,则对这些文件的更改(例如,附加)将在重新启动流查询之前不会被检测到。

在这个流-静态示例中,我们做了一个重要的假设:印象表是一个静态表。 实际上,随着新广告的显示,将生成新的印象流。 虽然流-静态连接适用于在一个流中使用额外的静态(或缓慢变化)信息来丰富数据,但当数据源都在快速变化时,这种方法是不足够的。 为此,您需要流-流连接,我们将在下面讨论。

流-流连接

在两个数据流之间生成连接的挑战在于,在任何时间点,任一数据集的视图都是不完整的,这使得在输入之间查找匹配事件变得更加困难。 两个流的匹配事件可能以任何顺序到达,并可能被任意延迟。 例如,在我们的广告使用案例中,可能会以任意顺序到达印象事件及其对应的点击事件,并可能存在任意延迟。 结构化流通过缓冲来自两侧的输入数据作为流状态,并在接收到新数据时持续检查匹配来解决这些延迟。 图 8-11 中概述了概念想法。

使用流-流连接进行广告货币化

图 8-11。 使用流-流连接进行广告货币化

让我们更详细地考虑这一点,首先是内连接,然后是外连接。

带有可选水印的内连接

假设我们已重新定义我们的impressions DataFrame 为流 DataFrame。为了获取匹配的展示和它们对应的点击的流,我们可以使用我们之前用于静态连接和流-静态连接的相同代码:

# In Python
# Streaming DataFrame [adId: String, impressionTime: Timestamp, ...]
impressions = spark.readStream. ... 

# Streaming DataFrame[adId: String, clickTime: Timestamp, ...]
clicks = spark.readStream. ...
matched = impressions.join(clicks, "adId")
// In Scala
// Streaming DataFrame [adId: String, impressionTime: Timestamp, ...] 
val impressions = spark.readStream. ...

// Streaming DataFrame[adId: String, clickTime: Timestamp, ...] 
val clicks = spark.readStream. ...
val matched = impressions.join(clicks, "adId")

尽管代码相同,执行方式完全不同。当执行此查询时,处理引擎将识别它为流-流连接而不是流-静态连接。引擎将所有点击和展示作为状态进行缓冲,并在接收到的点击与缓冲的展示(或相反,取决于哪个先收到)匹配时生成匹配的展示和点击。让我们通过示例事件时间轴图 图 8-12 来可视化这个内连接是如何工作的。

点击、展示和它们的连接输出的示意时间轴

图 8-12. 点击、展示和它们的连接输出的示意时间轴

在图 8-12 中,蓝色点表示在不同微批次(用虚线灰色线分隔)中接收到的展示和点击事件的事件时间。为了本示例,假设每个事件实际上在相同的挂钟时间收到。注意相关事件被连接的不同场景。adId = 的两个事件在同一微批次中接收,因此它们的连接输出由该微批次生成。然而,对于adId = ,展示事件在 12:04 收到,比其对应的点击事件 12:13 要早得多。结构化流处理将首先在 12:04 收到展示并将其缓冲在状态中。对于每个收到的点击,引擎将尝试将其与所有已缓冲的展示事件(反之亦然)进行连接。最终,在稍后大约 12:13 左右运行的微批次中,引擎收到adId = 的点击事件并生成了连接的输出。

然而,在这个查询中,我们没有给出引擎缓冲事件以找到匹配的时间长度的任何指示。因此,引擎可能会无限期地缓冲事件,并积累无界限的流状态。为了限制流-流连接维护的流状态,您需要了解关于您的用例的以下信息:

  • 两个事件在它们各自源生成之间的最大时间范围是多少? 在我们的用例背景下,假设一个点击可以在相应展示之后的零秒到一小时内发生。

  • 事件在源和处理引擎之间最长可以延迟多久? 例如,来自浏览器的广告点击可能由于间歇性连接问题而延迟到达,并且顺序可能错乱。假设展示和点击最多可以分别延迟两个小时和三个小时到达。

这些延迟限制和事件时间约束可以通过数据框操作使用水印和时间范围条件进行编码。换句话说,您将需要在连接中执行以下附加步骤,以确保状态清理:

  1. 在两个输入上定义水印延迟,使引擎知道输入可以延迟多久(类似于流聚合)。

  2. 在两个输入之间定义事件时间约束,以便引擎可以确定不需要旧行数据(即不满足时间约束的数据)与另一个输入匹配。可以通过以下一种方式定义此约束:

    1. 时间范围连接条件(例如,连接条件 = "leftTime BETWEEN rightTime AND rightTime + INTERVAL 1 HOUR"

    2. 在事件时间窗口上进行连接(例如,连接条件 = "leftTimeWindow = rightTimeWindow"

在我们的广告使用案例中,我们的内连接代码会变得稍微复杂一些:

# In Python
# Define watermarks
impressionsWithWatermark = (impressions
  .selectExpr("adId AS impressionAdId", "impressionTime")
  .withWatermark("impressionTime", "2 hours"))

clicksWithWatermark = (clicks
  .selectExpr("adId AS clickAdId", "clickTime")
  .withWatermark("clickTime", "3 hours"))

# Inner join with time range conditions
(impressionsWithWatermark.join(clicksWithWatermark,
  expr(""" 
 clickAdId = impressionAdId AND 
 clickTime BETWEEN impressionTime AND impressionTime + interval 1 hour""")))
// In Scala
// Define watermarks
val impressionsWithWatermark = impressions
  .selectExpr("adId AS impressionAdId", "impressionTime")
  .withWatermark("impressionTime", "2 hours ")

val clicksWithWatermark = clicks
  .selectExpr("adId AS clickAdId", "clickTime")
  .withWatermark("clickTime", "3 hours")

// Inner join with time range conditions
impressionsWithWatermark.join(clicksWithWatermark,
  expr(""" 
 clickAdId = impressionAdId AND 
 clickTime BETWEEN impressionTime AND impressionTime + interval 1 hour"""))

有了每个事件的时间约束,处理引擎可以自动计算事件需要缓冲多长时间以生成正确的结果,并确定何时可以从状态中删除事件。例如,它将评估以下内容(如图 8-13 中所示)

  • 展示需要最多缓冲四个小时(按事件时间计算),因为三小时延迟的点击事件可能与四小时前的展示匹配(即三小时延迟 + 展示和点击事件之间最多一小时的延迟)。

  • 相反,点击事件需要最多缓冲两个小时(按事件时间计算),因为两小时延迟的展示可能与两小时前收到的点击事件匹配。

结构化流处理使用水印延迟和时间范围条件自动计算状态清理的阈值

图 8-13。结构化流处理会使用水印延迟和时间范围条件自动计算状态清理的阈值

关于内连接有几个关键点需要记住:

  • 对于内连接,指定水印和事件时间约束都是可选的。换句话说,在可能存在无界状态的风险下,您可以选择不指定它们。只有当两者都指定时,才会进行状态清理。

  • 类似于水印在聚合中提供的保证,两小时的水印延迟保证引擎不会丢弃或不匹配少于两小时延迟的任何数据,但延迟超过两小时的数据可能会被处理或不会被处理。

使用水印的外连接

先前的内连接只会输出那些已收到两个事件的广告。换句话说,未收到任何点击的广告将不被报告。相反,您可能希望报告所有广告展示,无论是否有相关点击数据,以便稍后进行额外分析(例如点击率)。这带我们来到 流-流外连接。要实现这一点,您只需指定外连接类型:

# In Python
# Left outer join with time range conditions
(impressionsWithWatermark.join(clicksWithWatermark,
  expr(""" 
 clickAdId = impressionAdId AND 
 clickTime BETWEEN impressionTime AND impressionTime + interval 1 hour"""),
  "leftOuter"))  # only change: set the outer join type
// In Scala
// Left outer join with time range conditions
impressionsWithWatermark.join(clicksWithWatermark,
  expr(""" 
 clickAdId = impressionAdId AND 
 clickTime BETWEEN impressionTime AND impressionTime + interval 1 hour"""),
  "leftOuter")  // Only change: set the outer join type

如外连接所预期的那样,此查询将开始为每个展示生成输出,无论是否有(即使用NULL)点击数据。然而,关于外连接还有一些额外的要点需要注意:

  • 与内连接不同,外连接不可选的水印延迟和事件时间约束。这是因为为了生成NULL结果,引擎必须知道事件何时不会与将来的任何其他事件匹配。为了获得正确的外连接结果和状态清理,必须指定水印和事件时间约束。

  • 因此,外部NULL结果将会有延迟生成,因为引擎必须等待一段时间以确保没有任何匹配。这个延迟是引擎为每个事件计算的最大缓冲时间(关于事件时间)如前一节所讨论的(即展示四小时,点击两小时)。

任意有状态的计算

许多用例需要比我们目前讨论的 SQL 操作更复杂的逻辑。例如,假设您想通过实时跟踪用户的活动(例如点击)来跟踪用户的状态(例如登录、忙碌、空闲)。为了构建这个流处理管道,您将必须跟踪每个用户的活动历史作为带有任意数据结构的状态,并基于用户的操作在数据结构上连续应用任意复杂的更改。mapGroupsWithState() 操作及其更灵活的对应变体 flatMapGroupsWithState() 专为这种复杂的分析用例设计。

注意

截至 Spark 3.0,在 Scala 和 Java 中才能使用这两种操作。

在本节中,我们将从一个简单的示例开始,使用mapGroupsWithState()来说明建模自定义状态数据和定义自定义操作的四个关键步骤。然后我们将讨论超时的概念及如何使用它们来清除长时间未更新的状态。最后我们将介绍flatMapGroupsWithState(),它能给予你更多的灵活性。

使用mapGroupsWithState()建模任意有状态的操作

具有任意模式和任意状态转换的状态被建模为一个用户定义函数,该函数将先前版本的状态值和新数据作为输入,并生成更新后的状态和计算结果作为输出。在 Scala 中编程时,您需要定义以下签名的函数(KVSU 是稍后将要解释的数据类型):

// In Scala
def arbitraryStateUpdateFunction(
    key: K, 
    newDataForKey: Iterator[V], 
    previousStateForKey: GroupState[S]
): U

这个函数通过 groupByKey()mapGroupsWithState() 操作提供给流式查询,如下所示:

// In Scala
val inputDataset: Dataset[V] =  // input streaming Dataset

inputDataset
  .groupByKey(keyFunction)   // keyFunction() generates key from input
  .mapGroupsWithState(arbitraryStateUpdateFunction)

当启动这个流式查询时,Spark 将在每个微批次中的数据中为每个唯一键调用 arbitraryStateUpdateFunction()。让我们更详细地看看参数是什么以及 Spark 将使用哪些参数值调用函数:

key: K

K 是状态和输入中定义的公共键的数据类型。Spark 将为数据中的每个唯一键调用此函数。

newDataForKey: Iterator[V]

V 是输入数据集的数据类型。当 Spark 调用此函数来处理一个键时,这个参数将包含对应于该键的所有新输入数据。注意,迭代器中输入数据对象的顺序是不确定的。

previousStateForKey: GroupState[S]

S 是您要维护的任意状态的数据类型,而 GroupState[S] 是一个类型化的包装对象,提供访问和管理状态值的方法。当 Spark 为一个键调用此函数时,这个对象将提供先前微批次中 Spark 上次为该键调用此函数时设置的状态值。

U

U 是函数输出的数据类型。

注意

还有一些额外的参数需要提供。所有类型(KVSU)必须能够由 Spark SQL 的编码器进行编码。因此,在 mapGroupsWithState() 中,您必须在 Scala 中隐式提供或在 Java 中显式提供 SU 的类型化编码器。有关更多详细信息,请参阅 “Dataset Encoders” 第六章 Chapter 6。

让我们通过一个例子来看看如何以这种格式表达所需的状态更新函数。假设我们想根据用户的操作理解他们的行为。从概念上讲,这很简单:在每个微批次中,对于每个活跃用户,我们将使用用户执行的新操作并更新用户的“状态”。在程序上,我们可以定义以下步骤的状态更新函数:

  1. 定义数据类型。我们需要定义 KVSU 的确切类型。在这种情况下,我们将使用以下内容:

    1. 输入数据 (V) = case class UserAction(userId: String, action: String)

    2. 键 (K) = String(即 userId

    3. 状态 (S) = case class UserStatus(userId: String, active: Boolean)

    4. 输出 (U) = UserStatus,因为我们希望输出最新的用户状态。

      注意,所有这些数据类型都受编码器支持。

  2. 定义函数。基于所选类型,让我们将概念理念翻译成代码。当此函数与新用户行动一起调用时,我们需要处理两种主要情况:是否存在该键(即 userId)的先前状态(即先前用户状态)。因此,我们将初始化用户状态,或者使用新行动更新现有状态。我们将明确使用新的运行计数更新状态,并最终返回更新的 userId-userStatus 对:

    // In Scala
    import org.apache.spark.sql.streaming._
    
     def updateUserStatus(
        userId: String, 
        newActions: Iterator[UserAction],
        state: GroupState[UserStatus]): UserStatus = {
    
      val userStatus = state.getOption.getOrElse {
        new UserStatus(userId, false)
      }
      newActions.foreach { action => 
        userStatus.updateWith(action) 
      }
      state.update(userStatus) 
      return userStatus
    }
    
  3. 对行动应用函数。我们将使用 groupByKey() 对输入的行动数据集进行分组,然后使用 mapGroupsWithState() 应用 updateUserStatus 函数:

    // In Scala
    val userActions: Dataset[UserAction] = ...
    val latestStatuses = userActions
      .groupByKey(userAction => userAction.userId) 
      .mapGroupsWithState(updateUserStatus _)
    

一旦我们启动带有控制台输出的流式查询,我们将看到打印出更新的用户状态。

在我们进入更高级话题之前,有几个值得注意的点要记住:

  • 当调用函数时,新数据迭代器(例如 newActions)中的输入记录没有明确定义的顺序。如果需要按特定顺序(例如按操作执行的顺序)更新状态,则必须显式重新排序它们(例如基于事件时间戳或某种其他排序 ID)。实际上,如果存在从源读取的操作可能无序的情况,则必须考虑未来微批处理可能接收到应在当前批处理数据之前处理的数据的可能性。在这种情况下,必须将记录作为状态的一部分进行缓冲。

  • 在微批处理中,如果微批处理为特定键提供了数据,则仅对该键调用函数一次。例如,如果用户变得不活跃并且长时间没有提供新的行动,那么默认情况下函数将长时间不被调用。如果你想根据用户在长时间内的不活动来更新或移除状态,那么你必须使用超时,我们将在下一节讨论这一点。

  • mapGroupsWithState() 的输出被增量处理引擎假定为持续更新的键/值记录,类似于聚合的输出。这限制了在 mapGroupsWithState() 之后查询支持的操作以及支持的输出目的地。例如,不支持将输出追加到文件中。如果你希望以更大的灵活性应用任意有状态的操作,那么你必须使用 flatMapGroupsWithState()。我们将在超时后讨论这一点。

使用超时来管理非活动组

在跟踪活跃用户会话的上述示例中,随着更多用户变得活跃,状态中的键的数量将不断增加,状态使用的内存也会增加。然而,在实际情况中,用户可能不会一直保持活跃状态。保持不活动用户状态可能并不是非常有用,因为在这些用户再次变得活跃之前,状态不会再次改变。因此,我们可能希望明确地删除所有不活跃用户的信息。但是,用户可能不会明确采取任何行动来变得不活跃(例如,明确注销),我们可能需要定义不活动为一段时间没有任何操作。这在函数中编码变得棘手,因为在没有来自用户的新动作之前,函数不会为用户调用。

为了编码基于时间的不活动,mapGroupsWithState()支持以下定义的超时:

  • 每次在键上调用函数时,可以根据持续时间或阈值时间戳设置超时。

  • 如果某个键没有接收到任何数据,使得超时条件得到满足,该键将被标记为“超时”。下一个微批次将即使对于该键没有新数据,也会调用该键上的函数。在这个特殊的函数调用中,新的输入数据迭代器将为空(因为没有新数据),并且GroupState.hasTimedOut()将返回true。这是函数内部最佳的识别调用原因是由于新数据还是超时的方式。

有两种类型的超时,基于我们的两种时间概念:处理时间和事件时间。处理时间超时是其中更简单的一种,因此我们将从这里开始。

处理时间超时

处理时间超时基于运行流查询的机器的系统时间(也称为挂钟时间),定义如下:如果一个键在系统时间戳T时最后接收到数据,而当前时间戳超过(T + *<超时时长>*),则函数将再次被调用,但是使用一个新的空数据迭代器。

让我们通过更新我们的用户示例来调查如何使用超时,以便根据一小时的不活动时间删除用户的状态。我们将进行三处更改:

  • mapGroupsWithState()中,我们将超时指定为GroupStateTimeout.ProcessingTimeTimeout

  • 在状态更新函数中,在使用新数据更新状态之前,我们必须检查状态是否已经超时。相应地,我们将更新或删除状态。

  • 另外,每次使用新数据更新状态时,我们将设置超时时长。

以下是更新后的代码:

// In Scala
def updateUserStatus(
    userId: String, 
    newActions: Iterator[UserAction],
    state: GroupState[UserStatus]): UserStatus = {

  if (!state.hasTimedOut) {       // Was not called due to timeout
    val userStatus = state.getOption.getOrElse {
      new UserStatus(userId, false)
    }
    newActions.foreach { action => userStatus.updateWith(action) }
    state.update(userStatus) 
    state.setTimeoutDuration("1 hour") // Set timeout duration
    return userStatus

  } else {
    val userStatus = state.get()
    state.remove()                  // Remove state when timed out
    return userStatus.asInactive()  // Return inactive user's status
  }
}

val latestStatuses = userActions
  .groupByKey(userAction => userAction.userId) 
  .mapGroupsWithState(
    GroupStateTimeout.ProcessingTimeTimeout)(
    updateUserStatus _)

这个查询会自动清理那些超过一个小时没有处理任何数据的用户的状态。但是,关于超时需要注意几点:

  • 当再次调用函数时,最后一次调用函数设置的超时将自动取消,无论是为了新接收的数据还是为了超时。因此,每次调用函数时,都需要显式设置超时持续时间或时间戳以启用超时。

  • 由于超时是在微批处理期间处理的,它们的执行时间是不精确的,并且严重依赖触发间隔和微批处理时间。因此,不建议使用超时来进行精确的时间控制。

  • 虽然处理时间超时易于理解,但对于减速和停机不够健壮。如果流查询经历超过一小时的停机时间,那么重新启动后,状态中的所有键都将超时,因为每个键自接收数据以来已经过去了一小时以上。如果查询处理数据的速度比从源头到达的速度慢(例如,如果数据到达并在 Kafka 中缓冲),则可能发生类似的大规模超时。例如,如果超时为五分钟,那么处理速率突然下降(或数据到达速率激增),导致五分钟延迟,可能会产生偶发超时。为了避免这种问题,我们可以使用事件时间超时,接下来将讨论。

事件时间超时

事件时间超时不是基于系统时钟时间,而是基于数据中的事件时间(类似于基于时间的聚合)和在事件时间上定义的水印。如果一个键配置了特定的超时时间戳T(即不是一个持续时间),那么如果自上次调用函数以来该键未接收到新数据,则当水印超过T时,该键将超时。请注意,水印是一个移动的阈值,在处理数据时会落后于所见的最大事件时间。因此,与系统时间不同,水印以数据处理的速度向前移动。这意味着(与处理时间超时不同)查询处理中的任何减速或停机都不会导致偶发超时。

让我们修改我们的示例以使用事件时间超时。除了我们已经为使用处理时间超时所做的更改外,我们还将进行以下更改:

  • 在输入数据集上定义水印(假设类UserAction有一个eventTimestamp字段)。请注意,水印阈值表示输入数据可以延迟和无序的可接受时间量。

  • 更新mapGroupsWithState()以使用EventTimeTimeout

  • 更新函数以设置将发生超时的阈值时间戳。请注意,事件时间超时不允许设置超时持续时间,如处理时间超时。稍后我们将讨论这一点的原因。在本例中,我们将计算此超时为当前水印加一小时。

这里是更新后的示例:

// In Scala
def updateUserStatus(
    userId: String, 
    newActions: Iterator[UserAction],
    state: GroupState[UserStatus]):UserStatus = {

  if (!state.hasTimedOut) {  // Was not called due to timeout
    val userStatus = if (state.getOption.getOrElse {
      new UserStatus()
    }
    newActions.foreach { action => userStatus.updateWith(action) }
    state.update(userStatus)

    // Set the timeout timestamp to the current watermark + 1 hour
    state.setTimeoutTimestamp(state.getCurrentWatermarkMs, "1 hour") 
    return userStatus
  } else {
    val userStatus = state.get()
    state.remove()
    return userStatus.asInactive() }
}

val latestStatuses = userActions
  .withWatermark("eventTimestamp", "10 minutes") 
  .groupByKey(userAction => userAction.userId) 
  .mapGroupsWithState(
    GroupStateTimeout.EventTimeTimeout)(
    updateUserStatus _)

这个查询将更加健壮,能够抵御由于重新启动和处理延迟引起的偶发超时。

关于事件时间超时,有几点需要注意:

  • 与处理时间超时的前一个示例不同,我们使用了GroupState.setTimeoutTimestamp()而不是GroupState.setTimeoutDuration()。这是因为对于处理时间超时,持续时间足以计算未来精确的时间戳(即,当前系统时间 + 指定持续时间),超时会发生,但对于事件时间超时则不是这样。不同的应用可能希望使用不同的策略来计算阈值时间戳。在本例中,我们简单地基于当前水印计算它,但是不同的应用可能选择基于该键的最大事件时间戳来计算超时时间戳(作为状态的一部分进行跟踪和保存)。

  • 超时时间戳必须设置为大于当前水印的值。这是因为超时预期会在时间戳穿过水印时发生,因此将时间戳设置为已经大于当前水印的值是不合逻辑的。

在我们讨论超时机制的更多创造性处理方式之前,有一件事情需要记住,那就是你可以使用这些超时机制来进行比固定持续时间超时更有创意的处理。例如,你可以通过在状态中保存上次任务执行的时间戳,并使用它来设置处理时间超时持续时间来实现大约周期性的任务(比如,每小时一次),如本代码片段所示:

// In Scala
timeoutDurationMs = lastTaskTimstampMs + periodIntervalMs - 
groupState.getCurrentProcessingTimeMs()

使用flatMapGroupsWithState()进行泛化

mapGroupsWithState()存在两个关键限制,可能限制我们想要实现更复杂用例(例如,链式会话化)的灵活性:

  • 每次调用mapGroupsWithState()时,你必须返回一个且仅返回一个记录。对于某些应用程序,在某些触发器中,你可能根本不想输出任何内容。

  • 使用mapGroupsWithState()时,由于对不透明状态更新函数的更多信息缺乏,引擎假定生成的记录是更新的键/值数据对。因此,它推理关于下游操作并允许或禁止其中的一些。例如,使用mapGroupsWithState()生成的 DataFrame 不能以追加模式写入文件。然而,某些应用程序可能希望生成可以视为追加的记录。

flatMapGroupsWithState()克服了这些限制,代价是稍微复杂的语法。它与mapGroupsWithState()有两个不同之处:

  • 返回类型是一个迭代器,而不是单个对象。这允许函数返回任意数量的记录,或者如果需要的话,根本不返回记录。

  • 它还带有一个称为操作员输出模式的参数(不要与本章前面讨论的查询输出模式混淆),该参数定义了输出记录是可以追加的新记录(OutputMode.Append)还是更新键/值记录(OutputMode.Update)。

为了说明此函数的使用,让我们扩展我们的用户跟踪示例(我们已经删除了超时以保持代码简单)。例如,如果我们只想为某些用户更改生成警报,并且我们希望将输出警报写入文件,我们可以执行以下操作:

// In Scala
def getUserAlerts(
    userId: String, 
    newActions: Iterator[UserAction],
    state: GroupState[UserStatus]): Iterator[UserAlert] = {

  val userStatus = state.getOption.getOrElse {
    new UserStatus(userId, false) 
  }
  newActions.foreach { action => 
    userStatus.updateWith(action)
  } 
  state.update(userStatus)

  // Generate any number of alerts
  return userStatus.generateAlerts().toIterator  
}

val userAlerts = userActions
  .groupByKey(userAction => userAction.userId) 
  .flatMapGroupsWithState(
    OutputMode.Append, 
    GroupStateTimeout.NoTimeout)(
    getUserAlerts)

性能调整

结构化流使用 Spark SQL 引擎,因此可以使用与讨论 Spark SQL 章节中相同的参数进行调优 5 和 7。然而,与可能处理几十亿到几百万亿字节数据的批处理作业不同,微批处理作业通常处理的数据量要小得多。因此,运行流处理查询的 Spark 集群通常需要略微不同的调优。以下是一些需要记住的考虑因素:

集群资源配置

由于运行流处理查询的 Spark 集群将会全天候运行,因此适当地配置资源非常重要。资源配置不足可能会导致流处理查询落后(微批处理需要的时间越来越长),而过度配置(例如分配但未使用的核心)可能会导致不必要的成本。此外,分配应根据流处理查询的性质进行:无状态查询通常需要更多核心,而有状态查询通常需要更多内存。

洗牌分区的数量

对于结构化流查询,洗牌分区的数量通常需要设置比大多数批处理查询低得多——过度划分计算会增加开销并降低吞吐量。此外,由于检查点,由有状态操作引起的洗牌具有显着更高的任务开销。因此,对于具有几秒到几分钟触发间隔的流处理查询,建议将洗牌分区的数量从默认值 200 调整为至多分配核心数的两到三倍。

为稳定性设置源速率限制

在为查询的预期输入数据速率优化了分配的资源和配置之后,突然的数据速率激增可能会生成意外的大型作业和随后的不稳定性。除了过度配置的昂贵方法之外,您可以使用源速率限制来防止不稳定性。在支持的源(例如 Kafka 和文件)中设置限制可以防止查询在单个微批处理中消耗过多的数据。激增的数据将保留在源中,并且查询最终会赶上。但是,请注意以下事项:

  • 将限制设置得太低可能导致查询未充分利用分配的资源并落后于输入速率。

  • 限制不能有效防范输入速率持续增加。尽管保持稳定性,但在源头处未处理的缓冲数据量将无限增长,端到端延迟也会增加。

在同一 Spark 应用程序中运行多个流查询

在相同的SparkContextSparkSession中运行多个流查询可以实现细粒度的资源共享。然而:

  • 每个查询持续执行会消耗 Spark 驱动程序(即运行它的 JVM)中的资源。这限制了驱动程序可以同时执行的查询数量。达到这些限制可能会成为任务调度的瓶颈(即未充分利用执行者),或超出内存限制。

  • 您可以通过设置流到不同调度程序池的方式,在同一上下文中确保更公平的资源分配。为每个流设置SparkContext的线程本地属性spark.scheduler.pool为不同的字符串值:

// In Scala
// Run streaming query1 in scheduler pool1
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool1")
df.writeStream.queryName("query1").format("parquet").start(path1)

// Run streaming query2 in scheduler pool2
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool2")
df.writeStream.queryName("query2").format("parquet").start(path2)
# In Python
# Run streaming query1 in scheduler pool1
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool1")
df.writeStream.queryName("query1").format("parquet").start(path1)

# Run streaming query2 in scheduler pool2
spark.sparkContext.setLocalProperty("spark.scheduler.pool", "pool2")
df.writeStream.queryName("query2").format("parquet").start(path2)

总结

本章探讨了使用 DataFrame API 编写结构化流查询的方法。具体而言,我们讨论了:

  • 结构化流的核心理念以及将输入数据流视为无界表的处理模型

  • 定义、启动、重启和监视流查询的关键步骤

  • 如何使用各种内置流源和汇以及编写自定义流汇

  • 如何使用和调整管理的有状态操作,例如流聚合和流-流连接

  • 表达自定义有状态计算的技术

通过分析本章的代码片段和书籍的 GitHub 存储库 中的笔记本,您将深入了解如何有效使用结构化流。在下一章中,我们将探讨如何管理从批处理和流处理工作负载中同时读取和写入的结构化数据。

¹ 更详细的解释,请参阅 Matei Zaharia 等人(2013)的原始研究论文 “Discretized Streams: Fault-Tolerant Streaming Computation at Scale”

² 这个执行循环适用于基于微批次触发模式(即ProcessingTimeOnce),但不适用于Continuous触发模式。

³ 有关不支持操作的完整列表,请参阅结构化流编程指南

第九章:使用 Apache Spark 构建可靠的数据湖

在前几章中,您学习了如何轻松有效地使用 Apache Spark 构建可扩展和高性能的数据处理流水线。然而,在实践中,仅仅表达处理逻辑只解决了构建流水线端到端问题的一半。对于数据工程师、数据科学家或数据分析师来说,构建流水线的最终目标是查询处理后的数据并从中获取见解。存储解决方案的选择决定了从原始数据到见解(即端到端)的数据流水线的稳健性和性能。

在本章中,我们将首先讨论您需要关注的存储解决方案的关键特性。然后,我们将讨论两类广泛的存储解决方案,即数据库和数据湖,以及如何与它们一起使用 Apache Spark。最后,我们将介绍存储解决方案的下一波发展,称为湖仓库,并探讨这个领域中一些新的开源处理引擎。

优化存储解决方案的重要性

以下是存储解决方案中所需的一些属性:

可扩展性和性能

存储解决方案应能够扩展到所需的数据量,并提供工作负载所需的读/写吞吐量和延迟。

事务支持

复杂的工作负载通常同时读写数据,因此支持ACID 事务对于确保最终结果的质量至关重要。

支持多种数据格式

存储解决方案应能够存储非结构化数据(例如原始日志文本文件)、半结构化数据(例如 JSON 数据)和结构化数据(例如表格数据)。

支持多样化的工作负载

存储解决方案应能够支持多样化的业务工作负载,包括:

  • 像传统 BI 分析这样的 SQL 工作负载

  • 像传统 ETL 作业处理原始非结构化数据的批处理工作负载

  • 像实时监控和报警这样的流式工作负载

  • 像推荐和流失预测这样的 ML 和 AI 工作负载

开放性

支持广泛的工作负载通常要求数据以开放数据格式存储。标准 API 允许从各种工具和引擎访问数据。这使得企业可以针对每种类型的工作负载使用最优的工具,并做出最佳的业务决策。

随着时间的推移,不同类型的存储解决方案被提出,每种解决方案在这些属性方面都有其独特的优缺点。在本章中,我们将探讨可用的存储解决方案是如何从数据库发展到数据湖,以及如何与每种解决方案一起使用 Apache Spark。然后我们将转向下一代存储解决方案,通常被称为数据湖仓库,它们可以提供数据湖的可扩展性和灵活性,同时具备数据库的事务性保证。

数据库

多年来,数据库一直是构建存储业务关键数据的最可靠解决方案。在本节中,我们将探讨数据库及其工作负载的架构,以及如何在数据库上使用 Apache Spark 进行分析工作负载。我们将结束本节讨论数据库在支持现代非 SQL 工作负载方面的限制。

数据库简介

数据库旨在以表格形式存储结构化数据,可以使用 SQL 查询进行读取。数据必须遵循严格的模式,这允许数据库管理系统在数据存储和处理方面进行高度协同优化。也就是说,它们紧密地将数据和索引的内部布局与高度优化的查询处理引擎耦合在磁盘文件中,因此能够在存储的数据上提供非常快速的计算,并在所有读写操作上提供强大的事务 ACID 保证。

数据库上的 SQL 工作负载可以广泛分类为两类,如下所示:

在线事务处理 (OLTP) 工作负载

像银行账户交易一样,OLTP 工作负载通常是高并发、低延迟、简单查询,每次读取或更新少量记录。

在线分析处理 (OLAP)

OLAP 工作负载,例如周期性报告,通常是涉及聚合和连接的复杂查询,需要高吞吐量的扫描许多记录。

值得注意的是,Apache Spark 是专为 OLAP 工作负载而设计的查询引擎,而不是 OLTP 工作负载。因此,在本章的其余部分,我们将专注于分析工作负载的存储解决方案讨论。接下来,让我们看看如何使用 Apache Spark 读写数据库。

使用 Apache Spark 读写数据库

由于日益增长的连接器生态系统,Apache Spark 能够连接多种数据库进行数据读写。对于具有 JDBC 驱动程序的数据库(例如 PostgreSQL、MySQL),您可以使用内置的 JDBC 数据源以及适当的 JDBC 驱动程序 jar 包访问数据。对于许多其他现代数据库(例如 Azure Cosmos DB、Snowflake),还有专用连接器,可以使用适当的格式名称调用。本书的第五章详细讨论了几个示例,这使得基于 Apache Spark 的数据仓库和数据库的工作负载和用例扩展变得非常简单。

数据库的限制

自上个世纪以来,数据库和 SQL 查询被认为是构建 BI 工作负载的重要解决方案。然而,过去十年中出现了两大新的分析工作负载趋势:

数据规模的增长

随着大数据的出现,全球工业界出现了一种趋势,即为了理解趋势和用户行为,衡量和收集一切(页面访问量、点击等)。因此,任何公司或组织收集的数据量从几十年前的几吉字节增加到今天的几百或几千吉字节。

分析多样性的增长

随着数据收集量的增加,深入洞察的需求也在增加。这导致了像机器学习和深度学习这样的复杂分析技术的爆炸性增长。

数据库已被证明在适应这些新趋势方面相当不足,原因如下:

数据库在横向扩展方面成本极高

虽然数据库在单机上处理数据非常高效,但数据量的增长速度远远超过了单机性能的增长。处理引擎前进的唯一途径是横向扩展,即使用多台机器并行处理数据。然而,大多数数据库,尤其是开源数据库,并未设计成能够横向扩展执行分布式处理。少数能够满足处理需求的工业级数据库解决方案,往往是运行在专用硬件上的专有解决方案,因此非常昂贵,无论是获取还是维护。

数据库对非 SQL 基础分析的支持不够好

数据库以复杂(通常是专有的)格式存储数据,这些格式通常被高度优化,只能由该数据库的 SQL 处理引擎有效读取。这意味着其他处理工具,如机器学习和深度学习系统,无法有效地访问数据(除非通过从数据库中低效地读取所有数据)。数据库也不能轻易扩展以执行像机器学习这样的非 SQL 基础分析。

正是由于这些数据库的限制,才引发了一种完全不同的存储数据方法的发展,即数据湖

数据湖

与大多数数据库相反,数据湖是一种分布式存储解决方案,运行在通用硬件上,可以轻松实现横向扩展。在本节中,我们将从讨论数据湖如何满足现代工作负载的需求开始,然后看看 Apache Spark 如何与数据湖集成,使工作负载能够处理任意规模的数据。最后,我们将探讨数据湖为实现可扩展性而做出的架构上的牺牲所带来的影响。

数据湖简介

数据湖架构与数据库的架构不同,它将分布式存储系统与分布式计算系统解耦。这使得每个系统可以根据工作负载的需求进行横向扩展。此外,数据以开放格式的文件保存,因此任何处理引擎都可以使用标准 API 读取和写入它们。这个想法在 2000 年代后期由 Apache Hadoop 项目中的 Hadoop 文件系统(HDFS)推广开来,该项目本身深受 Sanjay Ghemawat、Howard Gobioff 和 Shun-Tak Leung 的研究论文《Google 文件系统》的启发。

组织通过独立选择以下内容来构建他们的数据湖:

存储系统

他们选择在机器群集上运行 HDFS 或使用任何云对象存储(例如 AWS S3、Azure Data Lake Storage 或 Google Cloud Storage)。

文件格式

根据下游工作负载的不同,数据以文件形式存储,可以是结构化(例如 Parquet、ORC)、半结构化(例如 JSON)或有时甚至是非结构化格式(例如文本、图像、音频、视频)。

处理引擎

再次,根据要执行的分析工作负载的类型,选择处理引擎。这可以是批处理引擎(例如 Spark、Presto、Apache Hive)、流处理引擎(例如 Spark、Apache Flink)或机器学习库(例如 Spark MLlib、scikit-learn、R)。

这种灵活性——能够选择最适合当前工作负载的存储系统、开放数据格式和处理引擎——是数据湖比数据库更大的优势。总体而言,对于相同的性能特征,数据湖通常提供比数据库更便宜的解决方案。这一关键优势导致了大数据生态系统的爆炸式增长。在接下来的部分中,我们将讨论如何使用 Apache Spark 在任何存储系统上读写常见文件格式。

使用 Apache Spark 读写数据湖

当构建自己的数据湖时,Apache Spark 是其中一种最佳处理引擎,因为它提供了他们需要的所有关键功能:

支持多样化的工作负载

Spark 提供了处理多种工作负载所需的所有必要工具,包括批处理、ETL 操作、使用 Spark SQL 进行 SQL 工作负载、使用结构化流进行流处理(在第八章中讨论)、以及使用 MLlib 进行机器学习(在第十章中讨论),等等。

支持多样化的文件格式

在第四章中,我们详细探讨了 Spark 对非结构化、半结构化和结构化文件格式的内置支持。

支持多样化的文件系统

Spark 支持从支持 Hadoop FileSystem API 的任何存储系统访问数据。由于这个 API 已成为大数据生态系统的事实标准,大多数云和本地存储系统都为其提供了实现——这意味着 Spark 可以读取和写入大多数存储系统。

然而,对于许多文件系统(特别是基于云存储的文件系统,如 AWS S3),您必须配置 Spark 以便以安全的方式访问文件系统。此外,云存储系统通常没有标准文件系统期望的相同文件操作语义(例如,S3 的最终一致性),如果不按照 Spark 的配置进行配置,可能会导致不一致的结果。有关详细信息,请参阅云集成文档

数据湖的局限性

数据湖并非没有缺陷,其中最严重的是缺乏事务性保证。具体来说,数据湖无法提供 ACID 保证:

原子性和隔离

处理引擎以分布方式写入数据湖中的数据。如果操作失败,就没有机制回滚已经写入的文件,从而可能留下潜在的损坏数据(当并发工作负载修改数据时,提供跨文件的隔离是非常困难的,因此问题进一步恶化)。

一致性

失败写入的缺乏原子性进一步导致读者获取数据的不一致视图。事实上,即使成功写入数据,也很难确保数据质量。例如,数据湖的一个非常常见的问题是意外地以与现有数据不一致的格式和架构写出数据文件。

为了解决数据湖的这些局限性,开发人员采用各种技巧。以下是几个示例:

  • 数据湖中的大量数据文件通常根据列的值(例如,一个大型的 Parquet 格式的 Hive 表按日期分区)“分区”到子目录中。为了实现对现有数据的原子修改,通常整个子目录会被重写(即写入临时目录,然后交换引用),以便更新或删除少量记录。

  • 数据更新作业(例如,每日 ETL 作业)和数据查询作业(例如,每日报告作业)的调度通常错开,以避免对数据的并发访问及由此引起的任何不一致性。

试图消除这些实际问题的努力导致了新系统的开发,例如湖岸。

湖岸:存储解决方案演进的下一个步骤

湖岸是一种新范式,它结合了数据湖和数据仓库的最佳元素,用于 OLAP 工作负载。湖岸由一个新的系统设计驱动,提供了类似数据库直接在用于数据湖的低成本可扩展存储上的数据管理功能。更具体地说,它们提供以下功能:

事务支持

类似于数据库,湖仓在并发工作负载下提供 ACID 保证。

模式强制执行和治理

湖仓防止将带有错误模式的数据插入表格,必要时,可以显式演变表格模式以适应不断变化的数据。系统应能够理解数据完整性,并具备强大的治理和审计机制。

支持开放格式中多样化的数据类型

与数据库不同,但类似于数据湖,湖仓可以存储、精炼、分析和访问所有类型的数据,以支持许多新数据应用程序所需的结构化、半结构化或非结构化数据。为了让各种工具能够直接和高效地访问它,数据必须以开放格式存储,并具备标准化的 API 来读取和写入。

支持多样化工作负载

借助各种工具的驱动,使用开放 API 读取数据,湖仓使得多样化工作负载能够在单一仓库中处理数据。打破孤立的数据孤岛(即针对不同数据类别的多个仓库),使开发人员更轻松地构建从传统 SQL 和流式分析到机器学习的多样化和复杂数据解决方案。

支持插入更新和删除操作

变更数据捕获(CDC)缓慢变化维度(SCD)这样的复杂用例需要对表格中的数据进行持续更新。湖仓允许数据在具有事务保证的情况下进行并发删除和更新。

数据治理

湖仓提供工具,帮助您理解数据完整性,并审计所有数据变更以符合政策要求。

目前有几个开源系统,如 Apache Hudi、Apache Iceberg 和 Delta Lake,可以用来构建具备这些特性的湖仓。在非常高的层次上,这三个项目都受到了著名数据库原理的启发,具有类似的架构。它们都是开放的数据存储格式,具有以下特点:

  • 在可扩展的文件系统中以结构化文件格式存储大量数据。

  • 维护事务日志记录数据的原子变更时间线(类似数据库)。

  • 使用日志定义表数据的版本,并在读写者之间提供快照隔离保证。

  • 支持使用 Apache Spark 读写表格数据。

在这些大致描述的框架内,每个项目在 API、性能以及与 Apache Spark 数据源 API 集成程度上都具有独特特征。我们将在接下来探讨它们。请注意,所有这些项目都在快速发展,因此在阅读时可能有些描述已经过时。请参考每个项目的在线文档获取最新信息。

Apache Hudi

最初由 Uber Engineering 创建,Apache Hudi——Hadoop 更新删除和增量的首字母缩写——是一种专为 key/value 数据的增量 upserts 和删除而设计的数据存储格式。数据存储为列式格式的组合(例如 Parquet 文件)和基于行的格式(例如用于在 Parquet 文件上记录增量变更的 Avro 文件)。除了前面提到的常见功能外,它还支持:

  • 快速、可插拔索引的 upsert

  • 原子发布数据,支持回滚

  • 读取表的增量更改

  • 数据恢复的保存点

  • 文件大小和布局管理使用统计信息

  • 异步压缩行和列式数据

Apache Iceberg

最初在 Netflix 创建,Apache Iceberg 是另一种用于大数据集的开放存储格式。但与专注于 upserting 键/值数据的 Hudi 不同,Iceberg 更专注于通用数据存储,可在单个表中扩展到 PB 级并具有模式演化特性。具体来说,它提供以下附加功能(除了常见功能):

  • 通过添加、删除、更新、重命名和重新排序列、字段和/或嵌套结构来进行模式演化

  • 隐藏分区,它在表中为行创建分区值

  • 分区演化,根据数据量或查询模式的变化自动执行元数据操作以更新表布局

  • 时间旅行,允许您通过 ID 或时间戳查询特定表快照

  • 回滚到以前的版本以更正错误

  • 可串行化隔离,即使在多个并发写入者之间也是如此

Delta Lake

Delta Lake 是由 Linux Foundation 托管的开源项目,由 Apache Spark 的原始创建者构建。与其他类似的项目一样,它是一种提供事务保证并支持模式强制和演化的开放数据存储格式。它还提供几个其他有趣的特性,其中一些是独特的。Delta Lake 支持:

  • 使用结构化流源和接收器进行表的流式读取和写入

  • 即使在 Java、Scala 和 Python API 中也支持更新、删除和合并(用于 upsert 操作)

  • 通过显式更改表模式或在 DataFrame 写入期间将 DataFrame 的模式隐式合并到表的模式中进行模式演化。(事实上,Delta Lake 中的合并操作支持条件更新/插入/删除的高级语法,同时更新所有列等,正如您稍后在本章中将看到的。)

  • 时间旅行,允许您通过 ID 或时间戳查询特定表快照

  • 回滚到以前的版本以更正错误

  • 多个并发写入者之间的可序列化隔离,执行任何 SQL、批处理或流操作

在本章的其余部分,我们将探讨如何使用 Apache Spark 系统来构建提供上述属性的 lakehouse。在这三个系统中,到目前为止 Delta Lake 与 Apache Spark 数据源(用于批处理和流处理工作负载)以及 SQL 操作(例如MERGE)的整合最紧密。因此,我们将使用 Delta Lake 进一步探索。

注意

这个项目被称为 Delta Lake,因为它类似于流。溪流流入海洋形成三角洲,这里是所有沉积物积累的地方,因此也是有价值的农作物生长的地方。朱尔斯·S·达姆吉(我们的合著者之一)提出了这个比喻!

使用 Apache Spark 和 Delta Lake 构建 Lakehouse

在本节中,我们将快速了解 Delta Lake 和 Apache Spark 如何用于构建 lakehouse。具体来说,我们将探索以下内容:

  • 使用 Apache Spark 读写 Delta Lake 表格

  • Delta Lake 如何允许并发批处理和流式写入,并提供 ACID 保证

  • Delta Lake 如何通过在所有写操作上强制执行模式并允许显式模式演变来确保更好的数据质量

  • 使用更新、删除和合并操作构建复杂的数据管道,所有操作均保证 ACID 保证

  • 审计修改 Delta Lake 表格的操作历史,并通过查询早期版本的表格实现时间旅行

本节中使用的数据是公共Lending Club Loan Data的修改版本(Parquet 格式中的列子集)。¹ 它包括了 2012 年至 2017 年间所有资助的贷款记录。每条贷款记录包括申请人提供的申请信息以及当前贷款状态(当前、逾期、已完全还清等)和最新的付款信息。

使用 Apache Spark 配置 Delta Lake

您可以通过以下任一方式配置 Apache Spark 以链接到 Delta Lake 库:

设置一个交互式 shell

如果您正在使用 Apache Spark 3.0,可以通过以下命令行参数启动 PySpark 或 Scala shell,并与 Delta Lake 一起使用:

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

例如:

pyspark --packages io.delta:delta-core_2.12:0.7.0

如果您正在运行 Spark 2.4,则必须使用 Delta Lake 0.6.0。

使用 Maven 坐标设置一个独立的 Scala/Java 项目

如果要使用 Maven 中央仓库中的 Delta Lake 二进制文件构建项目,可以将以下 Maven 坐标添加到项目依赖项中:

  <dependency>
  <groupId>io.delta</groupId>
  <artifactId>delta-core_2.12</artifactId>
  <version>0.7.0</version>
</dependency>

同样,如果您正在运行 Spark 2.4,则必须使用 Delta Lake 0.6.0。

注意

参见Delta Lake 文档获取最新信息。

加载数据到 Delta Lake 表格中

如果您习惯于使用 Apache Spark 和任何结构化数据格式(比如 Parquet)构建数据湖,那么很容易将现有工作负载迁移到使用 Delta Lake 格式。您只需将所有 DataFrame 读写操作更改为使用format("delta")而不是format("parquet")。让我们试试将一些前述的贷款数据,作为Parquet 文件,首先读取这些数据并保存为 Delta Lake 表格:

// In Scala
// Configure source data path
val sourcePath = "/databricks-datasets/learning-spark-v2/loans/
 loan-risks.snappy.parquet"

// Configure Delta Lake path
val deltaPath = "/tmp/loans_delta"

// Create the Delta table with the same loans data
spark
  .read
  .format("parquet")
  .load(sourcePath)
  .write
  .format("delta")
  .save(deltaPath)

// Create a view on the data called loans_delta
spark
 .read
 .format("delta")
 .load(deltaPath)
 .createOrReplaceTempView("loans_delta")
# In Python
# Configure source data path
sourcePath = "/databricks-datasets/learning-spark-v2/loans/
  loan-risks.snappy.parquet"

# Configure Delta Lake path
deltaPath = "/tmp/loans_delta"

# Create the Delta Lake table with the same loans data
(spark.read.format("parquet").load(sourcePath) 
  .write.format("delta").save(deltaPath))

# Create a view on the data called loans_delta
spark.read.format("delta").load(deltaPath).createOrReplaceTempView("loans_delta")

现在,我们可以像处理任何其他表格一样轻松读取和探索数据:

// In Scala/Python

// Loans row count
spark.sql("SELECT count(*) FROM loans_delta").show()

+--------+
|count(1)|
+--------+
|   14705|
+--------+

// First 5 rows of loans table
spark.sql("SELECT * FROM loans_delta LIMIT 5").show()

+-------+-----------+---------+----------+
|loan_id|funded_amnt|paid_amnt|addr_state|
+-------+-----------+---------+----------+
|      0|       1000|   182.22|        CA|
|      1|       1000|   361.19|        WA|
|      2|       1000|   176.26|        TX|
|      3|       1000|   1000.0|        OK|
|      4|       1000|   249.98|        PA|
+-------+-----------+---------+----------+

将数据流加载到 Delta Lake 表格中

与静态 DataFrame 一样,您可以通过将格式设置为"delta"轻松修改现有的结构化流作业以写入和读取 Delta Lake 表格。假设您有一个名为newLoanStreamDF的 DataFrame,其模式与表格相同。您可以如下追加到表格中:

// In Scala
import org.apache.spark.sql.streaming._

val newLoanStreamDF = ...   // Streaming DataFrame with new loans data
val checkpointDir = ...     // Directory for streaming checkpoints
val streamingQuery = newLoanStreamDF.writeStream
  .format("delta")
  .option("checkpointLocation", checkpointDir)
  .trigger(Trigger.ProcessingTime("10 seconds"))
  .start(deltaPath)
# In Python
newLoanStreamDF = ...   # Streaming DataFrame with new loans data
checkpointDir = ...     # Directory for streaming checkpoints
streamingQuery = (newLoanStreamDF.writeStream 
    .format("delta") 
    .option("checkpointLocation", checkpointDir) 
    .trigger(processingTime = "10 seconds") 
    .start(deltaPath))

使用此格式,就像任何其他格式一样,结构化流提供端到端的幂等保证。但是,Delta Lake 相比于传统格式(如 JSON、Parquet 或 ORC)具有一些额外的优势:

允许批处理和流作业向同一表格写入

使用其他格式,从结构化流作业写入表格的数据将覆盖表格中的任何现有数据。这是因为表格中维护的元数据用于确保流写入的幂等性,并不考虑其他非流写入。Delta Lake 的高级元数据管理允许同时写入批处理和流数据。

允许多个流作业向同一表格追加数据

与其他格式的元数据相同限制也会阻止多个结构化流查询向同一表格追加数据。Delta Lake 的元数据为每个流查询维护事务信息,从而使任意数量的流查询能够并发写入具有幂等保证的表格。

即使在并发写入的情况下也提供 ACID 保证

与内置格式不同,Delta Lake 允许并发批处理和流操作写入具有 ACID 保证的数据。

写入时强制执行模式以防止数据损坏

使用 Spark 管理数据时的常见问题是使用 JSON、Parquet 和 ORC 等常见格式时由于错误格式的数据写入而导致的意外数据损坏。由于这些格式定义了单个文件的数据布局而不是整个表格的布局,因此没有机制防止任何 Spark 作业将具有不同模式的文件写入到现有表中。这意味着对于由多个 Parquet 文件组成的整个表格,不存在一致性保证。

Delta Lake 格式将模式记录为表级元数据。因此,对 Delta Lake 表的所有写操作都可以验证正在写入的数据是否与表的模式兼容。如果不兼容,Spark 将在写入和提交数据之前抛出错误,从而防止此类意外数据损坏。让我们通过尝试写入带有额外列closed的一些数据来测试这一点,该列表示贷款是否已终止。请注意,该列在表中不存在:

// In Scala
val loanUpdates = Seq(
    (1111111L, 1000, 1000.0, "TX", false), 
    (2222222L, 2000, 0.0, "CA", true))
  .toDF("loan_id", "funded_amnt", "paid_amnt", "addr_state", "closed")

loanUpdates.write.format("delta").mode("append").save(deltaPath)
# In Python
from pyspark.sql.functions import *

cols = ['loan_id', 'funded_amnt', 'paid_amnt', 'addr_state', 'closed']
items = [
(1111111, 1000, 1000.0, 'TX', True), 
(2222222, 2000, 0.0, 'CA', False)
]

loanUpdates = (spark.createDataFrame(items, cols)
  .withColumn("funded_amnt", col("funded_amnt").cast("int")))
loanUpdates.write.format("delta").mode("append").save(deltaPath)

此写入将失败,并显示以下错误消息:

org.apache.spark.sql.AnalysisException: A schema mismatch detected when writing 
  to the Delta table (Table ID: 48bfa949-5a09-49ce-96cb-34090ab7d695).
To enable schema migration, please set:
'.option("mergeSchema", "true")'.

Table schema:
root
-- loan_id: long (nullable = true)
-- funded_amnt: integer (nullable = true)
-- paid_amnt: double (nullable = true)
-- addr_state: string (nullable = true)

Data schema:
root
-- loan_id: long (nullable = true)
-- funded_amnt: integer (nullable = true)
-- paid_amnt: double (nullable = true)
-- addr_state: string (nullable = true)
-- closed: boolean (nullable = true)

这说明了 Delta Lake 如何阻止不匹配表模式的写入。然而,它也提供了如何使用选项 mergeSchema 实际演进表模式的提示,接下来将进行讨论。

适应变化数据的演进模式

在我们这个变化不断的世界中,我们可能希望将这个新列添加到表中。可以通过设置选项 "mergeSchema""true" 显式地添加这个新列:

// In Scala
loanUpdates.write.format("delta").mode("append")
  .option("mergeSchema", "true")
  .save(deltaPath)
# In Python
(loanUpdates.write.format("delta").mode("append")
  .option("mergeSchema", "true")
  .save(deltaPath))

通过这个操作,列 closed 将被添加到表模式中,并且新数据将被追加。当读取现有行时,新列的值将被视为 NULL。在 Spark 3.0 中,您还可以使用 SQL DDL 命令 ALTER TABLE 来添加和修改列。

转换现有数据

Delta Lake 支持 UPDATEDELETEMERGE 等 DML 命令,允许您构建复杂的数据管道。这些命令可以使用 Java、Scala、Python 和 SQL 调用,使用户能够使用他们熟悉的任何 API,无论是使用 DataFrames 还是表。此外,每个数据修改操作都确保 ACID 保证。

让我们通过几个实际用例来探索这一点。

更新数据以修复错误

在管理数据时,一个常见的用例是修复数据中的错误。假设在查看数据时,我们意识到所有分配给 addr_state = 'OR' 的贷款都应该分配给 addr_state = 'WA'。如果贷款表是 Parquet 表,那么要执行这样的更新,我们需要:

  1. 将所有未受影响的行复制到一个新表中。

  2. 将所有受影响的行复制到一个 DataFrame 中,然后执行数据修改。

  3. 将之前提到的 DataFrame 的行插入到新表中。

  4. 删除旧表并将新表重命名为旧表名。

在 Spark 3.0 中,直接支持像 UPDATEDELETEMERGE 这样的 DML SQL 操作,而不是手动执行所有这些步骤,您可以简单地运行 SQL UPDATE 命令。然而,对于 Delta Lake 表,用户也可以通过使用 Delta Lake 的编程 API 来运行此操作,如下所示:

// In Scala
import io.delta.tables.DeltaTable
import org.apache.spark.sql.functions._

val deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.update(
  col("addr_state") === "OR",
  Map("addr_state" -> lit("WA")))
# In Python
from delta.tables import *

deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.update("addr_state = 'OR'",  {"addr_state": "'WA'"})

删除与用户相关的数据

随着类似欧盟通用数据保护条例(GDPR)这样的数据保护政策的实施,现在比以往任何时候都更重要能够从所有表中删除用户数据。假设您必须删除所有已完全偿还贷款的数据。使用 Delta Lake,您可以执行以下操作:

// In Scala
val deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.delete("funded_amnt >= paid_amnt")
# In Python
deltaTable = DeltaTable.forPath(spark, deltaPath)
deltaTable.delete("funded_amnt >= paid_amnt")

与更新类似,在 Delta Lake 和 Apache Spark 3.0 中,您可以直接在表上运行DELETE SQL 命令。

使用 merge()向表中插入变更数据

一个常见的用例是变更数据捕获,您必须将 OLTP 表中的行更改复制到另一个表中,以供 OLAP 工作负载使用。继续我们的贷款数据示例,假设我们有另一张新贷款信息表,其中一些是新贷款,另一些是对现有贷款的更新。此外,假设此changes表与loan_delta表具有相同的架构。您可以使用基于MERGE SQL 命令的DeltaTable.merge()操作将这些变更插入表中:

// In Scala
deltaTable
  .alias("t")
  .merge(loanUpdates.alias("s"), "t.loan_id = s.loan_id")
  .whenMatched.updateAll()
  .whenNotMatched.insertAll()
  .execute()
# In Python
(deltaTable
  .alias("t")
  .merge(loanUpdates.alias("s"), "t.loan_id = s.loan_id") 
  .whenMatchedUpdateAll() 
  .whenNotMatchedInsertAll() 
  .execute())

作为提醒,您可以将此作为 SQL MERGE命令在 Spark 3.0 中运行。此外,如果您有这类捕获变更的流,您可以使用 Structured Streaming 查询连续应用这些变更。查询可以从任何流源中的微批次(参见第八章)读取变更,并使用foreachBatch()将每个微批次中的变更应用于 Delta Lake 表中。

使用仅插入合并去重数据

Delta Lake 中的合并操作支持比 ANSI 标准指定的更多扩展语法,包括以下高级特性:

删除操作

例如,MERGE ... WHEN MATCHED THEN DELETE

子句条件

例如,MERGE ... WHEN MATCHED AND *<condition>* THEN ...

可选操作

所有MATCHEDNOT MATCHED子句都是可选的。

星号语法

例如,UPDATE *INSERT *以将源数据集中匹配列的所有列更新/插入目标表中。等效的 Delta Lake API 是updateAll()insertAll(),我们在前一节中看到了。

这使您能够用很少的代码表达更多复杂的用例。例如,假设您希望为loan_delta表回填历史数据。但是,一些历史数据可能已经插入了表中,您不希望更新这些记录,因为它们可能包含更为更新的信息。您可以通过loan_id进行插入时进行去重,运行以下仅包含INSERT操作的合并操作(因为UPDATE操作是可选的):

// In Scala
deltaTable
  .alias("t")
  .merge(historicalUpdates.alias("s"), "t.loan_id = s.loan_id")
  .whenNotMatched.insertAll()
  .execute()
# In Python
(deltaTable
  .alias("t")
  .merge(historicalUpdates.alias("s"), "t.loan_id = s.loan_id") 
  .whenNotMatchedInsertAll() 
  .execute())

还有更复杂的用例,例如包括删除和 SCD 表的 CDC,使用扩展合并语法变得简单。请参阅文档获取更多详细信息和示例。

通过操作历史审核数据更改

所有对 Delta Lake 表的更改都记录在表的事务日志中作为提交。当你向 Delta Lake 表或目录写入时,每个操作都会自动进行版本控制。你可以像以下代码片段中所示查询表的操作历史记录:

// In Scala/Python
deltaTable.history().show()

默认情况下,这将显示一个包含许多版本和大量列的巨大表格。我们可以打印最后三个操作的一些关键列:

// In Scala
deltaTable
  .history(3)
  .select("version", "timestamp", "operation", "operationParameters")
  .show(false)
# In Python
(deltaTable
  .history(3)
  .select("version", "timestamp", "operation", "operationParameters")
  .show(truncate=False))

这将生成以下输出:

+-------+-----------+---------+-------------------------------------------+
|version|timestamp  |operation|operationParameters                        |
+-------+-----------+---------+-------------------------------------------+
|5      |2020-04-07 |MERGE    |[predicate -> (t.`loan_id` = s.`loan_id`)] |
|4      |2020-04-07 |MERGE    |[predicate -> (t.`loan_id` = s.`loan_id`)] |
|3      |2020-04-07 |DELETE   |predicate -> ["(CAST(`funded_amnt` ...    |
+-------+-----------+---------+-------------------------------------------+

注意对审核更改有用的 operationoperationParameters

使用时间旅行查询表的先前快照

你可以使用 DataFrameReader 选项 "versionAsOf""timestampAsOf" 查询表的以前版本化的快照。以下是几个例子:

// In Scala
spark.read
  .format("delta")
  .option("timestampAsOf", "2020-01-01")  // timestamp after table creation
  .load(deltaPath)

spark.read.format("delta")
  .option("versionAsOf", "4")
  .load(deltaPath)
# In Python
(spark.read
  .format("delta")
  .option("timestampAsOf", "2020-01-01")  # timestamp after table creation
  .load(deltaPath))

(spark.read.format("delta")
  .option("versionAsOf", "4")
  .load(deltaPath))

这在各种情况下都很有用,例如:

  • 通过重新运行特定表版本上的作业复现机器学习实验和报告

  • 比较不同版本之间的数据变化以进行审核

  • 通过读取前一个快照作为 DataFrame 并用其覆盖表格来回滚不正确的更改

摘要

本章探讨了使用 Apache Spark 构建可靠数据湖的可能性。简而言之,数据库长期以来解决了数据问题,但未能满足现代用例和工作负载的多样化需求。数据湖的建立旨在减轻数据库的一些限制,而 Apache Spark 是构建它们的最佳工具之一。然而,数据湖仍然缺乏数据库提供的一些关键特性(例如 ACID 保证)。Lakehouse 是数据解决方案的下一代,旨在提供数据库和数据湖的最佳功能,并满足多样化用例和工作负载的所有要求。

我们简要探讨了几个开源系统(Apache Hudi 和 Apache Iceberg),可以用来构建 Lakehouse,然后更详细地了解了 Delta Lake,这是一个基于文件的开源存储格式,与 Apache Spark 一起是构建 Lakehouse 的优秀基础模块。正如你所看到的,它提供以下功能:

  • 像数据库一样的事务保证和架构管理

  • 可伸缩性和开放性,如数据湖

  • 支持具有 ACID 保证的并发批处理和流处理工作负载

  • 支持使用更新、删除和合并操作对现有数据进行转换,以确保 ACID 保证。

  • 支持版本控制、操作历史的审计和查询以前的版本

在下一章中,我们将探讨如何开始使用 Spark 的 MLlib 构建 ML 模型。

^([1) 可以在此 Excel 文件中查看完整的数据视图。

第十章:MLlib 机器学习

直到此刻,我们一直专注于使用 Apache Spark 进行数据工程工作负载。数据工程通常是为机器学习(ML)任务准备数据的前奏步骤,这也是本章的重点。我们生活在一个机器学习和人工智能应用成为生活中不可或缺部分的时代。无论我们是否意识到,每天我们都可能接触到用于在线购物推荐和广告、欺诈检测、分类、图像识别、模式匹配等目的的 ML 模型。这些 ML 模型对许多公司的重要业务决策起着推动作用。根据这项麦肯锡研究,消费者在亚马逊购买的 35%和在 Netflix 观看的 75%产品是基于机器学习的产品推荐。构建一个表现良好的模型可以成败企业。

在本章中,我们将带您开始使用MLlib,这是 Apache Spark 中的事实上的机器学习库来构建 ML 模型。我们将从机器学习的简要介绍开始,然后涵盖在规模上进行分布式 ML 和特征工程的最佳实践(如果您已经熟悉机器学习基础知识,您可以直接跳转到“设计机器学习管道”)。通过本书中呈现的简短代码片段和可在书的GitHub 库中找到的笔记本,您将学习如何构建基本的 ML 模型并使用 MLlib。

注意

本章涵盖了 Scala 和 Python 的 API;如果您有兴趣使用 R(sparklyr)与 Spark 进行机器学习,我们建议您查阅Mastering Spark with R由 Javier Luraschi、Kevin Kuo 和 Edgar Ruiz(O’Reilly)编著。

什么是机器学习?

当今机器学习受到了很多炒作,但它究竟是什么?广义地说,机器学习是一个从您的数据中提取模式的过程,使用统计学、线性代数和数值优化。机器学习可以应用于诸如预测能耗、确定您的视频中是否有猫,或者对具有相似特征的项目进行聚类等问题。

机器学习有几种类型,包括监督学习、半监督学习、无监督学习和强化学习。本章将主要关注监督学习,并简要涉及无监督学习。在我们深入讨论之前,让我们简要讨论一下监督学习和无监督学习的区别。

监督学习

监督学习 中,你的数据包含一组输入记录,每个记录都有相关的标签,目标是在给定新的未标记输入时预测输出标签。这些输出标签可以是离散的或连续的,这就引出了监督学习的两种类型:分类回归

在分类问题中,目标是将输入分为一组离散的类别或标签。在二元分类中,有两个离散的标签需要预测,例如“狗”或“非狗”,正如 图 10-1 所示。

二元分类示例:狗或非狗

图 10-1. 二元分类示例:狗或非狗

多类,也称为多项式,分类中,可能有三个或更多的离散标签,例如预测狗的品种(例如澳大利亚牧羊犬、金毛寻回犬或贵宾犬,如图 10-2](#multinomial_classification_example_austr) 所示。

多项分类示例:澳大利亚牧羊犬、金毛寻回犬或贵宾犬

图 10-2. 多项分类示例:澳大利亚牧羊犬、金毛寻回犬或贵宾犬

在回归问题中,要预测的值是一个连续的数字,而不是一个标签。这意味着你的模型可能会预测在训练过程中从未见过的值,就像在 图 10-3 中展示的那样。例如,你可以建立一个模型来预测根据温度每日的冰淇淋销量。即使它在训练时没有看到过这个值的输入/输出对,你的模型可能会预测出值 \(77.67\)

回归示例:根据温度预测冰淇淋销量

图 10-3. 回归示例:根据温度预测冰淇淋销量

表 10-1 列出了一些常用的监督机器学习算法,这些算法可以在 Spark MLlib 中使用,表明它们可以用于回归、分类或两者。

表 10-1. 热门分类和回归算法

算法 典型用法
线性回归 回归
逻辑回归 分类(我们知道它的名字里有回归!)
决策树 两者
梯度提升树 两者
随机森林 两者
朴素贝叶斯 分类
支持向量机 (SVMs) 分类

无监督学习

获得监督机器学习所需的带标签数据可能非常昂贵和/或不可行。这就是 无监督学习 发挥作用的地方。与预测标签不同,无监督 ML 帮助你更好地理解数据的结构。

例如,考虑左侧原始未聚类数据在图 10-4 中。这些数据点(x[1]、x[2])没有已知的真实标签,但通过应用无监督机器学习,我们可以找到自然形成的聚类,如右侧所示。

聚类示例

图 10-4. 聚类示例

无监督机器学习可用于异常检测或作为监督机器学习的预处理步骤,例如用于减少数据集的维度(即每个数据点的维数),这对于减少存储需求或简化下游任务非常有用。MLlib 中一些无监督机器学习算法包括 k-means、潜在狄利克雷分配(LDA)和高斯混合模型。

为什么选择 Spark 进行机器学习?

Spark 是一个统一的分析引擎,提供了数据摄取、特征工程、模型训练和部署的生态系统。没有 Spark,开发人员需要使用许多不同的工具来完成这些任务,并且可能仍然面临可扩展性问题。

Spark 有两个机器学习包:spark.mllibspark.mlspark.mllib 是基于 RDD API 的原始机器学习 API(自 Spark 2.0 起处于维护模式),而 spark.ml 是基于 DataFrames 的较新 API。本章的其余部分将重点介绍如何使用 spark.ml 包及如何设计 Spark 中的机器学习管道。然而,我们使用“MLlib”作为一个统称,用来指代 Apache Spark 中的两个机器学习库包。

使用 spark.ml,数据科学家可以在数据准备和模型构建中使用一个生态系统,而无需将数据降采样以适应单台机器。spark.ml 专注于 O(n) 的规模扩展,其中模型随着数据点数量线性扩展,因此可以适应大量数据。在接下来的章节中,我们将讨论在选择分布式框架(如 spark.ml)和单节点框架(如scikit-learn (sklearn))之间涉及的一些权衡。如果您之前使用过 scikit-learn,那么 spark.ml 中的许多 API 将感觉非常熟悉,但我们将讨论一些细微的差异。

设计机器学习管道

在本节中,我们将介绍如何创建和调整 ML 管道。管道的概念在许多 ML 框架中都很常见,作为一种组织一系列操作并应用于数据的方式。在 MLlib 中,Pipeline API 提供了一个基于 DataFrames 构建的高级 API,用于组织您的机器学习工作流程。Pipeline API 由一系列转换器和评估器组成,我们稍后将深入讨论。

在本章中,我们将使用来自 Inside Airbnb 的旧金山住房数据集。它包含有关旧金山 Airbnb 租赁的信息,如卧室数量、位置、评论得分等,我们的目标是建立一个模型来预测该市房源的每晚租金。这是一个回归问题,因为价格是一个连续变量。我们将引导你完成数据科学家处理这个问题的工作流程,包括特征工程、构建模型、超参数调优和评估模型性能。这个数据集相当凌乱,建模可能会比较困难(就像大多数真实世界的数据集一样!),所以如果你自己在尝试时,早期的模型不理想也不要感到沮丧。

本 本章的目的不是向你展示 MLlib 中的所有 API,而是装备你掌握使用 MLlib 构建端到端管道的技能和知识。在详细讲解之前,让我们先定义一些 MLlib 的术语:

转换器

接受 DataFrame 作为输入,并返回一个新 DataFrame,附加一个或多个列。转换器不会从数据中学习任何参数,而是简单地应用基于规则的转换,既为模型训练准备数据,又使用训练好的 MLlib 模型生成预测。它们有一个 .transform() 方法。

估计器

从你的 DataFrame 通过 .fit() 方法学习(或“拟合”)参数,并返回一个 Model,即一个转换器。

管道

将一系列转换器和估计器组织成一个单一的模型。虽然管道本身是估计器,但 pipeline.fit() 的输出返回一个 PipelineModel,即一个转换器。

虽然这些概念现在看起来可能相当抽象,但本章中的代码片段和示例将帮助你理解它们是如何结合在一起的。但在我们构建 ML 模型并使用转换器、估计器和管道之前,我们需要加载数据并进行一些数据准备。

数据获取和探索

我们对示例数据集进行了稍微的预处理,去除了异常值(例如,Airbnb 发布的每晚 $0),将所有整数转换为浮点数,并选择了超过一百个字段的有信息量的子集。此外,对于数据列中的任何缺失数值,我们使用中位数值进行了填充,并添加了一个指标列(列名后跟 _na,如 bedrooms_na)。这样,ML 模型或人工分析师就可以将该列中的任何值解释为填充值,而不是真实值。你可以在书的 GitHub 仓库 中查看数据准备笔记本。请注意,还有许多其他处理缺失值的方法,超出了本书的范围。

让我们快速浏览一下数据集和相应的模式(输出仅显示部分列):

# In Python
filePath = """/databricks-datasets/learning-spark-v2/sf-airbnb/
sf-airbnb-clean.parquet/"""
airbnbDF = spark.read.parquet(filePath)
airbnbDF.select("neighbourhood_cleansed", "room_type", "bedrooms", "bathrooms", 
                "number_of_reviews", "price").show(5)
// In Scala
val filePath = 
  "/databricks-datasets/learning-spark-v2/sf-airbnb/sf-airbnb-clean.parquet/"
val airbnbDF = spark.read.parquet(filePath)
airbnbDF.select("neighbourhood_cleansed", "room_type", "bedrooms", "bathrooms", 
                "number_of_reviews", "price").show(5)

+----------------------+---------------+--------+---------+----------+-----+
|neighbourhood_cleansed|      room_type|bedrooms|bathrooms|number_...|price|
+----------------------+---------------+--------+---------+----------+-----+
|      Western Addition|Entire home/apt|     1.0|      1.0|     180.0|170.0|
|        Bernal Heights|Entire home/apt|     2.0|      1.0|     111.0|235.0|
|        Haight Ashbury|   Private room|     1.0|      4.0|      17.0| 65.0|
|        Haight Ashbury|   Private room|     1.0|      4.0|       8.0| 65.0|
|      Western Addition|Entire home/apt|     2.0|      1.5|      27.0|785.0|
+----------------------+---------------+--------+---------+----------+-----+

我们的目标是预测租赁物业每晚的价格,给定我们的特征。

在数据科学家可以开始建模之前,他们需要探索和理解他们的数据。他们经常使用 Spark 对数据进行分组,然后使用诸如 matplotlib 等数据可视化库来可视化数据。我们将数据探索留给读者作为练习。

创建训练和测试数据集

在我们开始特征工程和建模之前,我们将把数据集分成两组:训练集测试集。根据您的数据集大小,您的训练/测试比例可能会有所不同,但许多数据科学家使用 80/20 作为标准的训练/测试分割。您可能会想:“为什么不使用整个数据集来训练模型呢?”问题在于,如果我们在整个数据集上建立模型,可能会导致模型记忆或“过拟合”我们提供的训练数据,并且我们将没有更多数据来评估它在以前未见数据上的泛化能力。模型在测试集上的表现是它在未见数据(即在野外或生产环境中)上表现良好的代理,假设数据遵循相似的分布。这种分割在图 10-5 中有所描绘。

Train/test split

图 10-5. 训练/测试分割

我们的训练集包含一组特征 X 和一个标签 y。这里我们使用大写 X 表示一个维度为 n x d 的矩阵,其中 n 是数据点(或示例)的数量,d 是特征的数量(这就是我们在 DataFrame 中称为字段或列的东西)。我们使用小写 y 表示一个向量,维度为 n x 1;对于每个示例,都有一个标签。

不同的度量标准用于衡量模型的性能。对于分类问题,一个标准度量标准是准确度,或者正确预测的百分比。一旦模型在训练集上使用该度量标准表现良好,我们将把模型应用到我们的测试集上。如果它根据我们的评估指标在我们的测试集上表现良好,那么我们可以确信我们已经构建了一个能够“泛化”到未见数据的模型。

对于我们的 Airbnb 数据集,我们将保留 80%作为训练集,并将 20%的数据留作测试集。此外,我们将设置一个随机种子以确保可重现性,这样如果我们重新运行此代码,我们将得到相同的数据点分配到我们的训练和测试数据集中。种子本身的值不应该有影响,但是数据科学家经常喜欢将其设置为 42,因为这是生命的终极问题的答案

# In Python
trainDF, testDF = airbnbDF.randomSplit([.8, .2], seed=42)
print(f"""There are {trainDF.count()} rows in the training set, 
and {testDF.count()} in the test set""")
// In Scala
val Array(trainDF, testDF) = airbnbDF.randomSplit(Array(.8, .2), seed=42)
println(f"""There are ${trainDF.count} rows in the training set, and 
${testDF.count} in the test set""")

这会产生以下输出:

There are 5780 rows in the training set, and 1366 in the test set

但是,如果我们改变 Spark 集群中执行器的数量会发生什么呢?Catalyst 优化器根据集群资源和数据集的大小作为函数确定 数据分区的最佳方式。考虑到 Spark DataFrame 中的数据是行分区的,每个工作节点独立执行其分区,如果分区中的数据发生变化,则 randomSplit() 的结果将不同。

虽然您可以固定您的集群配置和种子以确保获得一致的结果,但我们建议您仅拆分数据一次,然后将其写入到自己的训练/测试文件夹中,以避免这些可再现性问题。

注意

在探索性分析期间,您应该缓存训练数据集,因为在整个机器学习过程中您将经常访问它。请参考 第七章 中关于 “数据缓存和持久化” 的部分。

使用转换器准备特征

现在我们已经将数据分成了训练集和测试集,让我们准备数据来构建一个线性回归模型,预测给定卧室数量的价格。在稍后的示例中,我们将包括所有相关特征,但现在让我们确保已经准备好了机制。线性回归(像 Spark 中的许多其他算法一样)要求所有输入特征都包含在 DataFrame 中的单个向量中。因此,我们需要 转换 我们的数据。

Spark 中的转换器接受 DataFrame 作为输入,并返回一个新的 DataFrame,其中附加了一个或多个列。它们不会从您的数据中学习,而是使用 transform() 方法应用基于规则的转换。

对于将所有特征放入单个向量的任务,我们将使用 VectorAssembler 转换器VectorAssembler 接受一列输入列的列表,并创建一个新的 DataFrame,其中包含一个额外的列,我们将其称为 features。它将这些输入列的值合并到一个单独的向量中:

# In Python
from pyspark.ml.feature import VectorAssembler
vecAssembler = VectorAssembler(inputCols=["bedrooms"], outputCol="features")
vecTrainDF = vecAssembler.transform(trainDF)
vecTrainDF.select("bedrooms", "features", "price").show(10)
// In Scala
import org.apache.spark.ml.feature.VectorAssembler
val vecAssembler = new VectorAssembler()
  .setInputCols(Array("bedrooms"))
  .setOutputCol("features")
val vecTrainDF = vecAssembler.transform(trainDF)
vecTrainDF.select("bedrooms", "features", "price").show(10)

+--------+--------+-----+
|bedrooms|features|price|
+--------+--------+-----+
|     1.0|   [1.0]|200.0|
|     1.0|   [1.0]|130.0|
|     1.0|   [1.0]| 95.0|
|     1.0|   [1.0]|250.0|
|     3.0|   [3.0]|250.0|
|     1.0|   [1.0]|115.0|
|     1.0|   [1.0]|105.0|
|     1.0|   [1.0]| 86.0|
|     1.0|   [1.0]|100.0|
|     2.0|   [2.0]|220.0|
+--------+--------+-----+

您会注意到,在 Scala 代码中,我们必须实例化新的 VectorAssembler 对象,并使用 setter 方法来更改输入和输出列。在 Python 中,您可以直接将参数传递给 VectorAssembler 的构造函数,或者使用 setter 方法,但在 Scala 中只能使用 setter 方法。

接下来我们将讲解线性回归的基础知识,但如果您已经熟悉这种算法,请跳转到 “使用估算器构建模型”。

理解线性回归

线性回归模型描述了依赖变量(或标签)与一个或多个独立变量(或特征)之间的线性关系。在我们的案例中,我们希望拟合一个线性回归模型,以预测 Airbnb 租金的价格,根据卧室数量。

在图 10-6 中,我们有一个特征x和一个输出y(这是我们的因变量)。线性回归旨在为xy拟合一条直线方程,对于标量变量,可以表示为y = mx + b,其中m是斜率,b是偏移或截距。

点表示我们数据集中的真实(x, y)对,实线表示该数据集的最佳拟合线。数据点并不完全对齐,因此我们通常认为线性回归是将模型拟合到 y ≈ mx + b + ε的过程,其中ε(epsilon)是从某个分布独立抽取的每个记录x的误差。这些是我们模型预测与真实值之间的误差。通常我们将ε视为高斯或正态分布。回归线上方的垂直线表示正ε(或残差),即您的真实值高于预测值,而回归线下方的垂直线表示负残差。线性回归的目标是找到最小化这些残差平方的线。您会注意到,该线可以对其未见数据点进行预测。

单变量线性回归

图 10-6. 单变量线性回归

线性回归也可以扩展到处理多个独立变量。如果我们有三个输入特征,x = [x[1], x[2], x[3]],那么我们可以将y建模为yw[0] + w[1]x[1] + w[2]x[2] + w[3]x[3] + ε。在这种情况下,每个特征都有一个单独的系数(或权重),并且一个单独的截距(这里是w[0]而不是b)。估计我们模型的系数和截距的过程称为学习(或拟合)模型的参数。现在,我们将专注于预测价格与卧室数量的单变量回归示例,并稍后再讨论多变量线性回归。

使用估计器构建模型

设置好我们的vectorAssembler后,我们准备好了我们的数据,并将其转换为我们的线性回归模型所需的格式。在 Spark 中,LinearRegression是一种类型的估计器——它接受一个 DataFrame 并返回一个Model。估计器从您的数据中学习参数,具有estimator_name.fit()方法,并且会立即评估(即启动 Spark 作业),而变换器则是延迟评估的。其他一些估计器的示例包括ImputerDecisionTreeClassifierRandomForestRegressor

您会注意到,我们线性回归的输入列(features)是我们的vectorAssembler的输出:

# In Python
from pyspark.ml.regression import LinearRegression
lr = LinearRegression(featuresCol="features", labelCol="price")
lrModel = lr.fit(vecTrainDF)
// In Scala
import org.apache.spark.ml.regression.LinearRegression
val lr = new LinearRegression()
  .setFeaturesCol("features")
  .setLabelCol("price")

val lrModel = lr.fit(vecTrainDF)

lr.fit()返回一个LinearRegressionModel (lrModel),它是一个转换器。换句话说,估计器的fit()方法的输出是一个转换器。一旦估计器学习了参数,转换器可以将这些参数应用于新的数据点以生成预测结果。让我们检查它学到的参数:

# In Python
m = round(lrModel.coefficients[0], 2)
b = round(lrModel.intercept, 2)
print(f"""The formula for the linear regression line is 
price = {m}*bedrooms + {b}""")
// In Scala
val m = lrModel.coefficients(0)
val b = lrModel.intercept
println(f"""The formula for the linear regression line is 
price = $m%1.2f*bedrooms + $b%1.2f""")

这将打印:

The formula for the linear regression line is price = 123.68*bedrooms + 47.51

创建一个 Pipeline

如果我们想将我们的模型应用于测试集,那么我们需要以与训练集相同的方式准备数据(即通过向量组合器)。通常数据准备流水线会有多个步骤,记住应用哪些步骤以及步骤的顺序变得很麻烦。这就是Pipeline API的动机:您只需指定您希望数据通过的阶段,并按顺序进行处理,Spark 会为您处理处理过程。它们提供了更好的代码重用性和组织性。在 Spark 中,Pipeline是估计器,而经过拟合的PipelineModel是转换器。

现在让我们构建我们的流水线:

# In Python
from pyspark.ml import Pipeline
pipeline = Pipeline(stages=[vecAssembler, lr])
pipelineModel = pipeline.fit(trainDF)
// In Scala
import org.apache.spark.ml.Pipeline
val pipeline = new Pipeline().setStages(Array(vecAssembler, lr))
val pipelineModel = pipeline.fit(trainDF)

使用 Pipeline API 的另一个优点是它会为您确定哪些阶段是估计器/转换器,因此您不必担心为每个阶段指定*name*.fit()*name*.transform()

因为pipelineModel是一个转换器,所以将其应用于我们的测试数据集也很简单:

# In Python
predDF = pipelineModel.transform(testDF)
predDF.select("bedrooms", "features", "price", "prediction").show(10)
// In Scala
val predDF = pipelineModel.transform(testDF)
predDF.select("bedrooms", "features", "price", "prediction").show(10)

+--------+--------+------+------------------+
|bedrooms|features| price|        prediction|
+--------+--------+------+------------------+
|     1.0|   [1.0]|  85.0|171.18598011578285|
|     1.0|   [1.0]|  45.0|171.18598011578285|
|     1.0|   [1.0]|  70.0|171.18598011578285|
|     1.0|   [1.0]| 128.0|171.18598011578285|
|     1.0|   [1.0]| 159.0|171.18598011578285|
|     2.0|   [2.0]| 250.0|294.86172649777757|
|     1.0|   [1.0]|  99.0|171.18598011578285|
|     1.0|   [1.0]|  95.0|171.18598011578285|
|     1.0|   [1.0]| 100.0|171.18598011578285|
|     1.0|   [1.0]|2010.0|171.18598011578285|
+--------+--------+------+------------------+

在这段代码中,我们只使用一个特征bedrooms来构建模型(您可以在书的GitHub 仓库中找到本章的笔记本)。然而,您可能希望使用所有特征构建模型,其中一些可能是分类的,例如host_is_superhost。分类特征具有离散值并且没有内在的顺序——例如职业或国家名称。在下一节中,我们将考虑如何处理这些类型的变量的解决方案,称为独热编码

独热编码

在我们刚创建的流水线中,我们只有两个阶段,我们的线性回归模型只使用了一个特征。让我们看看如何构建一个稍微复杂的流水线,其中包含所有数值和分类特征。

MLlib 中的大多数机器学习模型都希望输入数值,表示为向量。为了将分类值转换为数值,我们可以使用一种称为独热编码(OHE)的技术。假设我们有一个名为Animal的列,有三种动物:DogCatFish。我们不能直接将字符串类型传递给我们的 ML 模型,因此我们需要分配一个数值映射,例如这样:

Animal = {"Dog", "Cat", "Fish"}
"Dog" = 1, "Cat" = 2, "Fish" = 3

然而,使用这种方法,我们在数据集中引入了一些不存在的虚假关系。例如,为什么我们给Cat分配了Dog的两倍的值?我们使用的数值不应该在数据集中引入任何关系。相反,我们希望为我们Animal列中的每个不同值创建单独的列:

"Dog" = [ 1, 0, 0]
"Cat" = [ 0, 1, 0]
"Fish" = [0, 0, 1]

如果动物是狗,则在第一列中有一个1,其他位置为0。如果是猫,则在第二列中有一个1,其他位置为0。列的顺序不重要。如果您之前使用过 pandas,您会注意到这与pandas.get_dummies()的功能相同。

如果我们有一只有 300 只动物的动物园,那么独热编码会大幅增加内存/计算资源的消耗吗?不会,使用 Spark!Spark 在大部分条目为0(通常在独热编码后如此)时内部使用SparseVector,因此不会浪费空间存储0值。让我们通过一个例子更好地理解SparseVector的工作原理:

DenseVector(0, 0, 0, 7, 0, 2, 0, 0, 0, 0)
SparseVector(10, [3, 5], [7, 2])

在这个例子中,DenseVector 包含了 10 个值,除了 2 个值外其余都是0。要创建一个SparseVector,我们需要跟踪向量的大小、非零元素的索引以及这些索引处的对应值。在这个例子中,向量的大小是 10,索引 3 和 5 处有两个非零值,这些索引处的值分别为 7 和 2。

有几种方法可以使用 Spark 对数据进行独热编码。常见的方法是使用StringIndexerOneHotEncoder。使用这种方法的第一步是将StringIndexer估算器应用于将分类值转换为类别索引。这些类别索引按标签频率排序,因此最常见的标签获得索引 0,这使得我们可以在相同数据的多次运行中获得可重复的结果。

一旦创建了类别索引,您可以将其作为输入传递给OneHotEncoder(如果使用 Spark 2.3/2.4,则为OneHotEncoderEstimator)。OneHotEncoder将类别索引列映射到二进制向量列。查看表 10-2 以了解从 Spark 2.3/2.4 到 3.0 中StringIndexerOneHotEncoder API 的差异。

表 10-2. Spark 3.0 中StringIndexerOneHotEncoder的更改

Spark 2.3 和 2.4 Spark 3.0
StringIndexer 单列作为输入/输出 多列作为输入/输出
OneHotEncoder 已弃用 多列作为输入/输出
OneHotEncoderEstimator 多列作为输入/输出 不适用

下面的代码演示了如何对我们的分类特征进行独热编码。在我们的数据集中,任何类型为string的列都被视为分类特征,但有时您可能希望将数值特征视为分类特征,反之亦然。您需要仔细识别哪些列是数值型的,哪些是分类的:

# In Python
from pyspark.ml.feature import OneHotEncoder, StringIndexer

categoricalCols = [field for (field, dataType) in trainDF.dtypes 
                   if dataType == "string"]
indexOutputCols = [x + "Index" for x in categoricalCols]
oheOutputCols = [x + "OHE" for x in categoricalCols]

stringIndexer = StringIndexer(inputCols=categoricalCols, 
                              outputCols=indexOutputCols, 
                              handleInvalid="skip")
oheEncoder = OneHotEncoder(inputCols=indexOutputCols, 
                           outputCols=oheOutputCols)

numericCols = [field for (field, dataType) in trainDF.dtypes 
               if ((dataType == "double") & (field != "price"))]
assemblerInputs = oheOutputCols + numericCols
vecAssembler = VectorAssembler(inputCols=assemblerInputs, 
                               outputCol="features")
// In Scala
import org.apache.spark.ml.feature.{OneHotEncoder, StringIndexer}

val categoricalCols = trainDF.dtypes.filter(_._2 == "StringType").map(_._1)
val indexOutputCols = categoricalCols.map(_ + "Index")
val oheOutputCols = categoricalCols.map(_ + "OHE")

val stringIndexer = new StringIndexer()
  .setInputCols(categoricalCols)
  .setOutputCols(indexOutputCols)
  .setHandleInvalid("skip")

val oheEncoder = new OneHotEncoder()
  .setInputCols(indexOutputCols)
  .setOutputCols(oheOutputCols)

val numericCols = trainDF.dtypes.filter{ case (field, dataType) => 
  dataType == "DoubleType" && field != "price"}.map(_._1)
val assemblerInputs = oheOutputCols ++ numericCols
val vecAssembler = new VectorAssembler()
  .setInputCols(assemblerInputs)
  .setOutputCol("features")

现在您可能会想,“StringIndexer如何处理测试数据集中出现但训练数据集中不存在的新类别?” 这里有一个handleInvalid参数,用于指定您想要如何处理它们。选项有skip(过滤掉无效数据的行),error(抛出错误)或keep(将无效数据放入特殊的附加桶中,索引为numLabels)。在本例中,我们只跳过了无效记录。

此方法的一个困难在于,您需要明确告诉StringIndexer哪些特征应被视为分类特征。您可以使用VectorIndexer自动检测所有分类变量,但这会消耗大量计算资源,因为它必须遍历每一列并检测其是否具有少于maxCategories个不同值。maxCategories是用户指定的参数,确定该值也可能很困难。

另一种方法是使用RFormula。其语法受到 R 编程语言的启发。使用RFormula时,您提供标签和要包含的特征。它支持 R 语言的有限子集运算符,包括~.:+-。例如,您可以指定formula = "y ~ bedrooms + bathrooms",这意味着根据bedroomsbathrooms预测y,或者formula = "y ~ .",这意味着使用所有可用特征(并自动排除y)。RFormula将自动StringIndex和 OHE 所有您的string列,将您的数值列转换为double类型,并使用VectorAssembler将所有这些组合成一个单一向量。因此,我们可以用一行代码替换所有前面的代码,得到相同的结果:

# In Python
from pyspark.ml.feature import RFormula

rFormula = RFormula(formula="price ~ .", 
                    featuresCol="features", 
                    labelCol="price", 
                    handleInvalid="skip")
// In Scala
import org.apache.spark.ml.feature.RFormula

val rFormula = new RFormula()
  .setFormula("price ~ .")
  .setFeaturesCol("features")
  .setLabelCol("price")
  .setHandleInvalid("skip")

RFormula自动结合StringIndexerOneHotEncoder的缺点在于,并非所有算法都需要或建议使用独热编码。例如,基于树的算法可以直接处理分类变量,只需使用StringIndexer处理分类特征即可。对于基于树的方法,您不需要对分类特征进行独热编码,通常这样做会使您的基于树的模型表现更差(详情见这里)。不幸的是,特征工程并没有一种适合所有情况的解决方案,最佳方法与您计划应用于数据集的下游算法密切相关。

注意

如果有人为您执行特征工程,请确保他们记录了如何生成这些特征。

一旦编写了用于转换数据集的代码,您可以使用所有特征作为输入添加线性回归模型。

在这里,我们将所有特征准备和模型构建放入管道中,并将其应用于我们的数据集:

# In Python
lr = LinearRegression(labelCol="price", featuresCol="features")
pipeline = Pipeline(stages = [stringIndexer, oheEncoder, vecAssembler, lr])
# Or use RFormula
# pipeline = Pipeline(stages = [rFormula, lr])

pipelineModel = pipeline.fit(trainDF)
predDF = pipelineModel.transform(testDF)
predDF.select("features", "price", "prediction").show(5)
// In Scala
val lr = new LinearRegression()
  .setLabelCol("price")
  .setFeaturesCol("features")
val pipeline = new Pipeline()
  .setStages(Array(stringIndexer, oheEncoder, vecAssembler, lr))
// Or use RFormula
// val pipeline = new Pipeline().setStages(Array(rFormula, lr))

val pipelineModel = pipeline.fit(trainDF)
val predDF = pipelineModel.transform(testDF)
predDF.select("features", "price", "prediction").show(5)

+--------------------+-----+------------------+
|            features|price|        prediction|
+--------------------+-----+------------------+
|(98,[0,3,6,7,23,4...| 85.0| 55.80250714362137|
|(98,[0,3,6,7,23,4...| 45.0| 22.74720286761658|
|(98,[0,3,6,7,23,4...| 70.0|27.115811183814913|
|(98,[0,3,6,7,13,4...|128.0|-91.60763412465076|
|(98,[0,3,6,7,13,4...|159.0| 94.70374072351933|
+--------------------+-----+------------------+

如您所见,特征列表示为SparseVector。经过独热编码后有 98 个特征,接着是非零索引,然后是值本身。如果将truncate=False传递给show(),您可以看到完整的输出。

我们的模型表现如何?您可以看到,尽管某些预测可能被认为“接近”,但其他预测则差距很大(租金为负数!?)。接下来,我们将在整个测试集上数值评估我们模型的表现。

评估模型

现在我们已经建立了一个模型,我们需要评估其表现。在spark.ml中,有分类、回归、聚类和排名评估器(在 Spark 3.0 中引入)。鉴于这是一个回归问题,我们将使用均方根误差(RMSE)R²(发音为“R 平方”)来评估我们模型的表现。

RMSE

RMSE 是一个从零到无穷大的度量。它越接近零,表现越好。

让我们逐步浏览数学公式:

  1. 计算真实值y[i]和预测值ŷ[i](发音为y-hat,其中“hat”表示它是帽子下的预测值)之间的差异(或误差):

    Error = ( y i - y ^ i )

  2. y[i]ŷ[i]之间的差异进行平方,以防止我们的正负残差相互抵消。这称为平方误差:

    Square Error (SE) = (y i -y ^ i ) 2

  3. 然后,我们对所有n个数据点的平方误差求和,称为平方误差和(SSE)或平方残差和:

    Sum of Squared Errors (SSE) = i=1 n (y i -y ^ i ) 2

  4. 然而,SSE 随数据集中记录数n的增加而增长,因此我们希望通过记录数对其进行归一化。这给出了均方误差(MSE),一个非常常用的回归指标:

    Mean Squared Error (MSE) = 1 n i=1 n (y i -y ^ i ) 2

  5. 如果我们停留在 MSE,那么我们的误差项在unit²的规模上。我们通常会取 MSE 的平方根,以使误差回到原始单位的尺度上,这就是均方根误差(RMSE):

    Root Mean Squared Error (RMSE) = 1 n i=1 n (y i -y ^ i ) 2

让我们使用 RMSE 来评估我们的模型:

# In Python
from pyspark.ml.evaluation import RegressionEvaluator
regressionEvaluator = RegressionEvaluator(
  predictionCol="prediction", 
  labelCol="price", 
  metricName="rmse")
rmse = regressionEvaluator.evaluate(predDF)
print(f"RMSE is {rmse:.1f}")
// In Scala
import org.apache.spark.ml.evaluation.RegressionEvaluator
val regressionEvaluator = new RegressionEvaluator()
  .setPredictionCol("prediction")
  .setLabelCol("price")
  .setMetricName("rmse")
val rmse = regressionEvaluator.evaluate(predDF)
println(f"RMSE is $rmse%.1f")

这产生了以下输出:

RMSE is 220.6

解释 RMSE 的值

那么我们如何知道 220.6 是 RMSE 的一个好值?有多种方法可以解释这个值,其中一种方法是构建一个简单的基线模型并计算其 RMSE 以进行比较。回归任务的一个常见基线模型是计算训练集标签的平均值ȳ(读作y-bar),然后为测试数据集中的每条记录预测ȳ并计算结果的 RMSE(示例代码可在该书的GitHub repo找到)。如果您尝试这样做,您会看到我们的基线模型具有 240.7 的 RMSE,所以我们打败了基线。如果您没有击败基线,则可能是在建模过程中出了问题。

注意

如果这是一个分类问题,您可能希望预测最普遍的类作为您的基线模型。

请记住,您标签的单位直接影响您的 RMSE。例如,如果您的标签是高度,则如果您使用厘米而不是米作为您的测量单位,您的 RMSE 将更高。您可以通过使用不同的单位任意减少 RMSE,这就是为什么比较 RMSE 与基线非常重要。

也有一些指标自然地让您直觉地了解您与基准的表现如何,例如R²,接下来我们将讨论它。

尽管名为R²的名称包含“平方”,R²值的范围从负无穷到 1。让我们来看看这一度量背后的数学。R²的计算如下:

R 2 = 1 - SS res SS tot

其中SS[tot]是如果您始终预测ȳ的总平方和:

S S tot = i=1 n (y i -y ¯) 2

SS[res]是从您的模型预测中残差平方和(也称为平方误差和,我们用它来计算 RMSE):

S S res = i=1 n (y i -y ^ i ) 2

如果您的模型完美预测每个数据点,那么您的SS[res] = 0,使您的R² = 1。如果您的SS[res] = SS[tot],那么分数是 1/1,因此您的R²为 0。这就是如果您的模型表现与始终预测平均值ȳ相同会发生的情况。

但是如果您的模型表现比始终预测ȳ还要差,而且您的SS[res]非常大怎么办?那么您的R²实际上可以是负的!如果您的R²为负数,您应该重新评估您的建模过程。使用R²的好处是您不一定需要定义一个基线模型进行比较。

如果我们想要更改我们的回归评估器以使用R²,而不是重新定义回归评估器,我们可以使用设置器属性来设置指标名称:

# In Python
r2 = regressionEvaluator.setMetricName("r2").evaluate(predDF)
print(f"R2 is {r2}")
// In Scala
val r2 = regressionEvaluator.setMetricName("r2").evaluate(predDF)
println(s"R2 is $r2")

输出为:

R2 is 0.159854

我们的 R² 是正值,但非常接近 0. 我们的模型表现不佳的一个原因是,我们的标签 price 看起来呈现出对数正态分布。如果一个分布是对数正态的,这意味着如果我们取该值的对数,结果看起来像正态分布。价格通常是对数正态分布的。如果您考虑一下旧金山的租金价格,大多数每晚约为 200 美元,但有些则每晚租金高达数千美元!您可以在我们的训练数据集中查看 Airbnb 价格的分布,见图 10-7。

旧金山房屋价格分布

图 10-7. 旧金山房屋价格分布

让我们看看如果我们查看价格的对数而不是价格本身的分布情况(图 10-8)。

旧金山房屋对数价格分布

图 10-8. 旧金山房屋对数价格分布

您可以看到我们的对数价格分布看起来更像正态分布。作为练习,尝试构建一个模型来预测对数尺度上的价格,然后指数化预测以将其从对数尺度转换出来,并评估您的模型。代码也可以在本章的笔记本以及书籍的 GitHub 仓库 中找到。您应该会发现,对于这个数据集,您的 RMSE 减小,R² 增加。

保存和加载模型

现在我们已经构建并评估了一个模型,让我们将其保存到持久存储中以便以后重用(或者在我们的集群崩溃时,我们不需要重新计算模型)。保存模型与编写 DataFrame 非常相似——API 是 model.write().save(*path*)。您可以选择使用 overwrite() 命令来覆盖该路径中包含的任何数据:

# In Python
pipelinePath = "/tmp/lr-pipeline-model"
pipelineModel.write().overwrite().save(pipelinePath)
// In Scala
val pipelinePath = "/tmp/lr-pipeline-model"
pipelineModel.write.overwrite().save(pipelinePath)

当您加载已保存的模型时,您需要指定要重新加载的模型类型(例如,是 LinearRegressionModel 还是 LogisticRegressionModel?)。出于这个原因,我们建议您始终将您的转换器/估计器放入 Pipeline 中,这样对于所有加载的模型,您只需加载一个 PipelineModel 并且只需更改模型的文件路径:

# In Python
from pyspark.ml import PipelineModel
savedPipelineModel = PipelineModel.load(pipelinePath)
// In Scala
import org.apache.spark.ml.PipelineModel
val savedPipelineModel = PipelineModel.load(pipelinePath)

加载完成后,您可以将其应用于新的数据点。但是,您不能使用此模型的权重作为初始化参数来训练新模型(而不是从随机权重开始),因为 Spark 没有“热启动”的概念。如果您的数据集略有变化,您将不得不从头开始重新训练整个线性回归模型。

我们构建并评估了线性回归模型之后,让我们看看我们的数据集上其他几种模型的表现。在下一节中,我们将探讨基于树的模型,并查看一些常见的超参数,以调整模型性能。

超参数调优

当数据科学家讨论调整模型时,他们经常讨论调整超参数以提高模型的预测能力。超参数是在训练之前定义的关于模型的属性,并且在训练过程中不会学习(不要与在训练过程中学习的参数混淆)。你随机森林中的树的数量就是一个超参数的例子。

在本节中,我们将重点讨论使用基于树的模型作为超参数调整程序的示例,但这些概念同样适用于其他模型。一旦我们设置好使用spark.ml进行超参数调整的机制,我们将讨论如何优化流程。让我们从决策树的简要介绍开始,然后介绍如何在spark.ml中使用它们。

基于树的模型

决策树等基于树的模型,如决策树、梯度提升树和随机森林,是相对简单而强大的模型,易于解释(也就是说,易于解释它们所做的预测)。因此,它们在机器学习任务中非常流行。我们很快就会讨论随机森林,但首先我们需要掌握决策树的基础知识。

决策树

作为现成的解决方案,决策树非常适合数据挖掘。它们相对快速建立,高度可解释,并且与缩放无关(即,标准化或缩放数值特征不会改变树的性能)。那么,什么是决策树呢?

决策树是从数据中学习的一系列 if-then-else 规则,用于分类或回归任务。假设我们试图建立一个模型来预测某人是否接受工作提议,特征包括工资、通勤时间、免费咖啡等等。如果我们将决策树拟合到这个数据集,我们可能会得到一个看起来像图 10-9 的模型。

Decision tree example

图 10-9. 决策树示例

树的顶部节点称为树的“根”,因为它是我们“分割”的第一个特征。这个特征应该提供最具信息量的分割——在这种情况下,如果工资低于$50,000,那么大多数候选人会拒绝工作提议。“拒绝提议”节点被称为“叶子节点”,因为在该节点没有其他分割出来;它在一个分支的末端。(是的,我们称之为决策“树”,但是把树的根画在顶部,把叶子画在底部确实有点有趣!)

然而,如果提供的工资高于$50,000,我们会继续处理决策树中下一个最具信息量的特征,即通勤时间。即使工资超过$50,000,如果通勤时间超过一小时,那么大多数人也会拒绝工作提议。

注意

我们不会在这里详细讨论如何确定哪些特征将为您提供最高的信息增益,但如果您感兴趣,可以查阅《统计学习的要素》第九章,作者是 Trevor Hastie,Robert Tibshirani 和 Jerome Friedman(Springer)。

我们模型中的最终特征是免费咖啡。在这种情况下,决策树显示,如果薪水超过$50,000,通勤时间少于一小时,并且有免费咖啡,那么大多数人将接受我们的工作提议(如果事情真的那么简单的话!)。作为后续资源,R2D3提供了关于决策树工作原理的出色可视化。

注意

单个决策树中可能会在同一特征上多次分裂,但每次分裂将发生在不同的值上。

决策树的深度是从根节点到任何给定叶节点的最长路径。在图 10-9 中,深度为三。非常深的树容易过拟合,即在训练数据集中记住噪音,但是过于浅的树将对数据集欠拟合(即可能没有从数据中获取更多信号)。

解释了决策树的本质后,让我们重新回到为决策树准备特征的话题。对于决策树,您无需担心标准化或缩放输入特征,因为这对分裂没有影响,但是您必须注意如何准备分类特征。

基于树的方法可以自然地处理分类变量。在spark.ml中,您只需将分类列传递给StringIndexer,决策树就可以处理其余的部分。让我们将一个决策树拟合到我们的数据集中:

# In Python
from pyspark.ml.regression import DecisionTreeRegressor

dt = DecisionTreeRegressor(labelCol="price")

# Filter for just numeric columns (and exclude price, our label)
numericCols = [field for (field, dataType) in trainDF.dtypes 
               if ((dataType == "double") & (field != "price"))]

# Combine output of StringIndexer defined above and numeric columns
assemblerInputs = indexOutputCols + numericCols
vecAssembler = VectorAssembler(inputCols=assemblerInputs, outputCol="features")

# Combine stages into pipeline
stages = [stringIndexer, vecAssembler, dt]
pipeline = Pipeline(stages=stages)
pipelineModel = pipeline.fit(trainDF) # This line should error
// In Scala
import org.apache.spark.ml.regression.DecisionTreeRegressor

val dt = new DecisionTreeRegressor()
  .setLabelCol("price")

// Filter for just numeric columns (and exclude price, our label)
val numericCols = trainDF.dtypes.filter{ case (field, dataType) => 
  dataType == "DoubleType" && field != "price"}.map(_._1)

// Combine output of StringIndexer defined above and numeric columns
val assemblerInputs = indexOutputCols ++ numericCols
val vecAssembler = new VectorAssembler()
  .setInputCols(assemblerInputs)
  .setOutputCol("features")

// Combine stages into pipeline
val stages = Array(stringIndexer, vecAssembler, dt)
val pipeline = new Pipeline()
  .setStages(stages)

val pipelineModel = pipeline.fit(trainDF) // This line should error

这会产生以下错误:

java.lang.IllegalArgumentException: requirement failed: DecisionTree requires
maxBins (= 32) to be at least as large as the number of values in each 
categorical feature, but categorical feature 3 has 36 values. Consider removing 
this and other categorical features with a large number of values, or add more 
training examples.

我们可以看到maxBins参数存在问题。该参数是做什么用的?maxBins确定连续特征分成的箱数或分裂数。这个离散化步骤对执行分布式训练至关重要。在scikit-learn中没有maxBins参数,因为所有数据和模型都驻留在单个机器上。然而,在 Spark 中,工作节点具有数据的所有列,但只有数据的部分行。因此,在通信关于分裂值的特征和值时,我们需要确保它们都在训练时从相同的离散化设置中获取。让我们看一下图 10-10,展示了PLANET分布式决策树的实现,以更好地理解分布式机器学习并说明maxBins参数。

PLANET implementation of distributed decision trees (source: https://oreil.ly/RAvvP)

图 10-10. PLANET 分布式决策树实现(来源:https://oreil.ly/RAvvP

每个工作进程都必须计算每个特征和每个可能分割点的汇总统计信息,并且这些统计信息将在工作进程之间进行聚合。MLlib 要求 maxBins 要足够大,以处理分类列的离散化。maxBins 的默认值为 32,而我们有一个具有 36 个不同值的分类列,这就是我们之前遇到错误的原因。虽然我们可以将 maxBins 增加到 64,以更准确地表示我们的连续特征,但这将使连续变量的可能分割数翻倍,大大增加计算时间。让我们将 maxBins 设置为 40,然后重新训练管道。在这里,您会注意到我们使用 setMaxBins() 设置器方法来修改决策树,而不是完全重新定义它:

# In Python
dt.setMaxBins(40)
pipelineModel = pipeline.fit(trainDF)
// In Scala
dt.setMaxBins(40)
val pipelineModel = pipeline.fit(trainDF)

由于实现上的差异,当使用 scikit-learn 与 MLlib 构建模型时,结果通常不会完全相同。然而,这没有关系。关键是理解它们之间的差异,并查看哪些参数在您的控制之下,以使它们表现出您需要的方式。如果您正在从 scikit-learn 迁移工作负载到 MLlib,请查看 spark.mlscikit-learn 文档,了解不同的参数,并调整这些参数以获得相同数据的可比较结果。一旦数值足够接近,您可以将您的 MLlib 模型扩展到 scikit-learn 无法处理的更大数据规模。

现在我们已经成功构建了我们的模型,我们可以提取决策树学到的 if-then-else 规则:

# In Python
dtModel = pipelineModel.stages[-1]
print(dtModel.toDebugString)
// In Scala
val dtModel = pipelineModel.stages.last
  .asInstanceOf[org.apache.spark.ml.regression.DecisionTreeRegressionModel]
println(dtModel.toDebugString)

DecisionTreeRegressionModel: uid=dtr_005040f1efac, depth=5, numNodes=47,...
  If (feature 12 <= 2.5)
   If (feature 12 <= 1.5)
    If (feature 5 in {1.0,2.0})
     If (feature 4 in {0.0,1.0,3.0,5.0,9.0,10.0,11.0,13.0,14.0,16.0,18.0,24.0})
      If (feature 3 in
{0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,...})
       Predict: 104.23992784125075
      Else (feature 3 not in {0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,...})
       Predict: 250.7111111111111
...

这只是打印输出的一个子集,但您会注意到可以多次在同一特征上分割(例如特征 12),但在不同的分割值上。还请注意决策树在数值特征和分类特征上分割的不同之处:对于数值特征,它检查值是否小于或等于阈值,而对于分类特征,则检查值是否在该集合中。

我们还可以从模型中提取特征重要性分数,以查看最重要的特征:

# In Python
import pandas as pd

featureImp = pd.DataFrame(
  list(zip(vecAssembler.getInputCols(), dtModel.featureImportances)),
  columns=["feature", "importance"])
featureImp.sort_values(by="importance", ascending=False)
// In Scala
val featureImp = vecAssembler
  .getInputCols.zip(dtModel.featureImportances.toArray)
val columns = Array("feature", "Importance")
val featureImpDF = spark.createDataFrame(featureImp).toDF(columns: _*)

featureImpDF.orderBy($"Importance".desc).show()
Feature Importance
bedrooms 0.283406
cancellation_policyIndex 0.167893
instant_bookableIndex 0.140081
property_typeIndex 0.128179
number_of_reviews 0.126233
neighbourhood_cleansedIndex 0.056200
longitude 0.038810
minimum_nights 0.029473
beds 0.015218
room_typeIndex 0.010905
accommodates 0.003603

虽然决策树非常灵活且易于使用,但并不总是最精确的模型。如果我们在测试数据集上计算我们的 R²,实际上会得到一个负分数!这比仅预测平均值更糟糕。(您可以在本章的笔记本中查看本书的 GitHub 仓库 中的具体情况。)

让我们通过使用集成方法来改进这个模型,这种方法结合了不同的模型以获得更好的结果:随机森林。

随机森林

集成采用民主的方法。想象一下罐子里有很多颗 M&M 巧克力。你请一百个人猜猜 M&M 的数量,然后取所有猜测的平均值。平均值可能比大多数个体猜测更接近真实值。这个概念同样适用于机器学习模型。如果建立多个模型并组合/平均它们的预测结果,它们将比任何单个模型更加健壮。

随机森林是由决策树组成的集成学习方法,具有两个关键调整:

行的自助抽样

自助法是通过从原始数据中有放回地抽样来模拟新数据的技术。每棵决策树都是在数据集的不同自助样本上训练的,这会产生略有不同的决策树,然后汇总它们的预测结果。这种技术被称为自助聚合,或者称为装袋。在典型的随机森林实现中,每棵树都从原始数据集中有放回地抽取相同数量的数据点进行样本,而这个数量可以通过参数subsamplingRate来控制。

列的随机特征选择

装袋的主要缺点在于所有树都高度相关,因此在数据中学习相似的模式。为了减轻这个问题,每次想要进行分裂时只考虑列的随机子集(对于RandomForestRegressor为特征数的 1/3,对于RandomForestClassifier为特征数的#features)。由于引入了这种随机性,你通常希望每棵树都相对较浅。你可能会想:每棵树都会比任何单个决策树表现得更差,那么这种方法怎么可能更好呢?事实证明,这些树各自从你的数据集中学到了不同的东西,将这些“弱”学习者组合成一个集成使得随机森林比单个决策树更加健壮。

图 10-11 说明了随机森林在训练时的情况。在每次分裂时,它考虑原始特征中的 3 个来进行分裂;最终选择其中最佳的。

随机森林训练

图 10-11. 随机森林训练

随机森林和决策树的 API 类似,都可以应用于回归或分类任务:

# In Python
from pyspark.ml.regression import RandomForestRegressor
rf = RandomForestRegressor(labelCol="price", maxBins=40, seed=42)
// In Scala
import org.apache.spark.ml.regression.RandomForestRegressor
val rf = new RandomForestRegressor()
  .setLabelCol("price")
  .setMaxBins(40)
  .setSeed(42)

一旦训练了随机森林,就可以将新数据点通过集成中训练的不同树。

如图 10-12 所示,如果你构建一个用于分类的随机森林,它会通过森林中的每棵树传递测试点,并对每棵树的预测结果进行多数投票。(相比之下,在回归中,随机森林仅仅是对这些预测进行了平均。)尽管这些树中的每一棵都比任何单独的决策树表现都要差,但集合(或整体)实际上提供了一个更为健壮的模型。

随机森林预测

图 10-12. 随机森林预测

随机森林真正展示了使用 Spark 进行分布式机器学习的威力,因为每棵树都可以独立构建(例如,在构建第 10 棵树之前,你不需要先构建第 3 棵树)。此外,在树的每个层级内,你可以并行处理以找到最优的分割点。

那么我们如何确定我们随机森林中的树的最佳数量或这些树的最大深度应该是多少呢?这个过程被称为超参数调优。与参数不同,超参数是控制学习过程或模型结构的值,在训练过程中不会被学习。树的数量和最大深度都是你可以为随机森林调整的超参数的例子。现在让我们把焦点转移到如何通过调整一些超参数来发现和评估最佳的随机森林模型。

k 折交叉验证

我们应该使用哪个数据集来确定最佳超参数值呢?如果我们使用训练集,那么模型可能会过拟合,即记住我们训练数据的细微差别。这意味着它很可能不能很好地推广到未见过的数据。但是如果我们使用测试集,那么它就不再代表“未见过”的数据,因此我们不能用它来验证模型的泛化能力。因此,我们需要另一个数据集来帮助我们确定最佳的超参数:验证 数据集。

例如,我们可以将数据划分为 60/20/20 的训练、验证和测试数据集,而不是之前的 80/20 划分。然后我们可以在训练集上构建我们的模型,在验证集上评估性能以选择最佳的超参数配置,并在测试集上应用模型以查看它在新数据上的表现。然而,这种方法的一个缺点是我们失去了 25%的训练数据(80% -> 60%),这些数据本来可以帮助改进模型。这促使我们使用k 折交叉验证技术来解决这个问题。

采用这种方法,我们不是将数据集分割为单独的训练、验证和测试集,而是将其分割为与以前相同的训练和测试集,但是我们使用训练数据进行训练和验证。为此,我们将我们的训练数据分成k个子集或“折叠”(例如三个)。然后,对于给定的超参数配置,我们在k-1个折叠上训练我们的模型,并在剩余的一个折叠上评估,重复这个过程k次。图 10-13 说明了这种方法。

k 折交叉验证

图 10-13。k 折交叉验证

正如本图所示,如果我们将数据分成三折,我们的模型首先在数据的第一和第二折(或分割)上进行训练,并在第三折上进行评估。然后,我们使用相同的超参数在数据的第一和第三折上构建相同的模型,并在第二折上评估其性能。最后,我们在第二和第三折上构建模型,并在第一折上评估它。然后,我们对这三个(或k)验证数据集的性能取平均值,作为这个模型在未见数据上表现如何的代理,因为每个数据点有机会恰好成为验证数据集的一部分一次。接下来,我们针对所有不同的超参数配置重复此过程,以确定最佳的配置。

确定超参数的搜索空间可能很困难,通常进行超参数的随机搜索优于结构化的网格搜索。有专门的库,比如Hyperopt,可以帮助您确定最佳的超参数配置,我们在第十一章中有所涉及。

要在 Spark 中执行超参数搜索,请按以下步骤进行:

  1. 定义您想要评估的estimator

  2. 使用ParamGridBuilder指定您要变化的超参数及其相应的值。

  3. 定义一个evaluator来指定用于比较各种模型的度量标准。

  4. 使用CrossValidator执行交叉验证,评估各种模型。

让我们首先定义我们的管道估计器:

# In Python
pipeline = Pipeline(stages = [stringIndexer, vecAssembler, rf])
// In Scala
val pipeline = new Pipeline()
  .setStages(Array(stringIndexer, vecAssembler, rf))

对于我们的ParamGridBuilder,我们将变化我们的maxDepth为 2、4 或 6,以及numTrees(随机森林中的树的数量)为 10 或 100。这将给我们总共 6 个(3 x 2)不同的超参数配置网格:

(maxDepth=2, numTrees=10)
(maxDepth=2, numTrees=100)
(maxDepth=4, numTrees=10)
(maxDepth=4, numTrees=100)
(maxDepth=6, numTrees=10)
(maxDepth=6, numTrees=100)
# In Python
from pyspark.ml.tuning import ParamGridBuilder
paramGrid = (ParamGridBuilder()
            .addGrid(rf.maxDepth, [2, 4, 6])
            .addGrid(rf.numTrees, [10, 100])
            .build())
// In Scala
import org.apache.spark.ml.tuning.ParamGridBuilder
val paramGrid = new ParamGridBuilder()
  .addGrid(rf.maxDepth, Array(2, 4, 6))
  .addGrid(rf.numTrees, Array(10, 100))
  .build()

现在我们已经设置好了超参数网格,我们需要定义如何评估每个模型,以确定哪一个表现最佳。为此,我们将使用RegressionEvaluator,并将 RMSE 作为我们感兴趣的度量标准:

# In Python
evaluator = RegressionEvaluator(labelCol="price", 
                                predictionCol="prediction", 
                                metricName="rmse")
// In Scala
val evaluator = new RegressionEvaluator()
  .setLabelCol("price")
  .setPredictionCol("prediction")
  .setMetricName("rmse")

我们将使用CrossValidator执行我们的k折交叉验证,它接受一个estimatorevaluatorestimatorParamMaps,以便知道要使用哪个模型,如何评估模型以及为模型设置哪些超参数。我们还可以设置我们希望将数据拆分为的折数(numFolds=3),以及设置一个种子,以便在折叠之间具有可重现的拆分(seed=42)。然后让我们将这个交叉验证器适配到我们的训练数据集上:

# In Python
from pyspark.ml.tuning import CrossValidator

cv = CrossValidator(estimator=pipeline, 
                    evaluator=evaluator, 
                    estimatorParamMaps=paramGrid, 
                    numFolds=3, 
                    seed=42)
cvModel = cv.fit(trainDF)
// In Scala
import org.apache.spark.ml.tuning.CrossValidator

val cv = new CrossValidator()
 .setEstimator(pipeline)
 .setEvaluator(evaluator)
 .setEstimatorParamMaps(paramGrid)
 .setNumFolds(3)
 .setSeed(42)
val cvModel = cv.fit(trainDF)

输出告诉我们操作花费了多长时间:

Command took 1.07 minutes

那么,我们刚刚训练了多少个模型?如果你回答 18(6 个超参数配置 x 3 折交叉验证),你就接近了。一旦确定了最佳的超参数配置,如何将这三个(或k个)模型组合在一起呢?虽然有些模型可能很容易平均起来,但有些则不是。因此,Spark 在确定了最佳的超参数配置后会在整个训练数据集上重新训练您的模型,因此最终我们训练了 19 个模型。如果您想保留训练的中间模型,可以在CrossValidator中设置collectSubModels=True

要检查交叉验证器的结果,您可以查看avgMetrics

# In Python
list(zip(cvModel.getEstimatorParamMaps(), cvModel.avgMetrics))
// In Scala
cvModel.getEstimatorParamMaps.zip(cvModel.avgMetrics)

这里是输出:

res1: Array[(org.apache.spark.ml.param.ParamMap, Double)] =
Array(({
    rfr_a132fb1ab6c8-maxDepth: 2,
    rfr_a132fb1ab6c8-numTrees: 10
},303.99522869739343), ({
    rfr_a132fb1ab6c8-maxDepth: 2,
    rfr_a132fb1ab6c8-numTrees: 100
},299.56501993529474), ({
    rfr_a132fb1ab6c8-maxDepth: 4,
    rfr_a132fb1ab6c8-numTrees: 10
},310.63687030886894), ({
    rfr_a132fb1ab6c8-maxDepth: 4,
    rfr_a132fb1ab6c8-numTrees: 100
},294.7369599168999), ({
    rfr_a132fb1ab6c8-maxDepth: 6,
    rfr_a132fb1ab6c8-numTrees: 10
},312.6678169109293), ({
    rfr_a132fb1ab6c8-maxDepth: 6,
    rfr_a132fb1ab6c8-numTrees: 100
},292.101039874209))

我们可以看到,我们的CrossValidator中最佳模型(具有最低的 RMSE)具有maxDepth=6numTrees=100。然而,这需要很长时间才能运行。在接下来的部分中,我们将看看如何在保持相同模型性能的同时缩短训练时间。

优化管道

如果您的代码执行时间足够长,需要考虑如何改进它,那么您应该对其进行优化。在前面的代码中,尽管交叉验证器中的每个模型在技术上是独立的,但spark.ml实际上是按顺序而不是并行地训练模型集合。在 Spark 2.3 中,引入了一个parallelism参数来解决这个问题。此参数确定并行训练的模型数量,这些模型本身是并行适配的。从Spark 调优指南中了解更多:

parallelism的值应该谨慎选择,以最大化并行处理能力,而不超过集群资源,并且较大的值并不总是会带来更好的性能。一般来说,大多数集群最多可以使用值为10的参数。

将这个值设为4,看看我们是否可以更快地训练:

# In Python
cvModel = cv.setParallelism(4).fit(trainDF)
// In Scala
val cvModel = cv.setParallelism(4).fit(trainDF)

答案是肯定的:

Command took 31.45 seconds

我们将训练时间减少了一半(从 1.07 分钟到 31.45 秒),但我们仍然可以进一步改进!还有另一个技巧可以加快模型训练速度:将交叉验证器放在流水线内部(例如,Pipeline(stages=[..., cv])),而不是将流水线放在交叉验证器内部(例如,CrossValidator(estimator=pipeline, ...))。每次交叉验证器评估流水线时,它会针对每个模型运行流水线的每个步骤,即使某些步骤不变,比如StringIndexer也是如此。通过重新评估流水线中的每个步骤,我们反复学习相同的StringIndexer映射,尽管它并未更改。

如果我们将交叉验证器放在流水线内部,那么我们将不会在每次尝试不同模型时重新评估StringIndexer(或任何其他估算器):

# In Python
cv = CrossValidator(estimator=rf, 
                    evaluator=evaluator, 
                    estimatorParamMaps=paramGrid, 
                    numFolds=3, 
                    parallelism=4, 
                    seed=42)

pipeline = Pipeline(stages=[stringIndexer, vecAssembler, cv])
pipelineModel = pipeline.fit(trainDF)
// In Scala
val cv = new CrossValidator()
  .setEstimator(rf)
  .setEvaluator(evaluator)
  .setEstimatorParamMaps(paramGrid)
  .setNumFolds(3)
  .setParallelism(4)
  .setSeed(42)

val pipeline = new Pipeline()
                   .setStages(Array(stringIndexer, vecAssembler, cv))
val pipelineModel = pipeline.fit(trainDF)

这减少了我们训练时间的五秒钟:

Command took 26.21 seconds

借助parallelism参数和重排我们的流水线顺序,上次运行速度最快,如果您将其应用于测试数据集,您会发现结果相同。尽管这些收益仅为几秒钟,但同样的技术也适用于更大的数据集和模型,相应地节省了更多时间。您可以通过访问书籍的GitHub repo中的笔记本来尝试运行此代码。

总结

在本章中,我们讨论了如何使用 Spark MLlib 构建流水线,特别是其基于 DataFrame 的 API 包spark.ml。我们讨论了转换器和估算器之间的区别,如何使用 Pipeline API 组合它们,以及一些不同的评估模型的指标。然后,我们探讨了如何使用交叉验证进行超参数调优以提供最佳模型,以及优化交叉验证和模型训练在 Spark 中的技巧。

所有这些为我们讨论下一章提供了背景,下一章中我们将讨论使用 Spark 管理和扩展机器学习流水线的部署策略。

第十一章:使用 Apache Spark 管理、部署和扩展机器学习管道

在上一章中,我们介绍了如何使用 MLlib 构建机器学习管道。本章将专注于如何管理和部署你训练的模型。通过本章的学习,你将能够使用 MLflow 跟踪、复现和部署你的 MLlib 模型,讨论各种模型部署场景中的困难和权衡,并设计可扩展的机器学习解决方案。但在讨论部署模型之前,让我们首先讨论一些模型管理的最佳实践,为你的模型部署做好准备。

模型管理

在部署机器学习模型之前,你应该确保能够复现和跟踪模型的性能。对于我们来说,端到端的机器学习解决方案的可复现性意味着我们需要能够复现生成模型的代码,训练时使用的环境,训练模型的数据以及模型本身。每个数据科学家都喜欢提醒你设置种子,这样你就可以复现你的实验(例如,在使用具有内在随机性的模型(如随机森林)时进行的训练/测试分割)。然而,影响可复现性的因素远不止设置种子,其中一些因素要微妙得多。以下是一些例子:

库版本管理

当一位数据科学家交给你他们的代码时,他们可能会或者不会提到依赖的库。虽然你可以通过错误消息找出需要的库,但你不确定他们使用的库版本,所以你可能会安装最新的版本。但如果他们的代码是建立在之前的库版本上的,这些版本可能利用了一些与你安装的最新版本不同的默认行为,使用最新版本可能会导致代码出错或结果不同(例如,考虑XGBoost在 v0.90 中如何改变了处理缺失值的方式)。

数据演变

假设你在 2020 年 6 月 1 日构建了一个模型,并跟踪了所有的超参数、库等信息。然后你试图在 2020 年 7 月 1 日复现同样的模型——但是管道中断或结果不同,因为底层数据发生了变化,这可能是因为某人在初始构建之后添加了额外的列或数量级更多的数据。

执行顺序

如果一位数据科学家交给你他们的代码,你应该能够顺利地从头到尾运行它。然而,数据科学家以非按顺序运行事物而著称,或者多次运行同一个有状态的单元格,使得他们的结果非常难以复现。(他们可能还会检查代码的副本,其中的超参数与用于训练最终模型的超参数不同!)

并行操作

为了最大化吞吐量,GPU 将并行运行许多操作。然而,并不总是能保证执行的顺序,这可能导致不确定性的输出。这是使用诸如 tf.reduce_sum() 和聚合浮点数(具有有限精度)时的已知问题:添加它们的顺序可能会生成略有不同的结果,这在许多迭代中可能会恶化。

无法重现你的实验通常会成为业务部门接受你的模型或将其投入生产的障碍。虽然你可以构建自己的内部工具来追踪模型、数据、依赖版本等,但它们可能会变得过时、脆弱,并需要大量的开发工作来维护。同样重要的是,具有行业标准的模型管理工具,可以让模型轻松共享给合作伙伴。有开源和专有工具可以帮助我们通过抽象掉许多常见困难来重现我们的机器学习实验。本节将重点介绍 MLflow,因为它与当前可用的开源模型管理工具中的 MLlib 集成最紧密。

MLflow

MLflow 是一个开源平台,帮助开发者重现和分享实验,管理模型等等。它提供了 Python、R 和 Java/Scala 的接口,以及一个 REST API。如图 11-1 所示,MLflow 主要包括四个组件:

追踪(Tracking)

提供 API 来记录参数、度量、代码版本、模型和诸如图表和文本等工件。

项目(Projects)

一个标准化的格式,用于打包你的数据科学项目及其依赖项,以在其他平台上运行。它帮助你管理模型训练过程。

模型(Models)

一个标准化的格式,用于打包模型以部署到不同的执行环境。它提供了一致的 API 来加载和应用模型,无论使用何种算法或库来构建模型。

注册(Registry)

一个存储模型谱系、模型版本、阶段转换和注释的库。

MLflow 组件

图 11-1. MLflow 组件

让我们追踪 MLlib 模型实验,在第十章中运行,以便重现。然后我们将讨论模型部署时 MLflow 的其他组件如何发挥作用。要开始使用 MLflow,只需在本地主机上运行 pip install mlflow

追踪(Tracking)

MLflow Tracking 是一个日志记录 API,它对实际执行训练的库和环境是不可知的。它围绕数据科学代码执行的概念组织,称为 runs。Runs 被聚合成 experiments,因此许多 runs 可以成为给定实验的一部分。

MLflow 跟踪服务器可以托管多个实验。您可以使用笔记本、本地应用程序或云作业将日志记录到跟踪服务器,如图 11-2 所示。

MLflow 跟踪服务器

图 11-2. MLflow 跟踪服务器

让我们检查一些可以记录到跟踪服务器的内容:

参数

代码中的键/值输入,例如随机森林中的超参数num_treesmax_depth

指标

数值(随时间更新)例如 RMSE 或准确率数值

艺术品

文件、数据和模型,例如matplotlib图像或 Parquet 文件

元数据

运行信息,比如执行了运行的源代码或代码版本(例如,代码版本的 Git 提交哈希字符串)

模型

您训练的模型(们)

默认情况下,跟踪服务器将所有内容记录到文件系统,但您可以指定一个数据库以进行更快的查询,例如参数和指标。让我们为我们的随机森林代码从第十章添加 MLflow 跟踪:

# In Python
from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer, VectorAssembler
from pyspark.ml.regression import RandomForestRegressor
from pyspark.ml.evaluation import RegressionEvaluator

filePath = """/databricks-datasets/learning-spark-v2/sf-airbnb/
sf-airbnb-clean.parquet"""
airbnbDF = spark.read.parquet(filePath)
(trainDF, testDF) = airbnbDF.randomSplit([.8, .2], seed=42)

categoricalCols = [field for (field, dataType) in trainDF.dtypes 
                   if dataType == "string"]
indexOutputCols = [x + "Index" for x in categoricalCols]
stringIndexer = StringIndexer(inputCols=categoricalCols, 
                              outputCols=indexOutputCols, 
                              handleInvalid="skip")

numericCols = [field for (field, dataType) in trainDF.dtypes 
               if ((dataType == "double") & (field != "price"))]
assemblerInputs = indexOutputCols + numericCols
vecAssembler = VectorAssembler(inputCols=assemblerInputs, 
                               outputCol="features")

rf = RandomForestRegressor(labelCol="price", maxBins=40, maxDepth=5, 
                           numTrees=100, seed=42)

pipeline = Pipeline(stages=[stringIndexer, vecAssembler, rf])

要开始使用mlflow.start_run()记录日志,您需要启动一个运行。本章节的示例将使用with子句来自动结束with块时自动结束运行,而不是显式调用mlflow.end_run()

# In Python 
import mlflow
import mlflow.spark
import pandas as pd

with mlflow.start_run(run_name="random-forest") as run:
  # Log params: num_trees and max_depth
  mlflow.log_param("num_trees", rf.getNumTrees())
  mlflow.log_param("max_depth", rf.getMaxDepth())

  # Log model
  pipelineModel = pipeline.fit(trainDF)
  mlflow.spark.log_model(pipelineModel, "model")

  # Log metrics: RMSE and R2
  predDF = pipelineModel.transform(testDF)
  regressionEvaluator = RegressionEvaluator(predictionCol="prediction", 
                                            labelCol="price")
  rmse = regressionEvaluator.setMetricName("rmse").evaluate(predDF)
  r2 = regressionEvaluator.setMetricName("r2").evaluate(predDF)
  mlflow.log_metrics({"rmse": rmse, "r2": r2})

  # Log artifact: feature importance scores
  rfModel = pipelineModel.stages[-1]
  pandasDF = (pd.DataFrame(list(zip(vecAssembler.getInputCols(), 
                                    rfModel.featureImportances)), 
                           columns=["feature", "importance"])
              .sort_values(by="importance", ascending=False))

  # First write to local filesystem, then tell MLflow where to find that file
  pandasDF.to_csv("feature-importance.csv", index=False)
  mlflow.log_artifact("feature-importance.csv")

让我们检查 MLflow 用户界面,您可以通过在终端中运行mlflow ui并导航至http://localhost:5000/来访问。图 11-3 显示了用户界面的截图。

MLflow 用户界面

图 11-3. MLflow 用户界面

用户界面存储了给定实验的所有运行。您可以搜索所有运行,按特定条件过滤运行,比较运行并排查等。如果希望,您还可以将内容导出为 CSV 文件进行本地分析。在 UI 中点击名为"random-forest"的运行。您应该看到类似图 11-4 的屏幕。

随机森林运行

图 11-4. 随机森林运行

您会注意到它记录了用于此 MLflow 运行的源代码,并存储了所有相应的参数、指标等。您可以添加关于此运行的自由文本注释,以及标签。运行完成后无法修改参数或指标。

您还可以使用MlflowClient或 REST API 查询跟踪服务器:

# In Python
from mlflow.tracking import MlflowClient

client = MlflowClient()
runs = client.search_runs(run.info.experiment_id, 
                          order_by=["attributes.start_time desc"], 
                          max_results=1)

run_id = runs[0].info.run_id
runs[0].data.metrics

这将产生以下输出:

{'r2': 0.22794251914574226, 'rmse': 211.5096898777315}

我们将这段代码作为MLflow 项目托管在本书的GitHub 存储库中,因此您可以尝试使用不同的超参数值(例如max_depthnum_trees)运行它。MLflow 项目内的 YAML 文件指定了库依赖关系,因此此代码可以在其他环境中运行:

# In Python
mlflow.run(
  "https://github.com/databricks/LearningSparkV2/#mlflow-project-example", 
  parameters={"max_depth": 5, "num_trees": 100})

# Or on the command line
mlflow run https://github.com/databricks/LearningSparkV2/#mlflow-project-example
-P max_depth=5 -P num_trees=100

现在您已经跟踪并复现了您的实验,让我们讨论可用于 MLlib 模型的各种部署选项。

使用 MLlib 的模型部署选项

部署机器学习模型对每个组织和用例来说意味着不同的事情。业务约束将对延迟、吞吐量、成本等施加不同的要求,这些要求决定了哪种模型部署模式适合当前任务——无论是批处理、流式处理、实时处理还是移动/嵌入式。在本书之外的范围内,将模型部署到移动/嵌入式系统,因此我们将主要关注其他选项。表 11-1 显示了生成预测的这三种部署选项的吞吐量延迟权衡。我们关心同时请求的数量和请求的大小,所得到的解决方案将会有很大的不同。

表 11-1. 批处理、流式处理和实时处理比较

吞吐量 延迟 示例应用程序
批处理 高(几小时到几天) 客户流失预测
流式处理 中等 中等(秒到分钟) 动态定价
实时处理 低(毫秒级) 在线广告竞价

批处理会定期生成预测并将结果写入持久存储以供其他地方提供服务。通常它是最便宜和最简单的部署选项,因为您只需在计划运行期间支付计算费用。批处理每个数据点的效率更高,因为在所有进行的预测中摊销更少的开销。这在 Spark 中特别明显,因为在驱动程序和执行器之间来回通信的开销很大——您不希望逐个数据点地进行预测!然而,它的主要缺点是延迟,因为通常会安排几小时或几天的时间来生成下一批预测。

流式处理在吞吐量和延迟之间提供了一个很好的权衡。您将持续对数据的微批次进行预测,并在几秒钟到几分钟内获取预测结果。如果您使用结构化流处理,几乎所有代码看起来都与批处理用例相同,这使得在这两个选项之间来回切换变得容易。使用流式处理,您将不得不为使用的虚拟机或计算资源支付费用,以确保系统能够持续运行,并确保正确配置流以实现容错和在数据传入出现峰值时提供缓冲。

实时部署优先考虑延迟而非吞吐量,并在几毫秒内生成预测。如果需求激增(例如,在假期期间的在线零售商),您的基础架构需要支持负载平衡,并能够扩展到许多并发请求。有时,当人们提到“实时部署”时,他们指的是实时提取预先计算的预测,但在这里,我们指的是实时生成模型预测。实时部署是 Spark 无法满足延迟要求的唯一选项,因此如果要使用它,您需要在 Spark 之外导出您的模型。例如,如果您打算使用 REST 端点进行实时模型推断(例如,在 50 毫秒内计算预测),MLlib 无法满足此应用所需的延迟要求,如图 11-5 所示。您需要将特征准备和模型移出 Spark,这可能是耗时且困难的过程。

MLlib 的部署选项

图 11-5. MLlib 的部署选项

在开始建模过程之前,您需要定义您的模型部署要求。MLlib 和 Spark 只是您工具箱中的几个工具,您需要理解何时以及在何处应用它们。本节的其余部分将更深入地讨论 MLlib 的部署选项,然后我们将考虑非 MLlib 模型的 Spark 部署选项。

批量

批量部署代表了部署机器学习模型的大多数用例,并且这可能是最容易实现的选项。您将运行定期作业来生成预测,并将结果保存到表格、数据库、数据湖等以供下游消费。事实上,您已经在第十章中看到了如何使用 MLlib 生成批量预测。MLlib 的model.transform()将并行地将模型应用于 DataFrame 的所有分区:

# In Python
# Load saved model with MLflow
import mlflow.spark
pipelineModel = mlflow.spark.load_model(f"runs:/{run_id}/model")

# Generate predictions
inputDF = spark.read.parquet("/databricks-datasets/learning-spark-v2/
  sf-airbnb/sf-airbnb-clean.parquet")

predDF = pipelineModel.transform(inputDF)

批量部署需要注意以下几点:

您将多频繁地生成预测?

存在延迟和吞吐量之间的权衡。批量将多个预测批次在一起可以获得更高的吞吐量,但接收任何单个预测结果的时间会更长,延迟您对这些预测的行动能力。

您会多频繁地重新训练模型?

不像像 sklearn 或 TensorFlow 这样的库,MLlib 不支持在线更新或热启动。如果您想重新训练模型以整合最新数据,您需要从头开始重新训练整个模型,而不是利用现有参数。关于重新训练的频率,有些人会设置定期作业来重新训练模型(例如,每月一次),而其他人则会积极监控模型漂移以确定何时需要重新训练。

您将如何对模型进行版本管理?

您可以使用MLflow 模型注册表来跟踪您正在使用的模型,并控制它们如何在暂存、生产和归档之间过渡。您可以在图 11-6 中查看模型注册表的截图。您还可以将模型注册表与其他部署选项一起使用。

MLflow 模型注册表

图 11-6. MLflow 模型注册表

除了使用 MLflow UI 来管理您的模型外,您还可以以编程方式管理它们。例如,一旦您注册了生产模型,它就有一个一致的 URI,您可以使用它来检索最新版本:

# Retrieve latest production model
model_production_uri = f"models:/{model_name}/production"
model_production = mlflow.spark.load_model(model_production_uri)

流式处理

不必等待每小时或每夜的作业来处理数据并生成预测,结构化流处理可以连续对传入数据进行推断。虽然这种方法比批处理解决方案更昂贵,因为您必须持续支付计算时间(并且获得较低的吞吐量),但您可以更频繁地生成预测,以便更早地采取行动。总体而言,流处理解决方案比批处理解决方案更复杂,需要更多的维护和监控工作,但提供更低的延迟。

使用 Spark,将批量预测转换为流式预测非常容易,实际上所有的代码都是一样的。唯一的区别在于,在读取数据时,您需要使用spark.readStream()而不是spark.read()并更改数据源。在下面的示例中,我们将通过流式处理一组 Parquet 文件的方式模拟读取流数据。您会注意到,尽管我们使用 Parquet 文件,但我们仍需要定义模式,因为在处理流数据时需要事先定义模式。在此示例中,我们将使用在前一章节中基于我们的 Airbnb 数据集训练的随机森林模型来执行这些流式预测。我们将使用 MLflow 加载保存的模型。我们将源文件分成了一百个小的 Parquet 文件,以便您可以在每个触发间隔看到输出变化:

# In Python
# Load saved model with MLflow
pipelineModel = mlflow.spark.load_model(f"runs:/{run_id}/model")

# Set up simulated streaming data
repartitionedPath = "/databricks-datasets/learning-spark-v2/sf-airbnb/
  sf-airbnb-clean-100p.parquet"
schema = spark.read.parquet(repartitionedPath).schema

streamingData = (spark
                 .readStream
                 .schema(schema) # Can set the schema this way
                 .option("maxFilesPerTrigger", 1)
                 .parquet(repartitionedPath))

# Generate predictions
streamPred = pipelineModel.transform(streamingData)

在生成这些预测之后,您可以将它们写入任何目标位置,以便稍后检索(请参阅第 8 章中关于结构化流处理的提示)。正如您所见,批处理和流处理场景之间的代码几乎没有变化,这使得 MLlib 成为两者的良好解决方案。然而,根据任务对延迟的需求不同,MLlib 可能不是最佳选择。使用 Spark 在生成查询计划和在驱动程序与工作节点之间传输任务和结果时会涉及显著的开销。因此,如果您需要真正低延迟的预测,您需要将模型导出到 Spark 之外。

实时推理的模型导出模式

有一些领域需要实时推断,包括欺诈检测、广告推荐等。尽管使用少量记录进行预测可以达到实时推断所需的低延迟,但您将需要处理负载平衡(处理许多并发请求)以及延迟关键任务中的地理位置。有一些流行的托管解决方案,例如 AWS SageMakerAzure ML,提供了低延迟模型服务解决方案。在本节中,我们将展示如何导出您的 MLlib 模型,以便部署到这些服务中。

将模型从 Spark 导出的一种方法是在 Python、C 等中本地重新实现模型。虽然提取模型的系数似乎很简单,但连同特征工程和预处理步骤(OneHotEncoderVectorAssembler 等)导出时会很麻烦,并且容易出错。有一些开源库,如 MLeapONNX,可以帮助您自动导出 MLlib 模型的支持子集,以消除它们对 Spark 的依赖。然而,到撰写本文时,开发 MLeap 的公司已不再支持它。MLeap 也尚不支持 Scala 2.12/Spark 3.0。

另一方面,ONNX(开放神经网络交换)已成为机器学习互操作性的事实开放标准。您可能还记得其他机器学习互操作格式,如 PMML(预测模型标记语言),但它们从未像现在的 ONNX 那样广受欢迎。在深度学习社区中,ONNX 作为一种工具非常受欢迎,它允许开发人员轻松在不同库和语言之间切换,并在撰写本文时,它已经具有对 MLlib 的实验性支持。

与导出 MLlib 模型不同,还有其他第三方库与 Spark 集成,方便在实时场景中部署,例如 XGBoost 和 H2O.ai 的 Sparkling Water(其名称源自 H2O 和 Spark 的结合)。

XGBoost 是结构化数据问题中 最成功的算法之一Kaggle 竞赛 中,它是数据科学家中非常流行的库。虽然 XGBoost 技术上不属于 MLlib 的一部分,但 XGBoost4J-Spark 库 允许您将分布式 XGBoost 集成到 MLlib 流水线中。XGBoost 的一个好处是部署的简易性:在训练 MLlib 流水线后,您可以提取 XGBoost 模型并保存为非 Spark 模型,用于 Python 服务,如此展示:

// In Scala
val xgboostModel = 
  xgboostPipelineModel.stages.last.asInstanceOf[XGBoostRegressionModel]
xgboostModel.nativeBooster.saveModel(nativeModelPath)
# In Python
import xgboost as xgb
bst = xgb.Booster({'nthread': 4})
bst.load_model("xgboost_native_model")
注意

到撰写本文时,分布式 XGBoost API 仅在 Java/Scala 中可用。书中的示例可以在 GitHub 仓库 找到。

现在您已经了解了不同的导出 MLlib 模型以在实时服务环境中使用的方式,请讨论我们如何利用 Spark 对非 MLlib 模型进行优化。

利用 Spark 进行非 MLlib 模型

正如之前提到的,MLlib 并不总是满足您的机器学习需求的最佳解决方案。它可能无法满足超低延迟推断要求,或者没有内置支持您想要使用的算法。对于这些情况,您仍然可以利用 Spark,但不使用 MLlib。在本节中,我们将讨论如何使用 Spark 执行单节点模型的分布式推断,使用 Pandas UDFs 进行超参数调整和特征工程的扩展。

Pandas UDFs

虽然 MLlib 在分布式训练模型方面非常出色,但您不仅限于仅使用 MLlib 在 Spark 中进行批处理或流式预测,您可以创建自定义函数以在规模化时应用您预先训练的模型,称为用户定义函数(UDFs,在 第五章 中有介绍)。一个常见的用例是在单台机器上构建 scikit-learn 或 TensorFlow 模型,可能在数据子集上,但使用 Spark 在整个数据集上进行分布式推断。

如果您在 Python 中定义自己的 UDF 来将模型应用于 DataFrame 的每条记录,请选择 pandas UDFs 进行优化的序列化和反序列化,如 第五章 中所讨论的。但是,如果您的模型非常庞大,则 Pandas UDF 对于每个批次重复加载相同模型会有很高的开销。在 Spark 3.0 中,Pandas UDF 可以接受 pandas.Seriespandas.DataFrame 的迭代器,因此您只需加载一次模型,而不是在迭代器中的每个系列中重复加载。有关 Apache Spark 3.0 中 Pandas UDF 的新功能详情,请参阅 第十二章。

注意

如果工作节点在第一次加载后缓存了模型权重,使用相同模型加载的同一个 UDF 的后续调用将变得显著更快。

在下面的示例中,我们将使用 Spark 3.0 中引入的 mapInPandas() 来将 scikit-learn 模型应用于我们的 Airbnb 数据集。mapInPandas() 接受 pandas.DataFrame 的迭代器作为输入,并输出另一个 pandas.DataFrame 的迭代器。如果您的模型需要所有列作为输入,它非常灵活且易于使用,但需要整个 DataFrame 的序列化/反序列化(因为它传递给其输入)。您可以通过 spark.sql.execution.arrow.maxRecordsPerBatch 配置来控制每个 pandas.DataFrame 的大小。可以在本书的 GitHub 仓库 中找到生成模型的完整代码,但在这里我们只关注从 MLflow 加载保存的 scikit-learn 模型并将其应用于我们的 Spark DataFrame:

# In Python
import mlflow.sklearn
import pandas as pd

def predict(iterator):
  model_path = f"runs:/{run_id}/random-forest-model"
  model = mlflow.sklearn.load_model(model_path) # Load model
  for features in iterator:
    yield pd.DataFrame(model.predict(features))

df.mapInPandas(predict, "prediction double").show(3)

+-----------------+
|       prediction|
+-----------------+
| 90.4355866254844|
|255.3459534312323|
| 499.625544914651|
+-----------------+

除了使用 Pandas UDF 扩展规模应用模型之外,你还可以使用它们来并行化构建多个模型的过程。例如,你可能希望为每种 IoT 设备类型构建一个模型来预测故障时间。你可以使用 pyspark.sql.GroupedData.applyInPandas()(在 Spark 3.0 中引入)来完成此任务。该函数接受一个 pandas.DataFrame 并返回另一个 pandas.DataFrame。本书的 GitHub 存储库包含了构建每种 IoT 设备类型模型的完整代码示例,并使用 MLflow 跟踪各个模型;这里只包含了一个简短的片段以保持简洁:

# In Python
df.groupBy("device_id").applyInPandas(build_model, schema=trainReturnSchema)

groupBy() 会导致数据集完全洗牌,你需要确保每个组的模型和数据都能在一台机器上适合。你们中的一些人可能熟悉 pyspark.sql.GroupedData.apply()(例如,df.groupBy("device_id").apply(build_model)),但该 API 将在未来的 Spark 版本中弃用,推荐使用 pyspark.sql.GroupedData.applyInPandas()

现在你已经看到如何应用 UDF 进行分布式推断和并行化模型构建,让我们看看如何使用 Spark 进行分布式超参数调整。

Spark 用于分布式超参数调整

即使你不打算进行分布式推断或不需要 MLlib 的分布式训练能力,你仍然可以利用 Spark 进行分布式超参数调整。本节将特别介绍两个开源库:Joblib 和 Hyperopt。

Joblib

根据其文档,Joblib 是“提供 Python 中轻量级管道处理的一组工具”。它具有 Spark 后端,可在 Spark 集群上分发任务。Joblib 可用于超参数调整,因为它会自动将数据的副本广播到所有工作节点,然后在数据的各自副本上使用不同的超参数创建自己的模型。这允许你并行训练和评估多个模型。但你仍然有一个基本限制,即单个模型和所有数据必须适合一台机器,但你可以轻松地并行化超参数搜索,正如 图 11-7 中所示。

分布式超参数搜索

图 11-7. 分布式超参数搜索

要使用 Joblib,请通过 pip install joblibspark 进行安装。确保你使用的是 scikit-learn 版本 0.21 或更高以及 pyspark 版本 2.4.4 或更高。这里展示了一个分布式交叉验证的示例,相同的方法也适用于分布式超参数调整:

# In Python
from sklearn.utils import parallel_backend
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV
import pandas as pd
from joblibspark import register_spark

register_spark() # Register Spark backend

df = pd.read_csv("/dbfs/databricks-datasets/learning-spark-v2/sf-airbnb/
  sf-airbnb-numeric.csv")
X_train, X_test, y_train, y_test = train_test_split(df.drop(["price"], axis=1), 
  df[["price"]].values.ravel(), random_state=42)

rf = RandomForestRegressor(random_state=42)
param_grid = {"max_depth": [2, 5, 10], "n_estimators": [20, 50, 100]}
gscv = GridSearchCV(rf, param_grid, cv=3)

with parallel_backend("spark", n_jobs=3):
  gscv.fit(X_train, y_train)

print(gscv.cv_results_)

查看 scikit-learn GridSearchCV 文档,了解交叉验证器返回的参数解释。

Hyperopt

Hyperopt 是一个 Python 库,用于“在棘手的搜索空间上进行串行和并行优化,这些空间可能包括实值、离散和条件维度”。您可以通过 pip install hyperopt 安装它。有两种主要方法可以使用 Apache Spark 扩展 Hyperopt

  • 使用单机 Hyperopt 与分布式训练算法(例如 MLlib)

  • 使用 SparkTrials 类的分布式 Hyperopt 与单机训练算法

对于前一种情况,与使用 Hyperopt 与任何其他库相比,您无需特别配置 MLlib。所以,让我们看看后一种情况:单节点模型的分布式 Hyperopt。不幸的是,您目前无法将分布式超参数评估与分布式训练模型结合起来。有关为 Keras 模型并行化超参数搜索的完整代码示例可以在该书的 GitHub 仓库 中找到;这里仅包含一个片段,以说明 Hyperopt 的关键组成部分:

# In Python
import hyperopt

best_hyperparameters = hyperopt.fmin(
  fn = training_function,
  space = search_space,
  algo = hyperopt.tpe.suggest,
  max_evals = 64,
  trials = hyperopt.SparkTrials(parallelism=4))

fmin() 生成新的超参数配置,用于您的 training_function 并传递给 SparkTrialsSparkTrials 将这些训练任务的批次作为单个 Spark 作业并行运行在每个 Spark 执行器上。当 Spark 任务完成时,它将结果和相应的损失返回给驱动程序。Hyperopt 使用这些新结果来计算未来任务的更好超参数配置。这允许进行大规模超参数调整。MLflow 也与 Hyperopt 集成,因此您可以跟踪您作为超参数调整一部分训练的所有模型的结果。

SparkTrials 的一个重要参数是 parallelism。这确定同时评估的最大试验数。如果 parallelism=1,则您是按顺序训练每个模型,但通过充分利用自适应算法,您可能会获得更好的模型。如果设置 parallelism=max_evals(要训练的总模型数),那么您只是在进行随机搜索。在 1max_evals 之间的任何数字允许您在可扩展性和适应性之间进行权衡。默认情况下,parallelism 设置为 Spark 执行器的数量。您还可以指定 timeout 来限制 fmin() 允许花费的最大秒数。

即使 MLlib 对您的问题不合适,希望您能看到在任何机器学习任务中使用 Spark 的价值。

摘要

在本章中,我们涵盖了多种管理和部署机器学习流水线的最佳实践。您看到了 MLflow 如何帮助您跟踪和重现实验,并将您的代码及其依赖打包以便在其他地方部署。我们还讨论了主要的部署选项——批处理、流处理和实时处理——及其相关的权衡取舍。MLlib 是大规模模型训练和批处理/流处理用例的绝佳解决方案,但在小数据集上进行实时推断时,它无法与单节点模型相提并论。您的部署需求直接影响您可以使用的模型和框架类型,因此在开始模型构建过程之前讨论这些需求至关重要。

在下一章中,我们将重点介绍 Spark 3.0 的几个关键新功能,以及如何将它们整合到您的 Spark 工作负载中。

第十二章:结语:Apache Spark 3.0

我们撰写本书时,Apache Spark 3.0 尚未正式发布;它仍在开发中,我们使用的是 Spark 3.0.0-preview2 版本。本书中的所有代码示例均经过 Spark 3.0.0-preview2 版本测试,且它们在正式发布的 Spark 3.0 版本中应该没有区别。在各章节中,尽可能地提及了 Spark 3.0 中新增的特性和行为变化。在本章中,我们对这些变化进行了概述。

修复的错误和功能增强非常多,为了简洁起见,我们仅突出了与 Spark 组件相关的一些显著变化和功能。一些新功能在幕后非常先进,超出了本书的范围,但我们在此提到它们,以便您在发布普遍可用时进行探索。

Spark Core 和 Spark SQL

让我们首先考虑一下底层的新变化。Spark Core 和 Spark SQL 引擎都引入了一些变化来加快查询速度。一种加快查询速度的方法是通过动态分区裁剪来减少读取的数据量。另一种方法是在执行期间调整和优化查询计划。

动态分区裁剪

动态分区裁剪(DPP) 的理念是跳过查询结果中不需要的数据。DPP 最适合的典型场景是在联接两个表时:一个是分区的事实表,另一个是非分区的维度表,如图 12-1 所示。通常,过滤器位于表的非分区一侧(在我们的例子中是Date)。例如,考虑以下在两个表SalesDate上执行的常见查询:

-- In SQL
SELECT * FROM Sales JOIN ON Sales.date = Date.date

动态过滤器从维度表注入到事实表中

图 12-1。动态过滤器从维度表注入到事实表中

DPP 中的关键优化技术是将来自维度表的过滤结果作为扫描操作的一部分注入到事实表中,以限制读取的数据量,如图 12-1 所示。

假设维度表比事实表小,并且我们进行了一个联接操作,如图 12-2 所示。在这种情况下,Spark 很可能会执行广播联接(在 第七章 中讨论)。在此联接过程中,Spark 将执行以下步骤来最小化从较大的事实表中扫描的数据量:

  1. 在联接的维度表侧,Spark 将从维度表构建一个哈希表,也称为构建关系,作为此过滤查询的一部分。

  2. Spark 将把此查询的结果插入到哈希表中,并将其分配给广播变量,该广播变量分发到所有参与此联接操作的执行器。

  3. 在每个执行器上,Spark 将探测广播的哈希表,以确定从事实表中读取哪些对应的行。

  4. 最后,Spark 将动态地将此过滤器注入到事实表的文件扫描操作中,并重用广播变量的结果。这样,在事实表的文件扫描操作中,只会扫描与过滤器匹配的分区,并且只会读取所需的数据。

Spark 将一个维度表过滤器注入事实表中的广播连接

图 12-2. Spark 在广播连接期间将一个维度表过滤器注入到事实表中

默认情况下启用,因此您无需显式配置,在执行两个表之间的连接时会动态发生这一切。通过 DPP 优化,Spark 3.0 可以更好地处理星型模式查询。

自适应查询执行

Spark 3.0 另一种优化查询性能的方法是在运行时调整其物理执行计划。自适应查询执行(AQE) 根据在查询执行过程中收集的运行时统计信息,重新优化和调整查询计划。它试图在运行时执行以下操作:

  • 通过减少洗牌阶段的 Reducer 数量来减少洗牌分区的数量。

  • 优化查询的物理执行计划,例如在适当的情况下将SortMergeJoin转换为BroadcastHashJoin

  • 处理连接期间的数据倾斜。

所有这些自适应措施都发生在运行时计划的执行过程中,如图 12-3 所示。要在 Spark 3.0 中使用 AQE,请将配置spark.sql.adaptive.enabled设置为true

AQE 在运行时重新检查并重新优化执行计划

图 12-3. AQE 在运行时重新检查并重新优化执行计划

AQE 框架

查询中的 Spark 操作被流水线化并并行执行,但是洗牌或广播交换会打破这个流水线,因为一个阶段的输出需要作为下一个阶段的输入(参见“第 3 步:理解 Spark 应用概念” 在第二章)。在查询阶段中,这些断点称为物化点,它们提供了重新优化和重新检查查询的机会,如图 12-4 所示。

在 AQE 框架中重新优化的查询计划

图 12-4. 在 AQE 框架中重新优化的查询计划

这里是 AQE 框架迭代的概念步骤,如图所示:

  1. 执行每个阶段的所有叶节点,例如扫描操作。

  2. 一旦物化点执行完成,它被标记为完成,并且在其逻辑计划中更新所有相关的统计信息。

  3. 基于这些统计数据,如读取的分区数、读取的数据字节数等,框架再次运行 Catalyst 优化器,以了解它是否可以:

    1. 合并分区数以减少读取洗牌数据的减少器数量。

    2. 根据读取的表的大小,用广播连接替换排序合并连接。

    3. 尝试修复倾斜连接。

    4. 创建一个新的优化逻辑计划,然后创建一个新的优化物理计划。

直到查询计划的所有阶段都执行完毕为止,该过程将重复进行。

简而言之,这种重新优化是动态进行的,如图 12-3 所示,其目标是动态合并洗牌分区、减少需要读取洗牌输出数据的减少器数量、在适当时切换连接策略并修复任何倾斜连接。

两个 Spark SQL 配置决定了 AQE 如何减少减少器的数量:

  • spark.sql.adaptive.coalescePartitions.enabled(设置为true

  • spark.sql.adaptive.skewJoin.enabled(设置为true

在撰写本文时,Spark 3.0 社区博客、文档和示例尚未公开发布,但在出版时它们应该已经发布。如果您希望了解这些功能在幕后的工作原理——包括如何注入 SQL 连接提示,我们将在接下来讨论。

SQL 连接提示

除了连接的现有BROADCAST提示外,Spark 3.0 还为所有Spark 连接策略添加了连接提示(参见“Spark 连接家族”中的第七章)。下面为每种连接类型提供了示例。

洗牌排序合并连接(SMJ)

借助这些新提示,您可以建议 Spark 在连接表abcustomersorders时执行SortMergeJoin,如下例所示。您可以在/*+ ... */注释块内的SELECT语句中添加一个或多个提示:

SELECT /*+ MERGE(a, b) */ id FROM a JOIN b ON a.key = b.key
SELECT /*+ MERGE(customers, orders) */ * FROM customers, orders WHERE 
    orders.custId = customers.custId

广播哈希连接(BHJ)

同样地,对于广播哈希连接,您可以向 Spark 提供提示,表明您更喜欢广播连接。例如,在这里我们将表a广播连接到表b和表customers广播连接到表orders

SELECT /*+ BROADCAST(a) */ id FROM a JOIN b ON a.key = b.key
SELECT /*+ BROADCAST(customers) */ * FROM customers, orders WHERE 
    orders.custId = customers.custId

洗牌哈希连接(SHJ)

您可以以类似的方式提供提示以执行洗牌哈希连接,尽管这比前两种支持的连接策略更少见:

SELECT /*+ SHUFFLE_HASH(a, b) */ id FROM a JOIN b ON a.key = b.key
SELECT /*+ SHUFFLE_HASH(customers, orders) */ * FROM customers, orders WHERE 
    orders.custId = customers.custId

洗牌并复制嵌套循环连接(SNLJ)

最后,洗牌并复制嵌套循环连接遵循相似的形式和语法:

SELECT /*+ SHUFFLE_REPLICATE_NL(a, b) */ id FROM a JOIN b

目录插件 API 和 DataSourceV2

Spark 3.0 的实验性 DataSourceV2 API 不仅限于 Hive 元数据存储和目录,还扩展了 Spark 生态系统,并为开发人员提供了三个核心能力。具体来说,它:

  • 允许插入外部数据源以进行目录和表管理

  • 支持将谓词下推到其他数据源,支持的文件格式如 ORC、Parquet、Kafka、Cassandra、Delta Lake 和 Apache Iceberg。

  • 为流数据源和汇提供统一的 API,支持数据的批处理和流处理

针对希望扩展 Spark 使用外部源和汇的开发人员,Catalog API 提供了 SQL 和编程 API,以从指定的可插拔目录创建、修改、加载和删除表。该目录提供了不同层次上执行的功能和操作的分层抽象,如图 12-5 所示。

Catalog plugin API’s hierarchical level of functionality

图 12-5. Catalog plugin API 的功能层次结构

Spark 与特定连接器的初始交互是将关系解析为其实际的Table对象。Catalog定义了如何在此连接器中查找表。此外,Catalog还可以定义如何修改其自身的元数据,从而实现像CREATE TABLEALTER TABLE等操作。

例如,在 SQL 中,您现在可以发出命令为您的目录创建命名空间。要使用可插拔目录,在您的spark-defaults.conf文件中启用以下配置:

spark.sql.catalog.ndb_catalog com.ndb.ConnectorImpl # connector implementation
spark.sql.catalog.ndb_catalog.option1  value1
spark.sql.catalog.ndb_catalog.option2  value2

在这里,与数据源目录的连接器有两个选项:option1->value1option2->value2。一旦它们被定义,Spark 或 SQL 中的应用程序用户可以使用DataFrameReaderDataFrameWriter API 方法或具有这些定义选项的 Spark SQL 命令作为数据源操作的方法。例如:

-- In SQL
SHOW TABLES ndb_catalog;
CREATE TABLE ndb_catalog.table_1;
SELECT * from ndb_catalog.table_1;
ALTER TABLE ndb_catalog.table_1
// In Scala 
df.writeTo("ndb_catalog.table_1")
val dfNBD = spark.read.table("ndb_catalog.table_1")
  .option("option1", "value1")
  .option("option2", "value2")

尽管这些目录插件 API 扩展了 Spark 利用外部数据源作为汇和源的能力,但它们仍处于试验阶段,不应在生产环境中使用。本书不涵盖其详细使用指南,但如果您希望编写自定义连接器以将外部数据源作为目录来管理您的外部表及其相关元数据,则建议您查阅发布文档以获取更多信息。

注意

以下是在您定义和实现目录连接器并用数据填充后,您的代码可能看起来像的示例代码片段。

加速器感知调度器

Project Hydrogen,一个将人工智能和大数据结合起来的社区倡议,有三个主要目标:实现障碍执行模式、加速器感知调度和优化数据交换。Apache Spark 2.4.0 引入了障碍执行模式的基本实现。在 Spark 3.0 中,已实现了基本的调度器,以利用目标平台上 Spark 独立模式、YARN 或 Kubernetes 上的 GPU 等硬件加速器。

要使 Spark 以组织良好的方式利用这些 GPU 用于使用它们的专门工作负载,您必须通过配置指定可用的硬件资源。然后,您的应用程序可以通过发现脚本帮助发现它们。在 Spark 应用程序中启用 GPU 使用是一个三步骤过程:

  1. 编写一个发现脚本,以发现每个 Spark 执行器上可用的底层 GPU 的地址。此脚本设置在以下 Spark 配置中:

    spark.worker.resource.gpu.discoveryScript=*/path/to/script.sh*
    
  2. 配置您的 Spark 执行器以使用这些发现的 GPU:

    spark.executor.resource.gpu.amount=2
    spark.task.resource.gpu.amount=1
    
  3. 编写 RDD 代码以利用这些 GPU 完成您的任务:

    import org.apache.spark.BarrierTaskContext
    val rdd = ...
    rdd.barrier.mapPartitions { it =>
      val context = BarrierTaskContext.getcontext.barrier()
      val gpus = context.resources().get("gpu").get.addresses
      // launch external process that leverages GPU
      launchProcess(gpus)
    }
    
注意

这些步骤仍处于实验阶段,未来 Spark 3.x 的进一步开发将继续支持无缝发现 GPU 资源,无论是在命令行(使用 spark-submit)还是在 Spark 任务级别。

结构化流

要检查您的结构化流作业在执行过程中随数据的起伏如何变化,Spark 3.0 UI 有一个新的结构化流选项卡,与我们在 第七章 探索的其他选项卡并列。此选项卡提供两组统计信息:关于已完成的流查询作业的聚合信息(图 12-6)和关于流查询的详细统计信息,包括输入速率、处理速率、输入行数、批处理持续时间和操作持续时间(图 12-7)。

显示已完成流作业的聚合统计信息的结构化流选项卡

图 12-6. 结构化流选项卡显示已完成流作业的聚合统计信息
注意

使用 Spark 3.0.0-preview2 截取的 图 12-7 屏幕截图;在最终版本中,您应该在 UI 页面的名称标识符中看到查询名称和 ID。

显示已完成流作业的详细统计信息

图 12-7. 显示已完成流作业的详细统计信息

不需要任何配置;所有配置都在 Spark 3.0 安装后直接工作,默认情况如下:

  • spark.sql.streaming.ui.enabled=true

  • spark.sql.streaming.ui.retainedProgressUpdates=100

  • spark.sql.streaming.ui.retainedQueries=100

PySpark、Pandas UDF 和 Pandas 函数 API

Spark 3.0 需要 pandas 版本 0.23.2 或更高版本来使用任何与 pandas 相关的方法,例如 DataFrame.toPandas()SparkSession.createDataFrame(pandas.DataFrame)

此外,要使用 PyArrow 功能(如 pandas_udf()DataFrame.toPandas()SparkSession.createDataFrame(pandas.DataFrame)),需要 PyArrow 版本 0.12.1 或更高版本,并将 spark.sql.execution.arrow.enabled 配置设置为 true。接下来的部分将介绍 Pandas UDF 中的新功能。

使用 Python 类型提示重新设计的 Pandas UDFs

Spark 3.0 中的 Pandas UDFs 经过重新设计,利用了 Python 类型提示。这使您可以自然地表达 UDFs,而无需评估类型。现在,Pandas UDFs 更具“Pythonic”风格,并且可以自行定义 UDF 应输入和输出的内容,而不需要您像在 Spark 2.4 中那样通过 @pandas_udf("long", PandasUDFType.SCALAR) 指定。

以下是一个示例:

# Pandas UDFs in Spark 3.0
import pandas as pd
from pyspark.sql.functions import pandas_udf

@pandas_udf("long")
def pandas_plus_one(v: pd.Series) -> pd.Series:
  return v + 1

这种新格式带来了多个好处,例如更容易的静态分析。您可以像以前一样应用新的 UDFs:

df = spark.range(3)
df.withColumn("plus_one", pandas_plus_one("id")).show()

+---+--------+
| id|plus_one|
+---+--------+
|  0|       1|
|  1|       2|
|  2|       3|
+---+--------+

Pandas UDFs 中的迭代器支持

Pandas UDFs 通常用于加载模型并对单节点机器学习和深度学习模型执行分布式推断。然而,如果模型非常大,则 Pandas UDF 为了在同一 Python 工作进程中的每个批次中重复加载相同的模型会产生高额开销。

在 Spark 3.0 中,Pandas UDFs 可以接受 pandas.Seriespandas.DataFrame 的迭代器,如下所示:

from typing import Iterator      

@pandas_udf('long')
def pandas_plus_one(iterator: Iterator[pd.Series]) -> Iterator[pd.Series]:
    return map(lambda s: s + 1, iterator)

df.withColumn("plus_one", pandas_plus_one("id")).show()

+---+--------+
| id|plus_one|
+---+--------+
|  0|       1|
|  1|       2|
|  2|       3|
+---+--------+

借助此支持,您可以仅在加载模型一次而不是在迭代器中的每个系列中加载它。以下伪代码说明了如何做到这一点:

@pandas_udf(...)
def predict(iterator):
  model = ... # load model
  for features in iterator:
    yield model.predict(features)

新的 Pandas 函数 APIs

Spark 3.0 引入了几种新类型的 Pandas UDFs,当您希望对整个 DataFrame 而不是列进行函数应用时非常有用,例如 mapInPandas(),在 第十一章 中引入。它们接受 pandas.DataFrame 的迭代器作为输入,并输出另一个 pandas.DataFrame 的迭代器:

def pandas_filter(
    iterator: Iterator[pd.DataFrame]) -> Iterator[pd.DataFrame]:
  for pdf in iterator:
    yield pdf[pdf.id == 1]

df.mapInPandas(pandas_filter, schema=df.schema).show()

+---+
| id|
+---+
|  1|
+---+

您可以通过在 spark.sql.execution.arrow.maxRecordsPerBatch 配置中指定来控制 pandas.DataFrame 的大小。请注意,输入大小和输出大小不必匹配,这与大多数 Pandas UDFs 不同。

注意

所有 cogroup 的数据将加载到内存中,这意味着如果存在数据倾斜或某些组过大而无法适应内存,可能会遇到 OOM 问题。

Spark 3.0 还引入了 cogrouped map Pandas UDFs。applyInPandas() 函数接受两个共享公共键的 pandas.DataFrame,并对每个 cogroup 应用函数。然后将返回的 pandas.DataFrame 合并为单个 DataFrame。与 mapInPandas() 一样,返回的 pandas.DataFrame 的长度没有限制。以下是一个示例:

df1 = spark.createDataFrame(
    [(1201, 1, 1.0), (1201, 2, 2.0), (1202, 1, 3.0), (1202, 2, 4.0)],
    ("time", "id", "v1"))
df2 = spark.createDataFrame(
    [(1201, 1, "x"), (1201, 2, "y")], ("time", "id", "v2"))

def asof_join(left: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame:
    return pd.merge_asof(left, right, on="time", by="id")

df1.groupby("id").cogroup(
    df2.groupby("id")
).applyInPandas(asof_join, "time int, id int, v1 double, v2 string").show()

+----+---+---+---+
|time| id| v1| v2|
+----+---+---+---+
|1201|  1|1.0|  x|
|1202|  1|3.0|  x|
|1201|  2|2.0|  y|
|1202|  2|4.0|  y|
+----+---+---+---+

功能变更

如果列出 Spark 3.0 中所有功能的更改,会使这本书变成几英寸厚的砖头。因此,为了简洁起见,我们在此提及几个显著的更改,并建议您查阅 Spark 3.0 的发行说明,以获取完整的详细信息和所有细微差别。

支持的语言和已弃用的语言

Spark 3.0 支持 Python 3 和 JDK 11,并且需要 Scala 版本 2.12。所有早于 3.6 版本的 Python 和 Java 8 都已不推荐使用。如果您使用这些已弃用版本,将收到警告消息。

DataFrame 和 Dataset APIs 的变更

在之前的 Spark 版本中,Dataset 和 DataFrame 的 API 已弃用了 unionAll() 方法。在 Spark 3.0 中,这一点已被颠倒过来,unionAll() 现在是 union() 方法的别名。

此外,在较早版本的 Spark 中,Dataset.groupByKey() 结果会使得非结构化类型(intstringarray 等)的键在显示时具有误导性地命名为 value。因此,查询中的聚合结果看起来反直觉,例如 (value, count)。这已被更正为更直观的 (key, count)。例如:

//  In Scala
val ds = spark.createDataset(Seq(20, 3, 3, 2, 4, 8, 1, 1, 3))
ds.show(5)

+-----+
|value|
+-----+
|   20|
|    3|
|    3|
|    2|
|    4|
+-----+

ds.groupByKey(k=> k).count.show(5)

+---+--------+
|key|count(1)|
+---+--------+
|  1|       2|
|  3|       3|
| 20|       1|
|  4|       1|
|  8|       1|
+---+--------+
only showing top 5 rows

然而,如果您愿意,可以通过将 spark.sql.legacy.dataset.nameNonStructGroupingKeyAsValue 设置为 true 来保留旧格式。

DataFrame 和 SQL Explain 命令

为了提高可读性和格式化,Spark 3.0 引入了 DataFrame.explain(*FORMAT_MODE*) 功能,以显示 Catalyst 优化器生成的计划的不同视图。*FORMAT_MODE* 选项包括 "simple"(默认)、"extended""cost""codegen""formatted"。这里是一个简单的示例:

// In Scala
val strings = spark
 .read.text("/databricks-datasets/learning-spark-v2/SPARK_README.md")
val filtered = strings.filter($"value".contains("Spark"))
filtered.count()
# In Python
strings = spark
 .read.text("/databricks-datasets/learning-spark-v2/SPARK_README.md")
filtered = strings.filter(strings.value.contains("Spark"))
filtered.count()
// In Scala
filtered.explain("simple")
# In Python
filtered.explain(mode="simple")

== Physical Plan ==
*(1) Project [value#72]
+- *(1) Filter (isnotnull(value#72) AND Contains(value#72, Spark))
   +- FileScan text [value#72] Batched: false, DataFilters: [isnotnull(value#72),
Contains(value#72, Spark)], Format: Text, Location:
InMemoryFileIndex[dbfs:/databricks-datasets/learning-spark-v2/SPARK_README.md],
PartitionFilters: [], PushedFilters: [IsNotNull(value),
StringContains(value,Spark)], ReadSchema: struct<value:string>
// In Scala
filtered.explain("formatted")
# In Python
filtered.explain(mode="formatted")

== Physical Plan ==
* Project (3)
+- * Filter (2)
   +- Scan text  (1)

(1) Scan text  
Output [1]: [value#72]
Batched: false
Location: InMemoryFileIndex [dbfs:/databricks-datasets/learning-spark-v2/...
PushedFilters: [IsNotNull(value), StringContains(value,Spark)]
ReadSchema: struct<value:string>

(2) Filter [codegen id : 1]
Input [1]: [value#72]
Condition : (isnotnull(value#72) AND Contains(value#72, Spark))

(3) Project [codegen id : 1]
Output [1]: [value#72]
Input [1]: [value#72]
-- In SQL
EXPLAIN FORMATTED 
SELECT * 
FROM tmp_spark_readme 
WHERE value like "%Spark%"

== Physical Plan ==
* Project (3)
+- * Filter (2)
   +- Scan text  (1)

(1) Scan text 
Output [1]: [value#2016]
Batched: false
Location: InMemoryFileIndex [dbfs:/databricks-datasets/
learning-spark-v2/SPARK_README.md]
PushedFilters: [IsNotNull(value), StringContains(value,Spark)]
ReadSchema: struct<value:string>

(2) Filter [codegen id : 1]
Input [1]: [value#2016]
Condition : (isnotnull(value#2016) AND Contains(value#2016, Spark))

(3) Project [codegen id : 1]
Output [1]: [value#2016]
Input [1]: [value#2016]

要查看其它格式模式的示例,您可以尝试书中的笔记本 GitHub 仓库。同时查看从 Spark 2.x 迁移到 Spark 3.0 的 迁移指南

总结

本章简要介绍了 Spark 3.0 的新功能亮点。我们随意提到了一些值得注意的高级功能。这些功能在 API 层面之下运作。特别是,我们研究了动态分区剪裁(DPP)和自适应查询执行(AQE)这两种优化,它们在执行时提升了 Spark 的性能。我们还探讨了实验性的 Catalog API 如何将 Spark 生态系统扩展到自定义数据存储,供批处理和流处理数据的源和汇。我们还看了 Spark 3.0 中的新调度器,使其能够利用执行器中的 GPU。

在我们讨论 Spark UI 的补充中,第七章 我们还向您展示了新的结构化流选项卡,提供了关于流作业的累积统计信息、额外的可视化效果以及每个查询的详细指标。

在 Spark 3.0 中,Python 版本低于 3.6 已被弃用,并且 Pandas UDF 已被重新设计以支持 Python 类型提示和迭代器作为参数。有 Pandas UDF 可以用于转换整个 DataFrame,以及将两个共组合的 DataFrame 合并为一个新的 DataFrame。

为了更好地阅读查询计划,DataFrame.explain(*FORMAT_MODE*) 和 SQL 中的 EXPLAIN *FORMAT_MODE* 显示逻辑和物理计划的不同级别和细节。此外,SQL 命令现在可以为 Spark 支持的整个连接家族提供连接提示。

尽管我们无法在本短章节中详细列出最新版本 Spark 中的所有更改,但我们建议您在 Spark 3.0 发布时查阅发布说明以获取更多信息。此外,为了快速了解用户界面的变化以及如何迁移到 Spark 3.0,请参阅迁移指南。

作为提醒,本书中的所有代码都已在 Spark 3.0.0-preview2 上进行了测试,并应在 Spark 3.0 正式发布时正常工作。希望您享受阅读本书,并从这段旅程中收获良多。感谢您的关注!

posted @ 2025-11-19 09:22  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报