发表于 OSDI 2004
MapReduce 是一个编程模式以及其相关的处理并生成大数据的实现方式。map
函数用来处理一对 key-value 来生册灰姑娘一系列的中间 key-value ,reduce
函数讲有着相同的中间 key 的 value 合并起来。许多现实问题都可以在在这个模型下表示出来。
用这种函数式风格写成的程序本身就是并行的,并且能够在大型商业集群上执行。运行时系统可以处理分离输入数据的细节、在不同机器之间调度程序的执行、处理机器故障等、以及管理机器之间的通讯。这可以让没有并行分布式系统经验的程序员来轻松地利用一个大型分布式系统的资源。
Implementation 实现
Execution Overview
执行流程:
- 用户程序中的 MapReduce 库首先将输入文件分成 M M M 块,每块一般是 16~64 MB。然后该程序在集群内部的机器上复制(fork)该程序。
- 其中有一个 fork 出去的子程序比较特殊,它运行在 master 上,剩下的是 worker,他们接收 master 分配下来的任务。总共需要分配 M M M 个 map 任务和 R R R 个 reduce 任务。master 选择空闲的机器,给他们分配 map 任务或 reduce 任务。
- 接受 map 任务的 worker 读取对应的文件块,从该块中解析出来 key-value pairs ,然后把每一对 key-value 分发给用户定义好的
Map
函数,Map
函数产生的中间的 key-value pairs 存放在内存中。 - 经过一定的周期,被缓存的 key-value pairs 会被写到磁盘上,他们通过 partitioning 函数来分成 R R R 个部分。在本地磁盘上的这些 key-value pairs 的位置信息会传给 master,master 负责将这些信息分发给负责 reduce 任务的 worker
- 当 master 用位置信息唤醒了一个 reduce work 时,这个 worker 会调用 remote procedure 来读取存放在磁盘上的 key-value pairs 。当该 worker 读取了全部的中间数据之后,它根据中间 key 进行排序,使得相同的 key 对应的 value 可以分到一组。排序的原因是:通常许多不同 key 的键值会 map 到一个相同的 reduce 的任务里来。假如中间数据量太大了以至于放不到内存中,可以使用外部排序。
- reduce worker 对排序好的中间数据进行迭代,对于每一种 key,它把这个 key 和对应的中间 value 的集合传给用户定义好的
Reduce
函数,函数的输出会被加到这个 reduce 部分产生的最终输出文件中去。 - 当所有的 map 任务和 reduce 任务都完成了之后,master 会唤醒用户程序,此时用户程序中的
MapReduce
调用完成返回。
Master Data Structures
master 维护了许多数据结构,对于每一个 map 任务和每一个 reduce 任务,它保存了该任务的状态(idle, in-progress, completed),以及每个 worker 的标识符(正在进行任务的 worker,也就是 non-idle task)
对于每一个已经完成的 map 任务,master 储存该 map 任务生成的 R R R 个中间文件所在的位置以及大小。当 map 任务完成时,master 中关于这些位置以及大小的信息会进行更新。这些信息会逐渐地推送给正在进行 reduce 任务的 worker。
Fault Tolerance 容错
Worker Failure
master 会周期性地 ping 一下 worker,假如在一定时间内某一个 worker 一直不回复,那么 master 就认为这个 woker 狗带了。这个 worker 完成的所有 map 任务都会重置为他们的初始状态——idle,因此这些任务可以再重新分配给其他 worker。相似地,一个狗带了的 worker 上假如有正在进行的 map 或 reduce 任务,那么这些任务也重置为 idle 状态。
聪明的你可能要问了,map 任务不是已经完成了嘛?为什么还要再重置呢?因为这些完成的任务被保存在狗带了的 worker 的本地磁盘上,现在我们拿不到了呀。而完成了的 reduce 任务不需要重置,因为他们的结果保存在 global file system 上。
当一个 map 任务先被 worker A 执行,后被 worker B 执行时(因为 A 狗带了),所有正在执行 reduce 任务的 worker 都会收到一条消息:需要重新执行。任何还没有从 worker A 读数据的 reduce 任务会转向 worker B 进行数据读取。
Master Failure
可以让 master 周期性地保存 master data structure (checkpoint),假如 master 任务 die 了,可以从最近的 checkpoint 重新开始。但是因为只有一个 master,因此它出错的可能性不高,在本论文的实现中,假如 master die 了,会直接停止 MapReduce 的执行。
Semantics in the Presence of Failures
当用户提供的 map 和 reduce 操作符是他们输入的确定性函数时,我们的分布式实现会给出顺序执行的相同输出。
我们通过 atomic commit of map and reduce task outputs 来实现这个性质。每一个正在进行的任务都把它的输出写到私有临时文件中,一个 reduce 任务产生一个这样的文件,一个 map 任务会产生 R R R 个这样的文件(每个文件对应一个 reduce 任务)。当 map 任务完成时,worker 会向 master 发消息,里面包含了 R R R 个临时文件的名字。假如 master 已经收到了该任务完成的信息,它就会忽略掉该重复信息,否则,他会在 master data structure 中记录这 R R R 个文件的名字。
当一个 reduce 任务完成时,reduce worker 会 atomically 将临时输出文件重命名为最终输出文件。假如一个相同的 reduce 任务在多台机器上执行,那么对于一个相同的最终输出文件会执行多次重命名操作(?)由于该重命名操作是原子操作(由底层的文件系统提供),这样可以保证最终的文件系统状态只包含由 reduce 任务一次执行而产生的结果。
我们 map 和 reduce 运算操作的大部分内容都是确定性的,并且我们的语义(semantics)和顺序执行的是相同的,因此这样很方便程序员们去推断他们程序的行为。当 map 和 reduce 是不确定的时,我们提供了一个较弱的、但仍是合理的语义。在这种情况下,一个特定的 reduce 任务 R 1 R_1 R1 的输出和顺序执行该不确定程序得到的输出是一样的。然而,另外一个不同的 reduce 任务 R 2 R_2 R2 可能对应于一个不同的顺序执行。
考虑 map 任务 M M M 以及 reduce 任务 R 1 , R 2 R_1,R_2 R1,R2 ,令 e ( R i ) e(R_i) e(Ri) 为 R i R_i Ri 的执行顺序,该语义是弱的,因为 e ( R 1 ) e(R_1) e(R1) 可能读取的是 M M M 的执行结果,而 e ( R 2 ) e(R_2) e(R2) 可能读取的是 M M M 的另外一种执行的结果。
Locality
网络带宽是一个稀缺资源。为了节省网络带宽,我们利用了一个事实:我们的输入文件是存在在集群的各个机器上的(通过 GFS, Google File System 来进行管理)。GFS 将每个文件分成 64 MB 的块,然后将每一块的备份(一般 3 个备份)储存在不同的机器上。MapReduce master 考虑输入文件的位置信息,然后给存放输入文件备份的机器安排 map 任务。假如没有的话,会选择离该文件备份比较近的机器来分配 map 任务,当在一个集群中大量机器上跑 MapReduce 时,大部分的输入数据是本地读入的,因此消耗的网络带宽很少。
Task Granularity
我们将 map 阶段分成 M M M 份,把 reduce 阶段分成 R R R 份。理想情况下, M , R M,R M,R 应该比 worker 的数量多得多。每个 worker 进行许多不同的任务可以改进动态负载平衡,当一个 worker 狗带时,恢复速度也很快:它完成的 map 任务可以分配给其他的机器。
M , R M,R M,R 可以有实际的取值上界,因为 master 必须进行 O ( M + R ) O(M+R) O(M+R) 次调度决策,然后在内存中维护 KaTeX parse error: Undefined control sequence: \tiems at position 4: O(M\̲t̲i̲e̲m̲s̲ ̲R) 个状态(内存使用的常数很小: O ( M × R ) O(M\times R) O(M×R) 个状态对于每对 map/reduce 大概只占 1 byte)
另外, R R R 通常被用户所限制,因为每个 reduce 任务的输出是在一个单独的文件中。在实际中,我们选择 M M M 使得每个任务的带下大概是 16Mb - 64MB (因此上一节说的 locality optimization 可以最有效)。 R R R 一般是机器数乘上一个小值。比如使用 2000 个 worker, M = 200 , 000 , R = 5 , 000 M=200,000,R=5,000 M=200,000,R=5,000
Backup Tasks
大概意思是说某些机器因为一些原因运行很慢,会拖后腿,本文设计了一种 backup 操作,在 MapReduce 快结束时对那些仍然在进行中的任务执行 backup (备份?)。不太懂这里具体怎么操作,意思是标记一下哪些机器在拖后腿,然后下次就不用它嘛?
Refinements
Partitioning Function
MapReduce 的用户确定他们想要的 reduce 的任务的数量( R R R),然后用一个 partitioning function 将数据分给这些任务。默认的 partitioning function 是哈希函数( h a s h ( k e y ) m o d R hash(key)\mod{R} hash(key)modR),有些时候想用一些特殊的性质,比如有些输出的键为 url,我们希望同一个 host 上的条目最终能在一个输出文件中,那么就可以使用类似于 h a s h ( H o s t n a m e ( u r l k e y ) ) m o d R hash(Hostname(urlkey))\mod{R} hash(Hostname(urlkey))modR 这样的函数。
Ordering Guarantees
我们可以保证在一个给定的 partition,中间键值对是按照 key 的升序进行处理的。这个保证能够使得我们能够轻易地为每一个 partition 生成一个有序得输出的文件。
Combiner Function
在一些情况下,每个 map 任务生成的中间 key 可能存在大量的重复,用户定义的 Reduce 函数具有交换性和结合性。如 word counting 的例子:
由于单词的频率服从 Zipf 分布,每一个 map 任务会产生非常多的
<the,1>
\verb|<the,1>|
<the,1>,所有的这些都会被送到同一个 reduce 任务,然后被 Reduce 函数加起来产生一个结果。我们允许用户可以定义一个可选的 Combiner 函数来在这些数据被发到网络中之前进行部分合并。
Combiner 函数在每台执行 map 的机器上执行,通常 combiner 和 reduce 使用相同的代码进行实现。他们之间唯一的区别是 MapReduce 库是如何处理函数输出的。reduce 函数的输出直接写到最终的文件中,而 combiner 函数的输出会写到一个中间文件内,随后再送给 reduce 任务。
Input and Output Types
输入输出有多种类型,并且用户可以实现自己的 reder 接口
Side-effects
在一些情况下,用户发现可以很方便地从 map/reduce 操作中生成额外的文件作为输出。但是论文并未提供相应的原子操作,但但是,在实践中没啥影响。
Skipping Bad Records
实现了一种机制,假如一些 record 一直出问题(有可能是某些库的锅),那么就可以检测出来并将他们忽略掉。
Local Execution
论文提供了一个工具,可以在本地调试 MapReduce
Status Information
可以显示各种各样运行时的信息,比如计算到那一部分了,每个 worker 的情况啥的,方便调试
Counters
内置了一些 counter 来对一些量进行计数,比如得到的 key-value pairs 的数量,或者总共处理了多少个单词等。用户也可以定义自己的 counter