SparkSQL基础

SparkSQL概述

SparkSQL是Spark的结构化数据处理模块。特点如下:

  • 数据兼容:可从Hive表、外部数据库(JDBC)、RDD、Parquet 文件、JSON 文件获取数据;
  • 组件扩展:SQL 语法解析器、分析器、优化器均可重新定义;
  • 性能优化:内存列存储、动态字节码生成等优化技术,内存缓存数据;
  • 多语言支持:Scala、Java、Python;
    Shark即Hive on Spark,Shark的设计导致了两个问题:
  • 执行计划优化完全依赖于Hive,不方便添加新的优化策略;
  • Spark是线程级并行,而MapReduce是进程级并行。Spark在兼容Hive的实现上存在线程安全问题,导致Shark不得不使用另外一套独立维护的打了补丁的Hive源码分支;

Spark团队汲取了shark的优点重新设计了Spark Sql,使之在数据兼容、性能优化、组件扩展等方面得到极大的提升:
数据兼容:不仅兼容Hive,还可以从RDD、parquet文件、Json文件获取数据、支持从RDBMS获取数据;
性能优化:采用内存列式存储、自定义序列化器等方式提升性能;
组件扩展:SQL的语法解析器、分析器、优化器都可以重新定义和扩展。

Spark SQL 是 Spark 中用于处理结构化数据的模块;
Spark SQL相对于RDD的API来说,提供更多结构化数据信息和计算方法;
Spark SQL 提供更多额外的信息进行优化,可以通过SQL或DataSet API方式同Spark SQL进行交互。无论采用哪种方法,哪种语言进行计算操作,实际上都用相同的执行引擎,使用者可以在不同的API中进行切换,选择一种最自然的方式完成数据操作。
Spark SQL在Hive兼容层面仅依赖HiveQL解析、Hive元数据。
从HQL被解析成抽象语法树(AST)起,就全部由Spark SQL接管了,Spark SQL执行计划生成和优化都由Catalyst(函数式关系查询优化框架)负责。

Spark SQL目前支持Scala、Java、Python三种语言,支持SQL-92规范;

Spark第一代API:RDD

优点:
1)编译时类型安全,编译时就能检查出类型错误;
2)面向对象的编程风格,直接通过class.name的方式来操作数据;
idAge.filter(.age > “”) // 编译时报错, int不能跟与String比较
idAgeRDDPerson.filter(
.age > 25) // 直接操作一个个的person对象

缺点:
1)序列化和反序列化的性能开销,无论是集群间的通信, 还是IO操作都需要对对象的结构和数据进行序列化和反序列化;
2)GC的性能开销,频繁的创建和销毁对象, 势必会增加GC。

Spark第二代API:DataFrame

DataFrame的前身是SchemaRDD。Spark1.3更名为DataFrame。不继承RDD,自己实现了RDD的大部分功能。可以在DataFrame上调用RDD的方法转化成另外一个RDD;
DataFrame可以看做分布式Row对象的集合,提供了由列组成的详细模式信息,使其可以得到优化。DataFrame 不仅有比RDD更多的算子,还可以进行执行计划的优化;
Spark2.0中两者统一,DataFrame表示为DataSet[Row],即DataSet的子集。

DataFrame核心特征:
Schema : 包含了以ROW为单位的每行数据的列的信息; Spark通过Schema就能够读懂数据, 因此在通信和IO时就只需要序列化和反序列化数据, 而结构的部分就可以省略了;

off-heap : Spark能够以二进制的形式序列化数据(不包括结构)到off-heap中, 当要操作数据时, 就直接操作off-heap内存;

Tungsten:新的执行引擎;

Catalyst:新的语法解析框架;

Spark第二代API:DataFrame

优点:
off-heap类似于地盘, schema类似于地图, Spark有地图又有自己地盘了, 就可以自己说了算了, 不再受JVM的限制, 也就不再受GC的困扰了,通过schema和off-heap, DataFrame克服了RDD的缺点。对比RDD提升计算效率、减少数据读取、底层计算优化;

缺点:
DataFrame克服了RDD的缺点, 但是却丢了RDD的优点。DataFrame不是类型安全的, API也不是面向对象风格的。

// API不是面向对象的
idAgeDF.filter(idAgeDF.col(“age”) > 25)
// 不会报错, DataFrame不是编译时类型安全的
idAgeDF.filter(idAgeDF.col(“age”) > “”)

Spark第三代API:Dataset;Dataset的核心:Encoder

DataSet不同于RDD,没有使用Java序列化器或者Kryo进行序列化,而是使用一个特定的编码器进行序列化,这些序列化器可以自动生成,而且在spark执行很多操作(过滤、排序、hash)的时候不用进行反序列化。

1)编译时的类型安全检查。性能极大的提升,内存使用极大降低、减少GC、极大的减少网络数据的传输、极大的减少scala和java之间代码的差异性。

2)DataFrame每一个行对应了一个Row。而Dataset的定义更加宽松,每一个record对应了一个任意的类型。DataFrame只是Dataset的一种特例。

3)不同于Row是一个泛化的无类型JVM object, Dataset是由一系列的强类型JVM object组成的,Scala的case class或者Java class定义。因此Dataset可以在编译时进行类型检查。

4)Dataset以Catalyst逻辑执行计划表示,并且数据以编码的二进制形式被存储,不需要反序列化就可以执行sorting、shuffle等操作。

5)Dataset创立需要一个显式的Encoder,把对象序列化为二进制。

SparkSQL API

SparkSession:Spark的一个全新的切入点,统一Spark入口;

Spark2.0中引入了SparkSession的概念,它为用户提供了一个统一的切入点来使用Spark的各项功能,包括是SQLContext和HiveContext的组合(未来可能还会加上StreamingContext),用户不但可以使用DataFrame和Dataset的各种API,学习Spark的难度也会大大降低。
SparkSQL基础_第1张图片

Dataset是一个类(RDD是一个抽象类,而Dataset不是抽象类),其中有三个参数:
SparkSession(包含环境信息)
QueryExecution(包含数据和执行逻辑)
Encoder[T]:数据结构编码信息(包含序列化、schema、数据类型)

Row

Row是一个泛化的无类型JVM object

Schema

DataFrame(即带有Schema信息的RDD)
Spark通过Schema就能够读懂数据

什么是schema?
DataFrame中提供了详细的数据结构信息,从而使得SparkSQL可以清楚地知道该数据集中包含哪些列,每列的名称和类型各是什么,DataFrame中的数据结构信息,即为schema。
// 最便捷
val schema6 = (new StructType).
add(“name”, “string”, false).
add(“age”, “integer”, false).
add(“height”, “integer”, false)

Spark提供了一整套用于操纵数据的DSL
(DSL :Domain Specified Language,领域专用语言)
DSL在语义上与SQL关系查询非常相近

Dataset的创建

SparkSQL基础_第2张图片
SparkSQL基础_第3张图片
// 1、由range生成Dataset
val numDS = spark.range(5, 100, 5)
numDS.orderBy(desc(“id”)).show(5)
numDS.describe().show

// 2、由集合生成Dataset
case class Person(name:String, age:Int, height:Int)
// 注意 Seq 中元素的类型
val seq1 = Seq(Person(“Jack”, 28, 184), Person(“Tom”, 10, 144), Person(“Andy”, 16, 165))
val ds1 = spark.createDataset(seq1)
ds1.show
val seq2 = Seq((“Jack”, 28, 184), (“Tom”, 10, 144), (“Andy”, 16, 165))
val ds2 = spark.createDataset(seq2)
ds2.show
// 3、集合转成DataFrame,并修改列名
val seq1 = ((“Jack”, 28, 184), (“Tom”, 10, 144), (“Andy”, 16, 165))
val df1 = spark.createDataFrame(seq1).withColumnRenamed("_1", “name1”).
withColumnRenamed("_2", “age1”).withColumnRenamed("3", “height1”)
df1.orderBy(desc(“age1”)).show(10)
val df2 = spark.createDataFrame(seq1).toDF(“name”, “age”, “height”) // 简单!2.0.0的新方法
// 4、RDD 转成DataFrame
import org.apache.spark.sql.Row
import org.apache.spark.sql.types.

val arr = Array((“Jack”, 28, 184), (“Tom”, 10, 144), (“Andy”, 16, 165))
val rdd1 = sc.makeRDD(arr).map(f=>Row(f._1, f._2, f._3))
val schema = StructType( StructField(“name”, StringType, false) :: StructField(“age”, IntegerType, false) ::
StructField(“height”, IntegerType, false) :: Nil)
val rddToDF = spark.createDataFrame(rdd1, schema)
rddToDF.orderBy(desc(“name”)).show(false)
// 5、RDD 转成 Dataset / DataFrame
val rdd2 = spark.sparkContext.makeRDD(arr).map(f=>Person(f._1, f._2, f.3))
val ds2 = rdd2.toDS()
val df2 = rdd2.toDF()
ds2.orderBy(desc(“name”)).show(10)
df2.orderBy(desc(“name”)).show(10)
// 6、rdd 转成 Dataset
val ds3 = spark.createDataset(rdd2)
ds3.show(10)
// 7 读取文件
val df5 = spark.read.csv(“file:///C:/Users/Administrator/Desktop/Spark-SQL/raw_user.csv”)
df5.show()
逗号分隔值(Comma-Separated Values,CSV,也称为字符分隔值,因为分隔字符也可以不是逗号),其文件以纯文本形式存储表格数据(数字和文本)。纯文本意味着该文件是一个字符序列,不含必须像二进制数字那样被解读的数据。CSV文件由任意数目的记录组成,记录间以某种换行符分隔;每条记录由字段组成,字段间的分隔符是其它字符或字符串,最常见的是逗号或制表符。通常,所有记录都有完全相同的字段序列。通常都是纯文本文件。
// 8 读取文件,详细参数
import org.apache.spark.sql.types.

val schema2 = StructType( StructField(“name”, StringType, false) ::
StructField(“age”, IntegerType, false) ::
StructField(“height”, IntegerType, false) :: Nil)
val df7 = spark.read.options(Map((“delimiter”, “,”), (“header”, “false”))). schema(schema2).csv(“file:///home/spark/t01.csv”)
df7.show()

spark.read 方法

val df = spark.read.csv(“file:\\\C:\Users\Administrator\Desktop\spark\score.csv”)
//自动类型推断
val df = spark.read.option(“inferschema”,“true”).csv(“file:///home/spark/data/sparksql/t01.csv”)
//以逗号为分隔符,不带表头
val df = spark.read.options(Map((“delimiter”, “,”), (“header”, “false”))). schema(schema6).csv(“file:///home/spark/data/sparksql/t01.csv”)

RDD、DataFrame、Dataset的共性与区别

共性:
1、RDD、DataFrame、Dataset都是spark平台下的分布式弹性数据集,为处理超大型数据提供便利;

2、三者都有惰性机制。在进行创建、转换时(如map方法),不会立即执行;只有在遇到Action时(如foreach) ,才会开始遍历运算。极端情况下,如果代码里面仅有创建、转换,但后面没有在Action中使用对应的结果,在执行时会被直接跳过;

3、三者都有partition的概念,进行缓存(cache)操作、还可以进行检查点(checkpoint)操作;

4、三者有许多共同的函数,如map、filter,排序等;

5、在对DataFrame和Dataset进行操作时,很多情况下需要 spark.implicits._ 进行支持;

三者之间的转换

RDD、DataFrame、Dataset三者有许多共性,有各自适用的场景常常需要在三者之间转换

  • DataFrame/Dataset 转 RDD:
    // 这个转换很简单
    val rdd1=testDF.rdd
    val rdd2=testDS.rdd

  • RDD 转 DataFrame:

// 一般用元组把一行的数据写在一起,然后在toDF中指定字段名
import spark.implicits._
val testDF = rdd.map {line=>
(line._1,line._2)
}.toDF(“col1”,“col2”)

  • RDD 转 Dataet:
    // 核心就是要定义case class
    import spark.implicits._
    case class Coltest(col1:String, col2:Int)
    val testDS = rdd.map{line=>Coltest(line._1,line._2)}.toDS
  • Dataset 转 DataFrame:
    // 这个转换简单,只是把 case class 封装成Row
    import spark.implicits._
    val testDF = testDS.toDF
  • DataFrame 转 Dataset:
    // 每一列的类型后,使用as方法(as方法后面还是跟的case class,这个是核心),转成Dataset。
    import spark.implicits._
    case class Coltest … …
    val testDS = testDF.as[Coltest]
    特别注意:
    在使用一些特殊操作时,一定要加上import spark.implicits._ 不然toDF、toDS无法使用
    case class Person(name:String, age:Int, height:Int)
    val arr = Array((“Jack”, 28, 184), (“Tom”, 10, 144), (“Andy”, 16, 165))
    val rdd1 = sc.makeRDD(arr)
    val df = rdd1.toDF()
    val df = rdd1.toDF(“name”, “age”, “height”)
    df.as[Person]
    备注:DF转为DS时要求:二者的列名相同
    以下操作均报错:
    rdd1.toDF().as[Person]
    rdd1.toDF(“name1”, “age”, “height”).asPerson
    SparkSQL基础_第4张图片

DSL

备注:
1、在官方文档及源码中并没有action、transformation的提法
2、这一部分的内容比较新

数据类型

SparkSQL基础_第5张图片

Transformation

与RDD类似的操作
map、filter、flatMap、mapPartitions、sample、 randomSplit、 limit、distinct、dropDuplicates、describe()
存储相关
cacheTable、persist、checkpoint、unpersist、cache
select相关
列的多种表示、select、selectExpr
drop、withColumn、 withColumnRenamed、cast(内置函数)
where相关
where、filter
groupBy相关
groupBy、agg、max、min、avg(mean)、sum、count(后面5个为内置函数)
orderBy相关
orderBy、sort
join相关
join
集合相关
union、unionAll、intersect、except
空值处理
na.fill、na.drop

// map、flatMap操作(与RDD基本类似)
df1.map(row=>row.getAsInt).show
// filter
df1.filter(“sal>3000”).show
// randomSplit(与RDD类似,将DF、DS按给定参数分成多份)
val df2 = df1.randomSplit(Array(0.5, 0.6, 0.7))
df2(0).count
df2(1).count
df2(2).count

// 取10行数据生成新的DataSet
val df2 = df1.limit(10)

// distinct,去重
val df2 = df1.union(df1)
df2.distinct.count

// dropDuplicates,按列值去重
df2.dropDuplicates.show
df2.dropDuplicates(“mgr”, “deptno”).show
df2.dropDuplicates(“mgr”).show
df2.dropDuplicates(“deptno”).show
// 返回全部列的统计(count、mean、stddev、min、max)
ds1.describe().show

// 返回指定列的统计
ds1.describe(“sal”).show
ds1.describe(“sal”, “comm”).show

Actions

df1.count

// 缺省显示20行
df1.union(df1).show()
// 显示2行
df1.show(2)
// 不截断字符
df1.toJSON.show(false)
// 显示10行,不截断字符
df1.toJSON.show(10, false)
spark.catalog.listFunctions.show(10000, false)

// collect返回的是数组, Array[org.apache.spark.sql.Row]
val c1 = df1.collect()

// collectAsList返回的是List, List[org.apache.spark.sql.Row]
val c2 = df1.collectAsList()

// 返回 org.apache.spark.sql.Row
val h1 = df1.head()
val f1 = df1.first()

// 返回 Array[org.apache.spark.sql.Row],长度为3
val h2 = df1.head(3)
val f2 = df1.take(3)

// 返回 List[org.apache.spark.sql.Row],长度为2
val t2 = df1.takeAsList(2)
// 结构属性
df1.columns // 查看列名
df1.dtypes // 查看列名和类型
df1.explain() // 参看执行计划
df1.col(“name”) // 获取某个列
df1.printSchema // 常用

select相关

// 列的多种表示方法(5种)。使用""、 " " 、 ′ 、 c o l ( ) 、 d s ( " " ) / / 注 意 : 不 要 混 用 ; 必 要 时 使 用 s p a r k . i m p l i c i t i s . ; 并 非 每 个 表 示 在 所 有 的 地 方 都 有 效 d f 1. s e l e c t ( ""、'、col()、ds("") // 注意:不要混用;必要时使用spark.implicitis._;并非每个表示在所有的地方都有效 df1.select( ""col()ds("")//:;使spark.implicitis.;df1.select(“ename”, $“hiredate”, $“sal”).show
df1.select(“ename”, “hiredate”, “sal”).show
df1.select('ename, 'hiredate, 'sal).show
df1.select(col(“ename”), col(“hiredate”), col(“sal”)).show
df1.select(df1(“ename”), df1(“hiredate”), df1(“sal”)).show

// 下面的写法无效,其他列的表示法有效
df1.select(“ename”, “hiredate”, “sal”+100).show
df1.select(“ename”, “hiredate”, “sal+100”).show

// 可使用expr表达式(expr里面只能使用引号)
df1.select(expr(“comm+100”), expr(“sal+100”), expr(“ename”)).show
df1.selectExpr(“ename as name”).show
df1.selectExpr(“power(sal, 2)”, “sal”).show
df1.selectExpr(“round(sal, -3) as newsal”, “sal”, “ename”).show
// drop、withColumn、 withColumnRenamed、casting
// drop 删除一个或多个列,得到新的DF
df1.drop(“mgr”)
df1.drop(“empno”, “mgr”)

// withColumn,修改列值
val df2 = df1.withColumn(“sal”, $“sal”+1000)
df2.show

// withColumnRenamed,更改列名
df1.withColumnRenamed(“sal”, “newsal”)

// 备注:drop、withColumn、withColumnRenamed返回的是DF

// cast,类型转换(cast是函数,hive中也有类似的函数,用法基本类似)
df1.selectExpr(“cast(empno as string)”).printSchema

import org.apache.spark.sql.types._
df1.select('empno.cast(StringType)).printSchema

where 相关

// where操作
df1.filter(“sal>1000”).show
df1.filter(“sal>1000 and job==‘MANAGER’”).show

// filter操作
df1.where(“sal>1000”).show
df1.where(“sal>1000 and job==‘MANAGER’”).show

groupBy 相关

// groupBy、max、min、mean、sum、count(与df1.count不同)
df1.groupBy(“Job”).sum(“sal”).show
df1.groupBy(“Job”).max(“sal”).show
df1.groupBy(“Job”).min(“sal”).show
df1.groupBy(“Job”).avg(“sal”).show
df1.groupBy(“Job”).count.show

// agg
df1.groupBy().agg(“sal”->“max”, “sal”->“min”, “sal”->“avg”, “sal”->“sum”, “sal”->“count”).show
df1.groupBy(“Job”).agg(“sal”->“max”, “sal”->“min”, “sal”->“avg”, “sal”->“sum”, “sal”->“count”).show

// 这种方式更好理解
df1.groupBy(“Job”).agg(max(“sal”), min(“sal”), avg(“sal”), sum(“sal”), count(“sal”)).show
// 给列取别名
df1.groupBy(“Job”).agg(max(“sal”), min(“sal”), avg(“sal”), sum(“sal”), count(“sal”)).withColumnRenamed(“min(sal)”, “min1”).show
// 给列取别名,最简便
df1.groupBy(“Job”).agg(max(“sal”).as(“max1”), min(“sal”).as(“min2”), avg(“sal”).as(“avg3”), sum(“sal”).as(“sum4”), count(“sal”).as(“count5”)).show

orderBy、sort 相关

// orderBy
df1.orderBy(“sal”).show
df1.orderBy( " s a l " ) . s h o w d f 1. o r d e r B y ( "sal").show df1.orderBy( "sal").showdf1.orderBy(“sal”.asc).show
df1.orderBy('sal).show
df1.orderBy(col(“sal”)).show
df1.orderBy(df1(“sal”)).show

df1.orderBy( " s a l " . d e s c ) . s h o w d f 1. o r d e r B y ( − ′ s a l ) . s h o w d f 1. o r d e r B y ( − ′ d e p t n o , − ′ s a l ) . s h o w / / s o r t , 以 下 语 句 等 价 d f 1. s o r t ( " s a l " ) . s h o w d f 1. s o r t ( "sal".desc).show df1.orderBy(-'sal).show df1.orderBy(-'deptno, -'sal).show // sort,以下语句等价 df1.sort("sal").show df1.sort( "sal".desc).showdf1.orderBy(sal).showdf1.orderBy(deptno,sal).show//sortdf1.sort("sal").showdf1.sort(“sal”).show
df1.sort($“sal”.asc).show
df1.sort('sal).show
df1.sort(col(“sal”)).show
df1.sort(df1(“sal”)).show

df1.sort($“sal”.desc).show
df1.sort(-'sal).show
df1.sort(-'deptno, -'sal).show

join 相关

// 1、笛卡尔积
df1.crossJoin(df1).count
// 2、等值连接(连接字段仅显示一次)
df1.join(df1, Seq(“empno”, “ename”)).show
ds1.join(ds2, “sname”).show
ds1.join(ds2, Seq(“sname”), “inner”).show
ds1.join(ds2, ds1(“sname”)===ds2(“sname”), “inner”).show
// 10种join的连接方式(下面有9种,还有一种是笛卡尔积)
ds1.join(ds2, “sname”).show
ds1.join(ds2, Seq(“sname”), “inner”).show

ds1.join(ds2, Seq(“sname”), “left”).show
ds1.join(ds2, Seq(“sname”), “left_outer”).show

ds1.join(ds2, Seq(“sname”), “right”).show
ds1.join(ds2, Seq(“sname”), “right_outer”).show

ds1.join(ds2, Seq(“sname”), “outer”).show
ds1.join(ds2, Seq(“sname”), “full”).show
ds1.join(ds2, Seq(“sname”), “full_outer”).show

// 类似于集合求交
ds1.join(ds2, Seq(“sname”), “left_semi”).show
// 类似于集合求差
ds1.join(ds2, Seq(“sname”), “left_anti”).show

备注:DS在join操作之后变成了DF

集合相关

// union、unionAll、intersect、except。集合的交、并、差
val ds3 = ds1.select(“sname”)
val ds4 = ds2.select(“sname”)

// union 求并集,不去重
ds3.union(ds4).show

// unionAll、union 等价;unionAll过期方法,不建议使用
ds3.unionAll(ds4).show

// intersect 求交
ds3.intersect(ds4).show

// except 求差
ds3.except(ds4).show

空值处理

// NaN 非法值
math.sqrt(-1.0); math.sqrt(-1.0).isNaN()

df1.show
// 删除所有列的空值和NaN
df1.na.drop.show

// 删除某列的空值和NaN
df1.na.drop(Array(“mgr”)).show

// 对全部列填充;对指定单列填充;对指定多列填充
df1.na.fill(1000).show
df1.na.fill(1000, Array(“comm”)).show
df1.na.fill(Map(“mgr”->2000, “comm”->1000)).show

// 对指定的值进行替换
df1.na.replace(“comm” :: “deptno” :: Nil, Map(0 -> 100, 10 -> 100)).show

// 查询空值列或非空值列。isNull、isNotNull为内置函数
df1.filter(“comm is null”).show
df1.filter($“comm”.isNull).show
df1.filter(col(“comm”).isNull).show

df1.filter(“comm is not null”).show
df1.filter(col(“comm”).isNotNull).show

时间日期函数

// 各种时间函数
df1.select(year( " h i r e d a t e " ) ) . s h o w d f 1. s e l e c t ( w e e k o f y e a r ( "hiredate")).show df1.select(weekofyear( "hiredate")).showdf1.select(weekofyear(“hiredate”)).show
df1.select(minute( " h i r e d a t e " ) ) . s h o w d f 1. s e l e c t ( d a t e a d d ( "hiredate")).show df1.select(date_add( "hiredate")).showdf1.select(dateadd(“hiredate”, 1), $“hiredate”).show
df1.select(current_date).show
df1.select(unix_timestamp).show

val df2 = df1.select(unix_timestamp as “unixtime”)
df2.select(from_unixtime($“unixtime”)).show

// 计算年龄
df1.select(round(months_between(current_date, $“hiredate”)/12)).show

json 数据源

// 读数据(txt、csv、json、parquet、jdbc)
val df2 = spark.read.json(“data/employees.json”)
df2.show
// 备注:SparkSQL中支持的json文件,文件内容必须在一行中

// 写文件
df1.select(“ename”, “sal”).write.format(“csv”).save(“data/t2”)
df1.select(“ename”, “sal”).write
.option(“header”, true)
.format(“csv”).save(“data/t2”)

DF、DS对象上的SQL语句

// 注册为临时视图。
// 有两种形式:createOrReplaceTempView / createTempView
df1.createOrReplaceTempView(“temp1”)
spark.sql(“select * from temp1”).show

df1.createTempView(“temp2”)
spark.sql(“select * from temp2”).show

// 使用下面的语句可以看见注册的临时表
spark.catalog.listTables.show

备注:
1、spark.sql返回的是DataFrame;
2、如果TempView已经存在,使用createTempView会报错;
3、SQL的语法与HQL兼容;

你可能感兴趣的:(spark)