Spark SQL Limit 介紹及優化

一、概念

1.1、GlobalLimit

case class GlobalLimit(limitExpr: Expression, child: LogicalPlan)

全局限制,最多返回 limitExpr 對應條 records。總是通過 IntegerLiteral#unapply(limitExpr: Expression): Option[Int] 將 limitExpr 轉換為 Int。

1.2、LocalLimit

case class LocalLimit(limitExpr: Expression, child: LogicalPlan)

分區級限制(非全局),限制每個物理分區最多返回 limitExpr 對應條 records。同樣通過 IntegerLiteral#unapply 得到 limitExpr 對應 Int 值。

當需要限制全局返回至多 n 條數據時,

GlobalLimit n
+- LocalLimit n

如上,通過 LocalLimit n 限制每個分區至多返回 n 條 records,再通過 GlobalLimit n 限制各個分區總體至多返回 n 條 records。

在分布式查詢中,將 limit 下推到分區級往往比推到全局級有更好的性能,因為可以減少數據的返回(網絡傳輸),比如對于 GlobalLimit(Union(A, B))

GlobalLimit(Union(LocalLimit(A), LocalLimit(B))) 是比 Union(GlobalLimit(A), GlobalLimit(B)) 更好的下推方式。

二、Optimizer 中 Limit 相關的 Rules

Rule 原則:改變 plan 結構,但不改變結果

2.1、LimitPushDown

下推 LocalLimitUNIONLeft/Right Outer JOIN之下:

  • 對于 Union:若 Union 的任一一邊 child 不是一個 limit(GlobalLimit 或 LocalLimit)或是一個 limit 但 limit value 大于 Union parent 的 limit value,以一個 LocalLimit (limit value 為 Union parent limit value)作為 child 的 parent 來作為該邊新的 child
  • 對于 Outer Join:對于 Left Outer Join,若 left side 不是一個 limit(GlobalLimit 或 LocalLimit)或是一個 limit 但 limit value 大于 Join parent 的 limit value,以一個 LocalLimit(limit value 為 Join parent limit value) 作為 left side 的 parent 來作為新的 left side;對于 Right Outer Join 同理,只是方向不同

2.1.1、Union: limit to each side

GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalRelation <empty>, [d#3, e#4, f#5]
      
GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalLimit 1
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalLimit 1
         +- LocalRelation <empty>, [d#3, e#4, f#5]

2.2.2、Union: limit to each sides if children having larger limit values

GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalRelation <empty>, [a#0, b#1, c#2]
      +- GlobalLimit 3
         +- LocalLimit 3
            +- LocalRelation <empty>, [d#3, e#4, f#5]

GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalLimit 1
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalLimit 1
         +- LocalLimit 3
            +- LocalRelation <empty>, [d#3, e#4, f#5]

注: Rule CombineLimits 會進一步優化

2.2.3、Left outer join: limit to left side

'GlobalLimit 1
+- 'LocalLimit 1
   +- 'Join LeftOuter
      :- SubqueryAlias x
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- SubqueryAlias y
         +- LocalRelation <empty>, [a#0, b#1, c#2]

GlobalLimit 1
+- LocalLimit 1
   +- Join LeftOuter
      :- LocalLimit 1
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalRelation <empty>, [a#6, b#7, c#8]
  • Rule EliminateSubqueryAliases:使用 SubqueryAlias 的 child 替換 SubqueryAlias
  • 同樣適用于 right outer join

2.2.4、Right outer join: limit to right side if right side having larger limit value

'GlobalLimit 3
+- 'LocalLimit 3
   +- 'Join RightOuter
      :- SubqueryAlias x
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- GlobalLimit 5
         +- LocalLimit 5
            +- SubqueryAlias y
               +- LocalRelation <empty>, [a#0, b#1, c#2]

GlobalLimit 3
+- LocalLimit 3
   +- Join RightOuter
      :- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalLimit 3
         +- LocalLimit 5
            +- LocalRelation <empty>, [a#6, b#7, c#8]

2.2、CombineLimits

合并兩個臨近(parnet 和 child)的 limit(GlobalLimit、LocalLimit、Limit) 為一個,limit value 取小的那個

GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalLimit 1
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalLimit 1
         +- LocalLimit 3
            +- LocalRelation <empty>, [d#3, e#4, f#5]
            
GlobalLimit 1
+- LocalLimit 1
   +- Union
      :- LocalLimit 1
      :  +- LocalRelation <empty>, [a#0, b#1, c#2]
      +- LocalLimit 1
           +- LocalRelation <empty>, [d#3, e#4, f#5]

三、現狀的收益與缺陷

3.1、缺陷及改進

3.1.1、limit 未下推到存儲層

上述 limit 相關的 rules,并沒有把 limit 下推到存儲,這樣并不會減少最初生成的 RDD 返回的各個分區對應的數據量,在我們的應用場景總中,計算集群和存儲集群都是獨立部署,在最初的 stage 中的 mapTask 都是通過網絡去拉取 parquet 數據,這往往是代價、耗時最高的操作。而實際上,對于很多 limit 場景,并不需要完整的 partition 數據,只需要 n 條

3.1.2、獲取結果時的 partitions 掃描策略不合理

limit 操作最終會調用 SparkPlan#executeTake(n: Int) 來獲取至多 n 條 records,其內部可能會執行多次 runJob,具體流程如下:

默認情況下每次 runJob 掃描的 partitions 數:

1
4
20
100
500
2500
6875

存在的問題:

  1. 初期掃描的 partitions 數太少,往往需要多個批次才能達到 limit n
  2. 后期每個批次掃描的 partitions 過多,對應的 job耗時較長
  3. 如要掃描多個批次才能達到 limit n,對于下一個批次需要等上一個批次完成才能開始運行,累計的等待時間過長

改進:

  • 以 n 個并發同時掃描多個 partitions,每完成一個 job,立即新增一個 job
  • 這樣使得初期掃描的 partitions 數大大增加,由于是并發執行多個 runJob,在相同的時間內能獲取到更多的 records 填充到 buf 中
  • 每執行 n 個 jobs 后,每個并發掃描的 partitions 也根據可配置的增長率進行增長,避免要掃描大量 partitions 才能拿到結果需要運行過多的 jobs

3.2、收益

雖然上述 rules 沒有將 limit 下推到存儲,但也將 limit 下推到相對更底層的 plan,這使得要基于該 plan 做的操作拉取和處理的數據量更小(如 LimitPushdown、CombineLimits 例子中展示)

四、下推 limit 到存儲

下推到存儲在 plan 層目的是讓最開始生成的 RDD 各分區包含盡量少的數據,對于 limit 來說就是要讓最開始的 RDD 的各分區至多包含 limit n 條記錄。最開始的 RDD 也即讀取 parquet 生成的 RDD。

4.1、Parquet RDD 如何生成

parquet on hdfs 是一個沒有計算能力的存儲方案,目前不支持直接下推 limit 給 parquet,但在一些場景下可以實現讓最開始直接讀取 parquet 返回的 RDD 的各個 partition 至多返回 limit n 條數據,先來看看這個 RDD 是如何生成的

4.1.1、應用 FileSourceStrategy 來獲取 scan

SparkPlanner 應用一系列策略于 Optimized Logical Plan 來生成 Physical Plan,FileSourceStrategy 就是其中的一個策略,主要用于掃描由 sql 指定列、分區的文件集合。其主要流程如下:

名詞解釋:

Project:投影,要 SELECT 的東西,比如 SELECT a, a+b, udf(c) FROM tb 中的 a, a+b, udf(c) 組合起來為Project 的 projectList: Seq[NamedExpression],和 child 一起組成了 Project:Project(projectList: Seq[NamedExpression], child: LogicalPlan) extends UnaryNode

PhysicalOperation

匹配一個 LogicalPlan 上套了任意個 project 或 filter 操作(連續的)

  • 會將所有 filters 的 conditions 分割(若用 And 連接)組成新的 filters: Seq[Expression]
  • 以最底層 Project 的 fields 作為最終返回的 fields
  • 以最底層 aliases 作為最終返回的 aliases
  def FileSourceStrategy#apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
    case PhysicalOperation(projects, filters,
      l @ LogicalRelation(fsRelation: HadoopFsRelation, _, table)) => ...
  }

所以 FileSourceStrategy 策略能應用的 plan 必定是 relation 為 HadoopFsRelation 的 LogicalRelation 上套了連續的任意個 Project 或 Filter。

上圖流程中創建了 scan: FileSourceScanExec,該類是一個用于掃描 HadoopFsRelation 的物理執行計劃節點。

名詞解釋:

HadoopFsRelation:包含讀取一個基于文件的表所需的所有元數據,如:

  • 經過 partition filters、data filters 過濾的要讀取的 partition -> 文件列表 對應關系
  • partition schema
  • data schema
  • file format
  • 表大小(in bytes)
  • 如何分桶(only for bucket table)
  • options

上述流程主要:

  • 將 filters 根據 attributes 是否包含 partition attribute 分為 partitionFilters 和 dataFilter
  • 計算
    • outputAttributes:filters 中包含的 attributes ++ projects
    • outputSchema:非 partition filters 中包含的 attributes ++ projects
  • 使用 partitionKeyFilters、dataFilters、outputAttributes、outputSchema 等構造 FileSourceScanExec。z
  • 若 afterScanFilters 不為空,則需要在 FileSourceScanExec 套一個 FilterExec 座位 parent,即對 FileSourceScanExec 返回的數據需要再做一次 filter(使用 afterScanFilters 包含的各 filters 的 conditions 組合,用 And 連接)
  • 若上一步的 output 與 projects 不一致,還需再套一個 ProjectExec 座位 parent,即再做一次 Project 操作

4.1.2、FileSourceScanExec 生成 RDD

最終會調用lazy 的 inputRDD 成員 來獲取 RDD[InternalRow] ,主要包含兩個步驟:

4.1.2.1、構造 readFile: (PartitionedFile) => Iterator[InternalRow] 函數變量

通過調用 ParquetFileFormat#buildReaderWithPartitionValues(...): (PartitionedFile) => Iterator[InternalRow] 來獲取 readFileFunc

主要就是:

  • 設置將要 scan 的 cols schema
  • 將 catalyst filters 轉換為 parquet filter(不是所有的都能轉換)
  • 根據是否啟用矢量化讀取構造 parquetReader
  • 將 cols schema、pushdown data filters 直接或通過配置設置給 parquetReader
  • 使用 reader 構造最終的迭代器,并轉化為 Iterator[InternalRow] 類型返回

再看 create rdd 前,我們先來看 FileSourceScanExec#selectedPartitions: Seq[PartitionDirectory] 方法,該方法調用 relation.location.listFiles(partitionFilters, dataFilters) 根據指定的 partition、data filters 過濾不需要掃描的 partitions,只返過濾后的 partitions(一個 PartitionDirectory 對應一個 Seq[FileStatus]

  • 分區表: 各分區及其對應的過濾后的文件列表
  • 非分區表:沒有分區值的單個分區及其文件列表
4.1.2.2、使用 readFile 函數變量 create rdd

根據是否是 bucket 表會調用 FileSourceScanExec#createBucketedReadRDDFileSourceScanExec#createNonBucketedReadRDD 來創建 rdd,我們以

FileSourceScanExec#createNonBucketedReadRDD(
      readFile: (PartitionedFile) => Iterator[InternalRow],
      selectedPartitions: Seq[PartitionDirectory],
      fsRelation: HadoopFsRelation): RDD[InternalRow]

為例,其中 readFile 即上一步得到的 func

主要關注:

  • 一個 parquet file 可以切分為多個片段
  • 一個 FileScanRDD 的一個 partition 可能包含多個 partquet file 片段
  • 一個 FileScanRDD 的一個 partition 至多包含 maxSplitSize 大小的數據

FileScanRDD#compute

偽代碼如下:

val iterator = new Iterator[Object] with AutoCloseable {
      def hasNext: Boolean = {}
      def next(): Object = {}
      override def close(): Unit = {}
    }
iterator.asInstanceOf[Iterator[InternalRow]] // This is an erasure hack.

其中:

  • readCurrentFile(): Iterator[InternalRow] : 讀取 currentFile 轉化為 Iterator[InternalRow];
    這個 readFile 就是通過 parquetFileFormat.buildReaderWithPartitionValues 得到的
  • nextIterator(): Boolean:若存在下一個 split,將該 split 轉為 iterator 設置為 currentIterator 返回 true;否則返回 false
  • hasNext: Boolean :(currentIterator != null && currentIterator.hasNext) || nextIterator()
  • next: Object : currentIterator.next()

4.2、存儲為 parquet 哪些場景可以下推?

只有當對最初生成的 FileScanRDD 各個分區的 iterator 調用 take(n) 不影響其最終結果時才能進行下推,各場景總結如下:

4.2.1、samplest limit

SELECT * FROM ${dbTable} LIMIT 10:能下推(對 partition 對應迭代器做 take(n)),當作為 subquery 或 child 時也支持下推

== Optimized Logical Plan ==
GlobalLimit 10
+- LocalLimit 10
   +- Relation[id#0L,content#1,dt#2] parquet

== Physical Plan ==
CollectLimit 10
+- FileScan parquet xx_jtest_dev.aaa_test_part1[id#0L,content#1,dt#2] Batched: true, Format: Parquet, Location: CatalogFileIndex[, PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<id:bigint,content:string>

4.2.2、limit with filter

SELECT * FROM (SELECT * FROM ${dbTable} LIMIT 10) a WHERE a.content != 'test':能下推

== Optimized Logical Plan ==
Filter (isnotnull(content#1) && NOT (content#1 = test))
+- GlobalLimit 10
   +- LocalLimit 10
      +- Relation[id#0L,content#1,dt#2] parquet
      
== Physical Plan ==
Filter (isnotnull(content#1) && NOT (content#1 = test))
+- GlobalLimit 10
   +- LocalLimit 10
      +- FileScan parquet xx_jtest_dev.aaa_test_part1[id#0L,content#1,dt#2] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<id:bigint,content:string>


SELECT * FROM ${dbTable} WHERE content != 'test' LIMIT 10:僅當 filters 都是 parquet 支持的才能下推 limit;否則不能下推 limit

== Optimized Logical Plan ==
GlobalLimit 10
+- LocalLimit 10
   +- Filter (isnotnull(content#1) && NOT (content#1 = test))
      +- Relation[id#0L,content#1,dt#2] parquet
      
== Physical Plan ==
CollectLimit 10
+- Project [id#0L, content#1, dt#2]
   +- Filter (isnotnull(content#1) && NOT (content#1 = test))
      +- FileScan parquet xx_jtest_dev.aaa_test_part1[id#0L,content#1,dt#2] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [IsNotNull(content), Not(EqualTo(content,test))], ReadSchema: struct<id:bigint,content:string>

4.2.3、limit with aggregation

SELECT COUNT(*) FROM (SELECT * FROM ${dbTable} LIMIT 10)a:能下推

== Optimized Logical Plan ==
Aggregate [count(1) AS count(1)#4L]
+- GlobalLimit 10
   +- LocalLimit 10
      +- Project
         +- Relation[id#1L,content#2,dt#3] parquet
         
== Physical Plan ==
HashAggregate(keys=[], functions=[count(1)], output=[count(1)#4L])
+- HashAggregate(keys=[], functions=[partial_count(1)], output=[count#11L])
   +- GlobalLimit 10
      +- LocalLimit 10
         +- Project
            +- FileScan parquet xx_jtest_dev.aaa_test_part1[dt#3] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<>

SELECT * FROM ${dbTable} ORDER BY content LIMIT 10:不能也沒必要下推

== Optimized Logical Plan ==
GlobalLimit 10
+- LocalLimit 10
   +- Aggregate [count(1) AS count(1)#4L]
      +- Project
         +- Relation[id#1L,content#2,dt#3] parquet
         
== Physical Plan ==
CollectLimit 10
+- HashAggregate(keys=[], functions=[count(1)], output=[count(1)#4L])
   +- HashAggregate(keys=[], functions=[partial_count(1)], output=[count#11L])
      +- Project
         +- FileScan parquet xx_jtest_dev.aaa_test_part1[dt#3] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<>

4.2.4、limit with order

SELECT * FROM ${dbTable} ORDER BY content LIMIT 10:不能下推,sort 后再 limit 與 limit 后再 sort 結果不同

== Optimized Logical Plan ==
GlobalLimit 10
+- LocalLimit 10
   +- Sort [content#1 ASC NULLS FIRST], true
      +- Relation[id#0L,content#1,dt#2] parquet
      
== Physical Plan ==
TakeOrderedAndProject(limit=10, orderBy=[content#1 ASC NULLS FIRST], output=[id#0L,content#1,dt#2])
+- FileScan parquet xx_jtest_dev.aaa_test_part1[id#0L,content#1,dt#2] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<id:bigint,content:string>

SELECT * FROM (SELECT * FROM ${dbTable} LIMIT 10)a ORDER BY a.content:能下推

== Optimized Logical Plan ==
Sort [content#1 ASC NULLS FIRST], true
+- GlobalLimit 10
   +- LocalLimit 10
      +- Relation[id#0L,content#1,dt#2] parquet
      
Sort [content#1 ASC NULLS FIRST], true, 0
+- GlobalLimit 10
   +- LocalLimit 10
      +- FileScan parquet xx_jtest_dev.aaa_test_part1[id#0L,content#1,dt#2] Batched: true, Format: Parquet, Location: CatalogFileIndex[hdfs://testspark-compile.inc.test.net:9000/testdata/warehouse/testdb.db/suo..., PartitionCount: 0, PartitionFilters: [], PushedFilters: [], ReadSchema: struct<id:bigint,content:string>

4.2.5、limit with union

參見 LimitPushdown rule,若應用 LimitPushdown rule 能將 limit 下推,則同樣也能在同一 side 對應生成 RDD 處對 partition iterator 應用 take(n)

4.2.6、limit with join

參見 LimitPushdown rule,若應用 LimitPushdown rule 能將 limit 下推,則同樣也能在同一 side 對應生成 RDD 處對 partition iterator 應用 take(n)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容