You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

18 KiB

32Window操作&Watermark流处理引擎提供了哪些优秀机制

你好,我是吴磊。

在上一讲我们从原理的角度出发学习了Structured Streaming的计算模型与容错机制。深入理解这些基本原理会帮我们开发流处理应用打下坚实的基础。

在“流动的Word Count”那一讲我们演示了在Structured Streaming框架下如何做流处理开发的一般流程。基于readStream API与writeStream API我们可以像读写DataFrame那样轻松地从Source获取数据流并把处理过的数据写入Sink。

今天这一讲咱们从功能的视角出发继续来聊一聊Structured Streaming流处理引擎都为开发者都提供了哪些特性与能力让你更灵活地设计并实现流处理应用。

Structured Streaming怎样坐享其成

学习过计算模型之后我们知道不管是Batch mode的多个Micro-batch、多个作业的执行方式还是Continuous mode下的一个Long running job这些作业的执行计划最终都会交付给Spark SQL与Spark Core付诸优化与执行。

图片

而这会带来两个方面的收益。一方面凡是Spark SQL支持的开发能力不论是丰富的DataFrame算子还是灵活的SQL查询Structured Streaming引擎都可以拿来即用。基于之前学过的内容我们可以像处理普通的DataFrame那样对基于流数据构建的DataFrame做各式各样的转换与聚合。

另一方面既然开发入口同为DataFrame那么流处理应用同样能够享有Spark SQL提供的“性能红利”。在Spark SQL学习模块我们学习过Catalyst优化器与Tungsten这两个组件会对用户代码做高度优化从而提升应用的执行性能。

因此就框架的功能来说我们可以简单地概括为Spark SQL所拥有的能力Structured Streaming都有。不过除了基本的数据处理能力以外为了更好地支持流计算场景Structured Streaming引擎还提供了一些专门针对流处理的计算能力比如说Window操作、Watermark与延迟数据处理等等。

Window操作

我们先来说说Window操作它指的是Structured Streaming引擎会基于一定的时间窗口对数据流中的消息进行消费并处理。这是什么意思呢首先我们需要了解两个基本概念Event Time和Processing Time也即事件时间和处理时间。

所谓事件时间它指的是消息生成的时间比如我们在netcat中敲入“Apache Spark”的时间戳是“2021-10-01 09:30:00”那么这个时间就是消息“Apache Spark”的事件时间。

图片

而处理时间它指的是这个消息到达Structured Streaming引擎的时间因此也有人把处理时间称作是到达时间Arrival Time也即消息到达流处理系统的时间。显然处理时间要滞后于事件时间。

所谓Window操作实际上就是Structured Streaming引擎基于事件时间或是处理时间以固定间隔划定时间窗口然后以窗口为粒度处理消息。在窗口的划分上Structured Streaming支持两种划分方式一种叫做Tumbling Window另一种叫做Sliding Window。

我们可以用一句话来记住二者之间的区别Tumbling Window划分出来的时间窗口“不重不漏”而Sliding Window划分出来的窗口可能会重叠、也可能会有遗漏如下图所示。

图片

不难发现Sliding Window划分出来的窗口是否存在“重、漏”取决于窗口间隔Interval与窗口大小Size之间的关系。Tumbling Window与Sliding Window并无优劣之分完全取决于应用场景与业务需要。

干讲理论总是枯燥无趣接下来咱们对之前的“流动的Word Count”稍作调整来演示Structured Streaming中的Window操作。为了让演示的过程更加清晰明了这里我们采用Tumbling Window的划分方式Sliding Window留给你作为课后作业。

为了完成实验我们还是需要准备好两个终端。第一个终端用于启动spark-shell并提交流处理代码而第二个终端用于启动netcat、输入数据流。要基于窗口去统计单词我们仅需调整数据处理部分的代码readStream与writeStreamUpdate Mode部分的代码不需要任何改动。因此为了聚焦Window操作的学习我这里仅贴出了有所变动的部分。

df = df.withColumn("inputs", split($"value", ","))
// 提取事件时间
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
// 提取单词序列
.withColumn("words", split(element_at(col("inputs"),2), " "))
// 拆分单词
.withColumn("word", explode($"words"))
// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
// 统计计数
.count()

为了模拟事件时间我们在netcat终端输入的消息会同时包含时间戳和单词序列。两者之间以逗号分隔而单词与单词之间还是用空格分隔如下表所示。

图片

因此,对于输入数据的处理,我们首先要分别提取出时间戳和单词序列,然后再把单词序列展开为单词。接下来,我们按照时间窗口与单词做分组,这里需要我们特别关注这行代码:

// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))

其中window(col(“eventTime”), “5 minute”)的含义就是以事件时间为准以5分钟为间隔创建Tumbling时间窗口。显然window函数的第一个参数就是创建窗口所依赖的时间轴而第二个参数则指定了窗口大小Size。说到这里你可能会问“如果我想创建Sliding Window该怎么做呢

其实非常简单只需要在window函数的调用中再添加第三个参数即可也就是窗口间隔Interval。比如说我们还是想创建大小为5分钟的窗口但是使用以3分钟为间隔进行滑动的方式去创建那么我们就可以这样来实现window(col(“eventTime”), “5 minute”, “3 minute”)。是不是很简单?

完成基于窗口和单词的分组之后我们就可以继续调用count来完成计数了。不难发现代码中的大多数转换操作实际上都是我们常见的DataFrame算子这也印证了这讲开头说的Structured Streaming先天优势就是能坐享其成享有Spark SQL提供的“性能红利”。

代码准备好之后我们就可以把它们陆续敲入到spark-shell并等待来自netcat的数据流。切换到netcat终端并陆续(注意,是陆续!)输入刚刚的文本内容我们就可以在spark-shell终端看到如下的计算结果。

图片

可以看到与“流动的Word Count”不同这里的统计计数是以窗口5分钟为粒度的。对于每一个时间窗口来说Structured Streaming引擎都会把事件时间落入该窗口的单词统计在内。不难推断随着时间向前推进已经计算过的窗口将不会再有状态上的更新。

比方说当引擎处理到“2021-10-01 09:39:00,Spark Streaming”这条消息记作消息39理论上前一个窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”记作窗口30-35的状态也就是不同单词的统计计数应该不会再有变化。

说到这里你可能会有这样的疑问“那不见得啊如果在消息39之后引擎又接收到一条事件时间落在窗口30-35的消息那该怎么办呢”要回答这个问题我们还得从Late data和Structured Streaming的Watermark机制说起。

Late data与Watermark

我们先来说Late data所谓Late data它指的是那些事件时间与处理时间不一致的消息。虽然听上去有点绕但通过下面的图解我们就能瞬间理解Late data的含义。

图片

通常来说,消息生成的时间,与消息到达流处理引擎的时间,应该是一致的。也即先生成的消息先到达,而后生成的消息后到达,就像上图中灰色部分消息所示意的那样。

不过在现实情况中总会有一些消息因为网络延迟或者这样那样的一些原因它们的处理时间与事件时间存在着比较大的偏差。这些消息到达引擎的时间甚至晚于那些在它们之后才生成的消息。像这样的消息我们统称为“Late data”如图中红色部分的消息所示。

由于有Late data的存在流处理引擎就需要一个机制来判定Late data的有效性从而决定是否让晚到的消息参与到之前窗口的计算。

就拿红色的“Spark is cool”消息来说在它到达Structured Streaming引擎的时候属于它的事件时间窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”已经关闭了。那么在这种情况下Structured Streaming到底要不要用消息“Spark is cool”中的单词去更新窗口30-35的状态单词计数

为了解决Late data的问题Structured Streaming采用了一种叫作Watermark的机制来应对。为了让你能够更容易地理解Watermark机制的原理在去探讨它之前我们先来澄清两个极其相似但是又完全不同的概念水印和水位线。

要说清楚水印和水位线,咱们不妨来做个思想实验。假设桌子上有一盒鲜牛奶、一个吸管、还有一个玻璃杯。我们把盒子开个口,把牛奶全部倒入玻璃杯,接着,把吸管插入玻璃杯,然后通过吸管喝一口新鲜美味的牛奶。好啦,实验做完了,接下来,我们用它来帮我们澄清概念。

图片

如图所示,最开始的时候,我们把牛奶倒到水印标示出来的高度,然后用吸管喝牛奶。不过,不论我们通过吸管喝多少牛奶,水印位置的牛奶痕迹都不会消失,也就是说,水印的位置是相对固定的。而水位线则不同,我们喝得越多,水位线下降得就越快,直到把牛奶喝光,水位线降低到玻璃杯底部。

好啦澄清了水印与水位线的概念之后我们还需要把这两个概念与流处理中的概念对应上。毕竟“倒牛奶”的思想实验是用来辅助我们学习Watermark机制的。

首先水印与水位线对标的都是消息的事件时间。水印相当于系统当前接收到的所有消息中最大的事件时间。而水位线指的是水印对应的事件时间减去用户设置的容忍值。为了叙述方便我们把这个容忍值记作T。在Structured Streaming中我们把水位线对应的事件时间称作Watermark如下图所示。

图片

显然在流处理引擎不停地接收消息的过程中水印与水位线也会相应地跟着变化。这个过程跟我们刚刚操作的“倒牛奶、喝牛奶”的过程很像。每当新到消息的事件时间大于当前水印的时候系统就会更新水印这就好比我们往玻璃杯里倒牛奶一直倒到最大事件时间的位置。然后我们用吸管喝牛奶吸掉深度为T的牛奶让水位线下降到Watermark的位置。

把不同的概念关联上之后接下来我们来正式地介绍Structured Streaming的Watermark机制。我们刚刚说过Watermark机制是用来决定哪些Late data可以参与过往窗口状态的更新而哪些Late data则惨遭抛弃。

如果用文字去解释Watermark机制很容易把人说得云里雾里因此咱们不妨用一张流程图来阐释这个过程。

图片

可以看到当有新消息到达系统后Structured Streaming首先判断它的事件时间是否大于水印。如果事件时间大于水印的话Watermark机制则相应地更新水印与水位线也就是最大事件时间与Watermark。

相反假设新到消息的事件时间在当前水印以下那么系统进一步判断消息的事件时间与“Watermark时间窗口下沿”的关系。所谓“Watermark时间窗口下沿”它指的是Watermark所属时间窗口的起始时间。

咱们来举例说明假设Watermark为“2021-10-01 09:34:00”且事件时间窗口大小为5分钟那么Watermark所在时间窗口就是[“2021-10-01 09:30:00”“2021-10-01 09:35:00”]也即窗口30-35。这个时候“Watermark时间窗口下沿”就是窗口30-35的起始时间也就是“2021-10-01 09:30:00”如下图所示。

图片

对于最新到达的消息如果其事件时间大于“Watermark时间窗口下沿”则消息可以参与过往窗口的状态更新否则消息将被系统抛弃不再参与计算。换句话说凡是事件时间小于“Watermark时间窗口下沿”的消息系统都认为这样的消息来得太迟了没有资格再去更新以往计算过的窗口。

不难发现,在这个过程中,延迟容忍度T是Watermark机制中的决定性因素它决定了“多迟”的消息可以被系统容忍并接受。那么问题来了既然T是由用户设定的那么用户通过什么途径来设定这个T呢再者在Structured Streaming的开发框架下Watermark机制要如何生效呢

其实要开启Watermark机制、并设置容忍度T我们只需一行代码即可搞定。接下来我们就以刚刚“带窗口的流动Word Count”为例演示并说明Watermark机制的具体用法。

df = df.withColumn("inputs", split($"value", ","))
// 提取事件时间
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
// 提取单词序列
.withColumn("words", split(element_at(col("inputs"),2), " "))
// 拆分单词
.withColumn("word", explode($"words"))
// 启用Watermark机制指定容忍度T为10分钟
.withWatermark("eventTime", "10 minute")
// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
// 统计计数
.count()

可以看到,除了“.withWatermark(“eventTime”, “10 minute”)”这一句代码其他部分与“带窗口的流动Word Count”都是一样的。这里我们用withWatermark函数来启用Watermark机制该函数有两个参数第一个参数是事件时间而第二个参数就是由用户指定的容忍度T。

为了演示Watermark机制产生的效果接下来咱们对netcat输入的数据流做一些调整如下表所示。注意消息7“Test Test”和消息8“Spark is cool”都是Late data。

图片

基于我们刚刚对于Watermark机制的分析在容忍度T为10分钟的情况下Late data消息8“Spark is cool”会被系统接受并消费而消息7“Test Test”则将惨遭抛弃。你不妨先花点时间自行推断出这一结论然后再来看后面的结果演示。

图片

上图中左侧是输入消息7“Test Test”时spark-shell端的输出可以看到消息7被系统丢弃没能参与计算。而右侧是消息8“Spark is cool”对应的执行结果可以看到“Spark”、“is”、“cool”这3个单词成功地更新了之前窗口30-35的状态注意这里的“Spark”计数为3而不是1

重点回顾

好啦今天的内容到这里就讲完了我们一起来做个总结。首先我们需要知道在数据处理方面Structured Streaming完全可以复用Spark SQL现有的功能与性能优势。因此开发者完全可以“坐享其成”使用DataFrame算子或是SQL语句来完成流数据的处理。

再者我们需要特别关注并掌握Structured Streaming的Window操作与Watermark机制。Structured Streaming支持两类窗口**一个是“不重不漏”的Tumbling Window另一个是“可重可漏”的Sliding Window。**二者并无高下之分作为开发者我们可以使用window函数结合事件时间、窗口大小、窗口间隔等多个参数来灵活地在两种窗口之间进行取舍。

对于Late data的处理Structured Streaming使用Watermark机制来决定其是否参与过往窗口的计算与更新。关于Watermark机制的工作原理我把它整理到了下面的流程图中供你随时查看。

图片

每课一练

1.请你结合Tumbling Window的代码把Tumbling Window改为Sliding Window。
2.对于Watermark机制中的示例请你分析一下为什么消息8“Spark is cool”会被系统接受并处理而消息7“Test Test”却惨遭抛弃

欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多同事、朋友。