Spark SQL 工作流程源码解析(一)总览(基于 Spark 3.3.0)

前言

本文隶属于专栏《1000个问题搞定大数据技术体系》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和参考文献请见1000个问题搞定大数据技术体系


目录

Spark SQL 工作流程源码解析(一)总览(基于 Spark 3.3.0)

Spark SQL 工作流程源码解析(二)parsing 阶段(基于 Spark 3.3.0)

Spark SQL 工作流程源码解析(三)analysis 阶段(基于 Spark 3.3.0)

Spark SQL 工作流程源码解析(四)optimization 阶段(基于 Spark 3.3.0)

Spark SQL 工作流程源码解析(五)planning 阶段(基于 Spark 3.3.0)


思维导图

在这里插入图片描述


正文

一个简单的示例

数据

{"name": "Alice","age": 18,"sex": "Female","addr": ["address_1","address_2", " address_3"]}
{"name": "Thomas","age": 20, "sex": "Male","addr": ["address_1"]}
{"name": "Tom","age": 50, "sex": "Male","addr": ["address_1","address_2","address_3"]}
{"name": "Catalina","age": 30, "sex": "Female","addr": ["address_1","address_2"]}

代码

package com.shockang.study.spark.sql.demo

import org.apache.log4j.{Level, Logger}
import org.apache.spark.sql.SparkSession

/**
 * @author Shockang
 */
object SparkSQLExample {

  val DATA_PATH = "/Users/shockang/code/spark-examples/data/simple/sql/user.json"

  def main(args: Array[String]): Unit = {
    Logger.getLogger("org").setLevel(Level.OFF)
    val spark = SparkSession.builder.master("local[*]").appName("SparkSQLExample").getOrCreate()

    val df = spark.read.json(DATA_PATH)
    df.createTempView("t_user")

    spark.sql("SELECT * FROM t_user").show

    spark.stop()
  }
}

先来看看控制台输出:

+--------------------+---+--------+------+
|                addr|age|    name|   sex|
+--------------------+---+--------+------+
|[address_1, addre...| 18|   Alice|Female|
|         [address_1]| 20|  Thomas|  Male|
|[address_1, addre...| 50|     Tom|  Male|
|[address_1, addre...| 30|Catalina|Female|
+--------------------+---+--------+------+

接下来一行行源码来分析,上面的结果是如何得到的

建议按照我的这篇博客下载编译 Apache Spark 源码,边看源码边阅读这篇博客——编译 Apache Spark 源码报错?那是因为你漏掉了关键操作


从 spark.sql 开始

上面创建数据表时虽然没有显示调用 SQL 语句(类似 CREATE TABLE 这样的),但是本质上也是 SQL 的一种(DDL),内部的执行过程涉及到的流程和 spark.sql 查询的流程是很类似的。所以,只对 spark.sql 的实现来具体分析。

spark.sql 来自 SparkSession.scala 文件,我们来看看具体的源码:

  /**
   * 使用 Spark 执行 SQL 查询,并将结果作为 DataFrame 返回。
   * 此 API 会“急切地”运行 DDL/DML 命令,但遇到 SELECT 查询则不会。
   *
   * @since 2.0.0
   */
  def sql(sqlText: String): DataFrame = withActive {
  	// 定义一个查询计划追踪器
    val tracker = new QueryPlanningTracker
    // 统计 parsing 阶段的开始和结束时间
    val plan = tracker.measurePhase(QueryPlanningTracker.PARSING) {
      // parsing 阶段
      sessionState.sqlParser.parsePlan(sqlText)
    }
    // 将 parsing 阶段生成的逻辑计划经过处理生成 DataFrame 返回
    Dataset.ofRows(self, plan, tracker)
  }
  /**
   * 执行一段代码块,将当前会话设置为活跃会话,并且在完成的时候恢复之前的会话。
   */
  private[sql] def withActive[T](block: => T): T = {
    // 直接使用线程本地的活跃会话,以确保我们得到的会话实际上是事实上设置的而不是默认的会话。
    // 这是为了防止一旦我们都搞完之后把默认会话升级到了活跃会话。
    val old = SparkSession.activeThreadSession.get()
    SparkSession.setActiveSession(this)
    try block finally {
      SparkSession.setActiveSession(old)
    }
  }

QueryPlanningTracker

这里会涉及一个类:QueryPlanningTracker,它是用于追踪查询计划中的运行时和相关统计信息的简单实用程序

我们会追踪两个不同的概念:

  1. Phases:这些是查询计划中的大范围阶段,即 analysis、optimization 和 physical planning(这里仅仅只是计划不会触发实际的物理执行)。
  2. Rules:这些是我们追踪的单个Catalyst规则。除了时间,我们还跟踪调用的数量和有效调用。

值得注意的是在 object QueryPlanningTracker 里面定义了 4 大阶段

  // 此处定义了通用阶段的列表。
  val PARSING = "parsing"
  val ANALYSIS = "analysis"
  val OPTIMIZATION = "optimization"
  val PLANNING = "planning"

所以,很明显,Apache Spark 的官方已经通过源码告诉了我们,Spark SQL 的工作流程就这 4 个阶段,这也是最标准的划分!

tracker.measurePhase(QueryPlanningTracker.PARSING)

这行源码的作用就是测量 parsing 阶段的开始和结束时间,后面在每一个阶段都会同样处理。

如果在同一阶段多次调用 tracker.measurePhase 函数,则记录的开始时间将是第一次调用的开始时间,记录的结束时间将是最后一次调用的结束时间。


SessionState

这是在给定 SparkSession 实例中保存所有会话特定状态的类。

Spark 最初添加这个类的原因是方便将一个 SparkSession 的状态 copy 到另一个 SparkSession 中,所以,很明显,SparkSession 的所有特有状态都会交给 SessionState 来保存。

那么,具体保存了哪些状态呢?

我们来看看这个类的类定义:

private[sql] class SessionState(
    sharedState: SharedState,
    val conf: SQLConf,
    val experimentalMethods: ExperimentalMethods,
    val functionRegistry: FunctionRegistry,
    val tableFunctionRegistry: TableFunctionRegistry,
    val udfRegistration: UDFRegistration,
    catalogBuilder: () => SessionCatalog,
    val sqlParser: ParserInterface,
    analyzerBuilder: () => Analyzer,
    optimizerBuilder: () => Optimizer,
    val planner: SparkPlanner,
    val streamingQueryManagerBuilder: () => StreamingQueryManager,
    val listenerManager: ExecutionListenerManager,
    resourceLoaderBuilder: () => SessionResourceLoader,
    createQueryExecution: (LogicalPlan, CommandExecutionMode.Value) => QueryExecution,
    createClone: (SparkSession, SessionState) => SessionState,
    val columnarRules: Seq[ColumnarRule],
    val queryStagePrepRules: Seq[Rule[SparkPlan]]) 

每个构造参数对应的含义如下:

  1. sharedState: SharedState
    跨会话共享的状态,例如全局视图管理器、外部目录。
  2. conf: SQLConf
    特定于SQL的键值配置。
  3. experimentalMethods: ExperimentalMethods
    添加自定义计划策略和优化器的界面。
  4. functionRegistry: FunctionRegistry
    用于管理用户注册的函数的内部目录。
  5. tableFunctionRegistry: TableFunctionRegistry
    用于查找表函数的目录。
  6. udfRegistration: UDFRegistration
    向用户公开的用于注册用户定义函数的界面。
  7. catalogBuilder: () => SessionCatalog
    用于创建内部目录以管理表和数据库状态的函数。
  8. sqlParser: ParserInterface
    从SQL文本中提取表达式、计划、表标识符等的编译器。
  9. analyzerBuilder: () => Analyzer
    用于创建逻辑查询计划分析器的函数,用于解析未解析的属性和关系。
  10. optimizerBuilder: () => Optimizer
    用于创建逻辑查询计划优化器的函数。
  11. planner: SparkPlanner
    将优化的逻辑计划转换为物理计划的计划器。
  12. streamingQueryManagerBuilder: () => StreamingQueryManager
    用于创建流式查询管理器以启动和停止流式查询的函数。
  13. listenerManager: ExecutionListenerManager
    用来注册custominternal/SessionState.scala org.apache.spark.sql.util.QueryExecutionListeners的接口。
  14. resourceLoaderBuilder: () => SessionResourceLoader
    用于创建会话共享资源加载程序以加载JAR、文件等的函数。
  15. createQueryExecution: (LogicalPlan, CommandExecutionMode.Value) => QueryExecution
    用于创建QueryExecution对象的函数。
  16. createClone: (SparkSession, SessionState) => SessionState
    用于创建会话状态克隆的函数。
  17. columnarRules: Seq[ColumnarRule]
    ColumnarRule 用于保存用户定义的规则,这些规则可用于在计划中注入各种运算符的列式实现。
  18. queryStagePrepRules: Seq[Rule[SparkPlan]])
    这个参数是为了确保物理计划不会优化掉用户的重分区策略。

其中,比较核心的参数有:


SessionCatalog

按照 SQL 标准,Catalog 是一个宽泛概念,通常可以理解为一个容器或数据库对象命名空间中的一个层次,主要用来解决命名冲突等问题。

在 Spark SQL 系统中,Catalog 主要于各种函资源信息信息(数据库、数据表、数据视图、数据分区与函数等)的统一管理。

具体来讲,Spark SQL 中Catalog 体系就是以 SessionCatalog 为主,通过 SparkSession 来提供给外界调用。

一般,一个 SparkSession 对应一个 SessionCatalog

本质上, SessionCatalog 起到了一个代理的作用,对底层的元数据信息(例如,Hive Metastore)、临时表信息、视图信息和函数做了封装。


ParserInterface

编译器通用接口,用来将 SQL 语句转换成 AST(抽象语法树),也就是 Unresolved Logical Plan,这个接口中包含对 SQL 语句、Expression 表达式和 TableIdentifier 数据表标识符的解析方法。

ParserInterface 有两个主要实现类:

  1. SparkSqlParser 用于外部调用,我们平常写的 SQL 都是它在解析。
  2. CatalystSqlParser 用于内部 Catalyst 引擎使用的解析器。
== Parsed Logical Plan ==
'Project [*]
+- 'UnresolvedRelation [t_user], [], false

此处可能部分同学会对Parsed Logical Plan 感到奇怪,实际上Unresolved Logical Plan这个概念来自 Spark SQL 的原始论文Spark SQL: Relational Data Processing in Spark 。大家只需要知道这两个概念是一回事情,只不过Unresolved Logical Plan是约定俗成的叫法而已。


Analyzer

提供逻辑查询计划的分析器,这个分析器会使用 SessionCatalog 中的信息将 UnsolvedAttributesUnsolvedRelationships 转换为有类型的对象。

其中,UnsolvedAttributes 保存尚未解析的属性的名称,UnsolvedRelationships 保存尚未在SessionCatalog中查找的关系的名称。

简单来讲,就是将 Unresolved Logical Plan 转化成 Analyzed Logical Plan

这个过程主要会结合 DataFrame 的 Schema 信息(来自 SessionCatalog),检查下面 3 点:

  1. 表名称
  2. 字段名称
  3. 字段类型
== Analyzed Logical Plan ==
addr: array<string>, age: bigint, name: string, sex: string
Project [addr#7, age#8L, name#9, sex#10]
+- SubqueryAlias t_user
   +- View (`t_user`, [addr#7,age#8L,name#9,sex#10])
      +- Relation [addr#7,age#8L,name#9,sex#10] json

Optimizer

所有优化器都应该继承的抽象类,包含标准规则批次(扩展优化器可以覆盖它)。

其实例类是 SparkOptimizer

Optimizer 会基于启发式的规则,将 Analyzed Logical Plan 转化成 Optimized Logical Plan

其中,启发式的规则主要涉及以下 3 个方面的优化:

  1. 谓词下推,简单来讲就是把过滤操作尽可能往数据源方向移动,这样可以减少计算存储的负载。
  2. 列剪裁,简单来讲就是根据列存储格式 footer 中的元数据信息来细粒度提取想要的数据,从而减少 IO 消耗。这里只有列存储格式比如 ORC 或者 Parquet 才能享受这个优化。
  3. 常量折叠,简单来讲就是把一些可以直接计算得到的结果替换掉本来的表达式,比如 1+1=2 这样的。
== Optimized Logical Plan ==
InMemoryRelation [addr#7, age#8L, name#9, sex#10], StorageLevel(disk, memory, deserialized, 1 replicas)
   +- FileScan json [addr#7,age#8L,name#9,sex#10] Batched: false, DataFilters: [], Format: JSON, Location: InMemoryFileIndex(1 paths)[file:/Users/shockang/code/spark-examples/data/simple/sql/user.json], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<addr:array<string>,age:bigint,name:string,sex:string>

SparkPlanner

基于既定的规则将逻辑计划转换成具体的物理计划(不涉及执行,只是提前规划),即将Optimized Logical Plan 转化成 Physical Plan

简单来说,逻辑计划就是“应该做什么”,物理计划就是“具体怎么做”。

物理计划包含 3 个子阶段:

  1. 首先,根据逻辑算子树,生成物理算子树的列表
  2. 其次,从列表中按照一定的策略选取最优的物理算子树
  3. 最后,对选取的物理算子树进行提交前的准备工作,例如,确保分区操作正确、物理算子树节点重用、执行代码生成等,得到 Prepared SparkPlan

最终生成的是一个 RDD 对象,并将其提交给 Spark Core 来执行。

后面涉及 Spark Core 的内容会专门写博客来详情阐述。

== Physical Plan ==
InMemoryTableScan [addr#7, age#8L, name#9, sex#10]
   +- InMemoryRelation [addr#7, age#8L, name#9, sex#10], StorageLevel(disk, memory, deserialized, 1 replicas)
         +- FileScan json [addr#7,age#8L,name#9,sex#10] Batched: false, DataFilters: [], Format: JSON, Location: InMemoryFileIndex(1 paths)[file:/Users/shockang/code/spark-examples/data/simple/sql/user.json], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<addr:array<string>,age:bigint,name:string,sex:string>

Spark SQL 基本工作流程

细心的同学已经发现,上面的 4 个类/接口对应的就是前面提到的 4 大阶段

类/接口阶段执行计划输出
ParserInterfaceparsingUnresolved Logical Plan
AnalyzeranalysisAnalyzed Logical Plan
OptimizeroptimizationOptimized Logical Plan
SparkPlannerplanningPhysical Plan

将这 4 个阶段串联起来,就得到了 Spark SQL 最基本的工作流程:

在这里插入图片描述

注:此处给出的是基本的工作流程,其中不包含 AQE(自适应查询执行), CBO(基于成本的优化),WSCG(全阶段代码生成)等,完整的流程会在最后一讲讲完上述几个概念之后再给出。

  • 12
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值