全面解析流处理框架 Flink,以及和 Python 的结合

Flink 在大数据领域已经应用的越来越广泛,很多大公司内部都有它的身影,那么问题来了,Flink 到底是用来做什么的呢?

首先提到 Flink 必然绕不开流计算(或者说流式计算、流处理等等),因为 Flink 是一个分布式、高性能的流计算引擎。比如天猫的成交额一分钟能破百亿,大屏实时监控等等,其背后靠的就是一套强大的流计算引擎来支撑,从而实时动态地得到统计结果。

目前在流计算领域,最主流的技术有:Storm、Spark Streaming、Flink,但是能同时做到低延时、Exactly-Once、以及高吞吐,在开源界只有 Flink 有这个能力。面对日益增长的数据规模,以及延时越来越低的数据处理要求,流计算正在成为数据处理平台所必备的功能之一。在好几年前,我们还停留在 Hadoop、MapReduce、Hive 上面,之后 Spark 异军突起、逐渐成为大数据领域的当红明星,即便现在很多公司所使用的仍是 Hadoop Spark 等离线处理技术。但是在未来,流计算一定会成为分布式计算的主要方向之一,而如果想掌握流计算相关的技术,Flink 必然是我们的首选。

除了 Flink 之外,我们还会涉及到 Python,因为 Python 是目前的主流语言之一,所以 Python + Flink(PyFlink) 就诞生了。并且我本人也是 Python 方向的,所以当涉及到使用代码操作 Flink 时,只使用 Python 进行操作。尽管 Flink 对 Python 的支持不像 Java 和 Scala 那么完美,但是对我而言没得选。

大数据技术发展

从 Google 的三驾马车 GFS、MapReduce、BigTable 开始,大数据在不断地发展,而在大数据处理里面,计算模式可以分为四种。

而我们这里重点关注批计算和流计算,那么这两者有什么区别呢?

  • 数据时效性不同:流式计算具有实时、低延迟等特点;批量计算则是非实时、高延迟的。
  • 数据特征不同:流式计算的数据一般是动态的、没有边界的,数据像水流一样源源不断,你不知道什么时候会结束(数据没有边界);而批量计算的数据则是静态的,每次处理的数据量是已知的(数据有边界)。
  • 应用场景不同:流式计算应用在实时场景,或者说时效性要求比较高的场景,比如实时推荐、业务监控等等;而批量计算一般也说成是批处理,应用在实时性要求不高、离线计算的场景下,比如数据分析、离线报表等等。
  • 运行方式不同:流式计算的任务是持续进行的,来了就立刻处理;批量计算的任务则是一次性完成,可以理解为将数据先积攒起来,达到一定规模之后再一次性处理。

所以概念还是很好理解的,比如批量计算就是将数据一批一批的处理,但如果每一批的这个量非常非常小,那么是不是就变成流计算了呢。像 Spark Streaming 就是这么做的。虽然 Spark Streaming 也可以做流处理,但它的实时性达不到 Flink 这个级别,就是因为它本质上还是批处理,只不过它每次处理的批(batch)非常小罢了。因此 Spark Streaming 属于伪实时,只不过对于很多场景还可以接受,因此仍被广泛使用。但如果对实时性的要求特别严苛的话,那么就需要使用 Flink 了。

因此批计算本身就是一种特殊的流计算,批和流是相辅相成的,当批小到可以忽略不计的时候就就变成流了。注意:虽然 Flink 是流计算处理框架,但它同时也支持批处理,所以 Flink 是批流一体的,同一套代码既可以批计算,也可以流计算。


为什么流式计算将成为主流?

  • 数据的实时性要求越来越高,越来越多的业务更加强调时效性,比如实时推荐、风控等业务,这些业务促使数据处理技术像流式计算慢慢普及。
  • 流计算技术日趋成熟,像 Spark Streaming、Flink 等一系列流式计算引擎变得越来越稳定、同时也越来越容易上手。因此越来越多的用户选择流处理技术,来处理自身的一些业务。
  • 批计算会带来一些计算和存储上的成本,以往对于离线做大数据处理来讲,数据需要先存储在分布式文件系统当中,然后再基于这些数据进行分析,这就导致存储成本相对较高。

然而不管是流计算还是批计算,Flink 都是支持的。


适合流计算的场景

  • 交通工具、工业设备和农业机械上的传感器会将数据发送到流处理应用程序,该应用程序会监控性能,从而提前检测潜在的缺陷。
  • 金融机构实时跟踪市场波动,计算风险价值,然后根据股票价格变动自动重新平衡投资组合。
  • 房地产网站跟踪客户移动设备中的部分数据,然后根据其地理位置实时建议应走访的房产。

当然场景还有很多,比如电商的推荐系统,就是最让人反感的 "猜你喜欢",还有天气预测、流量统计等等,场景非常多。

主流的流式计算引擎

我们说流计算引擎(或者框架)有很多种,那么它们的特点如何呢?

Storm

  • 最早使用的流处理框架,社区比较成熟
  • 支持原生流处理,即单事件来处理数据流,所有记录一个接一个处理
  • 延迟性低,在毫秒级
  • 但是消息保障能力弱,消息传输可能会出现重复,但不会丢失
  • 吞吐量比较低

Spark Streaming

  • Spark Streaming 属于 Spark API 的一个扩展
  • 以固定间隔(如几秒钟)处理一段段的批处理作业,即微批处理
  • 延迟性较高,在秒级
  • 消息保障能力强,能保证消息传输既不会丢失也不会重复
  • 具有非常高的吞吐

Spark Streaming 实际上就是将数据转成一个个的微批,然后交给 Spark Engine 处理完毕之后再发送给下游。所以如果对实时性要求不是特别高,对延迟的容忍度比较大的话,那么采用 Spark Streaming 是一个非常不错的选择。


Flink

  • 真正的流处理框架,底层采用的数据模型是 DataFlow Model
  • 和 Storm 一样,延迟不超过毫秒级
  • 消息能够既不丢失也不重复
  • 具有非常高的吞吐
  • 原生支持流处理

经过对比,显然 Flink 无疑是最优的,因为选择一个流处理框架我们主要从四个方面考虑:

  • 低延迟:是否能达到毫秒级
  • 高吞吐:是否能达到每秒千万级吞吐
  • 准确性:是否能保证 Exactly-Once 语义,消息不会重复也不会丢失,只会被传输一次
  • 易用性:是否易于开发,比如支持 SQL,以及其它的编程语言

而能够同时满足以上四点的只有 Flink,其它的都有相应的缺点。比如:Storm 不能保证高吞吐、以及不支持 SQL;Spark Streaming 延迟相对较高,不能做到真正的实时,只能是伪实时。

因此,Flink 是我们的最终选择,它不仅高吞吐、低延迟,还支持水平扩展。很多大公司的 Flink 应用程序在数千个 CPU 核心上运行,每天处理数万亿的事件,维护几 TB 大小的状态。然后 Flink 的生态也非常好,可以与 YARN、K8S 轻松集成。

有界数据流和无界数据流

根据官方的定义,Flink 是一个分布式、高性能的流处理引擎,用于对无界数据流有界数据流进行有状态的计算。这里面出现了三个关键词:

  • 无界数据流
  • 有界数据流
  • 有状态

我们分别解释。


无界数据流

  • 定义了流的开始,但没有定义流的结束。
  • 会无休止地产生数据。
  • 无界数据流必须持续处理,也就是接收到数据后就立即处理,不能等到所有数据都到达再处理,因为数据输入是无限的,没有边界。

比如监听 Kafka 的某个主题,显然这是一个无界数据流,因为只要有消息进入主题,就会源源不断地发送过来。


有界数据流

  • 定义了流的开始,也定义了流的结束。
  • 可以在接收完所有数据之后再一次性处理。
  • 可以对数据进行排序。
  • 有界数据流的处理通常被称为批处理。

比如读取一个已知大小的文件,显然这是一个有界数据流。

需要注意的是,数据流是有界还是无界,不取决于它的大小,而是取决于它是否有边界。像 Kafka 返回消息,不管消息的大小、频率如何,它都是无界的。而读取文件,不管这个文件是 1MB,还是 1GB,如果提前知道了它的大小,那么它就是有界的。

再比如快速排序和堆排序都可以解决 TopK 问题,但快排要求数据必须一次性全部给出,而堆排则不需要。堆排序只需维护包含 K 的元素的堆即可,每来一个元素,就和堆顶元素进行比较,然后对堆进行调整即可。所以快排只能计算有界数据的 TopK,而堆排可以同时计算有界数据和无界数据的 TopK。举个游戏充值的例子,游戏要实时展示充值金额最多的前 100 名玩家,显然这是一个无界数据流,因为用户随时都有可能充值,因此需要用堆排序来解决,而不能用快排。


有状态

有状态的计算意味着系统可以记住过去的信息,即系统中的计算可以利用之前已经计算好的结果。这是通过在计算过程中维护一些状态信息来实现的,使得每一次计算都可以根据之前的计算结果进行。这种状态信息可以是非常简单的,比如一个计数器,也可以是非常复杂的,比如一个大型的哈希表或者是其它数据结构。

状态信息可以保存在内存中(速度快,可靠性差),也可以保存在分布式系统中(速度慢,可靠性高)。

有状态的计算在复杂的流处理应用中极其重要,它使得 Flink 能够处理如窗口聚合、事件会话、模式匹配等高级流处理模式。通过利用状态,Flink 应用可以更加灵活和强大,能够处理复杂的业务逻辑,实现精细化管理和决策。

我们处理数据的目标是:低延迟、高吞吐、结果的准确性和良好的容错性,这些 Flink 都是具备的,具体特点如下:

  • 高吞吐和低延迟:每秒处理数百万个事件,毫秒级延迟;
  • 结果的准确性:Flink 提供了事件时间(event-time)和处理时间(processing-time)两种语义,对于乱序事件流,事件时间语义仍然能提供一致且准确的结果;
  • 精确一次(exactly-once)的状态一致性保证;
  • 可以连接到常见的存储系统,如 Kafka、Hive、JDBC、HDFS、Redis 等等;
  • 本身具备高可用,再加上与 K8S、YARN 和 Mesos 的紧密集成,以及从故障中快速恢复和动态扩展任务的能力,Flink 能做到以极少的停机时间 7 x 24 全天候运行;

Spark 以批处理为根本,我们知道 RDD 是 Spark 的基本数据结构,而 Spark Streaming 的 DStream 就是 RDD 的集合,只不过每个 RDD 的数据量很小,但它仍然是批处理。将 DAG 划分为不同的 Stage,一个完成后才可以计算下一个。

比如每隔 3 秒发送一批数据,这一批数据就是一个 RDD。


但 Flink 是以流处理为根本,基本数据模型是数据流,以及事件(Event)序列。Flink 是标准的流执行模式,一个事件在一个节点处理完后,可以直接发往下一个节点进行处理。

这里需要补充一下什么是事件时间、什么是处理时间。

  • 事件时间:事件产生的时间;
  • 处理时间:处理事件时的时间;

比如一个事件是当天的 23:59:59 产生的(事件时间),但在下一天的 00:00:01 开始处理(处理时间),那么处理结果是归为当天还是下一天呢?Flink 将这个选择权交给了你,看你需要用哪种时间。

正如 Spark 有 RDD、SparkSQL、SparkStreaming 等等,Flink 也是如此,它的 API 也是分层级的。越顶层越抽象,表达含义越简明,使用越方便;越底层越具体,表达能力越丰富,使用越灵活。

Stateful Stream Processing 指的是有状态流计算,它包含了大量的处理函数,底层 API 和 DataStream API 相集成,可以处理复杂的计算。

DataStream API(流处理)和 DataSet API(批处理)封装了底层的处理函数,提供了通用的模块,比如转换、连接、聚合、窗口等操作。注意:Flink1.12 以后,DataStream API 已经实现了真正的批流一体,所以 DataSet API 已经过时。

Table API 是以表为中心的声明式编程,并且表可能会动态变化。Table API 遵循关系模型:表有二维数据结构,类似于关系型数据库中的表,并支持 select、project、join、group by、aggregate 等操作。然后表可以和 DataStream / DataSet 无缝切换,允许程序将 Table API 和 DataStream / DataSet API 混合使用。

SQL 这一层在语法以及表达能力上与 Table API 类似,但它是以 SQL 查询表达式的形式表现程序。SQL 抽象与 Tabel API 交互密切,可以直接在 Table API 定义的表上执行操作。说到 SQL 你应该会想到 Hive SQL,但 Hive SQL 是做批处理的,而 Flink SQL 支持流处理,这就很方便了。并且 Flink 对 SQL 进行了大量的优化,它比我们自己用 API 开发的性能要高。

到目前为止我们对 Flink 已经有了一个最最基本的了解,下面来看看 Fink 集群的基本架构。

我之前介绍过 Spark,强烈建议先去看 Spark,再来学 Flink。因为 Spark 和 Flink 里面有很多概念都高度相似,并且都支持 Standalone 模式,都支持跑在 YARN 上,所以了解了 Spark 再来看 Flink 会非常简单。而且关于 Spark 的那篇文章介绍的非常详细,包括 HDFS 环境搭建、YARN 的运行原理等等,因此一些重复的东西这里就不介绍了。

整个 Flink 集群也是遵循 Master Worker 架构模式,其中 JobManager 为管理节点,TaskManager 为工作节点,它们的功能如下。

1)JobManager:简称 JM,它是管理节点,负责管理整个集群、Job 的调度与执行,以及 CheckPoint 的协调。

2)TaskManager:简称 TM,它是工作节点,可以有很多个,每个 TM 一般部署在不同的节点上。JM 会将整个作业(Job)切分成多个 Task,然后分别发送到不同的 TM 上面,再由 TM 提供计算资源来对任务进行处理。

当一个 Job 被提交到 Flink 集群时,它会被分解成多个 Task,并且每个 Task 会被分配到不同的 TaskManager 上执行。Flink 会根据数据源、数据的分区方式以及 Job 的算子等信息来进行任务的划分和调度,以实现最佳的性能和吞吐量。在任务执行期间,Flink 会对 Task 的状态进行管理和监控,以便在出现故障或异常情况时进行恢复或重试。

3)Client:客户端,它负责接收用户提交的应用程序(包含代码文件、Application Jar 等等),然后在本地内部产生一个 JobGraph 对象(也就是我们上面说的 Job)。接着通过 RPC 的方式将 JobGraph 对象提交到 JobManager 上面,如果成功提交,那么 JM 会给客户端返回一个 JobClient,客户端通过 JobClient 可以和 JM 进行通信,获取 Job 的执行状态。

JobManager

简单介绍了 Flink 集群架构之后,我们再来详细介绍一下里面的组件,首先是 JM。

这里面涉及了很多概念,我们慢慢说。


Checkpoint Coordinator

Checkpoint Coordinator 负责管理作业的状态检查点(Checkpoint),比如创建、维护和恢复。在分布式流处理系统中,状态检查点是一种重要的机制,它会记录一些状态性的数据,用于实现故障恢复和确保数据处理的一致性。下面是 Checkpoint Coordinator 的一些主要职责和工作流程:

  • 触发检查点:Checkpoint Coordinator 定期触发检查点操作,它会向作业中的所有任务发送指令,要求它们开始执行检查点操作。这个过程是协调的,确保所有的任务都能在相同的逻辑时间点上保存它们的状态。
  • 协调状态的保存:任务在接到触发检查点的指令后,会将自己的状态保存到配置的状态后端(State Backend),如 HDFS、RocksDB 等。状态的保存可以是异步的,以减少对数据处理的影响。
  • 管理检查点元数据:每个检查点的状态信息和元数据会被记录下来。Checkpoint Coordinator 负责管理这些信息,包括检查点的完成情况、位置、大小等。
  • 故障恢复:当作业出现故障需要恢复时,Checkpoint Coordinator 会选择一个合适的检查点来恢复整个作业的状态。这个选择可以基于不同的策略,例如选择最近的一个完成的检查点。
  • 维护检查点历史:为了支持故障恢复和减少存储空间的占用,Checkpoint Coordinator 需要维护检查点的历史记录。它会根据配置的策略删除旧的检查点,只保留必要的数量。

Checkpoint Coordinator 的设计使得 Flink 能够提供精确一次(exactly-once)的状态一致性保证,这是实现端到端精确一次语义的关键。通过定期创建状态检查点,Flink 能够在遇到故障时快速恢复,从而减少数据丢失和重复处理,确保数据处理的准确性和效率。


JobGraph >>> Execution Graph

  • JobGraph 是 Flink 作业的逻辑表示,它描述了作业的各个操作符(如 map、filter、reduce 等)之间的数据流关系。JobGraph 是在客户端(比如你的 Flink 程序)构建的,并且在提交作业给 Flink 集群时传递给 JM。
  • Execution Graph 是 JobGraph 的物理执行计划的表示,它由 JM 在接收到 JobGraph 后生成。Execution Graph 细化了 JobGraph,包括任务(Task)的并行实例、资源分配、任务之间的物理数据流等。

所以 JobGraph 定义了作业的静态结构,它包含了所有的逻辑操作但不涉及具体的执行计划。Execution Graph 是作业执行的基础与详细蓝图,包含了执行作业所需的所有细节信息。并且 Execution Graph 的生成考虑了集群的实际资源情况和作业配置,以决定如何最有效地执行作业。

总之 JobGraph 和 Execution Graph 可以类比 SQL 语句和 SQL 执行计划,JM 接收到 JobGraph 后,会基于当前的集群状态和作业配置生成 Execution Graph,这一过程包括决定任务的并行度、任务间数据的分发方式、资源的分配等。然后 JM 再将 Execution Graph 拆分成多个执行单元(Task)发送到 TM 上面,这些 Task 是 Flink 作业在 TM 上执行的基本单元。


RPC 通信(Actor System)

JM 和 TM、以及和客户端都是通过 Akka 来实现 RPC 通信,而 Akka 里面最核心的组件就是 Actor System,它负责两个进程之间的通信。


Job 接收(Job Dispatch)

客户端实际上会提交不同的 JobGraph,所以 JM 也会接收不同的 JobGraph,然后再拆分成多个 Task 分发到不同的 TM 上,因此这会涉及到 Job 的分发过程,而 JM 内部有一个 Job Dispatch 组件专门负责干这件事情。


集群资源管理(ResourceManager)

JM 内部有一个集群资源管理器,我们说 JM 会负责整个集群资源的管理,而不同的部署模式会对应不同实现的资源管理器,比如 On Standalone、On Kubernetes、On Yarn 等等。


TaskManager 的注册与管理

JM 会负责 TM 的注册,当我们尝试启动一个 TM 时,它会主动和 JM 建立 RPC 连接,这个过程中 JM 会将对应的 TM 的信息保存在本地,之后不断地对 TM 进行心跳检测。

TaskManager

再来看看 TM,它是真正负责提供计算资源来执行任务的。

JM 会将 Task 发送到 TM 上,TM 内部有一个 Task Scheduling,负责将 Task 分配到 Task Slot(任务资源槽)上执行。一个 Task Slot 可以看作是执行任务所需的最小资源容器(资源分配的基本单位),包含 CPU 资源、内存资源以及执行线程。每个 TM 配置有一定数量的 Task Slot,每个 Task Slot 能够执行一个任务链(Task Chain),所以每个 TM 上可以有很多个任务在执行,这取决于作业的并行度和集群的资源状况(Task Slot 的数量)。

Task Scheduling 非常智能,它会根据作业的并行度将任务分配到可用的 Task Slot 上执行。如果一个 TaskManager 上的所有 Task Slot 都已被占用,而作业还需要更多的资源执行任务,那么这些任务就需要等待或者在其它有空闲 Task Slot 的 TaskManager 上执行。另外在执行过程中如果遇到故障(比如 TaskManager 故障),Task Scheduling 会重新调度受影响的任务到其它 Task Slot 上执行,以保证作业的持续运行。

然后是 Data Exchange,表示数据交互。像 MapReduce、Spark 程序执行的时候经常会涉及到 Shuffle 的操作,而 Shuffle 就是数据在不同的节点之间进行传输和交互的过程(代价会比较昂贵)。对于 Flink 也是如此,如果数据进行了一个 GroupByKey 操作,那么就会涉及到数据的交互,而在 TM 里面也提供相应的 Shuffle Environment 来支持 Shuffle 操作。

当出现 Shuffle 操作时意味着数据要跨节点传输,而传输也是通过 RPC 的方式,通过 Network Manager 组件提供的基于 Netty 实现的网络通信站。

在 TM 里面还有一个比较重要的组件是 Memory Management,它和内存管理相关。当 Task 提交进来之后肯定还会伴随着相应的数据,这些数据肯定是需要内存的,而 Memory Management 则负责这些内存的管理。

最后就是我们之前提到的,当 TM 启动之后会向 JM 进行注册,因为 JM 要知道当前集群中都有哪些 TM 以及相应的状态。

Client

最后是客户端,它会接收用户发来的应用程序,在内部生成对应的 JobGraph,然后通过 RPC 的方式发给 JM。

用户可以使用 Java、Scala、Python、SQL 等语言编写 Flink 应用程序,然后发送给客户端 Client,再由 Client 提交到集群。而客户端在接收应用程序的时候,会在本地启动一个相应的 Client 进程,该进程负责解析用户提交的应用程序。解析的时候会将应用程序里面的 main 方法拿出来在自己的进程里面执行,执行的主要目的就是生成对应的 JobGraph 对象。

补充:JobGraph 其实就是应用程序的一种 DAG 表达,如果你熟悉 Spark 的话,那么肯定瞬间就能理解。在 Flink 应用程序中,开发者通过定义数据源、转换操作和数据汇总来构建数据处理逻辑,而 Flink 内部会将这些定义转换成一个 DAG,其中的节点代表数据处理操作,边代表数据流动的方向。

开发者在编写 Flink 应用程序时不会直接操作 JobGraph,而是使用 Flink 提供的高级 API 来定义数据处理逻辑,客户端在将应用程序提交到 Flink 集群之前,会先将这些逻辑转换成 JobGraph。一旦 JobGraph 被生成,它会作为作业的一部分提交给 Flink 集群。JobManager 接收到 JobGraph 后,负责将其转换成 Execution Graph,这是更为详细的执行计划,包括具体的任务分配、物理数据流路径等。最后基于 Execution Graph 拆分出多个 Task,发送到指定的 TaskManager 上面。

另外在 Client 里面还有几个核心的概念,比如说 Context Environment,它表示上下文环境,实际上 Client 第一步就会创建 Context Environment,然后在 Context Environment 中执行 main 方法。当生成 JobGraph 对象时,会将它和依赖一块提交到 JM 上(Job Submit),当然这个过程也是通过 RPC 的方式。

JobGraph

我们说 Client 会提交 JobGraph 到 JM 上,那么这个 JobGraph 到底长什么样子呢?

首先我们可以采用不同的 API 编写应用程序,比如:DataStream、DataSet、Flink SQL、Table 等等。不管采用哪种方式,编写出的应用程序最终都要打包成相应的 Jar 文件(以 Java、Scala 为例)。当然这也不是唯一的方式,比如 SQL Client 模式可以直接向客户端提交一些 SQL 脚本,但绝大部分情况都是打包成一个可执行的 Jar 文件,再调用 flink run 命令执行。

然后图中的客户端内部有一个 Exectuor,也就是执行器,当然执行器也分为几种类型,比如本地执行器、远程执行器、On Yarn 执行器,执行器后面会详细说,先来看看它的通用功能。

  • 首先会通过反射的方式调用应用程序里面的 main 方法,对应 Application Code 的执行,这个前面说过了;
  • 然后调用应用程序的 Execute 方法,将应用程序转成 StreamGraph,从图中可以看出这个 Streamgraph 只是描述了一个转换的大概逻辑,仅仅只是一个 DataFlow,但没有体现出算子的并行度;
  • 最后再调用 submit 将 StreamGraph 转成 JobGraph(一个有向无环图),此时会对每一个算子进行拆解、指定相应的并行度;

因此 JobGraph 就是应用程序对应的一个 DAG,也就是通过有向无环图的方式去表达应用程序。而且不同接口的应用程序最终都会生成 JobGraph,此时也具备统一性,也就是不管采用什么样的 API,最终提交给 JM 的都是相同标准的 JobGraph。

Flink 集群的运行模式有以下几种:

  • Session Mode
  • Per-Job Mode
  • Application Mode(Flink 1.11 版本提出)

集群运行模式分为这几种的原因主要从两方面考量:1. 集群的生命周期和资源隔离;2. 程序 main 方法的执行是在 Client 中还是在 JobManager 中。关于第二个方面可能有人会纳闷,之前不是说了吗?main 方法是在 Client 的 Executor 组件中执行的。但是在 Flink 1.11 版本的时候,Application 的 main 方法运行在 Flink 集群上,而不在客户端。

那么下面来分别看看这几种运行模式之间的区别。

集群运行模式

Session Mode

对于 Session 集群运行模式来说,所有提交的 Job(或者说 JobGraph)都在一个 Runtime 中运行。

JM 和多个 TM 整体组成一个集群,所有的 Job 都会发送到同一个 JM 上,因此 JM 会管理多个不同的 Job 并进行协调,以及为 Job 在 TM 上申请 Task Slot 进行计算。因此 Session 模式的特点就是多个 Job 共享同一个 JM,或者说同一个集群的 Master 节点。而对于客户端而言,会执行一些方法生成相应的 JobGraph 对象,然后连同依赖的 Jar 包一块传到 Master 节点上面。

所以对于 Session 模式的集群,JM 的生命周期不受 Job 的影响,不管提交多少个 Job,JM 始终处于运行状态。

Session 模式的好处显然是资源充分共享,利用率高,因为所有的 Job 都提交到同一个 JM 上。并且此时的 Job 由 JM 负责管理,运维也会简单。而缺点也明显,就是资源隔离相对较差,而且对于非 Native 类型部署时,TM 不容易扩展、Slot 计算资源伸缩性较差(Native 是什么后面会说)。


Per-Job Mode

从名字上可以看出,对于 Per-Job 模式而言每一个 Job 对应一个集群,每个 Job 独占一个 JM。

当我们提交一个 Job 之后,它会交给单独的 JM 负责,然后 JM 会去启动对应的 TM。而 TM 也会和相应的 Job 互相绑定,之间不会形成共享机制。比如另一个 Job 提交上来之后,会启动一个新的 JM,然后由新的 JM 去启动对应的 TM。而当 Job 执行完毕之后,JM 和对应的 TM 会被释放、回收,因此 Per-Job 一个最大的特点就是 JM 的生命周期和 Job 是绑定的。

对于 Per-Job 模式而言,在一套集群资源管理器(Cluster Resource Manger)上会启动多个 JM,它的好处就是 Job 之间的隔离是充分的。并且每个 JM 会为 Job 指定相应的 TM,而根据 Job 的不同 TM Slots 的数量可以不同;至于该模式的缺点也肯定都能想到,那就是浪费资源,因为 JM 是需要消耗资源的。此外 Job 的管理不再由 JM 负责,而是完全交给资源管理器,此时的管理会比较复杂。


Application Mode

无论是 Session 模式还是 Per-Job 模式,流程都是一样的,比如用户想要提交一个应用程序:

  • 1. 首先需要下载应用依赖的一些 Jar 包,以及安装客户端
  • 2. 在 Client 中执行 main 方法生成 JobGraph 对象
  • 3. 将 JobGraph 和依赖一块提交到集群上运行,这一步会消耗带宽,并且依赖每次都要上传
  • 4. 等待 Job 运行结果

但是问题来了,首先生成 JobGraph 是需要消耗 CPU 资源的,任务多的话会导致客户端压力增大。而且生成 JobGraph 的过程是同步的,任务一多还会造成阻塞,加上 JobGraph 和依赖的 Jar 包的提交也需要时间,如果 Jar 包比较大,会非常依赖带宽,更何况这些依赖每次都要上传。

对于流平台而言重要的就是实时,不能陷入等待。因此后来就有人提出,能不能把生成 JobGraph 这一步从客户端移到 JM 上,这样可以释放本地客户端的压力。最终在客户端里,只需要负责命令的提交、下发,以及等待 Job 的运行结果即可。而这便是 Application Mode。

可以看到 JM 首先会去分布式文件存储系统中拉取依赖的包,根据应用程序生成 JobGraph 对象,然后执行、调度,整个过程都是在 JM 当中进行的。这样做的好处就是,客户端不需要再每一次都将 Jar 包提交到 JM 上,从而避免网络传输上的消耗、以及客户端的负载。而且 Application Mode 也实现了资源隔离,虽然所有的 Job 共享一个 JM,但是 JM 内部又在 Application 层面实现了资源隔离。

那么问题来了,Application 模式的资源隔离和 Per-Job 模式的资源隔离有什么区别呢?举个栗子:Per-Job 模式实现的隔离类似于虚拟机层面的隔离,每个 Job 对应的 JM 可以看成是单独的虚机,显然这是比较耗资源的;而 Application 模式实现的隔离类似容器层面的隔离,所有 Job 共享一个 JM,但它们都在各自的容器中,因此既实现了隔离又避免了资源的浪费。而在执行任务的时候,也会启动相应的 TM。

在了解完 Flink 的运行模式之后,我们来看看 Flink 的部署模式,也就是它支持部署在哪些集群资源管理器(Cluster Resource Manager)上,就目前来说 Flink 支持的资源管理器有以下几种:

  • Local
  • Standalone
  • Hadoop YARN
  • Apache Mesos
  • Docker
  • Kubernetes

Flink 如何管理和分配资源是通过资源管理器实现的,资源管理器在 Flink 架构中扮演着至关重要的角色,它负责管理作业运行所需的资源,如任务执行器(Task Executor)或 TaskManager 的分配和回收。

集群资源管理器是一个抽象概念,指的是在 Flink 集群中负责资源分配和管理的组件。不同的部署模式下,Flink 使用不同的资源管理器实现,以适应不同环境下的资源管理策略。例如:

  • 在 YARN 模式下,Flink 通过 YARN 的资源管理器进行资源的申请、分配和释放。
  • 在 Kubernetes 模式下,Flink 利用 Kubernetes 的调度和容器管理能力来管理 Pod(运行 TaskManager 的容器)的生命周期。
  • 在 Standalone 模式下,资源管理较为简单,主要由 Flink 自己的机制来管理集群资源。

当前 Standalone 和 YARN 用的还是比较多的,但 K8S 是未来的主流。

然后解释一下什么是 Native 集群部署。我们知道 Flink 有三种运行模式,对于 Native 集群部署来说,当以 Session 模式启动集群时,只启动 JM 不会启动 TM。只有在客户端提交 Job 之后,JM 才会跟资源管理器进行交互、申请资源,动态启动 TM 满足计算要求。至于其它的 Job 也是同理,因此 TM 的申请是由提交的 Job 来决定的,这样做的好处就是可以极大地利用 Cluster 上的资源,不会造成资源的预占用(没拉💩️先把坑占了)。

支持 Native 部署模式的资源管理器有 YARN、K8S、Mesos,但 Standalone 是不支持的。对于 Standalone 而言,TM 节点实例有多少个需要事先指定好、并先启动,然后 JM 才会接收 Job。而不是在 JM 接收 Job 之后再去启动 TM,因为 TM 有多少个事先都已经启动完毕了。

然后我们来看看资源管理器为 Standalone 的集群,如果用过 Spark 的话,那么应该很容易理解,因为 Spark 也有 Standalone,就是真正意义上的分布式。多个节点之间,其中一个为主节点,其余的为从节点,节点之间通过 RPC 进行通信。

在 Standalone 模式下,Flink 的资源管理相对简单直接:

  • 资源管理器(Resource Manager):虽然 Standalone 模式下的资源管理相对简单,但 Flink 仍然有一个简化版的资源管理器组件,负责在 Flink 集群内部管理和分配资源。它主要处理 TaskManager 的注册和注销,以及简单的资源分配逻辑。
  • JobManager(JM):是 Flink 集群的主要协调者,负责作业的调度、状态管理和容错。在 Standalone 模式下,JobManager 启动时会尝试从配置中获取可用的 TaskManager 资源信息,然后基于这些信息来调度作业。

需要注意的是,在更复杂的部署环境中(如 YARN 或 Kubernetes),资源管理器负责与底层平台交互,申请和释放资源,而 JobManager 则专注于作业的调度和执行逻辑,这种分离使得 Flink 能够在不同的环境中灵活部署。但在 Standalone 模式下,这种分离不是特别明显,因为资源管理相对简单。Standalone 模式通常预先启动一定数量的 TaskManager,JobManager 在作业调度时直接利用这些可用的资源。

不管是哪种部署模式,JobManager 始终负责作业的调度和管理,而资源管理器则负责底层资源的管理,两者之间会进行交互。

对于 Flink on Standalone 这种模式来说,TM 必须要事先进行注册,也意味着能够计算的资源会一开始就确定好。然后 JM 和 TM 运行的时候也会有相应的进程,如果 JM 和 TM 都在同一个节点上,那么就是单机 Standalone 模式,也就是伪集群;如果在多个节点上,那么就是多机 Standalone 模式。

如果资源管理器为 Standalone,那么 Flink 的运行模式只支持 Session。

下面我们就来搭建 Standalone 集群,首先是操作系统,像 Flink 这种大数据组件显然要跑在 Linux 上,这里我采用的是本地虚拟机 CentOS 7。但要想运行 Flink 应用程序,还需要安装 JDK,版本依旧是万年的 Java 8,这个过程就不说了。

然后我们还要去 Flink 官网下载 Flink,因为它是 Apache 的一个顶级项目,所以地址是 flink.apache.org。这里我们下载的版本是 1.17.2,然后解压到 /opt 目录下。

对于这些 Java 编写的大数据组件来说,它们的目录结构基本上都是一致的。bin 目录包含一些启动脚本,conf 目录是配置文件相关的,examples 目录里面是一些测试案例,lib 目录存放的是依赖的 Jar 包,log 目录存放日志等等。

然后再配置环境变量,source 一下:

export FLINK_HOME=/opt/flink-1.17.2/
export PATH=$FLINK_HOME/bin:$PATH

如果是单机 Standalone,那么以上就算部署完了,可如果是多机部署的话,那么还需要配置其它节点,但并不麻烦。因为所有节点的配置都完全相同,配好了一个节点,然后其它的节点再重复一遍即可,或者直接 scp 命令发送过去。

首先你的每一个节点都需要安装 JDK 并配置 JAVA_HOME 路径和环境变量,这点是毋庸置疑的,此外每个节点都要安装 Flink,并且安装路径要一致、版本要一致,最后就是这些节点之间可以互相访问。假设我们有 5 个节点,分别是 Node 1、2、3、4、5,其中一个节点(比如 Node 1)作为 Master 节点仅部署 JM,剩余的节点(Node 2 ~ Node5)作为 Worker 节点部署 TM。

然后修改配置文件,这里我们只需要修改一个节点的配置文件即可,因为所有节点的配置是一样的,一个改好了,剩余的可以直接同步过去。那么要修改哪些配置呢?首先要指定谁是主节点、谁是工作节点,因此需要修改 conf/flink-conf.yaml,该配置文件是和 Flink 集群运行时相关的。里面的第一个配置选项是 jobmanager.rpc.address: localhost,我们需要将里面的 localhost 改成 Master 节点(也就是 JM 所在节点)的地址。

然后修改 conf/masters,也是在里面指定 Master 节点的地址以及监听的端口,默认里面只有一个 localhost:8081。我们需要将 localhost 改成 Master 的地址,当然为了保证高可用,你可以配置多个 Master 节点。注意:这个 8081 指的是 webUI 端口,我们通过 Master 的 ip:8081 即可在浏览器上查看 Flink 集群的运行状态;而在 conf/flink-conf.yaml 文件中有一个 jobmanager.rpc.port: 6123,这个 6123 是 RPC 通信的端口。

接下来再修改 conf/workers,显然我们要配置工作节点了,里面只有一个 localhost,说明默认当前节点既是主节点也是从节点(或者说工作节点)。然后我们在里面写上工作节点的地址,如果建立了主机名到 IP 之间的映射,那么也可以写主机名。

xx.xx.xx.xx  # Node 2 节点
xx.xx.xx.xx  # Node 3 节点
xx.xx.xx.xx  # Node 4 节点
xx.xx.xx.xx  # Node 5 节点

以上就配置好了,然后上面这些所做的配置要同步到所有的节点中,因此我们一般先修改 Master 节点的配置文件,配好之后再同步到其它的 Worker 节点上。

下面我们来实际搭建一个具有三个节点的 Standalone 集群。

多机 Standalone 集群搭建

我本地有三台虚拟机,主机名分别是 satori001、satori002、satori003,我们将主机 satori001 作为 Master 节点,satori002 和 satori003 作为 Worker 节点,搭建一个三个节点的集群。

我上面演示使用的主机就是 satori001,Java 和 Flink 都已经安装完毕,路径如下:

  • Java 安装目录:/opt/jdk1.8.0_221/
  • Flink 安装目录:/opt/flink-1.17.2/

然后我们来修改配置文件,首先是 conf/flink-conf.yaml,将 jobmanager.rpc.address 改成 Master 节点的地址。

# 指定 satori001 是 Master
jobmanager.rpc.address: satori001
# 节点之间的通信端口,默认为 6123
jobmanager.rpc.port: 6123

注意:如果你使用的是云服务器,那么会有一个内网 IP 和一个公网 IP。当多台服务器位于同一网段时,一定要使用内网 IP,尽管指定公网 IP 毫无疑问也是可以的,但公网的通信速度远没有内网快,而且使用公网还会产生额外的流量费用。但如果你使用的服务器不在同一个内网网段,那么就必须使用公网 IP 了。

所以使用公网 IP 和内网 IP 都是可以的,但集群的节点之间采用内网通信的速度要远快于公网,不过这要求你的节点必须位于同一网段。我们在实际开发中,同一个集群里面的所有节点都会位于同一网段,节点之间使用内网通信。

然后 flink-conf.yaml 里面还有几个配置也要改一下。

jobmanager.rpc.address: satori001
jobmanager.rpc.port: 6123

# 用于指定 JobManager 绑定的 IP 地址
# 默认是 localhost,表示只能从本机访问 JM,我们将其改成 satori001 的内网 IP
# 我这里已经建立了主机名到内网 IP 的映射,所以直接使用主机名即可
jobmanager.bind-host: satori001
# REST API 绑定的地址,这里改成 0.0.0.0,让外界能够通过网络接口访问
rest.bind-address: 0.0.0.0

接下来修改 conf/masters,指定 Master 节点 IP 和绑定的 webUI 端口:

satori001:8081

再修改 conf/workers,指定 worker 节点的 IP:

satori002
satori003

以上 Master 节点(satori001)就配置完毕了,然后配置 Worker 节点(satori002 和 satori003)。整个过程一模一样,同样要安装 JDK、Flink,并且版本和安装目录要保持一致,最后再修改 Worker 节点的配置。因为我们说配置 Worker 节点和配置 Master 节点一模一样,并且 satori002、satori003 还没有安装 Flink,所以我们可以通过 scp 命令将 satori001 节点的整个 Flink 目录同步过去,这样最方便。

# 将 satori001 节点的 flink-1.17.2 目录同步到其它节点的 /opt 下面
scp -r /opt/flink-1.17.2/ root@satori002:/opt
scp -r /opt/flink-1.17.2/ root@satori003:/opt

到此整个集群就配置完毕了,下面就可以启动集群了。不过一定要保证 6123 端口在所有的节点之间都是开放的,否则节点之间无法通信,因为我当前使用的是本地虚拟机,所以端口之间是畅通的。

  • 启动 Flink 集群:$FLINK_HOME/bin/start-cluster.sh
  • 关闭 Flink 集群:$FLINK_HOME/bin/stop-cluster.sh

我们只需要在 Master 节点上执行即可,会自动启动或关闭 Worker 节点上的 TM。

默认情况下,你在启动或关闭集群的时候应该会让你输入 Worker 节点的登录密码,而且每一个 Worker 节点都要输入一遍。显然这就太麻烦了,因此节点之间要配置免密码登录。

下面启动集群,因为配置了环境变量,所以在 Master 节点上直接输入 start-cluster.sh 即可。

很明显,输出的信息表示集群启动成功了,其中 satori001 是主节点、satori002 和 satori003 是工作节点,资源管理器是 Standalone,运行模式是 Session 模式。然后我们输入 jps 命令查看一下:

当前几个节点除了 Flink 之外,还启动了 HDFS、YARN、Kafka 等进程,这些都是之前在介绍 Spark 的时候启动的。对于 Flink 而言,箭头所指的便是 Flink 相关的进程,当然主节点和从节点之间启动的服务是不同的。然后再来通过 webUI 查看一下,输入 satori001:8081 即可。

当然还有很多其它相关的信息,可以自己点击查看,到此为止我们的 Standalone 多机部署以及启动就已经完成了,而接下来的关键就是如何把应用程序提交到 Standalone 集群上面。

编写 Python 应用程序提交到集群运行

由于我们还没有学习如何编写 Flink 应用程序,所以这里就用一个只包含简单 print 语句的 py 文件进行测试。

# test_flink.py
if __name__ == "__main__":
    print("Hello World")

Flink 的 examples/python 目录里面已经提供了一些测试案例,我们也可以直接拿它来做实验。

那么怎么提交到集群上面运行呢?

flink run -py 代码文件

通过 flink run 即可运行应用程序,但由于 Flink 既可以运行 Java 程序、也可以运行 Python 程序,所以这里我们需要指定 -py 参数,表示运行的是 Python 程序。但默认情况下解释器使用的是 Python2(除非你终端输入 python 进入的就是 Python3),要是我们想指定 Flink 使用 Python3 解释器的话,则需要配置一个环境变量。

export PYFLINK_CLIENT_EXECUTABLE=/usr/bin/python3

下面来测试一下:

很明显结果是成功的,当然代码里面没有涉及到任何与 Flink 有关的内容,只是演示如何提交一个 Python 应用程序。另外 flink run 是同时支持 Java、Python 等语言的,但这里只介绍 Python,因为我本人不是 Java 方向的,所以关于 Java 如何对接 Flink 就不说了(主要是我不会)。

在之前我们说过,不管使用哪种 API 进行编程,最终客户端都会生成 JobGraph 提交到 JM 上。但毕竟 Flink 的内核是采用 Java 语言编写的,如果 Python 应用程序变成 JobGraph 对象被提交到 Flink 集群上运行的话,那么 Python 虚拟机和 Java 虚拟机之间一定有某种方式,使得 Python 可以直接动态访问 Java 中的对象、Java 也可以回调 Python 中的对象。没错,实现这一点的便是 py4j。

然后提交任务也可以通过 webUI 界面实现,举个例子:

但很明显这种方式是针对 Java 的,因为它要求上传一个 Jar 文件,因此对于 Python,还是通过 flink run 命令来提交吧。


提交单个 py 文件我们知道怎么做了,但如果该文件还导入了其它文件该怎么办呢?其实不管项目里的文件有多少,启动文件只有一个,我们只需要把这个启动文件提交上去即可。下面举例说明,当然这里仍不涉及和 Flink 相关的内容,先把如何提交程序这一步给走通。因为不管编写的程序多复杂,提交这一步骤是不会变的。

flink_test 就是我们的主目录,里面有一个 apps 子目录和一个 main.py 文件,apps 目录里面有三个 py 文件,对应的内容分别如图所示。然后我们来将其提交到 Flink Standalone 集群上运行,命令和提交单个文件是一样的:

Flink 在执行时会自动将启动文件(这里是 main.py)所在的路径加入到环境变量中,所以执行没有问题。但如果我们将 main.py 给改一下:

from apps import add, sub
# other_file 位于 ~/dependency 目录中
from other_file import name

print("add:", add(1, 3))
print("sub:", sub(3, 1))
print("name =", name)

此时程序执行就会报错:找不到 other_file.py。

因为它不在 Python 的搜索路径中,那么这时候该怎么办呢?

通过 --pyFiles 指定即可,如果它后面跟的是目录,那么会将该目录下的所有内容都作为依赖提交到集群,并在作业运行时添加到 Python 解释器的搜索路径中。如果 --pyFiles 后面跟的是具体的文件,那么就只会提交相应的文件。当然不管是目录还是文件,都可以指定多个,之间用逗号分隔。

补充:我们还可以将依赖打包成一个 .zip 或 .egg 文件,然后通过 --pyFiles 提交上去。

以上就是向 Flink 集群提交 Python 应用程序,还是比较简单的。

介绍完 Flink on Standalone 之后,再来看看 Flink on YARN。YARN 对于做大数据开发的人来说并不陌生,它是一个非常主流的集群资源管理器,像 Spark 除了有 Standalone 模式之外,也有 Spark on YARN。YARN 可以为上层应用提供统一的资源管理和调度,在其之上可以运行各种作业,比如 MapReduce 作业、Spark 作业,当然还有这里的 Flink 作业。

对于 Standalone 模式而言,它需要有多个节点,但多个节点就意味着要多消耗资源,并且为了防止 Master 单点故障,我们一般还要配置高可用。可服务器的资源总是紧张的,但对于大数据公司来说,基本上都会有 Hadoop 集群(YARN 集群),在这种情况下如果再单独准备 Standalone 集群,那么对资源的利用率就不高了。

所以大部分公司都会将 Flink 以及 Spark 应用程序运行在 YARN 集群中,因为 YARN 本身就是一个资源调度框架,负责对运行在其内部的计算框架进行资源调度管理。而作为典型的流处理计算框架,Flink 本身也可以直接运行在 YARN 中,并接受 YARN 调度。因此,对于 Flink on YARN 来说,无需部署 Flink 集群,只需要有一个节点,充当客户端,即可提交任务到 YARN 集群中运行。

至于 Yarn 一般集成在 Hadoop 中,Hadoop 肯定不陌生,它是大数据生态圈中最基本的框架,由三个部分组成:分布式文件存储系统(HDFS)、分布式计算(MapReduce)、集群资源管理(YARN)。

我们之前的文章中已经详细介绍过 YARN 了,这里再来回顾一遍。

里面有很多角色,可以从两个层面进行分类。

资源管理层面

  • Resource Manager:管理整个集群资源,相当于 Master,后续简称 RM。
  • Node Manager:管理所在节点的资源,相当于 Worker,后续简称 NM。

所以 NM 负责管理单个节点,而每个节点上都有一个 NM,那么多个 NM 就能将所有节点的资源都管理起来,然后这些 NM 统一去向 RM 进行汇报。所以整个资源管理,就是 RM 配合一堆 NM 完成的。但是光有资源还不够,因为最终的目的还是要完成计算的,所以必须要有干活的。


任务计算层面

  • Application Master(后续简称 AM):任务的管理者,当任务在 NM 上运行的时候,就是由 AM 负责管理,比如任务失败重启,任务资源分配,任务的工作调度,每个任务都对应一个 AM。
  • Task:任务的执行者,真正用来干活的。
  • Container:这个图上没有画,但 AM 和 Task 都运行在 Container 里面。在 YARN 中它代表了资源的抽象,封装了节点上的多维度资源,如内存、CPU、磁盘、网络等等。

然后我们来看看 Flink 如何整合到 YARN 上面,Flink 的运行模式有三种,YARN 都是支持的。这里以 Session 模式为例:

客户端首先要连接到 YARN 集群(ResourceManager)申请资源,然后 RM 会接收到客户端提交的资源申请信息,并在 NM 上启动相应的 AM。接下来客户端提交作业,在 AM 所在的容器内部,Dispatcher 再将 JM 相关的进程启动起来(它们运行在同一个 Container 中),整个过程就是初始化 Flink JM 的过程。我们注意到里面还有一个 Flink-YARN ResourceManager,它其实是和整个 YARN 的集群资源管理器相互绑定的。

JM 收到 Job 之后会创建 Execution Graph,然后再切分成多个 Task 并申请 TM,TM 向 JM 进行注册,JM 发送任务到 TM 上执行(同样运行在 Container 里面,因为它是资源的抽象)。所以 Flink on YARN 这种模式,TM 是可以动态申请的。


我们再来总结一下这几个角色的作用。

Resource Manager

  • 1)处理客户端请求。客户端想访问集群,比如提交一个应用程序,或者说作业(可以是 Spark 作业,也可以是 MapReduce 作业),要经过 Resource Manager,它是整个资源的管理者,管理整个集群的 CPU、内存、磁盘等资源;
  • 2)监控 Node Manager;
  • 3)启动或监控 Application Master;
  • 4)资源的分配和调度;

Node Manager

  • 1)管理单个节点上的资源,Node Manager 是当前节点资源的管理者,当然它也需要跟 Resource Manager 汇报;
  • 2)处理来自 Resource Manager 的命令,比如启动 Container 运行 Application Master;
  • 3)处理来自 Application Master 的命令,比如启动 Container 运行 Task;

Application Master

  • 1)某个任务的管理者。当任务在 Node Manager 上运行的时候,就是由 Application Master 负责管理,每个任务都会对应一个 AM。当然 JM 也是如此,它也对应一个 AM;
  • 2)负责数据的切分;
  • 3)为应用程序向 RM 申请资源,并分配给内部的任务;
  • 4)任务的监控与容错;

Task

  • 1)任务的实际执行单元,运行在 AM 申请到的 Container 中。每个任务独占一个 Container,从而实现有效的资源管理和隔离。

所以整个 YARN 的流程应该不复杂,RM 管理全局资源,NM 管理单个节点资源,AM 管理单个任务,Task 负责执行任务,Container 代表了资源的抽象,AM 和 Task 都运行在 Container 中。因为 Container 是资源的抽象,所以不光是 Task,JobManager 也运行在 Container 中,也对应一个负责管理的 AM。

那么 Flink on YARN 都有哪些优势呢?

  • 可以和现有的大数据平台无缝对接(Hadoop 需要 2.4 版本以上)
  • 部署集群与任务提交都非常简单
  • 资源管理统一通过 YARN,提升整体资源利用率
  • 基于 Native 方法,TM 可以按需申请和启动,从而防止资源浪费
  • 借助于 Hadoop YARN 提供的自动 failover 机制,可以保证容错,能保证 JM、TM 节点从异常中正常恢复

提交应用程序到 YARN 上运行

应用程序在 YARN 上的运行流程并不复杂,客户端将 Flink 应用提交给 YARN 的 ResourceManager,然后 ResourceManager 会要求 NodeManager 创建容器。在这些容器中,Flink 会部署 JM 和 TM 的实例,从而启动集群。并且基于任务需要的 Task Slot 的数量,Flink 可以动态申请 TM。

下面来看看如何将 Flink 应用提交到 YARN 集群,我们说 Flink 有三种运行模式,YARN 都是支持的。另外关于 YARN 的配置这里就不赘述了,之前介绍 Spark 的时候已经说的很详细了。

如果想提交应用到 YARN 上,需要使用 flink-shaded-hadoop-2-uber 这个包,但该包从 Flink 1.11 版本开始不再内置,需要我们去官网下载。我这里下载的是 Pre-bundled Hadoop 2.8.3,完事之后丢到 Flink 安装目录的 lib 目录下即可,当然每个节点都要这么做。

再补充一点,Flink 要将应用提交到 YARN 集群,那么它怎么知道 YARN 集群的地址呢?所以我们要让 Flink 能够找到 YARN 的地址,做法是配置一个环境变量:HADOOP_CONF_DIR,指定 Hadoop 配置文件所在的目录,会自动读取里面的 yarn-site.xml,找到集群地址。


Per-Job 模式

提交应用到 YARN 上,首先以 Per-Job 模式提交,该模式最简单。

# 需要指定 -t yarn-per-job,明确表示以 Per-Job 模式提交到 YARN 上
# 如果不指定 -t,那么表示提交到 Standalone
flink run -t yarn-per-job -py flink_test/main.py

在提交应用到 YARN 集群的时候,还可以指定很多的参数:

  • -yn <taskManagers>:需要启动的 TaskManager 实例的数量
  • -ys <slotsPerTaskManager>:每个 TaskManager 提供的 Task Slot 的数量,Task Slot 是 Flink 并行执行作业的逻辑单位
  • -yjm <jobManagerMemory>:JobManager 可以使用的内存总大小,包含 JVM 堆内存、JVM 直接内存、JVM Metaspace 等。如果不指定,那么会使用 flink-conf.yaml 中的 jobmanager.memory.process.size 参数,默认 1600m
  • -ytm <taskManagerMemory>: 每个 TaskManager 可以使用的内存总大小,包含 JVM 堆内存、JVM 直接内存、JVM Metaspace 等。如果不指定,那么会使用 flink-conf.yaml 中的 taskmanager.memory.process.size 参数,默认 1728m
  • -ynm <taskName>:在 YARN UI 界面上显示的任务名
  • -yqu <queueName>:指定 YARN 队列名

需要注意的是,Flink 从 1.11 版本开始不再需要使用 -n 和 -s 指定 TaskManager 的数量和 Task Slot 的数量,YARN 会按照需求动态分配 TaskManager 和 Task Slot。

然后我们可以这么提交:flink run -t yarn-per-job -yjm 2048m -ytm 2048m -ynm 万明珠 -py flink_test/main.py

结果没有问题,正常执行,你也可以打开 YARN 的 webUI,查看相关信息,端口默认是 8088。


Application 模式

Application 模式同样非常简单,与 Per-Job 模式类似。

# 通过 -t yarn-application 表示以 Application 模式提交到 YARN 上
# 注意:这里的命令不是 flink run,而是 flink run-application
flink run-application -t yarn-application \
    -Djobmanager.memory.process.size=1024m \
    -Dtaskmanager.memory.process.size=2048m \
    -py flink_test/main.py

需要补充一下参数指定方式,在 flink-conf.yaml 中有很多的配置,这些配置都可以在启动的时候通过 -D 覆盖掉,如果不覆盖则使用默认值。这种方式更具有通用性,而 -ytm、-yjm 等等则只适用于 YARN。

但如果你直接执行上面的命令的话,是会报错的,因为缺少依赖。我们知道 Application 模式是为了减轻客户端压力的,将 JobGraph 的生成交给 JM 去做,旨在将作业的部署和执行更紧密地整合到 Flink 集群的生命周期管理中。因此在 Application 模式下,整个 Flink 应用(包括它的依赖和运行时环境)需要被一并打包提交给集群。这种模式特别适合于云环境,因为它允许应用以更自包含的方式运行,减少了对外部环境的依赖。

所以 Per-Job 模式不需要上传 Python 解释器,因为它依赖于集群节点上的现有 Python 环境。而 Application 模式需要上传 Python 解释器和其它依赖,以支持作业的独立运行,确保在任何目标集群节点上都能正确执行 Python 代码,特别是在不确定节点上预装了哪些软件的环境中。

假设你现在有一个虚拟环境 venv,通过 venv/bin/python 即可执行代码,并且也已经安装了相关的依赖,然后需要将它打成一个 zip 包。

zip -r venv.zip venv

在提交的时候,将 venv.zip 也一块提交上去。

# -Djobmanager.memory.process.size=1024m 可以写成 -yjm 1024m
# -Dtaskmanager.memory.process.size=2048m 可以写成 -ytm 2048m
# -Dyarn.application.name=万明珠 可以写成 -ynm 万明珠
# -pyarch 表示虚拟环境的归档文件
# -pyexec 表示集群中 TaskManager 执行 Python 代码时的解释器路径,venv.zip 解压之后的 venv/bin/python
# -pyclientexec 表示提交作业的客户端上执行 Python 代码时的解释器路径
flink run-application -t yarn-application \
    -Djobmanager.memory.process.size=1024m \
    -Dtaskmanager.memory.process.size=2048m \
    -Dyarn.application.name=万明珠 \
    -pyarch venv.zip \
    -pyexec venv.zip/venv/bin/python \
    -pyclientexec venv.zip/venv/bin/python \
    -py flink_test/main.py

以上是 Application 模式。


Session 模式

先总结一下 Per-Job 模式和 Application 模式。

  • Per-Job 模式:每次提交作业时,Flink 都会为该作业启动一个新的独立集群,等到作业运行结束后再关闭集群。这种模式确保了隔离性,每个作业都在自己的 Flink 集群上运行,不会和其它作业相互干扰。所以 Per-Job 模式特别适合资源敏感和需要隔离的长时间运行的作业,但如果有很多短时间的作业需要运行,那么频繁地启动和停止集群将会导致大量的资源浪费和时间开销。
  • Application 模式:类似于 Per-Job 模式,因为在这种模式下,Flink 集群的生命周期也是与单个应用程序的生命周期绑定的。不过 Application 模式通常更适合云环境,以及使用 YARN、Kubernetes 做资源管理,其中集群的启动和应用程序的提交可以被打包成一个单一的步骤。这种模式同样适合资源敏感和需要隔离的作业,并且还包含所有依赖的时候。

所以无论是 Per-Job 模式还是 Application 模式,Flink 集群的生命周期都是与应用程序的生命周期绑定的。提交作业时启动集群,并在作业完成后自动停止,这种模式特别适合于长时间运行的作业。但如果要提交的作业非常多,并且运行时间都不长,那么 Per-Job 和 Application 就不适合了,这时候应该使用 Session 模式。

在 Session 模式下,会先启动一个 Flink 集群,然后等待作业提交。这种模式适合于多个作业需要反复或同时运行在同一个 Flink 集群上的场景,因为它可以避免每次作业运行时都重复启动和停止集群的开销。

# 启动一个 YARN Session,-d 表示后台运行
yarn-session.sh -yjm 1024m -ytm 2048m -ynm 万明珠 -d

然后通过 flink run 提交应用即可。

配置历史服务器

运行 Flink Job 的集群一旦停止,就只能去 YARN 或本地磁盘上查看日志,无法再通过 webUI 查看运行完毕(或挂掉)的作业,这样对我们分析就会造成很大影响。如果还没有 Metrics 监控的话,那么完全就只能通过日志去分析和定位问题了,所以和 Spark 一样,Flink 也提供了历史服务器。历史服务器是用来在相应的 Flink 集群关闭后查询已完成作业的统计信息,无论作业是正常退出还是异常退出。

此外历史服务器对外还提供了 REST API,它接受 HTTP 请求并使用 JSON 数据进行响应。Flink 任务停止后,JobManager 会将已经完成任务的统计信息进行存档,History Server 进程则在任务停止后可以对任务统计信息进行查询,比如最后一次的 Checkpoint、任务运行时的相关配置。

下面我们来配置历史服务器,首先在 HDFS 上创建一个存储目录。

hdfs dfs -mkdir -p /logs/flink-job

然后修改 flink-conf.yaml,加入如下配置。

# 历史作业的数据存储位置
jobmanager.archive.fs.dir: hdfs://satori001:9000/logs/flink-job
# 历史服务器监听的 IP 和端口
historyserver.web.address: satori001
historyserver.web.port: 9001
# 历史服务器去哪个目录查询历史作业,和 jobmanager.archive.fs.dir 是一致的
historyserver.archive.fs.dir: hdfs://satori001:9000/logs/flink-job
# 历史作业的数据是不断更新的,那么历史服务器多久检测一次呢?默认 10 秒
historyserver.archive.fs.refresh-interval: 10000

配置完成后,我们启动历史服务器。

  • 启动历史服务器:historyserver.sh start
  • 关闭历史服务器:historyserver.sh stop

在浏览器中输入 satori001:9001。

通过历史服务器可以同时查看正在运行和已完成的 Job 的统计信息。

算子与并行度

当处理的数据量非常大时,我们可以对数据进行拆分,拆分后的每部分数据作用相同的算子。这样一来,一个算子任务就被拆分成了多个并行的子任务,再将它们分发到不同节点,就真正实现了并行计算。然后一个算子的子任务的个数,叫做该算子的并行度(Parallelism)。

每一个算子(Operator)可以包含一个或多个子任务(Operator Subtask),这些子任务在不同的线程、不同的物理机或不同的容器中完全独立地执行。比如 map 算子要处理 10G 的数据,现在拆分成两个 5G 的数据,然后这两个 5G 的数据分别作用 map,那么此时 map 算子的并行度就是 2。

不同的算子可能具有不同的并行度,比如图中的 sink 算子的并行度是 1,其它算子的并行度是 2。一般情况下,一个流程序的并行度,可以认为是其所有算子中最大的并行度。并行度代表了子任务的数量,包含并行子任务的数据流,就是并行数据流,它需要多个分区(Stream Partition)来分配并行任务。

那么我们如何设置并行度呢?有三种方式,优先级从高到低。


1)通过代码设置

from pyflink.datastream import StreamExecutionEnvironment

# 该方法返回一个 StreamExecutionEnvironment 对象,它是 Flink 程序的入口点、或者说执行环境
# 不管使用什么 API,StreamExecutionEnvironment 对象都是必须的
env = StreamExecutionEnvironment.get_execution_environment()
# 然后我们可以设置一些参数,比如并行度、最大并行度等等,有很多参数可以设置
# 直接输入 env.set_ ,然后 PyCharm 会自动提示,比如我们来设置一下并行度
env.set_parallelism(2)
# 再比如指定解释器的版本,这里指定为 Python3
# 当然,如果你在终端输入 python 指向的就是 Python3,那么也可以不用设置
env.set_python_executable("python3")  # 指定完整路径也是可以的

Flink 也支持 Python 通过 PyFlink 进行编程,通过 pip install apache-flink 安装之后,便可以导入 pyflink 开发 Flink 程序了。关于 pyflink,一会儿详细说。不过一般情况下,我们不会在程序中设置并行度(或者说全局并行度),因为如果在程序中对全局并行度进行硬编码,会导致无法动态扩容。


2)提交应用时设置

在使用 flink run 命令提交应用时,可以通过 -p 参数指定当前应用程序执行的并行度。


3)通过配置文件设置

我们还可以直接在集群的配置文件 flink-conf.yaml 中直接更改默认并行度。

parallelism.default: 2

这个设置对整个集群上提交的所有作业都有效,初始值为 1。另外无论是在代码中设置、还是提交时的 -p 参数,都不是必须的,因为在没有指定并行度的时候,会采用配置文件中的默认并行度。如果都没有配置,那么默认并行度就是当前机器的 CPU 核心数。

算子链(Operator Chain)

一个数据流在算子之间的传输形式可以是一对一的直通模式(forwarding),也可以是打乱的重分区模式(redistributing),具体是哪一种模式,取决于算子的种类。

在一对一模式下,数据流维护着分区以及元素的顺序,比如图中的 source 和 map 算子。source 算子读取数据之后,可以直接发送给 map 算子做处理,它们之间不需要重新分区,也不需要调整数据的顺序。这就意味着 map 算子的子任务看到的元素个数和顺序,跟 source 算子的子任务是完全一样的,保证一对一的关系。map、filter、flatMap 等算子都是这种一对一的对应关系,这种关系就类似于 Spark 当中的窄依赖。

而在重分区模式下,数据流的分区会发生改变。比如图中的 map 和后面的 keyBy / window 算子之间,以及 keyBy / window 算子和 sink 算子之间,都是这样的关系。这些算子的子任务,会根据数据传输的策略,把数据发送到不同的下游目标任务。这种传输方式会引起重分区的过程,类似于 Spark 中的宽依赖(Shuffle)。


然后说一说算子的合并,多个算子可以合并成一个算子链,如果你熟悉 Spark 的话,那么你会发现这个过程非常类似 Spark 里的 Stage 划分。

在 Spark 中,一个 Job 会基于宽依赖划分为多个 Stage,保证每个 Stage 里的算子操作都是窄依赖的,不会出现 Shuffle。然后在一个 Stage 中,每个分区上的所有算子都由同一个线程执行。对于 Flink 也是如此,图中的 source、map、filter 算子都是窄依赖的,每个分区操作都是独立的。但像 keyBy 这种宽依赖的算子则不同,因为 keyBy 是按 key 聚合,而不同分区可能会包含具有相同 key 的数据,因此就会涉及数据的传输,此时我们也说产生了 Shuffle。

而对于这些窄依赖的算子,完全可以将它们合并在一起,由同一个线程执行,这样可以减少线程之间的切换和基于缓存区的数据交换,从而降低延迟并提升吞吐量。由于图中有 9 个算子,本来应该产生 9 个任务,但 source、map、filter 合并在了一起,所以最终的任务数是 5,由 5 个线程执行。

因此我们说学习了 Spark 之后再学 Flink 会非常简单,因为很多概念都是相似的。窄依赖的算子在操作分区数据时,不需要和其它分区进行数据交互,那么每个分区上的连续多个窄依赖算子,就可以交给同一个线程来执行,从而避免线程切换带来的副作用。在 Spark 中,连续多个窄依赖算子会被划分为一个 Stage,而在 Flink 中,它们会合并为一个算子链,两者本质是一样的。

算子的合并在 Flink 中是默认行为,而我们也可以选择不合并。

# 禁用算子链
data.map(lambda x: x).disable_chaining()
# 从当前算子开始新链
data.map(lambda x: x).start_new_chain()

为了效率,我们一般会默认选择合并。

任务槽(Task Slot)

Flink 中每一个 TaskManager 都是一个 JVM 进程,它可以启动多个独立的线程,来并行执行多个子任务(Subtask)。但 TaskManager 的计算资源是有限的,并行的任务越多,每个线程的资源就会越少。为了控制并发量,我们需要在 TM 上对每个任务运行所占用的资源做出明确划分,这就是所谓的任务槽。每个任务槽(Task Slot)其实就是 TM 计算资源的一个固定大小的子集,用来独立执行子任务。

假如一个 TM 有三个 Slot,那么它会将管理的内存划分为三份,每个 Slot 独占一份。这样的话,在 Slot 上执行子任务时,相当于划定了一块专属的内存,就不用和其它任务竞争资源了。所以如果想处理上面的 5 个子任务,只需要分配两个 TM 就好了。

那么 Task Slot 的数量应该如何设置呢?在 flink-conf.yaml 里面有一个配置参数:

# 指定每个 TM 的 Task Slot 数量,默认为 1
taskmanager.numberOfTaskSlots: 8

需要注意的是,Slot 目前仅仅用来隔离内存,不会涉及 CPU 的隔离。所以在具体应用时,可以将 Slot 数量配置为机器的 CPU 核心数,尽量避免不同任务之间对 CPU 的竞争,这也是开发环境默认并行度设置为机器 CPU 核心数的原因。


然后 Slot 还可以被不同算子的子任务共享,假设只有一个 TM,那上面的 5 个任务应该如何分配呢?

只要属于同一个作业,那么不同任务节点(算子)的并行子任务,就可以放到同一个 Slot 上执行。但是同一个算子的并行子任务,则需要放到不同的 Slot 上。比如 source、map、filter 组成的算子链的两个并行子任务,需要放到不同的 Slot 上,但它可以和 keyBy 共享同一个 Slot。

当我们将资源密集型和非密集型的任务同时放到一个 Slot 中,它们就可以自行分配对资源占用的比例,从而保证最重的工作会平均分配给所有的 TM。Slot 共享的另一个好处就是允许我们保存完整的作业管道,这样一来,即使某个 TM 出现故障宕机,其它节点也可以完全不受影响,作业的任务可以继续执行。

Flink 默认是允许 Slot 共享的,但如果希望某个算子对应的任务完全独占一个 Slot,或者只有某一部分算子共享 Slot,我们也可以通过设置 Slot 共享组手动指定。

data.map(lambda x: x).slot_sharing_group("1")

这样只有属于同一个 Slot 共享组的子任务,才会开启 Slot 共享,而不同组之间的任务是完全隔离的,必须分配到不同的 Slot 上。在这种场景下,总共需要的 Slot 数量,就是各个 Slot 共享组最大并行度的总和。

任务槽和并行度的关系

任务槽和并行度都跟程序的并行执行有关,但两者是完全不同的概念。简单来说任务槽是静态的概念,是指 TM 具有的并发执行能力,可以通过参数 taskmanager.numberOfTaskSlots 进行配置;而并行度是动态的概念,也就是 TM 运行程序时实际使用的并发能力,可以通过参数 parallelism.default 进行配置(或者通过代码、命令行提交应用进行设置)。

假设有 3 个 TM,每个 TM 中的 Slot 数量设置为 3,那么一共有 9 个 Task Slot,表示集群最多能并行执行 9 个同一算子的子任务。但具体执行多少个,则取决于并行度,如果并行度为 5,那么可以理解为将数据拆成了 5 份,然后并行执行。

举个例子,有一个 word count 程序,需要经过四个算子:source -> flatmap -> reduce -> sink。当所有算子并行度相同时,很容易看出 source 和 flatmap 可以合并为算子链,于是最终有三个任务节点。假设这个程序部署到有 3 个 TM、每个 TM 有 3 个 Slot 的集群中,那么在不同并行度下的表现会是什么样呢?


并行度为 1 时

显然总共有三个任务,但由于不同算子的任务可以共享任务槽,所以最终占用的 Slot 只有 1 个,剩余 8 个处于空闲状态。


并行度为 2 时

有三个任务节点,但并行度为 2,因此总共会有 6 个任务。

共享任务槽之后会占用两个 Slot,有 7 个 Slot 空闲,计算资源没有被充分利用。所以可以看到,设置合适的并行度才能提高效率。


并行度为 9 时

怎样设置并行度效率最高呢?当然是把所有的 Slot 都利用起来。因为有 9 个 Slot,考虑到 Slot 共享,那么就把并行度设置为 9,这样每个任务都会完全占用 9 个 Slot。


单独设置算子的并行度

**另外再来考虑某个算子单独设置并行度的场景,例如上面的 sink 是写入文件,可我们不希望并行写入多个文件,那么就需要单独设置 sink 算子的并行度为 1,其它算子的并行度依旧是 9。所以此时的总任务数就是 19(9 * 2 + 1),而不是 27。 **

根据 Slot 共享的原则,它们依旧会占用全部的 9 个 Slot,而 sink 任务只在其中一个 Slot 上运行。

通过这个例子也可以明确地看到,整个流处理程序的并行度,就是所有算子的并行度中最大的那个,它代表了运行程序需要的 Slot 数量。假设算子并行度最大是 100,那么就需要 100 个 Slot,即使其它算子的并行度远小于 100,并行度是几,就用几个 Slot,但 Slot 申请标准按照最大的来。

默认情况下,如果你不单独设置某个算子的并行度,那么所有算子的并行度都是一样的。

最后再补充一下,如何设置某个算子的并行度。

from pyflink.datastream import StreamExecutionEnvironment

env = StreamExecutionEnvironment.get_execution_environment()
# 设置全局并行度,默认情况下所有算子的并行度都是 10
env.set_parallelism(10)
env.set_python_executable("python3")
# 创建一个 DataStream
data = env.from_collection([1, 2, 3, 4, 5])
# 单独设置 map 算子的并行度为 2
data.map(lambda x: x + 2).set_parallelism(2)

一般情况下我们不需要单独设置单个算子的并行度,除非在导出 DataStream 的时候。

DataStream API

DataStream API 是 Flink 的核心层 API,一个 Flink 程序,其实就是对 DataStream 做各种转换。具体来说,代码基本上都由以下几部分构成:

需要特别说明一下里面的 Execute,调用 DataStream 的算子的时候,只是定义了作业的执行操作,然后添加到数据流图中,这时并没有真正开始执行,因为数据可能还没来。Flink 是由事件驱动的,只有我们让它执行,才会触发真正的计算,所以这也被称为延迟执行或懒执行。而如果想触发程序执行,需要显式地调用执行环境的 execute() 方法,该方法将一直等待作业完成,然后返回一个执行结果(JobExecutionResult)。

下面我们分别介绍图中的每一个部分。

执行环境(Execution Environment)

Flink 程序可以在各种上下文环境中运行,比如我们可以在本地 JVM 中执行程序,也可以提交到远程集群上运行,一会儿在学习算子的时候就直接在本地 JVM 中运行。不同的环境,代码提交运行的过程会有所不同,这就要求我们在提交作业执行计算时,首先必须获取当前 Flink 的运行环境,从而建立起与 Flink 框架之间的联系。

from pyflink.datastream import StreamExecutionEnvironment

# 创建执行环境
env = StreamExecutionEnvironment.get_execution_environment()
# 设置执行环境的一些属性
env.set_parallelism(10)
env.set_python_executable("python3")

然后从 Flink 1.12 开始,关于批处理,官方推荐的做法是直接使用 DataStream API,不建议使用 DataSet API(已经废弃)。因为 DataStream 已经是批流一体,只需在提交任务时将执行模式设置为 BATCH,便可实现批处理。而执行模式,DataStream 支持三种:

  • 流执行模式(Streaming):这是 DataStream API 最经典的模式,一般用于需要持续实时处理的无界数据流。默认情况下,程序使用的就是 Streaming 执行模式。
  • 批执行模式(Batch):专门用于批处理的执行模式。
  • 自动模式(AutoMatic):在这种模式下,将由程序根据输入数据源是否有界,来自动选择执行模式。

而设置执行模式,主要有两种方式。

1)通过命令行设置:flink run -Dexecution.runtime-mode=BATCH ···,在提交作业时,增加 execution.runtime-mode 参数,指定值为 BATCH。

2)通过代码设置。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(10)
env.set_python_executable("python3")
# 设置执行模式为 BATCH,当然也可以是 STREAMING 和 AUTOMATIC
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
# env.set_runtime_mode(RuntimeExecutionMode.AUTOMATIC)

实际应用中一般不会通过代码设置,而是使用命令行,这样更加灵活。

源算子

Flink 可以从各种来源获取数据,然后构建 DataStream 进行转换处理。一般将数据的输入来源称为数据源(data source),而读取数据的算子就是源算子(source operator),所以 Source 就是我们整个处理程序的输入端。下面来看看 Flink 如何读取数据源,Flink 支持的数据源还是蛮多的。

从本地集合中读取数据

创建 DataStream 最简单的方式,直接调用执行环境的 from_collection 方法,即可将本地集合转成 DataStream。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
# 从本地集合读取数据,显然数据是有界的,因此对应批处理
# 而 DataStream 默认是流处理,所以这里要将执行模式设置为 BATCH
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# 基于列表创建 DataStream
ds = env.from_collection([1, 2, 3, 4, 5])
print(ds)
# 调用 print 方法打印数据
ds.print()
# 我们说 Flink 的算子是懒加载的,需要调用执行环境的 execute 方法才会触发执行
env.execute(job_name="from_collection")
"""
<pyflink.datastream.data_stream.DataStream object at 0x00000212CB896110>
1
2
3
4
5
"""

这里我们将并行度设置为 1,那么执行时数据只有一个分区,只有一个任务,从而保证打印的数据是有序的(为了方便测试和演示)。但在生产上,肯定会基于 CPU 核心数设置并行度的。

注意:我当前的代码是在本地执行的,只要安装了 Java 环境即可,这种方式一般用于学习和测试,生产上肯定是要提交到 Flink 集群上运行的。

从文件读取数据

实际应用中,肯定不会直接将数据写在代码里面。通常情况下,我们会从存储介质中获取数据,一个比较常见的方式就是读取日志文件,这也是批处理中最常见的读取方式。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
# 从文件读取数据也是批处理,所以要设置执行模式为 BATCH
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# 读取本地文件,文件的一行就是 DataStream 内部的一个元素
ds = env.read_text_file("data.log")
ds.print()
"""
Hello World
Hello Cruel World
Hello Beautiful World
"""
ds.map(lambda x: x.split()).print()
"""
['Hello', 'World']
['Hello', 'Cruel', 'World']
['Hello', 'Beautiful', 'World']
"""
env.execute("read_text_file")

read_text_file 还可以读取一个目录,比如我们将 data.log 拷贝一份得到 data2.log,然后将这两个文件放在 logs 目录中。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

env.read_text_file("logs/data.log").print()
"""
Hello World
Hello Cruel World
Hello Beautiful World
"""
env.read_text_file("logs/data2.log").print()
"""
Hello World
Hello Cruel World
Hello Beautiful World
"""
env.read_text_file("logs").print()
"""
Hello World
Hello Cruel World
Hello Beautiful World
Hello World
Hello Cruel World
Hello Beautiful World
"""
env.execute("read_text_file")

读取目录时,会依次读取目录中的文件,然后合并在一起。另外 read_text_file 不仅可以读取本地文件,还可以读取 HDFS 上的文件。

我们往 HDFS 上传了一个 girl.txt,然后来读取它,但是会发现报错了。

如果想连接 Hadoop(比如读取 HDFS 文件、提交应用程序到 YARN),需要使用一个叫 flink-shaded-hadoop-2-uber 的包,但是该包从 Flink 1.11 版本开始不再内置,需要我们去官网下载。我们之前已经下载过了,并拷贝到了 Flink 集群中,但问题是当前的程序是在本地执行的,和 Flink 集群没有关系。所以我们还需要将这个包拷贝到 site-packages/pyflink/lib 目录中,然后才能在本地读取 HDFS。

拷贝完成之后,再来读取试试。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
env.read_text_file("hdfs://satori001:9000/girl.txt").print()
"""
satori
koishi
marisa
"""
env.execute("read_text_file")

结果没有问题,另外再补充一下,当前的代码是在本地执行的,只需要有一个 Java 环境即可。因为 Python 代码最终还是要通过 py4j 翻译成 Java 代码,然后交给 Java 虚拟机执行,所以必须要有 Java 环境。至于 lib 目录存储的则是依赖的 Jar 包,如果缺少依赖,那么下载下来丢进去即可。

当然啦,如果你下载下来之后,不想丢进 lib 目录中,那么也可以在程序中手动指定。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.execution_mode import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# 如果包位于 lib 目录中,那么会自动查找,但如果你不想放到 lib 目录,则需要手动指定
# 调用 add_jars 方法将 Jar 包添加进去,可以同时添加多个
# 注意:文件路径必须遵循 POSIX 语义,要以 file:// 开头
env.add_jars(r"file:///E:/flink_project/flink-shaded-hadoop-2-uber-2.8.3-10.0.jar")
env.read_text_file("hdfs://satori001:9000/girl.txt").print()
"""
satori
koishi
marisa
"""
env.execute("read_text_file")

这样做也是没有任何问题的,具体喜欢哪种方式则取决于你。另外以上是本地执行,如果在提交到集群的时候需要指定依赖,那么可以通过命令行参数 --jarfile 进行指定。

从 Kafka 读取数据

对于读取本地数据或文件生成 DataStream,执行环境提供了单独的 from_collection 方法和 read_text_file 方法。但如果是从其它数据源(比如 Kafka)读取数据,那么要统一调用 from_source 方法,该方法接收一个 Source 对象。Flink 内部针对不同数据源已经提供了大量的 Source,需要操作什么数据源,就把对应的 Source 传递给 from_source 方法即可。

from pyflink.datastream import StreamExecutionEnvironment, RuntimeExecutionMode
# pyflink.datastream.connectors 里面定义了大量的连接器,用于读取外部数据源
# 比如 Cassandra、ES、JDBC、Kafka、RabbitMQ、Pulsar 等等
from pyflink.datastream.connectors.kafka import KafkaSource, KafkaOffsetsInitializer
from pyflink.common.serialization import SimpleStringSchema
from pyflink.common.watermark_strategy import WatermarkStrategy

env = StreamExecutionEnvironment.get_execution_environment()
# 消费 Kafka 数据显然是流处理,因为数据是无界的,所以要将执行模式设置为 STREAMING
# 不过执行模式默认就是流处理,所以不设置也没关系。但是我们不能设置为 BATCH,否则会报出如下错误
# java.lang.IllegalStateException: Detected an UNBOUNDED source with the 'execution.runtime-mode' set to 'BATCH'
env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
# 创建 Kafka Source 读取数据源
kafka_source = (KafkaSource.builder()
                .set_bootstrap_servers("satori001:9092")  # 指定 Broker 的地址
                .set_topics("fruits")  # 指定主题
                .set_group_id("test-group")  # 指定消费者组
                .set_starting_offsets(KafkaOffsetsInitializer.earliest())  # 指定起始的消费位置
                .set_value_only_deserializer(SimpleStringSchema())  # 指定反序列化器
                # .set_property("...", "...")  # 还可以通过 set_property 设置任意属性
                .build())

# 添加 Kafka Source 到执行环境
ds = env.from_source(
    source=kafka_source,
    # watermark_strategy 和水位相关,后续解释
    watermark_strategy=WatermarkStrategy.no_watermarks(),
    source_name="Kafka"
)

# 打印接收到的数据
ds.print()

# 执行作业
env.execute("PyFlink Kafka Source")

上面的代码如果直接执行的话会报错,因为缺少 Flink 连接 Kafka 的 Jar 包,所以我们需要先将该 Jar 包下载下来,下载链接如下:

https://repo.maven.apache.org/maven2/org/apache/flink/flink-sql-connector-kafka/3.1.0-1.18/

下载完之后丢进 lib 目录,然后我们启动 Kafka 集群,创建一个 fruits 主题并写入几条数据。

成功创建主题并写入数据,然后执行 Python 程序,看看能否消费到数据。

数据被成功消费了,但是进程没有结束,因为是流处理,它会一直监听,并且来一个处理一个。我们再往 fruits 主题写几条数据:

生产者又往主题写了几条数据,并且 Python 编写的 Flink 程序也成功消费到了。

关于 Flink 的源算子,我们暂时就说到这里,当然 Flink 支持的数据源非常多,感兴趣的话可以通过官网查看。

再来补充一下 Flink 中的数据类型,Flink 使用类型信息(TypeInformation)来统一表示数据类型,TypeInformation 类是 Flink 中所有类型的基类,它涵盖了类型的一些基本属性,并为每个数据类型生成特定的序列化器、反序列化器和比较器。需要说明的是,在大部分情况下,Flink 可以根据数据推断出类型,但在某些极端情况下,数据类型无法被自动推断、或者你需要更明确的类型信息时,就要手动指定数据类型了。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.common.typeinfo import Types

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

# 创建一个整数列表的数据流,并指定类型为 Types.INT()
ds1 = env.from_collection([1, 2, 3, 4, 5], Types.INT())
# 创建一个元组列表的数据流,并指定类型为 Types.TUPLE([Types.STRING(), Types.INT()])
ds2 = env.from_collection([("a", 1), ("b", 2)],
                          Types.TUPLE([Types.STRING(), Types.INT()]))
ds1.print()
"""
1
2
3
4
5
"""
ds2.print()
"""
(a,1)
(b,2)
"""
env.execute("create ds with type")

Flink 支持的类型还是很多的,可以进入源码中查看:

基础类型都见名知意,比如 Types.SHORT()、Types.INT()、Types.LONG() 等表示不同精度的整数,Types.FLOAT()、Types.DOUBLE() 表示不同精度的浮点数。至于一些更复杂的类型,我们后面用到的时候再说。

转换算子(Transformation)

读取数据创建 DataStream 之后,我们就可以使用各种转换算子,将一个 DataStream 转换为新的 DataStream。下面我们就来介绍这些算子,看看它们的用法。

基本转换算子

map 算子

map 应该都非常熟悉了,负责对 DataStream 中的每个元素进行映射,生成一个新的 DataStream。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.common.typeinfo import Types

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

ds = env.from_collection(["1", "2", "3"], Types.STRING())
# 部分算子还可以接收一个类型,比如 map 之后将数据类型指定为 SHORT()
ds = ds.map(lambda x: int(x) * 10, Types.SHORT())
ds.print()
"""
10
20
30
"""
env.execute("map")

filter 算子

对数据按照指定条件进行过滤,保留那些满足条件的数据。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

ds = env.from_collection(list(range(10)))
# 保留值为偶数的元素,注意:filter 算子不接收类型
# 因为 filter 是对数据进行过滤,并不会将其改变,所以类型会和之前保持一致
ds = ds.filter(lambda x: x & 1 == 0)
ds.print()
"""
0
2
4
6
8
"""
env.execute("filter")

flat_map 算子

和 map 类似,但如果映射之后的结果是一个列表,那么会将其展开。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

ds = env.from_collection(["hello python", "hello rust"])
ds.map(lambda x: x.split()).print()
"""
['hello', 'python']
['hello', 'rust']
"""
ds.flat_map(lambda x: x.split()).print()
"""
hello
python
hello
rust
"""
env.execute("flat_map")

以上就是基本转换算子,比较简单。

聚合算子

key_by 算子

按 key 分组,将相同 key 的数据汇聚在一起。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

ds = env.from_collection(list(range(10)))
# 将偶数归为一组,奇数归为一组
ds = ds.map(lambda x: ("even", x) if x & 1 == 0 else ("odd", x))
ds.print()
"""
('even', 0)
('odd', 1)
('even', 2)
('odd', 3)
('even', 4)
('odd', 5)
('even', 6)
('odd', 7)
('even', 8)
('odd', 9)
"""
# 按照 key 进行分组,那么谁是 key 呢?显然需要指定
# 这里表示元组的第一个元素是 key
ks = ds.key_by(lambda x: x[0])
# 需要注意:key_by 得到的结果不再是 DataStream,而是 KeyedStream(继承 DataStream)
# 得到 KeyedStream 之后,我们可以调用它的聚合函数,比如 sum,对分组数据求和
# 但由于每组数据包含的值都是元组,所以要通过索引指定对元组的哪一个元素进行求和
ks.sum(1).print()
"""
('odd', 25)
('even', 20)
"""
# 求最大值,和 sum 一样,返回的是元组
ks.max(1).print()
"""
('odd', 9)
('even', 8)
"""
# 求最小值
ks.min(1).print()
"""
('odd', 1)
('even', 0)
"""
# 可以类比 Python 的 reduce,参数 x 和 y 都是元组,它们的第一个元素(key)相同
# 此时等价于 .sum(1),当然你也可以定义别的逻辑
ks.reduce(lambda x, y: (x[0], x[1] + y[1])).print()
"""
('odd', 25)
('even', 20)
"""
env.execute("key_by")

还是比较简单的,总之 key_by 和聚合函数要成对出现,也就是先分区后聚合,最终得到的依然是一个 DataStream。然后一个聚合算子,会为每一个 key 保存一个聚合的值,在 Flink 中我们把它叫作状态(State)。每当有一个新的数据输入,算子就会更新保存的聚合结果,并发送一个带有更新后聚合值的事件到下游算子。由于对无界流来说,这些状态是永远不会被清除的,所以我们使用聚合算子(如 key_by)应该只用在包含有限个 key 的数据流上。

用户自定义函数(UDF)

用户自定义函数(user-defined function,UDF),即用户可以根据自身需求,自己实现算子的逻辑。

from pyflink.datastream.functions import *

pyflink.datastream.functions 里面定义了大量的类,只需要继承它们即可实现自己的算子。

我们举个例子:

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.functions import MapFunction

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

class MyMap(MapFunction):

    def map(self, value):
        return "girl_" + value

ds = env.from_collection(["satori", "koishi", "marisa"])
ds.map(MyMap()).print()
"""
girl_satori
girl_koishi
girl_marisa
"""
env.execute("UDF")

这种做法和我们直接传一个匿名函数是类似的,至于其它的自定义函数可以自己点进源码中查看。

物理分区算子(Physical Partitioning)

Flink 的 DataStream 和 Spark 的 RDD 都是有分区的,假设我们将默认的全局并行度设置为 10,那么程序执行时 DataStream 就会有 10 个分区,每个算子就会有 10 个子任务,开启 10 个线程并行执行。那么分区到底是怎么分的呢?有以下几个策略:

  • 随机分配(Random)
  • 轮询分配(Round-Robin)
  • 重缩放(Rescale)
  • 广播(Broadcast)

随机分区(shuffle)

最简单的重分区方式就是 "洗牌",通过调用 DataStream 的 shuffle() 方法,将数据随机地分配到下游算子的并行任务中。随机分区服从均匀分布(uniform distribution),会把流中的数据随机打乱,均匀地传递到下游。因为是完全随机的,所以即使同样的输入数据,每次执行得到的结果也不会相同。

我们举例说明:

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(2)  # 设置并行度为 2
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# 运行时会产生两个分区,分区一的数据是 1 3 5,分区二的数据是 2 4 6,当然也有可能反过来
ds = env.from_collection([1, 2, 3, 4, 5, 6])
# 打印的时候会依次打印每一个分区,这里先打印分区一,再打印分区二
ds.print()
"""
1
3
5
2
4
6
"""
# 将分区数据打乱,注意:打乱后得到的依旧是 DataStream
ds.shuffle().print()
"""
1
2
3
5
6
4
"""
env.execute("shuffle")

每次执行时打印的结果可能会不一样。


轮询分区(Round-Robin)

轮询简单来说就是 "发牌",按照先后顺序将数据依次分发,通过调用 DataStream 的 rebalance() 方法即可实现轮询重分区。rebalance 使用的是 Round-Robin 负载均衡算法,可以将输入流数据平均分配到下游的并行任务中。

代码中调用 ds.rebalance() 即可。


重缩放分区(rescale)

重缩放分区和轮询分区非常相似,当调用 rescale() 方法时,其实底层也是使用 Round-Robin 算法进行轮询,但是只会将数据轮询发送到下游并行任务的一部分当中。所以 rescale 的做法相当于是分成小团体,发牌人只给自己团体内的所有人轮流发牌。

代码中调用 ds.rescale() 即可。


广播(broadcast)

这种方式其实不应该叫重分区,因为经过广播之后,数据会在不同的分区中都保留一份,可能进行重复处理。可以通过调用 DataStream 的 broadcast() 方法,将输入数据复制并发送到下游算子的所有并行任务中。


全局分区(global)

全局分区也是一种特殊的分区方式,这种做法非常极端,通过调用 global() 方法,会将所有的输入流数据都发送到下游算子的第一个并行子任务中。这就相当于强行让下游任务的并行度变成了 1,所以使用这个操作需要非常谨慎,可能会对程序造成很大的压力。


自定义分区

当 Flink 提供的所有分区策略都不能满足需求时,我们也可以自定义。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.functions import Partitioner

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(4)  # 设置并行度为 4
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

class MyPartitioner(Partitioner):

    def partition(self, key, num_partitions):
        # 元素在进入分区之前,会先调用 partition 方法,基于返回值决定进入第几个分区
        if key in ("a", "e"):
            return 0
        elif key in ("b", "f"):
            return 1
        elif key in ("c", "g"):
            return 2
        else:
            return 3


ds = env.from_collection([
    ("a", 1), ("b", 2), ("c", 3), ("d", 4),
    ("e", 5), ("f", 6), ("g", 7), ("h", 8)
])
# 因为并行度设置为 4,所以会有 4 个分区,那么每个元素要进入哪一个分区呢?
# 将 x[0] 和 4 作为参数调用 MyPartitioner().partition 方法,返回几,就进入索引为几的分区
ds.partition_custom(MyPartitioner(), lambda x: x[0]).print()
"""
('a', 1)
('e', 5)
('c', 3)
('g', 7)
('d', 4)
('h', 8)
('b', 2)
('f', 6)
"""
# a 和 e 一个分区,b 和 f 一个分区,c 和 g 一个分区,d 和 h 一个分区
env.execute("custom partition")

当然啦,我们也可以不定义类,直接定义一个函数也可以的。

def partition(key, num_partitions):
    if key in ("a", "e"):
        return 0
    elif key in ("b", "f"):
        return 1
    elif key in ("c", "g"):
        return 2
    else:
        return 3

ds = env.from_collection([
    ("a", 1), ("b", 2), ("c", 3), ("d", 4),
    ("e", 5), ("f", 6), ("g", 7), ("h", 8)
])

ds.partition_custom(partition, lambda x: x[0]).print()

里面的 num_partitions 参数没有用上,实际工作中你可以计算 key 的哈希值,然后对 num_partitions 取模,来决定元素进入哪一个分区。

合并算子

在实际应用中,我们经常会遇到来源不同的多条流,需要将它们的数据进行联合处理。

联合(Union)

最简单的合流操作,就是直接将多条流汇在一起,叫作流的联合(union)。联合操作要求流的数据类型必须相同,合并之后的新流会包含所有流中的元素,数据类型不变。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

ds1 = env.from_collection([1, 2, 3])
ds2 = env.from_collection([11, 22, 33])
ds1.union(ds2).print()
"""
1
2
3
11
22
33
"""
env.execute("union")

连接(connect)

流的联合虽然简单,不过受限于数据类型不能改变,灵活性大打折扣,所以实际应用中较少出现。因此除了联合(union),Flink 还提供了另外一种方便的合流操作:连接(connect)。

为了处理更加灵活,连接操作允许流的数据类型不同,但我们知道 DataStream 的数据只能有唯一的类型,所以连接得到的并不是 DataStream,而是一个连接流。连接流可以看成是两条流在形式上的统一,虽然被放在了同一个流中,但内部仍保持各自的数据类型,彼此之间是相互独立的。要想得到新的 DataStream,还需要进一步定义一个同处理(co-process)转换操作,用于说明对不同来源、不同类型的数据,怎样分别进行处理转换,得到统一的输出类型。

所以整体来看,两条流的连接就像是 "一国两制",两条流可以保持各自的数据类型,处理方式也可以不同,但最终还是会统一到同一个 DataStream 中。

代码实现需要分为两步:首先调用 DataStream.connect() 方法,传入另一个 DataStream 作为参数,将两条流连接起来,得到一个 ConnectedStreams;然后再调用同处理方法得到 DataStream,这里可调用的同处理方法有 map,flat_map,以及 process。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.functions import CoMapFunction, CoFlatMapFunction, CoProcessFunction

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(1)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

# 数据类型是 STRING() 的 DataStream
ds1 = env.from_collection(["python", "rust"])
# 数据类型是 INT() 的 DataStream
ds2 = env.from_collection([1, 2, 3])
# 连接两条数据流,返回 ConnectedStreams
cs = ds1.connect(ds2)

class MyCoMapFunction(CoMapFunction):

    def map1(self, value):
        return "hello " + value

    def map2(self, value):
        return value * 10

# 第一条数据流会作用 map1 方法,第二条数据流会作用 map2 方法
cs.map(MyCoMapFunction()).print()
"""
hello python
hello rust
10
20
30
"""


ds1 = env.from_collection(["satori koishi", "marisa"])
ds2 = env.from_collection(["2020-01-02"])
cs = ds1.connect(ds2)

class MyCoFlatMapFunction(CoFlatMapFunction):

    def flat_map1(self, value):
        return value.split()

    def flat_map2(self, value):
        return value.split("-")

# 第一条数据流会作用 flat_map1 方法,第二条数据流会作用 flat_map2 方法
cs.flat_map(MyCoFlatMapFunction()).print()
"""
satori
koishi
2020
01
02
marisa
"""
env.execute("connect")

ConnectedStreams 调用 map 和 flat_map 之后会返回 DataStream。

输出算子(Sink)

Flink 作为数据处理框架,最终还是要把计算处理的结果写入外部存储,为外部应用提供支持,而写入的过程就是由 Sink 算子实现的。之前我们一直在使用的 print 方法其实就是一种 Sink,它表示将数据流写入标准控制台。在多数情况下,Sink 算子同样不需要我们自己实现,因为 Flink 官方已经提供了非常多的 Sink 连接器,下面举几个例子。

输出到文件

Flink 提供了一个流式文件系统的连接器:FileSink,为批处理和流处理提供了统一的 Sink,它可以将分区文件写入 Flink 支持的文件系统。

# FileSource 用于创建从文件读取内容的 Source 算子,当然也可以通过执行环境提供的 read_text_file 方法
# 而 FileSink 则是用于创建写入文件的 Sink 算子
from pyflink.datastream.connectors.file_system import FileSource, FileSink

和 Source 算子一样,你需要写入到哪种外部系统,就创建对应的 Sink 算子即可,然后作为参数传递给 sink_to 方法。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.connectors.file_system import FileSink, OutputFileConfig, RollingPolicy
from pyflink.common.serialization import Encoder
from pyflink.common.typeinfo import Types

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(10)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
# 当你要导出到外部数据源的时候,一定要明确指定 DataStream 的数据类型
# 特别是字符串,否则你会发现导入的内容都是 [B@······ 这种
ds = env.from_collection([f"message {i}" for i in range(100)], Types.STRING())

# Flink 提供了两种文件写入格式,一种是行格式,调用 for_row_format 方法
# 另一种是块格式,调用 for_bulk_format 方法,我们先来看第一种
sink = FileSink \
    .for_row_format(".", Encoder.simple_string_encoder()) \
    .with_output_file_config(OutputFileConfig.builder()
                             .with_part_prefix("data-")  # 给文件增加一个前缀
                             .with_part_suffix(".log")  # 给文件增加一个后缀
                             .build()) \
    .with_rolling_policy(
        # 设置文件的滚动策略
        RollingPolicy.default_rolling_policy(part_size=1024 ** 3,  # 文件滚动前可以写入的最大字节数,默认 128M
                                             # 文件滚动的最大时间间隔(单位毫秒),默认 60 秒
                                             # 即使当前文件大小未达到阈值,但只要文件已打开的时间超过限制,也会触发滚动
                                             rollover_interval=60 * 1000 * 15,
                                             # 如果在指定的时间内没有新数据写入文件,便认为文件处于非活动状态,此时也会触发滚动,默认 60 秒
                                             inactivity_interval=60 * 1000 * 10)) \
    .build()

# 添加指定的 Sink,将 DataStream 的数据导出
ds.sink_to(sink)
env.execute("sink to local file")

我们执行一下,因为 for_row_format 方法的第一个参数是 . ,表示写入到当前目录,所以执行之后本地会多出一个子目录。

目录以 "年-月-日--小时" 的格式命名,然后目录里面有 10 个文件。因为代码中将并行度设置为 10,那么 DataStream 就有 10 个分区,从而算子就会有 10 个并行子任务,因此最终会并行写入 10 个文件。但如果我们只想写入一个文件,并且又不想修改全局并行度(因为会影响其它算子),那该怎么办呢?很简单, 给某个算子单独设置并行度即可。

# 此时就只会生成一个文件
ds.sink_to(sink).set_parallelism(1)

我们说过全局的算子并行度应该参考 CPU 核心数进行设置,不要为了某一个算子去修改全局并行度,如果你需要控制某个算子的任务数量,那么只需要修改对应算子的并行度即可。

然后 Flink 提供了两种写入文件的方式:

  • for_row_format 适用于逐行写入数据的场景,比如写入纯文本文件或逐行编码的文件。当使用行格式时,每条记录被视为一个独立的行,并且会按照接收到的顺序依次写入文件中,这种方式适用于日志文件、CSV 文件等。补充:该方法除了可以写入到本地,也可以写入到 HDFS。
  • for_bulk_format:这种格式用于批量写入数据,适用于将数据写入更复杂的文件格式,如 Parquet 或 ORC。在块格式下,数据会被缓存并打包成较大的块,然后一次性写入到文件系统,这可以提高效率并减少文件数量,适用于需要高效存储和读取的大数据场景。

输出到 Kafka

操作 Kafka 需要相应的 Jar 包,在读取 Kafka 数据源的时候已经引入过了,这里可以直接导出。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.connectors.kafka import KafkaSink, KafkaRecordSerializationSchema
from pyflink.datastream.connectors.base import DeliveryGuarantee
from pyflink.common.serialization import SimpleStringSchema
from pyflink.common.typeinfo import Types

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(10)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)
ds = env.from_collection([f"message {i}" for i in range(100)], Types.STRING())

sink = (
    KafkaSink.builder()
    .set_bootstrap_servers("satori001:9092")
    .set_record_serializer(
        KafkaRecordSerializationSchema.builder()
        .set_topic("message_queue")
        .set_value_serialization_schema(SimpleStringSchema())
        .build()
    )
    # 设置一致性级别为精确一次
    .set_delivery_guarantee(DeliveryGuarantee.EXACTLY_ONCE)
    # 如果是精确一次,必须设置事务的前缀
    .set_transactional_id_prefix("trans-")
    # 如果是精确一次,还必须设置事务的超时时间
    .set_property("transaction.timeout.ms", str(10 * 60 * 1000))
    # 要是还想设置其它属性,那么继续调用 set_property 方法即可
    .build()
)

# 添加指定的 Sink,将 DataStream 的数据导出到 Kafka
ds.sink_to(sink)
env.execute("sink to kafka")

执行没有问题,但是数据是否导入成功呢?启动一个消费者测试一下。

结果没有问题,由于是多个线程并行写入 Kafka,因此数据是乱序的。如果想保证有序,那么将 Sink 算子的并行度单独设置为 1 即可,我们测试一下。

此时数据就是有序的了,还是比较简单的。另外在发送消息时,主题是 Flink 自动创建的,分区和副本数都是 1。但如果希望主题有多个分区,那么就需要事先手动创建了,比如我们创建一个主题 numbers,设置两个分区。

那么问题来了,Flink 在将 DataStream 导出到 Kafka 的时候,可以指定分区吗?显然是可以的,只不过 PyFlink 不支持,所以这里的分区白创建了。

输出到数据库(JDBC)

因为 JDBC 连接器不是二进制发行版的一部分,所以如果想输出到数据库中,首先需要下载连接器。然后有了连接器还不够,还需要下载相应的驱动依赖,比如比如输出到 MySQL,就需要下载 mysql-connector-java。连接器和驱动的下载地址如下:

  • https://repo.maven.apache.org/maven2/org/apache/flink/flink-connector-jdbc/3.1.2-1.17/flink-connector-jdbc-3.1.2-1.17.jar
  • https://repo.maven.apache.org/maven2/mysql/mysql-connector-java/8.0.16/mysql-connector-java-8.0.16.jar

所有的 Jar 包都可以去 https://repo.maven.apache.org/maven2 里面下载,支持各种不同的版本。

from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream import RuntimeExecutionMode
from pyflink.datastream.connectors.jdbc import JdbcSink, JdbcConnectionOptions, JdbcExecutionOptions
from pyflink.common.typeinfo import Types

env = StreamExecutionEnvironment.get_execution_environment()
env.set_parallelism(10)
env.set_runtime_mode(RuntimeExecutionMode.BATCH)

type_info = Types.ROW([Types.INT(), Types.STRING(), Types.STRING(), Types.INT()])
# Types.ROW 表示每个元素对应数据库的一行
ds = env.from_collection(
    [(101, "Stream Processing with Apache Flink", "Fabian Hueske, Vasiliki Kalavri", 2019),
     (102, "Streaming Systems", "Tyler Akidau, Slava Chernyak, Reuven Lax", 2018),
     (103, "Designing Data-Intensive Applications", "Martin Kleppmann", 2017),
     (104, "Kafka: The Definitive Guide", "Gwen Shapira, Neha Narkhede, Todd Palino", 2017)],
    type_info=type_info
)

sink = (
    JdbcSink.sink(
        "insert into books (id, title, authors, year) values (?, ?, ?, ?)",
        type_info,
        JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
            .with_url("jdbc:mysql://82.157.146.194:3306/data")
            # 5.7 之前叫 com.mysql.jdbc.Driver
            .with_driver_name("com.mysql.cj.jdbc.Driver")
            .with_user_name("root")
            .with_password("123456")
            .build(),
        JdbcExecutionOptions.builder()
            .with_batch_interval_ms(1000)
            .with_batch_size(200)
            .with_max_retries(5)
            .build()
    )
)

ds.add_sink(sink)
env.execute("sink to database")

执行完毕,代码没有问题,然后来看看数据库里面有没有数据。

显然数据被成功导入了,然后为了避免数据被重复导入,JDBCSink 还提供了精确一次处理的 Sink,但这个方法 Python 是不支持的,Java 的话可以调用 exactlyOnceSink 实现。精确一次其实就是至少一次和幂等的组合,它也是有可能处理多次的,但对系统产生的影响只会有一次。

关于 Sink 就说到这里,Flink 官方提供了非常多的 Sink,具体细节可以参考官网

在批处理统计中,可以等待一批数据都到齐后,统一处理。但在实时流处理统计中,是来一条数据就得处理一条,那么我们怎么统计最近一段时间内的数据呢?答案是引入 "窗口"。所谓窗口就是划定的一段时间范围,也就是时间窗,对在这范围内的数据进行处理,就是所谓的窗口计算,所以窗口和时间往往是分不开的。接下来我们就深入了解一下 Flink 中的时间语义和窗口的应用。

窗口(Window)

Flink 是一种流式计算引擎,主要用来处理无界数据流,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限的数据切割成有限的数据块进行处理,这就是所谓的窗口(Window)。

在 Flink 中,窗口并不是一个框,而是一个桶。窗口可以把数据流切割成有限大小的多个存储桶,数据会分发到对应的桶中,当到达窗口结束时间时,就会对每个桶中收集的数据进行处理。但是注意:窗口并不是静态准备好的,而是动态创建的,当有落在这个窗口区间范围的数据到达时,才创建对应的窗口。另外我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上 "触发计算" 和 "窗口关闭" 两个行为也可以分开,这部分内容我们会在后面详述。

窗口的分类

我们上面说的窗口其实是最简单的一种时间窗口,而在 Flink 中,窗口的应用非常灵活,我们可以使用各种不同类型的窗口来实现需求。接下来我们就从不同的角度,来对 Flink 中内置的窗口做一个分类说明。


1)按照驱动类型划分

窗口本身是截取有界数据的一种方式,所以窗口的一个非常重要的信息就是怎样截取数据。换句话说,就是以什么标准开始和结束数据的截取,我们称之为窗口的驱动类型。

  • 时间窗口(Time Window):以时间点来定义窗口的开始和结束,所以取出的就是某一时间段的数据。当到达结束时间时,窗口不再收集数据,并触发计算输出结果,之后再将窗口关闭销毁。整体思路类似于 "定点发车"。
  • 计数窗口(Count Window):基于元素的个数来截取数据,当元素到达固定的个数时就触发计算并关闭窗口,每个窗口截取数据的个数,就是窗口的大小。整体思路类似于 "人齐发车"。


2)按照窗口分配数据的规则分类

根据分配数据的规则,窗口的具体实现可以分为 4 类:

  • 滚动窗口(Tumbling Window)
  • 滑动窗口(Sliding Window)
  • 会话窗口(Session Window)
  • 全局窗口(Global Window)

滚动窗口:有固定的大小,是一种对数据进行均匀切片的划分形式,窗口之间没有重叠、也不会有间隔,是首尾相接的状态。这是最简单的窗口形式,每个数据都会分配到一个窗口上,而且只会属于一个窗口。滚动窗口可以基于时间定义,也可以基于数据个数定义,需要的参数只有一个,就是窗口的大小(Window Size)。

比如我们可以定义一个长度为 1 小时的滚动时间窗口,那么每隔 1 小时就会统计一次;或者定义一个长度为 10 的滚动计数窗口,那么每来 10 个元素就会统计一次。滚动窗口的应用十分广泛,它可以对每个时间段做聚合统计,很多 BI 分析指标都可以用它来实现。

滑动窗口:大小也是固定的,但窗口之间并不是首尾相接的,而是可以错开一定位置。定义滑动窗口的参数有两个,除了窗口大小之外,还有一个滑动步长。当滑动步长小于窗口大小时,滑动窗口就会出现重叠,这时数据可能会被分配到多个窗口中,而具体的个数则由窗口大小和滑动步长的比值来决定。

滑动窗口适合计算结果更新频率非常高的场景,此外窗口在结束时间触发计算结果,那么滑动步长就代表了计算频率。比如窗口长度为 m,滑动步长为 n,那么每个窗口就会统计 m 个数据,并每隔 n 个数据统计一次。

会话窗口:基于会话(Session)来对数据进行分组,并行只能基于时间定义。会话窗口中,最重要的参数就是会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(Size),那说明还在保持会话,它们就属于同一个窗口;如果 Gap 大于 Size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就要关闭了。

会话窗口的长度不固定,并且起始时间和结束时间也是不确定的,各个分区之间的窗口没有任何关联。此外会话窗口之间一定是不重叠的,而且至少会留有 Gap 的间隔。在一些类似保持会话的场景下,可以使用会话窗口来进行数据的处理统计。

全局窗口:这种窗口全局有效,会把相同 key 的数据都分配到同一个窗口中。这种窗口没有结束时间,不会做触发计算,如果你希望它能对数据进行计算处理,还需要自定义触发器(Trigger)。所以全局窗口一般在希望做出更加灵活的窗口处理时使用(自定义),Flink 中的计数窗口(Count Window),底层就是用全局窗口实现的。

窗口 API 概览

在定义窗口操作之前,首先需要确定,是基于按键分区(Keyed)的数据流 KeyedStream 来开窗,还是直接在没有按键分区的 DataStream 上开窗。也就是说,在调用窗口算子之前,要先判断是否有 key_by 操作。

如果有 key_by 操作,那么数据流会按照 key 被分为多条逻辑流(logical streams),也就是 KeyedStream。基于 KeyedStream 进行窗口操作时,窗口计算会在多个并行子任务上同时执行,相同 key 的数据会被发送到同一个子任务,而窗口操作会对拥有相同 key 的数据进行单独处理。所以可以认为有多个窗口计算,每个窗口各自针对一组拥有相同 key 的数据,独立地进行统计计算。

如果没有 key_by 操作,那么原始的 DataStream 就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(Task)上执行,就相当于并行度变成了 1。

# 按键分区窗口(Keyed Window)
ds.key_by(...).window(...).aggregate(...)
# 非按键分区窗口(Keyed Window)
ds.window_all(...).aggregate(...)

注意:对于非按键分区的窗口操作,即使手动调大窗口算子的并行度也是无效的,window_all 本身就是一个非并行的操作。

然后窗口操作主要有两部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)。其中 window() 方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的 aggregate() 方法需要传入一个窗口函数,它用来定义窗口具体的处理逻辑。窗口分配器有各种形式,而窗口函数的调用方法也不只 aggregate() 一种,我们接下来就详细展开讲解。

窗口分配器

定义窗口分配器(Window Assigners)是构建窗口算子的第一步,它的作用就是指定数据应该被分配到哪个窗口。所以可以说,窗口分配器其实就是在指定窗口的类型。

窗口分配器最通用的定义方式就是调用 .window() 方法,这个方法需要传入一个 WindowAssigner 对象作为参数,返回 WindowedStream。如果是非按键分区窗口,那么直接调用 .window_all() 方法,同样传入一个 WindowAssigner 对象,返回的是 AllWindowedStream。

然后窗口按照驱动类型可以分为时间窗口和计数窗口,而按照数据的分配规则,又有滚动窗口、滑动窗口、会话窗口、全局窗口四种。除去需要自定义的全局窗口外,其它常用的类型 Flink 都给出了内置的分配器实现,我们可以方便地调用。


滚动窗口(基于时间)

from pyflink.datastream.window import TumblingProcessingTimeWindows, TumblingEventTimeWindows
from pyflink.common.time import Time
# 时间分为两种:处理时间和事件时间
# 基于处理时间的滚动窗口
ds.key_by(...).window(TumblingProcessingTimeWindows.of(Time.seconds(5))).aggregate(...)
# 基于事件时间的滚动窗口
ds.key_by(...).window(TumblingEventTimeWindows.of(Time.seconds(5))).aggregate(...)

of() 方法需要接收一个 Time 类型的参数 size,表示滚动窗口的大小,我们这里创建了一个长度为 5 秒的滚动窗口。另外 of 还可以接收一个 offset 参数,表示窗口起始点的偏移量。


滑动窗口(基于时间)

from pyflink.datastream.window import SlidingProcessingTimeWindows, SlidingEventTimeWindows
from pyflink.common.time import Time
# 基于处理时间的滑动窗口
ds.key_by(...).window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5))).aggregate(...)
# 基于事件时间的滑动窗口
ds.key_by(...).window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5))).aggregate(...)

这里的 of() 方法需要传入两个 Time 类型的参数:size 和 slide,前者表示滑动窗口的大小,后者表示滑动窗口的滑动步长。我们这里创建了一个长度为 10 秒、滑动步长为 5 秒的滑动窗口。当然,滑动窗口同样可以追加第三个参数,用于指定窗口起始点的偏移量,用法与滚动窗口完全一致。


会话窗口(基于时间)

from pyflink.datastream.window import ProcessingTimeSessionWindows, EventTimeSessionWindows
from pyflink.common.time import Time
# 基于处理时间的会话窗口
ds.key_by(...).window(ProcessingTimeSessionWindows.with_gap(Time.seconds(10))).aggregate(...)
# 基于事件时间的会话窗口
ds.key_by(...).window(EventTimeSessionWindows.with_gap(Time.seconds(10))).aggregate(...)

这里 with_gap() 方法需要传入一个 Time 类型的参数 size,表示会话的超时时间,也就是最小间隔 Session Gap。我们这里创建了静态会话超时时间为 10 秒的会话窗口,另外还可以调用 with_dynamic_gap() 方法定义 Session Gap 的动态提取逻辑。

以上窗口都是基于时间,但窗口还可以基于计数。计数窗口的概念非常简单,本身底层就是基于全局窗口(Global Window)实现的,Flink 为我们提供了非常方便的接口:直接调用 .count_window() 方法。而根据分配规则的不同,又可以分为滚动计数窗口和滑动计数窗口两类,下面我们就来看它们的具体实现。

滚动窗口(基于计数)

ds.key_by(...).count_window(10).aggregate(...)

滚动计数窗口只需要传入一个整数 size,表示窗口的大小。上面代码定义了一个长度为 10 的滚动计数窗口,当窗口中元素数量达到 10 的时候,就会触发计算执行并关闭窗口。

滑动窗口(基于计数)

ds.key_by(...).count_window(10, 5).aggregate(...)

与滚动计数窗口类似,不过需要给 count_window 方法传入两个参数:size 和 slide,前者表示窗口大小,后者表示滑动步长。上面代码定义了一个长度为10、滑动步长为 3 的滑动计数窗口,每个窗口统计 10 个数据,每隔 3 个数据统计并输出结果一次。

窗口函数

定义了窗口分配器,我们只是知道了数据属于哪个窗口,可以将数据收集起来了。至于收集起来可以做什么,还完全没有头绪,所以在窗口分配器之后,必须再接上一个定义窗口如何计算的操作,这就是所谓的窗口函数。

窗口函数定义了要对窗口中收集的数据做的计算操作,根据处理的方式可以分为两类:增量聚合函数和全窗口函数,下面我们来进行分别讲解。


增量聚合函数

窗口将数据收集起来之后,最基本的处理操作当然就是进行聚合,我们可以每来一个数据就在之前的结果上聚合一次,这就是增量聚合。典型的增量聚合函数有两个:ReduceFunction 和 AggregateFunction。

未完待续

posted @ 2019-09-12 14:21  古明地盆  阅读(6902)  评论(2编辑  收藏  举报