ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Hadoop的好友推荐系统:MapReduce协同过滤实战指南

基于Hadoop的好友推荐系统:MapReduce协同过滤实战指南 简介一套面向高校计算机、通信、人工智能等相关专业的好友推荐系统毕业设计项目基于Hadoop平台并以Java语言实现完整覆盖从用户数据预处理、好友相似度计算到推荐结果生成的核心流程可作为毕业设计完整方案亦适合期末课程设计、课程大作业参考。项目答辩评审分达98分代码经过调试测试确保可运行结构规范、注释清晰既适合入门者理解分布式计算在推荐场景中的实际应用也便于基础较好的读者二次修改与功能扩展。资源包共2000个文件压缩包容量约79.46MB内容涵盖源码、配置、依赖包、页面素材与说明文档其中包含73个Java源文件、55个class编译文件、89个jar依赖包以及JSP、XML、CSS、JS等配套资源另有1260张PNG截图辅助核对各阶段运行效果目录组织较为清晰。核心代码覆盖数据聚类、距离计算、初始分簇等典型MapReduce任务并配有Web展示模块整体思路便于迁移复用。目前已有200人学习下载具有较高的学习参考与复用价值。1. 基于Hadoop的好友推荐系统毕业设计怎么把“猜你喜欢”做成能跑的工程每年毕业季都会有一批人选“基于Hadoop的好友推荐系统”这个题目但真正能把整套东西从PPT落到可运行代码上的其实不多。原因不是算法难而是大多数人卡在了“Hadoop环境还没跑通代码根本提交不上去”这一步。这个题目的本质是用MapReduce模拟协同过滤的思路在离线批处理的框架下完成“用户—好友—兴趣”的相似度计算最终给每个用户产出一份好友推荐列表。它能锻炼分布式计算的基本功也适合做毕设展示因为效果直观、数据自洽、答辩时讲得清楚。适合谁呢两类人。一类是已经学过Java SE、懂一点Linux命令、但没实际碰过分布式系统的在校生另一类是工作后想补一下Hadoop生态基础、手里正好需要一个练手项目的初级工程师。这篇笔记不打算复述教材上的概念而是直接按“环境搭建 → 数据设计 → 代码实现 → 调优排错 → 验收答辩”的顺序把一套能复现、能讲明白的完整路径给你铺开。中间我会尽量写清楚为什么这么做、参数怎么调、以及我踩过的坑。2. 先搞懂推荐逻辑与Hadoop选型为什么用协同过滤而不是图计算2.1 好友推荐的本质是“相似度计算”不是“关系挖掘”你要做的不是去爬社交关系链而是根据用户已有的行为数据比如共同好友、共同关注的兴趣标签、共同的浏览记录去计算用户之间的相似程度。毕设最常用的做法是基于“共同好友数”的Jaccard相似度两个用户共同好友越多越可能成为推荐好友。这里有个容易跑偏的点很多同学会想用图数据库或图计算引擎去解这个问题比如Neo4j或者Spark GraphX。但在Hadoop生态里答案不是图计算而是“由Mapper和Reducer组成的分布式相似度计算”。因为你的数据是静态的用户好友关系快照不需要实时遍历多层关系用MapReduce把“两两用户的共同好友集合”算出来就足够了。选择Hadoop而不是Spark不是因为Hadoop更适合推荐而是因为这个题目的教学目标是让你理解“分而治之”的批处理思想同时能展示HDFS、YARN、MapReduce这三大组件的综合使用。2.2 数据模型设计一张好友关系表如何变成推荐评分我们要准备的核心输入数据是好友关系表每一行表示一个关注关系。这里我采用常见的“uid, follow_uid”格式逗号分隔UTF-8编码。10001,10002 10001,10003 10001,10004 10002,10001 10002,10003 10003,10001 10003,10002 10003,10004 10004,10001 10004,10003 10005,10006 10005,10007这份数据的关键在于“关系和名人效应”。名人大V会被很多人关注如果直接用共同关注数做相似度结果会被大V污染。因此在设计Mapper输出时我会过滤掉关注数超过阈值的用户或者对这类用户做降权。毕设阶段用阈值过滤就够了不需要引入TF-IDF那种复杂机制但你要能在文档里说清楚“为什么过滤”——这是答辩时很加分的细节。数据量建议生成1000个用户、每人520个好友关系大约1万条边。这个规模用伪分布式跑起来大概几分钟既不会等太久也能体现出MapReduce的流程。生成脚本后面会写给你。3. Hadoop环境搭建与项目骨架从零开始把伪分布式跑起来3.1 伪分布式安装的两条路线与推荐选择做毕设不需要搭集群一台8G内存的笔记本跑伪分布式就够了。伪分布式意味着所有Hadoop守护进程都跑在本地HDFS的NameNode和DataNode也是在同一台机器上模拟出来的。安装方式主要有两条路线一条是直接下载Hadoop tar包解压手动配置core-site.xml、hdfs-site.xml、mapred-site.xml、yarn-site.xml这四个文件。这条路适合你想深入理解每个配置项含义的情况也是面试时容易被问到的点。另一条是用Docker镜像一键拉起。如果你本机没有Linux环境或者不想被一堆环境变量折磨直接用docker-compose跑一个Hadoop单节点镜像会更省时间。但我要提醒一句毕设答辩时老师很可能问“你配了哪些参数”如果你是用Docker跑的就很难答上来。所以我的建议是开发调试用Docker最后做演示和文档时用本地伪分布式。两条路都走一遍花不了多少时间。伪分布式安装的核心是SSH免密登录。很多教程会跳过这个但你如果不配置启动HDFS时会反复要求输密码极容易中途放弃。命令如下# 生成密钥对一路回车即可 ssh-keygen -t rsa -P -f ~/.ssh/id_rsa # 将公钥加入授权列表 cat ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys # 验证免密登录 ssh localhost这段命令干了三件事生成RSA密钥对、把公钥追加到authorized_keys、调整权限确保SSH能识别。-P 表示空密码-f指定输出路径。如果你不指定这两个参数会卡在交互式提问上。完成之后执行ssh localhost应该直接进入 shell不再提示密码。接下来是Hadoop解压和Java环境准备。JDK版本和Hadoop版本有兼容关系这是新手最容易翻车的地方。# 以Hadoop 3.3.x为例要求JDK 8或JDK 11 tar -zxvf hadoop-3.3.6.tar.gz -C /usr/local/ mv /usr/local/hadoop-3.3.6 /usr/local/hadoop # 配置环境变量写入 ~/.bashrc export JAVA_HOME/usr/lib/jvm/java-8-openjdk-amd64 export HADOOP_HOME/usr/local/hadoop export PATH$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin export HADOOP_CONF_DIR$HADOOP_HOME/etc/hadoop # 让环境变量生效 source ~/.bashrc # 验证安装 hadoop version这里有一个关键点Hadoop的bin目录下有很多脚本会直接调用$JAVA_HOME/bin/java如果JAVA_HOME没配对执行任何命令都会报“JAVA_HOME is not set”。另外Hadoop 3.x版本要求Java 8或11用Java 17会出现兼容性问题编译时各种奇怪的类找不到。装好之后别急先用hadoop version验证再往下走。3.2 四个核心配置文件的最小参数组配置伪分布式不需要把每个参数都调一遍我只列出必要的几项。首先是core-site.xml它决定了NameNode的地址和临时目录configuration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/usr/local/hadoop/tmp/value /property /configurationfs.defaultFS是HDFS的入口地址hadoop.tmp.dir是Hadoop存储元数据和数据块的根目录。这个目录一定要手动指定否则默认在/tmp下系统一重启数据就没了你的HDFS会变成一个空壳所有上传的文件都要重新来。然后是hdfs-site.xml伪分布式模式下需要指定副本数为1否则默认的3副本会在只有一台DataNode时报告警并卡在安全模式configuration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name valuefile:/usr/local/hadoop/tmp/dfs/name/value /property property namedfs.datanode.data.dir/name valuefile:/usr/local/hadoop/tmp/dfs/data/value /property /configurationdfs.replication1是伪分布式的标准配置。如果你忘了改上传文件时DataNode会一直尝试复制第二份和第三份副本虽然最后也能成功但启动过程会多出很多等待时间而且日志里全是警告。最后是mapred-site.xml和yarn-site.xml分别控制MapReduce的计算框架和资源调度configuration property namemapreduce.framework.name/name valueyarn/value /property /configurationconfiguration property nameyarn.nodemanager.aux-services/name valuemapreduce_shuffle/value /property property nameyarn.nodemanager.vmem-check-enabled/name valuefalse/value /property /configurationvmem-check-enabled设为false很重要。伪分布式下物理内存本身就紧张YARN默认会检查虚拟内存超限稍微跑个大一点的job就直接把Container杀掉报错信息是“Container killed on request. Exit code is 143”。我见过太多人被这个坑折磨一下午。配置写完后启动HDFS和YARN# 格式化NameNode只在第一次启动前执行 hdfs namenode -format # 启动HDFS start-dfs.sh # 启动YARN start-yarn.sh # 检查进程是否都在 jpsjps输出里应该能看到NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager这五个进程。少任何一个都说明启动失败最常见的原因是格式化之后又改动过dfs.namenode.name.dir路径导致NameNode和DataNode的clusterID不一致。如果出现这个问题解决方式是把tmp目录下所有文件删掉重新格式化不要试图手动改clusterID。启动完成后验证HDFS可用性hdfs dfs -mkdir -p /user/hadoop/input hdfs dfs -put friends.txt /user/hadoop/input/ hdfs dfs -ls /user/hadoop/input/这三行命令分别完成创建目录、上传数据、查看文件。走到这一步你的环境基础就算是踏实了。4. 用MapReduce实现好友推荐核心代码与参数调优4.1 Map阶段把好友关系拆成“共同好友”的候选对整个推荐算法的核心思路是如果A和B都是C的好友那么C的“共同好友列表”里就有A和B这一对。所以Mapper的任务是把每一条“粉丝—关注对象”的关系转换成“关注对象的粉丝之间的两两组合”。这个思路我再用一句话说明输入是“C关注了A”和“C关注了B”输出是“A和B可能互相认识推荐理由你们都是C的好友”。用代码实现就是这样public class RecommendMapper extends MapperLongWritable, Text, Text, Text { Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 输入示例10001,10002 String[] tokens value.toString().split(,); String follower tokens[0].trim(); String followee tokens[1].trim(); // 只输出“关注了同一个大V”的用户对 // key 为 followeevalue 为 follower context.write(new Text(followee), new Text(follower)); } }这段Mapper的逻辑很简单把10001,10002变成key10002, value10001表达的语义是“10002有一个粉丝叫10001”。但这里有个隐患如果只是这样输出Reducer拿到的只是“同一大V的所有粉丝列表”还需要在Reducer里做两两组合。更好的做法是在Mapper阶段就完成组合让Reducer只管计数。所以我一般会在Mapper里加一个缓冲结构把一个followee下的所有follower先收集起来然后做笛卡尔组合输出。代码如下public class RecommendMapper extends MapperLongWritable, Text, Text, Text { private MapString, ListString followeeMap new HashMap(); Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] tokens value.toString().split(,); String follower tokens[0].trim(); String followee tokens[1].trim(); followeeMap.computeIfAbsent(followee, k - new ArrayList()).add(follower); } Override protected void cleanup(Context context) throws IOException, InterruptedException { // 对每个大V的粉丝列表做两两组合 for (Map.EntryString, ListString entry : followeeMap.entrySet()) { ListString followers entry.getValue(); for (int i 0; i followers.size(); i) { for (int j i 1; j followers.size(); j) { String userA followers.get(i); String userB followers.get(j); // 用排序保证输出的一致性和后续合并 if (userA.compareTo(userB) 0) { context.write(new Text(userA , userB), new Text(1)); } else { context.write(new Text(userB , userA), new Text(1)); } } } } } }这里有几个细节值得写进你的课程设计文档里第一cleanup方法在Map阶段全部结束后执行一次适合做这种全局组合逻辑第二排序是为了让(A,B)和(B,A)在Reducer端能归到同一个key下否则相似度会被重复计算第三这个实现方式在极端数据下会有内存压力因为每个Mapper的followeeMap会把所有关系都记住但毕设数据量不大可以接受。4.2 Reduce阶段计算Jaccard相似度并输出TopN推荐Reducer接收到的输入是key用户A,用户Bvalue一堆1。你可能会问value里全是1怎么算Jaccard相似度别急这里有个信息缺口。Jaccard相似度的公式是|A∩B| / |A∪B|分子是共同好友数分母是两人的好友总数减去共同好友数。但目前的map输出只给了分子。要补上分母你需要另一个数据源每个用户的好友总数。常见做法是准备两份数据一份是friends_count.txt格式为uid,count表示每个用户关注了多少人另一份就是好友关系表。在Reducer阶段用DistributedCache或者直接在setup阶段读取这份计数文件。我这里用一个更符合Hadoop习惯的方式在Reducer的setup里加载一份预计算的用户度数文件。public class RecommendReducer extends ReducerText, Text, Text, NullWritable { private MapString, Integer userDegree new HashMap(); Override protected void setup(Context context) throws IOException, InterruptedException { // 从HDFS读取 /user/hadoop/input/user_degree.txt Path degreePath new Path(/user/hadoop/input/user_degree.txt); FileSystem fs FileSystem.get(context.getConfiguration()); BufferedReader br new BufferedReader(new InputStreamReader(fs.open(degreePath))); String line; while ((line br.readLine()) ! null) { String[] tokens line.split(,); userDegree.put(tokens[0].trim(), Integer.parseInt(tokens[1].trim())); } br.close(); } Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { String[] users key.toString().split(,); String userA users[0]; String userB users[1]; int common 0; for (Text val : values) { common Integer.parseInt(val.toString()); } int degreeA userDegree.getOrDefault(userA, 0); int degreeB userDegree.getOrDefault(userB, 0); int union degreeA degreeB - common; double similarity union 0 ? 0.0 : (double) common / union; // 输出用户A,用户B,相似度 context.write(new Text(userA , userB , similarity), NullWritable.get()); } }这段代码的逻辑分三步第一步在setup阶段把每个用户的好友总数加载到内存第二步计算共同好友数对value求和第三步套公式算Jaccard相似度。输出格式是A,B,0.35后面还需要一个排序Job把每个用户的推荐结果按相似度降序排列截取Top10。这里要特别说明DistributedCache和直接读HDFS的差别。DistributedCache是Hadoop老版本的做法在新版本里已经被Job.addCacheFile()替代了但原理一样。我选择在setup里直接读HDFS代码直观也方便你在答辩时解释。需要记住的是setup阶段读取的文件路径必须在启动Job时通过configuration传进来不要在代码里写死绝对路径否则换个环境就崩了。4.3 主类Job配置Mapper输出格式与Reducer数量写完两个核心类后主类负责把整个Job串起来。这里有一个关键参数是Reducer的数量。public class FriendRecommendJob { public static void main(String[] args) throws Exception { Configuration conf new Configuration(); Job job Job.getInstance(conf, friend recommend); job.setJarByClass(FriendRecommendJob.class); job.setMapperClass(RecommendMapper.class); job.setReducerClass(RecommendReducer.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(Text.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(NullWritable.class); job.setNumReduceTasks(2); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }setNumReduceTasks的值决定了最后的输出文件个数。伪分布式环境下建议不要超过2否则每个Reducer拿到的数据量太小体现不出并行优势反而多了很多启动开销。如果你想让每个用户的所有推荐结果都在同一个文件里方便后续查看那就设置成1。如果你想让整个流程更像“分布式处理”就设置成2或者4但你要能解释数据是怎么被分区的——默认按key的hash值分区。还差一个排序环节。MapReduce自带的shuffle过程只能保证相同key的值在一起不能保证value的降序。所以你需要第二个Job它把Reducer输出作为输入然后以用户为key、相似度为value在Reducer内部做排序。这里有个技巧把相似度转成字符串时要补零到固定长度否则按字典序排序会出现0.35大于0.8的错误。// 格式化相似度保证字典序等于数值序 String simStr String.format(%.6f, similarity); // 输出 keyuserA, valueuserB:simStr这个格式化是排序的成败关键。0.800000字典序大于0.350000看起来没问题但0.1的字典序会大于0.09因为你没有补零。所以格式化成固定6位小数养成了这个习惯以后做其他排序类MapReduce也不会翻车。4.4 生成测试数据用Java或Python脚本模拟社交关系写代码前先准备好输入数据。我给一段Python脚本生成1000个用户和大约1万条关注关系。注意要保证关系分布有一定倾斜度模拟真实场景中的大V效应。import random random.seed(42) users list(range(10001, 11001)) k 0.15 # 15%的用户会成为“大V”被大量关注 relations set() for u in users: # 每个人关注5~20个其他用户 follow_count random.randint(5, 20) candidates [x for x in users if x ! u] follows random.sample(candidates, min(follow_count, len(candidates))) for f in follows: relations.add((u, f)) with open(friends.txt, w) as f: for u, v in relations: f.write(f{u},{v}\n) # 计算每个用户的关注数出度 degree {} for u, v in relations: degree[u] degree.get(u, 0) 1 with open(user_degree.txt, w) as f: for u, d in degree.items(): f.write(f{u},{d}\n)这段脚本里的random.seed(42)是复现的保障你答辩时重新跑一遍生成的数据会和之前完全一致。relations用set存储避免了重复边。user_degree.txt里统计的是“关注数”而不是“粉丝数”这在Jaccard公式里对应分母。如果你把分母概念搞反了推荐结果就会很离谱——A关注了100个人B只关注了2个人但他们共同关注了2个人Jaccard相似度是2/100≈0.02是合理的如果分母算成粉丝数这个值就可能被推高。5. 项目排错与参数避坑从环境到代码的5个真实踩坑记录5.1 DataNode起不来日志报Incompatible clusterIDs现象执行start-dfs.sh后jps看不到DataNode进程日志文件/usr/local/hadoop/logs/hadoop-hadoop-datanode-*.log里报错Incompatible clusterIDs。原因我格式化NameNode后修改了hadoop.tmp.dir的路径导致NameNode的clusterID是新生成的而DataNode的clusterID还是旧数据目录里的老ID。两者对不上DataNode拒绝启动。解决停掉所有Hadoop进程删除/usr/local/hadoop/tmp目录下的全部内容然后重新执行hdfs namenode -format再启动。警告一句这个操作会清空HDFS上的所有文件开发阶段无所谓但如果你HDFS上已经存了重要数据记得先备份。5.2 Container被kill日志提示vmem超限现象提交MapReduce Job后运行到一半报错Container killed on request. Exit code is 143打开yarn的日志能看到Physical memory is running beyond virtual memory limit。原因伪分布式机器的物理内存可能只有8G而YARN默认的虚拟内存检查比例是2.1倍Java进程在加载大量数据时很容易触发这个限制。解决在yarn-site.xml里把yarn.nodemanager.vmem-check-enabled设为false。这个方法不优雅但对单机学习环境是最省事的。如果你不想彻底关闭可以同时调大yarn.nodemanager.vmem-pmem-ratio到4也能缓解。真正生产环境不建议关但你做毕设不用背这个包袱。5.3 Mapper输出乱码中文注释变成问号现象在Eclipse或IDEA里跑代码没报错但把输出文件下载下来中文字段全部是?。原因MapReduce的输出默认使用UTF-8编码但Linux终端的locale可能不是UTF-8或者你代码里读取输入文件时没有指定编码用系统默认编码读了一个UTF-8文件。解决写代码时不要依赖默认编码。在读取输入文件的代码里显式声明new BufferedReader(new InputStreamReader(fs.open(path), UTF-8))。写输出时同理在Text对象里写入的字符串本身是Java的Unicode不会乱码乱码通常发生在FileOutputFormat写出时用了错误编码。如果你控制不了编码最保险的做法是输入数据全用纯数字和英文逗号不要掺中文字段。5.4 Reducer输出为空shuffle过程出问题现象Job显示成功但输出目录下的part-r-00000是空的。打开日志Reducer的input records是0。原因Mapper的输出key或value类型与Reducer的输入类型不匹配。比如Mapper输出的是Text,Text但Job里忘了setMapOutputKeyClass和setMapOutputValueClass默认会按LongWritable,Text去解析shuffle阶段全乱了。解决写Job类时在main方法里显式设置这对配置。我数不清自己犯过多少次这个错了每次都要花半小时查这个问题。现在习惯是在写完Job类后从头到尾对照四个set检查一遍MapOutputKeyClass、MapOutputValueClass、OutputKeyClass、OutputValueClass各是什么类型必须对应清楚。5.5 本地跑通但打包后提交运行报ClassNotFoundException现象在IDE里右键运行一切正常但用hadoop jar提交时报ClassNotFoundException。原因IDE运行时会把依赖jar包自动加到classpath但打包时没带上依赖。这个项目本身只用Hadoop内置库按理说不会缺类但你如果引了额外工具包比如commons-lang3就需要做fat jar。解决用Maven做mvn package时在pom.xml里配置maven-shade-plugin把依赖打进去。不要依赖IDE自带的Artifact打包那个经常只打自己的class不带第三方jar。另外提交命令用标准形式hadoop jar friend-recommend-1.0.jar com.example.FriendRecommendJob /user/hadoop/input/friends.txt /user/hadoop/output/result如果你的代码里用了Job.getInstance而不是new Job就不需要在main里调job.setJarByClass了但保险起见一定要写否则在集群上会被识别为客户端模式而找不到外部依赖。6. 进阶验证与答辩准备用真实小数据手工推导一遍推荐结果到了这一步你的推荐系统已经能跑通了。但离“答辩不翻车”还差一件事你能不能用一份极小的数据手工推算出推荐结果然后和程序跑出来的结果对比。这是我认为整个毕设最值得花时间做的验证。我一般取3个用户A关注了B和CB关注了A和CC关注了A和B和D。按照Jaccard公式A和B的共同好友是C共同好友数为1A的好友数是2B的好友数是2并集是3AB一共关注了A、B、C三个不同对象所以相似度是1/3。程序跑出来也应该是这个数。然后我会写一段简单的Shell命令去检查输出hdfs dfs -cat /user/hadoop/output/result/part-r-00000 | grep ^10001,10002如果输出10001,10002,0.333333说明核心计算逻辑是对的。这个验证过程不会花很长时间但能让你在答辩现场非常自信地说出“这条推荐结果是我手算过的”。最后还有一个细节是“好友推荐”和“你可能认识的人”的差异。很多同学做完相似度计算就停了没有把“已经是好友”的关系剔除掉。如果你不剔除推荐列表里会出现用户已经关注的人这会让答辩老师立刻察觉到你的推荐逻辑少了过滤环节。解决方式是在Reducer输出前判断一下这两个用户是否已经是好友关系判断依据可以从user_degree.txt之外的另一个文件或者在第一个Job的输出里忽略已存在的边。加一个判断条件的事但效果差别非常大。这套项目做到这里其实已经超出了大多数毕设的完成度。我个人的经验是每次做这种Hadoop项目最难的不是Hadoop本身而是“你怎么证明你做的结果是对的”。手工验证是成本最低但最有说服力的做法也是我一直保留的习惯。希望这篇笔记帮你把这个毕业设计真正做成一个能跑、能讲、能经得起追问的工程也希望你在过程中慢慢体会到离线批处理的思维方式它对你看待后续所有大数据问题都会有帮助。本文还有配套的精品资源点击获取
返回列表