您已到达本书中有关流和表的部分。回想一下,在第一章中,我们简要讨论了数据的两个重要但正交的维度:基数和构成。到目前为止,我们一直只专注于基数方面(有界与无界),而忽略了构造方面(流与表)。这使我们能够了解无边界数据集的引入所带来的挑战,而不必过多担心真正驱动事物工作方式的底层细节。现在,我们将扩大视野,看看构成宪法的附加维度会带来什么。
尽管有点麻烦,但是思考这种方法转变的一种方法是比较经典力学与量子力学之间的关系。您知道他们在物理学课上如何教您一堆诸如牛顿理论之类的经典力学知识,然后在您认为您或多或少地掌握了这些知识之后,他们就来告诉您这全是铺天盖地的东西,而经典物理学只给您部分图片,实际上还有另一种叫做量子力学的东西,可以真正解释事物在较低层次上的工作原理,但是试图一次同时教您这两个问题使复杂的事情变得毫无意义。然后……哦,等等……我们还没有完全调和两者之间的所有内容,所以斜视一下并相信我们,这一切都有意义吗?好吧,这差不多就是这样,除了您的大脑受到的伤害更少之外,因为物理比数据处理要困难得多,而且您不必斜视任何东西并假装它是有道理的,因为它实际上确实可以完美地融合在一起,这真的很酷。
因此,通过适当地设置阶段,本章的重点是双重的:
- 试图描述波束模型(到目前为止,我们已经在书中对此进行了描述)与“流和表”理论(由马丁·克莱普曼和杰伊·克雷普斯等人推广,但本质上是这样)之间的关系。源于数据库世界)。事实证明,流表理论在描述作为梁模型基础的低层概念方面起到了很有启发性的作用。此外,在考虑如何将健壮的流处理概念清晰地集成到SQL中时,对它们之间的关系的清晰了解尤其有用(我们将在第8章中考虑)。
- 用糟糕的物理类比轰炸您,以享受纯粹的乐趣。写书是很多工作。您必须在这里和那里找到一些快乐,才能继续前进。
流表的基础知识或:流表相对性的特殊理论
流和表的基本思想源自数据库世界。任何使用SQL的人都可能熟悉表及其核心属性,大致概括为:表包含数据的行和列,并且每一行都通过某种显式或隐式键唯一地标识。
如果您回想起大学时期的数据库系统课程,[1]您可能会想起大多数数据库的数据结构是仅追加日志。将事务应用于数据库中的表时,这些事务会记录在日志中,然后将其内容依次应用于表以实现这些更新。在流和表命名法中,该日志实际上是流。
从这个角度来看,我们现在了解了如何从流中创建表:该表只是应用在流中找到的更新的事务日志的结果。但是,我们如何从表创建流?本质上是相反的:流是表的变更日志。通常用于表到流转换的激励示例是实例化视图。SQL中的实例化视图使您可以在表上指定查询,然后该查询本身由数据库系统表现为另一个一流的表。该物化视图本质上是该查询的缓存版本,可确保数据库系统始终是最新的,因为源表的内容会随着时间的推移而发展。可能不足为奇的是,通过原始表的变更日志来实现实例化视图。每当源表更改时,都会记录该更改。然后,数据库会在实例化视图的查询上下文中评估该更改,并将所有结果更改应用于目标实例化视图表。
将这两点结合在一起,并采用另一种可疑的物理类比,我们得出了所谓的流与表相对论的特殊理论:
流→表格
随着时间的推移,更新流的聚合产生一张表。
表→流
观察表随时间的变化会产生一个流。
这是一对非常有力的概念,它们在流处理领域的谨慎应用是Apache Kafka(基于这些基本原理构建的生态系统)取得巨大成功的主要原因。但是,这些语句本身不够通用,无法让我们将流和表与Beam Model中的所有概念联系在一起。为此,我们必须更深入一点。
走向流与表相对论的一般理论
如果我们想使流/表理论与我们对波束模型所了解的一切保持一致,则需要绑一些松散的末端,特别是:
- 批处理如何适应所有这些?
- 流与有界和无界数据集之间的关系是什么?
- 这四个问题如何,在何时何地,如何将问题映射到流/表世界?
在我们尝试这样做时,对流和表有正确的心态将很有帮助。除了理解它们之间的相互关系(如先前的定义所捕获的),将它们彼此独立地进行定义也很有启发性。这是一种简单的查看方式,可以强调我们将来的一些分析:
- 表是静止数据。
这并不是说表以任何方式都是静态的。几乎所有有用的表格都以某种方式随时间不断变化。但是在任何给定的时间,表的快照都会提供整体上包含的数据集的某种形式的图片。2这样,表就成为了概念上的安息之地,可以随着时间的推移积累和观察数据。因此,数据处于静止状态。
- 流是运动中的数据。
尽管表在特定时间点捕获了整个数据集的视图,而流则捕获了该数据随时间的演变。朱利安·海德(Julian Hyde)喜欢说流就像表的派生类一样,并且表是流的积分,这对那些有数学头脑的人来说是一种很好的思考方式。无论如何,流的重要特征是它们捕获表中数据变化时固有的移动。因此,数据在运动。
尽管表和流是密切相关的,但重要的是要记住,即使在很多情况下一个表可能完全从另一个表派生,它们也并非完全相同。正如我们将看到的,差异是细微但重要的。
批处理与流和表
现在,我们众所周知的指关节已破裂,让我们开始捆绑一些松散的末端。首先,我们解决第一个关于批处理的问题。最后,我们将发现,针对第二个问题的解决方案(关于流与绑定数据和无限制数据的关系)将自然地从第一个问题的答案中消失。碰巧得分一。
MapReduce的流和表分析
为了使我们的分析保持相对简单但又切实可靠,让我们看一下传统的Map Reduce作业如何适合流/表世界。顾名思义,MapReduce作业从表面上包含两个阶段:Map和Reduce。但是,出于我们的目的,将其看上去更深一些并将其更多地视为六个是很有用的:
MapRead
这将消耗输入数据并将其预处理为标准键/值形式以进行映射。
地图
重复(和/或并行)从预处理输入中消耗一个键/值对3,并输出零个或多个键/值对。
MapWrite
这会将具有相同键的Map阶段输出值集聚在一起,并将这些键/值列表组写入(临时)持久存储中。这样,MapWrite阶段本质上是按键和检查点进行分组的操作。
减少阅读
这将消耗保存的随机播放数据,并将它们转换为标准键/值列表形式以进行缩减。
减少
重复(和/或并行)消耗单个键及其关联的记录值列表,并输出零个或多个记录,所有这些都可以有选择地保持与同一键的关联。
ReduceWrite
这会将Reduce阶段的输出写入到输出数据存储中。
请注意,有时有时将MapWrite和ReduceRead阶段统称为Shuffle阶段,但出于我们的目的,最好将它们独立考虑。也许还值得注意的是,如今,MapRead和ReduceWrite阶段提供的功能通常被称为源和接收器。除了题外话,现在让我们看看这与流和表之间的关系。
映射为流/表
因为我们以static4数据集开头和结尾,所以应该清楚的是,我们以表开头,以表结尾。但是我们之间有什么呢? 天真的,人们可能会认为桌子一直在下降。毕竟,从概念上讲,批处理会消耗和产生表。而且,如果您将批处理工作视为执行经典SQL查询的粗略类比,那感觉就很自然了。但是,让我们一步一步地仔细看看实际发生的情况。
首先,MapRead消耗一张表并产生一些东西。Map阶段接下来会消耗一些东西,因此,如果我们想了解其性质,那么最好从Map阶段API开始,这在Java中看起来像这样:
void map(KI key, VI value, Emit<KO, VO> emitter);
将为输入表中的每个键/值对重复调用map调用。如果您认为这听起来像是可疑的,好像输入表被当作记录流使用了,那么您是对的。我们将更仔细地研究表稍后如何转换为流,但就目前而言,可以说MapRead阶段正在对输入表中静止的数据进行迭代,并以流的形式将其移动然后由map阶段消耗。
接下来,Map阶段将消耗该流,然后做什么?由于地图操作是逐元素转换,因此它不会做任何事情来阻止移动的元素并使它们静止。它可能会通过过滤掉某些元素或将某些元素分解为多个元素来更改流的有效基数,但是在Map阶段结束之后,这些元素都保持彼此独立。因此,可以肯定地说Map阶段既消耗流又产生流。
映射阶段完成后,我们进入MapWrite阶段。如前所述,MapWrite按键对记录进行分组,然后以该格式将它们写入持久性存储。实际上,只要在某处存在持久性,写操作的持久性部分实际上就不是严格必要的(即,如果上游输入被保存并且在失败的情况下可以重新计算中间结果,类似于Spark采取的方法)弹性分布式数据集[RDD]。重要的是将记录分组到某种类型的数据存储中,无论是在内存中,在磁盘上还是在您身上。这很重要,因为进行了分组操作,以前在流中一对一飞过的记录现在被放置在其键所指定的位置,从而允许每个键的组像自己一样累积关键的弟兄和姐妹们到了。请注意,这与之前提供的流到表转换的定义有多相似:随着时间的推移,更新流的聚合会产生一张表。MapWrite阶段通过按记录的键对记录流进行分组,使这些数据处于静止状态,从而将流转换回表中。酷!
现在我们到了MapReduce的一半,因此,使用图6-1,我们来回顾一下到目前为止所看到的内容。
我们从表到数据流,然后再通过三个操作返回。MapRead将表转换为流,然后通过Map(通过用户代码)将其转换为新的流,然后由MapWrite将其转换回表。我们将发现MapReduce中的下三个操作看起来非常相似,因此我将更快速地进行介绍,但是我仍然想指出其中的一个重要细节。
图6-1 在MapReduce中映射阶段。表中的数据将转换为流并再次返回。
减少为流/表
在MapWrite阶段结束之后,ReduceRead本身就变得没有意思了。它与MapRead基本相同,除了读取的值是单值列表而不是单值之外,因为MapWrite存储的数据是键/值列表对。但是它仍然只是遍历表快照以将其转换为流。这里没有新内容。
尽管听起来可能很有趣,但在这种情况下,Reduce实际上只是一个美化的地图阶段,碰巧接收到每个键的值列表而不是单个值。因此,它仍然只是将单个(复合)记录映射到零个或多个新记录中。这里也没有什么特别新鲜的。
ReduceWrite是一个值得注意的地方。我们已经知道,鉴于Reduce会生成流并且最终输出是表,因此此阶段必须将流转换为表。但是那是怎么发生的呢?如果我告诉你这是将前一阶段的输出密钥分组到持久性存储中的直接结果,就像我们在MapWrite上看到的那样,您可能会相信我,直到您记得我之前提到密钥关联是可选功能为止在减少阶段。
启用该功能后,ReduceWrite本质上与MapWrite 6相同。但是,如果禁用了该功能,而Reduce的输出没有关联的键,那么究竟是什么使这些数据停止了呢?
要了解发生了什么,重新考虑一下SQL表的语义很有用。尽管通常建议这样做,但对于SQL表而言,并非必须具有唯一标识每一行的主键。对于无键表,插入的每一行都被视为一个新的独立行(即使其中的数据与表中的一个或多个现有行相同),就好像有一个隐式的AUTO_INCREMENT字段用作密钥(顺便说一句,在大多数实现中,这实际上是在幕后有效地发生的事情,即使在这种情况下,“密钥”可能只是一些物理块位置,从未公开或期望用作逻辑标识符)。这种隐式的唯一键分配正是在ReduceWrite中使用非键数据进行的操作。从概念上讲,仍然存在按组分组操作。这就是使数据静止的原因。但是,由于缺少用户提供的密钥,ReduceWrite会将每条记录当作具有新的,从未见过的密钥来对待,并有效地将每条记录与自身分组,从而再次产生静态数据。
看一下图6-2,它从流/表的角度显示了整个管道。您可以看到它是TABLE→STREAM→STREAM→TABLE→STREAM→STREAM→TABLE的序列。即使我们正在处理有限的数据,即使我们做的是传统上认为是批处理的事情,它实际上也只是隐藏的流和表。
图6-2 从流和表的角度看,MapReduce中的Map和Reduce阶段
与批处理协调
那么关于我们的前两个问题,这又使我们离开哪里呢?
问:批处理如何适合流/表理论?
答:很好。基本模式如下:
a. 完整读取表成为流。
b. 流被处理为新的流,直到命中了分组操作。
c. 分组会将流变成表。
d. 重复执行步骤a到c,直到管道中的所有阶段用完为止。问:流如何与有界/无界数据相关?
答:从Map Reduce的示例中可以看出,流只是动作形式的数据,无论它们是有界还是无界的。
从这个角度来看,很容易看出,流/表理论与批处理有界数据并不一致。实际上,它仅进一步支持了我一直在处理该批处理的想法,而流处理的确没有什么不同:一天结束时,它一直是流式处理和表式处理。
这样,我们就可以迈向流和表的一般理论。但是,为了使内容干净,我们最后需要重新审视流/表上下文中的四个what / where / when / how任务,以了解它们之间的关系。
流和表世界中的内容,位置,时间和方式
在本节中,我们将研究四个问题中的每一个,并查看它们与流和表的关系。我们还将回答上一节中可能存在的任何问题,其中一个大问题是:如果分组是使数据静止的事物,那么使它们运动的“解分组”逆究竟是什么? 以后再说。但就目前而言,正在进行转型。
什么:转换
在第3章中,我们了解到转换可以告诉我们管道正在计算什么;也就是说,无论是构建模型,计算总和,过滤垃圾邮件等等。我们在前面的Map Reduce示例中看到六个阶段中的四个回答了哪些问题:
- Map和Reduce都分别对输入流中的每个键/值或键/值列表对应用了管道作者的逐元素转换,从而产生了一个新的转换流。
- MapWrite和ReduceWrite都根据该阶段分配的键将前一阶段的输出分组(在可选的Reduce情况下,可能是隐式的),并以此将输入流转换为输出表。
从这种角度来看,您可以看到从流/表理论的角度来看,转换实际上有两种类型:
非分组
这些操作(如我们在Map和Reduce中所见)仅接受记录流,并在另一端生成新的转换记录流。非分组转换的示例包括过滤器(例如,删除垃圾邮件),爆炸程序(即,将较大的复合记录拆分成其组成部分)和变异符(例如,除以100),等等。
分组
这些操作(如我们在MapWrite和ReduceWrite中所看到的)接受记录流并以某种方式将它们分组在一起,从而将记录流转换为表。分组转换的示例是联接,聚合,列表/集合累积,changelog应用程序,直方图创建,机器学习模型训练等。
为了更好地了解所有这些联系在一起的方式,让我们看一下图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所示。
图6-3 经典批处理的事件时间/处理时间视图
图6-4 从流和表的角度描绘了该管道随时间变化的更多拓扑视图
图6-4 经典批处理的流和表视图
在此可视化的流和表格版本中,随着时间的推移,通过在处理时间维度(y轴)中向下滚动图形区域来显示时间的流逝。用这种方式呈现事物的好处在于,它非常清楚地指出了非分组和分组操作之间的区别。与我们之前的图不同,在该图中我省略了管道中除Sum.integersByKey之外的所有初始转换,在这里我也包含了初始解析操作,因为解析操作的非分组方面与分组形成了很好的对比求和的方面。从这个角度来看,很容易看出两者之间的区别。非分组操作不会阻止流中元素的运动,因此会在另一侧生成另一个流。相反,分组操作使流中的所有元素都静止不动,因为它将它们加在一起成为最终总和。因为此示例在批处理数据上的批处理引擎上运行,所以只有在输入结束后才发出最终结果。正如我们在第2章中提到的那样,该示例足以处理有界数据,但在无界数据的上下文中却过于局限,因为从理论上讲,输入将永远不会结束。但这真的不够吗?
查看图的新流/表部分,如果我们要做的只是将总和计算为最终结果(而不是在流水线的下游进一步以任何其他方式实际转换这些总和),则我们将分组创建的表操作的答案就在那儿,随着新数据的到来,它会随着时间而发展。我们为什么不从那里读取结果呢?
这正是支持流处理器作为数据库的人们(主要是Kafka和Flink团队)提出的要点:在管道中进行分组操作的任何地方,您都将创建一个包含有效输出的表舞台那部分的价值。如果这些输出值恰好是您的管道计算的最终结果,那么只要您可以从该表中直接读取它们,就无需在其他地方重新实现它们。除了在结果随时间变化时提供对结果的快速,轻松访问之外,该方法还不需要在管道中额外的接收器阶段来实现输出,从而节省了计算资源,通过消除冗余数据存储来节省磁盘空间,并且不需要任何数据。唯一的主要缺点是,您需要注意确保只有数据处理管道才能对表进行修改。如果由于外部修改而导致表中的值可以从管道下更改,则所有关于一致性保证的押注都将取消。
行业中的许多人已经推荐了这种方法已有一段时间了,并且在各种情况下都可以使用它。我们已经看到Google内部的MillWheel客户通过直接从基于Bigtable的状态表中提供数据来做同样的事情,并且我们正在添加一流的支持,以便从C + +中的管道外部访问状态+-我们在Google内部使用的基于Apache Beam的等效产品(Google Flume);希望这些概念也能在不久的将来运用于Apache Beam。
现在,如果其中的值是您的最终结果,那么从状态表中读取数据非常有用。但是,如果您有更多的处理要在管道中执行下游操作(例如,假设我们的管道实际上是在计算最高得分团队),我们仍然需要一些更好的方法来处理无边界数据,从而使我们可以将表转换回流以更渐进的方式。为此,我们要回顾剩下的三个问题,从开窗,扩展到触发,最后将所有问题与积累联系在一起。
哪里:开窗
正如我们从第3章了解到的那样,加窗显示了事件分组的时间。结合我们以前的经验,我们还可以推断出它必须在流到表的转换中起作用,因为分组是驱动表创建的因素。窗口化实际上有两个方面与流/表理论交互:
窗口分配
这实际上意味着将记录放入一个或多个窗口。
窗口合并
这是使动态的,数据驱动的窗口类型(例如会话)成为可能的逻辑。
窗口分配的效果非常简单。将记录概念性地放置在窗口中时,窗口的定义实际上与该记录的用户分配键组合在一起,以创建在分组时使用的隐式复合键。简单。
为了完整起见,让我们从第3章,但从流和表的角度再看一下原始的窗口示例。如果您还记得的话,该代码段看起来类似于示例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所示。
图6-5 批处理引擎上窗口加总的事件时间/处理时间视图
现在,图6-6显示了流和表的版本。
图6-6 批处理引擎上窗口加总的流和表视图
如您所料,这看起来与图6-4非常相似,但是表中有四个分组(对应于数据占用的四个窗口),而不是一个分组。但是和以前一样,我们必须等到边界输入结束后才能发出结果。在下一节中,我们将探讨如何解决无边界数据的问题,但首先让我们简要介绍一下合并窗口。
窗口合并
继续进行合并,我们会发现窗口合并的效果比窗口分配更复杂,但是当您考虑将要发生的逻辑操作时,仍然很简单。将流分组为可以合并的窗口时,该分组操作必须考虑所有可能合并在一起的窗口。通常,这仅限于其数据都具有相同键的窗口(因为我们已经确定,窗口化不仅可以对键进行分组,还可以对键和窗口进行分组)。因此,系统并没有真正将键/窗口对视为平面组合键,而是将用户分配的键作为根,而将窗口作为该根的子组件视为层次结构键。当需要将数据实际分组在一起时,系统首先按层次结构的根(用户分配的键)进行分组。在按键对数据进行分组之后,系统可以继续按该键内的窗口进行分组(使用分层复合键的子组件)。这种按窗口分组的行为是发生窗口合并的地方。
从流和表的角度来看,有趣的是,此窗口合并如何改变最终应用于表的突变;即,它如何修改指示表内容随时间变化的变更日志。使用非合并窗口时,对每个新元素进行分组都会导致对该表进行单个更改(将该元素添加到该元素的键+窗口的组中)。使用合并窗口时,对新元素进行分组的操作可能导致一个或多个现有窗口与新窗口合并。因此,合并操作必须检查所有现有窗口中的当前键,找出哪些窗口可以与此新窗口合并,然后以原子方式将旧的未合并窗口的删除操作与新合并窗口的插入操作一起提交到窗口中。桌子。这就是为什么支持合并窗口的系统通常将原子性/并行化单元定义为键,而不是键+窗口。否则,将不可能(或至少要贵得多)提供正确性保证所需的强一致性。当您开始详细了解它时,您会明白为什么让系统处理与窗口合并相关的繁琐事务如此好。要进一步了解窗口合并语义,请参考“数据流模型”的2.2.2节。
归根结底,开窗实际上只是对分组语义的一个小改动,这意味着它是对流到表转换的语义的一个小改动。对于窗口分配,就像将窗口合并到分组时使用的隐式复合键一样简单。当涉及到窗口合并时,该复合键被更像是一个层次键,从而使系统能够处理按键分组的烦人事务,找出该键内的窗口合并,然后将所有必要的变异原子地应用于对应的为我们准备的餐桌。万岁的抽象层!
综上所述,在无界数据的情况下,我们实际上还没有解决将表转换为流的问题。为此,我们需要重新审视触发器。
时间:触发
在第3章中我们了解到,我们使用触发器来指示何时实现窗口内容(水印为某些类型的触发器提供了输入完整性的有用信号)。在将数据分组到一个窗口中之后,我们使用触发器来指示何时应将该数据发送到下游。用流/表术语,我们理解分组意味着流到表的转换。从那里开始,看到触发器是分组的补充是一个相对较小的飞跃。换句话说,我们之前已经掌握了这种“取消分组”操作。触发器是驱动表到流转换的因素。
在流/表术语中,触发器是应用于表的特殊过程,该过程允许响应相关事件而在该表中实现数据。如此说来,它们听起来实际上与经典的数据库触发器相似。实际上,这里的名称选择并非巧合;他们本质上是同一件事。指定触发器时,实际上是在编写代码,然后随着时间的推移对状态表中的每一行进行评估。当触发该触发器时,它将获取表中当前处于静止状态的相应数据并将其移动,从而产生新的流。
让我们回到我们的例子。我们将从第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());
图6-7 流引擎上的每条记录触发
和以前一样,每当遇到新记录时,就会产生新结果。以流和表类型的视图呈现,此图类似于图6-8。
图6-8 窗口加总的流和表视图,在流引擎上具有按记录触发的窗口
使用每个记录触发器的一个有趣的副作用是,如果触发器随后立即将它们重新放回原处,它会在某种程度上掩盖静止数据的影响。即便如此,由于未分组的值流从表中流失,所以分组后的汇总工件仍保留在表中。
为了更好地了解静止/运动之间的关系,让我们在触发示例中跳至第2章中的基本水印完整性流示例,该示例仅在完成时发出结果(由于水印通过了末尾)窗户)。例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());
图6-9 流式引擎上具有启发式水印的窗口求和的事件时间/处理时间视图
得益于示例6-4中指定的触发器,该触发器声明在水印通过窗口时应实例化窗口,因此系统能够以渐进方式发出结果,否则管道的无限制输入变得越来越完整。查看图6-10中的流和表版本,它看起来像您期望的那样。
图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所示。
图6-11 在具有提前/准时/延迟触发的流引擎上的窗口加总的事件时间/处理时间视图
而流和表的版本如图6-12所示。
图6-12 具有早期/按时/延迟触发的流引擎上窗口求和的流和表视图
该版本更加清楚了触发器具有的取消分组效果,将表的各个独立部分的演变视图呈现为流,如示例6-6中指定的触发器所指示。
到目前为止,我们已经讨论过的所有具体触发器的语义(事件时间,处理时间,计数,早期/准时/延迟等复合信息)与从流中查看/表透视图,因此不值得进一步讨论。但是,我们还没有花太多时间讨论经典批处理方案中触发器的外观。现在,我们了解了批处理管道的基础流/表拓扑是什么样的,值得简要介绍一下。
归根结底,在经典的批处理方案中,实际上只有一种类型的触发器:一种在输入完成后触发的触发器。对于我们之前看过的MapReduce作业的初始MapRead阶段,假设已假定批处理作业的输入已从该管道完成,则触发器在概念上会在管道启动后立即触发输入表中的所有数据。该输入源表将因此转换为单个元素的流,此后map阶段可以开始处理它们。
对于管道中间的表到流的转换,例如本例中的ReduceRead阶段,使用相同类型的触发器。但是,在这种情况下,触发器实际上必须等待表中的所有数据完成(即,通常将所有数据写入随机播放),就像我们示例中的批处理管道图6-4和6-6在输入最终结果之前等待输入结束。
鉴于经典批处理始终有效地利用了输入数据完成触发器,因此您可能会问到管道作者指定的任何自定义触发器在批处理方案中可能意味着什么。答案实际上是:取决于。有两个方面值得讨论:
触发担保(或缺乏担保)
在设计现有的大多数批处理系统时,要牢记这一锁定步骤:“读取-处理-组-写入-重复”序列,在这种情况下,很难提供任何更细粒度的触发功能,因为它们只会出现任何变化都将在管道的最后洗牌阶段进行。但是,这并不意味着用户未指定触发器。触发器的语义使得在适当的情况下可以诉诸于较低的公分母。
例如,AfterWatermark触发器旨在在水印通过窗口末端之后触发。它不能保证水印触发时可能超出窗口末端多远。同样,AfterCount(N)触发器仅保证在三角触发之前至少已处理了N个元素。N很可能是输入集中的所有元素。
请注意,并不是简单地选择触发器名称的这种巧妙措辞来适应模型中的经典批处理系统;考虑到触发的自然异步性和不确定性,这是模型本身非常必要的部分。即使在经过微调,低延迟的实时流系统中,也基本上不可能保证AfterWatermark触发器将在水印正好在任何给定窗口的末尾触发时触发,除非在最极端的情况下(例如,一台机器以相对较小的负载处理流水线的所有数据。即使可以保证,真正的意义是什么?触发器提供了一种控制数据从表到流的方式,仅此而已。
批处理和流式混合
鉴于我们在本文中学到的知识,应该清楚的是,批处理和流系统之间的主要语义差异是能够增量触发表。但这并不是真正的语义差异,而是更多的延迟/吞吐量折衷(因为批处理系统通常以更高的结果延迟为代价为您提供更高的吞吐量)。
这可以回溯到我在第7页上的“批处理和流式处理效率差异”中所说的:如今,批处理和流式处理系统之间实际上并没有太大区别,除了效率差异(有利于批处理)和自然的交易能力具有无限制的数据(有利于流式传输)。然后我认为,效率差异的很大一部分来自更大的捆绑包大小(延迟显着折衷,有利于吞吐量)和更有效的改组实现(即流→表→流转换)的组合。从这个角度来看,应该有可能提供一种可以无缝集成两个方面的系统:一个系统可以自然处理无限数据,还可以在广泛的范围内平衡延迟,吞吐量和成本之间的紧张关系。通过透明地调整包的大小,改组实现以及其他类似的实现细节来对使用案例进行分析。
这正是Apache Beam在API级别上已经做的事情。12这里提出的论据是,在执行引擎级别上,还有统一的空间。在这样的世界中,批处理和流式传输将不再是一回事,我们将能够一劳永逸地告别批处理和流式传输作为独立的概念。我们将只拥有通用的数据处理系统,该系统结合了家谱中两个分支的最佳创意,可以为手头的特定用例提供最佳体验。有一天。
此时,我们可以将叉子插入触发器部分。完成。我们对光束模型与流表理论(累积)之间的关系有了整体看法时,只停留了一个简短的停留。
方式:积累
在第二章中,我们了解到三种累积模式(丢弃,累积,累积和缩回13)告诉我们,当窗口在其整个生命周期中多次触发时,结果的细化是如何关联的。幸运的是,这里与流和表的关系非常简单:
- 放弃模式要求系统在触发时丢弃窗口的上一个值,或者保留前一个值的副本,并在下次窗口触发时计算增量。14(此模式最好称为Delta模式。)
- 累积模式不需要额外的工作;触发时表中窗口的当前值是发出的值。(最好将此模式称为“值”模式。)
- 累积和缩回模式要求保留该窗口的所有先前触发(但尚未缩回)值的副本。在合并会话之类的窗口的情况下,此先前值列表可能会变得非常大,但是对于无法简单地使用新值覆盖先前值的情况,对于完全还原那些先前触发触发的效果至关重要。(最好将此模式称为“值和撤消”模式。)
累积模式的流表可视化对它们的语义几乎没有任何其他了解,因此在此我们将不再对其进行研究。
梁模型中的流和表的整体视图
解决了四个问题后,我们现在可以对Beam Model管道中的流和表进行整体查看。让我们来看一个正在运行的示例(团队对计算管道进行评分),并在流和表级别查看其结构。管道的完整代码可能类似于示例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之间的三个主要区别:
逻辑操作与物理操作
作为构建物理执行计划的一部分,底层引擎必须将用户提供的逻辑操作转换为引擎支持的一系列原始操作。在某些情况下,这些物理等效项看起来基本相同(例如,解析),而在另一些情况下,它们却大不相同。
物理阶段与融合
在管道中将每个逻辑阶段作为一个完全独立的物理阶段执行通常效率低下(每个阶段之间都伴随着序列化,网络通信和反序列化开销)。结果,优化器通常会尝试将尽可能多的物理操作融合到一个物理阶段。
键,值,窗口和分区
为了更清楚地表明每个物理操作在做什么,我对中间的PCollection进行了注释,并在每个点上都使用了有效的键,值,窗口和数据分区的类型。
现在,让我们详细遍历每个逻辑操作,看看它在物理计划中转换为什么,以及它们与流和表的关系如何:
ReadFromSource
除了与紧随其后的物理操作(解析)相融合之外,ReadFromSource的翻译没有太多有趣的事情发生。就目前数据的特性而言,由于读取本质上是在消耗原始输入字节,因此我们基本上具有无键,无窗口和无(或随机)分区的原始字符串。原始数据源可以是表(例如Cassandra表)或流(例如RabbitMQ),也可以是两者都有点(例如日志压缩模式下的Kafka)。但是无论如何,从输入源读取的最终结果是一个流。
解析
逻辑分析操作还以相对直接的方式转换为物理版本。解析将获取原始字符串,并从中提取键(团队ID)和值(用户分数)。这是一项非分组操作,因此消耗的流仍然是另一侧的流。
窗口+触发
这种逻辑运算分布在许多不同的物理运算中。第一个是窗口分配,其中每个元素都分配给一组窗口。这会立即在AssignWindows操作中发生,这是一个非分组操作,它仅使用其现在所属的窗口对流中的每个元素进行注释,从而在另一侧生成另一个流。
第二个是窗口合并,这是我们在本章前面了解的,它是分组操作的一部分。这样一来,它就可以在稍后的管道中沉入GroupMer geAndCombine操作中。接下来,当我们讨论逻辑求和运算时,我们将讨论该运算。
最后,触发。触发是在分组之后发生的,这是我们将通过分组重新创建的表转换为流的方式。因此,它陷入了GroupMergeAndCombine之后的自己的操作中。
和
求和实际上是一个复合操作,由两部分组成:分区和聚合。分区是一种非分组操作,它以一种方式来重定向流中的元素,以使具有相同键的元素最终进入同一台物理计算机。分区的另一个词是改组,尽管该术语有点重载,因为MapReduce含义中的“随机”通常用于表示分区和分组(就此而言,是排序)。无论如何,分区在物理上改变了流的方式,使其可分组,但实际上并没有采取任何措施使数据静止。结果,这是一个非分组操作,在另一侧产生了另一个流。
分区后进行分组。分组本身是一个复合操作。首先是按键分组(由先前的按键分区操作启用)。如前所述,接下来是窗口合并和按窗口分组。最后,由于求和是在Beam中通过CombineFn实现的(本质上是增量聚合操作),因此可以进行合并,其中各个元素在到达时就被汇总在一起。对于我们的目的而言,具体细节并不十分重要。重要的是,由于这(显然)是分组操作,因此我们的信息流链现在放在一个表中,该表包含随着时间推移而变化的团队总数。
写到接收器
最后,我们具有写操作,该操作将通过触发产生的流(您可能还记得,它沉没在GroupMergeAndCombine操作之下)并将其写到我们的输出数据接收器中。该数据本身可以是表或流。如果是表,则WriteToSink将需要执行某种分组操作,这是将数据写入表的一部分。如果是流,则无需分组(尽管可能仍需要分区;例如,在写入诸如Kafka之类的文件时)。
这里的主要收获不只是物理计划中正在发生的所有事情的精确细节,而是光束模型与流和表世界的整体关系。我们看到了三种类型的操作:非分组(例如Parse),分组(例如GroupMergeAndCombine)和取消分组(例如Trigger)。非分组操作始终在另一侧消耗流并生成流。分组操作始终消耗流并产生表。取消分组操作消耗了表并产生了流。这些见解以及我们在此过程中学到的所有其他知识,足以使我们形成关于梁模型与流和表之间关系的更一般的理论。
流与表相对论的一般理论
在调查了流处理,批处理,四个“什么/何处/何时/如何”问题以及波束模型作为整体与流和表理论之间的关系之后,现在让我们尝试阐明流和表相对性的更一般定义。
流和表相关性的一般理论:
- 数据处理管道(批处理和流处理)包括表,流以及对这些表和流的操作。
- 表是静止的数据,并充当数据累积和随时间观察的容器。
- 流是运动中的数据,并编码表随时间变化的离散视图。
- 操作作用于流或表并产生新的流或表。它们分类如下:
- 流→流:非分组(按元素)操作
对流应用非分组操作会更改流中的数据,同时使它们保持运动状态,从而产生具有不同基数的新流。
- 流→表:分组操作
在流中对数据进行分组会使这些数据静止不动,从而产生随时间变化的表。
- 窗口化将事件时间的维度纳入此类分组。
- 合并的窗口会随着时间的流逝而动态地组合在一起,从而允许它们根据观察到的数据重塑自身,并指示该键仍然是原子性/并行化的单位,而窗口是该键内分组的子组件。
- 表→流:取消分组(触发)操作
触发表格中的数据会将它们分解为运动,从而产生一个流,该流捕获表格随时间变化的视图。
- 水印提供了与事件时间相关的输入完整性的概念,这在触发带有事件时间戳的数据(尤其是从无限制流分组到事件时间窗口中的数据)时非常有用。
- 触发器的累积模式确定流的性质,指示流是否包含增量或值,以及是否提供对先前增量/值的撤消。
- 表格→表格:(无)
没有消耗表并产生表的操作,因为数据不可能在不移动的情况下从静止状态回到静止状态。结果,对表的所有修改都是通过转换为流并再次返回。
我喜欢这些规则,因为它们很有意义。他们对它们具有非常自然和直观的感觉,因此,它们使通过一系列操作了解数据如何流动(或不流动)变得非常容易。他们编纂了这样一个事实,即数据在任何给定时间(流或表)都以两种构造之一存在,并且为推理这些状态之间的转换提供了简单的规则。他们通过展示如何对每个人已经天生理解的东西(即分组)进行微小的修改来使窗口神秘化。他们强调了为什么分组操作通常始终是流式处理的症结所在(因为它们将流中的数据作为表放到表中),但也很清楚地说明了需要进行哪些类型的操作才能使内容不再卡滞(触发;即对操作进行分组) -)。他们强调了从概念上讲,统一的批处理和流处理的真正意义。
当我着手编写本章时,我不确定要结束什么,但是最终结果比我想象的要令人满意的多。在接下来的章节中,我们将一再使用流和表相关性这一理论来帮助指导我们的分析。每次,它的应用都会带来清晰度和洞察力,否则这些东西将很难获得。流和表是最好的。
概括
在本章中,我们首先建立了流和表理论的基础。我们首先相对地定义流和表:
流→表格
随着时间的推移,更新流的聚合产生一张表。
表→流
观察表随时间的变化会产生一个流。
接下来,我们分别定义它们:
- 表是静止数据。
- 流是运动中的数据。
然后,我们从流和表的角度评估了批处理计算的经典MapReduce模型,并得出以下四个步骤从该角度描述批处理的结论:
- 完整读取表以使其成为流。
- 将流处理为新流,直到命中了分组操作为止。
- 分组将流变成表格。
- 重复步骤1到步骤3,直到管道中的操作用完为止。
从这一分析中,我们可以看到流和流处理一样是批处理的一部分,而且从所讨论的数据是否有界来看,数据是流的想法是正交的。
接下来,我们花了很多时间考虑流和表之间的关系以及Beam模型提供的健壮的,乱序的流处理语义,最终得出了前面所列举的流和表相关性的一般理论部分。除了流和表的基本定义之外,该理论的主要见解在于,数据处理管道中有四种(实际上只有三种)操作类型:
流→流
非分组(元素方式)操作
流→表
分组操作
表→流
取消分组(触发)操作
表→表(不存在)
通过以这种方式对操作进行分类,了解随着时间的流逝数据如何流经(并在其中徘徊)给我们变得微不足道。
最后,也许是最重要的一点,我们学到了这一点:当您从流和表的角度看事物时,很清楚地知道批处理和流在概念上实际上是同一件事。有界或无界,没关系。它是从上到下的数据流和表格。