MapReduce: Simplified Data Processing on Large Clusters:research.google/pubs/pub62/
MapReduce是Google三篇论文(DFS、MapReduce和BigTable)中关于分布式计算的描述。MapReduce可以说是大数据分布式计算领域的基石,之后无论用于批计算的Hadoop MapReduce、Spark,还是用于流计算的Storm、Flink都借鉴了MapReduce的一些思想。
Google遇到了什么问题
论文作者和他的同事在很长时间针对一些特殊的计算场景开发了大量计算应用,这些计算应用用于处理原始数据,比如抓取的文档、web请求日志等等,并针对原始数据产生的衍生数据进行计算,比如生成倒排索引、web请求分布等等。
这些计算应用的模式非常简单,处理海量原始数据,并对衍生数据进行计算。而处理海量数据就需要数百、甚至数千台计算同时进行计算,从而引入了一系列问题:如何并行计算、如何分布数据、如何处理失败。而将这些逻辑和业务计算逻辑耦合在一起,这就将原本非常简单的计算逻辑,但因为解决上面非业务逻辑从而将简单代码逻辑复杂化。
所以,作者为了避免这种复杂,对这类计算进行了抽象:为MapReduce使用者提供简单计算表达,然后通过库(library)的形式隐藏并行计算逻辑(parallelization)、容错(fault-tolerance)、数据分布(data distribution)和负载均衡(load balance)。而这种抽象就是MapReduce。
MapReduce模型
MapReduce是什么
Google在论文中首先给出了MapReduce的定义:
MapReduce是用于处理和生成大规模数据集的编程模型和相关实现。一个MapReduce程序是由Map函数和Reduce函数构成,Map函数用于处理输入的key/value对,并生成中间结果的key/value对;而Reduce负责将中间结果的key/value对中相同的key进行合并(merge)。
所以MapReduce本质是一个由Map函数和Reduce函数构成的编程模型,该编程模型对后续各种大数据计算框架都产生了非常深远的影响,之后的计算框架都在其基础之上进行了扩展。
在MapReduce论文中除了描述编程模型外,论文还对分布式任务执行进行了抽象:
MapReduce运行时系统负责将输入的数据进行分区,并在一组机器中调度执行,同时系统会处理机器故障并管理执行机器间的通信细节。
这就是上面说到的,将并行计算、数据分布、容错和负载均衡封装到库中。
MapReduce编程模型
MapReduce编程模型核心就是Map和Reduce函数:
-
Map函数由用户编写,用于接收输入的key/value对,并生成一组中间结果key/value对。MapReduce库会将相同中间结果key的value组合到一起,并发送给Reduce函数。
-
Reduce函数也有由用户编写,它会接收中间结果key以及对应的一组value,merge函数用于将value进行合并,来生成更一组更小的values(通常每个Reduce只会生成0个或多个输出值)。而中间结果的values是通过迭代器(iterator)的形式提供给Reduce函数的,这样能避免数据量太大导致内存无法获取全部values。
下面是一段MapReduce伪代码的实例,该实例就是Big Data中的Hello world:计算word count。
map(String key, String value):
// key: document name
// value: document contents
for each word w in value: //遍历doc中每个word
EmitIntermediate(w, "1"); //将每个word key的value赋值为1,标识当前单词数量为1
reduce(String key, Iterator values)://注意values是一个Iterator
// key: a word
// values: a list of counts
int result = 0;
for each v in values: //values就是每个相同word的计数值(这里都是1)
result += ParseInt(v);
Emit(AsString(result)); //sum所有数值,输出该word对应的数量值
在MapReduce编程模型中Map函数和Reduce函数接收的数据类型如下:核心就是map函数的输出类型需要和Reduce函数的输入类型一致。
map (k1, v1) -> list(k2, v2)
reduce (k2, list(v2)) -> list(v2)
MapReduce实现
MapReduce执行流程
对于一个MapReduce任务执行集群,是由一个Master节点和多个用于执行task的Worker节点组成。MapReduce会将输入数据划分为M份到多个机器(worker)执行,同时会将中间结果根据中间Key和分区函数(partition function)将中间结果划分为R份,分配到多个机器(worker)中执行。分区函数和R都可以由用户指定,分区函数可以简单的为hash(key) mod R。
下图是MapReduce实现中的执行视图:
-
用户程序中的MapReduce library首先将输入文件划分M块(pieces),每块大小一般是16M~64M(由用户参数指定)。
-
Master会将map task和reduce task分配到worker中执行。
-
分配了map task的worker会读取对应的分区输入,并将其解析为key/value对传入到用户定义的Map函数中,生成的中间key/value会由Map函数缓存到内存中。
-
缓存在worker中的key/value对会定时flush到本地磁盘,同时会将flush到本地磁盘的位置传递到Master,Master会将这些位置传递给执行reduce的work。
-
当master将位置信息发送给reduce work后,reduce会通过RPC(remote proceduce call)读取map worker磁盘中的中间数据。并对中间结果排序,将相同key分组在一起(如果中间数量太大无法使用内存排序,则会使用外部排序)。
-
Reduce work会迭代排序好的中间数据,并将相同key和对应的value list传递给用户定义的Reduce 函数。Reduce函数会将输出追加到对应该reduce输出函数的分区文件中。
-
当所有map task和reduce task都执行完成后,master会唤醒用户程序,继续执行用户代码逻辑。
上面reduce task读取map task数据后,要对其排序的原因是一个reduce work会接收多组key,后续我们要将相同key 的value list放到一起。
当上面MapReduce程序执行完成后,一般我们会得到R个输出文件(每个输出文件对应一个Reduce task),通常我们可以将这些输出文件作为另一个MapReduce任务的输出文件,或者其它分布式应用程序继续来处理这些文件。
Master数据结构
在上面执行流程中我们可以看到master起到中间协调的作用,master在整个MapReduce的职责主要是以下两点:
-
存储元数据信息:map task和reduce task的执行状态(空闲、处理中还是处理完成);work节点的身份,是map task还是reduce task。
-
传递中间文件的位置信息:当map task执行完成后,会将生成的中间文件位置和大小传递给reduce task。
容错实现(Fault Tolerance)
上面说过MapReduce会将并行计算、容错、数据分布、负载均衡等封装到MapReduce库中。MapReduce库针对容错所采用的使用方案就是失败重试。
Worker失败
Master会定时ping worker节点,如果在规定时间worker没有响应,master会将其标记为failed,然后会将其运行的task重新调度到其它worker上执行。
如果一个worker失败,其上面运行完成的Map task需要被重新执行,因为map task的输出中间结果存储在本地磁盘,此时中间结果数据无法被访问。如果failed worker上面执行完成的是Reduce task,则不需要重新执行,因为其计算结果输出到全局文件系统中。并且如果Map task首先在节点A上执行,然后因为节点故障改为在节点B重新执行,则此时所有执行中的Reduce task需要重新执行,因为他们要从B节点获取Map task的输出。
总结:如果节点故障,则会将其上运行中的Map/Reduce Task调度到其它节点执行;如果故障节点执行完了对应的task,则会根据task类型来决定是否重新执行。
Master失败
Master会定时将其上面的数据结构(元数据)生成快照写到外部存储中,如果master节点故障,则新启动的master节点会拷贝最后一次快照状态(last checkpoint state)。
论文中提到,当前只有一个master节点,所以当master节点挂掉后也没有其它节点能够重新拉起master。所以,当前实现基本是master节点挂掉后,需要client重新拉起MapReduce任务执行。
故障恢复后的语义
简单说就是:故障恢复后,执行结果和正常执行结果一致。这种语义的实现,依赖了map和reduce任务原子提交(atomic commits),即每个处理中的task都会将结果输出到一个临时目录,该临时目录用于保证处理一致性。
Map临时目录
每个map task会输出R个临时目录(分别为每个Reduce生成一个临时目录)。当map task执行完成后,worker会向master发送一条包含R个临时目录的消息,master收到消息后会判断是否已经有对应消息,如果有则忽略该消息(说明有map worker失败,被调度到其它worker执行了),如果没有则保存到元数据中。
Reduce临时目录
每个reduce task输出一个临时目录,当reduce执行完成后会原子修改临时文件为最终文件。如果有多个worker执行相同的reduce task(说明有reduce worker失败,被调度到其它worker执行了),则最终输出文件会被多次调用rename操作,而这里就依赖底层文件系统(Google的就是GFS了)的rename原子语义保证了。
上面提到map或reduce因为失败被多个worker执行,是发生worker可能是假死(网络等原因没有响应ping),导致master认为该worker挂了,继而将task调度到其它worker执行,从而产生同一个task被多个worker执行。
数据本地化(Locality)
在计算环境下,网络带宽是一种相对稀缺资源。作者会将输入数据存储到本地磁盘上(基于GFS),GFS会将每个输入文件划分为64M的块,并且存储多个副本到多台机器上(通常三个副本)。MapReduce master在调度任务时,会考虑数据本地化,即将map task调度到有对应副本数据的节点上执行。
这样当运行一个大规模的MapReduce任务时,读取的大部分输入数据都是本地磁盘,从而减少了网络带宽的消耗。
任务粒度(Task Granularity)
上面提到我们将map阶段划分为M task,将reduce阶段划分为R个task来执行。理论上M和R是没有限制的,并且每个worker机器可以执行不同类型的task来提升动态负载均衡,并且能够加速worker失败导致的task重新计算。
但实践发现,M和R的数量还是受到限制的,受限制原因主要一下两点:
-
Master worker内存限制:因为MapReduce master要调度O(M + R)个task,并且需要将O(M * R)状态存储在内存中。
-
用户指定reduce数量:因为每个reduce输出一个文件,业务会根据自己的需求指定R。
Google内部实践,M数量取决于输入数据量:M = input data size / 16MB ~ 64MB,之所以这样计算是因为每个GFS数据块16MB~64MB,这样每个task只需要处理对应数据块数据,能够充分发挥数据本地性特性。R数量期望是worker机器的整数倍,比如Google内一个MapReduce任务: M=200000, R=5000, worker machine=2000
备份任务(Backup Tasks)
整个MapReduce任务的执行完成时间取决于最后执行完成map/reduce task的时间,也就是木桶效应。当一台worker因为各种原因(比如cpu、mem、disk等原因)处理任务非常缓慢时,比如bad disk导致读流量从30MB/s 降到1MB/s,则任务的整体执行时间可能也就被大大拉长。
MapReduce通过Backup Tasks来解决该问题:即当一个task执行完成后,master会将正在执行的task调度备份执行。这样无论当主task还是备份task执行完成,都会标记该task执行完成。为了减少计算资源浪费,可以调整用于Backup task的资源。
Google内部实现来看,该方案有着非常好的效果,一些case执行时间能减少44%。
分区函数(Partitioning Function)
上面提到每个Map task都会为下游所有Reduce task生成一个中间文件,那么为每个Reduce task生成的中间文件有哪些数据呢?是map输出的全量数据?显然不是,要不也不需要为每个Reduce task生成一个中间文件了,所有Reduce task公用一个中间文件即可。
这里为每个Reduce生成的中间文件内容是Map输出的中间果调用分区函数来划分的,默认分区函数是通过hash实现的:intermediate file = hash(intermediate key ) mod R。所以,通过hash分区函数,就能保证所有相同key 落到同一个要被Reduce task读取的中间文件中了。
该分区函数是可以扩展自定义,可以根据具体业务逻辑来自定义。
合并函数(Combiner Function)
在一些case中,可以使用MapReduce提供的合并函数来减少网络开销。比如word count中,map输出结果*<word, 1>*,所有单次计数都需要通过网络写到本地,然后通过Reduce将其合并成一个最终值。MapReduce提供了可选的合并函数,该合并函数在执行完Map task后执行,先将当前Map能够合并的数据进行合并(也就是预聚合)。这样无论写到本地文件,还是Reduce task读取文件的数据量都大大减少。
Combine Function逻辑一般和Reduce Function一致,区别只是输出结果的位置。
其它细节实现
-
排序保证(Ordering Guarantees):MapReduce保证在指定的分区内中间key/value以key递增处理。也就是说map所写入的中间文件内容是以key递增形式组织的。这样能够保证下游可以随机访问,或者判定同一key是否都读取完成了。
-
跳过坏记录(Skipping Bad Records):跳过异常记录。
-
输入输出类型(Input and Output Typs):MapReduce library提供一套Reader和Writer可以让用户自定义输入输出类型。比如除了读写文件,还可以读写database、memory中的数据结构等等(这里叫做connector感觉更合适一些)。
-
本地执行(Local Execution):对于分布式程序debug、profilling和小数据集测试都不方便。MapReduce提供了可在单机模拟分布式执行的机制。
-
状态信息(Status Information):MapReduce master是一个内部HTTP服务,它会将一系列计算状态信息展示,比如多少task执行完成、多少执行中,输入字节数、中间数据字节以及输出字节数,处理速率等等。除了计算状态,还可以看到哪些work失败了,失败上的worker执行的是哪些task(可分析代码bug)。
-
计数器(Counters):全局计数器工具,能够让用户统计各种事件,比如wordcount统计总单词数。创建一个全局计数器伪代码如下,只需创建一个counter对象,并为其指定名称即可。 为了实现全局计数,worker会定期将本地counter数发送给master(比如伴随心跳上报),master来merge所有map和reduce task的计数值。 MapReduce library有一些默认Counter实现,比如统计输入input key/value对和输出key/value对,可辅助业务判断输入输出是否符合预期。
Counter* uppercase;
uppercase = GetCounter("uppercase");
map(String name, String contents):
for each word w in contents:
if (IsCapitalized(w)):
uppercase->Increment();
EmitIntermediate(w, "1");
可以看到MapReduce论文中的设计实现,在当前各种计算引擎框架中都有其实现的影子,佩服至极。
执行性能
上面看到了MapReduce各种实现,那我们肯定想知道MapReduce程序在实际中的表现。论文中也给出了两个实例场景来测试性能:
-
Search:从1TB数据中搜索特征模式(particular pattern)。
-
Sort:对1TB数据进行排序。
以上两个程序运行在1800台实例,实例规格为2Core/4GB mem/160GB disk/ 带宽100~200Gbps 集群上。
Search MapReduce程序启动Map task 15000个(input split 64MB),Reduce task 1个。程序运行结果如下:
-
整个程序从开始到结束大概150s(包括1min程序启动开销)。
-
50s吞吐达到峰值30GB,因为这时候集群中大部分机器都分配到这个MapReduce上来了。
Sort MapReduce程序启动Map task 15000个(input split 64MB),Reduce task 4000个。程序运行结果如下:
-
下图a中是MapReduce程序正常执行耗时情况,读取数据阶段200s完成,峰值达到13GB/s(相较search峰值底,是因为sort读取后要写本地盘,search只会写查询到的数据)。 中间阶段的shuffle过程会将数据通过网络从map task传输到reduce task。波谷分割的两段的原因是reduce task数量达到机器总数(每台机器启动了一个reduce task),待执行完第一波reduce task后开始执行第二波。 最后阶段reduce task输出结果到文件中(dfs),输出开始时间就是第一波reduce task shuffle完成时间。最终任务执行时间850s,如果加上启动开销,最终时间是891s。
-
图b中是没有启动backup task的MapReduce任务,可以看到长尾非常严重,最终执行时间是1283s,相较开启backup task增加44%执行时间。
-
图c中模拟机器失败,kill掉200个task的执行情况。最终执行时间933s,仅比正常执行完成时间多5%。
经验
-
限制编程模型,可以使得分布式计算实现更简单,并且对于容错实现也会简化很多。
-
迁移计算比迁移数据成本更低,因为网络带宽是稀缺资源。
-
冗余执行(Redundant execution)能减少慢节点的影响,也能处理机器故障和数据丢失。