1. MapReduce 简介
MapReduce是google于2004年提出的一种计算模型,用Matei(MIT分布式系统课程的讲师,强烈推荐)的话来说,MapReduce为编程人员提供了一个简单的接口,这个接口让他们可以像是在编写一般程序一样(当然你编写的程序必须符合MapReduce规定的编程规范),由MapReduce框架为整个任务进行划分、分发,在一个大型的商务集群中(MapReduce的初衷是这样的)运行你庞大计算量的任务,最后再由MapReduce收集你的计算结果,存储在文件系统中。
这里有一个要注意的地方:这个文件系统,在Hadoop中的话,是HDFS,一种分布式文件系统。MapReduce作为Hadoop的默认计算引擎,可以说是一个具有里程碑的技术。虽然后面也出现了很多其他的计算引擎,比如强大的spark,但是在批处理方面,MapReduce仍然具备优势,spark可能更加擅长处理流数据。
MapReduce经历过一次比较重大的更新,其体系结构发生了较大的变化,但是其原始版(主要来自于google发布的论文)仍然具备极大的研究价值,其设计的初衷和方式对之后学习、设计类似的系统具有很大的启发。
1.1 为什么要用MapReduce
当年谷歌在进行网页分析的时候,其每天要处理的数据量已经达到了TB级别了,虽然对数据的操作可能是非常简单的(比如word count或者排序),但是巨大的文件是无法全部读入内存的,单台计算机频发的发生缺页中断就会不断去磁盘上读数据,磁臂缓慢的移动会使得简单的操作也会耗上一天甚至好几天完成。庞大的数据往往要分配给上千台机器,等待机器完成之后再收回结果。当时的他们需要解决数据的分发、计算的并行化和机器的容错能力这些问题。这些就是MapReduce出现的原动力。
本文主要分成:MapReduce基本原理(来自google的论文)和MapReduce 2.0介绍。
1.2 需要提前明确的内容:
- 数据的存储:数据可以被存储在内存(易失性,私有)、本地文件(持久化,私有)、GFS(分布式文件系统,一个文件有多个备份可以理解为共享);关于GFS的介绍我后期也会补上。
2. MapReduce原理介绍
什么是MapReduce?谷歌的原文是这样描述的:用户编写一个map函数,这个函数用来处理键值对并生成许多中间的键值对(intermediate key/value pairs),reduce函数将这些中间键值对按照键相同的规则合并,并存储到文件中。
那么MapReduce系统需要做什么呢?
- 将输入数据分解。
- 在一系列的机器上调度程序。
- 处理节点的部分失效。
- 管理节点间通信。
2.1 MapReduce编程模型
MapReduce将整个计算过程分成Map和Reduce:
- Map函数:由用户编写,接受输入键值对并生成一组中间键/值对。MapReduce库将整理所有键值相同的键值对,并将它们传递给Reduce函数。
- Reduce函数:用户编写,接收一个中间键和其对应的value,产生1个或者0个输出。中间值将通过迭代器传递给用户的reduce函数。
Map函数和reduce函数之间的参数传递具备以下关系:这种关系的转化由MapReduce库完成。
根据论文中给出的example,我画了一个示意图,帮助理解:
2.2 MapReduce的实现
MapReduced的实现方式有很多种,论文中主要介绍的是一种面向于用交换式以太网连接的大型商品pc机集群。
2.2.1 MapReduce的工作流程
下图来自论文fig1,概述了整个MapReduce的执行一个job
(这里注意一下术语)的流程:
- 首先会将文件划分成
M
份,通常的大小为16M-64MB(现在通常是128MB),大文件块的好处是,减少寻址的开销,同时,根据局部性原理,要访问的数据的地址通常是连续的,将一个整块读入内存,可以减少缺页中断。接着MapReduce库会在集群中的多个机器上启动程序的副本。(图中序号1对应的几个虚线) - 首先一定会启动的是master节点。master节点不会被分配任务,但是其负责启动其他节点并为他们分配任务。图中有M个map任务和R个reduce任务,master会选择空闲的workers为其分配map和reduce任务,这里我插一句,分配不是随机分的,map任务会优先分配到存储着待处理数据的节点上。这样的好处就是可以本地读取,而不用在以太网上传输数据。
- 一个worker节点如果被分配到了
map task
(这里注意一下术语)就会去读取被划分好的文件块,利用用户定义好的map函数来处理这些文件块并将结果存储在内存中。 - 而我们知道,内存是易失性存储器,所以必须进行持久化,但是持久化会带来新的开销和一致性问题,这要求我们根据具体的需求来选择持久化策略。这些数据存储的位置(磁盘中)被记录在了master节点中(其实是GFS在这里发挥了作用),master负责将这些位置发送给
reduce task workers
。 - 由于一般来说,map worker和 reduce worker不是同一台机器,所以reduce worker会通过RPC(remote procedure calls)来访问这些数据,读出并作为reduce函数的输入。当reduce worker读取了所有中间数据时,它将按中间键对其进行排序,以便将所有出现的相同键分组在一起,因为通常有许多不同的键映射到同一个reduce任务(因为reduce worker是从多个map worker中读取文件的)。如果中间数据量太大,无法在内存中存储,则使用外部排序。
- reduce worker将遍历已排序的中间数据,对于遇到的每个唯一中间键,它将**该键和相应的中间值集<String key,Iterator values>**传递给用户的reduce函数。Reduce函数的输出被附加到这个Reduce分区的最终输出文件中,注意这里reduce worker产生的结果将被存储在GFS中。注意由于在5中进行了排序,最后输出的结果也是按照某种顺序的。
- 当所有map任务和reduce任务完成时,主程序唤醒用户程序,将结果返回给用户。
2.2.2 MapReduce的master节点
Master节点负责保存多种数据结构,包括节点的状态(空闲、正在处理、已经完成)和对应**节点的编号。**上文中提到的,中间键值对存储在map worker的磁盘中,由master节点将这些文件传输到reduce worker中。
2.2.2.3 容错
MapReduce是运行在大量的机器上的,这要求其可以在部分节点失效时仍然继续运行计算任务。
1. 工作节点失效
每个工作节点和master节点之间保持一种联系:心跳线机制(heart beat)。最简单实现就是master节点定期向worker发送一个ping,如果在一定时间内接受了到了worker的回复则认为节点正常,如果多次都没有收到,则任务节点已经失效了(仅仅一次可能无法准确判断,因为存在网络的延迟)。
从设计者的角度来说,我们肯定不会简单回复一个yes,回复的内容应该包括:当前节点的状态(负载或者网络),任务的完成情况,任务完成后文件的更新情况。当任务完成了,该worker 的状态将从completed--->idle
。
如果一个节点完成了map tasks,但是他出现了故障,那么为他分配的map tasks需要重新执行,因为他输出的结果(中间键值对)存储在本地磁盘,但是对于reduce tasks来说,只要他完成了任务,即使节点失效也不用重新计算,因为结果存储在GFS中,一个文件通常会有几个可靠的副本。当然,中间键值对也可以选择存储在GFS上,但是这是否是值得的?其带来的开销是我们需要考虑的。
2. Master节点失效
Master节点在整个MapReduce Library中作用非常重要,一旦Master节点失效了,要立刻处理。在MapReduce的初期设计中,Google的团队认为Master节点的失效是极低概率的,所有没有做很复杂的容灾处理,在Master失效后,整个MapReduce job将会暂停,等待Master的重启。
作为高性能的计算框架,HA(high availability)是必备的。在之后的升级中,MapReduce加入了master节点的热备份。通过在standby master node
上保存check point
,如果当前的master出问题了,另一个master立即接管整个集群,但是这种备份机制也会带来额外的开销(主要是带宽)。
MapReduce保证用户在指定map和reduce函数后,通过MapReduce library得到一个无差错的确定性结果。那么是如何保证的呢?在整个MapReduce执行job的过程中,每个worker会将产生的结果作为一个临时文件先存储到本地文件系统,即进行持久化。
- Map worker会产生R个临时文件存储在本地(联系两种worker的执行的过程)
- Reduce worker会产生一个临时文件存储在本地,**注意这个存储在本地和存储在GFS是不冲突的,**GFS是含有多个备份的分布式文件系统,该机器本地存储的文件可以理解该文件在GFS中的备份之一。
master在收到了Map worker的完成任务的信号时,会忽略该消息,否则master在每次和map worker心跳测试时会记录这个R个文件。相对应的,reduce worker在完成任务后会修改输出文件的文件名,将其修改成最终输出文件名(final output file name)。虽然该机器修改了本地的文件名,但是GFS会维护文件的一致性,即修改该文件备份的副本的文件名。
当操作是不确定的(non-deterministic operations),MapReduce也提供了合理的语义。这一部分我感觉我理解的不是很好。 什么是不确定性的呢?论文中给出了一个例子:假设一个map task M和一个集合e(Ri),该集合包含了reduce函数的操作R1,R2,弱语义(week semantics)的产生可能是因为R1需要读取M的输出文件,而R2可能需要读取别的Map task的输出文件。即在时间序列上是不确定的,因为我们不知道另一个Map task何时完成。
MapReduce的的处理方式是:
- Map worker的处理没有差别,非确定性操作输出的R1等价于确定性的输出R1。
- Reduce worker执行的不确定性操作而产生的输出R2可能等价于另一个由非确定性由不同顺序执行产生的不确定性程序的输出R2。
2.2.4 定位
Master会读取GFS中需要处理的文件的位置,按照本地优先、短距离优先的原则选择机器去执行job,以节省网络带宽。
2.2.5 任务划分的粒度
细粒度可以让每个worker执行许多不同的任务可以提高动态负载平衡,还可以加快worker失败时的恢复速度,因为我们可以将它完成的许多映射任务可以分散到所有其他worker机器上。
但是细粒度带来了更大的开销,划分操作的的时间复杂度为O(M+R),空间复杂度为O(M*R)。所以我们需要有一个合理的粒度划分。
在实践中,我们倾向于决定选择M,以便每个单独的任务大约有16mb到64mb的输入数据(这样上面描述的局部优化是最有效的),我们使R成为我们期望使用的工人机器数量的一个不大的倍数。
2.2.6 任务备份
straggler
:译文流浪者,掉队的人。即MapReduce的某个worker,因为各种原因执行速度缓慢,从而使得整个任务完成的速度减慢。
谷歌的设计在设计时采用任务备份的机制来处理掉队者的出现。当任务接近完成时,会让别的worker去执行还没有执行完的任务,只要有一方执行完毕,就代表任务执行完成。
2.3 一些优化的操作
2.3.1 划分函数
在2.2.5节中提到了划分粒度,MapReduce library中的默认方法是利用Hash来划分(“hash(key) mod R”),在大数据量的情况下,Hash函数可以将样本均匀地分布到每个节点上,有助于负载均衡。用户也可以提供一个划分函数,根据具体的情况要求。
2.3.2 确保有序
MapReduce会对整个中间键值对进行排序,这使得每个分区的输出也是有序的。有序是一个极其优秀的属性,他可以降低查找的时间开销。
2.3.3 组合器函数
其实我在看完了MapReduce执行的流程之后一直有一个疑问,**就是每个reduce worker都要去每个map worker上去读取结果,这样效率是否太低了,**在这里,MapReduce的设计者为用户提供了一个combiner
,用来合并大量重复的中间键值对。
通常使用相同的代码来实现combiner函数和reduce函数。为什么可以这样呢?其实combiner的作用就是合并key相同的文件,这和reduce函数的目的完全一致,只是其输入输出的对象不同。 map---->combiner---->reducer
。combiner输出的文件仍然是中间键值对文件(intermediate file),而reducer输出的文件是最终输出文件(final output files)。
2.3.4 输入输出类型
输入到MapReduce的文件需要遵循一定的文件格式:
- text——一行是一个键值对:键是行号,值是行的内容
- sequence——流的形式,以键值对的方式存储的序列
不论什么样的文件格式要求,其目的都在于让计算机知道你输入的文件的内容的含义是什么,这样才可以让用户指定的处理方式发挥作用。
用户可以通过实现reader
接口来提供对文件的处理。
2.3.5 无效记录的处理
一些代码bug可能会导致MapReduce任务的异常执行,这时候如果无法修复bug的话(比如bug出现在引用的第三方库),会导致出现无效的记录。此时MapReduce提供了一种机制,==可以允许MapReduce library忽略由一些数据引发的异常或者错误,以保证job的继续推进。==之所以可以这么做,是因为如果在数据量很大的情况,忽略一些数据是可以接受的。
这需要我们在每个worker上开辟一个进程,来捕捉程序产生的异常的和错误。在分配任务之前,MapReduce库会为每个输入的参数(这里是否可以理解为文件?)做一个序列号标记,并作为整个job的全局变量存储起来,每当一个worker在处理某个任务出现了错误,那么该worker就发送一个**“last gasp”的UDP给master,master会为该文件的标记值自增1**,当该标记的值超过某个阈值,它就会指示在下一次重新执行相应的记录时应该跳过该文件。
2.3.6 本地执行
我们知道MapReduce是运行在集群上的,很难对其进行debug和测试。所以MapReduce在设计时也提供了单机运行的方式,在一个机器上运行所有的MapReduce流程。
2.3.7 集群状态信息
MapReduce提供了一个web版本的执行信息的页面。同时也可以查看每个任务产生的文件和错误。此外,顶层状态页显示了哪些worker发送错误,以及这些错误导致了哪些worker没有处理这些数据。
2.3.8 计数器
MapReduce提供了计数器来记录各种事件发生的情况。用户可以创建一个count对象,使得其在Map或者reduce任务中计数。论文中提供了一个案例:
Counter* uppercase;
uppercase = GetCounter("uppercase");
map(String name, String contents):
for each word w in contents:
if (IsCapitalized(w)):
uppercase->Increment();
EmitIntermediate(w, "1");
来自各个worker的计数器值周期性地发送到master上,当MapReduce job完成时,该数值将被返回给用户。
2.4 索引系统
为大量的文件建立索引可以优化处理的速度。索引的优势在于:
- 使用索引处理的代码更加简单,因为MapReduce Library对索引的处理是透明的,用户不用管,只需要用即可;
- MapReduce在计算分离(降低计算的关联性)上做的很好,这使得修改索引操作变得很简单,因为不需要考虑重构整个索引。
- 使用索引使得操作变得更容易,因为大部分的机器故障、机器运行缓慢和网络故障都由MapReduce库自动处理。
3. 结语
MapReduce的特点:
- 高速的并行化计算;
- 函数式编程;
- 受限制的编程模型:有助于实现并行化和管理
- 大规模集群自动管理;
- 数据向计算点迁移:这使得网络带宽成了一个重要的指标;
- 任务备份机制:这一机制有一个小问题,如果一个任务不是因为计算慢而一直没有完成,是因为这个任务存在bug,这时候会导致整个集群都在重复执行这个任务,但是之前提到的在2.3.5中的无效记录的处理,可以解决这个小问题。
- 排序:对中间键值对进行排序处理;
- 负载均衡:基于MapReduce的编程模型,MapReduce框架可以将job划分成大量细粒度的任务,这些任务会实时、动态地被调度给空闲的节点(MapReduce通过分布式队列进行进程间通信)——使得计算快的节点会被分配到多的计算任务,;
- 网络通信:重新执行机制来确保网络通信的高可用。