Hadoop MapReduce join
MapReduce提供了表连接操作其中包括Map端join、Reduce端join还有半连接,Map端join是指数据到达map处理函数之前进行合并的,效率要远远高于Reduce端join,因为Reduce端join是把所有的数据都经过Shuffle,非常消耗资源。
1.map join
基本思路:
(1):需要join的两个文件,一个存储在HDFS中,一个使用DistributedCache.addCacheFile()将需要join的另外一个文件加入到所有Map缓存中。
(2):在Map函数里读取该文件,进行join
(3):将结果输出到reduce
(4):DistributedCache.addCacheFile()需要在作业提交前设置。
什么是DistributedCache?
DistributedCache是为了方便用户进行应用程序开发而设计的文件分发工具。它能够将只读的外部文件进行自动分发到各个节点上进行本地缓存,以便task运行时加载。
DistributedCache的使用步骤
(1):在HDFS中上传文件(文本文件、压缩文件、jar包等)
(2):调用相关API添加文件信息
(3):task运行前直接调用文件读写API获取文件。
常见API:
DistributedCache.addCacheFile();
DistributedCache.addCacheArchive();
下面我们通过一个示例来深入体会Map端join。
表一:tb_a数据如下
name sex age depNo
zhang male 20 1
li female 25 2
wang female 30 3
zhou male 35 2
表二:tb_b数据如下
depNo depName
1 sales
2 Dev
3 Mgt
注意:在Map端join操作中,我们往往将较小的表添加到内存中,因为内存的资源是很宝贵的,这也说明了另外一个问题,那就是如果表的数据量都非常大则不适合使用Map端join。
public class MyMapJoin {
// 定义输入路径
private static String INPUT_PATH1 = "";
//加载到内存的表的路径
private static String INPUT_PATH2 = "";
// 定义输出路径
private static String OUT_PATH = "";
public static void main(String[] args) {
try {
// 创建配置信息
Configuration conf = new Configuration();
// 获取命令行的参数
String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs();
// 当参数违法时,中断程序
if (otherArgs.length != 3) {
System.err.println("Usage:MyMapJoin ");
System.exit(1);
}
// 给路径赋值
INPUT_PATH1 = otherArgs[0];
INPUT_PATH2 = otherArgs[1];
OUT_PATH = otherArgs[2];
// 创建文件系统
FileSystem fileSystem = FileSystem.get(new URI(OUT_PATH), conf);
// 如果输出目录存在,我们就删除
if (fileSystem.exists(new Path(OUT_PATH))) {
fileSystem.delete(new Path(OUT_PATH), true);
}
// 添加到内存中的文件(随便添加多少个文件)
DistributedCache.addCacheFile(new Path(INPUT_PATH2).toUri(), conf);
// 创建任务
Job job = new Job(conf, MyMapJoin.class.getName());
// 打成jar包运行,这句话是关键
job.setJarByClass(MyMapJoin.class);
//1.1 设置输入目录和设置输入数据格式化的类
FileInputFormat.setInputPaths(job, INPUT_PATH1);
job.setInputFormatClass(TextInputFormat.class);
//1.2 设置自定义Mapper类和设置map函数输出数据的key和value的类型
job.setMapperClass(MapJoinMapper.class);
job.setMapOutputKeyClass(NullWritable.class);
job.setMapOutputValueClass(Emp_Dep.class);
//1.3 设置分区和reduce数量
job.setPartitionerClass(HashPartitioner.class);
job.setNumReduceTasks(0);
FileOutputFormat.setOutputPath(job, new Path(OUT_PATH));
// 提交作业 退出
System.exit(job.waitForCompletion(true) ? 0 : 1);
} catch (Exception e) {
e.printStackTrace();
}
}
public static class MapJoinMapper extends Mapper {
private Map joinData = new HashMap();
@Override
protected void setup(Mapper.Context context) throws IOException, InterruptedException {
// 预处理把要关联的文件加载到缓存中
Path[] paths = DistributedCache.getLocalCacheFiles(context.getConfiguration());
// 我们这里只缓存了一个文件,所以取第一个即可,创建BufferReader去读取
BufferedReader reader = new BufferedReader(new FileReader(paths[0].toString()));
String str = null;
try {
// 一行一行读取
while ((str = reader.readLine()) != null) {
// 对缓存中的表进行分割
String[] splits = str.split("\t");
// 把字符数组中有用的数据存在一个Map中
joinData.put(Integer.parseInt(splits[0]), splits[1]);
}
} catch (Exception e) {
e.printStackTrace();
} finally{
reader.close();
}
}
@Override
protected void map(LongWritable key, Text value, Mapper.Context context) throws IOException,
InterruptedException {
// 获取从HDFS中加载的表
String[] values = value.toString().split("\t");
// 创建Emp_Dep对象
Emp_Dep emp_Dep = new Emp_Dep();
// 设置属性
emp_Dep.setName(values[0]);
emp_Dep.setSex(values[1]);
emp_Dep.setAge(Integer.parseInt(values[2]));
// 获取关联字段depNo,这个字段是关键
int depNo = Integer.parseInt(values[3]);
// 根据depNo从内存中的关联表中获取要关联的属性depName
String depName = joinData.get(depNo);
// 设置depNo
emp_Dep.setDepNo(depNo);
// 设置depName
emp_Dep.setDepName(depName);
// 写出去
context.write(NullWritable.get(), emp_Dep);
}
}
}
2.reduce join
Reduce端连接比Map端连接更为普遍,因为输入的数据不需要特定的结构,但是效率比较低,因为所有数据都必须经过Shuffle过程。
基本思路
(1):Map端读取所有的文件,并在输出的内容里加上标示,代表数据是从哪个文件里来的。
(2):在reduce处理函数中,按照标识对数据进行处理。
(3):然后根据Key去join来求出结果直接输出。
数据准备
准备好下面两张表:
(1):tb_a(以下简称表A)
- id name
- 1 北京
- 2 天津
- 3 河北
- 4 山西
- 5 内蒙古
- 6 辽宁
- 7 吉林
- 8 黑龙江
(2):tb_b(以下简称表B)
- id statyear num
- 1 2010 1962
- 1 2011 2019
- 2 2010 1299
- 2 2011 1355
- 4 2011 3574
- 4 2011 3593
- 9 2010 2303
- 9 2011 2347
#需求就是以id为key做join操作(注:上面的数据都是以制表符“\t”分割)
计算模型
整个计算过程是:
(1):在Map阶段,把所有数据标记成
(2):在reduce阶段,先把每个key下的value列表拆分为分别来自表A和表B的两部分,分别放入两个向量中。然后遍历两个向量做笛卡尔积,形成一条条最终的结果。
如下图所示:
代码实现如下:
public class ReduceJoinTest {
// 定义输入路径
private static final String INPUT_PATH = "hdfs://liaozhongmin:9000/table_join/tb_*";
// 定义输出路径
private static final String OUT_PATH = "hdfs://liaozhongmin:9000/out";
public static void main(String[] args) {
try {
// 创建配置信息
Configuration conf = new Configuration();
// 创建文件系统
FileSystem fileSystem = FileSystem.get(new URI(OUT_PATH), conf);
// 如果输出目录存在,我们就删除
if (fileSystem.exists(new Path(OUT_PATH))) {
fileSystem.delete(new Path(OUT_PATH), true);
}
// 创建任务
Job job = new Job(conf, ReduceJoinTest.class.getName());
//1.1 设置输入目录和设置输入数据格式化的类
FileInputFormat.setInputPaths(job, INPUT_PATH);
job.setInputFormatClass(TextInputFormat.class);
//1.2 设置自定义Mapper类和设置map函数输出数据的key和value的类型
job.setMapperClass(ReduceJoinMapper.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(Text.class);
//1.3 设置分区和reduce数量(reduce的数量,和分区的数量对应,因为分区为一个,所以reduce的数量也是一个)
job.setPartitionerClass(HashPartitioner.class);
job.setNumReduceTasks(1);
//1.4 排序
//1.5 归约
//2.1 Shuffle把数据从Map端拷贝到Reduce端。
//2.2 指定Reducer类和输出key和value的类型
job.setReducerClass(ReduceJoinReducer.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
//2.3 指定输出的路径和设置输出的格式化类
FileOutputFormat.setOutputPath(job, new Path(OUT_PATH));
job.setOutputFormatClass(TextOutputFormat.class);
// 提交作业 退出
System.exit(job.waitForCompletion(true) ? 0 : 1);
} catch (Exception e) {
e.printStackTrace();
}
}
public static class ReduceJoinMapper extends Mapper{
@Override
protected void map(LongWritable key, Text value, Mapper.Context context) throws IOException, InterruptedException {
//获取输入文件的全路径和名称
FileSplit fileSplit = (FileSplit) context.getInputSplit();
String path = fileSplit.getPath().toString();
//获取输入记录的字符串
String line = value.toString();
//抛弃空记录
if (line == null || line.equals("")){
return;
}
//处理来自tb_a表的记录
if (path.contains("tb_a")){
//按制表符切割
String[] values = line.split("\t");
//当数组长度小于2时,视为无效记录
if (values.length < 2){
return;
}
//获取id和name
String id = values[0];
String name = values[1];
//把结果写出去
context.write(new Text(id), new Text("a#" + name));
} else if (path.contains("tb_b")){
//按制表符切割
String[] values = line.split("\t");
//当长度不为3时,视为无效记录
if (values.length < 3){
return;
}
//获取属性
String id = values[0];
String statyear = values[1];
String num = values[2];
//写出去
context.write(new Text(id), new Text("b#" + statyear + " " + num));
}
}
public static class ReduceJoinReducer extends Reducer{
@Override
protected void reduce(Text key, Iterable values, Reducer.Context context) throws IOException, InterruptedException {
//用来存放来自tb_a表的数据
Vector vectorA = new Vector();
//用来存放来自tb_b表的
Vector vectorB = new Vector();
//迭代集合数据
for (Text val : values){
//将集合中的数据对应添加到Vector中
if (val.toString().startsWith("a#")){
vectorA.add(val.toString().substring(2));
} else if (val.toString().startsWith("b#")){
vectorB.add(val.toString().substring(2));
}
}
//获取两个Vector集合的长度
int sizeA = vectorA.size();
int sizeB = vectorB.size();
//遍历两个向量将结果写出去
for (int i=0; i
程序运行的结果:
semi join
SemiJoin,一般称为半连接,其原理是在Map端过滤掉一些不需要join的数据,从而大大减少了reduce和Shuffle的时间,因为我们知道,如果仅仅使用Reduce端连接,那么如果一份数据,存在大量的无效数据,而这些数据在join中并不需要,但是因为没有做过预处理,所以这些数据直到真正执行reduce函数时,才被定义为无效数据,但是这个时候已经执行过了Shuffle、merge还有sort操作,所以这部分无效的数据就浪费了大量的网络IO和磁盘IO,所以在整体来讲,这是一种降低性能的表现,如果存在的无效数据越多,那么这种趋势就越明显。之所以会出现半连接,这其实是reduce端连接的一个变种,只不过是我们在Map端过滤掉了一些无效的数据,所以减少了reduce过程的Shuffle时间,所以能获取一个性能的提升。
二:技术实现
(1):利用DistributedCache将小表分发到各个节点上,在Map过程的setup()函数里,读取缓存里的文件,只将小表的连接键存储在hashSet中。
(2):在map()函数执行时,对每一条数据进行判断,如果这条数据的连接键为空或者在hashSet里不存在,那么则认为这条数据无效,使条数据也不参与reduce的过程。
注:从以上步骤就可以发现,这种做法很明显可以提升join性能,但是要注意的是小表的key如果非常大的话,可能会出现OOM的情况,这时我们就需要考虑其他的连接方式了。