Apache Flink流处理(三)

上一章讨论了分布式流式处理的一些重要概念,如并行化、时间和状态。在本章中,我们将对Flink的体系结构做一次高层次的介绍,并阐述Flink如何满足我们之前所讨论的流式处理所必须的各个需求方面。特别地,我们将解释Flink的处理架构和它的网络栈的设计。我们还将展示Flink如何处理流式应用程序中的时间和状态,并讨论了它的容错机制。本章提供了相关的基础信息,以便您可以成功地使用Apache Flink实现和操作高级流式应用程序。它将帮助您理解Flink的内部结构,并帮助您推断出流式应用程序的性能和行为。

3.1 系统架构

Flink是一种用于有状态并行数据流式处理的分布式系统。一个Flink setup由分布在多台机器上的多个进程组成。分布式系统需要解决的常见挑战是集群中计算资源的分配和管理、进程协作、持久和可用数据存储,以及故障恢复。

Flink本身并没有实现所有需要的功能。相反,它专注于其核心功能——分布式数据流式处理——并利用现有的集群基础设施和服务。Flink与集群资源管理器(如Apache Mesos、YARN和Kubernetes)紧密集成,但也可以配置为作为stand-alone集群运行。Flink不提供持久的分布式存储。相反,它支持分布式文件系统,如HDFS,或者对象存储系统,如S3。对于高可用设置中的领导者选举【leader election】,Flink则依赖于Apache ZooKeeper。

在本节中,我们将描述Flink setup所包含的不同组件,并讨论它们的职责以及它们如何相互交互来执行应用程序。我们会呈现两种不同的Flink应用程序部署风格,并讨论任务是如何分布和执行的。最后,我们将解释Flink的高可用模式是如何工作的。

3.1.1 Flink setup的组件

Flink setup由四个不同的组件组成,它们一起协同工作来执行流式应用程序。这些组件分别是:JobManager、ResourceManager、TaskManager和Dispatcher。由于Flink是用Java和Scala实现的,所以所有组件都运行在Java虚拟机(JVM)上。下面我们将讨论每个组件的职责以及它如何与其他组件交互。

  • JobManager是控制单个应用执行的主进程,即,每个应用程序都是由不同的JobManager控制的。JobManager接收一个应用用于执行。该应用由一个所谓的JobGraph、一个逻辑数据流图【 logical dataflow graph】(参见第2章)和一个打包了所有必需的类、库和其他资源的JAR文件组成。JobManager将JobGraph转换为物理数据流图【physical dataflow graph】,我们称该物理数据流图为ExecutionGraph,它包含可以并行执行的任务。JobManager要求ResourceManager提供执行任务所需的资源(即TaskManager slots),一旦它接收到足够的TaskManager插槽,它就将任务分配给这些负责执行任务的TaskManager 。在执行期间,JobManager负责所有需要中枢协调的操作,例如检查点的协调(请参阅后面的部分)。
  • Flink针对不同的环境和资源提供者(如YARN、Mesos、Kubernetes和stand-alone部署)提供了多种ResourceManager的实现。ResourceManager负责管理TaskManager插槽,它是Flink处理资源的单元。当JobManager请求申请TaskManager 插槽时,ResourceManager要求具有空闲插槽的TaskManager将这些插槽提供给JobManager。如果没有足够的插槽来满足JobManager的请求,则ResourceManager可以与资源提供者(如Apache Mesos、YARN和Kubernetes)对话,以便提供可以用于启动TaskManager进程的容器。ResourceManager还负责杀死空闲的任务管理器来释放计算资源。
  • TaskManagers是Flink的工作进程。通常,在Flink setup中运行着多个TaskManagers。TaskManagers提供了一定数量的插槽。插槽的数量限制了TaskManager可以执行的任务的数量。启动TaskManagers之后,TaskManagers会在ResourceManager中注册它的插槽。在ResourceManager的指示下,TaskManager会向JobManager提供一个或多个插槽。然后JobManager可以向插槽分配任务来执行它们。在执行期间,TaskManager可以与运行的是相同应用的任务的其他TaskManager交换数据。任务的执行和插槽的概念将在后面的部分中详细讨论。
  • Dispatcher的运行贯穿各个执行的作业,并提供一个REST接口用于提交应用程序以供执行。一旦它接收到应用程序,它就启动JobManager并将应用程序移交给它。REST接口使Dispatcher能够作为防火墙保护下的集群的入口点。Dispatcher还运行一个web仪表板来提供关于以往作业执行的详细信息。根据如何提交应用程序以供执行(在下一节中讨论),Dispatcher有时并不适用。

图3-1显示了一个应用程序被提交后,Flink中的这些组件如何相互交互的。
在这里插入图片描述
图3-1 应用提交和组件间交互

请注意,图3-1是一个高层次的示意图,用于图解组件的职责和交互。根据环境的不同(YARN、Mesos、Kubernetes、stand-alone集群),某些步骤可以被省略,或者组件可能在相同的进程中运行。例如,在stand-alone模式下,即在没有负责提供资源的程序(如Apache Mesos、YARN和Kubernetes)的情况下,ResourceManager只能分发手动启动的TaskManagers的插槽,不能启动新的TaskManagers。在第9章中,我们将讨论如何为不同的环境安装和配置Flink。

3.1.2 应用部署

Flink的应用可以以两种不同的风格部署

  • 框架风格:在这种模式下,Flink应用程序被打包到JAR文件中,并由客户端提交给运行中的服务。我们这里所说的服务可以是Flink Dispatcher、Flink JobManager或YARN的ResourceManager。无论是哪种情况,都有一个正在运行的服务接收Flink应用程序并确保它被执行。如果将应用程序提交给JobManager,它将立即开始执行应用程序。如果将应用程序提交给Dispatcher 或YARN的ResourceManager,它将启动一个JobManager,并提交该应用程序,JobManager将会继续执行该应用程序。
  • 库风格:在这种模式下,Flink应用程序绑定在特定于应用程序的容器镜像中,例如Docker镜像。该镜像还包括运行JobManager和ResourceManager的代码。当容器基于该镜像启动时(Docker中,镜像是静态实体,而容器则是该镜像运行后的形态,这种关系类似于进程与程序),它会自动启动ResourceManager和JobManager,并移交绑定的作业。第二种类型的镜像用于部署TaskManagers。启动容器时,它会自动启动TaskManagers,该TaskManagers会连接到ResourceManager并注册其插槽。TaskManager镜像可以独立于应用程序。通常,外部资源管理器(如Kubernetes)负责启动映像,并确保每种类型的镜像都有一定数量的容器在运行。

框架风格模式遵循通过客户端向运行中的Flink服务提交应用程序(或查询)的传统方法。而在库风格的模式中,没有持续运行的Flink服务。相反,Flink是作为一个库与容器镜像中的应用程序绑定在一起的。这种部署模式在微服务架构中很常见。我们将在第10章中更详细地讨论应用程序部署的主题。

3.1.3 任务执行

TaskManager 可以同时执行多个任务。这些任务可以是相同的操作符(数据并行),也可以是不同的操作符(任务并行),甚至可以是来自不同的应用程序(作业并行)。TaskManager提供一定数量的插槽来控制它可以并发执行的任务的数量。一个处理插槽能够执行应用程序的一个切片,即,应用程序中的每个操作符的一个任务。图3-2图解了TaskManager 、插槽【slot】、任务【task】、操作符【operator】之间的关系。
在这里插入图片描述
图3-2 操作符,任务和处理插槽
PS:处理插槽内的内一个方框,分别代表的是一个操作符任务

上图左辺展示的是JobGraph——应用程序的非并行表示形态——由五个操作符组成。操作符A和C是发生器【source】,操作符E是接收器【sink】。操作符C和E的并行度是2。其他操作符的并行度为4。由于最大的操作符并行度为4,因此应用程序至少需要4个可用的处理槽才能执行。给定两个TaskManager ,每个TaskManager 有两个处理插槽,就满足了这个需求。JobManager将JobGraph并行化为一个ExecutionGraph,并将任务分配给四个可用槽。并行度为4的操作符的任务可以分配给每个插槽。操作符C和E的两个任务则分别被分配给了(1.1插槽和2.1插槽),(1.2插槽和2.2插槽)。将任务作为切片调度到插槽上的优点是:多个任务会集中在了同一个TaskManager上,这意味着它们可以在不访问网络的情况下高效地交换数据。

TaskManager在相同的JVM进程中多线程的执行任务。线程比进程更轻量级,通信成本更低,但不会严格地将任务彼此隔离(有利有弊)。因此,一个行为不正常的任务可能会杀死整个TaskManager进程和在TaskManager上运行的所有任务。因此,我们可以跨TaskManagers隔离应用程序,即,一个TaskManager只运行一个应用程序的任务。在部署应用程序时,通过利用TaskManager内部的线程并行性以及可以在每个主机上部署多个TaskManager进程,Flink在权衡性能和资源隔离上提供了很多的灵活性。我们将在第9章中详细讨论Flink集群的配置和设置。

3.1.4 Highly-Available Setup

流式应用程序通常设计为24/7运行。因此,即使涉及的某一进程发生故障,它们的执行也不能停止,这一点很重要。从失败中恢复包括两个方面:一是重新启动故障的进程,二是重新启动应用程序并恢复其状态。在本节中,我们将解释Flink如何重新启动发生故障的进程。本章后面的部分将讨论如何恢复应用程序的状态。

如前所述,Flink需要足够数量的处理插槽来执行应用程序的所有任务。现给定一个配置好的Flink,其中有四个TaskManager,每个TaskManager提供两个插槽,一个流式应用程序可以以最大并行度8(2 x 4)来执行。如果一个TaskManager发生故障,可用的插槽数就会减少到6个。在这种情况下,JobManager将要求ResourceManager提供更多的处理槽。如果这是不可能的,例如,因为应用程序运行在一个stand-alone模式的集群中,那么JobManager将收缩应用程序,在更少的插槽上执行它,直到有更多的插槽可用为止。

比TaskManager发生故障更具挑战性的问题是JobManager发生故障。JobManager控制流式应用程序的执行,并保存有关其执行的元数据,例如指向已完成的检查点的指针。如果关联的JobManager进程消失,则流式应用程序无法继续处理,这使得JobManager成为Flink中的单点故障。为了克服这个问题,Flink提供了一种高可用性模式,在原始JobManager消失的情况下,将作业的职责和元数据迁移到另一个JobManager。

Flink的高可用模式基于Apache ZooKeeper,该系统用于需要协调和一致的分布式服务。Flink使用ZooKeeper进行领导者选举【leader election】,并将其作为一个高可用且可持久化的数据存储。在高可用模式下操作时,JobManager将JobGraph和所有必需的元数据(如应用程序的JAR文件)写入到由state backend配置的远程存储系统上。此外,JobManager还会将指向存储位置的指针写到ZooKeeper的数据存储中。在应用程序执行期间,JobManager接收各个任务检查点的状态句柄(存储位置)。在检查点完成后,即,当所有任务都成功地将状态写入远程存储时,JobManager将状态句柄写入远程存储,并将指向该位置的指针写入ZooKeeper。因此,从JobManager故障中恢复所需的所有数据都存储在远程存储中,ZooKeeper保存了指向该存储位置的指针。图3-3说明了这种设计。
在这里插入图片描述
图3 - 3 高可用的Flink设置

当JobManager发生失败时,归属于其应用程序的所有任务都会自动取消。接管故障主机工作的新JobManager将会执行以下步骤。

  1. 它从ZooKeeper请求存储位置以用来从远程存储中获取JobGraph、JAR文件和应用程序最后一个检查点的状态句柄。
  2. 它从ResourceManager请求处理插槽以继续执行应用程序
  3. 它重新启动应用程序,并将所有任务的状态重置为最后一个完成的检查点时的状态。

将应用程序作为库部署在容器环境(如Kubernetes)中时,发生故障的JobManager容器或TaskManager容器可以自动重启。当在YARN或Mesos上运行时,Flink的其余进程将触发JobManager或TaskManager进程的重启。当Flink以stand-alone集群模式运行时,Flink没有提供任务用于重启故障进程的工具。因此,运行备用的JobManager和TaskManager以用于接管故障组件的工作,这是一种很好的方式。我们将在后面的第9章中讨论高可用Flink setup的配置。

3.1.5 Flink数据传输

正在运行的应用程序中的任务是不断交换数据。TaskManagers负责将数据从发送任务端传送到接收任务端。TaskManagers的网络组件在发送记录之前会先在缓冲区中收集记录,也就是说,记录并不是一个一个地发送的,而是批处理到缓冲区中。该技术是有效利用网络资源,实现高吞吐量的基础。这种机制类似于网络或磁盘IO协议中使用的缓冲技术。注意,数据缓冲机制暗示了Flink的处理模型是基于微批处理【micro-batches】的.

每个TaskManagers都有一个网络缓冲区(默认大小为32KB)池,用于发送和接收数据。如果发送端和接收端任务在单独的TaskManager进程中运行,则它们通过操作系统的网络栈进行通信。流式应用程序需要以管道方式交换数据,即,每一对TaskManagers都维护一个永久的TCP连接来交换数据。对于洗牌连接模式,每个发送端任务需要能够向每个接收端任务发送数据。TaskManagers需要一个专用的网络缓冲区,用于接收任何任务需要向其发送数据的每个任务。一旦缓冲区被填满,它就通过网络发送到接收端任务。在接收端,每个接收端任务需要为与其连接的每个发送端任务准备一个缓冲区。图3-4可视化了该体系结构。
在这里插入图片描述

图3-4 Flink数据传输
图中展示了四个发送端和四个接收端任务。每个发送端任务有四个网络缓冲区,用于向每个接收端任务(接收端也有4个)发送数据,每个接收端任务也有四个缓冲区用于接收数据。需要发送到其他TaskManagers的缓冲区会通过相同的网络连接进行多路复用。为了实现流畅的管道数据交换,TaskManagers必须能够提供足够的缓冲区来并发的处理所有传出连接和传入连接。在洗牌或广播模式连接的情况下,每个发送端任务需要为每一个接收端任务准备一个缓冲区,也就是说,所需缓冲区的数量是所涉及的运算符的并行度的平方。

如果发送端和接收端任务在同一个TaskManager进程中运行,发送端任务将输出的记录序列化到字节缓冲中,并在缓冲被填满后将该其放入到队列中。接收端任务从队列中获取缓冲并对传入的记录反序列化。因此,不涉及网络通信。在TaskManager本地任务间序列化数据记录,有很多优点:它将任务解耦,并允许在任务中使用可变对对象,这可以显著提高性能,因为它减少了对象实例化和垃圾回收。一旦对象被序列化,就可以安全地修改它了。

另一方面,序列化可能会造成大量的计算开销。因此,Flink可以在特定条件下将多个DataStream操作符链接到一个任务中。同一任务中的操作符通过通过嵌套函数调用传递对象来进行通信,从而避免了序列化的性能消耗。第10章将更详细地讨论操作符链接的概念。

3.1.6高吞吐量和低延迟

通过网络连接发送单个数据记录是很低效的,并且会造成很大的性能花销。缓冲是充分利用网络连接带宽的一种强制性技术。在流处理上下文中,缓冲的一个缺点是它增加了延迟,因为数据记录是首先被收集到缓冲区中,而不是立即被分发。如果发送端任务很少为特定的接收端任务生成数据记录,则可能需要很长的时间才能填充满缓冲器,然后再发送相应的缓冲区。因为这将导致较高的处理延迟,所以Flink确保每个缓冲区在一段时间后被发送,而不管它被填充了多少。这个超时可以解释为网络连接所增加的延迟的上限。但是,该阈值并不作为作业的绝对延迟SLA,因为作业可能涉及多个网络连接,而且它也不考虑实际处理所造成的延迟。

3.1.7 背压流量控制

当流式应用程序接收的是一个高容量的数据流时,那么很容易就会出现任务处理速度跟不上数据流到达速度的情况。如果输入流的容量对于分配给某个操作符的资源量来说过高,或者操作符的输入速率显著变化并导致高负载峰值,就可能发生这种情况。无论操作符为什么不能处理其数据输入,这种情况都不应该成为流处理器终止应用程序的原因。相反,流处理器应该优雅地将流应用程序的数据输入速率控制在应用程序可以处理数据的最大速度。通过适当的监控基础设施,可以很容易地检测到节流情况,通常可以通过添加更多的计算资源和增加瓶颈操作符的并行度来解决节流问题。所述的流量控制技术称为背压,是流处理器的一个重要特性。

得益于其网络层的设计,Flink天生支持背压。 图3-5说明了当接收端任务无法以发送端任务数据发送的速率处理其输入数据时,网络堆栈的行为。
在这里插入图片描述
图3-5 Flink的背压

图中展示了在不同机器上运行的发送端任务和接收端任务。

  1. 当应用程序的数据输入速率增加时,发送端任务可以承受这种负载,但是接收端任务开始落后,不再能够按照记录到达的速率处理记录。现在接收端的TaskManager开始使用缓冲池中的缓冲区来缓冲接收到的数据。在某一时刻,当接收端TaskManager的缓冲池耗尽时,就无法继续缓冲到达的数据。
  2. 发送端TaskManager 也开始用缓冲池中的缓冲区来缓冲输出数据,直到它自己的缓冲池为空为止。
  3. 最后,在有新的可用缓冲区出现之前,发送端任务都会阻塞,且不能发射更多的数据。被阻塞任务由于其接收者较慢的接收行为,使得其自身也变成一个较慢的接收者,并反过来影响其上游的任务。这种放缓会逐渐升级到流式应用程序的发生器任务上【source】。最终,整个应用程序被减慢到最慢的操作符的处理速度。

3.2 事件时间处理【Event Time Processing】

在第2章中,我们强调了时间语义对于流处理应用程序的重要性,并解释了处理时间【processing-time】和事件时间【event time】之间的区别。虽然处理时间【processing-time】很容易理解,因为它基于负责数据处理的机器的本地时间,但是它会产生一些任意的、不一致的和不可重现的结果。相反,事件时间【event time】语义产生可重现的的和一致性的结果,而这正是许多流处理用例的一个硬性要求。然而,与支持处理时间【processing-time】语义的应用程序相比,事件时间【event time】应用程序需要一些额外的配置。此外,支持事件时间【event time】的流处理器的内部结构,要比只纯粹的支持处理时间予以的流处理器复杂的多。

Flink为常见的事件时间处理操作【event-time processing operations】提供了直观且易于使用的原语,但也公开了更具有表现力的API,以便使用自定义操作符实现更高级的基于事件时间的应用程序。对于这种高级应用程序,通常都需要我们深入的理解Flink的内部时间处理。上一章介绍了两个概念,Flink正是利用它们来提供的事件时间语义:时间戳和水印。在下面,我们将描述Flink内部是如何实现和处理时间戳和水印,来支持具有事件时间语义的流式应用程序。

3.2.1 Timestamps

Flink的基于事件时间的流式应用程序所处理的所有记录都必须具有一个时间戳。时间戳将记录与一个特定的时间点关联起来。通常,时间戳引用的是由记录编码的事件所发生的时间点。但是,应用程序可以自由选择时间戳的含义,只要流中的记录的时间戳大致随着流的前进而上升即可。正如在第2章中所提到的,现实中的用例基本都可以在一定程度上给出一个无序时间戳。

当Flink处理基于事件时间模式的数据流时,它根据记录的时间戳来评估/计算基于时间的操作符。例如,时间窗口【time-window】操作符根据记录所关联的时间戳将其分配给对应的窗口。Flink将时间戳编码为16字节长的long值,并将它们作为元数据附加到记录中。它的内置操作符会将该long值解释为具有毫秒精度的Unix时间戳,即,自1970-01-01-00:00:00.000以来的毫秒数。但是,自定义操作符可以有自己的解释,例如,将精度调整为微秒。

3.2.2 水印

除了记录的时间戳之外,一个基于事件时间的Flink流式应用程序还必须提供水印。水印用于获得基于事件时间的流式应用程序中每个任务的当前事件时间。基于时间的操作符使用这个时间来触发计算并取得进展。例如,当操作符的事件时间过了窗口的终止边界时间时,时间窗口操作符会去完成窗口计算,并发射计算结果。
PS:一个时间窗口总有起始边界和终止边界:如下面的方括号,即代表边界 [ data1,data2,data3 ] [data11,data12,data13,…]

在Flink中,水印是以一个特殊的记录的形式来实现的,该记录持有一个时间戳long值。水印记录与带有时间戳的正规记录一样,都是在数据流中流动,如图3-6所示。
在这里插入图片描述
图3-6 A stream with timestamped records and watermarks.

水印有两个基本特性:

  1. 水印必须单调递增,以确保任务的事件时间时钟【event-time clocks】始终在前进,而不是在倒退。
  2. 水印与记录的时间戳有关。带有时间戳为 t 的水印表示所有后续记录的时间戳都应该满足:时间戳 > t。

第二个特性用于处理时间戳无序的流,例如图3-6中时间戳为3和5的数据记录。基于时间的操作符的任务收集和处理时间戳可能无序的数据记录,并在其事件时间时钟(由接收到的水印推进)指示不需要更多具有相关时间戳的记录时完成计算。当一个任务接收到一条违反水印属性的记录,并且该记录的时间戳比之前接收到的水印的时间戳小时,那么该记录所属的计算可能已经完成。这些记录称为延迟记录【late records】。Flink提供了不同的机制来处理延迟记录,我们将在第六章讨论。

水印的一个非常有趣的特性是,它允许应用程序来控制结果的完整性和延迟。当水印比较紧密时,即接近于记录的时间戳,这会导致较低的处理延迟,因为任务只会短暂地等待更多记录到达,然后完成计算。与此同时,结果的完整性可能会受到影响,因为可能有更多的记录没有包括在结果中,并将被视为延迟记录【late records】。相反,宽松的水印则增加了处理延迟,但提高了结果的完整性。

3.2.3水印和事件时间

在本节中,我们将讨论操作符如何处理水印记录【watermark record】。在Flink中水印是以一种特殊的记录【record】的形式来实现的。任务有一个负责维护计时器的内部定时服务。计时器可以注册到该定时服务上,以便在将来的某个特定时间点上执行计算。例如:时间窗口任务会为其每个活动【active】的窗口的结束时间点注册一个计时器,以便在事件时间超过窗口的终止边界时,可以最终结束一个窗口。

当任务接收到水印时,它执行以下步骤。

  1. 该任务基于水印的时间戳更新其内部的事件时间时钟【event-time clock】。
  2. 任务的定时服务会标记所有计时器时间小于更新后的事件时间时钟【event-time clock】的计时器,该任务为每个过期的计时器调用一个回调函数,该函数可以执行计算并发射数据记录。
  3. 该任务基于更新后的事件时间,发射一个水印

Flink通过DataStream API限制对时间戳或水印的访问。除了ProcessFunction,任何函数都不能读取或修改数据记录的时间戳或水印。ProcessFunction可以读取当前所处理的数据记录的时间戳,请求操作符的当前事件时间时钟,和注册计时器。所有其他的函数都没有公开API来设置发射的记录的时间戳、操纵一个任务的事件时间时钟或发射水印。相反,基于时间的DataStream操作符的任务在内部设置待发射的数据记录的时间戳,以确保它们与发出的水印正确对齐。例如,时间窗口操作符任务将窗口的结束时间点作为时间戳附加到窗口计算发出的所有记录上,然后再使用触发窗口计算时的时间戳发出水印。

我们在前面解释过,任务在接收到新的水印时会发射水印并更新其事件时间时钟。如何真正做到这一点值得详细讨论。正如在第2章中所讨论的,Flink通过对流进行分区,并通过单独的操作符任务处理每个分区,从而并行地处理数据流。一个分区是就是一个流,流中的数据是带有时间戳属性的数据记录和水印。操作符的任务可以从一个或多个输入分区接收数据记录和水印,并将数据记录和水印发送到一个或多个输出分区,当然这取决于操作符如何与其前置【predecessor 】或后置【successor 】操作符连接。下面我们将详细描述一个任务如何向多个接收端任务发出水印,以及如何通过从发送端任务接收到的水印来计算事件时间时钟。

任务为每个输入分区维护一个分区水印。当它从一个分区接收到一个水印时,它将各自的分区水印更新为接收到的水印和当前分区水印的最大值。随后,该任务将其事件时间时钟更新为所有分区水印的最小值。如果事件时间时钟前进,任务将处理所有触发的计时器,并通过向所有连接的输出分区发出相应的水印,最终将其新的事件时间广播给所有下游任务。

图3-7显示了具有四个输入分区和三个输出分区的任务如何接收水印、更新其分区水印和事件时间时钟并发出水印。
在这里插入图片描述
图3 - 7 用水印更新任务的事件时间

具有两个或多个输入流的操作符(如Union或CoFlatMap操作符)的任务需要将其事件时间时钟计算为所有分区水印的最小值,即,它们并不区分输入流不相同的分区水印。因此,这多个输入流中的记录都基于相同的事件时间时钟进行处理。

Flink的水印处理和传播算法保证了操作符任务能够发出正确对齐的由时间戳标记的数据记录和水印。但是,它依赖于这样一个事实,即所有分区都不断地提供增长的水印。只要一个分区没有推进它的水印增长,或者变得完全空闲,没有发送任何记录或水印,那么任务的事件时间时钟就不会前进,任务的计时器也不会触发。对于依赖于时钟推进来执行计算和状态清理的这种依赖时间的操作符来说,这种情况是有问题的。因此,如果任务没有从所有输入任务(即相对于该任务的发送端任务)中定期接收新的水印,那么基于时间的操作符的处理延迟和状态大小会显著增加。

对于具有两个输入流且水印明显有分歧的操作符而言,也会出现类似的副作用。具有两个输入流的任务的事件时间时钟将对应于较慢流的水印,通常在事件时间时钟允许处理它们之前,较快流的数据记录或中间结果将被缓冲在状态中。

3.2.4 时间戳分配和水印生成

到目前为止,我们已经解释了什么是时间戳和水印,以及Flink如何在内部处理它们。然而,我们还没有讨论它们源于哪里。时间戳和水印通常是在流式应用程序接收流时分配和生成的。由于时间戳的选择是特定于应用程序的,而水印取决于时间戳和流的特征,因此应用程序必须显式地分配时间戳并生成水印。Flink DataStream应用程序可以以三种方式为流分配时间戳和生成水印。

  • 时间戳和水印可以由SourceFunction分配和生成,即,当流被摄取到应用程序中时。发生器函数【source function】发出数据记录流。记录可以与相关的时间戳一起发出,水印可以作为特殊记录在任何时间点发出。如果发生器函数【source function】(暂时的)不再发出水印,它可以声明自己为空闲的。Flink将空闲发生器函数【source function】产生的流分区排除在后续操作符的水印计算之外。这可以用来解决水印不推进的问题,正如前面一节所讨论的。发生器函数【source function】将在第7章中更详细地讨论。
  • DataStream API提供了一个名为AssignerWithPeriodicWatermarks的用户自定义函数,该函数从每个记录中提取一个时间戳,并定期查询当前的水印。提取的时间戳被分配给相应的记录,而查询到的水印被摄取到流中。这个函数将在第6章中讨论。
  • 另一个用户自定义的函数也可以从每个记录中提取时间戳。与具有周期性水印的AssignerWithPeriodicWatermarks函数相比,该函数可以(但不需要)从每个记录中提取水印。这个函数称为AssignerWithPunctuatedWatermarks ,可以用来生成水印(该水印被编码在输入记录中,该函数可以从记录中提取出水印)。第六章也将讨论这个函数

用户自定义的时间戳分配函数通常尽可能地应用于发生器操作符【source operator】,因为在操作符处理流之前,更容易推断出时间戳的无序性。这也是为什么在流式应用程序中间覆盖现有的时间戳和水印通常不是一个好主意的原因,尽管这在用户自定义的函数中是可能的。

3.3 State Management

在第2章中,我们指出大多数流式应用程序是有状态的。许多操作符会不断读取和更新某些状态,例如在窗口中收集的记录、输入源的读取位置,或者自定义的特定于应用程序的操作符状态,如机器学习模型。Flink对所有状态——不管内置的还是用户定义的操作符——都一视同仁。在本节中,我们将讨论Flink支持的不同类型的状态。我们还将解释state backends 是如何存储和维护状态的,以及如何通过重新分布状态来对有状态的应用程序进行缩放。

一般来说,由任务维护并用于计算函数结果的所有数据都属于任务的状态【state】。您可以将状态【state】看作是任务的业务逻辑访问的任何本地或实例变量。图3-8图解了任务及其状态的交互。
在这里插入图片描述
图3 - 8 有状态的流处理任务

任务接收一些输入数据。在处理数据时,任务可以读取和更新其状态,并根据输入数据和状态计算出结果。举个简单的例子,一个任务它不断地计算它接收到多少条记录。当任务接收到一条新记录时,它访问该状态以获取当前计数,增加计数,更新状态,并将新的计数值发射出去。

读取和写入状态的应用程序逻辑通常很简单。然而,高效可靠的状态管理更具挑战性。这些挑战包括处理非常大的状态(可能超过内存),并确保在发生故障时不会丢失任何状态。与状态一致性、故障处理以及高效存储和高效检索相关的所有问题都由Flink负责并处理,以便开发人员能够专注于应用程序的逻辑。

在Flink中,状态总是与特定的操作符相关联。为了使Flink的运行时【runtime】知道操作符的状态,操作符需要注册其状态。有两种类型的状态,操作符状态【Operator State】和键控状态【Keyed State】,它们可以从不同的作用域访问,我们将在下面的部分中进行讨论。

3.3.1 操作符状态【Operator State】

操作符状态【Operator State】的作用域是操作符任务。这意味着由同一并行任务处理的所有记录都可以访问相同的状态。操作符状态不能被相同或不同操作符的其他任务访问。图3-9图解了任务如何访问操作符的状态。
在这里插入图片描述
图3 - 9 带有操作符状态的任务

Flink为操作符状态提供了三个原语:

  • List State :将状态表示为条目列表。
  • Union List State:也将State表示为条目列表。它与常规的List State的不同之处在于,在失败或者应用程序从某个保存点启动的情况下,状态是如何恢复的。我们将在本节稍后讨论这种差异。
  • Broadcast State :是为操作符的每个任务的状态相同的特殊情况而设计的。此属性/特性可在检查点期间或者重新缩放操作符时使用。这两个方面将在本章后面几节中讨论。

3.3.2 Keyed State

键控状态的作用域限定在操作符输入流的数据记录上定义的键。Flink为每个键值维护一个状态实例,并将具有相同键的所有记录分区到维护该键状态的操作符任务上。当一个任务处理一条记录时,它会自动将状态访问范围限定到当前记录的键。因此,具有相同键的所有记录访问的是相同的状态。图3-10显示了任务如何与键控状态交互。
在这里插入图片描述
图3 - 10 带键控状态的任务

您可以将键控状态看作键值对映射,它在一个操作符的所有并行任务之上,按照键进行分区(或切片)。Flink为键控状态提供了不同的原语,这些原语决定了为这个分布映射中的每个键所存储的值的类型。我们将简要讨论最常见的键控状态原语。

  • Value State:为每个键存储一个任意类型的值。复杂的数据结构也可以存储为值状态
  • List State:存储每个键的值列表。列表中的条目可以是任意类型的。
  • Map State:为每个键存储一个键值对映射。该映射的键和值可以是任意类型的。

状态原语将状态的结构暴露给Flink,并支持更高效的状态访问。

3.3.3 State Backends

有状态的操作符的任务通常为每个传入数据读取和更新它的状态。由于高效的状态访问对于低延迟的处理记录至关重要,因此每个并行任务都会在本地维护其状态,以确保本地状态访问。状态的存储、访问和维护的具体方式由称为State Backends的可插拔组件决定。State Backends主要负责两个方面,本地状态管理和指向远程位置的检查点状态。

对于本地状态管理,State Backends确保键控状态的作用域正确地被限定到当前键,并存储和访问所有键控状态。Flink提供的State Backends通过将键控状态作为对象,存储在JVM堆内存数据结构中来管理键控状态。还有一种State Backends,它会序列化状态对象,并将它们放入RocksDB,RocksDB将它们写入本地硬盘。虽然第一个选项提供非常快速的状态访问,但它与内存大小有关。访问由RocksDB State Backends存储的状态比较慢,但是它的状态可以增长得非常大(因为它与硬盘大小有关,而不是内存)。

状态检查点非常重要,因为Flink是一个分布式系统,而状态仅在本地维护。TaskManager进程(及其上运行的所有任务)在任何时候都可能失败,因此必须将其存储视为不稳定的。State Backends负责将任务的状态检查点指向远程持久存储。检查点的远程存储可以是分布式文件系统,也可以是数据库系统。State Backends在状态的检查点方式上有所不同。例如,RocksDB State Backends支持异步和增量检查点,这大大降低了状态非常大的检查点开销。

我们将在第8章中更详细地讨论不同的State Backends及其优缺点。

3.3.4 缩放有状态的操作符

流式应用程序的一个常见需求是,由于输入速率的增加或减少,需要调整操作符的并行性。虽然缩放无状态操作符很简单,但是调整有状态的操作符的并行性则更具挑战性,因为它们的状态需要重新分区并重新分配给更多或更少的并行任务上。Flink支持四种模式来缩放不同类型的状态。

使用键控状态的操作符通过将键重新分区到更少或更多的任务来进行伸缩。但是,为了提高任务之间必要的状态传输效率,Flink不重新分配该特定的键。相反,Flink将键组织在所谓的键群【Key Groups】中。一个键群【Key Groups】由一个键的分区和为任务分配键的Flink单元组成(见下面的图3-1中的键群)。图3-11显示了键控状态如何在键群【Key Groups】中重新分区。
在这里插入图片描述
图3-11 Scaling an operator with keyed state out and in

使用操作符列表状态【operator list state】的操作符通过重新分配列表条目来进行缩放。从概念上讲,收集所有并行操作符任务的列表条目,并将其均匀地重新分配到由于缩放导致数量变大或者变小的任务中。如果列表条目少于操作符的新的并行度,一些任务将不会接收到状态,而必须从头开始重新构建它。图3-12显示了操作符列表状态【 operator list state】的重新分配。
在这里插入图片描述
Figure 3-12. Scaling an operator with operator list state out and in.

使用操作符联合列表状态【 operator union list state 】的操作符通过将状态条目列表广播向每个任务来进行伸缩。然后,任务可以选择使用哪些条目和丢弃哪些条目。图3-13显示了操作符联合列表状态【operator union list state】的重新分配。
在这里插入图片描述
Figure 3-13. Scaling an operator with operator union list state out and in.

使用操作符广播状态【 operator broadcast state】的操作符通过将状态复制到新任务来扩展规模。这是因为广播状态确保所有任务具有相同的状态。在收缩规模的情况下,剩余的任务将被简单地取消掉,因为状态已经被复制,不会丢失。图3-14题解了操作符广播状态【 operator broadcast state】的重新分配。
在这里插入图片描述
Figure 3-14. Scaling an operator with operator broadcast state out and in.

3.4 Checkpoints, Savepoints, and State Recovery

Flink是一个分布式数据处理系统,且可以处理如被杀死的进程,故障的机器和中断的网络连接等这样的故障。 由于任务在本地维护其状态,因此Flink必须确保此状态不会丢失并在发生故障时保持一致。

在本节中,我们将介绍Flink的轻量级检查点和恢复机制,以确保唯一状态一致性【exactly-once state consistency】。 我们还讨论了Flink独特的保存点特性,它是一种“瑞士军刀”式工具,可以解决运行流应用程序的许多挑战。

3.4.1 Consistent Checkpoints

Flink的恢复机制基于应用程序状态的一致检查点。有状态的流式应用程序的一致检查点是其每个任务在某一时间点的状态的副本,需要注意的是,在这个时间点,所有的任务需要处理完成所有的输入。 这意味着可以通过一个简单的算法的步骤来做解释,该算法使用了应用程序的一致检查点。

  1. 暂停对所有输入流的摄取。
  2. 等待所有传输中的数据被完全处理完成,即,所有任务都已经处理了所有的输入数据。
  3. 通过将每个任务的状态复制到远程持久存储来获得一个检查点。 所有任务完成副本后,检查点即完成。
  4. 恢复对所有输入流的摄取。
    请注意,Flink没有实现这个简单的算法。我们将在本节稍后介绍Flink更复杂的检查点算法。
    图3-15显示了一个简单示例应用程序的一致检查点。
    在这里插入图片描述

Figure 3-15. A consistent checkpoint of a streaming application.

该应用程序具有一个唯一的发生器任务【source task】,该任务消费一个数字递增的输入数据流(即1,2,3,4…的数据流)。该数据流被划分为奇数流和偶数流。求和操作符通过运行两个任务,来计算所有偶数和奇数的运行总和。 发生器任务【source task】将其输入流的当前偏移量存储为状态,负责计数求和的任务则将当前计数和的值保存为状态。 在图3-15中,当输入偏移量为5时,Flink执行了检查点,此时的计数和分别为6(2 + 4)和9(1 + 3 + 5)。

3.4.2 从一致检查点恢复

在流应用程序的执行过程中,Flink周期性地获得应用程序状态的一致检查点。在失败的情况下,Flink使用最新的检查点一致地恢复应用程序状态并重新启动处理。图3-16图解了恢复的过程。
在这里插入图片描述
Figure 3-16. Recovering an application from a checkpoint.
应用程序的恢复分为三个步骤。

  • 重启所有失败的任务。
  • 将整个应用程序的状态重置为最新检查点的状态,即重置每个任务的状态。
  • 恢复所有任务的处理。

这种检查点和恢复机制能够为应用程序的状态提供唯一一致性【exactly-once consistency】,前提是所有操作符都通过检查点并恢复它们的所有状态,并且所有输入流都被重置到检查点时所使用的位置。数据源是否也能够重置其输入流,这取决于它的实现以及使用该流的外部系统或接口。例如,像Apache Kafka这样的事件日志可以提供来自流以前偏移量的记录。相反,我们不能重置从Socket中消费的流,因为一旦数据被消费了,Socket就会丢弃数据。因此,如果所有输入流都被可重置数据源消费,那么应用程序只能在唯一状态一致性【exactly-once state consistency】下运行。

当应用程序从检查点重启后,其内部状态与检查点时完全相同。然后,它开始消费和处理检查点与故障之间所处理过的所有数据。虽然这意味着Flink操作符会对一些消息进行两次处理(在失败之前和失败之后,我们对检查点与故障时间点之间的数据记录操作了两次),但是该机制仍然能够实现唯一状态一致性【exactly-once state consistency】,因为所有操作符的状态都被重置到一个尚未看到过此数据的点。

我们还需要指出,Flink的检查点和恢复机制只会重置流式应用程序的内部状态。一旦恢复完成,一些记录将被多次处理。根据应用程序的接收器操作符【sink operators】,可能会出现将一些结果记录多次发送到下游系统的情况,例如事件日志、文件系统或数据库。对于选定的系统,Flink提供了具有唯一输出【exactly-once output】特性的接收器函数【sink functions】,例如在检查点完成时才提交待发射的记录。另一种适用于许多常用接收器系统的方法是幂等更新。第7章将详细讨论实现端到端唯一性【end-to-end exactly-once 】的应用程序的挑战和解决方案。

Flink的轻量级检查点算法

Flink的恢复机制基于应用程序的一致检查点。从流应用程序获取检查点的简单(天真)的方法,也就是:暂停、检查点和恢复应用程序这三重唱,它当中的“stop-the-world(沉默)”行为是我们所不能接受的,即使是可以容忍中等延迟需求的应用程序。相反,Flink基于著名的用于分布式快照的Chandy-Lamport算法,实现了一套检查点算法。该算法不会暂停整个应用程序,而是对单个任务的检查点进行解耦,使一些任务继续处理数据,而另一些任务则持久化它们的状态。下面我们将解释这个算法是如何工作的。

Flink的检查点算法基于一种称为检查点屏障【checkpoint barrier】的特殊类型的数据记录来实现的。与水印类似,检查点屏障【checkpoint barrier】由发生器操作符【source operator】注入到常规的数据记录流中,不能超越任何记录,也不会被任何其他记录通过。一个检查点屏障【checkpoint barrier】携带一个检查点ID来标识它所属的检查点,并在逻辑上将流分为两部分。所有在屏障之前的记录导致的状态修改都包含在检查点中,所有在屏障之后的记录导致的修改都包含在后面的检查点中。

我们使用一个简单的流应用程序示例逐步解释该算法。应用程序由两个发生器任务【 source tasks】组成,这两个发生器任务【 source tasks】消费一个数值递增的数据流。发生器任务【 source tasks】的输出被分区为偶数和奇数流。每个分区由一个任务处理,该任务计算所有接收到的数字的和,并将更新后的和转发到接收器【sink】。应用程序如图3-17所示。
在这里插入图片描述
Figure 3-17. An example streaming application with two stateful sources, two stateful tasks, and two stateless sinks.

JobManager通过向每个数据发生器任务【data source task】发送带有新检查点ID的消息来开启一个检查点,如图3-18所示。

在这里插入图片描述
Figure 3-18. The JobManager initiates a checkpoint by sending a message to all sources.

当数据发生器任务【data source task】接收到该消息时,它暂停向下游发送数据记录,触发state backend中的本地状态的检查点,并通过所有输出流的分区来广播带有检查点ID的检查点屏障【checkpoint barrier】。一旦任务检查点完成后, state backend就会通知任务,然后任务会在JobManager上确认检查点。在发出所有屏障之后,发生器【souce】将继续其常规操作。通过将屏障注入到其输出流中,发生器函数定义了在流中哪个位置执行检查点。图3-19显示了两个源任务检查其本地状态和发出检查点障碍后的流应用程序。
在这里插入图片描述
Figure 3-19. Sources checkpoint their state and emit a checkpoint barrier.

发生器任务【 source tasks】发出的检查点屏障将发送到后续任务。 与水印类似,检查点屏障被广播到所有连接的并行任务上。 检查点屏障必须被广播,以确保每个任务从其每个输入流(即所有下游连接的任务)接收到一个检查点。 当任务收到新检查点的屏障时,它会等待检查点的所有屏障到达。 在等待的同时,它继续处理尚未提供检查点屏障的流分区中的数据记录。 通过已转发屏障的分区到达的记录不得处理,需要进行缓冲。等待所有屏障到达的过程称为屏障对齐【barrier alignment】,如图3-20所示。
译者注:发送器任务发出通过分区的数据流发送屏障到下游任务,当下游任务接收到新的检查点屏障时,它需要等待该任务所处理的所有数据流分区均发送来屏障。已经发送来屏障的数据流分区,会缓冲屏障后到达的数据,而还没有发送来屏障的数据流分区,则继续执行计算,直到屏障到达。
在这里插入图片描述
Figure 3-20. Tasks wait until they received a barrier on each input stream. Meanwhile they bu er records of input streams for which a barrier did arrived. All other records are regularly processed.

一旦所有屏障都到达,任务会在State Backend上初始化一个检查点,并将检查点屏障广播给所有下游连接的任务,如图3-21所示。
在这里插入图片描述
Figure 3-21. Tasks checkpoint their state once all barriers have been received. Subsequently they forward the checkpoint barrier.

一旦发出所有检查点屏障,任务就开始处理缓冲的记录(即在屏障对齐阶段缓冲的数据记录)。当所有缓冲的数据记录被发射出去后,任务将继续处理其输入流。图3-22显示了此时的应用程序。
在这里插入图片描述
Figure 3-22. Tasks continue their regular processing after the checkpoint barrier was forwarded.
最后,检查点屏障抵达一个接收器任务【sink task】。当接收到屏障时,接收器任务【sink task】执行屏障对齐,然后对自己的状态执行检查点操作,并向JobManager确认屏障的接收。JobManager在收到来自应用程序所有任务的检查点确认后,将应用程序的检查点记录为已完成。图3-23显示了检查点算法的最后一步。如前所述,完成的检查点可用于从故障中恢复应用程序。
在这里插入图片描述
Figure 3-23. Sinks acknowledge the reception of a checkpoint barriers to the JobManager. A checkpoint is complete when all sinks acknowledge the barriers of a checkpoint.

我们此处所讨论的算法在不停止整个应用程序的情况下,从流应用程序中生成一致的分布式检查点。但是,它有两个属性可以增加应用程序的延迟。 Flink的实现具有一些调整功能,可以在某些条件下提高应用程序的性能。

第一个属性点是检查任务状态的过程。在此步骤中,任务被阻塞,其输入被缓冲。由于操作符的状态可能变得非常大,而且检查点意味着通过网络将数据发送到远程存储系统,因此检查点很容易花费几秒钟,这对于延迟敏感的应用程序来说太长了。在Flink的设计中, State Backend负责执行检查点。复制任务状态的具体方式取决于 State Backend实现,可以对其进行优化。例如,RocksDB State Backend支持异步和增量检查点。当触发检查点时,RocksDB State Backend会对自上一个检查点以来的所有状态修改(由于RocksDBs的设计,这是一个非常轻量且快速的操作)进行本地快照处理,并立即返回,以便任务可以继续处理。后台线程异步地将本地快照复制到远程存储,并在完成检查点后通知任务。异步检查点大大减少了将状态复制到远程存储的延迟。而且增量检查点减少了要传输的数据量(因为我们只传输了增量,而并不是全量)。

延迟增加的另一个原因可能来自于屏障对齐步骤中的记录缓冲。对于那些需要非常低的延迟且可以容忍至少一次状态保证【 at-least-once state guarantees】的应用程序而言,Flink可以配置为在缓冲区对齐期间处理所有到达的记录,而不是缓冲那些已经屏障已经到达的输入流分区中的记录。一旦检查点的所有屏障都到达,操作符就会对状态执行检查点操作,现在状态可能还包括属于下一个检查点的记录所引起的修改。在失败的情况下,这些记录将被再次处理,这意味着检查点需要提供至少一次一致性保证【at-least-once consistency guarantees】,而不是唯一一致性保证【exactly-once consistency guarantees】。

保存点

Flink的恢复算法是基于状态检查点的。检查点会定期的被生成,并在新检查点完成时自动丢弃。它们的唯一目的是确保在失败的情况下,应用程序可以重新启动而不会丢失状态。然而,应用程序状态的一致性快照可以用于更多目的。

Flink最有价值和独特的特性之一是保存点。 原则上,保存点是具有一些额外元数据的检查点,并使用与检查点相同的算法创建。 Flink不会自动生成保存点,但用户(或外部调度程序)必须触发其创建。 Flink也不会自动清理保存点。

给定一个应用程序和一个与之兼容的保存点,您可以从保存点启动应用程序,保存点将初始化应用程序的状态到保存点的状态,并从保存点所在的位置运行应用程序。虽然这听起来基本上与使用检查点从故障中恢复应用程序相同,但故障恢复实际上只是一种特殊情况,因为它在相同的集群上以相同的配置启动相同的应用程序。从保存点启动应用程序可以做更多的工作。

  • 您可以从保存点启动另一个不同但兼容的应用程序。因此,您可以修复应用程序逻辑中的bug,并重新处理流式发生器【streaming source】所可以提供的任意数量的事件,以便修复结果。修改后的应用程序还可以用于运行具有不同业务逻辑的A/B测试或假设场景。注意应用程序和保存点必须是兼容的,即,应用程序必须能够加载保存点的状态。
  • 您可以以不同的并行度启动相同的应用程序,并将应用程序扩展或缩小。
  • 您可以在不同的集群上启动相同的应用程序。这允许您将应用程序迁移到更新的Flink版本,或者迁移到不同的集群或数据中心。
  • 您可以使用保存点来暂停一个应用程序,并在稍后恢复它。
  • 您还可以将保存点设置版本,并并归档应用程序的状态。

由于保存点是如此强大的特性,许多用户定期生成保存点以便能够适时的回溯。我们所看到的保存点最有趣的应用之一是将流式应用程序迁移到提供最低实例价格的数据中心上。

3.5 Summary

在本章中,我们讨论了Flink的高级架构及其网络堆栈的内部结构、事件时间处理模式、状态管理和故障恢复机制。在设计高级流应用程序、设置和配置集群、操作流应用程序以及对其性能进行推理时,您将发现了解这些内部构件的知识非常有用。

  • 第10章将讨论DataStream API如何允许控制任务的分配和分组。
  • 批处理应用程序除了管道通信外,还可以通过在发送端收集传出数据来交换数据。发送端任务完成后,数据将通过临时TCP连接批量发送到接收方
  • TaskManager确保每个任务至少有一个传入缓冲区和一个传出缓冲区,并遵守额外的缓冲区分配约束,以避免死锁并保持流畅的通信。
  • 第6章将更详细地讨论ProcessFunction 。

猜你喜欢

转载自blog.csdn.net/qq_31179577/article/details/84590263