大数据Spark实战第三集 处理结构化数据和Spark优化

86 篇文章 49 订阅

如何处理结构化数据:DataFrame 、Dataet和Spark SQL

本课时我们来学习如何处理结构化数据:DataFrame、Dataset 和 Spark SQL。由于本课时是专栏的第 3 模块:Spark 高级编程的第 1 课,在开始今天的课程之前,首先对上一个模块进行一个总结。

模块回顾

在第 2 模块里,我们学习了 Spark 核心数据结构 RDD 和算子,以及 Spark 相关的一些底层原理。可以看到 RDD 将大数据集抽象为集合,这掩盖了分布式数据集的复杂性,而函数式编程风格的算子也能满足不同的数据处理逻辑。但是,RDD + 算子的组合,对于普通分析师来说还是不太友好,他们习惯于“表”的概念而非“集合”,而使用基于集合完成数据处理的逻辑更像是程序员们的思维方式。对于数据处理逻辑,分析师们更习惯用 SQL 而非算子来表达。所以,Spark 借鉴了 Python 数据分析库 pandas 中 DataFrame 的概念,推出了 DataFrame、Dataset 与 Spark SQL。

在数据科学领域中,DataFrame 抽象了矩阵,如 R、pandas 中的 DataFrame;在数据工程领域,如 Spark SQL 中,DataFrame 更多地代表了关系型数据库中的表,这样就可以利用简单易学的 SQL 来进行数据分析;在 Spark 中,我们既可以用 Spark SQL + DataFrame 的组合实现海量数据分析,也可用 DataFrame + MLlib(Spark 机器学习库)的组合实现海量数据挖掘。

在计算机领域中,高级往往意味着简单、封装程度高,而与之对应的通常是复杂、底层。对于 Spark 编程来说,RDD + 算子的组合无疑是比较底层的,而 DataFrame + Spark SQL 的组合无论从学习成本,还是从性能开销上来说,都显著优于前者组合,所以无论是分析师还是程序员,这种方式才是使用 Spark 的首选。 此外,对于分析师来说,DataFrame 对他们来说并不陌生,熟悉的概念也能让他们快速上手。

本课时的主要内容有 4点:

  • DataFrame、Dataset 的起源与演变

  • DataFrame API

  • Dataset API

  • Spark SQL

这里特别说明的是,由于 DataFrame API 的 Scala 版本与 Python 版本大同小异,差异极小,所以本课时的代码以 Scala 版本为主。

DataFrame、Dataset 的起源与演变

DataFrame 在 Spark 1.3 被引入,它的出现取代了 SchemaRDD,Dataset 最开始在 Spark 1.6 被引入,当时还属于实验性质,在 2.0 版本时正式成为 Spark 的一部分,并且在 Spark 2.0 中,DataFrame API 与 Dataset API 在形式上得到了统一。

Dataset API 提供了类型安全的面向对象编程接口。Dataset 可以通过将表达式和数据字段暴露给查询计划程序和 Tungsten 的快速内存编码,从而利用 Catalyst 优化器。但是,现在 DataFrame 和 Dataset 都作为 Apache Spark 2.0 的一部分,其实 DataFrame 现在是 Dataset Untyped API 的特殊情况。更具体地说:

DataFrame = Dataset[Row]

下面这张图比较清楚地表示了 DataFrame 与 Dataset 的变迁与关系。

2.png

由于 Python 不是类型安全的语言,所以 Spark Python API 没有 Dataset API,而只提供了 DataFrame API。当然,Java 和 Scala 就没有这种问题。

DataFrame API

DataFrame 与 Dataset API 提供了简单的、统一的并且更富表达力的 API ,简言之,与 RDD 与算子的组合相比,DataFrame 与 Dataset API 更加高级,所以这也是为什么我将这个模块命名为 Spark 高级编程。

DataFrame 不仅可以使用 SQL 进行查询,其自身也具有灵活的 API 可以对数据进行查询,与 RDD API 相比,DataFrame API 包含了更多的应用语义,所谓应用语义,就是能让计算框架知道你的目标的信息,这样计算框架就能更有针对性地对作业进行优化,本课时主要介绍如何创建DataFrame 以及如何利用 DataFrame 进行查询。

1、创建DataFrame

DataFrame 目前支持多种数据源、文件格式,如 Json、CSV 等,也支持由外部数据库直接读取数据生成,此外还支持由 RDD 通过类型反射生成,甚至还可以通过流式数据源生成,这在下个模块会详细介绍。DataFrame API 非常标准,创建 DataFrame 都通过 read 读取器进行读取。下面列举了如何读取几种常见格式的文件。

  • 读取 Json 文件。Json 文件如下:

{"name":"Michael"}
{"name":"Andy", "age":30}
{"name":"Justin", "age":19}
......
val df = spark.read.json("examples/src/main/resources/people.json")

我们可以利用初始化好的 SparkSession(spark)读取 Json 格式文件。

  • 读取 CSV 文件:

val df = spark.read.csv("examples/src/main/resources/people.csv")
  • 从 Parquet 格式文件中生成:

val df = spark.read.parquet("examples/src/main/resources/people.csv")
  • 从 ORC 格式文件中生成:

val df = spark.read.orc("examples/src/main/resources/people.csv")
关于 ORCParquet 文件格式会在后面详细介绍。
  • 从文本中生成:

val df = spark.read.text("examples/src/main/resources/people.csv")
  • 通过 JDBC 连接外部数据库读取数据生成

val df = spark.read
.format("jdbc")
.option("url", "jdbc:postgresql:dbserver")
.option("dbtable", "schema.tablename")
.option("user", "username")
.option("password", "password")
.load()

上面的代码表示通过 JDBC 相关配置,读取数据。

  • 通过 RDD 反射生成。此种方法是字符串反射为 DataFrame 的 Schema,再和已经存在的 RDD 一起生成 DataFrame,代码如下所示:

import spark.implicits._
val schemaString = "id f1 f2 f3 f4"
// 通过字符串转换和类型反射生成schema
val fields = schemaString.split(" ").map(fieldName => StructField(fieldName, StringType, nullable = true))
val schema = StructType(fields)
// 需要将RDD转化为RDD[Row]类型
val rowRDD = spark.sparkContext.textFile(textFilePath).map(_.split(",")).map(attributes => 
Row(attributes(0), 
attributes(1),
attributes(2),
attributes(3),
attributes(4).trim)
)
// 生成DataFrame
val df = spark.createDataFrame(rowRDD, schema)

注意这种方式需要隐式转换,需在转换前写上第一行:

import spark.implicits._

DataFrame 初始化完成后,可以通过 show 方法来查看数据,Json、Parquet、ORC 等数据源是自带 Schema 的,而那些无 Schema 的数据源,DataFrame 会自己生成 Schema。

Json 文件生成的 DataFrame 如下:

image (5).png

CSV 文件生成的 DataFrame 如下:

image (6).png

2、查询

完成初始化的工作之后就可以使用 DataFrame API 进行查询了,DataFrame API 主要分为两种风格,一种依然是 RDD 算子风格,如 reduce、groupByKey、map、flatMap 等,另外一种则是 SQL 风格,如 select、where 等。

2.1 算子风格

我们简单选取几个有代表性的 RDD 算子风格的 API,具体如下:

def groupByKey[K: Encoder](func: T => K): KeyValueGroupedDataset[K, T]

与 RDD 算子版作用相同,返回类型为 Dataset。

def map[U : Encoder](func: T => U): Dataset[U]

与 RDD 算子版作用相同,返回类型为 Dataset。

def flatMap[U : Encoder](func: T => TraversableOnce[U]): Dataset[U]

与 RDD 算子版作用相同,返回类型为 Dataset。
这些算子用法大同小异,但都需要传入 Encoder 参数,这可以通过隐式转换解决,在调用算子前,需加上:

import spark.implicits._

2.2 SQL风格

这类 API 的共同之处就是支持将部分 SQL 语法的字符串作为参数直接传入。

  • select 和 where

def select(cols: Column*): DataFrame
def where(conditionExpr: String): Dataset[T]

条件查询,例如:

df.select("age").where("name is not null and age > 10").foreach(println(_))
  • groupBy

def groupBy(col1: String, cols: String*): RelationalGroupedDataset

分组统计。例如:

df.select("name","age").groupBy("age").count().foreach(println(_))

此外,某些 RDD 算子风格的 API 也可以传入部分 SQL 语法的字符串,如 filter。例如:

df.select("age", "name").filter("age > 10").foreach(println(_))
  • join

def join(right: Dataset[_], usingColumns: Seq[String], joinType: String): DataFrame

DataFrame API 还支持最普遍的连接操作,代码如下:

val leftDF = ...
val rightDF = ...
leftDF.join(rightDF, leftDF("pid") === rightDF("fid"), "left_outer").foreach(println(_))

其中 joinType 参数支持常用的连接类型,选项有 inner、cross、outer、full、full_outer、left、left_outer、right、right_outer、left_semi 和 left_anti,其中 cross 表示笛卡儿积,这在实际使用中比较少见;left_semi 是左半连接,是 Spark 对标准 SQL 中的 in 关键字的变通实现;left_anti 是 Spark 对标准 SQL 中的 not in 关键字的变通实现。
除了 groupBy 这种分组方式,DataFrame 还支持一些特别的分组方式如 pivot、rollup、cube 等,以及常用的分析函数,先来看一个数据集:

{"name":"Michael", "grade":92, "subject":"Chinese", "year":"2017"}

{“name”:“Andy”, “grade”:87, “subject”:“Chinese”, “year”:“2017”}
{“name”:“Justin”, “grade”:75, “subject”:“Chinese”, “year”:“2017”}

{“name”:“Berta”, “grade”:62, “subject”:“Chinese”, “year”:“2017”}

{“name”:“Michael”, “grade”:96, “subject”:“math”, “year”:“2017”}

{“name”:“Andy”, “grade”:98, “subject”:“math”, “year”:“2017”}

{“name”:“Justin”, “grade”:78, “subject”:“math”, “year”:“2017”}

{“name”:“Berta”, “grade”:87, “subject”:“math”, “year”:“2017”}

{“name”:“Michael”, “grade”:87, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Andy”, “grade”:90, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Justin”, “grade”:76, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Berta”, “grade”:74, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Michael”, “grade”:68, “subject”:“math”, “year”:“2016”}

{“name”:“Andy”, “grade”:95, “subject”:“math”, “year”:“2016”}

{“name”:“Justin”, “grade”:87, “subject”:“math”, “year”:“2016”}

{“name”:“Berta”, “grade”:81, “subject”:“math”, “year”:“2016”}

{“name”:“Michael”, “grade”:95, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Andy”, “grade”:91, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Justin”, “grade”:85, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Berta”, “grade”:77, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Michael”, “grade”:63, “subject”:“math”, “year”:“2015”}

{“name”:“Andy”, “grade”:99, “subject”:“math”, “year”:“2015”}

{“name”:“Justin”, “grade”:79, “subject”:“math”, “year”:“2015”}

{“name”:“Berta”, “grade”:85, “subject”:“math”, “year”:“2015”}

以上是某班学生 3 年的成绩单,一共有 3 个维度,即 name、subject 和 year,度量为 grade,也就是成绩,因此,这个 DataFrame 可以看成三维数据立方体,如下图所示。

1.png

现在需要统计每个学生各科目 3 年的平均成绩,该操作可以通过下面的方式实现:

dfSG.groupBy("name","subject").avg("grade")

但是,这种形式使结果数据集只有两列——name 和 subject,不利于进一步分析,而利用 DataFrame 的数据透视 pivot 功能无疑更加方便。 pivot 功能在 pandas、Excel 等分析工具已得到了广泛应用,用户想使用透视功能,需要指定分组规则、需要透视的列以及聚合的维度列。所谓“透视”比较形象,即在分组结果上,对每一组进行“透视”,透视的结果会导致每一组基于透视列展开,最后再根据聚合操作进行聚合,统计每个学生每科 3 年平均成绩实现如下:

dfSG.groupBy("name").pivot("subject").avg("grade").show()

结果如下:

image (8).png

除了 groupBy 之外,DataFrame 还提供 rollup 和 cube 的方式进行分组聚合,如下:

def rollup(col1: String, cols: String*): RelationalGroupedDataset

rollup 也是用来进行分组统计,只不过分组逻辑有所不同,假设 rollup(A,B,C),其中 A、B、C 分别为 3 列,那么会先对 A、B、C 进行分组,然后依次对 A、B 进行分组、对 A 进行分组、对全表进行分组,执行:

dfSG.rollup("name", "subject").avg("grade").show()

结果如下:

image (9).png

可以看到,除了按照 name + subject 的组合键进行分组,还分别对每个人进行了分组,如 Michael,null,此外还将全表分为了一组,如 null,null。

def cube(col1: String, cols: String*): RelationalGroupedDataset

cube 与 rollup 类似,分组依据有所不同,仍以 cube(A,B,C) 为例,分组依据分别是 (A,B,C)、(A,B)、(A,C)、(B,C)、(A)、(B)、(C)、全表,执行:

dfSG.cube("name", "subject").avg("grade").show()

结果如下:

image (10).png

可以看到与 rollup 不同,这里还分别对每个科目进行分组,如 null、math。

在实际使用中,你应该尽量选用并习惯于用 SQL 风格的算子完成开发任务,SQL 风格的查询 API 不光表现力强,另外也非常易读。

3、写出

与创建 DataFrame 的 read 读取器相对应,写出为 write 输出器 API。下面列举了如何输出几种常见格式的文件。

  • 写出为 Json 文件:

df.select("age", "name").filter("age > 10").write.json("/your/output/path")
  • 写出为 Parquet 文件:

df.select("age", "name").filter("age > 10").write.parquet("/your/output/path")
  • 写出为 ORC 文件:

df.select("age", "name").filter("age > 10").write.orc("/your/output/path")
  • 写出为文本文件:

df.select("age", "name").filter("age > 10").write.text("/your/output/path")
  • 写出为 CSV 文件:

val saveOptions = Map("header" -> "true", "path" -> "csvout")
df.select("age", "name").filter("age > 10")
.write
.format("com.databricks.spark.csv")
.mode(SaveMode.Overwrite)
.options(saveOptions)
.save()

我们还可以在保存时对格式已经输出的方式进行设定,例如本例中是保留表头,并且输出方式是 Overwrite,输出方式有 Append、ErrorIfExist、Ignore、Overwrite,分别代表追加到已有输出路径中、如果输出路径存在则报错、存在则停止、存在则覆盖。

  • 写出到关系型数据库:

val prop = new java.util.Properties
prop.setProperty("user","spark")
prop.setProperty("password","123")
df.write.mode(SaveMode.Append).jdbc("jdbc:mysql://localhost:3306/test","tablename",prop)

写出到关系型数据库同样基于 JDBC ,用此种方式写入关系型数据库,表名可以不存在。

Dataset API

从本质上来说,DataFrame 只是 Dataset 的一种特殊情况,在 Spark 2.x 中已经得到了统一:

DataFrame = Dataset[Row]

因此,在使用 DataFrame API 的过程中,很容易就会自动转换为 Dataset[String]、Dataset[Int] 等类型。除此之外,用户还可以自定义类型。下面来看看 DataFrame 转成 Dataset 的例子,下面是一个 Json 文件,记录了学生的单科成绩:

{"name":"Michael", "grade":92, "subject":"Chinese"}

{“name”:“Andy”, “grade”:87, “subject”:“Chinese”}
{“name”:“Justin”, “grade”:75, “subject”:“Chinese”}

{“name”:“Berta”, “grade”:62, “subject”:“Chinese”}

{“name”:“Michael”, “grade”:96, “subject”:“math”}

{“name”:“Andy”, “grade”:98, “subject”:“math”}

{“name”:“Justin”, “grade”:78, “subject”:“math”}

{“name”:“Berta”, “grade”:87, “subject”:“math”}

代码如下:

// 首先定义StudentGrade类
case class StudentGrade(name: String, subject: String, grade: Long)
// 生成DataFrame
val dfSG = spark.read.json("data/examples/target/scala-2.11/classes/student_grade.json")
// 方法1:通过map函数手动转换为Dataset[StudentGrade]类型
val dsSG: Dataset[StudentGrade] = dfSG.map(a => StudentGrade(a.getAs[String](0),a.getAs[String](1),a.getAs[Long](2)))
// 方法2:使用DataFrame的as函数进行转换
val dsSG2: Dataset[StudentGrade] = dfSG.as[StudentGrade]
// 方法3:通过RDD转换而成(基于同样内容的CSV文件)
val dsSG3 = spark.sparkContext.
textFile("data/examples/target/scala-2.11/classes/student_grade.csv").
map[StudentGrade](row => {
     val fields = row.split(",")
	 StudentGrade(
	      fields(0).toString(),
	      fields(1).toString(),
	      fields(2).toLong
	    )
}).toDS
	 
// 求每科的平均分
dsSG3.groupBy("subject").mean("grade").foreach(println(_))
以上 3 种方法都可以将 DataFrame 转换为 Dataset 。转换完成后,就可以使用其 API 对数据进行分析,使用方式与 DataFrame 并无不同。

Spark SQL

在实际工作中,使用频率最高的当属 Spark SQL,通常一个大数据处理项目中,70% 的数据处理任务都是由 Spark SQL 完成,它贯穿于数据预处理、数据转换和最后的数据分析。由于 SQL 的学习成本低、用户基数大、函数丰富,Spark SQL 也通常是使用 Spark 最方便的方式。此外,由于 SQL 包含了丰富的应用语义,所以 Catalyst 优化器带来的性能巨大提升也使 Spark SQL 成为编写 Spark 作业的最佳方式。接下来我将为你介绍 Spark SQL 的使用。

从使用层面上来讲,要想用好 Spark SQL,只需要编写 SQL 就行了,本课时的最后简单介绍了下 SQL 的常用语法,方便没有接触过 SQL 的同学快速入门。

1、创建临时视图

想使用 Spark SQL,可以先创建临时视图,相当于数据库中的表,这可以通过已经存在的 DataFrame、Dataset 直接生成;也可以直接从 Hive 元数据库中获取元数据信息直接进行查询。先来看看创建临时视图:

case class StudentGrade(name: String, subject: String, grade: Long)
// 生成DataFrame
val dfSG = spark.read.json("data/examples/target/scala-2.11/classes/student_grade.json")
// 生成Dataset
val dsSG = dfSG.map(
	  a => StudentGrade( 
	      a.getAs[String]("name"),
	      a.getAs[String]("subject"),
	      a.getAs[Long]("grade")
	  )
)
	 
// 创建临时视图
dfSG.createOrReplaceTempView("student_grade_df")
dsSG.createOrReplaceTempView("student_grade_ds")
// 计算每科的平均分
spark.sql("SELECT subject, AVG(grade) FROM student_grade_df GROUP BY subject").show()
spark.sql("SELECT subject, AVG(grade) FROM student_grade_ds GROUP BY subject").show()

对于 Dataset 来说,对象类型的数据结构会作为临时视图的元数据,在 SQL 中可以直接使用。

2、使用Hive元数据

随着 Spark 越来越流行,有很多情况,需要将 Hive 作业改写成 Spark SQL 作业,Spark SQL 可以通过 hive-site.xml 文件的配置,直接读取 Hive 元数据。这样,改写的工作量就小了很多,代码如下:

val spark = SparkSession
.builder()
.master("local[*]")
.appName("Hive on Spark")
.enableHiveSupport()
.getOrCreate()
// 直接查询
spark.sql(…………)

代码中通过 enableHiveSupport 方法开启对 Hive 的支持,但需要将 Hive 配置文件 hive-site.xml 复制到 Spark 的配置文件夹下。

3、查询语句

Spark 的 SQL 语法源于 Presto (一种支持 SQL 的大规模并行处理技术,适合 OLAP),在源码中我们可以看见,Spark 的 SQL 解析引擎直接采用了 Presto 的 SQL 语法文件。查询是 Spark SQL 的核心功能,Spark SQL 的查询语句模式如下:

[ WITH with_query [, ...] ]
SELECT [ ALL | DISTINCT ] select_expr [, ...]
[ FROM from_item [, ...] ]
[ WHERE condition ]
[ GROUP BY expression [, ...] ]
[ HAVING condition]
[ UNION [ ALL | DISTINCT ] select ]
[ ORDER BY expression [ ASC | DESC ] [, ...] ]
[ LIMIT count ]

其中 from_item 为以下之一:

table_name [ [ AS ] alias [ ( column_alias [, ...] ) ] ]
from_item join_type from_item [ ON join_condition | USING ( join_column [, ...] ) ]

该模式基本涵盖了 Spark SQL 中查询语句的各种写法。

3.1 SELECT 与 FROM 子句

SELECT 与 FROM 是构成查询语句的最小单元,SELECT 后面跟列名表示要查询的列,或者用 * 表示所有列,FROM 后面跟表名,示例如下:

SELECT name, grade FROM student_grade t;

在使用过程中,对列名和表名都可以赋予别名,这里对 student_grade 赋予别名 t,此外我们还可以对某一列用关键字 DISTINCT 进行去重,默认为 ALL,表示不去重:

SELECT COUNT( DISTINCT name) FROM student_grade;

上面这条 SQL 代表统计有多少学生参加了考试。

3.2 WHERE 子句

WHERE 子句经常和 SELECT 配合使用,用来过滤参与查询的数据集,WHERE 后面一般会由运算符组合成谓词表达式(返回值为 True 或者 False ),例如:

SELECT * FROM student_grade WHERE grade > 90;
SELECT * FROM student_grade WHERE name IS NOT NULL;
SELECT * FROM student_grade WHERE name LIKE "*ndy";

常见的运算符还有 !=、<> 等,此外还可以用逻辑运算符:AND、OR 组合谓词表达式进行查询,例如:

SELECT * FROM student_grade WHERE grade > 90 AND name IS NOT NULL

3.3 GROUP BY 子句

GROUP BY 子句用于对 SELECT 语句的输出进行分组,分组中是匹配值的数据行。GROUP BY 子句支持指定列名或列序号(从 1 开始)表达式。以下查询是等价的,都会对 subject 列进行分组,第一个查询使用列序号,第二个查询使用列名:

SELECT avg(grade), subject FROM student_grade GROUP BY 2;
SELECT avg(grade), subject FROM student_grade GROUP BY subject;

使用 GROUP BY 子句时需注意,出现在 SELECT 后面的列,要么同时出现在 GROUP BY 后面,要么就在聚合函数中。

3.4 HAVING 子句

HAVING 子句与聚合函数以及 GROUP BY 子句配合使用,用来过滤分组统计的结果。HAVING 子句去掉不满足条件的分组。在分组和聚合计算完成后,HAVING 对分组进行过滤。例如以下查询会过滤掉平均分大于 90 分的科目:

SELECT subject,AVG(grade) 
FROM student_grade 
GROUP BY subject 
HAVING AVG(grade) < 90;

3.5 UNION 子句

UNION 子句用于将多个查询语句的结果合并为一个结果集:

query UNION [ALL | DISTINCT] query

参数 ALL 或 DISTINCT 可以控制最终结果集包含哪些行。如果指定参数 ALL,则包含全部行,即使行完全相同;如果指定参数 DISTINCT ,则合并结果集,结果集只有唯一不重复的行;如果不指定参数,执行时默认使用 DISTINCT。下面这句 SQL 是将两个班级的成绩进行合并:

SELECT * FROM student_grade_class1
UNION ALL 
SELECT * FROM student_grade_class2;

多个 UNION 子句会从左向右执行,除非用括号明确指定顺序。

3.6 ORDER BY 子句

ORDER BY 子句按照一个或多个输出表达式对结果集排序:

ORDER BY expression [ ASC | DESC ] [ NULLS { FIRST | LAST } ] [, ...]

每个表达式由列名或列序号(从 1 开始)组成。ORDER BY 子句作为查询的最后一步,在 GROUP BY 和 HAVING 子句之后。ASC 为默认升序,DESC 为降序。下面这句 SQL 会对结果进行过滤,并按照平均分进行排序,注意这里使用了列别名

SELECT subject,AVG(grade) avg 
FROM student_grade 
GROUP BY subject 
HAVING AVG(grade) < 90 
ORDER BY avg DESC;

3.7 LIMIT 子句

LIMIT 子句限制结果集的行数,这在查询大表时很有用。以下示例为对单科成绩进行排序并只返回前 3 名的记录:

SELECT * FROM student_grade 
WHERE subject = 'math' 
ORDER BY grade DESC 
LIMIT 3;

3.8 JOIN 子句

JOIN 操作可以将多个有关联的表进行关联查询,下面这句 SQL 是查询数学成绩在 90 分以上的学生的院系,其中院系信息可以从学生基础信息表内,通过姓名连接得到:

SELECT g.*, a.department 
FROM student_grade g 
JOIN student_basic b 
ON g.name = b.name 
WHERE g.subject = 'math' and grade > 90

在这句 SQL 中,表 g 被称为驱动表或是左表,表 b 被称为右表。如前所述,Spark 支持多种连接类型。

小结

本课时主要介绍了 DataFrame、Dataset 与 Spark SQL,相对于 RDD 与算子,这种数据处理方式无疑对分析师来说更为友好,如果前面完成了习题,那么你的体会要更加深刻,而且这也是 Spark 官方推荐的 Spark API,无论是从性能还是从开发效率来说都是全方位领先于 RDD 与算子的组合,这也是很好理解的,举个例子,学习了 Python 后,想用 Python 直接进行数据分析无疑是很不方便的,Python 的 pandas 库则很好地解决了这个问题。

由于 Spark 对于 SQL 支持得非常好,而 pandas 在这方面没那么强大,所以,在某些场景,你可以选择 Spark SQL 来代替 pandas,这有时对于分析师来说非常好用。

最后给你出一个思考题,表中存有某一年某只股票的历史价格,表结构如下:

股票 id,时间戳,成交价格

问题是:请用一句 SQL 计算这只股票最长连续价格上涨天数,这里解释一个概念,如果某天的开盘价小于当天的收盘价,就认为这只股票当天是上涨的。这个需求看起来简单,但是用 SQL 写出来还是比较复杂的,需要用到子查询等内容,如果你能够完成这个思考题,我相信你能很好地使用 Spark SQL。


如何使用用户自定义函数?

你好,我是范东来,本课时我们要讲解的内容是:用户自定义函数。在上个课时,你了解了 DataFrame、Dataset 和 Spark SQL,如果说 Spark 隐藏了分布式计算的复杂性,那么可以认为 DataFrame、Dataset 和 Spark SQL 比它要更近一步,用统一而简洁的接口隐藏了数据分析的复杂性。

在本课时,我们会主要介绍在上个课时中没有详细讲解的函数与自定义函数。在实际使用中,函数和自定义函数的使用频率非常高,可以说,对于复杂的需求,如果用好了函数,那么事情会简单许多,反之,则会事倍功半。

本课时的主要内容有:

  • 窗口函数
  • 函数
  • 用户自定义函数

窗口函数

首先,我们来看下窗口函数,窗口函数可以使用户针对某个范围的数据进行聚合操作,如:

  • 累积和
  • 差值
  • 加权移动平均

可以想象一个窗口在全量数据集上进行滑动,用户可以自定义在窗口中的操作,如下图所示。

1.png

使用窗口函数,首先需要定义窗口,DataFrame 提供了 API 定义窗口,以及窗口中的计算逻辑,还是以学生成绩为例,现在需要得出每个学生单科最佳成绩以及成绩所在的年份,这个需求就要用到窗口中的 row_number 函数,row_number 函数可以根据窗口中的数据生成行号,首先来定义窗口:

import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions._
 
val window = Window
.partitionBy("name","subject")
.orderBy(desc("grade"))

上面的代码定义了窗口的范围:按照每个人的姓名与科目的组合进行开窗,并控制了数据在窗口中的顺序:按照 grade 降序进行排序,row_number 函数就可以作用在这个窗口上,对每个人每个科目成绩赋予行号,代码如下:

dfSG.select(
col("name"),
col("subject"),
col("year"),
col("grade"),
row_number().over(window)
.show()

结果如下:

image (3).png

最后只需要从这张表中过滤出 row_number 等于 1 的数据即可。

此外,DataFrame 还提供了 rowsBetween 和 rangeBetween 来进一步定义窗口范围,其中 rowsBetween 是通过物理行号进行控制,rangeBetween 是通过逻辑条件来对窗口进行控制,来看一个简单的例子,一份两个字段的样例数据:

{"key":"1", "num":2}
{"key":"1", "num":2}
{"key":"1", "num":3}
{"key":"1", "num":4}
{"key":"1", "num":5}
{"key":"1", "num":6}
{"key":"2", "num":2}
{"key":"2", "num":2}
{"key":"2", "num":3}
{"key":"2", "num":4}
{"key":"2", "num":5}
{"key":"2", "num":6}

现在通过窗口函数对相同 key 的 num 字段做累加计算。代码如下:

val windowSlide = Window
.partitionBy("key")
.orderBy("num")
.rangeBetween(Window.currentRow + 2,Window.currentRow + 20)
 
val dfWin = spark.read.json("json/window.json")
 
dfWin
.select(col("key"),sum("num").over(windowSlide))
.sort("key")
.show()

在 rangeBetween 中,定义的窗口是当前行的 num 值 +2 到当前行的 num 值 +20 这个区间中的数据,如下所示:

{"key":"1", "num":2}       窗口为[4,22]           累加和为4 + 5 + 6 = 15

{“key”:“1”, “num”:2}       窗口为[4,22]           累加和为4 + 5 + 6 = 15

{“key”:“1”, “num”:3}       窗口为[5,23]           累加和为5 + 6 = 11

{“key”:“1”, “num”:4}       窗口为[6,24]           累加和为6

{“key”:“1”, “num”:5}       窗口为[8,25]           累加和为null

{“key”:“1”, “num”:6}       窗口为[8,26]           累加和为null

{“key”:“2”, “num”:1}       窗口为[3,21]           累加和为12

{“key”:“2”, “num”:2}       窗口为[4,22]           累加和为12

{“key”:“2”, “num”:5}       窗口为[7,25]           累加和为7

{“key”:“2”, “num”:7}       窗口为[9,27]           累加和为null

rangeBetween 通过字段的值定义了参与计算的逻辑窗口大小,也可以使用 rowsBetween 通过行号来指定参与计算的物理窗口,如下所示:

val windowSlide = Window
.partitionBy("key")
.orderBy("num")
.rowsBetween(Window.currentRow - 1,Window.currentRow + 1)
 
dfWin
.select(col("key"),sum("num").over(windowSlide))
.sort("key")
.show()

代码中定义的窗口由当前行、当前行的前一行、当前行的后一行组成,也就是说窗口大小为 3,计算结果如下:

{"key":"1", "num":2}              累加和为2 + 2 = 4
{"key":"1", "num":2}              累加和为2 + 2 + 3 = 7
{"key":"1", "num":3}              累加和为2 + 3 + 4 = 9
{"key":"1", "num":4}              累加和为3 + 4 + 5 = 12
{"key":"1", "num":5}              累加和为4 + 5 + 6 = 15
{"key":"1", "num":6}              累加和为5 + 6 = 11
{"key":"2", "num":1}              累加和为1 + 2 = 3
{"key":"2", "num":2}              累加和为1 + 2 + 5 = 8
{"key":"2", "num":5}              累加和为2 + 5 + 7 = 14
{"key":"2", "num":7}              累加和为5 + 7 = 12

函数

在需要对数据进行分析的时候,我们经常会使用到函数,Spark SQL 提供了丰富的函数供用户选择,基本涵盖了大部分的日常使用。下面介绍一些常用函数:

1. 转换函数

cast(value AS type) → type

它显式转换一个值的类型。可以将字符串类型的值转为数字类型,反过来转换也可以,在转换失败的时候,会返回 null。这个函数非常常用。

2. 数学函数

log(double base, Column a)

求与以 base 为底的 a 的对数。

factorial(Column e)

返回 e 的阶乘。

3. 字符串函数

split(Column str,String pattern)

根据正则表达式 pattern 匹配结果作为依据来切分字符串 str。

substring(Column str,int pos,int len)

返回字符串 str 中,起始位置为 pos,长度为 len 的字符串。

concat(Column... exprs)

连接多个字符串列,形成一个单独的字符串。

translate(Column src,String matchingString,String replaceString)

在字符串 src 中,用 replaceString 替换 mathchingString。

字符串函数也是非常常用的函数类型。

4. 二进制函数

bin(Column e)

返回输入内容 e 的二进制值。

base64(Column e)

计算二进制列e的 base64 编码,并以字符串返回。

5. 日期时间函数

current_date()

获取当前日期

current_timestamp()

获取当前时间戳

date_format(Column dateExpr,String format)

将日期/时间戳/字符串形式的时间列,按 format 指定的格式表示,并以字符串返回。

6. 正则表达式函数

regexp_extract(Column e,String exp,int groupIdx)

首先在 e 中匹配正则表达式 exp,按照 groupIdx 的值返回结果,groupIdx 默认值为 1,返回第 1 个匹配成功的内容,0 表示返回全部匹配成功的内容。

regexp_replace(Column e,String pattern,String replacement)

用 replacement 替换在 e 中根据 pattern 匹配成功的字符串。

7. JSON 函数

get_json_object(Column e,String path)

解析 JSON 字符串 e,返回 path 指定的值。

8. URL 函数

parse_url(string urlString, string partToExtract [, stringkeyToExtract])

该函数专门用来解析 URL,提取其中的信息,partToExtract 的选项包含 HOST、PATH、QUERY、REF、PROTOCOL、AUTHORITY、USEINFO,函数会根据选项提取出相应的信息。

9. 聚合函数

countDistinct(Column expr,Column... exprs)

返回一列数据或一组数据中不重复项的个数。expr 为返回 column 的表达式。

avg(Column e)

返回 e 列的平均数。

count(Column e)

返回 e 列的行数。

max(Column e)

返回 e 中的最大值

sum(Column e)

返回 e 中所有数据之和

skewness(Column e)

返回 e 列的偏度。

stddev_samp(Column e)

stddev(Column e)

返回 e 的样本标准差。

var_samp(Column e)

variance(Column e)

返回 e 的样本方差。

var_pop(Column e)

返回 e 的总体方差。

这类函数顾名思义,作用于很多行,所以往往与统计分析相关。

10. 窗口函数

row_number()

对窗口中的数据依次赋予行号。

rank()

与 row_number 函数类似,也是对窗口中的数据依次赋予行号,但是 rank 函数考虑到了 over 子句中排序字段值相同的情况,如下表所示。

2.png
dense_rank()

与 row_number 函数类似,也是对窗口中的数据依次赋予行号,但是 dense_rank 函数考虑到over 子句中排序字段值相同的情况,并保证了序号连续。

ntile(n)

将每一个窗口中的数据放入 n 个桶中,用 1-n 的数字加以区分。

在实际开发过程中,大量的需求都可以直接通过函数以及函数的组合完成,一般来说,函数的丰富程度往往超乎你的想象,所以在面临新需求时,建议首先查阅文档,看看有没有函数可以利用,如果实在不行,我们才会使用用户自定义函数(User Defined Function)。

Spark SQL 的函数文档目前我没有发现特别全面的,所以我通常就会直接阅读源码,源码列出了所有的函数,如下:

https://github.com/apache/spark/blob/6646b3e13e46b220a33b5798ef266d8a14f3c85b/sql/core/src/main/scala/org/apache/spark/sql/functions.scala

用户自定义函数

DataFrame API 支持用户自定义函数,自定义函数有两种:UDF 和 UDAF,前者是类似于 map操作的行处理,一行输入一行输出,后者是聚合处理,多行输入,一行输出,先来看看 UDF,下面的代码会开发一个根据得分显示分数等级的函数 level:

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import scala.reflect.api.materializeTypeTag
 
object MyUDF {
  
  def main(args: Array[String]): Unit = {
    
     val spark = SparkSession
    .builder
    .master("local[2]")
    .appName("Test")
    .getOrCreate()
    import spark.implicits._
    
    val dfSG = spark.read.json("examples/target/scala-2.11/classes/student_grade.json")
    
    def level(grade: Int): String = {
	if(grade >= 85)
         "A"
       else if(grade < 85 & grade >= 75)
         "B"
       else if(grade < 75 & grade >= 60)
         "C"
       else if(grade < 60)
         "D"  
       else
         "ERROR"
    }
    
	val myUDF = udf(level _)
    
    dfSG.select(col("name"),myUDF(col("grade"))).show()
    
  }
  
}

接下来看看 UDAF,UDAF 是用户自定义聚合函数,分为两种:un-type UDAF 和 safe-type UDAF,前者是与 DataFrame 配合使用,后者只能用于 Dataset,UDAF 需要实现 UserDefinedAggregateFunction 抽象类,本例实现了一个求某列最大值的 UDAF,代码如下:

import org.apache.spark.sql.expressions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.Row
import org.apache.spark.sql.functions._
import org.apache.spark.sql.SparkSession
 
object MyMaxUDAF extends UserDefinedAggregateFunction {
 
  //指定输入的类型
  override def inputSchema: StructType 
    = StructType(Array(StructField("input", IntegerType, true)))
  
  //指定中间输出的类型,可指定多个
  override def bufferSchema: StructType 
    = StructType(Array(StructField("max", IntegerType, true)))
 
  //指定最后输出的类型
  override def dataType: DataType = IntegerType
  override def deterministic: Boolean = true
  
  //初始化中间结果
  override def initialize(buffer: MutableAggregationBuffer): Unit 
    = {buffer(0) = 0}
  
  //实现作用在每个分区的结果
  override def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
    val temp = input.getAs[Int](0)
    val current = buffer.getAs[Int](0)
    if(temp > current)
       buffer(0) = temp
  }
 
  //合并多个分区的结果
  override def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
    if(buffer1.getAs[Int](0) < buffer2.getAs[Int](0))
        buffer1(0) = buffer2.getAs[Int](0)     
  }
  
  //返回最后的结果
  override def evaluate(buffer: Row): Any = buffer.getAs[Int](0)
}
 
object MyMaxUDAFDriver extends App{
  
   val spark = SparkSession
    .builder
    .master("local[2]")
    .appName("Test")
    .getOrCreate()
    import spark.implicits._
  
  val dfSG = spark.read.json("examples/target/scala-2.11/classes/student_grade.json")
    
  dfSG.select(MyMaxUDAF(col("grade"))).show()
}

可以从代码看到 UDAF 的逻辑,还是类似于 MapReduce 的思想,先通过 update 函数处理每个分区,最后再通过 merge 函数汇总结果。

Dataset 的 UDAF 对应的是 safe-type UDAF,这种类型的 UDAF 只有 Dataset 能够使用,因为 Dataset 是类型安全的。使用方式和 un-type UDAF 类似,也是先要结合自己聚合的逻辑实现 Aggregator 抽象类,最后再通过 Dataset API 调用,此处实现一个求学生成绩平均值的 UDAF,代码如下:

import org.apache.spark.sql.Encoders
import org.apache.spark.sql.Encoder
import org.apache.spark.sql.expressions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
import scala.reflect.api.materializeTypeTag
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.Dataset
 
case class StudentGrade(name: String, subject: String, grade: Long)
 
case class Average(var sum: Long, var count: Long)
 
//这里定义的三个类型分别是输入类型、中间结果类型、输出类型
object MyAvgUDAF extends Aggregator[StudentGrade,Average,Double]{
    
    //初始中间状态
    def zero: Average = Average(0L,0L)
    
    //更新中间状态
    def reduce(buffer: Average, sg: StudentGrade): Average = {
      buffer.sum += sg.grade
      buffer.count += 1
      buffer
    }
    
    //合并状态
    def merge(b1: Average, b2: Average): Average = {
      b1.sum += b2.sum
      b1.count += b2.count
      b1
    }
    
    //得到最后结果
    def finish(reduction: Average): Double = reduction.sum / reduction.count
    
    //为中间结果指定编译器
    def bufferEncoder: Encoder[Average] = Encoders.product
    
    //为输出结果指定编译器
    def outputEncoder: Encoder[Double] = Encoders.scalaDouble
 
}
	通过Dataset API调用:
object MyAvgUDAFDriver extends App{
  
    val spark = SparkSession
    .builder
    .master("local[2]")
    //.config("spark.reducer.maxSizeInFlight", "128M")
    .appName("Test")
    .getOrCreate()
    import spark.implicits._
    
    //读取数据
    val dfSG = spark.read.json("examples/target/scala-2.11/classes/student_grade.json")
    //生成Dataset
    val dsSG: Dataset[StudentGrade] = dfSG.map(a => StudentGrade(a.getAs[String](0),a.getAs[String](1),a.getAs[Long](2)))
    //注册UDAF
    val MyAvg = MyAvgUDAF.toColumn.name("MyAvg")
    //查询
    dsSG.select(MyAvg).show()
  

}

自定义函数注册以后,同样可以在 Spark SQL 中使用。

小结

最后来做一个小结,这不光针对本课时,而是一个阶段性小结,现在我们学习了 RDD API、DataFrame API 和 Dataset API,对于数据处理来说,它们都能胜任,那么在实际使用中应该如何选择呢。

一般来说,在任何情况下,都不推荐使用 RDD 算子,原因如下:

  • 在某种抽象层面上来说,使用 RDD 算子编程相当于直接使用汇编语言或者机器代码进行编程;
  • RDD算子与 SQL、DataFrame API 和 Dataset API 相比,更偏向于如何做,而非做什么,这样优化的空间很少;
  • RDD 语言不如 SQL 语法友好。

此外,在其他情况,应优先考虑 Dataset,因为静态类型的特点会使计算更加迅速,但用户必须使用静态语言才行,如 Java 与 Scala,像 Python 这种动态语言是没有 Dataset API 的。

下图是用户用不同语言基于 RDD API 和 DataFrame API 开发的应用性能对比,可以看到 Python + RDD API 的组合是远远落后其他组合的,此外,RDD API 开发应用的性能整体要明显落后于 DataFrame API 开发的应用性能。从开发速度和性能上来说,DataFrame + SQL 无疑是最好选择。

3.png

最后,还是留一个思考题,最开始我们举了一个窗口函数的例子,后面在介绍 Spark SQL 中也学习了窗口函数,你能用 SQL 的窗口函数实现同样的逻辑吗?


列式存储:针对查询场景的极致优化

在前 2 个课时,我们学习了如何用 DataFrame + SQL 的方式对数据进行分析与处理,需要实践的内容比较多,学习起来未免比较辛苦,那么本课时我们来聊聊列式存储这个比较轻松又实用的话题。在本课时的标题中,提到了查询场景和极致优化,也就是说,如果你的业务场景只是查询(这意味着没有增删改),那么列式存储将带来极其可观的性能提升。

本课时的主要内容有:

  • Google Dremel
  • 列式存储的实现
  • 对比测试

Google Dremel

Google 在 2004-2006 年期间发表了著名的“三驾马车”论文,开启了大数据时代。在 2010 年,Google 又发表了 3 篇论文,被称为 Google 的“新三驾马车”,可见其分量之重,其中一篇《Dremel: Interactive Analysis of Web-Scale Datasets》,提出了列式存储与多级执行树,文中介绍了 Google 运用 Dremel 分析来自互联网的千亿条级别数据的实践。

与论文中提出的列式存储相比,行式存储可以看成是一个行的集合,其中每一行都要求对齐,哪怕某个字段为空(下图中的左半部分),而列式存储则可以看成一个列的集合(下图中的右半部分)。列式存储的优点很明显,主要有以下 4 点:

  1. 查询时可以只读取涉及的列(选择操作),并且列可以直接作为索引,非常高效,而行式存储则必须读入整行。
  2. 列式存储的投影操作非常高效。
  3. 在数据稀疏的情况下,压缩率比行式存储高很多,甚至可以考虑将相关的表进行预先连接,来完全避免投影操作。
  4. 因为可以直接作用于某一列上,聚合分析非常迅速。

行式存储一般擅长的是插入与更新操作,而列式存储一般适用于数据为只读的场景。对于结构化数据,列式存储并不陌生。因此,列式存储技术经常用于传统数据仓库中。下图分别展示了行式存储和列式存储的区别。

1.png

在文章中,Dremel 在一开始就指出其面对的是只读的嵌套数据,而嵌套数据属于半结构化数据,例如 JSON、XML,所以 Dremel 的创新之处在于提出了一种支持嵌套数据的列式存储,而如今互联网上的数据又正好多是嵌套结构。下图左边是一个嵌套的 Schema,而右边的 r1、r2 为两条样例记录:

2.png

3.png

4.png

这个 Schema 其实可以转换为一个树形结构,如下图所示。

5.png

该树结构有 6 个叶子节点,可以看到叶子节点其实就是 Schema 中的基本数据类型,如果将这种嵌套结构的数据展平,那么展平后的表应该有 6 列。如果要应用列式存储来存储这种嵌套结构,还需要解决一个问题,我们看到 r1、r2 的数据结构还是差别非常大,所以需要标识出哪些列的值组成一条完整的记录,但我们不可能为每条记录都维护一个树结构。Google 提出的 record shredding and assembly algorithm 算法很好地解决了这个问题,该算法规定,在保存字段值时,还需要额外存储两个数字,分别表示 Repetition level(r)和 Definition level(d)其中,Repetition level 值记录了当前值属于哪一条记录以及它处于该记录的什么位置;另外对于 repeated 和 optional 类型的列,可能一条记录中某一列是没有值的,如果不进行标识就会导致本该属于下一条记录的值被当作当前记录的一部分,对于这种情况就需要用 Definition level 来标识这种情况,通过 Striping & Assembly 算法我们可以将一整条记录还原出来,如下图所示。这样就能用尽可能少的存储空间来表达复杂的嵌套数据格式了。

6.png

7.png

Dremel 的另外一个组成部分是查询执行树,利用这种架构,Dremel 可以用很低的延迟分析大量数据,这使得 Dremel 非常适合进行交互式分析。Dremel 有很多种开源实现,与 Dremel 一样,它主要分为两部分,一个实现了 Dremel 的嵌套列式存储,如 Apache Parquet、Apache ORC,还有一些实现了 Dremel 的查询执行架构,也就是多级执行树,如 Apache Impala、Aapche Drill 与 Presto。

这里要特别说明的是,多级执行树这种技术与 Spark 这种 MapReduce 类型的计算框架完全不同,它类似于一种大规模并行处理,希望以较低的延迟完成查询,所以并行程度要远远大于 Spark,但是每个执行者的性能要远远弱于 Spark。如果把 Spark 看成是对 CPU 核心的抽象,那么多级执行树可以看成是对线程的抽象。基于此,多级执行树 + 列式存储的组合往往用于 OLAP 的场景。

Parquet 和 ORC 这两种数据格式和 Json 一样都是自描述数据格式,Spark 很早就支持由 Parquet、ORC 格式的数据直接生成 DataFrame。**在课时 12 中曾讲到,我们可以非常方便地通过 read 读取器和 write 写入器读取和生成 Parquet 和 ORC 文件。**列式存储在选择、投影操作的性能优化提升非常明显,此外,Dremel 的高压缩比率也对 Spark 这种 I/O 密集型作业非常友好。在目前 Hadoop、Spark 体系的数据仓库中,已经很少采用 CSV、TEXT 这种格式了。

列式存储的实现

Apache Parquet

Apache Parquet 是 Dremel 的开源实现,它最先是由 Twitter 与 Cloudera 合作开发并开源,和 Impala 配合使用。Parquet 支持几乎 Hadoop 生态圈的所有项目,与数据处理框架、数据结构以及编程语言无关。

Apache ORC

Apache ORC(OptimizedRC file)来源于 RC(RecordColumnar file)格式,但目前已基本取代 RC 格式。ORC 提供 ACID 支持、也提供不同级别的索引,如布隆过滤器、列统计信息(数量、最值等),和 Parquet 一样,它也是自描述的数据格式,但与 Parquet 不同的是,ORC 支持多种复杂数据结构,如集合、映射等。ORC 与 Presto 配合使用,效果非常好。

Apache CarbonData

CarbonData 是华为开源的一种列式存储格式,是专门为海量数据分析和处理而生的。CarbonData 于 2016 年开源,目前发展非常迅猛,与 Apache Kylin 并列为由国人主导的两个Apache 顶级项目。它的设计初衷源于,在很多时候,对于同样一份数据,处理方式是不同的,比如以下几种处理方式:

  • 全表扫描,或者选取几列进行过滤;

  • 随机访问,如行键值查询,要求低延迟;

  • ad-hoc 交互式分析,如多维聚合分析、上卷、下钻、切片等。

不同的处理方式对于数据格式的需求侧重点是不同的,但 CarbonData 旨在为大数据多样化的分析需求提供一种统一的数据格式。CarbonData 的设计目标为:

  • 支持低延迟访问多种数据访问类型;
  • 允许在压缩编码过的数据上进行快速查询;
  • 确保存储空间的高效性;
  • 很好地支持 Hadoop 生态系统;
  • 读最优化的列式存储;
  • 利用多级索引实现低延迟;
  • 支持利用列组来获得基于行的优点;
  • 能够对聚合的延迟解码进行字典编码。

如下图所示,这是一个 CarbonData 数据文件,也是 HDFS 上的一个数据块,每个文件由 File Header、File Footer 与若干个 Blocklet 组成,其中 File Header 保存了文件版本号、Schema 以及更新时间戳;File Footer 包含了一些统计信息(每个 Blocklet 的最值)、多维索引等。一个 Blocklet 的默认大小为 64MB,包含多个 Column Page Group,Blocklet 可以看成一个表的水平切片,这个表有多少列,就有多少个 Column Page Group,在一个 Column Page Group 中,一列被分为若干个连续文件,每一个文件被称为 Page,一个 Page 默认为 32000 行,如下图所示。

8.png

这里要特别说明的是,CarbonData 在设计理念上没有采取 Dremel 提出的嵌套的列式存储,而是引入了索引和元数据的设计,但仍然属于列式存储格式。

对比测试

使用列式存储对 Spark 性能提升的影响是非常巨大的,下面是一份测试结果,包含了对于同样一份数据(368.4G),各种数据格式压缩率的对比,以及一些计算作业耗时的对比:

TEXTParquetORCCarbonData
压缩后大小368.4G298G148.4G145.8G
压缩率100%19.11%59.72%60.42%
TEXTParquetORCCarbonData
Count67s116s119s6s
Group By138s75s71s92s
Join231s172s140s95s

可以看到 ORC 的压缩率最高,而 CarbonData 在 Spark 批处理这种场景下,性能表现得非常好,是一种非常有前景的技术。

列式存储的压缩率如此之高,从本课时的第一张图也可以看出原因,列式存储作为列的集合,空间几乎没有多余的浪费。如此高的压缩效率也带来了一个优化思路:可以将若干相关的表预先进行连接,连接而成的表可以看成是一张稀疏的宽表,这张宽表对分析来说就非常友好了,但由于采用了列式存储,所以宽表所占的空间并不是指数上涨而是线性增加。在这种场景下,列式存储使得空间换时间成为可能。

小结

目前,列式存储在数据分析领域非常火,比如最近大热的俄罗斯开源列式分析数据库 ClickHouse。Spark 在很早就支持列式存储,而列式存储的使用带来的性能提升是十分巨大的,至于选取哪种列式存储,你可以根据具体的性能表现与存储空间综合进行考虑,不过 ORC 这种格式在绝大多数场景都能胜任。

这里给你留一个思考题,为什么 CarbonData 的 count 性能会远远超过 Dremel 系列的技术,如 ORC 和 Parquet 呢?大家可以从本文中找到答案。


如何对 Spark 进行全方位性能调优?

通过前面的课程学习和练习,我相信你们已经能够比较熟练地编写 Spark 作业,但如何在生产环境中让 Spark 作业稳定且快速地运行是另外一个问题,本课时将回答这个问题。

让 Spark 作业在海量数据面前稳定且快速地运行,这就需要对 Spark 进行性能调优。调优 Spark 是一个持续的过程,随着你对 Spark、数据本身、业务场景愈发了解,调优的思路也会更加多样,这是一个持续累积的过程。能够有针对性地对 Spark 作业进行调优是一名有经验的大数据工程师的必备技能。本课时将会从硬件、资源管理平台与使用方式 3 个维度介绍如何对 Spark 进行性能调优。在介绍调优之前,我们先来看看如何查看 Spark 的作业日志。

日志收集

如果作业执行报错或者速度异常,通常需要查看 Spark 作业日志,Spark 日志通常是排错的唯一根据,更是作业调优的好帮手。查看日志的时候,需要注意的是 Spark 作业是一个分布式执行的过程,所以日志也是分布式的,联想到 Spark 的架构,Spark 的日志也分为两个级别:

  • Driver
  • Executor

一般来说,小错误通常可以从 Driver 日志中定位,但是复杂一点的问题,还是要从 Executor 的执行情况来判断。如果我们选取 yarn-client 的模式执行,日志会输出到客户端,我们直接查看即可,非常方便。我们可以用下面这种方式收集,如下:

nohup ./bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode client \
--executor-memory 20G \
--num-executors 50 \
/path/to/examples.jar \

1000  >>  o  &
其中 nohup 和 & 表示后台执行,>> o 表示将日志输出到文件 o 中。

查看 Executor 的日志需要先将散落在各个节点(Container)的日志收集汇总成一个文件。以 YARN 平台为例:

yarn logs -applicationId application_1552880376963_0002 >> o
application_1552880376963_0002 是 Spark 作业 id,当汇聚为一个文件后,我们就可以对其进行查看了。打开文件,我们发现这份日志是这样组织的:
container_0
-----------------------------------------------------
WARN.....
ERROR.....
......(日志内容)

container_1

…(日志内容)

这也非常好理解,本来就是从各个 Container 中收集并拼接的,但是这种方式给我们定位造成了一定障碍。阅读这样的日志,最重要的是找到最开始报错的那一句日志,因为一旦作业报错,几乎会造成所有 Container 报错,但大部分错误日志都对定位原因没有什么帮助。所以拿到这份日志要做的第一件事是利用时间戳和 ERROR 标记定位最初的错误日志。这种方式通常可以直接解决一半以上的报错问题。

硬件配置与资源管理平台

构建 Spark 集群的硬件只需普通的商用 PC Server 即可,由于 Spark 作业对内存需求巨大,建议配置高性能 CPU、大内存的服务器,以下是建议配置:

内存:256G

CPU:Intel E5-2640v4

硬盘:3T * 8

该 CPU 是双路 6 核心,且具有超线程技术,所以一个 CPU 相当于有 2 * 6 * 2 = 24 核心。对于交换机的选择,通常,如果在生产环境使用,那么无论集群规模大小,都应该直接考虑万兆交换机,对于上千的集群,还需要多台交换机进行堆叠才能满足需求。

Spark 基于资源管理平台运行,该平台对于 Spark 来说就像一个资源池一样,资源池的大小取决于每个物理节点有多少资源供资源管理平台调度。一般来说,每台节点应预留 20% 的资源保证操作系统与其他服务稳定运行,对于前面提到的机器配置,加入资源池的内存为 200G,CPU 为 20 核。假设使用 YARN 作为资源管理平台,相关配置如下:

yarn.nodemanager.resource.memory-mb = 200G
yarn.nodemanager.resource.cpu-vcores = 20

假设 YARN 集群中有 10 个 NodeManager 节点,那么总共的资源池大小为 2000G、200 核。在 Spark 作业运行时,用户可以通过集群监控页面来查看集群 CPU 使用率,如果发现 CPU 使用率一直维持在偏低的水平,可以尝试将 yarn.nodemanager.resource.cpu-vcores 改大。内存与 CPU 资源设置应该维持一个固定的比例,如 1:5,这样在提交作业时,也按照这个比例来申请资源,可以提高集群整体资源利用率。

一般来说,YARN 集群中会运行各种各样的作业,这样资源利用率会比较高,但是也经常造成 Spark 作业在需要时申请不到资源,这时可以采取 YARN 的新特性:基于标签的调度,在某些节点上打上相应的标签,来实现部分资源的隔离。

这部分内容对于工程师与分析师来说,一般接触不到,属于大数据运维工程师职责的范畴,但是对于调优来说非常重要,有必要了解。

参数调优与应用调优

本课时主要从使用层面来介绍调优,其中会涉及参数调优、应用调优甚至代码调优。

1. 提高作业并行度

在作业并行程度不高的情况下,最有效的方式就是提高作业并行程度。在 Spark 作业划分中,一个 Executor 只能同时执行一个 Task ,一个计算任务的输入是一个分区(partition),所以改变并行程度只有一个办法,就是提高同时运行 Executor 的个数。通常集群的资源总量是一定的,这样 Executor 数量增加,必然会导致单个 Executor 所分得的资源减少,这样的话,在每个分区不变的情况下,有可能会引起性能方面的问题,所以,我们可以增大分区数来降低每个分区的大小,从而避免这个问题。

RDD 一开始的分区数与该份数据在 HDFS 上的数据块数量一致,后面我们可以通过 coalesce 与 repartition 算子进行重分区,这其实改变的是 Map 端的分区数,如果想改变 Reduce 端的分区数,有两个办法,一个是修改配置 spark.default.parallelism,该配置设定所有 Reduce 端的分区数,会对所有 Shuffle 过程生效,此外还可以直接在算子中将分区数作为参数传入,绝大多数算子都有分区数参数的重载版本,如 groupByKey(600) 等。在 Shuffle 过程中,Shuffle 相关的算子会构建一个哈希表,Reduce 任务有时会因为这个表过大而造成内存溢出,这时就可以试着增大并行程度。

2. 提高 Shuffle 性能

Shuffle 是 Spark 作业中关键的一环,也是性能调优的重点,先来看看 Spark 参数中与 Shuffle 性能有关的有哪些:

spark.shuffle.file.buffer
spark.reducer.maxSizeInFlight
spark.shuffle.compress

根据课时 11 的内容,第 1 个配置是 Map 端输出的中间结果的缓冲区大小,默认 32K,第二个配置是 Map 端输出的中间结果的文件大小,默认为 48M,该文件还会与其他文件进行合并。第三个配置是 Map 端输出是否开启压缩,默认开启。缓冲区当然越大,写入性能越高,所以有条件可以增大缓冲区大小,可以提升 Shuffle Write 的性能,但该参数实际消耗的内存为 C * spark.shuffle.file.buffer,其中 C 为执行该任务的核数。在 Shuffle Read 的过程中,Reduce Task 所在的 Executor 会按照 spark.reducer.maxSizeInFlight 的设置大小去拉取文件,这需要创建内存缓冲区来接收,在内存足够大的情况下,可以考虑提高 spark.reducer.maxSizeInFlight 的值来提升 Shuffle Read 的效率。spark.shuffle.compress 配置项默认为 true,表示会对 Map 端输出进行压缩。

Spark Shuffle 会将中间结果写入到 spark.local.dir 配置的目录下,可以将该目录配置多路磁盘目录,以提升写入性能。

3. 内存管理

Spark 作业中内存主要有两个用途:计算和存储。计算是指在 Shuffle,连接,排序和聚合等操作中用于执行计算任务的内存,而存储指的是用于跨集群缓存和传播数据的内存。在 Spark 中,这两块共享一个统一的内存区域(M),如下图所示:

图片1.png

用计算内存时,存储部分可以获取所有可用内存,反之亦然。如有必要,计算内存也可以将数据从存储区移出,但会在总存储内存使用量下降到特定阈值(R)时才执行。换句话说,R 决定了 M 内的一个分区,在这个分区中,数据不会被移出。由于实际情况的复杂性,存储区一般不会去占用计算区。

这样设计是为了对那些不使用缓存的作业可以尽可能地使用全部内存;而需要使用缓存的作业也会有一个区域始终用来缓存数据,这样用户就可以不需要知道其背后复杂原理,自己根据实际内存需求来调节 M 与 R 的值,以达到最好效果。下面是决定 M 与 R 的两个配置:

  • spark.memory.fraction,该配置表示 M 占 JVM 堆空间的比例,默认为 0.6,剩下 0.4 用于存储用户数据结构、Spark 中的内部元数据并防止在应对稀疏数据和异常大的数据时出现 OutOfMemory 的错误;
  • spark.memory.storageFraction,该配置表示 R 占 M 的比例,默认为 0.5,这部分缓存的数据不会被移出。

上面两个默认值基本满足绝大多数作业的使用,在特殊情况可以考虑设置 spark.memory.fraction 的值以适配 JVM 老年代的空间大小,默认 JVM 老年代在不经过设置的情况下占 JVM 的 2/3,所以这个值是合理的。

Spark Executor 除了堆内存以外,还有非堆内存空间,这部分通过参数spark.yarn.executor.memoryoverhead 进行配置,最小为 384MB,默认为 Executor 内存的 10%。所以整个Executor JVM 消耗的内存为:

spark.yarn.executor.memoryoverhead + spark.executor.memory

其中:

M = spark.executor.memory * spark.memory.fraction
R = M * spark.memory.storageFraction

此外,Spark 还有可能会用到堆外内存 O:

O = spark.memory.offHeap.size

所以整个 Spark 的内存管理布局如下图所示:

图片2.png

用户需要知道每个部分的大小应如何调节,这样才能针对场景进行调优。这其实是 Spark 实现的一种比较简化且粗粒度的内存调节方案。如果用户想要更精细地调整内存的每个区域,就需要在参数中 spark.executor.extraClassPath 配置 Java 选项了,这种方式只针对富有经验的工程师,对于普通用户来说不太友好。

4. 序列化

序列化是以时间换空间的一种内存取舍方式,其根本原因还是内存比较吃紧,我们可以优先选择对象数组或者基本类型而不是那些集合类型来实现自己的数据结构,fastutil 包提供了与 Java 标准兼容的集合类型。除此之外,还应该避免使用大量小对象与指针嵌套的结构。我们可以考虑使用数据 ID 或者枚举对象来代替字符串键。如果内存小于 32GB,可以设置 Java 选项 -XX:+UseCompressedOops 来压缩指针为 4 字节,以上是需要用到序列化之前可以做的调优工作,以节省内存。

对于大对象来说,可以使用 RDD 的 persist 算子并选取 MEMORY_ONLY_SER 级别进行存储,更好的方式则是以序列化的方式进行存储。这相当于用时间换空间,因为反序列化时会造成访问时间过慢,如果想用序列化的方式存储数据,推荐使用 Kyro 格式,它比原生的 Java 序列化框架性能优秀(官方介绍,性能提升 10 倍)。Spark 2.0 已经开始用 Kyro 序列化 shuffle 中传输的字符串等基础类型数据了。

要想使用 Kyro 序列化库,要将需要序列化的类在 Kyro 中注册方可使用。使用步骤如下。

  1. 编写一个注册器,实现 KyroRegister 接口,在 Kyro 中注册你需要使用的类:
public static class YourKryoRegistrator implements KryoRegistrator {
	public void registerClasses(Kryo kryo) {
    在Kryo序列化库中注册自定义的类
	kryo.register(YourClass.class, new FieldSerializer(kryo, YourClass.class));
	}
}
  1. 设置序列化工具并配置注册器:
……
spark.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
spark.config("spark.kryo.registrator", YourKryoRegistrator.class.getName())
5. JVM垃圾回收(GC)调优

通常来说,那种只读取 RDD 一次,然后对其进行各种操作的作业不太会引起 GC 问题。当 Java 需要将老对象释放,为新对象腾出空间时,需要追踪所有 Java 对象,然后在其中找出没有使用的那些对象。GC 的成本与 Java 对象数量成正比,所以使用较少对象的数据结构会大大减轻 GC 压力,如直接使用整型数组,而不选用链表。通常在出现 GC 问题的时候,序列化缓存是首先应该尝试的方法。

由于执行计算任务需要的内存和缓存 RDD 的内存互相干扰,GC 也可能成为问题。这可以控制分配给 RDD 缓存空间来缓解这个问题。

GC 调优的第 1 步是搞清楚 GC 的频率和花费的时间,这可以通过添加 Java 选项来完成:

-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

在 Spark 运行时,一旦发生 GC,就会被记录到日志里。

为了进一步调优 JVM,先来看看 JVM 如何管理内存。Java 的堆空间被划分为 2 个区域:年轻代、老年代,顾名思义,年轻代会保存一些短生命周期对象,而老年代会保存长生命周期对象。年轻代又被划分为 3 个区域:一个 Eden 区,两个 Supervisor 区,如下图所示。简单来说,GC 过程是这样的:当 Eden 区被填满后,会触发 minor GC,然后 Eden 区和 Supvisor1 区还存活的对象被复制到 Supervisor2 区,如果某个对象太老或者 Supervisor2 区已满,则会将对象复制到老年代中,当老年代快满了,则会触发 full GC。

图片3.png

在 Spark 中,GC 调优的目的是确保只有长生命周期的对象才会保存到老年代中,年轻代有充足的空间来存储短生命周期对象。这会有助于避免执行 full GC 来回收任务执行期间生成的临时对象,有以下几个办法:

  • 通过收集到的 GC 统计信息来检查是否有过多 GC,如果任务在完成之前多次触发 full GC,则意味着没有足够的内存可用于执行任务;
  • 如果 minor GC 次数过多,但并没有 major GC,可以为 Eden 区分配更多的内存来缓解,可以将 Eden 区大小预估为每个任务需要的内存空间,如果 Eden 区的大小为 E,则可以使用选项 -Xmn = 4 / 3 * E 来设置年轻代大小,增加的 1/3 为 Supervisor 区;
  • 如果通过收集到的 GC 统计信息,发现老年代快满了,可以通过 spark.memory.fraction 来减少用于缓存的内存空间;少缓存一点总比执行缓慢好,也可以考虑减少年轻代的大小,这可以通过设置 -Xmn 来实现,也可以设置 JVM 的 NewRatio 参数,该参数表示老年代与年轻代之间的比值,许多 JVM 默认为 2(2:1),这意味着老年代占堆空间的 2/3。该比例应该要大于 spark.memory.fraction 所设置的比例;
  • 在某些 GC 是瓶颈的情况下,可以通过 -XX:+UseG1GC 开启 G1GC,可以提高 GC 性能,对较大的堆,可能需要增加 G1 区大小,使用 -XX:G1HeapRegionSize=n 来进行设置;
  • 如果任务从 HDFS 读取数据,则可以以此来估计任务使用的内存量,解压缩块大小通常是 HDFS 数据块大小(假设为 256MB)的 2~3 倍,如果希望有足够 3~4 个任务内存空间,则 Eden 区大小为 4 * 3 * 256MB;每当我们对 Java 选项做出调整后,要通过监控工具来查看 GC 花费的时间和频率是否有变化。可以通过在作业中设置 spark.executor.extraJavaOptions选项来指定执行程序的 GC 选项以及 JVM 内存各个区域的精确大小,但不能设置 JVM 堆大小,该项只能通过 --executor-memory 或者 spark.executor.memory 来进行设置。
6. 将经常被使用的数据进行缓存

如果某份数据经常会被使用,可以尝试用 cache 算子将其缓存,有时效果极好。

7. 使用广播变量避免 Hash 连接操作

在进行连接操作时,可以尝试将小表通过广播变量进行广播,从而避免 Shuffle,这种方式也被称为 Map 端连接。

8. 聚合 filter 算子产生的大量小分区数据

在使用 filter 算子后,通常数据会被打碎成很多个小分区,这会影响后面的执行操作,可以先对后面的数据用 coalesce 算子进行一次合并。

9. 根据场景选用高性能算子

很多算子都能达到相同的效果,但是性能差异却比较大,例如在聚合操作时,选择 reduceByKey 无疑比 groupByKey 更好;在 map 函数初始化性能消耗太大或者单条记录很大时,mapPartition 算子比 map 算子表现更好;在去重时,distinct 算子比 groupBy 算子表现更好。

10. 数据倾斜

数据倾斜是数据处理作业中的一个非常常见也是非常难以处理的一个问题。 正常情况下,数据通常都会出现数据倾斜的问题,只是情况轻重有别而已。数据倾斜的症状是大量数据集中到一个或者几个任务里,导致这几个任务会严重拖慢整个作业的执行速度,严重时甚至会导致整个作业执行失败。如下图所示:

Lark20200602-171237.png

可以看到 Task A 处理了绝大多数数据,其他任务执行完成后,需要等待此任务执行完成,作业才算完成。对于这种情况,可以采取以下几种办法处理:

  • 过滤掉脏数据

很多情况下,数据倾斜通常是由脏数据引起的,这个时候需要将脏数据过滤。

  • 提高作业的并行度

这种方式从根本上仍然不能消除数据倾斜,只是尽可能地将数据分散到多个任务中去,这种方案只能提升作业的执行速度,但是不能解决数据倾斜的问题。

  • 广播变量

可以将小表进行广播,避免了 Shuffle 的过程,这样就使计算相对均匀地分布在每个 Map 任务中,但是对于数据倾斜严重的情况,还是会出现作业执行缓慢的情况。

  • 将不均匀的数据进行单独处理

在连接操作的时候,可以先从大表中将集中分布的连接键找出来,与小表单独处理,再与剩余数据连接的结果做合并。处理方法为:如果大表的数据存在数据倾斜,而小表不存在这种情况,可以将大表中存在倾斜的数据提取出来,并将小表中对应的数据提取出来,这时可以将小表中的数据扩充 n 倍,而大表中的每条数据则打上一个 n 以内的随机数作为新键,而小表中的数据则根据扩容批次作为新键,如下图所示:

图片4.png

这种方式可以将倾斜的数据打散,从而避免数据倾斜。

对于那种分组统计的任务,可以通过两阶段聚合的方案来解决,首先将数据打上一个随机的键值,并根据键的哈希值进行分发,将数据均匀的分散到多个任务中去,然后在每个任务中按照真实的键值做局部聚合,最后再按照真实的键值分发一次,得到最后的结果,如下图所示,这样,最后一次分发的数据已经是聚合过后的数据,就不会出现数据倾斜的情况。这种方法虽然能够解决数据倾斜的问题但只适合聚合计算的场景。

图片5.png

小结

本课时介绍了如何从几个方面对 Spark 作业进行调优。调优之前,看作业日志是基本功,这个没有什么捷径,只能多看。调优这个话题是一个很个性化的问题。对于离线计算任务,时间当然很重要,但不一定是最重要的。通常来说,对于实时处理,时间通常是唯一优化目标,如果执行时间有优化的空间,当然会不遗余力地进行优化。但是,对于离线计算任务,如 Spark 作业,作业执行时间并没有那么重要。通常,这类作业都是在夜深人静的晚上执行,1 小时与 90 分钟真的差异就那么大吗,不一定,所以对于离线计算作业来说,作业执行时间并不是最重要的,开发效率同样重要。

这里给你留一个思考题:通过 Spark Web UI 查看作业的执行情况。

未来的趋势,一定是 Spark 越来越智能,越来越简单,Spark 希望开发人员专注于业务,而对框架则无须过多关注。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

办公模板库 素材蛙

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

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

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

打赏作者

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

抵扣说明:

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

余额充值