MapReduce框架原理解析二:Shuffle

在 MapReduce 的宏大叙事中,如果说 Map 是"分"的智慧,Reduce 是"合"的艺术,那么连接这两者的 Shuffle 过程,就是那根穿针引线的"金线"。

很多初学者认为 Shuffle 仅仅是数据的传输,但实际上,它是 MapReduce 中最复杂、最消耗资源,也是最核心的阶段。据统计,Shuffle 过程可能占据整个作业30%-50%的执行时间。今天,我们就来一场"心脏手术",深度解剖 Shuffle 的四大核心组件:Partition、Sort、Combiner 和 GroupingComparator。

Hadoop 相关知识与文章参考:

1. Partition 分区

在 Map 端输出了大量的<key, value>对之后,这些数据需要被发送给 Reducer。但是,发给哪一个Reducer 呢?这就是 Partition(分区)要解决的问题。

1.1. 为什么要分区

假设我们有3个 Reducer,如果不进行分区,数据就会随机乱飞。为了保证同一个 Key(比如单词"apple")的所有数据都汇聚到同一个 Reducer 手中进行汇总,我们必须制定规则。

1.2. 默认分区器

Hadoop 默认使用HashPartitioner。它的逻辑非常简单粗暴:利用 Key 的哈希值对 Reducer 的数量取模。

源码逻辑如下:

复制代码

Java

代码解读

复制代码

public class HashPartitioner<K, V> extends Partitioner<K, V> { public int getPartition(K key, V value, int numReduceTasks) { return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks; } }

参数说明:

  • key.hashCode():获取键的哈希值。
  • Integer.MAX_VALUE:Integer 的最大值,key.hashCode() & Integer.MAX_VALUE确保哈希值为非负数。
  • numReduceTasks:Reduce任务的个数,取余操作,将数据均匀分散到 Reducer 中。

1.3. 自定义分区器

默认分区虽然简单,但有时无法满足业务需求。例如,在处理手机号数据时,我们希望将北京(139开头)的数据分发给 Reducer 1,上海(138开头)的数据分发给 Reducer 2。这时就需要自定义分区。

1.3.1. 自定义分区器步骤
  1. 继承 Partitioner,然后,重写 getPartition() 方法,返回分区。
  2. 在 Job 驱动类中,设置自定义的分区器。
  3. 自定义分区器后,需要根据自定义分区器的逻辑,设置相应数量的 ReduceTask。
1.3.2. 自定义分区示例

需求:将文件中的手机号按前三位数,分区输出到不同文件中。

注意:当前示例直接在之前"手机上下行流量"示例基础上实现,重复的代码省略,具体可参考:

  • 添加分区类DefPartitioner
  • 修改 Driver 类,添加分区器的定义。

核心代码修改如下:

  1. 创建分区类 DefPartitioner。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.partition; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Partitioner; /** * @Description TODO 自定义分区器,该方法返回指定分区 */ public class DefPartitioner extends Partitioner<Text, FlowBean> { /** * 计算,并返回分区 * * @param text * @param flowBean * @param i * @return */ @Override public int getPartition(Text text, FlowBean flowBean, int i) { int res = 0; switch (text.toString().substring(0, 3)) { case "136": res = 0; break; case "137": res = 1; break; case "138": res = 2; break; case "139": res = 3; break; default: res = 4; break; } return res; } }

  1. Driver 驱动类中,添加分区类与 ReduceTask 的个数配置。
复制代码

Java

代码解读

复制代码

//分区类 job.setPartitionerClass(DefPartitioner.class); //设置ReduceTask个数 job.setNumReduceTasks(5);

  1. 执行后,在output/目录下,会创建出5个文件分别对应5个分区,查看文件内的内容是否正确。

2. WritableComparable排序

Shuffle 的核心承诺之一是:发给 Reducer 的数据,Key 必须是有序的。为了实现这一点,Hadoop 需要一种机制来比较任意两个 Key 的大小。

2.1. 概述

在 Java 中,对象比较通常依赖Comparable接口。但在 Hadoop 的分布式环境中,数据需要在网络上传输(序列化),并在不同节点间比较(反序列化后比较)。因此,Hadoop 定义了自己的接口WritableComparable

它结合了两种能力:

  • Writable:支持序列化(写出)和反序列化(读取),以便网络传输和磁盘存储。
  • Comparable:支持比较大小(compareTo方法),以便排序。

2.2. WritableComparable排序示例

需求:根据手机流量示例,对总流量进行降序排序。

注意:当前示例直接在之前"手机上下行流量"示例基础上实现,具体可参考:4Hadoop序列化实战。这次整体的逻辑大致一致,但是我们不复用代码了,这里我们重新实现。

  1. 自定义 Bean 对象 ComFlowBean 实现WritableComparable接口。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.writablecomparable1; import org.apache.hadoop.io.Writable; import org.apache.hadoop.io.WritableComparable; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; /** * @Description TODO 实体类,封装上下行流量和总流量 */ public class ComFlowBean implements WritableComparable<ComFlowBean> { private long upFlow; private long downFlow; private long sumFlow; public ComFlowBean() { } // getter and setter... ... // toString... /** * 序列化方法 * * @param dataOutput * @throws IOException */ @Override public void write(DataOutput dataOutput) throws IOException { dataOutput.writeLong(upFlow); dataOutput.writeLong(downFlow); dataOutput.writeLong(sumFlow); } /** * 反序列化方法 * 注意:读取顺序与序列化顺序一致 * * @param dataInput * @throws IOException */ @Override public void readFields(DataInput dataInput) throws IOException { this.upFlow = dataInput.readLong(); this.downFlow = dataInput.readLong(); this.sumFlow = dataInput.readLong(); } /** * WritableComparable 接口需要实现的比较方法 * 方法需要给出比较的逻辑 * * @param o 与当前实例比较的对象 * @return */ @Override public int compareTo(ComFlowBean o) { return Long.compare(o.sumFlow, this.sumFlow); } }

  1. 编写 Mapper 类 ComFlowMapper。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.writablecomparable1; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; /** * @Description TODO */ public class ComFlowMapper extends Mapper<LongWritable, Text, ComFlowBean, Text> { private ComFlowBean flowBean = new ComFlowBean(); private Text phone = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { //1.将输入数据,按\t切分 String[] infos = value.toString().split("\t"); //2.获取数据,并封装 flowBean.setUpFlow(Long.parseLong(infos[infos.length - 3])); flowBean.setDownFlow(Long.parseLong(infos[infos.length - 2])); flowBean.setSumFlow(flowBean.getUpFlow() + flowBean.getDownFlow()); phone.set(infos[1]); //3.输出 context.write(this.flowBean, this.phone); } }

  1. 编写 Reducer 类 ComReducer。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.writablecomparable1; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; /** * @Description TODO */ public class ComFlowReducer extends Reducer<ComFlowBean, Text, Text, ComFlowBean> { @Override protected void reduce(ComFlowBean key, Iterable<Text> values, Context context) throws IOException, InterruptedException { // 这里直接输出即可 for (Text phone : values) { context.write(phone, key); } } }

  1. 编写 Driver 类 ComDriver。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.writablecomparable1; 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; import java.io.IOException; /** * @Description TODO */ public class ComFlowDriver { public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException { //1.生成一个job实例 Job job = Job.getInstance(new Configuration(), "flow bean"); //设置类路径 job.setJarByClass(ComFlowDriver.class); //2.设置job的mapper和reducer job.setMapperClass(ComFlowMapper.class); job.setReducerClass(ComFlowReducer.class); //3.设置输入输出数据类型 job.setMapOutputKeyClass(ComFlowBean.class); job.setMapOutputValueClass(Text.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(ComFlowBean.class); //4.设置程序的输入输出 FileInputFormat.setInputPaths(job, new Path("D:/input/*")); FileOutputFormat.setOutputPath(job, new Path("D:/output")); //5.提交任务 boolean b = job.waitForCompletion(true); System.exit(b ? 0: 1); } }

  1. 执行后,在output/目录下,会输出一个文件,查看该文件内容是否正确排序。

2.3. WritableComparable分区内排序示例

需求:要求按照手机号前三位分区输出,每个分区内按照总流量排序。

注意:当前示例直接在"手机上下行流量排序"示例基础上实现,具体可参考:2.2WritableComparable排序示例。主要修改如下:

  • 添加分区类ComPartitioner
  • 修改 Driver 类,添加分区器的定义。

核心代码修改如下:

  1. 添加自定义 Partitioner 分区类ComPartitioner
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce; import com.example.hadoop.mapreduce.writablecomparable1.ComFlowBean; import org.apache.hadoop.mapreduce.Partitioner; import javax.xml.soap.Text; /** * @Description TODO */ public class ComPartitioner extends Partitioner<ComFlowBean, Text> { @Override public int getPartition(ComFlowBean comFlowBean, Text text, int i) { String prePhoneNum = text.toString().substring(0, 3); int partition = 4; switch (prePhoneNum) { case "136": partition = 0; break; case "137": partition = 1; break; case "138": partition = 2; break; case "139": partition = 3; break; } return partition; } }

  1. 在 Driver 驱动类中,添加自定义分区类和 ReducerTask 个数。
复制代码

Java

代码解读

复制代码

//设置分区器 job.setPartitionerClass(ComPartitioner.class); //设置reduceTask数量 job.setNumReduceTasks(5);

  1. 执行后,在output/目录下,会输出5个文件,对应5个分区,查看各个文件内容,是否正确,以及是否排序。

3. Combiner合并

在 Shuffle 过程中,网络带宽是最宝贵的资源。如果 Map 端输出了1亿条数据,全部通过网络传给 Reducer,网络很容易拥堵。Combiner 就是为了解决这个问题而生的。

3.1. 概述

Combiner 本质上是一个运行在 Map 端的、局部的 Reducer。它在数据溢写(Spill)到磁盘之前,或者在合并(Merge)溢写文件时运行。

3.2. 作用

对同一个 Map 任务输出的相同Key的数据进行"预聚合"。

3.3. 场景举例

以 WordCount 为例:

  • 没有 Combiner:Map 输出<hello, 1>, <hello, 1>, <hello, 1>,网络传输3条记录。
  • 使用Combiner:Map 输出后,Combiner 先计算一次<hello, 3>。网络传输仅1条记录。

3.4. 自定义Combiner

Combiner 本质就是一个 Reducer,所以逻辑直接复用 Reducer 代码即可,如下:

复制代码

Java

代码解读

复制代码

public class WordcountCombiner extends Reducer<Text, IntWritable, Text, IntWritable>{ @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { // 逻辑代码 context.write(key, v); } }

Driver 驱动类中设置 Combiner。

复制代码

Java

代码解读

复制代码

job.setCombinerClass(MyCombiner.class);

避坑指南:如果业务逻辑是求"平均值",直接复用 Reducer 逻辑作为 Combiner 的逻辑的话,会出错。

4. GroupingComparator分组比较器

数据到了 Reducer 端,数据已经按键排好序了,但是,Reducer 的reduce()方法签名是:

复制代码

Java

代码解读

复制代码

reduce(KEY key, Iterable<VALUE> values, Context context)

这意味着,所有相同 Key 的数据,会被封装成一个 Iterable 传过来。那么,框架是如何判断哪些 Key 是相同的呢?

4.1. 分组排序步骤

  1. 继承 WritableComparator。
  2. 重写 compare() 方法。
  3. 创建一个构造方法,将比较对象的类传递给父类。

4.2. GroupingComparator分组比较器示例

需求:输出每个订单成交额最大的订单,输出格式:<订单ID 成交额>。

输入数据:

复制代码

代码解读

复制代码

订单ID 商品ID 成交额 0000001 Pdt_01 222.8 0000002 Pdt_05 722.4 0000001 Pdt_02 33.8 0000003 Pdt_06 232.8 0000003 Pdt_02 33.8 0000002 Pdt_03 522.8 0000002 Pdt_04 122.4

输出数据:

复制代码

代码解读

复制代码

订单ID 成交额 0000001 222.8 0000002 722.4 0000003 232.8

  1. 自定义订单实现类 OrderBean,需要实现 WritableComparable。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.groupingcomparator; import org.apache.hadoop.io.WritableComparable; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; /** * @Description TODO */ public class OrderBean implements WritableComparable<OrderBean> { private String orderId; private String productId; private double price; public OrderBean() { } public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId = orderId; } public String getProductId() { return productId; } public void setProductId(String productId) { this.productId = productId; } public double getPrice() { return price; } public void setPrice(double price) { this.price = price; } @Override public String toString() { return orderId + '\t' + productId + '\t' + price; } @Override public int compareTo(OrderBean o) { int i = this.orderId.compareTo(o.orderId); if (i == 0) { return Double.compare(o.price, this.price); } return i; } @Override public void write(DataOutput dataOutput) throws IOException { dataOutput.writeUTF(orderId); dataOutput.writeUTF(productId); dataOutput.writeDouble(price); } @Override public void readFields(DataInput dataInput) throws IOException { this.orderId = dataInput.readUTF(); this.productId = dataInput.readUTF(); this.price = dataInput.readDouble(); } }

  1. 编写 Mapper 类 OrderMapper。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.groupingcomparator; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.NullWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; /** * @Description TODO */ public class OrderMapper extends Mapper<LongWritable, Text, OrderBean, NullWritable> { private OrderBean orderBean = new OrderBean(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { //1.切分数据 String[] infos = value.toString().split("\t"); //2.封装数据 orderBean.setOrderId(infos[0]); orderBean.setProductId(infos[1]); orderBean.setPrice(Double.parseDouble(infos[2])); //3.输出 context.write(orderBean, NullWritable.get()); } }

  1. 编写自定义分组比较器 OrderComparator。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.groupingcomparator; import org.apache.hadoop.io.WritableComparable; import org.apache.hadoop.io.WritableComparator; /** * @Description TODO * * Map 输出通过 WritableComparator 进行分组,key 相同为一组,然后输出到 Reduce 中 * * WritableComparator 默认比较器,key相同认为是同一组 * 但是当前示例中,OrderBean 作为 key,里面重写了比较方法,所有只有 orderId 和 price 相同才会认为是同一组 * * 上述分组流程,并不可以满足当前需求,所以需要重写分组方式 * * 重新为:订单相同就认为是同一组 */ public class OrderComparator extends WritableComparator { public OrderComparator() { super(OrderBean.class, true); } @Override public int compare(WritableComparable a, WritableComparable b) { OrderBean oa = (OrderBean) a; OrderBean ob = (OrderBean) b; return oa.getOrderId().compareTo(ob.getOrderId()); } }

  1. 编写 Reducer 类 OrderReducer。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.groupingcomparator; import org.apache.hadoop.io.NullWritable; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.Iterator; /** * @Description TODO */ public class OrderReducer extends Reducer<OrderBean, NullWritable, OrderBean, NullWritable> { @Override protected void reduce(OrderBean key, Iterable<NullWritable> values, Context context) throws IOException, InterruptedException { //1.获取迭代器 Iterator<NullWritable> iterator = values.iterator(); //2.获取数据 //注:这里相同订单号的数据作为输入,并且同一订单号数据进行了逆序排序 //按照需求,取第一条数据即可,这里手动 next() if (iterator.hasNext()) { iterator.next(); context.write(key, NullWritable.get()); } } }

  1. 编写 Driver 类 OrderDriver。
复制代码

Java

代码解读

复制代码

package com.example.hadoop.mapreduce.groupingcomparator; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.NullWritable; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; import java.io.IOException; /** * @Description TODO */ public class OrderDriver { public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException { Job job = Job.getInstance(new Configuration()); job.setJarByClass(OrderDriver.class); job.setMapperClass(OrderMapper.class); job.setReducerClass(OrderReducer.class); // 设置分组比较器 job.setGroupingComparatorClass(OrderComparator.class); job.setMapOutputKeyClass(OrderBean.class); job.setMapOutputValueClass(NullWritable.class); job.setOutputKeyClass(OrderBean.class); job.setOutputValueClass(NullWritable.class); FileInputFormat.setInputPaths(job, new Path("D:/input/*")); FileOutputFormat.setOutputPath(job, new Path("D:/output")); boolean b = job.waitForCompletion(true); System.out.println(b ? 0 : 1); } }

  1. 执行后,查看output/目录下,生成文件内容是否正确。

5. Shuffle机制

6. 总结

Shuffle 并非黑盒,而是一个精密的流水线:

  1. Partition 决定了数据去往哪个"车间"(Reducer)。
  2. WritableComparable 确保了数据在车间内部是按顺序排列的。
  3. Combiner 在出发前剔除了冗余数据,减轻了运输压力。
  4. GroupingComparator 在终点站决定了哪些数据被打包成一个包裹交给工人(reduce函数)处理。

理解了这四个组件,你就掌握了 MapReduce 性能调优的钥匙。在下一篇中,我们将继续探讨 MapReduce 的 OutputFormat 相关内容。敬请期待!