大数据笔记(二)Spark(2)

727 阅读21分钟

四、Spark Streaming:Spark的实时流计算API

Spark Streaming 的原理

Spark Streaming 的原理与微积分的思想很类似。

在大学的微积分课上,你的老师一定说过,微分就是无限细分,积分就是对无限细分的每一段进行求和。它本质上把一个连续的问题转换成了无限个离散的问题。

比如,用微积分思想求下图中阴影部分 S 的面积。

你知道,流处理的数据是一系列连续不断变化,且无边界的。我们永远无法预测下一秒的数据是什么样。Spark Streaming 用时间片拆分了无限的数据流,然后对每一个数据片用类似于批处理的方法进行处理,输出的数据也是一块一块的。如下图所示。

Spark Streaming 提供一个对于流数据的抽象 DStream。DStream 可以由来自 Apache Kafka、Flume 或者 HDFS 的流数据生成,也可以由别的 DStream 经过各种转换操作得来。

底层 DStream 也是由很多个序列化的 RDD 构成,按时间片(比如一秒)切分成的每个数据单位都是一个 RDD。然后,Spark 核心引擎将对 DStream 的 Transformation 操作变为针对 Spark 中对 RDD 的 Transformation 操作,将 RDD 经过操作变成中间结果保存在内存中。

之前的 DataFrame 和 DataSet 也是同样基于 RDD,所以说 RDD 是 Spark 最基本的数据抽象。就像 Java 里的基本数据类型(Primitive Type)一样,所有的数据都可以用基本数据类型描述。

也正是因为这样,无论是 DataFrame,还是 DStream,都具有 RDD 的不可变性、分区性和容错性等特质。

DStream

下图就是 DStream 的内部形式,即一个连续的 RDD 序列,每一个 RDD 代表一个时间窗口的输入数据流。

对 DStream 的转换操作,意味着对它包含的每一个 RDD 进行同样的转换操作。比如下边的例子。

sc = SparkContext(master, appName)
ssc = StreamingContext(sc, 1)
lines = sc.socketTextStream("localhost", 9999)
words = lines.flatMap(lambda line: line.split(" "))

首先,我们创建了一个 lines 的 DStream,去监听来自本机 9999 端口的数据流,每一个数据代表一行文本。然后,对 lines 进行 flatMap 的转换操作,把每一个文本行拆分成词语。 本质上,对一个 DStream 进行 flatMap 操作,就是对它里边的每一个 RDD 进行 flatMap 操作,生成了一系列新的 RDD,构成了一个新的代表词语的 DStream。

滑动窗口操作

任何 Spark Streaming 的程序都要首先创建一个StreamingContext的对象,它是所有 Streaming 操作的入口

比如,我们可以通过 StreamingContext 来创建 DStream。前边提到的例子中,lines 这个 DStream 就是由名为 sc 的 StreamingContext 创建的。

StreamingContext 中最重要的参数是批处理的时间间隔,即把流数据细分成数据块的粒度。这个时间间隔决定了流处理的延迟性,所以,需要我们根据需求和资源来权衡间隔的长度。上边的例子中,我们把输入的数据流以秒为单位划分,每一秒的数据会生成一个 RDD 进行运算。

有些场景中,我们需要每隔一段时间,统计过去某个时间段内的数据。比如,对热点搜索词语进行统计,每隔 10 秒钟输出过去 60 秒内排名前十位的热点词。这是流处理的一个基本应用场景,很多流处理框架如 Apache Flink 都有原生的支持。所以,Spark 也同样支持滑动窗口操作。

从统计热点词这个例子,你可以看出滑动窗口操作有两个基本参数:

  • 窗口长度(window length):每次统计的数据的时间跨度,在例子中是 60 秒;
  • 滑动间隔(sliding interval):每次统计的时间间隔,在例子中是 10 秒。

显然,由于 Spark Streaming 流处理的最小时间单位就是 StreamingContext 的时间间隔,所以这两个参数一定是它的整数倍。

最基本的滑动窗口操作是 window,它可以返回一个新的 DStream,这个 DStream 中每个 RDD 代表一段时间窗口内的数据,如下例所示。

windowed_words = words.window(60, 10)

windowed_words 代表的就是热词统计例子中我们所需的 DStream,即它里边每一个数据块都包含过去 60 秒内的词语,而且这样的块每 10 秒钟就会生成一个。

此外,Spark Streaming 还支持一些“进阶”窗口操作。如 countByWindow、reduceByWindow、reduceByKeyAndWindow 和 countByValueAndWindow。

Spark Streaming 的优缺点

首先,Spark Streaming 的优点很明显,由于它的底层是基于 RDD 实现的,所以 RDD 的优良特性在它这里都有体现。比如,数据容错性,如果 RDD 的某些分区丢失了,可以通过依赖信息重新计算恢复。

再比如运行速度,DStream 同样也能通过 persist() 方法将数据流存放在内存中。这样做的好处是遇到需要多次迭代计算的程序时,速度优势十分明显。

而且,Spark Streaming 是 Spark 生态的一部分。所以,它可以和 Spark 的核心引擎、Spark SQL、MLlib 等无缝衔接。换句话说,对实时处理出来的中间数据,我们可以立即在程序中无缝进行批处理、交互式查询等操作。这个特点大大增强了 Spark Streaming 的优势和功能,使得基于 Spark Streaming 的应用程序很容易扩展。

而 Spark Streaming 的主要缺点是实时计算延迟较高,一般在秒的级别。这是由于 Spark Streaming 不支持太小的批处理的时间间隔。

五、如何用DataFrame API进行实时数据分析?

DataSet/DataFrame 的优点:

  • DataFrame 是高级 API,提供类似于SQL的 query 接口,方便熟悉关系型数据库的开发人员使用;
  • Spark SQL 执行引擎会自动优化 DataFrame 程序,而用 RDD API 开发的程序本质上需要工程师自己构造 RDD 的 DAG 执行图,所以依赖于工程师自己去优化。

Structured Streaming 模型

流数据处理最基本的问题就是如何对不断更新的无边界数据建模

之前讲的 Spark Streaming 就是把流数据按一定的时间间隔分割成许多个小的数据块进行批处理。在 Structured Streaming 的模型中,我们要把数据看成一个无边界的关系型的数据表。每一个数据都是表中的一行,不断会有新的数据行被添加到表里来。我们可以对这个表做任何类似批处理的查询,Spark 会帮我们不断对新加入的数据进行处理,并更新计算结果。

与 Spark Streaming 类似,Structured Streaming 也是将输入的数据流按照时间间隔(以一秒为例)划分成数据段。每一秒都会把新输入的数据添加到表中,Spark 也会每秒更新输出结果。输出结果也是表的形式,输出表可以写入硬盘或者 HDFS

Structured Streaming 的三种输出模式。

  • 完全模式(Complete Mode):整个更新过的输出表都被写入外部存储;
  • 附加模式(Append Mode):上一次触发之后新增加的行才会被写入外部存储。如果老数据有改动则不适合这个模式;
  • 更新模式(Update Mode):上一次触发之后被更新的行才会被写入外部存储。

需要注意的是,Structured Streaming 并不会完全存储输入数据。每个时间间隔它都会读取最新的输入,进行处理,更新输出表,然后把这次的输入删除。Structured Streaming 只会存储更新输出表所需要的信息

Structured Streaming 的模型在根据事件时间(Event Time)处理数据时十分方便。事件时间指的是事件发生的时间,是数据本身的属性;而处理时间指的是 Spark 接收到数据的时间。很多情况下,我们需要基于事件时间来处理数据。比如说,统计每个小时接到的订单数量,一个订单很有可能在 12:59 被创建,但是到了 13:01 才被处理。

在 Structured Streaming 的模型中,由于每个数据都是输入数据表中的一行,那么事件时间就是行中的一列。依靠 DataFrame API 提供的类似于 SQL 的接口,我们可以很方便地执行基于时间窗口的查询。

Streaming DataFrame API

在 Structured Streaming 发布以后,DataFrame 既可以代表静态的有边界数据,也可以代表无边界数据。之前对静态 DataFrame 的各种操作同样也适用于流式 DataFrame。接下来,让我们看几个例子。

创建 DataFrame

SparkSession.readStream() 返回的 DataStreamReader 可以用于创建流 DataFrame。它支持多种类型的数据流作为输入,比如文件、Kafka、socket 等。

socketDataFrame = spark
   .readStream
   .format("socket")
   .option("host", "localhost")
   .option("port", 9999)
   .load()

上边的代码例子创建了一个 DataFrame,用来监听来自 localhost:9999 的数据流。

基本的查询操作

流 DataFrame 同静态 DataFrame 一样,不仅支持类似 SQL 的查询操作(如 select 和 where 等),还支持 RDD 的转换操作(如 map 和 filter)。 让我们一起来看下面的例子。

假设我们已经有一个 DataFrame 代表一个学生的数据流,即每个数据是一个学生,每个学生有名字(name)、年龄(age)、身高(height)和年级(grade)四个属性,我们可以用 DataFrame API 去做类似于 SQL 的 Query。

df = … // 这个 DataFrame 代表学校学生的数据流,schema 是{name: string, age: number, height: number, grade: string}
df.select("name").where("age > 10") // 返回年龄大于 10 岁的学生名字列表
df.groupBy("grade").count() // 返回每个年级学生的人数
df.sort_values([‘age’], ascending=False).head(100) // 返回 100 个年龄最大的学生 

在这个例子中,通过第二行我们可以得到所有年龄在 10 岁以上的学生名字,第三行可以得到每个年级学生的人数,第四行得到 100 个年龄最大的学生信息。此外,DataFrame 还支持很多基本的查询操作,在此不做赘述。

我们还可以通过 isStreaming 函数来判断一个 DataFrame 是否代表流数据。

df.isStreaming()

基于事件时间的时间窗口操作

我们举过一个例子,是每隔 10 秒钟输出过去 60 秒的前十热点词。这个例子是基于处理时间而非事件时间的。

现在让我们设想一下,如果数据流中的每个词语都有一个时间戳代表词语产生的时间,那么要怎样实现,每隔 10 秒钟输出过去 60 秒内产生的前十热点词呢?你可以看看下边的代码。

words = ...  # 这个 DataFrame 代表词语的数据流,schema 是 { timestamp: Timestamp, word: String}
 
 
windowedCounts = words.groupBy(
   window(words.timestamp, "1 minute", "10 seconds"),
   words.word
).count()
.sort(desc("count"))
.limit(10)

基于词语的生成时间,我们创建了一个窗口长度为 1 分钟,滑动间隔为 10 秒的 window。然后,把输入的词语表根据 window 和词语本身聚合起来,并统计每个 window 内每个词语的数量。之后,再根据词语的数量进行排序,只返回前 10 的词语。

在 Structured Streaming 基于时间窗口的聚合操作中,groupBy 是非常常用的。

输出结果流

当经过各种 SQL 查询操作之后,我们创建好了代表最终结果的 DataFrame。下一步就是开始对输入数据流的处理,并且持续输出结果。

我们可以用 Dataset.writeStream() 返回的 DataStreamWriter 对象去输出结果。它支持多种写入位置,如硬盘文件、Kafka、console 和内存等。

query = wordCounts
   .writeStream
   .outputMode("complete")
   .format("csv")
   .option("path", "path/to/destination/dir")
   .start()
 
 
query.awaitTermination()

在上面这个代码例子中,我们选择了完全模式,把输出结果流写入了 CSV 文件。

Structured Streaming 与 Spark Streaming 对比

接下来,让我们对比一下 Structured Streaming 和上一讲学过的 Spark Streaming。看看同为流处理的组件的它们各有什么优缺点。

简易度和性能

Spark Streaming 提供的 DStream API 与 RDD API 很类似,相对比较低 level。

当我们编写 Spark Streaming 程序的时候,本质上就是要去构造 RDD 的 DAG 执行图,然后通过 Spark Engine 运行。这样开发者身上的担子就很重,很多时候要自己想办法去提高程序的处理效率。这不是 Spark 作为一个数据处理框架想看到的。对于好的框架来说,开发者只需要专注在业务逻辑上,而不用操心别的配置、优化等繁杂事项。

Structured Streaming 提供的 DataFrame API 就是这么一个相对高 level 的 API,大部分开发者都很熟悉关系型数据库和 SQL。这样的数据抽象可以让他们用一套统一的方案去处理批处理和流处理,不用去关心具体的执行细节。

而且,DataFrame API 是在 Spark SQL 的引擎上执行的,Spark SQL 有非常多的优化功能,比如执行计划优化和内存管理等,所以 Structured Streaming 的应用程序性能很好。

实时性

Spark Streaming 是准实时的,它能做到的最小延迟在一秒左右。

虽然 Structured Streaming 用的也是类似的微批处理思想,每过一个时间间隔就去拿来最新的数据加入到输入数据表中并更新结果,但是相比起 Spark Streaming 来说,它更像是实时处理,能做到用更小的时间间隔,最小延迟在 100 毫秒左右

而且在 Spark 2.3 版本中,Structured Streaming 引入了连续处理的模式,可以做到真正的毫秒级延迟,这无疑大大拓展了 Structured Streaming 的应用广度。不过现在连续处理模式还有很多限制,让我们期待它的未来吧。

对事件时间的支持

就像我们在前边讲过的,Structured Streaming 对基于事件时间的处理有很好的支持。

由于 Spark Streaming 是把数据按接收到的时间切分成一个个 RDD 来进行批处理,所以它很难基于数据本身的产生时间来进行处理。如果某个数据的处理时间和事件时间不一致的话,就容易出问题。比如,统计每秒的词语数量,有的数据先产生,但是在下一个时间间隔才被处理,这样几乎不可能输出正确的结果。

Structured Streaming 还有很多其他优点。比如,它有更好的容错性,保证了端到端 exactly once 的语义等等。所以综合来说,Structured Streaming 是比 Spark Streaming 更好的流处理工具。

六、流处理案例实战

数据集介绍

今天的数据集是纽约市 2009~2015 年出租车载客的信息。每一次出行包含了两个事件,一个事件代表出发,另一个事件代表到达。每个事件都有 11 个属性,它的 schema 如下所示:

这部分数据有个不太直观的地方,那就是同一次出行会有两个记录,而且代表出发的事件没有任何意义,因为到达事件已经涵盖了所有必要的信息。现实世界中的数据都是这样复杂,不可能像学校的测试数据一样简单直观,所以处理之前,我们要先对数据进行清洗,只留下必要的信息。

这个数据还包含有另外一部分信息,就是所有出租车的付费信息,它有 8 个属性,schema 如下所示。

流数据输入

在这个例子中,Consumer 是之后要写的 Spark 流处理程序,这个消息队列有两个 Topic,一个包含出行的地理位置信息,一个包含出行的收费信息。Kafka 会按照时间顺序,向这两个 Topic 中发布事件,从而模拟一个实时的流数据源。

相信你还记得,写 Spark 程序的第一步就是创建 SparkSession 对象,并根据输入数据创建对应的 RDD 或者 DataFrame。你可以看下面的代码。

from pyspark.sql import SparkSession
 
spark = SparkSession.builder
   .appName("Spark Structured Streaming for taxi ride info")
   .getOrCreate()
 
rides = spark
   .readStream
   .format("kafka")
   .option("kafka.bootstrap.servers", "localhost:xxxx") // 取决于 Kafka 的配置
   .option("subscribe", "taxirides")
   .option("startingOffsets", "latest")
   .load()
   .selectExpr("CAST(value AS STRING)")
 
fares = spark
   .readStream
   .format("kafka")
   .option("kafka.bootstrap.servers", "localhost:xxxx")
   .option("subscribe", "taxifares")
   .option("startingOffsets", "latest")
   .load()
   .selectExpr("CAST(value AS STRING)

在这段代码里,我们创建了两个 Streaming DataFrame,并订阅了对应的 Kafka topic,一个代表出行位置信息,另一个代表收费信息。Kafka 对数据没有做任何修改,所以流中的每一个数据都是一个长 String,属性之间是用逗号分割的。

417986,END,2013-01-02 00:43:52,2013-01-02  00:39:56,-73.984528,40.745377,-73.975967,40.765533,1,2013007646,2013007642

数据清洗

现在,我们要开始做数据清洗了。要想分离出我们需要的位置和付费信息,我们首先要把数据分割成一个个属性,并创建对应的 DataFrame 中的列。为此,我们首先要根据数据类型创建对应的 schema。

ridesSchema = StructType([
   StructField("rideId", LongType()), StructField("isStart", StringType()),
   StructField("endTime", TimestampType()), StructField("startTime", TimestampType()),
   StructField("startLon", FloatType()), StructField("startLat", FloatType()),
   StructField("endLon", FloatType()), StructField("endLat", FloatType()),
   StructField("passengerCnt", ShortType()), StructField("taxiId", LongType()),
   StructField("driverId", LongType())])
 
faresSchema = StructType([
   StructField("rideId", LongType()), StructField("taxiId", LongType()),
   StructField("driverId", LongType()), StructField("startTime", TimestampType()),
   StructField("paymentType", StringType()), StructField("tip", FloatType()),
   StructField("tolls", FloatType()), StructField("totalFare", FloatType())])

接下来,我们将每个数据都用逗号分割,并加入相应的列。

def parse_data_from_kafka_message(sdf, schema):
   from pyspark.sql.functions import split
   assert sdf.isStreaming == True, "DataFrame doesn't receive streaming data"
   col = split(sdf['value'], ',')
   for idx, field in enumerate(schema):
       sdf = sdf.withColumn(field.name, col.getItem(idx).cast(field.dataType))
   return sdf.select([field.name for field in schema])
 
rides = parse_data_from_kafka_message(rides, ridesSchema)
fares = parse_data_from_kafka_message(fares, faresSchema)

在上面的代码中,我们定义了函数 parse_data_from_kafka_message,用来把 Kafka 发来的 message 根据 schema 拆成对应的属性,转换类型,并加入到 DataFrame 的表中。

正如我们之前提到的,读入的数据包含了一些无用信息。

首先,所有代表出发的事件都已被删除,因为到达事件已经包含了出发事件的所有信息,而且只有到达之后才会付费。

其次,出发地点和目的地在纽约范围外的数据,也可以被删除。因为我们的目标是找出纽约市内小费较高的地点。DataFrame 的 filter 函数可以很容易地做到这些。

MIN_LON, MAX_LON, MIN_LAT, MAX_LAT = -73.7, -74.05, 41.0, 40.5
rides = rides.filter(
   rides["startLon"].between(MIN_LON, MAX_LON) &
   rides["startLat"].between(MIN_LAT, MAX_LAT) &
   rides["endLon"].between(MIN_LON, MAX_LON) &
   rides["endLat"].between(MIN_LAT, MAX_LAT))
rides = rides.filter(rides["isStart"] == "END")

上面的代码中首先定义了纽约市的经纬度范围,然后把所有起点和终点在这个范围之外的数据都过滤掉了。最后,把所有代表出发事件的数据也移除掉。

当然,除了前面提到的清洗方案,可能还会有别的可以改进的地方,比如把不重要的信息去掉,例如乘客数量、过路费等,你可以自己思考一下。

Stream-stream Join

我们的目标是找出小费较高的地理区域,而现在收费信息和地理位置信息还在两个 DataFrame 中,无法放在一起分析。那么要用怎样的方式把它们联合起来呢?

你应该还记得,DataFrame 本质上是把数据当成一张关系型的表。在我们这个例子中,rides 所对应的表的键值(Key)是 rideId,其他列里我们关心的就是起点和终点的位置;fares 所对应的表键值也是 rideId,其他列里我们关心的就是小费信息(tips)。

说到这里,你可能会自然而然地想到,如果可以像关系型数据表一样,根据共同的键值 rideId 把两个表 inner join 起来,就可以同时分析这两部分信息了。但是这里的 DataFrame 其实是两个数据流,Spark 可以把两个流 Join 起来吗?

答案是肯定的。在 Spark 2.3 中,流与流的 Join(Stream-stream join)被正式支持。这样的 Join 难点就在于,在任意一个时刻,流数据都不是完整的,流 A 中后面还没到的数据有可能要和流 B 中已经有的数据 Join 起来再输出。为了解决这个问题,我们就要引入**数据水印(Watermark)**的概念。

数据水印定义了我们可以对数据延迟的最大容忍限度。

比如说,如果定义水印是 10 分钟,数据 A 的事件时间是 1:00,数据 B 的事件时间是 1:10,由于数据传输发生了延迟,我们在 1:15 才收到了 A 和 B,那么我们将只处理数据 B 并更新结果,A 会被无视。在 Join 操作中,好好利用水印,我们就知道什么时候可以不用再考虑旧数据,什么时候必须把旧数据保留在内存中。不然,我们就必须把所有旧数据一直存在内存里,导致数据不断增大,最终可能会内存泄漏。

在这个例子中,为什么我们做这样的 Join 操作需要水印呢?

这是因为两个数据流并不保证会同时收到同一次出行的数据,因为收费系统需要额外的时间去处理,而且这两个数据流是独立的,每个都有可能产生数据延迟。所以要对时间加水印,以免出现内存中数据无限增长的情况。

那么下一个问题就是,究竟要对哪个时间加水印,出发时间还是到达时间?

前面说过了,我们其实只关心到达时间,所以对 rides 而言,我们只需要对到达时间加水印。但是,在 fares 这个 DataFrame 里并没有到达时间的任何信息,所以我们没法选择,只能对出发时间加水印。因此,我们还需要额外定义一个时间间隔的限制,出发时间和到达时间的间隔要在一定的范围内。具体内容你可以看下面的代码。

faresWithWatermark = fares
   .selectExpr("rideId AS rideId_fares", "startTime", "totalFare", "tip")
   .withWatermark("startTime", "30 minutes")
 
ridesWithWatermark = rides
 .selectExpr("rideId", "endTime", "driverId", "taxiId", "startLon", "startLat", "endLon", "endLat")
 .withWatermark("endTime", "30 minutes")
 
joinDF = faresWithWatermark
   .join(ridesWithWatermark,
     expr("""
      rideId_fares = rideId AND
       endTime > startTime AND
       endTime <= startTime + interval 2 hours
       """)

在这段代码中,我们对 fares 和 rides 分别加了半小时的水印,然后把两个 DataFrame 根据 rideId 和时间间隔的限制 Join 起来。这样,joinDF 就同时包含了地理位置和付费信息。

接下来,就让我们开始计算实时的小费最高区域。

计算结果并输出

到现在为止,我们还没有处理地点信息。原生的经纬度信息显然并没有很大用处。我们需要做的是把纽约市分割成几个区域,把数据中所有地点的经纬度信息转化成区域信息,这样司机们才可以知道大概哪个地区的乘客比较可能给高点的小费。

纽约市的区域信息以及坐标可以从网上找到,这部分处理比较容易。每个接收到的数据我们都可以判定它在哪个区域内,然后对 joinDF 增加一个列“area”来代表终点的区域。现在,让我们假设 area 已经加到现有的 DataFrame 里。接下来我们需要把得到的信息告诉司机了。

滑动窗口操作是流处理中常见的输出形式,即输出每隔一段时间内,特定时间窗口的特征值。在这个例子中,我们可以每隔 10 分钟,输出过去半小时内每个区域内的平均小费。这样的话,司机可以每隔 10 分钟查看一下数据,决定下一步去哪里接单。这个查询(Query)可以由以下代码产生。

tips = joinDF
   .groupBy(
       window("endTime", "30 minutes", "10 minutes"),
       "area")
   .agg(avg("tip"))
最后,我们把 tips 这个流式 DataFrame 输出。

query.writeStream
   .outputMode("append")
   .format("console")
   .option("truncate", False
   .start()
   .awaitTermination()

你可能会问,为什么我们不可以把输出结果按小费多少进行排序呢?

这是因为两个流的 inner-join 只支持附加输出模式(Append Mode),而现在 Structured Streaming 不支持在附加模式下进行排序操作。希望将来 Structured Streaming 可以提供这个功能,但是现在,司机们只能扫一眼所有的输出数据来大概判断哪个地方的小费最高了。