大数据界很早以前就意识到了批处理的不足,实际应用中对于实时查询和流处理的需求越来越迫切。近年来涌现出了很多解决方案,像Twitter的Storm,Yahoo的S4,Cloudera的Impala,Apache Spark, 和 Apache Tez 等。本文试着去研究这些流式处理技术,溯寻这些技术与批量处理以及OLTP/OLAP之间的关联,并探讨如何用一个统一的查询引擎来同时支持流处理、批处理和OLAP。
在Grid Dynamics(作者工作的公司名),我们需要建立一个每天要处理80亿条数据的流式数据处理系统,并且要有良好的容错性和严格的事务约束,数据不允许丢失或者重复。这个系统要作为对已有的基于hadoop系统的补充,hadoop系统的数据延迟以及维护成本都太高了。这个需求以及系统本身都是极具通用性和典型性的,我们提出了一个模型来来抽象这类问题。
工作环境如下图:
可以看出,这是一个非常典型的场景:分布在多个数据中心的应用产生数据,然后被采集系统传递到hdfs上,用hadoop系列工具(MapReduce, Pig, Hive)对原始数据进行聚合和分析,结果储存在hdfs或者NoSQL里,再导入到OLAP数据库或者提供给各种应用使用。我们现在要增加一个流处理引擎(如下图所示),可以对数据进行预处理,这样可以减少hadoop上原始数据的数量并减少重量级批处理作业的数量。流处理引擎要达到以下目的:
- 支持SQL。引擎要支持SQL查询,包括能够实现各种复杂业务逻辑的时间窗连接和聚合函数。该引擎还可以关联已经汇总的静态数据,将来还要实现复杂的多层数据挖掘算法。
- 模块化和灵活性。这并不是说只要发出一个简单的SQL查询就会建立相应的作业流并且自动运行,而是可以相对容易的把多个步骤组装成一个复杂数据处理流程。
- 容错性。严格的容错性是对这个引擎最重要的要求。如下图所示,这个引擎的设计目的之一是要利用分布式数据处理管道来实现连接、聚合、多步操作组合这样的操作,并用一个能够容错的缓冲机制来组合这些管道。这个缓冲机制是模块化的,通过发布/订阅机制管道可以很容易的添加和删除。为了让管道可以是有状态的,应该提供持久化的存储来保存状态检查点。
- 与Hadoop的协作。这个引擎应能整合流式数据和Hadoop数据,它应该是一个HDFS之上的定制的查询引擎。
- 高性能和便于迁移。小规模的集群也应该能具有每秒处理数万条信息的能力。引擎应该紧凑和高效,可以跨数据中心部署。
我们将在下面讨论如何去实现这些目标:
- 首先,我们要研究流数据处理系统,大规模批处理系统和关系查询引擎之间的关联,了解有哪些技术是流处理也用到了的。
- 其次,我们总结出一些构建流处理系统中经常会用到的模式和技术。此外,我们还要在当前和新兴的技术基础上提供一些实施建议。
本文基于Grid Dynamics Labs的一个研究项目。感谢Alexey Kharlamov和Rafael Bagmanov带领的团队,包括 Dmitry Suslov, Konstantine Golikov, Evelina Stepanova, Anatoly Vinogradov, Roman Belous, 和Varvara Strizhkova.
分布式查询基础
显而易见,分布式流数据处理与分布式关系数据库中的查询处理有类似。许多标准的查询处理技术都可以用在流处理上,所以了解分布式查询处理的经典算法,找寻流处理和mapreduce这样的流行范式的异同是非常有用的
分布式查询处理是一个谈论了几十年的老话题了,我们先简单的介绍一下其中的主要技术,以为进一步讨论提供基础。
数据分片和重组
分布式和并行查询都依赖于partitioning(数据分片)技术,通过这种技术把较大的数据集分解成多个单个处理单元可以应付的片段。查询过程分多步组成,每一步都有自己的分片策略,所以shuffling(数据重组)是分布式数据库中非常频繁的操作。
能够支持selection和projection的分片策略会非常复杂(例如,范围查询),我们假设对于流数据过滤,基于hash的分片已经够用了。
分布式join的处理也不容易,在分布式环境中,join操作的并行性是通过数据partitioning来实现的,即,数据被分布到多个处理单元,每个处理单元采用串行连接算法(如嵌套循环连接、排序合并或hash join)来处理它自己的数据。然后合并各个处理单元的结果作为最终结果。
分布式join主要有2种数据partitioning技术:
- Disjoint data partitioning 不相交数据分片
- Divide and broadcast join 分配散布连接
不相交分片是将分片的数据按照连接键再重新分片,每一个处理单元都参加重分片,并获得重分片后的拼接结果。考虑一个例子,R与S的用数字键k连接,然后取模分片(假定数据最初杂乱分布在各个处理单元):
分配散布连接算法如图中所示。这种方法把第一个数据集划分成多个不相交的分片(图中的R1,R2,R3),然后将第二个数据集复制到各个处理单元。在分布式数据库中,数据初始就会分布在多个节点,不会在查询的时候再分配数据。
这种策略适用于一大一小的两个集合连接。在流数据处理系统中,可以使用这种技术连接静态数据和流数据。
GroupBy 查询的处理过程主要靠shuffling,这和MapReduce很相似。比如按照字符键分组统计数字键的和,计算会分组进行:
在这个例子中,计算由两个步骤组成:局部聚合和全局聚合。两个步骤分别对应Map和Reduce过程。局部聚合并非必须的,也可以直接传递、重组然后做全局聚合。
以上所有算法都可以使用一个消息传递的架构风格来实现,例如查询执行引擎,可以被认为是一个消息队列连接的分布式节点网络。这是就和流处理管道的理念很近似了。
管道
在前一节中,我们可以看出许多分布式查询处理算法与消息传递网络很相像。然而,对于流处理这并不是最好的办法:一个查询里的所有操作应该在管道里连续进行,数据处理不应该等待输入完成才能输出,也不需要把结果持久到硬盘。像排序这样的操作与这个概念本质上是不相容的(显然,在输入完成之前排序不可能产生任何输出),但在许多情况下,管道是适用的。一个典型的管道例子如下所示:
在这个例子里,哈希连接算法用了3个处理单元来处理4个数据集:R1,S1,S2,S3。此方法把S1,S2和S3分别建成哈希表,R1放在流里,逐一与S1,S2和S3匹配。在流处理中一般用这种方法来连接流数据和静态数据。
在关系数据库中,连接操作可以通过对称哈希算法或变体来获得管道的效果。对称哈希连接是哈希连接的一种拓展。普通的哈希连接需要至少一个输入是完整的,以此来构建哈希表,才能产生输出结果。而对称哈希连接不需要完整的输入也能够立即产生结果。相对于普通的哈希连接,它给每个输入流都维护了一张哈希表:
一个数据到达时,首先在另一个流的哈希表中查找,如果匹配到了,就会产生一个输出,然后,该数据被加入到它自己的哈希表中。
然而,流中的连接并不是无限制的。很多情况下,连接只能在有限时间窗口或缓冲区中进行,如LFU缓存,里面缓存了流中使用最频繁的数据。对称哈希连接一般用于缓冲数据较多或缓冲区刷新频繁或缓存更新策略不确定的情况。其他情况下,比如缓冲数据是固定的,并不会阻塞处理过程,用简单哈希连接就够了:
注意,在流处理中,经常会涉及到更复杂的流相关算法,其中记录要根据某个度量的值来判断是否匹配,而不是简单的相等条件。这就需要一个更复杂的缓冲系统。
流处理模式
在前面的部分中,我们讨论了一些可用于大规模并行流处理的查询处理技术。因此,在一个概念的水平,分布式数据库中高效的查询引擎可以视作一个流处理系统,反之亦然,流处理系统可以作分布式数据库的查询引擎。Shuffling和pipelining是分布式查询处理的关键技术,消息传递网络很容易实现它们。但也有不同,数据库查询引擎对可靠性的要求并不高,因为一个只读的查询可以随时重启,流系统对事件处理可靠性的要求会更高一些。下面我们会讨论一些流式系统用来保证消息传递可靠性的的技术和其他一些非典型的查询模式。
数据回放
- 对于流处理系统来说,能及时回退数据流并回放数据非常重要。
这是保证数据处理正确的唯一方法。即使数据处理管道能容错,也很难保证处理逻辑没有错误的,修改程序并重新发布是不可避免的,这就需要管道能回放曾经的数据。 - 我们经常需要做即时查询,如果出现问题,系统必需能够重现有问题的数据,要能记录下代码的前后变化。
- 流处理系统应该设计为一旦处理错误和系统故障时能够从源重新读取消息,输入数据通常通过一个缓冲区从数据源进入流内管道,该缓冲区允许客户端来回移动读指针。
Kafka就是这样一个可扩展的、分布式部署、容错和高性能的缓冲区的实现。
流回放技术有以下要求:
- 系统能够存储预置时间内的原始输入数据,
- 能够撤销产生的部分结果,重输入相应的输入数据,并生成新的结果。
- 系统的运行速度应该足够快,能够及时地将数据倒回、重放,以跟上持续到来的数据流。
血缘跟踪
在流系统中,事件流经一系列处理链单元,直到结果到达最终目的地(如外部数据库)。每个输入事件都产生一个以最终结果为终点的后代事件(血缘)的有向图。为了数据处理可靠,必须确保整个图被成功处理,并在出现故障时能重启处理。
让我们来看看Twitter的Storm是如何跟踪消息保证至少一次传递语义的(见下图):
- 源(数据处理图中的第一个节点)发出的所有事件都由一个随机ID标记。对于每个源,框架为每个初始事件维护一组对[事件ID ->签名]。签名最初由事件ID初始化。
- 下游节点可以根据接收到的初始事件生成零或多个事件。每个事件都携带自己的随机ID和初始事件的ID。
- 如果图中的下一个节点成功接收并处理了事件,则此节点将使用传入事件的ID(a)和基于传入事件生成的所有事件的ID(b)来验证相应初始事件的签名,并更新相应初始事件的签名。在下图的第2部分,事件01111生成事件01100、10010和00010,因此事件01111的签名变为11100(= 01111(初始值) xor 01111 xor 01100 xor 10010 xor 00010)。
- 可以基于多个传入事件生成事件。在本例中,它整合了几个初始事件,并向下游传递了多个初始id(下图第3部分中的黄黑事件)。
- 一旦其签名变为0,即认为该事件已成功处理,即最后一个节点确认已成功处理了图中的最后一个事件,且没有向下游发出任何事件,框架向源节点发送确认消息(参见下面图中的第3部分)。
- 框架定期遍历初始事件表,查找存在的未确认事件(具有非零签名的事件)。这些事件被认为失败,框架会要求源节点重溯它们。
- 签名更新的顺序并不重要,因为异或操作具有可交换性。在下面的图中,第2部分中的结果可以在第3部分中的结果之后到达,这完全可以异步。
- 上面的算法并不是严格可靠的,由于id的不可预期的组合,签名可能会意外地变成零。但64位IDs足以保证非常低的出错概率,大约为2^(-64),这在几乎所有实际应用中都是可以接受的。因此,签名表只需要占用很小的内存。
这是个巧妙的方法,它是去中心化的,节点独立地发送确认消息,不存在显式地跟踪所有血缘的中心实体。但是要对于维护滑动窗口或其他的缓冲机制的流,以这种方式管理事务处理就比较困难。滑动窗口上的处理可能在每一时刻都涉及数十万个事件,许多事件都没有完成,要频繁地持久化计算状态,因此很难管理。
Apache Spark[3]中使用了另一种方法。其思想是将最终结果作为传入数据的函数来考虑。为了简化血缘跟踪,框架把事件分成一系列的批次来处理,因此结果也是一系列的批次,其中每个结果批是输入批的函数。生成的批可以并行计算,如果某些批计算失败,框架只需重新运行它。考虑一个例子:
在本例中,框架将两个流连接到一个滑动窗口上,然后结果再经过一个处理阶段。该框架不将传入的流视为流,而是视为一组批。每个批处理都有一个ID,框架可以在任何时候通过ID获取它。因此,流处理可以表示为一组事务,其中每个事务接受一组输入批,使用处理函数转换它们,并持久保存结果。在上图中,其中一个事务用红色突出显示。如果事务失败,框架只需重新运行它。诸多事务能够并行执行是很重要的。
这个范例简单但功能强大,支持集中式事务管理,并在本质上提供了有且只有一次消息处理语义。此技术既可用于批处理,也可用于流处理,因为它将输入数据视为一组批处理,而不当作持续不可分的流。
状态检查点
前面我们讨论了使用签名(checksums)来提供“至少一次”消息传递语义的血缘跟踪算法,这提高了系统的可靠性,但也存在两个问题:
- 许多情况下需要“有且只有一次”处理语义。例如,如果某些消息要传递两次,计数可能会产生不正确的结果。
- 管道中的节点在处理消息时处于计算状态,在节点发生故障时,可能会丢失状态信息,因此必须对其进行持久化或复制。
Twitter的Storm使用以下协议解决这些问题:
- 事件被分组为多个批次,每个批次都与一个事务ID相关联,事务ID是一个单调增长的数值(例如,第一个批的ID是1,第二个是2,以此类推)。如果管道在处理某个批次时失败了,则使用相同的事务ID重新发出该批次。
- 首先,框架通知管道中的所有节点新的事务开始,然后,框架通过管道发送批,最后,处理完成后框架宣布事务完成,所有节点确认它们的状态,例如在外部数据库中更新它。
- 该框架保证提交阶段在所有事务中都是全局排序的,即事务2不能在事务1之前提交。这使处理节点能够使用以下逻辑更新持久状态:
最新事务ID与状态一起持久化。
- 如果框架要求提交的当前事务ID与数据库中存储的ID值不同,则状态应该被更新,例如可以增加数据库中的计数器。假设事务是强有序的,那么对于每个批处理,这样的更新只会发生一次。
- 如果当前事务ID等于存储的值,则节点不提交,这是一个重溯的批次。节点处理批次和更新状态要尽可能快,以避免由于管道中其他地方的错误造成事务失败。
- 强有序提交对于实现“有且只有一次”语义非常重要。但严格的事务顺序处理其实很难,因为管道中的第一个节点常常会因为等待下游节点处理完成而处于空闲状态。这可以通过事务并行处理来缓解,不要串行提交。如下图所示:
对于一个容错,可以重溯数据的数据源,这种技术可以实现“有且只有一次”处理语义。但频繁的状态更新可能导致严重的性能下降,因此应尽可能减少或避免中间计算状态。
状态写入可以不同的方式实现。最直接的方法是将内存状态作为事务提交过程的一部分转储到持久存储。但这对于过大的状态(滑动窗口等)并不适用。另一种方法是写事务日志,记录所有状态变化操作(对于滑动窗口,它可以是一组添加和移除的事件)。这种方法使故障恢复变得复杂,状态必须从日志中重建,但是有性能优势。
Additive State and Sketches
可加性
中间和最终结果的可加性是一个重要的特性,它极大地简化了流数据处理系统的设计、实现、维护和恢复。可加性是指较大时间范围或较大数据分区的计算结果可以拆分为较小时间范围或较小分区的结果的组合。例如,每天的页面访问量是每小时页面访问量的和。可加性允许将持续的流拆分为可以独立计算和重算的小批次,正如我们在前面讨论过的,这有助于简化血缘跟踪并降低状态维护的复杂性。
但可加性并非随处适用:
- 多数情况下计算本身就具备可加性,例如简单计数器。
- 有时候需要通过存储少量附加信息才能实现可加性。例如一个计算电商每小时平均销售额的系统,按照小时均值无法得到正确的日均值,但如果再记录下每小时的销售额就足以计算日平均值。
- 也有时候很难实现可加性。例如,一个统计网站独立访问者的系统。如果昨天有100个独立用户访问,而今天又有100个独立用户访问,那么两天内的独立用户总数个100到200之间的数。要计算这个就必须维护每天一个用户ID列表,通过两个ID列表的交并来计算两天内的独立用户。原始数据越大维护的列表也越大。
草图是将不可加性值转换为可加性值的一种有效方法。在前面的示例中,可以用一种特殊的统计计数替换ID列表。这个计数器提供的是近似值而非精确结果,但对于许多实际应用已经足够了。草图在互联网广告领域应用很广泛,它可以被看作是一种独特的流处理模式。附录[5]中有这个技术的完整概述。
逻辑时钟
It is very common for in-stream computations to depend on time: aggregations and joins are often performed on sliding time windows; processing logic often depends on a time interval between events and so on. Obviously, the in-stream processing system should have a notion of application’s view of time, instead of CPU wall-clock. However, proper time tracking is not trivial because data streams and particular events can be replayed in case of failures. It is often a good idea to have a notion of global logical time that can be implemented as follows:
流计算通常依赖于时间:聚合和连接通常在滑动时间窗口上执行,处理逻辑通常取决于事件之间的时间间隔。所以流内处理系统应该有应用时钟的概念,而不是CPU时钟。然而,适当的时间跟踪并不容易,因为数据流和特定事件在发生故障时可以重放。一个全局逻辑时钟实现如下:
- 所有的事件都会被应用打上时间戳。
- 管道中的处理单元只要发现时间戳比全局时钟的时间新就把全局时钟更新成时间戳的值。其余的处理器再与全局时钟同步他们的时钟。
- 数据重复的时候全局计时器会被重置。
持久存储中聚合
我们说过持久性存储可用于状态检查点,但外部存储在流处理中还有别的用场,比如用Cassandra在一个时间窗口内连接多个数据流,不需要在内存中维护事件缓冲区,只要使用连接键作为row key将所有数据流中的传入事件保存到Casandra,如下图所示:
另一个进程定期遍历记录,收集并发出连接好的事件,再清除掉超出时间窗口的事件。Cassandra通过对事件的时间戳排序可以提高效率。
要注意,将太多事件写入存储可能会导致严重的性能瓶颈,即使写到Cassandra或Redis里也一样。但从另一方面来看,这种持久化状态的方法用批量写入换得了一定的性能优化,在大多数情况下这已经足够了。
滑动窗口中聚合
流数据处理经常会处理一些像“过去10分钟内值的总和是多少?”这样的查询,而且要在滑动时间窗口上进行连续查询。一种简单方法是分别计算每个时间窗口的聚合函数,如sum。但这不是最佳的方法,因为两个连续的时间窗口有大部分是重叠的,其实我们可以采用增量处理的方法。举例:如果T时刻的窗口包含样本{s(0), s(1), s(2),…,s(T-1), s(T)},那么T+1时刻的窗口包含样本{s(1), s(2), s(3),…,s(T), s(T+1)}。
滑动窗口上的增量计算广泛应用于数字信号处理。一个典型的例子是求和函数的计算。如果已知当前时间窗口的和,则可以通过添加新样本并减去窗口中最老的样本来计算下一个时间窗口的和:
类似的技术不仅应用于简单聚合(如求和或乘积)中,也用于更复杂的转换。例如,SDFT(滑动离散傅里叶变换)算法[4]就是FFT(快速傅里叶变换)算法逐窗口计算的更高效的替代方案。
查询处理管道: Storm, Cassandra, Kafka
现在让我们回到本文开头所述的实际问题。我们采用前文所述的技术在Storm、Kafka和Cassandra之上设计并实现了一个流数据处理系统,但这只是一个非常简单的梗概,要说清所有实现那需要单独的一篇文章。
系统使用Kafka 0.8作为分区容错事件缓冲区,通过简单地添加新的事件生产者和消费者来支持流回放并提高系统的可扩展性。Kafka的回读指针的能力还允许对传入批次的随机访问,从而实现spark风格的血缘跟踪。也可以将系统输入指向HDFS来处理历史数据。
如前所述,Cassandra用于状态检查点和存储中聚合。很多情况下还用它存储最终结果。
Twitter的Storm是整个系统的基石。所有的查询处理都在Storm的拓扑中执行,这些拓扑与Kafka和Cassandra交互。有的数据流很简单:数据到达Kafka,Storm读取并处理,将结果保存到Cassandra或其他目的地。有的数据流比较复杂:一个Storm拓扑可以通过Kafka或Cassandra将数据传递到另一个拓扑。上图中显示了两类例子(红色和蓝色的曲线箭头)。
迈向统一数据处理
现有的Hive、Storm和Impala等技术让我们能够在大数据上进行复杂分析和机器学习这样的的批处理操作,也可做实时查询处理以用于在线分析,还可以提供流处理的能力用于持续查询。Lambda架构[6,7]组合了这一堆解决方案。但也存在一个问题,未来能否有一个整体的解决方案?我们将讨论分布式关系查询处理、批处理和流处理之间的共性,以找出一种能够覆盖所有这些用例的技术。
关系查询处理、MapReduce和流内处理可以使用完全相同的概念和技术(如重组和管道)来实现。注意:
- 流处理需要严格的数据交付保证和持久化中间状态,而这些对于批处理并不重要,因为批处理可以轻松重启计算。
- 流处理是建筑在管道概念上的。而对于批处理,管道却不重要。像Apache Hive这样的系统是基于分阶段MapReduce的,中间状态持久化到磁盘,并没有充分利用管道。因需而定的持久性(持久到内存中与磁盘上)和高可靠性是理想中查询引擎的特点,它为更高级的框架提供了基本功能和接口:
有2个新技术比较符合本文的理论:
Apache TEZ ,Apache TEZ 是MapReduce框架的替代品,它用一系列巧妙的原生查询过程,把Apache Pig和Apache Hive的查询分解为高效的处理管道而非需要多次存盘的MapReduce作业。
Apache Spark,这个项目包含了批处理框架、SQL查询引擎,和流处理框架,最有可能成为统一大数据处理的方案。
引用
- A. Wilschut and P. Apers, “Dataflow Query Execution in a Parallel Main-Memory Environment “
- T. Urhan and M. Franklin, “XJoin: A Reactively-Scheduled Pipelined Join Operator“
- M. Zaharia, T. Das, H. Li, S. Shenker, and I. Stoica, “Discretized Streams: An Efficient and Fault-Tolerant Model for Stream Processing on Large Clusters”
- E. Jacobsen and R. Lyons, “The Sliding DFT“
- A. Elmagarmid, Data Streams Models and Algorithms
- N. Marz, “Big Data Lambda Architecture”
- J. Kinley, “The Lambda architecture: principles for architecting realtime Big Data systems”
- http://hortonworks.com/hadoop/tez/
- http://hortonworks.com/stinger/
- http://spark-project.org/
来源:oschina
链接:https://my.oschina.net/u/97772/blog/3115487