ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

很多做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:9000
  • hdfs-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.1,NameNode会起不来。

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 Mapper<LongWritable, 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 Reducer<Text, Text, Text, Text> { @Override protected void reduce(Text key, Iterable<Text> 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类型都是Text:

Job 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端最好做一个简单去重,比如用一个Set<String>收集,最后再拼字符串。别小看这个细节,后一阶段的候选对枚举如果基于重复好友列表,会输出大量重复候选对,拖垮性能。

第二个坑:好友列表拼接时别用逗号,用空格,并且用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 Mapper<LongWritable, 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 Reducer<Text, Text, Text, Text> { private Set<String> 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, Iterable<Text> values, Context context) throws IOException, InterruptedException { String pair = key.toString(); if (directFriends.contains(pair)) { return; } List<String> 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和C,B和C之间存在共同好友A,所以候选对是B C;B的好友是A和D,A和D存在共同好友B,所以候选对是A D;C的好友是A、D、E,两两组合得到A D、A E、D E;D的好友是B和C,候选对是B C;E的好友是C,只有一个好友,没有组合。

然后过滤掉直接好友。直接好友关系里有A B、A C、B D、C D、E C。现在看候选对:

  • B C:B和C不是直接好友,共同好友是A,保留。
  • A D:A和D不是直接好友,共同好友是B,还有C?等一下,A的好友是B和C,D的好友是B和C,所以A和D的共同好友是B和C,有两个。保留。
  • A E:A和E不是直接好友,共同好友是C,保留。
  • D E:D和E不是直接好友,共同好友是C,保留。

最终结果应该有五行。用这份小数据对拍,能快速确认逻辑正确。

5.2 打包与提交命令

在IDEA里,用Maven的mvn package或Gradle的build打出一个包含依赖的Jar包。由于Hadoop集群本身有Hadoop依赖,你打包时不需要把Hadoop相关依赖打进去,否则包会很大,甚至可能和集群版本冲突。我的pom里只把hadoop-common等设为provided。

把friends.txt放到HDFS:

hdfs 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 C

5.3 结果能对拍,但别急着庆祝

到这步,你的主流程已经通了。不过有几个小地方建议再验证一下:

第一,如果你的第二个作业结果多出了A D只显示两个共同好友还是显示一个共同好友,取决于Reduce端的values去没去重。如果在阶段一没有去重,A的好友列表里可能不止一个B,A和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差不多就入门了。剩下的路,就在真实数据和性能优化里慢慢磨。

返回列表