本文参考了《Spark SQL内核剖析》(朱峰、张韶全、黄明等著)的目录结构和内容,这本书主要集中在对SQL内核实现的剖析上,从源码实现上学习分布式计算和数据库领域的相关技术,非常值得有相关需求的专业人士学习和购买。我写这篇文章的目的也是基于此做一个关于Spark SQL的学习以及分享了一些自己的理解。
什么是Spark SQL?
Spark SQL是近年来SQL-on-Hadoop解决方案(包括Hive、Presto和Impala等)中的佼佼者,结合了数据库SQL处理和Spark分布式计算模型两个方面的技术,目标是取代传统的数据仓库。
1. Spark 基础知识
在这一节简单介绍了Spark涉及到的几个简单技术,包括RDD编程模型、DataFrame和DataSet用户接口。
1.1. RDD 编程模型
RDD是Spark的核心数据结构,全称是弹性分布式数据集(Resilient Distributed Dataset),其本质是一种分布式的内存抽象,表示一个只读的数据分区(Partition)集合。
至于RDD的创建、计算和转换等操作的原理和技术不在本文的介绍范围内,有兴趣的读者可以自行了解,我们只需要知道RDD作为弹性数据集可以很方便地支持MapReduce应用、关系型数据处理、流式数据处理和迭代性应用(图计算、机器学习等)。
1.2. DataFrame 与 DataSet
Spark在RDD的基础上,提供了DataFrame和DataSet用户编程接口,其实这两个就是数据集的封装,以方便用户通过对象方法和转换操作数据集。虽然DataFrame和DataSet也不是我们关注的重点,但我对于这两者的不同与统一比较感兴趣,所以搜集了一些资料。
Dataset可以认为是DataFrame的一个特例(有相反的说法),主要区别是Dataset每一个record存储的是一个强类型值而不是一个Row。因此具有如下三个特点:
- DataSet可以在编译时检查类型
- 并且是面向对象的编程接口。
- 后面版本DataFrame会继承DataSet,DataFrame是面向Spark SQL的接口。
关于究竟是DataFrame=DataSet[Row]还是Dataset to represent a DataFrame并不重要。我们只需要知道DataSet相比较于DataFrame提供了强类型接口,更方便我们使用类对外部数据进行类型检查和结构化。
2. Spark SQL 执行过程
简单介绍了Spark SQL内部机制中设计的基本概念。
2.1. 一个简单案例
下面这段代码是摘抄自《Spark SQL内核剖析》中的一段示例代码,在我们的日常Spark SQL的应用中,其实大部分需求都可以这样满足。
val spark = SparkSession.builder().appName("example").master("local").getOrCreate()
spark.read.json("student.json").createOrReplaceTempView("student")
spark.sql("select name from student where age > 18").show()
比较令我感兴趣的是,我们的项目中使用了一个弃用的接口registerTempTable(部分生产系统部署了较低版本的spark),而上述代码使用了当前版本的接口createOrReplaceTempView,通过阅读2.10的源码发现,这个弃用接口已经被createOrReplaceTempView接管了。
/** @deprecated */
public void registerTempTable(String tableName) {
this.createOrReplaceTempView(tableName);
}
虽然我们调用代码的过程十分简单方便,但是对于Spark SQL系统,从SQL到RDD的执行需要经过复杂的流程,一般分为两大阶段,分别是逻辑计划和物理计划。
逻辑计划阶段会将用户所提交的SQL语句转换成树形数据结构(逻辑算子树),SQL语句中蕴含的逻辑映射到逻辑算子树的不同节点。逻辑算子树并不会被提交执行而只是作为一个中间阶段,需经过生成未解析的逻辑算子树、解析后的逻辑算子树和优化(应用各种优化规则对低效的逻辑计划进行转换)后的逻辑算子树三个子阶段。
物理计划阶段将上一步生产的逻辑算子树进行进一步转换,生成物理算子树。物理算子树会直接生成RDD或对RDD进行transformation转换操作。同样地,物理计划阶段也可分为三个子阶段,首先生成物理算子树列表(同样的逻辑算子树可能对应多个物理算子树);然后从列表中按照策略选择最优的物理算子树;最后,对选取的物理算子树进行提交前的准备工作,例如确保分区操作正确、物理算子树节点重用、执行代码生成等。经过这些步骤之后,物理算子树生成的RDD执行action操作(如show、collect等),即可提交执行。
值得注意的是,从SQL解析到提交之前,所有的上述转换过程都是在Driver端(可以理解为客户端代码)进行,不涉及分布式环境。对于我们这种可能有特定优化需求的开发者是一件好事,这意味着我们可以通过修改客户端代码来优化执行逻辑。
回归到示例代码,该SQL语句select name from student where age > 18
生成的逻辑算子树中有Relation、Filter和Project节点,分别对应数据表、过滤逻辑(age>18)和列剪裁逻辑(只涉及三列中的两列。这里原文的意思是数据源共有三列,但是sql语句中用到了name和age两列,但我认为在这一步应该只剩下name是被需要的列)。下一步的物理算子树从逻辑算子树一一对应,Relation逻辑节点转换为FileSourceScanExec执行节点,Filter逻辑节点转换为FilterExec执行节点,Project转换为ProjectExec执行节点。
生成的物理算子树根节点是ProjectExec,每个物理节点中的execute函数都是执行调用接口,由根节点开始递归调用,从叶子节点开始执行。需要注意的是,FileSourceScanExec叶子节点执行的是构造数据源所需的RDD,其他两个节点执行的是transformation操作。
总结一下,虽然生产环境中执行的sql语句非常复杂,涉及的映射也比较繁琐,但总体上仍遵循上述步骤。
2.2. 几个重要概念
Spark SQL 内部实现上述流程中平台无关部分的基础框架叫做Catalyst,其中涉及的几种数据结构和概念如下。
2.2.1. InternalRow
在Spark SQL 内部实现中,InternalRow就是用来表示一行行数据的类,物理算子树所产生的RDD即为RDD[InternalRow],包含numFields和update方法,以及各列数据对应的get和set方法。在具体实现中通过下标来访问和操作列元素的。
其作为一个抽象类的具体实现不多,主要包括BaseGeneEricInternalRow(抽象类,实现所有get类型方法)、UnsafeRow(不采用Java对象存储,避免GC)和JoinedRow(主要用于Join操作,将两个InterRow放在一起形成新的InternalRow)三个直接子类。从直接子类往下又衍生出多个子类,就不一一介绍了。
2.2.2. TreeNode
TreeNode是Spark SQL中所有树结构的基类,定义了一系列通用的集合操作和树遍历操作的接口。它内部定义了一个children的变量来保存子节点的,还定义了foreach、map、collect等针对节点操作的方法,以及transformUp和transformDown等遍历节点并对匹配节点进行转换的方法。
作为基础类 ,TreeNode提供了一些如不同遍历方式的transform系列方法、用于替换新的子节点的withNewChildren方法等。
TreeNode一个非常重要的继承体系就是QueryPlan,它下面包括逻辑算子树(LogicPlan)和物理执行算子树(SparkPlan)两个非常重要的子类。
2.2.3. Expression
Catalyst实现了完善的表达式体系,与各种算子(QueryPlan)占同等地位。算子执行前通常会进行“绑定”操作,将表达式与输入属性对应起来,同时算子也可以调用各种表达式处理相应的逻辑。在Expression类中,主要定义了五个方面的操作,包括基本属性、核心操作、输入输出、字符串表示和等价判断。
核心操作中eval函数实现了表达式对应的处理逻辑,也是其他模块调用该表达式的主要接口,而genCode和doGenCode用于生成表达式对应的Java代码。字符串表示用于查看该Expression的具体内容,如表达式名和输入参数等。至于Expression包含的基本属性和操作就不一一列举,请自行查阅。
在Spark SQL中Expression本身也是TreeNode的子类,其相关的子类或接口将近300个,请自行查阅。
2.3. 内部数据类型
Spark SQL中,Catalyst实现了完善的数据类型系统。
所有的数据类型都继承自Abstract-DataType抽象类。比较常用的有各种NumericType,包括ByteType、ShortType、DecimalType等等。常用的各种复合数据类型有数组类型(ArrayType)、字典类型(MapType)和结构体类型(StructType)三种。
完整的数据类型继承关系请自行查阅。