本着对专业方向的不断进取,追求真理的精神。F君于上个星期找来了慕名已久的Google著名论文《MapReduce: Simplified Data Processing on Large Clusters》。通过自习研读和找住在隔壁的余总讨论,总算对于这个大规模并行数据处理框架有了一定的了解,下面我来谈谈体会。
简单说来,MapReduce是一个编程模型,用以进行大数据量的计算。对于大数据量的计算,通常采用的处理手法就是并行计算。至少现阶段而言,对许多开发人员来说,并行计算还是一个陌生,遥远,复杂的东西,假如涉及到分布式计算的问题,问题会更加棘手。MapReduce就是一种简化并行计算的编程模型,它向上层用户提供接口,屏蔽了并行计算特别是分布式处理的诸多细节问题,让那些没有多少并行计算经验的开发人员也可以很方便的开发并行应用,从而避免了“重复发明轮子”的问题。在我看来,这也就是MapReduce的价值所在,通过简化编程模型,降低了开发并行应用的入门门槛,并且能大大减轻了程序员在开发大规模数据的应用时的编程负担。相对于现在普通的开发而言,并行计算需要更多的专业知识,有了MapReduce,并行计算就可以得到更广泛的应用。
MapReduce的名字源于这个模型中的两项核心操作:Map和Reduce(来自于函数式编程语言Lisp)。简单的说来,Map是把用户输入的数据(键值对集,Key/Value Repairs)通过用户自定义的Map函数操作映射为一组临时中间(intermediate)键值对。Reduce是对生成的临时中间键值对进行归约,这个归约的规则由一个用户自定义Reduce函数操作指定,输出最终结果。用论文上的一个简单的例子吧,在TB数量级的文档集合中统计每一个单词出现的次数。有如下的伪代码,其中map函数检查每一个单词,并且对每一个单词增加1到其对应的计数器。reduce函数把特定单词的所有出现的次数进行合并。

Code
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));
论文上举的各种使用MapReduce的例子看的让人热血沸腾。用户仅仅需要定义下自己的Map操作和Reduce操作,连同各种运行时的参数一起打包后扔给一个函数调用,OK,不足百行代码,马上将会在服务器集群上有几千台机器上的几万个进程为您服务。1TB的数据算啥,几百秒就搞定。根据Google的某年某月的MapReduce运行情况报告显示,TB级的任务平均完成时间在634秒。以下是上面统计词频程序的完整版,可见只需寥寥数行,使用MapReduce Library很好很强大。

Code
1 #include "mapreduce/mapreduce.h"
2
3 // User's map function
4 class WordCounter : public Mapper {
5 public:
6 virtual void Map(const MapInput& input) {
7 const string& text = input.value();
8 const int n = text.size();
9 for (int i = 0; i < n; ) {
10 // Skip past leading whitespace
11 while ((i < n) && isspace(text[i]))
12 i++;
13
14 // Find word end
15 int start = i;
16 while ((i < n) && !isspace(text[i]))
17 i++;
18 if (start < i)
19 Emit(text.substr(start,i-start),"1");
20 }
21 }
22 };
23
24 REGISTER_MAPPER(WordCounter);
25
26 // User's reduce function
27 class Adder : public Reducer {
28 virtual void Reduce(ReduceInput* input) {
29 // Iterate over all entries with the
30 // same key and add the values
31 int64 value = 0;
32 while (!input->done()) {
33 value += StringToInt(input->value());
34 input->NextValue();
35 }
36
37 // Emit sum for input->key()
38 Emit(IntToString(value));
39 }
40 };
41
42 REGISTER_REDUCER(Adder);
43
44 int main(int argc, char** argv) {
45 ParseCommandLineFlags(argc, argv);
46
47 MapReduceSpecification spec;
48
49 // Store list of input files into "spec"
50 for (int i = 1; i < argc; i++) {
51 MapReduceInput* input = spec.add_input();
52 input->set_format("text");
53 input->set_filepattern(argv[i]);
54 input->set_mapper_class("WordCounter");
55 }
56
57 // Specify the output files:
58 // /gfs/test/freq-00000-of-00100
59 // /gfs/test/freq-00001-of-00100
60 // 
61 MapReduceOutput* out = spec.output();
62 out->set_filebase("/gfs/test/freq");
63 out->set_num_tasks(100);
64 out->set_format("text");
65 out->set_reducer_class("Adder");
66
67 // Optional: do partial sums within map
68 // tasks to save network bandwidth
69 out->set_combiner_class("Adder");
70
71 // Tuning parameters: use at most 2000
72 // machines and 100 MB of memory per task
73 spec.set_machines(2000);
74 spec.set_map_megabytes(100);
75 spec.set_reduce_megabytes(100);
76
77 // Now run it
78 MapReduceResult result;
79 if (!MapReduce(spec, &result)) abort();
80
81 // Done: 'result' structure contains info
82 // about counters, time taken, number of
83 // machines used, etc.
84 return 0;
85 }
86
比起用来说,F君更关注实现,论文上是说MapReduce根据不同环境下有不同实现(废话!),比如有的适用于小型的共享内存的机器,有的适用于基于NUMA的大型多处理器系统,而F君最想了解的,也就是Google的实现方案,是基于大规模计算机集群的实现。
以前看偶像Tanenbaum的《分布式操作系统》一书,总是觉得云里雾里的。今天有Google给我们提供了这个活生生的例子以供剖析。Google的集群是由随处可见的普通PC搭建起来的(传说Google买各种二手垃圾机器搭建集群是不是真的?),环境同构:都是双X86处理器(不是双核,是双CPU),2-4GB内存,跑Linux,存储都使用便宜的IDE硬盘(囧,F君本科时的实验室那可是好几十个T的SCSI硬盘搭建的存储集群,偶尔也就几个内部人士往上面传一传PPT啊MP3啊,各种悲剧),网络节点大多是百兆或者千兆网络,两级树型网络结构,集群中任意节点之间通信延迟小于1毫秒(不得不提以前的实验室啊,还尼玛万兆交换机啊,网络介质还各种FC啊,天朝的学术科研哎,各种资源浪费,那个啥,欢迎引入云计算优化资源配置)。
一个MapReduce任务的执行概况大致如下(附图一张):
1. 首先用户数据会输入到GFS(Google File System)上,把输入数据进行分区(partition)把用户输入文件会被分割成M个大小均为16M-64M的块(块的大小可以用户自定义参数决定,分割点也可以用户自定义)送入cluster,就可以分布到不同的机器上并行执行了。
2. 在集群节点中会有一个被选作为核心的存在,它就是主控程序master。master控制任务分配,总共有M个map任务和R个reduce任务需要分排。master选择空闲的worker并且分配这些map任务或者reduce任务。
3. 一个分配了map任务的worker读取并处理相关的用户输入文件的块(这里有一个优化,众所周知IO耗时,远程IO比本地IO更耗时,所以master会尽量在包含对应输入数据块副本的机器上启动Map操作,或者尽可能近的机器上,从而降低远程IO所带来的延时。这里涉及到一些GFS的细节,GFS会把输入文件分块后的文件块存在集群内的机器上,为了保证容错可靠,GFS会对每个文件块存放大约三个副本在其他的机器上)。它处理输入的数据,并且将分析出的key/value对传递给用户定义的map函数。map函数产生的中间结果key/value对暂时缓冲到内存。中间产生的key可以根据某种分区函数进行分布(比如hash(key) mod R,R是Reduce worker的数目),分布成为R块。分区(R)的数量和分区函数都是由用户指定的。缓存会周期性的写入到本地磁盘上,这些数据通过分区函数分成R个区、当Map worker结束时,它会将这些中间结果在本地位置的信息告知Master(注意,是信息,不是数据),Master更新自身的数据结构,并且负责把这些信息告知Reduce worker们.这个告知过程类似于Observer模式,就好像订阅/发行一样,每当Master的关于缓存文件的位置的信息的数据结构更新时,他就告诉所有正处于运行状态的Reduce worker这个变化。
4. 当master通知reduce的worker关于中间key/value对的位置时,Reduce worker就调用remote procedure来从Map worker的本地硬盘上读取缓冲的中间数据。当Reduce worker读到了所有的中间数据,他就使用中间key进行排序,这样可以使得相同key的值都在一起。因为有许多不同key都映射到对应的相同的reduce任务,所以,排序是必须的。如果中间结果集太大了,那么就需要使用外部排序。
5.Reduce worker会迭代所有的排序后的中间数据集合,并且把key和相关的中间结果值集合传递给用户定义的reduce函数。reduce函数会将内容输出(Append操作)到一个最终文件里。
6. 当所有的Map和Reduce worker都结束时,master会唤醒用户程序。这时候,MapReduce返回用户程序的调用点。
以上便是整个执行过程。先抛开各种细节优化不谈。先说说容错和可靠性,毕竟在一个大规模集群中,单点故障失效的问题也很常见。Google对于这个问题作了充分的考虑,Master维护同一个任务下所有Map worker和Reduce work的状态信息,它会周期性的ping下他们,如果有不回应的,Master就猜测该worker所在节点可能出现故障,对于Map worker(即使它完成了),它会另外找一台机器在上面启一个worker重新执行失效worker的任务,而对于Reduce worker,如果完成了的话,就不需要重新执行,负责需要和Map 一样处理。这是因为Reduce worker的输出已经存放到全局文件系统(留有副本),而Map worker的输出存放到本地文件上。重新执行Map时,Master会将新的Map worker信息告知Reduce们。这个机制看上去挺弱智的,但是别人实现简单,并且能很容易的应付较大尺度的worker失效问题。但对于Master呢,一旦坏掉就全挂了,所以master一般有两种机制,有backup和checkpoint。backup是最简单的方式,就是有多个master实例,备份的master实例不管事儿,但是保持数据结构和管事儿的master一致。一旦工作中的master挂了,马上替换掉。checkpoint呢,就是master一段时间会将整个数据结构持久化到全局文件系统中(类似于写日志),挂了后,就从上一个checkpoint开始启动master进程。还有个问题就是由于程序的bug(自己的或者是第三方的),导致对于特定的输入worker老是崩溃,对于这样的问题光是重新执行是不够的,因此每一个worker都有一个signal handler,可以捕获段异常(segment fault)和总线错误(bus error),由于记录号是通过全局维护的,所以当worker被某个输入记录搞死的时候,它会用最后一口气把记录号做成UDP包发给Master,于是Master就知道,哦,这条记录有问题,下次重新执行时要跳过它。另注:内存段异常 一般是访问不存在的虚拟内存页面时(不是缺页中断), 不存在的页面就是没有被os分配页面的虚拟内存地址空间, 如:进程的第一页,0x0000-0x4095, heap区以上&&stack段以下的区域. 如果进程试图访问这些地址,哪怕是只读, 都会产生段异常,这个异常是由于MMU在映射地址时找不到页面所致. 还有就是bus error, 一般这是因为进程试图write只读的段导致的, 一般只读的段包括: text 段,也就是代码区, 全局只读data段,也就是.rodata段, 这个段里放的是字符串常量的内容。
下面随便说说优化,很多优化问题上面其实已经提到了。比如根据用户输入文件块的存储位置来启动Map worker。有一个没提到的就是任务的粒度问题,在理想状态下,Map和Reduce worker的数目比机器数量多的多。这样每一个worker可以通过执行大量任务来提高动态的负载能力(Master给执行的快的机器分配更多的任务,给执行的慢的机器分配少的任务,从而大道负载平衡)。还有一个就是任务的backup,任务的backup可以解决一些容错和可靠性的问题,除此之外,它还可以很好的解决“拖后腿”的问题。当许多worker在运行时,当总的计算任务快要结束时,由于worker的执行有快有慢。所以每次都被最慢的几个worker严重的拖了后腿,导致整体执行时间变长。在总体任务快结束,这时候假如这个拖后腿的worker有多个相同的作为backup任务跑在多个机器上,跑的最快的那个结束了,我们就认为这个worker结束了,从而提高了执行速度,唯一的代价时要考虑输出的唯一性,这个通过原子的改名操作解决。这个想法类似于RAID 0,通过镜像冗余不但能解决容错而且能提高读数据的速度。通过这样一个解决拖后腿的优化,执行时间可以缩短接近百分之五十。
通过这一次学习,总算是对MapReduce有了一个比较全面的认识,也学习到许多分布式系统设计的技巧。接下来想继续研究下Hadoop的源代码,具体深入的研究下MapReduce。下次想对GFS做个调研。
PS:看过一篇好玩的文章,是数据库大牛David J. DeWitt 和 Michael Stonebraker 骂MapReduce是开技术倒车的文章《MapReduce: A major step backwards/MapReduce》各种RDBMS理论啊,不过用不同的视角来看待同一事物还是蛮有趣的。
再PS:据某位在百度工作的童鞋透露,百度现在也在做自己的GFS和MapReduce,几乎完全照着Google的论文来实现的,但很多技术细节还是不知道怎么办,于是就把论文上的实验数据图都研究了个遍,透过各种峰值啊拐点啊来摸索出Google的实现细节,然后Copy之。寒一个。