首先是 Map 的过程,输入数据是一封一封的邮件,彼此之间没有任何关联,因此可以很自然地分组处理。Map 将邮件转化到以邮件的收件人进行分组,如果邮件是垃圾邮件,则映射到收件人的垃圾邮件数“+1”。Reduce 的过程就是将各个收件人的邮件数统计结果加起来。
在 Hadoop 中实现一个 map 过程只需要实现 Mapper 接口就行了,一般同时继承自 MapReduceBase ,可以省下不少力气。map 接受 key, value 的 pair ,这是按照初始输入数据进行分组的,通常 map 方法从 value 中解析感兴趣的内容,并进行重新分组。map 的另一个 OutputCollector 类型的参数就是专门用于收集 map 之后的新分组的。map 方法看起来是这个样子:
1 | @Override |
由于数据需要在网络上传输,Hadoop 要求 Key 和 Value 的类型都必须是可以序列化的,不过这不是 Java 自带的那个序列化接口 Serializable
,而是 Hadoop 自己定义的一个更加简易的 Writable
接口。另外,由于 Key 是需要用于进行排序和分组的,所以需要实现的是更加具体的一个叫做 WriteComparable
的接口。不过,常用的数据类型 Hadoop 都提供了现成的支持,比如 Text
可以用于存放文本,LongWritable
可以用于存放长整型数据。
在这个任务中,我 map 的输入 key 是长整型,在这里是分组时在文件中的 offset ,这里不需要用到它,直接忽略。而 value 是一封邮件的邮件头的内容。首先我要判断邮件是否是垃圾邮件。原本我可以用作好的分类器进行在线分类,或者使用一个已经做好的 index 来进行判断,不过为了让示例简单一些,我对邮件内容进行了预处理,直接将垃圾邮件标记作为一个邮件头域插入了邮件首部。因此判断是否是垃圾邮件的代码是直 接从邮件头里搜索相应标签:
1 | private boolean isSpam(String header) { |
判断出是垃圾邮件之后,就解析出邮件的收件人地址,这里暂时不考虑多个收件人或者有抄送之类的情况。得到收件人之后(比如,是 foo@bar.com
),就算得到了一组结果。这将作为后面 reduce 任务的输入:key 的类型是 Text
,亦即收件人地址;value 的类型是 LongWritable
,即收到的垃圾邮件数目。当发现一封垃圾邮件时,就将这个中间结果 (foo@bar.com, 1)
收集起来。
这就完成了 map 的过程,之后应该是 reduce ,这个过程很简单,系统会按照 map 的结果将各个分组的结果传递到 reduce 函数,这里 reduce 只要把各个 LongWritable
加起来得到总和就可以了。在 Hadoop 自带的 Word Count 的示例中有一个类似的例子(不过这里加的是 IntWritable
):
1 | public class Reduce extends MapReduceBase |
不过因为 Hadoop 自带了一个 LongSumReducer
可以完成我们需要做的事情,就不用自己再费力写一个了。 另外,Hadoop 在 map 和 reduce 之间还有一个叫做 combine 的步骤,可以看作是“本地的 reduce ”。除了某些特殊情况,一般 combine 和 reduce 做的事情是一样的(因此这两个任务通常也是通过同一段代码来实现的),只是 combine 只在本地运行,将当前节点得到的局部结果进行一下局部的 reduce ,这样通常可以减少需要进行网络传输的数据量。例如,如果当前节点发现了 5 封发给 foo@bar.com
的垃圾邮件,需要对 5 个 (foo@bar.com, 1)
进行 reduce 调度,而经过本地 combine 之后,只需要处理一个 (foo@bar.com, 5)
就可以了。
map 和 reduce 做好之后,新建一个任务,并告知用于完成 map 和 reduce 任务的类:
countJob.setMapperClass(CountMapper.class);
countJob.setCombinerClass(LongSumReducer.class);
countJob.setReducerClass(LongSumReducer.class);
再设置输入输出的 key 和 value 的类型:
countJob.setInputKeyClass(LongWritable.class);
countJob.setInputValueClass(Text.class);
countJob.setOutputKeyClass(Text.class);
countJob.setOutputValueClass(LongWritable.class);
配置好之后调用 JobClient.runJob(countJob)
就可以开始 MapReduce 了。大致的流程就是这样,不过中间还有一些细节需要处理。一个问题就是结果如何输出?我们最终得到的是一些 (Text, LongWritable)
的 pair ,一个办法是让它以文本方式按行输出到文本文件中,这样只需要使用内置的 SequenceFileOutputFormat
即可:
countJob.setOutputFormat(SequenceFileOutputFormat.class);
另一个问题则是输入的格式,Hadoop 需要理解了输入文件的格式才能将其解析并作为参数传递给 map 函数,更重要的是:进行合适的分割,将任务分配到各个节点上去。Hadoop 使用 InputFormat
来控制输入格式,默认情况下,将输入目录中每个文件当作以行为单位的文本进行处理和分割,除此之外 Hadoop 也内置了二进制记录等文件类型的支持。不过我这里的情况比较特殊:一个文件中只有一封邮件,需要当作一个不可分割的原子来处理。因此我实现了一个 AtomFileInputFormat
,并在 isSplitable
方法中总是返回 false
:
public class AtomFileInputFormat extends FileInputFormat<LongWritable, Text> {
@Override
public RecordReader<LongWritable, Text> getRecordReader(InputSplit genericSplit,
JobConf job, Reporter reporter) throws IOException {
reporter.setStatus(genericSplit.toString());
return new FileRecordReader(job, (FileSplit)genericSplit);
}
@Override
protected boolean isSplitable(FileSystem fs, Path path) {
return false;
}
}
另外,我定制了一个 FileRecordReader
(需要实现 RecordReader
接口),从每个文件中读取邮件头,丢掉邮件的内容部分。
@Override
public boolean next(LongWritable key, Text value) throws IOException {
if (hasRead) {
return false;
} else {
Path file = split.getPath();
FileSystem fs = file.getFileSystem(job);
key.set(0);
FSDataInputStream in = fs.open(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
StringBuilder sb = new StringBuilder();
while (true) {
String line = reader.readLine();
// empty string, email headers and body are separated by an
// empty line
if (line == null || line.length() == 0)
break;
sb.append(line);
sb.append('\n');
}
value.set(sb.toString());
reader.close();
pos = split.getLength();
hasRead = true;
return true;
}
}
这样差不多任务就完成了,不过还有一点就是最后的结果是按照 key 进行排序的,亦即按照收件人地址排序,而我期望找到“最倒霉”的人,甚至需要按照“倒霉程度”进行排序,所以我再启动另一个 MapReduce 任务,将 key 和 value 颠倒过来,并进行排序。由于这是一个非常常见的任务,Hadoop 内置了相应的支持,因此只需要把内置的 InverseMapper
和 IdentityReducer
组装起来即可:
sortJob.setInputFormat(SequenceFileInputFormat.class);
sortJob.setMapperClass(InverseMapper.class);
sortJob.setNumReduceTasks(1); // write a single file
FileOutputFormat.setOutputPath(sortJob, new Path(args[1]));
sortJob.setOutputKeyComparatorClass(LongWritable.DecreasingComparator.class);
这样就能得到最终结果了!完整的代码可以从这里下载。
最后,总结一下:这样一个简单的 AtomFileInputFormat
似乎没有达到预期的目的,Hadoop 默认情况下似乎把“一个文件”当作一个“很大”的单位了,通常都是考虑将文件进行分割,再分派到各个节点,因此每个文件最少启动一个 map 任务,而现在我的情况是一个文件中只有一条数据,结果似乎是每一个 map 操作都启动了一个新的 map 任务。如果要改进的话,一个是更加深入地定制 InputFormat
的风割方法;另一个办法是对输入数据进行预处理,后者要方便一些,但是在实际应用中如果遇到数据量大到进行预处理本身就需要 MapReduce 的支持的话,就有些不现实了。 另外,这种分布式的应用出了问题之后不知道要怎样调试才好?且不说调试器的方法肯定不管用了,就算最原始的 printf
大法,也不知道最终的输出会 print 到苍穹的哪一个角落里去了 必须要有一个强大的 log 系统才行,而且如果是在单机环境下也能成功重现的那种 bug 的话,应该也会好姐姐一些。
[ HADOOP中一种非典型两表JOIN的处理方法 ]
HADOOP中两表JOIN一直以来是很困扰我的一个问题,如果是两个大表JOIN更是如此!比如说有一件商品,收藏者有100万,而这件商品同时有 100万个类目(虽然这是不可能的),我的要求是要把每个用户和每一个类目关联起来,这样的话,对于同一个商品,就需要在内存中存放所有的收藏者100万 和类目100万,再来遍历这两个数组,将其关联。注意,由于是同一个key,因此,这个做法和单机上处理一个100万阶层的循环没有两样,程序跑死是非常 正常的!当然,类目不可能是100万,但按照正常需求来看,100万用户对1个类目,在单机上处理,也会耗费很多的时间和空间,实际测算来看,20分钟都不能跑出这个关联结果!
根据剑英的一个想法,和志远讨论以后,这个问题现在看来已经可以被一种不典型做法解决了,现详述如下:
实际案例就如上所说:一张表是商品和用户的关系,一张表是商品和类目的关系,一件商品最多可能被几百万用户收藏,而其对应的类目则是唯一。现在的要求就是把用户和类目关联起来,结果为用户和类目的对应关系。
做法和结果如下:
a.将同一个商品和其对应的百万用户按照HASH进行区分,也就是说,如果一件商品A,对应的有100万用户,我们人为的将这100万用户拆成N份,分别 丢进N个reduce中去处理。这样做的好处就是,原来一个reduce中要处理100万的数据量,现在则变成了只要处理100/n个,降低了每个 reduce的压力。
b.由于将商品进行了拆分,所以其对应的类目我们要COPY N份给每个reduce,保证所有的用户都可以和其商品类目关联到。
c.这样做,在map阶段其实是增加了数据量,由于原来只要1份类目表就可以了,但是现在需要N份类目表,因此,在map阶段的时间明显增加.
d.原先没有按照HASH分布,20分钟整个程序都不能跑完,现在分开处理以后(HASH分布成5个),6分钟可以跑完,但是原先MAP只要1分钟,现在MAP用了有4分钟,整个程序用在MAP上的时间超过了REDUCE.
对于我这个实例,经过几次实验后,发现HASH分成5个的时候时间最短,是6分钟。原因在于如果HASH分的多的话,类目表的拷贝也会多,由于类目表有2000万条数据,拷贝一遍就是2000万,现在5个HASH,也就是要多出1亿条数据!
当然,这是一个特殊情况,对于同一个key来说,是“一组”极大的数据和“一条”数据进行关联,对于多对多,目前还没想出什么很好的办法!
2008年10月22日 15:30:30 发布:guwendong
淘宝数据仓库团队,在“HADOOP中一种非典型两表JOIN的处理方法”这篇文章里, 无私地 share 了他们的方法。我就着他们的写一个续,权当讨论,抛砖引玉。淘宝团队的文章主要说的是大规模数据情况下如何计算,我这篇接着他们最后的问题,即“多对多”的情况说一下思路。要解决的问题可以简化描述一下:
- 有两组数据,input1 { P1, U1; P1, U2; P2, U3; P3, U4; P4, U4 },input2 { P1, C1; P1, C2; P2, C3; P3, C3; P3, C4; P4, C4 }。
- 要 求执行类似于数据库两表 Inner Join 的操作,以 P 为 key,建立起 U 和 C 直接的对应关系,即最终结果为 output { U1, C1; U1, C2; U2, C1; U2, C2; U3, C3; U4, C3; U4, C4 }。
在数据库里,使用类似的 SQL 可以达到要求:SELECT DISTINCT(U, C) FROM input1 INNER JOIN input2 ON input1.P=input2.P。但如果要放在 Hadoop 里面求解,就需要动些脑筋了。
研究这个问题,首先需要理解 Hadoop 的运行机制。简单来讲,Hadoop 分为 Map 和 Reduce 两个操作:Map 操作将输入(如一行数据)格式化为 <key: value1><key: value2><key: value3> ... <key: valueN>这样的一组结果,作为 Map 的输出。Hadoop 在 Map 和 Reduce 之间,会自动把 Map 的输出按照 key 合并起来,作为 Reduce 的输入。Reduce 得到这样一个 {key: [value1, value2, value3, ..., valueN]} 的输入之后,就可以进行自己的处理,完成最终计算了。
针对于我们这里要解决的问题,步骤如下。
- 将 Map 的输入构造为下面的格式:来自于 input1 的输入格式化为 {<input1, P1>: U1, U2};来自于 input2 的输入格式化为 {<input2, P1>: C1, C2}。
- 在 Map 操作内,将数据转化为 {P1: <input1, U1>},{P1: <input1, U2>},{P1: <input2, C1>},{P1: <input2, C2>},作为 Reduce 操作的输入。
- 经过 Hadoop 内部自己的操作,实际 Reduce 操作的输入为:{P1: <input1, U1>, <input1, U2>, <input2, C1>, <input2, C2>}。
- Reduce 里操作会复杂一下。首先需要执行一次 regroup,得到如下的结果 {<input1>: <input1, U1>, <input1, U2>; <input2>: <input2, C1>, <input2, C2>}。把这个结果拆开,可以得到两个集合:{<input1>, <input2>} 与 {[<input1, U1>, <input1, U2>], [<input2, C1>, <input2, C2>]}。
- 循环集合2,即可以得到最终结果。不过在 Reduce 里面作这个循环是需要一定技巧的,讲起来比较绕,大家就直接看后面的代码吧。
- 在此 Reduce 的结果之上,再跑一个 Map/Reduce,还可以得到 <U, C>的次数,作为每个组合的权重。
这是一个通用地解决 Inner Join 问题的思路,在 Hadoop 的 contrib package 里有具体的代码实现,参见 org.apache.hadoop.contrib.utils.join。
国内还有哪个 team 在用 Hadoop?欢迎交流!
hadoop上对文件进行压缩
hadoop计算需要在hdfs文件系统上进行,因此每次计算之前必须把需要用到的文件(我们称为原始文件)都上传到hdfs上。文件上传到hdfs上通常有两种方法:
a hadoop自带的dfs服务,put;
b hadoop的API,Writer对象可以实现这一功能;
将a、b方案进行对比,如下:
1 空间:方案a在hdfs上占用空间同本地,因此假设只上传日志文件,则保存一个月日志文件将消耗掉约10T空间,如果加上这期间的各种维表、事实表,将占用大约25T空间
方案b经测试,压缩比大约为3~4:1,因此假设hdfs空间为100T,原来只能保存约4个月的数据,现在可以保存约1年
2 上传时间:方案a的上传时间经测试,200G数据上传约1小时
方案b的上传时间,程序不做任何优化,大约是以上的4~6倍,但存在一定程度提升速度的余地
3 运算时间:经过对200G数据,大约4亿条记录的测试,如果程序以IO操作为主,则压缩数据的计算可以提高大约50%的速度,但如果程序以内存操作为主,则只能提高5%~10%的速度
4 其它:未压缩的数据还有一个好处是可以直接在hdfs上查看原始数据。压缩数据想看原始数据只能用程序把它导到本地,或者利用本地备份数据
压缩格式:按照hadoop api的介绍,压缩格式分两种:BLOCK和RECORD,其中RECORD是只对value进行压缩,一般采用BLOCK进行压缩。
对压缩文件进行计算,需要用SequenceFileInputFormat类来读入压缩文件,以下是计算程序的典型配置代码:
JobConf conf = new JobConf(getConf(), log.class);
conf.setJobName(”log”);
conf.setOutputKeyClass(Text.class);//set the map output key type
conf.setOutputValueClass(Text.class);//set the map output value type
conf.setMapperClass(MapClass.class);
//conf.setCombinerClass(Reduce.class);//set the combiner class ,if havenot, use Recuce class for default
conf.setReducerClass(Reduce.class);
conf.setInputFormat(SequenceFileInputFormat.class);//necessary if use compress
SequenceFileInputFormat.setInputPath(new Path(your file path here));//your file path in hdfs
接下来的处理与非压缩格式的处理一样
[ 本期目录 ]Hadoop 实战:谁是最倒霉的人?
HADOOP中一种非典型两表JOIN的处理方法
使用 Hadoop 实现 Inner Join 操作
hadoop上对文件进行压缩