流式系统-全-
流式系统(全)
译者:飞龙
前言或:你在这里要做什么?
你好,冒险的读者,欢迎来到我们的书!在这一点上,我假设你要么对学习更多关于流处理的奇迹感兴趣,要么希望花几个小时阅读关于雄伟的棕色鳟鱼的荣耀。无论哪种方式,我都向你致敬!也就是说,属于后一种类型的人,如果你对计算机科学没有高级的理解,那么在继续前,你应该考虑一下你是否准备好面对失望;警告渔夫,等等。
为了从一开始就设定这本书的基调,我想提醒你一些事情。首先,这本书有点奇怪,因为我们有多个作者,但我们并不假装我们以某种方式都说和写着相同的声音,就像我们是奇怪的同卵三胞胎,碰巧出生在不同的父母身边。因为尽管听起来很有趣,但最终的结果实际上会更不愉快。相反,我们选择了用自己的声音写作,我们给了这本书足够的自我意识,可以在适当的时候提到我们每个人,但不会让它对我们只是一本书而不是像苏格兰口音的机器恐龙这样更酷的东西感到不满。¹
就声音而言,你会遇到三种:
泰勒
那就是我。如果你没有明确被告知有其他人在讲话,你可以假设是我,因为我们在游戏的后期才添加了其他作者,当我考虑回去更新我已经写过的一切时,我基本上是“不可能的”。我是谷歌数据处理语言和系统²组的技术负责人,负责谷歌云数据流、谷歌的 Apache Beam 工作,以及谷歌内部的数据处理系统,如 Flume、MillWheel 和 MapReduce。我也是 Apache Beam PMC 的创始成员。

图 P-1。本来可以成为封面的封面...
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 格式只显示了少量帧(漫画风格):
-
特定的图表可以在以下形式的 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.”
如果您觉得您对代码示例的使用超出了公平使用范围或上述给出的许可,请随时通过permissions@oreilly.com与我们联系。
O’Reilly Safari
注意
Safari(原 Safari Books Online)是一个面向企业、政府、教育工作者和个人的会员制培训和参考平台。
会员可以访问来自 250 多家出版商的数千本图书、培训视频、学习路径、交互式教程和精选播放列表,包括 O’Reilly Media、哈佛商业评论、Prentice Hall 专业、Addison-Wesley 专业、微软出版社、Sams、Que、Peachpit 出版社、Adobe、Focal Press、思科出版社、约翰威利与儿子、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、麦格劳希尔、琼斯与巴特利特等等。
有关更多信息,请访问http://www.oreilly.com/safari。
如何联系我们
请将有关本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(在美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书创建了一个网页,列出勘误、示例和任何其他信息。您可以访问http://bit.ly/streaming-systems。
要就本书发表评论或提出技术问题,请发送电子邮件至bookquestions@oreilly.com。
有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站http://www.oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://www.youtube.com/oreillymedia
致谢
最后,但肯定不是最不重要的:许多人都很棒,我们想在这里特别感谢其中的一部分人,因为他们在创建这本书时提供了帮助。
这本书中的内容汇集了 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
流数据处理在大数据领域是一件大事,而且有很多好的原因;其中包括以下几点:
-
企业渴望对其数据获得更及时的洞察,转向流处理是实现更低延迟的好方法。
-
在现代商业中越来越普遍的大规模、无限的数据集,更容易通过设计用于这种不断增长的数据量的系统来驯服。
-
随着数据到达时进行处理,可以更均匀地分配工作负载,从而产生更一致和可预测的资源消耗。
尽管业务驱动的对流处理的兴趣激增,但与批处理系统相比,流处理系统长期以来仍然相对不够成熟。直到最近,潮水才明确地向另一个方向转变。在我更为自负的时刻,我希望这在某种程度上是由于我最初在我的“流处理 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. 时间域映射。x 轴表示系统中事件时间的完整性;即,事件时间 X 之前的所有数据已被观察到。y 轴⁴表示处理时间的进展;即,数据处理系统执行时所观察到的正常时钟时间。
在图 1-1 中,具有斜率为 1 的黑色虚线代表理想状态,其中处理时间和事件时间完全相等;红线代表现实情况。在这个例子中,系统在处理时间开始时稍微滞后,向理想状态靠近,然后在结束时再次稍微滞后。乍一看,这个图表中有两种不同时间域中的偏差:
处理时间
理想状态和红线之间的垂直距离是处理时间域中的滞后。这个距离告诉您在事件发生时和它们被处理时之间观察到的延迟(在处理时间上)。这可能是两种偏差中更自然和直观的一种。
事件时间
理想状态和红线之间的水平距离是管道中事件时间偏差的量。它告诉您管道当前在事件时间上距离理想状态有多远。
实际上,在任何给定时间点上,处理时间滞后和事件时间偏差是相同的;它们只是观察同一事物的两种方式。关于滞后/偏差的重要要点是:因为事件时间和处理时间之间的整体映射不是静态的(即,滞后/偏差可以随时间任意变化),这意味着如果你关心它们的事件时间(即事件实际发生的时间),你不能仅仅在管道观察它们时分析你的数据。不幸的是,这是历史上许多为无限数据设计的系统的运行方式。为了应对无限数据集的特性,这些系统通常提供了一些关于窗口化传入数据的概念。我们稍后会深入讨论窗口化,但它基本上意味着沿着时间边界将数据集切分成有限的部分。如果你关心正确性并且有兴趣在它们的事件时间上分析你的数据,你不能使用处理时间来定义这些时间边界(即处理时间窗口化),因为许多系统这样做;由于处理时间和事件时间之间没有一致的关联,你的一些事件时间数据将会出现在错误的处理时间窗口中(由于分布式系统的固有滞后,许多类型的输入源的在线/离线性质等),这将使正确性不复存在。我们将在接下来的几个部分以及本书的其余部分中更详细地讨论这个问题。
不幸的是,按事件时间进行窗口化也并非一帆风顺。在无界数据的情况下,混乱和可变的偏差为事件时间窗口带来了完整性问题:缺乏处理时间和事件时间之间的可预测映射,你如何确定你何时观察到了给定事件时间X的所有数据?对于许多真实世界的数据源来说,你根本无法确定。但是今天大多数使用的数据处理系统都依赖于某种完整性的概念,这使它们在应用于无界数据集时处于严重劣势。
我建议,我们不应该试图将无限的数据整理成最终变得完整的有限批次的信息,而是应该设计一些工具,让我们能够生活在这些复杂数据集所施加的不确定性世界中。新数据会到达,旧数据可能会被撤回或更新,我们构建的任何系统都应该能够自行应对这些事实,完整性的概念应该是特定和适当用例的便利优化,而不是所有用例的语义必要性。
在深入讨论这种方法可能是什么样子之前,让我们先完成一个有用的背景知识:常见的数据处理模式。
数据处理模式
在这一点上,我们已经建立了足够的背景知识,可以开始看一下今天有界和无界数据处理中常见的核心使用模式。我们将在两种处理类型和相关的情况下看一下我们关心的两种主要引擎(批处理和流处理,在这个上下文中,我基本上将微批处理与流处理归为一类,因为在这个层面上两者之间的差异并不是非常重要)。
有界数据
处理有界数据在概念上非常简单,可能对每个人都很熟悉。在图 1-2 中,我们从左侧开始,有一个充满熵的数据集。我们将其通过一些数据处理引擎(通常是批处理,尽管一个设计良好的流处理引擎也可以很好地工作),比如MapReduce,最终在右侧得到一个具有更大内在价值的新结构化数据集。

图 1-2。使用经典批处理引擎处理有界数据。左侧的有限的非结构化数据通过数据处理引擎,生成右侧对应的结构化数据。
虽然在这种方案中实际上可以计算出无限多种变化,但总体模型非常简单。更有趣的是处理无界数据集的任务。现在让我们来看看通常处理无界数据的各种方式,从传统批处理引擎使用的方法开始,然后再看看您可以使用设计用于无界数据的系统(如大多数流式或微批处理引擎)采取的方法。
无界数据:批处理
尽管批处理引擎并非专门为无界数据设计,但自批处理系统首次构思以来,就一直被用于处理无界数据集。正如您所期望的那样,这些方法围绕将无界数据切分为适合批处理的有界数据集的集合。
固定窗口
使用批处理引擎的重复运行来处理无界数据集的最常见方式是将输入数据分割成固定大小的窗口,然后将每个窗口作为单独的有界数据源进行处理(有时也称为滚动窗口),如图 1-3 所示。特别是对于像日志这样的输入源,事件可以被写入目录和文件层次结构,其名称编码了它们对应的窗口,这种方法乍看起来似乎非常简单,因为您已经在适当的事件时间窗口中进行了基于时间的洗牌以提前获取数据。
然而,实际上,大多数系统仍然存在完整性问题需要解决(如果您的一些事件由于网络分区而延迟到达日志,该怎么办?如果您的事件是全球收集的,并且必须在处理之前转移到一个共同的位置,该怎么办?如果您的事件来自移动设备?),这意味着可能需要某种形式的缓解(例如,延迟处理直到确保所有事件都已收集,或者在数据迟到时重新处理给定窗口的整个批次)。

图 1-3。通过经典批处理引擎将无界数据处理成临时固定窗口。无界数据集首先被收集到有限的、固定大小的有界数据窗口中,然后通过经典批处理引擎的连续运行进行处理。
会话
当尝试使用批处理引擎处理无界数据以实现更复杂的窗口策略(如会话)时,这种方法会变得更加复杂。会话通常被定义为活动期间(例如特定用户的活动)之后的不活动间隔。当使用典型的批处理引擎计算会话时,通常会出现会话跨批次分割的情况,如图 1-4 中的红色标记所示。我们可以通过增加批处理大小来减少分割的次数,但这会增加延迟。另一种选择是添加额外的逻辑来从之前的运行中拼接会话,但这会增加复杂性。

图 1-4。通过经典批处理引擎将无界数据处理成会话,使用临时固定窗口。无界数据集首先被收集到有限的、固定大小的有界数据窗口中,然后通过经典批处理引擎的连续运行将其细分为动态会话窗口。
无论如何,使用经典批处理引擎计算会话都不是理想的方式。更好的方式是以流式方式构建会话,我们稍后会详细介绍。
无界数据:流式
与大多数基于批处理的无界数据处理方法的临时性相反,流处理系统是为无界数据而构建的。正如我们之前讨论的,对于许多真实世界的分布式输入源,你不仅需要处理无界数据,还需要处理以下类型的数据:
-
与事件时间相关的无序性很高,这意味着如果你想在发生事件的上下文中分析数据,你的管道中需要一些基于时间的洗牌。
-
事件时间偏移不同,这意味着你不能假设你总是会在某个常数时间Y内看到给定事件时间X的大部分数据。
处理具有这些特征的数据时,你可以采取几种方法。我通常将这些方法归为四类:时间不敏感、近似、按处理时间窗口分组、按事件时间窗口分组。
现在让我们花一点时间来看看这些方法。
时间不敏感
时间不敏感的处理用于时间基本无关的情况;也就是说,所有相关逻辑都是数据驱动的。因为这类用例的一切都由更多数据的到达来决定,所以流处理引擎实际上没有什么特别之处需要支持,除了基本的数据传递。因此,实际上所有现有的流处理系统都可以直接支持时间不敏感的用例(当然,如果你关心正确性,系统之间的一致性保证可能会有所不同)。批处理系统也非常适合对无界数据源进行时间不敏感的处理,只需将无界数据源切割成一系列有界数据集并独立处理这些数据集。本节中我们将看一些具体的例子,但考虑到处理时间不敏感的简单性(至少从时间的角度来看),我们不会在此之外花费太多时间。
过滤
时间不敏感处理的一个非常基本的形式是过滤,一个例子如图 1-5 所示。想象一下,你正在处理网站流量日志,并且想要过滤掉所有不是来自特定域的流量。当每条记录到达时,你会查看它是否属于感兴趣的域,并丢弃不属于的记录。因为这种处理方式只依赖于任何时间的单个元素,数据源是无界的、无序的,并且事件时间偏移不同是无关紧要的。

图 1-5。过滤无界数据。各种类型的数据(从左到右流动)被过滤成包含单一类型的同质集合。
内连接
另一个时间不敏感的例子是内连接,如图 1-6 所示。当连接两个无界数据源时,如果你只关心当来自两个源的元素到达时连接的结果,那么逻辑上就没有时间元素。在看到一个源的值后,你可以简单地将其缓存到持久状态中;只有在另一个源的第二个值到达后,你才需要发出连接的记录。(事实上,你可能希望对未发出的部分连接进行某种垃圾回收策略,这可能是基于时间的。但对于几乎没有未完成连接的用例来说,这可能不是一个问题。)

图 1-6。在无界数据上执行内连接。当观察到来自两个源的匹配元素时,连接就会产生。
将语义切换到某种外连接会引入我们之前讨论过的数据完整性问题:在看到连接的一侧之后,你怎么知道另一侧是否会到达或不会到达?说实话,你不知道,所以你需要引入某种超时的概念,这就引入了时间的元素。这个时间元素本质上是一种窗口,我们稍后会更仔细地看一下。
近似算法
第二大类方法是近似算法,比如近似 Top-N,流式 k 均值等。它们接受无限的输入源,并提供输出数据,如果你仔细看,它们看起来或多或少像你希望得到的结果,如图 1-7 所示。近似算法的优势在于,它们设计上开销低,适用于无限数据。缺点是它们的种类有限,算法本身通常很复杂(这使得很难想出新的算法),而且它们的近似性质限制了它们的实用性。

图 1-7。在无限数据上计算近似值。数据经过复杂算法处理,产生的输出数据看起来或多或少像另一侧期望的结果。
值得注意的是,这些算法通常在设计上都有一定的时间元素(例如,一些内置的衰减)。由于它们处理元素的方式是按照到达的顺序进行的,所以时间元素通常是基于处理时间的。这对于那些在近似中提供一定的可证明误差界限的算法尤为重要。如果这些误差界限是基于数据按顺序到达的,那么当你向算法提供无序数据和不同的事件时间偏移时,它们基本上就毫无意义了。这是需要记住的一点。
近似算法本身是一个迷人的课题,但由于它们本质上是时间不可知的处理的另一个例子(除了算法本身的时间特征),它们非常容易使用,因此在我们目前的重点下,不值得进一步关注。
窗口
处理无限数据的剩下两种方法都是窗口的变体。在深入讨论它们之间的区别之前,我应该明确窗口的确切含义,因为我们在上一节中只是简单提到了它。窗口简单地意味着将数据源(无论是无限的还是有限的)沿着时间边界切割成有限的块进行处理。图 1-8 显示了三种不同的窗口模式。

图 1-8。窗口策略。每个示例都显示了三个不同的键,突出了对齐窗口(适用于所有数据)和不对齐窗口(适用于数据子集)之间的差异。
让我们更仔细地看看每种策略:
固定窗口(又称滚动窗口)
我们之前讨论过固定窗口。固定窗口将时间划分为具有固定时间长度的段。通常(如图 1-9 所示),固定窗口的段均匀应用于整个数据集,这是对齐窗口的一个例子。在某些情况下,希望为数据的不同子集(例如,按键)相位移窗口,以更均匀地分散窗口完成负载,这反而是不对齐窗口的一个例子,因为它们在数据上变化。⁶
滑动窗口(又称跳跃窗口)
滑动窗口是固定长度和固定周期定义的。如果周期小于长度,窗口会重叠。如果周期等于长度,你就有了固定窗口。如果周期大于长度,你就有了一种奇怪的采样窗口,它只在时间上查看数据的子集。与固定窗口一样,滑动窗口通常是对齐的,尽管在某些用例中,它们可以是不对齐的性能优化。请注意,图 1-8 中的滑动窗口是按照它们的方式绘制的,以给出滑动运动的感觉;实际上,所有五个窗口都会应用于整个数据集。
会话
动态窗口的一个例子,会话由一系列事件组成,这些事件以大于某个超时的不活动间隙结束。会话通常用于分析用户随时间的行为,通过将一系列时间相关的事件(例如,一系列视频在一次观看中观看)分组在一起。会话很有趣,因为它们的长度不能事先定义;它们取决于实际涉及的数据。它们也是不对齐窗口的典型例子,因为会话在不同数据子集中几乎从不相同(例如,不同用户)。
我们之前讨论过的两个时间领域(处理时间和事件时间)基本上是我们关心的两个领域。分窗在这两个领域都是有意义的,所以让我们详细看看每个领域,并看看它们有何不同。因为按处理时间分窗在历史上更常见,我们将从那里开始。
按处理时间分窗
按处理时间分窗时,系统基本上会将传入的数据缓冲到窗口中,直到经过一定的处理时间。例如,在五分钟的固定窗口的情况下,系统会缓冲五分钟的处理时间的数据,之后将把在这五分钟内观察到的所有数据视为一个窗口,并将它们发送到下游进行处理。

图 1-9。按处理时间分窗到固定窗口。数据根据它们在管道中到达的顺序被收集到窗口中。
按处理时间分窗有一些不错的特性:
-
这很简单。实现非常简单,因为你永远不用担心在时间内对数据进行洗牌。当窗口关闭时,你只需按照它们到达的顺序缓冲数据并将它们发送到下游。
-
判断窗口的完整性是直截了当的。因为系统完全知道窗口的所有输入是否都已被看到,它可以对是否给定窗口完整做出完美的决定。这意味着在按处理时间分窗时,无需以任何方式处理“延迟”数据。
-
如果您想推断关于源在观察到的时刻的信息,按处理时间分窗正是您想要的。许多监控场景属于这一类。想象一下跟踪发送到全球规模网络服务的每秒请求的数量。计算这些请求的速率以便检测故障是按处理时间分窗的完美用途。
好处是一回事,但按处理时间分窗有一个非常大的缺点:如果所讨论的数据与事件时间相关联,那么如果处理时间窗口要反映这些事件实际发生的时间,这些数据必须按事件时间顺序到达。不幸的是,在许多真实世界的分布式输入源中,按事件时间排序的数据并不常见。
举个简单的例子,想象一下任何收集使用统计信息以供以后处理的移动应用程序。对于给定移动设备在任何时间段内离线的情况(短暂的连接丢失,飞越国家时的飞行模式等),在该期间记录的数据直到设备再次联机才会上传。这意味着数据可能会出现几分钟、几小时、几天、几周甚至更长的事件时间偏移。当按处理时间分窗时,基本上不可能从这样的数据集中得出任何有用的推断。
例如,许多分布式输入源在整个系统健康时可能看起来提供了按事件时间排序(或非常接近)的数据。不幸的是,当输入源在健康状态下事件时间偏移较低时,并不意味着它会一直保持在这种状态。考虑一个全球服务,处理在多个大陆上收集的数据。如果跨大陆线路上的网络问题(可悲的是,这种情况出奇地常见)进一步降低带宽和/或增加延迟,突然之间,部分输入数据的偏移可能比以前大得多。如果您按处理时间对这些数据进行窗口处理,那么您的窗口将不再代表实际发生在其中的数据;相反,它们代表事件到达处理管道时的时间窗口,这是一些旧数据和当前数据的任意混合。
在这两种情况下,我们真正想要的是根据事件时间对数据进行窗口处理,以便能够抵御事件到达顺序的影响。我们真正想要的是事件时间窗口。
按事件时间窗口化
当您需要以反映事件实际发生时间的有限块观察数据源时,事件时间窗口是您使用的窗口处理方式。这是窗口处理的黄金标准。在 2016 年之前,大多数使用的数据处理系统都缺乏对其的本地支持(尽管具有良好一致性模型的任何系统,如 Hadoop 或 Spark Streaming 1.x,都可以作为构建此类窗口处理系统的合理基础)。我很高兴地说,今天的世界看起来非常不同,从 Flink 到 Spark 再到 Storm 和 Apex,多个系统都原生支持某种形式的事件时间窗口处理。
图 1-10 显示了将无界数据源窗口化为一小时固定窗口的示例。

图 1-10。按事件时间窗口化为固定窗口。数据根据发生时间被收集到窗口中。黑色箭头指出了到达处理时间窗口的示例数据,这些数据与它们所属的事件时间窗口不同。
图 1-10 中的黑色箭头指出了两个特别有趣的数据片段。每个片段都到达了与其所属的事件时间窗口不匹配的处理时间窗口。因此,如果这些数据被按处理时间窗口化,用于关注事件时间的用例的计算结果将是不正确的。正如您所期望的那样,事件时间的正确性是使用事件时间窗口的一个好处。
事件时间窗口在无界数据源上的另一个好处是,您可以创建动态大小的窗口,例如会话,而无需在固定窗口上生成会话时观察到的任意拆分(如我们在“无界数据:流式处理”中看到的会话示例中所示),如图 1-11 所示。

图 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 说明了输入数据的静态图的样子。

图 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 所示。

图 2-2。转换的类型
对于我们的示例,我们通常假设我们从预加载的PCollection<KV<Team, Integer>>(即由Teams和Integers组成的PCollection,其中Teams只是表示团队名称的Strings,而Integers是相应团队中任何个人的得分)开始(例如,从 I/O 源读取原始数据(例如,日志记录)并将其转换为PCollection<KV<Team, Integer>>)。为了在第一个示例中更清晰,我包含了所有这些步骤的伪代码,但在后续示例中,我省略了 I/O 和解析。
因此,对于一个简单地从 I/O 源读取数据,解析团队/得分对,并计算得分的每个团队的管道,我们将会得到类似于示例 2-1 中所示的内容。
示例 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 中所示的样子。
<assets/stsy_0203.mp4>

图 2-3. 经典批处理
因为这是一个批处理管道,它会累积状态,直到看到所有的输入(由顶部的虚线绿线表示),然后产生 48 的单个输出。在这个示例中,我们计算了所有事件时间的总和,因为我们还没有应用任何特定的窗口处理转换;因此,状态和输出的矩形覆盖了整个 x 轴。然而,如果我们想要处理无界数据源,经典的批处理就不够了;我们不能等待输入结束,因为它实际上永远不会结束。我们想要的概念之一是窗口处理,我们在第一章中介绍过。因此,在我们的第二个问题的背景下——“在事件时间中结果是在哪里计算的?”——我们现在将简要回顾一下窗口处理。
Where: 窗口处理
如第一章所讨论的,窗口化是沿着时间边界切分数据源的过程。常见的窗口化策略包括固定窗口、滑动窗口和会话窗口,如图 2-4 所示。

图 2-4。示例窗口策略。每个示例都显示了三个不同的键,突出了对齐窗口(适用于所有数据)和不对齐窗口(适用于数据子集)之间的差异。
为了更好地了解窗口化在实践中的样子,让我们将整数求和管道窗口化为固定的两分钟窗口。使用 Beam,只需简单地添加一个Window.into转换,如示例 2-2 中所示。
示例 2-2。窗口化求和代码
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES)))
.apply(Sum.integersPerKey());
回想一下,Beam 提供了一个统一的模型,可以在批处理和流处理中同时工作,因为语义上批处理实际上只是流处理的一个子集。因此,让我们首先在批处理引擎上执行此管道;机制更加直接,而且在切换到流处理引擎时,可以直接进行对比。图 2-5 呈现了结果。
<assets/stsy_0205.mp4>

图 2-5。批处理引擎上的窗口化求和
与以前一样,输入在状态中累积,直到完全消耗,然后产生输出。但是,在这种情况下,我们不是得到一个输出,而是得到四个输出:一个输出,分别对应四个相关的两分钟事件时间窗口。
到目前为止,我们已经重新讨论了我在第一章介绍的两个主要概念:事件时间和处理时间域之间的关系,以及窗口化。如果我们想进一步,我们需要开始添加本节开头提到的新概念:触发器、水印和累积。
转向流处理:When和How
我们刚刚观察了批处理引擎上的窗口化管道的执行。但是,理想情况下,我们希望结果的延迟更低,并且我们还希望原生地处理无界数据源。切换到流处理引擎是朝着正确方向迈出的一步,但是我们以前的策略等待输入完全被消耗才生成输出的做法不再可行。这时就需要触发器和水印。
When:触发器的奇妙之处在于触发器是奇妙的东西!
触发器提供了对问题的答案:“When在处理时间中何时生成结果?”触发器声明在处理时间中窗口的输出应该发生的时间(尽管触发器本身可能基于在其他时间域中发生的事情做出这些决定,比如随着事件时间域中的水印进展,我们马上就会看到)。窗口的每个具体输出被称为窗口的窗格。
虽然可以想象出各种可能的触发语义,³但在概念上,通常只有两种通用的有用触发类型,实际应用几乎总是使用其中一种或两种的组合:
重复更新触发器
这些会定期为窗口生成更新的窗格,随着其内容的演变。这些更新可以随着每个新记录的到来而实现,也可以在一定的处理时间延迟后发生,比如每分钟一次。重复更新触发器的周期选择主要是在平衡延迟和成本方面的考量。
完整性触发器
这些在认为窗口的输入完全到达某个阈值后才为窗口生成一个窗格。这种类型的触发器最类似于我们在批处理中熟悉的:只有在输入完成后才提供结果。触发器方法的不同之处在于完整性的概念仅限于单个窗口的上下文范围,而不总是与整个输入的完整性绑定。
重复更新触发器是流式系统中最常见的触发器类型。它们易于实现和理解,并为特定类型的用例提供有用的语义:对材料化数据集的重复(并最终一致)更新,类似于数据库世界中材料化视图的语义。
完整性触发器并不经常遇到,但提供了更接近经典批处理世界的流语义。它们还提供了用于推理诸如缺失数据和延迟数据之类的工具,我们很快会讨论(并在下一章中)当我们探索驱动完整性触发器的基础原语:水印。
但首先,让我们从简单的开始,看看一些基本的重复更新触发器的实际操作。为了使触发器的概念更加具体,让我们继续向我们的示例管道添加最简单类型的触发器:随着每条新记录的触发,如例 2-3 所示。
例 2-3。重复触发每条记录
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AfterCount(1))));
.apply(Sum.integersPerKey());
如果我们在流式引擎上运行这个新的管道,结果会看起来像图 2-6 所示。
<assets/stsy_0206.mp4>

图 2-6。在流式引擎上按记录触发
您可以看到我们现在为每个窗口获得多个输出(窗格):每个输入对应一次。当输出流被写入某种表格时,这种触发模式效果很好,您可以简单地轮询结果。无论何时查看表格,您都会看到给定窗口的最新值,并且这些值随着时间的推移会趋向于正确。
按记录触发的一个缺点是它非常啰嗦。在处理大规模数据时,像求和这样的聚合提供了一个很好的机会,可以减少流的基数而不丢失信息。这在您有高容量键的情况下尤为明显;例如,我们的例子中有很多活跃玩家的大型团队。想象一下一个大型多人游戏,玩家被分成两个派别,您想要按派别统计数据。可能不需要在给定派别的每个玩家的每条新输入记录后更新您的统计数据。相反,您可能会在一定的处理时间延迟后,比如每秒或每分钟,更新它们。使用处理时间延迟的一个好处是它对高容量键或窗口具有均衡效果:结果流最终会在基数方面更加均匀。
触发器中有两种不同的处理时间延迟方法:对齐延迟(其中延迟将处理时间划分为与键和窗口对齐的固定区域)和未对齐延迟(其中延迟相对于给定窗口内观察到的数据)。具有未对齐延迟的管道可能看起来像例 2-4,其结果如图 2-7 所示。
例 2-4。在对齐的两分钟处理时间边界上触发
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AlignedDelay(TWO_MINUTES)))
.apply(Sum.integersPerKey());
<assets/stsy_0207.mp4>

图 2-7。两分钟对齐延迟触发器(即,微批处理)
这种对齐延迟触发实际上就是您从像 Spark Streaming 这样的微批处理流系统中获得的。它的好处在于可预测性;您可以同时获得所有修改窗口的定期更新。这也是它的缺点:所有更新同时发生,这导致了经常需要更大的峰值预配来正确处理负载的工作负载。另一种选择是使用未对齐延迟。这在 Beam 中可能看起来像例 2-5。图 2-8 呈现了结果。
例 2-5。在未对齐的两分钟处理时间边界上触发
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(UnalignedDelay(TWO_MINUTES))
.apply(Sum.integersPerKey());
<assets/stsy_0208.mp4>

图 2-8. 两分钟不对齐延迟触发器
将图 2-8 中的不对齐延迟与图 2-6 中的对齐延迟进行对比,很容易看出不对齐延迟如何在时间上更均匀地分布负载。对于任何给定窗口涉及的实际延迟在这两种情况下有所不同,有时更多,有时更少,但最终平均延迟基本上保持不变。从这个角度来看,不对齐延迟通常是大规模处理的更好选择,因为它会导致负载在时间上更均匀地分布。
重复更新触发器非常适用于我们只是希望定期更新结果并且可以接受这些更新朝着正确性收敛而没有明确指示何时达到正确性的用例。然而,正如我们在第一章中讨论的那样,分布式系统的种种变数经常导致事件发生的时间和管道实际观察到事件的时间之间存在不同程度的偏差,这意味着很难推断输出何时呈现出准确和完整的输入数据视图。对于输入完整性很重要的情况,有一种推理完整性的方式是很重要的,而不是盲目地相信计算结果,无论哪个数据子集恰好已经传递到管道中。这就是水印的作用。
何时:水印
水印是对问题“何时在处理时间中结果实现?”的支持方面。水印是事件时间域中输入完整性的时间概念。换句话说,它们是系统相对于正在处理的事件流中记录的事件时间的进度和完整性的方式(有界或无界的情况下它们的用处更加明显)。
回想一下第一章中的这个图表,在图 2-9 中稍作修改,我描述了事件时间和处理时间之间的偏差,对于大多数实际的分布式数据处理系统来说,这是一个随时间不断变化的函数。

图 2-9. 事件时间进度、偏差和水印
我声称代表现实的那条蜿蜒的红线本质上就是水印;它捕捉了事件时间完整性随着处理时间的进展而变化。在概念上,您可以将水印视为一个函数,F(P) → E,它接受一个处理时间点并返回一个事件时间点。⁴ 事件时间点E是系统认为所有事件时间小于E的输入都已被观察到的点。换句话说,这是一个断言,即再也不会看到事件时间小于E的数据。根据水印的类型,完美或启发式,这个断言可以是严格的保证或是一个有根据的猜测。
完美水印
对于我们完全了解所有输入数据的情况,可以构建完美水印。在这种情况下,不存在延迟数据;所有数据都是提前或准时的。
启发式水印
对于许多分布式输入源,完全了解输入数据是不切实际的,因此提供启发式水印是下一个最佳选择。启发式水印使用有关输入的任何可用信息(分区、分区内的排序(如果有)、文件的增长率等)来提供尽可能准确的进度估计。在许多情况下,这些水印的预测可以非常准确。即便如此,使用启发式水印意味着它有时可能是错误的,这将导致延迟数据。我们很快会向您展示处理延迟数据的方法。
因为它们提供了相对于我们的输入的完整性概念,水印构成了先前提到的第二种触发器的基础:完整性触发器。水印本身是一个迷人而复杂的话题,当你看到 Slava 在第三章中深入研究水印时,你会发现这一点。但现在,让我们通过更新我们的示例管道来利用建立在水印之上的完整性触发器来看看它们的作用,就像在示例 2-6 中演示的那样。
示例 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。在具有完美(左)和启发式(右)水印的流处理引擎上的窗口求和
缺失数据用例的一个很好的例子是外连接。如果没有像水印这样的完整性概念,你怎么知道何时放弃并发出部分连接,而不是继续等待该连接完成?你不知道。基于处理时间延迟做出决定的方式,这是缺乏真正水印支持的流处理系统的常见方法,这不是一个安全的方式,因为我们在第一章中讨论过的事件时间偏移的可变性:只要偏移保持小于所选的处理时间延迟,你的缺失数据结果将是正确的,但是一旦偏移超过了该延迟,它们将突然变得不正确。从这个角度来看,事件时间水印对于许多必须推理输入数据缺失的真实世界流处理用例(如外连接、异常检测等)是一个关键的拼图。现在,话虽如此,这些水印示例也突显了水印(以及任何其他完整性概念)的两个缺点,具体来说,它们可能是以下两种情况之一:太慢当任何类型的水印由于已知未处理的数据(例如,由于网络带宽限制而缓慢增长的输入日志)而被正确延迟时,如果水印的推进是你唯一依赖于刺激结果的因素,那么这直接转化为输出的延迟。这在图 2-10 的左侧图中最为明显,晚到的 9 会阻碍所有后续窗口的水印,即使这些窗口的输入数据较早就变得完整。对于第二个窗口,12:02, 12:04),从窗口中的第一个值出现到我们看到窗口的任何结果几乎需要七分钟。在这个示例中,启发式水印并没有遭受同样严重的问题(五分钟直到输出),但不要认为启发式水印永远不会遭受水印滞后的问题;这实际上只是我选择在这个特定示例中省略的记录的结果。这里的重要一点是:尽管水印提供了一个非常有用的完整性概念,但依赖完整性来产生输出通常从延迟的角度来看并不理想。想象一下一个包含有价值的指标的仪表板,按小时或天进行窗口化。你不太可能希望等到整整一个小时或一天才开始看到当前窗口的结果;这是使用经典批处理系统来支持这样的系统的痛点之一。相反,随着输入的演变和最终变得完整,看到这些窗口的结果随着时间的推移而不断完善会更好。太快当启发式水印比它应该提前推进时,事件时间早于水印的数据可能会在之后的某个时间到达,从而产生延迟数据。这就是右侧示例中发生的情况:水印在第一个窗口结束之前推进,而该窗口的所有输入数据尚未被观察到,导致输出值不正确,而不是 14。这个缺点严格来说是启发式水印的问题;它们的启发式本质意味着它们有时会出错。因此,如果你关心正确性,仅仅依赖它们来确定何时产生输出是不够的。在第一章中,我对完整性概念不足以满足大多数需要对无界数据流进行强大的乱序处理的用例做出了一些非常强调的陈述。这两个缺点——水印太慢或太快——是这些论点的基础。你简单地无法从完整性概念的系统中同时获得低延迟和正确性。因此,对于那些希望兼顾两全的情况,一个人该怎么办呢?如果重复更新触发器提供了低延迟更新但无法推理完整性,水印提供了完整性概念但变化和可能的高延迟,为什么不将它们的力量结合起来呢?
何时:早期/准时/延迟触发器
胜利!我们现在已经看过了两种主要类型的触发器:重复更新触发器和完整性/水印触发器。在许多情况下,它们单独都不足够,但它们的组合是。Beam 通过提供标准水印触发器的扩展来认识到这一事实,该扩展还支持水印两侧的重复更新触发。这被称为早期/准时/延迟触发器,因为它将由复合触发器实现的窗格分为三类:+ 零个或多个早期窗格,这是重复更新触发器的结果,它会定期触发,直到水印通过窗口的末尾。这些触发产生的窗格包含推测结果,但允许我们观察随着新的输入数据到达,窗口随时间的演变。这弥补了水印有时会太慢的缺点。
-
一个准时窗格,这是完整性/水印触发器在水印通过窗口的末尾后触发的结果。这种触发是特殊的,因为它提供了一个断言,即系统现在认为这个窗口的输入是完整的。这意味着现在可以推断缺失数据;例如,在执行外连接时发出部分连接。
-
零个或多个迟到窗格,这是另一个(可能不同的)重复更新触发器的结果,它会定期触发,任何迟到数据到达后,水印已经通过窗口的末尾。在完美的水印情况下,将始终没有迟到窗格。但在启发式水印的情况下,水印未能正确计算的任何数据都将导致迟到触发。这弥补了水印太快的缺点。
让我们看看这在实际中是什么样子。我们将更新我们的管道,使用周期性的处理时间触发器,早期触发的对齐延迟为一分钟,迟到触发的每条记录触发。这样,早期触发将为我们的高吞吐量窗口提供一定量的批处理(由于触发器每分钟只触发一次,不管窗口中的吞吐量如何),但我们不会为迟到触发引入不必要的延迟,如果我们使用一个相当准确的启发式水印,迟到触发应该是相当罕见的。在 Beam 中,这看起来像例 2-7(图 2-11 显示了结果)。
例 2-7。通过早期/准时/迟到 API 进行早期、准时和迟到触发
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());
<assets/stsy_0211.mp4>
在具有早期、准时和迟到触发的流引擎上的窗口求和

图 2-11。在具有早期、准时和迟到触发的流引擎上的窗口求和
这个版本比图 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 中添加一个一分钟的延迟地平线(请注意,这个特定的地平线之所以被选择,纯粹是因为它在图中很好地适应;对于实际用例,一个更大的地平线可能会更实用):
示例 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 到达的时间超过了延迟地平线,因此被简单地丢弃。
<assets/stsy_0212.mp4>

图 2-12。具有早期/准时/延迟触发的允许延迟
关于延迟时间范围的两个最终注意事项:
-
要非常清楚,如果你恰好从具有完美水印的数据源中获取数据,就不需要处理延迟数据,允许的延迟时间为零秒将是最佳的。这就是我们在图 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 进行的更改:
示例 2-9。流引擎上早期/及时/延迟触发的丢弃模式版本
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 所示的输出。
<assets/stsy_0213.mp4>

图 2-13。流引擎上早期/及时/延迟触发的丢弃模式版本
尽管输出的整体形状与图 2-11 中的累积模式版本相似,但请注意,这个丢弃版本中没有任何窗格重叠。因此,每个输出都是独立的。
如果我们想看撤回的运行情况,更改将是类似的,如示例 2-10 所示。???描述了结果。
示例 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());
<assets/stsy_0214.mp4>
流引擎上早期/及时/延迟触发的累积和撤回模式版本
因为每个窗口的窗格都重叠,清楚地看到撤回有点棘手。撤回用红色表示,与重叠的蓝色窗格结合在一起,产生了略带紫色的颜色。我还稍微水平移动了给定窗格内的两个输出的值(并用逗号分隔),以便更容易区分它们。
图 2-14 结合了图 2-9、2-11(仅启发式)和并排的最终帧,提供了三种模式的良好视觉对比。

图 2-14。累积模式的并排比较
正如你所想象的,按照呈现的顺序(丢弃、累积、累积和撤回)的模式在存储和计算成本方面是逐渐增加的。因此,累积模式的选择为在正确性、延迟和成本的轴上进行权衡提供了另一个维度。
摘要
通过本章的学习,您现在了解了强大的流处理的基础知识,并准备好进入世界,做出惊人的成就。当然,还有八章等着您去关注,所以希望您不要马上就出发,就在这一刻。但无论如何,让我们回顾一下我们刚刚涉及的主要概念:
事件时间与处理时间
事件发生的时间和数据处理系统观察到它们的时间之间的重要区别。
窗口
通过沿着时间边界(无论是处理时间还是事件时间,尽管在 Beam 模型中我们将窗口的定义缩小为仅在事件时间内)切分无界数据的常用方法。
触发器
用于准确指定何时对于特定用例来说输出的实现是有意义的声明性机制。
水印
事件时间中进展的强大概念,提供了一种推理完整性(因此缺失数据)的方式,用于处理无界数据的无序处理系统。
累积
对于单个窗口结果的改进与其多次实现时的情况之间的关系。
其次,我们用来构建我们探索的四个问题:
-
什么结果被计算?=转换。
-
在事件时间中计算结果的位置?=窗口。
-
在处理时间中结果何时实现?=触发器加水印。
-
如何结果的改进相关?=累积。
第三,为了强调这种流处理模型所提供的灵活性(因为归根结底,这才是关键:平衡正确性、延迟和成本等竞争张力),我们能够通过最少量的代码更改在相同数据集上实现的输出的主要变化的回顾:
| |
整数求和 例 2-1 / 图 2-3 |
整数求和 固定窗口批处理
例 2-2 / 图 2-5 |
整数求和 固定窗口流
每条记录重复触发
例 2-3 / 图 2-6 |
| |
整数求和 固定窗口流
重复对齐延迟触发
例 2-4 / 图 2-7 |
整数求和 固定窗口流
重复不对齐延迟触发
例 2-5 / 图 2-8 |
整数求和 固定窗口流
启发式水印触发
例 2-6 / 图 2-10 |
| |
整数求和 固定窗口流
早期/准时/延迟触发
丢弃
例 2-9 / 图 2-13 |
整数求和 固定窗口流
早期/准时/延迟触发
累积
例 2-7 / 图 2-11 |
整数求和 固定窗口流
早期/准时/延迟触发
累积和撤销
例 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。随着时间的推移,更多的消息将被添加到右侧的“在途”分布中,来自“在途”部分的更多消息将被完成并移动到“已完成”分布中。
<assets/stsy_0301.mp4>

图 3-1。流水线中在途和已完成消息事件时间的分布。新消息作为输入到达,并保持“在途”,直到完成处理。在任何给定时刻,“在途”分布的最左边缘对应于最老的未处理元素。
在这个分布上有一个关键点,位于“在途”分布的最左边缘,对应于我们管道中任何未处理消息的最老事件时间戳。我们使用这个值来定义水印:
水印是最老的尚未完成工作的单调¹递增时间戳。
这个定义提供了两个基本属性,使其有用:
完整性
如果水印已经超过某个时间戳T,我们可以通过其单调性质保证,不会再对T时刻或之前的准时(非延迟数据)事件进行处理。因此,我们可以正确地发出T时刻或之前的任何聚合。换句话说,水印允许我们知道何时正确关闭一个窗口。
可见性
如果由于任何原因消息在我们的管道中卡住,水印就无法前进。此外,我们将能够通过检查阻止水印前进的消息来找到问题的源头。
源水印创建
这些水印是从哪里来的?要为数据源建立水印,我们必须为从该源进入管道的每条消息分配一个逻辑事件时间戳。正如第二章所告诉我们的那样,所有水印创建都属于两种广泛的类别之一:完美或启发式。为了提醒自己完美水印和启发式水印之间的区别,让我们看一下第二章中的窗口求和示例的图 3-2。
<assets/stsy_0302.mp4>

图 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-3。流系统阶段的示例系统组件,包含正在传输的数据的缓冲区。每个缓冲区都将有相关的水印跟踪,阶段的整体输出水印将是所有这些缓冲区的水印的最小值。
我们可以跟踪每个缓冲区及其自己的水印。每个阶段的缓冲区中的水印的最小值形成了该阶段的输出水印。因此,输出水印可以是以下内容的最小值:
-
每个发送阶段都有一个水印。
-
每个外部输入都有一个水印,用于管道外部的来源
-
每种类型的状态组件都有一个水印,可以写入
-
每个接收阶段都有一个输出缓冲区的水印
在这个粒度级别提供水印还可以更好地了解系统的行为。水印跟踪系统中各种缓冲区中消息的位置,从而更容易诊断卡住的情况。
理解水印传播
为了更好地了解输入和输出水印之间的关系以及它们如何影响水印传播,让我们来看一个例子。让我们考虑游戏得分,但我们不是计算团队得分的总和,而是试图衡量用户参与水平。我们将首先根据每个用户的会话长度来计算,假设用户与游戏保持参与的时间是他们享受游戏程度的合理代理。在回答我们的四个问题一次以计算会话长度后,我们将再次回答这些问题,以计算固定时间段内的平均会话长度。
为了使我们的例子更有趣,假设我们正在使用两个数据集,一个用于移动得分,一个用于主机得分。我们希望通过整数求和并行计算这两个独立数据集的相同得分。一个管道正在计算使用移动设备玩游戏的用户的得分,而另一个管道是为在家庭游戏主机上玩游戏的用户计算得分,可能是因为为不同平台采用了不同的数据收集策略。重要的是,这两个阶段执行相同的操作,但是针对不同的数据,因此输出水印也会有很大的不同。
首先,让我们看一下示例 3-1,看看这个管道的第一部分的缩写代码可能是什么样子。
示例 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。
<assets/stsy_0304.mp4>

图 3-4。两个不同输入管道中的每个用户会话长度
因为我们需要跟踪跨多个阶段的数据,我们在图中用红色跟踪与移动得分相关的所有内容,用蓝色跟踪与主机得分相关的所有内容,而图 3-5 中的水印和输出是黄色的。
我们已经回答了计算个人会话长度的“什么”、“哪里”、“何时”和“如何”的四个问题。接下来我们将再次回答这些问题,将这些会话长度转换为一天内固定时间窗口内的全局会话长度平均值。这要求我们首先将两个数据源展平为一个,然后重新分配到固定窗口;我们已经捕捉到了我们计算的会话长度值的重要本质,现在我们想要在一天内的一致时间窗口内计算这些会话的全局平均值。示例 3-2 展示了这个代码。
示例 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。与以前一样,两个输入管道正在计算移动和控制台玩家的个人会话长度。然后这些会话长度进入管道的第二阶段,在那里在固定窗口中计算全局会话长度平均值。
<assets/stsy_0305.mp4>

图 3-5。移动和控制台游戏会话的平均会话长度
让我们仔细研究一些例子,因为这里有很多事情要做。这里的两个重要点是:
-
移动会话和控制台会话阶段的输出水印至少与每个对应的输入水印一样旧,实际上可能稍微更旧一些。这是因为在真实系统中,计算答案需要时间,我们不允许输出水印在给定输入的处理完成之前提前。
-
平均会话长度阶段的输入水印是直接上游两个阶段的输出水印的最小值。
结果是下游输入水印是上游输出水印的最小组合的别名。请注意,这与本章前面对这两种水印类型的定义相匹配。还要注意,下游的水印会更早一些,捕捉到上游阶段在时间上领先于其后续阶段的直观概念。
这里值得注意的一点是,我们在示例 3-1 中再次提出问题,从而大幅改变了管道的结果。以前我们只是计算每个用户的会话长度,现在我们计算两分钟的全局会话长度平均值。这提供了对玩家行为的更深入了解,并让你略微窥见简单数据转换和真正数据科学之间的差异。
更好的是,现在我们了解了这个管道运作的基本原理,我们可以更仔细地看待与再次提出四个问题相关的一个更微妙的问题:输出时间戳。
水印传播和输出时间戳
在图 3-5 中,我忽略了一些输出时间戳的细节。但是如果你仔细看图中的第二阶段,你会发现第一阶段的每个输出都被分配了一个与其窗口结束时间相匹配的时间戳。尽管这是一个相当自然的输出时间戳选择,但并不是唯一有效的选择。然而,在实践中,大多数情况下只有几种选择是有意义的:
窗口结束⁴
如果您希望输出时间戳代表窗口边界,那么使用窗口的结束是唯一安全的选择。正如我们将在一会儿看到的,这也是所有选项中水印进展最顺畅的选择。
第一个非延迟元素的时间戳
当您希望尽可能保守地保持水印时,使用第一个非延迟元素的时间戳是一个不错的选择。然而,折衷之处在于水印的进展可能会受到更大的阻碍,我们很快也会看到。
特定元素的时间戳
对于某些用例,某些其他任意(从系统角度看)元素的时间戳是正确的选择。想象一种情况,您正在将查询流与该查询结果的点击流进行连接。在执行连接后,某些系统会发现查询的时间戳更有用;其他人会更喜欢点击的时间戳。只要它对应于未延迟到达的元素,任何这样的时间戳都是从水印正确性的角度来看是有效的。
在考虑一些替代的输出时间戳选项后,让我们看看输出时间戳选择对整个流水线的影响。为了使变化尽可能显著,在示例 3-3 和图 3-6 中,我们将切换到使用窗口的最早时间戳:第一个非延迟元素的时间戳作为窗口的时间戳。
示例 3-3。会话窗口输出时间戳设置为最早元素的平均会话长度流水线
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());
<assets/stsy_0306.mp4>

图 3-6。在最早元素的时间戳输出的会话长度的平均值
为了突出输出时间戳选择的影响,请看第一阶段虚线显示的每个阶段输出水印被保持的情况。与图 3-7 和 3-8 相比,输出水印由于我们选择的时间戳而延迟,而在图 3-7 和 3-8 中,输出时间戳被选择为窗口的结束。从这个图表中可以看出,第二阶段的输入水印也因此被延迟。

图 3-7。不同窗口输出时间戳选择的水印和结果的比较。此图中的水印对应于会话窗口的结束时间戳(即图 3-5)。

图 3-8。在这个图中,水印位于会话窗口的开始位置(即图 3-6)。我们可以看到这个图中的水印线更加延迟,导致平均会话长度也不同。
与图 3-7 相比,这个版本的差异有两点值得注意:
水印延迟
与图 3-5 相比,图 3-6 中的水印进展要慢得多。这是因为第一阶段的输出水印被保持到每个窗口的第一个元素的时间戳,直到该窗口的输入变得完整为止。只有在给定窗口被实现后,输出水印(因此下游输入水印)才被允许前进。
语义差异
因为会话时间戳现在被分配为与会话中最早的非延迟元素相匹配,所以当我们在下一个阶段计算会话长度平均值时,个别会话通常会落入不同的固定窗口桶中。迄今为止,我们所看到的两种选择都没有固有的对错之分;它们只是不同而已。但重要的是要理解它们将是不同的,以及它们将以何种方式不同,这样当时机到来时,您就可以为您的特定用例做出正确的选择。
重叠窗口的棘手情况
关于输出时间戳的另一个微妙但重要的问题是如何处理滑动窗口。将输出时间戳设置为最早元素的朴素方法很容易导致下游由于水印被(正确地)阻止而出现延迟。为了理解原因,考虑一个具有两个阶段的示例管道,每个阶段都使用相同类型的滑动窗口。假设每个元素最终出现在三个连续的窗口中。随着输入水印的推进,这种情况下滑动窗口的期望语义如下:
-
第一个窗口在第一个阶段完成并向下游发出。
-
然后第一个窗口在第二阶段完成并且也可以向下游发出。
-
一段时间后,第二个窗口在第一个阶段完成...等等。
然而,如果选择输出时间戳为窗格中第一个非延迟元素的时间戳,实际发生的是:
-
第一个窗口在第一个阶段完成并向下游发出。
-
第二阶段的第一个窗口仍然无法完成,因为其输入水印被上游第二和第三个窗口的输出水印阻止。这些水印被正确地阻止,因为最早的元素时间戳被用作这些窗口的输出时间戳。
-
第二个窗口在第一个阶段完成并向下游发出。
-
第二阶段的第一个和第二个窗口仍然无法完成,被上游的第三个窗口阻塞。
-
第三个窗口在第一个阶段完成并向下游发出。
-
第二阶段的第一个、第二个和第三个窗口现在都能够完成,最终一次性发出所有三个窗口。
尽管这种窗口的结果是正确的,但这导致结果以不必要的延迟方式实现。因此,Beam 对重叠窗口有特殊逻辑,确保窗口N+1 的输出时间戳始终大于窗口N的结束时间。
百分位水印
到目前为止,我们关注的是水印,即在一个阶段中活动消息的最小事件时间所测量的。跟踪最小值允许系统知道何时已经考虑了所有更早的时间戳。另一方面,我们可以考虑活动消息的事件时间的整个分布,并利用它来创建更精细的触发条件。
与其考虑分布的最小点,我们可以取分布的任何百分位,并说我们保证已处理了这个百分比的所有具有更早时间戳的事件。⁵
这种方案的优势是什么?如果对于业务逻辑来说,“大多数情况下”正确就足够了,百分位水印提供了一种机制,使水印可以比跟踪最小事件时间更快、更平滑地前进,通过从水印中丢弃分布长尾中的异常值。图 3-9 显示了一个紧凑的事件时间分布,其中 90%百分位水印接近于 100%百分位。图 3-10 展示了一个异常值落后的情况,因此 90%百分位水印明显领先于 100%百分位。通过从水印中丢弃异常值数据,百分位水印仍然可以跟踪分布的大部分,而不会被异常值延迟。

图 3-9。看起来正常的水印直方图

图 3-10。带有异常值的水印直方图
图 3-11 显示了使用百分位水印来绘制两分钟固定窗口的窗口边界的示例。我们可以根据已到达数据的时间戳的百分位来绘制早期边界,由百分位水印跟踪。
<assets/stsy_0310.mp4>
水印百分位数变化的影响。随着百分位数的增加,窗口中包含的事件也会增加:然而,实现窗口的处理时间延迟也会增加。
图 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-13。处理时间水印也在增加。这表明系统处理被延迟了。
在第一种情况(图 3-12)中,当我们检查处理时间水印延迟时,我们看到它也在增加。这告诉我们系统中的一个操作卡住了,并且这种卡住也导致数据延迟落后。在现实世界中,可能发生这种情况的一些例子是网络问题阻止了管道各阶段之间的消息传递,或者发生了故障并且正在重试。通常,增长的处理时间水印表明存在一个问题,阻止了对系统功能必要的操作的完成,并且通常需要用户或管理员干预来解决。
在第二种情况中,如图 3-14 所示,处理时间水印延迟很小。这告诉我们没有卡住的操作。事件时间水印延迟仍在增加,这表明我们有一些缓冲状态正在等待排放。例如,如果我们在等待窗口边界发出聚合时缓冲了一些状态,这是可能的,并且对应于管道的正常操作,如图 3-15 所示。

图 3-14。事件时间水印延迟增加,处理时间水印稳定。这表明数据在系统中被缓冲并等待处理,而不是系统操作阻止数据处理完成的迹象。

图 3-15。固定窗口的水印延迟。随着每个窗口的元素被缓冲,事件时间水印延迟增加,并且随着每个窗口的聚合通过及时触发器发出,而处理时间水印只是跟踪系统级别的延迟(在健康的管道中保持相对稳定)。
因此,处理时间水印是一个有用的工具,可以区分系统延迟和数据延迟。除了可见性之外,我们还可以在系统实现级别使用处理时间水印,用于诸如临时状态的垃圾收集等任务(Reuven 在第五章中更多地讨论了一个例子)。
案例研究
现在我们已经为水印应该如何行为奠定了基础,是时候看一看一些真实系统,了解水印的不同机制是如何实现的了。我们希望这些能够揭示在现实世界系统中延迟和正确性以及可扩展性和可用性之间可能存在的权衡。
案例研究:Google Cloud Dataflow 中的水印
在流处理系统中,有许多可能的实现水印的方法。在这里,我们简要介绍了 Google Cloud Dataflow 中的实现,这是一个用于执行 Apache Beam 管道的完全托管的服务。Dataflow 包括用于定义数据处理工作流程的 SDK,以及在 Google Cloud Platform 资源上运行这些工作流程的 Cloud Platform 托管服务。
Dataflow 通过将每个数据处理步骤的数据处理图分布到多个物理工作器上,通过将每个工作器的可用键空间分割成键范围,并将每个范围分配给一个工作器来进行条纹化(分片)。每当遇到具有不同键的GroupByKey操作时,数据必须被洗牌到相应的键上。
图 3-16 描述了带有GroupByKey的处理图的逻辑表示。

图 3-16。GroupByKey 步骤从另一个 DoFn 中消耗数据。这意味着第一步的键和第二步的键之间存在数据洗牌。
图 3-17 显示了将键范围分配给工作节点的物理分配。

图 3-17。两个步骤的键范围(条纹)分配给可用的工作节点。
在水印传播部分,我们讨论了水印是如何为每个步骤的多个子组件维护的。Dataflow 跟踪每个组件的每个范围水印。然后,水印聚合涉及计算所有范围的每个水印的最小值,确保满足以下保证:
-
所有范围都必须报告水印。如果某个范围没有水印,则我们无法提前水印,因为未报告的范围必须被视为未知。
-
确保水印单调递增。由于可能存在延迟数据,如果更新水印会导致水印后退,我们就不能更新水印。
Google Cloud Dataflow 通过集中式聚合代理执行聚合。我们可以对此代理进行分片以提高效率。从正确性的角度来看,水印聚合器充当了水印的“唯一真相来源”。
在分布式水印聚合中确保正确性会带来一定的挑战。至关重要的是,水印不会过早提前,因为过早提前水印会将准时数据变成延迟数据。具体来说,当物理分配被激活到工作节点时,工作节点会对与键范围相关的持久状态维护租约,确保只有一个工作节点可以对键的持久状态进行变更。为了保证水印的正确性,我们必须确保来自工作进程的每个水印更新只有在工作进程仍然维护其持久状态的租约时才被纳入聚合;因此,水印更新协议必须考虑状态所有权租约验证。
案例研究:Apache Flink 中的水印
Apache Flink 是一个开源的流处理框架,用于分布式、高性能、始终可用和准确的数据流应用程序。可以使用 Flink 运行 Beam 程序。在这样做时,Beam 依赖于 Flink 内部的水印等流处理概念的实现。与 Google Cloud Dataflow 不同,后者通过集中式水印聚合器代理执行水印聚合,Flink 在内部执行水印跟踪和聚合。
要了解这是如何工作的,让我们看一下 Flink 管道,如图 3-18 所示。

图 3-18。一个 Flink 管道,其中有两个源和内部传播的事件时间水印
在这个管道中,数据在两个源处生成。这些源也都生成与数据流同步发送的水印“检查点”。这意味着当源 A 发出时间戳“53”的水印检查点时,它保证不会从源 A 发出时间戳在“53”之前的非延迟数据消息。下游的“keyBy”操作符消耗输入数据和水印检查点。随着新的水印检查点被消耗,下游操作符对水印的视图会被提前,并且可以为下游操作符发出新的水印检查点。
将水印检查点与数据流一起发送的选择与 Cloud Dataflow 方法不同,后者依赖于中央聚合,并导致一些有趣的权衡。
以下是内部水印的一些优势:
减少水印传播延迟,非常低延迟的水印
由于不需要水印数据在多个跳跃中传播并等待中央聚合,因此使用内部方法更容易实现非常低延迟。
水印聚合没有单点故障
中央水印聚合代理的不可用将导致整个管道中的水印延迟。采用带内方法,管道的部分不可用不能导致整个管道的水印延迟。
固有的可扩展性
尽管 Cloud Dataflow 在实践中具有良好的扩展性,但与带内水印的隐式可扩展性相比,实现具有集中式水印聚合服务的可扩展性需要更多的复杂性。
以下是带外水印聚合的一些优势:
“真相”的单一来源
对于调试、监控和其他应用(例如基于管道进度对输入进行限流),有一个可以提供水印值的服务是有利的,而不是在流中隐含水印,系统的每个组件都有自己的部分视图。
源水印创建
一些源水印需要全局信息。例如,源可能暂时空闲,数据速率低,或需要有关源或其他系统组件的带外信息来生成水印。这在中央服务中更容易实现。例如,查看接下来关于 Google Cloud Pub/Sub 源水印的案例研究。
案例研究: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-19。Pub/Sub 订阅上到达的消息的处理时间和事件时间时间戳
基本订阅上的一些消息是未确认的,形成了积压。这可能是因为它们尚未被传递,或者它们可能已经被传递但尚未被处理。还要记住,从此订阅中拉取的操作是分布在多个分片上的。因此,仅仅通过查看基本订阅,我们无法确定我们的水印应该是什么。
跟踪订阅,如图 3-20 所示,用于有效地检查基本订阅的积压,并获取积压中事件时间戳的最小值。通过在跟踪订阅上保持很少或没有积压,我们可以检查基本订阅最旧的未确认消息之前的消息。

图 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 中的紫色。您可以简单地想象,紫色示例是现实可能发生的另一种方式,如果风从东方吹来而不是从西方(即,复杂分布式系统的基础集合以稍有不同的顺序进行了一些操作)。
<assets/stsy_0401.mp4>

图 4-1。在处理时间中移动输入观察顺序,保持值和事件时间不变
事件时间窗口化
为了建立一个基准,让我们首先比较事件时间的固定窗口化和这两个观察顺序上的启发式水印。我们将重用示例 2-7/图 2-10 中的早期/晚期代码,以获得图 4-2 中显示的结果。左侧基本上是我们之前看到的;右侧是第二个观察顺序的结果。这里需要注意的重要一点是,尽管输出的整体形状不同(由于处理时间中观察的不同顺序),但四个窗口的最终结果保持不变:14、18、3 和 12。
<assets/stsy_0402.mp4>

图 4-2。在相同输入的两种不同处理时间排序上进行事件时间窗格化
通过触发器进行处理时间窗口化
现在让我们将其与刚刚描述的两种处理时间方法进行比较。首先,我们将尝试触发器方法。在以这种方式使处理时间“窗口化”方面有三个方面:
窗口化
我们使用全局事件时间窗口,因为我们实质上是用事件时间窗格模拟处理时间窗口。
触发
我们根据所需的处理时间窗口大小在处理时间域定期触发。
累积
我们使用丢弃模式使窗格彼此独立,从而让它们每个都像一个独立的处理时间“窗口”。
相应的代码看起来有点像示例 4-1;请注意,全局窗口是 Beam 中的默认设置,因此没有特定的窗口策略覆盖。
示例 4-1。通过重复丢弃全局事件时间窗格进行处理时间窗格
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。
<assets/stsy_0403.mp4>

图 4-3。通过触发器进行处理时间“窗口化”,在相同输入的两种不同处理时间排序上
通过处理时间窗口化
最后,让我们看看通过将输入数据的事件时间映射为其进入时间来实现的处理时间窗口化。在代码上,这里有四个值得一提的方面:
时间移位
当元素到达时,它们的事件时间需要被覆盖为到达时间。我们可以通过提供一个新的DoFn来在 Beam 中执行此操作,该函数通过outputWithTimestamp方法将元素的时间戳设置为当前时间。
窗口化
返回使用标准事件时间固定窗口。
触发
因为使用进入时间可以计算出完美的水印,所以我们可以使用默认触发器,在这种情况下,当水印通过窗口的结束时,触发器会隐式触发一次。
累积模式
因为每个窗口只有一个输出,所以累积模式是无关紧要的。
因此,实际代码可能看起来像示例 4-2 中的样子。
示例 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>

图 4-4。通过使用进入时间进行处理时间窗口化,对相同输入的两种不同处理时间排序
虽然看到不同的实现处理时间窗口的方式很有趣,但这里的重点是我从第一章开始一直在强调的:事件时间窗口是无序的,至少在极限情况下(直到输入变得完整之前,实际窗格可能会有所不同);处理时间窗口不是。如果你关心事件实际发生的时间,你必须使用事件时间窗口,否则你的结果将毫无意义。我现在要下台了。
何时:会话窗口
足够了解处理时间窗口化。现在让我们回到经过验证的事件时间窗口化,但现在我们要看一下我最喜欢的功能之一:动态、数据驱动的窗口,称为会话。
会话是一种特殊类型的窗口,它捕获了数据中的一段活动期间,该期间由不活动的间隙终止。它们在数据分析中特别有用,因为它们可以提供特定用户在特定时间段内参与某些活动的活动视图。这允许在会话内进行活动的相关性,根据会话的长度推断参与水平等等。
从窗口化的角度来看,会话在两个方面特别有趣:
-
它们是数据驱动窗口的一个例子:窗口的位置和大小直接取决于输入数据本身,而不是基于时间内的某些预定义模式,如固定窗口和滑动窗口。
-
它们也是不对齐窗口的一个例子;也就是说,窗口不是均匀适用于所有数据,而只适用于数据的特定子集(例如,每个用户)。这与固定和滑动窗口等对齐窗口形成对比,后者通常均匀适用于所有数据。
对于某些用例,有可能提前使用共同标识符标记单个会话中的数据(例如,发出带有服务质量信息的心跳 ping 的视频播放器;对于任何给定的观看,所有 ping 可以提前使用单个会话 ID 进行标记)。在这种情况下,会话的构建要容易得多,因为它基本上只是一种按键分组。
然而,在更一般的情况下(即,实际会话本身事先不知道的情况下),会话必须仅从数据在时间内的位置构建。处理无序数据时,这变得特别棘手。
图 4-5 显示了一个例子,其中五个独立的记录被分组到了会话窗口中,间隔超时为 60 分钟。每个记录最初都在自己的 60 分钟窗口中(原型会话)。合并重叠的原型会话产生了包含三个和两个记录的两个较大的会话窗口。

图 4-5. 未合并的原型会话窗口,以及最终合并的会话
提供一般会话支持的关键见解是,完整的会话窗口是由一组较小的重叠窗口组成的,每个窗口包含一个单独的记录,序列中的每个记录与下一个记录之间的不活动间隙不大于预定义的超时时间。因此,即使我们以无序方式观察会话中的数据,我们也可以通过简单地合并到达的任何重叠窗口来构建最终的会话。
换个角度看,考虑到我们迄今为止一直在使用的例子。如果我们指定一个一分钟的会话超时,我们期望在数据中识别出两个会话,在图 4-6 中用虚线标出。每个会话捕获了用户的一次活动,会话中的每个事件与会话中的至少一个其他事件相隔不到一分钟。

图 4-6. 我们想要计算的会话
为了看到窗口合并是如何随着事件的出现而随时间构建这些会话的,让我们看看它的实际操作。我们将使用示例 2-10 中启用了撤回的早期/晚期代码,并更新窗口以使用一分钟的间隙持续时间来构建会话。示例 4-3 说明了这是什么样子。
示例 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 所示的东西(请注意,我留下了用虚线标注的预期最终会话以供参考)。
<assets/stsy_0407.mp4>

图 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 所示。
示例 4-4. 简化的 FixedWindows 实现
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)。我们将从一个得分总和管道开始,就像我们在大多数示例中使用的那样,使用固定的两分钟窗口和单个水印触发器。
示例 4-5. 水印完整性触发器(与示例 2-6 相同)
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开始达到千百万或更多时,这种同步的突发性可能会成为问题。
<assets/stsy_0408.mp4>

图 4-8. 对齐的固定窗口
在不需要跨窗口比较的情况下,通常更希望将窗口完成负载均匀分布在时间上。这使得系统负载更可预测,可以减少处理峰值负载的需求。然而,在大多数系统中,如果系统不提供对齐的固定窗口支持,那么非对齐的固定窗口通常是不可用的。² 但是,通过自定义窗口支持,将默认的固定窗口实现修改为提供非对齐的固定窗口支持是相对简单的。我们希望继续保证所有被分组在一起的元素的窗口(即具有相同键的元素)具有相同的对齐,同时放宽对不同键之间的对齐限制。对默认的固定窗口策略进行的代码更改看起来像示例 4-6。
示例 4-6. 简化的 UnalignedFixedWindows 实现
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 所示。
示例 4-7. 具有单个水印触发器的非对齐的固定窗口
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(UnalignedFixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
然后,通过比较与之前相同数据集的不同固定窗口对齐方式,您可以在图 4-9 中看到这是什么样子(在这种情况下,我选择了两种对齐之间的最大相位移,以最清楚地突出其好处,因为在大量键上随机选择相位将产生类似的效果)。
<assets/stsy_0409.mp4>

图 4-9. 非对齐的固定窗口
请注意,我们没有同时为多个键发出多个窗格的情况。相反,窗格以更加均匀的节奏单独到达。这是另一个例子,可以在一个维度上进行权衡(跨键比较的能力),以换取另一个维度的好处(减少峰值资源配置要求),当用例允许时。当您试图尽可能高效地处理大量数据时,这种灵活性是至关重要的。
现在让我们看一下固定窗口的第二种变化,这种变化更与正在处理的数据密切相关。
每个元素/键的固定窗口
我们的第二个例子来自 Cloud Dataflow 的早期采用者之一。这家公司为其客户生成分析数据,但每个客户都可以配置其要聚合指标的窗口大小。换句话说,每个客户都可以定义其固定窗口的特定大小。
支持这样的用例并不太困难,只要可用的窗口大小数量本身是固定的。例如,您可以想象提供选择 30 分钟、60 分钟和 90 分钟固定窗口的选项,然后为每个选项运行一个单独的管道(或管道的分支)。虽然不理想,但也不太可怕。然而,随着选项数量的增加,这很快变得难以处理,在提供对真正任意窗口大小的支持的极限情况下(这正是这位客户的用例所需的),这完全是不切实际的。
幸运的是,因为客户处理的每条记录已经用描述聚合窗口所需大小的元数据进行了注释,因此支持任意的、每个用户的固定窗口大小就像从标准固定窗口实现中更改几行代码一样简单,如示例 4-8 所示。
示例 4-8。修改(和简化)支持每个元素窗口大小的 FixedWindows 实现
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 所示。
示例 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 的元素有一分钟的窗口大小。
<assets/stsy_0410.mp4>

图 4-10。每个键的自定义大小固定窗口
这确实不是您可以合理期望系统为您提供的东西;窗口大小偏好的存储方式对于尝试构建标准 API 来说太具体于用例,因此没有意义。然而,正如这位客户的需求所展示的那样,这样的用例确实存在。这就是自定义窗口提供的灵活性如此强大的原因。
会话窗口的变化
为了真正展示自定义窗口的有用性,让我们看一个最后的例子,这是会话的一个变化。会话窗口理所当然地比固定窗口更复杂。其实现包括以下内容:
分配
每个元素最初被放入一个原型会话窗口,该窗口从元素的时间戳开始,并持续一段间隔时间。
合并
在分组时,所有符合条件的窗口都被排序,然后任何重叠的窗口都被合并在一起。
会话代码的简化版本(手动从多个辅助类合并在一起)看起来像示例 4-10 中显示的样子。
示例 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(省略了我们将在这里使用的一些构建器样板)。
示例 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 中所看到的。
示例 4-12。通过早期/准时/迟到 API 进行早期、准时和迟到触发
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。
<assets/stsy_0411.mp4>

图 4-11。按键自定义大小的固定窗口
请注意,大会话的值为 36,跨越了 12:00.26, 12:05.20,或者近五分钟的时间,在无界会话实现中从[图 2-7 现在分解为两个较短的会话,长度分别为 2 分钟和 2 分钟 53 秒。
考虑到目前很少有系统提供自定义窗口支持,值得指出的是,如果要使用只支持无限会话实现的系统来实现这样的功能,将需要更多的工作。你唯一的选择是在会话分组逻辑的下游编写代码,查看生成的会话并在超过长度限制时对其进行切割。这将需要在事后对会话进行分解的能力,这将消除增量聚合的好处(我们将在第七章中更详细地讨论),增加成本。这也将消除任何希望通过限制会话长度获得的垃圾邮件保护的好处,因为会话首先需要增长到其完整大小,然后才能被切割或截断。
一刀切并不适用于所有情况
我们现在已经看到了三个真实的用例,每个用例都是数据处理系统通常提供的窗口类型的微妙变化:不对齐的固定窗口,按元素固定的窗口和有界会话。在这三种情况下,我们看到了通过自定义窗口支持这些用例是多么简单,以及如果没有它,支持这些用例将会更加困难(或昂贵)。尽管自定义窗口在行业中尚未得到广泛支持,但它是一个功能,为构建需要尽可能高效地处理大量数据的复杂真实用例的数据处理管道提供了非常需要的灵活性。
总结
高级窗口是一个复杂而多样的主题。在本章中,我们涵盖了三个高级概念:
处理时间窗口
我们看到了这与事件时间窗口的关系,指出了它固有有用的地方,并且最重要的是,通过明确强调事件时间窗口为我们提供的结果的稳定性,确定了它不适用的地方。
会话窗口
我们首次介绍了动态合并窗口策略类,并看到系统为我们提供了多么大的帮助,提供了这样一个强大的构造,你可以简单地放置在那里。
自定义窗口
在这里,我们看了三个现实世界的例子,这些例子在只提供静态一组标准窗口策略的系统中很难或不可能实现,但在具有自定义窗口支持的系统中相对容易实现:
-
不对齐的固定窗口,在使用水印触发器与固定窗口时,可以更均匀地分布输出。
-
每个元素的固定窗口,可以灵活地选择每个元素的固定窗口大小(例如,提供可定制的每个用户或每个广告活动窗口大小),以更好地定制管道语义以适应特定用例。
-
有界会话窗口,限制给定会话的增长大小;例如,用于抵消垃圾邮件尝试或对由管道实现的已完成会话的延迟设置边界。
在与 Slava 深入研究了第三章的水印,并在这里对高级窗口进行了广泛调查后,我们已经远远超出了多维度的稳健流处理的基础知识。因此,我们结束了对 Beam 模型的关注,也结束了书的第一部分。
接下来是 Reuven 的第五章,讨论一致性保证、精确一次处理和副作用,之后我们将开始进入第二部分流和表,阅读第六章。
¹ 据我所知,Apache Flink 是唯一支持自定义窗口到 Beam 所做程度的另一个系统。公平地说,由于能够提供自定义窗口逐出器的能力,其支持甚至超出了 Beam 的支持。头都快炸了。
² 我实际上并不知道目前有任何这样的系统。
³ 这自然意味着使用分组数据,但因为窗口化本质上与按键分组紧密相关,所以这个限制并不特别繁重。
⁴ 并不是关键的是元素本身知道窗口大小;你可以轻松查找并缓存所需维度的适当窗口大小;例如,每个用户。
第五章:一次性和副作用
我们现在从讨论编程模型和 API 转向实现它们的系统。模型和 API 允许用户描述他们想要计算的内容。在规模上准确地运行计算需要一个系统——通常是一个分布式系统。
在本章中,我们将重点介绍一个实现系统如何正确实现 Beam 模型以产生准确结果。流处理系统经常谈论一次性处理;也就是确保每个记录只被处理一次。我们将解释我们的意思,并介绍如何实现它。
作为一个激励性的例子,本章重点介绍了 Google Cloud Dataflow 用于有效地保证记录的一次性处理的技术。在本章末尾,我们还将介绍一些其他流行的流处理系统用于保证一次性处理的技术。
为什么一次性很重要
许多用户来说,数据处理管道中出现丢失记录或数据丢失的风险是不可接受的。即便如此,历史上许多通用流处理系统并没有对记录处理提供任何保证——所有处理都只是“尽力而为”。其他系统提供至少一次的保证,确保记录至少被处理一次,但记录可能会重复(从而导致不准确的聚合);实际上,许多这样的至少一次系统在内存中执行聚合,因此当机器崩溃时它们的聚合仍然可能会丢失。这些系统用于低延迟的、推测性的结果,但通常无法保证这些结果的真实性。
正如第一章所指出的,这导致了一个被称为Lambda 架构的策略——运行一个流处理系统以获得快速但不准确的结果。稍后(通常是在一天结束后),批处理系统运行以得到正确的答案。这只有在数据流是可重放的情况下才有效;然而,足够多的数据源都满足这一条件,这种策略被证明是可行的。尽管如此,许多尝试过这种策略的人都遇到了许多 Lambda 架构的问题:
不准确性
用户往往低估了故障的影响。他们经常假设只有少量记录会丢失或重复(通常是基于他们进行的实验),当有一天出现 10%(甚至更多!)的记录丢失或重复时,他们会感到震惊。在某种意义上,这样的系统只提供了“一半”的保证——没有完整的保证,一切皆有可能。
不一致性
用于每日计算的批处理系统通常具有与流处理系统不同的数据语义。让这两个管道产生可比较的结果的过程比最初想象的更加困难。
复杂性
根据定义,Lambda 要求您编写和维护两个不同的代码库。您还必须运行和维护两个复杂的分布式系统,每个系统都有不同的故障模式。除了最简单的管道之外,这很快就变得不堪重负。
不可预测性
在许多用例中,最终用户将看到与每日结果有不确定差异的流处理结果,这种差异可能会随机变化。在这些情况下,用户将停止信任流处理数据,而等待每日批处理结果,从而破坏了首次获得低延迟结果的价值。
延迟
一些业务用例需要低延迟的正确结果,而 Lambda 架构设计上并不提供这种功能。
幸运的是,许多 Beam 运行程序可以做得更好。在本章中,我们将解释一次流处理如何帮助用户依靠准确的结果并避免数据丢失的风险,同时依赖于单一的代码库和 API。由于一系列可能影响管道输出的问题经常被错误地与一次性保证混淆在一起,我们首先解释了在 Beam 和数据处理的上下文中,当我们提到“一次性”时,确切指的是哪些问题在范围内,哪些不在范围内。
准确性与完整性
每当 Beam 管道处理一个记录时,我们希望确保记录永远不会丢失或重复。然而,流水线的特性是有时记录会在时间窗口的聚合已经被处理后出现。Beam SDK 允许用户配置系统应该等待延迟数据的时间;任何(且仅有)晚于截止日期到达的记录都会被丢弃。这个特性有助于完整性,而不是准确性:所有及时到达的记录都会被准确处理一次,而这些延迟的记录则会被明确丢弃。
尽管延迟记录通常是在流式系统的背景下讨论的,但值得注意的是批处理管道也存在类似的完整性问题。例如,一个常见的批处理范例是在凌晨 2 点运行前一天所有数据的作业。然而,如果昨天的一些数据直到凌晨 2 点后才被收集,它就不会被批处理作业处理!因此,批处理管道也提供准确但不总是完整的结果。
副作用
Beam 和 Dataflow 的一个特点是用户可以注入自定义代码,作为他们的管道图的一部分执行。Dataflow 不保证该代码仅对每个记录运行一次,¹无论是通过流式处理还是批处理运行器。它可能会多次运行给定的记录通过用户转换,甚至可能同时在多个工作器上运行相同的记录;这是为了保证至少一次的处理在工作器故障的情况下。这些调用中只有一个可以“获胜”并在管道中产生输出。
因此,不幂等的副作用不能保证只执行一次;如果您编写的代码对外部服务具有副作用,例如联系外部服务,这些效果可能会对给定记录执行多次。这种情况通常是不可避免的,因为没有办法在 Dataflow 的处理与外部服务的副作用之间进行原子提交。管道最终需要将结果发送到外部世界,这样的调用可能不是幂等的。正如你将在本章后面看到的,这样的输出通常能够添加一个额外的阶段来将调用重构为幂等操作。
问题定义
所以,我们给出了一些我们不讨论的例子。那么我们所说的一次性处理是什么意思呢?为了激励这一点,让我们从一个简单的流水线开始,²如示例 5-1 所示。
示例 5-1. 一个简单的流水线
Pipeline p = Pipeline.create(options);
// Calculate 1-minute counts of events per user.
PCollection<..> perUserCounts =
p.apply(ReadFromUnboundedSource.read())
.apply(new KeyByUser())
.Window.<..>into(FixedWindows.of(Duration.standardMinutes(1)))
.apply(Count.perKey());
// Process these per-user counts, and write the output somewhere.
perUserCounts.apply(new ProcessPerUserCountsAndWriteToSink());
// Add up all these per-user counts to get 1-minute counts of all events.
perUserCounts.apply(Values.<..>create())
.apply(Count.globally())
.apply(new ProcessGlobalCountAndWriteToSink());
p.run();
这个管道计算了两种不同的窗口聚合。第一个计算了每个用户在一分钟内来自多少事件,第二个计算了每分钟总共有多少事件。这两个聚合都写入了未指定的流式输出。
请记住,Dataflow 并行在许多不同的工作器上执行管道。在每个GroupByKey(Count操作在底层使用GroupByKey),所有具有相同键的记录都在同一台机器上进行shuffle处理。Dataflow 工作器使用远程过程调用(RPC)在它们之间进行数据洗牌,确保给定键的记录都最终在同一台机器上。
图 5-1 显示了 Dataflow 为示例 5-1 中的管道创建的洗牌。³Count.perKey将每个用户的所有数据洗牌到给定的工作器,而Count.globally将所有这些部分计数洗牌到一个单一的工作器以计算全局总和。

图 5-1. 管道中的洗牌
为了 Dataflow 准确处理数据,这个 shuffle 过程必须确保每个记录只被洗牌一次。正如你将在下一刻看到的,shuffle 的分布式特性使得这成为一个具有挑战性的问题。
这个管道还可以从外部世界读取和写入数据,因此 Dataflow 必须确保这种交互不会引入任何不准确性。Dataflow 一直支持这项任务,即 Apache Spark 和 Apache Flink 所称的“端到端精确一次”,只要在技术上可行的情况下,对于数据源和数据汇。
本章的重点将放在三件事情上:
洗牌
Dataflow 如何保证每条记录只被洗牌一次。
数据源
Dataflow 如何保证每个源记录只被处理一次。
数据汇
Dataflow 如何保证每个数据汇产生准确的输出。
确保洗牌中的精确一次
正如刚才解释的,Dataflow 的流式洗牌使用 RPC。现在,每当有两台机器通过 RPC 进行通信时,都应该认真考虑数据完整性。首先,RPC 可能因为很多原因而失败。网络可能中断,RPC 可能在完成之前超时,或者接收服务器可能决定失败调用。为了保证记录在洗牌过程中不会丢失,Dataflow 采用了“上游备份”。这意味着发送方将重试 RPC,直到收到接收确认。Dataflow 还确保即使发送方崩溃,它也会继续重试这些 RPC。这保证了每条记录至少被传递一次。
现在的问题是,这些重试可能会产生重复。大多数 RPC 框架,包括 Dataflow 使用的框架,都会为发送方提供成功或失败的状态。在分布式系统中,你需要意识到 RPC 有时可能会在看似失败的情况下成功。这有很多原因:与 RPC 超时的竞争条件,服务器的积极确认尽管 RPC 成功但传输失败,等等。发送方真正可以信任的唯一状态是成功的状态。
返回失败状态的 RPC 通常表示调用可能成功也可能失败。尽管特定的错误代码可以传达明确的失败,但许多常见的 RPC 失败,如超过截止日期,都是模棱两可的。在流式洗牌的情况下,重试一个真正成功的 RPC 意味着将记录传递两次!Dataflow 需要一种方法来检测和删除这些重复。
在高层次上,这个任务的算法非常简单(见图 5-2):每个发送的消息都带有一个唯一标识符。每个接收者都存储了已经被看到和处理的所有标识符的目录。每次接收到一条记录时,它的标识符都会在这个目录中查找。如果找到了,记录就会被丢弃为重复。因为 Dataflow 是建立在可扩展的键/值存储之上的,所以这个存储被用来保存去重目录。

图 5-2。在洗牌中检测重复
解决确定性问题
然而,在现实世界中使这种策略生效需要非常小心。一个立即显现的问题是,Beam 模型允许用户代码产生非确定性输出。这意味着ParDo可能会对相同的输入记录执行两次(由于重试),但每次重试可能会产生不同的输出。期望的行为是,只有一个输出会提交到管道中;然而,涉及的非确定性使得很难保证这两个输出具有相同的确定性 ID。更棘手的是,ParDo可以输出多条记录,因此每次重试可能会产生不同数量的输出!
那么,为什么我们不要求所有用户处理都是确定性的呢?我们的经验是,在实践中,许多管道需要非确定性转换。而且很多时候,管道作者并没有意识到他们编写的代码是非确定性的。例如,考虑一个在 Cloud Bigtable 中查找补充数据以丰富其输入数据的转换。这是一个非确定性的任务,因为外部值可能会在转换的重试之间发生变化。任何依赖当前时间的代码也是不确定的。我们还看到需要依赖随机数生成器的转换。即使用户代码是纯确定的,任何允许延迟数据的事件时间聚合也可能具有非确定性的输入。
Dataflow 通过使用检查点来使非确定性处理有效地变为确定性来解决这个问题。每个转换的输出与其唯一 ID 一起被检查点到稳定存储中,然后再传递到下一个阶段之前。⁵在洗牌传递中的任何重试都只是重放已经被检查点的输出 - 用户的非确定性代码不会在重试时再次运行。换句话说,用户的代码可能会运行多次,但只有其中一个运行可以“获胜”。此外,Dataflow 使用一致的存储,可以防止重复写入稳定存储。
性能
为了实现精确一次的洗牌传递,每个接收器键中都存储了记录 ID 的目录。对于到达的每个记录,Dataflow 查找已经看到的 ID 目录,以确定这个记录是否是重复的。从一步到另一步的每个输出都被检查点到存储中,以确保生成的记录 ID 是稳定的。
然而,除非实施得当,否则这个过程会通过增加大量的读写来显著降低客户的管道性能。因此,为了使 Dataflow 用户的精确一次处理可行,必须减少 I/O,特别是通过阻止每个记录上的 I/O。
Dataflow 通过两种关键技术实现了这一目标:图优化和Bloom 过滤器。
图优化
在执行管道之前,Dataflow 服务对管道图运行一系列优化。其中一种优化是融合,在这种优化中,服务将许多逻辑步骤融合成单个执行阶段。图 5-3 显示了一些简单的示例。

图 5-3. 示例优化:融合
所有融合的步骤都作为一个内部单元运行,因此不需要为它们中的每一个存储精确一次数据。在许多情况下,融合将整个图减少到几个物理步骤,大大减少了所需的数据传输量(并节省了状态使用)。
Dataflow 还通过在将数据发送到主要分组操作之前在本地执行部分组合来优化关联和交换的Combine操作(例如Count和Sum),如图 5-4 所示。这种方法可以大大减少传递的消息数量,因此也减少了读写的数量。

图 5-4. 示例优化:组合器提升
布隆过滤器
上述优化是改进精确一次性性能的通用技术。对于严格旨在改进精确一次处理的优化,我们转向Bloom 过滤器。
在一个健康的管道中,大多数到达的记录都不是重复的。我们可以利用这一点通过布隆过滤器大大提高性能,布隆过滤器是一种紧凑的数据结构,可以快速进行成员检查。布隆过滤器有一个非常有趣的特性:它们可以返回误报,但永远不会返回假阴性。如果过滤器说“是的,元素在集合中”,我们知道该元素可能在集合中(可以计算概率)。然而,如果过滤器说一个元素不在集合中,那么它肯定不在。这个功能非常适合当前的任务。
Dataflow 中的实现方式如下:每个工作节点都保留了它所见过的每个 ID 的布隆过滤器。每当出现新的记录 ID 时,它会在过滤器中查找。如果过滤器返回 false,则该记录不是重复的,工作节点可以跳过更昂贵的稳定存储查找。只有当布隆过滤器返回 true 时,它才需要进行第二次查找,但只要过滤器的误报率低,这一步就很少需要。
然而,随着时间的推移,布隆过滤器往往会填满,这样做的话,误报率会增加。此外,每当工作节点重新启动时,我们还需要通过扫描状态中存储的 ID 目录来构建这个布隆过滤器。有帮助的是,Dataflow 为每条记录附加了一个系统时间戳。因此,服务不是创建一个单一的布隆过滤器,而是为每个 10 分钟范围创建一个单独的布隆过滤器。当记录到达时,Dataflow 根据系统时间戳查询适当的过滤器。这一步防止了布隆过滤器饱和,因为随着时间的推移,过滤器会被垃圾回收,并且它也限制了需要在启动时扫描的数据量。
图 5-5 说明了这个过程:记录到达系统并根据它们的到达时间被分配到一个布隆过滤器。第一个过滤器中的记录都不是重复的,它们的所有目录查找都被过滤了。记录r1被传递了第二次,因此需要进行目录查找以验证它是否确实是重复的;对于记录r4和r6也是如此。记录r8不是重复的;然而,由于它的布隆过滤器中出现了误报,生成了一个目录查找(这将确定r8不是重复的,应该被处理)。

图 5-5. 一次性布隆过滤器
垃圾回收
每个 Dataflow 工作节点都持久存储了它所见过的唯一记录 ID 的目录。由于 Dataflow 的状态和一致性模型是按键的,实际上每个键都存储了传递到该键的记录的目录。我们不能永远存储这些标识符,否则所有可用的存储空间最终都会被填满。为了避免这个问题,您需要对已确认的记录 ID 进行垃圾回收。
实现这一目标的一种策略是,发送方为了跟踪仍在传输中的最早序列号(对应于未确认的记录传递),为每条记录标记一个严格递增的序列号。目录中具有较早序列号的任何标识符都可以进行垃圾回收,因为所有较早的记录都已经被确认。
然而,有一个更好的选择。如前所述,Dataflow 已经为每个记录标记了一个系统时间戳,用于分桶一次性布隆过滤器。因此,Dataflow 不是使用序列号来垃圾回收一次性目录,而是基于这些系统时间戳计算垃圾回收水印(这是第三章讨论的处理时间水印)。这种方法的一个好处是,因为这个水印是基于在给定阶段等待的物理时间量(不像数据水印是基于自定义事件时间),它提供了对管道的哪些部分是慢的直觉。这些元数据是 Dataflow WebUI 中显示的系统滞后度指标的基础。
如果一个记录到达时带有旧的时间戳,而我们已经对这个时间点的标识符进行了垃圾回收,会发生什么?这可能是由于我们称之为网络残留的影响,其中一个旧消息在网络中停留了无限期,然后突然出现。垃圾回收触发的低水位不会提前,直到记录交付被确认,因此我们知道这个记录已经被成功处理。这样的网络残留显然是重复的,会被忽略。
在数据源中精确执行一次
Beam 提供了一个用于将数据读入 Dataflow 管道的源 API。⁹ 如果处理失败并且需要确保每个数据源产生的唯一记录被精确执行一次,Dataflow 可能会重试从源读取数据。
对于大多数数据源,Dataflow 会在后台处理这个过程;这些数据源是确定性的。例如,考虑一个从文件中读取数据的数据源。文件中的记录总是以确定性顺序和确定性字节位置出现,无论文件被读取多少次。¹⁰ 文件名和字节位置唯一标识每个记录,因此服务可以自动生成每个记录的唯一 ID。另一个提供类似确定性保证的数据源是 Apache Kafka;每个 Kafka 主题被分成一个静态的分区集,分区中的记录总是有确定性顺序的。这样的确定性数据源将在 Dataflow 中无重复地工作。
然而,并非所有的数据源都是如此简单。例如,Dataflow 管道的一个常见数据源是 Google Cloud Pub/Sub。Pub/Sub 是一个不确定性的数据源:多个订阅者可以从 Pub/Sub 主题中拉取消息,但哪些订阅者接收到给定的消息是不可预测的。如果处理失败,Pub/Sub 将重新传递消息,但消息可能会被传递给与最初处理它们的不同工作器,并且顺序也可能不同。这种不确定性行为意味着 Dataflow 需要帮助来检测重复,因为服务无法确定地分配在重试时稳定的记录 ID。(我们将在本章后面更详细地研究 Pub/Sub 的一个案例。)
因为 Dataflow 无法自动分配记录 ID,不确定性数据源需要通知系统记录 ID 应该是什么。Beam 的源 API 提供了UnboundedReader.getCurrentRecordId¹¹方法。如果一个数据源为每个记录提供唯一的 ID,并通知 Dataflow 它需要去重,¹²具有相同 ID 的记录将被过滤掉。
在汇聚中精确执行一次
在某个时候,每个管道都需要向外部输出数据,而汇聚是简单地执行这一操作的转换。请记住,向外部传递数据是一种副作用,我们已经提到 Dataflow 不能保证副作用的精确执行一次。那么,汇聚如何保证输出只被传递一次呢?
最简单的答案是 Beam SDK 提供了一些内置的汇聚。这些汇聚经过精心设计,以确保它们不会产生重复,即使执行多次。在可能的情况下,鼓励管道作者使用其中一个内置的汇聚。
然而,有时内置功能是不够的,你需要编写自己的功能。最好的方法是确保你的副作用操作是幂等的,因此在重播时是稳健的。然而,通常副作用DoFn的某些组件是不确定的,因此在重播时可能会发生变化。例如,在窗口聚合中,窗口中的记录集也可能是不确定的!
具体来说,窗口可能尝试使用元素e0、e1、e2触发,但工作器在提交窗口处理之前崩溃(但在这些元素作为副作用发送之前没有崩溃)。当工作器重新启动时,窗口将再次触发,但现在会出现一个延迟元素e3。因为这个元素在窗口提交之前出现,所以它不被视为延迟数据,所以DoFn会再次调用元素e0、e1、e2、e3。然后这些元素被发送到副作用操作。在这里幂等性是无法帮助的,因为每次发送的是不同的逻辑记录集。
还有其他引入不确定性的方式。解决这种风险的标准方法是依赖于 Dataflow 目前保证只有一个DoFn的输出版本可以通过洗牌边界。¹³
利用这一保证的一种简单方法是通过内置的Reshuffle转换。示例 5-2 中提出的模式确保副作用操作始终接收到一个确定性的记录以输出。
示例 5-2。重排示例
c.apply(Window.<..>into(FixedWindows.of(Duration.standardMinutes(1))))
.apply(GroupByKey.<..>.create())
.apply(new PrepareOutputData())
.apply(Reshuffle.<..>of())
.apply(WriteToSideEffect());
前面的管道将接收端分为两个步骤:PrepareOutputData和WriteToSideEffect。如果我们简单地依次运行,整个过程可能会在故障时重播,PrepareOutputData可能会产生不同的结果,并且两者都将被写入为副作用。当我们在两者之间添加Reshuffle时,Dataflow 保证这种情况不会发生。
当然,Dataflow 可能仍然多次运行WriteToSideEffect操作。这些副作用本身仍然需要是幂等的,否则接收端将收到重复的数据。例如,设置或覆盖数据存储中的值的操作是幂等的,即使运行多次,也会生成正确的输出。向列表追加的操作不是幂等的;如果操作运行多次,每次都会追加相同的值。
虽然Reshuffle提供了一种简单的方法来实现对DoFn的稳定输入,但GroupByKey同样有效。然而,目前有一个提案,可以消除添加GroupByKey以实现对DoFn的稳定输入的需要。相反,用户可以使用特殊注解@RequiresStableInput注解WriteToSideEffect,系统将确保该转换的输入稳定。
用例
为了说明这一点,让我们来看一些内置的源和接收端,看看它们如何实现上述模式。
示例来源:Cloud Pub/Sub
Cloud Pub/Sub 是一个完全托管的、可扩展的、可靠的、低延迟的系统,用于将消息从发布者传递给订阅者。发布者在命名主题上发布数据,订阅者创建命名订阅以从这些主题中拉取数据。可以为单个主题创建多个订阅,这种情况下,每个订阅从创建订阅时刻起都会接收到主题上发布的所有数据的完整副本。Pub/Sub 保证记录将继续传递直到被确认;但是,一条记录可能会被传递多次。
Pub/Sub 旨在用于分布式使用,因此许多发布过程可以发布到同一个主题,许多订阅过程可以从同一个订阅中拉取。在记录被拉取后,订阅者必须在一定时间内确认它,否则该拉取将过期,Pub/Sub 将重新将该记录传递给另一个订阅过程。
尽管这些特性使 Pub/Sub 具有高度可扩展性,但这也使它成为 Dataflow 等系统的一个具有挑战性的数据源。不可能知道哪个记录会被传递给哪个工作器,以及以什么顺序。更重要的是,在发生故障的情况下,重新传递可能会以不同的顺序将记录发送到不同的工作器!
Pub/Sub 为每条消息提供一个稳定的消息 ID,并且在重新传递时该 ID 将保持不变。Dataflow Pub/Sub 源将默认使用此 ID 来从 Pub/Sub 中删除重复项。(记录根据 ID 的哈希进行洗牌,因此重复的传递总是在同一个工作器上处理。)然而,在某些情况下,这还不够。用户的发布过程可能会重试发布,并因此将重复项引入 Pub/Sub。从该服务的角度来看,这些是唯一的记录,因此它们将获得唯一的记录 ID。Dataflow 的 Pub/Sub 源允许用户提供自己的记录 ID 作为自定义属性。只要发布者在重试时发送相同的 ID,Dataflow 就能够检测到这些重复项。
Beam(因此 Dataflow)为 Pub/Sub 提供了一个参考源实现。但是,请记住,这不是Dataflow 使用的,而是仅由非 Dataflow 运行器(如 Apache Spark,Apache Flink 和 DirectRunner)使用的实现。出于各种原因,Dataflow 在内部处理 Pub/Sub,并且不使用公共 Pub/Sub 源。
示例接收器:文件
流式运行器可以使用 Beam 的文件接收器(TextIO,AvroIO和任何实现FileBasedSink的其他接收器)来持续将记录输出到文件。示例 5-3 提供了一个示例用例。
示例 5-3。窗口化文件写入
c.apply(Window.<..>into(FixedWindows.of(Duration.standardMinutes(1))))
.apply(TextIO.writeStrings().to(new MyNamePolicy()).withWindowedWrites());
示例 5-3 中的片段每分钟写入 10 个新文件,其中包含该窗口的数据。MyNamePolicy是一个用户编写的函数,根据分片和窗口确定输出文件名。您还可以使用触发器,在这种情况下,每个触发器窗格将作为一个新文件输出。
这个过程是使用示例 5-3 中的模式的变体实现的。文件被写入临时位置,这些临时文件名通过GroupByKey发送到后续的转换。在GroupByKey之后是一个最终转换,它会将临时文件原子地移动到它们的最终位置。示例 5-4 中的伪代码提供了 Beam 中一致的流式文件接收器的实现草图。(有关更多详细信息,请参见 Beam 代码库中的FileBasedSink和WriteFiles。)
示例 5-4。文件接收器
c
// Tag each record with a random shard id.
.apply("AttachShard", WithKeys.of(new RandomShardingKey(getNumShards())))
// Group all records with the same shard.
.apply("GroupByShard", GroupByKey.<..>())
// For each window, write per-shard elements to a temporary file. This is the
// non-deterministic side effect. If this DoFn is executed multiple times, it will
// simply write multiple temporary files; only one of these will pass on through
// to the Finalize stage.
.apply("WriteTempFile", ParDo.of(new DoFn<..> {
@ProcessElement
public void processElement(ProcessContext c, BoundedWindow window) {
// Write the contents of c.element() to a temporary file.
// User-provided name policy used to generate a final filename.
c.output(new FileResult()).
}
}))
// Group the list of files onto a singleton key.
.apply("AttachSingletonKey", WithKeys.<..>of((Void)null))
.apply("FinalizeGroupByKey", GroupByKey.<..>create())
// Finalize the files by atomically renaming them. This operation is idempotent.
// Once this DoFn has executed once for a given FileResult, the temporary file
// is gone, so any further executions will have no effect.
.apply("Finalize", ParDo.of(new DoFn<..>, Void> {
@ProcessElement
public void processElement(ProcessContext c) {
for (FileResult result : c.element()) {
rename(result.getTemporaryFileName(), result.getFinalFilename());
}
}}));
您可以看到WriteTempFile中的非幂等工作是如何完成的。在GroupByKey完成后,Finalize步骤将始终看到相同的捆绑包进行重试。因为文件重命名是幂等的,¹⁴这给了我们一个恰好一次的接收器。
示例接收器:Google BigQuery
Google BigQuery 是一个完全托管的云原生数据仓库。Beam 提供了 BigQuery 接收器,BigQuery 提供了支持极低延迟插入的流式插入 API。这个流式插入 API 允许您为每个记录标记插入一个唯一的 ID,并且 BigQuery 将尝试使用相同的 ID 过滤重复的插入。¹⁵为了使用这个功能,BigQuery 接收器必须为每条记录生成统计上唯一的 ID。它通过使用java.util.UUID包来实现这一点,该包生成统计上唯一的 128 位 ID。
生成随机的通用唯一标识符(UUID)是一个非确定性操作,因此我们必须在插入到 BigQuery 之前添加Reshuffle。这样做后,Dataflow 的任何重试都将始终使用相同的被洗牌的 UUID。对 BigQuery 的重复尝试插入将始终具有相同的插入 ID,因此 BigQuery 能够对其进行过滤。示例 5-5 中显示的伪代码说明了 BigQuery 接收器的实现方式。
示例 5-5。BigQuery 接收器
// Apply a unique identifier to each record
c
.apply(new DoFn<> {
@ProcessElement
public void processElement(ProcessContext context) {
String uniqueId = UUID.randomUUID().toString();
context.output(KV.of(ThreadLocalRandom.current().nextInt(0, 50),
new RecordWithId(context.element(), uniqueId)));
}
})
// Reshuffle the data so that the applied identifiers are stable and will not change.
.apply(Reshuffle.<Integer, RecordWithId>of())
// Stream records into BigQuery with unique ids for deduplication.
.apply(ParDo.of(new DoFn<..> {
@ProcessElement
public void processElement(ProcessContext context) {
insertIntoBigQuery(context.element().record(), context.element.id());
}
});
再次,我们将接收器分成一个非幂等步骤(生成随机数),然后是一个幂等步骤。
其他系统
现在我们已经详细解释了 Dataflow 的恰好一次,让我们将其与其他流行的流式系统的简要概述进行对比。每个系统以不同的方式实现恰好一次保证,并因此做出不同的权衡。
Apache Spark Streaming
Spark Streaming 使用微批处理架构进行连续数据处理。用户在逻辑上处理一个流对象;然而,在底层,Spark 将这个流表示为连续的一系列 RDD。¹⁶ 每个 RDD 都作为一个批次进行处理,Spark 依赖批处理的精确一次性特性来确保正确性;正如之前提到的,正确的批处理洗牌技术已经有一段时间了。这种方法可能会导致输出的延迟增加,特别是对于深层管道和高输入量,通常需要仔细调整才能实现所需的延迟。
Spark 假设操作都是幂等的,并且可能重放操作链直到当前图中的点。提供了一个检查点原语,可以导致一个 RDD 被实体化,从而保证该 RDD 之前的历史不会被重放。这个检查点功能是为了性能原因而设计的(例如,防止重放昂贵的操作);然而,您也可以使用它来实现非幂等的副作用。
Apache Flink
Apache Flink 还为流式管道提供了精确一次处理,但是它的方式与 Dataflow 或 Spark 不同。Flink 流式管道定期计算一致的快照,每个快照代表整个管道在一致时间点的状态。Flink 快照是逐步计算的,因此在计算快照时无需停止所有处理。这使得记录可以在系统中继续流动,同时进行快照,缓解了 Spark Streaming 方法的一些延迟问题。
Flink 通过向从源流出的数据流插入特殊编号的快照标记来实现这些快照。当每个算子接收到快照标记时,它执行特定的算法,使其将状态复制到外部位置,并将快照标记传播到下游算子。在所有算子执行完这个快照算法后,完整的快照就可用了。任何工作器故障都将导致整个管道从最后一个完整快照中回滚其状态。在途消息不需要包含在快照中。Flink 中的所有消息传递都是通过有序的基于 TCP 的通道完成的。任何连接故障都可以通过从最后一个良好序列号恢复连接来处理;¹⁷ 与 Dataflow 不同,Flink 任务是静态分配给工作器的,因此可以假定连接将从相同的发送方恢复,并重放相同的有效载荷。
由于 Flink 可能随时回滚到先前的快照,尚未在快照中的任何状态修改都必须被视为临时的。将数据发送到 Flink 管道外部世界的接收器必须等到快照完成,然后只发送包含在该快照中的数据。Flink 提供了一个 notifySnapshotComplete 回调,允许接收器在每个快照完成时得知,并发送数据。尽管这会影响 Flink 管道的输出延迟,¹⁸ 但这种延迟只在接收器处引入。实际上,这使得 Flink 在深层管道中的端到端延迟比 Spark 更低,因为 Spark 在管道的每个阶段都引入了批处理延迟。
Flink 的分布式快照是处理流式管道一致性的一种优雅方式;然而,对管道做出了一些假设。假设故障是罕见的,¹⁹ 因为故障的影响(回滚到先前的快照)是重大的。为了保持低延迟输出,还假设快照可以快速完成。尚不清楚这是否会在非常大的集群中引起问题,那里的故障率可能会增加,完成快照所需的时间也会增加。
实现也简化了,因为假设任务静态分配给工作程序(至少在单个快照时期内)。这个假设允许 Flink 在工作程序之间提供简单的一次性传输,因为它知道如果连接失败,相同的数据可以按顺序从同一个工作程序中拉取。相比之下,Dataflow 不断地在工作程序之间进行负载平衡(并且工作程序的集合不断增长和缩减),因此 Dataflow 无法做出这个假设。这迫使 Dataflow 实现一个更复杂的传输层,以提供一次性处理。
总结
总之,曾经被认为与低延迟结果不兼容的一次性数据处理是完全可能的——Dataflow 在不牺牲延迟的情况下高效地实现了这一点。这为流处理提供了更丰富的用途。
尽管本章重点介绍了 Dataflow 特定的技术,其他流处理系统也提供了一次性保证。Apache Spark Streaming 将流式管道作为一系列小批处理作业运行,依赖于 Spark 批处理运行器中的一次性保证。Apache Flink 使用 Chandy Lamport 分布式快照的变体来获得运行一致状态,并可以使用这些快照来确保一次性处理。我们鼓励您也了解这些其他系统,以便广泛了解不同的流处理系统的工作方式!
¹ 实际上,我们所知道的没有一个系统能够保证至少一次(或更好),包括所有其他 Beam 运行器。
² Dataflow 还提供了准确的批处理运行器;然而,在这个上下文中,我们专注于流式运行器。
³ Dataflow 优化器将许多步骤组合在一起,并仅在需要时添加洗牌。
⁴ 批处理管道也需要防范洗牌中的重复项。但是,在批处理中解决这个问题要容易得多,这就是为什么历史批处理系统会这样做而流式系统不会这样做的原因。使用微批处理架构的流式运行时,比如 Spark Streaming,将重复项检测委托给批处理洗牌器。
⁵ 我们非常小心确保这种检查点是高效的;例如,与底层键/值存储的特性密切相关的模式和访问模式优化。
⁶ 这不是用于窗口化的自定义用户提供的时间戳。相反,这是由发送工作程序分配的确定性处理时间时间戳。
⁷ 需要小心确保这个算法能够运行。每个发送者必须保证系统生成的时间戳严格递增,并且这个保证必须在工作重新启动时保持不变。
⁸ 从理论上讲,我们可以通过在一个桶中的时间戳达到阈值时才懒惰地构建 Bloom 过滤器来完全摒弃启动扫描。
⁹ 在撰写本文时,Apache Beam 提供了一个名为SplittableDoFn的新的、更灵活的 API。
¹⁰ 我们假设在我们读取文件时没有人恶意修改文件中的字节。
¹¹ 再次注意,SplittableDoFn API具有不同的方法。
¹² 使用requiresDedupping覆盖。
¹³ 请注意,这些确定性边界可能在某个时候在 Beam 模型中变得更加明确。其他 Beam 运行器在处理非确定性用户代码的能力上有所不同。
¹⁴ 只要在源文件不再存在时正确处理故障。
¹⁵ 由于服务的全局性质,BigQuery 不能保证所有重复项都被移除。用户可以定期对他们的表运行查询,以移除流式插入 API 没有捕捉到的任何重复项。有关更多信息,请参阅 BigQuery 文档。
¹⁶ 弹性分布式数据集;Spark 对分布式数据集的抽象,类似于 Beam 中的 PCollection。
¹⁷ 这些序列号是针对每个连接的,与快照时期编号无关。
¹⁸ 仅适用于非幂等的接收器。完全幂等的接收器不需要等待快照完成。
¹⁹ 具体来说,Flink 假设工作器故障的平均时间小于快照时间;否则,管道将无法取得进展。
第二部分:流和表
第六章:流与表
你已经到达了书中讨论流和表的部分。如果你还记得,在第一章中,我们简要讨论了数据的两个重要但正交的维度:基数和构成。到目前为止,我们严格关注基数方面(有界与无界),并且忽略了构成方面(流与表)。这使我们能够了解无界数据集引入的挑战,而不用太担心真正驱动事物运作方式的底层细节。我们现在将扩展我们的视野,看看构成的增加维度给混合带来了什么。
虽然有点牵强,但一种思考这种方法转变的方式是将经典力学与量子力学的关系进行比较。你知道在物理课上他们教你一堆经典力学的东西,比如牛顿理论之类的,然后在你觉得你更或多少掌握了之后,他们告诉你那都是废话,经典物理只给你部分图景,实际上还有这个叫做量子力学的东西,它真正解释了更低层次的事物运作方式,但一开始让事情变得复杂并不合理,所以...哦等等...我们之间还没有完全协调好一切,所以只是眯着眼睛相信我们,总有一天一切都会有意义?嗯,这很像那个,只是你的大脑会疼得少一些,因为物理学比数据处理难得多,你也不必眯着眼睛假装一切都有意义,因为实际上最后一切都会美好地结合在一起,这真的很酷。
所以,舞台已经适当地设置好了,这一章的重点有两个:
-
试图描述 Beam 模型(就像我们在书中描述的那样)与“流和表”理论(由Martin Kleppmann和Jay Kreps等人普及,但实质上起源于数据库世界)之间的关系。事实证明,流和表理论很好地描述了 Beam 模型的底层概念。此外,当考虑如何将健壮的流处理概念清晰地集成到 SQL 中时,对它们之间的关系有一个清晰的理解尤为重要(这是我们在第八章中考虑的内容)。
-
为了纯粹的乐趣而向你轰炸糟糕的物理学类比。写一本书是一项艰苦的工作;你必须在这里找到一点点小乐趣来继续前行。
流与表的基础知识或者说:流与表的相对论特殊理论
流和表的基本概念源自数据库世界。熟悉 SQL 的人可能熟悉表及其核心属性,大致总结为:表包含数据的行和列,每行都由某种键唯一标识,可以是显式的也可以是隐式的。
如果你回想一下大学数据库系统课程,你可能会记得大多数数据库的数据结构是追加日志。当事务被应用到数据库中的表时,这些事务被记录在日志中,日志的内容然后被串行应用到表中以实现这些更新。在流和表的命名法中,该日志实际上就是流。
从这个角度来看,我们现在明白了如何从流创建表:表只是应用于流中找到的更新事务日志的结果。但是我们如何从表创建流呢?本质上是相反的:流是表的更改日志。通常用于表到流转换的激励示例是物化视图。SQL 中的物化视图允许您在表上指定查询,然后数据库系统将其本身作为另一个一流表来实现。这个物化视图本质上是该查询的缓存版本,数据库系统确保它始终保持最新,因为源表的内容随时间演变。也许并不奇怪,物化视图是通过原始表的更改日志实现的;每当源表更改时,该更改都会被记录。然后数据库在物化视图查询的上下文中评估该更改,并将任何结果更改应用于目标物化视图表。
将这两点结合起来,并运用另一个值得怀疑的物理学类比,我们就得到了可以称之为流和表相对论的特殊理论:
流→表
随时间对更新流的聚合产生一个表。
表→流
随时间观察表的变化产生一个流。
这是一对非常强大的概念,它们对流处理世界的精心应用是 Apache Kafka 巨大成功的一个重要原因,这个生态系统是围绕这些基本原则构建的。然而,这些陈述本身并不够一般化,无法将流和表与 Beam 模型中的所有概念联系起来。为此,我们必须深入一点。
朝着流和表相对论的一般理论
如果我们想要将流/表理论与我们对 Beam 模型的所有了解联系起来,我们需要解决一些问题,具体来说:
-
批处理如何融入其中?
-
流与有界和无界数据集的关系是什么?
-
四个“什么”、“哪里”、“何时”、“如何”问题如何映射到流/表世界?
当我们试图这样做时,对流和表有正确的心态将会有所帮助。除了理解它们之间的关系,如前面的定义所捕捉的那样,独立于彼此定义它们也是有启发性的。以下是一个简单的看待它的方式,将强调我们未来分析的一些内容:
-
表是静态数据。
这并不是说表在任何方面都是静态的;几乎所有有用的表在某种程度上都在不断变化。但在任何给定时间,表的快照提供了数据集的某种整体图片。在这方面,表充当数据随时间累积和观察的概念休息地。因此,静态数据。
-
流是运动中的数据。
表捕捉了在特定时间点数据集的整体视图,而流捕捉了数据随时间的演变。Julian Hyde 喜欢说流就像表的导数,而表就像流的积分,这对于数学思维的人来说是一个很好的思考方式。不管怎样,流的重要特征是它们捕捉了表内数据的固有运动,因为它改变了。因此,数据在运动。
尽管表和流密切相关,但重要的是要记住它们并不完全相同,即使有许多情况下,一个可能完全源自另一个。差异微妙但重要,我们将会看到。
批处理与流和表
现在我们已经做好了准备,让我们开始解决一些问题。首先,我们解决第一个问题,关于批处理。最后,我们会发现,关于流与有界和无界数据的关系的解决方案将自然而然地从第一个问题的答案中得出。这是巧合的一次得分。
MapReduce 的流和表分析
为了保持我们的分析相对简单,但又坚实具体,让我们看看传统MapReduce作业如何适应流/表世界。正如其名称所暗示的,MapReduce 作业表面上由两个阶段组成:Map 和 Reduce。然而,为了我们的目的,更深入地看待它并将其视为六个阶段是有用的:
MapRead
这个阶段消耗输入数据并对其进行一些预处理,使其成为映射的标准键值形式。
Map
这个阶段重复(和/或并行)从预处理输入中消耗一个键值对³,并输出零个或多个键值对。
MapWrite
这个阶段将具有相同键的 Map 阶段输出值组合在一起,并将这些键值对列表组写入(临时)持久存储。这样,MapWrite 阶段本质上是一个按键分组和检查点操作。
ReduceRead
这个阶段消耗保存的洗牌数据,并将它们转换成标准的键值对列表形式以便进行减少。
Reduce
这个阶段重复(和/或并行)消耗一个键及其关联的值记录列表,并输出零个或多个记录,所有这些记录都可以选择保持与相同键相关联。
ReduceWrite
这个阶段将 Reduce 阶段的输出写入输出数据存储。
请注意,MapWrite 和 ReduceRead 阶段有时被称为 Shuffle 阶段的一部分,但对于我们的目的,最好将它们视为独立的阶段。也值得注意的是,MapRead 和 ReduceWrite 阶段的功能如今更常被称为源和汇。然而,撇开这些,现在让我们看看这与流和表的关系。
Map 作为流/表
因为我们从静态⁴数据集开始并结束,所以很明显我们从一个表开始并以一个表结束。但在中间我们有什么?天真地,人们可能会认为中间都是表;毕竟,批处理(概念上)被认为是消耗和产生表。如果你把批处理作业看作是执行经典 SQL 查询的粗略类比,那感觉相对自然。但让我们更仔细地看看一步步发生了什么。
首先,MapRead 消耗一个表并产生某物。接下来,Map 阶段消耗了这个东西,所以如果我们想要了解它的性质,一个好的起点就是 Map 阶段的 API,它在 Java 中看起来像这样:
void map(KI key, VI value, Emit<KO, VO> emitter);
对于输入表中的每个键值对,map 调用将被重复调用。如果你觉得这听起来像输入表被消耗为记录流,那么你是对的。我们稍后会更仔细地看一下表是如何转换为流的,但现在,可以说 MapRead 阶段正在迭代输入表中的静态数据,并将它们以流的形式放入运动中,然后被 Map 阶段消耗。
接下来,Map 阶段消耗了这个流,然后做了什么?因为映射操作是逐元素转换,它并没有做任何会停止移动元素并使其休息的事情。它可能通过过滤一些元素或将一些元素分解成多个元素来改变流的有效基数,但在 Map 阶段结束后,这些元素仍然相互独立。因此,可以说 Map 阶段既消耗流,又产生流。
在 Map 阶段完成后,我们进入 MapWrite 阶段。正如我之前所指出的,MapWrite 通过键分组记录,然后以该格式将它们写入持久存储。实际上,在这一点上,写入的持久部分实际上并不是严格必要的,只要某个地方有持久性(即,如果上游输入被保存,并且在失败的情况下可以从中重新计算中间结果,类似于 Spark 对 Resilient Distributed Datasets(RDDs)采取的方法)。重要的是记录被组合到某种数据存储中,无论是在内存中,磁盘上,还是其他位置。这很重要,因为由于这个分组操作,以前在流中一个接一个地飞过的记录现在被带到由它们的键所指示的位置,从而允许每个键组积累,就像它们的同类兄弟姐妹到达一样。请注意,这与之前提供的流到表转换的定义有多么相似:随着时间的推移,对更新流的聚合产生了一个表。通过根据它们的键对记录进行分组,MapWrite 阶段使这些数据得到休息,从而将流转换回表。⁵酷!
现在我们已经完成了 MapReduce 的一半,所以,使用图 6-1,让我们回顾一下到目前为止我们所看到的内容。
我们已经通过三个操作从表转换为流,然后再转换回来。MapRead 将表转换为流,然后 Map(通过用户的代码)将其转换为新流,然后 MapWrite 将其转换回表。我们将发现 MapReduce 中的接下来的三个操作看起来非常相似,所以我会更快地通过它们,但我仍然想在途中指出一个重要的细节。

图 6-1。 MapReduce 中的映射阶段。表中的数据被转换为流,然后再转换回去。
将流/表减少
在 MapWrite 阶段之后,ReduceRead 本身相对不那么有趣。它基本上与 MapRead 相同,只是读取的值是值的单例列表,而不是单个值,因为 MapWrite 存储的数据是键/值列表对。但它仍然只是在表的快照上进行迭代,将其转换为流。这里没有什么新东西。
即使在这种情况下,Reduce 听起来可能很有趣,但实际上它只是一个有点特别的 Map 阶段,它恰好接收每个键的值列表,而不是单个值。因此,它仍然只是将单个(复合)记录映射为零个或多个新记录。这里也没有什么特别新的东西。
ReduceWrite 是一个有点值得注意的阶段。我们已经知道这个阶段必须将流转换为表,因为 Reduce 产生了一个流,最终输出是一个表。但是这是如何发生的呢?如果我告诉你,这是由于将前一阶段的输出键分组到持久存储中的直接结果,就像我们在 MapWrite 中看到的那样,你可能会相信我,直到你记得我之前指出的 Reduce 阶段的键关联是一个可选特性。启用了该特性,ReduceWrite 基本上 与 MapWrite 相同。⁶但是如果禁用了该特性,并且 Reduce 的输出没有关联的键,那么到底发生了什么来使这些数据得到休息呢?
要理解正在发生的事情,重新思考 SQL 表的语义是有用的。虽然经常建议,但并不严格要求 SQL 表具有唯一标识每行的主键。在无键表的情况下,插入的每一行都被视为新的、独立的行(即使其中的数据与表中的一个或多个现有行的数据相同),就像有一个隐式的 AUTO_INCREMENT 字段被用作键一样(顺便说一句,在大多数实现中,实际上就是这样的,即使在这种情况下,“键”可能只是一些从未公开或预期用作逻辑标识符的物理块位置)。这种隐式的唯一键分配正是在没有键数据的 ReduceWrite 中发生的。从概念上讲,仍然发生着按键分组操作;这就是将数据置于静止状态的原因。但是由于缺少用户提供的键,ReduceWrite 将每个记录都视为具有新的、以前从未见过的键,并有效地将每个记录与自身分组,再次导致数据处于静止状态。
看一下图 6-2,它显示了从流/表的角度看整个管道。你可以看到这是一个 TABLE → STREAM → STREAM → TABLE → STREAM → STREAM → TABLE 的序列。即使我们处理的是有界数据,即使我们正在进行传统意义上的批处理,实际上它只是在表面下进行流和表处理。

图 6-2。从流和表的角度看 MapReduce 中的 Map 和 Reduce 阶段
与批处理的调和
那么,这对我们的前两个问题有什么影响呢?
-
Q: 批处理如何适应流/表理论?
A: 非常好。基本模式如下:
-
表被完整地读取成为流。
-
流被处理成新的流,直到遇到分组操作。
-
分组将流转换为表。
-
步骤 a 到 c 重复,直到管道中没有阶段为止。
-
-
Q: 流如何与有界/无界数据相关联?
A: 从 MapReduce 示例中可以看出,流只是数据的运动形式,无论它们是有界的还是无界的。
从这个角度来看,很容易看出流/表理论与有界数据的批处理并不矛盾。事实上,它进一步支持了我一直在强调的观点,即批处理和流处理并没有那么不同:归根结底,它一直都是流和表。
有了这个,我们已经在通向流和表的一般理论的道路上了。但是为了清晰地总结,我们最后需要重新讨论流/表上下文中的四个什么/哪里/何时/如何问题,看看它们如何相关。
什么、哪里、何时和如何在流和表的世界中
在本节中,我们将看看这四个问题中的每一个,看看它们如何与流和表相关。我们还将回答可能从上一节中挥之不去的任何问题,其中一个重要的问题是:如果分组是将数据置于静止状态的原因,那么“取消分组”的逆过程究竟是什么?稍后再说。但现在,让我们来看看转换。
什么:转换
在第三章中,我们了解到转换告诉我们管道正在计算的是什么;也就是说,它是在构建模型、计算总和、过滤垃圾邮件等。我们在前面的 MapReduce 示例中看到,六个阶段中的四个回答了什么问题:
-
Map 和 Reduce 都对输入流中的每个键/值或键/值列表对应用了管道作者的逐元素转换,分别产生了一个新的、转换后的流。
-
MapWrite 和 ReduceWrite 都根据上一阶段分配的键对输出进行分组(在可选的 Reduce 情况下可能是隐式的),这样做可以将输入流转换为输出表。
从这个角度来看,你可以看到从流/表理论的角度来看,基本上有两种what转换类型:
非分组
这些操作(正如我们在 Map 和 Reduce 中看到的)只是接受一系列记录,并在另一侧生成一系列新的转换记录。非分组转换的示例包括过滤器(例如,删除垃圾邮件消息)、扩展器(即,将较大的复合记录拆分为其组成部分)和变换器(例如,除以 100),等等。
分组
这些操作(正如我们在 MapWrite 和 ReduceWrite 中看到的)接受一系列记录,并以某种方式将它们组合在一起,从而将流转换为表。分组转换的示例包括连接、聚合、列表/集合累积、变更日志应用、直方图创建、机器学习模型训练等。
为了更好地了解所有这些是如何联系在一起的,让我们看一下图 2-2 的更新版本,我们首次开始研究转换。为了避免你跳回去看我们在谈论什么,示例 6-1 包含了我们正在使用的代码片段。
示例 6-1。求和管道
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals =
input.apply(Sum.integersPerKey());
这个管道只是简单地读取输入数据,解析单个团队成员的分数,然后对每个团队的分数进行求和。它的事件时间/处理时间可视化看起来像图 6-3 中呈现的图表。
<assets/stsy_0603.mp4>

图 6-3。经典批处理的事件时间/处理时间视图
图 6-4 描述了随着时间推移,从流和表的角度呈现的管道的更顶层视图。
<assets/stsy_0604.mp4>

图 6-4。经典批处理的流和表视图
在这种可视化的流和表版本中,时间的流逝通过在处理时间维度(y 轴)向下滚动图形区域来体现。以这种方式呈现事物的好处在于,它非常清楚地指出了非分组和分组操作之间的差异。与我们以前的图表不同,在那些图表中,我省略了管道中除了Sum.integersByKey之外的所有初始转换操作,但在这里,我也包括了初始的解析操作,因为解析操作的非分组方面与求和的分组方面形成了鲜明对比。从这个角度来看,很容易看出两者之间的区别。非分组操作对流中的元素运动没有任何影响,因此在另一侧产生另一个流。相反,分组操作将流中的所有元素汇聚在一起,将它们相加得到最终的总和。因为这个示例是在有界数据上运行的批处理引擎上运行的,最终结果只有在输入结束后才会被发出。正如我们在第二章中指出的那样,这个示例对有界数据是足够的,但在无界数据的情况下太过限制,因为理论上输入永远不会结束。但它真的不够吗?
从图表的新流/表部分来看,如果我们所做的只是计算总和作为我们的最终结果(而不在管道中的下游实际上以任何其他方式转换这些总和),那么我们用分组操作创建的表中就有我们的答案,随着新数据的到来而不断演变。为什么我们不直接从那里读取我们的结果呢?
这正是那些支持流处理器作为数据库的人所要表达的观点⁸(主要是 Kafka 和 Flink 团队):在管道中进行分组操作时,实际上创建了一个包含该阶段输出值的表。如果这些输出值恰好是管道正在计算的最终结果,那么如果可以直接从该表中读取它们,就不需要在其他地方重新生成它们。除了在时间演变过程中提供快速和便捷的结果访问外,这种方法通过不需要在管道中添加额外的接收阶段来节省计算资源,通过消除冗余数据存储来节省磁盘空间,并且消除了构建前述接收阶段的任何工程工作的需要。⁹ 唯一的主要注意事项是,您需要小心确保只有数据处理管道有能力对表进行修改。如果表中的值可以在管道之外由外部修改而发生变化,那么关于一致性保证的所有赌注都将失效。
行业中有许多人一直在推荐这种方法,并且它正在被广泛应用于各种场景中。我们已经看到 Google 内部的 MillWheel 客户通过直接从基于 Bigtable 的状态表中提供数据来做同样的事情,而且我们正在为从 Google 内部使用的 C++-based Apache Beam 等效版本(Google Flume)中的管道外部访问状态添加一流支持;希望这些概念将来某一天能够真正地传递到 Apache Beam。
现在,如果从状态表中读取值是很好的,如果其中的值是您的最终结果。但是,如果您在管道下游有更多的处理要执行(例如,想象一下我们的管道实际上正在计算得分最高的团队),我们仍然需要一种更好的方式来处理无界数据,允许我们以更增量的方式将表转换回流。为此,我们将希望通过剩下的三个问题的旅程,从窗口化开始,扩展到触发,最后将其与累积结合起来。
在哪里:窗口化
正如我们从第三章所知,窗口化告诉我们在事件时间中分组发生的位置。结合我们之前的经验,我们也可以推断它必须在流到表转换中起到作用,因为分组是驱动表创建的原因。窗口化有两个方面与流/表理论相互作用:
窗口分配
这实际上意味着将记录放入一个或多个窗口中。
窗口合并
这就是使动态的、数据驱动类型的窗口(例如会话)成为可能的逻辑。
窗口分配的效果非常直接。当记录在概念上放置到窗口中时,窗口的定义基本上与该记录的用户分配的键结合起来,以在分组时创建一个隐式的复合键。¹⁰ 简单。
为了完整起见,让我们再次从第三章的原始窗口化示例中看一看,但从流和表的角度来看。如果你还记得,代码片段看起来有点像示例 6-2(这次没有省略解析)。
示例 6-2。求和管道
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES)))
.apply(Sum.integersPerKey());
原始可视化效果如图 6-5 所示。
<assets/stsy_0605.mp4>

图 6-5。批处理引擎上窗口化求和的事件时间/处理时间视图
现在,图 6-6 显示了流和表的版本。
<assets/stsy_0606.mp4>

图 6-6。批处理引擎上窗口化求和的流和表视图
正如你可能期望的那样,这看起来与图 6-4 非常相似,但表中有四个分组(对应数据占据的四个窗口),而不是只有一个。但与以前一样,我们必须等到有界输入结束后才能发出结果。我们将在下一节讨论如何处理无界数据,但首先让我们简要谈一下合并窗口。
窗口合并
接下来讨论合并,我们会发现窗口合并的影响比窗口分配更加复杂,但是当你考虑到需要发生的逻辑操作时,它仍然是直接的。当将流分组到可以合并的窗口时,该分组操作必须考虑到所有可能合并在一起的窗口。通常,这仅限于数据都具有相同键的窗口(因为我们已经确定窗口化修改了分组不仅仅是按键,还有键和窗口)。因此,系统实际上并不将键/窗口对视为一个平面复合键,而是将其视为分层键,用户分配的键是根,窗口是该根的子组件。当实际上将数据分组在一起时,系统首先按分层复合键的根(用户分配的键)进行分组。在按键分组后,系统可以继续在该键内按窗口进行分组(使用分层复合键的子组件)。按窗口进行分组就是窗口合并发生的地方。
从流和表的角度来看,有趣的是窗口合并如何改变最终应用于表的突变;也就是说,它如何修改了随时间指示表内容的更改日志。对于非合并窗口,每个新分组的元素都会导致对表的单个突变(将该元素添加到元素的键+窗口的组中)。对于合并窗口,分组新元素的操作可能导致一个或多个现有窗口与新窗口合并。因此,合并操作必须检查当前键的所有现有窗口,找出哪些窗口可以与新窗口合并,然后原子地删除旧未合并窗口并插入新合并窗口到表中。这就是为什么支持合并窗口的系统通常将原子性/并行性的单位定义为键,而不是键+窗口。否则,要提供正确性保证所需的强一致性将是不可能的(或者至少更加昂贵)。当你开始以这种细节水平来看待它时,你就会明白为什么让系统来处理窗口合并的麻烦事是多么美妙。要更近距离地了解窗口合并语义,我建议你参考“数据流模型”的 2.2.2 节。
归根结底,窗口化实际上只是对分组语义的轻微改变,这意味着它对流到表转换的语义也是轻微的改变。对于窗口分配,就像在分组时将窗口合并到隐式复合键中一样简单。当涉及窗口合并时,这个复合键更像是一个分层键,允许系统处理按键分组,找出该键内的窗口合并,然后原子地应用所有必要的突变到相应的表中。抽象层次的叠加真是太好了!
尽管如此,我们实际上还没有解决将表转换为流的问题,特别是在无界数据的情况下以更增量的方式进行。为此,我们需要重新审视触发器。
何时:触发器
我们在第三章学到,我们使用触发器来决定窗口的内容何时被实现(水印为某些类型的触发器提供了输入完整性的有用信号)。在数据被分组到窗口中之后,我们使用触发器来决定何时将这些数据发送到下游。在流/表术语中,我们了解到分组意味着流到表的转换。从那里,我们可以很容易地看到触发器是分组的补充;换句话说,这是我们之前所探索的“取消分组”操作。触发器是驱动表到流转换的东西。
在流/表术语中,触发器是应用于表的特殊程序,允许对表中的数据在响应相关事件时进行实现。以这种方式陈述,它们实际上听起来非常类似于经典数据库触发器。事实上,这里选择的名称并非巧合;它们本质上是相同的东西。当您指定触发器时,实际上是在随着时间的推移为状态表中的每一行编写代码。当触发器触发时,它会获取当前静止在表中的相应数据,并将它们置于运动中,产生一个新的流。
让我们回到我们的例子。我们将从第二章的简单的每记录触发器开始,该触发器在每次到达新记录时都会发出新的结果。该示例的代码和事件时间/处理时间可视化如示例 6-3 所示。图 6-7 呈现了结果。
示例 6-3. 每条记录重复触发
PCollection<String>> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AfterCount(1))));
.apply(Sum.integersPerKey());
<assets/stsy_0607.mp4>

图 6-7. 批处理引擎上窗口求和的流和表视图
与以前一样,每次遇到新记录时都会实现新的结果。以流和表类型的视图呈现,该图将类似于图 6-8。
<assets/stsy_0608.mp4>

图 6-8. 流引擎上每条记录触发的窗口求和的流和表视图
使用每条记录触发器的一个有趣的副作用是它在某种程度上掩盖了数据被静止的效果,因为它们随后立即被触发器重新置于运动中。即便如此,从分组中产生的聚合物件仍然静止在表中,而未分组的值流则从中流走。
为了更好地了解静止/运动关系,让我们跳过我们的触发示例,转到第二章的基本水印完整性流示例,该示例在完成时简单地发出结果(由于水印通过窗口末端)。该示例的代码和事件时间/处理时间可视化如示例 6-4 所示(请注意,我这里只显示了启发式水印版本,以便简洁和比较),图 6-9 说明了结果。
示例 6-4. 水印完整性触发器
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()))
.apply(Sum.integersPerKey());
<assets/stsy_0609.mp4>

图 6-9. 流引擎上带有启发式水印的窗口求和的事件时间/处理时间视图
由于在示例 6-4 中指定的触发器声明窗口应在水印通过它们时实现,系统能够在管道的无界输入变得越来越完整时以渐进的方式发出结果。在图 6-10 中的流和表版本中,它看起来就像您所期望的那样。
<assets/stsy_0610.mp4>

图 6-10. 带有启发式水印的窗口求和的流和表视图
在这个版本中,您可以非常清楚地看到触发器对状态表的取消分组效果。随着水印通过每个窗口的末尾,它将该窗口的结果从表中取出,并将其与表中的所有其他值分开,向下游传送。当然,我们仍然有之前的迟到数据问题,我们可以再次使用示例 6-5 中显示的更全面的触发器来解决。
示例 6-5。通过早期/准时/迟 API 进行早期、准时和迟触发
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
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());
事件时间/处理时间图看起来像图 6-11。
<assets/stsy_0611.mp4>

图 6-11。事件时间/处理时间视图的窗口求和在具有早期/准时/迟触发器的流引擎上
而流和表版本看起来像图 6-12 所示。
<assets/stsy_0612.mp4>

图 6-12。具有早期/准时/迟触发器的流引擎上窗口求和的流和表视图
这个版本更清楚地显示了触发器的取消分组效果,根据示例 6-6 中指定的触发器,将表的各个独立部分呈现为流的不断变化视图。
到目前为止,我们谈到的所有具体触发器的语义(事件时间、处理时间、计数、早期/准时/迟等复合触发器等)都符合我们从流/表视角看到的预期,因此不值得进一步讨论。然而,我们还没有花太多时间讨论触发器在经典批处理场景中的样子。现在我们了解了批处理管道的底层流/表拓扑结构是什么样子,这值得简要提及。
归根结底,在经典批处理场景中实际上只有一种类型的触发器:当输入完成时触发。对于我们之前看过的 MapReduce 作业的初始 MapRead 阶段,该触发器在概念上会立即为输入表中的所有数据触发,因为批处理作业的输入被假定为从一开始就是完整的。¹¹因此,该输入源表将被转换为单个元素的流,之后 Map 阶段可以开始处理它们。
在管道中间的表到流转换中,例如我们示例中的 ReduceRead 阶段,使用相同类型的触发器。然而,在这种情况下,触发器实际上必须等待表中的所有数据完成(即更常见地称为所有数据被写入洗牌),就像我们示例中的批处理管道在图 6-4 和 6-6 中等待输入结束之前发出最终结果一样。
鉴于经典批处理实际上总是使用输入数据完成触发器,您可能会问在批处理场景中作者指定的任何自定义触发器可能意味着什么。答案实际上是:这取决于情况。有两个值得讨论的方面:
触发器的保证(或缺乏保证)
大多数现有的批处理系统都是根据这种锁步读取-处理-分组-写入-重复的顺序进行设计的。在这种情况下,很难提供任何更精细的触发能力,因为它们可能会在管道的最终洗牌阶段才会表现出任何变化。然而,这并不意味着用户指定的触发器不会被尊重;触发器的语义是可以在适当的时候采用更低的共同分母。
例如,AfterWatermark触发器意味着在水印通过窗口结束时触发。 它不保证水印在触发时距离窗口结束有多远。 同样,AfterCount(N)触发器只保证在触发之前已处理至少 N个元素;N很可能是输入集中的所有元素。
请注意,触发器名称的巧妙措辞并不仅仅是为了适应经典的批处理系统,而是模型本身的一个非常必要的部分,考虑到触发的自然异步性和不确定性。 即使在经过精心调整的低延迟真正流式处理系统中,基本上不可能保证AfterWatermark触发器会在水印恰好在任何给定窗口的结束时触发,除非在极端有限的情况下(例如,单台机器处理管道的所有数据,并且负载相对较小)。 即使您可以保证,真的有什么意义吗? 触发器提供了一种控制数据从表到流的流动的手段,仅此而已。
批处理和流处理的融合
根据我们在本文中学到的知识,应该清楚批处理和流处理系统之间的主要语义区别是触发表的增量能力。 但即使这也不是真正的语义区别,而更多的是延迟/吞吐量的权衡(因为批处理系统通常以更高的吞吐量换取更高的结果延迟)。
这可以追溯到我在“批处理和流处理效率差异”中所说的一些内容:今天批处理和流处理系统之间实际上没有太大的区别,除了效率差异(有利于批处理)和处理无界数据的自然能力(有利于流处理)。 我当时认为,这种效率差异很大程度上来自于更大的捆绑大小(明确地在延迟和吞吐量之间进行折衷)和更有效的洗牌实现(即,流→表→流转换)。 从这个角度来看,应该可以提供一个系统,它可以无缝地整合两者的优点:既可以自然地处理无界数据,又可以通过透明地调整捆绑大小、洗牌实现和其他实现细节来平衡延迟、吞吐量和成本之间的紧张关系,以满足广泛的用例。
这正是 Apache Beam 在 API 级别已经做到的。¹² 这里提出的论点是,在执行引擎级别也有统一的空间。 在这样的世界中,批处理和流处理将不再存在,我们将能够永远告别批处理和流处理作为独立的概念。 我们将只有结合了两者最佳思想的通用数据处理系统,以提供特定用例的最佳体验。 某一天。
在这一点上,我们可以在触发部分插入叉子。 它完成了。 在我们全面了解 Beam 模型和流和表理论之间关系的过程中,我们只有一个更简短的停留:累积。
如何:累积
在第二章中,我们了解到三种累积模式(丢弃、累积、累积和撤销¹³)告诉我们结果的细化如何与窗口在其生命周期内多次触发相关。 幸运的是,在这里与流和表的关系非常直接:
-
丢弃模式要求系统在触发时要么丢弃窗口的先前值,要么保留先前值的副本并在下次窗口触发时计算增量¹⁴。(这种模式最好被称为增量模式。)
-
累积模式不需要额外的工作;在触发时表中窗口的当前值就会被发出。(这种模式最好被称为值模式。)
-
累积和撤回模式需要保留窗口中所有先前触发的(但尚未撤回)值的副本。在合并窗口(如会话)的情况下,先前值的列表可能会变得非常大,但对于干净地撤销先前触发的效果是至关重要的,因为新值不能简单地用于覆盖先前的值。(这种模式最好被称为值和撤回模式。)
流和表的可视化对累积模式的语义几乎没有额外的洞察力,因此我们不会在这里进行调查。
Beam 模型中流和表的整体视图
在解决了这四个问题之后,我们现在可以对 Beam 模型流水线中的流和表进行整体视图。让我们以我们的运行示例(团队得分计算流水线)为例,看看它在流和表级别的结构是什么样子。流水线的完整代码可能类似于示例 6-6(重复示例 6-4)。
示例 6-6。我们完整的分数解析流水线
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
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());
将其分解为由中间的PCollection类型分隔的阶段(我使用了更语义化的“类型”名称,如Team和User Score,而不是真实类型,以便清楚地说明每个阶段发生了什么),你会得到类似于图 6-13 所示的东西。

图 6-13。团队得分总和流水线的逻辑阶段,带有中间的 PCollection 类型
当您实际运行此流水线时,它首先通过优化器,其工作是将此逻辑执行计划转换为经过优化的物理执行计划。每个执行引擎都是不同的,因此实际的物理执行计划将在运行程序之间有所不同。但是一个可信的计划可能看起来像图 6-14。

图 6-14。团队得分总和流水线的理论物理阶段,带有中间的 PCollection 类型
这里有很多事情要做,所以让我们逐一讨论。图 6-13 和 6-14 之间有三个主要区别,我们将讨论:
逻辑与物理操作
作为构建物理执行计划的一部分,底层引擎必须将用户提供的逻辑操作转换为引擎支持的一系列原始操作。在某些情况下,这些物理等价物看起来基本相同(例如Parse),而在其他情况下,它们则非常不同。
物理阶段和融合
在流水线中,将每个逻辑阶段作为完全独立的物理阶段执行通常是低效的(伴随着每个阶段之间的序列化、网络通信和反序列化开销)。因此,优化器通常会尝试将尽可能多的物理操作融合成单个物理阶段。
键、值、窗口和分区
为了更清楚地说明每个物理操作正在做什么,我已经注释了中间的PCollection,并注明了每个点的键、值、窗口和数据分区的类型。
现在让我们详细地走一遍每个逻辑操作,看看它在物理计划中是如何转换的,以及它们如何与流和表相关联:
ReadFromSource
除了与紧随其后的物理操作融合在一起(Parse),ReadFromSource的翻译中并没有太多有趣的事情发生。就我们目前数据的特征而言,因为读取基本上是消耗原始输入字节,我们基本上有没有键、没有窗口和没有(或随机的)分区的原始字符串。原始数据源可以是表(例如 Cassandra 表)或流(例如 RabbitMQ)或类似两者的东西(例如处于日志压缩模式的 Kafka)。但无论如何,从输入源读取的最终结果是一个流。
Parse
逻辑Parse操作也以相对直接的方式转换为物理版本。Parse从原始字符串中提取键(团队 ID)和值(用户得分)。这是一个非分组操作,因此它消耗的流仍然是另一侧的流。
Window+Trigger
这个逻辑操作分布在许多不同的物理操作中。首先是窗口分配,其中每个元素被分配到一组窗口中。这立即发生在AssignWindows操作中,这是一个非分组操作,只是用窗口注释流中的每个元素,使其属于的窗口,产生另一个流。
第二个是窗口合并,我们在本章前面学到的,作为分组操作的一部分发生。因此,它被沉入管道后面的GroupMergeAndCombine操作中。我们在下一个逻辑Sum操作时讨论该操作。
最后,有触发。触发发生在分组之后,是我们将由分组创建的表转换回流的方式。因此,它被沉入自己的操作中,跟随GroupMergeAndCombine。
Sum
求和实际上是一个复合操作,由几个部分组成:分区和聚合。分区是一个非分组操作,以这样的方式重定向流中的元素,使得具有相同键的元素最终进入同一台物理机。分区的另一个词是洗牌,尽管这个术语有点过载,因为“Shuffle”在 MapReduce 意义上通常用来表示分区和分组(和排序,无论如何)。无论如何,分区在物理上改变了流,使其可以分组,但实际上并没有做任何事情来使数据真正停下来。因此,它是一个非分组操作,产生另一个流。
分区后是分组。分组本身是一个复合操作。首先是按键分组(由先前的按键分区操作启用)。接下来是窗口合并和按窗口分组,正如我们之前描述的那样。最后,因为求和在 Beam 中是作为CombineFn实现的(本质上是一个增量聚合操作),所以有组合,即当单个元素到达时将它们相加。具体细节对我们来说并不是非常重要的。重要的是,由于这显然是一个分组操作,我们的流链现在最终停留在一个包含随着时间演变的团队总分的表中。
WriteToSink
最后,我们有写操作,它接收触发产生的流(你可能还记得它被沉入GroupMergeAndCombine操作下面),并将其写入到我们的输出数据汇中。数据本身可以是表或流。如果是表,WriteToSink将需要执行某种分组操作来将数据写入表中。如果是流,就不需要分组(尽管可能仍然需要分区;例如,当写入类似 Kafka 的东西时)。
这里的重点不是物理计划中正在进行的一切的精确细节,而是 Beam 模型与流和表世界的整体关系。我们看到了三种类型的操作:非分组(例如,解析),分组(例如,GroupMergeAndCombine),和取消分组(例如,触发)。非分组操作总是消耗流并在另一侧产生流。分组操作总是消耗流并产生表。取消分组操作消耗表并产生流。这些见解以及我们一路学到的一切足以让我们制定关于 Beam 模型与流和表关系的更一般理论。
流和表相对论的一般理论
在调查了流处理、批处理、四个什么/哪里/何时/如何问题以及整个 Beam 模型与流和表理论的关系之后,现在让我们试图阐明流和表相对论的更一般定义。
流和表相对论的一般理论:
-
数据处理管道(批处理和流处理)包括对这些表和流进行的操作。
-
表是静止的数据,作为数据积累和随时间观察的容器。
-
流是运动中的数据,并编码了随时间演变的表的离散视图。
-
操作作用于流或表并产生新的流或表。它们被分类如下:
-
流 → 流:非分组(逐元素)操作
对流应用非分组操作会改变流中的数据,同时保持它们在运动中,产生一个可能具有不同基数的新流。
-
流 → 表:分组操作
在流中对数据进行分组会使这些数据静止下来,产生一个随时间演变的表。
-
窗口化将事件时间维度纳入这样的分组中。
-
合并窗口会随时间动态组合,使它们能够根据观察到的数据重新塑造自己,并决定键保持原子性/并行化的单位,窗口作为该键内分组的子组件。
-
-
表 → 流:取消分组(触发)操作
在表中触发数据会将它们取消分组并投入运动,产生一个捕获表随时间演变的流。
-
水印提供了相对于事件时间的输入完整性概念,这是一个有用的参考点,特别是在触发事件时间戳数据时,特别是从无界流中分组的数据。
-
触发器的累积模式决定了流的性质,决定它是否包含增量或值,以及是否提供先前增量/值的撤销。
-
-
表 → 表:(无)
没有操作可以消耗表并产生表,因为数据不可能在不被投入运动的情况下从静止到静止。因此,对表的所有修改都是通过转换为流,然后再转换回来。
-
我喜欢这些规则的原因是它们很有道理。它们给人一种非常自然和直观的感觉,因此使人更容易理解数据如何通过一系列操作流动(或不流动)。它们规范了这样一个事实:在任何给定时间,数据存在于两种状态中的一种(流或表格),并且它们提供了简单的规则来推理这些状态之间的转换。它们通过展示窗口化只是每个人都本能理解的分组的轻微修改来揭示窗口化的神秘。它们强调了为什么分组操作通常是流处理中的一个难点(因为它们将流中的数据转化为表格),但也非常清楚地表明了需要哪些操作来解决这个问题(触发器;即非分组操作)。它们强调了在概念上批处理和流处理实际上是多么统一。
当我开始写这一章时,我并不完全确定最终会得到什么,但最终的结果比我想象的要令人满意得多。在接下来的章节中,我们将再次使用这种流和表格相对论的理论来指导我们的分析。每一次应用都会带来清晰和洞察力,否则这些洞察力将会更难获得。流和表格是最好的。
总结
在这一章中,我们首先建立了流和表格理论的基础。我们首先相对地定义了流和表格:
流 → 表格
随着时间的推移,对更新流的聚合会产生一个表格。
表格 → 流
随着时间的推移观察表格的变化会产生一个流。
接下来我们独立定义它们:
-
表格是数据静止的。
-
流是数据在运动中。
然后,我们从流和表格的角度评估了经典的 MapReduce 批处理计算模型,并得出结论,以下四个步骤描述了从这个角度进行的批处理:
-
表格被完整地读取以成为流。
-
流被处理成新的流,直到遇到分组操作。
-
分组将流转换为表格。
-
步骤 1 到 3 重复,直到管道中的操作用尽。
通过这种分析,我们能够看到流在批处理中和流处理中同样重要,以及数据是流的想法与所讨论的数据是有界还是无界是无关的。
接下来,我们花了很多时间考虑流和表格之间的关系,以及 Beam 模型提供的强大的、无序的流处理语义,最终得出了我们在前一节中列举的流和表格相对论的一般理论。除了流和表格的基本定义之外,该理论的关键见解是数据处理管道中有四(实际上只有三)种操作类型:
流 → 流
非分组(逐元素)操作
流 → 表格
分组操作
表格 → 流
非分组(触发)操作
表格 → 表格
(不存在)
通过这种方式对操作进行分类,可以轻松地理解数据如何随着时间在给定的管道中流动(或停留)。
最后,也许最重要的是,我们学到了这一点:当你从流和表格的角度看问题时,批处理和流处理在概念上实际上是一样的。有界或无界都无所谓。从头到尾都是流和表格。
¹ 如果你不是为了计算机科学而上大学,但你已经读到了这本书的这一部分,你很可能是 1)我的父母,2)受虐狂,或者 3)非常聪明(就记录而言,我并不意味着这些群体必然是互相排斥的;如果你能理解这一点,妈妈和爸爸,就自己想想吧!
2 请注意,在某些情况下,表本身可以接受时间作为查询参数,允许您向过去查看表的快照。
3 请注意,对于单个 mapper 观察到的两个连续记录的键,没有任何保证,因为尚未进行键分组。这里的键实际上只是为了让带键数据集以一种自然的方式被消费,如果输入数据没有明显的键,它们实际上都将共享一个全局的空键。
4 将批处理作业的输入称为“静态”可能有点过分。实际上,被消费的数据集在处理过程中可能会不断变化;也就是说,如果你直接从 HBase/Bigtable 表中读取在时间戳范围内的数据,这些数据并不保证是不可变的。但在大多数情况下,建议的方法是确保你以某种方式处理了输入数据的静态快照,任何偏离这一假设的情况都是自己的风险。
5 请注意,按键对流进行分组与简单地按键对流进行分区是有重要区别的,后者确保具有相同键的所有记录最终由同一台机器处理,但并不会使记录停止。它们仍然保持运动,因此继续作为流进行。分组操作更像是按键分区后写入适当分区的组,这是使它们停止并将流转换为表的原因。
6 一个巨大的区别,至少从实现的角度来看,是 ReduceWrite 知道键已经被 MapWrite 分组在一起,进一步知道 Reduce 无法改变键,因此它可以简单地累积减少值生成的输出,以便将它们分组在一起,这比 MapWrite 阶段所需的完整洗牌实现要简单得多。
7 另一种看待这个问题的方式是,有两种类型的表:可更新的和可追加的;这是 Flink 团队为他们的 Table API 所构建的方式。但即使这是捕捉到两种情况的观察语义的一个很好的直观方式,我认为它掩盖了实际发生的导致流变成表的基本本质;也就是分组。
8 尽管从这个例子中我们可以清楚地看到,这不仅仅是一个流处理的问题;如果批处理系统的状态表是全局可读的,你也可以得到相同的效果。
9 如果你的存储系统中还没有适合的接收器,这将特别痛苦;构建能够保证一致性的适当接收器是一个令人惊讶地微妙和困难的任务。
10 这也意味着,如果你将一个值放入多个窗口——例如滑动窗口——这个值在概念上必须被复制成多个独立的记录,每个窗口一个。即便如此,在某些情况下,底层系统可以智能地处理某些类型的重叠窗口,从而优化掉实际复制值的需要。例如,Spark 就为滑动窗口做到了这一点。
11 请注意,批处理管道中事物工作的这种高层概念视图掩盖了有效触发整个数据表的复杂性,特别是当该表足够大以至于需要多台机器来处理时。Beam 最近添加的 SplittableDoFn API 提供了一些关于涉及的机制的见解。
12 是的,如果你将批处理和流处理混合在一起,你就会得到 Beam,这也是这个名字最初的由来。真的。
13 这就是为什么你应该始终使用牛津逗号。
¹⁴请注意,在合并窗口的情况下,除了合并两个窗口的当前值以得到合并后的当前值之外,还需要合并这两个窗口的先前值,以便在触发时间后进行合并增量的计算。
第七章:持久状态的实际性
人们为什么写书?当你排除了创造的乐趣、对语法和标点的某种喜爱,也许偶尔的自恋,你基本上只剩下了捕捉本来是短暂的想法,以便将来可以重新访问。在非常高的层面上,我刚刚激发并解释了数据处理管道中的持久状态。
持久状态,确切地说,就是我们在第六章中讨论过的表,额外的要求是这些表要稳固地存储在相对不易丢失的介质上。存储在本地磁盘上是可以的,只要你不问你的网站可靠性工程师。存储在一组复制的磁盘上更好。存储在不同物理位置的一组复制的磁盘上更好。存储在内存中绝对不算数。存储在多台机器上的复制内存,配备 UPS 电源备份和现场发电机,也许可以算数。你明白了。
在本章中,我们的目标是做以下事情:
-
激发管道内持久状态的需求
-
看看管道内经常出现的两种隐式状态形式
-
考虑一个现实世界的用例(广告转化归因),它本身不适合隐式状态,用它来激发一般显式持久状态管理的显著特点
-
探索一个具体的状态 API 的实例,就像在 Apache Beam 中找到的那样
动机
首先,让我们更准确地激发持久状态。我们从第六章知道,分组是给我们提供表的东西。而我在本章开头提出的核心观点是正确的:持久化这些表的目的是捕获其中包含的本来是短暂的数据。但为什么这是必要的呢?
失败的必然性
这个问题的答案在处理无界输入数据的情况下最清楚,所以我们从那里开始。主要问题是处理无界数据的管道实际上是打算永远运行的。但永远运行是一个更具挑战性的服务级别目标,远远超出了这些管道通常执行的环境所能实现的。长时间运行的管道将不可避免地因为机器故障、计划维护、代码更改以及偶尔的配置错误命令而中断整个生产管道集群。为了确保它们可以在这些情况发生时恢复到中断之前的状态,长时间运行的管道需要某种持久的记忆来记录它们中断之前的位置。这就是持久状态的作用。
让我们在无界数据之外再扩展一下这个想法。这只在无界情况下才相关吗?批处理管道使用持久状态吗,为什么或为什么不?与我们遇到的几乎每一个批处理与流处理的问题一样,答案与批处理和流处理系统本身的性质无关(也许这并不奇怪,鉴于我们在第六章学到的东西),而更多地与它们历史上用于处理的数据集类型有关。
有界数据集本质上是有限大小的。因此,处理有界数据的系统(历史上是批处理系统)已经针对这种情况进行了调整。它们通常假设在失败时可以重新处理输入的全部内容。换句话说,如果处理管道的某个部分失败,如果输入数据仍然可用,我们可以简单地重新启动处理管道的适当部分,让它再次读取相同的输入。这被称为重新处理输入。
他们可能还会假设失败不太频繁,因此会尽量少地进行持久化,接受在失败时重新计算的额外成本。对于特别昂贵的多阶段管道,可能会有某种每阶段全局检查点的方式,以更有效地恢复执行(通常作为洗牌的一部分),但这并不是严格要求,可能在许多系统中都不存在。
另一方面,无界数据集必须假定具有无限大小。因此,处理无界数据的系统(历史上的流处理系统)已经建立起来。它们从不假设所有数据都可用于重新处理,只假设其中的某个已知子集可用。为了提供至少一次或精确一次的语义,任何不再可用于重新处理的数据必须在持久检查点中得到考虑。如果最多一次是您的目标,您就不需要检查点。
归根结底,持久状态并不是批处理或流处理特有的。状态在这两种情况下都是有用的。只是在处理无界数据时,它变得至关重要,因此您会发现流处理系统通常提供更复杂的持久状态支持。
正确性和效率
考虑到失败的不可避免性和应对失败的需要,持久状态可以被视为提供两个东西:
-
在处理暂时输入时,提供正确性的基础。在处理有界数据时,通常可以安全地假设输入会永远存在;¹对于无界数据,这种假设通常不符合现实。持久状态允许您保留必要的中间信息,以便在不可避免的情况发生时继续处理,即使您的输入源已经移动并且忘记了之前提供给您的记录。
-
一种最小化重复工作和持久化数据的方式,作为应对失败的一部分。无论您的输入是暂时的,当您的管道遇到机器故障时,任何未在某个地方进行检查点的失败机器上的工作都必须重新进行。根据管道的性质和其输入,这在两个方面可能是昂贵的:重新处理期间执行的工作量以及存储以支持重新处理的输入数据量。
最小化重复工作相对比较简单。通过在管道内部进行部分进度的检查点(计算的中间结果以及检查点时间内的当前输入位置),可以大大减少失败发生时重复工作的量,因为检查点之前的操作都不需要从持久输入中重新播放。最常见的是,这涉及到静态数据(即表),这就是为什么我们通常在表和分组的上下文中提到持久状态。但是也有流的持久形式(例如 Kafka 及其相关产品)可以起到这样的作用。
最小化持久化数据量是一个更大的讨论,这将占据本章的相当大一部分。至少目前可以说,对于许多真实用例,与其记住管道中任何给定阶段的所有原始输入,通常实际上记住一些部分的中间形式更为实际,这些中间形式占用的空间比所有原始输入要少(例如,在计算平均值时,总和和值的计数比贡献到总和和计数的完整值列表更紧凑)。检查点这些中间数据不仅可以大大减少您需要在管道中任何给定点记住的数据量,而且还可以相应地减少从失败中恢复所需的重新处理量。
此外,通过智能地对那些不再需要的持久状态进行垃圾回收(即已知已被管道完全处理的记录的状态),即使输入在技术上是无限的,也可以随着时间的推移将存储在给定管道的持久状态中的数据保持在可管理的大小,这样处理无界数据的管道就可以继续有效地运行,同时仍然提供强一致性保证,但不需要完全回忆管道的原始输入。
归根结底,持久状态实际上只是在数据处理管道中提供正确性和高效的容错的手段。在这两个方面所需的支持程度取决于管道输入的性质和正在执行的操作。无界输入往往需要比有界输入更多的正确性支持。计算昂贵的操作往往需要比计算廉价的操作更多的效率支持。
隐式状态
现在让我们开始谈论持久状态的实际情况。在大多数情况下,这基本上归结为在始终持久化一切(对一致性有利,对效率不利)和从不持久化任何东西(对一致性不利,对效率有利)之间找到合适的平衡。我们将从始终持久化一切的极端端点开始,并朝着另一个方向前进,看看如何在不损害一致性的情况下权衡实现复杂性以换取效率(因为通过从不持久化任何东西来牺牲一致性是一种简单的解决方案,对于一致性无关紧要的情况来说,但在其他情况下是不可选的)。与以前一样,我们使用 Apache Beam API 来具体地落实我们的讨论,但我们讨论的概念适用于今天存在的大多数系统。
此外,由于在原始输入中几乎没有可以减少大小的方法,除了可能压缩数据,我们的讨论重点是围绕在管道内进行分组操作时创建的中间状态表中数据的持久化方式。将多个记录聚合到某种复合形式中的固有性质将为我们提供机会,在实现复杂性的代价下获得效率上的收益。
原始分组
我们探索的第一步是在持续保持一切的极端端点,即在管道内进行最直接的分组实现:对输入进行原始分组。在这种情况下,分组操作通常类似于列表追加:每当新元素到达组时,它都会被追加到该组已见元素的列表中。
在 Beam 中,当您将GroupByKey转换应用于PCollection时,您将获得的正是这种状态。代表该PCollection的流在运动中被按键分组,以产生一个包含来自流的记录的静态表,²以相同键的值的列表分组在一起。这显示在GroupByKey的PTransform签名中,它声明输入为K/V对的PCollection,输出为K/Iterable<V>对的集合:
class GroupByKey<K, V> extends PTransform<
PCollection<KV<K, V>>, PCollection<KV<K, Iterable<V>>>>>
每当表中的键+窗口触发时,它将为该键+窗口发出一个新的窗格,值为我们在前面签名中看到的Iterable<V>。
让我们在示例 7-1 中看一个示例。我们将从示例 6-5 中的求和流水线(具有固定窗口和早期/准时/延迟触发)转换为使用原始分组而不是增量组合(我们稍后在本章中讨论)。我们首先对解析的用户/分数键值对应用GroupByKey转换。GroupByKey操作执行原始分组,产生一个具有用户和分数组的PCollection键值对。然后,我们通过使用一个简单的MapElements lambda 将每个可迭代的Integer相加,将Integer的所有值相加起来,将Iterable<Integer>转换为IntStream<Integer>并在其上调用sum。
示例 7-1。通过早期/准时/延迟 API 进行早期、准时和延迟触发
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> groupedScores = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(
AfterWatermark()
.withEarlyFirings(AlignedDelay(ONE_MINUTE))
.withLateFirings(AfterCount(1))))
.apply(GroupBy.<String, Integer>create());
PCollection<KV<Team, Integer>> totals = input
.apply(MapElements.via((KV<String, Iterable<Integer>> kv) ->
StreamSupport.intStream(
kv.getValue().spliterator(), false).sum()));
观察这个流水线的运行,我们会看到类似于图 7-1 所示的情况。
<资产/stsy_0701.mp4>

图 7-1。通过窗口化和早期/准时/延迟触发的原始输入进行求和。原始输入被分组在一起,并通过 GroupByKey 转换存储在表中。在被触发后,MapElements lambda 将单个窗格内的原始输入相加,得出每个团队的得分。
与图 6-10 进行比较(该图使用了增量组合,稍后讨论),很明显可以看出这是更糟糕的。首先,我们存储了更多的数据:不再是每个窗口一个整数,而是现在存储了该窗口的所有输入。其次,如果我们有多个触发触发,我们会重复努力,重新对已经添加到以前触发触发的输入进行求和。最后,如果分组操作是我们将状态检查点到持久存储的地方,那么在机器故障时,我们必须重新计算表的任何重新触发的总和。这是大量重复的数据和计算。更好的做法是增量计算和检查点实际的总和,这是增量组合的一个例子。
增量组合
我们在将实现复杂性交换为效率的旅程中的第一步是增量组合。这个概念通过CombineFn类在 Beam API 中体现出来。简而言之,增量组合是一种自动状态,建立在用户定义的可结合和可交换的组合操作符之上(如果你不确定我所说的这两个术语是什么意思,我马上会更准确地定义它们)。虽然这对接下来的讨论并不是严格必要的,但 CombineFn API 的重要部分看起来像示例 7-2。
示例 7-2。来自 Apache Beam 的简化 CombineFn API
class CombineFn<InputT, AccumT, OutputT> {
// Returns an accumulator representing the empty value.
AccumT createAccumulator();
// Adds the given input value into the given accumulator
AccumT addInput(AccumT accumulator, InputT input);
// Merges the given accumulators into a new, combined accumulator
AccumT mergeAccumulators(Iterable<AccumT> accumulators);
// Returns the output value for the given accumulator
OutputT extractOutput(AccumT accumulator);
}
CombineFn接受类型为InputT的输入,可以将其组合成称为累加器的部分聚合,类型为AccumT。这些累加器本身也可以组合成新的累加器。最后,累加器可以转换为类型为OutputT的输出值。对于像平均值这样的东西,输入可能是整数,累加器可能是整数对(即Pair<输入总和,输入计数>),输出是表示组合输入的平均值的单个浮点值。
但是,这种结构给我们带来了什么?从概念上讲,增量组合的基本思想是,许多类型的聚合(求和、平均值等)表现出以下特性:
-
增量聚合具有一个中间形式,它捕获了组合一组 N 个输入的部分进展,比这些输入本身的完整列表更紧凑(即
CombineFn中的AccumT类型)。如前所述,对于平均值来说,这是一个总和/计数对。基本求和甚至更简单,它的累加器是一个单一的数字。直方图的累加器相对复杂,由桶组成,每个桶包含在某个特定范围内看到的值的计数。然而,在这三种情况下,表示 N 个元素聚合的累加器所占用的空间仍然明显小于原始 N 个元素本身所占用的空间,特别是当 N 的大小增长时。 -
增量聚合对两个维度的排序都是漠不关心的:
-
单个元素,意味着:
COMBINE(a, b) == COMBINE(b, a) -
元素的分组,意味着:
COMBINE(COMBINE(a, b), c) == COMBINE(a, COMBINE(b, c))
这些属性分别被称为可交换性和结合性。在一起,它们有效地意味着我们可以自由地以任意顺序和任意分组组合元素和部分聚合。这使我们能够通过两种方式优化聚合:
增量化
因为个别输入的顺序并不重要,我们不需要提前缓冲所有的输入,然后按照某种严格的顺序处理它们(例如,按事件时间顺序;注意,这仍然独立于按事件时间将元素洗牌到适当的事件时间窗口中进行聚合);我们可以在它们到达时逐个组合它们。这不仅极大地减少了必须缓冲的数据量(由于我们操作的第一个属性,即中间形式是部分聚合的更紧凑表示,而不是原始输入本身),而且还可以更均匀地分散计算负载的负担(与在缓冲完整输入集之后一次性聚合输入的负担相比)。
并行化
因为部分输入子组的组合顺序并不重要,我们可以任意分配这些子组的计算。更具体地说,我们可以将这些子组的计算分散到多台机器上。这种优化是 MapReduce 的
Combiners(Beam 的CombineFn的起源)的核心。MapReduce 的 Combiner 优化对解决热键问题至关重要,其中对输入流进行某种分组计算的数据量太大,无法由单个物理机器合理处理。一个典型的例子是将高容量的分析数据(例如,流量到一个热门网站的网页浏览量)按相对较少的维度(例如,按浏览器系列:Chrome,Firefox,Safari 等)进行分解。对于流量特别高的网站,即使该机器专门用于计算统计数据,也很难在单台机器上计算任何单个网页浏览器系列的统计数据;流量太大,无法跟上。但是,通过类似求和这样的结合和交换操作,可以将初始聚合分布到多台机器上,每台机器计算一个部分聚合。然后,这些机器生成的部分聚合集合(其大小现在比原始输入小几个数量级)可以在单台机器上进一步组合在一起,得到最终的聚合结果。
顺便说一句,这种并行化的能力还带来了一个额外的好处:聚合操作自然与合并窗口兼容。当两个窗口合并时,它们的值也必须以某种方式合并。对于原始分组来说,这意味着将两个完整的缓冲值列表合并在一起,其成本为 O(N)。但是对于
CombineFn来说,这只是两个部分聚合的简单组合,通常是 O(1)的操作。 -
为了完整起见,再考虑一下示例 6-5,如示例 7-3 所示,它使用增量组合实现了一个求和管道。
示例 7-3。通过增量组合进行分组和求和,就像示例 6-5 中那样
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
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());
执行时,我们得到了我们在图 6-10 中看到的结果(在这里显示为图 7-2)。与图 7-1 相比,这显然是一个很大的改进,存储的数据量和执行的计算量都大大提高了效率。
<资产/stsy_0702.mp4>

图 7-2。通过增量组合进行分组和求和。在这个版本中,增量和被计算并存储在表中,而不是输入的列表,这些列表必须在以后独立地进行求和。
通过为分组操作提供更紧凑的中间表示,并放宽对排序的要求(在元素和子组级别),Beam 的CombineFn在实现复杂性方面进行了一定程度的折衷,以换取效率的提高。这样做,它为热键问题提供了一个清晰的解决方案,并且与合并窗口的概念相互配合。
然而,一个缺点是,您的分组操作必须适合相对受限的结构。这对于求和、平均值等来说都很好,但在许多真实世界的用例中,需要更一般的方法,这种方法允许对复杂性和效率的权衡进行精确控制。接下来我们将看看这种一般方法包括什么。
广义状态
尽管我们迄今为止看到的两种隐式方法都各有其优点,但它们在某一方面都存在不足:灵活性。原始分组方法要求您在处理整个组之前始终缓冲原始输入到分组操作,因此在途中无法部分处理一些数据;要么全部处理,要么不处理。增量组合方法专门允许部分处理,但限制了所处理的处理必须是可交换和可结合的,并且是逐个记录到达时发生的。
如果我们想要支持更广义的流式持久状态方法,我们需要更灵活的东西。具体来说,我们需要在三个方面灵活:
-
数据结构的灵活性;也就是说,我们写入和读取数据的能力,以最适合和最有效的方式进行结构化。原始分组基本上提供了一个可附加的列表,而增量组合基本上提供了一个始终以其全部写入和读取的单个值。但是,我们可能希望以其他无数种方式来结构化我们的持久数据,每种方式都具有不同类型的访问模式和相关成本:映射、树、图、集合等等。支持各种持久数据类型对于效率至关重要。
Beam 通过允许单个
DoFn声明多个特定类型的状态字段来支持数据类型的灵活性。通过这种方式,逻辑上独立的状态片段(例如访问和印象)可以分别存储,并且语义上不同类型的状态(例如映射和列表)可以以符合其访问模式类型的方式进行访问。 -
写入和读取的灵活性;也就是说,能够根据需要调整在任何给定时间写入或读取的数据量和类型,以实现最佳效率。归根结底,这意味着能够在任何给定时间点精确地写入和读取必要数量的数据:不多,也不少(并且尽可能并行)。
这与前面的观点相辅相成,因为专用数据类型允许专注于特定类型的访问模式(例如,可以使用类似 Bloom 过滤器的东西来极大地减少在某些情况下读取的数据量)。但它不仅限于此;例如,允许多个大型读取并行分派(例如,通过 futures)。
在 Beam 中,通过特定数据类型的 API 实现了灵活的粒度写入和读取,这些 API 提供了细粒度的访问能力,结合了异步 I/O 机制,可以将写入和读取批量处理以提高效率。
-
处理调度的灵活性;也就是说,能够将特定类型的处理发生的时间与我们关心的两种时间域中的时间进展绑定在一起:事件时间的完整性和处理时间。触发器在这里提供了一定程度的灵活性,完整性触发器提供了一种将处理绑定到窗口结束时通过水印的方式,而重复更新触发器提供了一种将处理绑定到处理时间域中定期进展的方式。但对于某些用例(例如,某些类型的连接,对于这些连接,您不一定关心整个窗口的输入完整性,只关心连接中特定记录的事件时间之前的输入完整性),触发器的灵活性不够。因此,我们需要一个更通用的解决方案。
在 Beam 中,通过定时器提供了灵活的处理调度。定时器是一种特殊类型的状态,它将支持的时间域(事件时间或处理时间)中的特定时间点与在达到该时间点时要调用的方法绑定。通过这种方式,特定的处理可以延迟到未来更合适的时间。
这三个特征之间的共同点是灵活性。一些特定的用例子集通过原始分组或增量组合的相对不灵活的方法得到了很好的服务。但是,当处理超出它们相对狭窄的专业领域时,这些选项通常表现不佳。当发生这种情况时,您需要全面通用状态 API 的强大灵活性,以便您可以最佳地定制持久状态的利用。
换个角度来看,原始分组和增量组合是相对高级的抽象,可以简洁地表达具有(至少在组合器的情况下)一些良好属性的管道。但有时,您需要降低级别以获得所需的行为或性能。这就是通用状态让您能够做到的。
案例研究:转化归因
为了看到这一点,现在让我们来看一个既不受原始分组也不受增量组合良好服务的用例:转化归因。这是广告界广泛使用的一种技术,用于提供有关广告效果的具体反馈。尽管相对容易理解,但它的一些多样化要求并不完全适合我们迄今考虑的两种类型的隐式状态。
想象一下,您有一个分析管道,监视网站的流量以及将流量引导到该网站的广告印象。目标是将显示给用户的特定广告归因于网站本身的某个目标的实现(通常可能远远超出初始广告着陆页面的许多步骤),例如注册邮件列表或购买物品。
图 7-3 显示了一个网站访问、目标和广告展示的示例集合,其中一个归因转化以红色突出显示。在无界、无序的数据流中建立转化归因需要跟踪到目前为止所见的展示、访问和目标。这就是持久状态的作用所在。

图 7-3. 示例转化归因
在这个图表中,用户在网站上浏览各种页面的过程被表示为一个图形。展示是向用户展示并被点击的广告,导致用户访问网站上的页面。访问代表在网站上查看的单个页面。目标是被确定为用户的期望目的地的特定访问页面(例如,完成购买或注册邮件列表)。转化归因的目标是识别导致用户在网站上实现某个目标的广告展示。在这个图中,有一个这样的转化以红色突出显示。请注意,事件可能以无序方式到达,因此图表中有事件时间轴和水印参考点,指示认为输入正确的时间。
构建一个强大的大规模归因管道需要投入大量精力,但有一些方面值得明确指出。我们尝试构建的任何这样的管道必须做到以下几点:
处理无序数据
由于网站流量和广告展示数据来自分别作为分布式收集服务的系统,这些数据可能以极其无序的方式到达。因此,我们的管道必须对这种无序性具有弹性。
处理大量数据
我们不仅必须假设这个管道将处理大量独立用户的数据,而且根据给定广告活动的规模和给定网站的受欢迎程度,我们可能需要存储大量的展示和/或流量数据,以便我们尝试建立归因的证据。例如,为了让我们能够建立跨越多个月活动的归因,存储 90 天的访问、展示和目标树⁵数据对于每个用户来说并不罕见。
防范垃圾邮件
考虑到涉及到金钱,正确性至关重要。我们不仅必须确保访问和展示被准确计算一次(通过简单使用支持有效一次处理的执行引擎,我们基本上可以得到这一点),而且还必须保护我们的广告商免受试图不公平收费的垃圾邮件攻击。例如,同一用户连续多次点击同一广告将作为多个展示到达,但只要这些点击在一定时间内发生(例如在同一天内),它们只能被归因一次。换句话说,即使系统保证我们会看到每个单独的展示一次,我们还必须在技术上不同但根据我们的业务逻辑应该被解释为重复的展示之间执行一些手动去重。
优化性能
最重要的是,由于这个管道的潜在规模,我们必须始终关注优化管道的性能。由于写入持久存储的固有成本,持久状态往往会成为这种管道的性能瓶颈。因此,我们之前讨论的灵活性特征对于确保我们的设计尽可能高效至关重要。
使用 Apache Beam 进行转化归因
现在我们理解了我们要解决的基本问题,并且心中有一些重要的要求,让我们使用 Beam 的 State 和 Timers API 来构建一个基本的转化归因转换。我们将像在 Beam 中编写任何其他DoFn一样编写这个,但我们将利用状态和计时器扩展,允许我们编写和读取持久状态和计时器字段。那些想要在真实代码中跟随的人可以在GitHub上找到完整的实现。
请注意,与 Beam 中的所有分组操作一样,State API 的使用范围限定为当前的键和窗口,窗口的生命周期由指定的允许延迟参数决定;在这个例子中,我们将在一个全局窗口内操作。并行性是按键线性化的,就像大多数DoFns一样。还要注意,为了简单起见,我们将省略手动回收超出我们 90 天视野范围的访问和印象,这对于保持持久状态不断增长是必要的。
首先,让我们为访问、印象、访问/印象联合(用于连接)和已完成的归因定义一些 POJO 类,如示例 7-4 所示。
示例 7-4。Visit、Impression、VisitOrImpression 和 Attribution 对象的 POJO 定义
@DefaultCoder(AvroCoder.class)
class Visit {
@Nullable private String url;
@Nullable private Instant timestamp;
// The referring URL. Recall that we’ve constrained the problem in this
// example to assume every page on our website has exactly one possible
// referring URL, to allow us to solve the problem for simple trees
// rather than more general DAGs.
@Nullable private String referer;
@Nullable private boolean isGoal;
@SuppressWarnings("unused")
public Visit() {
}
public Visit(String url, Instant timestamp, String referer,
boolean isGoal) {
this.url = url;
this.timestamp = timestamp;
this.referer = referer;
this.isGoal = isGoal;
}
public String url() { return url; }
public Instant timestamp() { return timestamp; }
public String referer() { return referer; }
public boolean isGoal() { return isGoal; }
@Override
public String toString() {
return String.format("{ %s %s from:%s%s }", url, timestamp, referer,
isGoal ? " isGoal" : "");
}
}
@DefaultCoder(AvroCoder.class)
class Impression {
@Nullable private Long id;
@Nullable private String sourceUrl;
@Nullable private String targetUrl;
@Nullable private Instant timestamp;
public static String sourceAndTarget(String source, String target) {
return source + ":" + target;
}
@SuppressWarnings("unused")
public Impression() {
}
public Impression(Long id, String sourceUrl, String targetUrl,
Instant timestamp) {
this.id = id;
this.sourceUrl = sourceUrl;
this.targetUrl = targetUrl;
this.timestamp = timestamp;
}
public Long id() { return id; }
public String sourceUrl() { return sourceUrl; }
public String targetUrl() { return targetUrl; }
public String sourceAndTarget() {
return sourceAndTarget(sourceUrl, targetUrl);
}
public Instant timestamp() { return timestamp; }
@Override
public String toString() {
return String.format("{ %s source:%s target:%s %s }",
id, sourceUrl, targetUrl, timestamp);
}
}
@DefaultCoder(AvroCoder.class)
class VisitOrImpression {
@Nullable private Visit visit;
@Nullable private Impression impression;
@SuppressWarnings("unused")
public VisitOrImpression() {
}
public VisitOrImpression(Visit visit, Impression impression) {
this.visit = visit;
this.impression = impression;
}
public Visit visit() { return visit; }
public Impression impression() { return impression; }
}
@DefaultCoder(AvroCoder.class)
class Attribution {
@Nullable private Impression impression;
@Nullable private List<Visit> trail;
@Nullable private Visit goal;
@SuppressWarnings("unused")
public Attribution() {
}
public Attribution(Impression impression, List<Visit> trail, Visit goal) {
this.impression = impression;
this.trail = trail;
this.goal = goal;
}
public Impression impression() { return impression; }
public List<Visit> trail() { return trail; }
public Visit goal() { return goal; }
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("imp=" + impression.id() + " " + impression.sourceUrl());
for (Visit visit : trail) {
builder.append(" → " + visit.url());
}
builder.append(" → " + goal.url());
return builder.toString();
}
}
接下来,我们定义一个 Beam DoFn来消耗一个扁平化的Visit和Impression集合,以用户为键。反过来,它将产生一个Attribution集合。它的签名看起来像示例 7-5。
示例 7-5。用于我们的转化归因转换的 DoFn 签名
class AttributionFn extends DoFn<KV<String, VisitOrImpression>, Attribution>
在DoFn中,我们需要实现以下逻辑:
-
将所有访问存储在一个以它们的 URL 为键的映射中,这样我们可以在追踪访问路径时轻松查找它们。
-
将所有印象存储在一个以它们所引用的 URL 为键的映射中,这样我们可以识别引发通往目标的印象。
-
每当我们看到一个恰好是目标的访问时,为目标的时间戳设置一个事件时间计时器。与此计时器关联的是一个执行目标归因的方法。这将确保只有在导致目标的输入完成后才进行归因。
-
因为 Beam 缺乏对动态计时器集的支持(当前所有计时器必须在管道定义时声明,尽管每个单独的计时器可以在运行时的不同时间点设置和重置),我们还需要跟踪我们仍然需要归因的所有目标的时间戳。这将允许我们为所有待处理目标的最小时间戳设置一个单一的归因计时器。在我们归因最早时间戳的目标之后,我们再次使用下一个最早目标的时间戳设置计时器。
现在让我们逐步实现。首先,我们需要在DoFn中声明所有状态和计时器字段的规范。对于状态,规范规定了字段本身的数据结构类型(例如,映射或列表)以及其中包含的数据类型和它们关联的编码器;对于计时器,它规定了关联的时间域。然后,每个规范都被分配一个唯一的 ID 字符串(通过@StateID/@TimerId注释),这将允许我们动态地将这些规范与后续的参数和方法关联起来。对于我们的用例,我们将定义(在示例 7-6 中)以下内容:
-
两个用于访问和印象的
MapState规范 -
一个用于目标的
SetState规范 -
一个用于跟踪最小待处理目标时间戳的
ValueState规范 -
一个用于延迟归因逻辑的
Timer规范
示例 7-6。状态字段规范
class AttributionFn extends DoFn<KV<String, VisitOrImpression>, Attribution> {
@StateId("visits")
private final StateSpec<MapState<String, Visit>> visitsSpec =
StateSpecs.map(StringUtf8Coder.of(), AvroCoder.of(Visit.class));
// Impressions are keyed by both sourceUrl (i.e., the query) and targetUrl
// (i.e., the click), since a single query can result in multiple impressions.
// The source and target are encoded together into a single string by the
// Impression.sourceAndTarget method.
@StateId("impressions")
private final StateSpec<MapState<String, Impression>> impSpec =
StateSpecs.map(StringUtf8Coder.of(), AvroCoder.of(Impression.class));
@StateId("goals")
private final StateSpec<SetState<Visit>> goalsSpec =
StateSpecs.set(AvroCoder.of(Visit.class));
@StateId("minGoal")
private final StateSpec<ValueState<Instant>> minGoalSpec =
StateSpecs.value(InstantCoder.of());
@TimerId("attribution")
private final TimerSpec timerSpec =
TimerSpecs.timer(TimeDomain.EVENT_TIME);
... continued in Example 7-7 below ...
接下来,我们实现我们的核心@ProcessElement方法。这是每次新记录到达时都会运行的处理逻辑。正如前面所述,我们需要将访问和展示记录到持久状态中,并跟踪目标并管理将我们的归因逻辑绑定到事件时间完整性进展的定时器,由水印跟踪。对状态和定时器的访问是通过传递给我们的@ProcessElement方法的参数提供的,Beam 运行时使用@StateId和@TimerId注解指示适当的参数调用我们的方法。逻辑本身相对简单,如示例 7-7 所示。
示例 7-7。@ProcessElement 实现
... continued from Example 7-6 above ...
@ProcessElement
public void processElement(
@Element KV<String, VisitOrImpression> kv,
@StateId("visits") MapState<String, Visit> visitsState,
@StateId("impressions") MapState<String, Impression> impressionsState,
@StateId("goals") SetState<Visit> goalsState,
@StateId("minGoal") ValueState<Instant> minGoalState,
@TimerId("attribution") Timer attributionTimer) {
Visit visit = kv.getValue().visit();
Impression impression = kv.getValue().impression();
if (visit != null) {
if (!visit.isGoal()) {
LOG.info("Adding visit: {}", visit);
visitsState.put(visit.url(), visit);
} else {
LOG.info("Adding goal (if absent): {}", visit);
goalsState.addIfAbsent(visit);
Instant minTimestamp = minGoalState.read();
if (minTimestamp == null || visit.timestamp().isBefore(minTimestamp)) {
LOG.info("Setting timer from {} to {}",
Utils.formatTime(minTimestamp),
Utils.formatTime(visit.timestamp()));
attributionTimer.set(visit.timestamp());
minGoalState.write(visit.timestamp());
}
LOG.info("Done with goal");
}
}
if (impression != null) {
// Dedup logical impression duplicates with the same source and target URL.
// In this case, first one to arrive (in processing time) wins. A more
// robust approach might be to pick the first one in event time, but that
// would require an extra read before commit, so the processing-time
// approach may be slightly more performant.
LOG.info("Adding impression (if absent): {} → {}",
impression.sourceAndTarget(), impression);
impressionsState.putIfAbsent(impression.sourceAndTarget(), impression);
}
}
... continued in Example 7-8 below ...
请注意,这与我们对通用状态 API 中的三个期望功能的联系:
数据结构的灵活性
我们有地图、集合、值和定时器。它们使我们能够以对我们的算法有效的方式高效地操作我们的状态。
写入和读取粒度的灵活性
我们的@ProcessElement方法会为我们处理的每一个访问和展示调用一次。因此,我们需要尽可能地提高其效率。我们利用了进行细粒度的盲目写入,只针对我们需要的特定字段。我们在@ProcessElement方法中只在遇到新目标的罕见情况下从状态中读取。当我们这样做时,我们只读取一个整数值,而不触及(可能要大得多的)地图和列表。
处理调度的灵活性
由于定时器的存在,我们能够延迟我们复杂的目标归因逻辑(下面定义)直到我们确信已经收到了所有必要的输入数据,最大限度地减少重复工作并最大化效率。
在定义了核心处理逻辑后,让我们现在看看我们的最后一段代码,即目标归因方法。这个方法被注解为@TimerId,以标识它为在相应的归因定时器触发时执行的代码。这里的逻辑比@ProcessElement方法复杂得多:
-
首先,我们需要加载我们的访问和展示地图的全部内容,以及我们的目标集。我们需要地图来逆向穿越我们将要构建的归因路径,我们需要目标来知道我们正在归因的目标是由于当前定时器触发的结果,以及我们想要在未来安排归因的下一个待定目标(如果有的话)。
-
在加载了我们的状态之后,我们在一个循环中逐个处理这个定时器的目标:
-
检查是否有任何展示将用户引荐到路径中的当前访问(从目标开始)。如果是,我们已经完成了这个目标的归因,可以跳出循环并发出归因路径。
-
接下来检查是否有任何访问是当前访问的引荐者。如果是,我们在我们的路径中找到了一个反向指针,所以我们遍历它并重新开始循环。
-
如果找不到匹配的展示或访问,我们有一个是有机达成的目标,没有相关的展示。在这种情况下,我们只需跳出循环,继续下一个目标,如果有的话。
-
-
在我们用于归因的目标列表用尽后,我们为列表中的下一个待定目标设置一个定时器(如果有的话),并重置相应的
ValueState以跟踪最小的待定目标时间戳。
为了简洁起见,我们首先看一下核心目标归因逻辑,如示例 7-8 所示,它大致对应于前面列表中的第 2 点。
示例 7-8。目标归因逻辑
... continued from Example 7-7 above ...
private Impression attributeGoal(Visit goal,
Map<String, Visit> visits,
Map<String, Impression> impressions,
List<Visit> trail) {
Impression impression = null;
Visit visit = goal;
while (true) {
String sourceAndTarget = Impression.sourceAndTarget(
visit.referer(), visit.url());
LOG.info("attributeGoal: visit={} sourceAndTarget={}",
visit, sourceAndTarget);
if (impressions.containsKey(sourceAndTarget)) {
LOG.info("attributeGoal: impression={}", impression);
// Walked entire path back to impression. Return success.
return impressions.get(sourceAndTarget);
} else if (visits.containsKey(visit.referer())) {
// Found another visit in the path, continue searching.
visit = visits.get(visit.referer());
trail.add(0, visit);
} else {
LOG.info("attributeGoal: not found");
// Referer not found, trail has gone cold. Return failure.
return null;
}
}
}
... continued in Example 7-9 below ...
代码的其余部分(省略了一些简单的辅助方法),处理初始化和获取状态,调用归因逻辑,并处理清理以安排任何剩余的待定目标归因尝试,看起来像示例 7-9。
示例 7-9。目标归因的整体@TimerId 处理逻辑
... continued from Example 7-8 above ...
@OnTimer("attribution")
public void attributeGoal(
@Timestamp Instant timestamp,
@StateId("visits") MapState<String, Visit> visitsState,
@StateId("impressions") MapState<String, Impression> impressionsState,
@StateId("goals") SetState<Visit> goalsState,
@StateId("minGoal") ValueState<Instant> minGoalState,
@TimerId("attribution") Timer attributionTimer,
OutputReceiver<Attribution> output) {
LOG.info("Processing timer: {}", Utils.formatTime(timestamp));
// Batch state reads together via futures.
ReadableState<Iterable<Map.Entry<String, Visit> > > visitsFuture
= visitsState.entries().readLater();
ReadableState<Iterable<Map.Entry<String, Impression> > > impressionsFuture
= impressionsState.entries().readLater();
ReadableState<Iterable<Visit>> goalsFuture = goalsState.readLater();
// Accessed the fetched state.
Map<String, Visit> visits = buildMap(visitsFuture.read());
Map<String, Impression> impressions = buildMap(impressionsFuture.read());
Iterable<Visit> goals = goalsFuture.read();
// Find the matching goal
Visit goal = findGoal(timestamp, goals);
// Attribute the goal
List<Visit> trail = new ArrayList<>();
Impression impression = attributeGoal(goal, visits, impressions, trail);
if (impression != null) {
output.output(new Attribution(impression, trail, goal));
impressions.remove(impression.sourceAndTarget());
}
goalsState.remove(goal);
// Set the next timer, if any.
Instant minGoal = minTimestamp(goals, goal);
if (minGoal != null) {
LOG.info("Setting new timer at {}", Utils.formatTime(minGoal));
minGoalState.write(minGoal);
attributionTimer.set(minGoal);
} else {
minGoalState.clear();
}
}
这个代码块与通用状态 API 的三个期望功能非常相似,与@ProcessElement方法有一个显著的区别:
写入和读取粒度的灵活性
我们能够进行一次单一的粗粒度读取,加载所有地图和集合中的数据。这通常比单独加载每个字段或者更糟糕的是逐个加载每个字段元素要高效得多。这也显示了能够遍历从细粒度到粗粒度的访问粒度的重要性。
就是这样!我们实现了一个基本的转化归因流水线,以一种足够高效的方式在可观的规模上运行,并且使用了合理数量的资源。而且,最重要的是,它在面对无序数据时能够正常运行。如果您查看单元测试中使用的数据集,您会发现即使在这个小规模上也存在许多挑战:
-
跟踪和归因于共享 URL 集合中的多个不同的转化。
-
数据无序到达,特别是在处理时间上,目标到达(在访问和导致它们的印象之前),以及其他较早发生的目标。
-
生成多个不同印象到不同目标 URL 的源 URL。
-
物理上不同的印象(例如,对同一广告的多次点击)必须被去重为单个逻辑印象。
示例 7-10。用于验证转化归因逻辑的示例数据集
private static TestStream<KV<String, VisitOrImpression>> createStream() {
// Impressions and visits, in event-time order, for two (logical) attributable
// impressions and one unattributable impression.
Impression signupImpression = new Impression(
123L, "http://search.com?q=xyz",
"http://xyz.com/", Utils.parseTime("12:01:00"));
Visit signupVisit = new Visit(
"http://xyz.com/", Utils.parseTime("12:01:10"),
"http://search.com?q=xyz", false/*isGoal*/);
Visit signupGoal = new Visit(
"http://xyz.com/join-mailing-list", Utils.parseTime("12:01:30"),
"http://xyz.com/", true/*isGoal*/);
Impression shoppingImpression = new Impression(
456L, "http://search.com?q=thing",
"http://xyz.com/thing", Utils.parseTime("12:02:00"));
Impression shoppingImpressionDup = new Impression(
789L, "http://search.com?q=thing",
"http://xyz.com/thing", Utils.parseTime("12:02:10"));
Visit shoppingVisit1 = new Visit(
"http://xyz.com/thing", Utils.parseTime("12:02:30"),
"http://search.com?q=thing", false/*isGoal*/);
Visit shoppingVisit2 = new Visit(
"http://xyz.com/thing/add-to-cart", Utils.parseTime("12:03:00"),
"http://xyz.com/thing", false/*isGoal*/);
Visit shoppingVisit3 = new Visit(
"http://xyz.com/thing/purchase", Utils.parseTime("12:03:20"),
"http://xyz.com/thing/add-to-cart", false/*isGoal*/);
Visit shoppingGoal = new Visit(
"http://xyz.com/thing/receipt", Utils.parseTime("12:03:45"),
"http://xyz.com/thing/purchase", true/*isGoal*/);
Impression unattributedImpression = new Impression(
000L, "http://search.com?q=thing",
"http://xyz.com/other-thing", Utils.parseTime("12:04:00"));
Visit unattributedVisit = new Visit(
"http://xyz.com/other-thing", Utils.parseTime("12:04:20"),
"http://search.com?q=other thing", false/*isGoal*/);
// Create a stream of visits and impressions, with data arriving out of order.
return TestStream.create(
KvCoder.of(StringUtf8Coder.of(), AvroCoder.of(VisitOrImpression.class)))
.advanceWatermarkTo(Utils.parseTime("12:00:00"))
.addElements(visitOrImpression(shoppingVisit2, null))
.addElements(visitOrImpression(shoppingGoal, null))
.addElements(visitOrImpression(shoppingVisit3, null))
.addElements(visitOrImpression(signupGoal, null))
.advanceWatermarkTo(Utils.parseTime("12:00:30"))
.addElements(visitOrImpression(null, signupImpression))
.advanceWatermarkTo(Utils.parseTime("12:01:00"))
.addElements(visitOrImpression(null, shoppingImpression))
.addElements(visitOrImpression(signupVisit, null))
.advanceWatermarkTo(Utils.parseTime("12:01:30"))
.addElements(visitOrImpression(null, shoppingImpressionDup))
.addElements(visitOrImpression(shoppingVisit1, null))
.advanceWatermarkTo(Utils.parseTime("12:03:45"))
.addElements(visitOrImpression(null, unattributedImpression))
.advanceWatermarkTo(Utils.parseTime("12:04:00"))
.addElements(visitOrImpression(unattributedVisit, null))
.advanceWatermarkToInfinity();
}
还要记住,我们在这里处理的是相对受限的转化归因版本。一个完整的实现将有额外的挑战要处理(例如,垃圾收集,访问 DAG 而不是树)。无论如何,这个流水线提供了一个很好的对比,与原始分组和增量组合通常提供的不够灵活的方法相比。通过牺牲一定的实现复杂性,我们能够找到必要的效率平衡,而不会在正确性上妥协。此外,这个流水线突出了流处理更加命令式的方法,状态和定时器提供了这种方法(想想 C 或 Java),这是对窗口和触发器提供的更加功能性方法的一个很好的补充(想想 Haskell)。
总结
在本章中,我们仔细研究了为什么持久状态很重要,得出结论,它为长期运行的管道提供了正确性和效率的基础。然后,我们看了数据处理系统中遇到的两种最常见的隐式状态类型:原始分组和增量组合。我们了解到原始分组是简单直接的,但潜在地低效,而增量组合大大提高了对可交换和可结合操作的效率。最后,我们看了一个相对复杂但非常实际的用例(并通过 Apache Beam Java 实现),并用它来突出通用状态抽象中需要的重要特征:
-
数据结构的灵活性,允许使用针对特定用例定制的数据类型。
-
写入和读取粒度的灵活性,允许在任何时候写入和读取的数据量都可以根据用例进行调整,最小化或最大化 I/O。
-
处理时间调度的灵活性,允许将某些处理部分延迟到更合适的时间点,例如当输入被认为在特定事件时间点上完整时。
¹ 某种定义下的“永远”,通常至少是“直到我们成功完成批处理管道的执行并且不再需要输入”。
² 请记住,Beam 目前不直接暴露这些状态表;您必须将它们触发回到流中,以观察它们的内容作为新的 PCollection。
³ 或者,正如我的同事肯·诺尔斯指出的,如果你把定义看作是集合之间的可交换性,那么三参数版本的可交换性实际上也足以暗示结合性:COMBINE(a, b, c) == COMBINE(a, c, b) == COMBINE(b, a, c) == COMBINE(b, c, a) == COMBINE(c, a, b) == COMBINE(c, b, a)。数学很有趣。
⁴ 而且,定时器是实现我们在第二章讨论的大部分完整性和重复更新触发器的基础特性,以及基于允许迟到的垃圾回收。
⁵ 由于网络浏览的特性,我们将要分析的访问路径是由 HTTP 引用字段链接的 URL 树。实际上,它们最终会成为有向图,但为了简单起见,我们假设我们网站上的每个页面都有来自该网站上确切一个其他引用页面的入站链接,从而产生一个更简单的树结构。泛化到图是树结构实现的自然扩展,这进一步强调了所提出的观点。
第八章:流 SQL
让我们谈谈 SQL。在本章中,我们将从中间某个地方开始,然后回到过去一点,以建立额外的背景,最后再回到未来,用一个漂亮的蝴蝶结来总结一切。想象一下,如果昆汀·塔伦蒂诺拥有计算机科学学位,并且非常兴奋地向世界讲述流 SQL 的精髓,所以他提出要和我一起幽灵写作这一章;有点像那样。当然没有暴力。
什么是流 SQL?
我认为这个问题在我们行业中已经困扰了几十年。公平地说,数据库界可能已经理解了答案的 99%。但我还没有看到一个真正有力和全面的流 SQL 定义,它包括了强大的流语义的全部广度。这就是我们将在这里尝试提出的,尽管假设我们现在已经走了 99.1%的路,这可能是傲慢的。一步一步。
不管怎样,我想提前指出,我们在本章中讨论的大部分内容在写作时仍然是纯粹假设的。本章和接下来的一章(涵盖流连接)都描述了流 SQL 可能的理想愿景。一些部分已经在 Apache Calcite、Apache Flink 和 Apache Beam 等系统中实现。许多其他部分在任何地方都没有实现。在这个过程中,我会尽量指出一些已经以具体形式存在的东西,但考虑到这是一个不断变化的目标,你最好的选择就是简单地查阅你感兴趣的特定系统的文档。
值得一提的是,这里提出的流 SQL 的愿景是 Calcite、Flink 和 Beam 社区之间合作讨论的结果。Calcite 的首席开发人员 Julian Hyde 长期以来一直提出了他对流 SQL 可能的样子的愿景。2016 年,Flink 社区的成员将 Calcite SQL 支持集成到 Flink 本身,并开始向 Calcite SQL 方言添加流特定功能,如窗口构造。然后,在 2017 年,所有三个社区开始了一场讨论,试图就 Calcite SQL 中用于强大流处理的语言扩展和语义达成一致。本章试图将该讨论中的想法概括为一个清晰而连贯的叙述,关于将流概念整合到 SQL 中,无论是 Calcite 还是其他方言。
关系代数
谈到 SQL 的流式处理意味着什么,重要的是要记住 SQL 的理论基础:关系代数。关系代数简单地描述了由命名、类型化元组组成的数据之间的关系的数学方式。在关系代数的核心是关系本身,它是这些元组的集合。在经典的数据库术语中,关系类似于表,无论是物理数据库表,SQL 查询的结果,视图(实体化或其他),等等;它是包含命名和类型化数据列的行的集合。
关系代数的一个更为关键的方面是其封闭性质:将关系代数中的任何运算符应用于任何有效的关系¹,总是产生另一个关系。换句话说,关系是关系代数的通用货币,所有运算符都将其作为输入并将其作为输出。
从历史上看,许多支持 SQL 中的流式处理的尝试都未能满足封闭性质。它们将流与经典关系分开处理,提供新的运算符来在两者之间转换,并限制可以应用于其中一个或另一个的操作。这显著提高了任何此类流式 SQL 系统的采用门槛:潜在用户必须学习新的运算符,并理解它们适用的地方,以及它们不适用的地方,并且同样需要重新学习在这个新世界中任何旧运算符的适用规则。更糟糕的是,这些系统中大多数仍然无法提供我们想要的完整流语义套件,比如对强大的无序处理和强大的时间连接支持(我们将在第九章中介绍后者)的支持。因此,我认为基本上不可能指出任何现有的流式 SQL 实现已经实现了真正广泛的采用。这些流式 SQL 系统的额外认知负担和受限能力确保它们仍然是一个小众企业。
为了真正将流式 SQL 引入前沿,我们需要一种方法,使流式处理在关系代数本身内成为一等公民,以便标准的关系代数可以自然地适用于流式和非流式用例。这并不是说流和表应该被视为完全相同的东西;它们绝对不是一样的,认识到这一点可以清晰地理解和掌握流/表关系的力量,我们很快就会看到。但是核心代数应该干净自然地适用于两个世界,只有在绝对必要的情况下才需要在标准关系代数之外进行最小的扩展。
时变关系
简而言之,我在本章开头提到的要点是:将流式处理自然地整合到 SQL 中的关键是扩展关系代数的核心数据对象,以表示一组数据随着时间的推移而不是在特定时间点的数据集。更简洁地说,我们需要的不是“特定时间点”的关系,而是“时变关系”。
但是时变关系是什么?让我们首先从经典关系代数的角度来定义它们,之后我们还将考虑它们与流和表理论的关系。
从关系代数的角度来看,时变关系实际上只是经典关系随时间的演变。要理解我的意思,想象一个由用户事件组成的原始数据集。随着用户生成新事件,数据集会不断增长和演变。如果你在特定时间观察这个集合,那就是一个经典关系。但是如果你观察这个集合随着时间的整体演变,那就是一个时变关系。
换句话说,如果经典关系就像是由 x 轴上具有命名和类型的列和 y 轴上的记录行组成的二维表,那么时变关系就像是具有 x 和 y 轴的三维表,但是还有一个额外的 z 轴,用来捕捉随时间变化的二维表的不同版本。随着关系的变化,关系的新快照被添加到 z 维度中。
让我们来看一个例子。想象一下我们的原始数据集是用户和分数;例如,来自手机游戏的每个用户的分数,就像本书中大部分其他例子一样。假设我们的例子数据集最终在特定时间点观察时看起来像这样,比如 12:07:
*12:07> SELECT * FROM UserScores;*
-------------------------
| Name | Score | Time |
-------------------------
| Julie | 7 | 12:01 |
| Frank | 3 | 12:03 |
| Julie | 1 | 12:03 |
| Julie | 4 | 12:07 |
-------------------------
换句话说,它记录了随时间到达的四个分数:12:01 时朱莉的 7 分,12:03 时弗兰克的 3 分和朱莉的第二个分数 1 分,最后 12:07 时朱莉的第三个分数 4 分(请注意,这里的“时间”列包含表示系统内记录的到达时间的处理时间戳;我们稍后会介绍事件时间戳)。假设这是该关系曾经到达的唯一数据,那么无论我们在 12:07 之后何时观察它,它看起来都像前面的表格。但如果我们在 12:01 观察关系,它会看起来像下面这样,因为那时只有朱莉的第一个分数到达了:
*12:01> SELECT * FROM UserScores;*
-------------------------
| Name | Score | Time |
-------------------------
| Julie | 7 | 12:01 |
-------------------------
如果我们在 12:03 再次观察它,弗兰克的分数和朱莉的第二个分数也会到达,所以关系会发展成这样:
*12:03> SELECT * FROM UserScores;*
-------------------------
| Name | Score | Time |
-------------------------
| Julie | 7 | 12:01 |
| Frank | 3 | 12:03 |
| Julie | 1 | 12:03 |
-------------------------
通过这个例子,我们可以开始对这个数据集的时间变化关系有所了解:它将捕捉关系随时间的整体演变。因此,如果我们在 12:07 或之后观察时间变化关系(或 TVR),它将看起来像下面这样(请注意使用假设的TVR关键字来表示我们希望查询返回完整的时间变化关系,而不是经典关系的标准时点快照):
*12:07> SELECT TVR * FROM UserScores;*
---------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Score | Time | | | Name | Score | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| | | | | | | | | | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Score | Time | | | Name | Score | Time | |
| ------------------------- | ------------------------- |
| | Julie | 7 | 12:01 | | | Julie | 7 | 12:01 | |
| | Frank | 3 | 12:03 | | | Frank | 3 | 12:03 | |
| | Julie | 1 | 12:03 | | | Julie | 1 | 12:03 | |
| | | | | | | Julie | 4 | 12:07 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
由于印刷/数字页面仍然受限于二维,我已经将第三维压缩成了二维关系网格。但你可以看到,时间变化关系本质上是一系列经典关系(从左到右,从上到下排序),每个关系捕捉了特定时间范围内关系的完整状态(根据定义,所有这些时间范围都是连续的)。
定义时间变化关系的重要之处在于,它们实际上只是一系列经典关系,每个关系在其自己的不相交(但相邻)时间范围内独立存在,每个时间范围捕捉了关系在其中关系未发生变化的时间段。这一点很重要,因为这意味着将关系运算符应用于时间变化关系等同于分别将该运算符应用于相应序列中的每个经典关系。再进一步,将关系运算符分别应用于与时间间隔相关联的一系列关系的结果,将始终产生具有相同时间间隔的相应关系序列。换句话说,结果是相应的时间变化关系。这个定义给我们带来了两个非常重要的属性:
-
从经典关系代数中的完整运算符集在应用于时间变化关系时仍然有效,而且继续表现得正如你所期望的那样。
-
当应用于时间变化关系时,关系代数的闭包性仍然保持完整。
或者更简洁地说,当应用于时间变化关系时,所有经典关系代数的规则仍然保持不变。这是非常重要的,因为这意味着我们用时间变化关系替代经典关系并没有以任何方式改变游戏的参数。一切继续按照经典关系领域的方式运作,只是在一系列经典关系而不是单个关系上。回到我们的例子,考虑一下我们原始数据集上的另外两个时间变化关系,都是在 12:07 之后观察到的。首先是使用WHERE子句的简单过滤关系:
*12:07> SELECT TVR * FROM UserScores WHERE Name = "Julie";*
---------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Score | Time | | | Name | Score | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Score | Time | | | Name | Score | Time | |
| ------------------------- | ------------------------- |
| | Julie | 7 | 12:01 | | | Julie | 7 | 12:01 | |
| | Julie | 1 | 12:03 | | | Julie | 1 | 12:03 | |
| | | | | | | Julie | 4 | 12:07 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
正如你所期望的那样,这个关系看起来很像前面的关系,但弗兰克的分数被过滤掉了。尽管时间变化关系捕捉了记录数据集随时间演变所需的额外维度时间,但查询的行为与你对 SQL 的理解一样。
对于更复杂一些的情况,让我们考虑一个分组关系,我们将所有每个用户的得分相加,以生成每个用户的总体得分:
*12:07> SELECT TVR Name, SUM(Score) as Total, MAX(Time) as Time
FROM UserScores GROUP BY Name;*
---------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | Julie | 8 | 12:03 | | | Julie | 12 | 12:07 | |
| | Frank | 3 | 12:03 | | | Frank | 3 | 12:03 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
再次,这个查询的时间变化版本的行为与你所期望的完全一样,序列中的每个经典关系简单地包含了每个用户得分的总和。而且,无论我们选择多么复杂的查询,结果总是与独立应用该查询到输入时间变化关系组成的相应经典关系相同。我无法强调这一点有多重要!
好吧,这一切都很好,但时间变化关系本身更多的是一个理论构想,而不是数据的实际、物理表现;很容易看出,它们可能会变得非常庞大和难以控制,对于频繁变化的大型数据集来说。为了了解它们如何实际与现实世界的流处理联系起来,让我们现在探讨时间变化关系与流和表理论之间的关系。
流和表
对于这个比较,让我们再次考虑一下我们之前看过的分组时间变化关系:
*12:07> SELECT TVR Name, SUM(Score) as Total, MAX(Time) as Time
FROM UserScores GROUP BY Name;*
---------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | Julie | 8 | 12:03 | | | Julie | 12 | 12:07 | |
| | Frank | 3 | 12:03 | | | Frank | 3 | 12:03 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
我们知道这个序列捕捉了关系随时间的完整历史。鉴于我们在第六章对表和流的理解,理解时间变化关系与流和表理论的关系并不太困难。
表非常简单:因为时间变化关系本质上是经典关系的序列(每个捕捉了特定时间点上的关系快照),而经典关系类似于表,将时间变化关系观察为表,简单地产生了观察时间点的关系快照。
例如,如果我们在 12:01 观察之前的分组时间变化关系作为表,我们将得到以下结果(注意使用另一个假设关键字TABLE,明确表示我们希望查询返回一个表):
*12:01> SELECT TABLE Name, SUM(Score) as Total, MAX(Time) as Time*
*FROM UserScores GROUP BY Name;*
-------------------------
| Name | Total | Time |
-------------------------
| Julie | 7 | 12:01 |
-------------------------
在 12:07 观察到的结果将是预期的:
*12:07> SELECT TABLE Name, SUM(Score) as Total, MAX(Time) as Time*
*FROM UserScores GROUP BY Name;*
-------------------------
| Name | Total | Time |
-------------------------
| Julie | 12 | 12:07 |
| Frank | 3 | 12:03 |
-------------------------
这里特别有趣的是,实际上 SQL 中已经支持了时间变化关系的想法。SQL 2011 标准提供了“时间表”,它存储了表随时间的版本历史(实质上是时间变化关系),以及一个AS OF SYSTEM TIME结构,允许您明确地查询和接收在您指定的任何时间点的时间表/时间变化关系的快照。例如,即使我们在 12:07 执行了我们的查询,我们仍然可以看到关系在 12:03 时是什么样子的:
*12:07> SELECT TABLE Name, SUM(Score) as Total, MAX(Time) as Time*
*FROM UserScores GROUP BY Name AS OF SYSTEM TIME ‘12:03’;*
-------------------------
| Name | Total | Time |
-------------------------
| Julie | 8 | 12:03 |
| Frank | 3 | 12:03 |
-------------------------
因此,SQL 中已经有一些关于时间变化关系的先例。但我岔开了。这里的主要观点是,表在特定时间点捕捉时间变化关系的快照。大多数真实世界的表实现简单地跟踪我们观察到的实时时间;其他保留一些额外的历史信息,这在极限情况下等同于捕捉关系在时间上的整个历史的完整保真时间变化关系。
流畅有些不同。我们在第六章学到,它们也捕捉了表随时间的演变。但它们与我们迄今为止所看到的时间变化关系有些不同。它们不是在每次变化时整体捕捉整个关系的快照,而是捕捉导致这些快照的变化序列在时间变化关系中。这里微妙的差异在一个例子中变得更加明显。
作为一个提醒,再次回想一下我们的基准例子TVR查询:
*12:07> SELECT TVR Name, SUM(Score) as Total, MAX(Time) as Time
FROM UserScores GROUP BY Name;*
---------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | Julie | 8 | 12:03 | | | Julie | 12 | 12:07 | |
| | Frank | 3 | 12:03 | | | Frank | 3 | 12:03 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
现在,让我们观察我们的时变关系作为一个流在几个不同的时间点存在的情况。在每一步,我们将比较 TVR 在那个时间点的原始表格呈现与流到那个时间点的演变。为了看到我们的时变关系的流呈现是什么样子,我们需要引入两个新的假设关键词:
-
一个
STREAM关键词,类似于我已经介绍的TABLE关键词,表示我们希望查询返回一个事件流,捕捉时变关系随时间的演变。你可以把这看作是在时间上对关系应用每条记录触发器。 -
一个特殊的
Sys.Undo³列,可以从STREAM查询中引用,用于识别撤销的行。稍后会详细介绍。
因此,从 12:01 开始,我们将有以下情况:
*12:01> SELECT STREAM Name,*
*12:01> SELECT TABLE Name, SUM(Score) as Total,*
*SUM(Score) as Total, MAX(Time) as Time,*
*MAX(Time) as Time Sys.Undo as Undo*
*FROM UserScores GROUP BY Name; FROM UserScores GROUP BY Name;*
------------------------- --------------------------------
| Name | Total | Time | | Name | Total | Time | Undo |
------------------------- --------------------------------
| Julie | 7 | 12:01 | | Julie | 7 | 12:01 | |
------------------------- ........ [12:01, 12:01] ........
表格和流的呈现在这一点上几乎是相同的。除了Undo列(在下一个例子中会更详细地讨论),只有一个区别:表格版本在 12:01 时是完整的(通过下方的虚线表示关系的底部结束),而流版本仍然是不完整的,通过最后的省略号一样的一行点表示,标记了关系的开放尾部(未来可能会有额外的数据)以及迄今为止观察到的处理时间范围。实际上,如果在真实实现中执行,STREAM查询将无限期地等待额外的数据到达。因此,如果等到 12:03,STREAM查询将出现三行新数据。与 12:03 的新TABLE呈现进行比较:
*12:01> SELECT STREAM Name,*
*12:03> SELECT TABLE Name, SUM(Score) as Total,*
*SUM(Score) as Total, MAX(Time) as Time,*
*MAX(Time) as Time Sys.Undo as Undo*
*FROM UserScores GROUP BY Name; FROM UserScores GROUP BY Name;*
------------------------- --------------------------------
| Name | Total | Time | | Name | Total | Time | Undo |
------------------------- --------------------------------
| Julie | 8 | 12:03 | | Julie | 7 | 12:01 | |
| Frank | 3 | 12:03 | | Frank | 3 | 12:03 | |
------------------------- | Julie | 7 | 12:03 | undo |
| Julie | 8 | 12:03 | |
........ [12:01, 12:03] ........
这里有一个值得讨论的有趣观点:为什么在流中有三行新数据(Frank 的 3 和 Julie 的撤销-7 和 8),而我们原始数据集中只包含两行(Frank 的 3 和 Julie 的 1)?答案在于我们观察到的是原始输入的聚合变化流;特别是,在 12:01 到 12:03 的时间段内,流需要捕捉关于 Julie 的聚合分数变化的两个重要信息:
-
先前报告的总数为 7 是错误的。
-
新的总数是 8。
这就是特殊的Sys.Undo列允许我们做的事情:区分普通行和以前报告的值的撤销行。⁴
STREAM查询的一个特别好的特性是,你可以开始看到所有这些与经典的在线事务处理(OLTP)表有关:这个查询的STREAM呈现基本上捕捉了一系列INSERT和DELETE操作,你可以用它来在 OLTP 世界中随时间实现这个关系(实际上,当你考虑一下,OLTP 表本身本质上就是通过INSERT、UPDATE和DELETE的流来随时间变化的时变关系)。
现在,如果我们不关心流中的撤销,也完全可以不要求它们。在这种情况下,我们的STREAM查询将如下所示:
*12:01> SELECT STREAM Name,*
*SUM(Score) as Total,*
*MAX(Time) as Time*
*FROM UserScores GROUP BY Name;*
-------------------------
| Name | Total | Time |
-------------------------
| Julie | 7 | 12:01 |
| Frank | 3 | 12:03 |
| Julie | 8 | 12:03 |
.... [12:01, 12:03] .....
但显然了解完整的流是有价值的,所以我们将回到在我们的最后一个例子中包括Sys.Undo列。说到这一点,如果我们再等四分钟到 12:07,我们将在STREAM查询中看到另外两行新数据,而TABLE查询将继续像以前一样演变。
*12:01> SELECT STREAM Name,*
*12:07> SELECT TABLE Name, SUM(Score) as Total,*
*SUM(Score) as Total, MAX(Time) as Time,*
*MAX(Time) as Time Sys.Undo as Undo*
*FROM UserScores GROUP BY Name; FROM UserScores GROUP BY Name;*
------------------------- --------------------------------
| Name | Total | Time | | Name | Total | Time | Undo |
------------------------- --------------------------------
| Julie | 12 | 12:07 | | Julie | 7 | 12:01 | |
| Frank | 3 | 12:03 | | Frank | 3 | 12:03 | |
------------------------- | Julie | 7 | 12:03 | undo |
| Julie | 8 | 12:03 | |
| Julie | 8 | 12:07 | undo |
| Julie | 12 | 12:07 | |
........ [12:01, 12:07] ........
到目前为止,很明显STREAM版本的时变关系与表格版本非常不同:表格捕捉了特定时间点的整个关系快照,而流捕捉了关系随时间的个别变化的视图。有趣的是,这意味着STREAM呈现与我们原始的基于表格的 TVR 呈现有更多的共同点:
*12:07> SELECT TVR Name, SUM(Score) as Total, MAX(Time) as Time
FROM UserScores GROUP BY Name;*
---------------------------------------------------------
| -inf, 12:01) | [12:01, 12:03) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | | | | | | Julie | 7 | 12:01 | |
| | | | | | | | | | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
| [12:03, 12:07) | [12:07, now) |
| ------------------------- | ------------------------- |
| | Name | Total | Time | | | Name | Total | Time | |
| ------------------------- | ------------------------- |
| | Julie | 8 | 12:03 | | | Julie | 12 | 12:07 | |
| | Frank | 3 | 12:03 | | | Frank | 3 | 12:03 | |
| ------------------------- | ------------------------- |
---------------------------------------------------------
事实上,可以说STREAM查询只是提供了与相应基于表的TVR查询中存在的整个数据历史的另一种呈现方式。STREAM呈现的价值在于它的简洁性:它仅捕捉了TVR中每个时间点关系快照之间的变化增量。序列表TVR呈现的价值在于它提供的清晰度:它以突出其与经典关系的自然关系的格式捕捉了关系随时间的演变,并在此过程中提供了对于在流媒体环境中的关系语义的简单和清晰定义,以及流媒体带来的额外时间维度。
STREAM和基于表的TVR呈现之间相似之处的另一个重要方面是它们在总体上编码的数据实际上是等价的。这涉及到流/表对偶的核心,即其支持者长期以来一直宣扬的观点:流和表⁶实际上只是同一概念的两个不同物理表现形式,取决于上下文,就像波和粒子对于光一样⁷:完整的时变关系同时是表和流;表和流只是同一概念的不同物理表现形式。
现在,重要的是要记住,只有在两个版本编码相同信息时,即当你有全保真度的表或流时,流/表的这种对偶才是真实的。然而,在许多情况下,全保真度是不切实际的。正如我之前所暗示的,无论是以流的形式还是表的形式编码时变关系的完整历史对于大型数据源来说都可能非常昂贵。流/表的表现形式通常在某种程度上是有损的。表通常只编码 TVR 的最新版本;支持时间或版本访问的表通常将编码的历史压缩到特定的时间点快照,并/或者清理比某个阈值更老的版本。同样,流通常只编码 TVR 演变的有限时间段,通常是最近历史的一部分。像 Kafka 这样的持久流可以编码 TVR 的全部内容,但这相对不常见,通常会通过垃圾回收过程丢弃一些阈值之前的数据。
这里的主要观点是流和表绝对是彼此的对偶,每种都是编码时变关系的有效方式。但在实践中,流/表的物理表现形式通常在某种程度上是有损的。这些部分保真度的流和表在总编码信息减少的情况下换取了一些好处,通常是减少资源成本。这些类型的权衡是重要的,因为它们通常是我们能够构建能够处理真正大规模数据源的管道的原因。但它们也使事情变得复杂,并需要更深入的理解才能正确使用。我们将在后面更详细地讨论这个话题,当我们涉及 SQL 语言扩展时。但在我们尝试推理 SQL 扩展之前,了解当今常见的 SQL 和非 SQL 数据处理方法中存在的偏见会很有用。
回顾:流和表的偏见
在许多方面,将强大的流支持添加到 SQL 中实质上是一种尝试将 Beam 模型的where、when和how语义与经典 SQL 模型的what语义合并的过程。但要做到干净利落地,并且保持对经典 SQL 的外观和感觉,需要理解这两种模型之间的关系。因此,就像我们在第六章中探讨了 Beam 模型与流和表理论的关系一样,现在我们将使用流和表理论作为比较的基础框架,探讨 Beam 模型与经典 SQL 模型的关系。通过这样做,我们将发现每个模型中存在的固有偏见,这将为我们提供一些洞察,以便以一种干净、自然的方式最好地将这两种模型结合起来。
Beam 模型:一种流偏向的方法
让我们从 Beam 模型开始,基于第六章的讨论。首先,我想讨论 Beam 模型相对于流和表的固有流偏向。
如果回想一下图 6-11 和 6-12,它们展示了我们在整本书中一直使用的一个示例——分数求和管道的两种不同视图:图 6-11 是逻辑的 Beam 模型视图,图 6-12 是物理的流和表导向视图。比较这两者有助于突出 Beam 模型与流和表的关系。但是通过将一个叠加在另一个上面,就像我在图 8-1 中所做的那样,我们可以看到关系的另一个有趣方面:Beam 模型固有的流偏向。

图 8-1. Beam 模型方法中的流偏向
在这张图中,我画了虚线连接逻辑视图中的变换与物理视图中对应的组件。以这种方式观察时显而易见的是,所有逻辑变换都由流连接,即使涉及分组的操作(我们从第六章知道这会导致某处创建表)。在 Beam 术语中,这些变换是PTransforms,它们总是应用于PCollections以产生新的PCollections。这里的重要观点是,在 Beam 中,PCollections始终是流。因此,Beam 模型是一种固有的流偏向数据处理方法:流是 Beam 管道中的通用货币(即使是批处理管道),而表始终被特别对待,要么在管道边缘抽象在源和汇处,要么在管道中的某个地方被隐藏在分组和触发操作之下。因为 Beam 以流为单位运行,任何涉及表的地方(源、汇以及任何中间分组/取消分组),都需要进行某种转换以隐藏底层表。Beam 中的这些转换看起来像这样:
-
消费表的源通常会硬编码表的触发方式;用户无法指定他们想要消费的表的自定义触发方式。源可能被编写为触发对表的每次新更新作为记录,它可能批量组合更新,或者在某个时间点上提供表中数据的单个有界快照。这实际上取决于对于给定源来说什么是实际可行的,以及源的作者试图解决的用例是什么。
-
写表的汇通常会硬编码它们组输入流的方式。有时,这是以一种给用户一定控制的方式来完成的;例如,通过简单地按用户分配的键进行分组。在其他情况下,分组可能是隐式定义的;例如,通过在写入没有自然键的输入数据时在分片输出源上分组到一个随机物理分区号。与源一样,这实际上取决于给定汇的实际可行性,以及汇的作者试图解决的用例是什么。
-
对于分组/取消分组操作,与源和汇点相反,Beam 为用户提供了完全灵活的方式将数据分组到表中,并将其取消分组为流。这是有意设计的。分组操作的灵活性是必要的,因为数据分组的方式是定义管道的算法的关键组成部分。取消分组的灵活性很重要,以便应用程序可以以适合手头用例的方式塑造生成的流。⁸
然而,这里有一个问题。从图 8-1 中可以看出,Beam 模型本质上偏向于流。因此,虽然可以直接将分组操作清晰地应用于流(这是 Beam 的GroupByKey操作),但该模型从不提供可以直接应用触发器的一等表对象。因此,触发器必须在其他地方应用。基本上有两个选择:
触发器的预声明
在管道中的某个点之前指定触发器的位置应用于它们实际应用的表。在这种情况下,您基本上是预先指定了在管道中遇到分组操作后稍后希望看到的行为。以这种方式声明时,触发器是向前传播的。
触发器声明后
在管道中的某个点指定触发器的位置之后,它们被应用的表。在这种情况下,您正在指定在声明触发器的地方希望看到的行为。以这种方式声明时,触发器是向后传播的。
因为触发器的后声明允许您在实际观察它的地方指定所需的行为,所以这更直观。不幸的是,Beam 目前(2.x 及更早版本)使用的是触发器的预声明(类似于窗口也是预声明的)。尽管 Beam 提供了许多应对表格隐藏的方法,但我们仍然面临一个事实,那就是必须在观察表格之前触发它们,即使该表格的内容确实是您想要消耗的最终数据。这是 Beam 模型目前存在的一个缺点,可以通过摆脱流中心模型,转向将流和表格视为一等实体的模型来解决。现在让我们来看一下 Beam 模型的概念对应:经典 SQL。## SQL 模型:以表为中心的方法与 Beam 模型的流为中心的方法相反,SQL 历来采用以表为中心的方法:查询应用于表,并且总是产生新的表。这类似于我们在第六章中看到的 MapReduce 的批处理模型,但是考虑到 Beam 模型的一个类似的具体示例会很有用。考虑以下非规范化的 SQL 表:
UserScores (user, team, score, timestamp)
它包含用户得分,每个得分都带有相应用户和他们所在团队的 ID。没有主键,因此可以假设这是一个仅追加的表,每行都隐式地由其唯一的物理偏移标识。如果我们想要从这个表中计算团队得分,我们可以使用类似于以下内容的查询:
SELECT team, SUM(score) as total FROM UserScores GROUP BY team;
当由查询引擎执行时,优化器可能会将此查询大致分解为三个步骤:
-
扫描输入表(即触发其快照)
-
将该表中的字段投影到团队和得分
-
按团队分组并求和得分
如果我们使用类似于图 8-1 的图表来查看这一点,它会看起来像图 8-2。SCAN操作将输入表触发为一个有界流,其中包含查询执行时表的内容的快照。该流被SELECT操作消耗,将四列输入行投影到两列输出行。作为一个非分组操作,它产生另一个流。最后,团队和用户得分的这个两列流进入GROUP BY,按团队分组成一个表,相同团队的得分被SUM在一起,产生了我们的输出表,团队及其对应的团队得分总数。

图 8-2。简单 SQL 查询中的表倾向
这是一个相对简单的例子,自然会以一个表结束,因此它实际上并不足以突出经典 SQL 中的表倾向。但是,我们可以通过简单地将这个查询的主要部分(投影和分组)拆分成两个单独的查询来找出更多的证据:
SELECT team, score
INTO TeamAndScore
FROM UserScores;
SELECT team, SUM(score) as total
INTO TeamTotals
FROM TeamAndScore
GROUP BY team;
在这些查询中,我们首先将UserScores表投影到我们关心的两列中,将结果存储在一个临时的TeamAndScore表中。然后我们按团队对该表进行分组,同时对得分进行求和。在将事物拆分成两个查询的管道后,我们的图表看起来像图 8-3 所示。

图 8-3。将查询分成两部分以揭示表倾向的更多证据
如果经典 SQL 将流作为一流对象暴露出来,你会期望第一个查询TeamAndScore的结果是一个流,因为SELECT操作消耗一个流并产生一个流。但是因为 SQL 的通用货币是表,它必须首先将投影流转换为表。并且因为用户没有指定任何显式的键来分组,它必须简单地按其标识(即附加语义,通常通过按每行的物理存储偏移量进行分组)分组键。
因为TeamAndScore现在是一个表,第二个查询必须在前面添加一个额外的SCAN操作,将表扫描回流,以便GROUP BY再次将其分组成表,这次是按团队分组,并将它们的个人得分总和在一起。因此,我们看到了两次隐式转换(从流到表,然后再次转回来),这是由于中间表的显式实现而插入的。
话虽如此,SQL 中的表并不总是显式的;隐式表也是存在的。例如,如果我们在带有GROUP BY语句的查询末尾添加一个HAVING子句,以过滤出得分低于某个阈值的团队,那么图表将会改变,看起来会像图 8-4 所示的样子。

图 8-4。最终带有 HAVING 子句的表倾向
通过添加HAVING子句,原来的用户可见的TeamTotals表现在是一个隐式的中间表。为了根据HAVING子句中的规则过滤表的结果,必须将该表触发为一个流,然后可以对该流进行过滤,然后该流必须隐式地重新分组成一个表,以产生新的输出表LargeTeamTotals。
这里的重要观点是经典 SQL 中明显的表倾向。流总是隐式的,因此对于任何实现的流,都需要从/到表的转换。这种转换的规则可以大致分类如下:
输入表(即 Beam 模型术语中的源)
这些总是在特定时间点¹⁰(通常是查询执行时间)完全隐式触发,以产生一个包含该时间点表快照的有界流。这与经典的批处理得到的结果是相同的;例如,我们在第六章中看到的 MapReduce 案例。
输出表(即 Beam 模型术语中的接收器)
这些表要么是查询中最终分组操作创建的表的直接表现,要么是应用于查询的终端流的隐式分组(按行的某个唯一标识符)的结果,对于不以分组操作结束的查询(例如前面示例中的投影查询,或者GROUP BY后跟一个HAVING子句)。与输入一样,这与经典批处理中的行为相匹配。
分组/非分组操作
与 Beam 不同,这些操作只在一个维度上提供完全的灵活性:分组。而经典 SQL 查询提供了一整套分组操作(GROUP BY,JOIN,CUBE等),它们只提供了一种隐式的非分组操作:在所有贡献数据都被合并后触发中间表的整体(再次强调,这与 MapReduce 中作为洗牌操作的隐式触发完全相同)。因此,SQL 在通过分组塑造算法方面提供了很大的灵活性,但在塑造查询执行过程中存在的隐式流方面基本上没有灵活性。
物化视图
考虑到经典 SQL 查询与经典批处理的相似性,可能会诱使人们认为 SQL 固有的表偏见只是 SQL 不以任何方式支持流处理的产物。但这样做将忽视一个事实,即数据库已经支持了一种特定类型的流处理很长一段时间:物化视图。物化视图是作为表物理材料化并随着时间由数据库保持更新的视图,源表发生变化时也会相应更新。请注意,这听起来与我们对时变关系的定义非常相似。物化视图的迷人之处在于,它为 SQL 增加了一种非常有用的流处理形式,而不会显著改变它的操作方式,包括其固有的表偏见。
例如,让我们考虑一下图 8-4 中的查询。我们可以将这些查询改为CREATE MATERIALIZED VIEW¹¹语句:
CREATE MATERIALIZED VIEW TeamAndScoreView AS
SELECT team, score
FROM UserScores;
CREATE MATERIALIZED VIEW LargeTeamTotalsView AS
SELECT team, SUM(score) as total
FROM TeamAndScoreView
GROUP BY team
HAVING SUM(score) > 100;
通过这样做,我们将它们转换为连续的、持续的查询,以流式方式持续处理UserScores表的更新。即使如此,物化视图的物理执行图与一次性查询的执行图几乎完全相同;在查询执行过程中,流并没有被显式地转换为显式的一流对象来支持这种流式物化视图的概念。物理执行计划中唯一值得注意的变化是替换了不同的触发器:SCAN-AND-STREAM而不是SCAN,如图 8-5 所示。

图 8-5。物化视图中的表偏差
这个SCAN-AND-STREAM触发器是什么?SCAN-AND-STREAM开始时像SCAN触发器一样,将表的全部内容在某个时间点发射到流中。但它不会在此停止并声明流已完成(即有界),而是继续触发对输入表的所有后续修改,产生一个捕获表随时间演变的无界流。在一般情况下,这些修改不仅包括新值的INSERT,还包括先前值的DELETE和现有值的UPDATE(实际上被视为同时的DELETE/INSERT对,或者在 Flink 中称为undo/redo值)。
此外,如果我们考虑物化视图的表/流转换规则,唯一的真正区别是使用的触发器:
-
输入表通过
SCAN-AND-STREAM触发器隐式触发,而不是SCAN触发器。其他一切都与经典批处理查询相同。 -
输出表与经典批处理查询处理方式相同。
-
分组/取消分组操作与经典批处理查询相同,唯一的区别是使用
SCAN-AND-STREAM触发器而不是SNAPSHOT触发器进行隐式取消分组操作。
通过这个例子,很明显可以看出 SQL 固有的表偏向不仅仅是 SQL 被限制在批处理中的产物:¹²物化视图使 SQL 能够执行一种特定类型的流处理,而不需要进行任何重大的方法变更,包括对表的固有偏向。经典 SQL 只是一个偏向表的模型,无论你是用它进行批处理还是流处理。
展望未来:朝着强大的流 SQL
我们现在已经看过了时变关系,表和流提供不同的时变关系呈现方式,以及 Beam 和 SQL 模型在流和表理论方面的固有偏见。那么这一切对我们意味着什么?也许更重要的是,我们需要在 SQL 中做出哪些改变或添加以支持强大的流处理?令人惊讶的答案是:如果我们有好的默认值,就不需要太多。
我们知道,关键的概念变化是用时变关系替换经典的瞬时关系。我们之前看到,这是一个非常无缝的替换,适用于已经存在的关系代数的全部范围,这要归功于保持关系代数的关键闭包性质。但我们也看到,直接处理时变关系通常是不切实际的;我们需要能够以我们两种更常见的物理表现形式:表和流进行操作。这就是一些简单的带有良好默认值的扩展发挥作用的地方。
我们还需要一些工具来稳健地推理时间,特别是事件时间。这就是时间戳、窗口和触发器等东西发挥作用的地方。但同样,明智的默认选择将是重要的,以最小化这些扩展在实践中的必要性。
很棒的是,我们实际上不需要比这更多。所以现在让我们最终花一些时间详细研究这两类扩展:流/表选择和时间操作符。
流和表选择
当我们通过时变关系示例工作时,我们已经遇到了与流和表选择相关的两个关键扩展。它们是我们在SELECT关键字之后放置的TABLE和STREAM关键字,以指示我们对给定时变关系的期望物理视图。
*12:07> SELECT TABLE Name, 12:01> SELECT STREAM Name*
*SUM(Score) as Total, SUM(Score) as Total,*
*MAX(Time) MAX(Time)*
*FROM UserScores FROM UserScores*
*GROUP BY Name; GROUP BY Name;*
------------------------- -------------------------
| Name | Total | Time | | Name | Total | Time |
------------------------- -------------------------
| Julie | 12 | 12:07 | | Julie | 7 | 12:01 |
| Frank | 3 | 12:03 | | Frank | 3 | 12:03 |
------------------------- | Julie | 8 | 12:03 |
| Julie | 12 | 12:07 |
..... [12:01, 12:07] ....
这些扩展相对直接,需要时易于使用。但是,关于流和表选择的真正重要的事情是选择好的默认值,以便在没有明确提供时使用。这样的默认值应该尊重 SQL 的经典、偏向表的行为,这是每个人都习惯的,同时在包括流的世界中也能直观地操作。它们也应该容易记住。这里的目标是帮助系统保持自然的感觉,同时大大减少我们必须使用显式扩展的频率。满足所有这些要求的默认值的好选择是:
-
如果所有的输入都是表,输出是
TABLE。 -
如果任何输入都是流,输出是
STREAM。
这里另外需要指出的是,这些时变关系的物理呈现只有在你想以某种方式使 TVR 物化时才是真正必要的,无论是直接查看它还是将其写入某个输出表或流。鉴于 SQL 系统在全保真度时变关系方面的运行,中间结果(例如WITH AS或SELECT INTO语句)可以保持为系统自然处理的全保真度 TVR,无需将它们呈现为其他更有限的具体表现形式。
这就是流和表选择的全部内容。除了直接处理流和表的能力之外,如果我们想要在 SQL 中支持强大的、无序的流处理,我们还需要一些更好的工具来推理时间。现在让我们更详细地看看这些工具包含了什么。
时间操作符
强大的、无序处理的基础是事件时间戳:这个小的元数据片段捕获了事件发生的时间,而不是观察到它的时间。在 SQL 世界中,事件时间通常只是给定 TVR 的另一列数据,它在源数据中是本地存在的。在这个意义上,将记录的事件时间实现在记录本身中的想法是 SQL 自然地处理的,通过将时间戳放在一个常规列中。
在我们继续之前,让我们看一个例子。为了帮助将所有这些 SQL 的东西与我们之前在书中探讨过的概念联系起来,我们重新使用我们运行示例,将团队各成员的九个分数相加,得出团队的总分。如果你回忆一下,当这些分数在 X=事件时间/Y=处理时间轴上绘制时,看起来像图 8-6。

图 8-6. 我们运行示例中的数据点
如果我们把这些数据想象成一个经典的 SQL 表,它们可能看起来像这样,按事件时间排序(图 8-6 中从左到右):
*12:10> SELECT TABLE *, Sys.MTime as ProcTime
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 |
------------------------------------------------
如果你回忆一下,我们在第二章的时候就看到了这张表,那时我第一次介绍了这个数据集。这个渲染提供了比我们通常展示的更多关于数据的细节,明确地突出了这九个分数属于七个不同用户,每个用户都是同一个团队的成员。在我们开始深入示例之前,SQL 提供了一个很好的、简洁的方式来看到数据的完整布局。
这种数据视图的另一个好处是,它完全捕获了每条记录的事件时间和处理时间。你可以想象事件时间列只是原始数据的另一部分,而处理时间列是系统提供的东西(在这种情况下,使用一个假设的Sys.MTime列记录给定行的处理时间修改时间戳;也就是说,记录本身进入系统的时间)。
SQL 的有趣之处在于它可以很容易地以不同的方式查看数据。例如,如果我们希望以处理时间顺序查看数据(图 8-6 中从下到上),我们可以简单地更新ORDER BY子句:
*12:10> SELECT TABLE *, Sys.MTime as ProcTime*
*FROM UserScores ORDER BY ProcTime;*
-----------------------------------------------
| Name | Team | Score | EventTime | ProcTime |
-----------------------------------------------
| Julie | TeamX | 5 | 12:00:26 | 12:05:19 |
| Ed | TeamX | 7 | 12:02:26 | 12:05:39 |
| Amy | TeamX | 3 | 12:03:39 | 12:06:13 |
| Fred | TeamX | 4 | 12:04:19 | 12:06:39 |
| Julie | TeamX | 8 | 12:03:06 | 12:07:06 |
| Naomi | TeamX | 3 | 12:06:39 | 12:07:19 |
| Frank | TeamX | 9 | 12:01:26 | 12:08:19 |
| Becky | TeamX | 8 | 12:07:26 | 12:08:39 |
| Naomi | TeamX | 1 | 12:07:46 | 12:09:00 |
------------------------------------------------
正如我们之前学到的,这些数据的表格渲染实际上是对完整底层 TVR 的部分保真视图。如果我们改为查询完整的面向表的TVR(但为了简洁起见,只查询三个最重要的列),它会扩展到像这样:
*12:10> SELECT TVR Score, EventTime, Sys.MTime as ProcTime
FROM UserScores ORDER BY ProcTime;*
-----------------------------------------------------------------------
| [-inf, 12:05:19) | [12:05:19, 12:05:39) |
| -------------------------------- | -------------------------------- |
| | Score | EventTime | ProcTime | | | Score | EventTime | ProcTime | |
| -------------------------------- | -------------------------------- |
| -------------------------------- | | 5 | 12:00:26 | 12:05:19 | |
| | -------------------------------- |
| | |
-----------------------------------------------------------------------
| [12:05:39, 12:06:13) | [12:06:13, 12:06:39) |
| -------------------------------- | -------------------------------- |
| | Score | EventTime | ProcTime | | | Score | EventTime | ProcTime | |
| -------------------------------- | -------------------------------- |
| | 5 | 12:00:26 | 12:05:19 | | | 5 | 12:00:26 | 12:05:19 | |
| | 7 | 12:02:26 | 12:05:39 | | | 7 | 12:02:26 | 12:05:39 | |
| -------------------------------- | | 3 | 12:03:39 | 12:06:13 | |
| | -------------------------------- |
-----------------------------------------------------------------------
| [12:06:39, 12:07:06) | [12:07:06, 12:07:19) |
| -------------------------------- | -------------------------------- |
| | Score | EventTime | ProcTime | | | Score | EventTime | ProcTime | |
| -------------------------------- | -------------------------------- |
| | 5 | 12:00:26 | 12:05:19 | | | 5 | 12:00:26 | 12:05:19 | |
| | 7 | 12:02:26 | 12:05:39 | | | 7 | 12:02:26 | 12:05:39 | |
| | 3 | 12:03:39 | 12:06:13 | | | 3 | 12:03:39 | 12:06:13 | |
| | 4 | 12:04:19 | 12:06:39 | | | 4 | 12:04:19 | 12:06:39 | |
| -------------------------------- | | 8 | 12:03:06 | 12:07:06 | |
| | -------------------------------- |
-----------------------------------------------------------------------
| [12:07:19, 12:08:19) | [12:08:19, 12:08:39) |
| -------------------------------- | -------------------------------- |
| | Score | EventTime | ProcTime | | | Score | EventTime | ProcTime | |
| -------------------------------- | -------------------------------- |
| | 5 | 12:00:26 | 12:05:19 | | | 5 | 12:00:26 | 12:05:19 | |
| | 7 | 12:02:26 | 12:05:39 | | | 7 | 12:02:26 | 12:05:39 | |
| | 3 | 12:03:39 | 12:06:13 | | | 3 | 12:03:39 | 12:06:13 | |
| | 4 | 12:04:19 | 12:06:39 | | | 4 | 12:04:19 | 12:06:39 | |
| | 8 | 12:03:06 | 12:07:06 | | | 8 | 12:03:06 | 12:07:06 | |
| | 3 | 12:06:39 | 12:07:19 | | | 3 | 12:06:39 | 12:07:19 | |
| -------------------------------- | | 9 | 12:01:26 | 12:08:19 | |
| | -------------------------------- |
| | |
-----------------------------------------------------------------------
| [12:08:39, 12:09:00) | [12:09:00, now) |
| -------------------------------- | -------------------------------- |
| | Score | EventTime | ProcTime | | | Score | EventTime | ProcTime | |
| -------------------------------- | -------------------------------- |
| | 5 | 12:00:26 | 12:05:19 | | | 5 | 12:00:26 | 12:05:19 | |
| | 7 | 12:02:26 | 12:05:39 | | | 7 | 12:02:26 | 12:05:39 | |
| | 3 | 12:03:39 | 12:06:13 | | | 3 | 12:03:39 | 12:06:13 | |
| | 4 | 12:04:19 | 12:06:39 | | | 4 | 12:04:19 | 12:06:39 | |
| | 8 | 12:03:06 | 12:07:06 | | | 8 | 12:03:06 | 12:07:06 | |
| | 3 | 12:06:39 | 12:07:19 | | | 3 | 12:06:39 | 12:07:19 | |
| | 9 | 12:01:26 | 12:08:19 | | | 9 | 12:01:26 | 12:08:19 | |
| | 8 | 12:07:26 | 12:08:39 | | | 8 | 12:07:26 | 12:08:39 | |
| -------------------------------- | | 1 | 12:07:46 | 12:09:00 | |
| | -------------------------------- |
-----------------------------------------------------------------------
这是很多数据。另外,STREAM版本在这种情况下会更紧凑地呈现;由于关系中没有显式的分组,它看起来与之前的点时间TABLE呈现基本相同,另外还有一个尾部描述了迄今为止流中捕获的处理时间范围,以及系统仍在等待流中的更多数据(假设我们将流视为无界;我们很快将看到流的有界版本):
*12:00> SELECT STREAM Score, EventTime, Sys.MTime as ProcTime FROM UserScores;*
--------------------------------
| Score | EventTime | ProcTime |
--------------------------------
| 5 | 12:00:26 | 12:05:19 |
| 7 | 12:02:26 | 12:05:39 |
| 3 | 12:03:39 | 12:06:13 |
| 4 | 12:04:19 | 12:06:39 |
| 8 | 12:03:06 | 12:07:06 |
| 3 | 12:06:39 | 12:07:19 |
| 9 | 12:01:26 | 12:08:19 |
| 8 | 12:07:26 | 12:08:39 |
| 1 | 12:07:46 | 12:09:00 |
........ [12:00, 12:10] ........
但这只是查看原始输入记录,没有任何形式的转换。当我们开始改变关系时,更有趣的是。在过去的探索中,我们总是从经典的批处理开始,对整个数据集的分数进行求和,所以让我们在这里也这样做。第一个示例管道(之前作为示例 6-1 提供)在 Beam 中看起来像示例 8-1。
示例 8-1. 求和管道
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals =
input.apply(Sum.integersPerKey());
在世界的流和表视图中呈现,该流水线的执行看起来像图 8-7。
<assets/stsy_0807.mp4>

图 8-7. 经典批处理的流和表视图
鉴于我们已经将数据放入了适当的模式中,我们不会在 SQL 中进行任何解析;相反,我们专注于解析转换之后的所有流水线中的一切。因为我们采用的是传统的批处理模型,在处理完所有输入数据之后才会检索单个答案,所以求和关系的TABLE和STREAM视图看起来基本相同(请记住,对于这些初始的批处理样例,我们处理的是数据集的有界版本;因此,这个STREAM查询实际上以一行短横线和一个END-OF-STREAM标记终止):
*12:10> SELECT TABLE SUM(Score) as Total, MAX(EventTime),*
*MAX(Sys.MTime) as "MAX(ProcTime)" FROM UserScores GROUP BY Team;*
------------------------------------------
| Total | MAX(EventTime) | MAX(ProcTime) |
------------------------------------------
| 48 | 12:07:46 | 12:09:00 |
------------------------------------------
*12:00> SELECT STREAM SUM(Score) as Total, MAX(EventTime),*
*MAX(Sys.MTime) as "MAX(ProcTime)" FROM UserScores GROUP BY Team;*
------------------------------------------
| Total | MAX(EventTime) | MAX(ProcTime) |
------------------------------------------
| 48 | 12:07:46 | 12:09:00 |
------ [12:00, 12:10] END-OF-STREAM ------
更有趣的是当我们开始将窗口加入到混合中时。这将让我们有机会更仔细地查看需要添加到 SQL 中以支持鲁棒流处理的时间操作。
何处:窗口
正如我们在第六章中学到的,窗口是对按键分组的修改,其中窗口成为分层键的次要部分。与经典的程序化批处理一样,你可以通过简单地将时间作为GROUP BY参数的一部分,很容易地在现有的 SQL 中将数据窗口化。或者,如果所涉及的系统提供了,你可以使用内置的窗口操作。我们马上看一下两者的 SQL 示例,但首先,让我们重新访问第三章中的程序化版本。回想一下例子 6-2,窗口化的 Beam 流水线看起来就像例子 8-2 中所示的那样。
例子 8-2. 求和流水线
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES)))
.apply(Sum.integersPerKey());
而该流水线的执行(从图 6-5 中的流和表呈现),看起来像图 8-8 中呈现的图表。
<assets/stsy_0808.mp4>

图 8-8. 批处理引擎上窗口求和的流和表视图
正如我们之前看到的,从图 8-7 到 8-8 的唯一实质性变化是由SUM操作创建的表现现在被分成了固定的两分钟时间窗口,最终产生了四个窗口化的答案,而不是之前的单个全局总和。
在 SQL 中做同样的事情,我们有两个选择:通过在GROUP BY语句中包含窗口的某个唯一特征(例如结束时间戳)来隐式地进行窗口操作,或者使用内置的窗口操作。让我们来看看两者。
首先是临时窗口。在这种情况下,我们在 SQL 语句中自己执行计算窗口的数学运算:
*12:10> SELECT TABLE SUM(Score) as Total,*
*"" || EventTime / INTERVAL '2' MINUTES || ", " ||*
*(EventTime / INTERVAL '2' MINUTES) + INTERVAL '2' MINUTES ||*
*")" as Window,*
*MAX(Sys.MTime) as "MAX(ProcTime)"*
*FROM UserScores*
*GROUP BY Team, EventTime / INTERVAL '2' MINUTES;*
------------------------------------------------
| Total | Window | MAX(ProcTime) |
------------------------------------------------
| 14 | [12:00:00, 12:02:00) | 12:08:19 |
| 18 | [12:02:00, 12:04:00) | 12:07:06 |
| 4 | [12:04:00, 12:06:00) | 12:06:39 |
| 12 | [12:06:00, 12:08:00) | 12:09:00 |
------------------------------------------------
我们也可以使用显式的窗口语句来实现相同的结果,比如 Apache Calcite 支持的那些。
*12:10> SELECT TABLE SUM(Score) as Total,*
*TUMBLE(EventTime, INTERVAL '2' MINUTES) as Window,*
*MAX(Sys.MTime) as 'MAX(ProcTime)'*
*FROM UserScores*
*GROUP BY Team, TUMBLE(EventTime, INTERVAL '2' MINUTES);*
------------------------------------------------
| Total | Window | MAX(ProcTime) |
------------------------------------------------
| 14 | [12:00:00, 12:02:00) | 12:08:19 |
| 18 | [12:02:00, 12:04:00) | 12:07:06 |
| 4 | [12:04:00, 12:06:00) | 12:06:39 |
| 12 | [12:06:00, 12:08:00) | 12:09:00 |
------------------------------------------------
这就引出了一个问题:如果我们可以使用现有的 SQL 构造隐式地进行窗口操作,为什么还要支持显式的窗口构造呢?有两个原因,这个例子中只有第一个原因是明显的(我们将在本章后面看到另一个原因):
-
窗口化为你处理窗口计算数学。当你直接指定基本参数如宽度和滑动时,要保持一致地正确得到结果要容易得多,而不是自己计算窗口数学。¹⁴
-
窗口允许简洁地表达更复杂、动态的分组,比如会话。尽管 SQL 在技术上能够表达定义会话窗口的另一个元素时间间隔内的每个元素的关系,但相应的表达式是一团乱麻的分析函数、自连接和数组展开,普通人不可能合理地自己构造出来。
这两个都是支持 SQL 中提供一流的窗口构造的有力论据,除了已经存在的临时窗口功能。
到目前为止,当我们将数据作为表格消耗时,我们已经从经典的批处理/经典关系的角度看到了窗口的样子。但是,如果我们想将数据作为流来消耗,我们就回到了 Beam 模型中的第三个问题:在处理时间中,我们何时实现输出?
何时:触发器
与以前一样,这个问题的答案是触发器和水印。然而,在 SQL 的上下文中,有一个强有力的论点支持使用不同的默认值,而不是我们在第三章中引入的 Beam 模型的默认值:与其默认使用单个水印触发器,不如从物化视图中获取灵感,并在每个元素上触发。换句话说,每当有新的输入到达时,我们就会产生相应的新输出。
SQL 风格的默认值:每条记录触发器
使用每条记录触发器作为默认值有两个强有力的好处:
简单性
每条记录更新的语义易于理解;物化视图多年来一直以这种方式运作。
忠实度
与变更数据捕获系统一样,每条记录触发产生了给定时变关系的完整保真度流呈现;在转换过程中没有丢失任何信息。
缺点主要是成本:触发器总是应用于分组操作之后,而分组的性质通常提供了减少通过系统流动的数据的基数的机会,从而相应地减少了下游处理这些聚合结果的成本。即便如此,在成本不是禁锢的用例中,清晰和简单的好处可以说超过了默认使用非完整保真度触发器的认知复杂性。
因此,对于我们首次尝试将团队得分作为流来消耗的情况,让我们看看使用每条记录触发器会是什么样子。Beam 本身没有精确的每条记录触发器,因此,如示例 8-3 所示,我们使用重复的AfterCount(1)触发器,每当有新记录到达时就会立即触发。
示例 8-3。每条记录触发器
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(AfterCount(1)))
.apply(Sum.integersPerKey());
然后,这个管道的流和表格呈现将看起来像图 8-9 中所示的样子。
<assets/stsy_0809.mp4>

图 8-9。流和表格视图的窗口求和在具有每条记录触发的流引擎上
使用每条记录触发器的一个有趣的副作用是,它在某种程度上掩盖了数据被静止的效果,因为触发器立即将其重新激活。即使如此,来自分组的聚合物件仍然静止在表中,而未分组的值流则从中流走。
回到 SQL,我们现在可以看到将相应的时间-值关系呈现为流的效果会是什么样子。它(不出所料)看起来很像图 8-9 中动画中的值流:
*12:00> SELECT STREAM SUM(Score) as Total,*
*TUMBLE(EventTime, INTERVAL '2' MINUTES) as Window,*
*MAX(Sys.MTime) as 'MAX(ProcTime)''*
*FROM UserScores*
*GROUP BY Team, TUMBLE(EventTime, INTERVAL '2' MINUTES);*
------------------------------------------------
| Total | Window | MAX(ProcTime) |
------------------------------------------------
| 5 | [12:00:00, 12:02:00) | 12:05:19 |
| 7 | [12:02:00, 12:04:00) | 12:05:39 |
| 10 | [12:02:00, 12:04:00) | 12:06:13 |
| 4 | [12:04:00, 12:06:00) | 12:06:39 |
| 18 | [12:02:00, 12:04:00) | 12:07:06 |
| 3 | [12:06:00, 12:08:00) | 12:07:19 |
| 14 | [12:00:00, 12:02:00) | 12:08:19 |
| 11 | [12:06:00, 12:08:00) | 12:08:39 |
| 12 | [12:06:00, 12:08:00) | 12:09:00 |
................ [12:00, 12:10] ................
但即使对于这个简单的用例来说,它也是相当啰嗦的。如果我们要构建一个处理大规模移动应用程序数据的管道,我们可能不希望为每个上游用户分数的下游更新付出成本。这就是自定义触发器发挥作用的地方。
水印触发器
如果我们将 Beam 管道切换为使用水印触发器,例如,我们可以在 TVR 的流版本中每个窗口获得一个输出,如示例 8-4 所示,并如图 8-10 所示。
示例 8-4。水印触发器
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark())
.apply(Sum.integersPerKey());
<assets/stsy_0810.mp4>

图 8-10。带水印触发的窗口求和
要在 SQL 中获得相同的效果,我们需要语言支持来指定自定义触发器。类似于EMIT *<when>*语句,比如EMIT WHEN WATERMARK PAST *<column>*。这将向系统发出信号,即聚合创建的表应该在输入水印超过指定列中的时间戳值时触发一次流,这在这种情况下恰好是窗口的结束时间。
让我们看一下这个关系呈现为流。从理解触发器触发发生的时间的角度来看,停止依赖于原始输入的MTime值,并且捕获流中的行发出的当前时间戳也是很方便的:
*12:00> SELECT STREAM SUM(Score) as Total,*
*TUMBLE(EventTime, INTERVAL '2' MINUTES) as Window,*
*CURRENT_TIMESTAMP as EmitTime*
*FROM UserScores*
*GROUP BY Team, TUMBLE(EventTime, INTERVAL '2' MINUTES)*
*EMIT WHEN WATERMARK PAST WINDOW_END(Window);*
-------------------------------------------
| Total | Window | EmitTime |
-------------------------------------------
| 5 | [12:00:00, 12:02:00) | 12:06:00 |
| 18 | [12:02:00, 12:04:00) | 12:07:30 |
| 4 | [12:04:00, 12:06:00) | 12:07:41 |
| 12 | [12:06:00, 12:08:00) | 12:09:22 |
............. [12:00, 12:10] ..............
这里的主要缺点是由于启发式水印的使用而导致的延迟数据问题,正如我们在前几章中遇到的那样。考虑到延迟数据,一个更好的选择可能是在每次出现延迟记录时立即输出更新,使用支持重复延迟触发的水印触发器的变体,如示例 8-5 和图 8-11 所示。
示例 8-5。带有延迟触发的水印触发器
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(AfterWatermark()
.withLateFirings(AfterCount(1))))
.apply(Sum.integersPerKey());
<assets/stsy_0811.mp4>

图 8-11。带有准时/延迟触发的窗口求和
我们可以通过允许指定两个触发器来在 SQL 中做同样的事情:
-
一个水印触发器给我们一个初始值:
WHEN WATERMARK PAST *<column>*,窗口的结束时间被用作时间戳*<column>*。 -
用于延迟数据的重复延迟触发器:
AND THEN AFTER *<duration>*,其中*<duration>*为 0,以给出每条记录的语义。
现在我们每个窗口可以获得多行,还可以有另外两个系统列可用:每行/窗格相对于水印的时间(Sys.EmitTiming),以及每个窗口的窗格/行的索引(Sys.EmitIndex,用于标识给定行/窗口的修订序列):
*12:00> SELECT STREAM SUM(Score) as Total,*
*TUMBLE(EventTime, INTERVAL '2' MINUTES) as Window,*
*CURRENT_TIMESTAMP as EmitTime,*
*Sys.EmitTiming, Sys.EmitIndex*
*FROM UserScores*
*GROUP BY Team, TUMBLE(EventTime, INTERVAL '2' MINUTES)*
*EMIT WHEN WATERMARK PAST WINDOW_END(Window)*
*AND THEN AFTER 0 SECONDS;*
----------------------------------------------------------------------------
| Total | Window | EmitTime | Sys.EmitTiming | Sys.EmitIndex |
----------------------------------------------------------------------------
| 5 | [12:00:00, 12:02:00) | 12:06:00 | on-time | 0 |
| 18 | [12:02:00, 12:04:00) | 12:07:30 | on-time | 0 |
| 4 | [12:04:00, 12:06:00) | 12:07:41 | on-time | 0 |
| 14 | [12:00:00, 12:02:00) | 12:08:19 | late | 1 |
| 12 | [12:06:00, 12:08:00) | 12:09:22 | on-time | 0 |
.............................. [12:00, 12:10] ..............................
使用这个触发器,对于每个窗格,我们能够得到一个准时的答案,这很可能是正确的,这要归功于我们的启发式水印。对于任何延迟到达的数据,我们可以得到一行的更新版本,修正我们之前的结果。
重复延迟触发器
你可能想要的另一个主要时间触发器用例是重复延迟更新;也就是说,在任何新数据到达后的一分钟(在处理时间上)触发窗口。请注意,这与在微批处理系统中触发对齐边界是不同的。正如示例 8-6 所示,通过相对于窗口/行的最近新记录到达的延迟触发,有助于更均匀地分散触发负载,而不像突发的对齐触发那样。它也不需要任何水印支持。图 8-12 呈现了结果。
示例 8-6。重复触发,延迟一分钟
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(FixedWindows.of(TWO_MINUTES))
.triggering(Repeatedly(UnalignedDelay(ONE_MINUTE)))
.apply(Sum.integersPerKey());
<assets/stsy_0812.mp4>

图 8-12。带有重复一分钟延迟触发的窗口求和
使用这样的触发器的效果与我们最初开始的每条记录触发非常相似,但由于触发中引入了额外的延迟,稍微减少了一些冗余,这使得系统能够省略产生的某些行。调整延迟可以让我们调节生成的数据量,从而平衡成本和及时性的张力,以适应使用情况。
作为 SQL 流呈现,它可能看起来像这样:
*12:00> SELECT STREAM SUM(Score) as Total,*
*TUMBLE(EventTime, INTERVAL '2' MINUTES) as Window,*
*CURRENT_TIMESTAMP as EmitTime,*
*Sys.EmitTiming, SysEmitIndex*
*FROM UserScores*
*GROUP BY Team, TUMBLE(EventTime, INTERVAL '2' MINUTES)*
*EMIT AFTER 1 MINUTE;*
----------------------------------------------------------------------------
| Total | Window | EmitTime | Sys.EmitTiming | Sys.EmitIndex |
----------------------------------------------------------------------------
| 5 | [12:00:00, 12:02:00) | 12:06:19 | n/a | 0 |
| 10 | [12:02:00, 12:04:00) | 12:06:39 | n/a | 0 |
| 4 | [12:04:00, 12:06:00) | 12:07:39 | n/a | 0 |
| 18 | [12:02:00, 12:04:00) | 12:08:06 | n/a | 1 |
| 3 | [12:06:00, 12:08:00) | 12:08:19 | n/a | 0 |
| 14 | [12:00:00, 12:02:00) | 12:09:19 | n/a | 1 |
| 12 | [12:06:00, 12:08:00) | 12:09:22 | n/a | 1 |
.............................. [12:00, 12:10] ..............................
数据驱动触发器
在进入梁模型的最后一个问题之前,值得简要讨论“数据驱动触发器”的概念。由于 SQL 中处理类型的动态方式,似乎数据驱动触发器会是提议的EMIT *<when>*子句的一个非常自然的补充。例如,如果我们想在总分超过 10 时触发我们的总和,类似EMIT WHEN Score > 10的东西会非常自然地工作吗?
嗯,是的和不。是的,这样的构造会非常自然。但是当你考虑这样一个构造实际上会发生什么时,你基本上会在每条记录上触发,然后执行Score > 10谓词来决定触发的行是否应该向下游传播。你可能还记得,这听起来很像HAVING子句的情况。实际上,你可以通过简单地在查询的末尾添加HAVING Score > 10来获得完全相同的效果。在这一点上,它引出了一个问题:值得添加显式的数据驱动触发器吗?可能不值得。即便如此,看到使用标准 SQL 和精心选择的默认值如何轻松地获得所需的数据驱动触发器效果仍然令人鼓舞。
如何:累积
到目前为止,在本节中,我们一直忽略了我在本章开头介绍的Sys.Undo列。因此,我们默认使用累积模式来回答窗口/行的细化如何相互关联的问题。换句话说,每当我们观察到聚合行的多个修订时,后续的修订都建立在前面的修订之上,将新的输入与旧的输入累积在一起。我选择这种方法是因为它与早期章节中使用的方法相匹配,并且相对于表世界中的工作方式,这是一个相对简单的转换。
也就是说,累积模式有一些主要缺点。实际上,正如我们在第二章中讨论的那样,对于具有两个或更多分组操作序列的任何查询/管道来说,它对于过度计数是明显错误的。在允许包含多个序列分组操作的查询的系统中,允许对行的多个修订进行消耗的唯一明智的方法是默认情况下以累积和撤销模式运行。否则,由于对单行的多个修订的盲目合并,会出现一个给定输入记录在单个聚合中被多次包含的问题。
因此,当我们考虑将累积模式语义纳入 SQL 世界时,最符合我们提供直观和自然体验目标的选项是系统在底层默认使用撤销。正如我之前介绍Sys.Undo列时所指出的,如果你不关心撤销(就像直到现在本节中的示例一样),你不需要要求它们。但是如果你要求它们,它们应该在那里。
在 SQL 世界中的撤销
为了说明我的意思,让我们看另一个例子。为了适当地激发问题,让我们看一个相对不切实际的用例,即构建会话窗口并将它们逐步写入到 HBase 等键值存储中。在这种情况下,我们将从聚合中产生增量会话,但在许多情况下,给定的会话只是一个或多个先前会话的演变。在这种情况下,您真的希望删除先前的会话,并用新的会话替换它们。但是你该怎么做呢?判断给定的会话是否替换了另一个会话的唯一方法是将它们进行比较,看看新会话是否与旧会话重叠。但这意味着在管道的另一个部分中复制一些会话构建逻辑。更重要的是,这意味着您不再具有幂等输出,因此如果要保持端到端的一次性语义,就需要跳过一系列额外的步骤。更好的方法是,管道直接告诉您哪些会话被删除,哪些会话被替换。这就是撤销给您的东西。
要看到这个示例的效果(以及 SQL 中的效果),让我们修改我们的示例管道,计算具有一分钟间隔的会话窗口。为了简单和清晰起见,我们回到使用默认的每条记录触发。请注意,我还将处理时间内的一些数据点移动到这些会话示例中,以使图表更清晰;事件时间戳保持不变。更新后的数据集如下(用黄色突出显示了移动的处理时间戳):
*12:00> SELECT STREAM Score, EventTime, Sys.MTime as ProcTime*
*FROM UserScoresForSessions;*
--------------------------------
| Score | EventTime | ProcTime |
--------------------------------
| 5 | 12:00:26 | 12:05:19 |
| 7 | 12:02:26 | 12:05:39 |
| 3 | 12:03:39 | 12:06:13 |
| 4 | 12:04:19 | 12:06:46 | # Originally 12:06:39
| 3 | 12:06:39 | 12:07:19 |
| 8 | 12:03:06 | 12:07:33 | # Originally 12:07:06
| 8 | 12:07:26 | 12:08:13 | # Originally 12:08:39
| 9 | 12:01:26 | 12:08:19 |
| 1 | 12:07:46 | 12:09:00 |
........ [12:00, 12:10] ........
首先,让我们看一下没有撤销的管道。在清楚了为什么该管道对于将增量会话写入键/值存储的用例是有问题之后,我们将看一下带有撤销的版本。
不撤销管道的 Beam 代码看起来像示例 8-7。图 8-13 显示了结果。
示例 8-7。具有每条记录触发和累积但没有撤销的会话窗口
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(Sessions.withGapDuration(ONE_MINUTE))
.triggering(Repeatedly(AfterCount(1))
.accumulatingFiredPanes())
.apply(Sum.integersPerKey());
<assets/stsy_0813.mp4>

图 8-13。使用累积但没有撤销的会话窗口总结
最后,在 SQL 中呈现的输出流将如下所示:
*12:00> SELECT STREAM SUM(Score) as Total,*
*SESSION(EventTime, INTERVAL '1' MINUTE) as Window,*
*CURRENT_TIMESTAMP as EmitTime*
*FROM UserScoresForSessions*
*GROUP BY Team, SESSION(EventTime, INTERVAL '1' MINUTE);*
-------------------------------------------
| Total | Window | EmitTime |
-------------------------------------------
| 5 | [12:00:26, 12:01:26) | 12:05:19 |
| 7 | [12:02:26, 12:03:26) | 12:05:39 |
| 3 | [12:03:39, 12:04:39) | 12:06:13 |
| 7 | [12:03:39, 12:05:19) | 12:06:46 |
| 3 | [12:06:39, 12:07:39) | 12:07:19 |
| 22 | [12:02:26, 12:05:19) | 12:07:33 |
| 11 | [12:06:39, 12:08:26) | 12:08:13 |
| 36 | [12:00:26, 12:05:19) | 12:08:19 |
| 12 | [12:06:39, 12:08:46) | 12:09:00 |
............. [12:00, 12:10] ..............
在这里要注意的重要事情(在动画和 SQL 渲染中)是增量会话流的样子。从我们的整体观点来看,很容易在动画中直观地识别出哪些后续会话取代了之前的会话。但是想象一下,逐个接收这个流中的元素(就像在 SQL 列表中一样),并需要以一种最终使 HBase 表只包含两个最终会话(值为 36 和 12)的方式将它们写入 HBase。你会怎么做呢?嗯,你需要进行一系列的读取-修改-写入操作,读取一个键的所有现有会话,将它们与新会话进行比较,确定哪些会话重叠,删除过时的会话,最后为新会话发出写入操作——所有这些都需要额外的成本,并且会丧失幂等性,最终导致无法提供端到端的、一次性的语义。这是不切实际的。
然后,将这个与启用撤销的相同管道进行对比,就像示例 8-8 和图 8-14 中所示的那样。
示例 8-8。具有每条记录触发、累积和撤销的会话窗口
PCollection<String> raw = IO.read(...);
PCollection<KV<Team, Integer>> input = raw.apply(new ParseFn());
PCollection<KV<Team, Integer>> totals = input
.apply(Window.into(Sessions.withGapDuration(ONE_MINUTE))
.triggering(Repeatedly(AfterCount(1))
.accumulatingAndRetractingFiredPanes())
.apply(Sum.integersPerKey());
<assets/stsy_0814.mp4>

图 8-14。使用累积和撤销的会话窗口总结
最后,在 SQL 形式上。对于 SQL 版本,我们假设系统默认情况下正在使用撤销,并且每当我们请求特殊的Sys.Undo列时,单独的撤销行就会在流中实现。正如我最初描述的那样,该列的价值在于它允许我们区分撤销行(在Sys.Undo列中标记为“撤销”)和正常行(在这里Sys.Undo列中未标记,以便更清晰地对比,尽管它们也可以被标记为“重做”):
*12:00> SELECT STREAM SUM(Score) as Total,*
*SESSION(EventTime, INTERVAL '1' MINUTE) as Window,*
*CURRENT_TIMESTAMP as EmitTime,*
*Sys.Undo as Undo*
*FROM UserScoresForSessions*
*GROUP BY Team, SESSION(EventTime, INTERVAL '1' MINUTE);*
--------------------------------------------------
| Total | Window | EmitTime | Undo |
--------------------------------------------------
| 5 | [12:00:26, 12:01:26) | 12:05:19 | |
| 7 | [12:02:26, 12:03:26) | 12:05:39 | |
| 3 | [12:03:39, 12:04:39) | 12:06:13 | |
| 3 | [12:03:39, 12:04:39) | 12:06:46 | undo |
| 7 | [12:03:39, 12:05:19) | 12:06:46 | |
| 3 | [12:06:39, 12:07:39) | 12:07:19 | |
| 7 | [12:02:26, 12:03:26) | 12:07:33 | undo |
| 7 | [12:03:39, 12:05:19) | 12:07:33 | undo |
| 22 | [12:02:26, 12:05:19) | 12:07:33 | |
| 3 | [12:06:39, 12:07:39) | 12:08:13 | undo |
| 11 | [12:06:39, 12:08:26) | 12:08:13 | |
| 5 | [12:00:26, 12:01:26) | 12:08:19 | undo |
| 22 | [12:02:26, 12:05:19) | 12:08:19 | undo |
| 36 | [12:00:26, 12:05:19) | 12:08:19 | |
| 11 | [12:06:39, 12:08:26) | 12:09:00 | undo |
| 12 | [12:06:39, 12:08:46) | 12:09:00 | |
................. [12:00, 12:10] .................
包括撤销在内,会话流不再仅包括新会话,还包括已被替换的旧会话的撤销。有了这个流,随着时间的推移,逐步构建 HBase 中的会话集变得微不足道:您只需在新会话到达时写入新会话(未标记为“重做”行),并在它们被撤销时删除旧会话(“撤销”行)。好得多!
丢弃模式,或者缺乏丢弃模式
通过这个例子,我们展示了如何简单而自然地将撤销纳入 SQL 中,以提供累积模式和累积和撤销模式语义。但是丢弃模式呢?
对于特定用例,例如通过单个分组操作部分聚合高容量输入数据,然后将其写入支持聚合的存储系统(例如类似数据库的系统),丢弃模式可以作为节省资源的选项非常有价值。但在那些相对狭窄的用例之外,丢弃模式是令人困惑和容易出错的。因此,将其直接纳入 SQL 可能并不值得。需要它的系统可以在 SQL 语言本身之外提供它作为一个选项。那些不需要的系统可以简单地提供更自然的默认值累积和撤销模式,并在不需要时忽略撤销。
总结
这是一个漫长而迷人的旅程。我们在本章中涵盖了大量信息,让我们花点时间来反思一下。
首先,我们推断出流处理和非流处理数据处理之间的关键区别是时间的增加维度。我们观察到关系(关系代数的基础数据对象,它本身是 SQL 的基础)本身随时间演变,并从中推导出了TVR的概念,它将关系的演变捕捉为经典快照关系的序列。从这个定义中,我们能够看到关系代数的闭包性质在 TVR 的世界中保持完整,这意味着整套关系运算符(因此也是 SQL 构造)在我们从瞬时快照关系的世界转移到流兼容的 TVR 的世界时继续像预期的那样运行。
其次,我们探讨了 Beam 模型和经典 SQL 模型中固有的偏见,得出结论 Beam 具有面向流的方法,而 SQL 采用面向表的方法。
最后,我们看了一下需要对 SQL 进行语言扩展以支持健壮流处理的假设性语言扩展,以及一些精心选择的默认值,这些默认值可以大大减少对这些扩展的需求:
表/流选择
鉴于任何时变关系都可以以两种不同的方式呈现(表或流),我们需要在实现查询结果时选择所需的呈现方式。我们引入了TABLE、STREAM和TVR关键字,以提供一种明确选择所需呈现方式的方式。
更好的是不需要明确指定选择,这就是好的默认值的作用。如果所有输入都是表,那么输出为表是一个很好的默认值;这给了您所习惯的经典关系查询行为。相反,如果任何输入是流,则输出为流是一个合理的默认值。
窗口化
虽然你可以使用现有的 SQL 构造声明一些类型的简单窗口,但是具有显式窗口化运算符仍然具有价值:
-
窗口化运算符封装了窗口计算数学。
-
窗口化允许简洁地表达复杂的、动态的分组,比如会话。
因此,添加用于分组的简单窗口化构造可以帮助使查询更少出错,同时还提供了(例如会话)在现有的声明性 SQL 中难以表达的功能。
水印
这不仅仅是 SQL 的扩展,而是一个系统级特性。如果所涉及的系统在内部集成了水印,它们可以与触发器一起使用,以在相信该行的输入已经完成后生成包含单个、权威版本的流。这对于那些不可能为结果轮询物化视图表的用例至关重要,而是必须直接将管道的输出作为流进行消耗。示例包括通知和异常检测。
触发器
触发器定义了从 TVR 创建的流的形状。如果未指定,默认应该是每条记录触发,这提供了与物化视图相匹配的直接和自然的语义。除了默认值,基本上有两种主要类型的有用触发器:
-
水印触发器,用于在相信该窗口的输入已经完成时,为每个窗口产生单个输出。
-
重复延迟触发器,用于提供周期性更新。
这两者的组合也可能很有用,特别是在启发式水印的情况下,以提供我们之前看到的早期/准时/迟的模式。
特殊系统列
当将 TVR 作为流进行消耗时,有一些有趣的元数据可能会很有用,而且最容易暴露为系统级列。我们看了四个:
Sys.MTime
给定行在 TVR 中上次修改的处理时间。
Sys.EmitTiming
行发出相对于水印的时间(早、准时、迟)。
Sys.EmitIndex
该行的发出版本的从零开始的索引。¹⁹
Sys.Undo
该行是正常行还是撤销(undo)。默认情况下,系统应该在内部使用撤销,这在可能存在一系列多个分组操作的任何时候是必要的。如果在将 TVR 呈现为流时未投影Sys.Undo列,那么只会返回正常行,这提供了在累积和累积和撤销模式之间切换的简单方法。
使用 SQL 进行流处理并不需要很困难。事实上,SQL 中的流处理已经相当普遍,以物化视图的形式存在。真正重要的部分实际上归结为捕获数据集/关系随时间的演变(通过时变关系),提供在物理表或流表示之间进行选择的手段,以及提供关于时间的推理工具(窗口化、水印和触发器),这些我们在本书中一直在讨论的。而且,至关重要的是,你需要很好的默认值,以最小化这些扩展在实践中需要被使用的频率。
¹ 这里我所说的“有效关系”简单地是指对于给定操作符的应用是良好形式的关系。例如,对于 SQL 查询SELECT x FROM y,一个有效的关系 y 将是任何包含名为 x 的属性/列的关系。任何不包含这样命名属性的关系将是无效的,并且在实际数据库系统的情况下,将产生查询执行错误。
² 对 Julian Hyde 的这个名称和概念的简洁表达表示非常感谢。
³ 请注意,这里使用的Sys.Undo名称是在Apache Flink 的撤销/重做命名法的基础上进行的,我认为这是捕捉撤销和非撤销行的想法的一种非常简洁的方式。
⁴ 现在,在这个例子中,很容易发现新值 8 应该替换旧值 7,因为映射是 1:1。但当我们谈论会话时,我们将在稍后看到一个更复杂的例子,没有撤销作为指导,处理起来会更加困难。
⁵ 而且,这是一个需要记住的关键点。有一些系统主张将流和表视为相同,声称我们可以简单地将流视为永不结束的表。这种说法在某种程度上是准确的,因为真正的基础原语是时变关系,所有关系操作都可以等同地应用于任何时变关系,无论实际的物理表现形式是流还是表。但这种方法混淆了表和流为给定的时变关系提供的两种非常不同的视图类型。假装两个非常不同的东西是相同的,表面上看起来很简单,但这不是通向理解、清晰和正确的道路。
⁶ 这里指的是随时间变化的表;也就是我们一直在看的基于表的 TVR。
⁷ 这是朱利安·海德的礼貌。
⁸ 尽管各个项目中有许多正在进行的工作,试图简化触发/取消分组语义的规范。在 Flink 和 Beam 社区内部独立提出的最具说服力的建议是,触发器应该简单地在管道的输出处指定,并自动在整个管道中传播。通过这种方式,只需描述实际创建物化输出的流的期望形状;所有其他流的形状将从那里隐式地派生出来。
⁹ 当然,单个 SQL 查询的表达能力远远超过单个 MapReduce,因为它具有更少限制的操作和组合选项。
¹⁰ 请注意,我们在这里是在概念上讨论;当然,在实际执行中可以应用许多优化;例如,通过索引查找特定行而不是扫描整个表。
¹¹ 有多次提到这些查询的“MATERIALIZED”方面只是一种优化:从语义上讲,这些查询可以很容易地用通用的CREATE VIEW语句替换,这种情况下,数据库可能会在每次引用时重新生成整个视图。这是真的。我在这里使用MATERIALIZED变体的原因是,物化视图的语义是根据变化流增量更新视图表,这表明了它们背后的流式特性。也就是说,你可以根据发生的变化增量处理变化,也可以不时地重新处理整个输入数据集。这两种方式都是处理不断变化的数据表的有效方式。
¹² 虽然可以说 SQL 的表偏向可能是 SQL 在批处理中的根源。
¹³ 对于某些用例,捕获和使用给定记录的当前处理时间作为其未来事件时间可能是有用的(例如,当直接将事件记录到 TVR 中时,入口时间就是该记录的自然事件时间)。
¹⁴ 数学很容易出错。
15 默认情况下,使用撤回就足够了,而不仅仅是因为系统只需要选择使用撤回。有特定的用例;例如,具有单个分组操作的查询,其结果正在写入支持按键更新的外部存储系统,系统可以检测到不需要撤回并将其禁用作为优化。
16 请注意,仅仅在SELECT语句中简单添加新列就导致查询中出现新行有点奇怪。一个很好的替代方法是在不需要时通过WHERE子句过滤掉Sys.Undo行。
17 请注意,这种琐事只适用于最终一致性足够的情况。如果您需要始终在任何给定时间具有全局一致的视图,则必须 1)确保在其发出时间写入/删除(通过墓碑)每个会话,并且 2)仅从 HBase 表中的时间戳读取,该时间戳小于管道的输出水印(以使读取与会话合并时发生的多个独立写入/删除同步)。或者更好的是,直接从状态表中提供会话,而不是中间人。
18 明确地说,它们并非都是假设的。Calcite 支持本章描述的窗口构造。
19 请注意,在像会话这样的合并窗口的情况下,“索引”的定义变得复杂。一个合理的方法是取所有先前合并在一起的会话的最大值,并递增一。
第九章:流连接
当我开始学习连接时,这是一个令人生畏的话题;LEFT、OUTER、SEMI、INNER、CROSS:连接的语言是富有表现力和广泛的。再加上流带来的时间维度,你会发现这似乎是一个具有挑战性的复杂话题。好消息是,连接实际上并不是一开始看起来那么可怕的野兽,它没有令人畏惧的尖牙。与许多其他复杂话题一样,一旦你理解了连接的核心思想和主题,建立在这些基础之上的更广泛的景观突然变得更加易于访问。所以请加入我,我们一起探索这个迷人的话题...连接。
你所有的连接都属于流连接
加入两个数据集是什么意思?我们直观地理解,连接只是一种特定类型的分组操作:通过将共享某些属性(即键)的数据连接在一起,我们将一些先前不相关的个体数据元素收集到一组相关元素中。正如我们在第六章中学到的,分组操作总是消耗流并产生表格。知道这两件事,然后得出这整个章节基础的结论只是一个小小的飞跃:在它们的核心,所有连接都是流连接。
这个事实的伟大之处在于,它实际上使得流连接的主题更加易于处理。我们在流分组操作的时间推理方面学到的所有工具(窗口化、水印、触发器等)在流连接的情况下仍然适用。也许令人生畏的是,将流连接添加到混合中似乎只会使事情变得更加复杂。但正如您将在接下来的示例中看到的那样,对于将所有连接建模为流连接,存在一种优雅的简单和一致性。与其感觉存在令人困惑的多种不同连接方法,不如清楚地认识到几乎所有类型的连接实际上都归结为同一模式的轻微变化。最终,这种洞察力的清晰有助于使连接(流连接或其他)变得不那么令人生畏。
为了给我们一些具体的推理对象,让我们考虑一些不同类型的连接,它们被应用于以下数据集,方便地命名为Left和Right以匹配常见的命名约定:
*12:10> SELECT TABLE * FROM Left; 12:10> SELECT TABLE * FROM Right;*
-------------------- --------------------
| Num | Id | Time | | Num | Id | Time |
-------------------- --------------------
| 1 | L1 | 12:02 | | 2 | R2 | 12:01 |
| 2 | L2 | 12:06 | | 3 | R3 | 12:04 |
| 3 | L3 | 12:03 | | 4 | R4 | 12:05 |
-------------------- --------------------
每个包含三列:
Num
一个数字。
Id
对应表格的第一个字母(“L”或“R”)和Num的混合词,从而提供了一种唯一标识连接结果中给定单元格来源的方式。
时间
给定记录在系统中的到达时间,在考虑流连接时变得重要。
为了保持简单,注意我们的初始数据集将严格具有唯一的连接键。当我们到达SEMI连接时,我们将引入一些更复杂的数据集,以突出重复键存在时的连接行为。
我们首先深入研究未窗口连接,因为窗口化通常只在微小程度上影响连接语义。在我们满足未窗口连接的胃口之后,我们将触及一些窗口化上连接的更有趣的点。
未窗口连接
流连接无限数据并不总是需要窗口。但是通过应用我们在第六章学到的概念,我们可以看到这并不是真的。连接(包括窗口化和非窗口化)只是另一种分组操作,而分组操作产生表。因此,如果我们想要将非窗口连接创建的表作为流来消费,我们只需要应用一个非“等待直到我们看到所有输入”的非分组(或触发)操作。将连接窗口化为非全局窗口并使用水印触发器(即“等待直到我们看到流的有限时间段内的所有输入”触发器)确实是一种选择,但无论连接是否窗口化,都可以在每条记录上触发(即物化视图语义)或定期触发,而不考虑处理时间的推移。因为这样做可以使示例更容易理解,我们假设在以下所有非窗口连接示例中使用隐式默认的每条记录触发器来观察连接结果作为流。
现在,让我们来看看连接本身。ANSI SQL 定义了五种连接类型:FULL OUTER、LEFT OUTER、RIGHT OUTER、INNER和CROSS。我们深入研究前四种,并在下一段简要讨论最后一种。我们还涉及另外两种有趣但不太常见(至少使用标准语法支持较差)的变体:ANTI和SEMI连接。
表面上,听起来有很多变体。但是你会看到,实际上核心只有一种连接类型:FULL OUTER连接。CROSS连接只是具有虚假真连接谓词的FULL OUTER连接;也就是说,它返回左表中的每一行与右表中的每一行的所有可能配对。所有其他连接变体都简化为FULL OUTER连接的某个逻辑子集。¹因此,一旦你理解了所有不同连接类型之间的共同点,就会更容易记住它们。这也使得在流的上下文中推理它们变得更加简单。
在我们开始之前,这里还有一点需要注意:我们将主要考虑最多具有 1:1 基数的等值连接,也就是说,连接谓词是一个相等语句,并且每一侧最多只有一行匹配。这样可以使示例简单而简洁。当我们涉及SEMI连接时,我们将扩展我们的示例以考虑具有任意 N:M 基数的连接,这将让我们观察更多任意谓词连接的行为。
FULL OUTER
因为它们构成了其他每种变体的概念基础,我们首先看FULL OUTER连接。外连接体现了对“连接”一词的相当自由和乐观的解释:FULL OUTER连接两个数据集的结果基本上是两个数据集中所有行的完整列表,²两个数据集中具有相同连接键的行被合并在一起,但未匹配的行都包括在未连接中。
例如,如果我们将两个示例数据集进行FULL OUTER连接,生成一个只包含连接 ID 的新关系,结果可能如下所示:
*12:10> SELECT TABLE*
*Left.Id as L,*
*Right.Id as R,*
*FROM Left FULL OUTER JOIN Right*
*ON L.Num = R.Num;*
---------------
| L | R |
---------------
| L1 | null |
| L2 | R2 |
| L3 | R3 |
| null | R4 |
---------------
我们可以看到FULL OUTER连接包括满足连接谓词的行(例如,“L2, R2”和“L3, R3”),但它还包括未满足谓词的部分行(例如,“L1, null”和“null, R4”,其中 null 表示数据的未连接部分)。
当然,这只是这种FULL OUTER连接关系的一个时间点快照,在所有数据到达系统之后进行。我们在这里学习流连接,流连接的定义涉及时间的增加维度。正如我们从第八章所知,如果我们想要了解给定数据集/关系随时间如何变化,我们希望用时间变化关系(TVR)来表达。因此,为了最好地了解连接随时间的演变,让我们现在看看这个连接的完整 TVR(每个快照关系之间的变化都用黄色突出显示):
*12:10> SELECT TVR*
*Left.Id as L,*
*Right.Id as R,*
*FROM Left FULL OUTER JOIN Right*
*ON L.Num = R.Num;*
-------------------------------------------------------------------------
| [-inf, 12:01) | [12:01, 12:02) | [12:02, 12:03) | [12:03, 12:04) |
| --------------- | --------------- | --------------- | --------------- |
| | L | R | | | L | R | | | L | R | | | L | R | |
| --------------- | --------------- | --------------- | --------------- |
| --------------- | | null | R2 | | | L1 | null | | | L1 | null | |
| | --------------- | | null | R2 | | | null | R2 | |
| | | --------------- | | L3 | null | |
| | | | --------------- |
-------------------------------------------------------------------------
| [12:04, 12:05) | [12:05, 12:06) | [12:06, 12:07) |
| --------------- | --------------- | --------------- |
| | L | R | | | L | R | | | L | R | |
| --------------- | --------------- | --------------- |
| | L1 | null | | | L1 | null | | | L1 | null | |
| | null | L2 | | | null | L2 | | | L2 | L2 | |
| | L3 | L3 | | | L3 | L3 | | | L3 | L3 | |
| --------------- | | null | L4 | | | null | L4 | |
| | --------------- | --------------- |
-------------------------------------------------------
然后,正如你可能期望的那样,这个 TVR 的流呈现将捕捉到每个快照之间的具体增量:
*12:00> SELECT STREAM*
*Left.Id as L,*
*Right.Id as R,*
*CURRENT_TIMESTAMP as Time,*
*Sys.Undo as Undo*
*FROM Left FULL OUTER JOIN Right*
*ON L.Num = R.Num;*
------------------------------
| L | R | Time | Undo |
------------------------------
| null | R2 | 12:01 | |
| L1 | null | 12:02 | |
| L3 | null | 12:03 | |
| L3 | null | 12:04 | undo |
| L3 | R3 | 12:04 | |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
注意包括“时间”和“撤销”列,以突出给定行在流中出现的时间,并且还指出给定行的更新首次导致撤销该行的先前版本的情况。如果这个流要捕捉随时间变化的完整视图,那么撤销/撤回行就至关重要。
因此,尽管这三种连接(表、TVR、流)各自是不同的,但很明显它们都只是相同数据的不同视图:表快照向我们展示了所有数据到达后数据集的整体情况,而 TVR 和流版本以各自的方式捕捉了整个关系在其存在过程中的演变。
有了对FULL OUTER连接的基本了解,我们现在理解了流上下文中连接的所有核心概念。不需要窗口,也不需要自定义触发器,没有特别痛苦或不直观的东西。只是连接随时间的每条记录的演变,正如你所期望的那样。更好的是,所有其他类型的连接只是这个主题的变体(至少在概念上),本质上只是在FULL OUTER连接的每条记录流上执行的额外过滤操作。现在让我们更详细地看看它们中的每一个。
LEFT OUTER
LEFT OUTER连接只是FULL OUTER连接,其中右数据集中的任何未连接行都被移除。通过对原始的FULL OUTER连接进行灰掉将被过滤的行,可以最清楚地看到这一点。对于LEFT OUTER连接,看起来会像下面这样,原始的FULL OUTER连接中的每一行未连接的左侧都被过滤掉:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left LEFT OUTER JOIN Right FROM Left LEFT OUTER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | null | R2 | 12:01 | |
| L2 | R2 | | L1 | null | 12:02 | |
| L3 | R3 | | L3 | null | 12:03 | |
| null | R4 | | L3 | null | 12:04 | undo |
--------------- | L3 | R3 | 12:04 | |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
为了看到表和流在实践中实际上会是什么样子,让我们再次看一下相同的查询,但这次完全省略了灰色行:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left LEFT OUTER JOIN Right FROM Left LEFT OUTER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | L1 | null | 12:02 | |
| L2 | R2 | | L3 | null | 12:03 | |
| L3 | R3 | | L3 | null | 12:04 | undo |
--------------- | L3 | R3 | 12:04 | |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
RIGHT OUTER
RIGHT OUTER连接是左连接的相反:在完全外连接中,来自左数据集的所有未连接行都被右侧移除。
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left RIGHT OUTER JOIN Right FROM Left RIGHT OUTER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | null | R2 | 12:01 | |
| L2 | R2 | | L1 | null | 12:02 | |
| L3 | R3 | | L3 | null | 12:03 | |
| null | R4 | | L3 | null | 12:04 | undo |
--------------- | L3 | R3 | 12:04 | |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
在这里我们看到查询实际上呈现为实际的RIGHT OUTER连接会是什么样子:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left RIGHT OUTER JOIN Right FROM Left RIGHT OUTER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L2 | R2 | | null | R2 | 12:01 | |
| L3 | R3 | | L3 | R3 | 12:04 | |
| null | R4 | | null | R4 | 12:05 | |
--------------- | null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
INNER
INNER连接实质上是LEFT OUTER和RIGHT OUTER连接的交集。或者,从减法的角度来看,从原始的FULL OUTER连接中移除以创建INNER连接的行是从LEFT OUTER和RIGHT OUTER连接中移除的行的并集。因此,任一侧保留未连接的所有行都不会出现在INNER连接中:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left INNER JOIN Right FROM Left INNER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | null | R2 | 12:01 | |
| L2 | R2 | | L1 | null | 12:02 | |
| L3 | R3 | | L3 | null | 12:03 | |
| null | R4 | | L3 | null | 12:04 | undo |
--------------- | L3 | R3 | 12:04 | |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
再次,更简洁地呈现为实际的INNER连接会是什么样子:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left INNER JOIN Right FROM Left INNER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L2 | R2 | | L3 | R3 | 12:04 | |
| L3 | R3 | | L2 | R2 | 12:06 | |
--------------- ....... [12:00, 12:10] .......
根据这个例子,你可能会倾向于认为在INNER连接流中撤回从未发挥作用,因为在这个例子中它们都被过滤掉了。但是想象一下,如果“左”表中具有Num为3的行的值在 12:07 从L3更新为L3v2。除了在最终的TABLE查询中(再次在 12:10 执行,这是在“左”边的行3的更新到达之后)导致左侧的不同值之外,它还将导致一个STREAM,捕捉到旧值的撤回和新值的添加:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM LeftV2 INNER JOIN Right FROM LeftV2 INNER JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L2 | R2 | | L3 | R3 | 12:04 | |
| L3v2 | R3 | | L2 | R2 | 12:06 | |
--------------- | L3 | R3 | 12:07 | undo |
| L3v2 | R3 | 12:07 | |
....... [12:00, 12:10] .......
反对
ANTI连接是INNER连接的反面:它们包含所有未连接的行。并非所有的 SQL 系统都支持清晰的ANTI连接语法,但为了清晰起见,我将在这里使用最直接的语法:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left ANTI JOIN Right FROM Left ANTI JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- -------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | null | R2 | 12:01 | |
| L2 | R2 | | L1 | null | 12:02 | |
| L3 | R3 | | L3 | null | 12:03 | |
| null | R4 | | L3 | null | 12:04 | undo |
--------------- | L3 | R3 | 12:04 | |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
| L2 | R2 | 12:06 | |
....... [12:00, 12:10] .......
关于ANTI连接的流呈现略有趣的地方在于,它最终包含了一堆最终加入的行的错误开始和撤回;事实上,ANTI连接的撤回与INNER连接的轻松相比是相当重的。更简洁的版本将如下所示:
*12:00> SELECT STREAM Left.Id as L,*
*12:10> SELECT TABLE Right.Id as R,*
*Left.Id as L, Sys.EmitTime as Time,*
*Right.Id as R Sys.Undo as Undo*
*FROM Left ANTI JOIN Right FROM Left ANTI JOIN Right*
*ON L.Num = R.Num; ON L.Num = R.Num;*
--------------- ------------------------------
| L | R | | L | R | Time | Undo |
--------------- ------------------------------
| L1 | null | | null | R2 | 12:01 | |
| null | R4 | | L1 | null | 12:02 | |
--------------- | L3 | null | 12:03 | |
| L3 | null | 12:04 | undo |
| null | R4 | 12:05 | |
| null | R2 | 12:06 | undo |
....... [12:00, 12:10] .......
“SEMI”
现在我们来谈谈SEMI连接,而SEMI连接有点奇怪。乍一看,它们基本上看起来像是内连接,其中一个连接值被丢弃。确实,在 N:M 基数关系为Left,dropped=Right)。例如,在我们迄今使用的Left和Right数据集上(连接数据的基数分别为 0:1、1:0 和 1:1),INNER和SEMI连接变体看起来是相同的。
*12:10> SELECT TABLE 12:10> SELECT TABLE*
*Left.Id as L Left.Id as L*
*FROM Left INNER JOIN FROM Left SEMI JOIN*
*Right ON L.Num = R.Num; Right ON L.Num = R.Num;*
--------------- ---------------
| L | R | | L | R |
--------------- ---------------
| L1 | null | | L1 | null |
| L2 | R2 | | L2 | R2 |
| L3 | R3 | | L3 | R3 |
| null | R4 | | null | R4 |
--------------- ---------------
然而,在 N:M 基数为 M>1 的情况下,SEMI连接还有一个微妙之处:因为 M 端的值没有被返回,SEMI连接只是基于右侧存在任何匹配行来断定连接条件,而不是重复产生每个匹配行的新结果。
为了清楚地看到这一点,让我们切换到一对稍微复杂的输入关系,突出显示其中包含的行的 N:M 连接基数。在这些关系中,N_M列说明了左侧和右侧行之间的基数关系,而Id列(与之前一样)为每个输入关系中的每一行提供了一个唯一的标识符:
*12:15> SELECT TABLE * FROM LeftNM; 12:15> SELECT TABLE * FROM RightNM;*
--------------------- ---------------------
| N_M | Id | Time | | N_M | Id | Time |
--------------------- ---------------------
| 1:0 | L2 | 12:07 | | 0:1 | R1 | 12:02 |
| 1:1 | L3 | 12:01 | | 1:1 | R3 | 12:14 |
| 1:2 | L4 | 12:05 | | 1:2 | R4A | 12:03 |
| 2:1 | L5A | 12:09 | | 1:2 | R4B | 12:04 |
| 2:1 | L5B | 12:08 | | 2:1 | R5 | 12:06 |
| 2:2 | L6A | 12:12 | | 2:2 | R6A | 12:11 |
| 2:2 | L6B | 12:10 | | 2:2 | R6B | 12:13 |
--------------------- ---------------------
有了这些输入,FULL OUTER连接扩展如下:
*12:00> SELECT STREAM*
*COALESCE(LeftNM.N_M,*
*12:15> SELECT TABLE RightNM.N_M) as N_M,*
*COALESCE(LeftNM.N_M, LeftNM.Id as L,*
*RightNM.N_M) as N_M, RightNM.Id as R,*
*LeftNM.Id as L, Sys.EmitTime as Time,*
*RightNM.Id as R, Sys.Undo as Undo*
*FROM LeftNM FROM LeftNM*
*FULL OUTER JOIN RightNM FULL OUTER JOIN RightNM*
*ON LeftNM.N_M = RightNM.N_M; ON LeftNM.N_M = RightNM.N_M;*
--------------------- ------------------------------------
| N_M | L | R | | N_M | L | R | Time | Undo |
--------------------- ------------------------------------
| 0:1 | null | R1 | | 1:1 | L3 | null | 12:01 | |
| 1:0 | L2 | null | | 0:1 | null | R1 | 12:02 | |
| 1:1 | L3 | R3 | | 1:2 | null | R4A | 12:03 | |
| 1:2 | L4 | R4A | | 1:2 | null | R4B | 12:04 | |
| 1:2 | L4 | R4B | | 1:2 | null | R4A | 12:05 | undo |
| 2:1 | L5A | R5 | | 1:2 | null | R4B | 12:05 | undo |
| 2:1 | L5B | R5 | | 1:2 | L4 | R4A | 12:05 | |
| 2:2 | L6A | R6A | | 1:2 | L4 | R4B | 12:05 | |
| 2:2 | L6A | R6B | | 2:1 | null | R5 | 12:06 | |
| 2:2 | L6B | R6A | | 1:0 | L2 | null | 12:07 | |
| 2:2 | L6B | R6B | | 2:1 | null | R5 | 12:08 | undo |
--------------------- | 2:1 | L5B | R5 | 12:08 | |
| 2:1 | L5A | R5 | 12:09 | |
| 2:2 | L6B | null | 12:10 | |
| 2:2 | L6B | null | 12:11 | undo |
| 2:2 | L6B | R6A | 12:11 | |
| 2:2 | L6A | R6A | 12:12 | |
| 2:2 | L6A | R6B | 12:13 | |
| 2:2 | L6B | R6B | 12:13 | |
| 1:1 | L3 | null | 12:14 | undo |
| 1:1 | L3 | R3 | 12:14 | |
.......... [12:00, 12:15] ..........
作为一个附注,当每一侧有多行匹配相同谓词时,这些更复杂的数据集的一个额外好处是连接的乘法性质开始变得更加清晰(例如,“2:2”行,从输入的每一侧的两行扩展到输出的四行;如果数据集有一组“3:3”行,它们将从每个输入的三行扩展到输出的九行,依此类推)。
但回到SEMI连接的微妙之处。通过这些数据集,很清楚地看到过滤的INNER连接和SEMI连接之间的区别:对于任何 N:M 基数为 M>1 的行,INNER连接会产生重复值,而SEMI连接不会(请注意,我已经用红色突出显示了INNER连接版本中的重复行,并在相应的INNER和SEMI版本中省略了完全外连接的部分)。
*12:15> SELECT TABLE 12:15> SELECT TABLE*
*COALESCE(LeftNM.N_M, COALESCE(LeftNM.N_M,*
*RightNM.N_M) as N_M, RightNM.N_M) as N_M,*
*LeftNM.Id as L LeftNM.Id as L*
*FROM LeftNM INNER JOIN RightNM FROM LeftNM SEMI JOIN RightNM*
*ON LeftNM.N_M = RightNM.N_M; ON LeftNM.N_M = RightNM.N_M;*
--------------------- ---------------------
| N_M | L | R | | N_M | L | R |
--------------------- ---------------------
| 0:1 | null | R1 | | 0:1 | null | R1 |
| 1:0 | L2 | null | | 1:0 | L2 | null |
| 1:1 | L3 | R3 | | 1:1 | L3 | R3 |
| 1:2 | L4 | R5A | | 1:2 | L4 | R5A |
| 1:2 | L4 | R5B | | 1:2 | L4 | R5B |
| 2:1 | L5A | R5 | | 2:1 | L5A | R5 |
| 2:1 | L5B | R5 | | 2:1 | L5B | R5 |
| 2:2 | L6A | R6A | | 2:2 | L6A | R6A |
| 2:2 | L6A | R6B | | 2:2 | L6A | R6B |
| 2:2 | L6B | R6A | | 2:2 | L6B | R6A |
| 2:2 | L6B | R6B | | 2:2 | L6B | R6B |
--------------------- ---------------------
或者,更简洁地表达:
*12:15> SELECT TABLE 12:15> SELECT TABLE*
*COALESCE(LeftNM.N_M, COALESCE(LeftNM.N_M,*
*RightNM.N_M) as N_M, RightNM.N_M) as N_M,*
*LeftNM.Id as L LeftNM.Id as L*
*FROM LeftNM INNER JOIN RightNM FROM LeftNM SEMI JOIN RightNM*
*ON LeftNM.N_M = RightNM.N_M; ON LeftNM.N_M = RightNM.N_M;*
------------- -------------
| N_M | L | | N_M | L |
------------- -------------
| 1:1 | L3 | | 1:1 | L3 |
| 1:2 | L4 | | 1:2 | L4 |
| 1:2 | L4 | | 2:1 | L5A |
| 2:1 | L5A | | 2:1 | L5B |
| 2:1 | L5B | | 2:2 | L6A |
| 2:2 | L6A | | 2:2 | L6B |
| 2:2 | L6A | -------------
| 2:2 | L6B |
| 2:2 | L6B |
-------------
然后,STREAM呈现提供了一些上下文,说明了被过滤掉的行——它们只是后到达的重复行(从被投影的列的角度来看)。
*12:00> SELECT STREAM 12:00> SELECT STREAM*
*COALESCE(LeftNM.N_M, COALESCE(LeftNM.N_M,*
*RightNM.N_M) as N_M, RightNM.N_M) as N_M,*
*LeftNM.Id as L LeftNM.Id as L*
*Sys.EmitTime as Time, Sys.EmitTime as Time,*
*Sys.Undo as Undo, Sys.Undo as Undo,*
*FROM LeftNM INNER JOIN RightNM FROM LeftNM SEMI JOIN RightNM*
*ON LeftNM.N_M = RightNM.N_M; ON LeftNM.N_M = RightNM.N_M;*
------------------------------------ ------------------------------------
| N_M | L | R | Time | Undo | | N_M | L | R | Time | Undo |
------------------------------------ ------------------------------------
| 1:1 | L3 | null | 12:01 | | | 1:1 | L3 | null | 12:01 | |
| 0:1 | null | R1 | 12:02 | | | 0:1 | null | R1 | 12:02 | |
| 1:2 | null | R4A | 12:03 | | | 1:2 | null | R4A | 12:03 | |
| 1:2 | null | R4B | 12:04 | | | 1:2 | null | R4B | 12:04 | |
| 1:2 | null | R4A | 12:05 | undo | | 1:2 | null | R4A | 12:05 | undo |
| 1:2 | null | R4B | 12:05 | undo | | 1:2 | null | R4B | 12:05 | undo |
| 1:2 | L4 | R4A | 12:05 | | | 1:2 | L4 | R4A | 12:05 | |
| 1:2 | L4 | R4B | 12:05 | | | 1:2 | L4 | R4B | 12:05 | |
| 2:1 | null | R5 | 12:06 | | | 2:1 | null | R5 | 12:06 | |
| 1:0 | L2 | null | 12:07 | | | 1:0 | L2 | null | 12:07 | |
| 2:1 | null | R5 | 12:08 | undo | | 2:1 | null | R5 | 12:08 | undo |
| 2:1 | L5B | R5 | 12:08 | | | 2:1 | L5B | R5 | 12:08 | |
| 2:1 | L5A | R5 | 12:09 | | | 2:1 | L5A | R5 | 12:09 | |
| 2:2 | L6B | null | 12:10 | | | 2:2 | L6B | null | 12:10 | |
| 2:2 | L6B | null | 12:10 | undo | | 2:2 | L6B | null | 12:10 | undo |
| 2:2 | L6B | R6A | 12:11 | | | 2:2 | L6B | R6A | 12:11 | |
| 2:2 | L6A | R6A | 12:12 | | | 2:2 | L6A | R6A | 12:12 | |
| 2:2 | L6A | R6B | 12:13 | | | 2:2 | L6A | R6B | 12:13 | |
| 2:2 | L6B | R6B | 12:13 | | | 2:2 | L6B | R6B | 12:13 | |
| 1:1 | L3 | null | 12:14 | undo | | 1:1 | L3 | null | 12:14 | undo |
| 1:1 | L3 | R3 | 12:14 | | | 1:1 | L3 | R3 | 12:14 | |
.......... [12:00, 12:15] .......... .......... [12:00, 12:15] ..........
再次简洁地表达:
*12:00> SELECT STREAM 12:00> SELECT STREAM*
*COALESCE(LeftNM.N_M, COALESCE(LeftNM.N_M,*
*RightNM.N_M) as N_M, RightNM.N_M) as N_M,*
*LeftNM.Id as L LeftNM.Id as L*
*Sys.EmitTime as Time, Sys.EmitTime as Time,*
*Sys.Undo as Undo, Sys.Undo as Undo,*
*FROM LeftNM INNER JOIN RightNM FROM LeftNM SEMI JOIN RightNM*
*ON LeftNM.N_M = RightNM.N_M; ON LeftNM.N_M = RightNM.N_M;*
---------------------------- ----------------------------
| N_M | L | Time | Undo | | N_M | L | Time | Undo |
---------------------------- ----------------------------
| 1:2 | L4 | 12:05 | | | 1:2 | L4 | 12:05 | |
| 1:2 | L4 | 12:05 | | | 2:1 | L5B | 12:08 | |
| 2:1 | L5B | 12:08 | | | 2:1 | L5A | 12:09 | |
| 2:1 | L5A | 12:09 | | | 2:2 | L6B | 12:11 | |
| 2:2 | L6B | 12:11 | | | 2:2 | L6A | 12:12 | |
| 2:2 | L6A | 12:12 | | | 1:1 | L3 | 12:14 | |
| 2:2 | L6A | 12:13 | | ...... [12:00, 12:15] ......
| 2:2 | L6B | 12:13 | |
| 1:1 | L3 | 12:14 | |
...... [12:00, 12:15] ......
正如我们在许多示例中所看到的,流连接并没有什么特别之处。它们的功能完全符合我们对流和表的了解,连接流捕获了随着时间推移而发展的连接历史。这与连接表形成对比,后者只是在特定时间点捕获了整个连接的快照,这可能是我们更习惯的方式。
但更重要的是,通过流-表理论的视角来看待连接增加了一些额外的清晰度。核心的基础连接原语是FULL OUTER连接,它是一个流→表分组操作,将关系中所有连接和未连接的行收集在一起。我们详细研究的所有其他变体(LEFT OUTER,RIGHT OUTER,INNER,ANTI和SEMI)只是在FULL OUTER连接后对连接流添加了一个额外的过滤层。³
窗口化连接
在查看了各种未窗口化的连接之后,让我们接下来探讨窗口化对混合的影响。我认为窗口化连接有两个动机:
以某种有意义的方式分区时间
一个明显的情况是固定窗口;例如,每日窗口,对于在同一天发生的事件应该出于某种业务原因进行连接(例如,每日计费总额)。另一个可能是出于性能原因限制连接中的时间范围。然而,事实证明,在连接中还有更复杂(和有用)的时间分区方式,包括一种特别有趣的用例,我目前所知的任何流系统都不支持本地:时间有效连接。稍后再详细介绍。
为连接提供一个有意义的超时参考点
这对于许多无界连接情况非常有用,但对于外连接等用例来说,它可能最明显地有益,因为无法事先知道连接的一侧是否会出现。对于经典的批处理(包括标准交互式 SQL 查询),只有在有界输入数据集被完全处理时,外连接才会超时。但是在处理无界数据时,我们不能等待所有数据被处理。正如我们在第二章和第三章中讨论的那样,水印为事件时间的输入源的完整性提供了一个进度指标。但是,为了利用该指标来超时连接,我们需要一些参考点进行比较。通过对连接进行窗口化,可以通过将连接的范围限定到窗口的末尾来提供该参考点。在水印通过窗口的末尾之后,系统可能会认为窗口的输入已完成。在那时,就像有界连接的情况一样,可以安全地超时任何未连接的行并实现它们的部分结果。
也就是说,正如我们之前看到的,窗口化绝对不是流连接的必要条件。在许多情况下是有意义的,但绝不是必需的。
实际上,大多数窗口化连接的用例(例如,每日窗口)相对简单,可以从我们到目前为止学到的概念中轻松推断出来。为了理解原因,我们简要看一下将固定窗口应用于我们已经遇到的一些连接示例意味着什么。之后,我们将在本章的其余部分中调查更有趣(也更令人费解)的时间有效连接主题,首先详细了解我所说的时间有效窗口,然后继续研究在这种窗口的上下文中连接意味着什么。
固定窗口
连接的窗口化将时间维度纳入连接条件本身。通过这样做,窗口用于将要连接的行的集合范围限定为仅包含在窗口时间间隔内的行。这可能更清楚地通过一个例子来看到,所以让我们将我们的原始Left和Right表窗口化为五分钟的固定窗口:
*12:10> SELECT TABLE *, 12:10> SELECT TABLE *,*
*TUMBLE(Time, INTERVAL '5' MINUTE) TUMBLE(Time, INTERVAL '5' MINUTE)*
*as Window FROM Left; as Window FROM Right*
------------------------------------- -------------------------------------
| Num | Id | Time | Window | | Num | Id | Time | Window |
------------------------------------- -------------------------------------
| 1 | L1 | 12:02 | [12:00, 12:05) | | 2 | R2 | 12:01 | [12:00, 12:05) |
| 2 | L2 | 12:06 | [12:05, 12:10) | | 3 | R3 | 12:04 | [12:00, 12:05) |
| 3 | L3 | 12:03 | [12:00, 12:05) | | 4 | R4 | 12:05 | [12:05, 12:06) |
------------------------------------- -------------------------------------
在我们之前的“左”和“右”示例中,连接条件只是“左.Num = 右.Num”。要将其转换为窗口连接,我们将扩展连接条件以包括窗口相等:左.Num = 右.Num AND 左.Window = 右.Window。知道这一点,我们可以从前面的窗口表中推断出我们的连接将如何改变(为了清晰起见进行了突出显示):因为L2和R2行不在同一个五分钟的固定窗口内,它们在我们连接的窗口变体中将不会被连接在一起。
实际上,如果我们将未窗口化和窗口化的变体作为表进行对比,我们可以清楚地看到这一点(在连接的每一侧都突出显示了相应的L2和R2行):
*12:10> SELECT TABLE*
*Left.Id as L,*
*Right.Id as R,*
*COALESCE(*
*TUMBLE(Left.Time, INTERVAL '5' MINUTE),*
*TUMBLE(Right.Time, INTERVAL '5' MINUTE)*
*12:10> SELECT TABLE ) AS Window*
*Left.Id as L, FROM Left*
*Right.Id as R, FULL OUTER JOIN Right*
*FROM Left ON L.Num = R.Num AND*
*FULL OUTER JOIN Right TUMBLE(Left.Time, INTERVAL '5' MINUTE) =*
*ON L.Num = R.Num; TUMBLE(Right.Time, INTERVAL '5' MINUTE);*
--------------- --------------------------------
| L | R | | L | R | Window |
--------------- --------------------------------
| L1 | null | | L1 | null | [12:00, 12:05) |
| L2 | R2 | | null | R2 | [12:00, 12:05) |
| L3 | R3 | | L3 | R3 | [12:00, 12:05) |
| null | R4 | | L2 | null | [12:05, 12:10) |
--------------- | null | R4 | [12:05, 12:10) |
--------------------------------
当比较未窗口化和窗口化的连接作为流时,差异也是显而易见的。如下例所示,它们主要在最终行上有所不同。未窗口化的一侧完成了Num = 2的连接,产生了一个未连接的R2行的撤回,以及一个完成的“L2,R2”连接的新行。另一方面,窗口化的一侧只产生了一个未连接的L2行,因为L2和R2落入不同的五分钟窗口:
*12:10> SELECT STREAM*
*Left.Id as L,*
*Right.Id as R,*
*Sys.EmitTime as Time,*
*COALESCE(*
*TUMBLE(Left.Time, INTERVAL '5' MINUTE),*
*12:10> SELECT STREAM TUMBLE(Right.Time, INTERVAL '5' MINUTE)*
*Left.Id as L, ) AS Window,*
*Right.Id as R, Sys.Undo as Undo*
*Sys.EmitTime as Time, FROM Left*
*Sys.Undo as Undo FULL OUTER JOIN Right*
*FROM Left ON L.Num = R.Num AND*
*FULL OUTER JOIN Right TUMBLE(Left.Time, INTERVAL '5' MINUTE) =*
*ON L.Num = R.Num; TUMBLE(Right.Time, INTERVAL '5' MINUTE);*
------------------------------ -----------------------------------------------
| L | R | Time | Undo | | L | R | Time | Window | Undo |
------------------------------ -----------------------------------------------
| null | R2 | 12:01 | | | null | R2 | 12:01 | [12:00, 12:05) | |
| L1 | null | 12:02 | | | L1 | null | 12:02 | [12:00, 12:05) | |
| L3 | null | 12:03 | | | L3 | null | 12:03 | [12:00, 12:05) | |
| L3 | null | 12:04 | undo | | L3 | null | 12:04 | [12:00, 12:05) | undo |
| L3 | R3 | 12:04 | | | L3 | R3 | 12:04 | [12:00, 12:05) | |
| null | R4 | 12:05 | | | null | R4 | 12:05 | [12:05, 12:10) | |
| null | R2 | 12:06 | undo | | L2 | null | 12:06 | [12:05, 12:10) | |
| L2 | R2 | 12:06 | | ............... [12:00, 12:10] ................
....... [12:00, 12:10] .......
有了这一点,我们现在了解了窗口对FULL OUTER连接的影响。通过应用我们在本章前半部分学到的规则,很容易推导出LEFT OUTER、RIGHT OUTER、INNER、ANTI和SEMI连接的窗口化变体。我会把这些推导的大部分留给你来完成,但是举一个例子,LEFT OUTER连接,正如我们所学的,只是在左侧的FULL OUTER连接中删除了空列(再次突出显示了L2和R2行以比较差异):
*12:10> SELECT TABLE*
*Left.Id as L,*
*Right.Id as R,*
*COALESCE(*
*TUMBLE(Left.Time, INTERVAL '5' MINUTE),*
*TUMBLE(Right.Time, INTERVAL '5' MINUTE)*
*12:10> SELECT TABLE ) AS Window*
*Left.Id as L, FROM Left*
*Right.Id as R, LEFT OUTER JOIN Right*
*FROM Left ON L.Num = R.Num AND*
*LEFT OUTER JOIN Right TUMBLE(Left.Time, INTERVAL '5' MINUTE) =*
*ON L.Num = R.Num; TUMBLE(Right.Time, INTERVAL '5' MINUTE);*
--------------- --------------------------------
| L | R | | L | R | Window |
--------------- --------------------------------
| L1 | null | | L1 | null | 12:00, 12:05) |
| L2 | R2 | | L2 | null | [12:05, 12:10) |
| L3 | R3 | | L3 | R3 | [12:00, 12:05) |
--------------- --------------------------------
通过将连接的时间范围划分为固定的五分钟间隔,我们将我们的数据集划分为两个不同的时间窗口:12:00, 12:05)和[12:05, 12:10)。然后在这些区域内应用了我们之前观察到的完全相同的连接逻辑,对于L2和R2行分别落入不同区域的情况,得到了稍微不同的结果。基本上,窗口连接就是这样。
时间有效性
在看完窗口连接的基础知识后,我们现在将在本章的其余部分中看一种更高级的方法:时间有效性窗口。
时间有效性窗口
时间有效性窗口适用于在关系中的行有效地将时间划分为区域,其中给定值有效。更具体地说,想象一个用于执行货币转换的金融系统。这样的系统可能包含一个随时间变化的关系,记录了各种货币的当前转换率。例如,可能存在一个将不同货币转换为日元的关系。
*12:10> SELECT TABLE * FROM YenRates;*
--------------------------------------
| Curr | Rate | EventTime | ProcTime |
--------------------------------------
| USD | 102 | 12:00:00 | 12:04:13 |
| Euro | 114 | 12:00:30 | 12:06:23 |
| Yen | 1 | 12:01:00 | 12:05:18 |
| Euro | 116 | 12:03:00 | 12:09:07 |
| Euro | 119 | 12:06:00 | 12:07:33 |
--------------------------------------
为了强调我所说的时间有效性窗口“有效地将时间划分为区域,其中给定值有效”,只考虑该关系中的欧元兑换成日元的汇率:
*12:10> SELECT TABLE * FROM YenRates WHERE Curr = "Euro";*
--------------------------------------
| Curr | Rate | EventTime | ProcTime |
--------------------------------------
| Euro | 114 | 12:00:30 | 12:06:23 |
| Euro | 116 | 12:03:00 | 12:09:07 |
| Euro | 119 | 12:06:00 | 12:07:33 |
--------------------------------------
从数据库工程的角度来看,我们知道这些值并不意味着将欧元兑换成日元的汇率在 12:00 精确为 114 ¥/€,12:03 为 116 ¥/€,12:06 为 119 ¥/€,其他时间为未定义。相反,我们知道这个表的意图是捕捉欧元兑换成日元的汇率在 12:00 之前是未定义的,在 12:00 到 12:03 为 114 ¥/€,12:03 到 12:06 为 116 ¥/€,之后为 119 ¥/€。或者在时间轴上画出来:
Undefined 114 ¥/€ 116 ¥/€ 119 ¥/€
|----[-inf, 12:00)----|----[12:00, 12:03)----|----[12:03, 12:06)----|----[12:06, now)----→
现在,如果我们提前知道所有的费率,我们可以在行数据中明确地捕获这些区域。但是,如果我们需要基于给定费率变为有效的开始时间来逐步构建这些区域,我们会遇到问题:给定行的区域会随着之后的行的变化而随时间变化。即使数据按顺序到达也会出现这个问题(因为每次新费率到达时,先前的费率从永久有效变为有效直到新费率到达时间),但如果它们可以无序到达,则问题会进一步复杂化。例如,使用前面YenRates表中的处理时间排序,我们的表在时间上有效地表示的时间线序列将如下所示:
Range of processing time | Event-time validity timeline during that range of processing-time
=========================|==============================================================================
|
| Undefined
[-inf, 12:06:23) | |--[-inf, +inf)---------------------------------------------------------→
|
| Undefined 114 ¥/€
[12:06:23, 12:07:33) | |--[-inf, 12:00)--|--[12:00, +inf)--------------------------------------→
|
| Undefined 114 ¥/€ 119 ¥/€
[12:07:33, 12:09:07) | |--[-inf, 12:00)--|--[12:00, 12:06)---------------------|--[12:06, +inf)→
|
| Undefined 114 ¥/€ 116 ¥/€ 119 ¥/€
[12:09:07, now) | |--[-inf, 12:00)--|--[12:00, 12:03)--|--[12:03, 12:06)--|--[12:06, +inf)→
或者,如果我们想将其呈现为一个时间变化的关系(每个快照关系之间的变化用黄色突出显示):
*12:10> SELECT TVR * FROM YenRatesWithRegion ORDER BY EventTime;*
---------------------------------------------------------------------------------------------
| [-inf, 12:06:23) | [12:06:23, 12:07:33) |
| ------------------------------------------- | ------------------------------------------- |
| | Curr | Rate | Region | ProcTime | | | Curr | Rate | Region | ProcTime | |
| ------------------------------------------- | ------------------------------------------- |
| ------------------------------------------- | | Euro | 114 | [12:00, +inf) | 12:06:23 | |
| | ------------------------------------------- |
---------------------------------------------------------------------------------------------
| [12:07:33, 12:09:07) | [12:09:07, +inf) |
| ------------------------------------------- | ------------------------------------------- |
| | Curr | Rate | Region | ProcTime | | | Curr | Rate | Region | ProcTime | |
| ------------------------------------------- | ------------------------------------------- |
| | Euro | 114 | [12:00, 12:06) | 12:06:23 | | | Euro | 114 | [12:00, 12:03) | 12:06:23 | |
| | Euro | 119 | [12:06, +inf) | 12:07:33 | | | Euro | 116 | [12:03, 12:06) | 12:09:07 | |
| ------------------------------------------- | | Euro | 119 | [12:06, +inf) | 12:07:33 | |
| | ------------------------------------------- |
---------------------------------------------------------------------------------------------
这里需要注意的重要一点是,一半的变化涉及对多行的更新。这可能听起来不那么糟糕,直到你想起每个快照之间的差异是恰好到达一个新行。换句话说,单个新输入行的到达导致对多个输出行进行事务修改。这听起来不那么好。另一方面,这也听起来很像建立会话窗口所涉及的多行事务。事实上,这又是窗口提供超出简单时间分区的好处的另一个例子:它还提供了以涉及复杂的多行事务的方式进行操作的能力。
要看到这一点,让我们看一个动画。如果这是一个 Beam 管道,它可能看起来像以下内容:
PCollection<Currency, Decimal> yenRates = ...;
PCollection<Decimal> validYenRates = yenRates
.apply(Window.into(new ValidityWindows())
.apply(GroupByKey.<Currency, Decimal>create());
在流/表动画中呈现,该管道看起来像图 9-1 所示的样子。
<assets/stsy_0901.mp4>
![随时间变化的时间有效窗口
图 9-1。随时间变化的时间有效窗口
这个动画突出了时间有效的一个关键方面:缩小窗口。有效窗口必须能够随时间缩小,从而减少其有效范围,并将其中包含的任何数据分割成两个新窗口。请参阅GitHub 上的代码片段以获取一个示例部分实现。⁵
用 SQL 术语来说,创建这些有效窗口会看起来像以下内容(利用一个假设的VALIDITY_WINDOW结构),视为一个表:
*12:10> SELECT TABLE*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime)*
*HAVING Curr = "Euro";*
--------------------------------
| Curr | Rate | Window |
--------------------------------
| Euro | 114 | [12:00, 12:03) |
| Euro | 116 | [12:03, 12:06) |
| Euro | 119 | [12:06, +inf) |
--------------------------------
或者,更有趣的是,将其视为一个流:
*12:00> SELECT STREAM*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window,*
*Sys.EmitTime as Time,*
*Sys.Undo as Undo,*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime)*
*HAVING Curr = "Euro";*
--------------------------------------------------
| Curr | Rate | Window | Time | Undo |
--------------------------------------------------
| Euro | 114 | [12:00, +inf) | 12:06:23 | |
| Euro | 114 | [12:00, +inf) | 12:07:33 | undo |
| Euro | 114 | [12:00, 12:06) | 12:07:33 | |
| Euro | 119 | [12:06, +inf) | 12:07:33 | |
| Euro | 114 | [12:00, 12:06) | 12:09:07 | undo |
| Euro | 114 | [12:00, 12:03) | 12:09:07 | |
| Euro | 116 | [12:03, 12:06) | 12:09:07 | |
................. [12:00, 12:10] .................
很好,我们知道如何使用点时间值有效地将时间划分为值有效的范围。但是,当这些时间有效窗口应用于与其他数据连接时,它们的真正力量就显现出来。这就是时间有效连接的作用所在。
时间有效连接
为了探索时间有效连接的语义,假设我们的金融应用包含另一个时间变化的关系,跟踪各种货币兑换订单到日元的情况:
*12:10> SELECT TABLE * FROM YenOrders;*
----------------------------------------
| Curr | Amount | EventTime | ProcTime |
----------------------------------------
| Euro | 2 | 12:02:00 | 12:05:07 |
| USD | 1 | 12:03:00 | 12:03:44 |
| Euro | 5 | 12:05:00 | 12:08:00 |
| Yen | 50 | 12:07:00 | 12:10:11 |
| Euro | 3 | 12:08:00 | 12:09:33 |
| USD | 5 | 12:10:00 | 12:10:59 |
----------------------------------------
为了简单起见,和之前一样,让我们专注于欧元转换:
*12:10> SELECT TABLE * FROM YenOrders WHERE Curr = "Euro";*
----------------------------------------
| Curr | Amount | EventTime | ProcTime |
----------------------------------------
| Euro | 2 | 12:02:00 | 12:05:07 |
| Euro | 5 | 12:05:00 | 12:08:00 |
| Euro | 3 | 12:08:00 | 12:09:33 |
----------------------------------------
我们希望将这些订单与YenRates关系健壮地连接起来,将YenRates中的行视为定义有效窗口。因此,我们实际上希望连接到上一节末尾构建的YenRates关系的有效窗口版本:
*12:10> SELECT TABLE*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime)*
*HAVING Curr = "Euro";*
--------------------------------
| Curr | Rate | Window |
--------------------------------
| Euro | 114 | [12:00, 12:03) |
| Euro | 116 | [12:03, 12:06) |
| Euro | 119 | [12:06, +inf) |
--------------------------------
幸运的是,在我们将转换率放入有效窗口之后,这些费率与YenOrders关系之间的窗口连接正好给了我们想要的结果:
*12:10> WITH ValidRates AS*
*(SELECT*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime))*
*SELECT TABLE*
*YenOrders.Amount as "E",*
*ValidRates.Rate as "Y/E",*
*YenOrders.Amount * ValidRates.Rate as "Y",*
*YenOrders.EventTime as Order,*
*ValidRates.Window as "Rate Window"*
*FROM YenOrders FULL OUTER JOIN ValidRates*
*ON YenOrders.Curr = ValidRates.Curr*
*AND WINDOW_START(ValidRates.Window) <= YenOrders.EventTime*
*AND YenOrders.EventTime < WINDOW_END(ValidRates.Window)*
*HAVING Curr = "Euro";*
-------------------------------------------
| E | Y/E | Y | Order | Rate Window |
-------------------------------------------
| 2 | 114 | 228 | 12:02 | [12:00, 12:03) |
| 5 | 116 | 580 | 12:05 | [12:03, 12:06) |
| 3 | 119 | 357 | 12:08 | [12:06, +inf) |
-------------------------------------------
回想一下我们最初的YenRates和YenOrders关系,这个连接的关系确实看起来是正确的:每个转换最终都以给定事件时间窗口的(最终)适当汇率结束,其对应的订单也在其中。因此,我们可以相当肯定,这个连接在提供我们想要的最终正确性方面做得很好。
也就是说,这个关系的简单快照视图,是在所有值都到达并且尘埃落定之后拍摄的,掩盖了这个连接的复杂性。要真正理解这里发生了什么,我们需要查看完整的 TVR。首先,回想一下,有效窗口转换率关系实际上比之前的简单表格快照视图所让你相信的要复杂得多。供参考,这里是有效窗口关系的STREAM版本,它更好地突出了这些转换率随时间的演变:
*12:00> SELECT STREAM*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY(EventTime) as Window,*
*Sys.EmitTime as Time,*
*Sys.Undo as Undo,*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY(EventTime)*
*HAVING Curr = "Euro";*
--------------------------------------------------
| Curr | Rate | Window | Time | Undo |
--------------------------------------------------
| Euro | 114 | [12:00, +inf) | 12:06:23 | |
| Euro | 114 | [12:00, +inf) | 12:07:33 | undo |
| Euro | 114 | [12:00, 12:06) | 12:07:33 | |
| Euro | 119 | [12:06, +inf) | 12:07:33 | |
| Euro | 114 | [12:00, 12:06) | 12:09:07 | undo |
| Euro | 114 | [12:00, 12:03) | 12:09:07 | |
| Euro | 116 | [12:03, 12:06) | 12:09:07 | |
................. [12:00, 12:10] .................
因此,如果我们查看我们的有效窗口连接的完整 TVR,您会发现随着时间的推移,这种连接的对应演变要复杂得多,这是由于连接的两侧值的无序到达:
*12:10> WITH ValidRates AS*
*(SELECT*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime))*
*SELECT TVR*
*YenOrders.Amount as "E",*
*ValidRates.Rate as "Y/E",*
*YenOrders.Amount * ValidRates.Rate as "Y",*
*YenOrders.EventTime as Order,*
*ValidRates.Window as "Rate Window"*
*FROM YenOrders FULL OUTER JOIN ValidRates*
*ON YenOrders.Curr = ValidRates.Curr*
*AND WINDOW_START(ValidRates.Window) <= YenOrders.EventTime*
*AND YenOrders.EventTime < WINDOW_END(ValidRates.Window)*
*HAVING Curr = "Euro";*
-------------------------------------------------------------------------------------------
| [-inf, 12:05:07) | [12:05:07, 12:06:23) |
| ------------------------------------------ | ------------------------------------------ |
| | E | Y/E | Y | Order | Rate Window | | | E | Y/E | Y | Order | Rate Window | |
| ------------------------------------------ | ------------------------------------------ |
| ------------------------------------------ | | 2 | | | 12:02 | | |
| | ------------------------------------------ |
-------------------------------------------------------------------------------------------
| [12:06:23, 12:07:33) | [12:07:33, 12:08:00) |
| ------------------------------------------ | ------------------------------------------ |
| | E | Y/E | Y | Order | Rate Window | | | E | Y/E | Y | Order | Rate Window | |
| ------------------------------------------ | ------------------------------------------ |
| | 2 | 114 | 228 | 12:02 | [12:00, +inf) | | | 2 | 114 | 228 | 12:02 | [12:00, 12:06) | |
| ------------------------------------------ | | | 119 | | | [12:06, +inf) | |
| | ------------------------------------------ |
-------------------------------------------------------------------------------------------
| [12:08:00, 12:09:07) | [12:09:07, 12:09:33) |
| ------------------------------------------ | ------------------------------------------ |
| | E | Y/E | Y | Order | Rate Window | | | E | Y/E | Y | Order | Rate Window | |
| ------------------------------------------ | ------------------------------------------ |
| | 2 | 114 | 228 | 12:02 | [12:00, 12:06) | | | 2 | 114 | 228 | 12:02 | [12:00, 12:03) | |
| | 5 | 114 | 570 | 12:05 | [12:03, 12:06) | | | 5 | 116 | 580 | 12:05 | [12:03, 12:06) | |
| | | 119 | | | [12:06, +inf) | | | | 119 | | 12:08 | [12:06, +inf) | |
| ------------------------------------------ | ------------------------------------------ |
-------------------------------------------------------------------------------------------
| [12:09:33, now) |
| ------------------------------------------ |
| | E | Y/E | Y | Order | Rate Window | |
| ------------------------------------------ |
| | 2 | 114 | 228 | 12:02 | [12:00, 12:03) | |
| | 5 | 116 | 580 | 12:05 | [12:03, 12:06) | |
| | 3 | 119 | 357 | 12:08 | [12:06, +inf) | |
| ------------------------------------------ |
----------------------------------------------
特别是,5€订单的结果最初报价为 570¥,因为该订单(发生在 12:05)最初落入 114¥/€汇率的有效窗口。但是,当 12:03 发生的 116¥/€汇率无序到达时,5€订单的结果必须从 570¥更新为 580¥。如果您观察连接的结果作为流(这里我用红色突出显示了不正确的 570¥,用蓝色显示了 570¥的撤回和随后更正为 580¥的值),这也是显而易见的:
*12:00> WITH ValidRates AS*
*(SELECT*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime))*
*SELECT STREAM*
*YenOrders.Amount as "E",*
*ValidRates.Rate as "Y/E",*
*YenOrders.Amount * ValidRates.Rate as "Y",*
*YenOrders.EventTime as Order,*
*ValidRates.Window as "Rate Window",*
*Sys.EmitTime as Time,*
*Sys.Undo as Undo*
*FROM YenOrders FULL OUTER JOIN ValidRates*
*ON YenOrders.Curr = ValidRates.Curr*
*AND WINDOW_START(ValidRates.Window) <= YenOrders.EventTime*
*AND YenOrders.EventTime < WINDOW_END(ValidRates.Window)*
*HAVING Curr = “Euro”;*
------------------------------------------------------------
| E | Y/E | Y | Order | Rate Window | Time | Undo |
------------------------------------------------------------
| 2 | | | 12:02 | | 12:05:07 | |
| 2 | | | 12:02 | | 12:06:23 | undo |
| 2 | 114 | 228 | 12:02 | [12:00, +inf) | 12:06:23 | |
| 2 | 114 | 228 | 12:02 | [12:00, +inf) | 12:07:33 | undo |
| 2 | 114 | 228 | 12:02 | [12:00, 12:06) | 12:07:33 | |
| | 119 | | | [12:06, +inf) | 12:07:33 | |
| 5 | 114 | 570 | 12:05 | [12:00, 12:06) | 12:08:00 | |
| 2 | 114 | 228 | 12:02 | [12:00, 12:06) | 12:09:07 | undo |
| 5 | 114 | 570 | 12:05 | [12:00, 12:06) | 12:09:07 | undo |
| 2 | 114 | 228 | 12:02 | [12:00, 12:03) | 12:09:07 | |
| 5 | 116 | 580 | 12:05 | [12:03, 12:06) | 12:09:07 | |
| | 119 | | | [12:06, +inf) | 12:09:33 | undo |
| 3 | 119 | 357 | 12:08 | [12:06, +inf) | 12:09:33 | |
...................... [12:00, 12:10] ......................
值得一提的是,由于使用了FULL OUTER连接,这是一个相当混乱的流。实际上,当将转换订单作为流进行消耗时,您可能不关心未连接的行;切换到INNER连接有助于消除这些行。您可能也不关心汇率窗口发生变化,但实际转换值并未受到影响。通过从流中删除汇率窗口,我们可以进一步减少其通信量:
*12:00> WITH ValidRates AS*
*(SELECT*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime))*
*SELECT STREAM*
*YenOrders.Amount as "E",*
*ValidRates.Rate as "Y/E",*
*YenOrders.Amount * ValidRates.Rate as "Y",*
*YenOrders.EventTime as Order,*
*~~ValidRates.Window as "Rate Window",~~*
*Sys.EmitTime as Time,*
*Sys.Undo as Undo*
*FROM YenOrders INNER JOIN ValidRates*
*ON YenOrders.Curr = ValidRates.Curr*
*AND WINDOW_START(ValidRates.Window) <= YenOrders.EventTime*
*AND YenOrders.EventTime < WINDOW_END(ValidRates.Window)*
*HAVING Curr = "Euro";*
-------------------------------------------
| E | Y/E | Y | Order | Time | Undo |
-------------------------------------------
| 2 | 114 | 228 | 12:02 | 12:06:23 | |
| 5 | 114 | 570 | 12:05 | 12:08:00 | |
| 5 | 114 | 570 | 12:05 | 12:09:07 | undo |
| 5 | 116 | 580 | 12:05 | 12:09:07 | |
| 3 | 119 | 357 | 12:08 | 12:09:33 | |
............. [12:00, 12:10] ..............
好多了。现在我们可以清楚地看到,这个查询非常简洁地完成了我们最初的目标:以一种健壮的方式连接两个货币转换率和订单的 TVR,而且对数据无序到达具有容忍性。图 9-2 将此查询可视化为动画图表。在其中,您还可以非常清楚地看到随着时间的推移整体结构的变化方式。
<assets/stsy_0902.mp4>

图 9-2。使用每条记录触发的时间有效连接,将欧元转换为日元
水印和时间有效连接
通过这个例子,我们突出了本节开头提到的窗口连接的第一个好处:窗口连接允许您根据一些实际业务需求在时间内对连接进行分区。在这种情况下,业务需求是将时间划分为我们货币转换率的有效区域。
然而,在我们结束之前,事实证明这个例子也提供了一个突出第二点的机会:窗口连接可以为水印提供有意义的参考点。要看到这有多有用,想象一下将前面的查询更改为使用显式水印触发器替换隐式默认的每条记录触发器,当水印通过连接中有效窗口的末端时触发一次(假设我们有一个水印可用于我们的输入 TVR,准确跟踪这些关系在事件时间上的完整性,以及一个执行引擎知道如何考虑这些水印)。现在,我们的流不再包含多个输出和对到达顺序不正确的汇率的撤销,而是最终得到一个流,其中每个订单包含一个正确的转换结果,这显然比以前更理想:
*12:00> WITH ValidRates AS*
*(SELECT*
*Curr,*
*MAX(Rate) as Rate,*
*VALIDITY_WINDOW(EventTime) as Window*
*FROM YenRates*
*GROUP BY*
*Curr,*
*VALIDITY_WINDOW(EventTime))*
*SELECT STREAM*
*YenOrders.Amount as "E",*
*ValidRates.Rate as "Y/E",*
*YenOrders.Amount * ValidRates.Rate as "Y",*
*YenOrders.EventTime as Order,*
*Sys.EmitTime as Time,*
*Sys.Undo as Undo*
*FROM YenOrders INNER JOIN ValidRates*
*ON YenOrders.Curr = ValidRates.Curr*
*AND WINDOW_START(ValidRates.Window) <= YenOrders.EventTime*
*AND YenOrders.EventTime < WINDOW_END(ValidRates.Window)*
*HAVING Curr = "Euro"*
*EMIT WHEN WATERMARK PAST WINDOW_END(ValidRates.Window);*
-------------------------------------------
| E | Y/E | Y | Order | Time | Undo |
-------------------------------------------
| 2 | 114 | 228 | 12:02 | 12:08:52 | |
| 5 | 116 | 580 | 12:05 | 12:10:04 | |
| 3 | 119 | 357 | 12:08 | 12:10:13 | |
............. [12:00, 12:11] ..............
或者,以动画形式呈现,清楚地显示了连接的结果直到水印移动到它们之外才被发射到输出流中,如图 9-3 所示。
<assets/stsy_0903.mp4>

图 9-3。时间有效性连接,将欧元转换为日元,使用水印触发
无论如何,令人印象深刻的是,这个查询将如此复杂的交互集合封装成了所需结果的清晰简洁的呈现。
总结
在本章中,我们分析了在流处理的上下文中使用 SQL 连接词汇的连接世界。我们从未窗口连接开始,看到了概念上所有连接都是流连接的核心。我们看到了基本上所有其他连接变体的基础是FULL OUTER连接,并讨论了作为LEFT OUTER、RIGHT OUTER、INNER、ANTI、SEMI甚至CROSS连接的特定变化。此外,我们看到了所有这些不同的连接模式在 TVR 和流的世界中是如何相互作用的。
接下来,我们转向了窗口连接,并了解到窗口连接通常受到以下一个或两个方面的动机:
-
在时间内对连接进行分区的能力对某些业务需求来说是令人印象深刻的
-
将连接的结果与水印的进展联系起来的能力
最后,我们深入探讨了与连接相关的一种更有趣和有用的窗口类型:时间有效性窗口。我们看到时间有效性窗口如何自然地将时间划分为给定值的有效区域,仅基于这些值发生变化的特定时间点。我们了解到,在有效性窗口内进行连接需要支持可以随时间分割的窗口的窗口框架,这是当今没有任何现有的流系统本能支持的。我们看到时间有效性窗口如何简洁地让我们以稳健、自然的方式解决了将货币转换率和订单的 TVR 进行连接的问题。
连接通常是数据处理中最令人生畏的方面之一,无论是流式处理还是其他方式。然而,通过理解连接的理论基础以及我们如何可以从基本的基础中简单地推导出所有不同类型的连接,连接变得不再是一头可怕的野兽,即使在流式处理增加了时间维度的情况下。
¹至少从概念上来看。实现这些类型的连接有许多不同的方法,其中一些可能比执行实际的FULL OUTER连接然后过滤其结果更有效,特别是当考虑到查询的其余部分和数据的分布时。
²再次忽略当存在重复连接键时会发生什么;关于这一点我们稍后会讨论SEMI连接。
³ 从概念上讲,至少是这样。当然,实现这些类型的连接的方式有很多种,其中一些可能比执行实际的FULL OUTER连接然后过滤其结果更有效,这取决于查询的其余部分和数据的分布。
⁴ 请注意,示例数据和激发它的时间连接用例几乎完全来自 Julian Hyde 的优秀文档“Streams, joins, and temporal tables”。
⁵ 这是一个部分实现,因为它只在窗口存在于孤立状态时才有效,就像图 9-1 中所示。一旦将窗口与其他数据混合在一起,例如下面的连接示例,您将需要一些机制来将来自缩小窗口的数据分割成两个单独的窗口,而 Beam 目前并没有提供这样的机制。
第十章:大规模数据处理的演变
你现在已经到达了这本书的最后一章,你这位坚忍的有文化的人。你的旅程很快就要结束了!
最后,我想邀请你和我一起进行一个简短的历史漫步,从 MapReduce 的大规模数据处理的古老时代开始,触及在随后的十五年中带来流式系统到今天这一点的一些亮点。这是一个相对轻松的章节,我在其中对一些著名系统(也许还有一些不太知名的系统)的重要贡献做了一些观察,并引用了一些你可以自己阅读的来源材料,如果你想了解更多,同时尽量不冒犯或激怒那些负责的系统的人,他们的真正有影响力的贡献我要么过于简化,要么完全忽略了,这是为了节省空间,专注和连贯的叙述。应该会很有趣。
在阅读本章时,请记住,我们实际上只是在谈论大规模数据处理中 MapReduce/Hadoop 家族的特定部分。我没有以任何方式涵盖 SQL 领域;我们不讨论 HPC/超级计算机等。因此,尽管本章的标题听起来可能很广泛和广泛,但我实际上是在专注于大规模数据处理的宏大宇宙中的一个特定垂直领域。文学警告,等等。
还要注意的是,我在这里涵盖了不成比例的谷歌技术。你会认为这可能与我在谷歌工作了十多年有关。但还有两个原因:1)大数据一直对谷歌很重要,因此在那里创造了一些值得详细讨论的有价值的贡献,2)我的经验是,谷歌以外的人似乎很喜欢了解我们所做的事情,因为我们公司在这方面历来有点守口如瓶。所以请容我多说一些我们在闭门造车方面的工作。
为了让我们的旅程有具体的时间线,我们将遵循图 10-1 中显示的时间线,该时间线显示了我讨论的各种系统的大致存在日期。

图 10-1。本章讨论的系统的大致时间线
在每个停留点,我都会简要介绍我对系统的历史的理解,并从塑造我们今天所知的流式系统的角度来框定它的贡献。最后,我们将总结所有的贡献,看看它们如何总结出今天的现代流处理生态系统。
MapReduce
我们从 MapReduce(图 10-2)开始旅程。

图 10-2。时间线:MapReduce
我认为可以说,今天我们所知道的大规模数据处理始于 2003 年的 MapReduce。在当时,谷歌内部的工程师们正在构建各种定制系统,以解决全球网络规模的数据处理挑战。当他们这样做时,他们注意到了三件事:
数据处理很难
正如我们中的数据科学家和工程师所知,你可以通过专注于从原始数据中提取有用的见解来建立职业。
可扩展性很难
在大规模数据上提取有用的见解更加困难。
容错性很难
在商品硬件上以容错的正确方式从大规模数据中提取有用的见解是残酷的。
在同时解决了这三个挑战之后,他们开始注意到他们构建的定制系统之间的一些相似之处。他们得出结论,如果他们能够构建一个框架来解决后两个问题(可扩展性和容错性),那么专注于第一个问题将会变得简单得多。于是 MapReduce 诞生了。⁴
MapReduce 的基本思想是提供围绕函数式编程领域中两个广为人知的操作 map 和 reduce(图 10-3)的简单数据处理 API。使用该 API 构建的流水线将在一个分布式系统框架上执行,该框架负责处理所有让核心分布式系统工程师激动不已并压垮我们这些凡人灵魂的可扩展性和容错性问题。

图 10-3。MapReduce 作业的可视化
我们在第六章中已经非常详细地讨论了 MapReduce 的语义,所以我们不会在这里详细讨论。只需记住,我们将事情分解为六个离散阶段(MapRead、Map、MapWrite、ReduceRead、Reduce、ReduceWrite),作为我们的流和表分析的一部分,最终我们得出结论,总的来说,Map 和 Reduce 阶段并没有太大的不同;在高层次上,它们都做以下工作:
-
将表转换为流
-
对该流应用用户转换以产生另一个流
-
将该流分组成表
在谷歌内部投入使用后,MapReduce 在各种任务中得到了广泛的应用,团队决定值得与世界其他地方分享他们的想法。结果就是MapReduce 论文,发表于 OSDI 2004(见图 10-4)。

图 10-4。MapReduce 论文,发表于 OSDI 2004
在其中,团队详细描述了项目的历史,API 的设计和实施,以及 MapReduce 应用的许多不同用例的细节。不幸的是,他们没有提供实际的源代码,因此当时谷歌以外的人们能做的就是说,“是的,这听起来确实很不错”,然后回去构建他们定制的系统。
在随后的十年中,MapReduce 在谷歌内部继续进行大量的开发,投入大量时间使系统扩展到前所未有的规模。对于这段旅程中的一些亮点的更详细描述,我推荐我们的官方 MapReduce 历史学家/可扩展性和性能专家 Marián Dvorský撰写的文章“Google 大规模排序实验的历史”(图 10-5)。

图 10-5。Marián Dvorský的“大规模排序实验历史”博客文章
但就我们在这里的目的而言,可以说迄今为止没有其他东西能够触及 MapReduce 所实现的规模,甚至在谷歌内部也是如此。考虑到 MapReduce 已经存在了这么长时间,这是一个值得注意的事实;在我们的行业中,14 年就是一个漫长的时代。
从流系统的角度来看,我想要给 MapReduce 留下的主要印象是简单和可扩展性。MapReduce 迈出了驯服大规模数据处理的第一步,提供了一个简单而直接的 API,用于构建强大的数据处理流水线,其简约性掩盖了在幕后发生的复杂分布式系统魔术,使这些流水线能够在大规模的廉价硬件集群上运行。
Hadoop
接下来是 Hadoop(图 10-6)。警告:这是我为了集中叙述而大大简化系统影响的情况之一。Hadoop 对我们行业和整个世界的影响无法言喻,它远远超出了我在这里讨论的相对特定的范围。

图 10-6. 时间轴:Hadoop
Hadoop 诞生于 2005 年,当 Doug Cutting 和 Mike Cafarella 决定将 MapReduce 论文中的想法用于构建他们的 Nutch 网络爬虫的分布式版本时。他们已经构建了自己的谷歌分布式文件系统的版本(最初称为 NDFS,后来更名为 HDFS,或者 Hadoop 分布式文件系统),因此在该论文发表后,将 MapReduce 层添加到其上是一个自然的下一步。他们称这个层为 Hadoop。
Hadoop 和 MapReduce 的主要区别在于,Cutting 和 Cafarella 确保 Hadoop 的源代码通过开源方式与世界分享(以及 HDFS 的源代码),作为最终成为 Apache Hadoop 项目的一部分。雅虎聘请 Cutting 帮助将雅虎网络爬虫架构转移到 Hadoop,为该项目增添了额外的有效性和工程能量,从那时起,一个完整的开源数据处理工具生态系统就开始成长。与 MapReduce 一样,其他人已经在其他地方更好地讲述了 Hadoop 的历史;其中一个特别好的参考资料是 Marko Bonaci 的“Hadoop 的历史”,最初计划包括在一本印刷书籍中(图 10-7)。

图 10-7. Marko Bonaci 的“Hadoop 的历史”
我希望你从本节中得到的主要观点是,Hadoop 周围蓬勃发展的开源生态系统对整个行业产生了巨大影响。通过创建一个开放的社区,工程师可以改进和扩展早期 GFS 和 MapReduce 论文中的想法,一个繁荣的生态系统诞生了,产生了许多有用的工具,如 Pig、Hive、HBase、Crunch 等等。这种开放性对于培育我们行业现在存在的多样化思想至关重要,这也是为什么我将 Hadoop 的开源生态系统定位为其对我们今天所知的流处理系统世界的最重要贡献。
Flume
现在我们回到谷歌领地,谈论谷歌内部 MapReduce 的官方继任者:Flume(图 10-8 有时也被称为 FlumeJava,指的是系统的原始 Java 版本,并且不要与 Apache Flume 混淆,后者是一个完全不同的系统,只是碰巧有相同的名称)。

图 10-8. 时间轴:Flume
Flume 项目是由 Craig Chambers 在 2007 年谷歌西雅图办公室开设时创建的。它的动机是解决 MapReduce 的一些固有缺陷,这些缺陷在其成功的最初几年中变得明显。其中许多缺陷围绕着 MapReduce 的严格的 Map→Shuffle→Reduce 结构;尽管简单清晰,但它也带来了一些不利因素:
-
因为许多用例无法通过单个 MapReduce 的应用来解决,谷歌开始出现了许多定制的“编排系统”,用于协调一系列 MapReduce 作业。这些系统本质上都是为了相同的目的(将多个 MapReduce 作业粘合在一起,创建一个解决复杂问题的连贯管道)。然而,由于它们是独立开发的,它们自然是不兼容的,这是不必要的重复努力的典型例子。
-
更糟糕的是,有许多情况下,一个清晰的 MapReduce 作业序列会由于 API 的严格结构而引入低效。例如,一个团队可能会编写一个简单地过滤一些元素的 MapReduce 作业;也就是说,一个只有空 reducer 的仅映射作业。接着可能是另一个团队的仅映射作业,对元素进行逐个丰富(又是另一个空 reducer)。然后第二个作业的输出可能最终被最后一个团队的 MapReduce 消费,对数据进行一些分组聚合。这个管道基本上由一个 Map 阶段的单一链条和一个 Reduce 阶段组成,需要通过完全独立的三个作业进行编排,每个作业通过洗牌和输出阶段将数据实现链在一起。但这是假设你想要保持代码库的逻辑和清晰,这导致了最终的缺点...
-
为了消除这些 MapReduce 中的低效,工程师们开始引入手动优化,这将混淆管道的简单逻辑,增加了维护和调试成本。
Flume 通过提供一个可组合的、高级的 API 来解决这些问题,用于描述数据处理管道,基本上是围绕 Beam 中发现的相同的 PCollection 和 PTransform 概念,如图 10-9 所示。

图 10-9。Flume 中的高级管道(图片来源:Frances Perry)
这些管道在启动时将通过优化器⁵生成一个最优的 MapReduce 作业序列的计划,然后由框架进行编排,如图 10-10 所示。

图 10-10。从逻辑管道到物理执行计划的优化
也许 Flume 可以执行的最重要的自动优化示例是融合(Reuven 在第五章中已经讨论过一些),在融合中,两个逻辑上独立的阶段可以在同一个作业中顺序运行(消费者-生产者融合)或并行运行(同级融合),如图 10-11 所示。

图 10-11。融合优化将连续或并行的操作合并到同一个物理操作中
将两个阶段融合在一起可以消除序列化/反序列化和网络成本,这在处理大量数据的管道中可能是重要的。
另一种自动优化类型是组合器提升(见图 10-12),其机制我们在第七章已经提到,当时我们讨论了增量组合。组合器提升就是简单地应用我们在那一章讨论过的多级组合逻辑:在分组操作之后逻辑上发生的组合操作(例如求和)部分提升到分组之前的阶段(这根据定义需要通过网络进行数据洗牌),以便在分组发生之前进行部分组合。在非常热门的键的情况下,这可以大大减少在网络上传输的数据量,并且还可以更平稳地将最终聚合的负载分布在多台机器上。

图 10-12。组合器提升在消费者端的分组聚合之前对发送端进行部分聚合
由于其更清晰的 API 和自动优化,Flume Java 在 2009 年初在 Google 推出后立即受到欢迎。在这一成功之后,团队发表了题为“Flume Java: Easy, Efficient Data-Parallel Pipelines”的论文(见图 10-13),这本身就是一个了解系统最初存在情况的绝佳资源。

图 10-13。FlumeJava 论文
Flume C++在 2011 年不久后推出,2012 年初,Flume 被引入到 Google 为所有新工程师提供的 Noogler⁶培训中。这标志着 MapReduce 的终结。
自那时起,Flume 已经迁移到不再使用 MapReduce 作为其执行引擎;相反,它使用了一个名为 Dax 的自定义执行引擎,直接构建在框架本身中。通过将 Flume 本身从以前的 Map → Shuffle → Reduce 结构的 MapReduce 的限制中解放出来,Dax 实现了新的优化,例如 Eugene Kirpichov 和 Malo Denielou 在他们的“No shard left behind”博客文章中描述的动态工作再平衡功能(图 10-14)。

图 10-14。“No shard left behind”博客文章
尽管在 Cloud Dataflow 的上下文中讨论,动态工作再平衡(或者在 Google 中俗称的液体分片)会自动将额外的工作从滞后的分片重新平衡到系统中其他空闲的工作者身上,因为他们提前完成了工作。通过随时间动态地重新平衡工作分配,可以更接近于最佳工作分配,甚至比最好的初始分配更接近。它还允许适应工作者池中的变化,其中一个慢速机器可能会延迟作业的完成,但通过将其大部分任务移交给其他工作者来进行补偿。当液体分片在 Google 推出时,它在整个系统中回收了大量资源。
关于 Flume 的最后一点是,它后来还扩展到支持流处理语义。除了批处理的 Dax 后端外,Flume 还扩展为能够在 MillWheel 流处理系统上执行管道(稍后将讨论)。我们在本书中讨论的大多数高级流处理语义概念最初都是在 Flume 中首次应用,然后才逐渐进入 Cloud Dataflow,最终进入 Apache Beam。
总之,在本节中从 Flume 中可以得出的主要观点是引入了“高级管道”的概念,这使得清晰编写的逻辑管道可以进行“自动优化”。这使得可以创建更大更复杂的管道,而无需手动编排或优化,并且同时保持这些管道的代码逻辑清晰。
Storm
接下来是 Apache Storm(图 10-15),这是我们涵盖的第一个真正的流处理系统。Storm 肯定不是存在的第一个流处理系统,但我认为它是第一个在整个行业中得到广泛采用的流处理系统,因此我们在这里更仔细地研究它。

图 10-15。时间轴:Storm

图 10-16。“Apache Storm 的历史和经验教训”
Storm 是 Nathan Marz 的创意,后来他在一篇名为“Apache Storm 的历史和经验教训”的博客文章中详细记录了其创作历程(图 10-16)。简而言之,Nathan 所在的初创公司 BackType 一直在尝试使用自定义的队列和工作者系统来处理 Twitter 的数据流。他最终得出的结论与几乎十年前的 MapReduce 团队的结论基本相同:他们的代码中实际的数据处理部分只是系统的一小部分,如果有一个框架在幕后完成所有分布式系统的繁重工作,那么构建实时数据处理管道将会更容易。于是 Storm 诞生了。
与我们迄今为止谈到的其他系统相比,Storm 的有趣之处在于,团队选择放宽了所有其他系统中都有的强一致性保证,以提供更低的延迟。通过将最多一次或至少一次的语义与每条记录的处理和无集成(即无一致)的持久状态概念相结合,Storm 能够以比执行数据批处理并保证一次性正确性的系统更低的延迟提供结果。对于某种类型的用例来说,这是一个非常合理的权衡。
不幸的是,很快就清楚地看到,人们确实希望既能快速得到答案,又能同时获得低延迟的结果和最终的正确性。但是仅凭 Storm 是不可能做到这一点的。于是 Lambda 架构应运而生。
鉴于 Storm 的局限性,精明的工程师开始在强一致的 Hadoop 批处理管道旁边运行一个弱一致的 Storm 流处理管道。前者产生低延迟、不精确的结果,而后者产生高延迟、精确的结果,然后这两者最终会以某种方式合并在一起,以提供单一的低延迟、最终一致的输出视图。我们在第一章中了解到 Lambda 架构是 Marz 的另一个创意,详细内容在他的帖子中介绍“如何打败 CAP 定理”(图 10-17)。⁷

图 10-17。“如何打败 CAP 定理”
我已经花了相当多的时间批评 Lambda 架构的缺点,所以我不会在这里再多加赘述。但我会重申一点:尽管成本高昂,带来了很多麻烦,Lambda 架构仍然变得非常流行,仅仅是因为它满足了许多企业本来很难满足的关键需求:从数据处理管道中获得低延迟但最终正确的结果。
从流处理系统的演变角度来看,我认为 Storm 首次为大众带来了低延迟数据处理。然而,这是以弱一致性为代价的,这反过来导致了 Lambda 架构的兴起,以及随之而来的双管道黑暗时期。

图 10-18。Heron paper
但是,夸张的戏剧性除外,Storm 是该行业首次尝试低延迟数据处理的系统,这一影响在今天对流处理系统的广泛兴趣和采用中得到体现。
在继续之前,也值得一提的是 Heron。2015 年,Twitter(全球已知最大的 Storm 用户,最初培育了 Storm 项目的公司)宣布放弃 Storm 执行引擎,转而采用公司内部开发的新系统 Heron。Heron 旨在解决困扰 Storm 的一些性能和可维护性问题,同时保持 API 兼容性,详细内容在公司的论文中介绍“Twitter Heron: Stream Processing at Scale”(图 10-18)。Heron 本身随后被开源(治理权转移到了自己独立的基金会,而不是像 Apache 那样的现有基金会)。鉴于 Storm 的持续发展,现在有两个竞争性的 Storm 变种。事情最终会如何发展,任何人都无法预测,但观察将会是令人兴奋的。
Spark
继续前进,我们现在来到 Apache Spark(图 10-19)。这是另一个部分,我将通过专注于其在流处理领域的贡献来大大简化 Spark 对行业的总体影响。提前道歉。

图 10-19。时间轴:Spark
Spark 最初是在 2009 年左右在加州大学伯克利分校的著名 AMPLab 开始的。最初推动 Spark 声名鹊起的是其能够在大多数情况下完全在内存中执行管道的大部分计算,直到最后才触及磁盘。工程师们通过弹性分布式数据集(RDD)的概念实现了这一点,基本上捕获了管道中任何给定点的数据完整谱系,允许根据需要在机器故障时重新计算中间结果,假设 a)您的输入始终可以重放,b)您的计算是确定性的。对于许多使用案例,这些前提条件是真实的,或者至少在性能方面相对真实,用户能够实现与标准 Hadoop 作业相比的巨大性能提升。从那时起,Spark 逐渐建立起其作为 Hadoop 事实上的继任者的声誉。
Spark 创建几年后,当时是 AMPLab 的研究生的 Tathagata Das 意识到:嘿,我们有这个快速的批处理引擎,如果我们将多个批处理依次运行,并使用它来处理流数据怎么样?从这点洞察力出发,Spark Streaming 诞生了。
Spark Streaming 真正了不起的地方在于:由于在幕后支持一致性批处理引擎,世界现在拥有了一个流处理引擎,可以在不需要额外批处理作业的情况下自行提供正确的结果。换句话说,根据正确的使用案例,您可以放弃 Lambda 架构系统,只使用 Spark Streaming。万岁 Spark Streaming!
这里的一个主要限制是“正确的使用案例”部分。 Spark Streaming 的原始版本(1.x 变体)的一个很大的缺点是它只支持特定类型的流处理:处理时间窗口。因此,任何关心事件时间、需要处理延迟数据等的使用案例,都需要用户编写大量额外的代码来实现某种形式的事件时间处理,以覆盖 Spark 的处理时间窗口架构。这意味着 Spark Streaming 最适合于顺序数据或事件时间不可知的计算。正如我在本书中一再强调的那样,这些条件在处理当今常见的大规模用户中心数据集时并不如你希望的那样普遍。
围绕 Spark Streaming 的另一个有趣的争议是“微批处理与真正的流处理”的老问题。因为 Spark Streaming 建立在批处理引擎的小型重复运行的想法之上,批评者声称 Spark Streaming 不是真正的流处理引擎,因为系统中的进展受到每个批处理的全局障碍的限制。这里有一定的真相。尽管真正的流处理引擎几乎总是利用某种形式的批处理或捆绑以提高吞吐量,但它们有更高的灵活性,可以在更细粒度的级别进行,甚至可以到达单个键。微批处理架构在全局级别处理捆绑意味着几乎不可能同时具有低的每个键延迟和高的整体吞吐量,有许多基准测试表明这基本属实。但与此同时,以分钟或多秒为单位的延迟仍然相当不错。而且很少有使用案例要求确切的正确性和如此严格的延迟能力。因此,在某种程度上,Spark 最初定位的受众是绝对正确的;大多数人都属于这一类别。但这并没有阻止竞争对手将其视为该平台的巨大劣势。就我个人而言,在大多数情况下,我认为这只是一个小小的抱怨。
除了缺点之外,Spark Streaming 是流处理的一个分水岭时刻:首个公开可用的大规模流处理引擎,同时也能提供批处理系统的正确性保证。当然,正如之前提到的,流处理只是 Spark 整体成功故事的一小部分,它在迭代处理和机器学习领域做出了重要贡献,具有本地 SQL 集成,以及前面提到的快速内存性能等。
如果你对原始 Spark 1.x 架构的细节感兴趣,我强烈推荐马泰·扎哈里亚的论文,“大规模集群上快速通用数据处理的架构”(图 10-20)。这是 113 页的 Spark 精华,非常值得投资。

图 10-20. Spark 论文
截至目前,Spark 的 2.x 变体正在大大扩展 Spark Streaming 的语义能力,同时试图简化一些更复杂的部分。而且,Spark 甚至正在推动一种新的真正的流处理架构,试图关闭微批处理的反对论点。但是当它首次出现时,Spark 带来的重要贡献是,它是第一个公开可用的具有强一致性语义的流处理引擎,尽管只在有序数据或事件时间不可知的计算中。
MillWheel
接下来我们将讨论 MillWheel,这是我在 2008 年加入谷歌后在我的 20%工作时间中首次涉足的项目,后来在 2010 年全职加入了该团队(图 10-21)。

图 10-21. 时间轴:MillWheel
MillWheel 是谷歌最初的通用流处理架构,该项目是由保罗·诺德斯特罗姆在谷歌西雅图办公室开设之际创建的。MillWheel 在谷歌内部的成功长期以来一直集中在能够提供无界、无序数据的低延迟、强一致性处理能力上。在本书的过程中,我们已经看到了 MillWheel 中几乎所有组件的组合,使这一切成为可能:
-
Reuven 在第五章讨论了仅一次保证。仅一次保证对于正确性至关重要。
-
在第七章中,我们讨论了持久状态,其强一致性变体为在不可靠硬件上执行长时间运行的流水线维护正确性提供了基础。
-
Slava 在第三章谈到了水印。水印为推理输入数据的混乱提供了基础。
-
在第七章中,我们还讨论了持久定时器,它提供了水印和流水线业务逻辑之间的必要联系。
也许有些令人惊讶的是,MillWheel 项目最初并不关注正确性。保罗最初的愿景更接近 Storm 后来所倡导的:低延迟数据处理与弱一致性。最初的 MillWheel 客户,一个是在搜索数据上构建会话,另一个是在搜索查询上执行异常检测(MillWheel 论文中的 Zeitgeist 示例),他们驱使项目朝着正确性的方向发展。两者都对一致的结果有强烈需求:会话用于推断用户行为,异常检测用于推断搜索查询的趋势;如果它们提供的数据不可靠,它们的效用将显著降低。因此,MillWheel 的方向被引向了强一致性。
MillWheel 支持无序处理,这是强大流处理的另一个核心方面,也是 MillWheel 的常见特点,也是由客户的需求推动的。作为真正的流式使用案例,Zeitgeist 管道希望生成一个输出流,用于识别搜索查询流量中的异常情况,仅限异常情况(即,对于其分析的消费者来说,轮询等待标记异常的材料化视图输出表中的所有键是不切实际的;消费者只在特定键发生异常时需要直接信号)。对于异常的峰值(即查询流量的增加),这相对简单:当给定查询的计数超过模型对该查询的预期值的一定统计显著量时,可以发出异常信号。但对于异常的下降(即查询流量的减少),问题就有点棘手了。仅仅看到给定搜索词的查询数量减少是不够的,因为在任何时间段内,观察到的数量总是从零开始。在这种情况下,您真正需要做的是等到您有理由相信您已经看到了足够代表性的输入部分,然后再与您的模型进行比较。
Zeitgeist 管道最初尝试在寻找下降的分析逻辑之前插入处理时间延迟来实现这一点。当数据按顺序到达时,这种方法可以工作得相当不错,但管道的作者发现数据有时会被大大延迟,因此到达时会出现严重的无序。在这些情况下,他们使用的处理时间延迟是不够的,因为管道会错误地报告一大堆实际上并不存在的下降异常。他们真正需要的是一种等待直到输入变得完整的方法。
因此,水印是出于对无序数据的输入完整性的推理的需要而产生的。正如 Slava 在第三章中所描述的,基本思想是跟踪系统提供的输入的已知进度,使用给定类型的数据源提供的尽可能多或尽可能少的数据,构建一个可以用来量化输入完整性的进度指标。对于像静态分区的 Kafka 主题这样的简单输入源,每个分区按照递增的事件时间顺序写入(例如,通过实时记录事件的 Web 前端),您可以计算出一个完美的水印。对于像动态输入日志这样的更复杂的输入源,启发式可能是您能做的最好的。但无论如何,水印相对于使用处理时间来推理事件时间完整性的替代方法具有明显的优势,经验表明,后者在试图导航开罗的街道时,效果与使用伦敦地图一样好。
因此,由于客户的需求,MillWheel 最终成为了一个具有支持无序数据的强大流处理功能的系统。因此,题为“MillWheel: Fault-Tolerant Stream Processing at Internet Scale”⁸(图 10-22)的论文大部分时间都在讨论在这样的系统中提供正确性的困难,一致性保证和水印是主要关注的领域。如果您对这个主题感兴趣,这篇论文是非常值得一读的。

图 10-22。MillWheel 论文
在 MillWheel 论文发表后不久,MillWheel 被集成为 Flume 的替代流后端,通常一起称为 Streaming Flume。如今在 Google 内部,MillWheel 正在被其后继者 Windmill 所取代(也是 Cloud Dataflow 的执行引擎,稍后将讨论),这是一个从头开始的重写,融合了 MillWheel 的所有最佳想法,以及一些新的想法,如更好的调度和分发,以及用户和系统代码的更清晰分离。
然而,对于 MillWheel 来说,最重要的是前面列出的四个概念(仅一次、持久状态、水印、持久定时器)共同为一个系统提供了基础,这个系统最终能够实现流处理的真正承诺:在不可靠的通用硬件上对无序数据进行稳健、低延迟的处理。
Kafka
现在我们来谈谈 Kafka(图 10-23)。Kafka 在本章讨论的系统中是独一无二的,因为它不是一个数据处理框架,而是一个传输层。然而,毫无疑问,Kafka 在推动流处理方面发挥了最有影响力的作用。

图 10-23。时间线:Kafka
如果你对它不熟悉,Kafka 本质上是一个持久的流传输,实现为一组分区日志。它最初是在 LinkedIn 由 Neha Narkhede 和 Jay Kreps 等行业杰出人士开发的,它的荣誉包括以下内容:
-
提供了一个清晰的持久性模型,将批处理世界中温暖的持久、可重放的输入源的感觉打包到了一个流处理友好的接口中。
-
在生产者和消费者之间提供一个弹性的隔离层。
-
体现了我们在第六章讨论的流和表之间的关系,揭示了一种关于数据处理的基本思维方式,同时提供了与丰富而悠久的数据库世界的概念联系。
-
作为上述所有内容的副作用,Kafka 不仅成为了行业中大多数流处理安装的基石,还促进了流处理作为数据库和微服务运动。
他们一定是起得很早。
在这些荣誉中,有两个对我来说最突出。第一个是将持久性和可重放性应用于流数据。在 Kafka 之前,大多数流处理系统使用某种短暂的排队系统,如 Rabbit MQ 或甚至普通的 TCP 套接字来发送数据。持久性可能在生产者的上游备份中提供到一定程度(即,上游数据的生产者在下游工作人员崩溃时能够重新发送),但往往上游数据也是临时存储的。大多数方法完全忽略了在后续回填或用于原型设计、开发和回归测试时能够重放输入数据的想法。
Kafka 改变了一切。通过将数据库世界中经受考验的持久日志的概念应用到流处理领域,Kafka 让我们重新获得了从 Hadoop/批处理世界中常见的持久输入源到当时流处理世界中普遍存在的短暂源时失去的安全感和安全性。具有耐用性和可重放性,流处理又迈出了一步,成为了对昔日临时批处理系统的强大、可靠替代品。
作为流处理系统开发人员,Kafka 的耐用性和可重放性特性对行业产生的影响的一个更有趣的可见产物是,今天有多少流处理引擎在根本上依赖于可重放性来提供端到端的一次性保证。可重放性是 Apex、Flink、Kafka Streams、Spark 和 Storm 中端到端一次性保证的基础。在执行一次性模式时,这些系统中的每一个都假定/要求输入数据源能够倒带并重放直到最近的检查点。当与不能提供这种能力的输入源一起使用时(即使源可以通过上游备份保证可靠交付),端到端的一次性语义就会崩溃。对可重放性(以及相关的耐用性)的广泛依赖证明了这些特性在整个行业中产生的影响之大。
Kafka 简历中的第二个值得注意的亮点是流和表理论的普及。我们在整个第六章中讨论了流和表,以及第八章和第九章的大部分内容。而且理由充分。流和表构成了数据处理的基础,无论是 MapReduce 系统家族、庞大的 SQL 数据库系统还是其他任何系统。并非所有的数据处理方法都需要直接使用流和表的术语,但从概念上讲,它们都是如此操作的。作为这些系统的用户和开发人员,了解我们所有系统都建立在的核心基本概念是非常有价值的。我们都应该对 Kafka 社区的人们感激不尽,他们帮助更广泛地了解了流和表的思维方式。

图 10-24。我❤日志
如果您想了解更多关于 Kafka 及其基础的信息,《我❤日志》(O'Reilly;图 10-24)是杰伊·克雷普斯的一本优秀资源。¹⁰ 此外,正如在第六章中引用的那样,克雷普斯和马丁·克莱普曼有一对文章(图 10-25),我强烈推荐阅读,以了解流和表理论的起源。
Kafka 对流处理领域做出了巨大的贡献,可以说比其他任何单一系统都要多。特别是,将耐用性和可重放性应用于输入和输出流在帮助将流处理从近似工具的小众领域推向了一般数据处理的大联盟中发挥了重要作用。此外,由 Kafka 社区推广的流和表的理论为我们提供了对数据处理基本机制的深刻洞察。

图 10-25。马丁的文章(左)和杰伊的文章(右)
Cloud Dataflow
云数据流(图 10-26)是谷歌的全面托管的基于云的数据处理服务。 Dataflow 于 2015 年 8 月面向世界推出。它的构建意图是将十多年的 MapReduce、Flume 和 MillWheel 构建经验打包成一个无服务器的云体验。

图 10-26。时间轴:Cloud Dataflow
尽管 Cloud Dataflow 的无服务器方面可能是从系统角度最具挑战性和区分性的因素,但我想在这里讨论的对流系统的主要贡献是其统一的批处理加流处理编程模型。这就是我们在大部分书中讨论的所有转换、窗口、水印、触发器和累积的好处。当然,它们都包含了关于什么/在哪里/何时/如何思考问题的方式。
该模型最初出现在 Flume 中,当时我们试图将 MillWheel 中强大的无序处理支持纳入 Flume 提供的更高级别的编程模型中。随后,Flume 在谷歌内部可用的综合批处理和流处理方法成为 Dataflow 中包含的完全统一模型的基础。
统一模型的关键洞察力——当时我们甚至没有真正意识到的全部内容——是,在幕后,批处理和流处理实际上并没有那么不同:它们只是流和表主题的微小变化。正如我们在第六章中所学到的,主要区别实际上归结为能够逐渐将表触发为流;其他一切在概念上都是相同的。¹¹通过利用这两种方法的共同点,可以提供一个几乎无缝的单一体验,适用于两个世界。这是在使流处理更易于访问方面迈出的一大步。
除了利用批处理和流处理之间的共同点,我们还深入研究了多年来在谷歌遇到的各种用例,并利用这些信息来指导统一模型的组成部分。我们针对的关键方面包括以下内容:
-
不对齐的事件时间窗口,例如会话,提供了对无序数据进行简洁表达强大分析构造并将其应用的能力。
-
自定义窗口支持,因为一个(甚至三个或四个)大小很少适合所有情况。
-
灵活的触发和累积模式,提供了塑造数据流通过管道的方式,以匹配给定用例的正确性、延迟和成本需求的能力。
-
使用水印来推断输入完整性,这对于异常跌落检测等用例至关重要,其中分析取决于数据的缺失。
-
对底层执行环境的逻辑抽象,无论是批处理、微批处理还是流处理,都提供了执行引擎的选择灵活性,并避免系统级构造(如微批处理大小)渗入逻辑 API。
总的来说,这些方面提供了灵活性,以平衡正确性、延迟和成本之间的紧张关系,使该模型能够应用于广泛的用例。

图 10-27。Dataflow 模型论文
考虑到您刚刚阅读了一本涵盖 Dataflow/Beam 模型细节的整本书,因此在这里重温这些概念没有太大意义。但是,如果您想对事情有稍微更学术化的看法,并且对之前提到的一些激励用例有一个很好的概述,您可能会发现我们 2015 年的Dataflow 模型论文有价值(图 10-27)。
尽管 Cloud Dataflow 还有许多其他引人注目的方面,但从本章的角度来看,其重要贡献是其统一的批处理加流处理编程模型。它为解决无界、无序数据集提供了全面的方法,并以一种灵活的方式进行权衡,以满足给定用例的正确性、延迟和成本之间的紧张关系。
Flink
Flink(图 10-28)于 2015 年突然出现,迅速从一个几乎没有人听说过的系统转变为流处理世界的强大力量之一。

图 10-28。时间轴:Flink
Flink 崭露头角的两个主要原因是:
-
它快速采用 Dataflow/Beam 编程模型,使其成为当时全球最具语义能力的完全开源流处理系统。
-
随后不久,Flink 实现了高效的快照(源自 Chandy 和 Lamport 原始论文“分布式快照:确定分布式系统的全局状态” [图 10-29]的研究),为其提供了所需的强一致性保证以确保正确性。

图 10-29。Chandy-Lamport 快照
Reuven 在第五章简要介绍了 Flink 的一致性机制,但要重申的是,基本思想是定期障碍在系统中的工作人员之间的通信路径上传播。这些障碍充当各个分布式工作人员之间的数据生产者与消费者之间的对齐机制。当消费者在其所有输入通道(即来自其所有上游生产者)上接收到给定的障碍时,它会为所有活动键检查点其当前进度,然后可以安全地确认处理障碍之前的所有数据。通过调整障碍通过系统发送的频率,可以调整检查点的频率,从而在增加延迟(由于需要在检查点时间点上实现副作用)的情况下换取更高的吞吐量。

图 10-30。“扩展 Yahoo!流式基准”
当时,Flink 具有提供精确一次语义以及本地支持事件时间处理的能力是一个巨大的进步。但直到 Jamie Grier 发表了题为“扩展 Yahoo!流式基准”(图 10-30)的文章,才清楚地表明了 Flink 的性能。在那篇文章中,Jamie 描述了两个令人印象深刻的成就:
-
构建了一个原型 Flink 流水线,其准确性超过了 Twitter 现有的 Storm 流水线的 1%成本。
-
更新雅虎!流式基准,显示 Flink(具有精确一次)的吞吐量是 Storm(没有精确一次)的 7.5 倍。此外,由于网络饱和,Flink 的性能受到限制;消除网络瓶颈使 Flink 的吞吐量几乎是 Storm 的 40 倍。
从那时起,许多其他项目(特别是 Storm 和 Apex)都采用了相同类型的一致性机制。

图 10-31。“保存点:时光倒流”
通过添加快照机制,Flink 获得了端到端精确一次所需的强一致性。但值得赞扬的是,Flink 更进一步利用其快照的全局性质,提供了从过去任何时间点重新启动整个流水线的能力,这一功能称为保存点(由 Fabian Hueske 和 Michael Winters 在“保存点:时光倒流”一文中描述 [图 10-31])。保存点功能将 Kafka 应用于流式传输层的持久重放的温暖特性扩展到整个流水线的广度。长时间运行的流式流水线随时间的优雅演变仍然是该领域的一个重要开放问题,有很大的改进空间。但截至目前,Flink 的保存点功能是朝着正确方向迈出的第一步巨大进展之一,而且在整个行业中仍然是独一无二的。
如果您对了解 Flink 快照和保存点的系统构造感兴趣,可以阅读论文“Apache Flink 中的状态管理”(图 10-32)对实现进行了详细讨论。

图 10-32。“Apache Flink 中的状态管理”
除了保存点之外,Flink 社区继续创新,包括为大规模分布式流处理引擎推出了第一个实用的流式 SQL API,正如我们在第八章中讨论的那样。
总之,Flink 迅速崛起为流处理巨头主要归功于其方法的三个特点:1)吸收了行业中最好的现有思想(例如,成为 Dataflow/Beam 模型的第一个开源采用者),2)带来了自己的创新,推动了技术的发展(例如,通过快照和保存点实现强一致性,流式 SQL),以及 3)快速且反复地做这两件事。再加上所有这些都是在开源中完成的,您就可以看到为什么 Flink 一直在整个行业中不断提高流处理的标准。
Beam
我们要讨论的最后一个系统是 Apache Beam(图 10-33)。Beam 与本章中的大多数其他系统不同之处在于,它主要是一个编程模型、API 和可移植性层,而不是具有执行引擎的完整堆栈。但这正是重点所在:正如 SQL 作为声明式数据处理的通用语言,Beam 旨在成为编程式数据处理的通用语言。让我们来探讨一下。

图 10-33. 时间轴:Beam
具体来说,Beam 由多个组件组成:
-
一个统一的批处理加流式编程模型,继承自其起源地 Cloud Dataflow,我们在本书的大部分内容中讨论了其细节。该模型独立于任何语言实现或运行时系统。您可以将其视为 Beam 对 SQL 的关系代数的等价物。
-
一组SDKs(软件开发工具包),实现了该模型,允许以特定语言的习惯方式表达流水线。Beam 目前提供了 Java、Python 和 Go 的 SDKs。您可以将这些视为 Beam 对 SQL 语言的编程等价物。
-
一组DSLs(领域特定语言),建立在 SDKs 之上,提供专门的接口,以独特的方式捕捉模型的部分内容。而 SDKs 需要展示模型的所有方面,DSLs 只能暴露那些对特定领域有意义的部分。Beam 目前提供了一个名为 Scio 的 Scala DSL 和一个 SQL DSL,两者都是建立在现有的 Java SDK 之上的。
-
一组可以执行 Beam 流水线的运行器。运行器以 Beam SDK 术语描述的逻辑流水线,并尽可能高效地将其转换为物理流水线,然后执行。目前存在的 Beam 运行器包括 Apex、Flink、Spark 和 Google Cloud Dataflow。用 SQL 术语来说,您可以将这些运行器视为 Beam 对各种 SQL 数据库实现的等价物,如 Postgres、MySQL、Oracle 等。
Beam 的核心愿景建立在其作为可移植性层的价值上,而在这个领域中更具吸引力的特性之一是其计划支持完全跨语言的可移植性。尽管尚未完全完成(但即将到来),计划是让 Beam 在 SDK 和运行器之间提供足够高效的抽象层,以实现完全的跨产品 SDK × runner 匹配。在这样的世界中,使用 JavaScript SDK 编写的流水线可以在 Haskell 运行器上无缝执行,即使 Haskell 运行器本身没有本地执行 JavaScript 代码的能力。
作为一个抽象层,Beam 相对于其 runners 的定位对于确保 Beam 实际为社区带来价值至关重要,而不是引入一个不必要的抽象层。关键在于,Beam 的目标是永远不只是其 runners 中发现的特性的交集(最低公共分母)或并集(厨房水槽)。相反,它的目标是仅包括整个数据处理社区中最好的想法。这允许在两个维度上进行创新:
Beam 中的创新

图 10-34。强大和模块化的 I/O
Beam 可能会包括并非所有 runners 最初都支持的运行时特性的 API 支持。这没关系。随着时间的推移,我们期望许多 runners 将在未来版本中纳入这些特性;那些不这样做的将成为需要这些特性的用例的不太吸引人的 runner 选择。
这里的一个例子是 Beam 的 SplittableDoFn API,用于编写可组合的可伸缩源(由 Eugene Kirpichov 在他的文章“在 Apache Beam 中使用 Splittable DoFn 编写强大和模块化的 I/O 连接器”中描述)。它既独特又非常强大,但对于一些更具创新性的部分,比如动态工作重新平衡,目前还没有得到所有 runners 的广泛支持。然而,考虑到这些特性带来的价值,我们预计随着时间的推移,情况将会改变。
runners 中的创新
Runners 可能会引入运行时特性,而 Beam 最初并不提供 API 支持。这没关系。随着时间的推移,已经证明其有用性的运行时特性将被纳入 Beam 的 API 支持中。
这里的一个例子是 Flink 中的状态快照机制,或者之前我们讨论过的 savepoints。Flink 仍然是唯一公开可用的流处理系统,以这种方式支持快照,但 Beam 中有一个提案提供围绕快照的 API,因为我们认为随着时间的推移,管道的优雅演进是一个重要的特性,将在整个行业中具有价值。如果我们今天神奇地推出这样的 API,Flink 将是唯一支持它的运行时系统。但同样,这没关系。这里的重点是,随着这些特性的价值变得清晰,整个行业将随着时间的推移开始赶上。¹²这对每个人都更好。
通过鼓励 Beam 本身以及 runners 内的创新,我们希望随着时间的推移以更快的速度推动整个行业的能力,而不会在这一过程中接受妥协。通过实现跨运行时执行引擎的可移植性的承诺,我们希望建立 Beam 作为表达编程数据处理流水线的通用语言,类似于 SQL 如今作为声明式数据处理的通用货币存在。这是一个雄心勃勃的目标,截至目前,我们离完全实现它还有一段距离,但我们迄今为止也走了很长一段路。
总结
我们刚刚快速浏览了十五年来数据处理技术的进步,重点关注了使流式系统成为今天的样子的贡献。最后总结一下,每个系统的主要要点是:
MapReduce - 可伸缩性和简单性
通过在强大且可伸缩的执行引擎之上提供一组简单的数据处理抽象,MapReduce 使数据工程师能够专注于其数据处理需求的业务逻辑,而不是构建分布式系统的棘手细节,以使其能够抵御商品硬件的故障模式。
Hadoop - 开源生态系统
通过在 MapReduce 的思想基础上构建开源平台,Hadoop 创造了一个蓬勃发展的生态系统,远远超出了其前身的范围,并允许大量新的想法蓬勃发展。
Flume - 流水线,优化
通过将逻辑管道操作的高级概念与智能优化器相结合,Flume 使得编写干净、可维护的管道成为可能,其能力超越了 Map→Shuffle→Reduce 的 MapReduce 的限制,而又不牺牲通过手工调优获得的性能。
Storm-低延迟与弱一致性
通过牺牲结果的正确性以换取延迟的降低,Storm 将流处理带给了大众,并引领了 Lambda 架构的时代,其中弱一致性的流处理引擎与强一致性的批处理系统并行运行,实现了低延迟、最终一致的真正商业目标。
Spark-强一致性
通过利用强一致性批处理引擎的重复运行来提供无界数据集的连续处理,Spark Streaming 证明了在有序数据集中至少可以同时具有正确性和低延迟的结果是可能的。
MillWheel-无序处理
通过将强一致性和精确一次处理与水印和定时器等关于时间的推理工具相结合,MillWheel 征服了对无序数据进行强大流处理的挑战。
Kafka-持久流、流和表
通过将持久日志的概念应用于流传输问题,Kafka 重新带回了持久性流传输的温暖、踏实的感觉,这是由于像 RabbitMQ 和 TCP 套接字这样的短暂流传输而失去的。并通过推广流和表理论的思想,它帮助阐明了数据处理的概念基础。
Cloud Dataflow-统一批处理加流处理
通过将 MillWheel 的无序流处理概念与 Flume 的逻辑、自动可优化的管道相融合,Cloud Dataflow 提供了一个统一的批处理加流处理数据模型,提供了灵活性,以平衡正确性、延迟和成本之间的紧张关系,以适应任何给定的用例。
Flink-开源流处理创新者
通过迅速将无序处理的能力带到开源世界,并将其与分布式快照和相关的保存点功能等创新相结合,Flink 提高了开源流处理的标准,并帮助引领了行业内流处理创新的潮流。
Beam-可移植性
通过提供一个强大的抽象层,结合了行业内最佳想法的 Beam 提供了一个可移植性层,被定位为与 SQL 提供的声明性共同语言在编程上等效的,同时也鼓励行业内创新思想的采纳。
可以肯定的是,我在这里突出的这 10 个项目及其成就的抽样远远不能涵盖导致行业发展到今天的全部历史。但它们对我来说是重要和值得注意的里程碑,它们共同描绘了过去十五年中流处理的演变的信息图景。自 MapReduce 的早期以来,我们已经走了很长的路,沿途经历了许多起伏、曲折和转折。即便如此,在流系统领域仍然存在许多未解决的问题。我很期待未来会带来什么。
¹这意味着我跳过了关于流处理的大量学术文献,因为那是它的起源。如果你真的对这个话题的学术论文很感兴趣,可以从“数据流模型”论文的参考文献开始,然后往回看。你应该能够很容易地找到你的方向。
² 当然,MapReduce 本身建立在许多早已知名的想法之上,甚至在 MapReduce 论文中明确说明了这一点。这并不改变 MapReduce 将这些想法(以及它自己的一些想法)结合起来,创造出一个实际解决重要且新兴问题的系统,比以往任何人都做得更好,并且激发了后来的数据处理系统的世代。
³ 明确地说,谷歌当时绝对不是唯一一家在这个规模上解决数据处理问题的公司。谷歌只是第一代尝试解决大规模数据处理问题的众多公司之一。
⁴ 明确地说,MapReduce 实际上是建立在谷歌文件系统 GFS 之上的,GFS 本身解决了特定子集的可伸缩性和容错性问题。
⁵ 不完全像数据库世界长期使用的查询优化器。
⁶ Noogler == 新人 + Googler == Google 的新员工
⁷ 顺便说一句,我还强烈推荐阅读 Martin Kleppmann 的《CAP 定理批评》(http://bit.ly/2ybJlnt),对 CAP 定理本身的缺点进行了很好的分析,以及更有原则的替代方法来看待同样的问题。
⁸ 值得一提的是,这本书主要由 Sam McVeety 撰写,Reuven 提供帮助,其他作者也有一些输入;我们不应该按字母顺序排列作者名单,因为每个人总是假设我是主要作者,即使我并不是。
⁹ Kafka Streams 和现在的 KSQL 当然正在改变这一点,但这些都是相对较新的发展,我将主要关注过去的 Kafka。
¹⁰ 虽然我推荐这本书作为最全面和连贯的资源,但如果你在 O'Reilly 的网站上搜索 Kreps 的文章,你也可以找到其中的许多内容。抱歉,Jay...
¹¹ 就像许多广泛的概括一样,这个概括在特定的背景下是正确的,但掩盖了现实的基本复杂性。正如我在第一章中所暗示的,批处理系统在优化有界数据集的数据处理管道的成本和运行时间方面做出了很大的努力,而流处理引擎尚未尝试复制。暗示现代批处理和流处理系统只在一个小方面上有所不同是一个相当简化的说法,在纯粹概念之外的任何领域都是如此。
¹² 这里还有一个值得注意的微妙之处:即使运行器采用了新的语义并勾选了功能复选框,也不能盲目选择任何运行器并获得相同的体验。这是因为运行器本身在运行时和操作特性上仍然可能存在很大的差异。即使两个给定的运行器在 Beam 模型中实现了相同的语义特性,它们在运行时执行这些特性的方式通常也是非常不同的。因此,在构建 Beam 管道时,重要的是要对各种运行器进行功课,以确保选择最适合您用例的运行平台。


浙公网安备 33010602011771号