第2章 流处理基础
到目前为止,您已经了解了流处理如何解决传统批处理的限制,以及它如何支持新的应用程序和体系结构。您已经熟悉了开源流处理技术的演变,并对Flink流处理程序有了一个简单的了解。在这一章中,你将进入流处理世界,学习流处理基础知识,为本书的其余部分的学习打下基础。
这一章仍然与Flink无关。它的目标是介绍流处理的基本概念并讨论流处理框架的需求。我们希望通过阅读这一章,您能够更好地理解流应用程序需求,并能够评估现代流处理系统的特性。
数据流编程介绍
在深入研究流处理的基础知识之前,我们必须首先介绍数据流编程的必要背景知识,并建立贯穿本书的术语。
数据流图
顾名思义,数据流程序描述操作之间的数据流向。数据流程序通常表示为有向图,其中节点称为操作符,表示计算,边缘表示数据依赖关系。操作符是数据流应用程序的基本功能单元。它们从输入中使用数据,对其执行计算,并将数据生成输出以进行进一步处理。没有输入端口的操作符称为数据源,没有输出端口的操作符称为数据接收器。数据流图必须至少有一个数据源和一个数据接收器。图2.1显示了一个数据流程序,它从tweet的输入流中进行提取和计数操作。
像图2.1这样的数据流图被称为逻辑图,因为它们传达了计算逻辑的高级视图。为了执行一个数据流程序,它的逻辑图被转换成一个物理数据流图,其中包括关于如何执行计算的细节。例如,如果我们使用分布式处理引擎,每个操作员可能在不同的物理机器上运行多个并行任务。图2.2显示了图2.1的逻辑图的物理数据流图。在逻辑数据流图中,节点表示运算符,而在物理数据流中,节点表示任务。“提取标签”和“Count”操作符有两个并行操作符任务,每个任务都对输入数据的子集执行计算。
数据并行和任务并行
您可以以不同的方式利用数据流图中的并行性。首先,可以对输入数据进行分区,并在数据子集上并行执行相同操作的任务。这种类型的并行称为数据并行。数据并行性非常有用,因为它允许处理大量数据并将计算负载分散到多个计算节点。其次,可以让来自不同操作符的任务并行地对相同或不同的数据执行计算。这种类型的并行称为任务并行。使用任务并行可以更好地利用集群的计算资源。
数据交换策略
数据交换策略定义如何将数据项分配给物理数据流图中的任务。数据交换策略可以由执行引擎根据操作符的语义自动选择,也可以由数据流程序员显式地选择。在这里,我们简要回顾一些常见的数据交换策略,如图2.3所示。
-
转发(forward)策略将数据从任务发送到接收任务。如果两个任务位于同一物理机器上(通常由任务调度程序确保),则此交换策略将避免网络通信。
-
广播(broadcast)策略将每个数据项发送给操作符的所有并行任务。由于此策略复制数据并涉及网络通信,因此开销相当大。
-
基于键(key-based)的策略根据键属性划分数据,并保证具有相同键的数据项将由相同的任务处理。在图2.2中,“Extract hashtags”操作符的输出按键(hashtag)划分,这样count操作符任务就可以正确地计算每个hashtag的出现次数。
-
随机策略(random)将数据项均匀分配给操作符任务,以便在计算任务之间均匀分配负载。
forward策略和随机策略也可以看作是key-based策略的变体,其中第一个保存上游元组的key,而后者执行随机的key重新分配。
并行处理无限流
既然您已经熟悉了数据流编程的基础知识,现在就来看看这些概念如何应用于并行处理数据流。但首先,我们定义术语 数据流:
数据流是一个潜在的无限事件序列。
数据流中的事件可以表示为监视数据、传感器测量、信用卡交易、气象站观测、在线用户交互、web搜索等。在本节中,您将学习使用数据流编程范式并行处理无限流的概念。
延迟和吞吐量
在前一章中,您看到了流式应用程序与传统的批处理程序有许多不同的操作需求。在评估性能时,需求也有所不同。对于批处理应用程序,我们通常关心作业的总执行时间,或者处理引擎读取输入、执行计算和写回结果需要多长时间。因为流式应用程序是连续运行的,输入可能是无界的,在数据流处理中没有总执行时间的概念。相反,流应用程序必须尽可能快地为输入的数据提供结果,同时能够处理大量的事件发生率。我们用延迟和吞吐量来表示这些性能需求。
延迟
延迟指示处理事件所需的时间。本质上,它是接收事件与查看输出中处理此事件的结果之间的时间间隔。要直观地理解延迟,请考虑您每天对您最喜欢的咖啡店的访问。当你走进咖啡店时,里面可能已经有其他顾客了。因此,你排队等候,轮到你时你就下命令。收银员会收到你的付款,然后把你点的东西交给为你准备饮料的咖啡师。咖啡煮好后,咖啡师会叫你的名字,你可以从长凳上拿起咖啡。你的服务延迟是你在咖啡店里度过的时间,从你进入的那一刻到你喝到第一口咖啡。
在数据流中,延迟以时间(例如毫秒)为单位进行度量。根据应用程序的不同,您可能会关心平均延迟、最大延迟或百分比延迟。例如,平均延迟值为10ms意味着事件平均在10ms内处理。相反,95%延迟值为10ms意味着95%的事件在10ms内处理。平均值隐藏了处理延迟的真实分布,可能会使检测问题变得困难。如果咖啡师在你准备卡布奇诺之前就把牛奶用完了,你就得等到他们从供应室拿来一些。虽然你可能会对这种延迟感到恼火,但大多数其他客户仍然会很高兴。
确保低延迟对于许多流应用程序非常重要,比如欺诈检测、发出警报、网络监控和提供具有严格服务水平协议(sla)的服务。低延迟是流处理的一个关键特性,它支持我们所说的实时应用程序。现代的流处理器,比如Apache Flink,可以提供低至几毫秒的延迟。相比之下,传统的批处理延迟通常从几分钟到几个小时不等。在批处理中,您首先需要成批收集事件,然后才能处理它们。因此,延迟受到每个批中的最后一个事件到达时间的限制,并且自然地依赖于批大小。真正的流处理不会引入这种人为的延迟,因此可以实现非常低的延迟。在真正的流模型中,事件可以在它们到达系统时立即处理,而延迟则更紧密地反映了必须对每个事件执行的实际工作。
吞吐量
吞吐量是系统处理能力的度量,即处理速率。也就是说,吞吐量告诉我们系统在每个时间单元中可以处理多少事件。再看一下咖啡店的例子,如果咖啡店从早上7点开到晚上7点,一天服务600个客户,那么它的平均吞吐量将是每小时50个客户。虽然您希望延迟尽可能低,但通常希望吞吐量尽可能高。
吞吐量是用每个时间单位的事件或操作来度量的。重要的是要注意处理的速度取决于到达的速度;低吞吐量并不一定意味着性能差。在流系统中,您通常希望确保您的系统能够处理最大的预期事件率。也就是说,您主要关心的是确定峰值吞吐量,即系统处于最大负载时的性能限制。为了更好地理解峰值吞吐量的概念,让我们考虑系统资源完全未被使用的情况。
当第一个事件出现时,它将立即被处理,延迟尽可能小。如果你是咖啡店早上开门后第一个出现的顾客,你会立刻得到服务。理想情况下,您希望延迟保持不变,并且独立于传入事件的速率。但是,一旦达到系统资源被充分利用的传入事件率,我们就必须开始缓冲事件。在咖啡店的例子中,您可能会在午餐后看到这种情况。许多人在同一时间出现,你不得不排队下订单。此时系统已经达到了最大吞吐量,进一步增加事件率只会导致更糟糕的延迟。如果系统继续以高于其处理能力的速率接收数据,那么缓冲区可能不可用,数据可能会丢失。这种情况通常被称为反压,有不同的应对策略。在第三章中,我们将详细讨论Flink的反压机制。
延迟和吞吐量
此时,很明显,延迟和吞吐量不是独立的度量。如果事件在数据处理流程中需要很长时间才能传递,我们就无法轻松确保高吞吐量。类似地,如果系统的容量很小,事件将被缓冲,在处理它们之前必须等待。
让我们回顾一下咖啡店的例子,以阐明延迟和吞吐量是如何相互影响的。首先,应该清楚的是,在没有负载的情况下存在最短延迟。也就是说,如果你是咖啡店里唯一的顾客,你会得到最快的服务。然而,在繁忙时间,客户将不得不排队等待,延迟将增加。
影响延迟和吞吐量的另一个因素是处理事件所需的时间,或者每个客户在咖啡店接受服务所需的时间。想象一下,在圣诞节期间,咖啡师必须在他们提供的每杯咖啡上画一个圣诞老人。这样,准备一杯饮料的时间就会增加,导致每个人在咖啡店花费更多的时间,从而降低整体的吞吐量。
那么,您是否可以同时获得低延迟和高吞吐量,或者这是一种没有希望的尝试?降低延迟的一个方法是雇佣一个更熟练的咖啡师,也就是一个能更快准备咖啡的人。在高负载时,此更改还将增加吞吐量,因为在相同的时间内将为更多的客户提供服务。
另一种达到同样效果的方法是聘请另一名咖啡师,也就是利用并行。这里的主要结论是,降低延迟实际上增加了吞吐量。当然,如果一个系统可以更快地执行操作,那么它就可以在相同的时间内执行更多的操作。实际上,这是通过利用流处理管道中的并行性实现的。通过并行处理多个流,可以降低延迟,同时处理更多事件。
数据流操作
流处理引擎通常提供一组内置操作来加载数据、转换数据和输出数据流。这些操作符可以组合成数据流处理图来实现流应用程序的逻辑。在本节中,我们将描述最常见的流操作。
操作可以是无状态的,也可以是有状态的。无状态操作不维护任何内部状态。也就是说,事件的处理不依赖于过去看到的任何事件,也不保留历史记录。无状态操作很容易并行化,因为事件可以独立于其他事件及其到达顺序进行处理。此外,在出现故障的情况下,可以简单地重新启动无状态操作符,并从它停止的地方继续处理。相反,有状态操作符可以维护关于它们以前接收到的事件的信息。
此状态可由传入事件更新,并可在未来事件的处理逻辑中使用。有状态流处理应用程序在并行化和容错操作方面更具挑战性,因为需要对状态进行有效分区并在发生故障时可靠地恢复。在本章的最后,您将了解更多关于有状态流处理、故障场景和一致性的内容。
数据加载和数据输出
加载数据和输出数据操作允许流处理引擎与外部系统通信。加载数据是从外部源获取原始数据并将其转换成适合处理的格式的操作。实现数据加载逻辑的操作符称为数据源操作。数据源可以从TCP Socket、文件、Kafka Topic或传感器数据接口获取数据。数据输出是以适合外部系统使用的形式输出的操作。执行数据输出的操作符称为数据接收器(sink),示例包括文件、数据库、消息队列和监视接口。
转换操作
转换操作是独立处理每个事件的单通道操作。这些操作一个接一个地处理事件,并对事件数据应用一些转换,生成一个新的输出流。转换逻辑可以集成到操作符中,也可以由用户定义的函数(UDF)提供,如图2.4所示。udf由应用程序编程人员编写并实现自定义计算逻辑。
操作符可以接受多个输入并产生多个输出流。它们还可以修改数据流图的结构,方法是将一个流拆分为多个流,或者将多个流合并为一个流。我们将在第5章中讨论Flink中可用的所有操作符的语义。
滚动聚合
滚动聚合是连续更新每个输入事件的聚合,如sum、minimum和maximum。聚合操作是有状态的,并将当前状态与传入的事件相结合以生成更新的聚合值。注意,为了能够有效地将当前状态与事件组合起来并产生单个值,聚合函数必须符合交换律和结合律的。否则,操作符将不得不存储完整的流历史记录。图2.5显示了滚动最小聚合。操作符保留当前的最小值,并相应地更新每个传入事件的最小值。
窗口操作
转换和滚动聚合一次处理一个事件,以生成输出事件和可能的更新状态。然而,有些操作必须收集和缓存记录来计算它们的结果。例如,考虑一个流join操作或一个整体聚合,例如中位数。为了在无界流上有效地计算这些操作,您需要限制这些操作维护的数据量。在本节中,我们将讨论提供这种机制的窗口操作。
除了具有实际应用价值外,windows还支持对流进行语义上有趣的查询。您已经看到了滚动聚合如何将整个流的历史以聚合值编码,并为每个事件提供低延迟结果。这对于某些应用程序来说是可以的,但是如果您只对最近的数据感兴趣呢?考虑一个向司机提供实时交通信息的应用程序,这样他们就可以避免拥挤的路线。在这个场景中,您想知道在过去几分钟内某个位置是否发生了事故。另一方面,在这种情况下,了解所有发生过的事故可能不是那么有趣。更重要的是,通过将流历史缩减为单个聚合,您将丢失关于数据随时间变化的信息。例如,你可能想知道每5分钟有多少辆车通过一个十字路口。窗口操作不断地从无界事件流创建有限的称为bucket的事件集,并让我们对这些有限的事件集执行计算。事件通常根据数据属性或时间分配给bucket。为了正确定义窗口操作符语义,我们需要回答两个主要问题:“如何将事件分配给bucket ?”以及“窗口多久产生一次结果?”。windows的行为是由一组策略定义的。窗口策略决定何时创建新buckets,将哪些事件分配给哪些buckets,以及何时计算buckets的内容。何时计算buckets的内容基于一个触发条件。当触发器条件满足时,bucket内容被发送到一个计算函数,该函数对bucket中的元素应用计算逻辑。计算函数可以是像sum或minimum这样的聚合,也可以是应用在bucket收集的元素上的自定义操作。策略可以基于时间(例如最近5秒内接收到的事件)、计数(例如最近100个事件)或数据属性。在本节中,我们将描述常用窗口类型的语义。
翻转窗口(Tumbling windows)将事件分配到固定大小的非重叠buckets中。当到达窗口边界时,所有事件都被发送到一个计算函数进行处理。基于计数的翻转窗口定义了在触发计算之前收集了多少事件。图2.6显示了一个基于计数的翻转窗口,它将输入流离散化为4个元素。基于时间的翻转窗口定义一个时间间隔,在这个时间间隔中事件被缓存在桶中。图2.7显示了一个基于时间的翻转窗口,它将事件收集到buckets中,并每10分钟触发一次计算。
滑动窗口(Sliding windows)将事件分配到具有固定大小的重叠buckets中。因此,一个事件可能属于多个bucket。我们通过提供滑动窗口的长度和滑动间隔来定义滑动窗口。滑动间隔值定义了创建新buckets的时间间隔。图2.8基于滑动次数的窗口长度为4个事件,滑动次数为3个事件。
会话窗口(Session windows)在某些真实场景中非常有用,在这种场景中,翻转窗口和滑动窗口都不能应用。考虑一个分析在线用户行为的应用程序。在这样的应用程序中,我们希望将来自同一用户活动或会话期间的事件分组在一起。Sessions包括在相邻时间内发生的一系列事件,然后是一段不活动的时间。例如,用户与一系列新闻文章的交互可以视为一个会话。由于会话的长度不是预先定义的,而是取决于实际的数据,所以翻转和滑动窗口不能在此场景中应用。相反,我们需要一个窗口操作来分配属于相同bucket中相同会话的事件。会话窗口根据会话间隙值对会话中的事件进行分组,该值定义了认为会话已关闭的不活动时间。图2.9显示了一个会话窗口。
到目前为止,您看到的所有窗口类型都是全局窗口,并在全流上操作。但在实践中,您可能希望将一个流划分为多个逻辑流并定义并行窗口。例如,如果您从不同的传感器接收测量值,您可能希望在应用窗口计算之前根据传感器id对流进行分组。在并行窗口中,每个分区独立于其他分区应用窗口策略。图2.10显示了一个长度为2的基于并行计数的翻转窗口,该窗口通过事件颜色进行划分。
窗口操作与流处理中的两个主要概念密切相关:时间语义和状态管理。时间可能是流处理中最重要的方面。尽管低延迟是流处理的一个吸引人的特性,但它的真正价值远不止提供快速分析。现实世界的系统、网络和通信通道远非完美,因此流数据常常会延迟或无序到达。理解如何在这种情况下交付准确和确定的结果是至关重要的。更重要的是,在事件生成时处理它们的流应用程序还应该能够以相同的方式处理历史事件,从而支持离线分析甚至时间旅行分析。当然,如果您的系统不能防止故障,这些都不重要。到目前为止看到的所有窗口类型都需要在执行操作之前缓冲数据。事实上,如果您想要计算流应用程序中任何有趣的东西,即使是一个简单的计数,您也需要维护状态。考虑到流应用程序可能会运行几天、几个月甚至几年,您需要确保在出现故障时能够可靠地恢复状态,并且即使出现故障,您的系统也能够保证得到准确的结果。在本章的其余部分,我们将深入研究数据流处理失败情况下的时间和状态保证的概念。
时间语义
在本节中,我们将介绍时间语义并描述流处理中不同的时间概念。我们将讨论流处理引擎如何使用无序事件提供准确的结果,以及如何使用流执行历史事件处理和时间旅行。
一分钟是什么意思?
在处理可能无限的连续到达事件流时,时间成为应用程序的一个关键方面。假设您希望连续计算结果,例如每一分钟计算一次。在我们的流处理应用程序上下文中,一分钟到底意味着什么?
考虑一个程序,它可以分析用户玩在线手机游戏时产生的事件。用户被组织在团队中,应用程序收集团队的活动,并根据团队成员完成游戏目标的速度在游戏中提供奖励,比如额外的生命和升级。例如,如果一个团队中的所有用户在一分钟内弹出500个气泡,他们就会升级。Alice是个很投入的玩家,每天早上上班的路上她都要玩这个游戏。问题是Alice住在柏林,她坐地铁去上班。每个人都知道柏林地铁的移动互联网连接很糟糕。考虑这样一种情况,当Alice的手机连接到网络并向分析应用程序发送事件时,她开始弹出气泡。然后突然,火车进入隧道,她的电话被切断了。Alice继续玩,游戏事件在她的手机里被缓冲。当火车驶出隧道时,她回到网上,等待事件被发送到应用程序。应用程序应该做什么?在这种情况下一分钟是什么意思?它是否包括Alice离线的时间?
在线游戏是一个简单的场景,它显示了操作符语义应该如何依赖于事件实际发生的时间,而不是应用程序接收事件的时间。在手机游戏的情况下,结果可能会像Alice和她的团队感到失望而永远不再玩游戏一样糟糕。但是有更多的时间关键型应用程序,我们需要保证它们的语义。如果我们只考虑在一分钟内接收多少数据,我们的结果会有所不同,并取决于网络连接的速度或处理的速度。相反,真正定义一分钟内事件数量的是数据本身的时间。
在Alice的游戏示例中,流处理应用程序可以使用两个不同的时间概念进行操作,即处理时间或事件时间。我们将在下面几节中描述这两个概念。
处理时间
处理时间是正在执行流处理的操作符所在机器上的本地时钟的时间。处理时间窗口包括在一个时间段内碰巧到达窗口操作的所有事件,由其机器的挂钟测量。如图2-12所示,在Alice的情况下,处理时间窗口将在她的手机断开连接时继续计算时间,因此不考虑她在这段时间内的游戏活动。
事件时间
事件时间是流中事件实际发生的时间。事件时间基于附加在流事件上的时间戳。时间戳通常在事件数据进入处理管道之前就存在(例如,事件创建时间)。图2-13显示了事件时间窗口将正确地将事件放置在窗口中,反映了事情是如何发生的,即使有些事件被延迟了。
事件时间将处理速度与结果完全解耦。基于事件时间的操作是可预测的,其结果是确定的。事件时间窗口计算将产生相同的结果,不管流处理的速度有多快,也不管事件何时到达操作符。
处理延迟事件只是事件时间可以克服的挑战之一。除了经历网络延迟之外,流还可能受到许多其他因素的影响,从而导致事件无序到达。以Bob为例,他是另一个在线手机游戏的玩家,碰巧和Alice在同一列火车上。Bob和Alice玩的是同一款游戏,但是他们有不同的移动提供商。当Alice的手机在隧道中失去连接时,Bob的手机保持连接并向游戏应用程序发送事件。
即使在这种情况下,我们也可以依靠事件时间来保证结果的正确性。更重要的是,当与可重复播放的流相结合时,时间戳的决定论使您能够回到过去。也就是说,您可以重新播放流并分析历史数据,就像事件正在实时发生一样。另外,您可以将计算快进到现在,这样,一旦您的程序赶上了现在发生的事件,它就可以继续作为一个实时应用程序使用完全相同的程序逻辑。
水印
在我们到目前为止关于事件时间窗口的讨论中,我们忽略了一个非常重要的方面:我们如何决定何时触发事件时间窗口?也就是说,我们需要等待多长时间才能确定我们已经接收到在某个时间点之前发生的所有事件?我们怎么知道数据会被延迟呢?考虑到分布式系统不可预测的现实和外部组件可能导致的任意延迟,这些问题没有绝对正确的答案。在本节中,我们将看到如何使用水印的概念来配置事件时间窗口行为。
水印是一种全局进度度量,它指示在我们确信不会再有延迟事件到来时的某个时间点。本质上,水印提供了一个逻辑时钟,告知系统当前事件的时间。当操作符收到时间T的水印时,它可以假定不会再收到时间戳小于T的事件。对于事件时间窗口和处理无序事件的操作符来说,水印都是必不可少的。一旦接收到水印,操作人员就会收到信号,表示一定时间间隔内的所有时间戳都已被观察到,并且触发计算或接收事件的顺序。
水印提供了一个可配置的权衡结果之间的信心和延迟。急切的水印确保了低延迟,但提供了较低的可信度。在这种情况下,延迟事件可能在水印之后到达,我们应该提供一些代码来处理它们。另一方面,如果水印到达速度太慢,您就有很高的可信度,但是您可能会不必要地增加处理延迟。
在许多实际应用中,系统没有足够的知识来完美地确定水印。以手机游戏为例,我们几乎不可能知道用户可能会断开连接多长时间;他们可能要穿过隧道,登上飞机,或者再也不玩了。无论水印是用户定义的还是自动生成的,在分布式系统中跟踪全局进度都可能遇到问题。因此,仅仅依靠水印并不总是一个好主意。相反,重要的是流处理系统提供一些机制来处理可能在水印之后到达的事件。根据应用程序需求,您可能想要忽略这些事件,记录它们到日志中,或者使用它们来纠正以前的结果。
处理时间vs.事件时间
此时,您可能会想:既然事件时间解决了所有问题,为什么还要考虑处理时间T呢?事实是,处理时间在某些情况下确实是有用的。处理时间窗口引入了尽可能低的延迟。由于不考虑延迟事件和无序事件,所以窗口只需缓冲事件并在达到指定时间长度后立即触发计算。因此,对于那些速度比精度更重要的应用程序,处理时间是很方便的。另一种情况是,您需要定期地实时报告结果,而不依赖于结果的准确性。一个示例应用程序是一个实时监控仪表板,它显示接收到的事件聚合。最后,处理时间窗口提供了流本身的可靠表示,这对于某些用例来说也是一个理想的属性。总而言之,处理时间提供低延迟,但是结果依赖于处理的速度,并且是不确定的。另一方面,事件时间保证了确定性结果,并允许您处理延迟甚至无序的事件。
状态和一致性模型
现在我们来研究流处理的另一个非常重要的方面,状态。状态在数据处理中是普遍存在的。几乎任何重要的计算都需要它。为了产生一个结果,UDF在一段时间或事件的数量上积累状态,例如,计算聚合或检测模式。有状态操作符同时使用传入事件和内部状态来计算它们的输出。以一个滚动聚合操作符为例,它输出到目前为止所看到的所有事件的当前和。操作符将sum的当前值作为其内部状态,并在每次接收到新事件时更新它。类似地,考虑这样一个操作符,当它在10分钟内检测到“高温”事件之后出现“冒烟”事件时,会发出警报。操作员需要将“高温”事件存储在其内部状态,直到看到“冒烟”事件或直到10分钟的时间段过期。
如果我们考虑使用批处理系统来分析一个无界数据集的情况,那么状态的重要性就更加明显了。事实上,在现代流处理引擎兴起之前,这是一种常见的实现选择。在这种情况下,作业在成批传入事件中重复执行。当作业完成时,结果被写入持久存储,所有操作符状态将丢失。一旦作业被安排在下一批上执行,它就不能访问上一个作业的状态。这个问题通常通过将状态管理委托给外部系统(如数据库)来解决。相反,使用连续运行的流作业,可以极大地简化应用程序代码中的状态操作。在流处理中,我们有跨事件的持久状态,我们可以在编程模型中将其作为一等公民公开。可以认为,可以使用外部系统来管理流状态,即使这种设计选择可能会带来额外的延迟。
由于流操作符处理的数据可能是无界的,所以应该谨慎对待,不要让内部状态无限增长。为了限制状态大小,操作符通常维护到目前为止看到的事件的某种摘要或概要。这样的摘要可以是一个计数、一个求和、到目前为止看到的事件示例、一个窗口缓冲区或一个自定义数据结构,该结构保留了运行中的应用程序感兴趣的一些属性。
可以想象,支持有状态操作符会带来一些实现挑战。首先,系统需要有效地管理状态,并确保它不受并发更新的影响。其次,并行化变得复杂,因为结果同时依赖于状态和传入事件。幸运的是,在许多情况下,您可以通过一个key来划分状态,并独立地管理每个分区的状态。例如,如果您正在处理来自一组传感器的测量流,您可以使用分区操作符状态来独立地维护每个传感器的状态。有状态操作符带来的第三个也是最大的挑战是确保状态可以恢复,并且在出现故障时结果将是正确的。在下一节中,您将详细了解任务失败和结果保证。
任务的失败
流作业中的操作符状态非常有价值,应该防止失败。如果在故障期间丢失状态,则恢复后的结果将是不正确的。流作业会运行很长一段时间,因此可以在几天甚至几个月的时间内收集状态。在失败的情况下,重新处理所有输入以重现丢失的状态将是非常昂贵和耗时的。
在本章的开头,您了解了如何将流程序建模为数据流图。在执行之前,它们被转换成许多连接的并行任务的物理数据流图,每个任务运行一些操作符逻辑,消费输入流并为其他任务生成输出流。典型的实际设置很容易在许多物理机器上并行运行数百个这样的任务。在长时间运行的流作业中,每个任务都可能在任何时候失败。如何确保透明地处理此类故障,以便流作业能够继续运行?实际上,您希望流处理引擎不仅在任务失败的情况下继续处理,而且还要提供关于结果和操作符状态的正确性保证。我们将在本节讨论所有这些问题。
什么是任务失败?
对于输入流中的每个事件,任务执行以下步骤:(1)接收事件,即将其存储在本地缓冲区中;(2)可能更新内部状态;(3)生成输出记录。在这些步骤中任何一个步骤都可能发生故障,系统必须在故障场景中清楚地定义其行为。如果任务在第一步中失败,事件会丢失吗?如果它在更新内部状态后失败,它会在恢复后再次更新吗?在这些情况下,输出是确定的吗?
注意
我们假设可靠的网络连接,这样就不会删除或复制任何记录,所有事件最终都按照FIFO顺序交付到它们的目的地。注意,Flink使用TCP连接,因此保证了这些需求。我们还假设有完美的故障检测器,并且没有任务会故意恶意地运行;也就是说,所有未失败的任务都遵循上述步骤。
在批处理场景中,您可以轻松地解决所有这些问题,因为所有输入数据都是可用的。最简单的方法是简单地重新启动作业,然后我们必须重新播放所有数据。然而,在流媒体世界中,处理失败并不是一个微不足道的问题。流处理系统通过提供结果保证来定义它们在出现故障时的行为。接下来,我们将回顾现代流处理器提供的保证类型以及系统实现这些保证的一些机制。
结果保证
在描述不同类型的保证之前,我们需要澄清一些要点,在讨论流处理引擎中的任务失败时,这些要点常常是混乱的根源。在本章的其余部分,当我们讨论“结果保证”时,我们指的是流处理引擎内部状态的一致性。也就是说,我们关心的是从失败中恢复后应用程序代码所看到的状态值。请注意,流处理引擎通常只能保证流处理器内部状态的结果正确性。然而,确保向外部系统精确地交付结果是非常具有挑战性的。例如,一旦数据被发送到接收器,就很难保证结果的正确性,因为接收器可能不提供事务来恢复先前写入的结果。
最多一次
当一个任务失败时,最简单的事情就是不做任何事情来恢复丢失的状态和重放丢失的事件。At-most-once是保证对每个事件进行最多一次处理的简单情况。换句话说,可以简单地删除事件,并且没有确保结果正确性的机制。这种类型的担保也被称为“无担保”,因为即使是一个放弃所有事件的系统也可以实现它。没有任何保证听起来是一个糟糕的主意,但是如果您可以接受近似的结果,并且您所关心的只是提供尽可能低的延迟,那么这可能是好的。
至少一次
在大多数实际应用程序中,最基本的要求是事件不会丢失。这种类型的保证称为至少一次,这意味着所有事件都将被处理,即使其中一些事件可能被多次处理。如果应用程序的正确性仅依赖于信息的完整性,那么重复处理可能是可以接受的。例如,确定输入流中是否发生特定事件可以通过至少一次保证来正确实现。在最坏的情况下,您将不止一次地定位事件。但是,在至少一次保证的情况下,计算特定事件在输入流中发生的次数可能会返回错误的结果。
为了确保至少一次的结果正确性,您需要有一种机制来重放事件,从源或从某个缓冲区。持久事件日志将所有事件写入持久存储,以便在任务失败时可以重播这些事件。另一种实现同等功能的方法是使用记录确认。此方法将每个事件存储在缓冲区中,直到其处理被管道中的所有任务确认,此时可以丢弃事件。
有且只有一次
这是最严格也是最具挑战性的一种保障形式。精确的一次结果保证意味着不仅不会有事件丢失,而且对于每个事件内部状态的更新也只应用一次。本质上,一次保证意味着我们的应用程序将提供正确的结果,就像从未发生过故障一样。
提供精确的一次保证需要至少一次保证,因此再次需要数据重放机制。此外,流处理引擎需要确保内部状态的一致性。也就是说,在恢复之后,它应该知道是否已经在状态上反映了事件更新。事务性更新是实现此结果的一种方法,但是,它可能导致大量的性能开销。相反,Flink使用轻量级的快照机制来实现一次精确的结果保证。我们将在第三章讨论Flink的容错算法。
端到端有且只有一次
到目前为止,您看到的保证类型仅涉及流处理器组件。然而,在实际流架构中,通常有几个连接的组件。在非常简单的情况下,在流处理器之外至少有一个源和一个接收器。端到端保证是指跨数据处理管道的结果正确性。要评估端到端保证,必须考虑应用程序管道的所有组件。每个组件都提供自己的保证,整个管道的端到端保证是每个组件中最弱的。需要注意的是,有时候可以用更弱的保证获得更强的语义。常见的情况是任务执行幂等操作,比如最大值或最小值。在这种情况下,您可以使用至少一次保证来实现精确的一次语义。
总结
在本章中,您已经学习了数据流处理的基本概念和思想。您已经了解了数据流编程模型,并了解了如何将流应用程序表示为分布式数据流图。接下来,您了解了并行处理无限流的需求,并认识到流应用程序的延迟和吞吐量的重要性。您已经学习了基本的流操作,以及如何使用windows在无界输入数据上计算有意义的结果。您想知道流处理中时间的意义,并比较了事件时间和处理时间的概念。最后,您已经了解了为什么状态在流应用程序中很重要,以及如何防止失败和保证正确的结果。
到目前为止,我们已经学习了独立于Apache Flink的流概念。在本书的其余部分,我们将看到Flink如何实际实现这些概念,以及如何使用它的DataStream api编写使用我们目前介绍的所有功能的应用程序。