MapReduce能够执行大型数据集间的“连接”(join)操作,但是,自己从头写相关代码来执行连接的确非常棘手。除了写MapReduce程序,还可以考虑采用更高级的框架,如Pig、Hive、Cascading、Cruc或Spark等,它们都将连接操作视为整个实现的核心部分。
先简要地描述待解决的问题。假设有两个数据集:气象站数据库和天气记录数据集并考虑如何合二为一。一个典型的查询是:输出各气象站的历史信息,同时各行记录也包含气象站的元数据信息,如图9·2所示。
连接操作的具体实现技术取决于数据集的规模及分区方式。如果一个数据集很大(例如天气记录)而另外一个集合很小,以至于可以分发到集群中的每一个节点之中(例如气象站元数据),则可以执行一个MapReduce作业,将各个气象站的天气记录放到一块(例如,根据气象站ID执行部分排序),从而实现连接。mapper或reducer根据各气象站ID从较小的数据集合中找到气象站元数据,使元数据能够被写到各条记录之中。该方法将在9,4节中详细介绍,它侧重于将数据分发到集群中节点的机制。
连接操作如果由mapper执行,则称为"map端连接";如果由reducer执行,则称为"reduce端连接"。
如果两个数据集的规模均很大,以至于没有哪个数据集可以被完全复制到集群的每个节点,我们仍然可以使用MapReduce来进行连接,至于到底采用map端连接还是reduce端连接,则取决于数据的组织方式。最常见的一个例子便是用户数据库和用户活动日志(例如访问日志)0对于一个热门服务来说,将用户数据库(或日志)分发到所有MapReduce节点中是行不通的。
1、map端连接
在两个大规模输人数据集之间的map端连接会在数据到达map函数之前就执行连接操作。为达到该目的,各map的输人数据必须先分区并且以特定方式排序。各个输人数据集被划分成相同数鼠的分区,并且均按相同的键(连接键)排序。同一键的所有记录均会放在同一分区之中。听起来似乎要求非常严格啲确如此,但这的确合乎MapReduce作业的输出。
map端连接操作可以连接多个作业的输出,只要这些作业的reducer数量相同、键相同并且输出文件是不可切分的(例如,借助于小于一个HDFS块、或进行gzip压缩来实现)。在天气例子中,如果气象站文件以气象站ID部分排序,记录文件也以气象站ID部分排序,而且reducer的数量相同,则就满足了执行map端连接的前提条件。
利用org.apache.hadoop.mapreduce.join包中的CompositeInputFormat类来运行一个map端连接。compositeInputFormat类的输人源和连接类型(内连接或外连接)可以通过一个连接表达式进行配置,连接表达式的语法简单。详情与示例可参见包文档。
org.apache.hadoop.examples.join是一个通用的执行map端连接的命令行程序样例。该例运行一个基于多个输人数据集的mapper和reducer的MapReduce作业,以执行给定的连接操作。
2、reduce端连接
由于reduce端连接并不要求输人数据集符合特定结构,因而reduce端连接比map端连接更为常用。但是,由于两个数据集均需经过MapReduce的shume过程,所以reduce端连接的效率往往要低一些。基本思路是mapper为各个记录标记源,并且使用连接键作为出键,使键相同的记录放在同一个reducer中。以下技术能帮助实现reduce端连接。
2.1、多输入
数据集的输人源往往有多种格式,因此可以使用Mu1tip1e1nputs类(参见8.2.4节)来方便地解析和标注各个源。
2.2、辅助排序
如前所述,reducer将从两个源中选出键相同的记录,但这些记录不保证是经过排序的。然而,为了更好地执行连接操作,一个源的数据排列在另一个源的数据前是非常重要的。以天气数据连接为例,对应每个键,气象站记录的值必须是最先看到的,这样reducer能够将气象站名称填到天气记录之中再马上输出。虽然也可以不指定数据传输次序,并将待处理的记录缓存在内存之中,但应该尽量避免这种情况,因为其中任何一组的记录数量可能非常庞大,远远超出reducer的可用内存容量。
9.2.4节介绍如何对reducer所看到的每个键的值进行排序,所以在此也用到了辅助排序技术。
为标记每个记录,我们使用第5章的TextPair类,包括键(存储气象站ID)和“标记”。在这里,“标记"是一个虚拟的字段,其唯一目的是用于记录的排序,使气象站记录比天气记录先到达。一种简单的做法就是:对于气象站记录,“标记”值为0;对于天气记录,“标记”值为1。范例9-9和范例9-10分别描述了执行该任务的两个mapper类。
范例9-9,在reduce端连接中,标记气象站记录的mapper。
public class JoinStationMapper extends Mapper<LongWritable, Text, TextPair, Text> {
private NcdcStationMetadataParser parser = new NcdcStationMetadataParser();
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
if (parser.parse(value)) {
context.write(new TextPair(parser.getStationId(), "0"),new Text(parser.getStationName()));
}
}
}
范例9-10.在reduce端连接中标记天气记录的mapper
public class JoinRecordMapper extends Mapper<LongWritable, Text, TextPair, Text> {
private NcdcRecordParser parser = new NcdcRecordParser();
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
parser.parse(value);
context.write(new TextPair(parser.getStationId(), "1"), value);
}
}
reducer知道自己会先接收气象站记录。因此从中抽取出值,并将其作为后续每条输出记录的一部分写到输出文件。如范例9-11所示。
范例9-11.用于连接已标记的气象站记录和天气记录的reducer。
public class JoinReducer extends Reducer<TextPair, Text, Text, Text> {
@Override
protected void reduce(TextPair key, Iterable<Text> values, Context context) throws IOException,InterruptedException {
Iterator<Text> iter = values.iterator();
Text stationName = new Text(iter.next());
while (iter.hasNext()) {
Text record = iter.next();
Text outValue = new Text(stationName.toString() + "\t" + record.toString());
context.write(key.getFirst(), outValue);
}
}
}
上述代码假设天气记录的每个气象站ID恰巧与气象站数据集中的一条记录准确匹配。如果该假设不成立,则需要泛化代码,使用另一个TextPair将标记放人值的对象中。reduce()方法在处理天气记录之前,要能够区分哪些记录是气象站名称,检测(和处理)缺失或重复的记录。
在reducer的迭代部分中,对象被重复使用(为了提高效率)0因此,从第一个Text对象获得站点名称(即stationName)就非常关键。Text stationName=new Text(iter.next());如果不执行该语句,stationName就会指向上一条记录的值,这显然是错的。
将作业连接在一起通过驱动类来完成,如范例9-12所示。这里,关键点在于根据组合键的第一个字段(即气象站(D)进行分区和分组,即使用一个自定义的partitioner(即KeyPartitioner)和一个自定义的分组comparator(FirstComparator,作为TextPair的嵌套类)。
范例9-12,对天气记录和气象站名称执行连接操作
public class JoinRecordWithStationName extends Configured implements Tool {
public static class KeyPartitioner extends Partitioner<TextPair, Text> {
@Override
public int getPartition(/*[*/TextPair key/*]*/, Text value, int numPartitions) {
return (/*[*/key.getFirst().hashCode()/*]*/ & Integer.MAX_VALUE) % numPartitions;
}
}
@Override
public int run(String[] args) throws Exception {
if (args.length != 3) {
JobBuilder.printUsage(this, "<ncdc input> <station input> <output>");
return -1;
}
Job job = new Job(getConf(), "Join weather records with station names");
job.setJarByClass(getClass());
Path ncdcInputPath = new Path(args[0]);
Path stationInputPath = new Path(args[1]);
Path outputPath = new Path(args[2]);
MultipleInputs.addInputPath(job, ncdcInputPath,TextInputFormat.class, JoinRecordMapper.class);
MultipleInputs.addInputPath(job, stationInputPath,TextInputFormat.class, JoinStationMapper.class);
FileOutputFormat.setOutputPath(job, outputPath);
job.setPartitionerClass(KeyPartitioner.class);
job.setGroupingComparatorClass(TextPair.FirstComparator.class);
job.setMapOutputKeyClass(TextPair.class);
job.setReducerClass(JoinReducer.class);
job.setOutputKeyClass(Text.class);
return job.waitForCompletion(true) ? 0 : 1;
}
public static void main(String[] args) throws Exception {
int exitCode = ToolRunner.run(new JoinRecordWithStationName(), args);
System.exit(exitCode);
}
}
在样本数据上运行这个程序,获得以下输出: