MapReduce和HPC集群并行计算优劣对比
现在MapReduce/Hadoop以及相关的数据处理技术非常热,因此我想在这里将MapReduce的优势汇总一下,将MapReduce与传统基于HPC集群的并行计算模型做一个简要比较,也算是对前一阵子所学的MapReduce知识做一个总结和梳理。
随着互联网数据量的不断增长,对处理数据能力的要求也变得越来越高。当计算量超出单机的处理能力极限时,采取并行计算是一种自然而然的解决之道。在MapReduce出现之前,已经有像MPI这样非常成熟的并行计算框架了,那么为什么Google还需要MapReduce,MapReduce相较于传统的并行计算框架有什么优势,这是本文关注的问题。
文章之初先给出一个传统并行计算框架与MapReduce的对比表格,然后一项项对其进行剖析。
▲
在传统的并行计算中,计算资源通常展示为一台逻辑上统一的计算机。对于一个由多个刀片、SAN构成的HPC集群来说,展现给程序员的仍旧是一台计算机,只不过这台计算拥有为数众多的CPU,以及容量巨大的主存与磁盘。在物理上,计算资源与存储资源是两个相对分离的部分,数据从数据节点通过数据总线或者高速网络传输到达计算节点。对于数据量较小的计算密集型处理,这并不是问题。而对于数据密集型处理,计算节点与存储节点之间的I/O将成为整个系统的性能瓶颈。共享式架构造成数据集中放置,从而造成I/O传输瓶颈。此外,由于集群组件间耦合、依赖较紧密,集群容错性较差。
而实际上,当数据规模大的时候,数据会体现出一定的局部性特征,因此将数据统一存放、统一读出的做法并不是最佳的。MapReduce致力于解决大规模数据处理的问题,因此在设计之初就考虑了数据的局部性原理,利用局部性原理将整个问题分而治之。MapReduce集群由普通PC机构成,为无共享式架构。在处理之前,将数据集分布至各个节点。处理时,每个节点就近读取本地存储的数据处理(map),将处理后的数据进行合并(combine)、排序(shuffle and sort)后再分发(至reduce节点),避免了大量数据的传输,提高了处理效率。无共享式架构的另一个好处是配合复制(replication)策略,集群可以具有良好的容错性,一部分节点的down机对集群的正常工作不会造成影响。
硬件/价格/扩展性
传统的HPC集群由高级硬件构成,十分昂贵,若想提高HPC集群的性能,通常采取纵向扩展的方式:即换用更快的CPU、增加刀片、增加内存、扩展磁盘等。但这种扩展方式不能支撑长期的计算扩展(很容易就到顶了)且升级费用昂贵。因此相对于MapReduce集群,HPC集群的扩展性较差。
MapReduce集群由普通PC机构成,普通PC机拥有更高的性价比,因此同等计算能力的集群,MapReduce集群的价格要低得多。不仅如此,MapReduce集群中的节点通过以太网进行连接,因而具有良好的横向扩展性,即可以通过添加PC机节点的方式提高处理能力。Yahoo!拥有世界上最大的Hadoop集群,包含4000多个节点(Google的MapReduce集群规模应该更大,但好像没公布过具体数字,如有网友知情,还望不吝赐教)。
编程/学习难度
传统的并行计算模型都有着与多线程模型类似的逻辑,这种编程模型最大的问题是程序的行为难以控制。为了保证正确的执行结果,需要小心控制共享资源的访问,并由此发展出了互斥量、信号量、锁等一系列同步技术,也带来了诸如争抢、饥饿、死锁等问题。程序员在使用传统并行计算模型编程时,不仅仅要考虑要做的事情(即“what to do”:使用并行模型描述需要解决的问题),还要考虑程序执行的细节(即“how to do”,程序执行中的诸多同步、通信问题),这使得并行编程十分困难。已有的编程模型,例如MPI、OpenCL、CUDA也只是在较低的层次做了封装,需要处理的程序执行细节依然很多。
MapReduce则做了更多处理:MapReduce不仅包含编程模型,还提供一个运行时环境,用以执行MapReduce程序,并行程序执行的诸多细节,如分发、合并、同步、监测等功能均交由执行框架负责。使用MapReduce,程序员只需要考虑如何使用MapReduce模型描述问题(what),而无需操心程序是如何执行的(how),这使得MapReduce易学易用。
适用场景
说了这么多MapReduce的好话,MapReduce是万金油吗?
答案是否定的,无论什么时候,都不应该忘记MapReduce的设计初衷:解决大规模、非实时数据处理问题。大规模决定数据有局部性特性可利用(从而可以划分)、可以批处理;非实时代表响应时间可以较长,有充分的时间执行程序。比如下面的几个操作:
1. 更新搜索引擎排序(在整个web图上执行PageRank算法)
2. 计算推荐(推荐结果并不需要实时更新,因此设定一个固定时间点周期性更新)
MapReduce的诞生有它的时代背景:随着web的发展,尤其是SNS和物联网的发展,web上各种由用户、传感器产生数据量呈现出爆炸式的增长。数据存起来只能是死数据,唯有经过分析处理,才能得到数据中蕴含的信息,进而从信息中总结知识。因此数据重要,处理数据的能力同样重要。传统的基于HPC集群的并行计算已经无法满足飞速增长的数据处理需要,因此基于普通PC的低成本、高性能、高可扩展性、高可靠性的MapReduce应运而生。
Google的一篇重量级论文,介绍了MapReduce这个称得上是时下分布式计算的标准模型。
————————————————华丽丽的分割线——————————————————
Google MapReduce中文版
摘要
1、介绍
2、编程模型
2.1、例子
- map(String key, String value):
- // key: document name
- // value: document contents
- for each word w in value:
- EmitIntermediate(w, “1″);
- reduce(String key, Iterator values):
- // key: a word
- // values: a list of counts
- int result = 0;
- for each v in values:
- result += ParseInt(v);
- Emit(AsString(result));
2.2、类型
map(k1,v1) ->list(k2,v2)
reduce(k2,list(v2)) ->list(v2)
比如,输入的key和value值与输出的key和value值在类型上推导的域不同。此外,中间key和value值与输出key和value值在类型上推导的域相同。
(alex注:原文中这个domain的含义不是很清楚,我参考Hadoop、KFS等实现,map和reduce都使用了泛型,因此,我把domain翻译成类型推导的域)。
我们的C++中使用字符串类型作为用户自定义函数的输入输出,用户在自己的代码中对字符串进行适当的类型转换。
2.3、更多的例子
-
分布式的Grep:Map函数输出匹配某个模式的一行,Reduce函数是一个恒等函数,即把中间数据复制到输出。
-
计算URL访问频率:Map函数处理日志中web页面请求的记录,然后输出(URL,1)。Reduce函数把相同URL的value值都累加起来,产生(URL,记录总数)结果。
-
倒转网络链接图:Map函数在源页面(source)中搜索所有的链接目标(target)并输出为(target,source)。Reduce函数把给定链接目标(target)的链接组合成一个列表,输出(target,list(source))。
-
每个主机的检索词向量:检索词向量用一个(词,频率)列表来概述出现在文档或文档集中的最重要的一些词。Map函数为每一个输入文档输出(主机名,检索词向量),其中主机名来自文档的URL。Reduce函数接收给定主机的所有文档的检索词向量,并把这些检索词向量加在一起,丢弃掉低频的检索词,输出一个最终的(主机名,检索词向量)。
-
倒排索引:Map函数分析每个文档输出一个(词,文档号)的列表,Reduce函数的输入是一个给定词的所有(词,文档号),排序所有的文档号,输出(词,list(文档号))。所有的输出集合形成一个简单的倒排索引,它以一种简单的算法跟踪词在文档中的位置。
-
分布式排序:Map函数从每个记录提取key,输出(key,record)。Reduce函数不改变任何的值。这个运算依赖分区机制(在4.1描述)和排序属性(在4.2描述)。
3、实现
1.x86架构、运行Linux操作系统、双处理器、2-4GB内存的机器。
2.普通的网络硬件设备,每个机器的带宽为百兆或者千兆,但是远小于网络的平均带宽的一半。 (alex注:这里需要网络专家解释一下了)
3.集群中包含成百上千的机器,因此,机器故障是常态。
4.存储为廉价的内置IDE硬盘。一个内部分布式文件系统用来管理存储在这些磁盘上的数据。文件系统通过数据复制来在不可靠的硬件上保证数据的可靠性和有效性。
5.用户提交工作(job)给调度系统。每个工作(job)都包含一系列的任务(task),调度系统将这些任务调度到集群中多台可用的机器上。
3.1、执行概括
1.用户程序首先调用的MapReduce库将输入文件分成M个数据片度,每个数据片段的大小一般从 16MB到64MB(可以通过可选的参数来控制每个数据片段的大小)。然后用户程序在机群中创建大量的程序副本。 (alex:copies of the program还真难翻译)
2.这些程序副本中的有一个特殊的程序–master。副本中其它的程序都是worker程序,由master分配任务。有M个Map任务和R个Reduce任务将被分配,master将一个Map任务或Reduce任务分配给一个空闲的worker。
3.被分配了map任务的worker程序读取相关的输入数据片段,从输入的数据片段中解析出key/value pair,然后把key/value pair传递给用户自定义的Map函数,由Map函数生成并输出的中间key/value pair,并缓存在内存中。
4.缓存中的key/value pair通过分区函数分成R个区域,之后周期性的写入到本地磁盘上。缓存的key/value pair在本地磁盘上的存储位置将被回传给master,由master负责把这些存储位置再传送给Reduce worker。
5.当Reduce worker程序接收到master程序发来的数据存储位置信息后,使用RPC从Map worker所在主机的磁盘上读取这些缓存数据。当Reduce worker读取了所有的中间数据后,通过对key进行排序后使得具有相同key值的数据聚合在一起。由于许多不同的key值会映射到相同的Reduce任务上,因此必须进行排序。如果中间数据太大无法在内存中完成排序,那么就要在外部进行排序。
6.Reduce worker程序遍历排序后的中间数据,对于每一个唯一的中间key值,Reduce worker程序将这个key值和它相关的中间value值的集合传递给用户自定义的Reduce函数。Reduce函数的输出被追加到所属分区的输出文件。
7.当所有的Map和Reduce任务都完成之后,master唤醒用户程序。在这个时候,在用户程序里的对MapReduce调用才返回。
在成功完成任务之后,MapReduce的输出存放在R个输出文件中(对应每个Reduce任务产生一个输出文件,文件名由用户指定)。一般情况下,用户不需要将这R个输出文件合并成一个文件–他们经常把这些文件作为另外一个MapReduce的输入,或者在另外一个可以处理多个分割文件的分布式应用中使用。
3.2、Master数据结构
3.3、容错
master周期性的ping每个worker。如果在一个约定的时间范围内没有收到worker返回的信息,master将把这个worker标记为失效。所有由这个失效的worker完成的Map任务被重设为初始的空闲状态,之后这些任务就可以被安排给其他的worker。同样的,worker失效时正在运行的Map或Reduce任务也将被重新置为空闲状态,等待重新调度。
当worker故障时,由于已经完成的Map任务的输出存储在这台机器上,Map任务的输出已不可访问了,因此必须重新执行。而已经完成的Reduce任务的输出存储在全局文件系统上,因此不需要再次执行。
一个简单的解决办法是让master周期性的将上面描述的数据结构 (alex注:指3.2节)的写入磁盘,即检查点(checkpoint)。如果这个master任务失效了,可以从最后一个检查点(checkpoint)开始启动另一个master进程。然而,由于只有一个master进程,master失效后再恢复是比较麻烦的,因此我们现在的实现是如果master失效,就中止MapReduce运算。客户可以检查到这个状态,并且可以根据需要重新执行MapReduce操作。
(alex注:原文为”semantics in the presence of failures”)
当用户提供的Map和Reduce操作是输入确定性函数(即相同的输入产生相同的输出)时,我们的分布式实现在任何情况下的输出都和所有程序没有出现任何错误、顺序的执行产生的输出是一样的。
使用MapReduce模型的程序员可以很容易的理解他们程序的行为,因为我们绝大多数的Map和Reduce操作是确定性的,而且存在这样的一个事实:我们的失效处理机制等价于一个顺序的执行的操作。当Map或/和Reduce操作是不确定性的时候,我们提供虽然较弱但是依然合理的处理机制。当使用非确定操作的时候,一个Reduce任务R1的输出等价于一个非确定性程序顺序执行产生时的输出。但是,另一个Reduce任务R2的输出也许符合一个不同的非确定顺序程序执行产生的R2的输出。
3.4、存储位置
3.5、任务粒度
但是实际上,在我们的具体实现中对M和R的取值都有一定的客观限制,因为master必须执行O(M+R)次调度,并且在内存中保存O(M*R)个状态(对影响内存使用的因素还是比较小的:O(M*R)块状态,大概每对Map任务/Reduce任务1个字节就可以了)。
3.6、备用任务
4、技巧
虽然简单的Map和Reduce函数提供的基本功能已经能够满足大部分的计算需要,我们还是发掘出了一些有价值的扩展功能。本节将描述这些扩展功能。
4.1、分区函数
4.2、顺序保证
4.3、Combiner函数
部分的合并中间结果可以显著的提高一些MapReduce操作的速度。附录A包含一个使用combiner函数的例子。
4.4、输入和输出的类型
Reader并非一定要从文件中读取数据,比如,我们可以很容易的实现一个从数据库里读记录的Reader,或者从内存中的数据结构读取数据的Reader。
类似的,我们提供了一些预定义的输出数据的类型,通过这些预定义类型能够产生不同格式的数据。用户采用类似添加新的输入数据类型的方式增加新的输出类型。
4.5、副作用
如果一个任务产生了多个输出文件,我们没有提供类似两阶段提交的原子操作支持这种情况。因此,对于会产生多个输出文件、并且对于跨文件有一致性要求的任务,都必须是确定性的任务。但是在实际应用过程中,这个限制还没有给我们带来过麻烦。
4.6、跳过损坏的记录
每个worker进程都设置了信号处理函数捕获内存段异常(segmentation violation)和总线错误(bus error)。在执行Map或者Reduce操作之前,MapReduce库通过全局变量保存记录序号。如果用户程序触发了一个系统信号,消息处理函数将用“最后一口气”通过UDP包向master发送处理的最后一条记录的序号。当master看到在处理某条特定记录不止失败一次时,master就标志着条记录需要被跳过,并且在下次重新执行相关的Map或者Reduce任务的时候跳过这条记录。
4.7、本地执行
4.8、状态信息
另外,处于最顶层的状态页面显示了哪些worker失效了,以及他们失效的时候正在运行的Map和Reduce任务。这些信息对于调试用户代码中的bug很有帮助。
4.9、计数器
- 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操作中,用户需要确保输出的key value pair精确的等于输入的key value pair,或者处理的German文档数量在处理的整个文档数量中属于合理范围。
5、性能
5.1、集群配置
在4GB内存里,大概有1-1.5G用于运行在集群上的其他任务。测试程序在周末下午开始执行,这时主机的CPU、磁盘和网络基本上处于空闲状态。
5.2、GREP
图2显示了这个运算随时间的处理过程。其中Y轴表示输入数据的处理速度。处理速度随着参与MapReduce计算的机器数量的增加而增加,当1764台worker参与计算的时,处理速度达到了30GB/s。当Map任务结束的时候,即在计算开始后80秒,输入的处理速度降到0。整个计算过程从开始到结束一共花了大概150秒。这包括了大约一分钟的初始启动阶段。初始启动阶段消耗的时间包括了是把这个程序传送到各个worker机器上的时间、等待GFS文件系统打开1000个输入文件集合的时间、获取相关的文件本地位置优化信息的时间。
5.3、排序
在这个benchmark测试中,我们使用的分区函数知道key的分区情况。通常对于排序程序来说,我们会增加一个预处理的MapReduce操作用于采样key值的分布情况,通过采样的数据来计算对最终排序处理的分区点。
还有一些值得注意的现象:输入数据的读取速度比排序速度和输出数据写入磁盘速度要高不少,这是因为我们的输入数据本地化优化策略起了作用 — 绝大部分数据都是从本地硬盘读取的,从而节省了网络带宽。排序速度比输出数据写入到磁盘的速度快,这是因为输出数据写了两份(我们使用了2路的GFS文件系统,写入复制节点的原因是为了保证数据可靠性和可用性)。我们把输出数据写入到两个复制节点的原因是因为这是底层文件系统的保证数据可靠性和可用性的实现机制。如果底层文件系统使用类似容错编码[14](erasure coding)的方式而不是复制的方式保证数据的可靠性和可用性,那么在输出数据写入磁盘的时候,就可以降低网络带宽的使用。
5.4、高效的backup任务
5.5、失效的机器
6、经验
-
大规模机器学习问题
-
Google News和Froogle产品的集群问题
-
从公众查询产品(比如Google的Zeitgeist)的报告中抽取数据。
-
从大量的新应用和新产品的网页中提取有用信息(比如,从大量的位置搜索网页中抽取地理位置信息)。
-
大规模的图形计算。
在每个任务结束的时候,MapReduce库统计计算资源的使用状况。在表1,我们列出了2004年8月份MapReduce运行的任务所占用的相关资源。
6.1、大规模索引
-
实现索引部分的代码简单、小巧、容易理解,因为对于容错、分布式以及并行计算的处理都是MapReduce库提供的。比如,使用MapReduce库,计算的代码行数从原来的3800行C++代码减少到大概700行代码。
-
MapReduce库的性能已经足够好了,因此我们可以把在概念上不相关的计算步骤分开处理,而不是混在一起以期减少数据传递的额外消耗。概念上不相关的计算步骤的隔离也使得我们可以很容易改变索引处理方式。比如,对之前的索引系统的一个小更改可能要耗费好几个月的时间,但是在使用MapReduce的新系统上,这样的更改只需要花几天时间就可以了。
-
索引系统的操作管理更容易了。因为由机器失效、机器处理速度缓慢、以及网络的瞬间阻塞等引起的绝大部分问题都已经由MapReduce库解决了,不再需要操作人员的介入了。另外,我们可以通过在索引系统集群中增加机器的简单方法提高整体处理性能。
7、相关工作
TACC[7]是一个用于简化构造高可用性网络服务的系统。和MapReduce一样,它也依靠重新执行机制来实现的容错处理。
8、结束语
我们也从MapReduce开发过程中学到了不少东西。首先,约束编程模式使得并行和分布式计算非常容易,也易于构造容错的计算环境;其次,网络带宽是稀有资源。大量的系统优化是针对减少网络传输量为目的的:本地优化策略使大量的数据从本地磁盘读取,中间文件写入本地磁盘、并且只写一份中间文件也节约了网络带宽;第三,多次执行相同的任务可以减少性能缓慢的机器带来的负面影响(alex注:即硬件配置的不平衡),同时解决了由于机器失效导致的数据丢失问题。
9、感谢
10、参考资料
[2] Remzi H. Arpaci-Dusseau, Eric Anderson, NoahTreuhaft, David E. Culler, Joseph M. Hellerstein, David Patterson, and Kathy Yelick. Cluster I/O with River:Making the fast case common. In Proceedings of the Sixth Workshop on Input/Output in Parallel and Distributed Systems (IOPADS ’99), pages 10.22, Atlanta, Georgia, May 1999.
[3] Arash Baratloo, Mehmet Karaul, Zvi Kedem, and Peter Wyckoff. Charlotte: Metacomputing on the web. In Proceedings of the 9th International Conference on Parallel and Distributed Computing Systems, 1996. [4] Luiz A. Barroso, Jeffrey Dean, and Urs H¨olzle. Web search for a planet: The Google cluster architecture. IEEE Micro, 23(2):22.28, April 2003.
[5] John Bent, Douglas Thain, Andrea C.Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, and Miron Livny. Explicit control in a batch-aware distributed file system. In Proceedings of the 1st USENIX Symposium on Networked Systems Design and Implementation NSDI, March 2004.
[6] Guy E. Blelloch. Scans as primitive parallel operations.IEEE Transactions on Computers, C-38(11), November 1989.
[7] Armando Fox, Steven D. Gribble, Yatin Chawathe, Eric A. Brewer, and Paul Gauthier. Cluster-based scalable network services. In Proceedings of the 16th ACM Symposium on Operating System Principles, pages 78. 91, Saint-Malo, France, 1997.
[8] Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung. The Google file system. In 19th Symposium on Operating Systems Principles, pages 29.43, Lake George, New York, 2003. To appear in OSDI 2004 12
[9] S. Gorlatch. Systematic efficient parallelization of scan and other list homomorphisms. In L. Bouge, P. Fraigniaud, A. Mignotte, and Y. Robert, editors, Euro-Par’96. Parallel Processing, Lecture Notes in Computer Science 1124, pages 401.408. Springer-Verlag, 1996.
[10] Jim Gray. Sort benchmark home page. http://research.microsoft.com/barc/SortBenchmark/.
[11] William Gropp, Ewing Lusk, and Anthony Skjellum. Using MPI: Portable Parallel Programming with the Message-Passing Interface. MIT Press, Cambridge, MA, 1999.
[12] L. Huston, R. Sukthankar, R.Wickremesinghe, M. Satyanarayanan, G. R. Ganger, E. Riedel, and A. Ailamaki. Diamond: A storage architecture for early discard in interactive search. In Proceedings of the 2004 USENIX File and Storage Technologies FAST Conference, April 2004.
[13] Richard E. Ladner and Michael J. Fischer. Parallel prefix computation. Journal of the ACM, 27(4):831.838, 1980.
[14] Michael O. Rabin. Efficient dispersal of information for security, load balancing and fault tolerance. Journal of the ACM, 36(2):335.348, 1989.
[15] Erik Riedel, Christos Faloutsos, Garth A. Gibson, and David Nagle. Active disks for large-scale data processing. IEEE Computer, pages 68.74, June 2001.
[16] Douglas Thain, Todd Tannenbaum, and Miron Livny. Distributed computing in practice: The Condor experience. Concurrency and Computation: Practice and Experience, 2004.
[17] L. G. Valiant. A bridging model for parallel computation. Communications of the ACM, 33(8):103.111, 1997.
[18] Jim Wyllie. Spsort: How to sort a terabyte quickly. http://alme1.almaden.ibm.com/cs/spsort.pdf.
附录A、单词频率统计
- #include “mapreduce/mapreduce.h”
- // User’s map function
- class WordCounter : public Mapper {
- public:
- virtual void Map(const MapInput& input) {
- const string& text = input.value();
- const int n = text.size();
- for (int i = 0; i < n; ) {
- // Skip past leading whitespace
- while ((i < n) && isspace(text[i]))
- i++;
- // Find word end
- int start = i;
- while ((i < n) && !isspace(text[i]))
- i++;
- if (start < i)
- Emit(text.substr(start,i-start),”1″);
- }
- }
- };
- REGISTER_MAPPER(WordCounter);
- // User’s reduce function
- class Adder : public Reducer {
- virtual void Reduce(ReduceInput* input) {
- // Iterate over all entries with the
- // same key and add the values
- int64 value = 0;
- while (!input->done()) {
- value += StringToInt(input->value());
- input->NextValue();
- }
- // Emit sum for input->key()
- Emit(IntToString(value));
- }
- };
- REGISTER_REDUCER(Adder);
- int main(int argc, char** argv) {
- ParseCommandLineFlags(argc, argv);
- MapReduceSpecification spec;
- // Store list of input files into “spec”
- for (int i = 1; i < argc; i++) {
- MapReduceInput* input = spec.add_input();
- input->set_format(“text”);
- input->set_filepattern(argv[i]);
- input->set_mapper_class(“WordCounter”);
- }
- // Specify the output files:
- // /gfs/test/freq-00000-of-00100
- // /gfs/test/freq-00001-of-00100
- // …
- MapReduceOutput* out = spec.output();
- out->set_filebase(“/gfs/test/freq”);
- out->set_num_tasks(100);
- out->set_format(“text”);
- out->set_reducer_class(“Adder”);
- // Optional: do partial sums within map
- // tasks to save network bandwidth
- out->set_combiner_class(“Adder”);
- // Tuning parameters: use at most 2000
- // machines and 100 MB of memory per task
- spec.set_machines(2000);
- spec.set_map_megabytes(100);
- spec.set_reduce_megabytes(100);
- // Now run it
- MapReduceResult result;
- if (!MapReduce(spec, &result)) abort();
- // Done: ‘result’ structure contains info
- // about counters, time taken, number of
- // machines used, etc.
- return 0;
- }
————————————————华丽丽的分割线——————————————————
转自:http://blademaster.ixiezi.com/2010/03/27/google-mapreduce%e4%b8%ad%e6%96%87%e7%89%88/