上周一个读者在后台问我:单词计数练完了,接下来想找个真正有含金量的 MapReduce 案例,该做点什么。我几乎没犹豫就回了四个字:查找共同好友。这个题目在离线计算岗位的面试里出现频率极高,但它不是一个“背答案”就能糊弄过去的题。它的输入数据是一份普通的好友列表,输出是任意两个人之间的共同好友集合,表面上人畜无害,真正动手做的时候,你会发现 shuffle 分组、倒排索引、两两组合、二次 MapReduce,还有数据倾斜,这些 MapReduce 开发里最磨人的概念全被它撞上了。
这篇文章我会带你完整走一遍这个案例:先拆需求,再讲两个不同的算法思路,然后给出一套能直接跑通的两个阶段 Java 实现,最后把我在调试过程中踩过的高频坑和性能问题一次性说清楚。无论你是刚学完 WordCount 想进阶的初学者,还是准备大数据岗位面试、需要把 MapReduce 思维补齐的开发者,这篇文章都值得你花十五分钟读完。
1. 项目拆解:这个经典题到底在算什么东西
1.1 输入输出先说清楚
这个案例的输入文件长这样,每一行代表“一个人拥有哪些好友”:
A:B,C,D,F,E,O B:A,C,E,K C:F,A,D,I D:A,E,F,L E:B,C,D,M,L F:A,B,C,D,E,O,M G:A,C,D,E,F H:A,C,D,E,G I:A,O J:B,O K:A,D L:D,E,F M:E,F,G O:A,H,I,J格式很直观:冒号前面是用户名,冒号后面是他的好友列表,多个好友用逗号分隔。这里的“好友关系”默认是单向的,也就是说 A 的好友列表里有 B,只表示 A 把 B 当好友,并不强制要求 B 的好友列表里也一定有 A。这种不对称关系反而更贴近真实社交产品里的关注关系。
我们要算的东西也很明确:给出任意两个人,找出两个人共同拥有、都把他当好友的人。比如 A 的好友里有 C,B 的好友里也有 C,那 C 就是 A 和 B 的共同好友。最终输出的结果格式,我们希望是这样的:
A-B:D,E,F A-C:D,F A-D:C,F含义分别是:A 和 B 的共同好友是 D、E、F,A 和 C 的共同好友是 D、F,以此类推。注意这里的“A-B”最好固定顺序,不能一会儿输出“A-B”,一会儿输出“B-A”,否则后续统计根本没法做。
1.2 两种实现思路,为什么我推荐反向索引
拿到这个需求,第一反应通常是:遍历每个人的好友列表,把所有好友两两组合,用组合结果作为 key 继续聚合。比如对于 A 的好友列表 B,C,D,F,E,O,拆成所有二元组:(B,C)、(B,D)、(B,F)、(B,E)、(B,O)、(C,D)...,每组都输出一个“key=好友对,value=A”。等所有行都处理完,同一个好友对下面聚合的 value 列表,就是这两个人的共同好友。
这个思路叫做“直接两两配对”,逻辑最直白,但有一个很现实的问题:一个人的好友数如果是 n,那他这一行就会产生 n*(n-1)/2 个中间键值对。随着好友数量增长,Mapper 的输出规模是平方级膨胀。你想象一下一个拥有 5000 好友的用户,光他一行的中间结果就是约 1250 万条。这还只是一个用户,整个集群的 IO 很快就会被这种数据量打爆。
所以我更推荐第二种思路:反向索引。它先换一个角度处理数据——不把“A 的好友是谁”当成主信息,而是反过来建立“谁把某人当成好友”的映射。同样是上面 A 的那行数据,我们输出的不是“A 拥有 B,C,D...”,而是“B 被 A 拥有”“C 被 A 拥有”“D 被 A 拥有”。
经过 MapReduce 的 shuffle 自动分组之后,同一个好友名下的所有 value,就是所有把他当好友的人。此时再对这些人做两两组合,每一组输出一个共同好友关系。这两种思路最终结果完全一致,但中间数据量差了一个量级。我把对比整理在下面:
| 对比维度 | 直接两两配对 | 反向索引方案 |
|---|---|---|
| Mapper 输出规模 | 与好友数的平方成正比 | 与好友关系总条数线性相关 |
| Reducer 聚合对象 | 按好友对聚合用户 | 按单个好友聚合用户 |
| 数据倾斜风险 | 好友超多的人会打爆 Clouster 网络 | 超级好友名下的用户数会集中到同一次 Reduce |
| 代码可读性 | 简单但中间结果巨大 | 多一次转换,思路更清晰 |
| 适用场景 | 好友数少且分布均匀的小数据集 | 正常规模社交关系数据 |
一句话总结:选反向索引,是因为它把“平方级爆炸”的责任从 Mapper 转移到了 Reducer 端,而 Reducer 端的倾斜可以通过更多手段去缓解。企业里处理几十亿好友关系的时候,直接两两配对那条路几乎走不通,反向索引是相对靠谱的起点。
2. 方案设计:为什么 MapReduce 天然适合算共同好友
2.1 shuffle 分组就是干这个用的
MapReduce 里有一个非常强大但基础的动作:shuffle。它会把 Mapper 输出的所有键值对按照 key 自动分区、排序、归并,最终让同一个 key 的所有 value 进入同一个 Reducer 的 reduce 方法。很多初学者把 shuffle 当成一个“黑盒传输组件”,其实它就是分组聚合的实现机制。
共同好友问题本质上就是分组问题:所有把 C 当成好友的人应该被分到一组,然后这一组人两两之间就共享 C。MapReduce 的 shuffle 天然完成了“按共同好友分组”这个过程,我们只需要在 Mapper 里把好友关系“倒过来”写,剩下的交给框架。这也是这个案例经典的原因之一——它用最少量的代码,让你体会到 MapReduce 真正的优势在于分布式环境下的大规模分组。
用生活里的场景类比,这个过程就像快递分拣:所有寄往同一个城市“某好友”的包裹,在分拣中心会被自动放到同一个格口。你不需要一个个去找哪些人拥有同一个好友,shuffle 已经帮你把“拥有同一个好友的人”分到一起了。
2.2 第一阶段:从好友列表到共同好友对的映射
数据进来之后,核心执行流程是这样的:
原始好友列表 -> Mapper 阶段:拆开每一行,输出“好友名 -> 用户名” -> Shuffle 分组:同一个好友名下的所有用户名聚在一起 -> Reducer 阶段:同一好友下的用户名两两组合,输出“用户名对 -> 好友名”以样例数据为例,原始输入里有这么几行:
A:B,C,D,F,E,O C:F,A,D,I D:A,E,F,L第一行经过 Mapper 会输出六条中间记录:(B,A)、(C,A)、(D,A)、(F,A)、(E,A)、(O,A)。第二行会输出 (F,C)、(A,C)、(D,C)、(I,C)。第三行会输出 (A,D)、(E,D)、(F,D)、(L,D)。
注意看,好友名“F”底下,分别落下了 A、C 这两个用户名,这就说明 A 和 C 都把 F 当成好友,F 是它们的共同好友。到了 Reducer 端,同一组“F -> [A,C]”会输出一条记录:key = “A-C”,value = “F”。
但如果一个人名下有 6 个用户,Reducer 就要做 C(6,2)=15 次两两组合输出;如果有 100 个用户,就是 4950 次组合。这个组合数同样不能忽视,它直接决定了最终输出文件的大小。
2.3 为什么需要跑两个 MapReduce Job
很多人做完第一阶段就以为结束了,一查输出发现共同好友是散开的:
A-C:F A-C:D A-B:E A-B:FA 和 C 的两个共同好友 F、D 分散在不同的行里。这样的结果不算错,但不好看,也不方便下游做“每对用户有多少个共同好友”的统计。我们需要把同一个用户对的多条记录合并成一行,就需要再按“用户对”做一次分组聚合。
也就是说,第一阶段算出的结果是“用户对 -> 单个共同好友”,第二阶段要把“单个共同好友”聚合成“共同好友列表”。这两个阶段用的是同一个 MapReduce 机制,只是 key 从“好友名”换成了“用户对”。
第一轮输出: (A-C, F)、(A-C, D)、(A-B, E)、(A-B, F) 第二轮 shuffle: 按 A-C 聚合 -> values = [F, D] 第二轮 reduce: 输出 (A-C, D,F)这种“多阶段 MapReduce 串联”在实际业务里非常常见,一个复杂任务往往被拆成三四个有依赖关系的 Job 顺序执行。共同好友这个案例,实际上是让你在入门阶段就把这种思维方式练熟。
3. 代码落地:两个 MapReduce Job 完整实现
3.1 工程结构与前置准备
开发环境建议使用 Java 8,Hadoop 2.10.x 或 3.3.x 都可以。工程用 Maven 管理,目录结构如下:
friend-finder ├── pom.xml ├── src/main/java/com/example/friend/ │ ├── FriendMapper.java │ ├── FriendReducer.java │ ├── FriendDriver.java │ ├── FriendAggregateMapper.java │ ├── FriendAggregateReducer.java │ └── FriendAggregateDriver.java └── friends.txtpom.xml 里最关键的是 hadoop-client 依赖。注意,提交任务到集群时,Hadoop 运行环境已经包含了这些类,所以我们不需要把依赖打进 fat jar,插件默认打出的普通 jar 就能用。
<dependencies> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.4</version> <scope>provided</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> </plugins> </build>把上面的 friends.txt 准备好,启动集群后先建目录、传数据:
start-dfs.sh start-yarn.sh hadoop fs -mkdir -p /input hadoop fs -put friends.txt /input3.2 第一阶段的 Mapper:把好友关系反着写
FriendMapper 的代码不长,但有一个方向性错误,初学者非常容易犯。我直接在注释里标清楚。
package com.example.friend; import java.io.IOException; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; public class FriendMapper extends Mapper<LongWritable, Text, Text, Text> { private Text outKey = new Text(); // 输出 key:好友名 private Text outValue = new Text(); // 输出 value:用户名 @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); if (line == null || line.trim().isEmpty()) { return; } String[] parts = line.split(":"); if (parts.length != 2) { return; } String person = parts[0].trim(); String[] friends = parts[1].trim().split(","); for (String friend : friends) { String f = friend.trim(); if (f.isEmpty()) { continue; } // 输出“好友名 -> 用户名”,而不是“用户名 -> 好友名” outKey.set(f); outValue.set(person); context.write(outKey, outValue); } } }为什么不能反过来写?如果输出 key 是用户名,shuffle 之后同一用户名下的所有好友会聚在一起,面对一堆好友名没有任何配对意义。我们要找的是“拥有同一个好友的人”,所以必须先按好友名分组,再对组内的用户名配对。方向搞反,整个任务就废了。
3.3 第一阶段的 Reducer:同好友用户配对
Reducer 负责两件事:去重、配对。去重是为了防止原始数据里同一个好友出现多次造成重复输出;配对则是把组内所有用户名两两组合。
package com.example.friend; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; public class FriendReducer extends Reducer<Text, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { List<String> persons = new ArrayList<>(); for (Text val : values) { String p = val.toString(); // 自己把自己当好友属于脏数据,直接跳过 if (p.equals(key.toString())) { continue; } if (!persons.contains(p)) { persons.add(p); } } // 少于 2 人的组无法构成任何“共同好友”关系 if (persons.size() < 2) { return; } // 排序后按字典序遍历,能保证输出“A-B”而不是“B-A” Collections.sort(persons); for (int i = 0; i < persons.size(); i++) { for (int j = i + 1; j < persons.size(); j++) { outKey.set(persons.get(i) + "-" + persons.get(j)); outValue.set(key.toString()); context.write(outKey, outValue); } } } }这一段需要注意:排序之后 left 一定小于 right,所以“A-B”这种格式是稳定生成的。如果全部用户都是字母,对下游合并任务很重要;如果是中文用户名,建议把它们映射成内部 ID 再排序,否则可能会出现同一个用户对同时有“张三-李四”和“李四-张三”两种 key,后期去重成本大增。
3.4 第一个 Job 的 Driver
第一阶段 Driver 负责组装 Mapper、Reducer 和输入输出路径。
package com.example.friend; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class FriendDriver { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: FriendDriver <input> <output>"); System.exit(2); } Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "common-friends-stage1"); job.setJarByClass(FriendDriver.class); job.setMapperClass(FriendMapper.class); job.setReducerClass(FriendReducer.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); } }因为 Mapper 和 Reducer 的输入输出类型都是 Text-Text,所以这里不用特意设置 MapOutputKeyClass。如果你的 Mapper 中间类型不一样,就一定要单独配 setMapOutputKeyClass 和 setMapOutputValueClass,否则类型推断会报错。
3.5 第二阶段:把同一个用户对的好友合并成一行
第一轮输出落在 HDFS 上,行格式类似:
A-B E A-B F第二个 Job 的 Mapper 就是按制表符切分后原样透传,关键是 Reducer 里用逗号把多个好友拼接起来。
package com.example.friend; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; public class FriendAggregateMapper extends Mapper<LongWritable, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] parts = line.split("\t"); if (parts.length != 2) { return; } outKey.set(parts[0].trim()); outValue.set(parts[1].trim()); context.write(outKey, outValue); } }Reducer 端把同一个用户对下的所有好友收集起来,去重、排序、逗号拼接。
package com.example.friend; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; public class FriendAggregateReducer extends Reducer<Text, Text, Text, Text> { private Text outValue = new Text(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { List<String> friends = new ArrayList<>(); for (Text val : values) { String f = val.toString(); if (!friends.contains(f)) { friends.add(f); } } Collections.sort(friends); outValue.set(String.join(",", friends)); context.write(key, outValue); } }第二个 Driver 和第一个几乎一样,只是换类名和 Job 名称,这里不再重复。跑完第二个 Job,输出文件里每一行就是一个完整的用户对及他们的共同好友列表。
3.6 完整运行过程和结果验证
打包并提交两个作业的命令如下:
mvn clean package -DskipTests hadoop jar target/friend-common.jar com.example.friend.FriendDriver \ /input/friends.txt /output/stage1 hadoop jar target/friend-common.jar com.example.friend.FriendAggregateDriver \ /output/stage1 /output/stage2 hadoop fs -cat /output/stage2/part-r-00000输出大概是这个效果:
A-B D,E A-C D,F A-D C,F A-E C,D,F A-F B,C,D,E,O,M A-G C,D,E,F A-H C,D,E,G A-I O A-J O A-K D A-L D,E,F A-M E,F A-O H ...对照原始数据验证一下:A 的好友有 B,C,D,F,E,O,B 的好友有 A,C,E,K。共同好友是 E。第一行输出 “A-B E” 是符合预期的。到这里,整个案例的核心链路已经完整跑通。
如果你只想快速调试逻辑,不想每次启动整个 Hadoop 集群,也可以在 Driver 里加一行 conf.set("mapreduce.framework.name", "local"),使用本地模式运行,输入输出直接指向本地文件。本地模式下走的是和集群一样的任务调度逻辑,只是没有分布式拆分,调试效率很高。
4. 实战排雷:高频报错、性能瓶颈与修复方案
4.1 新手最容易踩的三个运行报错
第一个是 “Output directory already exists”。MapReduce 框架严格要求输出目录在执行前必须不存在,因为框架会自动创建它,如果目录已存在,它会直接拒绝运行。我见过有人为了省事,连续两次跑到同一个输出路径,第二次必报这个错。解决方法是每次运行前删掉旧目录,或者换一个新路径:
hadoop fs -rm -r /output/stage1第二个是 ClassNotFoundException。提交 jar 后可以运行,但启动 Task 时报找不到 Driver 或 Mapper 类。这种情况多半是打包不完整,或者提交命令里的类名写错了。检查一下 jar 中是否包含对应的 class:
jar tf target/friend-common.jar | grep Friend如果没有内置依赖的第三方库,普通 jar 就够用;如果代码里引用了 Hadoop 之外的自定义类,需要打成 fat jar,maven-shade-plugin 是标准解法,不要手动去拼 classpath。
第三个是任务频繁失败,但日志里只有 “Error: java.lang.OutOfMemoryError: Java heap space”。Reducers 在组合用户时,如果把几百万人的名字同时放到一个 List 里,堆内存肯定扛不住。后面专门说数据倾斜时再展开。
4.2 数据倾斜:一个人有十万好友怎么办
这是共同好友案例里最硬核的问题。假设有一个热门账号 O,几十万人把 O 当成好友。在第一阶段,shuffle 之后名为 “O” 的 key 会把几十万条记录全部发送到同一个 Reducer。这个 Reducer 需要对几十万人做两两组合,输出是天文数字,同时内存里要维护一个几十万人名的 List。你说它会不会 OOM?
实际处理手段有几种。
第一种是剪枝。如果业务只关心特定的人群组合,比如只想算 A、B、C 三个用户之间的共同好友,那在 Reducer 配对时只保留名字出现在目标白名单里的用户,其他全部丢弃。白名单可以用 Configuration 传进去,也可以用分布式缓存读。
第二种是重分区。第一阶段输出配对结果时,key 是“用户对”。我们可以增加 Reduce 数量、自定义 Partitioner,让不同的用户对均匀分散到不同节点。这只解决输出倾斜,解决不了单个“热门好友”的 Input 倾斜,因为按好友名分组是在 shuffle 阶段完成的,你无法把一个好友名拆给两个 Reducer。想要拆,就必须改变算法结构,把“O 的所有拥有者”再做一次哈希分桶,这已经属于进阶改造了。
第三种是治本方案,换计算引擎。这也是很多线上系统最后的选择。如果数据规模到了社交平台级别,MapReduce 的批处理模型处理这种倾斜天然吃亏。用 Spark 时可以用 salting(加盐)技巧,用 Hive 时可以用更细粒度的 SQL 让优化器去调整执行计划。但面试官问起 MapReduce 里的数据倾斜,你至少能说出“瓶颈在单个热门 key 的 Reduce 端”,这个认知本身比具体方案更重要。
4.3 编码和格式类隐蔽问题
有一个坑非常隐蔽:Windows 上编辑的 friends.txt 用记事本保存成 UTF-8 时,会在文件开头写入一个 BOM 头。Hadoop 的 TextInputFormat 会把 BOM 当作字符的一部分,导致第一行变成“\uFEFFA:B,C,D”,split(":") 之后 parts[0] 是“\uFEFFA”,数据错乱。解决办法是用文本编辑器另存为 UTF-8 without BOM,或者写一个小预处理 Map 把 BOM 字符去掉:
line = line.replace("\uFEFF", "");还有输入里的分隔符问题。早期数据从 Excel 导出,冒号可能被写成全角“:”,或者行尾带了\r。解析前统一清洗一下是最好的习惯:
line = line.replaceAll("\r", "").replace(":", ":");4.4 结果量和输出格式的权衡
第一个阶段 Reducer 输出的行数是“每个共同好友组合各一行”。如果你的数据里好友关系很多,这个中间结果可能比原始数据大不少。第二阶段合并之后,行数等于所有用户对的组合数,而不是共同好友个数。假设有 n 个用户都拥有某个热门好友,会产生 C(n,2) 行。这个行数可能突破亿级,单看输出文件大小也会冲上几十 GB。这种情况下可以再考虑:只输出共同好友数量,而不是全部好友名单。Modify Reducer 里把outValue.set(String.join(",", friends))换成outValue.set(String.valueOf(friends.size())),输出会小很多,后续按数量排序也方便。这就是“按业务需求压缩产物”的典型思路。
5. 延伸方向:从 MapReduce 到 Hive 和 Spark
5.1 面试里最常见的三种变体
第一个问题是“求共同好友数量”。在第二阶段的基础上,把输出 value 从好友名单改成 count 即可,本质上就是统计friends.size()。如果要按共同好友数量倒序排序,可以再开一个 job,用全排序或自定义 Partitioner 处理。
第二个问题是“找出共同好友最多的前 N 对”。这个需要从第二阶段的结果里做一次 TopN 聚合。MapReduce 实现 TopN 的标准做法是每个 Mapper 只保留本地前 N 条,Reducer 汇总后再全局排序取前 N。数据量很大的时候,比全量 sort 效率高得多。
第三个问题是“只求特定用户之间的共同好友”。比如线上产品展示“你可能认识的人”时,只对当前用户和他的潜在好友做计算,不需要全量两两配对。这种情况下,在第一阶段的 Reducer 里直接加白名单过滤,输入数据量和输出量都会大幅下降。
5.2 用 Hive SQL 怎么解决同类问题
MapReduce 代码虽然能让你深入理解框架,但生产环境里大家都不想写 Java。同样的需求用 Hive SQL 表达会直观很多。核心思路是先把好友表炸开,再按共同好友自关联聚合:
-- 将好友列表拆成多行 create table friend_exploded as select user, friend from friend_table lateral view explode(split(friend_list, ',')) t as friend; -- 两两用户按共同好友关联 select a.user as user1, b.user as user2, a.friend as common_friend from friend_exploded a join friend_exploded b on a.friend = b.friend and a.user < b.user group by a.user, b.user, a.friend; -- 再按 user1、user2 聚合 select user1, user2, concat_ws(',', collect_list(common_friend)) as common_friends from ... group by user1, user2;你会发现,Hive SQL 的思路和我们在 MapReduce 里亲手写的逻辑完全一致——拆开、自关联、分组聚合。如果你能把这个关系想明白,看 Hive 的 MapReduce 执行计划时就不会一头雾水,甚至能通过 EXPLAIN 预估数据倾斜点。
5.3 我个人在实际项目里的体会
这个案例我前前后后带人做过很多次,也见过各种各样的错误写法。有一个现象特别有意思:不少人能把代码跑通,但问他“为什么这里要输出好友名作为 key”的时候,他会愣一下。我后来才意识到,MapReduce 这个框架真正难的不是 API,而是“转换思维”。你要习惯把一个数据处理问题拆成“Map 阶段我需要重塑什么信息,Reduce 阶段我需要聚合什么信息”,以及“shuffle 会自动按什么键分组”。共同好友这个题目恰好把这套思维训练得非常密集。
还有一个体会是,算法选型一定要结合数据分布。如果我只在几 MB 的小数据集上测试,直接两两配对的方案跑得飞快,反向索引反而显得绕。但把数据量放大到几十个 GB、几亿条好友关系后,两种方案的差距会迅速拉开。学习和训练的时候用“真实规模”的心态去思考问题,比在玩具数据上反复调优更接近工程本质。最后再送你一个小技巧:跑任何 MapReduce 任务前先看一眼输入数据大小和单条记录长度,估算一下中间数据的膨胀倍率,这个习惯能帮你提前预判百分之八十的性能问题。