【Apache Spark 】第 3 章Apache Spark 的结构化 API

 🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

文章目录

Spark:RDD 的底层是什么?

构建 Spark

主要优点和好处

数据帧 API

Spark 的基本数据类型

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

模式和创建数据框

定义模式的两种方法

列和表达式

Rows

常见的 DataFrame 操作

使用 DataFrameReader 和 DataFrameWriter

Transformations and actions

端到端 DataFrame 示例

数据集 API

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

创建数据集

Scala:案例类

数据集操作

端到端数据集示例

数据帧与数据集

何时使用 RDDs

Spark SQL 和底层引擎

The Catalyst Optimizer

第一阶段:分析

第二阶段:逻辑优化

第 3 阶段:物理规划

阶段 4:代码生成

概括


讨向 Apache Spark 添加结构背后的主要动机,这些动机如何导致创建高级 API(DataFrames 和 Datasets),以及它们在 Spark 2.x 中跨组件的统一。我们还将了解支持这些结构化高级 API 的 Spark SQL 引擎。

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

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

Spark:RDD 的底层是什么?

RDD是 Spark 中最基本的抽象。与 RDD 相关的三个重要特征:

  • 依赖项

  • 分区(带有一些位置信息)

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

所有这三个都是简单的 RDD 编程 API 模型的组成部分,所有更高级别的功能都是在该模型上构建的。首先,需要一个依赖项列表来指示 Spark 如何使用其输入构造 RDD。当需要重现结果时,Spark 可以从这些依赖关系中重新创建一个 RDD 并在其上复制操作。此特性使 RDD 具有弹性。

其次,分区为 Spark 提供了拆分工作的能力,以在跨执行器的分区上并行计算。在某些情况下——例如,从HDFS读取——Spark将使用位置信息将工作发送给靠近数据的执行器。这样,通过网络传输的数据就更少了。

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

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

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

这种不透明性显然会妨碍 Spark 将计算重新排列为有效的查询计划的能力。那么解决方案是什么?

构建 Spark

Spark 2.x 引入了一些用于构建 Spark 的关键方案。一种是使用数据分析中的常见模式来表达计算。这些模式表示为高级操作,例如过滤、选择、计数、聚合、平均和分组。这提供了额外的清晰度和简单性。

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

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

但是这些结构有什么用呢?

主要优点和好处

结构产生了许多好处,包括跨 Spark 组件的更好的性能和空间效率。当我们稍后讨论 DataFrame 和 Dataset API 的使用时,我们将进一步探讨这些优势,但现在我们将专注于其他优势:表达性、简单性、可组合性和统一性。

让我们先用一个简单的代码片段来演示表达性和可组合性。在下面的示例中,我们希望聚合每个姓名的所有年龄,按姓名分组,然后平均年龄——这是数据分析和发现中的常见模式。如果我们为此使用低级 RDD API,代码将如下所示:

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

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

没有人会质疑这段代码,它告诉 Spark如何使用一串 lambda 函数聚合键和计算平均值,它是神秘且难以阅读的。换句话说,代码是在指导 Spark 如何计算查询。它对 Spark 完全不透明,因为它不传达意图。此外,Scala 中的等效 RDD 代码看起来与此处显示的 Python 代码非常不同。

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

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

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

这个版本的代码比早期版本更具表现力和简单,因为我们使用高级 DSL 运算符和 API 来告诉 Spark 做什么实际上,我们已经使用了这些运算符来组成我们的查询。由于 Spark 可以检查或解析此查询并了解我们的意图,因此它可以优化或安排操作以高效执行。Spark 确切地知道我们希望做什么:按姓名对人进行分组,汇总他们的年龄,然后计算所有同名人士的平均年龄。我们已经使用高级运算符作为一个简单的查询组成了整个计算——这有多富有表现力?

有些人会争辩说,通过仅使用映射到常见或重复数据分析模式的高级、富有表现力的 DSL 运算符来引入顺序和结构,我们限制了开发人员指导编译器或控制如何计算查询的能力范围. 请放心,您并不局限于这些结构化模式;您可以随时切换回非结构化的低级 RDD API,尽管我们几乎找不到这样做的需要。

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

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

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

笔记

如果您了解 SQL,其中一些 DSL 运算符执行您将熟悉的类似关系的操作,例如选择、过滤、分组和聚合。

由于构建了高级结构化 API 的 Spark SQL 引擎,我们开发人员所珍视的所有这些简单性和表达性都是可能的。正是因为这个支撑所有 Spark 组件的引擎,我们才获得了统一的 API。无论您是在结构化流式处理或 MLlib 中针对 DataFrame 表达查询,您总是在将 DataFrame 作为结构化数据进行转换和操作。我们将在本章后面仔细研究 Spark SQL 引擎,但现在让我们探索这些 API 和 DSL 用于常见操作以及如何将它们用于数据分析.

数据帧 API

pandas DataFrames在结构、格式和一些特定操作方面的启发,Spark DataFrames 就像具有命名列和模式的分布式内存表,其中每一列都有特定的数据类型:整数、字符串、数组、映射、实数、日期、时间戳等。在人眼看来,Spark DataFrame 就像一张表格。示例如表 3-1所示。

表 3-1。DataFrame 的表格格式
Id (Int)First (String)Last (String)Url (String)Published (Date)Hits (Int)Campaigns (List[Strings])
1JulesDamjihttps://tinyurl.11/4/20164535[twitter, LinkedIn]
2BrookeWenighttps://tinyurl.25/5/20188908[twitter, LinkedIn]
3DennyLeehttps://tinyurl.36/7/20197659[web, twitter, FB, LinkedIn]
4TathagataDashttps://tinyurl.45/12/201810568[twitter, FB]
5MateiZahariahttps://tinyurl.55/14/201440578[web, twitter, FB, LinkedIn]
6ReynoldXinhttps://tinyurl.63/2/201525568[twitter, LinkedIn]

当数据被可视化为结构化表格时,它不仅易于消化,而且在涉及您可能希望对行和列执行的常见操作时也易于使用。还记得,正如您在第 2 章中学到的,DataFrame 是不可变的,Spark 保留了所有转换的沿袭。您可以添加或更改列的名称和数据类型,在保留以前版本的同时创建新的 DataFrame。DataFrame 中的命名列及其关联的 Spark 数据类型可以在模式中声明。

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

Spark 的基本数据类型

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

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

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

表 3-2。Spark 中的基本 Scala 数据类型
数据类型在 Scala 中分配的值用于实例化的 API
ByteTypeByteDataTypes.ByteType
ShortTypeShortDataTypes.ShortType
IntegerTypeIntDataTypes.IntegerType
LongTypeLongDataTypes.LongType
FloatTypeFloatDataTypes.FloatType
DoubleTypeDoubleDataTypes.DoubleType
StringTypeStringDataTypes.StringType
BooleanTypeBooleanDataTypes.BooleanType
DecimalTypejava.math.BigDecimalDecimalType

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

表 3-3。Spark 中的基本 Python 数据类型
数据类型在 Python 中分配的值用于实例化的 API
ByteTypeintDataTypes.ByteType
ShortTypeintDataTypes.ShortType
IntegerTypeintDataTypes.IntegerType
LongTypeintDataTypes.LongType
FloatTypefloatDataTypes.FloatType
DoubleTypefloatDataTypes.DoubleType
StringTypestrDataTypes.StringType
BooleanTypeboolDataTypes.BooleanType
DecimalTypedecimal.DecimalDecimalType

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

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

表 3-4。Spark 中的 Scala 结构化数据类型
数据类型在 Scala 中分配的值用于实例化的 API
BinaryTypeArray[Byte]DataTypes.BinaryType
TimestampTypejava.sql.TimestampDataTypes.TimestampType
DateTypejava.sql.DateDataTypes.DateType
ArrayTypescala.collection.SeqDataTypes.createArrayType(ElementType)
MapTypescala.collection.MapDataTypes.createMapType(keyType, valueType)
StructTypeorg.apache.spark.sql.RowStructType(ArrayType[fieldTypes])
StructField与该字段的类型对应的值类型StructField(name, dataType, [nullable])

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

表 3-5。Spark 中的 Python 结构化数据类型
数据类型在 Python 中分配的值用于实例化的 API
BinaryTypebytearrayBinaryType()
TimestampTypedatetime.datetimeTimestampType()
DateTypedatetime.dateDateType()
ArrayType列表、元组或数组ArrayType(dataType, [nullable])
MapTypedictMapType(keyType, valueType, [nullable])
StructType列表或元组StructType([fields])
StructField与该字段的类型对应的值类型StructField(name, dataType, [nullable])

虽然这些表展示了支持的无数类型,但在为数据定义架构时了解这些类型如何组合在一起更为重要。

模式和创建数据框

Spark 中的模式定义了 DataFrame 的列名和关联的数据类型。大多数情况下,当您从外部数据源读取结构化数据时,模式就会发挥作用(下一章会详细介绍)。预先定义模式而不是采用读取模式的方法提供了三个好处:

  • 您可以减轻 Spark 推断数据类型的责任。

  • 您可以防止 Spark 创建单独的作业来读取文件的大部分以确定架构,这对于大型数据文件可能既昂贵又耗时。

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

因此,我们鼓励您在要从数据源读取大文件时始终预先定义架构。作为一个简短的说明,让我们为表 3-1中的数据定义一个模式,并使用该模式来创建一个 DataFrame。

定义模式的两种方法

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

要以编程方式为具有三个命名列 、 和 的 DataFrame 定义架构authortitlepages可以使用 Spark DataFrame API。例如:/

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

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

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

您可以选择任何您喜欢的方式来定义模式。对于许多示例,我们将同时使用:

# In Python 
from pyspark.sql import SparkSession

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

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

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

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

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

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

如果您想在代码中的其他地方使用此架构,只需执行blogs_df.schema,它将返回架构定义:

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

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

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

// In Scala
package main.scala.chapter3

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

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

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

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

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

毫不奇怪,Scala 程序的输出与 Python 程序的输出没有什么不同:

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

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

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

现在您已经了解了如何在 DataFrames 中使用结构化数据和模式,让我们关注 DataFrame 列和行以及使用 DataFrame API 对它们进行操作的意义.

列和表达式

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

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

笔记

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

让我们看一些示例,了解我们可以在 Spark 中对列进行哪些操作。每个示例后跟其输出:

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

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

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

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

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

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

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

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

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

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

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

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

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

笔记

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

ColumnDataFrame 中的对象不能孤立存在;每列是记录中一行的一部分,所有行一起构成一个 DataFrame,正如我们将在本章后面看到的那样,它实际上是Dataset[Row]Scala 中的一个。

Rows

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

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

Row如果您需要对象进行快速交互和探索,可以使用对象创建 DataFrame:

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

但实际上,您通常希望从文件中读取 DataFrame,如前所述。在大多数情况下,由于您的文件会很大,因此定义模式并使用它是创建 DataFrame 的更快、更有效的方法。

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

常见的 DataFrame 操作

要对 DataFrame 执行常见数据操作,您首先需要从保存结构化数据的数据源加载 DataFrame。Spark 提供了一个接口,DataFrameReader它使您能够以 JSON、CSV、Parquet、Text、Avro、ORC 等格式从无数数据源将数据读入 DataFrame。同样,将 DataFrame 写回到一个数据源中特定格式,Spark 使用DataFrameWriter.

使用 DataFrameReader 和 DataFrameWriter

Spark 中的读写很简单,因为这些高级抽象和社区的贡献可以连接到各种数据源,包括常见的 NoSQL 存储、RDBMS、Apache Kafka 和 Kinesis 等流引擎等。

首先,让我们阅读一个包含旧金山消防局呼叫数据的大型 CSV 文件。1如前所述,我们将为这个文件定义一个模式,并使用DataFrameReader类及其方法来告诉 Spark 要做什么。因为这个文件包含 28 列和超过 4,380,660 条记录,2定义一个模式比让 Spark 推断它更有效。

笔记

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

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

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

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

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

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

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

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

要将 DataFrame 以您选择的格式写入外部数据源,您可以使用该DataFrameWriter接口。就像DataFrameReader,它支持多个数据源。Parquet 是一种流行的柱状格式,是默认格式;它使用快速压缩来压缩数据。如果 DataFrame 以 Parquet 形式编写,则模式将保留为 Parquet 元数据的一部分。在这种情况下,后续读回 DataFrame 不需要您手动提供模式.

将 DataFrame 保存为 Parquet 文件或 SQL 表

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

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

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

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

在您读取数据后,让我们介绍一些在 DataFrames 上执行的常见操作。

Transformations and actions

现在您在内存中有一个由旧金山消防局呼叫组成的分布式 DataFrame,作为开发人员,您要做的第一件事就是检查您的数据以查看列的外观。它们是正确的类型吗?它们中的任何一个都需要转换为不同的类型吗?他们有null价值观吗?

第 2 章的“转换、动作和惰性求值”您了解了如何使用转换和动作对 DataFrame 进行操作,并看到了它们的一些常见示例。我们可以从旧金山消防局的电话中找到什么?

投影和过滤器

关系用语中的投影是一种通过使用过滤器仅返回与特定关系条件匹配的行的方法。在 Spark 中,投影是使用select()方法完成的,而过滤器可以使用filter()orwhere()方法来表示。我们可以使用这种技术来检查我们的 SF Fire Department 数据集的特定方面:

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

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

如果我们想知道有多少不同CallType的 s 被记录为引起火灾的原因怎么办?这些简单而富有表现力的查询可以完成这项工作:

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

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

我们可以使用这些查询列出数据集中不同的调用类型:

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

Out[20]: 32

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

重命名、添加和删除列

有时,出于样式或约定的原因,您想要重命名特定的列,而在其他时候,为了便于阅读或简洁。SF Fire Department 数据集中的原始列名中有空格。例如,列名IncidentNumberIncident Number. 列名中的空格可能会产生问题,尤其是当您想要将 DataFrame 写入或保存为 Parquet 文件时(禁止这样做)。

通过在模式中指定所需的列名StructField,正如我们所做的那样,我们有效地更改了生成的 DataFrame 中的所有名称。

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

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

这为我们提供了一个新的重命名列:

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

笔记

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

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

那么我们如何将它们转换成更可用的格式呢?这很简单,这要归功于一些高级 API 方法。有一组 to/from 日期/时间戳功能,spark.sql.functions例如我们可以用于此目的:to_timestamp()to_date()

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

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

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

这些查询包含了相当多的信息——许多事情正在发生。让我们解开他们所做的事情:

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

  2. 使用格式字符串中指定的新格式"MM/dd/yyyy""MM/dd/yyyy hh:mm:ss a"在适当的情况下使用。

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

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

查询产生三个新列:

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

现在我们已经修改了日期,我们可以使用spark.sql.functionslike dayofmonth(), , 中的函数进行查询dayofyear(),并dayofweek()进一步探索我们的数据。我们可以找出过去 7 天内记录了多少电话,或者我们可以通过以下查询查看数据集中包含了多少年的消防电话:

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

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

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

聚合

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

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

笔记

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

让我们提出第一个问题:最常见的fire calls类型是什么?

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

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

从该输出中,我们可以得出结论,最常见的呼叫类型是医疗事故。

笔记

DataFrame API 也提供了该collect()方法,但对于非常大的 DataFrame,这是资源繁重(昂贵)和危险的,因为它可能导致内存不足 (OOM) 异常。与count()向驱动程序返回单个数字不同的是,它返回整个 DataFrame 或 Datasetcollect()中所有对象的集合。Row如果您想查看一些Row记录,最好使用,它只会返回DataFrame 的第一个对象。take(n)n Row

其他常见的 DataFrame 操作

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

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

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

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

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

如您所见,使用 DataFrames 的高级 API 和 DSL 运算符很容易组合和链接表达查询。如果我们尝试对 RDD 做同样的事情,我们无法想象代码的不透明性和相对不可读性!

端到端 DataFrame 示例

在旧金山消防局公共数据集上进行探索性数据分析、ETL 和通用数据操作的可能性很多,超出了我们在此处展示的内容。

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

  • 2018 年有哪些不同类型的消防电话?

  • 2018 年哪几个月的消防电话数量最多?

  • 旧金山的哪个社区在 2018 年接到最多的火警电话?

  • 2018 年,哪些社区的消防电话响应时间最差?

  • 2018 年哪一周接到最多的火灾电话?

  • 社区、邮政编码和火警电话数量之间是否存在相关性?

  • 我们如何使用 Parquet 文件或 SQL 表来存储这些数据并将其读回?

到目前为止,我们已经广泛讨论了 DataFrame API,它是跨 Spark 的 MLlib 和结构化流组件的结构化 API 之一,我们将在本书后面介绍。

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

数据集 API

如本章前面所述,Spark 2.0将 DataFrame 和 Dataset API统一为具有相似接口的结构化 API,因此开发人员只需学习一组 API。数据集具有两个特征:类型化API和非类型化API如图 3-1所示。

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

从概念上讲,您可以将 Scala 中的 DataFrame 视为通用对象集合的别名Dataset[Row],其中 aRow是通用的无类型 JVM 对象,它可能包含不同类型的字段。相比之下,数据集是 Scala 中的强类型 JVM 对象或 Java 中的类的集合。或者,正如数据集文档所说,数据集是:

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

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

在 Spark 支持的语言中,Datasets 仅在 Java 和 Scala 中有意义,而在 Python 和 R 中只有 DataFrames 有意义。这是因为 Python 和 R 不是编译时类型安全的;类型是在执行期间动态推断或分配的,而不是在编译期间。在 Scala 和 Java 中情况正好相反:类型在编译时绑定到变量和对象。然而,在 Scala 中,DataFrame 只是 untyped 的别名Dataset[Row]表 3-6简述了它。

表 3-6。Spark 中的类型化和非类型化对象
Language有类型和无类型的主要抽象有类型或无类型
ScalaDataset[T] and DataFrame (alias for Dataset[Row])Both typed and untyped
JavaDataset<T>Typed
PythonDataFrameGeneric Row untyped
RDataFrameGeneric Row untyped

Row是 Spark 中的通用对象类型,包含可以使用索引访问的混合类型的集合。在内部,Spark 操作对象,将它们转换为表 3-2表 3-3Row中涵盖的等效类型。例如,作为 a 中的字段之一的 a将被映射或转换为或分别用于 Scala 或 Java 和 Python:IntRowIntegerTypeIntegerType()

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

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

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

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

创建数据集

与从数据源创建 DataFrame 一样,在创建 Dataset 时,您必须了解架构。换句话说,您需要知道数据类型。尽管使用 JSON 和 CSV 数据可以推断模式,但对于大型数据集,这是资源密集型的(昂贵的)。在 Scala 中创建数据集时,为生成的数据集指定模式的最简单方法是使用案例类。在 Java 中,使用 JavaBean 类(我们将在第 6 章进一步讨论 JavaBean 和 Scala 案例类)。

Scala:案例类

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

我们的文件有几行 JSON 字符串,如下所示:

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

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

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

定义后,我们可以使用它来读取我们的文件并将返回的内容转换Dataset[Row]Dataset[DeviceIoTData](截断输出以适合页面):

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

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

ds.show(5, false)

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

数据集操作

就像您可以对 DataFrame 执行转换和操作一样,您也可以使用 Dataset。根据操作的类型,结果会有所不同:

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

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

filterTempDS.show(5, false)

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

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

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

另一件需要注意的事情是,使用 DataFrame,您可以将filter()条件表达为类似 SQL 的 DSL 操作,这些操作与语言无关(正如我们在前面的 fire call 示例中看到的那样)。借助数据集,我们将语言原生表达式用作 Scala 或 Java 代码。

这是另一个导致另一个更小的数据集的示例:

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

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

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

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

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

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

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

笔记

从语义上讲,select()就像map()前面的查询一样,这两个查询都选择字段并生成等效的结果。

回顾一下,我们可以在 Datasets 上执行的操作—— filter()map()groupBy()select()take()等——类似于 DataFrames 上的操作。在某种程度上,Datasets 与 RDDs 相似,因为它们提供了与其上述方法相似的接口和编译时安全性,但具有更易于阅读和面向对象的编程接口。

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

端到端数据集示例

在这个端到端数据集示例中,您将使用 IoT 数据集执行与 DataFrame 示例中类似的探索性数据分析、ETL(提取、转换和加载)和数据操作。这个数据集很小而且是假的,但我们在这里的主要目标是说明使用 Datasets 表达查询的清晰度和这些查询的可读性,就像我们对 DataFrames 所做的那样。

同样,为简洁起见,我们不会在此处包含所有示例代码;但是,我们在GitHub 存储库中提供了笔记本。该笔记本探讨了您可能使用此数据集执行的常见操作。使用 Dataset API,我们尝试执行以下操作:

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

  2. 确定二氧化碳排放量高​​的违规国家。

  3. 计算温度、电池电量、二氧化碳和湿度​​的最小值和最大值。

  4. 按平均温度、二氧化碳、湿度和国家/地区分类和分组。

数据帧与数据集

到目前为止,您可能想知道为什么以及何时应该使用 DataFrames 或 Datasets。在许多情况下,任何一种都可以,具体取决于您使用的语言,但在某些情况下,一种比另一种更可取。这里有几个例子:

  • 如果你想告诉 Spark做什么,而不是如何去做,请使用 DataFrames 或 Datasets。

  • 如果您想要丰富的语义、高级抽象和 DSL 运算符,请使用 DataFrames 或 Datasets。

  • 如果您想要严格的编译时类型安全并且不介意为特定的创建多个案例类Dataset[T],请使用数据集。

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

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

  • 如果您想利用 Tungsten 使用编码器的高效序列化并从中受益, 使用数据集

  • 如果您希望跨 Spark 组件统一、优化代码和简化 API,请使用 DataFrame。

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

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

  • 如果您想要空间和速度效率,请使用 DataFrames。

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

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

何时使用 RDDs

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

在某些情况下,您需要考虑使用 RDD,例如:

  • 正在使用使用 RDD 编写的第三方包

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

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

更重要的是,您可以使用简单的 API 方法调用,随意在 DataFrames 或 Datasets 和 RDDs 之间无缝移动,df.rdd. (但是请注意,这确实有成本,除非必要,否则应该避免。)毕竟,DataFrames 和 Datasets 是建立在 RDD 之上的,并且它们在整个阶段的代码生成过程中被分解为紧凑的 RDD 代码,我们将讨论这一点在下一节中。

最后,前面的部分提供了一些关于 Spark 中的结构化 API 如何使开发人员能够使用简单友好的 API 来对结构化数据构成表达查询的一些直觉。换句话说,您告诉 Spark做什么,而不是如何去做,使用高级操作,它会确定构建查询的最有效方式并为您生成紧凑的代码。

这个构建高效查询和生成紧凑代码的过程是 Spark SQL 引擎的工作。它是构建我们一直在研究的结构化 API 的基础。现在让我们看看那个引擎的引擎盖。

Spark SQL 和底层引擎

在编程级别上,Spark SQL 允许开发人员对带有模式的结构化数据发出 ANSI SQL:2003 兼容的查询。自从在 Spark 1.3 中引入以来,Spark SQL 已经发展成为一个强大的引擎,许多高级结构化功能都在此基础上构建。除了允许您对数据发出类似 SQL 的查询外,Spark SQL 引擎:

  • 统一 Spark 组件并允许抽象为 Java、Scala、Python 和 R 中的 DataFrames/Datasets,从而简化了结构化数据集的处理。

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

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

  • 为快速数据探索提供交互式 Spark SQL shell。

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

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

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

图 3-3。Spark SQL 及其堆栈

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

The Catalyst Optimizer

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

  1. 分析

  2. 逻辑优化

  3. 物理规划

  4. 代码生成

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

例如,考虑第 2 章中 M&Ms 示例中的一个查询。以下两个示例代码块将经历相同的过程,最终以相似的查询计划和相同的字节码执行结束。也就是说,无论您使用哪种语言,您的计算都会经历相同的过程,并且生成的字节码可能是相同的:

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

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

count_mnm_df.explain(True)

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

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

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

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

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

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

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

图 3-5。特定查询转换的示例

让我们逐一介绍四个查询优化阶段。

第一阶段:分析

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

第二阶段:逻辑优化

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

第 3 阶段:物理规划

在此阶段,Spark SQL 使用与 Spark 执行引擎中可用的物理运算符匹配的物理运算符,为选定的逻辑计划生成最佳物理计划。

阶段 4:代码生成

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

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

笔记

我们已经从概念层面讨论了 Spark SQL 引擎的工作原理,它有两个主要组件:Catalyst 优化器和 Project Tungsten。内部技术工作超出了本书的范围;但是,出于好奇,我们鼓励您查看文本中的参考资料以进行深入的技术讨论.

概括

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

通过说明性的常见数据操作和代码示例,我们证明了高级 DataFrame 和 Dataset API 比低级 RDD API 更具表现力和直观性。结构化 API 旨在简化大型数据集的处理,为常见数据操作提供特定于域的运算符,从而提高代码的清晰度和表现力。

我们探讨了何时使用 RDD、DataFrame 和 Dataset,具体取决于您的用例场景。

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

本章中的概念和代码示例为接下来的两章奠定了基础,我们将在其中进一步说明 DataFrames、Datasets 和 Spark SQL 之间的无缝互操作性。

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sonhhxg_柒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值