原文:Streaming Systems
译者:飞龙
协议:CC BY-NC-SA 4.0
你好,冒险的读者,欢迎来到我们的书!在这一点上,我假设你要么对学习更多关于流处理的奇迹感兴趣,要么希望花几个小时阅读关于雄伟的棕色鳟鱼的荣耀。无论哪种方式,我都向你致敬!也就是说,属于后一种类型的人,如果你对计算机科学没有高级的理解,那么在继续前,你应该考虑一下你是否准备好面对失望;警告渔夫,等等。
为了从一开始就设定这本书的基调,我想提醒你一些事情。首先,这本书有点奇怪,因为我们有多个作者,但我们并不假装我们以某种方式都说和写着相同的声音,就像我们是奇怪的同卵三胞胎,碰巧出生在不同的父母身边。因为尽管听起来很有趣,但最终的结果实际上会更不愉快。相反,我们选择了用自己的声音写作,我们给了这本书足够的自我意识,可以在适当的时候提到我们每个人,但不会让它对我们只是一本书而不是像苏格兰口音的机器恐龙这样更酷的东西感到不满。¹
就声音而言,你会遇到三种:
泰勒
那就是我。如果你没有明确被告知有其他人在讲话,你可以假设是我,因为我们在游戏的后期才添加了其他作者,当我考虑回去更新我已经写过的一切时,我基本上是“不可能的”。我是谷歌数据处理语言和系统²组的技术负责人,负责谷歌云数据流、谷歌的 Apache Beam 工作,以及谷歌内部的数据处理系统,如 Flume、MillWheel 和 MapReduce。我也是 Apache Beam PMC 的创始成员。
Slava
Slava 是谷歌 MillWheel 团队的长期成员,后来成为 Windmill 团队的原始成员,该团队构建了 MillWheel 的继任者,迄今为止未命名的系统,该系统驱动了谷歌云数据流中的流引擎。Slava 是全球流处理系统中水印和时间语义的最高专家,没有之一。你可能会觉得不奇怪,他是第三章《水印》的作者。
Reuven
Reuven 在这个名单的底部,因为他在流处理方面的经验比 Slava 和我加起来还要丰富,因此如果他被放置得更高,他会压垮我们。Reuven 已经创建或领导了几乎所有谷歌通用流处理引擎中有趣的系统级魔术,包括在系统中应用了大量的细节关注,以提供高吞吐量、低延迟、精确一次的语义,同时利用了细粒度的检查点。你可能会觉得不奇怪,他是第五章《精确一次和副作用》的作者。他还是 Apache Beam PMC 成员。
现在你知道你将听到谁的声音,下一个合乎逻辑的步骤将是找出你将听到什么,这就是我想提到的第二件事。这本书在概念上有两个主要部分,每个部分有四章,然后是一个相对独立的章节。
乐趣从第一部分开始,Beam 模型(第 1-4 章),重点介绍了最初为谷歌云数据流开发的高级批处理加流处理数据处理模型,后来捐赠给 Apache 软件基金会作为 Apache Beam,并且现在整体或部分地出现在行业中的大多数其他系统中。它由四章组成:
第一章《流处理 101》,介绍了流处理的基础知识,建立了一些术语,讨论了流式系统的能力,区分了处理时间和事件时间这两个重要的时间领域,并最终研究了一些常见的数据处理模式。
第二章《数据处理的“什么”、“哪里”、“何时”和“如何”》,详细介绍了流处理的核心概念,分析了每个概念在无序数据的情况下的具体运行示例中的上下文,并通过动画图表突出了时间维度。
第三章《水印》(由 Slava 撰写),深入调查了时间进度指标的情况,它们是如何创建的,以及它们如何通过管道传播。最后,它通过检查两种真实世界的水印实现的细节来结束。
第四章《高级窗口》,延续了第二章的内容,深入探讨了一些高级窗口和触发概念,如处理时间窗口,会话和继续触发器。
在第一部分和第二部分之间,提供了一个及时的插曲,其中包含的细节非常重要,即第五章《精确一次和副作用》(由 Reuven 撰写)。在这一章中,他列举了提供端到端精确一次(或有效一次)处理语义的挑战,并详细介绍了三种不同方法的实现细节:Apache Flink,Apache Spark 和 Google Cloud Dataflow。
接下来是第二部分《流和表》(第 6-9 章),深入探讨了概念,并研究了更低级别的“流和表”处理流程的方式,这是最近由 Apache Kafka 社区的一些杰出成员广泛推广的,当然,几十年前就被数据库社区的人发明了,因为一切都是这样吗?它也由四章组成:
第六章《流和表》,介绍了流和表的基本概念,通过流和表的视角分析了经典的 MapReduce 方法,然后构建了一个足够一般的流和表理论,以包括 Beam 模型(以及更多)的全部范围。
第七章《持久状态的实际问题》,考虑了流水线中持久状态的动机,研究了两种常见的隐式状态类型,然后分析了一个实际用例(广告归因),以确定一般状态管理机制的必要特征。
第八章《流式 SQL》,研究了关系代数和 SQL 中流处理的含义,对比了 Beam 模型和经典 SQL 中存在的固有的流和表偏见,并提出了一套可能的前进路径,以在 SQL 中融入健壮的流处理语义。
第九章《流式连接》,调查了各种不同类型的连接,分析了它们在流处理环境中的行为,并最终详细研究了一个有用但支持不足的流式连接用例:时间有效窗口。
最后,结束本书的是第十章《大规模数据处理的演变》,它回顾了 MapReduce 数据处理系统的历史,检查了一些重要的贡献,这些贡献使流式处理系统演变成今天的样子。
最后,作为最后的指导,如果你让我描述我最希望读者从本书中学到的东西,我会说:
本书中最重要的一点是学习流和表的理论以及它们之间的关系。其他所有内容都是基于这一点展开的。不,我们要到第六章才会讨论这个话题。没关系,值得等待,到那时你会更好地准备好欣赏它的精彩之处。
时变关系是一种启示。它们是流处理的具体体现:是流系统构建的一切的具体体现,也是与我们从批处理世界中熟悉的工具的强大连接。我们要到第八章才会学习它们,但是再次强调,前面的学习将帮助你更加欣赏它们。
一个写得好的分布式流引擎是一件神奇的事情。这可能适用于分布式系统总体来说,但是当你了解更多关于这些系统是如何构建来提供它们的语义的(特别是第三章和第五章的案例研究),你就会更加明显地意识到它们为你做了多少繁重的工作。
LaTeX/Tikz 是一个制作图表的神奇工具,无论是动画还是其他形式。它是一个可怕的、充满尖锐边缘和破伤风的工具,但无论如何也是一个令人难以置信的工具。我希望本书中的动画图表能够为我们讨论的复杂主题带来清晰的解释,从而激励更多的人尝试使用 LaTeX/Tikz(在“图表”中,我们提供了本书动画的完整源代码链接)。
本书中使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序清单,以及在段萂中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应直接输入的命令或其他文本。
常量宽度斜体
显示应由用户提供值或由上下文确定的值替换的文本。
此元素表示提示或建议。
此元素表示一般注释。
此元素表示警告或注意事项。
有一些相关的在线资源可帮助您享受本书。
本书中的所有图表都以数字形式在书的网站上提供。这对于动画图表特别有用,因为书中非 Safari 格式只显示了少量帧(漫画风格):
在线索引:http://www.streamingbook.net/figures
特定的图表可以在以下形式的 URL 中引用:
http://www.streamingbook.net/fig/
例如,对于图 2-5:http://www.streamingbook.net/fig/2-5
动画图表本身是 LaTeX/Tikz 绘制的,首先渲染为 PDF,然后通过 ImageMagick 转换为动画 GIF。对于你们中的更有冒险精神的人,本书、“流媒体 101”和“流媒体 102”博客文章以及原始数据流模型论文的动画的完整源代码和渲染说明都可以在 GitHub 上找到,链接为http://github.com/takidau/animations。请注意,这大约有 14,000 行 LaTeX/Tikz 代码,它们是非常有机地生长出来的,没有意图被其他人阅读和使用。换句话说,这是一个混乱、纠缠不清的古老咒语网络;现在就回头吧,或者放弃所有希望吧,因为这里有龙。
尽管这本书在很大程度上是概念性的,但在整个过程中使用了许多代码和伪代码片段来帮助说明观点。来自第二章和第四章更功能核心 Beam 模型概念的代码,以及第七章更命令式状态和定时器概念的代码,都可以在http://github.com/takidau/streamingbook上找到。由于理解语义是主要目标,代码主要以 Beam PTransform
/DoFn
实现和相应的单元测试提供。还有一个独立的管道实现,用来说明单元测试和真实管道之间的差异。代码布局如下:
src/main/java/net/streamingbook/BeamModel.java
将 Beam PTransform
实现从第 2-1 到第 2-9 和第 4-3 的示例,每个示例都有一个额外的方法,在这些章节的示例数据集上执行时返回预期的输出。
src/test/java/net/streamingbook/BeamModelTest.java
通过生成的数据集验证BeamModel.java中示例PTransforms
的单元测试与书中的匹配。
src/main/java/net/streamingbook/Example2_1.java
可以在本地运行或使用分布式 Beam 运行程序的 Example 2-1 管道的独立版本。
src/main/java/net/streamingbook/inputs.csv
Example2_1.java的示例输入文件,其中包含了书中的数据集。
src/main/java/net/streamingbook/StateAndTimers.java
使用 Beam 的状态和定时器原语实现的第七章转换归属示例的 Beam 代码。
src/test/java/net/streamingbook/StateAndTimersTest.java
通过StateAndTimers.java验证转换归属DoFn
的单元测试。
src/main/java/net/streamingbook/ValidityWindows.java
时间有效窗口实现。
src/main/java/net/streamingbook/Utils.java
共享的实用方法。
这本书是为了帮助你完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大部分代码,否则您无需联系我们以获得许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。出售或分发 O’Reilly 图书示例的 CD-ROM 需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感激,但不需要归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Streaming Systems by Tyler Akidau, Slava Chernyak, and Reuven Lax (O’Reilly). Copyright 2018 O’Reilly Media, Inc., 978-1-491-98387-4.”
如果您觉得您对代码示例的使用超出了公平使用范围或上述给出的许可,请随时通过[email protected]与我们联系。
最后,但肯定不是最不重要的:许多人都很棒,我们想在这里特别感谢其中的一部分人,因为他们在创建这本书时提供了帮助。
这本书中的内容汇集了 Google、行业和学术界的无数聪明人的工作。我们要向他们表示真诚的感激之情,并为我们无法在这里列出所有人而感到遗憾,即使我们尝试过,我们也不会这样做。
在 Google 的同事中,DataPLS 团队(以及其各种祖先团队:Flume、MillWheel、MapReduce 等)的每个人多年来都帮助实现了这么多想法,因此我们要非常感谢他们。特别是,我们要感谢:
Paul Nordstrom 和黄金时代的 MillWheel 团队的其他成员:Alex Amato、Alex Balikov、Kaya Bekiroğlu、Josh Haberman、Tim Hollingsworth、Ilya Maykov、Sam McVeety、Daniel Mills 和 Sam Whittle,因为他们构想并构建了一套全面、强大和可扩展的低级原语,我们后来能够在此基础上构建本书中讨论的高级模型。如果没有他们的愿景和技能,大规模流处理的世界将会大不相同。
Craig Chambers、Frances Perry、Robert Bradshaw、Ashish Raniwala 和昔日 Flume 团队的其他成员,因为他们构想并创建了富有表现力和强大的数据处理基础,后来我们能够将其与流媒体世界统一起来。
Sam McVeety 因为他是最初的 MillWheel 论文的主要作者,这让我们了不起的小项目第一次出现在人们的视野中。
Grzegorz Czajkowski 多次支持我们的传教工作,即使竞争的最后期限和优先事项也在逼近。
更广泛地看,Apache Beam、Calcite、Kafka、Flink、Spark 和 Storm 社区的每个人都应该得到很多的赞扬。在过去的十年里,这些项目每一个都在推动流处理技术的发展。谢谢。
为了更具体地表达感激之情,我们还要感谢:
马丁·克莱普曼,领导倡导流和表思维的努力,并且在这本书的每一章的草稿中提供了大量有见地的技术和编辑意见。所有这些都是在成为一个灵感和全面优秀的人的同时完成的。
朱利安·海德,因为他对流 SQL 的深刻远见和对流处理的热情。
杰伊·克雷普斯,为了与 Lambda 架构暴政作斗争而不懈努力;正是你最初的“质疑 Lambda 架构”帖子让泰勒兴奋地加入了这场战斗。
斯蒂芬·伊文,科斯塔斯·佐马斯,法比安·休斯克,阿尔约夏·克雷特克,罗伯特·梅茨格,科斯塔斯·克劳达斯,杰米·格里尔,马克斯·米切尔斯和数据工匠的整个家族,过去和现在,总是以一种一贯开放和合作的方式推动流处理的可能性。由于你们所有人,流媒体的世界变得更美好。
杰西·安德森,感谢他的认真审查和所有的拥抱。如果你见到杰西,替我给他一个大大的拥抱。
丹尼·袁,西德·阿南德,韦斯·雷斯和令人惊叹的 QCon 开发者大会,让我们有机会在行业内公开讨论我们的工作,2014 年在 QCon 旧金山。
奥莱利的本·洛里卡和标志性的 Strata 数据大会,一直支持我们的努力,无论是在线、印刷还是亲自传播流处理的理念。
整个 Apache Beam 社区,特别是我们的同行,帮助推动 Beam 的愿景:阿赫梅特·阿尔塔伊,阿米特·塞拉,阿维姆·祖尔,本·张伯斯,格里斯尔达·奎瓦斯,查米卡拉·贾亚拉斯,达沃尔·博纳奇,丹·哈尔佩林,艾蒂安·肖肖,弗朗西斯·佩里,伊斯梅尔·梅希亚,杰森·卡斯特,让-巴蒂斯特·奥诺弗雷,杰西·安德森,尤金·基尔皮科夫,乔什·威尔斯,肯尼斯·诺尔斯,卢克·克维克,李静松,曼努·张,梅丽莎·帕什尼亚克,徐明敏,马克斯·米切尔斯,巴勃罗·埃斯特拉达,裴赫,罗伯特·布拉德肖,斯蒂芬·伊文,斯塔斯·莱文,托马斯·格罗,托马斯·韦斯和詹姆斯·徐。
没有致谢部分会完整无缺,没有对那些不知疲倦的审阅者的致谢,他们的深刻评论帮助我们将垃圾变成了精彩:杰西·安德森,格热戈日·查约夫斯基,马里安·德沃尔斯基,斯蒂芬·伊文,拉斐尔·J·费尔南德斯-莫克特苏马,马丁·克莱普曼,肯尼斯·诺尔斯,山姆·麦克维蒂,莫沙·帕苏曼斯基,弗朗西斯·佩里,杰莲娜·皮耶西瓦克-格博维奇,杰夫·舒特和威廉·万贝内普。你们是我们的德洛雷安时光机的弗尼斯先生。这在我脑海中听起来更好——看,这就是我所说的。
当然,还要感谢我们的作者和制作支持团队:
玛丽·博戈,我们最初的编辑,为了帮助和支持我们启动这个项目,并对我持续颠覆编辑规范的坚持耐心。我们想念你!
杰夫·布莱尔,我们的编辑 2.0,接管了这个庞大的项目,并对我们甚至不能满足最低限度的截止日期的无能耐心。我们成功了!
鲍勃·拉塞尔,我们的副本编辑,比任何人都更仔细地阅读了我们的书。我向你致敬,你对语法、标点、词汇和 Adobe Acrobat 批注的精湛掌握。
尼克·亚当斯,我们勇敢的制作编辑,帮助将一团混乱的 HTMLBook 代码整理成一本值得印刷的美丽之物,当我要求他手动忽略鲍勃提出的许多建议时,他并没有生气,这些建议是要将我们对“数据”一词的使用从复数改为单数。你让这本书看起来比我希望的还要好,谢谢你。
埃伦·特劳特曼-扎格,我们的索引制作人,以某种方式将一堆随意的参考文献编织成一个有用而全面的索引。我对你的细节关注感到敬畏。
我们的插图师 Rebecca Panzer,美化我们的静态图表,并向 Nick 保证我不需要再花更多周末来想办法重构我的动画 LaTeX 图表以获得更大的字体。呼~2 次!
我们的校对者 Kim Cofer 指出我们的懒散和不一致,这样其他人就不必这样做了。
Tyler 想要感谢:
我的合著者 Reuven Lax 和 Slava Chernyak,以他们的想法和章节的方式将它们变得生动起来,这是我无法做到的。
George Bradford Emerson II,为肖恩·康纳利的灵感。这是我在书中最喜欢的笑话,而我们甚至还没有到第一章。从这里开始,一切都是下坡路。
Rob Schlender,为他即将在机器人接管世界之前给我买的惊人的威士忌。为了以优雅的方式离开!
我的叔叔 Randy Bowen,确保我发现了我有多么喜欢计算机,特别是那张自制的 POV-Ray 2.x 软盘,为我打开了一个全新的世界。
我的父母 David 和 Marty Dauwalder,没有他们的奉献和难以置信的毅力,这一切都不可能。你们是最好的父母,真的!
Dr. David L. Vlasuk,没有他我今天就不会在这里。感谢一切,V 博士。
我的美好家庭,Shaina,Romi 和 Ione Akidau,他们在完成这项艰巨的工作中给予了坚定的支持,尽管我们因此分开度过了许多个夜晚和周末。我永远爱你们。
我的忠实写作伙伴 Kiyoshi:尽管我们一起写书的整个时间里你只是睡觉和对邮递员吠叫,但你做得无可挑剔,似乎毫不费力。你是你的物种的光荣。
Slava 想要感谢:
Josh Haberman,Sam Whittle 和 Daniel Mills 因为是 MillWheel 和随后的 Streaming Dataflow 中水印的共同设计者和共同创造者,以及这些系统的许多其他部分。这样复杂的系统从来不是在真空中设计的,如果不是你们每个人投入的所有思想和辛勤工作,我们今天就不会在这里。
data Artisans 的 Stephan Ewen,帮助我塑造了我对 Apache Flink 中水印实现的思想和理解。
Reuven 想要感谢:
Paul Nordstrom 因他的远见,Sam Whittle,Sam McVeety,Slava Chernyak,Josh Haberman,Daniel Mills,Kaya Bekiroğlu,Alex Balikov,Tim Hollingsworth,Alex Amato 和 Ilya Maykov 因他们在构建原始 MillWheel 系统和撰写随后的论文中所做的努力。
data Artisans 的 Stephan Ewen 在审阅关于一次性语义的章节和对 Apache Flink 内部工作的宝贵反馈中的帮助。
最后,我们都想感谢你,光荣的读者,愿意花真钱买这本书来听我们唠叨我们可以构建和玩耍的酷东西。写下这一切是一种快乐,我们已经尽力确保你物有所值。如果出于某种原因你不喜欢它…希望你至少买了印刷版,这样你至少可以在愤怒中把它扔到房间的另一边,然后在二手书店卖掉。小心猫。³
¹ 顺便说一句,这正是我们要求我们的动物书封面的样子,但 O’Reilly 觉得它在线插图中不会很好表现。我尊重地不同意,但一条棕色的鳟鱼是一个公平的妥协。
² 或者 DataPLS,发音为 Datapals——明白了吗?
³ 或者不要。我实际上不喜欢猫。
流数据处理在大数据领域是一件大事,而且有很多好的原因;其中包括以下几点:
企业渴望对其数据获得更及时的洞察,转向流处理是实现更低延迟的好方法。
在现代商业中越来越普遍的大规模、无限的数据集,更容易通过设计用于这种不断增长的数据量的系统来驯服。
随着数据到达时进行处理,可以更均匀地分配工作负载,从而产生更一致和可预测的资源消耗。
尽管业务驱动的对流处理的兴趣激增,但与批处理系统相比,流处理系统长期以来仍然相对不够成熟。直到最近,潮水才明确地向另一个方向转变。在我更为自负的时刻,我希望这在某种程度上是由于我最初在我的“流处理 101”和“流处理 102”博客文章中提出的坚定的激励(这本书的前几章显然是基于这些文章)。但实际上,行业对流处理系统成熟的兴趣很大,有很多聪明而积极的人喜欢构建这些系统。
尽管我认为一般流处理的倡导战已经取得了有效的胜利,但我仍然会基本上原封不动地提出我在“流处理 101”中的原始论点。首先,即使行业的大部分已经开始听从这个呼声,这些论点今天仍然非常适用。其次,还有很多人还没有得到这个消息;这本书是我努力传达这些观点的延续尝试。
首先,我介绍一些重要的背景信息,这将有助于构建我想讨论的其他主题。我在三个具体的部分中做了这件事:
术语
要准确地讨论复杂的主题,需要对术语进行准确的定义。对于一些当前使用中具有多重解释的术语,我将尽量明确我使用它们时的确切含义。
能力
我谈到了人们对流处理系统常常认为存在的缺点。我还提出了我认为数据处理系统构建者需要采取的心态,以满足现代数据消费者的需求。
时间领域
我介绍了数据处理中相关的两个主要时间领域,展示它们的关系,并指出这两个领域所带来的一些困难。
在继续之前,我想先搞清楚一件事:什么是流处理?今天,流处理这个术语被用来表示各种不同的东西(为了简单起见,我到目前为止一直在使用它有些宽泛),这可能会导致对流处理的真正含义或流处理系统实际能够做什么产生误解。因此,我更愿意对这个术语进行比较精确的定义。
问题的关键在于,许多本应该被描述为“它们是什么”(无限数据处理、近似结果等)的事物,却已经在口头上被描述为它们历史上是如何完成的(即通过流处理执行引擎)。术语的不精确使得流处理的真正含义变得模糊,并且在某些情况下,给流处理系统本身带来了这样的暗示,即它们的能力仅限于历史上被描述为“流处理”的特征,比如近似或推测性结果。
鉴于设计良好的流处理系统在技术上与任何现有的批处理引擎一样能够产生正确、一致、可重复的结果,我更倾向于将术语“流处理”限定为非常具体的含义:
流处理系统
一种设计时考虑到无限数据集的数据处理引擎。¹
如果我想谈论低延迟、近似或推测性结果,我会使用这些具体的词,而不是不准确地称它们为“流处理”。
在讨论可能遇到的不同类型的数据时,精确的术语也是有用的。在我看来,有两个重要(且正交的)维度来定义给定数据集的形状:基数和构成。
数据集的基数决定了其大小,基数最显著的方面是给定数据集是有限的还是无限的。以下是我喜欢用来描述数据集中粗略基数的两个术语:
有界数据
有限大小的数据集类型。
无界数据
无限大小的数据集类型(至少在理论上是这样)。
基数很重要,因为无限数据集的无界性对消耗它们的数据处理框架施加了额外的负担。在下一节中会详细介绍这一点。
另一方面,数据集的构成决定了其物理表现形式。因此,构成定义了人们可以与所讨论的数据进行交互的方式。我们直到第六章才会深入研究构成,但为了让你对事情有一个简要的了解,有两种主要的构成很重要:
表
在特定时间点上对数据集的整体视图。SQL 系统传统上处理表。
流²
逐个元素地查看数据集随时间的演变。MapReduce 数据处理系统传统上处理流。
我们在第 6、8 和 9 章深入探讨了流和表之间的关系,在第八章中,我们还了解了将它们联系在一起的统一基本概念时变关系。但在那之前,我们主要处理流,因为这是大多数数据处理系统(批处理和流处理)中开发人员直接交互的内容。它也是最自然地体现了流处理所特有的挑战的内容。
在这一点上,让我们接下来谈一谈流处理系统能做什么和不能做什么,重点是能做什么。我在本章最想传达的一件重要的事情是,一个设计良好的流处理系统有多么强大。流处理系统历来被局限在为提供低延迟、不准确或推测性结果的一些小众市场上,通常与更有能力的批处理系统一起提供最终正确的结果;换句话说,Lambda 架构。
对于那些对 Lambda 架构不太熟悉的人,基本思想是你同时运行一个流处理系统和一个批处理系统,两者基本上执行相同的计算。流处理系统提供低延迟、不准确的结果(要么是因为使用了近似算法,要么是因为流处理系统本身没有提供正确性),然后一段时间后,批处理系统提供正确的输出。最初由 Twitter 的 Nathan Marz(Storm的创建者)提出,它最终非常成功,因为事实上这是一个很棒的想法;流处理引擎在正确性方面有点令人失望,而批处理引擎像你期望的那样本质上难以处理,所以 Lambda 让你可以同时拥有你的谚语蛋糕并吃掉它。不幸的是,维护 Lambda 系统很麻烦:你需要构建、提供和维护两个独立版本的管道,然后还要以某种方式合并两个管道的结果。
作为一个花了多年时间在一个强一致性的流式引擎上工作的人,我也觉得 Lambda 架构的整个原则有点不可取。毫不奇怪,当 Jay Kreps 的“质疑 Lambda 架构”一文出来时,我是一个巨大的粉丝。这是对双模式执行的必要性的一个最早的高度可见的声明。令人愉快。Kreps 在使用可重放系统(如 Kafka)作为流式互连的情况下,解决了可重复性的问题,并且甚至提出了 Kappa 架构,基本上意味着使用一个为手头的工作量量身定制的系统来运行一个单一的流水线。我并不确定这个概念需要自己的希腊字母名称,但我完全支持这个原则。
坦率地说,我会更进一步。我会认为,设计良好的流式系统实际上提供了批处理功能的严格超集。除了效率差异之外,今天的批处理系统应该没有存在的必要。对于Apache Flink的人来说,他们将这个想法内化并构建了一个在底层始终是全流式的系统,即使在“批处理”模式下也是如此;我喜欢这一点。
所有这一切的推论是,流式系统的广泛成熟,加上对无界数据处理的健壮框架,最终将允许 Lambda 架构被归类到大数据历史的古董中。我相信现在是时候让这成为现实了。因为要做到这一点,也就是说,要在批处理的游戏中击败批处理,你真的只需要两件事:
正确性
这让你与批处理保持一致。在核心上,正确性归结为一致的存储。流式系统需要一种方法来随着时间对持久状态进行检查点(Kreps 在他的“为什么本地状态是流处理中的基本原语”一文中谈到了这一点),并且必须设计得足够好,以便在机器故障的情况下保持一致。几年前,当 Spark Streaming 首次出现在公共大数据领域时,它是一个一致性的信标,而其他流式系统则是黑暗的。幸运的是,事情自那时以来已经有了显著改善,但令人惊讶的是,仍然有很多流式系统试图在没有强一致性的情况下运行。
再重申一遍——因为这一点很重要:强一致性对于精确一次处理是必需的,这对于正确性是必需的,而这又是任何系统的要求,这个系统要有机会满足或超过批处理系统的能力。除非你真的不在乎你的结果,我恳求你抵制任何不提供强一致状态的流式系统。批处理系统不要求你提前验证它们是否能够产生正确的答案;不要浪费时间在那些无法达到同样标准的流式系统上。
如果你想了解如何在流式系统中获得强一致性,我建议你查看MillWheel、Spark Streaming和Flink snapshotting的论文。这三篇论文都花了大量时间讨论一致性。Reuven 将在第五章深入探讨一致性保证,如果你仍然渴望更多,文献和其他地方都有大量关于这个主题的高质量信息。
关于时间推理的工具
这使您超越了批处理。对于处理无界、无序数据的良好工具对于处理具有不同事件时间偏差的现代数据集至关重要。越来越多的现代数据集表现出这些特征,现有的批处理系统(以及许多流处理系统)缺乏应对它们带来的困难的必要工具(尽管我写这篇文章时情况正在迅速改变)。我们将在本书的大部分内容中解释和关注这一点的各个方面。
首先,我们要对时间域的重要概念有基本的理解,然后深入研究我所说的无界、无序数据的不同事件时间偏差。然后,我们将在本章的其余部分中,使用批处理和流处理系统,看一下有界和无界数据处理的常见方法。
要明晰地讨论无界数据处理,需要对涉及的时间域有清晰的理解。在任何数据处理系统中,通常有两个我们关心的时间域:
事件时间
这是事件实际发生的时间。
处理时间
这是在系统中观察事件的时间。
并非所有的用例都关心事件时间(如果你的用例不关心,太好了!你的生活会更轻松),但许多用例确实关心。例如,对用户行为进行时间特征化、大多数计费应用程序以及许多类型的异常检测等。
在理想的世界中,事件时间和处理时间总是相等的,事件发生时立即进行处理。然而,现实并不那么友好,事件时间和处理时间之间的偏差不仅不为零,而且通常是底层输入源、执行引擎和硬件特征的高度可变函数。影响偏差水平的因素包括以下内容:
共享资源限制,如网络拥塞、网络分区或非专用环境中的共享 CPU
软件原因,如分布式系统逻辑、争用等
数据本身的特征,如键分布、吞吐量的方差或无序性的方差(即,整个飞机上的人们在整个飞行中离线使用手机后将其从飞行模式中取出)
因此,如果在任何现实世界的系统中绘制事件时间和处理时间的进展,通常会得到类似图 1-1 中红线的结果。
在图 1-1 中,具有斜率为 1 的黑色虚线代表理想状态,其中处理时间和事件时间完全相等;红线代表现实情况。在这个例子中,系统在处理时间开始时稍微滞后,向理想状态靠近,然后在结束时再次稍微滞后。乍一看,这个图表中有两种不同时间域中的偏差:
处理时间
理想状态和红线之间的垂直距离是处理时间域中的滞后。这个距离告诉您在事件发生时和它们被处理时之间观察到的延迟(在处理时间上)。这可能是两种偏差中更自然和直观的一种。
事件时间
理想状态和红线之间的水平距离是管道中事件时间偏差的量。它告诉您管道当前在事件时间上距离理想状态有多远。
实际上,在任何给定时间点上,处理时间滞后和事件时间偏差是相同的;它们只是观察同一事物的两种方式。关于滞后/偏差的重要要点是:因为事件时间和处理时间之间的整体映射不是静态的(即,滞后/偏差可以随时间任意变化),这意味着如果你关心它们的事件时间(即事件实际发生的时间),你不能仅仅在管道观察它们时分析你的数据。不幸的是,这是历史上许多为无限数据设计的系统的运行方式。为了应对无限数据集的特性,这些系统通常提供了一些关于窗口化传入数据的概念。我们稍后会深入讨论窗口化,但它基本上意味着沿着时间边界将数据集切分成有限的部分。如果你关心正确性并且有兴趣在它们的事件时间上分析你的数据,你不能使用处理时间来定义这些时间边界(即处理时间窗口化),因为许多系统这样做;由于处理时间和事件时间之间没有一致的关联,你的一些事件时间数据将会出现在错误的处理时间窗口中(由于分布式系统的固有滞后,许多类型的输入源的在线/离线性质等),这将使正确性不复存在。我们将在接下来的几个部分以及本书的其余部分中更详细地讨论这个问题。
不幸的是,按事件时间进行窗口化也并非一帆风顺。在无界数据的情况下,混乱和可变的偏差为事件时间窗口带来了完整性问题:缺乏处理时间和事件时间之间的可预测映射,你如何确定你何时观察到了给定事件时间X的所有数据?对于许多真实世界的数据源来说,你根本无法确定。但是今天大多数使用的数据处理系统都依赖于某种完整性的概念,这使它们在应用于无界数据集时处于严重劣势。
我建议,我们不应该试图将无限的数据整理成最终变得完整的有限批次的信息,而是应该设计一些工具,让我们能够生活在这些复杂数据集所施加的不确定性世界中。新数据会到达,旧数据可能会被撤回或更新,我们构建的任何系统都应该能够自行应对这些事实,完整性的概念应该是特定和适当用例的便利优化,而不是所有用例的语义必要性。
在深入讨论这种方法可能是什么样子之前,让我们先完成一个有用的背景知识:常见的数据处理模式。
在这一点上,我们已经建立了足够的背景知识,可以开始看一下今天有界和无界数据处理中常见的核心使用模式。我们将在两种处理类型和相关的情况下看一下我们关心的两种主要引擎(批处理和流处理,在这个上下文中,我基本上将微批处理与流处理归为一类,因为在这个层面上两者之间的差异并不是非常重要)。
处理有界数据在概念上非常简单,可能对每个人都很熟悉。在图 1-2 中,我们从左侧开始,有一个充满熵的数据集。我们将其通过一些数据处理引擎(通常是批处理,尽管一个设计良好的流处理引擎也可以很好地工作),比如MapReduce,最终在右侧得到一个具有更大内在价值的新结构化数据集。
虽然在这种方案中实际上可以计算出无限多种变化,但总体模型非常简单。更有趣的是处理无界数据集的任务。现在让我们来看看通常处理无界数据的各种方式,从传统批处理引擎使用的方法开始,然后再看看您可以使用设计用于无界数据的系统(如大多数流式或微批处理引擎)采取的方法。
尽管批处理引擎并非专门为无界数据设计,但自批处理系统首次构思以来,就一直被用于处理无界数据集。正如您所期望的那样,这些方法围绕将无界数据切分为适合批处理的有界数据集的集合。
使用批处理引擎的重复运行来处理无界数据集的最常见方式是将输入数据分割成固定大小的窗口,然后将每个窗口作为单独的有界数据源进行处理(有时也称为滚动窗口),如图 1-3 所示。特别是对于像日志这样的输入源,事件可以被写入目录和文件层次结构,其名称编码了它们对应的窗口,这种方法乍看起来似乎非常简单,因为您已经在适当的事件时间窗口中进行了基于时间的洗牌以提前获取数据。
然而,实际上,大多数系统仍然存在完整性问题需要解决(如果您的一些事件由于网络分区而延迟到达日志,该怎么办?如果您的事件是全球收集的,并且必须在处理之前转移到一个共同的位置,该怎么办?如果您的事件来自移动设备?),这意味着可能需要某种形式的缓解(例如,延迟处理直到确保所有事件都已收集,或者在数据迟到时重新处理给定窗口的整个批次)。
当尝试使用批处理引擎处理无界数据以实现更复杂的窗口策略(如会话)时,这种方法会变得更加复杂。会话通常被定义为活动期间(例如特定用户的活动)之后的不活动间隔。当使用典型的批处理引擎计算会话时,通常会出现会话跨批次分割的情况,如图 1-4 中的红色标记所示。我们可以通过增加批处理大小来减少分割的次数,但这会增加延迟。另一种选择是添加额外的逻辑来从之前的运行中拼接会话,但这会增加复杂性。
无论如何,使用经典批处理引擎计算会话都不是理想的方式。更好的方式是以流式方式构建会话,我们稍后会详细介绍。
与大多数基于批处理的无界数据处理方法的临时性相反,流处理系统是为无界数据而构建的。正如我们之前讨论的,对于许多真实世界的分布式输入源,你不仅需要处理无界数据,还需要处理以下类型的数据:
与事件时间相关的无序性很高,这意味着如果你想在发生事件的上下文中分析数据,你的管道中需要一些基于时间的洗牌。
事件时间偏移不同,这意味着你不能假设你总是会在某个常数时间Y内看到给定事件时间X的大部分数据。
处理具有这些特征的数据时,你可以采取几种方法。我通常将这些方法归为四类:时间不敏感、近似、按处理时间窗口分组、按事件时间窗口分组。
现在让我们花一点时间来看看这些方法。
时间不敏感的处理用于时间基本无关的情况;也就是说,所有相关逻辑都是数据驱动的。因为这类用例的一切都由更多数据的到达来决定,所以流处理引擎实际上没有什么特别之处需要支持,除了基本的数据传递。因此,实际上所有现有的流处理系统都可以直接支持时间不敏感的用例(当然,如果你关心正确性,系统之间的一致性保证可能会有所不同)。批处理系统也非常适合对无界数据源进行时间不敏感的处理,只需将无界数据源切割成一系列有界数据集并独立处理这些数据集。本节中我们将看一些具体的例子,但考虑到处理时间不敏感的简单性(至少从时间的角度来看),我们不会在此之外花费太多时间。
时间不敏感处理的一个非常基本的形式是过滤,一个例子如图 1-5 所示。想象一下,你正在处理网站流量日志,并且想要过滤掉所有不是来自特定域的流量。当每条记录到达时,你会查看它是否属于感兴趣的域,并丢弃不属于的记录。因为这种处理方式只依赖于任何时间的单个元素,数据源是无界的、无序的,并且事件时间偏移不同是无关紧要的。
另一个时间不敏感的例子是内连接,如图 1-6 所示。当连接两个无界数据源时,如果你只关心当来自两个源的元素到达时连接的结果,那么逻辑上就没有时间元素。在看到一个源的值后,你可以简单地将其缓存到持久状态中;只有在另一个源的第二个值到达后,你才需要发出连接的记录。(事实上,你可能希望对未发出的部分连接进行某种垃圾回收策略,这可能是基于时间的。但对于几乎没有未完成连接的用例来说,这可能不是一个问题。)
将语义切换到某种外连接会引入我们之前讨论过的数据完整性问题:在看到连接的一侧之后,你怎么知道另一侧是否会到达或不会到达?说实话,你不知道,所以你需要引入某种超时的概念,这就引入了时间的元素。这个时间元素本质上是一种窗口,我们稍后会更仔细地看一下。
第二大类方法是近似算法,比如近似 Top-N,流式 k 均值等。它们接受无限的输入源,并提供输出数据,如果你仔细看,它们看起来或多或少像你希望得到的结果,如图 1-7 所示。近似算法的优势在于,它们设计上开销低,适用于无限数据。缺点是它们的种类有限,算法本身通常很复杂(这使得很难想出新的算法),而且它们的近似性质限制了它们的实用性。
值得注意的是,这些算法通常在设计上都有一定的时间元素(例如,一些内置的衰减)。由于它们处理元素的方式是按照到达的顺序进行的,所以时间元素通常是基于处理时间的。这对于那些在近似中提供一定的可证明误差界限的算法尤为重要。如果这些误差界限是基于数据按顺序到达的,那么当你向算法提供无序数据和不同的事件时间偏移时,它们基本上就毫无意义了。这是需要记住的一点。
近似算法本身是一个迷人的课题,但由于它们本质上是时间不可知的处理的另一个例子(除了算法本身的时间特征),它们非常容易使用,因此在我们目前的重点下,不值得进一步关注。
处理无限数据的剩下两种方法都是窗口的变体。在深入讨论它们之间的区别之前,我应该明确窗口的确切含义,因为我们在上一节中只是简单提到了它。窗口简单地意味着将数据源(无论是无限的还是有限的)沿着时间边界切割成有限的块进行处理。图 1-8 显示了三种不同的窗口模式。
让我们更仔细地看看每种策略:
固定窗口(又称滚动窗口)
我们之前讨论过固定窗口。固定窗口将时间划分为具有固定时间长度的段。通常(如图 1-9 所示),固定窗口的段均匀应用于整个数据集,这是对齐窗口的一个例子。在某些情况下,希望为数据的不同子集(例如,按键)相位移窗口,以更均匀地分散窗口完成负载,这反而是不对齐窗口的一个例子,因为它们在数据上变化。⁶
滑动窗口(又称跳跃窗口)
滑动窗口是固定长度和固定周期定义的。如果周期小于长度,窗口会重叠。如果周期等于长度,你就有了固定窗口。如果周期大于长度,你就有了一种奇怪的采样窗口,它只在时间上查看数据的子集。与固定窗口一样,滑动窗口通常是对齐的,尽管在某些用例中,它们可以是不对齐的性能优化。请注意,图 1-8 中的滑动窗口是按照它们的方式绘制的,以给出滑动运动的感觉;实际上,所有五个窗口都会应用于整个数据集。
会话
动态窗口的一个例子,会话由一系列事件组成,这些事件以大于某个超时的不活动间隙结束。会话通常用于分析用户随时间的行为,通过将一系列时间相关的事件(例如,一系列视频在一次观看中观看)分组在一起。会话很有趣,因为它们的长度不能事先定义;它们取决于实际涉及的数据。它们也是不对齐窗口的典型例子,因为会话在不同数据子集中几乎从不相同(例如,不同用户)。
我们之前讨论过的两个时间领域(处理时间和事件时间)基本上是我们关心的两个领域。分窗在这两个领域都是有意义的,所以让我们详细看看每个领域,并看看它们有何不同。因为按处理时间分窗在历史上更常见,我们将从那里开始。
按处理时间分窗时,系统基本上会将传入的数据缓冲到窗口中,直到经过一定的处理时间。例如,在五分钟的固定窗口的情况下,系统会缓冲五分钟的处理时间的数据,之后将把在这五分钟内观察到的所有数据视为一个窗口,并将它们发送到下游进行处理。
按处理时间分窗有一些不错的特性:
这很简单。实现非常简单,因为你永远不用担心在时间内对数据进行洗牌。当窗口关闭时,你只需按照它们到达的顺序缓冲数据并将它们发送到下游。
判断窗口的完整性是直截了当的。因为系统完全知道窗口的所有输入是否都已被看到,它可以对是否给定窗口完整做出完美的决定。这意味着在按处理时间分窗时,无需以任何方式处理“延迟”数据。
如果您想推断关于源在观察到的时刻的信息,按处理时间分窗正是您想要的。许多监控场景属于这一类。想象一下跟踪发送到全球规模网络服务的每秒请求的数量。计算这些请求的速率以便检测故障是按处理时间分窗的完美用途。
好处是一回事,但按处理时间分窗有一个非常大的缺点:*如果所讨论的数据与事件时间相关联,那么如果处理时间窗口要反映这些事件实际发生的时间,这些数据必须按事件时间顺序到达。*不幸的是,在许多真实世界的分布式输入源中,按事件时间排序的数据并不常见。
举个简单的例子,想象一下任何收集使用统计信息以供以后处理的移动应用程序。对于给定移动设备在任何时间段内离线的情况(短暂的连接丢失,飞越国家时的飞行模式等),在该期间记录的数据直到设备再次联机才会上传。这意味着数据可能会出现几分钟、几小时、几天、几周甚至更长的事件时间偏移。当按处理时间分窗时,基本上不可能从这样的数据集中得出任何有用的推断。
例如,许多分布式输入源在整个系统健康时可能看起来提供了按事件时间排序(或非常接近)的数据。不幸的是,当输入源在健康状态下事件时间偏移较低时,并不意味着它会一直保持在这种状态。考虑一个全球服务,处理在多个大陆上收集的数据。如果跨大陆线路上的网络问题(可悲的是,这种情况出奇地常见)进一步降低带宽和/或增加延迟,突然之间,部分输入数据的偏移可能比以前大得多。如果您按处理时间对这些数据进行窗口处理,那么您的窗口将不再代表实际发生在其中的数据;相反,它们代表事件到达处理管道时的时间窗口,这是一些旧数据和当前数据的任意混合。
在这两种情况下,我们真正想要的是根据事件时间对数据进行窗口处理,以便能够抵御事件到达顺序的影响。我们真正想要的是事件时间窗口。
当您需要以反映事件实际发生时间的有限块观察数据源时,事件时间窗口是您使用的窗口处理方式。这是窗口处理的黄金标准。在 2016 年之前,大多数使用的数据处理系统都缺乏对其的本地支持(尽管具有良好一致性模型的任何系统,如 Hadoop 或 Spark Streaming 1.x,都可以作为构建此类窗口处理系统的合理基础)。我很高兴地说,今天的世界看起来非常不同,从 Flink 到 Spark 再到 Storm 和 Apex,多个系统都原生支持某种形式的事件时间窗口处理。
图 1-10 显示了将无界数据源窗口化为一小时固定窗口的示例。
图 1-10 中的黑色箭头指出了两个特别有趣的数据片段。每个片段都到达了与其所属的事件时间窗口不匹配的处理时间窗口。因此,如果这些数据被按处理时间窗口化,用于关注事件时间的用例的计算结果将是不正确的。正如您所期望的那样,事件时间的正确性是使用事件时间窗口的一个好处。
事件时间窗口在无界数据源上的另一个好处是,您可以创建动态大小的窗口,例如会话,而无需在固定窗口上生成会话时观察到的任意拆分(如我们在“无界数据:流式处理”中看到的会话示例中所示),如图 1-11 所示。
当然,强大的语义很少是免费的,事件时间窗口也不例外。事件时间窗口由于窗口通常必须比窗口本身的实际长度(在处理时间上)存在更长的时间,因此具有两个显着的缺点:
缓冲
由于延长的窗口生命周期,需要更多的数据缓冲。幸运的是,持久存储通常是大多数数据处理系统所依赖的资源类型中最便宜的(其他资源主要是 CPU、网络带宽和 RAM)。因此,这个问题通常比你想象的要少得多,当使用任何设计良好的数据处理系统与强一致的持久状态和一个良好的内存缓存层时。此外,许多有用的聚合不需要整个输入集被缓冲(例如,求和或平均值),而是可以以增量方式执行,将一个更小的中间聚合存储在持久状态中。
完整性
鉴于我们经常没有好的方法来知道我们是否已经看到了给定窗口的所有数据,那么我们如何知道窗口的结果何时准备好实现?事实上,我们根本不知道。对于许多类型的输入,系统可以通过类似于 MillWheel、Cloud Dataflow 和 Flink 中的水印这样的东西给出一个相当准确的启发式估计窗口完成的时间(我们将在第三章和第四章中更多地讨论)。但对于绝对正确性至关重要的情况(再次思考计费),唯一的选择是为流水线构建者提供一种表达他们希望何时实现窗口结果以及如何随时间改进这些结果的方式。处理窗口的完整性(或缺乏完整性)是一个迷人的话题,但也许最好在具体例子的背景下进行探讨,这是我们接下来要看的内容。
哇!这是大量的信息。如果你已经走到这一步,你应该受到表扬!但我们只是刚刚开始。在继续深入研究 Beam 模型方法之前,让我们简要地回顾一下我们到目前为止学到的东西。在本章中,我们已经做了以下工作:
澄清了术语,将“流处理”的定义重点放在了指建立在无界数据基础上的系统上,同时使用更具描述性的术语来区分通常被归类为“流处理”的不同概念,例如近似/推测性结果。此外,我们还强调了大规模数据集的两个重要维度:基数(有界与无界)和编码(表与流),后者将占据本书下半部分的大部分内容。
评估了设计良好的批处理和流处理系统的相对能力,假设流处理实际上是批处理的严格超集,并且像 Lambda 架构这样的概念,这些概念是基于流处理比批处理差的,注定会在流处理系统成熟时被淘汰。
提出了流式系统赶上并最终超越批处理所需的两个高级概念,分别是正确性和关于时间推理的工具。
确定了事件时间和处理时间之间的重要差异,描述了这些差异在分析数据时所带来的困难,并提出了一种从完整性概念转向简单地适应数据随时间变化的方法。
审视了今天常见的有界和无界数据的主要数据处理方法,通过批处理和流处理引擎,粗略地将无界方法分类为:时间不可知、近似、按处理时间分窗和按事件时间分窗。
接下来,我们将深入了解 Beam 模型的细节,概念上看看我们如何在四个相关的轴上分解了数据处理的概念:什么、在哪里、何时和如何。我们还将详细研究在多种场景下处理一个简单的具体示例数据集,突出了 Beam 模型所支持的多种用例,同时提供一些具体的 API 来使我们更接地气。这些示例将有助于加深本章介绍的事件时间和处理时间的概念,同时还将探索水印等新概念。
¹ 为了完整起见,也许值得指出,这个定义包括真正的流式处理以及微批量实现。对于那些不熟悉微批量系统的人来说,它们是使用重复执行批处理引擎来处理无界数据的流式系统。Spark Streaming 是行业中的典型例子。
² 熟悉我原始文章的读者可能会记得,我曾强烈鼓励放弃在引用数据集时使用术语“流”。这从未流行起来,我最初认为是因为它的朗朗上口和广泛的使用。然而,回想起来,我认为我错了。实际上,在区分两种不同类型的数据集构成:表和流方面有很大的价值。事实上,本书的大部分后半部分都致力于理解这两者之间的关系。
³ 如果你不熟悉我所说的“仅一次”,它指的是某些数据处理框架提供的特定类型的一致性保证。一致性保证通常分为三个主要类别:最多一次处理、至少一次处理和仅一次处理。请注意,这里使用的名称是指在管道生成的输出中观察到的有效语义,而不是管道可能处理(或尝试处理)任何给定记录的实际次数。因此,有时会使用“有效一次”这个术语来代替“仅一次”,因为它更能代表事物的基本性质。Reuven 在第五章中更详细地介绍了这些概念。
⁴ 自从《流式处理 101》最初出版以来,许多人指出对我来说,在 x 轴上放置处理时间,y 轴上放置事件时间可能更直观。我同意,交换这两个轴最初会感觉更自然,因为事件时间似乎是处理时间的因变量。然而,由于这两个变量都是单调的并且密切相关,它们实际上是相互依存的变量。所以我认为从技术角度来看,你只需要选择一个轴并坚持下去。数学很令人困惑(特别是在北美以外的地方,它突然变成复数并且对你进行围攻)。
⁵ 这个结果实际上不应该令人惊讶(但对我来说是),因为我们实际上是在测量两种偏差/滞后时创建了一个直角三角形。数学很酷。
⁶ 我们将在第二章详细讨论对齐的固定窗口,以及在第四章讨论未对齐的固定窗口。
⁷ 如果你在学术文献或基于 SQL 的流处理系统中仔细研究,你还会遇到第三种窗口时间域:基于元组的窗口(即,其大小以元素数量计算的窗口)。然而,基于元组的窗口实质上是一种处理时间窗口,其中元素在到达系统时被分配单调递增的时间戳。因此,我们不会进一步详细讨论基于元组的窗口。
好了,派对的人们,是时候变得具体了!
第一章主要关注三个主要领域:术语,准确定义我在使用“流式处理”等术语时的含义;批处理与流处理,比较两种类型系统的理论能力,并假设将流处理系统提升到与批处理系统相同水平只需要两样东西:正确性和关于时间推理的工具;以及数据处理模式,研究在处理有界和无界数据时批处理和流处理系统采取的概念方法。
在本章中,我们现在将进一步关注第一章中的数据处理模式,但会更详细地结合具体示例进行讨论。到最后,我们将涵盖我认为是鲁棒的乱序数据处理所需的核心原则和概念;这些是真正让你超越经典批处理的关于时间推理的工具。
为了让你对实际情况有所了解,我使用了Apache Beam代码片段,结合时间流逝图表¹,以提供概念的可视化表示。Apache Beam 是用于批处理和流处理的统一编程模型和可移植性层,具有各种语言的具体 SDK(例如 Java 和 Python)。使用 Apache Beam 编写的管道可以在任何受支持的执行引擎上进行可移植运行(例如 Apache Apex,Apache Flink,Apache Spark,Cloud Dataflow 等)。
我在这里使用 Apache Beam 作为示例,不是因为这是一本 Beam 的书(不是),而是因为它最完全地体现了本书中描述的概念。回顾“流式处理 102”最初写作时(当时它仍然是来自 Google Cloud Dataflow 的 Dataflow 模型,而不是来自 Apache Beam 的 Beam 模型),它实际上是唯一存在的系统,提供了所有我们将在这里涵盖的示例所需的表达能力。一年半后,我很高兴地说,很多事情已经改变,大多数主要系统都已经或正在朝着支持与本书描述的模型非常相似的模型迈进。因此,请放心,我们在这里涵盖的概念,虽然是通过 Beam 的视角得出的,但同样适用于你将遇到的大多数其他系统。
为了帮助铺设本章的基础,我想先阐明将支撑其中所有讨论的五个主要概念,而且,对于第一部分的大部分内容来说,这些概念也是至关重要的。我们已经涵盖了其中的两个。
在第一章中,我首先建立了事件时间(事件发生的时间)和处理时间(在处理过程中观察到的时间)之间的关键区别。这为本书提出的一个主要论点奠定了基础:如果你关心正确性和事件实际发生的上下文,你必须分析数据相对于它们固有的事件时间,而不是它们在分析过程中遇到的处理时间。
然后我介绍了窗口化的概念(即,沿着时间边界对数据集进行分区),这是一种常用的方法,用来应对无界数据源在技术上可能永远不会结束的事实。一些更简单的窗口化策略示例是固定和滑动窗口,但更复杂的窗口化类型,比如会话(其中窗口由数据本身的特征定义;例如,捕获用户活动的会话,然后是一段不活动的间隙)也被广泛使用。
除了这两个概念之外,我们现在要仔细研究另外三个:
触发器
触发器是一种声明窗口输出何时相对于某些外部信号实现的机制。触发器在选择何时发出输出方面提供了灵活性。在某种意义上,你可以将它们看作是用于指示何时实现结果的流控制机制。另一种看法是,触发器就像相机的快门释放,允许你声明何时在计算的结果中拍摄时间快照。
触发器还使得可以观察窗口输出随着时间的演变而多次发生。这反过来打开了随着时间推移改进结果的大门,这允许在数据到达时提供推测结果,以及处理上游数据(修订)随时间变化或者延迟到达的数据(例如,移动场景,其中某人的手机在离线时记录各种操作和事件时间,然后在恢复连接后上传这些事件进行处理)。
水印
水印是相对于事件时间的输入完整性概念。具有时间X值的水印表示:“所有事件时间小于X的输入数据都已被观察到。”因此,当观察没有已知结束的无界数据源时,水印充当进度的度量。我们在本章中简要介绍了水印的基础知识,然后 Slava 在第三章中深入探讨了这个主题。
累积
累积模式指定了对于同一窗口观察到的多个结果之间的关系。这些结果可能是完全不相交的;即,代表随时间独立的增量,或者它们之间可能存在重叠。不同的累积模式具有不同的语义和相关成本,因此在各种用例中找到适用性。
此外,因为我认为这样做可以更容易地理解所有这些概念之间的关系,我们重新审视了旧的并在回答四个问题的结构中探索了新的,我提出这四个问题对于每个无界数据处理问题都至关重要:
计算什么结果?这个问题的答案取决于管道中的转换类型。这包括计算总和、构建直方图、训练机器学习模型等。这本质上也是经典批处理所回答的问题
在事件时间中,结果在何处计算?这个问题的答案取决于管道中的事件时间窗口化。这包括第一章中的常见示例(固定、滑动和会话);似乎没有窗口化概念的用例(例如,无时间概念的处理;经典的批处理通常也属于这一类);以及其他更复杂的窗口化类型,例如有时间限制的拍卖。还要注意,如果你将记录的进入时间分配为系统到达时的事件时间,它也可以包括处理时间窗口化。
在处理时间中,结果何时实现?这个问题的答案取决于触发器和(可选)水印的使用。在这个主题上有无限的变化,但最常见的模式是涉及重复更新(即,实现视图语义)、利用水印在相应输入被认为是完整后为每个窗口提供单一输出(即,经典的批处理语义应用于每个窗口),或者两者的某种组合。
结果的改进如何相关?这个问题的答案取决于所使用的累积类型:丢弃(其中结果都是独立和不同的)、累积(其中后续结果建立在先前结果的基础上)、或者累积和撤销(其中发出累积值以及先前触发的值的撤销)。
我们将在本书的其余部分更详细地讨论这些问题。是的,我将尽量清楚地表明什么/在哪里/何时/如何这种习语中的哪些概念与哪些问题相关,以此来运用这种颜色方案。不客气 。²
好的,让我们开始吧。首先停下来:批处理。
在经典批处理中应用的转换回答了问题:“计算出了什么结果?”即使您可能已经熟悉经典批处理,我们仍然要从那里开始,因为它是我们添加所有其他概念的基础。
在本章的其余部分(实际上,在本书的大部分内容中),我们将看一个单一的示例:计算一个简单数据集上的键控整数求和,该数据集由九个值组成。假设我们编写了一个基于团队的手机游戏,并且我们想要构建一个管道,通过对用户手机报告的个人得分进行求和来计算团队得分。如果我们将我们的九个示例得分捕获在名为“UserScores”的 SQL 表中,它可能看起来像这样:
*> SELECT * FROM UserScores ORDER BY EventTime;*
------------------------------------------------
| Name | Team | Score | EventTime | ProcTime |
------------------------------------------------
| Julie | TeamX | 5 | 12:00:26 | 12:05:19 |
| Frank | TeamX | 9 | 12:01:26 | 12:08:19 |
| Ed | TeamX | 7 | 12:02:26 | 12:05:39 |
| Julie | TeamX | 8 | 12:03:06 | 12:07:06 |
| Amy | TeamX | 3 | 12:03:39 | 12:06:13 |
| Fred | TeamX | 4 | 12:04:19 | 12:06:39 |
| Naomi | TeamX | 3 | 12:06:39 | 12:07:19 |
| Becky | TeamX | 8 | 12:07:26 | 12:08:39 |
| Naomi | TeamX | 1 | 12:07:46 | 12:09:00 |
------------------------------------------------
请注意,此示例中的所有得分都来自同一团队的用户;这是为了保持示例简单,因为我们的后续图表中的维度数量有限。而且因为我们是按团队分组,所以我们实际上只关心最后三列:
得分
与此事件相关联的个人用户得分
事件时间
得分的事件时间;即,得分发生的时间
处理时间
得分的处理时间;即,管道观察到得分的时间
对于每个示例管道,我们将查看一个时间跨度图,突出显示数据随时间如何演变。这些图表以我们关心的两个时间维度绘制了我们的九个得分:事件时间在 x 轴上,处理时间在 y 轴上。图 2-1 说明了输入数据的静态图的样子。
随后的时间跨度图要么是动画(Safari),要么是一系列帧(打印和所有其他数字格式),让您可以看到数据随时间如何处理(在我们到达第一个时间跨度图之后不久,我们将更详细地讨论这一点)。
在每个示例之前,都有一小段 Apache Beam Java SDK 伪代码,以使管道的定义更加具体。这是伪代码,因为我有时会弯曲规则,以使示例更清晰,省略细节(比如具体 I/O 源的使用),或简化名称(Beam Java 2.x 和之前的触发器名称非常冗长;我使用更简单的名称以增加清晰度)。除了这些小事情之外,它是真实世界的 Beam 代码(本章中的所有示例的真实代码都可以在GitHub上找到)。
如果您已经熟悉类似 Spark 或 Flink 的东西,您应该相对容易理解 Beam 代码在做什么。但是,为了给您一个快速入门,Beam 中有两个基本原语:
PCollections
这些代表数据集(可能是庞大的数据集),可以在其上执行并行转换(因此名称开头的“P”)。
PTransforms
这些应用于PCollections
以创建新的PCollections
。PTransforms
可以执行逐元素转换,它们可以将多个元素分组/聚合在一起,或者它们可以是其他PTransforms
的复合组合,如图 2-2 所示。
对于我们的示例,我们通常假设我们从预加载的PCollection
(即由Teams
和Integers
组成的PCollection
,其中Teams
只是表示团队名称的Strings
,而Integers
是相应团队中任何个人的得分)开始(例如,从 I/O 源读取原始数据(例如,日志记录)并将其转换为PCollection
)。为了在第一个示例中更清晰,我包含了所有这些步骤的伪代码,但在后续示例中,我省略了 I/O 和解析。
因此,对于一个简单地从 I/O 源读取数据,解析团队/得分对,并计算得分的每个团队的管道,我们将会得到类似于示例 2-1 中所示的内容。
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals =
input.apply(Sum.integersPerKey());
键/值数据从 I/O 源读取,其中Team
(例如,球队名称的String
)作为键,Integer
(例如,个人团队成员得分)作为值。然后对每个键的值进行求和,以生成输出集合中的每个键的总和(例如,团队总得分)。
在接下来的所有示例中,在看到描述我们正在分析的管道的代码片段之后,我们将看一下一个时间跨度图,显示该管道在我们的具体数据集上针对单个键的执行情况。在真实的管道中,你可以想象类似的操作会在多台机器上并行进行,但为了我们的示例,保持简单会更清晰。
正如之前提到的,Safari 版本呈现完整的执行过程,就像一部动画电影,而打印和所有其他数字格式则使用一系列静态关键帧,以提供管道随时间的进展的感觉。在这两种情况下,我们还提供一个完全动画版本的网址www.streamingbook.net。
每个图表都在两个维度上绘制输入和输出:事件时间(x 轴)和处理时间(y 轴)。因此,由管道观察到的实时时间从底部到顶部逐渐推移,如在处理时间轴上上升的粗黑水平线所示。输入为圆圈,圆圈内的数字表示该特定记录的值。它们开始为浅灰色,并在管道观察到它们时变暗。
当管道观察值时,它会将这些值累积到其中间状态中,并最终将聚合结果实现为输出。状态和输出由矩形表示(状态为灰色,输出为蓝色),聚合值靠近顶部,矩形覆盖的区域表示事件时间和处理时间累积到结果中的部分。对于示例 2-1 中的管道,在经典的批处理引擎上执行时,它看起来会像图 2-3 中所示的样子。
因为这是一个批处理管道,它会累积状态,直到看到所有的输入(由顶部的虚线绿线表示),然后产生 48 的单个输出。在这个示例中,我们计算了所有事件时间的总和,因为我们还没有应用任何特定的窗口处理转换;因此,状态和输出的矩形覆盖了整个 x 轴。然而,如果我们想要处理无界数据源,经典的批处理就不够了;我们不能等待输入结束,因为它实际上永远不会结束。我们想要的概念之一是窗口处理,我们在第一章中介绍过。因此,在我们的第二个问题的背景下——“在事件时间中结果是在哪里计算的?”——我们现在将简要回顾一下窗口处理。
如第一章所讨论的,窗口化是沿着时间边界切分数据源的过程。常见的窗口化策略包括固定窗口、滑动窗口和会话窗口,如图 2-4 所示。
为了更好地了解窗口化在实践中的样子,让我们将整数求和管道窗口化为固定的两分钟窗口。使用 Beam,只需简单地添加一个Window.into
转换,如示例 2-2 中所示。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES)))
.apply(Sum.integersPerKey());
回想一下,Beam 提供了一个统一的模型,可以在批处理和流处理中同时工作,因为语义上批处理实际上只是流处理的一个子集。因此,让我们首先在批处理引擎上执行此管道;机制更加直接,而且在切换到流处理引擎时,可以直接进行对比。图 2-5 呈现了结果。
与以前一样,输入在状态中累积,直到完全消耗,然后产生输出。但是,在这种情况下,我们不是得到一个输出,而是得到四个输出:一个输出,分别对应四个相关的两分钟事件时间窗口。
到目前为止,我们已经重新讨论了我在第一章介绍的两个主要概念:事件时间和处理时间域之间的关系,以及窗口化。如果我们想进一步,我们需要开始添加本节开头提到的新概念:触发器、水印和累积。
我们刚刚观察了批处理引擎上的窗口化管道的执行。但是,理想情况下,我们希望结果的延迟更低,并且我们还希望原生地处理无界数据源。切换到流处理引擎是朝着正确方向迈出的一步,但是我们以前的策略等待输入完全被消耗才生成输出的做法不再可行。这时就需要触发器和水印。
触发器提供了对问题的答案:“When在处理时间中何时生成结果?”触发器声明在处理时间中窗口的输出应该发生的时间(尽管触发器本身可能基于在其他时间域中发生的事情做出这些决定,比如随着事件时间域中的水印进展,我们马上就会看到)。窗口的每个具体输出被称为窗口的窗格。
虽然可以想象出各种可能的触发语义,³但在概念上,通常只有两种通用的有用触发类型,实际应用几乎总是使用其中一种或两种的组合:
重复更新触发器
这些会定期为窗口生成更新的窗格,随着其内容的演变。这些更新可以随着每个新记录的到来而实现,也可以在一定的处理时间延迟后发生,比如每分钟一次。重复更新触发器的周期选择主要是在平衡延迟和成本方面的考量。
完整性触发器
这些在认为窗口的输入完全到达某个阈值后才为窗口生成一个窗格。这种类型的触发器最类似于我们在批处理中熟悉的:只有在输入完成后才提供结果。触发器方法的不同之处在于完整性的概念仅限于单个窗口的上下文范围,而不总是与整个输入的完整性绑定。
重复更新触发器是流式系统中最常见的触发器类型。它们易于实现和理解,并为特定类型的用例提供有用的语义:对材料化数据集的重复(并最终一致)更新,类似于数据库世界中材料化视图的语义。
完整性触发器并不经常遇到,但提供了更接近经典批处理世界的流语义。它们还提供了用于推理诸如缺失数据和延迟数据之类的工具,我们很快会讨论(并在下一章中)当我们探索驱动完整性触发器的基础原语:水印。
但首先,让我们从简单的开始,看看一些基本的重复更新触发器的实际操作。为了使触发器的概念更加具体,让我们继续向我们的示例管道添加最简单类型的触发器:随着每条新记录的触发,如例 2-3 所示。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AfterCount(1))));
.apply(Sum.integersPerKey());
如果我们在流式引擎上运行这个新的管道,结果会看起来像图 2-6 所示。
您可以看到我们现在为每个窗口获得多个输出(窗格):每个输入对应一次。当输出流被写入某种表格时,这种触发模式效果很好,您可以简单地轮询结果。无论何时查看表格,您都会看到给定窗口的最新值,并且这些值随着时间的推移会趋向于正确。
按记录触发的一个缺点是它非常啰嗦。在处理大规模数据时,像求和这样的聚合提供了一个很好的机会,可以减少流的基数而不丢失信息。这在您有高容量键的情况下尤为明显;例如,我们的例子中有很多活跃玩家的大型团队。想象一下一个大型多人游戏,玩家被分成两个派别,您想要按派别统计数据。可能不需要在给定派别的每个玩家的每条新输入记录后更新您的统计数据。相反,您可能会在一定的处理时间延迟后,比如每秒或每分钟,更新它们。使用处理时间延迟的一个好处是它对高容量键或窗口具有均衡效果:结果流最终会在基数方面更加均匀。
触发器中有两种不同的处理时间延迟方法:对齐延迟(其中延迟将处理时间划分为与键和窗口对齐的固定区域)和未对齐延迟(其中延迟相对于给定窗口内观察到的数据)。具有未对齐延迟的管道可能看起来像例 2-4,其结果如图 2-7 所示。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AlignedDelay(TWO_MINUTES)))
.apply(Sum.integersPerKey());
这种对齐延迟触发实际上就是您从像 Spark Streaming 这样的微批处理流系统中获得的。它的好处在于可预测性;您可以同时获得所有修改窗口的定期更新。这也是它的缺点:所有更新同时发生,这导致了经常需要更大的峰值预配来正确处理负载的工作负载。另一种选择是使用未对齐延迟。这在 Beam 中可能看起来像例 2-5。图 2-8 呈现了结果。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(UnalignedDelay(TWO_MINUTES))
.apply(Sum.integersPerKey());
将图 2-8 中的不对齐延迟与图 2-6 中的对齐延迟进行对比,很容易看出不对齐延迟如何在时间上更均匀地分布负载。对于任何给定窗口涉及的实际延迟在这两种情况下有所不同,有时更多,有时更少,但最终平均延迟基本上保持不变。从这个角度来看,不对齐延迟通常是大规模处理的更好选择,因为它会导致负载在时间上更均匀地分布。
重复更新触发器非常适用于我们只是希望定期更新结果并且可以接受这些更新朝着正确性收敛而没有明确指示何时达到正确性的用例。然而,正如我们在第一章中讨论的那样,分布式系统的种种变数经常导致事件发生的时间和管道实际观察到事件的时间之间存在不同程度的偏差,这意味着很难推断输出何时呈现出准确和完整的输入数据视图。对于输入完整性很重要的情况,有一种推理完整性的方式是很重要的,而不是盲目地相信计算结果,无论哪个数据子集恰好已经传递到管道中。这就是水印的作用。
水印是对问题“何时在处理时间中结果实现?”的支持方面。水印是事件时间域中输入完整性的时间概念。换句话说,它们是系统相对于正在处理的事件流中记录的事件时间的进度和完整性的方式(有界或无界的情况下它们的用处更加明显)。
回想一下第一章中的这个图表,在图 2-9 中稍作修改,我描述了事件时间和处理时间之间的偏差,对于大多数实际的分布式数据处理系统来说,这是一个随时间不断变化的函数。
我声称代表现实的那条蜿蜒的红线本质上就是水印;它捕捉了事件时间完整性随着处理时间的进展而变化。在概念上,您可以将水印视为一个函数,F(P) → E,它接受一个处理时间点并返回一个事件时间点。⁴ 事件时间点E是系统认为所有事件时间小于E的输入都已被观察到的点。换句话说,这是一个断言,即再也不会看到事件时间小于E的数据。根据水印的类型,完美或启发式,这个断言可以是严格的保证或是一个有根据的猜测。
完美水印
对于我们完全了解所有输入数据的情况,可以构建完美水印。在这种情况下,不存在延迟数据;所有数据都是提前或准时的。
启发式水印
对于许多分布式输入源,完全了解输入数据是不切实际的,因此提供启发式水印是下一个最佳选择。启发式水印使用有关输入的任何可用信息(分区、分区内的排序(如果有)、文件的增长率等)来提供尽可能准确的进度估计。在许多情况下,这些水印的预测可以非常准确。即便如此,使用启发式水印意味着它有时可能是错误的,这将导致延迟数据。我们很快会向您展示处理延迟数据的方法。
因为它们提供了相对于我们的输入的完整性概念,水印构成了先前提到的第二种触发器的基础:完整性触发器。水印本身是一个迷人而复杂的话题,当你看到 Slava 在第三章中深入研究水印时,你会发现这一点。但现在,让我们通过更新我们的示例管道来利用建立在水印之上的完整性触发器来看看它们的作用,就像在示例 2-6 中演示的那样。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
现在,水印的一个有趣的特性是它们是一类函数,这意味着有多个不同的函数F(P) → E满足水印的属性,成功程度各不相同。正如我之前所指出的,对于你对输入数据有完美的了解的情况,可能可以构建一个完美的水印,这是理想的情况。但对于你缺乏对输入的完美了解的情况,或者计算完美水印太昂贵的情况,你可能会选择使用启发式来定义你的水印。我想要在这里强调的是,所使用的水印算法与管道本身是独立的。我们不打算在这里详细讨论实现水印意味着什么(Slava 在第三章中会讲到)。现在,为了帮助强调这个观点,即给定的输入集可以应用不同的水印,让我们看一下我们在示例 2-6 中的管道在相同数据集上执行时使用两种不同的水印实现(图 2-10):左侧是完美水印;右侧是启发式水印。
在这两种情况下,当水印通过窗口的末端时,窗口会被实体化。正如你所期望的那样,完美的水印完美地捕捉了管道随着时间的推移而发生的事件完整性。相比之下,右侧启发式水印的具体算法未考虑值为 9,⁵这极大地改变了实体化输出的形状,无论是在输出延迟还是正确性方面(如 12:00、12:02 提供的错误答案为 5)。
水印触发器与我们在图 2-5 到 2-7 中看到的重复更新触发器的一个重大区别是水印给了我们一种推理输入完整性的方式。直到系统为给定的窗口实体化输出,我们知道系统还不相信输入是完整的。这对于那些想要推理输入中的数据缺失或缺失数据的用例尤为重要。
<资产/ stsy_0210.mp4>
在具有完美(左)和启发式(右)水印的流处理引擎上的窗口求和
缺失数据用例的一个很好的例子是外连接。如果没有像水印这样的完整性概念,你怎么知道何时放弃并发出部分连接,而不是继续等待该连接完成?你不知道。基于处理时间延迟做出决定的方式,这是缺乏真正水印支持的流处理系统的常见方法,这不是一个安全的方式,因为我们在第一章中讨论过的事件时间偏移的可变性:只要偏移保持小于所选的处理时间延迟,你的缺失数据结果将是正确的,但是一旦偏移超过了该延迟,它们将突然变得不正确。从这个角度来看,事件时间水印对于许多必须推理输入数据缺失的真实世界流处理用例(如外连接、异常检测等)是一个关键的拼图。现在,话虽如此,这些水印示例也突显了水印(以及任何其他完整性概念)的两个缺点,具体来说,它们可能是以下两种情况之一:太慢当任何类型的水印由于已知未处理的数据(例如,由于网络带宽限制而缓慢增长的输入日志)而被正确延迟时,如果水印的推进是你唯一依赖于刺激结果的因素,那么这直接转化为输出的延迟。这在图 2-10 的左侧图中最为明显,晚到的 9 会阻碍所有后续窗口的水印,即使这些窗口的输入数据较早就变得完整。对于第二个窗口,12:02, 12:04),从窗口中的第一个值出现到我们看到窗口的任何结果几乎需要七分钟。在这个示例中,启发式水印并没有遭受同样严重的问题(五分钟直到输出),但不要认为启发式水印永远不会遭受水印滞后的问题;这实际上只是我选择在这个特定示例中省略的记录的结果。这里的重要一点是:尽管水印提供了一个非常有用的完整性概念,但依赖完整性来产生输出通常从延迟的角度来看并不理想。想象一下一个包含有价值的指标的仪表板,按小时或天进行窗口化。你不太可能希望等到整整一个小时或一天才开始看到当前窗口的结果;这是使用经典批处理系统来支持这样的系统的痛点之一。相反,随着输入的演变和最终变得完整,看到这些窗口的结果随着时间的推移而不断完善会更好。太快当启发式水印比它应该提前推进时,事件时间早于水印的数据可能会在之后的某个时间到达,从而产生延迟数据。这就是右侧示例中发生的情况:水印在第一个窗口结束之前推进,而该窗口的所有输入数据尚未被观察到,导致输出值不正确,而不是 14。这个缺点严格来说是启发式水印的问题;它们的启发式本质意味着它们有时会出错。因此,如果你关心正确性,仅仅依赖它们来确定何时产生输出是不够的。在第一章中,我对完整性概念不足以满足大多数需要对无界数据流进行强大的乱序处理的用例做出了一些非常强调的陈述。这两个缺点——水印太慢或太快——是这些论点的基础。你简单地无法从完整性概念的系统中同时获得低延迟和正确性。因此,对于那些希望兼顾两全的情况,一个人该怎么办呢?如果重复更新触发器提供了低延迟更新但无法推理完整性,水印提供了完整性概念但变化和可能的高延迟,为什么不将它们的力量结合起来呢?
胜利!我们现在已经看过了两种主要类型的触发器:重复更新触发器和完整性/水印触发器。在许多情况下,它们单独都不足够,但它们的组合是。Beam 通过提供标准水印触发器的扩展来认识到这一事实,该扩展还支持水印两侧的重复更新触发。这被称为早期/准时/延迟触发器,因为它将由复合触发器实现的窗格分为三类:+ 零个或多个早期窗格,这是重复更新触发器的结果,它会定期触发,直到水印通过窗口的末尾。这些触发产生的窗格包含推测结果,但允许我们观察随着新的输入数据到达,窗口随时间的演变。这弥补了水印有时会太慢的缺点。
一个准时窗格,这是完整性/水印触发器在水印通过窗口的末尾后触发的结果。这种触发是特殊的,因为它提供了一个断言,即系统现在认为这个窗口的输入是完整的。这意味着现在可以推断缺失数据;例如,在执行外连接时发出部分连接。
零个或多个迟到窗格,这是另一个(可能不同的)重复更新触发器的结果,它会定期触发,任何迟到数据到达后,水印已经通过窗口的末尾。在完美的水印情况下,将始终没有迟到窗格。但在启发式水印的情况下,水印未能正确计算的任何数据都将导致迟到触发。这弥补了水印太快的缺点。
让我们看看这在实际中是什么样子。我们将更新我们的管道,使用周期性的处理时间触发器,早期触发的对齐延迟为一分钟,迟到触发的每条记录触发。这样,早期触发将为我们的高吞吐量窗口提供一定量的批处理(由于触发器每分钟只触发一次,不管窗口中的吞吐量如何),但我们不会为迟到触发引入不必要的延迟,如果我们使用一个相当准确的启发式水印,迟到触发应该是相当罕见的。在 Beam 中,这看起来像例 2-7(图 2-11 显示了结果)。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE)) .withLateFirings(AfterCount(1))))
.apply(Sum.integersPerKey());
在具有早期、准时和迟到触发的流引擎上的窗口求和
这个版本比图 2-9 有两个明显的改进:
对于第二个窗口(12:02, 12:04)中的“水印太慢”的情况:我们现在每分钟提供定期的早期更新。最大的差异在于完美的水印情况,首次输出时间从将近七分钟减少到三分半;但在启发式情况下,也明显改善了。现在两个版本都随时间稳定改进(窗格的值为 7、10,然后是 18),在输入变得完整和窗口的最终输出窗格实现之间的延迟相对较小。
对于第一个窗口(12:00, 12:02)中的“启发式水印太快”的情况:当值为 9 的数据迟到时,我们立即将其合并到一个新的、更正的窗格中,值为 14。这些新触发器的一个有趣的副作用是,它们有效地使完美和启发式水印版本之间的输出模式得到了规范化。
在图 2-10 中,两个版本截然不同,而在这里的两个版本看起来非常相似。它们看起来也更类似于图 2-6 到 2-8 中的各种重复更新版本,但有一个重要的区别:由于使用了水印触发器,我们还可以推断我们使用早期/准时/迟到触发器生成的结果的输入完整性。这使我们能够更好地处理关心缺失数据的用例,比如外连接,异常检测等。在这一点上,完美和启发式的早期/准时/延迟版本之间最大的区别是窗口生命周期的限制。在完美的水印情况下,我们知道在水印通过窗口结束之后我们不会再看到任何窗口的数据,因此我们可以在那时丢弃窗口的所有状态。在启发式水印情况下,我们仍然需要保留窗口的状态一段时间来处理延迟数据。但是到目前为止,我们的系统还没有一个好的方法来知道每个窗口需要保留状态的时间。这就是允许延迟的作用。## 何时:允许延迟(即,垃圾回收)在继续我们的最后一个问题(“结果的改进如何相关?”)之前,我想谈谈长期、乱序的流处理系统中的一个实际必要性:垃圾回收。在图 2-11 中的启发式水印示例中,每个窗口的持久状态在整个示例的生命周期内都会持续存在;这是必要的,以便我们在需要时能够适当地处理延迟数据。但是,虽然能够一直保留我们的持久状态直到永远是很好的,但实际上,在处理无界数据源时,通常不太可能无限期地保留给定窗口的状态(包括元数据);我们最终会耗尽磁盘空间(或者至少厌倦为其付费,因为随着时间的推移,旧数据的价值会降低)。因此,任何现实世界中的乱序处理系统都需要提供一种方式来限制它正在处理的窗口的生命周期。一个清晰而简洁的方法是在系统内定义允许延迟的地平线;也就是说,对于系统来说,设定任何给定记录相对于水印可以有多晚(相对于水印)才值得处理;超过这个地平线的任何数据都会被简单地丢弃。在你设定了个别数据可以有多晚之后,你也确立了窗口状态必须保留多久的时间:直到水印超过窗口结束时的延迟地平线。但另外,你也给了系统自由,让它在观察到后面的数据时立即丢弃超过地平线的任何数据,这意味着系统不会浪费资源处理没有人关心的数据。由于允许延迟和水印之间的相互作用有点微妙,值得看一个例子。让我们看一下示例 2-7/图 2-11 中的启发式水印流水线,并在示例 2-8 中添加一个一分钟的延迟地平线(请注意,这个特定的地平线之所以被选择,纯粹是因为它在图中很好地适应;对于实际用例,一个更大的地平线可能会更实用):
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AfterCount(1)))
.withAllowedLateness(ONE_MINUTE))
.apply(Sum.integersPerKey());
执行此流水线的过程看起来有点像图 2-12,我在其中添加了以下功能以突出允许延迟的影响:
表示处理时间中当前位置的粗黑线现在带有刻度,用于指示所有活动窗口的延迟地平线(以事件时间为单位)。
当水印通过窗口的延迟地平线时,该窗口关闭,这意味着窗口的所有状态都被丢弃。我留下一个虚线矩形,显示窗口关闭时它覆盖的时间范围(在两个域中),并在右侧延伸一小段以表示窗口的延迟地平线(与水印进行对比)。
仅对于此图,我为第一个值为 6 的窗口添加了一个额外的延迟数据。6 是延迟的,但仍在允许的延迟地平线内,因此被合并到值为 11 的更新结果中。然而,9 到达的时间超过了延迟地平线,因此被简单地丢弃。
关于延迟时间范围的两个最终注意事项:
要非常清楚,如果你恰好从具有完美水印的数据源中获取数据,就不需要处理延迟数据,允许的延迟时间为零秒将是最佳的。这就是我们在图 2-10 的完美水印部分看到的情况。
即使在使用启发式水印时,需要指定延迟时间范围的规则有一个值得注意的例外,那就是对于可管理的有限数量的键(例如,按网页浏览器系列对所有时间的全局聚合进行计算,例如,按网页浏览器系列对所有时间的总访问次数进行计算)。在这种情况下,系统中活动窗口的数量受到使用的有限键空间的限制。只要键的数量保持在可以管理的范围内,就不需要担心通过允许的延迟时间来限制窗口的生命周期。
实用性满足后,让我们继续我们的第四个和最后一个问题。
当触发器用于在一段时间内为单个窗口生成多个窗格时,我们发现自己面临最后一个问题:“结果的修正如何相关?”在我们迄今为止看到的例子中,每个连续的窗格都是建立在紧随其后的窗格之上的。然而,实际上有三种不同的累积模式:⁹
丢弃
每次窗格被实现,任何存储的状态都会被丢弃。这意味着每个连续的窗格都与之前的窗格无关。当下游消费者执行某种累积时,丢弃模式是有用的;例如,当将整数发送到一个期望接收将它们相加以产生最终计数的增量的系统时。
累积
与图 2-6 到 2-11 一样,每次窗格被实现,任何存储的状态都会被保留,并且未来的输入会累积到现有的状态中。这意味着每个连续的窗格都建立在以前的窗格之上。当后续结果可以简单地覆盖先前的结果时,累积模式是有用的,例如在将输出存储在 HBase 或 Bigtable 等键/值存储中时。
累积和撤消
这就像是累积模式,但是在生成新的窗格时,它还会为以前的窗格产生独立的撤消。撤消(与新的累积结果结合)本质上是一种明确地说“我之前告诉过你结果是X,但我错了。去掉我上次告诉你的X,用Y替换它。”的方式。撤消有两种情况特别有帮助:
当下游消费者通过不同的维度重新分组数据时,新值很可能会以与以前的值不同的键方式进行分组,因此最终会进入不同的组。在这种情况下,新值不能简单地覆盖旧值;相反,您需要撤消以删除旧值
当使用动态窗口(例如,我们稍后将更仔细地研究的会话)时,新值可能会替换多个以前的窗口,因为窗口合并。在这种情况下,仅从新窗口中确定替换了哪些旧窗口可能会很困难。为旧窗口提供明确的撤消使得这个任务变得简单。我们在第八章中详细看到了一个例子。
每个组的不同语义在并排看时会更清晰一些。考虑图 2-11 中第二个窗口(事件时间范围为 12:06, 12:08)的两个窗格。表 2-1 显示了在三种累积模式下(累积模式是图 2-11 本身使用的特定模式)每个窗格的值会是什么样子。
表 2-1。使用图 2-11 的第二个窗口比较累积模式
丢弃 | 累积 | 累积和撤回 | |
---|---|---|---|
窗格 1:输入=[3] | 3 | 3 | 3 |
窗格 2:输入=[8, 1] | 9 | 12 | 12, –3 |
最终正常窗格的值 | 9 | 12 | 12 |
所有窗格的总和 | 12 | 15 | 12 |
让我们仔细看看发生了什么:
丢弃
每个窗格只包含在该特定窗格期间到达的值。因此,观察到的最终值并不能完全捕捉到总和。然而,如果你将所有独立窗格的值相加,你会得到一个正确的答案 12。这就是为什么在下游消费者本身对实体窗格执行某种聚合时,丢弃模式是有用的。
累积
就像图 2-11 一样,每个窗格都包含在该特定窗格期间到达的值,以及之前窗格的所有值。因此,观察到的最终值正确地捕捉到了总和 12。然而,如果你将各个窗格本身相加,你实际上会重复计算窗格 1 的输入,得到一个不正确的总和 15。这就是为什么当你可以简单地用新值覆盖先前的值时,累积模式是最有用的:新值已经包含了迄今为止看到的所有数据。
累积和撤回
每个窗格都包括一个新的累积模式值以及前一个窗格值的撤回。因此,最后观察到的值(不包括撤回)以及所有实体窗格的总和(包括撤回)都会给出正确答案 12。这就是为什么撤回是如此强大。
示例 2-9 演示了丢弃模式的运行,说明了我们将对示例 2-7 进行的更改:
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(
AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AtCount(1)))
.discardingFiredPanes())
.apply(Sum.integersPerKey());
在具有启发式水印的流引擎上再次运行会产生类似于图 2-13 所示的输出。
尽管输出的整体形状与图 2-11 中的累积模式版本相似,但请注意,这个丢弃版本中没有任何窗格重叠。因此,每个输出都是独立的。
如果我们想看撤回的运行情况,更改将是类似的,如示例 2-10 所示。???描述了结果。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(
AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AtCount(1)))
.accumulatingAndRetractingFiredPanes())
.apply(Sum.integersPerKey());
因为每个窗口的窗格都重叠,清楚地看到撤回有点棘手。撤回用红色表示,与重叠的蓝色窗格结合在一起,产生了略带紫色的颜色。我还稍微水平移动了给定窗格内的两个输出的值(并用逗号分隔),以便更容易区分它们。
图 2-14 结合了图 2-9、2-11(仅启发式)和并排的最终帧,提供了三种模式的良好视觉对比。
正如你所想象的,按照呈现的顺序(丢弃、累积、累积和撤回)的模式在存储和计算成本方面是逐渐增加的。因此,累积模式的选择为在正确性、延迟和成本的轴上进行权衡提供了另一个维度。
通过本章的学习,您现在了解了强大的流处理的基础知识,并准备好进入世界,做出惊人的成就。当然,还有八章等着您去关注,所以希望您不要马上就出发,就在这一刻。但无论如何,让我们回顾一下我们刚刚涉及的主要概念:
事件时间与处理时间
事件发生的时间和数据处理系统观察到它们的时间之间的重要区别。
窗口
通过沿着时间边界(无论是处理时间还是事件时间,尽管在 Beam 模型中我们将窗口的定义缩小为仅在事件时间内)切分无界数据的常用方法。
触发器
用于准确指定何时对于特定用例来说输出的实现是有意义的声明性机制。
水印
事件时间中进展的强大概念,提供了一种推理完整性(因此缺失数据)的方式,用于处理无界数据的无序处理系统。
累积
对于单个窗口结果的改进与其多次实现时的情况之间的关系。
其次,我们用来构建我们探索的四个问题:
什么结果被计算?=转换。
在事件时间中计算结果的位置?=窗口。
在处理时间中结果何时实现?=触发器加水印。
如何结果的改进相关?=累积。
第三,为了强调这种流处理模型所提供的灵活性(因为归根结底,这才是关键:平衡正确性、延迟和成本等竞争张力),我们能够通过最少量的代码更改在相同数据集上实现的输出的主要变化的回顾:
| | 整数求和 例 2-1 / 图 2-3 | 整数求和 固定窗口批处理
每条记录重复触发
例 2-3 / 图 2-6 |
重复对齐延迟触发
重复不对齐延迟触发
启发式水印触发
例 2-6 / 图 2-10 |
早期/准时/延迟触发
丢弃
早期/准时/延迟触发
累积
早期/准时/延迟触发
累积和撤销
例 2-10 / ??? |
总之,到目前为止,我们只看了一种窗口方式:事件时间中的固定窗口。正如我们所知,窗口有许多维度,我想至少在我们结束 Beam 模型之前再触及另外两个维度。然而,首先,我们将稍微偏离一下,深入探讨水印的世界,因为这些知识将有助于构建未来的讨论(并且本身也很有趣)。斯拉瓦,右边的舞台进入…
¹ 如果您有幸阅读 Safari 版本的书,您将拥有完整的延时动画,就像“流处理 102”中一样。对于印刷版、Kindle 和其他电子书版本,有静态图像并附有指向网络上动画版本的链接。
² 请耐心等待。在 O’Reilly 出版物中,严禁使用复合标点(即表情符号)进行细粒度的情感表达<winky-smiley/>。
事实上,我们在 Beam 中的原始触发器功能中就是这样做的。回顾起来,我们有点过头了。未来的迭代将更简单、更易于使用,在本书中,我只关注那些可能以某种形式保留的部分。
更准确地说,函数的输入实际上是在观察到水印的管道中的那一点上游的一切的时间 P 的状态:输入源、缓冲数据、正在处理的数据等等;但在概念上,将其简单地视为从处理时间到事件时间的映射会更简单。
请注意,我特意选择省略启发式水印中值为 9 的价值,因为这将帮助我就延迟数据和水印滞后做出一些重要观点。实际上,启发式水印可能会选择省略其他一些值,这反过来可能会对水印产生显著较小的影响。如果筛选迟到的数据是你的目标(在某些情况下非常有效,比如滥用检测,你只想尽快看到大部分数据),你不一定想要启发式水印而不是完美水印。你真正想要的是百分位水印,它明确地从计算中删除一些百分位的迟到数据。参见第三章。
这并不是说没有主要关心正确性而不太关心延迟的用例;在这些情况下,使用准确的水印作为管道输出的唯一驱动是一个合理的方法。
正如我们之前所知,这种断言要么是有保证的,如果使用完美的水印,要么是一个有根据的猜测,如果使用启发式水印。
你可能会注意到,逻辑上应该有第四种模式:丢弃和撤销。在大多数情况下,这种模式并不是非常有用,所以我在这里不再讨论它。
回顾起来,也许选择一组更加面向物化流中数据观察性质的名称会更清晰一些(例如,“输出模式”),而不是描述产生这些数据的状态管理语义的名称。也许:丢弃模式 → 增量模式,累积模式 → 值模式,累积和撤销模式 → 值和撤销模式?然而,丢弃/累积/累积和撤销的名称已经成为 Beam 模型的 1.x 和 2.x 系列的一部分,所以我不想在书中引入潜在的混淆。此外,随着 Beam 3.0 和 sink triggers 的引入,累积模式很可能会更加淡化;关于这一点,我们将在第八章讨论 SQL 时详细介绍。
到目前为止,我们一直从管道作者或数据科学家的角度来看待流处理。第二章介绍了水印作为解决事件时间处理发生在何处和处理时间结果何时实现这些基本问题的一部分。在本章中,我们从流处理系统的基本机制的角度来看待相同的问题。观察这些机制将帮助我们激发、理解和应用水印的概念。我们讨论了水印是如何在数据进入点创建的,它们如何通过数据处理管道传播,以及它们如何影响输出时间戳。我们还演示了水印如何保留必要的保证,以回答事件时间数据在何处处理和何时实现这些问题,同时处理无界数据。
考虑任何摄取数据并持续输出结果的管道。我们希望解决一个一般性问题,即何时可以安全地认为事件时间窗口已关闭,即窗口不再期望任何更多数据。为此,我们希望描述管道相对于其无界输入所做的进展。
解决事件时间窗口问题的一个天真的方法是简单地基于当前处理时间来确定我们的事件时间窗口。正如我们在第一章中看到的,我们很快就会遇到麻烦——数据处理和传输并不是瞬时的,因此处理和事件时间几乎永远不会相等。我们的管道中的任何故障或突发事件都可能导致我们错误地将消息分配给窗口。最终,这种策略失败了,因为我们没有一种健壮的方法来对这样的窗口做出任何保证。
另一种直观但最终是错误的方法是考虑管道处理的消息速率。虽然这是一个有趣的度量标准,但速率可能会随着输入的变化、预期结果的可变性、可用于处理的资源等任意变化。更重要的是,速率无法帮助回答完整性的基本问题。具体来说,速率无法告诉我们何时已经看到了特定时间间隔内的所有消息。在现实世界的系统中,会出现消息在系统中无法取得进展的情况。这可能是由于瞬态错误(如崩溃、网络故障、机器停机)的结果,也可能是由于需要更改应用逻辑或其他手动干预来解决的持久性错误,例如应用级故障。当然,如果发生了大量故障,处理速率指标可能是检测这一情况的良好代理。但是速率指标永远无法告诉我们单个消息未能在我们的管道中取得进展。然而,即使是单个这样的消息,也可能会任意影响输出结果的正确性。
我们需要一个更健壮的进展度量。为了达到这个目标,我们对我们的流数据做出一个基本假设:每条消息都有一个关联的逻辑事件时间戳。在不断到达的无界数据的情况下,这个假设是合理的,因为这意味着输入数据的持续生成。在大多数情况下,我们可以将原始事件发生的时间作为其逻辑事件时间戳。有了包含事件时间戳的所有输入消息,我们可以检查任何管道中这些时间戳的分布。这样的管道可能分布在许多代理上并行处理,并且在单个分片之间没有排序的保证。因此,在这个管道中处于活动状态的正在传输的消息的事件时间戳集合将形成一个分布,如图 3-1 所示。
消息被管道摄取,处理,最终标记为已完成。每条消息要么是“在途”,意味着已接收但尚未完成,要么是“已完成”,意味着不需要为此消息再进行处理。如果我们按事件时间检查消息的分布,它看起来会像图 3-1。随着时间的推移,更多的消息将被添加到右侧的“在途”分布中,来自“在途”部分的更多消息将被完成并移动到“已完成”分布中。
在这个分布上有一个关键点,位于“在途”分布的最左边缘,对应于我们管道中任何未处理消息的最老事件时间戳。我们使用这个值来定义水印:
水印是最老的尚未完成工作的单调¹递增时间戳。
这个定义提供了两个基本属性,使其有用:
完整性
如果水印已经超过某个时间戳T,我们可以通过其单调性质保证,不会再对T时刻或之前的准时(非延迟数据)事件进行处理。因此,我们可以正确地发出T时刻或之前的任何聚合。换句话说,水印允许我们知道何时正确关闭一个窗口。
可见性
如果由于任何原因消息在我们的管道中卡住,水印就无法前进。此外,我们将能够通过检查阻止水印前进的消息来找到问题的源头。
这些水印是从哪里来的?要为数据源建立水印,我们必须为从该源进入管道的每条消息分配一个逻辑事件时间戳。正如第二章所告诉我们的那样,所有水印创建都属于两种广泛的类别之一:完美或启发式。为了提醒自己完美水印和启发式水印之间的区别,让我们看一下第二章中的窗口求和示例的图 3-2。
请注意,完美水印的区别特征在于,完美水印确保水印占据了所有数据,而启发式水印则允许一些延迟数据元素。
水印一旦被创建为完美或启发式,就会在管道的其余部分保持不变。至于是什么使水印创建完美或启发式,这在很大程度上取决于被消耗的源的性质。为了了解原因,让我们看一些每种类型水印创建的例子。
完美的水印创建为传入消息分配时间戳,以便生成的水印是一个严格的保证,即在水印之前的事件时间内不会再次看到来自该源的任何数据。使用完美水印创建的管道永远不必处理延迟数据;也就是说,在水印已经超过新到达消息的事件时间之后到达的数据。然而,完美水印创建需要对输入有完美的了解,因此对于许多真实世界的分布式输入源来说是不切实际的。以下是一些可以创建完美水印的用例示例:
入口时间戳
将入口时间分配为进入系统的数据的事件时间的源可以创建一个完美的水印。在这种情况下,源水印简单地跟踪管道观察到的当前处理时间。这实际上是几乎所有在 2016 年之前支持窗口化的流系统使用的方法。
因为事件时间是从单一的、单调递增的源(实际处理时间)分配的,因此系统对于数据流中下一个时间戳有着完美的了解。因此,事件时间的进展和窗口语义变得更容易推理。当然,缺点是水印与数据本身的事件时间没有关联;这些事件时间实际上被丢弃了,水印只是跟踪数据相对于其在系统中到达的进展。
静态的时间顺序日志集
静态大小的²输入源时间顺序日志(例如,具有静态分区集合的 Apache Kafka 主题,其中源的每个分区包含单调递增的事件时间)将是一个相对简单的源,可以在其上创建一个完美的水印。为此,源将简单地跟踪已知和静态源分区中未处理数据的最小事件时间(即,每个分区中最近读取记录的事件时间的最小值)。
类似于前述的入口时间戳,系统对于下一个时间戳有着完美的了解,这要归功于静态分区集合中的事件时间是单调递增的事实。这实际上是一种有界的乱序处理形式;在已知分区集合中的乱序量由这些分区中观察到的最小事件时间所限制。
通常情况下,你可以保证分区内的时间戳单调递增的唯一方法是在数据写入时为分区内的时间戳分配;例如,通过网络前端直接将事件记录到 Kafka 中。尽管仍然是一个有限的用例,但这绝对比在数据处理系统到达时进行入口时间戳更有用,因为水印跟踪了基础数据的有意义的事件时间。
启发式水印创建,另一方面,创建的水印仅仅是一个估计,即在水印之前的事件时间内不会再次看到任何数据。使用启发式水印创建的管道可能需要处理一定量的延迟数据。延迟数据是指在水印已经超过该数据的事件时间之后到达的任何数据。只有启发式水印创建才可能出现延迟数据。如果启发式水印是一个相当好的方法,延迟数据的数量可能会非常小,水印仍然可以作为一个完成估计。系统仍然需要提供一种方式让用户处理延迟数据,如果要支持需要正确性的用例(例如计费等)。
对于许多现实世界的分布式输入源来说,构建完美的水印在计算上或操作上是不切实际的,但通过利用输入数据源的结构特征,仍然可以构建一个非常准确的启发式水印。以下是两个例子,其中可以构建启发式水印(质量不同):
动态的时间排序日志集
考虑一个动态的结构化日志文件集(每个单独的文件包含记录,其事件时间相对于同一文件中的其他记录是单调递增的,但文件之间的事件时间没有固定的关系),在运行时并不知道预期日志文件的完整集(即 Kafka 术语中的分区)。这种输入通常出现在由多个独立团队构建和管理的全球规模服务中。在这种情况下,创建完美的输入水印是棘手的,但创建准确的启发式水印是完全可能的。
通过跟踪现有日志文件集中未处理数据的最小事件时间、监控增长速率,并利用网络拓扑和带宽可用性等外部信息,即使缺乏对所有输入的完美了解,也可以创建一个非常准确的水印。这种类型的输入源是 Google 发现的最常见的无界数据集之一,因此我们在为这种情况创建和分析水印质量方面有丰富的经验,并已看到它们在许多用例中发挥了良好的效果。
Google Cloud Pub/Sub
Cloud Pub/Sub 是一个有趣的用例。Pub/Sub 目前不保证按顺序传递;即使单个发布者按顺序发布两条消息,也有可能(通常很小的概率)会以无序的方式传递(这是由于底层架构的动态特性,允许在无需用户干预的情况下实现透明的扩展,以实现非常高的吞吐量)。因此,无法保证 Cloud Pub/Sub 的完美水印。然而,Cloud Dataflow 团队利用了有关 Cloud Pub/Sub 数据的可用知识,构建了一个相当准确的启发式水印。本章后面将详细讨论这种启发式的实现作为一个案例研究。
考虑一个例子,用户玩一个手机游戏,他们的分数被发送到我们的流水线进行处理:通常可以假设对于任何利用移动设备进行输入的源,提供完美水印基本上是不可能的。由于设备可能长时间离线,无法提供对这种数据源的绝对完整性的任何合理估计。然而,可以想象构建一个水印,准确跟踪当前在线设备的输入完整性,类似于刚才描述的 Google Pub/Sub 水印。从提供低延迟结果的角度来看,活跃在线的用户很可能是最相关的用户子集,因此这通常并不像你最初想的那样是一个缺点。
通过启发式水印创建,大体上来说,对于源的了解越多,启发式就越好,晚期数据项就会越少。鉴于不同类型的来源、事件分布和使用模式会有很大差异,因此并不存在一种适合所有情况的解决方案。但无论是完美的还是启发式的,一旦在输入源创建了水印,系统就可以完美地将水印传播到整个流水线。这意味着完美水印在下游仍然完美,而启发式水印将保持与建立时一样的启发式。这就是水印方法的好处:您可以将在流水线中跟踪完整性的复杂性完全减少到在源头创建水印的问题上。
我们可以在管道中的任何单个操作或阶段的边界上定义水印。这不仅有助于理解管道中每个阶段的相对进展,还有助于独立地尽快为每个单独的阶段分发及时结果。我们为阶段边界的水印给出以下定义:
水印是在输入源处创建的,如前一节所讨论的。然后,它们在系统中概念上随着数据的进展而流动。您可以以不同粒度跟踪水印。对于包含多个不同阶段的管道,每个阶段可能会跟踪其自己的水印,其值是所有输入和之前阶段的函数。因此,管道中后面的阶段将具有过去更久的水印(因为它们看到的整体输入更少)。
每个阶段内的处理也不是单一的。我们可以将一个阶段内的处理分成几个概念组件的流,每个组件都有助于输出水印。正如前面提到的,这些组件的确切性质取决于阶段执行的操作和系统的实现。在概念上,每个这样的组件都充当一个缓冲区,其中活动消息可以驻留,直到某些操作完成。例如,数据到达时,它会被缓冲以进行处理。处理可能会将数据写入状态以进行延迟聚合。延迟聚合在触发时可能会将结果写入输出缓冲区,等待下游阶段消费,如图 3-3 所示。
输入水印捕获了该阶段之前所有内容的进展(即该阶段的输入对于该阶段而言有多完整)。对于源,输入水印是一个特定于源的函数,用于创建输入数据的水印。对于非源阶段,输入水印被定义为其所有上游源和阶段的所有分片/分区/实例的输出水印的最小值。
输入和输出水印的定义提供了整个管道中水印的递归关系。管道中的每个后续阶段根据阶段的事件时间滞后来延迟水印。
为特定阶段定义输入和输出水印的一个好处是,我们可以使用这些来计算阶段引入的事件时间延迟量。将阶段的输出水印值减去其输入水印值,得到阶段引入的事件时间延迟或滞后量。这种滞后是每个阶段输出相对于实时的延迟程度的概念。例如,执行 10 秒窗口聚合的阶段将具有至少 10 秒的滞后,这意味着阶段的输出至少会比输入和实时延迟这么多。
输出水印捕获了阶段本身的进展,基本上定义为阶段的输入水印和阶段内所有非延迟数据活动消息的事件时间的最小值。 “活动”包括的确切内容在某种程度上取决于给定阶段实际执行的操作和流处理系统的实现。它通常包括为聚合而缓冲但尚未在下游实现的数据,正在传输到下游阶段的待处理输出数据等。
到目前为止,我们只考虑了单个操作或阶段上下文中输入的水印。然而,大多数现实世界的管道由多个阶段组成。了解水印如何在独立阶段之间传播对于理解它们如何影响整个管道以及其结果的观察延迟是重要的。
我们可以跟踪每个缓冲区及其自己的水印。每个阶段的缓冲区中的水印的最小值形成了该阶段的输出水印。因此,输出水印可以是以下内容的最小值:
每个发送阶段都有一个水印。
每个外部输入都有一个水印,用于管道外部的来源
每种类型的状态组件都有一个水印,可以写入
每个接收阶段都有一个输出缓冲区的水印
在这个粒度级别提供水印还可以更好地了解系统的行为。水印跟踪系统中各种缓冲区中消息的位置,从而更容易诊断卡住的情况。
为了更好地了解输入和输出水印之间的关系以及它们如何影响水印传播,让我们来看一个例子。让我们考虑游戏得分,但我们不是计算团队得分的总和,而是试图衡量用户参与水平。我们将首先根据每个用户的会话长度来计算,假设用户与游戏保持参与的时间是他们享受游戏程度的合理代理。在回答我们的四个问题一次以计算会话长度后,我们将再次回答这些问题,以计算固定时间段内的平均会话长度。
为了使我们的例子更有趣,假设我们正在使用两个数据集,一个用于移动得分,一个用于主机得分。我们希望通过整数求和并行计算这两个独立数据集的相同得分。一个管道正在计算使用移动设备玩游戏的用户的得分,而另一个管道是为在家庭游戏主机上玩游戏的用户计算得分,可能是因为为不同平台采用了不同的数据收集策略。重要的是,这两个阶段执行相同的操作,但是针对不同的数据,因此输出水印也会有很大的不同。
首先,让我们看一下示例 3-1,看看这个管道的第一部分的缩写代码可能是什么样子。
PCollection<Double> mobileSessions = IO.read(new MobileInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.discardingFiredPanes())
.apply(CalculateWindowLength());
PCollection<Double> consoleSessions = IO.read(new ConsoleInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.discardingFiredPanes())
.apply(CalculateWindowLength());
在这里,我们独立读取每个输入,而以前我们是按团队对我们的集合进行分组,但在这个例子中,我们按用户进行分组。之后,对于每个管道的第一个阶段,我们将窗口划分为会话,然后调用一个名为CalculateWindowLength
的自定义PTransform
。这个PTransform
简单地按键(即User
)进行分组,然后通过将当前窗口的大小视为该窗口的值来计算每个用户的会话长度。在这种情况下,我们对默认触发器(AtWatermark
)和累积模式(discardingFiredPanes
)设置没有问题,但出于完整性考虑,我已经明确列出了它们。两个特定用户的每个管道的输出可能看起来像图 3-4。
因为我们需要跟踪跨多个阶段的数据,我们在图中用红色跟踪与移动得分相关的所有内容,用蓝色跟踪与主机得分相关的所有内容,而图 3-5 中的水印和输出是黄色的。
我们已经回答了计算个人会话长度的“什么”、“哪里”、“何时”和“如何”的四个问题。接下来我们将再次回答这些问题,将这些会话长度转换为一天内固定时间窗口内的全局会话长度平均值。这要求我们首先将两个数据源展平为一个,然后重新分配到固定窗口;我们已经捕捉到了我们计算的会话长度值的重要本质,现在我们想要在一天内的一致时间窗口内计算这些会话的全局平均值。示例 3-2 展示了这个代码。
PCollection<Double> mobileSessions = IO.read(new MobileInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.discardingFiredPanes())
.apply(CalculateWindowLength());
PCollection<Double> consoleSessions = IO.read(new ConsoleInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.discardingFiredPanes())
.apply(CalculateWindowLength());
PCollection<Float> averageSessionLengths = PCollectionList
.of(mobileSessions).and(consoleSessions)
.apply(Flatten.pCollections())
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(AtWatermark())
.apply(Mean.globally());
如果我们看到这个管道在运行,它会看起来像图 3-5。与以前一样,两个输入管道正在计算移动和控制台玩家的个人会话长度。然后这些会话长度进入管道的第二阶段,在那里在固定窗口中计算全局会话长度平均值。
让我们仔细研究一些例子,因为这里有很多事情要做。这里的两个重要点是:
移动会话和控制台会话阶段的输出水印至少与每个对应的输入水印一样旧,实际上可能稍微更旧一些。这是因为在真实系统中,计算答案需要时间,我们不允许输出水印在给定输入的处理完成之前提前。
平均会话长度阶段的输入水印是直接上游两个阶段的输出水印的最小值。
结果是下游输入水印是上游输出水印的最小组合的别名。请注意,这与本章前面对这两种水印类型的定义相匹配。还要注意,下游的水印会更早一些,捕捉到上游阶段在时间上领先于其后续阶段的直观概念。
这里值得注意的一点是,我们在示例 3-1 中再次提出问题,从而大幅改变了管道的结果。以前我们只是计算每个用户的会话长度,现在我们计算两分钟的全局会话长度平均值。这提供了对玩家行为的更深入了解,并让你略微窥见简单数据转换和真正数据科学之间的差异。
更好的是,现在我们了解了这个管道运作的基本原理,我们可以更仔细地看待与再次提出四个问题相关的一个更微妙的问题:输出时间戳。
在图 3-5 中,我忽略了一些输出时间戳的细节。但是如果你仔细看图中的第二阶段,你会发现第一阶段的每个输出都被分配了一个与其窗口结束时间相匹配的时间戳。尽管这是一个相当自然的输出时间戳选择,但并不是唯一有效的选择。然而,在实践中,大多数情况下只有几种选择是有意义的:
窗口结束⁴
如果您希望输出时间戳代表窗口边界,那么使用窗口的结束是唯一安全的选择。正如我们将在一会儿看到的,这也是所有选项中水印进展最顺畅的选择。
第一个非延迟元素的时间戳
当您希望尽可能保守地保持水印时,使用第一个非延迟元素的时间戳是一个不错的选择。然而,折衷之处在于水印的进展可能会受到更大的阻碍,我们很快也会看到。
特定元素的时间戳
对于某些用例,某些其他任意(从系统角度看)元素的时间戳是正确的选择。想象一种情况,您正在将查询流与该查询结果的点击流进行连接。在执行连接后,某些系统会发现查询的时间戳更有用;其他人会更喜欢点击的时间戳。只要它对应于未延迟到达的元素,任何这样的时间戳都是从水印正确性的角度来看是有效的。
在考虑一些替代的输出时间戳选项后,让我们看看输出时间戳选择对整个流水线的影响。为了使变化尽可能显著,在示例 3-3 和图 3-6 中,我们将切换到使用窗口的最早时间戳:第一个非延迟元素的时间戳作为窗口的时间戳。
PCollection<Double> mobileSessions = IO.read(new MobileInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.withTimestampCombiner(EARLIEST)
.discardingFiredPanes())
.apply(CalculateWindowLength());
PCollection<Double> consoleSessions = IO.read(new ConsoleInputSource())
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(AtWatermark())
.withTimestampCombiner(EARLIEST)
.discardingFiredPanes())
.apply(CalculateWindowLength());
PCollection<Float> averageSessionLengths = PCollectionList
.of(mobileSessions).and(consoleSessions)
.apply(Flatten.pCollections())
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(AtWatermark())
.apply(Mean.globally());
为了突出输出时间戳选择的影响,请看第一阶段虚线显示的每个阶段输出水印被保持的情况。与图 3-7 和 3-8 相比,输出水印由于我们选择的时间戳而延迟,而在图 3-7 和 3-8 中,输出时间戳被选择为窗口的结束。从这个图表中可以看出,第二阶段的输入水印也因此被延迟。
与图 3-7 相比,这个版本的差异有两点值得注意:
水印延迟
与图 3-5 相比,图 3-6 中的水印进展要慢得多。这是因为第一阶段的输出水印被保持到每个窗口的第一个元素的时间戳,直到该窗口的输入变得完整为止。只有在给定窗口被实现后,输出水印(因此下游输入水印)才被允许前进。
语义差异
因为会话时间戳现在被分配为与会话中最早的非延迟元素相匹配,所以当我们在下一个阶段计算会话长度平均值时,个别会话通常会落入不同的固定窗口桶中。迄今为止,我们所看到的两种选择都没有固有的对错之分;它们只是不同而已。但重要的是要理解它们将是不同的,以及它们将以何种方式不同,这样当时机到来时,您就可以为您的特定用例做出正确的选择。
关于输出时间戳的另一个微妙但重要的问题是如何处理滑动窗口。将输出时间戳设置为最早元素的朴素方法很容易导致下游由于水印被(正确地)阻止而出现延迟。为了理解原因,考虑一个具有两个阶段的示例管道,每个阶段都使用相同类型的滑动窗口。假设每个元素最终出现在三个连续的窗口中。随着输入水印的推进,这种情况下滑动窗口的期望语义如下:
第一个窗口在第一个阶段完成并向下游发出。
然后第一个窗口在第二阶段完成并且也可以向下游发出。
一段时间后,第二个窗口在第一个阶段完成…等等。
然而,如果选择输出时间戳为窗格中第一个非延迟元素的时间戳,实际发生的是:
第一个窗口在第一个阶段完成并向下游发出。
第二阶段的第一个窗口仍然无法完成,因为其输入水印被上游第二和第三个窗口的输出水印阻止。这些水印被正确地阻止,因为最早的元素时间戳被用作这些窗口的输出时间戳。
第二个窗口在第一个阶段完成并向下游发出。
第二阶段的第一个和第二个窗口仍然无法完成,被上游的第三个窗口阻塞。
第三个窗口在第一个阶段完成并向下游发出。
第二阶段的第一个、第二个和第三个窗口现在都能够完成,最终一次性发出所有三个窗口。
尽管这种窗口的结果是正确的,但这导致结果以不必要的延迟方式实现。因此,Beam 对重叠窗口有特殊逻辑,确保窗口N+1 的输出时间戳始终大于窗口N的结束时间。
到目前为止,我们关注的是水印,即在一个阶段中活动消息的最小事件时间所测量的。跟踪最小值允许系统知道何时已经考虑了所有更早的时间戳。另一方面,我们可以考虑活动消息的事件时间的整个分布,并利用它来创建更精细的触发条件。
与其考虑分布的最小点,我们可以取分布的任何百分位,并说我们保证已处理了这个百分比的所有具有更早时间戳的事件。⁵
这种方案的优势是什么?如果对于业务逻辑来说,“大多数情况下”正确就足够了,百分位水印提供了一种机制,使水印可以比跟踪最小事件时间更快、更平滑地前进,通过从水印中丢弃分布长尾中的异常值。图 3-9 显示了一个紧凑的事件时间分布,其中 90%百分位水印接近于 100%百分位。图 3-10 展示了一个异常值落后的情况,因此 90%百分位水印明显领先于 100%百分位。通过从水印中丢弃异常值数据,百分位水印仍然可以跟踪分布的大部分,而不会被异常值延迟。
图 3-11 显示了使用百分位水印来绘制两分钟固定窗口的窗口边界的示例。我们可以根据已到达数据的时间戳的百分位来绘制早期边界,由百分位水印跟踪。
水印百分位数变化的影响。随着百分位数的增加,窗口中包含的事件也会增加:然而,实现窗口的处理时间延迟也会增加。
图 3-11 显示了 33%百分位数、66%百分位数和 100%百分位数(完整)水印,跟踪数据分布中相应的时间戳百分位数。如预期的那样,这些允许边界比跟踪完整的 100%百分位数水印更早地绘制。请注意,33%和 66%百分位数水印分别允许更早地触发窗口,但以标记更多数据为延迟为代价。例如,对于第一个窗口,12:00, 12:02),基于 33%百分位数水印关闭的窗口将只包括四个事件,并在 12:06 处理时间时实现结果。如果使用 66%百分位数水印,相同的事件时间窗口将包括七个事件,并在 12:07 处理时间时实现。使用 100%百分位数水印将包括所有十个事件,并延迟到 12:08 处理时间时才实现结果。因此,百分位数水印提供了一种调整结果实现的延迟和精度之间的权衡的方法。
到目前为止,我们一直在研究水印与流经我们系统的数据的关系。我们已经看到,观察水印如何帮助我们识别最旧数据和实时之间的总延迟。然而,这还不足以区分旧数据和延迟系统。换句话说,仅仅通过检查我们到目前为止定义的事件时间水印,我们无法区分一个快速处理一小时前数据而没有延迟的系统,和一个试图处理实时数据并在这样做时延迟了一个小时的系统。
为了做出这种区别,我们需要更多的东西:处理时间水印。我们已经看到流处理系统中有两个时间域:处理时间和事件时间。到目前为止,我们已经完全在事件时间域中定义了水印,作为系统中流动数据的时间戳的函数。这是一个事件时间水印。现在我们将应用相同的模型到处理时间域,以定义一个处理时间水印。
我们的流处理系统不断执行操作,例如在阶段之间传递消息、读取或写入持久状态的消息,或者根据水印进度触发延迟聚合。所有这些操作都是响应于当前或上游阶段的先前操作而执行的。因此,就像数据元素“流”经系统一样,处理这些元素所涉及的一系列操作也“流”经系统。
我们以与我们定义事件时间水印完全相同的方式定义处理时间水印,只是不是使用最早未完成的工作的事件时间戳,而是使用最早未完成的操作的处理时间戳。处理时间水印的延迟示例可能是从一个阶段到另一个阶段的消息传递卡住,读取状态或外部数据的 I/O 调用卡住,或者在处理过程中发生异常导致处理无法完成。
因此,处理时间水印提供了一个与数据延迟分开的处理延迟概念。为了理解这种区别的价值,考虑图 3-12 中的图表,我们看一下事件时间水印延迟。
我们看到数据延迟是单调递增的,但没有足够的信息来区分系统卡住和数据卡住的情况。只有通过查看图 3-13 中显示的处理时间水印,我们才能区分这些情况。
![###### 图 3-12。事件时间水印增加。从这些信息中无法知道这是由于数据缓冲还是系统处理延迟造成的。
在第一种情况(图 3-12)中,当我们检查处理时间水印延迟时,我们看到它也在增加。这告诉我们系统中的一个操作卡住了,并且这种卡住也导致数据延迟落后。在现实世界中,可能发生这种情况的一些例子是网络问题阻止了管道各阶段之间的消息传递,或者发生了故障并且正在重试。通常,增长的处理时间水印表明存在一个问题,阻止了对系统功能必要的操作的完成,并且通常需要用户或管理员干预来解决。
在第二种情况中,如图 3-14 所示,处理时间水印延迟很小。这告诉我们没有卡住的操作。事件时间水印延迟仍在增加,这表明我们有一些缓冲状态正在等待排放。例如,如果我们在等待窗口边界发出聚合时缓冲了一些状态,这是可能的,并且对应于管道的正常操作,如图 3-15 所示。
因此,处理时间水印是一个有用的工具,可以区分系统延迟和数据延迟。除了可见性之外,我们还可以在系统实现级别使用处理时间水印,用于诸如临时状态的垃圾收集等任务(Reuven 在第五章中更多地讨论了一个例子)。
现在我们已经为水印应该如何行为奠定了基础,是时候看一看一些真实系统,了解水印的不同机制是如何实现的了。我们希望这些能够揭示在现实世界系统中延迟和正确性以及可扩展性和可用性之间可能存在的权衡。
在流处理系统中,有许多可能的实现水印的方法。在这里,我们简要介绍了 Google Cloud Dataflow 中的实现,这是一个用于执行 Apache Beam 管道的完全托管的服务。Dataflow 包括用于定义数据处理工作流程的 SDK,以及在 Google Cloud Platform 资源上运行这些工作流程的 Cloud Platform 托管服务。
Dataflow 通过将每个数据处理步骤的数据处理图分布到多个物理工作器上,通过将每个工作器的可用键空间分割成键范围,并将每个范围分配给一个工作器来进行条纹化(分片)。每当遇到具有不同键的GroupByKey
操作时,数据必须被洗牌到相应的键上。
图 3-16 描述了带有GroupByKey
的处理图的逻辑表示。
图 3-17 显示了将键范围分配给工作节点的物理分配。
在水印传播部分,我们讨论了水印是如何为每个步骤的多个子组件维护的。Dataflow 跟踪每个组件的每个范围水印。然后,水印聚合涉及计算所有范围的每个水印的最小值,确保满足以下保证:
所有范围都必须报告水印。如果某个范围没有水印,则我们无法提前水印,因为未报告的范围必须被视为未知。
确保水印单调递增。由于可能存在延迟数据,如果更新水印会导致水印后退,我们就不能更新水印。
Google Cloud Dataflow 通过集中式聚合代理执行聚合。我们可以对此代理进行分片以提高效率。从正确性的角度来看,水印聚合器充当了水印的“唯一真相来源”。
在分布式水印聚合中确保正确性会带来一定的挑战。至关重要的是,水印不会过早提前,因为过早提前水印会将准时数据变成延迟数据。具体来说,当物理分配被激活到工作节点时,工作节点会对与键范围相关的持久状态维护租约,确保只有一个工作节点可以对键的持久状态进行变更。为了保证水印的正确性,我们必须确保来自工作进程的每个水印更新只有在工作进程仍然维护其持久状态的租约时才被纳入聚合;因此,水印更新协议必须考虑状态所有权租约验证。
Apache Flink 是一个开源的流处理框架,用于分布式、高性能、始终可用和准确的数据流应用程序。可以使用 Flink 运行 Beam 程序。在这样做时,Beam 依赖于 Flink 内部的水印等流处理概念的实现。与 Google Cloud Dataflow 不同,后者通过集中式水印聚合器代理执行水印聚合,Flink 在内部执行水印跟踪和聚合。
要了解这是如何工作的,让我们看一下 Flink 管道,如图 3-18 所示。
在这个管道中,数据在两个源处生成。这些源也都生成与数据流同步发送的水印“检查点”。这意味着当源 A 发出时间戳“53”的水印检查点时,它保证不会从源 A 发出时间戳在“53”之前的非延迟数据消息。下游的“keyBy”操作符消耗输入数据和水印检查点。随着新的水印检查点被消耗,下游操作符对水印的视图会被提前,并且可以为下游操作符发出新的水印检查点。
将水印检查点与数据流一起发送的选择与 Cloud Dataflow 方法不同,后者依赖于中央聚合,并导致一些有趣的权衡。
以下是内部水印的一些优势:
减少水印传播延迟,非常低延迟的水印
由于不需要水印数据在多个跳跃中传播并等待中央聚合,因此使用内部方法更容易实现非常低延迟。
水印聚合没有单点故障
中央水印聚合代理的不可用将导致整个管道中的水印延迟。采用带内方法,管道的部分不可用不能导致整个管道的水印延迟。
固有的可扩展性
尽管 Cloud Dataflow 在实践中具有良好的扩展性,但与带内水印的隐式可扩展性相比,实现具有集中式水印聚合服务的可扩展性需要更多的复杂性。
以下是带外水印聚合的一些优势:
“真相”的单一来源
对于调试、监控和其他应用(例如基于管道进度对输入进行限流),有一个可以提供水印值的服务是有利的,而不是在流中隐含水印,系统的每个组件都有自己的部分视图。
源水印创建
一些源水印需要全局信息。例如,源可能暂时空闲,数据速率低,或需要有关源或其他系统组件的带外信息来生成水印。这在中央服务中更容易实现。例如,查看接下来关于 Google Cloud Pub/Sub 源水印的案例研究。
Google Cloud Pub/Sub 是一个完全托管的实时消息传递服务,允许您在独立应用程序之间发送和接收消息。在这里,我们讨论如何为通过 Cloud Pub/Sub 发送到管道的数据创建一个合理的启发式水印。
首先,我们需要描述一下 Pub/Sub 的工作原理。消息发布在 Pub/Sub 的主题上。任何数量的 Pub/Sub 订阅都可以订阅特定主题。相同的消息会传递到订阅给定主题的所有订阅。客户端通过拉取订阅中的消息,并通过提供的 ID 确认接收特定消息。客户端无法选择拉取哪些消息,尽管 Pub/Sub 会尝试首先提供最旧的消息,但没有硬性保证。
为了建立一个启发式方法,我们对将数据发送到 Pub/Sub 的源进行了一些假设。具体来说,我们假设原始数据的时间戳是“良好的”;换句话说,我们期望在将数据发送到 Pub/Sub 之前,源数据的时间戳存在有限的无序量。任何发送的数据,其时间戳超出允许的无序范围,将被视为延迟数据。在我们当前的实现中,这个范围至少为 10 秒,这意味着在发送到 Pub/Sub 之前,时间戳最多可以重新排序 10 秒,不会产生延迟数据。我们称这个值为估计带宽。另一种看待这个问题的方式是,当管道完全赶上输入时,水印将比实时晚 10 秒,以便允许源可能的重新排序。如果管道积压,所有积压(不仅仅是 10 秒的范围)都用于估计水印。
我们在使用 Pub/Sub 时面临哪些挑战?因为 Pub/Sub 不能保证排序,我们必须有某种额外的元数据来了解积压情况。幸运的是,Pub/Sub 提供了“最旧的未确认发布时间戳”的积压度量。这与我们消息的事件时间戳不同,因为 Pub/Sub 对通过它发送的应用级元数据是不可知的;相反,这是消息被 Pub/Sub 摄取的时间戳。
这个度量不同于事件时间水印。实际上,这是 Pub/Sub 消息传递的处理时间水印。Pub/Sub 发布时间戳不等于事件时间戳,如果发送了历史(过去)数据,可能会相差很远。这些时间戳的排序也可能不同,因为正如前面提到的,我们允许有限的重排序。
然而,我们可以将其用作积压的度量,以了解有关积压中存在的事件时间戳的足够信息,以便我们可以创建一个合理的水印,如下所示。
我们创建了两个订阅来订阅包含输入消息的主题:一个基本订阅,管道实际上将用它来读取要处理的数据,以及一个跟踪订阅,仅用于元数据,用于执行水印估计。
看一下我们在图 3-19 中的基本订阅,我们可以看到消息可能是无序到达的。我们用 Pub/Sub 发布时间戳“pt”和事件时间时间戳“et”标记每条消息。请注意,这两个时间域可能是无关的。
基本订阅上的一些消息是未确认的,形成了积压。这可能是因为它们尚未被传递,或者它们可能已经被传递但尚未被处理。还要记住,从此订阅中拉取的操作是分布在多个分片上的。因此,仅仅通过查看基本订阅,我们无法确定我们的水印应该是什么。
跟踪订阅,如图 3-20 所示,用于有效地检查基本订阅的积压,并获取积压中事件时间戳的最小值。通过在跟踪订阅上保持很少或没有积压,我们可以检查基本订阅最旧的未确认消息之前的消息。
我们通过确保从此订阅中拉取是计算上廉价的来保持跟踪订阅。相反,如果我们在跟踪订阅上落后得足够多,我们将停止推进水印。为此,我们确保满足以下条件之一:
跟踪订阅足够超前于基本订阅。足够超前意味着跟踪订阅至少超前于估计带宽。这确保了估计带宽内的任何有界重排序都会被考虑在内。
跟踪订阅与实时足够接近。换句话说,跟踪订阅上没有积压。
我们尽快在跟踪订阅上确认消息,在我们已经持久保存了消息的发布和事件时间戳的元数据之后。我们以稀疏直方图格式存储这些元数据,以最小化使用的空间和持久写入的大小。
最后,我们确保有足够的数据来进行合理的水印估计。我们从我们的跟踪订阅中读取的事件时间戳中取一个带宽,其发布时间戳比基本订阅的最旧未确认消息要新,或者等于估计带宽的宽度。这确保我们考虑了积压中的所有事件时间戳,或者如果积压很小,那么就是最近的估计带宽,以进行水印估计。
最后,水印值被计算为带宽中的最小事件时间。
这种方法在某种意义上是正确的,即在输入的重新排序限制内的所有时间戳都将被水印考虑在内,并且不会出现作为延迟数据。然而,它可能会产生一个过于保守的水印,即在第二章中描述的“进展过慢”。因为我们考虑了跟踪订阅上基本订阅最旧的未确认消息之前的所有消息的事件时间戳,所以我们可以将已经被确认的消息的事件时间戳包括在水印估计中。
此外,还有一些启发式方法来确保进展。这种方法在密集、频繁到达的数据情况下效果很好。在稀疏或不经常到达的数据情况下,可能没有足够的最近消息来建立合理的估计。如果我们在订阅中超过两分钟没有看到数据(而且没有积压),我们将将水印提前到接近实时。这确保了水印和管道即使没有更多消息也能继续取得进展。
以上所有内容确保只要源数据事件时间戳重新排序在估计范围内,就不会有额外的延迟数据。
在这一点上,我们已经探讨了如何利用消息的事件时间来给出流处理系统中进展的稳健定义。我们看到这种进展的概念随后如何帮助我们回答在事件时间处理中发生的位置和在处理时间中结果何时实现的问题。具体来说,我们看了水印是如何在源头创建的,即数据进入管道的地方,然后在整个管道中传播,以保留允许回答“在哪里”和“何时”的基本保证。我们还研究了更改输出窗口时间戳对水印的影响。最后,我们探讨了在构建大规模水印时的一些现实系统考虑因素。
现在我们对水印在幕后的工作有了牢固的基础,我们可以深入探讨它们在我们使用窗口和触发器来回答第四章中更复杂的查询时可以为我们做些什么。
¹ 请注意单调性的额外提及;我们还没有讨论如何实现这一点。事实上,到目前为止的讨论并未提及单调性。如果我们只考虑最旧的在途事件时间,水印不会总是单调的,因为我们对输入没有做任何假设。我们稍后会回到这个讨论。
² 要准确,不是日志的数量需要是静态的,而是系统需要事先知道任何给定时间点的日志数量。一个更复杂的输入源,由动态选择的输入日志组成,比如Pravega,同样可以用于构建完美的水印。只有当动态集合中存在的日志数量在任何给定时间点是未知的(就像下一节中的示例一样),才必须依赖启发式水印。
³ 请注意,通过说“流经系统”,我并不一定意味着它们沿着与正常数据相同的路径流动。它们可能会(就像 Apache Flink 一样),但它们也可能会以带外的方式传输(就像 MillWheel/Cloud Dataflow 一样)。
⁴ 窗口的“开始”并不是从水印正确性的角度来看一个安全的选择,因为窗口中的第一个元素通常在窗口开始之后出现,这意味着水印不能保证被拖延到窗口的开始。
⁵ 这里描述的百分位水印触发方案目前尚未由 Beam 实现;然而,其他系统如 MillWheel 实现了这一点。
⁶ 有关 Flink 水印的更多信息,请参阅有关此主题的 Flink 文档。
你好!希望你和我一样喜欢第三章。水印是一个迷人的话题,Slava 比地球上任何人都更了解它们。现在我们对水印有了更深入的了解,我想深入一些与什么、在哪里、何时和如何相关的高级主题。
我们首先看一下处理时间窗口,这是一个更好地理解何时和在哪里的有趣混合,以更好地了解它与事件时间窗口的关系,并了解什么时候它实际上是正确的方法。然后我们深入一些高级事件时间窗口的概念,详细了解会话窗口,最后提出为什么广义的自定义窗口是一个有用(并且令人惊讶地简单)的概念,通过探索三种不同类型的自定义窗口:不对齐固定窗口、按键固定窗口和有界会话窗口。
处理时间窗口的重要性有两个原因:
对于某些用例,比如使用监控(例如,Web 服务流量 QPS),你希望分析观察到的一系列数据流时,处理时间窗口绝对是适当的方法。
对于事件发生时间很重要的用例(例如,分析用户行为趋势、计费、评分等),处理时间窗口绝对不是正确的方法,能够识别这些情况至关重要。
因此,值得深入了解处理时间窗口和事件时间窗口之间的区别,特别是考虑到今天许多流系统中处理时间窗口的普遍性。
在一个模型中,窗口作为一个一流的概念严格基于事件时间,比如本书中介绍的模型,有两种方法可以实现处理时间窗口:
触发器
忽略事件时间(即使用跨越整个事件时间的全局窗口)并使用触发器在处理时间轴上提供该窗口的快照。
进入时间
将进入时间分配为数据的事件时间,并从那时开始使用正常的事件时间窗口。这基本上就是 Spark Streaming 1.x 之类的东西所做的。
请注意,这两种方法或多或少是等效的,尽管在多阶段管道的情况下略有不同:在触发器版本中,多阶段管道将在每个阶段独立地切割处理时间的“窗口”,因此,例如,一个阶段的窗口N中的数据可能最终会出现在下一个阶段的窗口N-1 或N+1 中;在进入时间版本中,一旦数据被合并到窗口N中,由于通过水印(在 Cloud Dataflow 情况下)、微批次边界(在 Spark Streaming 情况下)或其他引擎级别的协调因素的进度同步,它将在整个管道的持续时间内保持在窗口N中。
正如我一再指出的那样,处理时间窗口的一个很大的缺点是,当输入的观察顺序改变时,窗口的内容也会改变。为了更具体地强调这一点,我们将看看这三种用例:事件时间窗口、通过触发器的处理时间窗口和通过进入时间的处理时间窗口。
每个将应用于两组不同的输入(因此总共有六种变化)。这两组输入将是完全相同的事件(即相同的值,在相同的事件时间发生),但观察顺序不同。第一组将是我们一直看到的观察顺序,标为白色;第二组将使所有值在处理时间轴上移动,如图 4-1 中的紫色。您可以简单地想象,紫色示例是现实可能发生的另一种方式,如果风从东方吹来而不是从西方(即,复杂分布式系统的基础集合以稍有不同的顺序进行了一些操作)。
为了建立一个基准,让我们首先比较事件时间的固定窗口化和这两个观察顺序上的启发式水印。我们将重用示例 2-7/图 2-10 中的早期/晚期代码,以获得图 4-2 中显示的结果。左侧基本上是我们之前看到的;右侧是第二个观察顺序的结果。这里需要注意的重要一点是,尽管输出的整体形状不同(由于处理时间中观察的不同顺序),但四个窗口的最终结果保持不变:14、18、3 和 12。
现在让我们将其与刚刚描述的两种处理时间方法进行比较。首先,我们将尝试触发器方法。在以这种方式使处理时间“窗口化”方面有三个方面:
窗口化
我们使用全局事件时间窗口,因为我们实质上是用事件时间窗格模拟处理时间窗口。
触发
我们根据所需的处理时间窗口大小在处理时间域定期触发。
累积
我们使用丢弃模式使窗格彼此独立,从而让它们每个都像一个独立的处理时间“窗口”。
相应的代码看起来有点像示例 4-1;请注意,全局窗口是 Beam 中的默认设置,因此没有特定的窗口策略覆盖。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.triggering(Repeatedly(AlignedDelay(ONE_MINUTE)))
.discardingFiredPanes())
.apply(Sum.integersPerKey());
在流式运行器上执行针对输入数据的两种不同排序时,结果如图 4-3 所示。关于这张图有一些有趣的注释:
因为我们是通过事件时间窗格模拟处理时间窗口,所以“窗口”在处理时间轴上被界定,这意味着它们的有效宽度是在 y 轴上测量而不是 x 轴上。
因为处理时间窗口化对输入数据遇到的顺序敏感,每个“窗口”的结果对于两种观察顺序中的每一个都不同,尽管在每个版本中事件本身在技术上是在相同的时间发生的。在左侧,我们得到 12、18、18,而在右侧,我们得到 7、36、5。
最后,让我们看看通过将输入数据的事件时间映射为其进入时间来实现的处理时间窗口化。在代码上,这里有四个值得一提的方面:
时间移位
当元素到达时,它们的事件时间需要被覆盖为到达时间。我们可以通过提供一个新的DoFn
来在 Beam 中执行此操作,该函数通过outputWithTimestamp
方法将元素的时间戳设置为当前时间。
窗口化
返回使用标准事件时间固定窗口。
触发
因为使用进入时间可以计算出完美的水印,所以我们可以使用默认触发器,在这种情况下,当水印通过窗口的结束时,触发器会隐式触发一次。
累积模式
因为每个窗口只有一个输出,所以累积模式是无关紧要的。
因此,实际代码可能看起来像示例 4-2 中的样子。
PCollection<String> raw = IO.read().apply(ParDo.of(
new DoFn<String, String>() {
public void processElement(ProcessContext c) {
c.outputWithTimestmap(new Instant());
}
});
PCollection<KV<Team, Integer>> input =
raw.apply(ParDo.of(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.info(FixedWindows.of(TWO_MINUTES))
.apply(Sum.integersPerKey());
在流引擎上执行的情况如图 4-4 所示。随着数据的到达,它们的事件时间被更新以匹配它们的进入时间(即到达时的处理时间),导致向右水平移动到理想的水印线上。关于这张图有一些有趣的注释:
与其他处理时间窗口化示例一样,当输入的排序发生变化时,我们会得到不同的结果,尽管输入的值和事件时间保持不变。
与另一个示例不同,窗口再次在事件时间域(因此沿着 x 轴)中划定。尽管如此,它们并不是真正的事件时间窗口;我们只是将处理时间映射到事件时间域,擦除了每个输入的原始发生记录,并用一个新的记录替换它,该记录代表了管道首次观察到数据的时间。
尽管如此,由于水印的存在,触发器的触发仍然与前一个处理时间示例中的时间完全相同。此外,产生的输出值与该示例中的输出值相同,如预期的那样:左侧为 12、18、18,右侧为 7、36、5。
因为使用进入时间时可以实现完美的水印,所以实际水印与理想水印匹配,向上向右倾斜。
<资产/stsy_0404.mp4>
虽然看到不同的实现处理时间窗口的方式很有趣,但这里的重点是我从第一章开始一直在强调的:事件时间窗口是无序的,至少在极限情况下(直到输入变得完整之前,实际窗格可能会有所不同);处理时间窗口不是。*如果你关心事件实际发生的时间,你必须使用事件时间窗口,否则你的结果将毫无意义。*我现在要下台了。
足够了解处理时间窗口化。现在让我们回到经过验证的事件时间窗口化,但现在我们要看一下我最喜欢的功能之一:动态、数据驱动的窗口,称为会话。
会话是一种特殊类型的窗口,它捕获了数据中的一段活动期间,该期间由不活动的间隙终止。它们在数据分析中特别有用,因为它们可以提供特定用户在特定时间段内参与某些活动的活动视图。这允许在会话内进行活动的相关性,根据会话的长度推断参与水平等等。
从窗口化的角度来看,会话在两个方面特别有趣:
它们是数据驱动窗口的一个例子:窗口的位置和大小直接取决于输入数据本身,而不是基于时间内的某些预定义模式,如固定窗口和滑动窗口。
它们也是不对齐窗口的一个例子;也就是说,窗口不是均匀适用于所有数据,而只适用于数据的特定子集(例如,每个用户)。这与固定和滑动窗口等对齐窗口形成对比,后者通常均匀适用于所有数据。
对于某些用例,有可能提前使用共同标识符标记单个会话中的数据(例如,发出带有服务质量信息的心跳 ping 的视频播放器;对于任何给定的观看,所有 ping 可以提前使用单个会话 ID 进行标记)。在这种情况下,会话的构建要容易得多,因为它基本上只是一种按键分组。
然而,在更一般的情况下(即,实际会话本身事先不知道的情况下),会话必须仅从数据在时间内的位置构建。处理无序数据时,这变得特别棘手。
图 4-5 显示了一个例子,其中五个独立的记录被分组到了会话窗口中,间隔超时为 60 分钟。每个记录最初都在自己的 60 分钟窗口中(原型会话)。合并重叠的原型会话产生了包含三个和两个记录的两个较大的会话窗口。
提供一般会话支持的关键见解是,完整的会话窗口是由一组较小的重叠窗口组成的,每个窗口包含一个单独的记录,序列中的每个记录与下一个记录之间的不活动间隙不大于预定义的超时时间。因此,即使我们以无序方式观察会话中的数据,我们也可以通过简单地合并到达的任何重叠窗口来构建最终的会话。
换个角度看,考虑到我们迄今为止一直在使用的例子。如果我们指定一个一分钟的会话超时,我们期望在数据中识别出两个会话,在图 4-6 中用虚线标出。每个会话捕获了用户的一次活动,会话中的每个事件与会话中的至少一个其他事件相隔不到一分钟。
为了看到窗口合并是如何随着事件的出现而随时间构建这些会话的,让我们看看它的实际操作。我们将使用示例 2-10 中启用了撤回的早期/晚期代码,并更新窗口以使用一分钟的间隙持续时间来构建会话。示例 4-3 说明了这是什么样子。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(Sessions.withGapDuration(ONE_MINUTE))
.triggering(
AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AfterCount(1))))
.apply(Sum.integersPerKey());
在流式引擎上执行,你会得到类似图 4-7 所示的东西(请注意,我留下了用虚线标注的预期最终会话以供参考)。
这里有很多事情要做,所以我会带你走一些:
当遇到值为 5 的第一条记录时,它被放入一个单独的原型会话窗口中,该窗口从该记录的事件时间开始,跨越会话间隙持续时间的宽度;例如,在该数据发生的点之后一分钟。我们将来遇到的任何与此窗口重叠的窗口都应该属于同一个会话,并将被合并到其中。
第二条到达的记录是 7,类似地被放入了自己的原型会话窗口中,因为它与 5 的窗口不重叠。
同时,水印已经超过了第一个窗口的结束时间,因此值为 5 的结果在 12:06 之前作为及时结果实现。不久之后,第二个窗口也作为具有值 7 的推测结果实现,就在处理时间达到 12:06 时。
接下来,我们观察一对记录 3 和 4,它们的原始会话重叠。因此,它们被合并在一起,当 12:07 的早期触发器触发时,一个值为 7 的单个窗口被发出。
随后,8 到达时,它与值为 7 的两个窗口重叠。因此,所有三个窗口合并在一起,形成一个新的组合会话,值为 22。然后,当水印通过此会话的结束时,它实现了值为 22 的新会话,以及之前发出的值为 7 的两个窗口的撤销,但后来合并到其中。
当 9 迟到时,与值为 5 的原始会话和值为 22 的会话合并成一个值为 36 的更大会话。 36 和值为 5 和 22 的撤销都立即由迟到数据触发器发出。
这是非常强大的东西。真正令人惊讶的是,在将流处理的维度分解为不同的可组合部分的模型中描述这样的东西是多么容易。最后,您可以更多地专注于有趣的业务逻辑,而不是将数据塑造成可用形式的细枝末节。
如果你不相信我,看看这篇博文,描述如何在 Spark Streaming 1.x 上手动构建会话(请注意,这并不是要指责他们;Spark 的人员在其他方面做得足够好,以至于有人实际上费心记录在 Spark 1.x 上构建特定类型的会话支持需要什么;大多数其他系统都没有这样做)。这是相当复杂的,他们甚至没有进行适当的事件时间会话,或提供推测或迟到触发,或撤销。
到目前为止,我们主要讨论了预定义类型的窗口策略:固定、滑动和会话。您可以从标准窗口类型中获得很多收益,但是有很多真实世界的用例需要能够定义自定义窗口策略,这样可以真正拯救一天(其中三个我们将在接下来看到)。
今天大多数系统不支持自定义窗口到 Beam 支持的程度,因此我们专注于 Beam 方法。在 Beam 中,自定义窗口策略由两部分组成:
窗口分配
这将每个元素放入初始窗口。在极限情况下,这允许每个元素放入一个唯一的窗口,这是非常强大的。
(可选)窗口合并
这允许窗口在分组时间合并,这使得窗口随时间演变成为可能,我们之前在会话窗口中看到了这种情况。
为了让您了解窗口策略的简单性,以及自定义窗口支持的实用性,我们将详细查看 Beam 中固定窗口和会话的标准实现,然后考虑一些需要对这些主题进行自定义变体的真实用例。在这个过程中,我们将看到创建自定义窗口策略有多么容易,以及当您的用例不完全符合标准方法时,缺乏自定义窗口支持会有多么限制。
首先,让我们看一下相对简单的固定窗口策略。标准的固定窗口实现就像您想象的那样简单明了,并包括以下逻辑:
分配
根据其时间戳和窗口的大小和偏移参数,将元素放入适当的固定窗口中。
合并
无。
代码的简化版本如示例 4-4 所示。
public class FixedWindows extends WindowFn<Object, IntervalWindow> {
private final Duration size;
private final Duration offset;
public Collection<IntervalWindow> assignWindow(AssignContext c) {
long start = c.timestamp().getMillis() - c.timestamp()
.plus(size)
.minus(offset)
.getMillis() % size.getMillis();
return Arrays.asList(IntervalWindow(new Instant(start), size));
}
}
请记住,这里展示代码的目的并不是教你如何编写窗口策略(尽管解密它们并指出它们是多么简单也是不错的)。真正的目的是帮助对比支持一些相对基本的用例的相对容易和困难,分别使用和不使用自定义窗口。现在让我们考虑两种变体的固定窗口主题的用例。
我们之前提到的默认固定窗口实现的一个特点是,所有数据的窗口都是对齐的。在我们的运行示例中,给定团队的中午到下午 1 点的窗口与所有其他团队的相应窗口对齐,这些窗口也从中午延伸到下午 1 点。对于希望在另一个维度上比较类似窗口的用例,比如团队之间的比较,这种对齐非常有用。然而,它也带来了一个相对微妙的代价。从中午到下午 1 点的所有活动窗口大约在同一时间完成,这意味着每小时系统都会受到大量窗口的材料化冲击。
为了说明我的意思,让我们看一个具体的例子(示例 4-5)。我们将从一个得分总和管道开始,就像我们在大多数示例中使用的那样,使用固定的两分钟窗口和单个水印触发器。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
但在这种情况下,我们将并行地查看同一数据集中的两个不同键(见图 4-8)。我们将看到这两个键的输出都是对齐的,因为所有键的窗口都是对齐的。因此,每当水印通过窗口结束时,我们就会得到N个窗格的材料化,其中N是在该窗口中更新的键的数量。在这个例子中,N为 2,可能并不太痛苦。但当N开始达到千百万或更多时,这种同步的突发性可能会成为问题。
在不需要跨窗口比较的情况下,通常更希望将窗口完成负载均匀分布在时间上。这使得系统负载更可预测,可以减少处理峰值负载的需求。然而,在大多数系统中,如果系统不提供对齐的固定窗口支持,那么非对齐的固定窗口通常是不可用的。² 但是,通过自定义窗口支持,将默认的固定窗口实现修改为提供非对齐的固定窗口支持是相对简单的。我们希望继续保证所有被分组在一起的元素的窗口(即具有相同键的元素)具有相同的对齐,同时放宽对不同键之间的对齐限制。对默认的固定窗口策略进行的代码更改看起来像示例 4-6。
publicclass`Unaligned`FixedWindowsextendsWindowFn<`KV``<``K``,``V``>`,IntervalWindow>{privatefinalDurationsize;privatefinalDurationoffset;publicCollection<IntervalWindow>assignWindow(AssignContextc){`long``perKeyShift``=``hash``(``c``.``element``(``)``.``key``(``)``)``%``size``;`longstart=`perKe``yShift``+`c.timestamp().getMillis()-c.timestamp().plus(size).minus(offset)returnArrays.asList(IntervalWindow(newInstant(start),size));}}
通过这种改变,所有具有相同键的元素的窗口³是对齐的,但具有不同键的元素的窗口(通常)是不对齐的,因此在分布窗口完成负载的同时,也使得跨键的比较变得不太有意义。我们可以将我们的管道切换到使用我们的新窗口策略,如示例 4-7 所示。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(UnalignedFixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
然后,通过比较与之前相同数据集的不同固定窗口对齐方式,您可以在图 4-9 中看到这是什么样子(在这种情况下,我选择了两种对齐之间的最大相位移,以最清楚地突出其好处,因为在大量键上随机选择相位将产生类似的效果)。
请注意,我们没有同时为多个键发出多个窗格的情况。相反,窗格以更加均匀的节奏单独到达。这是另一个例子,可以在一个维度上进行权衡(跨键比较的能力),以换取另一个维度的好处(减少峰值资源配置要求),当用例允许时。当您试图尽可能高效地处理大量数据时,这种灵活性是至关重要的。
现在让我们看一下固定窗口的第二种变化,这种变化更与正在处理的数据密切相关。
我们的第二个例子来自 Cloud Dataflow 的早期采用者之一。这家公司为其客户生成分析数据,但每个客户都可以配置其要聚合指标的窗口大小。换句话说,每个客户都可以定义其固定窗口的特定大小。
支持这样的用例并不太困难,只要可用的窗口大小数量本身是固定的。例如,您可以想象提供选择 30 分钟、60 分钟和 90 分钟固定窗口的选项,然后为每个选项运行一个单独的管道(或管道的分支)。虽然不理想,但也不太可怕。然而,随着选项数量的增加,这很快变得难以处理,在提供对真正任意窗口大小的支持的极限情况下(这正是这位客户的用例所需的),这完全是不切实际的。
幸运的是,因为客户处理的每条记录已经用描述聚合窗口所需大小的元数据进行了注释,因此支持任意的、每个用户的固定窗口大小就像从标准固定窗口实现中更改几行代码一样简单,如示例 4-8 所示。
public class PerElementFixedWindows<T extends HasWindowSize%gt;
extends WindowFn<T, IntervalWindow> {
private final Duration offset;
public Collection<IntervalWindow> assignWindow(AssignContext c) {
long perElementSize = c.element().getWindowSize();
long start = perKeyShift + c.timestamp().getMillis()
- c.timestamp()
.plus(size)
.minus(offset)
.getMillis() % size.getMillis();
return Arrays.asList(IntervalWindow(
new Instant(start), perElementSize));
}
}
通过这种改变,每个元素都被分配到一个固定大小的窗口中,其大小由元素本身携带的元数据所决定。⁴ 将管道代码更改为使用这种新策略同样是微不足道的,如示例 4-9 所示。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(PerElementFixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
然后看着这个管道在运行中(图 4-10),很容易看出 Key A 的所有元素都有两分钟的窗口大小,而 Key B 的元素有一分钟的窗口大小。
这确实不是您可以合理期望系统为您提供的东西;窗口大小偏好的存储方式对于尝试构建标准 API 来说太具体于用例,因此没有意义。然而,正如这位客户的需求所展示的那样,这样的用例确实存在。这就是自定义窗口提供的灵活性如此强大的原因。
为了真正展示自定义窗口的有用性,让我们看一个最后的例子,这是会话的一个变化。会话窗口理所当然地比固定窗口更复杂。其实现包括以下内容:
分配
每个元素最初被放入一个原型会话窗口,该窗口从元素的时间戳开始,并持续一段间隔时间。
合并
在分组时,所有符合条件的窗口都被排序,然后任何重叠的窗口都被合并在一起。
会话代码的简化版本(手动从多个辅助类合并在一起)看起来像示例 4-10 中显示的样子。
public class Sessions extends WindowFn<Object, IntervalWindow> {
private final Duration gapDuration;
public Collection<IntervalWindow> assignWindows(AssignContext c) {
return Arrays.asList(
new IntervalWindow(c.timestamp(), gapDuration));
}
public void mergeWindows(MergeContext c) throws Exception {
List<IntervalWindow> sortedWindows = new ArrayList<>();
for (IntervalWindow window : c.windows()) {
sortedWindows.add(window);
}
Collections.sort(sortedWindows);
List<MergeCandidate> merges = new ArrayList<>();
MergeCandidate current = new MergeCandidate();
for (IntervalWindow window : sortedWindows) {
if (current.intersects(window)) {
current.add(window);
} else {
merges.add(current);
current = new MergeCandidate(window);
}
}
merges.add(current);
for (MergeCandidate merge : merges) {
merge.apply(c);
}
}
}
与以往一样,看代码的重点并不是教你如何实现自定义窗口函数,甚至不是会话实现的具体内容;真正的重点是展示通过自定义窗口函数支持新用例的简单性。
我多次遇到的一个这样的自定义用例是有界会话:不允许超出一定大小的会话,无论是在时间上,元素计数上还是其他维度上。这可能是出于语义原因,也可能只是一种垃圾邮件保护的练习。然而,鉴于限制类型的变化(一些用例关心事件时间上的总会话大小,一些关心总元素计数,一些关心元素密度等),很难为有界会话提供一个清晰简洁的 API。更实际的是允许用户实现自己的自定义窗口逻辑,以适应其特定用例。一个这样的用例示例,其中会话窗口受时间限制,可能看起来像示例 4-11(省略了我们将在这里使用的一些构建器样板)。
publicclass`Bounded`SessionsextendsWindowFn<Object,IntervalWindow>{privatefinalDurationgapDuration;`private``final``Duration``maxSize``;`publicCollection<IntervalWindow>assignWindows(AssignContextc){returnArrays.asList(newIntervalWindow(c.timestamp(),gapDuration));}`private``Duration``windowSize``(``IntervalWindow``window``)``{``return``window``=``=``null``?``new``Duration``(``0``)``:``new``Duration``(``window``.``start``(``)``,``window``.``end``(``)``)``;``}`publicstaticvoidmergeWindows(WindowFn<?,IntervalWindow>.MergeContextc)throwsException{List<IntervalWindow>sortedWindows=newArrayList<>();for(IntervalWindowwindow:c.windows()){sortedWindows.add(window);}Collections.sort(sortedWindows);List<MergeCandidate>merges=newArrayList<>();MergeCandidatecurrent=newMergeCandidate();for(IntervalWindowwindow:sortedWindows){`MergeCandidate``next``=``new``MergeCandidate``(``window``)``;`if(current.intersects(window)){current.add(window);`if``(``windowSize``(``current``.``union``)``<``=``(``maxSize``-``gapDuration``)``)``continue``;``// Current window exceeds bounds, so flush and move to next``next``=``new``MergeCandidate``(``)``;``}`merges.add(current);current=next;}merges.add(current);for(MergeCandidatemerge:merges){merge.apply(c);}}}
与以往一样,更新我们的管道(在这种情况下是示例 2-7 中的早期/准时/迟到版本)以使用这种自定义窗口策略是微不足道的,正如您在示例 4-12 中所看到的。
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(BoundedSessions
.withGapDuration(ONE_MINUTE)
.withMaxSize(THREE_MINUTES))
.triggering(
AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AfterCount(1))))
.apply(Sum.integersPerKey());
并在我们的运行示例中执行,它可能看起来像图 4-11。
请注意,大会话的值为 36,跨越了 12:00.26, 12:05.20,或者近五分钟的时间,在无界会话实现中从[图 2-7 现在分解为两个较短的会话,长度分别为 2 分钟和 2 分钟 53 秒。
考虑到目前很少有系统提供自定义窗口支持,值得指出的是,如果要使用只支持无限会话实现的系统来实现这样的功能,将需要更多的工作。你唯一的选择是在会话分组逻辑的下游编写代码,查看生成的会话并在超过长度限制时对其进行切割。这将需要在事后对会话进行分解的能力,这将消除增量聚合的好处(我们将在第七章中更详细地讨论),增加成本。这也将消除任何希望通过限制会话长度获得的垃圾邮件保护的好处,因为会话首先需要增长到其完整大小,然后才能被切割或截断。
我们现在已经看到了三个真实的用例,每个用例都是数据处理系统通常提供的窗口类型的微妙变化:不对齐的固定窗口,按元素固定的窗口和有界会话。在这三种情况下,我们看到了通过自定义窗口支持这些用例是多么简单,以及如果没有它,支持这些用例将会更加困难(或昂贵)。尽管自定义窗口在行业中尚未得到广泛支持,但它是一个功能,为构建需要尽可能高效地处理大量数据的复杂真实用例的数据处理管道提供了非常需要的灵活性。
高级窗口是一个复杂而多样的主题。在本章中,我们涵盖了三个高级概念:
处理时间窗口
我们看到了这与事件时间窗口的关系,指出了它固有有用的地方,并且最重要的是,通过明确强调事件时间窗口为我们提供的结果的稳定性,确定了它不适用的地方。
会话窗口
我们首次介绍了动态合并窗口策略类,并看到系统为我们提供了多么大的帮助,提供了这样一个强大的构造,你可以简单地放置在那里。
自定义窗口
在这里,我们看了三个现实世界的例子,这些例子在只提供静态一组标准窗口策略的系统中很难或不可能实现,但在具有自定义窗口支持的系统中相对容易实现:
不对齐的固定窗口,在使用水印触发器与固定窗口时,可以更均匀地分布输出。
每个元素的固定窗口,可以灵活地选择每个元素的固定窗口大小(例如,提供可定制的每个用户或每个广告活动窗口大小),以更好地定制管道语义以适应特定用例。
有界会话窗口,限制给定会话的增长大小;例如,用于抵消垃圾邮件尝试或对由管道实现的已完成会话的延迟设置边界。
在与 Slava 深入研究了第三章的水印,并在这里对高级窗口进行了广泛调查后,我们已经远远超出了多维度的稳健流处理的基础知识。因此,我们结束了对 Beam 模型的关注,也结束了书的第一部分。
接下来是 Reuven 的第五章,讨论一致性保证、精确一次处理和副作用,之后我们将开始进入第二部分流和表,阅读第六章。
¹ 据我所知,Apache Flink 是唯一支持自定义窗口到 Beam 所做程度的另一个系统。公平地说,由于能够提供自定义窗口逐出器的能力,其支持甚至超出了 Beam 的支持。头都快炸了。
² 我实际上并不知道目前有任何这样的系统。
³ 这自然意味着使用分组数据,但因为窗口化本质上与按键分组紧密相关,所以这个限制并不特别繁重。
⁴ 并不是关键的是元素本身知道窗口大小;你可以轻松查找并缓存所需维度的适当窗口大小;例如,每个用户。