本文将详细分析和总结Spark SQL及其DataFrame、Dataset的相关原理和优化过程。
Spark SQL简介
Spark SQL是Spark中 具有 大规模关系查询的结构化数据处理 模块(spark核心组件:spark sql,spark streaming,spark mllib,spark GraphX)。spark sql支持大规模的分布式内存计算,并且模糊了RDD与 relational table 之间的界限,DataFrame API和Datasets API是与Spark SQL进行交互的方式。
Spark SQL在Spark Core之上运行。它允许开发人员从Hive表和Parquet文件导入关系数据,对导入的数据和现有RDD运行SQL查询,并轻松将RDD写出到Hive表或Parquet文件。正如Spark SQL提供的DataFrame API一样,它可以在外部数据源和Sparks内置分布式集合上执行关系操作。Spark SQL引入了称为Catalyst的可扩展优化器。
Spark SQL使用结构化和半结构化数据的3种主要功能,如:a) 它在Scala,Java和Python中均可使用DataFrame。同样,简化了结构化数据集的工作,DataFrames与关系数据库中的表相似。b) Spark SQL以各种结构化格式可以读取和写入数据。例如,Hive Table,JSON和Parquet。c) 我们可以使用Spark SQL语句 查询数据,在Spark程序内部以及从外部工具连接到Spark SQL。
Spark SQL特点如下:a) 数据兼容:可从Hive表、外部数据库(JDBC)、RDD、Parquet文件、JSON文件获取数据,可通过Scala方法或SQL方式操作这些数据,并把结果转回RDD。b) 组件扩展:SQL语法解析器、分析器、优化器均可重新定义。c) 性能优化:内存列存储、动态字节码生成等优化技术,内存缓存数据。d) 多语言支持:Scala、Java、Python、R。
Spark SQL执行计划
Catalyst Optimizer
Catalyst Optimizer是基于scala中的函数编写的。Catalyst Optimizer支持基于规则和基于成本优化。
基于规则的优化中,基于规则的优化器使用规则集来确定如何执行查询。
而基于成本的优化旨在找到执行SQL语句的最合适方法。基于成本的优化中,使用规则生成多个计划,然后计算其成本。
Unresolved logical Plan:与编译器非常类似,spark的优化是多阶段的,在执行任何优化之前,需要解析表达式的引用和类型。
Logical Plan:在Unresolved logical Plan基础上加上Schema信息,spark直接对Logical Plan进行简化和优化,生成一个优化后的logical Plan。这些简化可以用匹配模式等规则来编写。优化器不仅限于模式匹配,而且规则还可以包含任意的Scala代码。
一旦logical plan得到优化,spark将生成一个物理计划。物理计划阶段既有基于规则的优化,也有基于成本的优化,以生成最佳的物理计划。
Spark SQL执行计划
Spark SQL优化可提高开发人员的生产力以及他们编写的查询的性能。一个好的查询优化器会自动重写关系查询,以使用诸如早期过滤数据,利用可用索引,甚至确保以最有效的顺序连接不同的数据源之类的技术来更有效地执行。
通过执行这些转换,优化器改善了关系查询的执行时间,并使开发人员从专注于其应用程序的语义而非性能上解放出来。
Catalyst利用Scala的强大功能(例如模式匹配和运行时元编程),使开发人员可以简明地指定复杂的关系优化。
SparkSession
在spark2.0版本之前
sparkContext:在2.0之前,SparkContext作为spark应用程序的入口,spark驱动程序利用spark context连接集群。
sparkConf :用来指定配置参数,例如APPName、或指定spark驱动,应用,core数量等等。
为了使用SQL、HIVE和Streaming的api,需要创建单独的Context
val conf=newSparkConf()
val sc = new SparkContext(conf)
val hc = new hiveContext(sc)
val ssc = new streamingContext(sc).
在spark2.0及其后续版本
SparkSession提供了与底层Spark功能交互的单一入口点,并允许使用Dataframe和DataSet API编写Spark程序。sparkContext提供的所有功能也都可以在sparkSession中使用。
为了使用SQL、HIVE和Streaming的api,不需要创建单独的Context,因为sparkSession包含所有api。
一旦SparkSession被实例化,我们就可以配置Spark的运行时配置属性
val session = SparkSession.builder()
.enableHiveSupport() //提供了与hive metastore的连接。
.getOrCreate()
// Import the implicits, unlike in core Spark the implicits are defined
// on the context.
import session.implicits._
Schemas
和RDD一样,Spark SQL下的DataFrame和Dataset也是一个分布式的集合。但是相对于RDD,DataFrame和Dataset多了一个额外的Schema信息。如上面所述,Schemas可在Catalyst优化器中使用。
通过case class指定schema
case class RawPanda(id: Long, zip: String, pt: String, happy: Boolean, attributes: Array[Double])
case class PandaPlace(name: String, pandas: Array[RawPanda])
def createAndPrintSchema() = {
val damao = RawPanda(1, "M1B 5K7", "giant", true, Array(0.1, 0.1))
val pandaPlace = PandaPlace("toronto", Array(damao))
val df = session.createDataFrame(Seq(pandaPlace))
df.printSchema()
}
通过StructType直接指定Schema
import org.apache.spark.{SparkContext, SparkConf}
val personRDD = sc.textFile(args(0)).map(_.split(" "))
//通过StructType直接指定每个字段的schema
val schema = StructType(
List(
StructField("id", IntegerType, true),
StructField("name", StringType, true),
StructField("age", IntegerType, true)
)
)
//将RDD映射到rowRDD
val rowRDD = personRDD.map(p => Row(p(0).toInt, p(1).trim, p(2).toInt))
//将schema信息应用到rowRDD上
val personDataFrame = sqlContext.createDataFrame(rowRDD, schema)
DataFrame
DataFrame与RDD类似,同样拥有 不变性,弹性,分布式计算的特性,也有惰性设计,有transform(转换)与action(执行)操作之分。相对于RDD,它能处理大量结构化数据,DataFrame包含带有Schema的行,类似于pandas的DataFrame的 header行。
注意:相对于RDD的lazy设计,DataFrame只是部分的lazy,例如schema是立即执行的。
为什么使用DataFrame
相对于RDD,DataFrame提供了内存管理和优化的执行计划。
自定义内存管理:这也被称为Tungsten,Spark是由scala开发的,JVM的实现带来了一些性能上的限制和弊端(例如GC上的overhead,java序列化耗时),使得Spark在性能上无法和一些更加底层的语言(例如c,可以对memory进行高效管理,从而利用hardware的特性)相媲美,Tungsten设计了一套内存管理机制,而不再是交给JVM托管,Spark的operation直接使用分配的binary data而不是JVM objects。
优化执行计划:这也被称为query optimizer(例如Catalyst Optimizer)。使用此选项,将为查询的执行创建一个优化的执行计划。一旦优化的计划被创建,最终将在Spark的rdd上执行。
关于jvm内存,可查看 JVM中的堆外内存(off-heap memory)与堆内内存(on-heap memory)[1]更多关于 Tungsten,可查看 Tungsten-github[2]
DataFrame 经验 tips:
$ 可以用来隐式的指定DataFrame中的列。
Spark DataFrame 中 === 用 =!= 来过滤特定列的行数据。
相对filter操作,distinct和dropDuplicates可能会引起shuffle过程,因此可能会比较慢。
与RDD中groupby效率低下不同,DataFrame中Groupby已经经过本地聚合再全局聚合(DataFrame / Dataset groupBy behaviour/optimization[3])
如果你需要计算各种复杂的统计运算,建议在GroupData(groupby后)执行:
def minMeanSizePerZip(pandas: DataFrame): DataFrame = {
// Compute the min and mean
pandas.groupBy(pandas("zip")).agg(
min(pandas("pandaSize")), mean(pandas("pandaSize")))
}
在hive Data上,有时使用sql表达式操作比直接在DataFrame上操作,效率更高
def registerTable(df: DataFrame): Unit = {
df.registerTempTable("pandas") //将pandas注册为一个临时table
df.write.saveAsTable("perm_pandas")
}
def querySQL(): DataFrame = {
sqlContext.sql("SELECT * FROM pandas WHERE size > 0") //即可利用sql表达式对临时表进行查询操作,返回的也是一个DataFrame
}
Dataset
Dataset是SparkSQL中的一种数据结构,它是强类型的,包含指定的schema(指定了变量的类型)。Dataset是对DataFrame API的扩展。Spark Dataset 提供了类型安全和面向对象的编程接口。关于强类型和类型安全的定义可参考 Magic lies here - Statically vs Dynamically Typed Languages[4]
Dataset有以下特点:
在编写代码时,有如RDD般的方便和灵活。
有如DataFrame优化表现(同样使用Tungsten 和 Catalyst Query Optimizer)
具有scala语言的静态、类型安全的特点
使用spark Dataset,可以在编译时检查语法和分析,而Dataframe、rdd或常规SQL查询不能做到。
RDD,DataFrame,Dataset区别
数据格式上差别
RDD:它可以方便有效地处理结构化和非结构化数据。但和DataFrame和DataSets不一样,RDD并不能推断schema信息,而是要求用户指定它。
DataFrame:它只适用于结构化和半结构化数据,可以推断schema信息。DataFrames允许Spark管理schema。
Dataset:它还可以有效地处理结构化和非结构化数据,它以行的JVM对象或行对象的集合的形式表示数据,一行就是一个通用的无类型的 JVM 对象。
三者互转

RDD转DataFrame(行动操作,立即执行)时,需要指定schema信息,有如下三种方法:
def createFromCaseClassRDD(input: RDD[PandaPlace]) = { // Create DataFrame explicitly using session and schema inference val df1 = session.createDataFrame(input) // Create DataFrame using session implicits and schema inference val df2 = input.toDF() // Create a Row RDD from our RDD of case classes val rowRDD = input.map(pm => Row(pm.name, pm.pandas.map(pi => Row(pi.id, pi.zip, pi.happy, pi.attributes)))) val pandasType = ArrayType(StructType(List( StructField("id", LongType, true), StructField("zip", StringType, true), StructField("happy", BooleanType, true), StructField("attributes", ArrayType(FloatType), true)))) // Create DataFrame explicitly with specified schema val schema = StructType(List(StructField("name", StringType, true), StructField("pandas", pandasType))) val df3 = session.createDataFrame(rowRDD, schema) }
DataFrame转RDD(转换操作,行动操作再执行),简单的df.rdd得到的是个Row Object,因为每行可以包含任意内容,你需要指定特别的类型,这样你才能获取每列的内容:
def toRDD(input: DataFrame): RDD[RawPanda] = { val rdd: RDD[Row] = input.rdd rdd.map(row => RawPanda(row.getAs[Long](0 "Long"), row.getAs[String](1 "String"), row.getAs[String](2 "String"), row.getAs[Boolean](3 "Boolean"), row.getAs[Array[Double]](4 "Array[Double]"))) }
转Dataset
def fromDF(df: DataFrame): Dataset[RawPanda] = { df.as[RawPanda]//RawPanda为一个case class } // rdd转 Dataset,可以先转 DataFrame再转Dataset /** * Illustrate converting a Dataset to an RDD */ def toRDD(ds: Dataset[RawPanda]): RDD[RawPanda] = { ds.rdd } /** * Illustrate converting a Dataset to a DataFrame */ def toDF(ds: Dataset[RawPanda]): DataFrame = { ds.toDF() }
静态类型与运行时类型安全
如果你用的是 Spark SQL 的查询语句,要直到运行时你才会发现有语法错误(这样做代价很大),而如果你用的是 DataFrame 和 Dataset,你在编译时就可以捕获syntax errors(这样就节省了开发者的时间和整体代价)。也就是说,当你在 DataFrame 中调用了 API 之外的函数时,编译器就可以发现这个错。不过,如果你使用了一个不存在的字段名字,那就要到运行时才能发现错误了。
Dataset API 都是用 lambda 函数和 JVM 类型对象表示的,所有不匹配的类型参数都可以在编译时发现。而且在使用 Dataset 时,你的Analysis errors 也会在编译时被发现,这样就节省了开发者的时间和代价。例如DataFrame编译时不检查列信息(例如无论你写df.select("name") 还是 df.select("naame") 编译时均不会报错,而实际运行时才会报错),而Dataset在编译时就会检查到该类错误。
分区方式
如何针对数据分布自定义分区方式,这对于避免令人头痛的数据倾斜非常重要。
分阶段分区聚合
使用map-side预聚合的shuffle操作。所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。与RDD中groupby效率低下不同,DataFrame中Groupby已经经过本地聚合再全局聚合
加随机数分区分阶段聚合。这个方案的核心实现思路就是进行两阶段聚合。第一次是局部聚合,先给每个key都打上一个随机数,比如10以内的随机数,此时原先一样的key就变成不一样的了,比如(hello, 1) (hello, 1) (hello, 1) (hello, 1),就会变成(1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)。接着对打上随机数后的数据,执行reduceByKey等聚合操作,进行局部聚合,那么局部聚合结果,就会变成了(1_hello, 2) (2_hello, 2)。然后将各个key的前缀给去掉,就会变成(hello,2)(hello,2),再次进行全局聚合操作,就可以得到最终结果了,比如(hello, 4)。--- 将原本相同的key通过附加随机前缀的方式,变成多个不同的key,就可以让原本被一个task处理的数据分散到多个task上去做局部聚合,进而解决单个task处理数据量过多的问题。接着去除掉随机前缀,再次进行全局聚合,就可以得到最终的结果。
加随机数参考代码如下:
val repartRdd = originRdd
// 切割
.flatMap(_.split(" "))
// 映射为元组
.map((_, 1))
// 给key加上随机数,聚合时候具有随机性
.map(t => {
val rnum = Random.nextInt(partitionNum)
(t._1 + "_" + rnum, 1)
})
// 初次聚合
.reduceByKey(_ + _)
// 去除key随机后缀
.map(e => {
val word = e._1.toString().substring(0, e._1.toString().indexOf("_"))
val count = e._2
(word, count)
})
// 再次聚合
.reduceByKey(_ + _)
// 排序(false->降序, true->升序)
.map(e => (e._2, e._1)).sortByKey(false).map(e => (e._2, e._1))
Hash Partitioning in Spark 与 Range Partitioning in Spark
可参考 Spark中的分区方法详解(https://www.cnblogs.com/tongxupeng/p/10435976.html),个人感觉这篇博客已经写得非常详细完整。
DataFrame.repartition(col):可由 指定的列 表达式来进行分区,默认hash分区 (随机key) 方式
DataFrame.repartitionByRange(col): 可由 指定的列 表达式来进行分区,默认Range分区 (随机key) 方式
Spark.DataFrame 与 DataSet 无自定义分区方式,可先将rdd自定分区完成,再转成DataFrame。
sqlContext.createDataFrame(
df.rdd.map(r => (r.getInt(1), r)).partitionBy(partitioner).values,
df.schema
)
UDFs & UDAFs 使用
User-defined functions(udfs) 和 user-defined aggregate functions(udafs) 提供了使用自己的自定义代码扩展DataFrame和SQL API的方法,同时保留了Catalyst优化器。这对性能的提高非常有用,否则您需要将数据转换为RDD(并可能再次转换)来执行任意函数,这非常昂贵。udf和udaf也可以使用SQL查询表达式进行 内部访问。
注:使用python编写udf和udaf函数,会丢失性能优势。
UDFs
spark 2.x:
def get_max(x: Double, y: Double): Double={
if ( x > y )
x
else
y
}
val udf_get_max = udf(get_max _)
df = df.withColumn("max_fea", udf_get_max(df("fea1"), df("fea2")))
UDAFs
相对于udfs,udafs编写较为复杂,需要继承 UserDefinedAggregateFunction 并实现里面的函数,但UDAFs的性能相当好。可以直接在列上使用UDAF,也可以像对非聚合UDF那样将其添加到函数注册表中。
计算平均值的UDAF例子代码:
import org.apache.spark.sql.Row
import org.apache.spark.sql.expressions.{MutableAggregationBuffer, UserDefinedAggregateFunction}
import org.apache.spark.sql.types._
object AverageUserDefinedAggregateFunction extends UserDefinedAggregateFunction {
// 聚合函数的输入数据结构
override def inputSchema: StructType = StructType(StructField("input", LongType) :: Nil)
// 缓存区数据结构
override def bufferSchema: StructType = StructType(StructField("sum", LongType) :: StructField("count", LongType) :: Nil)
// 聚合函数返回值数据结构
override def dataType: DataType = DoubleType
// 聚合函数是否是幂等的,即相同输入是否总是能得到相同输出
override def deterministic: Boolean = true
// 初始化缓冲区
override def initialize(buffer: MutableAggregationBuffer): Unit = {
buffer(0) = 0L
buffer(1) = 0L
}
// 给聚合函数传入一条新数据进行处理
override def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
if (input.isNullAt(0)) return
buffer(0) = buffer.getLong(0) + input.getLong(0)
buffer(1) = buffer.getLong(1) + 1
}
// 合并聚合函数缓冲区
override def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
buffer1(0) = buffer1.getLong(0) + buffer2.getLong(0)
buffer1(1) = buffer1.getLong(1) + buffer2.getLong(1)
}
// 计算最终结果
override def evaluate(buffer: Row): Any = buffer.getLong(0).toDouble / buffer.getLong(1)
}
然后在主函数里注册并使用该函数:
spark.read.json("data/user").createOrReplaceTempView("v_user")
spark.udf.register("u_avg", AverageUserDefinedAggregateFunction)
// 将整张表看做是一个分组对求所有人的平均年龄
spark.sql("select count(1) as count, u_avg(age) as avg_age from v_user").show()
// 按照性别分组求平均年龄
spark.sql("select sex, count(1) as count, u_avg(age) as avg_age from v_user group by sex").show()
参考
High Performance Spark
https://data-flair.training/blogs/spark-sql-optimization/
https://www.quora.com/What-is-the-difference-between-spark-context-and-spark-session
https://www.cnblogs.com/netoxi/p/7223413.html
https://www.infoq.cn/article/three-apache-spark-apis-rdds-dataframes-and-datasets/
https://tech.meituan.com/2016/05/12/spark-tuning-pro.html
https://www.cnblogs.com/tongxupeng/p/10435976.html
参考资料
[1]
JVM中的堆外内存(off-heap memory)与堆内内存(on-heap memory): https://blog.csdn.net/khxu666/article/details/80775635
[2]Tungsten-github: https://github.com/hustnn/TungstenSecret
[3]DataFrame / Dataset groupBy behaviour/optimization: https://stackoverflow.com/questions/32902982/dataframe-dataset-groupby-behaviour-optimization
[4]Magic lies here - Statically vs Dynamically Typed Languages: https://android.jlelse.eu/magic-lies-here-statically-typed-vs-dynamically-typed-languages-d151c7f95e2b
往期精彩回顾
适合初学者入门人工智能的路线及资料下载机器学习在线手册深度学习在线手册AI基础下载(pdf更新到25集)备注:加入本站微信群或者qq群,请回复“加群”获取一折本站知识星球优惠券,请回复“知识星球”
喜欢文章,点个在看