简介:这是一套基于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。取 N=100万、K=50,中间数据量是 625 亿条。这个量级用单机数据库做,磁盘 I/O 和内存都扛不住。
MapReduce 的优势在这里体现得很直接:Map 阶段把每条好友关系拆成以用户为 key 的多条记录,Shuffle 阶段按用户聚合,Reduce 阶段对同一个用户的好友列表做笛卡尔积,输出候选推荐对。整个过程是磁盘友好的,不依赖内存放全量数据。我实测过,在伪分布式环境下,100 万用户、5000 万关系对的数据,用 3 个 Reduce 任务跑完大约 12 分钟。这个速度对于离线推荐来说完全够用,因为推荐结果本来就是 T+1 更新的。
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 容器给 2048MB,Reduce 容器给 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,包管理方便。第一步装 JDK,Hadoop 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.xml:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/home/hadoop/hadoop/data/tmp</value> </property> </configuration>fs.defaultFS指定 HDFS 的访问地址,伪分布式下用 localhost。hadoop.tmp.dir是 Hadoop 运行时临时目录,默认在/tmp下,机器重启会丢失,必须改成持久化路径。这个目录要提前创建:mkdir -p /home/hadoop/hadoop/data/tmp。
接着改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/namenode</value> </property> <property> <name>dfs.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> <name>mapreduce.framework.name</name> <value>yarn</value> </property> <property> <name>mapreduce.application.classpath</name> <value>$HADOOP_HOME/share/hadoop/mapreduce/*:$HADOOP_HOME/share/hadoop/mapreduce/lib/*</value> </property> </configuration>yarn-site.xml配置 ResourceManager 和 NodeManager:
<configuration> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property> <property> <name>yarn.nodemanager.resource.memory-mb</name> <value>8192</value> </property> <property> <name>yarn.scheduler.maximum-allocation-mb</name> <value>4096</value> </property> </configuration>yarn.nodemanager.resource.memory-mb是 NodeManager 能支配的总内存,按机器实际内存的 70% 设置。yarn.scheduler.maximum-allocation-mb是单个容器能申请的最大内存,设成 4096 意味着一个 Reduce 任务最多能用 4G。这两个值要根据机器配置调整,设太小任务会卡在 ACCEPTED 状态,设太大可能把机器跑挂。
配置完成后,在hadoop-env.sh里显式指定JAVA_HOME:
echo '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输出两条:key=user1, value=user2和key=user2, value=user1。这样在 Shuffle 阶段,同一个用户的所有好友会聚合到一个 Reduce 任务里。
public class FriendRecommendMapper extends Mapper<LongWritable, 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 是用户 ID,value 是好友 ID。
4.2 Reduce 阶段做二度扩展与去重
Reduce 阶段拿到的是某个用户的所有好友列表。对这个列表做两两组合,就能得到所有二度好友候选对。比如 user1 的好友是 [user2, user3],那么组合出 (user2, user3) 这个候选对,表示 user2 和 user3 可以通过 user1 认识。
public class FriendRecommendReducer extends Reducer<Text, Text, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { // 收集当前用户的所有好友,用 Set 去重 Set<String> friends = new HashSet<>(); for (Text val : values) { friends.add(val.toString()); } // 好友数量少于 2 无法产生推荐对 if (friends.size() < 2) { return; } List<String> 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-1,j从 i+1 到 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 Mapper<LongWritable, 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 Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override protected 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); } }这个任务就是标准的 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 任务卡在 ACCEPTED:YARN 内存配置不足
现象:hadoop jar提交任务后一直卡在ACCEPTED状态,不进入RUNNING。
原因:yarn.nodemanager.resource.memory-mb设得太小,或者yarn.scheduler.maximum-allocation-mb小于 MapReduce 任务申请的内存。默认 Map 容器要 1024MB,Reduce 容器要 1024MB,如果 NodeManager 总内存只有 2048MB,同时跑一个 Map 和一个 Reduce 就满了,后续任务只能排队。
解决:把yarn.nodemanager.resource.memory-mb调到 8192 以上,yarn.scheduler.maximum-allocation-mb调到 4096。改完重启 YARN:stop-yarn.sh && start-yarn.sh。如果机器内存确实小,可以在提交任务时用-D mapreduce.map.memory.mb=512临时降低容器内存。
5.4 Reduce 阶段 OOM:好友列表过大导致堆溢出
现象:Reduce 任务跑到 66% 或 33% 时报java.lang.OutOfMemoryError: Java heap space。
原因:某个用户的好友数量特别多(比如大 V 用户有几千个好友),Reduce 阶段把这个列表全部加载到内存做两两组合,HashSet和ArrayList占用的堆内存超过了容器限制。
解决:调大 Reduce 容器的堆内存,在mapred-site.xml里加:
<property> <name>mapreduce.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字段。离线推荐结果是 T+1 更新的,如果用户当天已经加了好友,推荐列表里的某些候选可能已经失效。查询时过滤掉expire_time小于当前时间的记录,能减少无效推荐。这个字段我一般设成推荐生成时间加 7 天,一周内有效,过期自动清理。
这套方案我前后部署过三次,每次都会在环境配置上花掉一半时间。后来我养成了一个习惯:所有配置文件改完后先diff一下原始文件,确认只改了该改的地方。因为 Hadoop 的配置文件一旦多了一个空格或者少了一个闭合标签,启动时不会报错,但运行时会出各种玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取