流处理102-超越批处理

25 阅读53分钟

简介****

欢迎回来!如果你错过了我之前的文章《批处理之外的世界:流媒体101》,我强烈建议你先花时间读一遍。它为我将在这篇文章中介绍的主题奠定了必要的基础,并且我假设您已经熟悉其中介绍的术语和概念。警告老师之类的。

另外,请注意,这篇文章包含了一些动画,所以那些尝试打印它的人将错过一些最好的部分。打印机和所有这些都要小心。

免责声明不在话下,让我们开始狂欢吧。简要回顾一下,上次我主要关注了三个方面:

l 术语,当我使用“流”等术语的精确定义;

l 批处理和流处理,比较两种类型系统的理论能力,并假设只有两件事是必要的,使流处理系统超越它们的批处理对应:正确性和时间推理工具;

l 数据处理模式,查看批处理和流处理系统在处理有界和无界数据时所采用的基本方法。

在这篇文章中,我想进一步关注上一篇文章中的数据处理模式,但要更详细,并在具体示例的上下文中。这篇文章将贯穿两个主要部分:

l 流媒体101 Redux:简要回顾流媒体101中介绍的概念,并添加了一个运行示例来突出所提出的要点。

l 流媒体102:流媒体101的配套作品,详细介绍了在处理无界数据时重要的其他概念,并继续使用具体示例作为解释它们的工具。

当我们结束时,我们将涵盖我认为是健壮的乱序数据处理所需的核心原则和概念集;这些是推理时间的工具,真正让您超越经典的批处理。

为了让您了解它们实际的样子,我将使用Dataflow SDK代码片段(即谷歌Cloud Dataflow的API),再加上动画来提供概念的可视化表示。我之所以使用Dataflow SDK,而不是人们可能更熟悉的Spark Streaming或Storm,是因为在这一点上,没有其他系统能够提供我想要涵盖的所有示例所必需的表达量。好消息是,其他项目正开始朝这个方向发展。更好的消息是,谷歌今天已经向Apache软件基金会提交了一份创建Apache Dataflow孵化器项目的提案(与data Artisans、Cloudera、Talend和其他一些公司合作),希望围绕数据流模型提供的健壮的无序处理语义构建一个开放的社区和生态系统。这将使2016年变得非常有趣。但我离题了。

这篇文章缺少了我上次承诺的比较部分;对不起。我错误地低估了我想要在这篇文章中包含多少内容,以及我需要多长时间才能做到这一点。在这一点上,我只是不能看到延迟和扩展内容进一步适应这一部分。如果这是一种安慰的话,我在2015年新加坡Strata + Hadoop World上发表了一篇题为“大规模数据处理的进化”的演讲(并将在2016年6月的Strata + Hadoop World London上发表它的更新版本),其中涵盖了很多我想在这样一个比较部分中解决的材料;幻灯片非常漂亮,你可以在这里阅读。当然,不太一样,但也有意义。

现在,我们来看流!

概述和路线图****

在流101中,我首先澄清了一些术语。我从区分有界数据和无界数据开始。有界数据源具有有限的大小,通常称为“批处理”数据。无界源可能具有无限的大小,并且通常被称为“流”数据。我尽量避免使用批处理和流这两个术语来指代数据源,因为这些名称带有某些具有误导性的含义,而且常常具有局限性。

然后我继续定义批处理引擎和流引擎之间的区别:批处理引擎是那些只考虑有界数据的引擎,而流引擎是考虑无界数据的引擎。我的目标是只在提到执行引擎时使用批处理和流处理这两个术语。

在术语之后,我介绍了与处理无界数据相关的两个重要的基本概念。我首先建立了事件时间(事件发生的时间)和处理时间(在处理过程中观察事件的时间)之间的关键区别。这为流101中提出的一个主要论点提供了基础:如果您关心事件实际发生的正确性和上下文,则必须相对于它们固有的事件时间来分析数据,而不是在分析过程中遇到它们的处理时间。

然后,我介绍了窗口的概念(即,沿着时间边界划分数据集),这是一种常用的方法,用于处理技术上无界数据源可能永远不会结束的事实。窗口策略的一些简单的例子是固定窗口和滑动窗口,但是更复杂的窗口类型,如会话(其中窗口由数据本身的特征定义,例如,捕获每个用户的活动会话,然后是不活动的间隙)也看到了广泛的使用。

除了这两个概念,我们现在还要仔细研究另外三个概念:

水印:水印是相对于事件时间的输入完整性的概念。时间为X的水印表示:“所有事件时间小于X的输入数据都已被观察到。”因此,在观察没有已知端点的无界数据源时,水印可以作为进度的度量。

触发器:触发器是一种机制,用于声明窗口的输出何时应该相对于一些外部信号具体化。触发器在选择何时发出输出方面提供了灵活性。它们还可以在一个窗口的发展过程中多次观察它的输出。这反过来又为随着时间的推移精炼结果打开了大门,允许在数据到达时提供推测结果,以及处理随着时间的推移上游数据(修订)的变化或相对于水印延迟到达的数据(例如,移动场景,当某人离线时,某人的手机记录了各种动作和事件时间,然后继续上传这些事件以便在恢复连接后进行处理)。

累积:累积模式指定在同一窗口中观察到的多个结果之间的关系。这些结果可能是完全脱节的,也就是说,随着时间的推移,它们代表着独立的delta,或者它们之间可能存在重叠。不同的积累模式具有与之相关的不同语义和成本,因此可以在各种用例中找到适用性。

最后,因为我认为这样更容易理解所有这些概念之间的关系,所以我们将在回答四个问题的结构中重新审视旧的并探索新的,我认为所有这些问题对于每个无界数据处理问题都是至关重要的:

计算什么结果?管道中的转换类型可以回答这个问题。这包括计算总和、构建直方图、训练机器学习模型等。这也是经典批处理本质上要回答的问题。

在事件时间的什么地方计算结果?这个问题可以通过在管道中使用事件时间窗口来回答。这包括流101中窗口的常见示例(固定,滑动和会话),似乎没有窗口概念的用例(例如,流媒体101中描述的与时间无关的处理;经典的批处理通常也属于这一类),以及其他更复杂的窗口类型,例如限时拍卖。还要注意,如果将进入时间作为记录到达系统时的事件时间,则还可以包括处理时间窗口。

在处理时间内,结果何时实现?这个问题可以通过使用水印和触发器来回答。关于这个主题有无限的变化,但最常见的模式使用水印来描述给定窗口的输入何时完成,触发器允许规范早期结果(对于推测性的,在窗口完成之前发出的部分结果)和后期结果(对于水印只是对完整性的估计的情况,在水印声明给定窗口的输入完成后可能会有更多的输入数据到达)。

结果的改进是如何关联的?这个问题可以通过所使用的累积类型来回答:丢弃(其中结果都是独立且不同的)、累积(其中后来的结果建立在先前的结果之上)或累积和收回(其中累积值加上先前触发值的收回)。

我们将在接下来的文章中更详细地讨论这些问题。是的,我要把这个配色方案搞清楚,试图让它非常清楚,在What/Where/When/How习语[2]中,哪些概念与哪个问题相关。

流101 redux****

首先,让我们回顾一下流101中介绍的一些概念,但这一次还有一些详细的例子,将有助于使这些概念更加具体。

When :转换****

在经典批处理中应用的转换回答了这个问题:“计算什么结果?”尽管你们中的许多人可能已经熟悉了经典的批处理,但我们还是要从那里开始,因为它是我们将添加所有其他概念的基础。

在本节中,我们将看一个示例:在包含10个值的简单数据集上计算键合整数和。如果你想用更实用的方法来理解它,你可以把它想象成计算一个团队中玩某种手机游戏的人的总得分,将他们的独立得分结合在一起。您可以想象它同样适用于计费和使用监视用例。

对于每个示例,我将包括一小段Dataflow Java SDK伪代码,以使管道的定义更加具体。从某种意义上说,它将是伪代码,因为我有时会扭曲规则以使示例更清晰,省略细节(如使用具体的I/O源),或简化名称(Java中当前的触发器名称非常冗长;为了清楚起见,我将使用更简单的名称)。除了这些次要的东西(我在Postscript中明确列举了其中的大部分),它基本上是真实世界的Dataflow SDK代码。稍后,我还将为那些对类似示例感兴趣的人提供一个实际代码演练的链接,他们可以自己编译和运行。

如果您至少熟悉Spark Streaming或Flink之类的东西,那么您应该可以相对轻松地了解Dataflow代码在做什么。为了给你一个速成课程,在数据流中有两个基本的原语:

n PCollections,它表示数据集(可能是大量的数据集),可以在其上执行并行转换(因此名称开头的“P”)。

n PTransforms,它们被应用于PCollections以创建新的PCollections。ptransform可以执行元素转换,它们可以将多个元素聚合在一起,或者它们可以是其他ptransform的复合组合。

 

如果您发现自己感到困惑,或者只是想要参考参考资料,您可以查看Dataflow Java SDK文档。

在我们的示例中,我们假设从一个名为“input”的PCollection<KV<String, Integer>>开始(也就是说,一个由字符串和整数的键/值对组成的PCollection,其中字符串类似于团队名称,而整数是相应团队中任何个人的分数)。在现实世界的管道中,我们将通过从I/O源读取原始数据(例如,日志记录)的PCollection来获取输入,然后通过将日志记录解析为适当的键/值对将其转换为PCollection<KV<String, Integer>>。为了清晰起见,在第一个示例中,我将包括所有这些步骤的伪代码,但在随后的示例中,我将省略I/O和解析部分。

因此,对于简单地从I/O源读取数据,解析出球队/分数对,并计算每个球队的分数总和的管道,我们应该有这样的东西(注意,如果你的浏览器不够大,你可以水平滚动代码片段,例如,在移动设备上):

PCollection raw = IO.read(...);

PCollection<KV<String, Integer>> input = raw.apply(ParDo.of(new ParseFn());

PCollection<KV<String, Integer>> scores = input

  .apply(Sum.integersPerKey());

对于接下来的所有示例,在看到描述我们将要分析的管道的代码片段之后,我们将查看该管道在具体数据集上执行的动画呈现。更具体地说,我们将看到对单个键执行超过10个输入数据的管道是什么样子;在真实的管道中,您可以想象类似的操作将在多台机器上并行发生,但是为了我们的示例,保持简单会更清楚。

每个动画在两个维度上绘制输入和输出:事件时间(在X轴上)和处理时间(在Y轴上)。因此,管道所观察到的实时从下到上进展,如粗白色上升线所示。输入是圆圈,圆圈内的数字表示该特定记录的值。它们开始是灰色的,随着管道的观察而改变颜色。

当管道观察值时,它在其状态中累积这些值,并最终将聚合结果具体化为输出。状态和输出用矩形表示,汇总值靠近顶部,矩形所覆盖的面积表示事件时间和处理时间累积到结果中的部分。对于清单1中的管道,在经典的批处理引擎上执行时,它看起来像这样(注意,您需要单击/点击下面的图像来启动动画,然后动画将永远循环,直到再次单击/点击):

由于这是一个批处理管道,因此它会累积状态,直到看到所有输入(由顶部的绿色虚线表示),此时,它会生成单个输出51。在这个例子中,我们计算所有事件时间的总和,因为我们没有应用任何特定的窗口转换;因此,状态和输出的矩形覆盖了整个X轴。如果我们想要处理一个无界的数据源,然而,经典的批处理将是不够的;我们不能等待输入结束,因为它实际上永远不会结束。我们想要的一个概念是窗口,我们在流媒体101中介绍过。因此,在我们的第二个问题的上下文中:“在事件时间的哪里计算结果?”,我们现在将简要地回顾一下窗口。

Where 窗口****

正如上次所讨论的,窗口化是沿着时间边界对数据源进行切片的过程。常用的窗口策略包括固定窗口、滑动窗口和会话窗口。

 

为了更好地理解实际中的窗口是什么样子的,让我们来看看整数求和管道,并将其窗口化为固定的、两分钟的窗口。在Dataflow SDK中,这是一个简单的Window.into转换(用蓝色文本突出显示):

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2))))

  .apply(Sum.integersPerKey());

回想一下,Dataflow提供了一个同时适用于批处理和流处理的统一模型,因为从语义上讲,批处理实际上只是流处理的一个子集。因此,让我们首先在一个批处理引擎上执行这个管道;机制更直接,当我们切换到流引擎时,它会给我们一些直接比较的东西。

和前面一样,输入在状态中积累,直到它们被完全消耗,之后才产生输出。然而,在本例中,我们得到的不是一个输出,而是四个输出:四个相关的两分钟事件时间窗口中的每一个都有一个输出。

在这一点上,我们回顾了流101中介绍的两个主要概念:事件时间域和处理时间域之间的关系,以及窗口。如果我们想更进一步,我们需要开始添加本节开头提到的新概念:水印、触发器和积累。流102开始。

1 02****

我们刚刚观察了一个批处理引擎上窗口管道的执行。但理想情况下,我们希望我们的结果具有更低的延迟,并且我们还希望本地处理无界数据源。切换到流引擎是朝着正确方向迈出的一步,但是批处理引擎有一个已知的点,在这个点上,每个窗口的输入都完成了(即,一旦有界输入源中的所有数据都被消耗掉了),我们目前缺乏一种确定无界数据源完整性的实用方法。进入水印。

When 水印****

水印是这个问题的前半部分答案:“当使用处理时间时,结果如何物化?”水印是事件时域输入完整性的时间概念。换句话说,它们是系统测量相对于事件流(有界或无界,尽管它们在无界情况下更有用)中处理的记录的事件时间的进度和完整性的方式。

回想一下Streaming 101中的这张图,这里稍作修改,其中我描述了事件时间和处理时间之间的偏差,对于大多数真实世界的分布式数据处理系统来说,这是一个不断变化的时间函数。

 

图中那条蜿蜒的红线本质上是水印;随着处理时间的进展,它捕获事件时间完整性的进展。从概念上讲,您可以将水印看作一个函数,它在处理时间中取一个点,并在事件时间中返回一个点。(更准确地说,该函数的输入实际上是管道中水印被观察到的点上游的所有内容的当前状态:输入源、缓冲数据、正在积极处理的数据等;但从概念上讲,将其视为从处理时间到事件时间的映射更为简单。事件时间E的那个点,是系统认为所有事件时间小于E的输入都被观测到的点。换句话说,它是一个断言,即事件时间小于E的数据将不会再出现。根据水印的类型(完美的或启发式的),这种断言可能是严格的保证,也可能是有根据的猜测:

n 完美水印:在我们拥有所有输入数据的完美知识的情况下,可以构建一个完美的水印;在这种情况下,不存在所谓的延迟数据;所有数据均提前或准时。

n 启发式水印:对于许多分布式输入源,完全了解输入数据是不切实际的,在这种情况下,下一个最佳选择是提供启发式水印。启发式水印使用关于输入的任何可用信息(分区、分区内的排序、文件的增长率等)来提供尽可能准确的进度估计。在许多情况下,这些水印的预测是非常准确的。即便如此,启发式水印的使用也意味着它有时可能是错误的,这将导致数据延迟。我们将在下面的触发器部分中学习处理延迟数据的方法。

水印是一个迷人而复杂的话题,我在这里或页边空白处要讨论的内容远远超出了我的合理范围,所以对它们的进一步深入研究将不得不等待未来的帖子。现在,为了更好地理解水印所起的作用以及它们的一些缺点,让我们看两个流引擎的示例,它们在执行清单2中的窗口管道时仅使用水印来确定何时实现输出。左边的例子使用了一个完美的水印;右边的使用了一个启发式水印。

在这两种情况下,当水印通过窗口的末端时,窗口被物化。这两种执行的主要区别在于,右边的水印计算中使用的启发式算法没有考虑到9的值,这极大地改变了水印[3]的形状。这些例子突出了水印(以及任何其他完整性概念)的两个缺点,特别是它们可以是:

n 太慢:当任何类型的水印由于已知的未处理数据而正确延迟时(例如,由于网络带宽限制而缓慢增长的输入日志),如果水印的进步是您唯一依赖的刺激结果,则直接转化为输出延迟。这在左图中最为明显,延迟到达的9保留了所有后续窗口的水印,即使这些窗口的输入数据更早完成。这在第二个窗口[12:02,12:04]中尤为明显,从窗口中的第一个值出现到我们看到窗口的任何结果,大约需要7分钟。这个例子中的启发式水印没有那么严重的问题(5分钟后输出),但这并不意味着启发式水印不会有水印滞后的问题;这实际上只是我在这个具体例子中选择从启发式水印中省略的记录的结果。这里的重点如下:虽然水印提供了一个非常有用的完整性概念,但是从延迟的角度来看,依赖于输出的完整性通常并不理想。想象一个包含有价值指标的仪表板,按小时或天显示。你不太可能想要等待整整一个小时或一天才能开始看到当前窗口的结果;这是使用经典批处理系统为此类系统提供动力的痛点之一。相反,看到这些窗口的结果随着时间的推移随着输入的发展而改进并最终变得完整会更好。

n 太快:当启发式水印不正确地提前到时,在水印之前具有事件时间的数据可能会晚一段时间到达,从而创建延迟的数据。这就是右侧示例中发生的情况:在观察到第一个窗口的所有输入数据之前,水印已经超过了该窗口的末尾,导致错误的输出值为5而不是14。这个缺点严格来说是启发式水印的问题;它们的启发式本质意味着它们有时会出错。因此,如果您关心正确性,仅依靠它们来确定何时实现输出是不够的。

在流媒体101中,我对完整性的概念做了一些相当强调的陈述,完整性不足以对无界数据流进行健壮的乱序处理。这两个缺点,水印太慢或太快,是这些争论的基础。如果一个系统仅仅依赖于完整性的概念,那么就不可能同时获得低延迟和正确性。解决这些缺点是触发因素发挥作用的地方。

When 触发点的美妙之处在于,它是美妙的东西!****

触发器是这个问题的后半部分答案:“当使用处理时间时,结果如何物化?”触发器声明在处理时间内窗口的输出应该在什么时候发生(尽管触发器本身可以根据在其他时间域中发生的事情做出这些决定,例如事件时间域中的水印进展)。窗口的每个特定输出被称为窗口的窗格。

用于触发的信号示例包括:

n 水印进度(即事件时间进度),我们已经在图6中看到了它的隐式版本,当水印通过窗口[4]的末尾时,输出被具体化。另一个用例是当窗口的生命周期超过某个有用的范围时触发垃圾收集,稍后我们将看到这个例子。

n 处理时间进度,这对于提供定期的、定期的更新非常有用,因为处理时间(与事件时间不同)总是或多或少地均匀且无延迟地进行。

n 元素计数,这对于在窗口中观察到有限数量的元素后触发非常有用。

n 标点符号或其他数据相关的触发器,其中某些记录或记录的特征(例如,EOF元素或刷新事件)指示应该生成输出。

除了基于具体信号触发的简单触发器外,还有允许创建更复杂触发逻辑的复合触发器。复合触发器的例子包括:

n 重复,它与处理时间触发器结合起来特别有用,可以提供定期的、定期的更新。

n 连词(逻辑与),只有当所有子触发器都被触发时才会触发(例如,在水印通过窗口的末尾之后,我们观察到一个终止的标点记录)。

n 析取(逻辑或),在任何子触发器触发后触发(例如,在水印通过窗口的末尾或我们观察到终止的标点记录之后)。

n 序列,它以预定义的顺序触发一系列子触发器。

为了使触发器的概念更具体一些(并为我们提供一些构建的基础),让我们将图6中使用的隐式默认触发器添加到清单2中的代码中,从而显式地使用它:

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(AtWatermark()))

  .apply(Sum.integersPerKey());

考虑到这一点,以及对触发所提供的基本理解,我们可以考虑解决水印太慢或太快的问题。在这两种情况下,我们本质上都想为给定的窗口提供某种常规的、物化的更新,无论是在水印通过窗口结束之前还是之后(除了在水印通过窗口结束的阈值处我们将接收到的更新)。所以,我们需要某种重复触发器。那么问题就变成了:我们在重复什么?

在太慢的情况下(例如,提供早期的推测性结果),我们可能应该假设对于任何给定的窗口可能有稳定数量的传入数据,因为我们知道(根据处于窗口的早期阶段的定义)我们观察到的窗口输入到目前为止是不完整的。因此,在处理时间增加时定期触发(例如,每分钟一次)可能是明智的,因为触发的次数不会依赖于窗口实际观察到的数据量;最坏的情况下,我们只会得到稳定的周期性扳机射击。

在太快的情况下(例如,由于启发式水印而提供更新的结果以响应延迟的数据),让我们假设我们的水印基于相对准确的启发式(通常是一个相当安全的假设)。在这种情况下,我们不希望经常看到最新的数据,但当我们这样做时,最好能快速修改我们的结果。在观察到元素计数为1之后触发将为我们提供对结果的快速更新(即,在我们看到延迟数据时立即进行更新),但由于延迟数据的预期频率较低,不太可能使系统不堪重负。

请注意,这些只是示例:如果适合手头的用例,我们可以自由地选择不同的触发器(或者选择根本不触发其中一个或两个触发器)。

最后,我们需要协调这些不同触发器的时间:早、准时和晚。我们可以用一个Sequence触发器和一个特殊的OrFinally触发器来完成这个任务,OrFinally触发器安装一个子触发器,当子触发器触发时终止父触发器。

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(Sequence(

                 Repeat(AtPeriod(Duration.standardMinutes(1)))

                   .OrFinally(AtWatermark()),

                 Repeat(AtCount(1))))

  .apply(Sum.integersPerKey());

然而,这太啰嗦了。考虑到重复早触发、准时触发、重复晚触发的模式是如此普遍,我们在Dataflow中提供了一个自定义(但语义等同)API,以使指定此类触发器更简单、更清晰:

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(

                 AtWatermark()

                   .withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))

                   .withLateFirings(AtCount(1))))

  .apply(Sum.integersPerKey());

在流引擎上执行清单4或清单5(像以前一样使用完美水印和启发式水印),然后产生如下所示的结果:

这个版本比图6有两个明显的改进:

n 对于第二个窗口中的“水印太慢”的情况,[12:02,12:04]:我们现在每分钟提供一次定期的早期更新。在完美水印的情况下,差异最为明显,从第一次输出的时间从近7分钟缩短到3.5分钟;但在启发式的情况下也明显得到了改进。随着时间的推移,两个版本现在都提供了稳定的改进(窗格的值分别为7、14和22),在完成输入和实现窗口的最终输出窗格之间的延迟相对最小。

n 对于第一个窗口[12:00,12:02]中的“启发式水印太快”情况:当值9出现晚了,我们立即将其合并到值为14的新修正窗格中。

这些新触发器的一个有趣的副作用是,它们有效地标准化了完美水印和启发式水印版本之间的输出模式。尽管图6中的两个版本截然不同,但这里的两个版本看起来非常相似。

此时剩下的最大区别是窗口生存期界限。在完美的水印情况下,我们知道一旦水印通过了窗口的结束,我们就不会再看到窗口的任何数据,因此我们可以在那个时候删除窗口的所有状态。在启发式水印的情况下,我们仍然需要保持一个窗口的状态一段时间,以解释延迟的数据。但是到目前为止,我们的系统还没有任何好的方法来知道每个窗口需要保持多长时间的状态。这就是允许迟到的原因。

When:允许的延迟(即垃圾收集)****

在讨论我们的最后一个问题(“结果的细化是如何联系在一起的?”)之前,我想谈谈长寿命的乱序流处理系统中的一个实际需要:垃圾收集。在图7中的启发式水印示例中,每个窗口的持久状态在示例的整个生命周期中都保持不变;这是必要的,以便我们在迟来的数据到达时适当地处理它们。但是,虽然能够保持所有的持久状态直到时间结束是很好的,但实际上,当处理无界数据源时,为给定窗口无限期地保持状态(包括元数据)通常是不实际的;我们最终会耗尽磁盘空间。

因此,任何真实世界的乱序处理系统都需要提供某种方法来绑定它正在处理的窗口的生命周期。一种简洁明了的方法是定义系统内允许的延迟范围。对任何给定的记录(相对于水印)可能延迟的时间设置一个界限,以便系统费心处理它;任何在这个视界之后到达的数据都会被丢弃。一旦您确定了单个数据的延迟时间,您还可以精确地确定窗口的状态必须保持多长时间:直到水印超过窗口末端的延迟视界[5]。但除此之外,你也给了系统自由,一旦观察到任何数据,就可以立即删除任何晚于视界的数据,这意味着系统不会浪费资源来处理没有人关心的数据。

由于允许的延迟和水印之间的相互作用有点微妙,因此值得看一个示例。让我们从清单5/图7中获取启发式水印管道,并添加1分钟的延迟视界(注意,选择这个特定的视界是严格的,因为它很适合图表;对于现实世界的用例,更大的视界可能更实用):

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(

                 AtWatermark()

                   .withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))

                   .withLateFirings(AtCount(1)))

               .withAllowedLateness(Duration.standardMinutes(1)))

  .apply(Sum.integersPerKey());

这个管道的执行将类似于下面的图8,其中我添加了以下特性来突出显示允许延迟的效果:

n 表示处理时间内当前位置的粗白线现在用刻度标记,表示所有活动窗口的延迟地平线(在事件时间内)。

n 一旦水印通过某个窗口的延迟视界,该窗口将被关闭,这意味着该窗口的所有状态将被丢弃。我留下了一个虚点矩形,表示窗口关闭时覆盖的时间范围(在两个域中),向右延伸的小尾巴表示窗口的延迟地平线(与水印形成对比)。

n 仅对于这个图,我为第一个窗口添加了一个附加的后期数据,其值为6。6是延迟的,但仍在允许的延迟范围内,因此它被合并到值为11的更新结果中。然而,9超出了延迟视界,所以它就被丢弃了。

关于迟到期限的最后两点附注:

n 需要明确的是,如果您使用的数据恰好来自具有完美水印的数据源,那么就不需要处理延迟数据,允许的延迟范围为零秒将是最佳选择。这就是我们在图7的完美水印部分所看到的。

n 即使在使用启发式水印的情况下,需要指定延迟范围的规则也有一个值得注意的例外,比如计算可跟踪的有限数量的密钥在所有时间内的全局聚合(例如,计算所有时间内对您的站点的访问总数,按Web浏览器系列分组)。在这种情况下,系统中活动窗口的数量受到所使用的有限键空间的限制。只要键的数量保持在可管理的低水平,就没有必要担心通过允许的延迟来限制窗口的生命周期。

说到实际,让我们进入第四个也是最后一个问题。

 

How 积累****

当使用触发器为单个窗口生成多个窗格时,我们发现自己面临着最后一个问题:“结果的细化是如何关联的?”在我们目前看到的示例中,每个后续窗格都建立在它前面的窗格之上。然而,实际上有三种不同的积累模式[6]:

n Discarding:每次具体化窗格时,任何存储状态都将被丢弃。这意味着每个后续窗格都独立于之前的窗格。当下游消费者本身正在进行某种积累时,丢弃模式是有用的。,当将整数发送到期望接收增量的系统时,将这些增量加在一起以产生最终计数。

n 累积:如图7所示,每次具体化窗格时,都会保留任何存储的状态,并且将来的输入会累积到现有状态中。这意味着每个后续窗格都建立在前一个窗格之上。当后面的结果可以简单地覆盖前面的结果时,例如将输出存储在BigTable或HBase这样的键/值存储中时,累积模式非常有用。

n 累积和收缩:类似于累积模式,但是当产生一个新窗格时,也会对前一个窗格产生独立的收缩。撤回(结合新的累积结果)本质上是一种明确的方式,“我之前告诉过你结果是X,但我错了。”把我上次告诉你的X去掉,用y来代替它。”在两种情况下,撤回特别有用:

n 当下游的消费者按不同的维度重新分组数据时,新值的键值很可能与前一个值的键值不同,从而最终出现在不同的组中。在这种情况下,新值不能覆盖旧值;相反,您需要回缩来从旧组中删除旧值,同时将新值合并到新组中。

n 当动态窗口(例如,会话,我们将在下面更仔细地研究)正在使用时,由于窗口合并,新值可能会替换多个先前的窗口。在这种情况下,很难仅从新窗口确定哪些旧窗口正在被替换。对旧窗户进行明确的收缩使任务变得简单。

n 当并排看时,每个组的不同语义会更清晰一些。考虑图7中第二个窗口的三个窗格(事件时间范围[12:02,12:04])。下表显示了在三种支持的累积模式下每个窗格的值(累积模式是图7中使用的特定模式):

 DiscardingAccumulatingAccumulating & Retracting
Pane 1: [7]777
Pane 2: [3, 4]71414, -7
Pane 3: [8]82222, -14
Last Value Observed82222
Total Sum225122

n 丢弃:每个窗格只包含在该特定窗格期间到达的值。因此,观察到的最终值并不能完全捕获总数。然而,如果你把所有独立的窗格加起来,你会得到一个正确的答案22。这就是为什么当下游消费者本身在物化窗格上执行某种聚合时,丢弃模式是有用的。

n 累积:如图7所示,每个窗格合并了在该特定窗格期间到达的值,以及来自前一个窗格的所有值。因此,观察到的最终值正确地捕获了22的总和。但是,如果您要将各个窗格本身相加,那么您可能会将窗格2和窗格1的输入分别计算两次和三次,从而得到错误的总数51。这就是为什么当您可以简单地用新值覆盖以前的值时,累积模式最有用的原因:新值已经包含了到目前为止看到的所有数据。

n 累积和收缩:每个窗格既包括一个新的累积模式值,也包括一个前窗格值的收缩。因此,观察到的最后(非撤稿)值以及所有物化窗格(包括撤稿)的总和都为您提供了正确的答案22。这就是撤回的力量如此强大的原因。

要查看丢弃模式的实际情况,我们将对清单5进行以下更改:

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(

                 AtWatermark()

                   .withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))

                   .withLateFirings(AtCount(1)))

               .discardingFiredPanes())

  .apply(Sum.integersPerKey());

在带有启发式水印的流引擎上再次运行将产生如下输出:

虽然输出的整体形状类似于图7中的累积模式版本,但请注意,在这个丢弃版本中没有任何窗格重叠。因此,每个输出都独立于其他输出。

如果我们想看看实际的撤回,变化将是类似的(注意,撤回目前仍在谷歌Cloud Dataflow的开发中,所以这个API中的命名有点投机,尽管不太可能与我们最终发布的有很大不同):

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))

               .triggering(

                 AtWatermark()

                   .withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))

                   .withLateFirings(AtCount(1)))

               .accumulatingAndRetractingFiredPanes())

  .apply(Sum.integersPerKey());

在流引擎上运行,将产生如下输出:

由于每个窗口的窗格都是重叠的,因此要清楚地看到缩回有点棘手。缩回用红色表示,与重叠的蓝色窗格相结合,产生略带紫色的颜色。我还稍微水平移动了给定窗格中的两个输出的值(并用逗号分隔它们),以便更容易区分它们。

 

对比图9、图7和图10的最后一帧(仅为启发式),我们可以很好地看到这三种模式的视觉对比:

 

可以想象,按顺序呈现的模式(丢弃、积累、积累和收回)在存储和计算成本方面依次更高。为此,选择累积模式为在正确性、延迟和成本轴上进行权衡提供了另一个维度。

插曲****

至此,我们已经触及了所有四个问题:

l 计算什么结果?通过变换来回答。

l 在事件时间的什么地方计算结果?通过窗口回答。

l 在处理时间内,结果何时实现?通过水印和触发器回答。

l 结果的改进是如何关联的?通过积累模式回答。

然而,我们实际上只研究了一种类型的窗口:事件时间的固定窗口。正如你从流媒体101中所知道的,窗口有许多维度,在我们称之为一天之前,我想访问其中两个:处理时间的固定窗口和事件时间的会话窗口。

When/Where:处理时间窗口****

处理时间窗口之所以重要,有两个原因:

n 对于某些用例,例如使用监视(例如,Web服务流量QPS),您希望在观察到传入数据流时对其进行分析,处理时间窗口绝对是合适的方法。

n 对于事件发生的时间很重要的用例(例如,分析用户行为趋势,计费,评分等),处理时间窗口绝对是错误的方法,能够识别这些用例至关重要。

因此,有必要深入了解处理时间窗口和事件时间窗口之间的区别,特别是考虑到当今大多数流系统中普遍存在的处理时间窗口。

当在一个模型中工作时,比如在这篇文章中,窗口作为一级概念是严格基于事件时间的,有两种方法可以用来实现处理时间窗口:

 

l 触发器:忽略事件时间(即,使用跨越所有事件时间的全局窗口),并使用触发器在处理时间轴上提供该窗口的快照。

l 进入时间:将进入时间指定为数据到达时的事件时间,并从此使用正常的事件时间窗口。这就是目前Spark Streaming所做的事情。

请注意,这两个方法或多或少是等效的,尽管它们在多阶段管道的情况下略有不同:在触发器版本中,每个阶段独立地分割处理时间“窗口”,因此,例如,一个阶段的窗口X中的数据可能在下一阶段的窗口X-1或X+1中结束;在入口时间版本中,一旦数据被合并到窗口X中,由于通过水印(在Dataflow的情况下)、微批边界(在Spark Streaming的情况下)或引擎级别涉及的任何其他协调因素来同步阶段之间的进度,它将在管道的持续时间内保持在窗口X中。

正如我已经注意到的那样,处理时间窗口的最大缺点是,当输入的观察顺序发生变化时,窗口的内容也会发生变化。为了以更具体的方式阐明这一点,我们将看看这三个用例:

l 事件时间窗口

l 通过触发器的处理时间窗口

l 通过进入时间进行处理时间窗口

我们将每个变量应用于两个不同的输入集(因此,总共有六个变量)。两个输入集将用于完全相同的事件(即,相同的值,发生在相同的事件时间),但具有不同的观察顺序。第一组是我们一直看到的观测顺序,白色;第二个将在处理时间轴上移动所有值,如下面的图12所示,用紫色表示。你可以简单地想象一下,紫色的例子是另一种现实可能发生的方式,如果风是从东方而不是西方吹来的(也就是说,底层复杂的分布式系统以一种稍微不同的顺序发挥作用)。

事件时间窗口****

为了建立基线,我们首先将事件时间内的固定窗口与这两个观察顺序上的启发式水印进行比较。我们将重用清单5/图7中的早期/晚期代码来获得下面的结果。左边就是我们之前看到的;右边是第二个观测阶的结果。这里需要注意的重要事项是:尽管输出的整体形状不同(由于处理时间的观察顺序不同),但四个窗口的最终结果保持相同:14,22,3和12。

通过触发器的处理时间窗口****

现在让我们将其与上面描述的两种处理时间方法进行比较。首先,我们将尝试使用触发器方法。以这种方式使处理时间“窗口”工作有三个方面:

l 窗口:我们使用全局事件时间窗口,因为我们实际上是用事件时间窗格模拟处理时间窗口。

l 触发:我们基于所需的处理时间窗口的大小,在处理时间域中周期性地触发。

l 累积:我们使用丢弃模式来保持窗格彼此独立,从而让每个窗格都充当独立的处理时间“窗口”。

相应的代码类似于清单9;请注意,全局窗口是默认的,因此没有特定的窗口策略覆盖:

PCollection<KV<String, Integer>> scores = input

  .apply(Window.triggering(

                  Repeatedly(AtPeriod(Duration.standardMinutes(2))))

               .discardingFiredPanes())

  .apply(Sum.integersPerKey());

当在流运行器上对输入数据的两种不同顺序执行时,结果如图14所示。关于这个数字有趣的注意事项:

由于我们通过事件时间窗格模拟处理时间窗口,因此“窗口”是在处理时间轴上描绘的,这意味着它们的宽度是在Y轴上而不是X轴上测量的。

由于处理时间窗口对遇到的输入数据的顺序很敏感,因此每个“窗口”的结果对于两个观察顺序中的每个都是不同的,即使事件本身在技术上在每个版本中同时发生。左边是12 21 18,右边是7 36 4。

通过进入时间进行处理时间窗口****

最后,让我们看一下通过将输入数据的事件时间映射为它们的进入时间来实现的处理时间窗口。在代码方面,这里有四个方面值得一提:

 

l 时间转移:当元素到达时,它们的事件时间需要用进入时间覆盖。请注意,我们目前在Dataflow中没有标准的API,尽管将来可能会有(因此在下面的代码中使用伪代码I/O源上的虚构方法来表示它)。对于谷歌Cloud Pub/Sub,您只需要在发布消息时将消息的timestampLabel字段保留为空;对于其他源代码,您需要查阅特定于源代码的文档。

l 窗口:返回到使用标准的固定事件时间窗口。

l 触发:由于进入时间提供了计算完美水印的能力,我们可以使用默认触发器,在这种情况下,当水印通过窗口结束时,它会隐式地触发一次。

l 积累模式:因为我们每个窗口只有一个输出,积累模式是无关紧要的。

因此,实际的代码可能看起来像这样:

PCollection raw = IO.read().withIngressTimeAsTimestamp();

PCollection<KV<String, Integer>> input = raw.apply(ParDo.of(new ParseFn());

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(FixedWindows.of(Duration.standardMinutes(2))))

  .apply(Sum.integersPerKey());

流引擎上的执行如图15所示。当数据到达时,它们的事件时间被更新以匹配它们的进入时间(即到达时的处理时间),从而导致向右水平移动到理想的水印线上。图中有趣的注意事项:

与另一个处理时间窗口示例一样,当输入的顺序发生变化时,即使输入的值和事件时间保持不变,我们也会得到不同的结果。

与另一个示例不同的是,窗口再次在事件时域中勾画(因此沿着X轴)。尽管如此,它们并不是真正的事件时间窗口;我们简单地将处理时间映射到事件时间域中,擦除每个输入的原始记录,并用代表管道首次观察到数据的时间的新记录替换它。

尽管如此,由于有了水印,触发器触发仍然与前面的处理时间示例完全相同。此外,生成的输出值与该示例相同,如预测的那样:左侧为12、21、18,右侧为7、36、4。

因为当使用进入时间时,完美的水印是可能的,所以实际的水印匹配理想的水印,以1的斜率向上和向右上升。

图15。在相同输入的两个不同的处理时间顺序上,通过使用入口时间进行处理时间窗口。图片来源:Tyler Akidau

虽然看到实现处理时间窗口的不同方式很有趣,但这里的重要收获是我从第一篇文章开始就一直在强调的:事件时间窗口是顺序无关的,至少在限制范围内是这样(在输入完成之前,实际的窗格可能会有所不同);处理时间窗口不是。如果您关心事件实际发生的时间,则必须使用事件时间窗口,否则您的结果将毫无意义。我现在要离开我的临时演说台了。

Where 会话窗口****

我们已经非常非常接近完成这些例子了。如果你读到这里,说明你是一个非常有耐心的读者。好消息是,你的耐心没有白费。现在我们来看看我最喜欢的特性之一:动态的、数据驱动的窗口,称为会话。拿好你的帽子和眼镜。

会话是一种特殊类型的窗口,它捕获数据中的一段活动,该活动被一段不活动的间隙终止。它们在数据分析中特别有用,因为它们可以提供特定用户在特定时间内从事某些活动的活动视图。这允许在会话中活动的相关性,根据会话的长度来推断用户的粘性水平等。

从窗口的角度来看,会话在两个方面特别有趣:

l 它们是数据驱动窗口的一个例子:窗口的位置和大小是输入数据本身的直接结果,而不是基于时间内的一些预定义模式,就像固定窗口和滑动窗口一样。

l 它们也是一个非对齐窗口的例子,也就是说,一个窗口不统一地应用于数据,而是只应用于特定的数据子集(例如,每个用户)。这与固定窗口和滑动窗口等对齐窗口形成对比,后者通常在整个数据中均匀应用。

对于某些用例,可以提前用通用标识符标记单个会话中的数据(例如,视频播放器发出带有服务质量信息的心跳ping;对于任何给定的观看,所有的ping都可以提前标记为一个会话ID)。在这种情况下,会话更容易构造,因为它基本上只是一种按键分组的形式。

然而,在更一般的情况下(即,实际会话本身事先不知道的情况),会话必须仅从时间内的数据位置构造。在处理无序数据时,这变得特别棘手。

他们在提供一般会话支持方面的关键见解是,根据定义,一个完整的会话窗口是由一组较小的重叠窗口组成的,每个窗口包含一条记录,序列中的每个记录与下一个记录之间的间隔不大于预定义的超时。因此,即使我们观察到会话中的数据是无序的,我们也可以简单地通过合并单个数据到达时的任何重叠窗口来构建最终会话。

 

让我们看一个例子,从清单8中获取启用了收回的早/晚代码,并更新窗口以构建会话:

PCollection<KV<String, Integer>> scores = input

  .apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))

               .triggering(

                 AtWatermark()

                   .withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))

                   .withLateFirings(AtCount(1)))

               .accumulatingAndRetractingFiredPanes())

  .apply(Sum.integersPerKey());

在流引擎上执行,你会得到如下图17所示的结果:

这里有很多内容,所以我将带你浏览其中的一些:

l 当遇到第一个值为5的记录时,它被放置到一个原始会话窗口中,该窗口从该记录的事件时间开始,并跨越会话间隔持续时间的宽度。,即该基准点发生后一分钟。我们将来遇到的任何与此窗口重叠的窗口都应该是同一会话的一部分,并将合并到该会话中。

l 第二个到达的记录是7,它同样被放置在它自己的原始会话窗口中,因为它不与5的窗口重叠。

l 同时,水印已经通过了第一个窗口的结束,所以值5在12:06之前被物化为一个准时的结果。此后不久,当处理时间达到12:06时,第二个窗口也具体化为值为7的推测结果。

l 接下来,我们观察一系列记录,3,4和3,这些原始会话都是重叠的。结果,它们全部合并在一起,并且当12:07的早期触发器触发时,发出一个值为10的单个窗口。

l 当8在不久之后到达时,它与值为7的原始会话和值为10的会话重叠。因此,所有三个合并在一起,形成一个值为25的新组合会话。当水印通过此会话的结束时,它将值为25的新会话以及先前发出但后来合并到其中的两个窗口(7和10)的收回都具体化。

l 当9到达较晚时,将值为5的原始会话和值为25的会话连接到一个值为39的较大会话中,也会发生类似的变化。39和5和25窗口的缩回都是由后期数据触发器立即发出的。

这是一些非常强大的东西。真正令人敬畏的是,在一个将流处理的维度分解为不同的、可组合的部分的模型中描述这样的东西是多么容易。最后,您可以更多地关注手头有趣的业务逻辑,而不是将数据塑造成某种可用形式的细节。

如果你不相信我,可以看看这篇描述如何在Spark Streaming上手动构建会话的博客文章(注意,这并不是指他们;Spark的工作人员在其他方面做得很好,以至于有人愿意费心去记录如何在他们的基础上构建特定的各种会话支持;我不能说大多数其他系统也是如此)。这是相当复杂的,他们甚至没有进行适当的活动时间会议,或提供推测或延迟解雇,也没有撤回。

这是我们所知道的博客的结尾,我感觉很好****

就是这样!我已经讲完了例子。掌声,掌声!现在,您已经沉浸在健壮流处理的基础中,并准备好进入这个世界并做一些令人惊奇的事情。但在你离开之前,我想快速回顾一下我们已经学过的内容,以免你在匆忙中忘记。首先,我们谈到的主要概念是:

l 事件时间与处理时间:事件发生的时间与数据处理系统观察到事件的时间之间最重要的区别。

l 窗口化:一种常用的管理无界数据的方法,它沿着时间边界(在处理时间或事件时间,尽管我们将数据流模型中窗口化的定义缩小为仅指在事件时间内)对数据进行切片。

l 水印:事件时间进展的强大概念,它提供了一种在无界数据上运行的无序处理系统中对完整性进行推理的方法。

l 触发器:一种声明性机制,用于精确地指定何时输出的物化对您的特定用例有意义。

积累:在单个窗口的情况下,当它在发展过程中多次具体化时,结果的细化之间的关系。

其次,我们用来构建我们的探索的四个问题(我保证在此之后我不会让你再读下去):

l 计算什么结果?=转换

l 在事件时间的什么地方计算结果?=窗口

l 在处理过程中,什么时候将结果具体化?=水印+触发器

l 结果的改进是如何关联的?=积累

第三点,也是最后一点,为了充分说明这种流处理模型所提供的灵活性(因为最终,这才是真正的目的:平衡正确性、延迟和成本等竞争关系),回顾一下我们能够在相同数据集上实现的主要输出变化,只需少量的代码更改:

 

谢谢你的耐心和兴趣。下次见!

 

附言****

额外的资源****

如果你想了解更多关于Dataflow的知识,我们有一大堆优秀的文档在等着你。如上所述,我们也有一个非常好的代码演练,涵盖了四个示例管道分析移动游戏场景中的数据,完整的代码可以在GitHub上获得;如果您对真正的数据流代码感兴趣,这就是您的门票。

如果你更喜欢看视频,Frances Perry在@Scale 2015会议上做了一个关于数据流模型的精彩演讲,除了她说我们把撤回称为“backsies”的部分;这部分是正确的<恼怒-舌头伸出-微笑/>。

如果出于某种原因,你渴望听到我的学术演讲,我写了一篇关于这个主题的论文,VLDB的优秀人士去年很好心地发表了这篇论文。虽然这些博客文章可以说更全面(没有人为的页面限制!),而且明显更漂亮(颜色!)动画!),在那篇论文中有一些有趣的细节是关于我们在b谷歌的经验中激发用例的,你在其他任何地方都找不到。此外,它非常清晰和简洁地激发了对这些语义的需求,同时也为探索一些相关的学术文献提供了一个很好的起点。

偏离现实****

为了完整起见,我想在这篇文章中提供的示例中指出一些与现实(我指的是在发表时当前谷歌云数据流实现)的偏差:

在清单4、5和6中,没有指定累积模式,但是累积模式是我们在执行时得到的。实际上,目前Dataflow中没有默认的积累模式:您必须指定丢弃或积累模式。一旦该功能发布,我们将保留累积和收缩模式的默认值。

目前还不支持撤回。我们正在努力。

默认允许的延迟实际上是0。因此,对于所有未指定允许延迟的示例,我们将永远不会看到任何延迟窗格,因为每个窗口的状态将在水印通过后立即删除。

默认触发器实际上是一个重复的水印触发器,其默认允许延迟为0。在清单3中,为了简单起见,我(同样地)声明它是一个水印触发器。

致谢****

最后,但并非最不重要的是:很多人都很棒,我想在这里感谢他们中的一部分人,因为他们帮助我创建了这些庞然大物般的博客文章。

这些帖子中的内容提炼了无数来自b谷歌、行业和学术界的非常聪明的人的作品。我欠他们所有人一个真诚的感谢,我感到遗憾的是,即使我想把他们都列出来,也不可能。

在谷歌中,大部分功劳归功于Dataflow, Flume, MapReduce, MillWheel和相关团队,他们多年来帮助将这些想法变为现实。除了我最初设计高水平流媒体模型的合作伙伴罗伯特·布拉德肖和丹尼尔·米尔斯,以及本·钱伯斯,他的忍者工程技能和洞察力实现了这个模型中更棘手和更微妙的部分,我要特别感谢保罗·诺德斯特龙和以前的MillWheel团队,他们设想并建立了这样一个全面的,强大的,以及一组可扩展的低级原语,在这些原语的基础上,我们后来能够构建本文中描述的高级模型,现在具体化在Dataflow SDK中。如果没有他们的远见和技能,我想大规模流处理的世界将会变得非常不同。

最后,我要感谢那些不断投入时间、想法和支持这些帖子的人们的不懈努力,特别是:Frances Perry、Rafael J. Fernández-Moctezuma、Grzegorz Czajkowski和William Vambenepe。当然还有我在O 'Reilly的勇敢的编辑Marie Beaugureau,主要是因为她有见地的评论,帮助我把这些帖子变成值得一读的东西(祈祷),但也因为她对我不断尝试颠覆既定编辑标准的耐心。