本文将以WordCount的案例为主线,主要介绍Flink的设计和运行原理。关于Flink WordCount程序可以参考我之前的文章:Flink入门:读取Kafka实时数据流,实现WordCount。阅读完本文后,读者可以对Flink的分布式运行时有一个全面的认识。
1.1 Flink作业的逻辑视图
在大数据领域,词频统计(WordCount)程序就像是一个编程语言的HelloWorld程序,它展示了一个大数据引擎的基本规范。麻雀虽小,五脏俱全,从这个样例中,我们可以一窥Flink设计和运行原理。
图 1 Flink样例程序示意图
如图 1所示,程序分为三大部分,第一部分读取数据源(Source),第二部分对数据做转换操作(Transformation),最后将转换结果输出到一个目的地(Sink)。 代码中的方法被称为算子(Operator),是Flink提供给程序员的接口,程序员需要通过这些算子对数据进行操作。Source算子读取数据源中的数据,数据源可以是数据流、也可以存储在文件系统中的文件。Transformation算子对数据进行必要的计算处理。Sink算子将处理结果输出,数据一般被输出到数据库、文件系统或下一个数据流程序。
我们可以把算子理解为1 + 2 运算中的加号,加号(+)是这个算子的一个符号表示,它表示对数字1和数字2做加法运算。同样,在Flink或Spark这样的大数据引擎中,算子对数据进行某种操作,程序员可以根据自己的需求调用合适的算子,完成所需计算任务。常用的算子有map、flatMap、keyBy、timeWindow等,它们分别对数据流执行不同类型的操作。
我们先对这个样例程序中各个算子做一个简单的介绍,关于这些算子的具体使用方式将在后续文章中详细说明。
flatMap对输入进行处理,生成零到多个输出。这里是一个简单的分词过程,对一行字符串按照空格切分,生成一个(word, 1)的二元组。
keyBy根据某个Key对数据重新分组。本例中是将flatMap生成的二元组(word, 1)中第一项作为Key,相同的单词会被分到同一组。
timeWindow是时间窗口函数,用来界定对多长时间之内的数据做统计。
sum为求和函数。sum(1)表示对二元组中第二个元素求和,因为经过前面的keyBy,所有相同的单词都被分到了一起,因此,在这个分组内,将单词出现次数做加和,就得到出现的总次数。
图 2 WordCont程序的逻辑视图
在程序实际运行前,Flink会将用户编写的代码做一个简单处理,生成一个如图2所示的逻辑视图。图 2展示了WordCount程序中,数据从不同算子间流动的情况。图中,圆圈代表算子,圆圈间的箭头代表数据流,数据流在Flink程序中经过不同算子的计算,最终生成为目标数据。其中,keyBy、timeWindow和sum共同组成了一个时间窗口上的聚合操作,被归结为一个算子。我们可以在Flink的Web UI中,点击一个作业,查看这个作业的逻辑视图。
对于词频统计这个案例,逻辑上来讲无非是对数据流中的单词做提取,然后使用一个Key-Value结构对单词做词频计数,最后输出结果即可,这样的逻辑本可以用几行代码完成,改成使用算子形式,反而让新人看着一头雾水,为什么一定要用算子的形式来写程序呢?实际上,算子进化成当前这个形态,就像人类从石块计数,到手指计数,到算盘计数,再到计算机计数这样的进化过程一样,尽管更低级的方式可以完成一定的计算任务,但是随着计算规模的增长,古老的计数方式存在着低效的弊端,无法完成更高级别和更大规模的计算需求。试想,如果我们不使用大数据引擎提供的算子,而是自己实现一套上述的计算逻辑,尽管我们可以快速完成当前的词频统计的任务,但是当面临一个新计算任务时,我们需要重新编写程序,完成一整套计算任务。我们自己编写代码的横向扩展性可能很低,当输入数据暴增时,我们需要做很大改动,以部署在更多机器上。
大数据引擎的算子对计算做了一些抽象,对于新人来说有一定学习成本,而一旦掌握这门技术,人们所能处理的数据规模将成倍增加。大数据引擎的算子出现,正是针对数据分布在多个节点的大数据场景下,需要一种统一的计算描述语言来对数据做计算而进化出的新计算形态。基于Flink的算子,我们可以定义一个数据流的逻辑视图,以此完成对大数据的计算。剩下那些数据交换、横向扩展、故障恢复等问题全交由大数据引擎来解决。
1.2 从逻辑视图到物理执行
在绝大多数的大数据处理场景下,一台机器节点无法处理所有数据,数据被切分到多台节点上。在大数据领域,当数据量大到超过单台机器处理能力时,需要将一份数据切分到多个分区(Partition)上,每个分区分布在一台虚拟机或物理机上。
前一小节已经提到,大数据引擎的算子提供了编程接口,我们可以使用算子构建数据流的逻辑视图。考虑到数据分布在多个节点的情况,逻辑视图只是一种抽象,需要将逻辑视图转化为物理执行图,才能在分布式环境下执行。
图 3 样例程序物理执行示意图
图 3为WordCount程序的物理执行图,这里数据流分布在2个分区上。箭头部分表示数据流分区,圆圈部分表示算子在分区上的算子子任务(Operator Subtask)。从逻辑视图变为物理执行图后,FlatMap算子在每个分区都有一个算子子任务,以处理该分区上的数据:FlatMap[1/2]算子子任务处理第一个数据流分区上的数据,以此类推。
算子子任务又被称为算子实例,一个算子在并行执行时,会有多个算子实例。即使输入数据增多,我们也可以通过部署更多的算子实例来进行横向扩展。从图 3中可以看到,除去Sink外的算子都被分成了2个算子实例,他们的并行度(Parallelism)为2,Sink算子的并行度为1。并行度是可以被设置的,当设置某个算子的并行度为2时,也就意味着有这个算子有2个算子子任务(或者说2个算子实例)并行执行。实际应用中一般根据输入数据量的大小,计算资源的多少等多方面的因素来设置并行度。
注意,在本例中,为了演示,我们把所有算子的并行度设置为了2:env.setParallelism(2);,把最后输出的并行度设置成了1:wordCount.print().setParallelism(1);。如果不单独设置print的并行度的话,它的并行度也是2。
算子子任务是Flink物理执行的基本单元,算子子任务之间是相互独立的,某个算子子任务有自己的线程,不同算子子任务可能分布在不同的节点上。后文在Flink的资源分配部分我们还会重点介绍算子子任务。
1.3 数据交换策略
图 3中出现了数据流动的现象,即数据在不同的算子子任务上进行着数据交换。无论是Hadoop、Spark还是Flink,都都会涉及到数据交换策略。常见的据交换策略有4种,如图 4所示。
图 4 Flink数据交换策略
为了实现支持分布式运行,Flink跟其他大数据引擎一样,采用了主从(Master-Worker)架构,运行时主要包括两个组件:
• Master是一个Flink作业的主进程。它起到了协调管理的作用。
• TaskManager,又被称为Worker或Slave,是执行计算任务的进程。它拥有CPU、内存等计算资源。Flink作业需要将计算任务分发到多个TaskManager上并行执行。
下面将从作业执行层面来分析Flink各个模块如何工作。
2.1 Flink作业执行过程
Flink为适应不同的基础环境(独立集群、YARN、Kubernetes),在不断的迭代开发过程中已经逐渐形成了一个作业执行流程。不同的基础环境对计算资源的管理方式略有不同,不过都大同小异,这里以独立集群(Standalone)为例,分析作业的分布式执行流程。Standalone模式指Flink独占该集群,集群上无其他任务,如Spark、MapReduce等。
图 5 Flink作业提交流程
在一个作业提交前,Master和TaskManager等进程需要先被启动。我们可以在Flink主目录中执行脚本来启动这些进程:bin/start-cluster.sh。Master和TaskManager被启动后,TaskManager需要将自己注册给Master中的ResourceManager。这个初始化和资源注册过程发生在单个作业提交前,我们称之为第0步。
接下来我们逐步分析一个Flink作业如何被提交:
TaskManager在执行计算任务过程中可能会与其他TaskManager交换数据,会使用图 4提到的一些数据交换策略。同时,TaskManager也会将一些任务状态信息会反馈给JobManager,这些信息包括任务启动、运行或终止的状态,快照的元数据等。
我们再对涉及到的各个组件进行更为详细的介绍。
Client
用户一般使用客户端(Client)提交作业,比如Flink主目录下的bin目录中提供的命令行工具。Client会对用户提交的Flink程序进行预处理,并把作业提交到Flink集群上。Client提交作业时需要配置一些必要的参数,比如使用Standalone还是YARN集群等。整个作业被打成了Jar包,DataStream API被转换成了JobGraph,JobGraph是一种类似图2的逻辑视图。
Dispatcher
Dispatcher可以接收多个作业,每接收一个作业,Dispatcher都会为这个作业分配一个JobManager。Dispatcher对外提供一个REST式的接口,以HTTP的形式来对外提供服务。
JobManager
JobManager是单个Flink作业的协调者,一个作业会有一个JobManager来负责。JobManager会将Client提交的JobGraph转化为ExceutionGraph,ExecutionGraph是类似图3所示的可并行的物理执行图。JobManager会向ResourceManager申请必要的资源,当获取足够的资源后,JobManager将ExecutionGraph以及具体的计算任务分发部署到多个TaskManager上。同时,JobManager还负责管理多个TaskManager,这包括:收集作业的状态信息,生成检查点,必要时进行故障恢复等问题。
ResourceManager
如前文所说,Flink现在可以部署在Standalone、YARN或Kubernetes等环境上,不同环境中对计算资源的管理模式略有不同,Flink使用一个名为ResourceManager的模块来统一处理资源分配上的问题。在Flink中,计算资源的基本单位是TaskManager上的任务槽位(Task Slot,简称槽位Slot)。ResourceManager的职责主要是从YARN等资源提供方获取计算资源,当JobManager有计算需求时,将空闲的Slot分配给JobManager。当计算任务结束时,ResourceManager还会重新收回这些Slot。
TaskManager
TaskManager是实际负责执行计算的节点。一般地,一个Flink作业是分布在多个TaskManager上执行的,单个TaskManager上提供一定量的Slot。一个TaskManager启动后,相关Slot信息会被注册到ResourceManager中。当某个Flink作业提交后,TaskManager会将空闲的Slot信息提供给JobManager。JobManager获取到空闲Slot信息后会将具体的计算任务部署到该Slot之上,任务开始在这些Slot上执行。在执行过程,由于要进行数据交换,TaskManager还要和其他TaskManager进行必要的数据通信。
总之,TaskManager负责具体计算任务的执行,启动时它会将资源向ResourceManager注册。
2.2 再谈逻辑视图到物理执行图
了解了Flink的分布式架构和核心组件,这里我们从更细粒度上来介绍从逻辑视图转化为物理执行图过程,该过程可以分成四层:StreamGraph -> JobGraph -> ExecutionGraph -> 物理执行图。
图 6 WordCount程序数据流图转化过程
可以看到,Flink在数据流图上可谓煞费苦心,仅各类图就有四种之多。对于新人来说,可以不用太关心这些非常细节的底层实现,只需要了解以下几个核心概念:
2.3 任务、算子子任务与算子链
在构造物理执行图的过程中,Flink会将一些算子子任务链接在一起,组成算子链。链接后以任务(Task)的形式被TaskManager调度执行。使用算子链是一个非常有效的优化,它可以有效降低算子子任务之间的传输开销。链接之后形成的Task是TaskManager中的一个线程。
图 7 任务、子任务与算子链
例如,数据从Source前向传播到FlatMap,这中间没有发生跨分区的数据交换,因此,我们完全可以将Source、FlatMap这两个子任务组合在一起,形成一个Task。数据经过keyBy发生了数据交换,数据会跨越分区,因此无法将keyBy以及其后面的窗口聚合链接到一起。由于WindowAggregation的并行度是2,Sink的并行度为1,数据再次发生了交换,我们不能把WindowAggregation和Sink两部分链接到一起。1.2节中提到,Sink的并行度是人为设置为1,如果我们把Sink的并行度也设置为2,那么是可以让这两个算子链接到一起的。
默认情况下,Flink会尽量将更多的子任务链接在一起,这样能减少一些不必要的数据传输开销。但一个子任务有超过一个输入或发生数据交换时,链接就无法建立。两个算子能够链接到一起是有一些规则的,感兴趣的读者可以阅读Flink源码中org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator中的isChainable方法。StreamingJobGraphGenerator类的作用是将StreamGraph转换为JobGraph。
尽管将算子链接到一起会降低一些传输开销,但是也有一些情况并不需要太多链接。比如,有时候我们需要将一个非常长的算子链拆开,这样我们就可以将原来集中在一个线程中的计算拆分到多个线程中来并行计算。Flink允许开发者手动配置是否启用算子链,或者对哪些算子使用算子链。
2.4 任务槽位与计算资源
根据前文的介绍,我们已经了解到TaskManager负责具体的任务执行。TaskManager是一个JVM进程,在TaskManager中可以并行运行多个Task。在程序执行之前,经过优化,部分子任务被链接在一起,组成一个Task。每个Task是一个线程,需要TaskManager为其分配相应的资源,TaskManager使用任务槽位给Task分配资源。
在解释Flink任务槽位的概念前,我们先回顾一下进程与线程的概念。在操作系统层面,进程(Process)是进行资源分配和调度的一个独立单位,线程(Thread)是CPU调度的基本单位。比如,我们常用的Office Word软件,在启动后就占用操作系统的一个进程。Windows上可以使用任务管理器来查看当前活跃的进程,Linux上可以使用top命令来查看。线程是进程的一个子集,一个线程一般专注于处理一些特定任务,不独立拥有系统资源,只拥有一些运行中必要的资源,如程序计数器。一个进程至少有一个线程,也可以有多个线程。多线程场景下,每个线程都处理一小个任务,多个线程以高并发的方式同时处理多个小任务,可以提高处理能力。
回到Flink的槽位分配机制上,一个TaskManager是一个进程,TaskManager可以管理一至多个Task,每个Task是一个线程,占用一个槽位。每个槽位的资源是整个TaskManager资源的子集,比如这里的TaskManager下有3个槽位,每个槽位占用TaskManager所管理的1/3的内存,第一个槽位中的Task不会与第二个槽位中的Task互相争抢内存资源。注意,在分配资源时,Flink并没有将CPU资源明确分配给各个槽位。
图 8 Task Slot与Task Manager
假设我们给WordCount程序分配两个TaskManager,每个TaskManager又分配3个槽位,所以总共是6个槽位。结合图 7中对这个作业的并行度设置,整个作业被划分为5个Task,使用5个线程,这5个线程可以按照图 8所示的方式分配到6个槽位中。
Flink允许用户设置TaskManager中槽位的数目,这样用户就可以确定以怎样的粒度将任务做相互隔离。如果每个TaskManager只包含一个槽位,那么运行在该槽位内的任务将独享JVM。如果TaskManager包含多个槽位,那么多个槽位内的任务可以共享JVM资源,比如共享TCP连接、心跳信息、部分数据结构等。官方建议将槽位数目设置为TaskManager下可用的CPU核心数,那么平均下来,每个槽位都能平均获得1个CPU核心。
图 8中展示了任务的一种资源分配方式,默认情况下, Flink还提供了一种槽位共享(Slot Sharing)的优化机制,进一步优化数据传输开销,充分利用计算资源。将图 8中的任务做槽位共享优化后,结果如图 9所示。
图 9 槽位共享示意图
开启槽位共享后,Flink允许多个任务共享一个槽位。如图 9中最左侧的数据流,一个作业从Source到Sink的所有子任务都可以放置在一个槽位中,这样数据交换成本更低。而且,对于一个数据流图来说,Source、FlatMap等算子的计算量相对不大,WindowAggregation算子的计算量比较大,计算量较大的算子子任务与计算量较小的算子子任务可以互补,腾出更多的槽位,分配给更多Task,这样可以更好地利用资源。如果不开启槽位共享,如图8所示,计算量小的Source、FlatMap算子子任务独占槽位,造成一定的资源浪费。
图 10 槽位共享后,增大并行度,可以部署更多算子实例
图 8中的方式共占用5个槽位,支持槽位共享后,图 9只占用2个槽位。为了充分利用空槽位,剩余的4个空槽位可以分配给别的作业,也可以通过修改并行度来分配给这个作业。例如,这个作业的输入数据量非常大,我们可以把并行度设为6,更多的算子实例会将这些槽位填充,如图10所示。
综上,Flink的一个槽位中可能运行一个算子子任务、也可能是被链接的多个子任务,或者是多个子任务共享槽位,具体这个槽位上运行哪些计算由算子链和槽位共享两个优化措施决定。