Flink运行机制相关概念介绍
大数据计算分为离线计算和实时计算,其中离线计算就是我们通常说的批计算,代表技术是Hadoop MapReduce、Hive等;实时计算也被称作流计算,代表技术是Storm、Spark Streaming、Flink等。本文系统地介绍了流式计算的相关知识,并着重介绍了Flink的实现原理细节,便于大家快速地理解和掌握流式计算,并基于Flink完成业务开发。
1. 流式计算和批处理
批处理在大数据世界有着悠久的历史。早期的大数据处理基本上是批处理的天下。批处理主要操作大容量的静态数据集,并在计算过程完成之后返回结果。所以批处理面对的数据集通常具有以下特征:
- 有界:批处理数据集代表数据的有限集合
- 持久:数据通常存储在可重复获取的持久存储设备中
- 就绪:数据在计算之前已经就绪,不会发生变化
- 大量:批处理操作通常是处理海量数据集的唯一方法
批处理非常适合需要访问全部记录才能完成的计算工作。例如在计算数据集的总数或者平均数时,必须将数据集作为一个整体加以处理,而不能只处理其中的部分数据集。这些操作在计算进行的过程中需要维持计算的中间信息,即状态。当作业执行完成后,批处理系统会将最终的结果存储到持久介质中。由于批处理是离线计算,且大数据量的处理往往耗时较久,所以批处理适合于对时效性要求没那么高的场景。
相比于批处理,流处理是一种截然不同的处理方式。流处理系统需要对随时进入系统的数据进行实时计算。
批处理和流处理的差异主要体现在:
- 首先,流处理中的数据集是“无边界”的;
- 其次,流处理中的数据不一定是持久化的,有可能是业务系统实时产生的;
- 再次,流式计算常常需要处理业务系统实时产生的数据,而并非已就绪。
这些差异就产生了以下几个重要的影响:
- 完整数据集只能代表截至目前已经进入到系统中的数据总量
- 处理工作是基于事件的,除非明确停止,否则没有“尽头”
- 处理结果立刻可用,并随着新数据的抵达持续更新
- 无界、非就绪、非持久化,导致对流式计算有更高的容错要求
如下图所示,流处理系统可以处理无限量的数据。显然,同批处理一样,在流处理过程中,也都需要维持中间状态。
2. 流式计算的状态与容错
前一小节提到了流计算的状态,本小节将进一步详细讨论这个概念。在流计算中,状态(State)是一个较宽泛的概念。这里我们先明确给个定义:状态(State)就是计算过程中的“中间信息(Intermediate Information)”
。
从数据的角度看,流计算的处理方法主要有以下两种:
- 无状态(Stateless):每一个进入的记录独立于其他记录。不同记录之间没有任何关系,它们可以被独立处理和持久化。例如:map、fliter、静态数据 join 等操作。
- 有状态(Stateful):处理进入的记录依赖于之前记录处理的结果。因此,我们需要维护不同数据处理之间的中间信息。每一个进入的记录都可以读取和更新该信息。我们把这个中间信息称作状态(State)。例如:独立键的聚合计数、去重等等。
对应地,状态处理也分为两种:
- 过程状态:它是流计算的元数据(metadata),用于追踪和记录历史至今,已经被处理的数据偏移量及流处理系统当前的状态。在流的世界中,这些元数据包括 checkpoint /savepoint (后面会介绍)以及保存已经处理数据的偏移量(offset)等。这些信息是任何高可靠流处理的基本,同时被无状态和有状态处理需要。
- 数据状态:这些中间数据来自于数据本身(目前为止处理过的),它需要在记录之间维护(只在Stateful模式下需要维护)。
事实上,维护流式计算的中间信息不仅仅是因为计算本身所需要,还有个非常重要的原因是流式计算系统的容错性要求。维基百科对容错性(fault tolerance)的定义:容错性是指存在故障的情况下计算机系统不失效并且仍然能够正常工作的特性
。
根据这个定义我们可以知道为什么需要容错:因为“故障”的存在。故障产生的原因多种多样(例如机器故障、网络故障、软件失败或者服务异常重启等),并且发生的时机也具有不确定性,但最终对用户产生的直接影响都是导致任务执行失败。为此,流计算系统需要一种机制来周期性地持久化相应的状态快照(即checkpoint机制),当计算系统出现异常后,就可以从最近的持久化快照中恢复执行,从而确保计算结果的正确性。
- 在批处理场景中,我们可以很容易地应对故障导致的种种问题,因为所有的输入数据都是可再次获得的。我们可以重启作业然后重放所有输入数据。
- 在流计算场景中,却有以下三方面的挑战:
- 第一,流式计算的数据集有可能是非持久化的,即有可能是无法再次获得的,或者再次获得的成本将会很高;
- 第二,流式计算面向的是无界数据集,理论上作业的执行时间也是无界的,即便理论上可能达不到这一点,在实际情况下流作业的执行周期也非常长,因此状态很可能关联着整个执行周期内的计算结果;
- 第三,
相较于批处理,流式计算对计算结果的实时性更为敏感,从头开始重新计算得到的结果对于系统而言往往已经没有价值
。 - 这就导致了流计算作业状态的价值更为“昂贵”,因为一旦状态丢失,要重新计算并恢复它有可能做不到,或者需要花费非常高的计算开销以及时间成本,或者得到已经失去价值的结果。
3. Flink简介及其在业务系统中的位置
Apache Flink是由Apache软件基金会开发的开源流处理框架,其核心是用Java和Scala编写的分布式流数据引擎。Flink以数据并行(分布式)和流水线方式执行任意流数据程序,Flink的流水线运行时系统可以执行批处理和流处理程序。
下图给出了基于DB事务的传统业务系统和基于Flink的流数据处理系统的类比图。由此可知,传统业务系统和流数据处理系统的功能是类似的,两者都是对事件进行响应,并在响应完成后触发相应的行为。但在实际应用中,业务系统的事件往往直接来自用户的实时请求,而数据处理系统的事件则常常是由业务系统所触发。以风控系统为例,风控系统需要实时收集业务系统中用户的操作行为,以此计算出存在风险的用户及其风险操作,并将计算结果反馈给业务系统。
传统业务系统和流数据处理系统的主要差异体现在,前者的计算层和持久化存储层是分开的,计算层从持久化层读写数据;后者的数据和计算都是在本地的(内存或本地磁盘),因此可以有更高的吞吐量和更低的时延。而为了达到容错性要求,流计算需要定期将本地状态持久化到外部存储设备。
4. Flink模型
Flink对数据的处理被抽象为以下三步:
- 第一,接受数据;
- 第二,处理数据;
- 第三,输出处理结果
具体来说就是
- 1,接收(ingest)一个或者多个数据源(hdfs,kafka等);
- 2,执行若干用户需要的转换算子(transformation operators);
- 3,将转换后的结果输出(sink)。
如下图所示,Flink处理数据流的算子(operator)也分为三类:Source负责管理输入(数据源),Tranformation负责数据运算,Sink负责管理结果输出
。
Source和Sink就不再多说了,一个负责输入,一个负责输出。对于Transformation operators,熟悉java stream的同学应该很容易理解,因为Flink中的map,flatMap,reduce,apply等算子和java stream中对应的算子含义差不多。keyBy作为Flink的一个高频使用算子,其功能跟MySQL的group by功能差不多;而window算子则是通过窗口机制,将无界数据集拆分成一个个有界数据集,详细信息后面会进一步介绍。
作为一个分布式流数据处理引擎,各算子可以在不同的线程(不同的线程可以位于相同或者不同的物理节点)中并行执行。如下图所示,在Flink中可以对每个算子单独指定并行度(parallelism),也可以统一指定Flink的并行度
,优先级是算子的并行度值高于统一的并行度值。还有一点需要注意的是,Flink中执行的作业还必须要有最大并行度,可以用户指定,否则Flink会根据并行度计算出一个默认值。关于最大并行度的作用,后面介绍Key Group时会详细说明。
5. Flink的架构
Flink的系统架构如下图所示。用户在客户端提交作业(Job)到服务端。服务端为分布式的主从架构。
资源角色划分
- Master上的Dispatcher服务负责提供REST接口来接收Client提交的Job,运行Web UI,并负责启动和派发Job给JobManager。
- Resource Manager负责计算资源(TaskManager)的管理。
任务角色划分
- JobManager负责将任务调度到TaskManager执行、检查点(checkpoint,后面会介绍)的创建等工作,
- 而TaskManager(worker)负责SubTask的实际执行。
当服务端的JobManager接收到一个Job后,会按照各个算子的并发度将Job拆分成多个SubTask,并分配到TaskManager的Slot上执行。
任务的提交流程如下图所示:
6. Flink的重要概念
上一小节提到了Job、SubTask、Slot等概念,本小节就来对Flink涉及到的Job、Task、SubTask、 Slot、Slotsharing、Thread等概念进行详细介绍。
首先,Job最容易理解,一个Job代表一个可以独立提交给Flink执行的作业,我们向JobManager提交任务的时候就是以Job为单位的,只不过一份代码里可以包含多个Job(每个Job对应一个类的main函数)。接着我们来看Task和SubTask,如下图所示:
图说明如下:
- 图中每个圆代表一个Operator(算子),每个虚线圆角框代表一个Task,每个虚线直角框代表一个Subtask,其中的p表示算子的并行度。
- 最上面是StreamGraph,在没有经过任何优化时,可以看到包含4个Operator/Task:Task A1、Task A2、Task B、Task C。
- StreamGraph经过链式优化(Flink默认会将一些并行度相同的算子连成一条链)之后,Task A1和Task A2两个Task合并成了一个新的Task A(可以认为合并产生了一个新的Operator),得到了中间的JobGraph。
- 然后以并行度为2(需要2个Slot)执行的时候,Task A产生了2个Subtask,分别占用了Thread #1和Thread #2两个线程;Task B产生了2个Subtask,分别占用了Thread #3和Thread #4两个线程;Task C产生了1个Subtask,占用了Thread #5。
由此可以总结如下:
- Task是逻辑概念,一个Operator就代表一个Task(多个Operator被chain之后产生的新Operator算一个Operator);
- 真正运行的时候,Task会按照并行度分成多个Subtask,Subtask是执行/调度的基本单元;每个Subtask需要一个线程(Thread)来执行。
前一小节讲了TaskManager才是真正干活的,启动的时候,它会将自己的内存资源以Slot的方式注册到master节点上的资源管理器(ResourceManager)。JobManager从ResourceManager处申请到Slot资源后将自己优化过后的SubTask调度到这些Slot上面去执行。在整个过程中SubTask是调度的基本单元,而Slot则是资源分配的基本单元。需要注意的是目前Slot只隔离内存,不隔离CPU
。
为了更高效地使用资源,Flink默认允许同一个Job中不同Task的SubTask运行在同一个Slot中,这就是SlotSharing。注意以下描述中的几个关键条件:
- 必须是同一个Job。这个很好理解,slot是给Job分配的资源,目的就是隔离各个Job,如果跨Job共享,但隔离就失效了;
- 必须是不同Task的Subtask。这样是为了更好的资源均衡和利用。一个计算流中(pipeline),每个Subtask的资源消耗肯定是不一样的,如果都均分slot,那必然有些资源利用率高,有些低。限制不同Task的Subtask共享可以尽量让资源占用高的和资源占用低的放一起,而不是把多个高的或多个低的放一起。比如一个计算流中,source和sink一般都是IO操作,特别是source,一般都是网络读,相比于中间的计算Operator,资源消耗并不大。
- 默认是允许sharing的,也就是你也可以关闭这个特性。
下面我们依次来看看官方文档给出的两幅图:
图中两个TaskManager节点共有6个slot,5个SubTask,其中sink的并行度为1,另外两个SubTask的并行度为2。此时由于Subtask少于Slot个数,所以每个Subtask独占一个Slot,没有SlotSharing。下面我们把把并行度改为6:
此时,Subtask的个数多于Slot了,所以出现了SlotSharing。一个Slot中分配了多个Subtask,特别是最左边的Slot中跑了一个完整的Pipeline。SlotSharing除了提高了资源利用率,还简化了并行度和Slot之间的关系:一个Job运行需要的最少的Slot个数就是其中并行度最大的那个Task的并行度(ps:并行度最高和作业的最大并行度没有任何关系哈)
。
掌握了这些概念,就可以较好地评估流式计算作业所需要的资源量了。
7. Flink的状态、状态分区、状态缩放(rescale)和Key Group
由前面的小节已知,Flink的一个算子可能会有多个子任务,每个子任务可能分布在不同的实例上,我们可以把Flink的状态理解为某个算子的子任务在其当前实例上的一个变量,该变量记录了流过当前实例算子的历史记录产生的结果。当新数据记录流入时,我们需要结合该结果(即状态)来进行计算。实际上,Flink的状态是由算子的子任务来创建和管理的。一个状态的更新和获取的流程如下图所示,一个算子子任务接收输入流,获取对应的状态,根据新的计算结果更新状态。一个简单的例子是对一个时间窗口内流入的某个整数字段进行求和,那么当算子子任务接收到新元素时,会获取已经存储在状态中的数值(历史记录的求和结果),然后将当前输入加到状态上,并将状态数据更新。
为了保证流式计算的高可用性(容错),子任务的状态除了会暂存在节点内,还需要进行持久化存储(快照)。对于一个分布式计算系统,要自行实现状态的备份和故障恢复,并没有那么容易。可喜的是,Flink提供了有状态的计算能力,它封装了一些底层的实现,比如状态的高效存储、Checkpoint和Savepoint