JobManager 是一个 Flink 集群中任务管理和调度的核心,是控制应用执行的主进程。也就是说,每个应用都应该被唯一的 JobManager 所控制执行。当然,在高可用(HA)的场景下,可能会出现多个 JobManager;这时只有一个是正在运行的领导节点(leader),其他都是备用节点(standby)。
JobManger 又包含 3 个不同的组件,下面我们一一讲解。
JobMaster
JobMaster 是 JobManager 中最核心的组件,负责处理单独的作业(Job)。所以 JobMaster 和具体的 Job
是一一对应的,多个 Job 可以同时运行在一个 Flink 集群中, 每个 Job 都有一个自己的
JobMaster。需要注意在早期版本的 Flink 中,没有 JobMaster 的概念;而 JobManager
的概念范围较小,实际指的就是现在所说的 JobMaster。
在作业提交时,JobMaster 会先接收到要执行的应用。这里所说“应用”一般是客户端提交来的,包括:Jar
包,数据流图(dataflow graph),和作业图(JobGraph)。
JobMaster 会把 JobGraph 转换成一个物理层面的数据流图,这个图被叫作“执行图”
(ExecutionGraph),它包含了所有可以并发执行的任务。 JobMaster 会向资源管理器
(ResourceManager)发出请求,申请执行任务必要的资源。一旦它获取到了足够的资源,就会将执行图分发到真正运行它们的
TaskManager 上。
而在运行过程中,JobMaster 会负责所有需要中央协调的操作,比如说检查点(checkpoints)的协调。
2.资源管理器(ResourceManager)
Dispatcher 主要负责提供一个 REST 接口,用来提交应用,并且负责为每一个新提交的作业启动一个新的 JobMaster 组件。Dispatcher 也会启动一个 Web UI,用来方便地展示和监控作业执行的信息。Dispatcher 在架构中并不是必需的,在不同的部署模式下可能会被忽略掉。
接下来我们就具体介绍一下不同部署环境下的提交流程。
Flink 是流式计算框架。它的程序结构,其实就是定义了一连串的处理操作,每一个数据输入之后都会依次调用每一步计算。在 Flink 代码中,我们定义的每一个处理转换操作都叫作 “算子”(Operator),所以我们的程序可以看作是一串算子构成的管道,数据则像水流一样有序地流过。比如在之前的 WordCount 代码中,基于执行环境调用的 socketTextStream()方法,就是一个读取文本流的算子;而后面的 flatMap()方法,则是将字符串数据进行分词、转换成二元组的算子。
所有的 Flink 程序都可以归纳为由三部分构成:Source、Transformation 和 Sink。
⚫Source 表示“源算子”,负责读取数据源。
⚫Transformation 表示“转换算子”,利用各种算子进行处理加工。
⚫Sink 表示“下沉算子”,负责数据的输出。
在运行时,Flink 程序会被映射成所有算子按照逻辑顺序连接在一起的一张图,这被称为 “逻辑数据流”(logical dataflow),或者叫“数据流图”(dataflow graph)。我们提交作业之后,打开 Flink 自带的 Web UI,点击作业就能看到对应的 dataflow,如图 4-7 所示。在数据流图中,可以清楚地看到 Source、Transformation、Sink 三部分。
数据流图类似于任意的有向无环图(DAG),这一点与 Spark 等其他框架是一致的。图中的每一条数据流(dataflow)以一个或多个 source 算子开始,以一个或多个 sink 算子结束。
在大部分情况下,dataflow 中的算子,和程序中的转换运算是一一对应的关系。那是不是说,我们代码中基于 DataStream API
的每一个方法调用,都是一个算子呢?
并非如此。除了 Source 读取数据和 Sink 输出数据,一个中间的转换算子(Transformation Operator)必须是一个转换处理的操作;而在代码中有一些方法调用,数据是没有完成转换的。可能只是对属性做了一个设置,也可能定义的是数据的传递方式而非转换,又或者是需要几个方法合在一起才能表达一个完整的转换操作。例如,在之前的代码中,我们用到了定义分组的方法 keyBy,它就只是一个数据分区操作,而并不是一个算子。事实上,代码中我们可以看到调用其他转换操作之后返回的数据类型是 SingleOutputStreamOperator,说明这是一个算子操作;而 keyBy 之后返回的数据类型是 KeyedStream。感兴趣的读者也可以自行提交任务在 Web
UI 中查看。
1、什么是并行计算
要解答这个问题,我们需要先梳理一下其他框架分配任务、数据处理的过程。对于 Spark 而言,是把根据程序生成的 DAG 划分阶段(stage)、进而分配任务的。而对于 Flink 这样的流式引擎,其实没有划分 stage 的必要。因为数据是连续不断到来的,我们完全可以按照数据流图建立一个“流水线”,前一个操作处理完成,就发往处理下一步操作的节点。如果说 Spark 基于 MapReduce 架构的思想是“数据不动代码动”,那么 Flink 就类似“代码不动数据流动”,原因就在于流式数据本身是连续到来的、我们不会同时传输所有数据,这其实是更符合数据流本身特点的处理方式。
2、并行子任务和并行度
stream.map(word -> Tuple2.of(word, 1L)).setParallelism(2);
这种方式设置的并行度,只针对当前算子有效。
另外,我们也可以直接调用执行环境的 setParallelism()方法,全局设定并行度:
env.setParallelism(2);
这样代码中所有算子,默认的并行度就都为 2 了。我们一般不会在程序中设置全局并行度,因为如果在程序中对全局并行度进行硬编码,会导致无法动态扩容。
这里要注意的是,由于 keyBy 不是算子,所以无法对 keyBy 设置并行度。
(2)提交应用时设置
在使用 flink run 命令提交应用时,可以增加-p 参数来指定当前应用程序执行的并行度,
它的作用类似于执行环境的全局设置:
bin/flink run –p 2 –c com.lyh.wc.StreamWordCount
./FlinkTutorial-1.0-SNAPSHOT.jar
如果我们直接在 Web UI 上提交作业,也可以在对应输入框中直接添加并行度。
(3)配置文件中设置
我们还可以直接在集群的配置文件 flink-conf.yaml 中直接更改默认并行度:
parallelism.default: 2
我们可以总结一下所有的并行度设置方法,它们的优先级如下:
(1)对于一个算子,首先看在代码中是否单独指定了它的并行度,这个特定的设置优先级最高,会覆盖后面所有的设置。
(2)如果没有单独设置,那么采用当前代码中执行环境全局设置的并行度。
(3)如果代码中完全没有设置,那么采用提交时-p 参数指定的并行度。
(4)如果提交时也未指定-p 参数,那么采用集群配置文件中的默认并行度。
(1)一对一(One-to-one,forwarding)
这种模式下,数据流维护着分区以及元素的顺序。比如图中的 source 和 map 算子,source 算子读取数据之后,可以直接发送给 map 算子做处理,它们之间不需要重新分区,也不需要调整数据的顺序。这就意味着 map 算子的子任务,看到的元素个数和顺序跟 source 算子的子任务产生的完全一样,保证着“一对一”的关系。map、filter、flatMap 等算子都是这种 one-to-one 的对应关系。
这种关系类似于 Spark 中的窄依赖。
(2)重分区(Redistributing)
在这种模式下,数据流的分区会发生改变。比图中的 map 和后面的 keyBy/window 算子之间(这里的 keyBy
是数据传输算子,后面的 window、apply 方法共同构成了 window 算子), 以及 keyBy/window 算子和 Sink
算子之间,都是这样的关系。
每一个算子的子任务,会根据数据传输的策略,把数据发送到不同的下游目标任务。例如,
keyBy()是分组操作,本质上基于键(key)的哈希值(hashCode)进行了重分区;而当并行度改变时,比如从并行度为 2 的
window 算子,要传递到并行度为 1 的 Sink
算子,这时的数据传输方式是再平衡(rebalance),会把数据均匀地向下游子任务分发出去。这些传输方式都会引起重分区(redistribute)的过程,这一过程类似于
Spark 中的 shuffle。 总体说来,这种算子间的关系类似于 Spark 中的宽依赖。
合并算子链
在 Flink 中,并行度相同的一对一(one to one)算子操作,可以直接链接在一起形成一个 “大”的任务(task),这样原来的算子就成为了真正任务里的一部分,如图 4-11 所示。每个 task 会被一个线程执行。这样的技术被称为“算子链”(Operator Chain)。
比如在图 4-11 中的例子中,Source 和 map 之间满足了算子链的要求,所以可以直接合并在一起,形成了一个任务;因为并行度为 2,所以合并后的任务也有两个并行子任务。这样,这个数据流图所表示的作业最终会有 5 个任务,由 5 个线程并行执行。
Flink 为什么要有算子链这样一个设计呢?这是因为将算子链接成 task 是非常有效的优化:可以减少线程之间的切换和基于缓存区的数据交换,在减少时延的同时提升吞吐量。
Flink 默认会按照算子链的原则进行链接合并,如果我们想要禁止合并或者自行定义,也
可以在代码中对算子做一些特定的设置:
// 禁用算子链
.map(word -> Tuple2.of(word, 1L)).disableChaining();
// 从当前算子开始新链
.map(word -> Tuple2.of(word, 1L)).startNewChain()
至此,我们已经彻底了解了由代码生成任务的过程,现在来做个梳理总结。
由 Flink 程序直接映射成的数据流图(dataflow graph),也被称为逻辑流图(logical StreamGraph),因为它们表示的是计算逻辑的高级视图。到具体执行环节时,我们还要考虑并行子任务的分配、数据在任务间的传输,以及合并算子链的优化。为了说明最终应该怎样执行一个流处理程序,Flink 需要将逻辑流图进行解析,转换为物理数据流图。
在这个转换过程中,有几个不同的阶段,会生成不同层级的图,其中最重要的就是作业图
(JobGraph)和执行图(ExecutionGraph)。Flink 中任务调度执行的图,按照生成顺序可以分成四层:
逻辑流图(StreamGraph)→ 作业图(JobGraph)→ 执行图(ExecutionGraph)→ 物理图(Physical Graph)。
我们可以回忆一下之前处理 socket 文本流的 StreamWordCount 程序:
env.socketTextStream().flatMap(…).keyBy(0).sum(1).print();
如果提交时设置并行度为 2:
bin/flink run –p 2 –c com.lyh.wc.StreamWordCount
./FlinkTutorial-1.0-SNAPSHOT.jar
那么根据之前的分析,除了 socketTextStream()是非并行的 Source 算子,它的并行度始终
为 1,其他算子的并行度都为 2。接下来我们分析一下程序对应四层调度图的演变过程,如图 4-12 所示。
1.逻辑流图(StreamGraph)
2.作业图(JobGraph)
StreamGraph 经过优化后生成的就是作业图(JobGraph),这是提交给 JobManager 的数据结构,确定了当前作业中所有任务的划分。主要的优化为: 将多个符合条件的节点链接在一起合并成一个任务节点,形成算子链,这样可以减少数据交换的消耗。JobGraph 一般也是在客户端生成的,在作业提交时传递给 JobMaster。
在图 4-12 中,分组聚合算子(Keyed Aggregation)和输出算子 Sink(print)并行度都为 2,而且是一对一的关系,满足算子链的要求,所以会合并在一起,成为一个任务节点。
3.执行图(ExecutionGraph)
JobMaster 收到 JobGraph 后,会根据它来生成执行图(ExecutionGraph)。ExecutionGraph 是 JobGraph 的并行化版本,是调度层最核心的数据结构。
从图 4-12 中可以看到,与 JobGraph 最大的区别就是按照并行度对并行子任务进行了拆分,并明确了任务间数据传输的方式。
4.物理图(Physical Graph)
JobMaster 生成执行图后, 会将它分发给 TaskManager;各个 TaskManager 会根据执行图部署任务,最终的物理执行过程也会形成一张“图”,一般就叫作物理图(Physical Graph)。这只是具体执行层面的图,并不是一个具体的数据结构。
对应在上图 4-12 中,物理图主要就是在执行图的基础上,进一步确定数据存放的位置和收发的具体方式。有了物理图,TaskManager 就可以对传递来的数据进行处理计算了。
所以我们可以看到,程序里定义了四个算子操作:源(Source)->转换(flatMap)->分组聚合(keyBy/sum)->输出(print);合并算子链进行优化之后,就只有三个任务节点了;再考虑并行度后,一共有 5 个并行子任务,最终需要 5 个线程来执行。
1、任务槽(Task Slots)
之前已经提到过,Flink 中每一个 worker(也就是 TaskManager)都是一个 JVM 进程,它可以启动多个独立的线程,来并行执行多个子任务(subtask)。
所以如果想要执行 5 个任务,并不一定非要 5 个 TaskManager,我们可以让 TaskManager 多线程执行任务。如果可以同时运行 5 个线程,那么只要一个 TaskManager 就可以满足我们之前程序的运行需求了。
很显然,TaskManager 的计算资源是有限的,并不是所有任务都可以放在一个 TaskManager 上并行执行。并行的任务越多,每个线程的资源就会越少。那一个 TaskManager 到底能并行处理多少个任务呢?为了控制并发量,我们需要在 TaskManager 上对每个任务运行所占用的资源做出明确的划分,这就是所谓的任务槽(task slots)。
slot 的概念其实在分布式框架中并不陌生。所谓的“槽”是一种形象的表达。如果大家见过传说中的“卡带式游戏机”,就会对它有更直观的认识:游戏机上的卡槽提供了可以运行游戏的接口和资源,我们把游戏卡带插入卡槽,就可以占用游戏机的计算资源,执行卡带中的游戏程序了。一台经典的小霸王游戏机(如图 4-13)一般只有一个卡槽,而在 TaskManager 中,我们可以设置多个 slot,只要插入“卡带”——也就是分配好的任务,就可以并行执行了。
每个任务槽(task slot)其实表示了 TaskManager 拥有计算资源的一个固定大小的子集。
这些资源就是用来独立执行一个子任务的。
假如一个 TaskManager 有三个 slot,那么它会将管理的内存平均分成三份,每个 slot 独自占据一份。这样一来,我们在 slot 上执行一个子任务时,相当于划定了一块内存“专款专用”,就不需要跟来自其他作业的任务去竞争内存资源了。所以现在我们只要 2 个 TaskManager,就可以并行处理分配好的 5 个任务了,如图 4-14 所示。
2、任务槽数量的设置
我们可以通过集群的配置文件来设定 TaskManager 的 slot 数量:
taskmanager.numberOfTaskSlots: 8
通过调整 slot 的数量,我们就可以控制子任务之间的隔离级别。
具体来说,如果一个 TaskManager 只有一个 slot,那将意味着每个任务都会运行在独立的 JVM 中(当然,该 JVM 可能是通过一个特定的容器启动的);而一个 TaskManager 设置多个 slot 则意味着多个子任务可以共享同一个 JVM。它们的区别在于:前者任务之间完全独立运行,隔离级别更高、彼此间的影响可以降到最小;而后者在同一个 JVM 进程中运行的任务,将共享 TCP 连接和心跳消息,也可能共享数据集和数据结构,这就减少了每个任务的运行开销,在降低隔离级别的同时提升了性能。
需要注意的是,slot 目前仅仅用来隔离内存,不会涉及 CPU 的隔离。在具体应用时,可以将 slot 数量配置为机器的 CPU 核心数,尽量避免不同任务之间对 CPU 的竞争。这也是开发环境默认并行度设为机器 CPU 数量的原因。
于是最终结果就变成了:每个任务节点的并行子任务一字排开,占据不同的 slot;而不同的任务节点的子任务可以共享 slot。一个 slot 中,可以将程序处理的所有任务都放在这里执行,我们把它叫作保存了整个作业的运行管道(pipeline)。
这个特性看起来有点奇怪:我们不是希望并行处理、任务之间相互隔离吗,为什么这里又允许共享 slot 呢?
我们知道,一个 slot 对应了一组独立的计算资源。在之前不做共享的时候,每个任务都平等地占据了一个 slot,但其实不同的任务对资源的占用是不同的。例如这里的前两个任务, source/map 尽管是两个算子合并算子链得到的,但它只是基本的数据读取和简单转换,计算耗时极短,一般也不需要太大的内存空间;而 window 算子所做的窗口操作,往往会涉及大量的数据、状态存储和计算,我们一般把这类任务叫作“资源密集型”(intensive)任务。当它们被平等地分配到独立的 slot 上时,实际运行我们就会发现,大量数据到来时 source/map 和 sink 任务很快就可以完成,但 window 任务却耗时很久;于是下游的 sink 任务占据的 slot 就会等待闲置,而上游的 source/map 任务受限于下游的处理能力,也会在快速处理完一部分数据后阻塞对应的资源开始等待(相当于处理背压)。这样资源的利用就出现了极大的不平衡,“忙的忙死,闲的闲死”。
解决这一问题的思路就是允许 slot 共享。当我们将资源密集型和非密集型的任务同时放到一个 slot 中,它们就可以自行分配对资源占用的比例,从而保证最重的活平均分配给所有的
TaskManager。
slot 共享另一个好处就是允许我们保存完整的作业管道。这样一来,即使某个 TaskManager 出现故障宕机,其他节点也可以完全不受影响,作业的任务可以继续执行。
另外,同一个任务节点的并行子任务是不能共享 slot 的,所以允许 slot 共享之后,运行作业所需的 slot 数量正好就是作业中所有算子并行度的最大值。这样一来,我们考虑当前集群需要配置多少 slot 资源时,就不需要再去详细计算一个作业总共包含多少个并行子任务了,只看最大的并行度就够了。
当然,Flink 默认是允许 slot 共享的,如果希望某个算子对应的任务完全独占一个 slot,或者只有某一部分算子共享 slot,我们也可以通过设置“slot 共享组”(SlotSharingGroup)手动指定:
.map(word -> Tuple2.of(word, 1L)).slotSharingGroup(“1”);
这样,只有属于同一个 slot 共享组的子任务,才会开启 slot 共享;不同组之间的任务是完全隔离的,必须分配到不同的 slot 上。在这种场景下,总共需要的 slot 数量,就是各个 slot 共享组最大并行度的总和。
Slot 和并行度确实都跟程序的并行执行有关,但两者是完全不同的概念。简单来说,task slot 是静态的概念,是指 TaskManager 具有的并发执行能力,可以通过参数 taskmanager.numberOfTaskSlots 进行配置;而并行度(parallelism)是动态概念,也就是 TaskManager 运行程序时实际使用的并发能力,可以通过参数 parallelism.default 进行配置。换句话说,并行度如果小于等于集群中可用 slot 的总数,程序是可以正常执行的,因为 slot 不一定要全部占用,有十分力气可以只用八分;而如果并行度大于可用 slot 总数,导致超出了并行能力上限,那么心有余力不足,程序就只好等待资源管理器分配更多的资源了。
下面我们再举一个具体的例子。假设一共有 3 个 TaskManager,每一个 TaskManager 中的 slot 数量设置为 3 个,那么一共有 9 个 task slot,如图 4-16 所示,表示集群最多能并行执行 9 个任务。
而我们定义 WordCount 程序的处理操作是四个转换算子:
source→ flatMap→ reduce→ sink
当所有算子并行度相同时,容易看出 source 和 flatMap 可以合并算子链,于是最终有三个任务节点。
如果我们没有任何并行度设置,而配置文件中默认 parallelism.default=1,那么程序运行的默认并行度为 1,总共有 3 个任务。由于不同算子的任务可以共享任务槽,所以最终占用的 slot 只有 1 个。9 个 slot 只用了 1 个,有 8 个空闲,如图 4-17 中的 Example 1 所示。
如果我们更改默认参数,或者提交作业时设置并行度为 2,那么总共有 6 个任务,共享任务槽之后会占用 2 个 slot,如图 4-18 中 Example 2 所示。同样,就有 7 个 slot 空闲,计算资源没有充分利用。所以可以看到,设置合适的并行度才能提高效率。
那对于这个例子,怎样设置并行度效率最高呢?当然是需要把所有的 slot 都利用起来。考虑到 slot 共享,我们可以直接把并行度设置为 9,这样所有 27 个任务就会完全占用 9 个 slot。这是当前集群资源下能执行的最大并行度,计算资源得到了充分的利用,如图 4-19 中 Example
3 所示。
另外再考虑对于某个算子单独设置并行度的场景。例如,如果我们考虑到输出可能是写入文件,那会希望不要并行写入多个文件,就需要设置 sink 算子的并行度为 1。这时其他的算子并行度依然为 9,所以总共会有 19 个子任务。根据 slot 共享的原则,它们最终还是会占用全部的 9 个 slot,而 sink 任务只在其中一个 slot 上执行,如图 4-20 中 Example 4 所示。通过这个例子也可以明确地看到,整个流处理程序的并行度,就应该是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量。