diff --git a/doc/_toc.yml b/doc/_toc.yml index baaf3dd..8b2ee89 100644 --- a/doc/_toc.yml +++ b/doc/_toc.yml @@ -4,7 +4,7 @@ subtrees: entries: - file: ch-big-data-intro/index entries: - - file: ch-big-data-intro/bigdata + - file: ch-big-data-intro/sec-bigdata - file: ch-big-data-intro/batch-stream - file: ch-big-data-intro/technologies - file: ch-big-data-intro/evolution @@ -40,9 +40,9 @@ subtrees: - file: ch-time-window/exercise-stock - file: ch-state-checkpoint/index entries: + - file: ch-state-checkpoint/state - file: ch-state-checkpoint/checkpoint - file: ch-state-checkpoint/savepoint - - file: ch-state-checkpoint/state - file: ch-state-checkpoint/exercise-state - file: ch-flink-connectors/index entries: diff --git a/doc/ch-big-data-intro/batch-stream.md b/doc/ch-big-data-intro/batch-stream.md index f6f366c..f098cbd 100644 --- a/doc/ch-big-data-intro/batch-stream.md +++ b/doc/ch-big-data-intro/batch-stream.md @@ -3,12 +3,12 @@ ## 数据与数据流 -在大数据的 5 个 “V” 中我们已经提到,数据量大且产生速度快。从时间维度来讲,数据源源不断地产生,形成一个无界的数据流(Unbounded Data Stream)。如 {numref}`data-and-data-stream` 所示,单条数据被称为事件(Event),事件按照时序排列会形成一个数据流。例如,我们每时每刻的运动数据都会累积到手机传感器上,金融交易随时随地都在发生,物联网(Internet of Things,IoT)传感器会持续监控并生成数据。 +在大数据的 5 个 “V” 中我们已经提到,数据量大且产生速度快。从时间维度来讲,数据源源不断地产生,形成一个无界的数据流(Unbounded Data Stream)。如 {numref}`fig-data-and-data-stream` 所示,单条数据被称为事件(Event),事件按照时序排列会形成一个数据流。例如,我们每时每刻的运动数据都会累积到手机传感器上,金融交易随时随地都在发生,物联网(Internet of Things,IoT)传感器会持续监控并生成数据。 ```{figure} ./img/data-and-data-stream.png --- width: 60% -name: data-and-data-stream +name: fig-data-and-data-stream --- 数据和数据流 ``` @@ -39,12 +39,12 @@ name: data-and-data-stream ### 生产者 - 消费者模型 -处理流数据一般使用 “生产者 - 消费者”(Producer-Consumer)模型来解决问题。如 {numref}`producer-consumer` 所示,生产者生成数据,将数据发送到一个缓存区域(Buffer),消费者从缓存区域中消费数据。这里我们暂且不关心生产者如何生产数据,以及数据如何缓存,我们只关心如何实现消费者。 +处理流数据一般使用 “生产者 - 消费者”(Producer-Consumer)模型来解决问题。如 {numref}`fig-producer-consumer` 所示,生产者生成数据,将数据发送到一个缓存区域(Buffer),消费者从缓存区域中消费数据。这里我们暂且不关心生产者如何生产数据,以及数据如何缓存,我们只关心如何实现消费者。 ```{figure} ./img/producer-consumer.png --- width: 60% -name: producer-consumer +name: fig-producer-consumer --- 生产者 - 消费者模型 ``` diff --git a/doc/ch-big-data-intro/evolution.md b/doc/ch-big-data-intro/evolution.md index 1b4a19e..2e5e6b2 100644 --- a/doc/ch-big-data-intro/evolution.md +++ b/doc/ch-big-data-intro/evolution.md @@ -5,12 +5,12 @@ ## Lambda 架构 -当以 Storm 为代表的第一代流处理框架成熟后,一些互联网公司为了兼顾数据的实时性和准确性,采用 {numref}`lambda-architecture` 所示的 Lambda 架构来处理数据并提供在线服务。Lambda 架构主要分为 3 部分:批处理层、流处理层和在线服务层。其中数据流来自 Kafka 这样的消息队列。 +当以 Storm 为代表的第一代流处理框架成熟后,一些互联网公司为了兼顾数据的实时性和准确性,采用 {numref}`fig-lambda-architecture` 所示的 Lambda 架构来处理数据并提供在线服务。Lambda 架构主要分为 3 部分:批处理层、流处理层和在线服务层。其中数据流来自 Kafka 这样的消息队列。 ```{figure} ./img/lambda.png --- width: 60% -name: lambda-architecture +name: fig-lambda-architecture --- Lambda 架构 ``` @@ -43,12 +43,12 @@ Lambda 架构 ## Kappa 架构 -Kafka 的创始人杰•克雷普斯认为在很多场景下,维护一套 Lambda 架构的大数据处理平台耗时耗力,于是提出在某些场景下,没有必要维护一个批处理层,直接使用一个流处理层即可满足需求,即 {numref}`kappa-architecture` 所示的 Kappa 架构。 +Kafka 的创始人杰•克雷普斯认为在很多场景下,维护一套 Lambda 架构的大数据处理平台耗时耗力,于是提出在某些场景下,没有必要维护一个批处理层,直接使用一个流处理层即可满足需求,即 {numref}`fig-kappa-architecture` 所示的 Kappa 架构。 ```{figure} ./img/kappa.png --- width: 60% -name: kappa-architecture +name: fig-kappa-architecture --- Kappa 架构 ``` diff --git a/doc/ch-big-data-intro/exercise-stream-with-kafka.md b/doc/ch-big-data-intro/exercise-stream-with-kafka.md index f5ca86a..db924f0 100644 --- a/doc/ch-big-data-intro/exercise-stream-with-kafka.md +++ b/doc/ch-big-data-intro/exercise-stream-with-kafka.md @@ -7,7 +7,7 @@ ### 消息队列的功能 -消息队列一般使用{numref}`producer-consumer` 所示的 “生产者 - 消费者” 模型来解决问题:生产者生成数据,将数据发送到一个缓存区域,消费者从缓存区域中消费数据。消息队列可以解决以下问题: +消息队列一般使用{numref}`fig-producer-consumer` 所示的 “生产者 - 消费者” 模型来解决问题:生产者生成数据,将数据发送到一个缓存区域,消费者从缓存区域中消费数据。消息队列可以解决以下问题: - 系统解耦:很多企业内部有众多系统,一个 App 也包含众多模块,如果将所有的系统和模块都放在一起作为一个庞大的系统来开发,未来则会很难维护和扩展。如果将各个模块独立出来,模块之间通过消息队列来通信,未来可以轻松扩展每个独立模块。另外,假设没有消息队列,M 个生产者和 N 个消费者通信,会产生 M×N 个数据管道,消息队列将这个复杂度降到了 M+N。 - 异步处理:同步是指如果模块 A 向模块 B 发送消息,必须等待返回结果后才能执行接下来的业务逻辑。异步是消息发送方模块 A 无须等待返回结果即可继续执行,只需要向消息队列中发送消息,至于谁去处理这些消息、消息等待多长时间才能被处理等一系列问题,都由消费者负责。异步处理更像是发布通知,发送方不用关心谁去接收通知、如何对通知做出响应等问题。 diff --git a/doc/ch-big-data-intro/bigdata.md b/doc/ch-big-data-intro/sec-bigdata.md similarity index 90% rename from doc/ch-big-data-intro/bigdata.md rename to doc/ch-big-data-intro/sec-bigdata.md index ca99235..e17f228 100644 --- a/doc/ch-big-data-intro/bigdata.md +++ b/doc/ch-big-data-intro/sec-bigdata.md @@ -1,14 +1,14 @@ -(bigdata)= +(sec-bigdata)= # 什么是大数据 ## 大数据的 5 个 “V” -大数据,顾名思义,就是拥有庞大体量的数据。关于什么是大数据、如何定义大数据、如何使用大数据等一系列问题,拥有不同领域背景的读者的理解各不相同。通常,业界将大数据的特点归纳为 {numref}`5v` 所示的 5 个 “V”。 +大数据,顾名思义,就是拥有庞大体量的数据。关于什么是大数据、如何定义大数据、如何使用大数据等一系列问题,拥有不同领域背景的读者的理解各不相同。通常,业界将大数据的特点归纳为 {numref}`fig-5V` 所示的 5 个 “V”。 ```{figure} ./img/5V.png --- width: 60% -name: 5v +name: fig-5V --- 大数据的 5 个 "V" ``` @@ -31,12 +31,12 @@ name: 5v 计算机诞生之后,一般是在单台计算机上处理数据。大数据时代到来后,一些传统的数据处理方法无法满足大数据的处理需求。将一组计算机组织到一起形成一个集群,利用集群的力量来处理大数据的工程实践逐渐成为主流。这种使用集群进行计算的方式被称为分布式计算,当前几乎所有的大数据系统都在使用集群进行分布式计算。 -分布式计算的概念听起来很高深,其背后的思想却十分朴素,即分而治之,又称为分治法(Divide and Conquer)。如图 {numref}`divide-conquer` 所示,分治法是指将一个原始问题分解为多个子问题,多个子问题分别在多台计算机上求解,借助必要的数据交换和合并策略,将子结果汇总即可求出最终结果的方法。具体而言,不同的分布式系统使用的算法和策略根据所要解决的问题各有不同,但基本上都是将计算拆分,把子问题放到多台计算机上,分而治之地计算求解。分布式计算的每台计算机(物理机或虚拟机)又被称为一个节点。 +分布式计算的概念听起来很高深,其背后的思想却十分朴素,即分而治之,又称为分治法(Divide and Conquer)。如图 {numref}`fig-divide-conquer` 所示,分治法是指将一个原始问题分解为多个子问题,多个子问题分别在多台计算机上求解,借助必要的数据交换和合并策略,将子结果汇总即可求出最终结果的方法。具体而言,不同的分布式系统使用的算法和策略根据所要解决的问题各有不同,但基本上都是将计算拆分,把子问题放到多台计算机上,分而治之地计算求解。分布式计算的每台计算机(物理机或虚拟机)又被称为一个节点。 ```{figure} ./img/divide-conquer.png --- width: 60% -name: divide-conquer +name: fig-divide-conquer --- 分治法 ``` @@ -45,12 +45,12 @@ name: divide-conquer ### MPI -MPI 是一个 “老牌” 分布式计算框架,从 MPI 这个名字也可以看出,MPI 主要解决节点间数据通信的问题。在前 MapReduce 时代,MPI 是分布式计算的业界标准。MPI 现在依然广泛运用于全球各大超级计算中心、大学、研究机构中,许多物理、生物、化学、能源等基础学科的大规模分布式计算都依赖 MPI。{numref}`mpi`所示为使用 MPI 在 4 台服务器上并行计算的示意图。 +MPI 是一个 “老牌” 分布式计算框架,从 MPI 这个名字也可以看出,MPI 主要解决节点间数据通信的问题。在前 MapReduce 时代,MPI 是分布式计算的业界标准。MPI 现在依然广泛运用于全球各大超级计算中心、大学、研究机构中,许多物理、生物、化学、能源等基础学科的大规模分布式计算都依赖 MPI。{numref}`fig-mpi`所示为使用 MPI 在 4 台服务器上并行计算的示意图。 ```{figure} ./img/mpi.png --- width: 60% -name: mpi +name: fig-mpi --- 在 4 台服务器上使用 MPI 进行并行计算 ``` @@ -69,14 +69,14 @@ MPI 能够以很细的粒度控制数据的通信,这是它的优势,从某 比起 MPI,MapReduce 编程模型将更多的中间过程做了封装,程序员只需要将原始问题转化为更高层次的应用程序接口(Application Programming Interface,API),至于原始问题如何分解为更小的子问题、中间数据如何传输和交换、如何将计算扩展到多个节点等一系列细节问题可以交给大数据编程模型来解决。因此,MapReduce 相对来说学习门槛更低,使用更方便,编程开发速度更快。 -{numref}`mapreduce-sandwichs` 所示为使用 MapReduce 思想制作三明治的过程,读者可以通过这幅图更好的理解 MapReduce。 +{numref}`fig-mapreduce-sandwichs` 所示为使用 MapReduce 思想制作三明治的过程,读者可以通过这幅图更好的理解 MapReduce。 假设我们需要大批量地制作三明治,三明治的每种食材可以分别单独处理,Map 阶段将原材料在不同的节点上分别进行处理,生成一些中间食材,Shuffle/Group 阶段将不同的中间食材进行组合,Reduce 阶段最终将一组中间食材组合成三明治成品。可以看到,这种 Map + Shuffle/Group + Reduce 的方式就是分治法的一种实现。 ```{figure} ./img/mapreduce-sandwichs.jpeg --- width: 60% -name: mapreduce-sandwichs +name: fig-mapreduce-sandwichs --- 使用 MapReduce 制作三明治的过程 ``` diff --git a/doc/ch-big-data-intro/stream-processing-basics.md b/doc/ch-big-data-intro/stream-processing-basics.md index cd5e6fc..6fce3cf 100644 --- a/doc/ch-big-data-intro/stream-processing-basics.md +++ b/doc/ch-big-data-intro/stream-processing-basics.md @@ -29,19 +29,19 @@ 比起批处理,流处理对窗口(Window)和时间概念更为敏感。在批处理场景下,数据已经按照某个时间维度被分批次地存储了。一些公司经常将用户行为日志按天存储,一些开放数据集都会说明数据采集的时间始末。因此,对于批处理任务,处理一个数据集,其实就是对该数据集对应的时间窗口内的数据进行处理。在流处理场景下,数据以源源不断的流的形式存在,数据一直在产生,没有始末。我们要对数据进行处理时,往往需要明确一个时间窗口,比如,数据在 “每秒”“每小时”“每天” 的维度下的一些特性。窗口将数据流切分成多个数据块,很多数据分析都是在窗口上进行操作,比如连接、聚合以及其他时间相关的操作。 -{numref}`three-type-window` 展示了 3 种常见的窗口形式:滚动窗口、滑动窗口、会话窗口。 +{numref}`fig-three-type-window` 展示了 3 种常见的窗口形式:滚动窗口、滑动窗口、会话窗口。 ```{figure} ./img/three-type-window.png --- width: 60% -name: three-type-window +name: fig-three-type-window --- 3 种常见的窗口形式 ``` -- **滚动窗口(Tumbling Window)**:模式一般定义一个固定的窗口长度,长度是一个时间间隔,比如小时级的窗口或分钟级的窗口。窗口像车轮一样,滚动向前,任意两个窗口之间不会包含同样的数据。 -- **滑动窗口(Sliding Window)**:模式也设有一个固定的窗口长度。假如我们想每分钟开启一个窗口,统计 10 分钟内的股票价格波动,就使用滑动窗口模式。当窗口的长度大于滑动的间隔,可能会导致两个窗口之间包含同样的事件。其实,滚动窗口模式是滑动窗口模式的一个特例,滚动窗口模式中滑动的间隔正好等于窗口的大小。 -- **会话窗口(Session Window)**:模式的窗口长度不固定,而是通过一个间隔来确定窗口,这个间隔被称为会话间隔(Session Gap)。当两个事件之间的间隔大于会话间隔,则两个事件被划分到不同的窗口中;当事件之间的间隔小于会话间隔,则两个事件被划分到同一窗口。 +- 滚动窗口(Tumbling Window):模式一般定义一个固定的窗口长度,长度是一个时间间隔,比如小时级的窗口或分钟级的窗口。窗口像车轮一样,滚动向前,任意两个窗口之间不会包含同样的数据。 +- 滑动窗口(Sliding Window):模式也设有一个固定的窗口长度。假如我们想每分钟开启一个窗口,统计 10 分钟内的股票价格波动,就使用滑动窗口模式。当窗口的长度大于滑动的间隔,可能会导致两个窗口之间包含同样的事件。其实,滚动窗口模式是滑动窗口模式的一个特例,滚动窗口模式中滑动的间隔正好等于窗口的大小。 +- 会话窗口(Session Window):模式的窗口长度不固定,而是通过一个间隔来确定窗口,这个间隔被称为会话间隔(Session Gap)。当两个事件之间的间隔大于会话间隔,则两个事件被划分到不同的窗口中;当事件之间的间隔小于会话间隔,则两个事件被划分到同一窗口。 ### 时间语义 @@ -56,12 +56,12 @@ name: three-type-window #### “一分钟” 真的是一分钟吗? -在很多应用场景中,时间有着不同的语义,“一分钟” 真的是一分钟吗?很多手机游戏中多玩家在线实时竞技,假设我们在玩某款手机游戏,该游戏将数据实时发送给游戏服务器,服务器计算一分钟内玩家的一些操作,这些计算影响用户该局游戏的最终得分。当游戏正酣,我们进入了电梯,手机信号丢失,一分钟后才恢复信号;幸好手机在电梯期间缓存了掉线时的数据,并在信号恢复后将缓存数据传回了服务器,{numref}`data-transmission-signal-loss` 展示了这个场景的流处理过程。在丢失信号的这段时间,你的数据没有被计算进去,显然这样的计算不公平。当信号恢复时,数据重传到服务器,再根据 Event Time 重新计算一次,那就非常公平了。我们可以根据 Event Time 复现一个事件序列的实际顺序。因此,使用 Event Time 是最准确的。 +在很多应用场景中,时间有着不同的语义,“一分钟” 真的是一分钟吗?很多手机游戏中多玩家在线实时竞技,假设我们在玩某款手机游戏,该游戏将数据实时发送给游戏服务器,服务器计算一分钟内玩家的一些操作,这些计算影响用户该局游戏的最终得分。当游戏正酣,我们进入了电梯,手机信号丢失,一分钟后才恢复信号;幸好手机在电梯期间缓存了掉线时的数据,并在信号恢复后将缓存数据传回了服务器,{numref}`fig-data-transmission-signal-loss` 展示了这个场景的流处理过程。在丢失信号的这段时间,你的数据没有被计算进去,显然这样的计算不公平。当信号恢复时,数据重传到服务器,再根据 Event Time 重新计算一次,那就非常公平了。我们可以根据 Event Time 复现一个事件序列的实际顺序。因此,使用 Event Time 是最准确的。 ```{figure} ./img/signal.png --- width: 60% -name: data-transmission-signal-loss +name: fig-data-transmission-signal-loss --- 数据传输过程恰好遇到信号丢失 ``` @@ -76,12 +76,12 @@ Watermark 是一种折中解决方案,它假设某个时间点上,不会有 ## 状态与检查点 -状态是流处理区别于批处理的特有概念。如果我们对一个文本数据流进行处理,把英文大写字母都改成英文小写字母,这种处理是无状态的,即系统不需要记录额外的信息。如果我们想统计这个数据流一分钟内的单词出现次数,一方面要处理每一瞬间新流入的数据,另一方面要保存之前一分钟内已经进入系统的数据,额外保存的数据就是状态。{numref}`state-stateless` 展示了无状态和有状态两种不同类型的计算。 +状态是流处理区别于批处理的特有概念。如果我们对一个文本数据流进行处理,把英文大写字母都改成英文小写字母,这种处理是无状态的,即系统不需要记录额外的信息。如果我们想统计这个数据流一分钟内的单词出现次数,一方面要处理每一瞬间新流入的数据,另一方面要保存之前一分钟内已经进入系统的数据,额外保存的数据就是状态。{numref}`fig-state-stateless` 展示了无状态和有状态两种不同类型的计算。 ```{figure} ./img/state-stateless.png --- width: 60% -name: state-stateless +name: fig-state-stateless --- 无状态计算和有状态计算 ``` diff --git a/doc/ch-big-data-intro/technologies.md b/doc/ch-big-data-intro/technologies.md index bee749b..63c25d3 100644 --- a/doc/ch-big-data-intro/technologies.md +++ b/doc/ch-big-data-intro/technologies.md @@ -5,7 +5,7 @@ MapReduce 编程模型的提出为大数据分析和处理开创了一条先河 ## Hadoop -2004 年,Hadoop 的创始人道格·卡廷(Doug Cutting)和麦克·卡法雷拉(Mike Cafarella)受 MapReduce 编程模型和 Google File System 等技术的启发,对其中提及的思想进行了编程实现,Hadoop 的名字来源于道格 · 卡廷儿子的玩具大象。由于道格 · 卡廷后来加入了雅虎,并在雅虎工作期间做了大量 Hadoop 的研发工作,因此 Hadoop 也经常被认为是雅虎开源的一款大数据框架。时至今日,Hadoop 不仅是整个大数据领域的先行者和领航者,更形成了一套围绕 Hadoop 的生态圈,Hadoop 和它的生态圈是绝大多数企业首选的大数据解决方案。{numref}`hadoop-ecosystem` 展示了 Hadoop 生态圈一些流行组件。 +2004 年,Hadoop 的创始人道格·卡廷(Doug Cutting)和麦克·卡法雷拉(Mike Cafarella)受 MapReduce 编程模型和 Google File System 等技术的启发,对其中提及的思想进行了编程实现,Hadoop 的名字来源于道格 · 卡廷儿子的玩具大象。由于道格 · 卡廷后来加入了雅虎,并在雅虎工作期间做了大量 Hadoop 的研发工作,因此 Hadoop 也经常被认为是雅虎开源的一款大数据框架。时至今日,Hadoop 不仅是整个大数据领域的先行者和领航者,更形成了一套围绕 Hadoop 的生态圈,Hadoop 和它的生态圈是绝大多数企业首选的大数据解决方案。{numref}`fig-hadoop-ecosystem` 展示了 Hadoop 生态圈一些流行组件。 Hadoop 生态圈的核心组件主要有如下 3 个。 @@ -23,7 +23,7 @@ Hadoop 生态圈的核心组件主要有如下 3 个。 ```{figure} ./img/hadoop.png --- width: 60% -name: hadoop-ecosystem +name: fig-hadoop-ecosystem --- Hadoop 生态圈 ``` @@ -39,22 +39,22 @@ Spark 是一款大数据处理框架,其开发初衷是改良 Hadoop MapReduce Spark 的核心在于计算,主要目的在于优化 Hadoop MapReduce 计算部分,在计算层面提供更细致的服务。 -Spark 并不能完全取代 Hadoop,实际上,从 {numref}`hadoop-ecosystem` 可以看出,Spark 融入了 Hadoop 生态圈,成为其中的重要一员。一个 Spark 任务很可能依赖 HDFS 上的数据,向 YARN 申请计算资源,将结果输出到 HBase 上。当然,Spark 也可以不用依赖这些组件,独立地完成计算。 +Spark 并不能完全取代 Hadoop,实际上,从 {numref}`fig-hadoop-ecosystem` 可以看出,Spark 融入了 Hadoop 生态圈,成为其中的重要一员。一个 Spark 任务很可能依赖 HDFS 上的数据,向 YARN 申请计算资源,将结果输出到 HBase 上。当然,Spark 也可以不用依赖这些组件,独立地完成计算。 ```{figure} ./img/spark.png --- width: 60% -name: spark-ecosystem +name: fig-spark-ecosystem --- Spark 生态圈 ``` -Spark 主要面向批处理需求,因其优异的性能和易用的接口,Spark 已经是批处理界绝对的 “王者”。Spark 的子模块 Spark Streaming 提供了流处理的功能,它的流处理主要基于 mini-batch 的思想。如 {numref}`spark-streaming-mini-batch` 所示,Spark Streaming 将输入数据流切分成多个批次,每个批次使用批处理的方式进行计算。因此,Spark 是一款集批处理和流处理于一体的处理框架。 +Spark 主要面向批处理需求,因其优异的性能和易用的接口,Spark 已经是批处理界绝对的 “王者”。Spark 的子模块 Spark Streaming 提供了流处理的功能,它的流处理主要基于 mini-batch 的思想。如 {numref}`fig-spark-streaming-mini-batch` 所示,Spark Streaming 将输入数据流切分成多个批次,每个批次使用批处理的方式进行计算。因此,Spark 是一款集批处理和流处理于一体的处理框架。 ```{figure} ./img/spark-streaming-mini-batch.png --- width: 60% -name: spark-streaming-mini-batch +name: fig-spark-streaming-mini-batch --- Spark Streaming mini-batch 处理 ``` @@ -65,12 +65,12 @@ Spark Streaming mini-batch 处理 Kafka 也是一种面向大数据领域的消息队列框架。在大数据生态圈中,Hadoop 的 HDFS 或 Amazon S3 提供数据存储服务,Hadoop MapReduce、Spark 和 Flink 负责计算,Kafka 常常用来连接不同的应用系统。 -如 {numref}`kafka-multi-system` 所示,企业中不同的应用系统作为数据生产者会产生大量数据流,这些数据流还需要进入不同的数据消费者,Kafka 起到数据集成和系统解耦的作用。系统解耦是让某个应用系统专注于一个目标,以降低整个系统的维护难度。在实践上,一个企业经常拆分出很多不同的应用系统,系统之间需要建立数据流管道(Stream Pipeline)。假如没有 Kafka 的消息队列,M 个生产者和 N 个消费者之间要建立 M×N 个点对点的数据流管道,Kafka 就像一个中介,让数据管道的个数变为 M+N,大大减小了数据流管道的复杂程度。 +如 {numref}`fig-kafka-multi-system` 所示,企业中不同的应用系统作为数据生产者会产生大量数据流,这些数据流还需要进入不同的数据消费者,Kafka 起到数据集成和系统解耦的作用。系统解耦是让某个应用系统专注于一个目标,以降低整个系统的维护难度。在实践上,一个企业经常拆分出很多不同的应用系统,系统之间需要建立数据流管道(Stream Pipeline)。假如没有 Kafka 的消息队列,M 个生产者和 N 个消费者之间要建立 M×N 个点对点的数据流管道,Kafka 就像一个中介,让数据管道的个数变为 M+N,大大减小了数据流管道的复杂程度。 ```{figure} ./img/kafka.png --- width: 60% -name: kafka-multi-system +name: fig-kafka-multi-system --- Kafka 可以连接多个应用系统 ``` @@ -83,14 +83,14 @@ Kafka 可以连接多个应用系统 Flink 是由德国 3 所大学发起的的学术项目,后来不断发展壮大,并于 2014 年年末成为 Apache 顶级项目之一。在德语中,“flink” 表示快速、敏捷,以此来表征这款计算框架的特点。 -Flink 主要面向流处理,如果说 Spark 是批处理界的 “王者”,那么 Flink 就是流处理领域冉冉升起的 “新星”。流处理并不是一项全新的技术,在 Flink 之前,不乏流处理引擎,比较著名的有 Storm、Spark Streaming,{numref}`evolution-stream-frameworks` 展示了流处理框架经历的三代演进。 +Flink 主要面向流处理,如果说 Spark 是批处理界的 “王者”,那么 Flink 就是流处理领域冉冉升起的 “新星”。流处理并不是一项全新的技术,在 Flink 之前,不乏流处理引擎,比较著名的有 Storm、Spark Streaming,{numref}`fig-evolution-stream-frameworks` 展示了流处理框架经历的三代演进。 2011 年成熟的 Apache Strom(以下简称 Storm)是第一代被广泛采用的流处理引擎。它是以数据流中的事件为最小单位来进行计算的。以事件为单位的框架的优势是延迟非常低,可以提供毫秒级的延迟。流处理结果依赖事件到达的时序准确性,Storm 并不能保障处理结果的一致性和准确性。Storm 只支持至少一次(At-Least-Once)和至多一次(At-Most-Once),即数据流里的事件投递只能保证至少一次或至多一次,不能保证只有一次(Exactly-Once)。在多项基准测试中,Storm 的数据吞吐量和延迟都远逊于 Flink。对于很多对数据准确性要求较高的应用,Storm 有一定劣势。此外,Storm 不支持 SQL,不支持中间状态(State)。 ```{figure} ./img/evolution-stream-frameworks.png --- width: 60% -name: evolution-stream-frameworks +name: fig-evolution-stream-frameworks --- 流处理框架演进 ``` diff --git a/doc/ch-datastream-api/data-types.md b/doc/ch-datastream-api/data-types.md index b79a8a8..c0d3d97 100644 --- a/doc/ch-datastream-api/data-types.md +++ b/doc/ch-datastream-api/data-types.md @@ -3,36 +3,43 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -几乎所有的大数据框架都要面临分布式计算、数据传输和持久化问题。数据传输过程前后要进行数据的序列化和反序列化:序列化就是将一个内存对象转换成二进制串,形成可网络传输或者可持久化的数据流。反序列化将二进制串转换为内存对象,这样就可以直接在编程语言中读写和操作这个对象。一种最简单的序列化方法就是将复杂数据结构转化成JSON格式。序列化和反序列化是很多大数据框架必须考虑的问题,在Java和大数据生态圈中,已有不少序列化工具,比如Java自带的序列化工具、Kryo等。一些RPC框架也提供序列化功能,比如最初用于Hadoop的Apache Avro、Facebook开发的Apache Thrift和Google开发的Protobuf,这些工具在速度和压缩比等方面比JSON有明显的优势。 +几乎所有的大数据框架都要面临分布式计算、数据传输和持久化问题。数据传输过程前后要进行数据的序列化和反序列化:序列化就是将一个内存对象转换成二进制串,形成可网络传输或者可持久化的数据流。反序列化将二进制串转换为内存对象,这样就可以直接在编程语言中读写和操作这个对象。一种最简单的序列化方法就是将复杂数据结构转化成 JSON 格式。序列化和反序列化是很多大数据框架必须考虑的问题,在 Java 和大数据生态圈中,已有不少序列化工具,比如 Java 自带的序列化工具、Kryo 等。一些 RPC 框架也提供序列化功能,比如最初用于 Hadoop 的 Apache Avro、Facebook 开发的 Apache Thrift 和 Google 开发的 Protobuf,这些工具在速度和压缩比等方面比 JSON 有明显的优势。 -但是Flink依然选择了重新开发了自己的序列化框架,因为序列化和反序列化将关乎整个流处理框架各方面的性能,对数据类型了解越多,可以更早地完成数据类型检查,节省数据存储空间。 +但是 Flink 依然选择了重新开发了自己的序列化框架,因为序列化和反序列化将关乎整个流处理框架各方面的性能,对数据类型了解越多,可以更早地完成数据类型检查,节省数据存储空间。 -## Flink支持的数据类型 +## Flink 支持的数据类型 -![Flink支持的数据类型](./img/data-type.png) +```{figure} ./img/data-type.png +--- +name: flink-data-types +width: 80% +align: center +--- +Flink 支持的数据类型 +``` -Flink支持上图所示的几种数据类型:基础类型、数组、复合类型、辅助类型。其中,Kryo是最后的备选方案,如果能够优化,尽量不要使用Kryo,否则会有大量的性能损失。 +Flink 支持上图所示的几种数据类型:基础类型、数组、复合类型、辅助类型。其中,Kryo 是最后的备选方案,如果能够优化,尽量不要使用 Kryo,否则会有大量的性能损失。 ### 基础类型 -所有Java和Scala基础数据类型,诸如Int、Double、Long(包括Java原生类型int和装箱后的类型Integer)、String,以及Date、BigDecimal和BigInteger。 +所有 Java 和 Scala 基础数据类型,诸如 Int、Double、Long(包括 Java 原生类型 int 和装箱后的类型 Integer)、String,以及 Date、BigDecimal 和 BigInteger。 ### 数组 -基础类型或其他对象类型组成的数组,如`String[]`。 +基础类型或其他对象类型组成的数组,如 `String[]`。 ### 复合类型 #### Scala case class -Scala case class是Scala的特色,用这种方式定义一个数据结构非常简洁。例如股票价格的数据结构: +Scala case class 是 Scala 的特色,用这种方式定义一个数据结构非常简洁。例如股票价格的数据结构: ```scala case class StockPrice(symbol: String = "", @@ -40,7 +47,7 @@ case class StockPrice(symbol: String = "", ts: Long = 0) ``` -这样定义的数据结构,所有的子字段都是`public`,可以直接读取。另外,我们可以不用`new`即可获取一个新的对象。 +这样定义的数据结构,所有的子字段都是 `public`,可以直接读取。另外,我们可以不用 `new` 即可获取一个新的对象。 ```scala val stock = StockPrice("AAPL", 300d, 1582819200000L) @@ -48,14 +55,14 @@ val stock = StockPrice("AAPL", 300d, 1582819200000L) #### Java POJO -Java的话,需要定义POJO(Plain Old Java Object)类,定义POJO类有一些注意事项: +Java 的话,需要定义 POJO(Plain Old Java Object)类,定义 POJO 类有一些注意事项: -* 该类必须用`public`修饰。 -* 该类必须有一个`public`的无参数的构造函数。 -* 该类的所有非静态(non-static)、非瞬态(non-transient)字段必须是`public`,如果字段不是`public`则必须有标准的getter和setter方法,比如对于字段`A a`有`A getA()`和`setA(A a)`。 -* 所有子字段也必须是Flink支持的数据类型。 +* 该类必须用 `public` 修饰。 +* 该类必须有一个 `public` 的无参数的构造函数。 +* 该类的所有非静态(non-static)、非瞬态(non-transient)字段必须是 `public`,如果字段不是 `public` 则必须有标准的 getter 和 setter 方法,比如对于字段 `A a` 有 `A getA()` 和 `setA(A a)`。 +* 所有子字段也必须是 Flink 支持的数据类型。 -下面三个例子中,只有第一个是POJO,其他两个都不是POJO,非POJO类将使用Kryo序列化工具。 +下面三个例子中,只有第一个是 POJO,其他两个都不是 POJO,非 POJO 类将使用 Kryo 序列化工具。 ```java public class StockPrice { @@ -63,7 +70,7 @@ public class StockPrice { public double price; public long ts; - public StockPrice() {} + public StockPrice(){} public StockPrice(String symbol, Long timestamp, Double price){ this.symbol = symbol; this.ts = timestamp; @@ -74,14 +81,14 @@ public class StockPrice { // NOT POJO public class StockPriceNoGeterSeter { - // LOGGER 无getter和setter + // LOGGER 无 getter 和 setter private Logger LOGGER = LoggerFactory.getLogger(StockPriceNoGeterSeter.class); public String symbol; public double price; public long ts; - public StockPriceNoGeterSeter() {} + public StockPriceNoGeterSeter(){} public StockPriceNoGeterSeter(String symbol, long timestamp, Double price){ this.symbol = symbol; @@ -107,22 +114,22 @@ public class StockPriceNoConstructor { } ``` -如果不确定是否是POJO,可以使用下面的代码检查: +如果不确定是否是 POJO,可以使用下面的代码检查: ```java System.out.println(TypeInformation.of(StockPrice.class).createSerializer(new ExecutionConfig())); ``` -返回的结果中,如果这个类在使用`KryoSerializer`,说明不是POJO类。 +返回的结果中,如果这个类在使用 `KryoSerializer`,说明不是 POJO 类。 -此外,使用Avro生成的类可以被Flink识别为POJO。 +此外,使用 Avro 生成的类可以被 Flink 识别为 POJO。 #### Tuple -Tuple可被翻译为元组,比如我们可以将刚刚定义的股票价格抽象为一个三元组。Scala用括号来定义元组,比如一个三元组:`(String, Long, Double)`。 +Tuple 可被翻译为元组,比如我们可以将刚刚定义的股票价格抽象为一个三元组。Scala 用括号来定义元组,比如一个三元组:`(String, Long, Double)`。 :::info -Scala访问元组中的元素时,要使用下划线。与其他地方从0开始计数不同,这里是从1开始计数,_1为元组中的第一个元素。下面的代码是一个Scala Tuple的例子。 +Scala 访问元组中的元素时,要使用下划线。与其他地方从 0 开始计数不同,这里是从 1 开始计数,_1 为元组中的第一个元素。下面的代码是一个 Scala Tuple 的例子。 ::: ```scala @@ -141,7 +148,7 @@ def main(args: Array[String]): Unit = { } ``` -Flink为Java专门准备了元组类型,比如3元组为`Tuple3`,最多支持到25元组`Tuple25`。访问元组中的元素时,要使用Tuple类准备好的公共字段:`f0`、`f1`...或者使用`getField(int pos)`方法,并注意进行类型转换。这里的元组是从0开始计数。 +Flink 为 Java 专门准备了元组类型,比如 3 元组为 `Tuple3`,最多支持到 25 元组 `Tuple25`。访问元组中的元素时,要使用 Tuple 类准备好的公共字段:`f0`、`f1`... 或者使用 `getField(int pos)` 方法,并注意进行类型转换。这里的元组是从 0 开始计数。 ```java // Java Tuple Example @@ -164,11 +171,11 @@ public static void main(String[] args) throws Exception { } ``` -Scala的Tuple中所有元素都不可变,如果想改变元组中的值,一般需要创建一个新的对象并赋值。Java的Tuple中的元素是可以被更改和赋值的,因此在Java中使用Tuple可以充分利用这一特性,可以减少垃圾回收的压力。 +Scala 的 Tuple 中所有元素都不可变,如果想改变元组中的值,一般需要创建一个新的对象并赋值。Java 的 Tuple 中的元素是可以被更改和赋值的,因此在 Java 中使用 Tuple 可以充分利用这一特性,可以减少垃圾回收的压力。 ```java -// stock是一个Tuple3 -// 获取Tuple3中第三个位置的值 +// stock 是一个 Tuple3 +// 获取 Tuple3 中第三个位置的值 Double price = stock.getField(2); // 给第三个位置赋值 stock.setField(70, 2); @@ -176,30 +183,44 @@ stock.setField(70, 2); ### 辅助类型 -Flink还支持Java的`ArrayList`、`HashMap`和`Enum`,Scala的`Either`和`Option`。 +Flink 还支持 Java 的 `ArrayList`、`HashMap` 和 `Enum`,Scala 的 `Either` 和 `Option`。 ## 泛型和其他类型 -当以上任何一个类型均不满足时,Flink认为该数据结构是一种泛型(GenericType),使用Kryo来进行序列化和反序列化。但Kryo在有些流处理场景效率非常低,有可能造成流数据的积压。我们可以使用`senv.getConfig.disableGenericTypes()`来禁用Kryo,禁用后,Flink遇到无法处理的数据类型将抛出异常,这种方法对于调试非常有效。 +当以上任何一个类型均不满足时,Flink 认为该数据结构是一种泛型(GenericType),使用 Kryo 来进行序列化和反序列化。但 Kryo 在有些流处理场景效率非常低,有可能造成流数据的积压。我们可以使用 `senv.getConfig.disableGenericTypes()` 来禁用 Kryo,禁用后,Flink 遇到无法处理的数据类型将抛出异常,这种方法对于调试非常有效。 ## TypeInformation -以上如此多的类型,在Flink中,统一使用`TypeInformation`类表示。比如,POJO在Flink内部使用`PojoTypeInfo`来表示,`PojoTypeInfo`继承自`CompositeType`,`CompositeType`继承自`TypeInformation`。下图展示了`TypeInformation`的继承关系,可以看到,前面提到的诸多数据类型,在Flink中都有对应的类型。`TypeInformation`的一个重要的功能就是创建`TypeSerializer`序列化器,为该类型的数据做序列化。每种类型都有一个对应的序列化器来进行序列化。 +以上如此多的类型,在 Flink 中,统一使用 `TypeInformation` 类表示。比如,POJO 在 Flink 内部使用 `PojoTypeInfo` 来表示,`PojoTypeInfo` 继承自 `CompositeType`,`CompositeType` 继承自 `TypeInformation`。下图展示了 `TypeInformation` 的继承关系,可以看到,前面提到的诸多数据类型,在 Flink 中都有对应的类型。`TypeInformation` 的一个重要的功能就是创建 `TypeSerializer` 序列化器,为该类型的数据做序列化。每种类型都有一个对应的序列化器来进行序列化。 -![TypeInformation继承关系](./img/typeinformation.png)) +```{figure} ./img/typeinformation.png +--- +name: fig-typeinformation +width: 80% +align: center +--- +TypeInformation 继承关系 +``` -使用前面介绍的各类数据类型时,Flink会自动探测传入的数据类型,生成对应的`TypeInformation`,调用对应的序列化器,因此用户其实无需关心类型推测。比如,Flink的`map`函数Scala签名为:`def map[R: TypeInformation](fun: T => R): DataStream[R]`,传入`map`的数据类型是T,生成的数据类型是R,Flink会推测T和R的数据类型,并使用对应的序列化器进行序列化。 +使用前面介绍的各类数据类型时,Flink 会自动探测传入的数据类型,生成对应的 `TypeInformation`,调用对应的序列化器,因此用户其实无需关心类型推测。比如,Flink 的 `map` 函数 Scala 签名为:`def map[R: TypeInformation](fun: T => R): DataStream[R]`,传入 `map` 的数据类型是 T,生成的数据类型是 R,Flink 会推测 T 和 R 的数据类型,并使用对应的序列化器进行序列化。 -![Flink数据类型推断和序列化](./img/type-inference-process.png) +```{figure} ./img/type-inference-process.svg +--- +name: fig-type-inference-process +width: 80% +align: center +--- +Flink 数据类型推断和序列化 +``` -上图展示了Flink的类型推断和序列化过程,以一个字符串`String`类型为例,Flink首先推断出该类型,并生成对应的`TypeInformation`,然后在序列化时调用对应的序列化器,将一个内存对象写入内存块。 +{numref}`fig-type-inference-process` 展示了 Flink 的类型推断和序列化过程,以一个字符串 `String` 类型为例,Flink 首先推断出该类型,并生成对应的 `TypeInformation`,然后在序列化时调用对应的序列化器,将一个内存对象写入内存块。 ## 注册类 -如果传递给Flink算子的数据类型是父类,实际运行过程中使用的是子类,子类中有一些父类没有的数据结构和特性,将子类注册可以提高性能。在执行环境上调用`env.registerType(clazz) `来注册类。`registerType`方法的源码如下所示,其中`TypeExtractor`对数据类型进行推断,如果传入的类型是POJO,则可以被Flink识别和注册,否则将使用Kryo。 +如果传递给 Flink 算子的数据类型是父类,实际运行过程中使用的是子类,子类中有一些父类没有的数据结构和特性,将子类注册可以提高性能。在执行环境上调用 `env.registerType(clazz) ` 来注册类。`registerType` 方法的源码如下所示,其中 `TypeExtractor` 对数据类型进行推断,如果传入的类型是 POJO,则可以被 Flink 识别和注册,否则将使用 Kryo。 ```java -// Flink registerType java源码 +// Flink registerType java 源码 public void registerType(Class> type) { if (type == null) { throw new NullPointerException("Cannot register null type class."); @@ -217,14 +238,14 @@ public void registerType(Class> type) { ## 注册序列化器 -如果你的数据类型不是Flink支持的上述类型,这时Flink会使用Kryo序列化。我们需要对数据类型和序列化器进行注册,以便Flink对该数据类型进行序列化。 +如果你的数据类型不是 Flink 支持的上述类型,这时 Flink 会使用 Kryo 序列化。我们需要对数据类型和序列化器进行注册,以便 Flink 对该数据类型进行序列化。 ```java -// 使用对TestClassSerializer对TestClass进行序列化 +// 使用对 TestClassSerializer 对 TestClass 进行序列化 env.registerTypeWithKryoSerializer(TestClass.class, new TestClassSerializer()); ``` -其中`TestClassSerializer`要继承`com.esotericsoftware.kryo.Serializer`。下面的代码是一个序列化示意案例。 +其中 `TestClassSerializer` 要继承 `com.esotericsoftware.kryo.Serializer`。下面的代码是一个序列化示意案例。 ```java static class TestClassSerializer extends Serializer implements Serializable { @@ -243,7 +264,7 @@ static class TestClassSerializer extends Serializer implements Serial } ``` -相应的包需要添加到pom中: +相应的包需要添加到 pom 中: ``` @@ -253,7 +274,7 @@ static class TestClassSerializer extends Serializer implements Serial ``` -对于Apache Thrift和Protobuf的用户,已经有人将序列化器编写好,我们可以直接拿来使用: +对于 Apache Thrift 和 Protobuf 的用户,已经有人将序列化器编写好,我们可以直接拿来使用: ```java // Google Protobuf @@ -263,7 +284,7 @@ env.getConfig().registerTypeWithKryoSerializer(MyCustomType.class, ProtobufSeria env.getConfig().addDefaultKryoSerializer(MyCustomType.class, TBaseSerializer.class); ``` -Google Protobuf的pom: +Google Protobuf 的 pom: ``` @@ -284,7 +305,7 @@ Google Protobuf的pom: ``` -Apache Thrift的pom: +Apache Thrift 的 pom: ``` diff --git a/doc/ch-datastream-api/exercise-stock-basic.md b/doc/ch-datastream-api/exercise-stock-basic.md index 76f5a78..0bf072a 100644 --- a/doc/ch-datastream-api/exercise-stock-basic.md +++ b/doc/ch-datastream-api/exercise-stock-basic.md @@ -3,31 +3,31 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -经过本章的学习,读者应该对Flink的DataStream API有了初步的认识,本节以股票价格这个场景来实践所学内容。 +经过本章的学习,读者应该对 Flink 的 DataStream API 有了初步的认识,本节以股票价格这个场景来实践所学内容。 ## 实验目的 -针对具体的业务场景,学习如何定义相关数据结构,如何自定义Source,如何使用各类算子。 +针对具体的业务场景,学习如何定义相关数据结构,如何自定义 Source,如何使用各类算子。 ## 实验内容 -我们虚构了一个股票交易数据集,如下所示,该数据集中有每笔股票的交易时间、价格和交易量。数据集放置在了`src/main/resource/stock`文件夹里。 +我们虚构了一个股票交易数据集,如下所示,该数据集中有每笔股票的交易时间、价格和交易量。数据集放置在了 `src/main/resource/stock` 文件夹里。 ``` -股票代号,交易日期,交易时间(秒),价格,交易量 +股票代号, 交易日期, 交易时间(秒), 价格, 交易量 US2.AAPL,20200108,093003,297.260000000,100 US2.AAPL,20200108,093003,297.270000000,100 US2.AAPL,20200108,093003,297.310000000,100 ``` -在[数据类型和序列化](./data-types.md)章节,我们曾介绍Flink所支持的数据结构。对于这个股票价格的业务场景,首先要做的是对该业务进行建模,读者需要设计一个`StockPrice`类,能够表征一次交易数据。这个类至少包含以下字段: +在 [数据类型和序列化](./data-types.md) 章节,我们曾介绍 Flink 所支持的数据结构。对于这个股票价格的业务场景,首先要做的是对该业务进行建模,读者需要设计一个 `StockPrice` 类,能够表征一次交易数据。这个类至少包含以下字段: ```java /* @@ -38,12 +38,12 @@ US2.AAPL,20200108,093003,297.310000000,100 */ ``` -接下来,我们自定义Source,这个自定义的类继承`SourceFunction`,读取数据集中的元素,并将数据写入`DataStream`中。为了模拟不同交易之间的时间间隔,我们使用`Thread.sleep()`方法,等待一定的时间。下面的代码展示了如何自定义Source,读者可以直接拿来借鉴。 +接下来,我们自定义 Source,这个自定义的类继承 `SourceFunction`,读取数据集中的元素,并将数据写入 `DataStream` 中。为了模拟不同交易之间的时间间隔,我们使用 `Thread.sleep()` 方法,等待一定的时间。下面的代码展示了如何自定义 Source,读者可以直接拿来借鉴。 ```java public class StockSource implements SourceFunction { - // Source是否正在运行 + // Source 是否正在运行 private boolean isRunning = true; // 数据集文件名 private String path; @@ -54,11 +54,11 @@ public class StockSource implements SourceFunction { } // 读取数据集中的元素,每隔时间发送一次股票数据 - // 使用SourceContext.collect(T element)发送数据 + // 使用 SourceContext.collect(T element) 发送数据 @Override public void run(SourceContext sourceContext) throws Exception { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd HHmmss"); - // 从项目的resources目录获取输入 + // 从项目的 resources 目录获取输入 streamSource = this.getClass().getClassLoader().getResourceAsStream(path); BufferedReader br = new BufferedReader(new InputStreamReader(streamSource)); String line; @@ -98,27 +98,27 @@ public class StockSource implements SourceFunction { } ``` -对于我们自定义的这个Source,我们可以使用下面的时间语义: +对于我们自定义的这个 Source,我们可以使用下面的时间语义: ```java env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime) ``` -接下来,基于DataStream API,按照股票代号分组,对股票数据流进行分析和处理。 +接下来,基于 DataStream API,按照股票代号分组,对股票数据流进行分析和处理。 ## 实验要求 完成数据结构定义和数据流处理部分的代码编写。其中数据流分析处理部分需要完成: -* 程序1:价格最大值 +* 程序 1:价格最大值 实时计算某支股票的价格最大值。 -* 程序2:汇率转换 +* 程序 2:汇率转换 -数据中股票价格以美元结算,假设美元和人民币的汇率为7,使用`map`进行汇率转换,折算成人民币。 +数据中股票价格以美元结算,假设美元和人民币的汇率为 7,使用 `map` 进行汇率转换,折算成人民币。 -* 程序3:大额交易过滤 +* 程序 3:大额交易过滤 数据集中有交易量字段,给定某个阈值,过滤出交易量大于该阈值的,生成一个大额交易数据流。 diff --git a/doc/ch-datastream-api/img/batch-streaming-api-import.png b/doc/ch-datastream-api/img/batch-streaming-api-import.png index 104bfd5..b878a87 100644 Binary files a/doc/ch-datastream-api/img/batch-streaming-api-import.png and b/doc/ch-datastream-api/img/batch-streaming-api-import.png differ diff --git a/doc/ch-datastream-api/img/connect-control.png b/doc/ch-datastream-api/img/connect-control.png index 1696c62..b58b059 100644 Binary files a/doc/ch-datastream-api/img/connect-control.png and b/doc/ch-datastream-api/img/connect-control.png differ diff --git a/doc/ch-datastream-api/img/data-type.png b/doc/ch-datastream-api/img/data-type.png index 671a5d4..62c7480 100644 Binary files a/doc/ch-datastream-api/img/data-type.png and b/doc/ch-datastream-api/img/data-type.png differ diff --git a/doc/ch-datastream-api/img/filter.png b/doc/ch-datastream-api/img/filter.png deleted file mode 100644 index ea2c53a..0000000 Binary files a/doc/ch-datastream-api/img/filter.png and /dev/null differ diff --git a/doc/ch-datastream-api/img/filter.svg b/doc/ch-datastream-api/img/filter.svg new file mode 100644 index 0000000..6fae45f --- /dev/null +++ b/doc/ch-datastream-api/img/filter.svg @@ -0,0 +1,4 @@ + + + +算子算子ININOUTOUTText is not SVG - cannot display \ No newline at end of file diff --git a/doc/ch-datastream-api/img/flatmap.png b/doc/ch-datastream-api/img/flatmap.png deleted file mode 100644 index e350efa..0000000 Binary files a/doc/ch-datastream-api/img/flatmap.png and /dev/null differ diff --git a/doc/ch-datastream-api/img/flatmap.svg b/doc/ch-datastream-api/img/flatmap.svg new file mode 100644 index 0000000..99c30cc --- /dev/null +++ b/doc/ch-datastream-api/img/flatmap.svg @@ -0,0 +1,4 @@ + + + +算子算子ININOUTOUTText is not SVG - cannot display \ No newline at end of file diff --git a/doc/ch-datastream-api/img/keyBy.png b/doc/ch-datastream-api/img/keyBy.png index b9a0d55..1327a29 100644 Binary files a/doc/ch-datastream-api/img/keyBy.png and b/doc/ch-datastream-api/img/keyBy.png differ diff --git a/doc/ch-datastream-api/img/keyedstream-datastream.png b/doc/ch-datastream-api/img/keyedstream-datastream.png index 3c10cbb..2c3fa35 100644 Binary files a/doc/ch-datastream-api/img/keyedstream-datastream.png and b/doc/ch-datastream-api/img/keyedstream-datastream.png differ diff --git a/doc/ch-datastream-api/img/map.png b/doc/ch-datastream-api/img/map.png index 69b20e5..3dcfd66 100644 Binary files a/doc/ch-datastream-api/img/map.png and b/doc/ch-datastream-api/img/map.png differ diff --git a/doc/ch-datastream-api/img/rebalance.png b/doc/ch-datastream-api/img/rebalance.png index 33ddcdc..a704e3d 100644 Binary files a/doc/ch-datastream-api/img/rebalance.png and b/doc/ch-datastream-api/img/rebalance.png differ diff --git a/doc/ch-datastream-api/img/reduce.png b/doc/ch-datastream-api/img/reduce.png index 92a7951..7bed875 100644 Binary files a/doc/ch-datastream-api/img/reduce.png and b/doc/ch-datastream-api/img/reduce.png differ diff --git a/doc/ch-datastream-api/img/rescale1.png b/doc/ch-datastream-api/img/rescale1.png index 4d79684..1cfb886 100644 Binary files a/doc/ch-datastream-api/img/rescale1.png and b/doc/ch-datastream-api/img/rescale1.png differ diff --git a/doc/ch-datastream-api/img/rescale2.png b/doc/ch-datastream-api/img/rescale2.png index 9425a02..2e1a373 100644 Binary files a/doc/ch-datastream-api/img/rescale2.png and b/doc/ch-datastream-api/img/rescale2.png differ diff --git a/doc/ch-datastream-api/img/transformations.png b/doc/ch-datastream-api/img/transformations.png index c7dc05a..0e00d87 100644 Binary files a/doc/ch-datastream-api/img/transformations.png and b/doc/ch-datastream-api/img/transformations.png differ diff --git a/doc/ch-datastream-api/img/type-inference-process.png b/doc/ch-datastream-api/img/type-inference-process.png deleted file mode 100644 index 0cd6896..0000000 Binary files a/doc/ch-datastream-api/img/type-inference-process.png and /dev/null differ diff --git a/doc/ch-datastream-api/img/type-inference-process.svg b/doc/ch-datastream-api/img/type-inference-process.svg new file mode 100644 index 0000000..12c06ca --- /dev/null +++ b/doc/ch-datastream-api/img/type-inference-process.svg @@ -0,0 +1,4 @@ + + + +用户定义的数据类型 例如 String用户定义的数据类型...TypeInformation 例如 Types.STRINGTypeInformation...TypeSerializer 例如 StringSerializerTypeSerializer...内存块内存块推断推断定义定义序列化反序列化序列化反序列化...Text is not SVG - cannot display \ No newline at end of file diff --git a/doc/ch-datastream-api/img/typeinformation.png b/doc/ch-datastream-api/img/typeinformation.png index d67d77b..4cd8fd4 100644 Binary files a/doc/ch-datastream-api/img/typeinformation.png and b/doc/ch-datastream-api/img/typeinformation.png differ diff --git a/doc/ch-datastream-api/img/union.png b/doc/ch-datastream-api/img/union.png index b514941..44356ef 100644 Binary files a/doc/ch-datastream-api/img/union.png and b/doc/ch-datastream-api/img/union.png differ diff --git a/doc/ch-datastream-api/skeleton.md b/doc/ch-datastream-api/skeleton.md index 8cd025e..6d1b87c 100644 --- a/doc/ch-datastream-api/skeleton.md +++ b/doc/ch-datastream-api/skeleton.md @@ -1,26 +1,26 @@ (skeleton)= -# Flink程序的骨架结构 +# Flink 程序的骨架结构 :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -在进行详细的API介绍前,我们先回顾一下WordCount的案例,通过WordCount的代码结构,读者可以了解一个Flink程序的骨架结构。 +在进行详细的 API 介绍前,我们先回顾一下 WordCount 的案例,通过 WordCount 的代码结构,读者可以了解一个 Flink 程序的骨架结构。 -我们知道,一个Java或Scala的程序入口一般是一个静态(static)的main函数。在main函数中,还需要定义下面几个核心步骤: +我们知道,一个 Java 或 Scala 的程序入口一般是一个静态(static)的 main 函数。在 main 函数中,还需要定义下面几个核心步骤: 1. 初始化运行环境。 -2. 读取一到多个数据源Source。 +2. 读取一到多个数据源 Source。 -3. 根据业务逻辑对数据流进行Transformation转换。 +3. 根据业务逻辑对数据流进行 Transformation 转换。 -4. 将结果输出到Sink。 +4. 将结果输出到 Sink。 5. 调用作业执行函数。 @@ -28,67 +28,74 @@ ## 设置执行环境 -一个Flink作业必须依赖一个执行环境: +一个 Flink 作业必须依赖一个执行环境: ```java -// 创建Flink执行环境 +// 创建 Flink 执行环境 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); ``` -这行代码可以获取一个Flink流处理执行环境。Flink一般运行在一个集群上,执行环境是Flink程序运行的上下文,它提供了一系列作业与集群交互的方法,比如作业如何与外部世界交互。当调用`getExecutionEnvironment()`方法时,假如我们是在一个集群上提交作业,则返回集群的上下文,假如我们是在本地执行,则返回本地的上下文。本例中我们是进行流处理,在批处理场景则要获取DataSet API中批处理执行环境。 +这行代码可以获取一个 Flink 流处理执行环境。Flink 一般运行在一个集群上,执行环境是 Flink 程序运行的上下文,它提供了一系列作业与集群交互的方法,比如作业如何与外部世界交互。当调用 `getExecutionEnvironment()` 方法时,假如我们是在一个集群上提交作业,则返回集群的上下文,假如我们是在本地执行,则返回本地的上下文。本例中我们是进行流处理,在批处理场景则要获取 DataSet API 中批处理执行环境。 :::info -本例中我们是进行流处理,在批处理场景则要获取DataSet API中批处理执行环境。流处理和批处理的执行环境不同,流处理的执行环境名为`org.apache.flink.streaming.api.environment.StreamExecutionEnvironment`,批处理的执行环境名为`org.apache.flink.api.java.ExecutionEnvironment`。 +本例中我们是进行流处理,在批处理场景则要获取 DataSet API 中批处理执行环境。流处理和批处理的执行环境不同,流处理的执行环境名为 `org.apache.flink.streaming.api.environment.StreamExecutionEnvironment`,批处理的执行环境名为 `org.apache.flink.api.java.ExecutionEnvironment`。 ::: -Scala和Java所需要引用的包也不相同,Scala需要调用`org.apache.flink.streaming.api.scala.StreamExecutionEnvironment`和`org.apache.flink.api.scala.ExecutionEnvironment`。 +Scala 和 Java 所需要引用的包也不相同,Scala 需要调用 `org.apache.flink.streaming.api.scala.StreamExecutionEnvironment` 和 `org.apache.flink.api.scala.ExecutionEnvironment`。 -下图是批处理和流处理两种场景下,Java和Scala两种编程语言所需要引用的包。刚刚接触Flink的朋友很可能因为错误地引用导致出现莫名其妙的错误,一定要注意是否引用正确的包。 +{numref}`fig-batch-streaming-api-import` 是批处理和流处理两种场景下,Java 和 Scala 两种编程语言所需要引用的包。刚刚接触 Flink 的朋友很可能因为错误地引用导致出现莫名其妙的错误,一定要注意是否引用正确的包。 -![流处理和批处理不同场景下,相关API的引用](./img/batch-streaming-api-import.png) +```{figure} ./img/batch-streaming-api-import.png +--- +name: fig-batch-streaming-api-import +width: 80% +align: center +--- +流处理和批处理不同场景下,相关 API 的引用 +``` -另外,使用Scala API时,应该按照下面的方式引用,否则会出现一些问题。 +另外,使用 Scala API 时,应该按照下面的方式引用,否则会出现一些问题。 ```scala import org.apache.flink.streaming.api.scala._ ``` -Scala中的`_`就像Java中的`*`,是一种通配符。在这里使用`_`会引用`org.apache.flink.streaming.api.scala`下面的所有内容。 +Scala 中的 `_` 就像 Java 中的 `*`,是一种通配符。在这里使用 `_` 会引用 `org.apache.flink.streaming.api.scala` 下面的所有内容。 -回到执行环境上,我们可以通过执行环境做很多设置。比如,`env.setParallelism(2)`告知执行环境整个作业的并行度为2;`env.disableOperatorChaining()`关闭算子链功能。 +回到执行环境上,我们可以通过执行环境做很多设置。比如,`env.setParallelism(2)` 告知执行环境整个作业的并行度为 2;`env.disableOperatorChaining()` 关闭算子链功能。 -使用下面的设置可以创建一个基于本地的执行环境,这样我们使用IntelliJ Idea运行程序时,可以直接打开浏览器进入Flink Web UI查看运行的任务,本地的调试。 +使用下面的设置可以创建一个基于本地的执行环境,这样我们使用 IntelliJ Idea 运行程序时,可以直接打开浏览器进入 Flink Web UI 查看运行的任务,本地的调试。 ```java Configuration conf = new Configuration(); -// 访问http://localhost:8082 可以看到Flink Web UI +// 访问 http://localhost:8082 可以看到 Flink Web UI conf.setInteger(RestOptions.PORT, 8082); -// 创建本地执行环境,并行度为2 +// 创建本地执行环境,并行度为 2 StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironment(2, conf); ``` -此外,我们还可以在执行环境中设置一些时间属性等,配置Checkpoint等,我们将在后续章节中介绍这些功能。总之,执行环境是开发者和Flink交互的一个重要入口。 +此外,我们还可以在执行环境中设置一些时间属性等,配置 Checkpoint 等,我们将在后续章节中介绍这些功能。总之,执行环境是开发者和 Flink 交互的一个重要入口。 ## 读取数据源 -接着我们需要使用执行环境提供的方法读取数据源,读取数据源的部分统称为Source。数据源一般是消息队列或文件,我们也可以根据业务需求重写数据源,比如定时爬取网络中某处的数据。在本例中,我们使用`DataStream stream = env.addSource(consumer);`来读取数据源,其中`consumer`是一个Kafka消费者,我们消费Kafka中的数据作为Flink的输入数据。绝大多数流处理实战场景可能都是消费其他消息队列作为Source。 +接着我们需要使用执行环境提供的方法读取数据源,读取数据源的部分统称为 Source。数据源一般是消息队列或文件,我们也可以根据业务需求重写数据源,比如定时爬取网络中某处的数据。在本例中,我们使用 `DataStream stream = env.addSource(consumer);` 来读取数据源,其中 `consumer` 是一个 Kafka 消费者,我们消费 Kafka 中的数据作为 Flink 的输入数据。绝大多数流处理实战场景可能都是消费其他消息队列作为 Source。 -我们将在第七章介绍如何使用各类Source。 +我们将在第七章介绍如何使用各类 Source。 ## 进行转换操作 -此时,我们已经获取了一个文本数据流,接下来我们就可以在数据流上进行有状态的计算了。我们一般调用Flink提供的各类算子,使用链式调用的方式,对一个数据流进行操作。经过各算子的处理,`DataStream`可能被转换为`KeyedStream`、`WindowedStream`、`JoinedStream`等不同的数据流结构。相比Spark RDD的数据结构,Flink的数据流结构确实更加复杂。 +此时,我们已经获取了一个文本数据流,接下来我们就可以在数据流上进行有状态的计算了。我们一般调用 Flink 提供的各类算子,使用链式调用的方式,对一个数据流进行操作。经过各算子的处理,`DataStream` 可能被转换为 `KeyedStream`、`WindowedStream`、`JoinedStream` 等不同的数据流结构。相比 Spark RDD 的数据结构,Flink 的数据流结构确实更加复杂。 -本例中,我们先对一行文本进行分词,形成`(word, 1)`这样的二元组,然后以单词为Key进行分组,并开启一个时间窗口,统计该窗口中某个单词出现的次数。在这个过程中,涉及到对数据流的分组、窗口和聚合操作。其中,窗口相关操作涉及到如何将数据流中的元素划分到不同的窗口,聚合操作涉及到使用一个状态来记录单词出现的次数,不断维护更新状态来对数据进行实时处理。本章我们重点介绍一些DataStream API,第五章将介绍时间上的操作,第六章将介绍如何使用状态以及如何做失败恢复。 +本例中,我们先对一行文本进行分词,形成 `(word, 1)` 这样的二元组,然后以单词为 Key 进行分组,并开启一个时间窗口,统计该窗口中某个单词出现的次数。在这个过程中,涉及到对数据流的分组、窗口和聚合操作。其中,窗口相关操作涉及到如何将数据流中的元素划分到不同的窗口,聚合操作涉及到使用一个状态来记录单词出现的次数,不断维护更新状态来对数据进行实时处理。本章我们重点介绍一些 DataStream API,第五章将介绍时间上的操作,第六章将介绍如何使用状态以及如何做失败恢复。 ## 结果输出 -然后我们需要将前面的计算结果输出到外部系统。目的地可能是一个消息队列、文件系统或数据库,或其他自定义的输出方式。输出结果的部分统称为Sink。 +然后我们需要将前面的计算结果输出到外部系统。目的地可能是一个消息队列、文件系统或数据库,或其他自定义的输出方式。输出结果的部分统称为 Sink。 -本例中,我们的结果是窗口内的词频统计,它是一个`DataStream>`的数据结构。我们调用`print`函数将这个数据流打印到标准输出(Standard Output)上。`print`主要是为调试使用的,在实战场景中,计算结果会输出到一个外部的数据库或下一个流处理作业。 +本例中,我们的结果是窗口内的词频统计,它是一个 `DataStream>` 的数据结构。我们调用 `print` 函数将这个数据流打印到标准输出(Standard Output)上。`print` 主要是为调试使用的,在实战场景中,计算结果会输出到一个外部的数据库或下一个流处理作业。 ## 执行 -当定义好程序的Source、Transformation和Sink的业务逻辑后,程序并不会立即执行这些计算,我们还需要调用执行环境`execute()`方法来明确通知Flink去执行。Flink是延迟执行(Lazy Evaluation)的,即当程序明确调用`execute()`方法时,Flink才会将数据流图转化为一个`JobGraph`,提交给JobManager,JobManager根据当前的执行环境来执行这个作业。如果没有`execute()`方法,我们无法得到输出结果。 +当定义好程序的 Source、Transformation 和 Sink 的业务逻辑后,程序并不会立即执行这些计算,我们还需要调用执行环境 `execute()` 方法来明确通知 Flink 去执行。Flink 是延迟执行(Lazy Evaluation)的,即当程序明确调用 `execute()` 方法时,Flink 才会将数据流图转化为一个 `JobGraph`,提交给 JobManager,JobManager 根据当前的执行环境来执行这个作业。如果没有 `execute()` 方法,我们无法得到输出结果。 -综上,一个Flink程序的核心业务逻辑主要包括:初始化执行环境、进行Source、Transformation和Sink操作,最后要调用执行环境的`execute()`方法。 \ No newline at end of file +综上,一个 Flink 程序的核心业务逻辑主要包括:初始化执行环境、进行 Source、Transformation 和 Sink 操作,最后要调用执行环境的 `execute()` 方法。 \ No newline at end of file diff --git a/doc/ch-datastream-api/transformations.md b/doc/ch-datastream-api/transformations.md index 5db9e61..df1e519 100644 --- a/doc/ch-datastream-api/transformations.md +++ b/doc/ch-datastream-api/transformations.md @@ -3,20 +3,27 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -Flink的Transformation转换主要包括四种:单数据流基本转换、基于Key的分组转换、多数据流转换和数据重分布转换。Transformation各算子可以对Flink数据流进行处理和转化,多个Transformation算子共同组成一个数据流图,DataStream Transformation是Flink流处理非常核心的API。下图展示了数据流上的几类操作,本章主要介绍四种Transformation:单数据流转换、基于Key的分组转换、多数据流转换和数据重分布转换,时间窗口部分将在第五章介绍。 +Flink 的 Transformation 转换主要包括四种:单数据流基本转换、基于 Key 的分组转换、多数据流转换和数据重分布转换。Transformation 各算子可以对 Flink 数据流进行处理和转化,多个 Transformation 算子共同组成一个数据流图,DataStream Transformation 是 Flink 流处理非常核心的 API。下图展示了数据流上的几类操作,本章主要介绍四种 Transformation:单数据流转换、基于 Key 的分组转换、多数据流转换和数据重分布转换,时间窗口部分将在第五章介绍。 -![DataStream上的Transformaton操作分类](./img/transformations.png) +```{figure} ./img/transformations.png +--- +name: fig-transformations +width: 80% +align: center +--- +DataStream 上的 Transformation 操作分类 +``` -Flink的Transformation是对数据流进行操作,其中数据流涉及到的最常用数据结构是`DataStream`,`DataStream`由多个相同的元素组成,每个元素是一个单独的事件。在Java中,我们使用泛型`DataStream`来定义这种组成关系,在Scala中,这种泛型对应的数据结构为`DataStream[T]`,`T`是数据流中每个元素的数据类型。在WordCount的例子中,数据流中每个元素的类型是字符串`String`,整个数据流的数据类型为`DataStream`。 +Flink 的 Transformation 是对数据流进行操作,其中数据流涉及到的最常用数据结构是 `DataStream`,`DataStream` 由多个相同的元素组成,每个元素是一个单独的事件。在 Java 中,我们使用泛型 `DataStream` 来定义这种组成关系,在 Scala 中,这种泛型对应的数据结构为 `DataStream[T]`,`T` 是数据流中每个元素的数据类型。在 WordCount 的例子中,数据流中每个元素的类型是字符串 `String`,整个数据流的数据类型为 `DataStream`。 -在使用这些算子时,需要在算子上进行用户自定义操作,一般使用Lambda表达式或者继承类并重写函数两种方式完成这个用户自定义的过程。接下来,我们将对Flink Transformation中各算子进行详细介绍,并使用大量例子展示具体使用方法。 +在使用这些算子时,需要在算子上进行用户自定义操作,一般使用 Lambda 表达式或者继承类并重写函数两种方式完成这个用户自定义的过程。接下来,我们将对 Flink Transformation 中各算子进行详细介绍,并使用大量例子展示具体使用方法。 ## 单数据流转换 @@ -24,18 +31,25 @@ Flink的Transformation是对数据流进行操作,其中数据流涉及到的 ### map -`map`算子对一个`DataStream`中的每个元素使用用户自定义的Mapper函数进行处理,每个输入元素对应一个输出元素,最终整个数据流被转换成一个新的`DataStream`。输出的数据流`DataStream`类型可能和输入的数据流`DataStream`不同。 +`map` 算子对一个 `DataStream` 中的每个元素使用用户自定义的 Mapper 函数进行处理,每个输入元素对应一个输出元素,最终整个数据流被转换成一个新的 `DataStream`。输出的数据流 `DataStream` 类型可能和输入的数据流 `DataStream` 不同。 -![map](./img/map.png) +```{figure} ./img/map.png +--- +name: fig-map-transformation +width: 80% +align: center +--- +Map Transformation 操作示例 +``` -我们可以重写`MapFunction`或`RichMapFunction`来自定义map函数,`MapFunction`在源码的定义为:`MapFunction`,其内部有一个`map`虚函数,我们需要对这个虚函数重写。下面的代码重写了`MapFunction`中的`map`函数,将输入结果乘以2,转化为字符串后输出。 +我们可以重写 `MapFunction` 或 `RichMapFunction` 来自定义 map 函数,`MapFunction` 在源码的定义为:`MapFunction`,其内部有一个 `map` 虚函数,我们需要对这个虚函数重写。下面的代码重写了 `MapFunction` 中的 `map` 函数,将输入结果乘以 2,转化为字符串后输出。 ```java // 函数式接口类 -// T为输入类型,O为输出类型 +// T 为输入类型,O 为输出类型 @FunctionalInterface public interface MapFunction extends Function, Serializable { - // 调用这个API就是继承并实现这个虚函数 + // 调用这个 API 就是继承并实现这个虚函数 O map(T value) throws Exception; } ``` @@ -43,7 +57,7 @@ public interface MapFunction extends Function, Serializable { 第二章中我们曾介绍过,对于这样一个虚函数,可以继承接口类并实现虚函数: ```java -// 继承并实现MapFunction +// 继承并实现 MapFunction // 第一个泛型是输入类型,第二个泛型是输出类型 public static class DoubleMapFunction implements MapFunction { @Override @@ -59,7 +73,7 @@ public static class DoubleMapFunction implements MapFunction { DataStream functionDataStream = dataStream.map(new DoubleMapFunction()); ``` -这段的代码清单重写了`MapFunction`中的`map`函数,将输入结果乘以2,转化为字符串后输出。我们也可以不用显式定义`DoubleMapFunction`这个类,而是像下面的代码一样使用匿名类: +这段的代码清单重写了 `MapFunction` 中的 `map` 函数,将输入结果乘以 2,转化为字符串后输出。我们也可以不用显式定义 `DoubleMapFunction` 这个类,而是像下面的代码一样使用匿名类: ```java // 匿名类 @@ -71,50 +85,57 @@ DataStream anonymousDataStream = dataStream.map(new MapFunction lambdaStream = dataStream .map(input -> "lambda input : " + input + ", output : " + (input * 2)); ``` -Scala的API相对更加灵活,可以使用下划线来构造Lambda表达式: +Scala 的 API 相对更加灵活,可以使用下划线来构造 Lambda 表达式: ```scala -// 使用 _ 构造Lambda表达式 -val lambda2 = dataStream.map { _.toDouble * 2 } +// 使用 _ 构造 Lambda 表达式 +val lambda2 = dataStream.map {_.toDouble * 2} ``` :::info -使用Scala时,Lambda表达式可以可以放在圆括号()中,也可以使用花括号{}中。使用Java时,只能使用圆括号。 +使用 Scala 时,Lambda 表达式可以可以放在圆括号 ()中,也可以使用花括号 {} 中。使用 Java 时,只能使用圆括号。 ::: -对上面的几种方式比较可见,Lambda表达式更为简洁。重写函数的方式代码更为臃肿,但定义更清晰。 +对上面的几种方式比较可见,Lambda 表达式更为简洁。重写函数的方式代码更为臃肿,但定义更清晰。 -此外,`RichMapFunction`是一种RichFunction,它除了`MapFunction`的基础功能外,还提供了一系列其他方法,包括`open`、`close`、`getRuntimeContext`和`setRuntimeContext`等虚函数方法,重写这些方法可以创建状态数据、对数据进行广播,获取累加器和计数器等,这部分内容将在后面介绍。 +此外,`RichMapFunction` 是一种 RichFunction,它除了 `MapFunction` 的基础功能外,还提供了一系列其他方法,包括 `open`、`close`、`getRuntimeContext` 和 `setRuntimeContext` 等虚函数方法,重写这些方法可以创建状态数据、对数据进行广播,获取累加器和计数器等,这部分内容将在后面介绍。 ### filter -`filter`算子对每个元素进行过滤,过滤的过程使用一个Filter函数进行逻辑判断。对于输入的每个元素,如果filter函数返回True,则保留,如果返回False,则丢弃,如下图所示。 +`filter` 算子对每个元素进行过滤,过滤的过程使用一个 Filter 函数进行逻辑判断。对于输入的每个元素,如果 filter 函数返回 True,则保留,如果返回 False,则丢弃,如 {numref}`fig-filter-transformation` 所示。 -![filter](./img/filter.png) +```{figure} ./img/filter.svg +--- +name: fig-filter-transformation +width: 80% +align: center +--- +Filter Transformation 操作示例 +``` -我们可以使用Lambda表达式过滤掉小于等于0的元素: +我们可以使用 Lambda 表达式过滤掉小于等于 0 的元素: ```java DataStream dataStream = senv.fromElements(1, 2, -3, 0, 5, -9, 8); -// 使用 -> 构造Lambda表达式 -DataStream lambda = dataStream.filter ( input -> input > 0 ); +// 使用 -> 构造 Lambda 表达式 +DataStream lambda = dataStream.filter (input -> input > 0); ``` -也可以继承`FilterFunction`或`RichFilterFunction`,然后重写`filter`方法,我们还可以将参数传递给继承后的类。如下面的代码所示,`MyFilterFunction`增加一个构造函数参数`limit`,并在`filter`方法中使用这个参数。 +也可以继承 `FilterFunction` 或 `RichFilterFunction`,然后重写 `filter` 方法,我们还可以将参数传递给继承后的类。如下面的代码所示,`MyFilterFunction` 增加一个构造函数参数 `limit`,并在 `filter` 方法中使用这个参数。 ```java public static class MyFilterFunction extends RichFilterFunction { - // limit参数可以从外部传入 + // limit 参数可以从外部传入 private Integer limit; public MyFilterFunction(Integer limit) { @@ -127,30 +148,37 @@ public static class MyFilterFunction extends RichFilterFunction { } } -// 继承RichFilterFunction +// 继承 RichFilterFunction DataStream richFunctionDataStream = dataStream.filter(new MyFilterFunction(2)); ``` ### flatMap -`flatMap`算子和`map`有些相似,输入都是数据流中的每个元素,与之不同的是,`flatMap`的输出可以是零个、一个或多个元素,当输出元素是一个列表时,`flatMap`会将列表展平。如下图所示,输入是包含圆形或正方形的列表,`flatMap`过滤掉圆形,正方形列表被展平,以单个元素的形式输出。 +`flatMap` 算子和 `map` 有些相似,输入都是数据流中的每个元素,与之不同的是,`flatMap` 的输出可以是零个、一个或多个元素,当输出元素是一个列表时,`flatMap` 会将列表展平。如下图所示,输入是包含圆形或正方形的列表,`flatMap` 过滤掉圆形,正方形列表被展平,以单个元素的形式输出。 -![flatMap](./img/flatmap.png) +```{figure} ./img/flatmap.svg +--- +name: fig-flatmap-transformation +width: 80% +align: center +--- +FlatMap Transformation 操作示例 +``` -我们可以用切水果的例子来理解map和flatMap的区别。map会对每个输入元素生成一个对应的输出元素: +我们可以用切水果的例子来理解 map 和 flatMap 的区别。map 会对每个输入元素生成一个对应的输出元素: ``` {苹果,梨,香蕉}.map(去皮) => {去皮苹果, 去皮梨,去皮香蕉} ``` -`flatMap`先对每个元素进行相应的操作,生成一个相应的集合,再将集合展平: +`flatMap` 先对每个元素进行相应的操作,生成一个相应的集合,再将集合展平: ``` {苹果,梨,香蕉}.flMap(切碎) => -{[苹果碎片1, 苹果碎片2], [梨碎片1,梨碎片2, 梨碎片3],[香蕉碎片1]} +{[苹果碎片 1, 苹果碎片 2], [梨碎片 1,梨碎片 2, 梨碎片 3],[香蕉碎片 1]} => -{苹果碎片1, 苹果碎片2, 梨碎片1,梨碎片2, 梨碎片3,香蕉碎片1} +{苹果碎片 1, 苹果碎片 2, 梨碎片 1,梨碎片 2, 梨碎片 3,香蕉碎片 1} ``` 下面的代码对字符串进行切词处理: @@ -159,8 +187,8 @@ DataStream richFunctionDataStream = dataStream.filter(new MyFilterFunct DataStream dataStream = senv.fromElements("Hello World", "Hello this is Flink"); -// split函数的输入为 "Hello World" 输出为 "Hello" 和 "World" 组成的列表 ["Hello", "World"] -// flatMap将列表中每个元素提取出来 +// split 函数的输入为 "Hello World" 输出为 "Hello" 和 "World" 组成的列表 ["Hello", "World"] +// flatMap 将列表中每个元素提取出来 // 最后输出为 ["Hello", "World", "Hello", "this", "is", "Flink"] DataStream words = dataStream.flatMap ( (String input, Collector collector) -> { @@ -170,10 +198,10 @@ DataStream words = dataStream.flatMap ( }).returns(Types.STRING); ``` -因为`flatMap`可以输出零到多个元素,我们可以将其看做是`map`和`filter`更一般的形式。如果我们只想对长度大于15的句子进行处理,可以先在程序判断处理,再输出,如下所示。 +因为 `flatMap` 可以输出零到多个元素,我们可以将其看做是 `map` 和 `filter` 更一般的形式。如果我们只想对长度大于 15 的句子进行处理,可以先在程序判断处理,再输出,如下所示。 ```java -// 只对字符串数量大于15的句子进行处理 +// 只对字符串数量大于 15 的句子进行处理 // 使用匿名函数 DataStream longSentenceWords = dataStream.flatMap(new FlatMapFunction() { @Override @@ -187,10 +215,10 @@ DataStream longSentenceWords = dataStream.flatMap(new FlatMapFunction input.split(" ") ) val words2 = dataStream.map { _.split(" ") } ``` -## 基于Key的分组转换 +## 基于 Key 的分组转换 -对数据分组主要是为了进行后续的聚合操作,即对同组数据进行聚合分析。如下图所示,`keyBy`会将一个`DataStream`转化为一个`KeyedStream`,聚合操作会将`KeyedStream`转化为`DataStream`。如果聚合前每个元素数据类型是T,聚合后的数据类型仍为T。 +对数据分组主要是为了进行后续的聚合操作,即对同组数据进行聚合分析。如下图所示,`keyBy` 会将一个 `DataStream` 转化为一个 `KeyedStream`,聚合操作会将 `KeyedStream` 转化为 `DataStream`。如果聚合前每个元素数据类型是 T,聚合后的数据类型仍为 T。 -![DataStream和KeyedStream的转换关系](./img/keyedstream-datastream.png) +```{figure} ./img/keyedstream-datastream.png +--- +name: fig-keyedstream-datastream +width: 80% +align: center +--- +DataStream 和 KeyedStream 的转换关系 +``` ### keyBy -绝大多数情况,我们要根据事件的某种属性或数据的某个字段进行分组,然后对一个分组内的数据进行处理。如下图所示,`keyBy`算子根据元素的形状对数据进行分组,相同形状的元素被分到了一起,可被后续算子统一处理。比如,对股票数据流处理时,可以根据股票代号进行分组,然后对同一支股票统计其价格变动。又如,电商用户行为日志把所有用户的行为都记录了下来,如果要分析某一个用户行为,需要先按用户ID进行分组。 +绝大多数情况,我们要根据事件的某种属性或数据的某个字段进行分组,然后对一个分组内的数据进行处理。如下图所示,`keyBy` 算子根据元素的形状对数据进行分组,相同形状的元素被分到了一起,可被后续算子统一处理。比如,对股票数据流处理时,可以根据股票代号进行分组,然后对同一支股票统计其价格变动。又如,电商用户行为日志把所有用户的行为都记录了下来,如果要分析某一个用户行为,需要先按用户 ID 进行分组。 -`keyBy`算子将`DataStream`转换成一个`KeyedStream`。`KeyedStream`是一种特殊的`DataStream`,事实上,`KeyedStream`继承了`DataStream`,`DataStream`的各元素随机分布在各算子实例中,`KeyedStream`的各元素按照Key分组,相同Key的数据会被分配到同一算子实例中。我们需要向`keyBy`算子传递一个参数,以告知Flink以什么作为Key进行分组。 +`keyBy` 算子将 `DataStream` 转换成一个 `KeyedStream`。`KeyedStream` 是一种特殊的 `DataStream`,事实上,`KeyedStream` 继承了 `DataStream`,`DataStream` 的各元素随机分布在各算子实例中,`KeyedStream` 的各元素按照 Key 分组,相同 Key 的数据会被分配到同一算子实例中。我们需要向 `keyBy` 算子传递一个参数,以告知 Flink 以什么作为 Key 进行分组。 -![keyBy](./img/keyBy.png) +```{figure} ./img/keyBy.png +--- +name: fig-keyBy-transformation +width: 80% +align: center +--- +KeyBy Transformation 操作示例 +``` -我们可以使用数字位置来指定Key: +我们可以使用数字位置来指定 Key: ```java DataStream> dataStream = senv.fromElements( Tuple2.of(1, 1.0), Tuple2.of(2, 3.2), Tuple2.of(1, 5.5), Tuple2.of(3, 10.0), Tuple2.of(3, 12.5)); -// 使用数字位置定义Key 按照第一个字段进行分组 +// 使用数字位置定义 Key 按照第一个字段进行分组 DataStream> keyedStream = dataStream.keyBy(0).sum(1); ``` -也可以使用字段名来指定Key。比如,我们有一个`Word`类: +也可以使用字段名来指定 Key。比如,我们有一个 `Word` 类: ```java public class Word { @@ -234,7 +276,7 @@ public class Word { public String word; public int count; - public Word() {} + public Word(){} public Word(String word, int count) { this.word = word; @@ -251,27 +293,27 @@ public class Word { } } ``` -我们可以直接用`Word`中的字段名`word`来选择Key。 +我们可以直接用 `Word` 中的字段名 `word` 来选择 Key。 ```java DataStream fieldNameStream = wordStream.keyBy("word").sum("count"); ``` :::info -这种方法只适用于[数据类型和序列化](data-types.md#composite-types)章节中提到的Scala case class或Java POJO类型的数据。 +这种方法只适用于 [数据类型和序列化](data-types.md#composite-types) 章节中提到的 Scala case class 或 Java POJO 类型的数据。 ::: -指定Key本质上是实现一个`KeySelector`,在Flink源码中,它是这么定义的: +指定 Key 本质上是实现一个 `KeySelector`,在 Flink 源码中,它是这么定义的: ```java -// IN为数据流元素,KEY为所选择的Key +// IN 为数据流元素,KEY 为所选择的 Key @FunctionalInterface public interface KeySelector extends Function, Serializable { - // 选择一个字段作为Key + // 选择一个字段作为 Key KEY getKey(IN value) throws Exception; } ``` -我们可以重写`getKey()`方法,如下所示: +我们可以重写 `getKey()` 方法,如下所示: ```java DataStream wordStream = senv.fromElements( @@ -279,7 +321,7 @@ DataStream wordStream = senv.fromElements( Word.of("Hello", 2), Word.of("Flink", 2) ); -// 使用KeySelector +// 使用 KeySelector DataStream keySelectorStream = wordStream.keyBy(new KeySelector () { @Override public String getKey(Word in) { @@ -288,15 +330,15 @@ DataStream keySelectorStream = wordStream.keyBy(new KeySelector> tupleStream = @@ -314,10 +356,10 @@ DataStream> tupleStream = DataStream> sumStream = tupleStream.keyBy(0).sum(1); ``` -`max`算子对该字段求最大值,并将结果保存在该字段上。对于其他字段,该操作并不能保证其数值的计算结果。下面的例子对第三个字段求最大值,第二个字段是不确定的。 +`max` 算子对该字段求最大值,并将结果保存在该字段上。对于其他字段,该操作并不能保证其数值的计算结果。下面的例子对第三个字段求最大值,第二个字段是不确定的。 ```java -// 按第一个字段分组,对第三个字段求最大值max,打印出来的结果如下: +// 按第一个字段分组,对第三个字段求最大值 max,打印出来的结果如下: // (0,0,0) // (0,0,1) // (0,0,2) @@ -327,10 +369,10 @@ DataStream> sumStream = tupleStream.keyBy(0).s DataStream> maxStream = tupleStream.keyBy(0).max(2); ``` -`maxBy`算子对该字段求最大值,`maxBy`与`max`的区别在于,`maxBy`同时保留其他字段的数值,即`maxBy`返回数据流中最大的整个元素,包括其他字段。以下面的输入中Key为1的数据为例,我们要求第三个字段的最大值,Flink首先接收到`(1,0,6)`,当接收到`(1,1,7)`时,最大值发生变化,Flink将`(1,1,7)`这整个元组返回,当`(1,0,8)`到达时,最大值再次发生变化,Flink将`(1,0,8)`这整个元组返回。反观`max`,它只负责所求的字段,其他字段概不负责,无法保证其他字段的结果。因此,`maxBy`保证的是最大值的整个元素,`max`只保证最大值的字段。 +`maxBy` 算子对该字段求最大值,`maxBy` 与 `max` 的区别在于,`maxBy` 同时保留其他字段的数值,即 `maxBy` 返回数据流中最大的整个元素,包括其他字段。以下面的输入中 Key 为 1 的数据为例,我们要求第三个字段的最大值,Flink 首先接收到 `(1,0,6)`,当接收到 `(1,1,7)` 时,最大值发生变化,Flink 将 `(1,1,7)` 这整个元组返回,当 `(1,0,8)` 到达时,最大值再次发生变化,Flink 将 `(1,0,8)` 这整个元组返回。反观 `max`,它只负责所求的字段,其他字段概不负责,无法保证其他字段的结果。因此,`maxBy` 保证的是最大值的整个元素,`max` 只保证最大值的字段。 ```java -// 按第一个字段分组,对第三个字段求最大值maxBy,打印出来的结果如下: +// 按第一个字段分组,对第三个字段求最大值 maxBy,打印出来的结果如下: // (0,0,0) // (0,1,1) // (0,2,2) @@ -340,21 +382,28 @@ DataStream> maxStream = tupleStream.keyBy(0).m DataStream> maxByStream = tupleStream.keyBy(0).maxBy(2); ``` -同样,`min`和`minBy`的区别在于,`min`算子对某字段求最小值,`minBy`返回具有最小值的整个元素。 +同样,`min` 和 `minBy` 的区别在于,`min` 算子对某字段求最小值,`minBy` 返回具有最小值的整个元素。 -其实,这些聚合操作里已经使用了状态数据,比如,`sum`算子内部记录了当前的和,`max`算子内部记录了当前的最大值。算子的计算过程其实就是不断更新状态数据的过程。由于内部使用了状态数据,而且状态数据并不会被清理,因此一定要慎重地在一个无限数据流上使用这些聚合操作。 +其实,这些聚合操作里已经使用了状态数据,比如,`sum` 算子内部记录了当前的和,`max` 算子内部记录了当前的最大值。算子的计算过程其实就是不断更新状态数据的过程。由于内部使用了状态数据,而且状态数据并不会被清理,因此一定要慎重地在一个无限数据流上使用这些聚合操作。 :::info -对于一个`KeyedStream`,一次只能使用一个Aggregation聚合操作,无法链式使用多个。 +对于一个 `KeyedStream`, 一次只能使用一个 Aggregation 聚合操作,无法链式使用多个。 ::: ### reduce -前面几个Aggregation是几个较为常用的操作,对分组数据进行处理更为通用的方法是使用`reduce`算子。 +前面几个 Aggregation 是几个较为常用的操作,对分组数据进行处理更为通用的方法是使用 `reduce` 算子。 -![reduce](./img/reduce.png) +```{figure} ./img/reduce.png +--- +name: fig-reduce-transformation +width: 80% +align: center +--- +Reduce Transformation 操作示例 +``` -上图展示了`reduce`算子的原理:`reduce`在分组的数据流上生效,它接受两个输入,生成一个输出,即两两合一地进行汇总操作,生成一个同类型的新元素。 +{numref}`fig-reduce-transformation` 展示了 `reduce` 算子的原理:`reduce` 在分组的数据流上生效,它接受两个输入,生成一个输出,即两两合一地进行汇总操作,生成一个同类型的新元素。 例如,我们定义一个学生分数类: @@ -383,7 +432,7 @@ public static class Score { } ``` -在这个类上进行`reduce`: +在这个类上进行 `reduce`: ```java DataStream dataStream = senv.fromElements( @@ -391,13 +440,13 @@ DataStream dataStream = senv.fromElements( Score.of("Li", "Math", 85), Score.of("Wang", "Math", 92), Score.of("Liu", "Math", 91), Score.of("Liu", "English", 87)); -// 实现ReduceFunction +// 实现 ReduceFunction DataStream sumReduceFunctionStream = dataStream .keyBy("name") .reduce(new MyReduceFunction()); ``` -其中`MyReduceFunction`继承并实现了`ReduceFunction`: +其中 `MyReduceFunction` 继承并实现了 `ReduceFunction`: ```java public static class MyReduceFunction implements ReduceFunction { @@ -408,7 +457,7 @@ public static class MyReduceFunction implements ReduceFunction { } ``` -使用Lambda表达式更简洁一些: +使用 Lambda 表达式更简洁一些: ```java // 使用 Lambda 表达式 @@ -423,9 +472,16 @@ DataStream sumLambdaStream = dataStream ### union -在`DataStream`上使用`union`算子可以合并多个同类型的数据流,或者说,可以将多个`DataStream`合并为一个新的`DataStream`。数据将按照先进先出(First In First Out)的模式合并,且不去重。下图中,`union`对白色和深色两个数据流进行合并,生成一个数据流。 +在 `DataStream` 上使用 `union` 算子可以合并多个同类型的数据流,或者说,可以将多个 `DataStream` 合并为一个新的 `DataStream`。数据将按照先进先出(First In First Out)的模式合并,且不去重。下图中,`union` 对白色和深色两个数据流进行合并,生成一个数据流。 -![union示意图](./img/union.png) +```{figure} ./img/union.png +--- +name: fig-union-transformation +width: 80% +align: center +--- +Union Transformation 操作示例 +``` 假设股票价格数据流来自不同的交易所,我们将其合并成一个数据流: @@ -438,22 +494,29 @@ DataStream unionStockStream = shenzhenStockStream.union(hongkongStoc ### connect -`union`虽然可以合并多个数据流,但有一个限制:多个数据流的数据类型必须相同。`connect`提供了和`union`类似的功能,用来连接两个数据流,它与`union`的区别在于: +`union` 虽然可以合并多个数据流,但有一个限制:多个数据流的数据类型必须相同。`connect` 提供了和 `union` 类似的功能,用来连接两个数据流,它与 `union` 的区别在于: -1. `connect`只能连接两个数据流,`union`可以连接多个数据流。 -2. `connect`所连接的两个数据流的数据类型可以不一致,`union`所连接的两个数据流的数据类型必须一致。 -3. 两个`DataStream`经过`connect`之后被转化为`ConnectedStreams`,`ConnectedStreams`会对两个流的数据应用不同的处理方法,且双流之间可以共享状态。 +1. `connect` 只能连接两个数据流,`union` 可以连接多个数据流。 +2. `connect` 所连接的两个数据流的数据类型可以不一致,`union` 所连接的两个数据流的数据类型必须一致。 +3. 两个 `DataStream` 经过 `connect` 之后被转化为 `ConnectedStreams`,`ConnectedStreams` 会对两个流的数据应用不同的处理方法,且双流之间可以共享状态。 -如下图所示,`connect`经常被应用在使用控制流对数据流进行控制处理的场景上。控制流可以是阈值、规则、机器学习模型或其他参数。 +如 {numref}`fig-connect-control` 所示,`connect` 经常被应用在使用控制流对数据流进行控制处理的场景上。控制流可以是阈值、规则、机器学习模型或其他参数。 -![对一个数据流进行控制处理](./img/connect-control.png) +```{figure} ./img/connect-control.png +--- +name: fig-connect-control +width: 80% +align: center +--- +对一个数据流进行控制处理 +``` -两个`DataStream`经过`connect`之后被转化为`ConnectedStreams`。对于`ConnectedStreams`,我们需要重写`CoMapFunction`或`CoFlatMapFunction`。这两个接口都提供了三个泛型,这三个泛型分别对应第一个输入流的数据类型、第二个输入流的数据类型和输出流的数据类型。在重写函数时,对于`CoMapFunction`,`map1`处理第一个流的数据,`map2`处理第二个流的数据;对于`CoFlatMapFunction`,`flatMap1`处理第一个流的数据,`flatMap2`处理第二个流的数据。下面是`CoFlatMapFunction`在源码中的签名。 +两个 `DataStream` 经过 `connect` 之后被转化为 `ConnectedStreams`。对于 `ConnectedStreams`,我们需要重写 `CoMapFunction` 或 `CoFlatMapFunction`。这两个接口都提供了三个泛型,这三个泛型分别对应第一个输入流的数据类型、第二个输入流的数据类型和输出流的数据类型。在重写函数时,对于 `CoMapFunction`,`map1` 处理第一个流的数据,`map2` 处理第二个流的数据;对于 `CoFlatMapFunction`,`flatMap1` 处理第一个流的数据,`flatMap2` 处理第二个流的数据。下面是 `CoFlatMapFunction` 在源码中的签名。 ```java -// IN1为第一个输入流的数据类型 -// IN2为第二个输入流的数据类型 -// OUT为输出类型 +// IN1 为第一个输入流的数据类型 +// IN2 为第二个输入流的数据类型 +// OUT 为输出类型 public interface CoFlatMapFunction extends Function, Serializable { // 处理第一个流的数据 @@ -464,7 +527,7 @@ public interface CoFlatMapFunction extends Function, Serializable } ``` -Flink并不能保证两个函数调用顺序,两个函数的调用依赖于两个数据流中数据的流入先后顺序,即第一个数据流有数据到达时,`map1`或`flatMap1`会被调用,第二个数据流有数据到达时,`map2`或`flatMap2`会被调用。下面的代码对一个整数流和一个字符串流进行了`connect`操作。 +Flink 并不能保证两个函数调用顺序,两个函数的调用依赖于两个数据流中数据的流入先后顺序,即第一个数据流有数据到达时,`map1` 或 `flatMap1` 会被调用,第二个数据流有数据到达时,`map2` 或 `flatMap2` 会被调用。下面的代码对一个整数流和一个字符串流进行了 `connect` 操作。 ```java DataStream intStream = senv.fromElements(1, 0, 9, 2, 3, 6); @@ -473,7 +536,7 @@ DataStream stringStream = senv.fromElements("LOW", "HIGH", "LOW", "LOW" ConnectedStreams connectedStream = intStream.connect(stringStream); DataStream mapResult = connectedStream.map(new MyCoMapFunction()); -// CoMapFunction三个泛型分别对应第一个流的输入、第二个流的输入,map之后的输出 +// CoMapFunction 三个泛型分别对应第一个流的输入、第二个流的输入,map 之后的输出 public static class MyCoMapFunction implements CoMapFunction { @Override public String map1(Integer input1) { @@ -487,15 +550,15 @@ public static class MyCoMapFunction implements CoMapFunction { @@ -588,18 +672,18 @@ public class DataStream { } ``` -下面为`Partitioner`的源码,`partition`函数的返回一个整数,表示该元素将被路由到下游第几个实例。 +下面为 `Partitioner` 的源码,`partition` 函数的返回一个整数,表示该元素将被路由到下游第几个实例。 ```java @FunctionalInterface public interface Partitioner extends java.io.Serializable, Function { - // 根据key决定该数据分配到下游第几个分区(实例) + // 根据 key 决定该数据分配到下游第几个分区(实例) int partition(K key, int numPartitions); } ``` -`Partitioner[K]`中泛型K为根据哪个字段进行分区,比如我们要对一个`Score`数据流重分布,希望按照id均匀分配到下游各实例,那么泛型K就为id的数据类型`Long`。同时,泛型K也是`int partition(K key, int numPartitions)`函数的第一个参数的数据类型。 +`Partitioner[K]` 中泛型 K 为根据哪个字段进行分区,比如我们要对一个 `Score` 数据流重分布,希望按照 id 均匀分配到下游各实例,那么泛型 K 就为 id 的数据类型 `Long`。同时,泛型 K 也是 `int partition(K key, int numPartitions)` 函数的第一个参数的数据类型。 ```java public class Score { @@ -609,9 +693,9 @@ public class Score { } ``` -在调用`partitionCustom(partitioner, field)`时,第一个参数是我们重写的`Partitioner`,第二个参数表示按照id字段进行处理。 +在调用 `partitionCustom(partitioner, field)` 时,第一个参数是我们重写的 `Partitioner`,第二个参数表示按照 id 字段进行处理。 -`partitionCustom`涉及的类型和函数有点多,使用例子解释更为直观。下面的代码按照数据流中的第二个字段进行数据重分布,当该字段中包含数字时,将被路由到下游算子的前半部分,否则被路由到后半部分。如果设置并行度为4,表示所有算子的实例总数为4,或者说共有4个分区,那么如果字符串包含数字时,该元素将被分配到第0个和第1个实例上,否则被分配到第2个和第3个实例上。 +`partitionCustom` 涉及的类型和函数有点多,使用例子解释更为直观。下面的代码按照数据流中的第二个字段进行数据重分布,当该字段中包含数字时,将被路由到下游算子的前半部分,否则被路由到后半部分。如果设置并行度为 4,表示所有算子的实例总数为 4,或者说共有 4 个分区,那么如果字符串包含数字时,该元素将被分配到第 0 个和第 1 个实例上,否则被分配到第 2 个和第 3 个实例上。 ```java public class PartitionCustomExample { @@ -623,7 +707,7 @@ public class PartitionCustomExample { // 获取当前执行环境的默认并行度 int defaultParalleism = senv.getParallelism(); - // 设置所有算子的并行度为4,表示所有算子的并行执行的实例数为4 + // 设置所有算子的并行度为 4,表示所有算子的并行执行的实例数为 4 senv.setParallelism(4); DataStream> dataStream = senv.fromElements( @@ -631,7 +715,7 @@ public class PartitionCustomExample { Tuple2.of(3, "256"), Tuple2.of(4, "zyx"), Tuple2.of(5, "bcd"), Tuple2.of(6, "666")); - // 对(Int, String)中的第二个字段使用 MyPartitioner 中的重分布逻辑 + // 对 (Int, String) 中的第二个字段使用 MyPartitioner 中的重分布逻辑 DataStream> partitioned = dataStream.partitionCustom(new MyPartitioner(), 1); partitioned.print(); @@ -640,8 +724,8 @@ public class PartitionCustomExample { } /** - * Partitioner 其中泛型T为指定的字段类型 - * 重写partiton函数,并根据T字段对数据流中的所有元素进行数据重分配 + * Partitioner 其中泛型 T 为指定的字段类型 + * 重写 partiton 函数,并根据 T 字段对数据流中的所有元素进行数据重分配 * */ public static class MyPartitioner implements Partitioner { @@ -649,9 +733,9 @@ public class PartitionCustomExample { private Pattern pattern = Pattern.compile(".*\\d+.*"); /** - * key 泛型T 即根据哪个字段进行数据重分配,本例中是Tuple2(Int, String)中的String + * key 泛型 T 即根据哪个字段进行数据重分配,本例中是 Tuple2(Int, String) 中的 String * numPartitons 为当前有多少个并行实例 - * 函数返回值是一个Int 为该元素将被发送给下游第几个实例 + * 函数返回值是一个 Int 为该元素将被发送给下游第几个实例 * */ @Override public int partition(String key, int numPartitions) { diff --git a/doc/ch-datastream-api/user-define-functions.md b/doc/ch-datastream-api/user-define-functions.md index c71e3ea..6b4564a 100644 --- a/doc/ch-datastream-api/user-define-functions.md +++ b/doc/ch-datastream-api/user-define-functions.md @@ -3,19 +3,19 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -我们在[Transformations](transformations.md)部分中介绍了常用的一些操作,可以发现,使用Flink的算子必须进行自定义,自定义时可以使用Lambda表达式,也可以继承并重写函数类。本节将从源码和案例两方面对用户自定义函数进行一个总结和梳理。 +我们在 [Transformations](transformations.md) 部分中介绍了常用的一些操作,可以发现,使用 Flink 的算子必须进行自定义,自定义时可以使用 Lambda 表达式,也可以继承并重写函数类。本节将从源码和案例两方面对用户自定义函数进行一个总结和梳理。 ## 函数类 -对于`map`、`flatMap`、`reduce`等方法,我们可以实现`MapFunction`、`FlatMapFunction`、`ReduceFunction`等`interface`接口。这些函数类签名中都有泛型参数,用来定义该函数的输入或输出的数据类型。我们要继承这些类,并重写里面的自定义函数。以`flatMap`对应的`FlatMapFunction`为例,它在源码中的定义为: +对于 `map`、`flatMap`、`reduce` 等方法,我们可以实现 `MapFunction`、`FlatMapFunction`、`ReduceFunction` 等 `interface` 接口。这些函数类签名中都有泛型参数,用来定义该函数的输入或输出的数据类型。我们要继承这些类,并重写里面的自定义函数。以 `flatMap` 对应的 `FlatMapFunction` 为例,它在源码中的定义为: ```scala package org.apache.flink.api.common.functions; @@ -26,14 +26,14 @@ public interface FlatMapFunction extends Function, Serializable { } ``` -这是一个函数式接口类,它继承了Flink的`Function`函数式接口。我们在第二章中提到函数式接口,这正是只有一个抽象函数方法的接口类,其目的是为了方便应用Java Lambda表达式。此外,它还继承了`Serializable`,以便进行序列化,这是因为这些函数在运行过程中要发送到各个实例上,发送前后要进行序列化和反序列化。需要注意的是,使用这些函数时,一定要保证函数内的所有内容都可以被序列化。如果有一些不能被序列化的内容,或者使用接下来介绍的RichFunction函数类,或者重写Java的序列化和反序列化方法。 +这是一个函数式接口类,它继承了 Flink 的 `Function` 函数式接口。我们在第二章中提到函数式接口,这正是只有一个抽象函数方法的接口类,其目的是为了方便应用 Java Lambda 表达式。此外,它还继承了 `Serializable`,以便进行序列化,这是因为这些函数在运行过程中要发送到各个实例上,发送前后要进行序列化和反序列化。需要注意的是,使用这些函数时,一定要保证函数内的所有内容都可以被序列化。如果有一些不能被序列化的内容,或者使用接下来介绍的 RichFunction 函数类,或者重写 Java 的序列化和反序列化方法。 -进一步观察`FlatMapFunction`发现,这个函数类有两个泛型T和O,T是输入,O是输出。在继承这个接口类时,要设置好对应的输入和输出数据类型,否则会报错。我们最终其实是要重写虚函数`flatMap`,函数的两个参数也与输入输出的泛型类型对应。参数`value`是`flatMap`的输入,数据类型是T,参数`out`是`flatMap`的输出,它是一个`Collector`,从`Collector`命名可以看出它起着收集的作用,最终输出成一个数据流,我们需要将类型为O的数据写入`out`。 +进一步观察 `FlatMapFunction` 发现,这个函数类有两个泛型 T 和 O,T 是输入,O 是输出。在继承这个接口类时,要设置好对应的输入和输出数据类型,否则会报错。我们最终其实是要重写虚函数 `flatMap`,函数的两个参数也与输入输出的泛型类型对应。参数 `value` 是 `flatMap` 的输入,数据类型是 T,参数 `out` 是 `flatMap` 的输出,它是一个 `Collector`,从 `Collector` 命名可以看出它起着收集的作用,最终输出成一个数据流,我们需要将类型为 O 的数据写入 `out`。 -下面的例子继承`FlatMapFunction`,并实现`flatMap`,只对长度大于limit的字符串切词: +下面的例子继承 `FlatMapFunction`,并实现 `flatMap`,只对长度大于 limit 的字符串切词: ```scala -// 使用FlatMapFunction实现过滤逻辑,只对字符串长度大于 limit 的内容进行词频统计 +// 使用 FlatMapFunction 实现过滤逻辑,只对字符串长度大于 limit 的内容进行词频统计 public static class WordSplitFlatMap implements FlatMapFunction { private Integer limit; @@ -55,21 +55,21 @@ DataStream dataStream = senv.fromElements("Hello World", "Hello this is DataStream functionStream = dataStream.flatMap(new WordSplitFlatMap(10)); ``` -## Lambda表达式 +## Lambda 表达式 -当不需要处理非常复杂的业务逻辑时,使用Lambda表达式可能是更好的选择,Lambda表达式能让代码更简洁紧凑。Java和Scala都可以支持Lambda表达式。 +当不需要处理非常复杂的业务逻辑时,使用 Lambda 表达式可能是更好的选择,Lambda 表达式能让代码更简洁紧凑。Java 和 Scala 都可以支持 Lambda 表达式。 -### Scala的Lambda表达式 +### Scala 的 Lambda 表达式 -我们先看对Lambda表达式支持最好的Scala。对于`flatMap`,Flink的Scala源码有三种定义,我们先看一下第一种的定义: +我们先看对 Lambda 表达式支持最好的 Scala。对于 `flatMap`,Flink 的 Scala 源码有三种定义,我们先看一下第一种的定义: ```scala def flatMap[O: TypeInformation](fun: (T, Collector[O]) => Unit): DataStream[O] = {...} ``` -`flatMap`输入是泛型T,输出是泛型O,接收一个名为`fun`的Lambda表达式,`fun`形如`(T, Collector[O] => {...})`。Lambda表达式要将数据写到`Collector[O]`中。 +`flatMap` 输入是泛型 T,输出是泛型 O,接收一个名为 `fun` 的 Lambda 表达式,`fun` 形如 `(T, Collector[O] => {...})`。Lambda 表达式要将数据写到 `Collector[O]` 中。 -我们继续以切词为例,程序可以写成下面的样子,`flatMap`中的内容是一个Lambda表达式。其中的`foreach(out.collect)`本质上也是一个Lambda表达式。从这个例子可以看出,Scala的无所不在的函数式编程思想。 +我们继续以切词为例,程序可以写成下面的样子,`flatMap` 中的内容是一个 Lambda 表达式。其中的 `foreach(out.collect)` 本质上也是一个 Lambda 表达式。从这个例子可以看出,Scala 的无所不在的函数式编程思想。 ```scala val lambda = dataStream.flatMap{ @@ -81,33 +81,33 @@ val lambda = dataStream.flatMap{ } ``` -然后我们看一下源码中Scala的第二种定义: +然后我们看一下源码中 Scala 的第二种定义: ```scala def flatMap[O: TypeInformation](fun: T => TraversableOnce[O]): DataStream[O] = {...} ``` -与之前的不同,这里的Lambda表达式输入是泛型T,输出是一个`TraversableOnce[O]`,`TraversableOnce`表示这是一个O组成的列表。与之前使用`Collector`收集输出不同,这里直接输出一个列表,Flink帮我们将列表做了展平。使用`TraversableOnce`也导致我们无论如何都要返回一个列表,即使是一个空列表,否则无法匹配函数的定义。总结下来,这种场景的Lambda表达式输入是一个T,无论如何输出都是一个O的列表,即使是一个空列表。 +与之前的不同,这里的 Lambda 表达式输入是泛型 T,输出是一个 `TraversableOnce[O]`,`TraversableOnce` 表示这是一个 O 组成的列表。与之前使用 `Collector` 收集输出不同,这里直接输出一个列表,Flink 帮我们将列表做了展平。使用 `TraversableOnce` 也导致我们无论如何都要返回一个列表,即使是一个空列表,否则无法匹配函数的定义。总结下来,这种场景的 Lambda 表达式输入是一个 T,无论如何输出都是一个 O 的列表,即使是一个空列表。 ```scala -// 只对字符串数量大于15的句子进行处理 +// 只对字符串数量大于 15 的句子进行处理 val longSentenceWords = dataStream.flatMap { input => { if (input.size > 15) { // 输出是 TraversableOnce 因此返回必须是一个列表 - // 这里将Array[String]转成了Seq[String] + // 这里将 Array[String] 转成了 Seq[String] input.split(" ").toSeq } else { - // 为空时必须返回空列表,否则返回值无法与TraversableOnce匹配! + // 为空时必须返回空列表,否则返回值无法与 TraversableOnce 匹配! Seq.empty } } } ``` -在使用Lambda表达式时,我们应该逐渐学会使用IntelliJ Idea的类型检查和匹配功能。比如在本例中,如果返回值不是一个`TraversableOnce`,那么IntelliJ Idea会将该行标红,告知我们输入或输出的类型不匹配。 +在使用 Lambda 表达式时,我们应该逐渐学会使用 IntelliJ Idea 的类型检查和匹配功能。比如在本例中,如果返回值不是一个 `TraversableOnce`,那么 IntelliJ Idea 会将该行标红,告知我们输入或输出的类型不匹配。 -此外,还有第三种只针对Scala的Lambda表达式使用方法。Flink为了保持Java和Scala API的一致性,一些Scala独有的特性没有被放入标准的API,而是集成到了一个扩展包中。这种API支持类型匹配的偏函数(Partial Function),结合case关键字,能够在语义上更好地描述数据类型: +此外,还有第三种只针对 Scala 的 Lambda 表达式使用方法。Flink 为了保持 Java 和 Scala API 的一致性,一些 Scala 独有的特性没有被放入标准的 API,而是集成到了一个扩展包中。这种 API 支持类型匹配的偏函数(Partial Function),结合 case 关键字,能够在语义上更好地描述数据类型: ```scala val data: DataStream[(String, Long, Double)] = ... @@ -116,15 +116,15 @@ data.flatMapWith { } ``` -使用这种API时,需要添加引用: +使用这种 API 时,需要添加引用: ```scala import org.apache.flink.streaming.api.scala.extensions._ ``` -这种方式给输入定义了变量名和类型,方便阅读者阅读代码,同时也保留了函数式编程的简洁。Spark的大多数算子默认都支持此功能,Flink没有默认支持此功能,而是将这个功能放到了扩展包里,对于Spark用户来说,迁移到Flink时需要注意这个区别。此外`mapWith`、`filterWith`、`keyingBy`、`reduceWith`也分别是其他算子相对应的接口。 +这种方式给输入定义了变量名和类型,方便阅读者阅读代码,同时也保留了函数式编程的简洁。Spark 的大多数算子默认都支持此功能,Flink 没有默认支持此功能,而是将这个功能放到了扩展包里,对于 Spark 用户来说,迁移到 Flink 时需要注意这个区别。此外 `mapWith`、`filterWith`、`keyingBy`、`reduceWith` 也分别是其他算子相对应的接口。 -使用`flatMapWith`,之前的切词可以实现为: +使用 `flatMapWith`,之前的切词可以实现为: ```scala val flatMapWith = dataStream.flatMapWith { @@ -140,9 +140,9 @@ val flatMapWith = dataStream.flatMapWith { ### Java -再来看看Java,因为一些遗留问题,它的Lambda表达式使用起来有一些区别。 +再来看看 Java,因为一些遗留问题,它的 Lambda 表达式使用起来有一些区别。 -第二章中提到,Java有类型擦除问题,`void flatMap(IN value, Collector out)`编译成了`void flatMap(IN value, Collector out)`,擦除了泛型信息,Flink无法自动获取返回类型,如果不做其他操作,会抛出异常。 +第二章中提到,Java 有类型擦除问题,`void flatMap(IN value, Collector out)` 编译成了 `void flatMap(IN value, Collector out)`,擦除了泛型信息,Flink 无法自动获取返回类型,如果不做其他操作,会抛出异常。 ``` org.apache.flink.api.common.functions.InvalidTypesException: The generic type parameters of 'Collector' are missing. @@ -151,7 +151,7 @@ org.apache.flink.api.common.functions.InvalidTypesException: The generic type pa Otherwise the type has to be specified explicitly using type information. ``` -这种情况下,根据报错提示,或者使用一个类实现`FlatMapFunction`(包括匿名类),或者添加类型信息。这个类型信息,正是[数据类型和序列化](./data-types)章节中所介绍的数据类型。 +这种情况下,根据报错提示,或者使用一个类实现 `FlatMapFunction`(包括匿名类),或者添加类型信息。这个类型信息,正是 [数据类型和序列化](./data-types) 章节中所介绍的数据类型。 ```java DataStream words = dataStream.flatMap ( @@ -164,15 +164,15 @@ DataStream words = dataStream.flatMap ( .returns(Types.STRING); ``` -通过这里对Scala和Java的对比不难发现,Scala更灵活,Java更严谨,各有优势。 +通过这里对 Scala 和 Java 的对比不难发现,Scala 更灵活,Java 更严谨,各有优势。 -## Rich函数类 +## Rich 函数类 -在上面两种自定义方法的基础上,Flink还提供了RichFunction函数类。从名称上来看,这种函数类在普通的函数类上增加了Rich前缀,比如`RichMapFunction`、`RichFlatMapFunction`或`RichReduceFunction`等等。比起普通的函数类,Rich函数类增加了: +在上面两种自定义方法的基础上,Flink 还提供了 RichFunction 函数类。从名称上来看,这种函数类在普通的函数类上增加了 Rich 前缀,比如 `RichMapFunction`、`RichFlatMapFunction` 或 `RichReduceFunction` 等等。比起普通的函数类,Rich 函数类增加了: -* `open()`方法:Flink在算子调用前会执行这个方法,可以用来进行一些初始化工作。 -* `close()`方法:Flink在算子最后一次调用结束后执行这个方法,可以用来释放一些资源。 -* `getRuntimeContext()`方法:获取运行时上下文。每个并行的算子子任务都有一个运行时上下文,上下文记录了这个算子运行过程中的一些信息,包括算子当前的并行度、算子子任务序号、广播数据、累加器、监控数据。最重要的是,我们可以从上下文里获取状态数据。 +* `open()` 方法:Flink 在算子调用前会执行这个方法,可以用来进行一些初始化工作。 +* `close()` 方法:Flink 在算子最后一次调用结束后执行这个方法,可以用来释放一些资源。 +* `getRuntimeContext()` 方法:获取运行时上下文。每个并行的算子子任务都有一个运行时上下文,上下文记录了这个算子运行过程中的一些信息,包括算子当前的并行度、算子子任务序号、广播数据、累加器、监控数据。最重要的是,我们可以从上下文里获取状态数据。 我们可以看一下源码中的函数签名: @@ -180,12 +180,12 @@ DataStream words = dataStream.flatMap ( public abstract class RichFlatMapFunction extends AbstractRichFunction implements FlatMapFunction ``` -它既实现了`FlatMapFunction`接口类,又继承了`AbstractRichFunction`。其中`AbstractRichFunction`是一个抽象类,有一个成员变量`RuntimeContext`,有`open`、`close`和`getRuntimeContext`等方法。 +它既实现了 `FlatMapFunction` 接口类,又继承了 `AbstractRichFunction`。其中 `AbstractRichFunction` 是一个抽象类,有一个成员变量 `RuntimeContext`,有 `open`、`close` 和 `getRuntimeContext` 等方法。 -我们尝试继承并实现`RichFlatMapFunction`,并使用一个累加器。首先简单介绍累加器的概念:在单机环境下,我们可以用一个for循环做累加统计,但是在分布式计算环境下,计算是分布在多台节点上的,每个节点处理一部分数据,因此单纯循环无法满足计算,累加器是大数据框架帮我们实现的一种机制,允许我们在多节点上进行累加统计。 +我们尝试继承并实现 `RichFlatMapFunction`,并使用一个累加器。首先简单介绍累加器的概念:在单机环境下,我们可以用一个 for 循环做累加统计,但是在分布式计算环境下,计算是分布在多台节点上的,每个节点处理一部分数据,因此单纯循环无法满足计算,累加器是大数据框架帮我们实现的一种机制,允许我们在多节点上进行累加统计。 ```java -// 实现RichFlatMapFunction类 +// 实现 RichFlatMapFunction 类 // 添加了累加器 Accumulator public static class WordSplitRichFlatMap extends RichFlatMapFunction { @@ -201,7 +201,7 @@ public static class WordSplitRichFlatMap extends RichFlatMapFunction [OPTIONS] [ARGUMENTS] ``` -其中,`ACTION`包括`run`、`stop`等,分别对应提交和取消作业。`OPTIONS`为一些预置的选项,`ARGUMENTS`是用户传入的参数。由于命令行工具的参数很多,我们只介绍一些经常使用的参数,其他参数可以参考Flink官方文档。 +其中,`ACTION` 包括 `run`、`stop` 等,分别对应提交和取消作业。`OPTIONS` 为一些预置的选项,`ARGUMENTS` 是用户传入的参数。由于命令行工具的参数很多,我们只介绍一些经常使用的参数,其他参数可以参考 Flink 官方文档。 -## 9.4.1 提交作业 +## 提交作业 提交作业的语法如下。 @@ -21,15 +21,15 @@ $ ./bin/flink run [OPTIONS] [ARGUMENTS] ``` -我们要提供一个打包好的用户作业JAR包。打包需要使用Maven,在自己的Java工程目录下执行`mvn package`,在`target`文件夹下找到相应的JAR包。 +我们要提供一个打包好的用户作业 JAR 包。打包需要使用 Maven,在自己的 Java 工程目录下执行 `mvn package`,在 `target` 文件夹下找到相应的 JAR 包。 -我们使用Flink给我们提供的WordCount程序来演示。它的JAR包在Flink主目录下:`./examples/streaming/WordCount.jar`。提交作业的命令如下。 +我们使用 Flink 给我们提供的 WordCount 程序来演示。它的 JAR 包在 Flink 主目录下:`./examples/streaming/WordCount.jar`。提交作业的命令如下。 ```bash $ ./bin/flink run ./examples/streaming/WordCount.jar ``` -任何一个Java程序都需要一个主类和main方法作为入口,启动WordCount程序时,我们并没有提及主类,因为程序在`pom.xml`文件中设置了主类。确切地说,经过Maven打包生成的JAR包有文件`META-INF/MANIFEST.MF`,该文件里定义了主类。如果我们想明确使用自己所需要的主类,可以使用`-c ` 或`--class `来指定程序的主类。在一个包含众多`main()`方法的JAR包里,必须指定一个主类,否则会报错。 +任何一个 Java 程序都需要一个主类和 main 方法作为入口,启动 WordCount 程序时,我们并没有提及主类,因为程序在 `pom.xml` 文件中设置了主类。确切地说,经过 Maven 打包生成的 JAR 包有文件 `META-INF/MANIFEST.MF`,该文件里定义了主类。如果我们想明确使用自己所需要的主类,可以使用 `-c ` 或 `--class ` 来指定程序的主类。在一个包含众多 `main()` 方法的 JAR 包里,必须指定一个主类,否则会报错。 ```bash $ ./bin/flink run \ @@ -47,17 +47,17 @@ $ ./bin/flink run \ --output '/tmp/b.log' ``` -其中,`--input '/tmp/a.log' --output '/tmp/b.log'`为我们传入的参数,和其他Java程序一样,这些参数会写入`main()`方法的参数`String[]`中,以字符串数组的形式存在。参数需要程序代码解析,因此命令行工具与程序代码中的参数要保持一致,否则会出现参数解析错误的情况。 +其中,`--input '/tmp/a.log' --output '/tmp/b.log'` 为我们传入的参数,和其他 Java 程序一样,这些参数会写入 `main()` 方法的参数 `String[]` 中,以字符串数组的形式存在。参数需要程序代码解析,因此命令行工具与程序代码中的参数要保持一致,否则会出现参数解析错误的情况。 -我们也可以在命令行中用`-p`选项设置这个作业的并行度。下面的命令给作业设置的并行度为2。 +我们也可以在命令行中用 `-p` 选项设置这个作业的并行度。下面的命令给作业设置的并行度为 2。 ```bash $ ./bin/flink run -p 2 ./examples/streaming/WordCount.jar ``` -如果用户在代码中使用`setParallelism()`方法明确设置并行度,或有给某个算子设置并行度,那么用户代码中的设置会覆盖命令行中的`-p`设置。 +如果用户在代码中使用 `setParallelism()` 方法明确设置并行度,或有给某个算子设置并行度,那么用户代码中的设置会覆盖命令行中的 `-p` 设置。 -提交作业本质上是向Flink的Master提交JAR包,可以用`-m`选项来设置向具体哪个Master提交。下面的命令将作业提交到Hostname为`myJMHost`的节点上,端口号为8081。 +提交作业本质上是向 Flink 的 Master 提交 JAR 包,可以用 `-m` 选项来设置向具体哪个 Master 提交。下面的命令将作业提交到 Hostname 为 `myJMHost` 的节点上,端口号为 8081。 ```bash $ ./bin/flink run \ @@ -65,9 +65,9 @@ $ ./bin/flink run \ ./examples/streaming/WordCount.jar ``` -如果我们已经启动了一个YARN集群,且当前节点可以连接到YARN集群上,`-m yarn-cluster`会将作业以Per-Job模式提交到YARN集群上。如果我们已经启动了一个Flink YARN Session,可以不用设置`-m`选项,Flink会记住Flink YARN Session的连接信息,默认向这个Flink YARN Session提交作业。 +如果我们已经启动了一个 YARN 集群,且当前节点可以连接到 YARN 集群上,`-m yarn-cluster` 会将作业以 Per-Job 模式提交到 YARN 集群上。如果我们已经启动了一个 Flink YARN Session,可以不用设置 `-m` 选项,Flink 会记住 Flink YARN Session 的连接信息,默认向这个 Flink YARN Session 提交作业。 -因为Flink支持不同类型的部署方式,为了避免提交作业的混乱、设置参数过多,Flink提出了`-e `或`--executor `选项,用户可以通过这两个选项选择使用哪种执行模式(Executor Mode)。可选的执行模式有:`remote`、`local`、`kubernetes-session`、`yarn-per-job`、 `yarn-session`。例如,一个原生Kubernetes Session中提交作业的命令如下。 +因为 Flink 支持不同类型的部署方式,为了避免提交作业的混乱、设置参数过多,Flink 提出了 `-e ` 或 `--executor ` 选项,用户可以通过这两个选项选择使用哪种执行模式(Executor Mode)。可选的执行模式有:`remote`、`local`、`kubernetes-session`、`yarn-per-job`、 `yarn-session`。例如,一个原生 Kubernetes Session 中提交作业的命令如下。 ```bash $ ./bin/flink run \ @@ -76,11 +76,11 @@ $ ./bin/flink run \ examples/streaming/WindowJoin.jar ``` -上面命令的`-D`用于设置参数。我们用`-D`形式来设置一些配置信息,这些配置的含义和内容和`conf/flink-conf.yaml`中的配置是一致的。 +上面命令的 `-D` 用于设置参数。我们用 `-D` 形式来设置一些配置信息,这些配置的含义和内容和 `conf/flink-conf.yaml` 中的配置是一致的。 -无论用以上哪种方式提交作业,Flink都会将一些信息输出到屏幕上,最重要的信息就是作业的ID。 +无论用以上哪种方式提交作业,Flink 都会将一些信息输出到屏幕上,最重要的信息就是作业的 ID。 -## 9.4.2 管理作业 +## 管理作业 罗列当前的作业的命令如下。 @@ -88,27 +88,27 @@ $ ./bin/flink run \ $ ./bin/flink list ``` -触发一个作业执行Savepoint的命令如下。 +触发一个作业执行 Savepoint 的命令如下。 ```bash $ ./bin/flink savepoint [savepointDirectory] ``` -这行命令会通知作业ID为`jobId`的作业执行Savepoint,可以在后面添加路径,Savepoint会写入对应目录,该路径必须是Flink Master可访问到的目录,例如一个HDFS路径。 +这行命令会通知作业 ID 为 `jobId` 的作业执行 Savepoint,可以在后面添加路径,Savepoint 会写入对应目录,该路径必须是 Flink Master 可访问到的目录,例如一个 HDFS 路径。 -关停一个Flink作业的命令如下。 +关停一个 Flink 作业的命令如下。 ```bash $ ./bin/flink cancel ``` -关停一个带Savepoint的作业的命令如下。 +关停一个带 Savepoint 的作业的命令如下。 ```bash $ ./bin/flink stop ``` -从一个Savepoint恢复一个作业的命令如下。 +从一个 Savepoint 恢复一个作业的命令如下。 ```bash $ ./bin/flink run -s [OPTIONS] diff --git a/doc/ch-deployment-and-configuration/flink-deployment-and-configuration.md b/doc/ch-deployment-and-configuration/flink-deployment-and-configuration.md index 2891d06..1c7e2ef 100644 --- a/doc/ch-deployment-and-configuration/flink-deployment-and-configuration.md +++ b/doc/ch-deployment-and-configuration/flink-deployment-and-configuration.md @@ -1,91 +1,113 @@ (flink-deployment-and-configuration)= -# Flink集群部署模式 +# Flink 集群部署模式 -当前,信息系统基础设施正在飞速发展,常见的基础设施包括物理机集群、虚拟机集群、容器集群等。为了兼容这些基础设施,Flink曾在1.7版本中做了重构,提出了第3章中所示的Master-Worker架构,该架构可以兼容几乎所有主流信息系统的基础设施,包括Standalone集群、Hadoop YARN集群或Kubernetes集群。 +当前,信息系统基础设施正在飞速发展,常见的基础设施包括物理机集群、虚拟机集群、容器集群等。为了兼容这些基础设施,Flink 曾在 1.7 版本中做了重构,提出了第 3 章中所示的 Master-Worker 架构,该架构可以兼容几乎所有主流信息系统的基础设施,包括 Standalone 集群、Hadoop YARN 集群或 Kubernetes 集群。 -## 9.1.1 Standalone集群 +## Standalone 集群 -一个Standalone集群包括至少一个Master进程和至少一个TaskManager进程,每个进程作为一个单独的Java JVM进程。其中,Master节点上运行Dispatcher、ResourceManager和JobManager,Worker节点将运行TaskManager。图9-1展示了一个4节点的Standalone集群,其中,IP地址为192.168.0.1的节点为Master节点,其他3个为Worker节点。 +一个 Standalone 集群包括至少一个 Master 进程和至少一个 TaskManager 进程,每个进程作为一个单独的 Java JVM 进程。其中,Master 节点上运行 Dispatcher、ResourceManager 和 JobManager,Worker 节点将运行 TaskManager。{numref}`fig-flink-standalone-cluster` 展示了一个 4 节点的 Standalone 集群,其中,IP 地址为 192.168.0.1 的节点为 Master 节点,其他 3 个为 Worker 节点。 -![图9-1 Flink Standalone集群](./img/Flink-Standalone-cluster.png) +```{figure} ./img/Flink-Standalone-cluster.png +--- +name: fig-flink-standalone-cluster +width: 80% +align: center +--- +Flink Standalone 集群 +``` -第2章的实验中,我们已经展示了如何下载和解压Flink,该集群只部署在本地,结合图9-1,本节介绍如何在一个物理机集群上部署Standalone集群。我们可以将解压后的Flink主目录复制到所有节点的相同路径上;也可以在一个共享存储空间(例如NFS)的路径上部署Flink,所有节点均可以像访问本地目录那样访问共享存储上的Flink主目录。此外,节点之间必须实现免密码登录:基于安全外壳协议(Secure Shell,SSH),将公钥拷贝到待目标节点,可以实现节点之间免密码登录。所有节点上必须提前安装并配置好JDK,将$JAVA_HOME放入环境变量。 +第 2 章的实验中,我们已经展示了如何下载和解压 Flink,该集群只部署在本地,结合图 9-1,本节介绍如何在一个物理机集群上部署 Standalone 集群。我们可以将解压后的 Flink 主目录复制到所有节点的相同路径上;也可以在一个共享存储空间(例如 NFS)的路径上部署 Flink,所有节点均可以像访问本地目录那样访问共享存储上的 Flink 主目录。此外,节点之间必须实现免密码登录:基于安全外壳协议(Secure Shell,SSH),将公钥拷贝到待目标节点,可以实现节点之间免密码登录。所有节点上必须提前安装并配置好 JDK,将 $JAVA_HOME 放入环境变量。 -我们需要编辑`conf/flink-conf.yaml`文件,将`jobmanager.rpc.address`配置为Master节点的IP地址192.168.0.1;编辑`conf/slaves`文件,将192.168.0.2、192.168.0.3和192.168.0.4等Worker节点的IP地址加入该文件中。如果每个节点除了IP地址外,还配有主机名(Hostname),我们也可以用Hostname替代IP地址来做上述配置。 +我们需要编辑 `conf/flink-conf.yaml` 文件,将 `jobmanager.rpc.address` 配置为 Master 节点的 IP 地址 192.168.0.1;编辑 `conf/slaves` 文件,将 192.168.0.2、192.168.0.3 和 192.168.0.4 等 Worker 节点的 IP 地址加入该文件中。如果每个节点除了 IP 地址外,还配有主机名(Hostname),我们也可以用 Hostname 替代 IP 地址来做上述配置。 -综上,配置一个Standalone集群需要注意以下几点: -- 为每台节点分配固定的IP地址,或者配置Hostname,节点之间设置免密码SSH登录。 -- 在所有节点上提前安装配置JDK,将`$JAVA_HOME`添加到环境变量中。 -- 配置`conf/flink-conf.yaml`文件,设置`jobmanager.rpc.address`为Master节点的IP地址或Hostname。配置`conf/slaves`文件,将Worker节点的IP地址或Hostname添加进去。 -- 将Flink主目录同步到所有节点的相同目录下,或者部署在一个共享目录上,共享目录可被所有节点访问。 +综上,配置一个 Standalone 集群需要注意以下几点: +- 为每台节点分配固定的 IP 地址,或者配置 Hostname,节点之间设置免密码 SSH 登录。 +- 在所有节点上提前安装配置 JDK,将 `$JAVA_HOME` 添加到环境变量中。 +- 配置 `conf/flink-conf.yaml` 文件,设置 `jobmanager.rpc.address` 为 Master 节点的 IP 地址或 Hostname。配置 `conf/slaves` 文件,将 Worker 节点的 IP 地址或 Hostname 添加进去。 +- 将 Flink 主目录同步到所有节点的相同目录下,或者部署在一个共享目录上,共享目录可被所有节点访问。 -接着,我们回到Master节点,进入Flink主目录,运行`bin/start-cluster.sh`。该脚本会在Master节点启动Master进程,同时读取`conf/slaves`文件,脚本会帮我们SSH登录到各节点上,启动TaskManager。至此,我们启动了一个Flink Standalone集群,我们可以使用Flink Client向该集群的Master节点提交作业。 +接着,我们回到 Master 节点,进入 Flink 主目录,运行 `bin/start-cluster.sh`。该脚本会在 Master 节点启动 Master 进程,同时读取 `conf/slaves` 文件,脚本会帮我们 SSH 登录到各节点上,启动 TaskManager。至此,我们启动了一个 Flink Standalone 集群,我们可以使用 Flink Client 向该集群的 Master 节点提交作业。 ```bash $ ./bin/flink run -m 192.168.0.1:8081 ./examples/batch/WordCount.jar ``` -可以使用`bin/stop-cluster.sh`脚本关停整个集群。 +可以使用 `bin/stop-cluster.sh` 脚本关停整个集群。 -## 9.1.2 Hadoop YARN集群 +## Hadoop YARN 集群 -Hadoop一直是很多公司首选的大数据基础架构,YARN也是经常使用的资源调度器。YARN可以管理一个集群的CPU和内存等资源,MapReduce、Hive或Spark都可以向YARN申请资源。YARN中的基本调度资源是容器(Container)。 +Hadoop 一直是很多公司首选的大数据基础架构,YARN 也是经常使用的资源调度器。YARN 可以管理一个集群的 CPU 和内存等资源,MapReduce、Hive 或 Spark 都可以向 YARN 申请资源。YARN 中的基本调度资源是容器(Container)。 注意: -YARN Container和Docker Container有所不同。YARN Container只适合JVM上的资源隔离,Docker Container则是更广泛意义上的Container。 +YARN Container 和 Docker Container 有所不同。YARN Container 只适合 JVM 上的资源隔离,Docker Container 则是更广泛意义上的 Container。 -为了让Flink运行在YARN上,需要提前配置Hadoop和YARN,这包括下载针对Hadoop的Flink,设置`HADOOP_CONF_DIR`和`YARN_CONF_DIR`等与Hadoop相关的配置,启动YARN等。网络上有大量相关教程,这里不赘述Hadoop和YARN的安装方法,但是用户需要按照9.5节介绍的内容来配置Hadoop相关依赖。 +为了让 Flink 运行在 YARN 上,需要提前配置 Hadoop 和 YARN,这包括下载针对 Hadoop 的 Flink,设置 `HADOOP_CONF_DIR` 和 `YARN_CONF_DIR` 等与 Hadoop 相关的配置,启动 YARN 等。网络上有大量相关教程,这里不赘述 Hadoop 和 YARN 的安装方法,但是用户需要按照 9.5 节介绍的内容来配置 Hadoop 相关依赖。 -在YARN上使用Flink有3种模式:Per-Job模式、Session模式和Application模式。Per-Job模式指每次向YARN提交一个作业,YARN为这个作业单独分配资源,基于这些资源启动一个Flink集群,该作业运行结束后,相应的资源会被释放。Session模式在YARN上启动一个长期运行的Flink集群,用户可以向这个集群提交多个作业。Application模式在Per-Job模式上做了一些优化。图9-2展示了Per-Job模式的作业提交流程。 +在 YARN 上使用 Flink 有 3 种模式:Per-Job 模式、Session 模式和 Application 模式。Per-Job 模式指每次向 YARN 提交一个作业,YARN 为这个作业单独分配资源,基于这些资源启动一个 Flink 集群,该作业运行结束后,相应的资源会被释放。Session 模式在 YARN 上启动一个长期运行的 Flink 集群,用户可以向这个集群提交多个作业。Application 模式在 Per-Job 模式上做了一些优化。{numref}`fig-per-job-submission` 展示了 Per-Job 模式的作业提交流程。 -![图9-2 Per-Job模式的作业提交流程](./img/Per-Job.png) +```{figure} ./img/Per-Job.png +--- +name: fig-per-job-submission +width: 80% +align: center +--- +Per-Job 模式的作业提交流程 +``` -Client首先将作业提交给YARN的ResourceManager,YARN为这个作业生成一个ApplicationMaster以运行Fink Master,ApplicationMaster是YARN中承担作业资源管理等功能的组件。ApplicationMaster中运行着JobManager和Flink-YARN ResourceManager。JobManager会根据本次作业所需资源向Flink-YARN ResourceManager申请Slot资源。 +Client 首先将作业提交给 YARN 的 ResourceManager,YARN 为这个作业生成一个 ApplicationMaster 以运行 Fink Master,ApplicationMaster 是 YARN 中承担作业资源管理等功能的组件。ApplicationMaster 中运行着 JobManager 和 Flink-YARN ResourceManager。JobManager 会根据本次作业所需资源向 Flink-YARN ResourceManager 申请 Slot 资源。 -注意: -这里有两个ResourceManager,一个是YARN的ResourceManager,它是YARN的组件,不属于Flink,它负责整个YARN集群全局层面的资源管理和任务调度;一个是Flink-YARN ResourceManager,它是Flink的组件,它负责当前Flink作业的资源管理。 +:::{note} +这里有两个 ResourceManager,一个是 YARN 的 ResourceManager,它是 YARN 的组件,不属于 Flink,它负责整个 YARN 集群全局层面的资源管理和任务调度;一个是 Flink-YARN ResourceManager,它是 Flink 的组件,它负责当前 Flink 作业的资源管理。 +::: -Flink-YARN ResourceManager会向YARN申请所需的Container,YARN为之分配足够的Container作为TaskManager。TaskManager里有Flink计算所需的Slot,TaskManager将这些Slot注册到Flink-YARN ResourceManager中。注册成功后,JobManager将作业的计算任务部署到各TaskManager上。 +Flink-YARN ResourceManager 会向 YARN 申请所需的 Container,YARN 为之分配足够的 Container 作为 TaskManager。TaskManager 里有 Flink 计算所需的 Slot,TaskManager 将这些 Slot 注册到 Flink-YARN ResourceManager 中。注册成功后,JobManager 将作业的计算任务部署到各 TaskManager 上。 -下面的命令使用Per-Job模式启动单个作业。 +下面的命令使用 Per-Job 模式启动单个作业。 ```bash $ ./bin/flink run -m yarn-cluster ./examples/batch/WordCount.jar ``` -`-m yarn-cluster`表示该作业使用Per-Job模式运行在YARN上。 +`-m yarn-cluster` 表示该作业使用 Per-Job 模式运行在 YARN 上。 -图9-3展示了Session模式的作业提交流程。 +{numref}`fig-session-submission` 展示了 Session 模式的作业提交流程。 -![图9-3 Session模式的作业提交流程](./img/session.png) +```{figure} ./img/session.png +--- +name: fig-session-submission +width: 80% +align: center +--- +Session 模式的作业提交流程 +``` -Session模式将在YARN上启动一个Flink集群,用户可以向该集群提交多个作业。 +Session 模式将在 YARN 上启动一个 Flink 集群,用户可以向该集群提交多个作业。 -首先,我们在Client上,用`bin/yarn-session.sh`启动一个YARN Session。Flink会先向YARN ResourceManager申请一个ApplicationMaster,里面运行着Dispatcher和Flink-YARN ResourceManager,这两个组件将长期对外提供服务。当提交一个具体的作业时,作业相关信息被发送给了Dispatcher,Dispatcher会启动针对该作业的JobManager。 +首先,我们在 Client 上,用 `bin/yarn-session.sh` 启动一个 YARN Session。Flink 会先向 YARN ResourceManager 申请一个 ApplicationMaster,里面运行着 Dispatcher 和 Flink-YARN ResourceManager,这两个组件将长期对外提供服务。当提交一个具体的作业时,作业相关信息被发送给了 Dispatcher,Dispatcher 会启动针对该作业的 JobManager。 -接下来的流程就与Per-Job模式几乎一模一样:JobManager申请Slot,Flink-YARN ResourceManager向YARN申请所需的Container,每个Container里启动TaskManager,TaskManager向Flink-YARN ResourceManager注册Slot,注册成功后,JobManager将计算任务部署到各TaskManager上。如果用户提交下一个作业,那么Dispatcher启动新的JobManager,新的JobManager负责新作业的资源申请和任务调度。 +接下来的流程就与 Per-Job 模式几乎一模一样:JobManager 申请 Slot,Flink-YARN ResourceManager 向 YARN 申请所需的 Container,每个 Container 里启动 TaskManager,TaskManager 向 Flink-YARN ResourceManager 注册 Slot,注册成功后,JobManager 将计算任务部署到各 TaskManager 上。如果用户提交下一个作业,那么 Dispatcher 启动新的 JobManager,新的 JobManager 负责新作业的资源申请和任务调度。 -下面的命令本启动了一个Session,该Session的JobManager内存大小为1024MB,TaskManager内存大小为4096MB。 +下面的命令本启动了一个 Session,该 Session 的 JobManager 内存大小为 1024MB,TaskManager 内存大小为 4096MB。 ```bash $ ./bin/yarn-session.sh -jm 1024m -tm 4096m ``` -启动后,屏幕上会显示Flink WebUI的连接信息。例如,在一个本地部署的YARN集群上创建一个Session后,假设分配的WebUI地址为:`http://192.168.31.167:54680/`。将地址复制到浏览器,打开即显示Flink WebUI。 +启动后,屏幕上会显示 Flink WebUI 的连接信息。例如,在一个本地部署的 YARN 集群上创建一个 Session 后,假设分配的 WebUI 地址为:`http://192.168.31.167:54680/`。将地址复制到浏览器,打开即显示 Flink WebUI。 -之后我们可以使用`bin/flink`在该Session上启动一个作业。 +之后我们可以使用 `bin/flink` 在该 Session 上启动一个作业。 ```bash $ ./bin/flink run ./examples/batch/WordCount.jar ``` -上述提交作业的命令没有特意指定连接信息,所提交的作业会直接在Session中运行,这是因为Flink已经将Session的连接信息记录了下来。从 Flink WebUI 页面上可以看到,刚开始启动时,UI上显示 Total/Available Task Slots 为0,Task Managers也为0。随着作业的提交,资源会动态增加:每提交一个新的作业,Flink-YARN ResourceManager会动态地向YARN ResourceManager申请资源。 +上述提交作业的命令没有特意指定连接信息,所提交的作业会直接在 Session 中运行,这是因为 Flink 已经将 Session 的连接信息记录了下来。从 Flink WebUI 页面上可以看到,刚开始启动时,UI 上显示 Total/Available Task Slots 为 0,Task Managers 也为 0。随着作业的提交,资源会动态增加:每提交一个新的作业,Flink-YARN ResourceManager 会动态地向 YARN ResourceManager 申请资源。 -比较Per-Job模式和Session模式发现:Per-Job模式下,一个作业运行完后,JobManager、TaskManager都会退出,Container资源会释放,作业会在资源申请和释放上消耗时间;Session模式下,Dispatcher和Flink-YARN ResourceManager是可以被多个作业复用的。无论哪种模式,每个作业都有一个JobManager与之对应,该JobManager负责单个作业的资源申请、任务调度、Checkpoint等协调性功能。Per-Job模式更适合长时间运行的作业,作业对启动时间不敏感,一般是长期运行的流处理任务。Session模式更适合短时间运行的作业,一般是批处理任务。 +比较 Per-Job 模式和 Session 模式发现:Per-Job 模式下,一个作业运行完后,JobManager、TaskManager 都会退出,Container 资源会释放,作业会在资源申请和释放上消耗时间;Session 模式下,Dispatcher 和 Flink-YARN ResourceManager 是可以被多个作业复用的。无论哪种模式,每个作业都有一个 JobManager 与之对应,该 JobManager 负责单个作业的资源申请、任务调度、Checkpoint 等协调性功能。Per-Job 模式更适合长时间运行的作业,作业对启动时间不敏感,一般是长期运行的流处理任务。Session 模式更适合短时间运行的作业,一般是批处理任务。 -除了Per-Job模式和Session模式,Flink还提供了一个Application模式。Per-Job和Session模式作业提交的过程比较依赖Client,一个作业的main()方法是在Client上执行的。main()方法会将作业的各个依赖下载到本地,生成JobGraph,并将依赖以及JobGraph发送到Flink集群。在Client上执行main()方法会导致Client的负载很重,因为下载依赖和将依赖打包发送到Flink集群都对网络带宽有一定要求,执行main()方法会加重CPU的负担。而且在很多企业,多个用户会共享一个Client,多人共用加重了Client的压力。为了解决这个问题,Flink的Application模式允许main()方法在JobManager上执行,这样可以分担Client的压力。在资源隔离层面上,Application模式与Per-Job模式基本一样,相当于为每个作业应用创建一个Flink集群。 +除了 Per-Job 模式和 Session 模式,Flink 还提供了一个 Application 模式。Per-Job 和 Session 模式作业提交的过程比较依赖 Client,一个作业的 main()方法是在 Client 上执行的。main() 方法会将作业的各个依赖下载到本地,生成 JobGraph,并将依赖以及 JobGraph 发送到 Flink 集群。在 Client 上执行 main()方法会导致 Client 的负载很重,因为下载依赖和将依赖打包发送到 Flink 集群都对网络带宽有一定要求,执行 main() 方法会加重 CPU 的负担。而且在很多企业,多个用户会共享一个 Client,多人共用加重了 Client 的压力。为了解决这个问题,Flink 的 Application 模式允许 main() 方法在 JobManager 上执行,这样可以分担 Client 的压力。在资源隔离层面上,Application 模式与 Per-Job 模式基本一样,相当于为每个作业应用创建一个 Flink 集群。 -具体而言,我们可以用下面的代码,基于Application模式提交作业。 +具体而言,我们可以用下面的代码,基于 Application 模式提交作业。 ```bash $ ./bin/flink run-application -t yarn-application \ @@ -95,26 +117,33 @@ $ ./bin/flink run-application -t yarn-application \ ./examples/batch/WordCount.jar ``` -在上面这段提交作业的代码中,`run-application`表示使用Application模式,`-D`前缀加上参数配置来设置一些参数,这与Per-Job模式和Session模式的参数设置稍有不同。为了让作业下载各种依赖,可以向HDFS上传一些常用的JAR包,本例中上传路径是`hdfs://myhdfs/my-remote-flink-dist-dir`,然后使用`-Dyarn.provided.lib.dirs`告知Flink上传JAR包的地址,Flink的JobManager会前往这个地址下载各种依赖。 +在上面这段提交作业的代码中,`run-application` 表示使用 Application 模式,`-D` 前缀加上参数配置来设置一些参数,这与 Per-Job 模式和 Session 模式的参数设置稍有不同。为了让作业下载各种依赖,可以向 HDFS 上传一些常用的 JAR 包,本例中上传路径是 `hdfs://myhdfs/my-remote-flink-dist-dir`,然后使用 `-Dyarn.provided.lib.dirs` 告知 Flink 上传 JAR 包的地址,Flink 的 JobManager 会前往这个地址下载各种依赖。 -## 9.1.3 Kubernetes集群 +## Kubernetes 集群 -Kubernetes(简称K8s)是一个开源的Container编排平台。近年来,Container以及Kubernetes大行其道,获得了业界的广泛关注,很多信息系统正在逐渐将业务迁移到Kubernetes上。 +Kubernetes(简称 K8s)是一个开源的 Container 编排平台。近年来,Container 以及 Kubernetes 大行其道,获得了业界的广泛关注,很多信息系统正在逐渐将业务迁移到 Kubernetes 上。 -在Flink 1.10之前,Flink的Kubernetes部署需要用户对Kubernetes各组件和工具有一定的了解,而Kubernetes涉及的组件和概念较多,学习成本较高。和YARN一样,Flink Kubernetes部署方式支持Per-Job和Session两种模式。为了进一步减小Kubernetes部署的难度,Flink 1.10提出了原生Kubernetes部署,同时也保留了之前的模式。新的Kubernetes部署非常简单,将会成为未来的趋势,因此本小节只介绍这种原生Kubernetes部署方式。 +在 Flink 1.10 之前,Flink 的 Kubernetes 部署需要用户对 Kubernetes 各组件和工具有一定的了解,而 Kubernetes 涉及的组件和概念较多,学习成本较高。和 YARN 一样,Flink Kubernetes 部署方式支持 Per-Job 和 Session 两种模式。为了进一步减小 Kubernetes 部署的难度,Flink 1.10 提出了原生 Kubernetes 部署,同时也保留了之前的模式。新的 Kubernetes 部署非常简单,将会成为未来的趋势,因此本小节只介绍这种原生 Kubernetes 部署方式。 注意: -原生Kubernetes部署是Flink 1.10推出的新功能,还在持续迭代中,一些配置文件和命令行参数有可能在未来的版本迭代中发生变化,读者使用前最好阅读最新的官方文档。 +原生 Kubernetes 部署是 Flink 1.10 推出的新功能,还在持续迭代中,一些配置文件和命令行参数有可能在未来的版本迭代中发生变化,读者使用前最好阅读最新的官方文档。 -在使用Kubernetes之前,需要确保Kubernetes版本为1.9以上,配置`~/.kube/config`文件,提前创建用户,并赋予相应权限。 +在使用 Kubernetes 之前,需要确保 Kubernetes 版本为 1.9 以上,配置 `~/.kube/config` 文件,提前创建用户,并赋予相应权限。 -Flink原生Kubernetes部署目前支持Session模式和Application模式。Session模式是在Kubernetes集群上启动Session,然后在Session中提交多个作业。未来的版本将支持原生Kubernetes Per-Job模式。图9-4所示为一个原生Kubernetes Session模式的作业提交流程。 +Flink 原生 Kubernetes 部署目前支持 Session 模式和 Application 模式。Session 模式是在 Kubernetes 集群上启动 Session,然后在 Session 中提交多个作业。未来的版本将支持原生 Kubernetes Per-Job 模式。{numref}`fig-kubernetes-session-submission` 所示为一个原生 Kubernetes Session 模式的作业提交流程。 -![图9-4 原生Kubernetes Session模式的作业提交流程](./img/Kubernetes-Session.png) +```{figure} ./img/Kubernetes-Session.png +--- +name: fig-kubernetes-session-submission +width: 80% +align: center +--- +原生 Kubernetes Session 模式的作业提交流程 +``` -如图9-4中所示的第1步,我们用`bin/kubernetes-session.sh`启动一个Kubernetes Session,Kubernetes相关组件将进行初始化,Kubernetes Master、ConfigMap和Kubernetes Service等模块生成相关配置,剩下的流程与YARN的Session模式几乎一致。Client提交作业到Dispatcher,Dispatcher启动一个JobManager,JobManager向Flink-Kubernetes ResourceManager申请Slot,Flink-Kubernetes ResourceManager进而向Kubernetes Master申请资源。Kubernetes Master分配资源,启动Kubernetes Pod,运行TaskManager,TaskManager向Flink-Kubernetes ResourceManager注册Slot,这个作业可以基于这些资源进行部署。 +如 {numref}`fig-kubernetes-session-submission` 中所示的第 1 步,我们用 `bin/kubernetes-session.sh` 启动一个 Kubernetes Session,Kubernetes 相关组件将进行初始化,Kubernetes Master、ConfigMap 和 Kubernetes Service 等模块生成相关配置,剩下的流程与 YARN 的 Session 模式几乎一致。Client 提交作业到 Dispatcher,Dispatcher 启动一个 JobManager,JobManager 向 Flink-Kubernetes ResourceManager 申请 Slot,Flink-Kubernetes ResourceManager 进而向 Kubernetes Master 申请资源。Kubernetes Master 分配资源,启动 Kubernetes Pod,运行 TaskManager,TaskManager 向 Flink-Kubernetes ResourceManager 注册 Slot,这个作业可以基于这些资源进行部署。 -如图9-4中所示的第1步,我们需要启动一个Flink Kubernetes Session,其他参数需要参考Flink官方文档中的说明,相关命令如下。 +如 {numref}`fig-kubernetes-session-submission` 中所示的第 1 步,我们需要启动一个 Flink Kubernetes Session,其他参数需要参考 Flink 官方文档中的说明,相关命令如下。 ```bash $ ./bin/kubernetes-session.sh \ @@ -126,24 +155,24 @@ $ ./bin/kubernetes-session.sh \ -Dresourcemanager.taskmanager-timeout=3600000 ``` -上面的命令启动了一个名为ClusterId的Flink Kubernetes Session集群,集群中的每个TaskManager有2个CPU、4096MB的内存、4个Slot。ClusterId是该Flink Kubernetes Session集群的标识,实际使用时我们需要设置一个名字,如果不进行设置,Flink会给我们分配一个名字。 +上面的命令启动了一个名为 ClusterId 的 Flink Kubernetes Session 集群,集群中的每个 TaskManager 有 2 个 CPU、4096MB 的内存、4 个 Slot。ClusterId 是该 Flink Kubernetes Session 集群的标识,实际使用时我们需要设置一个名字,如果不进行设置,Flink 会给我们分配一个名字。 -为了使用Flink WebUI,可以使用下面的命令进行端口转发。 +为了使用 Flink WebUI,可以使用下面的命令进行端口转发。 ```bash $ kubectl port-forward service/ 8081 ``` -在浏览器中打开地址`http://127.0.0.1:8001`,就能看到Flink的WebUI了。与Flink YARN Session一样,刚开始所有的资源都是0,随着作业的提交,Flink会动态地向Kubernetes申请更多资源。 +在浏览器中打开地址 `http://127.0.0.1:8001`,就能看到 Flink 的 WebUI 了。与 Flink YARN Session 一样,刚开始所有的资源都是 0,随着作业的提交,Flink 会动态地向 Kubernetes 申请更多资源。 -我们继续使用`bin/flink`向这个Session集群中提交作业。 +我们继续使用 `bin/flink` 向这个 Session 集群中提交作业。 ```bash $ ./bin/flink run -d -e kubernetes-session \ -Dkubernetes.cluster-id= examples/streaming/WindowJoin.jar ``` -可以使用下面的命令关停这个Flink Kubernetes Session集群。 +可以使用下面的命令关停这个 Flink Kubernetes Session 集群。 ```bash $ echo 'stop' | ./bin/kubernetes-session.sh \ @@ -151,7 +180,7 @@ $ echo 'stop' | ./bin/kubernetes-session.sh \ -Dexecution.attached=true ``` -原生Kubernetes也有Application模式,Kubernetes Application模式与YARN Application模式类似。使用时,需要先将作业打成JAR包,放到Docker镜像中,代码如下。 +原生 Kubernetes 也有 Application 模式,Kubernetes Application 模式与 YARN Application 模式类似。使用时,需要先将作业打成 JAR 包,放到 Docker 镜像中,代码如下。 ```dockerfile FROM flink @@ -171,4 +200,4 @@ $ ./bin/flink run-application -p 8 -t kubernetes-application \ local:///opt/flink/usrlib/my-flink-job.jar ``` -其中,`-Dkubernetes.container.image`用来配置自定义的镜像,`local:///opt/flink/usrlib/my-flink-job.jar`表示JAR包在镜像中的位置。 +其中,`-Dkubernetes.container.image` 用来配置自定义的镜像,`local:///opt/flink/usrlib/my-flink-job.jar` 表示 JAR 包在镜像中的位置。 diff --git a/doc/ch-deployment-and-configuration/hadoop-flink.md b/doc/ch-deployment-and-configuration/hadoop-flink.md index 5db9cbd..49b4732 100644 --- a/doc/ch-deployment-and-configuration/hadoop-flink.md +++ b/doc/ch-deployment-and-configuration/hadoop-flink.md @@ -1,58 +1,58 @@ (hadoop-flink)= -# 与Hadoop集成 +# 与 Hadoop 集成 -Flink可以和Hadoop生态圈的组件紧密结合,比如9.1节中提到,Flink可以使用YARN作为资源调度器,或者读取HDFS、HBase中的数据。在使用Hadoop前,我们需要确认已经安装了Hadoop,并配置了环境变量`HADOOP_CONF_DIR`,如下环境变量配置是Hadoop安装过程所必需的。 +Flink 可以和 Hadoop 生态圈的组件紧密结合,比如 9.1 节中提到,Flink 可以使用 YARN 作为资源调度器,或者读取 HDFS、HBase 中的数据。在使用 Hadoop 前,我们需要确认已经安装了 Hadoop,并配置了环境变量 `HADOOP_CONF_DIR`,如下环境变量配置是 Hadoop 安装过程所必需的。 ```bash HADOOP_CONF_DIR=/path/to/etc/hadoop ``` -此外,Flink与Hadoop集成时,需要将Hadoop的依赖包添加到Flink中,或者说让Flink能够获取到Hadoop类。比如,使用`bin/yarn-session.sh`启动一个Flink YARN Session时,如果没有设置Hadoop依赖,将会出现下面的报错。 +此外,Flink 与 Hadoop 集成时,需要将 Hadoop 的依赖包添加到 Flink 中,或者说让 Flink 能够获取到 Hadoop 类。比如,使用 `bin/yarn-session.sh` 启动一个 Flink YARN Session 时,如果没有设置 Hadoop 依赖,将会出现下面的报错。 ```java java.lang.ClassNotFoundException: org.apache.hadoop.yarn.exceptions.YarnException ``` -这是因为Flink源码中引用了Hadoop YARN的代码,但是在Flink官网提供的Flink下载包中,新版本的Flink已经不提供Hadoop集成,或者说,Hadoop相关依赖包不会放入Flink包中。Flink将Hadoop剔除的主要原因是Hadoop发布和构建的时间过长,不利于Flink的迭代。Flink鼓励用户自己根据需要引入Hadoop依赖包,具体有如下两种方式。 +这是因为 Flink 源码中引用了 Hadoop YARN 的代码,但是在 Flink 官网提供的 Flink 下载包中,新版本的 Flink 已经不提供 Hadoop 集成,或者说,Hadoop 相关依赖包不会放入 Flink 包中。Flink 将 Hadoop 剔除的主要原因是 Hadoop 发布和构建的时间过长,不利于 Flink 的迭代。Flink 鼓励用户自己根据需要引入 Hadoop 依赖包,具体有如下两种方式。 -1. 在环境变量中添加Hadoop Classpath,Flink从Hadoop Classpath中读取所需依赖包。 -2. 将所需的Hadoop 依赖包添加到Flink主目录下的lib目录中。 +1. 在环境变量中添加 Hadoop Classpath,Flink 从 Hadoop Classpath 中读取所需依赖包。 +2. 将所需的 Hadoop 依赖包添加到 Flink 主目录下的 lib 目录中。 -## 9.5.1 添加Hadoop Classpath +## 添加 Hadoop Classpath -Flink使用环境变量`$HADOOP_CLASSPATH`来存储Hadoop相关依赖包的路径,或者说,`$HADOOP_CLASSPATH`中的路径会添加到`-classpath`参数中。很多Hadoop发行版以及一些云环境默认情况下并不会设置这个变量,因此,执行Hadoop的各节点应该在其环境变量中设置`$HADOOP_CLASSPATH`。 +Flink 使用环境变量 `$HADOOP_CLASSPATH` 来存储 Hadoop 相关依赖包的路径,或者说,`$HADOOP_CLASSPATH` 中的路径会添加到 `-classpath` 参数中。很多 Hadoop 发行版以及一些云环境默认情况下并不会设置这个变量,因此,执行 Hadoop 的各节点应该在其环境变量中设置 `$HADOOP_CLASSPATH`。 ```bash export HADOOP_CLASSPATH=`hadoop classpath` ``` -上面的命令中,`hadoop`是Hadoop提供的二进制命令工具,使用前必须保证`hadoop`命令添加到了环境变量`$PATH`中,`classpath`是`hadoop`命令的一个参数选项。`hadoop classpath`可以返回Hadoop所有相关的依赖包,将这些路径输出。如果在一台安装了Hadoop的节点上执行`hadoop classpath`,下面是部分返回结果。 +上面的命令中,`hadoop` 是 Hadoop 提供的二进制命令工具,使用前必须保证 `hadoop` 命令添加到了环境变量 `$PATH` 中,`classpath` 是 `hadoop` 命令的一个参数选项。`hadoop classpath` 可以返回 Hadoop 所有相关的依赖包,将这些路径输出。如果在一台安装了 Hadoop 的节点上执行 `hadoop classpath`,下面是部分返回结果。 ```plaintext /path/to/hadoop/etc/hadoop:/path/to/hadoop/share/hadoop/common/lib/*:/path/to/hadoop/share/hadoop/yarn/lib/*:... ``` -Flink启动时,会从`$HADOOP_CLASSPATH`中寻找所需依赖包。这些依赖包来自节点所安装的Hadoop,也就是说Flink可以和已经安装的Hadoop紧密结合起来。但Hadoop的依赖错综复杂,Flink所需要的依赖和Hadoop提供的依赖有可能发生冲突。 -该方式只需要设置`$HADOOP_CLASSPATH`,简单快捷,缺点是有依赖冲突的风险。 +Flink 启动时,会从 `$HADOOP_CLASSPATH` 中寻找所需依赖包。这些依赖包来自节点所安装的 Hadoop,也就是说 Flink 可以和已经安装的 Hadoop 紧密结合起来。但 Hadoop 的依赖错综复杂,Flink 所需要的依赖和 Hadoop 提供的依赖有可能发生冲突。 +该方式只需要设置 `$HADOOP_CLASSPATH`,简单快捷,缺点是有依赖冲突的风险。 -## 9.5.2 将Hadoop依赖包添加到lib目录中 +## 将 Hadoop 依赖包添加到 lib 目录中 -Flink主目录下有一个`lib`目录,专门存放各类第三方的依赖包。Flink程序启动时,会将`lib`目录加载到Classpath中。我们可以将所需的Hadoop 依赖包添加到`lib`目录中。具体有两种获取Hadoop 依赖包的方式:一种是从Flink官网下载预打包的Hadoop依赖包,一种是从源码编译。 +Flink 主目录下有一个 `lib` 目录,专门存放各类第三方的依赖包。Flink 程序启动时,会将 `lib` 目录加载到 Classpath 中。我们可以将所需的 Hadoop 依赖包添加到 `lib` 目录中。具体有两种获取 Hadoop 依赖包的方式:一种是从 Flink 官网下载预打包的 Hadoop 依赖包,一种是从源码编译。 -Flink社区帮忙编译生成了常用Hadoop版本的Flink依赖包,比如Hadoop 2.8.3、Hadoop 2.7.5等,使用这些Hadoop版本的用户可以直接下载这些依赖包,并放置到`lib`目录中。例如,Hadoop 2.8.3的用户可以下载`flink-shaded-Hadoop-2-uber-2.8.3-10.0.jar`,将这个依赖包添加到Flink主目录下的`lib`目录中。 +Flink 社区帮忙编译生成了常用 Hadoop 版本的 Flink 依赖包,比如 Hadoop 2.8.3、Hadoop 2.7.5 等,使用这些 Hadoop 版本的用户可以直接下载这些依赖包,并放置到 `lib` 目录中。例如,Hadoop 2.8.3 的用户可以下载 `flink-shaded-Hadoop-2-uber-2.8.3-10.0.jar`,将这个依赖包添加到 Flink 主目录下的 `lib` 目录中。 -如果用户使用的Hadoop版本比较特殊,不在下载列表里,比如是Cloudera等厂商发行的Hadoop,用户需要自己下载`flink-shaded`工程源码,基于源码和自己的Hadoop版本自行编译生成依赖包。编译命令如下。 +如果用户使用的 Hadoop 版本比较特殊,不在下载列表里,比如是 Cloudera 等厂商发行的 Hadoop,用户需要自己下载 `flink-shaded` 工程源码,基于源码和自己的 Hadoop 版本自行编译生成依赖包。编译命令如下。 ```bash $ mvn clean install -Dhadoop.version=2.6.1 ``` -上面的命令编译了针对Hadoop 2.6.1的`flink-shaded`工程。编译完成后,将名为`flink-shaded-hadoop-2-uber`的依赖包添加到Flink主目录的`lib`目录中。 -该方式没有依赖冲突的风险,但源码编译需要用户对Maven和Hadoop都有一定的了解。 +上面的命令编译了针对 Hadoop 2.6.1 的 `flink-shaded` 工程。编译完成后,将名为 `flink-shaded-hadoop-2-uber` 的依赖包添加到 Flink 主目录的 `lib` 目录中。 +该方式没有依赖冲突的风险,但源码编译需要用户对 Maven 和 Hadoop 都有一定的了解。 -## 9.5.3 本地调试 +## 本地调试 -9.5.1小节和9.5.2小节介绍的是针对Flink集群的Hadoop依赖设置方式,如果我们仅想在本地的IntelliJ IDEA里调试Flink Hadoop相关的程序,我们可以将下面的Maven依赖添加到`pom.xml`中。 +9.5.1 小节和 9.5.2 小节介绍的是针对 Flink 集群的 Hadoop 依赖设置方式,如果我们仅想在本地的 IntelliJ IDEA 里调试 Flink Hadoop 相关的程序,我们可以将下面的 Maven 依赖添加到 `pom.xml` 中。 ```xml diff --git a/doc/ch-deployment-and-configuration/index.md b/doc/ch-deployment-and-configuration/index.md index 4b1b6fa..07f462c 100644 --- a/doc/ch-deployment-and-configuration/index.md +++ b/doc/ch-deployment-and-configuration/index.md @@ -1,6 +1,6 @@ -# Flink的部署和配置 +# Flink 的部署和配置 -通过对前文的学习,我们已经学习了如何编写Flink程序,包括使用DataStream API和使用Table API & SQL来编写程序。本章将重点介绍如何部署和配置Flink作业,主要内容如下: +通过对前文的学习,我们已经学习了如何编写 Flink 程序,包括使用 DataStream API 和使用 Table API & SQL 来编写程序。本章将重点介绍如何部署和配置 Flink 作业,主要内容如下: ```{tableofcontents} ``` \ No newline at end of file diff --git a/doc/ch-deployment-and-configuration/operator-chaining-and-slot-sharing.md b/doc/ch-deployment-and-configuration/operator-chaining-and-slot-sharing.md index 9a1fc9e..526d968 100644 --- a/doc/ch-deployment-and-configuration/operator-chaining-and-slot-sharing.md +++ b/doc/ch-deployment-and-configuration/operator-chaining-and-slot-sharing.md @@ -1,17 +1,19 @@ (operator-chaining-and-slot-sharing)= # 算子链与槽位共享 -在第3章中我们曾介绍了算子链和槽位共享的概念。默认情况下,这两个功能都是开启的。 +在第 3 章中我们曾介绍了算子链和槽位共享的概念。默认情况下,这两个功能都是开启的。 -## 9.3.1 设置算子链 +## 设置算子链 -Flink会使用算子链将尽可能多的上、下游算子链接到一起,链接到一起的上、下游算子会被捆绑到一起,作为一个线程执行。假如两个算子不进行链接,那么这两个算子间的数据通信存在序列化和反序列化,通信成本较高,所以说算子链可以在一定程度上提高资源利用率。 +Flink 会使用算子链将尽可能多的上、下游算子链接到一起,链接到一起的上、下游算子会被捆绑到一起,作为一个线程执行。假如两个算子不进行链接,那么这两个算子间的数据通信存在序列化和反序列化,通信成本较高,所以说算子链可以在一定程度上提高资源利用率。 -**注意** +:::{note} -Flink无法把所有算子都链接到一起。上游算子将所有数据前向传播到下游算子上,数据不进行任何交换,那么这两个算子可以被链接到一起。比如,先进行`filter()`,再进行`map()`,这两个算子可以被链接到一起。Flink源码`org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator`中的`isChainable()`方法定义了何种情况可以进行链接,感兴趣的读者可以阅读一下相关代码。 +Flink 无法把所有算子都链接到一起。上游算子将所有数据前向传播到下游算子上,数据不进行任何交换,那么这两个算子可以被链接到一起。比如,先进行 `filter()`,再进行 `map()`,这两个算子可以被链接到一起。Flink 源码 `org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator` 中的 `isChainable()` 方法定义了何种情况可以进行链接,感兴趣的读者可以阅读一下相关代码。 -另外一些情况下,算子不适合链接在一起,比如两个算子的负载都很高,这时候应该让两个算子拆分到不同的Slot上执行。下面的代码从整个执行环境层面关闭了算子链。 +::: + +另外一些情况下,算子不适合链接在一起,比如两个算子的负载都很高,这时候应该让两个算子拆分到不同的 Slot 上执行。下面的代码从整个执行环境层面关闭了算子链。 ```java StreamExecutionEnvironment env = ... @@ -19,7 +21,7 @@ StreamExecutionEnvironment env = ... env.disableOperatorChaining(); ``` -关闭算子链之后,我们可以使用`startNewChain()`方法,根据需要对特定的算子进行链接。 +关闭算子链之后,我们可以使用 `startNewChain()` 方法,根据需要对特定的算子进行链接。 ```java DataStream result = input @@ -30,8 +32,8 @@ DataStream result = input .filter(new Filter2()); ``` -上面的例子中,`Filter1`和`Map1`被链接到了一起,`Map2`和`Filter2`被链接到了一起。 -也可以使用`disableChaining()`方法,对当前算子禁用算子链。 +上面的例子中,`Filter1` 和 `Map1` 被链接到了一起,`Map2` 和 `Filter2` 被链接到了一起。 +也可以使用 `disableChaining()` 方法,对当前算子禁用算子链。 ```java DataStream result = input @@ -41,22 +43,36 @@ DataStream result = input .map(new Map2()).disableChaining(); ``` -上面的例子中,`Filter1`和`Map1`被链接到了一起,`Map2`被分离出来。 +上面的例子中,`Filter1` 和 `Map1` 被链接到了一起,`Map2` 被分离出来。 -## 9.3.2 设置槽位共享 +## 设置槽位共享 -第3章中我们提到,Flink默认开启了槽位共享,从Source到Sink的所有算子子任务可以共享一个Slot,共享计算资源。或者说,从Source到Sink的所有算子子任务组成的Pipeline共享一个Slot。我们仍然以第3章使用的WordCount程序为例,整个TaskManager下有4个Slot,我们设置作业的并行度为2,其作业的执行情况如图9-7所示。可以看到,一个Slot中包含了从Source到Sink的整个Pipeline。图9-7中Source和FlatMap两个算子被放在一起是因为默认开启了算子链。 +第 3 章中我们提到,Flink 默认开启了槽位共享,从 Source 到 Sink 的所有算子子任务可以共享一个 Slot,共享计算资源。或者说,从 Source 到 Sink 的所有算子子任务组成的 Pipeline 共享一个 Slot。我们仍然以第 3 章使用的 WordCount 程序为例,整个 TaskManager 下有 4 个 Slot,我们设置作业的并行度为 2,其作业的执行情况如图 9-7 所示。可以看到,一个 Slot 中包含了从 Source 到 Sink 的整个 Pipeline。{numref}`fig-default-slot-sharing-pipeline` 中 Source 和 FlatMap 两个算子被放在一起是因为默认开启了算子链。 -![图9-7 默认情况下,槽位共享使得整个算子的Pipeline可以放在一个Slot中执行](./img/default-slot-sharing-pipeline.png) +```{figure} ./img/default-slot-sharing-pipeline.png +--- +name: fig-default-slot-sharing-pipeline +width: 80% +align: center +--- +默认情况下,槽位共享使得整个算子的 Pipeline 可以放在一个 Slot 中执行 +``` -跟算子链一样,过多的计算任务集中在一个Slot,有可能导致该Slot的负载过大。每个算子都有一个槽位共享组(Slot Sharing Group)。默认情况下,算子都会被分到`default`组中,也就意味着在最终的物理执行图中,从Source到Sink上、下游的算子子任务可以共享一个Slot。我们可以用`slotSharingGroup(String)`方法将某个算子分到特定的组中。例如,下面的代码把WordCount程序中的`WindowAggregation`算子划分到名为`A`的组中。 +跟算子链一样,过多的计算任务集中在一个 Slot,有可能导致该 Slot 的负载过大。每个算子都有一个槽位共享组(Slot Sharing Group)。默认情况下,算子都会被分到 `default` 组中,也就意味着在最终的物理执行图中,从 Source 到 Sink 上、下游的算子子任务可以共享一个 Slot。我们可以用 `slotSharingGroup(String)` 方法将某个算子分到特定的组中。例如,下面的代码把 WordCount 程序中的 `WindowAggregation` 算子划分到名为 `A` 的组中。 ```java stream.timeWindow(...).sum(...).slotSharingGroup("A"); ``` -图9-8 展示了这个作业的执行情况,Window Aggregation和Sink都被划分到另外的Slot里执行。这里需要注意的是,我们没有明确给Sink设置Slot Sharing Group,Sink继承了前序算子(Window Aggregation)的Slot Sharing Group,与之一起划分到同一组。 +{numref}`fig-window-aggregation-slot-sharing-group` 展示了这个作业的执行情况,Window Aggregation 和 Sink 都被划分到另外的 Slot 里执行。这里需要注意的是,我们没有明确给 Sink 设置 Slot Sharing Group,Sink 继承了前序算子(Window Aggregation)的 Slot Sharing Group,与之一起划分到同一组。 -第3章中我们提到,未开启算子链和槽位共享的情况下,一个算子子任务应该占用一个Slot。算子链和槽位共享可以让更多算子子任务共享一个Slot。默认情况下算子链和槽位共享是开启的,所以可以让图9-7中所示的从Source到Sink的Pipeline都共享一个Slot。如果一个作业的并行度为`parallelism`,该作业至少需要个数为`parallelism`的Slot。自定义算子链和槽位共享会打断算子子任务之间的共享,当然也会使该作业所需要的Slot数量大于`parallelism`。 +第 3 章中我们提到,未开启算子链和槽位共享的情况下,一个算子子任务应该占用一个 Slot。算子链和槽位共享可以让更多算子子任务共享一个 Slot。默认情况下算子链和槽位共享是开启的,所以可以让图 9-7 中所示的从 Source 到 Sink 的 Pipeline 都共享一个 Slot。如果一个作业的并行度为 `parallelism`,该作业至少需要个数为 `parallelism` 的 Slot。自定义算子链和槽位共享会打断算子子任务之间的共享,当然也会使该作业所需要的 Slot 数量大于 `parallelism`。 -![图9-8 给Window Aggreagtion设置Slot Sharing Group后,该算子及之后的算子被划分到其他Slot](./img/window-aggregation-slot-sharing-group.png) \ No newline at end of file +```{figure} ./img/window-aggregation-slot-sharing-group.png +--- +name: fig-window-aggregation-slot-sharing-group +width: 80% +align: center +--- +给 Window Aggregation 设置 Slot Sharing Group 后,该算子及之后的算子被划分到其他 Slot +``` \ No newline at end of file diff --git a/doc/ch-flink-connectors/Exactly-Once-guarantee.md b/doc/ch-flink-connectors/Exactly-Once-guarantee.md index 1c6e99c..4a986fb 100644 --- a/doc/ch-flink-connectors/Exactly-Once-guarantee.md +++ b/doc/ch-flink-connectors/Exactly-Once-guarantee.md @@ -1,36 +1,50 @@ (Exactly-Once-guarantee)= -# Flink端到端的Exactly-Once保障 +# Flink 端到端的 Exactly-Once 保障 -## 7.1.1 故障恢复与一致性保障 +## 故障恢复与一致性保障 -在流处理系统中,确保每条数据只被处理一次(Exactly-Once)是一种理想情况。然而,现实中的系统经常因各种意外因素发生故障,如流量激增、网络抖动等。Flink通过重启作业、读取Checkpoint数据、恢复状态和重新执行计算来处理这些故障。 +在流处理系统中,确保每条数据只被处理一次(Exactly-Once)是一种理想情况。然而,现实中的系统经常因各种意外因素发生故障,如流量激增、网络抖动等。Flink 通过重启作业、读取 Checkpoint 数据、恢复状态和重新执行计算来处理这些故障。 -Checkpoint和故障恢复过程保证了内部状态的一致性,但可能导致数据重发。如图7-1所示,假设最近一次Checkpoint的时间戳是3,系统在时间戳10处发生故障。在3到10之间处理的数据(如时间戳5和8的数据)需要重新处理。 +Checkpoint 和故障恢复过程保证了内部状态的一致性,但可能导致数据重发。如 {numref}`fig-data-redundancy-issues` 所示,假设最近一次 Checkpoint 的时间戳是 3,系统在时间戳 10 处发生故障。在 3 到 10 之间处理的数据(如时间戳 5 和 8 的数据)需要重新处理。 -Flink的Checkpoint过程保证了作业内部的数据一致性,主要通过备份以下两类数据: +Flink 的 Checkpoint 过程保证了作业内部的数据一致性,主要通过备份以下两类数据: 1. 作业中每个算子的状态。 -2. 输入数据的偏移量Offset。 +2. 输入数据的偏移量 Offset。 -![图7-1 Checkpoint和故障恢复过程会有数据重发问题](./img/data-redundancy-issues.png) +```{figure} ./img/data-redundancy-issues.png +--- +name: fig-data-redundancy-issues +width: 80% +align: center +--- +Checkpoint 和故障恢复过程会有数据重发问题 +``` -数据重发类似于观看直播比赛的重播(Replay),但这可能导致时间戳3至10之间的数据被重发,从而引发At-Least-Once问题。为了实现端到端的Exactly-Once保障,需要依赖Source的重发功能和Sink的幂等写或事务写。 +数据重发类似于观看直播比赛的重播(Replay),但这可能导致时间戳 3 至 10 之间的数据被重发,从而引发 At-Least-Once 问题。为了实现端到端的 Exactly-Once 保障,需要依赖 Source 的重发功能和 Sink 的幂等写或事务写。 -## 7.1.2 幂等写 +## 幂等写 -幂等写(Idempotent Write)是指多次向系统写入数据只产生一次结果影响。例如,向HashMap插入同一个(Key, Value)二元组,只有第一次插入会改变HashMap,后续插入不会改变结果。 +幂等写(Idempotent Write)是指多次向系统写入数据只产生一次结果影响。例如,向 HashMap 插入同一个 (Key, Value) 二元组,只有第一次插入会改变 HashMap,后续插入不会改变结果。 -Key-Value数据库如Cassandra、HBase和Redis常作为Sink来实现端到端的Exactly-Once保障。但幂等写要求(Key, Value)必须是确定性计算的。例如,如果Key是`name + curTimestamp`,每次重发时生成的Key不同,导致多次结果。如果Key是`name + eventTimestamp`,则即使重发,Key也是确定的。 +Key-Value 数据库如 Cassandra、HBase 和 Redis 常作为 Sink 来实现端到端的 Exactly-Once 保障。但幂等写要求 (Key, Value) 必须是确定性计算的。例如,如果 Key 是 `name + curTimestamp`,每次重发时生成的 Key 不同,导致多次结果。如果 Key 是 `name + eventTimestamp`,则即使重发,Key 也是确定的。 -Key-Value数据库作为Sink可能遇到时间闪回问题。例如,重启后,之前提交的数据可能被错误地认为是新的操作,导致数据不一致。只有当所有数据重发完成后,数据才恢复一致性。 +Key-Value 数据库作为 Sink 可能遇到时间闪回问题。例如,重启后,之前提交的数据可能被错误地认为是新的操作,导致数据不一致。只有当所有数据重发完成后,数据才恢复一致性。 -## 7.1.3 事务写 +## 事务写 -事务(Transaction)是数据库系统解决的核心问题。Flink借鉴了数据库中的事务处理技术,结合Checkpoint机制来保证Sink只对外部输出产生一次影响。 +事务(Transaction)是数据库系统解决的核心问题。Flink 借鉴了数据库中的事务处理技术,结合 Checkpoint 机制来保证 Sink 只对外部输出产生一次影响。 -Flink的事务写(Transaction Write)是指,Flink先将待输出的数据保存,暂时不提交到外部系统;等到Checkpoint结束,所有算子的数据一致时,再将之前保存的数据提交到外部系统。如图7-2所示,使用事务写可以避免时间戳5的数据多次产生输出并提交到外部系统。 +Flink 的事务写(Transaction Write)是指,Flink 先将待输出的数据保存,暂时不提交到外部系统;等到 Checkpoint 结束,所有算子的数据一致时,再将之前保存的数据提交到外部系统。如 {numref}`fig-transactional-write` 所示,使用事务写可以避免时间戳 5 的数据多次产生输出并提交到外部系统。 -![图7-2 Flink的事务写](./img/transactional-write.png) +```{figure} ./img/transactional-write.png +--- +name: fig-transactional-write +width: 80% +align: center +--- +Flink 的事务写 +``` -Flink提供了两种事务写实现方式:预写日志(Write-Ahead-Log,WAL)和两阶段提交(Two-Phase-Commit,2PC)。这两种方式的主要区别在于:WAL方式使用Operator State缓存待输出的数据;如果外部系统支持事务(如Kafka),可以使用2PC方式,待输出数据被缓存在外部系统。 +Flink 提供了两种事务写实现方式:预写日志(Write-Ahead-Log,WAL)和两阶段提交(Two-Phase-Commit,2PC)。这两种方式的主要区别在于:WAL 方式使用 Operator State 缓存待输出的数据;如果外部系统支持事务(如 Kafka),可以使用 2PC 方式,待输出数据被缓存在外部系统。 -事务写能提供端到端的Exactly-Once保障,但牺牲了延迟,因为输出数据不再实时写入外部系统,而是分批次提交。开发者需要权衡不同需求。 \ No newline at end of file +事务写能提供端到端的 Exactly-Once 保障,但牺牲了延迟,因为输出数据不再实时写入外部系统,而是分批次提交。开发者需要权衡不同需求。 \ No newline at end of file diff --git a/doc/ch-flink-connectors/custom-source-and-sink.md b/doc/ch-flink-connectors/custom-source-and-sink.md index ab1541e..0447fbb 100644 --- a/doc/ch-flink-connectors/custom-source-and-sink.md +++ b/doc/ch-flink-connectors/custom-source-and-sink.md @@ -1,25 +1,25 @@ (custom-source-and-sink)= -# 自定义Source和Sink +# 自定义 Source 和 Sink -本节将从原理和实现两个方面来介绍Flink的Source和Sink。 +本节将从原理和实现两个方面来介绍 Flink 的 Source 和 Sink。 -## 7.2.1 Flink 1.11之前的Source +## Flink 1.11 之前的 Source -Flink 1.11重构了Source接口,是一个非常大的改动,新的Source接口提出了一些新的概念,在使用方式上与老Source接口有较大区别。这里将先重点介绍老的Source接口,因为老的Source接口更易于理解和实现,之后会简单介绍新的Source接口的原理。 +Flink 1.11 重构了 Source 接口,是一个非常大的改动,新的 Source 接口提出了一些新的概念,在使用方式上与老 Source 接口有较大区别。这里将先重点介绍老的 Source 接口,因为老的 Source 接口更易于理解和实现,之后会简单介绍新的 Source 接口的原理。 -### 实现SourceFunction +### 实现 SourceFunction -在本书提供的示例程序中曾大量使用各类自定义的Source,Flink提供了自定义Source的公开接口:SourceFunction的接口和RichSourceFunction的Rich函数类。自定义Source时必须实现两个方法。 +在本书提供的示例程序中曾大量使用各类自定义的 Source,Flink 提供了自定义 Source 的公开接口:SourceFunction 的接口和 RichSourceFunction 的 Rich 函数类。自定义 Source 时必须实现两个方法。 ```java -// Source启动后调用run()方法,生成数据并将其向下游发送 +// Source 启动后调用 run() 方法,生成数据并将其向下游发送 void run(SourceContext ctx) throws Exception; // 停止 void cancel(); ``` -run()方法在Source启动后开始执行,一般都会在方法中使用循环,在循环内不断向下游发送数据,发送数据时使用SourceContext.collect()方法。cancel()方法停止向下游继续发送数据。由于run()方法内一般会使用循环,可以使用一个boolean类型的标志位来标记Source是否在执行。当停止Source时,也要修改这个标志位。代码清单 7-1自定义Source,从0开始计数,将数字发送到下游。 +run()方法在 Source 启动后开始执行,一般都会在方法中使用循环,在循环内不断向下游发送数据,发送数据时使用 SourceContext.collect() 方法。cancel()方法停止向下游继续发送数据。由于 run() 方法内一般会使用循环,可以使用一个 boolean 类型的标志位来标记 Source 是否在执行。当停止 Source 时,也要修改这个标志位。代码清单 7-1 自定义 Source,从 0 开始计数,将数字发送到下游。 ```java private static class SimpleSource @@ -47,22 +47,22 @@ implements SourceFunction> { } ``` -在主逻辑中调用这个Source。 +在主逻辑中调用这个 Source。 ```java DataStream> countStream = env.addSource(new SimpleSource()); ``` -与第4章中介绍的DataStream API类似,RichSourceFunction提供了RuntimeContext,以及增加了open()方法用来初始化资源,close()方法用来关闭资源。RuntimeContext指运行时上下文,包括并行度、监控项MetricGroup等。比如,我们可以使用getRuntimeContext().getIndexOfThisSubtask()获取当前子任务是多个并行子任务中的哪一个。 +与第 4 章中介绍的 DataStream API 类似,RichSourceFunction 提供了 RuntimeContext,以及增加了 open()方法用来初始化资源,close() 方法用来关闭资源。RuntimeContext 指运行时上下文,包括并行度、监控项 MetricGroup 等。比如,我们可以使用 getRuntimeContext().getIndexOfThisSubtask() 获取当前子任务是多个并行子任务中的哪一个。 -### 可恢复的Source +### 可恢复的 Source -对于代码清单7-1所示的示例中,假如遇到故障,整个作业重启,Source每次从0开始,没有记录遇到故障前的任何信息,所以它不是一个可恢复的Source。我们在7.1节中讨论过,Source需要支持数据重发才能支持端到端的Exactly-Once保障。如果想支持数据重发,需要满足如下两点。 +对于代码清单 7-1 所示的示例中,假如遇到故障,整个作业重启,Source 每次从 0 开始,没有记录遇到故障前的任何信息,所以它不是一个可恢复的 Source。我们在 7.1 节中讨论过,Source 需要支持数据重发才能支持端到端的 Exactly-Once 保障。如果想支持数据重发,需要满足如下两点。 -1. Flink开启Checkpoint机制,Source将数据Offset定期写到Checkpoint中。作业重启后,Flink Source从最近一次的Checkpoint中恢复Offset数据。 -2. Flink所连接的上游系统支持从某个Offset开始重发数据。如果上游是Kafka,它是支持Offset重发的。如果上游是一个文件系统,读取文件时可以直接跳到Offset所在的位置,从该位置重新读取数据。 +1. Flink 开启 Checkpoint 机制,Source 将数据 Offset 定期写到 Checkpoint 中。作业重启后,Flink Source 从最近一次的 Checkpoint 中恢复 Offset 数据。 +2. Flink 所连接的上游系统支持从某个 Offset 开始重发数据。如果上游是 Kafka,它是支持 Offset 重发的。如果上游是一个文件系统,读取文件时可以直接跳到 Offset 所在的位置,从该位置重新读取数据。 -在第6章中我们曾详细讨论Flink的Checkpoint机制,其中提到Operator State经常用来在Source或Sink中记录Offset。我们在代码清单7-1的基础上做了一些修改,让整个Source能够支持Checkpoint,即使遇到故障,也可以根据最近一次Checkpoint中的数据进行恢复,如代码清单 7-2所示。 +在第 6 章中我们曾详细讨论 Flink 的 Checkpoint 机制,其中提到 Operator State 经常用来在 Source 或 Sink 中记录 Offset。我们在代码清单 7-1 的基础上做了一些修改,让整个 Source 能够支持 Checkpoint,即使遇到故障,也可以根据最近一次 Checkpoint 中的数据进行恢复,如代码清单 7-2 所示。 ```java private static class CheckpointedSource @@ -77,7 +77,7 @@ private static class CheckpointedSource public void run(SourceContext> ctx) throws Exception { while (isRunning) { Thread.sleep(100); - // 使用同步锁,当触发某次Checkpoint时,不向下游发送数据 + // 使用同步锁,当触发某次 Checkpoint 时,不向下游发送数据 synchronized (ctx.getCheckpointLock()) { ctx.collect(new Tuple2<>("" + offset, 1)); offset++; @@ -98,13 +98,13 @@ private static class CheckpointedSource Exception { // 清除上次状态 offsetState.clear(); - // 将最新的Offset添加到状态中 + // 将最新的 Offset 添加到状态中 offsetState.add(offset); } @Override public void initializeState(FunctionInitializationContext initializationContext) throws Exception { - // 初始化offsetState + // 初始化 offsetState ListStateDescriptor desc = new ListStateDescriptor("offset", Types.INT); offsetState = @@ -112,32 +112,32 @@ initializationContext.getOperatorStateStore().getListState(desc); Iterable iter = offsetState.get(); if (iter == null || !iter.iterator().hasNext()) { - // 第一次初始化,从0开始计数 + // 第一次初始化,从 0 开始计数 offset = 0; } else { - // 从状态中恢复Offset + // 从状态中恢复 Offset offset = iter.iterator().next(); } } } ``` -代码清单 7-2继承并实现了CheckpointedFunction,可以使用Operator State。整个作业第一次执行时,Flink会调用initializeState()方法,offset被设置为0,之后每隔一定时间触发一次Checkpoint,触发Checkpoint时会调用snapshotState()方法来更新状态到State Backend。如果遇到故障,重启后会从offsetState状态中恢复上次保存的Offset。 +代码清单 7-2 继承并实现了 CheckpointedFunction,可以使用 Operator State。整个作业第一次执行时,Flink 会调用 initializeState()方法,offset 被设置为 0,之后每隔一定时间触发一次 Checkpoint,触发 Checkpoint 时会调用 snapshotState() 方法来更新状态到 State Backend。如果遇到故障,重启后会从 offsetState 状态中恢复上次保存的 Offset。 -在run()方法中,我们增加了一个同步锁ctx.getCheckpointLock(),是为了当触发这次Checkpoint时,不向下游发送数据。或者说,等本次Checkpoint触发结束,snapshotState()方法执行完,再继续向下游发送数据。如果没有这个步骤,有可能会导致run()方法中Offset和snapshotState()方法中Checkpoint的Offset不一致。 +在 run()方法中,我们增加了一个同步锁 ctx.getCheckpointLock(),是为了当触发这次 Checkpoint 时,不向下游发送数据。或者说,等本次 Checkpoint 触发结束,snapshotState()方法执行完,再继续向下游发送数据。如果没有这个步骤,有可能会导致 run() 方法中 Offset 和 snapshotState() 方法中 Checkpoint 的 Offset 不一致。 -需要注意的是,主逻辑中需要开启Checkpoint机制,如代码清单 7-3所示。 +需要注意的是,主逻辑中需要开启 Checkpoint 机制,如代码清单 7-3 所示。 ```java public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); - // 访问 http://localhost:8082 可以看到Flink WebUI + // 访问 http://localhost:8082 可以看到 Flink WebUI conf.setInteger(RestOptions.PORT, 8082); - // 设置本地执行环境,并行度为1 + // 设置本地执行环境,并行度为 1 StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironment(1, conf); - // 每隔2秒触发一次Checkpoint + // 每隔 2 秒触发一次 Checkpoint env.getCheckpointConfig().setCheckpointInterval(2 * 1000); DataStream> countStream = env.addSource(new @@ -150,21 +150,21 @@ FailingMapper(20)); } ``` -上述代码使用FailingMapper模拟了一次故障。即使发生了故障,Flink仍然能自动重启,并从最近一次的Checkpoint数据中恢复状态。 +上述代码使用 FailingMapper 模拟了一次故障。即使发生了故障,Flink 仍然能自动重启,并从最近一次的 Checkpoint 数据中恢复状态。 -### 时间戳和Watermark +### 时间戳和 Watermark -在5.1.3小节,我们曾经介绍过如何设置一个基于Event Time数据流的时间戳和Watermark,其中一种办法就是在Source中设置。在自定义Source的过程中,SourceFunction.SourceContext提供了相应的方法。 +在 5.1.3 小节,我们曾经介绍过如何设置一个基于 Event Time 数据流的时间戳和 Watermark,其中一种办法就是在 Source 中设置。在自定义 Source 的过程中,SourceFunction.SourceContext 提供了相应的方法。 ```java -// 设置element的时间戳为timestamp,并将element发送出去 +// 设置 element 的时间戳为 timestamp,并将 element 发送出去 void collectWithTimestamp(T element, long timestamp); -// 发送一个Watermark +// 发送一个 Watermark void emitWatermark(Watermark mark); ``` -其中,SourceContext.collectWithTimestamp()是一种针对Event Time的发送数据的方法,它是SourceContext.collect()的一种特例。比如,我们可以将计数器Source中的run()方法修改如下。 +其中,SourceContext.collectWithTimestamp()是一种针对 Event Time 的发送数据的方法,它是 SourceContext.collect() 的一种特例。比如,我们可以将计数器 Source 中的 run() 方法修改如下。 ```java @Override @@ -175,7 +175,7 @@ public void run(SourceContext> ctx) throws Exception { ctx.collectWithTimestamp(new Tuple2<>("" + offset, offset), System.currentTimeMillis()); offset++; - // 每隔一段时间,发送一个Watermark + // 每隔一段时间,发送一个 Watermark if (offset % 100 == 0) { ctx.emitWatermark(new Watermark(System.currentTimeMillis())); } @@ -186,15 +186,15 @@ System.currentTimeMillis()); } ``` -如果使用Event Time时间语义,越早设置时间戳和Watermark,越能保证整个作业在时间序列上的准确性和健壮性。 +如果使用 Event Time 时间语义,越早设置时间戳和 Watermark,越能保证整个作业在时间序列上的准确性和健壮性。 -我们在5.1.3小节也曾介绍过,对于Event Time时间语义,算子有一个Watermark对齐的过程,某些上游数据源没有数据,将导致下游算子一直等待,无法继续处理新数据。这时候要及时使用SourceContext.markAsTemporarilyIdle()方法将该Source标记为空闲。比如,在实现Flink Kafka Source时,源码如下。 +我们在 5.1.3 小节也曾介绍过,对于 Event Time 时间语义,算子有一个 Watermark 对齐的过程,某些上游数据源没有数据,将导致下游算子一直等待,无法继续处理新数据。这时候要及时使用 SourceContext.markAsTemporarilyIdle() 方法将该 Source 标记为空闲。比如,在实现 Flink Kafka Source 时,源码如下。 ```java public void run(SourceContext sourceContext) throws Exception { ... - // 如果当前Source没有数据,将当前Source标记为空闲 - // 如果当前Source发现有新数据流入,会自动回归活跃状态 + // 如果当前 Source 没有数据,将当前 Source 标记为空闲 + // 如果当前 Source 发现有新数据流入,会自动回归活跃状态 if (subscribedPartitionsToStartOffsets.isEmpty()) { sourceContext.markAsTemporarilyIdle(); } @@ -204,80 +204,101 @@ public void run(SourceContext sourceContext) throws Exception { ### 并行版本 -上面提到的Source都是并行度为1的版本,或者说启动后只有一个子任务在执行。如果需要在多个子任务上并行执行的Source,可以实现ParallelSourceFunction和RichParallelSourceFunction两个类。 +上面提到的 Source 都是并行度为 1 的版本,或者说启动后只有一个子任务在执行。如果需要在多个子任务上并行执行的 Source,可以实现 ParallelSourceFunction 和 RichParallelSourceFunction 两个类。 -## 7.2.2 Flink 1.11之后的Source +## 7.2.2 Flink 1.11 之后的 Source -仔细分析上面的Source接口,可以发现这样的设计只适合进行流处理,批处理需要另外的接口。Flink在1.11之后提出了一个新的Source接口,主要目的是统一流处理和批处理两大计算模式,提供更大规模并行处理的能力。新的Source接口仍然处于实验阶段,一些Connnector仍然基于老的Source接口来实现的,本书只介绍大概的原理,暂时不从代码层面做具体展示。相信在不久的未来,更多Connector将使用新的Source接口来实现。 +仔细分析上面的 Source 接口,可以发现这样的设计只适合进行流处理,批处理需要另外的接口。Flink 在 1.11 之后提出了一个新的 Source 接口,主要目的是统一流处理和批处理两大计算模式,提供更大规模并行处理的能力。新的 Source 接口仍然处于实验阶段,一些 Connnector 仍然基于老的 Source 接口来实现的,本书只介绍大概的原理,暂时不从代码层面做具体展示。相信在不久的未来,更多 Connector 将使用新的 Source 接口来实现。 -新的Source接口提出了3个重要组件。 +新的 Source 接口提出了 3 个重要组件。 -- **分片(Split)**:Split是将数据源切分后的一小部分。如果数据源是文件系统上的一个文件夹,Split可以是文件夹里的某个文件;如果数据源是一个Kafka数据流,Split可以是一个Kafka Partition。因为对数据源做了切分,Source就可以启动多个实例并行地读取。 -- **读取器(SourceReader)**:SourceReader负责Split的读取和处理,SourceReader运行在TaskManager上,可以分布式地并行运行。比如,某个SourceReader可以读取文件夹里的单个文件,多个SourceReader实例共同完成读取整个文件夹的任务。 -- **分片枚举器(SplitEnumerator)**:SplitEnumerator负责发现和分配Split。SplitEnumerator运行在JobManager上,它会读取数据源的元数据并构建Split,然后按照负载均衡策略将多个Split分配给多个SourceReader。 +- ** 分片(Split)**:Split 是将数据源切分后的一小部分。如果数据源是文件系统上的一个文件夹,Split 可以是文件夹里的某个文件;如果数据源是一个 Kafka 数据流,Split 可以是一个 Kafka Partition。因为对数据源做了切分,Source 就可以启动多个实例并行地读取。 +- ** 读取器(SourceReader)**:SourceReader 负责 Split 的读取和处理,SourceReader 运行在 TaskManager 上,可以分布式地并行运行。比如,某个 SourceReader 可以读取文件夹里的单个文件,多个 SourceReader 实例共同完成读取整个文件夹的任务。 +- ** 分片枚举器 (SplitEnumerator)**:SplitEnumerator 负责发现和分配 Split。SplitEnumerator 运行在 JobManager 上,它会读取数据源的元数据并构建 Split,然后按照负载均衡策略将多个 Split 分配给多个 SourceReader。 -图7-3展示了这3个组件之间的关系。其中,Master进程中的JobManager运行着SplitEnumerator,各个TaskManager中运行着SourceReader,SourceReader每次向SplitEnumerator请求Split,SplitEnumerator会分配Split给各个SourceReader。 +{numref}`fig-three-key-components` 展示了这 3 个组件之间的关系。其中,Master 进程中的 JobManager 运行着 SplitEnumerator,各个 TaskManager 中运行着 SourceReader,SourceReader 每次向 SplitEnumerator 请求 Split,SplitEnumerator 会分配 Split 给各个 SourceReader。 -![图7-3 新Source接口中的3个重要组件](./img/three-key-components.png) +```{figure} ./img/three-key-components.png +--- +name: fig-three-key-components +width: 80% +align: center +--- +新 Source 接口中的 3 个重要组件 +``` -## 7.2.3 自定义Sink +## 自定义 Sink -对于Sink,Flink提供的API为SinkFunction接口和RichSinkFunction函数类。使用时需要实现下面的虚方法。 +对于 Sink,Flink 提供的 API 为 SinkFunction 接口和 RichSinkFunction 函数类。使用时需要实现下面的虚方法。 ```java -// 每条数据到达Sink后都会调用invoke()方法,发送到下游外部系统 -// value为待输出数据 +// 每条数据到达 Sink 后都会调用 invoke() 方法,发送到下游外部系统 +// value 为待输出数据 void invoke(IN value, Context context) ``` -如7.1节所讨论的问题,如果想提供端到端的Exactly-Once保障,需要使用幂等写和事务写两种方式。 +如 7.1 节所讨论的问题,如果想提供端到端的 Exactly-Once 保障,需要使用幂等写和事务写两种方式。 ### 幂等写 -幂等写需要综合考虑业务系统的设计和下游外部系统的选型等多方面因素。数据流的一条数据经过Flink可能产生一到多次计算(因为故障恢复),但是最终输出的结果必须是可确定的,不能因为多次计算,导致一些变化。比如我们在前文中提到的,结果中使用系统当前时间戳作为Key就不是一个可确定的计算,因为每次计算的结果会随着系统当前时间戳发生变化。另外,写入外部系统一般是采用更新插入(Upsert)的方式,即将原有数据删除,将新数据插入,或者说将原有数据覆盖。一些Key-Value数据库经常被用来实现幂等写,幂等写也是一种实现成本相对比较低的方式。 +幂等写需要综合考虑业务系统的设计和下游外部系统的选型等多方面因素。数据流的一条数据经过 Flink 可能产生一到多次计算(因为故障恢复),但是最终输出的结果必须是可确定的,不能因为多次计算,导致一些变化。比如我们在前文中提到的,结果中使用系统当前时间戳作为 Key 就不是一个可确定的计算,因为每次计算的结果会随着系统当前时间戳发生变化。另外,写入外部系统一般是采用更新插入(Upsert)的方式,即将原有数据删除,将新数据插入,或者说将原有数据覆盖。一些 Key-Value 数据库经常被用来实现幂等写,幂等写也是一种实现成本相对比较低的方式。 ### 事务写 -另外一种提供端到端Exactly-Once保障的方式是事务写,并且有两种具体的实现方式:Write-Ahead-Log和Two-Phase-Commit。两者非常相似,下面分别介绍两种方式的原理,并重点介绍Two-Phase-Commit的具体实现。 +另外一种提供端到端 Exactly-Once 保障的方式是事务写,并且有两种具体的实现方式:Write-Ahead-Log 和 Two-Phase-Commit。两者非常相似,下面分别介绍两种方式的原理,并重点介绍 Two-Phase-Commit 的具体实现。 -#### Write-Ahead-Log协议的原理 +#### Write-Ahead-Log 协议的原理 -Write-Ahead-Log是一种广泛应用在数据库和分布式系统中的保证事务一致性的协议。Write-Ahead-Log的核心思想是,在数据写入下游系统之前,先把数据以日志(Log)的形式缓存下来,等收到明确的确认提交信息后,再将Log中的数据提交到下游系统。由于数据都写到了Log里,即使出现故障恢复,也可以根据Log中的数据决定是否需要恢复、如何进行恢复。图7-4所示为Flink的Write-Ahead-Log流程。 +Write-Ahead-Log 是一种广泛应用在数据库和分布式系统中的保证事务一致性的协议。Write-Ahead-Log 的核心思想是,在数据写入下游系统之前,先把数据以日志(Log)的形式缓存下来,等收到明确的确认提交信息后,再将 Log 中的数据提交到下游系统。由于数据都写到了 Log 里,即使出现故障恢复,也可以根据 Log 中的数据决定是否需要恢复、如何进行恢复。{numref}`fig-write-ahead-log` 所示为 Flink 的 Write-Ahead-Log 流程。 -![图7-4 Flink的Write-Ahead-Log流程](./img/write-ahead-log.png) +```{figure} ./img/write-ahead-log.png +--- +name: fig-write-ahead-log +width: 80% +align: center +--- +Flink 的 Write-Ahead-Log 流程 +``` -在Flink中,上游算子会不断向Sink发送待输出数据,这些待输出数据暂时存储在状态中,如图7-4的第0步所示。两次Checkpoint之间的待输出数据组成一个待输出的批次,会以Operator State的形式保存和备份。当Sink接收到一个新Checkpoint Barrier时,意味着Sink需要执行新一次Checkpoint,它会开启一个新的批次,新流入数据都进入该批次。同时,Sink准备将之前未提交的批次提交给外部系统。图7-4所示的第1步和第2步展示了这个过程。数据提交的过程又分为如下3步。 +在 Flink 中,上游算子会不断向 Sink 发送待输出数据,这些待输出数据暂时存储在状态中,如 {numref}`fig-write-ahead-log` 的第 0 步所示。两次 Checkpoint 之间的待输出数据组成一个待输出的批次,会以 Operator State 的形式保存和备份。当 Sink 接收到一个新 Checkpoint Barrier 时,意味着 Sink 需要执行新一次 Checkpoint,它会开启一个新的批次,新流入数据都进入该批次。同时,Sink 准备将之前未提交的批次提交给外部系统。{numref}`fig-write-ahead-log` 所示的第 1 步和第 2 步展示了这个过程。数据提交的过程又分为如下 3 步。 -1. Sink向CheckpointCommitter查询某批次是否已经提交,通常CheckpointCommitter是一个与外部系统紧密相连的插件,里面存储了各批次数据是否已经写入外部系统的信息。比如,Cassandra的CassandraCommitter使用了一个单独的表存储某批次数据是否已经提交。如果还未提交,则返回false。如果外部系统是一个文件系统,我们用一个文件存储哪些批次数据已经提交。总之,CheckpointCommitter依赖外部系统,它依靠外部系统存储了是否提交的信息。这个过程如图7-4的第3步所示。 -2. Sink得知某批次数据还未提交,则使用sendValues()方法,提交待输出数据到外部系统,即图7-4的第4步。此时,数据写入外部系统,同时也要在CheckpointCommitter中更新本批次数据已被提交的确认信息。 -3. 数据提交成功后,Sink会删除Operator State中存储的已经提交的数据。 +1. Sink 向 CheckpointCommitter 查询某批次是否已经提交,通常 CheckpointCommitter 是一个与外部系统紧密相连的插件,里面存储了各批次数据是否已经写入外部系统的信息。比如,Cassandra 的 CassandraCommitter 使用了一个单独的表存储某批次数据是否已经提交。如果还未提交,则返回 false。如果外部系统是一个文件系统,我们用一个文件存储哪些批次数据已经提交。总之,CheckpointCommitter 依赖外部系统,它依靠外部系统存储了是否提交的信息。这个过程如 {numref}`fig-write-ahead-log` 的第 3 步所示。 +2. Sink 得知某批次数据还未提交,则使用 sendValues()方法,提交待输出数据到外部系统,即 {numref}`fig-write-ahead-log` 的第 4 步。此时,数据写入外部系统,同时也要在 CheckpointCommitter 中更新本批次数据已被提交的确认信息。 +3. 数据提交成功后,Sink 会删除 Operator State 中存储的已经提交的数据。 -Write-Ahead-Log仍然无法提供百分之百的Exactly-Once保障,原因如下。 +Write-Ahead-Log 仍然无法提供百分之百的 Exactly-Once 保障,原因如下。 -1. sendValues()中途可能崩溃,导致部分数据已提交,部分数据还未提交。 -2. sendValues()成功,但是本批次数据提交的确认信息未能更新到CheckpointCommitter中。 +1. sendValues() 中途可能崩溃,导致部分数据已提交,部分数据还未提交。 +2. sendValues() 成功,但是本批次数据提交的确认信息未能更新到 CheckpointCommitter 中。 这两种原因会导致故障恢复后,某些数据可能会被多次写入外部系统。 -Write-Ahead-Log的方式相对比较通用,目前Flink的Cassandra Sink使用这种方式提供Exactly-Once保障。 +Write-Ahead-Log 的方式相对比较通用,目前 Flink 的 Cassandra Sink 使用这种方式提供 Exactly-Once 保障。 -#### Two-Phase-Commit协议的原理和实现 +#### Two-Phase-Commit 协议的原理和实现 -Two-Phase-Commit是另一种广泛应用在数据库和分布式系统中的事务协议。与刚刚介绍的Write-Ahead-Log相比,Flink中的Two-Phase-Commit协议不将数据缓存在Operator State,而是将数据直接写入外部系统,比如支持事务的Kafka。图7-4为Flink的Two-Phase-Commit流程图。 +Two-Phase-Commit 是另一种广泛应用在数据库和分布式系统中的事务协议。与刚刚介绍的 Write-Ahead-Log 相比,Flink 中的 Two-Phase-Commit 协议不将数据缓存在 Operator State,而是将数据直接写入外部系统,比如支持事务的 Kafka。图 7-4 为 Flink 的 Two-Phase-Commit 流程图。 -![图7-5 Flink的Two-Phase-Commit流程图](./img/two-phase-commit.png) +```{figure} ./img/two-phase-commit.png +--- +name: fig-two-phase-commit +width: 80% +align: center +--- +Flink 的 Two-Phase-Commit 流程图 +``` -如图7-5所示,上游算子将数据发送到Sink后,Sink直接将待输出数据写入外部系统的第k次事务(Transaction)中。接着Checkpoint Barrier到达,新一次Checkpoint开始执行。如图7-5的第2步所示,Flink执行preCommit(),将第k次Transaction的数据预提交到外部系统中,预提交时,待提交数据已经写入外部系统,但是为了保证数据一致性,这些数据由于还没有得到确认提交的信息,对于外部系统的使用者来说,还是不可见的。之所以使用预提交而非提交,是因为Flink无法确定多个并行实例是否都完成了数据写入外部系统的过程,有些实例已经将数据写入,其他实例未将数据写入。一旦发生故障恢复,写入实例的那些数据还有可能再次被写入外部系统,这就影响了Exactly-Once保障的数据一致性。 +如 {numref}`fig-two-phase-commit` 所示,上游算子将数据发送到 Sink 后,Sink 直接将待输出数据写入外部系统的第 k 次事务(Transaction)中。接着 Checkpoint Barrier 到达,新一次 Checkpoint 开始执行。如图 7-5 的第 2 步所示,Flink 执行 preCommit(),将第 k 次 Transaction 的数据预提交到外部系统中,预提交时,待提交数据已经写入外部系统,但是为了保证数据一致性,这些数据由于还没有得到确认提交的信息,对于外部系统的使用者来说,还是不可见的。之所以使用预提交而非提交,是因为 Flink 无法确定多个并行实例是否都完成了数据写入外部系统的过程,有些实例已经将数据写入,其他实例未将数据写入。一旦发生故障恢复,写入实例的那些数据还有可能再次被写入外部系统,这就影响了 Exactly-Once 保障的数据一致性。 -接着,Flink会执行beginTransaction()方法,开启下一次Transaction(Transaction k+1),之后上游算子流入的待输出数据都将流入新的Transaction,如图7-5的第3步。当所有并行实例都执行图7-5中的第2步和第3步之后,本次Checkpoint已经完成,Flink将预提交的数据最终提交到外部系统,至此待输出数据在外部系统最终可见。 +接着,Flink 会执行 beginTransaction() 方法,开启下一次 Transaction(Transaction k+1),之后上游算子流入的待输出数据都将流入新的 Transaction,如图 7-5 的第 3 步。当所有并行实例都执行图 7-5 中的第 2 步和第 3 步之后,本次 Checkpoint 已经完成,Flink 将预提交的数据最终提交到外部系统,至此待输出数据在外部系统最终可见。 -接下来我们使用具体的例子来演示整个数据写入的过程,这里继续使用本章之前一直使用的数据流DataStream>,我们将这个数据流写入文件。为此,我们准备两个文件夹,一个名为flink-sink-commited,这是数据最终要写入的文件夹,需要保证一条数据从Source到Sink的Exactly-Once一致性;第二个文件夹名为flink-sink-precommit,存储临时文件,主要为事务机制所使用。数据先经过flink-sink-precommit,等得到确认后,再将数据从此文件夹写入flink-sink-commited。结合上面所述的数据写入过程,我们需要继承TwoPhaseCommitSinkFunction,并实现下面的4个方法。 -1. beginTransaction():开启一次新的Transaction。我们为每次Transaction创建一个新的文件缓存,文件缓存名以当前时间命名,新流入数据都写入这个文件缓存。假设当前为第k次Transaction,文件名为k。文件缓存的数据在内存中,还未写入磁盘。 -2. preCommit():数据预提交。文件缓存k从内存写入flink-sink-precommit文件夹,数据持久化到磁盘中。一旦preCommit()方法被执行,Flink会调用beginTransaction()方法,开启下一次Transaction,生成名为k+1的文件缓存。 -3. commit():得到确认后,提交数据。将文件k从flink-sink-precommit文件夹移动到flink-sink-commited。 -4. abort():遇到异常,操作终止。将flink-sink-precommit中的文件删除。 +接下来我们使用具体的例子来演示整个数据写入的过程,这里继续使用本章之前一直使用的数据流 DataStream>,我们将这个数据流写入文件。为此,我们准备两个文件夹,一个名为 flink-sink-commited,这是数据最终要写入的文件夹,需要保证一条数据从 Source 到 Sink 的 Exactly-Once 一致性;第二个文件夹名为 flink-sink-precommit,存储临时文件,主要为事务机制所使用。数据先经过 flink-sink-precommit,等得到确认后,再将数据从此文件夹写入 flink-sink-commited。结合上面所述的数据写入过程,我们需要继承 TwoPhaseCommitSinkFunction,并实现下面的 4 个方法。 +1. beginTransaction():开启一次新的 Transaction。我们为每次 Transaction 创建一个新的文件缓存,文件缓存名以当前时间命名,新流入数据都写入这个文件缓存。假设当前为第 k 次 Transaction,文件名为 k。文件缓存的数据在内存中,还未写入磁盘。 +2. preCommit():数据预提交。文件缓存 k 从内存写入 flink-sink-precommit 文件夹,数据持久化到磁盘中。一旦 preCommit() 方法被执行,Flink 会调用 beginTransaction() 方法,开启下一次 Transaction,生成名为 k+1 的文件缓存。 +3. commit():得到确认后,提交数据。将文件 k 从 flink-sink-precommit 文件夹移动到 flink-sink-commited。 +4. abort():遇到异常,操作终止。将 flink-sink-precommit 中的文件删除。 -除此之外,还需要实现Sink最基本的数据写入方法invoke(),将数据写入文件缓存。代码清单 7-4展示了整个过程。 +除此之外,还需要实现 Sink 最基本的数据写入方法 invoke(),将数据写入文件缓存。代码清单 7-4 展示了整个过程。 ```java public static class TwoPhaseFileSink extends TwoPhaseCommitSinkFunction, String, Void> { @@ -304,7 +325,7 @@ LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); int subTaskIdx = getRuntimeContext().getIndexOfThisSubtask(); String fileName = time + "-" + subTaskIdx; Path preCommitFilePath = Paths.get(preCommitPath + "/" + fileName); -// 创建一个存储本次Transaction的文件 +// 创建一个存储本次 Transaction 的文件 Files.createFile(preCommitFilePath); transactionWriter = Files.newBufferedWriter(preCommitFilePath); System.out.println("transaction File: " + preCommitFilePath); @@ -348,16 +369,16 @@ System.out.println("transaction File: " + preCommitFilePath); } ``` -代码清单 7-4 实现了TwoPhaseCommitSinkFunction的Sink +代码清单 7-4 实现了 TwoPhaseCommitSinkFunction 的 Sink -TwoPhaseCommitSinkFunction接收如下3个泛型。 -- IN为上游算子发送过来的待输出数据类型。 -- TXN为Transaction类型,本例中是类型String,Kafka中是一个封装了Kafka Producer的数据类型,我们可以往Transaction中写入待输出的数据。 -- CONTEXT为上下文类型,是个可选选项。本例中我们没有使用上下文,所以这里使用了Void,即空类型。 -TwoPhaseCommitSinkFunction的构造函数需要传入TXN和CONTEXT的序列化器。在主逻辑中,我们创建了两个目录,一个为预提交目录,一个为最终的提交目录。我们可以比较使用未加任何保护的print()和该Sink:print()直接将结果输出到标准输出,会有数据重发现象;而使用了Two-Phase-Commit协议,待输出结果写到了目标文件夹内,即使发生了故障恢复,也不会有数据重发现象,代码清单 7-5展示了在主逻辑中使用Two-Phase-Commit的Sink。 +TwoPhaseCommitSinkFunction 接收如下 3 个泛型。 +- IN 为上游算子发送过来的待输出数据类型。 +- TXN 为 Transaction 类型,本例中是类型 String,Kafka 中是一个封装了 Kafka Producer 的数据类型,我们可以往 Transaction 中写入待输出的数据。 +- CONTEXT 为上下文类型,是个可选选项。本例中我们没有使用上下文,所以这里使用了 Void,即空类型。 +TwoPhaseCommitSinkFunction 的构造函数需要传入 TXN 和 CONTEXT 的序列化器。在主逻辑中,我们创建了两个目录,一个为预提交目录,一个为最终的提交目录。我们可以比较使用未加任何保护的 print()和该 Sink:print() 直接将结果输出到标准输出,会有数据重发现象;而使用了 Two-Phase-Commit 协议,待输出结果写到了目标文件夹内,即使发生了故障恢复,也不会有数据重发现象,代码清单 7-5 展示了在主逻辑中使用 Two-Phase-Commit 的 Sink。 ```java -// 每隔5秒进行一次Checkpoint +// 每隔 5 秒进行一次 Checkpoint env.getCheckpointConfig().setCheckpointInterval(5 * 1000); DataStream> countStream = env.addSource(new @@ -366,8 +387,8 @@ DataStream> countStream = env.addSource(new DataStream> result = countStream.map(new CheckpointedSourceExample.FailingMapper(20)); -// 类UNIX操作系统的临时文件夹在/tmp下 -// Windows用户需要修改该目录 +// 类 UNIX 操作系统的临时文件夹在 /tmp 下 +// Windows 用户需要修改该目录 String preCommitPath = "/tmp/flink-sink-precommit"; String commitedPath = "/tmp/flink-sink-commited"; @@ -377,13 +398,13 @@ if (!Files.exists(Paths.get(preCommitPath))) { if (!Files.exists(Paths.get(commitedPath))) { Files.createDirectory(Paths.get(commitedPath)); } -// 使用Exactly-Once语义的Sink,执行本程序时可以查看相应的输出目录 +// 使用 Exactly-Once 语义的 Sink,执行本程序时可以查看相应的输出目录 result.addSink(new TwoPhaseFileSink(preCommitPath, commitedPath)); -//输出数据,无Exactly-Once保障,有数据重发现象 +// 输出数据,无 Exactly-Once 保障,有数据重发现象 result.print(); ``` -代码清单 7-5 在主逻辑中使用Two-Phase-Commit的Sink +代码清单 7-5 在主逻辑中使用 Two-Phase-Commit 的 Sink -Flink的Kafka Sink中的FlinkKafkaProducer.Semantic.EXACTLY_ONCE选项就使用这种方式实现,因为Kafka提供了事务机制,开发者可以通过“预提交-提交”的两阶段提交方式将数据写入Kafka。但是需要注意的是,这种方式理论上能够提供百分之百的Exactly-Once保障,但实际执行过程中,这种方式比较依赖Kafka和Flink之间的协作,如果Flink作业的故障恢复时间过长会导致超时,最终会导致数据丢失。因此,这种方式只能在理论上提供百分之百的Exactly-Once保障。 -将转化为markdown格式,输出源代码: \ No newline at end of file +Flink 的 Kafka Sink 中的 FlinkKafkaProducer.Semantic.EXACTLY_ONCE 选项就使用这种方式实现,因为 Kafka 提供了事务机制,开发者可以通过“预提交 - 提交”的两阶段提交方式将数据写入 Kafka。但是需要注意的是,这种方式理论上能够提供百分之百的 Exactly-Once 保障,但实际执行过程中,这种方式比较依赖 Kafka 和 Flink 之间的协作,如果 Flink 作业的故障恢复时间过长会导致超时,最终会导致数据丢失。因此,这种方式只能在理论上提供百分之百的 Exactly-Once 保障。 +将转化为 markdown 格式,输出源代码: \ No newline at end of file diff --git a/doc/ch-flink-connectors/exercise-stock-price-data-stream.md b/doc/ch-flink-connectors/exercise-stock-price-data-stream.md index fc5a6a3..339d9b2 100644 --- a/doc/ch-flink-connectors/exercise-stock-price-data-stream.md +++ b/doc/ch-flink-connectors/exercise-stock-price-data-stream.md @@ -1,21 +1,21 @@ (exercise-stock-price-data-stream)= # 实验 读取并输出股票价格数据流 -经过本章的学习,读者应该基本了解了Flink Connector的使用方法,本节我们继续以股票交易场景来模拟数据流的输入和输出。 +经过本章的学习,读者应该基本了解了 Flink Connector 的使用方法,本节我们继续以股票交易场景来模拟数据流的输入和输出。 ## 一、实验目的 -结合股票交易场景,学习如何使用Source和Sink,包括如何自定义Source、如何调用Kafka Sink。 +结合股票交易场景,学习如何使用 Source 和 Sink,包括如何自定义 Source、如何调用 Kafka Sink。 ## 二、实验内容 -在第4章和第5章的实验中,我们都使用了股票交易数据,其中使用了StockPrice的数据结构,读取数据集中的数据来模拟一个真实数据流。这里我们将修改第4章实验中的Source,在读取数据集时使用一个Offset,保证Source有故障恢复的能力。 +在第 4 章和第 5 章的实验中,我们都使用了股票交易数据,其中使用了 StockPrice 的数据结构,读取数据集中的数据来模拟一个真实数据流。这里我们将修改第 4 章实验中的 Source,在读取数据集时使用一个 Offset,保证 Source 有故障恢复的能力。 -基于第5章中的对股票数据xVWAP的计算程序,使用Kafka Sink,将结果输出到Kafka。输出之前,需要在Kafka中建立对应的Topic。 +基于第 5 章中的对股票数据 xVWAP 的计算程序,使用 Kafka Sink,将结果输出到 Kafka。输出之前,需要在 Kafka 中建立对应的 Topic。 ## 三、实验要求 -整个程序启用Flink的Checkpoint机制,计算xVWAP,需要重新编写Source,使其支持故障恢复,计算结果被发送到Kafka。计算结果可以使用JSON格式进行序列化。在命令行中启动一个Kafka Consumer来接收数据,验证程序输出的正确性。 +整个程序启用 Flink 的 Checkpoint 机制,计算 xVWAP,需要重新编写 Source,使其支持故障恢复,计算结果被发送到 Kafka。计算结果可以使用 JSON 格式进行序列化。在命令行中启动一个 Kafka Consumer 来接收数据,验证程序输出的正确性。 ## 四、实验报告 @@ -23,4 +23,4 @@ ## 本章小结 -通过本章的学习,读者应该可以了解Flink Connector的原理和使用方法,包括:端到端Exactly-Once的含义、自定义Source和Sink以及常用Flink Connector使用方法。相信通过本章的学习,读者已经可以将从Source到Sink的一整套流程串联起来。 +通过本章的学习,读者应该可以了解 Flink Connector 的原理和使用方法,包括:端到端 Exactly-Once 的含义、自定义 Source 和 Sink 以及常用 Flink Connector 使用方法。相信通过本章的学习,读者已经可以将从 Source 到 Sink 的一整套流程串联起来。 diff --git a/doc/ch-flink-connectors/flink-connector.md b/doc/ch-flink-connectors/flink-connector.md index 5042cc7..ab2e506 100644 --- a/doc/ch-flink-connectors/flink-connector.md +++ b/doc/ch-flink-connectors/flink-connector.md @@ -1,52 +1,59 @@ (flink-connector)= -# Flink中常用的Connector +# Flink 中常用的 Connector -本节将对Flink常用的Connector做一些概括性的介绍,主要包括内置输入/输出(Input/Output,I/O)接口、flink-connector项目所涉及的Connector、Apache Bahir所提供的Connector等,如图7-5所示。 +本节将对 Flink 常用的 Connector 做一些概括性的介绍,主要包括内置输入 / 输出(Input/Output,I/O)接口、flink-connector 项目所涉及的 Connector、Apache Bahir 所提供的 Connector 等,如 {numref}`fig-flink-connectors` 所示。 -![图7-6 Flink中常用的Connector](./img/Connector.png) +```{figure} ./img/Connector.png +--- +name: fig-flink-connectors +width: 60% +align: center +--- +Flink 中常用的 Connector +``` -Flink支持了绝大多数的常见大数据系统,从系统的类型上,包括了消息队列、数据库、文件系统等;从具体的技术上,包括了Kafka、Elasticsearch、HBase、Cassandra、JDBC、Kinesis、Redis等。各个大数据系统使用起来略有不同,接下来将重点介绍一下Flink内置I/O接口和Flink Kafka Connector,这两类Connector被广泛应用在很多业务场景中,具有很强的代表性。 +Flink 支持了绝大多数的常见大数据系统,从系统的类型上,包括了消息队列、数据库、文件系统等;从具体的技术上,包括了 Kafka、Elasticsearch、HBase、Cassandra、JDBC、Kinesis、Redis 等。各个大数据系统使用起来略有不同,接下来将重点介绍一下 Flink 内置 I/O 接口和 Flink Kafka Connector,这两类 Connector 被广泛应用在很多业务场景中,具有很强的代表性。 -## 7.3.1 内置I/O接口 +## 内置 I/O 接口 -之所以给这类Connector起名为内置I/O接口,是因为这些接口直接集成在了Flink的核心代码中,无论在任何环境中,我们都可以调用这些接口进行数据输入/输出操作。与内置I/O接口相对应的是flink-connector子项目以及Apache Bahir项目中的Connector,flink-connector虽然是Flink开源项目的一个子项目,但是并没有直接集成到二进制包(我们在第2章下载安装的Flink安装包)中。因此,使用Flink的内置I/O接口,一般不需要额外添加依赖,使用其他Connector需要添加相应的依赖。 +之所以给这类 Connector 起名为内置 I/O 接口,是因为这些接口直接集成在了 Flink 的核心代码中,无论在任何环境中,我们都可以调用这些接口进行数据输入 / 输出操作。与内置 I/O 接口相对应的是 flink-connector 子项目以及 Apache Bahir 项目中的 Connector,flink-connector 虽然是 Flink 开源项目的一个子项目,但是并没有直接集成到二进制包(我们在第 2 章下载安装的 Flink 安装包)中。因此,使用 Flink 的内置 I/O 接口,一般不需要额外添加依赖,使用其他 Connector 需要添加相应的依赖。 -Flink的内置I/O接口如下: +Flink 的内置 I/O 接口如下: -- 基于Socket的Source和Sink。 -- 基于内存集合的Source。 -- 输出到标准输出的Sink。 -- 基于文件系统的Source和Sink。 +- 基于 Socket 的 Source 和 Sink。 +- 基于内存集合的 Source。 +- 输出到标准输出的 Sink。 +- 基于文件系统的 Source 和 Sink。 -在前文中,我们其实已经使用过这里提到的接口,比如从内存集合中创建数据流并将结果输出到标准输出。像Socket、内存集合和打印这3类接口非常适合调试。此外,文件系统被广泛用于大数据的持久化,是大数据架构中经常涉及的一种组件。下面我们将再次梳理一下这些接口,并重点介绍一下基于文件系统的Source和Sink。 +在前文中,我们其实已经使用过这里提到的接口,比如从内存集合中创建数据流并将结果输出到标准输出。像 Socket、内存集合和打印这 3 类接口非常适合调试。此外,文件系统被广泛用于大数据的持久化,是大数据架构中经常涉及的一种组件。下面我们将再次梳理一下这些接口,并重点介绍一下基于文件系统的 Source 和 Sink。 -### 1. 基于Socket的Source和Sink +### 基于 Socket 的 Source 和 Sink -我们可以从Socket数据流中读取和写入数据。 +我们可以从 Socket 数据流中读取和写入数据。 ```java -// 读取Socket中的数据,数据流数据之间用\n来切分 +// 读取 Socket 中的数据,数据流数据之间用 \n 来切分 env.socketTextStream(hostname, port, "\n"); -// 向Socket中写数入据,数据以SimpleStringSchema序列化 +// 向 Socket 中写数入据,数据以 SimpleStringSchema 序列化 stream.writeToSocket(outputHost, outputPort, new SimpleStringSchema()); ``` -由于Socket不能保存Offset,也无法实现数据重发,因此以它作为Connector可能会导致故障恢复时的数据丢失,只能提供At-Most-Once的投递保障。这种方式非常适合用来调试,开源工具nc可以创建Socket数据流,结合Flink的Socket接口可以用来快速验证一些逻辑。 +由于 Socket 不能保存 Offset,也无法实现数据重发,因此以它作为 Connector 可能会导致故障恢复时的数据丢失,只能提供 At-Most-Once 的投递保障。这种方式非常适合用来调试,开源工具 nc 可以创建 Socket 数据流,结合 Flink 的 Socket 接口可以用来快速验证一些逻辑。 -此外,Socket Source输入数据具有时序性,适合用来调试与时间和窗口有关的程序。 +此外,Socket Source 输入数据具有时序性,适合用来调试与时间和窗口有关的程序。 -注意,使用Socket时,需要提前启动相应的Socket端口,以便Flink能够建立Socket连接,否则将抛出异常。 +注意,使用 Socket 时,需要提前启动相应的 Socket 端口,以便 Flink 能够建立 Socket 连接,否则将抛出异常。 -### 2. 基于内存集合的Source +### 基于内存集合的 Source -最常见调试方式是在内存中创建一些数据列表,并直接写入Flink的Source。 +最常见调试方式是在内存中创建一些数据列表,并直接写入 Flink 的 Source。 ```java DataStream sourceDataStream = env.fromElements(1, 2, 3); ``` -它内部调用的是:`fromCollection(Collection data, TypeInformation typeInfo)`。`fromCollection()`基于Java的Collection接口。对于一些复杂的数据类型,我们用Java的Collection来创建数据,并写到Flink的Source里。 +它内部调用的是:`fromCollection(Collection data, TypeInformation typeInfo)`。`fromCollection()` 基于 Java 的 Collection 接口。对于一些复杂的数据类型,我们用 Java 的 Collection 来创建数据,并写到 Flink 的 Source 里。 ```java // 获取数据类型 @@ -54,9 +61,9 @@ TypeInformation typeInfo = ... DataStream collectionStream = env.fromCollection(Arrays.asList(data), typeInfo); ``` -### 3. 输出到标准输出的Sink +### 输出到标准输出的 Sink -`print()`和`printToErr()`分别将数据流输出到标准输出流(STDOUT)和标准错误流(STDERR)。这两个方法会调用数据的`toString()`方法,将内存对象转换成字符串,因此如果想进行调试、查看结果,一定要实现数据的`toString()`方法。Java的POJO类要重写`toString()`方法,Scala的case class已经有内置的`toString()`方法,无须实现。 +`print()` 和 `printToErr()` 分别将数据流输出到标准输出流(STDOUT)和标准错误流(STDERR)。这两个方法会调用数据的 `toString()` 方法,将内存对象转换成字符串,因此如果想进行调试、查看结果,一定要实现数据的 `toString()` 方法。Java 的 POJO 类要重写 `toString()` 方法,Scala 的 case class 已经有内置的 `toString()` 方法,无须实现。 ```java public class StockPrice { @@ -78,7 +85,7 @@ public class StockPrice { } ``` -`print()`和`printToErr()`方法实际在TaskManager上执行,如果并行度大于1,Flink会将算子子任务的ID一起输出。比如,在IntelliJ IDEA中执行程序,可以得到类似下面的结果,每行输出前都有一个数字,该数字表示相应方法实际在哪个算子子任务上执行。 +`print()` 和 `printToErr()` 方法实际在 TaskManager 上执行,如果并行度大于 1,Flink 会将算子子任务的 ID 一起输出。比如,在 IntelliJ IDEA 中执行程序,可以得到类似下面的结果,每行输出前都有一个数字,该数字表示相应方法实际在哪个算子子任务上执行。 ``` 1> 490894,1061719,4874384,pv,1512061207 @@ -89,28 +96,28 @@ public class StockPrice { ... ``` -### 4. 基于文件系统的Source和Sink +### 基于文件系统的 Source 和 Sink -#### (1) 基于文件系统的Source +#### 基于文件系统的 Source -文件系统一般用来存储数据,为批处理提供输入或输出,是大数据架构中最为重要的组件之一。比如,消息队列可能将一些日志写入文件系统进行持久化,批处理作业从文件系统中读取数据进行分析等。在Flink中,基于文件系统的Source和Sink可以从文件系统中读取和输出数据。 +文件系统一般用来存储数据,为批处理提供输入或输出,是大数据架构中最为重要的组件之一。比如,消息队列可能将一些日志写入文件系统进行持久化,批处理作业从文件系统中读取数据进行分析等。在 Flink 中,基于文件系统的 Source 和 Sink 可以从文件系统中读取和输出数据。 -Flink对各类文件系统都提供了支持,包括本地文件系统以及挂载到本地的网络文件系统(Network File System,NFS)、Hadoop HDFS、Amazon S3、阿里云OSS等。Flink通过路径中的文件系统描述符来确定该文件路径使用什么文件系统,例如`file:///some/local/file`或者`hdfs://host:port/file/path`。 +Flink 对各类文件系统都提供了支持,包括本地文件系统以及挂载到本地的网络文件系统(Network File System,NFS)、Hadoop HDFS、Amazon S3、阿里云 OSS 等。Flink 通过路径中的文件系统描述符来确定该文件路径使用什么文件系统,例如 `file:///some/local/file` 或者 `hdfs://host:port/file/path`。 -下面的代码从一个文件系统中读取一个文本文件,文件读入后以字符串的形式存在,并生成一个`DataStream`。 +下面的代码从一个文件系统中读取一个文本文件,文件读入后以字符串的形式存在,并生成一个 `DataStream`。 ```java StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); String textPath = ... -// readTextFile()方法默认以UTF-8编码格式读取文件 +// readTextFile() 方法默认以 UTF-8 编码格式读取文件 DataStream text = env.readTextFile(textPath); ``` -Flink在内部实际调用的是一个支持更多参数的接口。 +Flink 在内部实际调用的是一个支持更多参数的接口。 ```java /** - * 从filePath文件中读取数据 + * 从 filePath 文件中读取数据 * FileInputFormat 定义文件的格式 * watchType 检测文件路径下的内容是否有更新 * interval 检测间隔 @@ -122,12 +129,12 @@ public DataStreamSource readFile( long interval); ``` -上述方法可以读取一个路径下的所有文件。`FileInputFormat`定义了输入文件的格式,比如一个纯文本文件`TextInputFormat`,后文还将详细介绍这个接口。参数`filePath`是文件路径。如果这个路径指向一个文件,Flink将读取这个文件,如果这个路径是一个目录,Flink将读取目录下的文件。基于`FileProcessingMode`,Flink提供了如下两种不同的读取文件的模式。 +上述方法可以读取一个路径下的所有文件。`FileInputFormat` 定义了输入文件的格式,比如一个纯文本文件 `TextInputFormat`,后文还将详细介绍这个接口。参数 `filePath` 是文件路径。如果这个路径指向一个文件,Flink 将读取这个文件,如果这个路径是一个目录,Flink 将读取目录下的文件。基于 `FileProcessingMode`,Flink 提供了如下两种不同的读取文件的模式。 -- `FileProcessingMode.PROCESS_ONCE`模式只读取一遍某个目录下的内容,读取完后随即退出。 -- `FileProcessingMode.PROCESS_CONTINUOUSLY`模式每隔`interval`毫秒周期性地检查`filePath`路径下的内容是否有更新,如果有更新,重新读取里面的内容。 +- `FileProcessingMode.PROCESS_ONCE` 模式只读取一遍某个目录下的内容,读取完后随即退出。 +- `FileProcessingMode.PROCESS_CONTINUOUSLY` 模式每隔 `interval` 毫秒周期性地检查 `filePath` 路径下的内容是否有更新,如果有更新,重新读取里面的内容。 -下面的代码展示了如何调用`FileInputFormat`接口。 +下面的代码展示了如何调用 `FileInputFormat` 接口。 ```java // 文件路径 @@ -136,44 +143,46 @@ String filePath = ... // 文件为纯文本格式 TextInputFormat textInputFormat = new TextInputFormat(new org.apache.flink.core.fs.Path(filePath)); -// 每隔100毫秒检测一遍 +// 每隔 100 毫秒检测一遍 DataStream inputStream = env.readFile(textInputFormat, filePath, FileProcessingMode.PROCESS_CONTINUOUSLY, 100); ``` -Flink在实现文件读取时,增加了一个专门检测文件路径的线程。这个线程启动后定时检测路径下的任何修改,比如是否有文件被修改,或文件夹是否添加了新内容。确切地说,这个线程检测文件的修改时间(Modified Time)是否发生了变化。`FileProcessingMode.PROCESS_CONTINUOUSLY`模式下Flink每隔`interval`毫秒周期性地检测文件的修改时间;`FileProcessingMode.PROCESS_ONCE`只检测一次,不周期性地检测。 +Flink 在实现文件读取时,增加了一个专门检测文件路径的线程。这个线程启动后定时检测路径下的任何修改,比如是否有文件被修改,或文件夹是否添加了新内容。确切地说,这个线程检测文件的修改时间(Modified Time)是否发生了变化。`FileProcessingMode.PROCESS_CONTINUOUSLY` 模式下 Flink 每隔 `interval` 毫秒周期性地检测文件的修改时间;`FileProcessingMode.PROCESS_ONCE` 只检测一次,不周期性地检测。 + +:::{note} -注意 +重新读取文件内容会影响端到端的 Exactly-Once 一致性。因为检测更新是基于文件的修改时间,如果我们往一个文件中追加数据,文件的修改时间会发生变化,该文件下次检测时会被重新读取,导致一条数据可能会被多次处理。 -重新读取文件内容会影响端到端的Exactly-Once一致性。因为检测更新是基于文件的修改时间,如果我们往一个文件中追加数据,文件的修改时间会发生变化,该文件下次检测时会被重新读取,导致一条数据可能会被多次处理。 +::: -`FileInputFormat`是读取文件的基类,继承这个基类可以实现不同类型的文件读取,包括纯文本文件。`TextInputFormat`是`FileInputFormat`的一个实现,`TextInputFormat`按行读取文件,文件以纯文本的序列化方式打开。Flink也提供了`AvroInputFormat`、`OrcInputFormat`、`ParquetInputFormat`等其他大数据架构所采用的文件格式,这些文件格式比起纯文本文件的性能更好,它们的读/写方式也各有不同。 +`FileInputFormat` 是读取文件的基类,继承这个基类可以实现不同类型的文件读取,包括纯文本文件。`TextInputFormat` 是 `FileInputFormat` 的一个实现,`TextInputFormat` 按行读取文件,文件以纯文本的序列化方式打开。Flink 也提供了 `AvroInputFormat`、`OrcInputFormat`、`ParquetInputFormat` 等其他大数据架构所采用的文件格式,这些文件格式比起纯文本文件的性能更好,它们的读 / 写方式也各有不同。 -考虑到数据的容量比较大,在实现文件读取的过程中,Flink会判断`filePath`路径下的文件能否切分。假设这个作业的并行度是`n`,而且文件能够切分,检测线程会将读入的文件切分成`n`份,后续启动`n`个并行的文件读取实例读取这`n`份切分文件。 +考虑到数据的容量比较大,在实现文件读取的过程中,Flink 会判断 `filePath` 路径下的文件能否切分。假设这个作业的并行度是 `n`,而且文件能够切分,检测线程会将读入的文件切分成 `n` 份,后续启动 `n` 个并行的文件读取实例读取这 `n` 份切分文件。 -#### (2) 基于文件系统的Sink +#### 基于文件系统的 Sink -我们可以使用`writeAsText(String path)`、`writeAsText(String path, WriteMode writeMode)`和`writeUsingOutputFormat(OutputFormat format)`等方法来将文件输出到文件系统。`WriteMode`可以为`NO_OVERWRITE`和`OVERWRITE`,即是否覆盖原来路径里的内容。`OutputFormat`与`FileInputFormat`类似,表示目标文件的文件格式。在最新的Flink版本中,这几个输出到文件系统的方法被标记为`@Deprecated`,表示未来将被弃用,主要考虑到这些方法没有参与Flink的Checkpoint过程中,无法提供Exactly-Once保障。这些方法适合用于本地调试。 +我们可以使用 `writeAsText(String path)`、`writeAsText(String path, WriteMode writeMode)` 和 `writeUsingOutputFormat(OutputFormat format)` 等方法来将文件输出到文件系统。`WriteMode` 可以为 `NO_OVERWRITE` 和 `OVERWRITE`,即是否覆盖原来路径里的内容。`OutputFormat` 与 `FileInputFormat` 类似,表示目标文件的文件格式。在最新的 Flink 版本中,这几个输出到文件系统的方法被标记为 `@Deprecated`,表示未来将被弃用,主要考虑到这些方法没有参与 Flink 的 Checkpoint 过程中,无法提供 Exactly-Once 保障。这些方法适合用于本地调试。 -在生产环境中,为了保证数据的一致性,官方建议使用`StreamingFileSink`接口。下面这个例子展示了如何将一个文本数据流输出到一个目标路径上。这里用到的是一个非常简单的配置,包括一个文件路径和一个`Encoder`。`Encoder`可以将数据编码以便对数据进行序列化。 +在生产环境中,为了保证数据的一致性,官方建议使用 `StreamingFileSink` 接口。下面这个例子展示了如何将一个文本数据流输出到一个目标路径上。这里用到的是一个非常简单的配置,包括一个文件路径和一个 `Encoder`。`Encoder` 可以将数据编码以便对数据进行序列化。 ```java DataStream stream = env.addSource(...); -// 使用StreamingFileSink将DataStream输出为一个文本文件 +// 使用 StreamingFileSink 将 DataStream 输出为一个文本文件 StreamingFileSink fileSink = StreamingFileSink .forRowFormat(new Path("/file/base/path"), new SimpleStringEncoder("UTF-8")) .build(); stream.addSink(fileSink); ``` -`StreamingFileSink`主要支持两类文件,一种是行式存储,一种是列式存储。我们平时见到的很多数据是行式存储的,即在文件的末尾追加新的行。列式存储在某些场景下的性能很高,它将一批数据收集起来,批量写入。行式存储和列式存储的接口如下。 +`StreamingFileSink` 主要支持两类文件,一种是行式存储,一种是列式存储。我们平时见到的很多数据是行式存储的,即在文件的末尾追加新的行。列式存储在某些场景下的性能很高,它将一批数据收集起来,批量写入。行式存储和列式存储的接口如下。 - 行式存储:`StreamingFileSink.forRowFormat(basePath, rowEncoder)`。 - 列式存储:`StreamingFileSink.forBulkFormat(basePath, bulkWriterFactory)`。 -回到刚才的例子上,它使用了行式存储,`SimpleStringEncoder`是Flink提供的预定义的`Encoder`,它通过数据流的`toString()`方法将内存数据转换为字符串,将字符串按照UTF-8编码写入输出中。`SimpleStringEncoder`可以用来编码转换字符串数据流,`SimpleStringEncoder`可以用来编码转换长整数数据流。 +回到刚才的例子上,它使用了行式存储,`SimpleStringEncoder` 是 Flink 提供的预定义的 `Encoder`,它通过数据流的 `toString()` 方法将内存数据转换为字符串,将字符串按照 UTF-8 编码写入输出中。`SimpleStringEncoder` 可以用来编码转换字符串数据流,`SimpleStringEncoder` 可以用来编码转换长整数数据流。 -如果数据流比较复杂,我们需要自己实现一个`Encoder`。代码清单 7-7中的数据流是一个`DataStream>`,我们需要实现`encode()`方法,将每个数据编码。 +如果数据流比较复杂,我们需要自己实现一个 `Encoder`。代码清单 7-7 中的数据流是一个 `DataStream>`,我们需要实现 `encode()` 方法,将每个数据编码。 ```java // 将一个二元组数据流编码并序列化 @@ -186,7 +195,7 @@ static class Tuple2Encoder implements Encoder> { } ``` -对于列式存储,也需要一个类似的`Encoder`,Flink称之为`BulkWriter`,本质上将数据序列化为列式存储所需的格式。比如我们想使用Parquet格式,代码如下。 +对于列式存储,也需要一个类似的 `Encoder`,Flink 称之为 `BulkWriter`,本质上将数据序列化为列式存储所需的格式。比如我们想使用 Parquet 格式,代码如下。 ```java DataStream stream = ...; @@ -198,7 +207,7 @@ StreamingFileSink fileSink = StreamingFileSink stream.addSink(fileSink); ``` -考虑到大数据场景下,输出数据量会很大,而且流处理作业需要长时间执行,`StreamingFileSink`的具体实现过程中使用了桶的概念。桶可以理解为输出路径的一个子文件夹。如果不做其他设置,Flink按照时间来将输出数据分桶,会在输出路径下生成类似下面的文件夹结构。 +考虑到大数据场景下,输出数据量会很大,而且流处理作业需要长时间执行,`StreamingFileSink` 的具体实现过程中使用了桶的概念。桶可以理解为输出路径的一个子文件夹。如果不做其他设置,Flink 按照时间来将输出数据分桶,会在输出路径下生成类似下面的文件夹结构。 ``` /file/base/path @@ -213,7 +222,7 @@ stream.addSink(fileSink); [base-path]/[bucket-path]/part-[task-id]-[id] ``` -最顶层的文件夹是我们设置的输出目录,第二层是桶,Flink将当前的时间作为`bucket-path`桶名。实际输出时,Flink会启动多个并行的实例,每个实例有自己的`task-id`,`task-id`被添加在了`part`之后。 +最顶层的文件夹是我们设置的输出目录,第二层是桶,Flink 将当前的时间作为 `bucket-path` 桶名。实际输出时,Flink 会启动多个并行的实例,每个实例有自己的 `task-id`,`task-id` 被添加在了 `part` 之后。 我们也可以自定义数据分配的方式,将某一条数据分配到相应的桶中。 @@ -224,13 +233,13 @@ StreamingFileSink fileSink = StreamingFileSink .build(); ``` -上述的文件夹结构中,有“inprogress”字样,这与`StreamingFileSink`能够提供的Exactly-Once保障有关。一份数据从生成到最终可用需要经过3个阶段:进行中(In-progress)、等待(Pending)和结束(Finished)。当数据刚刚生成时,文件处于In-progress阶段;当数据已经准备好(比如单个part文件足够大),文件被置为Pending阶段;下次Checkpoint执行完,整个作业的状态数据是一致的,文件最终被置为Finished阶段,Finished阶段的文件名没有“inprogress”的字样。从这个角度来看,`StreamingFileSink`和Checkpoint机制结合,能够提供Exactly-Once保障。 +上述的文件夹结构中,有“inprogress”字样,这与 `StreamingFileSink` 能够提供的 Exactly-Once 保障有关。一份数据从生成到最终可用需要经过 3 个阶段:进行中(In-progress)、等待(Pending)和结束(Finished)。当数据刚刚生成时,文件处于 In-progress 阶段;当数据已经准备好(比如单个 part 文件足够大),文件被置为 Pending 阶段;下次 Checkpoint 执行完,整个作业的状态数据是一致的,文件最终被置为 Finished 阶段,Finished 阶段的文件名没有“inprogress”的字样。从这个角度来看,`StreamingFileSink` 和 Checkpoint 机制结合,能够提供 Exactly-Once 保障。 -## 7.3.2 Flink Kafka Connector +## Flink Kafka Connector -在第1章中我们曾提到,Kafka是一个消息队列,它可以在Flink的上游向Flink发送数据,也可以在Flink的下游接收Flink的输出。Kafka是一个很多公司都采用的消息队列,因此非常具有代表性。 +在第 1 章中我们曾提到,Kafka 是一个消息队列,它可以在 Flink 的上游向 Flink 发送数据,也可以在 Flink 的下游接收 Flink 的输出。Kafka 是一个很多公司都采用的消息队列,因此非常具有代表性。 -Kafka的API经过不断迭代,已经趋于稳定,我们接下来主要介绍基于稳定版本的Kafka Connector。如果仍然使用较旧版本的Kafka(0.11或更旧的版本),可以通过官方文档来了解具体的使用方法。由于Kafka Connector并没有内置在Flink核心程序中,使用之前,我们需要在Maven中添加依赖。 +Kafka 的 API 经过不断迭代,已经趋于稳定,我们接下来主要介绍基于稳定版本的 Kafka Connector。如果仍然使用较旧版本的 Kafka(0.11 或更旧的版本),可以通过官方文档来了解具体的使用方法。由于 Kafka Connector 并没有内置在 Flink 核心程序中,使用之前,我们需要在 Maven 中添加依赖。 ```xml @@ -240,12 +249,12 @@ Kafka的API经过不断迭代,已经趋于稳定,我们接下来主要介绍 ``` -### 1. Flink Kafka Source +### Flink Kafka Source -Kafka作为一个Flink作业的上游,可以为该作业提供数据,我们需要一个可以连接Kafka的Source读取Kafka中的内容,这时Kafka是一个Producer,Flink作为Kafka的Consumer来消费Kafka中的数据。代码清单 7-8展示了如何初始化一个Kafka Source Connector。 +Kafka 作为一个 Flink 作业的上游,可以为该作业提供数据,我们需要一个可以连接 Kafka 的 Source 读取 Kafka 中的内容,这时 Kafka 是一个 Producer,Flink 作为 Kafka 的 Consumer 来消费 Kafka 中的数据。代码清单 7-8 展示了如何初始化一个 Kafka Source Connector。 ```java -// Kafka参数 +// Kafka 参数 Properties properties = new Properties(); properties.setProperty("bootstrap.servers", "localhost:9092"); properties.setProperty("group.id", "flink-group"); @@ -257,17 +266,17 @@ FlinkKafkaConsumer consumer = DataStream stream = env.addSource(consumer); ``` -代码清单 7-8 初始化Kafka Source Consumer +代码清单 7-8 初始化 Kafka Source Consumer -代码清单7-8创建了一个FlinkKafkaConsumer,它需要3个参数:Topic、反序列化方式和Kafka相关参数。Topic是我们想读取的具体内容,是一个字符串,并且可以支持正则表达式。Kafka中传输的是二进制数据,需要提供一个反序列化方式,将数据转化为具体的Java或Scala对象。Flink已经提供了一些序列化实现,比如:SimpleStringSchema按照字符串进行序列化和反序列化,JsonNodeDeserializationSchema使用Jackson对JSON数据进行序列化和反序列化。如果数据类型比较复杂,我们需要实现DeserializationSchema或者KafkaDeserializationSchema接口。最后一个参数Properties是Kafka相关的设置,用来配置Kafka的Consumer,我们需要配置bootstrap.servers和group.id,其他的参数可以参考Kafka的文档进行配置。 +代码清单 7-8 创建了一个 FlinkKafkaConsumer,它需要 3 个参数:Topic、反序列化方式和 Kafka 相关参数。Topic 是我们想读取的具体内容,是一个字符串,并且可以支持正则表达式。Kafka 中传输的是二进制数据,需要提供一个反序列化方式,将数据转化为具体的 Java 或 Scala 对象。Flink 已经提供了一些序列化实现,比如:SimpleStringSchema 按照字符串进行序列化和反序列化,JsonNodeDeserializationSchema 使用 Jackson 对 JSON 数据进行序列化和反序列化。如果数据类型比较复杂,我们需要实现 DeserializationSchema 或者 KafkaDeserializationSchema 接口。最后一个参数 Properties 是 Kafka 相关的设置,用来配置 Kafka 的 Consumer,我们需要配置 bootstrap.servers 和 group.id,其他的参数可以参考 Kafka 的文档进行配置。 -Flink Kafka Consumer可以配置从哪个位置读取消息队列中的数据。默认情况下,从Kafka Consumer Group记录的Offset开始消费,Consumer Group是根据group.id所配置的。其他配置可以参考下面的代码。 +Flink Kafka Consumer 可以配置从哪个位置读取消息队列中的数据。默认情况下,从 Kafka Consumer Group 记录的 Offset 开始消费,Consumer Group 是根据 group.id 所配置的。其他配置可以参考下面的代码。 ```java StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); FlinkKafkaConsumer consumer = new FlinkKafkaConsumer<>(...); -consumer.setStartFromGroupOffsets(); // 默认从Kafka记录中的Offset开始 +consumer.setStartFromGroupOffsets(); // 默认从 Kafka 记录中的 Offset 开始 consumer.setStartFromEarliest(); // 从最早的数据开始 consumer.setStartFromLatest(); // 从最近的数据开始 consumer.setStartFromTimestamp(...); // 从某个时间戳开始 @@ -275,16 +284,18 @@ consumer.setStartFromTimestamp(...); // 从某个时间戳开始 DataStream stream = env.addSource(consumer); ``` -**注意** +:::{note} 上述代码中配置消费的起始位置只影响作业第一次启动时所应读取的位置,不会影响故障恢复时重新消费的位置。 -如果作业启用了Flink的Checkpoint机制,Checkpoint时会记录Kafka Consumer消费到哪个位置,或者说记录了Consumer Group在该Topic下每个分区的Offset。如果遇到故障恢复,Flink会从最近一次的Checkpoint中恢复Offset,并从该Offset重新消费Kafka中的数据。可见,Flink Kafka Consumer是支持数据重发的。 +::: + +如果作业启用了 Flink 的 Checkpoint 机制,Checkpoint 时会记录 Kafka Consumer 消费到哪个位置,或者说记录了 Consumer Group 在该 Topic 下每个分区的 Offset。如果遇到故障恢复,Flink 会从最近一次的 Checkpoint 中恢复 Offset,并从该 Offset 重新消费 Kafka 中的数据。可见,Flink Kafka Consumer 是支持数据重发的。 -### 2. Flink Kafka Sink +### Flink Kafka Sink -Kafka作为Flink作业的下游,可以接收Flink作业的输出,这时我们可以通过Kafka Sink将处理好的数据输出到Kafka中。在这种场景下,Flink是生成数据的Producer,向Kafka输出。 -比如我们将WordCount程序结果输出到一个Kafka数据流中。 +Kafka 作为 Flink 作业的下游,可以接收 Flink 作业的输出,这时我们可以通过 Kafka Sink 将处理好的数据输出到 Kafka 中。在这种场景下,Flink 是生成数据的 Producer,向 Kafka 输出。 +比如我们将 WordCount 程序结果输出到一个 Kafka 数据流中。 ```java DataStream> wordCount = ... @@ -298,9 +309,9 @@ FlinkKafkaProducer> ( wordCount.addSink(producer); ``` -上面的代码创建了一个FlinkKafkaProducer,它需要4个参数:Topic、序列化方式、连接Kafka的相关参数以及选择什么样的投递保障。这些参数中,Topic和连接的相关Kafka参数与前文所述的内容基本一样。 +上面的代码创建了一个 FlinkKafkaProducer,它需要 4 个参数:Topic、序列化方式、连接 Kafka 的相关参数以及选择什么样的投递保障。这些参数中,Topic 和连接的相关 Kafka 参数与前文所述的内容基本一样。 -序列化方式与前面提到的反序列化方式相对应,它主要将Java或Scala对象转化为可在Kafka中传输的二进制数据。这个例子中,我们要传输的是一个Tuple2,需要提供对这个数据类型进行序列化的代码,例如代码清单 7-9的序列化代码。 +序列化方式与前面提到的反序列化方式相对应,它主要将 Java 或 Scala 对象转化为可在 Kafka 中传输的二进制数据。这个例子中,我们要传输的是一个 Tuple2,需要提供对这个数据类型进行序列化的代码,例如代码清单 7-9 的序列化代码。 ```java public static class KafkaWordCountSerializationSchema implements @@ -321,11 +332,11 @@ element.f1).getBytes(StandardCharsets.UTF_8)); } ``` -代码清单 7-9 将数据写到Kafka Sink时,需要进行序列化 +代码清单 7-9 将数据写到 Kafka Sink 时,需要进行序列化 -最后一个参数决定了Flink Kafka Sink以什么样的语义来保障数据写入Kafka,它接受FlinkKafkaProducer.Semantic的枚举类型,有3种类型:NONE、AT_LEAST_ONCE和EXACTLY_ONCE。 +最后一个参数决定了 Flink Kafka Sink 以什么样的语义来保障数据写入 Kafka,它接受 FlinkKafkaProducer.Semantic 的枚举类型,有 3 种类型:NONE、AT_LEAST_ONCE 和 EXACTLY_ONCE。 - None:不提供任何保障,数据可能会丢失也可能会重复。 - AT_LEAST_ONCE:保证不丢失数据,但是有可能会重复。 -- EXACTLY_ONCE:基于Kafka提供的事务写功能,一条数据最终只写入Kafka一次。 +- EXACTLY_ONCE:基于 Kafka 提供的事务写功能,一条数据最终只写入 Kafka 一次。 -其中,EXACTLY_ONCE基于Kafka提供的事务写功能,使用了我们提到的Two-Phase-Commit协议,它保证了数据端到端的Exactly-Once保障。当然,这个类型的代价是输出延迟会增大。实际执行过程中,这种方式比较依赖Kafka和Flink之间的协作,如果Flink作业的故障恢复时间过长,Kafka不会长时间保存事务中的数据,有可能发生超时,最终也可能会导致数据丢失。AT_LEAST_ONCE是默认的,它不会丢失数据,但数据有可能是重复的。 +其中,EXACTLY_ONCE 基于 Kafka 提供的事务写功能,使用了我们提到的 Two-Phase-Commit 协议,它保证了数据端到端的 Exactly-Once 保障。当然,这个类型的代价是输出延迟会增大。实际执行过程中,这种方式比较依赖 Kafka 和 Flink 之间的协作,如果 Flink 作业的故障恢复时间过长,Kafka 不会长时间保存事务中的数据,有可能发生超时,最终也可能会导致数据丢失。AT_LEAST_ONCE 是默认的,它不会丢失数据,但数据有可能是重复的。 diff --git a/doc/ch-flink-connectors/index.md b/doc/ch-flink-connectors/index.md index 47671ef..dfe97ed 100644 --- a/doc/ch-flink-connectors/index.md +++ b/doc/ch-flink-connectors/index.md @@ -1,6 +1,6 @@ -# Flink连接器 +# Flink 连接器 -经过前文的学习,我们已经了解了Flink如何对一个数据流进行有状态的计算。在实际生产环境中,数据可能存放在不同的系统中,比如文件系统、数据库或消息队列。一个完整的Flink作业包括Source和Sink两大模块,Source和Sink肩负着Flink与外部系统进行数据交互的重要功能,它们又被称为外部连接器(Connector)。本章将详细介绍Flink的Connector相关知识,主要内容如下。 +经过前文的学习,我们已经了解了 Flink 如何对一个数据流进行有状态的计算。在实际生产环境中,数据可能存放在不同的系统中,比如文件系统、数据库或消息队列。一个完整的 Flink 作业包括 Source 和 Sink 两大模块,Source 和 Sink 肩负着 Flink 与外部系统进行数据交互的重要功能,它们又被称为外部连接器(Connector)。本章将详细介绍 Flink 的 Connector 相关知识,主要内容如下。 ```{tableofcontents} ``` \ No newline at end of file diff --git a/doc/ch-programming-basics/exercise-dev-environment.md b/doc/ch-programming-basics/exercise-dev-environment.md index 40f2cd7..23b6a70 100644 --- a/doc/ch-programming-basics/exercise-dev-environment.md +++ b/doc/ch-programming-basics/exercise-dev-environment.md @@ -33,11 +33,11 @@ $ cd flink-1.11.2-bin-scala_2.11 # 进入解压目录 $ ./bin/start-cluster.sh # 启动 Flink 集群 ``` -成功启动后,打开浏览器,输入 `http://localhost:8081`,可以进入 Flink 集群的仪表盘(WebUI),如 {numref}`flink-WebUI-job` 所示。Flink WebUI 可以对 Flink 集群进行管理和监控。 +成功启动后,打开浏览器,输入 `http://localhost:8081`,可以进入 Flink 集群的仪表盘(WebUI),如 {numref}`fig-flink-WebUI` 所示。Flink WebUI 可以对 Flink 集群进行管理和监控。 ```{figure} ./img/flink-WebUI.png --- -name: flink-WebUI +name: fig-flink-WebUI width: 60% --- Flink WebUI @@ -63,61 +63,61 @@ archetype 是 Maven 提供的一种项目模板,是别人提前准备好了的 不熟悉 Maven 的读者可以先使用 IntelliJ IDEA 内置的 Maven 工具,熟悉 Maven 的读者可直接跳过这部分。 -如 {numref}`new-project` 所示,在 IntelliJ IDEA 里依次单击“File”→“New”→“Project”,创建一个新工程。 +如 {numref}`fig-new-project` 所示,在 IntelliJ IDEA 里依次单击“File”→“New”→“Project”,创建一个新工程。 ```{figure} ./img/new-project.png --- -name: new-project +name: fig-new-project width: 60% --- 在 IntelliJ IDEA 中创建新工程 ``` -如 {numref}`Maven` 所示,选择左侧的“Maven”,并勾选“Create from archetype”,并单击右侧的“Add Archetype”按钮。 +如 {numref}`fig-Maven` 所示,选择左侧的“Maven”,并勾选“Create from archetype”,并单击右侧的“Add Archetype”按钮。 ```{figure} ./img/Maven.png --- -name: Maven +name: fig-Maven width: 60% --- 添加 Maven 项目 ``` -如 {numref}`archetype` 所示,在弹出的窗口中填写 archetype 信息。其中 GroupId 为 org.apache.flink,ArtifactId 为 flink-quickstart-java,Version 为 1.11.2,然后单击“OK”。这里主要是告诉 Maven 去资源库中下载哪个版本的模板。随着 Flink 的迭代开发,Version 也在不断更新,读者可以在 Flink 的 Maven 资源库中查看最新的版本。GroupId、ArtifactId、Version 可以唯一表示一个发布出来的 Java 程序包。配置好后,单击 Next 按钮进入下一步。 +如 {numref}`fig-archetype` 所示,在弹出的窗口中填写 archetype 信息。其中 GroupId 为 org.apache.flink,ArtifactId 为 flink-quickstart-java,Version 为 1.11.2,然后单击“OK”。这里主要是告诉 Maven 去资源库中下载哪个版本的模板。随着 Flink 的迭代开发,Version 也在不断更新,读者可以在 Flink 的 Maven 资源库中查看最新的版本。GroupId、ArtifactId、Version 可以唯一表示一个发布出来的 Java 程序包。配置好后,单击 Next 按钮进入下一步。 ```{figure} ./img/archetype.png --- -name: archetype +name: fig-archetype width: 60% --- 填写 archetype 信息 ``` -如 {numref}`project-info` 所示,这一步是建立你自己的 Maven 工程,以区别其他 Maven 工程,GroupId 是你的公司或部门名称(可以随意填写),ArtifactId 是工程发布时的 Java 归档(Java Archive,JAR)包名,Version 是工程的版本。这些配置主要用于区别不同公司所发布的不同包,这与 Maven 和版本控制相关,Maven 的教程中都会介绍这些概念,这里不赘述。 +如 {numref}`fig-project-info` 所示,这一步是建立你自己的 Maven 工程,以区别其他 Maven 工程,GroupId 是你的公司或部门名称(可以随意填写),ArtifactId 是工程发布时的 Java 归档(Java Archive,JAR)包名,Version 是工程的版本。这些配置主要用于区别不同公司所发布的不同包,这与 Maven 和版本控制相关,Maven 的教程中都会介绍这些概念,这里不赘述。 ```{figure} ./img/project-info.png --- -name: project-info +name: fig-project-info width: 60% --- 配置你的工程信息 ``` -接下来可以继续单击“Next”按钮,注意最后一步选择你的工程所在的磁盘位置,单击“Finish”按钮,如 {numref}`project-location` 所示。至此,一个 Flink 模板就下载好了。 +接下来可以继续单击“Next”按钮,注意最后一步选择你的工程所在的磁盘位置,单击“Finish”按钮,如 {numref}`fig-project-location` 所示。至此,一个 Flink 模板就下载好了。 ```{figure} ./img/project-location.png --- -name: project-location +name: fig-project-location width: 60% --- 配置本工程的位置 ``` -工程结构如 {numref}`project-structure` 所示。左侧的“Project”栏是工程结构,其中 src/main/java 文件夹是 Java 代码文件存放位置,src/main/scala 是 Scala 代码文件存放位置。我们可以在 StreamingJob 这个文件上继续修改,也可以重新创建一个新文件。 +工程结构如 {numref}`fig-project-structure` 所示。左侧的“Project”栏是工程结构,其中 src/main/java 文件夹是 Java 代码文件存放位置,src/main/scala 是 Scala 代码文件存放位置。我们可以在 StreamingJob 这个文件上继续修改,也可以重新创建一个新文件。 ```{figure} ./img/project-structure.png --- -name: project-structure +name: fig-project-structure width: 60% --- 工程结构 @@ -272,12 +272,12 @@ StreamExecutionEnvironment.getExecutionEnvironment(); 我们在 1.7 节中展示过如何启动一个 Kafka 集群,并向某个 Topic 内发送数据流。在本次 Flink 作业启动之前,我们还要按照 1.7 节提到的方式启动一个 Kafka 集群、创建对应的 Topic,并向 Topic 中写入数据。 1. 在 IntelliJ IDEA 中运行程序 - - 在 IntelliJ IDEA 中,单击绿色运行按钮,运行这个程序。{numref}`run` 所示的两个绿色运行按钮中的任意一个都可以运行这个程序。 - - IntelliJ IDEA 下方的“Run”栏会显示程序的输出,包括本次需要输出的结果,{numref}`result` 所示。 + - 在 IntelliJ IDEA 中,单击绿色运行按钮,运行这个程序。{numref}`fig-run` 所示的两个绿色运行按钮中的任意一个都可以运行这个程序。 + - IntelliJ IDEA 下方的“Run”栏会显示程序的输出,包括本次需要输出的结果,{numref}`fig-result` 所示。 ```{figure} ./img/run.png --- -name: run +name: fig-run width: 60% --- 在 IntelliJ IDEA 中运行 Flink 程序 @@ -285,7 +285,7 @@ width: 60% ```{figure} ./img/result.png --- -name: result +name: fig-result width: 60% --- WordCount 程序运行结果 @@ -319,11 +319,11 @@ com.flink.tutorials.java.api.projects.wordcount.WordCountKafkaInStdOut /Users/luweizheng/Projects/big-data/flink-tutorials/target/flink-tutorials-0.1.jar ``` -如{numref}`flink-WebUI-job`示,这时,Flink WebUI 上就多了一个 Flink 作业。 +如{numref}`fig-flink-WebUI-job`示,这时,Flink WebUI 上就多了一个 Flink 作业。 ```{figure} ./img/flink-WebUI-job.png --- -name: flink-WebUI-job +name: fig-flink-WebUI-job width: 60% --- Flink WebUI 中多了一个 Flink 作业 diff --git a/doc/ch-programming-basics/functional-programming.md b/doc/ch-programming-basics/functional-programming.md index 7f4d943..ccd3aac 100644 --- a/doc/ch-programming-basics/functional-programming.md +++ b/doc/ch-programming-basics/functional-programming.md @@ -73,11 +73,11 @@ x -> 2 * x 代码清单 2-5 Java Lambda 表达式 -可以看到,这几个例子都有一个 `->`,表示这是一个函数式的映射,相对比较灵活的是左侧的输入参数和右侧的函数体。{numref}`lambda` 所示为 Java Lambda 表达式的拆解,这很符合数学中对一个函数做映射的思维方式。 +可以看到,这几个例子都有一个 `->`,表示这是一个函数式的映射,相对比较灵活的是左侧的输入参数和右侧的函数体。{numref}`fig-lambda` 所示为 Java Lambda 表达式的拆解,这很符合数学中对一个函数做映射的思维方式。 ```{figure} ./img/lambda.png --- -name: lambda +name: fig-lambda width: 60% --- Java Lambda 表达式拆解 @@ -185,11 +185,11 @@ lengths.forEach((s) -> System.out.println(s)); 代码清单 2-7 使用 Lambda 表达式来完成对 String 类型列表的操作 -这段代码中,数据先经过 `stream()` 方法被转换为一个 `Stream` 类型,后经过 `filter()`、`map()`、`collect()` 等处理逻辑,生成我们所需的输出。各个操作之间使用英文点号 `.` 来连接,这种方式被称作方法链(Method Chaining)或者链式调用。链式调用可以被抽象成一个管道(Pipeline),将代码清单 2-7 进行抽象,可以形成 {numref}`stream` 所示的 Stream 管道。 +这段代码中,数据先经过 `stream()` 方法被转换为一个 `Stream` 类型,后经过 `filter()`、`map()`、`collect()` 等处理逻辑,生成我们所需的输出。各个操作之间使用英文点号 `.` 来连接,这种方式被称作方法链(Method Chaining)或者链式调用。链式调用可以被抽象成一个管道(Pipeline),将代码清单 2-7 进行抽象,可以形成 {numref}`fig-stream` 所示的 Stream 管道。 ```{figure} ./img/stream.png --- -name: stream +name: fig-stream width: 60% --- Stream 管道 diff --git a/doc/ch-programming-basics/inheritance-and-polymorphism.md b/doc/ch-programming-basics/inheritance-and-polymorphism.md index df342b4..c498c41 100644 --- a/doc/ch-programming-basics/inheritance-and-polymorphism.md +++ b/doc/ch-programming-basics/inheritance-and-polymorphism.md @@ -7,14 +7,11 @@ 继承在现实世界中无处不在。比如我们想描述动物和它们的行为,可以先创建一个动物类别,动物类别又可以分为狗和鱼,这样的一种层次结构其实就是编程语言中的继承关系。动物类涵盖了每种动物都有的属性,比如名字、描述信息等。从动物类衍生出的众多子类,比如鱼类、狗类等都具备动物的基本属性。不同类型的动物又有自己的特点,比如鱼会游泳、狗会吼叫。继承关系保证所有动物都具有动物的基本属性,这样就不必在创建一个新的子类的时候,将它们的基本属性(名字、描述信息)再复制一遍。同时,子类更加关注自己区别于其他类的特点,比如鱼所特有的游泳动作。 -{numref}`extend` 所示为对动物进行的简单的建模。其中,每个动物都有一些基本属性,即名字(name)和描述(description);有一些基本方法,即 getName()和 eat(),这些基本功能共同组成了 Animal 类。在 Animal 类的基础上,可以衍生出各种各样的子类、子类的子类等。比如,Dog 类有自己的 dogData 属性和 bark()方法,同时也可以使用父类的 name 等属性和 eat() 方法。 +{numref}`fig-extend` 所示为对动物进行的简单的建模。其中,每个动物都有一些基本属性,即名字(name)和描述(description);有一些基本方法,即 getName()和 eat(),这些基本功能共同组成了 Animal 类。在 Animal 类的基础上,可以衍生出各种各样的子类、子类的子类等。比如,Dog 类有自己的 dogData 属性和 bark()方法,同时也可以使用父类的 name 等属性和 eat() 方法。 -我们将 {numref}`extend` 所示的 Animal 类继承关系转化为代码,一个 Animal 公共父类可以抽象如 {numref}`code-animal-class` 所示。 - -```{code-block} java -:caption: Animal 类 -:name: code-animal-class +我们将 {numref}`fig-extend` 所示的 Animal 类继承关系转化为代码,一个 Animal 公共父类可以抽象如代码清单 2-1 所示。 +```java public class Animal { private String name; @@ -35,20 +32,19 @@ public class Animal { } ``` +代码清单 2-1 一个简单的 Animal 类 + ```{figure} ./img/extend.png --- -name: extend +name: fig-extend width: 60% --- Animal 类继承关系 ``` -子类可以拥有父类非 private 的属性和方法,同时可以扩展属于自己的属性和方法。比如 Dog 类或 Fish 类可以继承 Animal 类,可以直接复用 Animal 类里定义的属性和方法。这样就不存在代码的重复问题,整个工程的可维护性更好。在 Java 和 Scala 中,子类继承父类时都要使用 extends 关键字。{numref}`code-dog-class` 实现了一个 Dog 类,并在里面添加了 Dog 类的一些特有成员。 - -```{code-block} java -:caption: Dog 类 -:name: code-dog-class +子类可以拥有父类非 private 的属性和方法,同时可以扩展属于自己的属性和方法。比如 Dog 类或 Fish 类可以继承 Animal 类,可以直接复用 Animal 类里定义的属性和方法。这样就不存在代码的重复问题,整个工程的可维护性更好。在 Java 和 Scala 中,子类继承父类时都要使用 extends 关键字。代码清单 2-2 实现了一个 Dog 类,并在里面添加了 Dog 类的一些特有成员。 +```java public class Dog extends Animal implements Move { private String dogData; diff --git a/doc/ch-state-checkpoint/checkpoint.md b/doc/ch-state-checkpoint/checkpoint.md index 8d5be79..6979669 100644 --- a/doc/ch-state-checkpoint/checkpoint.md +++ b/doc/ch-state-checkpoint/checkpoint.md @@ -3,156 +3,190 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -在上一节中,我们介绍了Flink的状态都是基于本地的,而Flink又是一个部署在多节点的分布式系统,分布式系统经常出现进程被杀、节点宕机或网络中断等问题,那么本地的状态在遇到故障时如何保证不丢呢?Flink定期保存状态数据到存储上,故障发生后从之前的备份中恢复,这个过程被称为Checkpoint机制。Checkpoint为Flink提供了Exactly-Once的投递保障。本节将介绍Flink的Checkpoint机制的原理,介绍中会使用多个概念:快照(Snapshot)、分布式快照(Distributed Snapshot)、检查点(Checkpoint)等,这些概念均指的是Flink的Checkpoint机制提供的数据备份过程,读者可以将这些概念等同看待。 +在上一节中,我们介绍了 Flink 的状态都是基于本地的,而 Flink 又是一个部署在多节点的分布式系统,分布式系统经常出现进程被杀、节点宕机或网络中断等问题,那么本地的状态在遇到故障时如何保证不丢呢?Flink 定期保存状态数据到存储上,故障发生后从之前的备份中恢复,这个过程被称为 Checkpoint 机制。Checkpoint 为 Flink 提供了 Exactly-Once 的投递保障。本节将介绍 Flink 的 Checkpoint 机制的原理,介绍中会使用多个概念:快照(Snapshot)、分布式快照(Distributed Snapshot)、检查点(Checkpoint)等,这些概念均指的是 Flink 的 Checkpoint 机制提供的数据备份过程,读者可以将这些概念等同看待。 -## Flink分布式快照流程 +## Flink 分布式快照流程 -首先我们来看一下一个简单的Checkpoint的大致流程: +首先我们来看一下一个简单的 Checkpoint 的大致流程: 1. 暂停处理新流入数据,将新数据缓存起来。 2. 将算子子任务的本地状态数据拷贝到一个远程的持久化存储上。 3. 继续处理新流入的数据,包括刚才缓存起来的数据。 -Flink是在Chandy–Lamport算法[^1]的基础上实现了一种分布式快照算法。在介绍Flink的快照详细流程前,我们先要了解一下检查点分界线(Checkpoint Barrier)的概念。如下图所示,Checkpoint Barrier被插入到数据流中,它将数据流切分成段。Flink的Checkpoint逻辑是,一段新数据流入导致状态发生了变化,Flink的算子接收到Checpoint Barrier后,对状态进行快照。每个Checkpoint Barrier有一个ID,表示该段数据属于哪次Checkpoint。如下图所示,当ID为n的Checkpoint Barrier到达每个算子后,表示要对n-1和n之间状态更新做快照。Checkpoint Barrier有点像Event Time中的Watermark,它被插入到数据流中,但并不影响数据流原有的处理顺序。 - -![Checkpoint Barrier](./img/checkpoint-barrier.png) - -接下来,我们构建一个并行数据流图,用这个并行数据流图来演示Flink的分布式快照机制。这个数据流图的并行度为2,数据流会在这些并行算子上从Source流动到Sink。 - -首先,Flink的检查点协调器(Checkpoint Coordinator)触发一次Checkpoint(Trigger Checkpoint),这个请求会发送给Source的各个子任务。 - -![JobManager触发一次Checkpoint](./img/checkpoint-1.png) - - - -各Source算子子任务接收到这个Checkpoint请求之后,会将自己的状态写入到状态后端,生成一次快照,并且会向下游广播Checkpoint Barrier。 - -![Source将自身状态写入状态后端,向下游发送Checkpoint Barrier](./img/checkpoint-2.png) +Flink 是在 Chandy–Lamport 算法 [^1] 的基础上实现了一种分布式快照算法。在介绍 Flink 的快照详细流程前,我们先要了解一下检查点分界线(Checkpoint Barrier)的概念。如{numref}`fig-checkpoint-barrier` 所示,Checkpoint Barrier 被插入到数据流中,它将数据流切分成段。Flink 的 Checkpoint 逻辑是,一段新数据流入导致状态发生了变化,Flink 的算子接收到 Checpoint Barrier 后,对状态进行快照。每个 Checkpoint Barrier 有一个 ID,表示该段数据属于哪次 Checkpoint。如{numref}`fig-checkpoint-barrier` 所示,当 ID 为 n 的 Checkpoint Barrier 到达每个算子后,表示要对 n-1 和 n 之间状态更新做快照。Checkpoint Barrier 有点像 Event Time 中的 Watermark,它被插入到数据流中,但并不影响数据流原有的处理顺序。 +```{figure} ./img/checkpoint-barrier.png +--- +name: fig-checkpoint-barrier +width: 80% +align: center +--- +Checkpoint Barrier +``` +接下来,我们构建一个并行数据流图,用这个并行数据流图来演示 Flink 的分布式快照机制。这个数据流图的并行度为 2,数据流会在这些并行算子上从 Source 流动到 Sink。 -Source算子做完快照后,还会给Checkpoint Coodinator发送一个确认,告知自己已经做完了相应的工作。这个确认中包括了一些元数据,其中就包括刚才备份到State Backend的状态句柄,或者说是指向状态的指针。至此,Source完成了一次Checkpoint。跟Watermark的传播一样,一个算子子任务要把Checkpoint Barrier发送给所连接的所有下游子任务。 +首先,Flink 的检查点协调器(Checkpoint Coordinator)触发一次 Checkpoint(Trigger Checkpoint),这个请求会发送给 Source 的各个子任务。 -![Snapshot之后发送ACK给JobManager](./img/checkpoint-3.png) +```{figure} ./img/checkpoint-1.png +--- +name: fig-checkpoint-trigger +width: 80% +align: center +--- +JobManager 触发一次 Checkpoint +``` -对于下游算子来说,可能有多个与之相连的上游输入,我们将算子之间的边称为通道。Source要将一个ID为n的Checkpoint Barrier向所有下游算子广播,这也意味着下游算子的多个输入通道里都会收到ID为n的Checkpoint Barrier,而且不同输入通道里Checkpoint Barrier的流入速度不同,ID为n的Checkpoint Barrier到达的时间不同。Checkpoint Barrier传播的过程需要进行对齐(Barrier Alignment),我们从数据流图中截取一小部分,以下图为例,来分析Checkpoint Barrier是如何在算子间传播和对齐的。 +各 Source 算子子任务接收到这个 Checkpoint 请求之后,会将自己的状态写入到状态后端,生成一次快照,并且会向下游广播 Checkpoint Barrier。 -![Barrier在算子间传播过程](./img/barrier-alignment.png) +```{figure} ./img/checkpoint-2.png +--- +name: fig-checkpoint-source +width: 80% +align: center +--- +Source 将自身状态写入状态后端,向下游发送 Checkpoint Barrier +``` +Source 算子做完快照后,还会给 Checkpoint Coodinator 发送一个确认,告知自己已经做完了相应的工作。这个确认中包括了一些元数据,其中就包括刚才备份到 State Backend 的状态句柄,或者说是指向状态的指针。至此,Source 完成了一次 Checkpoint。跟 Watermark 的传播一样,一个算子子任务要把 Checkpoint Barrier 发送给所连接的所有下游子任务。 +```{figure} ./img/checkpoint-3.png +--- +name: fig-checkpoint-ack +width: 80% +align: center +--- +Snapshot 之后发送 ACK 给 JobManager +``` +对于下游算子来说,可能有多个与之相连的上游输入,我们将算子之间的边称为通道。Source 要将一个 ID 为 n 的 Checkpoint Barrier 向所有下游算子广播,这也意味着下游算子的多个输入通道里都会收到 ID 为 n 的 Checkpoint Barrier,而且不同输入通道里 Checkpoint Barrier 的流入速度不同,ID 为 n 的 Checkpoint Barrier 到达的时间不同。Checkpoint Barrier 传播的过程需要进行对齐(Barrier Alignment),我们从数据流图中截取一小部分,以下图为例,来分析 Checkpoint Barrier 是如何在算子间传播和对齐的。 +```{figure} ./img/barrier-alignment.png +--- +name: fig-barrier-propagation +width: 80% +align: center +--- +Barrier 在算子间传播过程 +``` -如上图所示,对齐分为四步: +如 {numref}`fig-barrier-propagation` 所示,对齐分为四步: -1. 算子子任务在某个输入通道中收到第一个ID为n的Checkpoint Barrier,但是其他输入通道中ID为n的Checkpoint Barrier还未到达,该算子子任务开始准备进行对齐。 +1. 算子子任务在某个输入通道中收到第一个 ID 为 n 的 Checkpoint Barrier,但是其他输入通道中 ID 为 n 的 Checkpoint Barrier 还未到达,该算子子任务开始准备进行对齐。 2. 算子子任务将第一个输入通道的数据缓存下来,同时继续处理其他输入通道的数据,这个过程被称为对齐。 -3. 第二个输入通道ID为n的Checkpoint Barrier抵达该算子子任务,所有通道ID为n的Checkpoint Barrier都到达该算子子任务,该算子子任务执行快照,将状态写入State Backend,然后将ID为n的Checkpoint Barrier向下游所有输出通道广播。 +3. 第二个输入通道 ID 为 n 的 Checkpoint Barrier 抵达该算子子任务,所有通道 ID 为 n 的 Checkpoint Barrier 都到达该算子子任务,该算子子任务执行快照,将状态写入 State Backend,然后将 ID 为 n 的 Checkpoint Barrier 向下游所有输出通道广播。 4. 对于这个算子子任务,快照执行结束,继续处理各个通道中新流入数据,包括刚才缓存起来的数据。 -数据流图中的每个算子子任务都要完成一遍上述的对齐、快照、确认的工作,当最后所有Sink算子确认完成快照之后,说明ID为n的Checkpoint执行结束,Checkpoint Coordinator向State Backend写入一些本次Checkpoint的元数据。 +数据流图中的每个算子子任务都要完成一遍上述的对齐、快照、确认的工作,当最后所有 Sink 算子确认完成快照之后,说明 ID 为 n 的 Checkpoint 执行结束,Checkpoint Coordinator 向 State Backend 写入一些本次 Checkpoint 的元数据。 -![Sink算子向JobManager发送ACK,一次Checkpoint完成](./img/checkpoint-4.png) +```{figure} ./img/checkpoint-4.png +--- +name: fig-checkpoint-completion +width: 80% +align: center +--- +Sink 算子向 JobManager 发送 ACK,一次 Checkpoint 完成 +``` -之所以要进行对齐,主要是为了保证一个Flink作业所有算子的状态是一致的,也就是说,一个Flink作业前前后后所有算子写入State Backend的状态都是基于同样的数据。 +之所以要进行对齐,主要是为了保证一个 Flink 作业所有算子的状态是一致的,也就是说,一个 Flink 作业前前后后所有算子写入 State Backend 的状态都是基于同样的数据。 ## 快照性能优化方案 前面和大家介绍了一致性快照的具体流程,这种方式保证了数据的一致性,但有一些潜在的问题: -1. 每次进行Checkpoint前,都需要暂停处理新流入数据,然后开始执行快照,假如状态比较大,一次快照可能长达几秒甚至几分钟。 -2. Checkpoint Barrier对齐时,必须等待所有上游通道都处理完,假如某个上游通道处理很慢,这可能造成整个数据流堵塞。 +1. 每次进行 Checkpoint 前,都需要暂停处理新流入数据,然后开始执行快照,假如状态比较大,一次快照可能长达几秒甚至几分钟。 +2. Checkpoint Barrier 对齐时,必须等待所有上游通道都处理完,假如某个上游通道处理很慢,这可能造成整个数据流堵塞。 -针对这些问题Flink已经有了一些解决方案,并且还在不断优化。 +针对这些问题 Flink 已经有了一些解决方案,并且还在不断优化。 -对于第一个问题,Flink提供了异步快照(Asynchronous Snapshot)的机制。当实际执行快照时,Flink可以立即向下广播Checkpoint Barrier,表示自己已经执行完自己部分的快照。同时,Flink启动一个后台线程,它创建本地状态的一份拷贝,这个线程用来将本地状态的拷贝同步到State Backend上,一旦数据同步完成,再给Checkpoint Coordinator发送确认信息。拷贝一份数据肯定占用更多内存,这时可以利用写入时复制(Copy-on-Write)的优化策略。Copy-on-Write指:如果这份内存数据没有任何修改,那没必要生成一份拷贝,只需要有一个指向这份数据的指针,通过指针将本地数据同步到State Backend上;如果这份内存数据有一些更新,那再去申请额外的内存空间并维护两份数据,一份是快照时的数据,一份是更新后的数据。是否开启Asynchronous Snapshot是可以配置的,下一节使用不同的State Backend将介绍如何配置。 +对于第一个问题,Flink 提供了异步快照(Asynchronous Snapshot)的机制。当实际执行快照时,Flink 可以立即向下广播 Checkpoint Barrier,表示自己已经执行完自己部分的快照。同时,Flink 启动一个后台线程,它创建本地状态的一份拷贝,这个线程用来将本地状态的拷贝同步到 State Backend 上,一旦数据同步完成,再给 Checkpoint Coordinator 发送确认信息。拷贝一份数据肯定占用更多内存,这时可以利用写入时复制(Copy-on-Write)的优化策略。Copy-on-Write 指:如果这份内存数据没有任何修改,那没必要生成一份拷贝,只需要有一个指向这份数据的指针,通过指针将本地数据同步到 State Backend 上;如果这份内存数据有一些更新,那再去申请额外的内存空间并维护两份数据,一份是快照时的数据,一份是更新后的数据。是否开启 Asynchronous Snapshot 是可以配置的,下一节使用不同的 State Backend 将介绍如何配置。 -对于第二个问题,Flink允许跳过对齐这一步,或者说一个算子子任务不需要等待所有上游通道的Checkpoint Barrier,直接将Checkpoint Barrier广播,执行快照并继续处理后续流入数据。为了保证数据一致性,Flink必须将那些上下游正在传输的数据也作为状态保存到快照中,一旦重启,这些元素会被重新处理一遍。这种不需要对齐的Checkpoint机制被称为Unaligned Checkpoint,我们可以通过`env.getCheckpointConfig().enableUnalignedCheckpoints();`开启Unaligned Checkpoint。Unaligned Checkpoint也是支持Exactly-Once的。Unaligned Checkpoint不执行Checkpoint Barrier对齐,因此在负载较重的场景下表现更好,但这并不意味这Unaligned Checkpoint就是最优方案,由于要将正在传输的数据也进行快照,状态数据会很大,磁盘负载会加重,同时更大的状态意味着重启后状态恢复的时间也更长,运维管理的难度更大。 +对于第二个问题,Flink 允许跳过对齐这一步,或者说一个算子子任务不需要等待所有上游通道的 Checkpoint Barrier,直接将 Checkpoint Barrier 广播,执行快照并继续处理后续流入数据。为了保证数据一致性,Flink 必须将那些上下游正在传输的数据也作为状态保存到快照中,一旦重启,这些元素会被重新处理一遍。这种不需要对齐的 Checkpoint 机制被称为 Unaligned Checkpoint,我们可以通过 `env.getCheckpointConfig().enableUnalignedCheckpoints();` 开启 Unaligned Checkpoint。Unaligned Checkpoint 也是支持 Exactly-Once 的。Unaligned Checkpoint 不执行 Checkpoint Barrier 对齐,因此在负载较重的场景下表现更好,但这并不意味这 Unaligned Checkpoint 就是最优方案,由于要将正在传输的数据也进行快照,状态数据会很大,磁盘负载会加重,同时更大的状态意味着重启后状态恢复的时间也更长,运维管理的难度更大。 ## State Backend -前面已经分享了Flink的快照机制,其中State Backend起到了持久化存储数据的重要功能。Flink将State Backend抽象成了一种插件,并提供了三种State Backend,每种State Backend对数据的保存和恢复方式略有不同。接下来我们开始详细了解一下Flink的State Backend。 +前面已经分享了 Flink 的快照机制,其中 State Backend 起到了持久化存储数据的重要功能。Flink 将 State Backend 抽象成了一种插件,并提供了三种 State Backend,每种 State Backend 对数据的保存和恢复方式略有不同。接下来我们开始详细了解一下 Flink 的 State Backend。 ### MemoryStateBackend -从名字中可以看出,这种State Backend主要基于内存,它将数据存储在Java的堆区。当进行分布式快照时,所有算子子任务将自己内存上的状态同步到JobManager的堆上。因此,一个作业的所有状态要小于JobManager的内存大小。这种方式显然不能存储过大的状态数据,否则将抛出`OutOfMemoryError`异常。这种方式只适合调试或者实验,不建议在生产环境下使用。下面的代码告知一个Flink作业使用内存作为State Backend,并在参数中指定了状态的最大值,默认情况下,这个最大值是5MB。 +从名字中可以看出,这种 State Backend 主要基于内存,它将数据存储在 Java 的堆区。当进行分布式快照时,所有算子子任务将自己内存上的状态同步到 JobManager 的堆上。因此,一个作业的所有状态要小于 JobManager 的内存大小。这种方式显然不能存储过大的状态数据,否则将抛出 `OutOfMemoryError` 异常。这种方式只适合调试或者实验,不建议在生产环境下使用。下面的代码告知一个 Flink 作业使用内存作为 State Backend,并在参数中指定了状态的最大值,默认情况下,这个最大值是 5MB。 ```java env.setStateBackend(new MemoryStateBackend(MAX_MEM_STATE_SIZE)); ``` -如果不做任何配置,默认情况是使用内存作为State Backend。 +如果不做任何配置,默认情况是使用内存作为 State Backend。 ### FsStateBackend -这种方式下,数据持久化到文件系统上,文件系统包括本地磁盘、HDFS以及包括Amazon、阿里云在内的云存储服务。使用时,我们要提供文件系统的地址,尤其要写明前缀,比如:`file://`、`hdfs://`或`s3://`。此外,这种方式支持Asynchronous Snapshot,默认情况下这个功能是开启的,可加快数据同步速度。 +这种方式下,数据持久化到文件系统上,文件系统包括本地磁盘、HDFS 以及包括 Amazon、阿里云在内的云存储服务。使用时,我们要提供文件系统的地址,尤其要写明前缀,比如:`file://`、`hdfs://` 或 `s3://`。此外,这种方式支持 Asynchronous Snapshot,默认情况下这个功能是开启的,可加快数据同步速度。 ```java -// 使用HDFS作为State Backend +// 使用 HDFS 作为 State Backend env.setStateBackend(new FsStateBackend("hdfs://namenode:port/flink-checkpoints/chk-17/")); -// 使用阿里云OSS作为State Backend +// 使用阿里云 OSS 作为 State Backend env.setStateBackend(new FsStateBackend("oss:///")); -// 使用Amazon作为State Backend +// 使用 Amazon 作为 State Backend env.setStateBackend(new FsStateBackend("s3:///")); -// 关闭Asynchronous Snapshot +// 关闭 Asynchronous Snapshot env.setStateBackend(new FsStateBackend(checkpointPath, false)); ``` -Flink的本地状态仍然在TaskManager的内存堆区上,直到执行快照时状态数据会写到所配置的文件系统上。因此,这种方式能够享受本地内存的快速读写访问,也能保证大容量状态作业的故障恢复能力。 +Flink 的本地状态仍然在 TaskManager 的内存堆区上,直到执行快照时状态数据会写到所配置的文件系统上。因此,这种方式能够享受本地内存的快速读写访问,也能保证大容量状态作业的故障恢复能力。 ### RocksDBStateBackend -这种方式下,本地状态存储在本地的RocksDB上。RocksDB是一种嵌入式Key-Value数据库,数据实际保存在本地磁盘上。比起`FsStateBackend`的本地状态存储在内存中,RocksDB利用了磁盘空间,所以可存储的本地状态更大。然而,每次从RocksDB中读写数据都需要进行序列化和反序列化,因此读写本地状态的成本更高。快照执行时,Flink将存储于本地RocksDB的状态同步到远程的存储上,因此使用这种State Backend时,也要配置分布式存储的地址。Asynchronous Snapshot在默认情况也是开启的。 +这种方式下,本地状态存储在本地的 RocksDB 上。RocksDB 是一种嵌入式 Key-Value 数据库,数据实际保存在本地磁盘上。比起 `FsStateBackend` 的本地状态存储在内存中,RocksDB 利用了磁盘空间,所以可存储的本地状态更大。然而,每次从 RocksDB 中读写数据都需要进行序列化和反序列化,因此读写本地状态的成本更高。快照执行时,Flink 将存储于本地 RocksDB 的状态同步到远程的存储上,因此使用这种 State Backend 时,也要配置分布式存储的地址。Asynchronous Snapshot 在默认情况也是开启的。 -此外,这种State Backend允许增量快照(Incremental Checkpoint),Incremental Checkpoint的核心思想是每次快照时只对发生变化的数据增量写到分布式存储上,而不是将所有的本地状态都拷贝过去。Incremental Checkpoint非常适合超大规模的状态,快照的耗时将明显降低,同时,它的代价是重启恢复的时间更长。默认情况下,Incremental Checkpoint没有开启,需要我们手动开启。 +此外,这种 State Backend 允许增量快照(Incremental Checkpoint),Incremental Checkpoint 的核心思想是每次快照时只对发生变化的数据增量写到分布式存储上,而不是将所有的本地状态都拷贝过去。Incremental Checkpoint 非常适合超大规模的状态,快照的耗时将明显降低,同时,它的代价是重启恢复的时间更长。默认情况下,Incremental Checkpoint 没有开启,需要我们手动开启。 ```java -// 开启Incremental Checkpoint +// 开启 Incremental Checkpoint boolean enableIncrementalCheckpointing = true; env.setStateBackend(new RocksDBStateBackend(checkpointPath, enableIncrementalCheckpointing)); ``` -相比`FsStateBackend`,`RocksDBStateBackend`能够支持的本地和远程状态都更大,Flink社区已经有TB级的案例。 +相比 `FsStateBackend`,`RocksDBStateBackend` 能够支持的本地和远程状态都更大,Flink 社区已经有 TB 级的案例。 -除了上述三种之外,开发者也可以自行开发State Backend的具体实现。 +除了上述三种之外,开发者也可以自行开发 State Backend 的具体实现。 ## 故障重启恢复流程 ### 重启恢复基本流程 -Flink的重启恢复逻辑相对比较简单: +Flink 的重启恢复逻辑相对比较简单: 1. 重启应用,在集群上重新部署数据流图。 -2. 从持久化存储上读取最近一次的Checkpoint数据,加载到各算子子任务上。 +2. 从持久化存储上读取最近一次的 Checkpoint 数据,加载到各算子子任务上。 3. 继续处理新流入的数据。 -这样的机制可以保证Flink内部状态的Excatly-Once一致性。至于端到端的Exactly-Once一致性,要根据Source和Sink的具体实现而定,我们还会在第7章端到端Exactly-Once详细讨论。当发生故障时,一部分数据有可能已经流入系统,但还未进行Checkpoint,Source的Checkpoint记录了输入的Offset;当重启时,Flink能把最近一次的Checkpoint恢复到内存中,并根据Offset,让Source从该位置重新发送一遍数据,以保证数据不丢不重。像Kafka等消息队列是提供重发功能的,`socketTextStream`就不具有这种功能,也意味着不能保证端到端的Exactly-Once投递保障。 +这样的机制可以保证 Flink 内部状态的 Excatly-Once 一致性。至于端到端的 Exactly-Once 一致性,要根据 Source 和 Sink 的具体实现而定,我们还会在第 7 章端到端 Exactly-Once 详细讨论。当发生故障时,一部分数据有可能已经流入系统,但还未进行 Checkpoint,Source 的 Checkpoint 记录了输入的 Offset;当重启时,Flink 能把最近一次的 Checkpoint 恢复到内存中,并根据 Offset,让 Source 从该位置重新发送一遍数据,以保证数据不丢不重。像 Kafka 等消息队列是提供重发功能的,`socketTextStream` 就不具有这种功能,也意味着不能保证端到端的 Exactly-Once 投递保障。 当一个作业出现故障,进行重启时,势必会暂停一段时间,这段时间上游数据仍然继续发送过来。作业被重新拉起后,肯定需要将刚才未处理的数据消化掉。这个过程可以被理解为,一次跑步比赛,运动员不慎跌倒,爬起来重新向前追击。为了赶上当前最新进度,作业必须以更快的速度处理囤积的数据。所以,在设定资源时,我们必须留出一定的富余量,以保证重启后这段“赶进度”过程中的资源消耗。 ### 三种重启策略 -一般情况下,一个作业遇到一些异常情况会导致运行异常,潜在的异常情况包括:机器故障、部署环境抖动、流量激增、输入数据异常等。以输入数据异常为例,如果一个作业发生了故障重启,如果触发故障的原因没有根除,那么重启之后仍然会出现故障。因此,在解决根本问题之前,一个作业很可能无限次地故障重启,陷入死循环。为了避免重启死循环,Flink提供了三种重启策略: +一般情况下,一个作业遇到一些异常情况会导致运行异常,潜在的异常情况包括:机器故障、部署环境抖动、流量激增、输入数据异常等。以输入数据异常为例,如果一个作业发生了故障重启,如果触发故障的原因没有根除,那么重启之后仍然会出现故障。因此,在解决根本问题之前,一个作业很可能无限次地故障重启,陷入死循环。为了避免重启死循环,Flink 提供了三种重启策略: * 固定延迟(Fixed Delay)策略:作业每次失败后,按照设定的时间间隔进行重启尝试,重启次数不会超过某个设定值。 * 失败率(Failure Rate)策略:计算一个时间段内作业失败的次数,如果失败次数小于设定值,继续重启,否则不重启。 * 不重启(No Restart)策略:不对作业进行重启。 -重启策略的前提是作业进行了Checkpoint,如果作业未设置Checkpoint,则会使用No Restart的策略。重启策略可以在`conf/flink-conf.yaml`中设置,所有使用这个配置文件执行的作业都将采用这样的重启策略;也可以在单个作业的代码中配置重启策略。 +重启策略的前提是作业进行了 Checkpoint,如果作业未设置 Checkpoint,则会使用 No Restart 的策略。重启策略可以在 `conf/flink-conf.yaml` 中设置,所有使用这个配置文件执行的作业都将采用这样的重启策略;也可以在单个作业的代码中配置重启策略。 #### Fixed Delay -Fixed Delay策略下,作业最多重启次数不会超过某个设定值,两次重启之间有一个可设定的延迟时间。例如,我们在`conf/flink-conf.yaml`中设置为: +Fixed Delay 策略下,作业最多重启次数不会超过某个设定值,两次重启之间有一个可设定的延迟时间。例如,我们在 `conf/flink-conf.yaml` 中设置为: ```yaml restart-strategy: fixed-delay @@ -160,24 +194,24 @@ restart-strategy.fixed-delay.attempts: 3 restart-strategy.fixed-delay.delay: 10 s ``` -这表示作业最多自动重启3次,两次重启之间有10秒的延迟。超过最多重启次数后,该作业被认定为失败。两次重启之间有延迟,是考虑到一些作业与外部系统有连接,连接一般会设置超时,频繁建立连接对数据准确性和作业运行都不利。如果在程序中用代码配置,可以写为: +这表示作业最多自动重启 3 次,两次重启之间有 10 秒的延迟。超过最多重启次数后,该作业被认定为失败。两次重启之间有延迟,是考虑到一些作业与外部系统有连接,连接一般会设置超时,频繁建立连接对数据准确性和作业运行都不利。如果在程序中用代码配置,可以写为: ```java StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); -// 开启Checkpoint +// 开启 Checkpoint env.enableCheckpointing(5000L); env.setRestartStrategy( RestartStrategies.fixedDelayRestart( 3, // 尝试重启次数 - Time.of(10L, TimeUnit.SECONDS) // 两次重启之间的延迟为10秒 + Time.of(10L, TimeUnit.SECONDS) // 两次重启之间的延迟为 10 秒 )); ``` -如果开启了Checkpoint,但没有设置重启策略,Flink会默认使用这个策略,最大重启次数为`Integer.MAX_VALUE`。 +如果开启了 Checkpoint,但没有设置重启策略,Flink 会默认使用这个策略,最大重启次数为 `Integer.MAX_VALUE`。 #### Failure Rate -Failure Rate策略下,在设定的时间内,重启失败次数小于设定阈值,该作业继续重启,重启失败次数超出设定阈值,该作业被最终认定为失败。两次重启之间会有一个等待的延迟。例如,我们在`conf/flink-conf.yaml`中设置为: +Failure Rate 策略下,在设定的时间内,重启失败次数小于设定阈值,该作业继续重启,重启失败次数超出设定阈值,该作业被最终认定为失败。两次重启之间会有一个等待的延迟。例如,我们在 `conf/flink-conf.yaml` 中设置为: ```yaml restart-strategy: failure-rate @@ -186,22 +220,22 @@ restart-strategy.failure-rate.failure-rate-interval: 5 min restart-strategy.failure-rate.delay: 10 s ``` -这表示在5分钟的时间内,重启次数小于3次时,继续重启,否则认定该作业为失败。两次重启之间的延迟为10秒。在程序中用代码配置,可以写为: +这表示在 5 分钟的时间内,重启次数小于 3 次时,继续重启,否则认定该作业为失败。两次重启之间的延迟为 10 秒。在程序中用代码配置,可以写为: ```java StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); -// 开启Checkpoint +// 开启 Checkpoint env.enableCheckpointing(5000L); env.setRestartStrategy(RestartStrategies.failureRateRestart( - 3, // 5分钟内最多重启3次 + 3, // 5 分钟内最多重启 3 次 Time.of(5, TimeUnit.MINUTES), - Time.of(10, TimeUnit.SECONDS) // 两次重启之间延迟为10秒 + Time.of(10, TimeUnit.SECONDS) // 两次重启之间延迟为 10 秒 )); ``` #### No Restart -No Restart策略下,一个作业遇到异常情况后,直接被判定为失败,不进行重启尝试。在`conf/flink-conf.yaml`中设置为: +No Restart 策略下,一个作业遇到异常情况后,直接被判定为失败,不进行重启尝试。在 `conf/flink-conf.yaml` 中设置为: ```yaml restart-strategy: none @@ -214,56 +248,56 @@ StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironm env.setRestartStrategy(RestartStrategies.noRestart()); ``` -## Checkpoint相关配置 +## Checkpoint 相关配置 -默认情况下,Checkpoint机制是关闭的,需要调用`env.enableCheckpointing(n)`来开启,每隔n毫秒进行一次Checkpoint。Checkpoint是一种负载较重的任务,如果状态比较大,同时n值又比较小,那可能一次Checkpoint还没完成,下次Checkpoint已经被触发,占用太多本该用于正常数据处理的资源。增大n值意味着一个作业的Checkpoint次数更少,整个作业用于进行Checkpoint的资源更小,可以将更多的资源用于正常的流数据处理。同时,更大的n值意味着重启后,整个作业需要从更长的Offset开始重新处理数据。 +默认情况下,Checkpoint 机制是关闭的,需要调用 `env.enableCheckpointing(n)` 来开启,每隔 n 毫秒进行一次 Checkpoint。Checkpoint 是一种负载较重的任务,如果状态比较大,同时 n 值又比较小,那可能一次 Checkpoint 还没完成,下次 Checkpoint 已经被触发,占用太多本该用于正常数据处理的资源。增大 n 值意味着一个作业的 Checkpoint 次数更少,整个作业用于进行 Checkpoint 的资源更小,可以将更多的资源用于正常的流数据处理。同时,更大的 n 值意味着重启后,整个作业需要从更长的 Offset 开始重新处理数据。 -此外,还有一些其他参数需要配置,这些参数统一封装在了`CheckpointConfig`里: +此外,还有一些其他参数需要配置,这些参数统一封装在了 `CheckpointConfig` 里: ```java CheckpointConfig checkpointCfg = env.getCheckpointConfig(); ``` -默认的Checkpoint配置使用了Checkpoint Barrier对齐功能,对齐会增加作业的负担,有一定延迟,但是可以支持Exactly-Once投递的。这里的Exactly-Once指的是除去Source和Sink外其他各算子的Exactly-Once,关于Exactly-Once,我们将在第七章进一步详细解释。Checkpoint Barrier对齐能保证在重启恢复时,各算子的状态对任一条数据只处理一次。如果作业对延迟的要求很低,那么应该使用At-Least-Once投递,不进行对齐,但某些数据会被处理多次。 +默认的 Checkpoint 配置使用了 Checkpoint Barrier 对齐功能,对齐会增加作业的负担,有一定延迟,但是可以支持 Exactly-Once 投递的。这里的 Exactly-Once 指的是除去 Source 和 Sink 外其他各算子的 Exactly-Once,关于 Exactly-Once,我们将在第七章进一步详细解释。Checkpoint Barrier 对齐能保证在重启恢复时,各算子的状态对任一条数据只处理一次。如果作业对延迟的要求很低,那么应该使用 At-Least-Once 投递,不进行对齐,但某些数据会被处理多次。 ```java -// 使用At-Least-Once +// 使用 At-Least-Once checkpointCfg.setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE); ``` -如果一次Checkpoint超过一定时间仍未完成,直接将其终止,以免其占用太多资源: +如果一次 Checkpoint 超过一定时间仍未完成,直接将其终止,以免其占用太多资源: ```java -// 超时时间1小时 +// 超时时间 1 小时 checkpointCfg.setCheckpointTimeout(3600*1000); ``` -如果两次Checkpoint之间的间歇时间太短,那么正常的作业可能获取的资源较少,更多的资源被用在了Checkpoint上。对下面这个参数进行合理配置能保证数据流的正常处理。比如,设置这个参数为60秒,那么前一次Checkpoint结束后60秒内不会启动新的Checkpoint。这种模式只在整个作业最多允许1个Checkpoint时适用。 +如果两次 Checkpoint 之间的间歇时间太短,那么正常的作业可能获取的资源较少,更多的资源被用在了 Checkpoint 上。对下面这个参数进行合理配置能保证数据流的正常处理。比如,设置这个参数为 60 秒,那么前一次 Checkpoint 结束后 60 秒内不会启动新的 Checkpoint。这种模式只在整个作业最多允许 1 个 Checkpoint 时适用。 ```java -// 两次Checkpoint的间隔为60秒 +// 两次 Checkpoint 的间隔为 60 秒 checkpointCfg.setMinPauseBetweenCheckpoints(60*1000); ``` -默认情况下一个作业只允许1个Checkpoint执行,如果某个Checkpoint正在进行,另外一个Checkpoint被启动,新的Checkpoint需要挂起等待。 +默认情况下一个作业只允许 1 个 Checkpoint 执行,如果某个 Checkpoint 正在进行,另外一个 Checkpoint 被启动,新的 Checkpoint 需要挂起等待。 ```java -// 最多同时进行3个Checkpoint +// 最多同时进行 3 个 Checkpoint checkpointCfg.setMaxConcurrentCheckpoints(3); ``` -如果这个参数大于1,将与前面提到的最短间隔相冲突。 +如果这个参数大于 1,将与前面提到的最短间隔相冲突。 -Checkpoint的初衷是用来进行故障恢复,如果作业是因为异常而失败,Flink会保存远程存储上的数据;如果开发者自己取消了作业,远程存储上的数据都会被删除。如果开发者希望通过Checkpoint数据进行调试,自己取消了作业,同时希望将远程数据保存下来,需要设置为: +Checkpoint 的初衷是用来进行故障恢复,如果作业是因为异常而失败,Flink 会保存远程存储上的数据;如果开发者自己取消了作业,远程存储上的数据都会被删除。如果开发者希望通过 Checkpoint 数据进行调试,自己取消了作业,同时希望将远程数据保存下来,需要设置为: ```java -// 作业取消后仍然保存Checkpoint +// 作业取消后仍然保存 Checkpoint checkpointCfg.enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION); ``` -`RETAIN_ON_CANCELLATION`模式下,用户需要自己手动删除远程存储上的Checkpoint数据。 +`RETAIN_ON_CANCELLATION` 模式下,用户需要自己手动删除远程存储上的 Checkpoint 数据。 -默认情况下,如果Checkpoint过程失败,会导致整个应用重启,我们可以关闭这个功能,这样Checkpoint失败不影响作业的运行。 +默认情况下,如果 Checkpoint 过程失败,会导致整个应用重启,我们可以关闭这个功能,这样 Checkpoint 失败不影响作业的运行。 ```java checkpointCfg.setFailOnCheckpointingErrors(false); diff --git a/doc/ch-state-checkpoint/exercise-state.md b/doc/ch-state-checkpoint/exercise-state.md index 6b7c263..aadc6db 100644 --- a/doc/ch-state-checkpoint/exercise-state.md +++ b/doc/ch-state-checkpoint/exercise-state.md @@ -3,34 +3,34 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -经过本章的学习,相信读者朋友已经了解状态的基本原理,包括如何使用Keyed State或Operator State进行有状态的计算。本节将继续以电商用户行为分析为场景,对状态相关知识进行实践。 +经过本章的学习,相信读者朋友已经了解状态的基本原理,包括如何使用 Keyed State 或 Operator State 进行有状态的计算。本节将继续以电商用户行为分析为场景,对状态相关知识进行实践。 ## 实验目的 -学习使用Keyed State,设置Checkpoint。 +学习使用 Keyed State,设置 Checkpoint。 ## 实验内容 -在[状态](./state.md)章节的Keyed State部分,我们介绍了电商用户行为场景,并举了一些例子,本次练习仍然基于这个场景。我们知道,一天之内,一个用户第一次产生行为到真正购买,这之间有一个时间差,这个时间是一个非常重要的指标,有助于商家提升产品质量和营销水平。这里我们使用Keyed State来实现一个程序,主要用来计算这个时间差。 +在 [状态](./state.md) 章节的 Keyed State 部分,我们介绍了电商用户行为场景,并举了一些例子,本次练习仍然基于这个场景。我们知道,一天之内,一个用户第一次产生行为到真正购买,这之间有一个时间差,这个时间是一个非常重要的指标,有助于商家提升产品质量和营销水平。这里我们使用 Keyed State 来实现一个程序,主要用来计算这个时间差。 ## 实验要求 -读者可以根据本书样例程序中提供的数据集和Source作为输入,编程完成下面的要求。 +读者可以根据本书样例程序中提供的数据集和 Source 作为输入,编程完成下面的要求。 -* 要求1: +* 要求 1: -使用Keyed State,计算每个用户当天第一次产生行为到第一次产生购买行为之间的时间差。在实现时需要注意,这里只考虑第一次产生购买行为,而不是多次产生购买行为中的最后一次。使用`print`将结果打印出来。 +使用 Keyed State,计算每个用户当天第一次产生行为到第一次产生购买行为之间的时间差。在实现时需要注意,这里只考虑第一次产生购买行为,而不是多次产生购买行为中的最后一次。使用 `print` 将结果打印出来。 -* 要求2: +* 要求 2: -开启Checkpoint,选择一种State Backend,将状态定期保存到存储的某个位置。 +开启 Checkpoint,选择一种 State Backend,将状态定期保存到存储的某个位置。 ## 实验报告 diff --git a/doc/ch-state-checkpoint/img/barrier-alignment.png b/doc/ch-state-checkpoint/img/barrier-alignment.png index 74d1581..479f8bb 100644 Binary files a/doc/ch-state-checkpoint/img/barrier-alignment.png and b/doc/ch-state-checkpoint/img/barrier-alignment.png differ diff --git a/doc/ch-state-checkpoint/img/broadcast-state.png b/doc/ch-state-checkpoint/img/broadcast-state.png index c6dd10a..4b0593f 100644 Binary files a/doc/ch-state-checkpoint/img/broadcast-state.png and b/doc/ch-state-checkpoint/img/broadcast-state.png differ diff --git a/doc/ch-state-checkpoint/img/checkpoint-1.png b/doc/ch-state-checkpoint/img/checkpoint-1.png index 2b1b70c..68b1f69 100644 Binary files a/doc/ch-state-checkpoint/img/checkpoint-1.png and b/doc/ch-state-checkpoint/img/checkpoint-1.png differ diff --git a/doc/ch-state-checkpoint/img/checkpoint-2.png b/doc/ch-state-checkpoint/img/checkpoint-2.png index 397cc4f..f8851e4 100644 Binary files a/doc/ch-state-checkpoint/img/checkpoint-2.png and b/doc/ch-state-checkpoint/img/checkpoint-2.png differ diff --git a/doc/ch-state-checkpoint/img/checkpoint-3.png b/doc/ch-state-checkpoint/img/checkpoint-3.png index 744b17a..c3311bd 100644 Binary files a/doc/ch-state-checkpoint/img/checkpoint-3.png and b/doc/ch-state-checkpoint/img/checkpoint-3.png differ diff --git a/doc/ch-state-checkpoint/img/checkpoint-4.png b/doc/ch-state-checkpoint/img/checkpoint-4.png index a5ee170..b6d6259 100644 Binary files a/doc/ch-state-checkpoint/img/checkpoint-4.png and b/doc/ch-state-checkpoint/img/checkpoint-4.png differ diff --git a/doc/ch-state-checkpoint/img/checkpoint-barrier.png b/doc/ch-state-checkpoint/img/checkpoint-barrier.png index aa702df..79ef85d 100644 Binary files a/doc/ch-state-checkpoint/img/checkpoint-barrier.png and b/doc/ch-state-checkpoint/img/checkpoint-barrier.png differ diff --git a/doc/ch-state-checkpoint/img/index.png b/doc/ch-state-checkpoint/img/index.png deleted file mode 100644 index 6e7e26e..0000000 Binary files a/doc/ch-state-checkpoint/img/index.png and /dev/null differ diff --git a/doc/ch-state-checkpoint/img/inheritance-relationships-of-keyedState.png b/doc/ch-state-checkpoint/img/inheritance-relationships-of-keyedState.png index d4d4c49..65eafea 100644 Binary files a/doc/ch-state-checkpoint/img/inheritance-relationships-of-keyedState.png and b/doc/ch-state-checkpoint/img/inheritance-relationships-of-keyedState.png differ diff --git a/doc/ch-state-checkpoint/img/keyedstate.png b/doc/ch-state-checkpoint/img/keyedstate.png index ad3a68a..01e2c05 100644 Binary files a/doc/ch-state-checkpoint/img/keyedstate.png and b/doc/ch-state-checkpoint/img/keyedstate.png differ diff --git a/doc/ch-state-checkpoint/img/operatorstate.png b/doc/ch-state-checkpoint/img/operatorstate.png index 3628f2e..aaf3be8 100644 Binary files a/doc/ch-state-checkpoint/img/operatorstate.png and b/doc/ch-state-checkpoint/img/operatorstate.png differ diff --git a/doc/ch-state-checkpoint/img/rescale.png b/doc/ch-state-checkpoint/img/rescale.png index 6f35b19..1bd0ee8 100644 Binary files a/doc/ch-state-checkpoint/img/rescale.png and b/doc/ch-state-checkpoint/img/rescale.png differ diff --git a/doc/ch-state-checkpoint/img/savepoint-data.png b/doc/ch-state-checkpoint/img/savepoint-data.png index 200a038..466e275 100644 Binary files a/doc/ch-state-checkpoint/img/savepoint-data.png and b/doc/ch-state-checkpoint/img/savepoint-data.png differ diff --git a/doc/ch-state-checkpoint/img/savepoint.png b/doc/ch-state-checkpoint/img/savepoint.png index 4b79714..daf678f 100644 Binary files a/doc/ch-state-checkpoint/img/savepoint.png and b/doc/ch-state-checkpoint/img/savepoint.png differ diff --git a/doc/ch-state-checkpoint/img/state-acquisition-and-update.png b/doc/ch-state-checkpoint/img/state-acquisition-and-update.png index 6a7529b..53e6f0a 100644 Binary files a/doc/ch-state-checkpoint/img/state-acquisition-and-update.png and b/doc/ch-state-checkpoint/img/state-acquisition-and-update.png differ diff --git a/doc/ch-state-checkpoint/savepoint.md b/doc/ch-state-checkpoint/savepoint.md index 6394271..bf125c7 100644 --- a/doc/ch-state-checkpoint/savepoint.md +++ b/doc/ch-state-checkpoint/savepoint.md @@ -3,51 +3,58 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) ::: -## Savepoint与Checkpoint的区别 +## Savepoint 与 Checkpoint 的区别 -目前,Checkpoint和Savepoint在代码层面使用的分布式快照逻辑基本相同,生成的数据也近乎一样,那这两个相似的名字到底有哪些功能性的区别呢?Checkpoint的目的是为了故障重启,使得作业中的状态数据与故障重启之前保持一致,是一种应对意外情况的有力保障。Savepoint的目的是手动备份数据,以便进行调试、迁移、迭代等,是一种协助开发者的支持功能。一方面,一个流处理作业不可能一次性就写好了,我们要在一个初版代码的基础上不断修复问题、增加功能、优化算法、甚至做一些机房迁移,一个程序是在迭代中更新的;另外一方面,流处理作业一般都是长时间运行的,作业内部的状态数据从零开始重新生成的成本很高,状态数据迁移成本高。综合这两方面的因素,Flink提供了Savepoint的机制,允许开发者调试开发有状态的作业。 +目前,Checkpoint 和 Savepoint 在代码层面使用的分布式快照逻辑基本相同,生成的数据也近乎一样,那这两个相似的名字到底有哪些功能性的区别呢?Checkpoint 的目的是为了故障重启,使得作业中的状态数据与故障重启之前保持一致,是一种应对意外情况的有力保障。Savepoint 的目的是手动备份数据,以便进行调试、迁移、迭代等,是一种协助开发者的支持功能。一方面,一个流处理作业不可能一次性就写好了,我们要在一个初版代码的基础上不断修复问题、增加功能、优化算法、甚至做一些机房迁移,一个程序是在迭代中更新的;另外一方面,流处理作业一般都是长时间运行的,作业内部的状态数据从零开始重新生成的成本很高,状态数据迁移成本高。综合这两方面的因素,Flink 提供了 Savepoint 的机制,允许开发者调试开发有状态的作业。 -Flink的Checkpoint机制设计初衷为:第一,Checkpoint过程是轻量级的,尽量不影响正常数据处理;第二,故障恢复越快越好。开发者需要进行的操作并不多,少量的操作包括:设置多大的间隔来定期进行Checkpoint,使用何种State Backend。绝大多数工作是由Flink来处理的,比如Flink会定期执行快照,发生故障后,Flink自动从最近一次Checkpoint数据中恢复。随着作业的关停,Checkpoint数据一般会被Flink删除,除非开发者设置了保留Checkpoint数据。原则上,一个作业从Checkpoint数据中恢复,作业的代码和业务逻辑不能发生变化。 +Flink 的 Checkpoint 机制设计初衷为:第一,Checkpoint 过程是轻量级的,尽量不影响正常数据处理;第二,故障恢复越快越好。开发者需要进行的操作并不多,少量的操作包括:设置多大的间隔来定期进行 Checkpoint,使用何种 State Backend。绝大多数工作是由 Flink 来处理的,比如 Flink 会定期执行快照,发生故障后,Flink 自动从最近一次 Checkpoint 数据中恢复。随着作业的关停,Checkpoint 数据一般会被 Flink 删除,除非开发者设置了保留 Checkpoint 数据。原则上,一个作业从 Checkpoint 数据中恢复,作业的代码和业务逻辑不能发生变化。 -相比而下,Savepoint机制主要考虑的是:第一,刻意备份,第二,支持修改状态数据或业务逻辑。Savepoint相关操作是有计划的、人为的。开发者要手动触发、管理和删除Savepoint。比如,将当前状态保存下来之后,我们可以更新并行度,修改业务逻辑代码,甚至在某份代码基础上生成一个对照组来验证一些实验猜想。可见,Savepoint的数据备份和恢复都有更高的时间和人力成本,Savepoint数据也必须有一定的可移植性,能够适应数据或逻辑上的改动。具体而言,Savepoint的潜在应用场景有: +相比而下,Savepoint 机制主要考虑的是:第一,刻意备份,第二,支持修改状态数据或业务逻辑。Savepoint 相关操作是有计划的、人为的。开发者要手动触发、管理和删除 Savepoint。比如,将当前状态保存下来之后,我们可以更新并行度,修改业务逻辑代码,甚至在某份代码基础上生成一个对照组来验证一些实验猜想。可见,Savepoint 的数据备份和恢复都有更高的时间和人力成本,Savepoint 数据也必须有一定的可移植性,能够适应数据或逻辑上的改动。具体而言,Savepoint 的潜在应用场景有: -* 我们可以给同一份作业设置不同的并行度,来找到最佳的并行度设置,每次可以从Savepoint中加载原来的状态数据。 +* 我们可以给同一份作业设置不同的并行度,来找到最佳的并行度设置,每次可以从 Savepoint 中加载原来的状态数据。 -* 我们想测试一个新功能或修复一个已知的bug,并用新的程序逻辑处理原来的数据。 +* 我们想测试一个新功能或修复一个已知的 bug,并用新的程序逻辑处理原来的数据。 -* 进行一些A/B实验,使用相同的数据源测试程序的不同版本。 +* 进行一些 A/B 实验,使用相同的数据源测试程序的不同版本。 * 因为状态可以被持久化存储到分布式文件系统上,我们甚至可以将同样一份应用程序从一个集群迁移到另一个集群,只需保证不同的集群都可以访问这个文件系统。 -可见,Checkpoint和Savepoint是Flink提供的两个相似的功能,它们满足了不同的需求,以确保一致性、容错性,满足了作业升级、BUG 修复、迁移、A/B测试等不同场景。 +可见,Checkpoint 和 Savepoint 是 Flink 提供的两个相似的功能,它们满足了不同的需求,以确保一致性、容错性,满足了作业升级、BUG 修复、迁移、A/B 测试等不同场景。 -## Savepoint的使用方法 +## Savepoint 的使用方法 -为了让Savepoint数据能够具有更好的兼容性和可移植性,我们在写一个Flink程序时需要为每个算子分配一个唯一ID。设置算子ID的目的在于将状态与Savepoint中的备份相对应。如下图所示,这个Flink作业共有三个算子:Source、Stateful Map和Stateless Sink。Source和Stateful Map分别有对应Operator State和Keyed State,Stateless Sink没有状态。 +为了让 Savepoint 数据能够具有更好的兼容性和可移植性,我们在写一个 Flink 程序时需要为每个算子分配一个唯一 ID。设置算子 ID 的目的在于将状态与 Savepoint 中的备份相对应。如下图所示,这个 Flink 作业共有三个算子:Source、Stateful Map 和 Stateless Sink。Source 和 Stateful Map 分别有对应 Operator State 和 Keyed State,Stateless Sink 没有状态。 -![将一个流处理作业状态映射到Savepoint](./img/savepoint.png) +```{figure} ./img/savepoint.png +--- +name: fig-savepoint +width: 80% +align: center +--- +将一个流处理作业状态映射到 Savepoint +``` -在实现这个数据流图时,我们需要给算子设置ID: +在实现这个数据流图时,我们需要给算子设置 ID: ```java DataStream stream = env. - // 一个带有Operator State的Source,例如Kafka Source - .addSource(new StatefulSource()).uid("source-id") // 算子ID + // 一个带有 Operator State 的 Source,例如 Kafka Source + .addSource(new StatefulSource()).uid("source-id") // 算子 ID .keyBy(...) - // 一个带有Keyed State的Stateful Map - .map(new StatefulMapper()).uid("mapper-id") // 算子ID - // print是一种无状态的Sink - .print(); // Flink为其自动分配一个算子ID + // 一个带有 Keyed State 的 Stateful Map + .map(new StatefulMapper()).uid("mapper-id") // 算子 ID + // print 是一种无状态的 Sink + .print(); // Flink 为其自动分配一个算子 ID ``` -上面的例子中,我们给算子设置了ID。如果代码中不明确设置算子ID,那么Flink会为其自动分配一个ID。严格来说,我们应该为每个算子都设置ID,因为很多算子内在实现上是有状态的,比如窗口算子。除非我们能够非常确认某个算子无状态,可以不为其设置ID。 +上面的例子中,我们给算子设置了 ID。如果代码中不明确设置算子 ID,那么 Flink 会为其自动分配一个 ID。严格来说,我们应该为每个算子都设置 ID,因为很多算子内在实现上是有状态的,比如窗口算子。除非我们能够非常确认某个算子无状态,可以不为其设置 ID。 如果我们想对这个作业进行备份,我们需要使用命令行工具执行下面的命令: @@ -55,25 +62,32 @@ DataStream stream = env. $ ./bin/flink savepoint [savepointDirectory] ``` -这行命令将对一个正在运行的作业触发一次Savepoint的备份,备份数据将写到`savepointDirectory`上。例如,我们可以指定一个HDFS路径作为Savepoint数据存放地址。 +这行命令将对一个正在运行的作业触发一次 Savepoint 的备份,备份数据将写到 `savepointDirectory` 上。例如,我们可以指定一个 HDFS 路径作为 Savepoint 数据存放地址。 -如果我们想从一个Savepoint数据中恢复一个作业,我们需要执行: +如果我们想从一个 Savepoint 数据中恢复一个作业,我们需要执行: ```bash $ ./bin/flink run -s [OPTIONS] ``` -## 读写Savepoint中的数据 +## 读写 Savepoint 中的数据 -Flink提供了一个名为State Processor API的功能,允许开发者读写Savepoint中的数据。它主要基于DataSet API,将Savepoint数据从远程存储读到内存中,对Savepoint数据进行处理,然后再保存到远程存储上。有了State Processor API,开发者在状态的修改和更新上有更大的自由度。例如,开发者可以先从其他位置读取数据,生成一份Savepoint,交给一个没有数据积累的流处理程序,用来做数据冷启动。 +Flink 提供了一个名为 State Processor API 的功能,允许开发者读写 Savepoint 中的数据。它主要基于 DataSet API,将 Savepoint 数据从远程存储读到内存中,对 Savepoint 数据进行处理,然后再保存到远程存储上。有了 State Processor API,开发者在状态的修改和更新上有更大的自由度。例如,开发者可以先从其他位置读取数据,生成一份 Savepoint,交给一个没有数据积累的流处理程序,用来做数据冷启动。 -将上节提到的程序中的Savepoint进一步分解,其内在存储形式如下图所示。Savepoint对数据的存储就像数据库存储数据一样,数据是按照一定的模式来组织和存储的。名为`source-id`的算子使用的是一个Operator State,Operator State的名字为`os1`,`os1`中的数据以一个列表的形式存储;名为`mapper-id`的算子使用的是一个Keyed State,Keyed State的名字为`ks1`,`ks1`中的数据是Key-Value对。 +将上节提到的程序中的 Savepoint 进一步分解,其内在存储形式如下图所示。Savepoint 对数据的存储就像数据库存储数据一样,数据是按照一定的模式来组织和存储的。名为 `source-id` 的算子使用的是一个 Operator State,Operator State 的名字为 `os1`,`os1` 中的数据以一个列表的形式存储;名为 `mapper-id` 的算子使用的是一个 Keyed State,Keyed State 的名字为 `ks1`,`ks1` 中的数据是 Key-Value 对。 -![Savepoint中的数据存储形式](./img/savepoint-data.png) +```{figure} ./img/savepoint-data.png +--- +name: fig-savepoint-data +width: 80% +align: center +--- +Savepoint 中的数据存储形式 +``` -建立好上述的数据模型后,我们就可以像从数据库中读写数据那样,使用State Processor API来读写Savepoint中的数据。 +建立好上述的数据模型后,我们就可以像从数据库中读写数据那样,使用 State Processor API 来读写 Savepoint 中的数据。 -State Processor API默认没有放在Flink的核心库中,使用之前需要先在`pom.xml`中引入正确的依赖: +State Processor API 默认没有放在 Flink 的核心库中,使用之前需要先在 `pom.xml` 中引入正确的依赖: ``` @@ -84,25 +98,25 @@ State Processor API默认没有放在Flink的核心库中,使用之前需要 ``` -### 从Savepoint中读数据 +### 从 Savepoint 中读数据 -首先,我们需要从一个存储路径上读取Savepoint。这里要使用批处理的DataSet API,执行环境为`ExecutionEnvironment`。 +首先,我们需要从一个存储路径上读取 Savepoint。这里要使用批处理的 DataSet API,执行环境为 `ExecutionEnvironment`。 ```java -// 使用批处理DataSet API的ExecutionEnvironment +// 使用批处理 DataSet API 的 ExecutionEnvironment ExecutionEnvironment bEnv = ExecutionEnvironment.getExecutionEnvironment(); -// 存储这个Savepoint所使用的State Backend +// 存储这个 Savepoint 所使用的 State Backend StateBackend backend = ... ExistingSavepoint savepoint = Savepoint.load(bEnv, "hdfs://path/", backend); bEnv.execute("read"); ``` -上面的代码从存储路径上读取一个Savepoint,生成一个`ExistingSavepoint`。`ExistingSavepoint`是一个已经存在的Savepoint,这个类提供了一个从Savepoint中读取数据的入口。 +上面的代码从存储路径上读取一个 Savepoint,生成一个 `ExistingSavepoint`。`ExistingSavepoint` 是一个已经存在的 Savepoint,这个类提供了一个从 Savepoint 中读取数据的入口。 #### Operator State -读取时Operator State,我们需要指定算子ID、Operator State名字、数据类型。下面的代码读取了`source-id`下的`os1`: +读取时 Operator State,我们需要指定算子 ID、Operator State 名字、数据类型。下面的代码读取了 `source-id` 下的 `os1`: ```java DataSet listState = savepoint.readListState<>( @@ -111,7 +125,7 @@ DataSet listState = savepoint.readListState<>( Types.INT); ``` -`readListState`方法读取ListState,它在源代码中的签名为: +`readListState` 方法读取 ListState,它在源代码中的签名为: ```java public DataSet readListState(String uid, String name, TypeInformation typeInfo) { @@ -127,9 +141,9 @@ public DataSet readListState( } ``` -其中,`uid`为上一节中我们设置的算子ID;`name`为这个状态的名字,我们在使用状态时,会在`ListStateDescriptor`里初始化一个名字;`typeInfo`为该状态的类型,用来进行序列化和反序列化;如果默认的序列化器不支持该类型,也可以传入一个自定义的序列化器。 +其中,`uid` 为上一节中我们设置的算子 ID;`name` 为这个状态的名字,我们在使用状态时,会在 `ListStateDescriptor` 里初始化一个名字;`typeInfo` 为该状态的类型,用来进行序列化和反序列化;如果默认的序列化器不支持该类型,也可以传入一个自定义的序列化器。 -UnionListState的读取方法与之类似: +UnionListState 的读取方法与之类似: ```java DataSet listState = savepoint.readUnionState<>( @@ -140,13 +154,13 @@ DataSet listState = savepoint.readUnionState<>( #### Keyed State -流处理中,Keyed State应用在一个`KeyedStream`上,需要在`StateDescriptor`中指定状态的名字和数据类型。例如,在一个流处理作业中,我们继承`KeyedProcessFunction`,实现下面两个状态: +流处理中,Keyed State 应用在一个 `KeyedStream` 上,需要在 `StateDescriptor` 中指定状态的名字和数据类型。例如,在一个流处理作业中,我们继承 `KeyedProcessFunction`,实现下面两个状态: ```java /** - * StatefulFunctionWithTime继承KeyedProcessFunction - * 接收Integer类型的输入,更新状态 - * 第一个泛型为KeyedStream中Key的类型 + * StatefulFunctionWithTime 继承 KeyedProcessFunction + * 接收 Integer 类型的输入,更新状态 + * 第一个泛型为 KeyedStream 中 Key 的类型 * 第二个泛型为输入数据 * 第三个泛型为输出数据 */ @@ -164,7 +178,7 @@ public class StatefulFunctionWithTime extends KeyedProcessFunction out) throws Exception { state.update(value + 1); @@ -173,7 +187,7 @@ public class StatefulFunctionWithTime extends KeyedProcessFunction { @@ -217,28 +231,28 @@ public class ReaderFunction extends KeyedStateReaderFunction keyedState = savepoint.readKeyedState("mapper-id", new ReaderFunction()); ``` -从上面的例子中可以看到,`readKeyedState`需要传入算子的ID和一个`KeyedStateReaderFunction`的具体实现,它在源码中的签名为: +从上面的例子中可以看到,`readKeyedState` 需要传入算子的 ID 和一个 `KeyedStateReaderFunction` 的具体实现,它在源码中的签名为: ```java /* - * 从Savepoint数据中读取Keyed State - * uid:算子ID - * function:一个KeyedStateReaderFunction - * K:Keyed State的Key类型 + * 从 Savepoint 数据中读取 Keyed State + * uid:算子 ID + * function:一个 KeyedStateReaderFunction + * K:Keyed State 的 Key 类型 * OUT:输出类型 */ public DataSet readKeyedState(String uid, KeyedStateReaderFunction function) { @@ -246,50 +260,50 @@ public DataSet readKeyedState(String uid, KeyedStateReaderFunction } ``` -`KeyedStateReaderFunction`允许我们从Savepoint中读取Keyed State数据,我们需要实现`open`方法和`readKey`方法。其中,我们必须在`open`方法中注册`StateDescriptor`,获取状态句柄;在`readKey`方法中逐Key读取数据,输出到`Collector`中。`KeyedStateReaderFunction`和这些方法在源码中的定义为: +`KeyedStateReaderFunction` 允许我们从 Savepoint 中读取 Keyed State 数据,我们需要实现 `open` 方法和 `readKey` 方法。其中,我们必须在 `open` 方法中注册 `StateDescriptor`,获取状态句柄;在 `readKey` 方法中逐 Key 读取数据,输出到 `Collector` 中。`KeyedStateReaderFunction` 和这些方法在源码中的定义为: ```java /** - * 从Savepoint中读取Keyed State - * 泛型K:Keyed State中Key的类型 - * 泛型OUT:输出数据 + * 从 Savepoint 中读取 Keyed State + * 泛型 K:Keyed State 中 Key 的类型 + * 泛型 OUT:输出数据 */ public abstract class KeyedStateReaderFunction extends AbstractRichFunction { /** - * 初始化方法,用来注册StateDescriptor,获取状态句柄 + * 初始化方法,用来注册 StateDescriptor,获取状态句柄 */ public abstract void open(Configuration parameters) throws Exception; /** - * 从Keyed State中逐Key读取数据,输出到Collector - * 参数K为Keyed State中的每个Key - * 参数Context为上下文 - * 参数Collector用来收集输出,可以是零到多个输出 + * 从 Keyed State 中逐 Key 读取数据,输出到 Collector + * 参数 K 为 Keyed State 中的每个 Key + * 参数 Context 为上下文 + * 参数 Collector 用来收集输出,可以是零到多个输出 */ public abstract void readKey(K key, Context ctx, Collector out) throws Exception; /** - * 上下文Context - * Context只在readKey时有效 + * 上下文 Context + * Context 只在 readKey 时有效 */ public interface Context { /** - * 返回当前Key所注册的Event Time Timer + * 返回当前 Key 所注册的 Event Time Timer */ Set registeredEventTimeTimers() throws Exception; /** - * 返回当前Key所注册的Processing Time Timer + * 返回当前 Key 所注册的 Processing Time Timer */ Set registeredProcessingTimeTimers() throws Exception; } } ``` -### 向Savepoint中写入数据 +### 向 Savepoint 中写入数据 -我们也可以从零开始构建状态,向Savepoint中写入数据,这个功能非常适合作业的冷启动。英文常使用Bootstrap这个词描述冷启动的过程,因此Flink设计的类名都会带有Bootstrap字样。具体而言,构建一个新的Savepoint时,需要实现一个名为`BootstrapTransformation`的操作,`BootstrapTransformation`表示一个状态写入的过程。从另一个角度来讲,我们可以将`BootstrapTransformation`理解成流处理时使用的有状态的算子。下面是一个Savepoint构建过程的主逻辑示例: +我们也可以从零开始构建状态,向 Savepoint 中写入数据,这个功能非常适合作业的冷启动。英文常使用 Bootstrap 这个词描述冷启动的过程,因此 Flink 设计的类名都会带有 Bootstrap 字样。具体而言,构建一个新的 Savepoint 时,需要实现一个名为 `BootstrapTransformation` 的操作,`BootstrapTransformation` 表示一个状态写入的过程。从另一个角度来讲,我们可以将 `BootstrapTransformation` 理解成流处理时使用的有状态的算子。下面是一个 Savepoint 构建过程的主逻辑示例: ```java ExecutionEnvironment bEnv = ExecutionEnvironment.getExecutionEnvironment(); @@ -302,18 +316,18 @@ StateBackend backend = ... DataSet accountDataSet = bEnv.fromCollection(accounts); DataSet currencyDataSet = bEnv.fromCollection(currencyRates); -// 构建一个BootstrapTransformation,将accountDataSet数据写入 +// 构建一个 BootstrapTransformation,将 accountDataSet 数据写入 BootstrapTransformation transformation = OperatorTransformation .bootstrapWith(accountDataSet) .keyBy(acc -> acc.id) .transform(new AccountBootstrapper()); -// 构建一个BootstrapTransformation,将currencyDataSet数据写入 +// 构建一个 BootstrapTransformation,将 currencyDataSet 数据写入 BootstrapTransformation broadcastTransformation = OperatorTransformation .bootstrapWith(currencyDataSet) .transform(new CurrencyBootstrapFunction()); -// 创建两个算子,算子ID分别为accounts、currency +// 创建两个算子,算子 ID 分别为 accounts、currency Savepoint .create(backend, maxParallelism) .withOperator("accounts", transformation) @@ -323,28 +337,28 @@ Savepoint bEnv.execute("bootstrap"); ``` -`Savepoint.create(backend, maxParallelism)`创建了一个新的Savepoint。`withOperator`方法向这个Savepoint中添加新的算子,它的两个参数分别为算子ID和一个`BootstrapTransformation`。`transformation`和`broadcastTransformation`就是两个`BootstrapTransformation`对象实例,他们的功能在于去模拟一个流处理中的有状态的算子,并写入状态数据。总体来讲,向Savepoint中写入数据需要三步: +`Savepoint.create(backend, maxParallelism)` 创建了一个新的 Savepoint。`withOperator` 方法向这个 Savepoint 中添加新的算子,它的两个参数分别为算子 ID 和一个 `BootstrapTransformation`。`transformation` 和 `broadcastTransformation` 就是两个 `BootstrapTransformation` 对象实例,他们的功能在于去模拟一个流处理中的有状态的算子,并写入状态数据。总体来讲,向 Savepoint 中写入数据需要三步: -1. 准备好需要写入状态的数据`DataSet`。 -2. 构建一个`BootstrapTransformation`,将第一步准备好的数据写入这个`BootstrapTransformation`。 -3. 将构建好的`BootstrapTransformation`写入Savepoint。 +1. 准备好需要写入状态的数据 `DataSet`。 +2. 构建一个 `BootstrapTransformation`,将第一步准备好的数据写入这个 `BootstrapTransformation`。 +3. 将构建好的 `BootstrapTransformation` 写入 Savepoint。 -Operator State和Keyed State的原理不同,因此所要实现的不同,下面将分别介绍这两种写入方式。 +Operator State 和 Keyed State 的原理不同,因此所要实现的不同,下面将分别介绍这两种写入方式。 #### Operator State -对于Operator State,我们要实现`StateBootstrapFunction`来写入状态数据,重点是实现它的`processElement`方法。每个输入进来之后,`processElement`方法都会被调用一次。下面是一个案例: +对于 Operator State,我们要实现 `StateBootstrapFunction` 来写入状态数据,重点是实现它的 `processElement` 方法。每个输入进来之后,`processElement` 方法都会被调用一次。下面是一个案例: ```java /** - * 继承并实现StateBootstrapFunction + * 继承并实现 StateBootstrapFunction * 泛型参数为输入类型 */ public class SimpleBootstrapFunction extends StateBootstrapFunction { private ListState state; - // 每个输入都会调用一次processElement,这里将输入加入到状态中 + // 每个输入都会调用一次 processElement,这里将输入加入到状态中 @Override public void processElement(Integer value, Context ctx) throws Exception { state.add(value); @@ -369,18 +383,18 @@ ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnviornment(); DataSet data = env.fromElements(1, 2, 3); BootstrapTransformation transformation = OperatorTransformation - // 使用data数据进行初始化 + // 使用 data 数据进行初始化 .bootstrapWith(data) .transform(new SimpleBootstrapFunction()); ``` #### Keyed State -对于Keyed State,我们要实现`KeyedStateBootstrapFunction`来写入状态数据。同样,每来一个输入,`processElement`都会被调用一次。下面的代码中,Key为`Account`中的`id`。 +对于 Keyed State,我们要实现 `KeyedStateBootstrapFunction` 来写入状态数据。同样,每来一个输入,`processElement` 都会被调用一次。下面的代码中,Key 为 `Account` 中的 `id`。 ```java /** - * 表示账户信息的POJO类 + * 表示账户信息的 POJO 类 */ public class Account { public int id; @@ -389,9 +403,9 @@ public class Account { } /** - * AccountBootstrapper继承并实现了KeyedStateBootstrapFunction - * 第一个泛型Integer为Key类型 - * 第二个泛型Account为输入类型 + * AccountBootstrapper 继承并实现了 KeyedStateBootstrapFunction + * 第一个泛型 Integer 为 Key 类型 + * 第二个泛型 Account 为输入类型 */ public class AccountBootstrapper extends KeyedStateBootstrapFunction { ValueState state; @@ -403,7 +417,7 @@ public class AccountBootstrapper extends KeyedStateBootstrapFunction accountDataSet = bEnv.fromCollection(accounts); BootstrapTransformation transformation = OperatorTransformation - // 使用accountDataSet数据进行初始化 + // 使用 accountDataSet 数据进行初始化 .bootstrapWith(accountDataSet) .keyBy(acc -> acc.id) .transform(new AccountBootstrapper()); @@ -431,21 +445,21 @@ Savepoint .write(savepointPath); ``` -我们可以看看`KeyedStateBootstrapFunction`在源码中的签名: +我们可以看看 `KeyedStateBootstrapFunction` 在源码中的签名: ```java /** - * 将Keyed State写入Savepoint - * 第一个泛型K为Key类型 - * 第二个泛型IN为输入类型 + * 将 Keyed State 写入 Savepoint + * 第一个泛型 K 为 Key 类型 + * 第二个泛型 IN 为输入类型 */ public abstract class KeyedStateBootstrapFunction extends AbstractRichFunction { private static final long serialVersionUID = 1L; /** - * 处理输入的每行数据,更新Keyed State - * Context可以用来构建时间相关属性 + * 处理输入的每行数据,更新 Keyed State + * Context 可以用来构建时间相关属性 * 当这个作业在流处理端重启后,时间相关属性可以用来触发计算 */ public abstract void processElement(IN value, Context ctx) throws Exception; @@ -453,20 +467,20 @@ public abstract class KeyedStateBootstrapFunction extends AbstractRichFun /* 上下文 */ public abstract class Context { - // 访问时间,注册Timer + // 访问时间,注册 Timer public abstract TimerService timerService(); - // 返回当前Key + // 返回当前 Key public abstract K getCurrentKey(); } } ``` -可以看到`KeyedStateBootstrapFunction`继承了`AbstractRichFunction`,它拥有RichFunction函数类的方法和属性,比如`open`方法等,因此实现起来也与在流处理中使用状态非常相似。`processElement`对每个输入数据进行处理,我们可以根据业务需要写入到Keyed State中。此外,该方法提供了上下文`Context`,里面包含了第五章[ProcessFunction](../chapter-time-window/process-function.md)中提到的`TimerService`。借助于`TimerService`,我们可以访问时间,注册Timer。这些Timer在当前写入Savepoint的过程并不会触发,仅当Savepoint恢复成一个流处理作业时被触发。 +可以看到 `KeyedStateBootstrapFunction` 继承了 `AbstractRichFunction`,它拥有 RichFunction 函数类的方法和属性,比如 `open` 方法等,因此实现起来也与在流处理中使用状态非常相似。`processElement` 对每个输入数据进行处理,我们可以根据业务需要写入到 Keyed State 中。此外,该方法提供了上下文 `Context`,里面包含了第五章 [ProcessFunction](../chapter-time-window/process-function.md) 中提到的 `TimerService`。借助于 `TimerService`,我们可以访问时间,注册 Timer。这些 Timer 在当前写入 Savepoint 的过程并不会触发,仅当 Savepoint 恢复成一个流处理作业时被触发。 -### 修改Savepoint +### 修改 Savepoint -除了从零开始构建一个新的Savepoint,我们也可以从一个已有的Savepoint基础上做修改,然后再保存起来。比如,下面的代码从一个已存在的Savepoint中获取数据,进行修改,生成新的Savepoint。 +除了从零开始构建一个新的 Savepoint,我们也可以从一个已有的 Savepoint 基础上做修改,然后再保存起来。比如,下面的代码从一个已存在的 Savepoint 中获取数据,进行修改,生成新的 Savepoint。 ```java ExecutionEnvironment bEnv = @@ -480,18 +494,18 @@ BootstrapTransformation transformation = OperatorTransformation Savepoint .load(bEnv, savepointPath, backend) - // 删除名为currency的算子 + // 删除名为 currency 的算子 .removeOperator("currency") - // 增加名为numbers的算子,使用transformation构建其状态数据 + // 增加名为 numbers 的算子,使用 transformation 构建其状态数据 .withOperator("number", transformation) - // 新的Savepoint会写到modifyPath路径下 + // 新的 Savepoint 会写到 modifyPath 路径下 .write(modifyPath); bEnv.execute("modify"); ``` -其中,`removeOperator`方法将一个算子状态数据从Savepoint中删除,`withOperator`方法增加了一个算子。修改完之后,我们可以通过`write`方法,将数据写入一个路径之下。 +其中,`removeOperator` 方法将一个算子状态数据从 Savepoint 中删除,`withOperator` 方法增加了一个算子。修改完之后,我们可以通过 `write` 方法,将数据写入一个路径之下。 -## Queryable State和State Processor API +## Queryable State 和 State Processor API -Flink提供的另外一个读取状态的API为Queryable State。使用Queryable State可以查询状态中的数据,其原理与State Processor API有相通之处。相比而下,两者侧重点各有不同,Queryable State重在查询状态,主要针对正在运行的线上服务,State Processor API可以修改状态,主要针对写入到Savepoint(或Checkpoint)中的数据。从侧重点上可以看到,两者所要解决的问题略有不同。感兴趣的读者前往官方查询Queryable State的使用方法。 \ No newline at end of file +Flink 提供的另外一个读取状态的 API 为 Queryable State。使用 Queryable State 可以查询状态中的数据,其原理与 State Processor API 有相通之处。相比而下,两者侧重点各有不同,Queryable State 重在查询状态,主要针对正在运行的线上服务,State Processor API 可以修改状态,主要针对写入到 Savepoint(或 Checkpoint)中的数据。从侧重点上可以看到,两者所要解决的问题略有不同。感兴趣的读者前往官方查询 Queryable State 的使用方法。 \ No newline at end of file diff --git a/doc/ch-state-checkpoint/state.md b/doc/ch-state-checkpoint/state.md index 0a2f2a4..ac792a9 100644 --- a/doc/ch-state-checkpoint/state.md +++ b/doc/ch-state-checkpoint/state.md @@ -3,7 +3,7 @@ :::{note} -本教程已出版为《Flink原理与实践》,感兴趣的读者请在各大电商平台购买! +本教程已出版为《Flink 原理与实践》,感兴趣的读者请在各大电商平台购买! ![](https://img.shields.io/badge/JD-%E8%B4%AD%E4%B9%B0%E9%93%BE%E6%8E%A5-red) @@ -17,99 +17,134 @@ * 数据流中的数据有重复,我们想对重复数据去重,需要记录哪些数据已经流入过应用,当新数据流入时,根据已流入数据来判断去重。 * 检查输入流是否符合某个特定的模式,需要将之前流入的元素以状态的形式缓存下来。比如,判断一个温度传感器数据流中的温度是否在持续上升。 -* 对一个时间窗口内的数据进行聚合分析,分析一个小时内某项指标的75分位或99分位的数值。 +* 对一个时间窗口内的数据进行聚合分析,分析一个小时内某项指标的 75 分位或 99 分位的数值。 * 在线机器学习场景下,需要根据新流入数据不断更新机器学习的模型参数。 -我们知道,Flink的一个算子有多个子任务,每个子任务分布在不同实例上,我们可以把状态理解为某个算子子任务在其当前实例上的一个变量,变量记录了数据流的历史信息。当新数据流入时,我们可以结合历史信息来进行计算。实际上,Flink的状态是由算子的子任务来创建和管理的。一个状态更新和获取的流程如下图所示,一个算子子任务接收输入流,获取对应的状态,根据新的计算结果更新状态。一个简单的例子是对一个时间窗口内输入流的某个整数字段求和,那么当算子子任务接收到新元素时,会获取已经存储在状态中的数值,然后将新元素加到状态上,并将状态数据更新。 +我们知道,Flink 的一个算子有多个子任务,每个子任务分布在不同实例上,我们可以把状态理解为某个算子子任务在其当前实例上的一个变量,变量记录了数据流的历史信息。当新数据流入时,我们可以结合历史信息来进行计算。实际上,Flink 的状态是由算子的子任务来创建和管理的。一个状态更新和获取的流程如下图所示,一个算子子任务接收输入流,获取对应的状态,根据新的计算结果更新状态。一个简单的例子是对一个时间窗口内输入流的某个整数字段求和,那么当算子子任务接收到新元素时,会获取已经存储在状态中的数值,然后将新元素加到状态上,并将状态数据更新。 -![状态获取和更新示意图](./img/state-acquisition-and-update.png) +```{figure} ./img/state-acquisition-and-update.png +--- +name: fig-state-acquisition-and-update +width: 50% +align: center +--- +状态获取和更新示意图 +``` 获取和更新状态的逻辑其实并不复杂,但流处理框架还需要解决以下几类问题: * 数据的产出要保证实时性,延迟不能太高。 * 需要保证数据不丢不重,恰好计算一次,尤其是当状态数据非常大或者应用出现故障需要恢复时,要保证状态不出任何错误。 -* 一般流处理任务都是7*24小时运行的,程序的可靠性非常高。 +* 一般流处理任务都是 7*24 小时运行的,程序的可靠性非常高。 基于上述要求,我们不能将状态直接交由内存管理,因为内存的容量是有限制的,当状态数据稍微大一些时,就会出现内存不够的问题。假如我们使用一个持久化的备份系统,不断将内存中的状态备份起来,当流处理作业出现故障时,需要考虑如何从备份中恢复。而且,大数据应用一般是横向分布在多个节点上,流处理框架需要保证横向的伸缩扩展性。可见,状态的管理并不那么容易。 -作为一个计算框架,Flink提供了有状态的计算,封装了一些底层的实现,比如状态的高效存储、Checkpoint和Savepoint持久化备份机制、计算资源扩缩容等问题。因为Flink接管了这些问题,开发者只需调用Flink API,这样可以更加专注于业务逻辑。 +作为一个计算框架,Flink 提供了有状态的计算,封装了一些底层的实现,比如状态的高效存储、Checkpoint 和 Savepoint 持久化备份机制、计算资源扩缩容等问题。因为 Flink 接管了这些问题,开发者只需调用 Flink API,这样可以更加专注于业务逻辑。 -## Flink的几种状态类型 +## Flink 的几种状态类型 -### Managed State和Raw State +### Managed State 和 Raw State -Flink有两种基本类型的状态:托管状态(Managed State)和原生状态(Raw State)。从名称中也能读出两者的区别:Managed State是由Flink管理的,Flink帮忙存储、恢复和优化,Raw State是开发者自己管理的,需要自己序列化。 +Flink 有两种基本类型的状态:托管状态(Managed State)和原生状态(Raw State)。从名称中也能读出两者的区别:Managed State 是由 Flink 管理的,Flink 帮忙存储、恢复和优化,Raw State 是开发者自己管理的,需要自己序列化。 | | Managed State | Raw State | | :----------: | ------------------------------------------------ | ---------------- | -| 状态管理方式 | Flink Runtime托管,自动存储、自动恢复、自动伸缩 | 用户自己管理 | -| 状态数据结构 | Flink提供的常用数据结构,如ListState、MapState等 | 字节数组:byte[] | -| 使用场景 | 绝大多数Flink算子 | 用户自定义算子 | +| 状态管理方式 | Flink Runtime 托管,自动存储、自动恢复、自动伸缩 | 用户自己管理 | +| 状态数据结构 | Flink 提供的常用数据结构,如 ListState、MapState 等 | 字节数组:byte[] | +| 使用场景 | 绝大多数 Flink 算子 | 用户自定义算子 | 上表展示了两者的区别,主要包括: -* 从状态管理的方式上来说,Managed State由Flink Runtime托管,状态是自动存储、自动恢复的,Flink在存储管理和持久化上做了一些优化。当我们横向伸缩,或者说我们修改Flink应用的并行度时,状态也能自动重新分布到多个并行实例上。Raw State是用户自定义的状态。 -* 从状态的数据结构上来说,Managed State支持了一系列常见的数据结构,如ValueState、ListState、MapState等。Raw State只支持字节,任何上层数据结构需要序列化为字节数组。使用时,需要用户自己序列化,以非常底层的字节数组形式存储,Flink并不知道存储的是什么样的数据结构。 -* 从具体使用场景来说,绝大多数的算子都可以通过继承RichFunction函数类或其他提供好的接口类,在里面使用Managed State。Raw State是在已有算子和Managed State不够用时,用户自定义算子时使用。 +* 从状态管理的方式上来说,Managed State 由 Flink Runtime 托管,状态是自动存储、自动恢复的,Flink 在存储管理和持久化上做了一些优化。当我们横向伸缩,或者说我们修改 Flink 应用的并行度时,状态也能自动重新分布到多个并行实例上。Raw State 是用户自定义的状态。 +* 从状态的数据结构上来说,Managed State 支持了一系列常见的数据结构,如 ValueState、ListState、MapState 等。Raw State 只支持字节,任何上层数据结构需要序列化为字节数组。使用时,需要用户自己序列化,以非常底层的字节数组形式存储,Flink 并不知道存储的是什么样的数据结构。 +* 从具体使用场景来说,绝大多数的算子都可以通过继承 RichFunction 函数类或其他提供好的接口类,在里面使用 Managed State。Raw State 是在已有算子和 Managed State 不够用时,用户自定义算子时使用。 -下面将重点介绍Managed State。 +下面将重点介绍 Managed State。 -### Keyed State和Operator State +### Keyed State 和 Operator State -对Managed State继续细分,它又有两种类型:Keyed State和Operator State。这里先简单对比两种状态,后续还将展示具体的使用方法。 +对 Managed State 继续细分,它又有两种类型:Keyed State 和 Operator State。这里先简单对比两种状态,后续还将展示具体的使用方法。 -Keyed State是`KeyedStream`上的状态。假如输入流按照id为Key进行了`keyBy`分组,形成一个`KeyedStream`,数据流中所有id为1的数据共享一个状态,可以访问和更新这个状态,以此类推,每个Key对应一个自己的状态。下图展示了Keyed State,因为一个算子子任务可以处理一到多个Key,算子子任务1处理了两种Key,两种Key分别对应自己的状态。 +Keyed State 是 `KeyedStream` 上的状态。假如输入流按照 id 为 Key 进行了 `keyBy` 分组,形成一个 `KeyedStream`,数据流中所有 id 为 1 的数据共享一个状态,可以访问和更新这个状态,以此类推,每个 Key 对应一个自己的状态。下图展示了 Keyed State,因为一个算子子任务可以处理一到多个 Key,算子子任务 1 处理了两种 Key,两种 Key 分别对应自己的状态。 -![Keyed State示意图](./img/keyedstate.png) +```{figure} ./img/keyedstate.png +--- +name: fig-keyed-state +width: 80% +align: center +--- +Keyed State 示意图 +``` -Operator State可以用在所有算子上,每个算子子任务或者说每个算子实例共享一个状态,流入这个算子子任务的所有数据都可以访问和更新这个状态。下图展示了Operator State,算子子任务1上的所有数据可以共享第一个Operator State,以此类推,每个算子子任务上的数据共享自己的状态。 +Operator State 可以用在所有算子上,每个算子子任务或者说每个算子实例共享一个状态,流入这个算子子任务的所有数据都可以访问和更新这个状态。{numref}`fig-operator-state` 展示了 Operator State,算子子任务 1 上的所有数据可以共享第一个 Operator State,以此类推,每个算子子任务上的数据共享自己的状态。 -![Operator State示意图](./img/operatorstate.png) +```{figure} ./img/operatorstate.png +--- +name: fig-operator-state +width: 80% +align: center +--- +Operator State 示意图 +``` -无论是Keyed State还是Operator State,Flink的状态都是基于本地的,即每个算子子任务维护着自身的状态,不能访问其他算子子任务的状态。 +无论是 Keyed State 还是 Operator State,Flink 的状态都是基于本地的,即每个算子子任务维护着自身的状态,不能访问其他算子子任务的状态。 -在之前各算子的介绍中曾提到,为了自定义Flink的算子,我们可以重写RichFunction函数类,比如`RichFlatMapFunction`。使用Keyed State时,我们也可以通过重写RichFunction函数类,在里面创建和访问状态。对于Operator State,我们还需进一步实现`CheckpointedFunction`接口。 +在之前各算子的介绍中曾提到,为了自定义 Flink 的算子,我们可以重写 RichFunction 函数类,比如 `RichFlatMapFunction`。使用 Keyed State 时,我们也可以通过重写 RichFunction 函数类,在里面创建和访问状态。对于 Operator State,我们还需进一步实现 `CheckpointedFunction` 接口。 | | Keyed State | Operator State | | -------------- | ----------------------------------------------- | -------------------------------- | -| 适用算子类型 | 只适用于`KeyedStream`上的算子 | 可以用于所有算子 | -| 状态分配 | 每个Key对应一个状态 | 一个算子子任务对应一个状态 | -| 创建和访问方式 | 重写Rich Function,通过里面的RuntimeContext访问 | 实现`CheckpointedFunction`等接口 | -| 横向扩展 | 状态随着Key自动在多个算子子任务上迁移 | 有多种状态重新分配的方式 | -| 支持的数据结构 | ValueState、ListState、MapState等 | ListState、BroadcastState等 | +| 适用算子类型 | 只适用于 `KeyedStream` 上的算子 | 可以用于所有算子 | +| 状态分配 | 每个 Key 对应一个状态 | 一个算子子任务对应一个状态 | +| 创建和访问方式 | 重写 Rich Function,通过里面的 RuntimeContext 访问 | 实现 `CheckpointedFunction` 等接口 | +| 横向扩展 | 状态随着 Key 自动在多个算子子任务上迁移 | 有多种状态重新分配的方式 | +| 支持的数据结构 | ValueState、ListState、MapState 等 | ListState、BroadcastState 等 | -上表总结了Keyed State和Operator State的区别。 +上表总结了 Keyed State 和 Operator State 的区别。 ## 横向扩展问题 -状态的横向扩展问题主要是指修改Flink应用的并行度,每个算子的并行实例数或算子子任务数发生了变化,应用需要关停或启动一些算子子任务,某份在原来某个算子子任务上的状态数据需要平滑更新到新的算子子任务上。Flink的Checkpoint可以辅助迁移状态数据。算子的本地状态将数据生成快照(Snapshot),保存到分布式存储(如HDFS)上。横向伸缩后,算子子任务个数变化,子任务重启,相应的状态从分布式存储上重建(Restore)。下图展示了一个算子扩容的状态迁移过程。 +状态的横向扩展问题主要是指修改 Flink 应用的并行度,每个算子的并行实例数或算子子任务数发生了变化,应用需要关停或启动一些算子子任务,某份在原来某个算子子任务上的状态数据需要平滑更新到新的算子子任务上。Flink 的 Checkpoint 可以辅助迁移状态数据。算子的本地状态将数据生成快照(Snapshot),保存到分布式存储(如 HDFS)上。横向伸缩后,算子子任务个数变化,子任务重启,相应的状态从分布式存储上重建(Restore)。{numref}`fig-flink-rescale` 展示了一个算子扩容的状态迁移过程。 -![Flink算子扩容示意图](./img/rescale.png) +```{figure} ./img/rescale.png +--- +name: fig-flink-rescale +width: 80% +align: center +--- +Flink 算子扩容示意图 +``` -对于Keyed State和Operator State这两种状态,他们的横向伸缩机制不太相同。由于每个Keyed State总是与某个Key相对应,当横向伸缩时,Key总会被自动分配到某个算子子任务上,因此Keyed State会自动在多个并行子任务之间迁移。对于一个非`KeyedStream`,流入算子子任务的数据可能会随着并行度的改变而改变。如上图所示,假如一个应用的并行度原来为2,那么数据会被分成两份并行地流入两个算子子任务,每个算子子任务有一份自己的状态,当并行度改为3时,数据流被拆成3支,此时状态的存储也相应发生了变化。对于横向伸缩问题,Operator State有两种状态分配方式:一种是均匀分配,另一种是将所有状态合并,再分发给每个实例上。 +对于 Keyed State 和 Operator State 这两种状态,他们的横向伸缩机制不太相同。由于每个 Keyed State 总是与某个 Key 相对应,当横向伸缩时,Key 总会被自动分配到某个算子子任务上,因此 Keyed State 会自动在多个并行子任务之间迁移。对于一个非 `KeyedStream`,流入算子子任务的数据可能会随着并行度的改变而改变。如上图所示,假如一个应用的并行度原来为 2,那么数据会被分成两份并行地流入两个算子子任务,每个算子子任务有一份自己的状态,当并行度改为 3 时,数据流被拆成 3 支,此时状态的存储也相应发生了变化。对于横向伸缩问题,Operator State 有两种状态分配方式:一种是均匀分配,另一种是将所有状态合并,再分发给每个实例上。 -## Keyed State的使用方法 +## Keyed State 的使用方法 -### Keyed State简介 +### Keyed State 简介 -对于Keyed State,Flink提供了几种现成的数据结构供我们使用,包括`ValueState`、`ListState`等,他们的继承关系如下图所示。首先,`State`主要有三种实现,分别为`ValueState`、`MapState`和`AppendingState`,`AppendingState`又可以细分为`ListState`、`ReducingState`和`AggregatingState`。 +对于 Keyed State,Flink 提供了几种现成的数据结构供我们使用,包括 `ValueState`、`ListState` 等,他们的继承关系如下图所示。首先,`State` 主要有三种实现,分别为 `ValueState`、`MapState` 和 `AppendingState`,`AppendingState` 又可以细分为 `ListState`、`ReducingState` 和 `AggregatingState`。 -![Keyed State继承关系](./img/inheritance-relationships-of-keyedState.png) +```{figure} ./img/inheritance-relationships-of-keyedState.png +--- +name: fig-keyed-state-inheritance +width: 60% +align: center +--- +Keyed State 继承关系 +``` 这几个状态的具体区别在于: -* `ValueState`是单一变量的状态,T是某种具体的数据类型,比如`Double`、`String`,或我们自己定义的复杂数据结构。我们可以使用`T value()`方法获取状态,使用`void update(T value)`更新状态。 -* `MapState`存储一个Key-Value Map,其功能与Java的`Map`几乎相同。`UV get(UK key)`可以获取某个Key下的Value值,`void put(UK key, UV value)`可以对某个Key设置Value,`boolean contains(UK key)`判断某个Key是否存在,`void remove(UK key)`删除某个Key以及对应的Value,`Iterable> entries()`返回`MapState`中所有的元素,`Iterator> iterator()`返回状态的迭代器。需要注意的是,`MapState`中的Key和Keyed State的Key不是同一个Key。 -* `ListState`存储了一个由T类型数据组成的列表。我们可以使用`void add(T value)`或`void addAll(List values)`向状态中添加元素,使用`Iterable get()`获取整个列表,使用`void update(List values)`来更新列表,新的列表将替换旧的列表。 -* `ReducingState`和`AggregatingState`与`ListState`同属于`MergingState`。与`ListState`不同的是,`ReducingState`只有一个元素,而不是一个列表。它的原理是:新元素通过`void add(T value)`加入后,与已有的状态元素使用`ReduceFunction`合并为一个元素,并更新到状态里。`AggregatingState`与`ReducingState`类似,也只有一个元素,只不过`AggregatingState`的输入和输出类型可以不一样。`ReducingState`和`AggregatingState`与窗口上进行`ReduceFunction`和`AggregateFunction`很像,都是将新元素与已有元素做聚合。 +* `ValueState` 是单一变量的状态,T 是某种具体的数据类型,比如 `Double`、`String`,或我们自己定义的复杂数据结构。我们可以使用 `T value()` 方法获取状态,使用 `void update(T value)` 更新状态。 +* `MapState` 存储一个 Key-Value Map,其功能与 Java 的 `Map` 几乎相同。`UV get(UK key)` 可以获取某个 Key 下的 Value 值,`void put(UK key, UV value)` 可以对某个 Key 设置 Value,`boolean contains(UK key)` 判断某个 Key 是否存在,`void remove(UK key)` 删除某个 Key 以及对应的 Value,`Iterable> entries()` 返回 `MapState` 中所有的元素,`Iterator> iterator()` 返回状态的迭代器。需要注意的是,`MapState` 中的 Key 和 Keyed State 的 Key 不是同一个 Key。 +* `ListState` 存储了一个由 T 类型数据组成的列表。我们可以使用 `void add(T value)` 或 `void addAll(List values)` 向状态中添加元素,使用 `Iterable get()` 获取整个列表,使用 `void update(List values)` 来更新列表,新的列表将替换旧的列表。 +* `ReducingState` 和 `AggregatingState` 与 `ListState` 同属于 `MergingState