在深入讨论后续内容之前,我想先明确一个概念:什么是流式(streaming)?如今,流式这个术语可以用来描述各种不同的概念(为简单起见,到目前为止我一直在比较宽泛地使用这个术语),这种情况会导致人们对“什么是流式”或者“流式系统能解决什么问题”产生误解。因此,我更愿意将这个术语定义得精确一些。
问题的关键在于,许多应该通过“是什么”来描述的概念(如无界数据处理、近似结果等),已经被非正式地描述为它们之前是“如何”(如通过流式执行引擎)形成的。术语缺乏精确性使流式的真正含义变得不清晰,在某些情况下,这种不清晰会给流式系统本身造成不良的影响,会使人们认为它们的功能局限于历史上所描述的“流式”特征,如只能提供近似的或者推测性的结果。
考虑到设计良好的流式系统与现有的任意批处理引擎一样能够(技术上更是如此)产生正确、一致且可重现的结果,我倾向于单独赋予“流式”这个术语一个非常具体的含义。
一类为无限的数据集而设计的数据处理引擎。
如果我想谈论低延时、近似的或推测性的结果,我会使用这些具体的词汇,而不是不精确地称它们为“流式”。
你会遇到讨论不同类型的数据的场景,这时精确的术语同样可以起到很好的作用。从我的角度来看,定义一个给定数据集的形态有两个重要的(并且正交的)维度: 基数 (cardinality)和 构成 (constitution)。
数据集的基数决定了数据集的大小,基数最突出的方面是决定给定数据集的大小是有限的还是无限的。我倾向于用以下两个术语描述数据集中的粗粒度基数。
一类大小有限的数据集。
一类大小(至少在理论上)无限的数据集。
基数之所以重要,是因为无限数据集的无界性会对消耗这些无限数据集的数据处理框架造成额外的负担。1.2节将对此进行详细介绍。
另外,数据集的构成决定了数据集的物理呈现方式。因此,数据集的构成定义了人们和所讨论的数据进行交互的方式。在第6章之前,我们不会深入研究数据集的构成,但是为了让你对构成有一个简要的感知,我们先介绍两个重要的基本构成。
表是数据集在某个特定时间点的整体视图。SQL系统传统上是在处理表。
流是数据集逐个元素随时间演变的视图。数据处理系统的MapReduce体系传统上是在处理流。
我们将在第6、8和9章中深入研究流和表之间的关系,还将在第8章中学习将流和表联系在一起的时变关系的统一的底层概念。但在此之前,我们主要讨论流,因为流是现今大多数数据处理系统(包括批处理系统和流式系统)中流水线开发人员直接进行交互的构成。这也是最自然地体现流处理独有的挑战的构成。
接下来讨论一下流式系统能做什么和不能做什么,重点讨论能做什么。我想在本章中阐述的最重要的事情之一就是,一个设计良好的流式系统有多么强大。流式系统在历史上已经被降级至一个能够提供低延时但是输出不准确或推测性结果的小众市场。为了最终能够提供正确的结果,流式系统通常与能力更强的批处理系统结合成一个数据处理架构,即Lambda架构。
如果你不太熟悉Lambda架构,可以将Lambda架构的基本思想理解为在批处理系统旁边运行一个流式系统,二者执行本质上相同的计算。流式系统提供低延时、不准确的结果(要么是因为使用近似算法,要么是因为流式系统本身不提供正确性),随后批处理系统会继续运行并提供正确的输出。Lambda架构最早由推特(Twitter)的Nathan Marz(Storm的作者)提出,最终非常成功,因为它在当时确实是一个了不起的想法。当时流式引擎在正确性上不尽如人意,而批处理引擎又天生稍显笨拙,于是Lambda架构提供了让你“鱼与熊掌兼得”的方法。但是,维护Lambda系统很麻烦——需要构建、提供和维护两个独立版本的流水线,而且在整个流程的最后需要以某种方式将这两个流水线的结果进行合并。
作为多年来致力于强一致性流式引擎的研究人员,我也发现Lambda架构的整个原理有点儿令人厌恶。Jay Kreps的“Questioning the Lambda Architecture”博客文章一出现我就很自然地成了这篇文章的忠实拥护者。这篇文章是当时反对双模式执行的必要性的非常明显的声明之一。令人开心的是,Kreps使用Kafka这样的可重放系统作为流式互联系统,解决了可重复性的问题,进而提出了Kappa架构。Kappa架构的基本思想是使用一个为当前作业量身定制的、设计良好的系统来运行单条流水线。我不确定这个概念是否需要有一个希腊字母名称,但原则上我完全支持这个观点。
我提出过一个观点:效率问题并不是流式系统固有的局限性,而是目前大多数流式系统的设计选择导致的结果。批处理和流式之间的效率差异很大程度上是批处理系统中不断增加的数据捆绑和更高效的混洗传输的结果。现代批处理系统不遗余力地实施复杂的优化,从而允许使用少得惊人的计算资源就能获得显著的吞吐量。没有理由说这些使批处理系统效率大增的奇思妙想不能被运用到一套为无界数据设计的系统中,为用户在我们通常考虑的高延时、较高效率的批处理和低延时、较低效率的流式处理的两种方案中提供灵活的选择。实际上我们在谷歌公司已经用Cloud Dataflow做到了这一点,方法是在统一模型下同时提供批处理运行引擎和流式运行引擎。在我们的使用场景中,我们使用了两套独立的运行引擎,因为正好有两个独立设计的系统针对其特定的使用场景进行了优化。从长远来看,从工程的角度,我希望看到我们将两套独立的系统合并成一套系统,这套系统融合了二者的精华,同时仍然保持让用户选择恰当的效率级别的灵活性。不过,这套系统目前仍未完全实现。老实说,因为有统一的Dataflow模型,将两套独立系统合并成一套系统甚至不是非常必要,所以将两套独立系统合并成一套系统可能永远不会发生。
说实话,我想进一步探讨一些东西。我认为设计良好的流式系统实际上提供了批处理功能的严格超集。模(modulo)运算也许是一个效率增量,应该不需要像现在这样的批处理系统。感谢Apache Flink社区的人们把这个想法铭记在心,构建了一个即使在“批处理”模式下底层也始终是全流式的系统。我太喜欢它了。
所有这一切的必然结果是,流式系统的日趋成熟与能够处理无界数据的稳健框架相结合,将促使Lambda架构慢慢退至它在大数据历史中古董的位置。我相信现在是实现这一目标的时候了。因为要做到这一点,也就是说,要以其人之道,还治其人之身,击败批处理系统,你只需要两样东西——正确性和推断时间的工具。
保证了正确性就可以使流式和批处理平起平坐。从核心上说,正确性可归结为一致性存储。流式系统需要一种随时间对持久状态进行检查点操作的方法(Kreps在他的“Why local state is a fundamental primitive in stream processing”一文中已经谈到了这一点),并且这种方法必须设计得足够好,以便在机器发生故障时保持状态的一致。当Spark Streaming数年前首次出现在大数据场景中时,它就像是黑暗的流式世界中一盏关于一致性的明灯。值得庆幸的是,从那以后情况有了很大的改善,但是需要关注的是,很多流式系统仍然试图在没有强一致性的情况下运行。
因为这一点非常重要,所以再强调一次:强一致性是实现“精确一次”(exactly-once)处理 的必要条件,“精确一次”处理对于正确性是必要条件,而正确性又是任何有希望齐平乃至超越批处理系统功能的必要条件。除非你真的不在乎计算结果,否则我恳请你避免使用任何不提供强一致状态的流式系统。批处理系统不要求提前验证它们是否能够产生正确的结果,不要把时间浪费在不能满足正确性的流式系统上。
如果想了解更多关于在流式系统中获得强一致性所需的内容,建议查看MillWheel、Spark Streaming和Flink snapshotting的论文。这3篇论文都用了大量的篇幅来讨论一致性。Reuven将在第5章中对一致性保证进行深入探讨,如果你渴望了解更多内容,可以在一些文献和其他地方找到关于该主题的高质量信息。
这些工具将帮助你超越批处理。好的时间推断工具对处理不同的事件时间偏差的无界无序数据至关重要。越来越多的现代数据集呈现出上述这些数据特征,而现有的批处理系统(以及许多流式系统)缺乏必要的工具来应对上述特征带来的种种困难(尽管现在情况在快速地变化,甚至在我写这本书的时候它也在变化)。本书的大部分内容会用来解释和关注这一要点的方方面面。
首先,大家需要对时间域的重要概念有基本的了解;然后,我会深入讨论刚才提到的不同的事件时间偏差的无界无序数据;最后,我会在本章的其余几节中讨论使用批处理系统和流式系统进行有界数据处理和无界数据处理的常见方法。
如果需要透彻地讲解无界数据处理,必须对时间相关的域有清晰的理解。在任何数据处理系统中,通常有两个我们关心的时间域。
事件实际发生的时间。
事件在系统中被观测到的时间。
并非所有使用场景都需要考虑事件时间(如果你的场景不需要考虑事件时间,你的工作会变得轻松很多),但很多使用场景需要。这样的示例包括随时间的推移描述用户行为、大多数的计费应用,以及很多类型的异常检测等。
在理想情况下,事件时间和处理时间应该总是相等的,即事件在发生时就立即被处理。但现实情况并非如此,事件时间和处理时间之间的偏差不仅非零,而且通常是受底层输入源、执行引擎和硬件等特征共同影响的一个高度可变的函数。能够影响偏差程度的因素包括以下几个。
● 共享资源限制,如网络拥塞、网络分区或非专用环境中的共享CPU。
● 软件原因,如分布式系统处理逻辑、竞争等。
● 数据本身的特性,如键分布、吞吐量变化或无序变化(例如,一架坐满了乘客的飞机,这些乘客在整个航程中离线使用手机后将手机从“飞行模式”退出)。
因此,如果你绘制任何真实系统中的事件时间和处理时间的进度关系,通常会得到类似于图1-1中实线所示的结果。
在图1-1中,斜率为1的虚线表示处理时间和事件时间恰好相等的理想情况,实线表示实际情况。在本示例中,系统的处理能力在处理时间的前期稍微滞后,在处理时间的中期趋近于理想情况,然后在后期再次稍微滞后。一眼就可以看出,在图1-1中有两种存在于不同时间域的偏差。
图1-1 时间域映射。 x 轴表示系统中事件时间的完整性,即到事件时间中的 X 时刻为止,所有事件时间小于 X 的数据都被系统观测到。 y 轴 [1] 表示处理时间的进度,即数据处理系统在执行操作时观测到的正常时钟时间
[1] 发布“Streaming 101”以来,许多人向我指出,将处理时间放在 x 轴上,将事件时间放在 y 轴上,会更直观。我认同交换两个轴最初会感觉更自然,因为事件时间似乎是处理时间自变量的因变量。然而,这两个变量都是单调的,且密切相关,它们实际上是相互依赖的变量,因此我认为从技术的角度来看,你只需要选择一个轴并坚持下去。数学很难理解(尤其是在美国和加拿大以外的地区,math突然变成了复数形式maths,仿佛要合起伙来整你)。
代表理想情况的虚线和实线之间的垂直距离是处理时间域中的滞后。这段距离告诉你(在处理时间中)事件发生的具体时间点和事件被处理的时间点之间的延迟。这可能是两种偏差中的较自然和直观的一种。
代表理想情况的虚线和实线之间的水平距离是当前时间点流水线中事件时间偏差的量。水平距离告诉你当前时间点流水线落后于理想情况(事件时间)的程度。
实际上,在任何给定的时间点上,处理时间滞后和事件时间偏差是相同的,它们只是看待同一事物的两种方式。 关于滞后/偏差的重要结论是:由于事件时间和处理时间之间的总体映射不是静态的(即滞后/偏差可以随时间任意变化),这意味着,如果你在意事件时间(即事件实际发生的时间),就不能仅在流水线观测到数据的上下文中分析数据。遗憾的是,许多为无界数据设计的系统都曾采用了这样的操作方式。为了应对无界数据集的无限性,这些系统通常提供一些对传入数据进行开窗的概念。我们稍后会深入讨论开窗,开窗在本质上就是将数据集以时间为边界切割成有限个数的片段。如果你在意结果的正确性,并且有兴趣在事件时间的上下文中分析数据,就不能像许多系统那样使用处理时间来定义这些时间边界(即处理时间开窗)。由于处理时间和事件时间之间没有一致的相关性,因此某些以事件时间标识的数据将被归入错误的处理时间窗口(由于分布式系统固有的滞后,以及许多类型输入源的在线/离线性质等),从而在某种程度上抛弃了正确性。我们将在后面几节以及本书其余部分的一些示例中更详细地讨论这个问题。
遗憾的是,当基于事件时间进行开窗时,整体情况也不是很乐观。在无界数据的上下文中,无序且变化的偏差会导致事件时间窗口出现完整性问题:如果缺少处理时间和事件时间之间的可预测映射关系,那么如何确定给定的事件时间为 X 的所有数据均已被观测到呢?对于许多真实的数据源,你完全不能确定数据的完整性,但是现在使用的绝大多数数据处理系统都依赖完整性的概念,这使得它们在应用于无界数据集时处于严重的劣势。
我建议,与其把无界数据划分为最终具有完整性的有限数量的批量信息,不如设计一种让我们可以应对由复杂数据集带来的这种不确定性的工具。新数据必将到达,旧数据可能被撤回或更新,我们构建的任何系统都应该有能力独立地应对这些状况。在这些系统中,完整性的概念只是针对特定的和适当的使用场景进行的便捷性优化,在语义层面上不是必要的。
在详细介绍这种方法之前,我们先讨论一下更有用的背景信息:常见的数据处理模式。