Spark3-入门指南-全-
Spark3 入门指南(全)
一、Apache Spark 简介
没有比现在更好的学习 Apache Spark 的时机了。由于其易用性、速度和灵活性,它已成为大数据堆栈中的关键组件之一。多年来,它已成为多种工作负载类型的统一引擎,如大数据处理、数据分析、数据科学和机器学习。许多行业的公司广泛采用这种可扩展的数据处理系统,包括脸书、微软、网飞和 LinkedIn。此外,它在每个主要版本中都得到了稳步的改进。
Apache Spark 的最新版本是 3.0,于 2020 年 6 月发布,标志着 Spark 作为开源项目的十周年纪念。这个版本包括对 Spark 许多方面的增强。值得注意的增强是创新的实时性能优化技术,以加速 Spark 应用程序,并帮助减少开发人员调整 Spark 应用程序所需的时间和精力。
本章提供了 Spark 的高级概述,包括核心概念、架构和 Apache Spark 堆栈中的各种组件。
概观
Spark 是一个通用的分布式数据处理引擎,旨在提高速度、易用性和灵活性。这三个属性的结合使得 Spark 如此受欢迎,并在行业中被广泛采用。
Apache Spark 网站声称,它运行某些数据处理作业的速度比 Hadoop MapReduce 快 100 倍。事实上,在 2014 年,Spark 赢得了 Daytona GraySort 大赛,这是一个行业基准,看看一个系统能以多快的速度对 100TB 的数据(1 万亿条记录)进行排序。Databricks 提交的材料声称,Spark 可以使用比 Hadoop MapReduce 创下的世界纪录少 10 倍的资源,将 100 TB 的数据排序快 3 倍。
自从 Spark 项目开始以来,易用性一直是 Spark 创造者的主要关注点之一。它提供了 80 多个高级的、通常需要的数据处理操作符,使开发人员、数据科学家和分析人员可以轻松地使用它们来构建各种有趣的数据应用程序。此外,这些运算符有多种语言版本:Scala、Java、Python 和 r。软件工程师、数据科学家和数据分析师可以挑选自己喜欢的语言,用 Spark 解决大规模数据处理问题。
在灵活性方面,Spark 提供了一个统一的数据处理堆栈,可以解决多种类型的数据处理工作负载,包括批处理应用程序、交互式查询、需要多次迭代的机器学习算法以及实时流应用程序,以近乎实时地提取可操作的见解。在 Spark 出现之前,每种类型的工作负载都需要不同的解决方案和技术。现在,公司只需利用 Spark 来满足其所有数据处理需求,它可以显著降低运营成本和资源。
大数据生态系统由许多技术组成,包括 Hadoop 分布式文件系统 (HDFS),这是一个分布式存储引擎和集群管理系统,可以有效地管理一个机器集群和不同的文件格式,以二进制和列格式存储大量数据。Spark 与大数据生态系统集成良好。这是 Spark 采用率快速增长的另一个原因。
Spark 的另一个很酷的地方是它是开源的。因此,任何人都可以下载源代码来检查代码,弄清楚某个特性是如何实现的,并扩展其功能。在某些情况下,它可以极大地帮助减少调试问题的时间。
历史
Spark 始于 2009 年加州大学伯克利分校 AMPLab 的一个研究项目。当时,该项目的研究人员观察到 Hadoop MapReduce 框架在处理交互式和迭代数据处理用例方面的低效率,因此他们想出了通过引入内存存储和有效处理故障恢复的方法来克服这些低效率的方法。一旦这个研究项目被证明是优于 MapReduce 的可行解决方案。它于 2010 年开源,并于 2013 年成为 Apache 顶级项目。
参与这个研究项目的许多研究人员成立了一家名为 Databricks 的公司,他们在 2013 年筹集了超过 4300 万美元。Databricks 是 Spark 背后的主要商业管家。2015 年,IBM 宣布了一项重大投资,旨在建立一个 Spark 技术中心,通过与开源社区密切合作来推进 Apache Spark,并将 Spark 打造为公司分析和商业平台的核心。
关于 Spark 的两篇热门研究论文分别是《Spark:带工作集的集群计算》( http://people.csail.mit.edu/matei/papers/2010/hotcloud_spark.pdf
)和《弹性分布式数据集:内存集群计算的容错抽象》( http://people.csail.mit.edu/matei/papers/2012/nsdi_spark.pdf
)。这些论文在学术会议上广受好评,为任何想要学习和了解 Spark 的人提供了良好的基础。
从一开始,Spark 开源项目就是一个非常活跃的项目和社区。贡献者的数量增加了 1000 多个,有超过 20 万个 Apache Spark meetups。Apache Spark 贡献者的数量已经超过了广泛流行的 Apache Hadoop 的贡献者数量。
Spark 的创造者为他们的项目选择了 Scala 编程语言,因为它结合了 Scala 的简洁性和静态类型。现在,Spark 被认为是用 Scala 编写的最大的应用程序之一,它的流行无疑帮助 Scala 成为主流编程语言。
Spark 核心概念和架构
在深入 Spark 的细节之前,对核心概念和各种核心组件有一个高层次的理解是很重要的。本节包括以下内容。
-
Spark 簇
-
资源管理系统
-
Spark 应用
-
Spark 驱动器
-
Spark 执行者
Spark 集群和资源管理系统
Spark 本质上是一个分布式系统,旨在高效快速地处理大量数据。这种分布式系统通常部署在一组机器上,称为 Spark 集群。一个集群可以小到几台机器,也可以大到几千台机器。根据 https://spark.apache.org/faq.html
的 Spark FAQ,世界上最大的 Spark 集群有 8000 多台机器。
公司依靠像 Apache YARN 或 Apache Meso 这样的资源管理系统来高效、智能地管理一组机器。典型资源管理系统中的两个主要组件是集群管理器和工作器。主设备知道从设备的位置、内存大小以及每个从设备拥有的 CPU 内核数量。集群管理器的主要职责之一是通过向工作人员分配工作来协调工作。每个工作者提供资源(内存、CPU 等。)分配给集群管理器,并执行分配的工作。这类工作的一个例子是启动一个特定的流程并监控其运行状况。Spark 旨在轻松地与这些系统进行互操作。近年来,大多数采用大数据技术的公司都有一个 YARN 集群来运行 MapReduce 作业或其他数据处理框架,如 Apache Pig 或 Apache Hive。
完全采用 Spark 的创业公司可以使用现成的 Spark 集群管理器来管理一组专门使用 Spark 执行数据处理的机器。
Spark 应用
Spark 应用程序由两部分组成。一个是用 Spark APIs 表达的数据处理逻辑,一个是驱动。数据处理逻辑可以简单到只有几行代码来执行解决特定数据问题的几个数据处理操作,也可以复杂到训练一个复杂的机器学习模型,需要多次迭代并运行数小时才能完成。Spark 驱动程序实际上是 Spark 应用程序的中央协调器,它与集群管理器进行交互,以确定哪些机器运行数据处理逻辑。对于每一台机器,驱动程序请求集群管理器启动一个被称为执行器的进程。
Spark 驱动程序的另一项非常重要的工作是代表应用程序管理 Spark 任务并将其分配给每个执行器。如果数据处理逻辑要求 Spark 驱动器收集计算结果以呈现给用户,则它与每个 Spark 执行器协调以收集计算结果,并在将它们呈现给用户之前将它们合并在一起。Spark 驱动程序通过一个名为SparkSession
的组件执行任务。
Spark 驱动器和执行器
每个 Spark 执行器都是一个 JVM 进程,专用于特定的 Spark 应用程序。Spark 执行器的生命周期是 Spark 应用程序的持续时间,可能是几分钟或几天。有一个有意识的设计决策是不在不同的多个 Spark 应用程序之间共享 Spark 执行器。这样做的好处是将每个应用程序相互隔离。不过,如果不将数据写入像 HDFS 这样的外部存储系统,在不同的应用程序之间共享数据并不容易。
简而言之,Spark 采用了主/从架构,其中驱动程序是主设备,执行程序是从设备。这些组件中的每一个都作为一个独立的进程在 Spark 集群上运行。Spark 应用程序由一个驱动程序和一个或多个执行程序组成。作为从角色,Spark 执行器执行被告知的任务,即以任务的形式执行数据处理逻辑。每个任务都在独立的 CPU 内核上执行。这就是 Spark 并行处理数据以提高速度的方式。此外,当应用程序逻辑要求时,每个 Spark 执行器负责在内存和/或磁盘上缓存一部分数据。
启动 Spark 应用程序时,您可以指定应用程序需要的执行器数量,以及每个执行器应该拥有的内存量和 CPU 内核数量。
图 1-1 显示了 Spark 应用程序和集群管理器之间的交互。
图 1-2
由一个驱动程序和三个执行器组成的 Spark 集群
图 1-1
Spark 应用程序和集群管理器之间的交互
Spark 统一堆栈
与其前身不同,Spark 提供了一个统一的数据处理引擎,称为 Spark stack。像其他设计良好的系统一样,该堆栈建立在一个名为 Spark Core 的强大基础之上,它提供了管理和运行分布式应用程序所需的所有功能,如调度、协调和处理容错。此外,它为数据处理提供了一个强大的通用编程抽象,称为弹性分布式数据集 (RDDs)。在这个坚实的基础之上是一个库集合,其中每个库都是为特定的数据处理工作负载而设计的。Spark SQL 擅长交互式数据处理。Spark 流是实时数据处理。Spark GraphX 用于图形处理。Spark MLlib 是机器学习用的。Spark R 使用 R shell 运行机器学习任务。
这一统一引擎为构建下一代大数据应用程序带来了多项重要优势。首先,应用程序的开发和部署更简单,因为它们使用一组统一的 API,并在单个引擎上运行。第二,结合不同类型的数据处理(批处理、流等。)的效率要高得多,因为 Spark 可以在相同的数据上运行这些不同的 API 集,而无需将中间数据写出到存储中。
最后,最令人兴奋的好处是,Spark 使全新的应用程序成为可能,因为它易于组合不同的数据处理类型集;例如,对实时数据流的机器学习预测的结果运行交互式查询。一个每个人都能联想到的类比是智能手机,由强大的相机、手机和 GPS 设备组成。通过结合这些组件的功能,智能手机可以实现像 Waze 这样的创新应用,Waze 是一种交通和导航应用。
图 1-3
Spark 统一堆栈
Spark 核心
Spark 核心是 Spark 分布式数据处理引擎的基石。它由 RDD、分布式计算基础设施和编程抽象组成。
分布式计算基础设施负责在集群中的许多机器之间分配、协调和调度计算任务。这使得能够在大型机器集群上高效、快速地执行大量数据的并行数据处理。分布式计算基础设施的另外两个重要职责是处理计算任务失败和跨机器移动数据的有效方式,称为数据混洗。高级 Spark 用户应该熟悉 Spark 分布式计算基础设施,以便有效地设计高性能 Spark 应用程序。
RDD 密钥编程抽象是每个 Spark 用户都应该学习并有效使用各种提供的 API 的东西。RDD 是跨集群划分的可并行操作的容错对象集合。本质上,它为 Spark 应用程序开发人员提供了一组 API,使他们能够轻松高效地执行大规模数据处理,而无需担心数据驻留在集群上的什么位置以及机器故障。RDD API 面向多种编程语言,包括 Scala、Java 和 Python。它们允许用户传递本地函数在集群上运行,这是非常强大和独特的。rdd 将在后面的章节中详细介绍。
Spark stack 中的其余组件被设计为运行在 Spark Core 之上。因此,Spark 内核在 Spark 版本之间所做的任何改进或优化都会自动提供给其他组件。
Spark SQL
Spark SQL 是构建在 Spark Core 之上的一个模块,它是为大规模结构化数据处理而设计的。自从它带来了新水平的灵活性、易用性和性能以来,它的受欢迎程度一直在飙升。
结构化查询语言(SQL)已经成为数据处理的通用语言,因为它易于用户表达他们的意图。执行引擎然后执行智能优化。Spark SQL 将它带到了 Pb 级的数据处理领域。Spark 用户现在可以发出 SQL 查询来执行数据处理,或者使用通过 DataFrame API 公开的高级抽象。数据帧实际上是组织成指定列的数据的分布式集合。这不是一个新的想法。它的灵感来自于 R 和 Python 中的数据帧。考虑数据帧的一个更简单的方法是,它在概念上相当于关系数据库中的一个表。
在幕后,Spark SQL Catalyst optimizer 执行许多分析数据库引擎中常见的优化。
提升 Spark 灵活性的另一个 Spark SQL 特性是能够从各种结构化格式和存储系统读取和写入数据,例如 JavaScript 对象符号(JSON)、逗号分隔值(CSV)、Parquet 或 ORC 文件、关系数据库、Hive 等。
根据 2021 年的 Spark 调查,Spark SQL 是增长最快的组件。这是有意义的,因为 Spark SQL 使“大数据”工程师之外的更广泛的受众能够利用分布式数据处理的能力,即数据分析师或任何熟悉 SQL 的人。
Spark SQL 的座右铭是编写更少的代码,读取更少的数据,而优化器会完成最艰巨的工作。
Spark 结构化流
有人说,“动态数据的价值等于或大于历史数据。”在高度竞争的行业中,处理数据的能力已经成为许多公司的竞争优势。Spark 结构化流模块能够以高吞吐量和容错的方式处理来自各种数据源的实时流数据。数据可以从 Kafka、Flume、Kinesis、Twitter、HDFS 或 TCP socket 等来源获取。
Spark 处理流数据的主要抽象是离散化流(d stream),它通过将输入数据分成小批(基于时间间隔)来实现增量流处理模型,这些小批可以定期结合当前处理状态来产生新的结果。
流处理有时涉及到连接静态数据,Spark 使这变得非常容易。换句话说,由于统一的 Spark 堆栈,在 Spark 中可以很容易地将批处理和交互式查询与流处理结合起来。
Spark 版引入了一个新的可伸缩和容错的流处理引擎,称为结构化流。该引擎进一步简化了流处理应用程序开发人员的生活,它将流计算视为静态数据上的批处理计算。这个新的引擎自动递增地和连续地执行流处理逻辑,并在新的流数据到达时产生结果。结构化流媒体引擎的另一个独特功能是保证端到端的一次性支持,这使得“大数据”工程师在将数据保存到关系数据库或 NoSQL 数据库等存储系统方面比以前容易得多。
随着这个新引擎的成熟,它支持一类新的易于开发和维护的流处理应用程序。
根据 Databricks 首席架构师 Reynold Xin 的说法,执行流分析的最简单方法是不必对流进行推理。
Spark MLlib(消歧义)
MLlib 是 Spark 的机器学习库。它提供了 50 多种常见的机器学习算法和抽象,用于管理和简化模型构建任务,如特征化、构建管道、评估和调整模型以及模型的持久性,以帮助将模型从开发转移到生产。
从 Spark 2.0 版本开始,MLlib APIs 基于数据帧,以利用 Spark SQL 引擎中 Catalyst 和钨组件提供的用户友好性和许多优化。
机器学习算法是迭代的,这意味着它们会经过多次迭代,直到实现预期目标。Spark 使得实现这些算法变得极其容易,并且可以通过一个机器集群以可扩展的方式运行它们。常用的机器学习算法,如分类、回归、聚类和协同过滤,可供数据科学家和工程师使用。
图计算
图形处理对由顶点和连接它们的边组成的数据结构进行操作。图数据结构通常用于表示现实生活中的互联实体网络,包括 LinkedIn 上的职业社交网络、互联网上的互联网页网络等等。Spark GraphX 是一个库,它通过提供一个带有附加到每个顶点和边的属性的有向多图的抽象来实现图形并行计算。GraphX 包含了一组常见的图形处理算法,包括页面排名、连接组件、最短路径等。
Spark
SparkR 是一个 R 包,它提供了使用 Apache Spark 的轻量级前端。r 是一种流行的统计编程语言,支持数据处理和机器学习任务。然而,R 并不是为处理无法在单台机器上运行的大型数据集而设计的。SparkR 利用 Spark 的分布式计算引擎,使用熟悉的 R shell 和许多数据科学家喜爱的流行 API 来实现大规模数据分析。
Apache Spark 3.0
3.0 版本对 Spark stack 中的大多数组件进行了新的特性和增强。然而,大约 60%的增强是针对 Spark SQL 和 Spark 核心组件的。查询性能优化是 Spark 3.0 的主题之一,所以大部分的关注和开发都在 Spark SQL 组件中。根据 Databricks 完成的 TPC-DS 30 TB 基准测试,Spark 3.0 大约比 Spark 2.4 快两倍。本节重点介绍一些与性能优化相关的显著特性。
自适应查询执行框架
顾名思义,查询执行框架在运行时根据关于数据大小、分区数量等的最新统计数据来调整执行计划。因此,Spark 可以动态切换连接策略,自动优化偏斜连接,并调整分区数量。所有这些智能优化都提高了 Spark 应用程序的查询性能。
动态分区修剪(DPP)
DPP 背后的主要思想很简单,就是避免读取不必要的数据。它是专门为使用星型模式中的事实表和维度表的连接来查询数据的用例而设计的。通过减少事实表中需要根据给定的过滤条件与维度表连接的行数,它可以显著提高连接性能。基于 TPC-DS 基准测试,这种优化技术可以将 60%的查询的性能提高 2 到 18 倍。
加速器感知调度程序
越来越多的 Spark 用户正在利用 Spark 处理大数据和机器学习工作负载。后一类工作负载往往需要 GPU 来加速机器学习模型训练过程。这种增强使 Spark 用户能够为他们涉及机器学习的复杂工作负载描述和请求 GPU 资源。
Apache Spark 应用程序
Spark 是一个多功能、快速、可伸缩的数据处理引擎。从一开始,它就被设计成一个通用引擎,并且已经证明它可以用来解决许多用例。因此,各个行业的许多公司都在使用 Spark 来解决许多现实生活中的用例。下面是使用 Spark 开发的一些应用程序。
-
客户智能应用
-
数据仓库解决方案
-
实时流解决方案
-
推荐引擎
-
日志处理
-
面向用户的服务
-
欺诈检测
Spark 示例应用
在大数据处理领域,典型的示例应用程序是字数统计应用程序。这一传统始于 MapReduce 框架的引入。从那以后,每一本与大数据处理技术相关的书都必须遵循这个不成文的传统,纳入这个典范的例子。word count 示例应用程序中的问题空间对每个人来说都很容易理解,因为它所做的只是计算某个特定的单词在每组文档中出现的次数,无论是一本书的一章还是来自 Internet 的数百 TB 的网页。
清单 1-1 是 Scala 语言中 Spark 的一个字数统计示例应用。
val textFiles = sc.textFile("hdfs://<folder>")
val words = textFiles.flatMap(line => line.split(" "))
val wordTuples = words.map(word => (word, 1))
val wordCounts = wordTuples.reduceByKey(_ + _)
wordCounts.saveAsTextFile("hdfs://<outoupt folder>")
Listing 1-1The Word Count Spark Example Application Written in Scala Language
在这五行代码背后发生了很多事情。第一行负责读取指定文件夹下的文本文件。第二行遍历每个文件中的每一行,然后将每一行标记为一个单词数组,最后将每个数组展平为每行一个单词。第三行为每个单词加 1,以计算所有文档中的单词数。第四行执行每个单词计数的求和。最后,最后一行将结果保存在指定的文件夹中。希望这能让您对 Spark 执行数据处理的易用性有一个大致的了解。未来的章节将更详细地介绍每一行代码的作用。
Apache Spark 生态系统
在大数据领域,创新不会停滞不前。随着时间的推移,最佳实践和架构不断涌现。Spark 生态系统正在扩展和发展,以解决数据湖中的一些新兴需求,帮助数据科学家更高效地与大量数据进行交互,并加快机器学习开发生命周期。本节重点介绍了 Spark 生态系统中一些激动人心的最新创新。
DeltaLake
在这一点上,大多数公司都认识到了数据的价值,并制定了某种形式的策略来接收、存储、处理和提取数据中的见解。Delta Lake 的理念是利用分布式存储解决方案为各种数据消费者(如数据科学家、数据工程师和业务分析师)存储结构化和非结构化数据。为了确保 Delta Lake 中的数据可用,必须在数据目录、数据发现、数据质量、访问控制和数据一致性语义方面存在疏漏。数据一致性语义提出了许多挑战,公司已经发明了技巧或“创可贴”解决方案。
Delta Lake 是一个针对数据一致性语义的开源解决方案,它提供了一种开放的数据存储格式,具有事务保证、模式实现和演化支持。DeltaLake 将在后面进一步讨论。
树袋熊
多年来,数据科学家一直在使用 Python pandas 库在他们的机器学习相关任务中执行数据操作。熊猫库( https://pandas.pydata.org
)是一个“基于 Python 编程语言构建的快速、强大、灵活且易于使用的开源数据分析和操作工具。”pandas 非常受欢迎,并且已经成为事实上的库,因为它强大而灵活的抽象称为数据操作的数据帧。然而,pandas 被设计成只能在一台机器上运行。要在 Python 中执行并行计算,可以探索一个名为 Dask ( https://docs.dask.org
)的开源项目。
考拉通过在 Apache Spark 上实现 pandas DataFrame API,结合了两个世界的精华,强大而灵活的 DataFrame 抽象和 Spark 的分布式数据处理引擎。
这项创新使数据科学家能够利用他们的熊猫知识与比过去大得多的数据集进行交互。
考拉 1.0 版本于 2020 年 6 月发布,覆盖了熊猫 API 的 80%。考拉的目标是让数据科学项目能够利用大型数据集,而不是被它们阻挡。
MLflow
机器学习领域已经存在很长时间了。最近,由于算法的进步,访问大量有用数据集(如图像和大量文本)的便利性,以及教育资源的可用性,它变得更加触手可及。然而,将机器学习应用于商业问题已被证明是一个挑战,因为管理机器学习生命周期更像是一个软件工程问题。
MLflow 是一个开源项目。它是在 2018 年构想的,旨在提供一个平台来帮助管理机器学习生命周期。它由以下组件组成,以满足生命周期每个阶段的各种需求。
-
跟踪记录和比较机器学习实验。
-
项目提供了组织机器学习项目的一致格式,以轻松共享和复制机器学习模型。
-
Models 提供了一个标准化的格式来打包机器学习模型,一个一致的 API 来处理机器学习模型,例如加载和部署它们。
-
Registry 是一个模型存储,它托管机器学习模型并跟踪它们的血统、版本和部署状态转换。
摘要
-
Apache Spark 自诞生以来当然产生了许多 Spark。它在大数据领域创造了许多激动人心的事情和机会。更重要的是,它允许您创建许多新的和创新的大数据应用程序,以解决数据应用程序的各种数据处理问题。
-
Spark 的三个重要特性是易用性、速度和灵活性。
-
Spark 分布式计算基础设施采用主从架构。每个 Spark 应用程序由一个驱动程序和一个或多个执行程序组成,用于并行处理数据。并行性是在短时间内处理大量数据的关键因素。
-
Spark 提供了统一的可扩展和分布式数据处理引擎,可用于批处理、交互式和探索性数据处理、实时流处理、构建机器学习模型和预测以及图形处理。
-
Spark 应用程序可以用多种编程语言编写,包括 Scala、Java、Python 或 r。
二、使用 Apache Spark
当谈到使用 Spark 或构建 Spark 应用程序时,有许多选择。本章描述了三个常见的选项,包括使用 Spark shell、从命令行提交 Spark 应用程序以及使用名为 Databricks 的托管云平台。本章的最后一部分面向那些希望在本地机器上安装 Apache Spark 源代码的软件工程师,他们将研究 Spark 源代码并了解某些特性是如何实现的。
下载和安装
要学习或试验 Spark,将它本地安装在您的计算机上是很方便的。通过这种方式,您可以轻松地尝试某些功能,或者使用小型数据集测试您的数据处理逻辑。将 Spark 本地安装在您的笔记本电脑上,您可以在任何地方学习它,包括您舒适的客厅、海滩或墨西哥的酒吧。
Spark 是用 Scala 写的。它经过打包,可以在 Windows 和类似 UNIX 的系统(例如 Linux、macOS)上运行。要在本地运行 Spark,只需要在您的计算机上安装 Java。
建立一个多租户 Spark 生产集群需要更多的信息和资源,这超出了本书的范围。
下载 Spark
Apache Spark 网站的下载部分( http://spark.apache.org/downloads.html
)有下载预打包 Spark 二进制文件的详细说明。在写这本书的时候,最新的版本是 3.1.1。包类型方面,选择 Hadoop 最新版本的。图 2-1 显示了下载 Spark 的各种选项。最简单的方法是下载预打包的二进制文件,因为它包含在您的计算机上运行 Spark 所必需的 JAR 文件。单击行项目 3 上的链接会触发二进制文件下载。有一种方法可以从源代码手动构建 Spark 二进制文件。本章稍后将介绍如何操作的说明。
图 2-1
Apache Spark 下载选项
安装 Spark
一旦文件成功下载到您的计算机上,下一步就是解压缩它。spark-3.1.1-bin-hadoop2.7.tgz
文件位于 GZIP 压缩的 tar 存档文件中,因此您需要使用正确的工具来解压缩它。
对于 Linux 或 macOs 电脑,tar
命令应该已经存在。所以运行下面的命令来解压缩下载的文件。
tar xvf spark-3.1.1-bin-hadoop2.7.tgz
对于 Windows 计算机,您可以使用 WinZip 或 7-zip 工具来解压缩下载的文件。
解压缩成功完成后,应该有一个名为 spark-3.1.1-bin-hadoop2.7 的目录。从这里开始,这个目录称为 spark 目录。
Note
如果下载了不同版本的 Spark,目录名会略有不同。
spark-3.1.1-bin-hadoop2.7
目录下大概有十几个目录。表 2-1 描述了值得了解的内容。
表 2-1
spark-3.1.1-bin-hadoop2.7 中的子目录
|名字
|
描述
|
| --- | --- |
| 容器 | 包含各种可执行文件,用于在 Scala 或 Python 中调用 Spark shell、提交 Spark 应用程序、运行 Spark 示例 |
| 数据 | 包含各种 Spark 示例的小样本数据文件 |
| 例子 | 包含所有 Spark 示例的源代码和二进制文件 |
| 震动 | 包含运行 Spark 所需的必要二进制文件 |
| 命令 | 包含管理 Spark 集群的可执行文件 |
下一步是通过调用 Spark shell 来测试安装。
Spark shell 类似于 Unix shell。它提供了一个交互式环境,可以轻松地学习 Spark 和分析数据。大多数 Spark 应用程序都是使用 Python 或 Scala 编程语言开发的。Spark shell 可用于这两种语言。如果你是一名数据科学家,Python 是你的最爱,你不会感到被冷落。下一节将展示如何使用 Spark Scala 和 Spark Python shell。
Note
Scala 是一种基于 Java JVM 的语言,因此很容易在 Scala 应用程序中利用现有的 Java 库。
Spark Scala 外壳
要启动 Spark Scala shell,在 Spark 目录中输入./bin/spark-shell
命令。几秒钟后,您应该会看到类似于图 2-2 的东西。
图 2-2
Scala Spark 壳输出
要退出 Scala Spark shell,请键入:quit
或:q
。
Note
Java 版本 11 或更高版本是运行 Spark Scala shell 的首选。
Spark Python Shell
要启动 Spark Python shell,请在 Spark 目录中输入./bin/pyspark
命令。几秒钟后,您应该会看到类似于图 2-3 的东西。
图 2-3
Python Spark shell 的输出
要退出 Python Spark shell,请输入ctrl-d
。
Note
Spark Python shell 需要 Python 3.7.x 或更高版本。
Spark Scala shell 和 Spark Python shell 分别是 Scala REPL 和 Python REPL 的扩展。REPL 是读取-评估-打印循环的首字母缩写。它是一个交互式计算机编程环境,接受用户输入,对其进行评估,并将结果返回给用户。一旦输入一行代码,REPL 会立即提供关于是否有语法错误的反馈。如果没有任何语法错误,它会对它们进行评估。如果有输出,它会显示在 shell 中。交互和即时反馈环境使开发人员能够通过绕过正常软件开发过程中的代码编译步骤来提高工作效率。
要学习 Spark,Spark shell 是一个非常方便的工具,可以随时随地在您的本地计算机上使用。除了您处理的数据文件需要驻留在您的计算机上之外,它没有任何外部依赖性。然而,如果你有一个互联网连接,有可能访问这些远程数据文件,但它会很慢。
本书的其余章节使用 Spark Scala shell。
享受 Spark Scala Shell 带来的乐趣
本节提供了关于 Scala Spark shell 的信息,以及一组有用的命令,这些命令在使用它进行探索性数据分析或交互式构建 Spark 应用程序时非常有效。
./bin/spark-shell
命令有效地启动了 Spark 应用程序,并提供了一个环境,您可以在其中交互式地调用 Spark Scala APIs 来轻松地执行探索性数据处理。由于 Spark Scala shell 是 Scala REPL 的扩展,所以用它同时学习 Scala 和 Spark 是一个很好的方法。
有用的 Spark Scala Shell 命令和提示
一旦 Spark Scala shell 启动,它会将您置于一个交互式环境中,以便输入 shell 命令和 Scala 代码。本节涵盖了各种有用的命令和一些使用 shell 的技巧。
进入 Spark Shell 后,键入以下命令以获得可用命令的完整列表。
scala> :help
该命令的输出如图 2-4 所示。
图 2-4
可用 shell 命令列表
有些命令比其他命令更常用,因为它们很有用。表 2-2 描述了常用的命令。
表 2-2
有用的 Spark Shell 命令
|名字
|
描述
|
| --- | --- |
| :历史 | 该命令显示在之前的 Spark shell 会话和当前会话中输入的内容。这对于复制非常有用。 |
| :加载 | 加载并执行所提供文件中的代码。当数据处理逻辑很长时,这尤其有用。跟踪文件中的逻辑要容易一些。 |
| :重置 | 试用各种 Scala 或 Spark APIs 一段时间后,您可能会忘记各种变量的值。该命令将 shell 重置为干净状态,以便于推理。 |
| :无声 | 这是为那些对查看 shell 中输入的每个 Scala 或 Spark APIs 的输出有些厌倦的高级用户准备的。要重新启用输出,只需再次键入:silent。 |
| :退出 | 这是一个不言自明的命令,但是知道它很有用。通常,人们试图通过进入:退出来退出外壳,这是行不通的。 |
| :类型 | 显示变量的类型。:类型
除了这些命令之外,还有一个有助于提高开发人员工作效率的特性是代码完成特性。与流行的集成开发环境(ide)如 Eclipse 或 IntelliJ 一样,代码完成特性帮助开发人员探索可能的选项并减少键入错误。
在 shell 中,键入spa
,然后按 Tab 键。环境添加字符将“spa”转换为“spark”。此外,它还显示了 Spark 的可能匹配(见图 2-5 )。
图 2-5
spa 的选项卡完成输出
scala> spa <tab>
除了完成部分输入单词的名称,制表符结束还可以显示对象的可用成员变量和函数。
在 shell 中,键入spark
,然后按 Tab 键。这将显示由spark
变量代表的 Scala 对象的可用成员变量和函数列表(参见图 2-6 )。
图 2-6
名为“spark”的对象的可用成员变量和函数列表
:history
命令显示先前输入的命令或代码行。这表明 Spark shell 保留了输入内容的记录。快速显示或回忆最近输入的内容的一种方法是按向上箭头键。一旦你向上滚动到你想要执行的行,只需按下回车键来执行它。
与 Scala 和 Spark 的基本交互
上一节介绍了导航 Spark shell 的基础知识;本节介绍了在 Spark shell 中使用 Scala 和 Spark 的一些基本方法。这些基础知识将在以后的章节中非常有帮助,因为您会更深入地研究 Spark DataFrame 和 Spark SQL 等主题。
与 Scala 的基本交互
让我们从 Spark Scala shell 中的 Scala 开始,它为学习 Scala 提供了一个成熟的环境。把 Spark Scala shell 想象成一个空体的 Scala 应用程序,这就是你的用武之地。您可以用 Scala 函数和应用程序逻辑来填充这个空身体。本节打算在 Spark shell 中演示几个简单的 Scala 示例。Scala 是一种迷人的编程语言,强大、简洁、优雅。请参考 Scala 相关书籍,了解更多关于这种编程语言的知识。
学习任何编程语言的典型例子是“Hello World”例子,它需要打印出一条消息。让我们开始吧。在 Spark Scala shell 中输入以下代码行;输出应该如图 2-7 所示。
图 2-7
Hello World 示例命令的输出
scala> println("Hello from Spark Scala shell")
下一个示例定义了一个年龄数组,并在 Spark shell 中打印出这些元素值。此外,这个例子说明了上一节中提到的代码完成特性。
要定义一个年龄数组并将其赋给一个不可变的变量,请在 Spark shell 中输入以下内容。图 2-8 显示了评估输出。
图 2-8
定义年龄数组的输出
scala> val ages = Array(20, 50, 35, 41)
现在你可以在下面一行代码中引用ages
变量。让我们假设您不能准确地记住Array
类中的一个函数名来迭代数组中的元素,但是您知道它以“fo”开头。您可以输入以下内容并点击选项卡,看看 Spark shell 可以提供什么帮助。
scala> ages.fo
按 Tab 键后,Spark shell 显示如图 2-9 所示。
图 2-9
代码完成的输出
啊哈!您需要foreach
函数来遍历数组中的元素。让我们用它来打印年龄。
scala> ages.foreach(println)
图 2-10 显示了预期的输出。
图 2-10
打印年龄的输出
对于 Scala 新手来说,前面的代码语句可能看起来有点晦涩;但是,你可以直观地猜测它是做什么的。当foreach
函数遍历“ages”数组中的每个元素时,它将该元素传递给println
函数以将值打印到控制台。这种风格在接下来的章节中会经常用到。
本节最后一个例子定义了一个 Scala 函数来确定年龄是奇数还是偶数;然后用它来查找数组中的奇数年龄。
scala> def isOddAge(age:Int) : Boolean = {
(age % 2) == 1
}
如果您来自 Java 编程背景,这个函数签名可能看起来很奇怪,但要破译它是做什么的并不太难。请注意,该函数不使用关键字return
来返回其主体中表达式的值。在 Scala 中,不需要添加return
关键字。函数体中最后一条语句的输出返回给调用者(如果该函数被定义为返回值)。图 2-11 显示了 Spark 壳的输出。
图 2-11
如果有语法错误,Spark shell 将返回函数签名
为了计算出ages
数组中的奇数年龄,让我们利用Array
类中的filter
函数。
scala> ages.filter(age => isOddAge(age)).foreach(println)
这行代码进行过滤,然后遍历结果,打印出奇数年龄。在 Scala 中,使用函数链使代码简洁是一种常见的做法。图 2-12 显示了 Spark 壳的输出。
图 2-12
过滤和打印出的输出只是奇数的年龄
现在让我们在前面定义的 Scala 变量和函数上尝试一下:type
shell 命令。一旦您使用 Spark shell 一段时间,并且忘记了某个变量的数据类型或某个函数的返回类型,这个命令就会派上用场。图 2-13 显示了:type
命令的示例。
图 2-13
输出:类型命令
学习 Spark,不一定要掌握 Scala 编程语言。然而,你必须熟悉 Scala 的基础知识并熟练使用。在 https://github.com/deanwampler/JustEnoughScalaForSpark
,有一个很好的资源可以学习刚刚够学习 Spark 的 Scala。该资源在各种 Spark 相关会议上展示。
Spark UI 和与 Spark 的基本交互
在上一节中,我提到了 Spark shell 是一个 Scala 应用程序。这只是部分正确。Spark shell 是用 Scala 编写的 Spark 应用程序。当 Spark shell 启动时,会初始化并设置一些东西供您使用,包括 Spark UI 和一些重要的变量。
Spark UI
如果你回头仔细检查图 2-2 或图 2-3 中的 Spark 壳输出,你会看到一条类似下面的线。(对于您的 Spark shell,URL 可能会有所不同。)
SparkContext Web UI 在 http://<ip>:4040
可用。
如果您将浏览器指向 Spark shell 中的 URL,它会显示如图 2-14 所示的内容。
图 2-14
Spark UI
Spark UI 是一个 web 应用程序,旨在帮助监控和调试 Spark 应用程序。它包含 Spark 应用程序的详细运行时信息和各种资源消耗。运行时包括各种度量,这些度量对于诊断 Spark 应用程序中的性能问题非常有帮助。需要注意的一点是,Spark UI 仅在 Spark 应用程序运行时可用。
Spark UI 顶部的导航栏包含到各种选项卡的链接,包括作业、阶段、存储、环境、执行器和 SQL。我将简要介绍环境和执行者选项卡,并在后面的章节中描述其余的选项卡。
Environment 选项卡包含 Spark 应用程序运行环境的静态信息。这包括运行时信息、spark 属性、系统属性和类路径条目。表 2-3 描述了这些区域。
表 2-3
环境选项卡中的部分
|名字
|
描述
|
| --- | --- |
| 运行时信息 | 包含 Spark 所依赖的各种组件的位置和版本,包括 Java 和 Scala。 |
| Spark 特性 | 该区域包含在 Spark 应用程序中配置的基本和高级属性。基本属性包括应用程序的基本信息,如应用程序 id、名称等。高级属性旨在打开或关闭某些 Spark 功能,或者以最适合特定应用程序的特定方式调整它们。有关可配置属性的完整列表,请参见位于 https://spark.apache.org/docs/latest/configuration.html
的参考资料。 |
| 资源配置文件 | 关于 Spark 集群中 CPU 数量和内存量的信息。 |
| Hadoop 属性 | 各种 Hadoop 和 Hadoop 文件系统属性。 |
| 系统属性 | 这些属性主要是 OS 和 Java 级别的,而不是 Spark 特有的。 |
| 类路径条目 | 包含 Spark 应用程序中使用的类路径和 jar 文件的列表。 |
Executors 选项卡包含支持 Spark 应用程序的每个执行器的摘要和细分信息。这些信息包括特定资源的容量,以及每个执行器使用了多少资源。资源包括内存、磁盘和 CPU。Summary 部分提供了 Spark 应用程序中所有执行器的资源消耗的鸟瞰图。图 2-15 显示了更多细节。
图 2-15
仅使用一个执行器的 Spark 应用程序的 Executor 选项卡
您将在后面的章节中再次讨论 Spark UI。
与 Spark 的基本交互
一旦一个 Spark shell 成功启动,一个名为spark
的重要变量就被初始化并准备好使用。变量spark
代表了一个SparkSession
类的实例。让我们使用:type
命令来验证这一点。
scala>:type spark
Spark 壳在图 2-16 中显示其类型。
图 2-16
显示“Spark”变量的类型
Spark 2.0 中引入了SparkSession
类,以提供与底层 Spark 功能交互的单一入口点。这个类有读取文本和二进制格式的非结构化和结构化数据的 API,比如 JSON、CSV、Parquet、ORC 等等。此外,SparkSession
组件提供了检索和设置 Spark 配置的工具。
让我们开始与 Spark shell 中的spark
变量交互,打印出一些有用的信息,比如版本和现有配置。在 Spark shell 中,键入以下代码来打印 Spark 版本。图 2-17 显示输出。
图 2-17
Spark 版本输出
scala> spark.version
再正式一点,可以使用上一节介绍的println
函数打印出如图 2-18 所示的 Spark 版本和输出。
图 2-18
使用 println 函数显示 Spark 版本
scala> println("Spark version: " + spark.version)
要查看 Spark shell 中的默认配置,您可以访问spark
的conf
变量。下面是显示默认配置的代码,输出如图 2-19 所示。
图 2-19
Spark shell 应用中的默认配置
scala> spark.conf.getAll.foreach(println)
要查看您可以从spark
变量访问的可用对象的完整集合,您可以利用 Spark shell 代码完成特性。
scala> spark.<tab>
图 2-20 显示了该命令的结果。
图 2-20
可以从 spark 变量访问的变量的完整列表
接下来的章节有更多使用spark
与 Spark 底层功能交互的例子。
协作笔记本简介
协作笔记本是由 Databricks 提供的商业产品,data bricks 是名为 Apache Spark 的开源项目的最初创建者。根据产品文档,Collaborative Notebooks 是为数据工程师、数据科学家和数据分析师设计的,用于执行数据分析和构建支持多种语言、内置数据可视化和自动数据版本化的机器学习模型。它还提供 Spark on demand 计算基础架构,并可以按照特定的计划执行生产数据管道的作业。它围绕 Apache Spark 构建,为全球客户提供四个主要价值主张。
-
完全管理的 Spark 集群
-
用于探索和可视化的交互式工作空间
-
生产流水线调度程序
-
为您最喜爱的基于 Spark 的应用提供支持的平台
协作笔记本产品有两个版本,完整平台版和社区版。商业版是一个付费产品,提供高级功能,如创建多个集群、用户管理和作业调度。社区版是免费的,非常适合开发人员、数据科学家、数据工程师以及任何想要学习 Apache Spark 或尝试 Databricks 的人。
以下部分涵盖了协作笔记本社区版的基本功能。它为学习 Spark、执行数据分析或构建 Spark 应用程序提供了一个简单直观的环境。本节不是一个全面的指南。为此,您可以参考 Databricks 用户指南( https://docs.databricks.com/user-guide/index.html
)。
要使用协作笔记本,您需要在 https://databricks.com/try-databricks
注册一个社区版的免费账户。这个注册过程简单快捷;几分钟之内就可以创建一个帐户。一旦在注册表单中提供并提交了必要的信息,您很快就会收到来自 Databricks 的电子邮件,确认您的电子邮件,它看起来有点像图 2-21 。
图 2-21
Databricks 电子邮件确认您的电子邮件地址
点击图 2-21 所示的网址链接,进入 Databricks 签到表,如图 2-22 所示。
图 2-22
数据块登录页面
使用电子邮件和密码成功登录后,您会看到如图 2-23 所示的 Databricks 欢迎页面。
图 2-23
数据块欢迎页面
随着时间的推移,欢迎页面可能会发生变化,因此它看起来并不完全像图 2-23 。请随意浏览教程或文档。
本节的目的是在 Databricks 中创建一个笔记本,以便您可以学习上一节中介绍的命令。以下是主要步骤。
-
创建一个集群。
-
创建一个文件夹。
-
创建一个笔记本。
创建一个集群
community edition (CE)最酷的特性之一是,它免费提供了一个 15 GB 内存的单节点 Spark 集群。在写这本书的时候,这个单节点集群托管在 AWS 云上。因为 ce 帐户是免费的,所以它提供了同时创建多个集群的能力。只要群集还在使用,它就会一直保持运行状态。如果闲置两个小时,它会自动关机。这意味着您不必主动关闭集群。
要创建集群,请单击页面左侧垂直导航栏中的集群图标。集群页面如图 2-24 所示。
图 2-24
没有活动集群的数据块集群页面
现在点击 Create Cluster 按钮,调出新的集群表单,如图 2-25 所示。
图 2-25
创建集群表单
该表单上唯一的必填字段是集群名称。表 2-4 描述了每个字段。
表 2-4
数据块新的集群表单字段
|名字
|
描述
|
| --- | --- |
| 集群名称 | 用于标识集群的唯一名称。名称的每个单词之间可以有空格;比如《我的星火簇》。 |
| 数据块运行时版本 | Databricks 支持许多版本的 Spark。出于学习目的,请选择最新版本,它会自动为您填充。每个版本都绑定到一个特定的 AWS 映像。 |
| 情况 | 对于 CE 版,没有其他选择。 |
| AWS–可用性区域 | 这允许您决定单节点集群运行在哪个 AWS 可用性区域。根据您所在的位置,选项可能会有所不同。 |
| Spark–Spark 配置 | 这允许您指定用于启动 Spark 集群的任何特定于应用程序的配置。例子包括打开某些 Spark 特性的 JVM 配置。 |
输入集群名称后,单击创建集群按钮。创建一个单节点 Spark 集群可能需要 10 分钟。如果需要,尝试切换到不同的可用性区域,如果默认区域需要很长时间。一旦成功创建一个 Spark 簇,簇名旁边会出现一个绿点,如图 2-26 所示。
图 2-26
成功创建集群后
通过单击您的集群的名称或本页上的各种链接,您可以随意探索。如果您试图按照相同的步骤创建另一个 Spark 集群,它不允许您这样做。
要终止活动的 Spark 簇,请单击 Actions 列下的方块。
有关在数据块中创建和管理 Spark 集群的更多信息,请访问 https://docs.databricks.com/user-guide/clusters/index.html
。
让我们进入下一步,创建一个文件夹。
创建文件夹
在讨论如何创建文件夹之前,有必要花点时间描述一下 Databricks 中的工作空间概念。考虑 workspace 最简单的方法是将其视为计算机上的文件系统,这意味着可以利用其分层属性来组织各种笔记本。
要创建文件夹,请单击页面左侧垂直导航栏中的工作区图标。工作区列滑出,如图 2-27 所示。
图 2-27
工作区列
现在点击工作区栏右上方的向下箭头,弹出菜单出现(见图 2-28 )。
图 2-28
用于创建文件夹的菜单项
选择创建➤文件夹菜单项,弹出新文件夹名称对话框(见图 2-29 )。
图 2-29
“新建文件夹名称”对话框
现在,您可以输入一个文件夹名称(即第二章),并点击创建文件夹按钮以完成该过程。章 2 文件夹现在应该出现在工作区栏中,如图 2-30 所示。
图 2-30
第二章文件夹出现在工作区列中
在创建笔记本之前,值得一提的是,还有一种创建文件夹的替代方法。将鼠标指针放在工作区列中的任意位置,然后右键单击;出现相同的菜单选项。
有关工作区和创建文件夹的更多信息,请访问 https://docs.databricks.com/user-guide/workspace.html
。
创建笔记本
在章节 2 文件夹中创建一个 Scala 笔记本。首先,在工作区栏中选择章节 2 文件夹。章节 2 栏在工作区栏后滑出,如图 2-31 所示。
图 2-31
章节 2 栏出现在工作区栏的右侧
现在你既可以点击章节 2 栏右上角的向下箭头,也可以在章节 2 栏的任意位置点击鼠标右键,调出菜单,如图 2-32 所示。
图 2-32
创建笔记本菜单项
选择“笔记本”菜单项会弹出“创建笔记本”对话框。为您的笔记本命名,并确保为语言字段选择 Scala 选项。应该自动填充集群的值,因为 CE 版本一次只能有一个集群。该对话框看起来应该如图 2-33 所示。
图 2-33
选择了 Scala 语言选项的“创建笔记本”对话框
点击创建按钮后,一个全新的笔记本被创建,如图 2-34 所示。
图 2-34
新 Scala 笔记本
如果你从未使用过 IPython 笔记本,笔记本的概念一开始可能会显得有些陌生。然而,一旦你习惯了,你会发现它很直观,很有趣。
笔记本本质上是一个交互式计算环境(类似于 Spark shell,但是更好)。您可以执行 Spark 代码,使用 Markdown 或 HTML 标记语言用富文本记录您的代码,并使用各种类型的图表和图形可视化您的数据分析结果。
以下部分仅涵盖几个基本部分,以帮助您高效使用 Spark 笔记本。有关使用 Databricks 笔记本并与之交互的完整说明列表,请访问 https://docs.databricks.com/user-guide/notebooks/index.html
。
Spark Notebook 包含一个单元格集合,每个单元格包含一个要执行的代码块或用于文档目的的标记。
Note
使用 Spark Notebook 的一个好习惯是将数据处理逻辑分成多个逻辑组,这样每个逻辑组都驻留在一个或多个单元中。这类似于开发可维护软件应用程序的实践。
让我们把笔记本分成两部分。第一部分包含您在“Scala 的基本交互”一节中输入的代码片段。第二部分包含您在“与 Spark 的基本交互”一节中输入的代码片段。
让我们从添加一个 Markdown 语句开始,通过在第一个单元格中输入以下内容来记录笔记本的第一部分(参见图 2-35 )。
图 2-35
单元格包含节头标记语句
%md #### Basic Interactions with Scala
要执行标记语句,请确保鼠标光标在单元格 1 中,按住 Shift 键,然后按 Enter 键。这是在单元格中运行代码或标记语句的快捷方式。结果应该如图 2-36 所示。
图 2-36
执行标记语句的输出
请注意,Shift+Enter 组合键执行该单元格中的语句,并在其下方创建一个新单元格。现在让我们在第二个单元格中输入“Hello World”示例并执行该单元格。输出应该如图 2-37 所示。
图 2-37
执行 println 语句的输出
“与 Scala 的交互”部分剩余的三个代码语句被复制到笔记本中(见图 2-38 )。
图 2-38
“与 Scala 的交互”一节中剩余的代码语句
像 Spark Scala shell 一样,Scala Notebook 是一个成熟的 Scala 交互环境,在这里可以执行 Scala 代码。
现在让我们输入第二个标记语句来表示笔记本第二部分的开始,以及“与 Spark 的交互”部分中剩余的代码片段。图 2-39 显示了输出。
图 2-39
与 Spark 部分交互的代码片段的输出
%md #### Basic Interactions with Spark
使用 Spark 笔记本时,有一些重要注意事项需要了解。它提供了一个非常方便的自动保存功能。当您输入市场声明或代码片段时,笔记本的内容会自动保存。事实上,文件菜单项下的菜单项没有保存笔记本的选项。
有时需要在两个现有单元之间创建一个新单元。一种方法是将鼠标光标移动到它们之间的空间,然后单击出现的加号图标创建一个新的单元格。图 2-40 显示了加号图标的样子。
图 2-40
使用加号图标在两个现有单元格之间创建一个新单元格
有时,您需要与在远程办公室工作的同事或其他合作者分享您的笔记本电脑,以展示您出色的 Spark 知识或获得他们对您的数据分析的反馈。只需单击 Spark 笔记本顶部的 File 菜单项,然后选择 Publish 子菜单项。图 2-41 显示了它的样子。
图 2-41
笔记本发布菜单项
点击发布子菜单项,弹出确认对话框(见图 2-42 )。如果你坚持到底,笔记本发布对话框(见图 2-43 )提供了一个你可以发送给世界上任何人的 URL。通过该 URL,您的同事或协作者可以查看您的笔记本,或者将它导入他们的 Databricks 工作区。
图 2-43
笔记本发布的 URL
图 2-42
发布确认对话框
本节仅涵盖使用数据块的基本部分。许多其他高级功能使 Databricks 成为执行交互式数据分析或构建机器学习模型等高级数据解决方案的平台。
CE 提供了一个单节点 Spark 集群的免费帐户。通过 Databricks 产品学习 Spark 变得比以前容易多了。我强烈建议您在学习 Spark 的过程中尝试一下 Databricks。
设置 Spark 源代码
本节面向软件开发人员或任何有兴趣了解 Spark 在代码级如何工作的人。由于 Apache Spark 是一个开源项目,它的源代码是公开的,可以从 GitHub 下载,研究某些特性是如何实现的。Spark 代码是由这个星球上一些最聪明的 Scala 程序员用 Scala 编写的,所以学习 Spark 代码是提高一个人的 Scala 编程技能和知识的好方法。
有两种方法可以将 Apache Spark 源代码下载到您的计算机上。您可以从位于 http://spark.apache.org/downloads.html
的 Spark 下载页面下载它,这个页面之前用于下载 Spark 二进制文件。这一次,让我们选择源代码包类型,如图 2-44 。
图 2-44
Apache Spark 源代码下载选项
要完成源代码下载过程,请单击第 3 行的链接下载压缩的源代码文件。最后一步是将文件解压缩到您选择的目录中。
您还可以使用git clone
命令从 GitHub 存储库中下载 Apache Spark 源代码。这需要在您的计算机上安装 git。Git 可以在 https://git-scm.com/downloads
下载。安装说明可从 https://git-scm.com/book/en/v2/Getting-Started-Installing-Git
获得。在您的计算机上正确安装 Git 后,发出以下命令在 GitHub 上克隆 Apache Spark git 存储库( https://github.com/apache/spark
)。
git clone git://github.com/apache/spark.git
一旦 Apache Spark 源代码被下载到你的计算机上,进入 http://spark.apache.org/developer-tools.html
获取关于如何将它们导入到你喜欢的 IDE 中的信息。
摘要
-
说到学习 Spark,有几个选择。您可以使用本地安装的 Spark,也可以使用协作笔记本社区版。这些工具让任何人学习 Spark 都变得简单方便。
-
Spark shell 是一个强大的交互式环境,用于学习 Spark APIs 和交互式分析数据。有两种类型的 Spark shell,Spark Scala shell 和 Spark Python shell。
-
Spark shell 提供了一组命令来帮助用户提高工作效率。
-
协作笔记本是一个全面管理的平台,旨在简化构建和部署数据探索、数据管道和机器学习解决方案。交互式工作区提供了一种直观的方式来组织和管理笔记本。每个笔记本都包含标记语句和代码片段的组合。与他人共享笔记本只需点击几下鼠标。
-
对于有兴趣了解 Spark 内部原理的软件开发人员来说,下载并研究 Apache Spark 源代码是满足这种好奇心的好方法。
三、Spark SQL:基础
随着 Spark 作为统一数据处理引擎的发展和成熟,在每个新版本中都有更多的特性,它的编程抽象也在发展。当 Spark 在 2012 年向世界推出时,弹性分布式数据集 (RDD)是最初的核心编程抽象。在 Spark 版中,引入了一种新的编程抽象,称为结构化 API。这是处理数据工程任务(如执行数据处理或构建数据管道)的新的首选方式。结构化 API 旨在通过易于使用、直观且富于表现力的 API 来提高开发人员的工作效率。新的编程抽象要求数据以结构化格式可用,数据计算逻辑需要遵循一定的结构。有了这两条信息,Spark 就可以执行必要的复杂优化来加速数据处理应用程序。
图 3-1 展示了 Spark SQL 组件是如何构建在可靠的 Spark 核心组件之上的。这种分层架构使其能够轻松利用 Spark 核心组件中引入的任何新改进。
图 3-1
Spark SQL 组件
本章介绍 Spark SQL 模块,它是为结构化数据处理而设计的。它提供了一个易于使用的抽象,用最少的代码来表达数据处理逻辑,并且在它的背后,它智能地执行必要的优化。
Spark SQL 模块由两个主要部分组成。第一个是称为 DataFrame 和 Dataset 的结构 API 的表示,它们定义了用于处理结构化数据的高级 API。DataFrame 概念的灵感来自 Python 熊猫 DataFrame。主要区别在于 Spark 中的 DataFrame 可以处理分布在多台机器上的大量数据。Spark SQL 模块的第二部分是 Catalyst optimizer,它负责所有在幕后工作的复杂机器,使您的生活更加轻松,并最终加快您的数据处理逻辑。Spark SQL 模块提供的一个很酷的功能是执行 SQL 查询来执行数据处理。有了这个功能,Spark 可以获得一个新的用户群,称为业务分析师,他们非常熟悉 SQL 语言,因为这是他们经常使用的主要工具之一。
区分结构化数据和非结构化数据的一个主要概念是模式,它以列名和相关数据类型的形式定义数据结构。模式概念是 Spark 结构化 API 不可或缺的一部分。
结构化数据通常以某种格式捕获。有些格式是基于文本的,有些是基于二进制的。文本数据的常见格式是 CSV、XML 和 JSON,二进制数据的常见格式是 Avro、Parquet 和 ORC。现成的 Spark SQL 模块使得从这些格式中读取数据和向其中写入数据变得非常容易。这种多功能性的一个意想不到的结果是 Spark 可以用作数据格式转换工具。
在进入结构化 API 之前,让我们讨论一下最初的编程抽象,以便更好地理解新抽象背后的动机。
了解 RDD
要真正理解 Spark 是如何工作的,你必须理解 RDD 的本质。它为构建结构化 API 提供了坚实的基础和抽象。简而言之,一个 RDD 代表一个容错的元素集合,这些元素被划分到一个集群中可以并行操作的节点上。它由以下特征组成。
-
一组对父 rdd 的依赖关系
-
一组分区,即构成整个数据集的区块
-
计算数据集中所有行的函数
-
关于分区方案的元数据(可选)
-
数据在群集上的驻留位置(可选)
Spark runtime 使用这五条信息来调度和执行使用 RDD 运算表达的数据处理逻辑。
前三条信息组成了血统信息,Spark 将它用于两个目的。第一个是确定 rdd 的执行顺序,第二个是故障恢复。
依赖项集合实质上是 RDD 的输入数据。需要此信息来重现故障场景中的 RDD,因此它提供了弹性特征。
该组分区使 Spark 能够并行执行计算逻辑,以加快计算时间。
Spark 需要产生 RDD 输出的最后一部分是计算功能,它是由 Spark 用户提供的。compute 函数被发送到集群中的每个执行器,以针对每个分区中的每一行执行。
RDD 抽象既简单又灵活。这种灵活性有一个缺点,即 Spark 无法洞察用户的意图。它不知道计算逻辑是在执行数据过滤、连接还是聚合。因此,Spark 不能执行任何优化,例如执行谓词下推以减少从输入源读取的数据量,推荐更有效的连接类型以加快计算速度,或者修剪输出不再需要的列。
DataFrame API 简介
数据帧是组织成行的不可变的分布式数据集合。每一个都由一组列组成,每一列都有一个名称和一个关联的类型。换句话说,这种分布式数据集合具有由模式定义的结构。如果您熟悉关系数据库管理系统(RDBMS)中的表概念,您会意识到数据帧本质上是等价的。一个通用的Row
对象代表数据帧中的每一行。与 RDD API 不同,DataFrame APIs 提供了一组特定于域的操作,这些操作是相关的并且具有丰富的语义。在接下来的章节中,您将了解更多关于这些 API 的内容。像 RDD API 一样,DataFrame APIs 分为两种类型:转换和动作。评估语义在 RDD 是相同的。转换被延迟评估,而动作被急切地评估。
可以通过从许多结构化数据源读取数据以及从 Hive 或其他数据库中的表读取数据来创建数据帧。此外,Spark SQL 模块通过提供关于 RDD 中数据的模式信息,提供了将 RDD 轻松转换为数据帧的 API。DataFrame API 有 Scala、Java、Python 和 r 版本。
创建数据帧
有许多方法可以创建数据帧;其中一个共同点是隐式或显式地提供一个模式。
从 RDD 创建数据帧
让我们从从 RDD 创建数据帧开始。清单 3-1 首先创建一个包含两列整数的 RDD。然后它调用toDF
隐式函数,该函数使用指定的列名将 RDD 转换为数据帧。列类型是从 RDD 中的数据值推断出来的。清单 3-2 显示了数据帧中两个常用的函数,printSchema,
和show
。printSchema
函数将列名及其相关类型打印到控制台。该函数以表格格式打印出数据帧中的数据。默认情况下,它显示 20 行。要更改显示的默认行数,可以将一个数字传递给show
函数。清单 3-3 是一个指定要显示的行数的例子。
kvDF.show(5)
+----+------+
| key| value|
+----+------+
| 1| 59|
| 2| 60|
| 3| 66|
| 4| 280|
| 5| 40|
+----+------+
Listing 3-3Call show Function to Display 5 Rows in Tabular Format
kvDF.printSchema
|-- key: integer (nullable = false)
|-- value: integer (nullable = false)
kvDF.show
+----+-------+
| key| value|
+----+-------+
| 1| 58|
| 2| 18|
| 3| 237|
| 4| 32|
| 5| 80|
| 6| 210|
| 7| 567|
| 8| 360|
| 9| 288|
| 10| 260|
+----+-------+
Listing 3-2Print Schema and Show the Data of a DataFrame
import scala.util.Random
val rdd = spark.sparkContext.parallelize(1 to 10).map(x => (x, Random.nextInt(100)* x))
val kvDF = rdd.toDF("key","value")
Listing 3-1Creating DataFrame from an RDD of Numbers
Note
值列中的实际数字可能看起来不同,因为它们是通过调用Random.nextInt()
函数随机生成的。
创建数据帧的另一种方法是指定 RDD 和模式,这可以通过编程方式创建。清单 3-4 首先使用一个行对象数组创建一个 RDD,其中每个行对象包含三列。它以编程方式创建一个模式,最后将 RDD 和模式提供给createDataFrame
函数以转换成 DataFrame。清单 3-5 显示了模式和数据帧peopleDF
中的数据。
peopleDF.printSchema
|-- id: long (nullable = true)
|-- name: string (nullable = true)
|-- age: long (nullable = true)
peopleDF.show
+----+-------------+----+
| id | name | age|
+----+-------------+----+
| 1| John Doe| 30|
| 2| Mary Jane| 25|
+----+-------------+----+
Listing 3-5Display Schema of peopleDF and Its Data
import org.apache.spark.sql.Row
import org.apache.spark.sql.types._
val peopleRDD = spark.sparkContext.parallelize(Array(Row(1L, "John Doe", 30L),Row(2L, "Mary Jane", 25L)))
val schema = StructType(Array(
StructField("id", LongType, true),
StructField("name", StringType, true),
StructField("age", LongType, true)
))
val peopleDF = spark.createDataFrame(peopleRDD, schema)
Listing 3-4Create a DataFrame from a RDD with a Schema Created Programmatically
编程创建模式的能力使 Spark 应用程序能够根据一些外部配置灵活地调整模式。
每个StructField
对象都有三条信息:名称、类型、值是否可以为空。
DataFrame 中的每个列类型都映射到一个内部 Spark 类型,它可以是简单的标量类型,也可以是复杂的类型。表 3-1 按照先标量类型后复杂类型的顺序引用 Spark 中可用的 Scala 类型。
表 3-1
Spark Scala 类型参考
|数据类型
|
Scala 类型
|
| --- | --- |
| 布尔类型 | 布尔代数学体系的 |
| 字节类型 | 字节 |
| 排序方式 | 短的 |
| 整合类型 | (同 Internationalorganizations)国际组织 |
| LongType(长型) | 长的 |
| 浮动型 | 浮动 |
| DoubleType(双精度型) | 两倍 |
| 十进制 | Java . math . bigdecline |
| StringType | 线 |
| 二元类型 | 数组[字节] |
| TimestampType | java.sql.Timestamp |
| datatype(日期类型) | java.sql.Date |
| ArrayType | scala.collection.Seq |
| 字体渲染 | scala.collection.Map |
| 结构类型 | org.apache.spark.sql.Row |
从一系列数字创建数据帧
Spark 2.0 为主要使用数据帧和数据集 API 的 Spark 应用程序引入了新的入口点。这个新的入口点由SparkSession
类表示,它有一个叫做range
的方便函数,您可以使用它轻松地创建一个包含一列的数据集,该列的名称为id
,类型为LongType
。这个函数有一些变化,可以使用额外的参数来指定结束和步骤。清单 3-6 提供了使用该函数创建数据帧的例子。
val df1 = spark.range(5).toDF("num").show
+-----+
| num|
+-----+
| 0|
| 1|
| 2|
| 3|
| 4|
+-----+
spark.range(5,10).toDF("num").show
+-----+
| num|
+-----+
| 5|
| 6|
| 7|
| 8|
| 9|
+-----+
spark.range(5,15,2).toDF("num").show
+------+
| num|
+------+
| 5|
| 7|
| 9|
| 11|
| 13|
+------+
Listing 3-6Examples Using SparkSession.range Function to Create a DataFrame
range
函数的最后一个版本有三个参数。第一个代表起始值,第二个代表结束值(不含),最后一个代表步长。注意range
函数只能创建一个列数据帧。你对如何创建两列数据帧有什么想法吗?
创建多列 DataFrame 的一个选项是使用 Spark 的隐式方法,它转换 Scala Seq 集合中的元组集合。列表 3-7 是 Spark 的toDF
隐式的一个例子。
val movies = Seq(("Damon, Matt", "The Bourne Ultimatum", 2007L),
("Damon, Matt", "Good Will Hunting", 1997L))
val moviesDF = movies.toDF("actor", "title", "year")
moviesDF.printSchema
|-- actor: string (nullable = true)
|-- title: string (nullable = true)
|-- year: long (nullable = false)
moviesDF.show
+-----------+--------------------+------+
| actor| title| year|
+-----------+--------------------+------+
|Damon, Matt|The Bourne Ultimatum| 2007|
|Damon, Matt| Good Will Hunting| 1997|
+-----------+--------------------+------+
Listing 3-7Converting a Collection Tuples to a DataFrame Using Spark’s toDF Implicit
这些创建数据帧的有趣方法使得学习和使用数据帧 API 变得容易,而不需要从一些外部文件加载数据。然而,当您开始对大型数据集执行严肃的数据分析时,必须知道如何从外部数据源加载数据,这将在接下来讨论。
从数据源创建数据帧
Spark SQL 支持一组内置数据源,其中每个数据源都映射到一种数据格式。Spark SQL 模块中的数据源层被设计为可扩展的,因此自定义数据源可以很容易地集成到 DataFrame APIs 中。Spark 社区编写了数百个自定义数据源,实现起来并不太难。
Spark 中用于读写数据的两个主要类分别是DataFrameReader
和DataFrameWriter
。本节介绍了如何使用DataFrameReader
类中的 API,以及从特定数据源读取数据时的各种可用选项。
DataFrameReader
类的实例和SparkSession
类的read
变量一样可用。您可以从 Spark shell 或 Spark 应用程序中引用它,如清单 3-8 所示。
spark.read
Listing 3-8Using read Variable from SparkSession
清单 3-9 中描述了与 DataFrameReader 交互的常见模式。
spark.read.format(...).option("key", value").schema(...).load()
Listing 3-9Common Pattern for Interacting with DataFrameReader
表 3-2 描述了读取数据时使用的三条主要信息:格式、选项和模式。关于这三条信息的更多内容将在本章后面讨论。
表 3-2
DataFrameReader 上的主要信息
|名字
|
可选择的
|
评论
|
| --- | --- | --- |
| 格式 | 不 | 它可以是内置数据源之一,也可以是自定义格式。对于内置格式,可以使用短名称(json、parquet、jdbc、orc、csv、text)。对于自定义数据源,需要提供完全限定的名称。参见清单 3-10 中的示例。 |
| 选择权 | 是 | DataFrameReader 对每种数据源格式都有一组默认选项。您可以通过提供一个值作为option
函数来覆盖这些默认值。 |
| 计划 | 是 | 一些数据源在数据文件中嵌入了模式,尤其是 Parquet 和 ORC。在这些情况下,会自动推断出模式。对于其他情况,您可能需要提供一个模式。 |
spark.read.json("<path>")
spark.read.format("json")
spark.read.parquet("<path>")
spark.read.format("parquet")
spark.read.jdbc
spark.read.format("jdbc")
spark.read.orc("<path>")
spark.read.format("orc")
spark.read.csv("<path>")
spark.read.format("csv")
spark.read.text("<path>")
spark.read.format("text")
// custom data source – fully qualified package name
spark.read.format("org.example.mysource")
Listing 3-10Specifying Data Source Format
表 3-3 描述了 Spark 的六个内置数据源,并为每个数据源提供了注释。
表 3-3
Spark 的内置数据源
|名字
|
数据格式
|
评论
|
| --- | --- | --- |
| 文本文件 | 文本 | 没有结构。 |
| 战斗支援车 | 文本 | 逗号分隔的值。可以指定另一个分隔符。可以从标题中引用列名。 |
| 数据 | 文本 | 流行的半结构化格式。列名和数据类型是自动推断的 |
| 镶木地板 | 二进制的 | (默认格式)Hadoop 社区中流行的二进制格式。 |
| 妖魔 | 二进制的 | Hadoop 社区中另一种流行的二进制格式。 |
| 数据库编程 | 二进制的 | 读写 RDBMS 的通用格式。 |
通过读取文本文件创建数据帧
文本文件包含非结构化数据。在读入 Spark 时,每一行都成为数据帧中的一行。在 www.gutenberg.org
有很多纯文本格式的免费书籍可供下载。对于纯文本文件,解析单词的一种常见方法是用空格分隔符分隔每行。这类似于典型的字数统计示例的工作方式。清单 3-11 是一个自述文本文件的例子。
val textFile = spark.read.text("README.md")
textFile.printSchema
|-- value: string (nullable = true)
// show 5 lines and don't truncate
textFile.show(5, false)
+-------------------------------------------------------------------------+
|value |
+-------------------------------------------------------------------------+
|# Apache Spark |
| |
|Spark is a fast and general cluster computing system for Big Data. 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 |
+-------------------------------------------------------------------------+
Listing 3-11Read README.md File as a Text File from Spark Shell
如果文本文件包含可以用来解析每行中的列的分隔符,那么最好使用 CSV 格式来读取它,这将在下一节中介绍。
通过读取 CSV 文件创建数据帧
一种流行的文本文件格式是 CSV,它代表逗号分隔的值。像 Microsoft Excel 这样的流行工具可以轻松地导入和导出 CSV 格式的数据。Spark 中的 CSV 解析器非常灵活,可以使用用户提供的分隔符解析文本文件。逗号分隔符恰好是默认分隔符。这意味着您可以使用 CSV 格式读取制表符分隔值文本文件或其他带有任意分隔符的文本文件。
有些 CSV 文件有文件头,有些没有。由于列值可能包含逗号,因此使用特殊字符对其进行转义是一种常见且良好的做法。表 3-4 描述了处理 CSV 格式时常用的选项。有关选项的完整列表,请参见 https://github.com/apache/spark
的CSVOptions
类。
表 3-4
CSV 常见选项
|钥匙
|
价值
|
默认
|
描述
|
| --- | --- | --- | --- |
| 九月 | 单字符 | , | 用作每列分隔符的单个字符值。 |
| 页眉 | 真,假 | 错误的 | 如果该值为 true,则意味着文件中的第一行代表列名。 |
| 逃跑 | 任何字符 | \ | 用于转义列值中字符的字符与 sep 相同。 |
| 推断模式 | 真,假 | 错误的 | Spark 是否应该尝试根据列值推断列类型。 |
将header
和inferSchema
选项指定为 true 不需要您指定模式。否则,您需要手动或编程定义一个模式,并将其传递给schema
函数。如果inferSchema
选项为 false,并且没有提供模式,Spark 假定所有列的数据类型都是字符串类型。
您用作示例的数据文件在data/chapter4
文件夹中称为movies.csv
。该文件包含每一列的标题:actor, title, year.
列表 3-12 提供了一些读取 CSV 文件的例子。
val movies = spark.read.option("header","true").csv("<path>/book/chapter4/data/movies/movies.csv")
movies.printSchema
|-- actor: string (nullable = true)
|-- title: string (nullable = true)
|-- year: string (nullable = true)
// now try to infer the schema
val movies2 = spark.read.option("header","true").option("inferSchema","true")
.csv("<path>/book/chapter4/data/movies/movies.csv")
movies2.printSchema
|-- actor: string (nullable = true)
|-- title: string (nullable = true)
|-- year: integer (nullable = true)
// now try to manually provide a schema
import org.apache.spark.sql.types._
val movieSchema = StructType(Array(StructField("actor_name", StringType, true),
StructField("movie_title", StringType, true),
StructField("produced_year", LongType, true)))
val movies3 = spark.read.option("header","true").schema(movieSchema)
.csv("<path>/book/chapter4/data/movies/movies.csv")
movies3.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
movies3.show(5)
+-----------------+--------------+--------------+
| actor_name| movie_title| produced_year|
+-----------------+--------------+--------------+
|McClure, Marc (I)| Freaky Friday| 2003|
|McClure, Marc (I)| Coach Carter| 2005|
|McClure, Marc (I)| Superman II| 1980|
|McClure, Marc (I)| Apollo 13| 1995|
|McClure, Marc (I)| Superman| 1978|
+-----------------+--------------+--------------+
Listing 3-12Read CSV Files with Various Options
第一个示例读取文件movies.csv
,并将第一行指定为标题。Spark 可以识别列名。但是,由于inferSchema
选项没有设置为 true,所以所有列的类型都是string
。第二个例子添加了inferSchema
选项,Spark 能够识别列类型。第三个例子提供了一个列名不同于标题中的列名的模式,因此 Spark 使用所提供的列名。
现在让我们试着用不同的分隔符,而不是逗号来读入一个文本文件。在这种情况下,您为 Spark 使用的 sep 选项指定一个值。清单 3-13 显示了在data/chapter4
文件夹中一个名为movies.tsv
的文件。
val movies4 = spark.read.option("header","true").option("sep", "\t")
.schema(movieSchema).csv("<path>/book/chapter4/data/movies/movies.tsv")
movies.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
Listing 3-13Read a TSV File with CSV Format
如您所见,处理包含逗号分隔值和其他分隔值的文本文件非常容易。
通过读取 JSON 文件创建数据帧
JSON 是 JavaScript 社区中非常有名的格式。它被认为是半结构化格式,因为每个对象(也称为行)都有一个结构,每个列都有一个名称。在 web 应用程序开发领域,JSON 被广泛用作后端服务器和浏览器端之间传输数据的数据格式。JSON 的优势之一是它提供了一种灵活的格式,可以对任何用例建模,并且它可以支持嵌套结构。JSON 有一个与冗长相关的缺点。数据文件的每一行中都有重复的列名(假设数据文件有一百万行)。
Spark 使得读取 JSON 文件中的数据变得很容易。但是,有一点你需要注意。JSON 对象可以在一行中表示,也可以跨多行表示,这是您需要让 Spark 知道的。假设 JSON 数据文件只包含列名,不包含数据类型,Spark 如何提出模式呢?Spark 尽力通过解析一组样本记录来推断模式。要采样的记录数量由samplingRatio
选项决定,其默认值为 1.0。因此,加载一个非常大的 JSON 文件是相当昂贵的。在这种情况下,您可以降低samplingRatio
值来加快数据加载过程。表 3-5 描述了 JSON 格式的常用选项列表。
表 3-5
JSON 常见选项
|钥匙
|
价值
|
默认
|
描述
|
| --- | --- | --- | --- |
| 允许注释 | 真,假 | 错误的 | 忽略 JSON 文件中的注释 |
| 多线 | 真,假 | 错误的 | 将整个文件视为一个跨越多行的大型 JSON 对象 |
| 抽样比率 | Zero point three | One | 为推断架构而读取的采样大小 |
清单 3-14 展示了两个读取 JSON 文件的例子。第一个只是读取一个 JSON 文件,而不覆盖任何选项值。注意 Spark 会根据 JSON 文件中的信息自动检测列名和数据类型。第二个示例指定了一个模式。
val movies5 = spark.read.json("<path>/book/chapter4/data/movies/movies.json")
movies.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
// specify a schema to override the Spark's inferring schema.
// producted_year is specified as integer type
import org.apache.spark.sql.types._
val movieSchema2 = StructType(Array(StructField("actor_name", StringType, true),
StructField("movie_title", StringType, true),
StructField("produced_year", IntegerType, true)))
val movies6 = spark.read.option("inferSchema","true").schema(movieSchema2)
.json("<path>/book/chapter4/data/movies/movies.json")
movies6.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: integer (nullable = true)
Listing 3-14Various Example of Reading a JSON File
当模式中指定的列数据类型与 JSON 文件中的值不匹配时会发生什么?默认情况下,当 Spark 遇到损坏的记录或遇到解析错误时,它会将该行中所有列的值设置为 null。您可以告诉 Spark 快速失败,而不是获得空值。清单 3-15 通过将mode
选项指定为failFast
来告诉 Spark 的解析逻辑快速失败。
// set data type for actor_name as BooleanType
import org.apache.spark.sql.types._
val badMovieSchema = StructType(Array(StructField("actor_name", BooleanType, true),
StructField("movie_title", StringType, true),
StructField("produced_year", IntegerType, true)))
val movies7 = spark.read.schema(badMovieSchema)
.json("<path>/book/chapter4/data/movies/movies.json")
movies7.printSchema
|-- actor_name: boolean (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: integer (nullable = true)
movies7.show(5)
+----------+-----------+-------------+
|actor_name|movie_title|produced_year|
+----------+-----------+-------------+
| null| null| null|
| null| null| null|
| null| null| null|
| null| null| null|
| null| null| null|
+----------+-----------+-------------+
// tell Spark to fail fast when facing a parsing error
val movies8 = spark.read.option("mode","failFast").schema(badMovieSchema)
.json("<path>/book/chapter4/data/movies/movies.json")
movies8.printSchema
|-- actor_name: boolean (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: integer (nullable = true)
// Spark will throw a RuntimeException when executing an action
movies8.show(5)
ERROR Executor: Exception in task 0.0 in stage 3.0 (TID 3)
java.lang.RuntimeException
: Failed to parse a value for data type BooleanType (current token: VALUE_STRING).
Listing 3-15Parsing Error and How to Tell Spark to Fail Fast
通过读取拼花文件创建数据帧
Parquet 是 Hadoop 生态系统中最流行的开源列存储格式之一。它是在 Twitter 上创建的。它的流行是由于它的自描述数据格式,并且它通过利用压缩以高度紧凑的结构存储数据。列存储格式旨在很好地处理数据分析工作负载,在数据分析过程中只使用一小部分列。Parquet 将每一列的数据存储在一个单独的文件中;因此,数据分析中不需要的列不必读入。在支持具有嵌套结构的复杂数据类型时,它非常灵活。像 CSV 和 JSON 这样的文本文件格式对于小文件来说是很好的,它们是人类可读的。对于处理大型数据集来说,Parquet 是一种更好的文件格式,可以降低存储成本并加快读取速度。如果你偷看一下chapter4/data/movies
文件夹中的movies.parquet
文件,你会看到它的大小大约是movies.csv
的六分之一。
Spark 与 Parquet 文件格式配合得非常好,事实上,Parquet 是 Spark 中读写数据的默认文件格式。清单 3-16 显示了一个读取拼花文件的例子。注意,您不需要提供一个模式,也不需要让 Spark 推断模式。Spark 可以从 Parquet 文件中检索模式。
Spark 在从 Parquet 读取数据时做的一个很酷的优化是按列批次解压缩和解码,这大大加快了读取速度。
// Parquet is the default format, so we don't need to specify the format when reading
val movies9 = spark.read.load("<path>/book/chapter4/data/movies/movies.parquet")
movies9.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
// If we want to more explicit, we can specify the path to the parqet function
val movies10 = spark.read.parquet("<path>/book/chapter4/data/movies/movies.parquet")
movies10.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
Listing 3-16Reading a Parquet File
in Spark
通过读取 ORC 文件创建数据帧
优化行列(ORC)是 Hadoop 生态系统中另一种流行的开源自描述列存储格式。它是由 Cloudera 创建的,作为大规模加速 Hive 计划的一部分。在效率和速度方面,它与 Parquet 非常相似,是为分析工作负载而设计的。使用 ORC 文件就像使用拼花文件一样简单。清单 3-17 显示了一个从 ORC 文件中读取数据来创建数据帧的例子。
val movies11 = spark.read.orc("<path>/book/chapter4/data/movies/movies.orc")
movies11.printSchema
|-- actor_name: string (nullable = true)
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
movies11.show(5)
+--------------------------+-------------------+--------------+
| actor_name| movie_title| produced_year|
+--------------------------+-------------------+--------------+
| McClure, Marc (I)| Coach Carter| 2005|
| McClure, Marc (I)| Superman II| 1980|
| McClure, Marc (I)| Apollo 13| 1995|
| McClure, Marc (I)| Superman| 1978|
| McClure, Marc (I)| Back to the Future| 1985|
+--------------------------+-------------------+--------------+
Listing 3-17Reading ORC File in Spark
从 JDBC 创建数据帧
JDBC 是一个标准的应用程序 API,用于从关系数据库管理系统读取数据和向关系数据库管理系统写入数据。Spark 支持 JDBC 数据源,这意味着您可以使用 Spark 从任何现有的 RDBMSs(如 MySQL、PostgreSQL、Oracle、SQLite 等)读取数据和向其中写入数据。使用 JDBC 数据源时,需要提供一些重要的信息:RDBMS 的 JDBC 驱动程序、连接 URL、认证信息和表名。
为了让 Spark 连接到 RDBMS,它必须能够在运行时访问 JDBC 驱动程序 JAR 文件。因此,需要将 JDBC 驱动程序的位置添加到 Spark 类路径中。清单 3-18 展示了如何从 Spark Shell 连接到 MySQL。
./bin/spark-shell ../jdbc/mysql-connector-java-5.1.45/mysql-connector-java-5.1.45-bin.jar --jars ../jdbc/mysql-connector-java-5.1.45/mysql-connector-java-5.1.45-bin.jar
Listing 3-18Specifying a JDBC Driver When Starting the Spark Shell
一旦 Spark shell 成功启动,您可以通过使用java.sql.DriverManager
快速验证 Spark 是否可以连接到您的 RDBMS,如清单 3-19 所示。这个例子试图测试一个到 MySQL 的连接。如果你的 RDBMS 不是 MySQL,URL 格式会有一点不同,所以请查阅你正在使用的 JDBC 驱动程序的文档。
import java.sql.DriverManager
val connectionURL = "jdbc:mysql://localhost:3306/<table>?user=<username>&password=<password>"
val connection = DriverManager.getConnection(connectionURL)
connection.isClosed()
connection close()
Listing 3-19Testing Connection to MySQL in Spark Shell
如果您没有得到任何关于连接的异常,Spark shell 可以成功地连接到您的 RDBMS。
表 3-6 描述了使用 JDBC 驱动程序时需要指定的主要选项。有关选项的完整列表,请咨询 https://spark.apache.org/docs/latest/sql-programming-guide.html#jdbc-to-other-databases
。
表 3-6
JDBC 数据源的主要选项
|钥匙
|
描述
|
| --- | --- |
| 全球资源定位器(Uniform Resource Locator) | Spark 要连接到的 JDBC URL。它至少应该包含主机、端口和数据库名称。对于 MySQL,它可能看起来像 JDBC:MySQL://localhost:3306/saki la。 |
| 成问题的 | Spark 读取或写入数据的数据库表的名称。 |
| 驾驶员 | Spark 实例化以连接到前面的 URL 的 JDBC 驱动程序的类名。请查阅您正在使用的 JDBC 驱动程序文档。对于 MySQL 连接器/J 驱动,类名为com.mysql.jdbc.Driver
。 |
清单 3-20 展示了一个从 MySQL 服务器中的 Sakila 数据库的 film 表中读取数据的例子。
val mysqlURL= "jdbc:mysql://localhost:3306/sakila"
val filmDF = spark.read.format("jdbc").option("driver", "com.mysql.jdbc.Driver")
.option("url", mysqlURL)
.option("dbtable", "film")
.option("user", "<username>")
.option("password","<pasword>")
.load()
filmDF.printSchema
|-- film_id: integer (nullable = false)
|-- title: string (nullable = false)
|-- description: string (nullable = true)
|-- release_year: date (nullable = true)
|-- language_id: integer (nullable = false)
|-- original_language_id: integer (nullable = true)
|-- rental_duration: integer (nullable = false)
|-- rental_rate: decimal(4,2) (nullable = false)
|-- length: integer (nullable = true)
|-- replacement_cost: decimal(5,2) (nullable = false)
|-- rating: string (nullable = true)
|-- special_features: string (nullable = true)
|-- last_update: timestamp (nullable = false)
filmDF.select("film_id","title").show(5)
+-------+---------------------+
|film_id| title|
+-------+---------------------+
| 1| ACADEMY DINOSAUR|
| 2| ACE GOLDFINGER|
| 3| ADAPTATION HOLES|
| 4| AFFAIR PREJUDICE|
| 5| AFRICAN EGG|
+-------+---------------------+
Listing 3-20Reading Data from a Table in MySQL Server
当使用 JDBC 数据源时,Spark 将过滤条件尽可能向下推到 RDBMS。通过这样做,大部分数据在 RDBMS 级别被过滤掉,因此这加快了数据过滤逻辑,并大大减少了 Spark 需要读取的数据量。这种优化被称为谓词下推,当 Spark 知道数据源可以支持过滤功能时,它经常这样做。Parquet 是另一个具有这种能力的数据源。第四章中的“Catalyst Optimizer”部分提供了一个示例。
使用结构化操作
现在您已经知道如何创建数据帧,下一步是学习如何使用结构化操作来操作或转换它们。与 RDD 运算不同,结构化运算被设计为更加关系化,这意味着这些运算反映了您可以使用 SQL 执行的表达式类型,如投影、过滤、转换、连接等。与 RDD 操作类似,结构化操作也分为两类:转换和操作。结构化转换和动作的语义与 rdd 中的相同。换句话说,结构化转换被延迟评估,而结构化动作被急切地评估。
结构化操作有时被描述为分布式数据操作的领域特定语言(DSL)。DSL 是一种专门用于特定应用领域的计算机语言。在这种情况下,应用程序域是分布式数据操作。如果你曾经使用过 SQL,那么学习结构化操作是很容易的。
表 3-7 描述了常用的数据帧结构化转换。提醒一下,数据帧是不可变的,它的转换操作总是返回一个新的数据帧。
表 3-7
常用的数据帧结构化转换
|操作
|
描述
|
| --- | --- |
| 挑选 | 从数据帧中的现有列集中选择一个或多个列。select 的一个更专业的术语是投影。在投影过程中,可以变换和操纵柱。 |
| 选择表达式 | 类似于 select,但在转换每一列时提供了强大的 SQL 表达式。 |
| 过滤器在哪里 | filter
和where
都有相同的语义。where
与 SQL 中的 where 条件更加相关和相似。它们都用于根据给定的布尔条件过滤行。 |
| 明显的删除重复项 | 从数据帧中删除重复的行 |
| 分类排序依据 | 按提供的列对数据帧进行排序 |
| 限制 | 通过取前“n”行返回一个新的数据帧。 |
| 联盟 | 合并两个数据帧中的行,并将其作为新的数据帧返回。 |
| 带栏 | 用于在数据帧中添加列或替换现有列 |
| 带列名 | 重命名现有列。如果给定的列名在模式中不存在,那么它就是一个空操作。 |
| 滴 | 从 DataFrame 中删除一列或多列。如果模式不包含给定的列名,则该操作不执行任何操作 |
| 样品 | 根据给定的分数、可选的种子值和可选的替换选项,随机选择一组行。 |
| 随机拆分 | 基于给定的权重将数据帧分割成一个或多个数据帧。在机器学习过程中,将主数据集分为训练数据集和测试数据集。 |
| 加入 | 连接两个数据帧。Spark 支持许多类型的连接。更多信息将在下一章介绍。 |
| 群组依据 | 按一列或多列对数据帧进行分组。常见的模式是在 groupBy 之后执行聚合。更多信息将在下一章介绍。 |
使用列
表 3-7 中的大多数数据帧结构化操作需要你指定一个或多个列。对于某些应用程序,列是在字符串中指定的;对于其他的,列需要被指定为Column
类的实例。质疑为什么有两种选择,什么时候用什么,是完全公平的。要回答这些问题,您需要理解Column
类提供的功能。在较高层次上,Column 类的功能可以分为以下几类。
-
像加法、乘法等数学运算
-
列值或文字之间的逻辑比较,如等于、大于和小于
-
字符串模式匹配,如以开头,以结尾,等等。
关于Column
类中可用函数的完整列表,请参考位于 https://spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.Column
的 Scala 文档。
了解了Column
类提供的功能后,您可以得出结论,无论何时需要指定列表达式,都有必要将列指定为Column
类的实例,而不是字符串。接下来的例子说明了这一点。
引用一个专栏有不同的方式,这在 Spark 用户社区中造成了混乱。一个常见的问题是何时使用哪一个,答案是——视情况而定。表 3-8 描述了可用的功能选项。
表 3-8
引用列的方式
|功能
|
例子
|
描述
|
| --- | --- | --- |
| "" | "columnName
" | 将列作为字符串类型引用。 |
| col
| col(``columnName``)
| col
函数返回Column
类的一个实例。 |
| column
| column(``columnName``)
| 类似于col
,这个函数返回一个Column
类的实例。 |
| $
| $``columnName
| 一种仅在 Scala 中构造Column
类的语法糖方式。 |
| (tick)
| 'columnName
| 一种利用 Scala 符号文字特性在 Scala 中构造Column
类的语法糖方式。 |
col
和column
函数是同义的,在 Scala 和 Python Spark APIs 中都有。如果你经常在 Spark Scala 和 Python APIs 之间切换,那么使用col
函数是有意义的,这样你的代码就有了一致性。如果您主要或专门使用 Spark Scala APIs,那么我建议您使用'(撇号),因为只需要键入一个字符。DataFrame 类有自己的col
函数,在执行连接时,该函数可以区分两个或更多 data frame 中同名的列。清单 3-21 提供了引用一个列的不同方法的例子。
import org.apache.spark.sql.functions._
val kvDF = Seq((1,2),(2,3)).toDF("key","value")
// to display column names in a DataFrame, we can call the columns function
kvDF.columns
Array[String] = Array(key, value)
kvDF.select("key")
kvDF.select(col("key"))
kvDF.select(column("key"))
kvDF.select($"key")
kvDF.select('key)
// using the col function of DataFrame
kvDF.select(kvDF.col("key"))
kvDF.select('key, 'key > 1).show
+---+----------+
|key| (key > 1)|
+---+----------+
| 1| false|
| 2| true|
+---+----------+
Listing 3-21Different Ways of Referring to Columns
这个例子演示了一个列表达式,因此需要指定一个列作为Column
类的实例。如果列被指定为字符串,则会导致类型不匹配错误。在各种数据帧结构操作的例子中可以找到更多的列表达式的例子。
使用结构化转换
本节提供了表 3-7 中列出的结构化转换的使用示例。为了保持一致,所有示例都一致使用'(撇号)来指代数据帧中的列。为了减少冗余,大多数例子都引用通过读取拼花文件创建的movies
数据帧(参见清单 3-22 )。
val movies = spark.read.parquet("<path>/chapter4/data/movies/movies.parquet")
Listing 3-22Creating the movies DataFame from a Parquet File
选择(列)
这种转换通常执行投影,从数据帧中选择所有列或列的子集。在选择过程中,每个列都可以通过列表达式进行转换。这种转换有两种变体。一个将列作为字符串,另一个将列作为Column
类。这种转换不允许您在使用这两种变体之一时混合使用列类型。清单 3-23 是这两种变化的一个例子。
movies.select("movie_title","produced_year").show(5)
+------------------------+--------------+
| movie_title| produced_year|
+------------------------+--------------+
| Coach Carter| 2005|
| Superman II| 1980|
| Apollo 13| 1995|
| Superman| 1978|
| Back to the Future| 1985|
+------------------------+--------------+
// using a column expression to transform year to decade
movies.select('movie_title,('produced_year - ('produced_year % 10)).as("produced_decade")).show(5)
+------------------------+----------------+
| movie_title| produced_decade|
+------------------------+----------------+
| Coach Carter| 2000|
| Superman II| 1980|
| Apollo 13| 1990|
| Superman| 1970|
| Back to the Future| 1980|
+------------------------+----------------+
Listing 3-23Two Variations of Select Transformation
第二个例子需要两个列表达式:取模和减法。两者都是通过Column
类中的模(%
)和减法(-
)函数实现的(参见 Scala 文档)。默认情况下,Spark 使用列表达式作为结果列的名称。为了提高可读性,as
函数将其重命名为一个更易于阅读的列名。作为一个敏锐的读者,您可能会发现可以向 DataFrame 添加一列或多列的 select 转换。
selectExpr(表示式)
此转换是 select 转换的变体。一个很大的区别是它接受一个或多个 SQL 表达式,而不是列。然而,两者本质上都在执行相同的投射任务。SQL 表达式是强大而灵活的构造,允许您自然地表达列转换逻辑,就像您思考的方式一样。您可以用字符串格式表示 SQL 表达式,Spark 将它们解析成一个逻辑树,以正确的顺序对它们求值。
如果您想创建一个包含电影数据帧中所有列的新数据帧,并引入一个新的列来表示电影制作的年代,请执行清单 3-24 中所示的操作。
movies.selectExpr("*","(produced_year - (produced_year % 10)) as decade").show(5)
+-----------------+--------------------+-------------------+----------+
| actor_name| movie_title| produced_year| decade|
+-----------------+--------------------+-------------------+----------+
|McClure, Marc (I)| Coach Carter| 2005| 2000|
|McClure, Marc (I)| Superman II| 1980| 1980|
|McClure, Marc (I)| Apollo 13| 1995| 1990|
|McClure, Marc (I)| Superman| 1978| 1970|
|McClure, Marc (I)| Back to the Future| 1985| 1980|
+-----------------+--------------------+-------------------+----------+
Listing 3-24Adding the Decade Column to Movies DataFrame using SQL Expression
SQL 表达式和内置函数的结合使得执行数据分析变得容易,否则需要多个步骤。清单 3-25 展示了在一条语句中确定电影数据集中唯一电影标题和唯一演员的数量是多么容易。count
函数对整个数据帧执行聚合。
movies.selectExpr("count(distinct(movie_title)) as movies","count(distinct(actor_name)) as actors").show
+---------+--------+
| movies| actors |
+---------+--------+
| 1409| 6527 |
+---------+--------+
Listing 3-25Using SQL Expression and Built-in Functions
填充符(条件),其中(条件)
这种转变很简单。它过滤掉不满足给定条件的行,换句话说,当条件评估为 false 时。看待筛选转换行为的另一种方式是,它只返回满足指定条件的行。给定的条件可以是简单的,也可以是复杂的。使用这种转换需要知道如何利用Column
类中的一些逻辑比较函数,比如等于、小于、大于和不等于。filter
和where
转换的行为是一样的,所以选择一个你觉得最舒服的。后者只是比前者关系更密切一点。清单 3-26 展示了一些过滤的例子。
movies.filter('produced_year < 2000)
movies.where('produced_year > 2000)
movies.filter('produced_year >= 2000)
movies.where('produced_year >= 2000)
// equality comparison require 3 equal signs
movies.filter('produced_year === 2000).show(5)
+-------------------+---------------------------+--------------+
| actor_name| movie_title| produced_year|
+-------------------+---------------------------+--------------+
| Cooper, Chris (I)| Me, Myself & Irene| 2000|
| Cooper, Chris (I)| The Patriot| 2000|
| Jolie, Angelina| Gone in Sixty Sec...| 2000|
| Yip, Françoise| Romeo Must Die| 2000|
| Danner, Blythe| Meet the Parents| 2000|
+-------------------+---------------------------+--------------+
// inequality comparison uses an interesting looking operator =!=
movies.select("movie_title","produced_year").filter('produced_year =!= 2000).show(5)
+-------------------+--------------+
| movie_title| produced_year|
+-------------------+--------------+
| Coach Carter| 2005|
| Superman II| 1980|
| Apollo 13| 1995|
| Superman| 1978|
| Back to the Future| 1985|
+-------------------+--------------+
// to combine one or more comparison
expressions, we will use either the OR and AND expression operator
movies.filter('produced_year >= 2000 && length('movie_title) < 5).show(5)
+----------------+------------+--------------+
| actor_name| movie_title| produced_year|
+----------------+------------+--------------+
| Jolie, Angelina| Salt| 2010|
| Cueto, Esteban| xXx| 2002|
| Butters, Mike| Saw| 2004|
| Franko, Victor| 21| 2008|
| Ogbonna, Chuk| Salt| 2010|
+----------------+------------+--------------+
// the other way of accomplishing the result is by calling the filter function two times
movies.filter('produced_year >= 2000).filter(length('movie_title) < 5).show(5)
Listing 3-26Filter Rows with Logical Comparison Functions in Column Class
不同,删除重复项
这两种转换具有相同的行为。但是,dropDuplicates
允许您控制应该在重复数据删除逻辑中使用哪些列。如果未指定,重复数据删除逻辑将使用DataFrame
中的所有列。清单 3-27 展示了计算电影数据集中有多少部电影的不同方法。
movies.select("movie_title").distinct.selectExpr("count(movie_title) as movies").show
movies.dropDuplicates("movie_title").selectExpr("count(movie_title) as movies").show
+--------+
| movies|
+--------+
| 1409|
+--------+
Listing 3-27Using distinct and dropDuplicates to Achieve the Same Goal
就性能而言,这两种方法没有区别,因为 Spark 将它们转换为相同的逻辑计划。
排序(列),排序依据(列)
两种转换具有相同的语义。orderBy
转换比另一个转换更有关系。默认情况下,排序是升序,很容易将其更改为降序。当指定多个列时,每个列可能有不同的顺序。清单 3-28 有一些例子。
val movieTitles = movies.dropDuplicates("movie_title")
.selectExpr("movie_title", "length(movie_title) as title_length", , "produced_year")
movieTitles.sort('title_length).show(5)
+-----------+-------------+--------------+
|movie_title| title_length| produced_year|
+-----------+-------------+--------------+
| RV| 2| 2006|
| 12| 2| 2007|
| Up| 2| 2009|
| X2| 2| 2003|
| 21| 2| 2008|
+-----------+-------------+--------------+
// sorting in descending order
movieTitles.orderBy('title_length.desc).show(5)
+---------------------+-------------+--------------+
| movie_title| title_length| produced_year|
+---------------------+-------------+--------------+
| Borat: Cultural L...| 83| 2006|
| The Chronicles of...| 62| 2005|
| Hannah Montana & ...| 57| 2008|
| The Chronicles of...| 56| 2010|
| Istoriya pro Rich...| 56| 1997|
+---------------------+-------------+--------------+
// sorting by two columns in different orders
movieTitles.orderBy('title_length.desc, 'produced_year).show(5)
+---------------------+-------------+--------------+
| movie_title| title_length| produced_year|
+---------------------+-------------+--------------+
| Borat: Cultural L...| 83| 2006|
| The Chronicles of...| 62| 2005|
| Hannah Montana & ...| 57| 2008|
| Istoriya pro Rich...| 56| 1997|
| The Chronicles of...| 56| 2010|
+---------------------+-------------+--------------+
Listing 3-28Sorting the DataFrame in Ascending and Descending Order
请注意,最后两部电影的片名长度相同,但它们的年份是按照正确的升序排列的。
极限值
该转换通过获取前 n 行返回一个新的数据帧。这种转换通常在排序完成后使用,以便根据排序顺序找出顶部的 n 或底部的 n 行。清单 3-20 展示了一个使用limit
转换来查找名字最长的前十名演员的例子。
// first create a DataFrame with their name and associated length
val actorNameDF = movies.select("actor_name").distinct.selectExpr("*", "length(actor_name) as length")
// order names by length and retrieve the top 10
actorNameDF.orderBy('length.desc).limit(10).show
+--------------------------------+-------+
| actor_name | length|
+--------------------------------+-------+
| Driscoll, Timothy 'TJ' James| 28|
| Badalamenti II, Peter Donald| 28|
| Shepard, Maridean Mansfield | 27|
| Martino, Nicholas Alexander | 27|
| Marshall-Fricker, Charlotte | 27|
| Phillips, Christopher (III) | 27|
| Pahlavi, Shah Mohammad Reza | 27|
| Juan, The Bishop Don Magic | 26|
| Van de Kamp Buchanan, Ryan | 26|
| Lough Haggquist, Catherine | 26|
+--------------------------------+-------+
Listing 3-29Using the limit Transformation to Figure Top Ten Actors with the Longest Name
联盟(奥赛达费布雷省)
你知道了数据帧是不可变的。如果需要向现有的数据帧中添加更多的行,那么union
转换对于这个目的以及合并两个数据帧中的行是有用的。这种转换要求两个数据帧具有相同的模式,这意味着两个列名及其顺序必须完全匹配。假设数据帧中的一部电影缺少一个演员,而您想要修复这个问题。清单 3-30 展示了如何使用联合转换来实现这一点。
// the movie we want to add missing actor is "12"
val shortNameMovieDF = movies.where('movie_title === "12")
shortNameMovieDF.show
+---------------------+------------+---------------+
| actor_name| movie_title| produced_year |
+---------------------+------------+---------------+
| Efremov, Mikhail| 12| 2007|
| Stoyanov, Yuriy| 12| 2007|
| Gazarov, Sergey| 12| 2007|
| Verzhbitskiy, Viktor| 12| 2007|
+---------------------+------------+---------------+
// create a DataFrame with one row
import org.apache.spark.sql.Row
val forgottenActor = Seq(Row("Brychta, Edita", "12", 2007L))
val forgottenActorRDD = spark.sparkContext.parallelize(forgottenActor)
val forgottenActorDF = spark.createDataFrame(forgottenActorRDD, shortNameMovieDF.schema)
// now adding the missing action
val completeShortNameMovieDF = shortNameMovieDF.union(forgottenActorDF)
completeShortNameMovieDF.union(forgottenActorDF).show
+----------------------+------------+---------------+
| actor_name| movie_title| produced_year|
+----------------------+------------+---------------+
| Efremov, Mikhail| 12| 2007|
| Stoyanov, Yuriy| 12| 2007|
| Gazarov, Sergey| 12| 2007|
| Verzhbitskiy, Viktor| 12| 2007|
| Brychta, Edita| 12| 2007|
+----------------------+------------+---------------+
Listing 3-30Add a Missing Actor to the movies DataFrame
withColumn(colName, column)
这种转换向数据帧添加了一个新列。它需要两个输入参数;一个列名和一个列表达式形式的值。您可以通过使用selectExpr
转换来完成几乎相同的目标。但是,如果给定的列名与某个现有列名匹配,则该列将被给定的列表达式替换。清单 3-31 提供了添加新列以及替换现有列的例子。
// adding a new column based on a certain column expression
movies.withColumn("decade", ('produced_year - 'produced_year % 10)).show(5)
+------------------+------------------------+--------------+-----------+
| actor_name| movie_title| produced_year| decade|
+------------------+------------------------+--------------+-----------+
| McClure, Marc (I)| Coach Carter| 2005| 2000|
| McClure, Marc (I)| Superman II| 1980| 1980|
| McClure, Marc (I)| Apollo 13| 1995| 1990|
| McClure, Marc (I)| Superman| 1978| 1970|
| McClure, Marc (I)| Back to the Future| 1985| 1980|
+------------------+------------------------+--------------+-----------+
// now replace the produced_year with new values
movies.withColumn("produced_year", ('produced_year - 'produced_year % 10)).show(5)
+------------------+-------------------+--------------+
| actor_name| movie_title| produced_year|
+------------------+-------------------+--------------+
| McClure, Marc (I)| Coach Carter| 2000|
| McClure, Marc (I)| Superman II| 1980|
| McClure, Marc (I)| Apollo 13| 1990|
| McClure, Marc (I)| Superman| 1970|
| McClure, Marc (I)| Back to the Future| 1980|
+------------------+-------------------+--------------+
Listing 3-31Add as Well Replacing a Column Using withColumn Transformation
with column renamed(existing colname,newColName)
这种转换严格地说是重命名数据帧中现有的列名。有理由问为什么 Spark 会提供这种转变。事实证明,这种转换在以下情况下很有用。
-
将一个晦涩的列名重命名为更人性化的名称。神秘的列名可能来自您无法控制的现有模式,例如,当您公司的合作伙伴在一个拼花文件中生成您需要的列时。
-
在连接两个碰巧有一个或多个相同列名的数据帧之前。这种转换可以重命名两个数据帧之一中的一个或多个列,因此在连接后可以很容易地引用它们。
注意,如果提供的existingColName
在模式中不存在,Spark 不会抛出错误,它也不会做任何事情。清单 3-32 将movies
DataFrame 中的一些列名重命名为简称。顺便说一下,这也可以通过使用选择或selectExpr
转换来实现。我把这个留给你做练习。
movies.withColumnRenamed("actor_name", "actor")
.withColumnRenamed("movie_title", "title")
.withColumnRenamed("produced_year", "year").show(5)
+------------------+--------------------+------+
| actor| title| year|
+------------------+--------------------+------+
| McClure, Marc (I)| Coach Carter| 2005|
| McClure, Marc (I)| Superman II| 1980|
| McClure, Marc (I)| Apollo 13| 1995|
| McClure, Marc (I)| Superman| 1978|
| McClure, Marc (I)| Back to the Future| 1985|
+------------------+--------------------+------+
Listing 3-32Using withColumnRenamed Transformation to Rename Some of the Column Names
drop(列名称 1,列名称 2)
这种转换只是从数据帧中删除指定的列。您可以指定一个或多个要删除的列名,但是只删除模式中存在的列名,而忽略不存在的列名。您可以使用select
转换,通过投影出您想要保留的列来删除列。但是,如果一个 DataFrame 有 100 列,而您想删除几列,那么这个转换比select
转换更方便使用。清单 3-33 提供了删除列的示例。
movies.drop("actor_name", "me").printSchema
|-- movie_title: string (nullable = true)
|-- produced_year: long (nullable = true)
Listing 3-33Drop Two Columns, One Exists and the Other One Doesn’t
如您所见,第二列"me"
在模式中不存在,drop 转换简单地忽略了它。
样本(分数),样本(分数,种子),样本(分数,种子,替换)
该转换从数据帧中返回一组随机选择的行。返回的行数大约等于指定的分数,表示一个百分比,该值必须介于 0 和 1 之间。种子植入随机数生成器,该生成器生成一个要包含在结果中的行号。如果未指定种子,则使用随机生成的值。withReplacement
选项决定是否将随机选择的行放回选择池中。换句话说,当withReplacement
为真时,特定的选定行有可能被选择不止一次。那么,什么时候需要使用这种转换呢?当原始数据集很大,并且需要将其缩减到较小的尺寸以便快速迭代数据分析逻辑时,这是非常有用的。清单 3-34 提供了使用sample
转换的例子。
// sample with no replacement and a ratio
movies.sample(false, 0.0003).show(3)
+--------------------+----------------------+--------------+
| actor_name| movie_title| produced_year|
+--------------------+----------------------+--------------+
| Lewis, Clea (I)| Ice Age: The Melt...| 2006|
| Lohan, Lindsay| Herbie Fully Loaded| 2005|
|Tagawa, Cary-Hiro...| Licence to Kill| 1989|
+--------------------+----------------------+--------------+
// sample with replacement, a ratio and a seed
movies.sample(true, 0.0003, 123456).show(3)
+---------------------+-----------------+--------------+
| actor_name| movie_title| produced_year|
+---------------------+-----------------+--------------+
| Panzarella, Russ (V)| Public Enemies| 2009|
| Reed, Tanoai| Daredevil| 2003|
| Moyo, Masasa| Spider-Man 3| 2007|
+---------------------+-----------------+--------------+
Listing 3-34Different ways of Using the sample Transformation
如你所见,返回的电影是相当随机的。
随机拆分(重量)
这种转换通常在准备数据以训练机器学习模型的过程中使用。与前面的转换不同,这个转换返回一个或多个数据帧。它返回的数据帧数量基于您指定的权重数量。如果这组权重的总和不等于 1,则它们会被相应地归一化为总和为 1。清单 3-35 提供了一个将电影数据帧分割成三个小帧的例子。
// the weights need to be an Array
val smallerMovieDFs = movies.randomSplit(Array(0.6, 0.3, 0.1))
// let's see if the counts are added up to the count of movies DataFrame
movies.count
Long = 31393
smallerMovieDFs(0).count
Long = 18881
smallerMovieDFs(0).count + smallerMovieDFs(1).count + smallerMovieDFs(2).count
Long = 31393
Listing 3-35Use randomSplit to Split movies DataFrame into Three Parts
处理缺失或错误的数据
实际上,您经常使用的数据并不像您希望的那样干净。可能是因为数据在发展,因此有些列有值,有些没有。在数据操作逻辑的开始处理这类问题是很重要的,以防止任何不愉快的意外,导致长时间运行的数据处理作业停止工作。
Spark 社区认识到处理缺失数据的需要是生活中的现实。因此,Spark 提供了一个名为DataFrameNaFunctions
的专用类来帮助处理这个不方便的问题。DataFrameNaFunctions
的一个实例可以作为DataFrame
类中的an
成员变量。有三种常见的处理缺失或错误数据的方法。第一种方法是删除一列或多列中缺少值的行。第二种方法是用用户提供的值来填充那些缺失的值。第三种方法是用你知道如何处理的东西替换坏数据。
让我们从删除丢失数据的行开始。您可以告诉 Spark 删除任何一列或只有特定列有缺失数据的行。清单 3-36 显示了删除丢失数据的行的几种不同方式。
// first create a DataFrame with missing values in one or more columns
import org.apache.spark.sql.Row
val badMovies = Seq(Row(null, null, null),
Row(null, null, 2018L),
Row("John Doe", "Awesome Movie", null),
Row(null, "Awesome Movie", 2018L),
Row("Mary Jane", null, 2018L))
val badMoviesRDD = spark.sparkContext.parallelize(badMovies)
val badMoviesDF = spark.createDataFrame(badMoviesRDD, movies.schema)
badMoviesDF.show
+-----------+-----------------+--------------+
| actor_name| movie_title| produced_year|
+-----------+-----------------+--------------+
| null| null| null|
| null| null| 2018|
| John Doe| Awesome Movie| null|
| null| Awesome Movie| 2018|
| Mary Jane| null| 2018|
+-----------+-----------------+--------------+
// dropping rows that have missing data in any column
// both of the lines below achieve the same output
badMoviesDF.na.drop().show
badMoviesDF.na.drop("any").show
+----------+------------+--------------+
|actor_name| movie_title| produced_year|
+----------+------------+--------------+
+----------+------------+--------------+
// drop rows that have missing data in every single column
badMoviesDF.na.drop("all").show
+-----------+--------------+--------------+
| actor_name| movie_title| produced_year|
+-----------+--------------+--------------+
| null| null| 2018|
| John Doe| Awesome Movie| null|
| null| Awesome Movie| 2018|
| Mary Jane| null| 2018|
+-----------+--------------+--------------+
// drops rows that column actor_name has missing data
badMoviesDF.na.drop(Array("actor_name")).show
+------------+---------------+--------------+
| actor_name| movie_title| produced_year|
+------------+---------------+--------------+
| John Doe| Awesome Movie| null|
| Mary Jane| null| 2018|
+------------+---------------+--------------+
Listing 3-36Dropping Rows with Missing Data
使用结构化操作
本节介绍结构化操作。它们与 RDD 动作具有相同的急切求值语义,因此它们触发导致特定动作的所有转换的计算。表 3-9 描述了结构化动作的列表。
表 3-9
常用的结构化操作
|操作
|
描述
|
| --- | --- |
| show()``show(numRows)``show(truncate)``show(numRows, truncate)
| 以表格格式显示行。如果未指定 numRows,则显示前 20 行。truncate 选项控制是否截断长度超过 20 个字符的字符串列。 |
| head()``first()``head(n)``take(n)
| 返回第一行。如果指定了 n,则返回前 n 行。first 是 first 的别名。take(n)是 first(n)的别名。 |
| takeAsList(n)
| 以 Java 列表的形式返回前 n 行。注意不要带太多行;否则,它可能会导致应用程序的驱动程序进程出现内存不足的错误。 |
| collect``collectAsList
| 以数组或 Java 列表的形式返回所有行。应用与采取列表操作中描述的相同的注意事项。 |
| count
| 返回数据帧中的行数。 |
| describe
| 计算数据帧中数值列和字符串列的常见统计信息。可用的统计数据有计数、平均值、标准差、最小值、最大值和任意近似百分位数。 |
其中大多数是不言自明的。show 动作已经在结构化转换部分的许多例子中使用过。
另一个有趣的动作叫做describe
,接下来讨论。
描述(列名)
有时,对您正在处理的数据的基本统计有一个大致的了解是很有用的。此操作可以计算字符串和数字列的基本统计信息,如计数、平均值、标准差、最小值和最大值。您可以选择计算哪个或哪些字符串或数字列的统计数据。清单 3-37 就是一个例子。
movies.describe("produced_year").show
+-----------+-------------------------+
| summary| produced_year|
+-----------+-------------------------+
| count| 31392|
| mean| 2002.7964449541284|
| stddev| 6.377236851493877|
| min| 1961|
| max| 2012|
+-----------+-------------------------+
Listing 3-37Use describe Action to Show the Statistics of produced_year Column
数据集简介
在某一点上,关于数据帧和数据集 API 之间的区别有很多混淆。给定这些选项,可以问它们之间的区别是什么,每个选项的优点和缺点,以及何时使用哪个选项。认识到 Spark 用户社区中的这一巨大混乱,Spark 设计师决定在 Spark 2.0 版本中统一 DataFrame APIs 和 Dataset APIs,以减少用户学习和记忆的抽象。
从 Spark 2.0 版本开始,只有一个称为 Dataset 的高级抽象,它有两种风格:强类型 API 和非类型 API。术语DataFrame
不会消失;相反,它被重新定义为 Dataset 中一般对象集合的别名。从代码的角度来看,DataFrame 本质上是Dataset[Row]
的类型别名,其中Row
是通用的非类型化 JVM 对象。数据集是强类型 JVM 对象的集合,由 Scala 中的case
类或 Java 中的类表示。表 3-10 描述了 Spark 支持的每种编程语言中可用的数据集 API 风格。
表 3-10
数据集风格
|语言
|
风味
|
| --- | --- |
| 斯卡拉 | 数据集[T]和数据帧 |
| 爪哇 | 数据表 |
| 计算机编程语言 | 数据帧 |
| 稀有 | 数据帧 |
Python 和 R 语言没有编译时类型安全;因此,仅支持非类型化数据集 API(也称为 DataFrame)。
把数据集当成 DataFrame 的弟弟。它的独特属性包括类型安全和面向对象。数据集是强类型、不可变的数据集合。像数据帧一样,数据被映射到一个定义的模式。但是,数据帧和数据集之间有一些重要的区别。
-
数据集中的每一行都由一个用户定义的对象表示,因此您可以将单个列作为该对象的成员变量来引用。这为您提供了编译类型的安全性。
-
数据集有名为
encoders
的帮助器,它们是智能和高效的编码实用程序,可以将每个用户定义的对象中的数据转换为紧凑的二进制格式。当数据集缓存在内存中时,这意味着内存使用的减少,当 Spark 需要在混洗过程中通过网络传输时,这意味着字节数的减少。
就限制而言,数据集 API 仅在 Scala 和 Java 等强类型语言中可用。将行对象转换为特定于域的对象会产生转换成本,当数据集有数百万行时,这种成本可能是一个因素。此时,您应该会想到一个关于何时使用数据帧 API 和数据集 API 的问题。数据集 API 适用于需要定期运行并由数据工程师团队编写和维护的生产作业。对于大多数交互式和探索性分析用例,使用 DataFrame APIs 就足够了。
Note
Scala 语言中的 case 类就像 Java 语言中的 JavaBean 类;但是,它有一些内置的有趣属性。case 类的实例是不可变的,因此它通常用于建模特定于领域的对象。此外,很容易推断出 case 类实例的内部状态,因为它们是不可变的。toString 和 equals 方法是自动生成的,以便更容易打印出 case 类的内容并在 case 类实例之间进行比较。Scala case 类与 Scala 模式匹配特性配合得很好。
创建数据集
在创建数据集之前,需要定义一个特定于域的对象来表示每一行。有几种方法可以创建数据集。第一种方法是使用 DataFrame 类的as
(符号)函数将 DataFrame 转换为 Dataset。第二种方法是使用SparkSession.createDataset()
函数从对象集合中创建数据集。第三种方法是使用toDS
隐式转换工具。清单 3-38 提供了创建数据集的不同示例。
// define Movie case class
case class Movie(actor_name:String, movie_title:String, produced_year:Long)
// convert DataFrame to strongly typed Dataset
val moviesDS = movies.as[Movie]
// create a Dataset using SparkSession.createDataset() and the toDS implicit function
val localMovies = Seq(Movie("John Doe", "Awesome Movie", 2018L),
Movie("Mary Jane", "Awesome Movie", 2018L))
val localMoviesDS1 = spark.createDataset(localMovies)
val localMoviesDS2 = localMovies.toDS()
localMoviesDS1.show
+------------+---------------+-------------+
| actor_name| movie_title|produced_year|
+------------+---------------+-------------+
| John Doe| Awesome Movie| 2018|
| Mary Jane| Awesome Movie| 2018|
+------------+---------------+-------------+
Listing 3-38Different Ways of Creating Datasets
在创建数据集的不同方法中,第一种方法是最受欢迎的。当使用 Scala case 类将 DataFrame 转换为 Dataset 时,Spark 会执行验证,以确保 Scala case 类中的成员变量名与 DataFrame 模式中的列名相匹配。如果有不匹配,Spark 会让您知道。
使用数据集
现在您已经有了一个数据集,您可以使用转换和操作来操作它。在本章的前面,数据帧中的列使用了这些选项之一。对于数据集,每一行都用强类型对象表示;因此,您可以只使用成员变量名来引用列,这为您提供了类型安全和编译时验证。如果名字中有拼写错误,编译器会在开发阶段立即标记出来。清单 3-39 是操作数据集的例子。
// filter movies that were produced in 2010 using
moviesDS.filter(movie => movie.produced_year == 2010).show(5)
+---------------------+---------------------+-------------+
| actor_name| movie_title|produced_year|
+---------------------+---------------------+-------------+
| Cooper, Chris (I)| The Town| 2010|
| Jolie, Angelina| Salt| 2010|
| Jolie, Angelina| The Tourist| 2010|
| Danner, Blythe| Little Fockers| 2010|
| Byrne, Michael (I)| Harry Potter and ...| 2010|
+---------------------+---------------------+-------------+
// displaying the title of the first movie in the moviesDS
moviesDS.first.movie_title
String = Coach Carter
// try with misspelling the movie_title and get compilation error
moviesDS.first.movie_tile
error: value movie_tile is not a member of Movie
// perform projection using map transformation
val titleYearDS = moviesDS.map(m => ( m.movie_title, m.produced_year))
titleYearDS.printSchema
|-- _1: string (nullable = true)
|-- _2: long (nullable = false)
// demonstrating a type-safe transformation that fails at compile time, performing subtraction on a column with string type
// a problem is not detected for DataFrame until runtime
movies.select('movie_title - 'movie_title)
// a problem is detected at compile time
moviesDS.map(m => m.movie_title - m.movie_title)
error: value - is not a member of String
// take action returns rows as Movie objects to the driver
moviesDS.take(5)
Array[Movie] = Array(Movie(McClure, Marc (I),Coach Carter,2005), Movie(McClure, Marc (I),Superman II,1980), Movie(McClure, Marc (I),Apollo 13,1995))
Listing 3-39Manipulating a Dataset in a Type-Safe Manner
对于那些经常使用 Scala 编程语言的人来说,使用数据集强类型 API 感觉很自然,给你的印象是数据集中的那些对象驻留在本地。
当您使用数据集强类型 API 时,Spark 隐式地将每个Row
实例转换为您提供的特定于域的对象。这种转换在性能方面有一些代价;然而,它提供了更多的灵活性。
帮助决定何时在 DataFrame 上使用 Dataset 的一个通用准则是,希望在编译时具有更高程度的类型安全,这对于由多个数据工程师开发和维护的复杂 ETL Spark 作业来说非常重要。
在 Spark SQL 中使用 SQL
在大数据时代,SQL 被描述为大数据分析的通用语言。Spark 中最酷的特性之一是能够使用 SQL 执行大规模的分布式数据操作。精通 SQL 的数据分析师现在可以使用 Spark 对大型数据集执行数据分析。需要记住的重要一点是,Spark 中的 SQL 是为在线分析处理(OLAP)用例设计的,而不是为在线事务处理(OLTP)用例设计的。换句话说,它不适用于低延迟用例。
SQL 随着时间的推移不断发展和改进。Spark 实现了 ANSI SQL:2003 修订版的一个子集,大多数流行的 RDBMS 服务器都支持它。符合这一修订版意味着 Spark SQL 数据处理引擎可以使用广泛使用的行业标准决策支持基准 TPC-DS 进行基准测试。
2016 年末,脸书开始将其最大的一些 Hive 工作负载迁移到 Spark,以利用 Spark SQL 引擎的强大功能(参见 https://code.facebook.com/posts/1671373793181703/apache-spark-scale-a-60-tb-production-use-case/
)
)。
Note
结构化查询语言(SQL)是一种特定于领域的语言,它对以表格格式组织的结构化数据进行数据分析和操作。SQL 中的概念基于关系代数;然而,这是一种容易学习的语言。SQL 与 Scala 或 Python 等其他编程语言之间的一个关键区别是,SQL 是一种声明式编程语言,这意味着您可以表达您想要对数据做什么,并让 SQL 执行引擎找出如何执行数据操作以及必要的优化以加快执行时间。如果你是 SQL 新手,在这个网站的 www.datacamp.com/courses/intro-to-sql-for-data-science
有一个免费的课程。
在 Spark 中运行 SQL
Spark 为在 Spark 中运行 SQL 提供了一些不同的选项。
-
Spark SQL CLI(。/bin/spark-sql)
-
JDBC/ODBC 服务器
-
Spark 应用程序中的编程
前两个选项集成了 Apache Hive 以利用其 megastore,这是一个包含关于各种系统和用户定义的表的元数据和模式信息的存储库。本节只讨论最后一个选项。
数据帧和数据集本质上类似于数据库中的表。在发出 SQL 查询来操作它们之前,您需要将它们注册为临时视图。每个视图都有一个名称,在select
子句中用作表名。Spark 为视图提供了两个层次的范围。一个是在 Spark 会议级别。当在这一级注册数据帧时,只有在同一会话中发出的查询才能引用该数据帧。当相关的 Spark 会话关闭时,会话范围的级别消失。第二个范围级别是全局的,这意味着这些视图对于所有 Spark 会话中的 SQL 语句都是可用的。所有注册的视图都保存在 Spark 元数据目录中,可以通过SparkSession
访问。清单 3-40 是注册视图并使用 Spark 目录检查视图元数据的一个例子。
// display tables in the catalog, expecting an empty list
spark.catalog.listTables.show
+-------+------------+---------------+------------+------------+
| name| database| description| tableType| isTemporary|
+-------+------------+---------------+------------+------------+
+-------+------------+---------------+------------+------------+
// now register movies DataFrame as a temporary view
movies.createOrReplaceTempView("movies")
// should see the movies view in the catalog
spark.catalog.listTables.show
+-------+---------+------------+-----------+--------------+
| name| database| description| tableType| isTemporary|
+-------+---------+------------+-----------+--------------+
| movies| null| null| TEMPORARY| true|
+-------+---------+------------+-----------+--------------+
// show the list of columns of movies view in catalog
spark.catalog.listColumns("movies").show
+--------------+------------+---------+---------+------------+------------+
| name| description| dataType| nullable| isPartition| isBucket|
+--------------+------------+---------+---------+------------+------------+
| actor_name| null| string| true| false| false|
| movie_title| null| string| true| false| false|
| produced_year| null| bigint| true| false| false|
+--------------+------------+---------+---------+------------+------------+
// register movies as global temporary view called movies_g
movies.createOrReplaceGlobalTempView("movies_g")
Listing 3-40Register the movies DataFrame as a Temporary View and Inspecting Metadata Catalog
清单 3-40 给出了几个视图供您选择。发布 SQL 查询的编程方式是使用SparkSession
类的sql
函数。在 SQL 语句中,您可以访问所有 SQL 表达式和内置函数。SparkSession.sql
函数执行给定的 SQL 查询;它返回一个数据帧。发布 SQL 语句和使用数据帧转换和动作的能力为您在 Spark 中选择如何执行分布式数据处理提供了很大的灵活性。
清单 3-41 提供了发出简单和复杂 SQL 语句的例子。
// simple example of executing a SQL statement without a registered view
val infoDF = spark.sql("select current_date() as today , 1 + 100 as value")
infoDF.show
+----------+--------+
| today| value|
+----------+--------+
|2017-12-27| 101|
+----------+--------+
// select from a view
spark.sql("select * from movies where actor_name like '%Jolie%' and produced_year > 2009").show
+---------------+----------------+--------------+
| actor_name| movie_title| produced_year|
+---------------+----------------+--------------+
|Jolie, Angelina| Salt| 2010|
|Jolie, Angelina| Kung Fu Panda 2| 2011|
|Jolie, Angelina| The Tourist| 2010|
+---------------+----------------+--------------+
// mixing SQL statement and DataFrame transformation
spark.sql("select actor_name, count(*) as count from movies group by actor_name")
.where('count > 30)
.orderBy('count.desc)
.show
+----------------------+--------+
| actor_name| count|
+----------------------+--------+
| Tatasciore, Fred| 38|
| Welker, Frank| 38|
| Jackson, Samuel L.| 32|
| Harnell, Jess| 31|
+----------------------+--------+
// using a subquery to figure out the number movies produced each year.
// leverage """ to format multi-line SQL statement
spark.sql("""select produced_year, count(*) as count
from (select distinct movie_title, produced_year from movies)
group by produced_year""")
.orderBy('count.desc).show(5)
+------------------+--------+
| produced_year| count|
+------------------+--------+
| 2006| 86|
| 2004| 86|
| 2011| 86|
| 2005| 85|
| 2008| 82|
+------------------+--------+
// select from a global view requires prefixing the view name with key word 'global_temp'
spark.sql("select count(*) from global_temp.movies_g").show
+--------+
| count|
+--------+
| 31393|
+--------+
Listing 3-41Executing SQL Statements in Spark
不是通过DataFrameReader
类读取数据文件并将新创建的数据帧注册为临时视图,而是有一种简单方便的方法对数据文件发出 SQL 查询。清单 3-42 就是一个例子。
spark.sql("SELECT * FROM parquet.`<path>/chapter4/data/movies/movies.parquet`").show(5)
Listing 3-42Issue SQL Query Against a Data File
将数据写出到存储系统
至此,您已经知道如何使用DataFrameReader
从各种文件格式或数据库服务器中读取数据,并且知道如何使用 SQL 或结构化 API 的转换和操作来操作数据。有时,您需要将数据帧中数据处理逻辑的结果写入外部存储系统(例如,本地文件系统、HDFS 或亚马逊 S3)。在一个典型的 ETL 数据处理作业中,结果很可能被写到一些持久存储系统中。
在 Spark SQL 中,DataFrameWriter
类负责将数据帧中的数据写出到外部存储系统的逻辑和复杂性。作为 DataFrame 类中的write
变量,DataFrameWriter
类的一个实例可供您使用。与DataFrameWriter
的交互方式与DataFrameReader
的交互方式类似。您可以从 Spark shell 或 Spark 应用程序中引用它,如清单 3-43 所示。
movies.write
Listing 3-43Using write Variable from DataFrame Class
清单 3-44 描述了与DataFrameWriter
交互的常见模式。
movies.write.format(...).mode(...).option(...).partitionBy(...).bucketBy(...).sortBy(...).save(path)
Listing 3-44Common Interacting Pattern with DataFrameWriter
和DataFrameReader
类似,默认格式是拼花;因此,如果所需的输出格式是拼花,则没有必要指定格式。partitionBy
、bucketBy,
和sortBy
函数控制基于文件的数据源中输出文件的目录结构。基于读取模式构建目录布局可以显著减少分析所需读取的数据量。在本章的后面你会学到更多。save
函数的输入是一个目录名,而不是文件名。
the DataFrameWriter class is the save mode, which controls how Spark handles the situation when the specified output location
中的一个重要选项存在。表 3-11 列出了各种支持的保存模式。
表 3-11
保存模式
|方式
|
描述
|
| --- | --- |
| 附加 | 这将把数据帧数据追加到指定目标位置已经存在的文件列表中。 |
| 写得过多 | 这将使用数据帧中的数据完全覆盖指定目标位置上已经存在的任何数据文件。 |
| 错误错误如果存在系统默认值 | 这是默认模式。如果指定的目标位置存在,DataFrameWriter 将引发错误。 |
| 忽视 | 如果指定的目标位置存在,那么什么也不做。换句话说,不要在 DataFrame 中写出数据。 |
清单 3-45 展示了一些使用各种格式和模式组合的例子
// write data out in CVS format, but using a '#' as delimiter
movies.write.format("csv").option("sep", "#").save("/tmp/output/csv")
// write data out using overwrite save mode
movies.write.format("csv").mode("overwrite").option("sep", "#").save("/tmp/output/csv")
Listing 3-45Using DataFrameWriter to Write Out Data to File-based Sources
写出到输出目录的文件数量对应于数据帧的分区数量。清单 3-46 展示了如何找出一个数据帧的分区数量。
movies.rdd.getNumPartitions
Int = 1
Listing 3-46Display the Number of DataFrame Partitions
当数据帧中的行数不大时,需要有一个输出文件,以便于共享。实现这个目标的一个小技巧是将数据帧中的分区数量减少到一个,然后将其写出。清单 3-47 展示了一个如何做到这一点的例子。
val singlePartitionDF = movies.coalesce(1)
Listing 3-47Reduce the Number of Partitions in a DataFrame to 1
使用分区和分桶写出数据的想法是从 Apache Hive 用户社区借鉴来的。根据经验,按列分区应该具有较低的基数。在movies
数据帧中,produced_year
列是按列分区的良好候选。假设您想要写出由produced_year
列分区的movies
数据帧。DataFrameWriter 将所有具有相同produced_year
的电影写入单个目录。输出文件夹中的目录数量对应于movies
数据帧中的年数。清单 3-48 是使用partitionBy
函数的一个例子。
movies.write.partitionBy("produced_year").save("/tmp/output/movies ")
// the /tmp/output/movies directory will contain the following subdirectories
produced_year=1961 to produced_year=2012
Listing 3-48Write the movies DataFrame Using Partition By produced_year Column
由partitionBy
选项生成的目录名看起来很奇怪,因为每个目录名都由分区列名和相关值组成。这两条信息在数据读取时用于根据数据访问模式选择要读取的目录,因此最终读取的数据比其他情况少得多。
三者:数据帧、数据集和 SQL
现在您知道了在 Spark SQL 模块中有三种不同的操作结构化数据的方法。表 3-12 显示了每个选项在语法和分析谱中的位置。
表 3-12
语法和分析错误谱
| |结构化查询语言
|
数据帧
|
资料组
|
| --- | --- | --- | --- |
| 系统错误 | 运行时间 | 编译时间 | 编译时间 |
| 分析错误 | 运行时间 | 运行时间 | 编译时间 |
越早发现错误,您的工作效率就越高,数据处理应用程序就越稳定。
数据帧持久性
数据帧可以在内存中持久化/缓存,就像使用 rdd 一样。DataFrame 类中提供了相同的常见持久性 API(persist 和 unpersist)。然而,缓存数据帧时有一个很大的区别。因为 Spark SQL 知道数据帧中的数据模式,所以它可以以列格式组织数据,并应用任何适用的压缩来最小化空间使用。最终结果是,当两者由相同的数据文件支持时,在内存中存储数据帧比存储 RDD 需要更少的空间。表 3-5 中描述的所有不同存储选项都适用于数据帧的保存。清单 3-49 演示了用一个人类可读的名字来持久化一个数据帧,这个名字在 Spark UI 中很容易识别。
val numDF = spark.range(1000).toDF("id")
// register as a view
numDF.createOrReplaceTempView("num_df")
// use Spark catalog to cache the numDF using name "num_df"
spark.catalog.cacheTable("num_df")
// force the persistence to happen by taking the count action
numDF.count
Listing 3-49Persisting a DataFrame with a Human Readable Name
接下来,将浏览器指向 Spark UI(运行 Spark shell 时为http://localhost:4040
),然后单击 Storage 选项卡。图 3-2 显示了一个例子。
图 3-2
存储选项卡
摘要
在本章中,您学习了以下内容。
-
Spark SQL 模块为结构化分布式数据操作提供了一个新的强大的抽象。结构化数据有一个已定义的模式,由列名和列数据类型组成。
-
Spark SQL 中的主要编程抽象是数据集,它有两种风格的 API:强类型 API 和非类型 API。对于强类型 API,每一行都由一个域指定的对象表示。对于非类型化的 API,范围行由一个行对象表示。DataFrame 现在只是 Dataset[Row]的别名。强类型 API 为您提供静态类型和编译时检查;因此,它们只在强类型语言中可用,比如 Scala 或 Java。
-
Spark SQL 支持从各种流行的数据源读取不同格式的数据。
DataFrameReader
类负责通过从这些数据源中读取数据来创建数据帧。 -
像 RDD 一样,数据集有两种类型的结构化操作。它们是转变和行动。前者评价慵懒,后者评价热切。
-
Spark SQL 使得使用 SQL 对大型集合执行数据处理变得非常容易。这为数据分析师和非程序员打开了大门。
-
从数据集或数据帧中写出数据是通过一个名为
DataFrameWriter
的类来完成的。
Spark SQL Exercises
以下练习基于chapter3/data/movies
目录下的movies.tsv
和movie-ratings.tsv
文件。这些文件中的列分隔符是一个制表符,所以确保使用它来分隔每一行。
movies.tsv
文件中的每一行代表一部电影中的一个演员。如果一部电影中有十个演员,那么这部电影就有行。
-
计算每年生产的电影数量。输出应该有两列:year 和 count。输出应按计数降序排列。
-
计算每个演员参演的电影数量。输出应该有两列:actor、count。输出应按计数降序排列。
-
计算每年收视率最高的电影,并包括该电影中的所有演员。输出应该每年只有一部电影,它应该包含四列:年份,电影名称,评级,一个分号分隔的演员姓名列表。这个问题需要在
movies.tsv
和movie-ratings.tsv
文件之间进行连接。解决这个问题有两种方法。首先是计算出每年收视率最高的电影,然后加入演员名单。第二个是先执行 join,然后算出每年收视率最高的电影和演员名单。每种方法的结果都不同。你认为这是为什么? -
确定哪一对演员合作得最多。合作被定义为出现在同一部电影中。输出应该有三列:actor1、actor2 和 count。输出应该按照计数降序排序。这个问题的解决方案需要进行自连接。
四、Spark SQL:高级
第三章介绍了 Spark SQL 模块中的基本元素,包括核心抽象、用于操作结构化数据的结构化操作以及各种支持的数据源,用于读取和写入数据。在此基础之上,本章将介绍 Spark SQL 模块中的一些高级功能,并深入了解 Catalyst 优化器和钨引擎提供的优化和执行效率。为了帮助您执行复杂的分析,Spark SQL 提供了一组强大而灵活的聚合功能、连接多个数据集的能力、一大组内置的高性能函数、一种编写您自己的自定义函数的简单方法以及一组高级分析函数。本章详细介绍了这些主题。
聚集
对大数据执行任何有趣而复杂的分析通常都涉及聚合,以汇总数据,从而提取模式或见解或生成摘要报告。聚合通常需要对整个数据集或基于一个或多个列进行分组,然后对每个组应用聚合函数,如求和、计数或平均。Spark 提供了许多常用的聚合函数,并且能够将值聚合到一个集合中,然后可以对其进行进一步分析。行的分组可以在不同的级别上完成,Spark 支持以下级别。
-
将数据帧视为一个组。
-
使用一个或多个列将数据帧分成多个组,并对每个组执行一次或多次聚合。
-
将一个数据帧分成多个窗口,并执行移动平均、累积和或排序。如果窗口是基于时间的,则可以按照翻转或滑动窗口来进行聚合。
聚合函数
在 Spark 中,所有的聚合都是通过函数完成的。聚合函数设计用于对一组行执行聚合,无论这些行是由数据帧中的所有行还是行的子组组成。在 http://spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.functions$
可以找到 Scala 语言聚合函数的完整列表。对于 Spark Python APIs,有时在某些功能的可用性方面存在一些差距。
常见聚合函数
本节描述了一组常用的聚合函数,并提供了使用它们的示例。表 4-1 描述了聚合函数。完整列表请见 http://spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.functions$
。
表 4-1
常用的聚合函数
|操作
|
描述
|
| --- | --- |
| count(col)
| 返回每组的项目数。 |
| countDistinct(col)
| 返回每组的唯一项目数。 |
| approx_count_distinct(col)
| 返回每组唯一项目的大致数量。 |
| min(col)
| 返回每组中给定列的最小值。 |
| max(col)
| 返回每组中给定列的最大值。 |
| sum(col)
| 返回给定列中每组值的总和。 |
| sumDistinct(col)
| 返回每组中给定列的不同值的总和。 |
| avg(col)
| 返回每组中给定列的平均值。 |
| skewness(col)
| 返回每组中给定列的值分布的偏斜度。 |
| kurtosis(col)
| 返回每组中给定列的值的分布的峰度。 |
| variance(col)
| 返回每组中给定列的值的无偏方差。 |
| stddev(col)
| 返回每组中给定列的值的标准偏差。 |
| collect_list(col)
| 返回给定列的值的集合。返回的集合可能包含重复值。 |
| collect_set(col)
| 返回给定列的唯一值的集合。 |
为了演示这些函数的用法,让我们使用飞行摘要数据集,该数据集来自位于 www.kaggle.com/usdot/flight-delays/data
的 Kaggle 网站上的数据文件。该数据集包含 2015 年美国国内航班延误和取消情况。清单 4-1 是从这个数据集创建一个 DataFrame 的代码。
val flight_summary = spark.read.format("csv")
.option("header", "true")
.option("inferSchema","true")
.load("<path>/chapter5/data/flights/flight-summary.csv")
// use count action to find out number of rows in this dataset
flight_summary.count()
Long = 4693
Remember the count() function of the DataFrame is an action so it immediately returns a value to us. All the functions listed in Table 5-1 are lazily evaluated functions.
Below is the schema of the flight_summary dataset.
|-- origin_code: string (nullable = true)
|-- origin_airport: string (nullable = true)
|-- origin_city: string (nullable = true)
|-- origin_state: string (nullable = true)
|-- dest_code: string (nullable = true)
|-- dest_airport: string (nullable = true)
|-- dest_city: string (nullable = true)
|-- dest_state: string (nullable = true)
|-- count: integer (nullable = true)
Listing 4-1Create a DataFrame from Reading Flight Summary Dataset
每行代表从出发地机场到目的地机场的航班。“计数”列包含航班的数量。
以下所有聚合示例都是在整个数据帧级别执行聚合。本章后面给出了在子组级别执行聚合的示例。
计数(列)
计数是一种常用的合计方法,用于找出一个组中的项目数。清单 4-2 计算了origin_airport
和dest_airport
列的计数,正如所料,计数是相同的。为了提高结果列的可读性,可以使用as
函数给出一个更友好的列名。注意,您需要调用show
动作来查看结果。
flight_summary.select(count("origin_airport"), count("dest_airport").as("dest_count")).show
+--------------------------+---------------+
| count(origin_airport)| dest_count|
+--------------------------+---------------+
| 4693| 4693|
+--------------------------+---------------+
Listing 4-2Computing the Count for Two Columns in the flight_summary DataFrame
当计算一列中的项数时,count(col)
函数在计算中不包括空值。为了包含空值,列名应该替换为*
。清单 4-3 通过创建一个在某些列中包含空值的小型数据帧来演示这种行为。
import org.apache.spark.sql.Row
case class Movie(actor_name:String, movie_title:String, produced_year:Long)
val badMoviesDF = Seq( Movie(null, null, 2018L),
Movie("John Doe", "Awesome Movie", 2018L),
Movie(null, "Awesome Movie", 2018L),
Movie("Mary Jane", "Awesome Movie", 2018L)).toDF
badMoviesDF.show
+---------------+--------------------+-------------------+
| actor_name| movie_title| produced_year|
+---------------+--------------------+-------------------+
| null| null| 2018|
| John Doe| Awesome Movie| 2018|
| null| Awesome Movie| 2018|
| Mary Jane| Awesome Movie| 2018|
+---------------+--------------------+-------------------+
// now performing the count aggregation on different columns
badMoviesDF.select(count("actor_name"), count("movie_title"), count("produced_year"), count("*")).show
+------------------+-------------------+---------------------+---------+
| count(actor_name)| count(movie_title)| count(produced_year)| count(1)|
+------------------+-------------------+---------------------+---------+
| 2| 3| 4| 4|
+------------------+-------------------+---------------------+---------+
Listing 4-3Counting Items with Null Value
输出表确认了count(col)
函数在最终计数中不包含 null。
countDistinct(列)
这个函数做的和它听起来一样。它只计算每组的唯一项目。清单 4-4 显示了countDistinct
函数和count
函数之间计数结果的差异。事实证明,在 flight_summary 数据集中有 322 个唯一的机场。
flight_summary.select(countDistinct("origin_airport"), countDistinct("dest_airport"), count("*")).show
+-------------------------------+-----------------------------+----------+
| count(DISTINCT origin_airport)| count(DISTINCT dest_airport)| count(1)|
+-------------------------------+-----------------------------+----------+
| 322| 322| 4693|
+-------------------------------+-----------------------------+----------+
approx_count_distinct (col, max_estimated_error=0.05)
Listing 4-4Counting Unique Items in a Group
在一个非常大的数据集中,计算每个组中唯一项目的准确数量是一项昂贵且耗时的工作。在某些用例中,有一个近似的唯一计数就足够了。其中一个用例是在线广告业务,每小时有数亿次广告投放。需要生成一份报告,说明每种类型的会员细分市场的独立访客数量。估算不同项目的数量是计算机科学中一个众所周知的问题。它也被称为基数估计问题。
幸运的是,已经有一个著名的算法叫做 HyperLogLog ( https://en.wikipedia.org/wiki/HyperLogLog
)可以用来解决这个问题,Spark 已经在approx_count_distinct
函数中实现了这个算法的一个版本。由于唯一计数是近似值,因此存在一定的误差。该函数允许您为该用例指定可接受的估计误差值。清单 4-5 展示了approx._count_distinct
函数的用法和行为。随着估计误差的减小,这个函数完成并返回结果所需的时间越来越长。
// let's do the counting on the "count" column of flight_summary DataFrame.
// the default estimation error is 0.05 (5%)
flight_summary.select(count("count"),countDistinct("count"), approx_count_distinct("count", 0.05)).show
+--------------+----------------------+-----------------------------+
| count(count) | count(DISTINCT count)| approx_count_distinct(count)|
+--------------+----------------------+-----------------------------+
| 4693| 2033| 2252|
+--------------+----------------------+-----------------------------+
// to get a sense how much approx_count_distinct function is faster than countDistinct function,
// trying calling them separately
flight_summary.select(countDistinct("count")).show
// specify 1% estimation error
flight_summary.select(approx_count_distinct("count", 0.01)).show
// one my Mac laptop, the approx_count_distinct function took about 0.1 second and countDistinct function took 0.6 second. The larger the approximation estimation error, the less time approx_count_distinct function takes to complete.
Listing 4-5Counting Unique Items in a Group
最小(列),最大(列)
组中项目的最小值和最大值是范围的两端。这两个函数很容易理解和使用。清单 4-6 从 count 列中提取这两个值。
flight_summary.select(min("count"), max("count")).show
+-------------+----------------+
| min(count)| max(count)|
+-------------+----------------+
| 1| 13744|
+-------------+----------------+
// looks like there is one very busy airport with 13744 incoming flights from another airport. It will be interesting to find which airport
Listing 4-6Get the Minimum and Maximum Values of the Count Column
总和(列)
此函数计算数值列中值的总和。清单 4-7 执行flight_summary
数据集中所有航班的总和。
flight_summary.select(sum("count")).show
+---------------+
| sum(count)|
+---------------+
| 5332914|
+---------------+
Listing 4-7Using sum Function to Sum up the Count Values
sumDistinct(列)
这个函数做的和它听起来一样。它只对数值列的不同值求和。flight_summary
数据帧中不同计数的总和应小于清单 4-7 中显示的总和。清单 4-8 计算不同值的总和。
flight_summary.select(sumDistinct("count")).show
+------------------------------+
| sum(DISTINCT count)|
+------------------------------+
| 3612257|
+------------------------------+
Listing 4-8Using sumDistinct Function to Sum up the Distinct Count Values
平均值(列)
此函数计算数值列的平均值。这个方便的函数只需将总数除以项目数。让我们看看清单 4-9 能否验证假设。
flight_summary.select(avg("count"), (sum("count") / count("count"))).show
+--------------------------+------------------------------------+
| avg(count)| (sum(count) / count(count))|
+--------------------------+------------------------------------+
| 1136.3549968037503| 1136.3549968037503|
+--------------------------+------------------------------------+
Listing 4-9Computing the Average Value of the Count Column Using Two Different Ways
偏度,峰度
在统计学中,数据集中值的分布揭示了数据集背后的无数故事。偏斜度衡量数据集中值分布的对称性,其值可以是正、零、负或未定义。在正态分布或钟形分布中,偏斜值为 0。正的倾斜表示右边的尾巴比左边的更长或更粗。负的倾斜表示相反的情况,左边的尾巴比右边的长或粗。当偏斜度为 0 时,两边的尾部是均匀的。图 4-1 显示了一个正负偏斜的例子。
图 4-1
https://en.wikipedia.org/wiki/Skewness
的反面和正面歪斜的例子
峰度是对分布曲线形状的一种度量,不管曲线是正态的、平坦的还是尖的。正峰度表示曲线细长而尖,负峰度表示曲线肥胖而平坦。清单 4-10 计算 flight_summary 数据集中计数分布的偏度和峰度。
flight_summary.select(skewness("count"), kurtosis("count")).show
+--------------------------+----------------------------+
| skewness(count)| kurtosis(count)|
+--------------------------+----------------------------+
| 2.682183800064101| 10.51726963017102|
+--------------------------+----------------------------+
Listing 4-10Compute the Skewness and Kurtosis of Column Count
结果表明,计数的分布是不对称的,右尾比左尾长或粗。峰度值表明分布曲线是尖的。
方差(列),标准差(列)
在统计学中,方差和标准差衡量数据的分散性或分布。换句话说,它们告诉我们这些值与平均值的平均距离。当方差值较低时,这些值接近平均值。方差和标准差是相关的;后者是前者的平方根。图 4-2 显示了来自两个总体的样本,均值相同但方差不同。红色群体的平均值为 100,方差为 100。蓝色群体的平均值为 100,方差为 2500。这个例子出自 https://en.wikipedia.org/wiki/Variance
。
图 4-2
来自 https://en.wikipedia.org/wiki/Variance
两个总体的样本示例
variance
和stddev
分别计算方差和标准差。Spark 提供了这些功能的两种不同实现;一种使用抽样来加速计算,另一种使用整个人口。清单 4-11 显示了flight_summary
数据帧中计数列的方差和标准差。
// use the two variations of variance and standard deviation
flight_summary.select(variance("count"), var_pop("count"), stddev("count"), stddev_pop("count")).show
+-----------------+------------------+------------------+-----------------+
| var_samp(count)| var_pop(count)| stddev_samp(count)| stddev_pop(count)|
+-----------------+------------------+------------------+-----------------+
|1879037.7571558713| 1878637.3655604832| 1370.779981308405| 1370.633928355957|
+-----------------+------------------+------------------+-----------------+
Listing 4-11Compute the Variance and Standard
Deviation Using variance and sttdev Functions
看起来计数值在flight_summary
数据帧中相当分散。
分组聚合
本节介绍对一列或多列进行分组的聚合。聚合通常在包含一个或多个分类列的数据集上执行,这些分类列的基数较低。分类值的例子有性别、年龄、城市名称或国家名称。聚合是通过类似于前面提到的函数来完成的。但是,它们不是对数据帧中的全局组执行聚合,而是对每个子组执行聚合。
通过分组执行聚合是一个两步过程。第一步是通过使用groupBy(col1,col2,...)
转换来执行分组,这是指定对哪些列进行分组的地方。与其他返回数据帧的转换不同,groupBy
转换返回一个RelationalGroupedDataset
类的实例,您可以对其应用一个或多个聚合函数。清单 4-12 展示了使用一个列和一个聚合的简单分组。注意groupBy
列自动包含在输出中。
flight_summary.groupBy("origin_airport").count().show(5, false)
+------------------------------------------------------+-------+
| origin_airport | count|
+------------------------------------------------------+-------+
|Melbourne International Airport | 1|
|San Diego International Airport (Lindbergh Field) | 46|
|Eppley Airfield | 21|
|Kahului Airport | 18|
|Austin-Bergstrom International Airport | 41|
+------------------------------------------------------+-------+
Listing 4-12Grouping by origin_airport and Perform Count Aggregation
列表 4-12 显示了从墨尔本国际机场(佛罗里达州)出发的航班只飞往另外一个机场。然而,从卡胡鲁伊机场起飞的航班降落在其他 18 个机场中的一个。
为了让事情变得有趣一点,让我们尝试按两列分组来计算城市级别的相同指标。清单 4-13 展示了如何去做。
flight_summary.groupBy('origin_state, 'origin_city).count(). .where('origin_state === "CA").orderBy('count.desc).show(5)
+---------------+------------------+---------+
| origin_state| origin_city| count|
+---------------+------------------+---------+
| CA| San Francisco| 80|
| CA| Los Angeles| 80|
| CA| San Diego| 47|
| CA| Oakland| 35|
| CA| Sacramento| 27|
+---------------+------------------+---------+
Listing 4-13Grouping by origin_state and origin_city and Perform Count Aggregation
除了按两列分组之外,该语句还对行进行筛选,只筛选具有“CA”状态的行。orderBy
转换可以轻松识别哪个城市拥有最多的目的地机场。加州的旧金山和洛杉矶拥有最多的目的地机场,这是有道理的。
RelationalGroupedDataset
类提供了一组标准的聚合函数,可以用来应用于每个子组。他们是avg(cols), count(), mean(cols), min(cols), max(cols), sum(cols)
。除了count()
函数,其余的都是对数字列进行操作。
每组多个聚合
有时需要同时对每个组执行多个聚合。例如,除了计数之外,您还想知道最小值和最大值。RelationalGroupedDataset
类提供了一个名为agg
的非常强大的函数,它采用一个或多个列表达式,这意味着您可以使用任何聚合函数,包括表 4-1 中列出的那些函数。一件很酷的事情是这些聚合函数返回了一个Column
类的实例,因此您可以使用提供的函数应用任何列表达式。一个常见的需求是在聚合完成后重命名列,使其更短、更易读、更易于引用。清单 4-14 展示了如何做到这一切。
import org.apache.spark.sql.functions._
flight_summary.groupBy("origin_airport")
.agg(
count("count").as("count"),
min("count"), max("count"),
sum("count")
).show(5)
+--------------------+-------+----------+----------+------------+
| origin_airport| count|min(count)|max(count)| sum(count)|
+--------------------+-------+----------+----------+------------+
|Melbourne Interna...| 1| 1332| 1332| 1332|
|San Diego Interna...| 46| 4| 6942| 70207|
| Eppley Airfield| 21| 1| 2083| 16753|
| Kahului Airport| 18| 67| 8313| 20627|
|Austin-Bergstrom ...| 41| 8| 4674| 42067|
+--------------------+-------+----------+----------+------------+
Listing 4-14Multiple Aggregations After a Group by of origin_airport
默认情况下,聚合列名是聚合表达式,这使得列名有点长,很难引用。因此,一种常见的模式是使用Column.as
函数将列重命名为更合适的名称。
多功能的agg
函数提供了一种通过基于字符串的键值映射来表达列表达式的额外方法。关键是列名,值是聚合方法,可以是avg, max, min, sum,
或count
。清单 4-15 提供了这种方法的一个例子。
flight_summary.groupBy("origin_airport")
.agg(
"count" -> "count",
"count" -> "min",
"count" -> "max",
"count" -> "sum")
.show(5)
Listing 4-15Specifying Multiple Aggregations Using a Key-Value Map
结果与清单 4-14 中的结果相同。请注意,重命名聚合结果列名并不容易。与第一种方法相比,这种方法的一个优点是可以通过编程生成地图。当编写生产 ETL 作业或执行探索性分析时,第一种方法比第二种更常用。
收集组值
collect_list(col)
和collect_set(col)
函数用于在应用分组后收集特定组的所有值。一旦每个组的值被放入一个集合中,就可以自由地以您选择的任何方式操作它们。这些函数的返回集合有一个小小的不同,那就是唯一性。collection_list
函数返回包含重复值的集合,而collection_set
函数返回包含唯一值的集合。清单 4-16 展示了如何使用the collection_list
函数收集从每个始发州出发的超过 5500 个航班的目的地城市。
val highCountDestCities = flight_summary.where('count > 5500)
.groupBy("origin_state")
.agg(collect_list("dest_city")
.as("dest_cities"))
highCountDestCities.withColumn("dest_city_count",
size('dest_cities))
.show(5, false)
+------------+------------------------------------+----------------+
|origin_state| dest_cities | dest_city_count|
+------------+------------------------------------+----------------+
| AZ| [Seattle, Denver, Los Angeles]| 3|
| LA| [Atlanta] | 1|
| MN| [Denver, Chicago] | 2|
| VA| [Chicago, Boston, Atlanta] | 3|
| NV|[Denver, Los Angeles, San Francisco]| 3|
+------------+------------------------------------+----------------+
Listing 4-16Using collection_list to Collect High Traffic Destination Cities Per Origin State
旋转聚合
透视是一种汇总数据的方法,方法是指定一个分类列,然后对其他列执行聚合,以便将分类值从行转置到单独的列中。思考旋转的另一种方式是,它是一种在应用一个或多个聚合时将行转换为列的方式。这种技术通常用于数据分析或报告。透视过程从对一列或多列进行分组开始,在一列上进行透视,最后在一列或多列上应用一个或多个聚合结束。
清单 4-17 显示了一个学生小数据集的透视示例,其中每行包含学生的姓名、性别、体重和毕业年份。旋转使得计算每个毕业年度每个性别的平均体重变得容易。
import org.apache.spark.sql.Row
case class Student(name:String, gender:String, weight:Int, graduation_year:Int)
val studentsDF = Seq(Student("John", "M", 180, 2015),
Student("Mary", "F", 110, 2015),
Student("Derek", "M", 200, 2015),
Student("Julie", "F", 109, 2015),
Student("Allison", "F", 105, 2015),
Student("kirby", "F", 115, 2016),
Student("Jeff", "M", 195, 2016)).toDF
// calculating the average weight for gender per graduation year
studentsDF.groupBy("graduation_year").pivot("gender")
.avg("weight").show()
+----------------+------+---------+
| graduation_year| F| M|
+----------------+------+---------+
| 2015| 108.0| 190.0|
| 2016| 115.0| 195.0|
+----------------+------+---------+
Listing 4-17Pivoting on a Small Dataset
此示例只有一个聚合,性别分类列只有两个可能的唯一值;因此,结果表只有两列。如果性别列有三个可能的唯一值,则结果表中有三列。您可以利用agg
函数来执行多个聚合,从而在结果表中创建更多的列。清单 4-18 是对清单 4-17 中的数据帧执行多重聚合的一个例子。
studentsDF.groupBy("graduation_year").pivot("gender")
.agg(
min("weight").as("min"),
max("weight").as("max"),
avg("weight").as("avg")
).show()
+---------------+------+-------+-------+-------+-------+------+
|graduation_year| F_min| F_max| F_avg| M_min| M_max| M_avg|
+---------------+------+-------+-------+-------+-------+------+
| 2015| 105| 110| 108.0| 180| 200| 190.0|
| 2016| 115| 115| 115.0| 195| 195| 195.0|
+---------------+------+-------+-------+-------+-------+------+
Listing 4-18Multiple Aggregations After Pivoting
在结果表的分组列之后添加的列数是透视列的唯一值数和聚合数的乘积。
如果透视列有许多不同的值,您可以有选择地选择为哪些值生成聚合。清单 4-19 展示了如何为旋转函数指定值。
studentsDF.groupBy("graduation_year").pivot("gender", Seq("M"))
.agg(
min("weight").as("min"),
max("weight").as("max"),
avg("weight").as("avg")
).show()
+---------------------+---------+----------+---------+
| graduation_year| M_min| M_max| M_avg|
+---------------------+---------+----------+---------+
| 2015| 180| 200| 190.0|
| 2016| 195| 195| 195.0|
+---------------------+---------+----------+---------+
Listing 4-19Selecting Values of Pivoting Column to Generate the Aggregations For
为透视列指定不同值的列表可以加快透视过程。否则,Spark 会花费一些精力自己找出一系列不同的值。
连接
为了执行任何复杂而有趣的数据分析或操作,您通常需要通过连接过程将来自多个数据集的数据集合在一起。在 SQL 术语中,这是一种众所周知的技术。执行连接会合并两个数据集(可以不同也可以相同)的列,合并后的数据集包含两端的列。这使您能够进一步分析组合的数据集,这样就不可能对每个数据集都进行分析。让我们以一家在线电子商务公司的两个数据集为例。一个表示包含哪些客户购买了哪些产品的信息的交易数据(也称为事实表)。另一个表示每个客户的信息(也称为维度表)。通过连接这两个数据集,您可以了解哪些产品在年龄或位置方面更受特定客户群的欢迎。
本节介绍如何使用join
转换在 Spark SQL 中执行连接,以及它支持的各种类型的连接。本节的最后一部分描述了 Spark SQL 如何在内部执行连接。
Note
在使用 SQL 执行数据分析的世界中,连接是一种经常使用的技术。如果您是 SQL 新手,强烈建议您学习基本概念和不同种类的连接。维基百科。org/ wiki/ Join_ (SQL) 。 www.w3schools.com/sql/sql_join.asp
提供了一些关于连接的教程。
连接表达式和连接类型
执行两个数据集的连接需要您指定两条信息。第一个是一个连接表达式,它指定每一侧的哪些列应该确定两个数据集中的哪些行包含在连接的数据集中。第二个是连接类型,它决定了连接数据集中应包含的内容。表 4-2 提供了 Spark SQL 中支持的连接类型列表。
表 4-2
连接类型
|类型
|
描述
|
| --- | --- |
| 内部联接(又称等联接) | 当连接表达式的计算结果为 true 时,返回两个数据集中的行。 |
| 左外部连接 | 即使联接表达式的计算结果为 false,也从左侧数据集中返回行。 |
| 右外部联接 | 即使联接表达式的计算结果为 false,也从正确的数据集中返回行。 |
| 外部连接 | 即使联接表达式的计算结果为 false,也从两个数据集中返回行。 |
| 左反连接 | 当连接表达式的计算结果为 false 时,仅返回左侧数据集中的行。 |
| 左半连接 | 当连接表达式的计算结果为 true 时,仅返回左侧数据集中的行。 |
| 十字架(又名笛卡尔坐标) | 通过将左侧数据集中的每一行与右侧数据集中的每一行进行组合来返回行。行数是每个数据集大小的乘积。 |
为了帮助可视化一些连接类型,图 4-3 显示了一组来自 https://en.wikipedia.org/wiki/Join_ (SQL)#Outer_join
的常见连接类型的文氏图。
图 4-3
常见连接类型的维恩图
使用连接
我使用了两个小的数据帧来演示如何在 Sparking SQL 中执行连接。第一个表示雇员的列表,每行包含雇员的姓名和他们所属的部门。第二个包含一个部门列表,每行包含一个部门 ID 和部门名称。清单 4-20 包含创建这两个数据帧的代码片段。
case class Employee(first_name:String, dept_no:Long)
val employeeDF = Seq( Employee("John", 31),
Employee("Jeff", 33),
Employee("Mary", 33),
Employee("Mandy", 34),
Employee("Julie", 34),
Employee("Kurt", null.asInstanceOf[Int])
).toDF
case class Dept(id:Long, name:String)
val deptDF = Seq( Dept(31, "Sales"),
Dept(33, "Engineering"),
Dept(34, "Finance"),
Dept(35, "Marketing")
).toDF
// register them as views so we can use SQL for perform joins
employeeDF.createOrReplaceTempView("employees")
deptDF.createOrReplaceTempView("departments")
Listing 4-20Creating Two Small DataFrames to Use in the Following Join Type Examples
内部联接
这是最常用的连接类型,其连接表达式包含两个数据集的列的相等比较。仅当连接表达式被评估为 true 时,连接的数据集才包含这些行;换句话说,两个数据集中的连接列值是相同的。不具有匹配列值的行将从连接的数据集中排除。如果连接表达式使用相等比较,那么连接表中的行数只能与较小数据集的大小一样大。内部连接是 Spark SQL 中的默认连接类型,因此在连接转换中指定它是可选的。清单 4-21 提供了进行内部连接的例子。
// define the join expression of equality comparison
val deptJoinExpression = employeeDF.col("dept_no") === deptDF.col("id")
// perform the join
employeeDF.join(deptDF, joinExpression, "inner").show
// no need to specify the join type since "inner" is the default
employeeDF.join(deptDF, joinExpression).show
+-------------+----------+---+----------------+
| first_name| dept_no| id| name|
+-------------+----------+---+----------------+
| John| 31| 31| Sales|
| Jeff| 33| 33| Engineering|
| Mary| 33| 33| Engineering|
| Mandy| 34| 34| Finance|
| Julie| 34| 34| Finance|
+-------------+----------+---+----------------+
// using SQL
spark.sql("select * from employees JOIN departments on dept_no == id").show
Listing 4-21Performing Inner Join by the Department ID
正如预期的那样,连接的数据集只包含雇员和部门数据集中具有匹配部门 id 的行,以及两个数据集中的列。输出告诉您每个雇员属于哪个部门。
可以在join
转换中或者使用where
转换来指定连接表达式。如果列名是唯一的,可以使用简写版本引用连接表达式中的列。否则,您必须使用col
函数指定特定列来自哪个数据帧。清单 4-22 展示了表达一个连接表达式的不同方式。
// a shorter version of the join expression
employeeDF.join(deptDF, 'dept_no === 'id).show
// specify the join expression inside the join transformation
employeeDF.join(deptDF, employeeDF.col("dept_no") === deptDF.col("id")).show
// specify the join expression using the where transformation
employeeDF.join(deptDF).where('dept_no === 'id).show
Listing 4-22Different Ways of Expressing a Join Expression
连接表达式只是一个布尔谓词,因此它可以像比较两列一样简单,也可以像链接多对列的多个逻辑比较一样复杂。
左外部连接
此连接类型的连接数据集包括内部连接的所有行,以及连接表达式评估为 false 的左侧数据集的所有行。对于那些不匹配的行,它为右侧数据集的列填充一个空值。清单 4-23 是一个左外连接的例子。
// the join type can be either "left_outer" or "leftouter"
employeeDF.join(deptDF, 'dept_no === 'id, "left_outer").show
// using SQL
spark.sql("select * from employees LEFT OUTER JOIN departments on dept_no == id").show
+--------------+----------+----+----------------+
| first_name| dept_no| id| name|
+--------------+----------+----+----------------+
| John| 31| 31| Sales|
| Jeff| 33| 33| Engineering|
| Mary| 33| 33| Engineering|
| Mandy| 34| 34| Finance|
| Julie| 34| 34| Finance|
| Kurt| 0|null| null|
+--------------+----------+----+----------------+
Listing 4-23Performing a Left Outer Join
不出所料,市场部在雇员数据集中没有任何匹配的行。关联数据集告诉您员工被分配到的部门以及哪些部门没有员工。
右外部联接
此连接类型的行为类似于左侧外部连接类型的行为,只是对右侧数据集应用了相同的处理。换句话说,连接的数据集包括内部连接中的所有行,以及连接表达式评估为 false 的右侧数据集中的所有行。清单 4-24 是一个右外连接的例子。
employeeDF.join(deptDF, 'dept_no === 'id, "right_outer").show
// using SQL
spark.sql("select * from employees RIGHT OUTER JOIN departments on dept_no == id").show
+-------------+-----------+----+----------------+
| first_name| dept_no| id| name|
+-------------+-----------+----+----------------+
| John| 31| 31| Sales|
| Mary| 33| 33| Engineering|
| Jeff| 33| 33| Engineering|
| Julie| 34| 34| Finance|
| Mandy| 34| 34| Finance|
| null| null| 35| Marketing|
+-------------+-----------+----+----------------+
Listing 4-24Performing a Right Outer Join
不出所料,市场部没有来自雇员数据集中的任何匹配行。关联数据集告诉您员工被分配到的部门以及哪些部门没有员工。
外部联接(也称为完全外部联接)
这种连接类型的行为实际上与合并左外部连接和右外部连接的结果是一样的。清单 4-25 是进行外部连接的一个例子。
employeeDF.join(deptDF, 'dept_no === 'id, "outer").show
// using SQL
spark.sql("select * from employees FULL OUTER JOIN departments on dept_no == id").show
+-------------+-----------+----+----------------+
| first_name| dept_no| id| name|
+-------------+-----------+----+----------------+
| Kurt| 0|null| null|
| Mandy| 34| 34| Finance|
| Julie| 34| 34| Finance|
| John| 31| 31| Sales|
| Jeff| 33| 33| Engineering|
| Mary| 33| 33| Engineering|
| null| null| 35| Marketing|
+-------------+-----------+----+----------------+
Listing 4-25Performing an Outer Join
外部联接的结果允许您查看某个雇员被分配到哪个部门,哪些部门有雇员,哪些雇员没有被分配到某个部门,以及哪些部门没有任何雇员。
左反连接
此连接类型可让您找出左侧数据集中哪些行在右侧数据集中没有任何匹配行,并且连接的数据集中仅包含左侧数据集中的列。清单 4-26 是一个做左反连接的例子。
employeeDF.join(deptDF, 'dept_no === 'id, "left_anti").show
// using SQL
spark.sql("select * from employees LEFT ANTI JOIN departments on dept_no == id").show
+-------------+-----------+
| first_name| dept_no|
+-------------+-----------+
| Kurt| 0|
+-------------+-----------+
Listing 4-26Performing a Left Anti-Join
左反联接的结果可以很容易地告诉您哪些员工没有被分配到某个部门。请注意,不存在正确的反联接类型;但是,您可以轻松地切换数据集来实现相同的目标。
左半连接
这种联接类型的行为类似于内部联接类型,只是联接的数据集不包括右侧数据集中的列。考虑这种连接类型的另一种方式是,它的行为与左反连接相反,在左反连接中,连接的数据集只包含匹配的行。清单 4-27 是做左半连接的一个例子。
employeeDF.join(deptDF, 'dept_no === 'id, "left_semi").show
// using SQL
spark.sql("select * from employees LEFT SEMI JOIN departments on dept_no == id").show
+-------------+-----------+
| first_name| dept_no|
+-------------+-----------+
| John| 31|
| Jeff| 33|
| Mary| 33|
| Mandy| 34|
| Julie| 34|
+-------------+-----------+
Listing 4-27Performing a Left Semi-Join
十字(又名笛卡尔)
就用法而言,这种连接类型是最容易使用的,因为不需要连接表达式。它的行为可能有点危险,因为它将左边数据集中的每一行与右边数据集中的每一行连接起来。连接数据集的大小是两个数据集大小的乘积。例如,如果每个数据集的大小为 1024,则连接的数据集的大小超过一百万行。因此,使用这种连接类型的方法是在DataFrame
类中显式地使用一个专用的转换,而不是将这种连接类型指定为一个字符串。清单 4-28 是交叉连接的一个例子。
// using crossJoin transformation and display the count
employeeDF.crossJoin(deptDF).count
Long = 24
// using SQL and passing 30 value to show action to see all rows
spark.sql("select * from employees CROSS JOIN departments").show(30)
+-------------+----------+---+----------------+
| first_name| dept_no| id| name|
+-------------+----------+---+----------------+
| John| 31| 31| Sales|
| John| 31| 33| Engineering|
| John| 31| 34| Finance|
| John| 31| 35| Marketing|
| Jeff| 33| 31| Sales|
| Jeff| 33| 33| Engineering|
| Jeff| 33| 34| Finance|
| Jeff| 33| 35| Marketing|
| Mary| 33| 31| Sales|
| Mary| 33| 33| Engineering|
| Mary| 33| 34| Finance|
| Mary| 33| 35| Marketing|
| Mandy| 34| 31| Sales|
| Mandy| 34| 33| Engineering|
| Mandy| 34| 34| Finance|
| Mandy| 34| 35| Marketing|
| Julie| 34| 31| Sales|
| Julie| 34| 33| Engineering|
| Julie| 34| 34| Finance|
| Julie| 34| 35| Marketing|
| Kurt| 0| 31| Sales|
| Kurt| 0| 33| Engineering|
| Kurt| 0| 34| Finance|
| Kurt| 0| 35| Marketing|
+-------------+----------+---+----------------+
Listing 4-28Performing a Cross Join
处理重复的列名
有时,两个数据帧可能有一个或多个同名的列。在连接它们之前,最好在两个数据帧中的一个中重命名这些列,以避免访问不明确的问题;否则,连接的数据帧将有多个同名的列。清单 4-29 模拟了这种情况。
// add a new column to deptDF with name dept_no
val deptDF2 = deptDF.withColumn("dept_no", 'id)
deptDF2.printSchema
|-- id: long (nullable = false)
|-- name: string (nullable = true)
|-- dept_no: long (nullable = false)
// now employeeDF with deptDF2 using dept_no column
val dupNameDF = employeeDF.join(deptDF2, employeeDF.col("dept_no") === deptDF2.col("dept_no"))
dupNameDF.printSchema
|-- first_name: string (nullable = true)
|-- dept_no: long (nullable = false)
|-- id: long (nullable = false)
|-- name: string (nullable = true)
|-- dept_no: long (nullable = false)
Listing 4-29Simulate a Joined DataFrame with Multiple Names That Are the Same
请注意,dupNameDF
数据帧现在有两列具有相同的名称,dept_no
。当你使用清单 4-30 中的dept_no
投射dupNameDF
数据帧时,Spark 抛出一个错误。
dupNameDF.select("dept_no")
org.apache.spark.sql.AnalysisException: Reference 'dept_no' is ambiguous, could be: dept_no#30L, dept_no#1050L.;
Listing 4-30Projecting Column dept_no in the dupNameDF DataFrame
事实证明,有几种方法可以处理这个问题。
使用原始数据帧
在连接过程中,连接的数据帧会记住哪些列来自哪个原始数据帧。为了消除一个列来自哪个数据帧的歧义,只需告诉 Spark 在它前面加上原始数据帧的名称。清单 4-31 展示了如何做到这一点。
dupNameDF.select(deptDF2.col("dept_no"))
Listing 4-31Using the Original DataFrame deptDF2 to Refer to dept_no Column in the Joined DataFrame
联接前重命名列
避免列名不明确问题的另一种方法是使用withColumnRenamed
转换重命名其中一个数据帧中的列。因为这很简单,所以我把它作为一个练习留给你。
使用联接的列名
当两个数据帧中的连接列名相同时,您可以利用某个版本的连接转换来自动删除连接数据帧中的重复列名。但是,如果它是一个自连接,意味着将一个数据帧连接到自身,那么就没有办法引用其他重复的列名。在这种情况下,您需要使用列重命名技术。清单 4-32 展示了一个使用连接列名执行连接的例子。
val noDupNameDF = employeeDF.join(deptDF2, "dept_no")
noDupNameDF.printSchema
|-- dept_no: long (nullable = false)
|-- first_name: string (nullable = true)
|-- id: long (nullable = false)
|-- name: string (nullable = true)
Listing 4-32Performing a Join Using Joined Column Name
注意在noDupNameDF
数据帧中只有一个dept_no
列。
连接实现概述
加入是 Spark 中最复杂、最昂贵的操作之一。在高层次上,Spark 使用一些策略来执行两个数据集的连接。它们是混洗散列连接和广播连接。选择特定策略的主要标准基于两个数据集的大小。当两个数据集的大小都很大时,则使用混洗散列连接策略。当其中一个数据集的大小足够小,可以放入执行器的内存中时,就使用广播连接策略。以下部分将详细介绍每种加入策略的工作原理。
无序散列连接
从概念上讲,连接就是将满足连接表达式中条件的两个数据集的行组合起来。为此,需要跨网络传输具有相同列值的行,这些行位于同一个分区上。
无序散列连接的实现包括两个步骤。第一步是计算每个数据集中每一行的连接表达式中的列的哈希值,然后将那些具有相同哈希值的行放入同一个分区。为了确定特定行移动到哪个分区,Spark 执行一个简单的算术运算,用分区的数量计算哈希值的模。第二步是合并那些具有相同列哈希值的行的列。在高层次上,这两个步骤类似于 MapReduce 编程模型中的步骤。
图 4-4 显示了在混洗散列连接中进行的混洗。由于通过网络在机器间传输大量数据,这是一项昂贵的操作。当通过网络移动数据时,数据通常要经过序列化和反序列化过程。想象一下,在两个大型数据集上执行连接,每个数据集的大小都是 100 GB。在这种情况下,它移动大约 200GB 的数据。当连接两个大型数据集时,不可能完全避免混洗散列连接。尽管如此,只要有可能,注意减少加入他们的频率是很重要的。
图 4-4
无序散列连接
广播散列连接
当其中一个数据集小到足以放入内存时,可以使用这种连接策略。由于知道混洗散列连接是一种开销很大的操作,广播散列连接避免了混洗两个数据集,而只混洗较小的数据集。像 shuffle hash 连接策略一样,这个策略也包含两个步骤。第一步是将较小数据集的副本广播到较大数据集的每个分区。第二步是遍历较大数据集中的每一行,并在较小数据集中查找具有匹配列值的相应行。图 4-5 显示了小数据集的广播。
图 4-5
广播散列连接
容易理解的是,当广播散列连接适用时,它是首选的。在大多数情况下,Spark SQL 可以在读取数据集时,根据它所拥有的关于数据集的统计信息,自动判断何时使用广播散列连接或混洗散列连接。然而,在使用join
转换时,提示 Spark SQL 使用广播散列连接是可行的。清单 4-33 提供了一个这样做的例子。
import org.apache.spark.sql.functions.broadcast
// Use broadcast hash join strategy and print out execution plan
employeeDF.join(broadcast(deptDF), employeeDF.col("dept_no") === deptDF.col("id")).explain()
// User broadcast hash join hint in a SQL statement
spark.sql("select /*+ MAPJOIN(departments) */ * from employees JOIN departments on dept_no == id").explain()
== Physical Plan ==
*BroadcastHashJoin [dept_no#30L], [id#41L], Inner, BuildRight
:- LocalTableScan [first_name#29, dept_no#30L]
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, bigint, false]))
+- LocalTableScan [id#41L, name#42]
Listing 4-33Provide a Hint to Use Broadcast Hash Join
功能
DataFrame APIs 旨在操作或转换数据集中的单个行,例如筛选和分组。如果您想要转换每一行的列值,例如将一个字符串从大写转换为骆驼大小写,您可以使用一个函数。函数是应用于列的方法。Spark SQL 提供了一大组常用函数和一种创建新函数的简单方法。在 Spark 3.0 版本中添加了大约 30 个新的内置函数。
使用内置函数
为了有效地使用 Spark SQL 执行分布式数据操作,您必须熟练使用 Spark SQL 内置函数。这些内置函数旨在生成优化的代码,以便在运行时执行,因此最好在使用自己的函数之前利用它们。这些函数的一个共同点是它们被设计成将同一行的一列或多列作为输入,并且它们只返回一列作为输出。Spark SQL 提供了 200 多个内置函数,它们被分成不同的类别。这些函数可用于数据帧操作,如select
、filter
和groupBy
。
有关内置函数的完整列表,请参考位于 https://spark.apache.org/docs/latest/api/scala/org/apache/spark/sql/functions$.html
的 Spark API Scala 文档。表 4-3 将它们分为不同的类别。
表 4-3
每个类别的内置函数的子集
|类别
|
描述
|
| --- | --- |
| 日期时间 | unix_timestamp,from_unixtime,to_date,current_date,current_timesatmp,date_add,date_sub,add_months,datediff,months_between,dayofmonth,dayofyear,weekofyear,second,minute,hour,month,make_date,make_timestamp,make_interval |
| 线 | concat,length,levenshtein,locate,lower,upper,ltrim,rtrim,trim,lpad,rpad,repeat,reverse,split,substring,base64 |
| 数学 | cos、acos、sin、asin、tan、atan、ceil、floor、exp、factor、log、pow、radian、degree、sqrt、hex、unhex |
| 密码系统 | cr32,哈希,md5,sha1,sha2 |
| 聚合 | 近似的 _count_distinct,countDistinct,sumDistinct,avg,corr,count,first,last,max,min,skewness,sum, |
| 募捐 | array_contain,explode,from_json,size,sort_array,to_json,size |
| 窗户 | dense_rank,lag,lead,ntile,rank,row_number |
| 杂项 | coalesce,isNan,isnull,isNotNull,单调递增 id,lit,when |
这些功能中的大部分都易于理解和直接使用。以下部分提供了一些有趣的工作示例。
使用日期时间函数
使用 Spark 执行数据分析的次数越多,遇到多一个日期或时间相关列的数据集的机会就越大。Spark 内置数据时间函数大致分为以下三类:将日期或时间戳从一种格式转换为另一种格式,执行数据时间计算,以及从日期或时间戳中提取特定值,如年、月、星期几等等。
日期-时间转换函数有助于将时间字符串转换为日期、时间戳或 Unix 时间戳,反之亦然。在内部,它使用 Java 日期格式模式语法,该语法记录在 http://docs.oracle.com/javase/tutorial/i18n/format/simpleDateFormat.html
中。这些函数使用的默认日期格式是 yyyy-MM-dd HH:mm:ss。因此,如果您的日期或时间戳列的日期格式不同,您需要向这些转换函数提供该模式。清单 4-34 展示了一个将字符串类型的日期和时间戳转换成 Spark 日期和时间戳类型的例子。
// the last two columns don't follow the default date format
val testDF = Seq((1, "2018-01-01", "2018-01-01 15:04:58:865",
"01-01-2018", "12-05-2017 45:50"))
.toDF("id", "date", "timestamp", "date_str",
"ts_str")
// convert these strings into date, timestamp and unix timestamp
// and specify a custom date and timestamp format
val testResultDF = testDF.select(to_date('date).as("date1"),
to_timestamp('timestamp).as("ts1"),
to_date('date_str,"MM-dd-yyyy").as("date2"),
to_timestamp('ts_str, "MM-dd-yyyy mm:ss").as("ts2"),
unix_timestamp('timestamp).as("unix_ts"))
.show(false)
// date1 and ts1 are of type date and timestamp respectively
testResultDF.printSchema
|-- date1: date (nullable = true)
|-- ts1: timestamp (nullable = true)
|-- date2: date (nullable = true)
|-- ts2: timestamp (nullable = true)
|-- unix_ts: long (nullable = true)
testDateResultDF.show
+----------+-------------------+----------+-------------------+-----------+
| date1| ts1| date2| ts2| unix_ts|
+----------+-------------------+----------+-------------------+-----------+
|2018-01-01|2018-01-01 15:04:58|2018-01-01|2017-12-05 00:45:50| 1514847898|
+----------+-------------------+----------+-------------------+-----------+
Listing 4-34Converting date and timestamp String to Spark Date and Timestamp Type
通过使用带有自定义日期格式的date_format
函数或者使用from_unixtime
函数将 Unix 时间戳(以秒为单位)转换为时间字符串,将日期或时间戳转换为时间字符串同样简单。清单 4-35 显示了转换的例子。
testResultDF.select(date_format('date1,"dd-MM-YYYY").as("date_str"),date_format('ts1, "dd-MM-YYYY HH:mm:ss").as("ts_str"),
from_unixtime('unix_ts,"dd-MM-YYYY HH:mm:ss").as("unix_ts_str"))
.show
+-------------+------------------------+------------------------+
| date_str| ts_str| unix_ts_str|
+-------------+------------------------+------------------------+
| 01-01-2018| 01-01-2018 15:04:58| 01-01-2018 15:04:58|
+-------------+------------------------+------------------------+
Listing 4-35Converting Date, Timestamp, and Unix Timestamp to Time String
日期时间计算函数对于计算两个日期或时间戳之间的差异以及执行日期或时间运算的能力非常有用。清单 4-36 显示了日期时间计算的工作示例。
val employeeData = Seq(("John", "2016-01-01", "2017-10-15"),
("May", "2017-02-06", "2017-12-25"))
.toDF("name", "join_date", "leave_date")
employeeData.show
+------+----------------+--------------+
| name| join_date| leave_date|
+------+----------------+--------------+
| John| 2016-01-01| 2017-10-15|
| May| 2017-02-06| 2017-12-25|
+------+----------------+--------------+
// perform date and month calculations
employeeData.select('name,
datediff('leave_date, 'join_date).as("days"),
months_between('leave_date, 'join_date).as("months"),
last_day('leave_date).as("last_day_of_mon"))
.show
+------+------+----------------+-----------------------+
| name| days| months| last_day_of_mon|
+------+------+----------------+-----------------------+
| John| 653| 21.4516129| 2017-10-31|
| May| 322| 10.61290323| 2017-12-31|
+------+------+----------------+-----------------------+
// perform date addition and subtraction
val oneDate = Seq(("2018-01-01")).toDF("new_year")
oneDate.select(date_add('new_year, 14).as("mid_month"),
date_sub('new_year, 1).as("new_year_eve"),
next_day('new_year, "Mon").as("next_mon"))
.show
+--------------+--------------------+----------------+
| mid_month| new_year_eve| next_mon|
+--------------+--------------------+----------------+
| 2018-01-15| 2017-12-31| 2018-01-08|
+--------------+--------------------+----------------+
Listing 4-36Date Time Calculation Examples
从日期或时间戳值(如年、月、小时、分钟和秒)中提取特定字段的能力非常方便。例如,当需要按季度、月或周对所有股票交易进行分组时,可以只从交易日期中提取信息,然后按这些值进行分组。清单 4-37 展示了从日期或时间戳中提取字段是多么容易。
val valentimeDateDF = Seq(("2018-02-14 05:35:55")).toDF("date")
valentimeDateDF.select(year('date).as("year"),
quarter('date).as("quarter"),
month('date).as("month"),
weekofyear('date).as("woy"),
dayofmonth('date).as("dom"),
dayofyear('date).as("doy"),
hour('date).as("hour"),
minute('date).as("minute"),
second('date).as("second"))
.show
+-----+--------+------+-----+-----+-----+------+-------+--------+
| year| quarter| month| woy| dom| doy| hour| minute| second|
+-----+--------+------+-----+-----+-----+------+-------+--------+
| 2018| 1| 2| 7| 14| 45| 5| 35| 55|
+-----+--------+------+-----+-----+-----+------+-------+--------+
Listing 4-37Extract Specific Fields from a Date Value
使用字符串函数
毫无疑问,大多数数据集中的大多数列都是字符串类型。Spark SQL 内置字符串函数提供了操作这种类型的列的多种功能强大的方法。这些功能分为两大类。第一个是关于转换字符串,第二个是关于应用正则表达式来替换字符串的某个部分,或者基于模式提取字符串的某些部分。
有许多方法来转换一个字符串。最常见的是修剪、填充、大写、小写和连接。修剪是指删除字符串左侧或右侧的空格,或者两者都删除。填充是将字符添加到字符串的左侧或右侧。清单 4-38 展示了使用各种内置字符串函数转换字符串的各种方法。
val sparkDF = Seq((" Spark ")).toDF("name")
// trimming - removing spaces on the left side, right side of a string, or both
// trim removes spaces on both sides of a string
// ltrim only removes spaces on the left side of a string
// rtrim only removes spaces on the right side of a string
sparkDF.select(trim('name).as("trim"),
ltrim('name).as("ltrim"),
rtrim('name).as("rtrim"))
.show
+-----+----------+---------+
| trim| ltrim| rtrim|
+-----+----------+---------+
|Spark| Spark | Spark|
+-----+----------+---------+
// padding a string to a specified length with given pad string
// first trim spaces around string "Spark" and then pad it so the final length is 8 characters long
// lpad pads the left side of the trim column with - to the length of 8
// rpad pads the right side of the trim colum with = to the length of 8
sparkDF.select(trim('name).as("trim"))
.select(lpad('trim, 8, "-").as("lpad"),
rpad('trim, 8, "=").as("rpad"))
.show
+---------+-------------+
| lpad| rpad|
+---------+-------------+
| ---Spark| Spark===|
+---------+-------------+
// transform a string with concatenation, uppercase, lowercase and reverse
val sparkAwesomeDF = Seq(("Spark", "is", "awesome"))
.toDF("subject", "verb", "adj")
sparkAwesomeDF.select(concat_ws(" ",'subject, 'verb,
'adj).as("sentence"))
.select(lower('sentence).as("lower"),
upper('sentence).as("upper"),
initcap('sentence).as("initcap"),
reverse('sentence).as("reverse"))
.show
+-----------------+-----------------+-----------------+-----------------+
| lower| upper| initcap| reverse|
+-----------------+-----------------+-----------------+-----------------+
| spark is awesome| SPARK IS AWESOME| Spark Is Awesome| emosewa si krapS|
+-----------------+-----------------+-----------------+-----------------+
// translate from one character to another
sparkAwesomeDF.select('subject, translate('subject, "ar",
"oc").as("translate"))
.show
+---------+------------+
| subject| translate|
+---------+------------+
| Spark| Spock|
+---------+------------+
Listing 4-38Different Ways of Transforming a String With Built-in String Functions
正则表达式是替换字符串的一部分或从字符串中提取子字符串的一种强大而灵活的方法。regexp_extract
和regexp_replace
功能就是专门为这些目的而设计的。Spark 利用 Java 正则表达式库来实现这两个字符串函数。
regexp_extract
函数的输入参数是一个字符串列、一个要匹配的模式和一个组索引。一个字符串中可能有多个模式匹配;因此,需要组索引(从 0 开始)来识别哪一个。如果指定的模式没有匹配项,该函数将返回一个空字符串。清单 4-30 是使用regexp_extract
函数的一个例子。
val rhymeDF = Seq(("A fox saw a crow sitting on a tree singing
\"Caw! Caw! Caw!\"")).toDF("rhyme")
// using a pattern
rhymeDF.select(regexp_extract('rhyme,"[a-z]*o[xw]",0)
.as("substring")).show
+------------+
| substring|
+------------+
| fox|
+------------+
Listing 4-39Using regexp_extract string Function to Extract “fox” Out Using a Pattern
字符串函数的输入参数是字符串列、要匹配的模式和要替换的值。清单 4-40 是使用regexp_replace
函数的一个例子。
val rhymeDF = Seq(("A fox saw a crow sitting on a tree singing
\"Caw! Caw! Caw!\"")).toDF("rhyme")
// both lines below produce the same output
rhymeDF.select(regexp_replace('rhyme, "fox|crow", "animal")
.as("new_rhyme"))
.show(false)
rhymeDF .select(regexp_replace('rhyme, "[a-z]*o[xw]", "animal")
.as("new_rhyme"))
.show(false)
+----------------------------------------------------------------+
| new_rhyme |
+----------------------------------------------------------------+
|A animal saw a animal sitting on a tree singing "Caw! Caw! Caw!"|
+----------------------------------------------------------------+
Listing 4-40Using regexp_replace String Function to Replace “fox” and “crow” with “animal”
使用数学函数
第二种最常见的列类型是数字类型。在客户交易或物联网传感器相关数据集中尤其如此。大多数数学函数都一目了然,易于使用。本节介绍一个有用且常用的函数round
,它根据给定的小数位数对数值进行上舍入。小数位数决定了要向上舍入的小数位数。这个函数有两种变体。第一个函数采用具有浮点值和小数位数的列,第二个函数只采用具有浮点值的列。第二个变量调用第一个变量,其值为 0。清单 4-41 演示了round
函数的行为。
val numberDF =Seq((3.14159, 3.5, 2018)).toDF("pie","gpa", "year")
numberDF.select(round('pie).as("pie0"),
round('pie, 1).as("pie1"),
round('pie, 2).as("pie2"),
round('gpa).as("gpa"),
round('year).as("year"))
.show
// because it is a half-up rounding, the gpa value is rounded up to 4.0
+-----+------+-----+-----+------+
| pie0| pie1| pie2| gpa| year|
+-----+------+-----+-----+------+
| 3.0| 3.1| 3.14| 4.0| 2018|
+-----+------+-----+-----+------+
Listing 4-41Demonstrates the Behavior of round with Various Scales
使用集合函数
集合函数旨在处理复杂的数据类型,如数组、映射或结构。本节介绍两种特定类型的收集函数。第一个是关于使用数组数据类型。第二个是关于使用 JSON 数据格式。
有时数据集中的特定列包含一系列值,而不是单个标量值。建模的一种方法是使用数组数据类型。例如,假设有一个关于每天需要执行的任务的数据集。在这个数据集中,每一行代表每天的任务列表。每行由两列组成。一列包含日期,另一列包含任务列表。您可以使用与数组相关的集合函数轻松获取数组大小、检查值的存在或对数组进行排序。清单 4-42 包含了使用各种数组相关函数的例子。
// create an tasks DataFrame
val tasksDF = Seq(("Monday", Array("Pick Up John",
"Buy Milk", "Pay Bill")))
.toDF("day", "tasks")
// schema of tasksDF
tasksDF.printSchema
|-- day: string (nullable = true)
|-- tasks: array (nullable = true)
| |-- element: string (containsNull = true)
// get the size of the array, sort it, and check to see if a particular value exists in the array
tasksDF.select('day, size('tasks).as("size"),
sort_array('tasks).as("sorted_tasks"),
array_contains('tasks, "Pay Bill").as("payBill"))
.show(false)
+---------+-----+-----------------------------------+-----------+
| day | size| sorted_ta | payBill|
+---------+-----+-----------------------------------+-----------+
| Monday| 3 | [Buy Milk, Pay Bill, Pick Up John]| true |
+---------+-----+-----------------------------------+-----------+
// the explode function will create a new row for each element in the array
tasksDF.select('day, explode('tasks)).show
+----------+------------------+
| day| col|
+----------+------------------+
| Monday| Pick Up John|
| Monday| Buy Milk|
| Monday| Pay Bill|
+----------+------------------+
Listing 4-42Using Array Collection Functions to Manipulate a List of Tasks
许多非结构化数据集采用 JSON 的形式,这是一种流行的自描述数据格式。一个常见的例子是以 JSON 格式对 Kafka 消息有效负载进行编码。由于这种格式在大多数编程语言中得到广泛支持,所以用这些编程语言之一编写的 Kafka 消费者可以很容易地解码这些 Kafka 消息。JSON 相关的集合函数对于在 JSON 字符串和 struct 数据类型之间进行转换非常有用。主要功能有from_json
和to_json
。一旦 JSON 字符串被转换成 Spark struct 数据类型,就可以很容易地提取这些值。清单 4-43 显示了使用from_json
和to_json
功能的例子。
import org.apache.spark.sql.types._
// create a string that contains JSON string
val todos = """{"day": "Monday","tasks": ["Pick Up John",
"Buy Milk","Pay Bill"]}"""
val todoStrDF = Seq((todos)).toDF("todos_str")
// at this point, todoStrDF is DataFrame with one column with string data type
todoStrDF.printSchema
|-- todos_str: string (nullable = true)
// in order to convert a JSON string into a Spark struct data type, we need to describe its structure to Spark
val todoSchema = new StructType().add("day", StringType)
.add("tasks", ArrayType(StringType))
// use from_json to convert JSON string
val todosDF = todoStrDF.select(from_json('todos_str, todoSchema)
.as("todos"))
// todos is a struct data type that contains two fields: day and tasks
todosDF.printSchema
|-- todos: struct (nullable = true)
| |-- day: string (nullable = true)
| |-- tasks: array (nullable = true)
| | |-- element: string (containsNull = true)
// retrieving value out of struct data type using the getItem function of Column class
todosDF.select('todos.getItem("day"), 'todos.getItem("tasks"),
'todos.getItem("tasks").getItem(0).as("first_task"))
.show(false)
+-----------+-----------------------------------+-------------+
| todos.day| todos.tasks | first_task |
+-----------+-----------------------------------+-------------+
| Monday | [Pick Up John, Buy Milk, Pay Bill]| Pick Up John|
+-----------+-----------------------------------+-------------+
// to convert a Spark struct data type to JSON string, we can use to_json function
todosDF.select(to_json('todos)).show(false)
+---------------------------------------------------------------+
| structstojson(todos) |
+---------------------------------------------------------------+
|{"day":"Monday","tasks":["Pick Up John","Buy Milk","Pay Bill"]}|
+---------------------------------------------------------------+
Listing 4-43Examples of Using from_json and to_json Functions
使用杂项功能
杂项类别中的一些函数很有趣,在某些情况下会很有用。本节包括以下功能:monotonically_increasing_id
、when
、coalesce
和lit
。
有时需要为数据集中的每一行生成单调递增的唯一 id,但不是连续的 id。如果你花些时间思考一下,这是一个相当有趣的问题。例如,如果一个数据集有 2 亿行,并且分布在许多分区(机器)上,如何确保这些值是唯一的并且同时增加?这是monotonically_increasing_id
函数的工作,它将 id 生成为 64 位整数。其算法的关键部分是将分区 ID 放在生成的 ID 的高 31 位。清单 4-44 显示了一个使用monotonically_increasing_id
函数的例子。
// first generate a DataFrame with values from 1 to 10
// and spread them across 5 partitions
val numDF = spark.range(1,11,1,5)
// verify that there are 5 partitions
numDF.rdd.getNumPartitions
Int = 5
// now generate the monotonically increasing numbers
// and see which ones are in which partition
numDF.select('id, monotonically_increasing_id().as("m_ii"),
spark_partition_id().as("partition")).show
+----+--------------+-----------+
| id| m_ii| partition|
+----+--------------+-----------+
| 1| 0| 0|
| 2| 1| 0|
| 3| 8589934592| 1|
| 4| 8589934593| 1|
| 5| 17179869184| 2|
| 6| 17179869185| 2|
| 7| 25769803776| 3|
| 8| 25769803777| 3|
| 9| 34359738368| 4|
| 10| 34359738369| 4|
+----+--------------+-----------+
// the above table shows the values in m_ii columns have a different range in each partition.
Listing 4-44monotonically_increasing_id in Action
如果需要根据条件列表计算值并返回值,那么典型的解决方案是使用 switch 语句,这在大多数高级编程语言中都可用。当需要对 DataFrame 中的一列的值执行此操作时,您可以对这个用例使用when
函数。清单 4-45 是使用when
函数的一个例子。
// create a DataFrame with values from 1 to 7 to represent each day of the week
val dayOfWeekDF = spark.range(1,8,1)
// convert each numerical value to a string
dayOfWeekDF.select('id, when('id === 1, "Mon")
.when('id === 2, "Tue")
.when('id === 3, "Wed")
.when('id === 4, "Thu")
.when('id === 5, "Fri")
.when('id === 6, "Sat")
.when('id === 7, "Sun").as("dow"))
.show
+---+----+
| id| dow|
+---+----+
| 1| Mon|
| 2| Tue|
| 3| Wed|
| 4| Thu|
| 5| Fri|
| 6| Sat|
| 7| Sun|
+---+----+
// to handle the default case when we can use the otherwise function of the column class
dayOfWeekDF.select('id, when('id === 6, "Weekend")
.when('id === 7, "Weekend")
.otherwise("Weekday").as("day_type"))
.show
+---+--------+
| id|day_type|
+--+---------+
| 1| Weekday|
| 2| Weekday|
| 3| Weekday|
| 4| Weekday|
| 5| Weekday|
| 6| Weekend|
| 7| Weekend|
+------------+
Listing 4-45Use the when Function to Convert a Numeric Value to a String
处理数据时,正确处理空值非常重要。方法之一是将它们转换成在数据处理逻辑中表示 null 的其他值。借鉴 SQL 世界,Spark 提供了一个coalesce
,它接受一个或多个列值,并返回第一个不为 null 的值。coalesce 中的每个参数都必须是 Column 类型,所以如果您想填充一个文字值,可以利用lit
函数。这个函数之所以有效,是因为它接受一个文字值,并返回包装输入的Column
类的一个实例。清单 4-46 是同时使用coalesce
和lit
功能的例子。
// create a movie with null title
case class Movie(actor_name:String, movie_title:String,
produced_year:Long)
val badMoviesDF = Seq( Movie(null, null, 2018L),
Movie("John Doe", "Awesome Movie", 2018L))
.toDF
// use coalesce function to handle null value in the title column
badMoviesDF.select(coalesce('actor_name,
lit("no_name")).as("new_title"))
.show
+-------------+
| new_title|
+-------------+
| no_name|
| John Doe|
+-------------+
Listing 4-46Using coalesce to Handle null Value in a Column
使用用户定义的函数(UDF)
尽管 Spark SQL 为最常见的用例提供了大量的内置函数,但总会出现这些函数都不能提供用例所需功能的情况。但是,不要绝望。Spark SQL 提供了一个简单的工具来编写用户定义函数(UDF ),并在 Spark 数据处理逻辑或应用程序中使用它们,就像使用内置函数一样。UDF 是扩展 Spark 功能以满足特定需求的有效方法之一。
我喜欢 Spark 的另一个原因是,UDF 可以用 Python、Java 或 Scala 编写,并且可以利用和集成任何必要的库。由于您可以使用自己最熟悉的编程语言来编写 UDF,因此开发和测试 UDF 非常容易和快速。
从概念上讲,UDF 只是常规函数,它接受一些输入并提供一个输出。尽管 UDF 可以用 Scala、Java 或 Python 编写,但是您必须意识到用 Python 编写 UDF 时的性能差异。UDF 在使用前必须向 Spark 注册,所以 Spark 知道把它们运送给 executors 来使用和执行。鉴于执行器是用 Scala 编写的 JVM 进程,它们可以在同一个进程中原生执行 Scala 或 Java UDFs。如果一个 UDF 是用 Python 编写的,那么一个执行器不能本地执行它,因此它必须生成一个单独的 Python 进程来执行 Python UDF。除了生成 Python 进程的成本之外,为数据集中的每一行来回序列化数据的成本也很高。
使用 UDF 涉及三个步骤。第一个是写一个函数并测试它。第二步是通过将函数名及其签名传递给 Spark 的udf
函数,向 Spark 注册该函数。最后一步是在 DataFrame 代码中或发出 SQL 查询时使用 UDF。在 SQL 查询中使用 UDF 时,注册过程略有不同。清单 4-47 用一个简单的 UDF 演示了这三个步骤。
import org.apache.spark.sql.functions.udf
// create student grades DataFrame
case class Student(name:String, score:Int)
val studentDF = Seq(Student("Joe", 85), Student("Jane", 90), Student("Mary", 55)).toDF()
// register as a view
studentDF.createOrReplaceTempView("students")
// create a function to convert grade to a letter grade
def letterGrade(score:Int) : String = {
score match {
case score if score > 100 => "Cheating"
case score if score >= 90 => "A"
case score if score >= 80 => "B"
case score if score >= 70 => "C"
case _ => "F"
}
}
// register as an UDF
val letterGradeUDF = udf(letterGrade(_:Int):String)
// use the UDF to convert scores to letter grades
studentDF.select($"name",$"score",
letterGradeUDF($"score").as("grade")).show
+----+-----+-----+
|name|score|grade|
+----+-----+-----+
| Joe| 85| B|
|Jane| 90| A|
|Mary| 55| F|
+----+-----+-----+
// register as UDF to use in SQL
spark.sqlContext.udf.register("letterGrade",
letterGrade(_: Int): String)
spark.sql("select name, score, letterGrade(score) as grade from students").show
+----+-----+-----+
|name|score|grade|
+----+-----+-----+
| Joe| 85| B|
|Jane| 90| A|
|Mary| 55| F|
+----+-----+-----+
Listing 4-47A Simple UDF in Scala to Convert Numeric Grades to Letter Grades
高级分析功能
前面几节介绍了 Spark SQL 为基本分析需求提供的内置函数,如聚合、连接、透视和分组。所有这些函数从单个行中获取一个或多个值并产生一个输出值,或者获取一组行并返回一个输出。
本节介绍 Spark SQL 提供的高级分析功能。第一个是关于多维聚合,这对于涉及分层数据分析的用例非常有用。通常需要计算一组分组列的小计和总计。第二个功能是基于时间窗口执行聚合,这在处理时序数据(如来自物联网设备的交易或传感器值)时非常有用。第三个是在逻辑行分组(称为窗口)中执行聚合的能力。这种功能使您能够轻松地执行计算,例如移动平均值、累积和或每行的排名。
带有汇总和多维数据集的聚合
Rollups 和 cube 是多列分组的更高级版本,它们跨这些列的组合和排列生成小计和总计。所提供的一组列的顺序被视为分组的层次结构。
汇总
当处理分层数据(如跨不同部门和分部的收入数据)时,汇总可以很容易地计算它们之间的小计和总计。累计会考虑给定累计列集的给定层次结构,并始终从层次结构中的第一列开始累计过程。输出中列出了总数,其中所有列值都为 null。清单 4-48 展示了一个汇总是如何工作的。
// read in the flight summary data
val flight_summary = spark.read.format("csv")
.option("header", "true")
.option("inferSchema","true")
.load(<path>/chapter4/data/ flights/flight-summary.csv)
// filter data down to smaller size to make it easier to see the rollups result
val twoStatesSummary = flight_summary.select('origin_state,
'origin_city,'count)
.where('origin_state === "CA" || 'origin_state === "NY")
.where('count > 1 && 'count < 20)
.where('origin_city =!= "White Plains")
.where('origin_city =!= "Newburgh")
.where('origin_city =!= "Mammoth Lakes")
.where('origin_city =!= "Ontario")
// let's see what the data looks like
twoStatesSummary.orderBy('origin_state).show
+-------------+--------------+------+
| origin_state| origin_city| count|
+-------------+--------------+------+
| CA| San Diego | 18|
| CA| San Francisco| 5|
| CA| San Francisco| 14|
| CA| San Diego| 4|
| CA| San Francisco| 2|
| NY| New York| 4|
| NY| New York| 2|
| NY| Elmira| 15|
| NY| Albany| 5|
| NY| Albany| 3|
| NY| New York| 4|
| NY| Albany| 9|
| NY| New York| 10|
+-------------+--------------+------+
// perform the rollup by state, city,
// then calculate the sum of the count,and finally order by null last
twoStatesSummary.rollup('origin_state, 'origin_city)
.agg(sum("count") as "total")
.orderBy('origin_state.asc_nulls_last,
'origin_city.asc_nulls_last)
.show
+-------------+--------------+------+
| origin_state| origin_city| total|
+-------------+--------------+------+
| CA| San Diego| 22|
| CA| San Francisco| 21|
| CA| null| 43|
| NY| Albany| 17|
| NY| Elmira| 15|
| NY| New York| 20|
| NY| null| 52|
| null| null| 95|
+-------------+--------------+------+
Listing 4-48Performing Rollups with Flight Summary Data
该输出在第三行和第七行显示了每个州的小计。最后一行显示了在 original_state 和 origin_city 列中都为空值的合计。诀窍是使用 asc_nulls_last 选项进行排序,因此 Spark SQL order 空值排在最后。
立方体
多维数据集是汇总的更高级版本。它对分组列的所有组合执行聚合。因此,结果包括汇总提供的内容以及其他组合。在按 origin_state 和 origin_city 进行立方的示例中,结果包括每个原始城市的聚合。使用cube
功能的方法类似于使用rollup
功能的方法。
清单 4-49 就是一个例子。
// perform the cube across origin_state and origin_city
twoStatesSummary.cube('origin_state, 'origin_city)
.agg(sum("count") as "total")
.orderBy('origin_state.asc_nulls_last,
'origin_city.asc_nulls_last)
.show
+------------+-------------+-----+
|origin_state| origin_city|total|
+------------+-------------+-----+
| CA| San Diego| 22|
| CA|San Francisco| 21|
| CA| null| 43|
| NY| Albany| 17|
| NY| Elmira| 15|
| NY| New York| 20|
| NY| null| 52|
| null| Albany| 17|
| null| Elmira| 15|
| null| New York| 20|
| null| San Diego| 22|
| null|San Francisco| 21|
| null| null| 95|
+------------+-------------+-----+
Listing 4-49Performing a Cube Across the origin_state and origin_city Columns
在该表中,origin_state 列中具有空值的行表示一个州中所有城市的聚合。因此,多维数据集的结果总是比汇总的结果有更多的行。
带时间窗口的聚合
Spark 2.0 中引入了带时间窗口的聚合,以便于处理时序数据,时序数据由一系列按时间顺序排列的数据点组成。这种数据集在金融或电信等行业很常见。例如,股票市场交易数据集包含每个股票代码的交易日期、开盘价、收盘价、交易量和其他信息。时间窗口聚合可以帮助回答诸如苹果股票的周平均收盘价或苹果股票每周的月移动平均收盘价之类的问题。
窗口函数有几种版本,但它们都需要一个时间戳类型的列和一个窗口长度,以秒、分钟、小时、天或周为单位。窗口长度表示具有开始时间和结束时间的时间窗口,它决定了特定的时间序列数据应属于哪个时段。另一个版本接受滑动窗口大小的额外输入,它告诉在计算下一个时段时时间窗口应该滑动多少。这些版本的窗口功能是世界事件处理中翻滚窗口和滑动窗口概念的实现,它们在第六章中有更详细的描述。
下面的例子使用了苹果股票交易,可以在 Yahoo! https://in.finance.yahoo.com/q/hp?s=AAPL
的财经网站。清单 4-50 根据一年的数据计算苹果股票的周均价。
val appleOneYearDF = spark.read.format("csv")
.option("header", "true")
.option("inferSchema","true")
.load("<path>/chapter5/data/stocks/aapl-2017.csv")
// display the schema, the first column is the transaction date
appleOneYearDF.printSchema
|-- Date: string (nullable = true)
|-- Open: double (nullable = true)
|-- High: double (nullable = true)
|-- Low: double (nullable = true)
|-- Close: double (nullable = true)
|-- Adj Close: double (nullable = true)
|-- Volume: integer (nullable = true)
// calculate the weekly average price using window function inside the groupBy transformation
// this is an example of the tumbling window, aka fixed window
val appleWeeklyAvgDF = appleOneYearDF.
groupBy(window('Date, "1 week"))
.agg(avg("Close"). as("weekly_avg"))
// the result schema has the window start and end time
appleWeeklyAvgDF.printSchema
|-- window: struct (nullable = false)
| |-- start: timestamp (nullable = true)
| |-- end: timestamp (nullable = true)
|-- weekly_avg: double (nullable = true)
// display the result with ordering by start time and
// round up to 2 decimal points
appleWeeklyAvgDF.orderBy("window.start")
.selectExpr("window.start",
"window.end","round(weekly_avg, 2) as
weekly_avg")
.show(5)
// notice the start time is inclusive and end time is exclusive
+--------------------+--------------------+---------------+
| start| end| weekly_avg|
+--------------------+--------------------+---------------+
| 2016-12-28 16:00:00| 2017-01-04 16:00:00| 116.08|
| 2017-01-04 16:00:00| 2017-01-11 16:00:00| 118.47|
| 2017-01-11 16:00:00| 2017-01-18 16:00:00| 119.57|
| 2017-01-18 16:00:00| 2017-01-25 16:00:00| 120.34|
| 2017-01-25 16:00:00| 2017-02-01 16:00:00| 123.12|
+--------------------+--------------------+---------------+
Listing 4-50Using the Time Window Function to Calculate the Average Closing Price of Apple Stock
清单 4-50 使用为期一周的滚动窗口,其中没有重叠。
因此,每笔交易只使用一次来计算移动平均线。清单 4-51 中的例子使用了滑动窗口。这意味着在计算月平均移动平均值时,一些交易会被多次使用。窗口大小为四周,在每个窗口中每次滑动一周。
// 4 weeks window length and slide by one week each time
val appleMonthlyAvgDF = appleOneYearDF.groupBy(
window('Date, "4 week", "1 week"))
.agg(avg("Close").as("monthly_avg"))
// display the results with order by start time
appleMonthlyAvgDF.orderBy("window.start")
.selectExpr("window.start", "window.end",
"round(monthly_avg, 2) as monthly_avg")
.show(5)
+--------------------+--------------------+------------+
| start| end| monthly_avg|
+--------------------+--------------------+------------+
| 2016-12-07 16:00:00| 2017-01-04 16:00:00| 116.08|
| 2016-12-14 16:00:00| 2017-01-11 16:00:00| 117.79|
| 2016-12-21 16:00:00| 2017-01-18 16:00:00| 118.44|
| 2016-12-28 16:00:00| 2017-01-25 16:00:00| 119.03|
| 2017-01-04 16:00:00| 2017-02-01 16:00:00| 120.42|
+--------------------+--------------------+------------+
Listing 4-51Use the Time Window Function to Calculate the Monthly Average Closing Price of Apple Stock
由于滑动窗口间隔是一周,前面的结果表显示两个连续行之间的开始时间差是一周。在两个连续的行之间,有大约三周的重叠交易,这意味着一个交易被多次用于计算移动平均。
窗口功能
您知道如何使用诸如concat
或round
之类的函数来计算单个行的一个或多个列值的输出,并利用诸如 max 或 sum 之类的聚合函数来计算每组行的输出。有时需要对一组行进行操作,并为每个输入行返回一个值。窗口函数提供了这种独特的功能,使得执行移动平均、累积和或每行的排名等计算变得容易。
使用窗口函数有两个主要步骤。第一个是定义一个窗口规范,该规范定义了一个称为框架的行逻辑分组,框架是评估每一行的上下文。第二步是应用一个适合你要解决的问题的窗口函数。在以下几节中,您将了解有关可用窗口功能的更多信息。
窗口规范定义了窗口函数使用的三个重要组件。第一个组件称为 partition by,您可以在这里指定一个或多个列来对行进行分组。第二个组件称为 order by,它定义了如何根据一个或多个列对行进行排序,以及排序应该是升序还是降序。在这三个组成部分中,最后一个更复杂,需要详细解释。最后一个组件叫做帧,它定义了当前行中窗口的边界。换句话说,“框架”限制了在计算当前行的值时要包括哪些行。可以使用行索引或 order by 表达式的实际值来指定要包含在窗口框架中的行的范围。最后一个组件适用于某些窗口函数,因此在某些情况下可能不是必需的。使用org.apache.spark.sql.expressions.Window
类中定义的函数构建窗口规范。rowsBetween
和rangeBetweeen
函数分别通过行索引和实际值定义范围。
窗口函数可以分为三种不同的类型:排名函数、分析函数和聚集函数。排序和分析功能分别在表 4-4 和表 4-5 中描述。对于聚合函数,您可以将任何聚合函数用作窗口函数。您可以在 https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/functions.html
找到窗口功能的完整列表。
表 4-5
分析函数
|名字
|
描述
|
| --- | --- |
| cume _ dist | 返回一个框架中值的累积分布。换句话说,当前行下面的行的比例。 |
| 滞后(列,偏移) | 返回当前行之前偏移行的列的值。 |
| 引线(列,偏移) | 返回当前行之后偏移行的列的值。 |
表 4-4
排名功能
|名字
|
描述
|
| --- | --- |
| 等级 | 根据某种排序顺序返回框架中行的等级或顺序。 |
| 密集 _ 秩 | 类似于等级,但是在有平局的情况下,等级之间没有空隙。 |
| 分钟 _rank | 返回一个框架中行的相对等级。 |
| 不完整的 | 返回有序窗口分区中的 ntile 组 ID。例如,如果 n 为 4,第一个四分之一的行的值为 1,第二个四分之一的行的值为 2,依此类推。 |
| 行数 | 返回一个帧中从 1 开始的序列号。 |
让我们通过一个小样本数据集来演示窗口函数功能,从而将这些步骤放在一起。表 4-6 包含两个虚拟用户:John 和 Mary 的购物交易数据。
表 4-6
用户购物交易
|名称
|
日期
|
金额
|
| --- | --- | --- |
| 约翰 | 2017-07-02 | Thirteen point three five |
| 约翰 | 2016-07-06 | Twenty-seven point three three |
| 约翰 | 2016-07-04 | Twenty-one point seven two |
| 玛丽 | 2017-07-07 | Sixty-nine point seven four |
| 玛丽 | 2017-07-01 | Fifty-nine point four four |
| 玛丽 | 2017-07-05 | Eighty point one four |
有了这些购物交易数据,让我们试着用窗口函数来回答
以下问题。
-
对于每个用户,最高的两笔交易金额是多少?
-
每个用户的交易金额和他们的最高交易金额相差多少?
-
每个用户的移动平均交易金额是多少?
-
每个用户的交易金额累计和是多少?
要回答第一个问题,您可以在一个窗口规范上应用rank
window 函数,该窗口规范按用户对数据进行分区,并按降序对数据进行排序。等级窗口函数根据每一帧中每一行的排序顺序为每一行分配一个等级。清单 4-52 是解决第一个问题的实际代码。
// small shopping transaction dataset for two users
val txDataDF= Seq(("John", "2017-07-02", 13.35),
("John", "2017-07-06", 27.33),
("John", "2017-07-04", 21.72),
("Mary", "2017-07-07", 69.74),
("Mary", "2017-07-01", 59.44),
("Mary", "2017-07-05", 80.14))
.toDF("name", "tx_date", "amount")
// import the Window class
import org.apache.spark.sql.expressions.Window
// define window specification to partition by name
// and order by amount in descending amount
val forRankingWindow =
Window.partitionBy("name").orderBy(desc("amount"))
// add a new column to contain the rank of each row,
// apply the rank function to rank each row
val txDataWithRankDF =
txDataDF.withColumn("rank", rank().over(forRankingWindow))
// filter the rows down based on the rank to find
// the top 2 and display the result
txDataWithRankDF.where('rank < 3).show(10)
+------+-----------+-------+-----+
| name| tx_date| amount| rank|
+------+-----------+-------+-----+
| Mary| 2017-07-05| 80.14| 1|
| Mary| 2017-07-07| 69.74| 2|
| John| 2017-07-06| 27.33| 1|
| John| 2017-07-04| 21.72| 2|
+------+-----------+-------+-----+
Listing 4-52Apply the Rank Window Function to Find out the Top Two Transactions per User
解决第二个问题的方法包括对所有分区行的 amount 列应用max
函数。除了按照用户名进行分区之外,还需要定义一个包含每个分区中所有行的框架边界。为此,您使用Window.rangeBetween
函数,将Window. unboundedPreceding
作为起始值,将Window.unboundedFollowing
作为结束值。清单 4-53 根据之前定义的逻辑定义了一个窗口规范,并对其应用了max
函数。
// use rangeBetween to define the frame boundary that includes
// all the rows in each frame
val forEntireRangeWindow =
Window.partitionBy("name").orderBy(desc("amount"))
.rangeBetween(Window.unboundedPreceding,
Window.unboundedFollowing)
// apply the max function over the amount column and then compute // the difference
val amountDifference =
max(txDataDF("amount")).over(forEntireRangeWindow) -
txDataDF("amount")
// add the amount_diff column using the logic defined above
val txDiffWithHighestDF =
txDataDF.withColumn("amount_diff", round(amountDifference, 3))
// display the result
txDiffWithHighestDF.show
+------+-----------+-------+-------------+
| name| tx_date| amount| amount_diff|
+------+-----------+-------+-------------+
| Mary| 2017-07-05| 80.14| 0.0|
| Mary| 2017-07-07| 69.74| 10.4|
| Mary| 2017-07-01| 59.44| 20.7|
| John| 2017-07-06| 27.33| 0.0|
| John| 2017-07-04| 21.72| 5.61|
| John| 2017-07-02| 13.35| 13.98|
+------+-----------+-------+-------------+
Listing 4-53Applying the max Window Function to Find the Difference of Each Row and the Highest Amount
为了按照交易日期的顺序计算每个用户的交易量移动平均值,您可以利用avg
函数根据一个帧中的一组行来计算每行的平均交易量。对于本例,您希望每个框架包括三行:当前行加上它前面的一行和它后面的一行。根据特定的用例,帧可能在当前行之前和之后包含更多的行。与前面的示例一样,窗口规范按用户划分数据,但是每个框架中的行是按事务日期排序的。清单 4-54 展示了如何将avg
函数应用到前面描述的窗口规范中。
// define the window specification
// a good practice is to specify the offset relative to
// Window.currentRow
val forMovingAvgWindow =
Window.partitionBy("name").orderBy("tx_date")
.rowsBetween(Window.currentRow-1,Window.currentRow+1)
// apply the average function over the amount column over the
// window specification
// also round the moving average amount to 2 decimals
val txMovingAvgDF = txDataDF.withColumn("moving_avg",
round(avg("amount").over(forMovingAvgWindow), 2))
// display the result
txMovingAvgDF.show
+------+-----------+-------+-----------+
| name| tx_date| amount| moving_avg|
+------+-----------+-------+-----------+
| Mary| 2017-07-01| 59.44| 69.79|
| Mary| 2017-07-05| 80.14| 69.77|
| Mary| 2017-07-07| 69.74| 74.94|
| John| 2017-07-02| 13.35| 17.54|
| John| 2017-07-04| 21.72| 20.8|
| John| 2017-07-06| 27.33| 24.53|
+------+-----------+-------+-----------+
Listing 4-54Applying the Average Window Function to Compute the Moving Average Transaction Amount
要计算每个用户的交易量的累积和,请对包含所有行直到当前行的帧应用sum
函数。partition by 和 order by 子句与移动平均示例相同。清单 4-55 展示了如何将sum
函数应用到前面描述的窗口规范中。
// define the window specification with each frame includes all
// the previous rows and the current row
val forCumulativeSumWindow =
Window.partitionBy("name").orderBy("tx_date")
.rowsBetween(Window.unbounded
Preceding,Window.currentRow)
// apply the sum function over the window specification
val txCumulativeSumDF =
txDataDF.withColumn("culm_sum",round(sum("amount")
.over(forCumulativeSumWindow),2))
// display the result
txCumulativeSumDF.show
+------+-----------+-------+---------+
| name| tx_date| amount| culm_sum|
+------+-----------+-------+---------+
| Mary| 2017-07-01| 59.44| 59.44|
| Mary| 2017-07-05| 80.14| 139.58|
| Mary| 2017-07-07| 69.74| 209.32|
| John| 2017-07-02| 13.35| 13.35|
| John| 2017-07-04| 21.72| 35.07|
| John| 2017-07-06| 27.33| 62.4|
+------+-----------+-------+---------+
Listing 4-55Applying the sum Window Function to Compute the Cumulative Sum of Transaction Amount
窗口规范的默认框架包括所有前面的行以及当前行。在清单 4-55 中,没有必要指定框架,所以你应该得到相同的结果。窗口函数示例是使用 DataFrame APIs 编写的。您可以使用带有PARTITION BY
、ORDER BY
、ROWS BETWEEN
和RANGE BETWEEN
关键字的 SQL 来实现相同的目标。
可以使用以下关键字来指定帧边界:UNBOUNDED PRECEDING, UNBOUNDED FOLLOWING, CURRENT ROW
、FOLLOWING
。清单 4-56 显示了在 SQL 中使用窗口函数的例子。
// register the txDataDF as a temporary view called tx_data
txDataDF.createOrReplaceTempView("tx_data")
// use RANK window function to find top two highest transaction amount
spark.sql("select name, tx_date, amount, rank from
(
select name, tx_date, amount,
RANK() OVER (PARTITION BY name ORDER BY amount DESC) as rank
from tx_data
) where rank < 3").show
// difference between maximum transaction amount
spark.sql("select name, tx_date, amount, round((max_amount -
amount),2) as amount_diff from
(
select name, tx_date, amount, MAX(amount) OVER
(PARTITION BY name ORDER BY amount DESC
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) as max_amount from tx_data)").show
// moving average
spark.sql("select name, tx_date, amount, round(moving_avg,2) as moving_avg from
(
select name, tx_date, amount, AVG(amount) OVER
(PARTITION BY name ORDER BY tx_date
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
) as moving_avg from tx_data)"
).show
// cumulative sum
spark.sql("select name, tx_date, amount, round(culm_sum,2) as moving_avg from
(
select name, tx_date, amount, SUM(amount) OVER
(PARTITION BY name ORDER BY tx_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) as culm_sum from tx_data)"
).show
Listing 4-56Example of a Window Function in SQL
在 SQL 中使用窗口函数时,partition by、order by 和 frame
必须在单个语句中指定 window。
探索催化剂优化器
编写高效的数据处理应用程序的最简单方法是不要担心它,并自动优化您的数据处理应用程序。这是 Spark Catalyst 的承诺,它是一个查询优化器,是 Spark SQL 模块中的第二个主要组件。它在确保用 DataFrame APIs 或 SQL 编写的数据处理逻辑高效快速运行方面起着重要作用。它旨在最小化端到端的查询响应时间,并且是可扩展的,这样 Spark 用户就可以将用户代码注入到优化器中来执行定制优化。
在高层次上,Spark Catalyst 将用户编写的数据处理逻辑转换为逻辑计划,然后使用试探法对其进行优化,最后将逻辑计划转换为物理计划。最后一步是根据物理规划生成代码。图 4-6 提供了这些步骤的直观表示。
图 4-6
催化剂优化器
逻辑计划
Catalyst 优化过程的第一步是从一个DataFrame
对象或解析的 SQL 查询的抽象语法树创建一个逻辑计划。逻辑计划是用户数据处理逻辑在操作符和表达式树中的内部表示。接下来,Catalyst 分析逻辑计划来解析引用,以确保它们是有效的。然后,它对逻辑计划应用一组基于规则和基于成本的优化。这两种类型的优化都遵循尽早删除不必要的数据和最小化每个操作符成本的原则。
基于规则的优化包括常量合并、项目修剪、谓词下推等。例如,在这个优化阶段,Catalyst 可能决定在执行连接之前移动过滤条件。出于好奇,基于规则的优化列表是在org.apache.spark.sql.catalyst.optimizer.Optimizer
类中定义的。
Spark 2.2 中引入了基于成本的优化,使 Catalyst 能够更智能地根据正在处理的数据的统计信息选择正确的连接类型。基于成本的优化依赖于参与筛选或连接条件的列的详细统计信息,这就是引入统计信息收集框架的原因。统计数据的示例包括基数、不同值的数量、最大值/最小值以及平均长度/最大长度。
物理计划
一旦逻辑计划得到优化,Catalyst 就会使用与 Spark 执行引擎相匹配的物理操作符来生成物理计划。除了在逻辑规划阶段执行的优化之外,物理规划阶段还执行自己的基于规则的优化,包括将投影和过滤合并到单个操作中,并将投影或过滤谓词下推到支持此功能的数据源,即 Parquet。同样,这些优化遵循数据修剪原则。Catalyst 执行的最后一步是生成最便宜的物理计划的 Java 字节码。
催化剂在起作用
本节展示了如何使用DataFrame
类的explain
函数来显示逻辑和物理计划。
您可以将 explain 函数的扩展参数作为布尔值 true 来调用,以查看逻辑和物理计划。否则,此功能仅显示物理平面图。
这个小而有点傻的示例首先读取 Parquet 格式的电影数据,根据 produced_year 执行过滤,添加一个名为 produced_ decade 的列,并投影 movie_title 和 produced_decade 列,最后根据produced_decade
过滤行。这里的目标是通过向explain
函数传递一个布尔true
值来检查生成的逻辑和物理计划,从而证明 Catalyst 执行了谓词下推和过滤条件优化。在输出中,您可以看到四个部分:解析的逻辑计划、分析的逻辑计划、优化的逻辑计划和物理计划。清单 4-57 展示了如何生成逻辑和物理计划。
// read movies data in Parquet format
val moviesDF =
spark.read.load("<path>/book/chapter4/data/movies/movies.
parquet")
// perform two filtering conditions
val newMoviesDF = moviesDF.filter('produced_year > 1970)
.withColumn("produced_decade",
'produced_year + 'produced_year % 10)
val latestMoviesDF = newMoviesDF.select('movie_title,
'produced_decade)
.where('produced_decade > 2010)
// display both logical and physical plans
latestMoviesDF.explain(true)
== Parsed Logical Plan ==
'Filter ('produced_decade > 2010)
+- Project [movie_title#673, produced_decade#678L]
+- Project [actor_name#672, movie_title#673, produced_year#674L, (produced_year#674L + (produced_year#674L % cast(10 as bigint))) AS produced_decade#678L]
+- Filter (produced_year#674L > cast(1970 as bigint))
+- Relation[actor_name#672,movie_title#673,produced_year#674L] parquet
== Analyzed Logical Plan ==
movie_title: string, produced_decade: bigint
Filter (produced_decade#678L > cast(2010 as bigint))
+- Project [movie_title#673, produced_decade#678L]
+- Project [actor_name#672, movie_title#673, produced_year#674L, (produced_year#674L + (produced_year#674L % cast(10 as bigint))) AS produced_decade#678L]
+- Filter (produced_year#674L > cast(1970 as bigint))
+- Relation[actor_name#672,movie_title#673,produced_year#674L] parquet
== Optimized Logical Plan ==
Project [movie_title#673, (produced_year#674L + (produced_year#674L % 10)) AS produced_decade#678L]
+- Filter ((isnotnull(produced_year#674L) AND (produced_year#674L > 1970)) AND ((produced_year#674L + (produced_year#674L % 10)) > 2010))
+- Relation[actor_name#672,movie_title#673,produced_year#674L] parquet
== Physical Plan ==
*(1) Project [movie_title#673, (produced_year#674L + (produced_year#674L % 10)) AS produced_decade#678L]
+- *(1) Filter ((isnotnull(produced_year#674L) AND (produced_year#674L > 1970)) AND ((produced_year#674L + (produced_year#674L % 10)) > 2010))
+- *(1) ColumnarToRow
+- FileScan parquet [movie_title#673,produced_year#674L] Batched: true, DataFilters: [isnotnull(produced_year#674L), (produced_year#674L > 1970), ((produced_year#674L + (produced_yea..., Format: Parquet, Location: InMemoryFileIndex[file:<path>/chapter4/data/movies/..., PartitionFilters: [], PushedFilters: [IsNotNull(produced_year), GreaterThan(produced_year,1970)], ReadSchema: struct<movie_title:string,produced_year:bigint>
Listing 4-57Using the explain Function to Generate the Logical and Physical Plans
如果您仔细分析优化的逻辑计划,您会看到它将两个过滤条件组合成一个过滤器。物理规划显示 Catalyst 在 FileScan 步骤中同时下推 produced_year 的过滤并执行预测修剪,以最佳方式仅读入所需的数据。
在 Spark 3.0 中,引入了explain
函数的新变体。它接受一个字符串形式的输入,允许你指定在输出中看到五种模式中的哪一种(见表 4-7 )。
表 4-7
输出格式的各种模式
|方式
|
描述
|
| --- | --- |
| 简单的 | 仅打印物理计划。 |
| 延长 | 打印逻辑和物理计划。 |
| codegen(代码基因) | 打印物理计划和生成的代码(如果它们可用)。 |
| 费用 | 打印逻辑计划和统计数据(如果有)。 |
| 格式化 | 将解释输出分成两部分;物理计划大纲和细节。 |
最后三个选项生成新信息。检查生成的 Scala 代码并把它作为一个练习留给你是很有趣的。formatted
选项的输出可读性更好,也更容易理解。清单 4-58 显示了如何在formatted
模式下使用explain
功能。
latestMoviesDF.explain("formatted")
== Physical Plan ==
* Project (4)
+- * Filter (3)
+- * ColumnarToRow (2)
+- Scan parquet (1)
(1) Scan parquet
Output [2]: [movie_title#673, produced_year#674L]
Batched: true
Location: InMemoryFileIndex [file:<path>/chapter4/data/movies/movies.parquet]
PushedFilters: [IsNotNull(produced_year), GreaterThan(produced_year,1970)]
ReadSchema: struct<movie_title:string,produced_year:bigint>
(2) ColumnarToRow [codegen id : 1]
Input [2]: [movie_title#673, produced_year#674L]
(3) Filter [codegen id : 1]
Input [2]: [movie_title#673, produced_year#674L]
Condition : ((isnotnull(produced_year#674L) AND (produced_year#674L > 1970)) AND ((produced_year#674L + (produced_year#674L % 10)) > 2010))
(4) Project [codegen id : 1]
Output [2]: [movie_title#673, (produced_year#674L + (produced_year#674L % 10)) AS produced_decade#678L]
Input [2]: [movie_title#673, produced_year#674L]
Listing 4-58Using the explain Function with formatted Mode
输出清楚地显示了 Spark 计算 latestMoviesDF 的四个步骤:扫描或读取输入的 parquet 文件,将列格式的数据转换为行,根据两个指定的条件进行筛选,最后投影标题并生成 decade 列。
钨项目
从 2015 年开始,Spark 设计师发现 Spark 工作负载越来越多地受到 CPU 和内存的瓶颈,而不是 I/O 和网络通信。这有点违反直觉,但并不令人惊讶,因为硬件方面的进步,如 10Gbps 网络链接和高速 SSD。钨计划旨在提高 Spark 应用程序中内存和 CPU 的使用效率,并将性能推向现代硬件的极限。钨项目有三项举措。
-
通过使用堆外管理技术来明确地管理内存,以消除 JVM 对象模型的开销并最小化垃圾收集。
-
使用智能缓存感知算法和数据结构来利用内存层次。
-
通过将多个运算符组合成一个函数,使用全阶段代码生成来最大限度地减少虚函数调用。
钨项目中艰苦而有趣的工作极大地改进了 Spark 2.0 以来的 Spark 执行引擎。钨项目中的大部分工作发生在执行引擎的幕后。下面的例子通过检查物理计划,展示了对整个阶段代码生成计划的一点了解。在下面的输出中,每当一个星号(*)出现在一个运算符之前,就意味着启用了全阶段代码生成。清单 4-59 显示了对数据帧中的整数进行过滤和求和的物理计划。
spark.range(1000).filter("id > 100")
.selectExpr("sum(id)").explain("formatted")
== Physical Plan ==
* HashAggregate (5)
+- Exchange (4)
+- * HashAggregate (3)
+- * Filter (2)
+- * Range (1)
(1) Range [codegen id : 1]
Output [1]: [id#719L]
Arguments: Range (0, 1000, step=1, splits=Some(12))
(2) Filter [codegen id : 1]
Input [1]: [id#719L]
Condition : (id#719L > 100)
(3) HashAggregate [codegen id : 1]
Input [1]: [id#719L]
Keys: []
Functions [1]: [partial_sum(id#719L)]
Aggregate Attributes [1]: [sum#726L]
Results [1]: [sum#727L]
(4) Exchange
Input [1]: [sum#727L]
Arguments: SinglePartition, ENSURE_REQUIREMENTS, [id=#307]
(5) HashAggregate [codegen id : 2]
Input [1]: [sum#727L]
Keys: []
Functions [1]: [sum(id#719L)]
Aggregate Attributes [1]: [sum(id#719L)#723L]
Results [1]: [sum(id#719L)#723L AS sum(id)#724L]
Listing 4-59Demonstrating the Whole-Stage Code Generation by Looking at the Physical Plan
全阶段代码生成将过滤和对整数求和的逻辑结合到一个函数中。
摘要
本章介绍了 Spark SQL 模块中许多有用且强大的特性。
-
聚合是大数据分析领域最常用的功能之一。Spark SQL 提供了许多常用的聚合函数,比如
sum
、count
和avg
。旋转聚合提供了一种很好的汇总数据以及将列转置为行的方式。 -
执行任何复杂且有意义的数据分析或处理通常需要连接两个或更多数据集。Spark SQL 支持 SQL 世界中存在的许多标准连接类型。
-
Spark SQL 附带了一组丰富的内置函数,应该涵盖了处理字符串、数学、日期和时间等的大多数常见需求。如果没有一个满足用例的特定需求,那么很容易编写一个用户定义的函数,它可以与 DataFrame APIs 和 SQL 查询一起使用。
-
窗口函数是强大的高级分析函数,因为它们可以计算输入组中每一行的值。它们对于计算移动平均值、累计和或每行的排名特别有用。
-
Catalyst optimizer 使您能够编写高效的数据处理应用程序。Spark 2.2 中引入了基于成本的优化器,使 Catalyst 能够更智能地根据收集到的处理数据的统计信息选择正确的连接实现。
-
钨项目是幕后的主力,它通过采用一些先进的技术来提高内存和 CPU 的使用效率,从而加速数据处理应用程序的执行。
五、优化 Spark 应用
第四章讲述了 Spark SQL 执行简单到复杂数据处理的主要功能。当您使用 Spark 处理数百 GB 或数 TB 的大型数据集时,您会遇到有趣且具有挑战性的性能问题;因此,知道如何处理它们是很重要的。掌握 Spark 应用程序的性能问题是一个非常有趣、富有挑战性的广泛话题。这需要进行大量的研究,并深入了解 Spark 中与内存管理和数据移动相关的一些关键领域。
全面的调优指南是一个非常大的表面积,值得在自己的书。然而,这不是本章的目的。相反,它旨在讨论一些常见的 Spark 应用程序性能问题,您在开发 Spark 应用程序的过程中可能会遇到这些问题。
首先,它描述了一组常见的性能问题。然后详细介绍提高 Spark 应用程序性能的常用技术,比如调优重要的 Spark 配置和利用内存计算。最后一部分介绍了 Spark 3.0 中引入的自适应查询执行(AQE)框架中的智能优化技术,比如动态合并混洗分区、动态切换连接策略和优化偏斜连接。这些技术使 Spark 开发人员能够将更多的时间放在构建强大的 Spark 应用程序上,而将更少的时间放在性能优化上。
常见的性能问题
如果您在互联网上快速搜索常见的 Spark 应用程序性能问题,您会发现它们大致分为两类:OOM(内存不足)和需要很长时间才能完成。为了克服这些性能问题,需要了解 Spark 如何处理内存管理的底层机制,以及它如何执行一些复杂而昂贵的转换,如连接、分组和聚合。
Spark 配置
优化 Spark 应用性能的一个重要方面是了解哪些旋钮可用,如何应用这些旋钮,以及这些旋钮何时有效。在 Spark 世界中,这些旋钮被称为 Spark 属性。它们有数百种,大多数都有合理的默认值。关于 Spark 属性的完整列表,您可以在 https://spark.apache.org/docs/latest/configuration.html
查看 Spark 属性文档。
每个 Spark 应用程序开发人员都需要了解 Spark 属性的两个重要方面:三种不同的属性设置方式和两种不同的属性。
设置属性的不同方式
有三种不同的设置属性的方法,按优先顺序进行描述。
第一种方法是通过一组配置文件完成的。在安装 Spark 的目录下,有一个包含三个文件的conf
文件夹:log4j.properties.template
、spark-env.sh.template
和spark-default.conf.template.
,简单地添加所需的属性和相关值,然后保存它们,不带.template
后缀,让 Spark 知道将这些属性应用到 Spark 集群和提交到集群的所有 Spark 应用程序。换句话说,这些配置文件中的属性适用于全局集群级别。
第二种方法是在通过spark-submit
提交 Spark 应用程序时指定 Spark 属性,或者使用--config
或-c
标志通过spark-shell
命令行启动 Spark shell。清单 5-1 是一个通过命令行传递 Spark 属性的例子。
./bin/spark-submit --conf "spark-executor-memory=4g" --class org.apache.spark.examples.SparkPi ./examples/jars/spark-examples_2.12-3.1.1.jar
Listing 5-1Passing in Spark Properties via Command Line
第三种方法是通过 Spark 应用程序中的SparkConf
对象直接指定 Spark 属性。清单 5-2 是在 Scala 的 Spark 应用程序中设置属性的一个非常短的例子。
import org.apache.spark.sql.SparkSession
def main(args: Array[String]) {
val sparkSession = SparkSession.builder
.config("spark.executor.memory","4g")
.config("spark.eventLog.enabled","true")
.appName("MyApp").getOrCreate()
}
Listing 5-2Setting Spark Properties Directly in a Spark Application in Scala
由于有多种设置属性的方法,Spark 建立了以下优先顺序。直接在SparkConf
对象上设置的属性优先级最高,其次是在spark-submit
或spark-shell
命令行上传递的标志,然后是配置文件中的选项。
不同种类的属性
并非所有的 Spark 特性都是同等产生的。理解在应用程序部署生命周期的哪一部分使用哪一个是很重要的。Spark 属性主要可以分为两种:部署和运行时。
与部署相关的属性在 Spark 应用程序启动步骤中设置一次,之后就不能再更改了。因此,在运行时通过SparkConf
对象以编程方式设置它们是没有意义的。这类属性的例子有spark.driver.memory
或spark.executor.instances.
,建议通过配置文件或通过spark-submit
命令行设置这些属性。
在 Spark 应用程序运行期间,与运行时相关的属性控制 Spark 的各个方面,例如spark.sql.shuffle.partitions
,在为连接和聚合而移动数据时使用的分区数量。除了通过命令行在配置文件中设置它们之外,还可以通过SparkConf
对象以编程方式重复设置它们。
查看 Spark 属性
设置 Spark 属性后,验证它们的值是很重要的。查看 Spark 属性最简单的方法之一是在 Spark web UI 的 Environment 选项卡中。图 5-1 显示了 Spark 特性的一个例子。
图 5-1
环境选项卡中的 Spark 属性
查看 Spark 属性的另一个简单方法是从 Spark 应用程序的SparkConf
对象中以编程方式检索它们。清单 5-3 展示了如何做到这一点。
scala> for (prop <- spark.conf.getAll.keySet) {
println(s"${prop}: ${spark.conf.get(prop)}")
}
spark.sql.warehouse.dir: file:/<path>/spark-warehouse
spark.driver.host: 192.168.0.22
spark.driver.port: 63834
spark.repl.class.uri: spark://<ip>:63834/classes
spark.repl.class.outputDir:/private/var/folders/_m/nq53ddp...
spark.app.name: Spark shell
spark.submit.pyFiles:
spark.ui.showConsoleProgress: true
spark.app.startTime: 1621812866358
spark.executor.id: driver
spark.submit.deployMode: client
spark.master: local[*]
spark.home: /<path>/spark-3.1.1-bin-hadoop2.7
spark.sql.catalogImplementation: hive
spark.app.id: local-1621812867559
Listing 5-3Displaying Spark Properties Programmatically
Spark 存储器管理
Spark 开发人员在开发和操作处理大量数据的 Spark 应用程序时遇到的挑战之一是处理内存不足(OOM)错误。当这种情况发生时,您的 Spark 应用程序停止工作或崩溃,您唯一能做的就是找出根本原因,修复它并重启您的应用程序。
在讨论 OOM 问题之前,让我们先回顾一下 Spark 是如何在驱动程序和执行器上管理内存的。如第一章所述,每个 Spark 应用程序由一个驱动程序和一个或多个执行程序组成。
Spark 驱动器
关于内存管理,Spark 驱动程序的内存量由spark.driver.memory
配置决定,这通常是在通过spark-submit
命令或 Databricks 集群创建过程中的类似机制启动 Spark 应用程序时指定的。spark.driver.memory
配置的默认值是 1 GB。
驱动程序主要负责协调 Spark 应用程序的工作负载,因此它不需要像执行器那样多的内存。有两种场景需要分配内存,在这些场景中任何不正确的 Spark 使用都可能导致 OOM 问题。
第一种情况是调用RDD.collect
或DataFrame.collect
动作。此操作将 RDD 或数据帧的所有数据从所有执行器传输到应用程序的驱动程序,然后驱动程序尝试分配必要的内存来存储传输的数据。例如,如果数据帧中的数据大小约为 5 GB,而驱动程序只有 2 GB,则在调用操作时会遇到 OOM 问题。与这种情况最相似的是将五个一加仑的桶中的水倒入一个两加仑的桶中。
Spark 文档建议,只有当数据量预计很小并且可以放入驱动程序内存时,才应该使用这些操作。减小 RDD 或数据帧大小的两种常见方法是,在将数据收集到 Spark 驱动器端之前,执行某种过滤并调用极限变换。
第二个场景是 Spark 试图广播数据集的内容。这是因为 Spark 应用程序通过调用SparkContext.broadcast
函数使用广播变量,或者 Spark 正在执行广播连接,这将在“理解 Spark 连接”一节中讨论。
广播变量是一种优化技术,Spark 将数据集的不可变副本从驱动程序发送到 Spark 应用程序中的所有执行器。一旦执行器拥有数据集的副本,它就可供该执行器上的所有当前和未来任务使用。本质上,这是为了避免在重复需要时将同一个数据集多次传输给执行者。为了确保不会遇到 OOM 问题,请确保数据集的大小较小,或者增加驱动程序的内存大小。
在执行广播连接时,Spark 将两个被连接的数据帧中较小的数据帧发送给所有的执行器。与广播变量的情况类似,如果较小数据帧的数据太大,就会遇到 OOM 问题。您可以通过设置spark.sql.autoBroadcastJoinThreshold
配置的值来控制 Spark 何时使用广播连接,该值的默认值为 10 GB。如果要禁用广播加入,可以将此配置值设置为–1。
Spark 执行器
Spark 执行器端的内存管理比驱动程序端更复杂。首先,让我们讨论它如何管理内存,以及它决定在内存中存储什么。
在运行时,每个执行器都是一个运行在操作系统上的 JVM 进程。可用内存量是在 Java 虚拟机(JVM)内存堆中分配的。当启动 Spark 应用程序时,可以通过为spark.executor.memory
属性指定一个值来为执行器指定 JVM 内存堆大小,这个值的默认值是 1 GB。JVM 堆分为三个区域:Spark 内存、用户内存和保留内存(见图 5-2 )。
图 5-2
JVM 堆的不同区域
图 5-2 表示spark.executor.memory
属性中指定的内存并没有全部分配给执行者。在 Spark 应用程序中处理与内存相关的问题时,记住这一点很重要。让我们探索 JVM 内存堆中的每个区域。
-
保留内存留出固定数量的内存供内部使用。截至 Spark 版本,预留量为 300 MB。
-
用户内存是用于存储从 Spark 应用程序开发人员的数据结构创建的对象的区域,Spark 中的内部元数据,并在记录稀疏且通常较大的情况下防止 OOM 错误。该区域的大小计算为(1-
spark.memory.fraction
)*(spark.executor.memory
-保留内存)。 -
Spark 记忆是执行者控制下的区域。它用于执行和存储。该区域的大小计算为(
spark.memory.fraction
)*(spark.executor.memory
–保留内存)。
更具体地说,让我们看一个例子,其中 JVM 堆大小(spark.executor.memory
)是 4 GB,并使用配置的默认值spark.memory.fraction
0.6。表 5-1 列出了每个 JVM 堆区域的大小。
表 5-1
具有 4 GB 堆大小的 JVM 堆区域大小
|面积
|
公式
|
大小
|
| --- | --- | --- |
| 保留内存 | 不适用的 | 300 兆字节 |
| 用户存储器 | (1 - 0.6) * (4GB - 300MB) | 1.4 GB |
| Spark 记忆 | (0.6) * (4 千兆字节 - 300 MB) | 2.2 GB |
现在让我们更深入地研究 JVM 堆的 Spark 内存区域,Spark 执行器完全控制这个区域。该区域进一步分为两个隔间:执行和存储(见图 5-3 )。这两个隔间的大小是使用spark.memory.storageFraction
配置计算的,默认值为 0.5。
图 5-3
Spark 存储区中的两个隔间
Spark executor 决定在每个隔间中存储什么,它可以根据需要扩展和收缩每个隔间。下面列出了每个隔间中存储的内容以及如何计算其初始大小。
-
执行
-
此隔离专区用于在任务执行过程中缓冲中间数据,例如混洗数据、联接两个数据集、执行聚合或排序数据。这些对象通常是短暂的,在任务完成后不再需要。
-
该隔间的大小计算如下(Spark 存储区*(1–
spark.memory.storageFraction
))
-
-
仓库
-
此区间用于存储所有缓存数据,以备将来重复访问和广播变量。当您在数据帧上调用
persist()
或cache()
API 时,它的数据被保存在内存中,并被称为缓存数据。Spark 应用程序用户控制缓存数据的生命周期,但是由于内存压力,数据可能会被驱逐。 -
该区间的大小由
spark.memory.storageFraction
控制,计算方法为(Spark 存储区*spark.memory.storageFraction
)
-
Spark 版中引入了统一内存管理功能,以智能方式管理这两个部分,这种方式适用于大多数工作负载,在调整内存部分方面需要很少的专业知识,并利用应用程序不会缓存太多数据的未使用存储内存。
它通过在隔离专区之间建立一个边界来实现这些目标,该边界作为一个灵活的屏障,可以根据需要移动到任何一侧,并建立特定的规则来避免内存不足和系统故障。规则如下。
-
当执行内存超出其区间时,它可以借用尽可能多的空闲存储内存。
-
当存储内存超出其容量时,它可以借用尽可能多的空闲执行内存。
-
当执行需要更多内存,并且它的一些内存被存储舱借用时,它可以强制驱逐存储所占用的内存。
-
当存储需要更多的内存,并且它的一些内存被执行舱借用时,它不能强制驱逐被执行占用的内存。存储必须等到执行程序释放它们。
这些规则背后的主要动机是为执行提供所需的空间,而清除存储内存实现起来要简单得多。
为了防止存储内存不足,需要保证为缓存数据保留的最小内存。这是通过要求执行器接受由spark.memory.storageFraction
属性指定的内存量来实现的,该属性指定免于驱逐的存储内存量。换句话说,只有当总存储量超过存储隔间大小时,才可以驱逐用于高速缓存数据的存储存储器。
Spark 的规则和保证可以很好地支持多种类型的工作负载。当特定类型的工作负载执行大量复杂而广泛的转换,并且不需要缓存太多数据时,它可以根据需要利用存储舱中的空闲内存。
表 5-2 描述了与内存相关的属性。
表 5-2
记忆相关的 Spark 特性
|属性名称
|
缺省值
|
描述
|
| --- | --- | --- |
| spark.driver.memory
| 1GB | 用于驱动程序进程的内存量 |
| spark.executor.memory
| 1 GB | 每个执行器进程使用的内存量 |
| spark.memory.fraction
| Zero point six | 用于执行和存储的部分(JVM 堆空间–300 MB)。越低。建议将其保留为默认值。 |
| spark.memory.storageFraction
| Zero point five | 存储内存的数量不受驱逐的影响,表示为由spark.memory.fraction
留出的区域大小的一部分。它越高,可用于执行的内存就越少,任务可能会更频繁地溢出到磁盘。建议将其保留为默认值 |
接下来,让我们讨论几个您可能会遇到 OOM 问题的常见场景。
利用内存中的计算
Spark 与其他数据处理引擎或框架的区别之一是能够执行内存计算,以加速 Spark 应用程序中的数据处理逻辑。本节讨论何时利用这个独特的特性,以及如何在 Spark 集群中的执行器之间将数据持久化到 Spark 临时存储(内存或磁盘)中。
何时保存和缓存数据
Spark 提供了将数据帧中的数据持久化(或缓存)到内存中的能力,这可供任何未来的操作使用。因此,这些操作的运行速度要快得多,通常是 10 倍,因为它们需要的数据是从计算机内存中读取的。这种能力对于迭代算法以及需要或多次重用数据帧的数据时非常有用。机器学习算法是高度迭代的,这意味着它们通过多次迭代来产生优化的模型。这种能力也有助于为分析目的提供数据的快速和交互式使用。简而言之,当一个数据帧的数据在 Spark 应用程序中被多次重用时,强烈建议您考虑持久化该数据帧数据以加速 Spark 应用程序。
让我们通过一个简单的例子来说明利用内存计算的必要性。假设您被要求分析一个具有数十亿行的大型日志文件,通过分析各种类型的异常来确定最近生产问题的根本原因。将日志文件中的数据读入 DataFrame 后,下一步是过滤这些行,只保留包含单词 exception . Then,
的行,并将这些行保存在内存中,以便可以重复分析不同种类的异常。
就序列化、反序列化和存储成本而言,持久化数据集确实会产生一些成本。如果数据集只使用一次,缓存会降低 Spark 应用程序的速度。
持久性和缓存 API
Spark DataFrame 类提供了两个 API 来持久化数据:cache()
和persist()
。两者都提供相同的功能。前者是后者的简化版本,它提供了对数据帧存储方式和位置的更多控制,例如存储在内存或磁盘上,以及数据是否以序列化格式存储。
有理由问,当 Spark 应用程序没有足够的内存在内存中缓存大型数据集时会发生什么。例如,假设您的 Spark 应用程序有十个执行器,每个执行器有 6 GB 的 RAM。如果您希望保存在内存中的数据帧的大小是 70 GB,那么它就不适合 60 GB 的 RAM。这就是存储级概念的由来。持久化数据时有两个选项可供选择:位置和序列化。位置决定了数据应该存储在内存中、磁盘上还是两者的组合中。序列化选项确定数据是否应该存储为序列化对象。这两个选项代表了您正在进行的不同类型的权衡:CPU 时间和内存使用。表 5-3 描述了不同的选项,表 5-4 描述了权衡信息。
表 5-4
内存空间与 CPU 时间的权衡
|存储级别
|
存储量
|
中央处理机时间
|
| --- | --- | --- |
| 仅限内存 | 高的 | 低的 |
| 内存和磁盘 | 高的 | 中等 |
| 仅存储用户 | 低的 | 高的 |
| 内存和磁盘用户 | 低的 | 高的 |
| 仅磁盘 | 低的 | 高的 |
表 5-3
持久化数据的存储和序列化
|存储级别
|
描述
|
| --- | --- |
| 仅限内存 | 将数据作为反序列化对象仅保存在内存中 |
| 内存和磁盘 | 将数据作为反序列化对象保存在内存中。如果没有足够的内存,其余的将作为序列化对象存储在磁盘上。 |
| 仅存储用户 | 将数据作为序列化对象仅保存在内存中。 |
| 内存和磁盘用户 | 类似于 MEMORY_AND_DISK,但是将数据作为序列化对象保存在内存中。 |
| 仅磁盘 | 仅将数据作为序列化对象保存在磁盘上。 |
| MEMORY_ONLY_2,内存和磁盘 2 | 与 MEMORY_ONLY 和 MEMORY_AND_DISK 相同,但是在两个集群节点上复制数据。 |
当不再需要持久存储数据帧的数据时,可以使用unpersist()
API 在调用persist()
API 时根据指定的级别将其从内存或磁盘中删除。Spark 簇具有有限的内存;如果保持持久化数据帧,当可用内存量较低时,Spark 会使用 LRU 回收策略来自动回收最近没有被访问过的持久化数据帧。
持久性和缓存示例
本节将介绍一个在 Spark show 中持久化数据以提高性能的例子。它还查看了一个示例 Spark UI,展示了存储在 Spark executor 中的分区。清单 5-4 中的例子首先生成有 3 亿行的app_log_df
数据帧,其中有三列:id、消息、日期。然后,它过滤app_log_df
数据帧,只包含消息列中带有单词 exception 的消息,最后将输出分配给except_log_df
数据帧。
import org.apache.spark.sql.functions._
import scala.util.Random
val log_messages = SeqString
// set up functions and UDF
def getLogMsg(idx:Int) : String = {
val randomIdx = if (Random.nextFloat() < 0.3) 0 else
Random.nextInt(log_messages.size)
log_messages(randomIdx)
}
val getLogMsgUDF = udf(getLogMsg(_:Int):String)
// generate a DataFrame
val app_log_df = spark.range(30000000).toDF("id")
.withColumn("msg", getLogMsgUDF(lit(1)))
.withColumn("date",
date_add(current_timestamp,
-(rand() * 360).cast("int")))
// generate a DataFrame with “Exception” message
val except_log_df = app_log_df
.filter(msg.contains("Exception"))
// before persisting
except_log_df.count()
// call persist transformation to persist exception_log_df
except_log_df.cache()
// materialize the exception_log_df
except_log_df.count()
// this should be really fast
// since exception_log_df is now in memory
exception_log_df.count()
// evict exception_log_df from memory
exception_log_df.unpersist()
Listing 5-4Code Example to Persisting Data in Spark
图 5-4 显示了三个count
动作的持续时间。作业 0 用于第一次调用,耗时 8 秒。Job 0 用于第二次调用,该调用强制 Spark 在内存中物化 DataFrame 数据,耗时 7 秒。作业 2 是最后一次调用,仅用了 56 毫秒。如您所见,访问内存中的数据非常快。
图 5-4
显示每个计数操作的持续时间
要查看 Spark 如何在内存中存储except_log_df
数据帧,请调出 Spark UI 并导航到 Storage 选项卡。图 5-5 显示了缓存分区的数量和内存的总大小。
图 5-5
Spark UI 中的存储选项卡显示了缓存的数据帧
要查看持久化数据帧的每个分区的详细信息,请单击 RDD 名称列下的链接。图 5-6 显示了每个分区的大小、位置、存储级别和复制因子。
图 5-6
缓存分区的详细信息
您可以通过使用类型为StorageLevel
的适当参数调用persist
API 来指定存储和复制因子。
要以编程方式驱逐持久化的数据帧,您可以简单地调用unpersist
API。分区从内存和 Spark UI 的存储选项卡中消失。
请注意,图 5-5 中的 RDD 名字列有一个长而神秘的名字。要为持久化数据帧指定一个人类可读的名称,您必须使用几行额外的代码。第一步是创建一个带有名称的临时视图,然后将相同的名称传递给使用的SQLContext
的cachTable
API。清单 5-5 显示了所需的代码行。
except_log_df.createOrReplaceTempView("exception_DataFrame")
spark.sqlContext.cacheTable("exception_DataFrame")
Listing 5-5Persisting a DataFrame with a Human-Readable Name
如果这两行被成功执行,那么存储选项卡的 RDD 名称列下的名称应该类似于图 5-7 。
图 5-7
“存储”选项卡上持久数据帧的可读名称
要驱逐持久化的数据帧,只需将视图名传递给SQLContext
的uncacheTable
API。
了解 Spark 连接
执行任何有意义的数据分析都需要连接两个数据集,以用更多信息丰富一个数据集,或者通过执行某种聚合来提取洞察力。连接操作实际上是使用指定的键合并两个数据集。
Spark 广泛支持连接操作和数据帧、数据集和 Spark SQL APIs 中的各种连接类型。这将在第四章中介绍。本章讨论了 Spark 使用的一些连接策略以及与内存和性能相关的方面。
连接策略是一种执行连接操作的方法。Spark 支持五种不同的连接策略:广播散列连接(BHJ)、混洗散列连接(SHJ)、混洗排序合并连接(SMJ)、广播嵌套循环连接(BNLJ)和混洗复制嵌套循环连接(又称为笛卡尔乘积连接)。表 5-5 总结了每一项。在这五种策略中,Spark 中最常用的是 BHJ 和 SMJ,因为它们足够灵活,可以处理大多数情况,例如当一个数据集很小或两个数据集都很大时。
表 5-5
加入策略摘要
|名字
|
连接条件
|
描述
|
| --- | --- | --- |
| 广播散列连接 | 等值联接 | 当其中一个数据集小到足以在网络上传播时,执行哈希连接。 |
| 无序散列连接 | 等值联接 | 当两个数据集很大时,在网络上混洗它们,然后执行散列连接。 |
| 洗牌合并加入 | 等值联接 | 当两个数据集很大时,在网络上对它们进行混排、排序,然后合并。 |
| 广播嵌套循环连接 | 等值和非等值联接 | 广播较小的数据集,并使用嵌套循环来执行连接。 |
| 无序复制嵌套循环连接 | 等值和非等值联接 | 笛卡尔乘积连接。非常慢。 |
广播散列连接
在五种连接策略中,BHJ(也称为仅地图端连接)是 Spark 中最简单、最快的连接策略,适用于其中一个数据集很小的情况。该策略通过驱动程序向 Spark 集群中的所有执行器广播较小数据集的副本。然后对更大的数据集执行连接,如图 5-7 所示。
Spark 使用属性spark.sql.autoBroadcastJoinThreshold
的值来确定哪个数据集适合广播,其默认值是 10 MB。如果 Spark 集群有足够的内存来处理更大数据集的广播,那么只需将阈值增加到适当的值。BHJ 如图 5-8 所示。
图 5-8
从驱动程序向执行器广播较小的数据集哈希连接策略
当您在两个数据集之间执行连接,并且确定其中一个数据集的大小小于上述属性的阈值时,您可以通过检查执行计划来验证 Spark 是否使用了 BHJ 策略。清单 5-6 展示了一个连接两个数据集的简单例子。
import org.apache.spark.sql.functions._
val small_df = Seq(("WA", "Washington"), ("CA", "California"),
("AZ", "Arizona"), ("AK", "ALASKA"))
.toDF("code", "name")
val large_df = spark.range(500000).toDF("id")
.withColumn("code", when(rand() < 0.2, "WA")
.when(rand() < 0.4, "CA")
.when(rand() < 0.6, "AZ")
.otherwise("AK"))
.withColumn("date",
date_add(current_date,
-(rand() * 360).cast("int")))
val joined_df = small_df.join(large_df, "code")
Listing 5-6Joining One Small Dataset with a Larger One
Spark UI 有一个很好的可视化方式来举例说明执行计划,如图 5-9 所示。只需导航到 SQL 选项卡,然后单击要执行的作业。这个例子显示了通过BroadcastExchange
操作符广播较小的数据集,然后使用BroadHashJoin
策略。
图 5-9
Spark UI 中的广播哈希连接策略
无序排序合并连接
Spark 的另一个常用连接策略是 SMJ,这是连接两个大型数据集的有效方法。SMJ 首先将两个数据集中具有相同键的行放到同一个执行器的同一个分区中。接下来,按照指定的连接键对这些行进行排序。最后,合并步骤遍历这些行,合并具有匹配键的行。排序-合并过程类似于合并排序算法中的过程。这是一种合并两个大型数据集的有效方法,无需像 SHJ 那样先将一个数据集加载到内存中。
清单 5-7 展示了一个模拟 SMJ 的小例子,首先通过将属性spark.sql.autoBroadcastJoinThreshold
的值设置为–1 来禁用 SHJ,然后连接两个合理大小的数据帧。
import org.apache.spark.sql.functions._
// disable the broadcast hash strategy for Spark
// to use the shuffle sort merge strategy
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")
val item_df = spark.range(3000000).toDF("item_id")
.withColumn("price",
(rand() * 1000).cast("int"))
val sales_df = spark.range(3000000).toDF("pId")
.withColumn("item_id",
when(rand() < 0.8, 100)
.otherwise(rand() *
30000000).cast("int"))
.withColumn("date",
date_add(current_date,
-(rand() * 360).cast("int")))
val item_sale_df = item_df.join(sales_df, "item_id")
item_sale_df.show()
+-------+-----+-------+----------+
|item_id|price| pId| date|
+-------+-----+-------+----------+
| 18295| 484|2607123|2020-09-27|
| 19979| 261|1121863|2020-07-05|
| 37282| 915|1680173|2020-10-04|
| 54349| 785| 452954|2021-05-28|
| 75190| 756| 142474|2021-02-19|
| 89806| 763|1842105|2020-06-26|
| 92357| 689|1451331|2021-03-07|
| 110753| 418|1803550|2020-11-25|
| 122965| 729| 917035|2020-06-22|
| 150285| 823|2306377|2020-10-05|
| 180163| 591| 330650|2020-07-13|
| 181800| 606|2065247|2020-11-06|
| 184659| 443| 982178|2020-09-01|
| 198667| 796|2985859|2021-04-02|
| 201833| 464| 709169|2020-07-31|
| 208354| 357| 927660|2021-05-30|
| 217616| 627| 174367|2021-04-25|
| 223396| 752|2850510|2020-11-05|
| 225653| 188|2439243|2021-01-16|
| 233633| 628|2811113|2020-12-02|
+-------+-----+-------+----------+
Listing 5-7Joining Two Large Datasets Using SMJ
运行代码后,您可以在 Spark UI 中导航到 SQL 选项卡来查看执行计划,如图 5-10 所示。请注意,排序步骤首先发生,接下来是合并步骤。
图 5-10
Spark UI 中的混洗合并连接策略
自适应查询执行
Spark 3.0 中引入的有用和创新特性之一是自适应查询执行(AQE)框架。它应该是开发和维护处理数百 TB 到数 Pb 的大型数据管道的 Spark 应用程序开发人员或每天使用 Spark 对大型数据集执行复杂的交互式分析的数据分析师最喜欢的功能。AQE 扩展了 Spark SQL 的查询优化器和规划器,使用关于行数、分区大小等的最新统计数据来动态调整和重新生成高质量和优化的查询执行计划,以自动解决大多数常见的性能问题,并加快 Spark 应用程序的完成时间或防止它们进入 OOM。
在 Spark 2.0 中,Spark SQL Catalyst optimizer 为生成查询执行计划提供了一个通用框架,并提供了一组基于规则的优化来提高性能。Spark 2.2 引入了基于成本的优化,通过利用生成的每列数据统计信息(如基数、最小/最大值和空值)来改进优化。
Spark 3.0 中引入了 AQE,以利用关于阶段间分区的运行时统计信息来调整和重新生成查询执行计划,从而提高一些最常见的性能场景的性能,如分区过多、数据不对称或使用错误的连接类型。
为了更好地理解 AQE 是如何工作的,让我们首先回顾一下关于 Spark 工作、阶段和任务的几个核心概念。当一个数据帧的动作类型 API 被调用时,比如count()
,或者collect(),
或者show()
,Spark 会启动一个任务,通过执行所有之前的转换来执行这个动作。
每个作业由一个或多个阶段组成,每当 Spark 遇到大范围转换时,就需要一个阶段,这涉及到从多个分区移动输入数据。宽变换的例子包括groupBy()
或orderBy()
。每个阶段都将其中间结果物化到磁盘上,只有完成所有分区的物化,才能进行下一个阶段。这代表了 AQE 通过利用分区的运行时统计数据(比如分区的总数和每个分区的大小)来调整其执行计划的自然位置。这就是为什么每个 AQE 特色名称的第一个单词都以单词开头的主要原因。需要注意的一点是,只有当 Spark 应用程序有一个或多个阶段时,AQE 才有用。
图 5-11 直观总结了本段所述的流程。
图 5-11
AQE 在行动
AQE 提供的性能优化非常容易使用。只需打开适当的属性,AQE 就会立即行动。启用 AQE 的顶级属性叫做spark.sql.adaptive.enabled
,所以在尝试下面的例子之前,一定要将它设置为 true。每个特性都有自己的属性集来控制特定特性的各个细粒度方面,这将在每一节中讨论。
AQE 框架提供了这三个极其有用的特性。
-
动态合并混洗分区
-
动态切换连接策略
-
动态优化偏斜连接
以下部分讨论了它们是什么以及它们是如何工作的。
动态合并混洗分区
Spark 中最昂贵的操作之一是洗牌,这意味着在网络上移动数据,以便根据后续操作的需要适当地重新分配数据。如果不进行相应的调整,该操作会显著影响查询性能。关于混排的主要调节旋钮是分区的数量。如果该数字太低或太高,查询性能会降低。一个常见的挑战是分区的最佳数量通常特定于手边的用例。由于数据量的增加或数据处理逻辑的任何变化,它可能需要频繁更新。
当分区数量太大时,由于在 Spark 集群中的许多节点上传输少量数据,会造成不必要的 I/O 使用效率低下。此外,需要许多任务来复制数据,这给 Spark 任务调度程序带来了不必要的工作。
当分区数量太少时,某些分区的数据量会很大。处理这些大分区的任务需要很长时间才能完成,并且可能需要将数据溢出到磁盘。因此,这会减慢整个查询的完成时间。
为了应对分区数量的两个极端,可以通过将分区数量设置得较大来开始查询。AQE 通过在洗牌结束时利用最新的统计数据,自动将小分区组合或合并成更大的分区,以克服低效率和性能相关问题。图 5-12 直观地描述了一个 AQE 未启用的例子,这些小分区是经过处理的独立异径管。图 5-13 直观地描述了启用 AQE 的示例。小分区(B、C、D)合并成一个分区,因此总的异径管数量较少。这些数字的灵感来自这份数据块日志(参见 https://databricks.com/blog/2020/05/29/adaptive-query-execution-speeding-up-spark-sql-at-runtime.html
)。
图 5-13
AQE 已启用
图 5-12
AQE 是残疾人
现在,让我们通过模拟一个场景来分析一个小型事务性事实表的计数,来看看动态合并混洗分区特性的实际应用,该事实表中大约 80%的商品是一个 ID 为 100 的流行商品。清单 5-8 生成 1 亿行,80%的 item _ ids 的值为 100。接下来,它对每个 item_id 计数,然后对不同的 item _ id 计数。
import org.apache.spark.sql.functions._
// make sure the AQE is enabled
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.get("spark.sql.adaptive.enabled")
val dataDF = spark.range(100000000).toDF("pId")
.withColumn("item_id",
when(rand() < 0.8, 100)
.otherwise(rand() * 3000000).cast("int"))
dataDF.groupBy("item_id").count().count()
Listing 5-8Performing the Analysis of Item Count from a Synthetic Small Transaction Fact Table
完成count()
动作后,进入 Spark UI。在第一个 Jobs 选项卡上,您会注意到三个作业中有两个被跳过,大部分工作是在 job 0 中完成的。图 5-14 显示了跳过作业的一个例子。
图 5-14
Spark UI 显示跳过的作业
让我们通过单击 Description 列下的超链接来检查 job 2。Spark UI 显示阶段 2 和阶段 3 被跳过,您会在阶段 2 的顶部看到一个名为CustomShuffleReader
operator 的操作符。AQE 框架引入了这个操作符来合并大小小于属性spark.sql.adaptive.advisoryPartitionSizeInBytes,
指定的大小的分区,该属性的默认值为 64 MB。图 5-15 显示了跳过阶段的 Spark UI。
图 5-15
Spark UI 显示跳过的作业和跳过的阶段
如果 AQE 被禁用,您将会看到一个非常不同的执行计划,该计划由一个单一作业(见图 5-16 )和三个阶段(见图 5-17 )组成。最值得注意的部分是第二阶段,根据属性spark.sql.shuffle.partitions
的默认值,将数据划分为 200 个分区,其中大多数分区只有少量数据,如图 5-18 所示。
图 5-18
Spark UI 显示 200 个任务
图 5-17
Spark UI 显示了三个阶段及其任务
图 5-16
Spark UI 显示了执行 GroupBy 操作符的单个作业
动态合并洗牌分区功能有一小组属性来控制其行为。表 5-6 描述了这些属性。它们的名字以相同的spark.sql.adaptive
前缀开始,因此为了简洁起见,省略了它。
表 5-6
动态合并随机分区属性
|财产
|
缺省值
|
描述
|
| --- | --- | --- |
|
|
|
|
动态切换连接策略
在 Spark 支持的不同连接类型中,性能最好的一种称为广播散列连接。过去,Spark 开发人员要么需要给 Spark 提示,要么通过使用broadcast
函数标记其中一个数据集来明确请求 Spark 使用广播散列连接。启用 AQE 后,当 Spark 检测到连接一端的大小低于广播大小阈值时,它会动态切换到广播散列连接,以提高查询性能。当其中一个联合数据集以较大的大小开始时,这非常有用,在过滤运算符完成后,其大小会显著减小。AQE 拥有各阶段之间所有分区的更新统计数据,因此它可以利用这些数据来确定在运行时切换 join 是否有意义。
控制是否应该使用广播散列连接的阈值是spark.sql.autoBroadcastJoinThreshold
属性,它的默认值是 10 MB。如果连接中某个数据集的数据大小小于该值,则 AQE 会切换到广播哈希连接。如果您的 Spark 集群有足够的内存在内存中存储广播数据集,那么将阈值增加到更高的值是有意义的。如果你使用数据块,那么这个属性就叫做spark.databricks.adaptive.autoBroadcastJoinThreshold.
现在,让我们通过模拟一个场景来看看动态切换连接策略的功能,在该场景中,连接两个数据集后,聚合逻辑具有一个过滤条件,使得一个数据集的大小低于自动广播连接阈值。AQE 可以在运行时检测到这一点,它将排序-合并连接切换到广播散列连接,以加快查询速度。初始静态计划不知道过滤器的选择性。
清单 5-9 设置了两个数据帧——汽车注册和汽车销售。在其中一个列被联接后,将对其应用筛选器。在最初的执行计划中,筛选条件是未知的。
spark.conf.set("spark.sql.adaptive.enabled", true)
import org.apache.spark.sql.functions._
import scala.util.Random
// setting up functions and UDFs
def getCarByIdx(idx:Int) : String = {
val validIdx = idx % popularCars.size
val finalIdx = if (validIdx == 0) 1 else validIdx
popularCars(finalIdx)
}
def randomCar(idx:Int): String = {
val randomCarIdx = if (Random.nextFloat() < 0.4) 0 else
Random.nextInt(popularCars.size)
popularCars(randomCarIdx)
}
val getCarByIdxUDF = udf(getCarByIdx(_:Int):String)
val randomCarUDF = udf(randomCar(_:Int):String)
// setting the data frames
val car_registration_df = spark.range(5000000).toDF("id")
.withColumn("make", when('id > 1,
getCarByIdxUDF('id)).otherwise("FORD:F-Series"))
.withColumn("sale_price",
(rand() * 100000).cast("int"))
val car_sales_df = spark.range(30000000).toDF("id")
.withColumn("make",randomCarUDF(lit(5)))
.withColumn("date",
date_add(current_date,
-(rand() * 360).cast("int")))
car_sales_df.join(car_registration_df, "make")
.filter('sale_price < 300)
.groupBy("date").agg(
sum("sale_price").as("total_sales"),
count("make").as("count_make"))
.orderBy('total_sales.desc)
.select('date, 'total_sales).show()
Listing 5-9Apply a Filter Condition after Joining Car Registrations and Car Sales DataFrames
动态优化偏斜连接
在 Spark 这样的并行数据计算系统中连接两个数据集时,偏斜连接是最烦人的性能问题之一。其中一个关联数据集中的数据倾斜导致分区数据大小不平衡。因此,连接需要很长时间才能完成。现实生活数据集中的数据倾斜是一种自然现象,由于受欢迎程度、人口集中度或消费者行为的影响,发生的频率比您想象的要高。例如,西海岸和东海岸城市的人口往往比美国其他城市多。在过去的几年中,Spark 应用程序开发人员提出了许多创新的想法来克服偏斜连接性能问题,这需要额外的努力。此功能只会加快倾斜连接的速度。它只需要 Spark 开发人员很少的努力。毫无疑问,这个特性让很多 Spark 开发者喜笑颜开。
Note
从概率论和统计学的研究领域来看,偏度是对分布对称性的一种度量。在数据处理领域,数据倾斜表明分布不均匀或不对称,有些列值比其他列值多很多行,而有些列值很少。
为了执行连接,Spark 根据连接键的散列为连接数据集的每一行分配一个分区 id,然后将那些具有相同分区 id 的行混排到同一个分区。如果其中一个数据集有数据倾斜,那么一个或多个分区比其他分区有更多的行,如图 5-19 所示。与处理较小分区的其他任务相比,分配给处理较大分区的任务需要更长的时间才能完成。
图 5-19
倾斜分区
启用 AQE 时,大分区被分割成几个较小的分区,由多个任务并行处理,以加快整个连接的完成时间。使用图 5-9 中的例子,分区 A,倾斜的分区,被分成两个更小的分区。分区 D 被复制两次,如图 5-20 所示。因此,有四个任务正在运行该连接,每个任务花费的时间大致相同;因此,整体完成时间较短。
图 5-20
不对称分区被拆分,相应的分区被复制
现在,让我们通过模拟两个关联数据集之一具有不对称数据的场景,来看看动态优化不对称连接功能的实际效果,并看看 AQE 将连接速度提高了多少。清单 5-10 改编自 https://coxautomotivedatasolutions.github.io/datadriven/spark/data%20skew/joins/data_skew/
的博客中的一个例子。
清单 5-10 通过设置两个数据集,汽车注册和汽车销售,演示了倾斜连接优化。第二个问题中存在数据偏差;福特 F 系列通过调用randomCarUDF
函数.
实现了 40%的销售
import org.apache.spark.sql.functions._
// make sure to turn on AQE
spark.conf.set("spark.sql.adaptive.enabled", true)
// since the data size is small, avoid broadcast hash join,
// reduce the skew partition factor and threshold
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
spark.conf.set("spark.sql.adaptive.skewJoin.
skewedPartitionFactor", "1")
spark.conf.set("spark.sql.adaptive.skewJoin.
skewedPartitionThresholdInBytes", "2mb")
val popularCars = SeqString
// setting up functions and UDFs
def getCarByIdx(idx:Int) : String = {
val validIdx = idx % popularCars.size
val finalIdx = if (validIdx == 0) 1 else validIdx
popularCars(finalIdx)
}
def randomCar(idx:Int): String = {
val randomCarIdx = if (Random.nextFloat() < 0.4) 0 else
Random.nextInt(popularCars.size)
popularCars(randomCarIdx)
}
val getCarByIdxUDF = udf(getCarByIdx(_:Int):String)
val randomCarUDF = udf(randomCar(_:Int):String)
// create the two data frames to join
val car_registration_df = spark.range(500000).toDF("id")
.withColumn("registration",
lit(Random.alphanumeric.take(7).mkString("")))
.withColumn("make",
when('id > getCarByIdxUDF('id))
.otherwise("FORD:F-Series"))
.withColumn("engine_size",
(rand() * 10).cast("int"))
val car_sales_df = spark.range(30000000).toDF("id")
.withColumn("make",randomCarUDF(lit(5)))
.withColumn("engine_size",
(rand() * 11).cast("int"))
.withColumn("sale_price",(
rand() * 100000).cast("int"))
.withColumn("date",
date_add(current_date,
- rand() * 360).cast("int")))
// perform the join by make
car_registration_df.join(car_sales_df, "make")
.groupBy("date").agg(
sum("sale_price").as("total_sales"),
count("make").as("count_make"))
.orderBy('total_sales.desc)
.select('date, 'total_sales).show()
Listing 5-10Joining Car Registrations and Car Sales DataFrames (Cars Sales Has Skew Data)
从分区统计数据中,AQE 看到了汽车销售数据帧中的不对称数据,并将大分区分割成三个较小的分区。图 5-21 显示了执行计划中分割的细节。
图 5-21
一个倾斜分区分成三个分区
一旦倾斜分区被分割成更小的分区,中值和最大持续时间都是 41 秒。图 5-22 显示任务完成时间。
图 5-22
任务持续时间平均为 41 秒
这个特性也有一小组属性来控制它的行为。表 5-7 描述了这些特性。它们的名字以相同的spark.sql.adaptive.skewJoin
前缀开始,因此为了简洁起见,省略了它。
表 5-7
动态优化倾斜连接属性
|财产
|
缺省值
|
描述
|
| --- | --- | --- |
| <prefix>.enabled
| 真实的 | 细粒度控制,支持优化倾斜连接。 |
| <prefix>.skewedPartitionFactor
| five | 乘以分区大小中值的因子,用于确定分区是否被认为是倾斜的。 |
| <prefix>.skewedPartitionThresholdInBytes
| 256 兆字节 | 合并分区的最小大小。大小不小于该值。 |
当满足以下两个条件时,分区被认为是倾斜的。
-
skewedPartitionFactor
分区大小> *分区大小中位数 -
分区大小>
skewedPartitionThresholdInBytes
摘要
Spark 应用程序调优和优化是一个广泛的话题。本章介绍了常见的与性能相关的挑战,包括内存问题和长时间运行的查询性能。优化 Spark 应用程序的能力需要对一些调谐旋钮、Spark 如何管理其内存以及利用 AQE 的一些新功能有广泛的理解。
-
Spark 中的调谐旋钮是属性,有三种不同的设置方式。第一种方法是通过配置文件。第二种方法是在启动 Spark 应用程序时在命令行上指定它们。最后一种方法是在 Spark 应用程序中以编程方式设置它们,这具有最高的优先级。Spark UI 提供了一种查看配置属性的简单方法,这些属性组织在 Environment 选项卡下。
-
记忆是另一种重要的资源。Spark 驱动程序使用其分配的内存来存储广播变量和从所有 Spark 执行器收集的数据,这可以灵活地管理其分配的内存来处理不同的工作负载。
-
Spark 的独特功能之一是执行内存计算的能力,这可以将迭代和交互式用例的速度提高 10 倍。如果一个数据集在 Spark 应用程序中被多次使用,那么将它的数据保存在内存或磁盘上是一个很好的选择。
-
Spark 3.0 中引入的自适应查询执行框架可以在运行时执行三种不同的优化,利用阶段间物化分区的最新统计数据来提高查询性能。第一个是关于合并混洗分区,以提高 I/O 效率并减轻 Spark 调度程序的负担。第二个是关于当其中一个数据集的大小低于广播大小阈值时,将连接从排序合并连接动态地切换到广播散列连接。最后一个是关于优化倾斜连接,通过检测倾斜分区并将其分割成多个更小的分区来提高查询性能。
六、Spark 流
除了批量数据处理,流处理已经成为任何企业利用实时数据的价值来增加竞争优势、做出更好的业务决策或改善用户体验的必备能力。随着物联网的出现,实时数据的数量和速度都在增加。对于像脸书、LinkedIn 或 Twitter 这样的互联网公司来说,他们平台上每秒发生的数百万次社交活动都被表示为流媒体数据。
在高层次上,流处理是指对无限数据流的连续处理。以容错和一致的方式大规模做到这一点是一项具有挑战性的任务。幸运的是,像 Spark、Flink、Samza、Heron 和 Kafka 这样的流处理引擎在过去几年中已经稳定而显著地成熟,使企业能够比以前更容易地构建和操作复杂的流处理应用程序。
随着社区了解如何最好地将日益成熟的流媒体引擎应用于其业务需求,更多有趣的实时数据处理用例应运而生。例如,优步利用流处理能力实时了解其平台上的乘客和司机数量。这些近乎实时的洞察会影响商业决策,比如将多余的司机从一个城市的低需求区域转移到高需求区域。
大多数互联网公司在发布新功能或尝试新设计时,都会利用实验系统来执行 A/B 测试。流处理通过将理解实验有效性所需的时间从几天减少到几个小时,实现了对实验更快的反应。
欺诈检测是一个张开双臂拥抱流处理的领域,因为它从即时洞察欺诈活动中获得了好处,因此可以阻止或监控欺诈活动。对于拥有数百项在线服务的大型公司来说,一个常见的需求是通过流处理以接近实时的方式处理大量生成的日志来监控其运行状况。还有许多更有趣的实时数据处理用例,其中一些将在本章中分享。
本章首先描述有用的流处理概念,然后简要介绍流处理引擎的前景。然后本章的其余部分将详细描述 Spark 流处理引擎及其 API。
流处理
在大数据领域,自从 Hadoop 问世以来,批量数据处理就广为人知。流行的 MapReduce 框架是 Hadoop 生态系统中的组件之一,由于其功能和健壮性,它成为了批量数据处理之王。在批量数据处理领域经过一段时间的创新之后,现在已经很好地理解了这个领域的大多数挑战。从那时起,大数据开源社区已经将其重点和创新转移到了流处理领域。
批量数据处理通过固定大小的静态输入数据集应用计算逻辑,并最终生成结果。这意味着处理在到达数据集的末尾时停止。相比之下,流处理是通过无界的数据流运行计算逻辑,因此处理是连续的和长期运行的。尽管批数据和流数据之间的差异主要在于有限性,但是流处理比批数据处理更复杂和更具挑战性,因为数据的无界性质、实时数据的传入顺序、数据到达的不同速率以及面对机器故障时对正确性和低延迟的期望。
在批量数据处理领域,经常听说由于输入数据集的大小而需要花费数小时来完成复杂的批量数据处理作业。
人们期望流处理引擎必须通过尽可能高效地向应用交付传入的数据流来提供低延迟和高吞吐量,以便它们能够快速做出反应或提取洞察力。执行任何有趣和有意义的流处理通常涉及以容错方式维护状态。例如,股票交易流应用程序想要维护和显示全天交易最活跃的前 10 或 20 只股票。为了实现这个目标,每个股票的运行计数必须由代表应用程序的流处理引擎或者由应用程序本身来维护。通常,状态保存在内存中,并由磁盘等弹性存储支持,这种存储对机器故障具有弹性。
流处理不能在竖井中工作。有时需要与批处理数据一起工作,以丰富传入的流数据。一个很好的例子是当页面视图流应用程序需要基于用户位置计算其用户的页面视图统计时;然后,它需要将用户点击数据与会员数据连接起来。一个好的流处理引擎应该提供一种简单的方法来连接批量数据和流数据,而不需要太多的努力。
流处理的一个常见用例是对传入数据执行一些聚合,然后将汇总的数据写入外部数据接收器,供 web 应用程序或数据分析引擎使用。这里的期望是,无论是由于机器故障还是数据处理应用程序中的一些错误,在面对故障时,具有端到端的恰好一次的数据保证。这里的关键是流处理引擎如何处理故障,使得传入的数据不会丢失和重复计算。
随着流处理引擎的成熟,它们提供了快速、可伸缩和容错的分布式系统属性,以及开发人员友好的方式,通过将低级 API 的抽象提升到 SQL 这样的高级声明性语言来执行数据流计算。凭借这一进步,构建自助服务流平台变得更加容易,产品团队可以通过利用各种公司产品生成的数据或事件,快速做出有意义的业务决策。请记住,数据流处理的目标之一是及时提取业务洞察,以便企业能够快速做出反应或采取业务行动。
总的来说,流处理有它自己的一套独特的挑战,这些挑战是由于处理连续的和无限的数据而产生的。当您开始构建长期运行的流处理应用程序或评估特定的流处理引擎时,注意这些挑战是很重要的。挑战如下。
-
为数据流应用程序可靠地维护潜在的大状态
-
高效快速地传递消息供应用程序处理
-
处理无序到达的流数据
-
加入批量数据以丰富传入的流数据
-
端到端恰好一次保证数据传送,即使出现故障
-
处理不均匀的数据到达率
概念
要执行流处理,必须理解以下核心和有用的概念。这些重要的概念非常适用于在任何流处理引擎上开发流应用程序。了解它们有助于评估流处理引擎;它们还使您能够提出正确的问题,以了解特定的流处理引擎在每个领域提供了多少支持。
-
数据交付语义
-
时间的概念
-
开窗术
数据交付语义
当一条数据进入流处理引擎时,它负责将其交付给流应用程序进行处理。即使在故障情况下,流处理引擎也可以提供三种类型的保证。
-
最多一次:这意味着一个流处理引擎保证一个数据只被传递给一个应用程序一次,但也可能是零次。换句话说,有可能丢失一部分数据,因此应用程序根本看不到它。对于一些用例来说,这是可以接受的,但是对于其他一些用例来说,这是不可以接受的。其中一个用例是金融交易处理应用程序。丢失数据会导致不向客户收费,从而减少收入。
-
至少一次:这意味着流处理引擎保证一段数据被一次或多次传递给应用程序。在这种情况下没有数据丢失;然而,有可能出现双重或三重计算。在金融交易处理应用程序的例子中,一个交易被多次应用,导致客户投诉。这种保证比最多一次强,因为没有数据丢失。
-
恰好一次:这意味着一个流处理引擎保证一段数据只被传递给一个应用程序一次,不多也不少。在这种情况下,没有数据丢失,也没有重复计算。大多数现代和流行的流处理引擎都提供了这种保证。在这三个保证中,这一个是构建关键业务流应用程序最理想的。
看待这些交付语义的一种方式是,它们属于一个范围,其中最多一次是最弱的保证,恰好一次是最强的保证,如图 6-1 所示。
图 6-1
交付语义谱
在评估流处理引擎时,了解它提供的保证级别以及这种保证背后的实现非常重要。大多数现代的流处理引擎采用检查点和预写日志技术的组合来提供恰好一次的保证。
时间的概念
在流处理的世界中,时间的概念非常重要,因为它使您能够理解在时间方面发生了什么。例如,在实时异常检测应用程序的情况下,时间的概念提供了对在最后 5 分钟或一天的某个部分发生的可疑交易的数量的洞察。
有两种重要的时间类型:事件时间和处理时间。如图 6-2 所示,事件时间代表创建数据的时间,该信息通常编码在数据中。例如,在世界上某个地方测量海洋温度的物联网设备中,事件时间是测量温度的时间。温度数据的编码可以由温度本身和时间戳组成。处理时间表示流处理引擎处理一段数据的时间。在海洋温度物联网设备的示例中,处理时间是流处理引擎开始对温度数据执行转换或聚合时的时钟时间。
图 6-2
事件时间和处理
要真正了解传入数据流背后的情况,必须按照偶数时间来处理传入数据,因为事件时间代表数据创建的时间点。在理想状态下,数据在创建后不久就到达并被处理,因此事件时间和处理时间之间的间隔很短。事实往往并非如此,因此,根据阻止数据在创建后立即到达的条件,延迟会随着时间而变化。滞后越大,就越需要使用事件时间而不是处理时间来处理数据。
图 6-3 展示了事件时间和处理时间之间的关系,以及一个真实延迟的例子。时间的概念与窗口的概念密切相关,这将在下面描述。为了处理无限制的传入数据流,流处理引擎中的一种常见做法是通过使用开始和结束时间作为边界,将传入数据分成块。使用事件时间作为时间边界更有意义。
图 6-3
事件时间和处理时间之间的延迟
开窗术
考虑到流数据的无界特性,拥有输入流数据的全局视图是不可行的。因此,要从传入的数据中提取任何有意义的值,您必须分块处理它们。例如,假设交通计数传感器每 20 秒发出一次汽车数量的计数,计算最终总和是不可行的。相反,问每分钟或每五分钟有多少辆车经过那个传感器更符合逻辑。在这种情况下,您需要将流量计数数据分别划分为 1 分钟或 5 分钟的数据块。每个块被称为一个窗口。
窗口是一种常见的流处理模式,在这种模式下,不受限制的传入数据流基于时间边界(事件时间或处理时间)被划分为多个块。虽然前者更常用于反映数据的实际情况。但是,考虑到数据可能不会按照创建的顺序到达,或者由于网络拥塞而延迟,因此不可能总是在该时间窗口内创建所有数据。
有三种常用的窗口模式,大多数现代流处理引擎都支持它们。这三种模式如图 6-4 所示。
图 6-4
三种常用的窗口模式
固定/滚动窗口将传入的数据流分成固定大小的数据段,每个数据段都有一个窗口长度、开始时间和结束时间。每个到来的数据片段被插入一个且仅一个固定/翻转窗口。有了每个窗口中的这一小批数据,在执行 sum、max 或 average 等聚合时就很容易推理了。
滑动窗口是将传入数据流分成固定大小的段的另一种方式,其中每个段都有一个窗口长度和滑动间隔。如果滑动间隔与窗口长度大小相同,则它与固定/滚动窗口相同。图 6-4 中的例子显示滑动间隔小于窗口长度。这意味着一条或多条数据包含在不止一个滑动窗口中。由于窗口的重叠,聚集产生比固定/滚动窗口更平滑的结果。
会话窗口类型通常用于分析网站上的用户行为。与固定/翻转和滑动窗口不同,它没有预先确定的窗口长度。相反,它通常由大于某个阈值的不活动间隙来确定。例如,脸书上会话窗口的长度由用户活动的持续时间决定,比如浏览用户提要、发送消息等等。
流处理引擎前景
开源社区在提出流处理解决方案方面不乏创新。事实上,有多种选择来选择流处理引擎。早期的一些流处理引擎是出于需要而诞生的,后来的一些是出于研究项目而诞生的,还有一些是由批处理引擎演变而来的。本节介绍一些流行的流处理引擎:Apache Storm、Apache Samza、Apache Flink、Apache Kafka Streams、Apache Apex 和 Apache Beam。
Apache Storm 是流处理的先驱之一,它的流行主要与 Twitter 所做的大规模流处理有关。Apache Storm 最初发布是在 2011 年,2014 年成为 Apache 顶级项目。2016 年,Twitter 放弃了阿帕奇 Storm,转而使用 Heron,这是阿帕奇 Storm 的下一代。Heron 比 Apache Storm 更节省资源并提供更好的吞吐量。
Apache Samza 诞生于 LinkedIn,以帮助解决其流处理需求,并于 2013 年开源。它旨在与 Kafka 紧密合作,并运行在 Hadoop YARN 之上,以实现进程隔离、安全性和容错。Apache Samza 设计用于处理流,流由有序的、分区的、可重放的和容错的不可变消息集组成。
Apache Flink 最初是一个名为“同温层:云上的信息管理”的研究项目的分支。它在 2015 年成为了 Apache 的顶级项目,从那以后,它作为一个高吞吐量和低延迟的流媒体引擎逐渐受到欢迎。Apache Flink 与 Apache Storm 和 Apache Samza 之间的一个关键区别是,它在同一个引擎中支持批处理和流处理。
Apache Kafka 已经从一个分布式发布-订阅消息传递系统发展成为一个分布式流平台。它创建于 LinkedIn,并于 2012 年成为 Apache 的顶级项目。与其他流处理引擎不同,Kafka 的流处理功能被打包为一个轻量级库,这使得编写实时流应用程序非常容易。
Apache Apex 相对来说是这个领域的新手。它是由一家名为 DataTorrent 的公司开发的,他们决定在 2016 年开源它。Apache Apex 被认为是统一了流和批处理的 Hadoop YARN 原生平台。
Apache Beam 是 2016 年从 Google 出来的一个相当有趣的项目。这个项目背后的主要思想是为流和批处理提供一个强大且易于使用的通用抽象层,可跨各种运行时平台(如 Apache Flink、Apache Spark、Google Cloud DataFlow)移植。换句话说,可以把 Apache Beam 看作是大数据处理的超级 API。
有两个标准的流处理模型,每个流处理引擎(Apache Beam 除外)都订阅了其中的一个。这两种模式分别称为一次记录和微量配料,如图 6-5 所示。
图 6-5
两种不同的流处理模型
这两种模式都有固有的优点和缺点。一次记录的模式确实如其名。它会在每一条传入的数据到达时立即进行处理。因此,该模型可以提供毫秒级的低延迟。微批处理模型根据可配置的批处理时间间隔等待并累积一小批输入数据,并并行处理每批数据。微批处理模型无法提供与其他模型相同的延迟水平。就吞吐量而言,微批处理具有更高的速率,因为一批数据是以优化的方式处理的,因此与其他模型相比,每份数据的成本较低。一个有趣的附带说明是,在时间记录模型的基础上构建微批处理模型相当容易。
在所有讨论的流处理引擎中,只有 Apache Spark 采用了微批处理模型;然而,一些支持一次记录模式的工作已经在进行中。
Spark 流概述
Apache Spark 的统一数据处理平台受欢迎的原因之一是能够执行流处理和批量数据处理。
在高层次描述了流处理的复杂性和挑战以及一些核心概念之后,本章的剩余部分将重点讨论 Spark 流主题。首先,它提供了对 Spark 的第一代流处理引擎 DStream 的简短和高层次的理解。然后剩余章节的大部分提供了关于 Spark 的第二个流处理引擎结构化流的信息。
新的 Spark 流应用程序应该在结构化流的基础上开发,以利用它提供的一些独特的高级功能。
司库流
第一代 Spark stream 处理引擎于 2012 年推出,该引擎中的主要编程抽象称为离散化流、或 d stream。它的工作原理是使用微批处理模型将传入的数据流分成几批,然后由 Spark 批处理引擎进行处理。这在 RDD 是主要编程抽象模型的时候很有意义。每个批次在内部由一个 RDD 代表。一批数据的数量是输入数据速率和批间隔的函数。图 6-6 直观地描述了 DStream 的高级工作方式。
图 6-6
司库流
可以从 Kafka、AWS Kinesis、文件或套接字的输入数据流创建数据流。创建数据流时需要的关键信息之一是批处理间隔,它可以以秒或毫秒为单位。使用数据流,您可以对输入的数据流应用高级数据处理功能,如map
、filter
、reduce
或reduceByKey
。此外,您可以通过提供窗口长度和滑动间隔来执行窗口操作,如减少和计数固定/翻转或滑动窗口。一个重要的注意事项是,窗口长度和滑动间隔必须是批处理间隔的倍数。例如,如果批处理间隔是三秒,并且使用固定/滚动间隔,则窗口长度和滑动间隔可以是六秒。虽然 DStream 支持在执行批量数据计算时保持任意状态,但这是一个手动过程,有点麻烦。用一个数据流可以做的一件很酷的事情是将它与另一个数据流或表示静态数据的 RDD 连接起来。完成所有处理逻辑后,可以使用 DStream 将数据写出到外部系统,如数据库、文件系统或 HDFS。
新的 Spark 流应用程序应该在第二代 Spark 流处理引擎上开发,称为结构化流,这将在下一节中介绍。在本节的剩余部分,您将看到一个字数很少的 Spark DStream 应用程序;目标是理解典型的 Spark DStream 应用程序是什么样子的。清单 6-1 包含字数统计应用程序的代码,这是来自 Apache Spark 源代码 GitHub 库的一个例子(参见 https://bit.ly/2G8N30G
)。
object NetworkWordCount {
def main(args: Array[String]) {
// Create the context with a 1 second batch size
val sparkConf = new SparkConf().setAppName("NetworkWordCount")
val ssc = new StreamingContext(sparkConf, Seconds(1))
val host = "localhost"
val port = 9999
val lines = ssc.socketTextStream(host, port, StorageLevel.MEMORY_AND_DISK_SER)
val words = lines.flatMap(_.split(" "))
val wordCounts = words.map(x => (x, 1)).reduceByKey(_ + _)
wordCounts.print()
ssc.start()
ssc.awaitTermination()
}
}
Listing 6-1Apache Spark DStream Word Count application
组装 DStream 应用程序有几个重要步骤。数据流应用程序的入口点是 StreamingContext。其中一个必需的输入是批处理间隔,它定义了 Spark 将一组输入数据批处理到 RDD 进行处理的持续时间。它还代表了 Spark 何时应该执行流应用计算逻辑的触发点。例如,如果批处理时间间隔是 3 秒,Spark 将对在 3 秒间隔内到达的所有数据进行批处理。间隔过后,它会将该批数据转换为 RDD,并根据您提供的处理逻辑进行处理。一旦创建了 StreamingContext,下一步将通过定义输入源来创建实例 DStream。该示例将输入源定义为读取文本行的套接字。在这一点上,然后你为新创建的数据流提供处理逻辑。前面示例中的处理逻辑并不复杂。一旦 1 秒钟后一组行的 RDD 可用,Spark 就执行将每一行拆分成单词的逻辑,将每个单词转换成该单词的元组和计数 1,最后对同一单词的所有计数求和。
最后,计数会在控制台上打印出来。请记住,流式应用程序是一个长期运行的应用程序;因此,它需要一个信号来开始接收和处理输入的数据流。该信号是通过调用 StreamingContext start()
函数给出的,这通常在文件末尾完成。awaitTermination()
函数等待流媒体应用程序停止执行,并等待一种机制来防止驱动程序在流媒体应用程序运行时退出。在一个典型的程序中,一旦最后一行代码被执行,它就退出。然而,一个长时间运行的流媒体应用程序需要在启动后保持运行,并且只有在您显式停止它时才会结束。
像大多数第一代流处理引擎一样,DStream 也有一些缺点。
-
对事件时间的本机支持:对于大多数流处理应用程序来说,基于事件时间提取洞察力或聚合是极其重要的。不幸的是,DStream 没有为这种需求提供本地支持。
-
批处理和流处理的独立 API:Spark 开发人员需要学习不同的 API 来构建批处理和流处理应用程序。这不是 DStream 的错,因为在 DStream 发明的时候,结构化 API 还不可用。
Spark 结构化流
结构化流媒体是 Spark 的第二代流媒体引擎。它被设计得更快、更可伸缩、更容错,并解决了第一代流媒体引擎的缺点。它旨在让开发人员构建端到端的流应用,使用简单的编程模型对数据做出实时反应,该模型构建在 Spark SQL 引擎的优化和坚实基础之上。结构化流的一个显著特点是,它为 Spark 用户和开发人员提供了一种独特而简单的方式来构建流应用程序。
构建生产级流媒体应用需要克服许多挑战,考虑到这一点,结构化流媒体引擎旨在帮助应对这些挑战。
-
处理端到端可靠性并保证正确性
-
能够对各种输入数据执行复杂的转换
-
基于事件时间处理数据,轻松处理无序数据
-
与各种数据源和数据接收器集成
以下部分涵盖了结构化流媒体引擎的各个方面及其应对这些挑战的支持。
概观
结构化流有两个关键的想法。第一种方法以批处理计算的方式处理流计算。这意味着将传入的数据流视为输入表,当一组新的数据到达时,将其视为附加到输入表的一组新的行(见图 6-7 )。
图 6-7
将流数据视为不断更新的表
另一种思考传入数据流的方式是,只不过是一个不断追加的表。这个简单而激进的想法有很多含义。其中之一是利用 Scala、Java 或 Python 中现有的用于数据帧和数据集的结构化 API 来执行流计算。当新的流数据到达时,结构化流引擎负责增量地和连续地运行它们。图 6-8 提供了在 Spark 中执行批处理和流处理的直观比较。另一个含义是,在第五章中讨论的相同 Catalyst 引擎优化了通过结构化 API 表达的流计算逻辑。您从使用结构化 API 中获得的知识可以直接转移到构建在 Spark 结构化流引擎上运行的流应用程序中。剩下唯一需要学习的部分是特定于流处理领域的部分,比如事件时处理和维护状态。
图 6-8
Spark 中批处理和流处理的比较
第二个关键思想是与存储系统的事务集成,以提供端到端的一次性保证。这里的目标是确保从存储系统读取数据的服务应用程序看到已由流式应用程序处理的数据的一致快照。传统上,在将数据从流式应用程序发送到外部存储系统时,确保没有重复数据或数据丢失是开发人员的责任。这是流式应用程序开发人员提出的难题之一。在内部,结构化流引擎已经提供了“恰好一次”的保证,现在这种保证扩展到了外部存储系统,前提是这些系统支持事务。
从 Apache Spark 2.3 开始,结构化流媒体引擎的处理模型已经扩展到支持一个名为连续处理的新模型。之前的处理模型是微批处理模型,这是默认的模型。鉴于微批处理模型的性质,它适合于可以容忍 100 毫秒范围内的端到端延迟的用例。对于其他需要低至 1 毫秒的端到端延迟的用例,他们应该使用连续处理模型;但是,从 Apache Spark 2.3 版本开始,它就处于实验状态。它在支持什么样的流计算方面有一些限制。
核心概念
本节涵盖了在构建流应用程序之前需要理解的一组核心概念。流式应用程序的主要部分包括:指定一个或多个流式数据源,以数据帧转换的形式提供操作传入数据流的逻辑,定义输出模式和触发器,以及最终指定将结果写入的数据接收器。因为输出模式和触发器都有默认值,如果它们的默认值符合您的用例,它们就是可选的。图 6-9 概述了这些步骤。可选的标有星号。
图 6-9
结构化流应用程序的核心部分
下面几节将详细描述这些概念。
数据源
先说数据来源。对于批处理,数据源是一个静态数据集,驻留在本地文件系统、HDFS 或 S3 等存储系统上。结构化流中的数据源非常不同。它们持续生成数据,并且速率会随着时间而变化。结构化流为以下来源提供本机支持。
-
Kafka 源码:需要 0.10 以上版本的 Apache Kafka。这是生产环境中最流行的数据源。使用这个数据源需要对 Kafka 的工作原理有一个基本的了解。连接到 Kafka 主题并从中读取数据需要一组必须提供的特定设置。更多信息请参考 Spark 网站上的 Kafka 集成指南(
https://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html
)。 -
文件源:位于 HDFS 或 S3 本地文件系统的文件。当新文件被放入一个目录中时,这个数据源会拾取它们进行处理。支持常用的文件格式,如 text、CSV、JSON、ORC 和 Parquet。有关支持的文件格式的最新列表,请参见 DataStreamReader 接口。使用此数据源时,一个好的做法是完全写入输入文件,然后将它们移动到此数据源的路径中。
-
插座源:这仅用于测试目的。它从监听某个主机和端口的套接字读取 UTF8 数据。
-
费率来源:仅用于测试和基准测试。这个源可以配置为每秒生成几个事件,其中每个事件由一个时间戳和一个单调递增的值组成。这是学习结构化流时最容易使用的资源。
数据源需要为结构化流提供的一个重要属性是标记,以提供端到端的一次性保证。当需要重新处理时,它可以倒回该位置。例如,Kafka 数据源提供了一个 Kafka 偏移量来跟踪主题的分区的读取位置。此属性确定特定数据源是否具有容错能力。表 6-1 描述了每个现成数据源的一些选项。
表 6-1
现成的数据源
|名字
|
容错的
|
配置
|
| --- | --- | --- |
| 文件 | 是 | path
:输入目录的路径maxFilesPerTrigger
:每个触发器要读取的新文件的最大数量latestFirst
:是否处理最新的文件(根据修改时间)。 |
| 窝 | 不 | 以下是必需的host
:要连接的主机port
:要连接的端口 |
| 速度 | 是 | rowsPerSecond
:每秒生成的行数rampUpTime
:到达rowsPerSecond
之前的上升时间(秒)numPartitions
:分区数量 |
| 卡夫卡 | 是 | kafka.bootstrap.servers
:逗号分隔的卡夫卡经纪人host:port
列表subscribe
:逗号分隔的主题列表更多信息请参考 Spark 网站上的 Kafka 集成指南。 |
Apache Spark 2.3 引入了数据源 V2 API,这是一组官方支持的接口,用于 Spark 开发人员开发可以轻松与结构化流集成的自定义数据源。有了这些定义良好的 API,定制的结构化流媒体源的数量会显著增加。
输出模式
输出模式是告诉结构化流应该如何将输出数据写入接收器的一种方式。这个概念是 Spark 中流处理所独有的。有三种选择。
-
追加模式:如果没有指定输出模式,这是默认模式。在这种模式下,只有追加到结果表中的新行被发送到指定的输出接收器。
-
完成模式:将整个结果表写入输出接收器。
-
更新模式:只有结果表中更新的行被写入输出接收器。这意味着未更改的行不会被写出。
各种输出模式的语义需要一些时间来适应,因为它们有几个维度。有了这三种选择,很自然地会想,在什么情况下你会使用一种输出模式而不是其他模式。希望,当你浏览一些例子时,它会更有意义。
触发器类型
触发器是另一个需要理解的重要概念。结构化流引擎使用触发信息来确定何时对新发现的流数据执行所提供的流计算逻辑。表 6-2 描述了不同的触发类型。
表 6-2
触发器类型
|类型
|
描述
|
| --- | --- |
| 未规定的(默认) | 对于这种默认类型,Spark 使用微批处理模式,并在前一批数据完成处理后立即处理下一批数据。 |
| 固定间隔 | 对于这种类型,Spark 使用微批量模式,并根据用户提供的时间间隔处理批量数据。如果前一批数据的处理时间长于间隔时间,则前一批数据完成后会立即处理下一批数据。换句话说,Spark 不会等到下一个区间边界。 |
| 一次性的 | 这种触发类型旨在用于一次性处理一批可用数据,一旦处理完成,Spark 会立即停止流应用程序。当数据量极低时,这种触发类型非常有用,因此,一天只处理几次数据会更加经济高效。 |
| 连续的 | Spark 使用新的低延迟和连续处理模式执行您的流应用程序逻辑。 |
数据接收器
数据接收器位于数据源的另一端。它们用于存储流应用程序的输出。了解哪些接收器可以支持哪种输出模式以及它们是否具有容错能力非常重要。此处提供了每个水槽的简短描述,表 6-3 中概述了每个水槽的各种选项。
表 6-3
现成的数据接收器
|名字
|
支持
输出模式
|
故障
容忍的
|
配置
|
| --- | --- | --- | --- |
| 文件 | 附加 | 是 | 路径:输入目录的路径支持所有流行的文件格式。有关更多信息,请参见 DataFrameWriter。 |
| 为每一个 | 追加,更新,完成 | 依赖 | 这是一个非常灵活的接收器,并且是特定于实现的。详见以下内容。 |
| 安慰 | 追加,更新,完成 | 不 | numRows:每个触发器要打印的行数。默认值为 20 行 truncate:如果每行都太长,是否截断。默认值为 true。 |
| 记忆 | 追加,完成 | 不 | 不适用的 |
| 卡夫卡 | 追加,更新,完成 | 是 | kafka.bootstrap.servers:逗号分隔的主机列表:kafka 代理的端口主题:要写入数据的 Kafka 主题。更多信息请参考 Spark 网站上的 Kafka 集成指南。 |
-
Kafka sink :需要 0.10 以上版本的 Apache Kafka。连接到 Kafka 集群有一组特定的设置。更多信息请参考 Spark 网站上的 Kafka 集成指南。
-
文件接收器:这是文件系统上的一个目的地,HDFS 或 S3。支持常用的文件格式,如 text、CSV、JSON、ORC 和 Parquet。有关支持的文件格式的最新列表,请参见
DataStreamReader
界面。 -
Foreach sink :这意味着对输出中的行运行任意计算。
-
控制台接收器:这仅用于测试和调试目的,并且在处理少量数据时使用。每次触发时,输出都会打印到控制台。
-
内存接收器:这仅用于处理少量数据时的测试和调试目的。它使用驱动程序的内存来存储输出。
数据接收器必须支持结构化流以提供端到端和一次性保证的一个重要属性是处理再处理的幂等性。换句话说,它必须能够处理同一数据的多次写入(发生在不同的时间),这样结果就如同只有一次写入一样。多次写入是在故障情况下重新处理数据的结果。
下一节将使用示例来演示在开发 Spark 结构化流应用程序时各个部分是如何组合在一起的。
水印
水印是流处理引擎中常用的技术,用于处理到达时间比几乎同时创建的其他数据晚得多的数据。当流计算逻辑需要维护某种状态时,后期数据给流处理引擎带来了挑战。这种情况的例子是正在进行聚合或连接。流式应用程序开发人员可以指定一个阈值,让结构化流式引擎知道数据在事件时间内预计会迟到多长时间。有了这些信息,结构化流引擎就可以决定是处理还是丢弃一条最新数据。
更重要的是,结构化流使用指定的阈值来确定何时可以丢弃旧状态。如果没有这些信息,结构化流需要无限期地维护所有状态,这会导致流应用程序出现内存不足的问题。任何执行聚合或连接的生产结构化流应用程序都需要指定水印。这是一个重要的概念,关于这个主题的更多信息将在后面的章节中讨论和说明。
结构化流应用
本节将通过一个 Spark 结构化流示例应用程序来了解概念是如何映射到代码中的。以下示例是关于处理来自文件数据源的一小组移动动作事件。每个事件由三个字段组成。
-
id :代表手机的唯一 id。在提供的样本数据集中,电话 ID 类似于 phone1、phone2、phone3。
-
动作:表示用户采取的动作。动作的可能值为打开和关闭
-
ts :表示用户采取动作时的时间戳。这是活动时间。
移动事件数据被分成三个 JSON 文件,它们位于chapter6/data/mobile
目录中。为了模拟数据流行为,JSON 文件按照一定的顺序被复制到输入文件夹中,然后检查输出以验证您的理解。
让我们通过使用 DataFrames 读取数据来研究移动事件数据(参见清单 6-2 )。
val mobileDataDF = spark.read.json("<path>/chapter6/data/mobile")
mobileDataDF.printSchema
|-- action: string (nullable = true)
|-- id: string (nullable = true)
|-- ts: string (nullable = true)
file1.json
{"id":"phone1","action":"open","ts":"2018-03-02T10:02:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:03:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:03:50"}
{"id":"phone1","action":"close","ts":"2018-03-02T10:04:35"}
file2.json
{"id":"phone3","action":"close","ts":"2018-03-02T10:07:35"}
{"id":"phone4","action":"open","ts":"2018-03-02T10:07:50"}
file3.json
{"id":"phone2","action":"close","ts":"2018-03-02T10:04:50"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:10:50"}
Listing 6-2Reading in Mobile Data and Printing Its Schema
默认情况下,当从基于文件的数据源读取数据时,结构化流需要一个架构。这是有意义的,因为当目录为空时,不可能推断输入流数据的模式。但是,如果您希望它推断模式,您可以将配置spark.sql.streaming.schemaInference
设置为 true。在本例中,您显式创建了一个模式。清单 6-3 包含了为移动事件数据创建模式的代码片段。
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val mobileDataSchema = new StructType()
.add("id", StringType, false)
.add("action", StringType, false)
.add("ts", TimestampType, false)
Listing 6-3Create a Schema for Mobile Event Data
让我们从处理移动事件数据的简单用例开始。我们的目标是使用十秒钟的固定窗口长度来生成每个动作类型的计数。清单 6-4 中的三行代码有助于实现这个目标。第一行展示了通过使用DataStreamReader
类从目录中读取数据来使用基于文件的数据源。预期的数据格式是 JSON,模式由清单 6-3 中定义的三列组成。第一行返回的对象是DataFrame
类的一个实例。与第四章所述的数据帧不同,该数据帧是一个流数据帧。您可以通过调用isStreaming
函数来简单地确认这一点,返回值应该是 true。这个简单应用程序中的流计算逻辑在第二行中表示,它使用action
列和基于ts
列的固定窗口执行 group by 转换。group by 转换中的固定窗口基于嵌入在移动事件数据中的时间戳。第三行很重要,因为它定义了输出模式和数据宿。最重要的是,它告诉结构化流引擎开始增量运行第二行中表示的流计算逻辑。更详细地说,第三行代码使用actionCountDF
DataFrame 的DataFrameWriter
实例将控制台指定为数据接收器,这意味着输出被打印到控制台供您检查。然后它将输出模式定义为"complete"
,这样您就可以看到结果表中的所有记录。最后,它调用DataStreamWriter
类的start()
函数开始执行,这意味着数据源开始处理放入/<path>/chapter6/data/input
目录的文件。另一个需要注意的重要事情是,start
函数返回一个StreamingQuery
类的实例,代表一个查询的句柄,当新数据到达时,该查询在后台持续执行。您可以使用mobileConsoleSQ
串流查询来检查串流应用程序中计算的状态和进度。
在输入清单 6-4 中的代码行之前,确保输入文件夹是空的。
// create a streaming DataFrame from reading data file in the specified directory
val mobileSSDF = spark.readStream.schema(mobileDataSchema)
.json("/<path>/chapter6/data/input")
mobileSSDF.isStreaming
// perform a group by using event time of column ts and fixed window of 10 mins
val actionCountDF = mobileSSDF.groupBy(window($"ts",
"10 minutes"), $"action").count
// start the streaming query and write the output to console
val mobileConsoleSQ = actionCountDF.writeStream
.format("console").option("truncate", "false")
.outputMode("complete")
.start()
Listing 6-4Generate a Count Per Action Type in a 10-Second Sliding Window
清单 6-4 中的start()
函数触发 Spark 结构化流引擎开始监视输入文件夹,并在看到该文件夹中的新文件时开始处理数据。将file1.json
文件从chapter6/data/mobile
目录复制到chapter6/data/input
目录后,输出控制台显示类似于清单 6-5 中的输出。
输出显示从 10:00 到 10:10 只有一个窗口,在这个窗口中,有一个关闭动作和三个打开动作,这应该与files1.json
中的四行事件相匹配。现在对file2.json,
重复相同的过程,输出应该与清单 6-6 匹配。file2.json
数据文件包含一个具有打开动作的事件和另一个具有关闭动作的事件,并且这两个事件都在同一个窗口中。因此,活动类型的计数分别更新为两个关闭和四个打开。
-------------------------------------------
Batch: 1
-------------------------------------------
+----------------------------- --------------+--------+-------+
| window| action| count|
+--------------------------------------------+--------+-------+
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| close | 2|
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| open | 4|
+--------------------------------------------+--------+-------+
Listing 6-6Output from Processing file2.json
-------------------------------------------
Batch: 0
-------------------------------------------
+-----------------------------------------------+--------+------+
| window| action| count|
+-----------------------------------------------+--------+------+
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| close | 1|
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| open | 3|
+-----------------------------------------------+--------+------+
Listing 6-5Output from Processing file1.json
此时,让我们调用查询流mobileConsoleSQ
(一个StreamingQuery
类的实例)的几个函数来检查状态和进度。status()
函数告诉您在查询流的当前状态下发生了什么,查询流可能处于等待模式,也可能正在处理当前的一批事件。lastProgress()
函数提供了关于最后一批事件处理的一些指标,包括处理速率、延迟等。清单 6-7 包含了这两个函数的样本输出。
scala> mobileConsoleSQ.status
res14: org.apache.spark.sql.streaming.StreamingQueryStatus =
{
"message" : "Waiting for data to arrive",
"isDataAvailable" : false,
"isTriggerActive" : false
}
scala> mobileConsoleSQ.lastProgress
res17: org.apache.spark.sql.streaming.StreamingQueryProgress =
{
"id" : "2200bc3f-077c-4f6f-af54-8043f50f719c",
"runId" : "0ed4894c-1c76-4072-8252-264fe98cb856",
"name" : null,
"timestamp" : "2018-03-18T18:18:12.877Z",
"batchId" : 2,
"numInputRows" : 0,
"inputRowsPerSecond" : 0.0,
"processedRowsPerSecond" : 0.0,
"durationMs" : {
"getOffset" : 1,
"triggerExecution" : 1
},
"stateOperators" : [ {
"numRowsTotal" : 2,
"numRowsUpdated" : 0,
"memoryUsedBytes" : 17927
} ],
"sources" : [ {
"description" : "FileStreamSource[file:<path>/chapter6/data/input]",
"startOffset" : {
"logOffset" : 1
},
"endOffset" : {
"logOffset" : 1
},
"numInputRows" : 0,
"inputRowsPerSecond" : 0.0,...
Listing 6-7Output from Calling status() and lastProgress() Functions
让我们处理完移动事件数据的最后一个文件。和file2.json
一样。在file3.json
被复制到输入目录后,输出应该如清单 6-8 所示。文件file3.json
包含一个属于第一个窗口的关闭动作和一个从 10:10 到 10:20 落入新窗口的打开动作。总共有八个动作。其中七个落入第一个窗口,一个动作落入第二个窗口。
-------------------------------------------
Batch: 2
-------------------------------------------
+----------------------------------------------+--------+-------+
| window| action| count|
+----------------------------------------------+--------+-------+
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| close| 3|
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| open| 4|
| [2018-03-02 10:10:00, 2018-03-02 10:20:00]| open| 1|
+----------------------------------------------+--------+-------+
Listing 6-8Output from Processing file3.json
在生产和长期运行的流应用中,需要调用StreamingQuery.awaitTermination()
函数。这是一个阻塞调用,用于防止主线程进程退出,并使流式查询能够在新数据到达数据源时继续运行和处理新数据。当流式查询由于某些可预见的错误而失败时,此函数将失败。
学习结构化流时,您可能希望停止流查询以更改输出模式、触发器或其他配置。您可以使用StreamingQuery.stop()
函数来停止数据源接收新数据,并停止流查询中逻辑的连续执行。清单 6-9 展示了管理流式查询的例子。
// this is blocking call
mobileSQ.awaitTermination()
// stop a streaming query
mobileSQ.stop
// another way to stop all streaming queries in a Spark application
for(qs <- spark.streams.active) {
println(s"Stop streaming query: ${qs.name} - active:
${qs.isActive}")
if (qs.isActive) {
qs.stop
}
}
Listing 6-9Managing Streaming Query
流式数据帧操作
清单 6-9 显示了一旦数据源被配置和定义,DataStreamReader
返回一个 DataFrame 的实例——与您在第 3 和第四章中所熟悉的相同。这意味着您可以使用大多数操作和 Spark SQL 函数来表达您的应用程序的流计算逻辑。重要的是要注意,不是数据帧中的所有操作都被流数据帧支持。这是因为它们中的一些不适用于流处理,在流处理中数据是无限的。这种操作的例子包括limit
、distinct
、cube
和sort
。
选择、项目、汇总操作
结构化流的卖点之一是 Spark 中一组用于批处理和流处理的统一 API。对于流式数据帧,可以对其应用任何选择和过滤转换,以及对各个列进行操作的任何 Spark SQL 函数。此外,第四章中涵盖的基本聚合和高级分析功能也可用于流式数据帧。流式数据帧可以注册为临时视图,然后对其应用 SQL 查询。清单 6-10 提供了一个在清单 6-4 中的mobileSSDF
数据帧上过滤和应用 Spark SQL 函数的例子。
import org.apache.spark.sql.functions._
val cleanMobileSSDF = mobileSSDF.filter($"action" === "open"
|| $"action" === "close")
.select($"id", upper($"action"), $"ts")
// create a view to apply SQL queries on
cleanMobileSSDF.createOrReplaceTempView("clean_mobile")
spark.sql("select count(*) from clean_mobile")
Listing 6-10Apply Filtering and Spark SQL Functions on a Streaming DataFrame
重要的是要注意,在流数据帧中还不支持以下数据帧转换,因为它们太复杂而无法维护状态,或者因为流数据的无限性质。
-
流式数据帧上的多个聚合或聚合链
-
限制并取 N 行
-
独特的转换(但是,有一种方法可以使用唯一标识符来消除重复数据。)
-
在没有任何聚合的情况下对流式数据帧进行排序(但是,在某种形式的聚合之后支持排序。)
任何试图使用不支持的操作都会导致AnalysisException
异常。您会看到一条类似“流数据帧/数据集不支持 XYZ 操作”的消息。
加入操作
对于流式数据帧,最酷的事情之一就是将它与静态数据帧或另一个流式数据帧连接起来。连接是一项复杂的操作,棘手之处在于,在连接时,并非流式数据帧的所有数据都可用。因此,连接的结果是在每个触发点以增量方式生成的,类似于聚合结果的生成方式。
从 Spark 版开始,结构化流支持连接两个流数据帧。考虑到流式数据帧的无界特性,结构化流式传输必须维护两个流式数据帧的过去数据,以匹配任何未来的、尚未接收的数据。为了避免结构化流必须保持的流状态的爆炸,可以可选地为两个流数据帧提供水印,并且必须在连接条件中定义对事件时间的约束。让我们来看一个物联网用例,它将数据中心的两个与数据传感器相关的数据流连接起来。第一个包含数据中心不同位置的温度读数。第二个包含同一数据中心中每台计算机的负载信息。这两股流的共同条件是位置。清单 6-11 包含关于在连接条件中提供水印和事件时间约束的代码。
import org.apache.spark.sql.functions.expr
// the specific streaming data source information is not important in this example
val tempDataDF = spark.readStream. ...
val loadDataDF = spark.readStream. ...
val tempDataWatermarkDF = tempDataDF.withWaterMark("temp_taken_time", "1 hour")
val loadDataWatermarkDF = loadDataDF.withWaterMark("load_taken_time", "2 hours")
// join on the location id as well as the event time constraint
tempWithLoadDataDF = tempDataWatermarkDF.join(loadDataWatermarkDF,
expr(""" temp_location_id = load_location_id AND
load_taken_time >= temp_taken_time AND
load_taken_time <= temp_taken_time + interval 1 hour
""")
)
Listing 6-11Joining Two Streaming DataFrames
当连接一个静态数据帧和一个流数据帧以及两个流数据帧时,对外部连接有更多的限制。表 6-4 提供了一些信息。
表 6-4
关于加入流数据帧的一些细节
|左侧+右侧
|
连接类型
|
注意
|
| --- | --- | --- |
| 静态+流式 | 内部的 | 支持 |
| 静态+流式 | 左侧外部 | 不支持 |
| 静态+流式 | 右侧外部 | 支持 |
| 静态+流式 | 全外 | 不支持 |
| 流媒体+流媒体 | 内部的 | 支持 |
| 流媒体+流媒体 | 左侧外部 | 有条件支持。必须在右侧指定水印和时间限制 |
| 流媒体+流媒体 | 右侧外部 | 有条件支持。必须指定左边的水印和时间限制 |
| 流媒体+流媒体 | 全外 | 不支持 |
使用数据源
上一节描述了结构化流提供的每个内置源。这一节将更详细地介绍并提供使用它们的示例代码。
socket 和 rate 数据源都是仅为测试和学习目的而设计的,它们不应该用于生产中。
使用套接字数据源
socket 数据源很容易使用,它只需要关于要连接的主机和端口的信息。在开始对 socket 数据源进行流查询之前,首先使用网络实用程序命令行实用程序(比如 macOS 上的nc
或 Windows 上的netcat
)启动一个 socket 服务器是很重要的。在这个例子中,使用了nc
网络实用程序,您需要打开两个终端。第一个用于启动端口号为 9999 的套接字服务器;命令是nc -lk 9999
。第二个是用清单 6-12 中的代码运行 Spark shell。
val socketDF = spark.readStream.format("socket")
.option("host", "localhost")
.option("port", "9999").load()
val words = socketDF.as[String].flatMap(_.split(" "))
val wordCounts = words.groupBy("value").count()
val query = wordCounts.writeStream.format("console")
.outputMode("complete")
.start()
Listing 6-12Reading Streaming Data from Socket Data Source
现在回到第一个终端,键入Spark is great
,并按回车键。然后键入Spark is awesome
并按回车键。按回车键告诉 Netcat 服务器将输入的内容发送给套接字侦听器。如果一切顺利,Spark shell 控制台中应该有两个输出批处理,如清单 6-13 所示,每个批处理都包含每个单词的计数。由于结构化流跨批次维护状态,它能够将单词Spark
和is
的计数更新为 2。
-------------------------------------------
Batch: 0
-------------------------------------------
+--------+-------+
| value| count|
+--------+-------+
| great| 1|
| is| 1|
| Spark| 1|
+--------+-------+
-------------------------------------------
Batch: 1
-------------------------------------------
+------------+-------+
| value| count|
+------------+-------+
| great| 1|
| is| 2|
| awesome| 1|
| Spark| 2|
+------------+-------+
Listing 6-13Output of Socket Data Source in Spark-Shell Console
当您测试完套接字数据源后,可以通过调用stop
函数来停止流式查询,如清单 6-14 所示。
query.stop
Listing 6-14Stop a Streaming Query of Socket Data Source
使用比率数据源
像 socket 数据源一样,rate 数据源只是为了测试和学习目的而设计的。它支持几个选项,其中最关键的一个是每秒生成的行数。如果这个数字很高,那么可以提供另一个可选配置来指定达到每秒行数的上升时间。比率源产生的每条数据包含两列:时间戳和自动增量值。清单 6-15 包含从 rate 数据源打印数据的代码,以及第一批数据在控制台中的样子。
// configure it to generate 10 rows per second
val rateSourceDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.load()
val rateQuery = rateSourceDF.writeStream
.outputMode("update")
.format("console")
.option("truncate", "false")
.start()
-------------------------------------------
Batch: 1
-------------------------------------------
+--------------------------------+-------+
| timestamp| value|
+--------------------------------+-------+
| 2018-03-19 10:30:21.952| 0 |
| 2018-03-19 10:30:22.052| 1 |
| 2018-03-19 10:30:22.152| 2 |
| 2018-03-19 10:30:22.252| 3 |
| 2018-03-19 10:30:22.352| 4 |
| 2018-03-19 10:30:22.452| 5 |
| 2018-03-19 10:30:22.552| 6 |
| 2018-03-19 10:30:22.652| 7 |
| 2018-03-19 10:30:22.752| 8 |
| 2018-03-19 10:30:22.852| 9 |
+--------------------------------+-------+
Listing 6-15Working with Rate Data Source
需要注意的一件有趣的事情是,value
列中的数字保证在所有分区中都是连续的。清单 6-16 展示了三个分区的输出。
import org.apache.spark.sql.functions._
// with 3 partitions
val rateSourceDF2 = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions",3).load()
// add partition id column to examine
val rateWithPartitionDF =
rateSourceDF2.withColumn("partition_id", spark_partition_id())
val rateWithPartitionQuery = rateWithPartitionDF.writeStream
.outputMode("update")
.format("console")
.option("truncate", "false")
.start()
// output of batch one
-------------------------------------------
Batch: 1
-------------------------------------------
+--------------------------------+--------+--------------+
| timestamp| value| partition_id|
+--------------------------------+--------+--------------+
| 2018-03-24 08:46:43.412| 0 | 0 |
| 2018-03-24 08:46:43.512| 1 | 0 |
| 2018-03-24 08:46:43.612| 2 | 0 |
| 2018-03-24 08:46:43.712| 3 | 1 |
| 2018-03-24 08:46:43.812| 4 | 1 |
| 2018-03-24 08:46:43.912| 5 | 1 |
| 2018-03-24 08:46:44.012| 6 | 2 |
| 2018-03-24 08:46:44.112| 7 | 2 |
| 2018-03-24 08:46:44.212| 8 | 2 |
| 2018-03-24 08:46:44.312| 9 | 2 |
+--------------------------------+--------+--------------+
Listing 6-16the Output of Rate Data Source with the Partition ID
输出显示这十行分布在三个分区中,并且这些值是连续的,就像是为单个分区生成的一样。如果你对这个数据源的实现很好奇,那就去看看 https://github.com/apache/spark/blob/master/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/RateSourceProvider.scala
。
使用文件数据源
文件数据源是最容易理解和使用的。假设需要处理定期复制到目录中的新文件。这是这个用例的完美数据源。开箱即用,它支持所有常用的文件格式,包括文本、CSV、JSON、ORC 和 Parquet。有关支持的文件格式的完整列表,请参考DataStreamReader
界面。在文件数据源支持的四个选项中,唯一需要的选项是要从中读取文件的输入目录。
当新文件被复制到指定的目录中时,文件数据源会选取它们进行处理。可以将该数据源配置为有选择地只选取固定数量的新文件进行处理。指定文件数量的选项称为maxFilesPerTrigger
。
清单 6-17 提供了一个从目录中读取 JSON 移动数据事件并使用清单 6-3 中定义的相同模式的例子。文件数据源支持的另一个有趣的可选选项是在处理旧文件之前处理最新的文件。它使用文件的时间戳来确定哪个文件较新。默认行为是从最旧到最新处理文件。当有大量文件需要处理,并且您想先处理新文件时,此选项很有用。
val mobileSSDF = spark.readStream.schema(mobileDataSchema)
.json("<directory name>")
// if you want to specify maxFilesPerTrigger
val mobileSSDF = spark.readStream.schema(mobileDataSchema)
.option("maxFilesPerTrigger", 5)
.json("<directory name>")
// if you want to process new files first
val mobileSSDF = spark.readStream.schema(mobileDataSchema)
.option("latestFirst", "true")
.json("<directory name>")
Listing 6-17Working with File Data Source
使用 Kafka 数据源
大多数生产流应用程序处理来自 Kafka 数据源的流数据。为了有效地使用这个数据源,您需要具备使用 Kafka 的基本知识。在高层次上,该数据源充当 Kafka 消费者,因此它需要的信息与典型的 Kafka 消费者需要的信息非常相似。有两条必选信息和一些可选信息。
两个必需的参数是要连接的 Kafka 服务器的列表和一个或多个消费数据的主题的信息。在灵活性和支持多种需求方面,它支持三种不同的方式来指定这些信息。您只需要选择最适合您的用例的一个。表 6-5 包含关于两个必需选项的信息。
表 6-5
Kafka 数据源的必需选项
|[计]选项
|
价值
|
描述
|
| --- | --- | --- |
| 卡夫卡. bootstrap .服务器 | 主机 1:端口 1,主机 2:端口 2 | 这是一个逗号分隔的 Kafka 代理服务器列表。请咨询您的 Kafka 管理员,了解要使用的主机名和端口号 |
| 订阅 | 主题 1,主题 2 | 这是数据源从中读取数据的主题名称的逗号分隔列表。 |
| 订阅模式 | 话题。* | 这是一个 regex 模式,用来表示从哪些主题中读取数据。它比subscribe
选项更灵活一点。 |
| 分配 | {主题 1: [1,2],主题 2: [3,4] } | 使用此选项,您可以指定要从中读取数据的主题的特定分区列表。这些信息必须以 JSON 格式提供。 |
指定所需选项后,您可以选择指定表 6-5 中的选项,该表仅包含常用选项的子集。有关可选选项的完整列表,请参考结构化流和 Kafka 集成指南。这些选项是可选的原因是它们有默认值。
通过startingOffsets
和endingOffsets
选项,您可以从特定主题的特定分区中的特定点对 Kafka 中的数据处理进行精细控制。这种灵活性在由于失败、新版本软件中引入的错误或者重新训练机器学习模型而需要重新处理的情况下非常有用。Kafka 中的数据再处理能力是 Kafka 在大数据处理领域非常受欢迎的原因之一。这可能是显而易见的,但是 Kafka 数据源使用startingOffsets
来确定在 Kafka 中从哪里开始读取数据。因此,一旦处理开始,就不再使用该选项。Kafka 数据源使用endingOffsets
来确定何时停止从 Kafka 读取数据。例如,如果您希望您的流应用程序从 Kafka 读取最新的数据,并继续处理新的传入数据,则最新的是startingOffsets
和endingOffsets
值。
表 6-6
Kafka 数据源的可选选项
|[计]选项
|
缺省值
|
价值
|
描述
|
| --- | --- | --- | --- |
| 开始偏移 | 最近的 | 最早、最晚每个主题的起始偏移量的 JSON 字符串,即,{ "topic1": { "0":45," 1": -1}," topic2": { "0":-2}} | earliest
指一个话题的开始。latest
表示某个主题中的任何最新数据。当使用 JSON 字符串格式时,–2 表示特定分区中的最早偏移量,而–1 表示特定分区中的最新偏移量 |
| 结束偏移量 | 最近的 | 最近的 JSON 字符串,即{ "topic1": { "0":45," 1": -1}," topic2": { "0":-2}} | latest
指某个话题的最新数据。当使用 JSON 字符串格式时,–1 表示特定分区中的最新偏移量。当然,–2 不适用于此选项。 |
| maxOffsetsPerTrigger | 没有人 | 很长。即 500 | 该选项是一种速率限制机制,用于控制每个触发间隔要处理的记录数。如果指定了值,它表示所有分区的记录总数,而不是每个分区的记录总数。 |
默认情况下,Kafka 数据源不包含在位于 https://spark.apache.org/downloads.html
的 Apache Spark 二进制文件中。如果您想从 Spark shell 中使用 Kafka 数据源,在启动 Spark shell 时使用一个额外的选项来下载和包含正确的 jar 文件是很重要的。结构化流和 Kafka 集成文档( https://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html
)的部署部分提供了有关额外选项的信息。它看起来有点像清单 6-18 中的内容。
./bin/spark-shell --packages org.apache.spark:spark-sql-kafka-0-10_2.11:2.3.0
// if the above package is not provided, the following problem will be encountered
java.lang.ClassNotFoundException: Failed to find data source: kafka. Please find packages at http://spark.apache.org/third-party-projects.html
at org.apache.spark.sql.execution.datasources.DataSource$.lookupDataSource(DataSource.scala:635)
at org.apache.spark.sql.streaming.DataStreamReader.load(DataStreamReader.scala:159)
Listing 6-18Start Spark Shell with Kafka Data Source Jar File
让我们从一个名为pageviews
的 Kafka 主题开始处理数据的简单示例开始,并在新数据到达 Kafka 时继续处理。清单 6-19 显示了代码。
import org.apache.spark.sql.functions._
val pvDF = spark.readStream.format("kafka")
.option("kafka.bootstrap.servers","localhost:9092")
.option("subscribe", "pageviews")
.option("startingOffsets", "earliest")
.load()
pvDF.printSchema
|-- key: binary (nullable = true)
|-- value: binary (nullable = true)
|-- topic: string (nullable = true)
|-- partition: integer (nullable = true)
|-- offset: long (nullable = true)
|-- timestamp: timestamp (nullable = true)
|-- timestampType: integer (nullable = true)
Listing 6-19Kafka Data Source Example
Kafka 数据源的一个独特之处是它返回的流数据帧有一个固定的模式,看起来像清单 6-19 。value
列包含每个 Kafka 消息的实际内容,而type
列是二进制的。Kafka 并不关心每条消息的内容,因此它将其视为二进制 blob。模式中的其余列包含每条消息的元数据。如果消息的内容在发送到 Kafka 时是以某种二进制格式序列化的,那么在 Spark 中处理这些消息之前,您需要使用 Spark SQL 函数或 UDF 来反序列化它。
在清单 6-20 中,内容是一个字符串,所以您只需要将它转换成一个String
类型。出于演示的目的,清单 6-20 执行value
列的转换,并选择几个元数据相关的列来显示。
val pvValueDF = pvDF.selectExpr("partition","offset",
"CAST(key AS STRING)", "CAST(value AS STRING)")
.as[(String, Long, String, String)]
Listing 6-20Casting Message Content To String Type
清单 6-21 中的例子包含了指定 Kafka 主题、分区和偏移量来读取 Kafka 消息的一些变化。
// reading from multiple topics with default startingOffsets and endingOffsets
val kafkaDF = spark.readStream.format("kafka")
.option("kafka.bootstrap.servers","server1:9092,server2:9092")
.option("subscribe", "topic1,topic2")
.load()
// reading from multiple topics using subscribePattern
val kafkaDF = spark.readStream.format("kafka")
.option("kafka.bootstrap.servers","server1:9092,server2:9092")
.option("subscribePattern", "topic*")
.load()
// reading from a particular offset of a partition using JSON format
// the triple quotes format in Scala is used to escape double quote in JSON string
Val kafkaDF = spark.readStream.format("kafka")
.option("kafka.bootstrap.servers","localhost:9092")
.option("subscribe", "topic1,topic2")
.option("startingOffsets", """ {"topic1": {"0":51} } """)
.load()
Listing 6-21Various Examples of Specifying Kafka Topic, Partition and Offset
使用自定义数据源
从 Spark 2.3 版本开始,引入了数据源 API V2 来解决 V1 的问题,并提供了一组干净、可扩展且易于使用的新 API。数据源 API V2 只在 Scala 中可用。
本节旨在提供使用数据源 API V2 构建自定义数据源所涉及的接口和主要 API 的快速概述。在这样做之前,最好先研究几个内置数据源的实现,比如RateSourceProvider.scala
、RateSourceProviderV2.scala
和KafkaSourceProvider.scala
类。
所有定制数据源必须实现一个名为DataSourceV2,
的标记接口,然后它可以决定是实现接口ContinuousReadSupport
还是MicroBatchReadSupport
或者两者都实现。例如,KafkaSourceProvider.scala
实现了这两个接口,因为它允许用户根据用例选择使用哪种处理模式。这两个接口中的每一个都作为工厂方法来分别创建ContinuousReader
或MicroBatchReader
的实例。大部分自定义数据源实现都是在实现这两个接口中定义的 API。
我实现了一个有趣的非容错数据源,从维基百科 IRC 服务器读取维基编辑。使用 Spark 结构化流来分析各种维基百科站点的维基编辑是相当容易的。详见 GitHub 资源库( https://github.com/beginning-spark/beginning-apache-spark-3/tree/master/chapter6/custom-data-source
)中的 README.md。要在 Spark shell 中使用这个定制数据源,第一步是从 GitHub 存储库中下载streaming_sources-assembly-0.0.1.jar
文件。清单 6-22 描述了剩余的步骤
// start up spark-shell with streaming_sources-assembly-0.0.1.jar
bin/spark-shell --jars <path>/streaming_sources-assembly-0.0.1.jar
// once spark-shell is successfully started
// define the data source provider name
val provideClassName = "org.structured_streaming_sources.wikedit.WikiEditSourceV2"
// use custom data and subscribe to English Wikipedia edit channel
val wikiEditDF = spark.readStream.format(provideClassName).option("channel", "#en.wikipedia").load()
// examine the schema of wikiEditDF streaming DataFrame
wikiEditDF.printSchema
|-- timestamp: timestamp (nullable = true)
|-- channel: string (nullable = true)
|-- title: string (nullable = true)
|-- diffUrl: string (nullable = true)
|-- user: string (nullable = true)
|-- byteDiff: integer (nullable = true)
|-- summary: string (nullable = true)
// select only a few columns for analysis
val wikiEditSmallDF = wikiEditDF.select("timestamp", "user", "channel", "title")
// start streaming query and write out the wiki edits to console
val wikiEditQS = wikiEditSmallDF.writeStream.format("console").option("truncate", "false").start()
// wait for a few seconds for data to come in and the result might looking like below
+------------------------+-------------+--------------+-----------------------------+
| timestamp | user | channel | title |
+------------------------+-------------+--------------+-----------------------------+
| 2018-03-24 15:36:39.409| 6.62.103.211| #en.wikipedia| Thomas J.R. Hughes |
| 2018-03-24 15:36:39.412| .92.206.108| #en.wikipedia| List of international schools|
+------------------------+-------------+--------------+-----------------------------+
// to stop the query stream
wikiEditQS.stop
Listing 6-22Analyzing Wiki Edits with a Custom Data Source
请注意,自定义数据源名称是数据源提供程序的完全限定类名。它不像内置数据源那样短,因为那些数据源已经在一个名为org.apache.spark.sql.sources.DataSourceRegister
的文件中注册了它们的短名称。
使用数据接收器
流式应用程序的最后一步是将计算结果写入某个存储系统,或者将其发送给某个下游系统以供使用。结构化流提供了五个内置接收器,其中三个用于生产用途,其余的用于测试目的。下面几节将详细介绍每一种方法,并提供使用它们的示例代码。
使用文件数据接收器
文件数据接收器易于理解和使用。您需要提供的唯一必需选项是输出目录。由于文件数据接收器是容错的,因此结构化流需要一个检查点位置来写入进度信息和其他元数据,以便在出现故障时帮助恢复。
清单 6-23 中的例子将 rate 数据源配置为每秒生成十行,将生成的行发送到两个分区,并将 JSON 格式的数据写入指定的目录。
val rateSourceDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
val rateSQ = rateSourceDF.writeStream.outputMode("append")
.format("json")
.option("path", "/tmp/output")
.option("checkpointLocation", "/tmp/ss/cp")
.start()
// use the line below to stop the writing the data
rateSQ.stop
Listing 6-23Write Data from Rate Data Source To File Sink
由于分区的数量被配置为两个,因此结构化流在每个触发点将输出写到指定输出文件夹的两个文件中。因此,如果您检查输出文件夹,您会看到文件名以 part-00000 和 part-00001 开头的文件。rate 数据源配置为每秒 10 行,有两个分区。因此,每个输出包含五行,如清单 6-24 所示。
{"timestamp":"2018-03-24T17:42:08.182-07:00","value":205}
{"timestamp":"2018-03-24T17:42:08.282-07:00","value":206}
{"timestamp":"2018-03-24T17:42:08.382-07:00","value":207}
{"timestamp":"2018-03-24T17:42:08.482-07:00","value":208}
{"timestamp":"2018-03-24T17:42:08.582-07:00","value":209}
Listing 6-24the Content of Each Output File
使用 Kafka 数据接收器
在结构化流中,将流数据帧的数据写入 Kafka 数据接收器比从 Kafka 数据源读取数据更简单。Kafka 数据接收器可以用表 6-7 中列出的四个选项进行配置。其中三个选项是必需的。需要理解的重要选项是与卡夫卡信息结构相关的关键字和值。Kafka 中的数据单元是消息,它本质上是一个键值对。value
的作用是保存卡夫卡信息的实际内容。
就卡夫卡而言,值只是字节的集合。Kafka 将key
视为元数据,它与 Kafka 消息中的值一起保存。当消息被发送到 Kafka,并且提供了密钥时,Kafka 利用它作为路由机制,通过散列密钥并对特定主题具有的分区数量执行模运算,来确定特定 Kafka 消息应该被发送到哪个分区。这意味着具有相同关键字的所有消息都被路由到相同的分区。如果没有提供密钥,Kafka 不能保证消息被发送到哪个分区,Kafka 使用循环算法来平衡分区之间的消息。
表 6-7
Kafka 数据接收器的选项
|[计]选项
|
价值
|
描述
|
| --- | --- | --- |
| 卡夫卡. bootstrap .服务器 | 主机 1:端口 1,主机 2:端口 2 | 这是一个逗号分隔的 Kafka 代理服务器列表。请咨询您的 Kafka 管理员,了解要使用的主机名和端口号 |
| 主题 | 主题 1 | 这是一个主题名称 |
| 键 | 字符串或二进制 | 这个键决定了 Kafka 信息应该被发送到哪个分区。具有相同密钥的所有 Kafka 消息都进入相同的分区。这是一个可选选项。 |
| 价值 | 字符串或二进制 | 这是一条信息的内容。对卡夫卡来说,它只是一个字节数组。 |
有两种方法可以提供主题名。第一种方法是在 Kafka 数据接收器的配置中提供它,第二种方法是在流数据帧中定义一个名为topic
的列。该列的值被用作主题名称。
如果流式数据帧有一个名为key
的列,则该列值将用作消息关键字。因为键是可选的元数据,所以不要求在流数据帧中有这个列。另一方面,必须提供值,Kafka data sink 期望在流数据帧中有一个名为value
的列。
清单 6-25 提供了一个设置比率数据源的例子,然后将数据写出到一个名为rates
的 Kafka 主题中。如果您计划使用 Spark shell 来测试代码,那么包括必要的参数来包含org.apache.spark:spark-sql-kafka-0-10_2.11:2.3.0
jar 文件及其依赖项。
Note
开始使用 Kafka 最简单的方法是下载 Confluent Platform 包,并遵循它的入门指南。更多信息请访问 https://docs.confluent.io/current/getting-started.html
。下载完成后,将压缩的 tar 文件解压缩到一个目录中。要启动服务器(Zookeeper、Kafka Broker、Schema Registry),请使用。/bin/汇合启动命令行。这些服务器中的每一个都监听一个特定的端口。所有的命令行工具都在 bin 目录中,并且几乎所有的工具都需要 Zookeeper 或 Kafka Broker 的主机和端口。在运行清单 6-21 中的代码之前,确保创建一个名为rates的主题,命令是 bin/Kafka-topics-create-zookeeper localhost:2181-replication-factor 1-partitions 2-topic rates。要列出活动主题,请使用以下命令:。/bin/卡夫卡-topics-zookeeper localhost:2181-list。
import org.apache.spark.sql.functions._
// setting up the rate data source with 10 rows per second and use two partitions
val ratesSinkDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
// transform the ratesSinkDF to create a column called "key" and "value" column
// the value column contains a JSON string that contains two fields: timestamp and value
val ratesSinkDF = ratesSinkDF.select(
$"value".cast("string") as "key",
to_json(struct("timestamp","value")) as "value")
// setup a streaming query to write data to Kafka using topic "rates"
val rateSinkSQ = ratesSinkDF.writeStream
.outputMode("append")
.format("kafka")
.option("kafka.bootstrap.servers",
"localhost:9092")
.option("topic","rates")
.option("checkpointLocation",
"/Users/hluu/tmp/ss/cp")
.start()
// it doesn't take long to write a lot of messages to Kafka, so after a few second, feel free to stop the
// rateSinkSQL
rateSinkSQ.stop
Listing 6-25Write Data from Rate Data Source To File Sink
要从 Kafka 中的rates
主题读回数据,请使用清单 6-21 中列出的示例代码,并用适当的值替换选项,如kafka.bootstrap.servers
和主题名称。rates
Kafka 主题中的数据看起来有点像清单 6-22 。
+---------+---------+---------+-------------------------------------------------------------+
|partition| offset| key | value |
+---------+---------+---------+-------------------------------------------------------------+
| 1 | 9350 | 583249| {"timestamp":"2018-03-25T09:53:52.582-07:00","value":583249}|
| 1 | 9351 | 583250| {"timestamp":"2018-03-25T09:53:52.682-07:00","value":583250}|
| 1 | 9352 | 583251| {"timestamp":"2018-03-25T09:53:52.782-07:00","value":583251}|
| 1 | 9353 | 583256| {"timestamp":"2018-03-25T09:53:53.282-07:00","value":583256}|
| 1 | 9354 | 583261| {"timestamp":"2018-03-25T09:53:53.782-07:00","value":583261}|
| 1 | 9355 | 583266| {"timestamp":"2018-03-25T09:53:54.282-07:00","value":583266}|
| 1 | 9356 | 583267| {"timestamp":"2018-03-25T09:53:54.382-07:00","value":583267}|
| 1 | 9357 | 583274| {"timestamp":"2018-03-25T09:53:55.082-07:00","value":583274}|
| 1 | 9358 | 583275| {"timestamp":"2018-03-25T09:53:55.182-07:00","value":583275}|
| 1 | 9359 | 583276| {"timestamp":"2018-03-25T09:53:55.282-07:00","value":583276}|
+---------+---------+---------+-------------------------------------------------------------+
Listing 6-26Sample of Data from Kafka
使用 foreach 数据接收器
与结构化流提供的其他内置数据接收器相比,foreach 数据接收器非常有趣,因为它在如何写入数据、何时写出数据以及将数据写入何处方面提供了完全的灵活性。它被设计成一个可扩展以及可插入的数据接收器。这种灵活性和可扩展性伴随着责任,因为您负责写出数据的逻辑。简而言之,您需要提供一个ForeachWriter
抽象类的实现,它由三个方法组成:open
、process
和close
。只要触发器后有输出行列表,就会调用它们。使用这个数据接收器需要一些关于 Spark 如何工作的细节。
-
在驱动程序端创建了一个
ForeachWriter
抽象类实现的实例,并发送给 Spark 集群中的执行器执行。这有两层含义。首先,ForeachWriter
的实现必须是可序列化的;否则,它的一个实例就不能通过网络传送给执行者。第二,如果在创建实现的过程中有任何初始化,它们都发生在驱动程序端。例如,如果您想要创建一个数据库或套接字连接,这不应该在类初始化期间发生,而应该在其他地方发生。 -
流数据帧中的分区数量决定了创建多少个
ForeachWriter
实现实例。这非常类似于Dataset.foreachPartition
方法的行为。 -
在
ForeachWriter
抽象类中定义的三个方法在执行者端被调用。 -
open
方法是执行初始化的最佳地方,比如打开数据库连接或套接字连接。但是,每次写出数据时都会调用它;因此,逻辑必须是智能和高效的。 -
open
方法签名有两个输入参数:partition id
和version
。布尔值是返回类型。这两个参数的组合唯一地表示了需要写出的一组行。版本的值是一个单调递增的 id,随着每次触发而增加。根据分区 id 和版本参数的值,open 方法需要决定是否需要写出行序列,并为结构化流引擎返回适当的布尔值。 -
如果
open
方法返回 true,那么对于触发器输出的每一行都调用process
方法。 -
保证会调用
close
方法。如果在调用process
方法的过程中出现错误,该错误将被传递给close
方法。调用close
方法的目的是让您有机会清理在open
或process
方法调用期间创建的任何必要状态。唯一不调用 close 方法的时候是当执行器的 JVM 崩溃或者 open 方法抛出一个 throwable 异常的时候。
简而言之,这个数据接收器在写出流数据帧的数据时提供了最大的灵活性。清单 6-27 包含了一个非常简单的抽象类ForeachWriter
的实现,它将数据从 rate 数据源写入控制台。
// define an implementation of the ForeachWriter abstract class
import org.apache.spark.sql.{ForeachWriter,Row}
class ConsoleWriter(private var pId:Long = 0, private var ver:Long = 0) extends ForeachWriter[Row] {
def open(partitionId: Long, version: Long): Boolean = {
pId = partitionId
ver = version
println(s"open => ($partitionId, $version)")
true
}
def process(row: Row) = {
println(s"writing => $row")
}
def close(errorOrNull: Throwable): Unit = {
println(s"close => ($pId, $ver)")
}
}
// setup the Rate data source
val ratesSourceDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
// setup the Foreach data sink
val rateSQ = ratesSourceDF.writeStream.foreach(new ConsoleWriter).start()
// sample output from the console
open => (1, 1)
writing => [2018-03-25 13:03:41.867,5]
writing => [2018-03-25 13:03:41.367,0]
writing => [2018-03-25 13:03:41.967,6]
writing => [2018-03-25 13:03:41.467,1]
writing => [2018-03-25 13:03:42.067,7]
writing => [2018-03-25 13:03:41.567,2]
writing => [2018-03-25 13:03:42.167,8]
writing => [2018-03-25 13:03:41.667,3]
writing => [2018-03-25 13:03:42.267,9]
close => (1, 1)
// to close the rateSQ streaming query
rateSQ.stop
Listing 6-27Sample Code for Working with Foreach Data Sink
使用控制台数据接收器
这个控制台数据接收器很容易使用。它确实如其名。它不是容错数据接收器。它设计用于调试目的或学习结构化流。它提供的两个选项是要显示的行数和如果输出过长是否截断输出。每个选项都有一个默认值,如表 6-8 所示。这个数据接收器的底层实现使用与DataFrame.show
方法中相同的逻辑来显示流数据帧中的数据。
表 6-8
控制台数据接收器的选项
|[计]选项
|
缺省值
|
描述
|
| --- | --- | --- |
| numRows 的 | Twenty | 要打印到控制台的行数 |
| 缩短 | 真实的 | 每列内容超过 20 个字符时是否截断 |
清单 6-28 显示了控制台数据接收器的运行情况,并为两个选项中的每一个提供了一个值。
// setting up a data source
val ratesDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
Val ratesSQ = ratesDF.writeStream.outputMode("append")
.format("console")
.option("truncate",false)
.option("numRows",50)
.start()
Listing 6-28Sample Code for Working with Console Data Sink
使用内存数据接收器
像控制台数据接收器一样,内存数据接收器非常容易理解和使用。它非常简单,因为它没有您需要提供的选项。它不是容错数据接收器。它设计用于调试目的或学习结构化流。它收集的数据被发送到驱动程序,并作为内存中的表存储在驱动程序中。换句话说,可以发送到内存数据接收器的数据量受到驱动程序 JVM 拥有的内存量的限制。在设置这个数据接收器时,您可以指定一个查询名称作为DataStreamWriter.queryName
函数的参数。然后,您可以对内存中的表发出 SQL 查询。与控制台数据接收器不同,一旦数据被发送到内存表,您就可以使用 Spark SQL 组件中几乎所有可用的特性来进一步分析或处理数据。如果数据量很大,不适合内存,那么下一个最好的选择是使用文件数据接收器以 Parquet 格式写出数据。
清单 6-29 中的示例代码将来自 rate 数据源的数据写入内存表,并针对内存表发出 Spark SQL 查询。
val ratesDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
// write data out to Memory data sink with in-memory table name as "rates"
val ratesSQ = ratesDF.writeStream.outputMode("append")
.format("memory")
.queryName("rates")
.start()
// you issue SQL queries against the "rates" in-memory table
spark.sql("select * from rates").show(10,false)
+---------------------------------+-------+
| timestamp | value|
+---------------------------------+-------+
| 2018-03-25 14:02:59.461| 0 |
| 2018-03-25 14:02:59.561| 1 |
| 2018-03-25 14:02:59.661| 2 |
| 2018-03-25 14:02:59.761| 3 |
| 2018-03-25 14:02:59.861| 4 |
| 2018-03-25 14:02:59.961| 5 |
| 2018-03-25 14:03:00.061| 6 |
| 2018-03-25 14:03:00.161| 7 |
| 2018-03-25 14:03:00.261| 8 |
| 2018-03-25 14:03:00.361| 9 |
+---------------------------------+-------+
// count the number of rows in the "rates" in-memory table
spark.sql("select count(*) from rates").show
+-----------+
| count(1)|
+-----------+
| 100|
+-----------+
// to stop the ratesSQ query stream
ratesSQ.stop
Listing 6-29Sample Code for Working with the Memory Data Sink
需要注意的一点是,在 ratesSQ 流查询停止后,内存中的rates
表仍然存在。但是,一旦使用相同的名称启动新的流式查询,内存中的数据就会被截断
在结束本节之前,有必要了解每种类型的数据宿支持哪些输出。表 6-9 是一个快速总结,供参考。输出模式将在下一节讨论。
表 6-9
数据接收器及其支持的输出模式
|水槽
|
支持的输出模式
|
笔记
|
| --- | --- | --- |
| 文件 | 附加 | 仅支持写出新行,不支持更新 |
| 卡夫卡 | 追加、更新、完成 | |
| 为每一个 | 追加、更新、完成 | 取决于 ForeachWriter 实现 |
| 安慰 | 追加、更新、完成 | |
| 记忆 | 附加,完成 | 不支持就地更新 |
输出模式
“输出模式”部分描述了每种输出模式。本节提供了关于它们的更多信息,以及理解哪种输出模式适用于哪种流查询类型的方法。
有两种类型的流式查询。第一种类型称为无状态类型**,它只对传入的流数据执行基本转换,然后将数据写出到一个或多个数据接收器。第二种类型是有状态类型,它要求在触发点之间维护某种状态,无论这是隐式还是显式完成的。有状态类型通常执行聚合或使用结构化的流 API,如mapGroupsWithState
或flatMapGroupsWithState
来维护特定用例所需的任意状态;例如,维护用户会话数据。
*让我们从简单的无状态流查询类型开始。这种流式查询的典型用例是实时流式 ETL。它不断读取传入的流数据,如在线服务产生的页面查看事件,以捕获用户正在查看哪些页面。在这种用例中,它通常执行以下操作。
-
过滤、转换和清洗。真实世界的数据是混乱的。该结构可能不太适合重复分析。
-
转换为更有效的存储格式。文本、CVS 和 JSON 是人类可读的文件格式,但是对于重复分析来说效率很低,尤其是在数据量很大的情况下,比如数百 TB。更有效的二进制格式,如 ORC、Parquet 或 Avro,更适合减少数据量和提高分析速度。
-
按某些列对数据进行分区。将数据写出到数据接收器时,可以根据查询中常用列的值对数据进行分区,以加快组织中不同团队的重复分析。
如您所见,在将数据写出到数据接收器之前,这些任务不需要流查询来维护任何类型的状态。当新数据进来时,它被清理、转换,并可能被重构,最后被写出。append
是该无状态流类型唯一适用的输出模式。complete
输出模式不适用,因为这需要结构化流来维护所有之前的数据,这些数据可能太大。update
输出模式不适用,因为只有新数据被写出。然而,当该输出模式用于无状态流查询时,结构化流识别出这一点,并将其视为与append
输出模式相同。最酷的是,当一个不合适的输出模式被用于流查询时,结构化流引擎会让你知道。清单 6-30 显示了使用不合适的输出模式时会发生什么。
val ratesDF = spark.readStream.format("rate")
.option("rowsPerSecond","10")
.option("numPartitions","2")
.load()
// simple transformation
val oddEvenDF = ratesDF.withColumn("even_odd",
$"value" % 2 === 0)
// write out to Console data sink using complete output mode
val ratesSQ = oddEvenDF.writeStream.outputMode("complete")
.format("console")
.option("truncate",false)
.option("numRows",50)
.start()
// An exception from Structured Streaming during the analysis phase
org.apache.spark.sql.AnalysisException: Complete output mode not supported when there are no streaming aggregations on streaming DataFrames/Datasets;
Listing 6-30Using “Complete” Output Mode with a Stateless Streaming Query
现在让我们转到第二种查询类型。当流式查询通过groupBy
转换执行聚合时,该聚合的状态由结构化流式引擎隐式维护。随着更多数据的到来,新数据的聚合结果会更新到结果表中。在每个触发点,结果表中的更新数据或所有数据都会写入数据宿,具体取决于输出模式。这意味着使用append
输出模式是不合适的,因为这违反了该输出模式的语义,该模式规定只有追加到结果表的新行才被发送到指定的输出接收器。换句话说,只有complete
和update
输出模式适合有状态查询类型。使用complete
输出模式的流式查询的输出总是等于或大于使用update
输出模式的同一流式查询的输出。清单 6-31 包含说明输出差异的代码。
// import statements
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val schema = new StructType().add("id", StringType, false)
.add("action", StringType, false)
.add("ts", TimestampType, false)
val mobileDF = spark.readStream.schema(schema)
.json("<path>/chapter6/data/input")
val actionCountDF = mobileDF.groupBy($"action").count
val completeModeSQ = actionCountDF.writeStream.format("console")
.option("truncate", "false")
.outputMode("complete")
.start()
val updateModeSQ = actionCountDF.writeStream.format("console")
.option("truncate", "false")
.outputMode("complete").start()
// at this point copy file1.json, file2.json, file3.json and newaction.json from
// mobile directory to the input directory
// the output of the streaming query with complete mode is below
-------------------------------------------
Batch: 3
-------------------------------------------
+--------+-------+
| action| count|
+--------+-------+
| close | 3 |
| swipe | 1 |
| crash | 1 |
| open | 5 |
+--------+-------+
// the output of the streaming query with update mode is below
-------------------------------------------
Batch: 3
-------------------------------------------
+-------+--------+
| action| count|
+-------+--------+
| swipe | 1 |
| crash | 1 |
+-------+--------+
Listing 6-31the Output Differences Between Update and Complete Mode
具有完整输出模式的流式查询的输出包含结果表中的所有动作类型。使用update
输出模式的流式查询的输出只包含结果表以前没有见过的newaction.json
文件中的动作。
同样,如果有状态查询类型使用了不合适的输出模式,结构化流引擎会让您知道,如清单 6-32 所示。
// use an inappropriate output for stateful streaming query, see exception below
val actionCountSQ = actionCountDF.writeStream.format("console")
.outputMode("append").start()
org.apache.spark.sql.AnalysisException: Append output mode not supported when there are streaming aggregations on streaming DataFrames/DataSets without watermark;
Listing 6-32Using an Inappropriate “Append” Output Mode with a Stateful Streaming Query
这个逻辑有一个例外。如果将水印提供给具有聚合的有状态流式传输查询,则所有输出模式都适用。不再违反附加输出的语义,因为结构化流引擎会丢弃比指定水印更旧的旧聚合状态数据,这意味着一旦越过水印,就可以向结果表中添加新行。
毫无疑问,输出模式是结构化流中最复杂的概念之一,因为多个维度共同决定了适合使用哪些输出模式。结构化流节目指南提供了一个兼容性矩阵,该矩阵位于 https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#output-modes
。
扳机
触发器设置确定结构化流式引擎何时运行流式查询中表达的流式计算逻辑,该逻辑包括所有转换逻辑并将数据写出到数据接收器。另一种思考方式是,触发器设置控制何时将数据写出到数据接收器,以及使用哪种处理模式。从 Spark 版本开始,引入了一种叫做连续的新加工模式。
“触发器类型”部分描述了结构化流中支持的类型。本节将更详细地介绍并提供一个指定不同触发器类型的示例代码。
所有的流查询示例都使用了默认的触发器类型,在没有指定触发器类型时使用。此默认触发器类型选择微批处理模式作为处理模式,并且流查询中的逻辑不是基于时间而是在前一批数据完成处理后立即执行。这意味着数据写出频率的可预测性较低。
如果需要更多一点的可预测性,那么可以指定固定间隔触发器来告诉结构化流以基于用户提供的值的特定时间间隔执行流查询逻辑,例如,每 30 秒。在处理方式上,这种触发器类型使用微批量类型。间隔可以用字符串格式或者 Scala Duration
或 Java TimeUnit
来指定。清单 6-33 包含使用固定间隔触发器的示例。
import org.apache.spark.sql.streaming.Trigger
// setting up with 3 rows per second
val ratesDF = spark.readStream.format("rate")
.option("rowsPerSecond","3")
.option("numPartitions","2")
.load()
// trigger the streaming query execution every 3 seconds and write out to console
val ratesSQ = ratesDF.writeStream.outputMode("append")
.format("console")
.option("numRows",50)
.option("truncate",false)
.trigger(Trigger.ProcessingTime("3 seconds"))
.start()
// you should expect to see about 9 rows in every 3 seconds
+---------------------------------+-------+
| timestamp | value|
+---------------------------------+-------+
| 2018-03-26 07:14:11.176| 0 |
| 2018-03-26 07:14:11.509| 1 |
| 2018-03-26 07:14:11.843| 2 |
| 2018-03-26 07:14:12.176| 3 |
| 2018-03-26 07:14:12.509| 4 |
| 2018-03-26 07:14:12.843| 5 |
| 2018-03-26 07:14:13.176| 6 |
| 2018-03-26 07:14:13.509| 7 |
| 2018-03-26 07:14:13.843| 8 |
+---------------------------------+-------+
// specifying the interval using Scala Duration type
import scala.concurrent.duration._
val ratesSQ = ratesDF.writeStream.outputMode("append")
.format("console")
.option("numRows",50)
.option("truncate",false)
.trigger(Trigger.ProcessingTime(3.seconds))
.start()
Listing 6-33Examples of Using Fixed Interval Trigger Type
固定间隔触发提供了最佳效果。它不能保证流查询的执行总是准确地发生在指定的内部。这有两个原因。第一种情况是当没有传入数据时,没有任何东西要处理,因此没有任何东西被写出数据接收器。第二个原因是,当前一个批处理的处理时间超过指定的时间间隔时,下一个流查询的执行会在处理完成后立即开始。换句话说,它不会等待下一个间隔边界。
一次性触发器确实如其名。它以微批处理模式执行流查询中的逻辑,并将数据写出到数据接收器一次,然后处理停止。这种触发器类型的存在听起来可能很傻;然而,它在开发和生产环境中都非常有用。而在开发阶段,流计算逻辑通常以迭代的方式开发,并且在每次迭代中,您都希望测试逻辑。这种触发器类型稍微简化了开发-测试迭代。对于生产环境,此触发器类型适用于传入流数据量较低的用例。因此,只需要每天运行几次流应用程序。与启动 Spark 集群并让它全天运行不同,每天启动 Spark 并执行流处理逻辑一次或多次的频率取决于您的特定用例所需的处理频率。指定这种一次性触发器类型非常简单。清单 6-34 展示了如何做到这一点。
import org.apache.spark.sql.streaming.Trigger
val mobileSQ = mobileDF.writeStream.outputMode("append")
.format("console")
.trigger(Trigger.Once())
.start()
Listing 6-34Example of Using One-Time Trigger Type
连续触发类型是 Spark 版本 2.3 中引入的一种令人兴奋的新实验处理模式,旨在解决需要端到端流毫秒延迟的用例。在这种新的处理模式中,结构化流启动长时间运行的任务,以连续读取、处理数据并将数据写入数据接收器。这意味着传入的数据一到达数据源就被处理并写出到数据接收器,端到端的延迟在几毫秒之内。此外,引入了异步检查点机制来有效地记录流查询进度,以避免中断长时间运行的任务,从而提供一致的毫秒级延迟。利用这种触发类型的一个很好的用例是信用卡欺诈交易检测。在高层次上,结构化流引擎根据触发器类型来确定使用哪种处理模式,如图 6-10 所示。
图 6-10
结构化流支持两种不同的处理模式
从 Spark 版开始,在连续处理模式下只允许投影和选择操作,如选择、位置、贴图、平面贴图和过滤。在这种处理模式下,除聚合函数外,所有 Spark SQL 函数都受支持。
要对流式查询使用连续处理模式,您必须指定一个具有所需检查点间隔的连续触发器,如清单 6-35 所示。
import org.apache.spark.sql.streaming.Trigger
// setting a Rate data source with two partitions
val ratesDF = spark.readStream.format("rate")
.option("numPartitions","2").load()
// write out the data to console and using continuous trigger with 2 second interval for writing out progress
val rateSQ = ratesDF.writeStream.format("console")
.trigger(Trigger.Continuous("2 second"))
.start()
// sample output from console
+--------------------------+-------+
| timestamp| value|
+--------------------------+-------+
| 2018-03-26 21:43:...| 0|
| 2018-03-26 21:43:...| 2|
| 2018-03-26 21:43:...| 4|
| 2018-03-26 21:43:...| 6|
| 2018-03-26 21:43:...| 1|
| 2018-03-26 21:43:...| 3|
| 2018-03-26 21:43:...| 5|
| 2018-03-26 21:43:...| 7|
+--------------------------+-------+
Listing 6-35Examples of Specifying a Continuous Trigger Type
ratesDF 流数据帧设置有两个分区;因此,结构化流以连续处理模式启动了两个运行任务。这就是为什么输出显示所有偶数一起出现,所有奇数一起出现。
摘要
结构化流是 Apache Spark 的第二代流处理引擎。它提供了一种简单的方法来构建和推理容错和可伸缩的流应用程序。本章涵盖了很多内容,包括流处理领域的核心概念和结构化流的关键部分。
-
流处理是一个令人兴奋的领域,可以帮助解决大数据时代许多新的有趣的用例。
-
由于无界数据的性质以及数据到达率和无序数据的不可预测性,构建生产流数据应用程序比构建批量数据处理应用程序更具挑战性。
-
为了有效地构建流数据应用程序,您必须熟悉流处理领域的三个核心概念。它们是数据交付语义、时间概念和窗口。
-
在过去的几年里,流处理引擎已经急剧成熟,现在有许多选项可供选择。比较流行的有阿帕奇 Flink,阿帕奇 Samza,阿帕奇 Kafka,阿帕奇 Spark。
-
Spark DStream 是 Apache Spark 的第一代流处理引擎。它是建立在 RDD 编程模型之上的。
-
结构化流处理引擎旨在帮助开发人员构建端到端流应用程序,这些应用程序可以使用简单的编程模型对数据做出实时反应,该模型构建在 Spark SQL 引擎的优化和坚实基础之上。
-
结构化流的独特思想是将流数据视为一个无界输入表,当新数据到达时,它会将其视为一组新的新行,以添加到无界表中。
-
流式查询的核心组件是数据源、流式操作、输出模式、触发器和数据接收器。
-
结构化流提供了一组内置数据源和数据接收器。内置的数据源是文件、Kafka、套接字和速率。内置的数据接收器是文件、Kafka、控制台和内存。
-
输出模式决定数据如何输出到数据接收器。有三个选项:追加、更新和完成。
-
触发器是结构化流式引擎确定何时运行流式计算的机制。有几个选项可供选择:微批量、固定间隔微批量、一次性微批量和连续。最后一个是需要毫秒级延迟的用例。从 Spark 版本开始,它就处于试验状态。*
七、高级 Spark 流
第六章介绍了流处理的核心概念、Spark 结构化流处理引擎的特性以及开发流应用的基本步骤。现实世界的流应用程序通常需要从大规模传入的实时数据中提取见解或模式,并将这些信息提供给下游应用程序,以做出业务决策或将这些信息保存在某个存储系统中,以供进一步分析或可视化。现实世界流应用程序的另一个方面是,它们持续运行以处理实时数据。因此,他们必须对失败有弹性。
本章的前半部分介绍了结构化流中的事件时间处理和有状态处理功能,以及它们如何帮助从传入的实时数据中提取洞察力或模式。本章的后半部分解释了结构化流提供的支持,以帮助流应用程序对故障具有容错能力,并监控它们的状态和进度。
事件时间
基于数据创建时间处理输入流数据的能力是任何严肃的流处理引擎必须具备的特性。这很重要,因为要真正理解并准确地从流数据中提取见解或模式。您需要基于数据或事件发生的时间来处理它们,而不是基于它们被处理的时间。通常,事件时间处理是在聚合的上下文中进行的,聚合包括事件时间和事件中的零个或多个附加信息。
让我们以第六章描述的移动动作事件为例。您可以在一个时间窗口内应用聚合,而不是在动作类型上应用聚合,该时间窗口可以是固定的或滑动的窗口类型(在第六章中描述)。此外,您可以轻松地将动作类型添加到分组键中,以便根据时间段和动作类型进一步对移动动作事件进行分组。
以下示例处理移动数据事件;清单 7-1 显示了它的模式。ts
列表示事件创建的时间,换句话说,就是用户打开或关闭应用程序的时间。移动事件数据位于<path>/chapter6/data/mobile
目录下,包含file1.json
、file2.json
、file3.json
和newaction.json
。清单 7-2 显示了每个文件中的内容。
// file1.json
{"id":"phone1","action":"open","ts":"2018-03-02T10:02:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:03:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:03:50"}
{"id":"phone1","action":"close","ts":"2018-03-02T10:04:35"}
// file2.json
{"id":"phone3","action":"close","ts":"2018-03-02T10:07:35"}
{"id":"phone4","action":"open","ts":"2018-03-02T10:07:50"}
// file3.json
{"id":"phone2","action":"close","ts":"2018-03-02T10:04:50"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:10:50"}
// newaction.json
{"id":"phone2","action":"crash","ts":"2018-03-02T11:09:13"}
{"id":"phone5","action":"swipe","ts":"2018-03-02T11:17:29"}
Listing 7-2Mobile Event Data in file1.json, file2.json, file3.json, newaction.json
mobileDataDF.printSchema
|-- action: string (nullable = true)
|-- id: string (nullable = true)
|-- ts: timestamp (nullable = true)
Listing 7-1Mobile Data Event Schema
事件时间内的固定窗口聚合
固定窗口(也称为滚动窗口)操作基于窗口长度将传入数据流离散化为不重叠的桶。每个传入数据都根据其事件时间放入一个桶中。执行聚合就是遍历每个存储桶并应用聚合逻辑,无论是计数还是求和。图 7-1 说明了固定窗口聚合逻辑。
图 7-1
固定窗口操作
固定窗口聚合的一个例子是对每个 10 分钟长的固定窗口执行移动事件数量的计数聚合。窗口长度通常由特定用例的需求和数据量决定。通过聚合结果,您可以深入了解每个窗口生成的移动事件的比率。如果您对全天和每小时的移动使用感兴趣,那么 60 分钟的窗口长度可能更合适。清单 7-3 包含执行计数聚合和聚合结果的代码。正如所料,在列出的所有四个文件中只有十个移动数据事件,输出中的总计数与该数字相匹配。
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val mobileDataSchema = new StructType()
.add("id", StringType, false)
.add("action", StringType, false)
.add("ts", TimestampType, false)
val mobileSSDF = spark.readStream.schema(mobileDataSchema)
.json("<path>/chapter6/data/input")
val windowCountDF = mobileSSDF.groupBy(
window($"ts", "10 minutes"))
.count()
val mobileConsoleSQ = windowCountDF.writeStream.format("console")
.option("truncate", "false")
.outputMode("complete")
.start()
// stop the streaming query
mobileConsoleSQ.stop
// output
+------------------------------------------------------+-------+
| window | count|
+------------------------------------------------------+-------+
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| 7|
| [2018-03-02 10:10:00, 2018-03-02 10:20:00]| 1|
| [2018-03-02 11:00:00, 2018-03-02 11:10:00]| 1|
| [2018-03-02 11:10:00, 2018-03-02 11:20:00]| 1|
+------------------------------------------------------+-------+
windowCountDF.printSchema
|-- window: struct (nullable = false)
| |-- start: timestamp (nullable = true)
| |-- end: timestamp (nullable = true)
|-- count: long (nullable = false)
Listing 7-3Process Mobile Event Data with a 10 Minute Window
当使用窗口执行聚合时,输出窗口是一个结构类型,它包含开始和结束时间。
除了在groupBy
转换中指定一个窗口之外,您还可以从事件本身指定额外的列。清单 7-4 使用窗口长度和动作执行聚合。这为每个窗口和动作类型的计数提供了额外的见解。只需对前面的示例做一点小小的修改就可以实现这一点。清单 7-4 只包含需要修改的行。
val windActDF= mobileSSDF.groupBy(
window($"ts", "10 minutes"), $"action").count
val windActDFSQ = windActDF.writeStream.format("console")
.option("truncate", "false")
.outputMode("complete")
.start()
// result
+----------------------------------------------+--------+-------+
| window | action| count|
+----------------------------------------------+--------+-------+
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| close | 3|
| [2018-03-02 11:00:00, 2018-03-02 11:10:00]| crash | 1|
| [2018-03-02 11:10:00, 2018-03-02 11:20:00]| swipe | 1|
| [2018-03-02 10:00:00, 2018-03-02 10:10:00]| open | 4|
| [2018-03-02 10:10:00, 2018-03-02 10:20:00]| open | 1|
+----------------------------------------------+--------+-------+
// stop the query stream
windowActionCountSQ.stop()
Listing 7-4Process Mobile Event Data with a 10 Minute Window and Action Type
该结果表中的每一行都包含关于每个 10 分钟窗口中动作类型数量的信息。如果在某个窗口中有许多崩溃动作,那么如果在那个时间范围内有一个发布,那么这种洞察力是有用的。
事件时间内的滑动窗口聚合
除了固定窗口类型,还有一种开窗类型叫做滑动窗口。定义滑动窗口需要两条信息,窗口长度和滑动间隔,滑动间隔通常小于窗口长度。假设聚合计算在传入的数据流上滑动,结果通常比固定窗口类型的结果更平滑。因此,这种窗口类型通常用于计算移动平均值。关于滑动窗口需要注意的一件重要事情是,由于重叠,一条数据可能落入多个窗口,如图 7-2 所示。
图 7-2
固定窗口操作
为了说明传入数据的滑动窗口聚合,您使用了关于数据中心计算机机架温度的小型合成数据。想象一下,每一个计算机机架都以一定的间隔发出它的温度。您希望生成一份关于所有计算机机架和每个机架在 10 分钟窗口长度和 5 分钟滑动间隔内的平均温度的报告。这个数据集在<path>/chapter7/data/iot
目录中,其中包含file1.json
和file2.json
。温度数据如清单 7-5 所示。
// file1.json
{"rack":"rack1","temperature":99.5,"ts":"2017-06-02T08:01:01"}
{"rack":"rack1","temperature":100.5,"ts":"2017-06-02T08:06:02"}
{"rack":"rack1","temperature":101.0,"ts":"2017-06-02T08:11:03"}
{"rack":"rack1","temperature":102.0,"ts":"2017-06-02T08:16:04"}
// file2.json
{"rack":"rack2","temperature":99.5,"ts":"2017-06-02T08:01:02"}
{"rack":"rack2","temperature":105.5,"ts":"2017-06-02T08:06:04"}
{"rack":"rack2","temperature":104.0,"ts":"2017-06-02T08:11:06"}
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:16:08"}
Listing 7-5Temperature Data of Two Racks
清单 7-6 首先读取温度数据,然后在ts
列的滑动窗口上执行groupBy
转换。对于每个滑动窗口,将avg()
功能应用于温度栏。为了便于检查输出,它使用查询名iot
将数据写出到内存数据接收器。然后,您可以对这个临时表发出 SQL 查询。
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
// define schema
val iotSchema = new StructType().add("rack", StringType, false)
.add("temperature",
DoubleType, false)
.add("ts", TimestampType, false)
val iotSSDF = spark.readStream.schema(iotSchema)
.json("<path>/chapter7/data/iot")
// group by a sliding window and perform average on the temperature column
val iotAvgDF = iotSSDF.groupBy(window($"ts",
10 minutes", "5 minutes"))
.agg(avg("temperature") as "avg_temp")
// write the data out to memory sink with query name as iot
val iotMemorySQ = iotAvgDF.writeStream.format("memory")
.queryName("iot")
.outputMode("complete")
.start()
// display the data in the order of start time
spark.sql("select * from iot")
.orderBy($"window.start").show(false)
// output
+------------------------------------------------+-------------+
| window | avg_temp|
+------------------------------------------------+-------------+
| [2017-06-02 07:55:00, 2017-06-02 08:05:00]| 99.5|
| [2017-06-02 08:00:00, 2017-06-02 08:10:00]| 101.25|
| [2017-06-02 08:05:00, 2017-06-02 08:15:00]| 102.75|
| [2017-06-02 08:10:00, 2017-06-02 08:20:00]| 103.75|
| [2017-06-02 08:15:00, 2017-06-02 08:25:00]| 105.0|
+------------------------------------------------+-------------+
// stop the streaming query
iotMemorySQ.stop
Listing 7-6Average Temperature of All the Computer Racks over a Sliding Window
该输出显示了合成数据集中的五个窗口。注意,由于您在groupBy
转换中指定的滑动间隔的长度,每个窗口的开始时间相隔五分钟。温度栏显示平均温度正在上升,这是令人担忧的。不清楚是所有计算机机架的温度都在升高,还是只有某些机架的温度在升高。
为了帮助识别哪些计算机机架,清单 7-7 将机架列添加到 groupBy 转换中,它只显示与清单 7-6 中不同的行。
// group by a sliding window and rack column
val iotAvgByRackDF = iotSSDF.groupBy(
window($"ts", "10 minutes", "5 minutes"),
$"rack")
.agg(avg("temperature") as "avg_temp")
// write out to memory data sink with iot_rack query name
val iotByRackConsoleSQ = iotAvgByRackDF.writeStream
.format("memory")
.queryName("iot_rack")
.outputMode("complete")
.start()
spark.sql("select * from iot_rack").orderBy($"rack",
$"window.start").show(false)
+------------------------------------------+-------+------------+
| window | rack | avg_temp|
+------------------------------------------+-------+------------+
|[2017-06-02 07:55:00, 2017-06-02 08:05:00]| rack1| 99.5|
|[2017-06-02 08:00:00, 2017-06-02 08:10:00]| rack1| 100.0|
|[2017-06-02 08:05:00, 2017-06-02 08:15:00]| rack1| 100.75|
|[2017-06-02 08:10:00, 2017-06-02 08:20:00]| rack1| 101.5|
|[2017-06-02 08:15:00, 2017-06-02 08:25:00]| rack1| 102.0|
|[2017-06-02 07:55:00, 2017-06-02 08:05:00]| rack2| 99.5|
|[2017-06-02 08:00:00, 2017-06-02 08:10:00]| rack2| 102.5|
|[2017-06-02 08:05:00, 2017-06-02 08:15:00]| rack2| 104.75|
|[2017-06-02 08:10:00, 2017-06-02 08:20:00]| rack2| 106.0|
|[2017-06-02 08:15:00, 2017-06-02 08:25:00]| rack2| 108.0|
+------------------------------------------+-------+------------+
// stop query stream
iotByRackConsoleSQ.stop()
Listing 7-7Average Temperature of Each Rack over a Sliding Window
输出表清楚地显示机架 1 的平均温度低于 103,您应该关注的是机架 2。
聚集状态
前面使用事件时间和附加列对固定或滑动窗口执行聚合的示例显示了在 Spark 结构化流中执行常用和复杂的流处理操作是多么容易。虽然从使用的角度来看这似乎很容易,但结构流引擎和 Spark SQL 引擎都努力工作并协同工作,以便在执行流聚合时以容错的方式维护中间聚合结果。每当对流式查询执行聚合时,必须维护中间聚合状态。这种状态在键-值对结构中维护,类似于哈希映射,其中键是组名,值是中间聚合值。在前面按滑动窗口和机架 ID 聚合的示例中,关键字是窗口的开始和结束时间以及机架名称的组合值,该值是平均温度。
中间状态存储在 Spark 执行器的内存中版本化的键/值“状态存储”中。它被写入预写日志,该日志应该被配置为驻留在像 HDFS 这样的持久性存储系统上。在每个触发点读取并更新内存“状态存储”中的状态,然后写入预写日志。当 Spark 结构化流应用程序重启失败后,状态将从预写日志中恢复,并从该点继续。这种容错状态管理会在结构化流引擎中引起一些资源和处理开销。开销的数量与它需要维护的状态数量成正比。因此,将状态的数量保持在可接受的大小是很重要的;换句话说,政府的规模不应该无限扩大。
鉴于滑动窗口的性质,窗口的数量会无限增长。这意味着滑动窗口聚合要求中间状态无限增长,除非有办法删除不再更新的旧状态。这是使用一种叫做水印的技术完成的。
水印:限制状态并处理后期数据
水印是流处理引擎中常用的技术,用于处理最新数据并限制需要维护的状态数量。现实世界中的流数据经常因为网络拥塞、网络中断或移动设备等数据生成器不在线而无序或延迟到达。作为实时流应用程序的开发人员,理解在处理某个阈值之后到达的最新数据时的权衡决策是很重要的。换句话说,相对于其他数据,您认为大部分数据到达的可接受时间是多少?最有可能的是,前一个问题的答案是它取决于用例。将最新的数据丢在地上并忽略它们可能是可以接受的。
从结构化流的角度来看,水印是事件时间中的移动阈值,落后于迄今为止所见的最大事件时间。当新数据到达时,最大事件时间被更新,这导致水印移动。图 7-3 举例说明了水印定义为十分钟的情况。实线代表水印线。它落后于最大事件时间线,由虚线表示。每个矩形框代表一段数据,它的事件时间就在框的下面。事件时间为 10:07 的数据到达得稍晚一些,大约在 10:12;然而,它仍然落在 10:03 和 10:13 之间的阈值内。因此它被照常处理。事件时间为 10:15 的数据属于同一类别。事件时间为 10:04 的数据到达较晚,大约在 10:22,这低于水位线,因此被忽略,不进行处理。
图 7-3
用水印处理过期日期
指定水印的最大好处之一是使结构流引擎能够安全地删除比水印旧的聚合状态。执行聚合的生产流应用程序应该指定一个水印,以避免内存不足问题。毫无疑问,水印是处理实时流数据中混乱部分的必要工具。
结构化流使得将水印指定为流数据帧的一部分变得非常容易。您只需要向withWatermark
API 提供两个数据,事件时间列和阈值,可以是秒、分钟或小时。为了演示水印的作用,您可以通过一个简单的例子来处理<path>/chapter7/data/mobile
目录中的两个 JSON 文件,并将水印指定为 10 分钟。清单 7-8 显示了这两个文件中的数据。数据的设置使得file1.json
文件中的每一行都落在自己的 10 分钟窗口内。file2.json
文件中的第一行落在 10:20:00 到 10:30:00 的窗口中,尽管它到达得较晚,但它的时间戳仍在可接受的阈值内,因此它被处理。file2.json 文件中的最后一行是对最新数据的模拟,其时间戳在 10:10:00 到 10:20:00 窗口内,由于超出了水印阈值,因此将被忽略,不予处理。
// file1.json
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:33:50"}
// file2.json
{"id":"phone4","action":"open","ts":"2018-03-02T10:29:35"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:11:35"}
Listing 7-8Mobile Event Data in Two JSON Files
为了模拟处理过程,首先在<path>/chapter7/data
目录下创建一个名为input
的目录。然后运行清单 7-9 中的代码。下一步是将file1.json
文件复制到input
目录并检查输出。最后一步是将file2.json
文件复制到输入目录并检查输出。
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val mobileSchema = new StructType().add("id", StringType, false)
.add("action", StringType, false)
.add("ts", TimestampType, false)
val mobileSSDF = spark.readStream.schema(mobileSchema)
.json("<path>/book/chapter7/data/input")
// setup a streaming DataFrame with a watermark and group by ts and action column.
val windowCountDF = mobileSSDF.withWatermark("ts", "10 minutes")
.groupBy(window($"ts",
"10 minutes"), $"action")
.count
val mobileMemorySQ = windowCountDF.writeStream
.format("console")
.option("truncate", "false")
.outputMode("update")
.start()
// the output from processing filel1.json
// as expected each row falls into its own window
+----------------------------------------------+--------+-------+
| window | action| count|
+----------------------------------------------+--------+-------+
| [2018-03-02 10:20:00, 2018-03-02 10:30:00]| open | 1 |
| [2018-03-02 10:30:00, 2018-03-02 10:40:00]| open | 1 |
| [2018-03-02 10:10:00, 2018-03-02 10:20:00]| open | 1 |
+----------------------------------------------+--------+-------+
// the output from processing file2.json
// notice the count for window 10:20 to 10:30 is now updated to 2
// and there was no change to the window 10:10:00 and 10:20:00
+----------------------------------------------+--------+-------+
| window | action | count|
+----------------------------------------------+--------+-------+
| [2018-03-02 10:20:00, 2018-03-02 10:30:00]| open | 2 |
+----------------------------------------------+--------+-------+
Listing 7-9Code for Processing Mobile Data Events with Late Arrival
由于file2.json
文件中最后一行的时间戳超出了 10 分钟的水印阈值,因此未对其进行处理。如果删除对 watermark API 的调用,输出看起来类似于清单 7-10 。窗口 10:10 和 10:20 的计数更新为 2。
+----------------------------------------------+--------+-------+
| window | action| count|
+----------------------------------------------+--------+-------+
| [2018-03-02 10:20:00, 2018-03-02 10:30:00]| open | 2|
| [2018-03-02 10:10:00, 2018-03-02 10:20:00]| open | 2|
+----------------------------------------------+--------+-------+
Listing 7-10Output of Removing the Call to Watermark API
水印是一个有用的特性,因此了解正确清理聚合状态的条件非常重要。
-
输出模式不能是完整模式,必须是更新或追加模式。原因是完整模式的语义规定必须维护所有聚合数据,并且不能违反这些语义;水印不能掉任何中间状态。
-
通过
groupBy
转换的聚合必须直接在事件时间列或事件时间列的窗口上进行。 -
水印 API 和
groupBy
转换中指定的事件时间列必须是同一个。 -
当设置一个流数据帧时,必须在
groupBy
转换之前调用水印 API 否则,它将被忽略。
任意状态处理
按键或事件窗口聚合的中间状态由结构化流自动维护。但是,并不是所有基于事件时间的处理都可以通过简单地在一个或多个列上进行聚合来满足,并且可以使用或不使用窗口。例如,当物联网温度数据集中连续三次出现大于 100 度的温度读数时,您希望发送警报、电子邮件或寻呼机。
维护用户会话是另一个例子,其中会话长度不是由固定的时间量决定的,而是由用户的活动和缺少活动决定的。为了解决这两个例子和类似的用例,您需要对每组数据应用任意的处理逻辑,控制每组数据的窗口长度,并在触发点之间保持任意的状态。这就是结构化流任意状态处理的用武之地。
具有结构化流的任意状态处理
为了实现灵活和任意的有状态处理,结构化流提供了回调机制。它负责确保以容错方式维护和存储中间状态。回调机制使您能够为自定义状态管理逻辑提供用户定义的函数,结构化流在适当的时候调用它。这种处理方式本质上归结为执行以下两个任务之一的能力,如图 7-4 所示。
-
映射多组数据,对每组数据应用任意处理,并且每组只生成一行。
-
映射数据组,对每个组应用任意处理,并为每个组生成任意数量的行,包括无行。
结构化流为每项任务提供了特定的 API。对于第一个任务,这个 API 叫做mapGroupsWithState
,
,对于第二个任务,这个 API 叫做flatMapGroupsWithState
。这些 API 从 Spark 2.2 开始可用,并且只在 Scala 和 Java 中可用。
图 7-4
两个任意状态处理任务的可视化描述
在使用回调机制时,清楚地理解框架和回调函数之间关于输入参数的约定,以及何时调用和多久调用一次是很重要的。在这种情况下,顺序如下。
-
要在流数据帧上执行任意有状态处理,必须首先通过调用
groupByKey
转换指定分组,并提供 group by 列;然后它返回一个KeyValueGroupedDataset
类的实例。 -
从一个
KeyValueGroupedDataset
类的实例中,你可以调用mapGroupsWithState
或者flatMapGroupsWithState
函数。每个 API 需要一组不同的输入参数。 -
调用
mapGroupsWithState
函数时,需要提供超时类型和自定义回调函数。超时部分稍后解释。 -
调用
flatMapGroupsWithState
函数时,需要提供一个输出模式,超时类型,以及一个用户自定义的回调函数。输出模式和超时部分将在稍后解释。
以下是结构化流和用户定义的回调函数之间的约定。
-
用户定义的回调函数在每个触发器中为每个组调用一次。在每次调用中,它意味着触发器中有数据的每个组。如果一个特定的组在触发器中没有任何数据,就没有调用。因此,您不应该假设在每个组的每个触发器中都调用了该函数。
-
每次调用用户定义的回调函数时,都会传递以下信息。
-
组密钥的值。
-
一个组的所有数据;不能保证它们有任何特定的顺序。
-
组的先前状态,由同一组的先前调用返回。组状态由名为
GroupState
的状态持有者类管理。当需要更新一个组的状态时,必须用新的状态调用这个类的 update 函数。用户定义的类定义了每个组的状态信息。调用更新函数时,提供的自定义状态不能为空。
-
正如你在第六章中了解到的,只要需要保持中间状态,就只允许某些输出模式。从 Spark 2.3 开始,调用 API mapGroupsWithState
API 时只支持更新输出模式;但是,在调用flatMapGroupsWithState
API 时,追加和更新模式都受支持。
处理状态超时
在使用水印的事件时间聚合的情况下,中间状态的超时由结构化流在内部管理,没有任何方法可以影响它。另一方面,结构化流的任意状态处理提供了控制中间状态超时的灵活性。因为您可以维护任意状态,所以应用程序逻辑控制中间状态超时以满足特定用例是有意义的。
结构流状态处理提供了三种不同的超时类型。第一个基于处理时间,第二个基于事件时间。超时类型是在全局级别配置的,这意味着它适用于特定流数据帧内的所有组。可以为每个单独的组配置超时时间,并且可以随意更改。如果中间状态配置了超时,那么在处理回调函数中给定的值列表之前检查它是否超时是很重要的。在某些用例中不需要超时,第三种超时类型就是为这种场景设计的。超时类型在GroupStateTimeout
类中定义。您在调用mapGroupsWithState
或flatMapGroupsWithState
函数时指定类型。分别使用处理超时和事件超时的GroupState.setTimeoutDuration
或GroupState.setTimeoutTimeStamp
功能指定超时持续时间。
敏锐的读者可能想知道当一个特定群体的中间状态超时时会发生什么。流提供的关于这种情况的契约结构是,它使用空值列表调用用户定义的回调函数,并将标志GroupState.hasTimedOut
设置为 true。
在这三种超时类型中,事件时间超时是最复杂的一种,首先介绍。事件时间超时意味着它基于事件中的时间,因此需要通过DataFrame.withWatermark
在流数据帧中设置水印。为了控制每个组的超时,您需要在处理特定组的过程中为GroupState.setTimeoutTimestamp
函数提供一个时间戳值。当水印前进超过提供的时间戳时,组的中间状态超时。在用户会话化用例中,当用户与您的网站交互时,只需根据用户的最新交互时间加上某个阈值更新超时时间戳,会话就会延长。这确保了只要用户与您的网站交互,用户会话就保持活动,并且中间数据不会超时。
处理超时类型的工作方式类似于事件时间超时类型;不过不同的是,它是基于服务器的挂钟,是不断前进的。为了控制每个组的超时,您可以在处理特定组的过程中为GroupState.setTimeoutDuration
函数提供一个持续时间。持续时间可以是一分钟、一小时或两天。当时钟前进超过规定的持续时间时,组的中间状态超时。由于这种超时类型取决于系统时钟,因此考虑时区变化或时钟偏差时的情况非常重要。
对于敏锐的读者来说,这可能是显而易见的,但重要的是要认识到,当流中暂时没有传入数据时,不会调用用户定义的回调函数。另外水印不前进,超时函数调用不发生。
至此,您应该对结构化流中的任意状态处理是如何工作的以及涉及到哪些 API 有了很好的理解。下一节将通过几个例子演示如何实现任意状态处理。
行动中的任意状态处理
本节通过两个用例演示了结构化流中的任意状态处理。
-
第一个是关于从数据中心计算机机架温度数据中提取模式,并将每个机架的状态保持在中间状态。每当遇到连续三个 100 度或更高的温度时,机架状态就会升级到警告级别。这个例子使用了
mapGroupsWithState
API。 -
第二个例子是用户会话化,它根据用户与网站的交互来跟踪用户状态。这个例子使用了
flatMapGroupsWithState
API。
不管哪个 API 执行任意状态处理,都需要一组通用的设置步骤。
-
定义几个类来表示输入数据、中间状态和输出。
-
定义两个函数。第一个是结构化流调用的回调函数。第二个功能包含对每组数据的任意状态处理逻辑以及维护状态的逻辑。
-
决定超时类型及其合适的值。
使用 mapGroupsWithState 提取模式
此使用案例旨在识别数据中心计算机机架温度数据中的特定模式。感兴趣的模式是来自同一机架的 100 度或更高的三个连续温度读数。两个连续高温读数之间的时间差必须在 60 秒以内。当检测到这种模式时,该机架的状态被升级为警告状态。如果下一个输入温度读数低于 100 度阈值,机架状态将降级为正常。
本例的数据在<path>/chapter7/data/iot_pattern
目录中,该目录由三个文件组成,它们的内容如清单 7-11 所示。file1.json
的内容显示rack1
的温度在 100 度上下变化。file2.json
文件显示 rack2 的温度正在上升。在file3.json
文件中,rack3 正在升温,但温度读数相差超过一分钟。
// file1.json
{"rack":"rack1","temperature":99.5,"ts":"2017-06-02T08:01:01"}
{"rack":"rack1","temperature":100.5,"ts":"2017-06-02T08:02:02"}
{"rack":"rack1","temperature":98.3,"ts":"2017-06-02T08:02:29"}
{"rack":"rack1","temperature":102.0,"ts":"2017-06-02T08:02:44"}
// file2.json
{"rack":"rack1","temperature":97.5,"ts":"2017-06-02T08:02:59"}
{"rack":"rack2","temperature":99.5,"ts":"2017-06-02T08:03:02"}
{"rack":"rack2","temperature":105.5,"ts":"2017-06-02T08:03:44"}
{"rack":"rack2","temperature":104.0,"ts":"2017-06-02T08:04:06"}
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:04:49"}
// file3.json
{"rack":"rack2","temperature":108.0,"ts":"2017-06-02T08:06:40"}
{"rack":"rack3","temperature":100.5,"ts":"2017-06-02T08:06:20"}
{"rack":"rack3","temperature":103.7,"ts":"2017-06-02T08:07:35"}
{"rack":"rack3","temperature":105.3,"ts":"2017-06-02T08:08:53"}
Listing 7-11Temperature Data in file1.json, file2.json and file3.json
接下来,准备几个类和两个函数,将模式检测逻辑应用于前面的数据。对于这个用例,机架温度数据输入数据由RackInfo
类表示,中间状态和输出由同一个名为RackState.
的类表示。清单 7-12 显示了代码。
case class RackInfo(rack:String, temperature:Double,
ts:java.sql.Timestamp)
// notice the constructor arguments are defined to be modifiable so you can update them
// the lastTS variable is used to compare the time between previous and current temperature reading
case class RackState(var rackId:String, var highTempCount:Int,
var status:String,
var lastTS:java.sql.Timestamp)
Listing 7-12Scala Case Classes for the Input and Intermediate State
接下来,定义两个函数。第一个称为updateRackState
,它包含了在一定时间内对三个连续温度读数进行事件模式检测的核心逻辑。第二个函数是updateAcrossAllRackStatus
,它是传入mapGroupsWithState
API 的回调函数。它确保根据事件时间的顺序处理机架温度读数。清单 7-13 是代码。
import org.apache.spark.sql.streaming.GroupState
// contains the main logic to detect the temperature pattern described above
def updateRackState(rackState:RackState, rackInfo:RackInfo) : RackState = {
// setup the conditions to decide whether to update the rack state
val lastTS = Option(rackState.lastTS).getOrElse(rackInfo.ts)
val withinTimeThreshold = (rackInfo.ts.getTime -
lastTS.getTime) <= 60000
val meetCondition = if (rackState.highTempCount < 1) true
else withinTimeThreshold
val greaterThanEqualTo100 = rackInfo.temperature >= 100.0
(greaterThanEqualTo100, meetCondition) match {
case (true, true) => {
rackState.highTempCount = rackState.highTempCount + 1
rackState.status = if (rackState.highTempCount >= 3)
"Warning" else "Normal"
}
case _ => {
rackState.highTempCount = 0
rackState.status = "Normal"
}
}
rackState.lastTS = rackInfo.ts
rackState
}
// call-back function to provide mapGroupsWithState API
def updateAcrossAllRackStatus(rackId:String,
inputs:Iterator[RackInfo],
oldState: GroupState[RackState]) : RackState = {
// initialize rackState with previous state if exists, otherwise create a new state
var rackState = if (oldState.exists) oldState.get
else RackState(rackId, 5, "", null)
// sort the inputs by timestamp in ascending order
inputs.toList.sortBy(_.ts.getTime).foreach( input => {
rackState = updateRackState(rackState, input)
// very important to update the rackState in the state holder class GroupState
oldState.update(rackState)
})
rackState
}
Listing 7-13the Functions for Performing Pattern Detection
设置步骤现在已经完成,现在您将回调函数连接到清单 7-14 中的结构化流应用程序的mapGroupsWithState
中。模拟流数据的步骤与前面的示例相似,如下所示。
-
在<目录下创建一个名为
input
的目录。如果该目录已经存在,请删除该目录中的所有文件。 -
运行清单 7-14 中的代码。
-
将
file1.json
复制到输入目录,然后观察输出。对file2.json
和file3.json
重复相同的步骤。
import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode}
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
// schema for the IoT data
val iotDataSchema = new StructType()
.add("rack",StringType, false)
.add("temperature", DoubleType, false)
.add("ts", TimestampType, false)
val iotSSDF = spark.readStream.schema(iotDataSchema)
.json("<path>/chapter7/data/input")
val iotPatDF = iotSSDF.as[RackInfo].groupByKey(_.rack)
.mapGroupsWithState[RackState,RackState]
(GroupStateTimeout.NoTimeout)(updateAcrossAllRackStatus)
// setup the output and start the streaming query
val iotPatternSQ = iotPatDF.writeStream.format("console")
.outputMode("update")
.start()
// after file3.json is copied over to "input" directory, run the line below stop the streaming query
iotPatternSQ.stop
// the output after processing file1.json
+--------+---------------------+----------+---------------------+
| rackId| highTempCount| status| lastTS|
+--------+---------------------+----------+---------------------+
| rack1| 1| Normal| 2017-06-02 08:02:44|
+--------+---------------------+----------+---------------------+
// the output after processing file2.json
+--------+---------------------+-----------+--------------------+
| rackId| highTempCount| status| lastTS|
+--------+---------------------+-----------+--------------------+
| rack1| 0| Normal| 2017-06-02 08:02:59|
| rack2| 3| Warning| 2017-06-02 08:04:49|
+--------+---------------------+-----------+--------------------+
// the output after processing file3.json
+--------+---------------------+----------+---------------------+
| rackId| highTempCount| status| lastTS|
+--------+---------------------+----------+---------------------+
| rack3| 1| Normal| 2017-06-02 08:08:53|
| rack2| 0| Normal| 2017-06-02 08:06:40|
+--------+---------------------+----------+---------------------+
Listing 7-14Using Arbitrary State Processing to Detect Patterns in a Streaming Application
rack1 有几个温度读数超过 100 度;然而,它们不是连续的,因此输出状态处于正常水平。file2.json
档中,rack2 连续三次温度读数超过 100 度,每一次与前一次的时间差距小于 60 秒;因此,机架 2 的状态处于警告级别。rack3 连续三次温度读数超过 100 度;但是每一个和之前的时间差距都在 60 秒以上;因此,其状态处于正常水平。
使用 flatMapGroupsWithState 的用户会话化
这个用例使用flatMapGroupsWithState
API 执行用户会话化,它支持每组输出多行的能力。在本例中,会话化处理逻辑基于用户活动。当采取login
动作时,创建一个会话。当采取logout
动作时,会话结束。如果 30 分钟内没有用户活动,会话将自动结束。您可以利用超时特性来执行这种检测。每当会话开始或结束时,该信息都会发送到输出。输出信息包括用户 id、会话开始和结束时间以及访问的页面数量。
这个用例的数据在<path>/chapter7/data/sessionization
目录中,它有三个文件。它们的内容如清单 7-15 所示。file1.json
文件包含 user1 的活动,并且包含一个login
动作,但是没有logout
动作。file2.json
文件包含用户 2 ,
的所有活动,包括login
和logout
动作。file3.json
文件只包含用户 3 的login
动作。设置三个文件中用户活动的时间戳,以便在处理file3.json
时 user1 会话超时。到那时,user1 空闲的时间已经超过 30 分钟。
// file1.json
{"user":"user1","action":"login","page":"page1", "ts":"2017-09-06T08:08:53"}
{"user":"user1","action":"click","page":"page2", "ts":"2017-09-06T08:10:11"}
{"user":"user1","action":"send","page":"page3", "ts":"2017-09-06T08:11:10"}
// file2.json
{"user":"user2","action":"login", "page":"page1", "ts":"2017-09-06T08:44:12"}
{"user":"user2","action":"view", "page":"page7", "ts":"2017-09-06T08:45:33"}
{"user":"user2","action":"view", "page":"page8", "ts":"2017-09-06T08:55:58"}
{"user":"user2","action":"view", "page":"page6", "ts":"2017-09-06T09:10:58"}
{"user":"user2","action":"logout","page":"page9", "ts":"2017-09-06T09:16:19"}
// file3.json
{"user":"user3","action":"login", "page":"page4", "ts":"2017-09-06T09:17:11"}
Listing 7-15User Activity Data
接下来,准备几个类和两个函数,将用户会话逻辑应用到前面的数据。对于这个用例,用户活动输入数据由UserActivity
类表示。用户会话数据的中间状态由UserSessionState
类表示,UserSessionInfo 类表示用户会话输出。这三个类的代码如清单 7-16 所示。
case class UserActivity(user:String, action:String,
page:String, ts:java.sql.Timestamp)
// the lastTS field is for storing the largest user activity timestamp and this information is used
// when setting the timeout value for each user session
case class UserSessionState(var user:String, var status:String,
var startTS:java.sql.Timestamp,
var endTS:java.sql.Timestamp,
var lastTS:java.sql.Timestamp,
var numPage:Int)
// the end time stamp is filled when the session has ended.
case class UserSessionInfo(userId:String, start:java.sql.Timestamp, end:java.sql.Timestamp, numPage:Int)
Listing 7-16Scala Case Classes for Input, Intermediate State, and Output
接下来,定义两个函数。第一个叫做updateUserActivity
,负责根据用户活动更新用户会话状态。它还根据用户操作和最新的活动时间戳更新会话开始或结束时间。第二个函数叫做updateAcrossAllUserActivities
.
,它是传递给flatMapGroupsWithState
函数的回调函数。该职能有两个主要职责。第一个是处理中间会话状态的超时,当出现这种情况时,它更新用户会话结束时间。另一个职责是确定何时向输出发送什么内容。期望的输出是在用户会话开始时发出一行,在用户会话结束时发出另一行。清单 7-17 是这两个函数的逻辑。
import org.apache.spark.sql.streaming.GroupState
import scala.collection.mutable.ListBuffer
def updateUserActivity(userSessionState:UserSessionState, userActivity:UserActivity) : UserSessionState = {
userActivity.action match {
case "login" => {
userSessionState.startTS = userActivity.ts
userSessionState.status = "Online"
}
case "logout" => {
userSessionState.endTS = userActivity.ts
userSessionState.status = "Offline"
}
case _ => {
userSessionState.numPage += 1
userSessionState.status = "Active"
}
}
userSessionState.lastTS = userActivity.ts
userSessionState
}
def updateAcrossAllUserActivities(user:String,
inputs:Iterator[UserActivity],
oldState: GroupState[UserSessionState]) :
Iterator[UserSessionInfo] = {
var userSessionState = if (oldState.exists) oldState.get
else UserSessionState(user, "",
new java.sql.Timestamp(System.currentTimeMillis), null, null, 0)
var output = ListBuffer[UserSessionInfo]()
inputs.toList.sortBy(_.ts.getTime).foreach( userActivity => {
userSessionState = updateUserActivity(userSessionState,
userActivity)
oldState.update(userSessionState)
if (userActivity.action == "login") {
output += UserSessionInfo(user, userSessionState.startTS,
userSessionState.endTS, 0)
}
})
val sessionTimedOut = oldState.hasTimedOut
val sessionEnded = !Option(userSessionState.endTS).isEmpty
val shouldOutput = sessionTimedOut || sessionEnded
shouldOutput match {
case true => {
if (sessionTimedOut) {
userSessionState.endTS =
new java.sql.Timestamp(oldState.getCurrentWatermarkMs)
}
oldState.remove()
output += UserSessionInfo(user, userSessionState.startTS,
userSessionState.endTS,
userSessionState.numPage)
}
case _ => {
// extend sesion
oldState.update(userSessionState) oldState.setTimeoutTimestamp(userSessionState.lastTS.getTime,
"30 minutes")
}
}
output.iterator
}
Listing 7-17the Functions for Performing User Sessionization
一旦设置步骤完成,下一步是将回调函数连接到结构化流应用程序中的flatMapGroupsWithState
函数,如清单 7-18 所示。这个示例利用了超时特性,因此需要设置一个水位标志和事件时间超时类型。以下是模拟流数据的步骤。
-
在
<path>/chapter7/data
目录下创建一个名为input
的目录。如果该目录已经存在,请确保删除该目录中的所有现有文件。 -
运行清单 7-17 中所示的代码。
-
将 file1.json 复制到输入目录,然后观察输出。对
file2.json
和file3.json
重复这些步骤。
import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode}
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val userActivitySchema = new StructType()
.add("user", StringType, false)
.add("action", StringType, false)
.add("page", StringType, false)
.add("ts", TimestampType, false)
val userActivityDF = spark.readStream.schema(userActivitySchema)
.json("<path>/chapter7/data/input")
// convert to DataSet of type UserActivity
val userActivityDS = userActivityDF.withWatermark("ts", "30 minutes").as[UserActivity]
// specify the event-time timeout type and wire in the call-back function
val userSessionDS = userActivityDS.groupByKey(_.user)
.flatMapGroupsWithState[UserSessionState,UserSessionInfo]
(OutputMode.Append,GroupStateTimeout.EventTimeTimeout)
(updateAcrossAllUserActivities)
// setup the output and start the streaming query
val userSessionSQ = userSessionDS.writeStream
.format("console")
.option("truncate",false)
.outputMode("append")
.start()
// only run this line of code below after done copying over file3.json
userSessionSQ.stop
// the output after processing file1.json
+--------+----------------------------+-----+-------------+
| userId| start | end | numPage|
+--------+----------------------------+-----+-------------+
| user1 | 2017-09-06 08:08:53| null| 0 |
+--------+----------------------------+-----+-------------+
// the output after processing file2.json
+--------+------------------------+--------------------+--------+
| userId| start | end | numPage|
+--------+------------------------+--------------------+--------+
| user2 | 2017-09-06 08:44:12| null | 0|
| user2 | 2017-09-06 08:44:12| 2017-09-06 09:16:19| 3|
+--------+------------------------+--------------------+--------+
// the output after processing file3.json
+--------+--------------------+--------------------+------------+
| userId| start | end | numPage|
+--------+--------------------+--------------------+------------+
| user1 | 2017-09-06 08:08:53| 2017-09-06 08:46:19| 2|
| user3 | 2017-09-06 09:17:11| null | 0|
+--------+--------------------+--------------------+------------+
Listing 7-18Using Arbitrary State Processing to Perform User Sessionization in a Streaming Application
在处理完file1.json
中的用户活动后,输出中应该有一行。这是意料之中的,因为每当updateAcrossAllUserActivities
函数在用户活动中看到一个login
动作,它就会将一个UserSessionInfo
类的实例添加到ListBuffer
输出中。处理 file2.json 后的输出中有两行,一行是针对login
动作的,另一行是针对logout
动作的。现在,file3.json
只包含 user3 的一个带有动作login
的用户活动,但是输出包含两行。user1
的行是检测到user1
会话超时的结果,这意味着由于缺少活动,水印已经超过该特定会话的超时值。
如前两个用例所示,结构化流中的任意状态处理功能提供了灵活而强大的方法,可以在每个组上应用用户定义的处理逻辑,并完全控制向输出发送的内容和时间。
处理重复数据
在处理数据时,重复数据删除是一种常见的需求,在批处理中很容易做到这一点。由于流数据的无界性质,它在流处理中更具挑战性。当数据生产者多次发送相同的数据以应对不可靠的网络连接或传输故障时,流数据中的数据复制就会发生。
幸运的是,结构化流使流应用程序可以轻松地执行数据复制,因此这些应用程序可以通过在重复数据到达时将其丢弃来保证一次性处理。结构化流提供的数据复制功能可以与水印结合使用,也可以不与水印结合使用。需要记住的一点是,在不指定水印的情况下执行数据复制时,状态结构化流需要在流应用程序的整个生命周期内保持无限增长,这可能会导致内存不足的问题。使用水印,比水印旧的最新数据会被自动丢弃,以避免重复。
指示结构化流执行重复数据删除的 API 非常简单。它只有一个输入:用于唯一标识每一行的列名列表。这些列的值执行重复检测,结构化流将它们存储为一个状态。演示重复数据删除功能的示例数据与移动事件数据具有相同的模式。计数聚合基于对id
列的分组。id
和ts
列都用作用户定义的重复数据删除键。本例的数据在<path>/chapter7/data/deduplication
中。它包含两个文件:file1.json
和file2.json
。这些文件的内容显示在清单 7-19 中。
// file1.json - each line is unique in term of id and ts columns
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone3","action":"open","ts":"2018-03-02T10:23:50"}
// file2.json - the first two lines are duplicate of the first two lines in file1.json above
// the third line is unique
// the fourth line is unique, but it arrives late, therefore it will not be processed
{"id":"phone1","action":"open","ts":"2018-03-02T10:15:33"}
{"id":"phone2","action":"open","ts":"2018-03-02T10:22:35"}
{"id":"phone4","action":"open","ts":"2018-03-02T10:29:35"}
{"id":"phone5","action":"open","ts":"2018-03-02T10:01:35"}
Listing 7-19Sample Data for the Data Duplication Example
为了模拟重复数据删除,首先在<path>/chapter7/data
目录下创建一个名为input
的目录。然后运行清单 7-20 中的代码。下一步是将file1.json
文件复制到输入目录,并检查输出。最后一步是将file2.json
文件复制到输入目录并检查输出。
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
val mobileDataSchema = new StructType()
.add("id", StringType, false)
.add("action", StringType, false)
.add("ts", TimestampType, false)
// mobileDataSchema is defined in previous example
val mobileDupSSDF = spark.readStream.schema(mobileDataSchema)
.json("<path>/chapter7/data/deduplication")
val windowCountDupDF = mobileDupSSDF.withWatermark("ts",
"10 minutes")
.dropDuplicates("id", "ts")
.groupBy("id").count
val mobileMemoryDupSQ = windowCountDupDF.writeStream
.format("console")
.option("truncate", "false")
.outputMode("update")
.start()
// output after copying file1.json to input directory
+---------+--------+
| id | count|
+---------+--------+
| phone3| 1 |
| phone1| 1 |
| phone2| 1 |
+---------+--------+
// output after coping file2.json to input directory
+---------+--------+
| id | count |
+---------+--------+
| phone4| 1 |
+---------+--------+
Listing 7-20Deduplicating Data Using dropDuplicates API
不出所料,file2.json
复制到输入目录后,控制台只显示一行。前两行与 file1.json 中的前两行重复,所以被过滤掉了。最后一行的时间戳为 10:10,这被认为是晚数据,因为时间戳早于 10 分钟的水位线阈值。所以没有处理掉。
容错
开发流式应用程序并将其部署到生产环境中时,最重要的考虑因素之一是处理故障恢复。根据墨菲定律,任何可能出错的事情都会出错。机器会出故障,软件会有 bug。
幸运的是,结构化流提供了一种在出现故障时重启或恢复流应用程序的方法,它会从中断的地方继续运行。要利用这种恢复机制,您需要通过在设置流式查询时指定检查点位置,将流式应用程序配置为使用检查点和预写日志。理想情况下,检查点位置应该是一个可靠且容错的文件系统上的目录,比如 HDFS 或亚马逊 S3。结构流定期保存所有的进度信息,例如正在处理的数据的偏移量细节和到检查点位置的中间状态值。为流式查询指定检查点位置非常简单。您只需要向您的流查询添加一个选项,名称为checkpointLocation
,值为目录名。列表 7-21 就是一个例子。
val userSessionSQ = userSessionDS.writeStream.format("console")
.option("truncate",false)
.option("checkpointLocation","/reliable/location")
.outputMode("append")
.start()
Listing 7-21Add the checkpointLocation Option to a Streaming Query
如果您查看指定的检查点位置,应该会看到以下子目录:commits
、metadata
、offsets
、sources
、stats
。这些目录中的信息特定于特定的流式查询;因此,每个都必须使用不同的检查点位置。
像大多数软件应用程序一样,流式应用程序会随着时间的推移而发展,因为需要改进处理逻辑或性能或修复错误。重要的是要记住这可能会如何影响保存在检查点位置的信息,并了解哪些更改被认为是安全的。概括地说,有两类变化。一个是对流式应用程序代码的更改,另一个是对 Spark 运行时的更改。
流式应用程序代码更改
检查点位置中的信息旨在对流式应用程序的变化具有一定的弹性。有几种变更被认为是不兼容的变更。第一个是改变聚合的方式,比如改变键列、添加更多的键列或者删除一个现有的键列。第二个是改变用于存储中间状态的类结构,例如,当一个字段被删除时,或者当一个字段的类型从字符串变为整数时。当重新启动期间检测到不兼容的更改时,结构化流会通过异常通知您。在这种情况下,您必须使用新的检查点位置,或者删除以前的检查点位置中的内容。
Spark 运行时间变化
检查点格式旨在向前兼容,以便流式应用程序可以跨 Spark 次要补丁版本或次要版本(即从 Spark 2.2.0 升级到 2.2.1 或从 Spark 2.2.x 升级到 2.3.x)从旧的检查点重新启动。唯一的例外是当有关键的错误修复时。当 Spark 引入不兼容的变化时,发行说明中清楚地记录了这一点,这很好。
如果由于不兼容问题而无法使用现有检查点位置启动串流应用程序,则需要使用新的检查点位置。您可能还需要为您的应用程序提供一些关于从中读取数据的偏移量的信息。
流式查询度量和监控
与其他长期运行的应用程序(如在线服务)一样,了解流式应用程序的进度、传入数据速率或中间状态消耗的内存量非常重要。结构化流提供了一些 API 来提取最近的执行进度,并提供了一种异步方式来监控流应用程序中的所有流查询。
流式查询度量
在任何时候,关于流式查询的最基本的有用信息是它的当前状态。您可以通过调用StreamingQuery.status
函数以人类可读的格式检索和显示这些信息。返回的对象是类型StreamingQueryStatus,
,它可以很容易地将状态信息转换成 JSON 格式。清单 7-22 展示了状态信息的一个例子。
// use a streaming query from the example above
userSessionSQ.status
// output
res11: org.apache.spark.sql.streaming.StreamingQueryStatus =
{
"message" : "Waiting for data to arrive",
"isDataAvailable" : false,
"isTriggerActive" : false
}
Listing 7-22Query Status Information in JSON Format
状态提供了关于流查询中正在发生的事情的非常基本的信息。要从最近的进展中获得更多的细节,比如传入数据速率、处理速率、水印、数据源的偏移量以及一些关于中间状态的信息,可以调用StreamingQuery.recentProgress
函数。该函数返回一组StreamingQueryProgress
实例,这些实例可以将信息转换成 JSON 格式。默认情况下,每个流式查询被配置为保留 100 个进度更新,这个数字可以通过更新名为spark.sql.streaming.numRecentProgressUpdates
的 Spark 配置来更改。要查看最近的流查询进度,可以调用StreamingQuery.lastProgress
函数。清单 7-23 展示了一个流查询进程的例子。
{
"id" : "9ba6691d-7612-4906-b64d-9153544d81e9",
"runId" : "c6d79bee-a691-4d2f-9be2-c93f3a88eb0c",
"name" : null,
"timestamp" : "2018-04-23T17:20:12.023Z",
"batchId" : 0,
"numInputRows" : 3,
"inputRowsPerSecond" : 250.0,
"processedRowsPerSecond" : 1.728110599078341,
"durationMs" : {
"addBatch" : 1548,
"getBatch" : 8,
"getOffset" : 36,
"queryPlanning" : 110,
"triggerExecution" : 1736,
"walCommit" : 26
},
"eventTime" : {
"avg" : "2017-09-06T15:10:04.666Z",
"max" : "2017-09-06T15:11:10.000Z",
"min" : "2017-09-06T15:08:53.000Z",
"watermark" : "1970-01-01T00:00:00.000Z"
},
"stateOperators" : [ {
"numRowsTotal" : 1,
"numRowsUpdated" : 1,
"memoryUsedBytes" : 16127
} ],
"sources" : [ {
"description" : "FileStreamSource[file:<path>/chapter7/data/input]",
"startOffset" : null,
"endOffset" : {
"logOffset" : 0
},
"numInputRows" : 3,
"inputRowsPerSecond" : 250.0,
"processedRowsPerSecond" : 1.728110599078341
} ],
"sink" : {
"description" : "org.apache.spark.sql.execution.streaming.ConsoleSinkProvider@37dc4031"
}
}
Listing 7-23Streaming Query Progress Details
在此流式处理进度状态中,有几个重要的关键指标需要注意。输入速率表示从输入源流入流式应用程序的输入数据量。处理速率告诉您流式应用程序处理传入数据的速度。在理想状态下,处理速率应该高于输入速率,如果不是这样,就需要考虑增加 Spark 集群中的节点数量。如果流应用程序通过groupBy
转换隐式地维护状态,或者通过任意状态处理 API 显式地维护状态,那么注意stateOperators
部分下的指标是很重要的。
Spark UI 在作业、阶段和任务级别提供了一组丰富的指标。流应用程序中的每个触发器都映射到 Spark UI 中的一个作业,在这里可以很容易地检查查询计划和任务持续时间。
Note
流式查询状态和进度详细信息可通过流式查询的实例获得。当流式应用程序在生产中运行时,您无权访问这些流式查询。如果您想从远程主机上看到这些信息,该怎么办呢?一种选择是在您的流应用程序中嵌入一个小型 HTTP 服务器,并公开几个简单的 URL 来检索这些信息。
通过回调监控流式查询
结构化流提供了一种回调机制,用于在流应用程序中异步接收流查询的事件和进度。这是通过注册一个StreamingQueryListener
接口的实现来完成的。这个接口定义了几个回调方法来接收关于流查询的状态,比如什么时候开始,什么时候有进展,什么时候终止。该接口的实现完全控制如何处理所提供的信息。实现的一个例子是将该信息发送到 Kafka 主题或其他发布-订阅系统进行离线分析,或者发送到其他流应用程序进行处理。清单 7-24 包含了StreamingQueryListener
接口的一个非常简单的实现。它只是将信息打印到控制台。
import org.apache.spark.sql.streaming.StreamingQueryListener
import org.apache.spark.sql.streaming.StreamingQueryListener.{
QueryStartedEvent, QueryProgressEvent,
QueryTerminatedEvent}
class ConsoleStreamingQueryListener extends StreamingQueryListener {
override def onQueryStarted(event: QueryStartedEvent): Unit = {
println(s"streaming query started: ${event.id} -
${event.name} - ${event.runId}")
}
override def onQueryProgress(event: QueryProgressEvent): Unit = {
println(s"streaming query progress: ${event.progress}")
}
override def onQueryTerminated(event: QueryTerminatedEvent): Unit = {
println(s"streaming query terminated: ${event.id} -
${event.runId}")
}
}
Listing 7-24a Simple Implementation of StreamingQueryListener Interface
一旦实现了StreamingQueryListener
,下一步就是向StreamQueryManager
注册它,它可以处理多个侦听器。清单 7-25 展示了如何注册和注销一个监听器。
Val listener = new ConsoleStreamingQueryListener
// to register
spark.streams.addListener(listener)
// to unregister
spark.streams.removeListener(listener)
Listing 7-25Register and Unregister an Instance of StreamingQueryListener with StreamQueryManager
需要记住的一点是,每个侦听器都会接收流式应用程序中所有流式查询的流式查询事件。如果需要将事件处理逻辑应用于特定的流式查询,您可以使用流式查询名称来标识感兴趣的查询。
通过可视化用户界面监控流式查询
Spark 3.0 引入了一种新的简单方法,通过 Spark UI 的结构化流选项卡来监控所有流查询,如图 7-5 所示。可视化 UI 旨在帮助 Spark 应用程序开发人员在开发阶段对其结构化流应用程序进行故障排除,并深入了解实时指标。UI 显示两种不同的统计数据。
图 7-5
Spark UI 中的结构化流选项卡
-
每个流式查询的摘要信息
-
每个流式查询的统计信息,包括输入速率、处理速率、输入行数和批处理持续时间
流式查询摘要信息
一个结构化流应用程序可以有多个流查询,只要其中一个被启动,它就会在 Spark UI 的 Structured Streaming 选项卡中列出。活动的和已完成的流式查询都有摘要信息,但在单独的部分中。图 7-6 显示了两个流式查询的汇总信息。
摘要信息表包含每个流式查询的基本信息,包括查询名称、状态、ID、运行 ID、开始时间、查询持续时间和聚集统计信息,如平均输入速率和平均处理速率。流式查询可以处于以下状态之一:正在运行、已完成和失败。“错误”列包含有关失败查询的异常详细信息的有用信息。
图 7-6
包含流查询摘要信息的结构化流选项卡
通过单击运行 ID 列中的链接,可以查看特定流式查询的详细统计信息。
流式查询详细的统计信息
「串流查询统计数据」页面会显示有用的测量结果,让您深入了解串流应用程序的性能、健康状况和除错问题。图 7-7 显示了一个样本流查询的详细统计信息。
图 7-7
流式查询统计示例
以下部分描述了图 7-7 中所示的指标。将鼠标放在指标名称旁边的问号上,可以看到每个指标的简要说明。
-
输入速率:数据流查询的所有输入源的数据到达速率。该比率显示为一个集合,即代表所有输入源的比率的单个值。
-
处理速率:结构化流媒体引擎处理传入数据的事件处理速率。与前面的指标一样,它是所有输入源的集合。
-
批次持续时间:每个微批次的加工持续时间
-
操作持续时间:(在批处理环境中)执行各种操作所花费的时间,以毫秒为单位。
-
addBatch
:读取、处理批处理的输出并将其写入接收器所需的时间。这将占用批处理持续时间的大部分时间。 -
getBatch
:准备逻辑查询以读取输入所需的时间。 -
getOffset
:查询输入源是否有新的输入数据所需的时间。 -
walCommit
:将偏移量写入元数据日志所需的时间。 -
queryPlanning
:生成执行计划所需的时间。
-
流式查询故障排除
有了流查询的详细指标,下一步就是利用它们来了解正在发生的事情以及要采取的措施,以提高结构化流应用程序的性能。本节讨论两种情况,并分享一些提高流式查询性能的建议。
第一种情况是当输入速率远高于处理速率度量时。这表明您的流式查询落后了,无法跟上数据生成者。以下是可以采取的一些措施。
-
增加更多的执行资源,如增加执行者的数量
-
或者增加分区数量以减少每个分区的工作量
第二种情况是当输入速率与过程速率度量大致相同,但是批处理持续时间度量相当高。这表明您的流式查询是稳定的,并且能够提供给数据生产者,但是处理每个批处理的延迟很高。以下是可以采取的一些措施。
- 提高流式查询的并行性
-
如果输入源是 Kafka,则增加 Kafka 分区的数量
-
增加每个 Spark 执行器的内核数量
-
新的结构化流式用户界面提供了每个流式查询的汇总和详细统计信息。这有助于 Spark 开发人员深入了解其结构化流应用程序的性能,以便采取适当的措施来解决性能问题。
摘要
Spark 结构化流引擎提供了许多高级功能和构建复杂和精密流应用程序的灵活性。
-
任何严肃的流处理引擎都必须支持在事件时间之前处理输入数据的能力。结构化流不仅支持做的能力,还支持基于固定和滑动窗口的窗口聚合。此外,它以容错方式自动保持中间状态。
-
随着流式应用程序长时间处理越来越多的数据,维护中间状态会带来内存耗尽的风险。引入水印是为了更容易推理出最新的数据,并删除不再需要的中间状态。
-
任意有状态处理允许以用户定义的方式处理每个组的值,并保持其中间状态。结构化流通过回调 API 提供了一种简单的方法,可以灵活地为每个组生成一行或多行输出。
-
结构化流提供端到端的一次性保证。这是通过使用检查点和预写日志机制实现的。通过提供一个位于容错文件系统上的检查点位置,这两者都可以很容易地打开。通过读取保存在检查点位置的信息,流式应用程序可以轻松重启,并从故障前停止的地方继续运行。
-
生产流应用程序需要能够洞察流查询的状态和指标。结构化流提供了流查询状态的简短摘要,以及有关传入数据速率、处理速率的详细指标,以及有关中间状态内存消耗的一些详细信息。为了监控流查询的生命周期和它们的详细进度,您可以注册一个或多个
StreamingQueryListener
接口的实例。Spark 3.0 中引入的新结构流 UI 提供了每个流查询的汇总和详细统计信息。
八、Spark 机器学习
近年来,围绕人工智能(AI)、机器学习(ML)和深度学习(DL)有很多令人兴奋的事情。人工智能专家和研究人员预测,人工智能将从根本上改变未来人类生活、工作和做生意的方式。对于世界各地的企业来说,人工智能是他们数字化转型之旅的下一步之一,一些企业在将人工智能纳入其商业战略方面取得了比其他企业更多的进展。企业希望人工智能能够高效快速地帮助解决他们的业务问题,并创造新的商业价值,以增加他们的竞争优势。像谷歌、亚马逊、微软、苹果和脸书这样的互联网巨头在投资、采用和将人工智能纳入其产品组合方面处于领先地位。2017 年,超过 150 亿美元的风险投资(VC)资金投资于全球人工智能相关的初创公司,预计这一趋势将持续下去。
人工智能是计算机科学的一个广阔领域,它试图让机器看起来像具有智能。帮助人类进步是一个大胆的目标。人工智能的一个子领域是机器学习,它专注于教会计算机在没有显式编程的情况下进行学习。学习过程包括使用算法从大量数据集中提取模式,并建立一个模型来解释世界。这些算法可以根据它们设计的任务分成不同的组。这些算法的一个共同特点是,它们通过优化内部参数的迭代过程来学习,以获得最佳结果。
深度学习(DL)是受人脑工作方式启发的机器学习方法之一,通过将复杂模式表示为嵌套的概念层次,它已被证明擅长从数据中学习复杂模式。随着大型和精选数据集的可用性与图形处理单元(GPU)的进步相结合,DL 已被证明可以有效地解决对象识别、图像识别、语音识别和机器翻译等领域的问题。在 ImageNet 图像分类挑战赛中,使用 DL 方法训练的计算机系统在图像分类方面击败了人类。这一成就和类似成就的含义是,现在计算机系统可以在与它们的创造者相同的水平上看到、识别物体和听到。图 8-1 说明了 AI、ML 和 DL 之间的关系以及它们的时间线。
图 8-1
AI、ML 和 DL 之间的关系及其时间线
创建 Spark 的动机之一是帮助应用程序大规模高效地运行迭代算法。在 Spark 的最近几个版本中,MLlib 库稳步增加了它的产品,通过提供一组常用的 ML 算法和一组工具来简化 ML 模型的构建和评估过程,使实用的 ML 变得可伸缩和简单。
为了理解 MLlib 库提供的特性,有必要对构建 ML 应用程序的过程有一个基本的了解。本章介绍了 MLlib 库中可用的特性和 API。
机器学习概述
本节提供了机器学习和 ML 应用程序开发过程的简要概述。这并不意味着详尽无遗;如果你已经熟悉机器学习,请随意跳过。
机器学习是一个广阔而迷人的研究领域,它结合了其他研究领域的部分内容,如数学、统计学和计算机科学。它教会计算机学习模式,并从历史数据中获得洞察力,通常用于决策或预测。与传统的硬编码软件不同,ML 只给你基于你提供的不完美数据的概率输出。向 ML 算法提供的数据越多,输出就越准确。ML 可以解决比传统软件有趣和困难得多的问题,并且这些问题不是特定于任何行业或商业领域的。相关领域的示例包括图像识别、语音识别、语言翻译、欺诈检测、产品推荐、机器人、自动驾驶汽车、加快药物发现过程、医疗诊断、客户流失预测、推荐等等。
鉴于人工智能的目标是让机器看起来像有智能,衡量这一点的最佳方式之一是通过比较机器智能和人类智能。
近几十年来,有几个众所周知的和公开的这种比较的例子。第一个是名为“深蓝”的计算机系统,它在 1997 年根据严格的比赛规则击败了世界象棋冠军。这个例子表明,在游戏中,计算机可以比人类思考得更快更好,有大量但有限的可能走法。
第二个是关于一个名叫沃森的计算机系统,它在 2011 年参加了一个 Jeopardy 游戏节目,与两位传奇冠军进行了比赛,赢得了 100 万美元的一等奖。这个例子表明,计算机可以理解特定问答结构中的人类语言,然后利用其庞大的知识库来开发概率答案。
第三个是关于一个名为 AlphGo 的计算机程序,它在 2016 年的一场历史性比赛中击败了一名世界冠军围棋选手。这个例子展示了人工智能领域进步的巨大飞跃。围棋是一种复杂的棋盘游戏,需要直觉、创造力和战略思维。执行穷举搜索移动是不可行的,因为它具有的可能移动的数量大于宇宙中的原子数量。
机器学习术语
在深入 ML 之前,学习这个领域的一些基本术语是很重要的。这在以后引用这些术语时很有帮助。为了更容易理解这些术语,在名为垃圾邮件分类的规范 ML 示例中提供了解释。
-
观察是一个来自统计学领域的术语。一个观察是用于学习的实体的一个实例。例如,电子邮件被认为是观察。
-
标签是标记观察值的值。例如,“垃圾邮件”或“非垃圾邮件”是用于标记电子邮件的两个可能值。
-
特征是关于最有可能对预测输出产生最大影响的观察值的重要属性,例如,电子邮件发件人 IP 地址、字数和大写单词数。
-
训练数据是训练 ML 算法以产生模型的观察值的一部分。通常的做法是将收集的数据分成三部分:训练数据、验证数据和测试数据。测试数据部分大约是原始数据集的 70%或 80%。
-
验证数据是在模型调整过程中评估 ML 模型性能的一部分观察结果。
-
测试数据是在调整过程完成后评估 ML 模型性能的一部分观察结果。
-
ML 算法是迭代运行的步骤的集合,用于从给定的测试数据中提取洞察或模式。ML 算法的主要目标是学习从输入到输出的映射。一套众所周知的 ML 算法可供您选择。挑战在于选择正确的算法来解决特定的 ML 问题。对于垃圾邮件检测问题,您可能会选择朴素贝叶斯算法。
-
模型:ML 算法从给定的输入数据中学习后,产生一个模型。然后,使用模型对新数据执行预测或做出决策。一个模型用一个数学公式来表示。我们的目标是生成一个通用模型,它可以很好地处理以前没有见过的任何新数据。
图 8-2 最好地说明了 ML 算法、数据和模型之间的关系。
图 8-2
最大似然算法、数据和模型之间的关系
应用机器学习时要记住的一个要点是,永远不要用测试数据来训练 ML 算法,因为这违背了产生通用 ML 模型的目的。另一个需要注意的要点是,ML 是一个广阔的领域,当你深入这个领域时,你会发现更多的术语和概念。希望这些基本术语能够帮助你开始学习 ML 的旅程。
机器学习类型
ML 是教机器从数据中学习模式,以做出决策或预测。这些任务广泛适用于许多不同类型的问题,其中每个问题类型需要不同的学习方式。有三种学习类型,如图 8-3 所示。
图 8-3
不同的机器学习类型
监督学习
在三种不同的学习类型中,这一种被广泛使用并且更受欢迎,因为它可以帮助解决分类和回归中的一大类问题。
分类是将观察值分类到标签的离散或分类类别中。分类问题的例子包括预测电子邮件是否是垃圾邮件;产品评论是正面的还是负面的;图像是否包含狗、猫、海豚或鸟;一篇新闻文章的主题是关于体育、医学、政治还是宗教;特定手写数字是 1 还是 2;以及第四季度收入是否符合预期。当分类结果正好有两个离散值时,称为二元分类。当它有两个以上的离散值时,称为多类分类。
回归是根据观察预测真实值。与分类不同,预测值不是离散的,而是连续的。回归问题的例子包括根据他们的位置和大小预测房价,根据一组人的背景和教育预测一个人的收入,等等。
这种类型的学习与其他类型的学习之间的一个关键区别因素是,训练数据中的每个观察必须包含一个标签,无论它是离散的还是连续的。换句话说,正确的答案被提供给算法,以通过迭代和递增地改进其对训练数据的预测来学习。一旦预测值和实际值之间达到可接受的误差范围,它就会停止。
区分分类和回归的一个简单的心理模型是,前者是关于将数据分成不同的桶,而后者是关于将最佳线拟合到数据。图 8-4 显示了这个心智模型的视觉表现。
图 8-4
分类和回归心理模型
设计了大量算法来解决分类和回归机器学习问题。本章涉及 Spark MLlib 组件中支持的一些,如表 8-1 中所列。
表 8-1
MLlib 中的监督学习算法
|任务
|
算法
|
| --- | --- |
| 分类 | 逻辑回归决策图表随机森林梯度增强树线性支持向量机奈伊夫拜厄斯 |
| 回归 | 线性回归广义线性回归决策树回归随机森林回归梯度推进回归 |
无监督学习
这种学习方法的名字意味着没有监督;换句话说,训练 ML 算法的数据不包含标签。这种学习类型旨在解决一类不同的问题,例如发现数据中隐藏的结构或模式,这取决于我们人类来解释这些见解背后的意义。其中一个隐藏的结构叫做聚类,对于在聚类内的观察值之间导出有意义的关系或相似性是有用的。图 8-5 描述了集群的例子。
图 8-5
聚类的可视化
事实证明,这种学习方法可以解决很多实际问题。假设有大量的文档,但事先不知道某个文档属于哪个主题。您可以使用无监督学习来发现相关文档的聚类,并从那里为每个聚类分配一个主题。无监督学习可以帮助解决的另一个有趣而常见的问题是信用卡欺诈检测。在将用户信用卡交易分组后,发现异常值并不太困难,这些异常值代表小偷偷走信用卡后的异常信用卡交易。表 8-2 列出了 Spark 中支持的无监督学习算法。
表 8-2
MLlib 中的无监督学习算法
|任务
|
算法
|
| --- | --- |
| 使聚集 | k 均值潜在狄利克雷分配平分k——意思是高斯的 |
强化学习
与前两种类型的学习不同,这种学习不从数据中学习。相反,它通过一系列动作和接收到的反馈,从与环境的互动中学习。根据反馈,它做出调整,向最大化回报的目标靠近。换句话说,它从自己的经验中学习。
直到最近,这种学习方式还没有像前两种那样受到关注,因为除了电脑游戏之外,它还没有取得重大的实际成功。2016 年,谷歌 DeepMind 能够成功地应用这种学习类型来玩一场雅达利游戏,然后将其纳入其 AlphGo 程序,该程序在围棋比赛中击败了一名世界冠军。
此时,Spark MLlib 不包含任何强化学习算法。接下来的部分集中在前两种类型的学习。
Note
术语被监督隐喻性地指的是一个教师(人类)“监督”学习者,这就是 ML 算法,通过专门提供答案(标签)以及一组例子(训练数据)。
机器学习开发过程
为了有效地应用机器学习来开发智能应用程序,您应该考虑研究和采用大多数 ML 从业者遵循的一套最佳实践。有人说,有效地应用机器学习是一门手艺——一半是科学,一半是艺术。幸运的是,一个众所周知的结构化流程由一系列步骤组成,有助于提供合理的可重复性和一致性,如图 8-6 所示。
图 8-6
机器学习应用程序开发流程
这个过程的第一步是清楚地了解你认为 ML 可以帮助你的商业目标或挑战。评估 ML 的替代解决方案以了解成本和权衡是有益的。有时候,从简单的基于规则的解决方案开始会更快。如果有强有力的证据表明,ML 是高效、快速地交付有价值的商业见解的更好选择,那么您将进入下一步,即建立一套您和您的利益相关者都同意的成功度量标准。
成功指标从商业角度建立了 ML 项目的成功标准。它们是可衡量的,并且与商业成功直接相关。度量标准的例子是增加一定百分比的客户转化率,增加一定数量的广告点击率,增加一定数量的收入。成功指标也有助于决定何时因成本或未产生预期收益而放弃 ML 项目。
在成功度量被识别之后,下一步是识别和收集适当数量的数据来训练 ML 算法。收集的数据的质量和数量直接影响训练的 ML 模型的性能。需要记住的重要一点是,确保收集的数据代表了您试图解决的问题。短语“垃圾输入,垃圾输出”仍然适用于描述 ML 中的关键限制。
特色工程是这一过程中最重要也是最耗时的步骤之一。它主要是关于数据清洗和使用领域知识来识别观察值中的关键属性或特征,以帮助 ML 算法学习训练数据和提供的标签之间的直接关系。数据清理任务通常使用探索性数据分析框架来完成,以便从数据分布、相关性、异常值等方面更好地理解数据。这一步是一个昂贵的步骤,因为需要让人们参与进来,并使用他们的领域知识。DL 已经被证明是优于 ML 的学习方法,因为它可以自动提取特征而无需人工干预。
特征工程之后的下一步是选择合适的 ML 模型或算法并训练它。鉴于有许多可用的算法来解决类似的 ML 任务,问题是,使用什么模型是最好的?像大多数事情一样,决定最好的一个需要结合对手头问题的良好理解,对每个算法的各种特征的良好工作知识,以及在过去将它们应用于类似问题的经验。换句话说,在选择最佳算法时,一半是科学,一半是艺术。找到最佳算法需要一些实验。一旦选择了算法,就让它从特征工程步骤中产生的特征中学习。训练步骤的输出是一个模型,然后您可以继续执行一个模型评估,看看它的表现如何。导致这一步的所有先前步骤的目标是产生一个一般化的模型,意味着它在以前从未见过的数据上执行得有多好。
ML 开发过程中的另一个重要步骤是模型评估任务。这既是必要的,也是具有挑战性的。这一步不仅旨在回答模型性能如何的问题,还旨在知道何时停止调整模型,因为其性能已经达到了既定的成功标准。评估过程可以离线或在线完成。前一种情况是指使用训练数据评估模型,后一种情况是指使用生产数据或新数据评估模型。有一组常用的指标来理解模型性能:精度、召回、F1 分数和 AUC。
这一步的艺术部分是理解哪些指标适用于某些 ML 任务。模型性能结果决定了是继续生产部署步骤,还是返回到收集更多数据或不同类型数据的步骤。
这些信息旨在提供 ML 开发流程的概述,并不全面。很容易用一整章的时间来充分涵盖的内部细节和最佳实践。
Spark 机器学习库
本章的其余部分涵盖了 Spark MLlib 组件的主要特性,并提供了将 Spark 中提供的 ML 算法应用于以下每个 ML 任务的示例:分类、回归、聚类和推荐。
Note
在 Python 世界中,scikit-learn 是最受欢迎的开源机器学习库之一。它构建在 NumPy、SciPy 和 matplotlib 库之上。它提供了一套有监督和无监督的学习算法。它被设计成一个简单高效的库,是在单机上学习和练习机器学习的完美之作。当数据大小超过单台机器的存储容量时,就该切换到 Spark MLlib 了。
近年来,有许多可用的 ML 库可供选择来训练 ML 模型。在大数据时代,有两个理由选择 Spark MLlib 而不是其他选项。第一个是易用性。Spark SQL 提供了一种非常用户友好的方式来执行探索性数据分析。MLlib 库提供了一种构建、管理和持久化复杂 ML 管道的方法。第二个原因是关于大规模训练 ML。Spark 统一数据分析引擎和 MLlib 库的结合可以支持训练具有数十亿次观察和数千个特征的机器学习模型。
机器学习管道
ML 流程本质上是一个管道,由一系列按顺序运行的步骤组成。管道通常需要运行多次才能产生最佳模型。为了使实用的机器学习变得容易,Spark MLlib 提供了一组抽象来帮助简化数据清理的步骤,包括工程,训练模型,模型调整和评估,并将它们组织到一个管道中,以便于理解,维护和重复。管道概念的灵感来自 scikit-learn 库。
有四个主要的抽象来形成端到端的 ML 管道:转换器、估计器、评估器和管道。他们提供了一套标准接口,便于与另一位数据科学家合作并理解他的管道。图 8-7 描述了 ML 过程的核心步骤和 MLlib 提供的主要抽象之间的相似性。
图 8-7
ML 主要步骤和 MLlib 管道主要概念之间的相似性
这些抽象的一个共同点是,输入和输出的类型主要是数据帧,这意味着您需要将输入数据转换成数据帧来使用这些抽象。
Note
像 Spark 统一数据分析引擎中的其他组件一样,MLlib 正在切换到基于 DataFrame 的 API,以提供更加用户友好的 API,并利用 Spark SQL 引擎的优化。org.apache.spark.ml 包中提供了新的 API。第一个 MLlib 版本是在基于 RDD 的 API 上开发的,现在仍受支持,但只是处于维护模式。旧的 API 可以在 org.apache.spark.mllib 包中找到。一旦达到功能对等,那么基于 RDD 的 API 将被弃用。
变形金刚(电影名)
转换器被设计成通过在特征工程和模型评估步骤期间操纵一个或多个列来转换数据帧中的数据。转换过程是在构建由 ML 算法学习使用的特征的环境中进行的。这个过程通常包括添加或删除列(要素),将列值从文本转换为数值,或者规范化特定列的值。
在 MLlib 中使用 ML 算法有一个严格的要求;它们要求所有要素都是双精度数据类型,包括标注。
从技术角度来看,转换器有一个transform
函数,它对输入列执行转换,结果存储在输出列中。输入列和输出列的名称可以在构造转换器的过程中指定。如果未指定,则使用默认的列名。图 8-8 描绘了变压器的样子;DF1 中的阴影列表示输入列。DF2 中较暗的阴影列表示输出列。
图 8-8
变压器输入和输出
每个列数据类型需要一组不同的数据转换器。MLlib 提供了大约 30 个变压器。表 8-3 列出了每种数据转换的各种转换器。
表 8-3
不同变压器类型的变压器
|类型
|
变形金刚
|
| --- | --- |
| 一般 | SQL 转换器向量汇编器 |
| 数字数据 | 斗式提升机量化分解器标准鞋匠 MixMaxScalerMaxAbsScaler 标准化者 |
| 文本数据 | IndexToStringOneHotEncoder 令牌设备,雨令牌设备停用词去除器 NGram 哈希 |
本节讨论几种常见的变压器。
Binarizer
转换器只是将一个或多个输入列的值转换成一个或多个输出列。输出值为 0 或 1。小于或等于指定阈值的值在输出列中被转换为零。对于大于指定阈值的值,它们的值在输出列中被转换为 1。输入列类型必须是 double 或 VectorUDT。清单 8-1 将温度列的值转换成两个桶。
import org.apache.spark.ml.feature.Binarizer
val arrival_data = spark.createDataFrame(Seq(
("SFO", "B737", 18, 95.1, "late"),
("SEA", "A319", 5, 65.7, "ontime"),
("LAX", "B747", 15, 31.5, "late"),
("ATL", "A319", 14, 40.5, "late") ))
.toDF("origin", "model", "hour",
"temperature", "arrival")
val binarizer = new Binarizer().setInputCol("temperature")
.setOutputCol("freezing")
.setThreshold(35.6)
binarizer.transform(arrival_data).show
// show the current values of the parameters in binarizer transformer
binarizer.explainParams
inputCol: input column name (current: temperature)
outputCol: output column name (default: binarizer_60430bb4e97f__output, current: freezing)
threshold: threshold used to binarize continuous features (default: 0.0, current: 35.6)
// show the transformation result
binarizer.transform(arrival_data)
.select("temperature", "freezing").show
+----------------+----------+
| temperature| freezing|
+----------------+----------+
| 95.1| 1.0|
| 65.7| 1.0|
| 31.5| 0.0|
| 40.5| 1.0|
+----------------+----------+
Listing 8-1Use Binarizer Transformer Convert Temperature into Two Buckets
Bucketizer
transformer 是二进制化器的通用版本,它可以将列值转换成您选择的桶。控制桶的数量和每个桶的值的范围的方法是以双精度值数组的形式指定一个桶边界列表。在列的值是连续的,并且您希望将它们转换为分类值的情况下,此转换器非常有用。例如,您有一个包含居住在特定州的每个人的收入金额的列,并且您希望将他们的收入分成以下几个类别:高收入、中等收入和低收入。
值存储桶边界数组必须是 double 类型,并且必须遵守以下要求。
-
最小的存储桶边界值必须小于数据帧中输入列的最小值。
-
最大存储桶边界值必须大于数据帧中输入列的最大值
-
输入数组中必须至少有三个桶边界,这将创建两个桶。
在一个人的收入中,很容易知道最小的收入额是 0。最小的桶边界值可以小于 0。当不可能预测最小列值时,可以指定负无穷大。同样,当无法预测最大列值时,则指定正无穷大。
清单 8-2 是使用这个转换器将温度列分成三个桶的例子,这意味着桶边界数组必须包含至少四个值。它按温度列排序,以便于查看。
import org.apache.spark.ml.feature.Bucketizer
val bucketBorders = Array(-1.0, 32.0, 70.0, 150.0)
val bucketer = new Bucketizer().setSplits(bucketBorders)
.setInputCol("temperature")
.setOutputCol("intensity")
val output = bucketer.transform(arrival_data)
.output.select("temperature", "intensity")
.orderBy("temperature")
.show
+----------------+-----------+
| temperature| intensity|
+----------------+-----------+
| 31.5| 0.0|
| 40.5| 1.0|
| 65.7| 1.0|
| 95.1| 2.0|
+----------------+-----------+
Listing 8-2Use Bucketizer Transformer Convert Temperature into Three Buckets
当处理数值分类值时,通常使用OneHotEncoder
转换器。如果分类值是字符串类型,首先应用StringIndexer
估计器,并将它们转换成数字类型。OneHotEncoder
本质上是将一个数值分类值映射到一个二进制向量,以有目的地删除数值的隐式排序。列表 8-3 代表学生专业,其中每个专业被分配一个序号值,这表明某个专业高于其他专业。该转换器将序数值转换成向量,以在 ML 训练步骤中消除这种非预期的偏差。清单 8-3 是使用这个变压器的一个例子。
import org.apache.spark.ml.feature.OneHotEncoder
val student_major_data = spark.createDataFrame(
Seq(("John", "Math", 3),
("Mary", "Engineering", 2),
("Jeff", "Philosophy", 7),
("Jane", "Math", 3),
("Lyna", "Nursing", 4) ))
.toDF("user", "major", "majorIdx")
val oneHotEncoder = new OneHotEncoder().setInputCol("majorIdx")
.setOutputCol("majorVect")
oneHotEncoder.transform(student_major_data).show()
+------+---------------+------------+----------------+
| user| major| majorIdx| majorVect|
+------+---------------+------------+----------------+
| John| Math| 3| (7,[3],[1.0])|
| Mary| Engineering| 2| (7,[2],[1.0])|
| Jeff| Philosophy| 7| (7,[],[])|
| Jane| Math| 3| (7,[3],[1.0])|
| Lyna| Nursing| 4| ( 7,[4],[1.0])|
+------+---------------+------------+----------------+
Listing 8-3Use OneHotEncoder Transformer the Ordinal Value of the Categorical Values
处理字符串分类值时的另一个常见需求是将它们转换为序数值,这可以使用 StringIndexer 估计器来完成。这个估计器在“估计器”一节中描述。
有许多有趣的机器学习用例,其中输入是自由格式的文本。它需要一些转换来将自由形式的文本转换成数字表示,以便 ML 算法可以使用它。其中包括标记化和统计词频。
最有可能的是,你可以猜到Tokenizer
转换器是做什么的。它对由空格分隔的单词字符串执行标记化,并返回单词数组。如果分隔符不是空格,那么您可以使用带有指定分隔符的RegexTokenizer
。清单 8-4 是使用Tokenizer
变压器的一个例子。
import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.sql.functions._
val text_data = spark.createDataFrame(Seq(
(1, "Spark is a unified data analytics engine"),
(2, "It is fun to work with Spark"),
(3, "There is a lot of exciting sessions at upcoming
Spark summit"),
(4, "mllib transformer estimator evaluator
and pipelines"))).toDF("id", "line")
val tokenizer = new Tokenizer().setInputCol("line")
.setOutputCol("words")
val tokenized = tokenizer.transform(text_data)
tokenized.select("words")
.withColumn("tokens", size(col("words")))
.show(false)
+-----------------------------------------------------------------+-------+
| words | tokens|
+-----------------------------------------------------------------+-------+
|[spark, is, a, unified, data, analytics, engine] | 7|
|[spark, is cool, and, it, is, fun, to, work, with, | 11|
|[there, is, a, lot, of, exciting, sessions, at, upcoming, spark, summit] | 11|
|[mllib, transformer, estimator, evaluator, and, pipelines] | 6|
+-----------------------------------------------------------------+-------+
Listing 8-4Use Tokenizer Transformer to Perform Tokenization
停用词是语言中常用的词。在自然语言处理或机器学习的背景下,停用词往往会添加不必要的噪音,不会添加任何有意义的贡献。因此,它们通常在标记化步骤后立即被删除。StopWordsRemover
transformer 旨在帮助这一努力。
从 Spark 2.3 版本开始,Spark 发行版中包含了以下语言的停用词:丹麦语、荷兰语、英语、芬兰语、法语、德语、匈牙利语、意大利语、挪威语、葡萄牙语、俄语、西班牙语、瑞典语和土耳其语。它被设计得很灵活,所以你可以从一个文件中提供一组停用词。
要在特定语言中使用停用词,首先要调用StopWordsRemover.loadDefaultStopWords(<language in lower case>)
来加载它们,并将它们提供给StopWordsRemover
的实例。此外,您可以请求该转换器执行不区分大小写的停用词过滤。清单 8-5 是使用StopWordsRemover
转换器删除英语停用词的一个例子。
import org.apache.spark.ml.feature.StopWordsRemover
val enSWords = StopWordsRemover.loadDefaultStopWords("english")
val remover = new StopWordsRemover().setStopWords(enSWords)
.setInputCol("words")
.setOutputCol("filtered")
// use the tokenized from Listing 8-5 example
val cleanedTokens = remover.transform(tokenized)
cleanedTokens.select("words","filtered").show(false)
Listing 8-5Use StopWordsRemover Transformer to Remove English Stop Words
HashingTF
转换器通过计算每个单词的频率,将单词集合转换成数字表示。通过应用名为 MurmurHash 3 的哈希函数,每个单词都被映射到一个索引中。这种方法是有效的,但是它遭受潜在的散列冲突,这意味着多个单词可能映射到同一个索引。最小化冲突的一种方法是以 2 的幂指定大量的桶,以均匀地分布字。清单 8-6 将来自清单 8-5 的过滤后的柱送入HashingTF
变压器。
import org.apache.spark.ml.feature.HashingTF
val tf = new HashingTF().setInputCol("filtered")
.setOutputCol("TFOut")
.setNumFeatures(4096)
val tfResult = tf.transform(cleanedTokens)
tfResult.select("filtered", "TFOut").show(false)
Listing 8-6Use HashingTF Transformer to Transform Words into Numerical Representation Via Hashing and Counting
本节讨论的最后一个转换器是VectorAssembler
,它将一组列组合成一个向量列。在机器学习术语中,这相当于将单个特征组合成单个向量特征,供 ML 算法学习。单个输入列的类型必须是以下类型之一:数值、布尔或向量类型。输出向量列包含按指定顺序排列的所有列的值。这个变换器实际上用在每一个 ML 流水线中,它的输出被传递到一个估计器中。清单 8-7 是使用VectorAssembler
变压器的一个例子。
import org.apache.spark.ml.feature.VectorAssembler
val arrival_features = spark.createDataFrame(Seq(
(18, 95.1, true),
(5, 65.7, true),
(15, 31.5, false),
(14, 40.5, false) ))
.toDF("hour", "temperature", "on_time")
val assembler = new VectorAssembler().setInputCols(
Array("hour", "temperature", "on_time"))
.setOutputCol("features")
val output = assembler.transform(arrival_features)
output.show
+-----+-----------------+-----------+-------------------+
| hour| temperature| on_time| features|
+-----+-----------------+-----------+-------------------+
| 18| 95.1| true| [18.0,95.1,1.0]|
| 5| 65.7| true| [5.0,65.7,1.0]|
| 15| 31.5| false| [15.0,31.5,0.0]|
| 14| 40.5| false| [14.0,40.5,0.0]|
+-----+-----------------+-----------+-------------------+
Listing 8-7Use VectorAssembler Transformer to Combines Features into a Vector Feature
为了方便一次转换多个列,Spark 版本增加了对这些转换器的支持:Binarizer
、StringIndexer
和StopWordsRemover
。清单 8-8 显示了一个用Binarizer
转换器.
转换多个列的小例子,您可以选择指定一个或多个阈值。如果指定了单个阈值,那么它将用于所有输入列。如果指定了多个阈值,则第一个阈值用于第一个输入列,依此类推。
import org.apache.spark.ml.feature.Binarizer
val temp_data = spark.createDataFrame(
Seq((65.3,95.1),(60.7,99.1),
(75.3, 105.3)))
.toDF("morning_temp", "night_temp")
val temp_bin = new Binarizer()
.setInputCols(Array("morning_temp", "night_temp"))
.setOutputCols(Array("morning_oput","night_out"))
.setThresholds(Array(65,96))
temp_bin.transform(temp_data).show
+------------+----------+------------+---------+
|morning_temp|night_temp|morning_oput|night_out|
+------------+----------+------------+---------+
| 65.3| 95.1| 1.0| 0.0|
| 60.7| 99.1| 0.0| 1.0|
| 75.3| 105.3| 1.0| 1.0|
+------------+----------+------------+---------+
Listing 8-8Transforming Multiple Columns With Binarizer Transformer
了解转换器如何工作以及 MLlib 中可用的转换器在 ML 开发过程的特性工程步骤中起着重要的作用。通常,VectorAssembler 转换器的输出由估计器消耗,这将在下一节讨论。
估计量
估计器是对 ML 学习算法或任何其他对数据进行操作的算法的抽象。一个估计量可以是两种算法中的一种,这是相当令人困惑的。第一种类型的一个例子是称为LinearRegression
的 ML 算法,它用于预测房价的回归任务。第二种算法的一个例子是StringIndexer
,它将分类值编码成索引。每个分类值的索引值基于它在数据帧的整个输入列中出现的频率。在高层次上,这种估计器将一列的值转换成另一列的值;然而,它需要在整个数据帧上通过两次才能产生预期的输出。
从技术角度来看,估计器有一个fit
函数,它在输入列上应用一个算法。产生的结果封装在一个名为Model
的对象类型中,这是一个Transformer
类型。输入列和输出列的名称可以在构造估计器的过程中指定。图 8-9 描述了估计器的样子及其输入和输出。
图 8-9
估计量及其输入和输出
为了给这两种类型的估计器一个概念,表 8-4 提供了 MLlib 中可用估计器的子集。
表 8-4
MLlib 中可用估计量的样本
|类型
|
估计量
|
| --- | --- |
| 机器学习算法 | 逻辑回归决策树分类器随机应变分类器线性回归随机森林回归量聚类皱胃向左移平分意味着 |
| 数据转换算法 | 综合资料的文件(intergrated Data File)公式 StringIndexeronehotencoderestomator 口腔癌标准鞋匠 MixMaxScalerMaxAbsScalerWord2Vec |
下一节提供了一些在处理文本和数字数据时常用的估计量的例子。
RFormula
是一个有趣的通用估计器,其中转换逻辑以声明方式表达。它可以处理数值和分类值,其输出是一个特征向量。MLlib 从 R 语言中借用了这个估计器的思想,它只支持 R 中可用的运算符的子集。表 8-5 中列出了基本的和支持的运算符。理解转换语言以充分利用RFormula
估算器的灵活性和强大功能需要时间。
表 8-5
公式转换器中支持的运算符
|操作员
|
描述
|
| --- | --- |
| ~ | 目标和术语之间的分隔符 |
| + | 连接术语 |
| - | 删除一个术语 |
| : | 其他术语之间的相互作用创造了新的特征。乘法用于数值,二进制用于分类值。 |
| 。 | 除目标之外的所有列 |
清单 8-9 将到达列和其余列中的标签指定为特性。此外,它使用小时和温度列之间的交互创建了一个新功能。因为这两列是数字类型,所以它们的值相乘。
import org.apache.spark.ml.feature.RFormula
val arrival_data = spark.createDataFrame(Seq(
("SFO", "B737", 18, 95.1, "late"),
("SEA", "A319", 5, 65.7, "ontime"),
("LAX", "B747", 15, 31.5, "late"),
("ATL", "A319", 14, 40.5, "late") ))
.toDF("origin", "model", "hour",
"temperature", "arrival")
val formula = new RFormula().setFormula(
"arrival ~ . + hour:temperature")
.setFeaturesCol("features")
.setLabelCol("label")
// call fit function first, which returns a model (type of transformer), then call transform
val output = formula.fit(arrival_data).transform(arrival_data)
output.select("*").show(false)
Listing 8-9Use RFomula Transformer to Create a Feature Vector
在处理文本时,最常用的估计量之一是 IDF 估计量。它的名字是逆文档频率的首字母缩写。这个估计器通常在文本被标记化和计算出词频后立即使用。这个估计器背后的思想是通过计算每个单词出现在文档中的数量来计算它的重要性或权重。这种想法背后的直觉是,一个出现频率高、流行范围广的词不太重要;例如,单词的。相反,仅在少数文档中出现频率高的单词表示更高的重要性;比如分类这个词。在数据帧的上下文中,文档指的是一行。敏锐的读者会发现,计算每个单词的重要性需要遍历每一行,因此 IDF 是一个估计器,而不是转换器。清单 8-10 将Tokenizer
和HashingTF
变压器与IDF
估算器链接在一起。与变压器不同,估值器被急切地评估,这意味着当调用fit
函数时,它触发一个 Spark 作业。
import org.apache.spark.ml.feature.Tokenizer
import org.apache.spark.ml.feature.HashingTF
import org.apache.spark.ml.feature.IDF
val text_data = spark.createDataFrame(Seq(
(1, "Spark is a unified data analytics engine"),
(2, "Spark is cool and it is fun to work with Spark"),
(3, "There is a lot of exciting sessions at upcoming
Spark summit"),
(4, "mllib transformer estimator evaluator and
pipelines") )).toDF("id", "line")
val tokenizer = new Tokenizer().setInputCol("line")
.setOutputCol("words")
// the output column of the Tokenizer transformer is the input to HashingTF
val tf = new HashingTF().setInputCol("words")
.setOutputCol("wordFreqVect")
.setNumFeatures(4096)
val tfResult = tf.transform(tokenizer.transform(text_data))
// the output of the HashingTF transformer is the input to IDF estimator
val idf = new IDF().setInputCol("wordFreqVect")
.setOutputCol("features")
// since IDF is an estimator, call the fit function
val idfModel = idf.fit(tfResult)
// the returned object is a Model, which is of type Transformer
val weightedWords = idfModel.transform(tfResult)
weightedWords.select("label", "features").show(false)
weightedWords.printSchema
|-- id: integer (nullable = false)
|-- line: string (nullable = true)
|-- words: array (nullable = true)
| |-- element: string (containsNull = true)
|-- wordFreqVect: vector (nullable = true)
|-- features: vector (nullable = true)
// the feature column contains a vector for the weight of each word, since it is long, the output is not included //below
weightedWords.select("wordFreqVect", "features").show(false)
Listing 8-10Use IDF Estimator to Compute the Weight of Each Word
当处理包含分类值的文本数据时,一个常用的估计器是StringIndexer
估计器。它将类别值编码到基于其频率的索引中,使得最频繁的类别值的索引值为 0,依此类推。对于这个估计器来说,要得出一个分类值的索引值,它首先必须计算每个分类值的频率,最后给每个分类值分配一个索引值。为了执行计数和分配索引值,它必须从数据帧的开始到结束遍历输入列的所有值。如果输入列是数字,这个估计器在计算它的频率之前先转换它的字符串。
清单 8-11 提供了一个使用StringIndexer
估算器对电影类型进行编码的例子。
import org.apache.spark.ml.feature.StringIndexer
val movie_data = spark.createDataFrame(Seq(
(1, "Comedy"),
(2, "Action"),
(3, "Comedy"),
(4, "Horror"),
(5, "Action"),
(6, "Comedy"))
).toDF("id", "genre")
val movieIndexer = new StringIndexer().setInputCol("genre")
.setOutputCol("genreIdx")
// first fit the data
val movieIndexModel = movieIndexer.fit(movie_data)
// use returned transformer to transform the data
val indexedMovie = movieIndexModel.transform(movie_data)
indexedMovie.orderBy("genreIdx").show()
+---+-----------+------------+
| id| genre| genreIdx|
+---+-----------+------------+
| 3| Comedy| 0.0|
| 6| Comedy| 0.0|
| 1| Comedy| 0.0|
| 5| Action| 1.0|
| 2| Action| 1.0|
| 4| Horror| 2.0|
+---+-----------+------------+
Listing 8-11StringIndex Estimator to Encode Movie Genre
该估计器基于频率的降序来分配索引。这种默认行为可以很容易地更改为频率的升序。它支持另外两种排序类型:降序和升序。要更改默认的排序类型,只需用下列值之一调用setStringOrderType("<ordering type>")
函数:frequencyDesc, frequencyAsc, alphabetDesc
和alphabetAsc
。
在 Spark 版中,StringIndexer
估算器可以支持对数据帧中的多列分类值进行编码。当有这样的需要时,您可以简单地调用setInputCols
函数来指定要编码的输入列名,并通过调用setOutputCols
函数来相应地指定输出列名。
import org.apache.spark.ml.feature.StringIndexer
val movie_data2 = spark.createDataFrame(Seq(
(1, "Comedy", "G"),
(2, "Action", "PG"),
(3, "Comedy", "NC-17"),
(4, "Horror", "PG-13"))
).toDF("id", "genre", "rating")
val movieIdx2 = new StringIndexer()
.setInputCols(Array("genre", "rating"))
.setOutputCols(Array("genreIdx", "ratingIdx"))
movieIdx2.fit(movie_data2)
.transform(movie_data2)
.orderBy('genreIdx)
.show()
+---+------+------+--------+---------+
| id| genre|rating|genreIdx|ratingIdx|
+---+------+------+--------+---------+
| 3|Comedy| NC-17| 0.0| 1.0|
| 1|Comedy| G| 0.0| 0.0|
| 2|Action| PG| 1.0| 2.0|
| 4|Horror| PG-13| 2.0| 3.0|
+---+------+------+--------+--------+
Listing 8-12StringIndex Estimator to Encode Multiple Columns
在特定分类值存在于训练数据集中但不存在于测试数据集中的情况下。默认情况下,StringIndexer
估计器抛出一个错误来指示这种情况。它提供了另外两种处理这种情况的方法。
-
跳过:过滤掉无效数据的行
-
保存:将无效数据放入专门的附加桶中
您可以通过为setHandleInvalid
函数指定以下参数来说明您希望StringIndexer
估计器如何处理这个场景:keep
、skip
、error
。
处理分类值时另一个有用的估计器是OneHotEncoderEstimator
,它将分类值的索引编码成一个二进制向量。从 Spark 版本 2.3.0 开始,OneHotEncoder
transformer 已经被弃用,因为它在处理未知类别方面存在限制。该估计器通常与StringIndexer
估计器结合使用,其中StringIndexer
的输出成为该估计器的输入。清单 8-13 展示了两种估算器的用法。
import org.apache.spark.ml.feature.OneHotEncoderEstimator
// the input column genreIdx is the output column of StringIndex in listing 8-9
val oneHotEncoderEst = new OneHotEncoderEstimator().setInputCols(
Array("genreIdx"))
.setOutputCols(Array("genreIdxVector"))
// fit the indexedMovie data produced in listing 8-10
val oneHotEncoderModel = oneHotEncoderEst.fit(indexedMovie)
val oneHotEncVect = oneHotEncoderModel.transform(indexedMovie)
oneHotEncVect.orderBy("genre").show()
+---+--------+------------+--------------------+
|id | genre | genreIdx| genreIdxVector|
+---+--------+------------+--------------------+
| 5 | Action | 1.0 | (2,[1],[1.0]) |
| 2 | Action | 1.0 | (2,[1],[1.0]) |
| 3 | Comedy | 2.0 | (2,[],[]) |
| 6 | Comedy | 2.0 | (2,[],[]) |
| 1 | Comedy | 2.0 | (2,[],[]) |
| 4 | Horror | 0.0 | (2,[0],[1.0])|
+---+--------+------------+--------------------+
Listing 8-13OneHotEncoderEstimator Consumes the Output of the StringIndexer Estimator
在处理自由文本时,Word2Vec
估算器很有用。它代表字到矢量。该估计器利用众所周知的单词嵌入技术,该技术将单词标记转换成数字向量表示,使得语义相似的单词被映射到附近的点。这种技术背后的直觉是,相似的单词往往一起出现,并且具有相似的上下文。换句话说,当两个不同的单词有非常相似的相邻单词时,那么它们很可能在意义上非常相似或者是相关的。这种技术已经在一些自然语言处理应用中被证明是有效的,例如单词类比、单词相似性、实体识别和机器翻译。
Word2Vec
估算器有几种配置,需要提供适当的值来控制输出。表 8-6 描述了这些配置。
表 8-6
Word2Vec 配置
|名字
|
缺省值
|
描述
|
| --- | --- | --- |
| 向量大小 | One hundred | 输出向量的大小。 |
| windows size(windows size) | five | 用作上下文的单词数。 |
| minCount | five | 令牌必须出现在输出中的最小次数。 |
| maxsentexcelength | One thousand | 其他术语之间的相互作用创造了新的特征。乘法用于数值,二进制用于分类值。 |
清单 8-14 展示了如何使用Word2Vec
估计器,以及如何找到相似的单词。
import org.apache.spark.ml.feature.Word2Vec
val documentDF = spark.createDataFrame(Seq(
"Unified data analytics engine Spark".split(" "),
"People use Hive for data analytics".split(" "),
"MapReduce is not fading away".split(" "))
.map(Tuple1.apply)).toDF("word")
val word2Vec = new Word2Vec().setInputCol("word")
.setOutputCol("feature")
.setVectorSize(3)
.setMinCount(0)
val model = word2Vec.fit(documentDF)
val result = model.transform(documentDF)
result.show(false)
Listing 8-14Use Word2Vec Estimator to Compute Word Embeddings and Find Similar Words
// find similar words to Spark, the result shows both Hive and MapReduce are similar.
model.findSynonyms("Spark", 3).show
+----------------+-----------------------------+
| word| similarity|
+----------------+-----------------------------+
| engine| 0.9133241772651672|
| MapReduce| 0.7623026967048645|
| Hive| 0.7179173827171326|
+----------------+-----------------------------+
// find similar words to Hive, the result shows Spark is similar
model.findSynonyms("Hive", 3).show
+---------+------------------------------+
| word| similarity|
+---------+------------------------------+
| Spark| 0.7179174423217773|
| fading| 0.5859972238540649|
| engine| 0.43200281262397766|
+---------+------------------------------+
下一个评估者是关于规范化和标准化数字数据的。使用这些估计值的原因是为了确保使用距离作为测量值的学习算法不会对具有较大值的要素施加比另一个具有较小值的要素更大的权重。
规范化数字数据是将其原始范围映射到从零到一的范围的过程。当观测值具有多个不同范围的属性时,这尤其有用。比如说你有一个员工的工资和身高。工资的价值以千计。高度的值是个位数。这就是MinMaxScaler
估算器的设计目的。它使用列汇总统计数据,将每个要素(列)分别线性重新缩放到最小值和最大值的公共范围。例如,如果最小值为 0.0,最大值为 3.0,则所有值都在该范围内。清单 8-15 提供了一个使用带有薪水和身高信息的 employee_data 与MinMaxScaler
一起工作的例子。这两个特性的值之间的幅度相当大,但是在运行了MinMaxScaler
之后,情况就不一样了。
import org.apache.spark.ml.feature.MinMaxScaler
import org.apache.spark.ml.linalg.Vectors
val employee_data = spark.createDataFrame(Seq(
(1, Vectors.dense(125400, 5.3)),
(2, Vectors.dense(179100, 6.9)),
(3, Vectors.dense(154770, 5.2)),
(4, Vectors.dense(199650, 4.11))))
.toDF("empId", "features")
val minMaxScaler = new MinMaxScaler().setMin(0.0)
.setMax(5.0)
.setInputCol("features")
.setOutputCol("sFeatures")
val scalerModel = minMaxScaler.fit(employee_data)
val scaledData = scalerModel.transform(employee_data)
println(s"Features scaled to range:
[${minMaxScaler.getMin}, ${minMaxScaler.getMax}]")
Features scaled to range: [0.0, 5.0]
scaledData.select("features", "sFeatures").show(false)
+--------------------+------------------------------------------+
| features | scaledFeatures |
+--------------------+------------------------------------------+
| [125400.0,5.3] | [0.0,2.1326164874551963] |
| [179100.0,6.9] | [3.616161616161616,5.0] |
| [154770.0,5.2] | [1.9777777777777779,1.9534050179211468] |
| [199650.0,4.11] | [5.0,0.0] |
+--------------------+------------------------------------------+
Listing 8-15Use MinMaxScaler to Rescale Features
除了数字数据规范化,另一个经常用于处理数字数据的操作是标准化。当数值数据具有钟形曲线分布时,此操作尤其适用。标准化操作有助于将数据转换为规范化形式,其中数据在-1 和–1 的范围内,平均值为 0。这样做的原因是为了帮助某些 ML 算法在数据具有围绕零均值的分布时更好地学习。StandardScaler
估算器是为标准化操作而设计的。清单 8-16 使用与清单 8-14 相同的输入数据集。输出显示要素值现在以 0 为中心,并带有一个单位的标准差。
import org.apache.spark.ml.feature.StandardScaler
import org.apache.spark.ml.linalg.Vectors
val employee_data = spark.createDataFrame(Seq(
(1, Vectors.dense(125400, 5.3)),
(2, Vectors.dense(179100, 6.9)),
(3, Vectors.dense(154770, 5.2)),
(4, Vectors.dense(199650, 4.11))))
.toDF("empId", "features")
// set the unit standard deviation to true and center around the mean
val standardScaler = new StandardScaler().setWithStd(true)
.setWithMean(true)
.setInputCol("features")
.setOutputCol("sFeatures")
val standardMode = standardScaler.fit(employee_data)
val standardData = standardMode.transform(employee_data)
standardData.show(false)
+-----+--------------+------------------------------------------+
|empId| feature | sFeatures |
+-----+--------------+------------------------------------------+
| 1 |[125400.0,5.3]|[-1.2290717420781212,-0.06743742573177587]|
| 2 |[179100.0,6.9]| [0.4490658767775897,1.3248191055048935]|
| 3 |[154770.0,5.2]|[-0.3112523404805006,-0.15445345893406737]|
| 4 |[199650.0,4.1]| [1.091258205781032,-1.102928220839048]|
+-----+--------------+------------------------------------------+
Listing 8-16Use StandardScaler to Standard the Features Around the Mean of Zero
MLlib 中有更多的估算器可以用来执行大量的数据转换和映射。它们都遵循符合输入数据的标准抽象,并产生一个Model
实例。这些例子旨在说明如何使用这些估算器。第二种估计量的例子是最大似然算法,将在下面的章节中介绍。
管道
在机器学习中,通常会运行一系列步骤来清理和转换数据,然后训练一个或多个 ML 算法来从数据中学习,最后调整模型以实现最佳的模型性能。MLlib 中的管道抽象旨在使该工作流更易于开发和维护。从技术角度来看,MLlib 有一个用于管理一系列阶段的Pipeline
类。每一个都由PipelineStage
类表示,要么是转换器,要么是估计器。Pipeline
抽象是一种估计器。
设置管道的第一步是创建一个 stage 集合,创建一个Pipeline
类的实例,并用 stage 数组对其进行配置。Pipeline
类按照指定的顺序运行这些阶段。如果一个阶段是一个变压器,那么transform()
功能被调用。如果一个阶段是一个估计器,则调用fit()
函数来产生一个转换器。
让我们看一个使用转换器和估算器处理文本的小工作流示例。图 8-10 中描述的小型管道由两个变压器和一个估算器组成。当调用Pipeline.fit()
函数时,输入数据帧的原始文本被传递到Tokenizer
转换器,其输出被传递到HashingTF
转换器,后者将单词转换成特征。Pipeline
类将LogisticRegression
识别为一个估计器,使用计算出的特征调用fit
函数来产生逻辑回归模式l
。
图 8-10 中描述了Pipeline
的代码,清单 8-17 中列出了该代码。抽象是一个估计器。因此,一旦创建并配置了Pipeline
的实例,就必须使用训练数据作为输入来调用fit()
函数,以触发阶段的执行。输出是PipelineModel
的一个实例,它是一种转换器。此时,您可以将测试数据传递给transform()
函数来执行预测。
MLlib 提供了 ML 持久性特性,使得将管道或模型保存到磁盘并在以后加载以执行预测变得容易。持久性特性的好处在于,它被设计成以一种语言中立的格式保存信息。因此,当管道或模型在 Scala 中持久化时,可以用不同的语言读回,比如 Java 或 Python。
许多实际生产管道由许多阶段组成。阶段数多了,就很难理解流程和维护。MLlib Pipeline
抽象可以帮助应对这些挑战。另一个需要注意的关键点是Pipelines
和PipelineModels
都被设计成确保训练和测试数据流通过相同的特征处理步骤。机器学习中的一个常见错误是不一致地处理训练和测试数据,这在模型评估结果中产生了差异。
图 8-10
小型管道示例
import org.apache.spark.ml.{Pipeline, PipelineModel}
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.feature.{HashingTF, Tokenizer}
val text_data = spark.createDataFrame(Seq(
(1, "Spark is a unified data analytics engine", 0.0),
(2, "Spark is cool and it is fun to work with Spark", 0.0),
(3, "There is a lot of exciting sessions at upcoming Spark
summit", 0.0),
(4, "signup to win a million dollars", 0.0) ))
.toDF("id", "line", "label")
val tokenizer = new Tokenizer().setInputCol("line")
.setOutputCol("words")
val hashingTF = new HashingTF()
.setInputCol(tokenizer.getOutputCol)
.setOutputCol("features")
.setNumFeatures(4096)
val logisticReg = new LogisticRegression().setMaxIter(5)
.setRegParam(0.01)
val pipeline = new Pipeline().setStages(Array(
tokenizer, hashingTF, logisticReg))
val logisticRegModel = pipeline.fit(text_data)
// persist model and pipeline
logisticRegModel.write.overwrite()
.save("/tmp/logistic-regression-model")
pipeline.write.overwrite()
.save("/tmp/logistic-regression-pipeline")
// load model and pipeline
val prevModel = PipelineModel.load("/tmp/spark-logistic-regression-model")
val prevPipeline = Pipeline.load("/tmp/logistic-regression-pipeline")
Listing 8-17Using Pipepline to Small a Small Workflow
管道持久性:保存和加载
一旦对模型进行了训练和评估,您可以保存该模型或训练该模型的管道,以便在以后的日子里或在 Spark 集群重新启动后,使用其他数据集进一步评估您的模型。后一种方法是首选的,因为它记住了模型类型;否则,您必须在加载步骤中指定它。
持久化您的模型的主要好处是节省时间和跳过训练步骤,这可能需要几个小时才能完成。
模型调整
模型调整步骤旨在使用一组参数来训练模型,以实现最佳模型性能,从而满足 ML 开发过程的第一步中定义的目标。这个步骤通常是乏味的、重复的和耗时的,因为它需要试验不同的 ML 算法或几组参数。
本节旨在描述 MLlib 提供的一些工具,以帮助完成模型调优步骤中的繁重部分。本节并不打算展示如何执行模型调优。
在详细介绍 MLlib 提供的工具之前,理解以下术语很重要。
-
模型超参数有
-
管理 ML 算法训练过程的配置
-
模型外部的配置,无法从训练数据中学习
-
机器学习实践者在培训过程开始之前提供的配置
-
通过迭代方式为给定的机器学习任务调整的配置
-
-
模型参数是
-
机器学习实践者不提供的属性
-
在训练过程中学习到的训练数据的属性
-
在培训过程中优化的属性
-
执行预测的模型的属性
-
模型超参数的示例包括 k 均值聚类算法中的聚类数、逻辑回归算法中应用的正则化量以及学习率。
模型参数的示例包括线性回归模型中的系数或决策树模型中的分支位置。
MLlib 中帮助模型调优的两个常用类是CrossValidator
和TrainValidationSplit,
,它们都是Estimator
类型。这些类也被称为验证器,它们需要以下输入。
-
第一个输入是需要调整的内容——一个 ML 算法或一个
Pipeline
实例。它一定是一种估计量。 -
第二个输入是用于调整所提供的估计器的一组参数。这些参数也被称为参数网格,用于搜索以找到最佳模型。名为
ParagramGridBuilder
的便利实用程序可用于构建参数网格。 -
最后一个输入是一个评估器,用于根据保留的测试数据评估模型的性能。MLlib 为每个机器学习任务提供了一个特定的评估器,它可以产生一个或多个评估指标,供您了解模型性能。支持常用的机器学习指标,如均方根误差、精度、召回率和准确度
在高层次上,验证器对给定的输入执行以下步骤。
-
根据指定的比率,将输入特征数据分为训练数据集和测试数据集。
-
对于参数网格中的每个组合,给定的估计器与训练数据和参数组合相匹配。
-
指定的评估器根据测试数据评估输出模型。记录并比较性能指标。
-
产生最佳性能的模型与所使用的参数集一起返回。
这些步骤如图 8-11 所示,使得验证器中发生的事情更加直观。
图 8-11
在验证器内部
TrainValidationSplit
验证器根据指定的比率将给定的输入数据分割成训练和验证数据集,然后根据每个参数组合训练和评估数据集对。例如,如果给定的参数集有六个组合,给定的估计器被训练和评估大小次,每次用不同的参数组合。
清单 8-18 提供了一个使用TrainValidationSplit
通过六个参数组合的参数网格调整线性回归估计器的例子。这个例子的重点是TrainValidationSplit
。假设特征工程已经完成,数据帧中有一个名为features
的列。
import org.apache.spark.ml.{Pipeline, PipelineModel}
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.feature.{HashingTF, Tokenizer}
import org.apache.spark.ml.tuning.{ParamGridBuilder, TrainValidationSplit}
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
val text_data = spark.createDataFrame(Seq(
(1, "Spark is a unified data analytics engine", 0.0),
(2, "Spark is cool and it is fun to work with Spark",
0.0),
(3, "There is a lot of exciting sessions at upcoming
Spark summit", 0.0),
(4, "signup to win a million dollars", 0.0) ))
.toDF("id", "line", "label")
val tokenizer = new Tokenizer().setInputCol("line")
.setOutputCol("words")
val hashingTF = new HashingTF().setInputCol(
tokenizer.getOutputCol)
.setOutputCol("features")
val logisticReg = new LogisticRegression().setMaxIter(5)
val pipeline = new Pipeline().setStages(
Array(tokenizer, hashingTF, logisticReg))
// the first parameter has 3 values and second parameter has 2 values,
// therefore the total parameter combinations is 6
val paramGrid = new ParamGridBuilder().addGrid(
hashingTF.numFeatures, Array(10, 100, 250))
.addGrid(logisticReg.regParam, Array(0.1, 0.05))
.build()
// setting up the validator with required inputs - estimator, evaluator, parameter grid and train ratio
val trainValSplit = new TrainValidationSplit()
.setEstimator(pipeline)
.setEvaluator(
new BinaryClassificationEvaluator)
.setEstimatorParamMaps(paramGrid)
.setTrainRatio(0.8)
// train the linear regression estimator
val model = trainValidationSplit.fit(training)
Listing 8-18Example of TrainValidationSplit
CrossValidator
验证器实现了机器学习社区中广为人知的技术来帮助模型调整步骤。这种技术通过将观察值随机分成大小大致相同的非重叠 k 组或折叠,最大化了用于训练和测试的数据量。每个都只使用一次。一折用于测试,剩下的用于训练。这个过程重复 k 次,并且每次都根据随机划分的训练和测试折叠来训练和评估估计器。
图 8-12 以 k 为四个褶皱说明了这个过程。CrossValidator
生成四个训练和测试数据集对,四分之一的数据用于测试,四分之三的数据用于测试。重要的是选择合理的 k 值,以便每个训练和测试组在统计上代表可用的观察值。每个文件夹都有大致相同数量的样本数据。
图 8-12
k=4 的 k 倍示例
当使用带有大量参数组合的验证器时,要注意很长的完成时间,这一点很重要。这是因为图 8-12 中描述的每个实验都是针对每个参数组合进行的。例如,如果 k 是 4,参数组合的数量是 6,那么估计器被训练和评估的总次数是 24。清单 8-17 将清单 8-15 中的TrainValidationSplit
替换为CrossValidator,
的一个实例,并配置为 4 作为 k 值。实际上, k 的值通常为 10 或更高。清单 8-17 结束了对评估者的 25 次训练和评估。
在识别出具有最佳性能的模型后,CrossValidator
在整个数据集上使用相同的参数集来重新训练或重新拟合您的模型。这就是清单 8-19 中的模型总共被训练 25 次的原因。
import org.apache.spark.ml.tuning.CrossValidator
val crossValidator = new CrossValidator()
.setEstimator(pipeline)
.setEvaluator(
new BinaryClassificationEvaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(4)
val model = crossValidator.fit(text_data)
Listing 8-19Example of CrossValidator
如果需要研究或分析中间模型,CrossValidator
可以在调整过程中保留它们。您所需要做的就是在调用setCollectSubmModels
函数时指定一个真值,然后通过调用getCollectSubmModels()
函数.
来访问中间模型
加速模型调整
TrainValidationSplit
和CrossValidator
估计器被设计成消除机器学习开发过程中模型调整步骤的痛苦。您可能会发现,由于不同的参数组合,训练和评估所有不同的模型需要一段时间。参数组合的数量越多,花费的时间就越多。
默认情况下,估计器以连续的方式一次训练和评估一个模型。为了加快这个过程,您可能希望增加并行性,以利用 Spark 集群的计算和内存资源。这是通过在启动模型调整过程之前将并行度设置为 2 或更大的值来实现的。作为《Spark 调谐指南》中的一般指导原则,最大值为 10 通常就足够了。清单 8-20 将 crossValidator 的并行度设置为 6。
crossValidator.setParallelism(6).fit(text_data)
Listing 8-20Setting CrossValidator Parallelism to 6
模型评估者
为了理解模型的性能,您首先需要知道如何计算和评估模型评估指标。每项机器学习任务都使用一组不同的指标,计算它们是乏味的,并且使用数学。幸运的是,MLlib 提供了一组名为 evaluator 的工具来计算指标,这样验证器就可以测量拟合模型在测试数据上的表现。表 8-7 列出了 MLlib 中支持的不同赋值器、支持指标的子集以及简短描述。
表 8-7
支持的评估者
|名字
|
支持的指标
|
描述
|
| --- | --- | --- |
| 回归评估器 | rmse,姆塞,r2,mae,var | 对于回归任务 |
| 二元分类计算器 | areaUnderROC,areaUnderPR | 对于只有两个类别分类任务 |
| 多类分类评估器 | 加权精度、加权回收等 | 对于只有两个以上类别分类任务 |
| 多层分类评估器 | 子准确度,精确度,汉明洛斯,召回,精确度按标签,召回按标签,f1 测量按标签 | 对于多标签分类任务 |
| 分级评估员 | meanAveragePrecisionAtK,PrecisionAtK,ndcgAtK,recallAtK | 对于分级任务 |
行动中的机器学习任务
本节汇集了本章中描述的概念和工具,并将其应用于以下机器学习任务:分类、回归和推荐。使用真实数据集完成机器学习开发过程,可以更清楚地了解所有部分是如何组合在一起的。
本节并不打算全面涵盖每个机器学习算法的超参数,模型调整步骤留给读者作为练习。
分类
分类是研究和使用最广泛的机器学习任务之一,因为它能够帮助解决许多现实生活中与分类相关的问题。比如这是不是信用卡欺诈交易?这是垃圾邮件吗?这是一只猫、一只狗还是一只鸟的图像?
有三种类型的分类。
-
二元分类:这里要预测的标签只有两种可能的类别(例如,欺诈与否,会议论文是否被接受,肿瘤是良性还是恶性)。
-
多类分类:这是指要预测的标签有两个以上可能的类别(例如,图像是狗、猫还是鸟)。
-
多标签分类:这是每个观察可以属于多个类别的地方。电影类型就是一个很好的例子。一部电影可以分为动作片和喜剧片。MLlib 本身不支持这种类型的分类。
MLlib 为分类任务提供了一些机器学习算法。
-
逻辑回归
-
决策图表
-
随机森林
-
梯度增强树
-
线性支持向量机
-
一对多
-
奈伊夫拜厄斯
模型超参数
本例中使用了逻辑回归算法。以下是其模型超参数的子集。每个模型超参数都有一个默认值。
-
family
:可能的值有auto
、binomial
和multinomial
。默认值为auto
,这意味着算法会根据标签列中的值自动选择系列为binomial
或multinomial
。binomial
是针对二元分类的。multinomial
用于多类分类。 -
regParam
:这是控制过拟合的正则化参数。默认值为 0.0。
例子
列表 8-21 试图预测哪些泰坦尼克号乘客在悲剧中幸存。这是一个二元分类机器学习问题。该示例使用逻辑回归算法。信息和数据可在 www.kaggle.com/c/titanic
获得。数据为 CSV 格式,有两个文件:train.csv
和test.csv
。train.csv
文件包含标签列。
提供的数据包含许多有趣的特征;然而,清单 8-21 仅使用年龄、性别和机票等级作为特征。
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature.StringIndexer
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator
val titanic_data = spark.read.format("csv")
.option("header", "true")
.option("inferSchema","true")
.load("/<folder>/train.csv")
// explore the schema
titanic_data.printSchema
|-- PassengerId: integer (nullable = true)
|-- Survived: integer (nullable = true)
|-- Pclass: integer (nullable = true)
|-- Name: string (nullable = true)
|-- Sex: string (nullable = true)
|-- Age: double (nullable = true)
|-- SibSp: integer (nullable = true)
|-- Parch: integer (nullable = true)
|-- Ticket: string (nullable = true)
|-- Fare: double (nullable = true)
|-- Cabin: string (nullable = true)
|-- Embarked: string (nullable = true)
// to start out with, we will use only three features
// filter out rows where age is null
val titanic_data1 = titanic_data.select('Survived.as("label"),
'Pclass.as("ticket_class"),
'Sex.as("gender"), 'Age.as("age"))
.filter('age.isNotNull)
// split the data into training and test with 80% and 20% split
val Array(training, test) = titanic_data1.randomSplit(
Array(0.8, 0.2))
println(s"training count: ${training.count}, test count:
${test.count}")
// estimator: to convert gender string to numbers
val genderIndxr = new StringIndexer().setInputCol("gender")
.setOutputCol("genderIdx")
// transformer: assemble the features into a vector
val assembler = new VectorAssembler().setInputCols(
Array("ticket_class", "genderIdx", "age"))
.setOutputCol("features")
// estimator: the algorithm
val logisticRegression = new LogisticRegression()
.setFamily("binomial")
// set up the pipeline with three stages
val pipeline = new Pipeline().setStages(Array(genderIndxr,
assembler, logisticRegression))
// train the algorithm with the training data
val model = pipeline.fit(training)
// perform the predictions
val predictions = model.transform(test)
// perform the evaluation of the model performance, the default metric is the area under the ROC
val evaluator = new BinaryClassificationEvaluator()
evaluator.evaluate(predictions)
res10: Double = 0.8746657754010692
evaluator.getMetricName
res11: String = areaUnderROC
Listing 8-21Use Logistic Regression Algorithm to Predict the Survival of Titanic Passengers
由BinaryClassificationEvaluator
产生的度量值为 0.87,对于使用三个特性来说,这是一个不错的性能。然而,这个例子没有探究各种超参数和训练参数。我强烈建议您试验各种超参数,看看您的模型是否能比 0.87 表现得更好。
回归
另一个流行的机器学习任务称为回归,它旨在预测一个实数或连续值。例如,您希望预测下一季度的销售收入,或者人口的收入,或者世界上某个地区的降雨量。
MLlib 为回归任务提供了以下机器学习算法。
-
线性回归
-
广义线性回归
-
决策树
-
随机森林
-
梯度增强树
-
保序回归
模型超参数
以下示例使用带有以下超参数的线性回归。
-
regParam
:该正则化参数控制过拟合。默认值为 0.0。 -
fitIntercept
:该参数决定是否拟合截距。默认值为 true。
例子
清单 8-22 试图根据房屋的一系列特征来预测房价。数据集在 www.kaggle.com/c/house-prices-advanced-regression-techniques/data
可用。数据以 CSV 格式提供,有两个文件,train.csv,
和test.csv
。train.csv
文件中的标签列称为SalePrice
。
提供的数据包含许多有趣的特征;然而,清单 8-22 只使用了其中的一个子集。
import org.apache.spark.sql.functions._
import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.feature.StringIndexer
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.ml.regression.LinearRegression
import org.apache.spark.ml.feature.RFormula
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.mllib.evaluation.RegressionMetrics
val house_data = spark.read.format("csv")
.option("header", "true")
.option("inferSchema","true")
.load("<path>/train.csv")
// select columns to use as features
val cols = SeqString
val colNames = cols.map(n => col(n))
// select only needed columns
val skinny_house_data = house_data.select(colNames:_*)
// create a new column called "TotalSF" by adding the value of "1stFlrSF" and "2ndFlrSF" columns
// cast the "SalePrice" column to double
val skinny_house_data1 = skinny_house_data.withColumn("TotalSF",
col("1stFlrSF") + col("2ndFlrSF"))
.drop("1stFlrSF", "2ndFlrSF")
.withColumn("SalePrice",
$"SalePrice".cast("double"))
// examine the statistics of the label column called "SalePrice"
skinny_house_data1.describe("SalePrice").show
+------------+-----------------------------+
| summary| SalePrice|
+------------+-----------------------------+
| count| 1460|
| mean| 180921.19589041095|
| stddev| 79442.50288288663|
| min| 34900.0|
| max| 755000.0|
+------------+-----------------------------+
// create estimators and transformers to setup a pipeline
// set the invalid categorical value handling policy to skip to avoid error
// at evaluation time
val roofStyleIndxr = new StringIndexer()
.setInputCol("RoofStyle")
.setOutputCol("RoofStyleIdx")
.setHandleInvalid("skip")
val heatingIndxr = new StringIndexer()
.setInputCol("Heating")
.setOutputCol("HeatingIdx")
.setHandleInvalid("skip")
val linearReg = new LinearRegression().setLabelCol("SalePrice")
// assembler to assemble the features into a feature vector
val assembler = new VectorAssembler().setInputCols(
Array("LotArea", "RoofStyleIdx",
"HeatingIdx", "LotArea",
"BedroomAbvGr", "KitchenAbvGr",
"GarageCars", "TotRmsAbvGrd",
"YearBuilt", "TotalSF"))
.setOutputCol("features")
// setup the pipeline
val pipeline = new Pipeline().setStages(Array(roofStyleIndxr,
heatingIndxr, assembler, linearReg))
// split the data into training and test pair
val Array(training, test) = skinny_house_data1.randomSplit(
Array(0.8, 0.2))
// train the pipeline
val model = pipeline.fit(training)
// perform prediction
val predictions = model.transform(test)
val evaluator = new RegressionEvaluator().setLabelCol("SalePrice")
.setPredictionCol("prediction")
.setMetricName("rmse")
val rmse = evaluator.evaluate(predictions)
rmse: Double = 37579.253919082395
Listing 8-22Use Linear Regression Algorithm to Predict House Price
RMSE 代表均方根误差。在这种情况下,RMSE 值约为 37,000 美元,这表明还有很大的改进空间。
建议
推荐系统是最直观和最著名的机器学习应用之一。也许事实就是如此,因为几乎每个人都在亚马逊和网飞等热门网站上看到过推荐系统的例子。几乎每一个受欢迎的网站或互联网电子商务公司都有一个或多个推荐系统。推荐系统的常见例子包括你可能在 Spotify 上喜欢的歌曲、你想在 Twitter 上关注的人、你可能在 Coursera 或 Udacity 上喜欢的课程。推荐系统给公司的用户和它自己都带来了好处。用户很高兴不用花太多力气就能找到或发现自己喜欢的物品。由于用户参与度、忠诚度和利润的增加,公司都很高兴。如果一个推荐系统表现好,那就是双赢。
构建推荐系统的常用方法包括基于内容的过滤、协同过滤以及两者的混合。第一种方法需要收集关于被推荐的项目和每个用户的简档的信息。第二种方法需要通过显式或隐式方式仅收集用户活动或行为。显性行为的例子包括对亚马逊上的电影或商品进行评级。隐含行为的例子包括观看电影预告片或描述。第二种方法背后的直觉是“群众的智慧”概念,即过去同意的人将来也会同意。
本节重点介绍协作过滤方法,这种方法的一种流行算法叫做 ALS,代表交替最小二乘。该算法需要的唯一输入是用户-项目评级矩阵,该矩阵通过矩阵分解发现用户偏好和项目属性。一旦找到这两条信息,它们就能预测用户对以前没有见过的物品的偏好。MLlib 实现了 ALS 算法。
模型超参数
MLlib 中的 ALS 算法实现有几个重要的超参数需要注意。以下部分仅包含一个子集。请查阅 https://spark.apache.org/docs/latest/ml-collaborative-filtering.html
的文档。
-
rank
:该参数指定在训练过程中学习到的关于用户和项目的潜在因素或属性的数量。等级的最佳值通常由实验和对准确描述项目所需的属性数量的直觉来确定。默认值为 10。 -
regParam
:处理过拟合的正则化量。该参数的最佳值通常由实验确定。默认值为 0.1 -
implicitPrefs
: ALS 算法支持显性和隐性的用户活动或行为。这个参数告诉我们输入数据代表哪一个。默认值为 false,意味着活动或行为是显式的。
例子
该示例使用在 https://grouplens.org/datasets/movielens/
的电影评级数据集来构建电影推荐系统。具体数据集为 http://files.grouplens.org/datasets/movielens/ml-latest-small.zip
最新的 MovieLens 100K 数据集。该数据集包含 700 名用户对 9000 部电影的大约 100,000 个评级。zip 文件中包含四个文件:links.csv
、movies.csv
、ratings.csv
和tags.csv
。文件中的每一行代表一个用户对一部电影的评价。是这样的格式:userId, movieId, rating, timestamp
。等级从 0 到 5,增量为半星。
清单 8-23 用一组参数训练 ALS 算法,然后根据 RMSE 度量评估模型性能。此外,它在ALSModel
类中调用几个有趣的提供的 API 来获得电影和用户的推荐。
import org.apache.spark.mllib.evaluation.RankingMetrics
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.ml.recommendation.ALS
import org.apache.spark.ml.tuning.{ParamGridBuilder, CrossValidator}
import org.apache.spark.sql.functions._
// we don't need the timestamp column, so drop it immediately
val ratingsDF = spark.read.option("header", "true")
.option("inferSchema", "true")
.csv("<path>/ratings.csv")
.drop("timestamp")
// quick check on the number of ratings
ratingsDF.count
res14: Long = 100004
// quick check who are the active movie raters
val ratingsByUserDF = ratingsDF.groupBy("userId").count()
ratingsByUserDF.orderBy($"count".desc).show(10)
+--------+-------+
| userId| count|
+--------+-------+
| 547| 2391|
| 564| 1868|
| 624| 1735|
| 15| 1700|
| 73| 1610|
| 452| 1340|
| 468| 1291|
| 380| 1063|
| 311| 1019|
| 30| 1011|
+--------+-------+
println("# of rated movies: " +ratingsDF.select("movieId").distinct().count)
# of rated movies: 9066
println("# of users: " + ratingsByUserDF.count)
# of users: 671
// analyze the movies largest number of ratings
val ratingsByMovieDF = ratingsDF.groupBy("movieId").count()
ratingsByMovieDF.orderBy($"count".desc).show(10)
+----------+-------+
| movieId| count|
+----------+-------+
| 356| 341|
| 296| 324|
| 318| 311|
| 593| 304|
| 260| 291|
| 480| 274|
| 2571| 259|
| 1| 247|
| 527| 244|
| 589| 237|
+----------+-------+
// prepare data for training and testing
val Array(trainingData, testData) = ratingsByUserDF.randomSplit(Array(0.8, 0.2))
// setting up an instance of ALS
val als = new ALS().setRank(12)
.setMaxIter(10)
.setRegParam(0.03)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating")
// train the model
val model = als.fit(trainingData)
// perform predictions
val predictions = model.transform(testData).na.drop
// setup an evaluator to calculate the RMSE metric
val evaluator = new RegressionEvaluator().setMetricName("rmse")
.setLabelCol("rating")
.setPredictionCol("prediction")
val rmse = evaluator.evaluate(predictions)
println(s"Root-mean-square error = $rmse")
Root-mean-square error = 1.06027809686058
Listing 8-23Building a Recommender System Using ALS Algorithm Implementation in MLlib
ALSModel
类提供了两组有用的函数来执行推荐。第一组向所有用户或一组特定用户推荐前 n 个项目。第二组用于向前 n 用户推荐所有项目或一组特定项目。清单 8-24 提供了一个调用这些函数的例子。
// recommend the top 5 movies for all users
model.recommendForAllUsers(5).show(false)
// active raters
val activeMovieRaters = Seq((547), (564), (624), (15),
(73)).toDF("userId")
model.recommendForUserSubset(activeMovieRaters, 5).show(false)
+------+---------------------------------------------------------------------------------------------------+
|userId| recommendations |
+------+---------------------------------------------------------------------------------------------------+
| 15 | [[363, 5.4706035], [422, 5.4109325], [1192, 5.3407555], [1030, 5.329553], [2467, 5.214414]] |
| 547 | [[1298, 5.752393], [1235, 5.4936843], [994, 5.426885], [926, 5.28749], [3910, 5.2009006]] |
| 564 | [[121231, 6.199452], [2454, 5.4714866], [3569, 5.4276495], [1096, 5.4212027], [1292, 5.4203687]] |
| 624 | [[1960, 5.4001703], [1411, 5.2505665], [3083, 5.1079946], [3030, 5.0170803], [132333, 5.0165534]]|
| 73 | [[2068, 5.0426316], [5244, 5.004793], [923, 4.992707], [85342, 4.979018], [1411, 4.9703207]] |
+-------+--------------------------------------------------------------------------------------------------+
// recommend top 3 users for each movie
val recMovies = model.recommendForAllItems(3)
// read in movies dataset so we can see the movie title
val moviesDF = spark.read.option("header", "true")
.option("inferSchema", "true")
.csv("<path>/movies.csv")
val recMoviesWithInfoDF = recMovies.join(moviesDF, "movieId")
recMoviesWithInfoDF.select("movieId", "title", "recommendations")
.show(5, false)
+--------+----------------------------------+---------------------------------------------------------+
| movieId| title | recommendations |
+--------+----------------------------------+---------------------------------------------------------+
| 1580 | Men in Black (a.k.a. MIB) (1997) | [[46, 5.6861496], [113, 5.6780157], [145, 5.3410296]] |
| 5300 | 3:10 to Yuma (1957) | [[545, 5.475599], [354, 5.2230153], [257, 5.0623646]] |
| 6620 | American Splendor (2003) | [[156, 5.9004226], [83, 5.699677], [112, 5.6194253]] |
| 7340 | Just One of the Guys (1985) | [[621, 4.5778027], [451, 3.9995837], [565, 3.6733315]] |
| 32460 | Knockin' on Heaven's Door (1997) | [[565, 5.5728054], [298, 5.00507], [476, 4.805148]] |
+--------+----------------------------------+---------------------------------------------------------+
// top rated movies
val topRatedMovies = Seq((356), (296), (318),
(593)).toDF("movieId")
// recommend top 3 users per movie in topRatedMovies
val recUsers = model.recommendForItemSubset(topRatedMovies, 3)
recUsers.join(moviesDF, "movieId")
.select("movieId", "title", "recommendations")
.show(false)
+----------+----------------------------------+-------------------------------------------------------+
| movieId| title | recommendations |
+----------+----------------------------------+-------------------------------------------------------+
| 296 | Pulp Fiction (1994) | [[4, 5.8505774], [473, 5.81865], [631, 5.588397]] |
| 593 | Silence of the Lambs, The (1991) | [[153, 5.839533], [586, 5.8279104], [473, 5.5933723]]|
| 318 | Shawshank Redemption, The (1994) | [[112, 5.8578305], [656, 5.8488774], [473, 5.795221]] |
| 356 | Forrest Gump (1994) | [[464, 5.6555476], [58, 5.6497917], [656, 5.625555]] |
+---------+----------------------------------+-------------------------------------------------------+
Listing 8-24Using ALSModel to Perform Recommendations
在清单 8-24 中,ALS 算法的一个实例用一组参数训练,RSME 大约是 1.06。让我们尝试使用CrossValidator
使用一组参数组合重新训练 ALS 算法的实例,看看是否可以降低 RSME 值。
清单 8-25 为两个超参数设置了一个搜索网格,总共有四个参数组合,还有一个CrossValidator
有三个折叠。这意味着 ALS 算法被训练和评估 12 次,因此需要一两分钟来完成。
val paramGrid = new ParamGridBuilder()
.addGrid(als.regParam,Array(0.05, 0.15))
.addGrid(als.rank, Array(12,20))
.build
val crossValidator = new CrossValidator().setEstimator(als)
.setEvaluator(evaluator)
.setEstimatorParamMaps(paramGrid)
.setNumFolds(3)
// print out the 4 hyperparameter combinations
crossValidator.getEstimatorParamMaps.foreach(println)
{
als_d2ec698bdd1a-rank: 12,
als_d2ec698bdd1a-regParam: 0.05
}
{
als_d2ec698bdd1a-rank: 20,
als_d2ec698bdd1a-regParam: 0.05
}
{
als_d2ec698bdd1a-rank: 12,
als_d2ec698bdd1a-regParam: 0.15
}
{
als_d2ec698bdd1a-rank: 20,
als_d2ec698bdd1a-regParam: 0.15
}
// this will take a while to run through more than 10 experiments
val cvModel = crossValidator.fit(trainingData)
// perform the predictions and drop the
val predictions2 = cvModel.transform(testData).na.drop
val evaluator2 = new RegressionEvaluator()
.setMetricName("rmse")
.setLabelCol("rating")
.setPredictionCol("prediction")
val rmse2 = evaluator2.evaluate(predictions2)
rmse2: Double = 0.9881840432547675
Listing 8-25Use CrossValidator to Tune the ALS Model
通过利用CrossValidator
来帮助调整模型,您已经成功地降低了 RMSE。训练最佳模型可能需要一段时间,但 MLlib 使试验一组参数组合变得很容易。
深度学习管道
如果没有提到深度学习主题,这一章将是不完整的,深度学习是人工智能和机器学习领域最热门的主题之一。已经有许多资源以书籍、博客、课程和研究论文的形式解释深度学习的每个方面。在技术方面,开源社区、大学和大型公司(如谷歌、脸书、微软和其他公司)有许多创新,提出了深度学习框架和最佳实践。这里是深度学习框架的当前列表。
-
TensorFlow 是 Google 创建的开源框架。
-
PyTorch 是由脸书开发的开源深度学习框架。
-
MXNet 是由一群大学和公司开发的深度学习框架。
-
Caffe 是由加州大学伯克利分校开发的深度学习框架。
-
CNTK 是微软开发的开源深度学习框架。
-
Theano 是蒙特利尔大学开发的另一个开放的深度学习框架。
-
BigDL 是英特尔开发的开源深度学习框架。
在 Apache Spark 这边,Databricks 正在推动开发一个名为深度学习管道的项目。它不是另一个深度学习框架,而是旨在现有流行的深度学习框架之上工作。本着 Spark 和 MLlib 的精神,深度学习管道项目提供了高级和易于使用的 API,用于使用 Apache Spark 在 Python 中构建可扩展的深度学习应用程序。这个项目目前正在 Apache Spark 开源项目之外开发,最终,它将被合并到主主干中。在撰写本文时,深度学习管道项目提供了以下功能。
-
常见的深度学习用例只需几行代码就可以实现。
-
在 Spark 中处理图像
-
应用预先训练的深度学习模型进行可扩展预测
-
进行迁移学习的能力,将为类似任务训练的模型应用于当前任务
-
分布式超参数调谐
-
让公开深度学习模型变得容易,这样其他人就可以将它们作为 SQL 中的一个函数来进行预测
有关令人兴奋的深度学习管道项目的更多信息,请访问 https://github.com/databricks/spark-deep-learning
。
摘要
人工智能和机器学习的采用正在稳步增加,未来几年将有许多令人兴奋的突破。MLlib 组件建立在 Spark 的强大基础之上,旨在帮助以简单和可伸缩的方式构建智能应用程序。
-
人工智能是一个广阔的领域,其目标是让机器看起来像具有智能。机器学习是其中一个子领域;它专注于通过用数据训练机器来教会它们学习。
-
构建机器学习应用程序由一系列步骤组成,并且是高度迭代的。
-
Spark MLlib 组件包括用于功能工程、构建、评估和调整机器学习管道的工具和抽象,以及一组众所周知的机器学习算法,如分类、回归、聚类和协作过滤。
-
MLlib 组件引入的有助于构建和维护复杂管道的核心概念是转换器、估计器和管道。管道是一个编排器,确保训练和测试数据流通过相同的特征处理步骤。
-
模型调优是 ML 应用程序开发过程中的一个关键步骤。这是乏味和费时的,因为它涉及在一组参数组合上训练和评估模型。结合管道抽象,MLlib 提供了两个有帮助的工具:
CrossValidator
和TrainValidationSplit
。
九、管理机器学习生命周期
随着公司利用人工智能和机器学习来转变他们的业务,他们很快意识到开发和部署 ML 应用程序不是一项小任务。在第八章中,你了解到机器学习开发过程是一个高度迭代和科学的过程,需要与传统软件开发过程略有不同的工程文化和实践。随着机器学习开发社区,包括数据科学家,*ML 工程师和软件工程师,获得更多开发机器学习应用程序并将其投入生产的经验,一个明显的主题出现了,并已正式形成一个称为 MLOps 的学科。
根据维基百科,MLOps 是一套实践,旨在可靠有效地开发、部署和维护生产中的机器学习模型。谷歌云团队将 MLOps 定义为旨在统一 ML 系统开发和运营的 ML 工程文化和实践。
作为在生产 ML 应用程序方面拥有丰富经验的机器学习先驱,谷歌在一篇名为“机器学习系统中隐藏的技术债务”( https://papers.nips.cc/paper/2015/file/86df7dcfd896fcaf2674f757a2463eba-Paper.pdf
)的开创性论文中分享了其在这一领域的经验和见解。
本章旨在深入探讨开发、管理和部署机器学习应用程序的挑战,然后展示 MLflow 开源项目如何帮助应对一些挑战。此外,它还讨论了一些常见的 ML 模型部署选项。
百万富翁的崛起
MLOps 已经成为 ML 从业者、云提供商和提供机器学习解决方案的初创公司中最热门的话题之一。随着公司投资构建机器学习应用程序,理解它的好处、最佳实践和实现是真实的。
MLOps 概述
MLOps 不是一种技术或平台。它是一个包含一系列实践和工程文化的术语,旨在使开发、部署、维护和监控生产机器学习系统无缝、高效和可靠。MLOps 的目标是最大限度地减少技术摩擦,在尽可能短的时间内以高质量的预测能力和尽可能低的风险将模型从想法变为产品。对于许多企业和 ML 从业者来说,只有在生产中运行的模型才能带来价值。
在高层次上,MLOps 倡导在整个 ML 生命周期中进行自动化和监控,以解决其独特的挑战和需求。虽然 ML 系统的一些需求与标准软件系统中的需求相似,例如持续集成源代码控制、单元测试和持续交付,但是有些需求是独特的。
-
ML 模型的输入数据对 ML 模型预测的质量有很大的影响;因此,测试和验证输入数据非常重要
-
再现性。除了对用于训练 ML 模型的代码进行版本控制之外,还必须跟踪附加信息,例如用于训练的输入数据、训练超参数以及机器学习库及其版本。
-
由于数据的不断变化,ML 模型的质量很容易下降。因此,密切监控模型性能和机器学习特定的指标是必不可少的。
为了应对机器学习的独特挑战,机器学习社区和从业者已经确定了一套企业可以遵循的最佳实践。
-
合作
- 成功实现机器学习的好处需要组织内各个团队之间的协作,例如数据科学家、ML 工程师、数据工程师、软件工程师和 DevOps 工程师。每个团队都带来了独特的技能和知识,为生产机器学习模型的各个步骤做出贡献。因此,它需要一种促进和推动密切合作的工程文化。
-
连续一致的管道
-
为消费者生产机器学习模型数据的数据管道需要进行版本控制,以一定的节奏连续运行,并受到密切监控,以确保最小的中断和高质量。
-
数据管道可能具有特定的数据转换逻辑来产生特征,因此该逻辑需要在训练和服务机器学习管道中一致地实现。
-
-
再现性
- 机器学习开发是一项科学努力,需要迭代,需要可重复性。由于客户行为或业务目标的变化,它需要迭代来适应数据的不断变化。训练和评估模型的所有资产、工件和元数据必须被跟踪和版本控制,以实现可再现性。
-
测试和可观察性
-
机器学习模型部署应该经历与标准软件部署类似的过程,但是具有一些特定于机器学习的统计性质的特定验证,例如模型特征和模型性能评估结果的分布和标准偏差。
-
一旦模型投入生产以预测新数据,密切监视模型性能降级并发出警报是非常重要的。
-
遵循 MLOps 最佳实践使企业能够大幅增加实现机器学习所提供优势的几率。随着利用机器学习的需求增加,MLOps 使得扩展开发速度和维护许多机器学习应用程序变得更加容易。当生产化机器学习的飞轮加速时,MLOps 有助于建立对领导层的信任,从而通过包括自动化、验证、可再现性和声音监控在内的可重复过程来获益。此外,机器学习通过利用从收集的数据中获得的见解,增加了企业在过去十年中构建大数据基础设施的投资回报。
在撰写本文时,许多初创公司和大型云提供商正在竞相发明和构建与 MLOps 相关的解决方案。然而,很难想象有一种“一刀切”的产品可以满足所有 MLOps 的需求。
已经生产机器学习一段时间的公司有一些共同点:投资建立自己的解决方案,称为机器学习平台。比如 Google 有 TFX,FB 有 FBLearner,优步有米开朗基罗,Twitter 有 Cortex,LinkedIn 有 Pro-ML。
本章的下一部分将介绍一个名为 MLflow 的开源项目,这是一个用于管理端到端机器学习生命周期的开源平台。
MLflow 概述
在 MLOps 领域,还没有很多开源项目,但是我怀疑随着 ML 从业者和社区走到一起讨论他们的需求并互相学习,这种情况将会改变。
这个领域最流行的开源项目之一是 MLflow,由 Databricks 创建。它的首次发布是在 2018 年。它提供的能力极其有用,是机器学习从业者所需要的;因此,自最初发布以来,它的受欢迎程度和采用率稳步上升。随着来自社区的贡献越来越多,MLflow 的功能不断成熟和扩展,变得越来越复杂。
MLflow 受欢迎的背后有几个原因。
-
展开性
- MLflow 被设计为开放和可扩展的,因此开源社区可以很容易地贡献和扩展其核心功能。
-
灵活性
- MLflow 被设计为与任何 ML 库一起工作,并且可以与机器学习社区中的编程语言一起使用。
-
可量测性
- MLflow 设计用于小型和大型组织。
-
到处跑
- MLflow 可以在公司的基础设施或大多数云提供商或某人的笔记本电脑上利用和部署。
Apache Spark 成功的原因之一是它的易用性。遵循这个配方,MLflow 的创建者希望最大限度地减少使用 MLflow 的摩擦,因此他们在设计 MLflow 时牢记了这两个原则:API 优先和模块化设计。API 优先原则鼓励从最终用户的需求向后工作,并提供一组编程 API 来满足这些需求。模块化设计为用户提供了一条简单的入门之路,以及以最适合其使用案例的方式逐步采用 MLflow 平台的自由。
有关 MLflow 的更多信息,请访问 https://mlflow.org
。GitHub 项目在 https://github.com/mlflow
。
MLflow 组件
从逻辑上讲,MLflow 平台提供的管理端到端机器学习生命周期的功能可以分为四个部分,如图 9-1 所示。MLflow 组件是模块化的,因此您可以灵活自由地采用一个或多个组件来满足您的机器学习用例及需求。
图 9-1
MLflow 组件
如上所述,机器学习开发是一项科学努力,需要运行许多实验,对输入进行各种小的调整,以达到最佳模型。跟踪组件是专门为这个目的设计的,它提供了跟踪实验输入、元数据和输出的工具。一旦收集了不同实验运行的数据,就可以很容易地进行比较、可视化和共享。
项目组件定义了一种标准格式,用于将机器学习代码打包为自包含的可执行单元,以促进机器学习模型在各种运行时平台(如本地笔记本电脑或云环境)上的再现性。轻松复制机器学习模型的能力增加了数据科学家之间的合作及其生产力。
模型组件定义了一种标准格式,用于打包机器学习模型,以便轻松部署到各种模型服务环境,例如本地机器或云提供商,如 Azure、GCP 或 AWS。
模型注册组件提供了存储机器学习模型的集中方式,以实现管理其生命周期和谱系的协作方式。一旦注册了机器学习模型,它就可以通过审计跟踪来帮助管理模型版本化和生产化。
MLflow 提供了多种语言的 API,如 Python、R、Java 和 Scala,命令行界面和 UI,供您与其每个组件进行交互。
工作中的 MLflow
本节将更详细地介绍每个组件,以便更好地理解其动机,并学习如何使用提供的 UI、命令行工具和 API 与它们进行交互。
运行以下示例的先决条件是 Python 3.x、scikit-learn 0.24.2 和 MLflow 1.18 或更高版本。假设您的计算机已经安装了 Python 3.x,您可以使用清单 9-1 中列出的命令安装其余的。
pip install scikit-learn
pip install mlflow
mlflow --version # to test the installation and version
# you should see the output similar to below
mlflow, version 1.18.0
Listing 9-1Installing MLflow and scikit-learn
物流跟踪
MLflow 跟踪组件背后的动机是使数据科学家能够在模型开发阶段开发和优化他们的模型时跟踪所有需要和产生的工件。在此阶段,数据科学家通常需要运行许多实验,通过调整各种输入参数(如输入要素、算法和超参数)来优化模型性能。
传统上,数据科学家使用记事本、文档或电子表格来跟踪他们实验的细节。不幸的是,这种方法是手动的,容易出错,并且实验结果不容易共享、可视化以及与其他实验进行比较。
从概念上讲,每次模型训练代码运行时产生的跟踪信息被组织成一次运行,,您可以记录以下信息。
-
参数:您选择的键/值输入参数,其中值是一个字符串。参数的例子是超参数,如学习率、树的数量和正则化。
-
指标:键/值指标,其中值是数字。每个指标都可以在整个运行过程中更新。你可以想象它的全部历史。度量的例子包括准确性、RMSE 和 F1。
-
工件:输出任意格式的文件。伪像的例子是图像的准确性和特征的重要性。
-
标签:当前活动运行中的一个或多个键/值标签。
-
元数据:关于运行的一般信息,例如运行日期和时间、名称、源代码和代码版本。
您可以选择将多次运行组织到一个实验中,该实验通常是为特定的机器学习任务而设计的。MLflow Tracking 组件提供了记录运行的 API,它们有多种语言版本:Python、R、Java、Scala 和 REST APIs。跟踪服务器记录运行信息,这些信息可以在调用 API 的应用程序所在的本地机器上运行,也可以在远程机器上运行。实质上,物流跟踪是一个客户端-服务器应用程序,其架构如图 9-2 所示。
图 9-2
ml 流跟踪组件架构
出于学习或探索的目的,在本地运行 ML 跟踪服务器更容易。在您的团队希望集中跟踪和管理机器学习模型生命周期的团队环境中,在远程主机上配置和运行跟踪服务器更有意义。MLflow 提供了两种简单的方法来指定跟踪服务器的运行位置。第一种方法是用跟踪服务器的 URI 设置MLFLOW_TRACKING_URI
环境变量。第二种方法是在应用程序中使用mlflow.set_tracking_uri()
来指定这样的 URI。如果没有设置跟踪服务器 URI,无论您在哪里运行程序,跟踪 API 都会将运行信息记录在本地的一个mlruns
目录中。
一旦运行信息在服务器中可用,您就可以通过提供的 MLflow 跟踪 UI、使用跟踪 API 或使用 Spark 来访问它,如图 9-2 的右侧所示。
运行信息可以分为两种类型:结构化数据和非结构化数据。工件,比如图像、模型或者数据文件,被认为是非结构化的,并存储在工件存储中。其余的运行信息被视为结构化数据,存储在后端存储中。工件存储可以是本地文件系统上的一个文件夹,也可以是云提供商的分布式存储,比如 AWS S3、Azure blob 存储或 Google 云存储。后端存储可以是本地文件系统上的一个文件夹,也可以是一个 SQL 存储,如 Postgres、MySQL 或 SQLite,如图 9-3 所示。
图 9-3
MLflow 跟踪服务器后端存储选项
在存储工件方面,MLflow 客户端 API 从跟踪服务器获得工件存储 URI。然后它负责将工件直接上传到工件存储。
对 MLflow 跟踪组件体系结构和运行信息的存储位置有了很好的理解后,下一部分将展示如何启动 MLflow 跟踪服务器的实例,使用跟踪 API 来跟踪运行,然后使用跟踪 UI 来可视化运行信息。
清单 9-2 使用本地目录作为工件存储,使用本地 SQLite 文件作为后端存储。在启动 MLflow 服务器来管理运行信息和工件之前,您需要创建两个目录:一个用于数据库后端存储,另一个用于工件存储。清单 9-2 显示了启动 MLflow 服务器的命令。
Note
为了使用模型注册功能,您必须使用数据库后端存储运行 MLflow 追踪服务器
mlflow server --backend-store-uri sqlite:////<directory>/backend-store/mlflow.db --default-artifact-root <directory>/artifact-store
# you should see the following in the console if the server was started successfully
[2021-07-24 08:17:55 -0700] [81975] [INFO] Starting gunicorn 20.0.4
[2021-07-24 08:17:55 -0700] [81975] [INFO] Listening at: http://127.0.0.1:5000 (81975)
[2021-07-24 08:17:55 -0700] [81975] [INFO] Using worker: sync
[2021-07-24 08:17:55 -0700] [81978] [INFO] Booting worker with pid: 81978
[2021-07-24 08:17:55 -0700] [81979] [INFO] Booting worker with pid: 81979
[2021-07-24 08:17:55 -0700] [81980] [INFO] Booting worker with pid: 81980
[2021-07-24 08:17:55 -0700] [81981] [INFO] Booting worker with pid: 81981
Listing 9-2Start MLflow Server with SQLlite Database Back End and Local Artifact Store
现在 MLflow 跟踪服务器已经启动并运行,将您的浏览器指向http://localhost:500
以查看 MLflow UI。它看起来有点像图 9-4 。
图 9-4
MLflow 用户界面
第一个示例演示了如何使用各种跟踪 API 来跟踪一次运行的不同信息。这个例子的源代码在chapter9/simple-tracking.py
文件中。默认情况下,该示例将 MLflow 追踪 URI 设置为http://localhost:5000
,因此在使用清单 9-3 中的命令执行该 Python 脚本之前,请确保您的 MLflow 追踪服务器已经启动并正在运行。
python simple-tracking.py
# the output would looking something like below
starting a run with experiment_id 1
done logging artifact
Done tracking on run
experiment_id: 1
run_id: cb17324d40764a428b3d983e8ac4d1dd
Listing 9-3Executing simple-tracking.py
清单 9-4 中显示的simple-tracking.py
脚本是以一种安全的方式编写的,可以多次运行,每次它都在同一个实验simple-tracking-experiment
下创建一个新的运行。如果运行五次,MLflow 跟踪用户界面将如图 9-5 所示,其中以表格形式显示了运行情况,包括每次运行的开始时间、记录运行信息的用户等信息。
图 9-5
运行五次后的 MLflow 跟踪 UI
import os
import mlflow
import numpy as np
import matplotlib.pyplot as plt
from random import random, randint
from mlflow import log_metric, log_param, log_params, log_artifacts
if __name__ == "__main__":
#mlflow.set_tracking_uri("http://localhost:5000")
experiment_name = "simple-tracking-experiment"
experiment = mlflow.get_experiment_by_name(experiment_name)
experiment_id = experiment.experiment_id if experiment else None
if experiment_id is None:
print("INFO: '{}' does not exist. Creating a new experiment
experiment".format(experiment_name))
experiment_id = mlflow.create_experiment(experiment_name)
print("starting a run with experiment_id
{}".format(experiment_id))
with mlflow.start_run(experiment_id=experiment_id) as run:
# Log a parameter (key-value pair)
log_param("mlflow", "is cool")
log_param("mlflow-version", mlflow.version.VERSION)
params = {"learning_rate": 0.01, "n_estimators": 10}
log_params(params)
# Log a metric; metrics can be updated throughout the run
log_metric("metric-1", random())
for x in range(1,11):
log_metric("metric-1", random() + x)
# Log an artifact (output file)
if os.path.exists("images"):
log_artifacts("images")
print("done logging artifact")
else:
print("images directory does not exists")
image = np.random.randint(0, 256, size=(100, 100, 3),
dtype=np.uint8)
mlflow.log_image(image, "random-image.png")
fig, ax = plt.subplots()
ax.plot([0, 2], [2, 5])
mlflow.log_figure(fig, "figure.png")
experiment = mlflow.get_experiment(experiment_id)
print("Done tracking on run")
print("experiment_id: {}".format(experiment.experiment_id))
print("run_id: {}".format(run.info.run_id))
Listing 9-4Content of simple-tracking.py
MLflow UI 中一个非常有用的特性是比较多次运行的指标。您只需通过点击这些运行的复选框来选择两个或多个运行,然后点击比较按钮来并排比较它们,如图 9-6 所示。
图 9-6
并排比较运行
让我们仔细看看simple-tracking.py
脚本中发生了什么。它首先设置跟踪服务器的跟踪 URI。然后它决定是否创建一个名为简单跟踪实验的实验,如果它还不存在的话。接下来,它使用 MLflow,一个高级的 fluent API,使用 Python with block
开始运行,并在with
块结束时自动终止运行。with
块包含记录参数、度量、工件、图像和数字的各种跟踪 API 的例子。我强烈建议您访问 MLflow API 文档网站,如 https://mlflow.org/docs/latest/python_api/index.html
,了解各种 API 及其用法。
如果您注释掉设置跟踪 URI 的行并运行清单 9-3 中的命令,MLflow 跟踪 API 会将运行信息记录在名为mlruns.
的本地目录中。要查看该本地目录中的运行信息,请运行mlflow ui
命令,然后将浏览器指向http://localhost:5000
。
每次运行都属于一个特定的实验,因此当开始运行而没有指定实验 ID 时,MLflow 会在默认实验中创建它。
要查看每次运行的详细信息,只需单击运行开始时间的链接。你会看到类似图 9-7 的东西。
图 9-7
运行的详细信息
关于跑步的常规元数据显示在顶部,然后每种类型的跟踪信息显示在单独的部分。对于每个更新多次的指标,您可以通过单击指标名称的链接来查看每个指标的可视化效果。在图 9-8 中描绘了度量-1 的图表。
图 9-8
指标的可视化
将 MLflow 跟踪集成到 ML 模型训练脚本中的一种方法是启动跟踪运行,并记录在模型训练逻辑开始时使用的参数。然后,添加调用来记录模型评估度量、模型以及它之后的任何工件。清单 9-5 就是一个例子。
with mlflow.start_run(experiment_id=experiment_id) as run:
mlflow.log_param("MLflow version", mlflow.version.VERSION)
params = {'n_estimators': n_estimators,
'max_depth': max_depth,
'min_samples_split': min_samples_split,
'learning_rate': learning_rate, 'loss': 'ls'}
mlflow.log_params(params)
gbr = ensemble.GradientBoostingRegressor(**params)
gbr.fit(X_train, y_train)
y_pred = gbr.predict(X_test)
# calculate error metrics
mae = metrics.mean_absolute_error(y_test, y_pred)
mse = metrics.mean_squared_error(y_test, y_pred)
rsme = np.sqrt(mse)
r2 = metrics.r2_score(y_test, y_pred)
# Log model
mlflow.sklearn.log_model(gbr, "GradientBoostingRegressor")
# Log metrics
mlflow.log_metric("mae", mae)
mlflow.log_metric("mse", mse)
mlflow.log_metric("rsme", rsme)
mlflow.log_metric("r2", r2)
experiment = mlflow.get_experiment(experiment_id)
print("Done training model")
print("experiment_id: {}".format(experiment.experiment_id))
print("run_id: {}".format(run.info.run_id))
Listing 9-5Integrate MLflow Tracking into Model Training Logic
事实证明,在使用各种机器学习库(如 scikit-learn、TensorFlow、Spark 和 Keras)训练机器学习模型时,记录指标、参数和模型的需求是常见的。MLflow 跟踪组件通过提供一个名为mlflow.autlog()
的 API 进一步简化了这个过程。在模型定型代码之前添加这一行代码会自动记录所有的公共信息,而不需要显式的 log 语句。清单 9-6 就是一个例子。
# enable auto logging
mlflow.autolog()
# prepare training data
X = np.array([[1, 1], [1, 2], [2, 2], [2, 3]])
y = np.dot(X, np.array([1, 2])) + 3
# train a model
model = LinearRegression()
with mlflow.start_run() as run:
model.fit(X, y)
Listing 9-6MLflow Automatic Logging
在写这本书的时候,对自动日志的支持还处于试验阶段。请查阅 https://mlflow.org/docs/latest/tracking.html#automatic-logging
上的文档,了解每个受支持库的最新信息。
MLflow 项目
MLflow 项目组件将项目打包格式标准化为可在多种平台上重用和再现。
围绕机器学习库的几项创新训练了模型,如 TensorFlow、PyTorch、Spark MLlib 和 XGBoost。数据科学家倾向于支持能够帮助他们为其业务用例生成优化的机器学习模型的库。如今,数据科学家可以使用大量的计算资源来训练小到大的机器学习模型,如本地机器、Docker、云等等。
项目组件组织和定义机器学习项目,以在可执行单元中捕获代码、配置、依赖性和数据。因此,数据科学家可以轻松地在他们的项目中使用任何机器学习库,并在任何计算平台上运行他们的项目(见图 9-9 )。
图 9-9
MLFlow 项目详细信息
每个 MLflow 项目只是一个文件目录或一个 Git 存储库。虽然它是可选的,但是强烈建议您的项目包含一个名为 MLproject 的文件,该文件指定了控制项目执行的环境、参数和入口点。一个项目支持以下类型的环境,每一种环境都需要自己的定义方式。
-
Conda :使用 Conda 包管理系统,可以支持 Python 包和原生库,在
-
Docker :使用一个 Docker 容器环境,它可以支持几乎任何类型的依赖项来执行您的 MLflow 项目
-
系统:执行 MLflow 项目的当前系统环境
有关 MLflow 中各种支持环境的更多信息,请参考位于 https://mlflow.org/docs/latest/projects.html#specifying-projects
的 MLflow 项目文档。
除了 MLproject 文件之外,MLflow 项目通常还包括一个定义环境的文件和另一个包含模型定型逻辑的文件。清单 9-7 显示了使用 Conda 环境的 MLflow 项目中的一个示例 MLproject 文件和conda.yml
文件的内容。在 MLproject 中设置如清单 9-7 所示的参数是一个很好的做法,这样就可以很容易地从命令行覆盖它们,这样数据科学家就可以很容易地在他们的模型优化过程中尝试不同的值。
# MLproject file
name: boston-housing-price
conda_env: conda.yaml
entry_points:
main:
parameters:
run_name: {type: str, default: "run_name"}
n_estimators: {type: int, default: 100}
max_depth: {type: int, default: 4}
min_samples_split: {type: int, default: 2}
learning_rate: {type: float, default: 0.01}
command: |
python train.py \
--n_estimators={n_estimators} \
--max_depth={max_depth} \
--min_samples_split={min_samples_split} \
--learning_rate={learning_rate}
# conda.yaml
channels:
- conda-forge
dependencies:
- python=3.7.6
- pip
- pip:
- mlflow
- scikit-learn==0.24.2
- cloudpickle==1.6.0
Listing 9-7An Example of MLproject File with Conda Environment
既然您已经知道了如何构建一个 MLflow 项目,接下来的部分就是学习如何运行它们。MLflow 项目组件提供了两种以编程方式运行项目的方法:mlflow run
命令行工具和mlflow.projects.run()
API。这两种方法采用相似的参数,工作方式也相似。清单 9-8 使用命令行工具运行一个 MLflow 项目。您可以通过发出mlflow run --help
命令来显示它的用法。
第一个也是最重要的参数是项目 URI,它要么是本地文件系统上的一个目录,要么是 Git 存储库路径。清单 9-8 包含了运行存在于本地目录中的 MLflow 项目的几个例子。
# run the boston-housing-price MLflow project with creating a
# new conda environment and using default parameter values and
# add run under the boston-housing-price experiment.
mlflow run <chapter9>/boston-housing-price --experiment-name=boston-housing-price
# similar to the one above, except without creating a
# new conda environment
mlflow run <chapter9>/boston-housing-price --no-conda --experiment-name=boston-housing-price
# to overwrite one or more parameter value, specify them using -P # format
mlflow run <chapter9>/boston-housing-price --no-conda -P learning_rate=0.06 --experiment-name=boston-housing-price
Listing 9-8Run MLflow Project from Local Directory
当运行使用 Conda 环境的 MLflow 项目时,MLflow 首先创建一个新的 Conda 环境,然后下载在conda.yaml
文件中指定的所有依赖项,因此完成所有步骤可能需要一段时间。这在尝试从其他人的 MLflow 项目中复制模型或验证 MLflow 项目的可重复性时非常有用。指定--no-coda
命令参数可以跳过 Conda 创建步骤,从而加快项目构建过程。这是非常有用的,当你把你的 ml 项目。
为了适应各种应用程序开发基础设施,MLflow 项目支持其他环境,如 Docker 和 Kubernetes。它们提供了更多的灵活性,但是设置起来有点复杂。
ml 流程模型
MLflow 模型组件背后的动机是通过标准化 ML 模型打包格式来促进模型互操作性,以便可以使用任何流行的机器学习库来开发它们,并将其部署到一组不同的执行环境,如图 9-10 所示。例如,您可以在 PyTorch 中开发一个模型,在您的本地 Docker、Spark 或某个云提供商 ML 平台上部署它并执行推理。MLflow Models 使用的解决方案是通过定义一个统一的模型抽象来捕捉模型的味道。
图 9-10
MLflow 模型抽象
口味是使 MLflow 模型组件通用和有用的关键概念。本质上,风味是一种约定,部署工具可以通过它来理解模型;因此,有可能开发出与使用任何 ML 库训练的模型一起工作的工具,而无需将每个工具与每个特定的库集成。MLflow 定义了几种受支持的风格,所有内置部署工具都支持这些风格。
与 MLflow 项目类似,MLflow 模型是包含一组文件的目录。其中有一个名为 MLmodel 的文件,它包含一些关于模型的元数据,并定义了可以查看模型的风格。如果您的模型训练脚本使用 API log_model
来记录模型或者使用 API save_model
来保存模型,那么会自动创建一个模型目录,其中包含所有适当的文件,这些文件包含有关环境和依赖项的信息,以便加载和服务它。图 9-10 显示了波士顿房价项目中 MLflow 模型目录及其内容的示例。导航到 boston-housing-price experiment 下的一个运行,然后向下滚动到 artifacts 部分。你会看到类似图 9-11 的东西。
图 9-11
MLFlow 模型目录及其内容
MLmodel 文件捕获模型的一些元数据,比如它是何时创建和运行的。更重要的是,它还包含模型签名和风味。清单 9-9 显示了chapter9/airbnb-price/train.py
中的mlflow.autoLog
API 生成的 MLmodel 文件的内容。
artifact_path: model
flavors:
python_function:
env: conda.yaml
loader_module: mlflow.sklearn
model_path: model.pkl
python_version: 3.7.6
sklearn:
pickled_model: model.pkl
serialization_format: cloudpickle
sklearn_version: 0.24.2
run_id: fc9bf6efeff74752812debc131b6c369
signature:
inputs: '[{"name": "bedrooms", "type": "double"},
{"name": "beds", "type": "double"},
{"name": "bathrooms", "type": "double"}]'
outputs: '[{"type": "tensor",
"tensor-spec": {"dtype": "float64",
"shape": [-1]}}]'
utc_time_created: '2021-07-28 20:14:17.612140'
Listing 9-9Content of MLmodel
模型签名定义了模型输入和输出的模式。模型输入模式指定了执行模型推理所需的特性。下一节将给出一个指定特性的例子。
清单 9-9 展示了两种风格:python_function 和 sklearn。python_function 风格定义了一种通用的自包含文件系统模型格式,专门用于 python 模型。这使得 MLflow 提供的模型部署和服务工具能够与任何 Python 模型一起工作,而不管哪个 ML 库训练了该模型。因此,任何 Python 模型都可以在各种运行时环境中轻松生产。
conday.yaml
和requirements.txt
分别捕获依赖项和环境信息,因此在部署时可以很容易地创建类似的环境。
MLflow 内置的模型持久性实用程序负责为各种流行的 ML 库打包模型,如 PyTorch、TensorFlow、scikit-learn、LightGBM 和 XGBoost。如果您的模型需要特殊处理,MLflow 支持持久化和加载定制模型格式。
除了提供一组 API 来管理模型生命周期之外,MLflow 的模型组件还提供了命令行工具来部署、加载和服务模型。
为了演示命令行工具的用法,下一节将使用airbnb-price
MLflow 项目,这是一个简单的 MLflow 项目,使用 scikit-learn 随机森林算法来预测 Airbnb 房源的价格。为了简单起见,它只使用三个特性:卧室的数量、床的数量和浴室的数量。这个项目位于chapter9/airbnb-price
文件夹中,train.py 训练脚本使用mlflow.autoLog
API 来自动记录参数、度量和模型。为了训练模型,您可以发出清单 9-10 中列出的命令之一。此示例假设 MLflow 已经启动,并且正在本地计算机的端口 5000 上运行。
# make sure to set the MLFLOW tracking server URI first
export MLFLOW_TRACKING_URI=http://localhost:5000
# run airbnb-price project
# with the default 100 estimators and max depth of 4
mlflow run ./airbnb-price --no-conda --experiment-name=airbnb-price
# with the 300 estimators and max depth of 9
mlflow run ./airbnb-price --no-conda --experiment-name=airbnb-price -P n_estimators=300 -P max_depth=9
Listing 9-10Run airbnb-price MLflow Project
接下来,导航到 MLflow Tracking UI 中的airbnb-price
实验下记录的最新运行,并在工件部分下的 MLmodel 文件中找到run_id
。接下来,通过在本地机器上启动一个 web 服务器,使用mlflow serve
命令行工具来服务与所提供的运行 id 相关联的模型。清单 9-11 中的mlflow serve c
命令启动一个使用端口 7000 运行的 web 服务器,并指示 MLflow 使用 python_function 风格。
# replace run id with the real run id
# the command below will launch the webserver that
# listens on port 7000.
mlflow models serve --model-uri runs:/<run id>/model -p 7000 --no-conda
# the output of the above command looks something like below
2021/07/28 19:50:25 INFO mlflow.models.cli: Selected backend for flavor 'python_function'
2021/07/28 19:50:25 INFO mlflow.pyfunc.backend: === Running command 'gunicorn --timeout=60 -b 127.0.0.1:7000 -w 1 ${GUNICORN_CMD_ARGS} -- mlflow.pyfunc.scoring_server.wsgi:app'
[2021-07-28 19:50:26 -0700] [36709] [INFO] Starting gunicorn 20.0.4
[2021-07-28 19:50:26 -0700] [36709] [INFO] Listening at: http://127.0.0.1:7000 (36709)
[2021-07-28 19:50:26 -0700] [36709] [INFO] Using worker: sync
[2021-07-28 19:50:26 -0700] [36712] [INFO] Booting worker with pid: 36712
[2021-07-28 19:54:24 -0700] [36709] [INFO] Handling signal: winch
Listing 9-11Launch Webserver to Perform Model Inference
为了使用airbnb-price
随机森林模型执行推理,您使用curl
命令行工具向invocations
REST 端点发送 HTTP 请求。列表 9-12 包含一些预测 Airbnb 列表价格的示例。
# single prediction
curl http://127.0.0.1:7000/invocations -H 'Content-Type: application/json' -d '{"columns": ["bedrooms","beds","bathrooms"], "data": [[1,1,1]]}'
# multiple predictions
curl http://127.0.0.1:7000/invocations -H 'Content-Type: application/json' -d '{"columns": ["bedrooms","beds","bathrooms"], "data": [[1,1,1], [2,2,1], [2,2,2], [3,2,2]]}'
# The HTTP request response contains a single value, which is the predicted price of an Airbnb listing with the specified features.
Listing 9-12Perform Model Inferencing Using HTTP Requests
您还可以使用mlflow.model
模块中预测的 API 以编程方式执行模型推断。
MLflow 模型组件提供了许多其他功能。更多信息在 www.mlflow.org/docs/latest/models.html
。
MLflow 模型注册表
MLflow Registry 组件背后的动机是提供管理 MLflow 模型的完整生命周期的方法,如图 9-12 所示。该生命周期包括关于 MLflow 实验和生成模型的运行的沿袭信息、模型注册和版本控制,以及在部署过程中使用审计跟踪和注释将模型从一个阶段转换到另一个阶段的工作流。该组件是最新的,是在 MLflow 1.7 中引入的。
图 9-12
MLflow 模型生命周期
Note
要使用模型注册表功能,必须使用数据库后端存储运行 MLflow tracking 服务器。
与其他 MLflow 组件一样,这个组件也提供了 UI、API 和命令行工具供您进行交互。下一节提供了管理从实验airbnb-price
中的一次运行中产生的模型生命周期的例子。
MLflow 模型生命周期的第一步是模型注册。在将 MLflow 模型添加到模型注册表之前,您必须使用log_model
API 或通过autolog
API 登录。每个注册的模型可以有一个或多个版本。这种模型名称和版本组合使得在完全投入生产之前执行推断和跟踪 A/B 测试变得容易。当模型在模型注册中心注册时,必须提供一个名称。如果模型名称还不存在,那么从版本 1 开始添加。否则,将自动创建新的模型版本。
要从用户界面注册某次运行产生的模型,您首先导航到该运行的详细页面,向下滚动到工件部分,选择顶层文件夹,并单击注册模型按钮(参见图 9-13 )。
图 9-13
模型注册
弹出注册型号对话框,输入型号名称,如图 9-14 所示。如果型号名称已经存在,您会看到一个下拉列表供您选择。
图 9-14
注册模型对话框
要在模型注册完成后查看注册的模型,请通过单击 MLflow UI 顶部的模型链接导航到注册的模型页面以查看所有注册的模型。你会看到类似图 9-15 的东西。
图 9-15
注册型号列表页面
每个模型都有一个概述页面,显示各种活动的模型版本。要查看 Airbnb SF-A 模型的概述页面,请点按模型名称。你会看到类似图 9-16 的东西。
图 9-16
注册模型详细信息页面
注册型号的内置阶段为暂存、生产和存档。通过导航至模型版本页面并点击阶段下拉菜单,您可以将模型和版本转换至特定阶段(参见图 9-17 )。当前模型阶段向您展示了特定模型版本可以转换到的可能阶段。选择一个阶段后,显示阶段转换确认对话框进行确认,如图 9-18 所示。
图 9-18
模型阶段转换
图 9-17
过渡模型版本
如果一个注册的模型有多个版本,并且处于不同的阶段,那么 model overview 页面会给你一个很好的鸟瞰图。图 9-19 中描述了一个例子。
图 9-19
模型版本阶段的鸟瞰图
前面的示例使用 MLflow Model Registry UI 来管理模型的生命周期,从注册到将它们转换到各个阶段。您可以通过使用提供的 API 以编程方式执行相同的任务。表 9-1 中列出的 API 使得模型管理生命周期与 CI/CD 系统的集成变得容易。例如,如果 CI/CD 管道以规则的节奏连续训练模型,并且如果模型性能通过了预定的标准,则 CI/CD 管道可以容易地过渡到下一个适当的阶段,以供数据科学家分析和确定下一步。
表 9-1
与模型注册交互的 API
|名字
|
描述
|
| --- | --- |
| mlflow.register_model | 使用运行 URI 和模型名称将模型添加到注册表中。如果提供的模型名称不存在,则创建版本 1;否则,将创建一个新版本。 |
| mlflow client . create _ registered _ model | 用提供的模型名称注册一个全新的空模型。如果这样的名称已经存在,则会引发异常。 |
| mlflow client . create _ model _ version | 使用提供的名称、源和 run_id 创建模型的新版本。 |
| mlflow。
| mlflow client . update _ model _ version | 使用提供的模型名称、版本和新描述更新特定版本的模型描述。 |
| mlflowclient . rename _ registered _ model | 重命名现有的注册型号名称。 |
| mlflow client . transition _ model _ version _ stage | 将注册的模型转换到以下阶段之一:登台、生产或归档 |
| mlflow client . list _ registered _ models | 获取注册表中所有注册的模型。 |
| mlflow client . search _ model _ versions | 使用注册的型号名称搜索型号版本列表。 |
| mlflow client . delete _ model _ version | 删除注册型号名称的特定版本。 |
| mlflowclient . delete _ registered _ model _ version | 删除已注册的模型及其所有版本。 |
有关模型注册 API 的完整列表,请阅读位于 https://mlflow.org/docs/latest/model-registry.html#api-workflow
的 MLflow 模型注册 API 工作流文档。
模型部署和预测
模型部署策略在很大程度上取决于模型预测需求,而这两者往往是相辅相成的。当涉及到模型预测时,不同的机器学习用例具有不同的需求和要求。一些标准要求包括延迟、吞吐量和成本。直到最近,模型部署主题通常被机器学习研究论文忽略。
将机器学习应用于业务用例时,了解不同的部署选项以及何时使用它们非常重要。本节描述了两种常见的模型部署策略和模型预测场景,以及 Spark 的适用范围。
两种常见的模型预测方案是联机预测和脱机预测。表 9-2 根据标准要求比较了这两种情况。
表 9-2
在线预测与离线预测
|方案
|
潜伏
|
生产能力
|
费用
|
| --- | --- | --- | --- |
| 在线的 | 毫秒 | 低的 | 变化 |
| 脱机的 | 几秒到几天 | 高的 | 变化 |
当机器学习模型预测是执行特定用户活动的在线系统的一部分时,使用在线预测场景,这通常意味着它需要很快。因此,延迟需要以毫秒为单位。在线预测的例子有在线广告、欺诈检测、搜索和推荐等等。在线预测的部署策略包括构建和管理支持 REST 或 gRPC 协议的预测服务,以并发执行模型预测并支持高请求率。响应延迟必须很低,只有几十毫秒。换句话说,预测服务必须是可伸缩的和可靠的。预测服务通常是一种无状态服务,位于负载平衡器之后,运行在一个机器集群或 Kubernetes 节点上。与在线预测相关的成本是延迟要求和预测请求速率的规模的函数。
Spark MLlib 组件提供的模型预测无法满足低延迟要求。因此,您要么使用 PyTorch、TensorFlow 和 XGBoost 等机器学习库来训练您的机器学习模型,要么使用 ONNX ( http://onnx.ai
)中的工具在 Spark 之外导出您的 MLlib 训练模型。
当需要以一定的节奏大批量执行模型预测时,离线预测场景非常有用。它不是在线用户流的一个组成部分。离线预测的例子是为每个用户生成的电影推荐、用户流失倾向预测、市场需求预测和客户细分分析。这些离线预测通常被写出到持久分布式存储或低延迟分布式数据库,以便下游系统访问预测或为在线用户流量提供服务。就复杂性和成本而言,这是最简单和最便宜的部署策略,因为离线预测是使用批处理作业进行的,并且由于开销较低,成本相对较低。只有当这些作业正在运行时,才会发生这种情况。
Spark 是这种情况下的最佳选择,因为它具有可扩展的分布式计算框架、用于模型训练和评估的良好集成的 MLlib 组件,以及用于模型生命周期管理和批处理作业的 MLflow 模型注册组件之间的轻松集成。离线预测的一个重要考虑因素是生成预测的频率。答案取决于预测新鲜度对你的机器学习用例有多重要。对于电影推荐用例,可能越接近实时越好,但是频率以小时为单位可能就足够了。离线预测可以为这个用例做的一个小的优化是跳过为过去几个月不活跃的用户生成推荐。
摘要
-
MLOps 为生产机器学习应用程序带来了最佳实践和工程思维,因此世界各地的企业都可以获得机器学习给业务用例带来的好处。
-
MLflow 是一个管理机器学习生命周期的开源平台。它提供了四个组件来帮助机器学习开发过程中的步骤
-
跟踪组件使数据科学家能够在模型开发阶段开发和优化他们的模型时跟踪所有需要和产生的工件。
-
项目组件将机器学习项目的打包格式标准化,以便在多个平台上可重用和可复制。
-
模型组件将使用任何流行的机器学习库开发的机器学习模型的打包格式标准化,并被部署到一组不同的执行环境。
-
Model Registry 组件提供了一种管理模型生命周期和沿袭的机制,它使用一个中央存储库来托管已注册的模型,使用一个工作流在模型的生命周期中转换模型,并使用 UI 和 API 与已注册的模型进行交互。
-
-
模型部署和预测齐头并进。两种常见的模型预测场景是在线和离线,每一种都适用于不同的用例集。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
2020-10-02 《线性代数》(同济版)——教科书中的耻辱柱