「Spark从入门到精通系列」3. Apache Spark结构化API(上)

来源 |  Learning Spark Lightning-Fast Data Analytics,Second Edition

作者 | Damji,et al.

翻译 | 吴邪 大数据4年从业经验,目前就职于广州一家互联网公司,负责大数据基础平台自研、离线计算&实时计算研究

校对 | gongyouliu

编辑 | auroral-L

全文共14319字,预计阅读时间80分钟。

第三章  Apache Spark结构化API

1.  Spark:什么是RDD?

2.  结构化Spark

    2.1  主要的优点和优势

3.  DataFrame API

    3.1  Spark的基本数据类型

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

    3.3  数据结构(schema)和DataFrame创建

    3.4  列和表达式

    3.5  行(Row)

    3.6  常见的DataFrame操作

    3.7  使用DataFrameReader和DataFrameWriter

    3.8  端到端的DataFrame示例

在本章中,我们将探讨Apache Spark添加结构化背后的主要动机,包括这些动机是如何引导高级API(DataFrame和DataSet)的创建,以及它们在Spark2.x中不同组件之间的一致性介绍。我们还将研究支撑这些结构化高级API的Spark SQL引擎。

当Spark SQL首次在早期的Spark1.x中被引入,接着是DataFrame作为Spark1.3中SchemaRDD的继承者,我们第一次看到了Spark完整的结构。Spark SQL引入了高级表达式操作函数,模拟了类似SQL的语法,DataFrame为后续版本中的更多结构奠定了基础,为Spark计算查询中的性能操作铺平了道路。

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

1.  Spark:什么是RDD?

RDD是Spark最基本的抽象,与RDD相关的三个重要特性:

  • 依赖关系:宽依赖和窄依赖

  • 数据分区(Partitions):数据集组成单位,带有位置信息

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

 

 

这三个特性都是RDD编程API模型最基本的组成部分,基于RDD模型构建所有更高级别的功能。首先,需要一个依赖关系列表,该依赖关系指示Spark如何使用其输入构造RDD。必要时,Spark可以根据这些依赖关系重新创建RDD并对其进行复制操作。这一特性使得RDD具有弹性。

其次,分区使得Spark能够对数据进行拆分,以便跨Executor的分区进行并行计算。在某些情况下,例如从HDFS读取,Spark将使用位置信息将工作发送给接近数据的Executor。这样,通过网络传输的数据就会更少,减少网络IO。

最后,RDD具有计算功能,它可以将存储在RDD中的数据生成一个Iterator[T]。

简单而优雅!然而,这个原始的模型存在几个问题。首先,计算函数(或计算)对Spark是不透明的。也就是说,Spark不知道你在计算函数中在做什么。无论是执行connect、filter、select还是aggregate,Spark都只将其视为lambda表达式。另一个问题是Iterator[T]数据类型对于Python RDD来说也不透明;Spark只知道它是Python中的通用对象。

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

这种不透明性明显阻碍了Spark将计算重排为高效的查询计划的能力。那么解决方案是什么呢?

2.  结构化Spark

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

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

最终的顺序和结构方案是允许你以表格的形式排列数据,如SQL表或电子表格,并使用受支持的结构化数据类型(稍后将介绍)。

但是,这种结构到底有什么好处呢?

2.1  主要的优点和优势

结构带来许多好处,包括跨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。无论是在结构化流(Structured Streaming)还是MLLib中对DataFrame做查询,你始终都会将DataFrame作为结构化数据进行转换和操作。我们将在这一章后面详细介绍Spark SQL引擎,但现在我们探讨常见操作所用的API和DSL,以及如何将它们用于数据分析。

3.  DataFrame API 

受Pandas DataFrame结构、格式和一些特定操作的启发,Spark DataFrame类似于具有命名列和模式的分布式内存表,其中每列都有一个特定的数据类型:integer, string, array, map, real, date, timestamp等。看起来Spark DataFrame就像一个表格。如表3-1所示。

当数据可视化为结构化表时,它不仅易于理解,而且在涉及可能需要在行和列上执行的常见操作时也很容易使用。还记得,正如你在第2章中了解到的,DataFrame是不可变的,并且Spark保留了所有转换的血缘关系。你可以添加或更改列的名称和数据类型,从而在保留先前版本的同时创建新的DataFrame。可以在数据结构(schema)中声明DataFrame中的命名列及其关联的Spark数据类型。

在使用它们定义数据结构(schema)之前,让我们检查一下Spark中可用的通用和结构化数据类型。然后,我们将说明如何使用schema创建DataFrame并录入表3-1中的数据。

3.1  Spark的基本数据类型

与其支持的编程语言相匹配,Spark支持基本的内部数据类型。这些数据类型可以在Spark应用程序中声明,也可以在数据结构(schema)中定义。例如,在Scala中,你可以定义或声明一个特定的列名,类型可以是String、Byte、Long或Map等类型。在这里,我们定义与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数据类型。除了DecimalType以外,它们都是DataTypes的子类型,具体如下。

数据类型

在Scala中分配的值

实例化的API

ByteType

Byte

DataTypes.ByteType

ShortType

Short

DataTypes.ShortType

IntegerType

Int

DataTypes.IntegerType

LongType

Long

DataTypes.LongType

FloatType

Float

DataTypes.FloatType

DoubleType

Double

DataTypes.DoubleType

StringType

String

DataTypes.StringType

BooleanType

Boolean

DataTypes.BooleanType

DecimalType

java.math.BigDecimal

DecimalType

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

数据类型

用Python分配的值

实例化的API

ByteType

int

DataTypes.ByteType

ShortType

int

DataTypes.ShortType

IntegerType

int

DataTypes.IntegerType

LongType

int

DataTypes.LongType

FloatType

float

DataTypes.FloatType

DoubleType

float

DataTypes.DoubleType

StringType

str

DataTypes.StringType

BooleanType

bool

DataTypes.BooleanType

DecimalType

decimal.Decimal

DecimalType

 

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

对于复杂的数据分析,你不会只处理简单或基本的数据类型。你的数据将很复杂,通常是结构化的或嵌套的,你将需要Spark来处理这些复杂的数据类型。它们有多种形式:maps, arrays, structs, dates, timestamps, fields,等。表3-4列出了Spark支持的Scala结构化数据类型。

数据类型

在Scala中分配的值

实例化的API

BinaryType

Array[Byte]

DataTypes.BinaryType

TimestampType

java.sql.Timestamp

DataTypes.TimestampType

DateType

java.sql.Date

DataTypes.DateType

ArrayType

scala.collection.Seq

DataTypes.createArrayType(ElementType)

MapType

scala.collection.Map

DataTypes.createMapType(keyType, valueType)

StructType

org.apache.spark.sql.Row

StructType(ArrayType[fieldTypes])

StructField

与该字段的类型相对应的值类型

StructField(name, dataType, [nullable])

 

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

数据类型

用Python分配的值

实例化的API

BinaryType

bytearray

BinaryType()

TimestampType

datetime.datetime

TimestampType()

DateType

datetime.date

DateType()

ArrayType

列表,元组或数组

ArrayType(dataType, [nullable])

MapType

dict

MapType(keyType, valueType, [nullable])

StructType

列表或元组

StructType([fields])

StructField

与该字段的类型相对应的值类型

StructField(name, dataType, [nullable])

 

虽然这些表显示了支持的各种类型,但在为数据定义数据结构(schema)时,查看这些类型如何组合更为重要。

3.2  数据结构(schema)和DataFrame创建

Spark中的数据结构(schema)定义了DataFrame的列名和关联的数据类型。通常情况下,在从外部数据源读取结构化数据时开始定义数据结构(schema)(下一章将详细介绍)。相对于采用“读取模式”方法,预先定义数据结构(schema)具有三个好处:

  • 你可以避免Spark的推断数据类型的工作

  • 你可以防止Spark创建一个单独的作业,只是为了读取文件的大部分内容来确定数据结构(schema),对于大型数据文件来说,这可能会很昂贵和耗时。

  • 如果数据与数据结构(schema)不匹配,则可以提前检测到错误。

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

定义数据结构(schema)的两种方式

Spark允许你通过两种方式定义模式。一种是通过编程的模式定义,另一种是使用数据定义语言(DDL),这样的定义出来的数据结构更简单、更容易阅读。

要以编程方式为具有三个命名列:作者、标题和页的DataFrame定义数据结构(schema),可以使用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定义相同的schema要简单得多:

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

你可以选择任何你想要定义schema的方式。对于许多例子,我们将同时使用:

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())

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

如果要在代码的其他地方使用此schema,只需执行blogs_df.schema即可,它将返回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文件中读取数据,而不是创建静态数据,则schema定义将完全相同。让我们用一个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程序的输出没有什么不同:

root
|-- Id: integer (nullable = true)
|-- First: string (nullable = true)
|-- Last: string (nullable = true)
|-- Url: string (nullable = true)
|-- Published: string (nullable = true)
|-- Hits: integer (nullable = true)
|-- Campaigns: array (nullable = true)
| |-- element: string (containsNull = true)
StructType(StructField("Id",IntegerType,true),
StructField("First",StringType,true),
StructField("Last",StringType,true),
StructField("Url",StringType,true),
StructField("Published",StringType,true),
StructField("Hits",IntegerType,true),
StructField("Campaigns",ArrayType(StringType,true),true))

现在你了解了如何在DataFrame中使用结构化数据和schema,让我们重点关注DataFrame列和行以及使用DataFrame API对它们进行操作的含义。

3.4  列和表达式

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

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

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

让我们来看看Spark中使用列的示例。每个例子后都带有其结果的输出。

// In Scala
scala> import org.apache.spark.sql.functions._
scala> blogsDF.columns
res2: Array[String] = Array(Campaigns, First, Hits, Id, Last, Published, Url)
// Access a particular column with col and it returns a Column type
scala> blogsDF.col("Id")
res3: org.apache.spark.sql.Column = id
// Use an expression to compute a value
scala> blogsDF.select(expr("Hits * 2")).show(2)
// or use col to compute value
scala> blogsDF.select(col("Hits") * 2).show(2)
+----------+
|(Hits * 2)|
+----------+
| 9070|
| 17816|
+----------+
// Use an expression to compute big hitters for blogs
// This adds a new column, Big Hitters, based on the conditional expression
blogsDF.withColumn("Big Hitters", (expr("Hits > 10000"))).show()
+---+---------+-------+---+---------+-----+--------------------+------
| Id| First| Last|Url|Published| Hits| Campaigns|Big Hitters|
+---+---------+-------+---+---------+-----+--------------------+------
| 1| Jules| Damji|...| 1/4/2016| 4535| [twitter, LinkedIn]| false|
| 2| Brooke| Wenig|...| 5/5/2018| 8908| [twitter, LinkedIn]| false|
| 3| Denny| Lee|...| 6/7/2019| 7659|[web, twitter, FB...| false|
| 4|Tathagata| Das|...|5/12/2018|10568| [twitter, FB]| true| 
| 5| Matei|Zaharia|...|5/14/2014|40578|[web, twitter, FB...| true|
| 6| Reynold| Xin|...| 3/2/2015|25568| [twitter, LinkedIn]| true|
+---+---------+-------+---+---------+-----+--------------------+---
// Concatenate three columns, create a new column, and show the
// newly created concatenated column
blogsDF
.withColumn("AuthorsId", (concat(expr("First"), expr("Last"), expr("Id"))))
.select(col("AuthorsId"))
.show(4)
+-------------+
| AuthorsId|
+-------------+
| JulesDamji1|
| BrookeWenig2|
| DennyLee3|
|TathagataDas4|
+-------------+
// These statements return the same value, showing that
// expr is the same as a col method call
blogsDF.select(expr("Hits")).show(2)
blogsDF.select(col("Hits")).show(2)
blogsDF.select("Hits").show(2)
+-----+
| Hits|
+-----+
| 4535|
| 8908|
+-----+
// Sort by column "Id" in descending order
blogsDF.sort(col("Id").desc).show()
blogsDF.sort($"Id".desc).show()
+--------------------+---------+-----+---+-------+---------+----------
| Campaigns| First| Hits| Id| Last|Published| Url|
+--------------------+---------+-----+---+-------+---------+---------
| [twitter, LinkedIn]| Reynold|25568| 6| Xin| 3/2/2015|https://tinyurl.6|
|[web, twitter, FB...| Matei|40578| 5|Zaharia|5/14/2014|https://tinyurl.5|
| [twitter, FB]|Tathagata|10568| 4| Das|5/12/2018|https://tinyurl.4|
|[web, twitter, FB...| Denny| 7659| 3| Lee| 6/7/2019|https://tinyurl.3|
| [twitter, LinkedIn]| Brooke| 8908| 2| Wenig| 5/5/2018|https://tinyurl.2|
| [twitter, LinkedIn]| Jules| 4535| 1| Damji| 1/4/2016|https://tinyurl.1|
+--------------------+---------+-----+---+-------+---------+--------

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

我们仅在此处进行了比较浅的使用,有关Column对象的所有公共方法的完整列表,请参考Spark文档。

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

3.5  行(Row)

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

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

Row可以用于创建DataFrame以便于进行快速交互和测试,:

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

但在实际操作中,通常需要从文件中读取DataFrame。在大多数情况下,因为你的文件将会很大,所以定义schema并使用它是创建DataFrame的一种更快、更有效的方法。

在创建了一个大型的分布式DataFrame之后,你将要对其执行一些常见的数据操作。让我们研究一下你可以在结构化API中使用高级关系运算符执行的一些Spark操作。

 

3.6  常见的DataFrame操作

要在DataFrame上执行常见的数据操作,首先需要从保存结构化数据的数据源中加载DataFrame。Spark提供了一个接口,DataFrameReader使你能够从多种数据源以JSON,CSV,Parquet,Text,Avro,ORC等格式将数据读取到DataFrame中。同样,可以将DataFrame按照特定格式写回到数据源中。Spark使用DataFrameWriter。

3.7  使用DataFrameReader 和 DataFrameWriter 

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

首先,让我们阅读一个大型CSV文件,其中包含有关旧金山消防部门呼叫的数据。如前所述,我们将为此文件定义一个架构,并使用DataFrameReader该类及其方法告诉Spark该怎么做。由于此文件包含28列和超过4,380,660条记录, 定义schema比使用Spark推断schema更有效。

如果不想指定schema,Spark可以以较低的成本从示例中推断schema。例如,你可以使用采样比率samplingRatio选项:
// In Scalaval 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),
The DataFrame API | 59 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文件,并返回具有schema中指定类型的行和命名列的DataFrame。

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

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

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

另外,你也可以将其另存为一个表,该表在Hive Metastore中注册元数据(我们将在下一章介绍SQL托管和非托管表,Metastore和DataFrame):

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

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

转换和操作(Transformations and actions )

现在你有了一个存在内存中的由旧金山消防部门组成的分布式DataFrame,作为一个开发人员,你要做的第一件事就是检查你的数据,看看这些列是什么样子的。它们的类型正确了吗?是否需要转换成不同的类型呢?它们有Null值吗?

在第2章的“转换、操作和惰性计算”中,你可以了解如何使用转换和操作对DataFrame进行处理,并看到了每个转换示例的一些常见示例。使用这些操作,我们可以从旧金山消防部门的电话中找到什么?

投影和滤镜

关系解析中的投影是一种通过使用过滤器只返回与某个关系条件匹配的列的方法。在Spark中,使用select`() 方法进行投影,而过滤器可以使用filter()或where() 方法表示。我们可以使用此技术来检查旧金山消防部门数据集的具体方面:

# 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

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

# In Python, return number of distinct types of calls using countDistinct()
from pyspark.sql.functions import *
(fire_df
The DataFrame API | 61 .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

重命名、添加和删除列

有时因为样式或规范的原因而需要重命名特定的列,有时是为了可读性或简洁性需要重命名。消防部门数据集中的原始列名在其中有空格。例如,列名IncidentNumber为Incident Number。列名中的空格可能会出现问题,特别是当你要将DataFrame写入或保存为Parquet文件时(禁止使用)。

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

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

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

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

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

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

修改列或其类型是数据处理期间的常见操作。在某些情况下,数据是原始数据或脏数据,或者其类型不适合于作为参数提供给关系运算符。例如,在我们的旧金山消防部门数据集中,CallDate,WatchDate和AlarmDtTm列都是字符串,而不是Unix时间戳或SQL日期,Spark支持两种日期格式转换或操作(例如,在日期或时间相关的数据分析期间)。

那么,我们如何将它们转换为一种更可用的格式呢?这非常简单,归功于一些高级的API方法。spark.sql.functions具有一组to / from日期/时间戳功能,例如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/yyyyhh:mm:ssa”中指定的新格式。

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(),还可以利用daofweej进一步分析我们的数据。我们可以找出过去七天内记录了多少个电话,也可以看到此查询的数据集中包含了多少年的消防部门的电话:

# 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;定义schema并在读取DataFrame时使用它;将DataFrame保存为Parquet文件或表;投影和过滤现有DataFrame中选定的列;以及修改、重命名和删除列。

 

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

 

聚合

如果我们想知道最常见的火灾电话类型是什么,或者什么邮政编码占大多数怎么办?这些问题在数据分析和使用中很常见。

 

在DataFrame上的一些转换和操作,如groupBy()、orderBy()和count()操作,通过提供列名,然后进行各种聚合计算。

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

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

# In Python
(fire_ts_df
.select("CallType")
.where(col("CallType").isNotNull())
.groupBy("CallType")
.count()
.orderBy("count", ascending=False)
.show(n=10, truncate=False)) 
// In Scala
fireTsDF
.select("CallType")
.where(col("CallType").isNotNull)
.groupBy("CallType")
.count()
.orderBy(desc("count"))
.show(10, false)
+-------------------------------+-------+
|CallType |count |
+-------------------------------+-------+
|Medical Incident |2843475|
|Structure Fire |578998 |
|Alarms |483518 |
|Traffic Collision |175507 |
|Citizen Assist / Service Call |65360 |
|Other |56961 |
|Outside Fire |51603 |
|Vehicle Fire |20939 |
|Water Rescue |20037 |
|Gas Leak (Natural and LP Gases)|17284 |
+-------------------------------+-------+

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

DataFrameAPI还提供了collect()方法,但对于非常大的DataFrame,这会占用大量的资源,因为它可能会导致内存不足(OOM)异常。与count()向驱动程序返回单个数字的方法不同,collect()返回整个DataFrame或Dataset中的所有Row对象的集合。如果你想查看一些行记录,你最好使用take(n),它将只返回DataFrame Row对象的前n条记录。

其他常见的DataFrame操作

除了我们看到的其他DataFrame API提供了描述性统计方法,如min()、max()、sum()和avg()。让我们看一些示例,这些示例说明如何使用旧金山消防部门的数据集进行计算。

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

# In Python
import pyspark.sql.functions as F
(fire_ts_df
.select(F.sum("NumAlarms"), F.avg("ResponseDelayedinMins"),
The DataFrame API | 67 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()

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

如你所见,可以使用DataFrames的高级API和DSL运算符来轻松编写和链接表达式查询。如果我们要尝试使用RDD执行相同的操作,我们将无法想象代码的不透明度和相对不可读性!

3.8  端到端的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以及原因。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

数据与智能

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

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

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

打赏作者

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

抵扣说明:

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

余额充值