到目前为止,您已经了解了流处理如何解决传统批处理的局限性,以及它如何支持新的应用程序和体系结构。您已经熟悉了开源的流处理空间的演变,并对Flink流应用程序有了简单的了解。在这一章,你将进入流世界中,并得到本书本书剩下部分所必要的基础知识。
这一章仍然与Flink无关。它的目标是介绍流处理的基本概念并讨论流处理框架的需求。我们希望在阅读本章之后,您能够更好地理解流应用程序需求,并能够评估现代流处理系统的特性。
2.1数据流编程介绍【dataflow programming】
在深入研究流处理的基础知识之前,我们必须首先介绍数据流编程【dataflow programming】的必要背景知识,并确定我们将在本书中使用的术语。
2.1.1数据流图[Dataflow graphs]
顾名思义,数据流程序描述了数据如何在业务操作【operations】之间流动。数据流程序通常表示为有向图,其中节点称为运算子/操作符【operator】,表示计算,而边表示数据依赖。运算子/操作符【operator】是数据流应用程序的基本功能单元。它们消耗输入的数据,对其执行计算,并将数据生成输出以进行进一步的处理。没有输入端口的运算子/操作符【operator】称为数据源[data source],没有输出端口的运算子/操作符【operator】称为数据接收器[data sinks]。数据流图必须至少包含有一个数据源和一个数据接收器。图2.1显示了一个dataflow程序,它从tweet的输入流中提取和计数散列标签:
图2 - 1 一个逻辑数据流图,用于连续计数散列标签。节点表示运算子/操作符【operator】,边表示数据依赖关系
图2.1中的数据流图之所以称为逻辑图,是因为它传达的是计算逻辑的高级视图。为了执行一个数据流程序,需要将其逻辑数据流图转换为物理数据流图,其中包括关于如何执行计算的详细信息。例如,如果我们使用分布式处理引擎,每个运算子/操作符【operator】可能在不同的物理机器上运行多个并行任务。图2.2显示了图2.1中的逻辑数据流图的物理数据流图。在逻辑数据流图中,节点表示运算子/操作符【operator】,而在物理数据流中,节点表示任务【tasks】。“Extract hashtags”和“Count”运算子/操作符【operator】分别有两个并行的运算子/操作符任务【operator task】,每个运算子/操作符任务【operator task】均是对输入数据的子集执行计算。
图2 - 2 统计标签的物理数据流计划,其中节点代表的任务。
2.1.2 数据并行与任务并行
可以以不同的方式利用数据流图中的并行性。首先,您可以对输入数据进行分区,并让相同操作的任务并行地在数据子集上执行。这种类型的并行性称为数据并行性。数据并行性非常有用,因为它允许处理大量数据并将计算负载分散到多个计算节点。其次,可以让来自不同运算子/操作符【operator】的任务并行地对相同或不同的数据执行计算。这种并行性称为任务并行性。使用任务并行性可以更好地利用集群的计算资源。
2.1.3 数据交换策略
数据交换策略定义了如何将数据项/数据条目分配给物理数据流图中的任务。数据交换策略可以由执行引擎根据运算子/操作符【operator】的语义自动选择,也可以由数据流程序员显式地强制实施。在这里,我们简要回顾一些常见的数据交换策略,如图2.3所示:
- 转发【forward】策略将数据从一个任务发送到另一个接收任务。如果两个任务位于同一台物理机器上(通常由任务调度器保证),那么这种交换策略可以避免网络通信。
- 广播【broadcast】策略将每个数据项发送给一个运算子/操作符【operator】的所有并行的任务上。因为这种策略涉及数据复制和网络通信,所以成本相当高。
- 基于键【key-based】的策略通过键属性【key】对数据进行分区,并确保具有相同key的数据项将由相同的任务处理。在图2.2中,“Extract hashtags”运算子/操作符【operator】的输出基于key(hashtag)进行分区,这样count运运算子/操作符任务【operator task】就能够正确计算每个hashtag的出现次数。
- 随机【random】策略将数据项均匀地分配给运算子/操作符任务【operator task】,以便在任务之间均匀地分配负载。
THE FORWARD AND RANDOM STRATEGIES AS KEY-BASED
转发策略和随机策略也可以看作是基于键的策略的变体,前者保持上游元组的键,而后者则执行键的随机重新分配。
图2-3 数据交换策略
PS:转发与随机策略是基于key-based策略的:转发策略和随机策略也可以看作是基于键的策略的变体,其中前者保存上游元组的键,而后者执行键的随机重新分配。
2.2 并行处理流
现在您已经熟悉了数据流编程【dataflow programming】的基础知识,接下来我们将看看这些概念如何应用于并行处理数据流。但首先,我们定义术语数据流【data stream】:
A data stream is a potentially unbounded sequence of events
数据流是一个可能无限界的事件序列
数据流中的事件可以表示监视数据、传感器测量、信用卡交易、气象站观测、在线用户交互、web搜索等。在本节中,您将学习使用数据流编程范式【dataflow programming paradigm】并行处理无限流的概念。
2.2.1 Latency and throughput:延迟和吞吐量
在上一章中,您看到了流式应用程序是与传统批处理程序有着不同的操作需求。在评估性能时,需求也有所不同。对于批处理应用程序,我们通常关心的是作业的总执行时间,简而言之:处理引擎读取输入、执行计算和回写结果需要多长时间。由于流式应用程序是连续运行的,并且输入可能是无限的,所以在数据流处理中不存在总执行时间的概念。相反,流式应用程序必须尽可能快地为传入的数据提供结果,同时能够处理较高的事件摄取率。我们用延迟和吞吐量这两个指标表示性能。
LATENCY:延迟
延迟表示处理事件需要多长时间。本质上,它是指从接收事件到在输出中看到处理此事件的效果之间的时间间隔。要直观地理解延迟,可以考虑您每天访问您最喜欢的咖啡店的情景。当你走进咖啡店时,里面可能已经有其他顾客了。因此,你排队等候,当轮到你时,你就下订单。收银员收到您的付款,并把您的订单交给为您准备饮料的咖啡师。一旦你的咖啡准备好了,咖啡师就会叫你的名字,你就可以从前台拿咖啡了。您的服务延迟指是您在咖啡店中,从您进入咖啡店的那一刻起,直到您喝了第一口咖啡所花费的时间。
在数据流中,延迟是以时间为单位度量的,比如毫秒。根据应用程序的不同,您可能会关心平均延迟、最大延迟或百分比延迟。例如,平均延迟值为10ms意味着事件平均在10ms时间内被处理。相反,95%的延迟值为10ms意味着95%的事件在10ms时间内处理。平均值隐藏了延迟的真实分布,并且可能使问题难以检测。如果咖啡师在准备卡布奇诺咖啡之前就把牛奶喝光了,你就得等他们从供应室拿来一些。虽然你可能会对这种延迟感到恼火,但大多数其他客户仍然会对这里的服务感到很满意。
对于绝大多数流式应用程序来说,确保低延迟是非常关键的,例如欺诈检测、报警、网络监视和使用严格的服务级别协议(sla)提供服务。低延迟是流处理的一个关键特性,它使得我们所说的实时应用程序变得可能。像Apache Flink这样的现代流处理器可以提供低至几毫秒的延迟。相比之下,传统的批处理延迟通常在几分钟到几个小时之间。在批处理中,首先需要分批次的收集事件,然后才能处理它们。因此,延迟受到每个批处理中最后一个事件的到达时间的限制,而这自然取决于批处理的大小。真正的流处理不会引入这种人为的延迟,因此可以实现非常低的延迟。在真正的流模型中,事件一旦到达系统就可以被处理,延迟自然更接近于对每个事件执行所花费的实际工作时间。
THROUGHPUT:吞吐量
吞吐量是系统处理能力的度量,即处理速度。也就是说,吞吐量告诉我们每个时间单元内,系统可以处理多少个事件。再看一下咖啡店的例子,如果这家咖啡店从早上7点开到晚上7点,一天接待600名顾客,那么它的平均吞吐量将是每小时50名顾客。虽然希望延迟尽可能低,但通常希望吞吐量尽可能高。
吞吐量通常以单位时间内所处理的业务操作或事件来度量。需要注意的是,处理的速度取决于到达的速度;低吞吐量并不一定意味着性能不好(即事件在传递的过程消费了太多的时间,比如网络信号不好,或者内存不足)。在流系统中,您通常希望确保您的系统能够处理最大的事件预期速率。也就是说,您主要关心的是确定峰值吞吐量,即系统在其最大负载时的性能限制。为了更好地理解峰值吞吐量的概念,让我们考虑系统资源完全未利用的情况。当第一个事件出现时,它将立即以尽可能低的延迟进行处理。如果你是早上第一个在咖啡店开门后出现的顾客,你就会立即得到服务。理想情况下,您希望这种延迟保持在一个常量值水平,并且与传入事件的速率无关。一旦我们达到了系统资源被充分利用起来时候的事件接收速率,我们将不得不开始缓冲事件。在咖啡店的例子中,你可能会在午后时分看到这种情况发生。许多人同时出现,你不得不排队等候下订单。此时系统已经达到了峰值吞吐量,进一步增加事件传入速度只会导致更糟糕的延迟。如果系统继续以超出其处理能力的速度接收数据,缓冲区可能会变得不可用,甚至丢失数据。这种情况通常被称为背压,有不同的应对策略。在第三章中,我们详细介绍了Flink的背压机制。
LATENCY VS. THROUGHPUT:延迟VS吞吐量
此时,你应该很清楚,延迟和吞吐量并不是相互独立的度量标准。如果事件在数据处理管道中传输需要很长时间,我们就不能轻松地保证高吞吐量。类似地,如果系统的吞吐量很小,事件将被缓冲,并且必须在被处理之前等待很长时间。
让我们再次引用咖啡店这个示例,以阐明延迟和吞吐量如何相互影响。首先,应该清楚的是,在空载情况下存在最佳延迟。也就是说,如果你是咖啡店里唯一的顾客,你会得到最快的服务。然而,在繁忙的时候,客户不得不排队等待,延迟会增加。影响延迟和吞吐量的另一个因素是处理事件所花费的时间,或者可以说是在咖啡店为每个顾客提供服务所花费的时间。想象一下,在圣诞节期间,咖啡师们必须在他们提供的每一杯咖啡上画一个圣诞老人。这样一来,准备一杯饮料的时间会增加,导致每个人在咖啡店的时间增加,从而降低整体的吞吐量。
那么,您是否能够同时获得低延迟和高吞吐量,或者这只是一种无望的尝试呢?获得低延迟的一种方法是雇佣一名更有技巧的咖啡师,即一名准备咖啡更快的咖啡师。在高负载下,这种改变还会增加吞吐量,因为在相同的时间内会有更多的客户得到服务。另一种达到相同效果的方法是雇佣第二个咖啡师,即利用并行性。这里的主要要点是,降低延迟实际上会增加吞吐量。当然,如果一个系统可以更快地执行业务操作【operations】(即低延迟),那么它可以在相同的时间内执行更多的业务操作【operations】(即高吞吐量)。事实上,这是通过在流处理管道中利用并行性实现的。通过并行处理多个流,可以在同时处理更多事件的同时降低延迟。
2.2.2 数据流上的操作
流处理引擎通常提供一组用于摄取、转换和输出流的内置操作【operations 】。这些运算子/操作符【operator】可以组合成数据流处理图来实现流应用程序的逻辑。在本节中,我们将描述最常见的流运算子/操作符【operator】。
操作【operation】可以是无状态的,也可以是有状态的。无状态的操作【Stateless operation】不维护任何内部状态。也就是说,事件的处理不依赖于过去发生的任何事件,也不保留任何历史。无状态的操作【Stateless operation】很容易并行化,因为这些事件可以彼此独立地处理,且不会受到事件到达的顺序影响。此外,在发生故障的情况下,可以简单地重新启动无状态运算子/操作符【operator】,并从它停止的地方继续处理。相反,有状态运算子/操作符【Stateful operator】可能会维护关于它们以前接收到的事件的信息。该状态可由传入事件更新,并可用于未来事件的处理逻辑。有状态的流处理应用程序在并行化和以容错方式操作方面面临更大的挑战,因为在出现故障的情况下,状态需要有效地分区和可靠地恢复。在本章的最后,您将了解关于有状态的流处理、故障场景和一致性的更多信息。
DATA INGESTION AND DATA EGRESS:数据摄取和数据输出
数据摄取和数据导出操作【operation】允许流处理器与外部系统通信。数据获取是指从外部数据源获取原始数据并将其转换为适合处理的格式的操作【operation】。实现数据摄入逻辑的运算子/操作符【operator】称为数据源【data source】。数据源可以从TCP套接字、文件、Kafka主题或传感器数据接口来摄取数据。数据导出是以适合于外部系统消费的形式产生输出的操作。执行数据导出的运算子/操作符【operator】称为数据接收器【data sinks 】,示例包括文件、数据库、消息队列和监视接口。
TRANSFORMATION OPERATIONS:转换操作
转换操作是独立处理每个事件的单通道操作。这些操作会一个又一个的消费事件,并对事件数据应用一些转换,生成一个新的输出流。转换逻辑可以集成在运算子/操作符【operator】中,也可以由用户自定义函数(UDF)提供,如图2.4所示。UDF由应用程序程序员编写,实现自定义的计算逻辑。
图2 - 4 带有UDF的流运算子【operator】,它将每个传入事件转换为黑色事件。
运算子/操作符【operator】可以接受多个输入并产生多个输出流。它们还可以通过将流拆分为多个流或将流合并为单个流来修改数据流图的结构。我们将在第五章讨论Flink中所有可用的运算子/操作符【operator】的语义。
ROLLING AGGREGATIONS:滚动聚合
滚动聚合【ROLLING AGGREGATIONS】是为每个输入事件不断更新的聚合,例如sum、minimum和maximum。聚合操作是有状态的,并将当前状态与传入的事件结合起来以生成更新的聚合值。注意,为了能够有效地将当前状态与事件结合起来并产生单个值,聚合函数必须满足结合律【associative】和交换律【commutative】。否则,运算子/操作符【operator】将不得不存储完整的流历史记录。图2.5显示了滚动最小值聚合。运算子【operator】保持当前的最小值,并根据传入的事件相应的更新该聚合值。
图2 - 5 滚动最小值聚合运算【operation】
WINDOWS:窗口
转换和滚动聚合一次只处理一个事件,以产生输出事件和潜在的状态更新。然而,一些操作必须收集和缓冲记录,以用来计算它们的结果。例如,考虑流式join操作或整体聚合(如中位数)。为了在无界流上高效地计算这些操作,您需要限制这些操作所维护的数据量。在本节中,我们将讨论窗口【windows】操作,它提供这种机制。
除了具有实用价值之外,窗口【windows】还支持对流进行特定语义的查询。您已经看到滚动聚合如何以一个聚合值的形式编码整个流的历史记录,并为每一个事件提供一个低延迟的结果。这对于某些应用程序来说很好,但是如果您只对最新的数据感兴趣呢?考虑一个向司机提供实时交通信息的应用程序,以便于司机可以避开拥挤的路线。在这个场景中,您希望知道在过去几分钟内某个位置是否发生了事故。另一方面,在这种情况下,了解所有发生过的事故可能就没有那么必要了。更重要的是,通过将流中的历史记录归约(reduce)为单个聚合值,您将丢失关于数据随时间变化的信息。例如,你可能想知道每5分钟有多少辆车穿过一个十字路口。
窗口【windows】操作会不断地从无限事件流中创建有限事件集,我们称之为bucket,并让我们对这些有限集执行计算。事件通常根据数据属性或时间被分配给bucket。为了正确定义窗口运算子/操作符【window operator】的语义,我们需要首先回答两个主要问题:“如何将事件分配给bucket ?”和“窗口多长时间产生一次结果?”。windows的行为由一组策略定义。窗口策略决定何时创建新的bucket、将哪些事件分配给哪些bucket以及何时计算bucket中的内容。基于一个触发条件,决定何时计算bucket中的内容。当触发条件满足时,bucket中的内容被发送到一个计算函数,该函数对bucket中的元素应用计算逻辑。计算函数可以是像sum或minimum这样的聚合,也可以是自定义的运算。策略可以基于时间(例如最近5秒内收到的事件)、计数(例如最近100个事件)或数据属性。在本节中,我们将描述常见的窗口类型的语义。
- 滚动窗口【Tumbling window】将事件分配到固定大小的非重叠的bucket中。在事件抵达窗口边界,所有事件都被发送到一个计算函数中进行处理。基于计数的滚动窗口定义了在触发计算之前需要收集多少事件。图2.6显示了一个基于计数的滚动窗口,它将输入流离散为包含4个元素的bucket。基于时间的滚动窗口定义了在桶中缓冲事件的时间间隔。图2.7显示了一个基于时间的滚动窗口,该窗口将事件聚合到bucket中,并每10分钟触发一次计算。
图2-6 基于计数的滚动窗口
图2-7 基于事件的滚动窗口
- 滑动窗口【Sliding window】将事件分配到固定大小的重叠的bucket中。因此,一个事件可能属于多个bucket。我们通过提供长度(length)和滑动(slide)这两个配置的值,来定义滑动窗口。滑动(slide)值定义了创建新桶的间隔。图2.8中所示的是基于计数的滑动窗口,它的length=4个事件,slide=3个事件。
- 图2-8 基于计数的滑动窗口
- 会话窗口【Session window】在既不能应用滚动窗口也不能应用滑动窗口的常见现实场景中非常有用。考虑一个分析在线用户行为的应用程序。在此类应用程序中,我们希望将来自同一用户活动或会话期间的事件组合在一起。会话是由两部分组成,一部分是在相邻时间内发生的一系列事件,另一部分则是一段失活时间(在这段时间内,没有任何事件摄入,我们就假定应该是会话结束了)。例如,用户一个又一个的与一系列新闻文章交互,这就可以看做是一个会话。由于会话的长度是无法预先定义的,而是取决于实际数据,因此在此场景中不能应用滚动窗口和滑动窗口。相反,我们需要一个窗口,来将同一会话中的事件,分配到同一个bucket中。会话窗口根据会话间隙值【 session gap value】对会话中的事件分组,会话间隙值定义了一个会话失活的参考时间值,当超过该时间值后仍没有新的事件进入窗口,那么表示会话关闭了。图2-9展示了一个会话窗口。
- 图2-9 会话窗口
到目前为止,您看到的所有窗口类型都是全局窗口,并且它们都是在整个流上进行操作。但在实践中,您可能希望将流划分为多个逻辑流并定义并行的窗口。例如,如果您正在接收来自不同传感器的测量数据,您可能希望在应用窗口计算之前,根据传感器id对流进行分组。在并行窗口中,每个分区独立于其他分区应用窗口策略。图2.10显示了通过事件的颜色属性分区,并并行执行的基于计数的滚动窗口(length=2)。 - 图2-10 并行的基于计数的滚动窗口(length=2)
窗口操作与流处理中的两个主要概念密切相关:时间语义和状态管理。时间可能是流处理中最重要的方面。尽管低延迟是流式处理的一个引人注目的特性,但它的真正价值远不仅仅是提供快速分析。现实世界中的系统、网络和通信渠道远非完美,因此流数据常常会延迟或无序到达。理解如何在这样的条件下交付准确和确定的结果是至关重要的。更重要的是,在生成事件时处理事件的流应用程序也应该能够以相同的方式处理历史事件,从而支持离线分析【offline analytics】甚至时间漫游分析【time travel analyses】。当然,如果您的系统不能保护状态不受故障影响,那么这些都不重要。到目前为止,您看到的所有窗口类型都需要在执行操作之前缓冲数据。事实上,如果您想在流应用程序中计算任何有趣的东西,即使是一个简单的计数,您也需要维护状态。考虑到流应用程序可能会运行几天、几个月甚至几年,您需要确保在出现故障时能够可靠地恢复状态,这样的话,你的系统就可以保证即使出现故障问题也能得到准确的结果。在本章的其余部分中,我们将深入研究数据流处理在失败情况下的时间和状态保证的概念。
2.4 时间语义
在本节中,我们将介绍时间语义,并描述流中时间的不同概念。我们将讨论流处理器如何为无序事件提供精确的结果,以及如何使用流执行历史事件处理和时间漫游【 time travel】。
2.4.1 一分钟是什么意思?
在处理持续不断到达的无限事件流时,时间成为应用程序的重要方面。假设您希望持续不断地计算结果,例如每分钟计算一次。在我们的流式应用上下文中,一分钟到底意味着什么?
让我们来考虑这样一个程序,它分析用户玩在线手机游戏所产生的事件。用户被组织在一个team中,应用程序收集一个team的活动,并根据team成员达到游戏目标的速度提供奖励,比如额外的生命和经验。例如,如果team中的所有用户在一分钟内弹出500个气泡,他们就会升级。Alice是一个忠实的游戏玩家,她每天早上上班的路上都会玩这个游戏。但问题是Alice住在柏林,她每天乘地铁上下班。每个人都知道柏林地铁里的移动互联网连接非常糟糕。考虑这样一种情况,当Alice的手机连接到网络时,她开始弹出气泡,并向分析系统发送事件消息。突然,火车进入隧道,她的网络被断开了。Alice仍然继续在玩,游戏事件在她的手机里缓冲。当火车离开隧道时,她会恢复在线状态,缓冲区中等待处理的事件会被发送到析系统。析系统应该怎么做呢?在这种情况下,一分钟是什么意思?它是否包括Alice脱机的时间?
图2-11 在地铁里玩手机游戏。当火车进入隧道断开网络时,接收游戏事件的应用程序将会经历一段缺口。事件将在玩家的手机中缓冲,并在网络连接恢复时发送到应用程序。
在线游戏是一个简单的场景,展示了操作符语义依赖于时间实例发生的时间,而不是应用程序接收到事件的时间。对于一款手机游戏来说,后果可能就像Alice和她的team会对游戏感到失望,再也不会玩了一样糟糕。但是还有很多对时序要求严格的应用程序,我们需要保证它们的语义。如果我们只考虑在一分钟内接收了多少数据,那么结果就会有所不同,这取决于网络连接的速度或处理的速度。相反,真正用来定义一分钟内事件数量的标准应该是数据本身的时间。
在Alice玩手游的这个示例中,流应用程序可以使用两种不同的时间概念-------处理时间【Processing Time】或事件时间【Event Time】。我们在下面的章节中会描述这两个概念。
2.4.2 处理时间【Processing Time】
处理时间【Processing Time】是正在执行处理流的运算子/操作符【operator】所在的机器的本地时钟的时间。处理时间窗口【 processing-time window】包括了在一段时间周期内碰巧到达窗口操作符【window operator 】的所有事件,这些事件由其机器的挂钟来度量的。如图2-12所示,在Alice的例子中,当她的手机断开网络时,一个处理时间窗口【 processing-time window】将继续计算时间,因此不会计入她在这段时间内的游戏活动。
图2 - 12 当Alice的手机断开连接时,一个处理时间窗口【 processing-time window】将继续计算时间。
2.4.3 事件时间【Event Time】
事件时间指的是流中事件实际发生的时间。事件时间基于附加在流事件上的时间戳。在进入处理管道之前,时间戳通常已经存在于事件数据中(例如,事件创建时间)。图2-13显示的事件时间窗口【Event Time Window】将正确地将事件放置在窗口中,这真实的反映了事情是如何发生的,即使有些事件被延迟了。
图2 - 13 事件时间Event Time】正确地将事件放置在窗口中,反映了事情是如何发生的。
事件事件【Event Time】完全将处理速度与结果解耦。基于事件时间的操作是可预测的,它们的结果是确定的。无论流处理的速度有多快,或者事件何时到达运算子/操作符【operator】,事件时间窗口【Event Time Window】的计算都会产生相同的结果。
处理延迟事件仅仅是事件时间【Event Time】所能克服的挑战之一。除了经历网络延迟之外,流还可能受到许多其他因素的影响,导致事件乱序到达。想想Bob,他是另一个在线移动游戏的玩家,碰巧坐在和爱丽丝一样的火车上。Bob和Alice玩同一款游戏,但他们使用不同的移动供应商。当Alice的手机在隧道内断开网络连接时,Bob的手机始终保持连接在线,并将事件传递给游戏应用程序。
通过依赖事件时间【Event Time】,即使在这种情况下,我们也可以保证结果的正确性。更重要的是,当事件时间【Event Time】与可重放【replayable】的流相结合时,时间戳决定论使您能够快进过去。也就是说,您可以重放流并分析历史数据,就好像事件正在实时发生一样。此外,您可以将计算快进到现在,这样,一旦您的程序赶上了正在实时发生的事件,它就可以继续使用完全相同的程序逻辑作为实时应用程序来运行。
2.4.4 水印【Watermarks】
在我们到目前为止关于事件时间窗口【event-time windows】的讨论中,我们忽略了一个非常重要的方面:我们如何决定何时触发事件时间窗口【event-time windows】?也就是说,我们需要等待多长时间才能确定我们已经完整的接收到了某个时间点之前发生的所有事件?我们怎么知道数据会被延迟呢?考虑到分布式系统不可预测性和外部组件可能导致的任意延迟,这些问题并没有绝对正确的答案。在本节中,我们将了解如何使用水印的概念来配置事件时间窗口【event-time windows】的行为。
水印是一种全局进度度量指标,它表示一个我们确信不会再有延迟事件发生的时间点。实际上,水印提供了一个逻辑时钟,它通知系统当前事件的时间。当运算子/操作符【operator】接收到时间为T的水印时,它可以假设再也不会接收到时间戳小于T的其他事件。水印对于事件时间窗口【event-time windows】和处理无序事件的运算子/操作符【operators 】都是必不可少的。一旦接收到水印,就会向运算子/操作符【operators 】发出信号,通知它已观察到一定时间间隔的所有时间戳,并触发计算或排序接收到的事件。
水印在结果可信度和延迟之间提供了一种可配置的平衡。 热水印【Eager watermarks】保证了低延迟,但提供较低的结果可信度。在这种情况下,延迟的事件可能会在水印之后到达,我们应该提供一些代码来处理它们。另一方面,如果水印很晚才到达,则您有很高的结果可信度,但可能会不必要地增加了处理的延迟。
在许多实际应用中,系统没有足够的知识来完美地确定水印。例如,在移动游戏的情况下,几乎不可能知道用户可能会断开连接多久;他们可能穿过隧道,登上飞机,或者再也不玩了。无论水印是用户定义的还是自动生成的,当存在掉队的任务时(即处理速度跟不上其他任务的速度),在分布式系统中追踪全局进度总会存在问题
。因此,仅仅依靠水印可能并不总是一个好主意。相反,流处理系统需要提供一些机制来处理水印之后可能到达的事件,这一点至关重要。根据应用程序需求的不同,您可能希望忽略这些事件、记录它们或使用它们来纠正以前的结果。
2.4.5 Processing time vs. event time
此时,您可能会想:既然事件时间【event time】解决了所有问题,为什么还要有处理时间【Processing time】呢?事实上,处理时间【Processing time】在某些情况下确实是有用的。处理时间窗口【Processing time window】引入了尽可能低的延迟。由于不考虑到延迟事件和无序事件,因此窗口只需要缓冲事件并在达到指定的时间长度后立即触发计算。因此,对于速度比准确性更重要的应用程序,处理时间【Processing time】更方便。另一种情况是,您需要定期实时报告结果,而不依赖其准确性时。实时监控仪表板就是一个很好的样例,当接收到事件后,就会将事件聚合值展示出来。最后,处理时间窗口【Processing time window】提供了对流本身的真实表示,对于某些用例来说,这可能也是一个理想的属性。总而言之,处理时间【Processing time】提供了较低的延迟,但是结果取决于处理的速度,且具有不确定性。另一方面,事件时间【event time】保证了结果的确定性,并允许您处理延迟事件,甚至是无序的事件。
2.5 状态与一致性模型
现在我们来研究流处理的另一个非常重要的方面,状态【state】。状态在数据处理中无处不在。任何有意义的计算都需要它。为了产生结果,UDF在一段时间周期或多个事件中累积状态,例如计算聚合值或模式检测(欺诈、钓鱼检测)。有状态的运算子/操作符【operators】使用传入事件和内部状态来计算并得到输出。以滚动聚合操作符【operator】为例,该操作符输出到目前为止所看到的所有事件的sum总和。该操作符将sum的当前值作为其内部状态,并在每次接收到新事件时更新它。类似地,考虑这么一个运算子/操作符【operator】,当它在检测到在出现“高温”事件后10分钟内出现“冒烟”事件时,就发出警报。运算子/操作符【operator】需要将“高温”事件存储在其内部状态,直到看到“冒烟”事件或10分钟的时间周期到期为止。
如果我们考虑使用批处理系统来分析无限数据集的情况,那么状态的重要性就会更加明显了。事实上,在现代流处理器出现之前,这是一种非常常见的实现选择。在这种情况下,一个作业在各个批次的事件集上重复执行。当作业完成时,结果被写入持久存储,所有的操作符状态都将丢失。一旦作业被调度用于在下一批次的事件集上执行,它就无法访问获取到之前的作业的状态。这个问题通常通过将状态管理委托给外部系统(如数据库)来解决。相反,连续运行流作业可以大大简化应用程序代码中的状态操作。在流中,我们拥有跨事件的持久状态,并且我们可以将其作为编程模型中的一等公民公开。可以说,亦可以使用外部系统来管理流状态,尽管这种设计选择可能会带来额外的延迟。
由于流式操作符【operator】处理的数据可能是无限的,因此应该注意不要允许内部状态无限增长。为了限制状态大小,操作符【operators】通常维护的是对到目前为止所看到的事件的某种摘要或概要。这样的摘要可以是一个计数、一个求和、到目前为止所看到的事件的一个样本、一个窗口缓冲区或一个自定义数据结构,这些结构保留了运行中的应用程序感兴趣的一些属性。
我们可以想象的到,支持有状态的运算子/操作符【stateful operator】带来了一些实现方面的挑战。
- State management:系统需要有效地管理状态,并确保它不受并发更新的影响。
- State partitioning:并行化变得复杂,因为结果依赖于状态和传入的事件。幸运的是,在许多情况下,您可以通过键【key】对状态进行分区,并独立管理每个分区的状态。例如,如果您正在处理来自一组传感器的测量流,您可以使用分区操作符状态来独立地维护每个传感器的状态。
- State recovery:有状态操作符带来的第三个,也是最大的挑战是确保状态可以恢复,即使出现故障,状态也可以正确的恢复以保证结果的正确性。在下一节中,您将详细了解任务失败和结果保证的细节。
2.5.1 Task failures
流式作业中的操作符状态【operator state】是非常有价值的,应该防范出现故障。如果在发生故障时状态丢失,那么恢复后结果将不正确。流式作业会运行很长一段时间,因此可以在几天甚至几个月的时间内收集状态。在发生故障的情况下,重新处理所有的输入以重现丢失的状态,这将是非常昂贵和耗时的。
在本章的开头,您看到了如何将流式程序建模为(逻辑)数据流图。在执行之前,这些(逻辑)数据流图被转换为由许多相互连接且并行的任务构成的物理数据流图,每个任务都运行一些操作逻辑,消费输入流并为其他任务生成输出流。现实生产中这种设置可以很容易地让数百个这样的任务在多个物理机器上并行运行。在长时间运行的流式作业中,这些任务中的每一个都可能在任何时候失败。如何确保透明地处理此类故障,以便流作业能够继续运行呢?事实上,您希望您的流处理器不仅在任务失败的情况下可以继续处理,而且还需要提供对于结果和操作符状态的正确性保证。我们将在本节中讨论所有这些问题。
2.5.1 什么是任务失败?
对于输入流中的每个事件,任务均会执行以下步骤:
(1)接收事件,即将其存储在本地缓冲区中
(2)可能会更新内部状态
(3)生成输出记录。
在任何这些步骤中都可能发生故障,系统必须为故障场景明确地定义其行为。如果任务在第一步中失败,事件会丢失吗?如果在更新内部状态后失败,在恢复后会还会再次更新状态吗?在这种情况下,输出还会是确定的吗?
NOTE:我们假设有可靠的网络连接,这样就不会有任何记录被删除或重复,所有事件最终都以FIFO顺序交付到它们的目的地。注意,Flink使用TCP连接,因此这些需求是有保证的。我们还假设有完美的故障检测器,没有任何任务会故意表现出恶意行为;也就是说,所有非失败的任务都遵循上述步骤。
在批处理场景中,您可以轻松地解决所有这些问题,因为所有输入数据都是可用的。最简单的方法是简单地重新启动作业,然后我们必须重放所有数据。然而,在流世界中,处理失败并不是一个微不足道的问题。流式系统通过提供结果保证,来定义它们在出现故障时的行为。接下来,我们将回顾现代流处理器提供的各种保证类型,以及系统实现这些保证的一些机制。
2.5.2 结果保证
我们描述不同类型的保证之前,我们需要澄清一些在讨论流处理器中的任务失败时经常引起混淆的问题。在本章的其余部分中,当我们谈到“结果保证【result guarantees】”时,我们指的是流处理器内部状态的一致性。也就是说,我们关心的是应用程序代码在从失败中恢复后所看到的状态值。注意,流处理器通常只能保证流处理器内部状态的结果正确性。然而,保证结果唯一的【exactly-once 】被交付给外部系统是非常具有挑战性的。例如,一旦数据被发送到接收器节点【sink node】,就很难保证结果的正确性,因为接收器【sink】可能不提供事务来恢复以前编写的结果。
AT-MOST-ONCE
当任务失败时,最简单的处理方式就是不做任何事情来恢复丢失的状态和重播丢失的事件。至多一次【AT-MOST-ONCE】是保证每个事件最多处理一次的简单情况。简而言之,事件可以简单地删除,并且没有任何确机制去保结果的正确性。这种类型的保证也被称为“无保证”,因为任何一个放弃所有事件的系统都可以实现它。没有任何保证听起来是一个糟糕的主意,但是如果您能够接受近似的结果,并且您所关心的只是提供尽可能低的延迟,那么它可能是好的。
AT-LEAST-ONCE
在大多数实际应用程序中,我们最基本的要求是不丢失事件。这种类型的保证称为至少一次【AT-LEAST-ONCE】,这意味着所有事件肯定会被处理,即使其中一些事件可能会被处理多次。如果应用程序的正确性仅取决于信息的完整性,那么重复处理是可以接受的。例如,确定输入流中是否发生了某一特定事件,这可以通过至少一次【at-least-once】保证正确地实现。在最坏的情况下,您将可能会找到多个相同的事件。但是如果我们统计的是某个事件出现的次数,那么至少一次【at-least-once】保证就很可能会返回我们一个错误的结果。
为了确保至少一次【at-least-once】的结果正确性,您需要有一种机制来从源或某个缓冲区中重放事件。持久化事件日志会将所有事件写入到持久性的存储设备上,以便在任务失败时可以重播这些事件(这是从源中重放事件的方法)。另一种实现等效功能的方法是使用记录确认【record acknowledgements】,此方法将每个事件存储在缓冲区中,直到其处理已被管道中的所有任务确认,此时才可以丢弃该事件。
EXACTLY-ONCE
这是最严格、最具挑战性的一种保证。唯一结果保证意味着不仅不会有事件丢失,而且对于每个事件,它们仅会对内部状态的更新产生唯一一次影响。从本质上说,唯一保证意味着我们的应用程序将会始终提供正确的结果,就好像从来没有发生过失败一样。
提供唯一【exactly-once】保证的前提是要有至少一次【at-least-once】保证,因此这里也需要数据重放机制。此外,流处理器还需要确保内部状态的一致性。也就是说,在恢复之后,它应该知道事件更新是否已经反映在状态上。事务性更新是一种实现方式,但是,它可能会产生大量的性能开销。相反,Flink使用轻量级快照机制来实现唯一【exactly-once】结果保证。我们将在第3章讨论Flink的容错算法。
END-TO-END EXACTLY-ONCE
到目前为止,您看到的保证类型仅涉及流处理器组件。然而,在实际的流架构中,通常有多个互联的组件。在一个最简单的情况下,除了流处理器,我们至少还需要有一个源和一个接收器组件。端到端保证是指整个数据处理流水线的结果正确性。在评估端到端保证时,我们必须考虑应用程序管道中的所有组件。每个组件都提供了自己的保证,而整个管道的端到端保证则由每个组件中最弱的那儿保证来决定(PS:水桶挡板的故事)。需要注意的是,有时候你可以通过较弱的保证获得更强的语义。一种常见的情况就是任务执行幂等操作,例如最大值或最小值。在这种情况下,您可以使用至少一次【at-least-once】保证(这是一种较弱的保证)实现唯一【exactly-once】保证语义(我们通过幂等,使得较弱的保证,获得了更强的语义----唯一保证)。
2.4 Summary
在本章中,您已经了解了数据流式处理的基本概念和思想。您已经了解了数据流编程模型【dataflow programming model】,并了解了如何将流应用程序表示为分布式数据流图【distributed dataflow graph】。接下来,您研究了并行处理无限流的要求,并认识到延迟和吞吐量对于流应用程序的重要性。您已经学习了基本的流操作,以及如何使用窗口在无限的数据输入流上计算有意义的结果。您已经想知道了流处理中时间的含义,并且比较了事件时间和处理时间的概念。最后,您已经了解了为什么状态【state】在流应用程序中的重要性,以及如何防范故障并保证正确的结果。
到目前为止,我们已经独立于Apache Flink细致的审视了流概念。在本书的其余部分中,我们将看到Flink如何实现这些概念,以及如何使用它的DataStream API编写使用到目前为止介绍的所有特性的应用程序。