来源 | Learning Spark Lightning-Fast Data Analytics,Second Edition
作者 | Damji,et al.
翻译 | 吴邪 大数据4年从业经验,目前就职于广州一家互联网公司,负责大数据基础平台自研、离线计算&实时计算研究
校对 | gongyouliu
编辑 | auroral-L
全文共6885字,预计阅读时间40分钟。
第三章 Apache Spark结构化API
4. Dataset API
4.1 有类型的对象、非类型的对象和通用行
4.2 创建DataSet
4.3 端到端的数据集示例
5. DataFrames VS Datasets
5.1 什么时候使用RDD
6. Spark SQL及基础引擎
6.1 优化器
7. 总结
在上一篇中,我们已经广泛讨论了DataFrame API。接下来,我们将把重点转移到Dataset API上,并探讨这两个API如何为Spark的开发者提供统一的结构化接口从而进行编程工作。然后,我们将比较RDD、DataFrame和Dataset API之间的关系,并帮助你确定何时使用哪个API以及原因。
4. Dataset API
如本章前面所述,Spark2.0将DataFrame和Dataset API统一为具有类似接口的结构化API,因此开发人员只需要学习一组API。数据集具有两个特性:类型化的和非类型化的API,如图3-1所示。
从概念上讲,你可以将Scala中的DataFrame视为通用对象集合Dataset[Row]的别名,其中Row是通用非类型JVM对象,可能包含不同类型的字段。相比之下,Dataset是Scala中或Java中的强类型JVM对象的集合。或者,如Dataset文档所说,Dataset是:
特定域对象的强类型集合,可以使用函数或关系操作进行转换。Scala中的每个Dataset都有一个称为DataFrame的无类型视图,它是一个Dataset Row。
4.1 有类型的对象、非类型的对象和通用行
在Spark支持的语言中,数据集只在Java和Scala中有意义,而在Python和R中,只有DataFrame有意义。这是因为Python和R不是编译时类型安全的;类型是在执行期间动态推断或分配的,而不是在编译时动态分配的。在Scala和Java中,情况正好相反:类型在编译时绑定到变量和对象。然而,在Scala中,DataFrame只是非类型Dataset[Row]的别名。表3-6简而言之。
Row是Spark中的通用对象类型,它包含可以使用索引访问的混合类型的集合。在内部,Spark会操作Row对象,并将它们转换为表3-2和表3-3中所涵盖的等效类型。例如,对于Scala或Java和Python,Row中的一个整数字段将分别映射或转换为整数类型:
// In Scala
import org.apache.spark.sql.Row
val row = Row(350, true, "Learning Spark 2E", null)
# In Python
from pyspark.sql import Row
row = Row(350, True, "Learning Spark 2E", None)
使用行对象的索引,可以使用公共getter方法访问各个字段:
// In Scala
row.getInt(0)
res23: Int = 350
row.getBoolean(1)
res24: Boolean = true
row.getString(2)
res25: String = Learning Spark 2E
# In Python
row[0]
Out[13]: 350
row[1]
Out[14]: True
row[2]
Out[15]: 'Learning Spark 2E'
相比之下,类型化对象是JVM中实际的Java或Scala类对象。数据集中的每个元素都映射到一个JVM对象。
4.2 创建DataSet
与从数据源创建DataFrame一样,在创建数据集时,你必须知道schema。换句话说,你需要了解数据类型。尽管使用JSON和CSV数据可以推断出schema,但对于大型数据集,这是资源密集型的(成本昂贵),非常消耗资源。在Scala中创建数据集时,为结果数据集指定schema最简单的方法是使用样例类(Case classes)。在Java中,使用JavaBean类(我们在第6章中进一步讨论JavaBean和Scala样例类)。
Scala: 样例类(Case classes)
当你希望将自己的域中特定的对象实例化为数据集时,你可以通过在Scala中定义一个样例类来实例化。作为一个例子,让我们查看JSON文件中从物联网设备读取的集合(我们在本节后面的端到端示例中使用此文件)。
我们的文件有几行JSON字符串,外观如下:
{"device_id": 198164, "device_name": "sensor-pad-198164owomcJZ", "ip":
"80.55.20.25", "cca2": "PL", "cca3": "POL", "cn": "Poland", "latitude":
53.080000, "longitude": 18.620000, "scale": "Celsius", "temp": 21,
"humidity": 65, "battery_level": 8, "c02_level": 1408,"lcd": "red",
"timestamp" :1458081226051}
要将每个JSON条目表示为DeviceIoTData,一种特定领域的对象,我们可以定义一个Scala样例类:
case class DeviceIoTData (battery_level: Long, c02_level: Long,
cca2: String, cca3: String, cn: String, device_id: Long,
device_name: String, humidity: Long, ip: String, latitude: Double,
lcd: String, longitude: Double, scale:String, temp: Long,
timestamp: Long)
一旦定义,我们可以使用它读取文件并将返回的内容Dataset[Row]转换为Dataset[DeviceIoTData](输出被截断以适合页面):
// In Scala
val ds = spark.read
.json("/databricks-datasets/learning-spark-v2/iot-devices/iot_devices.json")
.as[DeviceIoTData]
ds: org.apache.spark.sql.Dataset[DeviceIoTData] = [battery_level...]
ds.show(5, false)
+-------------|---------|----|----|-------------|---------|---+
|battery_level|c02_level|cca2|cca3|cn |device_id|...|
+-------------|---------|----|----|-------------|---------|---+
|8 |868 |US |USA |United States|1 |...|
|7 |1473 |NO |NOR |Norway |2 |...|
|2 |1556 |IT |ITA |Italy |3 |...|
|6 |1080 |US |USA |United States|4 |...|
|4 |931 |PH |PHL |Philippines |5 |...|
+-------------|---------|----|----|-------------|---------|---+
only showing top 5 rows
Dataset操作
就像你可以在DataFrame上执行转换和操作一样,你也可以使用数据集。根据操作类型的不同,操作结果将会有所不同:
// In Scala
val filterTempDS = ds.filter({d => {d.temp > 30 && d.humidity > 70})
filterTempDS: org.apache.spark.sql.Dataset[DeviceIoTData] = [battery_level...]
filterTempDS.show(5, false)
+-------------|---------|----|----|-------------|---------|---+
|battery_level|c02_level|cca2|cca3|cn |device_id|...|
+-------------|---------|----|----|-------------|---------|---+
|0 |1466 |US |USA |United States|17 |...|
|9 |986 |FR |FRA |France |48 |...|
|8 |1436 |US |USA |United States|54 |...|
|4 |1090 |US |USA |United States|63 |...|
|4 |1072 |PH |PHL |Philippines |81 |...|
+-------------|---------|----|----|-------------|---------|---+
only showing top 5 rows
在此查询中,我们使用一个函数作为数据集方法filter()的参数。这是一个具有很多签名的重载方法。我们使用的版本采用filter(func: (T) > Boolean): Dataset[T] lambda函数func: (T) > Boolean作为参数。
lambda函数的参数是类型为DeviceIoTData的JVM对象。这样,我们可以使用点(.)表示法访问其各个数据字段,就像在Scala类或JavaBean中一样。
另一件需要注意的事情是,对于DataFrame,你将filter()条件表示为类似SQL的DSL操作,这些操作是与语言无关的(正如我们前面在fire call示例中看到的)。对于数据集,我们利用原生语言的表达式作为Scala或Java代码。
下面是另一个较小数据集的例子:
// In Scala
case class DeviceTempByCountry(temp: Long, device_name: String, device_id: Long,
cca3: String)
val dsTemp = ds
.filter(d => {d.temp > 25})
.map(d => (d.temp, d.device_name, d.device_id, d.cca3))
.toDF("temp", "device_name", "device_id", "cca3")
.as[DeviceTempByCountry]
dsTemp.show(5, false)
+----+---------------------+---------+----+
|temp|device_name |device_id|cca3|
+----+---------------------+---------+----+
|34 |meter-gauge-1xbYRYcj |1 |USA |
|28 |sensor-pad-4mzWkz |4 |USA |
|27 |sensor-pad-6al7RTAobR|6 |USA |
|27 |sensor-pad-8xUD6pzsQI|8 |JPN |
|26 |sensor-pad-10BsywSYUF|10 |USA |
+----+---------------------+---------+----+
only showing top 5 rows
或者,你只能检查数据集的第一行:
val device = dsTemp.first()
println(device)
device: DeviceTempByCountry =
DeviceTempByCountry(34,meter-gauge-1xbYRYcj,1,USA)
另外,你可以使用列名表达相同的查询,然后强制转换为Dataset[DeviceTempByCountry]:
// In Scala
val dsTemp2 = ds
.select($"temp", $"device_name", $"device_id", $"device_id", $"cca3")
.where("temp > 25")
.as[DeviceTempByCountry]
在语义上,select()类似于上一个查询中的map(),这两个查询都会选择字段并生成等效的结果。
总的来说,我们可以在数据集上执行filter(),map(),groupBy(),select(),take()这些操作,与DataFrame上的操作相似。在某种程度上,数据集与RDD相似,因为它们提供了与上述方法类似的接口以及编译时安全性,但具有更容易读取和面向对象的编程接口。
当我们使用数据集时,底层的Spark SQL引擎会处理JVM对象的创建、控制版本、序列化和反序列化。它还借助数据集编码器来处理Java外堆内存管理。(我们将在第6章中讨论更多关于数据集和内存管理的内容。)
4.3 端到端的数据集示例
在此端到端数据集示例中,你将使用物联网数据集进行类似DataFrame示例中的使用的数据分析、ETL(提取、转换和加载)和数据操作。这个数据集很小而且还是伪造的,但我们这里的主要目的是说明使用数据集表达查询的清晰度以及这些查询的可读性,就像我们用DataFrame一样。
同样,为了简洁起见,我们不会在这里涵盖所有的示例代码;但是,我们已经在GitHub仓库中添加了这个手册。手册中介绍了你可能使用此数据集执行的常见操作。使用数据集API,我们将尝试执行以下操作:
1.检测电池电量低于阈值的故障设备。
2.确定二氧化碳排放量较高水平的违规国家。
3.计算温度、电池电量、二氧化碳和湿度的最小值和最大值。
4.按平均温度、二氧化碳、湿度和国家进行排序和分组。
5. DataFrames Vs Datasets
到目前为止,你可能知道为什么以及何时应该使用DataFrame或Dataset。在许多情况下,两者都可以做到,这取决于你使用的语言,但在某些情况下,一种语言比另一种语言更加可取。下面有几个例子:
如果要告诉Spark要做什么,而不是如何做,请使用DataFrame或Dataset。
如果你需要丰富的语义、高级抽象和DSL运算符,请使用DataFrame或Dataset。
如果你希望实现严格的编译时类型安全性,并且不介意为特定的Dataset[T]创建多个样例类,请使用“Dataset”。
如果处理需要高级表达式、过滤器、映射、聚合、计算平均值或和、SQL查询、列访问或在半结构化数据上使用关系运算符,请使用DataFrame或Dataset。
如果你的处理要求类似于SQL的查询的关系转换,请使用DataFrame。
如果你想利用Tungsten的高效编码器序列化,并从中受益,请使用“Dataset”。
如果希望跨Spark组件进行统一、代码优化和API简化,请使用DataFrame。
如果你是R用户,请使用DataFrame。
如果你是Python用户,请使用DataFrame,如果需要更多的控制,请使用RDD。
如果你需要空间和速度效率,请使用DataFrame。
如果希望在编译期间而不是在运行时捕获错误,请选择相应的API,如图3-2所示。
5.1 什么时候使用RDD
你可能会问:RDD是否被降级为二等公民?他们已经被召回了吗?答案是非常明确的,“没有”!RDD API将继续得到支持,尽管Spark2.x和Spark3.x中进行的所有开发工作都继续保持DataFrame接口和语义,而不是使用RDD,但仍将继续支持RDD API。
在某些情况下,你需要考虑使用RDD,例如:
是否使用了使用RDD编写的第三方软件包。
可以放弃代码优化、有效利用的空间以及DataFrame和Dataset所提供的性能优势。
想要精确地指示Spark如何执行查询。
此外,你还可以使用简单的API方法调用df.rdd在DataFrame、Dataset和RDD之间进行无缝切换。(但是,请注意,切换是需要成本的,除非有必要,否则应该避免。)毕竟,DataFrame和Dataset是建立在RDD之上,它们在整个测试阶段的代码生成过程中被分解为紧凑的RDD代码,我们将在下一节中讨论。
最后,前面部分提供了关于Spark中的结构化API如何使开发人员能够使用简单、友好的API来编写对结构化数据进行丰富的查询的洞察。换句话说,你使用高级操作告诉Spark该做什么,而不是怎么做,并且它确定了为你构建查询和生成紧凑代码的最有效的方法。
构建高效查询和生成紧凑代码的过程是Spark SQL引擎的工作。这是我们一直在研究的构建结构化API基础。现在让我们来看看该引擎的内幕。
6. Spark SQL及基础引擎
在编程级别上,Spark SQL允许开发人员对具有模式的结构化数据发出与ANSI SQL:2003兼容的查询。自从在Spark1.3中引入以来,Spark SQL已经发展成为一个强大的引擎,在此基础上建立了许多高级的结构化功能。除了允许你对数据发出类似SQL的查询外,Spark SQL引擎还包括:
统一Spark组件,并允许抽象为Java、Scala、Python和R中的DataFrame/Dataset,这简化了对结构化数据集的工作。
连接到Apache Hive元存储库和表。
从结构化文件(JSON、CSV、文本、CSV、拼花、ORC等)读写具有特定schema的结构化数据。并将数据转换为临时表。
提供交互式Spark SQL Shell支持快速数据浏览。
通过标准数据库JDBC/ODBC连接器提供与外部工具之间的桥梁。
为JVM生成优化的查询计划和紧凑的代码,以便最终执行。
图3-3显示了与Spark SQL交互以实现所有这些目标的组件。
Spark SQL引擎的核心是Catalyst优化器和Project Tungsten。它们一起支持高级DataFrame、Dataset API和SQL查询。我们将在第六章中讨论Tungsten,现在,让我们仔细看看优化器。
优化器
Catalyst优化器接受计算查询,并将其转换为一个执行计划。它经历了四个转换阶段,如图3-4所示:
1.分析
2.辑优化
3.物理规划
4.代码生成
例如,考虑第2章中M&M示例中的一个查询。以下两个示例代码块将经历相同的过程,最终会执行类似的查询计划和相同的字节码。也就是说,无论你使用什么语言,你的计算都会经历相同的过程,而得到的字节码很可能是相同的:
# In Python
count_mnm_df = (mnm_df
.select("State", "Color", "Count")
.groupBy("State", "Color")
.agg(count("Count")
.alias("Total"))
.orderBy("Total", ascending=False))
-- In SQL
SELECT State, Color, Count, sum(Count) AS Total
FROM MNM_TABLE_NAME
GROUP BY State, Color, Count
ORDER BY Total DESC
要查看Python代码所经历的不同阶段,可以在DataFrame上使用count_mnm_df.explain(True)方法。或者,要查看不同的逻辑和物理计划,在Scala中可以调用df.queryExecution.logical或df.queryExecution.optimizedPlan。(在第7章中,我们将讨论关于调整和调试Spark以及如何读取查询计划。)这给我们提供了以下输出:
count_mnm_df.explain(True)
== Parsed Logical Plan ==
'Sort ['Total DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
+- Project [State#10, Color#11, Count#12]
+- Relation[State#10,Color#11,Count#12] csv
== Analyzed Logical Plan ==
State: string, Color: string, Total: bigint
Sort [Total#24L DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
+- Project [State#10, Color#11, Count#12]
+- Relation[State#10,Color#11,Count#12] csv
== Optimized Logical Plan ==
Sort [Total#24L DESC NULLS LAST], true
+- Aggregate [State#10, Color#11], [State#10, Color#11, count(Count#12) AS...]
+- Relation[State#10,Color#11,Count#12] csv
== Physical Plan ==
*(3) Sort [Total#24L DESC NULLS LAST], true, 0
+- Exchange rangepartitioning(Total#24L DESC NULLS LAST, 200)
+- *(2) HashAggregate(keys=[State#10, Color#11], functions=[count(Count#12)],
output=[State#10, Color#11, Total#24L])
+- Exchange hashpartitioning(State#10, Color#11, 200)
+- *(1) HashAggregate(keys=[State#10, Color#11],
functions=[partial_count(Count#12)], output=[State#10, Color#11, count#29L])
+- *(1) FileScan csv [State#10,Color#11,Count#12] Batched: false,
Format: CSV, Location:
InMemoryFileIndex[file:/Users/jules/gits/LearningSpark2.0/chapter2/py/src/...
dataset.csv], PartitionFilters: [], PushedFilters: [], Read数据结构(schema):
struct<State:string,Color:string,Count:int>
让我们考虑另一个DataFrame计算示例。随着底层引擎优化其逻辑和物理计划,以下Scala代码也经历了类似的过程:
// In Scala
// Users DataFrame read from a Parquet table
val usersDF = ...
// Events DataFrame read from a Parquet table
val eventsDF = ...
// Join two DataFrames
val joinedDF = users
.join(events, users("id") === events("uid"))
.filter(events("date") > "2015-01-01")
经过初始分析阶段后,查询计划由Catalyst优化器进行转换和重新排列,如图3-5所示。
让我们先分析这四个查询优化阶段。
阶段1:分析
Spark SQL引擎首先会为SQL或DataFrame查询生成一个抽象语法树(AST)。在此初始阶段,任何列或表名都将会被解析为内部的Catalog,catalog是一个指向Spark SQL的编程接口,该接口包含列、数据类型、函数、表、数据库、列名等等的列表。一旦全部成功解决,查询将继续进入下一阶段。
阶段2:逻辑优化
如图3-4所示,该阶段包括两个内部阶段。应用基于标准化的优化方法,Catalyst优化器将首先构建一组多个计划,然后使用其基于成本的优化器(CBO)将成本分配给每个计划。这些计划展示为算子树的形式(如图3-5);例如,它们可能包括常数折叠、谓词下推、投影计算、布尔表达式简化等过程。这个逻辑计划是对物理计划的输入。
阶段3:物理执行计划
在此阶段,Spark SQL使用与Spark执行引擎相匹配的物理运算符,为所选的逻辑计划生成最佳的物理计划。
阶段4:代码生成
查询优化的最后阶段涉及生成在每台机器上运行的高效Java字节码。因为Spark SQL可以对内存中加载的数据集进行操作,所以Spark可以使用最先进的编译器技术来生成代码以加快执行速度。换句话说,它充当了编译器。Tungsten项目在这里发挥了重要作用,是整个阶段代码生成的利器。
整个阶段的代码生成是什么呢?这是一个物理查询优化阶段,它将整个查询分解成一个函数,摆脱虚拟函数调用,并使用CPU寄存器存储中间数据。Spark2.0中引入的第二代Tungsten引擎使用此方法生成紧凑的RDD代码以便最终执行。这种精简的策略显著提高了CPU的效率和性能。
我们已经在概念层面上讨论了Spark SQL引擎的工作原理,其中包括其两个主要组件:Catalyst优化器和Tungsten项目。内部的技术工作不在本书的讨论范围之内;然而,对于好奇的读者,我们建议你查看文本中的参考资料,以进行深入的技术讨论。
7. 总结
在本章中,我们深入研究了Spark的结构化API,了解了Spark的结构化历史和优点。
通过说明性的常见数据操作和代码示例,我们证明了高级的DataFrame和Dataset API比低级的RDD API更具表达力和直观性。结构化API旨在简化大型数据集的处理,为通用数据操作提供了特定领域的运算符,提高了代码的清晰度和表达性。
我们根据你使用的用例场景,探讨了何时使用RDD、DataFrame和Dataset。
最后,我们深入了解了Spark SQL引擎的主要组件(Catalyst优化器和Project Tungesten)如何支持结构化高级API和DSL运算符。正如你所看到的,无论你使用哪种Spark支持的语言,Spark查询都会经历相同的优化过程,从逻辑和物理计划构建到最终紧凑代码的生成。
本章中的概念和代码示例为接下来的两章奠定了基础,其中我们将进一步说明DataFrame、Dataset和Spark SQL之间的无缝互操作性。
可以从https://oreil.ly/iDzQK获得此公共数据。
原始数据集有60列以上。我们删除了一些不必要的列,删除了具有空值或无效值的记录,并添加了一个额外的Delay列。