欢迎回来!如果你错过了我之前的博文:Streaming 101:批处理之外的流式世界第一部分,我强烈建议你先花时间阅读这篇文章。在这篇文章介绍的内容是下面介绍内容的基础,并且当你阅读这篇文章时,我假设你已经熟悉第一篇文章中介绍的术语和概念了(有些东西在这篇文章不会详细介绍)。现在我们进入正题。先简要回顾一下,上篇文章我主要关注的三个方面:
在这篇文章中,我接着上次进一步介绍数据处理模式,但这次借助具体示例来更详细的介绍。这篇文章主要分为两个章节:
当我们读完这篇文章时,我们会学习到一个具有鲁棒性的乱序数据处理所需的核心原则和概念以及可以实现超越经典批处理系统的时间推理工具。为了让你有直观的感受,我会使用 Dataflow SDK 代码(即 Google Cloud Dataflow 的 API),并结合动画来表达一些概念。使用 Dataflow SDK 而不是你可能更熟悉的 Spark Streaming 或 Storm 的原因是,目前几乎没有其他系统可以提供我想要表达所有示例的能力。好消息是其他项目也开始朝这个方向发展。更好的消息是,我们(谷歌)今天向 Apache 软件基金会提交了一份提案,来创建一个 Apache Dataflow 孵化器项目(与 data Artisans、Cloudera、Talend 和其他一些公司合作),希望围绕数据流模型提供的强大的乱序处理语义建立一个开放的社区和生态系统。
很抱歉,这篇文章中缺少了我上次承诺的比较部分。我低估了这篇文章中包含的内容以及需要完成的时间。我不想为了完成这个一部分再看到时间上的延迟以及再做一些其他扩展。如果有什么安慰的话,我在 Strata + Hadoop World Singapore 2015 上做了一个 The evolution of massive-scale data processing 演讲(并将在 6 月的 Strata + Hadoop World London 2016 上给出它的更新版本),这里面会提供缺失的比较部分的资料;同时,会奉上一个精美的幻灯片。
在Streaming 101中,我们首先明确了一些术语。我们先区分了有限数据和无限数据。有限数据源的大小是有限的,通常被称为 ‘batch’ 批数据。无限数据源一般大小都是无限的,并且通常被称为 ‘streaming’ 流数据。我会尽量避免使用批和流术语来指代数据源,因为这些名称会让我们产生误解。然后,我们继续定义了批处理引擎和流处理引擎之间的区别:批处理引擎是那些仅为有限数据设计的引擎,而流处理引擎在设计时考虑到了无限数据。我的目标是只在谈及执行引擎时才使用批和流这样的术语。
在介绍完术语之后,我介绍了两个与处理无限数据有关的重要概念。首先明确了事件时间(事件发生的时间)和处理时间(处理期间观察到时间)之间的重要区别。这为Streaming 101提出的主要论点之一提供了基础:如果你关心正确性和事件实际发生的上下文,那么必须根据事件固有的事件时间来分析数据,而不是用它们在分析过程中的处理时间。最后我介绍了窗口的概念(即,将数据集按时间边界划分),这是处理无限数据源的一种常见方法。窗口策略中比较简单的是固定窗口的和滑动窗口,也还有更复杂的窗口类型,例如会话窗口(窗口由数据本身的特征定义,例如捕获每个用户的活动会话)。
除了这两个概念之外,我们还需要仔细研究一下如下三个概念:
最后,为了更好的理解这些概念之间的关系,我们可以在回答下面四个问题的过程中温故知新,这些问题对于无限数据处理来说是至关重要的:
首先,让我们回顾一下在Streaming 101中提出的一些概念,但是这次会提供一些详细的例子,这些例子将有助于我们更好的理解这些概念。
经典的批处理应用中的 transformations 回答了第一个问题:计算逻辑是什么?尽管你们可能对经典的批处理已经很熟悉了,但是我们还是从这里开始,因为它是我们添加所有其他的概念的基础。
在本节中,我们会看到一个简单的例子:在由 10 个值组成的简单数据集上分 Key 计算 SUM。对于每个示例,我都会提供一个 Dataflow Java SDK 伪代码的简短片段。从某种意义上说,这是伪代码,有时我会略作修改以使示例更清晰、也会省略一些细节(比如使用具体的I/O源)以及简化名称(Java 中当前的触发器名称非常冗长;为了清晰,我将使用更简单的名称)。除了诸如此类的小事之外,基本上都是真实的 Dataflow SDK 代码。后面我还会为那些对类似例子(可以编译和运行)感兴趣的人提供一个实际代码演练的链接。
如果你熟悉 Spark Streaming 或 Flink 等类似的工具,那么很容易理解 Dataflow 的代码。Dataflow 中有两个基本原语:
图1
就我们的例子而言,我们假定从名为 ‘input’ 的 PCollection<KV<String,Integer>>
(PCollection 由 Strings 和 Integer 的键/值对组成,其中 Strings 像是团队名称,Integer 则是对应每个团队成员分数)开始。在现实世界的 Pipeline 中,我们从来自 I/O 数据源的原始数据(例如,日志记录) PCollection 来获取输入,然后将日志记录解析为键/值对,并转换为 PCollection< KV<String,Integer>>
。在为了第一个例子更加清晰,我会包含所有步骤的伪代码,但在随后的例子中,我会忽略 I/O 和解析部分。因此,简单地从 I/O 源读取数据,解析出团队/分数,并计算每个团队总分数的 Pipeline 如下所示:
// 代码1
PCollection<String> raw = IO.read(...);
PCollection<KV<String, Integer>> input = raw.apply(ParDo.of(new ParseFn());
PCollection<KV<String, Integer>> scores = input.apply(Sum.integersPerKey());
上述代码从 I/O 数据源读取键/值对数据,其中 Key 是 String 类型的团队名称,Value 是 Integer 类型的团队每个成员分数。然后根据 Key 分组求和产生每个团队的总分数。
对于接下来的所有例子,我们都会先看 Pipeline 的代码,然后再用动画展示一个数据集的运行过程。更具体地说,我们会看到一个 Key 的 10 条输入数据的执行过程。在一个真正的 Pipeline 中,你可以想象类似的操作会在多台机器上并行执行,但是在这里会尽量简化。每个动画都有两个维度:X 轴上的事件时间和 Y 轴上处理时间。白色的粗线条从底部向上移动表示 Pipeline 的实时进度,圆圈表示输入,圆圈内的数字表示记录的值。当观察到它们时,它们由之前的灰色变成白色,同时将它们累加在状态中,并最终将聚合结果输出。状态和输出由矩形表示,靠近顶部展示一个聚合值,矩形覆盖的区域表示部分事件时间和处理时间已经累加到结果中。对于上述代码中的 Pipeline,在经典的批处理引擎上执行时看起来就像下面一样:
由于这是一个批处理 Pipeline,因此会累积状态,直到所有输入完成(到达顶部的绿色虚线时表示看到所有的输入),此时产生最终的结果为 51。在这个示例中,因为我们没有使用任何窗口转换操作,所以我们是在所有事件时间上计算的总和,因此状态和输出的矩形覆盖了整个 X 轴。如果我们处理的是一个无限数据源,那么经典的批处理是不够的。我们不能等待所有输入结束,因为输入永远不会结束。在这里我们需要使用窗口这个概念,上篇文章中已经介绍了这个概念。因此,在回答第二个问题之前:计算的事件时间范围?,先我们简要回顾一下窗口这个概念。
正如上次讨论的那样,窗口是沿着时间边界分割数据源的过程。常见的窗口策略有固定窗口、滑动窗口和会话窗口:
图3
为了更好地在实践中理解在窗口,我们以 2 分钟的固定窗口求和为例。使用 Dataflow SDK 比较简单,添加 Window.into 转换操作即可:
// 代码2
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2))))
.apply(Sum.integersPerKey());
Dataflow 提供了一个适用于批处理和流式传输的统一模型(语义上批处理只是流式处理的一种特殊情况)。因此,我们先在批处理引擎上执行这个 Pipeline;机制比较简单,可以与切换到的流处理引擎直接进行比较。
和以前一样,输入在状态中累积,直到所有输入完成,最后才输出最终结果。在这种情况下,我们会得到四个输出:四个两分钟事件时间窗口都有对应的一个输出。
到目前,我们重新回顾了 Streaming 101 中引入的两个概念:事件时间和处理时间之间的关系以及窗口。如果我们想继续探讨,我们需要了解本节涉及的新概念:Watermark、Triggers 和累积模式。
我们刚才观察到的是在批处理引擎上执行一个窗口的 Pipeline。但理想情况下,我们希望结果具有较低的延迟。切换到流式引擎是才是正确的方向,对于批处理引擎我们都知道,每个窗口的输入都是完整的(即一旦有限输入源中的所有数据都已被消费),但是对于无限数据源,我们目前缺乏确定其完整性的实际方法。因此引入了 Watermarks。
Watermark 回答了 “什么时候(处理时间)输出结果?” 的前半部分。Watermark 是一个事件时间概念,用来衡量输入数据的完整性。换一种说法,它是系统以事件时间为尺度衡量记录在事件流中处理的进度和完整性的方法(无论是有限还是无限数据,尽管在无限数据中作用更明显)。回想一下 Streaming 101 中这张图(在这里稍作了修改),对于大多数现实世界分布式数据处理系统,事件时间和处理时间之间的偏差可以用随时间不断变化的函数表示。
图5
上图中那条弯弯曲曲的红线实际上就是 Watermark,可以随着处理时间的推移能够获取事件时间完整性的进度。从概念上讲,可以将 Watermark 看作是一个函数 F(P) -> E
,输入一个处理时间点输出一个事件时间点。(更准确地说,函数的输入实际上是 Pipeline 中观察到 Watermark 时间点上游所有的当前状态:输入源,缓冲的数据,正在处理的数据等;但从概念上讲,可以简单的理解为处理时间到事件时间的映射)。事件时间点 E 表示事件时间小于 E 的那些输入数据都已经被看到了。换句话说,我们断言不会再看到事件时间小于 E 的其他数据了。根据 Watermark 的类型,完美 Watermark 和启发式 Watermark 会分别提供严格保证和有依据的猜测:
Watermark 是一个非常吸引人且复杂的话题,但是它不是这篇文章讨论的重点,以后有机会专门写一篇文章来介绍。现在,为了更好地理解 Watermark 的作用和缺点,我们来看看两个使用 Watermark 流引擎的例子,以确定在执上述代码的窗口化 Pipeline 时何时输出结果。左边的例子使用的是完美 Watermarks,右边使用的是启发式 Watermarks。
图6
在这两种情况下,当 Watermark 到达窗口结尾时,窗口就会被触发计算。两次执行的主要区别在于右侧的 Watermark 计算使用的是启发式算法没有考虑到 9 这个值,这很大程度上改变了 Watermark 的形状。这两个例子突出了 Watermark (以及其他的完整性概念)的两个缺点:
在 Streaming 101 中,我就强调完整性不足以解决无限数据流的乱序问题。Watermark 太慢和太快这两个缺点,是这个论点的理论依据。你不能寄希望系统只依赖完整性就能获得低延迟和正确性。触发器就是为了解决这些缺点而引入的。
触发器(Triggers)回答了 “什么时候(处理时间)输出结果?” 的后半部分。触发器决定了窗口在处理时间上什么时候输出(尽管触发器本身可以根据其他时间概念作出上述决策,例如基于事件时间的 Watermark 处理)。窗口的每个特定输出都称为窗口的窗格(pane)。有几种触发的信号,如下所示:
除了基于具体信号触发的简单触发器之外,还有复合触发器,可以允许创建更复杂的触发逻辑。复合触发器如下所示:
为了更具体的了解触发器,我们将上述代码 2 中的隐式触发器显示添加到代码中:
// 代码3
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(AtWatermark()))
.apply(Sum.integersPerKey());
考虑到我们已经对触发器有了基本了解,现在我们可以考虑解决 Watermark 太慢以及太快的问题。在这两种情况下,我们希望为窗口提供某种周期性的更新,无论是 Watermark 到达窗口结尾之前还是之后(不仅仅是在 Watermark 通过窗口的结尾时触发更新)。所以,我们需要某种重复触发器。那么问题就变成了:重复触发的条件是什么?
在太慢的情况下:由于输出太晚,所以要提早提供推测结果。我们假定任何窗口都有稳定的输入数据量,我们知道窗口输入是不完整的,还有数据尚未到达。因此,按照处理时间周期性(例如,每分钟一次)触发可能是一种明智的做法。因为触发器触发的次数不会取决于窗口内观察到的实际数据量,在最坏的情况下,也就是源源不断的周期性触发。
在太快的情况下:使用启发式 Watermarks 会出现迟到数据,所以当出现迟到数据时需要修正结果。假设我们的 Watermark 基于相对准确的启发式算法(通常是合理安全的假设)。在这种情况下,不会有过多的迟到数据,但是当看到迟到数据时,需要快速修正我们的结果。只要看到一个迟到元素时就要立即触发更新。考虑到这种迟到数据不会太多,不会对我们系统的负载产生太大影响。请注意,这里只是举了一个例子,你可以根据自己的情况自由选择触发的条件(比如只在上面某一种情况下触发,或者都不触发)。
最后,我们需要协调各种触发的时机:提前、准时或者迟到。我们可以通过顺序触发器和一个特殊的 OrFinally 触发器来完成这个工作,OrFinally 触发器有一个子触发器,当子触发器触发时会终止父触发器。
// 代码4
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(
Sequence(
Repeat(AtPeriod(Duration.standardMinutes(1))).OrFinally(AtWatermark()),
Repeat(AtCount(1))
)
)
.apply(Sum.integersPerKey());
但是,这种写法很罗嗦。考虑到 repeated-early | on-time | repeated-late
触发模式非常常见,所以我们在 Dataflow 中提供了一个自定义的(但是语义上相同的)API,使得指定这样的触发器更简单清晰:
// 代码5
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(
AtWatermark()
.withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))
.withLateFirings(AtCount(1))))
.apply(Sum.integersPerKey());
在流式引擎上执行上述两段代码(与之前一样使用完美 Watermarks 和启发式 watermarks),然后生成如下所示的结果:
图 7
这个版本比图 6 有两个明显的改进:
这些新触发器的让人兴奋的作用是有效地统一了完美和启发式 Watermark 版本之间的输出模式。虽然图 6 中的两个版本截然不同,但这里的两个版本看起来已经非常相似了。此时剩下最大的差异是窗口生命周期。在完美 Watermark 情况下,我们知道一旦 Watermark 到达了窗口结尾,我们再也不会看到窗口的其他任何数据,因此我们可以在这个时候删除窗口的所有状态。但在启发式 Watermark 的情况下,我们仍然需要保留一段时间的窗口状态,以解决迟到数据。但是到目前为止,我们的系统还没有任何好的方法可以知道每个窗口的状态需要保留多长时间。这里我们需要引入’可允许的迟到’这个概念。
在进入最后一个问题’如何修正相关结果?’之前,我们先讨论处理长期无序数据数据流系统必备的一个功能:垃圾回收。图 7 的启发式 Watermark 例子中,窗口的状态在该示例的整个生命周期内都会保存。为了处理迟到数据,这么做是有必要的。虽然将我们所有的持久状态保存到时间结束时是一件很棒的事情,但是,在实际环境中,当处理无限数据源时,无限期地保存窗口状态(包括元数据)是不切实际的,最终会耗尽磁盘空间。
因此,任何现实世界的无序处理系统都需要提供某种方法来限制窗口的生命周期。一种简单的方法是在系统内定义可允许的迟到时间范围,即需要对记录可能迟到多长时间(相对于 Watermark)设置一个上限,以便系统进行处理;在此范围之后到达的任何数据都会被简单地丢弃。一旦确定了单条数据的迟到时间,你就能精确地确定窗口的状态必须保存多长时间:直到 Watermark 超过窗口结束后的可迟到时间范围。此外,还允许系统立即删除观察到任何晚于可迟到时间范围内的数据,这意味着系统不会浪费资源处理没人关心的数据。
由于可允许的迟到时间范围与 Watermark 之间的交互有点微妙,所以我们需要看一个例子。我们在代码 5 中添加一分钟的可允许的迟到时间范围(请注意,这里选择这个迟到时间范围是因为它比较适合图表展示,但在实际用例中,迟到时间范围可能会有更大):
// 代码6
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(
AtWatermark()
.withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))
.withLateFirings(AtCount(1)))
.withAllowedLateness(Duration.standardMinutes(1)))
.apply(Sum.integersPerKey());
上述代码的执行类似于下图 8 所示,在图中我添加了如下功能来突出可允许的迟到时间范围的影响:
关于可允许的迟到时间范围最后有两个注意点:
随着时间的推移,触发器会为一个窗口产生多个窗格。到这,我们剩最后一个问题:如何修正相关结果?在我们目前看到的例子中,每个连续的窗格都建立在它前面的窗格之上。实际上存在三种不同的累积模式:
三种不同的累积模式放在一起对比查看时,不同模式的不同语义会更加清晰。我们以图 7 中第二个窗口为例,该窗口出现了三个窗格(事件时间范围为 [12:02, 12:04))。下表展示了在三种累积模式下每个窗格的值是什么样的(图 7 使用的是累积模式):
表1
如果要查看丢弃模式的实际效果,我们需要对代码 5 做如下修改:
// 代码7
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(
AtWatermark()
.withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))
.withLateFirings(AtCount(1)))
.discardingFiredPanes())
.apply(Sum.integersPerKey());
在带有启发式 Watermark 的流引擎上再次运行会产生如下输出:
虽然输出的整体形状类似于图 7 中的累积模式,但需要注意的是丢弃模式下任何窗格都没有重叠。因此,每个输出都与其他输出是相互独立的。如果我们想查看实际的撤回效果,修改也是相似的(但是请注意,此时 Google Cloud Dataflow 的撤回仍在开发中,因此 API 中的命名有些推测):
// 代码8
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2)))
.triggering(
AtWatermark()
.withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))
.withLateFirings(AtCount(1)))
.accumulatingAndRetractingFiredPanes())
.apply(Sum.integersPerKey());
在流引擎上运行,会产生如下输出:
由于每个窗格都有重叠,因此要清楚地看到撤回有点棘手,因此我们用红色表示撤回,由于与背景蓝色窗格重叠会产生略带紫色的颜色。为了更容易的区分两个值,我稍微调整了下两个数值的位置并把它们以逗号分隔。把图 9、7(仅看启发式)和 10 的最终帧放在一起比较,可以更好的看出三种模式的区别:
图11
可以预想到,按顺序呈现的三种模式(丢弃、累积、累积和收回)在存储和计算成本方面都越来越贵。因此,累积模式的选择是在正确性、延迟和成本三方面的一种权衡。
到目前为止,我们已经回答了所有四个问题:
上面我们上只研究了一种窗口:基于事件时间的固定窗口。从 Streaming 101 中我们知道,窗口有好几种类型,在结束之前我们一起看一下这两种窗口:处理时间固定窗口和事件时间会话窗口。
处理时间窗口很重要,原因有两个:
因此,深入了解处理时间窗口和事件时间窗口之间的差异是非常值得的,特别是考虑到当今大多数流系统中处理时间窗口被广泛应用。当我们面对的模型是严格使用事件时间时(例如本文的例子),有两种方式可以实现处理窗口:
需要注意的是,这两种方法或多或少是等价的,尽管在多阶段(Stage) Pipeline 的情况下会略有不同:
正如我已经注意到的那样,处理时间窗口的最大缺点是当输入的顺序发生变化时窗口的内容也会发生变化。为了更具体地说明这一点,我们将研究如下三个用例:
我们会在这三种用例上分别使用两个不同的数据集(所以,一共会有2*3种情况)。这两个输入集有完全相同的事件(相同的值,相同的事件时间),但是观察到顺序不同(即处理时间不同)。第一个输入集是我们上面一直看到的观察顺序,颜色为白色;第二个输入集在处理时间轴上做了一些移动(改变了处理时间),如下图 12 所示,颜色为紫色。
为了建立一个基线,我们首先在启发式 Watermark 的事件时间固定窗口上分别观察这两个输入集的输出结果。我们重用代码 5/图 7 中的 early/late 代码来获得下面的结果。左边基本上是我们之前看到的样子;右边是第二个输入集的结果。这里需要注意的一点是:即使输出的整体形状不同(在处理时间上观察的顺序不同),但四个窗口的最终输出结果都是一样的:14、22、3 和 12:
图13
现在让我们对比一下上述两种处理时间的方法。首先,我们看一下如何使用触发器实现,需要注意三个方面:
相应的代码类似于代码 9;需要注意的是,全局窗口是默认设置,因此不需指定窗口策略:
// 代码9
PCollection<KV<String, Integer>> scores = input
.apply(Window.triggering(
Repeatedly(AtPeriod(Duration.standardMinutes(2))))
.discardingFiredPanes())
.apply(Sum.integersPerKey());
当使用两种不同顺序的输入数据在流式引擎上执行时,结果如下图 14 所示。需要注意的是:
图14
最后,让我们看看通过将摄入时间映射输入数据的事件时间来实现处理时间窗口。代码方面,这里有四个方面值得一提:
因此,实际代码如下所示:
// 代码10
PCollection<String> raw = IO.read().withIngressTimeAsTimestamp();
PCollection<KV<String, Integer>> input = raw.apply(ParDo.of(new ParseFn());
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(FixedWindows.of(Duration.standardMinutes(2))))
.apply(Sum.integersPerKey());
在流式引擎上执行如下图 15 所示。当数据到达时,事件时间会用摄入时间更新(即到达系统时的处理时间),从而导致向右水平移动到理想的 Watermark 线上。需要注意的是:
图15
我们可以看到实现处理时间窗口可以有不同的方式,但这里最大的收获是我自第一篇文章以来一直在强调的:事件时间窗口与顺序无关(在输入完成之前,实际的窗口会不断变化);然而处理时窗口不是这样的。如果你关心事件实际发生的时间,则必须使用事件时间窗口,否则你的结果将会毫无意义。
我们现在要看看我最喜欢的功能之一:动态的、数据驱动的窗口,称为会话窗口。会话窗口是一种特殊类型的窗口,会捕获数据中的一个活动周期(由不活动的间隔时间划分不同的活动周期)。这在数据分析中特别有用,因为可以提供用户在特定时间段内参与的某些活动。在会话中看到关联的活动,并根据会话的长度推断参与程度等。从窗口的角度来看,会话窗口在两个方面特别有趣:
对于某些用例,可以提前使用通用标识符对单个会话中的数据进行打标。在这种情况下,会话更容易构建,因为基本上只要按照 Key 分组就好了。然而,在更一般的情况下(即,实际会话本身并不提前知道),会话必须仅根据在时间范围上的位置来构建。在处理乱序数据时,这变得特别棘手。
提供一般会话的关键是,根据定义,完整的会话窗口是一组较小的重叠窗口的组合,每个窗口包含一条记录,序列中的每条记录与下一条记录之间的间隔不超过预定义的超时时间。因此,即使我们观察到会话中的有乱序数据,我们也可以简单地通过将重叠的窗口合并在一起来构建最终会话,以便在单个数据到达时将它们合并在一起。
图16
让我们看一个示例,使用代码 8 中启用了撤回的 early/late 代码,并改为会话窗口:
// 代码11
PCollection<KV<String, Integer>> scores = input
.apply(Window.into(Sessions.withGapDuration(Duration.standardMinutes(1)))
.triggering(
AtWatermark()
.withEarlyFirings(AtPeriod(Duration.standardMinutes(1)))
.withLateFirings(AtCount(1)))
.accumulatingAndRetractingFiredPanes())
.apply(Sum.integersPerKey());
在流引擎上执行,你会得到如下图 17 所示的内容:
图17
这里做了很多事情,所以我与你们一起看一下:
这非常强大。真正令人敬畏的是,在一个模型中描述这样的事情是多么的容易,该模型将流处理的维度分解为不同的、可组合的部分。最后,你只需要更多地关注手头的业务逻辑,不用关注将数据塑造成某种可用形式的细节。如果你不相信我,可以查看这篇博文:如何在 Spark Streaming 上手动建立会话(请注意,这样做并不是为了指责他们做的不好;Spark 的人在其他所有方面都做得很好)。
到此,我已经完成了所有示例。你现在已经深入了解强大的流处理的基础,并准备好走向这个流处理世界并做出一些令人兴奋的事情。但在你离开之前,我想快速回顾一下我们所涵盖的内容,以免你匆忙忘记其中的任何内容。首先,我们涉及的主要概念:
其次,我们用来构建框架的四个问题(我承诺这是最后一次说它们):
第三,也是最后一点,为了将这种流处理模型所提供的灵活性带回家(因为最后,这才是真正的意义:权衡正确性、延迟和成本三者之间的关系),回顾一下:我们只要修改一点点代码就能实现在相同数据集上各种不同的产出:
图18