先说结论:MapReduce 这个“上古”编程模型,放到今天的大数据面试和离线数仓场景里依然是绕不开的硬骨头。我自己当年从“能跑通 WordCount”到“真看懂 Shuffle 那点事”,中间踩的坑比想象中多得多。这篇东西不打算给你堆概念,就用一个能跑起来的 WordCount 当线索,把 MapReduce 的骨架、执行流程、调优点、常见报错一次讲清楚。
1. 项目定位与核心思想拆解
1.1 这个项目解决的核心问题
MapReduce 本质上回答了一个很朴素的问题:当单台机器算不动海量数据时,怎么用一堆普通机器协作完成计算?
你可以把它理解成一场“分头干活、汇总交差”的团建:几百号人要把一仓库的箱子按颜色分类,如果只有一个人站在仓库门口慢慢挑,那得干到天亮。更聪明的做法是让每个人先各自抱走一堆箱子,在自己角落分好类,再把同类箱子集中到一块。放到分布式计算语境里,“每个人”就是集群里的一台台节点,“各自分好类”就是 Map 阶段,“集中到一块”就是 Reduce 阶段。
这个模型之所以在大数据领域长盛不衰,核心在于它把“并行计算”这件复杂事抽象成了两个简单函数:map和reduce。程序员只需要关心业务逻辑——怎么把一个数据项转成键值对、怎么聚合同一键的数据——至于数据怎么切分、任务怎么调度、某个节点挂了怎么重跑,统统交给框架。这种“计算向数据靠拢”的思想,后来 Spark、Flink 虽然都做了优化,但骨架依然是 Mapping 和 Reducing 那一套。
1.2 为什么必须理解 MapReduce
很多人觉得“现在谁还用 MapReduce 写业务?Spark 不香吗?”这话一半对一半不对。Spark 的 RDD 转换、窄依赖宽依赖、Stage 划分,你如果顺着 MapReduce 的 Map/Shuffle/Reduce 脉络去理解,会发现处处是基因层面的相似。更别提不少离线数仓任务、Hive 底层的执行引擎,本质还是翻译成一串 MapReduce 作业在跑。
我自己带过几个新人,发现凡是对 MapReduce Shuffle 理解扎实的,上手 Spark 调优特别快;反之整天只会spark-submit一个命令的人,遇到数据倾斜、OOM 就抓瞎。所以这篇博文虽然标题叫“基础编程”,但我会刻意把“为什么这么做”“底層发生了什么”讲透,因为这才是基础这两个字真正的分量。
2. 从输入到输出:MapReduce 作业的完整执行链路
2.1 数据从哪来:InputFormat 与 Split 的切分逻辑
一个 MapReduce 作业跑到集群上,第一步不是马上读数据,而是先做“预处理”——把输入数据切分成若干逻辑分片(Split)。每一个 Split 对应一个 Map 任务,任务并行度由此决定。
默认的FileInputFormat有两个关键参数:
mapreduce.input.fileinputformat.split.minsize:分片最小大小,默认 0mapreduce.input.fileinputformat.split.maxsize:分片最大大小,默认无穷大
真正的分片大小计算公式是:splitSize = Math.max(minSize, Math.min(maxSize, blockSize)),在 HDFS 默认配置下,blockSize 是 128MB,所以每个 Split 默认就是 128MB。这意味着:一个存储在 HDFS 上的 1GB 文件,默认会被切成 8 个 Split,并行跑 8 个 Map 任务。
这块新手最容易误解:以为 Map 数量由“文件行数”决定,其实完全不是。Map 数量由“分片数量”决定,而分片大小跟 HDFS 块大小强相关。除非你能证明某个分片数据量太小导致任务空转,否则不要轻易改这两个参数——过度调小分片只会让任务调度开销暴增。
2.2 Map 阶段:从原始数据到中间键值对
每个 Map 任务拿到所属分片后,RecordReader会按行读取数据,把它转换成键值对。对文本文件来说,默认的TextInputFormat产生的是:
- key:行首字节偏移量
- value:这一行的文本内容
随后代码里的map()方法会被逐行调用。这里有个细节很多人忽略:Map 任务的输出不是直接写入磁盘,而是先写进内存里的环形缓冲区。这个缓冲区默认 100MB,当数据量达到缓冲区大小的 80%(阈值)时,后台线程会开始把数据溢写(spill)到本地磁盘。
2.3 关键的 Shuffle:排序、分区与归并
Shuffle 是 MapReduce 的灵魂,也是最容易让人懵的阶段。它横跨 Map 端和 Reduce 端,干的活儿抽象出来就三件事:分区、排序、传输。
Map 端每次溢写前,会先做两件事:
分区:每条键值对通过
Partitioner计算出它该去哪个 Reduce 任务。默认实现是HashPartitioner,公式很简单:(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks。同一个 key 一定进同一个分区,这样才能保证后续 Reduce 聚合时拿到全部数据。排序:每个分区内部按 key 排序。这个排序不是为了展示好看,而是为了让 Reduce 端“按键分组”成为可能——雷同 key 被排到一起,Reduce 只要遍历一遍就能完成合并。
溢写到磁盘的多个小文件,最后会被Merge成一个有序大文件。整个过程中如果开启了 Combiner(可以理解成 Map 端的“小 Reduce”),会在溢写前先做一轮局部合并,显著减少磁盘 IO 和网络传输。我后面会专门讲 Combiner 的正确用法。
Reduce 端同样有 shuffle:先拉取属于自己分区的 Map 输出文件,在内存里归并排序,直到形成“按 key 聚集且有序”的输入流,然后逐组调用reduce()方法。
注意:Shuffle 这个过程不是由程序员代码控制的,它由框架自动完成。但它的任何一个细节都直接影响作业性能——数据量大时,Shuffle 消耗的时间甚至可以占到整个作业的 70% 以上。
2.4 Reduce 阶段与最终输出
Reduce 函数收到的是(key, values迭代器),其中 values 是同一个 key 对应的所有 value 的集合。它把这些 value 聚合后写出,默认写到 HDFS 上的part-r-xxxxx文件里。
这里有一个“零 Reduce”的冷门知识:如果把numReduceTasks设为 0,作业会跳过 Reduce 阶段,Map 输出直接落盘,文件名会变成part-m-xxxxx。这在某些只需要“过滤-清洗-输出”的场景下特别高效,很多人不知道。
3. 核心编程模型与 WordCount 手把手实现
3.1 Mapper 类的三个核心方法
写一个 Mapper,本质上继承Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT>后重写三个方法:
setup():每个 Map 任务初始化时调用一次。适合加载外部配置文件、初始化连接池等操作。map():每行输入调用一次。业务核心逻辑都写在这里。cleanup():每个 Map 任务结束前调用一次。适合关闭连接、写汇总信息。
我用一个经典 WordCount 来拆解。假设输入文件每行是若干单词,目标是统计每个单词出现总次数。Mapper 代码长这样:
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private Text outKey = new Text(); private IntWritable outValue = new IntWritable(1); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); // 按空格切分这一行 String[] words = line.split(" "); for (String word : words) { if (word.isEmpty()) { continue; // 处理连续空格导致的空串 } outKey.set(word); // 每个单词出现一次,记一个1 context.write(outKey, outValue); } } }注意到我这里故意没有在map()里写new Writable对象,而是复用了外部声明的outKey和outValue。这是性能细节:map()会被调用几百万次,每次new一个对象会给 JVM GC 造成巨大压力。复用对象属于 MapReduce 编程的隐形规范,网上很多示例代码没体现这一点,但真实跑大数据量时差别非常大。
3.2 Reducer 类的实现与分组逻辑
Reducer 的输入天然是“同一个 key 的所有 value 排成一队”。代码反而比 Mapper 简单:
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override protected 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); } }这里的values不是内存里的一份大集合!它是一个惰性迭代器,框架按需从磁盘或内存取数据。所以千万不能在 reduce 里把 values 遍历完后存进 List 再二次处理,当某个 key 的 value 数量达到百万级别时,这样会直接撑爆内存。
我在实际项目中处理用户行为日志时,遇到过单 key 对应近 1 亿条 value 的情况。正确的做法是:要么在遍历过程中完成逻辑(求和/最大值/拼接前N条),要么溢写到外部存储再排序,绝不能在迭代器外面保留全部数据。
3.3 Driver 主类:作业的组装与提交
MapReduce 程序需要一个主类来组装 Map、Reduce 和各类配置。代码很固定:
public class WordCountDriver { public static void main(String[] args) throws Exception { Job job = Job.getInstance(new Configuration(), "word count"); job.setJarByClass(WordCountDriver.class); // 输入输出路径,第一个参数是输入目录,第二个是输出目录 FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); job.setMapperClass(WordCountMapper.class); job.setReducerClass(WordCountReducer.class); // Map端输出类型 job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(IntWritable.class); // Reduce端最终输出类型 job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); // 默认一个Reduce,如果想调并行度,可以设置多个 job.setNumReduceTasks(1); System.exit(job.waitForCompletion(true) ? 0 : 1); } }有些新手会漏掉setMapOutputKeyClass和setMapOutputValueClass,导致 Map 输出类型和 Reducer 输入类型不匹配,运行时报序列化错误。这里给大家一个口诀:Map 输出类型看 Mapper 泛型的后两位,Reduce 输出类型看 Reducer 泛型的后两位。如果两个阶段输出类型一致,setMapOutputXxxClass可以省略,否则必须显式声明。
3.4 提交前的必检清单
- 输入的
args路径里,输出目录必须不存在。MapReduce 不允许覆盖已有输出目录,作业启动会直接报FileAlreadyExistsException。 - 输入文件如果是 gz、snappy 压缩格式,框架可以自动解压,无需额外处理;但如果是自定义压缩格式,需要额外配置。
- 本地 IDE 里调试时,可以设
mapreduce.framework.name为local,配合本地文件路径跑通逻辑;真正跑集群时再提交到 YARN。
4. 部署运行、参数调优与组合器实操
4.1 三种运行模式与选择建议
MapReduce 支持三种运行模式,我按亲测体验列个表:
| 模式 | 配置方式 | 适用场景 | 坑点 |
|---|---|---|---|
| 本地模式 | mapreduce.framework.name=local | 小数据量调试、单元测试 | 默认只跑一个 Map/Reduce,数据量大时慢到怀疑人生 |
| 伪分布式 | 本机启动 NameNode、DataNode、ResourceManager 等进程 | 单机模拟集群,学习 Shuffle 流程 | 配置繁琐,容易漏启动进程 |
| 完全分布式 | 多节点集群,YARN 调度 | 生产环境、海量数据 | 需要重点考虑网络 IO 和磁盘负载 |
对初学者,我的建议是直接在本地模式里把程序逻辑先跑通,再往集群提交。别一上来就怼集群,否则一条报错信息里同时夹杂代码问题、环境问题、权限问题,根本排查不了。
4.2 打包、提交作业与日志排查
项目用 Maven 管理时,pom.xml引入 hadoop-client 依赖(版本要和集群一致),然后执行:
mvn clean package -DskipTests生成 jar 包后,用以下命令提交:
hadoop jar target/wordcount-1.0.jar com.example.WordCountDriver /input /output跑完以后怎么确认结果?两条命令比看网页日志更实际:
hdfs dfs -cat /output/part-r-00000 hdfs dfs -ls /output提交后作业卡住不动,优先看 YARN 日志。最常见的Container killed by ApplicationMaster多半是内存不够,去查mapreduce.map.memory.mb和mapreduce.reduce.memory.mb的配置,默认值通常到 1024MB,小数据集压根不够用。
4.3 Combiner:Map 端的局部聚合
Combiner 不是全新的组件,它本质上就是复用 Reducer 类——只要你指定的 Combiner 满足“输入输出类型与 Mapper 输出类型一致”即可。WordCount 里可以直接设:
job.setCombinerClass(WordCountReducer.class);这样每个 Map 任务在溢写前,会先把已经排好序的中间结果做一次局部 sum。假设 10 个 Map 任务,每个都输出 100 万个<word, 1>,没有 Combiner 时 Reduce 端要拉取并归并 1000 万条记录;有了 Combiner,每个 Map 输出的记录数大幅压缩,shuffle 传输量可能从几百 MB 降到几十 MB。
但 Combiner 不是银弹,它有一个硬性前提:combine 操作必须满足交换律和结合律。求 sum、max、min 没问题;但如果 Reduce 函数里依赖“所有 value 的整体顺序”(比如找中位数、去重计数),Combiner 的结果可能是错的。我在做 UV 统计时,曾经直接把 Reducer(里面是HashSet去重逻辑)挂成 Combiner,结果某些场景数据直接不对——因为本地去重完再全局去重,根本没法等价于全局先去重。这个坑三年后我印象还很深。
4.4 关键参数调优速查表
以下参数是我在不同数据规模下实测过的参考值,不保证普适,但能作为起点:
| 参数 | 默认值 | 推荐设置思路 |
|---|---|---|
mapreduce.map.memory.mb | 1024 | 数据量大或 Map 内有复杂逻辑时提到 2048 或 4096 |
mapreduce.reduce.memory.mb | 1024 | 结合每条记录大小预估,别盲目调大,容易触发 OOM |
mapreduce.map.cpu.vcores | 1 | CPU 密集计算时可设 2 |
mapreduce.task.io.sort.mb | 100 | Map 端环形缓冲大小,建议 256 及以下,过大会增加 GC 压力 |
mapreduce.reduce.shuffle.parallelcopies | 5 | 并行拉取 Map 结果的线程数,大作业可调大 |
mapreduce.output.fileoutputformat.compress | false | 对中间结果或最终结果开启压缩,能大幅减少磁盘 IO |
一个有趣的经验:调优时永远不要“一次改一堆参数”。我见过有人同时调内存、并发、缓冲区,作业跑挂了都不知道是哪一步引起的。正确做法是每次只改一个参数,记录运行耗时和资源指标,再微调下一个。
5. 常见报错、疑难杂症与排查技巧实录
5.1 四大高频报错与解法
报错一:Input path does not exist
- 原因:输入路径写错,或文件在 HDFS 上不存在
- 排查:先
hdfs dfs -ls看一眼路径真实存在性 - 预防:启动脚本里先做路径检查,别把错误留给框架
报错二:FileAlreadyExistsException: Output directory
- 原因:输出目录已存在,MapReduce 不允许覆盖
- 解法:
hdfs dfs -rm -r /output - 误区:不要试图在代码里删除输出路径,那是危险操作,生产环境尤其忌讳
报错三:ClassNotFound或NoClassDefFoundError
- 原因:第三方依赖没有打入 jar 包,或 Mapper/Reducer 类名写错
- 解法:构建 fat jar,或者确认
setJarByClass填的是包含 main 方法的类 - 经验:本地模式正常、集群报错,80% 是 jar 包依赖问题
报错四:GC overhead limit exceeded或 OOM
- 原因:Map 或 Reduce 阶段单条记录太大、对象溢出内存
- 排查:看 YARN 日志里是哪个阶段的 container 被杀
- 解决:先调大
mapreduce.map.memory.mb,再检查代码是否在map()里堆积了过多中间结果——比如把一整个文件读进内存再切分,这种代码设计就是自找麻烦
5.2 数据倾斜:真实场景里的“致命伤”
数据倾斜在 MapReduce 里表现非常典型:99% 的 Map/Reduce 任务几十秒跑完,剩下一个任务卡了半小时还不动。
原因千奇百怪,但本质只有一个:某个 key 的数据量远大于其他 key。我处理过一个电商场景的订单日志,某个爆款商品 ID 的订单量是其他商品的几百倍,所有记录都涌进同一个 Reduce 任务,活活把它拖死。
常规解法分三种:
- 增大 Reduce 数量:设置
job.setNumReduceTasks为较大值,配合HashPartitioner,让 key 分布更散。但如果倾斜 key 本身数据量太大,这个方法作用有限。 - 两阶段聚合:Map 端给 key 加随机前缀,先局部聚合一轮;下一轮作业去掉前缀再全局聚合。代价是跑两轮 MapReduce,但常常能治根。
- 自定义 Partitioner:手动把倾斜 key 拆成多个子 key 分散到不同分区,Reduce 端再做二次聚合。
5.3 自定义 Writable 的三大纪律
处理复杂业务字段时,默认的Text、IntWritable不够用,你需要自定义一个实现Writable接口的类。这里三个纪律必须刻在脑子里:
- 必须有无参构造方法。框架反序列化时靠反射创建对象,你忘了写无参构造,运行时报
NoSuchMethodException。 write()和readFields()字段顺序必须完全一致。先写 int 再写 Text,读的时候也要先读 int 再读 Text,否则取出的数据全乱掉。我见过有人修改字段后忘了同步两个方法,结果线上数据错乱,排查了一整天。toString()建议重写。虽然不影响计算逻辑,但调试时输出友好太多了。你也不想每次打印对象都看到一坨十六进制地址吧?
示例骨架:
public class LogWritable implements Writable { private String userId; private long timestamp; public LogWritable() {} @Override public void write(DataOutput out) throws IOException { out.writeUTF(userId); out.writeLong(timestamp); } @Override public void readFields(DataInput in) throws IOException { this.userId = in.readUTF(); this.timestamp = in.readLong(); } }5.4 小文件问题的连锁反应
HDFS 上存在大量小文件时,MapReduce 作业会非常难受。每个小文件至少产生一个 InputSplit,而每个 Split 都要对应一个 Map 任务,意味着 task 调度的开销远大于实际计算的开销。我曾在测试环境里见过一个 2GB 存储目录里躺着 5 万个小文件,跑一个清洗作业生成了 5 万个 Map 任务,ResourceManager 直接被压垮。
解决思路有:
- 输入阶段用
CombineTextInputFormat把多个小文件合并成一个大 Split; - 源头治理:写入层定时做小文件合并(Hive 里的
Concatenate或者自研合并任务); - 不要轻易对每个文件
map()里做临时的new BufferedReader自己读文件,那样完全绕过框架的分布式读取能力。
写在最后的一点个人经验
MapReduce 真正上手以后,你会有一种“不过如此”的错觉,但千万别急着扔下它去追新框架。我自己后来写 Spark 作业,遇到 Shuffle 调优问题,脑子里浮现的还是当年排查 MapReduce 数据倾斜的经验。如果你打算入行大数据开发,我强烈建议你别跳过这一课。
最后打一个小补丁:本地跑通 WordCount 只是一个起点,不妨接着把 WordCount 改成“统计每行平均单词长度”“按首字母分组计数”之类的小变体,亲手改几次 Mapper 和 Reducer,你对整个模型的理解会瞬间上一个台阶。等你能独立处理一个小型离线统计项目,就可以大胆把 MapReduce 的这块能力写进简历了。