先说说我为什么写这篇。前阵子带几个零基础的同学搭Hadoop环境,发现大家普遍卡在一个很尴尬的位置:MapReduce的逻辑能听懂,WordCount的代码能看懂,但是一旦要求“把代码打包,传到Linux服务器上跑起来”,问题就成堆地冒出来——有人不知道jar包怎么打,有人不知道命令里该写主类还是写jar名,有人把本地文件路径当成HDFS路径,还有人一运行就报ClassNotFoundException。这篇就把MapReduce基础编程里最经典的一个例子,从写代码、打jar包、上传服务器、启动集群、提交任务到看结果,完整讲一遍,顺便把那些常规文档不会告诉你的坑都标出来。
1. 先搞清楚WordCount到底在演示什么
1.1 为什么大数据入门都用WordCount开刀
WordCount的意思是“单词计数”:给一堆文本,统计每个单词出现了多少次。这个需求本身非常简单,任何一个会写循环的程序员都能用十几行Java或Python实现。
但它在Hadoop生态里意义不一样。MapReduce把“统计单词频次”拆成了两个阶段:Map负责把每一行文本拆成一个个单词,并给每个单词标上一个“1”作为计数;Reduce负责把所有相同单词的“1”收集起来做加法。你会发现,这个例子里包含了MapReduce的全部核心概念:输入分片、Map阶段、Shuffle与排序、Reduce阶段、输出落盘。真正把代码跑通,等于亲手验证了一遍分布式计算里“数据在哪里算”的基本模型。
这也是我希望你认真对待这个例子的原因。它不是一道闭卷题,而是一块敲门砖。搞清楚WordCount的完整执行链路,后续再去看Hive的编译原理、Spark的DAG调度,都会有代入感。
1.2 用三行文本推演一遍数据流转
拿一个最简单的输入来说明,假设HDFS上有个文件,内容是:
hello world hadoop hello mapreduce hadoop hadoop mapreduceMap阶段会把每一行作为输入,按空格拆词。第一行产出三条中间结果:
(hello, 1) (world, 1) (hadoop, 1)第二行、第三行同理。但这些中间结果不会直接发给Reduce,框架会先做一次Shuffle:把key相同的记录汇聚到一起,并且按key排好序。于是Reduce收到的数据大致是:
hello -> [1, 1] world -> [1] hadoop -> [1, 1, 1] mapreduce -> [1, 1]Reduce拿到迭代器之后把value累加,写出的最终结果就是:
hello 2 world 1 hadoop 3 mapreduce 2很多人第一次看这里会问:Shuffle是代码写出来的吗?不是。它是MapReduce框架自带的机制,你在代码里只需要声明Mapper和Reducer,框架负责在中间完成分区、排序、合并、归并这些脏活累活。理解这一点,你才能明白为什么写MapReduce比写多线程并发要省心得多。
1.3 代码里真正需要你写的只有三个部分
一个完整的MapReduce作业,在代码层面至少要包含三样:继承Mapper的类、继承Reducer的类、以及用来组装作业的Driver(也就是main方法所在的地方)。
Mapper决定“输入的一行被拆成什么中间结果”,Reducer决定“相同key的中间结果如何聚合”,Driver决定“输入路径在哪、输出路径在哪、用哪个Mapper和Reducer、用哪种输入输出格式”。三条线各司其职,没有魔法,也没有复杂的线程同步。
这是MapReduce基础编程最重要的思维方式:你要定义的是数据的变换规则,而不是数据怎么并行。并行度由输入分片数量决定,Reduce数量由你配置或框架推算,这些在跑真正的集群作业前都不需要你去手动管理线程。
2. 动手前把环境盘点好,版本匹配是第一道门槛
2.1 JDK、Hadoop、Maven该怎么选
我见过太多人跑不起来,不是代码问题,而是环境版本互相打架。这里直接给出我验证过比较稳的组合。
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 1.8 | Hadoop对JDK17的兼容性在部分发行版仍有坑,入门阶段用1.8最稳 |
| Hadoop | 2.10.x | 经典稳定版本,教程最多,遇到问题最容易搜到答案 |
| Maven | 3.6+ | 用于打包Java代码,3.6以上都行 |
| Linux服务器 | CentOS 7 / Ubuntu 20.04均可 | 关键是hostname和SSH要通 |
如果你是拿一个已经装好的Hadoop环境来练手,建议先执行hadoop version看一眼版本号,再决定Maven依赖里写哪个版本。Hadoop 3.x也完全可以跑WordCount,只是个别日志格式和配置项名字不同,逻辑完全一致。我的经验是:如果服务器上是Hadoop 2.x,本地打包依赖就别用3.x的hadoop-client,否则提交作业时大概率出现依赖冲突或者序列化异常。
2.2 Linux服务器上要准备什么
先说结论:至少要能启动HDFS和YARN,也就是start-dfs.sh和start-yarn.sh之后,jps能看到五个进程:NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager。
拿到一台新服务器,很多人上来就敲hadoop jar,结果报Connection refused。原因很简单:集群根本没启动。所以准备阶段第一件事是确认进程,第二件事是检查HDFS根目录能不能用。
在服务器上创建我们自己的工作目录:
mkdir -p /home/hadoop-test然后在HDFS上准备输入目录。这里必须先给你打个预防针:HDFS路径和Linux本地路径完全是两码事,/input这个东西在Linux根目录下根本不存在,它是HDFS命名空间里的路径。
hdfs dfs -mkdir -p /user/hadoop-test/input如果当前执行命令的Linux用户和HDFS超级用户不一致,上面这步可能Permission denied,可以先用超级用户授权,或者直接切到启动Hadoop的那个用户去操作。我在实验环境里习惯把所有练习统一放在/user/hadoop-test/下面,分类清楚,后面清理也方便。
3. 写一份真正完整的WordCount代码
3.1 Mapper:把一行文本拆成单词并打上标记
直接看代码,我建议把三个类写在一个Java文件里,就是WordCountDriver.java文件,里面包含静态内部类形式的Mapper和Reducer。这样讲课、打包、分发都方便。
package com.example; import java.io.IOException; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; public class WordCountDriver { public static class TokenizerMapper extends Mapper<Object, Text, Text, IntWritable> { private final static IntWritable one = new IntWritable(1); private Text word = new Text(); @Override public void map(Object key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] words = line.split("\\s+"); for (String w : words) { if (w.length() == 0) { continue; } word.set(w); context.write(word, one); } } } // 后续的Reducer和main继续写在下面 }注意这里的关键细节:第一个泛型参数Object是输入key,默认是行偏移量;第二个Text是输入value,即一行文本;第三个Text是输出key,即单词;第四个IntWritable是输出value,即计数1。
很多人刚学Hadoop会问,为什么不用Java自带的String和Integer?因为Hadoop为分布式序列化设计了专用类型:Text对应String,IntWritable对应Integer,它们在网络传输和磁盘落盘时更高效。代码里用value.toString()拿到原始字符串,用word.set(w)复用对象而不是频繁new,这是一种性能习惯,虽然在小数据量下感知不到,但在海量输入下能减少GC压力。
3.2 Reducer:把同一个单词的计数累加
Reducer的输入key就是单词,value就是一堆1,只需要遍历累加。但这里有个新手很容易忽略的细节:Iterable<IntWritable>里的对象是框架复用的,不能直接存进List等循环结束后再用,必须立刻处理或者深拷贝。累加这种操作正好适合边遍历边处理,不涉及这个坑。
public static class IntSumReducer extends Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override 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); } }这段逻辑没多少好讲的,累加就完事了。但它的输入数据是有讲究的:不同Map任务产生的相同单词,会被Shuffle阶段自动汇聚到同一个Reduce任务里。如果没有Shuffle机制,你得自己写全局HashMap,或者引入分布式锁,复杂度完全不是一个量级。
3.3 Driver:告诉框架“作业怎么跑”
Driver是整个作业的组装车间。它要做的事包括:设置作业名、指定jar入口类、指定Mapper和Reducer类、指定输出key/value类型、设置输入输出路径。
public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: wordcount <inputPath> <outputPath>"); System.exit(-1); } org.apache.hadoop.conf.Configuration conf = new org.apache.hadoop.conf.Configuration(); org.apache.hadoop.mapreduce.Job job = org.apache.hadoop.mapreduce.Job.getInstance(conf, "word count"); job.setJarByClass(WordCountDriver.class); job.setMapperClass(TokenizerMapper.class); job.setReducerClass(IntSumReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); org.apache.hadoop.fs.Path inputPath = new org.apache.hadoop.fs.Path(args[0]); org.apache.hadoop.fs.Path outputPath = new org.apache.hadoop.fs.Path(args[1]); org.apache.hadoop.mapreduce.lib.input.FileInputFormat.addInputPath(job, inputPath); org.apache.hadoop.mapreduce.lib.output.FileOutputFormat.setOutputPath(job, outputPath); System.exit(job.waitForCompletion(true) ? 0 : 1); } }这里有几个务实建议。
job.setJarByClass(WordCountDriver.class)非常重要。它让框架知道去哪个jar包里找类。如果你不设置,本地模式跑无所谓,但丢到集群上YARN会把jar分发到NodeManager上去执行,不写这个字段很可能报ClassNotFoundException。
job.setOutputKeyClass和job.setOutputValueClass设置的是Reduce输出的类型。由于Map和Reduce的输出类型一致,框架可以推断Map输出类型,通常不用再显式setMapOutputKeyClass。但如果你后续写Map和Reduce输出类型不一致的作业,一定要分别设置Map输出类型和Reduce输出类型。
waitForCompletion(true)里的true表示打印进度信息。跑小作业时你能看到map和reduce的进度百分比,这也是排错的关键来源。
我给这段代码做一下运行主类的说明,打包时MANIFEST里的Main-Class会指向com.example.WordCountDriver,所以运行时用hadoop jar wordcount-1.0.jar com.example.WordCountDriver /input /output既可以看到类名,也可以在jar包MANIFEST缺失时兜底。
4. 用Maven打包,别把Hadoop依赖全塞进jar里
4.1 pom.xml怎么配
新建一个Maven工程,把pom.xml写成下面这样:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>wordcount</artifactId> <version>1.0</version> <packaging>jar</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <hadoop.version>2.10.2</hadoop.version> </properties> <dependencies> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>${hadoop.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.example.WordCountDriver</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> </project>这里有个很多人踩过的坑:看到网上教程用maven-shade-plugin把依赖打成一个“fat jar”,也跟着配。结果提交到集群上,报了一堆版本冲突或者找不到类。原因很简单,Hadoop运行环境本身已经带了自己那套依赖,你再塞一份进jar里,相当于让框架加载两份不同的版本,不冲突才怪。
我的建议是:项目依赖只在编译期需要,运行期YARN会从Hadoop安装目录里拿。所以就用普通的maven-jar-plugin,只打自己写的代码,干干净净。这也是最接近企业开发的做法。
4.2 执行打包并且验证jar内容
在项目根目录执行:
mvn clean package -DskipTests-DskipTests跳过单元测试,免得因为环境问题卡在测试阶段。打包完成后target目录下会出现wordcount-1.0.jar。
此时别急着上传,先看两眼jar包里有没有我们的主类:
jar tf target/wordcount-1.0.jar | grep WordCount能看到com/example/WordCountDriver.class就说明类打进去了。再确认MANIFEST:
unzip -p target/wordcount-1.0.jar META-INF/MANIFEST.MF能看到Main-Class: com.example.WordCountDriver这一行。这两步检查花不了十秒钟,但能帮你区分“打包失败”和“提交失败”两类完全不同的问题,划清边界。
5. 上传到Linux服务器,把WordCount真正跑起来
5.1 用scp上传jar包和输入数据
本地执行上传命令:
scp target/wordcount-1.0.jar 用户@服务器IP:/home/hadoop-test/如果提示Permission denied,检查Linux用户的目录写权限,或者先传到/tmp再mv到工作目录。我第一次操作时就踩过这个:/home/hadoop-test属于另一个用户,自己根本没写权限,当时传了半天还以为网络有问题。
输入数据也要准备好,本地先建一个input.txt,内容随意,比如:
hello world hadoop hello mapreduce hadoop hadoop mapreduce同步传到服务器,再放进HDFS:
scp input.txt 用户@服务器IP:/home/hadoop-test/ # 下面在服务器上执行 hdfs dfs -put /home/hadoop-test/input.txt /user/hadoop-test/input/验证一下文件是否真的进去了:
hdfs dfs -ls /user/hadoop-test/input/这里多说一句:hdfs dfs -put是上传到HDFS分布式文件系统,不是Linux本地。如果你直接写/home/hadoop-test/input.txt作为MapReduce的输入路径,框架会在HDFS里找这个路径,通常会报Input path does not exist。
5.2 确认集群状态,然后提交作业
在服务器上先看一眼进程:
jps如果NameNode、DataNode、ResourceManager、NodeManager都在,就可以提交了。
cd /home/hadoop-test hadoop jar wordcount-1.0.jar com.example.WordCountDriver \ /user/hadoop-test/input \ /user/hadoop-test/output_wordcount注意输出目录这个参数:MapReduce要求输出目录事先不存在,这是框架的自我保护机制,防止你把上一次的结果覆盖掉。所以第二次跑之前,要先删掉上一次的输出:
hdfs dfs -rm -r /user/hadoop-test/output_wordcount如果你懒得每次手动删,也可以写个小脚本,运行前先判断并删除输出目录。这个脚本我后面给出示例。
作业提交后,终端会打出一堆日志。以map 100% reduce 100%结束,旁边还会有耗时、计数器等统计信息。如果看到Job complete或者Successfully字样,说明作业跑通了。
5.3 查看结果和任务日志
输出目录下通常有两个关键条目:_SUCCESS空文件和part-r-00000结果文件。查看结果用:
hdfs dfs -cat /user/hadoop-test/output_wordcount/part-r-00000如果输入文本是之前那三行,你会看到类似输出:
hadoop 3 hello 2 mapreduce 2 world 1这里的顺序不一定和输入顺序一致,因为Reduce输出会按key排序,这是Hadoop默认行为。我见过有人拼命想让它保持输入顺序,但这与MapReduce的设计思想是相悖的。如果你对顺序有强需求,要么在Reduce之后再做一次全局排序,要么换个计算框架,比如Spark。
要看Map和Reduce过程中输出了什么细节日志,可以用:
yarn logs -applicationId application_xxxx这个命令会把作业的Container日志拉出来,如果代码里写了System.out.println,输出会显示在这里面。本地跑小作业时,也可以直接去$HADOOP_HOME/logs/userlogs/目录里翻,按applicationId找文件夹。
6. 最常见的5个坑和排查清单
6.1 第一次跑WordCount的报错速查
我把带新人时反复出现的报错整理成一张表,照着查比自己瞎猜快得多。
| 报错关键词 | 常见原因 | 解决动作 |
|---|---|---|
| Input path does not exist | 输入路径没写对,或者文件没传到HDFS | 先hdfs dfs -ls /user/hadoop-test/input确认 |
| Output directory already exists | 输出路径被上次的数据占用了 | hdfs dfs -rm -r /user/hadoop-test/output_wordcount |
| ClassNotFoundException | 主类名拼错,或jar里没打进去 | jar tf检查,命令里写包名+类名全称 |
| Connection refused | NameNode或ResourceManager没启动 | jps看进程,缺哪个启动哪个 |
| Permission denied | 当前用户对HDFS目录无权限 | 切换到HDFS超级用户,或hdfs dfs -chmod -R 777 /user/hadoop-test |
6.2 你以为卡住了,其实没卡住
有个特别容易误判的情况:作业跑完后终端显示map 100% reduce 0%,持续很久没有变化。新手一看就觉得死机了,直接Ctrl+C。
其实在shuffle阶段,Map端还在把中间结果拉给Reduce端,日志上reduce进度没动很正常。尤其是小数据集,整个shuffle可能就几秒钟,你多等一会儿就能看到reduce开始动。真正卡死的情况通常是某个NodeManager挂了,或者内存不够导致Container反复被kill。这时候去看ResourceManager页面端口(默认8088),看application的状态,比看终端日志更直观。
6.3 把重复命令封装成脚本
跑一次WordCount要敲好几条命令,练习多了就烦。我会在服务器上放一个run_wordcount.sh:
#!/bin/bash INPUT=/user/hadoop-test/input OUTPUT=/user/hadoop-test/output_wordcount hdfs dfs -rm -r $OUTPUT hadoop jar /home/hadoop-test/wordcount-1.0.jar \ com.example.WordCountDriver \ $INPUT \ $OUTPUT hdfs dfs -cat $OUTPUT/part-r-00000保存后chmod +x run_wordcount.sh,以后执行./run_wordcount.sh一条龙完成:删旧输出、提交作业、打印结果。这个脚本不是必须的,但你会在后面练习其他MapReduce作业时体会到它的价值——把注意力放在业务逻辑上,而不是重复敲命令上。
6.4 本地快速验证的一个实用小技巧
如果你只是想验证代码逻辑对不对,不想开整套Hadoop集群,可以临时把输入输出路径改成Linux本地路径:
hadoop jar wordcount-1.0.jar com.example.WordCountDriver \ file:///home/hadoop-test/input_local \ file:///home/hadoop-test/output_localMapReduce在本地模式下会直接在本机文件系统上跑,不经过HDFS,速度极快。这个技巧特别适合本地开发试错,等逻辑没问题了再上传到服务器跑分布式。不过要注意,本地模式用的是本地的classpath,你打包出来的jar里有没有Hadoop依赖都不影响,因为它读取的是HADOOP_HOME自带的那一套环境。
我自己带练习时,也会让人先用本地模式跑一遍再上集群,能过滤掉一大半低级错误。等集群上跑通了,再回过头来看HDFS路径和权限体系,理解会扎实很多。
最后说一个我自己的习惯:跑通WordCount之后,别急着往下学新框架,可以试着改动这个小作业,比如统计每个文件里出现次数最多的前3个单词,或者统计字符总数。改一次Mapper和Reducer,你对MapReduce基础编程的理解就会深一层。毕竟技术文档到处都有,但“亲手把一份代码从本地跑到分布式集群”的完整链路,才是别人拿不走的东西。