Flink-流式处理-全-

Flink 流式处理(全)

原文:zh.annas-archive.org/md5/c606dfb14caa29bf98455740e025a645

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

您将从本书中学到什么

本书将教会您有关使用 Apache Flink 进行流处理的一切。它由 11 章组成,希望能够讲述一个连贯的故事。虽然一些章节是描述性的,旨在介绍高级设计概念,其他章节更注重实操,并包含许多代码示例。

虽然我们在撰写时希望按章节顺序阅读本书,但熟悉某一章内容的读者可能会跳过它。其他更感兴趣立即编写 Flink 代码的读者可能会先阅读实用章节。以下简要描述了每章内容,以便您直接跳转到最感兴趣的章节。

  • 第一章概述了有状态流处理、数据处理应用程序架构、应用程序设计以及流处理相对传统方法的优势。它还让您简要了解了在本地 Flink 实例上运行第一个流式应用程序的情况。

  • 第二章讨论了流处理的基本概念和挑战,独立于 Flink。

  • 第三章描述了 Flink 的系统架构和内部工作原理。它讨论了分布式架构、流式应用程序中的时间和状态处理,以及 Flink 的容错机制。

  • 第四章详细说明如何设置开发和调试 Flink 应用程序的环境。

  • 第五章向您介绍了 Flink DataStream API 的基础知识。您将学习如何实现 DataStream 应用程序以及支持的流转换、函数和数据类型。

  • 第六章讨论了 DataStream API 的基于时间的运算符。这包括窗口操作符、基于时间的连接,以及处理函数,在处理流应用程序中处理时间时提供了最大的灵活性。

  • 第七章介绍了如何实现有状态函数,并讨论了与此主题相关的一切,例如性能、健壮性以及有状态函数的演变。它还展示了如何使用 Flink 的可查询状态。

  • 第八章介绍了 Flink 最常用的源和接收器连接器。它讨论了 Flink 实现端到端应用一致性的方法,以及如何实现自定义连接器从外部系统摄取数据并发送数据。

  • 第九章讨论了如何在各种环境中设置和配置 Flink 集群。

  • 第十章涵盖了全天候运行的流式应用程序的运维、监控和维护。

  • 最后,第十一章包含资源,您可以使用这些资源提问,参加与 Flink 相关的活动,并了解 Flink 当前的使用情况。

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单,以及段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。还用于模块和包名称,以及显示用户应逐字键入和命令输出的文本或其他文本。

Constant width italic

显示应由用户提供值或由上下文确定值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

可以下载补充材料(Java 和 Scala 中的代码示例)https://github.com/streaming-with-flink

本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发包含 O’Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“使用 Apache Flink 进行流处理,作者 Fabian Hueske 和 Vasiliki Kalavri(O’Reilly)。版权所有 2019 年 Fabian Hueske 和 Vasiliki Kalavri,978-1-491-97429-2。”

如果您认为使用示例代码超出了公平使用或上述许可的范围,请随时通过permissions@oreilly.com联系我们。

O’Reilly 在线学习

注意

近 40 年来,O’Reilly为技术和商业培训提供了知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com

如何联系我们

有关本书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有一本关于这本书的网页,上面列出了勘误、示例和任何额外信息。你可以访问这个页面:http://bit.ly/stream-proc

如需对这本书进行评论或提出技术问题,请发送电子邮件至bookquestions@oreilly.com

获取更多关于我们的书籍、课程、会议和新闻的信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上关注我们:http://www.youtube.com/oreillymedia

在 Twitter 上关注作者们:@fhueske@vkalavri

致谢

这本书要感谢和承蒙一些了不起的人的帮助和支持。我们在这里要特别感谢和承认其中的一些人。

这本书总结了 Apache Flink 社区多年来在设计、开发和测试中获得的知识。我们感谢所有通过代码、文档、审阅、错误报告、功能请求、邮件列表讨论、培训、会议演讲、聚会组织和其他活动为 Flink 做出贡献的人们。

特别感谢我们的同事 Flink 提交者们:Alan Gates, Aljoscha Krettek, Andra Lungu, ChengXiang Li, Chesnay Schepler, Chiwan Park, Daniel Warneke, Dawid Wysakowicz, Gary Yao, Greg Hogan, Gyula Fóra, Henry Saputra, Jamie Grier, Jark Wu, Jincheng Sun, Konstantinos Kloudas, Kostas Tzoumas, Kurt Young, Márton Balassi, Matthias J. Sax, Maximilian Michels, Nico Kruber, Paris Carbone, Robert Metzger, Sebastian Schelter, Shaoxuan Wang, Shuyi Chen, Stefan Richter, Stephan Ewen, Theodore Vasiloudis, Thomas Weise, Till Rohrmann, Timo Walther, Tzu-Li (Gordon) Tai, Ufuk Celebi, Xiaogang Shi, Xiaowei Jiang, Xingcan Cui。通过这本书,我们希望能够接触到全世界的开发者、工程师和流处理爱好者,并且扩大 Flink 社区的规模。

我们还要感谢我们的技术审阅人员,他们提出了无数宝贵的建议,帮助我们改进内容的呈现。感谢你们,Adam Kawa, Aljoscha Krettek, Kenneth Knowles, Lea Giordano, Matthias J. Sax, Stephan Ewen, Ted Malaska 和 Tyler Akidau。

最后,我们要衷心感谢 O'Reilly 公司的所有同仁,在我们漫长的两年半旅程中与我们同行,帮助我们将这个项目推向成功。谢谢你们,Alicia Young, Colleen Lobner, Christina Edwards, Katherine Tozer, Marie Beaugureau 和 Tim McGovern。

第一章:介绍有状态流处理

Apache Flink 是一个分布式流处理器,具有直观和表达力强的 API,可用于实现有状态的流处理应用程序。它以容错的方式高效地在大规模上运行此类应用程序。Flink 于 2014 年 4 月作为孵化项目加入 Apache 软件基金会,并于 2015 年 1 月成为顶级项目。自成立以来,Flink 拥有一个非常活跃且不断增长的用户和贡献者社区。迄今为止,已有超过五百名个人为 Flink 做出了贡献,并且它已经发展成为广泛采用的最复杂的开源流处理引擎之一。Flink 驱动许多公司和企业跨越不同行业和全球范围内的大规模、业务关键应用程序。

流处理技术在大公司和小公司中越来越受欢迎,因为它为许多成熟用例(如数据分析、ETL 和事务应用程序)提供了优越的解决方案,并促进了新的应用程序、软件架构和业务机会。在本章中,我们讨论为什么有状态流处理如此受欢迎,并评估其潜力。我们首先回顾传统数据应用程序架构,并指出它们的局限性。接下来,我们介绍基于有状态流处理的应用程序设计,展示其在传统方法上的许多有趣特性和优势。最后,我们简要讨论开源流处理器的发展,并帮助您在本地 Flink 实例上运行流应用程序。

传统数据基础设施

多年来,数据和数据处理在企业中普遍存在。多年来,数据的收集和使用一直在持续增长,公司设计和建立了基础设施来管理这些数据。大多数企业实施的传统架构区分两种数据处理类型:事务处理和分析处理。在本节中,我们讨论了这两种类型以及它们如何管理和处理数据。

事务处理

公司在日常业务活动中使用各种应用程序,如企业资源规划(ERP)系统、客户关系管理(CRM)软件和基于 Web 的应用程序。这些系统通常设计有独立的数据处理层(应用程序本身)和数据存储层(事务性数据库系统),如图 1-1 所示。

图 1-1 传统的事务性应用程序设计,将数据存储在远程数据库系统中

应用程序通常连接到外部服务或面向人类用户,并持续处理诸如订单、电子邮件或网站点击等传入事件。当事件被处理时,应用程序读取其状态或通过对远程数据库系统运行事务来更新状态。通常,数据库系统为多个应用程序提供服务,有时这些应用程序可能访问相同的数据库或表。

当应用程序需要发展或扩展时,这种应用程序设计可能引发问题。由于多个应用程序可能在相同的数据表示上工作或共享相同的基础设施,更改表的架构或扩展数据库系统需要谨慎规划和大量努力。克服应用程序紧密捆绑的最新方法是微服务设计模式。微服务被设计为小型、独立和独立的应用程序。它们遵循单一事务并做好的 UNIX 哲学。更复杂的应用程序是通过连接多个只通过标准化接口(例如 RESTful HTTP 连接)进行通信的微服务构建的。因为微服务彼此之间严格解耦并且只通过定义良好的接口进行通信,因此每个微服务都可以使用不同的技术堆栈实现,包括编程语言、库和数据存储。微服务和所有必需的软件和服务通常捆绑并部署在独立的容器中。图 1-2 展示了一个微服务架构。

图 1-2. 微服务架构

分析处理

公司各种事务性数据库系统中存储的数据可以提供有关公司业务运营的宝贵见解。例如,订单处理系统的数据可以分析以获得销售增长趋势,识别延迟发货原因,或者预测未来销售以调整库存。然而,事务性数据通常分布在多个不连通的数据库系统中,只有在可以联合分析时才更具价值。此外,这些数据通常需要转换为通用格式。

不直接在事务性数据库上运行分析查询,而是通常将数据复制到数据仓库,这是专门用于分析查询工作负载的专用数据存储。为了填充数据仓库,事务性数据库系统管理的数据需要复制到其中。将数据复制到数据仓库的过程称为抽取-转换-加载(ETL)。ETL 过程从事务性数据库中提取数据,将其转换为通用表示形式,可能包括验证、值规范化、编码、去重和模式转换,最后将其加载到分析数据库中。ETL 过程可能非常复杂,通常需要技术上复杂的解决方案以满足性能要求。ETL 过程需要定期运行,以保持数据仓库中的数据同步。

一旦数据导入数据仓库,就可以查询和分析它。通常,在数据仓库上执行两类查询。第一类是周期性报告查询,计算业务相关统计数据,如收入、用户增长或生产产出。这些指标被汇总成报告,帮助管理层评估企业的整体健康状况。第二类是即席查询,旨在回答特定问题并支持业务关键决策,例如查询以收集收入数据和用于广播广告的支出,以评估营销活动的效果。这两种类型的查询由数据仓库以批处理方式执行,如图 1-3 所示。

图 1-3. 传统数据仓库架构用于数据分析

今天,Apache Hadoop 生态系统的组件已成为许多企业 IT 基础设施的重要部分。与将所有数据插入关系数据库系统不同,大量数据(如日志文件、社交媒体或 Web 点击日志)被写入 Hadoop 的分布式文件系统(HDFS)、S3 或其他大容量数据存储,如 Apache HBase,这些系统提供了巨大的存储能力以较低的成本。驻留在这些存储系统中的数据可以通过 SQL-on-Hadoop 引擎查询和处理,例如 Apache Hive、Apache Drill 或 Apache Impala。然而,基础设施基本上仍然与传统的数据仓库架构相同。

状态型流处理

几乎所有的数据都是作为连续的事件流创建的。想象一下网站或移动应用中的用户交互、订单的下达、服务器日志或传感器测量;所有这些都是事件流。事实上,很难找到一次性生成的有限完整数据集的例子。有状态的流处理是一种处理无界事件流的应用程序设计模式,适用于公司 IT 基础设施中的许多不同用例。在我们讨论其用例之前,我们简要解释一下有状态的流处理是如何工作的。

任何处理事件流且不仅执行单个记录转换的应用程序都需要是有状态的,具有存储和访问中间数据的能力。当应用程序接收到事件时,它可以执行涉及从状态读取数据或向状态写入数据的任意计算。原则上,状态可以存储和访问在多个不同的地方,包括程序变量、本地文件或嵌入式或外部数据库。

Apache Flink 将应用程序状态存储在内存中或嵌入式数据库中。由于 Flink 是一个分布式系统,本地状态需要受到保护,以避免在应用程序或机器故障时数据丢失。Flink 通过定期将应用程序状态的一致性检查点写入远程和持久存储来保证这一点。关于状态、状态一致性以及 Flink 的检查点机制将在接下来的章节中详细讨论,但是现在,图 1-4 展示了一个有状态的流式 Flink 应用程序。

图 1-4. 一个有状态的流式应用程序

有状态的流处理应用程序通常从事件日志中接收其传入事件。事件日志存储和分发事件流。事件被写入持久、只追加的日志,这意味着写入事件的顺序不能被更改。写入到事件日志的流可以被同一个或不同的消费者多次读取。由于日志的只追加属性,事件总是以完全相同的顺序发布给所有消费者。有几种事件日志系统作为开源软件提供,Apache Kafka 是最流行的,也可以作为云计算提供商提供的集成服务。

将运行在 Flink 上的有状态流应用与事件日志连接起来是有趣的,原因有多种。在这种架构中,事件日志会持久化输入事件,并能够按确定性顺序重放它们。在发生故障时,Flink 通过从先前检查点恢复其状态并重置事件日志上的读取位置来恢复有状态流应用。应用程序将会从事件日志中回放(和快进)输入事件,直到达到流的末尾。这种技术用于从故障中恢复,但也可以用于更新应用程序、修复错误和修复先前发出的结果、将应用程序迁移到不同的集群,或使用不同的应用程序版本进行 A/B 测试。

如前所述,有状态流处理是一种多用途且灵活的设计架构,可用于许多不同的用例。以下我们介绍了三类常用状态流处理实现的应用程序:(1)事件驱动应用程序,(2)数据管道应用程序和(3)数据分析应用程序。

真实世界的流处理用例和部署

如果您有兴趣了解更多关于真实世界用例和部署的信息,请查看 Apache Flink 的Powered By 页面以及Flink Forward 的演讲录音和幻灯片。

我们将应用程序的这些类描述为不同的模式,以强调有状态流处理的多功能性,但大多数真实世界的应用程序共享多个类的属性。

事件驱动应用

事件驱动应用是有状态的流应用,它们摄取事件流并使用特定于应用的业务逻辑处理事件。根据业务逻辑,事件驱动应用可以触发诸如发送警报或电子邮件的操作,或者将事件写入出站事件流以供另一个事件驱动应用消费。

事件驱动应用的典型用例包括:

  • 实时推荐(例如在客户浏览零售商网站时推荐产品)

  • 模式检测或复杂事件处理(例如用于信用卡交易中的欺诈检测)

  • 异常检测(例如检测企图入侵计算机网络的尝试)

事件驱动应用是微服务的一种演进形式。它们通过事件日志进行通信,而不是通过 REST 调用,并将应用数据作为本地状态而不是写入和读取外部数据存储,比如关系型数据库或键值存储。图 1-5 展示了由事件驱动流应用组成的服务架构。

图 1-5. 事件驱动应用架构

图 1-5 中的应用程序通过事件日志连接。一个应用程序将其输出发出到事件日志,另一个应用程序消耗另一个应用程序发出的事件。事件日志解耦了发送方和接收方,并提供了异步、非阻塞的事件传输。每个应用程序可以是有状态的,并且可以在不访问外部数据存储的情况下本地管理其自己的状态。应用程序也可以单独操作和扩展。

事件驱动的应用程序相比事务性应用程序或微服务具有几个优点。与远程数据存储的读写查询相比,本地状态访问提供了非常好的性能。通过流处理器处理扩展性和容错性,通过利用事件日志作为输入源,应用程序的完整输入可靠地存储,并可以确定性地重放。此外,Flink 可以将应用程序的状态重置到先前的保存点,从而可以在不丢失状态的情况下演进或重新调整应用程序。

事件驱动的应用程序对运行它们的流处理器有相当高的要求。并非所有流处理器都同样适合运行事件驱动的应用程序。API 的表达能力以及状态处理和事件时间支持的质量决定了可以实现和执行的业务逻辑。这一方面取决于流处理器的 API、它提供的状态基元以及其对事件时间处理支持的质量。此外,一致的状态一致性和能够扩展应用程序是事件驱动应用程序的基本要求。Apache Flink 符合所有这些要求,是运行这类应用程序的非常好的选择。

数据管道

当今的 IT 架构包括许多不同的数据存储,例如关系型数据库系统、专用数据库系统、事件日志、分布式文件系统、内存缓存和搜索索引。所有这些系统都以不同的格式和数据结构存储数据,以便为它们特定的访问模式提供最佳性能。公司通常会将相同的数据存储在多个不同的系统中,以提高数据访问的性能。例如,在网店中提供的产品信息可以存储在事务性数据库、Web 缓存和搜索索引中。由于数据的复制,数据存储必须保持同步。

在不同存储系统中同步数据的传统方法是定期的 ETL 作业。然而,它们无法满足当今许多用例的延迟要求。一个替代方案是使用事件日志来分发更新。更新被写入和分发到事件日志。日志的消费者将更新合并到受影响的数据存储中。根据用例的不同,传输的数据可能需要在被目标数据存储摄取之前进行规范化、丰富化外部数据或聚合。

低延迟摄取、转换和插入数据是有状态流处理应用程序的另一个常见用例。这种应用称为数据管道。数据管道必须能够在短时间内处理大量数据。操作数据管道的流处理器还应具备许多源和汇接器连接器,用于从各种存储系统读取数据和写入数据。同样,Flink 可以做到这一切。

流式分析

定期的 ETL 作业将数据导入数据存储,并由即席或定期查询处理。无论架构是基于数据仓库还是 Hadoop 生态系统的组件,这都是批处理。多年来,周期性加载数据到数据分析系统一直是最先进的技术,但会给分析流程增加相当的延迟。

根据调度间隔,一个数据点可能需要几小时或几天才能包含在报告中。在某种程度上,可以通过使用数据管道应用程序将数据导入数据存储来减少延迟。然而,即使进行持续的 ETL,事件被查询处理之前总会有延迟。尽管这种延迟在过去可能是可以接受的,但现今的应用程序必须能够实时收集数据并立即对其进行操作(例如通过调整移动游戏中的变化条件或为在线零售商个性化用户体验)。

流式分析应用程序不等待周期性触发,而是持续摄取事件流,并通过低延迟将最新事件集成到其结果中。这类似于数据库系统用来更新物化视图的维护技术。通常,流式应用程序将其结果存储在支持高效更新的外部数据存储中,例如数据库或键值存储。流式分析应用程序的实时更新结果可用于驱动仪表盘应用程序,如图 1-6 所示。

图 1-6. 流式分析应用程序

除了事件进入分析结果所需的时间大大缩短外,流分析应用程序还有另一个不太明显的优势。传统的分析流水线由多个单独的组件组成,如 ETL 过程、存储系统,在基于 Hadoop 的环境中还包括数据处理器和调度器以触发作业或查询。相比之下,运行有状态流应用程序的流处理器负责所有这些处理步骤,包括事件摄入、连续计算(包括状态维护)和更新结果。此外,流处理器可以通过确保一次性状态一致性保证从故障中恢复,并且可以调整应用程序的计算资源。像 Flink 这样的流处理器还支持事件时间处理以生成正确和确定性的结果,并能在短时间内处理大量数据。

流分析应用程序通常用于:

  • 手机网络质量的监测

  • 分析移动应用程序中的用户行为

  • 在消费技术中对实时数据进行即席分析

虽然我们在这里没有涉及,但 Flink 还支持对流进行分析 SQL 查询。

开源流处理技术的演变

数据流处理并不是一项新技术。一些最早的研究原型和商业产品可以追溯到上世纪 90 年代末。然而,最近过去的流处理技术的广泛采用在很大程度上是由成熟的开源流处理器的可用性推动的。今天,分布式开源流处理器在许多企业的关键业务应用中发挥着作用,涵盖不同行业,如(在线)零售、社交媒体、电信、游戏和银行业。开源软件是这一趋势的主要推动力,主要原因有两点:

  1. 开源流处理软件是每个人都可以评估和使用的商品。

  2. 由于许多开源社区的努力,可伸缩的流处理技术正在迅速成熟和发展。

仅仅 Apache 软件基金会就拥有超过十几个与流处理相关的项目。新的分布式流处理项目不断进入开源阶段,并通过新的功能和能力挑战现有技术的水平。开源社区不断改进其项目的能力,并推动流处理技术的技术边界。我们将简要回顾一下过去,看看开源流处理技术的起源及其现状。

一点历史

第一代分布式开源流处理器(2011)专注于事件处理,具有毫秒级延迟,并在发生故障时保证不丢失事件。这些系统具有相对低级的 API,并且不提供内置支持以获取流应用程序的准确和一致的结果,因为结果取决于事件到达的时间和顺序。此外,即使事件未丢失,它们也可能被处理多次。与批处理处理器相比,首批开源流处理器通过更好的延迟来交换结果的准确性。数据处理系统(在这个时间点上)只能提供快速或准确的结果的观察结果导致了所谓的 Lambda 架构的设计,如图 1-7 所示。

图 1-7. Lambda 架构

Lambda 架构通过一个低延迟流处理器增加了传统周期批处理架构的速度层。到达 Lambda 架构的数据由流处理器摄取,同时写入批处理存储。流处理器实时计算近似结果并将其写入速度表。批处理器定期处理批处理存储中的数据,将准确结果写入批处理表,并从速度表中删除相应的不准确结果。应用程序通过合并速度表中的近似结果和批处理表中的准确结果来消费这些结果。

Lambda 架构已不再是技术的最前沿,但仍然在许多地方使用。这种架构的最初目标是改善原始批处理分析架构的高结果延迟。然而,它也有一些显著的缺点。首先,它需要两个语义等效的应用逻辑实现,分别针对具有不同 API 的两个独立处理系统。其次,流处理器计算的结果仅是近似的。第三,Lambda 架构难以设置和维护。

在第一代基础上改进,下一代分布式开源流处理器(2013)提供了更好的故障保证,并确保在故障情况下每个输入记录对结果的影响恰好一次。此外,编程 API 从相对低级的运算符界面发展到具有更多内置原语的高级 API。然而,一些改进,如更高的吞吐量和更好的故障保证,以增加处理延迟(从毫秒到秒)为代价。此外,结果仍然依赖于到达事件的时间和顺序。

第三代分布式开源流处理器(2015 年)解决了结果依赖于到达事件的时间和顺序的问题。结合精确一次的失败语义,这一代系统是第一个能够计算一致和准确结果的开源流处理器。通过仅基于实际数据计算结果,这些系统还能以与“实时”数据相同的方式处理历史数据。另一个改进是取消了延迟/吞吐量的折衷。第三代系统能够同时提供高吞吐量和低延迟,使λ架构过时。

除了迄今为止讨论的系统特性(如容错性,性能和结果准确性)之外,流处理器还不断增加新的操作特性,例如高度可用的设置,与资源管理器(如 YARN 或 Kubernetes)的紧密集成,以及动态扩展流处理应用程序的能力。其他特性包括支持升级应用程序代码或将作业迁移到不同的集群或流处理器的新版本,而不会丢失当前状态。

快速查看 Flink

Apache Flink 是一个具有竞争力特性集的第三代分布式流处理器。它在大规模情况下提供精确的流处理,具有高吞吐量和低延迟。特别是以下特性使 Flink 脱颖而出:

  • 事件时间和处理时间语义。事件时间语义提供了一致和准确的结果,尽管事件顺序可能混乱。处理时间语义可用于具有非常低延迟要求的应用程序。

  • 精确一次的状态一致性保证。

  • 在处理每秒数百万事件的同时毫秒级延迟。Flink 应用程序可以扩展到数千个核心。

  • 分层 API 具有不同的表达能力和易用性折衷。本书涵盖了 DataStream API 和处理函数,提供了常见流处理操作的原语,如窗口操作和异步操作,并提供了精确控制状态和时间的接口。本书不讨论 Flink 的关系型 API,即 SQL 和 LINQ 风格的 Table API。

  • 连接器与最常用的存储系统,例如 Apache Kafka,Apache Cassandra,Elasticsearch,JDBC,Kinesis,以及(分布式)文件系统,如 HDFS 和 S3。

  • 由于其高度可用的设置(无单点故障),与 Kubernetes、YARN 和 Apache Mesos 的紧密集成,故障恢复快,以及动态扩展作业的能力,能够 24/7 运行流处理应用程序,几乎没有停机时间。

  • 能够更新作业的应用程序代码,并将作业迁移到不同的 Flink 集群,而不会丢失应用程序的状态。

  • 详细和可定制的系统和应用程序指标收集,以便及时识别和应对问题。

  • 最后但同样重要的是,Flink 还是一个全功能的批处理处理器。¹

除了这些特性外,由于其易于使用的 API,Flink 也是一个非常友好的开发框架。嵌入式执行模式在单个 JVM 进程中启动应用程序和整个 Flink 系统,可用于在 IDE 中运行和调试 Flink 作业。在开发和测试 Flink 应用程序时,这一功能非常实用。

接下来,我们将指导您启动本地集群并执行流应用程序,让您首次了解 Flink。我们将要运行的应用程序会按时间转换和聚合随机生成的温度传感器读数。在这个例子中,您的系统需要安装 Java 8。我们描述了 UNIX 环境的步骤,但如果您使用 Windows,我们建议设置一个带有 Linux、Cygwin(Windows 上的 Linux 环境)或 Windows Subsystem for Linux(Windows 10 引入的 Linux 子系统)的虚拟机。以下步骤展示了如何启动本地 Flink 集群并提交应用程序进行执行。

  1. 前往 Apache Flink 网页 并下载适用于 Scala 2.12 的 Apache Flink 1.7.1 的无 Hadoop 二进制发行版。

  2. 解压存档文件:

    $ tar xvfz flink-1.7.1-bin-scala_2.12.tgz
    
    
  3. 启动本地 Flink 集群:

    $ cd flink-1.7.1
    $ ./bin/start-cluster.sh
    Starting cluster.
    Starting standalonesession daemon on host xxx.
    Starting taskexecutor daemon on host xxx.
    
    
  4. 在浏览器中输入 http://localhost:8081 打开 Flink 的 Web UI。如 图 1-8 所示,您将看到有关刚启动的本地 Flink 集群的一些统计信息。它将显示一个单独的 TaskManager(Flink 的工作进程)已连接,一个单独的任务槽(由 TaskManager 提供的资源单元)可用。

  5. 下载包含本书示例的 JAR 文件:

    $ wget https://streaming-with-flink.github.io/\
    examples/download/examples-scala.jar
    
    
    注意

    你也可以按照存储库的 README 文件中的步骤自行构建 JAR 文件。

  6. 通过指定应用程序的入口类和 JAR 文件在本地集群上运行示例:

    $ ./bin/flink run \
      -c io.github.streamingwithflink.chapter1.AverageSensorReadings \
      examples-scala.jar
    Starting execution of program
    Job has been submitted with JobID cfde9dbe315ce162444c475a08cf93d9
    
    
  7. 检查 web 仪表板。您应该看到“正在运行的作业”下列出的一个作业。如果点击该作业,您将看到与 图 1-9 类似的有关正在运行作业的数据流和实时指标的信息。

  8. 作业的输出被写入 Flink 工作进程的标准输出,通常被重定向到 ./log 文件夹中的文件。您可以使用 tail 命令监控持续产生的输出:

    $ tail -f ./log/flink-<user>-taskexecutor-<n>-<hostname>.out
    
    

    您应该看到类似这样的行被写入文件:

    SensorReading(sensor_1,1547718199000,35.80018327300259)
    SensorReading(sensor_6,1547718199000,15.402984393403084)
    SensorReading(sensor_7,1547718199000,6.720945201171228)
    SensorReading(sensor_10,1547718199000,38.101067604893444)
    
    

    SensorReading 的第一个字段是 sensorId,第二个字段是自 1970-01-01-00:00:00.000 以来的毫秒时间戳,第三个字段是计算出的 5 秒内的平均温度。

  9. 由于您正在运行流应用程序,该应用程序将继续运行,直到您取消它。您可以通过在 Web 仪表板中选择作业并单击页面顶部的“取消”按钮来执行此操作。

  10. 最后,您应该停止本地 Flink 集群的运行:

    $ ./bin/stop-cluster.sh
    
    

到此为止。您刚刚安装并启动了第一个本地 Flink 集群,并运行了第一个 Flink DataStream API 程序!当然,关于使用 Apache Flink 进行流处理还有更多知识可以学习,这也是本书的主题。

总结

在本章中,我们介绍了有状态流处理,讨论了其用例,并初步了解了 Apache Flink。我们从传统数据基础设施的概述开始,讨论了业务应用程序的常见设计方式,以及如何在今天大多数公司中收集和分析数据。然后,我们介绍了有状态流处理的概念,并解释了它如何解决从业务应用程序和微服务到 ETL 和数据分析的广泛用例。我们讨论了自 2010 年代初以来开源流处理系统的发展以及流处理如何成为今天许多业务用例的可行解决方案。最后,我们深入了解了 Apache Flink 及其提供的广泛功能,并展示了如何安装本地 Flink 环境并运行第一个流处理应用程序。

¹ Flink 的批处理 API,即 DataSet API 及其运算符与其相应的流处理 API 是分开的。然而,Flink 社区的愿景是将批处理视为流处理的一种特殊情况——有界流的处理。Flink 社区的持续努力是将 Flink 发展成一个具有真正统一的批处理和流处理 API 及运行时的系统。

第二章:流处理基础

到目前为止,你已经看到流处理如何解决传统批处理的一些局限性,并且它如何支持新的应用和架构。你也了解了开源流处理空间的演进和 Flink 流应用程序的外观。在本章中,你将真正进入流处理的世界。

本章的目标是介绍流处理的基本概念及其框架的需求。我们希望在阅读本章后,您能够评估现代流处理系统的特性。

数据流编程介绍

在深入研究流处理基础之前,让我们看一下数据流编程的背景以及本书中将使用的术语。

数据流图

如其名称所示,数据流程序描述了数据在操作之间的流动方式。数据流程序通常表示为有向图,其中节点称为运算符,表示计算,边表示数据依赖关系。运算符是数据流应用程序的基本功能单元。它们从输入消耗数据,对其执行计算,并将数据产生到输出以供进一步处理。没有输入端口的运算符称为数据源,没有输出端口的运算符称为数据汇。数据流图必须至少有一个数据源和一个数据汇。图 2-1 显示了一个从推文输入流中提取和计数标签的数据流程序。

逻辑数据流图

图 2-1 逻辑数据流图,持续计算标签数(节点表示运算符,边表示数据依赖关系)

像图 2-1 中的数据流图被称为逻辑图,因为它们传达了计算逻辑的高层视图。为了执行数据流程序,其逻辑图被转换为物理数据流图,详细指定了程序的执行方式。例如,如果我们使用分布式处理引擎,每个运算符可能在不同的物理机器上运行多个并行任务。图 2-2 展示了图 2-1 的逻辑图的物理数据流图。在逻辑数据流图中,节点表示运算符;而在物理数据流图中,节点是任务。“提取标签”和“计数”运算符各有两个并行运算任务,每个任务在输入数据的子集上执行计算。

物理数据流图

图 2-2 计算标签的物理数据流计划(节点表示任务)

数据并行和任务并行

您可以以不同方式利用数据流图中的并行性。首先,您可以对输入数据进行分区,并使同一操作的任务并行执行数据子集。这种并行性称为数据并行性。数据并行性非常有用,因为它允许处理大量数据并将计算负载分布到多个计算节点上。其次,您可以使来自不同运算符的任务并行执行相同或不同的数据。这种并行性称为任务并行性。使用任务并行性,您可以更好地利用集群的计算资源。

数据交换策略

数据交换策略定义了如何在物理数据流图中将数据项分配给任务。数据交换策略可以根据运算符的语义由执行引擎自动选择,也可以由数据流程序员明确指定。在这里,我们简要回顾了一些常见的数据交换策略,如图 2-3 所示。

  • 前向策略将数据从一个任务发送到接收任务。如果这两个任务位于同一物理机器上(这通常由任务调度器保证),这种交换策略可以避免网络通信。

  • 广播策略将每个数据项发送到运算符的所有并行任务。由于这种策略复制数据并涉及网络通信,因此成本相当高。

  • 基于键值策略根据键属性对数据进行分区,并保证具有相同键的数据项将由同一个任务处理。在图 2-2 中,“提取标签”运算符的输出按键(标签)进行分区,以便计数运算符任务可以正确计算每个标签的出现次数。

  • 随机策略将数据项均匀分发到运算符任务中,以便在计算任务中均匀分配负载。

数据交换策略

图 2-3. 数据交换策略

并行处理流

现在您已经熟悉了数据流编程的基础知识,是时候看看这些概念如何应用于并行处理数据流了。但首先,让我们定义一下 数据流 这个术语:数据流是一个潜在无界的事件序列。

数据流中的事件可以表示监控数据、传感器测量、信用卡交易、气象站观测、在线用户互动、网络搜索等。在本节中,您将学习如何使用数据流编程范式并行处理无限流。

延迟和吞吐量

在第一章中,你学习到流式应用程序与传统的批处理程序有不同的运行要求。在评估性能时,要求也不同。对于批处理应用程序,通常关心作业的总执行时间,或者处理引擎读取输入、执行计算并写回结果所需的时间。由于流式应用程序持续运行且输入可能是无界的,因此在数据流处理中没有总执行时间的概念。相反,流式应用程序必须尽可能快地为传入数据提供结果,同时能够处理高吞吐量的事件。我们用延迟吞吐量来表达这些性能需求。

延迟

延迟指事件被处理所需的时间长度。实质上,它是接收事件并在输出中看到处理效果之间的时间间隔。为了直观理解延迟,考虑你每天去你最喜欢的咖啡店的情景。当你进入咖啡店时,里面可能已经有其他顾客了。因此,你需要排队等候,当轮到你时你就点单。收银员收到你的支付并把订单传给咖啡师,后者准备你的饮料。当你的咖啡准备好时,咖啡师会叫你的名字,然后你可以从柜台取走你的咖啡。服务延迟就是你在咖啡店内的时间,从你进入到你第一口咖啡的时间。

在数据流处理中,延迟以时间单位(例如毫秒)来衡量。根据应用程序的不同,你可能关心平均延迟、最大延迟或百分位延迟。例如,平均延迟值为 10 毫秒意味着事件平均在 10 毫秒内被处理。而 95 分位数的延迟值为 10 毫秒则表示 95%的事件在 10 毫秒内被处理。平均值隐藏了处理延迟的真实分布,可能会使问题难以被察觉。如果咖啡师在准备你的卡布奇诺之前发现牛奶用完了,你就得等到他们从储藏室拿来。虽然这种延迟可能会让你感到恼火,但大多数其他顾客仍然会很满意。

确保低延迟对许多流应用程序至关重要,例如欺诈检测、系统警报、网络监控以及提供严格服务水平协议的服务。低延迟是流处理的关键特性,它使我们能够实现所谓的实时应用程序。现代流处理器,如 Apache Flink,可以提供低至几毫秒的延迟。相比之下,传统的批处理处理延迟通常在几分钟到几小时之间。在批处理中,你首先需要批量收集事件,然后才能处理它们。因此,延迟由每批最后一个事件到达时间界定,并且自然取决于批量大小。真正的流处理不引入这种人为延迟,因此可以实现非常低的延迟。在真正的流模型中,事件一到达系统就可以被处理,延迟更接近于每个事件需要执行的实际工作量。

吞吐量

Throughput 是系统处理能力的衡量标准—其处理速率。也就是说,通过 put 告诉我们系统每个时间单位可以处理多少事件。重新访问咖啡店的例子,如果店铺从早上 7 点到晚上 7 点开放,并且一天服务 600 位顾客,那么它的平均吞吐量将是每小时 50 位顾客。虽然你希望延迟尽可能低,但通常希望吞吐量尽可能高。

吞吐量是每个时间单位内的事件或操作数。重要的是要注意,处理速率取决于到达率;低吞吐量不一定意味着性能不佳。在流处理系统中,通常希望确保系统能够处理预期的最大事件速率。也就是说,你主要关心确定峰值吞吐量——系统在最大负载时的性能极限。为了更好地理解峰值吞吐量的概念,让我们考虑一个流处理应用程序,该应用程序没有接收任何传入数据,因此不消耗任何系统资源。当第一个事件进来时,它将立即被处理,延迟最小。例如,如果你是早上咖啡店开门后的第一个顾客,你将立即被服务。理想情况下,你希望这种延迟保持恒定,独立于传入事件的速率。然而,一旦达到传入事件的速率,使系统资源完全被使用,我们将不得不开始缓冲事件。在咖啡店的例子中,你可能会在午餐后看到这种情况发生。很多人同时出现并且不得不排队等候。在这一点上,系统已经达到了峰值吞吐量,进一步增加事件率只会导致更糟糕的延迟。如果系统继续以比其处理能力更高的速率接收数据,缓冲区可能会不可用,并且数据可能会丢失。这种情况通常被称为反压,并且有不同的策略来处理它。

延迟与吞吐量

在这一点上,应该清楚延迟和吞吐量不是独立的度量标准。如果事件在数据处理管道中传播需要很长时间,我们就不能轻易确保高吞吐量。同样地,如果系统的容量较小,事件将被缓冲并且必须等待被处理。

让我们重新审视咖啡店的例子,以澄清延迟和吞吐量如何相互影响。首先,应该清楚的是在没有负载的情况下存在最佳延迟。也就是说,如果你是咖啡店里唯一的顾客,你会得到最快的服务。然而,在繁忙时段,顾客将不得不排队等候,延迟会增加。影响延迟和因此影响吞吐量的另一个因素是处理事件所需的时间,或者在咖啡店里为每位顾客提供服务所需的时间。想象一下在圣诞节假期期间,咖啡师必须在每杯咖啡上画圣诞老人。这意味着准备一杯饮料所需的时间将增加,导致每个人在咖啡店里花费更多时间,从而降低总体吞吐量。

那么,您是否可以同时获得低延迟和高吞吐量,还是这是一个不切实际的努力?通过雇用一个更熟练的咖啡师——一个能更快地制作咖啡的人,也许您可以在我们咖啡店的例子中降低延迟。在高负载下,这种改变还将增加吞吐量,因为可以在同样的时间内为更多客户提供服务。实现同样结果的另一种方法是雇用第二个咖啡师并利用并行性。这里的主要要点是降低延迟会增加吞吐量。自然地,如果系统可以更快地执行操作,那么它在同样时间内可以执行更多操作。事实上,在流处理管道中利用并行性时,就会发生这种情况。通过并行处理多个流,您可以降低延迟,同时处理更多事件。

数据流操作

流处理引擎通常提供一组内置操作来摄入、转换和输出流。这些操作符可以组合成数据流处理图,实现流应用程序的逻辑。在本节中,我们描述最常见的流处理操作。

操作可以是无状态有状态的。无状态操作不维护任何内部状态。也就是说,事件的处理不依赖于过去看到的任何事件,也不保留任何历史记录。无状态操作易于并行化,因为可以独立处理事件,而不考虑它们的顺序。此外,在发生故障时,可以简单地重新启动无状态操作符,并从离开的地方继续处理。相比之下,有状态操作符可能会维护有关它们之前接收的事件的信息。此状态可以通过传入事件更新,并可以在未来事件的处理逻辑中使用。有状态流处理应用程序更具挑战性,因为需要有效地分区状态,并在发生故障时可靠地恢复。您将在本章末了解更多关于有状态流处理、故障场景和一致性的内容。

数据摄入和数据出口

数据摄入和数据出口操作允许流处理器与外部系统通信。数据摄入是从外部源获取原始数据并转换为适合处理的格式的操作。实现数据摄入逻辑的操作符称为数据源。数据源可以从 TCP 套接字、文件、Kafka 主题或传感器数据接口摄入数据。数据出口是生成适合外部系统消费的输出的操作。执行数据出口的操作符称为数据接收器,例如文件、数据库、消息队列和监控接口。

转换操作

变换操作是单次遍历操作,它们独立处理每个事件。这些操作逐个事件消耗并应用一些转换到事件数据,产生一个新的输出流。转换逻辑可以集成在操作符中,也可以由用户定义的函数提供,如 图 2-4 所示。函数由应用程序员编写并实现自定义计算逻辑。

带有 UDF 的变换操作符

图 2-4. 带有将每个传入事件转换为更暗事件的函数的流处理操作符

操作符可以接受多个输入并产生多个输出流。它们还可以通过将流分割为多个流或将流合并为单一流来修改数据流图的结构。我们在 第五章 中讨论了 Flink 中所有可用操作符的语义。

滚动聚合

滚动聚合是一种不断更新的聚合,如求和、最小值和最大值,它持续更新每个输入事件。聚合操作是有状态的,并结合当前状态与传入事件以产生更新后的聚合值。需要注意的是,为了能够有效地将当前状态与事件结合并产生单个值,聚合函数必须是可结合和可交换的。否则,操作符将不得不存储完整的流历史。图 2-5 展示了一个滚动最小聚合。操作符保持当前的最小值并相应地更新每个传入事件的值。

一个滚动最小聚合

图 2-5. 滚动最小聚合操作

窗口操作

变换和滚动聚合处理一个事件来产生输出事件,并可能更新状态。然而,有些操作必须收集和缓存记录以计算它们的结果。例如,考虑流连接操作或整体聚合,如中位数函数。为了有效评估这些操作在无界流上的表现,你需要限制这些操作维护的数据量。在本节中,我们讨论提供此服务的窗口操作。

除了具有实际价值之外,窗口还使流上的语义查询变得有趣。您已经看到滚动聚合如何将整个流的历史编码为聚合值,并为每个事件提供低延迟的结果。这对某些应用程序很好,但如果您只对最新数据感兴趣怎么办?考虑一个应用程序,为司机提供实时交通信息,以便他们可以避开拥堵的路线。在这种情况下,您想知道最近几分钟内某个位置是否发生了事故。另一方面,仅知道曾经发生过的所有事故可能对此案例并不那么有趣。此外,通过将流历史减少到单个聚合,您失去了关于数据随时间变化的信息。例如,您可能想知道每 5 分钟有多少车辆通过一个十字路口。

窗口操作不断从无界事件流中创建有限的事件集合,称为桶,并允许我们对这些有限集合进行计算。通常根据数据属性或时间将事件分配到桶中。为了准确定义窗口操作符的语义,我们需要确定事件如何分配到桶中以及窗口多久产生一个结果。窗口的行为由一组策略定义。窗口策略决定了何时创建新的桶,哪些事件分配到哪些桶中,以及何时评估桶的内容。后者的决定基于触发条件。当触发条件满足时,桶的内容被发送到一个评估函数上,该函数对桶元素应用计算逻辑。评估函数可以是诸如求和或最小值的聚合,也可以是应用于收集到的桶元素的自定义操作。策略可以基于时间(例如,最近五秒内接收到的事件)、计数(例如,最近一百个事件)或数据属性。接下来,我们描述常见窗口类型的语义。

  • 滚动 窗口将事件分配到不重叠的固定大小的桶中。当窗口边界被越过时,所有事件都被发送到评估函数进行处理。基于计数的滚动窗口定义了在触发评估之前收集多少事件。图 2-6 展示了一个将输入流离散化为四个元素桶的基于计数的滚动窗口。基于时间的滚动窗口定义了一个时间间隔,在此期间事件被缓冲到桶中。图 2-7 展示了一个每 10 分钟将事件收集到桶中并触发计算的基于时间的滚动窗口。

    基于计数的滚动窗口。

    图 2-6. 基于计数的滚动窗口

    基于时间的滚动窗口。

    图 2-7. 基于时间的滚动窗口
  • 滑动 窗口将事件分配到重叠的固定大小的桶中。因此,一个事件可能属于多个桶。我们通过提供它们的长度和滑动来定义滑动窗口。滑动值定义了创建新桶的间隔。图 2-8 中的滑动计数窗口具有四个事件的长度和三个事件的滑动。

    滑动窗口。

    图 2-8. 具有四个事件长度和三个事件滑动的滑动计数窗口
  • 会话 窗口在常见的实际场景中非常有用,这些场景中既不能应用翻滚窗口也不能应用滑动窗口。考虑一个分析在线用户行为的应用程序。在这类应用中,我们希望将来自同一用户活动期间的事件分组在一起,形成一个会话。会话由一系列相邻时间内发生的事件以及随后的非活动期组成。例如,用户连续查看一系列新闻文章可以被视为一个会话。由于会话的长度事先未定义,而是取决于实际数据,因此在这种情况下无法应用翻滚和滑动窗口。相反,我们需要一个窗口操作,将属于同一会话的事件分配到同一个桶中。会话窗口根据会话间隔值将事件分组到会话中。图 2-9 显示了一个会话窗口。

    会话窗口。

    图 2-9. 会话窗口

到目前为止,您所看到的所有窗口类型都是在完整数据流上操作的窗口。但实际中,您可能希望将流分成多个逻辑流,并定义并行窗口。例如,如果您从不同传感器接收测量数据,可能希望在应用窗口计算之前按传感器 ID 对流进行分组。在并行窗口中,每个分区都独立地应用窗口策略,而不受其他分区的影响。图 2-10 展示了一个按事件颜色分区的长度为 2 的并行计数翻滚窗口。

并行翻滚窗口。

图 2-10. 长度为 2 的并行计数翻滚窗口

窗口操作与流处理中的两个主要概念密切相关:时间语义和状态管理。时间可能是流处理中最重要的方面。尽管低延迟是流处理的一大吸引特点,其真正的价值远不止于快速分析。现实世界中的系统、网络和通信通道远非完美,流数据通常会延迟或无序到达。在这种情况下,了解如何在准确和确定的条件下提供结果至关重要。更重要的是,能够处理实时产生的事件的流应用程序也应能以相同方式处理历史事件,从而实现离线分析甚至时间旅行分析。当然,如果系统不能在发生故障时保护状态,这一切都毫无意义。到目前为止,您所看到的所有窗口类型在生成结果之前都需要缓冲数据。事实上,即使是在流应用程序中计算任何有趣的事情,如简单的计数,也需要维护状态。考虑到流应用程序可能运行数天、数月甚至数年,您需要确保状态能够在发生故障时可靠地恢复,并且您的系统可以在出现故障时保证准确的结果。在本章的其余部分,我们将更深入地探讨数据流处理中关于时间和状态在故障条件下的保证概念。

时间语义

在本节中,我们介绍时间语义,并描述流处理中不同的时间概念。我们讨论了流处理器如何处理无序事件并提供准确的结果,以及如何使用流进行历史事件处理和时间旅行。

流处理中的一分钟到底意味着什么?

当处理连续到达的潜在无界事件流时,时间成为应用程序的核心方面。假设您希望连续计算结果,可能是每分钟一次。在我们的流应用程序背景下,“一分钟”到底意味着什么?

考虑一个分析用户玩在线手机游戏生成事件的程序。用户被组织成团队,应用程序收集团队的活动,并根据团队成员完成游戏目标的速度提供奖励,例如额外生命和升级。例如,如果一个团队的所有用户在一分钟内弹出了 500 个气泡,他们就可以升级。艾丽斯是一个热爱游戏的玩家,每天早晨在上班路上都会玩游戏。问题在于,艾丽斯住在柏林,每天上班都乘坐地铁。大家都知道柏林地铁的移动互联网连接非常差。考虑艾丽斯开始在手机连接到网络时弹出气泡并向分析应用程序发送事件的情况。然后突然地铁进入隧道,她的手机断网了。艾丽斯继续玩游戏,并且游戏事件被缓存在她的手机中。当地铁驶出隧道时,她重新联网,待处理的事件被发送到应用程序。应用程序应该怎么做?在这种情况下一分钟的含义是什么?是否包括艾丽斯离线时的时间? Figure 2-11 描述了这个问题。

在地铁上玩在线手机游戏。

图 2-11. 接收在线手机游戏事件的应用程序在地铁上玩会遇到网络连接中断的间隙,但事件被缓存在玩家手机上,并在恢复连接时传送。

在线游戏是一个简单的场景,展示了操作语义应该依赖事件实际发生的时间,而不是应用程序接收事件的时间。在移动游戏的情况下,后果可能很严重,例如艾丽斯和她的团队感到失望,从而再也不想玩了。但有更加时间关键的应用程序,我们需要保证其语义。如果我们只考虑在一分钟内接收到多少数据,结果将会因网络连接速度或处理速度的不同而有所不同。而真正定义一分钟内事件数量的是数据本身的时间。

在艾丽斯的游戏示例中,流应用程序可以使用两种不同的时间概念:处理时间或事件时间。我们将在接下来的章节中描述这两种概念。

处理时间

处理时间是流处理操作员所在机器的本地时钟时间。处理时间窗口包括在某个时间段内到达窗口操作员的所有事件,这段时间由其机器的墙上时钟测量而得。如图 Figure 2-12 所示,在艾丽斯的情况中,处理时间窗口会继续计算时间,即使她的手机断网了,因此在此期间不会计算她的游戏活动。

移动游戏应用程序中的处理时间窗口。

图 2-12. 在处理时间窗口中,即使爱丽丝的手机断开连接后,时间继续计数。

事件时间

事件时间是流中事件实际发生的时间。事件时间基于附加到流事件中的时间戳。时间戳通常存在于事件数据进入处理流水线之前(例如,事件创建时间)。图 2-13 显示,事件时间窗口可以正确地将事件放置在窗口中,反映了事情发生的真实情况,尽管一些事件延迟

一个移动游戏应用程序中的事件时间窗口。

图 2-13. 事件时间正确地将事件放置在窗口中,反映了事情发生的真实情况。

事件时间完全将处理速度与结果解耦。基于事件时间的操作是可预测的,其结果是确定性的。事件时间窗口计算将产生相同的结果,无论流处理的速度有多快或事件何时到达操作符。

处理延迟事件只是使用事件时间可以克服的挑战之一。普遍存在的乱序数据问题也可以通过它解决。考虑一下鲍勃,是在线移动游戏的另一名玩家,恰好与爱丽丝同在一辆火车上。鲍勃和爱丽丝玩同一个游戏,但使用不同的移动服务提供商。当爱丽丝的手机在隧道内失去连接时,鲍勃的手机仍然保持连接并将事件传递给游戏应用程序。

依靠事件时间,即使数据出现乱序,我们也能保证结果的正确性。更重要的是,当与可重放流相结合时,时间戳的确定性赋予你快进过去的能力。也就是说,你可以重放流并分析历史数据,就像事件是实时发生的一样。此外,你还可以将计算快进到当前时刻,使得一旦你的程序赶上当前事件,它就可以继续作为一个实时应用程序,完全使用相同的程序逻辑。

水印

到目前为止,我们在讨论事件时间窗口时,忽略了一个非常重要的方面:我们如何决定何时触发事件时间窗口?也就是说,在我们可以确定在某个时间点之前收到了所有事件之前,我们需要等待多长时间?我们甚至如何知道数据会延迟?考虑到分布式系统的不可预测性和外部组件可能引起的任意延迟,这些问题没有一种绝对正确的答案。在本节中,我们将看到如何使用水印来配置事件时间窗口的行为。

水印是全局进度指标,表示我们确信不会再有延迟事件到达的时间点。实质上,水印提供了一个逻辑时钟,告知系统当前事件时间。当操作器接收到时间为 T 的水印时,可以假设不会再接收到时间戳小于 T 的事件。水印对事件时间窗口和处理乱序事件的操作器至关重要。一旦接收到水印,操作器就会收到信号,表明已观察到某个时间间隔内的所有时间戳,并触发计算或排序接收到的事件。

水印提供了结果可信度和延迟之间的可配置权衡。渴望的水印确保低延迟但提供较低的可信度。在这种情况下,延迟事件可能会在水印之后到达,我们应提供一些代码来处理它们。另一方面,如果水印过于宽松,您将获得高可信度,但可能会不必要地增加处理延迟。

在许多实际应用中,系统无法完全确定水印。在移动游戏的例子中,几乎不可能知道用户可能断开连接多长时间;他们可能在隧道中、登机或永远不再玩游戏。无论水印是用户定义还是自动生成的,在存在滞后任务的情况下,在分布式系统中追踪全局进度可能会有问题。因此,仅仅依赖水印可能并不总是一个好主意。相反,流处理系统提供某些机制来处理可能在水印之后到达的事件非常关键。根据应用要求,您可能希望忽略这些事件、记录它们或使用它们来修正之前的结果。

处理时间与事件时间

此时,你可能会想知道,如果事件时间能解决所有问题,为什么我们还要费心处理时间呢?事实是,在某些情况下,处理时间确实很有用处。处理时间窗口引入了可能的最低延迟。由于不考虑延迟事件和乱序事件,窗口只需缓冲事件并在达到指定时间长度后立即触发计算。因此,对于速度比准确性更重要的应用,处理时间非常方便。另一个案例是,当你需要定期实时报告结果时,与其准确性无关。一个示例应用是实时监控仪表板,在接收到事件后显示事件聚合数据。最后,处理时间窗口为流本身提供了忠实的表示,这可能对某些用例很重要。例如,你可能有兴趣观察流并计算每秒事件数量以检测故障。总结一下,处理时间提供低延迟,但结果取决于处理速度且不确定。另一方面,事件时间保证确定性结果,并允许处理延迟或乱序事件。

状态和一致性模型

现在我们转向流处理的另一个极其重要的方面——状态。状态在数据处理中无处不在,它是任何非平凡计算所必需的。为了产生结果,函数会在一段时间或事件数量内积累状态(例如计算聚合或检测模式)。有状态的操作符使用传入的事件和内部状态来计算它们的输出。例如,考虑一个滚动聚合操作符,它输出到目前为止所有事件的当前总和。该操作符将总和的当前值作为其内部状态,并在接收到新事件时更新它。类似地,考虑一个操作符,在检测到“高温”事件后,如果在 10 分钟内再次检测到“烟雾”事件,则发出警报。该操作符需要将“高温”事件存储在其内部状态中,直到它看到“烟雾”事件或者 10 分钟时间段到期为止。

如果考虑使用批处理系统来分析无界数据集的情况,则状态的重要性变得更加明显。在现代流处理器崛起之前,处理无界数据的常见方法是在批处理系统上重复调度小批量传入事件的作业。作业完成后,结果写入持久存储,并且所有操作符状态都丢失。一旦作业计划在下一批次上执行,它就无法访问前一个作业的状态。通常通过将状态管理委托给外部系统(如数据库)来解决此问题。相比之下,在持续运行的流作业中,状态跨事件是持久的,并且可以在编程模型中将其公开为一流对象。可以说,即使在流状态中使用外部系统管理,这种设计选择可能会引入额外的延迟。

由于流式操作符处理潜在的无界数据,必须小心,以防止内部状态无限增长。为了限制状态大小,操作符通常会维护某种事件概要或摘要。这样的摘要可以是计数、求和、迄今为止看到的事件的样本、窗口缓冲区或保留某个应用程序感兴趣属性的自定义数据结构。

正如您可以想象的那样,支持有状态操作符存在一些实现挑战:

状态管理

系统需要高效地管理状态,并确保免受并发更新的影响。

状态分区

并行化变得复杂,因为结果取决于状态和传入事件。幸运的是,在许多情况下,您可以按键对状态进行分区,并独立管理每个分区的状态。例如,如果您正在处理一组传感器的测量流,您可以使用分区操作状态来独立维护每个传感器的状态。

状态恢复

有状态操作符的第三个和最大的挑战是确保状态可以在故障的情况下恢复,并且结果将正确。

在接下来的部分,我们将详细讨论任务失败和结果保证。

任务失败

流作业中的操作符状态非常有价值,并且应当受到故障保护。如果在故障期间丢失状态,恢复后的结果将不正确。流作业长时间运行,因此状态可能会在几天甚至几个月内收集。在故障情况下,重新处理所有输入以重现丢失的状态将非常昂贵且耗时。

在本章开头,您看到如何将流处理程序建模为数据流图。在执行之前,这些被转化为由连接的并行任务组成的物理数据流图,每个任务运行某些操作逻辑,消耗输入流并为其他任务生成输出流。典型的实际设置可以在许多物理机器上并行运行数百个这样的任务。在长时间运行的流作业中,每个任务可以随时失败。如何确保这些故障被透明处理,以便您的流作业可以继续运行?事实上,您希望您的流处理器不仅在任务失败的情况下继续处理,而且还提供有关结果和操作状态的正确性保证。我们在本节讨论所有这些问题。

什么是任务失败?

对于输入流中的每个事件,任务是执行以下步骤的处理步骤:(1)接收事件,将其存储在本地缓冲区中;(2)可能更新内部状态;和(3)生成一个输出记录。在任何这些步骤中都可能发生故障,系统必须在故障情况下明确定义其行为。如果任务在第一步失败,事件会丢失吗?如果在更新内部状态后失败,系统会在恢复后再次更新它吗?在这些情况下,输出是否是确定性的?

注意

我们假设网络连接是可靠的,并且没有记录会被丢弃或复制,所有事件最终按照 FIFO 顺序传递到其目的地。请注意,Flink 使用 TCP 连接,因此可以保证这些要求。我们还假设存在完美的故障检测器,并且没有任务会故意恶意行事,这意味着所有未失败的任务都遵循上述步骤。

在批处理场景中,所有这些问题都有答案,因为可以简单地从头重新启动批处理作业。因此,不会丢失任何事件,并且状态完全是从头构建起来的。然而,在流处理世界中,处理故障并非一个简单的问题。流处理系统通过提供结果保证来定义其在故障情况下的行为。接下来,我们将审查现代流处理器提供的保证类型以及系统实现这些保证所采用的一些机制。

结果保证

在我们描述不同类型的保证之前,我们需要澄清一些经常在讨论流处理器中任务失败时引起混淆的要点。在本章的其余部分中,当我们谈论“结果保证”时,我们指的是流处理器内部状态的一致性。也就是说,我们关心的是应用代码在从故障中恢复后看到的状态值的一致性。请注意,保证应用程序状态的一致性并不等同于保证其输出的一致性。一旦数据已经被发送到接收器,除非接收系统支持事务,否则很难保证结果的正确性。

最多一次

当任务失败时,最简单的做法是不采取任何措施来恢复丢失的状态,并重播丢失的事件。最多一次是保证每个事件最多处理一次的特例。换句话说,事件可以简单地被丢弃,而且没有任何措施来确保结果的正确性。这种保证也被称为“无保证”,因为即使是每个事件都被丢弃的系统也可以提供此保证。完全没有保证听起来像是一个糟糕的主意,但如果您可以接受近似结果,并且您所关心的只是提供尽可能低的延迟,那么这可能是可以接受的。

至少一次

在大多数实际应用程序中,事件不应丢失是期望。这种保证称为至少一次,意味着所有事件都将被处理,并且可能会有一些事件被处理多次。如果应用程序的正确性仅依赖于信息的完整性,则重复处理可能是可以接受的。例如,确定特定事件是否发生在输入流中可以通过至少一次保证正确实现。在最坏的情况下,您可能会定位到多次事件。然而,在至少一次保证下,计算特定事件在输入流中发生的次数可能会返回错误的结果。

为了确保至少一次的结果正确性,您需要一种重播事件的方法——无论是从源头还是从某个缓冲区。持久性事件日志将所有事件写入持久存储,以便在任务失败时可以重播。实现等效功能的另一种方法是使用记录确认。该方法将每个事件存储在缓冲区中,直到所有流水线中的任务都确认了其处理,此时可以丢弃该事件。

至少一次

精确一次是最严格的保证,也是难以实现的。精确一次意味着不仅不会丢失事件,而且内部状态的更新将仅应用一次于每个事件。实质上,精确一次保证意味着我们的应用将提供正确的结果,就像从未发生过故障一样。

提供精确一次保证需要至少一次保证,因此再次需要数据重放机制。此外,流处理器需要确保内部状态的一致性。也就是说,在恢复之后,它应该知道事件更新是否已经反映在状态上。事务更新是实现此结果的一种方式,但可能会带来相当大的性能开销。相反,Flink 使用轻量级的快照机制来实现精确一次的结果保证。我们在“检查点、保存点和状态恢复”中讨论了 Flink 的容错算法。

端到端精确一次

到目前为止您已经看到的保证类型是指由流处理器管理的应用程序状态。然而,在真实的流处理应用程序中,除了流处理器之外,至少还会有一个源和一个汇。端到端保证指的是整个数据处理流水线的结果正确性。每个组件都提供自己的保证,整个流水线的端到端保证将是每个组件中最弱的保证。重要的是要注意,有时候您可以通过更弱的保证获得更强的语义。一个常见的情况是,当任务执行幂等操作时,比如最大值或最小值。在这种情况下,您可以通过至少一次保证实现精确一次语义。

总结

在本章中,您学习了数据流处理的基础知识。我们研究了数据流编程模型,并学习了如何将流处理应用程序表达为分布式数据流图。接下来,您学习了在并行处理无限流时的要求,并了解了流应用程序中延迟和吞吐量的重要性。我们介绍了基本的流操作,以及如何使用窗口在无界输入数据上计算有意义的结果。您学习了流处理中时间的含义,并比较了事件时间和处理时间的概念。最后,我们学习了在流处理应用程序中状态的重要性,以及如何保护它免受故障并保证正确的结果。

到目前为止,我们已经独立于 Apache Flink 考虑了流式概念。在本书的其余部分,我们将看到 Flink 如何实际实现这些概念,以及如何使用其 DataStream API 编写应用程序,以利用我们迄今为止介绍的所有功能。

第三章:Apache Flink 的架构

第二章 讨论了分布式流处理的重要概念,例如并行化、时间和状态。在本章中,我们对 Flink 的架构进行了高层次介绍,并描述了它如何解决我们早前讨论过的流处理方面的问题。特别是,我们解释了 Flink 的分布式架构,展示了它在流应用程序中如何处理时间和状态,并讨论了其容错机制。本章提供了成功实施和运行 Apache Flink 高级流处理应用程序所需的相关背景信息。它将帮助您理解 Flink 的内部机制,并推断流应用程序的性能和行为。

系统架构

Flink 是用于有状态并行数据流处理的分布式系统。一个 Flink 设置由多个进程组成,通常分布在多台机器上。分布式系统需要解决的常见挑战包括在集群中分配和管理计算资源、进程协调、持久且高可用的数据存储以及故障恢复。

Flink 并未自行实现所有这些功能。相反,它专注于其核心功能——分布式数据流处理,并利用现有的集群基础设施和服务。Flink 与集群资源管理器(如 Apache Mesos、YARN 和 Kubernetes)集成良好,但也可以配置为独立集群运行。Flink 不提供持久的分布式存储,而是利用诸如 HDFS 或对象存储(如 S3)的分布式文件系统。在高可用设置中的领导者选举方面,Flink 依赖于 Apache ZooKeeper。

在本节中,我们描述了 Flink 设置的不同组件以及它们如何相互作用来执行应用程序。我们讨论了部署 Flink 应用程序的两种不同方式,以及每种方式如何分发和执行任务。最后,我们解释了 Flink 的高可用模式的工作原理。

一个 Flink 设置由四个不同的组件组成,它们共同工作以执行流应用程序。这些组件分别是 JobManager、ResourceManager、TaskManager 和 Dispatcher。由于 Flink 是用 Java 和 Scala 实现的,所有组件都在 Java 虚拟机(JVM)上运行。每个组件有以下责任:

  • JobManager 是控制单个应用程序执行的主进程 — 每个应用程序由不同的 JobManager 控制。JobManager 接收应用程序进行执行。应用程序包括所谓的 JobGraph,即逻辑数据流图(参见 “数据流编程简介”),以及一个捆绑了所有所需类、库和其他资源的 JAR 文件。JobManager 将 JobGraph 转换为称为 ExecutionGraph 的物理数据流图,其中包含可以并行执行的任务。JobManager 从 ResourceManager 请求必要的资源(TaskManager 槽位)来执行任务。一旦它收到足够的 TaskManager 槽位,就会将 ExecutionGraph 的任务分配给执行它们的 TaskManagers。在执行过程中,JobManager 负责所有需要中心协调的操作,比如协调检查点(参见 “检查点、保存点和状态恢复”)。

  • Flink 提供了多个 ResourceManager 用于不同的环境和资源提供程序,如 YARN、Mesos、Kubernetes 和独立部署。ResourceManager 负责管理 TaskManager 槽位,即 Flink 处理资源的单位。当一个 JobManager 请求 TaskManager 槽位时,ResourceManager 指示具有空闲槽位的 TaskManager 将其提供给 JobManager。如果 ResourceManager 没有足够的槽位来满足 JobManager 的请求,则 ResourceManager 可以与资源提供程序通信,以提供容器,在其中启动 TaskManager 进程。ResourceManager 还负责终止空闲 TaskManager 以释放计算资源。

  • TaskManager 是 Flink 的工作进程。通常,在 Flink 设置中会运行多个 TaskManager。每个 TaskManager 提供一定数量的槽位。槽位的数量限制了 TaskManager 可以执行的任务数。启动后,TaskManager 将其槽位注册给 ResourceManager。当 ResourceManager 指示时,TaskManager 将其一个或多个槽位提供给 JobManager。然后,JobManager 可以将任务分配给这些槽位以执行它们。在执行过程中,TaskManager 与运行同一应用程序任务的其他 TaskManagers 交换数据。任务的执行和槽位的概念在 “任务执行” 中讨论。

  • Dispatcher 负责跨作业执行并提供 REST 接口来提交应用程序以进行执行。一旦应用程序提交执行,它启动一个 JobManager 并将应用程序移交给它。REST 接口使得调度程序能够作为集群的 HTTP 入口点提供服务,尤其是在防火墙后面的集群中。调度程序还运行一个 web 仪表板,用于提供有关作业执行的信息。根据应用程序的执行方式(详见 “应用部署”),可能不需要调度程序。

图 3-1 展示了当提交应用程序进行执行时,Flink 组件如何相互交互。

应用提交和组件交互

图 3-1. 应用提交和组件交互
注意

图 3-1 是一个高级草图,用于可视化应用程序组件的责任和交互。根据环境(如 YARN、Mesos、Kubernetes、独立集群),某些步骤可以省略,或者组件可以在同一个 JVM 进程中运行。例如,在独立设置中——没有资源提供者的设置中——ResourceManager 只能分发可用 TaskManager 的插槽,并且不能自行启动新的 TaskManager。在 “部署模式” 中,我们将讨论如何为不同环境设置和配置 Flink。

应用部署

Flink 应用程序可以以两种不同的方式部署。

框架样式

在这种模式下,Flink 应用程序被打包成 JAR 文件,并由客户端提交给运行中的服务。该服务可以是 Flink Dispatcher、Flink JobManager 或 YARN 的 ResourceManager。无论哪种情况,都有一个运行中的服务接受 Flink 应用程序并确保其执行。如果应用程序提交给了 JobManager,则立即开始执行应用程序。如果应用程序提交给了 Dispatcher 或 YARN 的 ResourceManager,则会启动一个 JobManager 并将应用程序移交给它,然后 JobManager 开始执行应用程序。

库样式

在这种模式下,Flink 应用程序打包在特定于应用程序的容器映像中,例如 Docker 映像。该映像还包括运行 JobManager 和 ResourceManager 的代码。当从映像启动容器时,它会自动启动 ResourceManager 和 JobManager,并提交打包的作业以供执行。第二个与作业无关的映像用于部署 TaskManager 容器。从该映像启动的容器会自动启动 TaskManager,并连接到 ResourceManager 并注册其插槽。通常,像 Kubernetes 这样的外部资源管理器负责启动映像,并确保在故障时重新启动容器。

框架样式遵循通过客户端将应用程序(或查询)提交给正在运行的服务的传统方法。在库样式中,没有 Flink 服务。相反,Flink 与应用程序捆绑在一个容器镜像中作为库一起提供。这种部署模式在微服务架构中很常见。我们在“运行和管理流处理应用程序”中更详细地讨论了应用程序部署的主题。

任务执行

一个 TaskManager 可以同时执行多个任务。这些任务可以是同一个运算符的子任务(数据并行)、不同运算符的任务(任务并行)甚至来自不同应用程序的任务(作业并行)。TaskManager 提供一定数量的处理槽位来控制其能够并发执行的任务数量。每个处理槽位可以执行一个应用程序的一个切片——每个运算符的一个并行任务。图 3-2 展示了 TaskManager、槽位、任务和运算符之间的关系。

运算符、任务和处理槽位

图 3-2. 运算符、任务和处理槽位

在图 3-2 的左侧,您可以看到一个作业图(JobGraph)——应用程序的非并行表示,由五个运算符组成。运算符 A 和 C 是源,运算符 E 是汇。运算符 C 和 E 的并行度为二。其他运算符的并行度为四。由于最大运算符并行度为四,该应用程序需要至少四个可用的处理槽位才能执行。考虑到每个具有两个处理槽位的两个 TaskManager,此需求得到满足。JobManager 将作业图拓展为执行图,并将任务分配给这四个可用槽位。具有并行度为四的运算符的任务被分配到每个槽位上。运算符 C 和 E 的两个任务分别分配到槽位 1.1 和 2.1,以及槽位 1.2 和 2.2。将任务作为切片调度到槽位的优势在于,许多任务被放置在同一个 TaskManager 上,这意味着它们可以在同一进程内有效地交换数据,而无需访问网络。然而,过多的共位任务也可能会使 TaskManager 过载,并导致性能不佳。在“控制任务调度”中,我们讨论了如何控制任务的调度。

TaskManager 在同一 JVM 进程中多线程执行其任务。线程比单独的进程更轻量,并具有较低的通信成本,但不能严格隔离任务。因此,单个表现不佳的任务可以终止整个 TaskManager 进程及其上运行的所有任务。通过配置每个 TaskManager 仅有一个槽位,可以在 TaskManagers 之间隔离应用程序。通过在 TaskManager 内利用线程并在每台主机上部署多个 TaskManager 进程,Flink 在部署应用程序时提供了灵活性来权衡性能和资源隔离。我们将在第九章详细讨论 Flink 集群的配置和设置。

Highly Available Setup

流处理应用程序通常设计为 24/7 运行。因此,即使涉及的进程失败,其执行也不应停止。为从故障中恢复,系统首先需要重新启动失败的进程,其次重新启动应用程序并恢复其状态。在本节中,您将了解 Flink 如何重新启动失败的进程。应用程序状态的恢复描述在“从一致检查点恢复”中。

TaskManager failures

如前所述,Flink 要求足够数量的处理槽位以执行应用程序的所有任务。假设有四个 TaskManager,每个提供两个槽位的 Flink 设置,流处理应用程序的最大并行性为八。如果其中一个 TaskManager 失败,则可用槽位数量降至六。在这种情况下,JobManager 将要求 ResourceManager 提供更多处理槽位。如果这不可能,例如因为应用程序在独立集群中运行,JobManager 将无法重新启动应用程序,直到足够的槽位变得可用。应用程序的重启策略决定了 JobManager 重新启动应用程序的频率以及重启尝试之间的等待时间。¹

JobManager 故障

比 TaskManager 故障更具挑战性的问题是 JobManager 故障。JobManager 控制流处理应用程序的执行,并保留有关其执行的元数据,如指向完成检查点的指针。如果负责的 JobManager 进程消失,流处理应用程序将无法继续处理。这使得 JobManager 成为 Flink 中应用程序的单点故障。为解决此问题,Flink 支持高可用模式,该模式在原始 JobManager 消失时将作业的责任和元数据迁移到另一个 JobManager。

Flink 的高可用模式基于Apache ZooKeeper,这是一个用于需要协调和共识的分布式服务系统。Flink 使用 ZooKeeper 进行 Leader 选举,并作为高可用和持久化的数据存储。在高可用模式下,JobManager 将 JobGraph 和所有必需的元数据(如应用程序的 JAR 文件)写入远程持久存储系统。此外,JobManager 将存储位置的指针写入 ZooKeeper 的数据存储。在应用程序执行期间,JobManager 接收各个任务检查点的状态句柄(存储位置)。完成检查点时(当所有任务成功将其状态写入远程存储时),JobManager 将状态句柄写入远程存储,并将指向此位置的指针写入 ZooKeeper。因此,从 JobManager 故障中恢复所需的所有数据都存储在远程存储中,而 ZooKeeper 则保存了存储位置的指针。图 3-3(#fig_ha-setup)说明了这一设计。

高可用 Flink 设置

当 JobManager 失败时,其应用程序的所有任务都会自动取消。接管失败主节点工作的新 JobManager 执行以下步骤:

  1. 它从 ZooKeeper 请求存储位置,以获取 JobGraph、JAR 文件以及应用程序上次检查点的状态句柄,这些都来自远程存储。

  2. 它从 ResourceManager 请求处理插槽,以继续执行应用程序。

  3. 它重新启动应用程序,并将其所有任务的状态重置为最后完成的检查点状态。

在容器环境(如 Kubernetes)中以库部署方式运行应用程序时,通常由容器编排服务自动重新启动失败的 JobManager 或 TaskManager 容器。在 YARN 或 Mesos 上运行时,Flink 的剩余进程会触发 JobManager 或 TaskManager 进程的重启。在独立集群中运行时,Flink 不提供重新启动失败进程的工具。因此,在此情况下运行备用 JobManagers 和 TaskManagers 以接管失败进程的工作可能非常有用。我们将在“高可用设置”中讨论高可用 Flink 设置的配置。

Flink 中的数据传输

运行中应用程序的任务不断交换数据。TaskManagers 负责将数据从发送任务传输到接收任务。TaskManager 的网络组件在将记录发送之前将其收集到缓冲区中,即记录不是逐个发送而是批量发送到缓冲区。这种技术对有效利用网络资源和实现高吞吐量至关重要。该机制类似于网络或磁盘 I/O 协议中使用的缓冲技术。

注意

请注意,将记录打包到缓冲区中意味着 Flink 的处理模型基于微批处理。

每个 TaskManager 都有一个网络缓冲池(默认大小为 32 KB)用于发送和接收数据。如果发送任务和接收任务在不同的 TaskManager 进程中运行,则它们通过操作系统的网络堆栈进行通信。流处理应用程序需要以流水线方式交换数据,每对 TaskManager 之间维护一个永久的 TCP 连接以交换数据。² 使用 shuffle 连接模式时,每个发送任务需要能够向每个接收任务发送数据。每个 TaskManager 需要为其任何任务需要向其发送数据的接收任务准备一个专用的网络缓冲区。图 3-4 展示了这种架构。

TaskManagers 之间的数据传输

图 3-4. TaskManagers 之间的数据传输

如图 3-4 所示,每个发送任务至少需要四个网络缓冲区来向每个接收任务发送数据,每个接收任务需要至少四个缓冲区来接收数据。需要发送到其他 TaskManager 的缓冲区在同一网络连接上进行复用。为了实现平稳的流水线数据交换,一个 TaskManager 必须能够提供足够的缓冲区以同时服务所有的出站和入站连接。对于 shuffle 或 broadcast 连接,每个发送任务都需要为每个接收任务准备一个缓冲区;所需的缓冲区数量与涉及的运算符的任务数呈二次关系。对于小型到中型的设置,Flink 默认的网络缓冲区配置已经足够。对于更大的设置,需要根据“主内存和网络缓冲区”中描述的内容进行配置调整。

当发送任务和接收任务在同一个 TaskManager 进程中运行时,发送任务将输出的记录序列化为一个字节缓冲区,并在填充完毕后将缓冲区放入队列。接收任务从队列中获取缓冲区并反序列化传入的记录。因此,在同一 TaskManager 上运行的任务之间的数据传输不会导致网络通信。

Flink 提供了不同的技术来减少任务之间的通信成本。在接下来的章节中,我们将简要讨论基于信用的流量控制和任务链。

基于信用的流量控制

通过网络连接发送单独的记录效率低下且会导致显著的开销。需要缓冲以充分利用网络连接的带宽。在流处理的背景下,缓冲的一个缺点是增加了延迟,因为记录被收集到缓冲区而不是立即发送。

Flink 实现了一种基于信用的流量控制机制,工作原理如下。接收任务向发送任务授予一些信用,即为接收其数据保留的网络缓冲区数量。一旦发送方接收到信用通知,它就会发送被授予的缓冲区数量以及其积压大小——填充并准备发送的网络缓冲区数量。接收方使用保留的缓冲区处理接收到的数据,并利用发送方的积压大小为其连接的所有发送方优先分配下一次信用授予。

基于信用的流量控制通过在接收方有足够资源接受数据时发送方可以立即发送数据来减少延迟。此外,它还是分布网络资源的有效机制,特别是在数据分布不均匀的情况下,因为信用是基于发送方积压大小授予的。因此,基于信用的流量控制对于 Flink 实现高吞吐量和低延迟至关重要。

任务链条

Flink 提供了一种优化技术称为任务链条,在某些条件下减少了本地通信的开销。为了满足任务链条的要求,两个或多个操作符必须配置相同的并行度,并通过本地前向通道连接。如图 图 3-5 所示的操作流水线满足这些要求。它由三个操作符组成,所有操作符都配置为并行度为二,并通过本地前向连接连接起来。

符合任务链条要求的操作流水线

图 3-5. 符合任务链条要求的操作流水线

图 3-6 描述了如何使用任务链条执行流水线。操作符的函数被融合成单个任务,由单个线程执行。一个函数产生的记录通过简单的方法调用传递给下一个函数。因此,在函数间传递记录几乎没有序列化和通信成本。

在单线程中使用融合函数执行链式任务

图 3-6. 使用单线程和方法调用传递数据执行链式任务

任务链条可以显著减少本地任务之间的通信成本,但在某些情况下,执行不使用链条的流水线也是有意义的。例如,可以合理地打破长的任务链或将链分成两个任务,以便将昂贵的函数安排到不同的插槽中执行。图 3-7 展示了同一流水线在没有任务链条的情况下的执行方式。所有函数都由各自运行在专用线程的单独任务评估。

在专用线程中执行非链式任务,通过缓冲通道和序列化传输数据

图 3-7. 使用专用线程进行非链式任务执行,数据通过缓冲通道和序列化进行传输。

Flink 默认启用任务链。在 “控制任务链” 中,我们展示了如何为应用程序禁用任务链以及如何控制各个操作符的链式行为。

事件时间处理

在 “时间语义” 中,我们强调了时间语义对流处理应用程序的重要性,并解释了处理时间和事件时间之间的差异。处理时间易于理解,因为它基于处理机器的本地时间,但它产生的结果有时是任意的、不一致的和不可重现的。相比之下,事件时间语义产生可重现且一致的结果,这对许多流处理用例是硬性要求。然而,与处理时间语义的应用程序相比,事件时间应用程序需要额外的配置。此外,支持事件时间的流处理器的内部比纯粹基于处理时间的系统更为复杂。

Flink 提供直观且易于使用的基本事件时间处理操作原语,同时还提供了表达丰富的 API,用于实现更高级的基于事件时间的应用程序,并支持自定义运算符。对于这类高级应用程序,了解 Flink 内部的时间处理机制通常是有帮助的,有时甚至是必需的。上一章介绍了 Flink 利用的两个概念,以提供事件时间语义:记录时间戳和水印。接下来我们将描述 Flink 如何在内部实现和处理时间戳和水印,以支持具有事件时间语义的流应用程序。

时间戳

所有由 Flink 事件时间流处理应用程序处理的记录都必须附带时间戳。时间戳将记录与特定时刻相关联,通常是表示记录事件发生时的时间点。但是,应用程序可以自由选择时间戳的含义,只要流记录的时间戳大致按照流的推进而升序即可。正如在 “时间语义” 中所见,基本上所有真实世界的用例中都存在一定程度的时间戳无序性。

当 Flink 在事件时间模式下处理数据流时,根据记录的时间戳评估基于时间的运算符。例如,时间窗口运算符根据其关联时间戳将记录分配到窗口中。Flink 将时间戳编码为 16 字节的 Long 值,并将其作为记录的元数据附加。其内置运算符将 Long 值解释为具有毫秒精度的 Unix 时间戳——自 1970-01-01-00:00:00.000 以来的毫秒数。然而,自定义运算符可以有自己的解释,并可能调整精度到微秒级别。

水印

除了记录时间戳外,Flink 事件时间应用程序还必须提供水印。水印用于在事件时间应用程序中的每个任务中推导当前事件时间。基于时间的运算符使用此时间触发计算并推进进度。例如,时间窗口任务在任务事件时间超过窗口结束边界时完成窗口计算并发出结果。

在 Flink 中,水印实现为包含 Long 值时间戳的特殊记录。水印以带有注释时间戳的常规记录流动,如图 3-8 所示。

带有时间戳记录和水印的数据流

图 3-8. 带有时间戳记录和水印的数据流

水印具有两个基本属性:

  1. 为了确保任务的事件时间时钟进展而非后退,水印必须单调递增。

  2. 它们与记录时间戳相关。具有时间戳 T 的水印表示所有后续记录的时间戳应 > T。

第二个属性用于处理具有无序记录时间戳的流,例如图 3-8 中时间戳为 3 和 5 的记录。基于时间的运算符的任务收集和处理具有可能无序时间戳的记录,并在其事件时间时钟(由接收到的水印推进)指示不再期望具有相关时间戳的记录时,完成计算。当任务接收到违反水印属性并且时间戳小于先前接收的水印的记录时,可能是其所属计算已经完成。这些记录称为延迟记录。Flink 提供了处理延迟记录的不同方法,详见“处理延迟数据”。

水印的一个有趣特性是它们允许应用程序控制结果的完整性和延迟。非常紧密的水印——接近记录时间戳——会导致低处理延迟,因为任务只会在更多记录到达之前短暂等待,然后完成计算。与此同时,结果的完整性可能会受到影响,因为相关记录可能不会包含在结果中,并被视为迟到的记录。相反,非常保守的水印会增加处理延迟,但提高结果的完整性。

水印传播与事件时间

在本节中,我们讨论操作符如何处理水印。Flink 将水印实现为操作符任务接收和发出的特殊记录。任务具有维护计时器的内部时间服务,并在接收水印时激活。任务可以在计时器服务中注册定时器,以在未来特定时间点执行计算。例如,窗口操作符为每个活动窗口注册一个定时器,当事件时间超过窗口结束时间时,清理窗口的状态。

当任务接收到水印时,会执行以下操作:

  1. 任务根据水印的时间戳更新其内部事件时间时钟。

  2. 任务的时间服务识别所有时间小于更新的事件时间的定时器。对于每个过期的定时器,任务调用一个回调函数,可以执行计算并发出记录。

  3. 任务发出带有更新的事件时间的水印。

注意

Flink 通过 DataStream API 限制了对时间戳或水印的访问。函数无法读取或修改记录的时间戳和水印,除了处理函数可以读取当前处理记录的时间戳,请求操作符的当前事件时间,并注册定时器。³ 没有一个函数公开的 API 可以设置发出记录的时间戳,操作任务的事件时间时钟,或者发出水印。相反,基于时间的 DataStream 操作符任务配置发出记录的时间戳,以确保它们与发出的水印正确对齐。例如,时间窗口操作符任务将窗口结束时间作为时间戳附加到窗口计算发出的所有记录之前,然后发出触发窗口计算的时间戳的水印。

现在让我们更详细地解释一个任务在接收新水印时如何发出水印并更新其事件时间时钟。正如您在“数据并行和任务并行”中看到的那样,Flink 将数据流拆分为分区,并通过单独的操作器任务并行处理每个分区。每个分区都是时间戳记录和水印的流。根据操作器与其前驱或后续操作器的连接方式,其任务可以从一个或多个输入分区接收记录和水印,并向一个或多个输出分区发出记录和水印。接下来,我们详细描述一个任务如何向多个输出任务发出水印,并从其接收的水印推进其事件时间时钟。

每个任务为每个输入分区维护一个分区水印。当它从分区接收到一个水印时,它会将相应的分区水印更新为接收到的值和当前值中的最大值。随后,任务将其事件时间时钟更新为所有分区水印的最小值。如果事件时间时钟前进,任务将处理所有触发的定时器,并最终通过向所有连接的输出分区发出相应的水印来向所有下游任务广播其新事件时间。

图 3-9 展示了一个具有四个输入分区和三个输出分区的任务如何接收水印,更新其分区水印和事件时间时钟,并发出水印。

使用水印更新任务的事件时间

图 3-9. 使用水印更新任务的事件时间

具有两个或更多输入流的操作符的任务(例如 UnionCoFlatMap,参见“多流转换”)也将其事件时间时钟计算为所有分区水印的最小值——它们不区分不同输入流的分区水印。因此,两个输入的记录基于相同的事件时间时钟进行处理。如果应用程序的各个输入流的事件时间不对齐,这种行为可能会导致问题。

Flink 的水印处理和传播算法确保操作任务发出正确对齐的时间戳记录和水印。但是,它依赖于所有分区持续提供递增的水印。一旦一个分区不再提升其水印或完全空闲且不发送任何记录或水印,任务的事件时间时钟将不会前进,并且任务的定时器不会触发。如果一个任务不定期地从所有输入任务接收新的水印,这种情况对于依赖于前进时钟执行计算和清理状态的基于时间的操作符可能会有问题。因此,如果任务不定期地从所有输入任务接收新的水印,时间基准操作符的处理延迟和状态大小可能会显著增加。

当两个输入流的水印显著分歧时,操作符也会出现类似的效果。具有两个输入流的任务的事件时间时钟将对应于较慢流的水印,通常情况下,较快流的记录或中间结果会在状态中缓冲,直到事件时间时钟允许处理它们。

时间戳分配和水印生成

到目前为止,我们已经解释了时间戳和水印是什么,以及它们如何在 Flink 中内部处理。然而,我们还没有讨论它们的来源。时间戳和水印通常在流被流处理应用摄取时分配和生成。由于时间戳的选择是特定于应用程序的,并且水印取决于流的时间戳和特性,因此应用程序必须显式分配时间戳并生成水印。Flink DataStream 应用程序可以通过以下三种方式为流分配时间戳并生成水印:

    • 在源处:时间戳和水印可以由 SourceFunction 在流摄入到应用程序时分配和生成。源函数会发出一系列记录流。记录可以与相关的时间戳一起发出,并且水印可以作为特殊记录在任何时间点发出。如果源函数(暂时)不再发出水印,它可以声明自己处于空闲状态。Flink 将排除由空闲源函数产生的流分区,不参与后续操作符的水印计算。源函数的空闲机制可用于解决前面讨论过的不推进水印的问题。源函数将在“实现自定义源函数”中详细讨论。
    • 周期性分配器:DataStream API 提供了一个名为 AssignerWithPeriodicWatermarks 的用户定义函数,它从每个记录中提取时间戳,并定期查询当前水印。提取的时间戳被分配给相应的记录,并将查询的水印摄取到流中。此功能将在“分配时间戳和生成水印”中讨论。
    • 中断分配器:AssignerWithPunctuatedWatermarks 是另一个用户定义的函数,它从每个记录中提取时间戳。它可用于生成编码在特殊输入记录中的水印。与 AssignerWithPeriodicWatermarks 函数相反,此函数可以从每个记录中提取水印,但不需要这样做。我们将在“分配时间戳和生成水印”中详细讨论此函数。

用户定义的时间戳分配函数通常尽可能接近源操作符应用,因为在操作符处理记录和它们的时间戳之后,理清记录的顺序和时间戳可能非常困难。这也是在流式应用程序中中途覆盖现有时间戳和水印不是一个好主意的原因,尽管这可以通过用户定义的函数实现。

状态管理

在第二章中,我们指出大多数流处理应用程序是有状态的。许多操作符持续读取和更新某种状态,例如在窗口中收集的记录、输入源的读取位置或自定义的应用程序特定操作符状态,如机器学习模型。Flink 将所有状态(无论是内置的还是用户定义的操作符)都视为相同。在本节中,我们讨论 Flink 支持的不同类型的状态。我们解释状态如何由状态后端存储和维护,以及如何通过重新分配状态来扩展具有状态的应用程序。

一般来说,由任务维护并用于计算函数结果的所有数据都属于任务的状态。您可以将状态视为任务业务逻辑访问的本地或实例变量。图 3-10(#fig_stateful-task)展示了任务与其状态之间的典型交互。

一个有状态的流处理任务

图 3-10. 一个有状态的流处理任务

任务接收一些输入数据。在处理数据时,任务可以读取和更新其状态,并根据其输入数据和状态计算其结果。一个简单的例子是一个任务,连续计算其接收的记录数。当任务接收到新的记录时,它访问状态以获取当前计数,增加计数,更新状态,并发出新的计数。

从状态读取和写入的应用逻辑通常很简单。但是,高效和可靠地管理状态更具挑战性。这包括处理可能超过内存的非常大的状态,并确保在发生故障时不会丢失任何状态。Flink 处理所有与状态一致性、故障处理以及高效存储和访问相关的问题,使开发人员可以专注于其应用程序的逻辑。

在 Flink 中,状态始终与特定的操作符相关联。为了让 Flink 运行时了解操作符的状态,操作符需要注册其状态。有两种类型的状态,操作符状态键控状态,它们可以从不同的作用域访问,并在接下来的部分中讨论。

操作符状态

操作符状态作用域限定在操作符任务内部。这意味着同一并行任务处理的所有记录可以访问相同的状态。操作符状态无法被同一或不同操作符的另一个任务访问。图 3-11(#fig_operator-state)展示了任务如何访问操作符状态。

具有操作符状态的任务

图 3-11. 具有操作符状态的任务

Flink 提供三种操作符状态基元:

列表状态

将状态表示为条目列表。

联合列表状态

也将状态表示为条目列表。但它与常规列表状态不同,因为在故障发生或从保存点启动应用程序时如何恢复会有所不同。我们将在本章后面讨论这种差异。

广播状态

设计用于操作符的每个任务的状态都相同的特殊情况。这种属性可以在检查点和重新调整操作符时利用。这两个方面将在本章后面讨论。

键控状态

键控状态是维护并访问操作符输入流记录中定义的键的状态。Flink 为每个键值维护一个状态实例,并将具有相同键的所有记录分区到维护此键状态的操作符任务。当任务处理记录时,自动将状态访问范围限定为当前记录的键。因此,具有相同键的所有记录访问相同的状态。图 3-12 显示任务如何与键控状态交互。

具有键控状态的任务

图 3-12. 具有键控状态的任务

您可以将键控状态视为按键分区(或分片)的键值映射。Flink 为键控状态提供不同的基元,这些基元确定存储在此分布式键值映射中每个键的值的类型。我们将简要讨论最常见的键控状态基元。

值状态

每个键存储任意类型的单个值。复杂数据结构也可以作为值状态存储。

列表状态

每个键存储一个值列表。列表条目可以是任意类型。

映射状态

每个键存储一个键值映射。映射的键和值可以是任意类型。

状态基元将状态的结构暴露给 Flink,并实现更高效的状态访问。它们将在“在 RuntimeContext 中声明键控状态”进一步讨论。

状态后端

具有状态的操作符任务通常为每个传入记录读取并更新其状态。由于高效的状态访问对于处理具有低延迟的记录至关重要,因此每个并行任务在本地维护其状态以确保快速状态访问。状态的存储、访问和维护方式由可插拔组件——状态后端决定。状态后端负责两件事:本地状态管理和将状态检查点保存到远程位置。

对于本地状态管理,状态后端存储所有键控状态,并确保所有访问正确地限定在当前键上。Flink 提供的状态后端将键控状态管理为存储在 JVM 堆上的内存数据结构中的对象。另一种状态后端将状态对象序列化并将它们放入 RocksDB,然后再写入到本地硬盘。第一种选择提供非常快速的状态访问,但受内存大小的限制。通过 RocksDB 状态后端存储的状态访问较慢,但其状态可以变得非常大。

状态检查点非常重要,因为 Flink 是一个分布式系统,并且状态仅在本地维护。任何时候,TaskManager 进程(及其上运行的所有任务)都可能失败。因此,必须考虑其存储是易失性的。状态后端负责将任务的状态检查点到远程和持久存储。用于检查点的远程存储可以是分布式文件系统或数据库系统。状态后端在检查点状态的方式上有所不同。例如,RocksDB 状态后端支持增量检查点,可以显著减少非常大的状态大小的检查点开销。

我们将在“选择状态后端”中更详细地讨论不同状态后端及其优缺点。

缩放有状态操作符

流应用程序的一个常见需求是根据输入速率的增加或减少调整操作符的并行性。尽管缩放无状态操作符很简单,但是调整有状态操作符的并行性要困难得多,因为它们的状态需要重新分区并分配给更多或更少的并行任务。Flink 支持四种缩放不同类型状态的模式。

具有键控状态的操作符通过将键重新分区到更少或更多的任务来进行缩放。然而,为了提高任务之间必要状态传输的效率,Flink 不会重新分布单个键。相反,Flink 将键组织在所谓的键组中。键组是键的分区,也是 Flink 分配键到任务的方式。 图 3-13 显示了如何在键组中重新分区键控状态。

缩放具有键控状态的操作符的内部和外部

图 3-13. 缩放具有键控状态的操作符的内部和外部

具有操作符列表状态的操作符通过重新分配列表条目来进行缩放。从概念上讲,所有并行操作符任务的列表条目被收集并均匀重新分配到更少或更多的任务中。如果列表条目少于操作符的新并行性,则某些任务将以空状态启动。 图 3-14 显示了操作符列表状态的重新分配。

缩放具有操作符列表状态的操作符的内部和外部

图 3-14. 缩放具有操作符列表状态的操作符的内部和外部

使用运算符联合列表状态的操作员通过向每个任务广播完整的状态条目列表来扩展。然后任务可以选择使用哪些条目并丢弃哪些条目。图 3-15 显示了运算符联合列表状态的重新分配过程。

使用运算符联合列表状态扩展运算符

图 3-15. 使用运算符联合列表状态扩展运算符

使用运算符广播状态的操作员通过将状态复制到新任务来进行扩展。这有效的原因是广播状态可以确保所有任务具有相同的状态。在缩减规模时,多余的任务简单地被取消,因为状态已经被复制,不会丢失。图 3-16 显示了运算符广播状态的重新分配过程。

使用运算符广播状态扩展运算符

图 3-16. 使用运算符广播状态扩展运算符

Checkpoints, Savepoints, and State Recovery

Flink 是一个分布式数据处理系统,因此必须处理如进程被杀死、机器故障和网络连接中断等故障。由于任务在本地维护其状态,Flink 必须确保在故障发生时不会丢失状态,并保持一致性。

在本节中,我们介绍了 Flink 的检查点和恢复机制,以保证状态一致性。我们还讨论了 Flink 独特的保存点特性,这是一种类似于“瑞士军刀”的工具,用于解决流处理应用的许多挑战。

一致性检查点

Flink 的恢复机制基于应用状态的一致检查点。一个有状态的流处理应用的一致检查点是在所有任务处理完全相同输入时复制每个任务状态的副本。这可以通过查看采用一致检查点应用的简单算法的步骤来解释。这个简单算法的步骤包括:

  1. 暂停所有输入流的摄入。

  2. 等待所有正在传输的数据被完全处理,即所有任务都已处理完其所有输入数据。

  3. 通过将每个任务的状态复制到远程持久存储来进行检查点。当所有任务完成其副本时,检查点才算完成。

  4. 恢复所有流的摄入。

注意,Flink 不实现这种简单的机制。我们将在本节后面介绍 Flink 更复杂的检查点算法。

图 3-17 显示了一个简单应用的一致检查点。

流应用的一致检查点

图 3-17. 流应用的一致检查点

应用程序有一个单源任务,消费递增数字流——1、2、3 等。数字流被分成偶数和奇数流。两个求和运算符任务计算所有偶数和奇数的累积和。源任务存储其输入流的当前偏移量作为状态。求和任务将当前和值作为状态持久化。在图 3-17 中,当输入偏移量为 5 时,Flink 进行了检查点,并且和分别为 6 和 9。

从一致检查点恢复

在流式应用程序执行期间,Flink 定期获取应用程序状态的一致检查点。在发生故障时,Flink 使用最新的检查点来恢复应用程序状态并重新启动处理。图 3-18 展示了恢复过程。

从检查点恢复应用程序

图 3-18. 从检查点恢复应用程序

应用程序恢复分为三个步骤:

  1. 重新启动整个应用程序。

  2. 将所有有状态任务的状态重置为最新的检查点。

  3. 恢复所有任务的处理。

此检查点和恢复机制可以为应用程序状态提供精确一次一致性,前提是所有运算符都能检查点和恢复其所有状态,并且所有输入流都被重置到在进行检查点时消费的位置。数据源能否重置其输入流取决于其实现以及从中获取流的外部系统或接口。例如,像 Apache Kafka 这样的事件日志可以提供从流的先前偏移量读取的记录。相反,从套接字消费的流无法重置,因为套接字一旦消费数据就会丢弃它。因此,只有当所有输入流都由可重置数据源消费时,应用程序才能在精确一次状态一致性下运行。

在从检查点重新启动应用程序后,其内部状态与检查点创建时完全相同。然后开始处理检查点和故障之间处理的所有数据。尽管这意味着 Flink 处理一些消息两次(故障前后各一次),但机制仍然实现了精确一次状态一致性,因为所有运算符的状态都被重置到尚未看到这些数据的点。

Flink 的检查点和恢复机制仅重置流式应用程序的内部状态。根据应用程序的汇聚操作符,一些结果记录在恢复期间可能会被多次发送到下游系统,如事件日志、文件系统或数据库。对于某些存储系统,Flink 提供了具备精准一次性输出的汇聚函数,例如在检查点完成时提交已发送的记录。另一种适用于许多存储系统的方法是幂等更新。有关端到端精准一次性应用程序及其解决方法的挑战,详细讨论见《应用程序一致性保证》。

Flink 的恢复机制基于一致的应用程序检查点。对于流式应用程序进行简单的检查点(暂停、检查点、恢复)的天真方法对于具有中等延迟要求的应用程序并不实用,因为其“停止世界”的行为。因此,Flink 实现了基于 Chandy-Lamport 算法的检查点,用于分布式快照。该算法不会暂停整个应用程序,而是将检查点与处理分离,以便一些任务继续处理,而其他任务持久化其状态。接下来,我们将详细解释该算法的工作原理。

Flink 的检查点算法使用一种称为检查点障碍的特殊记录类型。类似于水印,检查点障碍由源操作符注入到常规记录流中,并且不能被其他记录超越或超越。检查点障碍携带检查点 ID,用于标识其所属的检查点,并逻辑上将流分为两部分。所有由障碍之前的记录导致的状态修改均包括在障碍的检查点中,所有由障碍之后的记录导致的状态修改均包括在稍后的检查点中。

我们使用一个简单流式应用程序的示例来逐步解释该算法。该应用程序包括两个源任务,每个任务消费一个递增数字流。源任务的输出被分区为偶数和奇数数字流。每个分区由一个任务处理,计算接收到的所有数字的总和,并将更新的总和转发到一个汇聚。应用程序如图 Figure 3-19 所示。

带有两个有状态源、两个有状态任务和两个无状态汇聚的流式应用程序

图 3-19. 带有两个有状态源、两个有状态任务和两个无状态汇聚的流式应用程序

通过将新的检查点 ID 的消息发送给每个数据源任务,作业管理器启动检查点,如图 Figure 3-20 所示。

作业管理器通过发送消息到所有源启动检查点

图 3-20. JobManager 通过向所有源发送消息启动检查点。

当数据源任务接收到消息时,它会暂停发出记录,触发本地状态在状态后端的检查点,并通过所有输出流分区广播带有检查点 ID 的检查点障碍。状态后端在完成任务状态检查点并且任务在 JobManager 上确认检查点后通知任务。在所有障碍都发送完毕后,数据源继续其常规操作。通过将障碍注入其输出流,数据源函数定义了检查点的流位置。图 3-21 显示了在两个源任务检查点其本地状态并发出检查点障碍后的流处理应用程序。

源任务检查点其状态并发出检查点障碍

图 3-21. 源任务检查点其状态并发出检查点障碍。

源任务发出的检查点障碍被发送到连接的任务。与水印类似,检查点障碍被广播到所有连接的并行任务,以确保每个任务从其每个输入流接收到障碍。当任务接收到新检查点的障碍时,它会等待该检查点的所有输入分区的障碍到达。在等待期间,它会继续处理尚未提供障碍的流分区上的记录。已经转发了障碍的分区上到达的记录无法被处理,会被缓冲。等待所有障碍到达的过程称为障碍对齐,在图 3-22 中有所描述。

任务在每个输入分区等待障碍的到来;对于已经到达障碍的输入流记录进行缓冲;所有其他记录按照正常方式处理

图 3-22. 任务在每个输入分区等待障碍的到来;对于已经到达障碍的输入流记录进行缓冲;所有其他记录按照正常方式处理。

一旦任务从其所有输入分区接收到障碍,它会在状态后端发起检查点并向其所有下游连接任务广播检查点障碍,如图 3-23 所示。

任务在接收到所有障碍后检查它们的状态,然后将检查点障碍转发

图 3-23. 任务在接收到所有障碍后检查它们的状态,然后将检查点障碍转发。

一旦所有检查点障碍被发出,任务开始处理缓冲记录。在所有缓冲记录被发出后,任务继续处理其输入流。图 3-24 在此时显示了应用程序的状态。

任务在转发检查点障碍后继续正常处理

图 3-24. 在转发检查点障碍后,任务继续常规处理

最终,检查点障碍到达汇聚器任务。当汇聚器任务接收到障碍时,它执行障碍对齐,检查其自身状态,并向作业管理器确认接收障碍。作业管理器一旦从应用程序的所有任务收到检查点确认,就会记录应用程序的检查点为已完成。图 3-25 显示了检查点算法的最后步骤。完成的检查点可用于根据之前描述的方式从故障中恢复应用程序。

汇聚器向作业管理器确认接收检查点障碍,并且当所有任务确认其状态成功检查点化

图 3-25. 汇聚器向作业管理器确认接收检查点障碍,并且当所有任务确认其状态成功检查点化时,检查点完成。

检查点的性能影响

Flink 的检查点算法从流应用程序中生成一致的分布式检查点,而无需停止整个应用程序。然而,它可能会增加应用程序的处理延迟。在某些条件下,Flink 实现了一些调整来减轻性能影响。

当任务检查其状态时,它会被阻塞,并且其输入会被缓冲。由于状态可能会变得相当大,并且检查点需要通过网络将数据写入远程存储系统,因此检查点可能需要几秒到几分钟的时间——这对于延迟敏感的应用程序来说太长了。在 Flink 的设计中,状态后端负责执行检查点。任务的状态如何复制取决于状态后端的实现方式。例如,FileSystem 状态后端和 RocksDB 状态后端支持异步检查点。当触发检查点时,状态后端创建状态的本地副本。完成本地副本后,任务继续其常规处理。后台线程异步将本地快照复制到远程存储,并在完成检查点后通知任务。异步检查点显著减少了任务继续处理数据的时间。此外,RocksDB 状态后端还支持增量检查点,从而减少了需要传输的数据量。

减少处理延迟对检查点算法影响的另一种技术是调整屏障对齐步骤。对于需要非常低延迟且能够容忍至少一次状态保证的应用程序,可以配置 Flink 在缓冲对齐期间处理所有到达的记录,而不是缓冲那些屏障已经到达的记录。一旦检查点的所有屏障都到达,操作员将检查点的状态,这可能还包括由通常属于下一个检查点的记录引起的修改。在故障的情况下,这些记录将被再次处理,这意味着检查点提供至少一次而不是精确一次的一致性保证。

Savepoints

Flink 的恢复算法基于状态检查点。定期采取检查点并根据可配置的策略自动丢弃检查点。由于检查点的目的是确保在发生故障时可以重新启动应用程序,因此当应用程序明确取消时,它们将被删除。⁴ 然而,应用程序状态的一致快照可以用于更多目的,而不仅仅是故障恢复。

Flink 最有价值和独特的功能之一是保存点。原则上,保存点使用与检查点相同的算法创建,因此基本上是带有一些额外元数据的检查点。Flink 不会自动创建保存点,因此用户(或外部调度程序)必须显式触发其创建。Flink 也不会自动清理保存点。第十章描述了如何触发和处理保存点。

使用保存点

给定一个应用程序和兼容的保存点,您可以从保存点开始应用程序。这将初始化应用程序的状态到保存点的状态,并从保存点采取的点运行应用程序。虽然这种行为看起来与使用检查点从故障中恢复应用程序完全相同,但故障恢复实际上只是一种特殊情况。它在相同的集群上以相同的配置启动相同的应用程序。从保存点启动应用程序可以让您做更多事情。

  • 您可以从保存点开始一个不同但兼容的应用程序。因此,您可以修复应用程序逻辑中的错误,并重新处理您的流源能够提供的尽可能多的事件,以修复您的结果。修改后的应用程序还可以用于运行具有不同业务逻辑的 A/B 测试或假设情景。请注意,应用程序和保存点必须兼容——应用程序必须能够加载保存点的状态。

  • 您可以以不同的并行性启动相同的应用程序,并扩展或缩减应用程序。

  • 您可以在不同的集群上启动相同的应用程序。这使您可以将应用程序迁移到更新的 Flink 版本或不同的集群或数据中心。

  • 您可以使用保存点暂停应用程序,稍后恢复它。这使得可以释放集群资源以供更高优先级的应用程序使用,或者当输入数据不连续生成时。

  • 您还可以仅仅获取保存点以版本化归档应用程序的状态。

由于保存点是如此强大的功能,许多用户定期创建保存点以便能够回溯。我们在实际中看到的最有趣的保存点应用之一是持续将流式应用迁移到提供最低实例价格的数据中心。

从保存点启动应用程序

所有之前提到的保存点用例都遵循相同的模式。首先,需要获取运行中应用程序的保存点,然后使用它来恢复起始应用程序的状态。在本节中,我们描述了 Flink 如何初始化从保存点启动的应用程序的状态。

一个应用程序由多个操作符组成。每个操作符可以定义一个或多个键控和操作符状态。操作符通过一个或多个操作符任务并行执行。因此,典型的应用程序由分布在多个操作符任务上的多个状态组成,这些任务可以运行在不同的 TaskManager 进程上。

图 3-26 显示了一个具有三个操作符的应用程序,每个操作符都有两个任务在运行。一个操作符(OP-1)有一个操作符状态(OS-1),另一个操作符(OP-2)有两个键控状态(KS-1 和 KS-2)。当获取保存点时,所有任务的状态都被复制到持久存储位置。

从应用程序获取保存点并从保存点恢复应用程序

图 3-26. 从应用程序获取保存点并从保存点恢复应用程序

保存点中的状态副本按操作符标识符和状态名称进行组织。操作符标识符和状态名称是必需的,以便能够将保存点的状态数据映射到启动应用程序的操作符状态。当从保存点启动应用程序时,Flink 将保存点数据重新分发到相应操作符的任务中。

注意

注意,保存点不包含操作符任务的信息。这是因为当使用不同并行度启动应用程序时,任务数量可能会发生变化。我们在本节早些时候讨论了 Flink 缩放有状态操作符的策略。

如果从保存点启动修改后的应用程序,则只有保存点中包含具有相应标识符和状态名称的运算符时,才能将其映射到应用程序。默认情况下,Flink 分配唯一的运算符标识符。然而,运算符的标识符是根据其前驱运算符的标识符确定性地生成的。因此,当其前驱运算符之一更改时(例如添加或删除运算符),运算符的标识符也会更改。因此,默认运算符标识符的应用程序在不丢失状态的情况下如何演变非常有限。因此,我们强烈建议手动为运算符分配唯一标识符,而不依赖于 Flink 的默认分配。我们在 “指定唯一运算符标识符” 中详细描述了如何分配运算符标识符。

概要

在本章中,我们讨论了 Flink 的高级架构及其网络堆栈、事件时间处理模式、状态管理和故障恢复机制的内部工作原理。在设计高级流处理应用程序、设置和配置集群、操作流处理应用程序以及评估其性能时,这些信息将非常有用。

¹ 重新启动策略在 第十章 中有更详细的讨论。

² 除了流水线通信外,批处理应用程序还可以通过在发送方收集传出数据来交换数据。一旦发送方任务完成,数据将通过临时 TCP 连接作为批次发送到接收方。

³ 进程函数在 第六章 中有更详细的讨论。

⁴ 也可以在取消应用程序时配置其保留最后一个检查点。

第四章:为 Apache Flink 设置开发环境

现在我们已经掌握了所有这些知识,是时候动手开始开发 Flink 应用程序了!在本章中,您将学习如何设置环境来开发、运行和调试 Flink 应用程序。我们将从讨论所需软件和本书代码示例的获取位置开始。利用这些示例,我们将展示如何在 IDE 中执行和调试 Flink 应用程序。最后,我们展示如何启动一个 Flink Maven 项目,这是新应用程序的起点。

所需软件

首先,让我们讨论您需要开发 Flink 应用程序的软件。您可以在 Linux、macOS 和 Windows 上开发和执行 Flink 应用程序。但是,UNIX-based 设置因其被大多数 Flink 开发人员偏好的环境而享有最丰富的工具支持。在本章的其余部分,我们将假设 UNIX-based 设置。作为 Windows 用户,您可以使用 Windows 子系统用于 Linux(WSL)、Cygwin 或 Linux 虚拟机来在 UNIX 环境中运行 Flink。

Flink 的 DataStream API 可用于 Java 和 Scala。因此,实现 Flink DataStream 应用程序需要 Java JDK — Java JDK 8(或更高版本)。仅安装 Java JRE 是不够的。

我们还假设以下软件也已安装,尽管这些并不是开发 Flink 应用程序的严格要求:

  • Apache Maven 3.x。本书的代码示例使用 Maven 构建管理。此外,Flink 提供了 Maven 原型来启动新的 Flink Maven 项目。

  • 用于 Java 和/或 Scala 开发的 IDE。常见选择包括 IntelliJ IDEA、Eclipse 或带有适当插件(如 Maven、Git 和 Scala 支持)的 Netbeans。我们建议使用 IntelliJ IDEA。您可以按照 IntelliJ IDEA 网站 上的说明下载和安装它。

在 IDE 中运行和调试 Flink 应用程序

尽管 Flink 是一个分布式数据处理系统,但您通常会在本地机器上开发和运行初始测试。这样做可以简化开发,并简化集群部署,因为您可以在集群环境中运行完全相同的代码而无需进行任何更改。接下来,我们将描述如何获取我们在此处使用的代码示例,如何将其导入 IntelliJ,如何运行示例应用程序以及如何调试它。

在 IDE 中导入本书示例

本书的代码示例托管在 GitHub 上。在书的 GitHub 页面 上,您将找到一个包含 Scala 示例和一个包含 Java 示例的存储库。我们将使用 Scala 存储库进行设置,但如果您更喜欢 Java,您应该能够按照相同的说明操作。

打开终端并运行以下 Git 命令,将 examples-scala 存储库克隆到您的本地机器上:¹

> git clone https://github.com/streaming-with-flink/examples-scala

您也可以从 GitHub 下载示例源代码的 zip 存档:

> wget https://github.com/streaming-with-flink/examples-scala/archive/master.zip
> unzip master.zip

书籍示例以 Maven 项目的形式提供。你会在src/目录中找到按章节分组的源代码:

.
└── main
    └── scala
        └── io
            └── github
                └── streamingwithflink
                    ├── chapter1
                    │   └── AverageSensorReadings.scala
                    ├── chapter5
                    │   └── ...
                    ├── ...
                    │   └── ...
                    └── util
                        └── ...

现在打开你的集成开发环境,并导入 Maven 项目。对大多数集成开发环境来说,导入步骤是相似的。以下我们详细解释 IntelliJ 中的这一步骤。

转到 文件 -> 新建 -> 从现有资源导入项目,选择书籍示例文件夹 examples-scala,然后点击确定。确保选择了“从外部模型导入项目”和“Maven”,然后点击下一步。

一个项目导入向导将引导你执行接下来的步骤,例如选择要导入的 Maven 项目(应该只有一个),选择 SDK,并命名项目。图 4-1 到图 4-3 展示了导入过程。

图 4-1. 将书籍示例存储库导入 IntelliJ

图 4-2. 选择要导入的 Maven 项目

图 4-3. 给你的项目命名并点击完成

就这样!现在你应该能够浏览和检查书籍示例代码了。

接下来,在你的集成开发环境中运行一本书的示例应用程序。搜索AverageSensorReadings类并打开它。正如在“快速浏览 Flink”中所讨论的,该程序生成多个热传感器的读取事件,将事件的温度从华氏度转换为摄氏度,并计算每秒钟每个传感器的平均温度。程序的结果将被输出到标准输出。和许多 DataStream 应用程序一样,该程序的源、汇以及操作符被组装在AverageSensorReadings类的main()方法中。

要启动应用程序,请运行main()方法。程序的输出将被写入集成开发环境的标准输出(或控制台)窗口。程序的输出将以几条关于并行操作符任务状态的日志语句开头,如 SCHEDULING、DEPLOYING 和 RUNNING。一旦所有任务都启动并运行,程序开始生成其结果,结果应类似于以下几行:

2> SensorReading(sensor_31,1515014051000,23.924656183848732)
4> SensorReading(sensor_32,1515014051000,4.118569049862492)
1> SensorReading(sensor_38,1515014051000,14.781835420242471)
3> SensorReading(sensor_34,1515014051000,23.871433252250583)

程序将继续生成新事件,处理它们,并每秒钟发出新结果,直到你终止它。

现在让我们快速讨论一下内部原理。如在“Flink 设置的组件”中所解释的,Flink 应用程序被提交给 JobManager(主节点),后者将执行任务分发给一个或多个 TaskManager(工作节点)。由于 Flink 是一个分布式系统,JobManager 和 TaskManagers 通常在不同的机器上作为单独的 JVM 进程运行。通常情况下,程序的main()方法组装数据流,并在调用StreamExecutionEnvironment.execute()方法时将其提交给远程 JobManager。

然而,也有一种模式,在这种模式下,调用 execute() 方法会启动一个 JobManager 和一个 TaskManager(默认情况下,具有与可用 CPU 线程一样多的插槽作为单独的线程在同一个 JVM 中)。因此,整个 Flink 应用程序是多线程的,并在同一个 JVM 进程中执行。这种模式用于在 IDE 中执行 Flink 程序。

由于单个 JVM 执行模式,几乎可以像在 IDE 中调试任何其他程序一样调试 Flink 应用程序。您可以在代码中定义断点,并像正常调试应用程序一样调试您的应用程序。

然而,在使用 IDE 调试 Flink 应用程序时,有几件事情需要考虑:

  • 除非您指定并行性,否则程序将由与开发机器的 CPU 线程数相同的线程执行。因此,您应该意识到您可能在调试一个多线程程序。

  • 与将 Flink 程序发送到远程 JobManager 执行不同,该程序在单个 JVM 中执行。因此,某些问题,如类加载问题,无法正确调试。

  • 尽管程序在单个 JVM 中执行,但记录被序列化以进行跨线程通信和可能的状态持久化。

启动一个 Flink Maven 项目

examples-scala 仓库导入到您的 IDE 中以试验 Flink 是一个很好的第一步。但是,您还应该知道如何从头开始创建一个新的 Flink 项目。

Flink 提供了用于生成 Java 或 Scala Flink 应用程序的 Maven 原型。打开终端并运行以下命令,创建一个 Flink Maven 快速启动 Scala 项目作为您的 Flink 应用程序的起点:

mvn archetype:generate                            \
   -DarchetypeGroupId=org.apache.flink            \
   -DarchetypeArtifactId=flink-quickstart-scala   \
   -DarchetypeVersion=1.7.1                       \
   -DgroupId=org.apache.flink.quickstart          \
   -DartifactId=flink-scala-project               \
   -Dversion=0.1                                  \
   -Dpackage=org.apache.flink.quickstart          \
   -DinteractiveMode=false

这将在名为 flink-scala-project 的文件夹中生成一个 Flink 1.7.1 的 Maven 项目。您可以通过更改上述 mvn 命令的相应参数来更改 Flink 版本、组和构件 ID、版本和生成的包。生成的文件夹包含一个 src/ 文件夹和一个 pom.xml 文件。src/ 文件夹具有以下结构:

src/
└── main
    ├── resources
    │   └── log4j.properties
    └── scala
        └── org
            └── apache
                └── flink
                    └── quickstart
                        ├── BatchJob.scala
                        └── StreamingJob.scala

该项目包含两个骨架文件,BatchJob.scalaStreamingJob.scala,作为您自己程序的起点。如果不需要,您也可以删除它们。

您可以按照我们在上一节中描述的步骤将项目导入到您的 IDE 中,或者您可以执行以下命令来构建一个 JAR 文件:

mvn clean package -Pbuild-jar

如果命令成功完成,您将在项目文件夹中找到一个新的 target 文件夹。该文件夹包含一个名为 flink-scala-project-0.1.jar 的文件,这是您的 Flink 应用程序的 JAR 文件。生成的 pom.xml 文件还包含如何向项目添加新依赖项的说明。

摘要

本章介绍了如何设置环境来开发和调试 Flink DataStream 应用程序,并使用 Flink 的 Maven 原型生成了一个 Maven 项目。显然的下一步是学习如何实际实现一个 DataStream 程序。

第五章将介绍 DataStream API 的基础知识,而第六章,第七章,和第八章将介绍与基于时间的操作符、有状态函数以及源和接收器连接器相关的一切。

¹ 我们还提供了一个名为examples-Java的示例库,其中包含所有用 Java 实现的示例。

第五章:DataStream API(v1.7)

本章介绍了 Flink 的 DataStream API 的基础知识。我们展示了典型 Flink 流应用程序的结构和组件,讨论了 Flink 的类型系统和支持的数据类型,并展示了数据和分区转换。窗口操作符、基于时间的转换、有状态操作符和连接器将在接下来的章节中讨论。阅读完本章后,您将了解如何实现具有基本功能的流处理应用程序。我们的代码示例使用 Scala 以确保简洁性,但 Java API 在大多数情况下是类似的(会指出例外或特殊情况)。我们还在我们的GitHub 存储库中提供了用 Java 和 Scala 实现的完整示例应用程序。

Hello, Flink!

让我们从一个简单的示例开始,以了解使用 DataStream API 编写流应用程序的基本结构和一些重要特性。我们将使用此示例展示 Flink 程序的基本结构,并介绍 DataStream API 的一些重要功能。我们的示例应用程序从多个传感器中摄取温度测量流。

首先,让我们看一下我们将用于表示传感器读数的数据类型:

case class SensorReading(
  id: String,
  timestamp: Long,
  temperature: Double)

示例中的程序(示例 5-1)将温度从华氏度转换为摄氏度,并每 5 秒计算一次每个传感器的平均温度。

示例 5-1。每 5 秒计算一次传感器流的平均温度
// Scala object that defines the DataStream program in the main() method.
object AverageSensorReadings {

 // main() defines and executes the DataStream program
 def main(args: Array[String]) {

   // set up the streaming execution environment
   val env = StreamExecutionEnvironment.getExecutionEnvironment

   // use event time for the application
   env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

   // create a DataStream[SensorReading] from a stream source
   val sensorData: DataStream[SensorReading] = env
     // ingest sensor readings with a SensorSource SourceFunction
     .addSource(new SensorSource)
     // assign timestamps and watermarks (required for event time)
     .assignTimestampsAndWatermarks(new SensorTimeAssigner)

   val avgTemp: DataStream[SensorReading] = sensorData
     // convert Fahrenheit to Celsius with an inline lambda function
     .map( r => {
         val celsius = (r.temperature - 32) * (5.0 / 9.0)
         SensorReading(r.id, r.timestamp, celsius)
       } )
     // organize readings by sensor id
     .keyBy(_.id)
     // group readings in 5 second tumbling windows
     .timeWindow(Time.seconds(5))
     // compute average temperature using a user-defined function
     .apply(new TemperatureAverager)

   // print result stream to standard out
   avgTemp.print()

   // execute application
   env.execute("Compute average sensor temperature")
 }
}

您可能已经注意到,Flink 程序是在常规 Scala 或 Java 方法中定义并提交执行的。通常情况下,这是在一个静态的 main 方法中完成的。在我们的示例中,我们定义了AverageSensorReadings对象,并在main()内包含大部分应用逻辑。

为了构建典型的 Flink 流应用程序:

  1. 设置执行环境。

  2. 从数据源中读取一个或多个流。

  3. 应用流处理转换以实现应用逻辑。

  4. 可选地将结果输出到一个或多个数据汇。

  5. 执行程序。

现在我们详细看看这些部分。

设置执行环境

一个 Flink 应用程序首先需要做的是设置它的执行环境。执行环境决定程序是在本地机器上运行还是在集群上运行。在 DataStream API 中,应用程序的执行环境由StreamExecutionEnvironment表示。在我们的示例中,我们通过调用静态的getExecutionEnvironment()方法来获取执行环境。此方法根据调用方法时的上下文返回本地或远程环境。如果该方法从具有到远程集群的连接的提交客户端调用,则返回远程执行环境。否则,返回本地环境。

也可以按如下方式显式创建本地或远程执行环境:

// create a local stream execution environment
val localEnv: StreamExecutionEnvironment.createLocalEnvironment()

// create a remote stream execution environment
val remoteEnv = StreamExecutionEnvironment.createRemoteEnvironment(
  "host",                // hostname of JobManager
  1234,                  // port of JobManager process
  "path/to/jarFile.jar")  // JAR file to ship to the JobManager

接下来,我们使用env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)指示程序使用事件时间语义解释时间。执行环境提供了更多的配置选项,例如设置程序并行度和启用容错性。

读取输入流

一旦执行环境配置完成,就可以开始处理流并进行一些实际工作。StreamExecutionEnvironment提供了方法来创建流源,将数据流导入应用程序。数据流可以从消息队列或文件等源摄取,也可以动态生成。

在我们的示例中,我们使用:

val sensorData: DataStream[SensorReading] = 
  env.addSource(new SensorSource)

连接到传感器测量源并创建初始类型为SensorReadingDataStream。Flink 支持许多数据类型,我们将在下一节中描述。这里,我们使用 Scala case class 作为我们之前定义的数据类型。SensorReading包含传感器 ID、表示测量时间的时间戳以及测量的温度。assignTimestampsAndWatermarks(new SensorTimeAssigner)方法分配事件时间所需的时间戳和水印。现在我们不需要关心SensorTimeAssigner的实现细节。

应用转换

一旦我们有了DataStream,我们可以对其进行转换。有不同类型的转换。一些转换可以产生新的DataStream,可能是不同类型的,而其他转换则不修改DataStream的记录,但重新组织它通过分区或分组。应用程序的逻辑通过链式转换来定义。

在我们的示例中,我们首先应用了一个map()转换,将每个传感器读数的温度转换为摄氏度。然后,我们使用keyBy()转换来根据传感器 ID 对传感器读数进行分区。接下来,我们定义了一个timeWindow()转换,将每个传感器 ID 分区的传感器读数分组为 5 秒的滚动窗口:

val avgTemp: DataStream[SensorReading] = sensorData
     .map( r => {
           val celsius = (r.temperature - 32) * (5.0 / 9.0)
           SensorReading(r.id, r.timestamp, celsius)
       } )
     .keyBy(_.id)
     .timeWindow(Time.seconds(5))
     .apply(new TemperatureAverager)

窗口转换在“窗口操作符”中有详细描述。最后,我们应用一个用户定义的函数,在每个窗口上计算平均温度。我们将在本章后面的部分讨论用户定义函数的实现。

输出结果

流应用通常将其结果发射到某个外部系统,例如 Apache Kafka、文件系统或数据库。Flink 提供了一组良好维护的流接收器集合,可以用于将数据写入不同的系统。还可以实现自己的流接收器。还有一些应用程序不会发射结果,而是保留在内部以通过 Flink 的可查询状态功能提供服务。

在我们的示例中,结果是一个DataStream[SensorReading]记录。每个记录包含传感器在 5 秒内的平均温度。调用print()方法将结果流写入标准输出:

avgTemp.print()

注意

注意,流式汇聚器的选择会影响应用程序的端到端一致性,无论应用程序的结果是以至少一次还是精确一次语义提供的。应用程序的端到端一致性取决于所选流汇聚器与 Flink 的检查点算法的集成。我们将在“应用程序一致性保证”中更详细地讨论此主题。

执行

当应用程序完全定义后,可以通过调用StreamExecutionEnvironment.execute()来执行。这是我们示例中的最后一次调用:

env.execute("Compute average sensor temperature")

Flink 程序是惰性执行的。也就是说,创建流源和转换的 API 调用不会立即触发任何数据处理。相反,API 调用在执行环境中构建执行计划,该计划由从环境创建的流源和所有应用于这些源的传递转换组成。只有在调用execute()时,系统才会触发程序的执行。

构建的计划被翻译成一个 JobGraph,并提交给 JobManager 进行执行。根据执行环境的类型,JobManager 可以作为本地线程(本地执行环境)启动,或者 JobGraph 被发送到远程 JobManager。如果 JobManager 远程运行,则必须将 JobGraph 与包含应用程序所有类和所需依赖项的 JAR 文件一起发送。

转换

在本节中,我们概述了 DataStream API 的基本转换。像窗口操作符和其他专业转换这样的与时间相关的操作符在后续章节中描述。流转换应用于一个或多个流,并将它们转换为一个或多个输出流。编写 DataStream API 程序主要是将这些转换组合起来,以创建实现应用程序逻辑的数据流图。

大多数流转换都基于用户定义的函数。这些函数封装了用户应用逻辑,并定义了如何将输入流的元素转换为输出流的元素。例如,MapFunction等函数被定义为实现特定转换函数接口的类:

class MyMapFunction extends MapFunction[Int, Int] {
  override def map(value: Int): Int =  value + 1
}

函数接口定义了需要用户实现的转换方法,例如上面示例中的map()方法。

大多数函数接口都设计为 SAM(单一抽象方法)接口,可以作为 Java 8 lambda 函数实现。Scala DataStream API 也内置了对 lambda 函数的支持。在展示 DataStream API 的转换时,我们展示了所有函数类的接口,但在代码示例中为简洁起见,大多数情况下使用 lambda 函数而不是函数类。

DataStream API 提供了最常见数据转换操作的转换方法。如果您熟悉批处理数据处理 API、函数式编程语言或 SQL,您会发现 API 的概念非常容易理解。我们将 DataStream API 的转换分为四类:

  1. 基本转换是对单个事件的转换。

  2. KeyedStream 转换是在键的上下文中应用于事件的转换。

  3. 多流转换将多个流合并为一个流,或将一个流拆分为多个流。

  4. 分布转换重新组织流事件。

基本转换

基本转换处理单个事件,这意味着每个输出记录都是从单个输入记录生成的。简单值转换、记录分割或记录过滤是常见的基本函数示例。我们解释它们的语义并展示代码示例。

Map

调用 DataStream.map() 方法指定映射转换,并生成新的 DataStream。它将每个传入的事件传递给用户定义的映射器,后者返回一个完全相同类型的输出事件。图 5-1 显示了将每个方块转换为圆形的映射转换。

图 5-1. 将每个方块转换为相同颜色圆形的映射操作

MapFunction 的类型与输入和输出事件的类型相匹配,并可以使用 MapFunction 接口指定。它定义了 map() 方法,将输入事件转换为一个输出事件:

// T: the type of input elements
// O: the type of output elements
MapFunction[T, O]
    > map(T): O

以下是一个简单的映射器,从输入流中提取每个 SensorReading 的第一个字段(id):

val readings: DataStream[SensorReading] = ...
val sensorIds: DataStream[String] = readings.map(new MyMapFunction)

class MyMapFunction extends MapFunction[SensorReading, String] {
  override def map(r: SensorReading): String = r.id
}

在使用 Scala API 或 Java 8 时,映射器也可以表示为 Lambda 函数:

val readings: DataStream[SensorReading] = ...
val sensorIds: DataStream[String] = readings.map(r => r.id)

过滤

过滤转换通过对每个输入事件上的布尔条件进行评估来丢弃或转发流的事件。返回值为 true 保留输入事件并将其转发到输出,而 false 则会丢弃事件。调用 DataStream.filter() 方法指定过滤转换,并生成与输入 DataStream 类型相同的新 DataStream。图 5-2 显示了仅保留白色方块的过滤操作。

图 5-2. 仅保留白色值的过滤操作

布尔条件实现为一个函数,可以使用 FilterFunction 接口或 Lambda 函数。FilterFunction 接口与输入流的类型匹配,并定义了调用输入事件并返回布尔值的 filter() 方法:

// T: the type of elements
FilterFunction[T]
    > filter(T): Boolean

以下示例显示了一个过滤器,该过滤器丢弃所有温度低于 25°F 的传感器测量值:

val readings: DataStream[SensorReadings] = ...
val filteredSensors = readings
    .filter( r =>  r.temperature >= 25 )

FlatMap

flatMap 转换类似于 map,但它可以为每个传入事件生成零个、一个或多个输出事件。事实上,flatMap 转换是 filter 和 map 的泛化,可以用来实现这两个操作。图 5-3 展示了一个 flatMap 操作,根据传入事件的颜色区分其输出。如果输入是白色方块,则输出未修改的事件。黑色方块被复制,灰色方块被过滤掉。

执行 flatMap 操作,输出白色方块、复制黑色方块和丢弃灰色方块

图 5-3. 执行 flatMap 操作,输出白色方块、复制黑色方块和丢弃灰色方块

flatMap 转换在每个传入事件上应用一个函数。相应的 FlatMapFunction 定义了 flatMap() 方法,通过将结果传递给 Collector 对象,可以返回零个、一个或多个事件:

// T: the type of input elements
// O: the type of output elements
FlatMapFunction[T, O]
    > flatMap(T, Collector[O]): Unit

此示例展示了在数据处理教程中常见的 flatMap 转换。该函数应用于一系列句子的流,将每个句子按空格字符拆分,并将每个结果单词作为单独的记录发出。

val sentences: DataStream[String] = ...
val words: DataStream[String] = sentences
  .flatMap(id => id.split(" "))

键控流转换

许多应用程序的常见需求是一起处理具有某种属性的事件组。DataStream API 提供了 KeyedStream 的抽象,它是一个被逻辑分成不相交子流的 DataStream,这些子流共享相同的键。

应用于 KeyedStream 上的有状态转换在当前处理事件的键的上下文中读取和写入状态。这意味着具有相同键的所有事件访问相同的状态,因此可以一起处理。

注意

注意,有状态的转换和键控聚合必须小心使用。如果键域不断增长,例如因为键是唯一的事务 ID,您必须清理不再活动的键的状态,以避免内存问题。详见 “实现有状态函数”,该部分详细讨论了有状态函数。

KeyedStream 可以使用您之前看到的 map、flatMap 和 filter 转换进行处理。接下来,我们将使用 keyBy 转换将 DataStream 转换为 KeyedStream,并使用键控转换,如滚动聚合和 reduce。

keyBy

keyBy 转换通过指定键将 DataStream 转换为 KeyedStream。根据键,流的事件被分配到分区中,以便由后续操作符的同一任务处理具有相同键的所有事件。具有不同键的事件可以由同一任务处理,但任务函数的键控状态始终在当前事件键的范围内访问。

考虑输入事件的颜色作为键,图 5-4 将黑色事件分配给一个分区,将所有其他事件分配给另一个分区。

基于颜色对事件进行分区的 keyBy 操作

图 5-4. 基于颜色对事件进行分区的 keyBy 操作

keyBy() 方法接收一个指定要按其分组的键(或键)的参数,并返回一个 KeyedStream。有不同的方法指定键。我们在 “定义键和引用字段” 中进行了介绍。以下代码将 id 字段声明为 SensorReading 记录流的键:

val readings: DataStream[SensorReading] = ...
val keyed: KeyedStream[SensorReading, String] = readings
  .keyBy(r => r.id)

Lambda 函数 r => r.id 从传感器读数 r 中提取 id 字段。

滚动聚合

滚动聚合转换应用于 KeyedStream,生成包括求和、最小值和最大值在内的 DataStream 聚合。滚动聚合运算符为每个观察到的键保留一个聚合值。对于每个传入的事件,运算符更新相应的聚合值并发出带有更新值的事件。滚动聚合不需要用户定义的函数,但接收一个指定计算聚合的字段的参数。DataStream API 提供以下滚动聚合方法:

sum()

输入流的指定字段的滚动求和。

min()

输入流的指定字段的滚动最小值。

max()

输入流的指定字段的滚动最大值。

minBy()

输入流的滚动最小值返回迄今为止观察到的最低值的事件。

maxBy()

输入流的指定字段的滚动最大值。

无法组合多个滚动聚合方法,一次只能计算单个滚动聚合。

考虑下面的示例,对 Tuple3[Int, Int, Int] 流的第一个字段进行键控,并在第二个字段上计算滚动求和:

val inputStream: DataStream[(Int, Int, Int)] = env.fromElements(
  (1, 2, 2), (2, 3, 1), (2, 2, 4), (1, 5, 3))

val resultStream: DataStream[(Int, Int, Int)] = inputStream
  .keyBy(0) // key on first field of the tuple
  .sum(1)   // sum the second field of the tuple in place

在本例中,元组输入流按第一个字段键控,并在第二个字段上计算滚动求和。示例的输出为键“1”时的 (1,2,2),随后为 (1,7,2),以及键“2”时的 (2,3,1),随后为 (2,5,1)。第一个字段是公共键,第二个字段是求和值,第三个字段未定义。

仅在有界键域上使用滚动聚合

滚动聚合运算符为每个处理的键保留一个状态。由于此状态永远不会清除,应仅在具有有限键域的流上应用滚动聚合运算符。

Reduce

Reduce 转换是滚动聚合的一般化。它在 KeyedStream 上应用 ReduceFunction,将每个传入的事件与当前减少的值组合,并生成一个 DataStream。Reduce 转换不会更改流的类型。输出流的类型与输入流的类型相同。

函数可以由实现 ReduceFunction 接口的类指定。ReduceFunction 定义了 reduce() 方法,该方法接受两个输入事件并返回相同类型的事件:

// T: the element type
ReduceFunction[T]
    > reduce(T, T): T

在下面的示例中,流以语言为键,结果是每种语言的持续更新单词列表:

val inputStream: DataStream[(String, List[String])] = env.fromElements(
  ("en", List("tea")), ("fr", List("vin")), ("en", List("cake")))

val resultStream: DataStream[(String, List[String])] = inputStream
  .keyBy(0)
  .reduce((x, y) => (x._1, x._2 ::: y._2))

Lambda 减少函数将第一个元组字段(键字段)转发,并连接第二个元组字段的List[String]值。

只在有界键域上使用滚动减少操作。

滚动减少操作符会为处理的每个键保留状态。由于此状态从不清理,您应仅对具有有界键域的流应用滚动减少操作符。

多流转换

许多应用程序接收需要共同处理的多个流或分割流以应用不同逻辑于不同子流。接下来我们讨论处理多个输入流或发出多个输出流的 DataStream API 转换。

Union

DataStream.union() 方法合并两个或更多相同类型的 DataStream 并生成相同类型的新 DataStream。后续转换处理所有输入流的元素。 图 5-5 显示将黑色和灰色事件合并为单个输出流的联合操作。

将两个输入流合并为一个联合操作

图 5-5。将两个输入流合并为一个联合操作。

事件以 FIFO 方式合并 —— 操作符不生成特定顺序的事件。此外,联合操作符不执行重复消除。每个输入事件都发射到下一个操作符。

以下显示如何将三个 SensorReading 类型的流联合为单个流:

val parisStream: DataStream[SensorReading] = ...
val tokyoStream: DataStream[SensorReading] = ...
val rioStream: DataStream[SensorReading] = ...
val allCities: DataStream[SensorReading] = parisStream
  .union(tokyoStream, rioStream)

Connect、coMap 和 coFlatMap

在流处理中,将两个流的事件结合起来是一个非常常见的需求。考虑一个监控森林区域并在存在火灾高风险时输出警报的应用程序。该应用程序接收先前见过的温度传感器读数流以及额外的烟雾水平测量流。当温度超过给定阈值且烟雾水平较高时,应用程序会发出火灾警报。

DataStream API 提供连接转换以支持这类用例。¹ DataStream.connect() 方法接收一个 DataStream 并返回一个 ConnectedStreams 对象,表示这两个连接的流:

// first stream
val first: DataStream[Int] = ...
// second stream
val second: DataStream[String] = ...

// connect streams
val connected: ConnectedStreams[Int, String] = first.connect(second)

ConnectedStreams 对象提供了期望 CoMapFunctionCoFlatMapFunction 作为参数的 map()flatMap() 方法。²

这两个功能在第一个和第二个输入流的类型以及输出流的类型上都有类型化,并定义了两个方法——分别用于每个输入的map1()flatMap1(),以及用于处理第二个输入的map2()flatMap2()

// IN1: the type of the first input stream
// IN2: the type of the second input stream
// OUT: the type of the output elements
CoMapFunction[IN1, IN2, OUT]
    > map1(IN1): OUT
    > map2(IN2): OUT
// IN1: the type of the first input stream
// IN2: the type of the second input stream
// OUT: the type of the output elements
CoFlatMapFunction[IN1, IN2, OUT]
    > flatMap1(IN1, Collector[OUT]): Unit
    > flatMap2(IN2, Collector[OUT]): Unit

函数不能选择读取哪些 ConnectedStreams

无法控制CoMapFunctionCoFlatMapFunction方法的调用顺序。相反,一旦事件通过相应的输入到达,方法就会立即被调用。

通常,对两个流的联合处理要求基于某些条件确定地路由两个流的事件,以便由操作符的同一个并行实例进行处理。默认情况下,connect()不会建立两个流事件之间的关系,因此两个流的事件会随机分配给操作符实例。这种行为会导致不确定的结果,通常是不希望的。为了在ConnectedStreams上实现确定性转换,connect()可以与keyBy()broadcast()结合使用。首先我们展示了keyBy()的情况:

val one: DataStream[(Int, Long)] = ...
val two: DataStream[(Int, String)] = ...

// keyBy two connected streams
val keyedConnect1: ConnectedStreams[(Int, Long), (Int, String)] = one
  .connect(two)
  .keyBy(0, 0) // key both input streams on first attribute

// alternative: connect two keyed streams
val keyedConnect2: ConnectedStreams[(Int, Long), (Int, String)] = one.keyBy(0)
  .connect(two.keyBy(0)

无论您是通过keyBy() ConnectedStreams还是通过connect()连接两个KeyedStreamsconnect()转换将会将来自两个流的具有相同键的所有事件路由到同一个操作符实例。请注意,两个流的键应该引用同一类实体,就像 SQL 查询中的连接谓词一样。应用于连接和键控流的操作符可以访问键控状态。³

下一个示例展示了如何连接一个(非键控)DataStream和一个广播流:

val first: DataStream[(Int, Long)] = ...
val second: DataStream[(Int, String)] = ...

// connect streams with broadcast
val keyedConnect: ConnectedStreams[(Int, Long), (Int, String)] = first
  // broadcast second input stream
  .connect(second.broadcast())

所有广播流的事件都会复制并发送到后续处理函数的所有并行操作符实例。非广播流的事件则会简单地转发。因此,两个输入流的元素可以共同处理。

注意

您可以使用广播状态来连接键控流和广播流。广播状态是broadcast()-connect()转换的改进版本。它还支持连接键控流和广播流,并将广播事件存储在受管理状态中。这允许您通过数据流动态配置操作符(例如添加或删除过滤规则或更新机器学习模型)。广播状态在“使用连接广播状态”中有详细讨论。

分割和选择

Split 是 Union 变换的反向转换。它将输入流分成两个或多个与输入流类型相同的输出流。每个传入事件可以路由到零个、一个或多个输出流。因此,Split 也可以用于过滤或复制事件。图 5-6 显示了一个将所有白色事件路由到一个单独流的 Split 操作符。

将输入流分成白色事件流和其他事件流的分裂操作

图 5-6。将输入流分成白色事件流和其他事件流的分裂操作

DataStream.split()方法接收一个OutputSelector,定义了如何将流元素分配给命名输出。OutputSelector定义了一个select()方法,该方法对每个输入事件调用并返回一个java.lang.Iterable[String]。返回的String值指定将记录路由到的输出流。

// IN: the type of the split elements
OutputSelector[IN]
    > select(IN): Iterable[String]

DataStream.split()方法返回一个SplitStream,提供一个select()方法,通过指定输出名称从SplitStream中选择一个或多个流。

示例 5-2 将数字流分成大数流和小数流。

示例 5-2。将元组流分成大数流和小数流。
val inputStream: DataStream[(Int, String)] = ...

val splitted: SplitStream[(Int, String)] = inputStream
  .split(t => if (t._1 > 1000) Seq("large") else Seq("small"))

val large: DataStream[(Int, String)] = splitted.select("large")
val small: DataStream[(Int, String)] = splitted.select("small")
val all: DataStream[(Int, String)] = splitted.select("small", "large") 

注意

split 变换的一个限制是所有出站流都与输入类型相同。在“发射到侧输出”中,我们介绍了处理函数的侧输出功能,它可以从一个函数中发射多个不同类型的流。

分发变换

分区变换对应于我们在“数据交换策略”中介绍的数据交换策略。这些操作定义了事件分配给任务的方式。使用 DataStream API 构建应用程序时,系统会自动选择数据分区策略,并根据操作语义和配置的并行性将数据路由到正确的目的地。有时需要或希望在应用程序级别控制分区策略或定义自定义分区器。例如,如果我们知道DataStream的并行分区负载不均衡,可能希望重新平衡数据,以均匀分配后续运算符的计算负载。或者,应用程序逻辑可能要求操作的所有任务接收相同的数据,或者事件根据自定义策略进行分发。在本节中,我们介绍了允许用户控制分区策略或定义自己的DataStream方法。

注意

请注意,keyBy()与本节讨论的分发变换不同。本节中的所有变换都生成一个DataStream,而keyBy()会生成一个KeyedStream,可以在其中应用有关键状态访问的变换。

随机

随机数据交换策略由DataStream.shuffle()方法实现。该方法根据均匀分布随机分发记录给后续运算符的并行任务。

轮询

rebalance() 方法将输入流分区,以便以轮询方式均匀分发事件给后续任务。图 5-7 描述了轮询分发变换。

重新缩放

rescale() 方法还以轮询方式分发事件,但仅分发给某些后续任务。从本质上讲,重新缩放分区策略提供了一种在发送任务数和接收任务数不相同时执行轻量级负载重新平衡的方法。如果接收任务数是发送任务数的倍数或反之,则重新缩放变换更有效率。

rebalance()rescale() 之间的基本区别在于任务连接的形成方式。rebalance() 会在所有发送任务和所有接收任务之间创建通信通道,而 rescale() 只会从每个任务到下游运算符的一些任务之间创建通道。重新缩放分布变换的连接模式如 图 5-7 所示。

图 5-7. 重新平衡和重新缩放变换

广播

broadcast() 方法复制输入数据流,以便所有事件都发送到下游运算符的所有并行任务。

全局

global() 方法将输入数据流的所有事件发送到下游运算符的第一个并行任务。这种分区策略必须谨慎使用,因为将所有事件路由到同一个任务可能会影响应用程序的性能。

自定义

当预定义的分区策略都不适用时,可以使用 partitionCustom() 方法自定义分区策略。此方法接收一个实现分区逻辑并指定流应该分区的字段或键位置的 Partitioner 对象。以下示例将整数流分区,使所有负数被发送到第一个任务,而其他数字则随机发送到一个任务:

val numbers: DataStream[(Int)] = ...
numbers.partitionCustom(myPartitioner, 0)

object myPartitioner extends Partitioner[Int] {
  val r = scala.util.Random

  override def partition(key: Int, numPartitions: Int): Int = {
    if (key < 0) 0 else r.nextInt(numPartitions)
  }
}

设置并行性

Flink 应用在分布式环境中(如机器集群)并行执行。当一个 DataStream 程序提交给 JobManager 执行时,系统会创建数据流图并准备运算符进行执行。每个运算符会被并行化为一个或多个任务。每个任务会处理运算符输入流的一个子集。运算符的并行任务数称为运算符的并行度,它决定了运算符处理工作的分布程度和它能处理的数据量。

操作符的并行性可以在执行环境或每个单独操作符的级别上进行控制。默认情况下,一个应用程序的所有操作符的并行性都设置为应用程序执行环境的并行性。环境的并行性(因此也是所有操作符的默认并行性)会根据应用程序启动的上下文自动初始化。如果应用程序在本地执行环境中运行,则并行性设置为匹配 CPU 内核数。当将应用程序提交到正在运行的 Flink 集群时,环境并行性设置为集群的默认并行性,除非通过提交客户端明确指定(详见“运行和管理流应用程序”获取更多详细信息)。

一般来说,定义操作符的并行性相对于环境的默认并行性是个好主意。这样可以通过提交客户端调整并行性轻松扩展应用程序。您可以通过以下示例访问环境的默认并行性:

val env: StreamExecutionEnvironment.getExecutionEnvironment
// get default parallelism as configured in the cluster config or 
//   explicitly specified via the submission client.
val defaultP = env.env.getParallelism

您还可以覆盖环境的默认并行性,但将无法通过提交客户端控制应用程序的并行性:

val env: StreamExecutionEnvironment.getExecutionEnvironment
// set parallelism of the environment
env.setParallelism(32)

操作符的默认并行性可以通过显式指定来覆盖。在以下示例中,源操作符将以环境的默认并行性执行,映射转换将具有源的两倍任务数量,并且汇聚操作将始终由两个并行任务执行:

val env = StreamExecutionEnvironment.getExecutionEnvironment

// get default parallelism
val defaultP = env.getParallelism

// the source runs with the default parallelism
val result: = env.addSource(new CustomSource)
  // the map parallelism is set to double the default parallelism
  .map(new MyMapper).setParallelism(defaultP * 2)
  // the print sink parallelism is fixed to 2
  .print().setParallelism(2)

当您通过提交客户端提交应用程序并将并行性设置为 16 时,源将以并行性 16 运行,映射器将以 32 个任务运行,而汇聚将以 2 个任务运行。如果在本地环境中运行应用程序,例如在您的 IDE 上,在具有 8 个核心的机器上,源任务将以 8 个任务运行,映射器将以 16 个任务运行,而汇聚将以 2 个任务运行。

类型

Flink DataStream 应用程序处理表示为数据对象的事件流。函数使用数据对象调用并发出数据对象。在内部,Flink 需要能够处理这些对象。它们需要被序列化和反序列化以便通过网络传输或写入和读取状态后端、检查点和保存点。为了高效地完成这些操作,Flink 需要详细了解应用程序处理的数据类型。Flink 使用类型信息的概念来表示数据类型,并为每种数据类型生成特定的序列化器、反序列化器和比较器。

Flink 还提供了一种类型提取系统,分析函数的输入和返回类型以自动获取类型信息、序列化器和反序列化器。然而,在某些情况下,如 lambda 函数或泛型类型,需要显式提供类型信息来使应用程序正常工作或提高其性能。

在本节中,我们讨论了 Flink 支持的类型,如何为数据类型创建类型信息,以及如何在 Flink 的类型系统无法自动推断函数的返回类型时提供提示。

支持的数据类型

Flink 支持 Java 和 Scala 中所有常见的数据类型。最常用的类型可以分为以下几类:

  • 原始类型

  • Java 和 Scala 元组

  • Scala 的 case 类

  • 包括由 Apache Avro 生成的类在内的 POJOs

  • 一些特殊类型

对于没有特别处理的类型,将其视为通用类型,并使用Kryo 序列化框架对其进行序列化。

仅在备用解决方案中使用 Kryo

注意,如果可能的话,应该避免使用 Kryo。由于 Kryo 是一个通用序列化器,通常效率不是很高。Flink 提供配置选项,通过预注册类到 Kryo 来提高效率。此外,Kryo 没有提供良好的迁移路径来演变数据类型。

让我们看看每个类型类别。

原始类型

支持所有 Java 和 Scala 原始类型,例如Int(或 Java 中的Integer)、StringDouble。以下是处理Long值流并递增每个元素的示例:

val numbers: DataStream[Long] = env.fromElements(1L, 2L, 3L, 4L)
numbers.map( n => n + 1)

Java 和 Scala 元组

元组是由固定数量的类型字段组成的复合数据类型。

Scala DataStream API 使用常规的 Scala 元组。以下示例筛选具有两个字段的DataStream元组:

// DataStream of Tuple2[String, Integer] for Person(name, age)
val persons: DataStream[(String, Integer)] = env.fromElements(
  ("Adam", 17), 
  ("Sarah", 23))

// filter for persons of age > 18
persons.filter(p => p._2 > 18)

Flink 提供了高效的 Java 元组实现。Flink 的 Java 元组最多可以有 25 个字段,每个长度实现为一个单独的类——Tuple1Tuple2,直到Tuple25。元组类是强类型的。

我们可以将 Java DataStream API 中的过滤示例重写如下:

// DataStream of Tuple2<String, Integer> for Person(name, age)
DataStream<Tuple2<String, Integer>> persons = env.fromElements(
  Tuple2.of("Adam", 17),
  Tuple2.of("Sarah", 23));

// filter for persons of age > 18
persons.filter(p -> p.f1 > 18);

元组字段可以通过它们的公共字段名称如f0f1f2等(如前所示)或者通过使用getField(int pos)方法按位置访问,索引从 0 开始:

Tuple2<String, Integer> personTuple = Tuple2.of("Alex", "42");
Integer age = personTuple.getField(1); // age = 42

与它们的 Scala 对应物相比,Flink 的 Java 元组是可变的,因此可以重新分配字段的值。函数可以重用 Java 元组以减少垃圾收集器的压力。以下示例展示了如何更新 Java 元组的字段:

personTuple.f1 = 42;         // set the 2nd field to 42
personTuple.setField(43, 1); // set the 2nd field to 43

Scala 的 case 类

Flink 支持 Scala 的 case 类。案例类字段通过名称访问。以下是我们定义一个名为Person的 case 类,其中包含两个字段:nameage。至于元组,我们通过年龄筛选DataStream

case class Person(name: String, age: Int)

val persons: DataStream[Person] = env.fromElements(
  Person("Adam", 17), 
  Person("Sarah", 23))

// filter for persons with age > 18
persons.filter(p => p.age > 18)

POJOs

Flink 分析每个不属于任何类别的类型,并检查是否可以将其识别并作为 POJO 类型处理。如果满足以下条件,Flink 将接受一个类作为 POJO:

  • 它是一个公共类。

  • 它有一个公共构造函数,没有任何参数——默认构造函数。

  • 所有字段都是公共的或可以通过 getter 和 setter 访问。getter 和 setter 函数必须遵循默认命名方案,即对于类型为 Y 的字段 x,getter 为 Y getX(),setter 为 setX(Y x)

  • 所有字段的类型都受 Flink 支持。

例如,以下的 Java 类将被 Flink 标识为一个 POJO:

public class Person {
  // both fields are public
  public String name;
  public int age;

  // default constructor is present
  public Person() {}

  public Person(String name, int age) {
      this.name = name;
      this.age = age;
  }
}

DataStream<Person> persons = env.fromElements(
   new Person("Alex", 42),
   new Person("Wendy", 23)); 

由 Avro 生成的类会被 Flink 自动识别并作为 POJO 处理。

数组、列表、映射、枚举及其他特殊类型

Flink 支持多种特定用途的类型,如基本和对象 Array 类型;Java 的 ArrayListHashMapEnum 类型;以及 Hadoop 的 Writable 类型。此外,它还为 Scala 的 EitherOptionTry 类型提供类型信息,以及 Flink 的 Java 版本的 Either 类型。

为数据类型创建类型信息

Flink 类型系统的核心类是 TypeInformation。它为系统提供了生成序列化器和比较器所需的必要信息。例如,当你按键进行连接或分组时,TypeInformation 允许 Flink 执行语义检查,以确定用作键的字段是否有效。

当应用程序提交执行时,Flink 的类型系统尝试为框架处理的每种数据类型自动推导 TypeInformation。一个所谓的类型提取器分析所有函数的泛型类型和返回类型,以获取相应的 TypeInformation 对象。因此,你可能在不需要担心数据类型的 TypeInformation 的情况下使用 Flink 一段时间。然而,有时类型提取器会失败,或者你可能希望定义自己的类型并告诉 Flink 如何高效处理它们。在这种情况下,你需要为特定的数据类型生成 TypeInformation

Flink 为 Java 和 Scala 提供了两个实用程序类,其中包含静态方法来生成 TypeInformation。对于 Java,辅助类是 org.apache.flink.api.common.typeinfo.Types,并且如以下示例所示使用:

// TypeInformation for primitive types
TypeInformation<Integer> intType = Types.INT;

// TypeInformation for Java Tuples
TypeInformation<Tuple2<Long, String>> tupleType = 
  Types.TUPLE(Types.LONG, Types.STRING);

// TypeInformation for POJOs
TypeInformation<Person> personType = Types.POJO(Person.class);

对于 Scala API,TypeInformation 的辅助类是 org.apache.flink.api.scala.typeutils.Types,并且使用如下所示:

// TypeInformation for primitive types
val stringType: TypeInformation[String] = Types.STRING

// TypeInformation for Scala Tuples
val tupleType: TypeInformation[(Int, Long)] = Types.TUPLE[(Int, Long)]

// TypeInformation for case classes
val caseClassType: TypeInformation[Person] = Types.CASE_CLASS[Person]

Scala API 中的类型信息

在 Scala API 中,Flink 使用 Scala 编译器宏,在编译时为所有数据类型生成 TypeInformation 对象。要访问 createTypeInformation 宏函数,请确保始终向你的 Scala 应用程序添加以下导入语句:

import org.apache.flink.streaming.api.scala._

明确提供类型信息

在大多数情况下,Flink 可以自动推断类型并生成正确的TypeInformation。Flink 的类型提取器利用反射和分析函数签名和子类信息来推导用户定义函数的正确输出类型。不过,有时无法提取必要的信息(例如,由于 Java 擦除泛型类型信息)。此外,在某些情况下,Flink 可能不会选择生成最高效的序列化程序和反序列化程序的TypeInformation。因此,您可能需要为应用程序中使用的某些数据类型显式提供TypeInformation对象给 Flink。

有两种提供TypeInformation的方式。首先,您可以扩展函数类,通过实现ResultTypeQueryable接口来显式提供其返回类型的TypeInformation。以下示例显示了一个提供其返回类型的MapFunction

class Tuple2ToPersonMapper extends MapFunction[(String, Int), Person]
    with ResultTypeQueryable[Person] {

  override def map(v: (String, Int)): Person = Person(v._1, v._2)

  // provide the TypeInformation for the output data type
  override def getProducedType: TypeInformation[Person] = Types.CASE_CLASS[Person]
}

在 Java DataStream API 中,当定义数据流时,您还可以使用returns()方法显式指定操作符的返回类型,如下所示:

DataStream<Tuple2<String, Integer>> tuples = ...
DataStream<Person> persons = tuples
   .map(t -> new Person(t.f0, t.f1))
   // provide TypeInformation for the map lambda function's return type
   .returns(Types.POJO(Person.class));

定义键和引用字段

在前一节中看到的一些转换需要输入流类型上的关键规范或字段引用。在 Flink 中,键不像处理键值对的系统那样预定义在输入类型中。相反,键被定义为对输入数据的函数。因此,不需要定义用于保存键和值的数据类型,这避免了大量的样板代码。

在接下来的内容中,我们讨论了在数据类型上引用字段和定义键的不同方法。

字段位置

如果数据类型是元组,可以通过简单地使用相应元组元素的字段位置来定义键。以下示例将输入流按输入元组的第二个字段键入:

val input: DataStream[(Int, String, Long)] = ...
val keyed = input.keyBy(1)

还可以定义由多个元组字段组成的复合键。在这种情况下,位置将按顺序提供为列表。我们可以通过以下方式将输入流键入第二个和第三个字段:

val keyed2 = input.keyBy(1, 2)

字段表达式

另一种定义键和选择字段的方式是使用基于String的字段表达式。字段表达式适用于元组、POJO 和案例类。它们还支持选择嵌套字段。

在本章的入门示例中,我们定义了以下案例类:

case class SensorReading(
  id: String, 
  timestamp: Long, 
  temperature: Double)

要通过传递字段名idkeyBy()函数来键入传感器 ID 的流:

val sensorStream: DataStream[SensorReading] = ...
val keyedSensors = sensorStream.keyBy("id")

POJO 或案例类字段通过其字段名来选择,就像上面的示例中那样。元组字段则通过它们的字段名(Scala 元组为 1 偏移,Java 元组为 0 偏移)或它们的 0 偏移字段索引来引用:

val input: DataStream[(Int, String, Long)] = ...
val keyed1 = input.keyBy("2") // key by 3rd field
val keyed2 = input.keyBy("_1") // key by 1st field

DataStream<Tuple3<Integer, String, Long>> javaInput = ...
javaInput.keyBy("f2") // key Java tuple by 3rd field

在 POJO 和元组的嵌套字段中,通过使用“.”(句点字符)来表示嵌套级别来选择。考虑以下案例类:

case class Address(
  address: String, 
  zip: String
  country: String)

case class Person(
  name: String,
  birthday: (Int, Int, Int), // year, month, day
  address: Address)

如果我们想引用一个人的邮政编码,可以使用字段表达式:

val persons: DataStream[Person] = ...
persons.keyBy("address.zip") // key by nested POJO field

还可以在混合类型的表达式上进行嵌套表达式。以下表达式访问了嵌入在 POJO 中的元组字段:

persons.keyBy("birthday._1") // key by field of nested tuple

可以使用通配符字段表达式 "_"(下划线字符)选择完整的数据类型:

persons.keyBy("birthday._") // key by all fields of nested tuple

键选择器

第三种指定键的选项是 KeySelector 函数。KeySelector 函数从输入事件中提取键:

// T: the type of input elements
// KEY: the type of the key
KeySelector[IN, KEY]
  > getKey(IN): KEY

入门示例实际上在 keyBy() 方法中使用了一个简单的 KeySelector 函数:

val sensorData: DataStream[SensorReading] = ...
val byId: KeyedStream[SensorReading, String] = sensorData
  .keyBy(r => r.id)

KeySelector 函数接收输入项并返回一个键。该键不一定是输入事件的字段,可以使用任意计算派生。在下面的示例中,KeySelector 函数返回元组字段的最大值作为键:

val input : DataStream[(Int, Int)] = ...
val keyedStream = input.keyBy(value => math.max(value._1, value._2))

与字段位置和字段表达式相比,KeySelector 函数的一个优点是由于 KeySelector 类的泛型类型,生成的键是强类型的。

实现函数

在本章的代码示例中,你已经看到了用户定义函数的实际操作。在本节中,我们将更详细地解释在 DataStream API 中定义和参数化函数的不同方式。

函数类

Flink 为用户定义的所有函数接口(如 MapFunctionFilterFunctionProcessFunction)公开为接口或抽象类。

通过实现接口或扩展抽象类来实现函数。在以下示例中,我们实现了一个 FilterFunction,用于过滤包含字符串 "flink" 的字符串:

class FlinkFilter extends FilterFunction[String] {
  override def filter(value: String): Boolean = {
    value.contains("flink") 
  }
}

然后可以将函数类的实例作为参数传递给过滤转换:

val flinkTweets = tweets.filter(new FlinkFilter)

函数还可以实现为匿名类:

val flinkTweets = tweets.filter(
  new RichFilterFunction[String] {
    override def filter(value: String): Boolean = {
      value.contains("flink") 
    } 
  })

函数可以通过它们的构造函数接收参数。我们可以对上述示例进行参数化,并将 String "flink" 作为参数传递给 KeywordFilter 构造函数,如下所示:

val tweets: DataStream[String] = ???
val flinkTweets = tweets.filter(new KeywordFilter("flink"))

class KeywordFilter(keyWord: String) extends FilterFunction[String] {
  override def filter(value: String): Boolean = {
    value.contains(keyWord)
  }
}

当程序提交执行时,所有函数对象都使用 Java 序列化进行序列化,并发送到其对应操作符的所有并行任务。因此,所有配置值在对象反序列化后都得以保留。

函数必须是 Java 可序列化的

Flink 使用 Java 序列化序列化所有函数对象,以便将它们发送到工作进程。用户函数中包含的所有内容都必须是 Serializable

如果你的函数需要一个不可序列化的对象实例,你可以将其实现为富函数,并在 open() 方法中初始化不可序列化字段,或者重写 Java 的序列化和反序列化方法。

Lambda 函数

大多数 DataStream API 方法接受 Lambda 函数。Lambda 函数适用于 Scala 和 Java,并提供了一种简单而简洁的实现应用逻辑的方式,无需像访问状态和配置这样的高级操作。以下示例展示了一个过滤包含单词“flink”的 Lambda 函数:

val tweets: DataStream[String] = ...
// a filter lambda function that checks if tweets contains the word "flink"
val flinkTweets = tweets.filter(_.contains("flink"))

富函数

通常需要在函数处理第一条记录之前初始化函数或检索其执行上下文的信息。DataStream API 提供了富函数,它们比目前讨论的常规函数提供更多功能。

所有 DataStream API 转换函数都有富版本,并且您可以在可以使用常规函数或 lambda 函数的地方使用它们。富函数可以像常规函数类一样进行参数化。富函数的名称以 Rich 开头,后跟转换名称—RichMapFunctionRichFlatMapFunction 等。

当使用富函数时,可以实现两个额外的方法来扩展函数的生命周期:

  • open() 方法是富函数的初始化方法。在像 filter 或 map 这样的转换方法被调用之前,每个任务会调用一次 open() 方法。open() 通常用于只需执行一次的设置工作。请注意,Configuration 参数仅由 DataSet API 使用,而不由 DataStream API 使用。因此,应该忽略它。

  • close() 方法是函数的一个终结方法,它在转换方法的最后一次调用后每个任务调用一次。因此,通常用于清理和释放资源。

此外,getRuntimeContext() 方法提供对函数的 RuntimeContext 的访问。RuntimeContext 可用于检索信息,例如函数的并行性、其子任务索引以及执行函数的任务的名称。此外,它还包括用于访问分区状态的方法。在 Flink 中详细讨论了有状态流处理,可参考“实现有状态函数”。以下示例代码展示了如何使用 RichFlatMapFunction 的方法。示例 5-3 展示了 RichFlatMapFunction 的 open() 和 close() 方法。

示例 5-3. RichFlatMapFunction 的 open() 和 close() 方法。
class MyFlatMap extends RichFlatMapFunction[Int, (Int, Int)] {
  var subTaskIndex = 0

  override def open(configuration: Configuration): Unit = {
    subTaskIndex = getRuntimeContext.getIndexOfThisSubtask
    // do some initialization
    // e.g., establish a connection to an external system
  }

  override def flatMap(in: Int, out: Collector[(Int, Int)]): Unit = {
    // subtasks are 0-indexed
    if(in % 2 == subTaskIndex) {
      out.collect((subTaskIndex, in))
    }
    // do some more processing
  }

  override def close(): Unit = {
    // do some cleanup, e.g., close connections to external systems
  }
}

包括外部和 Flink 依赖

在实现 Flink 应用程序时,添加外部依赖是一个常见的需求。有许多流行的库可供选择,如 Apache Commons 或 Google Guava,用于各种用例。此外,大多数 Flink 应用程序依赖于一个或多个 Flink 连接器,用于从外部系统中摄取数据或将数据发出,如 Apache Kafka、文件系统或 Apache Cassandra。一些应用程序还利用 Flink 的领域特定库,如 Table API、SQL 或 CEP 库。因此,大多数 Flink 应用程序不仅依赖于 Flink 的 DataStream API 和 Java SDK,还依赖于额外的第三方和 Flink 内部依赖。

当应用程序执行时,必须将其所有依赖项可用于应用程序。默认情况下,Flink 集群仅加载核心 API 依赖项(DataStream 和 DataSet API)。应用程序需要的所有其他依赖项必须明确提供。

这样做的原因是为了保持默认依赖项的数量较少。⁴ 大多数连接器和库依赖于一个或多个库,这些库通常有几个额外的传递依赖项。这些经常包括常用库,如 Apache Commons 或 Google 的 Guava。许多问题源于从不同连接器或直接从用户应用程序中提取的同一库的不同版本之间的不兼容性。

有两种方法可以确保应用程序在执行时可用所有依赖项:

  1. 将所有依赖项捆绑到应用程序的 JAR 文件中。这将产生一个自包含但通常相当大的应用程序 JAR 文件。

  2. 一个依赖的 JAR 文件可以添加到 Flink 设置的 ./lib 文件夹中。在这种情况下,当 Flink 进程启动时,这些依赖项将加载到类路径中。像这样添加到类路径的依赖项可供运行在 Flink 设置上的所有应用程序使用,并可能会相互干扰。

构建所谓的 fat JAR 文件是处理应用程序依赖项的首选方法。我们在 “引导 Flink Maven 项目” 中介绍的 Flink Maven 原型生成的 Maven 项目配置为生成包含所有必需依赖项的应用程序 fat JAR 文件。默认情况下包含在 Flink 进程类路径中的依赖项将自动排除在 JAR 文件之外。生成的 Maven 项目的 pom.xml 文件包含了说明如何添加额外依赖项的注释。

摘要

在本章中,我们介绍了 Flink 的 DataStream API 的基础知识。我们研究了 Flink 程序的结构,并学习了如何组合数据和分区转换来构建流式应用程序。我们还探讨了支持的数据类型以及指定键和用户定义函数的不同方法。如果您退后一步再次阅读介绍性的例子,您希望对正在发生的事情有更好的理解。在 第六章 中,事情将变得更加有趣——我们将学习如何通过窗口操作符和时间语义丰富我们的程序。

¹ Flink 特有的基于时间的流连接操作符,已在 第六章 中讨论。本节讨论的连接转换和共函数更加通用。

² 您还可以将 CoProcessFunction 应用于 ConnectedStreams。我们在 第六章 中讨论了 CoProcessFunction

³ 详细内容请参见第八章关于键控状态的细节。

⁴ Flink 也旨在尽可能减少自己的外部依赖,并将大部分依赖(包括传递依赖)对用户应用程序隐藏起来,以防止版本冲突。

第六章:基于时间和窗口运算符

在本章中,我们将介绍用于时间处理和基于时间的运算符的 DataStream API 方法,例如窗口操作。正如您在 “时间语义” 中学到的,Flink 的基于时间的运算符可以使用不同的时间概念应用。

首先,我们将学习如何定义时间特性、时间戳和水印。接着,我们将介绍处理函数,这些低级转换函数提供时间戳和水印的访问,并能够注册定时器。接下来,我们将使用 Flink 的窗口 API,该 API 提供了最常见窗口类型的内置实现。您还将介绍自定义的用户定义窗口操作以及核心窗口构造,如分配器、触发器和逐出器。最后,我们将讨论如何在时间上对流进行连接,以及处理延迟事件的策略。

配置时间特性

要在分布式流处理应用程序中定义时间操作,理解时间的含义至关重要。当您指定一个窗口来收集每分钟的事件时,每个窗口将包含哪些确切的事件?在 DataStream API 中,您可以使用 时间特性 来告诉 Flink 在创建窗口时如何定义时间。时间特性是 StreamExecutionEnvironment 的属性,可以采用以下值:

ProcessingTime

指定操作符根据它们所执行的机器的系统时钟确定数据流的当前时间。处理时间窗口根据机器时间触发,并包含到达操作符的任何元素直到那个时间点为止。一般来说,使用处理时间进行窗口操作会导致非确定性结果,因为窗口的内容取决于元素到达的速度。这种设置提供非常低的延迟,因为处理任务无需等待水印推进事件时间。

EventTime

指定操作符通过使用数据本身的信息来确定当前时间。每个事件都携带一个时间戳,并且系统的逻辑时间由水印定义。正如您在 “时间戳” 中学到的,时间戳可以存在于进入数据处理管道之前的数据中,也可以由应用程序在源处分配。事件时间窗口在水印宣布接收到某个时间间隔的所有时间戳后触发。即使事件以无序方式到达,事件时间窗口也会计算确定性结果。窗口结果不会依赖于流的读取或处理速度。

IngestionTime

指定源操作符的处理时间为每个摄入记录的事件时间戳,并自动生成水印。它是 EventTimeProcessingTime 的混合体。事件的摄入时间是它进入流处理器的时间。与事件时间相比,摄入时间不提供确定性结果,并且与事件时间具有类似的性能。

Example 6-1 展示了如何通过重新访问你在 “Hello, Flink!” 中编写的传感器流应用程序代码来设置时间特征。

Example 6-1. 设置时间特征为事件时间
object AverageSensorReadings {

  // main() defines and executes the DataStream program
  def main(args: Array[String]) {
    // set up the streaming execution environment
    val env = StreamExecutionEnvironment.getExecutionEnvironment

    // use event time for the application
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

    // ingest sensor stream
    val sensorData: DataStream[SensorReading] = env.addSource(...)
  }
}

将时间特征设置为 EventTime 可以启用时间戳和水印处理,并因此支持事件时间操作。当然,如果选择 EventTime 时间特征,你仍然可以使用处理时间窗口和定时器。

为了使用处理时间,请将 TimeCharacteristic.EventTime 替换为 TimeCharacteristic.ProcessingTime

分配时间戳和生成水印

如 “事件时间处理” 中所讨论的,你的应用程序需要向 Flink 提供两个重要的信息,以便在事件时间中操作。每个事件必须与一个时间戳关联,通常表示事件实际发生的时间。事件时间流还需要携带水印,从中运算符推断当前事件时间。

时间戳和水印以自 1970-01-01T00:00:00Z 起的毫秒数指定。水印告知运算符不再预期时间戳小于或等于水印的事件。时间戳和水印可以由 SourceFunction 分配和生成,也可以使用显式的用户定义时间戳分配器和水印生成器。在 “源函数、时间戳和水印” 中讨论了在 SourceFunction 中分配时间戳和生成水印的方法。这里我们解释如何通过用户定义函数来实现这一点。

覆盖源生成的时间戳和水印

如果使用时间戳分配器,则会覆盖任何现有的时间戳和水印。

DataStream API 提供了 TimestampAssigner 接口,用于在流应用程序将元素摄取后从中提取时间戳。通常,时间戳分配器会在源函数之后立即调用,因为大多数分配器在生成水印时会对元素的时间戳顺序做出假设。由于元素通常是并行摄取的,任何导致 Flink 在并行流分区间重新分配元素的操作(如并行度变更、keyBy() 或其他显式重新分配操作),都会打乱元素的时间戳顺序。

最佳实践是尽可能在源处或甚至在 SourceFunction 内部分配时间戳并生成水印。根据用例,如果这些操作不会引起元素的重新分配,可以在分配时间戳之前对输入流进行初始过滤或转换。

为了确保事件时间操作的预期行为,应在任何事件时间依赖的转换之前调用分配器(例如,在第一个事件时间窗口之前)。

时间戳分配器的行为类似于其他转换操作符。它们在元素流上被调用,并产生一个新的时间戳元素流和水印流。时间戳分配器不会改变 DataStream 的数据类型。

示例 6-2 中的代码展示了如何使用时间戳分配器。在这个示例中,读取流之后,我们首先应用了一个过滤转换,然后调用了 assignTimestampsAndWatermarks() 方法,其中定义了时间戳分配器 MyAssigner()

示例 6-2. 使用时间戳分配器
val env = StreamExecutionEnvironment.getExecutionEnvironment

// set the event time characteristic
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

// ingest sensor stream
val readings: DataStream[SensorReading] = env
  .addSource(new SensorSource)
  // assign timestamps and generate watermarks
  .assignTimestampsAndWatermarks(new MyAssigner())

在上面的示例中,MyAssigner 可以是 AssignerWithPeriodicWatermarksAssignerWithPunctuatedWatermarks 类型之一。这两个接口都扩展了 DataStream API 提供的TimestampAssigner接口。第一个接口定义了定期发出水印的分配器,而第二个接口则根据输入事件的属性注入水印。接下来我们将详细描述这两个接口。

定期水印分配器

定期分配水印意味着我们指示系统在固定的机器时间间隔内发出水印并推进事件时间。默认间隔设置为两百毫秒,但可以使用 ExecutionConfig.setAutoWatermarkInterval() 方法进行配置:

val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// generate watermarks every 5 seconds
env.getConfig.setAutoWatermarkInterval(5000)

在前面的示例中,您指示程序每隔 5 秒发出水印。实际上,每隔 5 秒,Flink 调用 AssignerWithPeriodicWatermarksgetCurrentWatermark() 方法。如果该方法返回一个非空值且时间戳大于先前水印的时间戳,则会转发新的水印。这个检查是为了确保事件时间持续增加;否则不会产生水印。

示例 6-3 展示了一个定期时间戳分配器,它通过跟踪到目前为止看到的最大元素时间戳来生成水印。当要求生成新的水印时,分配器返回一个时间戳减去 1 分钟的容差区间的水印。

示例 6-3. 定期水印分配器
class PeriodicAssigner 
    extends AssignerWithPeriodicWatermarks[SensorReading] {

  val bound: Long = 60 * 1000     // 1 min in ms
  var maxTs: Long = Long.MinValue // the maximum observed timestamp

  override def getCurrentWatermark: Watermark = {
    // generated watermark with 1 min tolerance
    new Watermark(maxTs - bound)
  }

  override def extractTimestamp(
      r: SensorReading, 
      previousTS: Long): Long = {
    // update maximum timestamp
    maxTs = maxTs.max(r.timestamp)
    // return record timestamp
    r.timestamp
  }
}

DataStream API 提供了两种常见情况下时间戳分配器与周期性水印的实现。如果您的输入元素具有单调递增的时间戳,您可以使用快捷方法assignAscendingTimeStamps。该方法使用当前时间戳生成水印,因为不会出现早于当前时间戳的时间戳。以下是如何为单调递增时间戳生成水印的示例:

val stream: DataStream[SensorReading] = ...
val withTimestampsAndWatermarks = stream
  .assignAscendingTimestamps(e => e.timestamp)

周期性水印生成的另一个常见情况是当您知道输入流中会遇到的最大延迟时,即元素时间戳与先前摄取的所有元素的最大时间戳之间的最大差异。对于这种情况,Flink 提供了BoundedOutOfOrdernessTimeStampExtractor,它将预期的最大延迟作为参数:

val stream: DataStream[SensorReading] = ...
val output = stream.assignTimestampsAndWatermarks(
  new BoundedOutOfOrdernessTimestampExtractorSensorReading)(e =>.timestamp)

在前述代码中,允许元素最多延迟 10 秒。这意味着如果元素的事件时间与所有先前元素的最大时间戳之间的差大于 10 秒,则元素可能会在其相应计算完成并且结果已经发出后到达以进行处理。Flink 提供了不同的策略来处理此类延迟事件,我们将在“处理延迟数据”中进一步讨论这些策略。

带标点水印的分配器

有时输入流中包含指示流进度的特殊元组或标记。对于这种情况或基于输入元素其他属性定义水印的情况,Flink 提供了AssignerWithPunctuatedWatermarks接口。该接口定义了checkAndGetNextWatermark()方法,在extractTimestamp()后立即调用。该方法可以决定是否生成新的水印。如果该方法返回一个大于最新生成的水印的非空水印,则会发出新的水印。

示例 6-4 展示了一个带有标点水印分配器的例子,它对从 ID 为"sensor_1"的传感器接收到的每个读数都发出一个水印。

示例 6-4. 标点水印分配器
class PunctuatedAssigner 
    extends AssignerWithPunctuatedWatermarks[SensorReading] {

  val bound: Long = 60 * 1000 // 1 min in ms

  override def checkAndGetNextWatermark(
      r: SensorReading, 
      extractedTS: Long): Watermark = {
    if (r.id == "sensor_1") {
      // emit watermark if reading is from sensor_1
      new Watermark(extractedTS - bound)
    } else {
      // do not emit a watermark
      null
    }
  }

  override def extractTimestamp(
      r: SensorReading, 
      previousTS: Long): Long = {
    // assign record timestamp
    r.timestamp
  }
}

水印、延迟和完整性

到目前为止,我们讨论了如何使用TimestampAssigner生成水印。但我们还未讨论水印对流式应用程序的影响。

水印用于平衡延迟和结果完整性。它们控制在执行计算之前等待数据到达的时间,例如完成窗口计算并发出结果。基于事件时间的操作员使用水印来确定其摄取记录的完整性和操作的进度。根据接收到的水印,操作员计算一个时间点,该时间点是其预期已接收到相关输入记录的时间点。

然而,现实是我们永远无法拥有完美的水印,因为那意味着我们始终确定没有延迟记录。在实践中,您需要进行合理的猜测,并使用启发式方法在应用程序中生成水印。您需要利用有关源、网络和分区的任何信息来估计进度和输入记录的延迟上限。估计意味着可能会出现错误,如果出现这种情况,您可能会生成不准确的水印,导致数据延迟或应用程序延迟不必要地增加。有了这些想法,您可以使用水印来平衡结果的延迟和结果的完整性。

如果生成松散的水印——即水印远远落后于已处理记录的时间戳——则会增加生成结果的延迟。您可能本可以更早生成结果,但必须等待水印。此外,状态大小通常会增加,因为应用程序需要缓冲更多数据,直到可以执行计算。然而,当您执行计算时,可以相当确定所有相关数据都已经可用。

另一方面,如果生成非常紧密的水印——即水印可能比某些后续记录的时间戳更大——则可能会在所有相关数据到达之前执行基于时间的计算。虽然这可能导致不完整或不准确的结果,但结果会及时产生且延迟较低。

与围绕所有数据均可用的批处理应用程序不同,延迟/完整性的权衡是流处理应用程序的基本特征,这些应用程序按照数据到达的方式处理无限数据。水印是控制应用程序在时间方面行为的有效方式。除了水印之外,Flink 还有许多功能来调整基于时间操作的确切行为,例如进程函数和窗口触发器,并提供处理延迟数据的不同方法,这些方法在 “处理延迟数据” 中讨论。

进程函数

尽管时间信息和水印对许多流处理应用程序至关重要,但您可能已经注意到,通过我们到目前为止所看到的基本 DataStream API 转换,我们无法访问它们。例如,MapFunction 无法访问时间戳或当前事件时间。

DataStream API 提供了一系列低级别转换,即进程函数,这些函数还可以访问记录时间戳和水印,并注册在未来特定时间触发的定时器。此外,进程函数具有侧输出功能,可将记录发射到多个输出流。进程函数通常用于构建事件驱动的应用程序,并实现预定义窗口和转换不适用的自定义逻辑。例如,Flink SQL 的大多数操作符都是使用进程函数实现的。

目前,Flink 提供了八种不同的处理函数:ProcessFunctionKeyedProcessFunctionCoProcessFunctionProcessJoinFunctionBroadcastProcessFunctionKeyedBroadcastProcessFunctionProcessWindowFunctionProcessAllWindowFunction。正如它们的名字所示,这些函数适用于不同的上下文。然而,它们具有非常相似的功能集。我们将通过详细讨论 KeyedProcessFunction 来继续讨论这些共同的特性。

KeyedProcessFunction 是一个非常多功能的函数,可以应用于 KeyedStream。该函数对流的每条记录调用一次,并返回零个、一个或多个记录。所有处理函数都实现了 RichFunction 接口,因此提供了 open()close()getRuntimeContext() 方法。此外,KeyedProcessFunction[KEY, IN, OUT] 还提供以下两个方法:

  1. processElement(v: IN, ctx: Context, out: Collector[OUT]) 对流的每条记录调用一次。通常情况下,通过将记录传递给 Collector 来发射结果记录。Context 对象使得处理函数变得特殊。它提供了访问当前记录的时间戳和键以及 TimerService 的能力。此外,Context 还可以将记录发送到侧输出。

  2. onTimer(timestamp: Long, ctx: OnTimerContext, out: Collector[OUT]) 是一个回调函数,在先前注册的定时器触发时被调用。时间戳参数给出触发定时器的时间戳,而 Collector 允许发射记录。OnTimerContext 提供了与 processElement() 方法的 Context 对象相同的服务,并且还返回触发触发的时间域(处理时间或事件时间)。

TimerService 和 Timers

ContextOnTimerContext 对象的 TimerService 提供以下方法:

  • currentProcessingTime(): Long 返回当前的处理时间。

  • currentWatermark(): Long 返回当前水印的时间戳。

  • registerProcessingTimeTimer(timestamp: Long): Unit 为当前键注册一个处理时间定时器。当执行机器的处理时间达到提供的时间戳时,定时器将触发。

  • registerEventTimeTimer(timestamp: Long): Unit 为当前键注册一个事件时间定时器。当水印更新到等于或大于定时器的时间戳时,定时器将触发。

  • deleteProcessingTimeTimer(timestamp: Long): Unit 删除先前为当前键注册的处理时间定时器。如果不存在这样的定时器,则此方法不会产生任何效果。

  • deleteEventTimeTimer(timestamp: Long): Unit 删除先前为当前键注册的事件时间定时器。如果不存在这样的定时器,则此方法不会产生任何效果。

当定时器触发时,将调用 onTimer() 回调函数。processElement()onTimer() 方法是同步的,以防止对状态进行并发访问和操作。

非键控流上的定时器

定时器只能在键控流上注册。 定时器的常见用例是在某个键的一段不活动时间后清除键控状态或实现自定义基于时间的窗口逻辑。 要在非键控流上使用定时器,可以使用带有常量虚拟键的KeySelector创建一个键控流。 请注意,这将使所有数据移动到单个任务中,使操作符的实际并行度为 1。

对于每个键和时间戳,只能注册一个定时器,这意味着每个键可以有多个定时器,但每个时间戳只能有一个。 默认情况下,KeyedProcessFunction在堆上的优先队列中保存所有定时器的时间戳。 但是,您可以配置 RocksDB 状态后端来存储定时器。

定时器与函数的任何其他状态一起进行检查点。 如果应用程序需要从故障中恢复,则在应用程序重新启动时已过期的所有处理时间定时器将立即触发。 对于保存点中持久化的处理时间定时器也是如此。 定时器始终是异步检查点,有一个例外。 如果您正在使用 RocksDB 状态后端进行增量检查点并将定时器存储在堆上(默认设置),则会同步检查点它们。 在这种情况下,建议不要过度使用定时器,以避免长时间的检查点时间。

注意

已注册在过去时间戳的定时器不会被静默丢弃,而是会被处理。 处理时间定时器在注册方法返回后立即触发。 事件时间定时器在处理下一个水印时触发。

以下代码显示如何将KeyedProcessFunction应用于KeyedStream。 该函数监视传感器的温度,并在处理时间内传感器温度单调增加超过 1 秒的情况下发出警告:

val warnings = readings
  // key by sensor id
  .keyBy(_.id)
  // apply ProcessFunction to monitor temperatures
  .process(new TempIncreaseAlertFunction)

TempIncreaseAlterFunction的实现如 示例 6-5 所示。

示例 6-5。 如果传感器的处理时间温度单调增加了 1 秒,则发出警告的 KeyedProcessFunction
/** Emits a warning if the temperature of a sensor
  * monotonically increases for 1 second (in processing time).
  */
class TempIncreaseAlertFunction
    extends KeyedProcessFunction[String, SensorReading, String] {
  // stores temperature of last sensor reading
  lazy val lastTemp: ValueState[Double] = getRuntimeContext.getState(
      new ValueStateDescriptorDouble)
  // stores timestamp of currently active timer
  lazy val currentTimer: ValueState[Long] = getRuntimeContext.getState(
      new ValueStateDescriptorLong)

  override def processElement(
      r: SensorReading,
      ctx: KeyedProcessFunction[String, SensorReading, String]#Context,
      out: Collector[String]): Unit = {
    // get previous temperature
    val prevTemp = lastTemp.value()
    // update last temperature
    lastTemp.update(r.temperature)

    val curTimerTimestamp = currentTimer.value();
    if (prevTemp == 0.0 || r.temperature < prevTemp) {
      // temperature decreased; delete current timer
      ctx.timerService().deleteProcessingTimeTimer(curTimerTimestamp)
      currentTimer.clear()
    } else if (r.temperature > prevTemp && curTimerTimestamp == 0) {
      // temperature increased and we have not set a timer yet
      // set processing time timer for now + 1 second
      val timerTs = ctx.timerService().currentProcessingTime() + 1000
      ctx.timerService().registerProcessingTimeTimer(timerTs)
      // remember current timer
      currentTimer.update(timerTs)
    }
  }

  override def onTimer(
      ts: Long,
      ctx: KeyedProcessFunction[String, SensorReading, String]#OnTimerContext,
      out: Collector[String]): Unit = {
    out.collect("Temperature of sensor '" + ctx.getCurrentKey +
      "' monotonically increased for 1 second.")
    currentTimer.clear()
  }
}

发射到侧输出

DataStream API 的大多数操作符只有单个输出 - 它们生成一个具有特定数据类型的结果流。 仅分割操作符允许将流分割为具有相同类型的多个流。 侧输出是处理函数的功能,用于从具有可能具有不同类型的函数中发出多个流。 侧输出由OutputTag[X]对象标识,其中X是生成的侧输出流的类型。 处理函数可以通过Context对象将记录发射到一个或多个侧输出。

示例 6-6 显示如何通过ProcessFunction的侧输出DataStream发出数据。

示例 6-6。 应用一个 ProcessFunction,通过侧输出发出数据
val monitoredReadings: DataStream[SensorReading] = readings
  // monitor stream for readings with freezing temperatures
  .process(new FreezingMonitor)

// retrieve and print the freezing alarms side output
monitoredReadings
  .getSideOutput(new OutputTagString)
  .print()

// print the main output
readings.print()

示例 6-7 展示了 FreezingMonitor 函数,它监控传感器读数流,并对低于 32°F 的读数向侧输出发出警告。

示例 6-7. 一个将记录发送到侧输出的 ProcessFunction
/** Emits freezing alarms to a side output for readings 
 * with a temperature below 32F. */
class FreezingMonitor extends ProcessFunction[SensorReading, SensorReading] {

  // define a side output tag
  lazy val freezingAlarmOutput: OutputTag[String] =
    new OutputTagString

  override def processElement(
      r: SensorReading,
      ctx: ProcessFunction[SensorReading, SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // emit freezing alarm if temperature is below 32F
    if (r.temperature < 32.0) {
      ctx.output(freezingAlarmOutput, s"Freezing Alarm for ${r.id}")
    }
    // forward all readings to the regular output
    out.collect(r)
  }
}

CoProcessFunction

对于两个输入的低级操作,DataStream API 还提供了 CoProcessFunction。类似于 CoFlatMapFunctionCoProcessFunction 为每个输入提供了一个转换方法,即 processElement1()processElement2()。类似于 ProcessFunction,这两个方法都使用 Context 对象调用,该对象提供了对元素或计时器时间戳、TimerService 和侧输出的访问。CoProcessFunction 还提供了一个 onTimer() 回调方法。示例 6-8 展示了如何应用 CoProcessFunction 来合并两个流。

示例 6-8. 应用 CoProcessFunction
// ingest sensor stream
val sensorData: DataStream[SensorReading] = ...

// filter switches enable forwarding of readings
val filterSwitches: DataStream[(String, Long)] = env
  .fromCollection(Seq(
    ("sensor_2", 10 * 1000L), // forward sensor_2 for 10 seconds
    ("sensor_7", 60 * 1000L)) // forward sensor_7 for 1 minute
  )

val forwardedReadings = readings
  // connect readings and switches
  .connect(filterSwitches)
  // key by sensor ids
  .keyBy(_.id, _._1)
  // apply filtering CoProcessFunction
  .process(new ReadingFilter)

展示了一个 ReadingFilter 函数的实现,它根据过滤器开关流动态过滤传感器读数流。详见 示例 6-9。

示例 6-9. 实现一个动态过滤传感器读数流的 CoProcessFunction
class ReadingFilter
    extends CoProcessFunction[SensorReading, (String, Long), SensorReading] {

  // switch to enable forwarding
  lazy val forwardingEnabled: ValueState[Boolean] = getRuntimeContext.getState(
      new ValueStateDescriptorBoolean)

  // hold timestamp of currently active disable timer
  lazy val disableTimer: ValueState[Long] = getRuntimeContext.getState(
      new ValueStateDescriptorLong)

  override def processElement1(
      reading: SensorReading,
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // check if we may forward the reading
    if (forwardingEnabled.value()) {
      out.collect(reading)
    }
  }

  override def processElement2(
      switch: (String, Long),
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // enable reading forwarding
    forwardingEnabled.update(true)
    // set disable forward timer
    val timerTimestamp = ctx.timerService().currentProcessingTime() + switch._2
    val curTimerTimestamp = disableTimer.value()
      if (timerTimestamp > curTimerTimestamp) {
      // remove current timer and register new timer
      ctx.timerService().deleteEventTimeTimer(curTimerTimestamp)
      ctx.timerService().registerProcessingTimeTimer(timerTimestamp)
      disableTimer.update(timerTimestamp)
    }
  }

  override def onTimer(
      ts: Long,
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]
                            #OnTimerContext,
      out: Collector[SensorReading]): Unit = {
    // remove all state; forward switch will be false by default
    forwardingEnabled.clear()
    disableTimer.clear()
  }
}

窗口操作符

窗口在流式应用中是常见的操作。它们使得可以对无界流的有界间隔执行转换,如聚合操作。通常,这些间隔是使用基于时间的逻辑定义的。窗口操作符提供一种方法将事件分组为有限大小的桶,并在这些桶的有界内容上应用计算。例如,窗口操作符可以将流的事件分组为 5 分钟的窗口,并计算每个窗口收到了多少事件。

DataStream API 提供了最常见的窗口操作的内置方法,以及非常灵活的窗口机制来定义自定义的窗口逻辑。在本节中,我们将向您展示如何定义窗口操作符,介绍 DataStream API 的内置窗口类型,讨论可以应用于窗口的函数,最后解释如何定义自定义的窗口逻辑。

定义窗口操作符

窗口操作符可以应用于键控或非键控流。在键控窗口上评估窗口操作符是并行的,而非键控窗口在单个线程中处理。

要创建窗口操作符,您需要指定两个窗口组件:

  1. 窗口分配器 确定如何将输入流的元素分组到窗口中。窗口分配器生成一个 WindowedStream(或者如果应用于非键控 DataStream 则为 AllWindowedStream)。

  2. 窗口函数 应用于 WindowedStream(或 AllWindowedStream)并处理分配给窗口的元素。

以下代码显示了如何在键控或非键控流上指定窗口分配器和窗口函数:

// define a keyed window operator
stream
  .keyBy(...)                 
  .window(...)                   // specify the window assigner
  .reduce/aggregate/process(...) // specify the window function

// define a nonkeyed window-all operator
stream
  .windowAll(...)                // specify the window assigner
  .reduce/aggregate/process(...) // specify the window function

在本章的其余部分,我们仅关注键控窗口。非键控窗口(也称为所有窗口在 DataStream API 中)行为完全相同,除了它们收集所有数据并且不会并行评估。

注意

请注意,您可以通过提供自定义触发器或清除器,并声明处理延迟元素的策略来自定义窗口操作符。有关详细信息,请参见本节稍后讨论的自定义窗口操作符。

内置窗口分配器

Flink 为最常见的窗口使用情况提供了内置窗口分配器。我们在这里讨论的所有分配器都是基于时间的,并在“数据流操作”中介绍。基于时间的窗口分配器根据其事件时间戳或当前处理时间将元素分配到窗口。时间窗口具有开始和结束时间戳。

所有内置窗口分配器都提供默认触发器,一旦(处理或事件)时间超过窗口的结束时间,即触发窗口评估。重要的是要注意,窗口在第一个元素分配给它时被创建。Flink 永远不会评估空窗口。

基于计数的窗口

除了基于时间的窗口,Flink 还支持基于计数的窗口——窗口按照元素到达窗口操作符的顺序分组。由于它们依赖于摄入顺序,基于计数的窗口不是确定性的。此外,如果在某些时候不使用自定义触发器来丢弃不完整和过期的窗口,它们可能会导致问题。

Flink 的内置窗口分配器创建的窗口类型为TimeWindow。该窗口类型基本上表示两个时间戳之间的时间间隔,其中起始时间包含在内,结束时间不包含在内。它公开了检索窗口边界的方法,以检查窗口是否相交,并合并重叠窗口。

在下面,我们展示了 DataStream API 的不同内置窗口分配器及其如何用于定义窗口操作符。

滚动窗口

滚动窗口分配器将元素放入非重叠的固定大小窗口中,如图 6-1 所示。

滚动窗口分配器

图 6-1. 滚动窗口分配器将元素放入固定大小的非重叠窗口中

Datastream API 提供了两个分配器——TumblingEventTimeWindowsTumblingProcessingTimeWindows——用于滚动事件时间和处理时间窗口。滚动窗口分配器接收一个参数,即时间单位内的窗口大小;可以使用分配器的of(Time size)方法指定此参数。时间间隔可以设置为毫秒、秒、分钟、小时或天。

以下代码显示了如何在传感器数据测量流上定义事件时间和处理时间滚动窗口:

val sensorData: DataStream[SensorReading] = ...

val avgTemp = sensorData
  .keyBy(_.id)
   // group readings in 1s event-time windows
  .window(TumblingEventTimeWindows.of(Time.seconds(1)))
  .process(new TemperatureAverager)

val avgTemp = sensorData
  .keyBy(_.id)
   // group readings in 1s processing-time windows
  .window(TumblingProcessingTimeWindows.of(Time.seconds(1)))
  .process(new TemperatureAverager)

在我们的第一个 DataStream API 示例中,“数据流操作”(ch02.html#chap-2-ops-on-streams)中窗口定义有所不同。在那里,我们使用 timeWindow(size) 方法定义了一个事件时间滚动窗口,这是 window.(TumblingEventTimeWindows.of(size))window.(TumblingProcessingTimeWindows.of(size)) 的快捷方式,具体取决于配置的时间特征。以下代码展示了如何使用此快捷方式:

val avgTemp = sensorData
  .keyBy(_.id)
   // shortcut for window.(TumblingEventTimeWindows.of(size))
  .timeWindow(Time.seconds(1))
  .process(new TemperatureAverager)

默认情况下,滚动窗口与时代时间(1970-01-01-00:00:00.000)对齐。例如,大小为一小时的分配器将在 00:00:00、01:00:00、02:00:00 等时刻定义窗口。或者,您可以在分配器的第二个参数中指定偏移量。以下代码显示了具有 15 分钟偏移的窗口,从 00:15:00 开始,依次类推:

val avgTemp = sensorData
  .keyBy(_.id)
   // group readings in 1 hour windows with 15 min offset
  .window(TumblingEventTimeWindows.of(Time.hours(1), Time.minutes(15)))
  .process(new TemperatureAverager)

滑动窗口

滑动窗口分配器将元素分配到固定大小、以指定滑动间隔移动的窗口中,如图 6-2(#fig_sliding-assigner)所示。

滑动窗口分配器

图 6-2. 滑动窗口分配器将元素分配到固定大小、可能重叠的窗口中

对于滑动窗口,您需要指定窗口大小和滑动间隔,后者定义了新窗口启动的频率。当滑动间隔小于窗口大小时,窗口会重叠,元素可能会分配到多个窗口中。如果滑动间隔大于窗口大小,则有些元素可能不会被分配到任何窗口中,因此可能会被丢弃。

下面的代码展示了如何将传感器读数分组到大小为 1 小时、滑动间隔为 15 分钟的滑动窗口中。每个读数将被添加到四个窗口中。DataStream API 提供了事件时间和处理时间分配器,以及快捷方法,并且可以将时间间隔偏移量设置为窗口分配器的第三个参数:

// event-time sliding windows assigner
val slidingAvgTemp = sensorData
  .keyBy(_.id)
   // create 1h event-time windows every 15 minutes
  .window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(15)))
  .process(new TemperatureAverager)

// processing-time sliding windows assigner
val slidingAvgTemp = sensorData
  .keyBy(_.id)
   // create 1h processing-time windows every 15 minutes
  .window(SlidingProcessingTimeWindows.of(Time.hours(1), Time.minutes(15)))
  .process(new TemperatureAverager)

// sliding windows assigner using a shortcut method
val slidingAvgTemp = sensorData
  .keyBy(_.id)
   // shortcut for window.(SlidingEventTimeWindow.of(size, slide))
  .timeWindow(Time.hours(1), Time(minutes(15)))
  .process(new TemperatureAverager)

会话窗口

会话窗口分配器将元素分配到活动期间不重叠的窗口中。会话窗口的边界由不活动间隙定义,即在此期间未收到记录。图 6-3(#fig_session-windows)说明了如何将元素分配到会话窗口中。

会话窗口分配器

图 6-3. 会话窗口分配器将元素分配到由会话间隔定义的不同大小窗口中

下面的示例展示了如何将传感器读数分组到会话窗口中,其中每个会话由 15 分钟的不活动期定义:

// event-time session windows assigner
val sessionWindows = sensorData
  .keyBy(_.id)
   // create event-time session windows with a 15 min gap
  .window(EventTimeSessionWindows.withGap(Time.minutes(15)))
  .process(...)

// processing-time session windows assigner
val sessionWindows = sensorData
  .keyBy(_.id)
   // create processing-time session windows with a 15 min gap
  .window(ProcessingTimeSessionWindows.withGap(Time.minutes(15)))
  .process(...)

由于会话窗口的开始和结束取决于接收到的元素,窗口分配器无法立即将所有元素正确分配到窗口中。相反,SessionWindows 分配器最初将每个传入元素映射到具有元素时间戳作为起始时间和会话间隙作为窗口大小的窗口中。随后,它合并所有重叠范围的窗口。

在窗口上应用函数

窗口函数定义了在窗口元素上执行的计算。有两种类型的函数可以应用于窗口:

  1. 增量聚合函数是在窗口中添加元素时直接应用,并保持和更新一个单一值作为窗口状态。这些函数通常非常节省空间,并最终作为结果发出聚合值。ReduceFunctionAggregateFunction都是增量聚合函数。

  2. 完整窗口函数收集窗口中的所有元素,并在评估它们时对所有收集元素的列表进行迭代。完整窗口函数通常需要更多空间,但允许比增量聚合函数更复杂的逻辑。ProcessWindowFunction是一个完整窗口函数。

在本节中,我们讨论了可以应用于窗口上以执行聚合或窗口内容的任意计算的不同类型的函数。我们还展示了如何在窗口操作符中同时应用增量聚合和完整窗口函数。

ReduceFunction

当讨论键控流上的运行聚合时,“键控流转换”介绍了ReduceFunctionReduceFunction接受两个相同类型的值,并将它们组合成相同类型的单一值。当应用于窗口流时,ReduceFunction增量地聚合分配给窗口的元素。窗口仅存储聚合的当前结果——ReduceFunction输入(和输出)类型的单一值。当接收到新元素时,会用新元素和从窗口状态中读取的当前值调用ReduceFunction。窗口状态由ReduceFunction的结果替换。

在窗口上应用ReduceFunction的优点是每个窗口具有恒定且小的状态大小以及简单的函数接口。然而,ReduceFunction的应用通常受限且通常仅限于简单的聚合,因为输入和输出类型必须相同。

示例 6-10 显示了一个 lambda 减少函数,每 15 秒计算传感器的最低温度。

示例 6-10. 在 WindowedStream 上应用一个减少的 lambda 函数
val minTempPerWindow: DataStream[(String, Double)] = sensorData
  .map(r => (r.id, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(15))
  .reduce((r1, r2) => (r1._1, r1._2.min(r2._2)))

AggregateFunction

类似于ReduceFunctionAggregateFunction也逐渐应用于分配给窗口的元素。此外,带有AggregateFunction的窗口操作符的状态也包括单一值。

尽管AggregateFunction的接口更加灵活,但与ReduceFunction的接口相比,实现起来更为复杂。以下代码展示了AggregateFunction的接口:

public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {

  // create a new accumulator to start a new aggregate.
  ACC createAccumulator();

  // add an input element to the accumulator and return the accumulator.
  ACC add(IN value, ACC accumulator);

  // compute the result from the accumulator and return it.
  OUT getResult(ACC accumulator);

  // merge two accumulators and return the result.
  ACC merge(ACC a, ACC b);
}

该接口定义了输入类型IN,累加器类型ACC和结果类型OUT。与ReduceFunction不同,中间数据类型和输出类型不依赖于输入类型。

示例 6-11 展示了如何使用AggregateFunction来计算每个窗口传感器读数的平均温度。累加器维护了一个运行总和和计数,getResult()方法计算出平均值。

示例 6-11。在 WindowedStream 上应用 AggregateFunction
val avgTempPerWindow: DataStream[(String, Double)] = sensorData
  .map(r => (r.id, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(15))
  .aggregate(new AvgTempFunction)

// An AggregateFunction to compute the average tempeature per sensor.
// The accumulator holds the sum of temperatures and an event count.
class AvgTempFunction
    extends AggregateFunction
  [(String, Double), (String, Double, Int), (String, Double)] {

  override def createAccumulator() = {
    ("", 0.0, 0)
  }

  override def add(in: (String, Double), acc: (String, Double, Int)) = {
    (in._1, in._2 + acc._2, 1 + acc._3)
  }

  override def getResult(acc: (String, Double, Int)) = {
    (acc._1, acc._2 / acc._3)
  }

  override def merge(acc1: (String, Double, Int), acc2: (String, Double, Int)) = {
    (acc1._1, acc1._2 + acc2._2, acc1._3 + acc2._3)
  }
}

ProcessWindowFunction

ReduceFunctionAggregateFunction在分配给窗口的事件上逐步应用。然而,有时我们需要访问窗口中的所有元素来执行更复杂的计算,比如计算窗口中值的中位数或最频繁出现的值。对于这种应用,ReduceFunctionAggregateFunction都不适用。Flink 的 DataStream API 提供了ProcessWindowFunction来对窗口内容执行任意计算。

注意

Flink 1.7 的 DataStream API 功能中包含了WindowFunction接口。WindowFunction已经被ProcessWindowFunction取代,这里不再讨论。

以下代码展示了ProcessWindowFunction的接口:

public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> 
    extends AbstractRichFunction {

  // Evaluates the window
  void process(
    KEY key, Context ctx, Iterable<IN> vals, Collector<OUT> out) throws Exception;

  // Deletes any custom per-window state when the window is purged
  public void clear(Context ctx) throws Exception {}

  // The context holding window metadata
  public abstract class Context implements Serializable {

    // Returns the metadata of the window
    public abstract W window();

    // Returns the current processing time
    public abstract long currentProcessingTime();

    // Returns the current event-time watermark
    public abstract long currentWatermark();

    // State accessor for per-window state
    public abstract KeyedStateStore windowState();

    // State accessor for per-key global state
    public abstract KeyedStateStore globalState();

    // Emits a record to the side output identified by the OutputTag.
    public abstract <X> void output(OutputTag<X> outputTag, X value);
  }
}

process()方法以窗口的键、一个Iterable以访问窗口中的元素,并一个Collector以发出结果。此外,该方法有一个类似其他处理方法的Context参数。ProcessWindowFunctionContext对象提供了对窗口元数据、当前处理时间和水印、状态存储以管理每个窗口和每个键的全局状态,以及侧输出以发出记录的访问。

当介绍处理函数时,我们已经讨论了Context对象的一些特性,比如访问当前处理和事件时间以及侧输出。然而,ProcessWindowFunctionContext对象还提供了独特的特性。窗口的元数据通常包含可以用作窗口标识符的信息,比如时间窗口的开始和结束时间戳。

另一个特性是每个窗口和每个键的全局状态。全局状态是指不受限于任何窗口的键控状态,而每个窗口状态是指当前正在评估的窗口实例。每个窗口状态用于维护应在同一窗口上的process()方法的多次调用之间共享的信息,这可能是由于配置允许的延迟或使用自定义触发器而发生的。利用每个窗口状态的ProcessWindowFunction需要实现其clear()方法,在窗口被清除之前清理任何窗口特定状态。全局状态可用于在同一键上的多个窗口之间共享信息。

示例 6-12 将传感器读数流分组为 5 秒钟的滚动窗口,并使用ProcessWindowFunction计算窗口内发生的最低和最高温度。它为每个窗口发出一条记录,包括窗口的开始和结束时间戳以及最小和最大温度。

示例 6-12. 使用ProcessWindowFunction计算每个传感器和窗口的最小和最大温度
// output the lowest and highest temperature reading every 5 seconds
val minMaxTempPerWindow: DataStream[MinMaxTemp] = sensorData
  .keyBy(_.id)
  .timeWindow(Time.seconds(5))
  .process(new HighAndLowTempProcessFunction)

case class MinMaxTemp(id: String, min: Double, max:Double, endTs: Long)

/**
 * A ProcessWindowFunction that computes the lowest and highest temperature
 * reading per window and emits them together with the 
 * end timestamp of the window.
 */
class HighAndLowTempProcessFunction
    extends ProcessWindowFunction[SensorReading, MinMaxTemp, String, TimeWindow] {

  override def process(
      key: String,
      ctx: Context,
      vals: Iterable[SensorReading],
      out: Collector[MinMaxTemp]): Unit = {

    val temps = vals.map(_.temperature)
    val windowEnd = ctx.window.getEnd

    out.collect(MinMaxTemp(key, temps.min, temps.max, windowEnd))
  }
}

内部实现中,由ProcessWindowFunction评估的窗口将所有分配的事件存储在ListState中。¹ 通过收集所有事件并提供对窗口元数据和其他功能的访问,ProcessWindowFunction可以处理比ReduceFunctionAggregateFunction更多的使用情况。然而,收集所有事件的窗口状态可能会比增量聚合窗口的状态显著更大。

增量聚合和ProcessWindowFunction

ProcessWindowFunction是一个非常强大的窗口函数,但是你需要谨慎使用,因为它通常在状态中保存的数据比增量聚合函数要多。很多时候,需要在窗口上应用的逻辑可以表示为增量聚合,但它也需要访问窗口元数据或状态。

如果您有增量聚合逻辑,但也需要访问窗口元数据,可以将执行增量聚合的ReduceFunctionAggregateFunction与提供更多功能的ProcessWindowFunction结合使用。分配给窗口的元素将立即聚合,并且当窗口触发时,聚合结果将传递给ProcessWindowFunctionProcessWindowFunction.process()方法的Iterable参数将仅提供单个值,即增量聚合的结果。

在 DataStream API 中,可以通过在reduce()aggregate()方法的第二个参数中提供ProcessWindowFunction来实现,如下面的代码所示:

input
  .keyBy(...)
  .timeWindow(...)
  .reduce(
    incrAggregator: ReduceFunction[IN],
    function: ProcessWindowFunction[IN, OUT, K, W])

input
  .keyBy(...)
  .timeWindow(...)
  .aggregate(
    incrAggregator: AggregateFunction[IN, ACC, V],
    windowFunction: ProcessWindowFunction[V, OUT, K, W])

示例 6-13 和 6-14 中的代码展示了如何使用ReduceFunctionProcessWindowFunction组合解决与示例 6-12 中相同的用例,每 5 秒钟为每个传感器和每个窗口的最小和最大温度以及每个窗口的结束时间戳发出一次记录。

示例 6-13. 应用ReduceFunction进行增量聚合和ProcessWindowFunction进行窗口结果的最终化
case class MinMaxTemp(id: String, min: Double, max:Double, endTs: Long)

val minMaxTempPerWindow2: DataStream[MinMaxTemp] = sensorData
  .map(r => (r.id, r.temperature, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(5))
  .reduce(
    // incrementally compute min and max temperature
    (r1: (String, Double, Double), r2: (String, Double, Double)) => {
      (r1._1, r1._2.min(r2._2), r1._3.max(r2._3))
    },
    // finalize result in ProcessWindowFunction
    new AssignWindowEndProcessFunction()
  )

正如您在示例 6-13 中所看到的,ReduceFunctionProcessWindowFunction都在reduce()方法调用中定义。由于聚合是由ReduceFunction执行的,所以ProcessWindowFunction只需将窗口结束时间戳追加到增量计算的结果中,如示例 6-14 所示。

示例 6-14. 实现一个ProcessWindowFunction,将窗口结束时间戳分配给增量计算结果
class AssignWindowEndProcessFunction
  extends 
  ProcessWindowFunction[(String, Double, Double), MinMaxTemp, String, TimeWindow] {

  override def process(
      key: String,
      ctx: Context,
      minMaxIt: Iterable[(String, Double, Double)],
      out: Collector[MinMaxTemp]): Unit = {

    val minMax = minMaxIt.head
    val windowEnd = ctx.window.getEnd
    out.collect(MinMaxTemp(key, minMax._2, minMax._3, windowEnd))
  }
}

自定义窗口操作符

使用 Flink 内置窗口分配器定义的窗口操作符可以解决许多常见用例。然而,随着您开始编写更高级的流式应用程序,您可能会发现自己需要实现更复杂的窗口逻辑,例如发射早期结果并在遇到延迟元素时更新其结果的窗口,或者在接收特定记录时开始和结束窗口。

DataStream API 通过允许您实现自定义分配器、触发器和清除器的接口和方法,来定义自定义窗口操作符。除了先前讨论的窗口函数外,这些组件在窗口操作符中共同工作,以分组和处理窗口中的元素。

当元素到达窗口操作符时,它将被交给WindowAssigner。分配器确定元素需要路由到哪些窗口。如果窗口尚不存在,则会创建窗口。

如果窗口操作符配置了增量聚合函数,比如ReduceFunctionAggregateFunction,则新添加的元素会立即聚合,并将结果存储为窗口的内容。如果窗口操作符没有增量聚合函数,则新元素将附加到包含所有分配元素的ListState中。

每当将元素添加到窗口时,它也会传递给窗口的触发器。触发器定义窗口何时准备好进行评估以及何时清除窗口内容。触发器可以根据分配的元素或注册的计时器(类似于过程函数)决定在特定时间点评估或清除其窗口的内容。

触发器触发时发生的情况取决于窗口操作符的配置函数。如果操作符仅配置了增量聚合函数,则会发出当前的聚合结果。此情况如图 6-4 所示。

具有增量聚合函数的窗口操作符(窗口中的单个圆圈表示其聚合的窗口状态)

图 6-4. 具有增量聚合函数的窗口操作符(每个窗口中的单个圆圈表示其聚合的窗口状态)

如果操作符仅具有全窗口函数,则该函数将应用于窗口的所有元素,并将结果输出,如图 6-5 所示。

具有全窗口函数的窗口操作符(窗口中的圆圈表示其收集的原始输入记录)

图 6-5. 具有全窗口函数的窗口操作符(每个窗口中的圆圈表示其收集的原始输入记录)

最后,如果运算符具有增量聚合函数和全窗口函数,则全窗口函数应用于聚合值,并发出结果。图 6-6 描述了这种情况。

具有增量聚合和全窗口函数的窗口操作员(每个窗口中的单个圆表示其聚合窗口状态)

图 6-6. 具有增量聚合和全窗口函数的窗口操作员(每个窗口中的单个圆表示其聚合窗口状态)

逐出器是一个可选组件,可以在调用 ProcessWindowFunction 之前或之后注入。逐出器可以从窗口内容中删除收集的元素。由于它必须遍历所有元素,因此仅在未指定增量聚合函数时才能使用。

以下代码显示如何使用自定义触发器和逐出器定义窗口操作员:

stream
  .keyBy(...)
  .window(...)                   // specify the window assigner
 [.trigger(...)]                 // optional: specify the trigger
 [.evictor(...)]                 // optional: specify the evictor
  .reduce/aggregate/process(...) // specify the window function

虽然逐出器是可选组件,但每个窗口操作员都需要一个触发器来决定何时评估其窗口。为了提供简洁的窗口操作员 API,每个 WindowAssigner 都有一个默认触发器,除非明确定义了触发器。

注意

请注意,显式指定的触发器会覆盖现有触发器,而不是补充它——窗口将仅基于最后定义的触发器进行评估。

在接下来的章节中,我们讨论窗口的生命周期,并介绍定义自定义窗口分配器、触发器和逐出器的接口。

窗口生命周期

窗口操作员在处理传入流元素时创建并通常也删除窗口。如前所述,元素由 WindowAssigner 分配给窗口,触发器决定何时评估窗口,并且窗口函数执行实际的窗口评估。在本节中,我们讨论窗口的生命周期——创建时机、包含的信息以及删除时机。

WindowAssigner 将第一个元素分配给窗口时,窗口被创建。因此,没有至少一个元素的窗口。窗口由以下不同状态组成:

窗口内容

窗口内容包含已分配给窗口的元素或窗口操作员具有 ReduceFunctionAggregateFunction 时的增量聚合结果。

窗口对象

WindowAssigner 返回零、一个或多个窗口对象。窗口操作员根据返回的对象对元素进行分组。因此,窗口对象包含用于区分彼此的窗口的信息。每个窗口对象都有一个结束时间戳,定义了窗口及其状态可以被删除的时间点。

触发器的定时器

触发器可以注册定时器,在特定时间点回调,例如评估窗口或清除其内容。这些定时器由窗口操作员维护。

触发器中的自定义状态

触发器可以定义和使用自定义的每窗口和每键状态。这些状态完全由触发器控制,而不是由窗口操作符维护。

当窗口操作符的窗口结束时间(由窗口对象的结束时间戳定义)到达时,窗口操作符将删除窗口。这是根据WindowAssigner.isEventTime()方法返回的值来决定,无论是处理时间语义还是事件时间语义。

当删除窗口时,窗口操作符会自动清除窗口内容并丢弃窗口对象。不会清除自定义触发器状态和注册的触发器定时器,因为这些状态对窗口操作符来说是不透明的。因此,触发器必须在Trigger.clear()方法中清除所有状态,以防止状态泄漏。

窗口分配器

WindowAssigner确定每个到达的元素分配到哪些窗口。一个元素可以添加到零个、一个或多个窗口中。以下显示了WindowAssigner接口:

public abstract class WindowAssigner<T, W extends Window> 
    implements Serializable {

  // Returns a collection of windows to which the element is assigned
  public abstract Collection<W> assignWindows(
    T element, 
    long timestamp, 
    WindowAssignerContext context);

  // Returns the default Trigger of the WindowAssigner
  public abstract Trigger<T, W> getDefaultTrigger(
    StreamExecutionEnvironment env);

  // Returns the TypeSerializer for the windows of this WindowAssigner
  public abstract TypeSerializer<W> getWindowSerializer(
    ExecutionConfig executionConfig);

  // Indicates whether this assigner creates event-time windows
  public abstract boolean isEventTime();

  // A context that gives access to the current processing time
  public abstract static class WindowAssignerContext {

    // Returns the current processing time
    public abstract long getCurrentProcessingTime();
  }
}

WindowAssigner被分配给传入元素的类型和窗口的类型。如果未指定显式触发器,则还需要提供一个默认触发器。代码中的示例 6-15 创建了一个用于 30 秒滚动事件时间窗口的自定义分配器。

示例 6-15. 用于滚动事件时间窗口的窗口分配器
/** A custom window that groups events into 30-second tumbling windows. */
class ThirtySecondsWindows
    extends WindowAssigner[Object, TimeWindow] {

  val windowSize: Long = 30 * 1000L

  override def assignWindows(
      o: Object,
      ts: Long,
      ctx: WindowAssigner.WindowAssignerContext): java.util.List[TimeWindow] = {

    // rounding down by 30 seconds
    val startTime = ts - (ts % windowSize)
    val endTime = startTime + windowSize
    // emitting the corresponding time window
    Collections.singletonList(new TimeWindow(startTime, endTime))
  }

  override def getDefaultTrigger(
      env: environment.StreamExecutionEnvironment): Trigger[Object, TimeWindow] = {
    EventTimeTrigger.create()
  }

  override def getWindowSerializer(
      executionConfig: ExecutionConfig): TypeSerializer[TimeWindow] = {
    new TimeWindow.Serializer
  }

  override def isEventTime = true
}

全局窗口分配器

GlobalWindows分配器将所有元素映射到同一个全局窗口。它的默认触发器是NeverTrigger,顾名思义,永不触发。因此,GlobalWindows分配器需要一个自定义触发器,可能还需要一个驱逐器来有选择地从窗口状态中移除元素。

GlobalWindows的结束时间戳是Long.MAX_VALUE。因此,GlobalWindows永远不会完全清理。当应用于具有不断变化的键空间的KeyedStream时,GlobalWindows将为每个键维护一些状态。应谨慎使用它。

除了WindowAssigner接口外,还有MergingWindowAssigner接口,它扩展了WindowAssignerMergingWindowAssigner用于需要合并现有窗口的窗口操作符。例如,我们之前讨论过的EventTimeSessionWindows分配器,它通过为每个到达的元素创建一个新窗口,并在后续合并重叠窗口。

在合并窗口时,需要确保适当合并所有合并窗口及其触发器的状态。Trigger接口包含一个回调方法,在合并窗口时调用以合并与窗口关联的状态。窗口合并将在下一节中详细讨论。

触发器

触发器定义窗口何时被评估以及其结果何时被发出。触发器可以根据时间或数据特定条件的进度决定何时触发,例如元素计数或观察到的特定元素值。例如,前面讨论的时间窗口的默认触发器在处理时间或水印超过窗口结束边界的时间戳时触发。

触发器可以访问时间属性和定时器,并且可以与状态一起工作。因此,它们与流程函数一样强大。例如,您可以实现触发逻辑,当窗口接收到一定数量的元素时触发,当添加具有特定值的元素到窗口时触发,或者在添加的元素上检测到模式后触发,比如“5 秒内同一类型的两个事件”。还可以使用自定义触发器来计算和发出事件时间窗口的早期结果,即使在使用保守的水印策略时也能产生(不完整的)低延迟结果,这是一个常见的策略。

每次调用触发器时,它会产生一个TriggerResult,确定窗口应该发生什么。TriggerResult可以取以下值之一:

CONTINUE

不执行任何操作。

FIRE

如果窗口操作符具有ProcessWindowFunction,则调用该函数并发出结果。如果窗口仅具有增量聚合函数(ReduceFunctionAggregateFunction),则会发出当前聚合结果。窗口的状态不会改变。

PURGE

窗口的内容完全被丢弃,包括所有元数据的窗口也被移除。此外,会调用ProcessWindowFunction.clear()方法来清理所有自定义的每个窗口状态。

FIRE_AND_PURGE

FIRE_AND_PURGE:首先评估窗口(FIRE),然后移除所有状态和元数据(PURGE)。

可能的TriggerResult值使您能够实现复杂的窗口逻辑。自定义触发器可能会多次触发,计算新的或更新的结果,或者在满足某些条件时清除窗口而不发出结果。下一段代码展示了Trigger API:

public abstract class Trigger<T, W extends Window> implements Serializable {

  // Called for every element that gets added to a window
  TriggerResult onElement(
    T element, long timestamp, W window, TriggerContext ctx);

  // Called when a processing-time timer fires
  public abstract TriggerResult onProcessingTime(
    long timestamp, W window, TriggerContext ctx);

  // Called when an event-time timer fires
  public abstract TriggerResult onEventTime(
    long timestamp, W window, TriggerContext ctx);

  // Returns true if this trigger supports merging of trigger state
  public boolean canMerge();

  // Called when several windows have been merged into one window 
  // and the state of the triggers needs to be merged
  public void onMerge(W window, OnMergeContext ctx);

  // Clears any state that the trigger might hold for the given window
  // This method is called when a window is purged
  public abstract void clear(W window, TriggerContext ctx);
}

// A context object that is given to Trigger methods to allow them
// to register timer callbacks and deal with state
public interface TriggerContext {

  // Returns the current processing time
  long getCurrentProcessingTime();

  // Returns the current watermark time
  long getCurrentWatermark();

  // Registers a processing-time timer
  void registerProcessingTimeTimer(long time);

  // Registers an event-time timer
  void registerEventTimeTimer(long time);

  // Deletes a processing-time timer
  void deleteProcessingTimeTimer(long time);

  // Deletes an event-time timer
  void deleteEventTimeTimer(long time);

  // Retrieves a state object that is scoped to the window and the key of the trigger
  <S extends State> S getPartitionedState(StateDescriptor<S, ?> stateDescriptor);
}

// Extension of TriggerContext that is given to the Trigger.onMerge() method
public interface OnMergeContext extends TriggerContext {
  // Merges per-window state of the trigger
  // The state to be merged must support merging
  void mergePartitionedState(StateDescriptor<S, ?> stateDescriptor);
}

如您所见,通过提供对时间和状态的访问,触发器 API 可以用于实现复杂的逻辑。触发器有两个需要特别注意的方面:清理状态和合并触发器。

当在触发器中使用每个窗口状态时,需要确保在窗口删除时适当地删除此状态。否则,窗口操作符将随着时间的推移累积越来越多的状态,并且你的应用程序可能会在某个时刻失败。为了在删除窗口时清除所有状态,触发器的clear()方法需要移除所有自定义的每个窗口状态,并使用TriggerContext对象删除所有处理时间和事件时间计时器。不可能在定时器回调方法中清理状态,因为这些方法在窗口删除后不会被调用。

如果触发器与MergingWindowAssigner一起使用,则需要能够处理两个窗口合并的情况。在这种情况下,触发器的任何自定义状态也需要合并。canMerge()声明了一个触发器支持合并,而onMerge()方法需要实现执行合并的逻辑。如果触发器不支持合并,则不能与MergingWindowAssigner组合使用。

在合并触发器时,所有自定义状态的描述符都必须提供给OnMergeContext对象的mergePartitionedState()方法。

注意

请注意,可合并的触发器只能使用可以自动合并的状态原语——ListStateReduceStateAggregatingState

示例 6-16 展示了一个在窗口结束时间到达之前提前触发的触发器。当第一个事件分配给窗口时,触发器会注册一个定时器,提前 1 秒到达当前水印时间。当定时器触发时,会注册一个新的定时器。因此,触发器最多每秒触发一次。

示例 6-16. 早期触发器
/** A trigger that fires early. The trigger fires at most every second. */
class OneSecondIntervalTrigger
    extends Trigger[SensorReading, TimeWindow] {

  override def onElement(
      r: SensorReading,
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {

    // firstSeen will be false if not set yet
    val firstSeen: ValueState[Boolean] = ctx.getPartitionedState(
      new ValueStateDescriptorBoolean)

    // register initial timer only for first element
    if (!firstSeen.value()) {
      // compute time for next early firing by rounding watermark to second
      val t = ctx.getCurrentWatermark + (1000 - (ctx.getCurrentWatermark % 1000))
      ctx.registerEventTimeTimer(t)
      // register timer for the window end
      ctx.registerEventTimeTimer(window.getEnd)
      firstSeen.update(true)
    }
    // Continue. Do not evaluate per element
    TriggerResult.CONTINUE
  }

  override def onEventTime(
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {
    if (timestamp == window.getEnd) {
      // final evaluation and purge window state
      TriggerResult.FIRE_AND_PURGE
    } else {
      // register next early firing timer
      val t = ctx.getCurrentWatermark + (1000 - (ctx.getCurrentWatermark % 1000))
      if (t < window.getEnd) {
        ctx.registerEventTimeTimer(t)
      }
      // fire trigger to evaluate window
      TriggerResult.FIRE
    }
  }

  override def onProcessingTime(
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {
    // Continue. We don't use processing time timers
    TriggerResult.CONTINUE
  }

  override def clear(
      window: TimeWindow,
      ctx: Trigger.TriggerContext): Unit = {

    // clear trigger state
    val firstSeen: ValueState[Boolean] = ctx.getPartitionedState(
      new ValueStateDescriptorBoolean)
    firstSeen.clear()
  }
}

注意,触发器使用自定义状态,这些状态使用clear()方法清理。由于我们使用了简单的不可合并的ValueState,触发器是不可合并的。

驱逐者

Evictor是 Flink 窗口机制中的可选组件。它可以在窗口函数评估之前或之后从窗口中移除元素。

示例 6-17 展示了Evictor接口。

示例 6-17. 驱逐者接口
public interface Evictor<T, W extends Window> extends Serializable {

  // Optionally evicts elements. Called before windowing function.
  void evictBefore(
    Iterable<TimestampedValue<T>> elements, 
    int size, 
    W window, 
    EvictorContext evictorContext);

  // Optionally evicts elements. Called after windowing function.
  void evictAfter(
    Iterable<TimestampedValue<T>> elements, 
    int size, 
    W window, 
    EvictorContext evictorContext);

// A context object that is given to Evictor methods.
interface EvictorContext {

  // Returns the current processing time.
  long getCurrentProcessingTime();

  // Returns the current event time watermark.
  long getCurrentWatermark();
}

evictBefore()evictAfter() 方法分别在窗口函数应用于窗口内容之前和之后调用。这两种方法都使用一个Iterable,该Iterable提供了添加到窗口中的所有元素,窗口中的元素数量 (size),窗口对象,以及一个EvictorContext,该上下文提供了对当前处理时间和水印的访问。可以通过在从Iterable获取的Iterator上调用remove()方法来从窗口中移除元素。

预聚合和驱逐者

驱逐者在窗口中的元素列表上进行迭代。只有在窗口收集所有添加的事件并且不对窗口内容应用ReduceFunctionAggregateFunction以增量聚合窗口内容时,它们才能应用。

逐步清理器通常用于GlobalWindow,部分清理窗口状态而不是清除整个窗口状态。

根据时间连接流

在处理流时,一个常见的需求是连接或结合两个流的事件。Flink 的 DataStream API 提供了两个内置操作符来根据时间条件连接流:区间连接和窗口连接。本节将描述这两种操作符。

如果您无法使用 Flink 的内置连接操作符表达所需的连接语义,可以实现自定义连接逻辑,例如CoProcessFunctionBroadcastProcessFunctionKeyedBroadcastProcessFunction

注意

注意,您应设计这样一个操作符,具有高效的状态访问模式和有效的状态清理策略。

区间连接

区间连接将来自具有相同键和时间戳之间最多指定间隔的两个流的事件连接起来。

图 6-7 展示了两个流 A 和 B 的区间连接,如果 B 事件的时间戳不比 A 事件的时间戳早一个小时,并且不比 A 事件的时间戳晚 15 分钟,则将 A 事件与 B 事件连接。连接区间是对称的,即来自 B 的事件将与所有比 B 事件早不超过 15 分钟且最多晚一个小时的所有 A 事件连接。

区间连接

图 6-7. 一个区间连接将两个流 A 和 B 连接起来

目前,区间连接仅支持事件时间,并且使用INNER JOIN语义(没有匹配事件的事件将不会被转发)。区间连接定义如示例 6-18 所示。

示例 6-18. 使用区间连接
input1
  .keyBy(…)
  .between(<lower-bound>, <upper-bound>) // bounds with respect to input1
  .process(ProcessJoinFunction) // process pairs of matched events

成对连接的事件传递给ProcessJoinFunction。下界和上界定义为负数和正数时间间隔,例如,作为between(Time.hour(-1), Time.minute(15))。下界和上界可以任意选择,只要下界小于上界;您可以将所有 A 事件与所有 B 事件连接,其时间戳在 A 事件的时间之后一到两个小时之间。

区间连接需要缓冲来自一个或两个输入的记录。对于第一个输入,所有时间戳大于当前水印(上界)的记录都会被缓冲。对于第二个输入,所有时间戳大于当前水印加上下界的记录都会被缓冲。请注意,这两个边界可能是负数。在 图 6-7 中的连接存储了所有时间戳大于当前水印的 15 分钟流 A 记录,以及所有时间戳大于当前水印的一小时流 B 记录。您应注意,如果两个输入流的事件时间不同步,区间连接的存储需求可能显著增加,因为水印是由“较慢”的流确定的。

窗口连接

如其名称所示,窗口连接基于 Flink 的窗口机制。两个输入流的元素被分配到共同的窗口中,并在窗口完成时进行连接(或联合组合)。

示例 6-19 展示了如何定义窗口连接。

Example 6-19. 合并两个窗口流
input1.join(input2)
  .where(...)       // specify key attributes for input1
  .equalTo(...)     // specify key attributes for input2
  .window(...)      // specify the WindowAssigner
 [.trigger(...)]    // optional: specify a Trigger
 [.evictor(...)]    // optional: specify an Evictor
  .apply(...)       // specify the JoinFunction

图 6-8 展示了 DataStream API 的窗口连接如何工作。

图 6-8. 窗口连接的操作方式

两个输入流都以它们的键属性作为键,并且共同的窗口分配器将两个流的事件映射到共同的窗口,意味着一个窗口存储来自两个输入的事件。当窗口的计时器触发时,对于第一个和第二个输入的每一对元素组合,都会调用JoinFunction ——即交叉乘积。还可以指定自定义触发器和逐出器。由于两个流的事件被映射到相同的窗口中,触发器和逐出器的行为与常规窗口操作符完全相同。

除了合并两个流外,还可以通过使用coGroup()来对窗口中的两个流进行联合分组而不是使用join()来进行操作符定义。整体逻辑相同,但是不是为每对来自两个输入的事件调用JoinFunction,而是为每个窗口调用一次CoGroupFunction,该函数使用来自两个输入的迭代器。

应注意,连接窗口流可能具有意外的语义。例如,假设您使用配置了 1 小时滚动窗口的连接运算符加入两个流。第一个输入的元素与第二个输入的元素不会被连接,即使它们之间仅相隔 1 秒,但被分配到两个不同的窗口。

处理迟到数据

如前所述,水印可以用来平衡结果的完整性和结果的延迟性。除非选择一种非常保守的水印策略,以保证所有相关记录都将被包括,但这会带来很高的延迟,否则您的应用程序很可能需要处理迟到的元素。

迟到元素是指当一个计算需要其贡献时,它到达一个操作符。在事件时间窗口操作符的上下文中,如果一个事件到达操作符并且窗口分配器将其映射到一个已经计算完毕的窗口,那么该事件就是迟到的,因为操作符的水印超过了窗口的结束时间戳。

DataStream API 提供了处理迟到事件的不同选项:

  • 迟到事件可以简单地丢弃。

  • 迟到的事件可以重定向到一个单独的流。

  • 基于迟到事件可以更新计算结果,并且必须发出更新。

在接下来的部分,我们将详细讨论这些选项,并展示它们如何应用于处理函数和窗口操作符。

丢弃迟到事件

处理延迟事件最简单的方法是简单地丢弃它们。对于事件时间窗口操作符来说,丢弃延迟到达的元素是默认行为。因此,延迟到达的元素不会创建新的窗口。

通过将它们的时间戳与当前水印进行比较,一个处理函数可以轻松地过滤掉延迟事件。

重定向延迟事件

也可以使用侧输出功能将延迟事件重定向到另一个 DataStream 中。然后,可以使用常规的接收器函数处理或发出延迟事件。根据业务需求,延迟数据可以稍后通过周期性的回填过程集成到流应用程序的结果中。Example 6-20 展示了如何指定一个带有侧输出的窗口操作符来处理延迟事件。

Example 6-20. 定义一个带有侧输出的窗口操作符,用于处理延迟事件
val readings: DataStream[SensorReading] = ???

val countPer10Secs: DataStream[(String, Long, Int)] = readings
  .keyBy(_.id)
  .timeWindow(Time.seconds(10))
  // emit late readings to a side output
  .sideOutputLateData(new OutputTagSensorReading)
  // count readings per window
  .process(new CountFunction())

// retrieve the late events from the side output as a stream
val lateStream: DataStream[SensorReading] = countPer10Secs
  .getSideOutput(new OutputTagSensorReading)

一个处理函数可以通过比较事件时间戳与当前水印来识别延迟事件,并使用常规侧输出 API 将它们发出。Example 6-21 展示了一个 ProcessFunction,从其输入中过滤掉延迟传感器读数并将其重定向到侧输出流。

Example 6-21. 一个 ProcessFunction,用于过滤掉延迟传感器读数并将其重定向到侧输出
val readings: DataStream[SensorReading] = ???
val filteredReadings: DataStream[SensorReading] = readings
  .process(new LateReadingsFilter)

// retrieve late readings
val lateReadings: DataStream[SensorReading] = filteredReadings
  .getSideOutput(new OutputTagSensorReading)

/** A ProcessFunction that filters out late sensor readings and 
 * re-directs them to a side output */
class LateReadingsFilter 
    extends ProcessFunction[SensorReading, SensorReading] {

  val lateReadingsOut = new OutputTagSensorReading

  override def processElement(
      r: SensorReading,
      ctx: ProcessFunction[SensorReading, SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {

    // compare record timestamp with current watermark
    if (r.timestamp < ctx.timerService().currentWatermark()) {
      // this is a late reading => redirect it to the side output
      ctx.output(lateReadingsOut, r)
    } else {
      out.collect(r)
    }
  }
}

包含延迟事件的更新结果

延迟事件在计算完成后到达操作符。因此,操作符会发出一个不完整或不准确的结果。与其丢弃或重定向延迟事件,另一种策略是重新计算不完整的结果并发出更新。然而,为了能够重新计算和更新结果,需要考虑几个问题。

一个支持重新计算和更新已发出结果的操作符,在第一个结果被发出后需要保留所有必要的状态用于计算。然而,由于通常不可能永久保留所有状态,它需要在某个时刻清除状态。一旦为某个结果清除了状态,该结果就不能再更新,延迟事件只能被丢弃或重定向。

除了保留状态之外,下游操作符或跟随操作符的外部系统,需要能够处理先前发出结果的更新。例如,将键控窗口操作符的结果和更新写入键值存储的接收器操作符可以通过使用 upsert 写操作覆盖不准确的结果以进行处理。对于某些用例,还可能需要区分第一个结果和由于延迟事件导致的更新。

窗口操作符 API 提供了一个方法来明确声明您期望的延迟元素。在使用事件时间窗口时,您可以指定一个额外的时间段称为 允许的延迟。具有允许延迟的窗口操作符在水印通过窗口的结束时间戳后仍不会删除窗口及其状态。相反,操作符将继续维护完整的窗口,直到允许延迟期间。当延迟元素在允许延迟期间到达时,它将像及时元素一样被处理并传递给触发器。当水印通过窗口的结束时间戳加上延迟间隔时,窗口最终被删除,并且所有后续的延迟元素都被丢弃。

允许延迟可以使用 allowedLateness() 方法来指定,如 示例 6-22 所示。

示例 6-22. 定义一个允许延迟 5 秒的窗口操作符。
val readings: DataStream[SensorReading] = ???

val countPer10Secs: DataStream[(String, Long, Int, String)] = readings
  .keyBy(_.id)
  .timeWindow(Time.seconds(10))
  // process late readings for 5 additional seconds
  .allowedLateness(Time.seconds(5))
  // count readings and update results if late readings arrive
  .process(new UpdatingWindowCountFunction)

/** A counting WindowProcessFunction that distinguishes between 
 * first results and updates. */
class UpdatingWindowCountFunction
    extends ProcessWindowFunction[
            SensorReading, (String, Long, Int, String), String, TimeWindow] {

  override def process(
      id: String,
      ctx: Context,
      elements: Iterable[SensorReading],
      out: Collector[(String, Long, Int, String)]): Unit = {

    // count the number of readings
    val cnt = elements.count(_ => true)

    // state to check if this is the first evaluation of the window or not
    val isUpdate = ctx.windowState.getState(
      new ValueStateDescriptorBoolean)

    if (!isUpdate.value()) {
      // first evaluation, emit first result
      out.collect((id, ctx.window.getEnd, cnt, "first"))
      isUpdate.update(true)
    } else {
      // not the first evaluation, emit an update
      out.collect((id, ctx.window.getEnd, cnt, "update"))
    }
  }
}

进程函数也可以实现以支持延迟数据。由于状态管理在进程函数中始终是自定义和手动完成的,因此 Flink 不提供内置的 API 来支持延迟数据。相反,您可以使用记录时间戳、水印和计时器的基本构建块来实现必要的逻辑。

总结

在本章中,您学习了如何实现基于时间的流应用程序。我们解释了如何配置流应用程序的时间特性以及如何分配时间戳和水印。您了解了基于时间的操作符,包括 Flink 的处理函数、内置窗口和自定义窗口。我们还讨论了水印的语义,如何权衡结果的完整性和延迟性,以及处理延迟事件的策略。

¹ ListState 和它的性能特征在 第七章 中有详细讨论。

第七章:有状态操作符和应用程序

有状态操作符和用户函数是流处理应用程序的常见构建块。事实上,大多数复杂的操作需要记忆记录或部分结果,因为数据是流式传输并随时间到达。¹ Flink 的许多内置 DataStream 操作符、源和接收器都是有状态的,它们缓冲记录或维护部分结果或元数据。例如,窗口操作符收集输入记录以供ProcessWindowFunction处理,或者应用ReduceFunction后的结果,ProcessFunction记忆已安排的计时器,某些接收器函数维护关于事务的状态以提供精确一次性功能。除了内置操作符和提供的源和接收器外,Flink 的 DataStream API 还公开了接口以注册、维护和访问用户定义函数中的状态。

有状态流处理对流处理器的许多方面都有影响,比如故障恢复、内存管理以及流应用程序的维护。第二章和第三章分别讨论了有状态流处理的基础以及 Flink 架构的相关细节。第九章解释了如何设置和配置 Flink 以可靠地处理有状态应用程序。第十章提供了关于如何操作有状态应用程序的指导——如何从应用程序保存点中进行取值和恢复、应用程序的重新缩放以及应用程序升级。

本章重点讨论有状态用户定义函数的实现,并讨论有状态应用的性能和健壮性。具体来说,我们解释了如何在用户定义函数中定义和操作不同类型的状态。我们还讨论了性能方面的问题,以及如何控制函数状态的大小。最后,我们展示了如何将键控状态配置为可查询状态,并如何从外部应用程序访问它。

实现有状态函数

在“状态管理”中,我们解释了函数可以具有两种类型的状态,即键控状态和操作符状态。Flink 提供了多个接口来定义有状态函数。在本节中,我们展示了如何实现具有键控状态和操作符状态的函数。

在 RuntimeContext 声明键控状态

用户函数可以使用键控状态来存储和访问具有键属性上下文中的状态。对于键属性的每个不同值,Flink 维护一个状态实例。函数的键控状态实例分布在函数操作符的所有并行任务之间。这意味着函数的每个并行实例负责键域的一个子范围,并维护相应的状态实例。因此,键控状态非常类似于分布式键值映射。请参阅“状态管理”获取有关键控状态的更多详细信息。

只有应用于 KeyedStream 的函数才能使用键控状态。KeyedStream 是通过调用 DataStream.keyBy() 方法构建的,该方法在流上定义一个键。KeyedStream 根据指定的键进行分区,并记住键的定义。应用于 KeyedStream 上的操作符是在其键定义的上下文中应用的。

Flink 提供了多个用于键控状态的原语。状态原语定义了单个键的状态结构。选择正确的状态原语取决于函数与状态的交互方式。选择还会影响函数的性能,因为每个状态后端为这些原语提供了自己的实现。Flink 支持以下状态原语:

  • ValueState[T] 持有类型为 T 的单个值。可以使用 ValueState.value() 读取该值,并使用 ValueState.update(value: T) 进行更新。

  • ListState[T] 持有类型为 T 的元素列表。可以通过调用 ListState.add(value: T)ListState.addAll(values: java.util.List[T]) 将新元素追加到列表中。可以通过调用 ListState.get() 来访问状态元素,它返回一个覆盖所有状态元素的 Iterable[T]。不可能从 ListState 中删除单个元素,但可以通过调用 ListState.update(values: java.util.List[T]) 来更新列表。调用此方法将用给定的值列表替换现有的值。

  • MapState[K, V] 持有键值对的映射。该状态原语提供了许多类似于常规 Java Map 的方法,如 get(key: K)put(key: K, value: V)contains(key: K)remove(key: K),以及对包含的条目、键和值进行迭代的方法。

  • ReducingState[T] 提供了与 ListState[T] 相同的方法(除了 addAll()update()),但不是将值追加到列表中,而是立即使用 ReduceFunction 对值进行聚合。get() 返回的迭代器返回一个包含单个条目的 Iterable,即被减少的值。

  • AggregatingState[I, O] 的行为类似于 ReducingState。但它使用更通用的 AggregateFunction 来聚合值。AggregatingState.get() 计算最终结果并将其作为包含单个元素的 Iterable 返回。

所有状态原语都可以通过调用 State.clear() 来清除。

示例 7-1 展示了如何在传感器测量流中应用带键 ValueStateFlatMapFunction。该示例应用程序在传感器测量的温度与上次测量相比发生变化超过阈值时,发出警报事件。

示例 7-1. 使用带键 ValueStateFlatMapFunction 的应用
val sensorData: DataStream[SensorReading]  = ???
// partition and key the stream on the sensor ID
val keyedData: KeyedStream[SensorReading, String] = sensorData
  .keyBy(_.id)

// apply a stateful FlatMapFunction on the keyed stream which 
// compares the temperature readings and raises alerts
val alerts: DataStream[(String, Double, Double)] = keyedData
  .flatMap(new TemperatureAlertFunction(1.7))

带键状态的函数必须应用于KeyedStream。我们需要在应用函数之前在输入流上调用keyBy()来指定键。当调用带键输入函数的处理方法时,Flink 的运行时会自动将函数的所有带键状态对象放入由函数调用传递的记录键的上下文中。因此,函数只能访问当前处理的记录所属的状态。

示例 7-2 展示了一个带键的ValueStateFlatMapFunction的实现,它检查测量温度是否超过配置的阈值变化。

示例 7-2. 实现带键的ValueStateFlatMapFunction
class TemperatureAlertFunction(val threshold: Double)
    extends RichFlatMapFunction[SensorReading, (String, Double, Double)] {

  // the state handle object
  private var lastTempState: ValueState[Double] = _

  override def open(parameters: Configuration): Unit = {
    // create state descriptor
    val lastTempDescriptor = 
      new ValueStateDescriptorDouble
    // obtain the state handle
    lastTempState = getRuntimeContext.getStateDouble
  }

  override def flatMap(
      reading: SensorReading, 
      out: Collector[(String, Double, Double)]): Unit = {
    // fetch the last temperature from state
    val lastTemp = lastTempState.value()
    // check if we need to emit an alert
    val tempDiff = (reading.temperature - lastTemp).abs
    if (tempDiff > threshold) {
      // temperature changed by more than the threshold
      out.collect((reading.id, reading.temperature, tempDiff))
    }
    // update lastTemp state
    this.lastTempState.update(reading.temperature)
  }
}

要创建状态对象,我们必须通过RuntimeContext在 Flink 的运行时中注册一个StateDescriptor,这是由RichFunction公开的(参见“实现函数”讨论RichFunction接口)。StateDescriptor特定于状态原语,并包括状态的名称和数据类型。对于ReducingStateAggregatingState,描述符还需要一个ReduceFunctionAggregateFunction对象来聚合添加的值。状态名称在运算符范围内,因此函数可以通过注册多个状态描述符拥有多个状态对象。状态处理的数据类型被指定为ClassTypeInformation对象(参见“类型”讨论 Flink 的类型处理)。必须指定数据类型,因为 Flink 需要创建合适的序列化器。另外,还可以显式指定TypeSerializer来控制如何将状态写入状态后端、检查点和保存点。²

通常,状态句柄对象在RichFunctionopen()方法中创建。在调用任何处理方法之前调用open(),例如在FlatMapFunction的情况下调用flatMap()。状态句柄对象(在示例 7-2 中的lastTempState)是函数类的常规成员变量。

注意

状态句柄对象仅提供对状态的访问,该状态存储在状态后端中并进行维护。句柄本身不持有状态。

当函数注册StateDescriptor时,Flink 会检查状态后端是否有函数和给定名称和类型的状态数据。如果状态功能由于故障而重新启动或者从保存点启动应用程序,则可能会发生这种情况。在这两种情况下,Flink 将新注册的状态句柄对象链接到现有状态。如果状态后端不包含给定描述符的状态,则链接到句柄的状态将初始化为空。

Scala DataStream API 提供了语法快捷方式,以单个ValueState定义mapflatMap函数。示例 7-3 展示了如何使用快捷方式实现前面的示例。

示例 7-3. Scala DataStream API 中的 FlatMap 与键控 ValueState 的快捷方式
val alerts: DataStream[(String, Double, Double)] = keyedData
  .flatMapWithState[(String, Double, Double), Double] {
    case (in: SensorReading, None) =>
      // no previous temperature defined; just update the last temperature
      (List.empty, Some(in.temperature))
    case (r: SensorReading, lastTemp: Some[Double]) =>
      // compare temperature difference with threshold
      val tempDiff = (r.temperature - lastTemp.get).abs
      if (tempDiff > 1.7) {
        // threshold exceeded; emit an alert and update the last temperature
        (List((r.id, r.temperature, tempDiff)), Some(r.temperature))
      } else {
        // threshold not exceeded; just update the last temperature
        (List.empty, Some(r.temperature))
      }
  }

flatMapWithState()方法期望一个接受Tuple2的函数。元组的第一个字段保存了传递给flatMap的输入记录,第二个字段保存了处理记录的键的检索状态的Option。如果状态尚未初始化,Option未定义。该函数还返回一个Tuple2。第一个字段是flatMap结果的列表,第二个字段是状态的新值。

使用ListCheckpointed接口实现操作符列表状态

操作符状态针对操作符的每个并行实例进行管理。在操作符的同一并行任务中处理的所有事件都可以访问相同的状态。在“状态管理”中,我们讨论了 Flink 支持三种类型的操作符状态:列表状态、列表联合状态和广播状态。

实现函数可以通过实现ListCheckpointed接口来处理操作符列表状态。ListCheckpointed接口不处理像ValueStateListState这样在状态后端注册的状态句柄。相反,函数将操作符状态实现为常规成员变量,并通过ListCheckpointed接口的回调函数与状态后端交互。该接口提供两个方法:

// returns a snapshot the state of the function as a list
snapshotState(checkpointId: Long, timestamp: Long): java.util.List[T]

// restores the state of the function from the provided list
restoreState(java.util.List[T] state): Unit

snapshotState()方法在 Flink 触发有状态函数的检查点时被调用。该方法有两个参数,checkpointId是检查点的唯一、单调递增标识符,timestamp是主节点启动检查点时的墙钟时间。该方法必须返回操作符状态作为可序列化状态对象的列表。

restoreState()方法在函数的状态需要初始化时总是被调用——当作业启动时(从保存点或其他),或者在发生故障时。该方法使用状态对象列表被调用,并且必须基于这些对象来恢复操作符的状态。

示例 7-4 展示了如何为函数实现ListCheckpointed接口,以计算每个分区中超过阈值的温度测量次数,对于函数的每个并行实例。

示例 7-4. 具有操作符列表状态的 RichFlatMapFunction
class HighTempCounter(val threshold: Double)
    extends RichFlatMapFunction[SensorReading, (Int, Long)]
    with ListCheckpointed[java.lang.Long] {

  // index of the subtask
  private lazy val subtaskIdx = getRuntimeContext
    .getIndexOfThisSubtask
  // local count variable
  private var highTempCnt = 0L

  override def flatMap(
      in: SensorReading, 
      out: Collector[(Int, Long)]): Unit = {
    if (in.temperature > threshold) {
      // increment counter if threshold is exceeded
      highTempCnt += 1
      // emit update with subtask index and counter
      out.collect((subtaskIdx, highTempCnt))
    }
  }

  override def restoreState(
      state: util.List[java.lang.Long]): Unit = {
    highTempCnt = 0
    // restore state by adding all longs of the list
    for (cnt <- state.asScala) {
      highTempCnt += cnt
    }
  }

  override def snapshotState(
      chkpntId: Long, 
      ts: Long): java.util.List[java.lang.Long] = {
    // snapshot state as list with a single count
    java.util.Collections.singletonList(highTempCnt)
  }
}

上述示例中的函数计算每个并行实例中超过配置阈值的温度测量次数。该函数使用操作状态,并为每个并行操作符实例具有一个单一的状态变量,该变量通过ListCheckpointed接口的方法进行检查点和恢复。请注意,ListCheckpointed接口在 Java 中实现,并期望使用java.util.List而不是 Scala 本地列表。

查看示例时,您可能会想知道为什么操作状态被处理为状态对象列表。如“Scaling Stateful Operators”中所讨论的那样,列表结构支持具有操作状态的函数的并行度变化。为了增加或减少具有操作状态函数的并行性,需要将操作状态重新分发到较大或较小数量的任务实例。这需要拆分或合并状态对象。由于拆分和合并状态的逻辑对于每个有状态功能都是自定义的,因此无法自动执行任意类型的状态的操作。

通过提供状态对象列表,具有操作状态的函数可以使用snapshotState()restoreState()方法实现此逻辑。snapshotState()方法将操作状态拆分为多个部分,而restoreState()方法将操作状态从可能的多个部分组装起来。当函数的状态被恢复时,状态的各个部分分布在函数的所有并行实例中,并传递给restoreState()方法。如果有更多的并行子任务而没有状态对象,则某些子任务将以无状态启动,并且将以空列表调用restoreState()方法。

再次查看 Example 7-4 中的HighTempCounter函数,我们看到每个操作符的并行实例将其状态显示为具有单个条目的列表。如果增加此操作符的并行度,则一些新的子任务将使用空状态进行初始化,并从零开始计数。为了在重新调整HighTempCounter函数时实现更好的状态分布行为,我们可以实现snapshotState()方法,使其将其计数分割成多个部分,如 Example 7-5 所示。

Example 7-5. 在重新调整比例期间分割操作列表状态以获得更好的分布
override def snapshotState(
    chkpntId: Long, 
    ts: Long): java.util.List[java.lang.Long] = {
  // split count into ten partial counts
  val div = highTempCnt / 10
  val mod = (highTempCnt % 10).toInt
  // return count as ten parts
  (List.fill(mod)(new java.lang.Long(div + 1)) ++
    List.fill(10 - mod)(new java.lang.Long(div))).asJava
}

ListCheckpointed 接口使用 Java 序列化

ListCheckpointed接口使用 Java 序列化来序列化和反序列化状态对象列表。如果需要更新应用程序,则可能会遇到问题,因为 Java 序列化不允许迁移或配置自定义序列化程序。如果需要确保函数的操作状态可以演进,请使用CheckpointedFunction而不是ListCheckpointed接口。

使用连接广播状态

在流应用程序中的一个常见需求是将相同信息分发给函数的所有并行实例,并将其作为可恢复状态维护。一个示例是规则流和应用规则的事件流。应用规则的函数会接收两个输入流,即事件流和规则流。它将规则记忆在操作符状态中,以便将其应用于事件流的所有事件。由于函数的每个并行实例必须在其操作符状态中保留所有规则,因此需要广播规则流,以确保每个函数实例接收所有规则。

在 Flink 中,这样的状态称为广播状态。广播状态可以与常规的DataStreamKeyedStream结合使用。示例 7-6 展示了如何使用广播流动态配置阈值来实现温度警报应用程序。

示例 7-6. 连接广播流和键控事件流
val sensorData: DataStream[SensorReading] = ???
val thresholds: DataStream[ThresholdUpdate] = ???
val keyedSensorData: KeyedStream[SensorReading, String] = sensorData.keyBy(_.id)

// the descriptor of the broadcast state
val broadcastStateDescriptor =
  new MapStateDescriptorString, Double

val broadcastThresholds: BroadcastStream[ThresholdUpdate] = thresholds
  .broadcast(broadcastStateDescriptor)

// connect keyed sensor stream and broadcasted rules stream
val alerts: DataStream[(String, Double, Double)] = keyedSensorData
  .connect(broadcastThresholds)
  .process(new UpdatableTemperatureAlertFunction())

在三个步骤中,将具有广播状态的函数应用于两个流:

  1. 通过调用DataStream.broadcast()并提供一个或多个MapStateDescriptor对象来创建BroadcastStream。每个描述符定义了稍后应用于BroadcastStream的单独广播状态函数。

  2. BroadcastStreamDataStreamKeyedStream连接。必须将BroadcastStream作为connect()方法的参数。

  3. 在连接的流上应用函数。根据另一个流是否有键,可以应用KeyedBroadcastProcessFunctionBroadcastProcessFunction

示例 7-7 展示了支持运行时动态配置传感器阈值的KeyedBroadcastProcessFunction的实现。

示例 7-7. 实现KeyedBroadcastProcessFunction
class UpdatableTemperatureAlertFunction()
    extends KeyedBroadcastProcessFunction
      [String, SensorReading, ThresholdUpdate, (String, Double, Double)] {

  // the descriptor of the broadcast state
  private lazy val thresholdStateDescriptor =
    new MapStateDescriptorString, Double

  // the keyed state handle
  private var lastTempState: ValueState[Double] = _

  override def open(parameters: Configuration): Unit = {
    // create keyed state descriptor
    val lastTempDescriptor = new ValueStateDescriptorDouble
    // obtain the keyed state handle
    lastTempState = getRuntimeContext.getStateDouble
  }

  override def processBroadcastElement(
      update: ThresholdUpdate,
      ctx: KeyedBroadcastProcessFunction
        [String, SensorReading, ThresholdUpdate, (String, Double, Double)]#Context,
      out: Collector[(String, Double, Double)]): Unit = {
    // get broadcasted state handle
    val thresholds = ctx.getBroadcastState(thresholdStateDescriptor)

    if (update.threshold != 0.0d) {
      // configure a new threshold for the sensor
      thresholds.put(update.id, update.threshold)
    } else {
      // remove threshold for the sensor
      thresholds.remove(update.id)
    }
  }

  override def processElement(
      reading: SensorReading,
      readOnlyCtx: KeyedBroadcastProcessFunction
        [String, SensorReading, ThresholdUpdate, 
        (String, Double, Double)]#ReadOnlyContext,
      out: Collector[(String, Double, Double)]): Unit = {
    // get read-only broadcast state
    val thresholds = readOnlyCtx.getBroadcastState(thresholdStateDescriptor)
    // check if we have a threshold
    if (thresholds.contains(reading.id)) {
      // get threshold for sensor
      val sensorThreshold: Double = thresholds.get(reading.id)

      // fetch the last temperature from state
      val lastTemp = lastTempState.value()
      // check if we need to emit an alert
      val tempDiff = (reading.temperature - lastTemp).abs
      if (tempDiff > sensorThreshold) {
        // temperature increased by more than the threshold
        out.collect((reading.id, reading.temperature, tempDiff))
      }
    }

    // update lastTemp state
    this.lastTempState.update(reading.temperature)
  }
}

BroadcastProcessFunctionKeyedBroadcastProcessFunction与常规的CoProcessFunction不同,因为元素处理方法不对称。这些方法,processElement()processBroadcastElement(),会使用不同的上下文对象调用。两个上下文对象都提供一个方法getBroadcastState(MapStateDescriptor),用于访问广播状态句柄。然而,在processElement()方法中返回的广播状态句柄只能进行只读访问。这是一种安全机制,用于确保广播状态在所有并行实例中保存相同的信息。此外,这两个上下文对象还提供访问事件时间戳、当前水位线、当前处理时间和类似于其他处理函数的侧输出。

注意

BroadcastProcessFunctionKeyedBroadcastProcessFunction 也有所不同。BroadcastProcessFunction 不提供定时器服务用于注册定时器,因此不提供 onTimer() 方法。请注意,不应从 KeyedBroadcastProcessFunctionprocessBroadcastElement() 方法中访问键控状态。由于广播输入未指定键,状态后端无法访问键控值,并将抛出异常。相反,KeyedBroadcastProcessFunction.processBroadcastElement() 方法的上下文提供了一个方法 applyToKeyedState(StateDescriptor, KeyedStateFunction),用于将 KeyedStateFunction 应用到由 StateDescriptor 引用的键控状态的每个键的值上。

广播事件可能不会按确定性顺序到达

发送到广播状态操作符的不同并行任务的广播事件到达顺序可能会有所不同,如果发出广播消息的操作符的并行度大于 1。

因此,您应该确保广播状态的值不依赖于接收广播消息的顺序,或者确保广播操作符的并行度设置为 1。

使用 CheckpointedFunction 接口

CheckpointedFunction 接口是指定有状态函数的最低级别接口。它提供了注册和维护键控状态和操作状态的钩子,并且是唯一一个能访问操作符列表联合状态的接口——在恢复或保存点重启时,操作状态完全复制。³

CheckpointedFunction 接口定义了两个方法,initializeState()snapshotState(),它们与操作符列表状态的 ListCheckpointed 接口的方法类似。当创建 CheckpointedFunction 的并行实例时调用 initializeState() 方法。这发生在应用程序启动时或由于故障而重新启动任务时。该方法调用时会传入一个 FunctionInitializationContext 对象,该对象提供对 OperatorStateStoreKeyedStateStore 对象的访问。状态存储负责在 Flink 运行时注册函数状态并返回状态对象,例如 ValueStateListStateBroadcastState。每个状态都使用必须对该函数唯一的名称进行注册。当函数注册状态时,状态存储尝试通过检查状态后端是否持有以给定名称注册的函数状态来初始化状态。如果任务由于故障或从保存点重新启动,则将从保存的数据初始化状态。如果应用程序不是从检查点或保存点启动的,则状态将最初为空。

snapshotState() 方法在进行检查点之前立即被调用,并接收一个 FunctionSnapshotContext 对象作为参数。FunctionSnapshotContext 提供了检查点的唯一标识符和 JobManager 启动检查点时的时间戳。snapshotState() 方法的目的是确保所有状态对象在执行检查点之前都已更新。此外,结合 CheckpointListener 接口,snapshotState() 方法可以通过与 Flink 的检查点同步来一致地将数据写入外部数据存储。

示例 7-8 展示了如何使用 CheckpointedFunction 接口创建一个函数,该函数具有按键和操作状态进行计数的功能,统计超过指定阈值的传感器读数。

示例 7-8. 实现 CheckpointedFunction 接口的函数
class HighTempCounter(val threshold: Double)
    extends FlatMapFunction[SensorReading, (String, Long, Long)]
    with CheckpointedFunction {

  // local variable for the operator high temperature cnt
  var opHighTempCnt: Long = 0
  var keyedCntState: ValueState[Long] = _
  var opCntState: ListState[Long] = _

  override def flatMap(
      v: SensorReading, 
      out: Collector[(String, Long, Long)]): Unit = {
    // check if temperature is high
    if (v.temperature > threshold) {
      // update local operator high temp counter
      opHighTempCnt += 1
      // update keyed high temp counter
      val keyHighTempCnt = keyedCntState.value() + 1
      keyedCntState.update(keyHighTempCnt)
      // emit new counters
      out.collect((v.id, keyHighTempCnt, opHighTempCnt))
    }
  }

  override def initializeState(initContext: FunctionInitializationContext): Unit = {
    // initialize keyed state
    val keyCntDescriptor = new ValueStateDescriptorLong
    keyedCntState = initContext.getKeyedStateStore.getState(keyCntDescriptor)
    // initialize operator state
    val opCntDescriptor = new ListStateDescriptorLong
    opCntState = initContext.getOperatorStateStore.getListState(opCntDescriptor)
    // initialize local variable with state
    opHighTempCnt = opCntState.get().asScala.sum
  }

  override def snapshotState(
      snapshotContext: FunctionSnapshotContext): Unit = {
    // update operator state with local state
    opCntState.clear()
    opCntState.add(opHighTempCnt)
  }
}

关于完成检查点的通知

频繁同步是分布式系统性能限制的主要原因。Flink 的设计旨在减少同步点。检查点基于随数据流动的屏障实现,因此避免了跨应用程序的全局同步。

由于其检查点机制,Flink 可以实现非常好的性能。然而,另一个影响是应用的状态除了在进行检查点时的逻辑时间点外,永远不处于一致状态。对于某些操作符来说,了解检查点是否完成可能非常重要。例如,那些希望通过精确一次语义保证将数据写入外部系统的汇聚函数必须仅发出在成功检查点之前接收到的记录,以确保在发生故障时不会重新计算接收到的数据。

如 “检查点、保存点和状态恢复” 中所讨论的,只有当所有操作符任务成功将它们的状态检查点到检查点存储时,检查点才算成功。因此,只有 JobManager 能够确定检查点是否成功。需要接收关于完成检查点的通知的操作符可以实现 CheckpointListener 接口。该接口提供了 notifyCheckpointComplete(long chkpntId) 方法,在 JobManager 注册检查点完成时可能会被调用——当所有操作符成功将其状态复制到远程存储时。

注意

注意,Flink 不保证每个完成的检查点都会调用 notifyCheckpointComplete() 方法。可能会有任务错过通知。在实现接口时需要考虑这一点。

为状态化应用程序启用故障恢复

流式应用程序应该持续运行,并且必须从故障中恢复,例如机器或进程失败。大多数流式应用程序要求故障不影响计算结果的正确性。

在 “检查点、保存点和状态恢复” 中,我们解释了 Flink 创建有状态应用程序一致检查点的机制,即在所有操作者处理所有事件达到应用程序输入流中特定位置的时间点上,所有内置和用户定义的有状态函数的状态快照。为了为应用程序提供容错能力,JobManager 在定期间隔内启动检查点。

应用程序需要通过 StreamExecutionEnvironment 明确启用周期性检查点机制,如 示例 7-9 所示。

示例 7-9. 为应用程序启用检查点
val env = StreamExecutionEnvironment.getExecutionEnvironment

// set checkpointing interval to 10 seconds (10000 milliseconds)
env.enableCheckpointing(10000L)

检查点间隔是一个重要的参数,影响检查点机制在常规处理期间的开销和从故障中恢复所需的时间。较短的检查点间隔会导致常规处理期间的开销增加,但可以实现更快的恢复,因为需要重新处理的数据量较少。

Flink 提供了更多调整参数来配置检查点行为,如一致性保证选择(精确一次或至少一次)、并发检查点数量以及取消长时间运行检查点的超时时间,以及几个特定于状态后端的选项。我们在 “调整检查点和恢复” 中详细讨论了这些选项。

确保有状态应用程序的可维护性

应用程序运行数周后的状态可能难以重新计算,甚至是不可能的。同时,长时间运行的应用程序需要维护。需要修复错误,调整功能,添加或移除功能,或者调整操作者的并行度以适应更高或更低的数据速率。因此,重要的是应用程序状态能够迁移到应用程序的新版本,或者重新分配给更多或更少的操作者任务。

Flink 提供了保存点(savepoints)来维护应用程序及其状态。然而,它要求应用程序初始版本的所有有状态操作符指定两个参数,以确保将来可以正确维护应用程序。这些参数是唯一的操作符标识符和最大并行度(对具有键控状态的操作符而言)。以下是如何设置这些参数的描述。

操作符唯一标识符和最大并行度已嵌入保存点中

操作符的唯一标识符和最大并行度嵌入到保存点中,不能更改。如果操作符的标识符或最大并行度已更改,则无法从先前获取的保存点启动应用程序。

一旦更改运算符标识符或最大并行度,就无法从保存点启动应用程序,而必须从头开始,没有任何状态初始化。

指定唯一运算符标识符

每个应用程序的运算符都应指定唯一标识符。标识符作为运算符的实际状态数据与元数据一同写入保存点。从保存点启动应用程序时,这些标识符用于将保存点中的状态映射到启动的应用程序的相应运算符。只有它们的标识符相同,保存点状态才能还原到启动应用程序的运算符。

如果您没有显式为状态应用程序的运算符设置唯一标识符,那么当您必须演变应用程序时,您将面临显著的限制。我们在“保存点”中更详细地讨论了唯一运算符标识符的重要性以及保存点状态的映射。

我们强烈建议为应用的每个运算符分配唯一的标识符。您可以使用uid()方法设置标识符,如示例 7-10 所示。

示例 7-10. 为运算符设置唯一标识符
val alerts: DataStream[(String, Double, Double)] = keyedSensorData
  .flatMap(new TemperatureAlertFunction(1.1))  
  .uid("TempAlert")

定义键控状态运算符的最大并行度

运算符的最大并行度参数定义了运算符键控状态分割为的键组数。键组的数量限制了可以扩展键控状态的最大并行任务数。“扩展有状态运算符”讨论了键组以及如何扩展和缩减键控状态。可以通过StreamExecutionEnvironment为应用程序的所有运算符设置最大并行度,或者使用setMaxParallelism()方法为每个运算符单独设置,如示例 7-11 所示。

示例 7-11. 设置运算符的最大并行度
val env = StreamExecutionEnvironment.getExecutionEnvironment

// set the maximum parallelism for this application
env.setMaxParallelism(512)

val alerts: DataStream[(String, Double, Double)] = keyedSensorData
  .flatMap(new TemperatureAlertFunction(1.1))
  // set the maximum parallelism for this operator and
  // override the application-wide value
  .setMaxParallelism(1024)

运算符的默认最大并行度取决于应用程序第一个版本中的运算符并行度:

  • 如果并行度小于或等于 128,则最大并行度为 128。

  • 如果运算符的并行度大于 128,则最大并行度计算为nextPowerOfTwo(parallelism + (parallelism / 2))2¹⁵中的最小值。

状态应用的性能和健壮性

运算符与状态交互的方式对应用程序的健壮性和性能有影响。有几个方面会影响应用程序的行为,例如本地维护状态和执行检查点的状态后端的选择、检查点算法的配置以及应用程序状态的大小。在本节中,我们讨论需要考虑的方面,以确保长时间运行的应用程序具有健壮的执行行为和一致的性能。

选择状态后端

在“状态后端”中,我们解释了 Flink 如何在状态后端中维护应用程序状态。状态后端负责存储每个任务实例的本地状态,并在检查点被触发时将其持久化到远程存储中。由于本地状态可以以不同的方式进行维护和检查点,因此状态后端是可插拔的——两个应用程序可以使用不同的状态后端实现来维护它们的状态。选择状态后端会影响状态应用程序的稳健性和性能。每种状态后端都提供了不同状态原语的实现,例如ValueStateListStateMapState

目前,Flink 提供三种状态后端,分别是MemoryStateBackendFsStateBackendRocksDBStateBackend

  • MemoryStateBackend将状态存储为任务管理器 JVM 进程堆上的常规对象。例如,MapState由 Java 的HashMap对象支持。尽管这种方法提供了非常低的读写状态延迟,但它对应用程序的稳健性有影响。如果任务实例的状态增长过大,JVM 和其上运行的所有任务实例可能会因为OutOfMemoryError而被终止。此外,这种方法可能由于在堆上放置了许多长期存在的对象而受到垃圾回收暂停的影响。在触发检查点时,MemoryStateBackend将状态发送到作业管理器,后者将其存储在其堆内存中。因此,应用程序的总状态必须适合于作业管理器的内存。由于其内存是易失性的,作业管理器故障时状态会丢失。由于这些限制,MemoryStateBackend仅建议用于开发和调试目的。

  • FsStateBackend将本地状态存储在任务管理器的 JVM 堆上,类似于MemoryStateBackend。但是,与将状态检查点写入作业管理器的易失性内存不同,FsStateBackend将状态写入远程持久化文件系统。因此,FsStateBackend在本地访问时提供内存中的速度,并在故障时提供容错性。然而,它受到任务管理器内存大小的限制,并可能因垃圾回收暂停而受到影响。

  • RocksDBStateBackend将所有状态存储到本地的 RocksDB 实例中。RocksDB 是一个嵌入式键值存储,将数据持久化到本地磁盘。为了从 RocksDB 读取和写入数据,需要进行序列化/反序列化。RocksDBStateBackend还将状态检查点写入到远程和持久的文件系统中。由于它将数据写入磁盘并支持增量检查点(更多信息请参见“检查点、保存点和状态恢复”),RocksDBStateBackend是处理非常大状态的应用程序的良好选择。用户报告称,具有多个 TB 状态大小的应用程序可以利用RocksDBStateBackend。然而,将数据读取和写入磁盘以及序列化/反序列化对象的开销会导致读取和写入性能较维护堆上状态时更低。

由于StateBackend是一个公共接口,也可以实现自定义状态后端。示例 7-12(见#code_rocksdb-config)展示了如何为应用程序及其所有状态功能配置状态后端(此处为RocksDBStateBackend)。

示例 7-12. 配置应用程序的RocksDBStateBackend
val env = StreamExecutionEnvironment.getExecutionEnvironment

val checkpointPath: String = ???
// configure path for checkpoints on the remote filesystem
val backend = new RocksDBStateBackend(checkpointPath)

// configure the state backend
env.setStateBackend(backend)

我们在“调优检查点和恢复”中讨论了如何在应用程序中使用和配置状态后端。

选择状态原语

状态操作符(内置或用户定义)的性能取决于多个方面,包括状态的数据类型、应用程序的状态后端和选择的状态原语。

对于像RocksDBStateBackend这样在读取或写入时对状态对象进行序列化/反序列化的状态后端,如ValueStateListStateMapState的选择会对应用程序的性能产生重大影响。例如,当访问ValueState时,它会完全反序列化,更新时再序列化。RocksDBStateBackendListState实现在构建Iterable以读取值之前会反序列化所有列表条目。然而,向ListState添加单个值(追加到列表末尾)是一个廉价的操作,因为只有追加的值会被序列化。RocksDBStateBackendMapState允许按键读取和写入值,只有读取或写入的键和值会进行序列化/反序列化。在迭代MapState的条目集时,序列化条目会从 RocksDB 预取,并且只有在实际访问键或值时才会反序列化。

例如,对于RocksDBStateBackend,使用MapState[X, Y]而不是ValueState[HashMap[X, Y]]效率更高。如果经常向列表追加元素并且不经常访问列表的元素,则ListState[X]ValueState[List[X]]更有优势。

另一个良好的实践是每个函数调用仅更新状态一次。由于检查点与函数调用同步,多次状态更新不会带来任何好处,但在单个函数调用中多次更新状态可能会导致额外的序列化开销。

防止状态泄漏

流应用通常设计成连续运行数月甚至数年。如果应用状态持续增长,最终会变得过大并导致应用崩溃,除非采取措施将应用扩展到更多资源。为了防止应用的资源消耗随时间增长,控制操作状态的大小至关重要。由于状态处理直接影响操作符的语义,Flink 不能自动清理状态和释放存储空间。因此,所有有状态的操作符必须控制其状态的大小,并确保其不会无限增长。

状态增长的一个常见原因是在不断变化的键域上的键控状态。在这种情况下,有状态函数接收具有仅在一定时间段内活动且之后永不再接收的键的记录。典型示例是具有会话 ID 属性的点击事件流,该属性在一段时间后过期。在这种情况下,具有键控状态的函数将积累越来越多的键的状态。随着键空间的变化,过期键的状态变得陈旧且无用。解决此问题的方法是删除过期键的状态。然而,只有当函数收到具有该键的记录时,具有键控状态的函数才能访问该键的状态。在许多情况下,函数不知道记录是否是键的最后一个。因此,它将无法驱逐该键的状态,因为它可能会接收到键的另一条记录。

这个问题不仅存在于自定义有状态函数中,也存在于 DataStream API 的一些内置操作符中。例如,在KeyedStream上计算运行聚合,无论是使用内置的聚合函数如minmaxsumminBy,或者使用自定义的ReduceFunctionAggregateFunction,都会保留每个键的状态并且不会清除它。因此,只有在键值来自于常量且有界的域时,才应使用这些函数。另一个例子是基于计数触发器的窗口,它们在接收到一定数量的记录时会处理和清理它们的状态。基于时间触发器的窗口(处理时间和事件时间)不受此影响,因为它们基于时间触发并清除它们的状态。

这意味着在设计和实现有状态操作符时,应考虑应用程序需求和其输入数据的属性,例如键域。如果应用程序需要用于移动键域的键控状态,则应确保在不再需要时清除键的状态。可以通过为将来的某个时间点注册定时器来实现这一点。⁴ 与状态类似,定时器也是在当前活动键的上下文中注册的。当定时器触发时,会调用回调方法并加载定时器键的上下文。因此,回调方法可以完全访问键的状态并清除它。支持注册定时器的函数包括窗口的Trigger接口和过程函数。两者均在第六章中有详细介绍。

示例 7-13 展示了一个KeyedProcessFunction,它比较连续两次的温度测量,并在差异大于一定阈值时触发警报。这与之前的键控状态示例相同,但KeyedProcessFunction还清除了一个小时内未提供任何新温度测量的键(即传感器)的状态。

示例 7-13. 清除其状态的有状态KeyedProcessFunction
class SelfCleaningTemperatureAlertFunction(val threshold: Double)
    extends KeyedProcessFunction[String, SensorReading, (String, Double, Double)] {

  // the keyed state handle for the last temperature
  private var lastTempState: ValueState[Double] = _
  // the keyed state handle for the last registered timer
  private var lastTimerState: ValueState[Long] = _

  override def open(parameters: Configuration): Unit = {
    // register state for last temperature
    val lastTempDesc = new ValueStateDescriptorDouble
    lastTempState = getRuntimeContext.getStateDouble
    // register state for last timer
    val lastTimerDesc = new ValueStateDescriptorLong
    lastTimerState = getRuntimeContext.getState(timestampDescriptor)
  }

  override def processElement(
      reading: SensorReading,
      ctx: KeyedProcessFunction
        [String, SensorReading, (String, Double, Double)]#Context,
      out: Collector[(String, Double, Double)]): Unit = {

    // compute timestamp of new clean up timer as record timestamp + one hour
    val newTimer = ctx.timestamp() + (3600 * 1000)
    // get timestamp of current timer
    val curTimer = lastTimerState.value()
    // delete previous timer and register new timer
    ctx.timerService().deleteEventTimeTimer(curTimer)
    ctx.timerService().registerEventTimeTimer(newTimer)
    // update timer timestamp state
    lastTimerState.update(newTimer)

    // fetch the last temperature from state
    val lastTemp = lastTempState.value()
    // check if we need to emit an alert
    val tempDiff = (reading.temperature - lastTemp).abs
    if (tempDiff > threshold) {
      // temperature increased by more than the threshold
      out.collect((reading.id, reading.temperature, tempDiff))
    }

    // update lastTemp state
    this.lastTempState.update(reading.temperature)
  }

  override def onTimer(
      timestamp: Long,
      ctx: KeyedProcessFunction
        [String, SensorReading, (String, Double, Double)]#OnTimerContext,
      out: Collector[(String, Double, Double)]): Unit = {

    // clear all state for the key
    lastTempState.clear()
    lastTimerState.clear()
  }
}

上述KeyedProcessFunction实现的状态清理机制工作如下。对于每个输入事件,都会调用processElement()方法。在比较温度测量值并更新最后一个温度之前,该方法通过删除先前的定时器并注册一个新的定时器来更新清理定时器。清理时间通过将当前记录的时间戳加一小时来计算。为了能够删除当前注册的定时器,其时间戳存储在额外的ValueState[Long]中,称为lastTimerState。之后,该方法比较温度,可能触发警报,并更新其状态。

由于我们的KeyedProcessFunction始终通过删除当前定时器并注册新定时器来更新注册的定时器,因此每个键只注册一个定时器。一旦定时器触发,将调用onTimer()方法。该方法清除与键相关联的所有状态,最后一个温度和最后一个定时器状态。

演进的有状态应用程序

经常需要修复错误或演变长时间运行的有状态流应用程序的业务逻辑。因此,通常需要将运行中的应用程序替换为更新版本,通常不会丢失应用程序的状态。

Flink 通过对运行中的应用程序进行保存点、停止它并从保存点启动应用程序的新版本来支持此类更新。⁵然而,只有在特定应用程序更改情况下,原始应用程序及其新版本需要保存点兼容性才能同时更新应用程序并保留其状态。接下来,我们将解释如何在保留保存点兼容性的同时演进应用程序。

在“保存点”中,我们解释了保存点中每个状态都可以通过由唯一操作符标识符和状态描述符声明的状态名称组成的复合标识符来访问。

设计应用程序时要考虑演进

重要的是要理解,应用程序的初始设计决定了以后如何以保存点兼容的方式进行修改。如果原始版本没有考虑更新,许多更改将是不可能的。为操作符分配唯一标识符对大多数应用程序更改是强制性的。

当从保存点启动应用程序时,启动的应用程序的操作符将通过使用操作符标识符和状态名称从保存点查找相应状态来初始化。从保存点兼容性的角度来看,这意味着应用程序可以通过以下三种方式进行演进:

  1. 更新或扩展应用程序的逻辑,而不更改或删除现有状态。这包括向应用程序添加有状态或无状态操作符。

  2. 从应用程序中删除一个状态。

  3. 通过更改现有操作符的状态原语或状态数据类型来修改现有操作符的状态。

在接下来的几节中,我们将讨论这三种情况。

更新应用程序而不修改现有状态

如果应用程序在不删除或更改现有状态的情况下进行更新,则始终符合保存点兼容性,并且可以从早期版本的保存点启动。

如果向应用程序添加新的有状态操作符或向现有操作符添加新状态,则在从保存点启动应用程序时状态将初始化为空。

更改内置有状态操作符的输入数据类型

请注意,更改内置有状态操作符(如窗口聚合、基于时间的连接或异步函数的输入数据类型)通常会修改其内部状态的类型。因此,即使这些变化看起来不显眼,这些变化也不符合保存点兼容性。

从应用程序中删除状态

而不是向应用程序添加新状态,您可能还想通过删除状态来调整应用程序——可以通过删除完整的有状态运算符或仅从函数中删除状态。当新版本的应用程序从先前版本的保存点启动时,保存点包含无法映射到重新启动的应用程序的状态。如果运算符的唯一标识符或状态的名称已更改,情况也是如此。

默认情况下,为了避免丢失保存点中的状态,Flink 不会启动未恢复所有保存点中包含的状态的应用程序。但可以按照 “运行和管理流应用程序” 中描述的方式禁用此安全检查。因此,通过删除现有运算符的有状态运算符或状态,更新应用程序并不困难。

修改运算符的状态

虽然在应用程序中添加或删除状态相对简单且不影响保存点的兼容性,但修改现有运算符的状态则更为复杂。有两种方式可以修改状态:

  • 通过更改状态的数据类型,例如将ValueState[Int]更改为ValueState[Double]

  • 通过更改状态原语的类型,例如将ValueState[List[String]]更改为ListState[String]

在一些特定情况下,可以更改状态的数据类型。然而,目前 Flink 并不支持更改状态的原语(或结构)。有一些想法支持通过提供离线工具来转换保存点的情况。然而,截至 Flink 1.7,还没有这样的工具。以下我们重点讨论更改状态数据类型的问题。

为了理解修改状态数据类型的问题,我们必须了解保存点中状态数据的表示方式。保存点主要由序列化的状态数据组成。将状态 JVM 对象转换为字节的序列化程序由 Flink 的类型系统生成和配置。此转换基于状态的数据类型。例如,如果您有一个ValueState[String],Flink 的类型系统会生成一个StringSerializer来将String对象转换为字节。序列化程序还用于将原始字节转换回 JVM 对象。根据状态后端存储数据是序列化的(如RocksDBStateBackend)还是作为堆上的对象(如FSStateBackend),当函数读取状态或应用程序从保存点重新启动时进行此转换。

由于 Flink 的类型系统根据状态的数据类型生成序列化器,当状态的数据类型发生变化时,序列化器可能会发生变化。例如,如果将ValueState[String]更改为ValueState[Double],Flink 将创建一个DoubleSerializer来访问该状态。令人惊讶的是,使用DoubleSerializer来反序列化由StringSerializer序列化的二进制数据将失败。因此,在非常特定的情况下才支持更改状态的数据类型。

在 Flink 1.7 中,如果状态的数据类型被定义为 Apache Avro 类型,并且新数据类型也是根据 Avro 模式演化规则从原始类型演化而来的 Avro 类型,则支持更改状态的数据类型。Flink 的类型系统将自动生成能够读取先前数据类型版本的序列化器。

在 Flink 社区中,状态演进和迁移是一个重要的话题,得到了很多关注。您可以期待在 Apache Flink 未来版本中对这些场景的改进支持。尽管有这些努力,我们建议在将应用程序投入生产之前始终仔细检查是否可以按计划演进应用程序。

可查询状态

许多流处理应用程序需要与其他应用程序共享其结果。常见模式是将结果写入数据库或键值存储,并让其他应用程序从该数据存储中检索结果。这样的架构意味着需要设置和维护一个单独的系统,尤其是如果这需要是一个分布式系统的话,那么工作量可能会很大。

Apache Flink 提供了可查询状态功能,以解决通常需要外部数据存储来共享数据的用例。在 Flink 中,任何键控状态都可以作为可查询状态暴露给外部应用程序,并充当只读键值存储。状态流式处理应用程序像往常一样处理事件,并将其中间或最终结果存储和更新到可查询状态中。外部应用程序可以在流式应用程序运行时请求键的状态。

注意

请注意,仅支持关键点查询。不可能请求键范围或运行更复杂的查询。

可查询状态并不解决所有需要外部数据存储的用例。例如,可查询状态只在应用程序运行时可访问。在应用程序由于错误而重新启动、重新调整应用程序或将其迁移到另一个集群时,不可访问。然而,它使许多应用程序更容易实现,例如实时仪表盘或其他监控应用程序。

在接下来的内容中,我们将讨论 Flink 的可查询状态服务的架构,并解释流处理应用程序如何暴露可查询状态以及外部应用程序如何查询它。

架构和启用可查询状态

Flink 的可查询状态服务由三个进程组成:

  • QueryableStateClient由外部应用程序用于提交查询并检索结果。

  • QueryableStateClientProxy接受并提供客户端请求的服务。每个 TaskManager 运行一个客户端代理。由于键控状态分布在运算符的所有并行实例之间,代理需要识别维护所请求键的状态的 TaskManager。此信息从管理键组分配的 JobManager 请求,并且一旦收到,会被缓存。⁶ 客户端代理从相应 TaskManager 的状态服务器检索状态并将结果提供给客户端。

  • QueryableStateServer为客户端代理提供服务请求。每个 TaskManager 运行一个状态服务器,该服务器从本地状态后端获取查询键的状态并将其返回给请求的客户端代理。

图 7-1 展示了可查询状态服务的架构。

Flink 可查询状态服务的架构

要在 Flink 设置中启用可查询状态服务——启动 TaskManager 内的客户端代理和服务器线程,您需要将flink-queryable-state-runtime JAR 文件添加到 TaskManager 进程的类路径中。这可以通过从安装的./opt文件夹复制到./lib文件夹来完成。当 JAR 文件位于类路径中时,可查询状态线程会自动启动,并且可以为可查询状态客户端提供服务。正确配置后,您将在 TaskManager 日志中找到以下日志消息:

Started the Queryable State Proxy Server @ …

客户端代理和服务器使用的端口及其他参数可以在./conf/flink-conf.yaml文件中配置。

暴露可查询状态

实现具有可查询状态的流应用程序很简单。您只需定义具有键控状态的函数,并在获取状态句柄之前调用StateDescriptor上的setQueryable(String)方法使状态可查询。示例 7-14 展示了如何使lastTempState可查询,以说明键控状态的用法。

示例 7-14. 配置可查询的键控状态
 override def open(parameters: Configuration): Unit = {

   // create state descriptor
   val lastTempDescriptor = 
     new ValueStateDescriptorDouble
   // enable queryable state and set its external identifier
   lastTempDescriptor.setQueryable("lastTemperature")
   // obtain the state handle
   lastTempState = getRuntimeContext
     .getStateDouble
}

通过setQueryable()方法传递的外部标识符可以自由选择,并且仅用于配置可查询状态客户端。

除了启用对任何类型键控状态的查询的通用方式外,Flink 还提供了定义流接收器的快捷方式,用于在可查询状态中存储流的事件。示例 7-15 展示了如何使用可查询状态接收器。

示例 7-15. 将 DataStream 写入可查询状态接收器
val tenSecsMaxTemps: DataStream[(String, Double)] = sensorData
  // project to sensor id and temperature
  .map(r => (r.id, r.temperature))
  // compute every 10 seconds the max temperature per sensor
  .keyBy(_._1)
  .timeWindow(Time.seconds(10))
  .max(1)

// store max temperature of the last 10 secs for each sensor 
// in a queryable state
tenSecsMaxTemps
  // key by sensor id
  .keyBy(_._1)
  .asQueryableState("maxTemperature")

asQueryableState()方法向流添加一个可查询状态的接收端。可查询状态的类型是ValueState,它保存输入流的类型的值——我们的示例(String, Double)。对于每个接收到的记录,可查询状态接收端将记录插入ValueState,以便每个键的最新事件始终被存储。

一个具有可查询状态功能的应用程序执行起来就像任何其他应用程序一样。您只需要确保任务管理器配置为启动其可查询状态服务,就像前面的部分讨论的那样。

从外部应用程序查询状态

任何基于 JVM 的应用程序都可以通过使用QueryableStateClient查询运行中 Flink 应用程序的可查询状态。这个类由flink-queryable-state-client-java依赖提供,您可以按以下方式将其添加到项目中:

<dependency>
  <groupid>org.apache.flink</groupid>
  <artifactid>flink-queryable-state-client-java_2.12</artifactid>
  <version>1.7.1</version>
</dependency>

QueryableStateClient使用任何 TaskManager 的主机名和可查询状态客户端代理侦听的端口进行初始化。默认情况下,客户端代理侦听端口为 9067,但端口可以在 ./conf/flink-conf.yaml 文件中配置:

val client: QueryableStateClient = 
  new QueryableStateClient(tmHostname, proxyPort)

一旦获得状态客户端,您可以通过调用getKvState()方法查询应用程序的状态。该方法接受多个参数,如运行应用程序的JobID、状态标识符、应该获取状态的键、键的TypeInformation和查询状态的StateDescriptorJobID可以通过 REST API、Web UI 或日志文件获取。getKvState()方法返回一个CompletableFuture[S],其中S是状态的类型(例如,ValueState[_]MapState[_, _])。因此,客户端可以发送多个异步查询并等待它们的结果。示例 7-16 展示了一个简单的控制台仪表盘,查询在前一节中显示的应用程序的可查询状态。

object TemperatureDashboard {

  // assume local setup and TM runs on same machine as client
  val proxyHost = "127.0.0.1"
  val proxyPort = 9069

  // jobId of running QueryableStateJob
  // can be looked up in logs of running job or the web UI
  val jobId = "d2447b1a5e0d952c372064c886d2220a"

  // how many sensors to query
  val numSensors = 5
  // how often to query the state
  val refreshInterval = 10000

  def main(args: Array[String]): Unit = {
    // configure client with host and port of queryable state proxy
    val client = new QueryableStateClient(proxyHost, proxyPort)

    val futures = new Array[
      CompletableFuture[ValueState[(String, Double)]]](numSensors)
    val results = new ArrayDouble

    // print header line of dashboard table
    val header = 
      (for (i <- 0 until numSensors) yield "sensor_" + (i + 1))
        .mkString("\t| ")
    println(header)

    // loop forever
    while (true) {
      // send out async queries
      for (i <- 0 until numSensors) {
        futures(i) = queryState("sensor_" + (i + 1), client)
      }
      // wait for results
      for (i <- 0 until numSensors) {
        results(i) = futures(i).get().value()._2
      }
      // print result
      val line = results.map(t => f"$t%1.3f").mkString("\t| ")
      println(line)

      // wait to send out next queries
      Thread.sleep(refreshInterval)
    }
    client.shutdownAndWait()
  }

  def queryState(
      key: String,
      client: QueryableStateClient)
    : CompletableFuture[ValueState[(String, Double)]] = {

    client
      .getKvState[String, ValueState[(String, Double)], (String, Double)](
        JobID.fromHexString(jobId),
        "maxTemperature",
        key,
        Types.STRING,
        new ValueStateDescriptor(String, Double)]))
  }
}

要运行该示例,您必须首先启动带有可查询状态的流应用程序。一旦它运行起来,在日志文件或 Web UI 中查找JobID;将JobID设置在仪表板的代码中并运行它。然后,仪表板将开始查询运行中流应用程序的状态。

摘要

几乎每个非平凡的流式应用程序都是有状态的。DataStream API 提供了强大而易于使用的工具来访问和维护操作符状态。它提供了不同类型的状态原语,并支持可插拔的状态后端。尽管开发人员在与状态交互时具有很大的灵活性,但 Flink 的运行时管理着几个 terabytes 的状态,并在发生故障时确保精确一次性语义。如 第六章 中讨论的基于时间的计算与可扩展的状态管理的结合,使开发人员能够实现复杂的流式应用程序。可查询的状态是一个易于使用的功能,可以节省设置和维护数据库或键值存储以将流应用程序的结果暴露给外部应用程序的工作。

¹ 这与批处理不同,在批处理中,当收集到要处理的所有数据时,会调用用户定义的函数,如 GroupReduceFunction

² 在更新应用程序时,状态的序列化格式是一个重要的方面,并且在本章后面进行了讨论。

³ 详细了解操作符列表并集状态的分布,请参阅 第三章。

⁴ 定时器可以基于事件时间或处理时间。

⁵ 第十章 解释了如何对正在运行的应用程序进行保存点,并如何从现有的保存点启动新的应用程序。

⁶ 关键组在 第三章 中有讨论。

第八章:从外部系统读取和写入

数据可以存储在许多不同的系统中,如文件系统、对象存储、关系数据库系统、键值存储、搜索索引、事件日志、消息队列等等。每一类系统都被设计用于特定的访问模式,并擅长于服务特定的目的。因此,今天的数据基础设施通常由许多不同的存储系统组成。在将新组件添加到组合中之前,一个合乎逻辑的问题应该是:“它与我的堆栈中的其他组件如何协同工作?”

添加像 Apache Flink 这样的数据处理系统需要仔细考虑,因为它不包括自己的存储层,而是依赖于外部存储系统来摄取和持久化数据。因此,对于像 Flink 这样的数据处理器来说,提供一个充分配备的连接器库来从外部系统读取数据并将数据写入其中,以及实现自定义连接器的 API,是非常重要的。然而,仅仅能够读取或写入外部数据存储对于希望在故障情况下提供有意义的一致性保证的流处理器来说是不够的。

在本章中,我们讨论源和接收器连接器如何影响 Flink 流应用程序的一致性保证,并介绍 Flink 的最流行的连接器来读取和写入数据。您将学习如何实现自定义源和接收器连接器,以及如何实现发送异步读取或写入请求到外部数据存储的函数。

应用程序一致性保证

在 “检查点、保存点和状态恢复” 中,您了解到 Flink 的检查点和恢复机制周期性地获取应用程序状态的一致检查点。在发生故障时,应用程序的状态将从最新完成的检查点恢复,并继续处理。然而,仅仅能够将应用程序的状态重置到一致点并不足以实现应用程序的令人满意的处理保证。相反,应用程序的源和接收器连接器需要集成到 Flink 的检查点和恢复机制中,并提供特定的属性,以能够提供有意义的保证。

为了确保应用程序的精确一次状态一致性¹,应用程序的每个源连接器都需要能够将其读取位置设置为先前的检查点位置。在进行检查点时,源操作符会持久化其读取位置,并在恢复过程中恢复这些位置。支持读取位置检查点的源连接器示例包括将读取偏移量存储在文件的字节流中的基于文件的源,或者将读取偏移量存储在消费的 Kafka 主题分区中的 Kafka 源。如果应用程序从无法存储和重置读取位置的源连接器摄取数据,在故障发生时可能会导致数据丢失,并且只能提供至多一次保证。

Flink 的检查点和恢复机制与可重置的源连接器的结合确保了应用程序不会丢失任何数据。然而,应用程序可能会重复发出结果,因为在最后一个成功检查点之后发出的所有结果(在恢复时应用程序会回退到该检查点)将被再次发出。因此,可重置源和 Flink 的恢复机制虽然确保了应用程序状态的精确一次一致性,但不足以提供端到端的精确一次保证。

旨在提供端到端精确一次保证的应用程序需要特殊的接收器连接器。接收器连接器可以在不同情况下应用两种技术来实现精确一次保证:幂等写入和事务写入。

幂等写入

幂等操作可以多次执行,但只会产生单一变化。例如,重复将相同的键值对插入 hashmap 是一种幂等操作,因为第一次插入操作将键的值添加到映射中,所有后续插入操作不会改变映射,因为映射已经包含键值对。另一方面,追加操作不是幂等操作,因为多次追加相同的元素会导致多次追加。对于流应用程序来说,幂等写操作很有意义,因为它们可以多次执行而不改变结果。因此,它们可以在一定程度上缓解由 Flink 的检查点机制引起的重播结果的影响。

需要注意的是,一个依赖于幂等接收器实现精确一次性结果的应用程序必须保证在重播时覆盖先前写入的结果。例如,一个向键值存储进行更新插入的应用程序必须确保确定性地计算用于更新插入的键。此外,从接收器系统读取的应用程序在应用程序恢复期间可能观察到意外的结果。当重播开始时,先前发出的结果可能会被较早的结果覆盖。因此,在恢复应用程序的输出的应用程序可能会看到时间的倒退,例如读取较小的计数。此外,当重播进行时,流应用程序的总体结果将处于不一致状态,因为一些结果将被覆盖,而其他结果则没有。一旦重播完成,并且应用程序超过了先前失败的点,结果将再次变得一致。

事务性写入

第二种实现端到端精确一次性一致性的方法基于事务性写入。这里的想法是,仅将那些在最后一个成功检查点之前计算的结果写入外部接收器系统。这种行为保证了端到端精确一次性,因为在发生故障时,应用程序将被重置到最后一个检查点,并且在该检查点之后未将任何结果发出到接收器系统。通过仅在检查点完成后写入数据,事务性方法不会遭受幂等写入重播不一致性的问题。然而,它会增加延迟,因为结果只有在检查点完成时才变得可见。

Flink 提供了两个构建模块来实现事务性的接收器连接器——一个通用的 写前日志 (WAL) 接收器和一个 两阶段提交 (2PC) 接收器。WAL 接收器将所有结果记录写入应用程序状态,并在收到检查点完成的通知后将它们发送到接收器系统。由于接收器在状态后端缓冲记录,WAL 接收器可以与任何类型的接收器系统一起使用。然而,它无法提供牢固的精确一次性保证,² 还会增加应用程序的状态大小,并且接收器系统必须处理尖峰写入模式。

相比之下,2PC 接收器要求接收器系统提供事务支持或公开用于模拟事务的构建模块。对于每个检查点,接收器启动一个事务,并将所有接收到的记录追加到事务中,将它们写入接收器系统而不提交它们。当接收到检查点完成的通知时,它提交事务并实现已写入的结果。该机制依赖于接收器在从完成检查点之前打开的故障中恢复后提交事务的能力。

两阶段提交协议依赖于 Flink 现有的检查点机制。检查点屏障是启动新事务的通知,所有运算符关于各自检查点成功的通知是它们的提交投票,而 JobManager 关于检查点成功的通知则是提交事务的指令。与 WAL 汇流槽相比,2PC 汇流槽在特定的汇流槽系统和实现下可以实现精确一次的输出。此外,与 WAL 汇流槽的突发写入模式相比,2PC 汇流槽连续向汇流槽系统写入记录。

表 8-1 显示了不同类型的源和汇流槽连接器在最佳情况下可以实现的端到端一致性保证;根据汇流槽的实现,实际一致性可能会更差。

表 8-1. 不同来源和汇流槽组合的端到端一致性保证

不可重置源 可重置源
任何汇流槽 At-most-once At-least-once

| 幂等汇流槽 | At-most-once | Exactly-once*(临时不一致性

在恢复期间)|

WAL sink At-most-once At-least-once
2PC 汇流槽 At-most-once Exactly-once

提供的连接器

Apache Flink 提供了连接器,用于从各种存储系统读取数据和写入数据。消息队列和事件日志,如 Apache Kafka、Kinesis 或 RabbitMQ,是常见的数据流摄取源。在以批处理为主导的环境中,数据流也经常通过监视文件系统目录并在文件出现时读取来进行摄取。

在汇流槽端,数据流通常会被产生到消息队列中,以便后续的流处理应用程序可用事件,写入文件系统进行归档或使数据可用于离线分析或批处理应用程序,或者插入到键值存储或关系数据库系统中,如 Cassandra、ElasticSearch 或 MySQL,以使数据可搜索和可查询,或用于服务仪表盘应用程序。

不幸的是,除了关系型数据库管理系统的 JDBC 外,大多数存储系统都没有标准接口。相反,每个系统都具有自己的连接器库和专有协议。因此,像 Flink 这样的处理系统需要维护几个专用连接器,以便能够从最常用的消息队列、事件日志、文件系统、键值存储和数据库系统中读取事件并写入事件。

Flink 提供了与 Apache Kafka、Kinesis、RabbitMQ、Apache Nifi、各种文件系统、Cassandra、ElasticSearch 和 JDBC 相连的连接器。此外,Apache Bahir 项目为 ActiveMQ、Akka、Flume、Netty 和 Redis 提供了额外的 Flink 连接器。

要在应用程序中使用提供的连接器,需要将其依赖项添加到项目的构建文件中。我们解释了如何在“包括外部和 Flink 依赖项”中添加连接器依赖项。

在接下来的章节中,我们将讨论 Apache Kafka 的连接器、基于文件的源和汇、以及 Apache Cassandra。这些是最常用的连接器,也代表了重要的源和汇系统类型。您可以在Apache FlinkApache Bahir的文档中找到更多关于其他连接器的信息。

Apache Kafka 源连接器

Apache Kafka 是一个分布式流处理平台。其核心是一个分布式发布-订阅消息系统,被广泛采用来摄取和分发事件流。在我们深入了解 Flink 的 Kafka 连接器之前,我们简要解释 Kafka 的主要概念。

Kafka 将事件流组织为所谓的主题(topics)。主题是一个事件日志,保证事件按照写入的顺序读取。为了扩展对主题的写入和读取,可以将其分割为分区,分布在集群中。顺序保证仅限于分区内部——当从不同分区读取时,Kafka 不提供顺序保证。在 Kafka 分区中的读取位置称为偏移量(offset)。

Flink 为所有常见的 Kafka 版本提供源连接器。从 Kafka 0.11 开始,客户端库的 API 发生了演变,并添加了新功能。例如,Kafka 0.10 增加了对记录时间戳的支持。从 1.0 版本开始,API 保持稳定。Flink 提供了一个通用的 Kafka 连接器,适用于自 Kafka 0.11 以来的所有版本。Flink 还提供了针对 Kafka 版本 0.8、0.9、0.10 和 0.11 的特定版本连接器。在本节的其余部分,我们将专注于通用连接器,并且对于特定版本的连接器,我们建议您查阅 Flink 的文档。

将通用的 Flink Kafka 连接器的依赖项添加到 Maven 项目中,如下所示:

<dependency>
   <groupId>org.apache.flink</groupId>
   <artifactId>flink-connector-kafka_2.12</artifactId>
   <version>1.7.1</version>
</dependency>

Flink Kafka 连接器并行摄取事件流。每个并行源任务可以从一个或多个分区读取。任务跟踪每个分区的当前读取偏移量,并将其包含在其检查点数据中。在从故障中恢复时,将恢复偏移量,并且源实例将继续从检查点偏移量读取。Flink Kafka 连接器不依赖于 Kafka 自身的偏移量跟踪机制,后者基于所谓的消费者组(consumer groups)。图 8-1 展示了将分区分配给源实例的过程。

图 8-1. Kafka 主题分区的读取偏移量

Kafka 源连接器的创建如示例 8-1 所示。

val properties = new Properties()
properties.setProperty("bootstrap.servers", "localhost:9092")
properties.setProperty("group.id", "test")

val stream: DataStream[String] = env.addSource(
  new FlinkKafkaConsumerString,
    properties))

构造函数接受三个参数。第一个参数定义了要读取的主题。这可以是单个主题、主题列表或匹配所有要读取的主题的正则表达式。当从多个主题读取时,Kafka 连接器将所有主题的所有分区视为相同,并将它们的事件复用到单个流中。

第二个参数是一个DeserializationSchemaKeyedDeserializationSchema。Kafka 消息存储为原始字节消息,需要反序列化为 Java 或 Scala 对象。SimpleStringSchema是一个内置的DeserializationSchema,用于简单地将字节数组反序列化为String,如在示例 8-1 中所示。此外,Flink 提供了 Apache Avro 和基于文本的 JSON 编码的实现。DeserializationSchemaKeyedDeserializationSchema是公共接口,因此您可以随时实现自定义的反序列化逻辑。

第三个参数是一个Properties对象,用于配置用于连接和从 Kafka 读取的 Kafka 客户端。最少的Properties配置包括两个条目,"bootstrap.servers""group.id"。请参阅 Kafka 文档获取额外的配置属性。

为了提取事件时间戳并生成水印,您可以通过调用FlinkKafkaConsumer.assignTimestampsAndWatermark()向 Kafka 消费者提供一个AssignerWithPeriodicWatermarkAssignerWithPunctuatedWatermark。为每个分区应用分配器以利用每个分区的顺序保证,并且源实例根据水印传播协议合并分区水印(参见“水印传播和事件时间”)。

注意

注意,如果分区变为非活动状态并且不提供消息,源实例的水印将无法取得进展。因此,单个不活动的分区会导致整个应用程序停滞,因为应用程序的水印无法取得进展。

自 0.10.0 版本起,Kafka 支持消息时间戳。当从 Kafka 0.10 或更高版本读取时,如果应用程序在事件时间模式下运行,消费者将自动提取消息时间戳作为事件时间时间戳。在这种情况下,您仍然需要生成水印,并应用一个AssignerWithPeriodicWatermarkAssignerWithPunctuatedWatermark,以转发先前分配的 Kafka 时间戳。

还有一些其他值得注意的配置选项。您可以配置从主题的分区最初读取的起始位置。有效选项包括:

  • Kafka 为通过group.id参数配置的消费者组所知的最后读取位置。这是默认行为:

    FlinkKafkaConsumer.setStartFromGroupOffsets()

  • 每个单独分区的最早偏移量:

    FlinkKafkaConsumer.setStartFromEarliest()

  • 每个分区的最新偏移量:

    FlinkKafkaConsumer.setStartFromLatest()

  • 所有时间戳大于给定时间戳的记录(需要 Kafka 0.10.x 或更高版本):

    FlinkKafkaConsumer.setStartFromTimestamp(long)

  • Map 对象提供的所有分区的特定读取位置:

    FlinkKafkaConsumer.setStartFromSpecificOffsets(Map)

注意

请注意,此配置仅影响第一次读取位置。在恢复或从保存点启动时,应用程序将从检查点或保存点中存储的偏移量开始读取。

可以配置 Flink Kafka 消费者自动发现与正则表达式匹配的新主题或已添加到主题的新分区。这些功能默认情况下是禁用的,可以通过将参数 flink.partition-discovery.interval-millis 添加到 Properties 对象中并设置非负值来启用。

Apache Kafka Sink Connector

Flink 为自 Kafka 0.8 以来的所有 Kafka 版本提供了汇流连接器。通过 Kafka 0.11,客户端库的 API 发生了演变,并添加了新特性,例如 Kafka 0.10 中的记录时间戳支持和 Kafka 0.11 中的事务写入。自 1.0 版本以来,API 保持稳定。Flink 提供了一个通用的 Kafka 连接器,适用于自 Kafka 0.11 以来的所有 Kafka 版本。Flink 还为 Kafka 版本 0.8、0.9、0.10 和 0.11 提供了特定版本的连接器。在本节的其余部分,我们专注于通用连接器,并建议您查阅 Flink 的文档以获取特定版本的连接器信息。Flink 的通用 Kafka 连接器的依赖项如下所示添加到 Maven 项目中:

<dependency>
   <groupId>org.apache.flink</groupId>
   <artifactId>flink-connector-kafka_2.12</artifactId>
   <version>1.7.1</version>
</dependency>

Kafka sink 可以像示例 Example 8-2 中所示添加到 DataStream 应用程序中。

val stream: DataStream[String] = ...

val myProducer = new FlinkKafkaProducerString   // serialization schema

stream.addSink(myProducer)

在 Example 8-2 中使用的构造函数接收三个参数。第一个参数是逗号分隔的 Kafka 代理地址字符串。第二个参数是要写入数据的主题名称,最后一个是将汇流中的输入类型(在 Example 8-2 中为 String)转换为字节数组的 SerializationSchemaSerializationSchema 是我们在 Kafka 源部分讨论的 DeserializationSchema 的对应项。

FlinkKafkaProducer 提供了更多构造函数,具有不同的参数组合,如下所示:

  • 与 Kafka 源连接器类似,您可以传递一个 Properties 对象以向内部 Kafka 客户端提供自定义选项。在使用 Properties 时,必须提供代理服务器列表作为 "bootstrap.servers" 属性。请参阅 Kafka 文档获取参数的详细列表。

  • 您可以指定一个 FlinkKafkaPartitioner 来控制如何将记录映射到 Kafka 分区。我们稍后在本节中会更详细地讨论这个功能。

  • 除了使用 SerializationSchema 将记录转换为字节数组之外,还可以指定 KeyedSerializationSchema,它将记录序列化为两个字节数组——一个用于 Kafka 消息的键,另一个用于值。此外,KeyedSerializationSchema 还公开更多面向 Kafka 的功能,例如覆盖目标主题以写入多个主题。

Kafka sink 提供至少一次的保证

Flink 的 Kafka sink 提供的一致性保证取决于其配置。在以下条件下,Kafka sink 提供至少一次的保证:

  • Flink 的检查点已启用,应用程序的所有来源均可重置。

  • 如果写入失败,sink 连接器会抛出异常,导致应用程序失败和恢复。这是默认行为。通过将 retries 属性设置为大于零的值(默认值),可以配置内部 Kafka 客户端重试写入。还可以通过在 sink 对象上调用 setLogFailuresOnly(true) 配置仅记录写入失败。请注意,这将使应用程序的任何输出保证失效。

  • 当 Kafka sink 等待 Kafka 确认正在传输的记录后才完成检查点。这是默认行为。通过在 sink 对象上调用 setFlushOnCheckpoint(false) 可以禁用此等待。但是,这也会禁用任何输出保证。

Kafka sink 提供确保一次的保证

Kafka 0.11 引入了对事务写入的支持。由于此功能,Flink 的 Kafka sink 在正确配置的情况下还能提供确保一次的输出保证。同样,Flink 应用程序必须启用检查点并从可重置的源消费。此外,FlinkKafkaProducer 提供了一个带有 Semantic 参数的构造函数,用于控制 sink 提供的一致性保证。可能的一致性值有:

  • Semantic.NONE,不提供任何保证——可能会丢失记录或写入多次。

  • Semantic.AT_LEAST_ONCE,确保不会丢失写入但可能会重复。这是默认设置。

  • Semantic.EXACTLY_ONCE,建立在 Kafka 的事务基础上,确保每条记录只写入一次。

在运行使用 Kafka sink 以恰好一次模式操作的 Flink 应用程序时,有几个需要考虑的事项,了解 Kafka 如何处理事务会有所帮助。简而言之,Kafka 的事务通过将所有消息附加到分区的日志中,并将未提交的事务的消息标记为未提交来工作。一旦事务提交,标记将更改为已提交。从主题读取消息的消费者可以通过隔离级别(通过 isolation.level 属性)配置,声明是否可以读取未提交的消息(read_uncommitted,默认情况下)或不可以(read_committed)。如果将消费者配置为 read_committed,它在遇到未提交的消息时停止从分区消费,并在消息提交后恢复。因此,未提交的事务可能会阻塞消费者从分区读取,并引入显著的延迟。Kafka 通过在超时间隔后拒绝并关闭事务来防范这种情况,这是通过 transaction.timeout.ms 属性配置的。

在 Flink 的 Kafka sink 上下文中,这一点非常重要,因为由于长时间的恢复周期等原因导致的事务超时会导致数据丢失。因此,适当配置事务超时属性至关重要。默认情况下,Flink Kafka sink 将 transaction.timeout.ms 设置为一小时,这意味着您可能需要调整 Kafka 设置中 transaction.max.timeout.ms 属性的默认设置为 15 分钟。此外,已提交消息的可见性取决于 Flink 应用程序的检查点间隔。请参考 Flink 文档,了解在启用恰好一次的一致性时的几个其他特殊情况。

检查您的 Kafka 集群配置

即使在确认写入后,Kafka 集群的默认配置仍可能导致数据丢失。您应该仔细审查 Kafka 设置的配置,特别注意以下参数:

  • acks

  • log.flush.interval.messages

  • log.flush.interval.ms

  • log.flush.*

我们建议您参考 Kafka 文档,了解其配置参数的详细信息以及适当配置的指南。

自定义分区和写入消息时间戳

在向 Kafka 主题写入消息时,Flink Kafka sink 任务可以选择写入主题的哪个分区。在 Flink Kafka sink 的某些构造函数中可以定义 FlinkKafkaPartitioner。如果未指定,默认分区器将每个 sink 任务映射到单个 Kafka 分区 —— 由同一 sink 任务发出的所有记录都写入同一个分区,如果任务多于分区,则单个分区可能包含多个 sink 任务的记录。如果分区数大于子任务数,则默认配置会导致空分区,这可能会对以事件时间模式消费主题的 Flink 应用程序造成问题。

通过提供自定义的 FlinkKafkaPartitioner,您可以控制记录如何路由到主题分区。例如,可以基于记录的键属性创建一个分区器,或者创建一个轮询分区器以进行均匀分布。还可以选择将分区委托给基于消息键的 Kafka。这需要一个 KeyedSerializationSchema 来提取消息键,并将 FlinkKafkaPartitioner 参数配置为 null 以禁用默认分区器。

最后,Flink 的 Kafka 汇流器可以配置为写入消息时间戳,自 Kafka 0.10 起支持。通过在汇流器对象上调用 setWriteTimestampToKafka(true) 来启用将记录的事件时间戳写入 Kafka。

文件系统源连接器

文件系统通常用于以成本效益的方式存储大量数据。在大数据架构中,它们经常用作批处理应用程序的数据源和数据接收器。与高级文件格式(例如 Apache Parquet 或 Apache ORC)结合使用时,文件系统可以有效地为 Apache Hive、Apache Impala 或 Presto 等分析查询引擎提供服务。因此,文件系统通常用于“连接”流和批处理应用程序。

Apache Flink 提供了可重置的源连接器,用于将文件中的数据作为流进行摄取。文件系统源是flink-streaming-java模块的一部分。因此,您无需添加其他依赖项即可使用此功能。Flink 支持不同类型的文件系统,例如本地文件系统(包括本地挂载的 NFS 或 SAN 共享,Hadoop HDFS,Amazon S3 和 OpenStack Swift FS)。请参阅 “文件系统配置” 以了解如何在 Flink 中配置文件系统。示例 8-3 显示了如何通过逐行读取文本文件来摄取流。

示例 8-3. 创建文件系统源
val lineReader = new TextInputFormat(null) 

val lineStream: DataStream[String] = env.readFileString                     // The monitoring interval in ms

StreamExecutionEnvironment.readFile() 方法的参数包括:

  • FileInputFormat 负责读取文件内容。我们稍后在本节讨论此接口的详细信息。在 示例 8-3 中,TextInputFormatnull 参数定义了单独设置的路径。

  • 应读取的路径。如果路径引用的是文件,则读取单个文件。如果引用的是目录,则 FileInputFormat 会扫描目录以读取文件。

  • 应读取路径的模式。模式可以是 PROCESS_ONCEPROCESS_CONTINUOUSLY。在 PROCESS_ONCE 模式下,作业启动时会扫描一次读取路径,并读取所有匹配的文件。在 PROCESS_CONTINUOUSLY 模式下,在初始扫描后,路径会定期扫描,并持续读取新文件和修改过的文件。

  • 扫描路径的周期(毫秒)。在 PROCESS_ONCE 模式下,此参数被忽略。

FileInputFormat是专门用于从文件系统读取文件的InputFormat。⁴ FileInputFormat读取文件分为两步。首先,它扫描文件系统路径并为所有匹配文件创建所谓的输入分片。输入分片定义文件的范围,通常通过起始偏移量和长度。在将大文件分割为多个分片后,可以将分片分发给多个读取任务以并行读取文件。根据文件的编码方式,可能需要仅生成单个分片来整体读取文件。FileInputFormat的第二步是接收输入分片,读取由分片定义的文件范围,并返回所有相应的记录。

在 DataStream 应用程序中使用的FileInputFormat还应实现CheckpointableInputFormat接口,该接口定义了在文件分片中检查点和重置InputFormat当前读取位置的方法。如果FileInputFormat没有实现CheckpointableInputFormat接口,那么当启用检查点时,文件系统源连接器只提供至少一次的保证,因为输入格式将从上次完成检查点时处理的分片的开头开始读取。

在 1.7 版本中,Flink 提供了几个扩展FileInputFormat并实现CheckpointableInputFormat的类。TextInputFormat按行读取文本文件(由换行符分割),CsvInputFormat的子类读取逗号分隔值文件,AvroInputFormat读取 Avro 编码记录文件。

PROCESS_CONTINUOUSLY模式下,文件系统源连接器根据文件的修改时间戳识别新文件。这意味着如果文件被修改,其修改时间戳发生变化,那么文件将完全重新处理。这包括由于追加写入而进行的修改。因此,连续摄取文件的常见技术是将它们写入临时目录,并在最终确定后原子性地将它们移动到监视的目录中。当文件完全摄取并且检查点完成后,它可以从目录中移除。通过跟踪修改时间戳监控已摄取的文件,还涉及从具有最终一致性列表操作的文件存储(例如 S3)中读取文件的情况。由于文件可能不会按其修改时间戳的顺序显示,它们可能会被文件系统源连接器忽略。

注意,在PROCESS_ONCE模式下,文件系统路径扫描和创建所有分片后不会进行任何检查点。

如果您想在事件时间应用程序中使用文件系统源连接器,则应注意,由于输入分片是在单个进程中生成并以修改时间戳的顺序轮询分布到所有并行读取器中,因此生成水印可能具有挑战性。为了生成令人满意的水印,您需要考虑稍后由任务处理的分片中包含的记录的最小时间戳。

文件系统接收器连接器

将流写入文件是一个常见的需求,例如,为了为离线的即席分析准备低延迟的数据。由于大多数应用程序只能在文件最终完成后读取,并且流应用程序运行时间较长,流目标连接器通常将其输出分成多个文件。此外,通常会将记录组织成所谓的桶,以便消费应用程序更有控制地读取哪些数据。

类似于文件系统源连接器,Flink 的 StreamingFileSink 连接器包含在 flink-streaming-java 模块中。因此,您无需在构建文件中添加依赖项即可使用它。

StreamingFileSink 为应用程序提供端到端的精确一次性保证,前提是应用程序配置了精确一次性检查点,并且所有源在失败的情况下重置。我们将在本节后面更详细地讨论恢复机制。 示例 8-4 显示了如何使用最小配置创建 StreamingFileSink 并将其追加到流中。

示例 8-4. 在行编码模式下创建 StreamingFileSink
val input: DataStream[String] = …
val sink: StreamingFileSink[String] = StreamingFileSink
  .forRowFormat(
    new Path("/base/path"), 
    new SimpleStringEncoderString)
  .build()

input.addSink(sink)

StreamingFileSink 接收到一条记录时,该记录将被分配到一个桶中。桶是配置在 StreamingFileSink 构建器中的基路径的子目录——"/base/path" 在 示例 8-4 中。

桶由 BucketAssigner 选择,它是一个公共接口,并为每条记录返回一个确定记录将被写入的目录的 BucketId。可以使用构建器上的 withBucketAssigner() 方法配置 BucketAssigner。如果没有明确指定 BucketAssigner,则使用 DateTimeBucketAssigner,根据写入时的处理时间将记录分配给每小时的桶。

每个桶目录包含多个由多个并行 StreamingFileSink 实例同时写入的分片文件。此外,每个并行实例将其输出分成多个分片文件。分片文件的路径格式如下:

[base-path]/[bucket-path]/part-[task-idx]-[id]

例如,给定基路径 "/johndoe/demo" 和部分前缀 "part",路径 "/johndoe/demo/2018-07-22--17/part-4-8" 指向了由第五个(0 索引)sink 任务写入到桶 "2018-07-22--17" ——2018 年 7 月 22 日下午 5 点的第八个文件。

提交文件的 ID 可能不连续

非连续的文件 ID,即提交文件名称中的最后一个数字,不表示数据丢失。StreamingFileSink 简单地递增文件 ID。在丢弃待处理文件时,不会重用它们的 ID。

RollingPolicy 决定任务何时创建新的部分文件。您可以使用构建器的 withRollingPolicy() 方法配置 RollingPolicy。默认情况下,StreamingFileSink 使用 DefaultRollingPolicy,配置为在部分文件超过 128 MB 或旧于 60 秒时滚动。您还可以配置一个非活动间隔,之后将滚动部分文件。

StreamingFileSink 支持两种向部分文件写入记录的模式:行编码和批量编码。在行编码模式下,每个记录都单独编码并附加到部分文件中。在批量编码模式下,记录被收集并批量写入。Apache Parquet 是一种需要批量编码的文件格式,它以列式格式组织和压缩记录。

示例 8-4 创建了一个使用行编码的 StreamingFileSink,通过提供一个 Encoder 将单个记录写入部分文件。在 示例 8-4 中,我们使用了 SimpleStringEncoder,它调用记录的 toString() 方法,并将记录的 String 表示写入文件。Encoder 是一个简单的接口,只有一个方法,可以轻松实现。

如 示例 8-5 所示,可以创建一个批量编码的 StreamingFileSink

示例 8-5. 创建批量编码模式下的 StreamingFileSink
val input: DataStream[String] = …
val sink: StreamingFileSink[String] = StreamingFileSink
  .forBulkFormat(
    new Path("/base/path"), 
    ParquetAvroWriters.forSpecificRecord(classOf[AvroPojo]))
  .build()

input.addSink(sink)

批量编码模式下的 StreamingFileSink 需要一个 BulkWriter.Factory。在 示例 8-5 中,我们使用 Parquet 作为 Avro 文件的写入器。请注意,Parquet 写入器位于 flink-parquet 模块中,需要将其作为依赖项添加。通常情况下,BulkWriter.Factory 是一个接口,可以为自定义文件格式(如 Apache Orc)实现。

注意

批量编码模式下的 StreamingFileSink 无法选择 RollingPolicy。批量编码格式只能与 OnCheckpointRollingPolicy 结合使用,该策略在每次检查点时滚动进行中的部分文件。

StreamingFileSink 提供精确一次输出保证。该接收器通过一种提交协议实现这一点,通过不同阶段移动文件:进行中、待处理和已完成,该协议基于 Flink 的检查点机制。当接收器写入文件时,文件处于进行中状态。当 RollingPolicy 决定滚动文件时,通过重命名将其关闭并移到待处理状态。当下一个检查点完成时,将待处理文件移动到已完成状态(再次通过重命名)。

可能永远不会提交的待处理文件

在某些情况下,待处理文件可能永远不会被提交。StreamingFileSink 确保这不会导致数据丢失。然而,这些文件不会自动清理。

在手动删除挂起文件之前,您需要检查它是否挂起或即将提交。一旦找到一个具有相同任务索引和较高 ID 的已提交文件,就可以安全地删除一个挂起文件。

在失败的情况下,sink 任务需要将其当前正在进行的文件重置为上一个成功检查点处的写入偏移量。这通过关闭当前正在进行的文件并丢弃文件末尾的无效部分来完成,例如通过使用文件系统的截断操作。

StreamingFileSink 需要启用检查点

如果应用程序不启用检查点,StreamingFileSink将永远不会将文件从挂起状态移动到完成状态。

Apache Cassandra Sink 连接器

Apache Cassandra 是一个流行的、可扩展的和高可用的列存储数据库系统。Cassandra 将数据集建模为包含多个类型列的行表。一个或多个列必须被定义为(复合)主键。每行可以通过其主键唯一标识。除了其他 API 之外,Cassandra 还提供 Cassandra 查询语言(CQL),这是一种类似于 SQL 的语言,用于读取和写入记录以及创建、修改和删除数据库对象,例如键空间和表。

Flink 提供了一个 sink 连接器,用于将数据流写入 Cassandra。Cassandra 的数据模型基于主键,并且所有写入到 Cassandra 的操作都具有 upsert 语义。结合确切一次的检查点、可重置源和确定性应用逻辑,upsert 写操作产生最终确切一次的输出一致性。输出只是最终一致,因为在恢复期间结果会重置到先前的版本,这意味着消费者可能会读取比之前读取的更旧的结果。此外,多个键的值的版本可能会不同步。

为了在恢复期间防止时间不一致性并为具有非确定性应用逻辑的应用程序提供确切一次的输出保证,Flink 的 Cassandra 连接器可以配置为利用 WAL。我们稍后在本节中会详细讨论 WAL 模式。以下代码显示了您需要在应用程序的构建文件中添加的依赖项,以使用 Cassandra sink 连接器:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-cassandra_2.12</artifactId>
  <version>1.7.1</version>
</dependency>

为了说明 Cassandra sink 连接器的使用,我们使用一个简单的示例,展示一个包含传感器读数数据的 Cassandra 表,由sensorIdtemperature两列组成。在示例 8-6 中的 CQL 语句创建了一个名为“example”的键空间和一个名为“sensors”的表。

示例 8-6. 定义一个 Cassandra 示例表
CREATE KEYSPACE IF NOT EXISTS example
  WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'};

CREATE TABLE IF NOT EXISTS example.sensors (
  sensorId VARCHAR,
  temperature FLOAT,
  PRIMARY KEY(sensorId)
);

Flink 提供了不同的 sink 实现,用于将不同数据类型的数据流写入 Cassandra。Flink 的 Java 元组和 Row 类型以及 Scala 的内置元组和案例类与用户定义的 POJO 类型处理方式不同。我们分别讨论这两种情况。示例 8-7 展示了如何创建一个将 DataStream[(String, Float)] 写入“sensors”表的 sink。

示例 8-7. 为元组创建一个 Cassandra sink
val readings: DataStream[(String, Float)] = ???

val sinkBuilder: CassandraSinkBuilder[(String, Float)] =
  CassandraSink.addSink(readings)
sinkBuilder
  .setHost("localhost")
  .setQuery(
    "INSERT INTO example.sensors(sensorId, temperature) VALUES (?, ?);")
  .build()

Cassandra sink 使用通过调用 CassandraSink.addSink() 方法并传入应发出的 DataStream 对象来获取的构建器进行创建和配置。该方法返回适合 DataStream 数据类型的正确构建器。在示例 8-7 中,它返回一个处理 Scala 元组的 Cassandra sink 构建器。

为了元组、案例类和行创建的 Cassandra sink 构建器需要指定 CQL INSERT 查询。⁵ 该查询使用 CassandraSinkBuilder.setQuery() 方法进行配置。在执行期间,sink 将查询注册为准备好的语句,并将元组、案例类或行的字段转换为准备好的语句的参数。根据它们的位置将字段映射到参数;第一个值转换为第一个参数,依此类推。

由于 POJO 字段没有自然顺序,它们需要进行不同的处理。示例 8-8 展示了如何为类型为 SensorReading 的 POJO 配置 Cassandra sink。

示例 8-8. 为 POJO 创建一个 Cassandra sink
val readings: DataStream[SensorReading] = ???

CassandraSink.addSink(readings)
  .setHost("localhost")
  .build()

正如您在示例 8-8 中所看到的,我们并未指定 INSERT 查询。相反,POJO 交给了 Cassandra 的对象映射器,后者会自动将 POJO 字段映射到 Cassandra 表的字段。为了使其正常工作,需要使用 Cassandra 注解对 POJO 类及其字段进行注释,并为所有字段提供如示例 8-9 所示的设置器和获取器。在讨论支持的数据类型时,Flink 要求使用默认构造函数,如 “支持的数据类型” 中所述。

示例 8-9. 带有 Cassandra 对象映射器注解的 POJO 类
@Table(keyspace = "example", name = "sensors")
class SensorReadings(
  @Column(name = "sensorId") var id: String,
  @Column(name = "temperature") var temp: Float) {

  def this() = {
      this("", 0.0)
 }

  def setId(id: String): Unit = this.id = id
  def getId: String = id
  def setTemp(temp: Float): Unit = this.temp = temp
  def getTemp: Float = temp
}

除了图 8-7 和图 8-8 中的配置选项外,Cassandra sink 构建器提供了一些额外的方法来配置 sink 连接器:

  • setClusterBuilder(ClusterBuilder): ClusterBuilder 构建了一个管理与 Cassandra 的连接的 Cassandra Cluster。除了其他选项外,它可以配置一个或多个联系点的主机名和端口;定义负载均衡、重试和重新连接策略;并提供访问凭据。

  • setHost(String, [Int]): 这个方法是一个简化版的ClusterBuilder,配置了单个联系点的主机名和端口。如果未配置端口,则使用 Cassandra 的默认端口 9042。

  • setQuery(String): 这指定了 CQL INSERT 查询,用于将元组、case 类或行写入 Cassandra。查询不能配置为发射 POJO。

  • setMapperOptions(MapperOptions): 这为 Cassandra 的对象映射器提供选项,如一致性配置、生存时间(TTL)和空字段处理。如果接收器发射元组、case 类或行,则选项将被忽略。

  • enableWriteAheadLog([CheckpointCommitter]): 这启用了 WAL,在非确定性应用逻辑的情况下提供了精确一次性输出保证。CheckpointCommitter用于将已完成检查点的信息存储在外部数据存储中。如果未配置CheckpointCommitter,则将信息写入特定的 Cassandra 表中。

基于 Flink 的GenericWriteAheadSink运算符实现了带 WAL 的 Cassandra 接收器连接器。关于这个运算符的工作方式,包括CheckpointCommitter的角色以及提供的一致性保证,在 “事务性接收器连接器” 中有更详细的描述。

实现自定义源函数

DataStream API 提供了两个接口来实现源连接器,以及相应的RichFunction抽象类:

  • SourceFunctionRichSourceFunction可以用来定义非并行源连接器,即运行单个任务的源。

  • ParallelSourceFunctionRichParallelSourceFunction可用于定义使用多个并行任务实例运行的源连接器。

除了非并行和并行之外,两个接口是相同的。就像处理函数的富变体一样,RichSourceFunctionRichParallelSourceFunction的子类可以重写open()close()方法,并访问提供并行任务实例数及当前实例索引等信息的RuntimeContext

SourceFunctionParallelSourceFunction定义了两种方法:

  • void run(SourceContext<T> ctx)

  • void cancel()

run() 方法实际上是读取或接收记录并将其输入到 Flink 应用程序的工作。根据数据来源系统的不同,数据可能是推送或拉取的。run() 方法由 Flink 调用一次,并在专用源线程中运行,通常以无限循环(无限流)读取或接收数据并发出记录。任务可以在某个时间点被显式取消,或者在输入完全消耗时终止,例如有限流的情况。

当应用程序被取消并关闭时,Flink 会调用cancel()方法。为了执行优雅的关闭,运行在单独线程中的run()方法应该在调用cancel()方法时立即终止。示例 8-10 展示了一个从 0 计数到Long.MaxValue的简单源函数。

示例 8-10. 计数到 Long.MaxValue 的 SourceFunction
class CountSource extends SourceFunction[Long] {
  var isRunning: Boolean = true

  override def run(ctx: SourceFunction.SourceContext[Long]) = {

    var cnt: Long = -1
    while (isRunning && cnt < Long.MaxValue) {
      cnt += 1
      ctx.collect(cnt)
    }
  }

  override def cancel() = isRunning = false
}

可重置的源函数

本章前面我们解释过,Flink 只能为使用可以重放其输出数据的源连接器的应用程序提供满意的一致性保证。如果外部系统提供数据的源函数暴露了检索和重置读取偏移的 API,则源函数可以重放其输出。此类系统的示例包括提供文件流的偏移和用于将文件流移动到特定位置的查找方法的文件系统,以及为主题的每个分区提供偏移并可以设置分区读取位置的 Apache Kafka。相反的例子是从网络套接字读取数据的源连接器,它会立即丢弃传递的数据。

支持输出重播的源函数需要与 Flink 的检查点机制集成,并且在进行检查点时必须持久化所有当前的读取位置。当应用程序从保存点启动或从故障中恢复时,读取偏移会从最新的检查点或保存点中检索。如果应用程序在没有现有状态的情况下启动,则必须将读取偏移设置为默认值。可重置的源函数需要实现CheckpointedFunction接口,并应该将读取偏移和所有相关的元信息(例如文件路径或分区 ID)存储在操作符列表状态或操作符联合列表状态中,具体取决于在重新缩放应用程序的情况下如何分发偏移。有关操作符列表状态和联合列表状态的分发行为详情,请参见“扩展有状态操作符”。

此外,确保在进行检查点时不会在SourceFunction.run()方法中推进读取偏移并发出数据非常重要;换句话说,在调用CheckpointedFunction.snapshotState()方法时也是如此。这是通过在在一个同步于从SourceContext.getCheckpointLock()方法获取的锁对象上的代码中保护推进读取位置和发出记录的代码块来实现的。示例 8-11 展示了如何使得 示例 8-10 中的CountSource可重置。

示例 8-11. 可重置的 SourceFunction
class ResettableCountSource
    extends SourceFunction[Long] with CheckpointedFunction {

  var isRunning: Boolean = true
  var cnt: Long = _
  var offsetState: ListState[Long] = _

  override def run(ctx: SourceFunction.SourceContext[Long]) = {
    while (isRunning && cnt < Long.MaxValue) {
      // synchronize data emission and checkpoints
      ctx.getCheckpointLock.synchronized {
        cnt += 1
        ctx.collect(cnt)
      }
    }
  }

  override def cancel() = isRunning = false

  override def snapshotState(snapshotCtx: FunctionSnapshotContext): Unit = {
    // remove previous cnt
    offsetState.clear()
    // add current cnt
    offsetState.add(cnt)
  }

  override def initializeState(
      initCtx: FunctionInitializationContext): Unit = {

    val desc = new ListStateDescriptorLong
    offsetState = initCtx.getOperatorStateStore.getListState(desc)
    // initialize cnt variable
    val it = offsetState.get()
    cnt = if (null == it || !it.iterator().hasNext) {
      -1L
    } else {
      it.iterator().next()
    }
  }
}

源函数、时间戳和水印

源函数的另一个重要方面是时间戳和水印。如“事件时间处理”和“分配时间戳和生成水印”所指出的,DataStream API 提供了两种选项来分配时间戳和生成水印。可以通过专用的TimestampAssigner(详见“分配时间戳和生成水印”)分配和生成时间戳和水印,或者由源函数分配和生成。

源函数通过其SourceContext对象分配时间戳并发出水印。SourceContext提供以下方法:

  • def collectWithTimestamp(T record, long timestamp): Unit

  • def emitWatermark(Watermark watermark): Unit

collectWithTimestamp()以其关联的时间戳发出记录,而emitWatermark()则发出提供的水印。

除了消除额外运算符的需求之外,在源函数中分配时间戳和生成水印还具有以下好处,例如,如果源函数的一个并行实例从多个流分区中消费记录,比如 Kafka 主题的分区。通常情况下,如 Kafka 之类的外部系统只能保证流分区内的消息顺序。考虑到源函数操作符的并行度为 2,并从具有六个分区的 Kafka 主题读取数据的情况,则源函数的每个并行实例将从三个 Kafka 主题分区中读取记录。因此,源函数的每个实例复用三个流分区的记录来发出它们。复用记录很可能会引入与事件时间时间戳相关的额外无序性,从而导致下游时间戳分配器产生比预期更多的延迟记录。

为避免这种行为,源函数可以为每个流分区独立生成水印,并始终将其分区的最小水印作为其水印发出。这样可以确保在每个分区上的顺序保证被利用,且不会发出不必要的延迟记录。

源函数必须处理的另一个问题是那些变得空闲且不再发出数据的实例。这可能非常棘手,因为它可能阻止整个应用程序推进其水印,从而导致应用程序停滞。由于水印应该是数据驱动的,水印生成器(无论是集成在源函数中还是在时间戳分配器中)如果没有收到输入记录,则不会发出新的水印。如果查看 Flink 如何传播和更新水印(参见“水印传播和事件时间”),您可以看到,如果应用程序涉及到分区操作(keyBy()rebalance()等),单个不推进水印的操作符可能会使应用程序的所有水印都停滞。

Flink 通过将源函数标记为临时空闲的机制来避免这种情况。在空闲时,Flink 的水印传播机制将忽略空闲的流分区。一旦源函数重新开始发出记录,源就会自动设置为活动状态。源函数可以通过调用方法SourceContext.markAsTemporarilyIdle()来决定何时将自己标记为空闲。

实现自定义的 Sink 函数

在 Flink 的 DataStream API 中,任何操作符或函数都可以向外部系统或应用程序发送数据。一个DataStream不一定要最终流向一个 sink 操作符。例如,您可以实现一个FlatMapFunction,通过 HTTP POST 调用将每个传入的记录发送出去,而不是通过其Collector。尽管如此,DataStream API 提供了一个专用的SinkFunction接口和一个相应的RichSinkFunction抽象类。⁷ SinkFunction接口提供了一个方法:

void invoke(IN value, Context ctx)

SinkFunctionContext对象提供了当前处理时间,当前水印(即在 sink 端的当前事件时间)以及记录的时间戳的访问权限。

示例 8-12 展示了一个简单的SinkFunction,将传感器读数写入到一个 socket 中。请注意,在启动程序之前,您需要启动一个监听 socket 的进程。否则,程序会因为无法打开到 socket 的连接而失败,抛出ConnectException。在 Linux 上运行命令nc -l localhost 9191来监听 localhost:9191。

示例 8-12. 一个简单的 SinkFunction,将数据写入到 socket 中
val readings: DataStream[SensorReading] = ???

// write the sensor readings to a socket
readings.addSink(new SimpleSocketSink("localhost", 9191))
  // set parallelism to 1 because only one thread can write to a socket
  .setParallelism(1)

// -----

class SimpleSocketSink(val host: String, val port: Int)
    extends RichSinkFunction[SensorReading] {

  var socket: Socket = _
  var writer: PrintStream = _

  override def open(config: Configuration): Unit = {
    // open socket and writer
    socket = new Socket(InetAddress.getByName(host), port)
    writer = new PrintStream(socket.getOutputStream)
  }

  override def invoke(
      value: SensorReading,
      ctx: SinkFunction.Context[_]): Unit = {
    // write sensor reading to socket
    writer.println(value.toString)
    writer.flush()
  }

  override def close(): Unit = {
    // close writer and socket
    writer.close()
    socket.close()
  }
}

正如讨论的那样,一个应用程序的端到端一致性保证取决于其 sink 连接器的属性。为了实现端到端的精确一次语义,一个应用程序需要具有幂等或事务性的 sink 连接器。示例 8-12 中的SinkFunction既不执行幂等写入,也不支持事务性写入。由于 socket 的特性是仅追加,无法执行幂等写入。由于 socket 不具备内置的事务支持,只能使用 Flink 的通用 WAL sink 来执行事务性写入。在接下来的几节中,您将学习如何实现幂等或事务性的 sink 连接器。

幂等的 Sink 连接器

对于许多应用程序来说,SinkFunction接口足以实现一个幂等的 sink 连接器。这是可能的,如果满足以下两个属性:

  1. 结果数据具有确定性(组合)键,可以在其上执行幂等更新。对于计算每个传感器和每分钟的平均温度的应用程序,确定性键可以是传感器的 ID 和每分钟的时间戳。确定性键对于确保在恢复时正确覆盖所有写入非常重要。

  2. 外部系统支持按键更新,如关系数据库系统或键值存储。

示例 8-13 展示了如何实现和使用一个幂等的SinkFunction,用于写入到 JDBC 数据库,本例中为内置的 Apache Derby 数据库。

示例 8-13. 一个幂等的 SinkFunction,用于向 JDBC 数据库写入
val readings: DataStream[SensorReading] = ???

// write the sensor readings to a Derby table
readings.addSink(new DerbyUpsertSink)

// -----

class DerbyUpsertSink extends RichSinkFunction[SensorReading] {
  var conn: Connection = _
  var insertStmt: PreparedStatement = _
  var updateStmt: PreparedStatement = _

  override def open(parameters: Configuration): Unit = {
    // connect to embedded in-memory Derby
    conn = DriverManager.getConnection(
       "jdbc:derby:memory:flinkExample",
       new Properties())
    // prepare insert and update statements
    insertStmt = conn.prepareStatement(
      "INSERT INTO Temperatures (sensor, temp) VALUES (?, ?)")
    updateStmt = conn.prepareStatement(
      "UPDATE Temperatures SET temp = ? WHERE sensor = ?")
  }

  override def invoke(r: SensorReading, context: Context[_]): Unit = {
    // set parameters for update statement and execute it
    updateStmt.setDouble(1, r.temperature)
    updateStmt.setString(2, r.id)
    updateStmt.execute()
    // execute insert statement if update statement did not update any row
    if (updateStmt.getUpdateCount == 0) {
      // set parameters for insert statement
      insertStmt.setString(1, r.id)
      insertStmt.setDouble(2, r.temperature)
      // execute insert statement
      insertStmt.execute()
    }
  }

  override def close(): Unit = {
    insertStmt.close()
    updateStmt.close()
    conn.close()
  }
}

由于 Apache Derby 没有提供内置的UPSERT语句,示例的 sink 在首先尝试更新行并在给定键不存在行时插入新行的情况下执行UPSERT写入操作。当 WAL 未启用时,Cassandra sink 连接器采用相同的方法。

事务性 Sink 连接器

每当幂等的 sink 连接器不适用时,无论是应用程序输出的特性、所需 sink 系统的属性,还是由于更严格的一致性要求,事务性 sink 连接器都可以作为一种选择。如前所述,事务性 sink 连接器需要与 Flink 的检查点机制集成,因为它们只有在检查点成功完成时才能将数据提交到外部系统。

为了简化事务性 sink 的实现,Flink 的 DataStream API 提供了两个模板,可以扩展为实现自定义 sink 运算符。这两个模板都实现了CheckpointListener接口,用于接收来自 JobManager 关于已完成检查点的通知(有关接口详细信息,请参见“接收有关已完成检查点的通知”):

  • GenericWriteAheadSink模板会收集每个检查点的所有出站记录,并将它们存储在 sink 任务的操作状态中。在发现任务接收到检查点完成通知时,它会将已完成检查点的记录写入到外部系统。启用了 WAL 的 Cassandra sink 连接器实现了此接口。

  • TwoPhaseCommitSinkFunction模板利用外部 sink 系统的事务特性。对于每个检查点,它启动一个新事务,并在当前事务的上下文中将所有后续记录写入到 sink 系统。当接收到相应检查点的完成通知时,sink 会提交事务。

接下来,我们将描述两种接口及其一致性保证。

GenericWriteAheadSink

GenericWriteAheadSink简化了具有改进一致性属性的 sink 运算符的实现。该运算符集成了 Flink 的检查点机制,并旨在将每条记录仅写入外部系统一次。但是,请注意,在某些故障场景中,写前日志 sink 可能会多次发出记录。因此,GenericWriteAheadSink不能提供百分之百的恰好一次保证,只能提供至少一次保证。我们将在本节的后续部分详细讨论这些场景。

GenericWriteAheadSink通过将所有接收到的记录追加到以检查点为分段的预写式日志中来工作。每当 sink 操作符接收到检查点屏障时,它就会开始一个新的段,并且所有后续记录都追加到新的段中。WAL 作为操作符状态存储和检查点。由于日志将被恢复,在发生故障的情况下不会丢失任何记录。

GenericWriteAheadSink接收到有关已完成检查点的通知时,它会发射存储在 WAL 中与成功检查点对应的段中的所有记录。根据 sink 操作符的具体实现,记录可以写入任何类型的存储或消息系统。当所有记录成功发射后,相应的检查点必须在内部提交。

检查点在两个步骤中被提交。首先,sink 持久存储检查点已提交的信息,其次将记录从 WAL 中删除。不可能将提交信息存储在 Flink 的应用程序状态中,因为它不是持久的,并且在发生故障时将被重置。相反,GenericWriteAheadSink依赖于一个可插拔的组件称为CheckpointCommitter,用于在外部持久存储中存储和查找已提交检查点的信息。例如,默认情况下,Cassandra sink 连接器使用一个写入 Cassandra 的CheckpointCommitter

由于GenericWriteAheadSink内置的逻辑,实现利用 WAL 的 sink 并不困难。扩展GenericWriteAheadSink的操作符需要提供三个构造参数:

  • 如前所述的CheckpointCommitter

  • 用于序列化输入记录的TypeSerializer

  • 作业 ID 会传递给CheckpointCommitter,以便在应用程序重启时标识提交信息

此外,预写式操作符需要实现一个单一方法:

boolean sendValues(Iterable<IN> values, long chkpntId, long timestamp)

GenericWriteAheadSink调用sendValues()方法将完成检查点的所有记录写入外部存储系统。该方法接收一个检查点中所有记录的可迭代对象,检查点的 ID 以及检查点被获取的时间戳。如果所有写入都成功,则该方法必须返回true,如果写入失败,则返回false

示例 8-14 展示了一个将数据写入标准输出的预写式 sink 的实现。它使用了FileCheckpointCommitter,这里不进行讨论。您可以在包含本书示例的存储库中查找其实现。

注意

请注意,GenericWriteAheadSink未实现SinkFunction接口。因此,扩展GenericWriteAheadSink的 sink 无法使用DataStream.addSink()添加,而是使用DataStream.transform()方法附加。

示例 8-14. 向标准输出写入的 WAL sink
val readings: DataStream[SensorReading] = ???

// write the sensor readings to the standard out via a write-ahead log
readings.transform(
  "WriteAheadSink", new SocketWriteAheadSink)

// -----

class StdOutWriteAheadSink extends GenericWriteAheadSinkSensorReading),
    // Serializer for records
    createTypeInformation[SensorReading]
      .createSerializer(new ExecutionConfig),
    // Random JobID used by the CheckpointCommitter
    UUID.randomUUID.toString) {

  override def sendValues(
      readings: Iterable[SensorReading],
      checkpointId: Long,
      timestamp: Long): Boolean = {

    for (r <- readings.asScala) {
      // write record to standard out
      println(r)
    }
    true
  }
}

示例存储库包含一个应用程序,它定期失败和恢复,以演示 StdOutWriteAheadSink 和常规 DataStream.print() 汇聚在故障情况下的行为。

如前所述,GenericWriteAheadSink 无法提供百分之百的精确一次性保证。有两种故障情况可能导致记录被多次发射:

  1. 当任务在运行 sendValues() 方法时程序失败。如果外部汇聚系统无法原子化地写入多条记录(要么全部写入,要么一个也不写入),那么可能会写入部分记录而其他记录未被写入。由于检查点尚未提交,因此在恢复期间,汇聚器将重新写入所有记录。

  2. 所有记录都已正确写入,sendValues() 方法返回 true;然而,在调用 CheckpointCommitterCheckpointCommitter 未能提交检查点之前,程序失败。在恢复期间,所有未提交检查点的记录将再次写入。

注意

请注意,这些故障场景不会影响 Cassandra sink 连接器的精确一次性保证,因为它执行 UPSERT 写入。Cassandra sink 连接器受益于 WAL,因为它防止非确定性键,并防止向 Cassandra 写入不一致的内容。

TwoPhaseCommitSinkFunction

Flink 提供了 TwoPhaseCommitSinkFunction 接口来简化提供端到端精确一次性保证的汇聚函数的实现。然而,一个 2PC 汇聚函数是否提供此类保证取决于实现细节。我们从一个问题开始讨论这个接口:“2PC 协议是否太昂贵?”

通常情况下,2PC 是确保分布式系统一致性的昂贵方法。然而,在 Flink 的背景下,该协议仅在每次检查点时运行一次。此外,TwoPhaseCommitSinkFunction 协议依赖于 Flink 的常规检查点机制,因此增加的开销很小。TwoPhaseCommitSinkFunction 工作方式与 WAL sink 类似,但不会在 Flink 的应用状态中收集记录;相反,它会将它们作为开放事务写入外部汇聚系统。

TwoPhaseCommitSinkFunction 实现了以下协议。在接收端任务发出第一条记录之前,它会在外部接收系统上启动一个事务。随后接收到的所有记录都将在事务的上下文中写入。当 JobManager 启动检查点并在应用程序源头注入障碍时,2PC 协议的投票阶段开始。当操作员接收到障碍时,它会检查其状态并在完成后向 JobManager 发送确认消息。当接收端任务接收到障碍时,它会持久化其状态,准备当前事务以供提交,并在 JobManager 处确认检查点。向 JobManager 发送的确认消息类似于教科书 2PC 协议的提交投票。接收端任务不应立即提交事务,因为不能保证作业的所有任务都会完成其检查点。接收端任务还会为在下一个检查点障碍之前到达的所有记录启动一个新事务。

当 JobManager 收到所有任务实例的成功检查点通知时,它会向所有感兴趣的任务发送检查点完成通知。此通知对应于 2PC 协议中的提交命令。当接收到通知时,接收端任务会提交先前检查点的所有未完成事务。⁸ 一旦接收端任务确认其检查点,即使出现故障,也必须能够提交相应的事务。如果事务无法提交,则接收端会丢失数据。当所有接收端任务都提交了其事务时,2PC 协议的一个迭代成功完成。

让我们总结外部接收系统的要求:

  • 外部接收系统必须提供事务支持,或者接收端必须能够在外部系统上模拟事务。因此,接收端应能够写入接收系统,但在提交前写入的数据不能对外部可见。

  • 在检查点间隔期间,事务必须保持打开状态并接受写入。

  • 事务必须等待收到检查点完成通知后才能提交。在恢复周期中,这可能需要一些时间。如果接收系统关闭了一个事务(例如超时),未提交的数据将会丢失。

  • 接收端必须能够在进程失败后恢复事务。一些接收系统提供了可以用于提交或中止未完成事务的事务 ID。

  • 提交事务必须是幂等操作 - 接收端或外部系统应该能够知道事务已经提交,或者重复提交不会产生影响。

通过查看一个具体的示例,可以更容易理解接收器系统的协议和要求。在 示例 8-15 中展示了一个 TwoPhaseCommitSinkFunction,它向文件系统写入,保证了精确一次性。基本上,这是前面讨论的 BucketingFileSink 的简化版本。

示例 8-15. 一个写入文件的事务性接收器
class TransactionalFileSink(val targetPath: String, val tempPath: String)
    extends TwoPhaseCommitSinkFunction(String, Double), String, Void,
      createTypeInformation[Void].createSerializer(new ExecutionConfig)) {

  var transactionWriter: BufferedWriter = _

  /** Creates a temporary file for a transaction into which the records are
 * written.
    */
  override def beginTransaction(): String = {
    // path of transaction file is built from current time and task index
    val timeNow = LocalDateTime.now(ZoneId.of("UTC"))
      .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
    val taskIdx = this.getRuntimeContext.getIndexOfThisSubtask
    val transactionFile = s"$timeNow-$taskIdx"

    // create transaction file and writer
    val tFilePath = Paths.get(s"$tempPath/$transactionFile")
    Files.createFile(tFilePath)
    this.transactionWriter = Files.newBufferedWriter(tFilePath)
    println(s"Creating Transaction File: $tFilePath")
    // name of transaction file is returned to later identify the transaction
    transactionFile
  }

  /** Write record into the current transaction file. */
  override def invoke(
      transaction: String,
      value: (String, Double),
      context: Context[_]): Unit = {
    transactionWriter.write(value.toString)
    transactionWriter.write('\n')
  }

  /** Flush and close the current transaction file. */
  override def preCommit(transaction: String): Unit = {
    transactionWriter.flush()
    transactionWriter.close()
  }

  /** Commit a transaction by moving the precommitted transaction file
    * to the target directory.
    */
  override def commit(transaction: String): Unit = {
    val tFilePath = Paths.get(s"$tempPath/$transaction")
    // check if the file exists to ensure that the commit is idempotent
    if (Files.exists(tFilePath)) {
      val cFilePath = Paths.get(s"$targetPath/$transaction")
      Files.move(tFilePath, cFilePath)
    }
  }

  /** Aborts a transaction by deleting the transaction file. */
  override def abort(transaction: String): Unit = {
    val tFilePath = Paths.get(s"$tempPath/$transaction")
    if (Files.exists(tFilePath)) {
      Files.delete(tFilePath)
    }
  }
}

TwoPhaseCommitSinkFunction[IN, TXN, CONTEXT] 有三个类型参数:

  • IN 指定了输入记录的类型。在 示例 8-15 中,这是一个 Tuple2,包含一个 String 和一个 Double 字段。

  • TXN 定义了一个事务标识符,用于在失败后识别和恢复事务。在 示例 8-15 中,这是一个字符串,保存着事务文件的名称。

  • CONTEXT 定义了一个可选的自定义上下文。在 示例 8-15 中的 TransactionalFileSink 不需要上下文,因此将其类型设置为 Void

TwoPhaseCommitSinkFunction 的构造函数需要两个 TypeSerializer,一个用于 TXN 类型,另一个用于 CONTEXT 类型。

TwoPhaseCommitSinkFunction 最终定义了需要实现的五个函数:

  • beginTransaction(): TXN 开始一个新事务并返回事务标识符。在 示例 8-15 中的 TransactionalFileSink 创建一个新的事务文件并返回其名称作为标识符。

  • invoke(txn: TXN, value: IN, context: Context[_]): Unit 将一个值写入当前事务。在 示例 8-15 中的接收器将该值作为 String 追加到事务文件中。

  • preCommit(txn: TXN): Unit 预提交一个事务。预提交的事务将不会再接收到进一步的写入。我们在 示例 8-15 中的实现会刷新并关闭事务文件。

  • commit(txn: TXN): Unit 提交一个事务。该操作必须幂等——如果多次调用该方法,记录不应被写入输出系统两次。在 示例 8-15 中,我们检查事务文件是否仍然存在,并在是这种情况下将其移动到目标目录。

  • abort(txn: TXN): Unit 中止一个事务。该方法可能会对一个事务调用两次。我们在 示例 8-15 中的 TransactionalFileSink 检查事务文件是否仍然存在,并在是这种情况下将其删除。

正如您所看到的,接口的实现并不复杂。然而,实现的复杂性和一致性保证取决于许多因素,包括汇集系统的特性和能力。例如,Flink 的 Kafka 生产者实现了TwoPhaseCommitSinkFunction接口。正如前面提到的,如果由于超时而回滚事务,连接器可能会丢失数据。⁹ 因此,即使实现了TwoPhaseCommitSinkFunction接口,它也不能提供确切的仅一次保证。

异步访问外部系统

除了摄入或发出数据流之外,通过在远程数据库中查找信息来丰富数据流的操作是另一个常见的用例,需要与外部存储系统进行交互。一个示例是著名的 Yahoo!流处理基准,它基于广告点击流,需要使用存储在键值存储中的相应广告系列的详细信息来丰富它们。

处理此类用例的直接方法是实现一个MapFunction,该函数查询数据存储以获取每个处理记录,等待查询返回结果,丰富记录并发出结果。尽管这种方法易于实现,但存在一个主要问题:每次对外部数据存储的请求都会增加显著的延迟(请求/响应涉及两个网络消息),而MapFunction大部分时间都在等待查询结果。

Apache Flink 提供了AsyncFunction以减少远程 I/O 调用的延迟。AsyncFunction并发地发送多个查询并异步处理它们的结果。可以配置它以保持记录的顺序(请求的返回顺序可能与发送的顺序不同),或者按照查询结果的顺序返回结果以进一步减少延迟。该函数还与 Flink 的检查点机制完全集成——当前正在等待响应的输入记录被检查点,并且在恢复时重复查询。此外,AsyncFunction还能够正确地与事件时间处理配合工作,因为它确保即使启用了无序结果,水印也不会被记录超越。

为了利用AsyncFunction,外部系统应提供支持异步调用的客户端,这对许多系统来说是通用的。如果系统仅提供同步客户端,您可以创建线程来发送请求和处理它们。AsyncFunction的接口如下所示:

trait AsyncFunction[IN, OUT] extends Function {
  def asyncInvoke(input: IN, resultFuture: ResultFuture[OUT]): Unit
}

函数的类型参数定义其输入和输出类型。asyncInvoke() 方法为每个输入记录调用,带有两个参数。第一个参数是输入记录,第二个参数是回调对象,用于返回函数的结果或异常。在 示例 8-16 中,我们展示了如何在 DataStream 上应用 AsyncFunction

示例 8-16. 在 DataStream 上应用 AsyncFunction
val readings: DataStream[SensorReading] = ???

val sensorLocations: DataStream[(String, String)] = AsyncDataStream
  .orderedWait(
    readings,
    new DerbyAsyncFunction,
    5, TimeUnit.SECONDS,    // timeout requests after 5 seconds
    100)                    // at most 100 concurrent requests

应用 AsyncFunction 的异步操作符配置为 AsyncDataStream 对象,¹⁰ 提供两个静态方法:orderedWait()unorderedWait()。这两个方法根据不同的参数组合进行重载。orderedWait() 应用一个异步操作符,按输入记录的顺序发出结果,而 unorderWait() 操作符仅确保水印和检查点屏障保持对齐。额外的参数指定了何时超时异步调用以及启动多少并发请求。示例 8-17 展示了 DerbyAsyncFunction,它通过其 JDBC 接口查询嵌入的 Derby 数据库。

示例 8-17. 查询 JDBC 数据库的 AsyncFunction
class DerbyAsyncFunction
    extends AsyncFunction[SensorReading, (String, String)] {

  // caching execution context used to handle the query threads
  private lazy val cachingPoolExecCtx =
    ExecutionContext.fromExecutor(Executors.newCachedThreadPool())
  // direct execution context to forward result future to callback object
  private lazy val directExecCtx =
    ExecutionContext.fromExecutor(
      org.apache.flink.runtime.concurrent.Executors.directExecutor())

  /**
 * Executes JDBC query in a thread and handles the resulting Future
 * with an asynchronous callback.
 */
  override def asyncInvoke(
      reading: SensorReading,
      resultFuture: ResultFuture[(String, String)]): Unit = {

    val sensor = reading.id
    // get room from Derby table as Future
    val room: Future[String] = Future {
      // Creating a new connection and statement for each record.
      // Note: This is NOT best practice!
      // Connections and prepared statements should be cached.
      val conn = DriverManager
        .getConnection(
          "jdbc:derby:memory:flinkExample", 
          new Properties())
      val query = conn.createStatement()

      // submit query and wait for result; this is a synchronous call
      val result = query.executeQuery(
        s"SELECT room FROM SensorLocations WHERE sensor = '$sensor'")

      // get room if there is one
      val room = if (result.next()) {
        result.getString(1)
      } else {
        "UNKNOWN ROOM"
      }

      // close resultset, statement, and connection
      result.close()
      query.close()
      conn.close()
      // return room
      room
    }(cachingPoolExecCtx)

    // apply result handling callback on the room future
    room.onComplete {
      case Success(r) => resultFuture.complete(Seq((sensor, r)))
      case Failure(e) => resultFuture.completeExceptionally(e)
    }(directExecCtx)
  }
}

DerbyAsyncFunction 中的 asyncInvoke() 方法在 示例 8-17 中将阻塞 JDBC 查询包装在一个 Future 中,并通过 CachedThreadPool 执行。为了简洁起见,我们为每条记录创建一个新的 JDBC 连接,这显然效率不高,应该避免。Future[String] 包含了 JDBC 查询的结果。

最后,我们在 Future 上应用 onComplete() 回调,并将结果(或可能的异常)传递给 ResultFuture 处理程序。与 JDBC 查询 Future 不同,onComplete() 回调由 DirectExecutor 处理,因为将结果传递给 ResultFuture 是一个轻量级操作,不需要专用线程。请注意,所有操作都是以非阻塞方式完成的。

需要指出的是,AsyncFunction 实例为其每个输入记录顺序调用—函数实例不以多线程方式调用。因此,asyncInvoke() 方法应快速返回,通过启动异步请求并使用回调处理结果,将结果转发给 ResultFuture。必须避免的常见反模式包括:

  • 发送阻塞 asyncInvoke() 方法的请求

  • 发送异步请求,但在 asyncInvoke() 方法内等待请求完成

总结

在本章中,您学习了 Flink DataStream 应用程序如何从外部系统读取数据并写入数据,以及应用程序实现不同端到端一致性保证的要求。我们介绍了 Flink 最常用的内置源和接收器连接器,这些连接器也代表了不同类型的存储系统,如消息队列、文件系统和键值存储。

然后,我们向您展示了如何实现自定义源和接收器连接器,包括 WAL 和 2PC 接收器连接器,并提供了详细的示例。最后,您了解了 Flink 的 AsyncFunction,它通过异步执行和处理请求,显著提高了与外部系统交互的性能。

¹ 精确一次状态一致性是端到端精确一致性的要求,但并不相同。

² 我们在 “GenericWriteAheadSink” 中更详细地讨论了 WAL 接收器的一致性保证。

³ 有关时间戳分配器接口的详细信息,请参见 第六章。

InputFormat 是 Flink 在 DataSet API 中定义数据源的接口。

⁵ 与 SQL 的 INSERT 语句相比,CQL 的 INSERT 语句的行为类似于 upsert 查询——它们会覆盖具有相同主键的现有行。

⁶ 在 第五章 中讨论了 Rich 函数。

⁷ 通常使用 RichSinkFunction 接口,因为接收器函数通常需要在 RichFunction.open() 方法中建立与外部系统的连接。有关 RichFunction 接口的详细信息,请参见 第五章。

⁸ 如果确认消息丢失,一个任务可能需要提交多个事务。

⁹ 请参见 “Apache Kafka Sink Connector” 中的详细信息。

¹⁰ Java API 提供了一个 AsyncDataStream 类及其相应的静态方法。

第九章:设置 Flink 用于流应用程序

当今的数据基础设施多种多样。像 Apache Flink 这样的分布式数据处理框架需要设置与多个组件交互,例如资源管理器、文件系统和用于分布式协调的服务。

在本章中,我们讨论了部署 Flink 集群的不同方法以及如何安全配置和使其高可用。我们解释了不同 Hadoop 版本和文件系统的 Flink 设置,并讨论了 Flink 主节点和工作节点进程的最重要配置参数。阅读完本章后,您将了解如何设置和配置 Flink 集群。

部署模式

Flink 可以在不同的环境中部署,例如本地机器、裸机集群、Hadoop YARN 集群或 Kubernetes 集群。在“Flink 设置的组件”中,我们介绍了 Flink 设置的不同组件:JobManager、TaskManager、ResourceManager 和 Dispatcher。本节中,我们解释了如何在不同的环境中配置和启动 Flink,包括独立集群、Docker、Apache Hadoop YARN 和 Kubernetes,以及在每种设置中如何组装 Flink 的组件。

独立集群

独立的 Flink 集群至少包括一个主进程和至少一个 TaskManager 进程,它们在一个或多个机器上运行。所有进程均作为常规 Java JVM 进程运行。图 9-1 展示了独立 Flink 设置。

主进程在单独的线程中运行调度器(Dispatcher)和资源管理器(ResourceManager)。一旦它们开始运行,任务管理器(TaskManagers)会在资源管理器注册自己。图 9-2 展示了如何将作业提交到独立集群。

客户端将作业提交给调度器(Dispatcher),调度器内部启动一个 JobManager 线程并提供执行的 JobGraph。JobManager 从资源管理器请求必要的处理槽,并在收到请求的槽后部署作业以执行。

在独立部署中,主节点和工作节点在发生故障时不会自动重新启动。如果有足够数量的处理槽可用,作业可以从工作节点故障中恢复。这可以通过运行一个或多个备用工作节点来保证。从主节点故障中恢复作业需要一个高可用设置,后面在本章中讨论。

要设置独立的 Flink 集群,请从 Apache Flink 网站下载二进制分发版,并使用以下命令解压 tar 存档:

tar xfz ./flink-1.7.1-bin-scala_2.12.tgz

提取的目录包括一个 ./bin 文件夹,其中包含用于启动和停止 Flink 进程的 bash 脚本¹。./bin/start-cluster.sh 脚本在本地主机上启动一个主进程,以及一个或多个本地或远程机器上的 TaskManager 进程。

Flink 预配置为在本地设置上运行,并在本地机器上启动单个主进程和单个 TaskManager。启动脚本必须能够启动 Java 进程。如果 java 二进制文件不在 PATH 上,可以通过导出 JAVA_HOME 环境变量或在 ./conf/flink-conf.yaml 中设置 env.java.home 参数来指定 Java 安装的基本文件夹。通过调用 ./bin/start-cluster.sh 可以启动本地 Flink 集群。可以访问 http://localhost:8081 查看 Flink 的 Web UI,并检查连接的 TaskManagers 数量和可用的插槽数量。

要启动运行在多台机器上的分布式 Flink 集群,需要调整默认配置并完成几个额外的步骤。

  • 所有应该运行 TaskManager 的机器的主机名(或 IP 地址)需要列在 ./conf/slaves 文件中。

  • start-cluster.sh 脚本要求所有机器上都配置了无密码 SSH 配置,以便能够启动 TaskManager 进程。

  • Flink 分发文件夹必须在所有机器上位于相同的路径上。常见的方法是在每台机器上挂载一个网络共享的目录,其中包含 Flink 分发。

  • 运行主进程的机器的主机名(或 IP 地址)需要在 ./conf/flink-conf.yaml 文件中配置,使用配置键 jobmanager.rpc.address

一切设置完成后,可以通过调用 ./bin/start-cluster.sh 启动 Flink 集群。该脚本将在本地启动一个 JobManager,并为 slaves 文件中的每个条目启动一个 TaskManager。可以通过访问运行主进程的机器上的 Web UI 来检查主进程是否已启动,并且所有 TaskManager 是否成功注册。本地或分布式独立集群可以通过调用 ./bin/stop-cluster.sh 停止。

Docker

Docker 是一个流行的平台,用于将应用程序打包和运行在容器中。Docker 容器由主机系统的操作系统内核运行,因此比虚拟机更轻量级。此外,它们是隔离的,并且仅通过定义良好的通道进行通信。容器是从定义容器中软件的镜像启动的。

Flink 社区成员配置并构建 Apache Flink 的 Docker 镜像,并上传到 Docker Hub,这是一个公共 Docker 镜像仓库。² 该仓库存储了最新版本的 Flink 镜像。

在 Docker 中运行 Flink 是在本地机器上设置 Flink 集群的简便方法。对于本地 Docker 设置,您需要启动两种类型的容器:运行调度器和资源管理器的主容器,以及运行任务管理器的一个或多个工作容器。这些容器共同像独立部署一样工作(见“独立集群”)。启动后,任务管理器会在资源管理器上注册自己。当将作业提交给调度器时,它会生成一个作业管理器线程,该线程从资源管理器请求处理插槽。资源管理器将任务管理器分配给作业管理器,在所有必需的资源可用后,作业管理器部署作业。

主容器和工作容器使用相同的 Docker 镜像,但使用不同的参数启动,如示例 9-1 所示。

示例 9-1. 在 Docker 中启动主容器和工作容器
// start master process
docker run -d --name flink-jobmanager \
  -e JOB_MANAGER_RPC_ADDRESS=jobmanager \
  -p 8081:8081 flink:1.7 jobmanager

// start worker process (adjust the name to start more than one TM)
docker run -d --name flink-taskmanager-1 \
  --link flink-jobmanager:jobmanager \
  -e JOB_MANAGER_RPC_ADDRESS=jobmanager flink:1.7 taskmanager

Docker 将从 Docker Hub 下载请求的镜像及其依赖项,并启动运行 Flink 的容器。作业管理器的 Docker 内部主机名通过JOB_MANAGER_RPC_ADDRESS变量传递给容器,在容器的入口点中用于调整 Flink 的配置。

第一个命令的-p 8081:8081参数将主容器的 8081 端口映射到主机的 8081 端口,以便从主机访问 Web UI。您可以在浏览器中打开http://localhost:8081来访问 Web UI。Web UI 可用于上传应用程序 JAR 文件并运行应用程序。该端口还公开了 Flink 的 REST API。因此,您还可以使用 Flink 的 CLI 客户端在./bin/flink处提交应用程序,管理正在运行的应用程序,或请求有关集群或正在运行的应用程序的信息。

注意

请注意,目前无法向 Flink Docker 镜像传递自定义配置。如果要调整某些参数,您需要构建自己的 Docker 镜像。可用的 Docker Flink 镜像的构建脚本是自定义镜像的良好起点。

您可以创建一个 Docker Compose 配置脚本,而不是手动启动两个(或更多)容器,该脚本会自动启动和配置运行在 Docker 容器中的 Flink 集群,可能还包括其他服务,如 ZooKeeper 和 Kafka。我们不会详细介绍这种模式的细节,但是 Docker Compose 配置需要指定网络配置,以便隔离容器中运行的 Flink 进程可以相互通信。有关详细信息,请参阅 Apache Flink 的文档。

Apache Hadoop YARN

YARN 是 Apache Hadoop 的资源管理组件。它管理集群环境的计算资源——集群机器的 CPU 和内存,并向请求资源的应用程序提供这些资源。YARN 分配的资源为分布在集群中的容器³,应用程序在这些容器中运行它们的进程。由于其起源于 Hadoop 生态系统,YARN 通常由数据处理框架使用。

Flink 可以在 YARN 上以两种模式运行:作业模式和会话模式。在作业模式下,启动一个 Flink 集群以运行单个作业。作业终止后,停止 Flink 集群并归还所有资源。图 9-3 显示了如何向 YARN 集群提交 Flink 作业。

当客户端提交作业以执行时,它会连接到 YARN 的资源管理器,启动一个新的 YARN 应用主节点进程,包括一个 JobManager 线程和一个资源管理器。JobManager 请求所需的插槽以运行 Flink 作业。随后,Flink 的资源管理器从 YARN 的资源管理器请求容器并启动 TaskManager 进程。一旦启动,TaskManagers 在 Flink 的资源管理器上注册其插槽,后者提供给 JobManager。最后,JobManager 向 TaskManagers 提交作业的任务以执行。

会话模式启动一个长时间运行的 Flink 集群,可以运行多个作业,需要手动停止。如果在会话模式下启动,Flink 会连接到 YARN 的资源管理器,启动一个应用主节点,其中包括一个分派器线程和一个 Flink 资源管理器线程。图 9-4 显示了一个空闲的 Flink YARN 会话设置。

当提交作业以执行时,分派器启动一个 JobManager 线程,后者从 Flink 的资源管理器请求插槽。如果没有足够的插槽可用,Flink 的资源管理器会请求从 YARN 的资源管理器获取额外的容器,以启动 TaskManager 进程,这些进程在 Flink 的资源管理器上注册。一旦有足够的插槽可用,Flink 的资源管理器分配它们给 JobManager,并开始作业执行。图 9-5 显示了作业在 Flink 的 YARN 会话模式下的执行方式。

对于作业模式和会话模式下的两种设置,Flink 的 ResourceManager 将自动重新启动失败的 TaskManagers。在 ./conf/flink-conf.yaml 配置文件中,有几个参数可以用来控制 Flink 在 YARN 上的恢复行为。例如,您可以配置最大的失败容器数量,直到应用程序被终止。为了从主节点故障中恢复,需要配置一个高可用的设置,如后面章节所述。

无论您是在 YARN 上的作业模式还是会话模式下运行 Flink,它都需要访问正确版本的 Hadoop 依赖项和 Hadoop 配置路径。"与 Hadoop 组件集成" 详细描述了所需的配置。

在一个工作正常且配置良好的 YARN 和 HDFS 设置的情况下,可以使用以下命令将 Flink 作业提交到 YARN 上执行,使用 Flink 的命令行客户端:

./bin/flink run -m yarn-cluster ./path/to/job.jar

参数 -m 定义了要提交作业的主机。如果设置为关键字 yarn-cluster,则客户端将作业提交到由 Hadoop 配置标识的 YARN 集群。Flink 的 CLI 客户端支持许多其他参数,例如控制 TaskManager 容器的内存。请参考文档以获取可用参数的参考。启动的 Flink 集群的 Web UI 由运行在 YARN 集群中某个节点上的主进程提供。您可以通过 YARN 的 Web UI 访问它,在应用概述页面的“跟踪 URL: ApplicationMaster”下提供了一个链接。

使用 ./bin/yarn-session.sh 脚本可以启动一个 Flink YARN 会话,该脚本还使用各种参数来控制容器的大小、YARN 应用程序的名称或提供动态属性。默认情况下,该脚本会打印会话集群的连接信息并且不会返回。当脚本终止时,会话会停止并释放所有资源。还可以使用 -d 标志在后台模式下启动一个 YARN 会话。可以使用 YARN 的应用程序工具终止一个分离的 Flink 会话。

一旦 Flink YARN 会话运行起来,您可以使用命令 ./bin/flink run ./path/to/job.jar 向会话提交作业。

注意

请注意,您无需提供连接信息,因为 Flink 已经记住了在 YARN 上运行的 Flink 会话的连接详细信息。与作业模式类似,Flink 的 Web UI 是从 YARN 的 Web UI 的应用概述中链接过来的。

Kubernetes

Kubernetes 是一个开源平台,允许用户在分布式环境中部署和扩展容器化应用程序。给定一个 Kubernetes 集群和打包成容器镜像的应用程序,您可以创建应用程序的部署,告诉 Kubernetes 启动多少个应用程序实例。Kubernetes 将在其资源的任何地方运行请求的容器,并在发生故障时重新启动它们。Kubernetes 还可以负责打开用于内部和外部通信的网络端口,并提供进程发现和负载均衡的服务。Kubernetes 可以在本地、云环境或混合基础设施上运行。

部署数据处理框架和应用程序在 Kubernetes 上变得非常流行。Apache Flink 也可以在 Kubernetes 上部署。在深入探讨如何在 Kubernetes 上设置 Flink 之前,我们需要简要解释一些 Kubernetes 术语:

  • 一个 pod 是由 Kubernetes 启动和管理的容器。⁴

  • 一个 部署 定义了运行的特定数量的 pod 或容器。Kubernetes 确保请求的 pod 数量持续运行,并自动重新启动失败的 pod。部署可以进行水平扩展或缩减。

  • Kubernetes 可以在集群的任何地方运行 pod。当 pod 因故障重新启动或部署进行水平扩展或缩减时,IP 地址可能会变化。如果 pod 需要彼此通信,则这显然是一个问题。Kubernetes 提供了服务来解决这个问题。一个 服务 定义了如何访问某一组 pod 的策略。它负责在集群中的不同节点上启动 pod 时更新路由。

在本地机器上运行 Kubernetes

Kubernetes 设计用于集群操作。然而,Kubernetes 项目提供了 Minikube,一个在单台机器上本地运行单节点 Kubernetes 集群的环境,用于测试或日常开发。如果您想尝试在 Kubernetes 上运行 Flink 但又没有 Kubernetes 集群,我们建议设置 Minikube。

要成功在部署在 Minikube 上的 Flink 集群上运行应用程序,您需要在部署 Flink 之前运行以下命令:minikube ssh 'sudo ip link set docker0 promisc on'

Flink 在 Kubernetes 上的设置定义了两个部署——一个用于运行主进程的 pod,另一个用于工作进程 pod。还有一个服务将主 pod 的端口暴露给工作 pod。主和工作两种类型的 pod 的行为与之前描述的独立或 Docker 部署的进程相同。主部署配置如 示例 9-2 所示。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: flink-master
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: flink
        component: master
    spec:
      containers:
      - name: master
        image: flink:1.7
        args:
        - jobmanager
        ports:
        - containerPort: 6123
          name: rpc
        - containerPort: 6124
          name: blob
        - containerPort: 6125
          name: query
        - containerPort: 8081
          name: ui
        env:
        - name: JOB_MANAGER_RPC_ADDRESS
          value: flink-master

此部署指定应运行一个单一的主容器(replicas: 1)。主容器从 Flink 1.7 Docker 镜像启动(image: flink:1.7),并使用一个参数启动主进程(args: - jobmanager)。此外,部署配置了要为 RPC 通信、blob 管理器(用于交换大文件)、可查询状态服务器以及 Web UI 和 REST 接口打开容器的哪些端口。示例 9-3 展示了工作 Pod 的部署。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: flink-worker
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: flink
        component: worker
    spec:
      containers:
      - name: worker
        image: flink:1.7
        args:
        - taskmanager
        ports:
        - containerPort: 6121
          name: data
        - containerPort: 6122
          name: rpc
        - containerPort: 6125
          name: query
        env:
        - name: JOB_MANAGER_RPC_ADDRESS
          value: flink-master

工作部署与主节点部署几乎相同,但有几点不同之处。首先,工作部署指定了两个副本,即启动了两个工作容器。工作容器基于相同的 Flink Docker 镜像启动,但使用不同的参数(args: -taskmanager)。此外,部署还打开了几个端口,并传递了 Flink 主节点部署的服务名称,以便工作节点可以访问主节点。展示了公开主进程并使其对工作容器可访问的服务定义在示例 9-4 中。

apiVersion: v1
kind: Service
metadata:
  name: flink-master
spec:
  ports:
  - name: rpc
    port: 6123
  - name: blob
    port: 6124
  - name: query
    port: 6125
  - name: ui
    port: 8081
  selector:
    app: flink
    component: master

您可以通过将每个定义存储在单独的文件中(例如master-deployment.yamlworker-deployment.yamlmaster-service.yaml)来为 Kubernetes 创建一个 Flink 部署。这些文件也提供在我们的代码仓库中。一旦有了定义文件,您可以使用kubectl命令将它们注册到 Kubernetes:

kubectl create -f master-deployment.yaml
kubectl create -f worker-deployment.yaml
kubectl create -f master-service.yaml

运行这些命令时,Kubernetes 开始部署请求的容器。您可以通过运行以下命令显示所有部署的状态:

kubectl get deployments

当您首次创建部署时,将需要一些时间来下载 Flink 容器镜像。一旦所有 Pod 都启动,您将在 Kubernetes 上运行一个 Flink 集群。但是,根据给定的配置,Kubernetes 不会将任何端口导出到外部环境。因此,您无法访问主容器来提交应用程序或访问 Web UI。您首先需要告诉 Kubernetes 从主容器到本地机器创建端口转发。通过运行以下命令来完成这一操作:

kubectl port-forward deployment/flink-master 8081:8081

当端口转发运行时,您可以在http://localhost:8081访问 Web UI。

现在,您可以上传并提交作业到运行在 Kubernetes 上的 Flink 集群。此外,您可以使用 Flink CLI 客户端(./bin/flink)提交应用程序,并访问 REST 接口请求 Flink 集群的信息或管理运行中的应用程序。

当工作 Pod 失败时,Kubernetes 将自动重新启动失败的 Pod,并恢复应用程序(假设启用并正确配置了检查点)。为了从主 Pod 故障中恢复,您需要配置一个高可用设置。

您可以通过运行以下命令关闭运行在 Kubernetes 上的 Flink 集群:

kubectl delete -f master-deployment.yaml
kubectl delete -f worker-deployment.yaml
kubectl delete -f master-service.yaml

在本节中使用的 Flink Docker 映像不支持自定义 Flink 部署的配置。您需要使用调整过的配置构建自定义 Docker 映像。提供的映像构建脚本是开始自定义映像的良好起点。

高可用设置

大多数流处理应用程序理想情况下应持续执行,尽可能少地停机。因此,许多应用程序必须能够自动从执行过程中的任何故障中恢复。虽然 ResourceManager 处理了工作节点的故障,但 JobManager 组件的故障需要配置高可用性(HA)设置。

Flink 的 JobManager 存储有关应用程序及其执行的元数据,例如应用程序 JAR 文件、JobGraph 和已完成检查点的指针。在主节点故障时,需要恢复这些信息。Flink 的 HA 模式依赖于 Apache ZooKeeper,这是一个用于分布式协调和一致性存储的服务,并且依赖于持久的远程存储,例如 HDFS、NFS 或 S3. JobManager 将所有相关数据存储在持久存储中,并向 ZooKeeper 写入信息的指针 — 存储路径。在发生故障时,新的 JobManager 会从 ZooKeeper 查找指针,并从持久存储加载元数据。我们在 “高可用设置” 中更详细地介绍了 Flink 的 HA 设置的操作模式和内部机制。在本节中,我们将为不同的部署选项配置此模式。

一个 Flink 的 HA 设置需要运行的 Apache ZooKeeper 集群和持久的远程存储,例如 HDFS、NFS 或 S3. 为了帮助用户快速启动一个用于测试目的的 ZooKeeper 集群,Flink 提供了一个用于启动的辅助脚本。首先,您需要通过调整 ./conf/zoo.cfg 文件来配置集群中所有 ZooKeeper 进程的主机和端口。完成后,可以调用 ./bin/start-zookeeper-quorum.sh 在每个配置的节点上启动一个 ZooKeeper 进程。

请勿将 start-zookeeper-quorum.sh 用于生产环境设置

在生产环境中,不应使用 Flink 的 ZooKeeper 脚本,而应仔细配置和部署一个 ZooKeeper 集群。

./conf/flink-conf.yaml 文件中设置参数来配置 Flink 的 HA 模式,具体设置如 示例 9-5 所示。

# REQUIRED: enable HA mode via ZooKeeper
high-availability: zookeeper

# REQUIRED: provide a list of all ZooKeeper servers of the quorum
high-availability.zookeeper.quorum: address1:2181[,...],addressX:2181

# REQUIRED: set storage location for job metadata in remote storage
high-availability.storageDir: hdfs:///flink/recovery

# RECOMMENDED: set the base path for all Flink clusters in ZooKeeper.
# Isolates Flink from other frameworks using the ZooKeeper cluster.
high-availability.zookeeper.path.root: /flink

HA 独立设置

Flink 独立部署不依赖于资源提供程序,如 YARN 或 Kubernetes。所有进程都需要手动启动,并且没有组件监视这些进程并在发生故障时重新启动它们。因此,独立的 Flink 集群需要备用的 Dispatcher 和 TaskManager 进程,可以接管失败进程的工作。

除了启动备用的 TaskManager 外,独立部署不需要额外的配置来从 TaskManager 失败中恢复。所有启动的 TaskManager 进程都会注册到活动 ResourceManager。只要有足够的处理插槽处于待命状态以补偿丢失的 TaskManager,应用程序就可以从 TaskManager 失败中恢复。ResourceManager 分配先前空闲的处理插槽,应用程序重新启动。

如果配置了高可用性,独立部署的所有调度器都会注册到 ZooKeeper。ZooKeeper 选举一个负责执行应用程序的领导调度器。当提交应用程序时,负责的调度器会启动一个 JobManager 线程,将其元数据存储在配置的持久存储中,并在 ZooKeeper 中存储指针,如前所述。如果运行活动调度器和 JobManager 的主进程失败,ZooKeeper 将选举新的调度器作为领导者。领导调度器通过启动一个新的 JobManager 线程来恢复失败的应用程序,该线程从 ZooKeeper 中查找元数据指针并从持久存储加载元数据。

除了之前讨论的配置外,高可用性独立部署还需要进行以下配置更改。在 ./conf/flink-conf.yaml 中,你需要为每个运行的集群设置一个集群标识符。如果多个 Flink 集群依赖同一个 ZooKeeper 实例进行故障恢复,则这是必需的。

# RECOMMENDED: set the path for the Flink cluster in ZooKeeper.
# Isolates multiple Flink clusters from each other.
# The cluster id is required to look up the metadata of a failed cluster.
high-availability.cluster-id: /cluster-1

如果你已经运行了一个 ZooKeeper 集群且正确配置了 Flink,你可以使用常规的 ./bin/start-cluster.sh 脚本来启动一个高可用的独立集群,只需在 ./conf/masters 文件中添加额外的主机名和端口。

高可用性 YARN 设置

YARN 是一个集群资源和容器管理器。默认情况下,它会自动重启失败的主节点和 TaskManager 容器。因此,在 YARN 设置中,你不需要运行备用进程来实现高可用。

Flink 的主进程作为一个 YARN ApplicationMaster 启动。YARN 会自动重新启动失败的 ApplicationMaster,但会跟踪并限制重新启动的次数,以防止无限恢复循环。你需要在 YARN 配置文件 yarn-site.xml 中配置最大 ApplicationManager 重启次数,如下所示:

<property>
  <name>yarn.resourcemanager.am.max-attempts</name>
  <value>4</value>
  <description>
    The maximum number of application master execution attempts.
    Default value is 2, i.e., an application is restarted at most once.
  </description>
</property>

此外,你需要调整 Flink 的配置文件 ./conf/flink-conf.yaml 并配置应用程序重启尝试的次数:

# Restart an application at most 3 times (+ the initial start).
# Must be less or equal to the configured maximum number of attempts.
yarn.application-attempts: 4

YARN 仅计算由于应用程序故障而导致的重启次数——不考虑由于抢占、硬件故障或重新启动而导致的重启。如果运行的是 Hadoop YARN 版本 2.6 或更高版本,则 Flink 会自动配置尝试失败的有效间隔。此参数指定只有在有效间隔内超过重试尝试时,应用程序才完全取消,意味着不考虑间隔前的尝试。Flink 会将该间隔配置为 ./conf/flink-conf.yaml 中的 akka.ask.timeout 参数的相同值,默认为 10 秒。

给定运行中的 ZooKeeper 集群和正确配置的 YARN 和 Flink 设置,您可以像没有启用 HA 一样启动作业模式或会话模式的 Flink 集群——通过 ./bin/flink run -m yarn-cluster./bin/yarn-session.sh

注意

请注意,您必须为连接到同一 ZooKeeper 集群的所有 Flink 会话集群配置不同的集群 ID。在启动作业模式的 Flink 集群时,集群 ID 自动设置为启动应用程序的 ID,因此是唯一的。

HA Kubernetes 设置

在使用如 “Kubernetes” 中描述的主部署和工作部署在 Kubernetes 上运行 Flink 时,Kubernetes 将自动重新启动失败的容器,以确保正确数量的 pod 正在运行。这足以从由 ResourceManager 处理的工作器故障中恢复。然而,从主节点故障中恢复需要额外的配置,如前所述。

要启用 Flink 的 HA 模式,您需要调整 Flink 的配置并提供诸如 ZooKeeper quorum 节点的主机名、持久存储路径和 Flink 的集群 ID 等信息。所有这些参数都需要添加到 Flink 的配置文件(./conf/flink-conf.yaml)中。

Flink 镜像中的自定义配置

不幸的是,在之前 Docker 和 Kubernetes 示例中使用的 Flink Docker 镜像不支持设置自定义配置参数。因此,该镜像不能用于在 Kubernetes 上设置 HA Flink 集群。相反,您需要构建一个自定义镜像,该镜像可以“硬编码”所需的参数,或者足够灵活,可以通过参数或环境变量动态调整配置。标准的 Flink Docker 镜像是定制自己的 Flink 镜像的良好起点。

与 Hadoop 组件集成

Apache Flink 可以轻松集成到 Hadoop YARN 和 HDFS 以及 Hadoop 生态系统的其他组件,如 HBase。在所有这些情况下,Flink 需要在其类路径上具有 Hadoop 依赖项。

提供 Flink 与 Hadoop 依赖项的三种方法:

  1. 使用为特定 Hadoop 版本构建的 Flink 二进制分发版本。Flink 提供了最常用的原生 Hadoop 版本的构建。

  2. 为特定的 Hadoop 版本构建 Flink。如果 Flink 的二进制发行版都不适用于您环境中部署的 Hadoop 版本,例如运行补丁版的 Hadoop 版本或发行商提供的 Hadoop 版本(如 Cloudera、Hortonworks 或 MapR),则此方法很有用。

    要为特定的 Hadoop 版本构建 Flink,您需要 Flink 的源代码,可以从网站下载源码分发版或从项目的 Git 存储库克隆稳定的发行版分支,Java JDK 至少为版本 8,以及 Apache Maven 3.2。进入 Flink 源代码的基础文件夹,并在以下命令中运行一个:

    // build Flink for a specific official Hadoop version
    mvn clean install -DskipTests -Dhadoop.version=2.6.1
    
    // build Flink for a Hadoop version of a distributor
    mvn clean install -DskipTests -Pvendor-repos \
    -Dhadoop.version=2.6.1-cdh5.0.0
    

    完成的构建位于 ./build-target 文件夹中。

  3. 使用不带 Hadoop 的 Flink 分发版本,并手动配置 Hadoop 依赖的类路径。如果提供的构建都不适合您的设置,这种方法就很有用。必须在 HADOOP_CLASSPATH 环境变量中声明 Hadoop 依赖的类路径。如果未配置此变量,可以通过以下命令自动设置它(如果 hadoop 命令可访问):export HADOOP_CLASSPATH=`hadoop classpath`

    hadoop 命令的 classpath 选项会打印其配置的类路径。

除了配置 Hadoop 依赖项之外,还需要提供 Hadoop 配置目录的位置。应通过导出 HADOOP_CONF_DIR(优选)或 HADOOP_CONF_PATH 环境变量来完成。一旦 Flink 知道了 Hadoop 的配置,它就可以连接到 YARN 的 ResourceManager 和 HDFS。

文件系统配置

Apache Flink 在各种任务中使用文件系统。应用程序可以从文件读取输入并将其结果写入文件(参见“文件系统源连接器”),应用程序的检查点和元数据会持久化到远程文件系统以进行恢复(参见“检查点、保存点和状态恢复”),某些内部组件利用文件系统将数据分发到任务中,例如应用程序的 JAR 文件。

Flink 支持多种文件系统。由于 Flink 是一个分布式系统,运行在集群或云环境中,因此文件系统通常需要全局访问权限。因此,Hadoop HDFS、S3 和 NFS 是常用的文件系统。

与其他数据处理系统类似,Flink 根据路径的 URI 方案来识别路径所指向的文件系统。例如,file:///home/user/data.txt 指向本地文件系统中的文件,hdfs:///namenode:50010/home/user/data.txt 指向指定的 HDFS 集群中的文件。

在 Flink 中,文件系统由 org.apache.flink.core.fs.FileSystem 类的实现来表示。FileSystem 类实现文件系统操作,如从文件中读取和写入、创建目录或文件以及列出目录的内容。Flink 进程(JobManager 或 TaskManager)为每个配置的文件系统实例化一个 FileSystem 对象,并在所有本地任务之间共享,以确保强制执行诸如限制打开连接数等配置约束。

Flink 提供了以下最常用文件系统的实现:

本地文件系统

Flink 内置支持本地文件系统,包括本地挂载的网络文件系统,如 NFS 或 SAN,并且不需要额外的配置。本地文件系统使用 file:// URI 方案来引用。

Hadoop HDFS

Flink 对 HDFS 的连接器始终位于 Flink 的类路径中。但是,为了使其工作,需要在类路径中加载 Hadoop 依赖项。"与 Hadoop 组件集成" 解释了如何确保加载 Hadoop 依赖项。HDFS 路径以 hdfs:// 方案为前缀。

Amazon S3

Flink 提供了两种连接到 S3 的替代文件系统连接器,基于 Apache Hadoop 和 Presto。这两个连接器都是完全自包含的,不会暴露任何依赖项。要安装这两个连接器之一,需要将相应的 JAR 文件从 ./opt 文件夹移动到 ./lib 文件夹。Flink 文档提供了有关配置 S3 文件系统的更多详细信息。S3 路径使用 s3:// 方案来指定。

OpenStack Swift FS

Flink 提供了一个基于 Apache Hadoop 的 Swift FS 连接器。该连接器完全自包含,不会暴露任何依赖项。通过将 swift-connector JAR 文件从 ./opt 移动到 ./lib 文件夹来安装。Swift FS 路径通过 swift:// 方案来标识。

对于 Flink 没有提供专用连接器的文件系统,如果正确配置,Flink 可以委托给 Hadoop 文件系统连接器。这就是为什么 Flink 能够支持所有 HCFSs。

Flink 在 ./conf/flink-conf.yaml 中提供了一些配置选项,用于指定默认文件系统和限制文件系统连接的数量。您可以指定一个默认的文件系统方案(fs.default-scheme),如果路径没有提供方案,将自动添加为前缀。例如,如果您指定 fs.default-scheme: hdfs://nnode1:9000,路径 /result 将扩展为 hdfs://nnode1:9000/result

您可以限制从文件系统读取(输入)和写入(输出)的连接数。配置可以按 URI 方案定义。相关的配置键包括:

fs.<scheme>.limit.total: (number, 0/-1 mean no limit)
fs.<scheme>.limit.input: (number, 0/-1 mean no limit)
fs.<scheme>.limit.output: (number, 0/-1 mean no limit)
fs.<scheme>.limit.timeout: (milliseconds, 0 means infinite)
fs.<scheme>.limit.stream-timeout: (milliseconds, 0 means infinite)

连接数根据 TaskManager 进程和路径权限进行跟踪,hdfs://nnode1:50010hdfs://nnode2:50010 分别进行跟踪。可以单独为输入和输出连接配置连接限制,也可以作为总连接数进行配置。当文件系统达到其连接限制并尝试打开新连接时,它将阻塞并等待另一个连接关闭。超时参数定义了等待连接请求失败的时间(fs.<scheme>.limit.timeout)以及等待空闲连接关闭的时间(fs.<scheme>.limit.stream-timeout)。

您还可以提供自定义的文件系统连接器。查看Flink 文档了解如何实现和注册自定义文件系统。

系统配置

Apache Flink 提供了许多参数来配置其行为和调整其性能。所有参数都可以在 ./conf/flink-conf.yaml 文件中定义,该文件以键值对的形式组织为扁平的 YAML 文件。配置文件由不同组件读取,例如启动脚本、主节点和工作节点的 JVM 进程以及 CLI 客户端。例如,启动脚本(例如 ./bin/start-cluster.sh)解析配置文件以提取 JVM 参数和堆大小设置,而 CLI 客户端(./bin/flink)提取连接信息以访问主进程。在修改配置文件后,需要重新启动 Flink 才能生效。

为了改进开箱即用体验,Flink 预配置为本地设置。您需要调整配置以成功在分布式环境中运行 Flink。在本节中,我们讨论了设置 Flink 集群时通常需要配置的不同方面。我们建议您参考官方文档,以获取所有参数的详尽列表和详细描述。

Java 和类加载

默认情况下,Flink 使用与 PATH 环境变量链接的 Java 可执行文件启动 JVM 进程。如果 Java 不在 PATH 中,或者您想使用不同的 Java 版本,可以通过 JAVA_HOME 环境变量或配置文件中的 env.java.home 键指定 Java 安装的根文件夹。Flink 的 JVM 进程可以使用自定义的 Java 选项启动,例如用于微调垃圾收集器或启用远程调试的键 env.java.optsenv.java.opts.jobmanagerenv.java.opts.taskmanager

运行具有外部依赖的作业时常见类加载问题。为了执行 Flink 应用程序,必须由类加载器加载应用程序 JAR 文件中的所有类。Flink 将每个作业的类注册到单独的用户代码类加载器中,以确保作业的依赖不会干扰 Flink 的运行时依赖项或其他作业的依赖项。用户代码类加载器在相应作业终止时被释放。Flink 的系统类加载器加载 ./lib 文件夹中的所有 JAR 文件,并且用户代码类加载器是从系统类加载器派生的。

默认情况下,Flink 首先在子(用户代码)类加载器中查找用户代码类,然后在父(系统)类加载器中查找,以防止作业与 Flink 使用相同依赖版本冲突。但是,您也可以通过 classloader.resolve-order 配置键来反转查找顺序。

注意

注意一些类始终首先由父类加载器解析 (classloader.parent-first-patterns.default)。您可以通过提供一个类名模式的白名单来扩展列表,这些类名模式首先从父类加载器解析 (classloader.parent-first-patterns.additional)。

CPU

Flink 不会主动限制其消耗的 CPU 资源量。然而,它使用处理插槽(详见 “任务执行” 进行详细讨论)来控制可以分配给工作进程(TaskManager)的任务数量。一个 TaskManager 提供一定数量的插槽,这些插槽由 ResourceManager 注册和管理。JobManager 请求一个或多个插槽来执行应用程序。每个插槽可以处理应用程序的一个切片,应用程序中每个操作符的一个并行任务。因此,JobManager 需要至少获取与应用程序的最大操作符并行度相同数量的插槽。⁶ 任务作为线程在工作进程(TaskManager)内执行,并且会占用它们需要的 CPU 资源。

TaskManager 提供的插槽数量由配置文件中的 taskmanager.numberOfTaskSlots 键控制。默认为每个 TaskManager 一个插槽。通常只需为独立设置配置插槽数量,因为在集群资源管理器(YARN、Kubernetes、Mesos)上运行 Flink 可以轻松地在每个计算节点上启动多个 TaskManager(每个具有一个插槽)。

主存储器和网络缓冲区

Flink 的主控和工作进程具有不同的内存需求。主控进程主要管理计算资源(ResourceManager)并协调应用程序的执行(JobManager),而工作进程则负责处理重活和处理可能大量数据。

通常,主进程的内存需求适中。默认情况下,它使用 1GB JVM 堆内存启动。如果主进程需要管理几个应用程序或具有许多运算符的应用程序,则可能需要使用jobmanager.heap.size配置键增加 JVM 堆大小。

配置工作进程的内存涉及到多个组件,因为有多个组件分配不同类型的内存。最重要的参数是 JVM 堆内存的大小,使用taskmanager.heap.size键进行设置。堆内存用于所有对象,包括任务管理器运行时、应用程序的操作符和函数以及飞行中的数据。使用内存或文件系统状态后端的应用程序状态也存储在 JVM 上。请注意,单个任务可能会消耗其所在 JVM 的整个堆内存。Flink 不能保证或授予每个任务或插槽的堆内存。每个任务管理器的单个插槽配置具有更好的资源隔离性,并且可以防止行为异常的应用程序干扰不相关的应用程序。如果您运行具有许多依赖关系的应用程序,则 JVM 的非堆内存也可能会显著增长,因为它存储所有任务管理器和用户代码类。

除了 JVM 外,另外两个主要的内存消耗者是 Flink 的网络堆栈和 RocksDB(用作状态后端时)。Flink 的网络堆栈基于 Netty 库,它从本地(堆外)内存中分配网络缓冲区。Flink 需要足够数量的网络缓冲区,以便能够从一个工作进程向另一个工作进程发送记录。缓冲区的数量取决于操作符任务之间的网络连接总数。对于通过分区或广播连接连接的两个操作符,缓冲区的数量取决于发送和接收操作符并行度的乘积。对于具有多个分区步骤的应用程序,这种二次依赖关系可能很快累积到需要进行网络传输的大量内存。

Flink 的默认配置仅适用于较小规模的分布式设置,并且需要调整以适应更严重的规模。如果缓冲区的数量未适当配置,则作业提交将以java.io.IOException: Insufficient number of network buffers失败。在这种情况下,您应该为网络堆栈提供更多内存。

网络缓冲区分配的内存量由 taskmanager.network.memory.fraction 键配置,该键确定为网络缓冲区分配的 JVM 大小的分数。默认情况下,使用 JVM 堆大小的 10%。由于缓冲区分配为堆外内存,因此 JVM 堆会减少相应的量。taskmanager.memory.segment-size 配置键确定网络缓冲区的大小,默认为 32 KB。减少网络缓冲区的大小会增加缓冲区的数量,但可能会降低网络堆栈的效率。您还可以指定用于网络缓冲区的最小 (taskmanager.network.memory.min) 和最大 (taskmanager.network.memory.max) 内存量(默认分别为 64 MB 和 1 GB),以设置相对配置值的绝对限制。

RocksDB 是配置工作进程内存时需要考虑的另一个内存消耗者。不幸的是,确定 RocksDB 的内存消耗并不直接,因为它取决于应用程序中键控状态的数量。对于每个键控操作符的任务,Flink 创建一个单独的(嵌入式)RocksDB 实例。在每个实例中,操作符的每个不同状态存储在单独的列族(或表)中。在默认配置下,每个列族需要约 200 MB 到 240 MB 的堆外内存。您可以通过多个参数调整 RocksDB 的配置并优化其性能。

配置 TaskManager 内存设置时,应调整 JVM 堆内存大小,以便为 JVM 非堆内存(类和元数据)和如果配置为状态后端的 RocksDB 留出足够的内存。网络内存会自动从配置的 JVM 堆大小中减去。请记住,某些资源管理器(如 YARN)会在容器超出内存预算时立即终止该容器。

磁盘存储

Flink 工作进程出于多种原因将数据存储在本地文件系统中,包括接收应用程序 JAR 文件、写入日志文件以及在配置了 RocksDB 状态后端时维护应用程序状态。通过 io.tmp.dirs 配置键,您可以指定一个或多个目录(用冒号分隔),用于在本地文件系统中存储数据。默认情况下,数据写入由 Java 系统属性 java.io.tmpdir 决定的默认临时目录,Linux 和 MacOS 上为 /tmpio.tmp.dirs 参数用作 Flink 大多数组件本地存储路径的默认值。但是,这些路径也可以单独配置。

确保不自动清理临时目录

一些 Linux 发行版定期清理临时目录 /tmp。如果计划运行持续的 Flink 应用程序,请确保禁用此行为或配置不同的目录。否则,作业恢复可能会丢失存储在临时目录中的元数据而失败。

blob.storage.directory 键配置了 blob 服务器的本地存储目录,用于交换诸如应用程序 JAR 文件之类的大文件。env.log.dir 键配置了 TaskManager 写入其日志文件的目录(在 Flink 设置中,默认为 ./log 目录)。最后,RocksDB 状态后端在本地文件系统中维护应用程序状态。该目录使用 state.backend.rocksdb.localdir 键进行配置。如果未明确配置存储目录,则 RocksDB 使用 io.tmp.dirs 参数的值。

检查点和状态后端

Flink 提供了几种选项来配置状态后端如何检查其状态。可以在应用程序代码中显式指定所有参数,如 “调整检查点和恢复” 中所述。但是,您还可以通过 Flink 的配置文件为 Flink 集群提供默认设置,如果未声明特定于作业的选项,则会应用这些设置。

影响应用程序性能的重要选择之一是维护其状态的状态后端。您可以使用 state.backend 键定义集群的默认状态后端。此外,您可以启用异步检查点(state.backend.async)和增量检查点(state.backend.incremental)。一些后端不支持所有选项,可能会忽略它们。您还可以配置远程存储的根目录,用于写入检查点(state.checkpoints.dir)和保存点(state.savepoints.dir)。

一些检查点选项是特定于后端的。对于 RocksDB 状态后端,您可以定义一个或多个路径,RocksDB 在这些路径上存储其本地文件(state.backend.rocksdb.localdir),以及定时器状态是存储在堆(默认)还是在 RocksDB 中(state.backend.rocksdb.timer-service.factory)。

最后,您可以默认启用并配置 Flink 集群的本地恢复。要启用本地恢复,请将参数 state.backend.local-recovery 设置为 true。还可以指定本地状态副本的存储位置(taskmanager.state.local.root-dirs)。

安全性

数据处理框架是公司 IT 基础设施中敏感的组件,需要防止未经授权的使用和对数据的访问。Apache Flink 支持 Kerberos 认证,并可以配置为使用 SSL 加密所有网络通信。

Flink 支持与 Hadoop 及其组件(YARN、HDFS、HBase)、ZooKeeper 和 Kafka 的 Kerberos 集成。您可以为每个服务单独启用和配置 Kerberos 支持。Flink 支持两种认证模式——Keytabs 和 Hadoop 委托令牌。Keytabs 是首选方法,因为令牌在一段时间后会过期,这可能会导致长时间运行的流处理应用程序出现问题。请注意,凭据与 Flink 集群绑定,而不是与运行的作业绑定;所有在同一集群上运行的应用程序使用相同的认证令牌。如果您需要使用不同的凭据工作,您应该启动一个新的集群。请参阅Flink 文档以获取关于启用和配置 Kerberos 认证的详细说明。

Flink 支持通信双方的互相认证,并使用 SSL 对内部和外部通信进行网络通信加密。对于内部通信(RPC 调用、数据传输和分发库或其他文件的 blob 服务通信),所有的 Flink 进程(调度器、资源管理器、作业管理器和任务管理器)都执行互相认证——发送方和接收方通过 SSL 证书进行验证。证书作为一个共享的秘密可以嵌入到容器中或者附加到 YARN 设置中。

所有与 Flink 服务的外部通信——提交和控制应用程序以及访问 REST 接口——都通过 REST/HTTP 端点进行。⁸ 您也可以为这些连接启用 SSL 加密。还可以启用双向认证。然而,推荐的方法是设置和配置一个专用的代理服务来控制对 REST 端点的访问。原因是代理服务提供比 Flink 更多的认证和配置选项。目前尚不支持对查询状态通信的加密和认证。

默认情况下,未启用 SSL 认证和加密。由于设置涉及多个步骤,如生成证书、设置信任存储和密钥存储以及配置密码套件,我们建议您参阅官方的Flink 文档。文档还包括如何在独立集群、Kubernetes 和 YARN 等不同环境中进行操作和技巧。

概要

在本章中,我们讨论了 Flink 在不同环境中的设置以及如何配置高可用设置。我们解释了如何启用对各种文件系统的支持以及如何与 Hadoop 及其组件集成。最后,我们讨论了最重要的配置选项。我们没有提供全面的配置指南;而是建议您参考Apache Flink 的官方文档,以获取所有配置选项的完整列表和详细描述。

¹ 要在 Windows 上运行 Flink,可以使用提供的批处理脚本,也可以在 Windows Subsystem for Linux (WSL) 或 Cygwin 上使用常规的 bash 脚本。所有脚本仅适用于本地设置。

² Flink Docker 镜像不包含在官方 Apache Flink 发行版中。

³ 注意,YARN 中的容器概念与 Docker 中的容器不同。

⁴ Kubernetes 还支持由多个紧密链接的容器组成的 pod。

⁵ ApplicationMaster 是 YARN 应用的主进程。

⁶ 可以将运算符分配给不同的槽共享组,从而将它们的任务分配给不同的槽。

⁷ 详见“配置恢复”了解此功能的详情。

⁸ 第十章讨论了作业提交和 REST 接口。

第十章:操作 Flink 和流应用程序

流应用程序是长时间运行的,其工作负载通常是不可预测的。流作业连续运行几个月并不罕见,因此其操作需求与短暂批处理作业的需求有很大不同。考虑一种情况,您在部署的应用程序中检测到一个 bug。如果您的应用程序是批处理作业,您可以轻松地在离线状态下修复 bug,然后在当前作业实例完成后重新部署新的应用程序代码。但是如果您的作业是长时间运行的流作业呢?在保证正确性的同时如何低成本地应用重新配置?

如果您正在使用 Flink,您无需担心。Flink 将会为您完成所有繁重的工作,因此您可以轻松监视、操作和重新配置您的作业,同时保留精确一次性状态语义。在本章中,我们介绍 Flink 用于操作和维护连续运行的流应用程序的工具。我们将向您展示如何收集指标并监视您的应用程序,以及在想要更新应用程序代码或调整应用程序资源时如何保持结果的一致性。

运行和管理流应用程序

正如您所预期的那样,维护流应用程序比维护批处理应用程序更具挑战性。流应用程序具有状态并且持续运行,而批处理应用程序则是周期性执行的。重新配置、扩展或更新批处理应用程序可以在执行之间进行,这比升级连续摄取、处理和发射数据的应用程序要容易得多。

然而,Apache Flink 具有许多功能,可以显著简化流应用程序的维护工作。这些功能大多基于保存点。¹ Flink 提供以下接口来监视和控制其主节点、工作节点和应用程序:

  1. 命令行客户端是用于提交和控制应用程序的工具。

  2. REST API 是命令行客户端和 Web UI 使用的基础接口。用户和脚本可以访问它,并提供对系统和应用程序指标的访问,以及提交和管理应用程序的端点。

  3. Web UI 是一个提供有关 Flink 集群和正在运行的应用程序的详细信息和指标的 Web 界面。它还提供基本功能来提交和管理应用程序。Web UI 在 “Flink Web UI” 中有描述。

在本节中,我们解释了保存点的实际方面,并讨论了如何使用 Flink 的命令行客户端和 REST API 启动、停止、暂停和恢复、扩展以及升级具有状态的流应用程序。

Savepoints

保存点基本上与检查点相同——它是应用程序状态的一致完整快照。但是,检查点和保存点的生命周期不同。检查点会根据应用程序的配置自动创建,在失败时加载,并由 Flink 自动删除(具体取决于应用程序的配置)。此外,除非应用程序显式启用检查点保留,否则在取消应用程序时检查点会被自动删除。相比之下,保存点必须由用户或外部服务手动触发,并且永远不会被 Flink 自动删除。

保存点是持久数据存储中的一个目录。它包括一个子目录,其中包含包含所有任务状态的数据文件,以及一个包含所有数据文件绝对路径的二进制元数据文件。由于元数据文件中的路径是绝对的,将保存点移动到不同路径将使其无法使用。这是保存点的结构:

# Savepoint root path
/savepoints/

# Path of a particular savepoint
/savepoints/savepoint-:shortjobid-:savepointid/

# Binary metadata file of a savepoint
/savepoints/savepoint-:shortjobid-:savepointid/_metadata

# Checkpointed operator states
/savepoints/savepoint-:shortjobid-:savepointid/:xxx

使用命令行客户端管理应用程序

Flink 的命令行客户端提供启动、停止和管理 Flink 应用程序的功能。它从 ./conf/flink-conf.yaml 文件中读取配置(参见 “系统配置”)。您可以在 Flink 设置的根目录中使用命令 ./bin/flink 调用它。

在没有额外参数的情况下运行,客户端会打印帮助消息。

Windows 上的命令行客户端

命令行客户端基于一个 bash 脚本。因此,它无法在 Windows 命令行中运行。./bin/flink.bat 脚本提供的功能非常有限。如果您是 Windows 用户,建议使用常规命令行客户端,并在 WSL 或 Cygwin 上运行。

启动应用程序

您可以使用命令行客户端的 run 命令启动应用程序:

./bin/flink run ~/myApp.jar

上述命令从 JAR 文件的 META-INF/MANIFEST.MF 文件的 program-class 属性引用的类的 main() 方法开始应用程序,而不向应用程序传递任何参数。客户端将 JAR 文件提交到主进程,然后将其分发到工作节点。

您可以通过在命令的末尾附加参数来传递参数给应用程序的 main() 方法:

./bin/flink run ~/myApp.jar my-arg1 my-arg2 my-arg3

默认情况下,客户端在提交应用程序后不会返回,而是等待其终止。您可以使用 -d 标志以分离模式提交应用程序,如下所示:

./bin/flink run -d ~/myApp.jar

客户端不会等待应用程序终止,而是返回并打印提交的作业 ID。作业 ID 用于在获取保存点、取消或重新缩放应用程序时指定作业。您可以使用 -p 标志指定应用程序的默认并行度:

./bin/flink run -p 16 ~/myApp.jar

上述命令将执行环境的默认并行度设置为 16。执行环境的默认并行度会被应用源代码显式指定的所有设置覆盖——在StreamExecutionEnvironment或操作符上调用setParallelism()定义的并行度优先于默认值。

如果您的应用程序 JAR 文件的清单文件未指定入口类,则可以使用-c参数指定该类:

./bin/flink run -c my.app.MainClass ~/myApp.jar

客户端将尝试启动静态的main()方法,位于my.app.MainClass类中。

默认情况下,客户端将应用程序提交到由./conf/flink-conf.yaml文件指定的 Flink 主节点(有关不同设置的配置,请参阅“系统配置”)。您可以使用-m标志将应用程序提交到特定的主节点进程:

./bin/flink run -m myMasterHost:9876 ~/myApp.jar

此命令将应用程序提交到运行在主机myMasterHost端口9876上的主节点。

注意

如果您首次启动应用程序或未提供保存点或检查点以初始化状态,则应用程序的状态将为空。在这种情况下,一些有状态的操作符会运行特殊逻辑来初始化它们的状态。例如,如果没有可恢复的读取位置,则 Kafka 源需要选择消费主题的分区偏移量。

列出正在运行的应用程序

对于要应用于运行中作业的所有操作,您需要提供一个标识应用程序的 JobID。可以从 Web UI、REST API 或使用命令行客户端获取作业的 ID。运行以下命令时,客户端将打印所有运行中作业的列表,包括它们的 JobID:

./bin/flink list -r
Waiting for response...
------------------ Running/Restarting Jobs -------------------
17.10.2018 21:13:14 : bc0b2ad61ecd4a615d92ce25390f61ad : 
Socket Window WordCount (RUNNING)
​--------------------------------------------------------------

在此示例中,JobID 为bc0b2ad61ecd4a615d92ce25390f61ad

进行保存点和处理

可以使用命令行客户端为正在运行的应用程序获取保存点,如下所示:

./bin/flink savepoint <jobId> [savepointPath]

此命令触发具有提供的 JobID 的保存点。如果显式指定保存点路径,则保存在提供的目录中。否则,将使用flink-conf.yaml文件中配置的默认保存点目录。

要为作业bc0b2ad61ecd4a615d92ce25390f61ad触发保存点,并将其存储在目录hdfs:///xxx:50070/savepoints中,我们调用命令行客户端:

./bin/flink savepoint bc0b2ad61ecd4a615d92ce25390f61ad \
hdfs:///xxx:50070/savepoints
Triggering savepoint for job bc0b2ad61ecd4a615d92ce25390f61ad.
Waiting for response...
Savepoint completed. 
Path: hdfs:///xxx:50070/savepoints/savepoint-bc0b2a-63cf5d5ccef8
You can resume your program from this savepoint with the run command.

保存点可能占用大量空间,并且不会被 Flink 自动删除。您需要手动删除它们以释放已使用的存储空间。可以使用以下命令删除保存点:

./bin/flink savepoint -d <savepointPath>

要删除之前触发的保存点,请执行以下命令:

./bin/flink savepoint -d \
hdfs:///xxx:50070/savepoints/savepoint-bc0b2a-63cf5d5ccef8
Disposing savepoint 'hdfs:///xxx:50070/savepoints/savepoint-bc0b2a-63cf5d5ccef8'.
Waiting for response...
​Savepoint 'hdfs:///xxx:50070/savepoints/savepoint-bc0b2a-63cf5d5ccef8' disposed.

删除保存点

删除一个保存点之前,不得在另一个检查点或保存点完成之前进行。由于系统处理保存点类似于常规检查点,运营商也会收到完成保存点的通知并对其进行操作。例如,事务性下沉在保存点完成时提交对外系统的更改。为了确保输出的精确一次性,Flink 必须从最新完成的检查点或保存点中恢复。如果 Flink 尝试从已删除的保存点中恢复,故障恢复将失败。一旦另一个检查点(或保存点)完成,您可以安全地删除保存点。

取消应用程序

应用程序可以以两种方式取消:带保存点或不带保存点。要取消运行中的应用程序而不获取保存点,请运行以下命令:

./bin/flink cancel <jobId>

为了在取消正在运行的应用程序之前获取保存点,请将 -s 标志添加到 cancel 命令中:

./bin/flink cancel -s [savepointPath] <jobId>

如果您未指定 savepointPath,则将使用在 ./conf/flink-conf.yaml 文件中配置的默认保存点目录(详见“系统配置”)。如果保存点文件夹既未在命令中显式指定,也未从配置中提供,则命令将失败。要取消具有作业 ID bc0b2ad61ecd4a615d92ce25390f61ad 的应用程序并将保存点存储在 hdfs:///xxx:50070/savepoints,请运行以下命令:

./bin/flink cancel -s \
hdfs:///xxx:50070/savepoints d5fdaff43022954f5f02fcd8f25ef855
Cancelling job bc0b2ad61ecd4a615d92ce25390f61ad 
with savepoint to hdfs:///xxx:50070/savepoints.
Cancelled job bc0b2ad61ecd4a615d92ce25390f61ad. 
Savepoint stored in hdfs:///xxx:50070/savepoints/savepoint-bc0b2a-d08de07fbb10.

取消应用程序可能会失败

请注意,如果获取保存点失败,则作业将继续运行。您需要尝试取消作业的另一个尝试。

从保存点启动应用程序

从保存点启动应用程序非常简单。您只需使用运行命令启动应用程序,并使用 -s 选项另外提供保存点的路径:

./bin/flink run -s <savepointPath> [options] <jobJar> [arguments]

当作业启动时,Flink 将保存点的各个状态快照与启动应用程序的所有状态进行匹配。这种匹配分为两步。首先,Flink 比较保存点和应用程序操作符的唯一标识符。其次,它为每个操作符匹配状态标识符(详见“保存点”)。

您应该定义唯一的操作符 ID

如果您没有使用 uid() 方法为您的操作符分配唯一的 ID,Flink 将分配默认标识符,这些标识符是依赖于操作符类型和所有前面操作符的哈希值。由于无法更改保存点中的标识符,如果您不使用 uid() 手动分配操作符标识符,则在更新和演变应用程序时的选择将更少。

如前所述,仅当应用程序与保存点兼容时,才能从保存点启动应用程序。未修改的应用程序始终可以从其保存点重新启动。但是,如果重新启动的应用程序与保存点所取的应用程序不同,则需要考虑三种情况:

  • 如果你向应用程序添加了新的状态或更改了有状态操作符的唯一标识符,则 Flink 在保存点中找不到相应的状态快照。在这种情况下,新状态将被初始化为空。

  • 如果你从应用程序中删除了一个状态或者更改了有状态操作符的唯一标识符,那么在保存点中存在无法匹配到应用程序的状态。在这种情况下,Flink 不会启动应用程序,以避免丢失保存点中的状态。你可以通过在运行命令中添加 -n 选项来禁用此安全检查。

  • 如果你在应用程序中更改了状态—改变了状态原语或修改了状态的数据类型—应用程序将无法启动。这意味着你不能轻易地演变应用程序中状态的数据类型,除非你从一开始就考虑了应用程序中的状态演变。Flink 社区目前正在努力改进对状态演变的支持。(参见《修改操作符状态》。)

扩展应用程序的内部和外部

增加或减少应用程序的并行度并不困难。你需要进行保存点,取消应用程序,并从保存点重新启动并调整并行度。应用程序的状态将自动重新分布到更大或更小数量的并行操作任务中。有关不同类型的操作符状态和键控状态如何扩展的详细信息,请参阅《扩展有状态操作符》。但是,有几点需要考虑。

如果你需要确保一次性结果,你应该进行保存点,并使用集成的保存点并取消命令停止应用程序。这可以防止保存点之后完成另一个检查点,从而触发一次性 sink 在保存点之后发出数据。

如《设置并行度》中所述,应用程序及其操作符的并行度可以通过不同方式指定。默认情况下,操作符使用其关联的 StreamExecutionEnvironment 的默认并行度运行。可以在启动应用程序时指定默认并行度(例如,使用 CLI 客户端中的 -p 参数)。如果你实现的应用程序使其操作符的并行度依赖于默认环境并行度,那么你可以通过从相同的 JAR 文件启动应用程序并指定新的并行度来简单地扩展应用程序。然而,如果在 StreamExecutionEnvironment 上或某些操作符上硬编码了并行度,则可能需要在提交执行之前调整源代码并重新编译和重新打包应用程序。

如果你的应用程序的并行度依赖于环境的默认并行度,Flink 提供了一个原子重新调整命令,可以进行保存点,取消应用程序,并使用新的默认并行度重新启动:

./bin/flink modify <jobId> -p <newParallelism>

要将 jobId bc0b2ad61ecd4a615d92ce25390f61ad 的应用程序重新调整到并行度为 16,请运行以下命令:

./bin/flink modify bc0b2ad61ecd4a615d92ce25390f61ad -p 16
Modify job bc0b2ad61ecd4a615d92ce25390f61ad.
​Rescaled job bc0b2ad61ecd4a615d92ce25390f61ad. Its new parallelism is 16.

如 “缩放有状态操作符” 中所述,Flink 将有状态的操作符的键控状态分布在所谓的键组的粒度上。因此,有状态操作符的最大并行度取决于键组的数量。使用 setMaxParallelism() 方法可为每个操作符配置键组的数量。(见 “定义键控状态操作符的最大并行度”。)

使用 REST API 管理应用程序

用户或脚本可以直接访问 REST API,并公开有关 Flink 集群及其应用程序的信息,包括指标以及提交和控制应用程序的端点。Flink 从同一 Web 服务器运行 REST API 和 Web UI,作为 Dispatcher 进程的一部分。默认情况下,它们都公开在端口 8081 上。您可以通过 ./conf/flink-conf.yaml 文件中的配置键 rest.port 配置不同的端口。值为 -1 会禁用 REST API 和 Web UI。

与 REST API 交互的常见命令行工具是 curl。典型的 curl REST 命令如下:

curl -X <HTTP-Method> [-d <parameters>] http://hostname:port/v1/<REST-point>

v1 表示 REST API 的版本。Flink 1.7 提供第一个版本 (v1) 的 API。假设您在运行本地 Flink 设置,并将其 REST API 公开在端口 8081 上,则以下 curl 命令提交一个对 /overview REST 端点的 GET 请求:

curl -X GET http://localhost:8081/v1/overview

命令返回有关集群的一些基本信息,如 Flink 版本、TaskManagers 数量、任务槽以及正在运行、已完成、取消或失败的作业:

{
 "taskmanagers":2,
 "slots-total":8,
 "slots-available":6,
 "jobs-running":1,
 "jobs-finished":2,
 "jobs-cancelled":1,
 "jobs-failed":0,
 "flink-version":"1.7.1",
 "flink-commit":"89eafb4"
}

下面列出并简要描述了最重要的 REST 调用。有关支持的所有调用列表,请参阅 Apache Flink 的官方文档。“使用命令行客户端管理应用程序” 提供了有关一些操作(如升级或扩展应用程序)的更多详细信息。

REST API 提供了一些端点,用于查询运行中集群的信息以及关闭它。表 10-1、10-2 和 10-3 显示了获取有关 Flink 集群信息的 REST 请求,如任务槽数、运行和完成的作业、JobManager 的配置或所有连接的 TaskManagers 列表。

表 10-1. 获取基本集群信息的 REST 请求

请求 GET /overview
响应 显示集群的基本信息如上所示

表 10-2. 获取 JobManager 配置的 REST 请求

请求 GET /jobmanager/config
响应 返回 ./conf/flink-conf.yaml 中定义的 JobManager 的配置

表 10-3. 列出所有连接的 TaskManagers 的 REST 请求

请求 GET /taskmanagers
响应 返回包括其 ID 和基本信息(如内存统计和连接端口)在内的所有 TaskManager 列表

表格 10-4 显示了用于列出为 JobManager 收集的所有指标的 REST 请求。

表格 10-4. 列出可用 JobManager 指标的 REST 请求

请求 GET /jobmanager/metrics
响应 返回为 JobManager 可用的指标列表

要检索一个或多个 JobManager 指标,请将所有请求的指标添加到请求中的 get 查询参数:

curl -X GET http://hostname:port/v1/jobmanager/metrics?get=metric1,metric2

表格 10-5 显示了用于列出 TaskManager 收集的所有指标的 REST 请求。

表格 10-5. 列出可用 TaskManager 指标的 REST 请求

请求
GET /taskmanagers/<tmId>/metrics

|

参数 tmId:已连接的 TaskManager 的 ID
响应 返回所选 TaskManager 可用的指标列表

要检索一个或多个 TaskManager 的指标,请将所有请求的指标添加到请求中的 get 查询参数:

curl -X GET http://hostname:port/v1/taskmanagers/<tmId>/metrics?get=metric1

您还可以使用显示在 表格 10-6 中的 REST 调用关闭集群。

表格 10-6. 关闭集群的 REST 请求

请求 DELETE /cluster
动作 关闭 Flink 集群。请注意,在独立模式下,只有主进程将被终止,工作进程将继续运行。

REST API 还可用于管理和监控 Flink 应用程序。要启动应用程序,首先需要将应用程序的 JAR 文件上传到集群。表格 10-7,10-8 和 10-9 显示了管理这些 JAR 文件的 REST 终端点。

表格 10-7. 上传 JAR 文件的 REST 请求

请求 POST /jars/upload
参数 文件必须以多部分数据形式发送
动作 将 JAR 文件上传到集群
响应 已上传的 JAR 文件的存储位置

上传 JAR 文件的 curl 命令:

curl -X POST -H "Expect:" -F "jarfile=@path/to/flink-job.jar" \
 http://hostname:port/v1/jars/upload

表格 10-8. 列出所有已上传 JAR 文件的 REST 请求

请求 GET /jars
响应 所有已上传的 JAR 文件列表。列表包括 JAR 文件的内部 ID、原始名称和上传时间。

表格 10-9. 删除 JAR 文件的 REST 请求

请求 DELETE /jars/<jarId>
参数 jarId:由列表 JAR 文件命令提供的 JAR 文件的 ID
动作 删除由提供的 ID 引用的 JAR 文件

应用程序是通过显示在 表格 10-10 中的 REST 调用从上传的 JAR 文件启动的。

表格 10-10. 启动应用程序的 REST 请求

请求 POST /jars/<jarId>/run
参数 jarId:从中启动应用程序的 JAR 文件的 ID。您可以将其他参数作为 JSON 对象传递,例如作业参数、入口类、默认并行度、保存点路径和允许非恢复状态标志。
操作 使用提供的参数启动由 JAR 文件(及其入口类)定义的应用程序。如果提供了保存点路径,则从保存点初始化应用程序状态。
响应 启动应用程序的作业 ID

使用默认并行度为 4 启动应用程序的 curl 命令是:

curl -d '{"parallelism":"4"}' -X POST \
http://localhost:8081/v1/jars/43e844ef-382f-45c3-aa2f-00549acd961e_App.jar/run

表 10-11、10-12 和 10-13 显示如何使用 REST API 管理运行中的应用程序。

表 10-11. 列出所有应用程序的 REST 请求

请求 GET /jobs
响应 列出所有运行中应用程序的作业 ID,以及最近失败、取消和完成的应用程序的作业 ID。

表 10-12. 显示应用程序详细信息的 REST 请求

请求 GET /jobs/<jobId>
参数 jobId: 由列表应用程序命令提供的作业 ID
响应 基本统计信息,如应用程序的名称、启动时间(和结束时间),以及有关执行任务的信息,包括摄取和发出的记录数和字节数

REST API 还提供有关应用程序以下方面的更详细信息:

  • 应用程序的操作计划

  • 应用程序的配置

  • 应用程序在各个详细级别上的收集指标

  • 检查点度量

  • 回压指标

  • 导致应用程序失败的异常

查看 官方文档 获取访问此信息的详细信息。

表 10-13. 取消应用程序的 REST 请求

请求
PATCH /jobs/<jobId>

|

参数 jobId: 由列表应用程序命令提供的作业 ID
操作 取消应用程序

您还可以通过显示在 表 10-14 中的 REST 调用获取正在运行的应用程序的保存点。

表 10-14. 用于获取应用程序保存点的 REST 请求

请求
POST /jobs/<jobId>/savepoints

|

参数 由列表应用程序命令提供的作业 ID。此外,您需要提供一个 JSON 对象,其中包含保存点文件夹的路径,并告知是否终止带有保存点的应用程序。
操作 获取应用程序的保存点
响应 用于检查保存点触发操作是否成功完成的请求 ID

触发不取消保存点的 curl 命令是:

curl -d '{"target-directory":"file:///savepoints", "cancel-job":"false"}'\ 
-X POST http://localhost:8081/v1/jobs/e99cdb41b422631c8ee2218caa6af1cc/savepoints
{"request-id":"ebde90836b8b9dc2da90e9e7655f4179"}

使用保存点取消应用程序可能失败

只有在成功获取保存点的情况下,取消应用程序的请求才会成功。如果保存点命令失败,应用程序将继续运行。

要检查带有 ID ebde90836b8b9dc2da90e9e7655f4179 的请求是否成功,并检索保存点运行的路径:

curl -X GET http://localhost:8081/v1/jobs/e99cdb41b422631c8ee2218caa6af1cc/\
savepoints/ebde90836b8b9dc2da90e9e7655f4179
{"status":{"id":"COMPLETED"} 
"operation":{"location":"file:///savepoints/savepoint-e99cdb-34410597dec0"}}

要释放保存点,请使用显示在 表 10-15 中的 REST 调用。

表 10-15. 释放保存点的 REST 请求

请求
POST /savepoint-disposal

|

参数 需要提供作为 JSON 对象参数的保存点路径
操作 处置一个保存点
响应 用于检查保存点是否成功释放的请求 ID

要使用 curl 处置一个保存点,请运行:

curl -d '{"savepoint-path":"file:///savepoints/savepoint-e99cdb-34410597"}'\
-X POST http://localhost:8081/v1/savepoint-disposal
{"request-id":"217a4ffe935ceac2c281bdded76729d6"}

表 10-16 显示重新缩放应用程序的 REST 调用。

表 10-16. 重新缩放应用程序的 REST 请求

请求
PATCH /jobs/<jobID>/rescaling

|

参数 jobID:由列表应用命令提供的作业 ID。此外,您需要将应用程序的新并行度作为 URL 参数提供。
操作 获取一个保存点,取消应用程序,并使用保存点的新默认并行度重新启动应用程序
响应 用于检查重缩放请求是否成功的请求 ID

要使用 curl 将应用程序重新缩放到新的默认并行度 16,请运行:

curl -X PATCH
http://localhost:8081/v1/jobs/129ced9aacf1618ebca0ba81a4b222c6/rescaling\
?parallelism=16
{"request-id":"39584c2f742c3594776653f27833e3eb"}

应用程序可能不会重新缩放

如果触发的保存点失败,应用程序将继续以原始并行度运行。您可以使用请求 ID 检查重新缩放请求的状态。

在容器中捆绑和部署应用程序

到目前为止,我们已经解释了如何在运行中的 Flink 集群上启动应用程序。这就是我们称之为框架风格的应用程序部署。在“应用程序部署”中,我们简要介绍了另一种选择——库模式,它不需要运行 Flink 集群来提交作业。

在库模式下,应用程序被捆绑到一个 Docker 镜像中,该镜像还包含所需的 Flink 二进制文件。该镜像可以通过两种方式启动——作为 JobMaster 容器或 TaskManager 容器。当镜像作为 JobMaster 部署时,容器启动一个 Flink 主进程,立即启动捆绑的应用程序。TaskManager 容器在 JobMaster 注册自己并提供其处理插槽。一旦有足够的插槽可用,JobMaster 容器就会部署应用程序以执行。

运行 Flink 应用程序的库式风格类似于在容器化环境中部署微服务。当部署在容器编排框架(如 Kubernetes)上时,框架会重新启动失败的容器。在本节中,我们描述了如何构建特定作业的 Docker 镜像以及如何在 Kubernetes 上部署库式捆绑应用程序。

Apache Flink 提供了一个脚本来构建特定作业的 Flink Docker 镜像。该脚本包含在源分发和 Flink 的 Git 存储库中。它不是 Flink 二进制分发的一部分。

您可以下载并提取 Flink 的源分发包,或克隆 Git 存储库。从分发包的基本文件夹开始,脚本位于 ./flink-container/docker/build.sh

构建脚本创建并注册一个新的 Docker 镜像,该镜像基于 Java Alpine 镜像,这是一个提供 Java 的最小基础镜像。脚本需要以下参数:

  • Flink 存档的路径

  • 应用程序 JAR 文件的路径

  • 新镜像的名称

要使用包含本书示例应用程序的 Flink 1.7.1 构建镜像,请执行以下脚本:

cd ./flink-container/docker
./build.sh \
    --from-archive <path-to-Flink-1.7.1-archive> \
    --job-jar <path-to-example-apps-JAR-file> \
    --image-name flink-book-apps

如果在构建脚本完成后运行docker images命令,您应该看到一个名为flink-book-apps的新 Docker 镜像。

./flink-container/docker目录还包含一个docker-compose.yml文件,用于使用docker-compose部署 Flink 应用程序。

如果运行以下命令,则从“A Quick Look at Flink”的示例应用程序部署到一个主控和三个工作容器的 Docker 上:

FLINK_DOCKER_IMAGE_NAME=flink-book-jobs \
  FLINK_JOB=io.github.streamingwithflink.chapter1.AverageSensorReadings \
  DEFAULT_PARALLELISM=3 \
  docker-compose up -d

您可以通过访问运行在http://localhost:8081的 Web UI 来监控和控制应用程序。

在 Kubernetes 上运行特定作业的 Docker 镜像

在 Kubernetes 上运行特定作业的 Docker 镜像与在“Kubernetes”中描述的启动 Flink 集群非常相似。原则上,您只需调整描述部署的 YAML 文件以使用包含作业代码的镜像,并配置它在容器启动时自动启动作业。

Flink 为源分发或项目 Git 仓库中提供的 YAML 文件提供模板。从基础目录开始,模板位于以下位置:

./flink-container/kubernetes

该目录包含两个模板文件:

  • job-cluster-job.yaml.template将主控容器配置为 Kubernetes 作业。

  • task-manager-deployment.yaml.template将工作容器配置为 Kubernetes 部署。

两个模板文件都包含需要替换为实际值的占位符:

  • ${FLINK_IMAGE_NAME}:作业特定镜像的名称。

  • ${FLINK_JOB}:要启动的作业的主类。

  • ${FLINK_JOB_PARALLELISM}:作业的并行度。此参数还确定启动的工作容器数量。

如您所见,这些是我们在使用docker-compose部署作业特定镜像时使用的相同参数。该目录还包含一个定义 Kubernetes 服务的 YAML 文件job-cluster-service.yaml。一旦复制了模板文件并配置了所需的值,您就可以像以前一样使用kubectl将应用程序部署到 Kubernetes:

kubectl create -f job-cluster-service.yaml
kubectl create -f job-cluster-job.yaml
kubectl create -f task-manager-deployment.yaml

在 Minikube 上运行特定作业图像

在 Minikube 集群上运行特定作业图像需要比在“Kubernetes”中讨论的步骤多一些。问题在于 Minikube 试图从公共 Docker 镜像注册表而不是您机器的本地 Docker 注册表中获取自定义镜像。

但是,您可以通过运行以下命令配置 Docker 将其镜像部署到 Minikube 自己的注册表:

eval $(minikube docker-env)

之后您在此 shell 中构建的所有映像都将部署到 Minikube 的映像注册表中。Minikube 必须在运行状态。

此外,您需要在 YAML 文件中设置ImagePullPolicyNever,以确保 Minikube 从其自己的注册表中获取映像。

一旦特定于作业的容器运行起来,你可以按照“Kubernetes”章节描述的方式将集群视为常规的 Flink 集群。

控制任务调度

Flink 应用程序通过将运算符并行化为任务并分布这些任务到集群中的工作进程来并行执行。与许多其他分布式系统一样,Flink 应用程序的性能很大程度上取决于任务的调度方式。分配任务的工作进程、与任务同处一个位置的任务以及分配给工作进程的任务数量,这些因素都可能对应用程序的性能产生显著影响。

在“任务执行”章节中,我们描述了 Flink 如何将任务分配给插槽,并如何利用任务链接来减少本地数据交换的成本。在本节中,我们讨论了如何调整默认行为、控制任务链接以及任务分配到插槽,以提高应用程序的性能。

控制任务链接

任务链接将两个或多个运算符的并行任务融合成一个由单个线程执行的单个任务。融合的任务通过方法调用交换记录,因此基本上没有通信成本。由于任务链接可以提高大多数应用程序的性能,因此在 Flink 中默认启用。

然而,某些应用程序可能不会从任务链接中获益。一个原因是为了打破昂贵函数的链条,以便在不同的处理插槽上执行它们。您可以通过StreamExecutionEnvironment完全禁用应用程序的任务链接:

StreamExecutionEnvironment.disableOperatorChaining()

除了为整个应用程序禁用链接外,您还可以控制单个运算符的链接行为。要禁用特定运算符的链接,可以调用其disableChaining()方法。这将阻止该运算符的任务链接到前后任务(Example 10-1)。

Example 10-1. 为运算符禁用任务链接
val input: DataStream[X] = ...
val result: DataStream[Y] = input
  .filter(new Filter1())
  .map(new Map1())
  // disable chaining for Map2
  .map(new Map2()).disableChaining()
  .filter(new Filter2())

Example 10-1 中的代码会产生三个任务:Filter1Map1 的链接任务,Map2 的单独任务以及 Filter2 的任务,不允许链接到 Map2

还可以通过调用其startNewChain()方法(Example 10-2)来启动运算符的新链。该运算符的任务将不会链接到前面的任务,但如果符合链接要求,则会链接到后续任务。

Example 10-2. 使用运算符启动新的任务链
val input: DataStream[X] = ...
val result: DataStream[Y] = input
  .filter(new Filter1())
  .map(new Map1())
  // start a new chain for Map2 and Filter2
  .map(new Map2()).startNewChain()
  .filter(new Filter2())

在示例 10-2 中创建了两个链接任务:一个任务用于 Filter1Map1,另一个任务用于 Map2Filter2。请注意,新的链接任务以调用startNewChain()方法的操作符开始——在我们的示例中是Map2

定义槽共享组

Flink 的默认任务调度策略将程序的完整切片分配给单个处理槽,即将应用程序的每个操作符的一个任务分配给单个处理槽。² 根据应用程序的复杂性和操作符的计算成本,这种默认策略可能会使处理槽过载。Flink 提供手动控制任务分配到槽的机制——即槽共享组。

每个操作符都是槽共享组的成员。所有属于同一槽共享组的操作符任务都由相同的槽处理。在槽共享组内,任务按照“任务执行”中描述的方式分配给槽——每个槽处理每个操作符的最多一个任务。因此,一个槽共享组需要与其操作符的最大并行度相同数量的处理槽。属于不同槽共享组的操作符任务不由相同的槽执行。

默认情况下,每个操作符都属于"default"槽共享组。对于每个操作符,您可以使用slotSharingGroup(String)方法显式指定其槽共享组。如果其输入操作符都属于同一组,则操作符继承其输入操作符的槽共享组。如果输入操作符属于不同组,则操作符属于"default"组。示例 10-3 展示了如何在 Flink DataStream 应用程序中指定槽共享组。

示例 10-3 控制任务调度与槽共享组
// slot-sharing group "green"
val a: DataStream[A] = env.createInput(...)
  .slotSharingGroup("green")
  .setParallelism(4)
val b: DataStream[B] = a.map(...)
  // slot-sharing group "green" is inherited from a
  .setParallelism(4)

// slot-sharing group "yellow"
val c: DataStream[C] = env.createInput(...)
  .slotSharingGroup("yellow")
  .setParallelism(2)

// slot-sharing group "blue"
val d: DataStream[D] = b.connect(c.broadcast(...)).process(...)
  .slotSharingGroup("blue")
  .setParallelism(4)
val e = d.addSink()
  // slot-sharing group "blue" is inherited from d
  .setParallelism(2)

应用程序在示例 10-3 中包括五个操作符、两个源、两个中间操作符和一个接收操作符。这些操作符分配到三个共享槽组中:greenyellowblue。图 10-1 显示了应用程序的 JobGraph 及其任务如何映射到处理槽。

图 10-1 控制任务调度与槽共享组

该应用程序需要 10 个处理槽。蓝色和绿色的槽共享组各需四个槽,因为其分配的操作符具有最大并行度。黄色的槽共享组需要两个槽。

调整检查点和恢复

启用故障容错的 Flink 应用程序会定期对其状态进行检查点。检查点可以是一项昂贵的操作,因为需要复制到持久存储的数据量可能相当大。增加检查点间隔可以减少常规处理期间的故障容错开销。但是,这也会增加作业在从故障中恢复后需要重新处理的数据量,以赶上流的尾部。

Flink 提供了几个参数来调整检查点和状态后端。配置这些选项对于确保生产中流应用程序的可靠和平稳运行非常重要。例如,减少每个检查点的开销可以促进更高的检查点频率,从而加快恢复周期。在本节中,我们描述了用于控制应用程序检查点和恢复的参数。

配置检查点

当您为应用程序启用检查点时,必须指定检查点间隔——JobManager 将在该间隔内启动应用程序源头的检查点。

StreamExecutionEnvironment上启用了检查点:

val env: StreamExecutionEnvironment = ???

// enable checkpointing with an interval of 10 seconds.
env.enableCheckpointing(10000);

进一步配置检查点行为的选项由CheckpointConfig提供,该对象可从StreamExecutionEnvironment中获取:

// get the CheckpointConfig from the StreamExecutionEnvironment
val cpConfig: CheckpointConfig = env.getCheckpointConfig

默认情况下,Flink 创建检查点以确保状态一致性为一次性。但是,也可以配置为提供至少一次性保证:

// set mode to at-least-once
cpConfig.setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE);

根据应用程序的特性、其状态的大小以及状态后端及其配置,检查点可能需要几分钟的时间。此外,状态的大小可能会随时间的推移而增长和缩小,可能是由于长时间运行的窗口。因此,检查点花费的时间可能比配置的检查点间隔长。默认情况下,Flink 每次只允许一个检查点处于进行中状态,以避免检查点占用用于常规处理的太多资源。如果根据配置的检查点间隔需要启动检查点,但当前有另一个检查点正在进行,第二个检查点将被暂停,直到第一个检查点完成。

如果许多或所有检查点的时间超过了检查点间隔,这种行为可能不是最佳选择,原因有两个。首先,这意味着应用程序的常规数据处理将始终与并发检查点竞争资源。因此,其处理速度减慢,可能无法取得足够的进展以跟上传入的数据。其次,可能会因为需要等待另一个检查点完成而延迟检查点,从而导致较低的检查点间隔,在恢复期间导致较长的赶上处理。Flink 提供了参数来解决这些情况。

为确保应用程序能够取得足够的进展,您可以配置检查点之间的最小暂停时间。如果将最小暂停时间配置为 30 秒,则在完成一个检查点后的前 30 秒内不会启动新的检查点。这也意味着有效的检查点间隔至少为 30 秒,并且最多同时进行一个检查点。

// make sure we process at least 30s without checkpointing
cpConfig.setMinPauseBetweenCheckpoints(30000);

在某些情况下,您可能希望确保在配置的检查点间隔内进行检查点,即使检查点需要的时间超过了该间隔。一个例子是当检查点需要很长时间但不消耗太多资源时;例如,由于对外部系统的高延迟调用操作。在这种情况下,您可以配置最大并发检查点数目。

// allow three checkpoint to be in progress at the same time 
cpConfig.setMaxConcurrentCheckpoints(3);

注意

保存点与检查点并发进行。由于检查点操作,Flink 不会延迟显式触发的保存点。无论进行中的检查点数目如何,保存点始终会启动。

为避免长时间运行的检查点,您可以配置超时间隔,超过此间隔后将取消检查点。默认情况下,检查点在 10 分钟后取消。

// checkpoints have to complete within five minutes, or are discarded
cpConfig.setCheckpointTimeout(300000);

最后,您可能还想配置检查点失败时的处理方式。默认情况下,检查点失败会导致应用程序重新启动的异常。您可以禁用此行为,并在检查点错误后让应用程序继续运行。

// do not fail the job on a checkpointing error
cpConfig.setFailOnCheckpointingErrors(false);

启用检查点压缩

Flink 支持压缩检查点和保存点。在 Flink 1.7 之前,唯一支持的压缩算法是 Snappy。您可以如下启用压缩检查点和保存点:

val env: StreamExecutionEnvironment = ???

// enable checkpoint compression
env.getConfig.setUseSnapshotCompression(true)

注意

注意,增量 RocksDB 检查点不支持检查点压缩。

在应用程序停止后保留检查点

检查点的目的是在失败后恢复应用程序。因此,在作业停止运行时,无论是由于故障还是显式取消,它们都会清理。但是,您还可以启用一个称为外部化检查点的功能,在应用程序停止后保留检查点。

// Enable externalized checkpoints
cpConfig.enableExternalizedCheckpoints(
  ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION)

外部化检查点有两个选项:

  • RETAIN_ON_CANCELLATION在应用程序完全失败并显式取消时保留检查点。

  • DELETE_ON_CANCELLATION仅在应用程序完全失败后保留检查点。如果显式取消应用程序,则删除检查点。

注意

外部化检查点不替代保存点。它们使用特定于状态后端的存储格式,不支持重新调整大小。因此,在应用程序失败后,它们足以重新启动应用程序,但比保存点灵活性较低。应用程序再次运行后,可以进行保存点。

配置状态后端

应用程序的状态后端负责维护本地状态,执行检查点和保存点,并在故障后恢复应用程序状态。因此,应用程序状态后端的选择和配置对检查点性能有很大影响。有关各个状态后端的详细信息,请参阅"选择状态后端"。

应用程序的默认状态后端是 MemoryStateBackend。由于它将所有状态保存在内存中,并且检查点完全存储在易失性和 JVM 大小限制的 JobManager 堆存储中,因此不建议用于生产环境。但是,它在本地开发 Flink 应用程序时表现良好。"检查点和状态后端"描述了如何配置 Flink 集群的默认状态后端。

您还可以明确选择应用程序的状态后端:

val env: StreamExecutionEnvironment = ???

// create and configure state backend of your choice
val stateBackend: StateBackend = ???
// set state backend
env.setStateBackend(stateBackend)

不同的状态后端可以使用最小设置创建,如下所示。MemoryStateBackend 不需要任何参数。但是,有些构造函数接受参数以启用或禁用异步检查点(默认情况下启用)并限制状态的大小(默认为 5 MB):

// create a MemoryStateBackend
val memBackend = new MemoryStateBackend()

FsStateBackend 只需要一个路径来定义检查点的存储位置。还有构造函数变体可用于启用或禁用异步检查点(默认情况下启用):

// create a FsStateBackend that checkpoints to the /tmp/ckp folder
val fsBackend = new FsStateBackend("file:///tmp/ckp", true)

RocksDBStateBackend 只需要一个路径来定义检查点的存储位置,并采用一个可选参数来启用增量检查点(默认情况下禁用)。RocksDBStateBackend 总是异步写入检查点:

// create a RocksDBStateBackend that writes incremental checkpoints 
// to the /tmp/ckp folder
val rocksBackend = new RocksDBStateBackend("file:///tmp/ckp", true)

在"检查点和状态后端"中,我们讨论了状态后端的配置选项。当然,您也可以在应用程序中配置状态后端,覆盖默认值或群集范围的配置。为此,您必须通过向状态后端传递一个 Configuration 对象来创建一个新的后端对象。有关可用配置选项的描述,请参阅"检查点和状态后端":

// all of Flink's built-in backends are configurable
val backend: ConfigurableStateBackend = ??? 

// create configuration and set options
val sbConfig = new Configuration()
sbConfig.setBoolean("state.backend.async", true)
sbConfig.setString("state.savepoints.dir", "file:///tmp/svp")

// create a configured copy of the backend
val configuredBackend = backend.configure(sbConfig)

由于 RocksDB 是一个外部组件,它带来了一套自己的调优参数,这些参数也可以为您的应用程序进行调整。默认情况下,RocksDB 针对 SSD 存储进行了优化,如果状态存储在旋转磁盘上,则性能不佳。Flink 为常见硬件设置提供了一些预定义的设置以提高性能。查看文档以了解更多可用的设置。您可以按以下方式将预定义选项应用于 RocksDBStateBackend

val backend: RocksDBStateBackend = ???

// set predefined options for spinning disk storage
backend.setPredefinedOptions(PredefinedOptions.SPINNING_DISK_OPTIMIZED)

配置恢复

当经过检查点的应用程序失败时,将通过启动其任务、恢复其状态(包括源任务的读取偏移量)并继续处理来重启它。应用程序重新启动后立即处于追赶阶段。由于应用程序的源任务被重置到较早的输入位置,它处理先前失败时处理的数据以及应用程序关闭时累积的数据。

为了能够赶上流的尾部,应用程序必须以比新数据到达的速度更高的速度处理累积的数据。在应用程序追赶时,处理延迟——即输入变为可用直至实际处理的时间——会增加。

因此,应用程序在重启后需要足够的备用资源来成功恢复其常规处理。这意味着应用程序在常规处理期间不应该接近 100%的资源消耗。为恢复可用的资源越多,追赶阶段完成得越快,处理延迟恢复正常的速度也越快。

除了对恢复的资源考虑外,我们还将讨论另外两个与恢复相关的话题:重启策略和本地恢复。

重启策略

根据导致应用程序崩溃的故障,应用程序可能会因同样的故障再次崩溃。一个常见例子是应用程序无法处理的无效或损坏的输入数据。在这种情况下,应用程序将陷入无尽的恢复循环中,消耗大量资源而无法恢复到常规处理状态。Flink 具有三种重启策略来解决这个问题:

  • 固定延迟重启策略会重新启动应用程序固定次数,并在重启尝试之前等待一段时间。

  • 故障率重启策略在可配置的故障率未超出范围的情况下重启应用程序。故障率指定为在时间间隔内的最大故障次数。例如,您可以配置应用程序只要在过去的十分钟内没有超过三次失败就会重启。

  • 无重启策略不会重启应用程序,而是立即失败。

应用程序的重启策略是通过StreamExecutionEnvironment配置的,如示例 10-4 所示。

示例 10-4. 配置应用程序的重启策略
val env = StreamExecutionEnvironment.getExecutionEnvironment

env.setRestartStrategy(
  RestartStrategies.fixedDelayRestart(
    5,                            // number of restart attempts
    Time.of(30, TimeUnit.SECONDS) // delay between attempts
))

如果没有显式定义重启策略,则使用的默认重启策略是固定延迟重启策略,重启尝试次数为Integer.MAX_VALUE,延迟为 10 秒。

本地恢复

Flink 的状态后端(除了 MemoryStateBackend)将检查点存储在远程文件系统中。这样首先保证状态已保存且持久化,其次能够在工作节点丢失或应用程序重新缩放时重新分发状态。然而,在恢复期间从远程存储读取状态并不是非常高效的。此外,在恢复时,可能能够在与故障前相同的工作节点上重新启动应用程序。

Flink 支持一个称为本地恢复的功能,可显著加快恢复速度,如果应用程序能够重新启动到相同的机器上。启用时,状态后端还将检查点数据的副本存储在其工作节点的本地磁盘上,除了将数据写入远程存储系统。应用程序重新启动时,Flink 尝试将相同的任务调度到相同的工作节点。如果成功,任务首先尝试从本地磁盘加载检查点数据。如果出现任何问题,它们会回退到远程存储。

本地恢复的实现方式是远程系统中的状态副本为真实数据源。任务仅在远程写入成功时才确认检查点。此外,检查点不会因为本地状态副本失败而失败。由于检查点数据被写入两次,本地恢复会增加检查点的开销。

可以在 flink-conf.yaml 文件中为集群或每个应用程序启用和配置本地恢复,方法如下所示:

  • state.backend.local-recovery: 此标志用于启用或禁用本地恢复。默认情况下,本地恢复未激活。

  • taskmanager.state.local.root-dirs: 此参数指定一个或多个本地路径,用于存储本地状态副本。

注意

本地恢复仅影响分区键控状态,这种状态始终被分区,并且通常占据大部分状态大小。操作符状态不会存储在本地,需要从远程存储系统中检索。然而,操作符状态通常比键控状态小得多。此外,MemoryStateBackend 不支持本地恢复,因为它本身不支持大状态。

监控 Flink 集群和应用程序

监控您的流处理作业对确保其健康运行至关重要,并及早检测到可能的配置错误、资源不足或意外行为的症状。特别是当流处理作业是更大数据处理管道或用户面向应用程序的事件驱动服务的一部分时,您可能希望尽可能精确地监控其性能,并确保其满足延迟、吞吐量、资源利用率等特定目标。

Flink 在运行时收集一组预定义的指标,并提供一个框架,允许您定义和跟踪自己的指标。

使用 Flink 的 Web UI 是了解您的 Flink 集群概况以及了解作业内部运行情况的最简单方法。您可以通过访问 http://**<jobmanager-hostname>**:8081 来访问仪表板。

在主屏幕上,您将看到集群配置的概述,包括 TaskManagers 的数量,配置和可用任务插槽数量,以及正在运行和已完成的作业数。图 10-2 展示了仪表板主屏幕的一个实例。左侧菜单链接到有关作业和配置参数的更详细信息,还允许通过上传 JAR 文件进行作业提交。

如果您单击正在运行的作业,可以快速查看每个任务或子任务的运行统计信息,如图 10-3](#fig_statistics) 所示。您可以检查交换的持续时间、字节和记录,并根据需要对每个 TaskManager 进行汇总。

运行中作业的统计数据

图 10-3. 运行中作业的统计数据

如果您单击“任务度量”选项卡,可以从下拉菜单中选择更多度量指标,如图 10-4](#fig_metrics) 所示。这些指标包括有关任务的更精细的统计信息,例如缓冲区使用情况、水印和输入/输出速率。

选择要绘制的指标

图 10-4. 选择要绘制的指标

图 10-5 展示了选择的指标如何显示为持续更新的图表。

实时度量图

图 10-5. 实时度量图

检查点 选项卡(图 10-3)显示有关先前和当前检查点的统计信息。在 概述 下,您可以查看触发了多少个检查点、正在进行中的检查点、成功完成的检查点或失败的检查点。如果单击 历史 视图,可以检索更详细的信息,例如状态、触发时间、状态大小以及在检查点对齐阶段期间缓冲的字节数。摘要 视图汇总检查点统计信息,并提供所有已完成检查点的最小、最大和平均值。最后,在 配置 下,您可以查看检查点的配置属性,例如设置的间隔和超时值。

类似地,背压 选项卡显示每个操作符和子任务的背压统计信息。如果单击行,将触发背压采样,并且将在大约五秒钟内看到消息 采样进行中…。采样完成后,您将在第二列中看到背压状态。受背压影响的任务将显示 HIGH 标志;否则,您应该看到一个漂亮的绿色 OK 消息显示。

度量系统

在生产环境中运行诸如 Flink 之类的数据处理系统时,监控其行为至关重要,以便发现和诊断性能下降的原因。Flink 默认收集多个系统和应用程序指标。指标按操作符、TaskManager 或 JobManager 收集。在这里,我们描述了一些常用的指标,并引导您参阅 Flink 文档获取可用指标的完整列表。

类别包括 CPU 利用率、内存使用情况、活动线程数、垃圾收集统计信息、网络指标(如排队的输入/输出缓冲区数)、整个集群的指标(如运行中的作业数和可用资源)、作业指标(包括运行时间、重试次数和检查点信息)、I/O 统计信息(包括本地和远程记录交换数)、水印信息、特定连接器的指标等。

注册和使用指标

要注册指标,您需要通过在RuntimeContext上调用getMetrics()方法来检索MetricGroup,如示例 Example 10-5 所示。

示例 10-5. 在 FilterFunction 中注册和使用指标
class PositiveFilter extends RichFilterFunction[Int] {

  @transient private var counter: Counter = _

  override def open(parameters: Configuration): Unit = {
    counter = getRuntimeContext
      .getMetricGroup
      .counter("droppedElements")
  }

  override def filter(value: Int): Boolean = {
    if (value > 0) {
      true
    }
    else {
      counter.inc()
      false
    }
  }
}

指标组

Flink 指标通过MetricGroup接口进行注册和访问。MetricGroup提供了创建嵌套的命名指标层次结构的方法,并提供了注册以下指标类型的方法:

Counter

org.apache.flink.metrics.Counter指标用于计量计数,并提供了增加和减少计数的方法。您可以使用MetricGroup上的counter(String name, Counter counter)方法注册计数器指标。

Gauge

Gauge指标在某一时刻计算任意类型的值。要使用Gauge,您需要实现org.apache.flink.metrics.Gauge接口,并使用MetricGroup上的gauge(String name, Gauge gauge)方法进行注册。代码示例 Example 10-6 展示了WatermarkGauge指标的实现,它公开了当前水印。

示例 10-6. 实现了一个展示当前水印的 WatermarkGauge 指标
public class WatermarkGauge implements Gauge<Long> {
  private long currentWatermark = Long.MIN_VALUE;

  public void setCurrentWatermark(long watermark) {
    this.currentWatermark = watermark;
    }

  @Override
  public Long getValue() {
    return currentWatermark;
  }
}

指标以字符串形式报告

如果类型没有提供有意义的toString()实现,指标报告器将把Gauge值转换为String,因此请确保您提供一个有意义的实现。

Histogram

您可以使用直方图表示数值数据的分布情况。Flink 的直方图专门用于报告长整型值的指标。org.apache.flink.metrics.Histogram接口允许您收集值,获取已收集值的当前计数,并为迄今为止见过的值创建统计信息,如最小值、最大值、标准差和平均值。

除了创建自己的直方图实现外,Flink 还允许您使用DropWizard直方图,方法是在以下位置添加依赖:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-metrics-dropwizard</artifactId>
  <version>flink-version</version>
</dependency>

您可以使用 DropwizardHistogramWrapper 类在 Flink 程序中注册一个 DropWizard 直方图,如 示例 10-7 所示。

示例 10-7. 使用 DropwizardHistogramWrapper
// create and register histogram
DropwizardHistogramWrapper histogramWrapper = 
  new DropwizardHistogramWrapper(
    new com.codahale.metrics.Histogram(new SlidingWindowReservoir(500)))
metricGroup.histogram("myHistogram", histogramWrapper)

// update histogram
histogramWrapper.update(value)

您可以使用 Meter 指标来测量某些事件发生的速率(每秒事件数)。org.apache.flink.metrics.Meter 接口提供了标记一个或多个事件发生、获取每秒事件当前速率以及获取计量器上标记的当前事件数的方法。

与直方图一样,您可以通过在 pom.xml 中添加 flink-metrics-dropwizard 依赖项并使用 DropwizardMeterWrapper 类将 Meter 用作 DropWizard 米来实现。

作用域和格式化指标

Flink 指标属于一个作用域,可以是系统作用域(用于系统提供的指标)或用户作用域(用于自定义用户指标)。指标由一个包含最多三个部分的唯一标识符引用:

  1. 用户在注册指标时指定的名称

  2. 可选的用户作用域

  3. 系统作用域

例如,名称“myCounter”,用户作用域“MyMetrics”和系统作用域“localhost.taskmanager.512”将导致标识符“localhost.taskmanager.512.MyMetrics.myCounter”。您可以通过设置 metrics.scope.delimiter 配置选项来更改默认的“.” 分隔符。

系统作用域声明指标引用的系统组件以及应包含的上下文信息。指标可以作用于作业管理器、任务管理器、作业、操作器或任务。您可以通过在 flink-conf.yaml 文件中设置相应的指标选项来配置指标应包含的上下文信息。我们在 表 10-17 中列出了一些这些配置选项及其默认值。

表 10-17. 系统作用域配置选项及其默认值

作用域 配置键 默认值
作业管理器 metrics.scope.jm .jobmanager
作业管理器和作业 metrics.scope.jm.job .jobmanager.<job_name>
任务管理器 metrics.scope.tm .taskmanager.<tm_id>
任务管理器和作业 metrics.scope.tm.job .taskmana​****​ger.<tm_id>.<job_name>
任务 metrics.scope.task .taskmanager.<tm_id>.<job_name>.<task_name>.<subtask_index>
操作器 metrics.scope.operator .taskmanager.<tm_id>.<job_name>.<operator_name>.<subtask_index>

配置键包含常量字符串,如“taskmanager”,以及显示在尖括号中的变量。后者将在运行时替换为实际值。例如,TaskManager 指标的默认作用域可能创建作用域“localhost.taskmanager.512”,其中“localhost”和“512”是参数值。表 10-18 显示了可用于配置指标作用域的所有变量。

表 10-18. 可用于配置指标作用域格式的所有变量

范围 可用变量
作业管理器: <host>
任务管理器: <host>, <tm_id>
作业: <job_id>, <job_name>
任务: <task_id>, <task_name>, <task_attempt_id>, <task_attempt_num>, <subtask_index>
操作符: <operator_id>, <operator_name>, <subtask_index>

作业范围标识符必须是唯一的。

如果同时运行多个相同的作业副本,由于字符串冲突,可能会导致指标不准确。为避免此风险,您应确保每个作业的作用域标识符是唯一的。可以通过包含 <job_id> 来轻松处理此问题。

您还可以通过调用 MetricGroupaddGroup() 方法来为指标定义用户范围,如 示例 10-8 中所示。

示例 10-8. 定义用户范围“我的指标”
counter = getRuntimeContext
  .getMetricGroup
  .addGroup("MyMetrics")
  .counter("myCounter")

暴露指标

现在您已经学会了如何注册、定义和分组指标,您可能想知道如何从外部系统访问它们。毕竟,您可能收集指标是为了创建实时仪表板或将测量结果馈送给另一个应用程序。您可以通过报告器将指标暴露给外部后端系统,而 Flink 为其中的几种提供了实现(参见 表 10-19 )。

表 10-19. 可用指标报告器列表

报告器 实现
JMX org.apache.flink.metrics.jmx.JMXReporter
Graphite org.apache.flink.metrics.graphite.GraphiteReporter
Prometheus org.apache.flink.metrics.prometheus.PrometheusReporter
PrometheusPushGateway org.apache.flink.metrics.prometheus.PrometheusPushGatewayReporter
StatsD org.apache.flink.metrics.statsd.StatsDReporter
Datadog org.apache.flink.metrics.datadog.DatadogHttpReporter
Slf4j org.apache.flink.metrics.slf4j.Slf4jReporter

如果要使用不在上述列表中的指标后端系统,还可以通过实现 org.apache.flink.metrics.reporter.MetricReporter 接口来定义自己的报告器。

报告器需要在 flink-conf.yaml 中进行配置。将以下行添加到您的配置文件中将定义一个名为“my_reporter”的 JMX 报告器,监听 9020-9040 端口:

metrics.reporters: my_reporter
Metrics.reporter.my_jmx_reporter.class: org.apache.flink.metrics.jmx.JMXReporter
metrics.reporter.my_jmx_reporter.port: 9020-9040

请参阅Flink 文档获取每个受支持报告器的完整配置选项列表。

监控延迟

延迟可能是您希望监控以评估流式作业性能特征的第一个指标。与此同时,它也是分布式流式引擎(如 Flink)中定义的最棘手的指标之一。在“延迟”中,我们广义地定义延迟为处理事件所需的时间。您可以想象,在高速率流式作业中尝试按事件跟踪延迟的精确实现在实践中可能会出现问题。如果事件对多个窗口贡献,考虑到窗口运算符会进一步复杂化延迟跟踪,我们是否需要报告第一次调用的延迟,还是需要等到评估事件可能属于的所有窗口?如果一个窗口多次触发会怎么样?

Flink 采用简单和低开销的方法来提供有用的延迟度量测量。它并不试图严格为每个事件测量延迟,而是通过定期从源发出特殊记录并允许用户跟踪这些记录到达接收器所需时间来近似延迟。这种特殊记录称为延迟标记,它携带一个时间戳,指示其发出时间。

要启用延迟跟踪,您需要配置从源发出延迟标记的频率。您可以通过在ExecutionConfig中设置latencyTrackingInterval来实现这一点,如下所示:

env.getConfig.setLatencyTrackingInterval(500L)

延迟间隔以毫秒为单位指定。接收到延迟标记后,除接收器外的所有运算符将其向下游转发。延迟标记使用与正常流记录相同的数据流通道和队列,因此它们的跟踪延迟反映了记录等待处理的时间。但是,它们不测量记录处理所需的时间,也不测量记录在状态中等待处理的时间。

运算符在一个延迟度量器中保留延迟统计信息,其中包含最小值、最大值和平均值,以及 50、95 和 99 百分位值。接收器运算符则保留每个并行源实例接收到的延迟标记统计信息,因此检查接收器处的延迟标记可以用来近似记录在数据流中遍历的时间。如果您想自定义运算符处理延迟标记的方式,您可以覆盖processLatencyMarker()方法,并使用LatencyMarker的方法getMarkedTime()getVertexId()getSubTaskIndex()来检索相关信息。

警惕时钟偏移

如果您没有使用像 NTP 这样的自动时钟同步服务,您的机器时钟可能会出现时钟偏移。在这种情况下,延迟跟踪估计将不可靠,因为其当前实现假设时钟已同步。

配置日志记录行为

日志记录是调试和理解应用程序行为的另一个重要工具。默认情况下,Flink 使用SLF4J 日志抽象以及 log4j 日志框架。

示例 10-9 展示了一个 MapFunction,它记录每个输入记录的转换过程。

示例 10-9. 在 MapFunction 中使用日志记录
import org.apache.flink.api.common.functions.MapFunction
import org.slf4j.LoggerFactory
import org.slf4j.Logger

class MyMapFunction extends MapFunction[Int, String] {

  Logger LOG = LoggerFactory.getLogger(MyMapFunction.class)

  override def map(value: Int): String = {
    LOG.info("Converting value {} to string.", value)
    value.toString
  }
}

要修改 log4j 记录器的属性,请修改 conf/ 文件夹中的 log4j.properties 文件。例如,以下行将根日志级别设置为“warning”:

log4j.rootLogger=WARN

要设置自定义文件名和文件位置,请将 -Dlog4j.configuration= 参数传递给 JVM。Flink 还提供了 log4j-cli.properties 文件(用于命令行客户端)和 log4j-yarn-session.properties 文件(用于启动 YARN 会话的命令行客户端)。

除了 log4j 外,logback 也是一种替代方案,Flink 为该后端提供了默认配置文件。若要使用 logback 替代 log4j,则需要从 lib/ 文件夹中移除 log4j。关于如何设置和配置后端的详细信息,请参阅Flink 的文档logback 手册

概述

本章中,我们讨论了如何在生产环境中运行、管理和监控 Flink 应用程序。我们解释了收集和公开系统和应用程序指标的 Flink 组件、如何配置日志记录系统,以及如何使用命令行客户端和 REST API 启动、停止、恢复和调整应用程序。

¹ 请参阅第 3 章了解保存点的详细信息及其功能。

² 默认调度行为在第 3 章中有解释。

第十一章:从这里去哪里?

这是一个漫长的旅程,你已经完成了本书的阅读!但是你的 Flink 之旅才刚刚开始,本章节将指引你从这里可以走的可能路径。我们将简要介绍一些未包含在本书中的额外 Flink 功能,并提供一些指向更多 Flink 资源的指引。Flink 周围存在着一个充满活力的社区,我们鼓励你与其他用户建立联系,开始贡献,或者了解正在使用 Flink 构建什么样的公司,以激发你自己的工作。

Flink 生态系统的其他部分

尽管本书特别关注流处理,但实际上 Flink 是一个通用的分布式数据处理框架,也可以用于其他类型的数据分析。此外,Flink 提供了领域特定的库和 API,用于关系查询、复杂事件处理(CEP)和图处理。

用于批处理的 DataSet API

Flink 是一个完整的批处理处理器,可用于实现对有界输入数据进行一次性或定期查询的用例。DataSet 程序被指定为一系列转换,就像 DataStream 程序一样,不同之处在于 DataSet 是有界数据集合。DataSet API 提供运算符来执行过滤、映射、选择、连接和分组,以及用于从外部系统(如文件系统和数据库)读取和写入数据集的连接器。使用 DataSet API,您还可以定义迭代 Flink 程序,该程序执行固定步数的循环函数,或者直到满足收敛标准为止。

批处理作业在内部被表示为数据流程程序,并在与流处理作业相同的底层执行运行时上运行。目前,这两个 API 使用单独的执行环境,并且不能混合使用。然而,Flink 社区已经在努力统一这两者,并提供单一 API 以分析有界和无界数据流,这是 Flink 未来路线图中的一个重点。

Table API 和 SQL 用于关系分析

尽管底层的 DataStream 和 DataSet API 是分离的,但是您可以使用其更高级别的关系 API(Table API 和 SQL)在 Flink 中实现统一的流和批量分析。

Table API 是 Scala 和 Java 的语言集成查询(LINQ)API。查询可以在批处理或流分析中执行,无需修改。它提供了常见的运算符来编写关系查询,包括选择、投影、聚合和连接,并且还具有用于自动完成和语法验证的 IDE 支持。

Flink SQL 遵循 ANSI SQL 标准,并利用Apache Calcite进行查询解析和优化。Flink 为批处理和流处理查询提供统一的语法和语义。由于对用户定义函数的广泛支持,SQL 可以涵盖各种用例。您可以将 SQL 查询嵌入到常规的 Flink DataSet 和 DataStream 程序中,或者直接使用 SQL CLI 客户端向 Flink 集群提交 SQL 查询。CLI 客户端允许您在命令行中检索和可视化查询结果,这使其成为尝试和调试 Flink SQL 查询或在流处理或批处理数据上运行探索性查询的强大工具。此外,您还可以使用 CLI 客户端提交分离的查询,直接将结果写入外部存储系统。

用于复杂事件处理和模式匹配的 FlinkCEP

FlinkCEP 是用于复杂事件模式检测的高级 API 和库。它建立在 DataStream API 之上,允许您指定要在流中检测的模式。常见的 CEP 用例包括金融应用程序、欺诈检测、复杂系统中的监控和警报,以及检测网络入侵或可疑用户行为。

图处理的 Gelly

Gelly 是 Flink 的图处理 API 和库。它建立在 DataSet API 和 Flink 对高效批处理迭代的支持之上。Gelly 在 Java 和 Scala 中提供高级编程抽象,用于执行图转换、聚合以及诸如顶点为中心和收集-求和-应用等迭代处理。它还包括一组常见的图算法,可供直接使用。

注意

Flink 的高级 API 和接口与 DataStream 和 DataSet API 以及彼此之间良好集成,因此您可以轻松混合它们,并在同一程序中在库和 API 之间切换。例如,您可以使用 CEP 库从 DataStream 中提取模式,然后使用 SQL 分析提取的模式,或者您可以使用 Table API 将表筛选和投影为图,然后使用 Gelly 库中的图算法进行分析。

一个热情好客的社区

Apache Flink 拥有一个不断增长且热情好客的社区,全球贡献者和用户遍布各地。以下是一些资源,您可以使用这些资源提问、参加与 Flink 相关的活动,并了解人们如何使用 Flink:

邮件列表

  • user@flink.apache.org:用户支持和问题

  • dev@flink.apache.org:开发、发布和社区讨论

  • community@flink.apache.org:社区新闻和见面会

博客

见面会和会议

再次,我们希望您通过本书更好地了解 Apache Flink 的能力和可能性。我们鼓励您成为其社区的积极一员。

posted @ 2025-11-14 20:39  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报