Spark Structured Streaming笔记

一、流处理基础

1. 流处理是连续处理新到来的数据以更新计算结果的行为。在流处理中输入数据是无边界的,没有预定的开始或结束。它是一系列到达流处理系统的事件(例如信用卡交易、点击网站动作,或从物联网IoT传感器读取的数据),用户应用程序对此事件流可以执行各种查询操作(例如跟踪每种事件类型的发生次数,或将这些事件按照某时间窗口聚合)。应用程序在运行时将输出多个版本的结果,或者在某外部系统(如HBase等键值存储)中持续保持最新的聚合结果。

而批处理(batch processing)是在固定大小输入数据集上进行计算的,通常可能是数据仓库中的大规模数据集,其包含来自应用程序的所有历史事件(例如过去一个月所有网站访问记录或传感器记录的数据)。批处理也可以进行查询计算,与流处理差不多,但只计算一次结果。

虽然流处理和批处理不同,但在实践中它们经常需要一起使用。例如,流式application通常需要将输入流数据与批处理job定期产生的数据join起来,流式作业的输出通常是在批处理作业中要查询的文件或数据表。此外,应用程序中的任何业务逻辑都需要在流处理和批处理执行之间保持一致:例如如果有一个自定义代码来计算用户的计费金额,而流处理和批处理运行出来的结果不同那就出问题了。

为了满足这些需求,structured streaming从一开始就设计成可以轻松地与其它Spark组件进行交互,包括批处理application。结构化流处理提出了一个叫做连续应用程序(continuous application)的概念,它把包括流处理、批处理和交互式job等全部作用在同一数据集上的处理环节串联起来,从而构建端到端application,提供最终的处理结果。结构化流处理注重使用端到端的方式构建此类application,而不是仅仅在流数据级别上针对每条记录进行处理。

下面具体介绍一下流处理的优点。在大多数情况下批处理更容易理解、更容易调试、也更容易编写应用程序。此外批量处理数据也使得数据处理的吞吐量大大高于许多流处理系统。然而流处理对于以下两种情况非常必要:

(1)流处理可以降低延迟时间。当用户需要快速响应时间(分钟、秒或毫秒级别),就需要一个可以将状态保存在内存中的流处理系统以获得更好的性能,例如实时决策和告警系统等。

(2)流处理在更新结果方面也比重复的批处理job更有效,因为它会自动增量计算。例如,如果要计算过去24小时内的web 流量统计数据,那么简单的批处理job实现可能会在每次运行时遍历所有数据。与此相反,流处理系统可以记住以前计算的状态,只计算新数据。如果用户告诉流处理系统每个小时更新一次报告,则每次只需处理1小时的数据(自上次报告以来的新数据),在批处理系统中需要手动实现这种增量计算以获得相同的性能,从而导致要做大量额外的工作,而这些工作在流处理系统中会自动完成。

2. 然而,流处理也会遇到一些挑战。举个例子,假设application接收来自传感器(例如汽车内部)的输入消息,该传感器在不同时间报告其值。然后,用户希望在该数据流中搜索特定值或特定模式,一个挑战就是输入记录可能会无序地到达应用程序,比如因延迟和重新传输,可能会收到以下顺序的更新序列,其中time字段显示实际测量的时间:

{value:1,time:"2017-04-07T00:00:00"}
{value:2,time:"2017-04-07T01:00:00"}
{value:5,time:"2017-04-07T02:00:00"}
{value:10,time:"2017-04-07T01:30:00"}
{value:7,time:"2017-04-07T03:00:00"}

在任何数据处理系统中,都可以构造逻辑来执行例如接收单值“5”的操作。然而,如果你只想根据收到的特定值序列触发某个动作,比如2 然后10然后5,事情就复杂多了。在批处理的情况下这并不困难,因为可以简单地按time字段对所有事件进行排序,以发现10在2和5之间到来。然而对于流处理系统来说这是比较困难的,原因是流处理系统将单独接收每个事件,并且需要跟踪事件的某些状态以记住值为2和5的事件,并意识到值为10的事件是在它们之间,这种在流中记住事件状态的需求带来了更多的挑战

总的来说,流处理系统的挑战有以下几个方面:

(1)基于application时间戳(也称为事件时间) 处理无序数据;

(2)维持大量的状态;

(3)支持高吞吐量;

(4)即使有机器故障也仅需对事件进行一次处理;

(4)处理负载不均衡和拖延task(straggler)。

(5)快速响应时间;

(6)与其他存储系统中的数据连接;

(7)确定在新事件到达时如何更新输出;

(8)事务性地向输出系统写入数据;

(9)在运行时更新应用程序的业务逻辑。

3.事件时间(event time)是根据数据源插入记录中的时间戳来处理数据的概念,而不是流处理application在接收记录时的时间(称为处理时间processing time)。因为记录可能会出现无序状况(例如,如果记录从不同的网络路径返回),并且不同的数据源可能也无法同步(对于标记相同事件时间的记录,某些记录可能比其他记录晚到达),如果application从可能产生延迟的远程数据源(如手机或物联网设备)收集数据,则基于事件时间的处理方式就非常必要。如果不基于事件时间,可能在某些数据延迟到达时无法发现某些重要的特征。相比之下,如果application只处理本地事件(例如在同一个数据中心中生成的数据),则可能不需要复杂的事件时间处理。

在Flink和Spark Streaming的流处理思路中,两者分别使用连续处理(Continuous Processing)模式和微批处理(Micro-batch Processing)模式。在连续处理系统中,每个节点都不断地侦听来自其他节点的消息并将新的更新输出到其子节点。例如,假设application在多个输入流上实现了map-reduce运算,实现map的每个节点将从输入数据源一个一个地读取记录,根据函数逻辑执行计算,并将它们发送到相应的reducer。当reducer获得新记录时,将更新其状态,这种情况是发生在每一条记录上的,如下图所示:

在输入数据速率较低时,Flink这样的连续处理方式提供尽可能低的处理延迟,这是连续处理的优势,因为每个节点都会立即响应新消息。但是,连续处理系统通常具有较低的最大吞吐量,因为它们处理每条记录的开销都很大(例如调用操作系统将数据包发送到下游节点)。此外,连续处理系统通常具有固定的计算拓扑,如果不停止整个application,在运行状态下是不能够动态改动的(例如动态并发数调整),这也可能会导致负载均衡和资源利用效率(Flink会按整个流程中的最高并发来固定死整个application中的并发数,部分低并发的子流程会浪费资源)的问题

相比之下,Spark Streaming这样的微批处理系统等待积累少量的输入数据(比如500 ms范围),然后使用分布式task集合并行处理每个批次,类似于在Spark中执行批处理job。微批量处理系统通常可以在每个节点上实现高吞吐量,因为它们利用与批处理系统相同的优化操作(例如向量化处理Vectorized Processing),并且不会引入频繁处理单个记录的额外开销,如下图所示:

因此,微批处理只需要较少的节点就可以应对相同生产速率的数据。微批处理系统还可以使用动态负载均衡技术(如Adaptive Execution特性)来应对不断变化的负载(例如动态增加或减少task数量),提高资源利用率。然而,微批处理的缺点是由于等待累积一个微批量而导致更长的响应延迟。在实际生产中,在处理大规模数据并需要分布式计算的流处理应用程序往往优先考虑吞吐量,因此Spark最开始就实现了微批量处理,但是在structured streaming中,积极开发了基于相同API来支持连续处理模式。

在这两种执行模式之间进行选择时,最主要因素是用户期望的延迟和总的操作成本(TCO)。根据应用程序的不同,微批处理系统可能将延迟从100毫秒延长到秒。在这种机制下,通常需要较少的节点就可以达到要求的吞吐量,因而降低了运营成本(包括由于节点故障次数较少带来的维护成本降低)。为了获得更低的延迟,那应该考虑一个连续处理系统,或使用一个微批处理系统与快速服务层(fast serving layer)结合以提供低延迟查询(例如将数据输出到MySQL或Cassandra,可以用它们在几毫秒内将数据提供给客户端)。

4. Spark包括两种streaming API:早期的DStream API纯粹是基于微批处理模式的,但不支持事件时间。新的structured streaming API添加了更高级别的优化、事件时间,并且支持连续处理。但是,DStream API 有几个限制:首先它完全基于Java/Python对象和函数,而不是DataFrame和Dataset中丰富的结构化表概念,这不利于执行引擎进行优化;其次,DStream API纯粹基于processing time(要处理event time操作,应用程序需要自己实现它们);最后,DStream只能以微批处理方式运行,并在其API的某些部分暴露了微批处理的持续时间,这使得其难以支持其他执行模式。

structured streaming是基于Spark结构化API的高级流处理API,它适用于运行结构化处理的所有环境,例如Scala,Java,Python,R和SQL。与DStream一样它是基于高级操作的声明式API,但是structured streaming可以自动执行更多类型的优化;与DStream不同,structured streaming对标记event time的数据具有原生支持(所有窗口操作都自动支持它)。并且,Spark 2.3版本已经支持Flink那样的Continuous Processing

更重要的是,除了简化流处理之外,structured streaming还旨在轻松构建端到端连续application,这些application结合了流处理、批处理和交互式查询。例如structured streaming不使用DataFrame之外的API,只需编写一个正常的DataFrame(或SQL)并在数据流上应用它。当数据到达时,structured streaming将以增量方式自动更新此计算的结果,这非常有益于简化编写端到端数据处理程序,开发人员不需要维护批处理代码版本和流处理版本,并且避免这两个版本代码失去同步的风险。另外一个例子是,structured streaming可以将数据输出到SparkSQL(如Parquet表)可用的表格式中,从而便于从另一个Spark application查询该流的状态。

二、Structured Streaming基础

5. Structred Streaming是建立在SparkSQL引擎上的流处理框架,它不是独立的API,而是使用了Spark中现有的结构化API (包括DataFrame、Dataset和SQL) ,这意味着用户熟悉的所有操作都支持。用户可以用批处理的计算方式来表达流处理计算代码。在指定流处理操作后,Structred Streaming引擎将负责对新到达系统的数据执行增量、连续地查询。用于计算的逻辑指令将在Catalyst引擎上执行,包括查询优化、代码生成等。结构化流处理还包括一些专门用于流处理的特性,例如支持仅执行一次的端到端处理,并且支持基于checkpoint和预写式日志(write-ahead log)的错误恢复功能。

Structred Streaming背后的主要思想是将数据流视为连续追加数据的数据表。然后,该job定期检查新的输入数据对其进行处理,在需要时更新位于状态存储区的某些内部状态,并更新其结果。API的设计原则是批处理和流处理的查询代码是一致的,不需要更改查询代码,只需要指定是以批处理还是以流处理方式运行该查询即可。在内部,Structred Streaming将自动找出增量查询的方法,即在新数据到达时更新其结果,并以容错的方式运行,如下图所示:

简单来说,结构化流即是“以流处理方式处理的DataFrame”,这使得使用流处理application非常容易,但是Structred Streaming的查询类型有一定的限制,以及必须考虑到一些特定于流的新概念,例如event time和无序数据。通过与Spark其余部分集成,Structred Streaming使用户能够构建连续应用程序(continuous applications)。连续应用程序是一个端到端的application,它通过组合各种工具来实时对数据作出响应,包括流处理job、批处理job、流数据和离线数据之间的join、以及交互式的ad-hoc查询(即席查询)。

由于当前大多数流处理job都是在更大的连续应用程序中部署的,因此Spark开发者试图使其在一个框架中简单地指定整个application,并在这些不同部分的数据获得一致的结果。例如,可以使用Structured Streaming连续地更新一个SparkSQL数据表,或者为MLlib训练的机器学习模型提供流处理服务,或者将数据流与在任一个Spark数据源中的离线数据进行join等。

Structred Streaming同样涉及transformation和action的概念,结构化流处理的transformation操作就是离线批处理中的转换操作,虽然有一些限制,这些限制通常是在引擎无法增量执行某些类型的查询上,但一些限制会在新版本的Spark中去除。Structred Streaming中只有一个action操作,即启动流处理,然后运行并持续输出结果。除了需要指定数据源(如Kafka)从其读取数据流,也需要指定接收器(sink)如HBase来设置流处理之后结果集的去处。sink和执行引擎还负责跟踪数据处理的进度。

6. 为Structred Streaming作业指定一个sink只解决了一半问题,还需要定义Spark将数据以何种方式写入sink,例如是否只想追加新信息?当随着时间的推移收到更多信息时,是否希望更新数据记录(例如更新给定网页的点击次数)?是否希望每次都完全覆盖结果集(即始终使用总点击数更新文件)?为此需要定义一个输出模式,Spark支持的输出模式如下:

(1)append(追加,只向输出接收器中添加新记录);

(2)update(更新,更新有变化的记录);

(3)complete(完全,重写所有输出)。

值得注意的是,某些查询和某些sink只支持某些固定的输出模式,例如假设job只是在流上执行map操作,随着新记录的到达输出数据将无限地增长,因此使用complete模式将所有数据一次性重新写入一个新的文件,则是没有意义的,相比之下如果正在针对有限数量的键进行聚合操作,则complete模式和update模式是有意义的,但append模式不行,因为某些键的值需要随着时间的推移不断更新。

输出模式定义了数据以何种方式被输出,trigger则定义了数据何时被输出,即Structred Streaming应何时检查处理新输入数据并更新其结果。默认情况下,Structred Streaming将在处理完最后一组输入数据后立即检查是否有新的输入记录,从而尽力保证低延迟。但当sink是一组文件时,可能产生许多小输出文件。因此Spark还支持基于process time的trigger,仅在固定时间间隔内检查新数据,将来还可能支持其他类型的触发器。

事件时间(event time表示嵌入到数据中的时间字段。这意味着不是根据它到达系统的时间处理数据,而是根据生成数据的时间对其进行处理,不管由于上传速度慢、或网络延迟导致输入记录乱序到达依然如此。在Structred Streaming中实现event time处理很简单,由于系统将输入数据视作数据表,因此event time只是该表中的一个字段,应用程序可以使用SQL运算符进行分组(grouping)、聚合(aggregation)和窗口化(windowing)。当Spark知道其中一列是event time字段时,Structred Streaming可以采取一些特殊的操作,包括进行查询优化或确定何时可以安全地忘记某时间窗口的状态。许多这些操作可以使用watermarks进行。

Watermarks是流处理系统的一项功能,它允许指定在event time内查看数据的延迟程度。例如,某个application需要处理来自移动设备的日志,由于上传延迟可能会导致日志延迟30分钟。支持event time的系统通常允许设置watermarks来限制记住旧数据的时长。watermarks还可用于控制何时输出特定event time窗口的结果(例如,一直等待直到它的watermarks过期)。

7. 来看一个使用结构化流的应用示例,这里将使用异质性人类活动识别数据集(Heterogeneity Human Activity Recognition Dataset),数据是从包括智能手机和智能手表等设备的各种传感器采集的读数,特别是加速度计和陀螺仪,设备以最高频率进行采样。这些传感器采集用户活动的信息并将其记录下来,如骑自行车、坐、站、步行等。其中涉及多个不同的智能手机和智能手表,一共采集了9个用户的信息。首先读取数据集的静态版本到DataFrame:

val static = spark.read.json("/data/activity-data/")
val dataSchema = static.schema
root
|--Arrival_Time:long (nullable = true)
|--Creation_Time:long (nullable = true)
|--Device:string (nullable = true)
|--Index:long (nullable = true)
|--Model:string (nullable = true)
|--User:string (nullable = true)
|--_corrupt_record:string (nullable = true)
|--gt:string (nullable = true)
|--x:double (nullable = true)
|--y:double (nullable = true)
|--z:double (nullable = true)

下面是该DataFrame的一些样本,其中包括一些时间戳、型号信息、用户信息和设备信息列,gt字段记录用户当时正在进行的活动:

+------------------+--------------------------+----------+------+---------+----+--------+-----+-----
|  Arrival_Time|      Creation_Time|  Device| Index| Model|User|_c…ord|.gt| x
|1424696634224|142469663222623685|nexus4_1|   62|nexus4|   a|   null|stand|-0…
…
|1424696660715|142469665872381726|nexus4_1| 2342| nexus4|   a|   null|stand|-0…
+------------------+--------------------------+----------+------+--------+-----+--------+-----+-----

接下来创建该数据集的流式版本,它会将数据集中的每个输入文件一个一个地读出来,就好像是一个流一样。

流式DataFrame基本与静态DataFrame相同,需要在Spark application中创建它们,然后对它们执行transformation操作,以使数据成为正确的格式。基本上所有静态的结构化API中可用的transformation操作都适用于流式DataFrame。但是,一个小的区别是Structred Streaming不允许在未明确指定它的情况下执行模式推断(scheme inference),需要通过设置spark.sql.streaming.schemaInference为true来显式指定模式推断。

鉴于此,代码将从一个具有已知数据模式的文件中读取模式dataSchema对象,并将dataSchema对象从静态DataFrame应用到流式DataFrame,在数据可能被更改时,应避免在生产应用中使用模式推断:

val streaming = spark.readStream.schema(dataSchema)
  .option("maxFilesPerTrigger", 1).json("/data/activity-data")

maxFilesPerTrigger属性控制Spark将以什么样的速度读取文件夹中的文件。将该值设置的低一些,比如将流限制为每次触发读取一个文件,有助于演示如何以增量方式运行Structred Streaming,但在生产中可能需要设置为更大的值。就像其他的Spark API 一样,流式DataFrame的创建和执行是惰性的。这里将展示一个简单的转换,按gt列对数据进行分组和计数,这是用户在该时间点执行的活动:

val activityCounts = streaming.groupBy("gt").count()

8. 在设置了transformation操作之后,只需要调用action操作来启动查询。另外,需要为查询结果指定输出目标或输出Sink,对于这个示例将编写一个内存接收器,它将结果保存为内存中的表。在指定这个sink的过程中,需要指定Spark将如何输出这些数据,本例中使用complete输出模式,在每个触发操作之后重写覆盖所有key键以及它们的计数:

val activityQuery = activityCounts.writeStream.queryName("activity_counts")
  .format("memory").outputMode("complete")
  .start()

这样就开始写出数据流了。这里设置了唯一的查询名称来代表流即“activity_counts”,将格式指定为内存表,并设置了输出模式。当运行前面的代码时,还要包括下面这一行代码:

activityQuery.awaitTermination()

执行此代码后,流式计算将在后台启动。查询对象是该活跃流查询的句柄,这里需要activityQuery.awaitTermination()来等待查询终止,以防止查询程序还在运行而driver已经终止的情况,后面会省略掉这行代码,但是在生产应用中必须都有,否则流处理程序将无法运行。Spark可以列出这个流和其他Spark Session中活跃的流,通过运行下面代码,可以看到这些流的列表:

spark.streams.active

Spark为每个流分配一个UUID,所以可以遍历流的列表,并选择其中的一个。在上面例子中,把流赋值给了一个变量activityQuery,所以不需要通过UUID获取流。现在上面这个流正在运行,可以通过查询内存中的数据表来查看流聚合的结果,此表叫activity_counts与流的名称相同。要查看这个输出表中的当前数据,只需要在一个简单的循环中执行此操作,每秒打印一次流查询的结果:

for( i <- 1 to 5 ) {
    spark.sql("SELECT * FROM activity_counts").show()
    Thread.sleep(1000)
}

在该查询运行时,应该看到每个活动的计数随时间而变化。例如第一次调用show显示以下空结果,因为在流读取第一个文件的同时查询了表:

+---+-----+
|  gt|count|
+---+-----+
+---+-----+

在下一个时间返回结果时,里面就有内容了(不同时间可能结果不同):

+----------+-----+
|      gt|count|
+----------+-----+
|      sit| 8207|
…
|     null| 6966|
|    bike| 7199|
+----------+-----+

通过这个简单的例子,Structured Streaming的优势应该很清晰了,可以执行在批处理中使用的相同操作,代码只需做少量修改就可以直接在数据流上运行。

9. Structured Streaming支持所有的select转换和筛选转换,对所有DataFrame函数和对单个列的操作也都支持。这里使用下面的选择和筛选操作演示一个简单的示例,由于Key没有任何改变,将使用append输出模式,以便将新结果追加到输出表中:

import org.apache.spark.sql.functions.expr
val simpleTransform = streaming.withColumn("stairs", expr("gt like '%stairs%'"))
  .where("stairs")
  .where("gt is not null")
  .select("gt", "model", "arrival_time", "creation_time")
  .writeStream
  .queryName("simple_transform")
  .format("memory")
  .outputMode("append")
  .start()

Structured Streaming对聚合操作有很好的支持,可以指定任意聚合,如在结构化API中看到的那样。例如可以根据电话型号和活动信息进行cube分组后,再计算x、y、z加速平均值等这样复杂的聚合操作:

val deviceModelStats = streaming.cube("gt", "model").avg()
  .drop("avg(Arrival_time)")
  .drop("avg(Creation_Time)")
  .drop("avg(Index)")
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

查询该表可以看到下面结果:

SELECT * FROM device_counts

+----------+------+------------------+--------------------+--------------------+
| gt| model| avg(x)| avg(y)| avg(z)|
+----------+------+------------------+--------------------+--------------------+
| sit| null|-3.682775300344...|1.242033094787975...|-4.22021191297611...|
| stand| null|-4.415368069618...|-5.30657295890281...|2.264837548081631...|
...
| walk|nexus4|-0.007342235359...|0.004341030525168...|-6.01620400184307...|
|stairsdown|nexus4|0.0309175199508...|-0.02869185568293...| 0.11661923308518365|
...
+----------+------+------------------+--------------------+--------------------+

除了对数据集中原始列上的这些聚合外,Structured Streaming对表示event time的列具有特殊支持,包括watermark支持和窗口操作(windowing),并且支持流式DataFrame与静态DataFrame的join操作。Spark 2.3增加了对join多个流的支持,可以执行多列join,并从静态数据源中补充流数据:

val historicalAgg = static.groupBy("gt", "model").avg()
val deviceModelStats = streaming.drop("Arrival_Time", "Creation_Time", "Index")
  .cube("gt", "model").avg()
  .join(historicalAgg, Seq("gt", "model"))
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

10. Spark Streaming中最简单的数据来源是文件源,虽然基本上任何文件源都应该可行,但在实践中常看到的是Parquet、文本、JSON 和CSV。使用文件数据源或sink与Spark静态文件源之间的唯一区别是,通过流可以控制在每个trigger读取的文件数,即maxFilesPerTrigger选项。要注意为流式job添加到输入目录中的任何文件都需要以原子形式显示,否则Spark将在输入目录里的所有数据源文件完成之前,就开始处理不完整的数据源(如HDFS等可以显示部分写入结果的文件系统),最好是在其他目录中写文件并在完成后将其移动到输入目录中,比如在亚马逊S3中对象通常只在完全写完后才能显示在目录列表里。

Kafka是一种分布式的数据流发布和订阅系统。Kafka允许发布和订阅与消息队列类似的记录流,它们以容错的方式存储,可以认为Kafka类似一个分布式缓冲区Kafka将记录流分类存储在主题(topic中,每条记录都包含一个键、一个值和一个时间戳,topic包含不可改变的记录序列,每个记录在序列中的位置称为偏移量(offset),读取数据称为订阅(subscrib)topic,写数据类似把数据发布(publish)到topic。Spark可以通过批量方式和流方式从Kafka上读取DataFrame。

从Kafka读取数据之前,首先需要选择下列选项之一:assign,subscribe,或者subscribePattern。assign是一种细粒度的方法,它不仅指定topic,而且还需要指定想读取的topic partition,这通过一个JSON字符串指定,如{"topicA":[0,1],"topicB":[2,4]}。subscribe和subscribePattern是通过指定topic列表(前者)或指定模式(后者)来订阅一个或多个topic的方法

其次,需要指定kafka提供的kafka.bootstrap.servers来连接到服务。在指定了这些选项后,还有几个其他选项可以指定:

(1)startingOffsets指定查询的起始点,可以是从最早的偏移量earliest; 也可以是最新的偏移量latest; 或者是一个JSON字符串,它指定每个Topic Partition的起始偏移量。JSON 中-2代表最早的偏移量,-1是最新的。例如JSON格式可以写成是{"topicA":{"0":23,"1":-1},"topicB":{"0":-2}},这仅当新的流式查询启动时才适用,并且将始终从查询中断的偏移量位置开始恢复,查询过程中新发现的分区将从最早偏移量开始

(2)endingOffsets是查询的终止偏移量。

(3)failOnDataLoss。在可能丢失数据(例如删除topic或offset超出范围)时是否停止查询,这可能是一个误报,如果它不能按预期工作也可以禁用它,默认值为true。

(4)maxOffsetsPerTrigger为在给定trigger中读取的总offset。

还有一些选项可以设置Kafka消费者超时、提取重试次数和间隔时间。要从Kafka读取数据,需要在Structured Streaming中执行以下操作:

// Subscribe to 1 topic
val ds1 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "topic1")
  .load()
// Subscribe to multiple topics
val ds2 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribe", "topic1,topic2")
  .load()
// Subscribe to a pattern of topics
val ds3 = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("subscribePattern", "topic.*")
  .load()

Kafka数据源中的每一行都具有以下模式:

(1)键key:二进制binary。

(2)值value:二进制binary。

(3)主题topic:字符串string。

(3)分区partition:整型int。

(4)偏移量offset:长整型long。

(5)时间戳timestamp:长整型long。

Kafka中的每条消息都有可能以某种方式序列化,在结构化API 或UDF中使用原生(native)Spark函数,可以将消息解析为更结构化的格式。一个常见的模式是使用JSON或Avro读写Kafka。

11. 写入Kafka与从Kafka读取数据,除了几个参数不同之外其他几乎相同。用户仍然需要指定Kafka的引导服务器(bootstrap server),唯一需要额外提供的是指定topic的列,或者把topic作为option给出。例如,下面两种写法是等效的:

ds1.selectExpr("topic", "CAST(key AS STRING)", "CAST(value AS STRING)")
  .writeStream.format("kafka")
  .option("checkpointLocation", "/to/HDFS-compatible/dir")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .start()
ds1.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
  .writeStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")
  .option("checkpointLocation", "/to/HDFS-compatible/dir")
  .option("topic", "topic1")
  .start()

foreach sink类似于Dataset API中的foreachPartitions,此操作在每个分区上并行执行任意操作。要使用foreach sink,必须实现ForeachWriter接口,它在Scala/Java文档中有所描述,其中包含三个方法:open,process和close。在触发操作生成输出行序列的时候,就会调用相关的方法。以下是一些重要的细节:

(1)writer必须是可序列化的(Serializable),它可以是UDF或Dataset映射函数。

(2)每个executor上,这三个方法(open,process,close)将会被调用

(3)writer必须在open()方法中执行其所有初始化,如打开连接或启动事务。常见的错误是,初始化发生在open方法之外(在使用的类中),比如在driver而不是executor上发生。

因为Foreach sink运行任意用户代码,所以使用它时必须考虑的一个关键问题是容错。如果Structured Streaming要求sink写出一些数据但随后崩溃,它无法知道原始写入是否成功。因此,API提供了一些附加参数来保证只处理一次。

首先,ForeachWriter中调用open函数会接收两个参数,这两个参数唯一标识了需要执行操作的行集,version参数是一个单调递增的ID,每次触发后自动增加,partitionId是任务中输出分区的IDopen()方法应返回是否处理这组行,如果在外部跟踪sink的输出,并看到这组行已经输出(例如,在存储系统中看到了最后一个version和partitionId已经输出),则可以让open方法返回false来跳过处理这组行,如果要处理则返回true,ForeachWriter将再次打开来写出每次触发的数据。

接下来,假设open方法返回true,那么process方法将处理数据中的每条记录,这非常简单就是处理或写出数据。最后,每当open方法被调用,那不管它返不返回true,close()方法最后都会被调用(除非在这之前节点崩溃)。如果Spark在处理过程中出现错误,则close方法将接收到该错误,用户应该在close方法中写清理任何程序占用资源的逻辑。同时,ForeachWriter接口可以高效实现自己的sink,包括跟踪哪些trigger的数据已经被写入的逻辑,以及在发生错误时如何安全恢复的逻辑。可以看下面的ForeachWriter 的例子来理解:

datasetOfString.write.foreach(new ForeachWriter[String] {
  def open(partitionId: Long, version: Long): Boolean = {
    // open a database connection
  }
  def process(record: String) = {
    // write string to connection
  }
  def close(errorOrNull: Throwable): Unit = {
    // close the connection
  }
})

12. Spark还包括几个用于测试的数据源和sink,可用于流查询的调试(仅在开发过程中使用而不能应用于生产,因为它们不为application提供端到端的容错能力):

(1)Socket数据源。套接字(socket)数据源允许通过TCP套接字向流发送数据,通过指定要从中读取数据的主机和端口来启动它,Spark将打开一个新的TCP连接以从该地址读取数据。在生产中不要使用Socket数据源,因为Socket位于driver上,并且不提供端到端的容错保证。下面是设置从地址端口为localhost:9999的socket数据源读取流数据的简单示例:

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

如果想将数据写入application,则需要运行侦听端口9999的服务器,在类Unix系统上可以使用NetCat工具,它可以往端口9999的连接中键入文本。在启动Spark application之前运行下面的命令,然后在里面写入文本数据:

nc -lk 9999

Socket源将返回一个文本字符串的数据表,输入数据中每一行对应数据表中的一行。

(2)控制台接收器(Console sink)。它允许将一些流式查询写入控制台,对调试很有用但它不支持容错。写到控制台很简单,将流查询的一些行打印到控制台即可,它支持append和complete输出模式:

activityCounts.format("console").write()

(3)内存接收器(Memory sink)。设置内存接收器是测试流处理系统的一个简单方法。它与控制台接收器类似,它将数据收集到driver,然后使数据整理为可用于交互式查询的内存表。这个sink不是容错的,所以不应该在生产中使用它,但是在开发过程中用于测试自然是很合适的。它支持append和complete输出模式:

activityCounts.writeStream.format("memory").queryName("my_device_table")

如果要将数据输出到表中,以便在生产中进行交互式SQL查询,建议在分布式文件系统上使用Parquet文件sink(例如S3),这样就可以在任何Spark application中查询数据。

13. 上面知道了数据输出到哪里,下面讨论结果数据集输出时的形式,这就是输出模式(output mode),它与静态DataFrame的保存模式是相同的概念。结构化流支持三种输出模式:

(1)append模式。它是默认的模式,当新行添加到结果数据表时,它们将根据指定的trigger输出到sink。假设sink提供容错能力,此模式确保每行输出一次(并且仅一次)。当使用带有event time和watermarks的append模式时,只有最终结果才会输出到sink。

(2)complete模式将结果表的整个状态输出到sink。当使用有状态数据时这就很有用,因为每行都可能会随时间而变化,也可能因为正在写入的sink不支持行级更新。可以把它想象为在上一批量运行后流的状态。

(3)update模式类似于complete模式,但只把与上一个批量输出中不同的行才会被写出到sink,当然sink必须支持行级更新以支持此模式。如果查询不包含聚合操作,则它和append模式是等价的。

Structured Streaming在有些情况是限制某种模式的,因为要保证应用到查询的模式必须有意义。例如如果查询只是执行map操作,则Structured Streaming不允许complete模式,因为这将要求它记住自job开始以来的所有输入记录并重写整个输出表,这会变得非常耗时。如果选择的模式不可用,则在启动流时Spark流将引发异常。

为了控制数据何时输出到sink,需要设置一个触发器(Trigger。默认情况下当上一个trigger完成处理时,Structured Streaming将立即启动数据。可以使用trigger来确保不输出过多的更新数据以避免对sink造成太大的负载,也可以使用trigger来尝试控制文件大小。目前有一种基于processing time的周期性trigger类型,以及一个手动控制的trigger类型来触发一次数据,将来可能会增加更多的trigger。

(1)对于处理时间触发器(processing time trigger),只需用字符串指定processing time周期(也可以使用Scala中的Duration或者Java中的TimeUnit):

import org.apache.spark.sql.streaming.Trigger

activityCounts.writeStream.trigger(Trigger.ProcessingTime("100 seconds"))
  .format("console").outputMode("complete").start()

Processing Time trigger将等待给定持续时间的倍数间隔才能输出数据,例如触发时间为一分钟,trigger将在12:00、12:01、12:02 等处触发。如果由于先前的处理尚未完成而错过触发时间,则Spark将等待到下一个触发点(如下一分钟),而不是在上一次处理完成后立即运行。

(2)也可以设置一次性触发器(once tirgger来运行流处理job,这可能看起来很奇怪但实际上非常有用。在开发过程中,可以一次只测试一个trigger的数据。在生产过程中,可以使用once trigger以较低的速率手动运行job(例如只是偶尔将新数据输出到摘要表中)。由于Structured Streaming仍然完全跟踪处理所有输入文件和计算状态,这比编写自定义逻辑来跟踪批处理作业更容易,并且比运行Flink那样的continuous job节省了大量资源:

import org.apache.spark.sql.streaming.Trigger

activityCounts.writeStream.trigger(Trigger.Once())
  .format("console").outputMode("complete").start()

14. 关于Structured Streaming,不仅支持使用DataFrame API来处理流数据,还能以类型安全的方式使用Dataset执行相同的计算。可以将流式DataFrame转换为Dataset,这与静态数据的处理方式相同。与静态Dataset一样,Dataset的元素需要是Scala case class或Java bean类。另外在流DataFrame和Dataset上的操作与在静态数据上的操作方式相同,在流上运行时也会变成流式执行计划。下面是一个示例,使用美国航班数据集:

case class Flight(DEST_COUNTRY_NAME: String, ORIGIN_COUNTRY_NAME: String,
  count: BigInt)
val dataSchema = spark.read
  .parquet("/data/flight-data/parquet/2010-summary.parquet/")
  .schema
val flightsDF = spark.readStream.schema(dataSchema)
  .parquet("/data/flight-data/parquet/2010-summary.parquet/")
val flights = flightsDF.as[Flight]
def originIsDestination(flight_row: Flight): Boolean = {
  return flight_row.ORIGIN_COUNTRY_NAME == flight_row.DEST_COUNTRY_NAME
}

flights.filter(flight_row => originIsDestination(flight_row))
  .groupByKey(x => x.DEST_COUNTRY_NAME).count()
  .writeStream.queryName("device_counts").format("memory").outputMode("complete")
  .start()

使用Structured Streaming是编写流式application的有效方法,批处理job程序的代码几乎不需要怎么改动,就可以转换为流处理job程序。

三、Event time和有状态处理

15. event time是一个很重要的概念,而Spark Streaming的老版API即DStream不支持event time的处理。在流处理系统中,每个事件实际上有两个相关的时间:数据本身发生的时间(event time),以及数据被处理或到达流处理系统的时间(processing time)。流处理系统面临的挑战是事件数据由于网络等原因可能会延迟或乱序,所以必须能够处理乱序或延迟到达的数据。

只有需要长时间使用或更新中间结果信息(状态)时,才需要进行有状态处理(在微批处理模型和面向记录模型中都是如此)。当使用event time或在键上执行聚合操作时,有状态处理就可能会发生,无论是否涉及event time。大多数情况下当执行有状态操作时,Spark会自动处理所有复杂的事情,例如在实现分组操作时,Structured Streaming会维护并更新信息,用户只需指定处理逻辑。在执行有状态操作时,Spark会将中间结果信息存储在状态存储(state store)中,Spark当前的状态存储实现是一个内存状态存储,它通过将中间状态存储到checkpoint目录来实现容错

有状态处理功能足以解决许多流处理问题,但是有时关于应该存储什么状态、如何更新状态、以及何时移除状态(显式指定或通过一个超时限制)等这些问题,需要进行细粒度控制,这叫做任意(或定制)有状态处理,来用一些例子来说明:

(1)记录有关电子商务网站上用户会话的信息。例如可能希望跟踪用户在本次session过程中访问的页面,以便在下一次session中实时地提供建议。每个用户session的启动时间和停止时间可以是任意的。

(2)公司希望报告web应用程序中的错误,但仅在用户session期间发生5个错误事件时才会报告错误,可以使用基于计数的窗口来执行此操作,只有发生某种类型的事件5次时才输出结果。

(3)希望删除流数据里的重复记录,为此需要在找到重复数据之前一直跟踪看到的每条记录。

下面的例子使用与之前相同的数据集,在使用event time时,它只是数据集中的一列,因此只需用列的方式去访问即可:

spark.conf.set("spark.sql.shuffle.partitions", 5)
val static = spark.read.json("/data/activity-data")
val streaming = spark
  .readStream
  .schema(static.schema)
  .option("maxFilesPerTrigger", 10)
  .json("/data/activity-data")
streaming.printSchema()

root
|--Arrival_Time:long (nullable = true)
|--Creation_Time:long (nullable = true)
|--Device:string (nullable = true)
|--Index:long (nullable = true)
|--Model:string (nullable = true)
|--User:string (nullable = true)
|--gt:string (nullable = true)
|--x:double (nullable = true)
|--y:double (nullable = true)
|--z:double (nullable = true)

在此数据集中有两个基于时间的列,Creation_Time列是事件的创建时间,而Arrival_Time是事件从上游某处到达服务器的时间,因此将前者作为event time。

16. event time分析的第一步是将时间戳列转换为合适的SparkSQL时间戳类型。上面数据集中的时间列单位是纳秒(表示为long长整型),因此要做一些操作将其转换成为适当的格式:

val withEventTime = streaming.selectExpr(
  "*",
  "cast(cast(Creation_Time as double)/1000000000 as timestamp) as event_time)")

接下来最简单的操作就是计算给定时间窗口中某事件的发生次数,即滚动窗口(tumbling window)。下图描述了基于输入数据和键执行简单求和的过程:

这里正在对某时间窗口内中的键值执行聚合。每次trigger运行时都会更新结果表(怎么更新取决于输出模式),这将根据自上次trigger以来收到的数据进行操作。本例在10分钟的时间窗口中进行,并且这些时间窗口不会发生任何重叠(一个事件只可能落入一个时间窗口),并且结果也将实时更新,如果系统上游添加了新的事件,则Structured Streaming将相应地更新这些计数。这里使用了complete输出模式,因此无论是否处理完了整个数据集,Spark都会输出整个结果表:

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes")).count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

这里把数据写到了内存sink中以便于调试,可以在运行流处理之后使用SQL查询它:

spark.sql("SELECT * FROM events_per_window").printSchema()

该查询的结果如下,结果取决于在运行查询时已经处理了的数据量:

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|11035|
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|18854|
...
|[2015-02-23 13:40:00.0,2015-02-23 13:50:00.0]|20870|
|[2015-02-23 11:20:00.0,2015-02-23 11:30:00.0]|9392 |
+---------------------------------------------+-----+

作为参考,下面是从查询中得到的模式schema:

root
|--window:struct (nullable = false)
| |--start:timestamp (nullable = true)
| |--end:timestamp (nullable = true)
|--count:long (nullable = false)

注意时间窗口实际上是一个结构体(一个复杂类型),使用此方法可以查询该结构体以获得特定时间窗口的开始时间和结束时间。重要的是可以在多个列上执行聚合操作,包括event time列,甚至可以使用cube之类的复杂分组方法来执行聚合。下面代码涉及到执行多键聚合,但这确实适用于任何窗口式聚合(或有状态计算):

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes"), "User").count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

17. 前面例子是在给定窗口中简单计数(滚动窗口),但有时多个时间窗口是可以重叠的,下图的例子可以说明滑动窗口(Slide Window):

上图中正在运行一个以1小时为增量的滑动窗口,但希望每10分钟获得一次状态,这意味着将随着时间的推移更新这些值,并考虑之前1小时的数据。在这个例子中每隔5分钟设置一个窗口,该窗口包括当前时刻往前10分钟内的所有状态,因此每个事件都将落到两个不同的时间窗口:

import org.apache.spark.sql.functions.{window, col}
withEventTime.groupBy(window(col("event_time"), "10 minutes", "5 minutes"))
  .count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

然后按如下方法查询内存表:

SELECT * FROM events_per_window

此查询返回以下结果,要注意这里每隔5分钟就启动一个时间窗口,而不是10分钟,这样就会产生两个重叠的时间窗口,这与之前的例子不一样:

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 14:15:00.0,2015-02-23 14:25:00.0]|40375|
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|56549|
...
|[2015-02-24 11:45:00.0,2015-02-24 11:55:00.0]|51898|
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|33200|
+---------------------------------------------+-----+

18. 前面的例子虽然很好但有一个缺陷,就是未指定系统可以接受延迟多久的迟到数据,这意味着Spark需要永久存储这些中间数据,如果指定了一个过期时间,数据迟到超过一定时间阈值将不会处理它,这适用于所有基于event time的有状态处理,这个阈值称为水位(watermark这样就可以免于由于长时间不清理过期数据而造成系统存储压力过大

具体来说,watermark是给定事件或事件集之后的一个时间长度,在该时间长度之后不希望再看到来自该时间长度之前的任何数据。这种数据到达延迟可能是由于网络延迟、设备断开连接等。在老DStream API中,过去没有一种处理延迟数据的可靠方法,如果某个事件是在某个时间窗口发生的,但在对该时间窗口的批处理开始时该事件还未到达处理系统,则它将显示在其他的批处理批次中。Structured Streaming可以处理这种问题,在基于event time的有状态处理中,一个时间窗口的状态或数据是与processing time解耦的

如果知道一般情况下通常会在几分钟内收到上游生产的数据,但偶尔也遇到过在事件发生后5个小时才收到数据的情况(可能是用户失去了手机连接),可以按照以下方式指定watermark:

import org.apache.spark.sql.functions.{window, col}
withEventTime
  .withWatermark("event_time", "5 hours")
  .groupBy(window(col("event_time"), "10 minutes", "5 minutes"))
  .count()
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("complete")
  .start()

这样查询语句几乎没有任何变化,只是增加了一个watermark配置。现在Structured Streaming会等待10分钟窗口中最晚时间戳之后的5小时,然后才确定该窗口的结果。这里可以查询数据表并查看所有中间结果,因为使用的是complete输出模式,它们将随着时间的推移而持续更新。而在append输出模式下,结果直到窗口关闭时才会输出,如下所示:

SELECT * FROM events_per_window

+---------------------------------------------+-----+
|window |count|
+---------------------------------------------+-----+
|[2015-02-23 14:15:00.0,2015-02-23 14:25:00.0]|9505 |
|[2015-02-24 11:50:00.0,2015-02-24 12:00:00.0]|13159|
...
|[2015-02-24 11:45:00.0,2015-02-24 11:55:00.0]|12021|
|[2015-02-23 10:40:00.0,2015-02-23 10:50:00.0]|7685 |

如果没有指定最多接受迟到多晚的数据,那么Spark会永久将这些数据保留在内存中,指定watermark可使其从内存中释放,从而使流处理能够长时间持续运行

19. 在一次一记录(record-at-a-time)系统中,更困难的操作之一是删除数据流中的重复项,必须一次对一批记录进行操作才可能查找重复项,处理系统需要很高的协调开销。重复项删除是许多应用程序需要支持的一个重要能力,尤其是当消息可能从上游系统多次传递时。IoT应用程序就是这样一个例子,上游产品在非稳定的网络环境中生产消息,并且相同的消息可能会被多次发送,下游应用程序和聚合操作需要保证每个消息只出现一次。

Structured Streaming可以轻松地让消息系统支持“at least once”的语义,并根据记录任意键的方法来丢弃重复消息,将其转换为“exact once”的语义。为了消除重复数据,Spark将维护许多用户指定的键,并忽略重复项。与其他有状态处理应用程序一样,需要指定一个watermark以确保维护的状态在流处理过程中不会无限增长。在下面的例子中,注意是如何将event time列指定为需要去重的列,一个重要假设是重复事件具有相同的时间戳和标识符,具有两个不同时间戳的行是两个不同的记录:

import org.apache.spark.sql.functions.expr

withEventTime
  .withWatermark("event_time", "5 seconds")
  .dropDuplicates("User", "event_time")
  .groupBy("User")
  .count()
  .writeStream
  .queryName("deduplicated")
  .format("memory")
  .outputMode("complete")
  .start()

结果如下所示,随着读取数据流中更多的数据,这个结果将随着时间的推移而不断更新:

+----+-----+
|User|count|
+----+-----+
| a| 8085|
| b| 9123|
| c| 7715|
| g| 9167|
| h| 7733|
| e| 9891|
| f| 9206|
| d| 8124|
| i| 9255|
+----+-----+

20. 上面演示了Spark如何根据用户配置来维护信息和更新窗口,但是当有更复杂的窗口概念时情况会有所不同,而这是任意有状态处理(Arbitrary Stateful Processing)的所长之处。执行有状态处理时,可能需要执行以下操作:

(1)根据给定键的计数创建窗口。

(2)如果在特定时间范围内发生多个特定事件,则发出警报。

(3)在不确定时间内维护用户session,保存这些session以便稍后进行一些分析。

如果执行这类处理时,将需要执行以下两件事情:

(1)映射数据中的组,对每组数据进行操作,并为每个组生成至多一行。这个用例的相关API是mapGroups WithState。

(2)映射数据中的组,对每组数据进行操作,并为每个组生成一行或多行。这个用例的相关API是flatMapGroups WithState。

当对每组数据进行操作时,可以独立于其他数据组去任意更新该组,也就是说也可以定义不符合tumbling window或slide window的任意窗口类型。在执行这种处理方式时获得的一个重要好处是可以控制配置状态超时,使用time window和watermark非常简单:当一个window开始前watermark已经超时,只需暂停该window。这不适用于任意有状态处理,因为是基于用户定义的概念来管理状态,所以需要适当地设置状态超时。

超时时间是指在标记某个中间状态为超时(timeout)之前应该等待多长时间,超时时间是作用在每组上的一个全局参数。超时可以基于处理时间(GroupStateTimeout.ProcessingTimeTimeout)也可以基于事件时间(GroupStateTimeout.EventTimeTimeout)。要先检查超时时间的设置,可以通过检查state.hasTimedOut标志或检查值迭代器是否为空来获取此信息。

基于processing time的超时,可以通过调用GroupState.setTimeoutDuration设置超时时间。如果超时时间设置为D毫秒,可以保证:

(1)在时钟时间增加D ms之前,超时不会发生。

(2)当查询中存在某个trigger在D ms之后,则最终会发生超时。因此超时最终发生的时间没有严格的上限限制,例如查询的触发间隔会影响实际发生超时的时间,如果数据流中没有任何数据(相对于某个组),就没有触发的机会,直到有数据时才会发生超时函数调用

由于processing time超时是基于时钟时间的,所以系统时钟的不同会对超时有影响,这意味着时区不同和时钟偏差是需要给予考虑的。基于event time的超时,用户还必须指定watermark,设置后比watermark延迟更久的旧数据会被过滤掉。可以通过使用GroupState.setTimeoutTimestamp(...)的API设置超时时间戳,从而设置watermark应参考的时间戳,当watermark超出设定的时间戳时将发生超时。当然,可以通过指定较长的watermark来控制超时延迟,或者在处理流时动态更新超时时间。因为可以用代码来实现,所以可以针对特定组来更改超时时间,此超时提供的保证是,在watermark超过设置的超时时间之前,保证不会发生这种情况。

与processing time超时类似,当超时实际发生时延迟没有严格的上限,在event time已经超时的情况下,只有当流中有数据时才会提高watermark。值得强调的是,虽然超时很重要,但它们可能并不总是按预期的那样运行。例如Spark2.2中Structured Streaming不支持异步job执行,这意味着不会在周期(epoch)结束和下一次开始之间输出数据或超时数据,因为它在那个时候不处理任何数据。此外,如果处理的一批数据中没有记录(这是批处理的批次,而不是组)则没有更新,并且不可能有event time超时,这可能会在未来版本中改变。

21. 在使用这种任意有状态处理时,需要注意并非所有输出模式都被支持。随着Spark的不断改进,这肯定会发生变化,但在Spark2.2中mapGroupsWithState仅支持update输出模式,而flatMapGroupsWithState支持append和update输出模式。append模式意味着只有在超时(超过watermark)之后,数据才会显示在结果集中。但这不会自动发生,用户需要负责输出正确的一行或者多行。

mapGroupsWithState()类似UDAF,它将更新数据集作为输入,然后将其解析为对应一个值集合的键,需要给出如下几个定义:

(1)三个类定义:输入定义、状态定义、以及可选的输出定义。

(2)基于键、事件迭代器和先前状态的一个更新状态函数。

(3)超时时间参数。

通过这些对象和定义,可以通过创建、随时间更新以及删除它来控制任意状态。从一个基于定量的状态更新key的例子开始,来看如何处理传感器数据,要查找给定用户在数据集中执行某项活动的第一个和最后一个时间戳,这意味着groupBy操作的key是user和activity组合。当使用mapGroupsWithState时,输出始终是每个键(或组)只对应一行如果希望每个组都有多个输出,则应该使用flatMapGroupsWithState。接下来建立上面说的输入、状态和输出定义:

case class InputRow(user:String, timestamp:java.sql.Timestamp, activity:String)
case class UserState(user:String,
  var activity:String,
  var start:java.sql.Timestamp,
  var end:java.sql.Timestamp)

还有设置函数,定义如何根据给定行更新状态:

def updateUserStateWithEvent(state:UserState, input:InputRow):UserState = {
  if (Option(input.timestamp).isEmpty) {
    return state
  }
  if (state.activity == input.activity) {

    if (input.timestamp.after(state.end)) {
      state.end = input.timestamp
    }
    if (input.timestamp.before(state.start)) {
      state.start = input.timestamp
    }
  } else {
    if (input.timestamp.after(state.end)) {
      state.start = input.timestamp
      state.end = input.timestamp
      state.activity = input.activity
    }
  }

  state
}

现在需要通过函数来定义根据每一批次行来更新状态:

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode, GroupState}
def updateAcrossEvents(user:String,
  inputs: Iterator[InputRow],
  oldState: GroupState[UserState]):UserState = {
  var state:UserState = if (oldState.exists) oldState.get else UserState(user,
        "",
        new java.sql.Timestamp(6284160000000L),
        new java.sql.Timestamp(6284160L)
    )
  // we simply specify an old date that we can compare against and
  // immediately update based on the values in our data

  for (input <- inputs) {
    state = updateUserStateWithEvent(state, input)
    oldState.update(state)
  }
  state
}

当定义好了这些后,就可以通过传递相关信息来执行查询。在指定mapGroupsWithState时,需要指定是否需要超时给定组的状态,它告诉系统如果在一段时间后没有收到更新的状态应该做什么。在这个例子中,希望无限期地维护状态,因此指定Spark不会超时,使用update输出模式以便获得用户活动的更新:

import org.apache.spark.sql.streaming.GroupStateTimeout
withEventTime
  .selectExpr("User as user",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp", "gt as activity")
  .as[InputRow]
  .groupByKey(_.user)
  .mapGroupsWithState(GroupStateTimeout.NoTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("events_per_window")
  .format("memory")
  .outputMode("update")
  .start()

以下是查询结果:

+----+--------+--------------------+--------------------+
|user|activity| start| end|
+----+--------+--------------------+--------------------+
| a| bike|2015-02-23 13:30:...|2015-02-23 14:06:...|
| a| bike|2015-02-23 13:30:...|2015-02-23 14:06:...|
...
| d| bike|2015-02-24 13:07:...|2015-02-24 13:42:...|
+----+--------+--------------------+--------------------+

22. 典型的窗口操作是把落入开始时间和结束时间之内的所有事件都来进行计数或求和。但是有时候不是基于时间创建窗口,而是基于大量事件创建它们,而不考虑状态和event time,并在该数据窗口上执行一些聚合。例如可能想要为接收到的每500个事件计算一个值,而不管它们何时收到。

下一个示例分析用户活动数据集,并定期输出每个设备的平均读数,根据事件计数创建一个窗口,并在每次为该设备累积500个事件时输出该累积结果,为此程序定义了两个case类:包括输入行格式(它只是一个设备和一个时间戳),以及状态和输出行(其中包含收集记录的当前计数、设备ID、窗口中事件数组)。下面是各种自描述的case类定义:

case class InputRow(device: String, timestamp: java.sql.Timestamp, x: Double)
case class DeviceState(device: String, var values: Array[Double],
  var count: Int)
case class OutputRow(device: String, previousAverage: Double)

现在可以定义一个函数,根据输入的一行来更新状态,也可以用许多其他方式编写这个例子,这个例子帮助了解如何基于给定的行来更新状态:

def updateWithEvent(state:DeviceState, input:InputRow):DeviceState = {
  state.count += 1
  // maintain an array of the x-axis values
  state.values = state.values ++ Array(input.x)
  state
}

现在来定义一个基于多输入行的更新函数。在下面的示例中可以看到,有一个特定的键、包含一系列输入的迭代器、还有旧的状态,然后随着时间推移在接收到新事件后持续更新这个旧状态。它根据每个设备上发生事件的计数数量,针对每个设备返回更新的输出行。这种情况非常简单,即在一定事件数量之后,更新状态并重置它,然后创建一个输出行,可以在输出表中看到此行:

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode,
  GroupState}

def updateAcrossEvents(device:String, inputs: Iterator[InputRow],
  oldState: GroupState[DeviceState]):Iterator[OutputRow] = {
  inputs.toSeq.sortBy(_.timestamp.getTime).toIterator.flatMap { input =>
    val state = if (oldState.exists) oldState.get
      else DeviceState(device, Array(), 0)

    val newState = updateWithEvent(state, input)
    if (newState.count >= 500) {
      // 某个窗口完成之后用空的DeviceState代替该状态,并输出旧状态中过去500项的平均值
      oldState.update(DeviceState(device, Array(), 0))
      Iterator(OutputRow(device,
        newState.values.sum / newState.values.length.toDouble))
    }
    else {
      更新此处的DeviceState并不输出任何结果记录
      oldState.update(newState)
      Iterator()
    }
  }
}

现在可以运行流处理任务了,会注意到需要显式说明输出模式即append模式,还需要设置一个GroupStateTimeout,这个超时时间指定了窗口输出的等待时间(即使它没有达到所需的计数)。在这种情况下,如果设置一个无限的超时时间,则意味着如果一个设备一直没有累积到要求的500计数阈值,该状态将一直保持为“不完整”并且不会输出到结果表,在updateAcrossEvents函数中指定这两个参数之后,就可以启动流处理了:

import org.apache.spark.sql.streaming.GroupStateTimeout

withEventTime
  .selectExpr("Device as device",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp", "x")
  .as[InputRow]
  .groupByKey(_.device)
  .flatMapGroupsWithState(OutputMode.Append,
    GroupStateTimeout.NoTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("count_based_device")
  .format("memory")
  .outputMode("append")
  .start()

启动流后,就可以执行实时查询。结果如下:

SELECT * FROM count_based_device

+--------+--------------------+
| device| previousAverage|
+--------+--------------------+
|nexus4_1| 4.660034012E-4|
|nexus4_1|0.001436279298199...|
...
|nexus4_1|1.049804683999999...|
|nexus4_1|-0.01837188737960...|
+--------+--------------------+

可以看到每个窗口的统计值都在变化,新的统计结果加入到了结果集中。

23. 第二个有状态处理的示例使用flatMapGroupsWithState,这与mapGroupsWithState非常相似,不同之处在于一个键不是只对应最多一个输出,而是一个键可以对应多个输出。这可提供更多的灵活性,并且与mapGroupsWithState具有相同的基本结构,需要定义下面几项:

(1)三个类定义:输入定义、状态定义、以及可选的输出定义。

(2)一个函数,输入参数为一个键、一个多事件迭代器、和先前状态。

(3)超时事件参数(如前面超时时间部分所述)。

通过这些对象和定义,可以通过创建状态、(随着时间推移)更新状态、以及删除状态来控制任意状态。接下来看一个会话化(Sessionization)的例子。会话是未指定的时间窗口,其中可能发生一系列事件。通常需要将这些不同的事件记录在数组中,以便将其与将来的其他会话进行比较。在一次会话中,用户可能会设计各种不同的逻辑来维护和更新状态,以及定义窗口何时结束(如计数)或通过简单的超时来结束。以前面的例子为基础,并把它更严格地定义为一个会话。

有时候可能有一个明确的会话ID,可以在函数中使用它。这是很容易实现的,因为只是执行简单的聚合,甚至可能不需要自定义的有状态逻辑。在下面这个例子中,将根据用户ID和一些时间信息即时创建会话,并且如果在五秒内没有看到该用户的新事件则会话将终止,注意此代码使用超时的方式与其他示例中的不同,可以遵循创建类的相同流程,定义单个事件更新函数,然后定义多事件更新函数:

case class InputRow(uid:String, timestamp:java.sql.Timestamp, x:Double,
  activity:String)
case class UserSession(val uid:String, var timestamp:java.sql.Timestamp,
  var activities: Array[String], var values: Array[Double])
case class UserSessionOutput(val uid:String, var activities: Array[String],
  var xAvg:Double)

def updateWithEvent(state:UserSession, input:InputRow):UserSession = {
  // handle malformed dates
  if (Option(input.timestamp).isEmpty) {
    return state
  }

  state.timestamp = input.timestamp
  state.values = state.values ++ Array(input.x)
  if (!state.activities.contains(input.activity)) {
    state.activities = state.activities ++ Array(input.activity)
  }
  state
}

import org.apache.spark.sql.streaming.{GroupStateTimeout, OutputMode,
  GroupState}

def updateAcrossEvents(uid:String,
  inputs: Iterator[InputRow],
  oldState: GroupState[UserSession]):Iterator[UserSessionOutput] = {

  inputs.toSeq.sortBy(_.timestamp.getTime).toIterator.flatMap { input =>
    val state = if (oldState.exists) oldState.get else UserSession(
    uid,
    new java.sql.Timestamp(6284160000000L),
    Array(),
    Array())
    val newState = updateWithEvent(state, input)

    if (oldState.hasTimedOut) {
      val state = oldState.get
      oldState.remove()
      Iterator(UserSessionOutput(uid,
      state.activities,
      newState.values.sum / newState.values.length.toDouble))
    } else if (state.values.length > 1000) {
      val state = oldState.get
      oldState.remove()
      Iterator(UserSessionOutput(uid,
      state.activities,
      newState.values.sum / newState.values.length.toDouble))
    } else {
      oldState.update(newState)
      oldState.setTimeoutTimestamp(newState.timestamp.getTime(), "5 seconds")
      Iterator()
    }

  }
}

可以看到只希望看到最多延迟5秒的事件,将忽略太晚到达的事件。在这个有状态操作中将使用EventTimeTimeout来基于event time设置超时:

import org.apache.spark.sql.streaming.GroupStateTimeout

withEventTime.where("x is not null")
  .selectExpr("user as uid",
    "cast(Creation_Time/1000000000 as timestamp) as timestamp",
    "x", "gt as activity")
  .as[InputRow]
  .withWatermark("timestamp", "5 seconds")
  .groupByKey(_.uid)
  .flatMapGroupsWithState(OutputMode.Append,
    GroupStateTimeout.EventTimeTimeout)(updateAcrossEvents)
  .writeStream
  .queryName("count_based_device")
  .format("memory")
  .start()

查询此表将显示此时间段内每个用户的输出行:

SELECT * FROM count_based_device

+---+--------------------+--------------------+
|uid| activities| xAvg|
+---+--------------------+--------------------+
| a| [stand,null,sit]|-9.10908533566433...|
| a| [sit,null,walk]|-0.00654280428601...|
...
| c|[null,stairsdown...|-0.03286657789999995|
+---+--------------------+--------------------+

正如所预料的那样,其中包含许多活动的会话比具有较少活动的会话具有更高的x轴平均值。

四、生产中的Structured Streaming

24. 流处理application中最需要重视的问题是故障恢复。故障是不可避免的,可能会丢失集群中的一台机器、在没有适当迁移的情况下数据模式schema发生意外更改、或者可能需要重新启动集群或应用程序。在上面任何一种情况下,Structured Streaming允许仅通过重新启动application来恢复它,为此必须将application配置为使用checkpoint和预写日志,这两者都由引擎自动处理。

具体来说,用户必须配置查询以写入可靠文件系统上的checkpoint位置(例如HDFS、S3或其他兼容文件系统)。然后,Structured Streaming将周期性地保存所有相关进度信息(例如一个trigger操作处理的offset范围),以及当前中间状态值,将它们保存到checkpoint路径下。在失败情况下只需重新启动application,确保指向相同的checkpoint位置,它将自动恢复其状态并在其中断的位置重新开始处理数据,用户不必手动管理此状态,Structured Streaming可以自动完成这些过程。

要使用checkpoint,请在启动application之前通过writeStream上的checkpointLocation选项指定checkpoint位置。如下所示:

val static = spark.read.json("/data/activity-data")
val streaming = spark
  .readStream
  .schema(static.schema)
  .option("maxFilesPerTrigger", 10)
  .json("/data/activity-data")
  .groupBy("gt")
  .count()
val query = streaming
  .writeStream
  .outputMode("complete")
  .option("checkpointLocation", "/some/location/")
  .queryName("test_stream")
  .format("memory")
  .start()

如果丢失了checkpoint目录或它内部的信息,则application将无法从故障中恢复,用户将不得不从头开始重新启动流处理。

25. checkpoint对于生产中运行application来说是最重要的事情,这是因为checkpoint将存储所有的信息,包括流到目前为止已处理的所有信息,以及它可能存储的中间状态等。但是checkpoint有一个小问题,当更新流处理application时,不得不对旧的checkpoint数据进行推理。在更新Spark时,必须确保该更新不是一个中断性的更改。下面针对两种更新类型进行详细介绍:更新application代码、运行新的Spark版本:

(1)Structured Streaming允许在application两次重启之间对代码进行某些更改,最重要的是只要具有相同的类型签名,就可以更改UDF,此功能对于bug修复非常有用。例如假设application开始接收新类型的数据,并且当前逻辑中的某个数据解析函数崩溃,使用Structured Streaming可以使用该函数的新版本重新编译application,并在之前流处理的崩溃位置开始流处理。

尽管像添加新列或更改UDF这样的小调整不算是破坏性的改变,并且不需要新的checkpoint目录,但有某些更大的更改需要新的检查点目录,例如如果修改代码以添加用于聚合操作的新键,或甚至更改查询本身,则Spark无法为新查询从旧的checkpoint目录构建所需的状态。在这些情况下Structured Streaming将会抛出一个异常,表示无法从checkpoint目录开始,必须从头开始用一个新的(空)目录作为检查点位置

(2)对于基于补丁(patch)的版本更新,Structured Streaming应用程序应该能够从旧checkpoint目录重新启动(例如从Spark 2.2.0迁移到2.2.1到2.2.2)。checkpoint格式被设计为向前兼容,因此它可能被破坏的原因是由于关键bug修复。如果某个Spark发行版无法从旧版本checkpoint恢复,则会在其发行说明中明确说明。

一般而言,集群的大小应该能够轻松处理超出数据速率突发的情况,用户应该在应用程序和集群中监视下面所述的关键指标。一般来说,如果输入速率比处理率高得多,那么就需要优化代码与参数、扩展集群资源等。根据资源管理器和部署情况,可能需将Executor动态添加到application中,这些更改可能会导致一些处理延迟(因为当executor被删除时,数据会被重新计算或分区)。

虽然有时需要对集群或application进行底层结构的更改,但有时候的更改可能只需要用新的配置来重新启动application或流。例如在流处理正在运行时,不支持更改spark.sql.shuffle.partitions配置(即使更改但实际上也不会生效并改变shuffle的并发度),这需要重新启动流处理而不一定是整个application,更重量级的更改(如更改application配置)可能需要重新启动application。

26. 流处理application中的metric和监视与离线批处理Spark application的度量(Metrics)与监视(Monitoring)工具相同,但是Structured Streaming增加了更多选项,可以用两个关键API来查询流查询的状态,并查看其最近的执行进度。查询状态(query status)是最基本的监视API,它回答“流正在执行什么处理?”这样的问题,此信息可以在startStream返回的查询对象的status字段中报告。例如有一个简单的计数流,它提供由下列查询定义的IOT设备的计数(这里我们使用第三节中的相同查询,省略了初始化代码):

query.status

要获取给定查询的结果状态,只需运行query.status命令即可返回流的当前状态,它返回数据流在那个点当前正在发生事情的细节。以下是查询此状态时将返回的结果:

{
    "message" :"Getting offsets from ...",
    "isDataAvailable" :true,
    "isTriggerActive" :true
}

上面的JSON段描述了从结构化流数据源获取偏移量(因此“message”字段描述为获取偏移量)。有各种各样的消息来描述流的状态。在这里显示了在Spark shell中调用status命令的方式,但是对于独立application,可以通过实现监视服务器来显示其状态,例如一个监听某端口的小型HTTP服务器,该服务器在监听端口收到请求时返回query.status,或者可以使用后面描述的StreamingQueryListener API来监听更多事件,它提供更丰富的功能接口。

虽然查询当前状态很有用,但查看流执行进度的能力同样重要。进度API(progress API)可以回答诸如“处理元组的速度怎样?”或“元组从数据源到达的速度有多快?”等问题。通过运行query.recentProgress将获得更多基于时间的信息,如处理速率和批处理持续时间。流查询的进度信息还包括有关数据流的输入源和输出sink的信息:

query.recentProgress

以下是运行代码之后的版本的结果,版本的结果类似:

Array({
    "id" :"d9b5eac5-2b27-4655-8dd3-4be626b1b59b",
    "runId" :"f8da8bc7-5d0a-4554-880d-d21fe43b983d",
    "name" :"test_stream",
    "timestamp" :"2017-08-06T21:11:21.141Z",
    "numInputRows" :780119,
    "processedRowsPerSecond" :19779.89350912779,
    "durationMs" :{
        "addBatch" :38179,
        "getBatch" :235,
        "getOffset" :518,
        "queryPlanning" :138,
        "triggerExecution" :39440,
        "walCommit" :312
    },
    "stateOperators" :[ {
        "numRowsTotal" :7,
        "numRowsUpdated" :7
    } ],
    "sources" :[ {
        "description" :"FileStreamSource[/some/stream/source/]",
        "startOffset" :null,
        "endOffset" :{
            "logOffset" :0
        },
    "numInputRows" :780119,
    "processedRowsPerSecond" :19779.89350912779
} ],
    "sink" :{
        "description" :"MemorySink"
    }
})

正如从上面所显示输出中看到的,这包括有关流状态的许多详细信息。需要注意的是,这是一个某个时间点的快照(根据询问进度的时间)。为了持续获得有关流处理的状态,需要重复执行此API查询以获取更新的状态。查询结果输出中的大部分字段是容易理解的,但是来详细介绍一下更为重要的字段。

27. 在监控metrics信息中,输入速率(input rate)是指数据从输入源流向Structured Streaming系统的速度,处理速率(process rate)是application处理分析数据的速度。在理想的情况下输入速率和处理速率应变化一致。另一种情况是输入速率远大于处理速率,当这种情况发生时流处理就延迟落后了,需要扩展集群以处理更大的负载。

一些流式传输系统使用微批处理以任何合理的吞吐量运行(有些可选择高延迟以换取更高的吞吐量)。Structured Streaming实现了两者,随着时间的推移Structured Streaming会以不同的批量大小来处理事件,因此可能会看到微批处理每个批次处理时间的持续变化。当执行选项为连续处理引擎(continuous processing engine)时,此度量值就没有太大意义。

一般来说,最好的做法是将每个微批次的持续时间以及输入和处理速率的变化用可视化的方法显示出来,相对于持续的文本报告,可视化方法可以更好帮助用户理解数据流的状态。在Spark UI上,每个流处理application将显示为一系列短job,每个都有一个trigger,可以使用相同的UI来查看application中的指标、查询计划、task持续时间和日志。DStream API与Structured Streaming的一个区别是Structured Streaming不使用流标签(Streaming Tab)。

了解和查看结构化流查询的metrics是重要的第一步,这涉及到持续监视仪表板和各种指标,以发现潜在的问题。同时还需要强大的自动警报功能,以便在job失败时通知用户,或者在处理速率跟不上输入数据速率的情况下通知,整个过程是自动的不需要人工参与。将现有告警工具与Spark集成有几种方法,通常是基于之前说的近期进度(recent progress)API,例如可以直接将metrics提供给监控系统如开源的CodaHale Metrics库或Prometheus,或者可以简单地记录它们并使用Splunk等日志聚合系统。除了对查询过程进行监视和告警之外,还可以监视集群运行状态和整个application并提供告警服务。

可以使用queryProgress API将监视事件输出到所选的监视平台上(例如日志聚合系统或Prometheus仪表板)。除了这些方法之外,还有一种更低级但更强大的方式来监视application的执行过程,这就是使用StreamingQueryListener。StreamingQueryListener类允许从流查询中接收异步更新,以便自动将此信息输出到其他系统,并实现强大的监视和警报机制。

首先要开发自己的对象来扩展StreamingQueryListener,然后将其附加到正在运行的SparkSession。一旦使用sparkSession.streams.addListener()附加了自定义侦听器,自定义类将在查询开始或停止时收到通知,或者在active状态查询上有进度变化时收到通知。以下是Structured Streaming文档的一个监听器示例:

val spark: SparkSession = ...

spark.streams.addListener(new StreamingQueryListener() {
    override def onQueryStarted(queryStarted: QueryStartedEvent): Unit = {
        println("Query started: " + queryStarted.id)
    }
    override def onQueryTerminated(
      queryTerminated: QueryTerminatedEvent): Unit = {
        println("Query terminated: " + queryTerminated.id)
    }
    override def onQueryProgress(queryProgress: QueryProgressEvent): Unit = {
        println("Query made progress: " + queryProgress.progress)
    }
})

流侦听器使用户可以用自定义代码处理每次进度更新或状态更改,并将其传递给外部系统。例如以下StreamingQueryListener的实现代码是用于将所有查询进度信息转发给Kafka,一旦从Kafka读取数据获取实际metric指标,必须解析此JSON字符串:

class KafkaMetrics(servers: String) extends StreamingQueryListener {
  val kafkaProperties = new Properties()
  kafkaProperties.put(
    "bootstrap.servers",
    servers)
  kafkaProperties.put(
    "key.serializer",
    "kafkashaded.org.apache.kafka.common.serialization.StringSerializer")
  kafkaProperties.put(
    "value.serializer",
    "kafkashaded.org.apache.kafka.common.serialization.StringSerializer")

  val producer = new KafkaProducer[String, String](kafkaProperties)

  import org.apache.spark.sql.streaming.StreamingQueryListener
  import org.apache.kafka.clients.producer.KafkaProducer

  override def onQueryProgress(event:
    StreamingQueryListener.QueryProgressEvent): Unit = {
    producer.send(new ProducerRecord("streaming-metrics",
      event.progress.json))
  }
  override def onQueryStarted(event:
    StreamingQueryListener.QueryStartedEvent): Unit = {}
  override def onQueryTerminated(event:
    StreamingQueryListener.QueryTerminatedEvent): Unit = {}
}

通过使用StreamingQueryListener接口,甚至可以运行Structured Streaming application来监视同一个(或另一个)集群上的Structured Streaming application,也可以用这种方式管理多个流。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值