简介:基于Hadoop实现的好友推荐系统毕业设计资源,包含可调试运行的Java源码和完整文档说明。项目围绕Hadoop平台设计好友推荐核心逻辑,覆盖数据读取、距离计算、聚类分析、推荐生成等环节,并包含距离计算、聚类、画图等模块,适合计算机、通信、人工智能、自动化等专业学生用于毕业设计、课程大作业或期末项目,也便于初学者学习进阶。压缩包共2000个文件,大小约79.46MB,主要文件类型包括Java源码、class字节码、JSP页面、CSS样式、JS脚本、XML配置、JAR依赖库以及PNG图片等;其中Java/class构成核心算法,JSP/CSS/JS搭建Web展示层,XML/properties保存配置,jar为第三方依赖,目录结构清晰,便于部署查看。已有200人学习下载。该项目经调试测试,答辩评审分达98分,具备较高借鉴价值;基础扎实者可在现有框架上修改扩展,实现不同推荐功能。
1. 基于Hadoop的好友推荐系统,为什么拿它做毕业设计最稳
做毕业设计最怕的不是没思路,而是思路大到一个人写不完。基于Hadoop实现的好友推荐系统,恰好是那种既有算法深度、又有工程落地感的题:核心算法是共同好友统计,数据形态是用户关系对,计算框架是Hadoop MapReduce,最终产物是一个能运行、能验证、能写进文档的完整系统。按最常见的毕业设计形态,它是一份 Java 源码加一份完整的文档说明,从需求分析写到测试报告,正好覆盖答辩要的全部过程性材料。适合三类人:选了社交推荐方向、需要 Hadoop 课程设计或 Java 课程设计案例源码、以及想用最短时间把分布式计算真正跑通的在校生。先说结论:用两轮 MapReduce 把推荐逻辑切出来,再加一层打分与 TopN 过滤,是这条路线上性价比最高、也最容易被老师认可的做法。
2. 把好友推荐拆成可计算的 MapReduce 流程:两轮作业与数据模型
2.1 共同好友推荐的本质:把社交关系变成可统计的组合
推荐系统的经典做法有基于物品、基于用户、基于内容三类,但毕业设计里绝大多数“好友推荐”题,底层都是同一个直觉:你和某个人有越多的共同好友,你们越可能本来就应该认识。这个逻辑放进 Hadoop 里非常顺,因为它本质上是“统计”而不是“预测”:先找共同好友,再统计共同好友个数,最后排序取 TopN。这也是为什么 Hadoop 课程设计、hadoop 面试题都喜欢拿它当例子——计算过程能被拆成清晰的 Map 和 Reduce,又不像电商推荐那样需要大规模矩阵运算。
输入数据只需要一张最简单的社交关系表,两列:用户ID、好友ID。常见的数据格式是 CSV 或者用制表符分隔的文本,每一行表示一条单向的“关注/好友”关系。为了减少后续处理的分支,我一般会把好友关系按“双向”处理:如果 A 是 B 的好友,就认为 B 也是 A 的好友,读数据时补一条反向边。这样做的好处是后面做两两组合时,不会因为边的方向漏掉推荐目标;代价是数据量会翻倍,但对于课程设计和毕设这个量级完全可以忽略。
这一步的数据模型是整个系统的地基。我建议不要一上来就设计用户画像、标签、行为日志之类的东西,先把“用户—好友”这张表跑通,再考虑加字段。很多毕设翻车,是因为数据模型画了七八张表,最后 MapReduce 里根本用不上其中一半。
2.2 第一轮 MapReduce:把边列表转成“用户 → 直接好友列表”
第一轮 Job 要解决的问题是:把每一行只有一条关系的边文件,聚合出每个用户的直接好友列表。比如用户 1 的好友是 2 和 3,这一轮就要输出一行1 2,3。Mapper 做的事很简单:读一行,把行按分隔符拆成两个字段,以用户ID为 key,好友ID为 value 发出去。
public class FriendListMapper extends Mapper<LongWritable, Text, Text, Text> { @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty()) { return; } // 输入是 "用户ID,好友ID",一行一条记录 String[] parts = line.split(","); String user = parts[0].trim(); String friend = parts[1].trim(); context.write(new Text(user), new Text(friend)); // 补一条反向边,保证好友关系在后续组合中对称可用 context.write(new Text(friend), new Text(user)); } }这段代码有两个关键地方。第一,line.split(",")后面马上trim(),是因为 CSV 文件里经常混着空格和不可见字符;第二,补反向边直接写在 Mapper 里,不要等到 Reducer 再做,因为 Reducer 里拿不到“原始边的另一个方向”。context.write每次调用都会产生一条中间记录,Map 的输出会落盘并按 key 排序,这是 Hadoop 的 shuffle 机制在做的事,不需要我们手动排序。
Reducer 的职责是把同一个 key(也就是同一个用户)下的所有好友ID去重合并,输出成一行逗号分隔的列表。用LinkedHashSet去重,既能保证唯一性,又能让输出顺序稳定,后面调试时对得上。
public class FriendListReducer extends Reducer<Text, Text, Text, Text> { @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { LinkedHashSet<String> friends = new LinkedHashSet<>(); for (Text value : values) { friends.add(value.toString()); } if (friends.isEmpty()) { return; } // key=用户ID,value=该用户的全部直接好友,逗号分隔 context.write(key, new Text(String.join(",", friends))); } }为什么用字符串拼接而不是直接把多个好友写到多个 value?因为下一轮 Job 的 Mapper 希望“一个用户的好友列表”能整体作为一行输入,这样它才能对这个列表做两两组合。如果这里输出多行,下一轮还得再聚合一次。这个设计属于典型的“为下一轮优化数据格式”,写在文档说明里多提一句,能让老师看出你考虑过作业之间的衔接。
2.3 第二轮 MapReduce:两两组合统计共同好友数并过滤直连关系
第二轮是算法的核心。输入是第一轮的输出,格式是用户ID \t 好友1,好友2,好友3。Mapper 要做两件事:一是把“当前用户和列表里的每个好友”标记为直连关系,二是把列表里的好友两两组合,认为“这两个人通过当前用户成为了共同好友”,发出一条计数为 1 的中间记录。
public class RecommendMapper extends Mapper<LongWritable, Text, Text, Text> { private static final String DIRECT = "-1"; private final Text outKey = new Text(); private final Text outVal = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] parts = value.toString().split("\\t"); if (parts.length != 2) { return; } String user = parts[0].trim(); String[] friends = parts[1].split(","); // 当前用户与列表中的每一个人已经是好友,用-1标记 for (String friend : friends) { outKey.set(user + ":" + friend); outVal.set(DIRECT); context.write(outKey, outVal); } // 两两组合:friends[i] 和 friends[j] 都认识 user // 所以 user 是两人的共同好友 for (String a : friends) { for (String b : friends) { if (a.equals(b)) { continue; } outKey.set(a + ":" + b); outVal.set("1"); context.write(outKey, outVal); } } } }这里的 key 用了a:b这种字符串拼接,方向固定为“给 a 推荐 b”。注意组合是笛卡尔积,a:b和b:a会被当成不同的推荐方向分别输出,这符合好友推荐的直觉:A 认识了 B,B 也该被推荐认识 A。直连标记却只需要当前用户到好友这一个方向吗?不是,直连也应该两个方向都写。当前这份代码里,因为第一轮补了反向边,用户 A 的好友列表里包含 B,用户 B 的好友列表里也包含 A,所以在两个用户各自的列表处理中,A:B和B:A的直连标记都会出现,能完整覆盖所有已有边。
Reducer 拿到的是同一个 pair 下的所有 value,里面可能混着 1 和 -1。一旦出现 -1,说明这两个人已经是好友,直接跳过;否则把所有的 1 加起来,就是这对人的共同好友数量。
public class RecommendReducer extends Reducer<Text, Text, Text, Text> { @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { int mutual = 0; for (Text value : values) { String s = value.toString(); if ("-1".equals(s)) { // 已经是好友,绝不推荐 return; } mutual += Integer.parseInt(s); } if (mutual == 0) { return; } String[] pair = key.toString().split(":"); // 输出:目标用户 \t 候选用户 \t 共同好友数 context.write(new Text(pair[0]), new Text(pair[1] + "\t" + mutual)); } }这段 Reducer 里最容易被忽略的是return而非break:一旦遇到 -1,整个 key 的输出都要放弃。这个细节我建议在文档说明里专门写一笔,因为它体现了设计者对“直连关系优先级最高”的理解。到这里,两轮 MR 已经能输出“A → B 有 N 个共同好友”的原始结果,但离“推荐系统”还差排序和截断,这部分放到第四章展开。
关于第二轮的数据膨胀,我补充一个经验值:假设平均每个用户有 50 个好友,两两组合会产生 50×49 也就是约 2450 条中间记录,用户量 10 万时就是 2.45 亿条,这个量在单机伪分布式上处理需要几分钟。所以第二轮从一开始就要考虑 Combiner。Combiner 不能随便加,它必须满足一个条件:合并操作对同一个 key 的 value 是可交换、可结合的。我们这个 Reducer 的求和逻辑满足条件,但 -1 的优先级不满足,所以要在 Combiner 里保留 -1 并把 1 求和,等 Reducer 看到 -1 时仍然直接放弃。
3. Hadoop 伪分布式搭建与项目骨架:从空目录到第一个任务
3.1 伪分布式、虚拟机还是 Docker 镜像,毕设环境怎么选
环境搭建是很多人在这一步放弃的原因:教程里的截图千奇百怪,hadoop 安装与配置的文章从 2.x 到 3.x 都有,照着敲完不是起不来就是进程互相打架。我建议毕设默认走“单机伪分布式”,也就是一台机器上同时跑 NameNode、DataNode、ResourceManager、NodeManager。这和真正的 hadoop 集群搭建只差在机器数量,但流程完全一致,足以支撑课程设计和毕业设计。除非你的文档里专门写了 HA、多节点压测,否则别轻易上多节点集群,光同步配置和调试网络就能耗掉一周。
如果本机是 Windows,不要直接在 Windows 里硬刚。最省事的路线是在虚拟机里装一个 Ubuntu Server,内存给 4G 以上,磁盘 40G,网络用 NAT 即可。其次可以考虑 hadoop 的 Docker 镜像,一条docker run起容器,适合只想快速验证代码的情况,但 Docker 的端口映射和容器重启后数据丢失,容易在答辩前掉链子。我的血泪经验是:虚拟机里的伪分布式最接近真实操作,出问题也最好查。
如果在虚拟机上安装 hadoop 遇到 SSH 免密登录的问题,记得先执行ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa生成密钥,再把~/.ssh/id_rsa.pub追加进~/.ssh/authorized_keys。网上很多 hadoop 安装与配置的教程卡在start-dfs.sh提示Permission denied,基本都是 SSH 免密没配上,和 Hadoop 本身没关系。
伪分布式的核心配置只有三个文件。core-site.xml里设置默认文件系统为 HDFS 地址;hdfs-site.xml里把副本数设为 1,因为伪分布式只有一个 DataNode,默认副本数为 3 会导致块一直处于 under-replicated 告警;mapred-site.xml里把计算框架切成 YARN。很多教程让你改一堆别名和路径,其实对毕设来说没必要。
<!-- core-site.xml --> <configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> </configuration> <!-- hdfs-site.xml --> <configuration> <property> <name>dfs.replication</name> <value>1</value> </property> </configuration> <!-- mapred-site.xml --> <configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property> </configuration>3.2 Maven 项目骨架与三个核心配置文件
源码部分,最常见的工程结构是一个 Java Maven 工程,三个包:mapper、reducer、driver,外加一个Driver主类调度两个 Job。依赖用hadoop-client一个就够,它会带进 HDFS 和 MapReduce 的客户端类。下面是一个最少依赖的pom.xml。
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.course</groupId> <artifactId>friend-recommend</artifactId> <version>1.0</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.6</version> </dependency> </dependencies> <build> <plugins> <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> </plugins> </build> </project>用 Hadoop 3.3.x 而不是 2.x 的原因很简单:3.x 的接口更干净,String.join之类在 Mapper 里写起来没有兼容问题,而且老教程里一堆setJarByClass的坑在 3.x 下依然适用,不会更复杂。如果你是照着网上 Java 课程设计案例源码改的,注意把源码里的org.apache.hadoop.mapred改成org.apache.hadoop.mapreduce,前者是旧 API,两者的 Mapper 生命周期和 Context 接口不完全一样,混用会直接编译失败。如果直接用了 hadoop 已编译 jar 包,记得把HADOOP_HOME和HADOOP_CLASSPATH环境变量配置正确,否则运行时大概率报ClassNotFoundException: org.apache.hadoop.mapreduce.Job。
Driver 类里两个 Job 要串起来执行:第一个 Job 输出到临时目录,第二个 Job 把临时目录作为输入,最后输出到结果目录。这里最容易踩的坑是“输出目录已存在会直接报错”,所以 Driver 里每次跑之前最好把输出目录删掉,或者用一个带时间戳的输出路径。我自己习惯在 Driver 里加几行清理代码,否则反复调参时要手动去 HDFS 删目录,很烦。
FileSystem fs = FileSystem.get(conf); Path outPath = new Path(args[2]); if (fs.exists(outPath)) { fs.delete(outPath, true); }注意:输出目录已存在时任务会直接失败,所以 Driver 里先删除目标路径是常规操作。这条在 Hadoop 3.x 同样适用。
3.3 提交任务到 YARN 的最小命令组
编译和提交的完整流程如下。先启动 HDFS 和 YARN,再用jps确认进程,然后传数据、打包、跑任务、看结果。
# 启动两个守护进程组 start-dfs.sh start-yarn.sh # 确认进程都活着,至少应该有 NameNode、DataNode、ResourceManager、NodeManager jps # 把数据放上 HDFS hdfs dfs -mkdir -p /recommend/input hdfs dfs -put friends.csv /recommend/input/ # 打包并提交任务 mvn clean package -DskipTests hadoop jar target/friend-recommend-1.0.jar \ com.course.recommend.Driver \ /recommend/input/friends.csv \ /recommend/output/friend_list \ /recommend/output/recommend # 查看推荐结果 hdfs dfs -cat /recommend/output/recommend/part-r-00000hadoop jar后面依次是 jar 包、主类全限定名、三个路径参数。第一个参数是输入,后两个是两轮 Job 各自的输出目录。YARN 的 8088 端口的 Web 页面上能看到两个 Job 先后执行的状态,建议跑之前记住jps的输出,如果提交后一直停留在Running job,十有八九是 ResourceManager 没起来或者内存被系统杀掉。
到这里,一个最小可运行的推荐系统已经通了。你会发现整个链路没有用到任何 Spark、Flume 之类的东西,输入就是朴素的文本文件,输出也是文本文件,这反而让毕业设计的“系统架构图”特别好画:数据从 HDFS 进入、经 MapReduce 计算、写回 HDFS。接下来要解决的是“推荐质量”,也就是怎么让结果不像一个只会计数的脚本。
4. 给推荐结果加权重:Jaccard 打分与 TopN 输出
4.1 为什么纯共同好友计数会把热门用户推给所有人
直接跑完第二轮的输出,你会发现一个尴尬的现场:那个好友最多、社交最活跃的用户,会被推荐给几乎所有人。这不是 bug,是计数模型的天性。共同好友数量只度量了“绝对关系数”,没考虑“关系的稀缺性”。如果 A 和 B 有 2 个共同好友,但 B 本身有 5000 个好友,说明 B 是个社交达人,这 2 个共同好友可能只是巧合;如果 C 和 D 有 2 个共同好友,而 D 总共只有 3 个好友,那这 2 个共同好友就非常说明问题了。
解决这个问题最常用的办法是 Jaccard 相似度。对两个用户 A 和 B,定义相似度等于共同好友数除以两人好友集合的并集大小:J(A,B) = 共同好友数 / |A的好友 ∪ B的好友|。这个值会惩罚那些好友数很大的用户,让“小众而精准”的推荐排到前面。在 hadoop 面试题里,这个点也经常被拿来问“为什么纯统计不够”,答上这一句,分数就不一样了。
要在 MapReduce 里算 Jaccard,困难点在于:第二轮 Reducer 只输出了共同好友数,没输出两人的好友总数。所以要给打分阶段补信息。常见做法是让第二轮另外再输出一份用户度数表,也就是第一轮已经算出来的好友列表长度,然后在打分阶段把它作为配置参数或者小表文件读进来。
4.2 在 Reducer 里算 Jaccard 相似度:分母怎么凑出来
一个实现成本很低的方案:把用户度数表放进一个Map<String, Integer>,在打分 Reducer 的setup里从配置读取。因为参与推荐的用户规模在课程设计里通常只有几万到几十万,直接以字符串形式塞进 Configuration 会超出上限,所以更稳妥的做法是先用一个小工具从第一轮输出里再跑一个简单 MR,输出用户ID \t 好友数,然后在打分阶段用 DistributedCache 把这张表分发到每个节点。
public class ScoreReducer extends Reducer<Text, Text, Text, Text> { private final Map<String, Integer> degree = new HashMap<>(); private final TreeMap<Double, List<String>> ranking = new TreeMap<>(Collections.reverseOrder()); private int topN = 10; @Override protected void setup(Context context) { topN = context.getConfiguration().getInt("recommend.topn", 10); // 从分布式缓存读用户度数表,格式“用户\t好友数” try { URI[] caches = context.getCacheFiles(); for (URI uri : caches) { List<String> lines = Files.readAllLines( Paths.get(uri.getPath())); for (String line : lines) { String[] p = line.split("\\t"); degree.put(p[0], Integer.parseInt(p[1])); } } } catch (Exception ignored) { } } @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { ranking.clear(); // key=目标用户,value=“候选用户\t共同好友数” for (Text value : values) { String[] p = value.toString().split("\\t"); String candidate = p[0]; int mutual = Integer.parseInt(p[1]); int degA = degree.getOrDefault(key.toString(), 1); int degB = degree.getOrDefault(candidate, 1); // Jaccard = 共同好友数 / 并集大小,并集 = degA + degB - mutual double score = mutual * 1.0 / (degA + degB - mutual); ranking.computeIfAbsent(score, k -> new ArrayList<>()).add(candidate); } emitTopN(context, key); } }这段代码的核心是score = mutual / (degA + degB - mutual)。degA + degB - mutual就是并集大小,因为共同好友同时属于两边的好友集合,只算一次。分母里对缺失用户默认给 1,是为了避免除零,同时也是个容忍脏数据的兜底。注意ranking是 TreeMap 并且用了逆序,所以分数高的候选会排在前面,后面emitTopN只要取前若干个即可。
setup里读分布式缓存是标准的“小表广播”手法。如果不想踩 DistributedCache 路径的坑,也可以在 Driver 里把这个文件放到 HDFS 上,然后用job.addCacheFile(new URI("/recommend/output/degree/part-r-00000"))传入,节点运行时会在本地拿到同名文件。这个方案比把度数表硬编码到代码里干净得多,文档说明里画系统架构图时,这条边会让数据流向显得更完整。
4.3 用 TreeMap 在 Reduce 端做 TopN 排序
最后一个步骤是对每个用户截取 TopN。这里不要在 Reducer 里收集全量数据再Collections.sort,因为一个大用户的候选可能有几十万条,全量排序的内存开销不划算。正确做法是维护一个容量固定的排行榜:TreeMap 本身有序,但它的computeIfAbsent会把所有分数都放进去,内存还是会涨。所以更稳的写法是限制 TreeMap 的大小。
private void addToRanking(String candidate, double score) { if (ranking.size() < topN || score > ranking.lastKey()) { ranking.computeIfAbsent(score, k -> new ArrayList<>()).add(candidate); // 超过容量时删除分数最低的那批 while (ranking.size() > topN) { ranking.pollLastEntry(); } } }这个函数的逻辑是典型的“击穿式 TopN”:只有当新分数比当前最低分还高时,才插入;插入后如果超过容量,就删掉分数最低的一个桶。因为 TreeMap 是有序的,lastKey()就是最小的分数(逆序排列下最后一个元素),pollLastEntry()删除的就是最低分桶。这个写法在 hadoop 面试题里对应“如何在 Reduce 端取 TopN 而不 OOM”,拿出来讲比一句“用排序”值钱得多。
输出阶段要处理一个细节:同一个 score 桶里有多个人。如果只按桶数截断,可能出现实际推荐人数超过 topN。我的处理是按桶遍历,桶内逐个输出,累计超过 topN 就整体停掉。这样每个用户最多输出 topN 条,不会多。到了这一步,整个推荐系统已经具备可用的形态:输入是关系数据,输出是每个用户的最优候选列表。打分阶段之后,整个流程经过“数据清洗 → 好友列表 → 共同好友计数 → Jaccard 打分 → TopN 截断”五步,每一层的输出都是上一层的输入,这样每一轮结果都能单独检查,出了问题也好定位。
5. 避坑与排查:从伪分布式到集群,好友推荐系统最常见的翻车现场
5.1 任务一直 RUNNING 不动,YARN 页面显示容器反复被 kill
现象:用hadoop jar提交后,日志卡在Running job,YARN 的 8088 端口页面上能看到 Application 状态是 RUNNING,但 Container 日志里一堆Killed by External signals。
原因:伪分布式在一台机器里同时跑四个守护进程,默认的 YARN 内存参数是按集群机器配置算的。yarn.nodemanager.resource.memory-mb可能高达 8G,而虚拟机总共才给了 4G,NodeManager 申请到的内存被内核直接杀掉。还有一个隐蔽原因是容器内存超限判定里包含了虚拟内存,Java 堆没超但堆外内存超了。
解决:先看jps确认 ResourceManager 和 NodeManager 在,再调低 YARN 内存并把虚拟内存检查关掉。
# etc/hadoop/yarn-site.xml 里追加 <property> <name>yarn.nodemanager.resource.memory-mb</name> <value>2048</value> </property> <property> <name>yarn.scheduler.minimum-allocation-mb</name> <value>256</value> </property> <property> <name>yarn.nodemanager.vmem-check-enabled</name> <value>false</value> </property>vmem-check-enabled=false是伪分布式环境下的常规操作,但写到文档里时一定要说明:这是为了规避单机虚拟内存限制,生产环境不建议关闭。老师看到这句话,会觉得你明白自己在做什么,而不是抄了一个配置。
5.2 结果里又把“已经是好友”的两人推荐了一遍
现象:推荐结果的某一行是A \t B \t 2,可是原始数据里明明存在A,B这条边。
原因:直连关系的标记没有覆盖所有方向。常见写法是在第二轮 Mapper 里只对“当前用户:好友”写了 -1,但两两组合输出的是好友i:好友j,两者的 key 方向对不上。比如用户 1 的好友列表是2,3,直连标记写的是1:2、1:3,而组合输出的是2:3,Reducer 根本看不到2:3的 -1,于是把已经是好友的 2 和 3 又推荐了一遍。
解决:在 Mapper 里把直连关系也做成和组合相同的 pair 格式,并且利用第一轮补过的反向边,让直连 pair 的两个方向都出现。我的做法是维护一张Set<String> directEdges,每处理完一个用户列表,就把a:b和b:a都放进去,组合时如果 pair 在直连集合里,直接跳过。这一步属于算法正确性问题,不是性能问题,答辩时最容易暴露,务必在测试用例里加一条“已知好友不应被推荐”的断言。
5.3 全组合导致数据爆炸,Reduce 阶段直接 OOM
现象:某个用户的好友数有 3000,第二轮 Mapper 对这个列表做笛卡尔组合,瞬间产生约 900 万条中间记录,shuffle 阶段网络和磁盘暴涨,Reducer 出现GC overhead limit exceeded。
原因:组合的复杂度是 O(n²),而且每条都会被写入 Map 的输出缓冲区。这在真实社交数据里不是假设,而是必然出现的大 V 用户。
解决:至少做三层优化。第一层在 Mapper 端,给好友列表长度设上限,比如超过 500 的大列表单独走一条降级逻辑,只对它排在最前面的活跃好友做组合;第二层加 Combiner,把同一个 Map 任务内的相同 pair 先求和,减少 shuffle 数据量;第三层在 Reducer 里对每个用户维护固定大小的 TopN 结构,见第四章的addToRanking,不要全量收集再排序。如果还不行,就把大 V 用户单独拆成一个 Job,用更多 Reduce 处理,不要让普通用户被它拖累。
5.4 中文用户名读出乱码,第一列永远多一个字符
现象:数据文件是 Windows 记事本或 WPS 保存的 CSV,第一行第一列的用户ID解析出来前面有个\uFEFF,导致和后续关联不上;或者整个文件是 GBK 编码,Hadoop 默认按 UTF-8 读,全部乱码。
原因:UTF-8 带 BOM 时,Hadoop 的TextInputFormat不会主动去掉文件头的 BOM 标记,它把\uFEFF当作第一个字符交给 Mapper;GBK 文件则是因为默认编码不匹配。
解决:数据入口统一转成 UTF-8 无 BOM。Linux 下一条命令搞定:
# 去掉BOM sed -i '1s/^\xEF\xBB\xBF//' friends.csv # 检查编码 file friends.csv # 如果不是UTF-8,先转码 iconv -f GBK -t UTF-8 friends.csv > friends_utf8.csv代码里再兜底一层:Mapper 拆完字段后对每个字段做String.replace("\uFEFF", "")。不要只依赖外部命令,因为答辩时老师可能直接用他手里的文件跑你的 jar,你的代码必须对脏数据有容忍度。另外TextOutputFormat写出时会按系统默认字符集编码,如果你的代码里new Text的是 UTF-8 字符串,而部署机的 LANG 不是 UTF-8,输出文件也会出现中文乱码。处理方式是启动任务前export LANG=zh_CN.UTF-8,或者统一以字节方式拼装输出。
5.5 本地伪分布跑得好好的,换了机器后结果对不上
现象:在一台机器上跑出来的推荐结果,换到另一台机器或真正的 hadoop 集群搭建环境后,输出顺序变了,甚至有的用户消失。
原因:MapReduce 的 Reduce 输出顺序与分区、Task 数量有关,本身就不保证全局稳定;如果中途加了 Combiner,部分求和仍然正确,但结果里的推荐顺序会受 shuffle 影响。真正的隐患是代码里使用了依赖进程内状态的写法,比如static的 HashMap 或者没清干净的 TreeMap,多个 Task 复用 JVM 时会把上一个 key 的数据带到下一个 key。
解决:把打分和 TopN 都做成“每个 key 独立初始化”,所有临时状态放到reduce方法内部或每轮clear。换机器前把输入数据放到 HDFS 上重新跑一遍,用结果文件做 diff,而不是直接对比命令行打印顺序。这个“换机器结果不一致”的问题在文档里写成一个“系统测试与容错”小节,是很漂亮的加分项。如果你后面真要做 hadoop 和 zookeeper 整合实战那一套高可用方案,那是另一个量级的工作量,毕设不建议碰。
6. 文档说明、验证与答辩:让老师觉得你“真做过”
6.1 毕业设计文档的六个章节怎么组织
标题里那句“文档说明”别浪费。文档我建议严格按六章写:需求分析、系统设计、系统实现、实验测试、总结展望。需求分析里把推荐场景讲成“基于共同好友的社交推荐”,不要吹大词;系统设计里放一张分层图:HDFS 存储层、MapReduce 计算层、推荐结果展示层;实现章节按两轮 MR 代码逐个展开,每个方法说清楚输入输出。文档不要贴全部源码,贴关键类加数据流向就够了。老师的耐心有限,能在他翻完前看到你的核心思路,比凑一百页管用。源码加笔记的搭配,笔记就是对每个类的设计取舍做一句话说明,比如“这里补反向边是为了第二轮组合时方向对称”,这种句子比任何概念都打动人。
6.2 用三五个人的小数据集验证推荐逻辑
小规模验证是让自己信服的唯一办法。拿一份只有 4 个用户的小关系表,手算推荐结果,再和程序输出对照。例如原始关系为1-2、1-3、2-3、3-4,四个用户中 3 的好友列表是1,2,4,那么 4 会被推荐给 1 和 2,因为共同好友都是 3;而 1 和 2 虽然共同好友是 3,但两人本来就是好友,应被过滤。建议全部自己手算一遍:
| 目标用户 | 候选用户 | 共同好友 | 是否直连 | 是否推荐 |
|---|---|---|---|---|
| 1 | 4 | 3 | 否 | 推荐 |
| 2 | 4 | 3 | 否 | 推荐 |
| 1 | 2 | 3 | 是 | 过滤 |
| 3 | 4 | 无 | 是 | 不推荐 |
把这张表连同程序输出一起截进文档,是答辩时最有说服力的一页。
6.3 一个让系统加分的进阶技巧:关联用户资料表
如果你还有精力,把推荐结果从“用户ID”变成“用户昵称 + 头像路径”很值得做。做法是用 DistributedCache 再广播一张用户资料表,在最后一轮 Reducer 里直接查表拼格式。这一步不改变任何算法逻辑,但演示时从 HDFS 直接输出一串 ID,和输出可读的昵称,观感差别极大。答辩前花一小时把 hadoop 面试题里的高频题过一遍:InputSplit 是什么、Combiner 与 Reducer 区别、数据倾斜怎么处理。这三点在这套系统中全部真实遇到过,能用自己的项目讲清楚,比背答案强太多。我现在做任何 Hadoop 任务前,都会先确认输入路径、输出路径和内存参数这三件事,这个习惯帮我省了很多次无用功。希望帮到你。
本文还有配套的精品资源,点击获取