目录
- Map Reduce 工作流程
- Map Reduce 业务场景 (Map 端 3连接)
- 批处理的意义(分区,容错)
- Map Reduce 之后
批处理是我们构建可靠、可扩展和可维护应用程序的重要组成部分。而谷歌在2004年发布的批处理算法:MapReduce,是处理大规模数据集的重要模型,虽然与为数据仓库专门开发的并行处理系统相比,MapReduce是一种相当低级的编程模型,但它依然对批处理的模型理解有很大的帮助,所以我们以MapReduce作为起点,开启我们的批处理的计算之旅。
分布式存储系统与MapReduce
MapReduce是一种相当生硬,野蛮的工具,但却十分有效。单个MapReduce作业:可以有一个或多个输入,并生成一个或多个输出。 MapReduce作业是函数式编程的模型,不会修改输入,除了生成输出之外,不会产生任何副作用。输出文件按顺序编写一次(不修改已写入文件的任何现有部分)。
MapReduce作业需要读、写文件的分布式文件系统。如:HDFS,GFS,GlusterFS,Amazon S3 等等。之后我们使用HDFS作为运行环境,但这些原则适用于任何的分布式存储系统。HDFS是基于无共享的存储集群,而共享磁盘存储由集中式存储设备实现,通常使用定制硬件和特殊的网络基础设施(如光纤通道)。所以HDFS不需要特殊的硬件,只需要由传统的数据中心网络连接的计算机。HDFS的守护进程的每台计算机上运行,将允许其他节点访问存储在该计算机上文件的数据,而中央服务器NameNode跟踪哪些文件块存储在哪台机器。因此,创建一个大的文件HDFS上,可以使用集群之中的所有计算机。
为了容忍机器和磁盘故障,可以在集群的多台机器上复制文件块。所以多台机器上的同一数据的几个副本,当然这里也可以使用纠删码技术,可以允许丢失的数据以比完全复制更低的存储开销被存储。纠删码技术类似于RAID,它在同一台机器上的多个磁盘上提供冗余。不同之处在于,对纠删码的副本进行读写时需要额外的编解码计算。
MapReduce的工作流程
MapReduce与传统的UNIX命令管道的主要区别在于,MapReduce可以跨越多台计算机并行计算,手动编写的Mapper和Reducer不需要了解输入来自何处或输出的去处,由框架来处理机器之间移动数据的复杂性。
下图展示了一个MapReduce作业的工作流程,作业的输入是HDFS的一个目录,目录内每个文件块作为一个单独的分区,由一个单独的Map任务处理,每个输入文件的大小通常是数百兆字节(取决于HDFS的块大小)。MapReduce的调度器试图在存储输入文件副本块机器上运行Mapper,只要该机器有足够内存和CPU资源来运行Map任务。通过这样的方式节省了在网络上复制文件块的带宽,减少了网络负载,利用了分布式计算的局部性原理。
应用程序代码被打包成Jar文件,上传到分布式存储系统之上,对应的节点会下载应用程序的Jar文件,然后启动Map任务并开始读取输入文件,每次将一条记录传递给Mapper的回调函数,并输出Map处理过后的键值对。Map的任务的数量取决于输入文件块的数量,但是Reduce任务的数量由作业作者配置,为了确保同一个键的所有键值对都由同一个Reducer处理,框架使用一个散列键来确定键值对应该对应的Reduce任务。
MapReduce需要对键值对进行排序,但数据集可能太大,无法用一台机器上的常规排序算法进行排序。所以,每个Map任务根据散列将键值对输出到对应的Reducer的磁盘分区,并对键值对进行排序。每当Mapper完成工作时,MapReduce调度器通知Reducer,它们可以开始从Mapper获取输出文件。Reducer从Mapper端获取对应的输出的键值对文件,并进行归并排序,保持排序顺序,这个过程称之为Shuffle。
最后,Reducer调用Reduce函数来处理这些有序的键值对,并且可以生成任意数量的输出记录,并写入分布式存储系统。这便是一次完整的MapReduce任务的全过程。
MapReduce作业的链式调度
一个MapReduce作业可以解决的问题范围是有限的。因此,MapReduce的作业需要被链接到工作流中,这样一个作业的输出就成为下一个作业的输入。Hadoop的MapReduce框架,可以隐式的通过目录名来链接:第一个MapReduc的作业配置写输出到HDFS的指定的目录,第二个MapReduce作业读取相同的目录名作为输入。从MapReduce的框架来看,它们是两个独立的工作。
只有当前一个作业成功完成时,下一个作业的输入才会被认为是有效的(失败的MapReduce作业的结果会被丢弃)。所以不同的作业之前会产生依赖关系的有向无环图,来处理这些依赖关系的工作执行,目前Hadoop有许多对批处理的调度程序,如:Oozie,Azkaban, Luigi, Airflow,等。在一个大型公司之中,许多不同的团队可能运行不同的工作,它们读取彼此的输出,所以通过工具支持管理等复杂的数据流是很重要的。
2.MapReduce作业的业务场景
我们通过一个实例,来具体了解类MapReduce作业的业务场景。如下图所示:左边是一个由日志记录的行为描述,称为用户活动,右边是一个数据库的用户用户表。
数据分析人员的任务可能需要将用户活动与用户的信息关联起来:分析哪些页面最受年龄组的欢迎。但是用户活动日志之中,只包含了用户的ID,而不包含完整的用户信息。这时候就需要一个Join操作,最简单的实现思路是逐一检查用户活动,并对每个用户ID来查询用户数据库,显然,这样的实现会带来很糟糕的性能表现。任务吞吐量将受到数据库服务器往返时间的限制,并且本地缓存的有效性将非常依赖于数据的分布,并行运行海量的查询可能会超出数据服务器的处理能力。为了在作业过程之中有更大的吞吐量,计算必须(尽可能地)在一台机器上进行。通过网络上随机访问请求要处理的每一条记录是十分缓慢的。此外,查询远程数据库将意味着批处理作业变得不确定,因为远程数据库中的数据随时可能会更改。
因此,更好的方法是获取用户数据库的副本(使用ETL将数据库的数据中提取到“数据仓库”),并将其放入分布式存储系统之中。这样,我们可以使用MapReduce这样的工具来更加有效地处理。如下图所示:由MapReduce框架按键对Mapper输出进行分区,然后对键值对排序时,其效果是所有活动事件和具有相同用户ID的用户记录在同一个Reducer之中并且彼此相邻。之后,Reducer可以很容易地执行实际的Join逻辑:每个用户ID都调用一次Reduce函数,输出活动的URL和用户的年龄。随后可以启动一个新的MapReduce作业来计算每个URL的查看器年龄分布,并按年龄组分组。
接下来,我们来梳理一些业务层面的细节,以及用MapReduce框架的一些细节:
业务逻辑分离
在上述的业务场景之中,最重要的就是保证同一个用户ID的活动需要汇集到同一个Reducer来进行处理,这个就是前文我们聊到Shuffle的功能,所有键值相同的键值对都会被传递到相同的目的地。MapReduce编程模型将计算的通信协作与应用程序逻辑处理分离。这就是MapReduce框架的高明之处,由MapReduce的框架本身处理所有的网络通信,业务人员专注于应用程序代码的实现,如果在这个过程之中出现了节点的故障,MapReduce透明的失败重试来确保应用程序逻辑不受影响。数据分组
数据除了Join场景之外,通过键值对对数据进行分组也是数据系统常用的操作:对所有具有相同键的记录都形成一个组,之后对组内的数据进行操作。 现在问题来了?我们怎么样使用MapReduce来实现这样的分组操作呢?实现方式也很简单,通过在Map函数之中对键值对进行改造,插入使键值对产生预期分组的Key,之后分区和排序将相同的Key汇集到同一个Reducer之中。在MapReduce上实现时,分组和Join看起来非常相似。数据倾斜
如果同一个键相关的数据量非常大,对于MapReduce框架来说可能会成为一个挑战,因为相同键会汇集到同一个Reducer进行处理。例如,在社交网络中,少数名人可能有数以百万计的追随者。(第一章我们就举过这个例子)所以在MapReduce作业之中存在数据倾斜,如何来进行补偿呢?在Pig之中,会先运行一个采样任务来确定哪个键是热的,在作业实际执行时,Mapper会把出现数据倾斜的键值对通过随机选择分发个指定的多个Reducer。而Hive的倾斜连接优化采用了另一种方法。它需要在表元数据中显式指定热键,它将与这些键相关的记录存储在元数据之中,后续对表进行操作时,采用类似于Pig的优化思路。
3. Map 端连接
如果你能对输入数据作出某些假设,则通过使用所谓的Map端连接来加快连接速度是可行的。这种方法使用了一个阉掉Reduce与排序的MapReduce作业,每个Mapper只是简单地从分布式文件系统中读取一个输入文件块,然后将输出文件写入文件系统,仅此而已。
广播散列连接
适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。
例如,假设在图10-2的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID。
参与连接的较大输入的每个文件块各有一个Mapper。每个Mapper都会将较小输入整个加载到内存中。
这种简单有效的算法被称为广播散列连接(broadcast hash join):广播一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),散列一词反映了它使用一个散列表。 Pig(名为“复制链接(replicated join)”),Hive(“MapJoin”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用。
除了将连接较小输入加载到内存散列表中,另一种方法是将较小输入存储在本地磁盘上的只读索引中【42】。索引中经常使用的部分将保留在操作系统的页面缓存中,因而这种方法可以提供与内存散列表几乎一样快的随机查找性能,但实际上并不需要数据集能放入内存中。
分区散列连接
如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在图10-2的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。
这种方法只有当连接两端输入有相同的分区数,且两侧的记录都是使用相同的键与相同的哈希函数做分区时才适用。如果输入是由之前执行过这种分组的MapReduce作业生成的,那么这可能是一个合理的假设。
分区散列连接在Hive中称为Map端桶连接(bucketed map joins)【37】。
Map端合并连接
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行排序,则可适用另一种Map端联接的变体。在这种情况下,输入是否小到能放入内存并不重要,因为这时候Mapper同样可以执行归并操作(通常由Reducer执行)的归并操作:按键递增的顺序依次读取两个输入文件,将具有相同键的记录配对。
如果能进行Map端合并连接,这通常意味着前一个MapReduce作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的Reduce阶段进行。但使用独立的仅Map作业有时也是合适的,例如,分好区且排好序的中间数据集可能还会用于其他目的。
4.批处理的意义
前文已经讨论了MapReduce作业的工作流程,现在我们回到一个问题来:所有处理的结果是什么?为什么我们一开始就要做所有这些工作? 批处理操作的核心是对数据系统之中的数据进行解析,这类操作需要扫描大量的记录,进行分组和聚合,并输出到数据库以报告的形式呈现,通过报告给消费者或分析师进行数据决策。
同样,批处理适合建立搜索索引。谷歌最初使用MapReduce是为它的搜索引擎构建索引,通过5到10个MapReduce作业的工作流来实现实现的。如果需要执行全文搜索一组文件中,通过批处理过程是一个非常有效的方法:由每个Map任务对数据分区,之后每个Reducer建立分区索引,将索引文件写入到分布式文件系统。因为通过关键字查询搜索索引是只读操作,这些索引文件在创建后是不可变的。 如果索引的文档集发生变化,一个选项是周期性地为整个文档集重新运行整个索引工作流程,并在完成新索引文件时将以前的索引文件替换为新的索引文件。(如果只是少量文件的变化,则不适用批处理任务进行处理)
批处理的作业的将输入视为不可变且避免副作用(如向外部数据库写入),不仅实现了良好的性能,而且变得更容易维护。如果您在代码中引入了一个bug,输出错误,可以简单地回滚到以前版本的代码并重新运行该作业,并且再次输出正确的结果。更简单的解决方案,可以将旧输出保存在不同的目录中,然后简单地进行切换。由于这种易于回滚的特性,功能开发可以比在不能回滚的环境中进行得更快。有利于敏捷的软件开发。批处理将逻辑处理代码与配置分离,这里便允许优雅地重用代码:一个团队可以专注于实现逻辑处理,而其他团队可以决定何时何地运行该作业。
Map Reduce 之后
1.MapReduce的局限
MapReduce作业是独立于其他作业,输入与输出目录通过分布式存储系统串联。MapReduce作业的存在相互的依赖关系,前后相互依赖的作业需要将后面作业的输入目录配置为与之前作业的输出目录,工作流调度器必须在第一个作业完成后才开始第二个作业。
依赖关系的衔接问题
MapReduce作业的输出的数据,写入分布式存储系统的过程称为物化。而通过将中间状态的数据物化,以充分利用中间状态的数据,可以实现作业之间松散的耦合,中间数据可以被其他作业重用,来加快分布式计算的性能。但MapReduce作业只能在前一个作业生产输入之后,后一个作业才能启动,所以整个工作流程的执行才相对缓慢。无界数据的演算
在生产环境之中,很多数据是无界的,因为它随着时间的推移逐渐产生,这个过程永远不会结束。所以批处理计算必须人为地将数据分割成固定的时间段:例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。而这种方案对于时效性要求较高的应用来说,是不能接受的。多余的中间状态
MapReduce任务会将中间状态的数据存储在分布式存储系统存储之中,这就意味着这些数据将会在多个节点上复制,尽管这样保证了数据的安全性,但是对于临时数据来说,有些矫枉过正,会占据大量的存储空间与不必要的磁盘读写操作。
2.数据流式计算
为了解决这些MapReduce的一些问题,新的计算引擎被提出,类似于Spark,Flink等。这些新的计算引擎有一个共同点:将整个处理流程作为一个大作业,而不是把它们分解成独立的子作业。通过几个处理阶段显式地处理数据流,所以这些系统称为数据流引擎。 计算部分与MapReduce类似,数据流引擎也通过调用用户定义函数来处理记录。并将输入进行分区,一个函数的输出可以成为下一个函数的输入。而与MapReduce不同的是,这些函数不必严格通过Map函数与Reduce函数进行交替运行,而是可以以更加灵活的方式进行组合。
相比起MapReduce模型,流式计算有如下几个优点:
- 代价较高的工作,例如排序,只需要在实际需要的地方执行,而不是总是默认地在每个Map和Reduce阶段都需要进行。
- 减少了不必要的Map任务,Mapper所做的工作常常可以合并上一个Reducer之中(因为Mapper不改变数据集的分区)。
- 因为流中的所有的数据依赖关系都是显式声明的,所以调度器可以进行局部优化。例如,它可以尝试将某些数据互相依赖的任务调度在同一台机器之上,这样就可以通过共享内存缓冲区的方式交换数据,而不是通过网络进行传输,来加快作业的进行。
- 作业运行的中间状态将被保存在内存中或本地磁盘中,比起写入到类HDFS的分布式存储系统之中,这样可以大大降低延迟。
- 相比于MapReduce模型,流计算模型会显式重用JVM,来减少JVM启动关闭带来的性能损失。
- 数据流引擎可以实现与MapReduce引擎相同的计算模型,而且由于数据流引擎的优化工作,任务通常的执行速度会更快。
容错机制
将中间状态写入分布式存储系统并非一无是处,这其实是MapReduce模型的容错机制:一旦一个任务失败了,可以在另一台机器上重新启动,再从分布式存储系统之中读取相同的输入。而流计算引擎避免了将中间状态写入分布式存储系统,而采用了一种新的容错机制:一旦运行机器出现故障,机器上的中间状态会丢失,它会重新计算丢失的中间状态。
当需要重新计算中间状态之后,最为重要的计算的确定性:给定相同的输入数据,最终要产生相同的输出结果。如果丢失的数据已经发送给下一阶段的计算函数,那么这个问题就变得复杂了。如果重新计算的数据和上一次计算的结果不一致,需要同样中止下一阶段的计算。所以通过重新计算数据,来进行容错会比较苛刻而且会产生额外的计算代价:计算是CPU密集型的,那么重新计算可能会付出更高的代价。
3.更高层的API
无论是MapReduce还是流计算引擎,都是在较为底层的角度对数据进行分析与处理。而在这种角度上建立起编程模型是十分费时费力的事情,所以我们开始关注计算抽象化的问题,通过在更高的层次抽象底层的计算模型来实现:改进编程模型,提高处理效率,以及扩大这些技术可以解决的问题。
所以更加高级语言和API开始流行起来,如Hive、Pig、Impala等,他们将手工编写MapReduce作业进行了简化,只需要编写少量的代码便可以完成相同的任务,并且能够转移到新的数据流执行引擎不需要重新编写代码。除了需要更少代码的明显优势外,这些高级API还允许交互式的使用,我们可以在shell中逐步地编写分析代码,这种开发风格在探索数据集和尝试处理数据的方法时非常有用。