资讯动态

Hadoop MapReduce二度好友推荐实战:环境配置、代码实现与避坑指南

发布时间:2026/10/22 11:03:45 来源:尧图企业网站定制
很多做MapReduce入门的人到了二度好友推荐这个案例都会卡一下。原因在于这个案例看起来只有两条逻辑找出共同好友、过滤掉已经是好友的人。但真要在Hadoop2.6.0 CentOS 7 IDEA这套组合里跑通从环境到代码、从打包到验证每一步都有小坑等着你。这篇文章我按实际跑通的顺序来写不是教科书式的代码讲解而是把我调试过程中踩过的坑、验证过的思路、以及哪些代码可以无脑抄、哪些必须根据你的场景改全部拆开说清楚。适合同样在用Hadoop 2.6.0做课程设计或企业入门项目的朋友参考。1. 先把二度好友推荐这件事建模清楚再动手写代码我见过不少人拿到这个标题就直接写Mapper和Reducer写到一半才发现不知道输出什么。根本原因是你还没把二度好友转化成MapReduce能理解的数据流动过程。1.1 二度好友的本质是共同好友数社交网络里的好友关系最朴素的表现形式就是一张边表A B A C B D C D E C每一行代表两个用户之间存在直接好友关系。这里不用管有向还是无向一般二度好友推荐都按无向关系处理也就是说A B和B A等价。所谓二度好友就是两个人之间隔着一个人就能建立联系但这个联系可能是间接的。比如 A 和 B 是好友B 和 D 是好友那么 D 就是 A 的二度好友候选。推荐的时候通常还要算一个共同好友数D 和 A 之间共同认识的人越多推荐优先级越高。所以在MapReduce框架下核心计算就变成了对每一对用户统计他们共同好友的个数。1.2 为什么这个场景天生适合MapReduce如果数据量只有几千个用户你直接在内存里构建一个邻接表两层循环就完了。但真实社交平台是千万级用户、亿级关系边。两层循环的复杂度是 O(N²)用户数一上千万直接不可行。MapReduce的优势在于它把找人和找共同好友这两件事拆成了可以利用分布式并行能力的阶段。比如第二阶段的Mapper可以对不同用户的好友列表做组合枚举这些组合彼此独立完全可以在不同节点上并行计算。这也是为什么大多数大数据教材都会拿这个案例当入门实战题——它涵盖了MapReduce最核心的三种操作转换、分组、连接。1.3 整体设计两个MapReduce阶段我最终跑通的方案是两阶段MapReduce阶段一把好友边表转成用户 - 好友列表的邻接表。阶段二对每个用户的好友列表做两两组合输出用户对 - 共同好友再用DistributedCache把直接好友关系加载进来过滤掉已经是好友的推荐对。有些资料会用三个阶段先求邻接表再求共同好友最后剔除已关注关系。我合并成两个阶段把过滤逻辑放在Reduce端做减少一次作业调度。你如果只是为了演示两个阶段更好理解如果是为了生产级复用可以拆成三个模块后面各有各的优化空间。2. 环境准备CentOS 7上让Hadoop2.6.0和IDEA老实配合需要做的几件事很多人在这一步就耗掉了半天。Hadoop 2.6.0这个版本并不新但正因为老它和当下环境的兼容性问题比新版本更明显。2.1 JDK版本选择比想象中重要Hadoop 2.6.0官方文档要求JDK 7但实测在JDK 8下编译运行也问题不大。不过有两点要注意第一IDEA默认会用你机器上配置的JRE如果你用的是JDK 9以上Hadoop 2.6.0很可能直接报UnsupportedClassVersionError或者反射方法找不到。我建议统一使用JDK 8比如jdk1.8.0_212同时把IDEA的Project SDK和Module SDK都指到同一个JDK。第二CentOS 7自带的可能是OpenJDK 1.7如果你集群上的Java版本和IDEA里的Java版本不一致打包时用IDEA的JDK 8编译提交到集群上可能因为版本不同出现各种奇怪问题。稳妥做法是IDEA里用什么JDK集群上的java -version也尽量匹配。2.2 Hadoop集群配置的常规检查清单网上大量教程会让你改一堆配置文件实际上对于本地跑通案例来说只需要保证这几个核心项是好的core-site.xml里fs.defaultFS指向hdfs://你的主机名或IP:9000hdfs-site.xml里dfs.replication设为1单节点伪分布式yarn-site.xml里配置yarn.resourcemanager.hostname如果只是测试可以用local模式跑在CentOS 7上最容易忽略的是防火墙。防火墙没关NameNode和DataNode之间心跳频繁失败表现就是进程都在但hdfs dfs -ls /超时。我排查这类问题一般是先执行systemctl status firewalld systemctl stop firewalld systemctl disable firewalld然后确认/etc/hosts里主机名能不能正常解析。Hadoop有个特点它会拿hostname去做集群节点识别如果hostname解析到127.0.0.1NameNode会起不来。2.3 在IDEA里跑MapReduce的两种姿势方法A本地模式调试。把代码里的配置直接写成Configuration conf new Configuration(); conf.set(mapreduce.framework.name, local);然后直接Run main方法。这种方式的优点是不需要启动HDFS和YARN就能看Mapper、Reducer的输出。缺点是和集群行为有差异特别是DistributedCache的加载路径在本地模式下不太一样。方法B打包成Jar丢到CentOS 7上用hadoop jar命令跑。这是最终验证的正确姿势。我实际推荐的流程是先用本地模式把逻辑跑通再改回集群模式打包提交。如果你一上来就配置集群一旦报错很难判断是环境问题还是代码问题。3. 阶段一用MapReduce生成用户 - 好友列表邻接表阶段一的目标把下面这种边表A B A C B D C D E C转换成A B C B A D C A D E D B C E C这里注意输出格式是用户Tab好友列表。每个好友之间用空格分隔。3.1 代码骨架Mapper、Reducer、Job配置阶段一的Mapper其实很简单每一行是一条边直接输出两个人之间的好友关系。为了无向图处理我输出两次把(x, y)和(y, x)都发出去。这样可以保证每个人的好友列表都完整。public class FriendsListMapper extends MapperLongWritable, Text, Text, Text { Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] tokens value.toString().split(\\s); if (tokens.length 2) { return; } String user tokens[0]; String friend tokens[1]; context.write(new Text(user), new Text(friend)); context.write(new Text(friend), new Text(user)); } }Reducer做的事情是把同一个用户的所有好友拼接起来形成一个空格分隔的字符串。public class FriendsListReducer extends ReducerText, Text, Text, Text { Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { StringBuilder sb new StringBuilder(); for (Text value : values) { if (sb.length() 0) { sb.append( ); } sb.append(value.toString()); } context.write(key, new Text(sb.toString())); } }Job配置里只需要设置三个类名、输入输出格式以及设置Map输出Key/Value类型都是TextJob job Job.getInstance(conf, friend-list-builder); job.setJarByClass(FriendsListJob.class); job.setMapperClass(FriendsListMapper.class); job.setReducerClass(FriendsListReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(Text.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1);3.2 这里至少有三个坑值得单独说第一个坑重复边导致的重复好友。原始数据里如果存在A B和B A两行经过两次正反输出A的好友列表里会出现两次B。Reducer端最好做一个简单去重比如用一个SetString收集最后再拼字符串。别小看这个细节后一阶段的候选对枚举如果基于重复好友列表会输出大量重复候选对拖垮性能。第二个坑好友列表拼接时别用逗号用空格并且用Tab隔开用户和好友列表。不少人在阶段一输出user,friend到了阶段二又得写自定义字符串切割徒增麻烦。统一成Tab 空格可以省掉一条StringUtils类。第三个坑自环边和空好友列表。真实数据里可能有A A这种自环或者一行只有单个字符。Mapper里最好加个判断if (user.equals(friend)) return;。这种脏数据在测试集中通常没有但真实数据里很常见。4. 阶段二生成二度候选对再用DistributedCache把已是好友的过滤掉阶段二是整个案例的核心。它的思路是如果你和某人不直接是好友但是你们有共同好友那你们就是二度好友候选。共同好友越多推荐优先级越高。4.1 为什么Reducer里要基于邻接表做两两组合拿到邻接表之后假设用户U的好友列表是[f1, f2, f3]。任意两个好友f1和f2他们都是U的好友所以他们之间存在一条经过U的路径。换句话说f1和f2是二度关系。所以第二阶段的关键操作就是针对每个用户遍历他的好友列表并对所有好友做两两组合输出(好友对, 当前用户)。等所有用户都计算完相同的好友对会在Reducer端聚合values里出现多少个用户就说明这对用户有多少个共同好友。这里我最开始犯过一个逻辑错误直接拿用户自己和他的好友做组合。比如用户A好友是B、C那就会输出(A,B)和(A,C)这其实是一度关系不是二度关系。正确做法是只对好友列表内部做两两组合不要涉及当前用户自己。4.2 阶段二Mapper和Reducer代码阶段二的输入是阶段一的输出即用户 Tab 好友列表。Mapper里要做的事情是解析出用户U和好友列表把所有好友拆成一个数组或List然后双重循环生成组合。为了减少重复输出我强制要求组合的前后顺序按字典序比较小的排在前面。这样做的好处是(A,B)和(B,A)不会同时出现Reducer端的group只会见到一种key。public class TwoHopCandidatesMapper extends MapperLongWritable, Text, Text, 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].trim().split(\\s); if (friends.length 2) { return; } for (int i 0; i friends.length; i) { for (int j i 1; j friends.length; j) { String friendA friends[i]; String friendB friends[j]; if (friendA.equals(friendB)) { continue; } if (friendA.compareTo(friendB) 0) { context.write(new Text(friendA \t friendB), new Text(user)); } else { context.write(new Text(friendB \t friendA), new Text(user)); } } } } }Reducer拿到key就是候选好友对(friendA, friendB)values就是他们的共同好友列表。如果values里共同好友数大于0说明双方确实二度可达可以输出一个候选结果。public class TwoHopCandidatesReducer extends ReducerText, Text, Text, Text { private SetString directFriends new HashSet(); Override protected void setup(Context context) throws IOException, InterruptedException { URI[] cacheFiles context.getCacheFiles(); if (cacheFiles ! null cacheFiles.length 0) { Path path new Path(cacheFiles[0]); // 伪分布式场景下DistributedCache文件会被本地化到节点 FileSystem fs FileSystem.get(context.getConfiguration()); BufferedReader reader new BufferedReader(new InputStreamReader(fs.open(path))); String line; while ((line reader.readLine()) ! null) { String[] tokens line.trim().split(\\s); if (tokens.length 2) continue; directFriends.add(tokens[0] \t tokens[1]); directFriends.add(tokens[1] \t tokens[0]); } reader.close(); } } Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { String pair key.toString(); if (directFriends.contains(pair)) { return; } ListString commonFriends new ArrayList(); for (Text val : values) { commonFriends.add(val.toString()); } String commonFriendStr String.join(,, commonFriends); context.write(key, new Text(commonFriendStr)); } }这里要说明一下setup里的DistributedCache读取逻辑。Hadoop 2.6.0环境下DistributedCache的API有两种形式老版本用DistributedCache.addCacheFile新版本用job.addCacheFile。我们在2.6.0上更稳定的做法是job.addCacheFile(new URI(/input/direct_friends.txt#direct_friends.txt));Reduer里再通过context.getCacheFiles()拿到URI然后用Path读取。这个过程和本地模式、集群模式略有差异本地模式下文件放本地路径也兼容集群模式下建议先把这个文件放到HDFS。4.3 直接好友关系文件怎么准备直接好友关系就是原始边表但为了过滤方便要生成一张无向去重后的列表。最简单的方式就是直接拿阶段一的中间结果或者写一个非常简单的Map-only任务把原始数据去重。其实连编码都不需要只需要保证文件每一行是A B A C ...Reducer里会把A B和B A都加进directFriends集合这样不管候选对是A B还是B A都能命中过滤规则。4.4 为什么非要用DistributedCache而不是直接再跑一个Reduce可能有人会想我能不能在Reduce阶段再join一次好友关系表从逻辑上说完全可以但代价是又多一次全量shuffle。二度好友推荐本身就是IO密集型任务第二次作业的shuffle数据量已经不小了再关联一份大文件时间和磁盘消耗都会明显上涨。DistributedCache的本质是让每个Map或Reduce任务启动前把只读的小数据分发到本地内存里查一个HashSet开销极小。对于直接好友关系这种本身就是图的边表如果不超过几十GB用DistributedCache是很成熟的方案。5. 用一份小数据完成全流程验证从Jar包到HDFS再到结果对拍光有代码不跑一遍等于白看。我准备了一个小数据集你可以直接用。5.1 测试数据与人肉预期新建一个文件friends.txt内容如下A B A C B D C D E C手动推导一下第一阶段会生成A B C B A D C A D E D B C E C第二阶段A的好友是B和CB和C之间存在共同好友A所以候选对是B CB的好友是A和DA和D存在共同好友B所以候选对是A DC的好友是A、D、E两两组合得到A D、A E、D ED的好友是B和C候选对是B CE的好友是C只有一个好友没有组合。然后过滤掉直接好友。直接好友关系里有A B、A C、B D、C D、E C。现在看候选对B CB和C不是直接好友共同好友是A保留。A DA和D不是直接好友共同好友是B还有C等一下A的好友是B和CD的好友是B和C所以A和D的共同好友是B和C有两个。保留。A EA和E不是直接好友共同好友是C保留。D ED和E不是直接好友共同好友是C保留。最终结果应该有五行。用这份小数据对拍能快速确认逻辑正确。5.2 打包与提交命令在IDEA里用Maven的mvn package或Gradle的build打出一个包含依赖的Jar包。由于Hadoop集群本身有Hadoop依赖你打包时不需要把Hadoop相关依赖打进去否则包会很大甚至可能和集群版本冲突。我的pom里只把hadoop-common等设为provided。把friends.txt放到HDFShdfs dfs -mkdir -p /input hdfs dfs -put friends.txt /input/把直接好友关系文件也放上去hdfs dfs -put friends.txt /input/direct_friends.txt执行第一个作业hadoop jar hfriend-1.0.jar com.example.FriendsListJob /input/friends.txt /output/phase1执行第二个作业hadoop jar hfriend-1.0.jar com.example.TwoHopJob /output/phase1 /output/phase2 -files hdfs://localhost:9000/input/direct_friends.txt注意-files参数在Hadoop 2.6.0的hadoop jar命令里是支持的它等价于DistributedCache。我在代码里用context.getCacheFiles()读取所以必须通过-files传入。跑完之后hdfs dfs -cat /output/phase2/part-r-00000预期会看到类似这样的输出A D B,C A E C B C A D E C5.3 结果能对拍但别急着庆祝到这步你的主流程已经通了。不过有几个小地方建议再验证一下第一如果你的第二个作业结果多出了A D只显示两个共同好友还是显示一个共同好友取决于Reduce端的values去没去重。如果在阶段一没有去重A的好友列表里可能不止一个BA和D的共同好友数量就会变成2、3、4这种虚高。这个数字直接影响推荐排序不能掉以轻心。第二跑完第一个作业一定要hdfs dfs -cat看看中间结果里有没有空行、重复好友、以及排序是否稳定。很多第二个作业跑出奇怪结果根源在第一个作业。第三确认作业ID和HDFS日志路径。Hadoop 2.6.0的应用日志在HDFS上有个_logs目录如果任务失败不要只看YARN的报错先hdfs dfs -ls /output/phase2/_logs找到task日志。6. 跑通之后回头看这个案例里的性能隐患和可扩展方向两阶段作业跑通后我只觉得松了一半气。因为真实场景下这个案例并不像测试数据这么简单。有几个跟性能相关的点如果你只是作业能跑可以忽略但如果数据量上去了它们就是致命的。6.1 好友列表两两组合的数据放大效应阶段二最需要警惕的是候选对的爆炸式增长。假设某个用户有N个好友那他的好友列表就会产生 C(N,2) 个候选对。当某个大V用户有1万个好友时这个人单独就会产生约5000万个候选对。如果有100个这样的大V就是50亿条中间记录比全平台的好友关系数还要多出好几个量级。这是二度好友推荐案例最核心的痛点。大部分教材不会告诉你。缓解方案有两种一是设置好友列表组合上限比如只对好友数量小于1000的用户做两两组合超过的直接跳过或在Mapper里采样二是按共同好友数只保留topN但在MapReduce里做topN过滤需要额外的辅助机制。实际生产环境一般会结合GraphX这类图计算框架而不是纯MapReduce不过在课程设计和练习场景下理解这个瓶颈就够了。6.2 数据倾斜热点用户拖垮整个Reduce第二阶段Reduce端按候选对分组部分热点候选对比如某两个明星的共同好友非常多会导致某个Reduce任务数据量极大而其他Reduce任务早就跑完了。集群整体卡在最后一个task上这就是数据倾斜。最简单的测试方法是看YARN上每个Reduce处理了多少条记录。如果发现某个reducer明显异常可以考虑自定义Partitioner把大key拆散或者做两轮MapReduce第一轮只统计哪些key共同好友数超过阈值第二轮再单独处理。6.3 从二度扩展到N度需要重新思考存储结构这个案例做的是二度但实际产品里往往要扩展到三度、四度。用MapReduce硬写会非常痛苦因为每增加一度中间结果就翻好几倍。我个人的建议是如果只是学习MapReduce就老老实实把二度案例吃透如果真要解决好友推荐问题请考虑图计算系统或者至少认真设计图数据模型。MapReduce擅长的是批量分析而不是多跳算路。这个边界想清楚你就不会拿着锤子把所有东西都看成钉子。6.4 我在这个案例上的一些小经验最后说几个零碎但实用的体会。第一个是IDEA远程debug。如果集群和开发环境是分离的可以用IDEA的Remote Debug连到YARN的ApplicationMaster或者NodeManager上但要配YARN site的mapreduce.reduce.java.opts加上调试参数。我在课程项目里试过非常折腾而且Hadoop 2.6.0的调试端口经常起不来。更快的方式是在本地模式下加日志输出把Mapper和Reducer处理到哪一步打出来本地验证通过后再上集群。第二个是写代码时尽量让Mapper输出稳定的key顺序。二度好友推荐里候选对的key稳定排序不仅能减少重复输出还能让后续调试时肉眼比对结果变得容易。这个习惯在更复杂的MapReduce任务里更值钱。第三个是测试数据的边界设计。我上面给的六行数据能覆盖无向边、双向边、多个共同好友、无共同好友这四种情况。建议你再补两个极端case一个用户只有单个好友看会不会输出空结果一个用户有很多重复边输入看去重逻辑有没有生效。边界case才是真正检验代码质量的尺度。二度好友推荐并不是一个多高深的算法但它确实是把MapReduce的shuffle、分组、连接、DistributedCache这些核心机制串起来的绝佳案例。你如果能把环境、两阶段逻辑、过滤、验证全部自己跑通MapReduce差不多就入门了。剩下的路就在真实数据和性能优化里慢慢磨。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑