社交App里“可能认识的人”这个功能,估计大家都刷到过。你刚加了几个新同事,系统马上把另外几个同事推给你;你给某个老同学点赞,第二天列表里就出现了他的大学室友。这种推荐背后有很多种算法,但最朴素、也最适合拿来学 MapReduce 的一类,就是“你朋友的朋友,大概率也是你的朋友”。把这句话落到工程上,就有了二度好友推荐。本文要拆解的这个案例,就是典型的 Hadoop 2.6.0 + Linux CentOS 7 + IDEA 环境下,用 MapReduce 实现二度好友推荐的全过程。输入是一张好友关系表,输出是用户之间的推荐关系以及共同好友数。我会按实际动手的顺序,把环境选型、数据设计、算法思路、核心代码、集群提交、问题排查完整讲一遍,正在学 Hadoop 或者准备面试手写 MR 的同学可以直接照做。
1. 项目拆解:二度好友推荐到底在算什么
1.1 输入数据与输出目标
先明确输入。这个案例的原始数据是一张好友关系列表,每一行表示一个用户以及它的直接好友,字段之间用制表符分割,好友之间用逗号分割。我实际准备的测试数据是下面这样:
A B,C B A,D,E C A,D,E D B,C E B,C意思是 A 的直接好友是 B 和 C,B 的直接好友是 A、D、E,以此类推。这里的好友关系我按无向处理:A 是 B 的好友,那 B 也是 A 的好友。
输出目标是什么?对任意两个“目前不是直接好友”的用户,统计他们有多少个共同好友,共同好友数越多,说明这两人越有可能认识,推荐优先级就越高。用上面的数据跑完,最终输出应该是:
| 推荐对 | 共同好友数 | 共同好友列表 | 含义 |
|---|---|---|---|
| A-D | 2 | B,C | A 和 D 都认识 B、C,但互相不认识,可以互相推荐 |
| A-E | 2 | B,C | A 和 E 都认识 B、C,可以互相推荐 |
| B-C | 3 | A,D,E | B 和 C 交集最大,三个共同好友,最值得互相推荐 |
这样一看就清楚了。这个案例的输出不是只给某个特定用户推荐,而是全局算出所有“二度可达但没有直接关系”的用户对,附带推荐权重(共同好友数)。拿到结果后,产品侧按权重排序,再结合业务规则过滤,就是线上“好友推荐”的候选数据源。
1.2 为什么这道题适合用 MapReduce
有人说这种需求用 SQL 也能写,两张表做 join 就能算共同好友,为什么非要用 MapReduce?没错,数据量小的时候 SQL 完全能搞定,但这个案例出现在 Hadoop 生态里,目标是一张千万级甚至亿级的好友关系表。这种规模下,单机 join 的 shuffle、内存和 IO 都是瓶颈,而 MapReduce 天然能把“两两组合”“分组聚合”这类逻辑拆到分布式节点上执行。
更深一层看,二度好友推荐本质上是在图上做“两步邻域展开”。每个用户是一条边,用户和好友是一个点对。要算共同好友,就得把所有好友对的两两组合找出来,再按照用户对分组。这个“展开-分组-聚合”的模式,和 MapReduce 的 Map-Shuffle-Reduce 执行模型刚好一一对应。所以与其说这是一个业务案例,不如说它是理解 MapReduce 数据流的最佳练习:你想清楚一个键值对该长什么样,整个计算模型就通了。
1.3 核心难点在哪里
我见过的初学者写这个案例,大部分不是卡在代码语法上,而是卡在一个点上:无序对怎么统一。A 的好友列表里有 D,D 的好友列表里也有 A,A-D 和 D-A 在数据里是两个方向,但算法如果想聚合它们的共同好友,就必须让这两个方向落到同一个 Reduce 分组里。这是整个案例最关键的设计点。解决办法不复杂:自定义一个 pair 类型的 key,在构造时就把两个字段按字典序排好。A-D 排成 A-D,D-A 也排成 A-D,Map 阶段写出这个 key 之后,Shuffle 就会自动把它们送到同一个 Reducer。
另一个坑是直接关系的过滤。A 和 D 如果是直接好友,那即便产生了一些间接候选,也不能推荐,否则推给用户一个“你早就认识的人”,产品体验就是灾难。怎么在不额外引入一张大表的前提下,让 Reducer 知道自己正在处理的这个用户对是不是直接好友?答案是在 Map 阶段同时输出两类信号:一类标记 direct,一类标记 indirect,Reducer 按 key 收到全部信号后先看 direct,有就直接丢弃。
2. 环境准备:Hadoop 2.6.0 与 CentOS 7 版本选择
2.1 版本搭配怎么选才不折腾
标题里指定的是 Hadoop 2.6.0。这个版本放到今天看已经很老了,但作为教学项目它有不可替代的优势:社区资料最多、网上踩坑记录最全、对硬件要求低,跑通一套伪分布式只需要一台 2 核 4G 的机器。生产环境当然没人再用 2.6.0,但学习阶段用它能省掉很多新版本才有的兼容性问题。
JDK 版本我建议用 JDK 1.8。Hadoop 2.6.0 官方支持 Java 7 和 Java 8,实测 1.8 完全能跑 MapReduce,老版本的某些脚本在 Java 9 以上才会出问题。CentOS 7 系统自带的 OpenJDK 版本可能比较杂,最稳妥的路径是自己下载一个 JDK 8 的 tar 包解压安装,然后配置JAVA_HOME,不要偷懒直接用系统自带的java -version能跑就完事,后续 Hadoop 脚本会因为找不到JAVA_HOME报错。
CentOS 7 本身还分 minimal 和桌面版,跑 Hadoop 用 minimal 就够了,省内存。如果你只是学习,我这里强烈建议先搭伪分布式,也就是单节点同时跑 NameNode、DataNode、ResourceManager、NodeManager。整套集群逻辑和三节点完全一致,但少了很多网络排查成本。先把流程跑通,再考虑加节点。
2.2 踩过的配置坑:从 hostname 到免密登录
伪分布式也要有好习惯。第一步改主机名:
hostnamectl set-hostname master然后在/etc/hosts里加上一行,把本机 IP 映射到 master:
10.0.0.11 master不配 hosts 会有一个很隐蔽的毛病:NameNode 和 DataNode 之间通过主机名互相通信,偶尔能启动但状态显示异常,日志里全是UnknownHostException。顺手把防火墙关掉,避免后面 YARN 容器之间连不上:
systemctl stop firewalld systemctl disable firewalld接着解压 Hadoop 到/data/hadoop,修改etc/hadoop/hadoop-env.sh,找到export JAVA_HOME这一行改成你的 JDK 路径。注意 2.6.0 版本有个经典毛病,官方下载的安装包是 32 位编译的,在 64 位 CentOS 上会报 native 库加载警告:
Unable to load native-hadoop library for your platform...这个警告不影响纯 Java 的 MapReduce 作业运行,不用焦虑。当然如果想消掉,可以用源码重新编译 64 位 native 库,但教学项目完全没必要,别在这上面浪费时间。
配置文件的重点我列一下。core-site.xml里指定默认文件系统:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://master:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/data/hadoop/tmp</value> </property> </configuration>hdfs-site.xml里设置副本数为 1,伪分布式不需要三副本:
<property> <name>dfs.replication</name> <value>1</value> </property>yarn-site.xml里启用 shuffle 辅助服务,这是 MapReduce 跑在 YARN 上的前提:
<property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property>mapred-site.xml需要从模板复制一份再改:
cp /data/hadoop/etc/hadoop/mapred-site.xml.template /data/hadoop/etc/hadoop/mapred-site.xml核心内容是指定框架为 yarn。配置完先格式化 NameNode:
hdfs namenode -format格式化只要做一次,后面重复启动集群时绝对不能重新格式化,否则会把元数据清空,之前 HDFS 里的文件全丢。然后start-dfs.sh启动 HDFS,start-yarn.sh启动 YARN,最后用jps检查进程。出现 NameNode、DataNode、ResourceManager、NodeManager 四个进程就说明环境就绪了。
2.3 IDEA 侧的开发环境
集群环境搞定后,写代码一般在 IDEA 里做。新建一个 Maven 项目,pom.xml里引入一个核心依赖就够了:
<dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>2.6.0</version> </dependency>这个依赖会把hadoop-common、hdfs、mapreduce-client-core都带进来,本地编写、编译、打包都没问题。需要特别说明的是,IDEA 直接 Run 默认走的是本地模式,也就是 MapReduce 框架不会连接集群,而是在本地 JVM 里模拟整个流程。本地模式适合拿小数据快速验证逻辑,但它不访问 HDFS,路径直接写本地文件路径。真要提交到集群,还是得打成 Jar 包丢到 Linux 上执行。这两种运行方式我会在第 5 节完整演示。
3. 算法设计:一轮 MapReduce 怎么完成推荐
3.1 核心思路:把好友关系拆成两类信号
二度好友推荐的 MapReduce 算法,核心就一句话:把好友关系拆成两类信号,一类标记“这两个人是直接好友”,一类标记“这两个人有一个共同的间接好友”。
拿测试数据里 A 这一行来说:A 的好友是 B 和 C。
- 直接信号:A 和 B 是直接好友,A 和 C 是直接好友。
- 间接信号:B 和 C 共同认识 A。也就是说,B 和 C 虽然不一定是好友,但他们有一个共同好友 A,所以 B、C 这个用户对可以获得一条间接推荐信号,信号来源是 A。
再看 D 这一行:D 的好友是 B 和 C,它也会产生一条间接信号:B 和 C 共同认识 D。这样 B-C 这个用户对就会收到两条间接信号,共同好友是 A 和 D。再加上 E 这一行,B-C 收到三条信号,共同好友是 A、D、E,共同好友数 3。
这个逻辑推到每个用户身上就统一了。Map 阶段读一行数据,先对每个“user-friend”直接对写一个 direct 标记,再对好友列表里任意两两组合写一个 indirect 标记,indirect 的值带上当前用户作为“共同好友来源”。Reduce 阶段负责判断和聚合。
3.2 自定义 Key:让无序对变成有序对
前面说了,A-D 和 D-A 必须聚合到同一个分组。系统自带的 Text 类型做不到这一点,因为字符串 “A-D” 和 “D-A” 在字典序比较时不是同一个 key。所以我们需要自定义一个 Key 类型,内部持有两个字段,构造时强制按字典序排列。
我起名TextPair,实现WritableComparable接口。核心逻辑在构造函数里:
public TextPair(String first, String second) { int cmp = first.compareTo(second); if (cmp <= 0) { this.first.set(first); this.second.set(second); } else { this.first.set(second); this.second.set(first); } }传入 A-D 得到 (A, D),传入 D-A 也会得到 (A, D)。Map 端只要统一用这个 key,Shuffle 阶段的排序和分区就会自然把同一个无序对的所有信号送到同一个 Reducer 实例。这一步是整个算法正确性的地基。
同时要注意,Map 输出的 key 要参与默认的哈希分区。TextPair的hashCode也需要两个字段共同参与计算,并且要对称,否则可能出现一个问题:A-D 和 D-A 虽然 key 内容相同,但 hashCode 不一致,被分到不同分区。我在实现里用first.hashCode() * 163 + second.hashCode(),由于 first 和 second 已经排序,保证了同一无序对哈希结果一致。
3.3 Reduce 阶段:先过滤直接好友,再聚合共同好友
Reducer 拿到一个用户对 key 和它对应的所有信号值。信号值有两类:
0:直连标记,说明这两个人已经是直接好友。1:用户ID:间接标记,冒号后面的用户就是共同好友来源。
Reducer 的逻辑并不复杂:遍历所有 value,只要看到一个0开头的值,就说明这是直接好友关系,整个用户对直接跳过,不再输出。如果没有0,就把所有1:xxx里的 xxx 收集起来,去重后就是共同好友列表,列表长度就是共同好友数。
这里有个值得注意的细节:为什么不用 Combiner?表面上看,Map 端应该可以先聚合同一个用户对的间接信号,减少网络传输。但在我们的算法里,Reducer 必须检查整个分组里是否存在 direct 标记。如果 Combiner 把部分信号提前聚合成条数,Reducer 就无法判断这个用户对是否已经是直接好友,也可能把不该合并的1:A和1:B合并成计数,丢失共同好友的来源信息。所以这个案例不适合加 Combiner,保持最简单的一轮 MapReduce 即可。
4. 核心代码实现
4.1 自定义 TextPair 完整实现
TextPair是这次作业的枢纽。它需要具备 Hadoop 序列化的能力,才能在网络和磁盘间传输,需要实现compareTo才能参与排序:
import org.apache.hadoop.io.Text; import org.apache.hadoop.io.WritableComparable; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; public class TextPair implements WritableComparable<TextPair> { private Text first = new Text(); private Text second = new Text(); public TextPair() { } public TextPair(String first, String second) { int cmp = first.compareTo(second); if (cmp <= 0) { this.first.set(first); this.second.set(second); } else { this.first.set(second); this.second.set(first); } } public Text getFirst() { return first; } public Text getSecond() { return second; } @Override public void write(DataOutput out) throws IOException { first.write(out); second.write(out); } @Override public void readFields(DataInput in) throws IOException { first.readFields(in); second.readFields(in); } @Override public int compareTo(TextPair o) { int cmp = first.compareTo(o.first); if (cmp != 0) { return cmp; } return second.compareTo(o.second); } @Override public boolean equals(Object obj) { if (obj instanceof TextPair) { TextPair tp = (TextPair) obj; return first.equals(tp.first) && second.equals(tp.second); } return false; } @Override public int hashCode() { return first.hashCode() * 163 + second.hashCode(); } }特别提醒两点。第一,必须保留一个无参构造,Hadoop 在反序列化 key 时要通过反射调用无参构造创建对象,否则会抛java.lang.NoSuchMethodException。第二,write和readFields的字段顺序要严格一致,先写 first 就必须先读 first,顺序错乱会得到完全不可读的 key。
4.2 Mapper:双重信号输出
Mapper 的输入是LongWritable, Text,输出 key 是TextPair,value 是Text。逻辑分两步,第一步输出直接关系,第二步输出间接关系:
import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class FriendRecommendMapper extends Mapper<LongWritable, Text, TextPair, Text> { @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] fields = line.split("\t"); if (fields.length != 2) { return; } String user = fields[0].trim(); String[] friends = fields[1].trim().split(","); // 第一类信号:直接好友,标记为 0 for (String friend : friends) { context.write(new TextPair(user, friend), new Text("0")); } // 第二类信号:间接候选,value 里记录共同好友来源 for (int i = 0; i < friends.length; i++) { for (int j = i + 1; j < friends.length; j++) { context.write( new TextPair(friends[i], friends[j]), new Text("1:" + user)); } } } }为什么直接关系只写一次就够了?因为TextPair内部排序后,A-B 和 B-A 对应同一个 key。不管这条关系是从 A 那一行发现的,还是从 B 那一行发现的,最终都归到同一个 Reduce 分组里,所以不需要写两遍,写多了反而浪费网络带宽。
间接信号的 value 设计有讲究。1:B表示“这是一个间接推荐信号,B 是该用户对的共同好友”。用冒号做分隔符,Reducer 里根据前缀区分信号类型。你也可以只输出 user,不加前缀,用同一个 value 既表示 direct 又表示 indirect,但那样 Reducer 必须扫描完整列才敢下结论,而且还要处理重复信号,可读性会差很多。加前缀是最直观的做法,代价几乎为零。
4.3 Reducer:过滤与聚合
Reducer 输入 key 是TextPair,value 是Text的可迭代集合,输出我直接用Text作为 key,value 用一个空Text占位。这样输出文件里每条记录是一行可读文本,方便直接查看:
import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class FriendRecommendReducer extends Reducer<TextPair, Text, Text, Text> { @Override protected void reduce(TextPair key, Iterable<Text> values, Context context) throws IOException, InterruptedException { boolean direct = false; List<String> commonFriends = new ArrayList<String>(); for (Text value : values) { String val = value.toString(); if (val.startsWith("0")) { direct = true; break; } commonFriends.add(val.substring(2)); } if (direct || commonFriends.isEmpty()) { return; } String joined = join(commonFriends); String result = key.getFirst() + "\t" + key.getSecond() + "\t" + commonFriends.size() + "\t" + joined; context.write(new Text(result), new Text("")); } private String join(List<String> list) { StringBuilder sb = new StringBuilder(); for (String item : list) { sb.append(item).append(","); } String joined = sb.toString(); if (joined.length() > 0) { joined = joined.substring(0, joined.length() - 1); } return joined; } }这段代码有个效率点:一旦发现 direct 标记,立刻 break,因为已经不需要再处理同组的其他信号了。测试数据量小看不出差别,但在真实的大数据场景下,这个 break 能省掉大量的无用解析。
关于 Reducer 内存,还有一个经验要分享。如果在真实业务里想按共同好友数排序输出,很多教程会让 Reducer 维护一个 TreeMap,收集完所有结果后统一输出。这个思路可以跑通,但在大作业里非常危险,因为单个 Reducer 会把所有结果都压在内存里,数据量大时直接 OOM。我的建议是:保持 Reducer 流式输出,业务侧要做排序就再加一个排序作业,或者直接让下游消费端处理,不要把排序逻辑塞进这个 Reducer。
4.4 Driver 作业配置
Driver 负责组装整个作业。需要注意的配置点是 Map 输出 key 的类型是自定义的TextPair,不要漏设置,否则框架会按默认的LongWritable反序列化,直接报错:
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 FriendRecommendDriver { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: FriendRecommendDriver <input path> <output path>"); System.exit(-1); } Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "two-degree friend recommendation"); job.setJarByClass(FriendRecommendDriver.class); job.setMapperClass(FriendRecommendMapper.class); job.setReducerClass(FriendRecommendReducer.class); job.setMapOutputKeyClass(TextPair.class); job.setMapOutputValueClass(Text.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); } }这里其实有个隐含问题值得展开:Reducer 的输出类型和 Mapper 的输出类型不同。Mapper 输出 key 是TextPair,而 Reducer 输出 key 是Text,所以必须分别设置setMapOutputKeyClass和setOutputKeyClass。很多样例代码只写了其中一个,结果作业在运行时出现类型不匹配的异常,而且这个异常往往要到 Reduce 阶段才爆发,排查起来比较花时间。
5. 打包部署:从 IDEA 到集群上真实跑一遍
5.1 先在 IDEA 里用本地模式验证
写完代码不要着急打 Jar,先用 IDEA 本地模式跑一遍小数据,排除逻辑问题。本地模式不需要连接集群,直接 Run Driver 类,输入路径写本地文件,输出路径写本地目录。注意本地模式的输出目录也遵循“不能已存在”的规则,第二次运行前要先删掉旧输出目录。
我习惯在项目根目录放一个data/friends.txt,内容就是第 1 节那份测试数据。运行完打开输出目录里的part-r-00000,应该能看到:
A D 2 B,C A E 2 B,C B C 3 A,D,E看到这三行,算法逻辑基本就验证通过了。这里有个我最初踩过的坑:Reducer 输出 value 用了空Text(""),在本地模式没问题,但在集群模式里部分环境会把空 Text 序列化出一个奇怪的字符,导致输出文件末尾多个空字段。后来我改成NullWritable更干净,不过用Text("")不影响主流程判断,你可以按自己习惯来。严格一点的话,把输出 value 类型改成NullWritable更规范。
5.2 IDEA 打 Jar 包的两种方式
本地验证通过后开始打包。IDEA 自带 Artifacts 功能,菜单路径是 File -> Project Structure -> Artifacts,点加号选 JAR -> From modules with dependencies,Main Class 选FriendRecommendDriver,Build 后生成带依赖的 Jar。这种方式最省事,但会把所有第三方类都打进去,Jar 体积偏大,好处是集群环境不需要额外准备依赖。
更推荐的是用 Maven Shade 插件,在pom.xml里配置好后执行:
mvn clean package -DskipTests打出来的 Jar 在target目录下。无论是哪种方式,最终把 Jar 拿到集群上执行之前,先确认里面包含FriendRecommendDriver.class,否则提交时会报ClassNotFoundException。
5.3 上传数据并提交作业
把测试数据传到 HDFS 上:
hdfs dfs -mkdir -p /input hdfs dfs -put /data/friends.txt /input/friends.txt然后执行提交命令:
hadoop jar friend-recommend-1.0.jar com.example.FriendRecommendDriver /input/friends.txt /output/recommend如果你的主类不在com.example包下,换成实际包名。提交后注意观察终端输出,MapReduce 会打印 job 的进度:
Map 100% Reduce 100%等待完成后再看结果:
hdfs dfs -cat /output/recommend/part-r-00000正常会得到和本地模式一样的三行结果。提交命令里最关键的错误点我已经遇到过无数次:输出目录必须不存在。Hadoop 不会自动覆盖输出目录,第二次跑相同作业时必须先执行:
hdfs dfs -rm -r /output/recommend否则直接报FileAlreadyExistsException。
5.4 结果解读与线上衔接
输出结果里每一行可以理解成一条“互相推荐”的候选记录。A-D 这行表示 A 和 D 有 2 个共同好友 B、C,系统可以把 D 推荐给 A,也可以把 A 推荐给 D。B-C 这行共同好友数是 3,权重最高,在产品里排序应该靠前。
线上消费这份结果时,常见做法是按共同好友数倒排,截断 Top N,再过滤掉用户主动屏蔽的对象,然后写入推荐服务缓存。MapReduce 作业按天跑一次,产出当天的最新推荐池。这也是离线推荐系统的经典链路:批量计算候选集,在线服务读取排序。如果你后续想学推荐系统,二度好友推荐是一个非常顺滑的切入点,因为这个小小的作业已经涵盖了“候选生成 -> 特征计算 -> 结果落地”的完整思路。
6. 常见问题与避坑实录
6.1 环境与运行时报错速查
这个案例我前后帮不同的人调过很多次,大部分问题集中在下面这个表里:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
jps看不到 DataNode | NameNode 格式化后没有成功启动,或启动又被重复格式化 | 停止所有 Hadoop 进程,清空 hadoop.tmp.dir 下的数据,重新格式化,只格式化一次 |
| 提交作业后一直卡在 0% | ResourceManager 或 NodeManager 没启动,多半是防火墙挡了端口 | 检查防火墙状态,确认 8088、9000、8042 等端口可访问 |
Container is running beyond virtual memory limits | YARN 默认虚拟内存比例太小,本地跑不爆集群跑必爆 | 在 yarn-site.xml 调大yarn.nodemanager.vmem-pmem-ratio,比如改成 4 或 6 |
FileAlreadyExistsException | 输出目录已存在 | 删除旧输出目录,hdfs dfs -rm -r |
ClassNotFoundException | Jar 里没打主类,或类名写错 | 用jar tf检查 Jar 内容,确认主类路径 |
| Reduce 输出为空 | 数据分隔符不是\t,Map 里直接跳过 | 检查原始文件是否用了空格或逗号分隔,统一格式 |
| 自定义 TextPair 报序列化异常 | write/readFields没实现,或字段顺序不一致 | 补全方法,保持读写顺序一致 |
6.2 算法实现的经典误区
第一个误区是忽略 key 排序。如果TextPair构造器里不做字典序归一,A-D 和 D-A 会被当成两个不同 key,结果你会发现共同好友数少了一半,甚至完全没有结果。排查这个问题的方法是把TextPair的 compareTo 单独写个单元测试,输入 (A,D) 和 (D,A),断言两个对象相等且 hashCode 一致。
第二个误区是给作业乱加 Combiner。我在前面强调过,这个案例的 Reducer 需要看到纯正的 direct 标记和完整的间接来源。加了 Combiner 之后,同一 key 的多个1:xxx可能被合并成一条聚合后的 value,Reducer 的过滤逻辑就完全失效了,轻则结果错误,重则输出一堆脏数据。很多人随便写个 Combiner 类上去发现结果对不上,百思不得其解,其实底层原因就是这个。
第三个误区是忘记设置setMapOutputKeyClass。主类里 Mapper 输出TextPair,Reducer 输出Text,因为两者不一致,必须分开设置。漏设置时作业能提交,但跑到 Shuffle 阶段会报反序列化异常,而且异常日志藏在 stdout 里,新手经常翻半天找不到。记住一个原则:Map 输出类型和 Reduce 输出类型只要有一个不同,就要分别显示声明。
6.3 我实际跑这个项目时的一些体会
这套代码我在伪分布式和两节点集群上都跑过,讲两个实际观察。第一,数据量在几百行时,本地模式和集群模式的耗时差距基本看不出来,集群模式反而因为启动 YARN 容器多出十几秒开销。但一旦把数据量放大到几万行,本地模式就撑不住了,MapReduce 的分布式优势才开始体现。所以做这个案例时,建议你准备两个数据集:一个小集验证逻辑,一个大集感受分布式效果。
第二,调试的时候别急着上大数据。先把测试数据控制在十行以内,每一行都设计好它能产生的信号。比如我的测试数据里特意让 B 和 C 互相不认识,但都认识 A、D、E,这样能直观看到共同好友数最多的推荐对。小数据验证通过后再扩大数据量,否则出问题根本没法肉眼排查。
第三,Hadoop 2.6.0 的日志系统默认比较啰嗦,但 error 级别的关键信息往往藏在最后面。作业失败的时候,先找作业 ID,再执行yarn logs -applicationId <appid>查看容器日志,比在终端里来回翻输出效率高得多。
最后再分享一个小技巧:如果你想把最终的推荐结果按共同好友数降序排列,不要在这个作业里硬生生加全局排序。MapReduce 里做全局排序通常要控制分区或者再跑一个作业,对教学项目来说是过度设计。更务实的做法是直接把结果输出到 HDFS,然后用 Hive 或者 Spark 接一层,ORDER BY一下就行。这样既不用改流程,也能看到清晰的排序结果。这次的二度好友推荐案例,重点是把 MapReduce 的数据流理解透:自定义 key 怎么设计、信号怎么区分、分组后怎么过滤聚合。这个思维模型打通,后面再学 Spark、Flink 的算子,都会轻松很多。