Spark sql是spark內部最核心,也是社區最活躍的組件。Spark SQL支持在Spark中執行SQL,或者HiveQL的關係查詢表達式。列式存儲的類RDD(DataSet/DataFrame)數據類型以及對sql語句的支持使它更容易上手,同時,它對數據的抽取、清洗的特性,使它廣泛的用於etl,甚至是機器學習領域。因此,saprk sql較其他spark組件,獲得了更多的使用者。
下文,我們首先通過查看一個簡單的sql的執行計劃,對sql的執行流程有一個簡單的認識,後面將通過對sql優化器catalyst的每個部分的介紹,來讓大家更深入的瞭解sql後臺的執行流程。由於此模板中代碼較多,我們在此僅就執行流程中涉及到的主要代碼進行介紹,方便大家更快地瀏覽spark sql的源碼。本文中涉及到的源碼spark 2.1.1的。
1. catalyst整體執行流程介紹
1.1 catalyst整體執行流程
說到spark sql不得不提的當然是Catalyst了。Catalyst是spark sql的核心,是一套針對spark sql 語句執行過程中的查詢優化框架。因此要理解spark sql的執行流程,理解Catalyst的工作流程是理解spark sql的關鍵。而說到Catalyst,就必須得上下面這張圖1了,這張圖描述了spark sql執行的全流程。其中,長方形框內爲catalyst的工作流程。
圖1 spark sql 執行流程圖
SQL語句首先通過Parser模塊被解析爲語法樹,此棵樹稱爲Unresolved Logical Plan;Unresolved Logical Plan通過Analyzer模塊藉助於Catalog中的表信息解析爲Logical Plan;此時,Optimizer再通過各種基於規則的優化策略進行深入優化,得到Optimized Logical Plan;優化後的邏輯執行計劃依然是邏輯的,並不能被Spark系統理解,此時需要將此邏輯執行計劃轉換爲Physical Plan。
1.2 一個簡單sql語句的執行
爲了更好的對整個過程進行理解,下面通過一個簡單的sql示例來查看採用catalyst框架執行sql的過程。示例代碼如下:
object TestSql {
case class Student(id:Long,name:String,chinese:String,math:String,English:String,age:Int)
case class Score(sid:Long,weight1:String,weight2:String,weight3:String)
def main(args: Array[String]): Unit = {
//使用saprkSession初始化spark運行環境
val spark=SparkSession.builder().appName("Spark SQL basic example").config("spark.some.config.option", "some-value").getOrCreate()
//引入spark sql的隱式轉換
import sqlContext.implicits._
//讀取第一個文件的數據並轉換成DataFrame
val testP1 = spark.sparkContext.textFile("/home/dev/testP1").map(_.split(" ")).map(p=>Student(p(0).toLong,p(1),p(2),p(3),p(4),p(5).trim.toInt)).toDF()
//註冊成表
testP1.registerTempTable("studentTable")
//讀取第二個文件的數據並轉換成DataFrame
val testp2 = spark.sparkContext.textFile("/home/dev/testP2").map(_.split(" ")).map(p=>Score(p(0).toLong,p(1),p(2),p(3))).toDF()
//註冊成表
testp2.registerTempTable("scoreTable")
//查看sql的邏輯執行計劃
val dataframe = spark.sql("select sum(chineseScore) from " +
"(select x.id,x.chinese+20+30 as chineseScore,x.math from studentTable x inner join scoreTable y on x.id=y.sid)z" +
" where z.chineseScore <100").map(p=>(p.getLong(0))).collect.foreach(println)
此例也是針對spark2.1.1版本的,程序的入口是SparkSession。由於此例超級簡單,做過spark的人一眼就能看出,而且每行代碼都帶有中文註釋,所以在這裏,我們就不做具體的介紹了。
這裏涉及到的sql查詢就是最後一句,通過spark shell可以看到該sql查詢的邏輯執行計劃和物理執行計劃。進入sparkshell後,輸入一下代碼即可顯示此sql查詢的執行計劃。
spark.sql("select sum(chineseScore) from " +
"(select x.id,x.chinese+20+30 as chineseScore,x.math from studentTable x inner join scoreTable y on x.id=y.sid)z" +
" where z.chineseScore <100").explain(true)
這裏,是使用DataSet的explain函數實現邏輯執行計劃和物理執行計劃的打印,調用explain的源碼如下:
/**
* Prints the plans (logical and physical) to the console for debugging purposes.
*
* @group basic
* @since 1.6.0
*/
def explain(extended: Boolean): Unit = {
val explain = ExplainCommand(queryExecution.logical, extended = extended)
sparkSession.sessionState.executePlan(explain).executedPlan.executeCollect().foreach {
// scalastyle:off println
r => println(r.getString(0))
// scalastyle:on println
}
}
顯示在spark shell中的unresolved logical plan、resolved logical plan、optimized logical plan和physical plan如下圖2所示:
圖2 spark sql 執行計劃
將上圖2中的Parsed Logical Plan表示成樹結構如下圖3所示。Catalyst中的parser將圖左中一個sql查詢的字符串解析成圖右的一個AST語法樹,該語法樹就稱爲Parsed Logical Plan。解析後的邏輯計劃基本形成了執行計劃的基礎骨架,此邏輯執行計劃被稱爲 unresolved Logical Plan,也就是說該邏輯計劃是無法執行的,系統並不知道語法樹中的每個詞是神馬意思,如圖中的filter,join,以及studentTable等。
圖3 Parsed Logical Plan樹
將上圖2中的Analyzed logical plan,即Resolved logical plan表示成樹結構如下圖4所示。Catalyst的analyzer將unresolved Logical Plan解析成resolved Logical Plan。Analyzer藉助cataLog(下文介紹)中表的結構信息、函數信息等將此邏輯計劃解析成可被識別的邏輯執行計劃。
圖4 Analyzed logical plan樹
optimized logical plan與physical plan的樹結構跟上面兩種邏輯執行計劃樹結構的畫法相似,下面就不在畫了。從optimized logical plan可出,此次優化使用了Filter下推的策略,即將Filter下推到子查詢中實現,繼而減少後續數據的處理量。
前面我們展示了catalyst執行一段sql語句的大致流程,下面我們就從源代碼的角度來分析catalyst的每個部分內部如何實現,以及它們之間是如何承接的。
2. catalyst各個模塊介紹
本章我們通過分析上面的例子代碼的調用過程來分析catalys各個部分的主要代碼模塊,spark sql的入口是最後一句,SparkSession類裏的sql函數,傳入一個sql字符串,返回一個dataframe對象。入口調用的代碼如下:
def sql(sqlText: String): DataFrame = {
Dataset.ofRows(self, sessionState.sqlParser.parsePlan(sqlText))
}
2.1 Parser
從入口代碼可看出,首先調用sqlParser的parsePlan方法,將sql字符串解析成unresolved邏輯執行計劃。parsePlan的具體實現在AbstractSqlParser類中。如下:
/** Creates LogicalPlan for a given SQL string. */
override def parsePlan(sqlText: String): LogicalPlan = parse(sqlText) { parser =>
astBuilder.visitSingleStatement(parser.singleStatement()) match {
case plan: LogicalPlan => plan
case _ =>
val position = Origin(None, None)
throw new ParseException(Option(sqlText), "Unsupported SQL statement", position, position)
}
}
由上段代碼可看出,調用的主函數是parse,繼續進入到parse中,代碼如下:
protected def parse[T](command: String)(toResult: SqlBaseParser => T): T = {
logInfo(s"Parsing command: $command")
val lexer = new SqlBaseLexer(new ANTLRNoCaseStringStream(command))
lexer.removeErrorListeners()
lexer.addErrorListener(ParseErrorListener)
val tokenStream = new CommonTokenStream(lexer)
val parser = new SqlBaseParser(tokenStream)
parser.addParseListener(PostProcessor)
parser.removeErrorListeners()
parser.addErrorListener(ParseErrorListener)
try {
try {
// first, try parsing with potentially faster SLL(Strong-LL) mode
parser.getInterpreter.setPredictionMode(PredictionMode.SLL)
toResult(parser)
}
}
}
從parse函數可以看出,這裏對於SQL語句的解析採用的是ANTLR 4,這裏使用到了兩個類:詞法解析器SqlBaseLexer和語法解析器SqlBaseParser
SqlBaseLexer和SqlBaseParser均是使用ANTLR 4自動生成的Java類。這裏,採用這兩個解析器將SQL語句解析成了ANTLR 4的語法樹結構ParseTree。之後,在parsePlan(見code 2)中,使用AstBuilder(AstBuilder.scala)將ANTLR 4語法樹結構轉換成catalyst表達式,即logical plan。
此時生成的邏輯執行計劃成爲unresolved logical plan。只是將sql串解析成類似語法樹結構的執行計劃,系統並不知道每個詞所表示的意思,離真正能夠執行還差很遠。
2.2 Analyzer
parser生成邏輯執行計劃後,使用analyzer將邏輯執行計劃進行分析。我們回到Dataset的ofRows函數:
def ofRows(sparkSession: SparkSession, logicalPlan: LogicalPlan): DataFrame = {
val qe = sparkSession.sessionState.executePlan(logicalPlan)
qe.assertAnalyzed()
new Dataset[Row](sparkSession, qe, RowEncoder(qe.analyzed.schema))
}
這裏首先創建了queryExecution類對象,QueryExecution中定義了sql執行過程中的關鍵步驟,是sql執行的關鍵類,返回一個dataframe類型的對象。QueryExecution類中的成員都是lazy的,被調用時纔會執行。只有等到程序中出現action算子時,纔會調用 queryExecution類中的executedPlan成員,原先生成的邏輯執行計劃纔會被優化器優化,並轉換成物理執行計劃真正的被系統調用執行。
//調用analyzer解析器
lazy val analyzed: LogicalPlan = {
SparkSession.setActiveSession(sparkSession)
sparkSession.sessionState.analyzer.execute(logical)
}
lazy val withCachedData: LogicalPlan = {
assertAnalyzed()
assertSupported()
sparkSession.sharedState.cacheManager.useCachedData(analyzed)
}
//調用optimizer優化器
lazy val optimizedPlan: LogicalPlan = sparkSession.sessionState.optimizer.execute(withCachedData)
//將優化後的邏輯執行計劃轉換成物理執行計劃
lazy val sparkPlan: SparkPlan = {
SparkSession.setActiveSession(sparkSession)
// TODO: We use next(), i.e. take the first plan returned by the planner, here for now,
// but we will implement to choose the best plan.
planner.plan(ReturnAnswer(optimizedPlan)).next()
}
// executedPlan should not be used to initialize any SparkPlan. It should be
// only used for execution.
lazy val executedPlan: SparkPlan = prepareForExecution(sparkPlan)
/**
* Prepares a planned [[SparkPlan]] for execution by inserting shuffle operations and internal
* row format conversions as needed.
*/
protected def prepareForExecution(plan: SparkPlan): SparkPlan = {
preparations.foldLeft(plan) { case (sp, rule) => rule.apply(sp) }
}
QueryExecution類的主要成員如下所示。其中定義瞭解析器analyzer、優化器optimizer以及生成物理執行計劃的sparkPlan。前文有介紹,analyzer的主要職責是將parser生成的unresolved logical plan解析生成logical plan。調用analyzer的代碼在QueryExecution中,code 5中已經有貼出。此模塊的主函數來自於analyzer的父類RuleExecutor。主函數execute實現在RuleExecutor類中,代碼如下:
/**
* Executes the batches of rules defined by the subclass. The batches are executed serially
* using the defined execution strategy. Within each batch, rules are also executed serially.
*/
def execute(plan: TreeType): TreeType = {
var curPlan = plan
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
RuleExecutor.timeMap.addAndGet(rule.ruleName, runTime)
if (!result.fastEquals(plan)) {
logTrace(
s"""
|=== Applying Rule ${rule.ruleName} ===
|${sideBySide(plan.treeString, result.treeString).mkString("\n")}
""".stripMargin)
}
result
}
iteration += 1
}
此函數實現了針對analyzer類中定義的每一個batch(類別),按照batch中定義的fix point(策略)和rule(規則)對Unresolved的邏輯計劃進行解析。其中batch的結構如下:
/** A batch of rules. */
protected case class Batch(name: String, strategy: Strategy, rules: Rule[TreeType]*)
由於在analyzer的batchs中定義了多個規則,代碼段很長,因此這裏就不再貼出,有需要的請去spark的源碼中找到Analyzer類查看。
在batchs裏的這些batch中,Resolution是最常用的,從字面意思就可以看出其用途,就是將parser解析後的邏輯計劃裏的各個節點,轉變成resolved節點。而其中ResolveRelations是比較好理解的一個rule(規則),這一步調用了catalog這個對象,Catalog對象裏面維護了一個tableName,Logical Plan的HashMap結果。通過這個Catalog目錄來尋找當前表的結構,從而從中解析出這個表的字段,如UnResolvedRelations 會得到一個tableWithQualifiers。(即表和字段)。catalog中緩存表名稱和邏輯執行計劃關係的代碼如下:
/**
* A cache of qualified table names to table relation plans.
*/
val tableRelationCache: Cache[QualifiedTableName, LogicalPlan] = {
val cacheSize = conf.tableRelationCacheSize
CacheBuilder.newBuilder().maximumSize(cacheSize).build[QualifiedTableName, LogicalPlan]()
}
2.3 Optimizer
optimizer是catalyst中關鍵的一個部分,提供對sql查詢的一個優化。optimizer的主要職責是針對Analyzer的resolved logical plan,根據不同的batch優化策略),來對執行計劃樹進行優化,優化邏輯計劃節點(Logical Plan)以及表達式(Expression),同時,此部分也是轉換成物理執行計劃的前置。optimizer的調用在QueryExecution類中,代碼code 5中已經貼出。
其工作方式與上面講的Analyzer類似,因爲它們的主函數executor都是繼承自RuleExecutor。因此,optimizer的主函數如上面的code 6代碼,這裏就不在貼出。optimizer的batchs(優化策略)定義如下:
def batches: Seq[Batch] = {
// Technically some of the rules in Finish Analysis are not optimizer rules and belong more
// in the analyzer, because they are needed for correctness (e.g. ComputeCurrentTime).
// However, because we also use the analyzer to canonicalized queries (for view definition),
// we do not eliminate subqueries or compute current time in the analyzer.
Batch("Finish Analysis", Once,
EliminateSubqueryAliases,
EliminateView,
ReplaceExpressions,
ComputeCurrentTime,
GetCurrentDatabase(sessionCatalog),
RewriteDistinctAggregates,
ReplaceDeduplicateWithAggregate) ::
//////////////////////////////////////////////////////////////////////////////////////////
// Optimizer rules start here
//////////////////////////////////////////////////////////////////////////////////////////
// - Do the first call of CombineUnions before starting the major Optimizer rules,
// since it can reduce the number of iteration and the other rules could add/move
// extra operators between two adjacent Union operators.
// - Call CombineUnions again in Batch("Operator Optimizations"),
// since the other rules might make two separate Unions operators adjacent.
Batch("Union", Once,
CombineUnions) ::
Batch("Pullup Correlated Expressions", Once,
PullupCorrelatedPredicates) ::
Batch("Subquery", Once,
OptimizeSubqueries) ::
Batch("Replace Operators", fixedPoint,
ReplaceIntersectWithSemiJoin,
ReplaceExceptWithAntiJoin,
ReplaceDistinctWithAggregate) ::
Batch("Aggregate", fixedPoint,
RemoveLiteralFromGroupExpressions,
RemoveRepetitionFromGroupExpressions) ::
Batch("Operator Optimizations", fixedPoint,
// Operator push down
PushProjectionThroughUnion,
ReorderJoin,
EliminateOuterJoin,
PushPredicateThroughJoin,
PushDownPredicate,
LimitPushDown(conf),
ColumnPruning,
InferFiltersFromConstraints,
// Operator combine
CollapseRepartition,
CollapseProject,
CollapseWindow,
CombineFilters,
CombineLimits,
CombineUnions,
// Constant folding and strength reduction
NullPropagation(conf),
FoldablePropagation,
OptimizeIn(conf),
ConstantFolding,
ReorderAssociativeOperator,
LikeSimplification,
BooleanSimplification,
SimplifyConditionals,
RemoveDispensableExpressions,
SimplifyBinaryComparison,
PruneFilters,
EliminateSorts,
SimplifyCasts,
SimplifyCaseConversionExpressions,
RewriteCorrelatedScalarSubquery,
EliminateSerialization,
RemoveRedundantAliases,
RemoveRedundantProject,
SimplifyCreateStructOps,
SimplifyCreateArrayOps,
SimplifyCreateMapOps) ::
Batch("Check Cartesian Products", Once,
CheckCartesianProducts(conf)) ::
Batch("Join Reorder", Once,
CostBasedJoinReorder(conf)) ::
Batch("Decimal Optimizations", fixedPoint,
DecimalAggregates(conf)) ::
Batch("Typed Filter Optimization", fixedPoint,
CombineTypedFilters) ::
Batch("LocalRelation", fixedPoint,
ConvertToLocalRelation,
PropagateEmptyRelation) ::
Batch("OptimizeCodegen", Once,
OptimizeCodegen(conf)) ::
Batch("RewriteSubquery", Once,
RewritePredicateSubquery,
CollapseProject) :: Nil
}
由此可以看出,Spark 2.1.1版本增加了更多的優化策略,因此如果要提高spark sql程序的性能,升級spark版本是非常必要的。
其中,"Operator Optimizations",即操作優化是使用最多的,也是比較好理解的優化操作。"Operator Optimizations"中包括的規則有PushProjectionThroughUnion,ReorderJoin等。
PushProjectionThroughUnion策略是將左邊子查詢的Filter或者是projections移動到union的右邊子查詢中。例如針對下面代碼
case class a:item1:String,item2:String,item3:String
case class b:item1:String,item2:String
select a.item1,b.item2 from a where a.item1>'example' from a union all (select item1,item2 from b )
此時,通過PushProjectionThroughUnion規則後,查詢優化器會將sql改爲下面的sql,即將Filter右移到了union的右端。如下所示。
select a.item1,b.item2 from a where a.item1>’example’ union all (select item1,item2 from b where item1>’example’)
RorderJoin,顧名思義,就是對多個join操作進行重新排序。具體操作就是將一系列的帶有join的子執行計劃進行排序,儘可能地將帶有條件過濾的子執行計劃下推到執行樹的最底層,這樣能儘可能地減少join的數據量。
例如下面代碼中是三個表做join操作,而過濾條件是針對表a的,但熟悉sql的人就會發現對a中字段item1的過濾可以挪到子查詢中,這樣可以減少join的時候數據量,如果滿足此過濾條件的記錄比較少,則可以大大地提高join的性能。
case class b:item1:String,item2:String
select a.item1,d.item2 from a where a.item1> ‘example’ join (select b.item1,b.item2 from b join c on b.item1=c.item1) d on a.item1= d.item1
2.4 SparkPlann
optimizer將邏輯執行計劃優化後,接着該SparkPlan登場了,SparkPlann將optimized logical plan轉換成physical plans。執行代碼如下:
code 10
lazy val sparkPlan: SparkPlan = {
SparkSession.setActiveSession(sparkSession)
// TODO: We use next(), i.e. take the first plan returned by the planner, here for now,
// but we will implement to choose the best plan.
planner.plan(ReturnAnswer(optimizedPlan)).next()
}
其中,planner爲SparkPlanner類的對象,對象的創建如下code 11所示。該對象中定義了一系列的執行策略,包括LeftSemiJoin 、HashJoin等等,這些策略用來指定實際查詢時所做的操作。SparkPlanner中定義的策略如下code 12所示:
def strategies: Seq[Strategy] =
extraStrategies ++ (
FileSourceStrategy ::
DataSourceStrategy ::
SpecialLimits ::
Aggregation ::
JoinSelection ::
InMemoryScans ::
BasicOperators :: Nil)
/**
* Planner that converts optimized logical plans to physical plans.
*/
def planner: SparkPlanner =
new SparkPlanner(sparkContext, conf, experimentalMethods.extraStrategies)
plan真正的處理函數如下的code 13所示。該函數的功能是整合所有的Strategy,_(plan)每個Strategy應用plan上,得到所有Strategies執行完後生成的所有Physical Plan的集合。
def plan(plan: LogicalPlan): Iterator[PhysicalPlan] = {
// Obviously a lot to do here still...
// Collect physical plan candidates.
val candidates = strategies.iterator.flatMap(_(plan))
// The candidates may contain placeholders marked as [[planLater]],
// so try to replace them by their child plans.
val plans = candidates.flatMap { candidate =>
val placeholders = collectPlaceholders(candidate)
if (placeholders.isEmpty) {
// Take the candidate as is because it does not contain placeholders.
Iterator(candidate)
} else {
// Plan the logical plan marked as [[planLater]] and replace the placeholders.
placeholders.iterator.foldLeft(Iterator(candidate)) {
case (candidatesWithPlaceholders, (placeholder, logicalPlan)) =>
// Plan the logical plan for the placeholder.
val childPlans = this.plan(logicalPlan)
candidatesWithPlaceholders.flatMap { candidateWithPlaceholders =>
childPlans.map { childPlan =>
// Replace the placeholder by the child plan
candidateWithPlaceholders.transformUp {
case p if p == placeholder => childPlan
}
}
}
}
}
3. spark2.x較spark1.x性能
對源碼的分析後,可以看出spark 2.0較 spark 1.x版本性能有較大提升,具體可從以下幾個方面看出:
1.parser語法解析不同
spark 1.x版本使用的是scala的parser語法解析器,而spark 2.x版本使用的是ANTLR4,解析性能更好
2.spark 2.x的optimizer優化策略不同
spark 2.x爲optimizer優化器提供了更加豐富的優化策略,從兩個版本里optimizer類中的batchs中可以看出。
3.spark 2.x提供了第二代Tungsten引擎
Spark2.x移植了第二代Tungsten引擎,這一代引擎是建立在現代編譯器和MPP數據庫的基礎上,並且應用到數據的處理中。主要的思想是將那些拖慢整個程序執行速度的代碼放到一個單獨的函數中,消除虛擬函數的調用,並使用寄存器來存放中間結果。這項技術被稱作“whole-stage code generation.”
下面通過在單機上執行10億條數據的aggregations and joins操作,來對比spark1.6和2.0的性能。其中,ns爲納秒,表格中的處理時間爲單個線程處理單行數據所用時間。由此,可看出spark2.0的處理性能遠遠好於1.6。
圖5 spark性能對比