Scala 和 Spark 大数据分析(四)
原文:
annas-archive.org/md5/39eecc62e023387ee8c22ca10d1a221a译者:飞龙
第八章:简介结构 - Spark SQL
“一台机器可以做五十个普通人能做的工作,但没有一台机器能做一个非凡人能做的工作。”
- 埃尔伯特·哈伯德
在本章中,你将学习如何使用 Spark 来分析结构化数据(如需将无结构数据,比如包含任意文本或其他格式的文档,转换为结构化形式);我们将看到 DataFrames/datasets 在这里是基础,Spark SQL 的 API 如何使查询结构化数据既简单又强大。此外,我们还会介绍 datasets,并探讨 datasets、DataFrames 和 RDDs 之间的区别。总的来说,本章将涵盖以下主题:
-
Spark SQL 和 DataFrames
-
DataFrame 和 SQL API
-
DataFrame 模式
-
数据集和编码器
-
数据加载和保存
-
聚合操作
-
连接操作
Spark SQL 和 DataFrames
在 Apache Spark 之前,Apache Hive 是每当需要对大量数据运行 SQL 类似查询时的首选技术。Apache Hive 实质上将 SQL 查询转换为类似 MapReduce 的逻辑,从而自动使得执行各种大数据分析变得非常简单,而不需要学习编写复杂的 Java 和 Scala 代码。
随着 Apache Spark 的出现,我们在大数据规模上执行分析的方式发生了范式转变。Spark SQL 提供了一个易于使用的 SQL 类似层,建立在 Apache Spark 的分布式计算能力之上。实际上,Spark SQL 可以作为一个在线分析处理数据库使用。
Spark SQL 的工作原理是通过将 SQL 类似语句解析为抽象语法树(AST),随后将该计划转换为逻辑计划,并优化逻辑计划为可执行的物理计划。最终执行使用底层的 DataFrame API,这使得任何人都可以通过使用类似 SQL 的接口而不是学习所有内部实现,轻松使用 DataFrame API。由于本书深入探讨了各种 API 的技术细节,我们将主要介绍 DataFrame API,并在某些地方展示 Spark SQL API,以对比不同的 API 使用方式。
因此,DataFrame API 是 Spark SQL 底层的基础层。在本章中,我们将展示如何使用各种技术创建 DataFrames,包括 SQL 查询和对 DataFrame 执行操作。
DataFrame 是 弹性分布式数据集(RDD)的抽象,处理通过 Catalyst 优化器优化的更高级功能,并且通过 Tungsten Initiative 实现高性能。你可以将数据集看作是 RDD 的高效表格,并具有经过优化的二进制数据表示。二进制表示是通过编码器实现的,编码器将各种对象序列化为二进制结构,从而提供比 RDD 表示更好的性能。由于 DataFrame 内部无论如何都使用 RDD,因此 DataFrame/数据集也像 RDD 一样分布式,因此它也是一个分布式数据集。显然,这也意味着数据集是不可变的。
以下是数据的二进制表示示意图:
数据集(datasets)在 Spark 1.6 中被添加,并在 DataFrame 之上提供强类型的好处。事实上,从 Spark 2.0 开始,DataFrame 实际上是数据集的别名。
org.apache.spark.sql 将类型 DataFrame 定义为 dataset[Row],这意味着大多数 API 可以很好地与数据集和 DataFrame 配合使用。
type DataFrame = dataset[Row]
DataFrame 在概念上类似于关系数据库中的表。因此,DataFrame 包含数据行,每行由多个列组成。
我们需要记住的第一件事是,像 RDD 一样,DataFrame 也是不可变的。DataFrame 的这种不可变性意味着每个转换或操作都会创建一个新的 DataFrame。
让我们开始深入了解 DataFrame 以及它们如何与 RDD 不同。正如之前所见,RDD 是 Apache Spark 中的数据操作的低级 API。DataFrame 是在 RDD 之上创建的,目的是抽象化 RDD 的低级内部工作原理,并暴露出更高层次的 API,这些 API 更易于使用,并且提供了大量开箱即用的功能。DataFrame 的创建遵循了类似于 Python pandas 包、R 语言、Julia 语言等中的概念。
正如我们之前提到的,DataFrame 将 SQL 代码和特定领域语言表达式转换为优化执行计划,这些计划将在 Spark Core API 上运行,以便 SQL 语句能够执行各种操作。DataFrame 支持多种不同类型的输入数据源和操作类型。包括所有类型的 SQL 操作,如连接、分组、聚合和窗口函数,类似于大多数数据库。Spark SQL 也非常类似于 Hive 查询语言,并且由于 Spark 提供了与 Apache Hive 的自然适配器,已经在 Apache Hive 中工作的用户可以轻松地将他们的知识转移到 Spark SQL,从而最大限度地减少过渡时间。
DataFrames 本质上依赖于表的概念,正如之前所见。可以非常类似于 Apache Hive 的方式操作表。事实上,Apache Spark 中对表的许多操作与 Apache Hive 处理表及操作表的方式非常相似。一旦有了作为 DataFrame 的表,可以将 DataFrame 注册为表,并且可以使用 Spark SQL 语句操作数据,而不是使用 DataFrame API。
DataFrames 依赖于催化剂优化器和钨丝性能改进,因此让我们简要地看一下催化剂优化器是如何工作的。催化剂优化器从输入的 SQL 创建一个解析后的逻辑计划,然后通过查看 SQL 语句中使用的所有各种属性和列来分析这个逻辑计划。一旦分析完逻辑计划,催化剂优化器进一步尝试通过合并多个操作和重新排列逻辑来优化计划,以获得更好的性能。
要理解催化剂优化器,可以将其看作是一个常识逻辑优化器,它可以重新排序诸如过滤器和转换等操作,有时将多个操作组合成一个操作,以尽量减少在工作节点之间传输的数据量。例如,催化剂优化器可能决定在不同数据集之间执行联合操作时广播较小的数据集。使用 explain 查看任何数据框的执行计划。催化剂优化器还计算 DataFrame 的列和分区的统计信息,提高执行速度。
例如,如果数据分区上有转换和过滤操作,则过滤数据和应用转换的顺序对操作的整体性能至关重要。由于所有优化,生成了优化的逻辑计划,然后将其转换为物理计划。显然,有几个物理计划可以执行相同的 SQL 语句并生成相同的结果。成本优化逻辑根据成本优化和估算选择一个好的物理计划。
钨丝性能改进是 Spark 2.x 提供的显著性能改进背后的秘密武器之一,与之前的版本如 Spark 1.6 和更早的版本相比。钨丝实现了对内存管理和其他性能改进的完全改造。最重要的内存管理改进使用对象的二进制编码,并在堆外和堆内存中引用它们。因此,钨丝允许使用二进制编码机制来编码所有对象以使用办公堆内存。二进制编码的对象占用的内存要少得多。项目钨丝还改进了洗牌性能。
通常通过 DataFrameReader 将数据加载到 DataFrames 中,并通过 DataFrameWriter 从 DataFrames 中保存数据。
DataFrame API 和 SQL API
创建数据框可以通过多种方式进行:
-
通过执行 SQL 查询
-
加载外部数据,如 Parquet、JSON、CSV、文本、Hive、JDBC 等
-
将 RDD 转换为数据框
可以通过加载 CSV 文件来创建一个数据框。我们将查看一个名为statesPopulation.csv的 CSV 文件,它被加载为一个数据框。
该 CSV 文件包含 2010 年到 2016 年间美国各州的人口数据格式。
| 州 | 年份 | 人口 |
|---|---|---|
| 阿拉巴马州 | 2010 | 4785492 |
| 阿拉斯加州 | 2010 | 714031 |
| 亚利桑那州 | 2010 | 6408312 |
| 阿肯色州 | 2010 | 2921995 |
| 加利福尼亚州 | 2010 | 37332685 |
由于此 CSV 文件包含标题行,我们可以使用它快速加载到数据框中,并进行隐式的模式检测。
scala> val statesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]
一旦数据框加载完成,就可以检查其模式:
scala> statesDF.printSchema
root
|-- State: string (nullable = true)
|-- Year: integer (nullable = true)
|-- Population: integer (nullable = true)
option("header", "true").option("inferschema", "true").option("sep", ",")告诉 Spark,CSV 文件包含header,使用逗号作为分隔符,并且可以隐式推断模式。
数据框通过解析逻辑计划、分析逻辑计划、优化计划,然后最终执行物理执行计划来工作。
使用explain命令对数据框进行查看,可以显示执行计划:
scala> statesDF.explain(true)
== Parsed Logical Plan ==
Relation[State#0,Year#1,Population#2] csv
== Analyzed Logical Plan ==
State: string, Year: int, Population: int
Relation[State#0,Year#1,Population#2] csv
== Optimized Logical Plan ==
Relation[State#0,Year#1,Population#2] csv
== Physical Plan ==
*FileScan csv [State#0,Year#1,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<State:string,Year:int,Population:int>
数据框也可以注册为表名(如下所示),这将允许你像关系型数据库一样编写 SQL 语句。
scala> statesDF.createOrReplaceTempView("states")
一旦我们获得了结构化的数据框或表格,我们就可以运行命令来对数据进行操作:
scala> statesDF.show(5)
scala> spark.sql("select * from states limit 5").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
| Alabama|2010| 4785492|
| Alaska|2010| 714031|
| Arizona|2010| 6408312|
| Arkansas|2010| 2921995|
|California|2010| 37332685|
+----------+----+----------+
如果你查看前面的代码片段,我们已经编写了一个类似 SQL 的语句,并使用spark.sql API 执行它。
请注意,Spark SQL 实际上是转换为数据框 API 进行执行,SQL 只是一个简化使用的领域特定语言(DSL)。
使用数据框上的sort操作,你可以按任何列对数据框中的行进行排序。以下是使用Population列进行降序sort操作的效果。行将按照人口的降序进行排序。
scala> statesDF.sort(col("Population").desc).show(5)
scala> spark.sql("select * from states order by Population desc limit 5").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
|California|2016| 39250017|
|California|2015| 38993940|
|California|2014| 38680810|
|California|2013| 38335203|
|California|2012| 38011074|
+----------+----+----------+
使用groupBy我们可以按任何列对数据框进行分组。以下是按State分组行,然后为每个State的Population数量求和的代码。
scala> statesDF.groupBy("State").sum("Population").show(5)
scala> spark.sql("select State, sum(Population) from states group by State limit 5").show
+---------+---------------+
| State|sum(Population)|
+---------+---------------+
| Utah| 20333580|
| Hawaii| 9810173|
|Minnesota| 37914011|
| Ohio| 81020539|
| Arkansas| 20703849|
+---------+---------------+
使用agg操作,你可以对数据框中的列执行多种不同的操作,例如查找列的min、max和avg。你还可以同时执行操作并重命名列,以适应你的用例。
scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).show(5)
scala> spark.sql("select State, sum(Population) as Total from states group by State limit 5").show
+---------+--------+
| State| Total|
+---------+--------+
| Utah|20333580|
| Hawaii| 9810173|
|Minnesota|37914011|
| Ohio|81020539|
| Arkansas|20703849|
+---------+--------+
自然,逻辑越复杂,执行计划也越复杂。让我们查看之前groupBy和agg API 调用的执行计划,以更好地理解背后发生了什么。以下是显示按State分组并对人口进行求和的执行计划的代码:
scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).explain(true)
== Parsed Logical Plan ==
'Aggregate [State#0], [State#0, sum('Population) AS Total#31886]
+- Relation[State#0,Year#1,Population#2] csv
== Analyzed Logical Plan ==
State: string, Total: bigint
Aggregate [State#0], [State#0, sum(cast(Population#2 as bigint)) AS Total#31886L]
+- Relation[State#0,Year#1,Population#2] csv
== Optimized Logical Plan ==
Aggregate [State#0], [State#0, sum(cast(Population#2 as bigint)) AS Total#31886L]
+- Project [State#0, Population#2]
+- Relation[State#0,Year#1,Population#2] csv
== Physical Plan ==
*HashAggregate(keys=[State#0], functions=[sum(cast(Population#2 as bigint))], output=[State#0, Total#31886L])
+- Exchange hashpartitioning(State#0, 200)
+- *HashAggregate(keys=[State#0], functions=[partial_sum(cast(Population#2 as bigint))], output=[State#0, sum#31892L])
+- *FileScan csv [State#0,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [], ReadSchema: struct<State:string,Population:int>
数据框操作可以很好地链式连接,这样执行就能利用成本优化(Tungsten 性能改进和 Catalyst 优化器协同工作)。
我们还可以将操作链式连接在一个语句中,如下所示,其中我们不仅按 State 列对数据进行分组并汇总 Population 值,还按汇总列对 DataFrame 进行排序:
scala> statesDF.groupBy("State").agg(sum("Population").alias("Total")).sort(col("Total").desc).show(5)
scala> spark.sql("select State, sum(Population) as Total from states group by State order by Total desc limit 5").show
+----------+---------+
| State| Total|
+----------+---------+
|California|268280590|
| Texas|185672865|
| Florida|137618322|
| New York|137409471|
| Illinois| 89960023|
+----------+---------+
上述链式操作包含了多个转换和操作,可以通过以下图表来可视化:
也可以同时创建多个聚合,如下所示:
scala> statesDF.groupBy("State").agg(
min("Population").alias("minTotal"),
max("Population").alias("maxTotal"),
avg("Population").alias("avgTotal"))
.sort(col("minTotal").desc).show(5)
scala> spark.sql("select State, min(Population) as minTotal, max(Population) as maxTotal, avg(Population) as avgTotal from states group by State order by minTotal desc limit 5").show
+----------+--------+--------+--------------------+
| State|minTotal|maxTotal| avgTotal|
+----------+--------+--------+--------------------+
|California|37332685|39250017|3.8325798571428575E7|
| Texas|25244310|27862596| 2.6524695E7|
| New York|19402640|19747183| 1.962992442857143E7|
| Florida|18849098|20612439|1.9659760285714287E7|
| Illinois|12801539|12879505|1.2851431857142856E7|
+----------+--------+--------+--------------------+
数据透视
数据透视是一种很好的方法,可以将表格转化为不同的视图,更适合进行多项汇总和聚合。这是通过获取列的值,并将每个值转化为一个实际的列来实现的。
为了更好地理解这一点,让我们按 Year 对 DataFrame 的行进行数据透视,并查看结果。这显示现在 Year 列通过将每个唯一值转换为一个实际的列,创建了几个新的列。最终结果是,现在我们不仅仅查看年份列,而是可以使用每年的列来按 Year 进行汇总和聚合。
scala> statesDF.groupBy("State").pivot("Year").sum("Population").show(5)
+---------+--------+--------+--------+--------+--------+--------+--------+
| State| 2010| 2011| 2012| 2013| 2014| 2015| 2016|
+---------+--------+--------+--------+--------+--------+--------+--------+
| Utah| 2775326| 2816124| 2855782| 2902663| 2941836| 2990632| 3051217|
| Hawaii| 1363945| 1377864| 1391820| 1406481| 1416349| 1425157| 1428557|
|Minnesota| 5311147| 5348562| 5380285| 5418521| 5453109| 5482435| 5519952|
| Ohio|11540983|11544824|11550839|11570022|11594408|11605090|11614373|
| Arkansas| 2921995| 2939493| 2950685| 2958663| 2966912| 2977853| 2988248|
+---------+--------+--------+--------+--------+--------+--------+--------+
过滤器
DataFrame 还支持过滤器(Filters),可以快速过滤 DataFrame 行并生成新的 DataFrame。过滤器使数据的转换变得非常重要,可以将 DataFrame 精简到我们的使用场景。例如,如果你只想分析加利福尼亚州的情况,那么使用 filter API 可以在每个数据分区上执行非匹配行的删除,从而提高操作的性能。
让我们查看过滤 DataFrame 的执行计划,以仅考虑加利福尼亚州的情况。
scala> statesDF.filter("State == 'California'").explain(true)
== Parsed Logical Plan ==
'Filter ('State = California)
+- Relation[State#0,Year#1,Population#2] csv
== Analyzed Logical Plan ==
State: string, Year: int, Population: int
Filter (State#0 = California)
+- Relation[State#0,Year#1,Population#2] csv
== Optimized Logical Plan ==
Filter (isnotnull(State#0) && (State#0 = California))
+- Relation[State#0,Year#1,Population#2] csv
== Physical Plan ==
*Project [State#0, Year#1, Population#2]
+- *Filter (isnotnull(State#0) && (State#0 = California))
+- *FileScan csv [State#0,Year#1,Population#2] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/states.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State), EqualTo(State,California)], ReadSchema: struct<State:string,Year:int,Population:int>
现在我们可以看到执行计划,接下来执行 filter 命令,如下所示:
scala> statesDF.filter("State == 'California'").show
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
|California|2010| 37332685|
|California|2011| 37676861|
|California|2012| 38011074|
|California|2013| 38335203|
|California|2014| 38680810|
|California|2015| 38993940|
|California|2016| 39250017|
+----------+----+----------+
用户自定义函数(UDFs)
UDF 定义了基于列的新的函数,扩展了 Spark SQL 的功能。通常,Spark 中内置的函数无法处理我们所需的精确功能。在这种情况下,Apache Spark 支持创建 UDF,我们可以使用它们。
udf() 内部调用了一个案例类用户自定义函数,它内部又调用了 ScalaUDF。
让我们通过一个示例来了解一个简单的用户自定义函数(UDF),该函数将 State 列的值转换为大写。
首先,我们在 Scala 中创建所需的函数。
import org.apache.spark.sql.functions._
scala> val toUpper: String => String = _.toUpperCase
toUpper: String => String = <function1>
然后,我们必须将创建的函数封装在 udf 中,以创建 UDF。
scala> val toUpperUDF = udf(toUpper)
toUpperUDF: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType)))
现在我们已经创建了 udf,可以使用它将 State 列转换为大写。
scala> statesDF.withColumn("StateUpperCase", toUpperUDF(col("State"))).show(5)
+----------+----+----------+--------------+
| State|Year|Population|StateUpperCase|
+----------+----+----------+--------------+
| Alabama|2010| 4785492| ALABAMA|
| Alaska|2010| 714031| ALASKA|
| Arizona|2010| 6408312| ARIZONA|
| Arkansas|2010| 2921995| ARKANSAS|
|California|2010| 37332685| CALIFORNIA|
+----------+----+----------+--------------+
架构 数据的结构
架构是数据结构的描述,可以是隐式的或显式的。
由于 DataFrame 内部基于 RDD,因此将现有 RDD 转换为数据集有两种主要方法。可以通过反射推断 RDD 的架构,从而将 RDD 转换为数据集。创建数据集的第二种方法是通过编程接口,使用该接口可以提供现有的 RDD 和架构,将 RDD 转换为带架构的数据集。
为了通过反射推断模式从 RDD 创建 DataFrame,Spark 的 Scala API 提供了案例类,可以用来定义表的模式。DataFrame 是通过程序化方式从 RDD 创建的,因为在所有情况下使用案例类并不容易。例如,在一个有 1000 列的表上创建案例类会非常耗时。
隐式模式
让我们来看一个将 CSV(逗号分隔值)文件加载到 DataFrame 中的例子。每当文本文件包含表头时,读取 API 可以通过读取表头行来推断模式。我们也可以指定用于分隔文本文件行的分隔符。
我们读取 csv 文件,通过表头推断模式,并使用逗号(,) 作为分隔符。我们还展示了 schema 命令和 printSchema 命令的使用,来验证输入文件的模式。
scala> val statesDF = spark.read.option("header", "true")
.option("inferschema", "true")
.option("sep", ",")
.csv("statesPopulation.csv")
statesDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]
scala> statesDF.schema
res92: org.apache.spark.sql.types.StructType = StructType(
StructField(State,StringType,true),
StructField(Year,IntegerType,true),
StructField(Population,IntegerType,true))
scala> statesDF.printSchema
root
|-- State: string (nullable = true)
|-- Year: integer (nullable = true)
|-- Population: integer (nullable = true)
显式模式
模式通过 StructType 描述,它是 StructField 对象的集合。
StructType 和 StructField 属于 org.apache.spark.sql.types 包。
IntegerType、StringType 等数据类型也属于 org.apache.spark.sql.types 包。
使用这些导入,我们可以定义一个自定义的显式模式。
首先,导入必要的类:
scala> import org.apache.spark.sql.types.{StructType, IntegerType, StringType}
import org.apache.spark.sql.types.{StructType, IntegerType, StringType}
定义一个包含两列/字段的模式——一个 Integer 字段,后跟一个 String 字段:
scala> val schema = new StructType().add("i", IntegerType).add("s", StringType)
schema: org.apache.spark.sql.types.StructType = StructType(StructField(i,IntegerType,true), StructField(s,StringType,true))
打印新创建的 schema 非常简单:
scala> schema.printTreeString
root
|-- i: integer (nullable = true)
|-- s: string (nullable = true)
还可以选择打印 JSON,方法如下,使用 prettyJson 函数:
scala> schema.prettyJson
res85: String =
{
"type" : "struct",
"fields" : [ {
"name" : "i",
"type" : "integer",
"nullable" : true,
"metadata" : { }
}, {
"name" : "s",
"type" : "string",
"nullable" : true,
"metadata" : { }
} ]
}
Spark SQL 的所有数据类型都位于 org.apache.spark.sql.types 包中。你可以通过以下方式访问它们:
import org.apache.spark.sql.types._
编码器
Spark 2.x 支持为复杂数据类型定义模式的另一种方式。首先,我们来看一个简单的例子。
必须通过 import 语句导入编码器,以便使用编码器:
import org.apache.spark.sql.Encoders
让我们来看一个简单的例子,定义一个元组作为数据类型,并在数据集 API 中使用它:
scala> Encoders.product[(Integer, String)].schema.printTreeString
root
|-- _1: integer (nullable = true)
|-- _2: string (nullable = true)
上面的代码看起来总是很复杂,因此我们也可以为需求定义一个案例类,然后使用它。我们可以定义一个名为 Record 的案例类,包含两个字段——一个 Integer 和一个 String:
scala> case class Record(i: Integer, s: String)
defined class Record
使用 Encoders,我们可以轻松地在案例类上创建一个 schema,从而轻松使用各种 API:
scala> Encoders.product[Record].schema.printTreeString
root
|-- i: integer (nullable = true)
|-- s: string (nullable = true)
Spark SQL 的所有数据类型都位于包 org.apache.spark.sql.types 中。你可以通过以下方式访问它们:
import org.apache.spark.sql.types._
你应该在代码中使用 DataTypes 对象来创建复杂的 Spark SQL 类型,例如数组或映射,如下所示:
scala> import org.apache.spark.sql.types.DataTypes
import org.apache.spark.sql.types.DataTypes
scala> val arrayType = DataTypes.createArrayType(IntegerType)
arrayType: org.apache.spark.sql.types.ArrayType = ArrayType(IntegerType,true)
以下是 Spark SQL API 中支持的数据类型:
| 数据类型 | Scala 中的值类型 | 访问或创建数据类型的 API |
|---|---|---|
ByteType | Byte | ByteType |
ShortType | Short | ShortType |
IntegerType | Int | IntegerType |
LongType | Long | LongType |
FloatType | Float | FloatType |
DoubleType | Double | DoubleType |
DecimalType | java.math.BigDecimal | DecimalType |
StringType | String | StringType |
BinaryType | Array[Byte] | BinaryType |
BooleanType | Boolean | BooleanType |
TimestampType | java.sql.Timestamp | TimestampType |
DateType | java.sql.Date | DateType |
ArrayType | scala.collection.Seq | ArrayType(elementType, [containsNull]) |
MapType | scala.collection.Map | MapType(keyType, valueType, [valueContainsNull]) 注意:valueContainsNull 的默认值为 true。 |
StructType | org.apache.spark.sql.Row | StructType(fields) 注意:fields 是 Seq 类型的 StructFields。此外,不允许有两个同名的字段。 |
加载和保存数据集
我们需要将数据读取到集群作为输入,并将输出或结果写回存储,这样才能对代码做实际操作。输入数据可以从多种数据集和来源读取,如文件、Amazon S3 存储、数据库、NoSQL 和 Hive,输出也可以保存到文件、S3、数据库、Hive 等。
通过连接器,多个系统已经支持 Spark,随着越来越多的系统接入 Spark 处理框架,这一数字正在日益增长。
加载数据集
Spark SQL 可以通过 DataFrameReader 接口从外部存储系统(如文件、Hive 表和 JDBC 数据库)读取数据。
API 调用的格式是 spark.read.inputtype
-
Parquet
-
CSV
-
Hive 表
-
JDBC
-
ORC
-
文本
-
JSON
让我们看几个简单的例子,展示如何将 CSV 文件读取到 DataFrame 中:
scala> val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesPopulationDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]
scala> val statesTaxRatesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesTaxRates.csv")
statesTaxRatesDF: org.apache.spark.sql.DataFrame = [State: string, TaxRate: double]
保存数据集
Spark SQL 可以通过 DataFrameWriter 接口将数据保存到外部存储系统,如文件、Hive 表和 JDBC 数据库。
API 调用的格式是 dataframe``.write.outputtype
-
Parquet
-
ORC
-
文本
-
Hive 表
-
JSON
-
CSV
-
JDBC
让我们看几个将 DataFrame 写入或保存为 CSV 文件的示例:
scala> statesPopulationDF.write.option("header", "true").csv("statesPopulation_dup.csv")
scala> statesTaxRatesDF.write.option("header", "true").csv("statesTaxRates_dup.csv")
聚合
聚合是根据某个条件收集数据并对其进行分析的方法。聚合对于理解各种规模的数据非常重要,因为仅仅拥有原始数据记录对于大多数用例来说并不那么有用。
举个例子,如果你查看下面的表格和它的聚合视图,显然仅仅是原始记录并不能帮助你理解数据。
想象一下,一个表格,其中记录了世界上每个城市五年内每天的气温测量数据。
以下展示了一个包含每天每个城市的平均气温记录的表格:
| 城市 | 日期 | 气温 |
|---|---|---|
| 波士顿 | 2016/12/23 | 32 |
| 纽约 | 2016/12/24 | 36 |
| 波士顿 | 2016/12/24 | 30 |
| 费城 | 2016/12/25 | 34 |
| 波士顿 | 2016/12/25 | 28 |
如果我们想计算上表中每个城市的日均气温,我们可以看到的结果将类似于下面的表格:
| 城市 | 平均气温 |
|---|---|
| 波士顿 | 30 - (32 + 30 + 28)/3 |
| 纽约 | 36 |
| 费城 | 34 |
聚合函数
大多数聚合操作可以使用在org.apache.spark.sql.functions包中找到的函数来完成。此外,还可以创建自定义聚合函数,称为用户定义的聚合函数(UDAF)。
每个分组操作返回一个RelationalGroupeddataset,你可以在其上指定聚合操作。
我们将加载示例数据,以说明本节中所有不同类型的聚合函数:
val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
计数
count是最基本的聚合函数,它简单地计算指定列的行数。其扩展版本是countDistinct,它还会去除重复项。
count API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景:
def count(columnName: String): TypedColumn[Any, Long]
Aggregate function: returns the number of items in a group.
def count(e: Column): Column
Aggregate function: returns the number of items in a group.
def countDistinct(columnName: String, columnNames: String*): Column
Aggregate function: returns the number of distinct items in a group.
def countDistinct(expr: Column, exprs: Column*): Column
Aggregate function: returns the number of distinct items in a group.
让我们看一些在 DataFrame 上调用count和countDistinct的示例,来打印行数:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("*")).agg(count("State")).show
scala> statesPopulationDF.select(count("State")).show
+------------+
|count(State)|
+------------+
| 350|
+------------+
scala> statesPopulationDF.select(col("*")).agg(countDistinct("State")).show
scala> statesPopulationDF.select(countDistinct("State")).show
+---------------------+
|count(DISTINCT State)|
+---------------------+
| 50|
第一行
获取RelationalGroupeddataset中的第一条记录。
first API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景:
def first(columnName: String): Column
Aggregate function: returns the first value of a column in a group.
def first(e: Column): Column
Aggregate function: returns the first value in a group.
def first(columnName: String, ignoreNulls: Boolean): Column
Aggregate function: returns the first value of a column in a group.
def first(e: Column, ignoreNulls: Boolean): Column
Aggregate function: returns the first value in a group.
让我们看一个在 DataFrame 上调用first的示例,来输出第一行:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(first("State")).show
+-------------------+
|first(State, false)|
+-------------------+
| Alabama|
+-------------------+
最后一行
获取RelationalGroupeddataset中的最后一条记录。
last API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景:
def last(columnName: String): Column
Aggregate function: returns the last value of the column in a group.
def last(e: Column): Column
Aggregate function: returns the last value in a group.
def last(columnName: String, ignoreNulls: Boolean): Column
Aggregate function: returns the last value of the column in a group.
def last(e: Column, ignoreNulls: Boolean): Column
Aggregate function: returns the last value in a group.
让我们看一个在 DataFrame 上调用last的示例,来输出最后一行。
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(last("State")).show
+------------------+
|last(State, false)|
+------------------+
| Wyoming|
+------------------+
approx_count_distinct
近似的不同计数比进行精确计数要快得多,因为精确计数通常需要很多数据重分区和其他操作。虽然近似计数不是 100%准确,但在许多使用场景下,即使没有精确计数,表现也能一样好。
approx_count_distinct API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景。
def approx_count_distinct(columnName: String, rsd: Double): Column
Aggregate function: returns the approximate number of distinct items in a group.
def approx_count_distinct(e: Column, rsd: Double): Column
Aggregate function: returns the approximate number of distinct items in a group.
def approx_count_distinct(columnName: String): Column
Aggregate function: returns the approximate number of distinct items in a group.
def approx_count_distinct(e: Column): Column
Aggregate function: returns the approximate number of distinct items in a group.
让我们看一个在 DataFrame 上调用approx_count_distinct的示例,来打印 DataFrame 的近似计数:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("*")).agg(approx_count_distinct("State")).show
+----------------------------+
|approx_count_distinct(State)|
+----------------------------+
| 48|
+----------------------------+
scala> statesPopulationDF.select(approx_count_distinct("State", 0.2)).show
+----------------------------+
|approx_count_distinct(State)|
+----------------------------+
| 49|
+----------------------------+
最小值
DataFrame 中某一列的最小值。例如,如果你想找出某个城市的最低温度。
min API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景:
def min(columnName: String): Column
Aggregate function: returns the minimum value of the column in a group.
def min(e: Column): Column
Aggregate function: returns the minimum value of the expression in a group.
让我们看一个在 DataFrame 上调用min的示例,来打印最小人口:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(min("Population")).show
+---------------+
|min(Population)|
+---------------+
| 564513|
+---------------+
最大值
DataFrame 中某一列的最大值。例如,如果你想找出某个城市的最高温度。
max API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景。
def max(columnName: String): Column
Aggregate function: returns the maximum value of the column in a group.
def max(e: Column): Column
Aggregate function: returns the maximum value of the expression in a group.
让我们看一个在 DataFrame 上调用max的示例,来打印最大人口:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(max("Population")).show
+---------------+
|max(Population)|
+---------------+
| 39250017|
+---------------+
平均值
值的平均值通过将所有值相加然后除以值的个数来计算。
1、2、3 的平均值是(1 + 2 + 3) / 3 = 6 / 3 = 2
avg API 有多种实现方式,具体使用哪个 API 取决于特定的使用场景:
def avg(columnName: String): Column
Aggregate function: returns the average of the values in a group.
def avg(e: Column): Column
Aggregate function: returns the average of the values in a group.
让我们看一个在 DataFrame 上调用avg的示例,来打印平均人口:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(avg("Population")).show
+-----------------+
| avg(Population)|
+-----------------+
|6253399.371428572|
+-----------------+
求和
计算列中值的总和。可以选择使用 sumDistinct 来仅计算不同值的总和。
sum API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景:
def sum(columnName: String): Column
Aggregate function: returns the sum of all values in the given column.
def sum(e: Column): Column
Aggregate function: returns the sum of all values in the expression.
def sumDistinct(columnName: String): Column
Aggregate function: returns the sum of distinct values in the expression
def sumDistinct(e: Column): Column
Aggregate function: returns the sum of distinct values in the expression.
让我们来看一个调用 sum 的例子,计算 DataFrame 中 Population 的总和:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(sum("Population")).show
+---------------+
|sum(Population)|
+---------------+
| 2188689780|
+---------------+
峰度
峰度是量化分布形状差异的一种方式,即使均值和方差看起来非常相似,它们的形状实际上却是不同的。在这种情况下,峰度成为衡量分布尾部相对于分布中部的权重的良好指标。
kurtosis API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景。
def kurtosis(columnName: String): Column
Aggregate function: returns the kurtosis of the values in a group.
def kurtosis(e: Column): Column
Aggregate function: returns the kurtosis of the values in a group.
让我们来看一个调用 kurtosis 的例子,针对 Population 列的 DataFrame:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(kurtosis("Population")).show
+--------------------+
|kurtosis(Population)|
+--------------------+
| 7.727421920829375|
+--------------------+
偏度
偏度衡量数据中各值围绕平均值或均值的非对称性。
skewness API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景。
def skewness(columnName: String): Column
Aggregate function: returns the skewness of the values in a group.
def skewness(e: Column): Column
Aggregate function: returns the skewness of the values in a group.
让我们来看一个调用 skewness 的例子,针对 Population 列的 DataFrame:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(skewness("Population")).show
+--------------------+
|skewness(Population)|
+--------------------+
| 2.5675329049100024|
+--------------------+
方差
方差是每个值与均值的平方差的平均值。
var API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景:
def var_pop(columnName: String): Column
Aggregate function: returns the population variance of the values in a group.
def var_pop(e: Column): Column
Aggregate function: returns the population variance of the values in a group.
def var_samp(columnName: String): Column
Aggregate function: returns the unbiased variance of the values in a group.
def var_samp(e: Column): Column
Aggregate function: returns the unbiased variance of the values in a group.
现在,让我们来看一个调用 var_pop 的例子,计算 Population 的方差:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(var_pop("Population")).show
+--------------------+
| var_pop(Population)|
+--------------------+
|4.948359064356177E13|
+--------------------+
标准差
标准差是方差的平方根(参见前文)。
stddev API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景:
def stddev(columnName: String): Column
Aggregate function: alias for stddev_samp.
def stddev(e: Column): Column
Aggregate function: alias for stddev_samp.
def stddev_pop(columnName: String): Column
Aggregate function: returns the population standard deviation of the expression in a group.
def stddev_pop(e: Column): Column
Aggregate function: returns the population standard deviation of the expression in a group.
def stddev_samp(columnName: String): Column
Aggregate function: returns the sample standard deviation of the expression in a group.
def stddev_samp(e: Column): Column
Aggregate function: returns the sample standard deviation of the expression in a group.
让我们来看一个调用 stddev 的例子,打印 Population 的标准差:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(stddev("Population")).show
+-----------------------+
|stddev_samp(Population)|
+-----------------------+
| 7044528.191173398|
+-----------------------+
协方差
协方差是衡量两个随机变量联合变异性的指标。如果一个变量的较大值与另一个变量的较大值主要对应,而较小值也同样如此,那么这两个变量趋向于表现出相似的行为,协方差为正。如果情况相反,一个变量的较大值与另一个变量的较小值对应,那么协方差为负。
covar API 有几种实现方式,具体使用哪个 API 取决于特定的使用场景。
def covar_pop(columnName1: String, columnName2: String): Column
Aggregate function: returns the population covariance for two columns.
def covar_pop(column1: Column, column2: Column): Column
Aggregate function: returns the population covariance for two columns.
def covar_samp(columnName1: String, columnName2: String): Column
Aggregate function: returns the sample covariance for two columns.
def covar_samp(column1: Column, column2: Column): Column
Aggregate function: returns the sample covariance for two columns.
让我们来看一个调用 covar_pop 的例子,计算年份与人口列之间的协方差:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(covar_pop("Year", "Population")).show
+---------------------------+
|covar_pop(Year, Population)|
+---------------------------+
| 183977.56000006935|
+---------------------------+
groupBy
在数据分析中,常见的任务之一是将数据分组到不同的类别中,然后对结果数据进行计算。
理解分组的一种快速方法是想象自己被要求快速评估办公室所需的用品。你可以开始环顾四周,按不同类型的物品进行分组,如笔、纸张、订书机,并分析你有什么和需要什么。
让我们对DataFrame运行groupBy函数,以打印每个州的聚合计数:
scala> statesPopulationDF.groupBy("State").count.show(5)
+---------+-----+
| State|count|
+---------+-----+
| Utah| 7|
| Hawaii| 7|
|Minnesota| 7|
| Ohio| 7|
| Arkansas| 7|
+---------+-----+
您还可以先groupBy,然后应用之前看到的任何聚合函数,如min、max、avg、stddev等:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.groupBy("State").agg(min("Population"), avg("Population")).show(5)
+---------+---------------+--------------------+
| State|min(Population)| avg(Population)|
+---------+---------------+--------------------+
| Utah| 2775326| 2904797.1428571427|
| Hawaii| 1363945| 1401453.2857142857|
|Minnesota| 5311147| 5416287.285714285|
| Ohio| 11540983|1.1574362714285715E7|
| Arkansas| 2921995| 2957692.714285714|
+---------+---------------+--------------------+
汇总
汇总是用于执行层次或嵌套计算的多维聚合。例如,如果我们想显示每个State+Year组的记录数,以及每个State的记录数(汇总所有年份,给出每个州的总数,不考虑Year),我们可以按如下方式使用rollup:
scala> statesPopulationDF.rollup("State", "Year").count.show(5)
+------------+----+-----+
| State|Year|count|
+------------+----+-----+
|South Dakota|2010| 1|
| New York|2012| 1|
| California|2014| 1|
| Wyoming|2014| 1|
| Hawaii|null| 7|
+------------+----+-----+
rollup计算州和年份的计数,例如加利福尼亚州+2014 年,以及加利福尼亚州(汇总所有年份)。
立方体
立方体是用于执行层次或嵌套计算的多维聚合,类似于汇总,但不同的是立方体对所有维度执行相同的操作。例如,如果我们想显示每个State和Year组的记录数,以及每个State的记录数(汇总所有年份,给出每个州的总数,不考虑Year),我们可以按如下方式使用汇总。此外,cube还会显示每年的总计(不考虑State):
scala> statesPopulationDF.cube("State", "Year").count.show(5)
+------------+----+-----+
| State|Year|count|
+------------+----+-----+
|South Dakota|2010| 1|
| New York|2012| 1|
| null|2014| 50|
| Wyoming|2014| 1|
| Hawaii|null| 7|
+------------+----+-----+
窗口函数
窗口函数允许您在数据窗口而非整个数据或某些过滤数据上执行聚合操作。这类窗口函数的使用案例有:
-
累计和
-
与前一个值的差异(对于相同的键)
-
加权移动平均
理解窗口函数的最好方法是想象在更大的数据集宇宙中有一个滑动窗口。您可以指定一个窗口,查看三行 T-1、T 和 T+1,并通过执行一个简单的计算。您还可以指定一个包含最新/最近十个值的窗口:
窗口规范的 API 需要三个属性:partitionBy()、orderBy()和rowsBetween()。partitionBy将数据分割成由partitionBy()指定的分区/组。orderBy()用于对每个数据分区中的数据进行排序。
rowsBetween()指定窗口帧或滑动窗口的跨度,以进行计算。
要尝试窗口函数,需要一些特定的包。您可以通过以下导入指令导入所需的包:
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions.col import org.apache.spark.sql.functions.max
现在,您已准备好编写代码来了解窗口函数。让我们创建一个窗口规范,对按Population排序并按State分区的分区进行排序。同时,指定我们希望将所有行视为Window的一部分,直到当前行。
val windowSpec = Window
.partitionBy("State")
.orderBy(col("Population").desc)
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
计算窗口规范上的rank。结果将是为每一行添加一个排名(行号),只要它落在指定的Window内。在这个例子中,我们选择按State进行分区,然后进一步按降序排列每个State的行。因此,每个州的行都有自己的排名号。
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("State"), col("Year"), max("Population").over(windowSpec), rank().over(windowSpec)).sort("State", "Year").show(10)
+-------+----+------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
| State|Year|max(Population) OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|RANK() OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|
+-------+----+------------------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
|Alabama|2010| 4863300| 6|
|Alabama|2011| 4863300| 7|
|Alabama|2012| 4863300| 5|
|Alabama|2013| 4863300| 4|
|Alabama|2014| 4863300| 3|
分位数
ntiles 是窗口聚合中常见的一种方法,通常用于将输入数据集分为 n 个部分。例如,在预测分析中,十等分(10 个部分)通常用于首先对数据进行分组,然后将其分为 10 个部分,以获得公平的数据分布。这是窗口函数方法的自然功能,因此 ntiles 是窗口函数如何提供帮助的一个很好的例子。
例如,如果我们想按State对statesPopulationDF进行分区(如前所示的窗口规范),按人口排序,然后将其分为两部分,我们可以在windowspec上使用ntile:
import org.apache.spark.sql.functions._
scala> statesPopulationDF.select(col("State"), col("Year"), ntile(2).over(windowSpec), rank().over(windowSpec)).sort("State", "Year").show(10)
+-------+----+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
| State|Year|ntile(2) OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|RANK() OVER (PARTITION BY State ORDER BY Population DESC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)|
+-------+----+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+
|Alabama|2010| 2| 6|
|Alabama|2011| 2| 7|
|Alabama|2012| 2| 5|
|Alabama|2013| 1| 4|
|Alabama|2014| 1| 3|
|Alabama|2015| 1| 2|
|Alabama|2016| 1| 1|
| Alaska|2010| 2| 7|
| Alaska|2011| 2| 6|
| Alaska|2012| 2| 5|
+-------+----+-----------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------
如前所示,我们已使用Window函数和ntile()一起将每个State的行分为两等份。
该功能的常见用途之一是计算数据科学模型中使用的十等分(deciles)。
连接
在传统数据库中,连接用于将一张交易表与另一张查找表连接,以生成更完整的视图。例如,如果你有一张按客户 ID 分类的在线交易表和另一张包含客户城市及客户 ID 的表,你可以使用连接来生成按城市分类的交易报告。
交易表:以下表格包含三列,客户 ID、购买商品以及客户为商品支付的价格:
| 客户 ID | 购买商品 | 支付价格 |
|---|---|---|
| 1 | 耳机 | 25.00 |
| 2 | 手表 | 100.00 |
| 3 | 键盘 | 20.00 |
| 1 | 鼠标 | 10.00 |
| 4 | 电缆 | 10.00 |
| 3 | 耳机 | 30.00 |
客户信息表:以下表格包含两列,客户 ID和客户所在的城市:
| 客户 ID | 城市 |
|---|---|
| 1 | 波士顿 |
| 2 | 纽约 |
| 3 | 费城 |
| 4 | 波士顿 |
将交易表与客户信息表连接将生成如下视图:
| 客户 ID | 购买商品 | 支付价格 | 城市 |
|---|---|---|---|
| 1 | 耳机 | 25.00 | 波士顿 |
| 2 | 手表 | 100.00 | 纽约 |
| 3 | 键盘 | 20.00 | 费城 |
| 1 | 鼠标 | 10.00 | 波士顿 |
| 4 | 电缆 | 10.00 | 波士顿 |
| 3 | 耳机 | 30.00 | 费城 |
现在,我们可以使用这个连接后的视图来生成按城市分类的总销售价格报告:
| 城市 | #商品 | 总销售价格 |
|---|---|---|
| 波士顿 | 3 | 45.00 |
| 费城 | 2 | 50.00 |
| 纽约 | 1 | 100.00 |
连接是 Spark SQL 的重要功能,因为它允许你将两个数据集结合起来,如前所示。当然,Spark 不仅仅用于生成报告,它还用于处理 PB 级别的数据,以应对实时流处理、机器学习算法或普通的分析任务。为了实现这些目标,Spark 提供了所需的 API 函数。
一个典型的数据集连接通过使用左侧和右侧数据集的一个或多个键来完成,然后在键的集合上评估条件表达式作为布尔表达式。如果布尔表达式的结果为真,则连接成功,否则连接后的 DataFrame 将不包含相应的连接数据。
Join API 有 6 种不同的实现方式:
join(right: dataset[_]): DataFrame
Condition-less inner join
join(right: dataset[_], usingColumn: String): DataFrame
Inner join with a single column
join(right: dataset[_], usingColumns: Seq[String]): DataFrame
Inner join with multiple columns
join(right: dataset[_], usingColumns: Seq[String], joinType: String): DataFrame
Join with multiple columns and a join type (inner, outer,....)
join(right: dataset[_], joinExprs: Column): DataFrame
Inner Join using a join expression
join(right: dataset[_], joinExprs: Column, joinType: String): DataFrame
Join using a Join expression and a join type (inner, outer, ...)
我们将使用其中一个 API 来理解如何使用 join API;不过,您也可以根据使用场景选择其他 API:
def join(right: dataset[_], joinExprs: Column, joinType: String): DataFrame Join with another DataFrame using the given join expression
right: Right side of the join.
joinExprs: Join expression.
joinType : Type of join to perform. Default is *inner* join
// Scala:
import org.apache.spark.sql.functions._
import spark.implicits._
df1.join(df2, $"df1Key" === $"df2Key", "outer")
注意,连接的详细内容将在接下来的几部分中讨论。
Join 的内部工作原理
Join 通过在多个执行器上操作 DataFrame 的分区来工作。然而,实际的操作和随后的性能取决于join的类型和所连接数据集的性质。在下一部分中,我们将讨论各种连接类型。
Shuffle 连接
在两个大数据集之间进行连接时,涉及到 shuffle join,其中左侧和右侧数据集的分区被分布到各个执行器中。Shuffle 操作是非常昂贵的,因此必须分析逻辑,确保分区和 shuffle 的分布是最优的。以下是 shuffle join 如何在内部工作的示意图:
广播连接
在一个大数据集和一个小数据集之间进行连接时,可以通过将小数据集广播到所有执行器来完成,前提是左侧数据集的分区存在。以下是广播连接如何在内部工作的示意图:
连接类型
以下是不同类型的连接表。这非常重要,因为在连接两个数据集时所做的选择对结果和性能有着决定性影响。
| Join type | Description |
|---|---|
| inner | 内连接将left中的每一行与right中的每一行进行比较,并且只有当两者都有非 NULL 值时,才会将匹配的left和right行组合在一起。 |
| cross | cross join 将left中的每一行与right中的每一行进行匹配,生成一个笛卡尔积。 |
| outer, full, fullouter | 全外连接返回left和right中的所有行,如果某一侧没有数据,则填充 NULL。 |
| leftanti | leftanti Join 仅返回left中的行,基于right侧的不存在。 |
| left, leftouter | leftouter Join 返回left中的所有行以及left和right的共同行(内连接)。如果不在right中,则填充 NULL。 |
| leftsemi | leftsemi Join 仅返回left中的行,基于right侧的存在。它不包括right侧的值。 |
| right, rightouter | rightouter Join 返回right中的所有行以及left和right的共同行(内连接)。如果不在left中,则填充 NULL。 |
我们将通过使用示例数据集来研究不同的连接类型是如何工作的。
scala> val statesPopulationDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesPopulation.csv")
statesPopulationDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 1 more field]
scala> val statesTaxRatesDF = spark.read.option("header", "true").option("inferschema", "true").option("sep", ",").csv("statesTaxRates.csv")
statesTaxRatesDF: org.apache.spark.sql.DataFrame = [State: string, TaxRate: double]
scala> statesPopulationDF.count
res21: Long = 357
scala> statesTaxRatesDF.count
res32: Long = 47
%sql
statesPopulationDF.createOrReplaceTempView("statesPopulationDF")
statesTaxRatesDF.createOrReplaceTempView("statesTaxRatesDF")
内连接
内连接(Inner join)会返回来自statesPopulationDF和statesTaxRatesDF的数据行,前提是两个数据集中state列的值都非空。
按照state列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "inner")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF INNER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 329
scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Alabama|2010| 4785492| Alabama| 4.0|
| Arizona|2010| 6408312| Arizona| 5.6|
| Arkansas|2010| 2921995| Arkansas| 6.5|
| California|2010| 37332685| California| 7.5|
| Colorado|2010| 5048644| Colorado| 2.9|
| Connecticut|2010| 3579899| Connecticut| 6.35|
你可以对joinDF运行explain(),查看执行计划:
scala> joinDF.explain
== Physical Plan ==
*BroadcastHashJoin [State#570], [State#577], Inner, BuildRight
:- *Project [State#570, Year#571, Population#572]
: +- *Filter isnotnull(State#570)
: +- *FileScan csv [State#570,Year#571,Population#572] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/spark-2.1.0-bin-hadoop2.7/statesPopulation.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State)], ReadSchema: struct<State:string,Year:int,Population:int>
+- BroadcastExchange HashedRelationBroadcastMode(List(input[0, string, true]))
+- *Project [State#577, TaxRate#578]
+- *Filter isnotnull(State#577)
+- *FileScan csv [State#577,TaxRate#578] Batched: false, Format: CSV, Location: InMemoryFileIndex[file:/Users/salla/spark-2.1.0-bin-hadoop2.7/statesTaxRates.csv], PartitionFilters: [], PushedFilters: [IsNotNull(State)], ReadSchema: struct<State:string,TaxRate:double>
左外连接(Left outer join)
左外连接(Left outer join)返回statesPopulationDF中的所有行,包括statesPopulationDF和statesTaxRatesDF中共同的行。
按照state列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftouter")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 357
scala> joinDF.show(5)
+----------+----+----------+----------+-------+
| State|Year|Population| State|TaxRate|
+----------+----+----------+----------+-------+
| Alabama|2010| 4785492| Alabama| 4.0|
| Alaska|2010| 714031| null| null|
| Arizona|2010| 6408312| Arizona| 5.6|
| Arkansas|2010| 2921995| Arkansas| 6.5|
|California|2010| 37332685|California| 7.5|
+----------+----+----------+----------+-------+
右外连接
右外连接(Right outer join)返回statesTaxRatesDF中的所有行,包括statesPopulationDF和statesTaxRatesDF中共同的行。
按照State列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "rightouter")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF RIGHT OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 323
scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Colorado|2011| 5118360| Colorado| 2.9|
| Colorado|2010| 5048644| Colorado| 2.9|
| null|null| null|Connecticut| 6.35|
| Florida|2016| 20612439| Florida| 6.0|
| Florida|2015| 20244914| Florida| 6.0|
| Florida|2014| 19888741| Florida| 6.0|
外连接
外连接(Outer join)返回statesPopulationDF和statesTaxRatesDF中的所有行。
按照State列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "fullouter")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF FULL OUTER JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 351
scala> joinDF.show
+--------------------+----+----------+--------------------+-------+
| State|Year|Population| State|TaxRate|
+--------------------+----+----------+--------------------+-------+
| Delaware|2010| 899816| null| null|
| Delaware|2011| 907924| null| null|
| West Virginia|2010| 1854230| West Virginia| 6.0|
| West Virginia|2011| 1854972| West Virginia| 6.0|
| Missouri|2010| 5996118| Missouri| 4.225|
| null|null| null| Connecticut| 6.35|
左反连接
左反连接(Left anti join)只会返回来自statesPopulationDF的数据行,前提是statesTaxRatesDF中没有对应的行。
按照State列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftanti")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT ANTI JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 28
scala> joinDF.show(5)
+--------+----+----------+
| State|Year|Population|
+--------+----+----------+
| Alaska|2010| 714031|
|Delaware|2010| 899816|
| Montana|2010| 990641|
| Oregon|2010| 3838048|
| Alaska|2011| 722713|
+--------+----+----------+
左半连接
左半连接(Left semi join)只会返回来自statesPopulationDF的数据行,前提是statesTaxRatesDF中有对应的行。
按照state列连接这两个数据集,如下所示:
val joinDF = statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State") === statesTaxRatesDF("State"), "leftsemi")
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF LEFT SEMI JOIN statesTaxRatesDF ON statesPopulationDF.State = statesTaxRatesDF.State")
scala> joinDF.count
res22: Long = 322
scala> joinDF.show(5)
+----------+----+----------+
| State|Year|Population|
+----------+----+----------+
| Alabama|2010| 4785492|
| Arizona|2010| 6408312|
| Arkansas|2010| 2921995|
|California|2010| 37332685|
| Colorado|2010| 5048644|
+----------+----+----------+
交叉连接
交叉连接(Cross join)会将左表的每一行与右表的每一行匹配,生成一个笛卡尔积。
按照State列连接这两个数据集,如下所示:
scala> val joinDF=statesPopulationDF.crossJoin(statesTaxRatesDF)
joinDF: org.apache.spark.sql.DataFrame = [State: string, Year: int ... 3 more fields]
%sql
val joinDF = spark.sql("SELECT * FROM statesPopulationDF CROSS JOIN statesTaxRatesDF")
scala> joinDF.count
res46: Long = 16450
scala> joinDF.show(10)
+-------+----+----------+-----------+-------+
| State|Year|Population| State|TaxRate|
+-------+----+----------+-----------+-------+
|Alabama|2010| 4785492| Alabama| 4.0|
|Alabama|2010| 4785492| Arizona| 5.6|
|Alabama|2010| 4785492| Arkansas| 6.5|
|Alabama|2010| 4785492| California| 7.5|
|Alabama|2010| 4785492| Colorado| 2.9|
|Alabama|2010| 4785492|Connecticut| 6.35|
|Alabama|2010| 4785492| Florida| 6.0|
|Alabama|2010| 4785492| Georgia| 4.0|
|Alabama|2010| 4785492| Hawaii| 4.0|
|Alabama|2010| 4785492| Idaho| 6.0|
+-------+----+----------+-----------+-------+
你还可以使用cross连接类型来代替调用交叉连接(cross join)API。statesPopulationDF.join(statesTaxRatesDF, statesPopulationDF("State").isNotNull, "cross").count。
连接的性能影响
选择的连接类型直接影响连接的性能。这是因为连接操作需要在执行器之间对数据进行洗牌,因此在使用连接时,需要考虑不同的连接类型,甚至连接的顺序。
以下是你在编写Join代码时可以参考的表格:
| Join type | 性能考虑和提示 |
|---|---|
| inner | 内连接要求左表和右表具有相同的列。如果左表或右表中有重复或多个键值,连接操作会迅速膨胀成一种笛卡尔连接,导致执行时间远长于正确设计的连接,后者能够最小化多个键的影响。 |
| cross | 交叉连接将左表的每一行与右表的每一行匹配,生成一个笛卡尔积。使用时需谨慎,因为这是性能最差的连接类型,应该仅在特定用例中使用。 |
| outer, full, fullouter | 完全外连接(Fullouter Join)返回左表和右表中的所有行,如果某行仅存在于右表或左表中,则填充为 NULL。如果用于表之间的公共部分很少的情况,可能会导致结果非常大,从而影响性能。 |
| leftanti | Leftanti 连接仅根据right侧的不存不存在返回left中的行。性能非常好,因为只有一个表被完全考虑,另一个表只是根据连接条件进行检查。 |
| left, leftouter | Leftouter 连接返回所有来自left的行,并加上left和right的公共行(内连接)。如果在right中不存在,则填充 NULL。如果在两个表之间的共同部分很少,可能会导致结果非常庞大,从而导致性能变慢。 |
| leftsemi | Leftsemi 连接仅根据right侧的存在返回left中的行。不会包括right侧的值。性能非常好,因为只有一个表被完全考虑,另一个表只是根据连接条件进行检查。 |
| right, rightouter | Rightouter 连接提供了所有来自right的行,并加上left和right的公共行(内连接)。如果在left中不存在,则填充 NULL。性能与前面表格中的左外连接相似。 |
总结
在本章中,我们讨论了 DataFrame 的起源以及 Spark SQL 如何在 DataFrame 上提供 SQL 接口。DataFrame 的强大之处在于,执行时间相比原始的 RDD 计算大幅缩短。拥有这样一个强大的层次结构,并且具有简单的 SQL 类似接口,使其更加高效。我们还探讨了各种 API 来创建和操作 DataFrame,同时深入了解了聚合操作的复杂特性,包括 groupBy、Window、rollup 和 cubes。最后,我们还研究了数据集连接的概念以及可能的各种连接类型,如内连接、外连接、交叉连接等。
在下一章,我们将探索实时数据处理和分析的精彩世界,内容见第九章,Stream Me Up, Scotty - Spark Streaming。
第九章:Stream Me Up, Scotty - Spark Streaming
“我真的很喜欢流媒体服务。这是人们发现你音乐的一个好方式”
- Kygo
本章将介绍 Spark Streaming,并了解我们如何利用 Spark API 处理数据流。此外,在本章中,我们将通过一个实际的例子,学习如何处理实时数据流,使用 Twitter 的推文来消费和处理数据。简而言之,本章将涵盖以下主题:
-
流媒体简介
-
Spark Streaming
-
离散化流
-
有状态/无状态转换
-
检查点
-
与流媒体平台的互操作性(Apache Kafka)
-
结构化流
流媒体简介
在当今互联设备和服务的世界里,我们几乎不可能每天花几小时而不依赖智能手机去查看 Facebook,或者订车,或者发推特分享你刚买的汉堡,或者查看你最喜欢的球队的最新新闻或体育动态。我们依赖手机和互联网做很多事情,无论是工作,还是浏览,还是给朋友发邮件。这个现象已经无可避免,而且应用和服务的数量与种类只会随着时间增长。
结果是,智能设备无处不在,并且它们时刻产生大量数据。这种现象,也被广泛称为物联网,永远改变了数据处理的动态。无论何时你使用 iPhone、Android 或 Windows 手机上的任何服务或应用程序,以某种形式,实时数据处理都在发挥作用。由于大量依赖于应用程序的质量和价值,人们非常关注各种初创公司和成熟公司如何应对SLA(服务级别协议)、有用性以及数据的及时性等复杂挑战。
许多组织和服务提供商正在研究并采用的一个范式是构建非常可扩展的、接近实时或实时的数据处理框架,运行在非常前沿的平台或基础设施上。所有东西都必须快速并且对变化和故障具有反应能力。如果你的 Facebook 每小时更新一次,或者你每天只收到一次电子邮件,你可能不会喜欢;因此,数据流、处理和使用必须尽可能接近实时。我们感兴趣的许多系统都会生成大量数据,作为一个无限期持续的事件流。
与任何其他数据处理系统一样,我们面临着数据收集、存储和处理的基本挑战。然而,额外的复杂性来自于平台的实时需求。为了收集这些不确定的事件流,并随后处理所有这些事件以生成可操作的见解,我们需要使用高度可扩展的专业架构来应对海量的事件速率。因此,许多系统已经在几十年中发展起来,从 AMQ、RabbitMQ、Storm、Kafka、Spark、Flink、Gearpump、Apex 等开始。
为了应对如此大量的流数据,现代系统采用了非常灵活且可扩展的技术,这些技术不仅效率极高,而且比以往更能帮助实现业务目标。通过使用这些技术,几乎可以立即或根据需要在稍后的时间里,消费来自各种数据源的数据,并将其用于各种使用场景。
让我们谈谈当你拿出智能手机并预定 Uber 车去机场时发生了什么。通过在手机屏幕上轻点几下,你可以选择一个地点、选择信用卡、完成支付并预定乘车。交易完成后,你可以在手机上的地图上实时监控车辆的进展。当汽车向你驶来时,你可以精确看到车辆的位置,同时,你也可以决定在等车时去附近的 Starbucks 买杯咖啡。
你还可以通过查看预计的汽车到达时间来做出关于汽车和随后的机场之行的明智决策。如果看起来汽车接你需要花费相当长的时间,而且这可能会影响到你即将乘坐的航班,那么你可以取消这次乘车并选择附近恰好有空的出租车。或者,如果恰好由于交通情况无法按时到达机场,从而可能影响到你即将乘坐的航班,那么你也可以做出重新安排或取消航班的决定。
现在,为了理解这种实时流架构是如何工作并提供如此宝贵的信息的,我们需要理解流架构的基本原则。一方面,实时流架构必须能够以极高的速率消费大量数据,另一方面,还需要确保获取的数据也能够被处理。
以下图示展示了一个通用的流处理系统,生产者将事件放入消息系统,消费者则从消息系统读取事件:
实时流数据处理可以分为以下三种基本范式:
-
至少一次处理
-
至多一次处理
-
精确一次处理
让我们来看一下这三种流处理范式对我们的业务用例意味着什么。
尽管实时事件的精确一次处理是我们的最终目标,但在不同场景下,始终实现这一目标非常困难。我们必须在某些情况下对精确一次处理的特性进行妥协,因为这样的保证的好处往往被实现的复杂性所抵消。
至少一次处理
至少一次处理范式涉及一种机制,在事件实际处理并且结果已持久化之后,仅仅在事件被处理后才保存最后接收到事件的位置,这样,如果发生故障并且消费者重启,消费者将重新读取旧的事件并进行处理。然而,由于无法保证接收到的事件没有被完全处理或部分处理,这会导致事件被重复获取,从而可能导致事件重复处理。这就导致了“事件至少被处理一次”的行为。
至少一次处理理想适用于任何涉及更新某些瞬时计量器或仪表来显示当前值的应用程序。任何累计总和、计数器或依赖于聚合结果准确性的应用场景(如sum、groupBy等)不适合采用这种处理方式,因为重复的事件会导致错误的结果。
消费者的操作顺序如下:
-
保存结果
-
保存偏移量
以下是一个示例,展示了如果发生故障并且消费者重启时会发生什么情况。由于事件已经处理完毕,但偏移量未保存,消费者将从之前保存的偏移量处开始读取,从而导致重复。下图中事件 0 被处理了两次:
至多一次处理
至多一次处理范式涉及一种机制,在事件实际处理并且结果已持久化之前保存最后接收到事件的位置,这样,如果发生故障并且消费者重启,消费者将不会再尝试读取旧的事件。然而,由于无法保证接收到的事件都已处理完,这会导致潜在的事件丢失,因为这些事件再也不会被获取。这样就导致了“事件至多被处理一次或根本未被处理”的行为。
至多一次处理理想适用于任何涉及更新某些瞬时计量器或仪表来显示当前值的应用程序,以及任何累计总和、计数器或其他聚合操作,只要不要求精确度或应用程序不需要所有事件。任何丢失的事件都会导致错误的结果或缺失的结果。
消费者的操作顺序如下:
-
保存偏移量
-
保存结果
以下图示展示了如果发生故障且消费者重新启动时的情况。由于事件尚未处理,但偏移量已经保存,消费者将从保存的偏移量读取,导致事件消费出现间隙。在下图中,事件 0 从未被处理:
精确一次处理
精确一次处理范式类似于至少一次范式,涉及一种机制,只有在事件实际被处理并且结果已经持久化到某处后,才保存接收到的最后一个事件的位置。因此,如果发生故障且消费者重新启动,消费者将再次读取旧的事件并处理它们。然而,由于无法保证接收到的事件完全没有处理或仅部分处理,这可能会导致事件的潜在重复,因为它们会被再次获取。然而,与至少一次范式不同,重复的事件不会被处理,而是被丢弃,从而实现了精确一次范式。
精确一次处理范式适用于任何需要准确计数、聚合,或一般需要每个事件仅处理一次且一定要处理一次(不丢失)的应用。
消费者的操作顺序如下:
-
保存结果
-
保存偏移量
以下图示展示了如果发生故障且消费者重新启动时的情况。由于事件已经处理,但偏移量没有保存,消费者将从先前保存的偏移量读取,从而导致重复。下图中事件 0 只被处理一次,因为消费者丢弃了重复的事件 0:
精确一次范式如何丢弃重复项?有两种技术可以帮助解决这个问题:
-
幂等更新
-
事务性更新
Spark Streaming 在 Spark 2.0+中也实现了结构化流处理,支持开箱即用的精确一次处理。我们将在本章稍后讨论结构化流处理。
幂等更新涉及根据某个唯一的 ID/键保存结果,以便如果有重复,生成的唯一 ID/键已经存在于结果中(例如,数据库),这样消费者就可以丢弃重复项而无需更新结果。这是复杂的,因为并非总能生成唯一的键,且生成唯一键并不总是容易的。它还需要消费者端额外的处理。另一个问题是,数据库可能会将结果和偏移量分开。
事务性更新将结果保存在批次中,批次具有事务开始和事务提交阶段,因此当提交发生时,我们知道事件已成功处理。因此,当接收到重复事件时,可以在不更新结果的情况下将其丢弃。这种技术比幂等更新复杂得多,因为现在我们需要一些事务性数据存储。另一个要点是,结果和偏移量的数据库必须相同。
您应该研究您尝试构建的用例,并查看至少一次处理或最多一次处理是否可以合理地广泛应用,并仍然能够达到可接受的性能和准确性水平。
在接下来的章节中,当我们学习 Spark Streaming,以及如何使用 Spark Streaming 和消费来自 Apache Kafka 的事件时,我们将密切关注这些范式。
Spark Streaming
Spark Streaming 并不是第一个出现的流处理架构。多种技术随着时间的推移应运而生,以应对各种业务用例的实时处理需求。Twitter Storm 是最早的流处理技术之一,并被许多组织广泛使用,满足了许多企业的需求。
Apache Spark 配备了一个流处理库,该库已迅速发展为最广泛使用的技术。Spark Streaming 在其他技术之上具有一些明显优势,首先是 Spark Streaming API 与 Spark 核心 API 之间的紧密集成,使得构建一个同时支持实时和批量分析的平台变得可行和高效。Spark Streaming 还集成了 Spark ML 和 Spark SQL,以及 GraphX,使其成为能够服务许多独特和复杂用例的最强大的流处理技术。在本节中,我们将深入了解 Spark Streaming 的所有内容。
欲了解更多关于 Spark Streaming 的信息,请参阅 spark.apache.org/docs/2.1.0/streaming-programming-guide.html。
Spark Streaming 支持多种输入源,并可以将结果写入多个输出目标。
虽然 Flink、Heron(Twitter Storm 的继任者)、Samza 等都可以在收集事件时以最低的延迟处理事件,但 Spark Streaming 则会连续消耗数据流,并以微批处理的形式处理收集到的数据。微批的大小可以低至 500 毫秒,但通常不会低于此值。
Apache Apex、Gear pump、Flink、Samza、Heron 或其他即将推出的技术在某些用例中与 Spark Streaming 竞争。如果您需要真正的事件处理,则 Spark Streaming 不适合您的用例。
流处理的工作方式是根据配置定期创建事件批次,并在每个指定的时间间隔交付数据的微批处理以供进一步处理。
就像SparkContext一样,Spark Streaming 也有一个StreamingContext,它是流处理作业/应用的主要入口点。StreamingContext依赖于SparkContext。实际上,SparkContext可以直接用于流处理作业中。StreamingContext与SparkContext相似,不同之处在于,StreamingContext还要求程序指定批处理时间间隔或持续时间,单位可以是毫秒或分钟。
记住,SparkContext是主要的入口点,任务调度和资源管理是SparkContext的一部分,因此StreamingContext复用了这部分逻辑。
StreamingContext
StreamingContext是流处理的主要入口点,负责流处理应用的各个方面,包括检查点、转换和对 DStreams 的 RDD 操作。
创建 StreamingContext
新的 StreamingContext 可以通过两种方式创建:
- 使用现有的
SparkContext创建一个StreamingContext,如下所示:
StreamingContext(sparkContext: SparkContext, batchDuration: Duration) scala> val ssc = new StreamingContext(sc, Seconds(10))
- 通过提供新
SparkContext所需的配置来创建一个StreamingContext,如下所示:
StreamingContext(conf: SparkConf, batchDuration: Duration) scala> val conf = new SparkConf().setMaster("local[1]")
.setAppName("TextStreams")
scala> val ssc = new StreamingContext(conf, Seconds(10))
- 第三种方法是使用
getOrCreate(),它用于从检查点数据重新创建一个StreamingContext,或者创建一个新的StreamingContext。如果在提供的checkpointPath中存在检查点数据,StreamingContext将从该检查点数据重新创建。如果数据不存在,则通过调用提供的creatingFunc来创建StreamingContext:
def getOrCreate(
checkpointPath: String,
creatingFunc: () => StreamingContext,
hadoopConf: Configuration = SparkHadoopUtil.get.conf,
createOnError: Boolean = false
): StreamingContext
启动 StreamingContext
start()方法启动使用StreamingContext定义的流的执行。它实际上启动了整个流处理应用:
def start(): Unit
scala> ssc.start()
停止 StreamingContext
停止StreamingContext会停止所有处理,你需要重新创建一个新的StreamingContext并调用start()来重新启动应用。以下是两个用于停止流处理应用的 API。
立即停止流的执行(不等待所有接收的数据被处理):
def stop(stopSparkContext: Boolean) scala> ssc.stop(false)
停止流的执行,并确保所有接收的数据都已经被处理:
def stop(stopSparkContext: Boolean, stopGracefully: Boolean) scala> ssc.stop(true, true)
输入流
有几种类型的输入流,例如receiverStream和fileStream,可以使用StreamingContext创建,具体内容见以下小节:
receiverStream
创建一个输入流,并使用任何自定义的用户实现接收器。它可以根据使用案例进行定制。
详情请访问spark.apache.org/docs/latest/streaming-custom-receivers.html。
以下是receiverStream的 API 声明:
def receiverStreamT: ClassTag: ReceiverInputDStream[T]
socketTextStream
这将从 TCP 源hostname:port创建一个输入流。数据通过 TCP 套接字接收,接收到的字节将被解释为 UTF8 编码的\n分隔行:
def socketTextStream(hostname: String, port: Int,
storageLevel: StorageLevel = StorageLevel.MEMORY_AND_DISK_SER_2):
ReceiverInputDStream[String]
rawSocketStream
创建一个输入流,从网络源hostname:port接收数据,数据以序列化块的形式接收(使用 Spark 的序列化器进行序列化),这些数据可以在不反序列化的情况下直接推送到块管理器中。这是最高效的方式。
接收数据的方式。
def rawSocketStreamT: ClassTag:
ReceiverInputDStream[T]
fileStream
创建一个输入流,监控一个与 Hadoop 兼容的文件系统,监听新文件并使用给定的键值类型和输入格式读取它们。文件必须通过从同一文件系统中的其他位置移动到监控的目录来写入。以.开头的文件名将被忽略,因此这对于被移动到监控目录中的文件名来说是一个明显的选择。通过使用原子文件重命名函数调用,可以将以.开头的文件名重命名为一个实际可用的文件名,从而使fileStream能够接管并处理文件内容:
def fileStream[K: ClassTag, V: ClassTag, F <: NewInputFormat[K, V]: ClassTag] (directory: String): InputDStream[(K, V)]
textFileStream
创建一个输入流,监控一个与 Hadoop 兼容的文件系统,监听新文件并将它们作为文本文件读取(使用LongWritable作为键,Text 作为值,输入格式为TextInputFormat)。文件必须通过从同一文件系统中的其他位置移动到监控的目录来写入。以.开头的文件名将被忽略:
def textFileStream(directory: String): DStream[String]
binaryRecordsStream
创建一个输入流,监控一个与 Hadoop 兼容的文件系统,监听新文件并将它们作为平面二进制文件读取,假设每个记录的长度是固定的,并为每个记录生成一个字节数组。文件必须通过从同一文件系统中的其他位置移动到监控的目录来写入。以.开头的文件名将被忽略:
def binaryRecordsStream(directory: String, recordLength: Int): DStream[Array[Byte]]
queueStream
创建一个从 RDD 队列读取的输入流。在每个批次中,它将处理队列返回的一个或所有 RDD:
def queueStreamT: ClassTag: InputDStream[T]
textFileStream 示例
以下展示了一个简单的 Spark Streaming 示例,使用textFileStream。在这个例子中,我们从 spark-shell 的SparkContext(sc)和一个 10 秒的时间间隔创建一个StreamingContext。这将启动textFileStream,该流监控名为streamfiles的目录,并处理目录中找到的任何新文件。在这个例子中,我们仅仅打印出 RDD 中元素的数量:
scala> import org.apache.spark._
scala> import org.apache.spark.streaming._
scala> val ssc = new StreamingContext(sc, Seconds(10))
scala> val filestream = ssc.textFileStream("streamfiles")
scala> filestream.foreachRDD(rdd => {println(rdd.count())})
scala> ssc.start
twitterStream 示例
让我们看看另一个如何使用 Spark Streaming 处理 Twitter 推文的例子:
-
首先,打开一个终端并将目录更改为
spark-2.1.1-bin-hadoop2.7。 -
在
spark-2.1.1-bin-hadoop2.7文件夹下创建一个streamouts文件夹,其中安装了 Spark。当应用程序运行时,streamouts文件夹将收集推文并保存为文本文件。 -
下载以下的 JAR 文件到目录中:
-
使用指定的 Twitter 集成所需的 jars 启动 spark-shell:
./bin/spark-shell --jars twitter4j-stream-4.0.6.jar,
twitter4j-core-4.0.6.jar,
spark-streaming-twitter_2.11-2.1.0.jar
- 现在,我们可以编写一个示例代码。以下是用于测试 Twitter 事件处理的代码:
import org.apache.spark._
import org.apache.spark.streaming._
import org.apache.spark.streaming.Twitter._
import twitter4j.auth.OAuthAuthorization
import twitter4j.conf.ConfigurationBuilder
//you can replace the next 4 settings with your own Twitter
account settings.
System.setProperty("twitter4j.oauth.consumerKey",
"8wVysSpBc0LGzbwKMRh8hldSm")
System.setProperty("twitter4j.oauth.consumerSecret",
"FpV5MUDWliR6sInqIYIdkKMQEKaAUHdGJkEb4MVhDkh7dXtXPZ")
System.setProperty("twitter4j.oauth.accessToken",
"817207925756358656-yR0JR92VBdA2rBbgJaF7PYREbiV8VZq")
System.setProperty("twitter4j.oauth.accessTokenSecret",
"JsiVkUItwWCGyOLQEtnRpEhbXyZS9jNSzcMtycn68aBaS")
val ssc = new StreamingContext(sc, Seconds(10))
val twitterStream = TwitterUtils.createStream(ssc, None)
twitterStream.saveAsTextFiles("streamouts/tweets", "txt")
ssc.start()
//wait for 30 seconds
ss.stop(false)
你会看到 streamouts 文件夹中包含几个以文本文件形式输出的 tweets。现在可以打开 streamouts 目录,并检查文件是否包含 tweets。
离散化流
Spark Streaming 基于一个叫做 离散化流(Discretized Streams) 的抽象,简称 DStreams。DStream 表示为一系列 RDD,每个 RDD 在每个时间间隔内创建。DStream 可以像常规 RDD 一样使用类似的概念(如基于有向无环图的执行计划)进行处理。与常规 RDD 处理类似,执行计划中的转换操作和行动操作也会用于 DStream。
DStream 本质上是将源源不断的数据流基于时间间隔划分为较小的块,称为微批次(micro-batches),并将每个微批次物化为一个 RDD,然后可以像常规 RDD 一样进行处理。每个微批次独立处理,并且不同微批次之间不会维护状态,因此其处理方式本质上是无状态的。假设批次间隔是 5 秒,那么在事件消费的同时,每隔 5 秒就会创建一个微批次,并将该微批次作为 RDD 交给后续处理。Spark Streaming 的主要优势之一是,用于处理微批次事件的 API 调用与 Spark API 紧密集成,从而实现与架构其他部分的无缝集成。当创建微批次时,它会被转化为一个 RDD,使得使用 Spark API 进行处理成为一个无缝的过程。
DStream 类在源代码中的样子如下,展示了最重要的变量,一个 HashMap[Time, RDD] 对:
class DStream[T: ClassTag] (var ssc: StreamingContext)
//hashmap of RDDs in the DStream
var generatedRDDs = new HashMap[Time, RDD[T]]()
以下是一个 DStream 的示例,展示了每 T 秒创建的 RDD:
在以下示例中,创建一个流上下文,每 5 秒创建一个微批次,并创建一个 RDD,这与 Spark 核心 API 中的 RDD 类似。DStream 中的 RDD 可以像任何其他 RDD 一样进行处理。
构建流处理应用程序的步骤如下:
-
从
SparkContext创建一个StreamingContext。 -
从
StreamingContext创建一个DStream。 -
提供可以应用于每个 RDD 的转换和行动操作。
-
最后,通过调用
StreamingContext上的start()启动流式应用程序。这将启动整个消费和处理实时事件的过程。
一旦 Spark Streaming 应用程序启动,就无法再添加更多操作。已停止的上下文无法重新启动,如果有此需求,必须创建一个新的流式上下文。
以下是如何创建一个简单的流式作业来访问 Twitter 的示例:
- 从
SparkContext创建一个StreamingContext:
scala> val ssc = new StreamingContext(sc, Seconds(5))
ssc: org.apache.spark.streaming.StreamingContext =
org.apache.spark.streaming.StreamingContext@8ea5756
- 从
StreamingContext创建一个DStream:
scala> val twitterStream = TwitterUtils.createStream(ssc, None)
twitterStream: org.apache.spark.streaming.dstream
.ReceiverInputDStream[twitter4j.Status] =
org.apache.spark.streaming.Twitter.TwitterInputDStream@46219d14
- 提供可以应用于每个 RDD 的转换和操作:
val aggStream = twitterStream
.flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
.map(x => (x, 1))
.reduceByKey(_ + _)
- 最后,通过调用
StreamingContext上的start()启动流式应用程序。这将启动整个消费和处理实时事件的过程:
ssc.start() //to stop just call stop on the StreamingContext
ssc.stop(false)
- 创建了一个类型为
ReceiverInputDStream的DStream,它被定义为一个抽象类,用于定义任何需要在工作节点上启动接收器以接收外部数据的InputDStream。在这里,我们从 Twitter 流中接收数据:
class InputDStreamT: ClassTag extends
DStreamT
class ReceiverInputDStreamT: ClassTag
extends InputDStreamT
- 如果在
twitterStream上运行flatMap()转换,您将获得一个FlatMappedDStream,如下所示:
scala> val wordStream = twitterStream.flatMap(x => x.getText()
.split(" "))
wordStream: org.apache.spark.streaming.dstream.DStream[String] =
org.apache.spark.streaming.dstream.FlatMappedDStream@1ed2dbd5
转换
对DStream的转换类似于适用于 Spark 核心 RDD 的转换。由于 DStream 由 RDD 组成,转换也适用于每个 RDD,以生成转换后的 RDD,然后创建一个转换后的 DStream。每个转换都会创建一个特定的DStream派生类。
以下图表展示了DStream类的层次结构,从父类DStream开始。我们还可以看到从父类继承的不同类:
有很多DStream类是专门为此功能构建的。Map 转换、窗口函数、reduce 操作和不同类型的输入流都是通过不同的DStream派生类实现的。
以下是一个关于基础 DStream 的转换示例,用于生成一个过滤后的 DStream。类似地,任何转换都可以应用于 DStream:
请参考下表了解可能的转换类型。
| 转换 | 含义 |
|---|---|
map(func) | 这将转换函数应用于 DStream 的每个元素,并返回一个新的 DStream。 |
flatMap(func) | 这类似于 map;然而,就像 RDD 的flatMap与 map 的区别,使用flatMap操作每个元素,并应用flatMap,每个输入生成多个输出项。 |
filter(func) | 这会过滤掉 DStream 中的记录,返回一个新的 DStream。 |
repartition(numPartitions) | 这会创建更多或更少的分区,以重新分配数据,从而改变并行度。 |
union(otherStream) | 这会将两个源 DStream 中的元素合并,并返回一个新的 DStream。 |
count() | 这通过计算源 DStream 中每个 RDD 的元素数量来返回一个新的 DStream。 |
reduce(func) | 这通过对源 DStream 的每个元素应用reduce函数,返回一个新的 DStream。 |
countByValue() | 这计算每个键的频率,并返回一个新的 DStream,其中的元素是(key, long)对。 |
reduceByKey(func, [numTasks]) | 这通过在源 DStream 的 RDD 上按键聚合数据,并返回一个新的 DStream,其中的元素是(键,值)对。 |
join(otherStream, [numTasks]) | 这将两个 DStream 的*(K, V)和(K, W)对连接在一起,并返回一个新的 DStream,它的元素是(K, (V, W))*对,合并了两个 DStream 中的值。 |
cogroup(otherStream, [numTasks]) | 当在*(K, V)和(K, W)对的 DStream 上调用cogroup()时,它将返回一个新的 DStream,其中的元素是(K, Seq[V], Seq[W])*元组。 |
transform(func) | 这在源 DStream 的每个 RDD 上应用一个转换函数,并返回一个新的 DStream。 |
updateStateByKey(func) | 这通过在每个键的先前状态和该键的新值上应用给定的函数,更新每个键的状态。通常用于维持一个状态机。 |
窗口操作
Spark Streaming 提供了窗口处理功能,允许你在滑动窗口中的事件上应用转换。滑动窗口是在指定的间隔上创建的。每当窗口滑过一个源 DStream 时,符合窗口规范的源 RDD 会被合并并进行操作,生成窗口化的 DStream。窗口有两个参数需要指定:
-
窗口长度:这是指定的窗口考虑的时间间隔长度
-
滑动间隔:这是窗口创建的间隔。
窗口长度和滑动间隔必须都是块间隔的倍数。
下面展示了一个示意图,显示了一个 DStream 的滑动窗口操作,演示了旧窗口(虚线矩形)如何滑动一个间隔到右边,进入新的窗口(实线矩形):
一些常见的窗口操作如下。
| 转换 | 含义 |
|---|---|
window(windowLength, slideInterval) | 这在源 DStream 上创建一个窗口,并返回相同的 DStream 作为新的 DStream。 |
countByWindow(windowLength, slideInterval) | 这通过应用滑动窗口返回 DStream 中元素的计数。 |
reduceByWindow(func, windowLength, slideInterval) | 这通过在源 DStream 的每个元素上应用 reduce 函数,并在创建一个长度为windowLength的滑动窗口后,返回一个新的 DStream。 |
reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]) | 这通过在源 DStream 的 RDD 上应用窗口进行按键聚合,并返回一个新的 DStream,其中的元素是(键,值)对。计算由func函数提供。 |
reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]) | 该函数通过键对源 DStream 的 RDD 进行窗口聚合,并返回一个新的包含(键,值)对的 DStream。与前面的函数的主要区别在于invFunc,它提供了在滑动窗口开始时需要执行的计算。 |
countByValueAndWindow(windowLength, slideInterval, [numTasks]) | 该函数计算每个键的频率,并返回一个新的包含(键,长整型)对的 DStream,该 DStream 符合指定的滑动窗口。 |
让我们更详细地看一下 Twitter 流的示例。我们的目标是每 5 秒打印推文中使用的前五个单词,使用一个长度为 15 秒、每 10 秒滑动一次的窗口。因此,我们可以在 15 秒内获取前五个单词。
要运行此代码,请按照以下步骤操作:
-
首先,打开终端并切换到
spark-2.1.1-bin-hadoop2.7目录。 -
在
spark-2.1.1-bin-hadoop2.7文件夹下创建一个streamouts文件夹,该文件夹是你安装 spark 的地方。当应用程序运行时,streamouts文件夹将收集推文并保存为文本文件。 -
将以下 jar 下载到目录中:
-
使用指定所需 Twitter 集成的 jar 启动 spark-shell:
./bin/spark-shell --jars twitter4j-stream-4.0.6.jar,
twitter4j-core-4.0.6.jar,
spark-streaming-twitter_2.11-2.1.0.jar
- 现在,我们可以编写代码。下面是用于测试 Twitter 事件处理的代码:
import org.apache.log4j.Logger
import org.apache.log4j.Level
Logger.getLogger("org").setLevel(Level.OFF)
import java.util.Date
import org.apache.spark._
import org.apache.spark.streaming._
import org.apache.spark.streaming.Twitter._
import twitter4j.auth.OAuthAuthorization
import twitter4j.conf.ConfigurationBuilder
System.setProperty("twitter4j.oauth.consumerKey",
"8wVysSpBc0LGzbwKMRh8hldSm")
System.setProperty("twitter4j.oauth.consumerSecret",
"FpV5MUDWliR6sInqIYIdkKMQEKaAUHdGJkEb4MVhDkh7dXtXPZ")
System.setProperty("twitter4j.oauth.accessToken",
"817207925756358656-yR0JR92VBdA2rBbgJaF7PYREbiV8VZq")
System.setProperty("twitter4j.oauth.accessTokenSecret",
"JsiVkUItwWCGyOLQEtnRpEhbXyZS9jNSzcMtycn68aBaS")
val ssc = new StreamingContext(sc, Seconds(5))
val twitterStream = TwitterUtils.createStream(ssc, None)
val aggStream = twitterStream
.flatMap(x => x.getText.split(" "))
.filter(_.startsWith("#"))
.map(x => (x, 1))
.reduceByKeyAndWindow(_ + _, _ - _, Seconds(15),
Seconds(10), 5)
ssc.checkpoint("checkpoints")
aggStream.checkpoint(Seconds(10))
aggStream.foreachRDD((rdd, time) => {
val count = rdd.count()
if (count > 0) {
val dt = new Date(time.milliseconds)
println(s"\n\n$dt rddCount = $count\nTop 5 words\n")
val top5 = rdd.sortBy(_._2, ascending = false).take(5)
top5.foreach {
case (word, count) =>
println(s"[$word] - $count")
}
}
})
ssc.start
//wait 60 seconds
ss.stop(false)
- 输出每 15 秒显示在控制台上,输出结果类似如下:
Mon May 29 02:44:50 EDT 2017 rddCount = 1453
Top 5 words
[#RT] - 64
[#de] - 24
[#a] - 15
[#to] - 15
[#the] - 13
Mon May 29 02:45:00 EDT 2017 rddCount = 3312
Top 5 words
[#RT] - 161
[#df] - 47
[#a] - 35
[#the] - 29
[#to] - 29
有状态/无状态转换
如前所述,Spark Streaming 使用 DStream 的概念,DStream 本质上是以 RDD 形式创建的数据微批次。我们还看到了 DStream 上可能进行的转换类型。DStream 的转换可以分为两种类型:无状态转换和有状态转换。
在无状态转换中,每个数据微批的处理不依赖于先前批次的数据。因此,这是一种无状态转换,每个批次独立处理,而不依赖于之前发生的任何事情。
在有状态转换中,每个微批数据的处理都完全或部分依赖于之前的数据批次。因此,这是一个有状态转换,每个批次在计算当前批次数据时都会考虑之前发生的事情,并利用这些信息。
无状态转换
无状态转换通过对 DStream 中的每个 RDD 应用转换,将一个 DStream 转换为另一个 DStream。像map()、flatMap()、union()、join()和reduceByKey等转换都属于无状态转换的例子。
以下是一个插图,展示了对inputDStream进行map()转换以生成新的mapDstream:
有状态转换
有状态转换操作一个 DStream,但计算依赖于处理的前一状态。像countByValueAndWindow、reduceByKeyAndWindow、mapWithState和updateStateByKey等操作都是有状态转换的例子。事实上,所有基于窗口的转换都是有状态的,因为根据窗口操作的定义,我们需要跟踪 DStream 的窗口长度和滑动间隔。
检查点
实时流式应用程序旨在长时间运行,并能够容忍各种故障。Spark Streaming 实现了一种检查点机制,能够保留足够的信息,以便在发生故障时进行恢复。
需要进行检查点的数据有两种类型:
-
元数据检查点
-
数据检查点
可以通过在StreamingContext上调用checkpoint()函数来启用检查点功能,如下所示:
def checkpoint(directory: String)
指定检查点数据将可靠存储的目录。
请注意,这必须是容错的文件系统,如 HDFS。
一旦设置了检查点目录,任何 DStream 都可以根据指定的间隔将数据检查到该目录中。以 Twitter 示例为例,我们可以每 10 秒将每个 DStream 检查到checkpoints目录中:
val ssc = new StreamingContext(sc, Seconds(5))
val twitterStream = TwitterUtils.createStream(ssc, None)
val wordStream = twitterStream.flatMap(x => x.getText().split(" "))
val aggStream = twitterStream
.flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
.map(x => (x, 1))
.reduceByKeyAndWindow(_ + _, _ - _, Seconds(15), Seconds(10), 5)
ssc.checkpoint("checkpoints")
aggStream.checkpoint(Seconds(10))
wordStream.checkpoint(Seconds(10))
checkpoints目录在几秒钟后看起来像下面这样,显示了元数据以及 RDD 和logfiles作为检查点的一部分:
元数据检查点
元数据检查点保存定义流式操作的信息,这些操作由有向无环图(DAG)表示,并将其保存到 HDFS 中。这些信息可以在发生故障并重新启动应用程序时用于恢复 DAG。驱动程序会重新启动并从 HDFS 读取元数据,重建 DAG 并恢复崩溃前的所有操作状态。
元数据包括以下内容:
-
配置:用于创建流式应用程序的配置
-
DStream 操作:定义流式应用程序的 DStream 操作集
-
不完整批次:排队中的作业但尚未完成的批次
数据检查点
数据检查点将实际的 RDD 保存到 HDFS 中,这样,如果流应用程序发生故障,应用程序可以恢复检查点的 RDD 并从上次中断的地方继续。虽然流应用程序恢复是数据检查点的一个典型应用场景,但检查点也有助于在某些 RDD 因缓存清理或执行器丢失而丢失时,通过实例化生成的 RDD 而无需等待所有父 RDD 在 DAG(有向无环图)中重新计算,从而实现更好的性能。
对于具有以下任何要求的应用程序,必须启用检查点:
-
有状态转换的使用:如果应用程序中使用了
updateStateByKey或reduceByKeyAndWindow(带有逆向函数),则必须提供检查点目录,以允许周期性地进行 RDD 检查点。 -
从驱动程序故障中恢复:元数据检查点用于通过进度信息进行恢复。
如果您的流应用程序没有使用有状态转换,那么可以在不启用检查点的情况下运行该应用程序。
您的流应用程序可能会丢失接收到但尚未处理的数据。
请注意,RDD 的检查点会产生将每个 RDD 保存到存储中的成本。这可能导致检查点化的 RDD 所在的批次处理时间增加。因此,必须小心设置检查点的间隔,以避免引发性能问题。在非常小的批次(例如 1 秒)下,检查点每个微小批次的频率过高,可能会显著降低操作吞吐量。相反,检查点的频率过低会导致血统和任务大小增长,这可能会引起处理延迟,因为需要持久化的数据量较大。
对于需要 RDD 检查点的有状态转换,默认间隔是批处理间隔的倍数,至少为 10 秒。
DStream 的 5 到 10 个滑动间隔的检查点间隔是一个良好的初始设置。
驱动程序故障恢复
驱动程序故障恢复可以通过使用StreamingContext.getOrCreate()来实现,该方法可以从现有的检查点初始化StreamingContext或创建一个新的StreamingContext。
启动流应用程序时需要满足以下两个条件:
-
当程序第一次启动时,需要创建一个新的
StreamingContext,设置所有的流,然后调用start()。 -
当程序在失败后重新启动时,需要从检查点目录中的检查点数据初始化一个
StreamingContext,然后调用start()。
我们将实现一个函数 createStreamContext(),该函数创建 StreamingContext 并设置各种 DStreams 来解析推文,并使用窗口每 15 秒生成前五个推文标签。但是,我们不会调用 createStreamContext() 然后调用 ssc.start(),而是会调用 getOrCreate(),这样如果 checkpointDirectory 存在,则将从检查点数据重新创建上下文。如果目录不存在(应用程序首次运行),则将调用 createStreamContext() 来创建新的上下文并设置 DStreams:
val ssc = StreamingContext.getOrCreate(checkpointDirectory,
createStreamContext _)
下面显示的代码显示了函数定义以及如何调用 getOrCreate():
val checkpointDirectory = "checkpoints"
// Function to create and setup a new StreamingContext
def createStreamContext(): StreamingContext = {
val ssc = new StreamingContext(sc, Seconds(5))
val twitterStream = TwitterUtils.createStream(ssc, None)
val wordStream = twitterStream.flatMap(x => x.getText().split(" "))
val aggStream = twitterStream
.flatMap(x => x.getText.split(" ")).filter(_.startsWith("#"))
.map(x => (x, 1))
.reduceByKeyAndWindow(_ + _, _ - _, Seconds(15), Seconds(10), 5)
ssc.checkpoint(checkpointDirectory)
aggStream.checkpoint(Seconds(10))
wordStream.checkpoint(Seconds(10))
aggStream.foreachRDD((rdd, time) => {
val count = rdd.count()
if (count > 0) {
val dt = new Date(time.milliseconds)
println(s"\n\n$dt rddCount = $count\nTop 5 words\n")
val top10 = rdd.sortBy(_._2, ascending = false).take(5)
top10.foreach {
case (word, count) => println(s"[$word] - $count")
}
}
})
ssc
}
// Get StreamingContext from checkpoint data or create a new one
val ssc = StreamingContext.getOrCreate(checkpointDirectory, createStreamContext _)
与流处理平台(Apache Kafka)的互操作性
Spark Streaming 与 Apache Kafka 集成非常好,后者是目前最流行的消息平台。Kafka 集成有多种方法,并且随着时间的推移机制已经演变,以提高性能和可靠性。
有三种主要方法可以将 Spark Streaming 与 Kafka 集成:
-
基于接收器的方法
-
直接流处理方法
-
结构化流处理
基于接收器的方法
基于接收器的方法是 Spark 和 Kafka 之间的第一个集成方法。在此方法中,驱动程序在执行器上启动接收器,使用高级 API 从 Kafka brokers 拉取数据。由于接收器从 Kafka brokers 拉取事件,接收器会将偏移量更新到 Zookeeper 中,这也被 Kafka 集群使用。关键之处在于使用 WAL(Write Ahead Log),接收器在消费 Kafka 数据时持续写入 WAL。因此,当存在问题并且执行器或接收器丢失或重启时,可以使用 WAL 恢复事件并处理它们。因此,这种基于日志的设计提供了持久性和一致性。
每个接收器都会创建一个来自 Kafka 主题的输入 DStream,并查询 Zookeeper 获取 Kafka 主题、brokers、偏移量等信息。在此之后,我们之前讨论的 DStreams 将发挥作用。
长时间运行的接收器使得并行性复杂化,因为随着应用程序的扩展,工作负载不会被正确分配。依赖于 HDFS 也是一个问题,还有写操作的重复。至于处理的幂等性所需的可靠性,只有幂等方法才能起作用。基于接收器的方法之所以无法使用事务方法,是因为无法从 HDFS 位置或 Zookeeper 访问偏移范围。
基于接收器的方法适用于任何消息系统,因此更通用。
您可以通过调用 createStream() API 来创建基于接收器的流,如下所示:
def createStream(
ssc: StreamingContext, // StreamingContext object
zkQuorum: String, //Zookeeper quorum (hostname:port,hostname:port,..)
groupId: String, //The group id for this consumer
topics: Map[String, Int], //Map of (topic_name to numPartitions) to
consume. Each partition is consumed in its own thread
storageLevel: StorageLevel = StorageLevel.MEMORY_AND_DISK_SER_2
Storage level to use for storing the received objects
(default: StorageLevel.MEMORY_AND_DISK_SER_2)
): ReceiverInputDStream[(String, String)] //DStream of (Kafka message key, Kafka message value)
以下显示了一个示例,展示了如何创建一个从 Kafka brokers 拉取消息的基于接收器的流:
val topicMap = topics.split(",").map((_, numThreads.toInt)).toMap
val lines = KafkaUtils.createStream(ssc, zkQuorum, group,
topicMap).map(_._2)
下图展示了驱动程序如何在执行器上启动接收器,通过高级 API 从 Kafka 拉取数据。接收器从 Kafka Zookeeper 集群中拉取主题偏移范围,然后在从代理拉取事件时更新 Zookeeper:
直接流
基于直接流的方法是相较于 Kafka 集成的新方法,它通过驱动程序直接连接到代理并拉取事件。关键点在于,使用直接流 API 时,Spark 任务与 Kafka 主题/分区之间是 1:1 的关系。这种方法不依赖于 HDFS 或 WAL,使其更加灵活。而且,由于我们现在可以直接访问偏移量,可以使用幂等或事务性的方法进行精确一次处理。
创建一个输入流,直接从 Kafka 代理拉取消息,无需使用任何接收器。此流可以保证从 Kafka 来的每条消息都在转换中仅出现一次。
直接流的属性如下:
-
没有接收器:此流不使用任何接收器,而是直接查询 Kafka。
-
偏移量:此方法不使用 Zookeeper 来存储偏移量,消费的偏移量由流本身跟踪。你可以从生成的 RDD 中访问每个批次使用的偏移量。
-
故障恢复:为了从驱动程序故障中恢复,你必须在
StreamingContext中启用检查点。 -
端到端语义:此流确保每条记录都被有效接收并且转换仅发生一次,但无法保证转换后的数据是否准确地输出一次。
你可以通过使用 KafkaUtils,createDirectStream() API 来创建直接流,如下所示:
def createDirectStream[
K: ClassTag, //K type of Kafka message key
V: ClassTag, //V type of Kafka message value
KD <: Decoder[K]: ClassTag, //KD type of Kafka message key decoder
VD <: Decoder[V]: ClassTag, //VD type of Kafka message value decoder
R: ClassTag //R type returned by messageHandler
](
ssc: StreamingContext, //StreamingContext object
KafkaParams: Map[String, String],
/*
KafkaParams Kafka <a href="http://Kafka.apache.org/documentation.html#configuration">
configuration parameters</a>. Requires "metadata.broker.list" or "bootstrap.servers"
to be set with Kafka broker(s) (NOT zookeeper servers) specified in
host1:port1,host2:port2 form.
*/
fromOffsets: Map[TopicAndPartition, Long], //fromOffsets Per- topic/partition Kafka offsets defining the (inclusive) starting point of the stream
messageHandler: MessageAndMetadata[K, V] => R //messageHandler Function for translating each message and metadata into the desired type
): InputDStream[R] //DStream of R
下图展示了一个示例,说明如何创建一个直接流,从 Kafka 主题拉取数据并创建 DStream:
val topicsSet = topics.split(",").toSet
val KafkaParams : Map[String, String] =
Map("metadata.broker.list" -> brokers,
"group.id" -> groupid )
val rawDstream = KafkaUtils.createDirectStreamString, String, StringDecoder, StringDecoder
直接流 API 只能与 Kafka 一起使用,因此这不是一种通用方法。
下图展示了驱动程序如何从 Zookeeper 拉取偏移量信息,并指导执行器根据驱动程序指定的偏移范围启动任务,从 Kafka 代理拉取事件:
结构化流处理
结构化流处理是 Apache Spark 2.0+中的新特性,从 Spark 2.2 版本开始已进入 GA 阶段。接下来你将看到详细信息,并附有如何使用结构化流处理的示例。
关于结构化流处理中的 Kafka 集成的更多详细信息,请参阅spark.apache.org/docs/latest/structured-streaming-kafka-integration.html。
如何在结构化流处理中使用 Kafka 源流的示例如下:
val ds1 = spark
.readStream
.format("Kafka")
.option("Kafka.bootstrap.servers", "host1:port1,host2:port2")
.option("subscribe", "topic1")
.load()
ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
.as[(String, String)]
如何使用 Kafka 源流而不是源流(如果你需要更多批处理分析方法)的示例如下:
val ds1 = spark
.read
.format("Kafka")
.option("Kafka.bootstrap.servers", "host1:port1,host2:port2")
.option("subscribe", "topic1")
.load()
ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
.as[(String, String)]
结构化流处理
结构化流处理是构建在 Spark SQL 引擎之上的一个可扩展且具有容错性的流处理引擎。这使得流处理和计算更加接近批处理,而不是当前 Spark 流处理 API 所面临的 DStream 范式及其挑战。结构化流引擎解决了多个挑战,例如精准一次流处理、增量更新处理结果、聚合等。
结构化流处理 API 还提供了解决 Spark 流处理的一个重大挑战的方法,即,Spark 流处理是以微批次方式处理传入数据,并使用接收时间作为拆分数据的依据,因此并不考虑数据的实际事件时间。结构化流处理允许你在接收到的数据中指定事件时间,从而自动处理任何迟到的数据。
结构化流处理在 Spark 2.2 中已经是 GA(一般可用版),并且 API 已标记为 GA。参考spark.apache.org/docs/latest/structured-streaming-programming-guide.html。
结构化流处理的核心思想是将实时数据流视为一个无限制的表,随着事件的处理,该表会不断地被附加新的数据。你可以像对待批量数据一样,对这个无限制的表进行计算和 SQL 查询。例如,Spark SQL 查询会处理这个无限制的表:
随着 DStream 随时间变化,越来越多的数据将被处理以生成结果。因此,无限制输入表被用来生成结果表。输出或结果表可以写入被称为输出的外部存储。
输出是指写入的内容,可以通过不同模式进行定义:
-
完整模式:整个更新后的结果表将写入外部存储。由存储连接器决定如何处理整个表的写入。
-
追加模式:仅将自上次触发以来附加到结果表中的新行写入外部存储。这仅适用于那些预期结果表中的现有行不会发生变化的查询。
-
更新模式:自上次触发以来仅更新的结果表中的行将写入外部存储。请注意,这与完整模式不同,因为该模式只输出自上次触发以来发生变化的行。如果查询不包含聚合操作,那么它将等同于追加模式。
以下展示的是来自无限制表的输出示意图:
我们将展示一个通过监听本地主机端口 9999 创建结构化流查询的示例。
如果使用 Linux 或 Mac,启动一个简单的服务器并监听端口 9999 非常简单:nc -lk 9999。
下面是一个示例,我们首先创建一个inputStream,调用 SparkSession 的readStream API,然后从行中提取单词。接着,我们对单词进行分组并计算出现次数,最后将结果写入输出流:
//create stream reading from localhost 9999
val inputLines = spark.readStream
.format("socket")
.option("host", "localhost")
.option("port", 9999)
.load()
inputLines: org.apache.spark.sql.DataFrame = [value: string]
// Split the inputLines into words
val words = inputLines.as[String].flatMap(_.split(" "))
words: org.apache.spark.sql.Dataset[String] = [value: string]
// Generate running word count
val wordCounts = words.groupBy("value").count()
wordCounts: org.apache.spark.sql.DataFrame = [value: string, count: bigint]
val query = wordCounts.writeStream
.outputMode("complete")
.format("console")
query: org.apache.spark.sql.streaming.DataStreamWriter[org.apache.spark.sql.Row] = org.apache.spark.sql.streaming.DataStreamWriter@4823f4d0
query.start()
当你继续在终端输入时,查询会不断更新并生成结果,这些结果会打印到控制台:
scala> -------------------------------------------
Batch: 0
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 1|
+-----+-----+
-------------------------------------------
Batch: 1
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 1|
| cat| 1|
+-----+-----+
scala> -------------------------------------------
Batch: 2
-------------------------------------------
+-----+-----+
|value|count|
+-----+-----+
| dog| 2|
| cat| 1|
+-----+-----+
处理事件时间和迟到数据
事件时间是数据本身内部的时间。传统的 Spark Streaming 只将时间视为接收时间,用于 DStream 的目的,但对于许多应用程序来说,这不足以满足需求,我们需要的是事件时间。例如,如果你想要每分钟计算推文中某个标签出现的次数,那么你应该使用数据生成的时间,而不是 Spark 接收事件的时间。为了在结构化流处理中引入事件时间,可以将事件时间视为行/事件中的一列。这使得基于窗口的聚合可以使用事件时间而非接收时间来运行。此外,这种模型自然处理比预期晚到达的数据,因为它基于事件时间进行处理。由于 Spark 正在更新结果表,它完全控制在出现迟到数据时如何更新旧的聚合,并清理旧的聚合以限制中间状态数据的大小。同时,还支持对事件流进行水印处理,允许用户指定迟到数据的阈值,并使引擎根据该阈值清理旧状态。
水印使引擎能够追踪当前事件时间,并通过检查接收数据的迟到阈值,判断事件是否需要处理或已经处理。例如,假设事件时间用eventTime表示,迟到数据的阈值间隔为lateThreshold,则通过检查max(eventTime) - lateThreshold与从时间 T 开始的特定窗口的比较,引擎可以确定该事件是否可以在该窗口中进行处理。
下面显示的是前面示例的扩展,演示了结构化流处理监听端口 9999 的情况。在这里,我们启用了Timestamp作为输入数据的一部分,这样我们就可以在无限制的表上执行窗口操作以生成结果:
import java.sql.Timestamp import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._ // Create DataFrame representing the stream of input lines from connection to host:port
val inputLines = spark.readStream
.format("socket")
.option("host", "localhost")
.option("port", 9999)
.option("includeTimestamp", true)
.load() // Split the lines into words, retaining timestamps
val words = inputLines.as[(String, Timestamp)].flatMap(line =>
line._1.split(" ").map(word => (word, line._2))
).toDF("word", "timestamp") // Group the data by window and word and compute the count of each group
val windowedCounts = words.withWatermark("timestamp", "10 seconds")
.groupBy(
window($"timestamp", "10 seconds", "10 seconds"), $"word"
).count().orderBy("window") // Start running the query that prints the windowed word counts to the console
val query = windowedCounts.writeStream
.outputMode("complete")
.format("console")
.option("truncate", "false")
query.start()
query.awaitTermination()
容错语义
实现 端到端精确一次语义 是设计结构化流处理的关键目标之一,它通过实现结构化流处理源、输出端和执行引擎,可靠地跟踪处理的精确进度,从而在发生任何类型的失败时通过重启和/或重新处理来处理。每个流源都假定具有偏移量(类似于 Kafka 偏移量),用来跟踪流中的读取位置。引擎使用检查点和预写日志来记录每个触发器中正在处理数据的偏移范围。流输出端被设计为幂等性,以便处理重新处理操作。通过使用可重放的流源和幂等性输出端,结构化流处理可以确保在任何失败情况下实现端到端的精确一次语义。
记住,传统流处理中使用外部数据库或存储来维护偏移量时,"精确一次"的范式更加复杂。
结构化流处理仍在发展中,面临一些挑战需要克服,才能广泛应用。以下是其中的一些挑战:
-
在流数据集上,不支持多重流聚合操作。
-
在流数据集上,不支持限制或获取前 N 行操作。
-
流数据集上的去重操作不被支持。
-
只有在执行聚合步骤后,并且仅在完全输出模式下,才支持对流数据集进行排序操作。
-
目前尚不支持两个流数据集之间的任何类型连接操作。
-
目前只支持少数几种类型的输出端 - 文件输出端和每个输出端。
小结
在本章中,我们讨论了流处理系统的概念,Spark 流处理、Apache Spark 的 DStreams、DStreams 的定义、DAGs 和 DStreams 的血统、转换和动作。我们还探讨了流处理中的窗口概念。最后,我们还看了一个实际示例,使用 Spark Streaming 消费 Twitter 中的推文。
此外,我们还研究了基于接收者和直接流式处理的两种从 Kafka 消费数据的方法。最后,我们还看了新型的结构化流处理,它承诺解决许多挑战,比如流处理中的容错性和"精确一次"语义问题。我们还讨论了结构化流处理如何简化与消息系统(如 Kafka 或其他消息系统)的集成。
在下一章,我们将探讨图形处理及其工作原理。