简介:基于 Hadoop 实现的好友推荐系统是一套完整的毕业设计资源,采用 Java 开发,包含源码和文档说明,面向计算机、大数据、通信、人工智能等专业学生,既可用于课程设计、期末大作业或毕设参考,也适合 Hadoop 初学者进阶学习。压缩包共 2000 个文件,大小约 79.46MB,主要包含 Java 源码、JSP 页面、XML 配置、Jar 依赖包等,此外还有大量 PNG 图片和 CSS 文件,用于系统页面展示与前端样式配置;Java 类中涉及数据访问、Mapper 计算、聚类与距离计算等模块,整体代码结构较清晰。该项目是作者个人毕设,答辩评审 98 分,代码已完成调试测试,可正常运行。配套文档说明有助于理解好友推荐场景下的数据处理流程与协同过滤思路,便于二次开发与功能扩展。目前已有 200 人浏览学习,适合不同基础的学习者参考实践。
1. 基于Hadoop的好友推荐系统:一套能交差又能讲清楚的毕业设计
如果你正在为毕业设计选题发愁,又不想选一个“听起来很AI、实际跑不出数”的题目,基于Hadoop的好友推荐系统是目前最稳妥的方向之一。它不算新颖,但胜在链路完整:Hadoop环境搭建、HDFS存储、MapReduce计算、前端展示、文档说明,每一环都能在论文里写出实打实的内容,答辩时也能把一个完整流程讲明白。这套系统做的事情很直接——输入一份“用户-好友列表”,输出一个“你可能认识的人”的TopN候选列表,而核心算法其实只是共同好友计数。本文会按我实际做过的方案,把环境搭建、MapReduce源码、参数配置和踩坑记录全部拆开讲,新手照着操作就能跑通。
2. 推荐原理与数据模型:为什么“找共同好友”两轮MapReduce就能出结果
2.1 共同好友计数的数学本质:好友矩阵的转置相乘
很多教程一上来就抛“基于物品的协同过滤”“基于用户的协同过滤”,听起来高大上,但落到好友推荐这个场景,最经典的算法反而是最简单的“共同好友数”。它的逻辑用一句话说就是:如果两个陌生人都认识张三,那他们俩大概率也愿意认识彼此。
把这句话翻译成数学语言,就是一个矩阵运算。假设有一张好友关系矩阵 M,行是用户,列也是用户,M[i][j]=1 表示用户 i 是用户 j 的好友。那么 M 乘以 M 的转置得到的结果矩阵里,第 (a,b) 个元素的值,就是用户 a 和用户 b 的共同好友数量。这就是社交网络里最常见的“Friend-of-Friend”推荐,也叫“二度人脉推荐”。
但注意,这里有个很关键的问题:MapReduce 不能直接算矩阵乘法。一个 10 万用户的矩阵,光是存储就需要 100 亿个元素,伪分布式环境下根本跑不动。所以实际落地时,我们不构建矩阵,而是对每个用户的好友列表做两两组合,生成候选对。用一个小例子说明:用户 A 的好友是 B、C、D,那么从这一行数据里可以产生三个候选对:(B,C)、(B,D)、(C,D)。每一对的值都是 A,表示“A 是 B 和 C 的共同好友”。把所有用户的数据都这样处理完,再按候选对分组,数一数每组有多少个不同的值,就是共同好友数。
这个做法的巧妙之处在于,它把矩阵乘法拆成了“局部两两组合 + 全局聚合”,正好是 MapReduce 最擅长的事情。Map 阶段做组合,Reduce 阶段做计数,一个作业就能完成。但实际系统里通常要跑两个作业:第一个作业生成候选对并统计共同好友数,第二个作业做排序取 TopN,因为 MapReduce 默认只按键排序,不按值排序,直接在第一个作业里输出 TopN 需要额外处理。这也是本文源码部分要重点讲清楚的地方。
2.2 数据格式与HDFS目录规划:一表两job怎么组织
开发这套系统前,先要把数据格式定下来,否则后面写 MapReduce 解析逻辑时会非常痛苦。我常用的输入格式是一行一个用户,用制表符分隔用户 ID 和好友 ID 列表,好友 ID 之间用英文逗号分隔:
A B,C,D B A,C,E C A,B,F D A,G E B,G F C G D,E这里有一个容易忽略的细节:好友关系是对称的,A 的好友列表里有 B,B 的好友列表里也必须有 A,否则后面过滤“已经是好友”的候选对时会漏掉。写一个校验脚本检查对称性,比在代码里调试半天要快得多。
HDFS 目录规划方面,我一般建三个目录:
/user/hadoop/input存放原始好友关系数据;/user/hadoop/friendpair存放第一个作业的输出,也就是候选对和共同好友列表;/user/hadoop/recommend存放第二个作业的输出,也就是最终推荐 TopN。
这样的好处是两个作业解耦,第一个作业跑完可以先检查中间结果,确认候选对生成正确,再提交第二个作业。尤其是排错的时候,能直接hdfs dfs -cat看中间结果,比对着日志猜省事得多。
2.3 选型边界:为什么是MapReduce而不是Spark,以及什么时候该上Hive
你可能要问:现在大数据生态里,Spark 的流行度早就超过原生 MapReduce 了,为什么毕业设计还要用 Hadoop MapReduce?我的看法是,MapReduce 更适合教学和答辩:它的运行机制是“一个 Map 阶段 + 一个 Reduce 阶段”,出错时日志堆栈简单直接,适合在论文里画流程图。而 Spark 的 RDD、DAG、宽窄依赖这些概念,如果只是照抄别人的代码,答辩时很容易被问穿。
但是有一个边界必须清楚:如果数据量真的到了 TB 级,这种“先两两组合再聚合”的写法会产生巨大的中间数据,因为组合数是 O(n²) 的。比如一个用户有 1000 个好友,一次 map 就要输出约 50 万条记录。这时候原生 MapReduce 的 Shuffle 会成为瓶颈,更合理的方案是用 Spark 的groupByKey配合内存计算,或者直接用 Hive SQL 让优化器帮我们处理。第 4 章末尾会给出一段 Hive 对照 SQL,你可以根据自己的数据量决定用哪种方式。
另外补充一句:如果你的目标不是毕业设计而是真上线,那这套“共同好友计数”逻辑还需要配合用户行为权重、时间衰减因子一起用,否则推荐结果会偏向那些好友数量极多的高热度用户。关于这点在第 5 章的数据倾斜部分会展开。
3. Hadoop伪分布式搭建:从零到能跑通的最小配置
3.1 JDK、SSH与Hadoop安装:三条命令之外还要做的事
第 1 章说过,这套系统的第一道坎是环境。Hadoop 的安装本身只有三步:下载、解压、配环境变量,但很多同学在这一步就卡了三四天,所以我把完整流程写出来。我用的是 Hadoop 3.3.4、JDK 1.8,操作系统是 CentOS 7,你不需要完全一致,但版本别差太多。
打开终端,依次执行:
# 更新系统并安装必要的工具 sudo yum install -y java-1.8.0-openjdk-devel ssh rsync # 创建Hadoop专用用户(如果已用root,可以先跳过用户切换这步) sudo useradd -m hadoop sudo passwd hadoop # 配置SSH免密登录到本机 su - hadoop ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ssh localhost这段命令里,ssh localhost如果能直接登录不提示输密码,说明免密配置成功。这个步骤很容易被忽略,但 HDFS 的 DataNode 启动时需要 SSH 到各个节点,伪分布式虽然只有一台机器,也必须先搞定免密。
然后是安装 Hadoop 本身:
cd /opt sudo tar -zxvf hadoop-3.3.4.tar.gz -C /usr/local/ sudo mv /usr/local/hadoop-3.3.4 /usr/local/hadoop sudo chown -R hadoop:hadoop /usr/local/hadoop # 配置环境变量,写入 ~/.bashrc export HADOOP_HOME=/usr/local/hadoop export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin source ~/.bashrc # 验证 hadoop version最后还要改/etc/hosts,给机器起一个固定的主机名,否则 NameNode 启动时解析主机名会失败:
127.0.0.1 localhost 192.168.1.100 node1把HOSTNAME也改成node1,执行hostnamectl set-hostname node1后重新登录。这个操作比很多人想的更重要,后面配置core-site.xml时会用到主机名。
3.2 三个核心配置文件的参数:core-site、hdfs-site、yarn-site
Hadoop 的配置分散在etc/hadoop/目录下的好几个 XML 文件里,最核心的是core-site.xml、hdfs-site.xml、yarn-site.xml,还有一个mapred-site.xml(注意这个文件默认叫mapred-site.xml.template,要手动重命名)。
先看core-site.xml:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://node1:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/home/hadoop/hadoop_data/tmp</value> </property> </configuration>fs.defaultFS是 NameNode 的地址,这里用node1而不是localhost,是为了后面hdfs dfs命令和 Java 代码里都能用同一个地址访问集群。如果你在代码里写hdfs://localhost:9000,在 shell 里却用hdfs://node1:9000,可能出现“文件系统不同”的诡异问题。
hadoop.tmp.dir是 Hadoop 存放临时文件的根目录,默认在/tmp下,系统重启后会被清空,所以一定要改成自己的目录。很多同学早上起来发现 HDFS 进不去了,元数据全部丢失,就是因为这个参数没改。
然后是hdfs-site.xml:
<configuration> <property> <name>dfs.replication</name> <value>1</value> </property> <property> <name>dfs.namenode.name.dir</name> <value>/home/hadoop/hadoop_data/name</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/home/hadoop/hadoop_data/data</value> </property> </configuration>伪分布式只有一台机器,副本数必须设为 1,否则 DataNode 会一直提示“块副本不足”的警告。dfs.namenode.name.dir和dfs.datanode.data.dir分别存 NameNode 的元数据和 DataNode 的块数据,也要指向独立目录,不能跟hadoop.tmp.dir混在一起。
接下来是yarn-site.xml:
<configuration> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property> <property> <name>yarn.nodemanager.resource.memory-mb</name> <value>4096</value> </property> </configuration>aux-services必须配置成mapreduce_shuffle,否则 MapReduce 作业提交后会一直卡在 ACCEPTED 状态。如果你的机器内存只有 4G,yarn.nodemanager.resource.memory-mb可以调到 3072,给系统留一点余量。
最后是mapred-site.xml:
<configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property> <property> <name>mapreduce.map.memory.mb</name> <value>1024</value> </property> <property> <name>mapreduce.reduce.memory.mb</name> <value>2048</value> </property> </configuration>这几个参数是亲测过的血泪经验:默认的 map/reduce 内存上限太低,跑稍大一点的数据就会触发GC overhead limit exceeded,第 5 章会单独讲这个坑。这里先把参数立好。
3.3 启动、jps验证与数据上传:确认HDFS真的在工作
配置写完之后,第一次启动 Hadoop 需要先格式化 NameNode:
hdfs namenode -format这一步会生成 NameNode 的元数据目录,并且把集群 ID 写入目录里的VERSION文件。注意,格式化只需一次,以后不要随便重复执行。然后启动 HDFS 和 YARN:
start-dfs.sh start-yarn.sh启动完成后,用jps命令检查进程。伪分布式模式下,你应该能看到这五个进程:
- NameNode
- DataNode
- SecondaryNameNode
- ResourceManager
- NodeManager
缺哪个进程,就说明对应的服务启动失败了,优先看/usr/local/hadoop/logs/下的hadoop-hadoop-namenode-node1.log这类日志文件。接下来的验证分两步:先检查 HDFS 的基本状态,再上传真正要用的数据文件。
可以先创建一个模拟的好友数据文件friends.csv(格式就是第 2 章的 7 行数据),然后执行上传命令:
# 创建输入目录 hdfs dfs -mkdir -p /user/hadoop/input # 上传数据 hdfs dfs -put /home/hadoop/friends.csv /user/hadoop/input/ # 查看文件 hdfs dfs -ls /user/hadoop/input/ # 检查文件块分布 hdfs fsck /user/hadoop/input/friends.csv -files -blocks -locationsfsck命令会输出文件在 HDFS 上的块 ID 和所在 DataNode 的地址,这是判断“数据真的存进了 HDFS”最直接的证据。如果这里能看到一个blk_开头的块,说明数据已经完成了分块和副本复制,后面跑 MapReduce 时才会从 HDFS 读取而不是从本地文件系统读取。
4. 源码实现:两个MapReduce作业完成好友推荐
4.1 作业一:从好友列表生成“用户对-共同好友”候选集
代码层面,我建议用 Java 写,因为 Hadoop 原生 API 就是 Java 的,网上能查到的源码和博客也以 Java 为主。第一个作业的 Mapper 核心逻辑是:解析一行用户数据,对好友列表做两两组合,输出键为“好友对”,值为“当前用户ID”。
import org.apache.hadoop.io.*; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class FriendRecommendMapper extends Mapper<LongWritable, Text, Text, Text> { private final Text outKey = new Text(); private final Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); // 跳过空行 if (line.isEmpty()) return; String[] parts = line.split("\t"); if (parts.length != 2) return; String user = parts[0]; String[] friends = parts[1].split(","); // 对好友列表做两两组合,生成候选好友对 for (int i = 0; i < friends.length; i++) { String friendA = friends[i].trim(); if (friendA.equals(user)) continue; for (int j = i + 1; j < friends.length; j++) { String friendB = friends[j].trim(); if (friendB.equals(user)) continue; // 使用compareTo保证两个好友的键唯一,避免(A,B)和(B,A)被当作两对 String pairKey = friendA.compareTo(friendB) < 0 ? friendA + "-" + friendB : friendB + "-" + friendA; outKey.set(pairKey); outValue.set(user); context.write(outKey, outValue); } } } }这里有一个容易被忽视的点:为什么用friendA.compareTo(friendB)来决定顺序?如果不排序,A 的好友列表里有 B 和 C 时,可能输出B-C,而 B 的好友列表里有 A 和 C 时输出C-A,这两个键在 Shuffle 阶段会被分配到不同的 Reduce,导致共同好友统计不出来。用compareTo强行规定键的字母序,能让同一个好友对无论从哪一行解析,都生成完全相同的键。
Reducer 阶段做的事情就更简单了:把同一好友对的所有 value(也就是共同好友 ID)收集起来,输出键是好友对,值是“共同好友数 + 共同好友 ID 列表”。
import org.apache.hadoop.io.*; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class FriendRecommendReducer extends Reducer<Text, Text, Text, Text> { private final Text outValue = new Text(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { List<String> commonFriends = new ArrayList<>(); // 同一个好友对的所有value,都是共同好友 for (Text val : values) { String friend = val.toString(); if (!commonFriends.contains(friend)) { commonFriends.add(friend); } } // 输出格式:好友对 \t 共同好友数 \t 共同好友ID列表 int count = commonFriends.size(); String value = count + "\t" + String.join(",", commonFriends); outValue.set(value); context.write(key, outValue); } }这段代码里我用了一个ArrayList.contains()去重,在数据量大时会比较慢,换成HashSet会更合适。毕业设计的数据量一般不大,用 ArrayList 反而让逻辑更直白,回答答辩时也更容易解释。
作业一的 Driver(main 方法)需要注意输出目录不能存在,Hadoop 默认不允许覆盖输出目录:
import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class FriendPairJob { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: FriendPairJob <input> <output>"); System.exit(-1); } Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "friend pair"); job.setJarByClass(FriendPairJob.class); job.setMapperClass(FriendRecommendMapper.class); job.setReducerClass(FriendRecommendReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(Text.class); FileInputFormat.setInputPaths(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }跑完作业一之后,用下面的命令查看中间结果:
hadoop jar recommend.jar FriendPairJob /user/hadoop/input /user/hadoop/friendpair hdfs dfs -cat /user/hadoop/friendpair/part-r-00000如果数据正常,你会看到类似这样的输出:
A-D 1 A B-C 2 B,C这个结果说明B和C有A这个共同好友,而A和D的共同好友还是A自己,逻辑上没问题但显然不是我们想要的推荐——因为 A 和 D 本来就是好友。这个问题由作业二来解决。
4.2 作业二:统计共同好友数并输出推荐TopN
作业一输出的候选对里,包含了很多“已经是好友”的用户对,比如上面的A-D。真正的推荐系统要推荐那些“不是好友但共同好友多”的人。所以作业二需要在读取作业一结果时,过滤掉已经存在好友关系的对。
过滤的依据是什么?最直接的方法是把原始的好友关系表加载到内存,做一个HashSet,判断某一对用户是否已经是好友。MapReduce 提供了 DistributedCache 机制(Hadoop 3 中推荐用job.addCacheFile()),可以在作业启动前把一个小文件分发到所有节点上。
作业二的 Mapper 代码如下:
import org.apache.hadoop.io.*; import org.apache.hadoop.mapreduce.Mapper; import org.apache.hadoop.fs.Path; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.util.HashSet; import java.util.Map; import java.util.TreeMap; public class TopNMapper extends Mapper<Object, Text, Text, Text> { private final HashSet<String> existingFriends = new HashSet<>(); private final TreeMap<Long, String> topNMap = new TreeMap<>(); private static final int TOP_N = 20; @Override protected void setup(Context context) throws IOException, InterruptedException { // 从DistributedCache加载已有的好友关系,格式为 user\tfriend Path[] cacheFiles = context.getLocalCacheFiles(); if (cacheFiles != null && cacheFiles.length > 0) { BufferedReader reader = new BufferedReader(new FileReader(cacheFiles[0].toString())); String line; while ((line = reader.readLine()) != null) { String[] parts = line.split("\t"); if (parts.length == 2) { String a = parts[0].trim(); String b = parts[1].trim(); String key = a.compareTo(b) < 0 ? a + "-" + b : b + "-" + a; existingFriends.add(key); } } reader.close(); } } @Override protected void map(Object key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty()) return; String[] parts = line.split("\t"); if (parts.length < 2) return; String pairKey = parts[0]; long count = Long.parseLong(parts[1]); // 过滤掉已经是好友的用户对 if (existingFriends.contains(pairKey)) return; topNMap.put(count, line); if (topNMap.size() > TOP_N) { topNMap.remove(topNMap.firstKey()); } } @Override protected void cleanup(Context context) throws IOException, InterruptedException { // 从大到小输出TopN,注意TreeMap默认按从小到大排序 for (Map.Entry<Long, String> entry : topNMap.descendingMap().entrySet()) { context.write(new Text(String.valueOf(entry.getKey())), new Text(entry.getValue())); } } }这个实现的核心是TreeMap,它天然对键排序,topNMap.size() > TOP_N时不断删除最小元素,map 任务结束时cleanup里剩下的就是最大的 20 个候选对。这里要注意TreeMap的键是共同好友数,如果两个候选对的共同好友数相同,后写入的会覆盖先写入的,需要改成TreeMap<Long, List<String>>或者给键加一个随机后缀,否则可能漏掉一部分结果。
作业二不需要 Reduce,所以在 Driver 里要设置job.setNumReduceTasks(0),输出文件就不是part-r-而是part-m-。
hadoop jar recommend.jar TopNJob /user/hadoop/friendpair /user/hadoop/recommend -files hdfs:///user/hadoop/input/edges.txt其中-files参数指定的是从 HDFS 上读取的好友关系边表,格式是每行一对好友关系,两列用制表符分隔。
4.3 Job参数设置:Combiner、JVM复用与输出目录清理
两个作业的 Driver 代码缺了几个生产环境下必调的参数,单独拎出来讲。
第一个是 Combiner。作业一 Shuffle 阶段的数据量很大,因为同一个好友对会被很多个用户当作共同好友输出。可以在 Driver 里加一行:
job.setCombinerClass(FriendRecommendReducer.class);Combiner 的逻辑和 Reducer 完全一样,但它是在每个 Map 任务本地先做一次合并,减少发送到 Reduce 端的数据量。不是所有场景都能把 Reducer 当 Combiner 用,但“汇总统计”这种场景没问题,因为合并操作满足交换律和结合律。
第二个是 JVM 复用。MapReduce 默认每个任务启动一个新的 JVM,启动开销在数据量大时非常明显。在mapred-site.xml里设置探查参数:
<property> <name>mapreduce.job.jvm.numtasks</name> <value>5</value> </property>意思是每个 JVM 最多运行 5 个任务,减少 JVM 反复启动的开销。我在第 3 章配置环境时没有写这个参数,现在补上。
第三个是输出目录清理。作业第二次跑的时候,如果输出目录已经存在,会直接抛FileAlreadyExistsException。我在 Driver 里会加一段预处理代码:
Path outputPath = new Path(args[1]); outputPath.getFileSystem(conf).delete(outputPath, true);这段代码在提交作业前先删除输出目录,省得每次手动hdfs dfs -rm -r,也避免作业失败后残留的半成品数据干扰下一次运行。
4.4 Hive写法对比:一行SQL能替代多少Java代码
如果你完成 MapReduce 版本之后还有时间,建议把同样的逻辑用 Hive 写一遍,论文里可以做对比分析。Hive 的底层虽然还是 MapReduce,但 SQL 表达力更强,代码量能大幅缩减。
WITH expanded AS ( SELECT user, friend FROM friend_relation LATERAL VIEW explode(friends) t AS friend ) SELECT e1.user AS user_a, e2.user AS user_b, COUNT(DISTINCT e1.friend) AS common_cnt FROM expanded e1 JOIN expanded e2 ON e1.friend = e2.friend AND e1.user < e2.user GROUP BY e1.user, e2.user ORDER BY common_cnt DESC LIMIT 20;这段 SQL 的核心是LATERAL VIEW explode(),把每一行的好友列表拆成多行;然后自连接,连接条件是“两个人的好友相同”,即e1.friend = e2.friend;最后按用户对分组统计共同好友数。e1.user < e2.user的条件也起到了去重作用,避免把 (A,B) 和 (B,A) 算成两组。
对比下来你会发现,MapReduce 版本的 4 个类、几百行 Java 代码,Hive 版 15 行 SQL 就搞定了。但这也恰恰是毕业设计要写 MapReduce 的原因:Hive 帮我们屏蔽了太多细节,答辩证道“Hive 底层如何执行 join”时,如果只写过 SQL 会很难回答;而你亲自写过 Mapper 和 Reducer,就能理解 Hive 的 join 实际上也是 Map 端或 Reduce 端的连接操作。
5. 避坑指南:Hadoop好友推荐最常见的5个翻车现场
5.1 Container内存超限:GC overhead limit exceeded怎么根治
现象:作业提交后进度一直停在 60% 左右,日志里出现大量GC overhead limit exceeded,TaskAttempt 被反复 kill 重启,整个作业跑了一个多小时还没结束。
原因:YARN 给 Container 分配的内存不够。默认配置下mapreduce.reduce.memory.mb是 1024MB,但 JVM 的堆上限mapreduce.reduce.java.opts也继承了这个值,一旦数据量上来,GC 频繁触发,最终直接 OOM。
解决:把内存参数调大,同时保证java.opts的值不要超过 YARN 的 Container 内存上限,否则 Container 启动阶段就会被 NodeManager 杀掉。
<property> <name>mapreduce.reduce.memory.mb</name> <value>2048</value> </property> <property> <name>mapreduce.reduce.java.opts</name> <value>-Xmx1536m</value> </property>注意-Xmx要比 Container 内存小 512M 左右,剩下留给 JVM 以外的开销。如果机器内存只有 4G,建议先把yarn.nodemanager.resource.memory-mb调低到 3072,否则多个 Container 同时申请内存会把 NodeManager 拖垮。
5.2 反复格式化后集群起不来:CLUSTERID不一致
现象:之前 Hadoop 跑得好好的,某天想重新初始化环境,执行了hdfs namenode -format,然后start-dfs.sh,但jps一看 DataNode 没起来,日志里报Incompatible clusterIDs。
原因:namenode -format会生成一个新的集群 ID,但 DataNode 的data目录里保存的还是旧的集群 ID。两个 ID 对不上,DataNode 拒绝启动。
解决:格式化之前,先清空 NameNode 和 DataNode 的数据目录。
stop-dfs.sh rm -rf /home/hadoop/hadoop_data/name rm -rf /home/hadoop/hadoop_data/data hdfs namenode -format start-dfs.sh如果不确定数据目录在哪里,可以看hdfs-site.xml里的dfs.namenode.name.dir和dfs.datanode.data.dir配置。以后不要反复格式化同一个集群,格式化前先咨询一下为什么需要格式化,90% 的情况用别的方式能解决。
5.3 第二次跑同一个Job直接报错:FileAlreadyExistsException
现象:修改了代码,重新hadoop jar提交作业,日志里抛异常org.apache.hadoop.mapred.FileAlreadyExistsException,提示输出目录已存在。
原因:MapReduce 默认不覆盖输出目录,这是防止误删数据的设计决策。
解决:两种方式任选。一是每次手动删除:
hdfs dfs -rm -r /user/hadoop/friendpair二是在 Driver 代码里加自动清理,第 4.3 节已经写过。我自己的习惯是代码里加自动清理,因为这个作业的输出是中间结果,删掉不心疼;但最终结果目录不要用自动删除,防止误操作把有价值的输出冲掉。
5.4 IDE里能跑,hadoop jar却ClassNotFound
现象:在 IDEA 里点 Run 能正常跑完作业,但打包成 jar 用hadoop jar提交后,报ClassNotFoundException: org.apache.hadoop.fs.Path或类似错误。
原因:IDEA 里运行时用的是本地 Hadoop 依赖,但hadoop jar提交到集群后,需要代码里手动设置job.setJarByClass()让框架知道从哪个 jar 加载类。如果你的 jar 包没有把依赖的第三方类打进去,就会在 Reduce 端遇到类找不到。
解决:第一步在 Driver 里写job.setJarByClass(YourMainClass.class),我写的代码里已经有了。第二步,如果用了第三方库,打包时用 Maven Shade 插件把依赖打成一个 fat jar。毕业设计项目一般只用 Hadoop 自带的类,所以 setJarByClass 就足够了。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> </execution> </executions> </plugin>5.5 Reduce长尾:热门用户把共同好友对打成数据倾斜
现象:作业过程中大部分 Reduce 任务 30 秒跑完,但有一两个 Reduce 任务跑了 20 分钟还在跑,整个作业被这两个任务拖到超时。
原因:数据倾斜。假设用户 “A” 是社交达人,有 5000 个好友,那 A 参与的组合会产生近 1250 万个候选对,这些候选对大概率都包含 A 的某个好友对,而默认的 HashPartitioner 让这些键集中在少数几个 Reduce 上。
解决:第一选择是给键加盐做两阶段聚合,第一阶段加随机前缀分散到多个 Reduce,第二阶段去掉前缀再聚合一次。好友推荐场景还有一个更简单的手段:过滤掉好友数超过阈值的用户,比如好友数超过 2000 的用户不参与两两组合,因为这些高热度用户容易被推荐给所有人,推荐价值反而不高。在 Map 阶段加一个判断:
if (friends.length > 2000) return;这样会牺牲部分高热度用户的推荐覆盖率,但显著降低作业运行时间。面试被问到数据倾斜时,能说出“加盐两阶段聚合”和“业务层过滤极端值”这两个方案,基本就拿住得分点了。
6. 进阶验证:把推荐结果做成用户敢信的TopN
6.1 留出法验证:从真实好友里藏掉20%再算准确率
跑完推荐,很多人不知道如何证明“推荐得准”。最朴素也最有效的做法是留出法验证:把原始数据里的好友关系随机删掉 20%,当作“用户还没有成为好友的关系”,用剩下 80% 的数据做推荐,看 TopN 推荐结果里有多少命中被删掉的那 20%。
操作时先写一个 Python 脚本,把friends.csv里的边随机抽取 20% 单独存成test_edges.csv,从原始文件里删掉这些边,得到train_edges.csv。用训练数据跑完整推荐流程,然后写一段校验脚本:
# 读取被隐藏的真实好友关系 test_edges = set() with open("test_edges.csv", "r", encoding="utf-8") as f: for line in f: a, b = line.strip().split("\t") test_edges.add((a, b) if a < b else (b, a)) # 读取推荐结果,格式:共同好友数 \t 好友对 \t 共同好友列表 hits = 0 total = 0 with open("recommend_result.txt", "r", encoding="utf-8") as f: for line in f: parts = line.strip().split("\t") if len(parts) < 2: continue pair = parts[1] a, b = pair.split("-") key = (a, b) if a < b else (b, a) total += 1 if key in test_edges: hits += 1 precision = hits / total if total > 0 else 0 print(f"推荐总数: {total}, 命中隐藏好友数: {hits}, Precision@{total}: {precision:.2%}")注意这里算的是 Precision 而非 Recall,因为 TopN 推荐天然只看“推荐的结果里有没有正确的”,不追求覆盖所有可能的好友。如果 Precision 能到 10%~20%,对这个算法来说已经是相当好的结果,毕竟 TopN 只有 20 个名额,而候选可能有几万。
6.2 让推荐结果有解释性:输出共同好友ID而不是只有一个计数
很多毕业设计在这一步戛然而止:输出一张“用户A推荐用户B”的表格就结束了。但你的系统如果拿到用户面前演示,一定会被问一个问题:为什么推荐这个人?
我建议作业一的输出保留共同好友 ID 列表(代码里已经保留了),然后在最终推荐结果的前端展示中,把“共同好友数”替换成“你和张三有 5 个共同好友:李四、王五、赵六……”。要让前端拿到这些信息,需要在作业二里把作业一的完整输出行传递下去,而不是只传计数。第 4.2 节的TopNMapper里已经做了这一点,topNMap的值存的是line(包含共同好友列表),后续解析时把parts[2]拆出来就可以给前端用。
用户看到推荐理由后,点下“加好友”的意愿会明显提升。这个细节在答辩时讲出来,导师会认为你考虑了真实产品体验,而不仅仅是把数据跑通。
6.3 演示与面试的收尾技巧
最后说一个操作层面的习惯:正式演示前,我会预先把recommend输出目录清空并跑完一次完整作业,把结果存在 HDFS 上;演示时再跑一次,展示全过程。这样即使现场集群状态波动,也能先给出结果再慢慢看流程。
面试或答辩时,除了讲代码,建议把下面这几个点提前组织好语言:
- 共同好友推荐本质是好友矩阵的转置乘法,但用 MapReduce 实现时拆成了两两组合和聚合两步;
- 为什么需要两个 MapReduce 作业,而不是一个作业直接输出 TopN;
- 数据倾斜如何影响作业性能,你的作业里怎么缓解;
- 如果数据量扩大 100 倍,瓶颈会在哪里,怎么扩展。
这些都是 Hadoop 面试题里的高频方向。说实话,这个项目做了两轮之后,我自己最大的教训是:不要一上来就写代码,先用小数据把两个作业的数据流跑通,再扩展完整数据集。数据流一旦理解偏了,后面每跑一次作业都会在某个角落里翻车。
希望这篇笔记能帮到你,让你的 Hadoop 毕业设计少踩几个坑。
本文还有配套的精品资源,点击获取