第四章 编写基本的MapReduce程序
本章涵盖了:
用Hadoop处理数据集,以专利数据为例
一个MapReduce程序的基本结构
基本的MapReduce程序,以数据统计为例
Hadoop的流API,用于使用脚本语言来编写MapReduce程序
使用Combiner来提升性能
MapReduce程序与您所学过的编程模型有所不同。您需要花一些时间,并进行一些练习来熟悉它。为了帮助您精通它,我们在后面几章会通过多个例子来进行练习。这些例子描述了不同的MapReduce编程技术。通过用不同方式应用MapReduce,您可以开始培养一种直觉,并养成“用MapReduce思考(thinking in MapReduce)”的习惯。这些例子包括了简单的例子和高级的应用。在一个高级的应用程序中,我们介绍了Bloom滤镜,一种在标准的计算机科学课程中不会讲授的数据结构。您会了解到处理大量的数据集时,无论您是否使用Hadoop,通常都会需要重新考虑底层的算法。
我们假设您已经掌握了Hadoop的基础,您可以建立Hadoop,并编译和运行示例程序,例如第一章中的单词统计的例子。我们将以现实世界中的数据集为例来进行学习。
4.1 获取专利数据集
要用Hadoop做一点有意义的事情的话,我们需要数据。我们的许多例子会使用专利数据集,可以从全国经济研究局(NBER)的网址http://www.nber.org/patents/获取这些数据。这些数据集最初是为论文《NBER专利引用数据文件:经验,见解和方法工具》编制的。我们将使用专利引用数据集cite75_99.txt和专利描述数据集apat63_99.txt。
请注意
每个数据集有将近250MB,这对于我们的以独立或伪分布模式运行的Hadoop而言是足够小的。您可以使用它们练习编写MapReduce程序,甚至不需要访问一个集群。Hadoop最好的一个方面是您可以很确定您的MapReduce程序可以在集群机上运行,处理100或者1000倍的数据,而几乎不需要改动任何代码。
一个开发中经常涉及的话题,是为您的大量的生产数据建立较小的用于示范的子集,这也被称为开发数据集。这些开发数据集可能只有几百兆。这将缩短您的开发进程中的,在开发与生产环境之间切换所需要的往返时间,便于您在自己的机器上运行,并在另一个独立的环境中进行调试。
我们选择这两个数据集是因为它们与您遇到的大多数数据类型相似。首先,这些引用数据构成了一个“图”,而用于描述网络连接和社交网络的数据结构也是图。专利是按时间顺序公布的,它们的一些属性表示了时间序列。每个专利都与一个人(发明者)和一个地点(发明者所在的国家)。您可以将它们看作个人或者地理信息。最后您可以将这些数据看作定义良好的数据库关系,它们以逗号分隔。
请注意
有很多这两个数据集无法完全表现的数据类型,例如文本,但您已经在单词计算的例子里见过文本了。其他没有涉及的类型包括XML、图像和地理位置信息(用经纬度的形式表示)。数学矩阵没有以一般的形式表示,尽管引用图可以被解释为离散的0/1矩阵。
4.1.1 专利引用数据
这些专利引用数据包含了美国从1975年到1999年之间发布的引用。它有超过1600万行数据,并且前几行包含类似这样的信息:
以专利数据集65为例:
“CITING”,”CITED”
3858241,956203
3858241,1324234
3858241,3398406
3858241,3557384
3858241,3634889
3858242,1515701
3858242,3319261
3858242,3668705
3858242,3707004
...
数据集以标准的逗号分隔值(CSV)格式表示,第一行是列的描述。其他的每一行记录了一个特定的引用。例如,第二行表示专利3858241引用了专利956203。文件是按照进行引用的专利(而不是被引用的专利)进行排序的。我们可以看到专利3858241总共引用了五个专利。更定量地分析这些数据可以使我们对它有一个更深入的了解。
如果您只是阅读这个数据文件,引用数据看起来好像只是一系列的数据。您可以用更有趣的术语来考虑这些数据。一种方式是将它想象为一张图。在图 4.1中,我们展示了这张引用图的一部分。我们可以看到有些专利经常被引用,而另一些则从来没有被引用过。专利5936972和6009552引用了类似的专利集合(4354269, 4486882, 5598422),尽管它们没有相互引用。我们可以使用Hadoop来获取关于这些专利数据的描述性的数据,并寻找有趣的但不那么明显的专利。
4.1.2 专利描述数据
我们使用的另一个数据集是描述数据。它包含了专利号、专利申请年份、专利授予年份、索赔金额和其他关于专利的元数据。看看这个数据的前面几行。它与一个关系型数据库中的表格很相似,但它是CSV格式的。这个数据集有超过290万行记录。和现实世界中的很多数据集一样,它可能有丢失的数据
专利描述数据
“PATENT”,”GYEAR”,”GDATE”,”APPYEAR”,”COUNTRY”,”POSTATE”,”ASSIGNEE”,
➥ ”ASSCODE”,”CLAIMS”,”NCLASS”,”CAT”,”SUBCAT”,”CMADE”,”CRECEIVE”,
➥ ”RATIOCIT”,”GENERAL”,”ORIGINAL”,”FWDAPLAG”,”BCKGTLAG”,”SELFCTUB”,
➥ ”SELFCTLB”,”SECDUPBD”,”SECDLWBD”
3070801,1963,1096,,”BE”,””,,1,,269,6,69,,1,,0,,,,,,,
3070802,1963,1096,,”US”,”TX”,,1,,2,6,63,,0,,,,,,,,,
3070803,1963,1096,,”US”,”IL”,,1,,2,6,63,,9,,0.3704,,,,,,,
3070804,1963,1096,,”US”,”OH”,,1,,2,6,63,,3,,0.6667,,,,,,,
3070805,1963,1096,,”US”,”CA”,,1,,2,6,63,,1,,0,,,,,,,
3070806,1963,1096,,”US”,”PA”,,1,,2,6,63,,0,,,,,,,,,
3070807,1963,1096,,”US”,”OH”,,1,,623,3,39,,3,,0.4444,,,,,,,
3070808,1963,1096,,”US”,”IA”,,1,,623,3,39,,4,,0.375,,,,,,,
3070809,1963,1096,,”US”,”AZ”,,1,,4,6,65,,0,,,,,,,,,
请注意
和其他数据分析一样,我们在解释这些有限的数据时需要非常地谨慎。如果一个专利看起来没有引用任何其他的专利,它可能是我们没有引用信息的旧的专利。另一方面,时间越晚的专利被引用的频率更小,因为只有更新的专利才会意识到它们的存在。
图 4.1 将专利引用数据的一部分看作一张图。每个专利显示为一个顶点(节点),而每个引用是一条有向边(箭头)。
第一行包含了一些属性的名称,这只有对专利专家有意义。尽管我们不了解所有的属性,了解它们中的一部分仍然是十分有用的。表 4.1描述了前10行。
表 4.1 专利描述数据集前10个属性的定义
属性名称 | 内容 |
PATENT | 专利号 |
GYEAR | 授权年份 |
GDATE | 授权日期, 从1960年1月1日算起的日期数 |
APPYEAR | 申请日期(只对1967年之后授权的专利有效) |
COUNTRY | 第一发明人的国家 |
POSTATE | 第一发明人所在的州(如果国家是美国) |
ASSIGNEE | 专利受让人的数字标识(例如,专利拥有者) |
ASSCODE | 一位数(1-9)表示的受让人类型。 (受让人类型包括美国个人,美国政府,美国组织,非美国个人,等等) |
CLAIMS | 索赔金额(只对1975年之后授权的专利有效) |
NCLASS | 三位数表示的专利类别 |
既然我们已经有了两个专利数据集,那么让我们编写Hadoop程序来处理这些数据吧。
4.2 建立MapReduce程序的基本模板
我们的大多数MapReduce程序是简短的并且是在一个模板上进行变化的。担负编写一个新的MapReduce程序时,您通常需要在一个现有的MapReduce程序上进行修改,直到它成为您想要的样子。在这个小节里,我们将编写第一个MapReduce程序并解释它的不同部分。这个程序可以作为将来的MapReduce程序的模板。我们的第一个程序将把专利引用数据作为输入,并将它反转。对每个专利,我们想要找出引用它的专利并将它们分组。我们的输出如下:
输出
1 3964859,4647229
10000 4539112
100000 5031388
1000006 4714284
1000007 4766693
1000011 5033339
1000017 3908629
1000026 4043055
1000033 4190903,4975983
1000043 4091523
1000044 4082383,4055371
1000045 4290571
1000046 5918892,5525001
1000049 5996916
1000051 4541310
1000054 4946631
1000065 4748968
1000067 5312208,4944640,5071294
1000070 4928425,5009029
我们已经发现专利5312208、4944640和507129引用了专利1000067。在这个小节里,我们不会太关注MapReduce数据流,也就是我们在第3章中探讨过的。相反地,我们只关注MapReduce程序的结构。整个程序只需要一个文件,就像您在清单 4.1中看到的那样。
清单 4.1 经典Hadoop程序的模板
public class MyJob extends Configured implements Tool {
public static class MapClass extends MapReduceBase implements
Mapper<Text, Text, Text, Text> {
public void map(Text key, Text value,
OutputCollector<Text, Text> output, Reporter reporter)
throws IOException {
output.collect(value, key);
}
}
public static class Reduce extends MapReduceBase implements
Reducer<Text, Text, Text, Text> {
public void reduce(Text key, Iterator<Text> values,
OutputCollector<Text, Text> output, Reporter reporter)
throws IOException {
String csv = "";
while (values.hasNext()) {
if (csv.length() > 0)
csv += ",";
csv += values.next().toString();
}
output.collect(key, new Text(csv));
}
}
public int run(String[] args) throws Exception {
Configuration conf = getConf();
JobConf job = new JobConf(conf, MyJob.class);
Path in = new Path(args[0]);
Path out = new Path(args[1]);
FileInputFormat.setInputPaths(job, in);
FileOutputFormat.setOutputPath(job, out);
job.setJobName("MyJob");
job.setMapperClass(MapClass.class);
job.setReducerClass(Reduce.class);
job.setInputFormat(KeyValueTextInputFormat.class);
job.setOutputFormat(TextOutputFormat.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
job.set("key.value.separator.in.input.line", ",");
JobClient.runJob(job);
return 0;
}
public static void main(String[] args) throws Exception {
int res = ToolRunner.run(new Configuration(), new MyJob(), args);
System.exit(res);
}
}
我们的惯例是使用单一的类,如这个例子里的MyJob,完全地定义每个MapReduce任务。Hadoop需要将Mapper和Reducer作为它们自己的静态类。这些类很小,并且我们的模板将它们作为MyJob类的内部类。但是请记住,这些内部类是独立的,并且不与MyJob类交互。在任务执行的过程中,不同Java虚拟机上的多个节点将复制并运行Mapper和Reducer,而job类剩下的部分只在客户端机器上运行。
我们先探讨一下Mapper类和Reducer类。不考虑这些类的话,MyJob类的基本结构如下:
public class MyJob extends Configured implements Tool {
public int run(String[] args) throws Exception {
Configuration conf = getConf();
JobConf job = new JobConf(conf, MyJob.class);
Path in = new Path(args[0]);
Path out = new Path(args[1]);
FileInputFormat.setInputPaths(job, in);
FileOutputFormat.setOutputPath(job, out);
job.setJobName("MyJob");
job.setMapperClass(MapClass.class);
job.setReducerClass(Reduce.class);
job.setInputFormat(KeyValueTextInputFormat.class);
job.setOutputFormat(TextOutputFormat.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
job.set("key.value.separator.in.input.line", ",");
JobClient.runJob(job);
return 0;
}
public static void main(String[] args) throws Exception {
int res = ToolRunner.run(new Configuration(), new MyJob(), args);
System.exit(res);
}
}
这个骨架的核心在run()方法中,也可以把它称为driver。driver实例化、配置并将一个被命名为job的JobConf传递给JobClient。用runJob()来启动MapReduce job。(JobClient类会与JobTracker交互以通过集群启动job。)JobConf对象包含了运行job所必需的所有配置参数。driver需要需要指定job的输入路径、输出路径,Mapper类和Reducer类——每个job的基础参数。此外,每个job将重置job的默认属性,如InputFOrmat,OutputFormat等等。可以调用JobConf对象的set()方法来设置配置参数。一旦您将JobConf对象传递给JobClient.runJob(),它将会被作为job的总体规划(master plan)。它将会称为如何运行job的蓝图。
JobConf对象可能会有很多参数,但我们不会在driver中设置所有参数。Hadoop安装的配置文件是一个号的起点。当通过命令行来启动一个Job时,用户可能会想要传递其余的参数来修改job的配置。driver自己可以定义它自己的命令,并处理用户输入的参数,使得用户可以修改配置参数。由于这项任务将会需要频繁地进行,Hadoop框架提供了ToolRunner、Tool和Configured来简化它。当与上面的MyJob骨架一起使用时,这些类将会使得我们的job理解用户定义的,并且被GenericOptionsParser所支持的选项。例如,我们之前使用这个命令行来执行MyJob类:
bin/hadoop jar playground/MyJob.jar MyJob input/cite75_99.txt output
如果我们只是想运行job并查看mapper的输出(可能在您进行调试的时候需要这么做),我们可以使用如下选项将reducer的数量设置为0:
bin/hadoop jar playground/MyJob.jar MyJob -D mapred.reduce.tasks=0
➥ input/cite75_99.txt output
这在我们的程序并不显式地解释-D选项时也仍然是有效的。通过使用ToolRunner,MyJob可以自动支持表4.2中的选项。通过使用ToolRunner,MyJob将自动支持表1.2中列出的选项。
表 4.2 GenericOptionsParser支持的选项
选项 | 描述 |
-conf <configurationfile> | 指定一个配置文件。 |
-D <property=value> | 设置JobConf的属性。 |
-fs <local|namenode:port> | 指定一个NameNode,可以为“local”。 |
-jt <local|jobtracker:port> | 指定一个JobTracker。 |
-files <list of fi les> | 指定一个用逗号分隔的文件列表,这些文件将在MapReduce job中被用到。 这些文件将被自动地分配到所有的任务节点上,使得在本地可以使用。 |
-libjars <list of jars> | 指定一个用逗号分隔的jar文件的列表,它们被包含于所有的任务JVM的classpath中。 |
-archives <list of archives> | 指定一个用逗号分隔的压缩文件列表,将在所有节点上被解压 |
我们的模板的惯例是将Mapper类命名为MapClass,并将Reducer类命名为Reduce。更对称的命名方法是将Mapper类命名为Map,但Java已经有一个名为Map的类(接口)了。Mapper和Reducer都继承自MapReduceBase,这个基类提供了这两个接口所需要的configure()和close()方法(但没有进行任何操作)。 我们使用configure()和close()方法来建立map(reduce)任务。除非需要使用更高级的job,否则我们不需要覆盖它们。
Mapper类和Reducer类的方法签名如下:
public static class MapClass extends MapReduceBase implements
Mapper<K1, V1, K2, V2> {
public void map(K1 key, V1 value, OutputCollector<K2, V2> output,
Reporter reporter) throws IOException {
}
}
public static class Reduce extends MapReduceBase implements
Reducer<K2, V2, K3, V3> {
public void reduce(K2 key, Iterator<V2> values,
OutputCollector<K3, V3> output, Reporter reporter)
throws IOException {
}
}
Mapper类和Reducer类的核心操作分别是map()和reduce()方法。每个对map()方法的调用都需要提供类型分别为K1和V1的键/值对。这个键/值对是由mapper生成的,并且通过OutputCollector对象的collect() 方法输出。在您的map()方法中的某处,您需要调用
output.collect((K2) k, (V2) v);
每个对reducer的reduce()方法的调用都需要提供类型为K2的键和类型为V2的值的列表。请注意这与在Mapper中使用的K2和V2必须是相同的。reduce()方法可能会有一个用于遍历类型为V2的值的循环。
while (values.hasNext()) {
V2? v = values.next();
...
}
reduce()方法同时也有一个用于收集键/值输出的OutputCollector,类型是K3/V3。在reduce()方法中的某处您需要调用output.collect((K3) k, (V3) v);除了在Mapper和Reducer中使用一致的K2和V2类型,您还需要确保Mapper和Reducer中使用的键/值类型与driver中设置的输入格式、输出键类型和值类型是一致的。使用KeyValueTextInputFormat 意味着K1和V1都需要是Text类型的。driver需要分别用K2类和V2类来调用setOutputKeyClass()和setOutputValueClass()。
最后,键和值的类型需要是Writable的子类,以确保Hadoop的序列化接口可以将数据分发到分布式集群中。事实上,键类型实现了WritableComparable,是Writable的子接口。键类型需要额外地支持compareTo()方法,因为键需要在MapReduce框架中的多个地方进行排序。