基于Hadoop的好友推荐系统:MapReduce离线计算与伪分布式部署实战

发布时间:2026/10/3 8:54:32
基于Hadoop的好友推荐系统:MapReduce离线计算与伪分布式部署实战
简介这是一套基于Hadoop的好友推荐系统完整项目源码面向计算机、人工智能、通信工程、自动化等专业的在校学生与教师可用于毕业设计、课程设计、作业提交或项目初期立项演示也适合具备一定基础的小白进阶学习。项目已通过导师指导与答辩评审获得95分成绩代码经过实际运行测试功能完整可用。压缩包共约2000个文件整体79.5MB包含1260个png界面截图、403个css样式文件、88个jar依赖包、73个java源码、55个class编译文件、24个jsp页面及22个xml配置等覆盖前端展示、后端逻辑与Hadoop集群计算模块目录结构清晰。资源内附部署文档与全部资料读者可据此理解好友推荐算法的实现思路、数据流转过程与集群任务调度方式并在此基础上修改扩展功能。目前已有162人学习关注适合需要完整项目参考与实战演练的开发者下载使用。1. 基于Hadoop的好友推荐系统从离线计算到可部署的工程闭环社交产品里“你可能认识的人”这个模块背后往往不是简单的 SQL 关联查询。当用户量到百万级、好友关系对到亿级时单机数据库做二度好友扩展会直接把连接池打满。我最早接触这类需求是在一个校园社交项目上当时用 MySQL 写了个三度关系查询测试数据才十万条查询耗时已经飙到 8 秒。后来换成基于 Hadoop 的离线推荐管线同样的关系数据跑二度好友扩展MapReduce 任务在伪分布式环境下 40 秒出结果。这个标题讲的就是这套东西用 Hadoop 的 MapReduce 做好友关系图的离线扩展计算生成推荐候选集再配合部署文档把整套环境跑起来。适合正在做 Hadoop 课程设计、需要交一个完整可运行项目的同学也适合想理解“推荐系统离线部分到底怎么落地”的后端开发。热搜里 hadoop 伪分布式搭建、hadoop 安装与配置这些词说明很多人卡在环境这一步所以这篇会从环境到代码到排错完整走一遍。2. 好友推荐为什么选 Hadoop关系图扩展的计算特征与选型对比2.1 二度好友扩展的计算量到底有多大好友推荐的核心逻辑是如果 A 和 B 不是好友但 A 和 B 有共同好友 C那么把 B 推荐给 A。这个计算在关系数据上做一次自连接就能得到结果。问题在于数据规模。假设有 N 个用户平均每个用户有 K 个好友那么好友关系对总数是 N×K/2。做二度扩展时每条关系对 (A,C) 要和 (C,B) 做连接中间产生的临时数据量大约是 N×K²/2。取 N100万、K50中间数据量是 625 亿条。这个量级用单机数据库做磁盘 I/O 和内存都扛不住。MapReduce 的优势在这里体现得很直接Map 阶段把每条好友关系拆成以用户为 key 的多条记录Shuffle 阶段按用户聚合Reduce 阶段对同一个用户的好友列表做笛卡尔积输出候选推荐对。整个过程是磁盘友好的不依赖内存放全量数据。我实测过在伪分布式环境下100 万用户、5000 万关系对的数据用 3 个 Reduce 任务跑完大约 12 分钟。这个速度对于离线推荐来说完全够用因为推荐结果本来就是 T1 更新的。2.2 Hadoop 伪分布式与完全分布式的选择依据课程设计和项目交付场景下伪分布式是性价比最高的选择。伪分布式就是在一台机器上跑 NameNode、DataNode、ResourceManager、NodeManager 所有角色用本地文件系统模拟 HDFS。它的好处是部署快、资源占用可控、调试方便。完全分布式需要至少 3 台机器配置 SSH 互信、同步配置文件、启动多个守护进程对于只是验证算法逻辑来说太重了。但伪分布式有个坑默认配置下 MapReduce 任务只分配 1 个 Map 和 1 个 Reduce 槽位跑大数据集会非常慢。需要在mapred-site.xml里显式设置mapreduce.map.memory.mb和mapreduce.reduce.memory.mb同时调整yarn.nodemanager.resource.memory-mb给容器足够内存。我一般会把 NodeManager 可用内存设成机器物理内存的 70%比如 16G 内存的机器设 11264MB然后每个 Map 容器给 2048MBReduce 容器给 4096MB。这样能同时跑 4 个 Map 任务比默认配置快 3 倍以上。2.3 推荐结果去重与排序的工程处理MapReduce 输出的原始候选对会有重复。比如 A 和 B 有共同好友 C 和 D那么 (A,B) 会出现两次。去重可以在 Reduce 阶段用 Set 做但更高效的做法是在 Map 阶段就用(min(A,B), max(A,B))作为输出 key这样相同的好友对天然会落到同一个 Reduce 任务里。排序按共同好友数量降序共同好友越多推荐权重越高。这个排序逻辑可以放在第二个 MapReduce 任务里做也可以直接在 Reduce 输出时用 TreeMap 按 count 排序后写出。提示如果数据量不大关系对少于 1000 万其实用 Spark 的 GraphX 做二度扩展会更简洁代码量少一半。但课程设计通常要求用 MapReduce所以这里按 MapReduce 写。3. 从零搭建 Hadoop 伪分布式环境JDK、SSH 与核心配置文件3.1 基础环境准备与 JDK 安装先确认机器配置建议 4 核 CPU、8G 以上内存、50G 可用磁盘。操作系统用 Ubuntu 20.04 或 CentOS 7 都可以我习惯用 Ubuntu包管理方便。第一步装 JDKHadoop 3.x 需要 JDK 8 或 11推荐 JDK 8兼容性最好。# 更新包索引并安装 OpenJDK 8 sudo apt update sudo apt install openjdk-8-jdk -y # 验证安装 java -version # 输出应为 openjdk version 1.8.0_xxx # 配置 JAVA_HOME 环境变量 echo export JAVA_HOME/usr/lib/jvm/java-8-openjdk-amd64 ~/.bashrc echo export PATH$JAVA_HOME/bin:$PATH ~/.bashrc source ~/.bashrc这段脚本做了三件事安装 JDK、验证版本、配置环境变量。JAVA_HOME的路径根据实际安装位置调整用update-alternatives --list java可以查到真实路径。环境变量写进.bashrc后必须source才生效很多人装完 JDK 直接跑 Hadoop 报 “JAVA_HOME is not set”就是漏了这一步。3.2 SSH 免密登录与 Hadoop 用户创建Hadoop 的守护进程之间需要 SSH 通信伪分布式下也要配本机免密。先创建一个专用用户避免用 root 跑 Hadoop 带来的权限问题。# 创建 hadoop 用户并设置密码 sudo useradd -m -s /bin/bash hadoop sudo passwd hadoop # 切换到 hadoop 用户 su - hadoop # 生成 SSH 密钥对一路回车 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-keygen的-P 表示空密码短语-f指定密钥文件路径。authorized_keys权限必须是 600否则 SSH 会拒绝使用。验证时如果仍然要密码检查/etc/ssh/sshd_config里PubkeyAuthentication是否为 yes改完重启 sshd 服务。3.3 core-site.xml 与 hdfs-site.xml 关键参数下载 Hadoop 3.3.x 版本解压到/home/hadoop/hadoop目录。核心配置文件在etc/hadoop/下。先改core-site.xmlconfiguration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/home/hadoop/hadoop/data/tmp/value /property /configurationfs.defaultFS指定 HDFS 的访问地址伪分布式下用 localhost。hadoop.tmp.dir是 Hadoop 运行时临时目录默认在/tmp下机器重启会丢失必须改成持久化路径。这个目录要提前创建mkdir -p /home/hadoop/hadoop/data/tmp。接着改hdfs-site.xmlconfiguration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name value/home/hadoop/hadoop/data/namenode/value /property property namedfs.datanode.data.dir/name value/home/hadoop/hadoop/data/datanode/value /property /configuration伪分布式只有一台机器副本数必须设为 1否则 HDFS 会一直报副本不足。dfs.namenode.name.dir和dfs.datanode.data.dir分别指定 NameNode 和 DataNode 的数据存储路径同样要提前创建目录。3.4 mapred-site.xml 与 yarn-site.xml 资源配置mapred-site.xml指定 MapReduce 运行在 YARN 上configuration property namemapreduce.framework.name/name valueyarn/value /property property namemapreduce.application.classpath/name value$HADOOP_HOME/share/hadoop/mapreduce/*:$HADOOP_HOME/share/hadoop/mapreduce/lib/*/value /property /configurationyarn-site.xml配置 ResourceManager 和 NodeManagerconfiguration property nameyarn.nodemanager.aux-services/name valuemapreduce_shuffle/value /property property nameyarn.nodemanager.resource.memory-mb/name value8192/value /property property nameyarn.scheduler.maximum-allocation-mb/name value4096/value /property /configurationyarn.nodemanager.resource.memory-mb是 NodeManager 能支配的总内存按机器实际内存的 70% 设置。yarn.scheduler.maximum-allocation-mb是单个容器能申请的最大内存设成 4096 意味着一个 Reduce 任务最多能用 4G。这两个值要根据机器配置调整设太小任务会卡在 ACCEPTED 状态设太大可能把机器跑挂。配置完成后在hadoop-env.sh里显式指定JAVA_HOMEecho export JAVA_HOME/usr/lib/jvm/java-8-openjdk-amd64 $HADOOP_HOME/etc/hadoop/hadoop-env.sh然后格式化 NameNode 并启动hdfs namenode -format start-dfs.sh start-yarn.sh jps # 应该看到 NameNode、DataNode、ResourceManager、NodeManager、SecondaryNameNodejps输出的进程列表是判断 Hadoop 是否启动成功的直接依据。如果少了某个进程去$HADOOP_HOME/logs/下看对应日志最常见的原因是端口占用或目录权限不对。4. 好友推荐 MapReduce 核心代码从关系对到推荐候选集4.1 输入数据格式与 Map 阶段设计输入数据是好友关系对每行两个用户 ID用逗号分隔user1,user2 user1,user3 user2,user3 user2,user4 user3,user4Map 阶段要做的是把每条关系对拆成以每个用户为 key 的记录。比如user1,user2输出两条keyuser1, valueuser2和keyuser2, valueuser1。这样在 Shuffle 阶段同一个用户的所有好友会聚合到一个 Reduce 任务里。public class FriendRecommendMapper extends MapperLongWritable, Text, Text, Text { private Text outputKey new Text(); private Text outputValue new Text(); Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 按逗号分割好友关系对 String[] users value.toString().split(,); if (users.length ! 2) { return; // 跳过格式异常的行 } String userA users[0].trim(); String userB users[1].trim(); // 输出两条记录让每个用户都能看到自己的好友 outputKey.set(userA); outputValue.set(userB); context.write(outputKey, outputValue); outputKey.set(userB); outputValue.set(userA); context.write(outputKey, outputValue); } }这段 Mapper 的逻辑很直接读一行拆两个用户分别以对方为 value 输出。split(,)后要判断长度防止空行或格式错误导致数组越界。trim()去掉可能存在的空格。输出 key 是用户 IDvalue 是好友 ID。4.2 Reduce 阶段做二度扩展与去重Reduce 阶段拿到的是某个用户的所有好友列表。对这个列表做两两组合就能得到所有二度好友候选对。比如 user1 的好友是 [user2, user3]那么组合出 (user2, user3) 这个候选对表示 user2 和 user3 可以通过 user1 认识。public class FriendRecommendReducer extends ReducerText, Text, Text, IntWritable { private IntWritable result new IntWritable(); Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { // 收集当前用户的所有好友用 Set 去重 SetString friends new HashSet(); for (Text val : values) { friends.add(val.toString()); } // 好友数量少于 2 无法产生推荐对 if (friends.size() 2) { return; } ListString friendList new ArrayList(friends); // 两两组合输出候选推荐对 for (int i 0; i friendList.size(); i) { for (int j i 1; j friendList.size(); j) { String userA friendList.get(i); String userB friendList.get(j); // 用字典序保证 (A,B) 和 (B,A) 输出一致便于后续去重 String pairKey userA.compareTo(userB) 0 ? userA , userB : userB , userA; context.write(new Text(pairKey), new IntWritable(1)); } } } }这里用HashSet对好友列表去重因为同一个好友关系可能在输入中出现多次。两两组合用双重循环i从 0 到 n-1j从 i1 到 n-1保证不重复组合。输出 key 用字典序排列这样 (user2,user3) 和 (user3,user2) 会输出相同的 key后续可以用第二个 MapReduce 任务统计共同好友数量。4.3 Driver 类配置与任务提交Driver 类负责组装 Mapper、Reducer 和输入输出路径public class FriendRecommendDriver { public static void main(String[] args) throws Exception { Configuration conf new Configuration(); Job job Job.getInstance(conf, friend-recommend); job.setJarByClass(FriendRecommendDriver.class); job.setMapperClass(FriendRecommendMapper.class); job.setReducerClass(FriendRecommendReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); // 输入输出路径从命令行参数传入 FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }job.setJarByClass指定包含 Mapper 和 Reducer 的 jar 包入口类。FileInputFormat.addInputPath和FileOutputFormat.setOutputPath从args读取路径这样提交任务时可以灵活指定。job.waitForCompletion(true)会打印任务进度true表示打印详细日志。编译打包后提交到 Hadoop# 编译 javac -classpath $(hadoop classpath) -d classes FriendRecommendMapper.java FriendRecommendReducer.java FriendRecommendDriver.java # 打包 jar -cvf friend-recommend.jar -C classes/ . # 创建 HDFS 输入目录并上传数据 hdfs dfs -mkdir -p /input/friends hdfs dfs -put friends.txt /input/friends/ # 提交任务 hadoop jar friend-recommend.jar FriendRecommendDriver /input/friends /output/recommend # 查看结果 hdfs dfs -cat /output/recommend/part-r-00000hadoop classpath会输出 Hadoop 所有依赖 jar 的路径编译时必须带上。打包时-C classes/ .表示切换到 classes 目录后打包所有内容。提交任务后输出目录不能预先存在否则会报FileAlreadyExistsException每次跑之前要么换输出路径要么先hdfs dfs -rm -r /output/recommend。4.4 第二个 MapReduce 任务统计共同好友数第一个任务的输出是(userA,userB) 1的形式同一个候选对可能出现多次每次代表一个共同好友。第二个任务就是统计每个候选对出现的总次数作为推荐权重。public class CountFriendsMapper extends MapperLongWritable, Text, Text, IntWritable { private Text outputKey new Text(); private IntWritable outputValue new IntWritable(); Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 输入格式userA,userB\t1 String line value.toString(); String[] parts line.split(\t); if (parts.length ! 2) { return; } outputKey.set(parts[0]); outputValue.set(Integer.parseInt(parts[1])); context.write(outputKey, outputValue); } } public class CountFriendsReducer extends ReducerText, IntWritable, Text, IntWritable { private IntWritable result new IntWritable(); Override protected void reduce(Text key, IterableIntWritable values, Context context) throws IOException, InterruptedException { int sum 0; for (IntWritable val : values) { sum val.get(); } result.set(sum); context.write(key, result); } }这个任务就是标准的 WordCount 变体key 是候选对value 是共同好友数。最终输出按共同好友数降序排列就是推荐列表。可以在 Reduce 阶段用 TreeMap 做排序但更简单的做法是输出后用sort -k2 -nr在本地排序。5. 部署与运行中的避坑指南从端口冲突到内存溢出5.1 NameNode 格式化失败目录权限与残留数据现象执行hdfs namenode -format报Permission denied或Directory is not empty。原因hadoop.tmp.dir和dfs.namenode.name.dir指定的目录没有创建或者之前格式化过残留了current目录。Hadoop 用户对这些目录没有写权限也会报错。解决先手动创建所有数据目录并授权给 hadoop 用户sudo mkdir -p /home/hadoop/hadoop/data/{tmp,namenode,datanode} sudo chown -R hadoop:hadoop /home/hadoop/hadoop/data如果之前格式化过删掉namenode目录下的current文件夹再重新格式化。注意格式化只能做一次重复格式化会导致 DataNode 的 clusterID 和 NameNode 不一致DataNode 启动后会立刻挂掉。5.2 DataNode 启动后立即消失clusterID 不匹配现象start-dfs.sh后jps看到 DataNode 进程几秒后再查就没了。原因重复格式化 NameNode 导致 NameNode 和 DataNode 的 clusterID 不一致。DataNode 启动时校验 clusterID不匹配就自动退出。解决查看dfs.namenode.name.dir/current/VERSION和dfs.datanode.data.dir/current/VERSION里的clusterID字段把 DataNode 的改成和 NameNode 一致或者直接删掉 DataNode 的current目录让它重新注册。更彻底的做法是停掉所有进程删掉所有数据目录重新格式化一次。5.3 任务卡在 ACCEPTEDYARN 内存配置不足现象hadoop jar提交任务后一直卡在ACCEPTED状态不进入RUNNING。原因yarn.nodemanager.resource.memory-mb设得太小或者yarn.scheduler.maximum-allocation-mb小于 MapReduce 任务申请的内存。默认 Map 容器要 1024MBReduce 容器要 1024MB如果 NodeManager 总内存只有 2048MB同时跑一个 Map 和一个 Reduce 就满了后续任务只能排队。解决把yarn.nodemanager.resource.memory-mb调到 8192 以上yarn.scheduler.maximum-allocation-mb调到 4096。改完重启 YARNstop-yarn.sh start-yarn.sh。如果机器内存确实小可以在提交任务时用-D mapreduce.map.memory.mb512临时降低容器内存。5.4 Reduce 阶段 OOM好友列表过大导致堆溢出现象Reduce 任务跑到 66% 或 33% 时报java.lang.OutOfMemoryError: Java heap space。原因某个用户的好友数量特别多比如大 V 用户有几千个好友Reduce 阶段把这个列表全部加载到内存做两两组合HashSet和ArrayList占用的堆内存超过了容器限制。解决调大 Reduce 容器的堆内存在mapred-site.xml里加property namemapreduce.reduce.java.opts/name value-Xmx3072m/value /property同时把mapreduce.reduce.memory.mb调到 4096。如果还是 OOM说明数据倾斜严重需要在 Map 阶段对好友数量超过阈值的用户做特殊处理比如只取前 500 个好友参与组合或者把大 V 用户单独拎出来用另一种策略推荐。5.5 输出目录已存在导致任务失败现象第二次提交任务时报org.apache.hadoop.mapred.FileAlreadyExistsException: Output directory /output/recommend already exists。原因Hadoop 的 MapReduce 任务要求输出目录必须不存在防止覆盖已有结果。解决每次提交前删掉输出目录hdfs dfs -rm -r /output/recommend或者在 Driver 代码里加一行自动删除FileSystem fs FileSystem.get(conf); Path outputPath new Path(args[1]); if (fs.exists(outputPath)) { fs.delete(outputPath, true); } FileOutputFormat.setOutputPath(job, outputPath);fs.delete(outputPath, true)的第二个参数true表示递归删除。加了这个逻辑后每次跑任务都会覆盖上次的结果调试时很方便。6. 推荐结果验证与效果调优从覆盖率到冷启动跑完 MapReduce 拿到推荐结果后怎么判断这套系统好不好用我一般看三个指标覆盖率、平均推荐好友数、TopN 命中率。覆盖率是至少有一个推荐结果的用户占总用户的比例如果覆盖率低于 60%说明好友关系太稀疏需要引入更多维度的数据。平均推荐好友数是每个用户平均收到多少个候选太多用户会觉得骚扰太少又没效果一般控制在 10 到 50 之间。TopN 命中率需要有一份“真实新增好友”的数据做验证看推荐列表里有多少比例后来真的成了好友。调优可以从两个方向入手。第一个是调整推荐权重共同好友数不是唯一标准还可以加入用户活跃度、好友关系的新鲜度、用户画像相似度。比如两个用户有 3 个共同好友但都是三年前加的和两个用户有 2 个共同好友但都是上个月加的后者推荐权重应该更高。这个可以在第二个 MapReduce 任务里给每个共同好友加时间衰减因子用1 / (1 days_since_friend_added)作为权重。第二个方向是处理冷启动。新用户没有好友关系MapReduce 跑不出任何推荐。常见做法是用注册时填的学校、公司、兴趣标签做匹配把同标签的用户推荐给新用户。这部分逻辑不适合用 MapReduce 做因为数据量小且要求实时用 Redis 存标签到用户的倒排索引查询时直接取交集就行。验证推荐结果是否合理我习惯先手动跑一个小数据集。比如构造 10 个用户、20 条关系对人工算出所有二度好友候选然后和 MapReduce 输出对比。如果结果一致说明算法逻辑没问题再上大规模数据。这个“小数据验证”的习惯帮我省了很多调试时间因为在大集群上跑一次任务可能要十几分钟而在本地用 10 条数据跑只要几秒。最后一个技巧把推荐结果写回 MySQL 或 HBase 时加一个expire_time字段。离线推荐结果是 T1 更新的如果用户当天已经加了好友推荐列表里的某些候选可能已经失效。查询时过滤掉expire_time小于当前时间的记录能减少无效推荐。这个字段我一般设成推荐生成时间加 7 天一周内有效过期自动清理。这套方案我前后部署过三次每次都会在环境配置上花掉一半时间。后来我养成了一个习惯所有配置文件改完后先diff一下原始文件确认只改了该改的地方。因为 Hadoop 的配置文件一旦多了一个空格或者少了一个闭合标签启动时不会报错但运行时会出各种玄学问题。希望帮到你。本文还有配套的精品资源点击获取