简介:基于Hadoop实现的好友推荐系统毕业设计项目,包含Java源码与文档说明,适合计算机、人工智能等专业学生用于课程设计、期末大作业或毕设参考,也适合大数据离线计算初学者。压缩包共2000个文件、约79.46MB,含1260个PNG截图、403个CSS样式、89个JAR依赖库、73个Java源码、24个JSP页面等,覆盖前台、后台与MapReduce计算链路,代码调试可运行且答辩评分98分。预览中的多个Mapper类涉及距离计算、聚类与初始簇查找等推荐算法步骤,便于理解推荐系统的核心思路,文档与源码对应,方便排错和二次开发。目前已有200人学习,适合作为毕业设计答辩或大数据课程项目的可靠蓝本。
1. Hadoop做好友推荐的选型理由:这题不是非Spark不可
被问“为什么不用Spark做推荐”是每一个选择Hadoop写毕业设计的人都要面对的问题。我的回答通常很直接:好友推荐的核心是共同好友统计和相似度排序,它不需要实时迭代,也不需要复杂的特征管道,恰恰是MapReduce最拿手的两阶段批处理。离线链路短、依赖少、结果可复现,Hadoop的伪分布式环境就能覆盖八成场景,存量和增量都能讲得清。
下面的篇幅按一个完整项目该有的顺序走:先定清楚算法思路和MapReduce作业设计,再把hadoop伪分布式搭建、输入输出格式和参数调整逐一落成可执行命令,最后补一份和源码对得上的文档写作建议。适合正在做课程设计或毕业设计、想从概念走到“能跑、能截图、能答辩”的读者,也适合想快速评估这个方向工作量的人。
2. 算法设计与MapReduce作业:把好友推荐拆成三个可运行的Job
2.1 选型:共同好友比协同过滤更适合Hadoop批处理
好友推荐常见的实现路径有两条:一条是基于用户属性匹配,比如专业、学校、城市重合度排序;另一条是基于关系图的二度邻居推荐,也就是“好友的好友”。毕业设计里我更倾向后者,因为它的数据依赖只有一张好友关系表,不需要额外的用户画像,而且共同好友的个数天然是排序分,口头上很容易向评委解释。
把一张好友关系表当成无向图来读,推荐给用户 A 的结果,就是所有“与 A 至少有一个共同好友、但和 A 没有直接关系”的用户。推荐强度可以直接用共同好友数量,也可以用 Jaccard 系数:
score(A,B) = 共同好友个数 / (A的好友数 + B的好友数 - 共同好友个数)
Hadoop 在这里做的事情,是把全量关系表展开成用户对,再按用户对聚合统计。两个用户如果有很多共同好友,他们被分到同一个 reduce 去计算,map 阶段产出什么 key 直接决定后续聚合是否正确。
2.2 第一趟:把好友列表展开成“双方+共同好友”三元组
输入数据是两列,tab 分隔,第二列是逗号分隔的好友列表,示例如下:
u1 tom,jerry,john u2 tom,jack u3 jerry,jack u4 tom,jerry,jack第一趟 Mapper 要做的,是让每个用户都和他好友的“其它好友”产生一条关系。比如 u1 的好友是 tom、jerry、john,那么(u1, tom)这一对就应该被展开成(u1:tom, tom)这条记录,value 代表“谁把这两个人联系在一起”。更关键的是 key 要做字典序排序,保证(u1,tom)和(tom,u1)最终走到同一个 reduce。
import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; /** * 输入:user \t friend1,friend2,... * 输出:key=A:B(字典序),value=当前中间人 * 过滤掉自己和自己的配对,避免无意义数据 */ public class PairMapper extends Mapper<LongWritable, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] parts = value.toString().trim().split("\t"); if (parts.length != 2) { return; } String user = parts[0]; String[] friends = parts[1].split(","); for (String friend : friends) { if (user.equals(friend)) { continue; // 自环数据直接丢弃 } String a = user.compareTo(friend) < 0 ? user : friend; String b = user.compareTo(friend) < 0 ? friend : user; outKey.set(a + ":" + b); outValue.set(user); context.write(outKey, outValue); } } }这里用compareTo做字典序拼接,是为了让无序对(A,B)在 key 上统一格式,避免同一对用户被拆到两个 reduce。outValue存的是“中间人”,后续聚合阶段可以用它去重,也可以用它还原共同好友列表,这一步保留了后续扩展的空间。不要只输出计数,因为毕业设计问答环节大概率会问“你怎么证明这两个人的共同好友真的是这几个”。
2.3 第二趟:聚合共同好友并计算加权推荐分
Reducer 收到的 key 是用户对,value 是中间人列表。这一级要做的核心工作是去重、过滤掉已经直接相连的用户对,再计算推荐分。
import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.Set; import java.util.TreeSet; /** * 输入:key=A:B,value=中间人列表 * 输出:key=A,value=B\t共同好友数\t共同好友串 * 过滤条件:B 不能是 A 的直接好友(由外部配置传入) */ public class FriendRecReducer extends Reducer<Text, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { String[] users = key.toString().split(":"); Set<String> common = new TreeSet<>(); for (Text val : values) { common.add(val.toString()); } common.remove(users[0]); common.remove(users[1]); if (common.isEmpty()) { return; } outKey.set(users[0]); outValue.set(users[1] + "\t" + common.size() + "\t" + String.join(",", common)); context.write(outKey, outValue); } }这里的去重逻辑很关键:同一个中间人可能被重复输出,比如多条脏数据里都包含同一关系,直接用TreeSet去重后才能保证计数准确。如果项目里要体现“优化意识”,可以把 Jaccard 系数的分母算出来:用户的好友度数可以提前用另一个 Job 统计,或者在 map 阶段额外输出一条user->count记录。但毕业设计通常做到“共同好友数 + 度归一化”就足够有深度了,不必把相似度矩阵做成全量笛卡尔积。
2.4 第三趟:利用局部 TopN 输出推荐列表
第二个 Job 的结果是“每个用户产生了很多条推荐候选”,不能直接把几万条结果塞给前端。最后一趟 MapReduce 要做局部 TopN:同一个 user 的所有候选进入同一个 reducer,在内存里用大小为 N 的最小堆筛选分数最高的 N 个。
import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.PriorityQueue; /** * 按照推荐分倒序取前 N 个推荐好友 * 配置项 rec.topn 控制每个用户输出几条 */ public class TopNReducer extends Reducer<Text, Text, Text, Text> { private int topN = 10; @Override protected void setup(Context context) { topN = context.getConfiguration().getInt("rec.topn", 10); } @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { PriorityQueue<String> heap = new PriorityQueue<>( (a, b) -> { int scoreA = Integer.parseInt(a.split("\t")[1]); int scoreB = Integer.parseInt(b.split("\t")[1]); return Integer.compare(scoreA, scoreB); } ); for (Text val : values) { heap.offer(val.toString()); if (heap.size() > topN) { heap.poll(); } } for (String line : heap) { context.write(key, new Text(line)); } } }这种局部 TopN 的好处是不需要全量排序,reduce 端只需要维护 N 个元素的堆,内存压力可以忽略。注意rec.topn是通过-Drec.topn=20传入的,setup里读取一次即可。第三趟的意义不只是“控制输出条数”,它让源码里每增加一个 Job 都有明确职责,文档里画数据流向图时也更清晰。
3. hadoop伪分布式搭建:从安装到跑通推荐作业的最小路径
3.1 环境准备:JDK、SSH与安装目录
推荐系统项目跑在 Hadoop 上,第一步不是解压安装包,而是确认 JDK 版本。Hadoop 对 JDK 8 的兼容性最稳妥,很多发行版默认也是用 8 构建的,没有必要在这个环节冒险升级到太新的版本。下载完安装包后解压到统一目录,并在~/.bashrc里写入环境变量:
tar -zxf hadoop-*.tar.gz -C /opt/hadoop vim ~/.bashrc # 追加以下内容 export HADOOP_HOME=/opt/hadoop export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin source ~/.bashrcSSH 配置在伪分布式模式下不是必须的,因为 NameNode 和 DataNode 都在本机,但如果后面想扩展到 hadoop集群搭建,免密登录迟早要配。建议现在就生成密钥并加入 authorized_keys,省得到时再改。接着运行hadoop version,能看到版本信息说明环境变量没问题。
3.2 修改核心配置文件:伪分布式与集群的差异
伪分布式搭建通常只需要改四个文件。配置文件位于$HADOOP_HOME/etc/hadoop/下,重点关注四张表:
| 文件 | 属性名 | 推荐配置 | 说明 |
|---|---|---|---|
| core-site.xml | fs.defaultFS | hdfs://localhost:9000 | 默认文件系统地址,工作在集群模式时改成 NameNode 主机名 |
| hdfs-site.xml | dfs.replication | 1 | 单个 DataNode 时副本必须设为 1,否则会一直处于缺副本状态 |
| mapred-site.xml | mapreduce.framework.name | yarn | 任务调度交给 YARN,而不是本地模拟 |
| yarn-site.xml | yarn.nodemanager.aux-services | mapreduce_shuffle | 通过 Shuffle 服务传输 map 输出给 reduce |
伪分布式的fs.defaultFS必须写localhost,不要写 IP,不少环境里本机解析不到主机名,启动 NameNode 后一直报连接失败。修改完配置后,第一次启动前需要格式化 NameNode:
hdfs namenode -format start-dfs.sh start-yarn.sh jps执行后,jps里应该能看到 NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager 这五个进程。缺一个进程,后面的 Job 都会以 ClusterID 不一致或连接拒绝告终。Hadoop单机版和伪分布式的本质区别就在这一步:单机版直接跑本地文件系统,伪分布式则把 HDFS 和 YARN 都拉起来,跑一次完整的分布式调度流程。
3.3 数据导入与提交推荐作业
把好友关系表放进 HDFS,建立输入输出目录。注意输出目录绝对不能被提前创建,否则作业会报Output directory already exists。
hadoop fs -mkdir -p /rec/input hadoop fs -put friendship.csv /rec/input hadoop jar friend-recommend-1.0.jar com.example.FriendRecDriver \ -Dmapreduce.job.reduces=2 \ -Drec.topn=10 \ /rec/input /rec/output-Dmapreduce.job.reduces=2是典型的需要按数据量调整的参数,后面会详细讲。作业跑完后查看结果:
hadoop fs -cat /rec/output/part-r-00000 | head -n 20如果觉得 HDFS 路径操作不顺手,可以把输入输出放到/user/当前用户名/下,这是 HDFS 的用户目录,免去写绝对路径的麻烦。课程设计环境里,这时应该能交出一张作业运行截图和part-r-00000的输出截图。
3.4 从伪分布式扩展到三节点集群的改动清单
不少答辩老师不会只问“你怎么配的伪分布式”,而是顺势问一句“如果数据量翻倍,你的架构要怎么扩”。从伪分布式扩展到 hadoop集群搭建,改动集中在三处:
- hdfs-site.xml 里把副本数从 1 改成 2 或 3,数据有了冗余;
- core-site.xml 里把
localhost换成 NameNode 主机的内网 IP 或域名; - 每台机器的
/etc/hosts要写全三台节点的主机名映射,slaves 或 workers 文件里登记 DataNode 列表。
在这个基础上再做一步安全性考虑:各节点之间配好 SSH 免密,防火墙放通 8020、9866、8088 这几个常见端口;时间同步用 ntp 或 chrony,时钟漂移会让 HDFS 的租约判断混乱。这些内容写在文档的“系统部署”一章,比贴一大段云主机参数有说服力得多。
4. 参数调优与排错:让推荐结果稳定可复现
4.1 三个必调参数:reduce个数、minScore、内存上限
好友推荐作业能不能稳定跑完,和输入数据大小没有绝对关系,真正影响成败的是资源参数和过滤条件。毕业设计里我很少做全量参数扫描,只盯着三个参数调。
第一个是mapreduce.job.reduces。这个参数决定第二阶段聚合的并行度。伪分布式环境下设 1 或 2 即可,真正到多节点集群时,业界常用的经验公式是节点数的 0.95 到 1.75 倍。reduce 数不是越大越好:每个 reduce 都要拉取所有 map 输出,数量多了会把mapreduce.task.io.sort.mb的 buffer 撑爆,shuffle 反而变慢。对好友推荐这种 key 数量巨大的作业,先跑一次默认配置看总耗时,然后翻倍观察耗时曲线,比直接抄一个公式更可靠。
第二个是推荐分过滤阈值,我习惯把它定义成rec.min.common,默认值为 1,也就是至少有 1 个共同好友才进入候选列表。要注意的是,这个参数不是只在 Reducer 里判断一次就完了,在 Reducer 输出到 HDFS 之前做过滤,能显著减少第三趟 TopN Job 的输入体量。实现上只需要一行 if 判断:
int minCommon = context.getConfiguration().getInt("rec.min.common", 1); if (common.size() < minCommon) { return; }第三个是容器内存。伪分布式默认分配 1GB 到 2GB,但 JVM 本身和 shuffle 的开销经常让任务在数据量稍大时被 YARN 杀掉,日志里出现Container killed by the ApplicationMaster。常见做法是在启动命令里临时指定:
hadoop jar friend-recommend-1.0.jar com.example.FriendRecDriver \ -Dmapreduce.map.memory.mb=1536 \ -Dmapreduce.reduce.memory.mb=2048 \ -Dyarn.nodemanager.resource.memory-mb=4096 \ /rec/input /rec/outputmapreduce.map.memory.mb和mapreduce.reduce.memory.mb是任务 JVM 堆上限;yarn.nodemanager.resource.memory-mb是单节点所有容器可用总内存,必须大于单个容器内存,否则资源不够直接调度失败。
4.2 三个高频MapReduce报错与定位思路
对源码附带的文档说明来说,排错章节写到能覆盖三个常见错误就及格了。hadoop面试题里也经常拿这几类问题考察候选人排查分布式任务的思路。
第一类Input path does not exist,百分之八十是路径问题。HDFS 里没有建立对应目录,或者把本地路径当成 HDFS 路径传给了 Job,都会报这个错。排查命令是hadoop fs -ls /rec/input,确认目录存在并且文件块没有损坏。注意 HDFS 路径默认不带file://前缀,它是 Hadoop 自己的文件系统视图。
第二类ClassNotFoundException,这是打包问题。Mapper、Reducer、自定义 Writable 没有打进同一个 jar,或者主类里用了libjars但没有把依赖一起传,YARN 的 NodeManager 上根本找不到类。排查思路不是回本地看 IDE,而是把 jar 拿到集群环境里执行jar tf friend-recommend-1.0.jar | grep Friend,确认字节码文件在不在。
第三类是数据倾斜。好友关系表里如果存在一个几百万粉丝的“明星用户”,这个明星的 key 会在 reduce 阶段成为单点热点,其他 reducer 全跑完了它还在跑。常见的缓解思路是加盐打散:map 阶段把热 key 加上随机后缀,分布到多个 reducer 预聚合,再第二趟去后缀做全局聚合。毕设层面能说出来“热点 Key 可以通过二次聚合缓解调度倾斜”这句话,就已经和直接调内存参数的文档拉开了差距。
4.3 本地模式与分布式模式的切换验证
一个很容易被忽略的验证手法是让同一个 Jar 同时支持两种运行模式。MapReduce 的LocalJobRunner可以在不启动 HDFS 和 YARN 的情况下跑完整作业,非常适合小数据量逻辑验证:
hadoop jar friend-recommend-1.0.jar com.example.FriendRecDriver \ -Dmapreduce.framework.name=local \ file:///tmp/rec_input file:///tmp/rec_output这里路径前缀从hdfs://换成了file://,输入输出直接落在本地磁盘上,跑完直接打开part-r-00000查看结果。本地模式最大的价值不是性能,而是排错:任何分布式环境下看不清楚的序列化问题、类型不匹配问题,在 local 模式下都能得到更短的问题栈。
验证推荐结果正确性时,我习惯准备一张手算过的小图:用户 u1 到 u4,每个用户的推荐结果先用 SQL 或 Python 算一遍,再和 Hadoop 输出逐行 diff。这一步在文档的“系统测试”章节里非常加分。它不是黑盒测试,而是有预期值的单元级测试。
5. 毕业设计文档说明怎么写:让源码和论文互为索引
5.1 文档结构先对齐源码模块
拿到一份源码后,最容易犯的错误是文档按“绪论、技术与工具、系统实现、测试”这种通用模板硬套,评委看到的是一份跟代码完全脱节的说明书。更高效的做法是先梳理源码目录,让每个核心类在文档里都有一个去处。下面是一份可直接用的对应关系表。
| 源码模块 | 文档章节 | 文档里应写的内容 |
|---|---|---|
| PairMapper + PairReducer | 系统详细设计 | 第一趟作业的输入输出格式,key 字典序排序的原因 |
| FriendRecReducer | 系统详细设计 | 共同好友去重逻辑,minScore 过滤条件 |
| RecDriver | 系统架构 | 三个 Job 的调度顺序,数据流向图 |
| LocalRecRunner | 系统测试 | 本地模式测试命令,预期结果截图 |
| friendship.csv | 数据描述 | 数据来源、字段说明、数据量标注 |
表格里每一行都能从论文反查到源码文件,评审老师拿到源码包后能按图索骥,答辩体验会好很多。
5.2 用一张实验记录表支撑过程和结论
毕业设计文档的“系统测试”部分,除了截图,还应该有一张可追溯的实验记录表。每次改动参数都要留下输入规模、参数、耗时、输出条数四列信息,表头可以直接复用这个结构:
| 实验编号 | 输入规模 | reducer数 | minCommon | 耗时 | 输出推荐对数 |
|---|---|---|---|---|---|
| exp-01 | 1万用户/5万条边 | 1 | 1 | 52s | 3102 |
| exp-02 | 10万用户/80万条边 | 4 | 2 | 11m40s | 9865 |
每次实验跑完,把part-r-00000的行数和前十条回显连同实验编号一起记录,文档写成“推荐覆盖率和配置参数的关系”,从结果反推出 minCommon 升高会显著压缩候选列表,这个观察比堆砌配置截图有价值。如果做增量实验,记得每次先删输出目录再跑,否则作业直接失败:
hadoop fs -rm -r /rec/output5.3 只写验证过的优化项,不照搬博客参数
文档里一定会有“系统优化”一节,这是最容易翻车的地方。网上大量关于调优的博客,参数名是真的,但推荐值是从集群环境里抄来的。伪分布式和双节点集群的内存参数完全是两个世界,如果文档里写了一个从未实际生效过的参数,答辩时被问到“你这个参数为什么设置成这个值”,很难自圆其说。
我的建议是只写两到三类优化:reduce 数的调整、内存上限的调整、局部 TopN 避免全排序。每一条都必须是实验记录表里出现过、能说出前后对比的。把第三个 Job 之前的数据量压缩了多少写成具体数字,评委就能直观看到这套设计的必要性。最后保存实验记录时,把每轮hadoop fs -cat的结果连同日志一并放进 docs 目录,这就是整套源码之外最扎实的文档说明。
本文还有配套的精品资源,点击获取