2004年谷歌发表的那篇划时代论文《MapReduce: Simplified Data Processing on Large Clusters》彻底改变了数据处理的方式。当时我在一家电商公司做数据分析,每天最头疼的就是处理日益增长的日志文件。单机环境下用Python脚本处理100GB的日志需要近8小时,而业务部门往往等不了这么久。
MapReduce的核心思想其实很简单——把大数据集拆分成小块(Map阶段),分散到多台机器并行处理,再把结果汇总(Reduce阶段)。这种"分而治之"的思路让我们的日志处理时间从小时级降到了分钟级。举个例子,统计用户点击量这个常见需求,传统方法是顺序读取每条记录计数,而MapReduce则是:
一个完整的MapReduce作业涉及以下关键角色:
典型的工作流程是这样的:
关键细节:Map输出不是直接传给Reduce,而是先写入Map节点的本地磁盘。这个设计减少了网络传输,但也是后来Spark改进的重点之一。
输入数据会被自动切分为InputSplit,每个Split对应一个Map任务。假设我们有一个1GB的文本文件,HDFS默认块大小128MB,那么会产生8个InputSplit。但实际分片数可以通过以下参数调整:
xml复制<property>
<name>mapreduce.input.fileinputformat.split.minsize</name>
<value>134217728</value> <!-- 128MB -->
</property>
任务调度有个重要原则叫数据本地化,分为三个级别:
通过mapreduce.jobtracker.taskScheduler可以配置调度策略,默认是FIFO,生产环境建议改用Fair或Capacity调度器。
下面这个WordCount示例展示了最基础的MapReduce编程模型:
java复制public class WordCount {
// Mapper实现
public static class TokenizerMapper
extends Mapper<Object, Text, Text, IntWritable>{
private final static IntWritable one = new IntWritable(1);
private Text word = new Text();
public void map(Object key, Text value, Context context
) throws IOException, InterruptedException {
StringTokenizer itr = new StringTokenizer(value.toString());
while (itr.hasMoreTokens()) {
word.set(itr.nextToken());
context.write(word, one);
}
}
}
// Reducer实现
public static class IntSumReducer
extends Reducer<Text,IntWritable,Text,IntWritable> {
private IntWritable result = new IntWritable();
public void reduce(Text key, Iterable<IntWritable> values,
Context context
) throws IOException, InterruptedException {
int sum = 0;
for (IntWritable val : values) {
sum += val.get();
}
result.set(sum);
context.write(key, result);
}
}
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf, "word count");
job.setJarByClass(WordCount.class);
job.setMapperClass(TokenizerMapper.class);
job.setCombinerClass(IntSumReducer.class);
job.setReducerClass(IntSumReducer.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
FileInputFormat.addInputPath(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
Combiner的使用:上述代码中job.setCombinerClass(IntSumReducer.class)这行很关键。Combiner相当于本地Reduce,可以大幅减少网络传输。但要注意Combiner的输入输出类型必须和Mapper一致。
自定义Partitioner:默认的HashPartitioner可能导致数据倾斜。比如统计热门词汇时,某些词的频率可能极高。这时可以实现自己的Partitioner:
java复制public class CustomPartitioner extends Partitioner<Text, IntWritable> {
@Override
public int getPartition(Text key, IntWritable value, int numPartitions) {
if(key.toString().equals("the")) return 0; // 把"the"强制分到第一个分区
else return (key.hashCode() & Integer.MAX_VALUE) % numPartitions;
}
}
xml复制<property>
<name>mapreduce.map.memory.mb</name>
<value>2048</value> <!-- Map任务内存 -->
</property>
<property>
<name>mapreduce.reduce.memory.mb</name>
<value>4096</value> <!-- Reduce任务内存 -->
</property>
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| Task失败重试多次 | 数据倾斜或内存不足 | 检查是否有异常的key,增加mapreduce.task.timeout |
| Reduce阶段卡在99% | 个别Reduce任务处理数据量过大 | 增加Reduce任务数(mapreduce.job.reduces) |
| 作业运行速度慢 | 没有启用Combiner或数据本地化失效 | 检查NodeManager日志,确保数据本地化 |
Web UI监控:通过http://<jobtracker>:8088可以查看:
日志分析:任务日志通常包含宝贵信息:
bash复制# 查看某个任务的日志
yarn logs -applicationId application_123456789_0001 -containerId container_123456789_0001_01_000001
code复制File System Counters
FILE: Number of bytes read=2263214
FILE: Number of bytes written=8736421
Map-Reduce Framework
Map input records=500000
Reduce output records=1000
虽然MapReduce开创了分布式计算的新纪元,但在实际使用中我们也发现了一些痛点:
中间结果落盘:Map输出必须写入磁盘,再通过网络传输给Reduce,这个I/O瓶颈导致性能受限。这也是Spark引入内存计算的重要原因。
编程模型单一:复杂的多阶段计算需要串联多个MapReduce作业,每个作业都要从HDFS读写数据。后来出现的Tez和Spark提供了更灵活的DAG执行模型。
实时性不足:批处理模式导致延迟较高,难以满足实时分析需求。Storm、Flink等流计算框架填补了这个空白。
在实际架构选型时,我们现在的常见做法是:
这种混合架构既能利用MapReduce的稳定性,又能获得新框架的性能优势。