Spark sql 学习笔记 —— DataFrame、Dataset、sql 解析原理

目录

一、SparkSession 与 DataFrame、Dataset

二、Spark Sql 解析

1. 整体概览

2. sql 语法解析关键对象

三、Spark LogicalPlan(逻辑计划)

1. 整体概述

2. LogicalPlan 类结构体系

3. Analyzed LogicalPlan 生成 


一、SparkSession 与 DataFrame、Dataset

1.  要使用sparksql功能,需要需创建一个SparkSession对象,使用 SparkSession.builder()方法来创建:

 val session = SparkSession
      .builder()
      .appName("Spark Hive Example")
      .master("local[2]")
      .getOrCreate()

2. Spark SQL中的 DataFrame 类似于一张关系型数据表。在关系型数据库中对单表或进行的查询操作,在DataFrame中都可以通过调用其API接口来实现。Dataset 与 DateFrame 的区别为 Dataset 中的泛型类 T 是某个确定类型,需要用户显示指定 schema 来确定,DataFrame 是 Dataset 的特定泛型类:type DataFrame = Dataset[Row]。创建 DataFrame 有3种方式:

  1. 基于已存在内存的数据对象,如List, RDD等:
    //基于List对象创建
    val peopleList = List(People(1, "zhangsan"), People(2, "Lisi"))
    val dfFromList = session.createDataFrame(peopleList)
    //基于Rdd创建
    val textRdd = session.sparkContext.makeRDD(peopleList)
    val dfFromRdd = session.createDataFrame(textRdd)
  2. 使用 DataFrameReader 类的 api,该类支持读取 text、csv、json 等各种文件,也支持通过jdbc 连接读取表数据:
    val dataReader = session.read
    dataReader.csv("/tmp/test.txt")
    dataReader.jdbc("jdbc:mysql://localhost:3306", "user", new Properties())
    dataReader.json("/data1/people.json")
  3. 使用 session.sql() 方法读取 Hive 表数据:
    val session = SparkSession
          .builder()
          .appName("Spark Hive Example")
          .config("spark.sql.warehouse.dir", "/apps/hive/warehouse")
          .config("spark.driver.memory", "1024m")
          .master("local[2]")
          .enableHiveSupport()
          .getOrCreate()
    
    session.sql("select * from people where age > 18")

二、Spark Sql 解析

1. 整体概览

一般来讲,对于Spark SQL 系统,从 SQL 到 Spark 中 RDD 的执行需要经过两个大的阶段,分别是逻辑计划(LogicalPlan)和物理计划(PhysicalPlan),如下图所示:

Spark sql 模块提供 sql 语法分析与编译功能,让我们写 sql 代码查询表数据,而无需调用繁杂的 api。语法分析模块在spark 源码的 sql/catalyst 目录下,使用 antlr4 框架生成语法树。sql 语法分析要经过 3 个阶段:

  1. 词法分析:将 sql 语句按规则切分成一个个 token。
  2. 句法分析:使用自上而下的递归下降分析方法生成语法树,树的各个节点在 antlr4 中使用 ParseTree 接口的子类 ParserRuleContext 来表示,在 spark 中继承了该类来表示各个节点。
  3. 语法树遍历:antlr4 使用 访问者模式 去访问这些树节点生成不同的对象。

实现上述过程需要 3 个关键角色:SqlParser,解析 sql 生成语法树。visitor,拜访者,遍历树节点;ParseDriver,驱动类,驱动代码运行。整体可以用伪代码表示如下:

public class MyVisitor extends SqlBaseBaseVisitor<String> {

    public String visitSingleStatement(SqlBaseParser.SingleStatementContext ctx) {
        System.out.println("visitSingleStatement");
        return visitChildren(ctx);
    }

    public String visitSingleExpression(SqlBaseParser.SingleExpressionContext ctx) {
        System.out.println("visitSingleExpression");
        return visitChildren(ctx);
    }
   
    // ... 其余代码省略
}

public class ParserDriver {
    public static void main(String[] args) {
        String query = "select * from people where age > 18".toUpperCase();
        SqlBaseLexer lexer = new SqlBaseLexer(new ANTLRInputStream(query));
        SqlBaseParser parser = new SqlBaseParser(new CommonTokenStream(lexer));

        MyVisitor visitor = new MyVisitor();
        String res = visitor.visitSingleStatement(parser.singleStatement());
        System.out.println("res="+res);
    }
}

2. sql 语法解析关键对象

2.1 SparkSqlParser

Spark 使用 SparkSqlParser 类来解析 sql 语句,在 SparkSession.sql() 方法中被使用:

def sql(sqlText: String): DataFrame = {
    Dataset.ofRows(self, sessionState.sqlParser.parsePlan(sqlText))
}

SparkSqlParser 类图结构如下所示:

上文提到了在 Catalyst 中,SQL 语法经过解析,生成的抽象语法树节点以 Context 结尾来命名,以 select id, name, age, sex from people where age > 18 order by id desc 这条为例,生成的语法树结构如下(我在图中只画出了关键树节点,实际节点复杂的多):

 SparkSqlParser 将 sql 语句转换成语法树后,会使用自身实例属性 val astBuilder = new SparkSqlAstBuilder(conf) 去遍历这些树节点,从上面类图中我们知道该类是 SqlBaseBaseVistor 的子类,是一个 visitor,大部分逻辑在 AstBuilder 类中实现。

2.2 AstBuilder

从上文中我们知道 AstBuilder 是一个 visitor,用来遍历语法树,先来看一下该类的定义以及一些方法:

class AstBuilder(conf: SQLConf) extends SqlBaseBaseVisitor[AnyRef] with Logging {
  override def visitSingleStatement(ctx: SingleStatementContext): LogicalPlan = withOrigin(ctx) {
    visit(ctx.statement).asInstanceOf[LogicalPlan]
  }

  override def visitSingleExpression(ctx: SingleExpressionContext): Expression = withOrigin(ctx) {
    visitNamedExpression(ctx.namedExpression)
  }

  override def visitSingleTableIdentifier(
      ctx: SingleTableIdentifierContext): TableIdentifier = withOrigin(ctx) {
    visitTableIdentifier(ctx.tableIdentifier)
  }

  override def visitSingleFunctionIdentifier(
      ctx: SingleFunctionIdentifierContext): FunctionIdentifier = withOrigin(ctx) {
    visitFunctionIdentifier(ctx.functionIdentifier)
  }
}

该类复写了父类的一些方法,这些方法在去访问各个树节点(Context对象)时,实际上是去调用了 Context 类的 accept() 方法。各种 Context 类的 accept 方法大致实现逻辑如下:

  • 如果该树节点(Context)不是叶子节点,则让 visitor 去递归访问自己的孩子节点。
  • 如果该树节点(Context)是叶子节点(TerminalNodeImpl),则调用 visitor 的 visitTerminal() 方法,该方法默认实现为调用 visitor 自身的 defaultResult() 方法。

AstBuilder 类的 visitXXX() 方法返回对象类型是 LogicalPlan 或者 Expression。该类是Spark SQL 逻辑计划在代码层面的定义类。

三、Spark LogicalPlan(逻辑计划)

1. 整体概述

经过上面的分析,我们知道了 Spark Sql 通过 visitor 遍历语法树节点(各种Context对象),返回多个 LogicalPlan 对象,而这些对象也是保持着树状关系。

从 sql 语句经过 SparkSqlParser 解析生成 UnresolvedLogicalPlan,到最终生成  Optimized LogicalPlan,这个流程主要经过3个阶段,如下图所示:

其中 OptimizedLogicalPlan 传递到下一个阶段用于物理执行计划的生成。

2. LogicalPlan 类结构体系

在介绍 LogicalPlan 之前,有必要介绍一下其父类 QueryPlan。QueryPlan 的主要操作分为6个模块:

  1. 输入输出:QueryPlan 的输入输出定义了5个方法,用来表示该节点本身的输出以及接收其子节点的输入。
  2. 基本属性:表示 QueryPlan 节点中的一些基本信息,其中 schema 对应 output 输出属性的 schema 信息。
  3. 字符串:这部分方法主要用于输出打印 QueryPlan 树型结构信息,其中 schema 信息也会以树状形式展示。
  4. 规范化
  5. 表达式操作
  6. 约束

LogicalPlan 的类继承关系如下图所示:

LogicalPlan 仍然是抽象类,根据子节点数目,绝大部分的 LogicalPlan 可以分为三类,即叶子节点 LeafNode 类型(不存在子节点)、一元节点 UnaryNode 类型(仅包含一个子节点)和二元节点 BinaryNode 类型(包含两个子节点)。类结构关系如下图所示:

LeafNode 类型的 LogicalPlan 节点对应数据表和命令(Command)相关的逻辑,因此这些 LeafNode 子类中有很大一部分属于 datasources 包和 comand 包。与命令相关的有12种情形,包括 database 相关命令、Table 相关命令、View 相关命令、DDL 相关命令、Function 相关命令和 Resource 相关命令等。 

Unary 类型的 LogicalPlan 表示该节点为非叶子节点,但是只有1个子节点,用于将得到的子节点的数据做一次转换操作,如:Sort、Filter、Distinct、Sample等

BinaryNode 类型的 LogicalPlan 表示该节点有两个子节点,最常见的就是 Join。

从逻辑上看,对根节点的访问操作会递归访问其子节点(ctx.statement,默认为 StatementDefaultContext 节点)。这样逐步向下递归调用,直到访问某个子节点时能够构造LogicalPlan,然后传递给父节点,因此返回的结果可以转换为 LogicalPlan 类型。

总的来看,生成UnresolvedLogicalPlan 的过程如下图所示(图片转载自知乎):

3. Analyzed LogicalPlan 生成 

经过上一个阶段 AstBuilder 的处理,已经得到了 Unresolved LogicalPlan。再回头来看 SparkSession.sql 方法代码:

class SparkSession {
  def sql(sqlText: String): DataFrame = {
    // 调用下面 Dataset 类的方法
    Dataset.ofRows(self, sessionState.sqlParser.parsePlan(sqlText))
  }
}

private[sql] object Dataset {
  def ofRows(sparkSession: SparkSession, logicalPlan: LogicalPlan): DataFrame = {
    // 调用下面 SessionState 类的方法生成 QueryExecution 对象
    val qe = sparkSession.sessionState.executePlan(logicalPlan)
    qe.assertAnalyzed()
    new Dataset[Row](sparkSession, qe, RowEncoder(qe.analyzed.schema))
  }
}

private[sql] class SessionState {
  def executePlan(plan: LogicalPlan): QueryExecution = createQueryExecution(plan)
}

从上面的代码中我们可以看出,sql 方法先得到 LogicalPlan,然后调用 Dataset.ofRows() 方法生成 Dataset 对象,而 ofRows 方法又先调用 SessionState 类的 executePlan 方法生成 QueryExecution 对象,然后调用其 assertAnalyzed 方法去解析 Unresolved LogicalPlan

QueryExecution 类是一个比较重要的类,其后续的由 LogicalPlan 生成 PhysicalPlan 的方法都是由该类提供的。

再来看 QueryExecution.assertAnalyzed 方法:

class QueryExecution(val sparkSession: SparkSession, val logical: LogicalPlan) {
  def assertAnalyzed(): Unit = analyzed
    
  lazy val analyzed: LogicalPlan = {
    SparkSession.setActiveSession(sparkSession)
    sparkSession.sessionState.analyzer.executeAndCheck(logical)
  }
}

class Analyzer(catalog: SessionCatalog, conf: SQLConf, maxIterations: Int)
  extends RuleExecutor[LogicalPlan] with CheckAnalysis {
  def executeAndCheck(plan: LogicalPlan): LogicalPlan = {
    val analyzed = execute(plan)
    try {
      checkAnalysis(analyzed)
      EliminateBarriers(analyzed)
    } catch {
      case e: AnalysisException =>
        val ae = new AnalysisException(e.message, e.line, e.startPosition, Option(analyzed))
        ae.setStackTrace(e.getStackTrace)
        throw ae
    }
  }
}

从代码中我们可以看出执行 LogicalPlan 的方法入口是 Analyzer.execute(plan)。

3.1 Catalog 体系

在 Spark SQL 系统中,Catalog 主要用于各种函数资源信息和元数据信息(数据库、数据表、数据视图等)的统一管理。Catatlog 体系实现以 SessionCatalog 为主体,通过 SparkSession 提供给外部调用,整体类关系如下图所示:

 3.2 Rule 体系

在 Unresolved LogicalPlan 逻辑算子树的操作(如绑定、解析、优化等)中,主要方法都是基于规则(Rule)的,通过 Scala 语言模式匹配机制进行树结构的转换或节点改写。Rule 是一个抽象类,子类需要复写 apply(plan: TreeType) 方法来制定特殊的处理逻辑。

有了各种规则后,还需要驱动程序来调用这些规则,在 Catalyast 中这个功能由 RuleExecutor 提供。RuleExecutor 内部提供了一个 Seq[Batch],里面定义的是该 RuleExecutor 的处理步骤。每个Batch 代表一个规则,配置一个策略(Strategy),

该策略说明了迭代次数(一次还是多次)。

RuleExecutor 类的关键代码如下:

abstract class RuleExecutor[TreeType <: TreeNode[_]] extends Logging {

  abstract class Strategy { def maxIterations: Int }

  case object Once extends Strategy { val maxIterations = 1 }

  case class FixedPoint(maxIterations: Int) extends Strategy

  protected case class Batch(name: String, strategy: Strategy, rules: Rule[TreeType]*)

  protected def batches: Seq[Batch]

  protected def isPlanIntegral(plan: TreeType): Boolean = true


  def execute(plan: TreeType): TreeType = {
    // ... 代码太长省略
  }
}

Analyzer 继承 RuleExecutor 类,为父类的 batches 变量进行了赋值,添加了多个 batch 及 rule。

class Analyzer(
    catalog: SessionCatalog,
    conf: SQLConf,
    maxIterations: Int)
  extends RuleExecutor[LogicalPlan] with CheckAnalysis {
  
  lazy val batches: Seq[Batch] = Seq(
    Batch("Hints", fixedPoint,
      new ResolveHints.ResolveBroadcastHints(conf),
      ResolveHints.RemoveAllHints),
    Batch("Simple Sanity Check", Once,
      LookupFunctions),
    Batch("Substitution", fixedPoint,
      CTESubstitution,
      WindowsSubstitution,
      EliminateUnions,
      new SubstituteUnresolvedOrdinals(conf)),
    Batch("Resolution", fixedPoint,
      ResolveTableValuedFunctions ::
      ResolveRelations ::
      ResolveReferences ::
      ResolveCreateNamedStruct ::
      ResolveDeserializer ::
      ResolveNewInstance ::
      ResolveUpCast ::
      ResolveGroupingAnalytics ::
      ResolvePivot ::
      ResolveOrdinalInOrderByAndGroupBy ::
      ResolveAggAliasInGroupBy ::
      ResolveMissingReferences ::
      ExtractGenerator ::
      ResolveGenerate ::
      ResolveFunctions ::
      ResolveAliases ::
      ResolveSubquery ::
      ResolveSubqueryColumnAliases ::
      ResolveWindowOrder ::
      ResolveWindowFrame ::
      ResolveNaturalAndUsingJoin ::
      ExtractWindowExpressions ::
      GlobalAggregates ::
      ResolveAggregateFunctions ::
      TimeWindowing ::
      ResolveInlineTables(conf) ::
      ResolveTimeZone(conf) ::
      ResolvedUuidExpressions ::
      TypeCoercion.typeCoercionRules(conf) ++
      extendedResolutionRules : _*),
    Batch("Post-Hoc Resolution", Once, postHocResolutionRules: _*),
    Batch("View", Once,
      AliasViewChild(conf)),
    Batch("Nondeterministic", Once,
      PullOutNondeterministic),
    Batch("UDF", Once,
      HandleNullInputsForUDF),
    Batch("FixNullability", Once,
      FixNullability),
    Batch("Subquery", Once,
      UpdateOuterReferences),
    Batch("Cleanup", fixedPoint,
      CleanupAliases)
  )
}

Analyzer 类默认定义了 11 个 Batch,其中最重要的是 Resolution(解析)类别,它下面的 ResolveRelations 规则会去匹配 UnresolvedRelation 节点(由 from 语句得到),从数据库中查找该表的元信息,如果该表不存在,则会抛出异常。

3.3 AnalyzedLogicalPlan 生成过程

前面提到执行逻辑计划解析的入口方法是 Analyzer.execute(),该方法实际上是去调用父类 RuleExecutor 的 execute 方法,方法代码如下:

def execute(plan: TreeType): TreeType = {
    var curPlan = plan
    val queryExecutionMetrics = RuleExecutor.queryExecutionMeter

    batches.foreach { batch =>
      val batchStartPlan = curPlan
      var iteration = 1
      var lastPlan = curPlan
      var continue = true

      // Run until fix point (or the max number of iterations as specified in the strategy.
      while (continue) {
        curPlan = batch.rules.foldLeft(curPlan) {
          case (plan, rule) =>
            val startTime = System.nanoTime()
            val result = rule(plan)
            val runTime = System.nanoTime() - startTime

            if (!result.fastEquals(plan)) {
              queryExecutionMetrics.incNumEffectiveExecution(rule.ruleName)
              queryExecutionMetrics.incTimeEffectiveExecutionBy(rule.ruleName, runTime)
              logTrace(
                s"""
                  |=== Applying Rule ${rule.ruleName} ===
                  |${sideBySide(plan.treeString, result.treeString).mkString("\n")}
                """.stripMargin)
            }
            queryExecutionMetrics.incExecutionTimeBy(rule.ruleName, runTime)
            queryExecutionMetrics.incNumExecution(rule.ruleName)

            // Run the structural integrity checker against the plan after each rule.
            if (!isPlanIntegral(result)) {
              val message = s"After applying rule ${rule.ruleName} in batch ${batch.name}, " +
                "the structural integrity of the plan is broken."
              throw new TreeNodeException(result, message, null)
            }

            result
        }
        iteration += 1
        if (iteration > batch.strategy.maxIterations) {
          // Only log if this is a rule that is supposed to run more than once.
          if (iteration != 2) {
            val message = s"Max iterations (${iteration - 1}) reached for batch ${batch.name}"
            if (Utils.isTesting) {
              throw new TreeNodeException(curPlan, message, null)
            } else {
              logWarning(message)
            }
          }
          continue = false
        }

        if (curPlan.fastEquals(lastPlan)) {
          continue = false
        }
        lastPlan = curPlan
      }
    }

    curPlan
}

RuleExecutor 的 execute() 方法会按照 batches 顺序和 batch 内的 Rules 顺序,对传入的节点进行迭代处理,处理逻辑由具体 Rule 子类的 apply 方法实现。以上面的 ResolveRelations 节点为例,它实现其父类方法代码如下:

object ResolveRelations extends Rule[LogicalPlan] {

    def apply(plan: LogicalPlan): LogicalPlan = plan.transformUp {
      case i @ InsertIntoTable(u: UnresolvedRelation, parts, child, _, _) if child.resolved =>
        EliminateSubqueryAliases(lookupTableFromCatalog(u)) match {
          case v: View =>
            u.failAnalysis(s"Inserting into a view is not allowed. View: ${v.desc.identifier}.")
          case other => i.copy(table = other)
        }
      case u: UnresolvedRelation => resolveRelation(u)
    }
}

上面代码分析了总体 analyzed 流程,可以概况起来为:SparkSqlParser 类进行语法解析生成语法树,得到的结果是将未解析的 LogicalPlan,然后转交给 QueryExection 类去处理。QueryExecution 类是一个比较重要的类,它定义了 analyzed,optimizedPlan, sparkPlan 等一系列方法,分别对应逻辑树解析、优化、生成物理算子树等一系列过程,其中 analyzed 方法便是解析未经解析的 LogicalPlan 得到解析后的 LogicalPlan。

QueryExection 的 analyzed 方法实际上是去调用 Analyzer 类的方法做实际解析。Analyzer 继承自 RuleExecutor 类,重写了父类的 batches 方法,添加了一系列的 rule 到集合中。然后调用其父类的 execute 方法,该方法遍历 rule 列表,根据模式匹配 LogicalPlan 节点,生成解析后的 LogicalPlan 节点。

并且要注意的是,我们调用 sparkSession.sql("xxx") 方法,到这理流程就结束了,可以再回顾一下 sql 方法:

def sql(sqlText: String): DataFrame = {
    Dataset.ofRows(self, sessionState.sqlParser.parsePlan(sqlText))
  }

private[sql] object Dataset {
  def ofRows(sparkSession: SparkSession, logicalPlan: LogicalPlan): DataFrame = {
    val qe = sparkSession.sessionState.executePlan(logicalPlan)
    qe.assertAnalyzed()
    new Dataset[Row](sparkSession, qe, RowEncoder(qe.analyzed.schema))
  }
}

从方法中我们可以看出返回的 Dataset 对象,其构造方法了传入 SparkSession、QueryExecution、RowEncoder 这3个类的实例。这也是为什么我们使用了 sql 方法不能得到查询结果的原因,这里面包含了 spark 惰性计算的思想,后面我们必须再调用例如 show 这样的方法,才会触发后续流程的计算过程。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值